Files
Aislo/A00_Common/formula_doc_lines.ts
T

418 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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<string, DocVar>;
/** 기호 · 금액 줄 이름 → 표지 글 */
marks: Map<string, string>;
auto: EditAt;
onLine: (i: number, text: string) => void;
onExpr: (i: number, text: string) => void;
onMoney: (i: number, patch: Partial<MoneyLine> | 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<DocAnswer, Values>();
/** 기호 값 + 금액 줄 기호(그 줄 금액 — 뒤 금액 줄이 부름) */
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<HTMLElement>("[data-lit]");
const atom = t.closest<HTMLElement>("[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<HTMLElement>(".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<string, number>; 금액?: 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<string, unknown> | 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<string, unknown>[];
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<string, unknown>) : 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;
};