2297 lines
106 KiB
HTML
2297 lines
106 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;
|
||
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, "&")
|
||
.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 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 %}
|