/* ============================================================================= * M01_MasterData_UI_LogicLab_Sheet.ts * M01 단가산출 로직 상세 「시트」 보기(PLAN 794-1 · 요구 650~652 · 요구서_652 1 장 6) — M02 단가산출 시트와 같은 꼴. * 한 줄 「이름 | 식 | 값 | 비목」(줄 꼴 = `logic/M01_Logic_Sheet_Model`) · 상태줄 = 지금 · 고치기 전 + [계산] * 식 글 = 서버 이름 꼴(`POST /api/m01/logic/sheet` · 줄이 바뀔 때만 · 옛 요청 끊음) * 고침 = 시스템 관리자만 — [식 고침] → 식 칸(공용 `sheet_formula_cell` · 칠 때마다 `/logic/sheet/check`) * → 되면 엔진 식을 줄 사본에(`onEdit`) → 상세 캐시(drafts) → 기존 M01 [저장](서버 검사 · 개정 확인) * 옵션 칸 = 시험 값(저장 안 함 · 고치면 시험 계산) · 자동저장 없음 · 부품 = calc_row · 입력 · 고르기 · 단추 · 배지 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createBadge } from "@ui/ui_template_badge"; import { createButton } from "@ui/ui_template_button"; import { createCalcFormula, createCalcSheetRow } from "@ui/ui_template_calc_row"; import { el } from "@ui/ui_template_elements"; import { createInputField, createSelectField } from "@ui/ui_template_input"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; import { createToolbar } from "@ui/ui_template_toolbar"; import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_formula_cell"; import { formatLineMoney, formatMoney } from "../common_util/common_util_money"; import type { CalcAnswer, LogicRow } from "./M01_MasterData_UI_Logic_Api"; import { formulaAt, labItems, setLabFormula, spotKey, type LabItem, type LabKind, type LabNames, type LabPart, } from "./logic/M01_Logic_Sheet_Model"; import "../M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.css"; const TEXT = { Sheet: ["시트", "Sheet"], Now: ["지금", "Now"], Before: ["고치기 전", "Before edit"], Run: ["계산", "Calculate"], 근거: ["근거", "Basis"], 옵션: ["옵션 · 시험 값", "Options · trial values"], 계산: ["변수 · 계산", "Variables"], 자원: ["자원", "Resources"], 율: ["율 · 덧줄", "Rates"], 단계: ["단계", "Stages"], 소계: ["소계 · 합계", "Subtotals"], Edit: ["식 고침", "Edit formula"], Close: ["식 닫기", "Close formula"], Back: ["원래로", "Revert"], Wait: ["[계산] 을 누르면 값이 채워짐", "Press Calculate to fill values"], Address: ["번지", "Address"], } as const satisfies Record; const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; const SECTION: Record = { 근거: "근거", 옵션: "옵션", 계산: "계산", 자원: "자원", 율: "율", 단계: "단계", 소계: "소계", 합계: "소계", }; const ORDER: (keyof typeof TEXT)[] = ["근거", "옵션", "계산", "자원", "율", "단계", "소계"]; const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 }); export interface LabSheetDraw { row: LogicRow; /** 저장본 줄(새 로직 = null) */ original: LogicRow | null; values: Record; answer: CalcAnswer | null; } export interface LabSheetOptions { /** 시스템 관리자 — 식 칸 · [식 고침] · [원래로] */ admin: boolean; onInput: (name: string, value: string) => void; /** 줄 식 칸을 고친 뒤(줄 객체는 여기서 고침) — 상세가 캐시(touch) · 다시 그림 */ edited: () => void; /** 시험 계산 [계산] 누름 */ run: () => void; } async function post(path: string, body: unknown, signal?: AbortSignal): Promise { const r = await fetch(`${API_BASE_URL}/m01${path}`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal, }); const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown }; if (!r.ok) throw new Error(typeof data.detail === "string" ? data.detail : r.statusText); return data; } export function createLabSheet(opt: LabSheetOptions) { const status = el("div", { className: "m02-sks__status", attrs: { "data-lab-status": "" } }); const body = el("div", { className: "m02-sks__body" }); const run = createButton({ label: t("Run"), variant: "ghost", size: 28, onClick: opt.run }); const root = el("div", { className: "m02-sks", attrs: { "data-lab-sheet": "" }, children: [createToolbar({ items: [run, "divider", status], label: t("Sheet") }), body], }); let last: LabSheetDraw | null = null; let names: LabNames | null = null; let asked = ""; let ctl: AbortController | null = null; let before: number | null = null; let beforeAt = ""; // 고치기 전 값을 잰 시험 값(옵션) — 옵션이 바뀌면 고친 줄의 「고치기 전」 은 모름 const open = new Set(); const drafts = new Map(); /** 줄이 바뀌면 이름 꼴 식 글을 다시 받음(옛 요청 끊음) */ const askNames = (row: LogicRow): void => { const text = JSON.stringify(row); if (text === asked) return; asked = text; ctl?.abort(); const mine = (ctl = new AbortController()); post("/logic/sheet", { row }, mine.signal).then( (got) => { if (mine !== ctl) return; names = got; if (last) paint(last); }, () => undefined, // 못 받으면 엔진 식 그대로 보임 ); }; const check = (row: LogicRow, part: LabPart, at: number) => (text: string, signal: AbortSignal): Promise => post("/logic/sheet/check", { row, 갈래: part, 차례: at, 식: text }, signal); const money = (it: LabItem): string => { const v = it.값; if (typeof v !== "number") return it.kind === "옵션" ? (it.input?.단위 ?? "") : String(v ?? ""); if (it.kind === "자원" || it.kind === "율") return formatLineMoney(v); return it.kind === "소계" || it.kind === "합계" ? formatMoney(v) : NUM.format(v); }; function optionCell(d: LabSheetDraw, it: LabItem): HTMLElement { const spec = it.input!; const value = d.values[spec.이름] ?? ""; if (spec.고르기?.length) { const sel = createSelectField({ options: ["", ...spec.고르기.map(String)].map((o) => ({ value: o, text: o })), value, compact: true, size: 24, ariaLabel: spec.이름, onChange: (v) => opt.onInput(spec.이름, v), }); sel.select.dataset.sheetCell = `opt|${spec.이름}`; return sel.root; } const box = createInputField({ value, compact: true, size: 24 }); box.input.setAttribute("aria-label", spec.이름); box.input.dataset.sheetCell = `opt|${spec.이름}`; box.input.addEventListener("change", () => opt.onInput(spec.이름, box.input.value.trim())); return box.root; } function tail(d: LabSheetDraw, it: LabItem): HTMLElement { const kids: HTMLElement[] = it.비목 ? [createBadge(it.비목, "neutral")] : []; if (it.번지) kids.push(createBadge(`${t("Address")} ${it.번지}`, "info")); if (opt.admin && it.part && it.at !== undefined) { const key = spotKey(it.part, it.at); const on = open.has(key); const toggle = createButton({ label: on ? t("Close") : t("Edit"), variant: "ghost", size: 22, onClick: () => { if (on) open.delete(key); else open.add(key); drafts.delete(key); paint(d); }, }); toggle.dataset.sheetFxToggle = key; kids.push(toggle); if (it.changed && d.original) { const { part, at } = it; const back = createButton({ label: t("Back"), variant: "ghost", size: 22, onClick: () => edit(d, part, at, null), }); back.dataset.sheetRevert = key; kids.push(back); } } return el("span", { children: kids }); } function rowOf(d: LabSheetDraw, it: LabItem): HTMLElement { const key = it.part && it.at !== undefined ? spotKey(it.part, it.at) : ""; const editing = opt.admin && key && open.has(key) && it.part && it.at !== undefined; const middle = it.kind === "옵션" ? optionCell(d, it) : el("div", { className: "m02-sks__mid", children: [ createCalcFormula(it.식 || (["근거", "소계", "합계"].includes(it.kind) ? "" : "—")), ...(editing ? [ createFormulaCell({ key, spot: key, shown: it.식, origin: it.식, drafts, check: check(d.row, it.part!, it.at!), put: (engine) => { if (engine === null) return; open.delete(key); edit(d, it.part!, it.at!, engine); }, }), ] : []), ], }); const value = el("span", { className: `ui-calc-row__out${it.kind === "소계" || it.kind === "합계" ? " is-key" : ""}`, text: money(it), }); const row = createCalcSheetRow(it.이름, middle, value, tail(d, it)); row.dataset.sheetKind = it.kind; if (key) row.dataset.sheetSpot = key; if (it.changed) row.classList.add("is-changed"); return row; } function drawStatus(d: LabSheetDraw): void { const ok = d.answer?.ok ? d.answer : null; const now = ok?.sums?.계 ?? null; const at = JSON.stringify(d.values); const same = !d.original || JSON.stringify(d.row) === JSON.stringify(d.original); if (same && now !== null) [before, beforeAt] = [now, at]; // 저장본 그대로 = 지금이 고치기 전 else if (!same && at !== beforeAt) before = null; const part = (k: string, label: string, v: number | null): HTMLElement => el("span", { className: "m02-sks__amount", attrs: { "data-lab-amount": k }, children: [createText(`${label} ${v == null ? "—" : formatMoney(v)}`, "muted", 14)], }); const diff = now !== null && before !== null && now !== before ? now - before : 0; status.replaceChildren( part("now", t("Now"), now), part("before", t("Before"), before), ...(diff ? [createBadge(`${diff > 0 ? "+" : "−"}${NUM.format(Math.abs(diff))}`, "warning")] : []), ); } function paint(d: LabSheetDraw): void { last = d; drawStatus(d); const items = labItems({ ...d, names }); const active = document.activeElement as HTMLElement | null; const keep = active && body.contains(active) ? active.dataset.sheetCell : ""; const typed = active instanceof HTMLInputElement && keep ? active.value : null; // 치던 글 지킴 const out: HTMLElement[] = []; for (const sec of ORDER) { const mine = items.filter((it) => SECTION[it.kind] === sec); if (!mine.length) continue; out.push( el("div", { className: "m02-sks__head", attrs: { "data-sheet-section": sec }, children: [createText(t(sec), "muted", 14)], }), ...mine.map((it) => rowOf(d, it)), ); } if (!d.answer) out.push(createText(t("Wait"), "muted")); body.replaceChildren(...out); const back = keep ? body.querySelector(`[data-sheet-cell="${CSS.escape(keep)}"]`) : null; if (back instanceof HTMLInputElement && typed !== null) back.value = typed; back?.focus(); } /** 식 칸 고침(엔진 식) · null = 저장본 식 → 상세 캐시 · 시험 계산 다시 */ function edit(d: LabSheetDraw, part: LabPart, at: number, engine: string | null): void { const text = engine ?? (d.original ? formulaAt(d.original, part, at) : null); if (text === null || !setLabFormula(d.row, part, at, text)) return; opt.edited(); opt.run(); } let answer: CalcAnswer | null = null; let answerOf: Record | null = null; // 답을 받은 로직(시험 값 그릇 = 연 로직마다 하나) let base: { src: string | null; row: LogicRow | null } = { src: null, row: null }; return { root, /** 시험 계산 답 — 상세 `buildCalc` onAnswer */ answer: (a: CalcAnswer) => void (answer = a), /** 상세가 그릴 때마다 — 줄 · 저장본 글(`original`) · 시험 값 그릇 */ draw: (row: LogicRow, original: string | null, values: Record) => { if (answerOf !== values) [answer, answerOf] = [null, values]; // 다른 로직 · 저장 뒤 if (base.src !== original) { base = { src: original, row: original ? (JSON.parse(original) as LogicRow) : null }; open.clear(); // 연 식 칸 · 치던 글 버림 drafts.clear(); } askNames(row); paint({ row, original: base.row, values, answer }); }, }; }