/* ============================================================================= * M02_MasterTemplete_Sangeun_SheetFormula.ts * 단가산출(SK) 시트 식 칸(PLAN 792 · E4 · _분석_650-H3 2 장 「식 고침」) — 시트(`_Sangeun_Sheet`)가 부름. * 식 칸 = 이름 꼴 글(`[노임 보통인부] × 무게 / 운반량`) · 칠 때마다 검사 길(`formula-check` · 옛 요청 끊기) * → 틀리면 빨간 칸 + 까닭 한 줄 · 되면 바꿀 때(Enter · 떠남) 엔진 식을 문서에(`setFormula`) → 서버 셈 * 변수 줄 = 이름 · 식 · 값 · [+ 변수 줄] · 빼기(`addVar` · `editVar` · `dropVar`) * 빈 칸 · M01 식 그대로 = 식 덮개 풂 · 같은 자리 상수 · 표값 · 품 덮개는 식 덮개와 같이 못 둠 → 걷고 넣음 * 문서 고치기 = `logic/M02_Sangeun_Formula_Model`(792-1 짝) · 금액은 늘 서버 셈 · 자동저장 없음 * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; import { createButton } from "@ui/ui_template_button"; import { createCalcSheetRow } from "@ui/ui_template_calc_row"; import { el } from "@ui/ui_template_elements"; import { createInputField } from "@ui/ui_template_input"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import type { EditCtx } from "./M02_MasterTemplete_Sangeun_SheetEdit"; import { addVar, dropVar, editVar, setFormula, type FormulaDoc, type VarLine, } from "./logic/M02_Sangeun_Formula_Model"; import { num, type SpotInfo } from "./logic/M02_Sangeun_Sheet_Model"; const TEXT = { Formula: ["식", "Formula"], Edit: ["식 고침", "Edit formula"], Close: ["식 닫기", "Close formula"], Hint: ["예) 무게 / 운반량 × (1 + 할증율 / 100)", "e.g. weight / load × 1.2"], Var: ["변수", "Variable"], VarName: ["변수 이름", "Variable name"], AddVar: ["+ 변수 줄", "+ Variable"], DropVar: ["변수 빼기", "Remove variable"], Bad: ["식이 틀려 안 넣음", "Invalid formula — not applied"], Checking: ["검사 중", "Checking…"], } as const satisfies Record; const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; export interface CheckAnswer { 되나: boolean; 까닭: string[]; 알림: string[]; /** 엔진 식(문서에 넣을 것) */ 식: string; /** 이름 꼴 글 */ 글: string; } export interface FormulaCtx extends EditCtx { scope: { layer: Layer; owner: string | null }; /** 식 칸 연 자리(화면 캐시) */ open: Set; /** 치는 중 글(다시 그려도 안 잃음) — 넣으면 지움 */ drafts: Map; redraw: () => void; } const q = (c: FormulaCtx): string => c.scope.owner && c.scope.layer !== "system" ? `?owner=${encodeURIComponent(c.scope.owner)}` : ""; /** 검사 길(792-1) — 셈 · 저장 없음 */ export const checkFormula = ( c: FormulaCtx, spot: string, text: string, signal?: AbortSignal, ): Promise => call(`/layers/${c.scope.layer}/sangeun/formula-check${q(c)}`, { method: "POST", body: JSON.stringify({ 문서: c.doc, 자리: spot, 식: text }), signal, }); /** 식 덮개와 같이 못 두는 같은 자리 상수 · 표값 · 품 덮개 걷음(배율은 둠) */ export function clearNums(doc: T, spot: string): T { const out: FormulaDoc = { ...doc }; for (const name of ["상수덮개", "표값덮개"] as const) { const all = { ...(doc[name] ?? {}) }; delete all[spot]; if (Object.keys(all).length) out[name] = all; else delete out[name]; } const one = doc.덮개?.[spot]; if (one && "qty" in one) { const rest = { ...one }; delete rest.qty; const covers = { ...doc.덮개 }; if (Object.keys(rest).length) covers[spot] = rest; else delete covers[spot]; out.덮개 = covers; } return out as T; } /** 식 칸 — 칠 때마다 검사(앞 요청 끊음) · 바꿀 때 넣음 · `put(엔진 식 | null)` */ function formulaBox( c: FormulaCtx, spot: string, key: string, shown: string, origin: string, put: (engine: string | null) => void, ): HTMLElement { const box = createInputField({ value: c.drafts.get(key) ?? shown, compact: true, size: 24, placeholder: t("Hint"), }); box.input.setAttribute("aria-label", t("Formula")); box.input.dataset.sheetCell = key; box.input.spellcheck = false; const note = createText("", "muted", 12); note.dataset.sheetCheck = key; let timer = 0; let ctl: AbortController | null = null; let last: Promise | null = null; const run = (text: string): Promise => { ctl?.abort(); const mine = (ctl = new AbortController()); note.textContent = t("Checking"); last = checkFormula(c, spot, text, mine.signal).then( (got) => { if (mine !== ctl) return got; box.setError(got.되나 ? undefined : got.까닭.join(" · ")); note.textContent = got.알림.join(" · "); return got; }, (error: unknown) => { if (mine !== ctl || mine.signal.aborted) return null; box.setError(error instanceof Error ? error.message : String(error)); note.textContent = ""; return null; }, ); return last; }; box.input.addEventListener("input", () => { c.drafts.set(key, box.input.value); window.clearTimeout(timer); const text = box.input.value.trim(); if (!text) return void (ctl?.abort(), box.setError(undefined), (note.textContent = "")); timer = window.setTimeout(() => void run(text), 150); }); box.input.addEventListener("change", async () => { window.clearTimeout(timer); const text = box.input.value.trim(); if (!text || text === origin) { c.drafts.delete(key); return put(null); } const got = await run(text); if (!got?.되나) return void (note.textContent = t("Bad")); c.drafts.delete(key); put(got.식); }); return el("div", { className: "m02-sks__fx", attrs: { "data-sheet-fx": spot }, children: [box.root, note], }); } /** 줄 식 칸 — 열었거나 식 덮개 있는 자리만 · 못 고치는 자리(로직 · 율 더한줄) = null */ export function rowFormula( c: FormulaCtx, spot: string, info: SpotInfo | undefined, ): HTMLElement | null { if (!c.edit || !spot || !info || (!c.open.has(spot) && !info.식덮개)) return null; const shown = info.식글 ?? info.식 ?? ""; const origin = info.원식글 ?? ""; return formulaBox(c, spot, `${spot}|f`, shown, origin, (engine) => { const doc = engine ? clearNums(c.doc as FormulaDoc, spot) : (c.doc as FormulaDoc); c.apply(setFormula(doc, spot, engine)); }); } /** 꼬리 [식 고침] · [식 닫기] — 식 덮개 있으면 늘 열림(닫기 없음) */ export function formulaToggle( c: FormulaCtx, spot: string, info: SpotInfo | undefined, ): HTMLElement[] { const addOne = /^add:#\d+$/.test(spot); // 더한줄 수량식(율 · 로직 줄은 부르는 쪽이 뺌) if (!c.edit || !spot || !info || info.식덮개 || (info.식 == null && !addOne)) return []; const on = c.open.has(spot); const b = createButton({ label: on ? t("Close") : t("Edit"), variant: "ghost", size: 22, onClick: () => { if (on) c.open.delete(spot); else c.open.add(spot); c.drafts.delete(`${spot}|f`); c.focus(on ? "" : `${spot}|f`); c.redraw(); }, }); b.dataset.sheetFxOpen = spot; return [b]; } const vars = (c: FormulaCtx): VarLine[] => (c.doc as FormulaDoc).변수줄 ?? []; /** 자리 뒤 변수 줄들(이어 붙은 변수까지) */ export function varRows(c: FormulaCtx, after: (v: VarLine) => boolean): HTMLElement[] { return vars(c).flatMap((v) => after(v) ? [varRow(c, v), ...varRows(c, (x) => x.자리 === `mid:${v.이름}`)] : [], ); } function varRow(c: FormulaCtx, v: VarLine): HTMLElement { const spot = `mid:${v.이름}`; const info = c.spots?.자리[spot]; const label = c.edit ? (() => { const box = createInputField({ value: v.이름, compact: true, size: 24 }); box.input.setAttribute("aria-label", t("VarName")); box.input.dataset.sheetCell = `var|${v.이름}`; box.input.addEventListener("change", () => { const to = box.input.value.trim(); if (!to || to === v.이름 || vars(c).some((x) => x.이름 === to)) return void (box.input.value = v.이름); c.focus(`var|${to}`); c.apply(editVar(c.doc as FormulaDoc, v.이름, { 이름: to })); }); return box.root; })() : el("span", { className: "ui-calc-row__label", text: v.이름 }); const middle = c.edit ? formulaBox(c, spot, `${spot}|f`, info?.식글 ?? v.식, "", (engine) => c.apply(editVar(c.doc as FormulaDoc, v.이름, { 식: engine ?? "0" })), ) : el("span", { className: "ui-calc-formula", text: info?.식글 ?? v.식 }); const value = el("span", { className: "ui-calc-row__out", text: typeof info?.지금 === "number" ? num(info.지금) : "—", }); const tail = el("span", { children: [ createBadge(t("Var"), "accent"), ...(c.edit ? [ createButton({ label: t("DropVar"), variant: "danger-ghost", size: 22, onClick: () => c.apply(dropVar(c.doc as FormulaDoc, v.이름)), }), ] : []), ], }); const row = createCalcSheetRow(label, middle, value, tail); row.dataset.sheetKind = "변수"; row.dataset.sheetSpot = spot; row.classList.add("is-changed"); return row; } /** [+ 변수 줄] — 맨 위(자리 "") · 이름 = 안 겹치는 「변수n」 */ export function addVarButton(c: FormulaCtx): HTMLElement[] { if (!c.edit) return []; const b = createButton({ label: t("AddVar"), variant: "ghost", size: 22, onClick: () => { const used = new Set([ ...vars(c).map((v) => v.이름), ...(c.spots?.이름표 ?? []).map((x) => x.글), ]); let n = 1; while (used.has(`변수${n}`)) n++; c.focus(`var|변수${n}`); c.apply(addVar(c.doc as FormulaDoc, { 이름: `변수${n}`, 식: "0", 자리: "" }, 0)); }, }); b.dataset.sheetAddVar = ""; return [b]; }