feat(manage): refine lifecycle flow UI and direct/shared cost breakdown
This commit is contained in:
@@ -49,6 +49,7 @@
|
|||||||
:root {
|
:root {
|
||||||
--ink: #0f1c2e;
|
--ink: #0f1c2e;
|
||||||
--muted: #66788f;
|
--muted: #66788f;
|
||||||
|
--muted-strong: #4b5d73;
|
||||||
--line: #d8e2ec;
|
--line: #d8e2ec;
|
||||||
--blue: #113f67;
|
--blue: #113f67;
|
||||||
--cyan: #1f7a8c;
|
--cyan: #1f7a8c;
|
||||||
@@ -1182,6 +1183,50 @@
|
|||||||
color: var(--blue);
|
color: var(--blue);
|
||||||
text-decoration: underline;
|
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 {
|
.link-button {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -2071,38 +2116,59 @@
|
|||||||
const related = detail?.related_projects || [];
|
const related = detail?.related_projects || [];
|
||||||
const roleOrder = ["영업", "설계", "시공"];
|
const roleOrder = ["영업", "설계", "시공"];
|
||||||
const cards = [];
|
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) {
|
for (const role of roleOrder) {
|
||||||
let item = null;
|
let item = null;
|
||||||
if (role === "시공") {
|
if (role === "시공") {
|
||||||
item = related.find((row) => (row.project_code || "") === (selectedProjectCode || "")) || null;
|
item = related.find((row) => (row.project_code || "") === selectedSummaryCode) || null;
|
||||||
}
|
}
|
||||||
if (!item) {
|
if (!item) {
|
||||||
item = related.find((row) => (row.project_type || "") === role) || null;
|
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 === "설계"
|
const ratio = role === "영업" || role === "설계"
|
||||||
? (lifecycleRatioMap[item.project_code] ?? 1)
|
? (item?.project_code ? (lifecycleRatioMap[item.project_code] ?? 1) : 1)
|
||||||
: 1;
|
: 1;
|
||||||
const income = Number(item.income_supply || 0);
|
const income = Number(item?.income_supply || 0);
|
||||||
const expense = Number(item.expense_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({
|
cards.push({
|
||||||
role,
|
role,
|
||||||
project_code: item.project_code || "",
|
has_project: !!item?.project_code,
|
||||||
project_name: item.project_name || "",
|
project_code: item?.project_code || "",
|
||||||
note: item.note || "",
|
project_name: item?.project_name || "",
|
||||||
|
note: item?.note || "",
|
||||||
income,
|
income,
|
||||||
expense,
|
expense,
|
||||||
reflected_income: income * ratio,
|
reflected_income: income * ratio,
|
||||||
reflected_expense: expense * ratio,
|
reflected_expense: expense * ratio,
|
||||||
numerator: Number((detail?.lifecycle_cost?.rows || []).find((row) => row.project_code === item.project_code)?.allocation_numerator || 1),
|
numerator: Number(rowAllocation?.allocation_numerator || 1),
|
||||||
denominator: Number((detail?.lifecycle_cost?.rows || []).find((row) => row.project_code === item.project_code)?.allocation_denominator || 1),
|
denominator: Number(rowAllocation?.allocation_denominator || 1),
|
||||||
|
progress_rate: role === "시공"
|
||||||
|
? selectedProgressRate
|
||||||
|
: Number(item?.progress_rate || 0),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return cards;
|
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;
|
const visibleProjectList = isLifecycleTab ? filteredLifecycleProjects : filteredProjects;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -3473,6 +3539,8 @@
|
|||||||
setLifecycleBreakdownModal({
|
setLifecycleBreakdownModal({
|
||||||
label: refreshed.label || "",
|
label: refreshed.label || "",
|
||||||
expense_supply: Number(refreshed.expense_supply || 0),
|
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 })) : [],
|
projects: Array.isArray(refreshed.projects) ? refreshed.projects.map((project) => ({ ...project })) : [],
|
||||||
accounts: Array.isArray(refreshed.accounts) ? refreshed.accounts.map((account) => ({ ...account })) : [],
|
accounts: Array.isArray(refreshed.accounts) ? refreshed.accounts.map((account) => ({ ...account })) : [],
|
||||||
opened_at: Date.now(),
|
opened_at: Date.now(),
|
||||||
@@ -3518,6 +3586,8 @@
|
|||||||
setLifecycleBreakdownModal({
|
setLifecycleBreakdownModal({
|
||||||
label: refreshed.label || "",
|
label: refreshed.label || "",
|
||||||
expense_supply: Number(refreshed.expense_supply || 0),
|
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 })) : [],
|
projects: Array.isArray(refreshed.projects) ? refreshed.projects.map((project) => ({ ...project })) : [],
|
||||||
accounts: Array.isArray(refreshed.accounts) ? refreshed.accounts.map((account) => ({ ...account })) : [],
|
accounts: Array.isArray(refreshed.accounts) ? refreshed.accounts.map((account) => ({ ...account })) : [],
|
||||||
opened_at: Date.now(),
|
opened_at: Date.now(),
|
||||||
@@ -5016,17 +5086,17 @@
|
|||||||
|
|
||||||
{currentTab === "lifecycle" && (detail?.lifecycle_cost?.rows || []).length > 0 && (
|
{currentTab === "lifecycle" && (detail?.lifecycle_cost?.rows || []).length > 0 && (
|
||||||
<section className="panel" style={{ padding: 20 }}>
|
<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 style={{ fontSize: 16, fontWeight: 700 }}>관련 프로젝트 흐름</div>
|
||||||
<div className="subtle" style={{ marginTop: 6 }}>영업/설계 카드를 누르면 배분 비율(해당프로젝트/총프로젝트)을 입력할 수 있습니다.</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) => (
|
{lifecycleFlowCards.map((card) => (
|
||||||
<div
|
<div
|
||||||
key={`lifecycle-flow-${card.role}-${card.project_code}`}
|
key={`lifecycle-flow-${card.role}-${card.project_code}`}
|
||||||
role={card.role !== "시공" ? "button" : undefined}
|
role={card.role !== "시공" && card.has_project ? "button" : undefined}
|
||||||
tabIndex={card.role !== "시공" ? 0 : undefined}
|
tabIndex={card.role !== "시공" && card.has_project ? 0 : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (card.role === "시공") return;
|
if (card.role === "시공" || !card.has_project) return;
|
||||||
openLifecycleAllocationModal({
|
openLifecycleAllocationModal({
|
||||||
project_code: card.project_code,
|
project_code: card.project_code,
|
||||||
project_name: card.project_name,
|
project_name: card.project_name,
|
||||||
@@ -5036,7 +5106,7 @@
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (card.role === "시공") return;
|
if (card.role === "시공" || !card.has_project) return;
|
||||||
if (event.key !== "Enter" && event.key !== " ") return;
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openLifecycleAllocationModal({
|
openLifecycleAllocationModal({
|
||||||
@@ -5048,48 +5118,56 @@
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
border: "1px solid var(--line)",
|
border: "none",
|
||||||
borderRadius: 14,
|
borderRight: card.role !== "시공" ? "1px solid var(--line)" : "none",
|
||||||
background: "white",
|
borderRadius: 0,
|
||||||
padding: "12px 14px",
|
background: card.has_project ? "transparent" : "rgba(15, 28, 46, 0.03)",
|
||||||
|
padding: `12px 14px 10px ${card.role === "영업" ? 0 : 14}px`,
|
||||||
display: "grid",
|
display: "grid",
|
||||||
|
alignContent: "start",
|
||||||
gap: 8,
|
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>
|
<div style={{ fontSize: 14, fontWeight: 800 }}>{card.role}</div>
|
||||||
{card.role !== "시공" && (
|
{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 === "시공" && (
|
{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>
|
||||||
<div style={{ fontWeight: 800 }}>{card.project_code}</div>
|
<div className="lifecycle-flow-project-block">
|
||||||
<div style={{ fontWeight: 700 }}>{card.project_name || "(이름없음)"}</div>
|
<div className="lifecycle-flow-project-code" style={{ color: card.has_project ? "var(--ink)" : "var(--muted-strong)" }}>
|
||||||
{!!card.note && (
|
{card.project_code || "-"}
|
||||||
<div className="subtle" style={{ whiteSpace: "pre-wrap" }}>{card.note}</div>
|
</div>
|
||||||
)}
|
<div className="lifecycle-flow-project-name" style={{ color: card.has_project ? "var(--ink)" : "var(--muted-strong)" }}>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 8, marginTop: 2 }}>
|
{card.project_name || "연결 프로젝트 없음"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lifecycle-flow-amount-row" style={{ marginTop: 2 }}>
|
||||||
<div>
|
<div>
|
||||||
<div className="subtle">실제 매출</div>
|
<div className="subtle" style={{ color: card.has_project ? undefined : "var(--muted-strong)" }}>실제 매출</div>
|
||||||
<div style={{ fontWeight: 700 }}>{fmt(card.income)}원</div>
|
<div style={{ fontWeight: 700, color: card.has_project ? "var(--ink)" : "var(--muted-strong)" }}>{fmt(card.income)}원</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="subtle">실제 매입</div>
|
<div className="subtle" style={{ color: card.has_project ? undefined : "var(--muted-strong)" }}>실제 매입</div>
|
||||||
<div style={{ fontWeight: 700 }}>{fmt(card.expense)}원</div>
|
<div style={{ fontWeight: 700, color: card.has_project ? "var(--ink)" : "var(--muted-strong)" }}>{fmt(card.expense)}원</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{card.role !== "시공" && (
|
{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>
|
||||||
<div className="subtle">반영 매출</div>
|
<div className="subtle" style={{ color: card.has_project ? undefined : "var(--muted-strong)" }}>반영 매출</div>
|
||||||
<div style={{ fontWeight: 800, color: "var(--good)" }}>{fmt(card.reflected_income)}원</div>
|
<div style={{ fontWeight: 800, color: card.has_project ? "var(--good)" : "var(--muted-strong)" }}>{fmt(card.reflected_income)}원</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="subtle">반영 매입</div>
|
<div className="subtle" style={{ color: card.has_project ? undefined : "var(--muted-strong)" }}>반영 매입</div>
|
||||||
<div style={{ fontWeight: 800 }}>{fmt(card.reflected_expense)}원</div>
|
<div style={{ fontWeight: 800, color: card.has_project ? "var(--ink)" : "var(--muted-strong)" }}>{fmt(card.reflected_expense)}원</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -5105,7 +5183,7 @@
|
|||||||
<div style={{ fontSize: 18, fontWeight: 700 }}>프로젝트 생애주기 원가</div>
|
<div style={{ fontSize: 18, fontWeight: 700 }}>프로젝트 생애주기 원가</div>
|
||||||
<div className="subtle" style={{ marginTop: 6 }}>현재 시공 프로젝트를 포함해 연결된 전체 비용을 시공비, 인건비, 관리비로 나눠 봅니다.</div>
|
<div className="subtle" style={{ marginTop: 6 }}>현재 시공 프로젝트를 포함해 연결된 전체 비용을 시공비, 인건비, 관리비로 나눠 봅니다.</div>
|
||||||
</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>
|
||||||
<div className="subtle">총 입금</div>
|
<div className="subtle">총 입금</div>
|
||||||
<div className="summary-value" style={{ fontSize: 20 }}>{fmt(detail.lifecycle_cost.summary?.income_supply || 0)}원</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)}원
|
{fmt(detail.lifecycle_cost.summary?.profit_supply || 0)}원
|
||||||
</div>
|
</div>
|
||||||
</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 }}>
|
</div>
|
||||||
{(detail.lifecycle_cost.breakdown || []).map((item) => (
|
</div>
|
||||||
|
<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
|
<div
|
||||||
key={`lifecycle-breakdown-${item.label}`}
|
key={`lifecycle-breakdown-${item.label}`}
|
||||||
|
className="lifecycle-breakdown-trigger"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
title={`${item.label} 상세 보기`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.__lifecycleBreakdownClicked = item.label || "";
|
window.__lifecycleBreakdownClicked = item.label || "";
|
||||||
setLifecycleBreakdownModal({
|
setLifecycleBreakdownModal({
|
||||||
label: item.label || "",
|
label: item.label || "",
|
||||||
expense_supply: Number(item.expense_supply || 0),
|
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 })) : [],
|
projects: Array.isArray(item.projects) ? item.projects.map((project) => ({ ...project })) : [],
|
||||||
accounts: Array.isArray(item.accounts) ? item.accounts.map((account) => ({ ...account })) : [],
|
accounts: Array.isArray(item.accounts) ? item.accounts.map((account) => ({ ...account })) : [],
|
||||||
opened_at: Date.now(),
|
opened_at: Date.now(),
|
||||||
@@ -5145,16 +5233,19 @@
|
|||||||
setLifecycleBreakdownModal({
|
setLifecycleBreakdownModal({
|
||||||
label: item.label || "",
|
label: item.label || "",
|
||||||
expense_supply: Number(item.expense_supply || 0),
|
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 })) : [],
|
projects: Array.isArray(item.projects) ? item.projects.map((project) => ({ ...project })) : [],
|
||||||
accounts: Array.isArray(item.accounts) ? item.accounts.map((account) => ({ ...account })) : [],
|
accounts: Array.isArray(item.accounts) ? item.accounts.map((account) => ({ ...account })) : [],
|
||||||
opened_at: Date.now(),
|
opened_at: Date.now(),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
border: "1px solid var(--line)",
|
border: "none",
|
||||||
borderRadius: 16,
|
borderRight: index < arr.length - 1 ? "1px solid var(--line)" : "none",
|
||||||
background: "white",
|
borderRadius: 0,
|
||||||
padding: "10px 14px 9px",
|
background: "transparent",
|
||||||
|
padding: `10px 14px 9px ${index === 0 ? 0 : 14}px`,
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gap: 4,
|
gap: 4,
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
@@ -5165,6 +5256,12 @@
|
|||||||
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.1 }}>
|
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.1 }}>
|
||||||
{fmt(item.expense_supply || 0)}원
|
{fmt(item.expense_supply || 0)}원
|
||||||
</div>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -5182,6 +5279,14 @@
|
|||||||
<div className="subtle">합계 지출</div>
|
<div className="subtle">합계 지출</div>
|
||||||
<div className="summary-value" style={{ marginTop: 6 }}>{fmt(lifecycleBreakdownModal.expense_supply || 0)}원</div>
|
<div className="summary-value" style={{ marginTop: 6 }}>{fmt(lifecycleBreakdownModal.expense_supply || 0)}원</div>
|
||||||
</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="mini-card">
|
||||||
<div className="subtle">프로젝트 수</div>
|
<div className="subtle">프로젝트 수</div>
|
||||||
<div className="summary-value" style={{ marginTop: 6 }}>{fmt((lifecycleBreakdownModal.projects || []).length)}개</div>
|
<div className="summary-value" style={{ marginTop: 6 }}>{fmt((lifecycleBreakdownModal.projects || []).length)}개</div>
|
||||||
@@ -5251,10 +5356,11 @@
|
|||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
border: "1px solid var(--line)",
|
border: "none",
|
||||||
borderRadius: 14,
|
borderBottom: "1px solid var(--line)",
|
||||||
background: "white",
|
borderRadius: 0,
|
||||||
padding: "12px 14px",
|
background: "transparent",
|
||||||
|
padding: "10px 2px",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "minmax(220px, 1fr) minmax(140px, 0.4fr)",
|
gridTemplateColumns: "minmax(220px, 1fr) minmax(140px, 0.4fr)",
|
||||||
gap: 12,
|
gap: 12,
|
||||||
@@ -5291,47 +5397,6 @@
|
|||||||
|
|
||||||
{currentTab === "project" && (
|
{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">
|
<section className="budget-split">
|
||||||
<div className="panel" style={{ padding: 20 }}>
|
<div className="panel" style={{ padding: 20 }}>
|
||||||
<div style={{ fontSize: 18, fontWeight: 700 }}>집행률 / 공정률 그래프</div>
|
<div style={{ fontSize: 18, fontWeight: 700 }}>집행률 / 공정률 그래프</div>
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -12,10 +12,18 @@ PTC 실행 원장 기반의 사내 프로젝트 관리/원가 분석 웹앱입
|
|||||||
- 프로젝트 마스터 관리
|
- 프로젝트 마스터 관리
|
||||||
- 프로젝트명/구분/공법/기간/메모 수정
|
- 프로젝트명/구분/공법/기간/메모 수정
|
||||||
- 프로젝트 간 연관 코드 관리
|
- 프로젝트 간 연관 코드 관리
|
||||||
|
- 프로젝트 관리 탭에서는 거래내역 표를 제거하고, 거래는 `거래내역확인` 탭에서만 조회
|
||||||
- 프로젝트 생애주기 원가
|
- 프로젝트 생애주기 원가
|
||||||
- 연관 프로젝트(영업/설계/시공) 흐름 카드 조회
|
- 연관 프로젝트(영업/설계/시공) 흐름을 3열 고정 레이아웃으로 조회
|
||||||
- 영업/설계 카드 클릭 시 배분 팝업에서 `해당프로젝트/총프로젝트` 저장
|
- 영업/설계 카드 클릭 시 배분 팝업에서 `해당프로젝트/총프로젝트` 저장
|
||||||
- 배분값 저장/삭제 후 반영 매출 자동 재계산
|
- 배분값 저장/삭제 후 반영 매출 자동 재계산
|
||||||
|
- 시공 컬럼에 공정률 표시(숫자 강조)
|
||||||
|
- 영업/설계 연결 프로젝트가 없을 때 톤다운된 비어있음 상태 표시
|
||||||
|
- 계정별 금액/항목 목록을 박스형 카드가 아닌 라인형 리스트로 표시
|
||||||
|
- 프로젝트 생애주기 원가 분해
|
||||||
|
- `시공비/인건비/관리비` 상세 모달 제공
|
||||||
|
- 인건비/관리비에 `직접분/공통배분분` 분리 표시
|
||||||
|
- 현재는 연결 프로젝트 비용을 직접분으로 처리(공통배분분은 향후 공통배분 기능 추가 시 반영)
|
||||||
- 배분 로직
|
- 배분 로직
|
||||||
- 예: 설계 프로젝트에 `1/3` 저장 시, 생애주기 화면 반영금액은 해당 프로젝트 금액의 `1/3`
|
- 예: 설계 프로젝트에 `1/3` 저장 시, 생애주기 화면 반영금액은 해당 프로젝트 금액의 `1/3`
|
||||||
- 저장은 `project_lifecycle_allocations` 테이블에 영구 반영
|
- 저장은 `project_lifecycle_allocations` 테이블에 영구 반영
|
||||||
@@ -119,6 +127,22 @@ python3 server/ptc_api_server.py
|
|||||||
|
|
||||||
저장 데이터는 `project_lifecycle_allocations` 테이블에 유지되며, 페이지 재진입 후에도 반영됩니다.
|
저장 데이터는 `project_lifecycle_allocations` 테이블에 유지되며, 페이지 재진입 후에도 반영됩니다.
|
||||||
|
|
||||||
|
### 7.3 관련 프로젝트 흐름(영업/설계/시공)
|
||||||
|
|
||||||
|
- 컬럼은 항상 `영업/설계/시공` 3개를 고정 표시합니다.
|
||||||
|
- 영업/설계가 없으면 `연결 프로젝트 없음`을 톤다운 텍스트로 보여줍니다.
|
||||||
|
- 시공 컬럼은 현재 프로젝트 기준으로 표시되며, 공정률(`x.x%`)이 헤더에 노출됩니다.
|
||||||
|
- 영업/설계만 배분 팝업 클릭 대상이고, 시공은 읽기 전용입니다.
|
||||||
|
|
||||||
|
### 7.4 프로젝트 생애주기 원가(분해 기준)
|
||||||
|
|
||||||
|
- 상단 요약: `총 입금 / 총 지출 / 총 수익 / 수익률`.
|
||||||
|
- 하단 분해: `시공비 / 인건비 / 관리비` 클릭 시 상세 모달.
|
||||||
|
- 인건비/관리비 상세는 `직접분 / 공통배분분`을 함께 보여줍니다.
|
||||||
|
- 현재 구현 기준:
|
||||||
|
- 연결 프로젝트에 귀속된 비용은 직접분으로 집계
|
||||||
|
- 공통배분분은 0원(향후 공통비 배분 로직 추가 예정)
|
||||||
|
|
||||||
## 8. 프로젝트 화면 숨김 계정 정책
|
## 8. 프로젝트 화면 숨김 계정 정책
|
||||||
|
|
||||||
프로젝트/생애주기 관점에서는 특정 계정을 집계에서 완전히 제외합니다.
|
프로젝트/생애주기 관점에서는 특정 계정을 집계에서 완전히 제외합니다.
|
||||||
@@ -163,6 +187,8 @@ python3 server/ptc_api_server.py
|
|||||||
- `GET /api/management-overview-accounts` : 관리 화면 계정집계
|
- `GET /api/management-overview-accounts` : 관리 화면 계정집계
|
||||||
- `GET /api/transactions` : 원장 행 미리보기
|
- `GET /api/transactions` : 원장 행 미리보기
|
||||||
|
|
||||||
|
`GET /api/project-detail` 응답의 `related_projects` 항목에는 시공 공정률(`progress_rate`)이 포함됩니다.
|
||||||
|
|
||||||
### 10.2 POST
|
### 10.2 POST
|
||||||
|
|
||||||
- `POST /api/project-master/upsert` : 프로젝트 마스터 저장
|
- `POST /api/project-master/upsert` : 프로젝트 마스터 저장
|
||||||
@@ -208,6 +234,11 @@ python3 server/ptc_api_server.py
|
|||||||
- 프로젝트 화면에서는 숨김 계정 정책이 적용됩니다.
|
- 프로젝트 화면에서는 숨김 계정 정책이 적용됩니다.
|
||||||
- `기타 수지/자산` 계정은 의도적으로 상세/집계에서 제외됩니다.
|
- `기타 수지/자산` 계정은 의도적으로 상세/집계에서 제외됩니다.
|
||||||
|
|
||||||
|
### 12.4 코드 수정 후 화면이 이전 동작으로 보일 때
|
||||||
|
|
||||||
|
- 서버 프로세스가 이전 코드를 계속 실행 중일 수 있습니다.
|
||||||
|
- `server/ptc_api_server.py` 수정 후에는 서버를 재시작해 최신 로직(배분/공정률/직접분-공통배분분 집계)이 반영되었는지 확인하세요.
|
||||||
|
|
||||||
## 13. 개발 시 참고
|
## 13. 개발 시 참고
|
||||||
|
|
||||||
- 현재 서버는 단일 파일(`server/ptc_api_server.py`) 중심 구조입니다.
|
- 현재 서버는 단일 파일(`server/ptc_api_server.py`) 중심 구조입니다.
|
||||||
|
|||||||
@@ -879,6 +879,13 @@ def build_related_projects(conn: sqlite3.Connection, project_code: str, project_
|
|||||||
{excluded_clause}
|
{excluded_clause}
|
||||||
group by tx.project_code
|
group by tx.project_code
|
||||||
),
|
),
|
||||||
|
pile_progress_summary as (
|
||||||
|
select
|
||||||
|
project_code,
|
||||||
|
sum(coalesce(pile_count, 0)) as entry_pile_total
|
||||||
|
from project_pile_progress_entries
|
||||||
|
group by project_code
|
||||||
|
),
|
||||||
master_rows as (
|
master_rows as (
|
||||||
select
|
select
|
||||||
pm.project_code as project_code,
|
pm.project_code as project_code,
|
||||||
@@ -906,10 +913,17 @@ def build_related_projects(conn: sqlite3.Connection, project_code: str, project_
|
|||||||
coalesce(ps.expense_supply, 0) as expense_supply,
|
coalesce(ps.expense_supply, 0) as expense_supply,
|
||||||
coalesce(ps.txn_count, 0) as txn_count,
|
coalesce(ps.txn_count, 0) as txn_count,
|
||||||
coalesce(ps.min_date, '') as min_date,
|
coalesce(ps.min_date, '') as min_date,
|
||||||
coalesce(ps.max_date, '') as max_date
|
coalesce(ps.max_date, '') as max_date,
|
||||||
|
case
|
||||||
|
when coalesce(pp.contract_pile_count, 0) > 0 then
|
||||||
|
(coalesce(psum.entry_pile_total, pp.constructed_pile_count, 0) / pp.contract_pile_count) * 100
|
||||||
|
else coalesce(pp.progress_rate, 0)
|
||||||
|
end as progress_rate
|
||||||
from code_set cs
|
from code_set cs
|
||||||
left join project_summary ps on ps.project_code = cs.project_code
|
left join project_summary ps on ps.project_code = cs.project_code
|
||||||
left join master_rows mr on mr.project_code = cs.project_code
|
left join master_rows mr on mr.project_code = cs.project_code
|
||||||
|
left join project_progress pp on pp.project_code = cs.project_code
|
||||||
|
left join pile_progress_summary psum on psum.project_code = cs.project_code
|
||||||
order by cs.project_code
|
order by cs.project_code
|
||||||
""",
|
""",
|
||||||
[project_code, *excluded_values],
|
[project_code, *excluded_values],
|
||||||
@@ -949,6 +963,7 @@ def build_related_projects(conn: sqlite3.Connection, project_code: str, project_
|
|||||||
"txn_count": int(row_dict.get("txn_count") or 0),
|
"txn_count": int(row_dict.get("txn_count") or 0),
|
||||||
"min_date": row_dict.get("min_date") or "",
|
"min_date": row_dict.get("min_date") or "",
|
||||||
"max_date": row_dict.get("max_date") or "",
|
"max_date": row_dict.get("max_date") or "",
|
||||||
|
"progress_rate": float(row_dict.get("progress_rate") or 0),
|
||||||
"is_current": code == project_code,
|
"is_current": code == project_code,
|
||||||
}
|
}
|
||||||
items.append(item)
|
items.append(item)
|
||||||
@@ -1135,7 +1150,11 @@ def build_project_lifecycle_cost(
|
|||||||
total_income = sum(float(item.get("income_supply") or 0) for item in rows_with_allocation)
|
total_income = sum(float(item.get("income_supply") or 0) for item in rows_with_allocation)
|
||||||
total_expense = sum(float(item.get("adjusted_expense_supply") or 0) for item in rows_with_allocation)
|
total_expense = sum(float(item.get("adjusted_expense_supply") or 0) for item in rows_with_allocation)
|
||||||
project_codes = [item.get("project_code") for item in rows_with_allocation if item.get("project_code")]
|
project_codes = [item.get("project_code") for item in rows_with_allocation if item.get("project_code")]
|
||||||
breakdown_totals = {"시공비": 0.0, "인건비": 0.0, "관리비": 0.0}
|
breakdown_components = {
|
||||||
|
"시공비": {"direct": 0.0, "shared": 0.0, "total": 0.0},
|
||||||
|
"인건비": {"direct": 0.0, "shared": 0.0, "total": 0.0},
|
||||||
|
"관리비": {"direct": 0.0, "shared": 0.0, "total": 0.0},
|
||||||
|
}
|
||||||
breakdown_project_maps: dict[str, dict[str, dict]] = {
|
breakdown_project_maps: dict[str, dict[str, dict]] = {
|
||||||
"시공비": {},
|
"시공비": {},
|
||||||
"인건비": {},
|
"인건비": {},
|
||||||
@@ -1185,7 +1204,11 @@ def build_project_lifecycle_cost(
|
|||||||
allocation_ratio = float(project_info.get("allocation_ratio") or 1.0)
|
allocation_ratio = float(project_info.get("allocation_ratio") or 1.0)
|
||||||
expense_supply = float(row["expense_supply"] or 0) * allocation_ratio
|
expense_supply = float(row["expense_supply"] or 0) * allocation_ratio
|
||||||
|
|
||||||
breakdown_totals[bucket] += expense_supply
|
# Until common-cost allocation is introduced, lifecycle-linked costs
|
||||||
|
# are treated as direct costs for the target project.
|
||||||
|
source_component = "direct"
|
||||||
|
breakdown_components[bucket][source_component] += expense_supply
|
||||||
|
breakdown_components[bucket]["total"] += expense_supply
|
||||||
|
|
||||||
project_entry = breakdown_project_maps[bucket].setdefault(
|
project_entry = breakdown_project_maps[bucket].setdefault(
|
||||||
project_code,
|
project_code,
|
||||||
@@ -1198,9 +1221,12 @@ def build_project_lifecycle_cost(
|
|||||||
"allocation_numerator": numerator,
|
"allocation_numerator": numerator,
|
||||||
"allocation_denominator": denominator,
|
"allocation_denominator": denominator,
|
||||||
"allocation_ratio": allocation_ratio,
|
"allocation_ratio": allocation_ratio,
|
||||||
|
"direct_expense_supply": 0.0,
|
||||||
|
"shared_expense_supply": 0.0,
|
||||||
"expense_supply": 0.0,
|
"expense_supply": 0.0,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
project_entry[f"{source_component}_expense_supply"] += expense_supply
|
||||||
project_entry["expense_supply"] += expense_supply
|
project_entry["expense_supply"] += expense_supply
|
||||||
|
|
||||||
account_entry = breakdown_account_maps[bucket].setdefault(
|
account_entry = breakdown_account_maps[bucket].setdefault(
|
||||||
@@ -1208,15 +1234,20 @@ def build_project_lifecycle_cost(
|
|||||||
{
|
{
|
||||||
"account_code": account_code or "",
|
"account_code": account_code or "",
|
||||||
"account_name": (meta or {}).get("name") or (row["account_code"] or ""),
|
"account_name": (meta or {}).get("name") or (row["account_code"] or ""),
|
||||||
|
"direct_expense_supply": 0.0,
|
||||||
|
"shared_expense_supply": 0.0,
|
||||||
"expense_supply": 0.0,
|
"expense_supply": 0.0,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
account_entry[f"{source_component}_expense_supply"] += expense_supply
|
||||||
account_entry["expense_supply"] += expense_supply
|
account_entry["expense_supply"] += expense_supply
|
||||||
|
|
||||||
breakdown = [
|
breakdown = [
|
||||||
{
|
{
|
||||||
"label": "시공비",
|
"label": "시공비",
|
||||||
"expense_supply": breakdown_totals["시공비"],
|
"expense_supply": breakdown_components["시공비"]["total"],
|
||||||
|
"direct_expense_supply": breakdown_components["시공비"]["direct"],
|
||||||
|
"shared_expense_supply": breakdown_components["시공비"]["shared"],
|
||||||
"projects": sorted(
|
"projects": sorted(
|
||||||
breakdown_project_maps["시공비"].values(),
|
breakdown_project_maps["시공비"].values(),
|
||||||
key=lambda item: (-float(item.get("expense_supply") or 0), item.get("project_code") or ""),
|
key=lambda item: (-float(item.get("expense_supply") or 0), item.get("project_code") or ""),
|
||||||
@@ -1228,13 +1259,23 @@ def build_project_lifecycle_cost(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "인건비",
|
"label": "인건비",
|
||||||
"expense_supply": 0.0,
|
"expense_supply": breakdown_components["인건비"]["total"],
|
||||||
"projects": [],
|
"direct_expense_supply": breakdown_components["인건비"]["direct"],
|
||||||
"accounts": [],
|
"shared_expense_supply": breakdown_components["인건비"]["shared"],
|
||||||
|
"projects": sorted(
|
||||||
|
breakdown_project_maps["인건비"].values(),
|
||||||
|
key=lambda item: (-float(item.get("expense_supply") or 0), item.get("project_code") or ""),
|
||||||
|
),
|
||||||
|
"accounts": sorted(
|
||||||
|
breakdown_account_maps["인건비"].values(),
|
||||||
|
key=lambda item: (-float(item.get("expense_supply") or 0), item.get("account_code") or ""),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "관리비",
|
"label": "관리비",
|
||||||
"expense_supply": breakdown_totals["관리비"],
|
"expense_supply": breakdown_components["관리비"]["total"],
|
||||||
|
"direct_expense_supply": breakdown_components["관리비"]["direct"],
|
||||||
|
"shared_expense_supply": breakdown_components["관리비"]["shared"],
|
||||||
"projects": sorted(
|
"projects": sorted(
|
||||||
breakdown_project_maps["관리비"].values(),
|
breakdown_project_maps["관리비"].values(),
|
||||||
key=lambda item: (-float(item.get("expense_supply") or 0), item.get("project_code") or ""),
|
key=lambda item: (-float(item.get("expense_supply") or 0), item.get("project_code") or ""),
|
||||||
@@ -1263,10 +1304,10 @@ def classify_lifecycle_bucket(account_code: str, project_code: str, project_type
|
|||||||
meta = meta or ACCOUNT_MASTER.get(account_code)
|
meta = meta or ACCOUNT_MASTER.get(account_code)
|
||||||
if meta:
|
if meta:
|
||||||
if meta.get("category") == "인건비":
|
if meta.get("category") == "인건비":
|
||||||
return "시공비"
|
return "인건비"
|
||||||
if meta.get("project_type") == "시공":
|
if meta.get("project_type") == "관리":
|
||||||
return "시공비"
|
|
||||||
return "관리비"
|
return "관리비"
|
||||||
|
return "시공비"
|
||||||
if "-시공-" in project_code or project_type == "시공":
|
if "-시공-" in project_code or project_type == "시공":
|
||||||
return "시공비"
|
return "시공비"
|
||||||
return "관리비"
|
return "관리비"
|
||||||
|
|||||||
Reference in New Issue
Block a user