/* ============================================================================= * 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 }; } /** 머리 한 줄짜리 표(목록표·집계표) — 실무 시트 칸 이름 그대로 넘김. */ export function plainTable(headers: string[]): { wrap: HTMLElement; tbody: HTMLElement } { const thead = el("thead"); const tr = el("tr"); for (const label of headers) tr.append(el("th", "", label)); thead.append(tr); return sheetTable(thead); } /** 한 탭 안의 갈래 고르개(재료비·노무비…) — 고른 갈래를 돌려줌. */ export function segmented( items: Array<[string, string]>, active: string, onPick: (key: string) => void, ): HTMLElement { const bar = el("div", "b09s-bar"); for (const [key, label] of items) { const button = el("button", `b09s-tab${key === active ? " is-active" : ""}`, label); button.type = "button"; button.addEventListener("click", () => onPick(key)); bar.append(button); } return bar; } /** 누르면 들어가는 글 — 「제 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; } .b09s-head { font-weight:600; margin-top:6px; } .b09s-info td { text-align:right; font-variant-numeric:tabular-nums; } .b09s-info td.b09s-left, .b09s-info th.b09s-left { text-align:left; white-space:normal; } .b09s-group { display:flex; flex-direction:column; gap:4px; border-top:1px solid var(--ui-border, #d0d4dc); padding-top:6px; } .b09s-inline { display:flex; align-items:center; gap:8px; flex-wrap:wrap; } .b09s-table td.b09s-adopted { font-weight:700; color:var(--ui-accent, #2f6fed); background:rgba(47,111,237,0.08); } .b09s-min { display:inline-block; margin-left:4px; font-size:10px; color:#1e8e3e; border:1px solid #1e8e3e; border-radius:8px; padding:0 4px; } `; document.head.append(style); }