3360 lines
163 KiB
HTML
3360 lines
163 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>통합 운영 관리 시스템</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap');
|
||
|
||
body {
|
||
font-family: 'Noto Sans KR', sans-serif;
|
||
background-color: #f3f4f6;
|
||
margin: 0;
|
||
padding: 0;
|
||
-webkit-print-color-adjust: exact;
|
||
}
|
||
|
||
/* 화면 표시용 페이지 스타일 */
|
||
.page {
|
||
width: 210mm;
|
||
height: 297mm;
|
||
padding: 15mm;
|
||
margin: 0 auto 10mm auto;
|
||
background: white;
|
||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||
position: relative;
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 수질일지 2페이지 화면 전용 좌우 배치 */
|
||
@media screen and (min-width: 1500px) {
|
||
#water-tab.two-up.active {
|
||
max-width: 430mm;
|
||
margin: 0 auto;
|
||
display: grid !important;
|
||
grid-template-columns: 210mm 210mm;
|
||
gap: 10mm;
|
||
align-items: start;
|
||
}
|
||
#water-tab.two-up:not(.active) {
|
||
display: none !important;
|
||
}
|
||
#water-tab.two-up .page {
|
||
margin: 0;
|
||
}
|
||
}
|
||
|
||
/* 인쇄 전용 스타일 */
|
||
@media print {
|
||
@page {
|
||
size: A4;
|
||
margin: 0;
|
||
}
|
||
body { background: none; margin: 0; padding: 0; }
|
||
.no-print { display: none !important; }
|
||
.tab-content { display: none !important; }
|
||
.tab-content.active { display: block !important; }
|
||
.page {
|
||
margin: 0;
|
||
box-shadow: none;
|
||
page-break-after: always;
|
||
border: none;
|
||
width: 210mm;
|
||
height: 297mm;
|
||
padding: 15mm;
|
||
}
|
||
.editable-cell:focus { outline: none !important; }
|
||
.line-input, .cell-input {
|
||
color: #000 !important;
|
||
-webkit-text-fill-color: #000 !important;
|
||
}
|
||
.toggle-cell.selecting-start { outline: none !important; }
|
||
}
|
||
|
||
/* 공통 표 스타일 */
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 8pt;
|
||
margin-bottom: 6px;
|
||
table-layout: fixed;
|
||
}
|
||
th, td {
|
||
border: 1px solid black;
|
||
padding: 2px 4px;
|
||
text-align: center;
|
||
height: 22px;
|
||
line-height: 1.2;
|
||
word-break: keep-all;
|
||
}
|
||
th { background-color: #f8f8f8; font-weight: bold; }
|
||
|
||
.title { font-size: 16pt; font-weight: bold; text-align: center; margin-bottom: 5px; }
|
||
.approval-table { width: 210px; float: right; margin-bottom: 8px; }
|
||
.approval-table td { width: 50px; height: 50px; }
|
||
.approval-table td.stamp-cell {
|
||
position: relative;
|
||
cursor: pointer;
|
||
overflow: hidden;
|
||
background: #fff;
|
||
}
|
||
.approval-table td.stamp-cell img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
display: block;
|
||
}
|
||
.sign-stamp-target {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 34px;
|
||
height: 20px;
|
||
border: 1px solid transparent;
|
||
vertical-align: middle;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
overflow: hidden;
|
||
background: #fff;
|
||
}
|
||
.sign-stamp-target .sign-stamp-label {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
transform: translate(-50%, -50%);
|
||
font-size: 8pt;
|
||
color: #111;
|
||
z-index: 1;
|
||
line-height: 1;
|
||
white-space: nowrap;
|
||
}
|
||
.sign-stamp-target.has-image {
|
||
border-color: #64748b;
|
||
}
|
||
.sign-stamp-target img {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
display: block;
|
||
z-index: 2;
|
||
}
|
||
.input-line { border-bottom: 1px solid #000; display: inline-block; text-align: center; }
|
||
.line-input {
|
||
border: none;
|
||
border-bottom: 1px solid #000;
|
||
background: transparent;
|
||
text-align: center;
|
||
font: inherit;
|
||
padding: 0 2px;
|
||
height: 1.2em;
|
||
line-height: 1.2;
|
||
vertical-align: baseline;
|
||
}
|
||
.line-input:focus { outline: none; }
|
||
.cell-input {
|
||
width: 100%;
|
||
border: none;
|
||
background: transparent;
|
||
font: inherit;
|
||
text-align: center;
|
||
padding: 0;
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.cell-input:focus { outline: none; }
|
||
textarea.cell-input {
|
||
resize: none;
|
||
text-align: left;
|
||
line-height: 1.2;
|
||
height: 100%;
|
||
min-height: 18px;
|
||
overflow: auto;
|
||
padding-bottom: 0;
|
||
}
|
||
.unit-inline {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
width: 100%;
|
||
justify-content: flex-end;
|
||
}
|
||
.unit-inline .cell-input {
|
||
width: 60%;
|
||
text-align: right;
|
||
border-bottom: 1px solid #000;
|
||
}
|
||
.toggle-cell {
|
||
cursor: pointer;
|
||
background: #fff;
|
||
position: relative;
|
||
}
|
||
.toggle-cell.on::after {
|
||
content: "";
|
||
position: absolute;
|
||
left: -1px;
|
||
right: -1px;
|
||
top: 50%;
|
||
border-top: 2px solid #111;
|
||
transform: translateY(-50%);
|
||
pointer-events: none;
|
||
z-index: 1;
|
||
}
|
||
.toggle-cell.range-start::before {
|
||
content: "";
|
||
position: absolute;
|
||
left: 1px;
|
||
top: 50%;
|
||
width: 0;
|
||
height: 0;
|
||
transform: translateY(-50%);
|
||
border-top: 4px solid transparent;
|
||
border-bottom: 4px solid transparent;
|
||
border-right: 7px solid #111;
|
||
pointer-events: none;
|
||
z-index: 2;
|
||
}
|
||
.toggle-cell.range-end::before {
|
||
content: "";
|
||
position: absolute;
|
||
right: 1px;
|
||
top: 50%;
|
||
width: 0;
|
||
height: 0;
|
||
transform: translateY(-50%);
|
||
border-top: 4px solid transparent;
|
||
border-bottom: 4px solid transparent;
|
||
border-left: 7px solid #111;
|
||
pointer-events: none;
|
||
z-index: 2;
|
||
}
|
||
.toggle-cell.selecting-start {
|
||
outline: 1px solid #1d4ed8;
|
||
outline-offset: -2px;
|
||
}
|
||
.section-title { font-weight: bold; text-align: left; margin-top: 8px; margin-bottom: 3px; font-size: 9pt; }
|
||
.small-note { font-size: 7.5pt; line-height: 1.3; }
|
||
|
||
/* 수질용 대각선 헤더 */
|
||
.diag {
|
||
position: relative;
|
||
background: #fff;
|
||
overflow: hidden;
|
||
}
|
||
.diag::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
background: linear-gradient(to top right, transparent 49.4%, #000 49.7%, #000 50.3%, transparent 50.6%);
|
||
pointer-events: none;
|
||
}
|
||
.diag .diag-label { position: absolute; font-size: 7.5pt; font-weight: normal; }
|
||
.diag .diag-label.top-right { top: 2px; right: 4px; }
|
||
.diag .diag-label.bottom-left { bottom: 2px; left: 4px; }
|
||
|
||
/* 탭 스타일 */
|
||
.tab-btn {
|
||
padding: 12px 24px;
|
||
cursor: pointer;
|
||
background: #e5e7eb;
|
||
border: none;
|
||
font-weight: bold;
|
||
transition: all 0.2s;
|
||
border-radius: 8px 8px 0 0;
|
||
margin-right: 4px;
|
||
}
|
||
.tab-btn.active {
|
||
background: white;
|
||
color: #2563eb;
|
||
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
|
||
}
|
||
.tab-content { display: none; }
|
||
.tab-content.active {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
/* 대기용 기상 체크박스 */
|
||
.checkbox-container { display: inline-flex; align-items: center; gap: 4px; margin-right: 6px; white-space: nowrap; cursor: pointer; user-select: none; }
|
||
.checkbox-box { width: 10px; height: 10px; border: 1px solid black; display: inline-block; cursor: pointer; }
|
||
.checkbox-box.checked { background: #000; }
|
||
.editable-cell {
|
||
cursor: text;
|
||
text-align: left;
|
||
vertical-align: top;
|
||
font-size: 8pt;
|
||
line-height: 1.2;
|
||
}
|
||
.editable-cell:focus {
|
||
outline: 1px dashed #666;
|
||
outline-offset: -2px;
|
||
}
|
||
|
||
/* 대기 하단 정보 박스 공통 스타일 */
|
||
.info-box-row {
|
||
display: flex;
|
||
border-left: 1px solid black;
|
||
border-right: 1px solid black;
|
||
border-bottom: 1px solid black;
|
||
min-height: 46px;
|
||
font-size: 8.5pt;
|
||
}
|
||
.info-box-label {
|
||
width: 30%;
|
||
border-right: 1px solid black;
|
||
padding: 6px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: flex-start;
|
||
background-color: #ffffff;
|
||
}
|
||
.info-box-content {
|
||
width: 70%;
|
||
padding: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.info-box-content.editable-cell {
|
||
min-height: 44px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
/* Signature area spacing */
|
||
.signature-group {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 60px;
|
||
font-size: 9.5pt;
|
||
margin-top: 10px;
|
||
}
|
||
.doc-calendar {
|
||
border: 1px solid #000;
|
||
padding: 8px;
|
||
margin-bottom: 8px;
|
||
background: #fff;
|
||
}
|
||
.doc-cal-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.doc-cal-title {
|
||
font-size: 9pt;
|
||
font-weight: 700;
|
||
}
|
||
.doc-cal-nav {
|
||
border: 1px solid #94a3b8;
|
||
background: #fff;
|
||
border-radius: 4px;
|
||
padding: 2px 8px;
|
||
font-size: 8pt;
|
||
cursor: pointer;
|
||
}
|
||
.doc-cal-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||
gap: 2px;
|
||
}
|
||
.doc-cal-cell {
|
||
border: 1px solid #cbd5e1;
|
||
min-height: 28px;
|
||
padding: 2px 4px;
|
||
font-size: 8pt;
|
||
text-align: right;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
position: relative;
|
||
background: #fff;
|
||
}
|
||
.doc-cal-cell.is-empty {
|
||
background: #f8fafc;
|
||
border-color: #e2e8f0;
|
||
cursor: default;
|
||
}
|
||
.doc-cal-cell.has-doc::after {
|
||
content: "";
|
||
position: absolute;
|
||
left: 4px;
|
||
bottom: 4px;
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 9999px;
|
||
background: #2563eb;
|
||
}
|
||
.doc-cal-cell.selected {
|
||
border-color: #1d4ed8;
|
||
box-shadow: inset 0 0 0 1px #1d4ed8;
|
||
background: #eff6ff;
|
||
}
|
||
.doc-cal-week {
|
||
font-size: 7.5pt;
|
||
text-align: center;
|
||
font-weight: 700;
|
||
color: #334155;
|
||
padding: 2px 0;
|
||
}
|
||
.toggle-grid {
|
||
table-layout: fixed;
|
||
font-size: 7.2pt;
|
||
border-collapse: separate !important;
|
||
border-spacing: 0;
|
||
border: 1px solid #000;
|
||
}
|
||
.toggle-grid th,
|
||
.toggle-grid td {
|
||
height: 20px;
|
||
padding: 1px 2px;
|
||
line-height: 1.12;
|
||
border: 0 !important;
|
||
border-right: 1px solid #000 !important;
|
||
border-bottom: 1px solid #000 !important;
|
||
box-sizing: border-box;
|
||
}
|
||
.toggle-grid tr > th:last-child,
|
||
.toggle-grid tr > td:last-child {
|
||
border-right: 0 !important;
|
||
}
|
||
.toggle-grid tr:last-child > th,
|
||
.toggle-grid tr:last-child > td {
|
||
border-bottom: 0 !important;
|
||
}
|
||
.toggle-grid th:first-child,
|
||
.toggle-grid td:first-child {
|
||
width: 190px;
|
||
}
|
||
.toggle-grid th:not(:first-child),
|
||
.toggle-grid td:not(:first-child) {
|
||
width: 21px;
|
||
}
|
||
.toggle-grid td:first-child {
|
||
text-align: left;
|
||
white-space: normal;
|
||
padding-left: 4px;
|
||
word-break: keep-all;
|
||
}
|
||
#data-calc-tab table {
|
||
font-size: 7.1pt;
|
||
table-layout: fixed;
|
||
}
|
||
#data-calc-tab th,
|
||
#data-calc-tab td {
|
||
padding: 1px 2px;
|
||
height: 20px;
|
||
line-height: 1.15;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- 화면 상단 제어 바 -->
|
||
<div class="no-print sticky top-0 z-50 border-b border-slate-300 bg-white shadow-sm">
|
||
<div class="h-12 px-4 flex items-center justify-between">
|
||
<div class="flex items-center gap-3">
|
||
<div class="bg-slate-900 text-white w-7 h-7 rounded-lg flex items-center justify-center text-[10px] font-black">환</div>
|
||
<h1 class="text-[16px] font-extrabold italic tracking-tight text-slate-700">환경시설 INTEGRATED JOURNAL V1.0</h1>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2 justify-end">
|
||
<div class="flex items-center gap-2 bg-white border border-slate-300 px-3 py-1.5 rounded-lg shadow-sm">
|
||
<span class="text-[11px] font-bold text-slate-600">작성일</span>
|
||
<input id="work-date-picker" type="date" onchange="onWorkDateChange(this.value)" class="text-[12px] font-semibold text-slate-700 outline-none">
|
||
</div>
|
||
<button onclick="saveDocument()" class="bg-white border border-slate-300 hover:border-cyan-500 px-3 py-1.5 rounded-lg text-[12px] font-bold text-cyan-700 shadow-sm">저장/수정</button>
|
||
<button onclick="deleteSelectedDateData()" class="bg-white border border-slate-300 hover:border-rose-600 px-3 py-1.5 rounded-lg text-[12px] font-bold text-rose-700 shadow-sm">선택날짜 데이터 삭제</button>
|
||
<button onclick="exportData()" class="bg-white border border-slate-300 hover:border-emerald-500 px-3 py-1.5 rounded-lg text-[12px] font-bold text-emerald-700 shadow-sm">데이터 내보내기</button>
|
||
<button onclick="document.getElementById('import-data-file').click()" class="bg-white border border-slate-300 hover:border-amber-500 px-3 py-1.5 rounded-lg text-[12px] font-bold text-amber-700 shadow-sm">데이터 불러오기</button>
|
||
</div>
|
||
</div>
|
||
<div class="bg-slate-200/70 border-t border-slate-200 px-4 py-3">
|
||
<div class="grid grid-cols-1 md:grid-cols-5 gap-2">
|
||
<button class="tab-btn active !m-0 !rounded-xl border border-slate-300 bg-white px-3 py-2 text-left shadow-sm" onclick="openTab(event, 'water-tab')">
|
||
<div class="text-[10px] font-bold text-slate-500">메인 양식</div>
|
||
<div class="text-base font-black text-blue-700">폐수배출시설</div>
|
||
</button>
|
||
<button class="tab-btn !m-0 !rounded-xl border border-slate-300 bg-white px-3 py-2 text-left shadow-sm" onclick="openTab(event, 'air-tab')">
|
||
<div class="text-[10px] font-bold text-slate-500">메인 양식</div>
|
||
<div class="text-base font-black text-blue-700">대기배출시설</div>
|
||
</button>
|
||
<button class="tab-btn !m-0 !rounded-xl border border-slate-300 bg-white px-3 py-2 text-left shadow-sm" onclick="openTab(event, 'data-calc-tab')">
|
||
<div class="text-[10px] font-bold text-slate-500">집계 화면</div>
|
||
<div class="text-base font-black text-blue-700">데이터</div>
|
||
</button>
|
||
<button class="tab-btn !m-0 !rounded-xl border border-slate-300 bg-white px-3 py-2 text-left shadow-sm" onclick="openTab(event, 'data-pdf-tab')">
|
||
<div class="text-[10px] font-bold text-slate-500">문서 보관</div>
|
||
<div class="text-base font-black text-blue-700">데이터(pdf)</div>
|
||
</button>
|
||
<button onclick="printCurrentTab()" class="bg-white border border-slate-300 hover:border-blue-500 px-3 py-2 rounded-xl text-left shadow-sm">
|
||
<div class="text-[10px] font-bold text-slate-500">즉시 출력</div>
|
||
<div class="text-base font-black text-blue-700">현재 탭 인쇄</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<input id="import-data-file" type="file" accept="application/json" class="hidden" onchange="importData(event)">
|
||
</div>
|
||
<input id="stamp-file-input" type="file" accept="image/*" class="hidden">
|
||
|
||
<!-- 1. 수질(폐수) 운영일지 탭 -->
|
||
<div id="water-tab" class="tab-content active two-up">
|
||
<!-- 앞 쪽 -->
|
||
<div class="page">
|
||
<div class="text-left text-[8pt]">[별지 제18호서식] ( 앞 쪽)</div>
|
||
<div class="flex justify-between items-start mt-1">
|
||
<div class="flex-1 pt-4">
|
||
<div class="title text-left whitespace-nowrap">폐수배출시설 및 수질오염방지시설 운영일지</div>
|
||
</div>
|
||
<table class="approval-table">
|
||
<tr><th rowspan="2" class="w-8">결<br>재</th><th>담 당</th><th>팀 장</th><th>공장장</th></tr>
|
||
<tr><td></td><td></td><td></td></tr>
|
||
</table>
|
||
</div>
|
||
<div class="clear-both text-right mb-3 text-[8.5pt]">
|
||
202<span class="input-line w-6"></span>년 <span class="input-line w-6"></span>월 <span class="input-line w-6"></span>일 <span class="input-line w-10"></span>요일 날씨: <span class="input-line w-12"></span> 온도 : 저: <span class="input-line w-8"></span>℃ 고: <span class="input-line w-8"></span>℃
|
||
</div>
|
||
<div class="section-title">1. 폐수배출시설 가동(조업)시간대</div>
|
||
<table class="toggle-grid">
|
||
<tr><th class="diag"><span class="diag-label top-right">시간대</span><span class="diag-label bottom-left">구분</span></th><th>1</th><th>2</th><th>3</th><th>4</th><th>5</th><th>6</th><th>7</th><th>8</th><th>9</th><th>10</th><th>11</th><th>12</th><th>13</th><th>14</th><th>15</th><th>16</th><th>17</th><th>18</th><th>19</th><th>20</th><th>21</th><th>22</th><th>23</th><th>24</th></tr>
|
||
<tr><td class="text-left text-[7pt]">시멘트·석회·프라스터·및그제품제조시설</td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
|
||
<tr><td class="text-left text-[7pt]">운수장비수선 및 세차 또는 세척시설</td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
|
||
</table>
|
||
<div class="section-title">2. 수질오염방지시설 가동시간대 (처리방법 : 물리적 처리 후 전량재이용)</div>
|
||
<table class="toggle-grid">
|
||
<tr><th class="diag"><span class="diag-label top-right">시간대</span><span class="diag-label bottom-left">구분</span></th><th>1</th><th>2</th><th>3</th><th>4</th><th>5</th><th>6</th><th>7</th><th>8</th><th>9</th><th>10</th><th>11</th><th>12</th><th>13</th><th>14</th><th>15</th><th>16</th><th>17</th><th>18</th><th>19</th><th>20</th><th>21</th><th>22</th><th>23</th><th>24</th></tr>
|
||
<tr><td class="text-left text-[7pt]">물리적 처리 후 전량재이용</td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
|
||
<tr><td class="text-left text-[7pt]"></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
|
||
</table>
|
||
<div class="text-right text-[8.5pt] mb-2">
|
||
시간대별 근무자 직ㆍ성명 <span class="input-line w-12"></span> : <span class="input-line w-12"></span> ~ <span class="input-line w-12"></span> : <span class="input-line w-12"></span> B/P 운전원 직 급 : 기 사 성 명 : 곽 병 목 <span class="sign-stamp-target">(인)</span>
|
||
</div>
|
||
<div class="section-title">3. 용수 공급원별 사용량과 폐수배출량</div>
|
||
<table>
|
||
<colgroup><col style="width:7%"><col style="width:5%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:8%"><col style="width:13%"><col style="width:10%"><col style="width:10%"><col style="width:17%"></colgroup>
|
||
<tr><th colspan="2" class="diag h-10"><span class="diag-label top-right">항목</span><span class="diag-label bottom-left">구분</span></th><th>전일 지침<br>(㎥)</th><th>금일 지침<br>(㎥)</th><th>사용량<br>(㎥/일)</th><th>검침<br>시간대</th><th class="diag h-10"><span class="diag-label top-right">항목</span><span class="diag-label bottom-left">구분</span></th><th>전일 지침<br>(㎥)</th><th>금일 지침<br>(㎥)</th><th>배출량 및<br>사용량(㎥/일)</th></tr>
|
||
<tr><td colspan="2">계</td><td></td><td></td><td></td><td></td><td>폐수발생량</td><td></td><td></td><td></td></tr>
|
||
<tr><td rowspan="2">상수도</td><td>1호</td><td></td><td></td><td></td><td></td><td rowspan="2">폐수배출량</td><td rowspan="2"></td><td rowspan="2"></td><td rowspan="2"></td></tr>
|
||
<tr><td>2호</td><td></td><td></td><td></td><td></td></tr>
|
||
<tr><td rowspan="2">공업용수</td><td>1호</td><td></td><td></td><td></td><td></td><td rowspan="2">냉각수량</td><td rowspan="2"></td><td rowspan="2"></td><td rowspan="2"></td></tr>
|
||
<tr><td>2호</td><td></td><td></td><td></td><td></td></tr>
|
||
<tr><td rowspan="2">지하수</td><td>1호</td><td></td><td></td><td></td><td></td><td rowspan="2">소모 (증발량)<br>제품함유수량</td><td rowspan="2"></td><td rowspan="2"></td><td rowspan="2"></td></tr>
|
||
<tr><td>2호</td><td></td><td></td><td></td><td></td></tr>
|
||
<tr><td rowspan="2">하천수</td><td>1호</td><td></td><td></td><td></td><td></td><td rowspan="2">재사용량</td><td rowspan="2"></td><td rowspan="2"></td><td rowspan="2"></td></tr>
|
||
<tr><td>2호</td><td></td><td></td><td></td><td></td></tr>
|
||
<tr><td rowspan="2">해수등<br>기타</td><td>1호</td><td></td><td></td><td></td><td></td><td rowspan="2">생활용수량</td><td rowspan="2"></td><td rowspan="2"></td><td rowspan="2"></td></tr>
|
||
<tr><td>2호</td><td></td><td></td><td></td><td></td></tr>
|
||
</table>
|
||
<div class="section-title">4. 슬러지의 발생량 및 처리량</div>
|
||
<table>
|
||
<tr><th>슬러지발생량 (㎥)</th><th>처리량(㎥)</th><th>보관량(㎥)</th><th>함수율(%)</th><th>보관장소</th></tr>
|
||
<tr><td class="h-10"></td><td></td><td></td><td></td><td>옥 내</td></tr>
|
||
<tr><td class="h-10"></td><td></td><td></td><td></td><td></td></tr>
|
||
</table>
|
||
<div class="small-note leading-7 py-1">
|
||
※ 슬러지를 스스로 처리하는 경우 그 처리장소: <span class="input-line w-64"></span><br>
|
||
※ 위탁처리를 하는 경우 위탁처리업소명: <span class="input-line w-64"></span>
|
||
</div>
|
||
<div class="mt-auto text-[8pt] border-t border-gray-300 pt-1 text-center text-gray-400">210㎜×297㎜[일반용지 60g/㎡(재활용품)]</div>
|
||
</div>
|
||
<!-- 뒤 쪽 -->
|
||
<div class="page">
|
||
<div class="text-left text-[8pt] mb-1">( 뒤 쪽)</div>
|
||
<div class="section-title">5. 원료 또는 첨가제 등의 사용량</div>
|
||
<table>
|
||
<colgroup><col style="width:18%"><col style="width:10%"><col style="width:12%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"></colgroup>
|
||
<tr><th>원료 또는 첨가제 등</th><th>시멘트</th><th>슬래그<br>미분말</th><th>자 갈</th><th>모 래</th><th>혼화제</th><th>용 수</th><th></th><th class="bg-gray-50">생산량<br>(㎥)</th></tr>
|
||
<tr><td class="h-10">사용량(㎏)</td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
|
||
</table>
|
||
<div class="small-note mb-4">※ 일반적으로 사용되는 용어 또는 공통어로 기재합니다.</div>
|
||
<div class="section-title">6. 전력사용량</div>
|
||
<table>
|
||
<tr><th>가동시간</th><th>사용량(㎾h)</th><th>금일 폐수 1㎥당<br>소모전력량(㎾h/㎥)</th><th>검침시간</th><th>적산전력계 지침</th><th>참고사항</th></tr>
|
||
<tr><td class="h-8"></td><td></td><td></td><td></td><td></td><td></td></tr>
|
||
</table>
|
||
<div class="section-title">7. 약품사용량</div>
|
||
<table>
|
||
<tr><th>약품명</th><th>구입량</th><th>약품 소모량</th><th>잔고량</th><th>비고</th><th>약품명</th><th>구입량</th><th>약품 소모량</th><th>잔고량</th><th>비고</th></tr>
|
||
<tr><td class="h-6"></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
|
||
<tr><td class="h-6"></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
|
||
</table>
|
||
<div class="section-title">8. 폭기조 운전상태(생물화학적 처리시설의 경우)</div>
|
||
<table>
|
||
<tr><th>pH</th><th>수온</th><th>DO</th><th>SV30</th><th>MLSS</th><th>SVI</th><th>폭기시간</th><th>주미생물상태</th></tr>
|
||
<tr><td class="h-6"></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
|
||
</table>
|
||
<div class="small-note mb-4">※ 미생물 관찰: 현미경 보유(600배율 이상), 주미생물상태는 양호 또는 불량으로 적습니다.</div>
|
||
<div class="section-title">9. 수질오염방지시설 고장 유무 및 특기사항</div>
|
||
<div class="border border-black h-20 mb-2 editable-block"></div>
|
||
<div class="section-title">10. 수질오염물질 측정내용</div>
|
||
<table>
|
||
<tr><th class="diag"><span class="diag-label top-right">항목</span><span class="diag-label bottom-left">구분</span></th><th>pH</th><th>BOD<br>COD</th><th>SS</th><th>n-Hex</th><th>CN</th><th>Cu</th><th></th><th></th><th></th><th></th><th></th><th>분석일</th></tr>
|
||
<tr><td class="h-8">원폐수</td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
|
||
<tr><td class="h-8">방류수</td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
|
||
</table>
|
||
<div class="small-note mb-3 leading-7">
|
||
※ 사업장에서 분석하는 경우 분석자명: <span class="input-line w-48"></span><br>
|
||
※ 분석을 위탁하는 경우 측정대행업소명: <span class="input-line w-48"></span>
|
||
</div>
|
||
<div class="section-title">11. 수질자동측정기기 등의 측정항목별 점검내용</div>
|
||
<table><tr class="text-[7pt]"><th class="diag w-16"><span class="diag-label top-right">항목</span><span class="diag-label bottom-left">구분</span></th><th>평균</th><th>08:00</th><th>10:00</th><th>12:00</th><th>14:00</th><th>16:00</th><th>18:00</th><th>20:00</th><th>24:00</th><th>02:00</th><th>04:00</th><th>06:00</th></tr><tr><td class="text-[7pt] h-8">COD 측정치(mg/L)</td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr></table>
|
||
<div class="section-title">12. 지도ㆍ점검을 받은 사항</div>
|
||
<div class="border border-black h-20 mb-2 editable-block"></div>
|
||
<div class="p-2 border border-black small-note mb-6">※ 제1호부터 제4호까지는 폐수처리방법 등 사업장 특성을 고려하여 해당 부분을 반드시 적어야 하며, 제5호부터 제12호까지는 사업자의 판단에 따라 선택적으로 적을 수 있습니다.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 2. 대기 배출 운영기록부 탭 -->
|
||
<div id="air-tab" class="tab-content">
|
||
<div class="page">
|
||
<div class="flex justify-between items-start mt-2">
|
||
<div class="flex-1 pt-6">
|
||
<div class="title text-left whitespace-nowrap">대기배출시설 및 방지시설 운영기록부</div>
|
||
</div>
|
||
<table class="approval-table">
|
||
<tr><th rowspan="2" class="w-8">결<br>재</th><th>담 당</th><th>팀 장</th><th>공장장</th></tr>
|
||
<tr><td></td><td></td><td></td></tr>
|
||
</table>
|
||
</div>
|
||
<div class="clear-both text-right mb-4 text-[9pt]">
|
||
202<span class="input-line w-6"></span>년 <span class="input-line w-6"></span>월 <span class="input-line w-6"></span>일 <span class="input-line w-10"></span>요일 날씨: <span class="input-line w-16"></span> 온도: 저: <span class="input-line w-8"></span>℃ 고: <span class="input-line w-8"></span>℃
|
||
</div>
|
||
<div class="section-title">1. 배출구별 주요 배출시설 및 방지시설 가동(조업)시간</div>
|
||
<table>
|
||
<colgroup><col style="width: 15%;"><col style="width: 45%;"><col style="width: 20%;"><col style="width: 20%;"></colgroup>
|
||
<tr><th>배출구</th><th>배 출 시 설</th><th>가 동 시 간</th><th>비 고</th></tr>
|
||
<tr><td>1</td><td class="text-left">혼합시설(60HP(1㎥)</td><td></td><td></td></tr>
|
||
<tr><td>2</td><td class="text-left">저장시설(64.8㎥)</td><td></td><td></td></tr>
|
||
<tr><td>3</td><td class="text-left">저장시설(64.8㎥)</td><td></td><td></td></tr>
|
||
</table>
|
||
<div class="section-title">2. 방지시설 운영사항 - 가. 방지시설 운전사항</div>
|
||
<table>
|
||
<colgroup>
|
||
<col style="width: 16%;">
|
||
<col style="width: 10%;">
|
||
<col style="width: 12%;">
|
||
<col style="width: 12%;">
|
||
<col style="width: 10%;">
|
||
<col style="width: 12%;">
|
||
<col style="width: 8%;">
|
||
<col style="width: 10%;">
|
||
<col style="width: 10%;">
|
||
</colgroup>
|
||
<tr>
|
||
<th rowspan="2">방 지 시 설 명</th>
|
||
<th rowspan="2">설치위치</th>
|
||
<th rowspan="2">전력 사용량<br>(㎾/h)</th>
|
||
<th rowspan="2">처리용량<br>(㎥/min)</th>
|
||
<th rowspan="2">처리 오염물질</th>
|
||
<th rowspan="2">처리농도<br>(ppm, ㎎/S㎥)</th>
|
||
<th rowspan="2">처리효율<br>(%)</th>
|
||
<th colspan="2">사용약품</th>
|
||
</tr>
|
||
<tr>
|
||
<th>약품명</th>
|
||
<th>사용량</th>
|
||
</tr>
|
||
<tr>
|
||
<td class="text-left">1. 여과에 의한 시설</td>
|
||
<td>공장C동</td>
|
||
<td></td>
|
||
<td>40</td>
|
||
<td>먼지</td>
|
||
<td></td>
|
||
<td></td>
|
||
<td></td>
|
||
<td></td>
|
||
</tr>
|
||
<tr>
|
||
<td class="text-left">2. 여과에 의한 시설</td>
|
||
<td>공장C동</td>
|
||
<td></td>
|
||
<td>12</td>
|
||
<td>먼지</td>
|
||
<td></td>
|
||
<td></td>
|
||
<td></td>
|
||
<td></td>
|
||
</tr>
|
||
<tr>
|
||
<td class="text-left">3. 여과에 의한 시설</td>
|
||
<td>공장C동</td>
|
||
<td></td>
|
||
<td>12</td>
|
||
<td>먼지</td>
|
||
<td></td>
|
||
<td></td>
|
||
<td></td>
|
||
<td></td>
|
||
</tr>
|
||
</table>
|
||
<div class="text-[8.5pt] font-bold mb-1 mt-2">나. 방지시설 보수사항</div>
|
||
<table><tr><th>방지시설명</th><th>배 출 구 별</th><th>보 수 기 간</th><th>보 수 자</th><th>보 수 명 세</th></tr><tr><td class="h-10"></td><td></td><td></td><td></td><td></td></tr></table>
|
||
<div class="section-title">3. 자가측정사항 <span class="text-[8pt] font-normal">(자가측정 기록부 참고)</span></div>
|
||
<table>
|
||
<tr><th class="w-1/3">①기 상</th><th class="w-12">②기온</th><th class="w-12">③습도</th><th class="w-12">④기압</th><th class="w-12">⑤풍향</th><th class="w-12">⑥풍속</th></tr>
|
||
<tr>
|
||
<td class="text-left text-[7.5pt] p-2">
|
||
<div class="flex justify-around items-center h-full">
|
||
<div class="checkbox-container"><div class="checkbox-box"></div> 맑음</div>
|
||
<div class="checkbox-container"><div class="checkbox-box"></div> 흐림</div>
|
||
<div class="checkbox-container"><div class="checkbox-box"></div> 구름</div>
|
||
<div class="checkbox-container"><div class="checkbox-box"></div> 눈</div>
|
||
<div class="checkbox-container"><div class="checkbox-box"></div> 비</div>
|
||
</div>
|
||
</td>
|
||
<td class="text-right pr-2">℃</td>
|
||
<td class="text-right pr-2">%</td>
|
||
<td class="text-right pr-2">mb</td>
|
||
<td class="text-right pr-2">풍</td>
|
||
<td class="text-right pr-2">m/sec</td>
|
||
</tr>
|
||
</table>
|
||
<table>
|
||
<tr>
|
||
<th>⑦ 배출구</th>
|
||
<th>⑧ 주요배출시설</th>
|
||
<th>⑨측정항목</th>
|
||
<th>⑩측정농도<br>(ppm, mg/Sm³)</th>
|
||
<th>⑪일일유량<br>(Sm³/일)</th>
|
||
<th>⑫ 일일배출량<br>(kg/일)</th>
|
||
<th>⑬검사기기</th>
|
||
<th>⑭검사방법</th>
|
||
</tr>
|
||
<tr><td class="h-10"></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
|
||
</table>
|
||
|
||
<!-- 대기 하단 분할 정보 구역 (image_4b8b01.png 반영) -->
|
||
<div class="info-box-row" style="border-top: 1px solid black;">
|
||
<div class="info-box-label">
|
||
<div>⑮ 연 료 명 및 사 용 량</div>
|
||
<div class="mt-1">( <span class="input-line w-24"></span> 일)</div>
|
||
</div>
|
||
<div class="info-box-content"></div>
|
||
</div>
|
||
<div class="info-box-row">
|
||
<div class="info-box-label">
|
||
<div>⑯ 원 료 명 및 사 용 량</div>
|
||
<div class="text-[7pt]">(특정대기유해물질 배출원 포함)</div>
|
||
</div>
|
||
<div class="info-box-content"></div>
|
||
</div>
|
||
<div class="info-box-row">
|
||
<div class="info-box-label">⑰ 환 경 기 술 인 의 의 견</div>
|
||
<div class="info-box-content"></div>
|
||
</div>
|
||
<div class="info-box-row">
|
||
<div class="info-box-label">⑱ 기 타</div>
|
||
<div class="info-box-content"></div>
|
||
</div>
|
||
|
||
<!-- 서명란 (image_4b82ea.png 스타일 적용) -->
|
||
<div class="signature-group">
|
||
<div>근 무 자 : B/P 운전원</div>
|
||
<div>직 급 : 기 사</div>
|
||
<div>성 명 : 곽 병 목 <span class="sign-stamp-target">(인)</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 3. 데이터 계산 탭 -->
|
||
<div id="data-calc-tab" class="tab-content">
|
||
<div class="page">
|
||
<div class="title text-left no-print">데이터(계산)</div>
|
||
<div class="small-note mb-3 no-print">수질 3번 항목을 날짜 기준으로 자동 계산합니다. 전일 지침은 전날 금일 지침을 사용하고, 일일량은 `금일-전일`입니다.</div>
|
||
<div class="flex flex-wrap items-center gap-2 mb-2 no-print">
|
||
<button id="calc-view-daily-btn" class="bg-white border border-slate-300 hover:border-blue-500 px-3 py-1 rounded text-[12px] font-bold text-blue-700" onclick="openCalcView('daily')">일별</button>
|
||
<button id="calc-view-monthly-btn" class="bg-white border border-slate-300 hover:border-blue-500 px-3 py-1 rounded text-[12px] font-bold text-slate-700" onclick="openCalcView('monthly')">월별</button>
|
||
<div id="calc-daily-range" class="flex items-center gap-1">
|
||
<span class="text-[11px] font-bold text-slate-600">일별</span>
|
||
<input id="calc-date-from" type="date" class="border border-slate-300 rounded px-2 py-1 text-[12px]" onchange="onCalcFilterChange()">
|
||
<span class="text-[11px] text-slate-600">~</span>
|
||
<input id="calc-date-to" type="date" class="border border-slate-300 rounded px-2 py-1 text-[12px]" onchange="onCalcFilterChange()">
|
||
</div>
|
||
<div id="calc-month-range" class="flex items-center gap-1" style="display:none;">
|
||
<span class="text-[11px] font-bold text-slate-600">월별</span>
|
||
<input id="calc-month-from" type="month" class="border border-slate-300 rounded px-2 py-1 text-[12px]" onchange="onCalcFilterChange()">
|
||
<span class="text-[11px] text-slate-600">~</span>
|
||
<input id="calc-month-to" type="month" class="border border-slate-300 rounded px-2 py-1 text-[12px]" onchange="onCalcFilterChange()">
|
||
</div>
|
||
<button class="bg-white border border-slate-300 hover:border-slate-500 px-2 py-1 rounded text-[12px] font-bold text-slate-700" onclick="clearCalcFilters()">필터초기화</button>
|
||
<button class="bg-white border border-slate-300 hover:border-indigo-500 px-2 py-1 rounded text-[12px] font-bold text-indigo-700" onclick="printCalcData()">데이터 인쇄</button>
|
||
</div>
|
||
<div id="calc-daily-view">
|
||
<div class="section-title">일별 집계</div>
|
||
<table id="calc-daily-table">
|
||
<thead>
|
||
<tr>
|
||
<th rowspan="2" style="width: 8%;">일자</th>
|
||
<th rowspan="2" style="width: 7%;">전일지침<br>(㎥)</th>
|
||
<th rowspan="2" style="width: 7%;">금일지침<br>(㎥)</th>
|
||
<th rowspan="2" style="width: 7%;">사용량<br>(㎥/일)</th>
|
||
<th rowspan="2" style="width: 7%;">폐수발생량</th>
|
||
<th rowspan="2" style="width: 8%;">소모(증발량)<br>제품함유</th>
|
||
<th colspan="3" style="width: 26%;">재사용량</th>
|
||
<th rowspan="2" style="width: 7%;">생활용수량</th>
|
||
<th rowspan="2" style="width: 8%;">슬러지<br>발생량(㎥)</th>
|
||
</tr>
|
||
<tr>
|
||
<th style="width: 8%;">전일지침<br>(㎥)</th>
|
||
<th style="width: 8%;">금일지침<br>(㎥)</th>
|
||
<th style="width: 10%;">배출량 및<br>사용량(㎥/일)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="calc-daily-body">
|
||
<tr><td colspan="11">일별 집계 데이터가 없습니다.</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div id="calc-monthly-view" style="display:none;">
|
||
<div class="section-title">월별 집계</div>
|
||
<table id="calc-monthly-table">
|
||
<thead>
|
||
<tr>
|
||
<th rowspan="2" style="width: 8%;">월</th>
|
||
<th rowspan="2" style="width: 7%;">전일지침<br>(㎥)</th>
|
||
<th rowspan="2" style="width: 7%;">금일지침<br>(㎥)</th>
|
||
<th rowspan="2" style="width: 7%;">사용량<br>(㎥/일)</th>
|
||
<th rowspan="2" style="width: 7%;">폐수발생량</th>
|
||
<th rowspan="2" style="width: 8%;">소모(증발량)<br>제품함유</th>
|
||
<th colspan="3" style="width: 26%;">재사용량</th>
|
||
<th rowspan="2" style="width: 7%;">생활용수량</th>
|
||
<th rowspan="2" style="width: 8%;">슬러지<br>발생량(㎥)</th>
|
||
</tr>
|
||
<tr>
|
||
<th style="width: 8%;">전일지침<br>(㎥)</th>
|
||
<th style="width: 8%;">금일지침<br>(㎥)</th>
|
||
<th style="width: 10%;">배출량 및<br>사용량(㎥/일)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="calc-monthly-body">
|
||
<tr><td colspan="11">월별 집계 데이터가 없습니다.</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 4. 데이터 PDF 탭 -->
|
||
<div id="data-pdf-tab" class="tab-content">
|
||
<div class="page">
|
||
<div class="title text-left">데이터(pdf)</div>
|
||
<div class="small-note mb-3">`저장` 버튼으로 만든 PDF 문서를 날짜별로 확인하고 열람할 수 있습니다.</div>
|
||
<div class="doc-calendar">
|
||
<div class="doc-cal-head">
|
||
<button class="doc-cal-nav" onclick="moveDocCalendar(-1)">이전달</button>
|
||
<div id="doc-cal-month-label" class="doc-cal-title">0000년 00월</div>
|
||
<div class="flex items-center gap-1">
|
||
<button class="doc-cal-nav" onclick="clearDocDateFilter()">전체보기</button>
|
||
<button class="doc-cal-nav" onclick="moveDocCalendar(1)">다음달</button>
|
||
</div>
|
||
</div>
|
||
<div class="doc-cal-grid mb-1">
|
||
<div class="doc-cal-week">일</div><div class="doc-cal-week">월</div><div class="doc-cal-week">화</div><div class="doc-cal-week">수</div><div class="doc-cal-week">목</div><div class="doc-cal-week">금</div><div class="doc-cal-week">토</div>
|
||
</div>
|
||
<div id="doc-cal-grid" class="doc-cal-grid"></div>
|
||
<div id="doc-cal-selected" class="small-note mt-2 text-slate-700">선택 날짜: 전체</div>
|
||
</div>
|
||
<table id="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 16%;">날짜</th>
|
||
<th style="width: 18%;">문서유형</th>
|
||
<th style="width: 36%;">파일명</th>
|
||
<th style="width: 30%;">작업</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="data-table-body">
|
||
<tr><td colspan="4">저장된 데이터가 없습니다.</td></tr>
|
||
</tbody>
|
||
</table>
|
||
<div id="pdf-preview-panel" class="hidden mt-3 border border-black">
|
||
<div class="flex justify-between items-center p-2 border-b border-black bg-gray-50 text-[8.5pt]">
|
||
<div id="pdf-preview-title" class="font-bold">PDF 미리보기</div>
|
||
<button onclick="closePdfPreview()" class="bg-slate-700 text-white px-2 py-1 rounded text-[8pt]">닫기</button>
|
||
</div>
|
||
<div class="p-2 text-[8pt] border-b border-gray-300">
|
||
미리보기가 보이지 않으면 <a id="pdf-preview-open-link" href="#" target="_blank" class="text-blue-700 underline">새창으로 열기</a>를 눌러주세요.
|
||
</div>
|
||
<object id="pdf-preview-object" type="application/pdf" class="w-full" style="height: 520px;">
|
||
<iframe id="pdf-preview-frame" title="PDF 미리보기" class="w-full" style="height: 520px;"></iframe>
|
||
</object>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const STORAGE_KEY = 'env_journal_form_data_v5';
|
||
const DOC_INDEX_KEY = 'env_journal_pdf_index_v1';
|
||
const PDF_DB_NAME = 'envJournalPdfDB';
|
||
const PDF_STORE_NAME = 'pdfDocs';
|
||
const formData = {};
|
||
const fieldMeta = {};
|
||
const stampData = {};
|
||
const stampTemplateData = {};
|
||
const dailyMeterHistory = {};
|
||
const datedFormSnapshots = {};
|
||
const savedDocs = [];
|
||
let currentPreviewUrl = null;
|
||
let fieldCounter = 0;
|
||
let calendarViewYear = 0;
|
||
let calendarViewMonth = 0;
|
||
let selectedDocDate = '';
|
||
let currentEditingDateKey = '';
|
||
let isApplyingSnapshot = false;
|
||
|
||
function nextField(prefix) {
|
||
fieldCounter += 1;
|
||
return prefix + '_' + String(fieldCounter).padStart(4, '0');
|
||
}
|
||
|
||
function saveToStorage() {
|
||
if (!isApplyingSnapshot && currentEditingDateKey) {
|
||
datedFormSnapshots[currentEditingDateKey] = JSON.parse(JSON.stringify(formData));
|
||
}
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||
formData,
|
||
fieldMeta,
|
||
stampData,
|
||
stampTemplateData,
|
||
dailyMeterHistory,
|
||
datedFormSnapshots,
|
||
activeDateKey: currentEditingDateKey
|
||
}));
|
||
localStorage.setItem(DOC_INDEX_KEY, JSON.stringify(savedDocs));
|
||
}
|
||
|
||
function loadFromStorage() {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
if (raw) {
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && parsed.formData) {
|
||
Object.assign(formData, parsed.formData);
|
||
if (parsed.fieldMeta) Object.assign(fieldMeta, parsed.fieldMeta);
|
||
if (parsed.stampData) Object.assign(stampData, parsed.stampData);
|
||
if (parsed.stampTemplateData) Object.assign(stampTemplateData, parsed.stampTemplateData);
|
||
if (parsed.dailyMeterHistory) Object.assign(dailyMeterHistory, parsed.dailyMeterHistory);
|
||
if (parsed.datedFormSnapshots) Object.assign(datedFormSnapshots, parsed.datedFormSnapshots);
|
||
if (parsed.activeDateKey) currentEditingDateKey = String(parsed.activeDateKey);
|
||
} else if (parsed && typeof parsed === 'object') {
|
||
Object.assign(formData, parsed);
|
||
}
|
||
} catch (e) {
|
||
console.error('저장 데이터 파싱 실패:', e);
|
||
}
|
||
}
|
||
const docRaw = localStorage.getItem(DOC_INDEX_KEY);
|
||
if (docRaw) {
|
||
try {
|
||
const parsedDocs = JSON.parse(docRaw);
|
||
if (Array.isArray(parsedDocs)) {
|
||
savedDocs.length = 0;
|
||
parsedDocs.forEach((d) => savedDocs.push(d));
|
||
}
|
||
} catch (e) {
|
||
console.error('문서 인덱스 파싱 실패:', e);
|
||
}
|
||
}
|
||
reconcileDailyHistoryWithSavedDocs();
|
||
}
|
||
|
||
function reconcileDailyHistoryWithSavedDocs() {
|
||
const waterDocDates = new Set(
|
||
(savedDocs || [])
|
||
.filter((d) => d && d.tabId === 'water-tab')
|
||
.map((d) => String(d.date || ''))
|
||
.filter((v) => /^\d{4}-\d{2}-\d{2}$/.test(v))
|
||
);
|
||
Object.keys(dailyMeterHistory).forEach((dateKey) => {
|
||
if (!waterDocDates.has(dateKey)) {
|
||
delete dailyMeterHistory[dateKey];
|
||
}
|
||
});
|
||
}
|
||
|
||
function hasSavedWaterDoc(dateKey) {
|
||
return (savedDocs || []).some((d) =>
|
||
d && d.tabId === 'water-tab' && String(d.date || '') === String(dateKey || '')
|
||
);
|
||
}
|
||
|
||
function clearCurrentWaterPrevFields() {
|
||
const pairs = getWaterPrevTodayFieldPairs();
|
||
pairs.forEach(({ prevField }) => {
|
||
if (!prevField) return;
|
||
if (formData[prevField] !== undefined && String(formData[prevField]).trim() !== '') {
|
||
formData[prevField] = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
function cleanText(text) {
|
||
return String(text || '').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function openPdfDb() {
|
||
return new Promise((resolve, reject) => {
|
||
const req = indexedDB.open(PDF_DB_NAME, 1);
|
||
req.onupgradeneeded = function () {
|
||
const db = req.result;
|
||
if (!db.objectStoreNames.contains(PDF_STORE_NAME)) {
|
||
db.createObjectStore(PDF_STORE_NAME, { keyPath: 'id' });
|
||
}
|
||
};
|
||
req.onsuccess = () => resolve(req.result);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
}
|
||
|
||
async function putPdfBlob(doc) {
|
||
const db = await openPdfDb();
|
||
await new Promise((resolve, reject) => {
|
||
const tx = db.transaction(PDF_STORE_NAME, 'readwrite');
|
||
tx.objectStore(PDF_STORE_NAME).put(doc);
|
||
tx.oncomplete = () => resolve();
|
||
tx.onerror = () => reject(tx.error);
|
||
});
|
||
db.close();
|
||
}
|
||
|
||
async function getPdfBlobById(id) {
|
||
const db = await openPdfDb();
|
||
const data = await new Promise((resolve, reject) => {
|
||
const tx = db.transaction(PDF_STORE_NAME, 'readonly');
|
||
const req = tx.objectStore(PDF_STORE_NAME).get(id);
|
||
req.onsuccess = () => resolve(req.result || null);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
db.close();
|
||
return data;
|
||
}
|
||
|
||
async function deletePdfBlobById(id) {
|
||
const db = await openPdfDb();
|
||
await new Promise((resolve, reject) => {
|
||
const tx = db.transaction(PDF_STORE_NAME, 'readwrite');
|
||
tx.objectStore(PDF_STORE_NAME).delete(id);
|
||
tx.oncomplete = () => resolve();
|
||
tx.onerror = () => reject(tx.error);
|
||
});
|
||
db.close();
|
||
}
|
||
|
||
function getFormTitle(el) {
|
||
const page = el.closest('.page');
|
||
const title = page ? page.querySelector('.title') : null;
|
||
return cleanText(title ? title.textContent : '');
|
||
}
|
||
|
||
function getSectionTitle(el) {
|
||
let node = el;
|
||
while (node && node !== document.body) {
|
||
let prev = node.previousElementSibling;
|
||
while (prev) {
|
||
if (prev.classList && prev.classList.contains('section-title')) {
|
||
return cleanText(prev.textContent);
|
||
}
|
||
prev = prev.previousElementSibling;
|
||
}
|
||
node = node.parentElement;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function inferCellLabel(td) {
|
||
const row = td.parentElement;
|
||
const table = td.closest('table');
|
||
if (!row || !table) return getFormTitle(td) || '입력칸';
|
||
|
||
const cells = Array.from(row.children);
|
||
const colIndex = cells.indexOf(td);
|
||
const rowIndex = Array.from(table.rows).indexOf(row);
|
||
let rowHeader = '';
|
||
for (let i = colIndex; i >= 0; i--) {
|
||
const t = cleanText(cells[i] ? cells[i].textContent : '');
|
||
if (!t) continue;
|
||
if (t === '구분' || t === '항목') continue;
|
||
rowHeader = t;
|
||
break;
|
||
}
|
||
if (!rowHeader) rowHeader = cleanText(cells[0] ? cells[0].textContent : '');
|
||
const headerRow = table.rows[0];
|
||
const colHeader = headerRow && headerRow.cells[colIndex] ? cleanText(headerRow.cells[colIndex].textContent) : '';
|
||
const section = getSectionTitle(table) || getFormTitle(table);
|
||
|
||
const parts = [section, rowHeader, colHeader].filter(Boolean);
|
||
return parts.length ? parts.join(' / ') : '표 입력 R' + (rowIndex + 1) + 'C' + (colIndex + 1);
|
||
}
|
||
|
||
function inferLineLabel(span, idxInLine) {
|
||
const dateLine = span.closest('.clear-both');
|
||
const formTitle = getFormTitle(span);
|
||
if (dateLine) {
|
||
const labels = ['년(뒤)', '월', '일', '요일', '날씨', '저온', '고온'];
|
||
const suffix = labels[idxInLine] || ('입력' + (idxInLine + 1));
|
||
return formTitle + ' / 기본정보 / ' + suffix;
|
||
}
|
||
const section = getSectionTitle(span);
|
||
return [formTitle, section, '라인 입력'].filter(Boolean).join(' / ');
|
||
}
|
||
|
||
function bindTextInput(input, field, meta) {
|
||
input.dataset.field = field;
|
||
fieldMeta[field] = meta || fieldMeta[field] || { label: field };
|
||
if (formData[field] !== undefined) input.value = formData[field];
|
||
input.addEventListener('input', () => {
|
||
formData[field] = input.value;
|
||
saveToStorage();
|
||
renderCalcDataTab();
|
||
});
|
||
}
|
||
|
||
function bindEditableBlock(el, field, meta) {
|
||
el.dataset.field = field;
|
||
fieldMeta[field] = meta || fieldMeta[field] || { label: field };
|
||
if (formData[field] !== undefined) el.textContent = formData[field];
|
||
el.addEventListener('input', () => {
|
||
formData[field] = el.textContent;
|
||
saveToStorage();
|
||
renderCalcDataTab();
|
||
});
|
||
}
|
||
|
||
function makeLineInputsEditable() {
|
||
const widthMap = { 'w-6': 24, 'w-8': 32, 'w-10': 40, 'w-12': 48, 'w-16': 64, 'w-24': 96, 'w-40': 160 };
|
||
document.querySelectorAll('.clear-both').forEach((line) => {
|
||
const spans = Array.from(line.querySelectorAll('.input-line'));
|
||
spans.forEach((span, idx) => {
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'line-input';
|
||
const spanWidth = span.getBoundingClientRect().width;
|
||
const widthClass = Object.keys(widthMap).find((cls) => span.classList.contains(cls));
|
||
const fallbackWidth = widthClass ? widthMap[widthClass] : 42;
|
||
input.style.width = (spanWidth > 0 ? spanWidth : fallbackWidth) + 'px';
|
||
const field = nextField('line');
|
||
bindTextInput(input, field, { label: inferLineLabel(span, idx), tab: span.closest('.tab-content')?.id || '' });
|
||
span.replaceWith(input);
|
||
});
|
||
});
|
||
document.querySelectorAll('.input-line').forEach((span) => {
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'line-input';
|
||
const spanWidth = span.getBoundingClientRect().width;
|
||
input.style.width = (spanWidth > 0 ? spanWidth : 42) + 'px';
|
||
const field = nextField('line');
|
||
bindTextInput(input, field, { label: inferLineLabel(span, 0), tab: span.closest('.tab-content')?.id || '' });
|
||
span.replaceWith(input);
|
||
});
|
||
}
|
||
|
||
function createCellInput(td, field, label) {
|
||
td.innerHTML = '';
|
||
const isSludgeCell = String(label || '').includes('슬러지발생량');
|
||
const multiline =
|
||
/(h-8|h-10|h-20|h-24)/.test(td.className || '') ||
|
||
(td.rowSpan && td.rowSpan > 1);
|
||
const useTextarea = multiline && !isSludgeCell;
|
||
const input = useTextarea ? document.createElement('textarea') : document.createElement('input');
|
||
if (!useTextarea) input.type = 'text';
|
||
input.className = 'cell-input';
|
||
if (useTextarea) input.rows = 2;
|
||
bindTextInput(input, field, { label, tab: td.closest('.tab-content')?.id || '' });
|
||
if (isSludgeCell) input.style.textAlign = 'center';
|
||
if (useTextarea) {
|
||
input.classList.add('multiline-cell-input');
|
||
attachTextareaVerticalAlign(input);
|
||
}
|
||
td.appendChild(input);
|
||
}
|
||
|
||
function adjustTextareaVerticalAlign(textarea) {
|
||
const td = textarea.closest('td');
|
||
if (!td) return;
|
||
const cs = window.getComputedStyle(textarea);
|
||
const lineHeight = parseFloat(cs.lineHeight) || 14;
|
||
const text = textarea.value || '';
|
||
const explicitLines = text.split('\n').length;
|
||
const isMultiLine = explicitLines > 1;
|
||
|
||
if (isMultiLine) {
|
||
textarea.style.paddingTop = '2px';
|
||
textarea.style.verticalAlign = 'top';
|
||
return;
|
||
}
|
||
|
||
const cellInnerHeight = td.clientHeight - 4;
|
||
const padTop = Math.max(0, Math.floor((cellInnerHeight - lineHeight) / 2));
|
||
textarea.style.paddingTop = padTop + 'px';
|
||
}
|
||
|
||
function attachTextareaVerticalAlign(textarea) {
|
||
const run = () => adjustTextareaVerticalAlign(textarea);
|
||
textarea.addEventListener('input', run);
|
||
textarea.addEventListener('keyup', run);
|
||
requestAnimationFrame(run);
|
||
}
|
||
|
||
function createUnitInput(td, unitText, field, label) {
|
||
td.innerHTML = '';
|
||
const wrap = document.createElement('span');
|
||
wrap.className = 'unit-inline';
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'cell-input';
|
||
const unit = document.createElement('span');
|
||
unit.textContent = unitText;
|
||
bindTextInput(input, field, { label: label + ' (' + unitText + ')', tab: td.closest('.tab-content')?.id || '' });
|
||
wrap.appendChild(input);
|
||
wrap.appendChild(unit);
|
||
td.appendChild(wrap);
|
||
}
|
||
|
||
function setupToggleGridCells() {
|
||
const anchorByRow = new WeakMap();
|
||
const lastRangeByRow = new WeakMap();
|
||
|
||
function updateRowArrows(hourCells) {
|
||
hourCells.forEach((cell) => {
|
||
cell.classList.remove('range-start', 'range-end');
|
||
});
|
||
for (let i = 0; i < hourCells.length; i++) {
|
||
const currOn = hourCells[i].classList.contains('on');
|
||
if (!currOn) continue;
|
||
const prevOn = i > 0 ? hourCells[i - 1].classList.contains('on') : false;
|
||
const nextOn = i < hourCells.length - 1 ? hourCells[i + 1].classList.contains('on') : false;
|
||
if (!prevOn) hourCells[i].classList.add('range-start');
|
||
if (!nextOn) hourCells[i].classList.add('range-end');
|
||
}
|
||
}
|
||
|
||
document.querySelectorAll('table.toggle-grid').forEach((table) => {
|
||
const rows = Array.from(table.rows);
|
||
if (rows.length < 2) return;
|
||
const hourHeaders = Array.from(rows[0].cells).map((c) => cleanText(c.textContent));
|
||
const section = getSectionTitle(table) || getFormTitle(table) || '시간대 체크';
|
||
rows.slice(1).forEach((row) => {
|
||
const rowLabel = cleanText(row.cells[0] ? row.cells[0].textContent : '');
|
||
const hourCells = Array.from(row.cells).slice(1);
|
||
Array.from(row.cells).forEach((td, colIndex) => {
|
||
if (colIndex === 0) return;
|
||
const field = nextField('tog');
|
||
const hourLabel = hourHeaders[colIndex] || ('시간' + colIndex);
|
||
td.classList.add('toggle-cell');
|
||
td.dataset.field = field;
|
||
fieldMeta[field] = {
|
||
label: [section, rowLabel, hourLabel + '시'].filter(Boolean).join(' / '),
|
||
tab: td.closest('.tab-content')?.id || ''
|
||
};
|
||
const initCount = Number(formData[field] || 0);
|
||
const count = Number.isFinite(initCount) ? initCount : (formData[field] ? 1 : 0);
|
||
td.dataset.count = String(count);
|
||
if (count > 0) td.classList.add('on');
|
||
td.addEventListener('click', () => {
|
||
const anchor = anchorByRow.get(row);
|
||
if (anchor === undefined) {
|
||
hourCells.forEach((c) => c.classList.remove('selecting-start'));
|
||
td.classList.add('selecting-start');
|
||
anchorByRow.set(row, colIndex);
|
||
return;
|
||
}
|
||
|
||
const start = Math.min(anchor, colIndex);
|
||
const end = Math.max(anchor, colIndex);
|
||
const rangeKey = String(start) + '-' + String(end);
|
||
const shouldRemove = lastRangeByRow.get(row) === rangeKey;
|
||
|
||
hourCells.forEach((cell, idx) => {
|
||
const hourCol = idx + 1;
|
||
const f = cell.dataset.field;
|
||
if (hourCol >= start && hourCol <= end) {
|
||
const curr = Number(cell.dataset.count || '0') || 0;
|
||
const next = shouldRemove ? Math.max(0, curr - 1) : (curr + 1); // 같은 시작/끝 재선택 시 삭제
|
||
cell.dataset.count = String(next);
|
||
cell.classList.toggle('on', next > 0);
|
||
if (f) formData[f] = next;
|
||
}
|
||
cell.classList.remove('selecting-start');
|
||
});
|
||
anchorByRow.delete(row);
|
||
if (shouldRemove) lastRangeByRow.delete(row);
|
||
else lastRangeByRow.set(row, rangeKey);
|
||
updateRowArrows(hourCells);
|
||
saveToStorage();
|
||
});
|
||
});
|
||
updateRowArrows(hourCells);
|
||
});
|
||
});
|
||
}
|
||
|
||
function makeTableCellsEditable() {
|
||
const unitSet = new Set(['℃', '%', 'mb', '풍', 'm/sec']);
|
||
document.querySelectorAll('td').forEach((td) => {
|
||
if (td.closest('.no-print')) return;
|
||
if (td.closest('.approval-table')) return; // 결재칸(담당/팀장/공장장)은 입력 제외
|
||
if (td.classList.contains('toggle-cell')) return;
|
||
if (td.querySelector('input, textarea')) return;
|
||
const text = td.textContent.replace(/\u00A0/g, ' ').trim();
|
||
const hasOnlyParens = /^\(\s*\)$/.test(text);
|
||
const isUnitOnly = unitSet.has(text);
|
||
const isEmpty = text === '';
|
||
const label = inferCellLabel(td);
|
||
const field = nextField('cell');
|
||
|
||
if (isEmpty) return createCellInput(td, field, label);
|
||
if (hasOnlyParens) {
|
||
td.innerHTML = '(';
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'cell-input';
|
||
input.style.width = '70%';
|
||
input.style.display = 'inline-block';
|
||
bindTextInput(input, field, { label, tab: td.closest('.tab-content')?.id || '' });
|
||
td.appendChild(input);
|
||
td.append(')');
|
||
return;
|
||
}
|
||
if (isUnitOnly) createUnitInput(td, text, field, label);
|
||
});
|
||
}
|
||
|
||
function setupWaterSection3AutoCalc() {
|
||
const waterTab = document.getElementById('water-tab');
|
||
if (!waterTab) return;
|
||
const sectionTitle = Array.from(waterTab.querySelectorAll('.section-title')).find((el) =>
|
||
cleanText(el.textContent).includes('3. 용수 공급원별 사용량과 폐수배출량')
|
||
);
|
||
if (!sectionTitle) return;
|
||
const table = sectionTitle.nextElementSibling;
|
||
if (!table || table.tagName !== 'TABLE') return;
|
||
|
||
function toCalcNumber(v) {
|
||
const n = parseFloat(String(v || '').replace(/,/g, '').trim());
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
|
||
function formatCalcNumber(n) {
|
||
if (!Number.isFinite(n)) return '';
|
||
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
|
||
return String(Math.round(n * 1000) / 1000);
|
||
}
|
||
|
||
function buildLogicalGrid(tbl) {
|
||
const grid = [];
|
||
const spanMap = [];
|
||
const rows = Array.from(tbl.rows);
|
||
rows.forEach((tr, r) => {
|
||
if (!grid[r]) grid[r] = [];
|
||
let c = 0;
|
||
while (spanMap[c] && spanMap[c] > 0) {
|
||
spanMap[c] -= 1;
|
||
c += 1;
|
||
}
|
||
Array.from(tr.cells).forEach((cell) => {
|
||
while (spanMap[c] && spanMap[c] > 0) {
|
||
spanMap[c] -= 1;
|
||
c += 1;
|
||
}
|
||
const colSpan = cell.colSpan || 1;
|
||
const rowSpan = cell.rowSpan || 1;
|
||
for (let cc = 0; cc < colSpan; cc++) {
|
||
grid[r][c + cc] = cell;
|
||
if (rowSpan > 1) spanMap[c + cc] = rowSpan - 1;
|
||
}
|
||
c += colSpan;
|
||
});
|
||
});
|
||
return grid;
|
||
}
|
||
|
||
function getInput(cell) {
|
||
if (!cell) return null;
|
||
return cell.querySelector('input.cell-input, textarea.cell-input');
|
||
}
|
||
|
||
function bindCalcRow(prevInput, todayInput, resultInput) {
|
||
if (!prevInput || !todayInput || !resultInput) return;
|
||
lockComputedInput(resultInput);
|
||
const recalc = () => {
|
||
const prev = toCalcNumber(prevInput.value);
|
||
const today = toCalcNumber(todayInput.value);
|
||
const next = (prev === null || today === null) ? '' : formatCalcNumber(today - prev);
|
||
if (resultInput.value !== next) {
|
||
resultInput.value = next;
|
||
resultInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
};
|
||
if (resultInput.dataset.autoCalcBound !== '1') {
|
||
prevInput.addEventListener('input', recalc);
|
||
todayInput.addEventListener('input', recalc);
|
||
resultInput.dataset.autoCalcBound = '1';
|
||
}
|
||
recalc();
|
||
}
|
||
|
||
function lockComputedInput(input) {
|
||
if (!input) return;
|
||
input.readOnly = true;
|
||
input.setAttribute('readonly', 'readonly');
|
||
input.style.backgroundColor = '#f8fafc';
|
||
input.style.textAlign = 'center';
|
||
input.style.caretColor = 'transparent';
|
||
input.tabIndex = -1;
|
||
if (input.dataset.lockedComputed === '1') return;
|
||
input.addEventListener('beforeinput', (e) => e.preventDefault());
|
||
input.addEventListener('keydown', (e) => {
|
||
const allowed = ['Tab', 'Shift', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'];
|
||
if (!allowed.includes(e.key)) e.preventDefault();
|
||
});
|
||
input.dataset.lockedComputed = '1';
|
||
}
|
||
|
||
function collectUniqueRows(gridRows, prevCol, todayCol, resultCol, excludeResultInput, rowFilter) {
|
||
const uniq = new Map();
|
||
for (let r = 2; r < gridRows.length; r++) {
|
||
const row = gridRows[r] || [];
|
||
if (typeof rowFilter === 'function' && !rowFilter(row)) continue;
|
||
const prevInput = getInput(row[prevCol]);
|
||
const todayInput = getInput(row[todayCol]);
|
||
const resultInput = getInput(row[resultCol]);
|
||
if (!prevInput || !todayInput || !resultInput) continue;
|
||
if (excludeResultInput && resultInput === excludeResultInput) continue;
|
||
const key = resultInput.dataset.field || ('res_' + r + '_' + resultCol);
|
||
if (!uniq.has(key)) uniq.set(key, { prevInput, todayInput, resultInput });
|
||
}
|
||
return Array.from(uniq.values());
|
||
}
|
||
|
||
function readCellText(cell) {
|
||
return cleanText(cell ? cell.textContent : '');
|
||
}
|
||
|
||
function sumOrBlank(nums) {
|
||
let sum = 0;
|
||
let hasAny = false;
|
||
nums.forEach((n) => {
|
||
if (n === null) return;
|
||
hasAny = true;
|
||
sum += n;
|
||
});
|
||
return hasAny ? formatCalcNumber(sum) : '';
|
||
}
|
||
|
||
function bindTotalRow(totalPrevInput, totalTodayInput, totalResultInput, sourceRows) {
|
||
if (!totalPrevInput || !totalTodayInput || !totalResultInput) return;
|
||
[totalPrevInput, totalTodayInput, totalResultInput].forEach((inp) => {
|
||
inp.readOnly = true;
|
||
inp.style.backgroundColor = '#f1f5f9';
|
||
});
|
||
|
||
const recalcTotal = () => {
|
||
const prevVals = sourceRows.map((r) => toCalcNumber(r.prevInput.value));
|
||
const todayVals = sourceRows.map((r) => toCalcNumber(r.todayInput.value));
|
||
const dailyVals = sourceRows.map((r) => {
|
||
const p = toCalcNumber(r.prevInput.value);
|
||
const t = toCalcNumber(r.todayInput.value);
|
||
if (p === null || t === null) return null;
|
||
return t - p;
|
||
});
|
||
|
||
const prevSum = sumOrBlank(prevVals);
|
||
const todaySum = sumOrBlank(todayVals);
|
||
const dailySum = sumOrBlank(dailyVals);
|
||
|
||
if (totalPrevInput.value !== prevSum) {
|
||
totalPrevInput.value = prevSum;
|
||
totalPrevInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
if (totalTodayInput.value !== todaySum) {
|
||
totalTodayInput.value = todaySum;
|
||
totalTodayInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
if (totalResultInput.value !== dailySum) {
|
||
totalResultInput.value = dailySum;
|
||
totalResultInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
};
|
||
|
||
sourceRows.forEach((row) => {
|
||
if (row.resultInput.dataset.totalCalcBound !== '1') {
|
||
row.prevInput.addEventListener('input', recalcTotal);
|
||
row.todayInput.addEventListener('input', recalcTotal);
|
||
row.resultInput.addEventListener('input', recalcTotal);
|
||
row.resultInput.dataset.totalCalcBound = '1';
|
||
}
|
||
});
|
||
recalcTotal();
|
||
}
|
||
|
||
const grid = buildLogicalGrid(table);
|
||
for (let r = 2; r < grid.length; r++) {
|
||
const row = grid[r] || [];
|
||
const leftPrev = getInput(row[2]);
|
||
const leftToday = getInput(row[3]);
|
||
const leftResult = getInput(row[4]);
|
||
bindCalcRow(leftPrev, leftToday, leftResult);
|
||
|
||
const rightPrev = getInput(row[7]);
|
||
const rightToday = getInput(row[8]);
|
||
const rightResult = getInput(row[9]);
|
||
if (rightPrev) rightPrev.style.textAlign = 'center';
|
||
if (rightToday) rightToday.style.textAlign = 'center';
|
||
if (rightResult) rightResult.style.textAlign = 'center';
|
||
bindCalcRow(rightPrev, rightToday, rightResult);
|
||
}
|
||
|
||
const totalRow = grid[1] || [];
|
||
const leftTotalPrev = getInput(totalRow[2]);
|
||
const leftTotalToday = getInput(totalRow[3]);
|
||
const leftTotalResult = getInput(totalRow[4]);
|
||
const rightTotalPrev = getInput(totalRow[7]);
|
||
const rightTotalToday = getInput(totalRow[8]);
|
||
const rightTotalResult = getInput(totalRow[9]);
|
||
|
||
const leftSourceRows = collectUniqueRows(grid, 2, 3, 4, leftTotalResult);
|
||
const rightIncludeLabels = ['폐수배출량', '냉각수량', '소모', '재사용량', '생활용수량'];
|
||
const rightSourceRows = collectUniqueRows(
|
||
grid,
|
||
7,
|
||
8,
|
||
9,
|
||
rightTotalResult,
|
||
(row) => {
|
||
const rightItem = readCellText(row[6]);
|
||
return rightIncludeLabels.some((k) => rightItem.includes(k));
|
||
}
|
||
);
|
||
bindTotalRow(leftTotalPrev, leftTotalToday, leftTotalResult, leftSourceRows);
|
||
bindTotalRow(rightTotalPrev, rightTotalToday, rightTotalResult, rightSourceRows);
|
||
|
||
// 오른쪽 폐수 항목 결과칸은 항상 자동계산(직접입력 불가)으로 강제
|
||
for (let r = 1; r < grid.length; r++) {
|
||
const row = grid[r] || [];
|
||
const rightItem = readCellText(row[6]);
|
||
if (!rightIncludeLabels.some((k) => rightItem.includes(k))) continue;
|
||
const rightPrev = getInput(row[7]);
|
||
const rightToday = getInput(row[8]);
|
||
const rightResult = getInput(row[9]);
|
||
if (rightPrev) rightPrev.style.textAlign = 'center';
|
||
if (rightToday) rightToday.style.textAlign = 'center';
|
||
if (rightResult) rightResult.style.textAlign = 'center';
|
||
bindCalcRow(rightPrev, rightToday, rightResult);
|
||
lockComputedInput(rightResult);
|
||
}
|
||
|
||
// 오른쪽 폐수 구역은 라벨 기준으로 다시 한번 강제 계산(행/rowspan 오차 방지)
|
||
function normLabel(s) {
|
||
return cleanText(s).replace(/\s+/g, '');
|
||
}
|
||
function findRightRowInputs(labelKey) {
|
||
const key = normLabel(labelKey);
|
||
const trs = Array.from(table.rows);
|
||
for (const tr of trs) {
|
||
const cells = Array.from(tr.cells);
|
||
for (let i = 0; i < cells.length; i++) {
|
||
const txt = normLabel(cells[i].textContent || '');
|
||
if (!txt) continue;
|
||
if (!txt.includes(key)) continue;
|
||
const prevInput = getInput(cells[i + 1]);
|
||
const todayInput = getInput(cells[i + 2]);
|
||
const resultInput = getInput(cells[i + 3]);
|
||
if (prevInput && todayInput && resultInput) {
|
||
return { prevInput, todayInput, resultInput };
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
function setInputValue(input, value) {
|
||
if (!input) return;
|
||
if (input.value !== value) {
|
||
input.value = value;
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
}
|
||
function recalcRightWasteRowsForced() {
|
||
const total = findRightRowInputs('폐수발생량');
|
||
const rows = [
|
||
findRightRowInputs('폐수배출량'),
|
||
findRightRowInputs('냉각수량'),
|
||
findRightRowInputs('소모'),
|
||
findRightRowInputs('재사용량'),
|
||
findRightRowInputs('생활용수량')
|
||
].filter(Boolean);
|
||
|
||
rows.forEach((r) => {
|
||
r.prevInput.style.textAlign = 'center';
|
||
r.todayInput.style.textAlign = 'center';
|
||
r.resultInput.style.textAlign = 'center';
|
||
lockComputedInput(r.resultInput);
|
||
const p = toCalcNumber(r.prevInput.value);
|
||
const t = toCalcNumber(r.todayInput.value);
|
||
const v = (p === null || t === null) ? '' : formatCalcNumber(t - p);
|
||
setInputValue(r.resultInput, v);
|
||
});
|
||
|
||
if (total) {
|
||
lockComputedInput(total.prevInput);
|
||
lockComputedInput(total.todayInput);
|
||
lockComputedInput(total.resultInput);
|
||
total.prevInput.style.textAlign = 'center';
|
||
total.todayInput.style.textAlign = 'center';
|
||
total.resultInput.style.textAlign = 'center';
|
||
const prevVals = rows.map((r) => toCalcNumber(r.prevInput.value));
|
||
const todayVals = rows.map((r) => toCalcNumber(r.todayInput.value));
|
||
const dailyVals = rows.map((r) => {
|
||
const p = toCalcNumber(r.prevInput.value);
|
||
const t = toCalcNumber(r.todayInput.value);
|
||
if (p === null || t === null) return null;
|
||
return t - p;
|
||
});
|
||
setInputValue(total.prevInput, sumOrBlank(prevVals));
|
||
setInputValue(total.todayInput, sumOrBlank(todayVals));
|
||
setInputValue(total.resultInput, sumOrBlank(dailyVals));
|
||
}
|
||
}
|
||
const bindRows = [
|
||
findRightRowInputs('폐수배출량'),
|
||
findRightRowInputs('냉각수량'),
|
||
findRightRowInputs('소모'),
|
||
findRightRowInputs('재사용량'),
|
||
findRightRowInputs('생활용수량')
|
||
].filter(Boolean);
|
||
bindRows.forEach((r) => {
|
||
if (r.prevInput.dataset.rightWasteCalcBound !== '1') {
|
||
r.prevInput.addEventListener('input', recalcRightWasteRowsForced);
|
||
r.todayInput.addEventListener('input', recalcRightWasteRowsForced);
|
||
r.prevInput.dataset.rightWasteCalcBound = '1';
|
||
r.todayInput.dataset.rightWasteCalcBound = '1';
|
||
}
|
||
});
|
||
recalcRightWasteRowsForced();
|
||
}
|
||
|
||
function centerAlignWaterRightSectionInputs() {
|
||
const waterTab = document.getElementById('water-tab');
|
||
if (!waterTab) return;
|
||
const sectionTitle = Array.from(waterTab.querySelectorAll('.section-title')).find((el) =>
|
||
cleanText(el.textContent).includes('3. 용수 공급원별 사용량과 폐수배출량')
|
||
);
|
||
if (!sectionTitle) return;
|
||
const table = sectionTitle.nextElementSibling;
|
||
if (!table || table.tagName !== 'TABLE') return;
|
||
|
||
function buildLogicalGrid(tbl) {
|
||
const grid = [];
|
||
const spanMap = [];
|
||
const rows = Array.from(tbl.rows);
|
||
rows.forEach((tr, r) => {
|
||
if (!grid[r]) grid[r] = [];
|
||
let c = 0;
|
||
while (spanMap[c] && spanMap[c] > 0) {
|
||
spanMap[c] -= 1;
|
||
c += 1;
|
||
}
|
||
Array.from(tr.cells).forEach((cell) => {
|
||
while (spanMap[c] && spanMap[c] > 0) {
|
||
spanMap[c] -= 1;
|
||
c += 1;
|
||
}
|
||
const colSpan = cell.colSpan || 1;
|
||
const rowSpan = cell.rowSpan || 1;
|
||
for (let cc = 0; cc < colSpan; cc++) {
|
||
grid[r][c + cc] = cell;
|
||
if (rowSpan > 1) spanMap[c + cc] = rowSpan - 1;
|
||
}
|
||
c += colSpan;
|
||
});
|
||
});
|
||
return grid;
|
||
}
|
||
|
||
const grid = buildLogicalGrid(table);
|
||
for (let r = 1; r < grid.length; r++) {
|
||
const row = grid[r] || [];
|
||
[7, 8, 9].forEach((col) => {
|
||
const cell = row[col];
|
||
if (!cell) return;
|
||
cell.style.textAlign = 'center';
|
||
cell.style.verticalAlign = 'middle';
|
||
const input = cell.querySelector('input.cell-input, textarea.cell-input');
|
||
if (!input) return;
|
||
input.style.textAlign = 'center';
|
||
input.style.verticalAlign = 'middle';
|
||
});
|
||
}
|
||
}
|
||
|
||
function setupEditableBlocks() {
|
||
document.querySelectorAll('.info-box-content, .editable-block').forEach((el, idx) => {
|
||
el.contentEditable = 'true';
|
||
el.classList.add('editable-cell');
|
||
const section = getSectionTitle(el) || getFormTitle(el) || '특기사항';
|
||
bindEditableBlock(el, nextField('block'), { label: section + ' / 서술칸 ' + (idx + 1), tab: el.closest('.tab-content')?.id || '' });
|
||
});
|
||
}
|
||
|
||
function setupCheckboxToggle() {
|
||
document.querySelectorAll('.checkbox-box').forEach((box) => {
|
||
const text = cleanText(box.parentElement ? box.parentElement.textContent : '체크');
|
||
const section = getSectionTitle(box) || getFormTitle(box);
|
||
const field = nextField('chk');
|
||
box.dataset.field = field;
|
||
fieldMeta[field] = { label: [section, text].filter(Boolean).join(' / '), tab: box.closest('.tab-content')?.id || '' };
|
||
if (formData[field]) box.classList.add('checked');
|
||
box.addEventListener('click', () => {
|
||
box.classList.toggle('checked');
|
||
formData[field] = box.classList.contains('checked');
|
||
saveToStorage();
|
||
renderCalcDataTab();
|
||
});
|
||
|
||
const wrapper = box.closest('.checkbox-container');
|
||
if (wrapper) {
|
||
wrapper.addEventListener('click', (e) => {
|
||
if (e.target === box) return;
|
||
box.classList.toggle('checked');
|
||
formData[field] = box.classList.contains('checked');
|
||
saveToStorage();
|
||
renderCalcDataTab();
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
function applyStampToCell(cell, dataUrl) {
|
||
cell.innerHTML = '';
|
||
if (!dataUrl) return;
|
||
const img = document.createElement('img');
|
||
img.src = dataUrl;
|
||
img.alt = '결재 스탬프';
|
||
cell.appendChild(img);
|
||
}
|
||
|
||
function applyStampToSignTarget(el, dataUrl) {
|
||
el.innerHTML = '';
|
||
const label = document.createElement('span');
|
||
label.className = 'sign-stamp-label';
|
||
label.textContent = '(인)';
|
||
el.appendChild(label);
|
||
if (!dataUrl) {
|
||
el.classList.remove('has-image');
|
||
return;
|
||
}
|
||
const img = document.createElement('img');
|
||
img.src = dataUrl;
|
||
img.alt = '서명/인';
|
||
el.appendChild(img);
|
||
el.classList.add('has-image');
|
||
}
|
||
|
||
function clearAllStamps() {
|
||
if (!confirm('모든 결재 스탬프와 기본 스탬프 설정을 지울까요?')) return;
|
||
Object.keys(stampData).forEach((k) => delete stampData[k]);
|
||
Object.keys(stampTemplateData).forEach((k) => delete stampTemplateData[k]);
|
||
document.querySelectorAll('.approval-table td.stamp-cell').forEach((cell) => {
|
||
applyStampToCell(cell, null);
|
||
});
|
||
saveToStorage();
|
||
alert('스탬프를 모두 지웠습니다.');
|
||
}
|
||
|
||
function setupApprovalStamps() {
|
||
const fileInput = document.getElementById('stamp-file-input');
|
||
if (!fileInput) return;
|
||
let targetKey = null;
|
||
let targetCell = null;
|
||
let targetRole = null;
|
||
const roleNames = ['담당', '팀장', '공장장'];
|
||
|
||
document.querySelectorAll('.approval-table').forEach((table, tableIdx) => {
|
||
const stampRow = table.querySelector('tr:nth-child(2)');
|
||
if (!stampRow) return;
|
||
Array.from(stampRow.querySelectorAll('td')).forEach((cell, cellIdx) => {
|
||
const key = 'stamp_' + tableIdx + '_' + cellIdx;
|
||
const role = roleNames[cellIdx] || ('role_' + cellIdx);
|
||
cell.classList.add('stamp-cell');
|
||
cell.dataset.stampKey = key;
|
||
cell.dataset.stampRole = role;
|
||
if (stampData[key]) applyStampToCell(cell, stampData[key]);
|
||
|
||
cell.addEventListener('click', () => {
|
||
const template = stampTemplateData[role];
|
||
if (template) {
|
||
const ok = confirm(role + ' 기본 스탬프를 적용할까요?');
|
||
if (ok) {
|
||
stampData[key] = template;
|
||
applyStampToCell(cell, template);
|
||
saveToStorage();
|
||
return;
|
||
}
|
||
}
|
||
targetKey = key;
|
||
targetCell = cell;
|
||
targetRole = role;
|
||
fileInput.value = '';
|
||
fileInput.click();
|
||
});
|
||
|
||
cell.addEventListener('contextmenu', (e) => {
|
||
e.preventDefault();
|
||
if (!confirm('이 결재 이미지(스탬프)를 삭제할까요?')) return;
|
||
delete stampData[key];
|
||
applyStampToCell(cell, null);
|
||
saveToStorage();
|
||
});
|
||
});
|
||
});
|
||
|
||
fileInput.addEventListener('change', (e) => {
|
||
const file = e.target.files && e.target.files[0];
|
||
if (!file || !targetKey || !targetCell) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const dataUrl = String(reader.result || '');
|
||
stampData[targetKey] = dataUrl;
|
||
if (targetRole) stampTemplateData[targetRole] = dataUrl;
|
||
if (targetCell.classList.contains('sign-stamp-target')) {
|
||
applyStampToSignTarget(targetCell, dataUrl);
|
||
} else {
|
||
applyStampToCell(targetCell, dataUrl);
|
||
}
|
||
saveToStorage();
|
||
};
|
||
reader.readAsDataURL(file);
|
||
});
|
||
|
||
// 본문의 '(인)' 서명 위치 바인딩
|
||
document.querySelectorAll('.sign-stamp-target').forEach((el, idx) => {
|
||
const key = 'sign_stamp_' + idx;
|
||
el.dataset.stampKey = key;
|
||
if (stampData[key]) applyStampToSignTarget(el, stampData[key]);
|
||
else applyStampToSignTarget(el, null);
|
||
|
||
el.addEventListener('click', () => {
|
||
targetKey = key;
|
||
targetCell = el;
|
||
targetRole = null;
|
||
fileInput.value = '';
|
||
fileInput.click();
|
||
});
|
||
|
||
el.addEventListener('contextmenu', (e) => {
|
||
e.preventDefault();
|
||
if (!confirm('이 서명/인을 삭제할까요?')) return;
|
||
delete stampData[key];
|
||
applyStampToSignTarget(el, null);
|
||
saveToStorage();
|
||
});
|
||
});
|
||
}
|
||
|
||
function fillTodayDate() {
|
||
const now = new Date();
|
||
const yy = String(now.getFullYear()).slice(-1);
|
||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||
const d = String(now.getDate()).padStart(2, '0');
|
||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||
const day = days[now.getDay()];
|
||
|
||
['water-tab', 'air-tab'].forEach((tabId) => {
|
||
const tab = document.getElementById(tabId);
|
||
if (!tab) return;
|
||
const dateLine = tab.querySelector('.clear-both');
|
||
if (!dateLine) return;
|
||
const inputs = dateLine.querySelectorAll('input.line-input');
|
||
if (inputs.length < 4) return;
|
||
inputs[0].value = yy;
|
||
inputs[1].value = m;
|
||
inputs[2].value = d;
|
||
inputs[3].value = day;
|
||
inputs.forEach((inp) => inp.dispatchEvent(new Event('input')));
|
||
});
|
||
}
|
||
|
||
function getTodayDateKeyLocal() {
|
||
const now = new Date();
|
||
const y = now.getFullYear();
|
||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||
const d = String(now.getDate()).padStart(2, '0');
|
||
return y + '-' + m + '-' + d;
|
||
}
|
||
|
||
function setFormDateInputsByDateKey(tabId, dateKey) {
|
||
const parsed = parseDateKey(dateKey);
|
||
if (!parsed) return;
|
||
const tab = document.getElementById(tabId);
|
||
if (!tab) return;
|
||
const dateLine = tab.querySelector('.clear-both');
|
||
if (!dateLine) return;
|
||
const inputs = dateLine.querySelectorAll('input.line-input');
|
||
if (inputs.length < 4) return;
|
||
const yy = String(parsed.y).slice(-1);
|
||
const m = String(parsed.mo).padStart(2, '0');
|
||
const d = String(parsed.d).padStart(2, '0');
|
||
const day = ['일', '월', '화', '수', '목', '금', '토'][new Date(parsed.y, parsed.mo - 1, parsed.d).getDay()];
|
||
const vals = [yy, m, d, day];
|
||
inputs.forEach((inp, idx) => {
|
||
if (idx > 3) return;
|
||
inp.value = vals[idx];
|
||
if (inp.dataset.field) formData[inp.dataset.field] = vals[idx];
|
||
});
|
||
}
|
||
|
||
function refreshToggleRangeArrows() {
|
||
document.querySelectorAll('table.toggle-grid').forEach((table) => {
|
||
const rows = Array.from(table.rows).slice(1);
|
||
rows.forEach((row) => {
|
||
const hourCells = Array.from(row.cells).slice(1);
|
||
hourCells.forEach((cell) => cell.classList.remove('range-start', 'range-end'));
|
||
for (let i = 0; i < hourCells.length; i++) {
|
||
const currOn = hourCells[i].classList.contains('on');
|
||
if (!currOn) continue;
|
||
const prevOn = i > 0 ? hourCells[i - 1].classList.contains('on') : false;
|
||
const nextOn = i < hourCells.length - 1 ? hourCells[i + 1].classList.contains('on') : false;
|
||
if (!prevOn) hourCells[i].classList.add('range-start');
|
||
if (!nextOn) hourCells[i].classList.add('range-end');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function syncUiFromFormData() {
|
||
document.querySelectorAll('input.line-input, input.cell-input, textarea.cell-input').forEach((el) => {
|
||
const field = el.dataset.field;
|
||
if (!field) return;
|
||
const v = formData[field];
|
||
el.value = (v === undefined || v === null) ? '' : String(v);
|
||
});
|
||
document.querySelectorAll('[contenteditable="true"]').forEach((el) => {
|
||
const field = el.dataset.field;
|
||
if (!field) return;
|
||
const v = formData[field];
|
||
el.textContent = (v === undefined || v === null) ? '' : String(v);
|
||
});
|
||
document.querySelectorAll('.checkbox-box').forEach((box) => {
|
||
const field = box.dataset.field;
|
||
const on = !!formData[field];
|
||
box.classList.toggle('checked', on);
|
||
});
|
||
document.querySelectorAll('.toggle-cell').forEach((cell) => {
|
||
const field = cell.dataset.field;
|
||
if (!field) return;
|
||
const count = Number(formData[field] || 0) || 0;
|
||
cell.dataset.count = String(count);
|
||
cell.classList.toggle('on', count > 0);
|
||
cell.classList.remove('selecting-start');
|
||
});
|
||
refreshToggleRangeArrows();
|
||
document.querySelectorAll('textarea.multiline-cell-input').forEach((ta) => adjustTextareaVerticalAlign(ta));
|
||
}
|
||
|
||
function onWorkDateChange(dateKey) {
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(dateKey || ''))) return;
|
||
if (currentEditingDateKey && !isApplyingSnapshot) {
|
||
datedFormSnapshots[currentEditingDateKey] = JSON.parse(JSON.stringify(formData));
|
||
}
|
||
currentEditingDateKey = dateKey;
|
||
const snap = datedFormSnapshots[dateKey] ? JSON.parse(JSON.stringify(datedFormSnapshots[dateKey])) : {};
|
||
const prevDateKey = getPrevDateKey(dateKey);
|
||
const hasPrevWaterDoc = hasSavedWaterDoc(prevDateKey);
|
||
const hasCurrentWaterDoc = hasSavedWaterDoc(dateKey);
|
||
// 저장된 폐수 문서가 없는 날짜는 수질 입력값을 빈 상태로 시작
|
||
if (!hasSavedWaterDoc(dateKey)) {
|
||
Object.keys(snap).forEach((field) => {
|
||
const meta = fieldMeta[field] || {};
|
||
if (String(meta.tab || '') === 'water-tab') delete snap[field];
|
||
});
|
||
}
|
||
|
||
isApplyingSnapshot = true;
|
||
Object.keys(formData).forEach((k) => delete formData[k]);
|
||
Object.assign(formData, snap);
|
||
// 전날 저장 문서가 없고 현재 날짜도 미저장 상태라면 전일지침 자동값을 강제로 비움
|
||
if (!hasPrevWaterDoc && !hasCurrentWaterDoc) {
|
||
clearCurrentWaterPrevFields();
|
||
}
|
||
// 현재 날짜에 이미 수질 입력값이 있으면 자동 이월로 덮어쓰지 않음
|
||
const hasCurrentWaterValues = Object.keys(formData).some((field) => {
|
||
const meta = fieldMeta[field] || {};
|
||
if (String(meta.tab || '') !== 'water-tab') return false;
|
||
const v = formData[field];
|
||
return v !== undefined && v !== null && String(v).trim() !== '';
|
||
});
|
||
if (!hasCurrentWaterValues) {
|
||
applyPrevDayTodayToCurrentPrev(dateKey);
|
||
}
|
||
syncUiFromFormData();
|
||
setFormDateInputsByDateKey('water-tab', dateKey);
|
||
setFormDateInputsByDateKey('air-tab', dateKey);
|
||
setupWaterSection3AutoCalc();
|
||
centerAlignWaterRightSectionInputs();
|
||
renderCalcDataTab();
|
||
isApplyingSnapshot = false;
|
||
saveToStorage();
|
||
}
|
||
|
||
function initWorkDatePicker() {
|
||
const picker = document.getElementById('work-date-picker');
|
||
if (!picker) return;
|
||
const baseDate = currentEditingDateKey || getTodayDateKeyLocal();
|
||
picker.value = baseDate;
|
||
onWorkDateChange(baseDate);
|
||
}
|
||
|
||
function getCurrentDateKey(tabId) {
|
||
if (currentEditingDateKey) return currentEditingDateKey;
|
||
const tab = document.getElementById(tabId || 'water-tab');
|
||
const dateLine = tab ? tab.querySelector('.clear-both') : null;
|
||
if (!dateLine) return new Date().toISOString().slice(0, 10);
|
||
const inputs = dateLine.querySelectorAll('input.line-input');
|
||
if (inputs.length < 3) return new Date().toISOString().slice(0, 10);
|
||
const yRaw = cleanText(inputs[0].value);
|
||
const mRaw = cleanText(inputs[1].value).padStart(2, '0');
|
||
const dRaw = cleanText(inputs[2].value).padStart(2, '0');
|
||
let year = yRaw;
|
||
if (yRaw.length === 1) year = '202' + yRaw;
|
||
if (yRaw.length === 2) year = '20' + yRaw;
|
||
if (!/^\d{4}$/.test(year) || !/^\d{2}$/.test(mRaw) || !/^\d{2}$/.test(dRaw)) return new Date().toISOString().slice(0, 10);
|
||
return year + '-' + mRaw + '-' + dRaw;
|
||
}
|
||
|
||
function getActiveTabId() {
|
||
const active = document.querySelector('.tab-content.active');
|
||
return active ? active.id : 'water-tab';
|
||
}
|
||
|
||
function getTabLabel(tabId) {
|
||
if (tabId === 'water-tab') return '폐수배출시설';
|
||
if (tabId === 'air-tab') return '대기배출시설';
|
||
if (tabId === 'data-calc-tab') return '데이터(계산)';
|
||
if (tabId === 'data-pdf-tab') return '데이터(pdf)';
|
||
return tabId;
|
||
}
|
||
|
||
function toNumber(val) {
|
||
if (typeof val === 'number') return Number.isFinite(val) ? val : null;
|
||
const num = parseFloat(String(val).replace(/,/g, '').trim());
|
||
return Number.isFinite(num) ? num : null;
|
||
}
|
||
|
||
function normalizeItemLabel(label) {
|
||
return String(label || '').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function pickTripletByKeywordFromMap(mapObj, keyword) {
|
||
const map = mapObj || {};
|
||
const keyNorm = cleanText(keyword).replace(/\s+/g, '');
|
||
const scoreTriplet = (triplet) => {
|
||
if (!triplet || typeof triplet !== 'object') return -1;
|
||
const p = toNumber(triplet.prev);
|
||
const t = toNumber(triplet.today);
|
||
const d = toNumber(triplet.daily);
|
||
if (p === null && t === null && d === null) return -1;
|
||
return Math.abs(p || 0) + Math.abs(t || 0) + Math.abs(d || 0);
|
||
};
|
||
const candidates = Object.entries(map).filter(([k]) =>
|
||
cleanText(k).replace(/\s+/g, '').includes(keyNorm)
|
||
);
|
||
if (!candidates.length) return null;
|
||
const selected = candidates
|
||
.filter(([, v]) => scoreTriplet(v) >= 0)
|
||
.sort((a, b) => {
|
||
const aKey = cleanText(a[0]).replace(/\s+/g, '');
|
||
const bKey = cleanText(b[0]).replace(/\s+/g, '');
|
||
const aExact = aKey === keyNorm ? 2 : (aKey.startsWith(keyNorm) ? 1 : 0);
|
||
const bExact = bKey === keyNorm ? 2 : (bKey.startsWith(keyNorm) ? 1 : 0);
|
||
if (aExact !== bExact) return bExact - aExact;
|
||
return scoreTriplet(b[1]) - scoreTriplet(a[1]);
|
||
})[0] || candidates[0];
|
||
const t = selected[1] || {};
|
||
return {
|
||
prev: toNumber(t.prev) || 0,
|
||
today: toNumber(t.today) || 0,
|
||
daily: toNumber(t.daily) || 0
|
||
};
|
||
}
|
||
|
||
function getPrevDateKey(dateKey) {
|
||
const p = parseDateKey(dateKey);
|
||
if (!p) return '';
|
||
const d = new Date(p.y, p.mo - 1, p.d);
|
||
d.setDate(d.getDate() - 1);
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||
const day = String(d.getDate()).padStart(2, '0');
|
||
return y + '-' + m + '-' + day;
|
||
}
|
||
|
||
function parseCalcLabel(metaLabel) {
|
||
const raw = String(metaLabel || '');
|
||
if (!raw.includes('3. 용수 공급원별 사용량과 폐수배출량')) return null;
|
||
const parts = raw.split('/').map((s) => cleanText(s)).filter(Boolean);
|
||
if (parts.length < 2) return null;
|
||
const col = parts[parts.length - 1];
|
||
const item = normalizeItemLabel(parts.slice(1, -1).join(' / '));
|
||
return { item, col };
|
||
}
|
||
|
||
function getWaterPrevTodayFieldPairs() {
|
||
const out = [];
|
||
const waterTab = document.getElementById('water-tab');
|
||
const sectionTitle = waterTab
|
||
? Array.from(waterTab.querySelectorAll('.section-title')).find((el) =>
|
||
cleanText(el.textContent).includes('3. 용수 공급원별 사용량과 폐수배출량'))
|
||
: null;
|
||
const table = sectionTitle ? sectionTitle.nextElementSibling : null;
|
||
if (!table || table.tagName !== 'TABLE') return out;
|
||
|
||
const seen = new Set();
|
||
const getField = (input) => (input && input.dataset ? String(input.dataset.field || '') : '');
|
||
const pushPair = (prevField, todayField) => {
|
||
if (!prevField || !todayField) return;
|
||
if (seen.has(prevField)) return;
|
||
seen.add(prevField);
|
||
out.push({ prevField, todayField });
|
||
};
|
||
|
||
Array.from(table.rows).forEach((tr) => {
|
||
const rowInputs = Array.from(tr.querySelectorAll('input.cell-input, textarea.cell-input'));
|
||
// 좌측(용수): 각 행의 첫 3칸이 전일/금일/일량
|
||
if (rowInputs.length >= 2) {
|
||
pushPair(getField(rowInputs[0]), getField(rowInputs[1]));
|
||
}
|
||
// 우측(폐수): 같은 행에 우측 입력칸이 있을 때 마지막 3칸이 전일/금일/일량
|
||
if (rowInputs.length >= 6) {
|
||
pushPair(getField(rowInputs[rowInputs.length - 3]), getField(rowInputs[rowInputs.length - 2]));
|
||
}
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function getSnapshotValueByMetaLabel(snapshot, currentField) {
|
||
const targetMeta = fieldMeta[currentField] || {};
|
||
const targetLabel = cleanText(targetMeta.label || '');
|
||
const targetTab = String(targetMeta.tab || '');
|
||
if (!targetLabel || targetTab !== 'water-tab') return null;
|
||
const foundField = Object.keys(snapshot || {}).find((f) => {
|
||
const m = fieldMeta[f] || {};
|
||
return String(m.tab || '') === 'water-tab' && cleanText(m.label || '') === targetLabel;
|
||
});
|
||
if (!foundField) return null;
|
||
const v = snapshot[foundField];
|
||
if (v === undefined || v === null || String(v).trim() === '') return null;
|
||
return v;
|
||
}
|
||
|
||
function getSnapshotValueByParsedMeta(snapshot, currentField, wantedColText) {
|
||
const targetMeta = fieldMeta[currentField] || {};
|
||
if (String(targetMeta.tab || '') !== 'water-tab') return null;
|
||
const targetParsed = parseCalcLabel(targetMeta.label);
|
||
if (!targetParsed) return null;
|
||
const targetItemNorm = cleanText(targetParsed.item).replace(/\s+/g, '');
|
||
|
||
const foundField = Object.keys(snapshot || {}).find((f) => {
|
||
const m = fieldMeta[f] || {};
|
||
if (String(m.tab || '') !== 'water-tab') return false;
|
||
const p = parseCalcLabel(m.label);
|
||
if (!p) return false;
|
||
const itemNorm = cleanText(p.item).replace(/\s+/g, '');
|
||
if (itemNorm !== targetItemNorm) return false;
|
||
return cleanText(p.col).includes(wantedColText);
|
||
});
|
||
if (!foundField) return null;
|
||
const v = snapshot[foundField];
|
||
if (v === undefined || v === null || String(v).trim() === '') return null;
|
||
return v;
|
||
}
|
||
|
||
function applyPrevDayTodayToCurrentPrev(dateKey) {
|
||
const prevDateKey = getPrevDateKey(dateKey);
|
||
if (!prevDateKey) return false;
|
||
const hasSavedPrevWaterDoc = (savedDocs || []).some((d) =>
|
||
d && d.tabId === 'water-tab' && String(d.date || '') === prevDateKey
|
||
);
|
||
// 전날 문서를 저장(PDF)하지 않았다면 이월하지 않음
|
||
if (!hasSavedPrevWaterDoc) return false;
|
||
const prevSnap = datedFormSnapshots[prevDateKey];
|
||
if (!prevSnap || typeof prevSnap !== 'object') return false;
|
||
|
||
const pairs = getWaterPrevTodayFieldPairs();
|
||
let changed = false;
|
||
pairs.forEach(({ prevField, todayField }) => {
|
||
let fromVal = prevSnap[todayField];
|
||
// 사용자가 요구한 규칙: 전일 빈칸은 절대 가져오지 않음.
|
||
// 따라서 보조 매칭 없이 '같은 칸(todayField)' 값이 있을 때만 복사.
|
||
if (fromVal === undefined || fromVal === null || String(fromVal).trim() === '') return;
|
||
const currentPrev = formData[prevField];
|
||
if (currentPrev !== undefined && currentPrev !== null && String(currentPrev).trim() !== '') return;
|
||
const nextVal = String(fromVal);
|
||
if (String(formData[prevField] ?? '') !== nextVal) {
|
||
formData[prevField] = nextVal;
|
||
changed = true;
|
||
}
|
||
});
|
||
|
||
return changed;
|
||
}
|
||
|
||
function getTripletFromDateSnapshot(dateKey, keyword) {
|
||
const snap = datedFormSnapshots[dateKey];
|
||
if (!snap || typeof snap !== 'object') return null;
|
||
let prev = null;
|
||
let today = null;
|
||
let daily = null;
|
||
const keyNorm = cleanText(keyword).replace(/\s+/g, '');
|
||
Object.keys(snap).forEach((field) => {
|
||
const meta = fieldMeta[field] || {};
|
||
const num = toNumber(snap[field]);
|
||
if (num === null) return;
|
||
const parsed = parseCalcLabel(meta.label);
|
||
if (!parsed) return;
|
||
const itemNorm = cleanText(parsed.item).replace(/\s+/g, '');
|
||
if (!itemNorm.includes(keyNorm)) return;
|
||
const colNorm = cleanText(parsed.col);
|
||
if (colNorm.includes('전일 지침')) prev = num;
|
||
if (colNorm.includes('금일 지침')) today = num;
|
||
if (colNorm.includes('배출량 및 사용량')) daily = num;
|
||
});
|
||
if (prev === null && today === null && daily === null) return null;
|
||
return {
|
||
prev: prev === null ? 0 : prev,
|
||
today: today === null ? 0 : today,
|
||
daily: daily === null ? 0 : daily
|
||
};
|
||
}
|
||
|
||
function renderCalcByDateTable() {
|
||
const dailyBody = document.getElementById('calc-daily-body');
|
||
const monthlyBody = document.getElementById('calc-monthly-body');
|
||
if (!dailyBody || !monthlyBody) return;
|
||
dailyBody.innerHTML = '';
|
||
monthlyBody.innerHTML = '';
|
||
|
||
const savedWaterDateSet = new Set(
|
||
(savedDocs || [])
|
||
.filter((d) => d && d.tabId === 'water-tab')
|
||
.map((d) => String(d.date || ''))
|
||
.filter((v) => /^\d{4}-\d{2}-\d{2}$/.test(v))
|
||
);
|
||
let dateKeys = Object.keys(dailyMeterHistory || {})
|
||
.filter((k) => savedWaterDateSet.has(k))
|
||
.sort((a, b) => (a < b ? -1 : 1));
|
||
const dateFrom = (document.getElementById('calc-date-from') || {}).value || '';
|
||
const dateTo = (document.getElementById('calc-date-to') || {}).value || '';
|
||
const monthFrom = (document.getElementById('calc-month-from') || {}).value || '';
|
||
const monthTo = (document.getElementById('calc-month-to') || {}).value || '';
|
||
const kindFilter = ((document.getElementById('calc-kind-filter') || {}).value || 'all');
|
||
const dailyVisible = document.getElementById('calc-daily-view') && document.getElementById('calc-daily-view').style.display !== 'none';
|
||
const monthlyVisible = document.getElementById('calc-monthly-view') && document.getElementById('calc-monthly-view').style.display !== 'none';
|
||
|
||
if (dailyVisible) {
|
||
if (dateFrom) dateKeys = dateKeys.filter((k) => k >= dateFrom);
|
||
if (dateTo) dateKeys = dateKeys.filter((k) => k <= dateTo);
|
||
}
|
||
if (monthlyVisible) {
|
||
if (monthFrom) dateKeys = dateKeys.filter((k) => k.slice(0, 7) >= monthFrom);
|
||
if (monthTo) dateKeys = dateKeys.filter((k) => k.slice(0, 7) <= monthTo);
|
||
}
|
||
|
||
if (dateKeys.length === 0) {
|
||
const tr = document.createElement('tr');
|
||
const td = document.createElement('td');
|
||
td.colSpan = 11;
|
||
td.textContent = '일별 집계 데이터가 없습니다.';
|
||
tr.appendChild(td);
|
||
dailyBody.appendChild(tr);
|
||
const tr2 = document.createElement('tr');
|
||
const td2 = document.createElement('td');
|
||
td2.colSpan = 11;
|
||
td2.textContent = '월별 집계 데이터가 없습니다.';
|
||
tr2.appendChild(td2);
|
||
monthlyBody.appendChild(tr2);
|
||
return;
|
||
}
|
||
|
||
function pickValueByKeyword(obj, keyword) {
|
||
const keys = Object.keys(obj || {});
|
||
const found = keys.find((k) => cleanText(k).includes(keyword));
|
||
return found ? (toNumber(obj[found]) || 0) : 0;
|
||
}
|
||
|
||
function dailyMetrics(dateKey) {
|
||
const curr = dailyMeterHistory[dateKey] || {};
|
||
const usageTodayMap = curr.usageToday || {};
|
||
const wasteTodayMap = curr.wasteToday || {};
|
||
const usageDailyMap = curr.usageDaily || {};
|
||
const wasteDailyMap = curr.wasteDaily || {};
|
||
const wasteDailyExact = curr.wasteDailyExact || {};
|
||
const wasteTripletExact = curr.wasteTripletExact || {};
|
||
const reuseTripletStored = curr.reuseTriplet || null;
|
||
const lifeTripletStored = curr.lifeTriplet || null;
|
||
const consumeTripletStored = curr.consumeTriplet || null;
|
||
const prevDateKey = getPrevDateKey(dateKey);
|
||
const prev = (prevDateKey && dailyMeterHistory[prevDateKey]) ? dailyMeterHistory[prevDateKey] : {};
|
||
const prevUsageMap = prev.usageToday || {};
|
||
const prevWasteMap = prev.wasteToday || {};
|
||
|
||
function getExactTriplet(keyword) {
|
||
return pickTripletByKeywordFromMap(wasteTripletExact, keyword);
|
||
}
|
||
|
||
function makeAgg(todayMap, prevMap) {
|
||
const keys = Object.keys(todayMap);
|
||
let prevSum = 0;
|
||
let todaySum = 0;
|
||
let dailySum = 0;
|
||
keys.forEach((k) => {
|
||
const t = toNumber(todayMap[k]) || 0;
|
||
const p = toNumber(prevMap[k]) || 0;
|
||
prevSum += p;
|
||
todaySum += t;
|
||
dailySum += (t - p);
|
||
});
|
||
return { count: keys.length, prevSum, todaySum, dailySum };
|
||
}
|
||
|
||
const usageAgg = makeAgg(usageTodayMap, prevUsageMap);
|
||
const wasteAgg = makeAgg(wasteTodayMap, prevWasteMap);
|
||
const reuseExact = getExactTriplet('재사용량');
|
||
const lifeExact = getExactTriplet('생활용수량');
|
||
const consumeExact = getExactTriplet('소모');
|
||
const wasteOccExact = getExactTriplet('폐수발생량');
|
||
const wasteOccurredPrev = wasteOccExact ? wasteOccExact.prev : pickValueByKeyword(prevWasteMap, '폐수발생량');
|
||
const wasteOccurredToday = wasteOccExact ? wasteOccExact.today : pickValueByKeyword(wasteTodayMap, '폐수발생량');
|
||
const wasteOccurredDailyDirect = toNumber(curr.wasteOccurredUse) !== null
|
||
? toNumber(curr.wasteOccurredUse)
|
||
: toNumber(curr.wasteOccurredDaily);
|
||
const wasteOccurredDailyFromDaily = pickValueByKeyword(wasteDailyMap, '폐수발생량');
|
||
const wasteOccurredDailyFallback = pickValueByKeyword(wasteTodayMap, '폐수발생량') - pickValueByKeyword(prevWasteMap, '폐수발생량');
|
||
const wasteOccurredDaily = (wasteOccurredDailyDirect !== null)
|
||
? wasteOccurredDailyDirect
|
||
: (wasteOccurredDailyFromDaily !== 0 ? wasteOccurredDailyFromDaily : wasteOccurredDailyFallback);
|
||
let consumeDaily = consumeExact ? consumeExact.daily : null;
|
||
if (consumeDaily === null && consumeTripletStored) consumeDaily = toNumber(consumeTripletStored.daily);
|
||
consumeDaily = (consumeDaily === null
|
||
? ((pickValueByKeyword(wasteDailyExact, '소모'))
|
||
|| (pickValueByKeyword(wasteDailyMap, '소모'))
|
||
|| (pickValueByKeyword(wasteTodayMap, '소모') - pickValueByKeyword(prevWasteMap, '소모')))
|
||
: consumeDaily) || 0;
|
||
let reusePrev = (reuseExact ? reuseExact.prev : null);
|
||
let reuseToday = (reuseExact ? reuseExact.today : null);
|
||
let effluentDaily = (reuseExact ? reuseExact.daily : null);
|
||
if (reusePrev === null || reuseToday === null || effluentDaily === null) {
|
||
const s = reuseTripletStored || {};
|
||
if (reusePrev === null) reusePrev = toNumber(s.prev);
|
||
if (reuseToday === null) reuseToday = toNumber(s.today);
|
||
if (effluentDaily === null) effluentDaily = toNumber(s.daily);
|
||
}
|
||
reusePrev = (reusePrev === null ? pickValueByKeyword(prevWasteMap, '재사용량') : reusePrev) || 0;
|
||
reuseToday = (reuseToday === null ? pickValueByKeyword(wasteTodayMap, '재사용량') : reuseToday) || 0;
|
||
effluentDaily = (effluentDaily === null
|
||
? ((pickValueByKeyword(wasteDailyExact, '재사용량')) || (pickValueByKeyword(wasteTodayMap, '재사용량') - pickValueByKeyword(prevWasteMap, '재사용량')))
|
||
: effluentDaily) || 0;
|
||
|
||
let lifeDaily = (lifeExact ? lifeExact.daily : null);
|
||
if (lifeDaily === null && lifeTripletStored) lifeDaily = toNumber(lifeTripletStored.daily);
|
||
lifeDaily = (lifeDaily === null
|
||
? ((pickValueByKeyword(wasteDailyMap, '생활용수량')) || (pickValueByKeyword(wasteTodayMap, '생활용수량') - pickValueByKeyword(prevWasteMap, '생활용수량')))
|
||
: lifeDaily) || 0;
|
||
const usagePrevDirect = toNumber(curr.usageTotalPrev);
|
||
const usageTodayDirect = toNumber(curr.usageTotalToday);
|
||
const usageDailyDirect = toNumber(curr.usageTotalUse);
|
||
|
||
let usagePrev = usageAgg.prevSum;
|
||
let usageToday = usageAgg.todaySum;
|
||
let usageDaily = Object.keys(usageDailyMap).length ? Object.values(usageDailyMap).reduce((a, v) => a + (toNumber(v) || 0), 0) : usageAgg.dailySum;
|
||
|
||
// 요구사항: 일별 표 전일/금일/사용량은 용수 사용량 '계' 행 값을 최우선 사용
|
||
usagePrev = (usagePrevDirect !== null) ? usagePrevDirect : usagePrev;
|
||
usageToday = (usageTodayDirect !== null) ? usageTodayDirect : usageToday;
|
||
usageDaily = (usageDailyDirect !== null) ? usageDailyDirect : usageDaily;
|
||
|
||
return {
|
||
dateKey,
|
||
monthKey: dateKey.slice(0, 7),
|
||
usagePrev,
|
||
usageToday,
|
||
usageDaily,
|
||
wasteDaily: wasteOccurredDaily || 0,
|
||
consumeDaily,
|
||
reusePrev,
|
||
reuseToday,
|
||
effluentDaily,
|
||
lifeDaily,
|
||
sludgeDaily: toNumber(curr.sludgeDaily) || 0,
|
||
days: 1
|
||
};
|
||
}
|
||
|
||
const dailyRows = dateKeys.map((dateKey) => dailyMetrics(dateKey));
|
||
const isUsageOnly = kindFilter === 'usage';
|
||
const isWasteOnly = kindFilter === 'waste';
|
||
const formatMetric = (key, value) => {
|
||
if (isUsageOnly) {
|
||
const usageKeys = new Set(['usagePrev', 'usageToday', 'usageDaily']);
|
||
if (!usageKeys.has(key)) return '-';
|
||
}
|
||
if (isWasteOnly) {
|
||
const wasteKeys = new Set(['wasteDaily', 'consumeDaily', 'reusePrev', 'reuseToday', 'effluentDaily', 'lifeDaily', 'sludgeDaily']);
|
||
if (!wasteKeys.has(key)) return '-';
|
||
}
|
||
return Number(value || 0).toLocaleString();
|
||
};
|
||
dailyRows.forEach((m) => {
|
||
const tr = document.createElement('tr');
|
||
[
|
||
m.dateKey,
|
||
m.usagePrev,
|
||
m.usageToday,
|
||
m.usageDaily,
|
||
m.wasteDaily,
|
||
m.consumeDaily,
|
||
m.reusePrev,
|
||
m.reuseToday,
|
||
m.effluentDaily,
|
||
m.lifeDaily,
|
||
m.sludgeDaily
|
||
].forEach((v, idx) => {
|
||
const td = document.createElement('td');
|
||
if (idx === 0) {
|
||
td.textContent = String(v);
|
||
} else {
|
||
const keys = ['usagePrev', 'usageToday', 'usageDaily', 'wasteDaily', 'consumeDaily', 'reusePrev', 'reuseToday', 'effluentDaily', 'lifeDaily', 'sludgeDaily'];
|
||
td.textContent = formatMetric(keys[idx - 1], v);
|
||
}
|
||
tr.appendChild(td);
|
||
});
|
||
dailyBody.appendChild(tr);
|
||
});
|
||
|
||
if (dailyRows.length > 0) {
|
||
const metricKeys = ['usagePrev', 'usageToday', 'usageDaily', 'wasteDaily', 'consumeDaily', 'reusePrev', 'reuseToday', 'effluentDaily', 'lifeDaily', 'sludgeDaily'];
|
||
const totals = {};
|
||
metricKeys.forEach((k) => { totals[k] = 0; });
|
||
dailyRows.forEach((m) => {
|
||
metricKeys.forEach((k) => {
|
||
totals[k] += Number(m[k] || 0);
|
||
});
|
||
});
|
||
const averages = {};
|
||
metricKeys.forEach((k) => {
|
||
averages[k] = totals[k] / dailyRows.length;
|
||
});
|
||
|
||
const appendSummaryRow = (label, metrics) => {
|
||
const tr = document.createElement('tr');
|
||
tr.style.fontWeight = '700';
|
||
const tdLabel = document.createElement('td');
|
||
tdLabel.textContent = label;
|
||
tdLabel.colSpan = 1;
|
||
tr.appendChild(tdLabel);
|
||
metricKeys.forEach((k) => {
|
||
const td = document.createElement('td');
|
||
const raw = Number(metrics[k] || 0);
|
||
td.textContent = formatMetric(k, raw);
|
||
tr.appendChild(td);
|
||
});
|
||
dailyBody.appendChild(tr);
|
||
};
|
||
|
||
appendSummaryRow('합계', totals);
|
||
appendSummaryRow('평균', averages);
|
||
}
|
||
|
||
const monthlyMap = new Map();
|
||
dailyRows.forEach((m) => {
|
||
if (!monthlyMap.has(m.monthKey)) {
|
||
monthlyMap.set(m.monthKey, {
|
||
monthKey: m.monthKey,
|
||
usagePrev: 0,
|
||
usageToday: 0,
|
||
usageDaily: 0,
|
||
wasteDaily: 0,
|
||
consumeDaily: 0,
|
||
reusePrev: 0,
|
||
reuseToday: 0,
|
||
effluentDaily: 0,
|
||
lifeDaily: 0,
|
||
sludgeDaily: 0,
|
||
days: 0
|
||
});
|
||
}
|
||
const agg = monthlyMap.get(m.monthKey);
|
||
agg.usagePrev += m.usagePrev;
|
||
agg.usageToday += m.usageToday;
|
||
agg.usageDaily += m.usageDaily;
|
||
agg.wasteDaily += m.wasteDaily;
|
||
agg.consumeDaily += m.consumeDaily;
|
||
agg.reusePrev += m.reusePrev;
|
||
agg.reuseToday += m.reuseToday;
|
||
agg.effluentDaily += m.effluentDaily;
|
||
agg.lifeDaily += m.lifeDaily;
|
||
agg.sludgeDaily += m.sludgeDaily;
|
||
agg.days += 1;
|
||
});
|
||
|
||
Array.from(monthlyMap.values())
|
||
.sort((a, b) => (a.monthKey < b.monthKey ? -1 : 1))
|
||
.forEach((m) => {
|
||
const tr = document.createElement('tr');
|
||
[
|
||
m.monthKey,
|
||
m.usagePrev,
|
||
m.usageToday,
|
||
m.usageDaily,
|
||
m.monthKey,
|
||
m.usagePrev,
|
||
m.usageToday,
|
||
m.usageDaily,
|
||
m.wasteDaily,
|
||
m.consumeDaily,
|
||
m.reusePrev,
|
||
m.reuseToday,
|
||
m.effluentDaily,
|
||
m.lifeDaily,
|
||
m.sludgeDaily
|
||
].forEach((v, idx) => {
|
||
const td = document.createElement('td');
|
||
if (idx === 0) {
|
||
td.textContent = String(v);
|
||
} else {
|
||
const keys = ['usagePrev', 'usageToday', 'usageDaily', 'wasteDaily', 'consumeDaily', 'reusePrev', 'reuseToday', 'effluentDaily', 'lifeDaily', 'sludgeDaily'];
|
||
td.textContent = formatMetric(keys[idx - 1], v);
|
||
}
|
||
tr.appendChild(td);
|
||
});
|
||
monthlyBody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
function renderCalcDataTab() {
|
||
renderCalcByDateTable();
|
||
}
|
||
|
||
function onCalcFilterChange() {
|
||
renderCalcByDateTable();
|
||
}
|
||
|
||
function clearCalcFilters() {
|
||
const d1 = document.getElementById('calc-date-from');
|
||
const d2 = document.getElementById('calc-date-to');
|
||
const m1 = document.getElementById('calc-month-from');
|
||
const m2 = document.getElementById('calc-month-to');
|
||
const k = document.getElementById('calc-kind-filter');
|
||
if (d1) d1.value = '';
|
||
if (d2) d2.value = '';
|
||
if (m1) m1.value = '';
|
||
if (m2) m2.value = '';
|
||
if (k) k.value = 'all';
|
||
renderCalcByDateTable();
|
||
}
|
||
|
||
function printCalcData() {
|
||
window.print();
|
||
}
|
||
|
||
function openCalcView(view) {
|
||
const dailyView = document.getElementById('calc-daily-view');
|
||
const monthlyView = document.getElementById('calc-monthly-view');
|
||
const dailyBtn = document.getElementById('calc-view-daily-btn');
|
||
const monthlyBtn = document.getElementById('calc-view-monthly-btn');
|
||
const dailyRange = document.getElementById('calc-daily-range');
|
||
const monthRange = document.getElementById('calc-month-range');
|
||
if (!dailyView || !monthlyView || !dailyBtn || !monthlyBtn) return;
|
||
const isMonthly = view === 'monthly';
|
||
dailyView.style.display = isMonthly ? 'none' : '';
|
||
monthlyView.style.display = isMonthly ? '' : 'none';
|
||
if (dailyRange) dailyRange.style.display = isMonthly ? 'none' : '';
|
||
if (monthRange) monthRange.style.display = isMonthly ? '' : 'none';
|
||
dailyBtn.classList.toggle('text-blue-700', !isMonthly);
|
||
dailyBtn.classList.toggle('text-slate-700', isMonthly);
|
||
monthlyBtn.classList.toggle('text-blue-700', isMonthly);
|
||
monthlyBtn.classList.toggle('text-slate-700', !isMonthly);
|
||
renderCalcByDateTable();
|
||
}
|
||
|
||
function collectCurrentWaterMeters() {
|
||
const wasteRowKeywords = ['폐수발생량', '폐수배출량', '냉각수량', '소모', '재사용량', '생활용수량'];
|
||
const usageTodayMap = {};
|
||
const wasteTodayMap = {};
|
||
const usageDailyMap = {};
|
||
const wasteDailyMap = {};
|
||
Object.keys(formData).forEach((field) => {
|
||
const meta = fieldMeta[field] || {};
|
||
const tab = String(meta.tab || '');
|
||
if (tab !== 'water-tab') return;
|
||
const parsed = parseCalcLabel(meta.label);
|
||
if (!parsed) return;
|
||
const num = toNumber(formData[field]);
|
||
if (num === null) return;
|
||
const item = parsed.item || '미분류';
|
||
const isWaste = wasteRowKeywords.some((k) => item.includes(k));
|
||
if (parsed.col.includes('금일 지침')) {
|
||
if (isWaste) wasteTodayMap[item] = num;
|
||
else usageTodayMap[item] = num;
|
||
}
|
||
if (parsed.col.includes('배출량 및 사용량')) {
|
||
if (isWaste) wasteDailyMap[item] = num;
|
||
}
|
||
if (parsed.col.includes('사용량(㎥/일)') && !parsed.col.includes('배출량')) {
|
||
if (!isWaste) usageDailyMap[item] = num;
|
||
}
|
||
});
|
||
return { usageTodayMap, wasteTodayMap, usageDailyMap, wasteDailyMap };
|
||
}
|
||
|
||
function collectCurrentSludgeAmount() {
|
||
let total = 0;
|
||
let hasAny = false;
|
||
Object.keys(formData).forEach((field) => {
|
||
const meta = fieldMeta[field] || {};
|
||
if (String(meta.tab || '') !== 'water-tab') return;
|
||
const label = String(meta.label || '');
|
||
if (!label.includes('4. 슬러지의 발생량 및 처리량')) return;
|
||
if (!label.includes('슬러지발생량')) return;
|
||
const num = toNumber(formData[field]);
|
||
if (num !== null) {
|
||
total += num;
|
||
hasAny = true;
|
||
}
|
||
});
|
||
return hasAny ? total : 0;
|
||
}
|
||
|
||
function collectCurrentWasteOccurrenceDaily() {
|
||
let value = null;
|
||
Object.keys(formData).forEach((field) => {
|
||
const meta = fieldMeta[field] || {};
|
||
if (String(meta.tab || '') !== 'water-tab') return;
|
||
const label = String(meta.label || '');
|
||
if (!label.includes('3. 용수 공급원별 사용량과 폐수배출량')) return;
|
||
if (!label.includes('폐수발생량')) return;
|
||
if (!label.includes('배출량 및 사용량')) return;
|
||
const num = toNumber(formData[field]);
|
||
if (num !== null) value = num;
|
||
});
|
||
return value === null ? 0 : value;
|
||
}
|
||
|
||
function collectCurrentWasteOccurrenceTriplet() {
|
||
let prev = null;
|
||
let today = null;
|
||
let daily = null;
|
||
|
||
// 1) 우선: 표의 실제 위치(폐수발생량 행의 7/8/9열)에서 직접 추출
|
||
const waterTab = document.getElementById('water-tab');
|
||
const sectionTitle = waterTab
|
||
? Array.from(waterTab.querySelectorAll('.section-title')).find((el) =>
|
||
cleanText(el.textContent).includes('3. 용수 공급원별 사용량과 폐수배출량'))
|
||
: null;
|
||
const table = sectionTitle ? sectionTitle.nextElementSibling : null;
|
||
if (table && table.tagName === 'TABLE') {
|
||
const grid = [];
|
||
const spanMap = [];
|
||
Array.from(table.rows).forEach((tr, r) => {
|
||
if (!grid[r]) grid[r] = [];
|
||
let c = 0;
|
||
while (spanMap[c] && spanMap[c] > 0) { spanMap[c] -= 1; c += 1; }
|
||
Array.from(tr.cells).forEach((cell) => {
|
||
while (spanMap[c] && spanMap[c] > 0) { spanMap[c] -= 1; c += 1; }
|
||
const cs = cell.colSpan || 1;
|
||
const rs = cell.rowSpan || 1;
|
||
for (let cc = 0; cc < cs; cc++) {
|
||
grid[r][c + cc] = cell;
|
||
if (rs > 1) spanMap[c + cc] = rs - 1;
|
||
}
|
||
c += cs;
|
||
});
|
||
});
|
||
for (let r = 0; r < grid.length; r++) {
|
||
const row = grid[r] || [];
|
||
const label = cleanText(row[6] ? row[6].textContent : '').replace(/\s+/g, '');
|
||
if (!label.includes('폐수발생량')) continue;
|
||
const pInput = row[7] ? row[7].querySelector('input.cell-input, textarea.cell-input') : null;
|
||
const tInput = row[8] ? row[8].querySelector('input.cell-input, textarea.cell-input') : null;
|
||
const dInput = row[9] ? row[9].querySelector('input.cell-input, textarea.cell-input') : null;
|
||
prev = toNumber(pInput ? pInput.value : null);
|
||
today = toNumber(tInput ? tInput.value : null);
|
||
daily = toNumber(dInput ? dInput.value : null);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 2) 보조: 혹시 못 읽었으면 기존 formData 라벨 기반으로 보완
|
||
if (prev === null || today === null || daily === null) {
|
||
Object.keys(formData).forEach((field) => {
|
||
const meta = fieldMeta[field] || {};
|
||
if (String(meta.tab || '') !== 'water-tab') return;
|
||
const label = String(meta.label || '');
|
||
if (!label.includes('3. 용수 공급원별 사용량과 폐수배출량')) return;
|
||
if (!label.includes('폐수발생량')) return;
|
||
const num = toNumber(formData[field]);
|
||
if (num === null) return;
|
||
if (label.includes('전일 지침') && prev === null) prev = num;
|
||
if (label.includes('금일 지침') && today === null) today = num;
|
||
if (label.includes('배출량 및 사용량') && daily === null) daily = num;
|
||
});
|
||
}
|
||
|
||
return {
|
||
prev: prev === null ? 0 : prev,
|
||
today: today === null ? 0 : today,
|
||
daily: daily === null ? 0 : daily
|
||
};
|
||
}
|
||
|
||
function collectCurrentUsageTotalTriplet() {
|
||
let prev = null;
|
||
let today = null;
|
||
let daily = null;
|
||
const waterTab = document.getElementById('water-tab');
|
||
const sectionTitle = waterTab
|
||
? Array.from(waterTab.querySelectorAll('.section-title')).find((el) =>
|
||
cleanText(el.textContent).includes('3. 용수 공급원별 사용량과 폐수배출량'))
|
||
: null;
|
||
const table = sectionTitle ? sectionTitle.nextElementSibling : null;
|
||
if (table && table.tagName === 'TABLE') {
|
||
const rows = Array.from(table.rows);
|
||
rows.forEach((tr) => {
|
||
const cells = Array.from(tr.cells);
|
||
const headText = cleanText((cells[0] ? cells[0].textContent : '') + ' ' + (cells[1] ? cells[1].textContent : '')).replace(/\s+/g, '');
|
||
if (!headText.includes('계')) return;
|
||
const inputs = tr.querySelectorAll('input.cell-input, textarea.cell-input');
|
||
if (inputs.length >= 3) {
|
||
prev = toNumber(inputs[0].value);
|
||
today = toNumber(inputs[1].value);
|
||
daily = toNumber(inputs[2].value);
|
||
}
|
||
});
|
||
}
|
||
return {
|
||
prev: prev === null ? 0 : prev,
|
||
today: today === null ? 0 : today,
|
||
daily: daily === null ? 0 : daily
|
||
};
|
||
}
|
||
|
||
function collectCurrentWasteDailyExactMap() {
|
||
const out = {};
|
||
const waterTab = document.getElementById('water-tab');
|
||
const sectionTitle = waterTab
|
||
? Array.from(waterTab.querySelectorAll('.section-title')).find((el) =>
|
||
cleanText(el.textContent).includes('3. 용수 공급원별 사용량과 폐수배출량'))
|
||
: null;
|
||
const table = sectionTitle ? sectionTitle.nextElementSibling : null;
|
||
if (!table || table.tagName !== 'TABLE') return out;
|
||
|
||
const targets = ['폐수발생량', '폐수배출량', '냉각수량', '소모', '재사용량', '생활용수량'];
|
||
|
||
// rowspan이 있어도 정확히 오른쪽 6/9열을 찾기 위해 논리 그리드 구성
|
||
const grid = [];
|
||
const spanMap = [];
|
||
Array.from(table.rows).forEach((tr, r) => {
|
||
if (!grid[r]) grid[r] = [];
|
||
let c = 0;
|
||
while (spanMap[c] && spanMap[c] > 0) { spanMap[c] -= 1; c += 1; }
|
||
Array.from(tr.cells).forEach((cell) => {
|
||
while (spanMap[c] && spanMap[c] > 0) { spanMap[c] -= 1; c += 1; }
|
||
const cs = cell.colSpan || 1;
|
||
const rs = cell.rowSpan || 1;
|
||
for (let cc = 0; cc < cs; cc++) {
|
||
grid[r][c + cc] = cell;
|
||
if (rs > 1) spanMap[c + cc] = rs - 1;
|
||
}
|
||
c += cs;
|
||
});
|
||
});
|
||
|
||
for (let r = 0; r < grid.length; r++) {
|
||
const row = grid[r] || [];
|
||
const labelCell = row[6];
|
||
const label = cleanText(labelCell ? labelCell.textContent : '');
|
||
if (!targets.some((k) => label.includes(k))) continue;
|
||
const dailyCell = row[9];
|
||
const input = dailyCell ? dailyCell.querySelector('input.cell-input, textarea.cell-input') : null;
|
||
const daily = toNumber(input ? input.value : null);
|
||
if (daily === null) continue;
|
||
out[label] = daily;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function collectCurrentWasteTripletExactMap() {
|
||
const out = {};
|
||
const waterTab = document.getElementById('water-tab');
|
||
const sectionTitle = waterTab
|
||
? Array.from(waterTab.querySelectorAll('.section-title')).find((el) =>
|
||
cleanText(el.textContent).includes('3. 용수 공급원별 사용량과 폐수배출량'))
|
||
: null;
|
||
const table = sectionTitle ? sectionTitle.nextElementSibling : null;
|
||
if (!table || table.tagName !== 'TABLE') return out;
|
||
|
||
const targets = ['폐수발생량', '폐수배출량', '냉각수량', '소모', '재사용량', '생활용수량'];
|
||
const norm = (s) => cleanText(s).replace(/\s+/g, '');
|
||
const rows = Array.from(table.rows);
|
||
rows.forEach((tr) => {
|
||
const cells = Array.from(tr.cells);
|
||
for (let i = 0; i < cells.length; i++) {
|
||
const rawLabel = cleanText(cells[i].textContent || '');
|
||
const label = norm(rawLabel);
|
||
if (!label) continue;
|
||
if (!targets.some((k) => label.includes(norm(k)))) continue;
|
||
const prevInput = cells[i + 1] ? cells[i + 1].querySelector('input.cell-input, textarea.cell-input') : null;
|
||
const todayInput = cells[i + 2] ? cells[i + 2].querySelector('input.cell-input, textarea.cell-input') : null;
|
||
const dailyInput = cells[i + 3] ? cells[i + 3].querySelector('input.cell-input, textarea.cell-input') : null;
|
||
if (!prevInput || !todayInput || !dailyInput) continue;
|
||
out[rawLabel] = {
|
||
prev: toNumber(prevInput.value) || 0,
|
||
today: toNumber(todayInput.value) || 0,
|
||
daily: toNumber(dailyInput.value) || 0
|
||
};
|
||
break;
|
||
}
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function collectCurrentWasteRowTriplet(keyword) {
|
||
const waterTab = document.getElementById('water-tab');
|
||
const sectionTitle = waterTab
|
||
? Array.from(waterTab.querySelectorAll('.section-title')).find((el) =>
|
||
cleanText(el.textContent).includes('3. 용수 공급원별 사용량과 폐수배출량'))
|
||
: null;
|
||
const table = sectionTitle ? sectionTitle.nextElementSibling : null;
|
||
if (!table || table.tagName !== 'TABLE') return { prev: 0, today: 0, daily: 0 };
|
||
|
||
const norm = (s) => cleanText(s).replace(/\s+/g, '');
|
||
const key = norm(keyword);
|
||
const rows = Array.from(table.rows);
|
||
for (const tr of rows) {
|
||
const cells = Array.from(tr.cells);
|
||
for (let i = 0; i < cells.length; i++) {
|
||
const label = norm(cells[i].textContent || '');
|
||
if (!label || !label.includes(key)) continue;
|
||
const prevInput = cells[i + 1] ? cells[i + 1].querySelector('input.cell-input, textarea.cell-input') : null;
|
||
const todayInput = cells[i + 2] ? cells[i + 2].querySelector('input.cell-input, textarea.cell-input') : null;
|
||
const dailyInput = cells[i + 3] ? cells[i + 3].querySelector('input.cell-input, textarea.cell-input') : null;
|
||
if (!prevInput || !todayInput || !dailyInput) continue;
|
||
return {
|
||
prev: toNumber(prevInput.value) || 0,
|
||
today: toNumber(todayInput.value) || 0,
|
||
daily: toNumber(dailyInput.value) || 0
|
||
};
|
||
}
|
||
}
|
||
|
||
// 2) 보조: fieldMeta + formData (3칸 모두 찾은 경우만 사용)
|
||
let prevMeta = null;
|
||
let todayMeta = null;
|
||
let dailyMeta = null;
|
||
Object.keys(formData).forEach((field) => {
|
||
const meta = fieldMeta[field] || {};
|
||
const tab = String(meta.tab || '');
|
||
const label = String(meta.label || '');
|
||
if (tab !== 'water-tab') return;
|
||
if (!label.includes('3. 용수 공급원별 사용량과 폐수배출량')) return;
|
||
if (!label.includes(keyword)) return;
|
||
const num = toNumber(formData[field]);
|
||
if (num === null) return;
|
||
if (label.includes('전일 지침')) prevMeta = num;
|
||
if (label.includes('금일 지침')) todayMeta = num;
|
||
if (label.includes('배출량 및 사용량')) dailyMeta = num;
|
||
});
|
||
if (prevMeta !== null && todayMeta !== null && dailyMeta !== null) {
|
||
return { prev: prevMeta, today: todayMeta, daily: dailyMeta };
|
||
}
|
||
|
||
return { prev: 0, today: 0, daily: 0 };
|
||
}
|
||
|
||
function commitCurrentDateMeterHistory(dateKey) {
|
||
if (!dateKey) return;
|
||
const { usageTodayMap, wasteTodayMap, usageDailyMap, wasteDailyMap } = collectCurrentWaterMeters();
|
||
const wasteDailyExact = collectCurrentWasteDailyExactMap();
|
||
const wasteTripletExact = collectCurrentWasteTripletExactMap();
|
||
const reuseTriplet = pickTripletByKeywordFromMap(wasteTripletExact, '재사용량') || collectCurrentWasteRowTriplet('재사용량');
|
||
const lifeTriplet = pickTripletByKeywordFromMap(wasteTripletExact, '생활용수량') || collectCurrentWasteRowTriplet('생활용수량');
|
||
const consumeTriplet = pickTripletByKeywordFromMap(wasteTripletExact, '소모') || collectCurrentWasteRowTriplet('소모');
|
||
const sludgeDaily = collectCurrentSludgeAmount();
|
||
const wasteOccurredTriplet = pickTripletByKeywordFromMap(wasteTripletExact, '폐수발생량') || collectCurrentWasteOccurrenceTriplet();
|
||
const wasteOccurredDaily = toNumber(wasteOccurredTriplet.daily) || collectCurrentWasteOccurrenceDaily();
|
||
const usageTotalTriplet = collectCurrentUsageTotalTriplet();
|
||
dailyMeterHistory[dateKey] = {
|
||
usageToday: usageTodayMap,
|
||
wasteToday: wasteTodayMap,
|
||
usageDaily: usageDailyMap,
|
||
wasteDaily: wasteDailyMap,
|
||
wasteDailyExact,
|
||
wasteTripletExact,
|
||
reuseTriplet,
|
||
lifeTriplet,
|
||
consumeTriplet,
|
||
sludgeDaily,
|
||
wasteOccurredDaily,
|
||
wasteOccurredPrev: wasteOccurredTriplet.prev,
|
||
wasteOccurredToday: wasteOccurredTriplet.today,
|
||
wasteOccurredUse: wasteOccurredTriplet.daily,
|
||
usageTotalPrev: usageTotalTriplet.prev,
|
||
usageTotalToday: usageTotalTriplet.today,
|
||
usageTotalUse: usageTotalTriplet.daily,
|
||
updatedAt: new Date().toISOString()
|
||
};
|
||
saveToStorage();
|
||
renderCalcByDateTable();
|
||
}
|
||
|
||
function syncCloneValues(sourceRoot, clonedRoot) {
|
||
const sourceInputs = sourceRoot.querySelectorAll('input, textarea');
|
||
const clonedInputs = clonedRoot.querySelectorAll('input, textarea');
|
||
sourceInputs.forEach((src, idx) => {
|
||
const dst = clonedInputs[idx];
|
||
if (!dst) return;
|
||
if (src.type === 'checkbox' || src.type === 'radio') {
|
||
dst.checked = src.checked;
|
||
} else {
|
||
dst.value = src.value;
|
||
dst.setAttribute('value', src.value);
|
||
}
|
||
});
|
||
|
||
const sourceEditable = sourceRoot.querySelectorAll('[contenteditable="true"]');
|
||
const clonedEditable = clonedRoot.querySelectorAll('[contenteditable="true"]');
|
||
sourceEditable.forEach((src, idx) => {
|
||
const dst = clonedEditable[idx];
|
||
if (dst) dst.textContent = src.textContent;
|
||
});
|
||
}
|
||
|
||
function buildPdfContainer(tabId) {
|
||
const tab = document.getElementById(tabId);
|
||
const container = document.createElement('div');
|
||
container.style.background = '#fff';
|
||
container.style.padding = '0';
|
||
const pages = tab ? tab.querySelectorAll('.page') : [];
|
||
pages.forEach((page, idx) => {
|
||
const clone = page.cloneNode(true);
|
||
clone.style.margin = '0';
|
||
clone.style.boxShadow = 'none';
|
||
clone.style.width = '210mm';
|
||
clone.style.height = '297mm';
|
||
clone.style.minHeight = '297mm';
|
||
clone.style.boxSizing = 'border-box';
|
||
clone.style.overflow = 'hidden';
|
||
clone.style.pageBreakAfter = (idx === pages.length - 1) ? 'auto' : 'always';
|
||
clone.querySelectorAll('.editable-cell:focus').forEach((el) => el.style.outline = 'none');
|
||
syncCloneValues(page, clone);
|
||
container.appendChild(clone);
|
||
});
|
||
return container;
|
||
}
|
||
|
||
async function generatePdfBlob(tabId, filename) {
|
||
const tab = document.getElementById(tabId);
|
||
const pages = tab ? Array.from(tab.querySelectorAll('.page')) : [];
|
||
if (pages.length === 0) throw new Error('PDF로 저장할 페이지를 찾을 수 없습니다.');
|
||
const requiredPageCount = tabId === 'water-tab' ? 2 : (tabId === 'air-tab' ? 1 : pages.length);
|
||
const targetPages = pages.slice(0, requiredPageCount);
|
||
if (targetPages.length !== requiredPageCount) {
|
||
throw new Error('페이지 수가 올바르지 않습니다. 탭 내용을 확인해 주세요.');
|
||
}
|
||
|
||
const jsPDFCtor = (window.jspdf && window.jspdf.jsPDF) || window.jsPDF;
|
||
const canManual = !!(window.html2canvas && jsPDFCtor);
|
||
if (!canManual) throw new Error('PDF 라이브러리를 불러오지 못했습니다.');
|
||
|
||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||
const pdf = new jsPDFCtor({ orientation: 'portrait', unit: 'mm', format: 'a4', compress: true });
|
||
|
||
for (let i = 0; i < targetPages.length; i++) {
|
||
const pageEl = targetPages[i];
|
||
const canvas = await window.html2canvas(pageEl, {
|
||
scale: 2,
|
||
useCORS: true,
|
||
backgroundColor: '#ffffff'
|
||
});
|
||
const imgData = canvas.toDataURL('image/jpeg', 0.98);
|
||
if (i > 0) pdf.addPage('a4', 'portrait');
|
||
pdf.addImage(imgData, 'JPEG', 0, 0, 210, 297, undefined, 'FAST');
|
||
}
|
||
return pdf.output('blob');
|
||
}
|
||
|
||
function upsertDocMeta(meta) {
|
||
const idx = savedDocs.findIndex((d) => d.date === meta.date && d.tabId === meta.tabId);
|
||
if (idx >= 0) {
|
||
savedDocs[idx] = { ...savedDocs[idx], ...meta };
|
||
} else {
|
||
savedDocs.push(meta);
|
||
}
|
||
savedDocs.sort((a, b) => (a.date < b.date ? 1 : -1) || (a.createdAt < b.createdAt ? 1 : -1));
|
||
}
|
||
|
||
function renderDataTable() {
|
||
const body = document.getElementById('data-table-body');
|
||
if (!body) return;
|
||
body.innerHTML = '';
|
||
const docs = selectedDocDate ? savedDocs.filter((d) => d.date === selectedDocDate) : savedDocs.slice();
|
||
if (docs.length === 0) {
|
||
const tr = document.createElement('tr');
|
||
const td = document.createElement('td');
|
||
td.colSpan = 4;
|
||
td.textContent = selectedDocDate ? '선택한 날짜의 문서가 없습니다.' : '저장된 문서가 없습니다.';
|
||
tr.appendChild(td);
|
||
body.appendChild(tr);
|
||
updateSelectedDateLabel();
|
||
return;
|
||
}
|
||
docs.forEach((doc) => {
|
||
const tr = document.createElement('tr');
|
||
const tdDate = document.createElement('td');
|
||
const tdType = document.createElement('td');
|
||
const tdName = document.createElement('td');
|
||
const tdAction = document.createElement('td');
|
||
tdDate.textContent = doc.date;
|
||
tdType.textContent = doc.title;
|
||
tdName.textContent = doc.fileName;
|
||
tdAction.innerHTML =
|
||
'<button class="bg-indigo-600 text-white px-2 py-1 rounded text-[8pt] mr-1" onclick="previewSavedDoc(\'' + doc.id + '\')">미리보기</button>' +
|
||
'<button class="bg-sky-600 text-white px-2 py-1 rounded text-[8pt] mr-1" onclick="openSavedDoc(\'' + doc.id + '\')">새창</button>' +
|
||
'<button class="bg-emerald-600 text-white px-2 py-1 rounded text-[8pt] mr-1" onclick="downloadSavedDoc(\'' + doc.id + '\')">다운로드</button>' +
|
||
'<button class="bg-rose-600 text-white px-2 py-1 rounded text-[8pt]" onclick="deleteSavedDoc(\'' + doc.id + '\')">삭제</button>';
|
||
tr.appendChild(tdDate);
|
||
tr.appendChild(tdType);
|
||
tr.appendChild(tdName);
|
||
tr.appendChild(tdAction);
|
||
body.appendChild(tr);
|
||
});
|
||
updateSelectedDateLabel();
|
||
}
|
||
|
||
function updateSelectedDateLabel() {
|
||
const el = document.getElementById('doc-cal-selected');
|
||
if (!el) return;
|
||
el.textContent = '선택 날짜: ' + (selectedDocDate || '전체');
|
||
}
|
||
|
||
function parseDateKey(dateKey) {
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(dateKey || ''));
|
||
if (!m) return null;
|
||
return { y: Number(m[1]), mo: Number(m[2]), d: Number(m[3]) };
|
||
}
|
||
|
||
function initDocCalendar() {
|
||
const base = selectedDocDate || (savedDocs[0] && savedDocs[0].date) || getCurrentDateKey('water-tab');
|
||
const parsed = parseDateKey(base);
|
||
if (!parsed) return;
|
||
calendarViewYear = parsed.y;
|
||
calendarViewMonth = parsed.mo;
|
||
if (!selectedDocDate && savedDocs.length > 0) selectedDocDate = savedDocs[0].date;
|
||
renderDocCalendar();
|
||
}
|
||
|
||
function moveDocCalendar(step) {
|
||
if (!calendarViewYear || !calendarViewMonth) initDocCalendar();
|
||
let y = calendarViewYear;
|
||
let m = calendarViewMonth + step;
|
||
if (m < 1) { y -= 1; m = 12; }
|
||
if (m > 12) { y += 1; m = 1; }
|
||
calendarViewYear = y;
|
||
calendarViewMonth = m;
|
||
renderDocCalendar();
|
||
}
|
||
|
||
function clearDocDateFilter() {
|
||
selectedDocDate = '';
|
||
renderDocCalendar();
|
||
renderDataTable();
|
||
}
|
||
|
||
function renderDocCalendar() {
|
||
const label = document.getElementById('doc-cal-month-label');
|
||
const grid = document.getElementById('doc-cal-grid');
|
||
if (!label || !grid) return;
|
||
if (!calendarViewYear || !calendarViewMonth) return;
|
||
|
||
label.textContent = calendarViewYear + '년 ' + String(calendarViewMonth).padStart(2, '0') + '월';
|
||
grid.innerHTML = '';
|
||
|
||
const first = new Date(calendarViewYear, calendarViewMonth - 1, 1);
|
||
const startWeek = first.getDay();
|
||
const lastDate = new Date(calendarViewYear, calendarViewMonth, 0).getDate();
|
||
const docSet = new Set(savedDocs.map((d) => d.date));
|
||
|
||
for (let i = 0; i < startWeek; i++) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'doc-cal-cell is-empty';
|
||
grid.appendChild(empty);
|
||
}
|
||
|
||
for (let day = 1; day <= lastDate; day++) {
|
||
const cell = document.createElement('div');
|
||
const dateKey = calendarViewYear + '-' + String(calendarViewMonth).padStart(2, '0') + '-' + String(day).padStart(2, '0');
|
||
cell.className = 'doc-cal-cell';
|
||
cell.textContent = String(day);
|
||
if (docSet.has(dateKey)) cell.classList.add('has-doc');
|
||
if (selectedDocDate === dateKey) cell.classList.add('selected');
|
||
cell.addEventListener('click', () => {
|
||
selectedDocDate = dateKey;
|
||
renderDocCalendar();
|
||
renderDataTable();
|
||
});
|
||
grid.appendChild(cell);
|
||
}
|
||
updateSelectedDateLabel();
|
||
}
|
||
|
||
async function saveDocument() {
|
||
const tabId = getActiveTabId();
|
||
if (tabId.startsWith('data-')) {
|
||
alert('수질 또는 대기 탭에서 저장해 주세요.');
|
||
return;
|
||
}
|
||
const dateKey = getCurrentDateKey(tabId);
|
||
const title = getTabLabel(tabId);
|
||
const fileName = dateKey + '_' + title.replace(/[^\w가-힣()-]+/g, '_') + '.pdf';
|
||
const existing = savedDocs.find((d) => d.date === dateKey && d.tabId === tabId);
|
||
const id = existing ? existing.id : ('doc_' + Date.now());
|
||
|
||
try {
|
||
if (tabId === 'water-tab') {
|
||
commitCurrentDateMeterHistory(dateKey);
|
||
}
|
||
const blob = await generatePdfBlob(tabId, fileName);
|
||
await putPdfBlob({ id, blob });
|
||
|
||
const meta = { id, date: dateKey, tabId, title, fileName, createdAt: new Date().toISOString() };
|
||
upsertDocMeta(meta);
|
||
reconcileDailyHistoryWithSavedDocs();
|
||
saveToStorage();
|
||
if (!selectedDocDate) selectedDocDate = dateKey;
|
||
const parsed = parseDateKey(dateKey);
|
||
if (parsed) {
|
||
calendarViewYear = parsed.y;
|
||
calendarViewMonth = parsed.mo;
|
||
}
|
||
renderDocCalendar();
|
||
renderDataTable();
|
||
alert(existing ? '같은 날짜/형식 문서를 덮어써서 저장했습니다.' : 'PDF가 저장되었습니다.');
|
||
} catch (e) {
|
||
console.error(e);
|
||
alert('PDF 저장 중 오류가 발생했습니다. 브라우저를 새로고침 후 다시 시도해 주세요.');
|
||
}
|
||
}
|
||
|
||
function printCurrentTab() {
|
||
window.print();
|
||
}
|
||
|
||
function exportData() {
|
||
const payload = {
|
||
exportedAt: new Date().toISOString(),
|
||
current: { formData, fieldMeta, dailyMeterHistory, datedFormSnapshots, activeDateKey: currentEditingDateKey },
|
||
docs: savedDocs
|
||
};
|
||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = '환경시설_운영일지_데이터.json';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function importData(event) {
|
||
const file = event.target.files && event.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
try {
|
||
const parsed = JSON.parse(String(reader.result || '{}'));
|
||
if (parsed.current || parsed.docs) {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed.current || {}));
|
||
localStorage.setItem(DOC_INDEX_KEY, JSON.stringify(parsed.docs || []));
|
||
} else {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ formData: parsed, fieldMeta: {} }));
|
||
}
|
||
location.reload();
|
||
} catch (e) {
|
||
alert('JSON 파일 형식이 올바르지 않습니다.');
|
||
}
|
||
};
|
||
reader.readAsText(file, 'utf-8');
|
||
event.target.value = '';
|
||
}
|
||
|
||
function resetData() {
|
||
const tabId = getActiveTabId();
|
||
if (tabId.startsWith('data-')) {
|
||
alert('수질일지 또는 대기록부 탭에서 사용해 주세요.');
|
||
return;
|
||
}
|
||
const tabLabel = tabId === 'water-tab' ? '수질일지' : '대기록부';
|
||
if (!confirm('현재 페이지(' + tabLabel + ') 입력만 초기화할까요?')) return;
|
||
|
||
const targets = Object.keys(fieldMeta).filter((field) => String((fieldMeta[field] || {}).tab || '') === tabId);
|
||
targets.forEach((field) => {
|
||
delete formData[field];
|
||
});
|
||
|
||
if (currentEditingDateKey && datedFormSnapshots[currentEditingDateKey]) {
|
||
const snap = { ...datedFormSnapshots[currentEditingDateKey] };
|
||
targets.forEach((field) => {
|
||
delete snap[field];
|
||
});
|
||
datedFormSnapshots[currentEditingDateKey] = snap;
|
||
}
|
||
|
||
syncUiFromFormData();
|
||
if (tabId === 'water-tab') {
|
||
setupWaterSection3AutoCalc();
|
||
centerAlignWaterRightSectionInputs();
|
||
}
|
||
renderCalcDataTab();
|
||
saveToStorage();
|
||
}
|
||
|
||
function deleteSelectedDateData() {
|
||
const dateKey = currentEditingDateKey || getCurrentDateKey('water-tab');
|
||
if (!dateKey) {
|
||
alert('삭제할 날짜를 먼저 선택해 주세요.');
|
||
return;
|
||
}
|
||
if (!confirm(dateKey + ' 입력 데이터를 삭제할까요? (PDF 문서는 유지됩니다)')) return;
|
||
|
||
delete datedFormSnapshots[dateKey];
|
||
delete dailyMeterHistory[dateKey];
|
||
|
||
if (currentEditingDateKey === dateKey) {
|
||
Object.keys(formData).forEach((k) => delete formData[k]);
|
||
// 삭제 직후에도 전일지침 자동이월은 즉시 반영
|
||
applyPrevDayTodayToCurrentPrev(dateKey);
|
||
syncUiFromFormData();
|
||
setFormDateInputsByDateKey('water-tab', dateKey);
|
||
setFormDateInputsByDateKey('air-tab', dateKey);
|
||
setupWaterSection3AutoCalc();
|
||
centerAlignWaterRightSectionInputs();
|
||
}
|
||
|
||
renderCalcDataTab();
|
||
saveToStorage();
|
||
alert(dateKey + ' 데이터가 삭제되었습니다.');
|
||
}
|
||
|
||
async function previewSavedDoc(id) {
|
||
const doc = await getPdfBlobById(id);
|
||
const meta = savedDocs.find((d) => d.id === id);
|
||
if (!doc || !doc.blob) return alert('문서를 찾을 수 없습니다.');
|
||
|
||
if (currentPreviewUrl) URL.revokeObjectURL(currentPreviewUrl);
|
||
currentPreviewUrl = URL.createObjectURL(doc.blob);
|
||
|
||
const panel = document.getElementById('pdf-preview-panel');
|
||
const obj = document.getElementById('pdf-preview-object');
|
||
const frame = document.getElementById('pdf-preview-frame');
|
||
const link = document.getElementById('pdf-preview-open-link');
|
||
const title = document.getElementById('pdf-preview-title');
|
||
if (!panel || !obj || !frame || !link || !title) return;
|
||
|
||
title.textContent = meta ? meta.fileName : 'PDF 미리보기';
|
||
obj.data = currentPreviewUrl;
|
||
frame.src = currentPreviewUrl;
|
||
link.href = currentPreviewUrl;
|
||
panel.classList.remove('hidden');
|
||
}
|
||
|
||
function closePdfPreview() {
|
||
const panel = document.getElementById('pdf-preview-panel');
|
||
const obj = document.getElementById('pdf-preview-object');
|
||
const frame = document.getElementById('pdf-preview-frame');
|
||
const link = document.getElementById('pdf-preview-open-link');
|
||
if (panel) panel.classList.add('hidden');
|
||
if (obj) obj.data = '';
|
||
if (frame) frame.src = 'about:blank';
|
||
if (link) link.href = '#';
|
||
if (currentPreviewUrl) {
|
||
URL.revokeObjectURL(currentPreviewUrl);
|
||
currentPreviewUrl = null;
|
||
}
|
||
}
|
||
|
||
async function openSavedDoc(id) {
|
||
const doc = await getPdfBlobById(id);
|
||
if (!doc || !doc.blob) return alert('문서를 찾을 수 없습니다.');
|
||
const url = URL.createObjectURL(doc.blob);
|
||
window.open(url, '_blank');
|
||
}
|
||
|
||
async function downloadSavedDoc(id) {
|
||
const doc = await getPdfBlobById(id);
|
||
const meta = savedDocs.find((d) => d.id === id);
|
||
if (!doc || !doc.blob) return alert('문서를 찾을 수 없습니다.');
|
||
const url = URL.createObjectURL(doc.blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = meta ? meta.fileName : (id + '.pdf');
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
async function deleteSavedDoc(id) {
|
||
if (!confirm('선택한 문서를 삭제할까요?')) return;
|
||
const target = savedDocs.find((d) => d.id === id) || null;
|
||
await deletePdfBlobById(id);
|
||
const idx = savedDocs.findIndex((d) => d.id === id);
|
||
if (idx >= 0) savedDocs.splice(idx, 1);
|
||
reconcileDailyHistoryWithSavedDocs();
|
||
saveToStorage();
|
||
renderCalcDataTab();
|
||
renderDocCalendar();
|
||
renderDataTable();
|
||
closePdfPreview();
|
||
}
|
||
|
||
function openTab(evt, tabName) {
|
||
var i, tabcontent, tablinks;
|
||
tabcontent = document.getElementsByClassName("tab-content");
|
||
for (i = 0; i < tabcontent.length; i++) tabcontent[i].classList.remove("active");
|
||
tablinks = document.getElementsByClassName("tab-btn");
|
||
for (i = 0; i < tablinks.length; i++) tablinks[i].classList.remove("active");
|
||
document.getElementById(tabName).classList.add("active");
|
||
evt.currentTarget.classList.add("active");
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
loadFromStorage();
|
||
makeLineInputsEditable();
|
||
setupToggleGridCells();
|
||
makeTableCellsEditable();
|
||
setupWaterSection3AutoCalc();
|
||
centerAlignWaterRightSectionInputs();
|
||
setupEditableBlocks();
|
||
setupCheckboxToggle();
|
||
setupApprovalStamps();
|
||
initWorkDatePicker();
|
||
openCalcView('daily');
|
||
initDocCalendar();
|
||
renderDataTable();
|
||
window.addEventListener('resize', () => {
|
||
document.querySelectorAll('textarea.multiline-cell-input').forEach((ta) => adjustTextareaVerticalAlign(ta));
|
||
});
|
||
saveToStorage();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|