1266 lines
48 KiB
HTML
1266 lines
48 KiB
HTML
<!doctype html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>시공 코드 조회</title>
|
||
<style>
|
||
:root {
|
||
--bg-top: #f3efe4;
|
||
--bg-bottom: #f8fbff;
|
||
--panel: rgba(255, 255, 255, 0.88);
|
||
--line: rgba(38, 56, 88, 0.14);
|
||
--text: #1b2a3a;
|
||
--muted: #607086;
|
||
--accent: #0f766e;
|
||
--accent-strong: #115e59;
|
||
--shadow: 0 24px 60px rgba(26, 43, 66, 0.12);
|
||
--mono: "JetBrains Mono", "Fira Code", monospace;
|
||
--sans: "Pretendard", "Noto Sans KR", sans-serif;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
min-height: 100vh;
|
||
font-family: var(--sans);
|
||
color: var(--text);
|
||
background:
|
||
radial-gradient(circle at top left, rgba(15, 118, 110, 0.16), transparent 28%),
|
||
radial-gradient(circle at top right, rgba(59, 130, 246, 0.12), transparent 22%),
|
||
linear-gradient(180deg, var(--bg-top), var(--bg-bottom));
|
||
}
|
||
.shell {
|
||
max-width: 2040px;
|
||
margin: 0 auto;
|
||
padding: 18px 18px 48px;
|
||
}
|
||
.table-card {
|
||
background: var(--panel);
|
||
border: 1px solid var(--line);
|
||
border-radius: 24px;
|
||
box-shadow: var(--shadow);
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
.meta-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin-top: 18px;
|
||
}
|
||
.shared-note {
|
||
margin: 0 0 14px;
|
||
padding: 12px 14px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 14px;
|
||
background: rgba(238, 248, 251, 0.92);
|
||
color: #355468;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
}
|
||
.meta-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-height: 38px;
|
||
padding: 8px 12px;
|
||
border-radius: 999px;
|
||
border: 1px solid var(--line);
|
||
background: rgba(255, 255, 255, 0.76);
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
.table-card {
|
||
padding: 18px;
|
||
}
|
||
.workspace {
|
||
display: grid;
|
||
grid-template-columns: 290px minmax(0, 1fr);
|
||
gap: 18px;
|
||
align-items: start;
|
||
}
|
||
.sidebar-card,
|
||
.content-card {
|
||
background: rgba(255, 255, 255, 0.92);
|
||
border: 1px solid var(--line);
|
||
border-radius: 18px;
|
||
padding: 18px;
|
||
}
|
||
.sidebar-card {
|
||
position: sticky;
|
||
top: 18px;
|
||
}
|
||
.sidebar-title,
|
||
.content-title {
|
||
margin: 0 0 8px;
|
||
font-size: 18px;
|
||
}
|
||
.sidebar-sub {
|
||
margin: 0 0 14px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
.sidebar-tools {
|
||
display: grid;
|
||
gap: 10px;
|
||
margin-bottom: 14px;
|
||
}
|
||
.sidebar-tools input,
|
||
.sidebar-tools select,
|
||
.sidebar-tools button {
|
||
width: 100%;
|
||
height: 42px;
|
||
border-radius: 12px;
|
||
border: 1px solid var(--line);
|
||
padding: 0 12px;
|
||
font-size: 13px;
|
||
font-family: var(--sans);
|
||
}
|
||
.sidebar-tools input {
|
||
background: rgba(255, 255, 255, 0.96);
|
||
}
|
||
.filter-field {
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
.filter-label {
|
||
padding: 0 2px;
|
||
color: #4d6276;
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
}
|
||
.sidebar-tools select {
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
-moz-appearance: none;
|
||
height: 46px;
|
||
padding: 0 42px 0 14px;
|
||
border-radius: 14px;
|
||
background:
|
||
linear-gradient(180deg, rgba(255,255,255,0.98), rgba(242,248,248,0.96)),
|
||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='%230f766e' stroke-width='2.3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||
background-repeat: no-repeat, no-repeat;
|
||
background-position: 0 0, calc(100% - 14px) 50%;
|
||
background-size: auto, 16px;
|
||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.7), 0 8px 20px rgba(15, 118, 110, 0.06);
|
||
color: #173247;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
|
||
}
|
||
.sidebar-tools select:hover,
|
||
.sidebar-tools input:hover {
|
||
border-color: rgba(15, 118, 110, 0.28);
|
||
}
|
||
.sidebar-tools select:focus,
|
||
.sidebar-tools input:focus {
|
||
outline: none;
|
||
border-color: rgba(15, 118, 110, 0.58);
|
||
box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12);
|
||
}
|
||
.sidebar-tools button {
|
||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||
color: #fff;
|
||
font-weight: 700;
|
||
border: 0;
|
||
cursor: pointer;
|
||
}
|
||
.status {
|
||
margin: 0 0 14px;
|
||
color: var(--muted);
|
||
font-size: 14px;
|
||
}
|
||
.table-wrap {
|
||
overflow: auto;
|
||
border: 1px solid var(--line);
|
||
border-radius: 18px;
|
||
background: rgba(255, 255, 255, 0.86);
|
||
max-height: 72vh;
|
||
}
|
||
table {
|
||
width: 100%;
|
||
min-width: 640px;
|
||
border-collapse: collapse;
|
||
}
|
||
thead th {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 1;
|
||
background: #eef6f6;
|
||
color: #274254;
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.12em;
|
||
}
|
||
th, td {
|
||
padding: 14px 16px;
|
||
border-bottom: 1px solid rgba(38, 56, 88, 0.08);
|
||
text-align: left;
|
||
vertical-align: top;
|
||
font-size: 14px;
|
||
}
|
||
tbody tr:hover {
|
||
background: rgba(15, 118, 110, 0.05);
|
||
}
|
||
tbody tr.project-row {
|
||
cursor: pointer;
|
||
}
|
||
tbody tr.project-row.selected {
|
||
background: rgba(15, 118, 110, 0.12);
|
||
}
|
||
.code {
|
||
font-family: var(--mono);
|
||
font-weight: 700;
|
||
color: #17496f;
|
||
white-space: nowrap;
|
||
}
|
||
.empty {
|
||
padding: 42px 18px;
|
||
text-align: center;
|
||
color: var(--muted);
|
||
font-size: 14px;
|
||
}
|
||
.detail-grid {
|
||
display: grid;
|
||
gap: 18px;
|
||
grid-template-columns: minmax(0, 1fr);
|
||
margin-top: 18px;
|
||
}
|
||
.detail-card {
|
||
background: rgba(255, 255, 255, 0.92);
|
||
border: 1px solid var(--line);
|
||
border-radius: 18px;
|
||
padding: 18px;
|
||
}
|
||
.detail-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 14px;
|
||
}
|
||
.detail-title {
|
||
margin: 0;
|
||
font-size: 20px;
|
||
}
|
||
.detail-sub {
|
||
margin: 6px 0 0;
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
}
|
||
.detail-actions button {
|
||
height: 40px;
|
||
border-radius: 12px;
|
||
border: 1px solid var(--line);
|
||
padding: 0 14px;
|
||
background: #f3faf9;
|
||
color: var(--accent-strong);
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
.kv-table {
|
||
width: 100%;
|
||
min-width: 0;
|
||
border-collapse: collapse;
|
||
}
|
||
.kv-table th,
|
||
.kv-table td {
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid rgba(38, 56, 88, 0.08);
|
||
text-align: left;
|
||
font-size: 14px;
|
||
}
|
||
.kv-table th {
|
||
width: 180px;
|
||
background: #f5f8fb;
|
||
color: #425466;
|
||
}
|
||
.merge-table {
|
||
width: 100%;
|
||
min-width: 1500px;
|
||
border-collapse: collapse;
|
||
table-layout: fixed;
|
||
}
|
||
.merge-table th,
|
||
.merge-table td {
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid rgba(38, 56, 88, 0.08);
|
||
text-align: center;
|
||
vertical-align: top;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
.merge-table th {
|
||
background: #eef6f6;
|
||
color: #274254;
|
||
font-size: 12px;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
white-space: nowrap;
|
||
}
|
||
.merge-table thead tr:first-child th {
|
||
text-align: center;
|
||
border-bottom: 1px solid rgba(38, 56, 88, 0.08);
|
||
}
|
||
.merge-table thead tr:nth-child(2) th {
|
||
text-align: center;
|
||
font-size: 11px;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.merge-table td {
|
||
white-space: normal;
|
||
overflow: visible;
|
||
text-overflow: clip;
|
||
word-break: keep-all;
|
||
}
|
||
.bridge-summary {
|
||
white-space: normal;
|
||
word-break: keep-all;
|
||
overflow: visible;
|
||
text-overflow: clip;
|
||
}
|
||
.cell-num {
|
||
white-space: normal;
|
||
text-align: center;
|
||
word-break: break-word;
|
||
line-height: 1.35;
|
||
}
|
||
.cell-text {
|
||
text-align: center;
|
||
}
|
||
.cell-wide {
|
||
text-align: left;
|
||
}
|
||
.remark-button {
|
||
width: 32px;
|
||
height: 32px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 999px;
|
||
background: #f3faf9;
|
||
color: var(--accent-strong);
|
||
font-size: 16px;
|
||
font-weight: 800;
|
||
cursor: pointer;
|
||
}
|
||
.remark-button:hover {
|
||
filter: brightness(0.97);
|
||
}
|
||
.remark-button:disabled {
|
||
opacity: 0.45;
|
||
cursor: default;
|
||
}
|
||
.modal-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
background: rgba(20, 30, 40, 0.42);
|
||
z-index: 50;
|
||
}
|
||
.modal-backdrop.open {
|
||
display: flex;
|
||
}
|
||
.modal-card {
|
||
width: min(560px, 100%);
|
||
background: rgba(255, 255, 255, 0.98);
|
||
border: 1px solid var(--line);
|
||
border-radius: 20px;
|
||
box-shadow: var(--shadow);
|
||
padding: 20px;
|
||
}
|
||
.modal-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.modal-title {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
}
|
||
.modal-close {
|
||
width: 36px;
|
||
height: 36px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 999px;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
font-size: 18px;
|
||
}
|
||
.modal-body {
|
||
color: var(--text);
|
||
font-size: 14px;
|
||
line-height: 1.7;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
.plan-button {
|
||
height: 32px;
|
||
padding: 0 10px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 999px;
|
||
background: #eef6f6;
|
||
color: #17496f;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
}
|
||
.plan-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin-top: 12px;
|
||
}
|
||
.plan-table th,
|
||
.plan-table td {
|
||
padding: 8px 10px;
|
||
border: 1px solid rgba(38, 56, 88, 0.08);
|
||
font-size: 13px;
|
||
text-align: left;
|
||
vertical-align: top;
|
||
}
|
||
.plan-table th {
|
||
background: #f4f8fb;
|
||
color: #425466;
|
||
width: 140px;
|
||
}
|
||
.plan-section-title {
|
||
margin: 18px 0 8px;
|
||
font-size: 14px;
|
||
font-weight: 800;
|
||
color: #17496f;
|
||
}
|
||
.inline-plan-wrap {
|
||
padding: 10px 0;
|
||
}
|
||
.inline-plan-wrap .plan-table {
|
||
margin-top: 0;
|
||
}
|
||
.inline-plan-wrap .plan-table th,
|
||
.inline-plan-wrap .plan-table td {
|
||
text-align: center;
|
||
}
|
||
.inline-plan-wrap .plan-table td.plan-left,
|
||
.inline-plan-wrap .plan-table th.plan-left {
|
||
text-align: left;
|
||
}
|
||
@media (max-width: 720px) {
|
||
.shell { padding: 18px 12px 32px; }
|
||
.table-card { border-radius: 20px; }
|
||
.workspace { grid-template-columns: 1fr; }
|
||
.sidebar-card { position: static; }
|
||
.table-wrap { max-height: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main class="shell">
|
||
<div class="shared-note">`8092` 페이지입니다. 시공코드 목록, 계약정보, 공사개요, 교량 매칭 정보를 조회하는 전용 화면입니다.</div>
|
||
<section class="table-card">
|
||
<div class="meta-row" style="margin: 0 0 14px;">
|
||
<div class="meta-chip" id="countChip">데이터 대기 중</div>
|
||
<div class="meta-chip" id="sourceChip">DB 캐시 확인 중</div>
|
||
</div>
|
||
<div class="workspace">
|
||
<aside class="sidebar-card">
|
||
<h2 class="sidebar-title">프로젝트 목록</h2>
|
||
<p class="sidebar-sub">왼쪽 목록에서 시공코드나 약칭을 누르면 오른쪽에 계약정보 표가 표시됩니다.</p>
|
||
<div class="sidebar-tools">
|
||
<input id="searchInput" type="search" placeholder="시공코드 또는 약칭 검색">
|
||
<div class="filter-field">
|
||
<label class="filter-label" for="contractTypeFilter">계약종류</label>
|
||
<select id="contractTypeFilter">
|
||
<option value="">전체 보기</option>
|
||
</select>
|
||
</div>
|
||
<div class="filter-field">
|
||
<label class="filter-label" for="applicationTypeFilter">적용형식</label>
|
||
<select id="applicationTypeFilter">
|
||
<option value="">전체 보기</option>
|
||
</select>
|
||
</div>
|
||
<button id="reloadButton" type="button">시공코드 목록 새로 가져오기</button>
|
||
</div>
|
||
<p class="status" id="statusText">프로젝트 목록을 불러오는 중입니다.</p>
|
||
<div class="table-wrap">
|
||
<table style="min-width: 0;">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 150px;">시공코드</th>
|
||
<th>약칭</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="resultBody">
|
||
<tr><td colspan="2" class="empty">불러오는 중...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</aside>
|
||
|
||
<div class="content-card">
|
||
<div class="detail-grid">
|
||
<div class="detail-card" style="padding:0; border:none; background:transparent;">
|
||
<div class="detail-head">
|
||
<div>
|
||
<h2 class="content-title" id="detailTitle">계약정보 표</h2>
|
||
<p class="detail-sub" id="detailMeta">시공코드나 약칭을 클릭하면 DB에 저장된 계약정보를 먼저 보여줍니다.</p>
|
||
</div>
|
||
<div class="detail-actions">
|
||
<button id="detailSyncButton" type="button">새 정보 다시 가져오기</button>
|
||
</div>
|
||
</div>
|
||
<table class="kv-table">
|
||
<tbody id="detailBody">
|
||
<tr><td colspan="2" class="empty">선택된 시공코드가 없습니다.</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="detail-card">
|
||
<div class="detail-head">
|
||
<div>
|
||
<h2 class="content-title">교량 매칭 정보</h2>
|
||
<p class="detail-sub" id="bridgeMeta">공사규모와 공사개요를 교량명 기준으로 매칭해서 보여줍니다.</p>
|
||
</div>
|
||
</div>
|
||
<div class="table-wrap" style="max-height:none;">
|
||
<table class="merge-table">
|
||
<colgroup>
|
||
<col style="width: 110px;">
|
||
<col style="width: 110px;">
|
||
<col style="width: 96px;">
|
||
<col style="width: 120px;">
|
||
<col style="width: 58px;">
|
||
<col style="width: 58px;">
|
||
<col style="width: 58px;">
|
||
<col style="width: 58px;">
|
||
<col style="width: 84px;">
|
||
<col style="width: 84px;">
|
||
<col style="width: 64px;">
|
||
<col style="width: 64px;">
|
||
<col style="width: 58px;">
|
||
<col style="width: 58px;">
|
||
<col style="width: 58px;">
|
||
<col style="width: 58px;">
|
||
<col style="width: 240px;">
|
||
<col style="width: 64px;">
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
<th rowspan="2">교량명</th>
|
||
<th rowspan="2">적용형식</th>
|
||
<th rowspan="2">시공상태</th>
|
||
<th rowspan="2">공사기간</th>
|
||
<th colspan="2">연장</th>
|
||
<th colspan="2">폭원</th>
|
||
<th colspan="2">형고</th>
|
||
<th colspan="2">경간구성</th>
|
||
<th rowspan="2">GIRDER</th>
|
||
<th rowspan="2">가로보</th>
|
||
<th rowspan="2">패널</th>
|
||
<th rowspan="2">철근</th>
|
||
<th rowspan="2">교량위치</th>
|
||
<th rowspan="2">특이사항</th>
|
||
</tr>
|
||
<tr>
|
||
<th>하행</th>
|
||
<th>상행</th>
|
||
<th>하행</th>
|
||
<th>상행</th>
|
||
<th>지점부</th>
|
||
<th>중앙부</th>
|
||
<th>하행</th>
|
||
<th>상행</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="bridgeBody">
|
||
<tr><td colspan="19" class="empty">선택된 시공코드가 없습니다.</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
<div class="modal-backdrop" id="remarkModal" aria-hidden="true">
|
||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="remarkModalTitle">
|
||
<div class="modal-head">
|
||
<h3 class="modal-title" id="remarkModalTitle">특이사항</h3>
|
||
<button class="modal-close" id="remarkModalClose" type="button" aria-label="팝업 닫기">×</button>
|
||
</div>
|
||
<div class="modal-body" id="remarkModalBody">내용이 없습니다.</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-backdrop" id="planModal" aria-hidden="true">
|
||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="planModalTitle">
|
||
<div class="modal-head">
|
||
<h3 class="modal-title" id="planModalTitle">공사시행계획서</h3>
|
||
<button class="modal-close" id="planModalClose" type="button" aria-label="팝업 닫기">×</button>
|
||
</div>
|
||
<div class="modal-body" id="planModalBody">내용이 없습니다.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const state = {
|
||
rows: [],
|
||
filteredRows: [],
|
||
selectedRow: null,
|
||
detail: null,
|
||
bridgeOverviews: [],
|
||
budgetPlan: null,
|
||
sourceTable: '',
|
||
sourceColumns: {},
|
||
};
|
||
|
||
const searchInput = document.getElementById('searchInput');
|
||
const contractTypeFilter = document.getElementById('contractTypeFilter');
|
||
const applicationTypeFilter = document.getElementById('applicationTypeFilter');
|
||
const reloadButton = document.getElementById('reloadButton');
|
||
const countChip = document.getElementById('countChip');
|
||
const sourceChip = document.getElementById('sourceChip');
|
||
const statusText = document.getElementById('statusText');
|
||
const resultBody = document.getElementById('resultBody');
|
||
const detailTitle = document.getElementById('detailTitle');
|
||
const detailMeta = document.getElementById('detailMeta');
|
||
const detailBody = document.getElementById('detailBody');
|
||
const bridgeMeta = document.getElementById('bridgeMeta');
|
||
const bridgeBody = document.getElementById('bridgeBody');
|
||
const detailSyncButton = document.getElementById('detailSyncButton');
|
||
const remarkModal = document.getElementById('remarkModal');
|
||
const remarkModalBody = document.getElementById('remarkModalBody');
|
||
const remarkModalTitle = document.getElementById('remarkModalTitle');
|
||
const remarkModalClose = document.getElementById('remarkModalClose');
|
||
const planModal = document.getElementById('planModal');
|
||
const planModalBody = document.getElementById('planModalBody');
|
||
const planModalTitle = document.getElementById('planModalTitle');
|
||
const planModalClose = document.getElementById('planModalClose');
|
||
let syncedAt = '';
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? '')
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
function toHtmlWithBreaks(value) {
|
||
return escapeHtml(value || '').replaceAll('\n', '<br>');
|
||
}
|
||
|
||
function formatCellValue(value) {
|
||
const text = String(value || '').trim();
|
||
if (!text) return '-';
|
||
return text
|
||
.replace(/\+/g, '+\n')
|
||
.replace(/~/g, '~\n')
|
||
.replace(/=/g, '=\n')
|
||
.replace(/\//g, '/\n')
|
||
.replace(/(?<=\d)-(?=\d)/g, '-\n');
|
||
}
|
||
|
||
function renderBudgetPlanInlineTables(plan) {
|
||
if (!plan) return '-';
|
||
const inputDays = plan.inputDays || {};
|
||
const inputRebar = plan.inputRebar || {};
|
||
const girderSpecs = Array.isArray(plan.girderSpecs) ? plan.girderSpecs : [];
|
||
const predeck = plan.predeck || {};
|
||
const crossbeam = plan.crossbeam || {};
|
||
|
||
return `
|
||
<div class="inline-plan-wrap">
|
||
<table class="plan-table">
|
||
<tr>
|
||
<th colspan="5">투입일수 (일)</th>
|
||
<th colspan="3">투입철근 (Ton)</th>
|
||
</tr>
|
||
<tr>
|
||
<th>제작</th>
|
||
<th>인장</th>
|
||
<th>거치</th>
|
||
<th>판넬</th>
|
||
<th>총투입일</th>
|
||
<th>공장</th>
|
||
<th>현장</th>
|
||
<th>합계</th>
|
||
</tr>
|
||
<tr>
|
||
<td>${escapeHtml(inputDays.fabrication || '-')}</td>
|
||
<td>${escapeHtml(inputDays.tensioning || '-')}</td>
|
||
<td>${escapeHtml(inputDays.erection || '-')}</td>
|
||
<td>${escapeHtml(inputDays.panel || '-')}</td>
|
||
<td>${escapeHtml(inputDays.total || '-')}</td>
|
||
<td>${escapeHtml(inputRebar.factory || '-')}</td>
|
||
<td>${escapeHtml(inputRebar.site || '-')}</td>
|
||
<td>${escapeHtml(inputRebar.total || '-')}</td>
|
||
</tr>
|
||
</table>
|
||
<div class="plan-section-title">교량제원</div>
|
||
<table class="plan-table">
|
||
<tr>
|
||
<th rowspan="${Math.max(girderSpecs.length, 1) + 1}">거더</th>
|
||
<th>연장(m)</th>
|
||
<th>폭원(m)</th>
|
||
<th>길이(m)</th>
|
||
<th>형고(m)</th>
|
||
<th>수량(본)</th>
|
||
<th>거푸집(조)</th>
|
||
<th>비고</th>
|
||
</tr>
|
||
${
|
||
girderSpecs.length
|
||
? girderSpecs.map((row) => `
|
||
<tr>
|
||
<td>${escapeHtml(row.extension || '-')}</td>
|
||
<td>${escapeHtml(row.width || '-')}</td>
|
||
<td>${escapeHtml(row.length || '-')}</td>
|
||
<td>${escapeHtml(row.height || '-')}</td>
|
||
<td>${escapeHtml(row.quantity || '-')}</td>
|
||
<td>${escapeHtml(row.formCount || '-')}</td>
|
||
<td class="plan-left">${escapeHtml(row.remarks || '-')}</td>
|
||
</tr>
|
||
`).join('')
|
||
: `
|
||
<tr>
|
||
<td colspan="7">내용이 없습니다.</td>
|
||
</tr>
|
||
`
|
||
}
|
||
<tr>
|
||
<th rowspan="2">프리덱</th>
|
||
<th>일반부(㎡)</th>
|
||
<th>중분대(㎡)</th>
|
||
<th>방호벽부(㎡)</th>
|
||
<th>비고</th>
|
||
<th rowspan="2">가로보</th>
|
||
<th>형고(m)</th>
|
||
<th>길이(m)</th>
|
||
<th>수량(EA)</th>
|
||
<th>비고</th>
|
||
</tr>
|
||
<tr>
|
||
<td>${escapeHtml(predeck.generalArea || '-')}</td>
|
||
<td>${escapeHtml(predeck.medianArea || '-')}</td>
|
||
<td>${escapeHtml(predeck.barrierArea || '-')}</td>
|
||
<td class="plan-left">${escapeHtml(predeck.remarks || '-')}</td>
|
||
<td>${escapeHtml(crossbeam.height || '-')}</td>
|
||
<td>${escapeHtml(crossbeam.length || '-')}</td>
|
||
<td>${escapeHtml(crossbeam.quantity || '-')}</td>
|
||
<td class="plan-left">${escapeHtml(crossbeam.remarks || '-')}</td>
|
||
</tr>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function splitApplicationTypes(value) {
|
||
return String(value || '')
|
||
.split('||')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function renderRows(rows) {
|
||
if (!rows.length) {
|
||
resultBody.innerHTML = '<tr><td colspan="2" class="empty">조건에 맞는 프로젝트가 없습니다.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
resultBody.innerHTML = rows.map((row) => `
|
||
<tr class="project-row ${state.selectedRow && state.selectedRow.projectCode === row.projectCode ? 'selected' : ''}" data-code="${escapeHtml(row.projectCode)}">
|
||
<td class="code">${escapeHtml(row.projectCode)}</td>
|
||
<td>${escapeHtml(row.projectName)}</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
Array.from(resultBody.querySelectorAll('.project-row')).forEach((element) => {
|
||
element.addEventListener('click', () => {
|
||
const projectCode = element.getAttribute('data-code');
|
||
const selected = state.rows.find((row) => row.projectCode === projectCode);
|
||
if (selected) {
|
||
state.selectedRow = selected;
|
||
renderRows(state.filteredRows);
|
||
loadDetail(selected.projectCode, selected.projectName, false);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function populateFilterOptions() {
|
||
const previousContractType = contractTypeFilter.value;
|
||
const previousApplicationType = applicationTypeFilter.value;
|
||
const contractTypes = Array.from(new Set(
|
||
state.rows.map((row) => String(row.contractType || '').trim()).filter(Boolean)
|
||
)).sort((a, b) => a.localeCompare(b, 'ko'));
|
||
const applicationTypes = Array.from(new Set(
|
||
state.rows.flatMap((row) => splitApplicationTypes(row.applicationType))
|
||
)).sort((a, b) => a.localeCompare(b, 'ko'));
|
||
|
||
contractTypeFilter.innerHTML = ['<option value="">전체 보기</option>']
|
||
.concat(contractTypes.map((value) => `<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`))
|
||
.join('');
|
||
applicationTypeFilter.innerHTML = ['<option value="">전체 보기</option>']
|
||
.concat(applicationTypes.map((value) => `<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`))
|
||
.join('');
|
||
|
||
if (contractTypes.includes(previousContractType)) {
|
||
contractTypeFilter.value = previousContractType;
|
||
}
|
||
if (applicationTypes.includes(previousApplicationType)) {
|
||
applicationTypeFilter.value = previousApplicationType;
|
||
}
|
||
}
|
||
|
||
function renderDetail(detail) {
|
||
if (!detail) {
|
||
detailTitle.textContent = '계약정보 표';
|
||
detailMeta.textContent = '시공코드나 약칭을 클릭하면 DB에 저장된 계약정보를 먼저 보여줍니다.';
|
||
detailBody.innerHTML = '<tr><td colspan="2" class="empty">선택된 시공코드가 없습니다.</td></tr>';
|
||
bridgeMeta.textContent = '공사규모와 공사개요를 교량명 기준으로 매칭해서 보여줍니다.';
|
||
bridgeBody.innerHTML = '<tr><td colspan="19" class="empty">선택된 시공코드가 없습니다.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
detailTitle.textContent = `${detail.projectName || '-'} [${detail.projectCode}]`;
|
||
detailMeta.textContent = `사업코드 ${detail.businessCode || '-'} · 마지막 동기화 ${detail.syncedAt || '-'}`;
|
||
|
||
const rows = [
|
||
['사업코드', detail.businessCode],
|
||
['약칭', detail.projectName],
|
||
['시공코드', detail.projectCode],
|
||
['현장위치', detail.siteLocation],
|
||
['발주처', detail.clientName],
|
||
['최종계약금액', detail.finalContractAmountText],
|
||
['계약종류', detail.contractType],
|
||
];
|
||
if (state.budgetPlan) {
|
||
rows.push(
|
||
['총투입일', state.budgetPlan.totalInputDays || '-'],
|
||
['투입철근', state.budgetPlan.totalRebarTon || '-'],
|
||
['공사개요', renderBudgetPlanInlineTables(state.budgetPlan)],
|
||
);
|
||
}
|
||
detailBody.innerHTML = rows.map(([label, value]) => `
|
||
<tr>
|
||
<th>${escapeHtml(label)}</th>
|
||
<td>${typeof value === 'string' && value.includes('<table') ? value : escapeHtml(value || '-')}</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
renderMergedBridgeRows(detail.scaleRows || [], state.bridgeOverviews || [], state.budgetPlan);
|
||
}
|
||
|
||
function normalizeBridgeName(value) {
|
||
return String(value || '')
|
||
.replace(/\s+/g, '')
|
||
.replace(/\([^)]*\)/g, '')
|
||
.replace(/\[[^\]]*\]/g, '')
|
||
.trim();
|
||
}
|
||
|
||
function normalizeValue(value, fallback = '-') {
|
||
const text = String(value || '').trim();
|
||
return text || fallback;
|
||
}
|
||
|
||
function formatConstructionPeriod(value) {
|
||
const text = String(value || '').trim();
|
||
if (!text) return '-';
|
||
const parts = text.split('~').map((item) => item.trim()).filter(Boolean);
|
||
if (parts.length >= 2) {
|
||
return `${parts[0]}\n${parts[1]}`;
|
||
}
|
||
return text;
|
||
}
|
||
|
||
function formatBridgeLocation(value) {
|
||
const text = String(value || '').trim();
|
||
if (!text) return '-';
|
||
if (text.length <= 18) return text;
|
||
const midpoint = Math.floor(text.length / 2);
|
||
let splitIndex = text.indexOf(' ', midpoint);
|
||
if (splitIndex === -1) {
|
||
splitIndex = text.lastIndexOf(' ', midpoint);
|
||
}
|
||
if (splitIndex === -1) {
|
||
return text;
|
||
}
|
||
return `${text.slice(0, splitIndex).trim()}\n${text.slice(splitIndex + 1).trim()}`;
|
||
}
|
||
|
||
function formatStatus(scaleRow, overviewRow) {
|
||
const status = overviewRow?.constructionStatus || scaleRow?.constructionStatus || '';
|
||
return status || '-';
|
||
}
|
||
|
||
function escapeAttr(value) {
|
||
return escapeHtml(value).replaceAll('\n', ' ');
|
||
}
|
||
|
||
function renderBudgetPlanModal(plan, bridgeName) {
|
||
if (!plan) {
|
||
planModalBody.textContent = '내용이 없습니다.';
|
||
return;
|
||
}
|
||
const inputDays = plan.inputDays || {};
|
||
const inputRebar = plan.inputRebar || {};
|
||
const girderSpecs = Array.isArray(plan.girderSpecs) ? plan.girderSpecs : [];
|
||
const predeck = plan.predeck || {};
|
||
const crossbeam = plan.crossbeam || {};
|
||
|
||
planModalTitle.textContent = `${bridgeName || plan.projectName || '프로젝트'} 공사시행계획서`;
|
||
planModalBody.innerHTML = `
|
||
<div class="plan-section-title">투입일수 / 투입철근</div>
|
||
<table class="plan-table">
|
||
<tr><th>제작</th><td>${escapeHtml(inputDays.fabrication || '-')}</td><th>공장 철근</th><td>${escapeHtml(inputRebar.factory || '-')}</td></tr>
|
||
<tr><th>인장</th><td>${escapeHtml(inputDays.tensioning || '-')}</td><th>현장 철근</th><td>${escapeHtml(inputRebar.site || '-')}</td></tr>
|
||
<tr><th>거치</th><td>${escapeHtml(inputDays.erection || '-')}</td><th>철근 합계</th><td>${escapeHtml(inputRebar.total || '-')}</td></tr>
|
||
<tr><th>판넬</th><td>${escapeHtml(inputDays.panel || '-')}</td><th>총투입일</th><td>${escapeHtml(inputDays.total || '-')}</td></tr>
|
||
</table>
|
||
<div class="plan-section-title">교량제원 - 거더</div>
|
||
<table class="plan-table">
|
||
<tr><th>연장</th><th>폭원</th><th>길이</th><th>형고</th><th>수량</th><th>거푸집</th><th>비고</th></tr>
|
||
${girderSpecs.length ? girderSpecs.map((row) => `
|
||
<tr>
|
||
<td>${escapeHtml(row.extension || '-')}</td>
|
||
<td>${escapeHtml(row.width || '-')}</td>
|
||
<td>${escapeHtml(row.length || '-')}</td>
|
||
<td>${escapeHtml(row.height || '-')}</td>
|
||
<td>${escapeHtml(row.quantity || '-')}</td>
|
||
<td>${escapeHtml(row.formCount || '-')}</td>
|
||
<td>${escapeHtml(row.remarks || '-')}</td>
|
||
</tr>
|
||
`).join('') : '<tr><td colspan="7">내용이 없습니다.</td></tr>'}
|
||
</table>
|
||
<div class="plan-section-title">교량제원 - 프리덱 / 가로보</div>
|
||
<table class="plan-table">
|
||
<tr><th>프리덱 일반부</th><td>${escapeHtml(predeck.generalArea || '-')}</td><th>가로보 형고</th><td>${escapeHtml(crossbeam.height || '-')}</td></tr>
|
||
<tr><th>프리덱 중분대</th><td>${escapeHtml(predeck.medianArea || '-')}</td><th>가로보 길이</th><td>${escapeHtml(crossbeam.length || '-')}</td></tr>
|
||
<tr><th>프리덱 방호벽부</th><td>${escapeHtml(predeck.barrierArea || '-')}</td><th>가로보 수량</th><td>${escapeHtml(crossbeam.quantity || '-')}</td></tr>
|
||
<tr><th>프리덱 비고</th><td>${escapeHtml(predeck.remarks || '-')}</td><th>가로보 비고</th><td>${escapeHtml(crossbeam.remarks || '-')}</td></tr>
|
||
</table>
|
||
`;
|
||
}
|
||
|
||
function mergeBridgeRows(scaleRows, overviews, budgetPlan) {
|
||
const mergedMap = new Map();
|
||
const order = [];
|
||
|
||
const ensureRow = (name, seed = {}) => {
|
||
const key = normalizeBridgeName(name) || `__row_${order.length}`;
|
||
if (!mergedMap.has(key)) {
|
||
mergedMap.set(key, { key, bridgeName: String(name || '').trim(), scaleRow: null, overviewRow: null, ...seed });
|
||
order.push(key);
|
||
}
|
||
return mergedMap.get(key);
|
||
};
|
||
|
||
(scaleRows || []).forEach((row) => {
|
||
const target = ensureRow(row.bridgeName, {});
|
||
target.bridgeName = target.bridgeName || row.bridgeName || '';
|
||
target.scaleRow = row;
|
||
});
|
||
|
||
(overviews || []).forEach((row) => {
|
||
const target = ensureRow(row.bridgeName || row.bridgeDisplayName, {});
|
||
target.bridgeName = target.bridgeName || row.bridgeName || row.bridgeDisplayName || '';
|
||
target.overviewRow = row;
|
||
});
|
||
|
||
return order.map((key) => {
|
||
const row = mergedMap.get(key);
|
||
const scaleRow = row.scaleRow || {};
|
||
const overviewRow = row.overviewRow || {};
|
||
return {
|
||
bridgeName: row.bridgeName || overviewRow.bridgeName || scaleRow.bridgeName || '-',
|
||
applicationType: normalizeValue(overviewRow.applicationType),
|
||
constructionStatus: formatStatus(scaleRow, overviewRow),
|
||
constructionPeriod: formatConstructionPeriod(overviewRow.constructionPeriod),
|
||
spanLengthDown: normalizeValue(overviewRow.spanLengthDown),
|
||
spanLengthUp: normalizeValue(overviewRow.spanLengthUp, scaleRow.spanLength || '-'),
|
||
widthDown: normalizeValue(overviewRow.widthDown),
|
||
widthUp: normalizeValue(overviewRow.widthUp, scaleRow.width || '-'),
|
||
girderHeightSupport: normalizeValue(overviewRow.girderHeightSupport),
|
||
girderHeightCenter: normalizeValue(overviewRow.girderHeightCenter, scaleRow.girderHeight || '-'),
|
||
spanCompositionDown: normalizeValue(overviewRow.spanCompositionDown),
|
||
spanCompositionUp: normalizeValue(overviewRow.spanCompositionUp),
|
||
girderCount: scaleRow.girderCount || '-',
|
||
crossbeamCount: scaleRow.crossbeamCount || '-',
|
||
panelCount: scaleRow.panelCount || '-',
|
||
rebarCount: scaleRow.rebarCount || '-',
|
||
bridgeLocation: formatBridgeLocation(overviewRow.bridgeLocation),
|
||
remarks: overviewRow.remarks || '-',
|
||
};
|
||
});
|
||
}
|
||
|
||
function renderMergedBridgeRows(scaleRows, overviews, budgetPlan) {
|
||
const mergedRows = mergeBridgeRows(scaleRows, overviews, budgetPlan);
|
||
bridgeMeta.textContent = mergedRows.length
|
||
? `공사규모 ${scaleRows.length}건 · 공사개요 ${overviews.length}건을 교량명 기준으로 매칭했습니다.`
|
||
: '연결된 공사규모나 공사개요 데이터가 없습니다.';
|
||
|
||
if (!mergedRows.length) {
|
||
bridgeBody.innerHTML = '<tr><td colspan="19" class="empty">표시할 교량 매칭 정보가 없습니다.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
bridgeBody.innerHTML = mergedRows.map((row) => `
|
||
<tr>
|
||
<td class="cell-text">${escapeHtml(row.bridgeName)}</td>
|
||
<td class="cell-text">${escapeHtml(row.applicationType)}</td>
|
||
<td class="bridge-summary">${toHtmlWithBreaks(row.constructionStatus)}</td>
|
||
<td class="bridge-summary">${toHtmlWithBreaks(row.constructionPeriod)}</td>
|
||
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.spanLengthDown))}</td>
|
||
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.spanLengthUp))}</td>
|
||
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.widthDown))}</td>
|
||
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.widthUp))}</td>
|
||
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.girderHeightSupport))}</td>
|
||
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.girderHeightCenter))}</td>
|
||
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.spanCompositionDown))}</td>
|
||
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.spanCompositionUp))}</td>
|
||
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.girderCount))}</td>
|
||
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.crossbeamCount))}</td>
|
||
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.panelCount))}</td>
|
||
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.rebarCount))}</td>
|
||
<td class="bridge-summary cell-wide">${toHtmlWithBreaks(row.bridgeLocation)}</td>
|
||
<td>
|
||
<button
|
||
class="remark-button"
|
||
type="button"
|
||
data-bridge="${escapeHtml(row.bridgeName)}"
|
||
data-remarks="${escapeHtml(row.remarks === '-' ? '' : row.remarks)}"
|
||
${row.remarks && row.remarks !== '-' ? '' : 'disabled'}
|
||
title="${row.remarks && row.remarks !== '-' ? '특이사항 보기' : '특이사항 없음'}"
|
||
>i</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
Array.from(bridgeBody.querySelectorAll('.remark-button')).forEach((button) => {
|
||
button.addEventListener('click', () => {
|
||
const bridgeName = button.getAttribute('data-bridge') || '교량';
|
||
const remarks = button.getAttribute('data-remarks') || '내용이 없습니다.';
|
||
openRemarkModal(`${bridgeName} 특이사항`, remarks);
|
||
});
|
||
});
|
||
}
|
||
|
||
function openRemarkModal(title, body) {
|
||
remarkModalTitle.textContent = title || '특이사항';
|
||
remarkModalBody.textContent = body || '내용이 없습니다.';
|
||
remarkModal.classList.add('open');
|
||
remarkModal.setAttribute('aria-hidden', 'false');
|
||
}
|
||
|
||
function closeRemarkModal() {
|
||
remarkModal.classList.remove('open');
|
||
remarkModal.setAttribute('aria-hidden', 'true');
|
||
}
|
||
|
||
function openPlanModal(bridgeName) {
|
||
renderBudgetPlanModal(state.budgetPlan, bridgeName);
|
||
planModal.classList.add('open');
|
||
planModal.setAttribute('aria-hidden', 'false');
|
||
}
|
||
|
||
function closePlanModal() {
|
||
planModal.classList.remove('open');
|
||
planModal.setAttribute('aria-hidden', 'true');
|
||
}
|
||
|
||
function applyFilter() {
|
||
const keyword = searchInput.value.trim().toLowerCase();
|
||
const selectedContractType = contractTypeFilter.value.trim();
|
||
const selectedApplicationType = applicationTypeFilter.value.trim();
|
||
if (!keyword) {
|
||
state.filteredRows = [...state.rows];
|
||
} else {
|
||
state.filteredRows = state.rows.filter((row) => {
|
||
return row.projectCode.toLowerCase().includes(keyword)
|
||
|| row.projectName.toLowerCase().includes(keyword);
|
||
});
|
||
}
|
||
|
||
if (selectedContractType) {
|
||
state.filteredRows = state.filteredRows.filter((row) => (row.contractType || '') === selectedContractType);
|
||
}
|
||
if (selectedApplicationType) {
|
||
state.filteredRows = state.filteredRows.filter((row) => splitApplicationTypes(row.applicationType).includes(selectedApplicationType));
|
||
}
|
||
|
||
countChip.textContent = `총 ${state.rows.length.toLocaleString()}건 / 표시 ${state.filteredRows.length.toLocaleString()}건`;
|
||
statusText.textContent = keyword
|
||
? `"${keyword}" 검색 결과 ${state.filteredRows.length.toLocaleString()}건입니다.`
|
||
: `프로젝트 목록 ${state.filteredRows.length.toLocaleString()}건을 표시 중입니다.`;
|
||
renderRows(state.filteredRows);
|
||
}
|
||
|
||
async function loadRows(refresh = false) {
|
||
statusText.textContent = refresh
|
||
? 'ERP 시공 코드를 DB로 동기화하는 중입니다.'
|
||
: 'DB에 저장된 시공 코드 목록을 불러오는 중입니다.';
|
||
reloadButton.disabled = true;
|
||
try {
|
||
const response = await fetch(`/api/erp-project-codes?page=const&refresh=${refresh ? '1' : '0'}`, { cache: 'no-store' });
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(data.error || '프로젝트 목록 조회에 실패했습니다.');
|
||
}
|
||
|
||
state.rows = Array.isArray(data.rows) ? data.rows : [];
|
||
state.sourceTable = data.source || '';
|
||
state.sourceColumns = { projectCode: data.page || 'const', projectName: 'ProjectNickname' };
|
||
syncedAt = data.syncedAt || '';
|
||
populateFilterOptions();
|
||
|
||
sourceChip.textContent = state.sourceTable
|
||
? `원본 ERP → DB · page=${state.sourceColumns.projectCode || 'const'}${syncedAt ? ` · ${syncedAt}` : ''}`
|
||
: '원본 정보 없음';
|
||
|
||
applyFilter();
|
||
} catch (error) {
|
||
state.rows = [];
|
||
state.filteredRows = [];
|
||
countChip.textContent = '조회 실패';
|
||
sourceChip.textContent = '원본 테이블 확인 실패';
|
||
statusText.textContent = error.message || String(error);
|
||
resultBody.innerHTML = `<tr><td colspan="2" class="empty">${escapeHtml(error.message || String(error))}</td></tr>`;
|
||
} finally {
|
||
reloadButton.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function loadDetail(projectCode, projectName, refresh = false) {
|
||
if (!projectCode) {
|
||
renderDetail(null);
|
||
return;
|
||
}
|
||
detailMeta.textContent = refresh
|
||
? 'ERP 계약정보를 DB로 동기화하는 중입니다.'
|
||
: 'DB에 저장된 계약정보를 불러오는 중입니다.';
|
||
bridgeMeta.textContent = refresh
|
||
? 'ERP 공사규모와 공사개요를 동기화하는 중입니다.'
|
||
: 'DB에 저장된 공사규모와 공사개요를 불러오는 중입니다.';
|
||
detailSyncButton.disabled = true;
|
||
try {
|
||
const detailUrl = `/api/erp-contract-detail?page=const&projectCode=${encodeURIComponent(projectCode)}&projectName=${encodeURIComponent(projectName || '')}&refresh=${refresh ? '1' : '0'}`;
|
||
const overviewUrl = `/api/erp-bridge-overviews?page=const&projectCode=${encodeURIComponent(projectCode)}&projectName=${encodeURIComponent(projectName || '')}&refresh=${refresh ? '1' : '0'}`;
|
||
const budgetPlanUrl = `/api/erp-budget-plan?page=const&projectCode=${encodeURIComponent(projectCode)}&projectName=${encodeURIComponent(projectName || '')}&refresh=${refresh ? '1' : '0'}`;
|
||
const [detailResponse, overviewResponse, budgetPlanResponse] = await Promise.all([
|
||
fetch(detailUrl, { cache: 'no-store' }),
|
||
fetch(overviewUrl, { cache: 'no-store' }),
|
||
fetch(budgetPlanUrl, { cache: 'no-store' }),
|
||
]);
|
||
const data = await detailResponse.json();
|
||
const overviewData = await overviewResponse.json();
|
||
const budgetPlanData = await budgetPlanResponse.json();
|
||
|
||
if (!detailResponse.ok) {
|
||
if (!refresh && detailResponse.status === 404) {
|
||
return loadDetail(projectCode, projectName, true);
|
||
}
|
||
throw new Error(data.error || '계약정보 조회에 실패했습니다.');
|
||
}
|
||
|
||
state.detail = data.detail || null;
|
||
if (!refresh && (!state.detail || !Array.isArray(state.detail.scaleRows) || !state.detail.scaleRows.length)) {
|
||
return loadDetail(projectCode, projectName, true);
|
||
}
|
||
state.bridgeOverviews = Array.isArray(overviewData.overviews) ? overviewData.overviews : [];
|
||
state.budgetPlan = budgetPlanResponse.ok ? (budgetPlanData.plan || null) : null;
|
||
if (!refresh && !state.bridgeOverviews.length) {
|
||
const freshOverviewResponse = await fetch(
|
||
`/api/erp-bridge-overviews?page=const&projectCode=${encodeURIComponent(projectCode)}&projectName=${encodeURIComponent(projectName || '')}&refresh=1`,
|
||
{ cache: 'no-store' }
|
||
);
|
||
const freshOverviewData = await freshOverviewResponse.json();
|
||
if (freshOverviewResponse.ok) {
|
||
state.bridgeOverviews = Array.isArray(freshOverviewData.overviews) ? freshOverviewData.overviews : [];
|
||
}
|
||
}
|
||
if (!refresh && !state.budgetPlan) {
|
||
const freshPlanResponse = await fetch(
|
||
`/api/erp-budget-plan?page=const&projectCode=${encodeURIComponent(projectCode)}&projectName=${encodeURIComponent(projectName || '')}&refresh=1`,
|
||
{ cache: 'no-store' }
|
||
);
|
||
const freshPlanData = await freshPlanResponse.json();
|
||
if (freshPlanResponse.ok) {
|
||
state.budgetPlan = freshPlanData.plan || null;
|
||
}
|
||
}
|
||
if (state.selectedRow && state.detail) {
|
||
state.selectedRow.contractType = state.detail.contractType || state.selectedRow.contractType || '';
|
||
state.selectedRow.applicationType = Array.from(new Set(
|
||
state.bridgeOverviews.map((item) => String(item.applicationType || '').trim()).filter(Boolean)
|
||
)).sort((a, b) => a.localeCompare(b, 'ko')).join('||') || state.selectedRow.applicationType || '';
|
||
const rowIndex = state.rows.findIndex((row) => row.projectCode === projectCode);
|
||
if (rowIndex >= 0) {
|
||
state.rows[rowIndex] = { ...state.rows[rowIndex], ...state.selectedRow };
|
||
populateFilterOptions();
|
||
}
|
||
}
|
||
renderDetail(state.detail);
|
||
} catch (error) {
|
||
state.detail = null;
|
||
state.bridgeOverviews = [];
|
||
state.budgetPlan = null;
|
||
detailTitle.textContent = `${projectName || '-'} [${projectCode}]`;
|
||
detailMeta.textContent = error.message || String(error);
|
||
detailBody.innerHTML = `<tr><td colspan="2" class="empty">${escapeHtml(error.message || String(error))}</td></tr>`;
|
||
bridgeMeta.textContent = error.message || String(error);
|
||
bridgeBody.innerHTML = `<tr><td colspan="19" class="empty">${escapeHtml(error.message || String(error))}</td></tr>`;
|
||
} finally {
|
||
detailSyncButton.disabled = false;
|
||
}
|
||
}
|
||
|
||
remarkModalClose.addEventListener('click', closeRemarkModal);
|
||
remarkModal.addEventListener('click', (event) => {
|
||
if (event.target === remarkModal) {
|
||
closeRemarkModal();
|
||
}
|
||
});
|
||
planModalClose.addEventListener('click', closePlanModal);
|
||
planModal.addEventListener('click', (event) => {
|
||
if (event.target === planModal) {
|
||
closePlanModal();
|
||
}
|
||
});
|
||
document.addEventListener('keydown', (event) => {
|
||
if (event.key !== 'Escape') return;
|
||
if (remarkModal.classList.contains('open')) closeRemarkModal();
|
||
if (planModal.classList.contains('open')) closePlanModal();
|
||
});
|
||
searchInput.addEventListener('input', applyFilter);
|
||
contractTypeFilter.addEventListener('change', applyFilter);
|
||
applicationTypeFilter.addEventListener('change', applyFilter);
|
||
reloadButton.addEventListener('click', () => loadRows(true));
|
||
detailSyncButton.addEventListener('click', () => {
|
||
if (state.selectedRow) {
|
||
loadDetail(state.selectedRow.projectCode, state.selectedRow.projectName, true);
|
||
}
|
||
});
|
||
loadRows(false).then(() => {
|
||
if (!state.rows.length) {
|
||
loadRows(true);
|
||
} else {
|
||
state.selectedRow = state.rows[0];
|
||
renderRows(state.filteredRows);
|
||
loadDetail(state.selectedRow.projectCode, state.selectedRow.projectName, false);
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|