Files
issue-sample/waste/wastewater.html

3360 lines
163 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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> &nbsp; B/P 운전원 &nbsp; 직 급 : 기 사 &nbsp; 성 명 : 곽 병 목 <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">⑱ 기 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;</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>