Files
HM_project_Viewer_Board/templates/cost_analysis.html
T

1521 lines
63 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.
{% extends "base.html" %}
{% block title %}프로젝트 손익분석{% endblock %}
{% block head_extra %}
<style>
.cost-analysis-shell {
display: grid;
gap: 18px;
}
.cost-toolbar {
display: grid;
grid-template-columns: 104px 118px 118px 120px 104px 124px 160px 62px 120px 120px;
gap: 12px;
align-items: end;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
overflow-x: auto;
justify-content: start;
}
.cost-toolbar .field,
.cost-toolbar .actions {
min-width: 0;
}
.cost-toolbar input,
.cost-toolbar select {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.cost-toolbar input[type="date"] {
font-variant-numeric: tabular-nums;
font-size: 12px;
padding-left: 7px;
padding-right: 4px;
}
.cost-toolbar .actions {
display: flex;
justify-content: flex-end;
}
.cost-toolbar .actions button {
width: 100%;
min-width: 0;
padding-left: 8px;
padding-right: 8px;
}
.cost-kpis {
display: grid;
grid-template-columns: repeat(9, minmax(0, 1fr));
gap: 6px;
}
.cost-kpi {
border-bottom: 1px solid var(--line);
padding-bottom: 10px;
min-width: 0;
}
.cost-kpi span {
display: block;
color: var(--muted);
font-size: 11px;
font-weight: 800;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cost-kpi strong {
display: block;
font-size: 14px;
line-height: 1.15;
white-space: nowrap;
word-break: keep-all;
overflow: hidden;
text-overflow: ellipsis;
}
.summary-accordion {
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.summary-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 40px;
padding: 8px 0;
border: 0;
background: transparent;
color: #172033;
box-shadow: none;
font-weight: 900;
}
.summary-toggle:hover {
background: transparent;
transform: none;
}
.summary-arrow {
font-size: 16px;
line-height: 1;
transition: transform 0.16s ease;
}
.summary-accordion.open .summary-arrow {
transform: rotate(180deg);
}
.summary-body {
display: none;
padding: 0 0 12px;
}
.summary-accordion.open .summary-body {
display: grid;
gap: 10px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(8, minmax(120px, 1fr));
gap: 8px;
}
.summary-cell {
border-bottom: 1px solid #e4e7ec;
padding: 8px 0;
min-width: 0;
}
.summary-cell span {
display: block;
color: var(--muted);
font-size: 12px;
font-weight: 800;
margin-bottom: 4px;
}
.summary-cell strong {
display: block;
font-size: 15px;
line-height: 1.2;
text-align: right;
}
.summary-check {
display: flex;
justify-content: flex-end;
color: #166534;
font-size: 12px;
font-weight: 900;
}
.summary-check.warn {
color: #b45309;
}
.cost-table-wrap {
--cost-table-header-height: 99px;
height: calc(100vh - 310px);
max-height: calc(100vh - 310px);
min-height: 420px;
overflow: auto;
border-top: 1px solid var(--line);
background: #ffffff;
}
.cost-table {
width: 100%;
min-width: 0;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
}
.cost-table th,
.cost-table td {
padding: 7px 5px;
font-size: 11px;
vertical-align: middle;
border-right: 1px solid #e6e8ec;
background: #ffffff;
white-space: nowrap;
}
.cost-table thead th {
position: sticky;
padding: 0;
text-align: center;
vertical-align: middle;
font-weight: 900;
color: #21313b;
background: #edf4f4;
z-index: 30;
overflow: hidden;
line-height: 1.2;
}
.cost-table thead th[rowspan="3"] {
height: var(--cost-table-header-height);
}
.cost-table thead th[rowspan="2"] {
height: 66px;
}
.cost-table thead th:not([rowspan]) {
height: 33px;
}
.cost-table thead tr:nth-child(1) th {
top: 0;
z-index: 40;
}
.cost-table thead tr:nth-child(2) th {
top: 33px;
z-index: 39;
}
.cost-table thead tr:nth-child(3) th {
top: 66px;
z-index: 38;
}
.cost-table tbody tr:hover td {
background: #f8fbfb;
}
.sticky-col {
position: sticky;
left: 0;
z-index: 6;
background: #ffffff;
background-clip: padding-box;
box-shadow: 1px 0 0 #d9dde3;
}
.sticky-col-2 {
position: sticky;
left: 94px;
z-index: 6;
background: #ffffff;
background-clip: padding-box;
box-shadow: 1px 0 0 #d9dde3;
}
.sticky-col-3 {
position: sticky;
left: 172px;
z-index: 6;
background: #ffffff;
background-clip: padding-box;
box-shadow: 1px 0 0 #d9dde3;
}
thead .sticky-col,
thead .sticky-col-2,
thead .sticky-col-3 {
background: #edf4f4;
z-index: 70 !important;
}
.cost-table tbody td.sticky-col,
.cost-table tbody td.sticky-col-2,
.cost-table tbody td.sticky-col-3 {
background: #ffffff;
background-clip: padding-box;
z-index: 16;
}
.cost-table tbody tr:hover td.sticky-col,
.cost-table tbody tr:hover td.sticky-col-2,
.cost-table tbody tr:hover td.sticky-col-3 {
background: #f8fbfb;
}
.summary-table-body td {
background: #fbfcfd;
font-weight: 900;
border-top: 1px solid #d7dde5;
}
.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;
}
.summary-table-body.open .summary-control-row {
display: none;
}
.summary-table-body .summary-data-row {
display: none;
}
.summary-table-body.open .summary-data-row {
display: table-row;
}
.summary-table-body.open .summary-data-row td {
position: sticky;
top: var(--cost-table-header-height);
z-index: 48;
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;
}
.summary-table-body .sticky-col,
.summary-table-body .sticky-col-2,
.summary-table-body .sticky-col-3 {
z-index: 8;
background: #fbfcfd;
background-clip: padding-box;
}
.summary-table-body.open .summary-data-row .sticky-col,
.summary-table-body.open .summary-data-row .sticky-col-2,
.summary-table-body.open .summary-data-row .sticky-col-3 {
z-index: 58;
background: #fbfcfd;
background-clip: padding-box;
}
.summary-row-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 76px;
min-height: 28px;
padding: 4px 10px;
border: 0;
border-radius: 6px;
background: #d8dee8;
color: #172033;
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.08);
font-size: 13px;
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;
transition: transform 0.16s ease;
}
.summary-table-body.open .summary-row-arrow {
transform: rotate(180deg);
}
.sort-button {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 4px 6px;
border: 0;
background: transparent;
color: inherit;
box-shadow: none;
font: inherit;
font-weight: 900;
line-height: 1.2;
text-align: center;
white-space: normal;
}
.cost-table thead .col-money,
.cost-table thead .col-rate {
text-align: center;
}
.sort-button:hover {
background: transparent;
color: #0f766e;
transform: none;
}
.sort-button::after {
content: "";
position: absolute;
right: 7px;
top: 50%;
transform: translateY(-50%);
display: block;
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid transparent;
}
.sort-button.sorted-asc::after {
border-top: 0;
border-bottom: 5px solid currentColor;
}
.sort-button.sorted-desc::after {
border-top: 5px solid currentColor;
}
.summary-valid {
color: #166534;
}
.summary-invalid {
color: #b45309;
}
.mode-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 4px;
border: 1px solid var(--line);
background: #f7f9fb;
}
.mode-toggle button {
min-height: 34px;
padding: 6px 10px;
border: 0;
background: transparent;
color: var(--muted);
box-shadow: none;
font-size: 13px;
font-weight: 900;
}
.mode-toggle button.active {
background: #ffffff;
color: #102a43;
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; }
.group-pre { background: #f3f8ee !important; }
.group-during { background: #eef5fb !important; }
.group-post { background: #f8f1f5 !important; }
.group-total { background: #f4f4f5 !important; }
.sub-cost { color: #155e75 !important; }
.sub-sga { color: #7c2d12 !important; }
.amount-button {
width: 100%;
justify-content: flex-end;
min-height: 26px;
padding: 2px 0;
border: 0;
border-radius: 0;
background: transparent;
color: #111827;
box-shadow: none;
font-size: 11px;
font-weight: 800;
text-align: right;
}
.amount-button:hover {
background: transparent;
color: #0f766e;
text-decoration: underline;
transform: none;
}
.amount-muted {
display: block;
text-align: right;
color: #9aa3af;
font-weight: 700;
}
.ratio-cell {
text-align: right;
font-weight: 800;
white-space: nowrap;
}
.col-money,
.col-rate {
white-space: nowrap;
}
.project-name-cell {
overflow: hidden;
text-overflow: ellipsis;
}
.linked-project-button {
width: 100%;
justify-content: flex-start;
min-height: 26px;
padding: 2px 0;
border: 0;
border-radius: 0;
background: transparent;
color: #0f766e;
box-shadow: none;
font-size: 11px;
font-weight: 900;
text-align: left;
}
.linked-project-button:hover {
background: transparent;
text-decoration: underline;
transform: none;
}
.detail-modal-card {
width: min(980px, 96vw);
max-height: min(82vh, 820px);
border-radius: 12px;
display: grid;
grid-template-rows: auto 1fr auto;
overflow: hidden;
}
.detail-modal-head,
.detail-modal-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.detail-modal-foot {
border-top: 1px solid var(--line);
border-bottom: 0;
}
.detail-modal-title {
font-size: 16px;
font-weight: 900;
}
.detail-modal-body {
overflow: auto;
}
.detail-table {
min-width: 760px;
}
.detail-table th,
.detail-table td {
font-size: 13px;
padding: 10px 12px;
}
.detail-table .memo-col {
white-space: normal;
min-width: 240px;
}
@media (max-width: 1200px) {
.summary-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@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));
}
}
</style>
{% endblock %}
{% block content %}
<section class="panel cost-analysis-shell">
<form class="cost-toolbar" id="costAnalysisForm">
<div class="field">
<label>조회방식</label>
<div class="mode-toggle" id="modeToggle">
<button type="button" class="active" data-mode="individual">개별</button>
<button type="button" data-mode="aggregate">연계</button>
</div>
</div>
<div class="field">
<label for="startDate">시작일</label>
<input id="startDate" type="date" value="{{ default_start_date }}" min="1900-01-01" max="9999-12-31">
</div>
<div class="field">
<label for="endDate">종료일</label>
<input id="endDate" type="date" value="{{ default_end_date }}" min="1900-01-01" max="9999-12-31">
</div>
<div class="field">
<label for="pmFilter">PM부서</label>
<select id="pmFilter">
<option value="">전체</option>
</select>
</div>
<div class="field">
<label for="typeFilter">사업분류</label>
<select id="typeFilter">
<option value="">전체</option>
<option value="설계">설계</option>
<option value="감리">감리</option>
<option value="사업전">사업전</option>
<option value="기타">기타</option>
</select>
</div>
<div class="field">
<label for="statusFilter">준공/용역완료</label>
<select id="statusFilter">
<option value="">전체</option>
</select>
</div>
<div class="field">
<label for="projectSearch">사업명</label>
<input id="projectSearch" type="text" placeholder="검색">
</div>
<div class="actions">
<button type="submit">조회</button>
</div>
<div class="actions">
<button type="button" class="button-secondary" id="missingGradeButton">직급누락</button>
</div>
<div class="actions">
<button type="button" class="button-secondary" id="hanmacCacheRebuildButton">캐시재생성</button>
</div>
</form>
<div class="cost-kpis" id="costKpis"></div>
<div class="cost-table-wrap">
<table class="cost-table" id="costAnalysisTable">
<thead>
<tr>
<th rowspan="3" class="sticky-col col-pm"><button type="button" class="sort-button" data-sort-key="pm_department" data-sort-type="text">PM부서</button></th>
<th rowspan="3" class="sticky-col-2 col-code"><button type="button" class="sort-button" data-sort-key="support_dept_code" data-sort-type="text">프로젝트코드</button></th>
<th rowspan="3" class="sticky-col-3 col-name"><button type="button" class="sort-button" data-sort-key="project_name" data-sort-type="text">사업명</button></th>
<th rowspan="3" class="col-small"><button type="button" class="sort-button" data-sort-key="year" data-sort-type="text">연도</button></th>
<th rowspan="3" class="col-small"><button type="button" class="sort-button" data-sort-key="project_type" data-sort-type="text">사업분류</button></th>
<th rowspan="3" class="col-small"><button type="button" class="sort-button" data-sort-key="completion_status" data-sort-type="text">준공/용역완료</button></th>
<th rowspan="3" class="col-date"><button type="button" class="sort-button" data-sort-key="project_start_date" data-sort-type="date">시작일</button></th>
<th rowspan="3" class="col-date"><button type="button" class="sort-button" data-sort-key="project_end_date" data-sort-type="date">준공일</button></th>
<th rowspan="3" class="col-money"><button type="button" class="sort-button" data-sort-key="contract_amount" data-sort-type="number">도급금액</button></th>
<th rowspan="3" class="col-money"><button type="button" class="sort-button" data-sort-key="billing_amount" data-sort-type="number">청구금액</button></th>
<th rowspan="3" class="col-money"><button type="button" class="sort-button" data-sort-key="collection_amount" data-sort-type="number">수금금액</button></th>
<th rowspan="3" class="col-money"><button type="button" class="sort-button" data-sort-key="contract_balance_amount" data-sort-type="number">잔여도급금액</button></th>
<th rowspan="3" class="col-rate"><button type="button" class="sort-button" data-sort-key="collection_rate" data-sort-type="number">수금률</button></th>
<th colspan="4" class="group-pre">사업전</th>
<th colspan="5" class="group-during">사업중</th>
<th colspan="5" class="group-post">사업후</th>
<th colspan="9" class="group-total">총계</th>
</tr>
<tr>
<th colspan="2" class="group-pre sub-cost">원가</th>
<th colspan="2" class="group-pre sub-sga">판관비</th>
<th colspan="3" class="group-during sub-cost">원가</th>
<th colspan="2" class="group-during sub-sga">판관비</th>
<th colspan="3" class="group-post sub-cost">원가</th>
<th colspan="2" class="group-post sub-sga">판관비</th>
<th rowspan="2" class="group-total col-money"><button type="button" class="sort-button" data-sort-key="revenue_amount" data-sort-type="number">청구수익</button></th>
<th rowspan="2" class="group-total col-money"><button type="button" class="sort-button" data-sort-key="cost_total" data-sort-type="number">원가합계</button></th>
<th rowspan="2" class="group-total col-money"><button type="button" class="sort-button" data-sort-key="sga_total" data-sort-type="number">판관비합계</button></th>
<th rowspan="2" class="group-total col-money"><button type="button" class="sort-button" data-sort-key="sales_total" data-sort-type="number">영업비합계</button></th>
<th rowspan="2" class="group-total col-money"><button type="button" class="sort-button" data-sort-key="total_cost" data-sort-type="number">총비용</button></th>
<th rowspan="2" class="group-total col-money"><button type="button" class="sort-button" data-sort-key="profit_amount" data-sort-type="number">이윤</button></th>
<th rowspan="2" class="group-total col-rate"><button type="button" class="sort-button" data-sort-key="revenue_profit_rate" data-sort-type="number">청구수익률</button></th>
<th rowspan="2" class="group-total col-rate"><button type="button" class="sort-button" data-sort-key="collection_profit_rate" data-sort-type="number">수금수익률</button></th>
<th rowspan="2" class="group-total col-rate"><button type="button" class="sort-button" data-sort-key="cumulative_profit_rate" data-sort-type="number">누적수익률</button></th>
</tr>
<tr>
<th class="group-pre col-money"><button type="button" class="sort-button" data-sort-key="phases.pre.labor" data-sort-type="number">인건비</button></th>
<th class="group-pre col-money"><button type="button" class="sort-button" data-sort-key="phases.pre.overhead" data-sort-type="number">제경비</button></th>
<th class="group-pre col-money"><button type="button" class="sort-button" data-sort-key="phases.pre.sga" data-sort-type="number">판관비</button></th>
<th class="group-pre col-money"><button type="button" class="sort-button" data-sort-key="phases.pre.sales" data-sort-type="number">영업비</button></th>
<th class="group-during col-money"><button type="button" class="sort-button" data-sort-key="phases.during.labor" data-sort-type="number">인건비</button></th>
<th class="group-during col-money"><button type="button" class="sort-button" data-sort-key="phases.during.outsource" data-sort-type="number">외주비</button></th>
<th class="group-during col-money"><button type="button" class="sort-button" data-sort-key="phases.during.overhead" data-sort-type="number">제경비</button></th>
<th class="group-during col-money"><button type="button" class="sort-button" data-sort-key="phases.during.sga" data-sort-type="number">판관비</button></th>
<th class="group-during col-money"><button type="button" class="sort-button" data-sort-key="phases.during.sales" data-sort-type="number">영업비</button></th>
<th class="group-post col-money"><button type="button" class="sort-button" data-sort-key="phases.post.labor" data-sort-type="number">인건비</button></th>
<th class="group-post col-money"><button type="button" class="sort-button" data-sort-key="phases.post.outsource" data-sort-type="number">외주비</button></th>
<th class="group-post col-money"><button type="button" class="sort-button" data-sort-key="phases.post.overhead" data-sort-type="number">제경비</button></th>
<th class="group-post col-money"><button type="button" class="sort-button" data-sort-key="phases.post.sga" data-sort-type="number">판관비</button></th>
<th class="group-post col-money"><button type="button" class="sort-button" data-sort-key="phases.post.sales" data-sort-type="number">영업비</button></th>
</tr>
</thead>
<tbody class="summary-table-body" id="summaryTableBody">
<tr class="summary-control-row">
<td colspan="36">
<button type="button" class="summary-row-toggle" id="summaryToggle" aria-expanded="false">
<span class="summary-row-arrow"></span>
<span>합계</span>
</button>
</td>
</tr>
<tr class="summary-data-row" id="summaryTableRow"></tr>
</tbody>
<tbody id="costAnalysisBody">
<tr><td colspan="36" class="empty">조회 중입니다.</td></tr>
</tbody>
</table>
</div>
</section>
<div class="modal-backdrop" id="costDetailModal">
<div class="modal-card detail-modal-card">
<div class="detail-modal-head">
<div>
<div class="detail-modal-title" id="detailModalTitle">상세 내역</div>
<div class="meta" id="detailModalMeta"></div>
</div>
<button type="button" class="button-secondary button-icon" id="detailModalClose" title="닫기" aria-label="닫기">×</button>
</div>
<div class="detail-modal-body">
<table class="detail-table">
<thead>
<tr id="detailModalHeader">
<th>일자</th>
<th>계정명</th>
<th>거래처명</th>
<th>적요</th>
<th class="col-money">금액</th>
</tr>
</thead>
<tbody id="detailModalBody"></tbody>
</table>
</div>
<div class="detail-modal-foot">
<span id="detailModalCount">0건</span>
<strong id="detailModalTotal">0</strong>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script>
(() => {
const form = document.getElementById("costAnalysisForm");
const startDateInput = document.getElementById("startDate");
const endDateInput = document.getElementById("endDate");
const modeToggle = document.getElementById("modeToggle");
const pmFilter = document.getElementById("pmFilter");
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 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 modal = document.getElementById("costDetailModal");
const modalTitle = document.getElementById("detailModalTitle");
const modalMeta = document.getElementById("detailModalMeta");
const modalHeader = document.getElementById("detailModalHeader");
const modalBody = document.getElementById("detailModalBody");
const modalCount = document.getElementById("detailModalCount");
const modalTotal = document.getElementById("detailModalTotal");
const modalClose = document.getElementById("detailModalClose");
let allRows = [];
let currentPayload = null;
let currentMode = "individual";
let lockedStartDate = startDateInput.value;
let lockedEndDate = endDateInput.value;
let sortState = { key: "", direction: "asc", type: "text" };
let latestDisplayRows = [];
let activeLoadController = null;
const virtualRowHeight = 43;
const virtualBufferRows = 8;
const hanmacCredentialStorageKey = "hanmac-db-external-credentials-v1";
const phaseLabels = { pre: "사업전", during: "사업중", post: "사업후", all: "전체" };
const itemLabels = {
revenue: "청구수익",
billing: "청구금액",
collection: "수금금액",
labor: "인건비",
outsource: "외주비",
overhead: "제경비",
sga: "판관비",
sales: "영업비",
cost_total: "원가합계",
sga_total: "판관비합계",
sales_total: "영업비합계",
total_cost: "총비용",
};
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function formatNumber(value) {
const number = Number(value || 0);
return Math.round(number).toLocaleString("ko-KR");
}
function formatMoneyOrDash(value) {
const number = Number(value || 0);
if (!number) return "-";
return formatNumber(number);
}
function formatBalance(value) {
return formatNumber(Math.max(0, Number(value || 0)));
}
function formatRate(value) {
const number = Number(value || 0);
return `${number.toFixed(1)}%`;
}
function normalizeDateInputYear(input) {
const value = String(input.value || "");
const match = value.match(/^(\d{4,})(-\d{2}-\d{2})$/);
if (!match) return;
input.value = `${match[1].slice(0, 4)}${match[2]}`;
}
function rememberDateInputs() {
normalizeDateInputYear(startDateInput);
normalizeDateInputYear(endDateInput);
if (startDateInput.value) lockedStartDate = startDateInput.value;
if (endDateInput.value) lockedEndDate = endDateInput.value;
}
function restoreRememberedDates() {
if (lockedStartDate) startDateInput.value = lockedStartDate;
if (lockedEndDate) endDateInput.value = lockedEndDate;
}
function amountCell(row, phase, item, amount, options = {}) {
const value = Number(amount || 0);
if (!value) return `<span class="amount-muted">-</span>`;
const allocated = phase && item ? Number(row?.allocated?.[phase]?.[item] || 0) : 0;
const hasDirect = Math.abs(value - allocated) > 0.5;
if (!hasDirect && allocated) {
return `<span class="amount-muted" title="Hanmac 근무시간 기준 배분액">${formatNumber(value)}</span>`;
}
const codes = options.codes || (phase === "pre" || row.view_mode === "aggregate" ? row.direct_codes : [row.support_dept_code]);
const label = options.label || itemLabels[item] || item;
return `<button type="button" class="amount-button" data-detail-codes="${escapeHtml((codes || []).join(","))}" data-detail-phase="${escapeHtml(phase || "all")}" data-detail-item="${escapeHtml(item)}" data-detail-title="${escapeHtml(`${row.project_name} · ${phaseLabels[phase] || "전체"} · ${label}`)}">${formatNumber(value)}</button>`;
}
function phaseCost(row, phase) {
const bucket = row.phases?.[phase] || {};
return Number(bucket.labor || 0) + Number(bucket.outsource || 0) + Number(bucket.overhead || 0);
}
function phaseTotal(row, phase) {
const bucket = row.phases?.[phase] || {};
return phaseCost(row, phase) + Number(bucket.sga || 0) + Number(bucket.sales || 0);
}
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)],
];
kpis.innerHTML = items.map(([label, value]) => `<div class="cost-kpi"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`).join("");
}
function renderSummaryTable(summary) {
const componentTotal = Number(summary.cost_total || 0) + Number(summary.sga_total || 0) + Number(summary.sales_total || 0);
const totalCost = Number(summary.total_cost || 0);
const diff = totalCost - componentTotal;
const isValid = Math.abs(diff) < 1;
const phases = summary.phases || {};
const pre = phases.pre || {};
const during = phases.during || {};
const post = phases.post || {};
const moneyCell = (value, className = "col-money") => `<td class="${className}">${Number(value || 0) ? formatNumber(value) : "-"}</td>`;
const balanceCell = (value) => `<td class="col-money">${formatBalance(value)}</td>`;
const rateCell = (value) => `<td class="ratio-cell">${formatRate(value)}</td>`;
const labelClass = isValid ? "summary-valid" : "summary-invalid";
summaryTableRow.innerHTML = `
<td class="sticky-col col-pm">
<button type="button" class="summary-row-toggle" aria-expanded="true">
<span class="summary-row-arrow">▾</span>
<span>합계</span>
</button>
</td>
<td class="sticky-col-2 col-code">${formatNumber(summary.project_count)}건</td>
<td class="sticky-col-3 col-name project-name-cell ${labelClass}" title="${escapeHtml(isValid ? "원가 + 판관비 + 영업비 = 총비용" : `총비용 차이 ${formatNumber(diff)}`)}">${escapeHtml(isValid ? "검증 완료" : `검증 필요 ${formatNumber(diff)}`)}</td>
<td class="col-small">-</td>
<td class="col-small">-</td>
<td class="col-small">-</td>
<td class="col-date">-</td>
<td class="col-date">-</td>
${moneyCell(summary.contract_amount)}
${moneyCell(summary.billing_amount)}
${moneyCell(summary.collection_amount)}
${balanceCell(summary.contract_balance_amount)}
${rateCell(summary.collection_rate)}
${moneyCell(pre.labor)}
${moneyCell(pre.overhead)}
${moneyCell(pre.sga)}
${moneyCell(pre.sales)}
${moneyCell(during.labor)}
${moneyCell(during.outsource)}
${moneyCell(during.overhead)}
${moneyCell(during.sga)}
${moneyCell(during.sales)}
${moneyCell(post.labor)}
${moneyCell(post.outsource)}
${moneyCell(post.overhead)}
${moneyCell(post.sga)}
${moneyCell(post.sales)}
${moneyCell(summary.revenue_amount)}
${moneyCell(summary.cost_total)}
${moneyCell(summary.sga_total)}
${moneyCell(summary.sales_total)}
${moneyCell(summary.total_cost)}
${moneyCell(summary.profit_amount)}
${rateCell(summary.revenue_profit_rate)}
${rateCell(summary.collection_profit_rate)}
${rateCell(summary.cumulative_profit_rate)}
`;
}
function refreshFilterOptions(rows) {
const selectedPm = pmFilter.value;
const selectedStatus = statusFilter.value;
const pms = [...new Set(rows.map((row) => row.pm_department || "").filter(Boolean))].sort((a, b) => a.localeCompare(b, "ko"));
const statuses = [...new Set(rows.map((row) => row.completion_status || "").filter(Boolean))].sort((a, b) => a.localeCompare(b, "ko"));
pmFilter.innerHTML = `<option value="">전체</option>${pms.map((value) => `<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`).join("")}`;
statusFilter.innerHTML = `<option value="">전체</option>${statuses.map((value) => `<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`).join("")}`;
pmFilter.value = pms.includes(selectedPm) ? selectedPm : "";
statusFilter.value = statuses.includes(selectedStatus) ? selectedStatus : "";
}
function getFilteredRows() {
const pm = pmFilter.value;
const type = typeFilter.value;
const status = statusFilter.value;
const keyword = projectSearch.value.trim().toLowerCase();
const rows = 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;
return true;
});
if (!sortState.key) return rows;
return [...rows].sort((a, b) => compareRows(a, b, sortState));
}
function getSortValue(row, key) {
return key.split(".").reduce((value, part) => value && value[part] !== undefined ? value[part] : "", row);
}
function compareRows(a, b, state) {
const direction = state.direction === "desc" ? -1 : 1;
const aValue = getSortValue(a, state.key);
const bValue = getSortValue(b, state.key);
if (state.type === "number") {
return (Number(aValue || 0) - Number(bValue || 0)) * direction;
}
if (state.type === "date") {
const aTime = Date.parse(aValue || "1900-01-01") || 0;
const bTime = Date.parse(bValue || "1900-01-01") || 0;
return (aTime - bTime) * direction;
}
return String(aValue || "").localeCompare(String(bValue || ""), "ko", { numeric: true }) * direction;
}
function updateSortHeaders() {
document.querySelectorAll(".sort-button").forEach((button) => {
const active = button.dataset.sortKey === sortState.key;
button.classList.toggle("sorted-asc", active && sortState.direction === "asc");
button.classList.toggle("sorted-desc", active && sortState.direction === "desc");
button.setAttribute("aria-sort", active ? (sortState.direction === "asc" ? "ascending" : "descending") : "none");
});
}
function renderTable() {
const rows = latestDisplayRows.length ? latestDisplayRows : getFilteredRows();
if (!rows.length) {
body.innerHTML = `<tr><td colspan="36" class="empty">조건에 맞는 데이터가 없습니다.</td></tr>`;
return;
}
const viewportHeight = tableWrap?.clientHeight || 600;
const scrollTop = tableWrap?.scrollTop || 0;
const startIndex = Math.max(0, Math.floor(scrollTop / virtualRowHeight) - virtualBufferRows);
const visibleCount = Math.ceil(viewportHeight / virtualRowHeight) + virtualBufferRows * 2;
const endIndex = Math.min(rows.length, startIndex + visibleCount);
const topHeight = startIndex * virtualRowHeight;
const bottomHeight = Math.max(0, (rows.length - endIndex) * virtualRowHeight);
const visibleRows = rows.slice(startIndex, endIndex);
const topSpacer = topHeight ? `<tr class="virtual-spacer"><td colspan="36" style="height:${topHeight}px;padding:0;border:0;background:#fff;"></td></tr>` : "";
const bottomSpacer = bottomHeight ? `<tr class="virtual-spacer"><td colspan="36" style="height:${bottomHeight}px;padding:0;border:0;background:#fff;"></td></tr>` : "";
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 codeCell = row.view_mode === "aggregate" && linkedMembers.length > 1
? `<button type="button" class="linked-project-button" data-linked-index="${rowIndex}">${escapeHtml(row.support_dept_code || "-")}</button>`
: escapeHtml(row.support_dept_code || "-");
return `
<tr>
<td class="sticky-col col-pm">${escapeHtml(row.pm_department || "-")}</td>
<td class="sticky-col-2 col-code">${codeCell}</td>
<td class="sticky-col-3 col-name project-name-cell" title="${escapeHtml(row.project_name || "")}">${escapeHtml(row.project_name || "-")}</td>
<td class="col-small">${escapeHtml(row.year || "-")}</td>
<td class="col-small">${escapeHtml(row.project_type || "-")}</td>
<td class="col-small">${escapeHtml(row.completion_status || "-")}</td>
<td class="col-date">${escapeHtml(row.project_start_date || "-")}</td>
<td class="col-date">${escapeHtml(row.project_end_date || "-")}</td>
<td class="col-money">${formatMoneyOrDash(row.contract_amount)}</td>
<td class="col-money">${amountCell(row, "all", "billing", row.billing_amount, { codes: row.direct_codes || [row.support_dept_code], label: "청구금액" })}</td>
<td class="col-money">${amountCell(row, "all", "collection", row.collection_amount, { codes: row.direct_codes || [row.support_dept_code], label: "수금금액" })}</td>
<td class="col-money">${formatBalance(row.contract_balance_amount)}</td>
<td class="ratio-cell">${formatRate(row.collection_rate)}</td>
<td class="col-money">${amountCell(row, "pre", "labor", pre.labor)}</td>
<td class="col-money">${amountCell(row, "pre", "overhead", pre.overhead)}</td>
<td class="col-money">${amountCell(row, "pre", "sga", pre.sga)}</td>
<td class="col-money">${amountCell(row, "pre", "sales", pre.sales)}</td>
<td class="col-money">${amountCell(row, "during", "labor", during.labor)}</td>
<td class="col-money">${amountCell(row, "during", "outsource", during.outsource)}</td>
<td class="col-money">${amountCell(row, "during", "overhead", during.overhead)}</td>
<td class="col-money">${amountCell(row, "during", "sga", during.sga)}</td>
<td class="col-money">${amountCell(row, "during", "sales", during.sales)}</td>
<td class="col-money">${amountCell(row, "post", "labor", post.labor)}</td>
<td class="col-money">${amountCell(row, "post", "outsource", post.outsource)}</td>
<td class="col-money">${amountCell(row, "post", "overhead", post.overhead)}</td>
<td class="col-money">${amountCell(row, "post", "sga", post.sga)}</td>
<td class="col-money">${amountCell(row, "post", "sales", post.sales)}</td>
<td class="col-money">${amountCell(row, "all", "revenue", row.revenue_amount, { codes: row.direct_codes || [row.support_dept_code], label: "청구수익" })}</td>
<td class="col-money">${amountCell(row, "all", "cost_total", row.cost_total, { codes: row.direct_codes, label: "원가합계" })}</td>
<td class="col-money">${amountCell(row, "all", "sga_total", row.sga_total, { codes: row.direct_codes, label: "판관비합계" })}</td>
<td class="col-money">${amountCell(row, "all", "sales_total", row.sales_total, { codes: row.direct_codes, label: "영업비합계" })}</td>
<td class="col-money">${amountCell(row, "all", "total_cost", row.total_cost, { codes: row.direct_codes, label: "총비용" })}</td>
<td class="col-money">${formatNumber(row.profit_amount)}</td>
<td class="ratio-cell">${formatRate(row.revenue_profit_rate)}</td>
<td class="ratio-cell">${formatRate(row.collection_profit_rate)}</td>
<td class="ratio-cell">${formatRate(row.cumulative_profit_rate)}</td>
</tr>
`;
}).join("") + bottomSpacer;
}
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"]) {
acc[key] += Number(row[key] || 0);
}
if (usePeriodValues) {
acc.billing_amount += Number(row.period_billing_amount || 0) - Number(row.billing_amount || 0);
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.sga_total += Number(row.period_sga_total || 0) - Number(row.sga_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"]) {
acc.phases[phase][item] += Number(bucket[item] || 0);
}
}
acc.project_count += 1;
return acc;
}, {
project_count: 0,
contract_amount: 0,
billing_amount: 0,
collection_amount: 0,
contract_balance_amount: 0,
revenue_amount: 0,
cost_total: 0,
sga_total: 0,
sales_total: 0,
total_cost: 0,
profit_amount: 0,
cumulative_profit_amount: 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 },
},
});
const summary = buildSummary(false);
summary.collection_rate = summary.contract_amount ? summary.collection_amount / summary.contract_amount * 100 : 0;
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;
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_profit_rate = summary.cumulative_profit_rate;
renderKpis(kpiSummary);
renderSummaryTable(summary);
}
async function loadData() {
restoreRememberedDates();
if (activeLoadController) activeLoadController.abort();
activeLoadController = new AbortController();
const controller = activeLoadController;
searchButton.disabled = true;
searchButton.textContent = "조회 중";
kpis.innerHTML = `<div class="cost-kpi"><span>조회</span><strong>조회 중</strong></div>`;
summaryTableRow.innerHTML = "";
body.innerHTML = `<tr><td colspan="36" class="empty">조회 중입니다.</td></tr>`;
try {
const params = new URLSearchParams({ start_date: lockedStartDate, end_date: lockedEndDate, mode: currentMode });
const response = await fetch(`/cost-analysis/data?${params.toString()}`, {
headers: { Accept: "application/json" },
cache: "no-store",
signal: controller.signal,
});
const payload = await response.json();
if (!response.ok || payload.error) throw new Error(payload.error || "조회에 실패했습니다.");
if (controller !== activeLoadController) return;
currentPayload = payload;
allRows = Array.isArray(payload.rows) ? payload.rows : [];
refreshFilterOptions(allRows);
updateSortHeaders();
recalcVisibleSummary();
if (tableWrap) tableWrap.scrollTop = 0;
renderTable();
} catch (error) {
if (error?.name === "AbortError") return;
throw error;
} finally {
if (controller === activeLoadController) {
activeLoadController = null;
searchButton.disabled = false;
searchButton.textContent = "조회";
}
}
}
async function openDetail(button) {
const params = new URLSearchParams({
start_date: lockedStartDate,
end_date: lockedEndDate,
codes: button.dataset.detailCodes || "",
phase: button.dataset.detailPhase || "all",
item: button.dataset.detailItem || "",
});
modalTitle.textContent = button.dataset.detailTitle || "상세 내역";
modalMeta.textContent = `누적 ~ ${currentPayload?.end_date || lockedEndDate}`;
modalBody.innerHTML = `<tr><td colspan="5" class="empty">조회 중입니다.</td></tr>`;
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 = `
<th>사번</th>
<th>부서</th>
<th>성명</th>
<th>직급</th>
<th>근무시간</th>
<th class="col-money">금액</th>
`;
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 `
<tr>
<td>${escapeHtml(row.member_no || "-")}</td>
<td>${escapeHtml(row.dept_name || "-")}</td>
<td>${escapeHtml(row.member_name || "-")}</td>
<td>${escapeHtml(row.member_grade || "-")}</td>
<td class="memo-col">${escapeHtml(workHours)}</td>
<td class="col-money">${formatNumber(row.amount)}</td>
</tr>
`;
}).join("") : `<tr><td colspan="6" class="empty">상세 내역이 없습니다.</td></tr>`;
} else {
modalHeader.innerHTML = `
<th>일자</th>
<th>계정명</th>
<th>거래처명</th>
<th>적요</th>
<th class="col-money">금액</th>
`;
modalBody.innerHTML = rows.length ? rows.map((row) => `
<tr>
<td>${escapeHtml(row.posting_date || "-")}</td>
<td>${escapeHtml(row.account_name || "-")}</td>
<td>${escapeHtml(row.partner_name || "-")}</td>
<td class="memo-col">${escapeHtml(row.memo1 || "")}</td>
<td class="col-money">${formatNumber(row.amount)}</td>
</tr>
`).join("") : `<tr><td colspan="5" class="empty">상세 내역이 없습니다.</td></tr>`;
}
modalCount.textContent = `${formatNumber(rows.length)}건`;
modalTotal.textContent = formatNumber(payload.total_amount || 0);
}
function openLinkedProjects(row) {
const members = Array.isArray(row?.aggregate_members) ? row.aggregate_members : [];
modalTitle.textContent = `${row?.support_dept_code || ""} 연계 프로젝트`;
modalMeta.textContent = row?.project_name || "";
modalHeader.innerHTML = `
<th>프로젝트코드</th>
<th>사업명</th>
<th>시작일</th>
<th>준공일</th>
<th class="col-money">도급금액</th>
<th class="col-money">청구금액</th>
<th class="col-money">수금금액</th>
<th class="col-money">총비용</th>
`;
modalBody.innerHTML = members.length ? members.map((item) => `
<tr>
<td>${escapeHtml(item.support_dept_code || "-")}</td>
<td class="memo-col" title="${escapeHtml(item.project_name || "")}">${escapeHtml(item.project_name || "-")}</td>
<td>${escapeHtml(item.project_start_date || "-")}</td>
<td>${escapeHtml(item.project_end_date || "-")}</td>
<td class="col-money">${formatNumber(item.contract_amount)}</td>
<td class="col-money">${formatNumber(item.billing_amount)}</td>
<td class="col-money">${formatNumber(item.collection_amount)}</td>
<td class="col-money">${formatNumber(item.total_cost)}</td>
</tr>
`).join("") : `<tr><td colspan="8" class="empty">연계 프로젝트가 없습니다.</td></tr>`;
modalCount.textContent = `${formatNumber(members.length)}건`;
modalTotal.textContent = "";
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 = `
<th>일자</th>
<th>구분</th>
<th>프로젝트코드</th>
<th>사업명</th>
<th>성명</th>
<th>투입구분</th>
<th class="col-money">시간</th>
<th>한맥집계기간</th>
`;
modalBody.innerHTML = `<tr><td colspan="8" class="empty">조회 중입니다.</td></tr>`;
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) => `
<tr>
<td>${escapeHtml(row.work_date || "-")}</td>
<td>${escapeHtml(row.phase || "-")}</td>
<td>${escapeHtml(row.support_dept_code || "-")}</td>
<td class="memo-col" title="${escapeHtml(row.project_name || "")}">${escapeHtml(row.project_name || "-")}</td>
<td>${escapeHtml(row.member_name || "-")}</td>
<td>${escapeHtml(row.hour_kind || "-")}</td>
<td class="col-money">${Number(row.hours || 0).toLocaleString("ko-KR", { maximumFractionDigits: 2 })}</td>
<td>${escapeHtml(row.metric_range || "-")}</td>
</tr>
`).join("") : `<tr><td colspan="8" class="empty">직급 누락 투입 내역이 없습니다.</td></tr>`;
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 = `<th>안내</th>`;
modalBody.innerHTML = `<tr><td class="empty">한맥 DB_external 페이지에서 접속 정보를 저장한 뒤 다시 실행해주세요.</td></tr>`;
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 = `<th>기간</th><th>상태</th><th>작업ID</th>`;
const jobs = Array.isArray(payload.jobs) ? payload.jobs : [];
modalBody.innerHTML = jobs.length ? jobs.map((job) => `
<tr>
<td>${escapeHtml(job.start_year || "-")}</td>
<td>${escapeHtml(job.status || "-")}</td>
<td>${escapeHtml(job.id || "-")}</td>
</tr>
`).join("") : `<tr><td colspan="3" class="empty">등록된 작업이 없습니다.</td></tr>`;
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 = `<tr><td colspan="36" class="empty">${escapeHtml(error.message)}</td></tr>`;
});
});
modeToggle.addEventListener("click", (event) => {
const button = event.target.closest("[data-mode]");
if (!button) return;
restoreRememberedDates();
currentMode = button.dataset.mode || "individual";
modeToggle.querySelectorAll("[data-mode]").forEach((item) => item.classList.toggle("active", item === button));
loadData().catch((error) => {
body.innerHTML = `<tr><td colspan="36" class="empty">${escapeHtml(error.message)}</td></tr>`;
});
});
missingGradeButton?.addEventListener("click", () => {
openMissingGrades().catch((error) => {
modalBody.innerHTML = `<tr><td colspan="8" class="empty">${escapeHtml(error.message)}</td></tr>`;
});
});
hanmacCacheRebuildButton?.addEventListener("click", () => {
rebuildHanmacCache().catch((error) => {
modalTitle.textContent = "한맥 캐시 재생성";
modalHeader.innerHTML = `<th>오류</th>`;
modalBody.innerHTML = `<tr><td class="empty">${escapeHtml(error.message)}</td></tr>`;
modalCount.textContent = "0건";
modalTotal.textContent = "";
modal.classList.add("open");
hanmacCacheRebuildButton.disabled = false;
hanmacCacheRebuildButton.textContent = "캐시재생성";
});
});
document.getElementById("costAnalysisTable").querySelector("thead").addEventListener("click", (event) => {
const button = event.target.closest(".sort-button");
if (!button) return;
const key = button.dataset.sortKey || "";
sortState = {
key,
type: button.dataset.sortType || "text",
direction: sortState.key === key && sortState.direction === "asc" ? "desc" : "asc",
};
updateSortHeaders();
recalcVisibleSummary();
if (tableWrap) tableWrap.scrollTop = 0;
renderTable();
});
tableWrap?.addEventListener("scroll", () => {
if (!latestDisplayRows.length) return;
window.requestAnimationFrame(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");
});
});
[pmFilter, typeFilter, statusFilter, projectSearch].forEach((element) => {
element.addEventListener("input", () => {
recalcVisibleSummary();
renderTable();
});
});
[startDateInput, endDateInput].forEach((element) => {
element.addEventListener("input", rememberDateInputs);
element.addEventListener("change", rememberDateInputs);
});
body.addEventListener("click", (event) => {
const linkedButton = event.target.closest(".linked-project-button");
if (linkedButton) {
const row = latestDisplayRows[Number(linkedButton.dataset.linkedIndex || -1)];
openLinkedProjects(row);
return;
}
const button = event.target.closest("[data-detail-item]");
if (!button) return;
openDetail(button).catch((error) => {
modalBody.innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(error.message)}</td></tr>`;
});
});
modalClose.addEventListener("click", () => modal.classList.remove("open"));
modal.addEventListener("click", (event) => {
if (event.target === modal) modal.classList.remove("open");
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") modal.classList.remove("open");
});
loadData().catch((error) => {
body.innerHTML = `<tr><td colspan="36" class="empty">${escapeHtml(error.message)}</td></tr>`;
kpis.innerHTML = "";
summaryTableRow.innerHTML = "";
});
})();
</script>
{% endblock %}