- 663-1: 수량 · 단가가 만약으로 0 을 탄 금액 줄 = 안 보임(GC000211 폼타이 9.5 · 7.9 ㎜) · 규격 바꾸면 그 줄만 · [선택으로 꺼진 줄 보기] = 고침 모드에서만 · 정의 영역도 꺼진 줄 기호 뺌 1,518 문서 금액 줄 5,912 중 기본 옵션에서 꺼짐 145 줄(107 문서) · 옵션에 따라 꺼질 수 있는 줄 305 줄(141 문서) - 식 안 만약이 0 · 1 을 타면 그 항(+ 0 · × 1)도 안 보임 — 고침 글은 그대로 - 수 하나 · 기호 하나 줄 값 식 = 한 번만(「= 1 = 1」 없음) · 수량 없음 「× 1」 자리는 줄에 마우스 올릴 때만 - 옮김 slim(master_doc_convert) — + 0 · × 1 · ÷ 1 지움 · A × k ÷ 100 → A × k% · 빌더 다시(목록 xlsx 는 안 씀) · 대조 그대로(기본 1,519 같음 · 옵션 다름 1 · 저장 1,518) · 31 파일 글만 - 시험 test_802_3 + 군더더기 없음 1(12) · 802_1 · 802_2 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
400 lines
15 KiB
TypeScript
400 lines
15 KiB
TypeScript
/* =============================================================================
|
||
* 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 {
|
||
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;
|
||
}
|
||
|
||
/** 식 글 → 그림(못 풀면 글 그대로 빨강) */
|
||
function expr(text: unknown, h: LinesHooks, side: "sym" | "val", inline = false, cost?: string) {
|
||
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: 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];
|
||
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).node);
|
||
row.append(pv);
|
||
if (hasQty) {
|
||
const qv = span("fdoc__edit");
|
||
qv.append(expr(line.수량, h, "val", true).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 off = new Set(lines.flatMap((l, i) => (offLine(l, vals(h)) ? [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;
|
||
};
|