feat(manage): refine lifecycle flow UI and direct/shared cost breakdown

This commit is contained in:
hikim2 committed 2026-04-23 14:31:41 +09:00
1 parent 90042a003a
commit 868661426f
3 files changed
+242 -105

No files matched your search

+157 -92
View File
@@ -49,6 +49,7 @@
:root {
--ink: #0f1c2e;
--muted: #66788f;
--muted-strong: #4b5d73;
--line: #d8e2ec;
--blue: #113f67;
--cyan: #1f7a8c;
@@ -1182,6 +1183,50 @@
color: var(--blue);
text-decoration: underline;
}
.lifecycle-breakdown-trigger {
transition: background-color 0.16s ease, color 0.16s ease;
outline: none;
}
.lifecycle-breakdown-trigger:hover,
.lifecycle-breakdown-trigger:focus-visible {
background: rgba(18, 76, 124, 0.06) !important;
}
.lifecycle-breakdown-trigger:focus-visible {
box-shadow: inset 0 0 0 1px rgba(18, 76, 124, 0.34);
}
.lifecycle-flow-project-block {
min-height: 60px;
display: grid;
align-content: start;
gap: 2px;
overflow: hidden;
}
.lifecycle-flow-head {
min-height: 22px;
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
}
.lifecycle-flow-project-code {
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lifecycle-flow-project-name {
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lifecycle-flow-amount-row {
min-height: 56px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
align-items: start;
}
.link-button {
border: none;
background: transparent;
@@ -2071,38 +2116,59 @@
const related = detail?.related_projects || [];
const roleOrder = ["영업", "설계", "시공"];
const cards = [];
const selectedSummaryCode = detail?.summary?.project_code || selectedProjectCode || "";
const selectedSummaryName = detail?.summary?.project_name || "";
const selectedProgressRate = Number(detail?.budget_analysis?.progress_rate || 0);
for (const role of roleOrder) {
let item = null;
if (role === "시공") {
item = related.find((row) => (row.project_code || "") === (selectedProjectCode || "")) || null;
item = related.find((row) => (row.project_code || "") === selectedSummaryCode) || null;
}
if (!item) {
item = related.find((row) => (row.project_type || "") === role) || null;
}
if (!item) continue;
if (!item && role === "시공" && selectedSummaryCode) {
item = {
project_code: selectedSummaryCode,
project_name: selectedSummaryName,
project_type: "시공",
progress_rate: selectedProgressRate,
};
}
const ratio = role === "영업" || role === "설계"
? (lifecycleRatioMap[item.project_code] ?? 1)
? (item?.project_code ? (lifecycleRatioMap[item.project_code] ?? 1) : 1)
: 1;
const income = Number(item.income_supply || 0);
const expense = Number(item.expense_supply || 0);
const income = Number(item?.income_supply || 0);
const expense = Number(item?.expense_supply || 0);
const rowAllocation = (detail?.lifecycle_cost?.rows || []).find((row) => row.project_code === item?.project_code);
cards.push({
role,
project_code: item.project_code || "",
project_name: item.project_name || "",
note: item.note || "",
has_project: !!item?.project_code,
project_code: item?.project_code || "",
project_name: item?.project_name || "",
note: item?.note || "",
income,
expense,
reflected_income: income * ratio,
reflected_expense: expense * ratio,
numerator: Number((detail?.lifecycle_cost?.rows || []).find((row) => row.project_code === item.project_code)?.allocation_numerator || 1),
denominator: Number((detail?.lifecycle_cost?.rows || []).find((row) => row.project_code === item.project_code)?.allocation_denominator || 1),
numerator: Number(rowAllocation?.allocation_numerator || 1),
denominator: Number(rowAllocation?.allocation_denominator || 1),
progress_rate: role === "시공"
? selectedProgressRate
: Number(item?.progress_rate || 0),
});
}
return cards;
}, [detail?.related_projects, detail?.lifecycle_cost?.rows, lifecycleRatioMap, selectedProjectCode]);
}, [detail?.related_projects, detail?.lifecycle_cost?.rows, detail?.summary?.project_code, detail?.summary?.project_name, detail?.budget_analysis?.progress_rate, lifecycleRatioMap, selectedProjectCode]);
const lifecycleMarginRate = useMemo(() => {
const income = Number(detail?.lifecycle_cost?.summary?.income_supply || 0);
const profit = Number(detail?.lifecycle_cost?.summary?.profit_supply || 0);
if (!income) return 0;
return (profit / income) * 100;
}, [detail?.lifecycle_cost?.summary?.income_supply, detail?.lifecycle_cost?.summary?.profit_supply]);
const visibleProjectList = isLifecycleTab ? filteredLifecycleProjects : filteredProjects;
useEffect(() => {
@@ -3473,6 +3539,8 @@
setLifecycleBreakdownModal({
label: refreshed.label || "",
expense_supply: Number(refreshed.expense_supply || 0),
direct_expense_supply: Number(refreshed.direct_expense_supply || 0),
shared_expense_supply: Number(refreshed.shared_expense_supply || 0),
projects: Array.isArray(refreshed.projects) ? refreshed.projects.map((project) => ({ ...project })) : [],
accounts: Array.isArray(refreshed.accounts) ? refreshed.accounts.map((account) => ({ ...account })) : [],
opened_at: Date.now(),
@@ -3518,6 +3586,8 @@
setLifecycleBreakdownModal({
label: refreshed.label || "",
expense_supply: Number(refreshed.expense_supply || 0),
direct_expense_supply: Number(refreshed.direct_expense_supply || 0),
shared_expense_supply: Number(refreshed.shared_expense_supply || 0),
projects: Array.isArray(refreshed.projects) ? refreshed.projects.map((project) => ({ ...project })) : [],
accounts: Array.isArray(refreshed.accounts) ? refreshed.accounts.map((account) => ({ ...account })) : [],
opened_at: Date.now(),
@@ -5016,17 +5086,17 @@
{currentTab === "lifecycle" && (detail?.lifecycle_cost?.rows || []).length > 0 && (
<section className="panel" style={{ padding: 20 }}>
<section className="mini-card" style={{ marginBottom: 14 }}>
<section style={{ marginBottom: 14, paddingBottom: 10, borderBottom: "1px solid var(--line)" }}>
<div style={{ fontSize: 16, fontWeight: 700 }}>관련 프로젝트 흐름</div>
<div className="subtle" style={{ marginTop: 6 }}>영업/설계 카드를 누르면 배분 비율(해당프로젝트/총프로젝트)을 입력할 수 있습니다.</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 12, marginTop: 10 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 0, marginTop: 10, borderTop: "1px solid var(--line)" }}>
{lifecycleFlowCards.map((card) => (
<div
key={`lifecycle-flow-${card.role}-${card.project_code}`}
role={card.role !== "시공" ? "button" : undefined}
tabIndex={card.role !== "시공" ? 0 : undefined}
role={card.role !== "시공" && card.has_project ? "button" : undefined}
tabIndex={card.role !== "시공" && card.has_project ? 0 : undefined}
onClick={() => {
if (card.role === "시공") return;
if (card.role === "시공" || !card.has_project) return;
openLifecycleAllocationModal({
project_code: card.project_code,
project_name: card.project_name,
@@ -5036,7 +5106,7 @@
});
}}
onKeyDown={(event) => {
if (card.role === "시공") return;
if (card.role === "시공" || !card.has_project) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
openLifecycleAllocationModal({
@@ -5048,48 +5118,56 @@
});
}}
style={{
border: "1px solid var(--line)",
borderRadius: 14,
background: "white",
padding: "12px 14px",
border: "none",
borderRight: card.role !== "시공" ? "1px solid var(--line)" : "none",
borderRadius: 0,
background: card.has_project ? "transparent" : "rgba(15, 28, 46, 0.03)",
padding: `12px 14px 10px ${card.role === "영업" ? 0 : 14}px`,
display: "grid",
alignContent: "start",
gap: 8,
cursor: card.role !== "시공" ? "pointer" : "default",
cursor: card.role !== "시공" && card.has_project ? "pointer" : "default",
color: card.has_project ? "var(--ink)" : "var(--muted-strong)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "center" }}>
<div className="lifecycle-flow-head" style={{ opacity: card.has_project ? 1 : 0.9 }}>
<div style={{ fontSize: 14, fontWeight: 800 }}>{card.role}</div>
{card.role !== "시공" && (
<div className="subtle">{fmt(card.numerator)} / {fmt(card.denominator)}</div>
<div className="subtle" style={{ color: card.has_project ? undefined : "var(--muted-strong)" }}>{fmt(card.numerator)} / {fmt(card.denominator)}</div>
)}
{card.role === "시공" && (
<div className="subtle">현재 프로젝트</div>
<div className="subtle">
{card.has_project ? <>공정률 <strong style={{ color: "var(--ink)" }}>{Number(card.progress_rate || 0).toFixed(1)}%</strong></> : ""}
</div>
)}
</div>
<div style={{ fontWeight: 800 }}>{card.project_code}</div>
<div style={{ fontWeight: 700 }}>{card.project_name || "(이름없음)"}</div>
{!!card.note && (
<div className="subtle" style={{ whiteSpace: "pre-wrap" }}>{card.note}</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 8, marginTop: 2 }}>
<div className="lifecycle-flow-project-block">
<div className="lifecycle-flow-project-code" style={{ color: card.has_project ? "var(--ink)" : "var(--muted-strong)" }}>
{card.project_code || "-"}
</div>
<div className="lifecycle-flow-project-name" style={{ color: card.has_project ? "var(--ink)" : "var(--muted-strong)" }}>
{card.project_name || "연결 프로젝트 없음"}
</div>
</div>
<div className="lifecycle-flow-amount-row" style={{ marginTop: 2 }}>
<div>
<div className="subtle">실제 매출</div>
<div style={{ fontWeight: 700 }}>{fmt(card.income)}원</div>
<div className="subtle" style={{ color: card.has_project ? undefined : "var(--muted-strong)" }}>실제 매출</div>
<div style={{ fontWeight: 700, color: card.has_project ? "var(--ink)" : "var(--muted-strong)" }}>{fmt(card.income)}원</div>
</div>
<div>
<div className="subtle">실제 매입</div>
<div style={{ fontWeight: 700 }}>{fmt(card.expense)}원</div>
<div className="subtle" style={{ color: card.has_project ? undefined : "var(--muted-strong)" }}>실제 매입</div>
<div style={{ fontWeight: 700, color: card.has_project ? "var(--ink)" : "var(--muted-strong)" }}>{fmt(card.expense)}원</div>
</div>
</div>
{card.role !== "시공" && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 8, borderTop: "1px solid var(--line)", paddingTop: 8 }}>
<div className="lifecycle-flow-amount-row" style={{ borderTop: "1px solid var(--line)", paddingTop: 8, minHeight: 54 }}>
<div>
<div className="subtle">반영 매출</div>
<div style={{ fontWeight: 800, color: "var(--good)" }}>{fmt(card.reflected_income)}원</div>
<div className="subtle" style={{ color: card.has_project ? undefined : "var(--muted-strong)" }}>반영 매출</div>
<div style={{ fontWeight: 800, color: card.has_project ? "var(--good)" : "var(--muted-strong)" }}>{fmt(card.reflected_income)}원</div>
</div>
<div>
<div className="subtle">반영 매입</div>
<div style={{ fontWeight: 800 }}>{fmt(card.reflected_expense)}원</div>
<div className="subtle" style={{ color: card.has_project ? undefined : "var(--muted-strong)" }}>반영 매입</div>
<div style={{ fontWeight: 800, color: card.has_project ? "var(--ink)" : "var(--muted-strong)" }}>{fmt(card.reflected_expense)}원</div>
</div>
</div>
)}
@@ -5105,7 +5183,7 @@
<div style={{ fontSize: 18, fontWeight: 700 }}>프로젝트 생애주기 원가</div>
<div className="subtle" style={{ marginTop: 6 }}>현재 시공 프로젝트를 포함해 연결된 전체 비용을 시공비, 인건비, 관리비로 나눠 봅니다.</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(100px, 1fr))", gap: 14, minWidth: 360 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(120px, 1fr))", gap: 24, minWidth: 560 }}>
<div>
<div className="subtle">총 입금</div>
<div className="summary-value" style={{ fontSize: 20 }}>{fmt(detail.lifecycle_cost.summary?.income_supply || 0)}원</div>
@@ -5120,19 +5198,29 @@
{fmt(detail.lifecycle_cost.summary?.profit_supply || 0)}원
</div>
</div>
<div>
<div className="subtle">수익률</div>
<div className="summary-value" style={{ fontSize: 20, color: lifecycleMarginRate < 0 ? "#b42318" : "var(--good)" }}>
{lifecycleMarginRate.toFixed(1)}%
</div>
</div>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 12, marginTop: 14 }}>
{(detail.lifecycle_cost.breakdown || []).map((item) => (
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 0, marginTop: 14, borderTop: "1px solid var(--line)" }}>
{(detail.lifecycle_cost.breakdown || []).map((item, index, arr) => (
<div
key={`lifecycle-breakdown-${item.label}`}
className="lifecycle-breakdown-trigger"
role="button"
tabIndex={0}
title={`${item.label} 상세 보기`}
onClick={() => {
window.__lifecycleBreakdownClicked = item.label || "";
setLifecycleBreakdownModal({
label: item.label || "",
expense_supply: Number(item.expense_supply || 0),
direct_expense_supply: Number(item.direct_expense_supply || 0),
shared_expense_supply: Number(item.shared_expense_supply || 0),
projects: Array.isArray(item.projects) ? item.projects.map((project) => ({ ...project })) : [],
accounts: Array.isArray(item.accounts) ? item.accounts.map((account) => ({ ...account })) : [],
opened_at: Date.now(),
@@ -5145,16 +5233,19 @@
setLifecycleBreakdownModal({
label: item.label || "",
expense_supply: Number(item.expense_supply || 0),
direct_expense_supply: Number(item.direct_expense_supply || 0),
shared_expense_supply: Number(item.shared_expense_supply || 0),
projects: Array.isArray(item.projects) ? item.projects.map((project) => ({ ...project })) : [],
accounts: Array.isArray(item.accounts) ? item.accounts.map((account) => ({ ...account })) : [],
opened_at: Date.now(),
});
}}
style={{
border: "1px solid var(--line)",
borderRadius: 16,
background: "white",
padding: "10px 14px 9px",
border: "none",
borderRight: index < arr.length - 1 ? "1px solid var(--line)" : "none",
borderRadius: 0,
background: "transparent",
padding: `10px 14px 9px ${index === 0 ? 0 : 14}px`,
display: "grid",
gap: 4,
textAlign: "left",
@@ -5165,6 +5256,12 @@
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.1 }}>
{fmt(item.expense_supply || 0)}원
</div>
{(item.label === "인건비" || item.label === "관리비") && (
<div className="subtle" style={{ marginTop: 2, fontSize: 12 }}>
직접분 {fmt(item.direct_expense_supply || 0)}원 · 공통배분분 {fmt(item.shared_expense_supply || 0)}원
</div>
)}
<div style={{ fontSize: 12, color: "var(--blue)", fontWeight: 700 }}>상세보기 &gt;</div>
</div>
))}
</div>
@@ -5182,6 +5279,14 @@
<div className="subtle">합계 지출</div>
<div className="summary-value" style={{ marginTop: 6 }}>{fmt(lifecycleBreakdownModal.expense_supply || 0)}원</div>
</div>
{(lifecycleBreakdownModal.label === "인건비" || lifecycleBreakdownModal.label === "관리비") && (
<div className="mini-card">
<div className="subtle">직접분 / 공통배분분</div>
<div className="summary-value" style={{ marginTop: 6, fontSize: 20 }}>
{fmt(lifecycleBreakdownModal.direct_expense_supply || 0)}원 / {fmt(lifecycleBreakdownModal.shared_expense_supply || 0)}원
</div>
</div>
)}
<div className="mini-card">
<div className="subtle">프로젝트 수</div>
<div className="summary-value" style={{ marginTop: 6 }}>{fmt((lifecycleBreakdownModal.projects || []).length)}개</div>
@@ -5251,10 +5356,11 @@
}}
style={{
textAlign: "left",
border: "1px solid var(--line)",
borderRadius: 14,
background: "white",
padding: "12px 14px",
border: "none",
borderBottom: "1px solid var(--line)",
borderRadius: 0,
background: "transparent",
padding: "10px 2px",
display: "grid",
gridTemplateColumns: "minmax(220px, 1fr) minmax(140px, 0.4fr)",
gap: 12,
@@ -5291,47 +5397,6 @@
{currentTab === "project" && (
<>
<section className="panel" style={{ padding: 20 }}>
<div className="section-heading">
<div>
<h3>거래내역</h3>
<p>프로젝트 원장에서 조회된 입금/출금 내역입니다.</p>
</div>
</div>
{(detail?.transactions || []).length ? (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>거래일</th>
<th>입/출금</th>
<th>계정</th>
<th>거래처</th>
<th>적요</th>
<th>공급가액</th>
</tr>
</thead>
<tbody>
{(detail?.transactions || []).map((row, index) => (
<tr key={`project-inline-transaction-${index}`}>
<td>{row.transaction_date || "-"}</td>
<td>{row.in_out || "-"}</td>
<td>
<div>{row.account_name || "-"}</div>
<div className="table-sub">{row.account_code || "-"}</div>
</td>
<td>{row.vendor_name || "-"}</td>
<td>{row.description || "-"}</td>
<td style={{ fontWeight: 700 }}>{fmt(row.supply_amount || 0)}원</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="empty-state">표시할 거래내역이 없습니다.</div>
)}
</section>
<section className="budget-split">
<div className="panel" style={{ padding: 20 }}>
<div style={{ fontSize: 18, fontWeight: 700 }}>집행률 / 공정률 그래프</div>