/* ============================================================================= * B09_Estimation_UI_Tab_Contract.ts * 계약내역 탭 — STmate 「당초설계 → 계약내역 변환등록」(wM_Mk_Cont)을 본뜸 (PLAN 12장 · 랩탑 메인). * * - 좌측 = 단가 적용율 【 노 】【 재 】【 경 】 % · 적용 옵션 여섯(화면 표기 그대로) · [저장]. * - 본문 = 공종번호 · 명칭 · 규격 · 금액(설계) · 계약 단가·금액 · **적용제외** · 계약단가 코드. * - ⚠ 값은 서버(`/estimation/contract`)가 설계 내역을 복사해 셈 — 설계는 안 바뀜. * - ⚠ 계약 표본이 없어 「구조가 선다」까지만 확인된 화면 — 머리에 그 한계를 적음. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, showToast } from "@ui/ui_template_elements"; import { API_BASE_URL } from "@config/config_frontend"; import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } interface ContractRow { item_no: string; name: string; spec: string; unit: string; quantity: string | null; is_group: boolean; in_bill: boolean; amount_krw: string | null; contract_unit_price_krw?: string; contract_amount_krw?: string; contract_code?: string; contract_excluded?: boolean; contract_note?: string; } interface Money { material_krw: string; labor_krw: string; expense_krw: string; total_krw: string; } /** 계약 호표 한 장 — 설계 본표(`detail_of`)와 같은 꼴 + 계약 코드(W-). */ interface ContractSheet { code: string; design_code: string; name: string; spec: string; unit: string; material: string; labor: string; expense: string; total: string; rows: { name: string; spec: string; unit?: string; quantity: string; material: string; labor: string; expense: string; total: string; }[]; } interface ContractDto { status: string; message?: string; rows: ContractRow[]; unit_price_sheets: ContractSheet[]; totals: { design: Money; contract: Money; ratio_pct: Record }; options_not_used: Record; settings: Record; fields: { rates: { key: string; label: string }[]; options: { key: string; label: string }[]; }; bill_missing_count: number; limit_note: string; } const STYLE_ID = "b09-contract-styles"; function injectStyles(): void { if (document.getElementById(STYLE_ID)) return; const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = ` .b09ct { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } .b09ct__meta { font-size: 12px; color: var(--color-text-secondary); } .b09ct__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); } .b09ct__scroll { flex: 1; overflow: auto; min-height: 0; } .b09ct__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; } .b09ct__table th, .b09ct__table td { border: 1px solid var(--color-border); padding: 2px 6px; } .b09ct__table td.num { text-align: right; font-variant-numeric: tabular-nums; } .b09ct__table tr.is-group td { font-weight: 600; } .b09ct__table tr.is-excluded td { color: var(--color-text-secondary); } .b09ct__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; } .b09ct__rates { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } .b09ct__rates input { width: 4.5em; text-align: right; } .b09ct__option { display: flex; gap: 4px; align-items: flex-start; } .b09ct__totals { font-size: 12px; font-variant-numeric: tabular-nums; } .b09ct__sheets { display: flex; flex-direction: column; gap: 4px; margin-top: 12px; font-size: 12px; } .b09ct__sheets summary { cursor: pointer; } `; document.head.append(style); } 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; } function won(value: string | null | undefined): string { if (value === null || value === undefined || value === "") return ""; const n = Number(value); return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value; } /** 프로젝트별 입력 캐시 — [저장] 전 값(지침 5장 · 자동저장 없음). */ const drafts = new Map>(); async function fetchContract(projectId: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/contract`, { credentials: "include" }, ); const body = (await response.json()) as ContractDto; if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); return body; } async function saveContract( projectId: string, values: Record, ): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/contract`, { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(values), }, ); const body = (await response.json()) as { message?: string }; if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); } function drawPanel(ctx: B09TabContext, data: ContractDto, reload: () => void): void { const projectId = ctx.projectId as string; const draft = drafts.get(projectId) ?? { ...data.settings }; drafts.set(projectId, draft); const box = el("div", "b09ct__panel"); box.append(el("strong", "", "단가 적용율")); const rates = el("div", "b09ct__rates"); for (const field of data.fields.rates) { const label = el("label", "b09ct__rates"); label.append(el("span", "", `【 ${field.label} 】`)); const input = el("input"); input.type = "number"; input.min = "0"; input.step = "0.001"; input.value = String(draft[field.key] ?? "100"); input.addEventListener("input", () => (draft[field.key] = input.value)); label.append(input, el("span", "", "%")); rates.append(label); } box.append(rates); for (const option of data.fields.options) { const row = el("label", "b09ct__option"); const check = el("input"); check.type = "checkbox"; check.checked = Boolean(draft[option.key]); check.addEventListener("change", () => (draft[option.key] = check.checked)); row.append(check, el("span", "", option.label)); box.append(row); const unused = data.options_not_used[option.key]; if (unused) box.append(el("span", "b09ct__warn", `⚠ ${unused}`)); } box.append( createButton({ label: "저장", onClick: async () => { try { await saveContract(projectId, draft); drafts.delete(projectId); showToast("계약 조건 저장 — 설계 내역은 그대로", "success"); reload(); } catch (error) { showToast(error instanceof Error ? error.message : "저장 못 함", "error"); } }, }), ); const t = data.totals; const totals = el("div", "b09ct__totals"); for (const [label, part] of [ ["재료비", "material"], ["노무비", "labor"], ["경비", "expense"], ] as const) { totals.append( el( "div", "", `${label} ${won(t.design[`${part}_krw`])} → ${won(t.contract[`${part}_krw`])}` + (t.ratio_pct[part] ? ` (${t.ratio_pct[part]}%)` : ""), ), ); } totals.append( el("div", "", `직접공사비 ${won(t.design.total_krw)} → ${won(t.contract.total_krw)}`), ); box.append(totals); ctx.panel.append(box); } function drawBody(ctx: B09TabContext, data: ContractDto): void { const projectId = ctx.projectId as string; const draft = drafts.get(projectId) ?? { ...data.settings }; drafts.set(projectId, draft); const excluded = new Set((draft.excluded as string[] | undefined) ?? []); const wrap = el("div", "b09ct"); wrap.append(el("div", "b09ct__warn", `⚠ ${data.limit_note}`)); if (data.bill_missing_count) { wrap.append( el( "div", "b09ct__warn", `⚠ 설계 내역 ${L("B09_Sheet_Missing")} ${data.bill_missing_count}${L("B09_Sheet_Count")} — 계약단가도 못 섬`, ), ); } wrap.append( el("div", "b09ct__meta", "적용율을 제외할(ex 관급자재대..) 공정을 선택 — [저장]하면 반영"), ); const scroll = el("div", "b09ct__scroll"); const table = el("table", "b09ct__table"); const head = el("tr"); for (const label of [ "공종번호", "명칭", "규격", "단위", "수량", "금액(설계)", "계약 단가", "계약 금액", "적용제외", "계약단가 코드", "비고", ]) { head.append(el("th", "", label)); } table.append(head); for (const row of data.rows) { const tr = el("tr", row.is_group ? "is-group" : row.contract_excluded ? "is-excluded" : ""); tr.append( el("td", "", row.item_no), el("td", "", row.name), el("td", "", row.spec ?? ""), el("td", "", row.unit ?? ""), el("td", "num", row.quantity ?? ""), el("td", "num", won(row.amount_krw)), el("td", "num", won(row.contract_unit_price_krw)), el("td", "num", won(row.contract_amount_krw)), ); const cell = el("td"); if (!row.is_group && row.in_bill) { const check = el("input"); check.type = "checkbox"; check.checked = excluded.has(row.item_no); check.addEventListener("change", () => { if (check.checked) excluded.add(row.item_no); else excluded.delete(row.item_no); draft.excluded = [...excluded]; }); cell.append(check); } tr.append(cell, el("td", "", row.contract_code ?? ""), el("td", "", row.contract_note ?? "")); table.append(tr); } scroll.append(table); if (data.unit_price_sheets.length) scroll.append(drawSheets(data.unit_price_sheets)); wrap.append(scroll); ctx.body.append(wrap); } /** 「적용율 적용된 일위대가/산출근거」 — 설계 호표는 그대로, 계약 호표(W-)만 따로 펼침. */ function drawSheets(sheets: ContractSheet[]): HTMLElement { const box = el("div", "b09ct__sheets"); box.append(el("strong", "", `계약 일위대가·산출근거 ${sheets.length}장 — 설계 호표는 그대로`)); for (const sheet of sheets) { const details = el("details"); details.append( el( "summary", "", `${sheet.code} ${sheet.name} ${sheet.spec} (설계 ${sheet.design_code}) — ` + `재 ${won(sheet.material)} · 노 ${won(sheet.labor)} · 경 ${won(sheet.expense)} · 계 ${won(sheet.total)}`, ), ); const table = el("table", "b09ct__table"); const head = el("tr"); for (const label of ["명칭", "규격", "단위", "수량", "재료비", "노무비", "경비", "합계"]) { head.append(el("th", "", label)); } table.append(head); for (const row of sheet.rows) { const tr = el("tr"); tr.append( el("td", "", row.name), el("td", "", row.spec ?? ""), el("td", "", row.unit ?? ""), el("td", "num", row.quantity), el("td", "num", won(row.material)), el("td", "num", won(row.labor)), el("td", "num", won(row.expense)), el("td", "num", won(row.total)), ); table.append(tr); } details.append(table); box.append(details); } return box; } function render(ctx: B09TabContext): void { injectStyles(); if (!ctx.projectId) { ctx.body.append(el("div", "b09ct__meta", "프로젝트를 고르세요")); return; } const load = (): void => { ctx.body.replaceChildren(el("div", "b09ct__meta", "계약내역 계산 중…")); ctx.panel.replaceChildren(); fetchContract(ctx.projectId as string) .then((data) => { ctx.body.replaceChildren(); drawPanel(ctx, data, load); drawBody(ctx, data); }) .catch((error: unknown) => { ctx.body.replaceChildren( el( "div", "b09ct__warn", `계약내역을 세우지 못함 — ${error instanceof Error ? error.message : ""}`, ), ); }); }; load(); } export const contractTab: B09Tab = { key: "contract", label: () => L("B09_Estimation_Tab_Contract"), render, };