From 85f7c9c6b3755d3144366ff16ba3eaca97e47f9f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 01:30:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EC=9B=90=EA=B0=80=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=ED=99=94=EB=A9=B4=20=EC=83=88=20=ED=8B=80=20?= =?UTF-8?q?=E2=80=94=20=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD=EC=84=9C=C2=B7?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=C2=B7=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=EA=B7=BC=EA=B1=B0=20=ED=83=AD(=EC=8B=A4?= =?UTF-8?q?=EB=AC=B4=20=EC=84=9C=EC=8B=9D=20=C2=B7=20=EB=82=B4=EC=97=AD=20?= =?UTF-8?q?=E2=86=92=20=ED=98=B8=ED=91=9C=20=E2=86=92=20=EC=82=B0=EA=B7=BC?= =?UTF-8?q?=20=EB=93=A4=EC=96=B4=EA=B0=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 틀 B09_Estimation_UI_Shell: 탭 줄과 등록만 · 탭마다 파일 하나(계약 _Shell_Types) - 설계내역서: 실무 열(합계/노무/재료/경비 단가·금액) · 머리글 접기·레벨 고르개 · 줄 누르면 제 N 호표 · 단산 N 단추 · 미확정 빨간 테두리 - 일위대가·단가산출근거: 목록표(내역에 처음 쓰인 차례) + 본표 · 줄 누르면 하위 호표·산근·중기로 · 자취 눌러 되돌아감 · Q 식 글자 그대로 - 옛 탭 여섯(원가계산서·중기·관급사급·기초자료·설계서 구성·산출기초)은 옛 코드 그대로 이어 붙임 — 새 탭 파일이 서면 등록 한 줄씩 바꿈 - 본표 합계 줄 = 호표 성분 소계 원 미만 절사 값 · 비율 줄 금액도 0.1원 절사 표시 - 사전 ui_template_locale_b3 새 벌(b2 700줄 넘음) - 검증: ORCA 검증 프로젝트 — 본체 122,848,989 · 지장목제거 865·15,460,837 · 제 9 호표 합계 1,708 = 산근 8호표 합계 1,708 · 옛 탭 여섯 다 뜸 · 접기 53→51·레벨1 11줄 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- A00_Common/router.ts | 2 +- B09_Estimation/B09_Estimation_UI_Detail.ts | 142 +++++++++++ B09_Estimation/B09_Estimation_UI_Page.ts | 40 +-- B09_Estimation/B09_Estimation_UI_Sheet.ts | 175 +++++++++++++ B09_Estimation/B09_Estimation_UI_Shell.ts | 83 +++++++ B09_Estimation/B09_Estimation_UI_Store.ts | 149 +++++++++++ B09_Estimation/B09_Estimation_UI_Tab_Bill.ts | 233 ++++++++++++++++++ .../B09_Estimation_UI_Tab_Legacy.ts | 30 +++ .../B09_Estimation_UI_Tab_PriceBasis.ts | 76 ++++++ .../B09_Estimation_UI_Tab_UnitPrice.ts | 126 ++++++++++ .../B09_Estimation_UnitPrice_View.py | 20 +- ui_template/ui_template_locale.ts | 2 + ui_template/ui_template_locale_b3.ts | 60 +++++ 13 files changed, 1116 insertions(+), 22 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Detail.ts create mode 100644 B09_Estimation/B09_Estimation_UI_Sheet.ts create mode 100644 B09_Estimation/B09_Estimation_UI_Shell.ts create mode 100644 B09_Estimation/B09_Estimation_UI_Store.ts create mode 100644 B09_Estimation/B09_Estimation_UI_Tab_Bill.ts create mode 100644 B09_Estimation/B09_Estimation_UI_Tab_Legacy.ts create mode 100644 B09_Estimation/B09_Estimation_UI_Tab_PriceBasis.ts create mode 100644 B09_Estimation/B09_Estimation_UI_Tab_UnitPrice.ts create mode 100644 ui_template/ui_template_locale_b3.ts diff --git a/A00_Common/router.ts b/A00_Common/router.ts index 1adace4a..a3c56318 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -52,7 +52,7 @@ const routeTable: Partial Promise>> = { [ROUTES.B08_QUANTITY]: async () => (await import("../B08_Quantity/B08_Quantity_UI_Page")).renderB08Quantity, [ROUTES.B09_ESTIMATION]: async () => - (await import("../B09_Estimation/B09_Estimation_UI_Page")).renderB09Estimation, + (await import("../B09_Estimation/B09_Estimation_UI_Shell")).renderB09Estimation, [ROUTES.B10_PAYMENT]: async () => (await import("../B10_Payment/B10_Payment_UI_Page")).renderB10Payment, [ROUTES.B11_STATUS]: async () => diff --git a/B09_Estimation/B09_Estimation_UI_Detail.ts b/B09_Estimation/B09_Estimation_UI_Detail.ts new file mode 100644 index 00000000..d23c3b39 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Detail.ts @@ -0,0 +1,142 @@ +/* ============================================================================= + * B09_Estimation_UI_Detail.ts + * 호표 본표(일위대가표·단가산출근거·시간당 중기) 한 장 + **들어가기 자취** (PLAN 12장 1차) + * + * - ⭐ STmate 를 익숙하게 만드는 동작 = 타고 들어가기(브레인 판정). 내역 줄 → 제 N 호표 → + * 호표 안 줄(일위대가·단산·중기) → … 을 누를 때마다 자취가 쌓이고, 자취 글을 누르면 그 자리로 되돌아감. + * - 표 모양은 실무 `일위대가표`·`단가산출근거` 시트: 명칭·규격·수량·단위 · 합계/노무/재료/경비(단가·금액) · 비고. + * - ⚠ 합계 줄은 서버 값(호표 성분 소계 원 미만 절사) — 여기서 안 더함. + * ========================================================================== */ + +import type { B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { + L, + el, + hint, + linkButton, + moneyCells, + numberCell, + quantity, + sheetHead, + sheetTable, +} from "./B09_Estimation_UI_Sheet"; +import { loadDetail, type DetailDto, type DetailRowDto } from "./B09_Estimation_UI_Store"; + +interface Crumb { + tab: string; + code: string; + label: string; +} + +const crumbs: Crumb[] = []; +let drilling = false; + +/** 본표 안 줄을 눌러 들어갈 때 — 자취를 이어 쌓음. 목록·내역에서 들어가면 자취가 새로 시작. */ +export function drill(ctx: B09TabContext, tab: string, code: string): void { + drilling = true; + ctx.open(tab, code); +} + +function visit(tab: string, code: string, label: string): void { + if (!drilling) crumbs.length = 0; + drilling = false; + const at = crumbs.findIndex((crumb) => crumb.tab === tab && crumb.code === code); + if (at >= 0) crumbs.length = at; + crumbs.push({ tab, code, label }); +} + +function trail(ctx: B09TabContext): HTMLElement { + const box = el("div", "b09s-trail"); + crumbs.forEach((crumb, index) => { + if (index > 0) box.append(el("span", "b09s-hint", "›")); + if (index === crumbs.length - 1) { + box.append(el("span", "b09s-title", crumb.label)); + return; + } + box.append(linkButton(crumb.label, () => drill(ctx, crumb.tab, crumb.code))); + }); + return box; +} + +/** 줄이 가리키는 곳 — 단산(D)은 단가산출근거 탭, 일위대가·중기는 일위대가 탭. */ +function targetTab(row: DetailRowDto): string | null { + if (!row.drillable || !row.ref_code) return null; + return row.kind === "price_basis" ? "price_basis" : "unit_price"; +} + +function detailRow(row: DetailRowDto, ctx: B09TabContext): HTMLElement { + const tr = el("tr"); + const percent = row.unit === "%"; + const unit: [string, string, string, string] | null = percent + ? null + : [row.unit_total ?? "", row.unit_labor ?? "", row.unit_material ?? "", row.unit_expense ?? ""]; + tr.append( + el("td", "", row.name), + el("td", "", row.spec), + numberCell(quantity(row.quantity)), + el("td", "", row.unit), + ...moneyCells(unit, [row.total, row.labor, row.material, row.expense]), + ); + const note = el("td", "b09s-note"); + const source = row.source_label || row.source || ""; + if (source) note.append(el("span", "b09s-hint", `[${source}] `)); + if (row.note) note.append(el("span", "b09s-formula", row.note)); + tr.append(note); + const tab = targetTab(row); + if (tab && row.ref_code) { + const code = row.ref_code; + tr.classList.add("is-clickable"); + tr.title = L("B09_Sheet_Drill"); + tr.addEventListener("click", () => drill(ctx, tab, code)); + } + return tr; +} + +function drawDetail(ctx: B09TabContext, box: HTMLElement, detail: DetailDto, label: string): void { + const title = el( + "div", + "b09s-title", + `${label} ${detail.name}${detail.spec ? ` · ${detail.spec}` : ""}`, + ); + if (detail.unit) title.append(el("span", "b09s-hint", ` (${detail.unit})`)); + const { wrap, tbody } = sheetTable( + sheetHead([ + L("B09_Sheet_Col_Name"), + L("B09_Sheet_Col_Spec"), + L("B09_Sheet_Col_Quantity"), + L("B09_Sheet_Col_Unit"), + ]), + ); + for (const row of detail.rows) tbody.append(detailRow(row, ctx)); + const sum = el("tr", "is-sum"); + sum.append(el("td", "", L("B09_Sheet_Sum")), el("td"), el("td"), el("td")); + sum.append( + ...moneyCells(null, [detail.total, detail.labor, detail.material, detail.expense]), + el("td"), + ); + tbody.append(sum); + box.append(title, wrap); + if (detail.unattached_note) box.append(hint(detail.unattached_note.replace(/\*\*/g, ""), true)); + if (detail.known_gap_note) box.append(hint(detail.known_gap_note, true)); +} + +/** 본표 한 장을 `box` 에 — 자취를 쌓고 서버 본표를 받아 그림. */ +export function renderDetail( + ctx: B09TabContext, + box: HTMLElement, + tab: string, + code: string, + label: string, +): void { + if (!ctx.projectId) return; + visit(tab, code, label); + box.replaceChildren(trail(ctx), hint(L("B09_Sheet_Loading"))); + loadDetail(ctx.projectId, code) + .then((detail) => { + box.replaceChildren(trail(ctx)); + drawDetail(ctx, box, detail, label); + }) + .catch((error: Error) => { + box.replaceChildren(trail(ctx), hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); + }); +} diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 984a7b14..40a7f58a 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -15,7 +15,6 @@ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; -import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { attachProvenance, @@ -47,7 +46,6 @@ import { type PriceSourcesDto, } from "./B09_Estimation_UI_BaseData"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; -import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; function L(key: keyof typeof ui_locales): string { @@ -900,7 +898,15 @@ async function confirmEstimationStage(projectId: string): Promise { * 페이지 진입점 * -------------------------------------------------------------------------- */ -export async function renderB09Estimation(root: HTMLElement): Promise { +/** 옛 화면을 새 틀(`B09_Estimation_UI_Shell`) 안에 이어 붙이는 자리 — 옛 탭 줄은 숨고 틀이 `select` 로 고름. + * ⚠ 옛 탭을 새 탭 파일로 다 바꾸면 이 파일째 지움(PLAN 12장 · 브레인 판정). */ +export interface LegacyEstimation { + main: HTMLElement; + panel: HTMLElement; + select: (key: string) => void; +} + +export function createLegacyEstimation(root: HTMLElement): LegacyEstimation { injectStyles(); const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const form: CostFormState = { ...INITIAL_FORM }; @@ -1546,16 +1552,22 @@ export async function renderB09Estimation(root: HTMLElement): Promise { drawTabs(); drawBody(); - const layout = createWorkflowLayout({ - title: L("B09_Estimation_Title"), - steps: workflowSteps(), - activeStep: 6, - leftPanel: panel.root, - mainContent: main, - routes: WORKFLOW_STEP_ROUTES, - onStepClick: (stepIndex) => { - if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + return { + main, + panel: panel.root, + select: (key: string) => { + activeTab = key; + drawTabs(); + drawBody(); + // 관급·사급 표는 내역 응답에 실려 옴 — 옛 내역 탭을 안 거치므로 여기서 받음. + if (key === "supply" && !bill && projectId) { + void fetchBill(projectId) + .then((data) => { + bill = data; + drawBody(); + }) + .catch(() => showToast(L("B09_Estimation_Boq_Failed"), "error")); + } }, - }); - root.append(layout.root); + }; } diff --git a/B09_Estimation/B09_Estimation_UI_Sheet.ts b/B09_Estimation/B09_Estimation_UI_Sheet.ts new file mode 100644 index 00000000..c76f1cc1 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Sheet.ts @@ -0,0 +1,175 @@ +/* ============================================================================= + * B09_Estimation_UI_Sheet.ts + * B09 실무 서식 표 공용 — 내역서·일위대가표·단가산출근거가 같은 모양으로 섬 (PLAN 12장) + * + * - 표 모양은 STmate 출력 시트 그대로: 명칭·규격·수량·단위 · 합계/노무비/재료비/경비 (단가·금액) · 비고. + * - ⚠ 여기서 곱하거나 더하지 않음 — 서버가 실은 칸을 찍기만. 숫자 꼴(천 단위 쉼표)만 바꿈. + * - 가로 넘침은 표 칸 안에서만(`min-width:0` · `overflow-x:auto`) — 옛 화면 2026-09-08 실측 교훈. + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; + +export function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 금액 글 → 「1,234,567」 · 소수부가 있으면 한 자리(일위대가 금액란 0.1원). 숫자가 아니면 그대로. */ +export function won(value: string | null | undefined): string { + if (value === null || value === undefined || value === "") return ""; + const number = Number(value); + if (!Number.isFinite(number)) return String(value); + const fraction = Math.abs(number % 1) > 1e-9; + return number.toLocaleString("ko-KR", { + minimumFractionDigits: fraction ? 1 : 0, + maximumFractionDigits: fraction ? 1 : 0, + }); +} + +/** 수량 — 서버가 준 표시 자리(`digits`). 내역 줄인데 자리를 모르면 옛 화면처럼 둘째 자리, + * 호표 안 줄(`digits` 안 줌)은 품이 작아(0.0115 hr) 넷째 자리까지. */ +export function quantity(value: string | null | undefined, digits?: number | null): string { + if (value === null || value === undefined || value === "") return ""; + const number = Number(value); + if (!Number.isFinite(number)) return String(value); + if (digits === undefined) { + return number.toLocaleString("ko-KR", { maximumFractionDigits: 4 }); + } + if (digits === null) { + return number.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); + } + return number.toLocaleString("ko-KR", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} + +export function el( + tag: K, + className = "", + text = "", +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text) node.textContent = text; + return node; +} + +/** 실무 시트 머리 두 줄 — 앞 칸(명칭 따위)은 두 줄을 차지, 성분 넷은 「단가·금액」 두 칸. */ +export function sheetHead( + front: string[], + back: string[] = [L("B09_Sheet_Col_Note")], +): HTMLElement { + const thead = el("thead"); + const top = el("tr"); + const bottom = el("tr"); + for (const label of front) { + const th = el("th", "", label); + th.rowSpan = 2; + top.append(th); + } + for (const key of [ + "B09_Sheet_Col_Total", + "B09_Sheet_Col_Labor", + "B09_Sheet_Col_Material", + "B09_Sheet_Col_Expense", + ] as const) { + const th = el("th", "", L(key)); + th.colSpan = 2; + top.append(th); + bottom.append( + el("th", "", L("B09_Sheet_Col_UnitPrice")), + el("th", "", L("B09_Sheet_Col_Amount")), + ); + } + for (const label of back) { + const th = el("th", "", label); + th.rowSpan = 2; + top.append(th); + } + thead.append(top, bottom); + return thead; +} + +/** 숫자 칸 하나. */ +export function numberCell(text: string): HTMLTableCellElement { + return el("td", "b09s-num", text); +} + +/** 성분 넷 × (단가·금액) 여덟 칸 — 합계·노무·재료·경비 차례(실무 시트). */ +export function moneyCells( + unit: [string, string, string, string] | null, + amount: [string, string, string, string] | null, +): HTMLTableCellElement[] { + const cells: HTMLTableCellElement[] = []; + for (let i = 0; i < 4; i += 1) { + cells.push(numberCell(unit ? won(unit[i]) : ""), numberCell(amount ? won(amount[i]) : "")); + } + return cells; +} + +/** 표 한 장 — 가로로 넘치면 이 칸 안에서만 밀림. */ +export function sheetTable(head: HTMLElement): { wrap: HTMLElement; tbody: HTMLElement } { + const wrap = el("div", "b09s-wrap"); + const table = el("table", "b09s-table"); + const tbody = el("tbody"); + table.append(head, tbody); + wrap.append(table); + return { wrap, tbody }; +} + +/** 누르면 들어가는 글 — 「제 3 호표」·「단산 2」. */ +export function linkButton(text: string, onClick: () => void): HTMLButtonElement { + const button = el("button", "b09s-link", text); + button.type = "button"; + button.addEventListener("click", (event) => { + event.stopPropagation(); + onClick(); + }); + return button; +} + +/** 수동 단가 표시 — 어느 화면에서든 같게(빨간 테두리 + 「미확정 N건」). */ +export function unconfirmedBadge(count: number): HTMLElement { + return el("span", "b09s-badge", `${L("B09_Sheet_Unconfirmed")} ${count}${L("B09_Sheet_Count")}`); +} + +export function hint(text: string, warn = false): HTMLElement { + return el("div", warn ? "b09s-hint b09s-hint--warn" : "b09s-hint", text); +} + +export function injectSheetStyles(): void { + if (document.getElementById("b09-sheet-styles")) return; + const style = document.createElement("style"); + style.id = "b09-sheet-styles"; + style.textContent = ` + .b09s-page { display:flex; flex-direction:column; gap:8px; min-width:0; min-height:0; flex:1; } + .b09s-tabs { display:flex; flex-wrap:wrap; gap:4px; border-bottom:1px solid var(--ui-border, #d0d4dc); padding-bottom:4px; } + .b09s-tab { border:1px solid var(--ui-border, #d0d4dc); background:var(--ui-surface, #fff); padding:4px 10px; border-radius:6px 6px 0 0; cursor:pointer; font-size:13px; } + .b09s-tab.is-active { background:var(--ui-accent, #2f6fed); color:#fff; border-color:var(--ui-accent, #2f6fed); } + .b09s-body { display:flex; flex-direction:column; gap:8px; min-width:0; min-height:0; flex:1; overflow:auto; } + .b09s-bar { display:flex; flex-wrap:wrap; align-items:center; gap:8px; font-size:13px; } + .b09s-wrap { overflow-x:auto; max-width:100%; } + .b09s-table { border-collapse:collapse; font-size:12px; white-space:nowrap; } + .b09s-table th, .b09s-table td { border:1px solid var(--ui-border, #d0d4dc); padding:2px 6px; } + .b09s-table th { background:var(--ui-surface-muted, #f1f3f7); font-weight:600; text-align:center; } + .b09s-num { text-align:right; font-variant-numeric:tabular-nums; } + .b09s-table tr.is-group td { font-weight:600; background:var(--ui-surface-muted, #f7f8fb); } + .b09s-table tr.is-sum td { font-weight:700; background:var(--ui-surface-muted, #eef1f6); } + .b09s-table tr.is-clickable { cursor:pointer; } + .b09s-table tr.is-clickable:hover td { background:rgba(47,111,237,0.08); } + .b09s-table tr.is-selected td { background:rgba(47,111,237,0.16); } + .b09s-table tr.is-manual td { box-shadow:inset 0 0 0 1px #d93025; } + .b09s-table td.b09s-note { white-space:normal; min-width:160px; max-width:420px; } + .b09s-toggle { border:none; background:none; cursor:pointer; padding:0 4px 0 0; font-size:11px; } + .b09s-link { border:none; background:none; color:var(--ui-accent, #2f6fed); cursor:pointer; padding:0 4px 0 0; text-decoration:underline; font-size:12px; } + .b09s-badge { display:inline-block; border:1px solid #d93025; color:#d93025; border-radius:10px; padding:0 6px; font-size:11px; margin-left:4px; } + .b09s-hint { font-size:12px; color:var(--ui-text-muted, #5f6673); } + .b09s-hint--warn { color:#b3261e; } + .b09s-trail { display:flex; flex-wrap:wrap; gap:4px; align-items:center; font-size:13px; } + .b09s-split { display:flex; flex-direction:column; gap:12px; min-width:0; } + .b09s-title { font-weight:700; font-size:14px; } + .b09s-formula { white-space:pre-wrap; font-size:12px; color:var(--ui-text, #1f2430); } + .b09s-legacy .b09-tabs { display:none; } + `; + document.head.append(style); +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts new file mode 100644 index 00000000..ffd8eca6 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -0,0 +1,83 @@ +/* ============================================================================= + * B09_Estimation_UI_Shell.ts + * 로그인 후 09: 6차 워크플로우 (원가계산) — 화면 **틀** (PLAN 12장 · 2026-09-14 브레인 판정) + * + * - 틀은 탭 줄과 등록만. 탭마다 파일 하나(`B09_Estimation_UI_Tab_<이름>.ts`) — 계약은 `_Shell_Types`. + * - ⚠ 등록 권한 = 랩탑_서브. 다른 창은 탭 파일을 만들고 이름을 알려 등록을 부탁함. + * - 탭을 고를 때마다 본문·좌측 칸을 비우고 `render(ctx, arg)`. `ctx.open(key, arg)` = 다른 탭으로 들어가기. + * ========================================================================== */ + +import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { workflowSteps } from "../A00_Common/b_page_scaffold"; +import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { L, el, injectSheetStyles } from "./B09_Estimation_UI_Sheet"; +import { legacyTab } from "./B09_Estimation_UI_Tab_Legacy"; +import { billTab } from "./B09_Estimation_UI_Tab_Bill"; +import { unitPriceTab } from "./B09_Estimation_UI_Tab_UnitPrice"; +import { priceBasisTab } from "./B09_Estimation_UI_Tab_PriceBasis"; + +/** 탭 등록 — 한 줄에 탭 하나. 옛 탭(`legacyTab`)은 새 탭 파일이 서면 그 줄만 바꿈. */ +const TABS: B09Tab[] = [ + legacyTab("cost_sheet", "B09_Estimation_Tab_CostSheet"), // → 랩탑_메인 원가계산서 탭 + billTab, + unitPriceTab, + priceBasisTab, + legacyTab("machine", "B09_Estimation_Tab_Machine"), + legacyTab("supply", "B09_Estimation_Tab_Supply"), + legacyTab("base_data", "B09_Estimation_Tab_BaseData"), + legacyTab("design_doc", "B09_Estimation_Tab_DesignDoc"), + legacyTab("basis_sheet", "B09_Estimation_Tab_BasisSheet"), +]; + +export async function renderB09Estimation(root: HTMLElement): Promise { + injectSheetStyles(); + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const page = el("div", "b09s-page"); + const bar = el("div", "b09s-tabs"); + const body = el("div", "b09s-body"); + const panel = el("div"); + page.append(bar, body); + + let active = TABS[0].key; + const open = (key: string, arg?: string): void => { + const tab = TABS.find((item) => item.key === key); + if (!tab) return; + active = key; + drawBar(); + body.replaceChildren(); + panel.replaceChildren(); + const ctx: B09TabContext = { projectId, body, panel, root, open }; + tab.render(ctx, arg); + }; + const drawBar = (): void => { + bar.replaceChildren( + ...TABS.map((tab) => { + const button = el( + "button", + `b09s-tab${tab.key === active ? " is-active" : ""}`, + tab.label(), + ); + button.type = "button"; + button.dataset.tab = tab.key; + button.addEventListener("click", () => open(tab.key)); + return button; + }), + ); + }; + + const layout = createWorkflowLayout({ + title: L("B09_Estimation_Title"), + steps: workflowSteps(), + activeStep: 6, + leftPanel: panel, + mainContent: page, + routes: WORKFLOW_STEP_ROUTES, + onStepClick: (stepIndex) => { + if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + }, + }); + root.append(layout.root); + open(active); +} diff --git a/B09_Estimation/B09_Estimation_UI_Store.ts b/B09_Estimation/B09_Estimation_UI_Store.ts new file mode 100644 index 00000000..c3158e1c --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Store.ts @@ -0,0 +1,149 @@ +/* ============================================================================= + * B09_Estimation_UI_Store.ts + * B09 내역서·일위대가·단가산출근거 탭이 **한 벌로** 쓰는 서버 자료 (PLAN 12장) + * + * - 설계내역서 응답 한 벌에 호표 목록(`unit_price_sheet`)·단산 목록(`price_basis`)이 함께 옴 — + * 세 탭이 같은 응답을 봄(번호가 탭끼리 갈리지 않게). 번호는 서버가 낼 때마다 매김(저장 안 함). + * - 캐시는 메모리 한 벌(프로젝트별) — [다시 불러오기] 가 비움. 값을 저장하지 않음(보기 전용 1차). + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; + +export interface BillNoteDto { + column: string; + text: string; +} + +export interface BillRowDto { + item_no: string; + level: number; + code: string | null; + name: string; + spec: string; + unit: string; + quantity: string | null; + quantity_shown: string | null; + quantity_digits: number | null; + unit_price_krw: string | null; + amount_krw: string | null; + material_krw: string; + labor_krw: string; + expense_krw: string; + unit_material_krw: string | null; + unit_labor_krw: string | null; + unit_expense_krw: string | null; + price_code: string; + unconfirmed: number; + price_basis_label: string; + is_group: boolean; + in_bill: boolean; + note: string; + notes: BillNoteDto[]; +} + +export interface SheetEntryDto { + number: number; + label: string; + code: string; + name: string; + spec: string; + unit: string; + material_krw: string; + labor_krw: string; + expense_krw: string; + total_krw?: string; + unit_price_krw?: string; + source?: string; + unconfirmed?: number; + ref_code?: string; + unit_price_codes?: string[]; +} + +export interface MissingDto { + name: string; + unit?: string; + quantity?: string; + reason: string; + code?: string; + blocked_kind?: string; +} + +export interface BillDto { + rows: BillRowDto[]; + excluded: BillRowDto[]; + summary: { + body_total_krw: string; + detail_rows: number; + missing: MissingDto[]; + unconfirmed_count: number; + notes: string[]; + }; + price_basis: { entries: SheetEntryDto[] }; + unit_price_sheet: { entries: SheetEntryDto[] }; +} + +export interface DetailRowDto { + ref_code?: string; + code?: string; + name: string; + spec: string; + unit: string; + kind?: string; + source_label?: string; + source?: string; + drillable: boolean; + quantity: string; + unit_material?: string; + unit_labor?: string; + unit_expense?: string; + unit_total?: string; + material: string; + labor: string; + expense: string; + total: string; + note: string; +} + +export interface DetailDto { + code: string; + name: string; + spec: string; + unit: string; + kind: string; + material: string; + labor: string; + expense: string; + total: string; + rows: DetailRowDto[]; + unattached_note: string; + known_gap_note: string; +} + +const bills = new Map>(); + +async function getJson(path: string): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(String((body as { message?: string }).message || response.status)); + } + return body as T; +} + +/** 설계내역서 한 벌 — 세 탭이 같은 응답을 봄. `force` 면 다시 받음. */ +export function loadBill(projectId: string, force = false): Promise { + if (force || !bills.has(projectId)) { + const request = getJson(`/projects/${encodeURIComponent(projectId)}/estimation/bill`); + request.catch(() => bills.delete(projectId)); + bills.set(projectId, request); + } + return bills.get(projectId) as Promise; +} + +/** 호표 본표 — 일위대가(B)·시간당 중기(X)는 `unit-prices`, 단가산출(D)은 `price-basis`. */ +export function loadDetail(projectId: string, code: string): Promise { + const kind = code.startsWith("D-") ? "price-basis" : "unit-prices"; + return getJson( + `/projects/${encodeURIComponent(projectId)}/estimation/${kind}/${encodeURIComponent(code)}`, + ); +} diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts new file mode 100644 index 00000000..6573f0e1 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts @@ -0,0 +1,233 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_Bill.ts + * B09 설계내역서 탭 — STmate `wBoq` 본 (PLAN 12장 1차: 표시 + 들어가기) + * + * - 열은 실무 설계내역서 그대로: 공종 · 명칭 · 규격 · 수량 · 단위 · 합계/노무비/재료비/경비(단가·금액) · 비고. + * - 머리글 줄은 ▸/▾ 로 접고, 「레벨」 고르개로 한 번에 접음(STmate `소계~총대계` 고르기의 우리 꼴). + * - ⭐ 줄을 누르면 그 줄 호표(일위대가) → 호표 안 줄을 누르면 단가산출근거로 들어감(브레인 판정 핵심). + * - 수동 단가로 선 줄 = 빨간 테두리 + 「미확정 N건」. + * - ⚠ 금액·합계는 서버가 실은 값 — 여기서 더하지 않음(머리글 줄 금액도 서버 합). + * ========================================================================== */ + +import { createButton, showToast } from "@ui/ui_template_elements"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { + L, + el, + hint, + linkButton, + moneyCells, + numberCell, + quantity, + sheetHead, + sheetTable, + unconfirmedBadge, + won, +} from "./B09_Estimation_UI_Sheet"; +import { loadBill, type BillDto, type BillRowDto } from "./B09_Estimation_UI_Store"; + +/** 접힌 머리글 줄(공종 번호) — 탭을 오가도 남음. */ +const collapsed = new Set(); +let levelLimit = 0; // 0 = 모두 + +function rowMoney(row: BillRowDto): { + unit: [string, string, string, string] | null; + amount: [string, string, string, string] | null; +} { + const amount: [string, string, string, string] | null = + row.amount_krw === null + ? null + : [row.amount_krw, row.labor_krw, row.material_krw, row.expense_krw]; + if (row.is_group || row.unit_price_krw === null) return { unit: null, amount }; + return { + unit: [ + row.unit_price_krw, + row.unit_labor_krw ?? "", + row.unit_material_krw ?? "", + row.unit_expense_krw ?? "", + ], + amount, + }; +} + +function isHidden(row: BillRowDto, rows: BillRowDto[]): boolean { + if (levelLimit > 0 && row.level > levelLimit) return true; + for (const group of rows) { + if ( + group.is_group && + collapsed.has(group.item_no) && + row.item_no.startsWith(`${group.item_no}-`) + ) { + return true; + } + } + return false; +} + +function noteCell(row: BillRowDto, bill: BillDto, ctx: B09TabContext): HTMLTableCellElement { + const cell = el("td", "b09s-note"); + const sheet = bill.unit_price_sheet.entries.find((entry) => entry.code === row.price_code); + if (sheet) cell.append(linkButton(sheet.label, () => ctx.open("unit_price", sheet.code))); + const basis = bill.price_basis.entries.filter((entry) => + (entry.unit_price_codes ?? []).includes(row.price_code), + ); + for (const entry of basis) { + cell.append( + linkButton(`${L("B09_Sheet_Basis_Short")} ${entry.number}`, () => + ctx.open("price_basis", entry.code), + ), + ); + } + if (row.unconfirmed > 0) cell.append(unconfirmedBadge(row.unconfirmed)); + // 비고 글 — 단산 번호는 위 단추로 보였으니 그 조각만 뺌. + const rest = row.notes + .map((note) => note.text) + .filter((text) => text && text !== row.price_basis_label) + .join(" / "); + if (rest) cell.append(el("span", "", rest)); + return cell; +} + +function drawRows(tbody: HTMLElement, bill: BillDto, ctx: B09TabContext, redraw: () => void): void { + for (const row of bill.rows) { + if (isHidden(row, bill.rows)) continue; + const tr = el("tr", row.is_group ? "is-group" : ""); + const numberTd = el("td"); + if (row.is_group) { + const toggle = el("button", "b09s-toggle", collapsed.has(row.item_no) ? "▸" : "▾"); + toggle.type = "button"; + toggle.addEventListener("click", (event) => { + event.stopPropagation(); + if (collapsed.has(row.item_no)) collapsed.delete(row.item_no); + else collapsed.add(row.item_no); + redraw(); + }); + numberTd.append(toggle); + } + numberTd.append(document.createTextNode(row.item_no)); + const name = el("td", "", row.name); + name.style.paddingLeft = `${6 + Math.max(0, row.level - 1) * 12}px`; + const { unit, amount } = rowMoney(row); + tr.append( + numberTd, + name, + el("td", "", row.spec), + numberCell( + row.is_group ? "" : quantity(row.quantity_shown ?? row.quantity, row.quantity_digits), + ), + el("td", "", row.unit), + ...moneyCells(unit, amount), + ); + if (row.is_group) { + tr.append(el("td")); + } else { + tr.append(noteCell(row, bill, ctx)); + if (row.unconfirmed > 0) tr.classList.add("is-manual"); + if (row.price_code) { + tr.classList.add("is-clickable"); + tr.title = L("B09_Sheet_Open_UnitPrice"); + tr.addEventListener("click", () => ctx.open("unit_price", row.price_code)); + } + } + tbody.append(tr); + } +} + +function levelPicker(bill: BillDto, redraw: () => void): HTMLElement { + const select = el("select", "ui-input"); + const deepest = Math.max(1, ...bill.rows.map((row) => row.level)); + const all = el("option", "", L("B09_Sheet_Level_All")); + all.value = "0"; + select.append(all); + for (let level = 1; level < deepest; level += 1) { + const option = el("option", "", `${level}${L("B09_Sheet_Level_Upto")}`); + option.value = String(level); + select.append(option); + } + select.value = String(levelLimit); + select.addEventListener("change", () => { + levelLimit = Number(select.value); + redraw(); + }); + const label = el("label", "b09s-bar", L("B09_Sheet_Level")); + label.append(select); + return label; +} + +function drawBill(ctx: B09TabContext, bill: BillDto, reload: () => void): void { + const redraw = (): void => { + ctx.body.replaceChildren(); + drawBill(ctx, bill, reload); + }; + const bar = el("div", "b09s-bar"); + bar.append( + createButton({ label: L("B09_Sheet_Reload"), variant: "ghost", onClick: reload }), + levelPicker(bill, redraw), + el( + "span", + "", + `${L("B09_Sheet_BodyTotal")} ${won(bill.summary.body_total_krw)}${L("B09_Sheet_Won")}`, + ), + ); + if (bill.summary.unconfirmed_count > 0) + bar.append(unconfirmedBadge(bill.summary.unconfirmed_count)); + ctx.body.append(bar); + + const { wrap, tbody } = sheetTable( + sheetHead([ + L("B09_Sheet_Col_ItemNo"), + L("B09_Sheet_Col_Name"), + L("B09_Sheet_Col_Spec"), + L("B09_Sheet_Col_Quantity"), + L("B09_Sheet_Col_Unit"), + ]), + ); + drawRows(tbody, bill, ctx, redraw); + const sum = el("tr", "is-sum"); + sum.append(el("td"), el("td", "", L("B09_Sheet_BodyTotal")), el("td"), el("td"), el("td")); + sum.append(...moneyCells(null, [bill.summary.body_total_krw, "", "", ""]), el("td")); + tbody.append(sum); + ctx.body.append(wrap); + + // 금액을 못 세운 줄 — 0 으로 안 때우고 이름째 보임(서버 `missing`). + if (bill.summary.missing.length > 0) { + const details = el("details"); + details.append( + el( + "summary", + "b09s-hint b09s-hint--warn", + `${L("B09_Sheet_Missing")} ${bill.summary.missing.length}${L("B09_Sheet_Count")}`, + ), + ); + for (const item of bill.summary.missing) { + details.append(hint(`${item.name} — ${item.reason}`)); + } + ctx.body.append(details); + } + for (const note of bill.summary.notes) ctx.body.append(hint(note)); +} + +export const billTab: B09Tab = { + key: "boq", + label: () => L("B09_Estimation_Tab_Boq"), + render(ctx) { + if (!ctx.projectId) { + ctx.body.append(hint(L("B09_Sheet_NoProject"))); + return; + } + const projectId = ctx.projectId; + const show = (force: boolean): void => { + ctx.body.replaceChildren(hint(L("B09_Sheet_Loading"))); + loadBill(projectId, force) + .then((bill) => { + ctx.body.replaceChildren(); + drawBill(ctx, bill, () => show(true)); + }) + .catch((error: Error) => { + ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); + showToast(L("B09_Sheet_LoadFailed"), "error"); + }); + }; + show(false); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Legacy.ts b/B09_Estimation/B09_Estimation_UI_Tab_Legacy.ts new file mode 100644 index 00000000..855d5554 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_Legacy.ts @@ -0,0 +1,30 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_Legacy.ts + * 옛 B09 화면의 탭을 새 틀에 **그대로 이어 붙이는** 자리 (PLAN 12장 · 2026-09-14 브레인 판정) + * + * - 원가계산서(→ 랩탑_메인 새 탭 파일) · 중기 · 관급·사급 · 기초자료(계수 고르개 = 계산 입력) · + * 설계서 구성 · 산출기초 — 새 탭 파일이 서면 틀 등록 한 줄을 바꾸고, 다 바뀌면 이 파일과 옛 파일을 지움. + * - 옛 화면 한 벌을 처음 고를 때 한 번 세우고 탭끼리 나눠 씀(옛 상태·캐시 그대로). + * ========================================================================== */ + +import type { ui_locales } from "@ui/ui_template_locale"; +import type { B09Tab } from "./B09_Estimation_UI_Shell_Types"; +import { L } from "./B09_Estimation_UI_Sheet"; +import { createLegacyEstimation, type LegacyEstimation } from "./B09_Estimation_UI_Page"; + +let legacy: LegacyEstimation | null = null; + +export function legacyTab(key: string, labelKey: keyof typeof ui_locales): B09Tab { + return { + key, + label: () => L(labelKey), + render(ctx) { + legacy ??= createLegacyEstimation(ctx.root); + legacy.main.classList.add("b09s-legacy"); + ctx.body.append(legacy.main); + // 옛 좌측 칸은 원가계산서 입력 — 그 탭에서만 보임. + if (key === "cost_sheet") ctx.panel.append(legacy.panel); + legacy.select(key); + }, + }; +} diff --git a/B09_Estimation/B09_Estimation_UI_Tab_PriceBasis.ts b/B09_Estimation/B09_Estimation_UI_Tab_PriceBasis.ts new file mode 100644 index 00000000..dcb47e96 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_PriceBasis.ts @@ -0,0 +1,76 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_PriceBasis.ts + * B09 단가산출근거 탭 — 목록표 + 단가산출근거 (STmate `단가산출근거목록표` · `wM_Edit_San` 보기 · PLAN 12장 1차) + * + * - 목록표 = 내역 일위대가가 품은 D 만, **내역에 처음 쓰인 차례**로 「산근 N호표」(내역 비고 「단산 N」과 한 번호). + * - 본표 줄의 비고에 Q 식·품셈 근거를 **글자 그대로** 보임(서버 줄 `note`). + * - 「부르는 호표」 칸의 글을 누르면 그 일위대가표로 되돌아 들어감. + * - Q 식 편집은 2차(브레인 판정). + * ========================================================================== */ + +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { renderDetail } from "./B09_Estimation_UI_Detail"; +import { L, el, hint, linkButton, sheetTable } from "./B09_Estimation_UI_Sheet"; +import { listHead, listRow } from "./B09_Estimation_UI_Tab_UnitPrice"; +import { loadBill, type BillDto } from "./B09_Estimation_UI_Store"; + +let selected = ""; + +function draw(ctx: B09TabContext, bill: BillDto): void { + const entries = bill.price_basis.entries; + const page = el("div", "b09s-split"); + const { wrap, tbody } = sheetTable(listHead(L("B09_Sheet_Col_Basis"))); + const detail = el("div", "b09s-split"); + for (const entry of entries) { + const row = { + ...entry, + label: `${L("B09_Sheet_Basis_Long")} ${entry.number}${L("B09_Sheet_Basis_Suffix")}`, + }; + const tr = listRow(row, entry.unit_price_krw ?? "", entry.code === selected); + const note = tr.lastElementChild as HTMLElement; + for (const code of entry.unit_price_codes ?? []) { + const sheet = bill.unit_price_sheet.entries.find((item) => item.code === code); + if (sheet) note.append(linkButton(sheet.label, () => ctx.open("unit_price", code))); + } + tr.addEventListener("click", () => { + selected = entry.code; + ctx.body.replaceChildren(); + draw(ctx, bill); + }); + tbody.append(tr); + } + page.append(el("div", "b09s-title", L("B09_Sheet_BasisList")), wrap); + if (entries.length === 0) page.append(hint(L("B09_Sheet_EmptyList"))); + page.append(detail); + ctx.body.append(page); + if (!selected) { + detail.append(hint(L("B09_Sheet_PickSheet"))); + return; + } + const picked = entries.find((entry) => entry.code === selected); + const label = picked + ? `${L("B09_Sheet_Basis_Long")} ${picked.number}${L("B09_Sheet_Basis_Suffix")}` + : selected; + renderDetail(ctx, detail, "price_basis", selected, label); +} + +export const priceBasisTab: B09Tab = { + key: "price_basis", + label: () => L("B09_Estimation_Tab_PriceBasis"), + render(ctx, arg) { + if (!ctx.projectId) { + ctx.body.append(hint(L("B09_Sheet_NoProject"))); + return; + } + if (arg) selected = arg; + ctx.body.append(hint(L("B09_Sheet_Loading"))); + loadBill(ctx.projectId) + .then((bill) => { + ctx.body.replaceChildren(); + draw(ctx, bill); + }) + .catch((error: Error) => { + ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); + }); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_UnitPrice.ts b/B09_Estimation/B09_Estimation_UI_Tab_UnitPrice.ts new file mode 100644 index 00000000..7eacc813 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_UnitPrice.ts @@ -0,0 +1,126 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_UnitPrice.ts + * B09 일위대가 탭 — 목록표 + 일위대가표 (STmate `일위대가목록표` · `wM_Edit_iLWi` 보기 · PLAN 12장 1차) + * + * - 목록표 = 내역에 쓰인 호표만, **내역에 처음 쓰인 차례**로 「제 N 호표」(번호는 서버가 낼 때마다 매김). + * - 줄을 누르면 아래에 그 호표의 일위대가표 — 호표 안 줄(하위 호표·단산·중기)을 누르면 더 들어감. + * - 구조물도 호표는 B08 구조물도 탭의 일위대가 표가 본표 — 여기선 금액·안내만. + * - 편집(구성행 수정)은 2차 — B09 에 수정 저장 자리가 아직 없음(브레인 판정). + * ========================================================================== */ + +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { renderDetail } from "./B09_Estimation_UI_Detail"; +import { + L, + el, + hint, + numberCell, + sheetTable, + unconfirmedBadge, + won, +} from "./B09_Estimation_UI_Sheet"; +import { loadBill, type BillDto, type SheetEntryDto } from "./B09_Estimation_UI_Store"; + +let selected = ""; + +/** 목록표 머리 — 호표 · 명칭 · 규격 · 단위 · 합계 · 노무비 · 재료비 · 경비 · 비고(실무 목록표). */ +export function listHead(first: string): HTMLElement { + const thead = el("thead"); + const tr = el("tr"); + for (const label of [ + first, + L("B09_Sheet_Col_Name"), + L("B09_Sheet_Col_Spec"), + L("B09_Sheet_Col_Unit"), + L("B09_Sheet_Col_Total"), + L("B09_Sheet_Col_Labor"), + L("B09_Sheet_Col_Material"), + L("B09_Sheet_Col_Expense"), + L("B09_Sheet_Col_Note"), + ]) { + tr.append(el("th", "", label)); + } + thead.append(tr); + return thead; +} + +export function listRow(entry: SheetEntryDto, total: string, isSelected: boolean): HTMLElement { + const tr = el("tr", `is-clickable${isSelected ? " is-selected" : ""}`); + tr.append( + el("td", "", entry.label), + el("td", "", entry.name), + el("td", "", entry.spec), + el("td", "", entry.unit), + numberCell(won(total)), + numberCell(won(entry.labor_krw)), + numberCell(won(entry.material_krw)), + numberCell(won(entry.expense_krw)), + ); + const note = el("td", "b09s-note"); + if (entry.unconfirmed) { + note.append(unconfirmedBadge(entry.unconfirmed)); + tr.classList.add("is-manual"); + } + tr.append(note); + return tr; +} + +function labelFor(bill: BillDto, code: string): string { + const entry = bill.unit_price_sheet.entries.find((item) => item.code === code); + if (entry) return entry.label; + return code.startsWith("X-") ? `${L("B09_Sheet_Machine")} ${code.slice(2)}` : code; +} + +function draw(ctx: B09TabContext, bill: BillDto): void { + const entries = bill.unit_price_sheet.entries; + const page = el("div", "b09s-split"); + const { wrap, tbody } = sheetTable(listHead(L("B09_Sheet_Col_Sheet"))); + const detail = el("div", "b09s-split"); + const select = (code: string): void => { + selected = code; + ctx.body.replaceChildren(); + draw(ctx, bill); + }; + for (const entry of entries) { + const tr = listRow(entry, entry.total_krw ?? "", entry.code === selected); + tr.addEventListener("click", () => select(entry.code)); + tbody.append(tr); + } + page.append(el("div", "b09s-title", L("B09_Sheet_UnitPriceList")), wrap); + if (entries.length === 0) page.append(hint(L("B09_Sheet_EmptyList"))); + page.append(detail); + ctx.body.append(page); + + if (!selected) { + detail.append(hint(L("B09_Sheet_PickSheet"))); + return; + } + const picked = entries.find((entry) => entry.code === selected); + if (picked?.source === "structure") { + detail.append(el("div", "b09s-title", `${picked.label} ${picked.name} · ${picked.spec}`)); + detail.append(hint(L("B09_Sheet_StructureSheet"))); + return; + } + renderDetail(ctx, detail, "unit_price", selected, labelFor(bill, selected)); +} + +export const unitPriceTab: B09Tab = { + key: "unit_price", + label: () => L("B09_Estimation_Tab_UnitPrice"), + render(ctx, arg) { + if (!ctx.projectId) { + ctx.body.append(hint(L("B09_Sheet_NoProject"))); + return; + } + if (arg) selected = arg; + ctx.body.append(hint(L("B09_Sheet_Loading"))); + loadBill(ctx.projectId) + .then((bill) => { + ctx.body.replaceChildren(); + draw(ctx, bill); + }) + .catch((error: Error) => { + ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); + }); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UnitPrice_View.py b/B09_Estimation/B09_Estimation_UnitPrice_View.py index 3d88c18f..b9f3279a 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice_View.py +++ b/B09_Estimation/B09_Estimation_UnitPrice_View.py @@ -14,7 +14,7 @@ from decimal import Decimal from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price from B09_Estimation.B09_Estimation_MaterialCatalog import catalog_summary, load_material_catalog -from B09_Estimation.B09_Estimation_PriceBook import PriceKind +from B09_Estimation.B09_Estimation_PriceBook import TRUNCATED_KINDS, PriceKind from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import ( DRILLABLE_KINDS, @@ -202,7 +202,7 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict: "quantity": str(detail.percent_of_labor), "material": "0", "labor": "0", - "expense": str(amount), + "expense": _money_text(amount), "total": _money_text(amount), "source": "품셈 [주]", "drillable": False, @@ -233,7 +233,7 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict: "spec": f"{'주연료비' if misc else '주재료비'}의 {detail.percent_of_material}%", "unit": "%", "quantity": str(detail.percent_of_material), - "material": str(amount), + "material": _money_text(amount), "labor": "0", "expense": "0", "total": _money_text(amount), @@ -285,16 +285,22 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict: # ㉤ 열 방향 검사 — 같은 성분을 두 층에서 세면 여기서 멈춘다. # 행 방향(`TC=NC+GC+JC`)만으로는 안 잡히는 어긋남이다. check_column_sums(rows=rows, totals=summed, label=f"{title.name} 본표") + # 합계 줄 — 호표 안에서 자르는 층(X·D·B)은 **성분 소계 원 미만 절사** 값(실무 표 합계 줄 · 명세 7장). + shown = ( + {"material": money.material, "labor": money.labor, "expense": money.expense} + if title.kind in TRUNCATED_KINDS + else {key: summed[key] for key in ("material", "labor", "expense")} + ) return { "code": code, "name": title.name, "spec": title.spec, "unit": title.unit, "kind": title.kind.value, - "material": str(summed["material"]), - "labor": str(summed["labor"]), - "expense": str(summed["expense"]), - "total": str(summed["total"]), + "material": str(shown["material"]), + "labor": str(shown["labor"]), + "expense": str(shown["expense"]), + "total": str(sum(shown.values(), Decimal(0))), # TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다. "sum_matches": summed["total"] == summed["material"] + summed["labor"] + summed["expense"], # 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다. diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 0dcfca77..bc4247e4 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -21,6 +21,7 @@ import { ui_locales_common } from "./ui_template_locale_common"; import { ui_locales_a } from "./ui_template_locale_a"; import { ui_locales_b1 } from "./ui_template_locale_b1"; import { ui_locales_b2 } from "./ui_template_locale_b2"; +import { ui_locales_b3 } from "./ui_template_locale_b3"; /** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */ export const LANGUAGES = ["ko", "en"] as const; @@ -53,6 +54,7 @@ export const ui_locales = { ...ui_locales_a, ...ui_locales_b1, ...ui_locales_b2, + ...ui_locales_b3, } as const; export type LocaleKey = keyof typeof ui_locales; diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts new file mode 100644 index 00000000..c9fcf9d8 --- /dev/null +++ b/ui_template/ui_template_locale_b3.ts @@ -0,0 +1,60 @@ +/* ============================================================================= + * ui_template_locale_b3.ts + * B 그룹 사전 3 — B09 실무 서식 화면(설계내역서·일위대가표·단가산출근거, PLAN 12장) + * + * b2 가 700줄을 넘어 새 벌로 뗌. 신규 문구는 이 파일 최하단에 추가. + * ========================================================================== */ + +export const ui_locales_b3 = { + /* --- B09 실무 서식 표 --- */ + B09_Sheet_Col_ItemNo: ["공종", "Item"], + B09_Sheet_Col_Name: ["명칭", "Name"], + B09_Sheet_Col_Spec: ["규격", "Spec"], + B09_Sheet_Col_Quantity: ["수량", "Qty"], + B09_Sheet_Col_Unit: ["단위", "Unit"], + B09_Sheet_Col_Total: ["합계", "Total"], + B09_Sheet_Col_Labor: ["노무비", "Labor"], + B09_Sheet_Col_Material: ["재료비", "Material"], + B09_Sheet_Col_Expense: ["경비", "Expense"], + B09_Sheet_Col_UnitPrice: ["단가", "Unit price"], + B09_Sheet_Col_Amount: ["금액", "Amount"], + B09_Sheet_Col_Note: ["비고", "Note"], + B09_Sheet_Col_Sheet: ["호표", "Sheet"], + B09_Sheet_Col_Basis: ["산근", "Basis"], + B09_Sheet_Sum: ["합계", "Total"], + B09_Sheet_BodyTotal: ["내역 본체 합계", "Bill body total"], + B09_Sheet_Won: ["원", " KRW"], + B09_Sheet_Count: ["건", ""], + B09_Sheet_Unconfirmed: ["미확정", "Unconfirmed"], + B09_Sheet_Missing: ["금액을 못 세운 줄", "Rows without a price"], + B09_Sheet_Reload: ["다시 불러오기", "Reload"], + B09_Sheet_Level: ["보이는 레벨", "Show levels"], + B09_Sheet_Level_All: ["모두", "All"], + B09_Sheet_Level_Upto: ["레벨까지", " level(s)"], + B09_Sheet_Loading: ["불러오는 중…", "Loading…"], + B09_Sheet_LoadFailed: ["불러오지 못했습니다.", "Could not load."], + B09_Sheet_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."], + B09_Sheet_Open_UnitPrice: [ + "누르면 이 줄의 일위대가표로 들어갑니다", + "Click to open this row's unit price sheet", + ], + B09_Sheet_Drill: ["누르면 이 줄의 호표로 들어갑니다", "Click to open this row's sheet"], + B09_Sheet_Basis_Short: ["단산", "Basis"], + B09_Sheet_Basis_Long: ["산근", "Basis"], + B09_Sheet_Basis_Suffix: ["호표", ""], + B09_Sheet_Machine: ["시간당 중기", "Machine hourly"], + B09_Sheet_UnitPriceList: ["일위대가 목록표", "Unit price list"], + B09_Sheet_BasisList: ["단가산출근거 목록표", "Price basis list"], + B09_Sheet_EmptyList: [ + "내역에 쓰인 호표가 없습니다 — 설계내역서가 먼저 서야 합니다.", + "No sheets used by the bill yet.", + ], + B09_Sheet_PickSheet: [ + "목록표에서 호표를 누르면 본표가 아래에 섭니다.", + "Click a sheet above to see its table.", + ], + B09_Sheet_StructureSheet: [ + "구조물도 일위대가는 수량산출 › 구조물도 탭의 일위대가 표가 본표입니다.", + "Structure unit prices are shown on the structure drawing tab.", + ], +} as const;