From a09190ecd3a0e89678a98dba7d66a7baa3b8d015 Mon Sep 17 00:00:00 2001 From: b17301 Date: Thu, 25 Jun 2026 09:51:22 +0900 Subject: [PATCH] Fix cost analysis table scroll behavior --- templates/cost_analysis.html | 1430 ++++++++++++++++++++++++++-------- 1 file changed, 1103 insertions(+), 327 deletions(-) diff --git a/templates/cost_analysis.html b/templates/cost_analysis.html index 06b6c4c..3755339 100644 --- a/templates/cost_analysis.html +++ b/templates/cost_analysis.html @@ -11,7 +11,7 @@ .cost-toolbar { display: grid; - grid-template-columns: 104px 118px 118px 120px 104px 124px 160px 62px 120px 120px; + grid-template-columns: 104px 118px 118px 120px 104px 124px 160px 62px; gap: 12px; align-items: end; padding-bottom: 14px; @@ -52,15 +52,20 @@ } .cost-kpis { + --kpi-count: 14; display: grid; - grid-template-columns: repeat(9, minmax(0, 1fr)); + grid-template-columns: repeat(var(--kpi-count), minmax(0, 1fr)); gap: 6px; + overflow: hidden; + align-items: end; } .cost-kpi { border-bottom: 1px solid var(--line); padding-bottom: 10px; min-width: 0; + white-space: nowrap; + overflow: hidden; } .cost-kpi span { @@ -76,12 +81,38 @@ .cost-kpi strong { display: block; - font-size: 14px; + font-size: clamp(10px, 0.66vw, 13px); line-height: 1.15; white-space: nowrap; word-break: keep-all; overflow: hidden; text-overflow: ellipsis; + font-variant-numeric: tabular-nums; + } + + .cost-kpi-button { + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; + font: inherit; + } + + .cost-kpi-button:hover strong { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 3px; + } + + .cost-kpi-button.is-plain-metric { + display: block; + min-height: 0; + border-radius: 0; + box-shadow: none; + transform: none; } .summary-accordion { @@ -169,31 +200,58 @@ } .cost-table-wrap { - --cost-table-header-height: 99px; + --cost-table-header-height: 90px; + --cost-col-pm: 84px; + --cost-col-code: 78px; + --cost-col-name: 180px; + --cost-col-small: 56px; + --cost-col-date: 70px; + --cost-col-money: 96px; + --cost-col-rate: 70px; height: calc(100vh - 310px); max-height: calc(100vh - 310px); min-height: 420px; overflow: auto; + overflow-anchor: none; + overscroll-behavior: contain; + scrollbar-gutter: stable; border-top: 1px solid var(--line); background: #ffffff; + position: relative; + isolation: isolate; } .cost-table { - width: 100%; - min-width: 0; + width: max-content; + min-width: 100%; table-layout: fixed; border-collapse: separate; border-spacing: 0; } + .cost-table.has-pre-outsource { + width: max-content; + } + .cost-table th, .cost-table td { - padding: 7px 5px; - font-size: 11px; + padding: 5px 3px; + font-size: 10px; vertical-align: middle; border-right: 1px solid #e6e8ec; background: #ffffff; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + #costAnalysisBody td:not([colspan]) > button, + #costAnalysisBody td:not([colspan]) > span, + .summary-data-row td > button, + .summary-data-row td > span { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; } .cost-table thead th { @@ -246,24 +304,27 @@ z-index: 6; background: #ffffff; background-clip: padding-box; + box-sizing: border-box; box-shadow: 1px 0 0 #d9dde3; } .sticky-col-2 { position: sticky; - left: 94px; + left: var(--cost-col-pm); z-index: 6; background: #ffffff; background-clip: padding-box; + box-sizing: border-box; box-shadow: 1px 0 0 #d9dde3; } .sticky-col-3 { position: sticky; - left: 172px; + left: calc(var(--cost-col-pm) + var(--cost-col-code)); z-index: 6; background: #ffffff; background-clip: padding-box; + box-sizing: border-box; box-shadow: 1px 0 0 #d9dde3; } @@ -295,18 +356,10 @@ } .summary-table-body .summary-control-row td { - position: sticky; - left: 0; - z-index: 80; - padding: 0; - height: 0; - line-height: 0; - overflow: visible; - border-top: 0; - background: transparent; + display: none; } - .summary-table-body.open .summary-control-row { + .summary-table-body .summary-control-row { display: none; } @@ -322,11 +375,12 @@ position: sticky; top: var(--cost-table-header-height); z-index: 48; + background: #fbfcfd; + background-clip: padding-box; box-shadow: 0 1px 0 #cfd6e2, 0 3px 8px rgba(15, 23, 42, 0.08); } .summary-table-body:not(.open) .summary-control-row td { - border-top: 0; border-right: 0; } @@ -350,43 +404,53 @@ display: inline-flex; align-items: center; justify-content: center; - gap: 5px; - min-width: 76px; - min-height: 28px; - padding: 4px 10px; + gap: 3px; + width: 58px; + min-width: 58px; + min-height: 22px; + box-sizing: border-box; + padding: 2px 6px; border: 0; - border-radius: 6px; + border-radius: 5px; background: #d8dee8; color: #172033; box-shadow: 0 1px 0 rgba(15, 23, 42, 0.08); - font-size: 13px; + font-size: 11px; font-weight: 900; line-height: 1; } - .summary-table-body:not(.open) .summary-row-toggle { - position: absolute; - left: 24px; - top: 0; - z-index: 90; - transform: translateY(-18px); - } - .summary-row-toggle:hover { background: #cfd6e0; transform: none; } - .summary-table-body:not(.open) .summary-row-toggle:hover { - transform: translateY(-18px); - } - .summary-row-arrow { display: inline-block; - font-size: 12px; + font-size: 10px; transition: transform 0.16s ease; } + .summary-toggle-host .sort-button { + padding-bottom: 28px; + } + + .summary-header-toggle { + position: absolute; + left: 50%; + bottom: 7px; + z-index: 90; + transform: translateX(-50%); + } + + .summary-header-toggle:hover { + transform: translateX(-50%); + } + + .summary-header-toggle.is-hidden { + display: none; + } + .summary-table-body.open .summary-row-arrow { transform: rotate(180deg); } @@ -480,37 +544,46 @@ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12); } - .col-pm { width: 94px; } - .col-code { width: 78px; } - .col-name { width: 148px; } - .col-small { width: 54px; } - .col-date { width: 72px; } - .col-money { width: 72px; text-align: right; } - .col-rate { width: 62px; text-align: right; } + col.col-pm, .col-pm { width: var(--cost-col-pm); min-width: var(--cost-col-pm); max-width: var(--cost-col-pm); } + col.col-code, .col-code { width: var(--cost-col-code); min-width: var(--cost-col-code); max-width: var(--cost-col-code); } + col.col-name, .col-name { width: var(--cost-col-name); min-width: var(--cost-col-name); max-width: var(--cost-col-name); } + col.col-small, .col-small { width: var(--cost-col-small); min-width: var(--cost-col-small); max-width: var(--cost-col-small); } + col.col-date, .col-date { width: var(--cost-col-date); min-width: var(--cost-col-date); max-width: var(--cost-col-date); } + col.col-money, .col-money { width: var(--cost-col-money); min-width: var(--cost-col-money); max-width: var(--cost-col-money); text-align: right; } + col.col-rate, .col-rate { width: var(--cost-col-rate); min-width: var(--cost-col-rate); max-width: var(--cost-col-rate); text-align: right; } .group-pre { background: #f3f8ee !important; } .group-during { background: #eef5fb !important; } .group-post { background: #f8f1f5 !important; } .group-total { background: #f4f4f5 !important; } + .pre-outsource-col.is-hidden { display: none !important; } .sub-cost { color: #155e75 !important; } .sub-sga { color: #7c2d12 !important; } - .amount-button { + .amount-button, + .amount-link { width: 100%; + max-width: 100%; justify-content: flex-end; - min-height: 26px; - padding: 2px 0; + min-height: 22px; + padding: 1px 0; border: 0; border-radius: 0; background: transparent; color: #111827; box-shadow: none; - font-size: 11px; + font-size: clamp(8.6px, 0.48vw, 9.8px); font-weight: 800; + line-height: 1.1; text-align: right; + letter-spacing: -0.04em; + font-variant-numeric: tabular-nums; + overflow: visible; + text-overflow: clip; } - .amount-button:hover { + .amount-button:hover, + .amount-link:hover { background: transparent; color: #0f766e; text-decoration: underline; @@ -522,17 +595,26 @@ text-align: right; color: #9aa3af; font-weight: 700; + font-size: clamp(8.6px, 0.48vw, 9.8px); + letter-spacing: -0.04em; + font-variant-numeric: tabular-nums; } .ratio-cell { text-align: right; font-weight: 800; white-space: nowrap; + font-size: clamp(8.6px, 0.48vw, 9.8px); + letter-spacing: -0.03em; + font-variant-numeric: tabular-nums; } .col-money, .col-rate { white-space: nowrap; + font-size: clamp(8.6px, 0.48vw, 9.8px); + letter-spacing: -0.04em; + font-variant-numeric: tabular-nums; } .project-name-cell { @@ -562,8 +644,8 @@ } .detail-modal-card { - width: min(980px, 96vw); - max-height: min(82vh, 820px); + width: min(1500px, 98vw); + max-height: min(92vh, 920px); border-radius: 12px; display: grid; grid-template-rows: auto 1fr auto; @@ -585,28 +667,175 @@ border-bottom: 0; } + .detail-modal-totals { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px 16px; + min-width: 0; + } + + .detail-modal-totals span { + white-space: nowrap; + } + .detail-modal-title { font-size: 16px; font-weight: 900; } .detail-modal-body { - overflow: auto; + overflow-y: auto; + overflow-x: hidden; } .detail-table { - min-width: 760px; + width: 100%; + min-width: 0; + table-layout: fixed; } .detail-table th, .detail-table td { - font-size: 13px; - padding: 10px 12px; + font-size: 12px; + padding: 8px 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: none; } .detail-table .memo-col { - white-space: normal; - min-width: 240px; + white-space: nowrap; + min-width: 0; + max-width: none; + width: 30%; + } + + .detail-table .project-code-col { + min-width: 0; + max-width: none; + width: 92px; + } + + .detail-table .col-money { + width: 96px; + } + + .cost-table tbody tr.common-master-row td, + .cost-table tbody tr.common-master-row td.sticky-col, + .cost-table tbody tr.common-master-row td.sticky-col-2, + .cost-table tbody tr.common-master-row td.sticky-col-3 { + background: #f0f7f5; + background-clip: padding-box; + font-weight: 800; + } + + .cost-table tbody tr.common-master-row:hover td, + .cost-table tbody tr.common-master-row:hover td.sticky-col, + .cost-table tbody tr.common-master-row:hover td.sticky-col-2, + .cost-table tbody tr.common-master-row:hover td.sticky-col-3 { + background: #e7f1ef; + } + + .cost-table tbody tr.common-master-row td:nth-child(n+9) { + cursor: pointer; + } + + .cost-table tbody tr.common-detail-row td, + .cost-table tbody tr.common-detail-row td.sticky-col, + .cost-table tbody tr.common-detail-row td.sticky-col-2, + .cost-table tbody tr.common-detail-row td.sticky-col-3 { + background: #f8faf9; + background-clip: padding-box; + } + + .cost-table tbody tr.common-detail-row:hover td, + .cost-table tbody tr.common-detail-row:hover td.sticky-col, + .cost-table tbody tr.common-detail-row:hover td.sticky-col-2, + .cost-table tbody tr.common-detail-row:hover td.sticky-col-3 { + background: #eef5f2; + } + + .common-detail-row .project-name-cell { + padding-left: 22px; + } + + .detail-accordion { + border-bottom: 1px solid var(--line); + } + + .detail-accordion-toggle { + width: 100%; + min-height: 44px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border: 0; + border-radius: 0; + background: #f8fafc; + color: var(--ink); + box-shadow: none; + text-align: left; + } + + .detail-accordion-toggle:hover { + background: #f1f5f9; + transform: none; + } + + .detail-accordion-panel { + display: none; + padding: 0 12px 12px; + overflow-x: auto; + } + + .detail-accordion.open .detail-accordion-panel { + display: block; + } + + .detail-accordion-arrow { + transition: transform .15s ease; + } + + .detail-accordion.open .detail-accordion-arrow { + transform: rotate(180deg); + } + + .detail-accordion-table { + width: 100%; + min-width: 860px; + border-collapse: collapse; + } + + .detail-accordion-table th, + .detail-accordion-table td { + padding: 8px; + border-bottom: 1px solid var(--line); + font-size: 12px; + text-align: left; + white-space: nowrap; + } + + .detail-accordion-table .col-money { + text-align: right; + } + + .cost-cache-status { + margin-left: auto; + min-width: 0; + padding: 0 4px; + color: #667085; + font-size: 11px; + white-space: nowrap; + } + + .cost-cache-status-line { + display: flex; + justify-content: flex-end; + padding: 8px 0 0; } @media (max-width: 1200px) { @@ -616,11 +845,6 @@ } @media (max-width: 720px) { - .cost-kpis { - overflow-x: auto; - grid-template-columns: repeat(9, minmax(96px, 1fr)); - } - .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -629,7 +853,7 @@ {% endblock %} {% block content %} -
+
@@ -675,21 +899,41 @@
-
- -
-
- -
+
+ 상태 확인 중 +
+
+ + + + + + + + + + + + + + + + - + @@ -702,58 +946,67 @@ - - - - + + + + + + + + - - + + - + - + + + + + + - + + + - + + - + + - + + - +
+ + + 사업전사업중사업후총계사업전사업중사업후총계
원가판관비원가판관비 원가판관비판관비 원가판관비판관비
- -
조회 중입니다.
조회 중입니다.
@@ -769,7 +1022,8 @@
- + +
@@ -784,7 +1038,10 @@
0건 - 0 +
+ + 0 +
@@ -801,35 +1058,70 @@ const typeFilter = document.getElementById("typeFilter"); const statusFilter = document.getElementById("statusFilter"); const projectSearch = document.getElementById("projectSearch"); - const missingGradeButton = document.getElementById("missingGradeButton"); - const hanmacCacheRebuildButton = document.getElementById("hanmacCacheRebuildButton"); const searchButton = form.querySelector('button[type="submit"]'); const tableWrap = document.querySelector(".cost-table-wrap"); + const costTable = document.getElementById("costAnalysisTable"); const body = document.getElementById("costAnalysisBody"); const kpis = document.getElementById("costKpis"); const summaryTableBody = document.getElementById("summaryTableBody"); const summaryToggle = document.getElementById("summaryToggle"); const summaryTableRow = document.getElementById("summaryTableRow"); + const summaryControlFiller = document.getElementById("summaryControlFiller"); + const prePhaseHeader = document.getElementById("prePhaseHeader"); + const preCostHeader = document.getElementById("preCostHeader"); + let lastRenderedTableScrollTop = -1; + let tableScrollRenderPending = false; + let stableTableScrollLeft = 0; + let tableHorizontalScrollAllowedUntil = 0; + let tableHorizontalScrollbarDragging = false; + let restoringTableScrollLeft = false; const modal = document.getElementById("costDetailModal"); const modalTitle = document.getElementById("detailModalTitle"); const modalMeta = document.getElementById("detailModalMeta"); + const modalTable = document.getElementById("detailModalTable"); + const modalAccordion = document.getElementById("detailModalAccordion"); const modalHeader = document.getElementById("detailModalHeader"); const modalBody = document.getElementById("detailModalBody"); const modalCount = document.getElementById("detailModalCount"); + const modalHours = document.getElementById("detailModalHours"); const modalTotal = document.getElementById("detailModalTotal"); const modalClose = document.getElementById("detailModalClose"); + const costCacheStatus = document.getElementById("costCacheStatus"); + + const queryStateStorageKey = "cost-analysis-query-state-v2"; + + function readSavedQueryState() { + try { + const parsed = JSON.parse(window.localStorage.getItem(queryStateStorageKey) || "{}"); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (_error) { + return {}; + } + } + + function isValidSavedDate(value) { + return /^\d{4}-\d{2}-\d{2}$/.test(String(value || "")); + } + + const savedQueryState = readSavedQueryState(); + if (isValidSavedDate(savedQueryState.startDate)) startDateInput.value = savedQueryState.startDate; + if (isValidSavedDate(savedQueryState.endDate)) endDateInput.value = savedQueryState.endDate; let allRows = []; let currentPayload = null; - let currentMode = "individual"; + let currentMode = savedQueryState.mode === "aggregate" ? "aggregate" : "individual"; + let activeRefreshJobId = ""; + let refreshPollTimer = null; let lockedStartDate = startDateInput.value; let lockedEndDate = endDateInput.value; let sortState = { key: "", direction: "asc", type: "text" }; let latestDisplayRows = []; + let latestKpiSummary = null; let activeLoadController = null; + let showPreOutsourceColumn = false; + let commonDetailsOpen = false; const virtualRowHeight = 43; const virtualBufferRows = 8; - const hanmacCredentialStorageKey = "hanmac-db-external-credentials-v1"; const phaseLabels = { pre: "사업전", during: "사업중", post: "사업후", all: "전체" }; const itemLabels = { @@ -837,16 +1129,73 @@ billing: "청구금액", collection: "수금금액", labor: "인건비", + labor_combined: "인건비", + labor_adjustment: "조정인건비", outsource: "외주비", overhead: "제경비", + sga_labor: "판관비 인건비", + sga_labor_combined: "판관비 인건비", + sga_labor_adjustment: "판관비 조정인건비", sga: "판관비", sales: "영업비", cost_total: "원가합계", + cost_labor_total: "원가인건비합계", sga_total: "판관비합계", + sga_labor_total: "판관비인건비합계", sales_total: "영업비합계", total_cost: "총비용", }; + function renderCacheStatus(payload) { + const info = payload?.cache_info || {}; + const sourceLabels = { + memory: "메모리 캐시", + persistent: "저장 캐시", + computed: "새 계산", + "last-valid": "직전 결과", + "hanmac-auto-refresh": "근무데이터 자동 갱신", + pending: "계산 대기", + }; + const source = sourceLabels[info.source] || info.source || "상태 미확인"; + const generated = info.generated_at || info.updated_at || ""; + costCacheStatus.textContent = `${source}${generated ? ` · ${generated.replace("T", " ")}` : ""}`; + costCacheStatus.title = [ + `재무 로직: ${info.financial_logic_version || "-"}`, + `H코드 매핑: ${info.h_project_mapping_version || "-"}`, + `연계 로직: ${info.link_logic_version || "-"}`, + `데이터 버전: ${info.data_version || "-"}`, + ].join("\n"); + } + + function tableColumnCount() { + return showPreOutsourceColumn ? 49 : 48; + } + + function hasPreOutsource(rows, summary) { + const summaryAmount = Number(summary?.phases?.pre?.outsource || 0); + return Math.abs(summaryAmount) > 0.5 || rows.some((row) => { + const amount = Number(row?.phases?.pre?.outsource || 0); + return Math.abs(amount) > 0.5; + }); + } + + function updatePreOutsourceColumnVisibility(show) { + showPreOutsourceColumn = Boolean(show); + costTable?.classList.toggle("has-pre-outsource", showPreOutsourceColumn); + document.querySelectorAll(".pre-outsource-col").forEach((element) => { + element.classList.toggle("is-hidden", !showPreOutsourceColumn); + }); + if (prePhaseHeader) prePhaseHeader.colSpan = showPreOutsourceColumn ? 6 : 5; + if (preCostHeader) preCostHeader.colSpan = showPreOutsourceColumn ? 3 : 2; + if (summaryControlFiller) summaryControlFiller.colSpan = Math.max(1, tableColumnCount() - 1); + } + + function setSummaryOpen(open) { + summaryTableBody.classList.toggle("open", Boolean(open)); + summaryToggle?.setAttribute("aria-expanded", open ? "true" : "false"); + summaryToggle?.classList.toggle("is-hidden", Boolean(open)); + } + function escapeHtml(value) { return String(value ?? "") .replace(/&/g, "&") @@ -876,6 +1225,18 @@ return `${number.toFixed(1)}%`; } + function formatHours(value) { + return Number(value || 0).toLocaleString("ko-KR", { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }); + } + + function resetModalHours() { + modalHours.hidden = true; + modalHours.textContent = ""; + } + function normalizeDateInputYear(input) { const value = String(input.value || ""); const match = value.match(/^(\d{4,})(-\d{2}-\d{2})$/); @@ -888,6 +1249,15 @@ normalizeDateInputYear(endDateInput); if (startDateInput.value) lockedStartDate = startDateInput.value; if (endDateInput.value) lockedEndDate = endDateInput.value; + try { + window.localStorage.setItem(queryStateStorageKey, JSON.stringify({ + startDate: lockedStartDate, + endDate: lockedEndDate, + mode: currentMode, + })); + } catch (_error) { + // Browser storage may be unavailable; in-page state still remains active. + } } function restoreRememberedDates() { @@ -898,14 +1268,22 @@ function amountCell(row, phase, item, amount, options = {}) { const value = Number(amount || 0); if (!value) return `-`; - const allocated = phase && item ? Number(row?.allocated?.[phase]?.[item] || 0) : 0; + const allocatedBucket = row?.allocated?.[phase] || {}; + const allocated = item === "labor_combined" + ? Number(allocatedBucket.labor || 0) + Number(allocatedBucket.labor_adjustment || 0) + : item === "sga_labor_combined" + ? Number(allocatedBucket.sga_labor || 0) + Number(allocatedBucket.sga_labor_adjustment || 0) + : phase && item ? Number(allocatedBucket[item] || 0) : 0; const hasDirect = Math.abs(value - allocated) > 0.5; - if (!hasDirect && allocated) { - return `${formatNumber(value)}`; - } - const codes = options.codes || (phase === "pre" || row.view_mode === "aggregate" ? row.direct_codes : [row.support_dept_code]); + const codes = row.is_common_activity + ? (row.detail_codes || []) + : options.codes || (phase === "pre" || row.view_mode === "aggregate" ? row.direct_codes : [row.support_dept_code]); const label = options.label || itemLabels[item] || item; - return ``; + const representativeCode = row.view_mode === "aggregate" || ["0", "9"].includes(String(row.support_dept_code || "").slice(0, 1)) + ? row.support_dept_code + : ""; + const allocationTitle = !hasDirect && allocated ? " title=\"공통비 배분 상세\"" : ""; + return ``; } function phaseCost(row, phase) { @@ -920,17 +1298,159 @@ function renderKpis(summary) { const items = [ - ["프로젝트", `${formatNumber(summary.project_count)}건`], - ["도급금액", formatNumber(summary.contract_amount)], - ["기간 청구금액", formatNumber(summary.billing_amount)], - ["기간 수금금액", formatNumber(summary.collection_amount)], - ["잔여도급금액", formatBalance(summary.contract_balance_amount)], - ["총비용", formatNumber(summary.total_cost)], - ["이윤", formatNumber(summary.profit_amount)], - ["수금수익률", formatRate(summary.collection_profit_rate)], - ["누적수익률", formatRate(summary.cumulative_profit_rate)], + { label: "프로젝트", value: `${formatNumber(summary.project_count)}건` }, + { label: "도급금액", value: formatNumber(summary.contract_amount) }, + { label: "기간 청구금액", value: formatNumber(summary.period_billing_amount ?? summary.billing_amount) }, + { label: "기간 ERP매출", value: formatNumber(summary.period_revenue_amount ?? summary.revenue_amount) }, + { label: "매출-청구", value: formatNumber(summary.period_revenue_billing_gap), action: "revenue-billing-gap", title: "프로젝트별 기간 ERP매출과 기간청구 차이 보기" }, + { label: "기간 수금금액", value: formatNumber(summary.period_collection_amount ?? summary.collection_amount) }, + { label: "잔여도급금액", value: formatBalance(summary.contract_balance_amount) }, + { label: "기간 배부비용", value: formatNumber(summary.total_cost), action: "allocated-cost", title: "기간 배부비용 구성 항목 보기" }, + { label: "기간 손익", value: formatNumber(summary.profit_amount) }, + { label: "수금수익률", value: formatRate(summary.collection_profit_rate) }, + { label: "누적수익", value: formatNumber(summary.cumulative_revenue_amount) }, + { label: "누적비용", value: formatNumber(summary.cumulative_total_cost) }, + { label: "누적손익", value: formatNumber(summary.cumulative_profit_amount) }, + { label: "누적수익률", value: formatRate(summary.cumulative_profit_rate) }, ]; - kpis.innerHTML = items.map(([label, value]) => `
${escapeHtml(label)}${escapeHtml(value)}
`).join(""); + kpis.style.setProperty("--kpi-count", String(Math.max(1, items.length))); + kpis.innerHTML = items.map((item) => { + if (item.action) { + return `
`; + } + return `
${escapeHtml(item.label)}${escapeHtml(item.value)}
`; + }).join(""); + } + + function openAllocatedCostComposition() { + modalTable.hidden = false; + modalAccordion.hidden = true; + resetModalHours(); + const visibleSummary = latestKpiSummary || {}; + const visibleAllocatedCost = Number(visibleSummary.total_cost || 0); + const phases = visibleSummary.phases || {}; + const phaseLabelsForCost = { pre: "착수전", during: "진행중", post: "완료후" }; + const itemRows = []; + for (const [phase, phaseLabel] of Object.entries(phaseLabelsForCost)) { + const bucket = phases[phase] || {}; + for (const [item, label] of [ + ["labor", "인건비"], + ["labor_adjustment", "조정인건비"], + ["outsource", "외주비"], + ["overhead", "제경비"], + ["sga_labor", "판관비 인건비"], + ["sga_labor_adjustment", "판관비 조정인건비"], + ["sga", "판관비"], + ["sales", "영업비"], + ]) { + const amount = Number(bucket[item] || 0); + if (Math.abs(amount) <= 0.5) continue; + itemRows.push({ + phase: phaseLabel, + item: label, + amount, + note: ["labor", "labor_adjustment", "outsource", "overhead"].includes(item) + ? "원가 구성" + : "판관/영업비 구성", + }); + } + } + const rows = itemRows.length ? itemRows : [{ phase: "-", item: "기간 배부비용", amount: visibleAllocatedCost, note: "구성 항목 없음" }]; + modalTitle.textContent = "기간 배부비용 구성"; + modalMeta.textContent = `${lockedStartDate} ~ ${lockedEndDate} · 현재 화면 프로젝트 기준`; + modalHeader.innerHTML = ``; + modalBody.innerHTML = rows.map((row) => ` + + + + + + + `).join(""); + modalCount.textContent = `${formatNumber(rows.length)}개 항목`; + modalTotal.textContent = `기간 배부비용 ${formatNumber(visibleAllocatedCost)}`; + modal.classList.add("open"); + } + + function openRevenueBillingGap(row = null) { + modalTable.hidden = false; + modalAccordion.hidden = true; + resetModalHours(); + const sourceRows = row ? [row] : getFilteredRows().filter((item) => !item.exclude_from_totals); + const rows = sourceRows + .filter((item) => Math.abs(Number(item.period_revenue_billing_gap || 0)) > 0.5) + .map((item) => ({ + code: item.support_dept_code || "-", + name: item.project_name || "-", + revenue: Number(item.period_revenue_amount || 0), + billing: Number(item.period_billing_amount || 0), + gap: Number(item.period_revenue_billing_gap || 0), + })) + .sort((a, b) => Math.abs(b.gap) - Math.abs(a.gap)); + const totalRevenue = rows.reduce((sum, item) => sum + item.revenue, 0); + const totalBilling = rows.reduce((sum, item) => sum + item.billing, 0); + const totalGap = rows.reduce((sum, item) => sum + item.gap, 0); + modalTitle.textContent = row ? "매출-청구 상세" : "매출-청구 차이"; + modalMeta.textContent = `${lockedStartDate} ~ ${lockedEndDate} · 기간 ERP매출 - 기간청구`; + modalHeader.innerHTML = ``; + modalBody.innerHTML = rows.length ? rows.map((item) => ` + + + + + + + + `).join("") : ``; + modalCount.textContent = `${formatNumber(rows.length)}개 프로젝트`; + modalTotal.textContent = `매출 ${formatNumber(totalRevenue)} · 청구 ${formatNumber(totalBilling)} · 차이 ${formatNumber(totalGap)}`; + modal.classList.add("open"); + } + + function openAccountingComparison() { + modalTable.hidden = false; + modalAccordion.hidden = true; + resetModalHours(); + const comparison = currentPayload?.accounting_comparison || {}; + const visibleSummary = latestKpiSummary || {}; + const visibleAllocatedCost = Number(visibleSummary.total_cost ?? comparison.allocated_cost ?? 0); + const visibleProfit = Number(visibleSummary.profit_amount ?? comparison.allocated_profit ?? 0); + const visibleRevenue = Number(visibleSummary.revenue_amount || 0); + const rows = [ + ["현재 화면 기간 배부비용", visibleAllocatedCost, "현재 필터에 보이는 프로젝트의 관리 목적 배부비용"], + ["현재 화면 기간 수익", visibleProfit, `현재 화면 수익률 ${formatRate(visibleRevenue ? visibleProfit / visibleRevenue * 100 : 0)}`], + ["공통 제경비 배부", comparison.common_cost_allocated, "공통 5xx 중 급여성 계정을 제외한 비인건비"], + ["공통 판관비 배부", comparison.common_sga_allocated, "공통 6xx 판관비"], + ["한맥 부족근로 판관비", comparison.hanmac_missing_regular_sga_allocated, "관리/지원부서 또는 부서 프로젝트 비율이 없는 정규근로 부족분"], + ["한맥 근무·부족분 보완 인건비", comparison.hanmac_labor_total, "실제 근무시간 인건비와 실무부서 부족분 원가, 관리·미배부 부족분 판관비를 중복 없이 합산"], + ["중복 방지 제외 인건비", comparison.common_labor_excluded, "시간기반 인건비와 중복되지 않도록 공통 5xx 급여성 계정을 배부에서 제외"], + ]; + if (comparison.available) { + rows.push( + ["WEHAGO 인건비", comparison.wehago_labor_total, "원가성 인건비 604·606·609·611 + 판관 인건비 802·808·811"], + ["한맥 - WEHAGO 인건비 차이", comparison.hanmac_to_wehago_labor_gap, "양수는 한맥 산출액이 크고, 음수는 WEHAGO 인건비가 큼"], + ["ERP 영업비용", comparison.erp_operating_expense, "ERP 원가 + 판관비"], + ["WEHAGO 감사 후 영업비용", comparison.wehago_operating_expense, "회사 전체 감사 후 원가 + 판관비"], + ["배부비용 - WEHAGO 영업비용", comparison.allocated_to_wehago_expense_gap, "배부 목적 값과 회사 전체 회계비용의 차이"], + ["WEHAGO 감사 후 매출", comparison.wehago_revenue, "회사 전체 감사 후 매출"], + ["WEHAGO 감사 후 영업이익", comparison.wehago_operating_profit, `영업이익률 ${formatRate(comparison.wehago_operating_margin)}`], + ); + } + modalTitle.textContent = "기간 배부비용 구성 및 회계비용 비교"; + modalMeta.textContent = comparison.available + ? `${comparison.scope_label || ""} · WEHAGO 값은 회사 전체 기준` + : (comparison.unavailable_reason || "회계비용 비교값을 확인할 수 없습니다."); + modalHeader.innerHTML = ``; + modalBody.innerHTML = rows.map(([label, amount, note]) => ` + + + + + + `).join(""); + modalCount.textContent = `${formatNumber(rows.length)}개 항목`; + modalTotal.textContent = `현재 화면 기간 배부비용 ${formatNumber(visibleAllocatedCost)}`; + modal.classList.add("open"); } function renderSummaryTable(summary) { @@ -965,28 +1485,41 @@ ${moneyCell(summary.collection_amount)} ${balanceCell(summary.contract_balance_amount)} ${rateCell(summary.collection_rate)} - ${moneyCell(pre.labor)} + ${moneyCell(summary.period_revenue_amount || summary.revenue_amount)} + ${moneyCell(summary.period_billing_amount || summary.billing_amount)} + ${moneyCell(summary.period_collection_amount || summary.collection_amount)} + ${moneyCell(summary.period_revenue_billing_gap)} + ${moneyCell(Number(pre.labor || 0) + Number(pre.labor_adjustment || 0))} + ${showPreOutsourceColumn ? moneyCell(pre.outsource, "col-money pre-outsource-col") : ""} ${moneyCell(pre.overhead)} + ${moneyCell(Number(pre.sga_labor || 0) + Number(pre.sga_labor_adjustment || 0))} ${moneyCell(pre.sga)} ${moneyCell(pre.sales)} - ${moneyCell(during.labor)} + ${moneyCell(Number(during.labor || 0) + Number(during.labor_adjustment || 0))} ${moneyCell(during.outsource)} ${moneyCell(during.overhead)} + ${moneyCell(Number(during.sga_labor || 0) + Number(during.sga_labor_adjustment || 0))} ${moneyCell(during.sga)} ${moneyCell(during.sales)} - ${moneyCell(post.labor)} + ${moneyCell(Number(post.labor || 0) + Number(post.labor_adjustment || 0))} ${moneyCell(post.outsource)} ${moneyCell(post.overhead)} + ${moneyCell(Number(post.sga_labor || 0) + Number(post.sga_labor_adjustment || 0))} ${moneyCell(post.sga)} ${moneyCell(post.sales)} ${moneyCell(summary.revenue_amount)} ${moneyCell(summary.cost_total)} + ${moneyCell(summary.cost_labor_total)} ${moneyCell(summary.sga_total)} + ${moneyCell(summary.sga_labor_total)} ${moneyCell(summary.sales_total)} ${moneyCell(summary.total_cost)} ${moneyCell(summary.profit_amount)} ${rateCell(summary.revenue_profit_rate)} ${rateCell(summary.collection_profit_rate)} + ${moneyCell(summary.cumulative_revenue_amount)} + ${moneyCell(summary.cumulative_total_cost)} + ${moneyCell(summary.cumulative_profit_amount)} ${rateCell(summary.cumulative_profit_rate)} `; } @@ -1007,18 +1540,36 @@ const type = typeFilter.value; const status = statusFilter.value; const keyword = projectSearch.value.trim().toLowerCase(); - const rows = allRows.filter((row) => { + const filteredRows = allRows.filter((row) => { if (pm && row.pm_department !== pm) return false; if (type && row.project_type !== type) return false; if (status && row.completion_status !== status) return false; - if (keyword && !`${row.project_name || ""} ${row.pm_department || ""}`.toLowerCase().includes(keyword)) return false; + if (keyword && !`${row.project_name || ""} ${row.pm_department || ""} ${(row.direct_codes || []).join(" ")}`.toLowerCase().includes(keyword)) return false; return true; }); - if (!sortState.key) return rows; - return [...rows].sort((a, b) => compareRows(a, b, sortState)); + const masterRows = filteredRows.filter((row) => row.is_common_master); + const hiddenCommonRows = filteredRows.filter((row) => row.is_hidden_common_activity); + const regularRows = filteredRows.filter((row) => !row.is_common_master && !row.is_hidden_common_activity); + const sorter = (a, b) => sortState.key ? compareRows(a, b, sortState) : 0; + masterRows.sort(sorter); + hiddenCommonRows.sort(sorter); + regularRows.sort(sorter); + return [ + ...masterRows, + ...(commonDetailsOpen ? hiddenCommonRows : []), + ...regularRows, + ]; } function getSortValue(row, key) { + if (key.endsWith(".labor_combined")) { + const bucket = key.split(".").slice(0, -1).reduce((value, part) => value && value[part] !== undefined ? value[part] : {}, row) || {}; + return Number(bucket.labor || 0) + Number(bucket.labor_adjustment || 0); + } + if (key.endsWith(".sga_labor_combined")) { + const bucket = key.split(".").slice(0, -1).reduce((value, part) => value && value[part] !== undefined ? value[part] : {}, row) || {}; + return Number(bucket.sga_labor || 0) + Number(bucket.sga_labor_adjustment || 0); + } return key.split(".").reduce((value, part) => value && value[part] !== undefined ? value[part] : "", row); } @@ -1046,10 +1597,52 @@ }); } + function releaseTablePointerFocus() { + const active = document.activeElement; + if (active && active !== document.body && tableWrap?.contains(active)) { + active.blur(); + } + } + + function allowTableHorizontalScroll(durationMs = 900) { + tableHorizontalScrollAllowedUntil = Math.max(tableHorizontalScrollAllowedUntil, Date.now() + durationMs); + } + + function isTableHorizontalScrollAllowed() { + return tableHorizontalScrollbarDragging || Date.now() <= tableHorizontalScrollAllowedUntil; + } + + function setTableScrollPosition(top = 0, left = 0) { + if (!tableWrap) return; + restoringTableScrollLeft = true; + tableWrap.scrollTop = top; + tableWrap.scrollLeft = left; + restoringTableScrollLeft = false; + stableTableScrollLeft = left; + lastRenderedTableScrollTop = top; + } + + function restoreUnexpectedTableScrollLeft() { + if (!tableWrap || restoringTableScrollLeft) return false; + const currentLeft = tableWrap.scrollLeft || 0; + if (Math.abs(currentLeft - stableTableScrollLeft) < 1) return false; + if (isTableHorizontalScrollAllowed()) { + stableTableScrollLeft = currentLeft; + return false; + } + restoringTableScrollLeft = true; + tableWrap.scrollLeft = stableTableScrollLeft; + restoringTableScrollLeft = false; + return true; + } + function renderTable() { const rows = latestDisplayRows.length ? latestDisplayRows : getFilteredRows(); + const preservedScrollLeft = tableWrap?.scrollLeft || 0; if (!rows.length) { - body.innerHTML = ``; + body.innerHTML = ``; + lastRenderedTableScrollTop = tableWrap?.scrollTop || 0; + stableTableScrollLeft = tableWrap?.scrollLeft || stableTableScrollLeft; return; } const viewportHeight = tableWrap?.clientHeight || 600; @@ -1060,19 +1653,27 @@ const topHeight = startIndex * virtualRowHeight; const bottomHeight = Math.max(0, (rows.length - endIndex) * virtualRowHeight); const visibleRows = rows.slice(startIndex, endIndex); - const topSpacer = topHeight ? `` : ""; - const bottomSpacer = bottomHeight ? `` : ""; + const topSpacer = topHeight ? `` : ""; + const bottomSpacer = bottomHeight ? `` : ""; body.innerHTML = topSpacer + visibleRows.map((row, visibleOffset) => { const rowIndex = startIndex + visibleOffset; const pre = row.phases?.pre || {}; const during = row.phases?.during || {}; const post = row.phases?.post || {}; const linkedMembers = Array.isArray(row.aggregate_members) ? row.aggregate_members : []; + const sourceCodeTitle = row.is_common_activity && Array.isArray(row.direct_codes) + ? `원천 업무코드: ${row.direct_codes.join(", ")}` + : ""; const codeCell = row.view_mode === "aggregate" && linkedMembers.length > 1 ? `` - : escapeHtml(row.support_dept_code || "-"); + : `${escapeHtml(row.support_dept_code || "-")}`; + const rowClasses = [ + row.is_common_master ? "common-master-row" : "", + row.is_hidden_common_activity ? "common-detail-row" : "", + row.is_common_exception ? "common-exception-row" : "", + ].filter(Boolean).join(" "); return ` - + @@ -1086,39 +1687,58 @@ - + + + + + + ${showPreOutsourceColumn ? `` : ""} + - + + - + + + + - - - - + + + + + + + `; }).join("") + bottomSpacer; + if (tableWrap && tableWrap.scrollLeft !== preservedScrollLeft) { + tableWrap.scrollLeft = preservedScrollLeft; + } + stableTableScrollLeft = tableWrap?.scrollLeft || stableTableScrollLeft; + lastRenderedTableScrollTop = tableWrap?.scrollTop || 0; } function recalcVisibleSummary() { const rows = getFilteredRows(); latestDisplayRows = rows; - const buildSummary = (usePeriodValues = false) => rows.reduce((acc, row) => { - for (const key of ["contract_amount", "billing_amount", "collection_amount", "contract_balance_amount", "revenue_amount", "cost_total", "sga_total", "sales_total", "total_cost", "profit_amount"]) { + const accountingRows = rows.filter((row) => !row.exclude_from_totals); + const buildSummary = (usePeriodValues = false) => accountingRows.reduce((acc, row) => { + for (const key of ["contract_amount", "billing_amount", "collection_amount", "contract_balance_amount", "revenue_amount", "period_billing_amount", "period_negative_billing_amount", "period_collection_amount", "period_revenue_amount", "period_revenue_billing_gap", "period_revenue_collection_gap", "cost_total", "cost_labor_total", "sga_total", "sga_labor_total", "sales_total", "total_cost", "profit_amount", "cumulative_revenue_amount", "cumulative_total_cost", "cumulative_profit_amount"]) { acc[key] += Number(row[key] || 0); } if (usePeriodValues) { @@ -1126,14 +1746,16 @@ acc.collection_amount += Number(row.period_collection_amount || 0) - Number(row.collection_amount || 0); acc.revenue_amount += Number(row.period_revenue_amount || 0) - Number(row.revenue_amount || 0); acc.cost_total += Number(row.period_cost_total || 0) - Number(row.cost_total || 0); + acc.cost_labor_total += Number(row.period_cost_labor_total || 0) - Number(row.cost_labor_total || 0); acc.sga_total += Number(row.period_sga_total || 0) - Number(row.sga_total || 0); + acc.sga_labor_total += Number(row.period_sga_labor_total || 0) - Number(row.sga_labor_total || 0); acc.sales_total += Number(row.period_sales_total || 0) - Number(row.sales_total || 0); acc.total_cost += Number(row.period_total_cost || 0) - Number(row.total_cost || 0); acc.profit_amount += Number(row.period_profit_amount || 0) - Number(row.profit_amount || 0); } for (const phase of ["pre", "during", "post"]) { const bucket = row.phases?.[phase] || {}; - for (const item of ["labor", "outsource", "overhead", "sga", "sales"]) { + for (const item of ["labor", "labor_adjustment", "outsource", "overhead", "sga_labor", "sga_labor_adjustment", "sga", "sales"]) { acc.phases[phase][item] += Number(bucket[item] || 0); } } @@ -1146,16 +1768,26 @@ collection_amount: 0, contract_balance_amount: 0, revenue_amount: 0, + period_billing_amount: 0, + period_negative_billing_amount: 0, + period_collection_amount: 0, + period_revenue_amount: 0, + period_revenue_billing_gap: 0, + period_revenue_collection_gap: 0, cost_total: 0, + cost_labor_total: 0, sga_total: 0, + sga_labor_total: 0, sales_total: 0, total_cost: 0, profit_amount: 0, cumulative_profit_amount: 0, + cumulative_revenue_amount: 0, + cumulative_total_cost: 0, phases: { - pre: { labor: 0, outsource: 0, overhead: 0, sga: 0, sales: 0 }, - during: { labor: 0, outsource: 0, overhead: 0, sga: 0, sales: 0 }, - post: { labor: 0, outsource: 0, overhead: 0, sga: 0, sales: 0 }, + pre: { labor: 0, labor_adjustment: 0, outsource: 0, overhead: 0, sga_labor: 0, sga_labor_adjustment: 0, sga: 0, sales: 0 }, + during: { labor: 0, labor_adjustment: 0, outsource: 0, overhead: 0, sga_labor: 0, sga_labor_adjustment: 0, sga: 0, sales: 0 }, + post: { labor: 0, labor_adjustment: 0, outsource: 0, overhead: 0, sga_labor: 0, sga_labor_adjustment: 0, sga: 0, sales: 0 }, }, }); const summary = buildSummary(false); @@ -1163,29 +1795,73 @@ summary.contract_profit_rate = summary.contract_amount ? summary.profit_amount / summary.contract_amount * 100 : 0; summary.revenue_profit_rate = summary.revenue_amount ? summary.profit_amount / summary.revenue_amount * 100 : 0; summary.collection_profit_rate = summary.collection_amount ? summary.profit_amount / summary.collection_amount * 100 : 0; - summary.cumulative_profit_rate = summary.collection_amount ? (summary.collection_amount - summary.total_cost) / summary.collection_amount * 100 : 0; + summary.cumulative_profit_rate = summary.cumulative_revenue_amount ? summary.cumulative_profit_amount / summary.cumulative_revenue_amount * 100 : 0; const kpiSummary = buildSummary(true); kpiSummary.collection_rate = kpiSummary.contract_amount ? kpiSummary.collection_amount / kpiSummary.contract_amount * 100 : 0; kpiSummary.contract_profit_rate = kpiSummary.contract_amount ? kpiSummary.profit_amount / kpiSummary.contract_amount * 100 : 0; kpiSummary.revenue_profit_rate = kpiSummary.revenue_amount ? kpiSummary.profit_amount / kpiSummary.revenue_amount * 100 : 0; kpiSummary.collection_profit_rate = kpiSummary.collection_amount ? kpiSummary.profit_amount / kpiSummary.collection_amount * 100 : 0; + kpiSummary.cumulative_revenue_amount = summary.cumulative_revenue_amount; + kpiSummary.cumulative_total_cost = summary.cumulative_total_cost; + kpiSummary.cumulative_profit_amount = summary.cumulative_profit_amount; kpiSummary.cumulative_profit_rate = summary.cumulative_profit_rate; + latestKpiSummary = kpiSummary; renderKpis(kpiSummary); renderSummaryTable(summary); } - async function loadData() { + function scheduleRefreshPoll(jobId) { + if (!jobId || activeRefreshJobId === jobId) return; + activeRefreshJobId = jobId; + if (refreshPollTimer) window.clearTimeout(refreshPollTimer); + const poll = async () => { + try { + const response = await fetch(`/api/system-jobs/${encodeURIComponent(jobId)}`, { + headers: { Accept: "application/json" }, + cache: "no-store", + }); + const payload = await response.json(); + const job = payload.job || null; + if (!response.ok || !job) throw new Error(payload.error || "갱신 상태를 확인하지 못했습니다."); + if (["done", "failed", "cancelled"].includes(String(job.status || ""))) { + activeRefreshJobId = ""; + refreshPollTimer = null; + if (job.status === "done") await loadData({ background: false, preserve: true }); + else searchButton.textContent = "조회"; + return; + } + searchButton.textContent = "갱신 중"; + refreshPollTimer = window.setTimeout(poll, 1200); + } catch (error) { + activeRefreshJobId = ""; + refreshPollTimer = null; + searchButton.textContent = "조회"; + } + }; + refreshPollTimer = window.setTimeout(poll, 700); + } + + async function loadData(options = {}) { restoreRememberedDates(); if (activeLoadController) activeLoadController.abort(); activeLoadController = new AbortController(); const controller = activeLoadController; + const preserve = options.preserve !== false && allRows.length > 0; + const background = options.background !== false; searchButton.disabled = true; searchButton.textContent = "조회 중"; - kpis.innerHTML = `
조회조회 중
`; - summaryTableRow.innerHTML = ""; - body.innerHTML = ``; + if (!preserve) { + kpis.innerHTML = `
조회조회 중
`; + summaryTableRow.innerHTML = ""; + body.innerHTML = ``; + } try { - const params = new URLSearchParams({ start_date: lockedStartDate, end_date: lockedEndDate, mode: currentMode }); + const params = new URLSearchParams({ + start_date: lockedStartDate, + end_date: lockedEndDate, + mode: currentMode, + background: background ? "true" : "false", + }); const response = await fetch(`/cost-analysis/data?${params.toString()}`, { headers: { Accept: "application/json" }, cache: "no-store", @@ -1194,12 +1870,29 @@ const payload = await response.json(); if (!response.ok || payload.error) throw new Error(payload.error || "조회에 실패했습니다."); if (controller !== activeLoadController) return; + if (payload.pending) { + renderCacheStatus(payload); + if (Array.isArray(payload.rows) && payload.rows.length) { + currentPayload = payload; + allRows = payload.rows; + updatePreOutsourceColumnVisibility(hasPreOutsource(allRows, payload.summary)); + refreshFilterOptions(allRows); + updateSortHeaders(); + recalcVisibleSummary(); + renderTable(); + } + searchButton.textContent = "갱신 중"; + scheduleRefreshPoll(payload.job?.id || ""); + return; + } currentPayload = payload; + renderCacheStatus(payload); allRows = Array.isArray(payload.rows) ? payload.rows : []; + updatePreOutsourceColumnVisibility(hasPreOutsource(allRows, payload.summary)); refreshFilterOptions(allRows); updateSortHeaders(); recalcVisibleSummary(); - if (tableWrap) tableWrap.scrollTop = 0; + setTableScrollPosition(0, 0); renderTable(); } catch (error) { if (error?.name === "AbortError") return; @@ -1208,74 +1901,192 @@ if (controller === activeLoadController) { activeLoadController = null; searchButton.disabled = false; - searchButton.textContent = "조회"; + if (!activeRefreshJobId) searchButton.textContent = "조회"; } } } - async function openDetail(button) { + function allocationItemMatches(detail, requestedItem, sourceView) { + const item = sourceView ? detail.source_item : detail.target_item; + if (requestedItem === "labor_combined") return ["labor", "labor_adjustment"].includes(item); + if (requestedItem === "sga_labor_combined") return ["sga_labor", "sga_labor_adjustment"].includes(item); + if (requestedItem === "total_cost") return true; + if (requestedItem === "cost_total") return ["labor", "labor_adjustment", "outsource", "overhead"].includes(item); + if (requestedItem === "sga_total") return ["sga_labor", "sga_labor_adjustment", "sga"].includes(item); + if (requestedItem === "sales_total") return item === "sales"; + return item === requestedItem; + } + + function detailAccordionSection(title, summary, content, open = false) { + return ` +
+ +
${content}
+
+ `; + } + + function directDetailTable(rows, detailType) { + if (!rows.length) return `
직접 귀속된 상세 내역이 없습니다.
`; + if (detailType === "billing") { + return ` +
일자 단계구성 항목금액구분
${escapeHtml(row.phase)}${escapeHtml(row.item)}${formatNumber(row.amount)}${escapeHtml(row.note)}
프로젝트사업명기간 ERP매출기간청구차이
${escapeHtml(item.code)}${escapeHtml(item.name)}${formatNumber(item.revenue)}${formatNumber(item.billing)}${formatNumber(item.gap)}
기간 ERP매출과 기간청구 차이가 있는 프로젝트가 없습니다.
구분금액해석
${escapeHtml(label)}${formatNumber(amount)}${escapeHtml(note)}
조건에 맞는 데이터가 없습니다.
조건에 맞는 데이터가 없습니다.
${escapeHtml(row.pm_department || "-")} ${codeCell} ${escapeHtml(row.project_name || "-")}${amountCell(row, "all", "collection", row.collection_amount, { codes: row.direct_codes || [row.support_dept_code], label: "수금금액" })} ${formatBalance(row.contract_balance_amount)} ${formatRate(row.collection_rate)}${amountCell(row, "pre", "labor", pre.labor)}${amountCell(row, "all", "revenue", row.period_revenue_amount, { codes: row.direct_codes || [row.support_dept_code], label: "기간 ERP매출" })}${amountCell(row, "all", "billing", row.period_billing_amount, { codes: row.direct_codes || [row.support_dept_code], label: "기간청구" })}${amountCell(row, "all", "collection", row.period_collection_amount, { codes: row.direct_codes || [row.support_dept_code], label: "기간수금" })}${amountCell(row, "pre", "labor_combined", Number(pre.labor || 0) + Number(pre.labor_adjustment || 0))}${amountCell(row, "pre", "outsource", pre.outsource)}${amountCell(row, "pre", "overhead", pre.overhead)}${amountCell(row, "pre", "sga_labor_combined", Number(pre.sga_labor || 0) + Number(pre.sga_labor_adjustment || 0))} ${amountCell(row, "pre", "sga", pre.sga)} ${amountCell(row, "pre", "sales", pre.sales)}${amountCell(row, "during", "labor", during.labor)}${amountCell(row, "during", "labor_combined", Number(during.labor || 0) + Number(during.labor_adjustment || 0))} ${amountCell(row, "during", "outsource", during.outsource)} ${amountCell(row, "during", "overhead", during.overhead)}${amountCell(row, "during", "sga_labor_combined", Number(during.sga_labor || 0) + Number(during.sga_labor_adjustment || 0))} ${amountCell(row, "during", "sga", during.sga)} ${amountCell(row, "during", "sales", during.sales)}${amountCell(row, "post", "labor", post.labor)}${amountCell(row, "post", "labor_combined", Number(post.labor || 0) + Number(post.labor_adjustment || 0))} ${amountCell(row, "post", "outsource", post.outsource)} ${amountCell(row, "post", "overhead", post.overhead)}${amountCell(row, "post", "sga_labor_combined", Number(post.sga_labor || 0) + Number(post.sga_labor_adjustment || 0))} ${amountCell(row, "post", "sga", post.sga)} ${amountCell(row, "post", "sales", post.sales)} ${amountCell(row, "all", "revenue", row.revenue_amount, { codes: row.direct_codes || [row.support_dept_code], label: "청구수익" })} ${amountCell(row, "all", "cost_total", row.cost_total, { codes: row.direct_codes, label: "원가합계" })}${formatNumber(row.cost_labor_total)} ${amountCell(row, "all", "sga_total", row.sga_total, { codes: row.direct_codes, label: "판관비합계" })}${formatNumber(row.sga_labor_total)} ${amountCell(row, "all", "sales_total", row.sales_total, { codes: row.direct_codes, label: "영업비합계" })} ${amountCell(row, "all", "total_cost", row.total_cost, { codes: row.direct_codes, label: "총비용" })}${formatNumber(row.profit_amount)}${formatRate(row.revenue_profit_rate)}${formatRate(row.collection_profit_rate)}${formatRate(row.cumulative_profit_rate)}${row.is_common_master ? "-" : formatNumber(row.profit_amount)}${row.is_common_master ? "-" : formatRate(row.revenue_profit_rate)}${row.is_common_master ? "-" : formatRate(row.collection_profit_rate)}${row.is_common_master ? "-" : formatNumber(row.cumulative_revenue_amount)}${row.is_common_master ? "-" : formatNumber(row.cumulative_total_cost)}${row.is_common_master ? "-" : formatNumber(row.cumulative_profit_amount)}${row.is_common_master ? "-" : formatRate(row.cumulative_profit_rate)}
조회 중입니다.
조회 중입니다.
+ + ${rows.map((row) => ` + + + + + + + + + + + + `).join("")} +
DB 청구일세금계산서일ERP 증빙일거래처프로젝트가전표번호확정전표번호대조상태금액
${escapeHtml(row.posting_date || "-")}${escapeHtml(row.tax_invoice_date || "-")}${escapeHtml(row.erp_posting_date || "-")}${escapeHtml(row.partner_name || "-")}${escapeHtml(row.project_code || "-")}${escapeHtml(row.voucher_number || "-")}${escapeHtml(row.confirmed_voucher_number || "-")}${escapeHtml(row.match_status || "-")}${formatNumber(row.amount)}
+ `; + } + if (detailType === "collection") { + return ` + + + ${rows.map((row) => ` + + + + + + + + + + + + + `).join("")} +
수금 전표일미수금계정거래처프로젝트가전표번호확정전표번호원 청구 전표환산상태공급가액입금총액
${escapeHtml(row.posting_date || "-")}${escapeHtml(row.account_name || "-")}${escapeHtml(row.partner_name || "-")}${escapeHtml(row.project_code || "-")}${escapeHtml(row.voucher_number || "-")}${escapeHtml(row.confirmed_voucher_number || "-")}${escapeHtml(row.source_invoice_confirmed_voucher_number || row.source_invoice_voucher_number || "-")}${escapeHtml(row.match_status || "-")}${formatNumber(row.amount)}${formatNumber(row.gross_amount)}
+ `; + } + if (detailType === "labor") { + return ` + + + ${rows.map((row) => ` + + + + + + + + + + + + `).join("")} +
사번성명직급프로젝트정규/연장/휴일기본 인건비연장 가산휴일 가산합계
${escapeHtml(row.member_no || "-")}${escapeHtml(row.member_name || "-")}${escapeHtml(row.member_grade || "-")}${escapeHtml(row.project_code || "-")}${formatHours(row.regular_hours)} / ${formatHours(row.overtime_hours)} / ${formatHours(row.holiday_hours)}h${formatNumber(Number(row.regular_amount || 0) + Number(row.overtime_base_amount || 0) + Number(row.holiday_base_amount || 0))}${formatNumber(row.overtime_premium_amount)}${formatNumber(row.holiday_premium_amount)}${formatNumber(row.amount)}
+ `; + } + return ` + + + ${rows.map((row) => ` + + + + + + + + + `).join("")} +
일자계정명거래처개별프로젝트적요금액
${escapeHtml(row.posting_date || "-")}${escapeHtml(row.account_name || "-")}${escapeHtml(row.partner_name || "-")}${escapeHtml(row.project_code || "-")}${escapeHtml(row.memo1 || "")}${formatNumber(row.amount)}
+ `; + } + + function allocationDetailTable(rows, sourceView) { + if (!rows.length) return `
추가 배분 내역이 없습니다.
`; + return ` + + + ${rows.map((detail) => ` + + + + + + + + + + + `).join("")} +
원천 공통사업${sourceView ? "배분 프로젝트" : "원천코드"}단계프로젝트시간단계시간전체 대상시간배분율배분금액
${escapeHtml(detail.source_project_name || "-")}${escapeHtml(sourceView ? `${detail.target_project_code || ""} ${detail.target_project_name || ""}` : detail.source_project_code || "-")}${escapeHtml(phaseLabels[detail.target_phase] || detail.target_phase || "-")}${formatHours(detail.project_hours)}h${formatHours(detail.project_phase_hours)}h${formatHours(detail.eligible_total_hours)}h${(Number(detail.project_ratio || 0) * Number(detail.phase_ratio || 0) * 100).toFixed(2)}%${formatNumber(detail.allocated_amount)}
+ `; + } + + async function openDetail(button, row) { const params = new URLSearchParams({ start_date: lockedStartDate, end_date: lockedEndDate, codes: button.dataset.detailCodes || "", + representative_code: button.dataset.detailRepresentativeCode || "", phase: button.dataset.detailPhase || "all", item: button.dataset.detailItem || "", }); modalTitle.textContent = button.dataset.detailTitle || "상세 내역"; - modalMeta.textContent = `누적 ~ ${currentPayload?.end_date || lockedEndDate}`; - modalBody.innerHTML = `조회 중입니다.`; + modalMeta.textContent = `${lockedStartDate} ~ ${lockedEndDate}`; + modalTable.hidden = true; + modalAccordion.hidden = false; + modalAccordion.innerHTML = `
조회 중입니다.
`; modal.classList.add("open"); const response = await fetch(`/cost-analysis/detail?${params.toString()}`, { headers: { Accept: "application/json" } }); const payload = await response.json(); if (!response.ok || payload.error) throw new Error(payload.error || "상세 조회에 실패했습니다."); - const rows = Array.isArray(payload.rows) ? payload.rows : []; - if (payload.detail_type === "labor") { - modalHeader.innerHTML = ` - 사번 - 부서 - 성명 - 직급 - 근무시간 - 금액 - `; - modalBody.innerHTML = rows.length ? rows.map((row) => { - const total = Number(row.total_hours || 0); - const extra = Number(row.extra_hours || 0); - const workHours = `${total.toFixed(1)} (${extra.toFixed(1)})`; - return ` - - ${escapeHtml(row.member_no || "-")} - ${escapeHtml(row.dept_name || "-")} - ${escapeHtml(row.member_name || "-")} - ${escapeHtml(row.member_grade || "-")} - ${escapeHtml(workHours)} - ${formatNumber(row.amount)} - - `; - }).join("") : `상세 내역이 없습니다.`; - } else { - modalHeader.innerHTML = ` - 일자 - 계정명 - 거래처명 - 적요 - 금액 - `; - modalBody.innerHTML = rows.length ? rows.map((row) => ` - - ${escapeHtml(row.posting_date || "-")} - ${escapeHtml(row.account_name || "-")} - ${escapeHtml(row.partner_name || "-")} - ${escapeHtml(row.memo1 || "")} - ${formatNumber(row.amount)} - - `).join("") : `상세 내역이 없습니다.`; - } - modalCount.textContent = `${formatNumber(rows.length)}건`; - modalTotal.textContent = formatNumber(payload.total_amount || 0); + const rawRows = Array.isArray(payload.rows) ? payload.rows : []; + const directRows = rawRows.filter((item) => !String(item.account_name || "").startsWith("공통 ") || !String(item.account_name || "").includes("배부")); + const requestedPhase = button.dataset.detailPhase || "all"; + const requestedItem = button.dataset.detailItem || ""; + const sourceView = Boolean(row?.is_hidden_common_activity); + const allocationRows = (Array.isArray(row?.allocation_details) ? row.allocation_details : []).filter((detail) => { + const detailPhase = sourceView ? detail.source_phase : detail.target_phase; + if (requestedPhase !== "all" && detailPhase !== requestedPhase) return false; + return allocationItemMatches(detail, requestedItem, sourceView); + }); + const directSubtotal = directRows.reduce( + (sum, item) => sum + (item.included_in_total === false ? 0 : Number(item.amount || 0)), + 0, + ); + const allocationSubtotal = allocationRows.reduce((sum, item) => sum + Number(item.allocated_amount || 0), 0); + const displayAmount = Number(button.dataset.detailAmount || 0); + const totalTable = ` + + + + + + +
직접 귀속 원값${formatNumber(directSubtotal)}
${sourceView ? "타 프로젝트 배분액" : "추가 배분액"}${formatNumber(allocationSubtotal)}
화면 표시 합계${formatNumber(displayAmount)}
+ `; + modalAccordion.innerHTML = [ + detailAccordionSection("직접 귀속 원값", `${formatNumber(directSubtotal)}원`, directDetailTable(directRows, payload.detail_type), true), + detailAccordionSection(sourceView ? "프로젝트별 배분 내역" : "공통비 추가 배분", `${formatNumber(allocationSubtotal)}원`, allocationDetailTable(allocationRows, sourceView)), + detailAccordionSection("합계", `${formatNumber(displayAmount)}원`, totalTable), + ].join(""); + modalCount.textContent = `원값 ${formatNumber(directRows.length)}건 · 배분 ${formatNumber(allocationRows.length)}건`; + resetModalHours(); + modalTotal.textContent = `최종 ${formatNumber(displayAmount)}`; } function openLinkedProjects(row) { + modalTable.hidden = false; + modalAccordion.hidden = true; + resetModalHours(); const members = Array.isArray(row?.aggregate_members) ? row.aggregate_members : []; modalTitle.textContent = `${row?.support_dept_code || ""} 연계 프로젝트`; modalMeta.textContent = row?.project_name || ""; @@ -1306,143 +2117,23 @@ modal.classList.add("open"); } - async function openMissingGrades() { - restoreRememberedDates(); - const params = new URLSearchParams({ - start_date: lockedStartDate, - end_date: lockedEndDate, - }); - modalTitle.textContent = "직급 누락 투입 내역"; - modalMeta.textContent = `누적 ~ ${lockedEndDate}`; - modalHeader.innerHTML = ` - 일자 - 구분 - 프로젝트코드 - 사업명 - 성명 - 투입구분 - 시간 - 한맥집계기간 - `; - modalBody.innerHTML = `조회 중입니다.`; - modalCount.textContent = "0건"; - modalTotal.textContent = ""; - modal.classList.add("open"); - const response = await fetch(`/cost-analysis/missing-grade?${params.toString()}`, { headers: { Accept: "application/json" } }); - const payload = await response.json(); - if (!response.ok || payload.error) throw new Error(payload.error || "직급 누락 조회에 실패했습니다."); - const rows = Array.isArray(payload.rows) ? payload.rows : []; - modalBody.innerHTML = rows.length ? rows.map((row) => ` - - ${escapeHtml(row.work_date || "-")} - ${escapeHtml(row.phase || "-")} - ${escapeHtml(row.support_dept_code || "-")} - ${escapeHtml(row.project_name || "-")} - ${escapeHtml(row.member_name || "-")} - ${escapeHtml(row.hour_kind || "-")} - ${Number(row.hours || 0).toLocaleString("ko-KR", { maximumFractionDigits: 2 })} - ${escapeHtml(row.metric_range || "-")} - - `).join("") : `직급 누락 투입 내역이 없습니다.`; - modalCount.textContent = `${formatNumber(payload.row_count || rows.length)}건${payload.truncated ? " 중 5,000건 표시" : ""}`; - const totalHours = rows.reduce((sum, row) => sum + Number(row.hours || 0), 0); - modalTotal.textContent = `${totalHours.toLocaleString("ko-KR", { maximumFractionDigits: 1 })}h`; - } - - function getSavedHanmacCredentials() { - try { - const raw = window.localStorage.getItem(hanmacCredentialStorageKey); - const saved = raw ? JSON.parse(raw) : null; - return saved && typeof saved === "object" ? saved : null; - } catch (_error) { - return null; - } - } - - async function rebuildHanmacCache() { - restoreRememberedDates(); - const credentials = getSavedHanmacCredentials(); - if (!credentials?.password) { - modalTitle.textContent = "한맥 캐시 재생성"; - modalMeta.textContent = ""; - modalHeader.innerHTML = `안내`; - modalBody.innerHTML = `한맥 DB_external 페이지에서 접속 정보를 저장한 뒤 다시 실행해주세요.`; - modalCount.textContent = "0건"; - modalTotal.textContent = ""; - modal.classList.add("open"); - return; - } - hanmacCacheRebuildButton.disabled = true; - hanmacCacheRebuildButton.textContent = "등록 중"; - try { - const response = await fetch("/cost-analysis/hanmac-cache-rebuild", { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify({ - ...credentials, - start_date: lockedStartDate, - end_date: lockedEndDate, - view: "member", - employment: "all", - }), - }); - const payload = await response.json(); - if (!response.ok || payload.error) throw new Error(payload.error || "캐시 재생성 등록에 실패했습니다."); - modalTitle.textContent = "한맥 캐시 재생성"; - modalMeta.textContent = "작업은 백그라운드에서 진행됩니다."; - modalHeader.innerHTML = `기간상태작업ID`; - const jobs = Array.isArray(payload.jobs) ? payload.jobs : []; - modalBody.innerHTML = jobs.length ? jobs.map((job) => ` - - ${escapeHtml(job.start_year || "-")} - ${escapeHtml(job.status || "-")} - ${escapeHtml(job.id || "-")} - - `).join("") : `등록된 작업이 없습니다.`; - modalCount.textContent = `${formatNumber(payload.job_count || jobs.length)}건`; - modalTotal.textContent = ""; - modal.classList.add("open"); - } finally { - hanmacCacheRebuildButton.disabled = false; - hanmacCacheRebuildButton.textContent = "캐시재생성"; - } - } - form.addEventListener("submit", (event) => { event.preventDefault(); rememberDateInputs(); loadData().catch((error) => { - body.innerHTML = `${escapeHtml(error.message)}`; + body.innerHTML = `${escapeHtml(error.message)}`; }); }); modeToggle.addEventListener("click", (event) => { const button = event.target.closest("[data-mode]"); if (!button) return; - restoreRememberedDates(); + rememberDateInputs(); currentMode = button.dataset.mode || "individual"; modeToggle.querySelectorAll("[data-mode]").forEach((item) => item.classList.toggle("active", item === button)); + rememberDateInputs(); loadData().catch((error) => { - body.innerHTML = `${escapeHtml(error.message)}`; - }); - }); - - missingGradeButton?.addEventListener("click", () => { - openMissingGrades().catch((error) => { - modalBody.innerHTML = `${escapeHtml(error.message)}`; - }); - }); - - hanmacCacheRebuildButton?.addEventListener("click", () => { - rebuildHanmacCache().catch((error) => { - modalTitle.textContent = "한맥 캐시 재생성"; - modalHeader.innerHTML = `오류`; - modalBody.innerHTML = `${escapeHtml(error.message)}`; - modalCount.textContent = "0건"; - modalTotal.textContent = ""; - modal.classList.add("open"); - hanmacCacheRebuildButton.disabled = false; - hanmacCacheRebuildButton.textContent = "캐시재생성"; + body.innerHTML = `${escapeHtml(error.message)}`; }); }); @@ -1457,23 +2148,68 @@ }; updateSortHeaders(); recalcVisibleSummary(); - if (tableWrap) tableWrap.scrollTop = 0; + setTableScrollPosition(0, 0); renderTable(); }); + tableWrap?.addEventListener("mouseenter", () => { + releaseTablePointerFocus(); + restoreUnexpectedTableScrollLeft(); + }); + + tableWrap?.addEventListener("mousedown", (event) => { + const rect = tableWrap.getBoundingClientRect(); + if (event.clientY >= rect.bottom - 18) { + tableHorizontalScrollbarDragging = true; + allowTableHorizontalScroll(3000); + } + if (!event.target.closest("button")) return; + window.setTimeout(releaseTablePointerFocus, 0); + }, true); + + document.addEventListener("mouseup", () => { + if (!tableHorizontalScrollbarDragging) return; + tableHorizontalScrollbarDragging = false; + stableTableScrollLeft = tableWrap?.scrollLeft || stableTableScrollLeft; + }); + + tableWrap?.addEventListener("click", (event) => { + if (!event.target.closest("button")) return; + window.setTimeout(releaseTablePointerFocus, 0); + }); + + tableWrap?.addEventListener("wheel", (event) => { + const horizontalIntent = Math.abs(event.deltaX || 0) > 1 || event.shiftKey; + if (horizontalIntent) allowTableHorizontalScroll(1200); + }, { passive: true }); + tableWrap?.addEventListener("scroll", () => { if (!latestDisplayRows.length) return; - window.requestAnimationFrame(renderTable); + releaseTablePointerFocus(); + if (restoringTableScrollLeft) return; + const restoredLeft = restoreUnexpectedTableScrollLeft(); + const currentTop = tableWrap.scrollTop || 0; + if (Math.abs(currentTop - lastRenderedTableScrollTop) < 1) return; + if (tableScrollRenderPending) return; + tableScrollRenderPending = true; + window.requestAnimationFrame(() => { + tableScrollRenderPending = false; + if (restoredLeft) restoreUnexpectedTableScrollLeft(); + const nextTop = tableWrap?.scrollTop || 0; + if (Math.abs(nextTop - lastRenderedTableScrollTop) < 1) return; + renderTable(); + }); }); summaryTableBody.addEventListener("click", (event) => { const button = event.target.closest(".summary-row-toggle"); if (!button) return; const nextOpen = !summaryTableBody.classList.contains("open"); - summaryTableBody.classList.toggle("open", nextOpen); - summaryTableBody.querySelectorAll(".summary-row-toggle").forEach((item) => { - item.setAttribute("aria-expanded", nextOpen ? "true" : "false"); - }); + setSummaryOpen(nextOpen); + }); + + summaryToggle?.addEventListener("click", () => { + setSummaryOpen(true); }); [pmFilter, typeFilter, statusFilter, projectSearch].forEach((element) => { @@ -1489,6 +2225,21 @@ }); body.addEventListener("click", (event) => { + const tableRow = event.target.closest("tr[data-row-index]"); + const clickedCell = event.target.closest("td"); + const clickedRow = tableRow ? latestDisplayRows[Number(tableRow.dataset.rowIndex || -1)] : null; + const gapButton = event.target.closest("[data-revenue-billing-gap]"); + if (gapButton) { + const row = latestDisplayRows[Number(gapButton.dataset.revenueBillingGap || -1)]; + openRevenueBillingGap(row); + return; + } + if (clickedRow?.is_common_master && clickedCell && clickedCell.cellIndex >= 8) { + commonDetailsOpen = !commonDetailsOpen; + recalcVisibleSummary(); + renderTable(); + return; + } const linkedButton = event.target.closest(".linked-project-button"); if (linkedButton) { const row = latestDisplayRows[Number(linkedButton.dataset.linkedIndex || -1)]; @@ -1497,21 +2248,46 @@ } const button = event.target.closest("[data-detail-item]"); if (!button) return; - openDetail(button).catch((error) => { - modalBody.innerHTML = `${escapeHtml(error.message)}`; + openDetail(button, clickedRow).catch((error) => { + modalAccordion.hidden = false; + modalTable.hidden = true; + modalAccordion.innerHTML = `
${escapeHtml(error.message)}
`; }); }); + kpis.addEventListener("click", (event) => { + const actionButton = event.target.closest("[data-kpi-action]"); + if (!actionButton) return; + if (actionButton.dataset.kpiAction === "allocated-cost") { + openAllocatedCostComposition(); + return; + } + if (actionButton.dataset.kpiAction === "revenue-billing-gap") { + openRevenueBillingGap(); + } + }); + modalClose.addEventListener("click", () => modal.classList.remove("open")); modal.addEventListener("click", (event) => { if (event.target === modal) modal.classList.remove("open"); + const toggle = event.target.closest(".detail-accordion-toggle"); + if (toggle) { + const section = toggle.closest(".detail-accordion"); + const nextOpen = !section.classList.contains("open"); + section.classList.toggle("open", nextOpen); + toggle.setAttribute("aria-expanded", nextOpen ? "true" : "false"); + } }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") modal.classList.remove("open"); }); + modeToggle.querySelectorAll("[data-mode]").forEach((item) => { + item.classList.toggle("active", item.dataset.mode === currentMode); + }); + rememberDateInputs(); loadData().catch((error) => { - body.innerHTML = `${escapeHtml(error.message)}`; + body.innerHTML = `${escapeHtml(error.message)}`; kpis.innerHTML = ""; summaryTableRow.innerHTML = ""; });