Preserve latest comparison functionality

This commit is contained in:
b17301
2026-06-08 14:25:47 +09:00
parent b93289bfa8
commit 886192ae58
12 changed files with 7966 additions and 485 deletions
+86 -39
View File
@@ -53,8 +53,8 @@
.cost-kpis {
display: grid;
grid-template-columns: repeat(10, minmax(0, 1fr));
gap: 8px;
grid-template-columns: repeat(9, minmax(0, 1fr));
gap: 6px;
}
.cost-kpi {
@@ -69,6 +69,9 @@
font-size: 11px;
font-weight: 800;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cost-kpi strong {
@@ -77,6 +80,8 @@
line-height: 1.15;
white-space: nowrap;
word-break: keep-all;
overflow: hidden;
text-overflow: ellipsis;
}
.summary-accordion {
@@ -174,7 +179,8 @@
}
.cost-table {
min-width: 2820px;
width: 100%;
min-width: 0;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
@@ -182,8 +188,8 @@
.cost-table th,
.cost-table td {
padding: 8px 9px;
font-size: 12px;
padding: 7px 5px;
font-size: 11px;
vertical-align: middle;
border-right: 1px solid #e6e8ec;
background: #ffffff;
@@ -238,29 +244,50 @@
position: sticky;
left: 0;
z-index: 6;
background: #ffffff;
background-clip: padding-box;
box-shadow: 1px 0 0 #d9dde3;
}
.sticky-col-2 {
position: sticky;
left: 130px;
left: 94px;
z-index: 6;
background: #ffffff;
background-clip: padding-box;
box-shadow: 1px 0 0 #d9dde3;
}
.sticky-col-3 {
position: sticky;
left: 236px;
left: 172px;
z-index: 6;
background: #ffffff;
background-clip: padding-box;
box-shadow: 1px 0 0 #d9dde3;
}
thead .sticky-col,
thead .sticky-col-2,
thead .sticky-col-3 {
background: #edf4f4;
z-index: 70 !important;
}
.cost-table tbody td.sticky-col,
.cost-table tbody td.sticky-col-2,
.cost-table tbody td.sticky-col-3 {
background: #ffffff;
background-clip: padding-box;
z-index: 16;
}
.cost-table tbody tr:hover td.sticky-col,
.cost-table tbody tr:hover td.sticky-col-2,
.cost-table tbody tr:hover td.sticky-col-3 {
background: #f8fbfb;
}
.summary-table-body td {
background: #fbfcfd;
font-weight: 900;
@@ -308,12 +335,15 @@
.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 {
@@ -450,13 +480,13 @@
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12);
}
.col-pm { width: 130px; }
.col-code { width: 106px; }
.col-name { width: 240px; }
.col-small { width: 82px; }
.col-date { width: 104px; }
.col-money { width: 118px; text-align: right; }
.col-rate { width: 86px; text-align: right; }
.col-pm { width: 94px; }
.col-code { width: 78px; }
.col-name { width: 148px; }
.col-small { width: 54px; }
.col-date { width: 72px; }
.col-money { width: 72px; text-align: right; }
.col-rate { width: 62px; text-align: right; }
.group-pre { background: #f3f8ee !important; }
.group-during { background: #eef5fb !important; }
@@ -475,7 +505,7 @@
background: transparent;
color: #111827;
box-shadow: none;
font-size: 12px;
font-size: 11px;
font-weight: 800;
text-align: right;
}
@@ -520,7 +550,7 @@
background: transparent;
color: #0f766e;
box-shadow: none;
font-size: 12px;
font-size: 11px;
font-weight: 900;
text-align: left;
}
@@ -588,7 +618,7 @@
@media (max-width: 720px) {
.cost-kpis {
overflow-x: auto;
grid-template-columns: repeat(10, minmax(96px, 1fr));
grid-template-columns: repeat(9, minmax(96px, 1fr));
}
.summary-grid {
@@ -600,12 +630,6 @@
{% block content %}
<section class="panel cost-analysis-shell">
<div class="section-title">
<div>
<h2>프로젝트 손익분석</h2>
</div>
</div>
<form class="cost-toolbar" id="costAnalysisForm">
<div class="field">
<label>조회방식</label>
@@ -681,7 +705,7 @@
<th colspan="4" class="group-pre">사업전</th>
<th colspan="5" class="group-during">사업중</th>
<th colspan="5" class="group-post">사업후</th>
<th colspan="8" class="group-total">총계</th>
<th colspan="9" class="group-total">총계</th>
</tr>
<tr>
<th colspan="2" class="group-pre sub-cost">원가</th>
@@ -698,6 +722,7 @@
<th rowspan="2" class="group-total col-money"><button type="button" class="sort-button" data-sort-key="profit_amount" data-sort-type="number">이윤</button></th>
<th rowspan="2" class="group-total col-rate"><button type="button" class="sort-button" data-sort-key="revenue_profit_rate" data-sort-type="number">청구수익률</button></th>
<th rowspan="2" class="group-total col-rate"><button type="button" class="sort-button" data-sort-key="collection_profit_rate" data-sort-type="number">수금수익률</button></th>
<th rowspan="2" class="group-total col-rate"><button type="button" class="sort-button" data-sort-key="cumulative_profit_rate" data-sort-type="number">누적수익률</button></th>
</tr>
<tr>
<th class="group-pre col-money"><button type="button" class="sort-button" data-sort-key="phases.pre.labor" data-sort-type="number">인건비</button></th>
@@ -718,7 +743,7 @@
</thead>
<tbody class="summary-table-body" id="summaryTableBody">
<tr class="summary-control-row">
<td colspan="35">
<td colspan="36">
<button type="button" class="summary-row-toggle" id="summaryToggle" aria-expanded="false">
<span class="summary-row-arrow"></span>
<span>합계</span>
@@ -728,7 +753,7 @@
<tr class="summary-data-row" id="summaryTableRow"></tr>
</tbody>
<tbody id="costAnalysisBody">
<tr><td colspan="35" class="empty">조회 중입니다.</td></tr>
<tr><td colspan="36" class="empty">조회 중입니다.</td></tr>
</tbody>
</table>
</div>
@@ -897,14 +922,13 @@
const items = [
["프로젝트", `${formatNumber(summary.project_count)}`],
["도급금액", formatNumber(summary.contract_amount)],
["청구금액", formatNumber(summary.billing_amount)],
["청구수익", formatNumber(summary.revenue_amount)],
["수금금액", formatNumber(summary.collection_amount)],
["기간 청구금액", formatNumber(summary.billing_amount)],
["기간 수금금액", formatNumber(summary.collection_amount)],
["잔여도급금액", formatBalance(summary.contract_balance_amount)],
["총비용", formatNumber(summary.total_cost)],
["이윤", formatNumber(summary.profit_amount)],
["청구수익률", formatRate(summary.revenue_profit_rate)],
["수금수익률", formatRate(summary.collection_profit_rate)],
["누적수익률", formatRate(summary.cumulative_profit_rate)],
];
kpis.innerHTML = items.map(([label, value]) => `<div class="cost-kpi"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`).join("");
}
@@ -963,6 +987,7 @@
${moneyCell(summary.profit_amount)}
${rateCell(summary.revenue_profit_rate)}
${rateCell(summary.collection_profit_rate)}
${rateCell(summary.cumulative_profit_rate)}
`;
}
@@ -1024,7 +1049,7 @@
function renderTable() {
const rows = latestDisplayRows.length ? latestDisplayRows : getFilteredRows();
if (!rows.length) {
body.innerHTML = `<tr><td colspan="35" class="empty">조건에 맞는 데이터가 없습니다.</td></tr>`;
body.innerHTML = `<tr><td colspan="36" class="empty">조건에 맞는 데이터가 없습니다.</td></tr>`;
return;
}
const viewportHeight = tableWrap?.clientHeight || 600;
@@ -1035,8 +1060,8 @@
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="35" style="height:${topHeight}px;padding:0;border:0;background:#fff;"></td></tr>` : "";
const bottomSpacer = bottomHeight ? `<tr class="virtual-spacer"><td colspan="35" style="height:${bottomHeight}px;padding:0;border:0;background:#fff;"></td></tr>` : "";
const topSpacer = topHeight ? `<tr class="virtual-spacer"><td colspan="36" style="height:${topHeight}px;padding:0;border:0;background:#fff;"></td></tr>` : "";
const bottomSpacer = bottomHeight ? `<tr class="virtual-spacer"><td colspan="36" style="height:${bottomHeight}px;padding:0;border:0;background:#fff;"></td></tr>` : "";
body.innerHTML = topSpacer + visibleRows.map((row, visibleOffset) => {
const rowIndex = startIndex + visibleOffset;
const pre = row.phases?.pre || {};
@@ -1083,6 +1108,7 @@
<td class="col-money">${formatNumber(row.profit_amount)}</td>
<td class="ratio-cell">${formatRate(row.revenue_profit_rate)}</td>
<td class="ratio-cell">${formatRate(row.collection_profit_rate)}</td>
<td class="ratio-cell">${formatRate(row.cumulative_profit_rate)}</td>
</tr>
`;
}).join("") + bottomSpacer;
@@ -1091,10 +1117,20 @@
function recalcVisibleSummary() {
const rows = getFilteredRows();
latestDisplayRows = rows;
const summary = rows.reduce((acc, row) => {
const buildSummary = (usePeriodValues = false) => rows.reduce((acc, row) => {
for (const key of ["contract_amount", "billing_amount", "collection_amount", "contract_balance_amount", "revenue_amount", "cost_total", "sga_total", "sales_total", "total_cost", "profit_amount"]) {
acc[key] += Number(row[key] || 0);
}
if (usePeriodValues) {
acc.billing_amount += Number(row.period_billing_amount || 0) - Number(row.billing_amount || 0);
acc.collection_amount += Number(row.period_collection_amount || 0) - Number(row.collection_amount || 0);
acc.revenue_amount += Number(row.period_revenue_amount || 0) - Number(row.revenue_amount || 0);
acc.cost_total += Number(row.period_cost_total || 0) - Number(row.cost_total || 0);
acc.sga_total += Number(row.period_sga_total || 0) - Number(row.sga_total || 0);
acc.sales_total += Number(row.period_sales_total || 0) - Number(row.sales_total || 0);
acc.total_cost += Number(row.period_total_cost || 0) - Number(row.total_cost || 0);
acc.profit_amount += Number(row.period_profit_amount || 0) - Number(row.profit_amount || 0);
}
for (const phase of ["pre", "during", "post"]) {
const bucket = row.phases?.[phase] || {};
for (const item of ["labor", "outsource", "overhead", "sga", "sales"]) {
@@ -1115,17 +1151,26 @@
sales_total: 0,
total_cost: 0,
profit_amount: 0,
cumulative_profit_amount: 0,
phases: {
pre: { labor: 0, outsource: 0, overhead: 0, sga: 0, sales: 0 },
during: { labor: 0, outsource: 0, overhead: 0, sga: 0, sales: 0 },
post: { labor: 0, outsource: 0, overhead: 0, sga: 0, sales: 0 },
},
});
const summary = buildSummary(false);
summary.collection_rate = summary.contract_amount ? summary.collection_amount / summary.contract_amount * 100 : 0;
summary.contract_profit_rate = summary.contract_amount ? summary.profit_amount / summary.contract_amount * 100 : 0;
summary.revenue_profit_rate = summary.revenue_amount ? summary.profit_amount / summary.revenue_amount * 100 : 0;
summary.collection_profit_rate = summary.collection_amount ? summary.profit_amount / summary.collection_amount * 100 : 0;
renderKpis(summary);
summary.cumulative_profit_rate = summary.collection_amount ? (summary.collection_amount - summary.total_cost) / summary.collection_amount * 100 : 0;
const kpiSummary = buildSummary(true);
kpiSummary.collection_rate = kpiSummary.contract_amount ? kpiSummary.collection_amount / kpiSummary.contract_amount * 100 : 0;
kpiSummary.contract_profit_rate = kpiSummary.contract_amount ? kpiSummary.profit_amount / kpiSummary.contract_amount * 100 : 0;
kpiSummary.revenue_profit_rate = kpiSummary.revenue_amount ? kpiSummary.profit_amount / kpiSummary.revenue_amount * 100 : 0;
kpiSummary.collection_profit_rate = kpiSummary.collection_amount ? kpiSummary.profit_amount / kpiSummary.collection_amount * 100 : 0;
kpiSummary.cumulative_profit_rate = summary.cumulative_profit_rate;
renderKpis(kpiSummary);
renderSummaryTable(summary);
}
@@ -1136,7 +1181,9 @@
const controller = activeLoadController;
searchButton.disabled = true;
searchButton.textContent = "조회 중";
body.innerHTML = `<tr><td colspan="35" class="empty">조회 중입니다.</td></tr>`;
kpis.innerHTML = `<div class="cost-kpi"><span>조회</span><strong>조회 중</strong></div>`;
summaryTableRow.innerHTML = "";
body.innerHTML = `<tr><td colspan="36" class="empty">조회 중입니다.</td></tr>`;
try {
const params = new URLSearchParams({ start_date: lockedStartDate, end_date: lockedEndDate, mode: currentMode });
const response = await fetch(`/cost-analysis/data?${params.toString()}`, {
@@ -1365,7 +1412,7 @@
event.preventDefault();
rememberDateInputs();
loadData().catch((error) => {
body.innerHTML = `<tr><td colspan="35" class="empty">${escapeHtml(error.message)}</td></tr>`;
body.innerHTML = `<tr><td colspan="36" class="empty">${escapeHtml(error.message)}</td></tr>`;
});
});
@@ -1376,7 +1423,7 @@
currentMode = button.dataset.mode || "individual";
modeToggle.querySelectorAll("[data-mode]").forEach((item) => item.classList.toggle("active", item === button));
loadData().catch((error) => {
body.innerHTML = `<tr><td colspan="35" class="empty">${escapeHtml(error.message)}</td></tr>`;
body.innerHTML = `<tr><td colspan="36" class="empty">${escapeHtml(error.message)}</td></tr>`;
});
});
@@ -1464,7 +1511,7 @@
});
loadData().catch((error) => {
body.innerHTML = `<tr><td colspan="35" class="empty">${escapeHtml(error.message)}</td></tr>`;
body.innerHTML = `<tr><td colspan="36" class="empty">${escapeHtml(error.message)}</td></tr>`;
kpis.innerHTML = "";
summaryTableRow.innerHTML = "";
});