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

This commit is contained in:
2026-04-23 14:31:41 +09:00
parent 90042a003a
commit 868661426f
3 changed files with 242 additions and 105 deletions

View File

@@ -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> </div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 12, marginTop: 14 }}> <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) => ( {(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 }}>상세보기 &gt;</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>

View File

@@ -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`) 중심 구조입니다.

View File

@@ -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 "관리비"