feat(manage): refine lifecycle flow UI and direct/shared cost breakdown
This commit is contained in:
1 parent
90042a003a
commit
868661426f
3 files changed
+242
-105
No files matched your search
@@ -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 }}>상세보기 ></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>
|
||||
|
||||
Reference in new issue
Block a user