Files
Aislo/M01_MasterData/LogicBuilder/M01_LB_Grid.ts
T

541 lines
19 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.
/* =============================================================================
* M01_LB_Grid.ts
* 로직 만들기 판 — 가운데 격자 보기(엑셀처럼 줄 · 칸). 문장 보기(Steps)와 같은 LbLogic · 같은 on.change
* 열 = 단계 · 이름 · 무엇으로 · 값 · 단위 · 출처 / 줄 = 입력값 · 표 조회(+조건) · 계산(+조각) · 항목 · 결과값
* 칸은 고르기 · 숫자 칸만 · 줄을 누르면 그 줄이 쓰는 줄(앞 줄)을 칠함 · 표준은 읽기만
* 모양 = docs/raw/M01_쉬운단가/로직판_A_정함.md · 로직판_A_설계초안.md 7-1. 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createSelectField, el } from "@ui/ui_template_elements";
import type { CalcAnswer } from "../M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
import { safeReason } from "../M01_MasterData_UI_Logic_Text";
import { updateCard, type EasyCard } from "../EasyCalc/M01_EasyCalc_Model";
import {
setCalc,
setCond,
type LbCalc,
type LbExpr,
type LbLogic,
type LbRef,
} from "./M01_LB_Blocks";
import "./M01_LB_Grid.css";
const TEXT = {
Step1: ["① 입력값", "① Input"],
Step2: ["② 표 조회", "② Table lookup"],
Step3: ["③ 계산", "③ Calculation"],
Step4: ["④ 항목 연결", "④ Items"],
Step5: ["⑤ 결과값", "⑤ Result"],
HStep: ["단계", "Step"],
HName: ["이름", "Name"],
HHow: ["무엇으로", "From"],
HValue: ["값", "Value"],
HUnit: ["단위", "Unit"],
HSource: ["출처", "Source"],
Busy: ["계산 중", "Calculating"],
Stopped: ["계산하지 못했어요", "Could not calculate"],
ReadOnly: [
"표준은 읽기만 — 「복사 편집」 뒤에 고칠 수 있어요",
"Standard is read-only — use “Copy and edit” first",
],
Hint: ["줄을 누르면 그 줄이 쓰는 줄이 칠해져요", "Click a row to paint the rows it uses"],
Bad: ["숫자를 넣어 주세요", "Enter a number"],
Cant: ["지금은 이 칸을 고칠 수 없어요", "This cell cannot be changed now"],
Typed: ["숫자 칸", "Number"],
Choice: ["고르기", "Choice"],
Range: ["범위", "Range"],
FromInput: ["입력값", "Input"],
FromCalc: ["계산", "Calc"],
FromLookup: ["표 조회", "Lookup"],
FromFixed: ["고정값", "Fixed"],
FromLogic: ["다른 일위대가 결과", "Other unit-price result"],
FromPeople: ["사람 품값 합", "People cost total"],
FromNone: ["무관", "Any"],
Own: ["직접 쓴 계산", "Written by hand"],
Cond: ["조건", "Condition"],
Table: ["표", "Table"],
Number: ["숫자", "Number"],
Calc: ["계산", "Calculation"],
Sum: ["항목 합계", "Item total"],
UseName: ["계산 이름", "Calc name"],
UseQty: ["항목 수량", "Item amount"],
UseRate: ["항목 비율", "Item rate"],
UseResult: ["결과값", "Result"],
Won: ["원", "KRW"],
Unnamed: ["수량 계산", "Amount calc"],
Price: ["단가", "Unit price"],
Result: ["결과", "Result"],
KValue: ["값", "Value"],
KMul: ["곱하기 · 나누기", "Multiply / divide"],
KRaise: ["할증", "Surcharge"],
KAdd: ["더하기", "Add"],
KCond: ["조건 목록", "Conditions"],
KMinMax: ["최소 · 최대", "Min / max"],
KRound: ["올림 · 버림", "Round"],
KSteps: ["여러 단계", "Several steps"],
Person: ["사람", "Person"],
Material: ["재료", "Material"],
Machine: ["기계", "Machine"],
Logic: ["다른 일위대가", "Other unit price"],
Percent: ["덧붙임", "Add-on %"],
Locked: ["그 밖 줄", "Other line"],
Base: ["바탕", "Base"],
Rate: ["율(%)", "Rate (%)"],
First: ["처음", "First"],
Times: ["곱하기", "times"],
Divide: ["나누기", "divided by"],
Plus: ["더하기", "plus"],
Minus: ["빼기", "minus"],
} as const;
type TextKey = keyof typeof TEXT;
const tg = (key: TextKey): string => TEXT[key][currentLanguageIndex];
type Part = Exclude<LbCalc["at"]["part"], never>;
type Ref = LbRef | LbExpr;
type Cell = string | HTMLElement;
interface GridRow {
id: string;
step: string;
sub: boolean;
name: Cell;
how: Cell;
value: Cell;
unit: string;
source: string;
/** 이 줄이 쓰는 줄 번호 */
uses: string[];
}
const KIND_KEY: Record<LbCalc["kind"], TextKey> = {
값: "KValue",
곱나눔: "KMul",
할증: "KRaise",
더하기: "KAdd",
조건목록: "KCond",
최소최대: "KMinMax",
올림: "KRound",
단계: "KSteps",
};
const CARD_KEY: Record<EasyCard["kind"], TextKey> = {
person: "Person",
material: "Material",
machine: "Machine",
logic: "Logic",
percent: "Percent",
locked: "Locked",
};
const USE_KEY: Record<Part, TextKey> = {
중간: "UseName",
수량: "UseQty",
비율: "UseRate",
결과: "UseResult",
};
const num = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 6 });
const show = (v: unknown): string =>
typeof v === "number"
? num(v)
: v === undefined || v === null || typeof v === "object"
? ""
: String(v);
/** 선택된 줄 — 다시 그려도 남도록 칸(host)마다 기억 */
const picked = new WeakMap<HTMLElement, string>();
/** 이름이 글 안에 따로 있는지 — 영문 이름은 낱말 경계로 */
function mentions(text: string, name: string): boolean {
if (!name) return false;
if (!/^\w+$/.test(name)) return text.includes(name);
return new RegExp(`(^|[^A-Za-z0-9_])${name}($|[^A-Za-z0-9_])`).test(text);
}
export interface GridOptions {
/** 입력값 칸에 보일 예시값 — 없으면 계산 답에서 찾음 */
trial?: Record<string, unknown>;
}
/** 격자 보기 — on.change 로 고친 LbLogic 을 알림(읽기만이면 안 알림) */
export function renderGrid(
host: HTMLElement,
lb: LbLogic,
ans: CalcAnswer | null,
on: { change(lb: LbLogic): void },
opts: GridOptions = {},
): void {
const readOnly = lb.doc.isBase;
const okAns = ans && ans.ok ? ans : null;
const middle = okAns?.middle ?? {};
const lines = okAns?.lines ?? [];
const msg = el("p", { className: "m01-lbgrid__msg" });
const calcIndex = (name: string): number => lb.calcs.findIndex((c) => c.name === name);
const apply = (make: () => LbLogic): void => {
if (readOnly) return;
msg.textContent = "";
try {
on.change(make());
} catch {
msg.textContent = tg("Cant");
}
};
/* ---- 값 · 줄 번호 ---- */
const rowOf = (r: Ref): string[] => {
if (r.k === "input") return [`in:${r.name}`];
if (r.k === "lookup") return [`lk:${r.id}`];
if (r.k === "calc") return calcIndex(r.name) >= 0 ? [`ca:${calcIndex(r.name)}`] : [];
if (r.k === "expr") return textRows(r.text);
return [];
};
const textRows = (text: string, self = -1): string[] => [
...lb.inputs.filter((i) => mentions(text, i.이름)).map((i) => `in:${i.이름}`),
...lb.calcs.flatMap((c, i) =>
i !== self && c.name && mentions(text, c.name) ? [`ca:${i}`] : [],
),
];
const lookupsAt = (part: Part | "요소", index: number): string[] =>
lb.lookups
.filter((l) => l.at.some((a) => a.part === part && a.index === index))
.map((l) => `lk:${l.id}`);
const lineOf = (card: EasyCard) =>
lines.find((l) => l.이름 === card.name && l.단위 === card.unit);
const lookupValue = (id: string): unknown => {
const solo = lb.calcs.find(
(c) => c.parts.length === 1 && c.parts[0].k === "lookup" && c.parts[0].id === id,
);
if (solo?.name && middle[solo.name] !== undefined) return middle[solo.name];
const at = lb.lookups
.find((l) => l.id === id)
?.at.find((a) => a.part === "수량" && a.start === 0);
const card = lb.doc.cards.find(
(c) => c.source?.part === "호표" && c.source.index === at?.index,
);
const line = card && lineOf(card);
return line && card?.qtyFormula && at && at.end === card.qtyFormula.length ? line.수량 : "";
};
const valueOf = (r: Ref): unknown => {
if (r.k === "input") return opts.trial?.[r.name] ?? middle[r.name];
if (r.k === "fixed") return r.value;
if (r.k === "calc") return middle[r.name];
if (r.k === "lookup") return lookupValue(r.id);
if (r.k === "cost") return okAns?.sums?.[r.name === "사람줄합" ? "노무비" : r.name];
return "";
};
const unitOf = (r: Ref): string =>
r.k === "input" ? (lb.inputs.find((i) => i.이름 === r.name)?.단위 ?? "") : "";
/* ---- 칸 만들기 ---- */
const numberCell = (value: unknown, make: (n: number) => LbLogic): HTMLElement => {
const box = el("input", {
className: "m01-lbgrid__input",
attrs: { type: "text", inputmode: "decimal", "aria-label": tg("Number") },
});
box.value = show(value);
box.readOnly = readOnly;
box.addEventListener("click", (e) => e.stopPropagation());
box.addEventListener("change", () => {
const n = Number(box.value.trim());
if (box.value.trim() === "" || Number.isNaN(n)) {
msg.textContent = tg("Bad");
box.value = show(value);
return;
}
apply(() => make(n));
});
return box;
};
const refLabel = (r: Ref): string => {
if (r.k === "input") return `${tg("FromInput")} · ${r.name}`;
if (r.k === "calc") return `${tg("FromCalc")} · ${r.name}`;
if (r.k === "lookup") {
const l = lb.lookups.find((x) => x.id === r.id);
return `${tg("FromLookup")} · ${l ? `${l.table} ${l.col}` : r.id}`;
}
if (r.k === "fixed") return tg("FromFixed");
if (r.k === "logic") return tg("FromLogic");
if (r.k === "cost") return r.name === "사람줄합" ? tg("FromPeople") : r.name;
return tg("Own");
};
const refKey = (r: Ref): string =>
r.k === "input"
? `input:${r.name}`
: r.k === "calc"
? `calc:${r.name}`
: r.k === "lookup"
? `lookup:${r.id}`
: r.k === "fixed"
? "fixed"
: r.k === "logic"
? `logic:${r.key}`
: r.k === "cost"
? `cost:${r.name}`
: "expr";
/** 고를 수 있는 곳 — 입력값 · (앞선) 계산 · 표 조회 · 지금 값 */
const refSelect = (
cur: Ref,
pool: Ref[],
none: boolean,
pick: (r: Ref | null) => LbLogic,
): HTMLElement => {
const all = [...pool];
if (!all.some((r) => refKey(r) === refKey(cur))) all.unshift(cur);
const options = [
...(none ? [{ value: "none", text: tg("FromNone") }] : []),
...all.map((r) => ({ value: refKey(r), text: refLabel(r) })),
];
const field = createSelectField({
options,
value: refKey(cur),
compact: true,
ariaLabel: refLabel(cur),
disabled: readOnly || cur.k === "expr",
onChange: (v) => {
if (v === refKey(cur)) return;
apply(() => pick(v === "none" ? null : (all.find((r) => refKey(r) === v) ?? cur)));
},
});
field.root.addEventListener("click", (e) => e.stopPropagation());
return field.root;
};
const inputRefs: Ref[] = lb.inputs.map((i) => ({ k: "input", name: i.이름 }));
const calcRefs = (before: number): Ref[] =>
lb.calcs.flatMap((c, i): Ref[] => (c.name && i < before ? [{ k: "calc", name: c.name }] : []));
const lookupRefs: Ref[] = lb.lookups.map((l) => ({ k: "lookup", id: l.id }));
/* ---- 줄 모으기 ---- */
const rows: GridRow[] = [];
const add = (r: Partial<GridRow> & { id: string; name: Cell }): GridRow => {
const row: GridRow = {
step: "",
sub: false,
how: "",
value: "",
unit: "",
source: "",
uses: [],
...r,
};
rows.push(row);
return row;
};
lb.inputs.forEach((inp, i) => {
add({
id: `in:${inp.이름}`,
step: i === 0 ? tg("Step1") : "",
name: inp.이름,
how: inp.고르기?.length ? tg("Choice") : tg("Typed"),
value: show(valueOf({ k: "input", name: inp.이름 })),
unit: inp.단위 ?? "",
source: inp.고르기?.length
? inp.고르기.join(" · ")
: inp.범위
? `${tg("Range")} ${inp.범위[0]}∼${inp.범위[1]}`
: "",
});
});
lb.lookups.forEach((lk, i) => {
const head = add({
id: `lk:${lk.id}`,
step: i === 0 ? tg("Step2") : "",
name: lk.col,
how: tg("FromLookup"),
value: show(lookupValue(lk.id)),
source: `${tg("Table")} ${lk.table}`,
});
lk.conds.forEach((cd) => {
const from = cd.from;
const fixedNum = from.k === "fixed" && typeof from.value === "number";
const subRow = add({
id: `lc:${lk.id}:${cd.col}`,
sub: true,
name: `${tg("Cond")} · ${cd.col}`,
how: refSelect(
from,
[
...inputRefs,
...calcRefs(lb.calcs.length).filter((r) => {
const c = lb.calcs.find((x) => x.name === (r as { name: string }).name);
return c && !c.parts.some((p) => p.k === "lookup") && !c.text.includes("찾기(");
}),
],
true,
(r) => setCond(lb, lk.id, cd.col, r),
),
value:
fixedNum && from.k === "fixed"
? numberCell(from.value, (n) => setCond(lb, lk.id, cd.col, { k: "fixed", value: n }))
: show(valueOf(from)),
unit: unitOf(from),
uses: rowOf(from),
});
head.uses.push(...subRow.uses);
});
});
lb.calcs.forEach((c, i) => {
const first = i === 0;
const own = lookupsAt(c.at.part, c.at.index);
const head = add({
id: `ca:${i}`,
step: first ? tg("Step3") : "",
name: c.name ?? `${tg("Unnamed")} ${i + 1}`,
how: tg(KIND_KEY[c.kind]),
value: show(c.name ? middle[c.name] : c.at.part === "수량" ? lines[c.at.index]?.수량 : ""),
source: tg(USE_KEY[c.at.part]),
uses: [...textRows(c.text, i), ...own],
});
if (!["값", "곱나눔", "할증", "더하기"].includes(c.kind)) return;
c.parts.forEach((p, pi) => {
const label =
c.kind === "할증"
? tg(pi === 0 ? "Base" : "Rate")
: c.kind === "값"
? tg("KValue")
: pi === 0
? tg("First")
: tg(
({ "*": "Times", "/": "Divide", "+": "Plus", "-": "Minus" } as const)[
c.ops?.[pi - 1] ?? (c.kind === "더하기" ? "+" : "*")
],
);
const fixedNum = p.k === "fixed" && typeof p.value === "number";
const setPart = (r: Ref | null): LbLogic => {
const parts = c.parts.slice();
if (r) parts[pi] = r;
return setCalc(lb, c, { parts });
};
const sub = add({
id: `cp:${i}:${pi}`,
sub: true,
name: label,
how: refSelect(p, [...inputRefs, ...calcRefs(i), ...lookupRefs], false, setPart),
value:
fixedNum && p.k === "fixed"
? numberCell(p.value, (n) => setPart({ k: "fixed", value: n }))
: show(valueOf(p)),
unit: unitOf(p),
uses: rowOf(p),
});
head.uses.push(...sub.uses);
});
});
lb.doc.cards.forEach((card, i) => {
const sp = card.source;
const parts: Part[] = sp?.part === "덧줄" ? ["비율"] : ["수량"];
const viaLookup = sp
? parts
.flatMap((p) => lookupsAt(p, sp.index))
.concat(sp.part === "호표" ? lookupsAt("요소", sp.index) : [])
: [];
const viaCalc = sp
? lb.calcs.flatMap((c, ci) =>
parts.includes(c.at.part) && c.at.index === sp.index ? [`ca:${ci}`] : [],
)
: [];
const line = lineOf(card);
const how = card.qty !== null ? tg("Number") : viaLookup.length ? tg("FromLookup") : tg("Calc");
add({
id: `it:${card.id}`,
step: i === 0 ? tg("Step4") : "",
name: card.spec ? `${card.name} ${card.spec}` : card.name,
how: tg(CARD_KEY[card.kind]),
value:
card.qty !== null
? numberCell(card.qty, (n) => ({ ...lb, doc: updateCard(lb.doc, card.id, { qty: n }) }))
: show(line?.수량),
unit: card.unit,
source: `${how}${line ? ` · ${formatMoney(line.금액)} ${tg("Won")}` : ""}`,
uses: [...viaLookup, ...viaCalc, ...(card.qtyFormula ? textRows(card.qtyFormula) : [])],
});
});
const res = lb.result;
add({
id: "res",
step: tg("Step5"),
name: tg(res.kind === "금액" ? "Price" : "Result"),
how: res.kind,
value:
res.kind === "금액"
? okAns
? formatMoney(okAns.sums?.["계"] ?? 0)
: ""
: show(okAns?.result),
unit: res.unit,
source: res.kind === "금액" ? tg("Sum") : tg("Calc"),
uses:
res.kind === "금액"
? lb.doc.cards.map((c) => `it:${c.id}`)
: [
...textRows(res.calc.text),
...lb.calcs.flatMap((c, i) => (c.at.part === "결과" ? [`ca:${i}`] : [])),
],
});
/* ---- 그리기 ---- */
const tbody = el("tbody");
const paint = (): void => {
const sel = picked.get(host);
const uses = new Set(rows.find((r) => r.id === sel)?.uses ?? []);
tbody.querySelectorAll<HTMLElement>("tr").forEach((tr) => {
const id = tr.dataset.row ?? "";
tr.classList.toggle("m01-lbgrid__row--on", id === sel);
tr.classList.toggle("m01-lbgrid__row--pre", uses.has(id));
});
};
const td = (c: Cell, className = ""): HTMLElement =>
el("td", { className, ...(typeof c === "string" ? { text: c } : { children: [c] }) });
rows.forEach((r) => {
const tr = el("tr", {
className: `m01-lbgrid__row${r.sub ? " m01-lbgrid__row--sub" : ""}${r.step ? " m01-lbgrid__row--first" : ""}`,
attrs: { "data-row": r.id },
children: [
td(r.step, "m01-lbgrid__step"),
td(r.name, "m01-lbgrid__name"),
td(r.how),
td(r.value, "m01-lbgrid__num"),
td(r.unit),
td(r.source, "m01-lbgrid__src"),
],
});
tr.addEventListener("click", () => {
if (picked.get(host) === r.id) picked.delete(host);
else picked.set(host, r.id);
paint();
});
tbody.append(tr);
});
const heads: TextKey[] = ["HStep", "HName", "HHow", "HValue", "HUnit", "HSource"];
const status =
ans && !ans.ok ? `${tg("Stopped")} — ${safeReason(ans.reason)}` : ans ? "" : tg("Busy");
host.replaceChildren(
...(readOnly ? [el("p", { className: "m01-lbgrid__note", text: tg("ReadOnly") })] : []),
...(status ? [el("p", { className: "m01-lbgrid__note", text: status })] : []),
el("div", {
className: "m01-lbgrid__scroll",
children: [
el("table", {
className: "m01-lbgrid__table",
children: [
el("thead", {
children: [el("tr", { children: heads.map((h) => el("th", { text: tg(h) })) })],
}),
tbody,
],
}),
],
}),
msg,
el("p", { className: "m01-lbgrid__hint", text: tg("Hint") }),
);
paint();
}