Preserve latest comparison functionality
This commit is contained in:
@@ -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 = "";
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user