From 868661426f2e223cfaa3d39aacd5a6050c5c98cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=ED=98=9C=EC=9D=B8?= Date: Thu, 23 Apr 2026 14:31:41 +0900 Subject: [PATCH] feat(manage): refine lifecycle flow UI and direct/shared cost breakdown --- PTC/management_dashboard_preview.html | 249 ++++++++++++++++---------- README.md | 33 +++- server/ptc_api_server.py | 65 +++++-- 3 files changed, 242 insertions(+), 105 deletions(-) diff --git a/PTC/management_dashboard_preview.html b/PTC/management_dashboard_preview.html index 62922c9..e298a5b 100644 --- a/PTC/management_dashboard_preview.html +++ b/PTC/management_dashboard_preview.html @@ -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 && (
-
+
관련 프로젝트 흐름
영업/설계 카드를 누르면 배분 비율(해당프로젝트/총프로젝트)을 입력할 수 있습니다.
-
+
{lifecycleFlowCards.map((card) => (
{ - 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)", }} > -
+
{card.role}
{card.role !== "시공" && ( -
{fmt(card.numerator)} / {fmt(card.denominator)}
+
{fmt(card.numerator)} / {fmt(card.denominator)}
)} {card.role === "시공" && ( -
현재 프로젝트
+
+ {card.has_project ? <>공정률 {Number(card.progress_rate || 0).toFixed(1)}% : ""} +
)}
-
{card.project_code}
-
{card.project_name || "(이름없음)"}
- {!!card.note && ( -
{card.note}
- )} -
+
+
+ {card.project_code || "-"} +
+
+ {card.project_name || "연결 프로젝트 없음"} +
+
+
-
실제 매출
-
{fmt(card.income)}원
+
실제 매출
+
{fmt(card.income)}원
-
실제 매입
-
{fmt(card.expense)}원
+
실제 매입
+
{fmt(card.expense)}원
{card.role !== "시공" && ( -
+
-
반영 매출
-
{fmt(card.reflected_income)}원
+
반영 매출
+
{fmt(card.reflected_income)}원
-
반영 매입
-
{fmt(card.reflected_expense)}원
+
반영 매입
+
{fmt(card.reflected_expense)}원
)} @@ -5105,7 +5183,7 @@
프로젝트 생애주기 원가
현재 시공 프로젝트를 포함해 연결된 전체 비용을 시공비, 인건비, 관리비로 나눠 봅니다.
-
+
총 입금
{fmt(detail.lifecycle_cost.summary?.income_supply || 0)}원
@@ -5120,19 +5198,29 @@ {fmt(detail.lifecycle_cost.summary?.profit_supply || 0)}원
+
+
수익률
+
+ {lifecycleMarginRate.toFixed(1)}% +
+
-
- {(detail.lifecycle_cost.breakdown || []).map((item) => ( +
+ {(detail.lifecycle_cost.breakdown || []).map((item, index, arr) => (
{ 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 @@
{fmt(item.expense_supply || 0)}원
+ {(item.label === "인건비" || item.label === "관리비") && ( +
+ 직접분 {fmt(item.direct_expense_supply || 0)}원 · 공통배분분 {fmt(item.shared_expense_supply || 0)}원 +
+ )} +
상세보기 >
))}
@@ -5182,6 +5279,14 @@
합계 지출
{fmt(lifecycleBreakdownModal.expense_supply || 0)}원
+ {(lifecycleBreakdownModal.label === "인건비" || lifecycleBreakdownModal.label === "관리비") && ( +
+
직접분 / 공통배분분
+
+ {fmt(lifecycleBreakdownModal.direct_expense_supply || 0)}원 / {fmt(lifecycleBreakdownModal.shared_expense_supply || 0)}원 +
+
+ )}
프로젝트 수
{fmt((lifecycleBreakdownModal.projects || []).length)}개
@@ -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" && ( <> -
-
-
-

거래내역

-

프로젝트 원장에서 조회된 입금/출금 내역입니다.

-
-
- {(detail?.transactions || []).length ? ( -
- - - - - - - - - - - - - {(detail?.transactions || []).map((row, index) => ( - - - - - - - - - ))} - -
거래일입/출금계정거래처적요공급가액
{row.transaction_date || "-"}{row.in_out || "-"} -
{row.account_name || "-"}
-
{row.account_code || "-"}
-
{row.vendor_name || "-"}{row.description || "-"}{fmt(row.supply_amount || 0)}원
-
- ) : ( -
표시할 거래내역이 없습니다.
- )} -
집행률 / 공정률 그래프
diff --git a/README.md b/README.md index 44cb539..0b12814 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,18 @@ PTC 실행 원장 기반의 사내 프로젝트 관리/원가 분석 웹앱입 - 프로젝트 마스터 관리 - 프로젝트명/구분/공법/기간/메모 수정 - 프로젝트 간 연관 코드 관리 + - 프로젝트 관리 탭에서는 거래내역 표를 제거하고, 거래는 `거래내역확인` 탭에서만 조회 - 프로젝트 생애주기 원가 - - 연관 프로젝트(영업/설계/시공) 흐름 카드 조회 + - 연관 프로젝트(영업/설계/시공) 흐름을 3열 고정 레이아웃으로 조회 - 영업/설계 카드 클릭 시 배분 팝업에서 `해당프로젝트/총프로젝트` 저장 - 배분값 저장/삭제 후 반영 매출 자동 재계산 + - 시공 컬럼에 공정률 표시(숫자 강조) + - 영업/설계 연결 프로젝트가 없을 때 톤다운된 비어있음 상태 표시 + - 계정별 금액/항목 목록을 박스형 카드가 아닌 라인형 리스트로 표시 +- 프로젝트 생애주기 원가 분해 + - `시공비/인건비/관리비` 상세 모달 제공 + - 인건비/관리비에 `직접분/공통배분분` 분리 표시 + - 현재는 연결 프로젝트 비용을 직접분으로 처리(공통배분분은 향후 공통배분 기능 추가 시 반영) - 배분 로직 - 예: 설계 프로젝트에 `1/3` 저장 시, 생애주기 화면 반영금액은 해당 프로젝트 금액의 `1/3` - 저장은 `project_lifecycle_allocations` 테이블에 영구 반영 @@ -119,6 +127,22 @@ python3 server/ptc_api_server.py 저장 데이터는 `project_lifecycle_allocations` 테이블에 유지되며, 페이지 재진입 후에도 반영됩니다. +### 7.3 관련 프로젝트 흐름(영업/설계/시공) + +- 컬럼은 항상 `영업/설계/시공` 3개를 고정 표시합니다. +- 영업/설계가 없으면 `연결 프로젝트 없음`을 톤다운 텍스트로 보여줍니다. +- 시공 컬럼은 현재 프로젝트 기준으로 표시되며, 공정률(`x.x%`)이 헤더에 노출됩니다. +- 영업/설계만 배분 팝업 클릭 대상이고, 시공은 읽기 전용입니다. + +### 7.4 프로젝트 생애주기 원가(분해 기준) + +- 상단 요약: `총 입금 / 총 지출 / 총 수익 / 수익률`. +- 하단 분해: `시공비 / 인건비 / 관리비` 클릭 시 상세 모달. +- 인건비/관리비 상세는 `직접분 / 공통배분분`을 함께 보여줍니다. +- 현재 구현 기준: + - 연결 프로젝트에 귀속된 비용은 직접분으로 집계 + - 공통배분분은 0원(향후 공통비 배분 로직 추가 예정) + ## 8. 프로젝트 화면 숨김 계정 정책 프로젝트/생애주기 관점에서는 특정 계정을 집계에서 완전히 제외합니다. @@ -163,6 +187,8 @@ python3 server/ptc_api_server.py - `GET /api/management-overview-accounts` : 관리 화면 계정집계 - `GET /api/transactions` : 원장 행 미리보기 +`GET /api/project-detail` 응답의 `related_projects` 항목에는 시공 공정률(`progress_rate`)이 포함됩니다. + ### 10.2 POST - `POST /api/project-master/upsert` : 프로젝트 마스터 저장 @@ -208,6 +234,11 @@ python3 server/ptc_api_server.py - 프로젝트 화면에서는 숨김 계정 정책이 적용됩니다. - `기타 수지/자산` 계정은 의도적으로 상세/집계에서 제외됩니다. +### 12.4 코드 수정 후 화면이 이전 동작으로 보일 때 + +- 서버 프로세스가 이전 코드를 계속 실행 중일 수 있습니다. +- `server/ptc_api_server.py` 수정 후에는 서버를 재시작해 최신 로직(배분/공정률/직접분-공통배분분 집계)이 반영되었는지 확인하세요. + ## 13. 개발 시 참고 - 현재 서버는 단일 파일(`server/ptc_api_server.py`) 중심 구조입니다. diff --git a/server/ptc_api_server.py b/server/ptc_api_server.py index 487d594..a5a2782 100644 --- a/server/ptc_api_server.py +++ b/server/ptc_api_server.py @@ -879,6 +879,13 @@ def build_related_projects(conn: sqlite3.Connection, project_code: str, project_ {excluded_clause} 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 ( select 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.txn_count, 0) as txn_count, 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 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 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 """, [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), "min_date": row_dict.get("min_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, } 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_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")] - 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]] = { "시공비": {}, "인건비": {}, @@ -1185,7 +1204,11 @@ def build_project_lifecycle_cost( allocation_ratio = float(project_info.get("allocation_ratio") or 1.0) 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_code, @@ -1198,9 +1221,12 @@ def build_project_lifecycle_cost( "allocation_numerator": numerator, "allocation_denominator": denominator, "allocation_ratio": allocation_ratio, + "direct_expense_supply": 0.0, + "shared_expense_supply": 0.0, "expense_supply": 0.0, }, ) + project_entry[f"{source_component}_expense_supply"] += expense_supply project_entry["expense_supply"] += expense_supply account_entry = breakdown_account_maps[bucket].setdefault( @@ -1208,15 +1234,20 @@ def build_project_lifecycle_cost( { "account_code": 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, }, ) + account_entry[f"{source_component}_expense_supply"] += expense_supply account_entry["expense_supply"] += expense_supply breakdown = [ { "label": "시공비", - "expense_supply": breakdown_totals["시공비"], + "expense_supply": breakdown_components["시공비"]["total"], + "direct_expense_supply": breakdown_components["시공비"]["direct"], + "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 ""), @@ -1228,13 +1259,23 @@ def build_project_lifecycle_cost( }, { "label": "인건비", - "expense_supply": 0.0, - "projects": [], - "accounts": [], + "expense_supply": breakdown_components["인건비"]["total"], + "direct_expense_supply": breakdown_components["인건비"]["direct"], + "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": "관리비", - "expense_supply": breakdown_totals["관리비"], + "expense_supply": breakdown_components["관리비"]["total"], + "direct_expense_supply": breakdown_components["관리비"]["direct"], + "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 ""), @@ -1263,10 +1304,10 @@ def classify_lifecycle_bucket(account_code: str, project_code: str, project_type meta = meta or ACCOUNT_MASTER.get(account_code) if meta: if meta.get("category") == "인건비": - return "시공비" - if meta.get("project_type") == "시공": - return "시공비" - return "관리비" + return "인건비" + if meta.get("project_type") == "관리": + return "관리비" + return "시공비" if "-시공-" in project_code or project_type == "시공": return "시공비" return "관리비"