/* ============================================================================= * 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, type CalcShown } 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 { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail"; import { buildHo, lineOpener, 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 { baseHeads } from "./M02_MasterTemplete_Sangeun_BaseHead"; import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover"; import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick"; import { createSangeunSide, tabOf, type SourceTab } from "./M02_MasterTemplete_Sangeun_Side"; import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit"; import { dropBase, dropLine, goneRows, hoView, removedSubs, restoreLine, revisedAdds, rowsOf, setApart, setLineCover, sheetGroups, type CoverRow, type HoView, type SkAnswer, type SkDoc, } from "./logic/M02_Sangeun_Cover_Model"; import { resetQty, setQty, withFirst } from "./logic/M02_Sangeun_Undo"; export interface SangeunDetailOptions { layer: Layer; owner: string | null; readOnly: boolean; onChanged: (dirty: boolean) => void; /** 752-1 오른쪽 원문 패널 — 단가산출 화면만 켬(일위대가 줄 모달 안 상세는 끔) */ source?: boolean; } 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 okKey = ""; // 마지막으로 잘 받은 셈의 입력(문서 + 시험 값) — 같으면 [계산] 이 다시 안 셈 let fail = ""; let extra: LogicInput[] = []; // 더한줄 로직 입력 중 바탕에 없는 것 = 옵션 칸(725-6 · 727-3) const inputsOf = new Map(); const infoOf = new Map(); // 더한줄 로직 → 원문 탭(752-1) let extraKeys: string[] = []; const side = opt.source ? createSangeunSide() : null; 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.더한줄 ?? []), withFirst(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.더한줄 ?? []), withFirst({ ...line, 수량: qty } as unknown as NonNullable[number]), ]; touch(); } /** 첫 기초 = 바탕(옛 [기본 로직 선택] 동작 · 단위는 `loadBase` 가 기초 결과단위로) */ function setBase(key: string): void { doc.바탕 = { 로직: key }; delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음 touch(false); void loadBase(true); } const main = el("div", { className: "m02-sk", attrs: { "data-sangeun-detail": doc.키 ?? "" }, children: [ head, notes, el("div", { className: "m02-sk__head", children: [views.root, addButton] }), editor, ], }); host.replaceChildren(side ? side.wrap(main) : main); 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) => { inputsOf.set(k, r.logic.입력 ?? []); infoOf.set(k, tabOf(r.logic)); }, () => void inputsOf.set(k, []), ), ), ); extraKeys = keys; 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(); }; const inputKey = (): string => JSON.stringify([doc, values]); async function recalc(): Promise { const mine = ++seq; await syncExtra(); if (mine !== seq || !alive) return; const plain = { ...doc, 덮개: {}, 뺀줄: [], 더한줄: [] }; const key = inputKey(); try { const [got, was] = await Promise.all([ calcSangeun(scope, doc, values), doc.바탕 ? calcSangeun(scope, plain, values, false) : Promise.resolve(null), // 바탕 원값 = 보기 덧칸 없이 ]); if (mine !== seq || !alive) return; // 늦게 온 답 — 버림 [ans, bare, fail, okKey] = [got, was, "", key]; text = got.글 ?? null; // 750-2 텍스트 수식 · 시험 계산 = 이 답 하나(바탕 로직 따로 셈 없음) if (text?.ok) lastText = text; } catch (error) { if (mine !== seq || !alive) return; [ans, text, fail] = [null, 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(); 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), ...goneRows(doc), // 뺀 더한줄(744-1) ]; const row = base ? { ...base.logic, 입력: [...(base.logic.입력 ?? []), ...extra] } : standIn(); const view = ans ? hoView(ans, bare, base?.logic ?? null, doc) : null; const hoCtx = { row: view ? { ...row, 호표: view.호표, 덧줄: view.덧줄 } : row, prices: base?.prices ?? {}, lines: (view?.lines ?? null) as CalcLine[] | null, text, lastText, values, onInput, calcHost, edit: view && edit ? hoEdit(view, rows) : undefined, cids: view?.cids, pickMat: (idx: number) => pickMat(view?.cids[idx]), }; const ho = el("div", { attrs: { "data-sk-cover": "" }, children: [ ...removedStrip(removed, edit, (cid) => (restoreLine(doc, cid), touch())), ...baseHeads({ doc, ans, edit, onQty: (k, qty) => { const one = doc.더한줄?.[k]; if (one) setQty(one, qty * (ans?.화면배 ?? 1)); touch(); }, onReset: (k) => { const one = doc.더한줄?.[k]; if (one) resetQty(one); touch(); }, onDrop: (k, name) => (dropBase(doc, k, name), touch()), }), ...buildHo(hoCtx), ], }); 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, open: lineOpener(hoCtx) }, // 748-2 텍스트 수식 변수 · 값 누름 = 같은 줄 모달 }); drawCalc(); side?.set([ ...(base ? [tabOf(base.logic)] : []), ...extraKeys.flatMap((k) => (k === baseKey() ? [] : infoOf.has(k) ? [infoOf.get(k)!] : [])), ]); } /** 재료 고름(줄 모달 · 시험 계산 재료 칸) = SK 문서 `고름`(cid → MT) — 둘째 기초 세부 줄은 보기만 */ function pickMat(cid: string | undefined): ((it: { ref: string }) => void) | undefined { if (!cid || cid.startsWith("add:#")) return undefined; return (it) => { doc.고름 = { ...(doc.고름 ?? {}), [cid]: it.ref }; touch(); }; } /** 공용 상세 고침 고리 — 열쇠 = 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(); }, more: (idx, close) => { const r = at(idx); if (!r) return { head: [], body: [] }; const drop = createButton({ label: L("M02_SangeunDrop"), variant: "danger-ghost", size: 32, 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, ans?.줄.find((x) => x.cid === `add:#${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, ] : []; const rest = [...apart, ...whole]; return { head: [drop], body: rest.length ? [el("div", { className: "m02-sk__tags", children: rest })] : [], }; }, }; } /** 옵션 칸 · 텍스트 수식 · 계산서 입력 단추 — 입력 고침 → 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); } void recalc(); // 750-2 옵션 바꿈 = 서버 셈 한 번 — 네 보기가 그 답 하나로 다시 그려짐 } function drawCalc(): void { if (!base) { calcHost.replaceChildren(createText(L("M02_SangeunNoInputs"))); return; } const count = base.logic.호표?.length ?? 0; buildCalc(calcHost, { savedKey: base.logic.키, file: base.file, row: base.logic, prices: base.prices, dirty: () => false, values, answer: async () => { if (okKey !== inputKey()) await recalc(); // 칸을 고치고 [계산] — SK 셈 한 번 return shownOf(count); }, shown: shownOf(count), held: (i, it) => pickMat(bare?.정체[i])?.(it), }); } /** 시험 계산 칸에 그릴 것 = SK 셈 답(줄 · 기준 · 중간) */ function shownOf(count: number): CalcShown | null { if (!ans) return fail ? { answer: { ok: false, reason: fail } } : null; const by = new Map(ans.줄.map((l) => [l.cid, l as unknown as CalcLine])); return { answer: { ok: true, lines: ans.줄 as unknown as CalcLine[], // 못 셈 줄 = 금액 「—」(계에 안 듦) sums: ans.기준 ?? undefined, middle: ans.중간 ?? {}, }, items: (bare?.정체 ?? []).slice(0, count).map((cid) => by.get(cid)), }; } /** `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) 먼저 — 시험 계산 칸 항목(재료 잡힌 품목)이 쓰임 } 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(); }, }; }