/* ============================================================================= * M02_MasterTemplete_Sangeun_Detail.ts * M02 단가산출(SK) 상세(PLAN 717-2b · 724-1 · 요구서 4장 3 · 4 · 13 · 17 · 24) — M01 「단가산출 로직」 상세 부품을 M02 길로. * 보기 넷(`LogicLab_View`) = 단가산출 상세(공용 상세 `LogicLab_Ho` · SK 셈 답 줄 = `hoView` · 줄 모달 그 자리 고침 = * HoEdit → SK 덮개 · 더한줄 · 줄 빼기 · 별도계상) · 인력·재료·경비(공용 계산서 · SK 셈 답) · * 시험 계산(`Logic_Calc` · 바탕 M01 로직 + 덮개 품 · 단가 사본) · 텍스트 수식(같은 사본 식) · 기본정보 = 바탕 로직(맨 위 접힘) * 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만 * 옵션 칸 = SK `입력값`(727-3 · 저장 · 열린입력만 시험 값) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판) * 둘째 기초(727-2) = 서버 `펼침` 세부 줄 · 인력 · 재료 · 경비 상자에 함께 + 꼬리표(기초 둘 이상) · 세부 덮개 · * 뺀줄 · [기초 빼기] · M01 개정 알림 · 옵션 칸 = 바탕 입력 + 더한줄 기초 입력 * [항목 추가] 기초 = 바탕 비었으면 바탕(727-2 · 단위가 비거나 「식」이면 기초 결과단위) · 있으면 더한줄 · 바탕 없이 줄만도 됨 * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; import { createBanner } from "@ui/ui_template_banner"; import { createButton } from "@ui/ui_template_button"; import { createCheckbox } from "@ui/ui_template_checkbox"; import { el } from "@ui/ui_template_elements"; import { createInputField } from "@ui/ui_template_input"; import { t as L } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; import { calcSheetRows } from "../A00_Common/calc_sheet"; import { swapUnit } from "../A00_Common/master_unit_swap"; import { buildCalc } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc"; import { fetchLogic, setLogicApiPaths, type CalcLine, type LogicInput, type LogicOne, type LogicRow, type TextAnswer, } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { applyCovers } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Cover"; import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail"; import { buildHo, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho"; import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_View"; import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover"; import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick"; import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit"; import { baseCovers, dropBase, dropLine, dropText, hoView, removedSubs, restoreLine, revisedAdds, rowsOf, setApart, setLineCover, sheetGroups, type CoverRow, type HoView, type SkAnswer, type SkDoc, } from "./logic/M02_Sangeun_Cover_Model"; export interface SangeunDetailOptions { layer: Layer; owner: string | null; readOnly: boolean; onChanged: (dirty: boolean) => void; } const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" }; const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; export function mountSangeunDetail( host: HTMLElement, doc0: unknown, opt: SangeunDetailOptions, ): { getDoc: () => SkDoc; getValues: () => Record; destroy: () => void } { setLogicApiPaths(LIB); // 상세 부품 길 = M02 로그인 길(717-1) const doc = clone((doc0 ?? {}) as SkDoc); let saved = JSON.stringify(doc); const edit = !opt.readOnly; const scope = { layer: opt.layer, owner: opt.owner }; const values: Record = Object.fromEntries( Object.entries(doc.입력값 ?? {}).map(([k, v]) => [k, String(v)]), ); let base: LogicOne | null = null; let text: TextAnswer | null = null; let lastText: TextAnswer | undefined; let ans: SkAnswer | null = null; let bare: SkAnswer | null = null; let fail = ""; let extra: LogicInput[] = []; // 더한줄 로직 입력 중 바탕에 없는 것 = 옵션 칸(725-6 · 727-3) const inputsOf = new Map(); let seq = 0; let alive = true; const head = el("div", { className: "m02-sk__head", attrs: { "data-sk-head": "" } }); const notes = el("div"); const editor = el("div", { className: "m01-logic__editor" }); const views = createViewPicker(editor); const calcHost = el("div", { className: "m01lab__calc" }); const ownHost = el("div"); const addButton = createButton({ label: L("M02_SangeunAddLine"), variant: "ghost", size: 28, onClick: () => openSangeunLinePicker(scope, (line) => { if ("종류" in line && line.종류 === "로직" && !doc.바탕) return setBase(line.로직); if ("종류" in line && line.종류 === "로직") return void addBase(line); doc.더한줄 = [ ...(doc.더한줄 ?? []), line as unknown as NonNullable[number], ]; touch(); }), }); addButton.hidden = !edit; /** 둘째 기초(728-2) — 수량 처음값 = 바탕 기준단위 1 개 = 그 기초 기준단위 몇 개(같은 성격 · 다르면 1) */ async function addBase(line: { 로직: string; 수량: number }): Promise { let qty = 1; const have = base ? baseUnitOf(base.logic.결과단위) : ""; try { const swap = have ? swapUnit(have, baseUnitOf((await fetchLogic(line.로직)).logic.결과단위)) : null; if (swap?.배 != null && swap.계수 === null) qty = swap.배; } catch { /* 못 읽음 = 1 그대로 */ } if (!alive) return; doc.더한줄 = [ ...(doc.더한줄 ?? []), { ...line, 수량: qty } as unknown as NonNullable[number], ]; touch(); } /** 첫 기초 = 바탕(옛 [기본 로직 선택] 동작 · 단위는 `loadBase` 가 기초 결과단위로) */ function setBase(key: string): void { doc.바탕 = { 로직: key }; delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음 touch(false); void loadBase(true); } const baseHint = createText(L("M02_SangeunBaseViews")); host.replaceChildren( el("div", { className: "m02-sk", attrs: { "data-sangeun-detail": doc.키 ?? "" }, children: [ head, notes, el("div", { className: "m02-sk__head", children: [views.root, addButton, baseHint] }), editor, ], }), ); const opened = (): boolean => (doc.열린입력 ?? []).length > 0; // 바탕 없는 SK 의 지문 = 이름 · 단위 따라 바뀜 — 바탕이 있을 때만 M01 개정으로 봄(725-3) · 둘째 기초 지문(727-2) const revisedBase = (): boolean => !!(doc.바탕 && doc.바탕판 && ans?.바탕판 && doc.바탕판 !== ans.바탕판) || revisedAdds(ans, doc).length > 0; const baseKey = (): string | null => doc.바탕?.로직 ?? null; /** 바탕 없는 · 참조 바탕 SK 의 기본정보 자리 줄 */ const standIn = (): LogicRow => ({ 키: doc.키 ?? "", 원문번호: "", 이름: doc.이름 ?? "", 결과단위: `원/${doc.단위 ?? ""}`, 출처: doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase"), 입력: extra, 중간: [], 호표: [], 덧줄: [], }); /** 더한줄 로직들의 입력을 모음(같은 이름은 하나 · 바탕 입력에 있는 이름 뺌) */ async function syncExtra(): Promise { const keys = [ ...new Set((doc.더한줄 ?? []).flatMap((x) => ("로직" in x ? [String(x.로직)] : []))), ]; await Promise.all( keys .filter((k) => !inputsOf.has(k)) .map((k) => fetchLogic(k).then( (r) => void inputsOf.set(k, r.logic.입력 ?? []), () => void inputsOf.set(k, []), ), ), ); const seen = new Set((base?.logic.입력 ?? []).map((i) => i.이름)); extra = keys .flatMap((k) => inputsOf.get(k) ?? []) .filter((i) => !seen.has(i.이름) && seen.add(i.이름)); } const touch = (again = true): void => { opt.onChanged(edit && JSON.stringify(doc) !== saved); if (again) void recalc(); else drawHead(); }; async function recalc(): Promise { const mine = ++seq; await syncExtra(); if (mine !== seq || !alive) return; const plain = { ...doc, 덮개: {}, 뺀줄: [], 더한줄: [] }; try { const [got, was] = await Promise.all([ calcSangeun(scope, doc, values), doc.바탕 ? calcSangeun(scope, plain, values) : Promise.resolve(null), ]); if (mine !== seq || !alive) return; // 늦게 온 답 — 버림 [ans, bare, fail] = [got, was, ""]; } catch (error) { if (mine !== seq || !alive) return; [ans, fail] = [null, error instanceof Error ? error.message : String(error)]; } draw(); } function drawHead(): void { const field = (label: string, key: "이름" | "단위"): HTMLElement => { const box = createInputField({ label, value: doc[key] ?? "", compact: true, size: 28 }); box.input.dataset.sk = key; box.input.disabled = !edit; box.input.addEventListener("change", () => { doc[key] = box.input.value.trim(); box.setError(doc[key] ? undefined : L("M02_SangeunNeed")); touch(key === "단위"); }); return box.root; }; const baseName = base ? `${base.logic.키} ${base.logic.이름}` : null; const baseText = baseName ?? doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase"); const revised = revisedBase(); const swap = base ? buildUnitSwap({ result: base.logic.결과단위, unit: doc.단위 ?? "", how: doc.환산 as SwapHow | undefined, edit, onChange: (how) => { if (how) doc.환산 = how; else delete doc.환산; touch(); }, }) : null; head.replaceChildren( field(L("M02_Name"), "이름"), field(L("M02_SangeunUnit"), "단위"), ...(swap ? [swap] : []), el("div", { className: "m02-sk__tags", attrs: { "data-sk-base": baseKey() ?? "" }, children: [ createText(`${L("M02_SangeunBase")} · ${baseText}`, "muted", 14), ...(ans?.머리 ? [createBadge(ans.머리, "info")] : []), ...(revised ? [createBadge(L("M02_SangeunRevisedBase"), "danger")] : []), ...(opened() ? [createBadge(L("M02_SangeunScaleOnly"), "warning")] : []), ], }), ); } function drawNotes(): void { const lost = ans?.짝잃음 ?? []; const gaps = ans?.빠진줄 ?? []; const revised = revisedBase(); notes.replaceChildren( ...(fail || ans?.까닭 ? [createBanner({ tone: "danger", text: fail || String(ans?.까닭) })] : []), ...(revised ? [createBanner({ tone: "warning", text: L("M02_SangeunRevisedNote") })] : []), ...(gaps.length ? [ createBanner({ tone: "warning", text: `${L("M02_SangeunGap")} — ${gaps.map((x) => x.이름 || x.cid).join(" · ")}`, details: gaps.map((x) => `${x.이름 || x.cid} — ${x.까닭}`).join("\n"), }), ] : []), ...(lost.length ? [ createBanner({ tone: "warning", text: L("M02_SangeunLost"), details: lost.map((x) => `${x.cid} — ${x.까닭}`).join("\n"), }), ] : []), ); } function draw(): void { if (!alive) return; drawHead(); drawNotes(); baseHint.hidden = !base; const rows = ans ? rowsOf(ans, bare, doc) : []; const names = new Map((bare ? rowsOf(bare, null, {}) : []).map((r) => [r.cid, r.이름])); const removed = [ ...(doc.뺀줄 ?? []).map((cid) => ({ cid, 이름: names.get(cid) ?? cid })), ...removedSubs(ans), ]; const row = base ? { ...base.logic, 입력: [...(base.logic.입력 ?? []), ...extra] } : standIn(); const view = ans ? hoView(ans, bare, base?.logic ?? null, doc) : null; const baseNames = [...(row.호표 ?? []), ...(row.덧줄 ?? [])].map((x) => String(x.이름 ?? ("요소" in x ? x.요소 : "")), ); const cut = (t: T): T => (t ? dropText(t, baseNames, bare, doc) : t) as T; const ho = el("div", { attrs: { "data-sk-cover": "" }, children: [ ...removedStrip(removed, edit, (cid) => (restoreLine(doc, cid), touch())), ...buildHo({ row: view ? { ...row, 호표: view.호표, 덧줄: view.덧줄 } : row, prices: base?.prices ?? {}, lines: (view?.lines ?? null) as CalcLine[] | null, text: cut(text ?? undefined) ?? null, lastText: cut(lastText), values, onInput, calcHost, edit: view && edit ? hoEdit(view, rows) : undefined, }), ], }); const est = el("div", { attrs: { "data-est": "" }, children: ans ? calcSheetRows({ 묶음: sheetGroups(rows), 계: ans.기준?.계 ?? null, sumLabel: `${L("M02_SangeunTotal")} (${doc.단위 && doc.단위 !== "식" ? `원/${doc.단위}` : row.결과단위 || "원"})`, bundle: true, inputs: row.입력, values, onInput, }) : [createText(L("M02_SangeunWait"))], }); buildLabDetail(editor, { row: base?.logic ?? row, file: base?.file ?? "", reasons: base?.reasons ?? [], prices: base?.prices ?? {}, lines: null, text, lastText, values, ownHost, onChange: () => undefined, calcHost, onInput, cover: { ho, est }, }); } /** 공용 상세 고침 고리 — 열쇠 = Ho 줄 차례 → cid · 고침 = SK 덮개 · 더한줄(M01 안 바뀜 · 내역 17-2) · * 모달 덧 동작 = 줄 빼기 · 더한 재료 별도계상 */ function hoEdit(view: HoView, rows: CoverRow[]): HoEdit { const by = new Map(rows.map((r) => [r.cid, r])); const at = (idx: number): CoverRow | undefined => by.get(view.cids[idx]); return { covers: view.covers, scope: "m02", set: (idx, c) => { const r = at(idx); if (!r) return; setLineCover(doc, r, c, opened()); touch(); if (base) rerunCalc(); // 시험 계산 · 텍스트 수식 = 덮개 사본으로 }, more: (idx, close) => { const r = at(idx); if (!r) return []; const drop = createButton({ label: L("M02_SangeunDrop"), variant: "danger-ghost", size: 28, onClick: () => (close(), dropLine(doc, r), touch()), }); drop.dataset.skDrop = ""; const sub = r.sub; const whole = sub ? [ createButton({ label: L("M02_SangeunDropBase"), variant: "danger-ghost", size: 28, onClick: () => (close(), dropBase(doc, sub.k), touch()), }), ] : []; const apart = r.apartPick ? [ createCheckbox(L("M02_SangeunApart"), { chip: true, size: 28, checked: r.apart, onChange: (on) => (close(), setApart(doc, r, on), touch()), }).root, ] : []; return [el("div", { className: "m02-sk__tags", children: [...apart, drop, ...whole] })]; }, }; } function rerunCalc(): void { drawCalc(); calcHost.querySelector("[data-calc-run]")?.click(); } /** 옵션 칸 · 텍스트 수식 · 계산서 입력 단추 — 입력 고침 → SK `입력값`(727-3 · 열린입력 = 시험 값만) · * 시험 계산 다시 그림 · 셈 */ function onInput(name: string, v: string): void { values[name] = v; if (edit && !(doc.열린입력 ?? []).includes(name)) { const kept = { ...(doc.입력값 ?? {}) }; if (String(v).trim()) kept[name] = v; else delete kept[name]; doc.입력값 = kept; touch(false); } drawCalc(); calcHost.querySelector("[data-calc-run]")?.click(); if (!base) void recalc(); // 바탕 없음 — 시험 계산 단추 없음 · SK 셈을 바로 } function drawCalc(): void { if (!base) { calcHost.replaceChildren(createText(L("M02_SangeunNoInputs"))); return; } const covers = baseCovers(bare, base.logic.호표?.length ?? 0, doc); const covered = Object.keys(covers).length > 0; buildCalc(calcHost, { savedKey: base.logic.키, file: base.file, row: covered ? applyCovers(base.logic, covers) : base.logic, prices: base.prices, dirty: () => covered, // 덮개 사본 = 저장 안 한 줄로 셈(메모리만) values, onText: (t) => { text = t; if (t.ok) lastText = t; draw(); }, onLines: () => void recalc(), // 시험 입력이 바뀜 — SK 도 같은 입력으로 }); } /** `fresh` = 막 넣은 바탕 — 단위가 비거나 「식」이면 기초 기준단위(「100본」 · 바꾸면 서버가 환산 · 728-2) */ async function loadBase(fresh = false): Promise { const key = baseKey(); base = null; text = null; if (key) { try { base = await fetchLogic(key); } catch (error) { fail = error instanceof Error ? error.message : String(error); } } if (!alive) return; if (fresh && base && (!doc.단위 || doc.단위 === "식")) { doc.단위 = baseUnitOf(base.logic.결과단위) || doc.단위; touch(false); } draw(); await recalc(); // 바탕 정체(bare) 먼저 — 시험 계산 · 텍스트 수식 = 덮개 사본 if (alive) drawCalc(); } void loadBase(); return { getDoc: () => { if (ans?.바탕판 && doc.바탕) doc.바탕판 = ans.바탕판; // 저장 = 지금 바탕 확인(서버가 다시 적음) for (const l of ans?.줄 ?? []) { const one = l.바탕판 ? doc.더한줄?.[Number(/^add:#(\d+)$/.exec(l.cid)?.[1] ?? -1)] : undefined; if (one) one.바탕판 = l.바탕판; // 둘째 기초도 같음(727-2) } saved = JSON.stringify(doc); return clone(doc); }, getValues: () => ({ ...values }), // 723-3 — 일위대가 줄 모달 [적용] = 옵션을 그 줄에 남김 destroy: () => { alive = false; host.replaceChildren(); }, }; }