/* ============================================================================= * formula_doc_lines.ts * 수식 문서 식 줄 · 금액 줄 · 묶음(PLAN 802-3 · 요구 660~664) — 왼쪽 기호 식 · 오른쪽 값 식 한 줄씩(격자 두 칸) * 식 줄: 왼쪽 `Q = 3600 × q … ÷ Cm`(그 자리 고침 = 공용 `editEq` · 머리 = 함께) · 오른쪽 `= 값 식 = 결과 단위` * (값 식 숫자 누름 = 그 자리 값 고침 · 기호 값 누름 = 정의 줄 열림 · 그 밖 누름 = 식 고침) * 금액 줄: 경쟁사 꼴 `이름 : 단가 × 수량 = 금액`(÷ 한 줄) · 묶음 줄 = 노무비 · 재료비 · 경비 세 줄 * 묶음: 돈 묶음 하나 = 머리 없이 「계」 · 둘 이상 = 「가. 이름」마다 「소계」 + 맨 끝 「합계」(지어낸 말 없음) * 고친 것은 부른 쪽(`formula_doc_screen.ts`) 훅으로 — 여기선 문서를 안 고침 * ========================================================================== */ import { createButton, createIconButton } from "@ui/ui_template_button"; import { eqText } from "@ui/ui_template_equation"; import { editEq, editNumber, serializeEq, tidyEq } from "@ui/ui_template_equation_edit"; import { el } from "@ui/ui_template_elements"; import { createSelectField } from "@ui/ui_template_input"; import type { DocVar, FormulaDoc, MoneyLine, Node } from "../common_util/common_util_formula_doc"; import type { DocAnswer } from "../common_util/common_util_formula_doc_calc"; import { moneyScope } from "../common_util/common_util_formula_doc_view"; import { lineMap, litsOf, offLine, renderNode, symNode, takenOf, tryParse, valueNode, type Values, } from "./formula_doc_render"; import { ft } from "./formula_doc_text"; export const COSTS = ["노무비", "재료비", "경비"] as const; const KIND = [...COSTS, "묶음"]; const ORDER = "가나다라마바사아자차카타파하"; export type EditAt = { kind: "line" | "expr" | "price" | "qty" | "name"; i: number } | null; export interface LinesHooks { admin: boolean; /** 선택으로 꺼진 금액 줄도 보임(고침 모드) */ showOff?: boolean; onShowOff?: () => void; doc: FormulaDoc; answer: DocAnswer; vars: Map; /** 기호 · 금액 줄 이름 → 표지 글 */ marks: Map; auto: EditAt; onLine: (i: number, text: string) => void; onExpr: (i: number, text: string) => void; onMoney: (i: number, patch: Partial | null) => void; onLit: (target: "line" | "price" | "qty", i: number, at: number, value: number) => void; onPickVar: (id: string, number?: HTMLElement) => void; onAddLine: () => void; onAddMoney: (group: string) => void; onGroupName: (old: string, now: string) => void; onRedraw: () => void; } const span = (cls: string, text?: string): HTMLSpanElement => el("span", { className: cls, ...(text !== undefined ? { text } : {}) }); const memo = new WeakMap(); /** 기호 값 + 금액 줄 기호(그 줄 금액 — 뒤 금액 줄이 부름) */ function vals(h: LinesHooks): Values { let got = memo.get(h.answer); if (!got) { got = { ...(h.answer.값 ?? {}) }; const outs = (h.answer.줄 ?? []) as { 금액?: number | null }[]; (h.doc.금액 ?? []).forEach((l, i) => { if (l.기호 && typeof outs[i]?.금액 === "number") got![String(l.기호)] = outs[i].금액; }); memo.set(h.answer, got); } return got; } /** 식 글 → 그림(못 풀면 글 그대로 빨강) · scope = 금액 줄이 부르는 비목 합(앞 줄 누계) */ function expr( text: unknown, h: LinesHooks, side: "sym" | "val", inline = false, cost?: string, scope?: Values, ) { const n = tryParse(text); if (!n) { const raw = span("fdoc__raw", String(text ?? "")); if (String(text ?? "").trim()) raw.dataset.bad = "1"; return { node: raw, tree: null as Node | null }; } const lits = side === "val" ? litsOf(n) : undefined; return { node: renderNode(n, { side, vals: scope ? { ...vals(h), ...scope } : vals(h), vars: h.vars, inline, cost, lits, }), tree: n, }; } /** 그 자리 고침 시작 — 누른 자리에 커서 */ function startEdit( host: HTMLElement, h: LinesHooks, commit: (text: string) => void, at?: { x: number; y: number }, ): void { if (host.dataset.editing) return; editEq(host, { at, onInput: (text) => { const body = text.includes(" = ") ? text.slice(text.indexOf(" = ") + 3) : text; host.toggleAttribute("data-bad", !!body.trim() && !tryParse(body)); }, onCommit: commit, onCancel: () => h.onRedraw(), }); // 전체 고르기 = 한 줄 글(선형 꼴)로 바꿔 통째로 고침 · 지움 host.addEventListener("keydown", (ev) => { if (!(ev.ctrlKey || ev.metaKey) || ev.key.toLowerCase() !== "a") return; ev.preventDefault(); host.textContent = tidyEq(serializeEq(host)); window.getSelection()?.selectAllChildren(host); }); } /** 값 식 누름 — 수 = 그 자리 값 · 기호 값 = 정의 줄 · 그 밖 = 식 고침 */ function onValueClick( ev: MouseEvent, host: HTMLElement, h: LinesHooks, target: "line" | "price" | "qty", i: number, editAll: () => void, ): void { if (!h.admin || host.dataset.editing) return; const t = ev.target as HTMLElement; const lit = t.closest("[data-lit]"); const atom = t.closest("[data-var]"); if (lit && host.contains(lit)) { ev.stopPropagation(); editNumber(lit, (text) => { const v = Number(text.replace(/,/g, "")); if (Number.isFinite(v)) h.onLit(target, i, Number(lit.dataset.lit), v); else h.onRedraw(); }); } else if (atom && host.contains(atom)) { ev.stopPropagation(); h.onPickVar(atom.dataset.var!, atom.querySelector(".ui-eq__num") ?? undefined); } else editAll(); } function lineRow(i: number, h: LinesHooks): HTMLElement[] { const line = (h.doc.식 ?? [])[i]; const head = String(line.기호 ?? ""); const left = el("div", { className: "fdoc__cell fdoc__sym", attrs: { "data-line": String(i) } }); const edit = span("fdoc__edit"); if (head) { const a = span("ui-eq__var fdoc__atom fdoc__head"); a.dataset.tok = head; a.append(symNode(head)); edit.append(a, span("ui-eq__op", "=")); } edit.append(expr(line.식, h, "sym").node); const bad = h.marks.get(head); if (bad) { edit.dataset.bad = "1"; edit.title = bad; } left.append(edit); const right = el("div", { className: "fdoc__cell fdoc__valcell", attrs: { "data-line": String(i) }, }); const vexpr = span("fdoc__edit"); vexpr.append(expr(line.식, h, "val").node); const got = vals(h)[head]; right.append(span("ui-eq__op", "="), vexpr); // 수 하나 · 기호 하나(탄 갈래) 줄 = 값 한 번만(「= 1 = 1」 없음) let eff = tryParse(line.식); for (let pick = eff ? takenOf(eff, vals(h)) : 0; eff && pick; pick = takenOf(eff, vals(h))) eff = (eff as ["call", string, Node[]])[2][pick]; const single = !!eff && (eff[0] === "num" || (eff[0] === "var" && vals(h)[eff[1]] === got)); if (single) { /* 값 식이 곧 결과 */ } else if (got !== null && got !== undefined && typeof got !== "object") { right.append(span("ui-eq__op", "="), valueNode(got, unitOf(head, h))); } else right.append(span("ui-eq__op", "="), span("fdoc__dash", "—")); if (h.admin) { const commitLine = (text: string): void => h.onLine(i, text); left.addEventListener("click", (ev) => startEdit(edit, h, commitLine, { x: ev.clientX, y: ev.clientY }), ); vexpr.addEventListener("click", (ev) => onValueClick(ev, vexpr, h, "line", i, () => startEdit(vexpr, h, (text) => h.onExpr(i, text), { x: ev.clientX, y: ev.clientY }), ), ); if (h.auto?.kind === "line" && h.auto.i === i) startEdit(edit, h, commitLine); } return [left, right]; } const unitOf = (id: string, h: LinesHooks): string => h.vars.get(id)?.단위 || (h.doc.결과 === id ? (h.doc.결과단위 ?? "") : ""); function moneyRow(i: number, h: LinesHooks): HTMLElement[] { const line = (h.doc.금액 ?? [])[i]; const got = (h.answer.줄 ?? [])[i] as { 비목?: Record; 금액?: number | null; 까닭?: string } | undefined; const left = el("div", { className: "fdoc__cell fdoc__sym fdoc__money" }); const name = span("fdoc__name", line.이름 || ft("Unnamed")); const price = span("fdoc__edit"); price.append(expr(line.단가, h, "sym", true).node); const qty = span("fdoc__edit"); const hasQty = line.수량 !== null && line.수량 !== undefined && line.수량 !== ""; if (hasQty) qty.append(expr(line.수량, h, "sym", true).node); else qty.append(span("ui-eq__num", "1")); const times = span("ui-eq__op", "×"); left.append(name, span("ui-eq__op", ":"), price); // 수량 없음 = 1(흐리게 — 눌러 수량을 씀 · 묶음 줄은 단가 × 수량이어야 셈) if (hasQty || h.admin) left.append(times, qty); if (!hasQty) [times, qty].forEach((x) => x.classList.add("is-ghost")); const bad = h.marks.get(line.이름 ?? ""); if (bad) { left.dataset.bad = "1"; left.title = bad; } if (h.admin) { const kind = createSelectField({ options: KIND.map((k) => ({ value: k, text: k })), value: line.비목 ?? "노무비", compact: true, size: 22, ariaLabel: ft("Kind"), onChange: (v) => h.onMoney(i, { 비목: v }), }); kind.root.classList.add("fdoc__kind"); const drop = createIconButton({ glyph: "✕", title: ft("Drop_Line"), size: 20, variant: "ghost", onClick: (ev) => { ev.stopPropagation(); h.onMoney(i, null); }, }); drop.classList.add("fdoc__drop"); left.prepend(kind.root); left.append(drop); name.addEventListener("click", () => editName(name, line.이름 ?? "", (t) => h.onMoney(i, { 이름: t })), ); const put = (slot: "단가" | "수량") => (text: string) => h.onMoney(i, { [slot]: text }); price.addEventListener("click", (ev) => startEdit(price, h, put("단가"), { x: ev.clientX, y: ev.clientY }), ); qty.addEventListener("click", (ev) => startEdit(qty, h, put("수량"), { x: ev.clientX, y: ev.clientY }), ); if (h.auto?.kind === "price" && h.auto.i === i) startEdit(price, h, put("단가")); if (h.auto?.kind === "name" && h.auto.i === i) editName(name, "", (t) => h.onMoney(i, { 이름: t })); } const right = el("div", { className: "fdoc__cell fdoc__valcell fdoc__money" }); const costs = line.비목 === "묶음" ? [...COSTS] : [null]; const scope = moneyScope(h.answer.줄, i); for (const cost of costs) { const row = span("fdoc__mrow"); if (cost) row.append(span("fdoc__cost", cost), span("ui-eq__op", ":")); const pv = span("fdoc__edit"); pv.append(expr(line.단가, h, "val", true, cost ?? undefined, scope).node); row.append(pv); if (hasQty) { const qv = span("fdoc__edit"); qv.append(expr(line.수량, h, "val", true, undefined, scope).node); row.append(span("ui-eq__op", "×"), qv); if (h.admin) qv.addEventListener("click", (ev) => onValueClick(ev, qv, h, "qty", i, () => undefined)); } if (h.admin) pv.addEventListener("click", (ev) => onValueClick(ev, pv, h, "price", i, () => undefined)); const amount = cost ? got?.비목?.[cost] : got?.금액; row.append(span("ui-eq__op", "=")); if (typeof amount === "number") row.append(span("ui-eq__num fdoc__amount", eqText(amount))); else { const dash = span("fdoc__dash", "—"); if (got?.까닭) dash.title = got.까닭; row.append(dash); } right.append(row); } return [left, right]; } /** 이름 그 자리 고침(글만) */ function editName(node: HTMLElement, before: string, done: (text: string) => void): void { node.textContent = before; node.contentEditable = "true"; node.dataset.editing = "1"; let over = false; const finish = (keep: boolean): void => { if (over) return; over = true; node.contentEditable = "false"; const text = (node.textContent ?? "").trim(); if (keep && text !== before) done(text); else node.textContent = before || ft("Unnamed"); delete node.dataset.editing; }; node.addEventListener("keydown", (ev) => { if (ev.key === "Enter" || ev.key === "Escape") { ev.preventDefault(); finish(ev.key === "Enter"); } }); node.addEventListener("blur", () => finish(true), { once: true }); queueMicrotask(() => { node.focus(); window.getSelection()?.selectAllChildren(node); }); } function totalRow(label: string, b: Record | null, cls: string): HTMLElement[] { const left = el("div", { className: `fdoc__cell fdoc__total ${cls}`, children: [span("fdoc__name", label)], }); const right = el("div", { className: `fdoc__cell fdoc__total ${cls}` }); for (const k of [...COSTS, "계"]) { const v = b?.[k]; right.append( span("fdoc__tk", k), typeof v === "number" ? span("ui-eq__num fdoc__amount", eqText(v)) : span("fdoc__dash", "—"), ); } return [left, right]; } /** 식 줄 · 금액 줄 · 묶음 · 계 — 격자 칸 차례 */ export function buildLines(h: LinesHooks): HTMLElement[] { const out: HTMLElement[] = []; const add = (cells: HTMLElement[]): void => void out.push(...cells); const pair = (left: HTMLElement | null, right: HTMLElement | null): void => add([left ?? el("div"), right ?? el("div")]); (h.doc.식 ?? []).forEach((_, i) => add(lineRow(i, h))); if (h.admin) pair( el("div", { className: "fdoc__cell", children: [button(ft("Add_Line"), h.onAddLine)], }), null, ); const lines = h.doc.금액 ?? []; const groups: string[] = []; lines.forEach((l) => groups.includes(l.묶음 ?? "") || groups.push(l.묶음 ?? "")); const steps = lineMap(h.doc); const off = new Set(lines.flatMap((l, i) => (offLine(l, vals(h), steps) ? [i] : []))); const seen = (i: number): boolean => !!h.showOff || !off.has(i); const many = groups.length > 1; const bundles = (h.answer.묶음 ?? []) as Record[]; const done = h.answer.셈됨; groups.forEach((g, gi) => { if (!lines.some((l, i) => (l.묶음 ?? "") === g && seen(i))) return; // 다 꺼진 묶음 if (many) { const head = span("fdoc__group", `${ORDER[gi] ?? gi + 1}. `); const label = span("fdoc__name", g || ft("Unnamed")); head.append(label); if (h.admin) label.addEventListener("click", () => editName(label, g, (t) => h.onGroupName(g, t))); add([el("div", { className: "fdoc__cell fdoc__grouphead", children: [head] })]); // 두 칸 걸침 } lines.forEach((l, i) => (l.묶음 ?? "") === g && seen(i) && add(moneyRow(i, h))); if (h.admin && lines.length) pair( el("div", { className: "fdoc__cell", children: [button(ft("Add_Money"), () => h.onAddMoney(g))], }), null, ); if (many) add(totalRow(ft("Subtotal"), done ? (bundles[gi] ?? null) : null, "is-sub")); }); if (h.admin && !lines.length) pair( el("div", { className: "fdoc__cell", children: [button(ft("Add_Money"), () => h.onAddMoney(""))], }), null, ); if (h.admin && lines.length) { const kids = [button(ft("Add_Group"), () => h.onAddMoney(ft("New_Group")))]; if (off.size && h.onShowOff) kids.push(button(`${h.showOff ? ft("Hide_Off") : ft("Show_Off")} ${off.size}`, h.onShowOff)); pair(el("div", { className: "fdoc__cell", children: kids }), null); } if (lines.length) { const sum = done ? (h.answer as unknown as Record) : null; add(totalRow(many ? ft("Total") : ft("Sum"), sum, "is-end")); } return out; } const button = (label: string, onClick: () => void): HTMLElement => { const b = createButton({ label, variant: "ghost", size: 24, onClick }); b.classList.add("fdoc__add"); return b; };