1521 lines
63 KiB
HTML
1521 lines
63 KiB
HTML
{% 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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
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 %}
|