/* ============================================================================= * B09_Estimation_UI_Tab_CostSheet.ts * 원가계산서 탭 — STmate 「제잡비 계산」(wM_KanJub) 일반형식을 본뜸 (PLAN 12장 · 랩탑 메인). * * - 좌측 = 기준 입력판: 번호·제목은 DFM `TWM_KANJUB` 그대로. 엔진이 받는 칸만 엶. * - 본문 = 서식(점선 라벨 + 오른쪽 금액) · 아래 형식 탭(일반형식만 켜짐) · 상태줄(직재·직노·산경·이윤). * - ⚠ 값은 서버(`/estimation/cost-sheet`)가 냄 — 여기는 그리기·입력만(지침 5장). * - 입력은 캐시(모듈 안)에만 쌓이고 [저장]에서 정본으로 감 · 자동저장 없음. * ========================================================================== */ 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"; import { injectSheetStyles, unconfirmedBadge } from "./B09_Estimation_UI_Sheet"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } interface SheetRow { key: string; mark: string; label: string; level: number; formula: string; rate_percent: string | null; amount_krw: string; note: string; total: boolean; } interface FieldOption { value: string; label: string; } interface CostSheetDto { status: string; message?: string; rows: SheetRow[]; status_line: Record; settings: Record; fields: { work_types: { key: string; label: string }[]; amounts: { key: string; label: string }[]; options: Record; }; waste_note: string; bill_missing_count: number; bill_unconfirmed_count: number; rate_version: { dataset_id: string; effective_date: string }; notes: string[]; } /** DFM 기준 입력판 첫 칸 「제잡비계산형식」 선택지 그대로. 지금은 「일반 형식」만 엶. */ const FORMS = [ "일반 형식", "토지개발공사 형식", "인천상수도 형식", "수자원공사 형식", "지역난방공사 형식", "고용개선지원(서울)", ]; /** DFM 본문 아래 탭 이름 그대로. */ const FORM_TABS = ["일반형식", "수공형식", "실적일반", "실적수공"]; const STYLE_ID = "b09-cost-sheet-styles"; function injectStyles(): void { if (document.getElementById(STYLE_ID)) return; const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = ` .b09cs { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } .b09cs__head { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; } .b09cs__title { font-weight: 600; } .b09cs__meta { font-size: 12px; color: var(--color-text-secondary); } .b09cs__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); } .b09cs__sheet { flex: 1; overflow: auto; min-height: 0; border: 1px solid var(--color-border); padding: 8px 12px; } .b09cs__line { display: flex; align-items: baseline; gap: 6px; font-size: 13px; line-height: 1.9; } .b09cs__mark { flex: 0 0 2.4em; text-align: right; } .b09cs__label { flex: 1; min-width: 0; display: flex; gap: 4px; overflow: hidden; white-space: nowrap; } .b09cs__label::after { content: ""; flex: 1; border-bottom: 1px dotted var(--color-border); margin-bottom: 4px; } .b09cs__formula { color: var(--color-text-secondary); font-size: 12px; overflow: hidden; text-overflow: ellipsis; } .b09cs__amount { flex: 0 0 11em; text-align: right; font-variant-numeric: tabular-nums; } .b09cs__line--total { font-weight: 600; } .b09cs__line--total .b09cs__amount { flex-basis: 12.5em; } .b09cs__tabs { display: flex; gap: 2px; border-top: 1px solid var(--color-border); padding-top: 4px; } .b09cs__tab { font-size: 12px; padding: 2px 10px; border: 1px solid var(--color-border); border-top: none; background: none; } .b09cs__tab[aria-selected="true"] { font-weight: 600; background: var(--color-surface, #fff); } .b09cs__status { display: flex; gap: 16px; font-size: 12px; font-variant-numeric: tabular-nums; } .b09cs__panel { display: flex; flex-direction: column; gap: 6px; } .b09cs__field { display: flex; flex-direction: column; gap: 2px; font-size: 12px; } .b09cs__field select, .b09cs__field input { width: 100%; } .b09cs__group { font-size: 12px; font-weight: 600; margin-top: 6px; } .b09cs__buttons { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } `; document.head.append(style); } function won(value: string): string { const n = Number(value); return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value; } 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; } /** 프로젝트별 입력 캐시 — 탭을 다시 골라도 [저장] 전 값이 남음. */ const drafts = new Map>(); async function fetchSheet(projectId: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost-sheet`, { credentials: "include" }, ); const body = (await response.json()) as CostSheetDto; if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); return body; } async function saveSheet(projectId: string, values: Record): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost-sheet`, { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(values), }, ); if (!response.ok) throw new Error(`HTTP ${response.status}`); } function selectField( label: string, value: string, options: { value: string; label: string; disabled?: boolean }[], onChange: (value: string) => void, ): HTMLElement { const wrap = el("label", "b09cs__field"); wrap.append(el("span", "", label)); const select = el("select"); for (const option of options) { const node = el("option", "", option.label); node.value = option.value; node.disabled = Boolean(option.disabled); select.append(node); } select.value = value; select.addEventListener("change", () => onChange(select.value)); wrap.append(select); return wrap; } function numberField(label: string, value: string, onInput: (value: string) => void): HTMLElement { const wrap = el("label", "b09cs__field"); wrap.append(el("span", "", label)); const input = el("input"); input.type = "number"; input.min = "0"; input.value = value; input.addEventListener("input", () => onInput(input.value)); wrap.append(input); return wrap; } function drawPanel(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void): void { const projectId = ctx.projectId as string; const draft = drafts.get(projectId) ?? Object.fromEntries(Object.entries(sheet.settings).map(([k, v]) => [k, String(v)])); drafts.set(projectId, draft); const box = el("div", "b09cs__panel"); box.append(el("div", "b09cs__group", "기준 입력")); box.append( selectField( "제잡비계산형식 :", FORMS[0], FORMS.map((name, i) => ({ value: name, label: i ? `${name} (준비 중)` : name, disabled: i > 0, })), () => undefined, ), ); const workType = (key: string) => sheet.fields.work_types.find((f) => f.key === key); const typeSelect = (key: string) => { const field = workType(key); if (!field) return; box.append( selectField(field.label + " :", draft[key] ?? "", sheet.fields.options[key] ?? [], (v) => { draft[key] = v; }), ); }; typeSelect("work_type_indirect_labor"); box.append( numberField("3.공사의기간(일) :", draft.duration_days ?? "", (v) => { draft.duration_days = v; }), ); for (const field of sheet.fields.amounts.slice(0, 2)) { box.append( numberField(field.label + " :", draft[field.key] ?? "", (v) => (draft[field.key] = v)), ); } typeSelect("work_type_safety"); typeSelect("environment_work_type"); typeSelect("equipment_guarantee_work_type"); box.append(el("div", "b09cs__group", "계산/인쇄 설정")); for (const field of sheet.fields.amounts.slice(2)) { box.append( numberField(field.label + " :", draft[field.key] ?? "", (v) => (draft[field.key] = v)), ); } const buttons = el("div", "b09cs__buttons"); buttons.append( createButton({ label: "제비율표", variant: "ghost", onClick: () => ctx.open("rate_table"), }), createButton({ label: "다시 계산", variant: "ghost", onClick: () => { drafts.delete(projectId); reload(); }, }), createButton({ label: "저장", onClick: async () => { try { await saveSheet(projectId, draft); drafts.delete(projectId); showToast("원가계산서 기준 저장", "success"); reload(); } catch (error) { showToast(error instanceof Error ? error.message : "저장 못 함", "error"); } }, }), ); box.append(buttons); ctx.panel.append(box); } function drawBody(ctx: B09TabContext, sheet: CostSheetDto): void { const wrap = el("div", "b09cs"); const head = el("div", "b09cs__head"); head.append( el("span", "b09cs__title", "제잡비 계산 — 일반형식"), el( "span", "b09cs__meta", `요율 판 ${sheet.rate_version.effective_date} · 직접비는 설계내역서 합계`, ), ); // 표현은 내역서·구조물도와 한 벌(「금액을 못 세운 줄 N건」 · 「미확정 N건」 배지). if (sheet.bill_missing_count) { head.append( el( "span", "b09cs__warn", `⚠ 내역서 ${L("B09_Sheet_Missing")} ${sheet.bill_missing_count}${L("B09_Sheet_Count")} — 직접비가 덜 섬`, ), ); } if (sheet.bill_unconfirmed_count) head.append(unconfirmedBadge(sheet.bill_unconfirmed_count)); if (sheet.waste_note) head.append(el("span", "b09cs__meta", `폐기물처리비: ${sheet.waste_note}`)); wrap.append(head); const body = el("div", "b09cs__sheet"); for (const row of sheet.rows) { const line = el("div", `b09cs__line${row.total ? " b09cs__line--total" : ""}`); line.style.paddingLeft = `${row.level * 1.2}em`; line.append(el("span", "b09cs__mark", row.mark)); const label = el("span", "b09cs__label"); label.append(el("span", "", row.label)); if (row.formula) label.append(el("span", "b09cs__formula", `<${row.formula}>`)); label.title = [row.formula, row.note].filter(Boolean).join(" · "); line.append(label, el("span", "b09cs__amount", won(row.amount_krw))); body.append(line); } wrap.append(body); const tabs = el("div", "b09cs__tabs"); FORM_TABS.forEach((name, i) => { const tab = el("button", "b09cs__tab", name); tab.setAttribute("aria-selected", String(i === 0)); tab.disabled = i > 0; if (i > 0) tab.title = "준비 중 — 일반형식부터 세움"; tabs.append(tab); }); wrap.append(tabs); const status = el("div", "b09cs__status"); for (const [name, value] of Object.entries(sheet.status_line)) { status.append(el("span", "", `${name}: ${won(value)}`)); } wrap.append(status); for (const note of sheet.notes) wrap.append(el("div", "b09cs__meta", note)); ctx.body.append(wrap); } function render(ctx: B09TabContext): void { injectStyles(); injectSheetStyles(); if (!ctx.projectId) { ctx.body.append(el("div", "b09cs__meta", "프로젝트를 고르세요")); return; } const load = (): void => { ctx.body.replaceChildren(el("div", "b09cs__meta", "원가계산서 계산 중…")); ctx.panel.replaceChildren(); fetchSheet(ctx.projectId as string) .then((sheet) => { ctx.body.replaceChildren(); drawPanel(ctx, sheet, load); drawBody(ctx, sheet); }) .catch((error: unknown) => { ctx.body.replaceChildren( el( "div", "b09cs__warn", `원가계산서를 세우지 못함 — ${error instanceof Error ? error.message : ""}`, ), ); }); }; load(); } export const costSheetTab: B09Tab = { key: "cost_sheet", label: () => L("B09_Estimation_Tab_CostSheet"), render, };