Files
JH/project-codes.html

1290 lines
50 KiB
HTML
Raw 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">
<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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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 || '-'}`;
renderDetailRows(detail);
renderMergedBridgeRows(detail.scaleRows || [], state.bridgeOverviews || [], state.budgetPlan);
}
function renderDetailRows(detail) {
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('');
}
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', '&#10;');
}
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) {
state.bridgeOverviews = overviewResponse.ok ? (Array.isArray(overviewData.overviews) ? overviewData.overviews : []) : [];
state.budgetPlan = budgetPlanResponse.ok ? (budgetPlanData.plan || null) : null;
detailTitle.textContent = `${projectName || '-'} [${projectCode}]`;
detailMeta.textContent = 'DB 캐시에 계약정보가 없어 ERP에서 기본 계약정보를 가져오는 중입니다.';
try {
const freshDetailResponse = await fetch(
`/api/erp-contract-detail?page=const&projectCode=${encodeURIComponent(projectCode)}&projectName=${encodeURIComponent(projectName || '')}&refresh=1`,
{ cache: 'no-store' }
);
const freshDetailData = await freshDetailResponse.json();
if (freshDetailResponse.ok) {
state.detail = freshDetailData.detail || null;
if (state.selectedRow && state.detail) {
state.selectedRow.businessCode = state.detail.businessCode || state.selectedRow.businessCode || '';
state.selectedRow.siteLocation = state.detail.siteLocation || state.selectedRow.siteLocation || '';
state.selectedRow.clientName = state.detail.clientName || state.selectedRow.clientName || '';
state.selectedRow.finalContractAmountText = state.detail.finalContractAmountText || state.selectedRow.finalContractAmountText || '';
state.selectedRow.contractType = state.detail.contractType || state.selectedRow.contractType || '';
state.selectedRow.syncedAt = state.detail.syncedAt || state.selectedRow.syncedAt || '';
}
renderDetail(state.detail);
return;
}
} catch (error) {
}
state.detail = null;
const selectedSummary = state.selectedRow || {};
detailMeta.textContent = '기본 계약정보를 아직 가져오지 않았습니다. 새 정보 다시 가져오기를 누르면 ERP에서 수집합니다.';
renderDetailRows({
businessCode: selectedSummary.businessCode || '',
projectName: projectName || selectedSummary.projectName || '',
projectCode,
siteLocation: selectedSummary.siteLocation || '',
clientName: selectedSummary.clientName || '',
finalContractAmountText: selectedSummary.finalContractAmountText || '',
contractType: selectedSummary.contractType || '',
syncedAt: selectedSummary.syncedAt || '',
});
renderMergedBridgeRows([], state.bridgeOverviews || [], state.budgetPlan);
return;
}
throw new Error(data.error || '계약정보 조회에 실패했습니다.');
}
state.detail = data.detail || null;
state.bridgeOverviews = Array.isArray(overviewData.overviews) ? overviewData.overviews : [];
state.budgetPlan = budgetPlanResponse.ok ? (budgetPlanData.plan || null) : null;
if (state.selectedRow && state.detail) {
state.selectedRow.businessCode = state.detail.businessCode || state.selectedRow.businessCode || '';
state.selectedRow.siteLocation = state.detail.siteLocation || state.selectedRow.siteLocation || '';
state.selectedRow.clientName = state.detail.clientName || state.selectedRow.clientName || '';
state.selectedRow.finalContractAmountText = state.detail.finalContractAmountText || state.selectedRow.finalContractAmountText || '';
state.selectedRow.contractType = state.detail.contractType || state.selectedRow.contractType || '';
state.selectedRow.syncedAt = state.detail.syncedAt || state.selectedRow.syncedAt || '';
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>