/* ============================================================================= * M02_MasterTemplete_Labor_SangeunModal.ts * 일위대가 줄 [단가산출 보기] 모달(PLAN 717-3 · 요구서 내역12 4장 4 · 16 · 24 · 4-1 확정 5) — * 로직 줄 · 단가산출(SK) 참조 줄 하나의 단가산출을 보고 그 일위대가에서만 고침. * · 위 = 일위대가 머리(「1 ㎡ 당」) · 이 줄 수량 · 1 단위 금액 · 짝 잃은 덮개 알림 * · 보기 고르기 = 인력·재료·경비(기본 · 공용 계산서 `calc_sheet`) | 단가산출 상세(품 · 단가 칸 고침 · `Sangeun_Cover`) * · 고침 = 그 일위대가 줄 칸 `패치 {덮개, 뺀줄, 더한줄}` · [적용] = 화면 캐시(저장은 페이지 [저장] · * 원값은 저장 때 서버가 셈) · 적용 안 하고 닫으면 물음 * · [단가산출로 저장] = 개인 층 새 SK(이름 받음 · `from-line`) · 일위대가가 자기 개인 층이면 그 줄을 새 SK 참조 줄로 * · 셈 = 서버 셈 길(로그인 · 저장 안 함 · 최신 응답만 · CLAUDE.md 5장) * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { askConfirm, askText, openModal } from "@ui/ui_template_modal"; import { createSegment } from "@ui/ui_template_segment"; import { createText } from "@ui/ui_template_text"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { calcSheetRows } from "../A00_Common/calc_sheet"; import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; import type { LaborRow } from "./M02_MasterTemplete_Labor_Api"; import { calcSangeun, coverTable, sangeunFromLine, type SkScope, } from "./M02_MasterTemplete_Sangeun_Cover"; import { cleanPatch, lineDoc, needsIds, setCover, sheetGroups, type LinePatch, type SkCalc, type SkLine, } from "./M02_Sangeun_Cover_Model"; const TEXT = { Title: ["단가산출", "Unit-price build-up"], Sheet: ["인력·재료·경비", "Labor · material · expense"], Detail: ["단가산출 상세", "Detail"], Qty: ["이 줄 수량", "Line quantity"], Per: ["1 {u} 당", "per 1 {u}"], Sum: ["계", "Total"], Name: ["이름", "Name"], Poom: ["품 · 수량", "Qty"], Unit: ["단위", "Unit"], Price: ["단가", "Unit price"], Amount: ["금액", "Amount"], Was: ["원래", "was"], Tag: ["이 일위대가에서 고침", "Edited in this unit price"], Empty: ["줄 없음", "No lines"], Loading: ["셈하는 중…", "Calculating…"], Lost: ["덮개 짝 잃음", "Override lost"], Apply: ["적용", "Apply"], ToSk: ["단가산출로 저장", "Save as unit-price build-up"], AskName: ["새 단가산출 이름(내 개인 층)", "New build-up name (my layer)"], Saved: ["{k} 저장함(내 개인 층)", "{k} saved (my layer)"], Replaced: ["이 줄을 {k} 참조로 바꿈 — [저장]으로 남김", "Line now refers to {k} — press [Save]"], Discard: ["적용 안 한 고침이 있음 — 버리고 닫을까?", "Discard unapplied edits?"], Close: ["닫기", "Close"], ReadOnly: ["읽기만 — 고친 값은 [단가산출로 저장]으로만 남김", "Read only — use [Save as]"], } as const satisfies Record; const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; export interface RowSangeunOptions { /** 그 일위대가 층(셈 길 · 참조 위 층 검사) */ scope: SkScope; row: LaborRow; /** 줄 이름(로직 키 · 이름) */ title: string; /** 일위대가 머리(이름 · 「1 ㎡ 당」) */ head: string; /** 이 줄 지금 수량 글(「수기 5 ㎥」) */ qty: string; /** 줄 결과 단위(분모 · SK 단위 기본) */ unit: string; readOnly: boolean; /** [적용] — 그 줄 패치(null = 고침 없음) */ apply: (patch: LinePatch | null) => void; /** [단가산출로 저장] 뒤 그 줄을 새 SK 참조 줄로(없으면 안 바꿈) */ replace?: (row: LaborRow) => void; } const same = (a: LinePatch | null, b: LinePatch | null): boolean => JSON.stringify(a) === JSON.stringify(b); export function openRowSangeun(o: RowSangeunOptions): void { let applied = cleanPatch(o.row.패치); let patch = applied; let ids: string[] | null = null; let orig = new Map(); let calc: SkCalc | null = null; let fail: string | null = null; let view = "sheet"; let seq = 0; let timer = 0; const info = el("div", { className: "m02-std-sheet__body", attrs: { "data-sk-head": "" } }); const main = el("div", { className: "m02-std-sheet__body", attrs: { "data-sk-body": "" } }); const seg = createSegment({ options: [ { value: "sheet", label: t("Sheet") }, { value: "detail", label: t("Detail") }, ], value: view, ariaLabel: t("Title"), onChange: (v) => { view = v; draw(); }, }); const draw = (): void => { const unit = o.unit || "식"; const one = calc?.["1단위"]?.계; info.replaceChildren( createText(o.head, "muted", 14), createText(`${o.title} · ${t("Qty")} ${o.qty}`, "muted", 14), ...(one != null ? [createText(`${t("Per").replace("{u}", unit)} ${formatMoney(one)}`)] : []), ...(o.readOnly ? [createText(t("ReadOnly"), "hint")] : []), ...(calc?.짝잃음 ?? []).map((x) => createText(`${t("Lost")} ${x.cid} · ${x.까닭}`, "warning"), ), ); if (!calc) return void main.replaceChildren(createText(fail ?? t("Loading"), fail ? "error" : "muted")); const base = calc.배 && calc.배 !== 1 ? `${calc.배} ${unit}` : `1 ${unit}`; main.replaceChildren( ...(fail ? [createText(fail, "error")] : []), ...(view === "sheet" ? calcSheetRows({ 묶음: sheetGroups(calc), 계: calc.기준?.계 ?? null, sumLabel: `${t("Sum")} (${base})`, }) : [ coverTable(calc, orig, patch, { edit: true, text: { name: t("Name"), qty: t("Poom"), unit: t("Unit"), price: t("Price"), amount: t("Amount"), was: t("Was"), tag: t("Tag"), empty: t("Empty"), }, onCover: (cid, key, value) => { patch = setCover(patch, cid, key, value); recalc(); }, }), ]), ); applyBtn.disabled = o.readOnly || same(patch, applied); }; /** 서버 셈 — 늦게 온 답은 버림 */ const recalc = (): void => { window.clearTimeout(timer); timer = window.setTimeout(async () => { const mine = ++seq; try { const got = await calcSangeun(o.scope, lineDoc(o.row, ids, patch)); if (mine !== seq) return; calc = got; fail = null; } catch (error) { if (mine !== seq) return; fail = error instanceof Error ? error.message : String(error); } draw(); }, 150); }; let close = (): void => {}; const applyBtn = createButton({ label: t("Apply"), variant: "filled", onClick: () => { o.apply(patch); applied = patch; close(); }, }); applyBtn.dataset.skApply = ""; const saveBtn = createButton({ label: t("ToSk"), variant: "ghost", onClick: async () => { const name = (await askText({ message: t("AskName"), value: o.title }))?.trim(); if (!name) return; const line: Record = { ...o.row }; delete line.패치; if (patch) line.패치 = patch; try { const got = await sangeunFromLine( { layer: "personal", owner: null }, line, name, o.unit || "식", ); showToast(t("Saved").replace("{k}", got.이름), "success"); if (!o.replace) return; o.replace({ 로직: "", 입력값: {}, 메모: null, 별칭: null, 수량: o.row.수량, ...got.줄 }); showToast(t("Replaced").replace("{k}", got.이름), "info"); applied = patch; close(); } catch (error) { showToast(error instanceof Error ? error.message : String(error), "error"); } }, }); saveBtn.dataset.skSave = ""; ({ close } = openModal({ title: `${t("Title")} — ${o.title}`, closeLabel: t("Close"), width: "1100px", wide: true, actions: [seg.root, saveBtn, applyBtn], beforeClose: () => same(patch, applied) || askConfirm({ message: t("Discard"), danger: true }), mount: (body) => body.append(info, main), })); draw(); // 원래 값(그 일위대가 고침 없음) 먼저 — 고름 · 자재표시가 있으면 바탕 줄 정체부터 void (async () => { try { if (needsIds(o.row)) ids = (await calcSangeun(o.scope, lineDoc(o.row, null, null))).정체 ?? null; const base = await calcSangeun(o.scope, lineDoc(o.row, ids, null)); orig = new Map(base.줄.map((x) => [x.cid, x])); if (patch) return recalc(); calc = base; } catch (error) { fail = error instanceof Error ? error.message : String(error); } draw(); })(); }