Files

2297 lines
106 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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;
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 {
--kpi-count: 14;
display: grid;
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 {
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: 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 {
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: 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: 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: 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 {
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-sizing: border-box;
box-shadow: 1px 0 0 #d9dde3;
}
.sticky-col-2 {
position: sticky;
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: 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;
}
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 {
display: none;
}
.summary-table-body .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;
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-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: 3px;
width: 58px;
min-width: 58px;
min-height: 22px;
box-sizing: border-box;
padding: 2px 6px;
border: 0;
border-radius: 5px;
background: #d8dee8;
color: #172033;
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.08);
font-size: 11px;
font-weight: 900;
line-height: 1;
}
.summary-row-toggle:hover {
background: #cfd6e0;
transform: none;
}
.summary-row-arrow {
display: inline-block;
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);
}
.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.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-link {
width: 100%;
max-width: 100%;
justify-content: flex-end;
min-height: 22px;
padding: 1px 0;
border: 0;
border-radius: 0;
background: transparent;
color: #111827;
box-shadow: none;
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-link:hover {
background: transparent;
color: #0f766e;
text-decoration: underline;
transform: none;
}
.amount-muted {
display: block;
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 {
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(1500px, 98vw);
max-height: min(92vh, 920px);
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-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-y: auto;
overflow-x: hidden;
}
.detail-table {
width: 100%;
min-width: 0;
table-layout: fixed;
}
.detail-table th,
.detail-table td {
font-size: 12px;
padding: 8px 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: none;
}
.detail-table .memo-col {
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) {
.summary-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
{% endblock %}
{% block content %}
<section class="panel cost-analysis-shell" data-cost-analysis-ui-version="20260618-standard-labor-adjustment-v3">
<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>
</form>
<div class="cost-cache-status-line">
<span class="cost-cache-status" id="costCacheStatus" title="현재 조회 결과의 캐시 상태">상태 확인 중</span>
</div>
<div class="cost-kpis" id="costKpis"></div>
<div class="cost-table-wrap">
<table class="cost-table" id="costAnalysisTable">
<colgroup>
<col class="col-pm"><col class="col-code"><col class="col-name">
<col class="col-small"><col class="col-small"><col class="col-small">
<col class="col-date"><col class="col-date">
<col class="col-money"><col class="col-money"><col class="col-money"><col class="col-money"><col class="col-rate">
<col class="col-money"><col class="col-money"><col class="col-money"><col class="col-money">
<col class="col-money"><col class="col-money pre-outsource-col is-hidden"><col class="col-money">
<col class="col-money"><col class="col-money"><col class="col-money">
<col class="col-money"><col class="col-money"><col class="col-money">
<col class="col-money"><col class="col-money"><col class="col-money">
<col class="col-money"><col class="col-money"><col class="col-money">
<col class="col-money"><col class="col-money"><col class="col-money">
<col class="col-money"><col class="col-money"><col class="col-money"><col class="col-money">
<col class="col-money"><col class="col-money"><col class="col-money"><col class="col-money">
<col class="col-rate"><col class="col-rate"><col class="col-money"><col class="col-money"><col class="col-rate">
</colgroup>
<thead>
<tr>
<th rowspan="3" class="sticky-col col-pm summary-toggle-host">
<button type="button" class="sort-button" data-sort-key="pm_department" data-sort-type="text">PM부서</button>
<button type="button" class="summary-row-toggle summary-header-toggle" id="summaryToggle" aria-expanded="false" title="합계 행 펼치기">
<span class="summary-row-arrow"></span>
<span>합계</span>
</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 rowspan="3" class="col-money"><button type="button" class="sort-button" data-sort-key="period_revenue_amount" data-sort-type="number">기간 ERP매출</button></th>
<th rowspan="3" class="col-money"><button type="button" class="sort-button" data-sort-key="period_billing_amount" data-sort-type="number">기간청구</button></th>
<th rowspan="3" class="col-money"><button type="button" class="sort-button" data-sort-key="period_collection_amount" data-sort-type="number">기간수금</button></th>
<th rowspan="3" class="col-money"><button type="button" class="sort-button" data-sort-key="period_revenue_billing_gap" data-sort-type="number">매출-청구</button></th>
<th colspan="5" class="group-pre" id="prePhaseHeader">사업전</th>
<th colspan="6" class="group-during">사업중</th>
<th colspan="6" class="group-post">사업후</th>
<th colspan="14" class="group-total">총계</th>
</tr>
<tr>
<th colspan="2" class="group-pre sub-cost" id="preCostHeader">원가</th>
<th colspan="3" class="group-pre sub-sga">판관비</th>
<th colspan="3" class="group-during sub-cost">원가</th>
<th colspan="3" class="group-during sub-sga">판관비</th>
<th colspan="3" class="group-post sub-cost">원가</th>
<th colspan="3" 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="cost_labor_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="sga_labor_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-money"><button type="button" class="sort-button" data-sort-key="cumulative_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="cumulative_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="cumulative_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="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_combined" data-sort-type="number">인건비</button></th>
<th class="group-pre col-money pre-outsource-col is-hidden"><button type="button" class="sort-button" data-sort-key="phases.pre.outsource" 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_labor_combined" 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_combined" 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_labor_combined" 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_combined" 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_labor_combined" 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 class="sticky-col col-pm summary-control-sticky" id="summaryControlCell"></td>
<td colspan="48" class="summary-control-filler" id="summaryControlFiller"></td>
</tr>
<tr class="summary-data-row" id="summaryTableRow"></tr>
</tbody>
<tbody id="costAnalysisBody">
<tr><td colspan="48" 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">
<div id="detailModalAccordion" hidden></div>
<table class="detail-table" id="detailModalTable">
<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>
<div class="detail-modal-totals" id="detailModalTotals">
<span id="detailModalHours" hidden></span>
<strong id="detailModalTotal">0</strong>
</div>
</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 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 = 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 phaseLabels = { pre: "사업전", during: "사업중", post: "사업후", all: "전체" };
const itemLabels = {
revenue: "청구수익",
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, "&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 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})$/);
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;
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() {
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 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;
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;
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 `<button type="button" class="amount-button" data-detail-codes="${escapeHtml((codes || []).join(","))}" data-detail-representative-code="${escapeHtml(representativeCode)}" data-detail-phase="${escapeHtml(phase || "all")}" data-detail-item="${escapeHtml(item)}" data-detail-amount="${escapeHtml(value)}" data-detail-title="${escapeHtml(`${row.project_name} · ${phaseLabels[phase] || "전체"} · ${label}`)}"${allocationTitle}>${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 = [
{ 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.style.setProperty("--kpi-count", String(Math.max(1, items.length)));
kpis.innerHTML = items.map((item) => {
if (item.action) {
return `<div class="cost-kpi"><button type="button" class="cost-kpi-button is-plain-metric" data-kpi-action="${escapeHtml(item.action)}" title="${escapeHtml(item.title || "")}"><span>${escapeHtml(item.label)}</span><strong>${escapeHtml(item.value)}</strong></button></div>`;
}
return `<div class="cost-kpi"><span>${escapeHtml(item.label)}</span><strong>${escapeHtml(item.value)}</strong></div>`;
}).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 = `<th>단계</th><th>구성 항목</th><th class="col-money">금액</th><th>구분</th>`;
modalBody.innerHTML = rows.map((row) => `
<tr>
<td>${escapeHtml(row.phase)}</td>
<td>${escapeHtml(row.item)}</td>
<td class="col-money">${formatNumber(row.amount)}</td>
<td class="memo-col">${escapeHtml(row.note)}</td>
</tr>
`).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 = `<th>프로젝트</th><th>사업명</th><th class="col-money">기간 ERP매출</th><th class="col-money">기간청구</th><th class="col-money">차이</th>`;
modalBody.innerHTML = rows.length ? rows.map((item) => `
<tr>
<td>${escapeHtml(item.code)}</td>
<td class="memo-col" title="${escapeHtml(item.name)}">${escapeHtml(item.name)}</td>
<td class="col-money">${formatNumber(item.revenue)}</td>
<td class="col-money">${formatNumber(item.billing)}</td>
<td class="col-money">${formatNumber(item.gap)}</td>
</tr>
`).join("") : `<tr><td colspan="5" class="empty">기간 ERP매출과 기간청구 차이가 있는 프로젝트가 없습니다.</td></tr>`;
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 = `<th>구분</th><th class="col-money">금액</th><th>해석</th>`;
modalBody.innerHTML = rows.map(([label, amount, note]) => `
<tr>
<td>${escapeHtml(label)}</td>
<td class="col-money">${formatNumber(amount)}</td>
<td class="memo-col" title="${escapeHtml(note)}">${escapeHtml(note)}</td>
</tr>
`).join("");
modalCount.textContent = `${formatNumber(rows.length)}개 항목`;
modalTotal.textContent = `현재 화면 기간 배부비용 ${formatNumber(visibleAllocatedCost)}`;
modal.classList.add("open");
}
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(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(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(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)}
`;
}
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 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 || ""} ${(row.direct_codes || []).join(" ")}`.toLowerCase().includes(keyword)) return false;
return true;
});
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);
}
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 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 = `<tr><td colspan="${tableColumnCount()}" class="empty">조건에 맞는 데이터가 없습니다.</td></tr>`;
lastRenderedTableScrollTop = tableWrap?.scrollTop || 0;
stableTableScrollLeft = tableWrap?.scrollLeft || stableTableScrollLeft;
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="${tableColumnCount()}" style="height:${topHeight}px;padding:0;border:0;background:#fff;"></td></tr>` : "";
const bottomSpacer = bottomHeight ? `<tr class="virtual-spacer"><td colspan="${tableColumnCount()}" 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 sourceCodeTitle = row.is_common_activity && Array.isArray(row.direct_codes)
? `원천 업무코드: ${row.direct_codes.join(", ")}`
: "";
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>`
: `<span title="${escapeHtml(sourceCodeTitle)}">${escapeHtml(row.support_dept_code || "-")}</span>`;
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 `
<tr class="${rowClasses}" data-row-index="${rowIndex}">
<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, "all", "revenue", row.period_revenue_amount, { codes: row.direct_codes || [row.support_dept_code], label: "기간 ERP매출" })}</td>
<td class="col-money">${amountCell(row, "all", "billing", row.period_billing_amount, { codes: row.direct_codes || [row.support_dept_code], label: "기간청구" })}</td>
<td class="col-money">${amountCell(row, "all", "collection", row.period_collection_amount, { codes: row.direct_codes || [row.support_dept_code], label: "기간수금" })}</td>
<td class="col-money"><button type="button" class="amount-link" data-revenue-billing-gap="${rowIndex}">${formatNumber(row.period_revenue_billing_gap)}</button></td>
<td class="col-money">${amountCell(row, "pre", "labor_combined", Number(pre.labor || 0) + Number(pre.labor_adjustment || 0))}</td>
${showPreOutsourceColumn ? `<td class="col-money pre-outsource-col">${amountCell(row, "pre", "outsource", pre.outsource)}</td>` : ""}
<td class="col-money">${amountCell(row, "pre", "overhead", pre.overhead)}</td>
<td class="col-money">${amountCell(row, "pre", "sga_labor_combined", Number(pre.sga_labor || 0) + Number(pre.sga_labor_adjustment || 0))}</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_combined", Number(during.labor || 0) + Number(during.labor_adjustment || 0))}</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_labor_combined", Number(during.sga_labor || 0) + Number(during.sga_labor_adjustment || 0))}</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_combined", Number(post.labor || 0) + Number(post.labor_adjustment || 0))}</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_labor_combined", Number(post.sga_labor || 0) + Number(post.sga_labor_adjustment || 0))}</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">${formatNumber(row.cost_labor_total)}</td>
<td class="col-money">${amountCell(row, "all", "sga_total", row.sga_total, { codes: row.direct_codes, label: "판관비합계" })}</td>
<td class="col-money">${formatNumber(row.sga_labor_total)}</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">${row.is_common_master ? "-" : formatNumber(row.profit_amount)}</td>
<td class="ratio-cell">${row.is_common_master ? "-" : formatRate(row.revenue_profit_rate)}</td>
<td class="ratio-cell">${row.is_common_master ? "-" : formatRate(row.collection_profit_rate)}</td>
<td class="col-money">${row.is_common_master ? "-" : formatNumber(row.cumulative_revenue_amount)}</td>
<td class="col-money">${row.is_common_master ? "-" : formatNumber(row.cumulative_total_cost)}</td>
<td class="col-money">${row.is_common_master ? "-" : formatNumber(row.cumulative_profit_amount)}</td>
<td class="ratio-cell">${row.is_common_master ? "-" : formatRate(row.cumulative_profit_rate)}</td>
</tr>
`;
}).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 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) {
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.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", "labor_adjustment", "outsource", "overhead", "sga_labor", "sga_labor_adjustment", "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,
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, 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);
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.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);
}
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 = "조회 중";
if (!preserve) {
kpis.innerHTML = `<div class="cost-kpi"><span>조회</span><strong>조회 중</strong></div>`;
summaryTableRow.innerHTML = "";
body.innerHTML = `<tr><td colspan="${tableColumnCount()}" class="empty">조회 중입니다.</td></tr>`;
}
try {
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",
signal: controller.signal,
});
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();
setTableScrollPosition(0, 0);
renderTable();
} catch (error) {
if (error?.name === "AbortError") return;
throw error;
} finally {
if (controller === activeLoadController) {
activeLoadController = null;
searchButton.disabled = false;
if (!activeRefreshJobId) searchButton.textContent = "조회";
}
}
}
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 `
<section class="detail-accordion${open ? " open" : ""}">
<button type="button" class="detail-accordion-toggle" aria-expanded="${open ? "true" : "false"}">
<strong>${escapeHtml(title)}</strong>
<span>${escapeHtml(summary)} <span class="detail-accordion-arrow">▾</span></span>
</button>
<div class="detail-accordion-panel">${content}</div>
</section>
`;
}
function directDetailTable(rows, detailType) {
if (!rows.length) return `<div class="empty">직접 귀속된 상세 내역이 없습니다.</div>`;
if (detailType === "billing") {
return `
<table class="detail-accordion-table">
<thead><tr><th>DB 청구일</th><th>세금계산서일</th><th>ERP 증빙일</th><th>거래처</th><th>프로젝트</th><th>가전표번호</th><th>확정전표번호</th><th>대조상태</th><th class="col-money">금액</th></tr></thead>
<tbody>${rows.map((row) => `
<tr>
<td>${escapeHtml(row.posting_date || "-")}</td>
<td>${escapeHtml(row.tax_invoice_date || "-")}</td>
<td>${escapeHtml(row.erp_posting_date || "-")}</td>
<td>${escapeHtml(row.partner_name || "-")}</td>
<td>${escapeHtml(row.project_code || "-")}</td>
<td>${escapeHtml(row.voucher_number || "-")}</td>
<td>${escapeHtml(row.confirmed_voucher_number || "-")}</td>
<td>${escapeHtml(row.match_status || "-")}</td>
<td class="col-money">${formatNumber(row.amount)}</td>
</tr>
`).join("")}</tbody>
</table>
`;
}
if (detailType === "collection") {
return `
<table class="detail-accordion-table">
<thead><tr><th>수금 전표일</th><th>미수금계정</th><th>거래처</th><th>프로젝트</th><th>가전표번호</th><th>확정전표번호</th><th>원 청구 전표</th><th>환산상태</th><th class="col-money">공급가액</th><th class="col-money">입금총액</th></tr></thead>
<tbody>${rows.map((row) => `
<tr>
<td>${escapeHtml(row.posting_date || "-")}</td>
<td>${escapeHtml(row.account_name || "-")}</td>
<td>${escapeHtml(row.partner_name || "-")}</td>
<td>${escapeHtml(row.project_code || "-")}</td>
<td>${escapeHtml(row.voucher_number || "-")}</td>
<td>${escapeHtml(row.confirmed_voucher_number || "-")}</td>
<td>${escapeHtml(row.source_invoice_confirmed_voucher_number || row.source_invoice_voucher_number || "-")}</td>
<td>${escapeHtml(row.match_status || "-")}</td>
<td class="col-money">${formatNumber(row.amount)}</td>
<td class="col-money">${formatNumber(row.gross_amount)}</td>
</tr>
`).join("")}</tbody>
</table>
`;
}
if (detailType === "labor") {
return `
<table class="detail-accordion-table">
<thead><tr><th>사번</th><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></tr></thead>
<tbody>${rows.map((row) => `
<tr>
<td>${escapeHtml(row.member_no || "-")}</td>
<td>${escapeHtml(row.member_name || "-")}</td>
<td>${escapeHtml(row.member_grade || "-")}</td>
<td>${escapeHtml(row.project_code || "-")}</td>
<td>${formatHours(row.regular_hours)} / ${formatHours(row.overtime_hours)} / ${formatHours(row.holiday_hours)}h</td>
<td class="col-money">${formatNumber(Number(row.regular_amount || 0) + Number(row.overtime_base_amount || 0) + Number(row.holiday_base_amount || 0))}</td>
<td class="col-money">${formatNumber(row.overtime_premium_amount)}</td>
<td class="col-money">${formatNumber(row.holiday_premium_amount)}</td>
<td class="col-money">${formatNumber(row.amount)}</td>
</tr>
`).join("")}</tbody>
</table>
`;
}
return `
<table class="detail-accordion-table">
<thead><tr><th>일자</th><th>계정명</th><th>거래처</th><th>개별프로젝트</th><th>적요</th><th class="col-money">금액</th></tr></thead>
<tbody>${rows.map((row) => `
<tr>
<td>${escapeHtml(row.posting_date || "-")}</td>
<td>${escapeHtml(row.account_name || "-")}</td>
<td>${escapeHtml(row.partner_name || "-")}</td>
<td>${escapeHtml(row.project_code || "-")}</td>
<td>${escapeHtml(row.memo1 || "")}</td>
<td class="col-money">${formatNumber(row.amount)}</td>
</tr>
`).join("")}</tbody>
</table>
`;
}
function allocationDetailTable(rows, sourceView) {
if (!rows.length) return `<div class="empty">추가 배분 내역이 없습니다.</div>`;
return `
<table class="detail-accordion-table">
<thead><tr><th>원천 공통사업</th><th>${sourceView ? "배분 프로젝트" : "원천코드"}</th><th>단계</th><th>프로젝트시간</th><th>단계시간</th><th>전체 대상시간</th><th>배분율</th><th class="col-money">배분금액</th></tr></thead>
<tbody>${rows.map((detail) => `
<tr>
<td>${escapeHtml(detail.source_project_name || "-")}</td>
<td>${escapeHtml(sourceView ? `${detail.target_project_code || ""} ${detail.target_project_name || ""}` : detail.source_project_code || "-")}</td>
<td>${escapeHtml(phaseLabels[detail.target_phase] || detail.target_phase || "-")}</td>
<td>${formatHours(detail.project_hours)}h</td>
<td>${formatHours(detail.project_phase_hours)}h</td>
<td>${formatHours(detail.eligible_total_hours)}h</td>
<td>${(Number(detail.project_ratio || 0) * Number(detail.phase_ratio || 0) * 100).toFixed(2)}%</td>
<td class="col-money">${formatNumber(detail.allocated_amount)}</td>
</tr>
`).join("")}</tbody>
</table>
`;
}
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 = `${lockedStartDate} ~ ${lockedEndDate}`;
modalTable.hidden = true;
modalAccordion.hidden = false;
modalAccordion.innerHTML = `<div class="empty">조회 중입니다.</div>`;
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 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 = `
<table class="detail-accordion-table">
<tbody>
<tr><th>직접 귀속 원값</th><td class="col-money">${formatNumber(directSubtotal)}</td></tr>
<tr><th>${sourceView ? "타 프로젝트 배분액" : "추가 배분액"}</th><td class="col-money">${formatNumber(allocationSubtotal)}</td></tr>
<tr><th>화면 표시 합계</th><td class="col-money"><strong>${formatNumber(displayAmount)}</strong></td></tr>
</tbody>
</table>
`;
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 || "";
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");
}
form.addEventListener("submit", (event) => {
event.preventDefault();
rememberDateInputs();
loadData().catch((error) => {
body.innerHTML = `<tr><td colspan="${tableColumnCount()}" class="empty">${escapeHtml(error.message)}</td></tr>`;
});
});
modeToggle.addEventListener("click", (event) => {
const button = event.target.closest("[data-mode]");
if (!button) return;
rememberDateInputs();
currentMode = button.dataset.mode || "individual";
modeToggle.querySelectorAll("[data-mode]").forEach((item) => item.classList.toggle("active", item === button));
rememberDateInputs();
loadData().catch((error) => {
body.innerHTML = `<tr><td colspan="${tableColumnCount()}" class="empty">${escapeHtml(error.message)}</td></tr>`;
});
});
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();
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;
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");
setSummaryOpen(nextOpen);
});
summaryToggle?.addEventListener("click", () => {
setSummaryOpen(true);
});
[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 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)];
openLinkedProjects(row);
return;
}
const button = event.target.closest("[data-detail-item]");
if (!button) return;
openDetail(button, clickedRow).catch((error) => {
modalAccordion.hidden = false;
modalTable.hidden = true;
modalAccordion.innerHTML = `<div class="empty">${escapeHtml(error.message)}</div>`;
});
});
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 = `<tr><td colspan="${tableColumnCount()}" class="empty">${escapeHtml(error.message)}</td></tr>`;
kpis.innerHTML = "";
summaryTableRow.innerHTML = "";
});
})();
</script>
{% endblock %}