feat(m01-lb): 로직 만들기 판 격자 보기(Grid)
엑셀처럼 줄 · 칸으로 보는 가운데 격자 — 고르기 · 숫자 칸 · 줄 누르면 앞 줄 칠함 · 표준은 읽기만. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
.m01-lbgrid__scroll {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m01-lbgrid__table {
|
||||
width: 100%;
|
||||
min-width: 720px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-lbgrid__table th,
|
||||
.m01-lbgrid__table td {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-right: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.m01-lbgrid__table th:first-child,
|
||||
.m01-lbgrid__table td:first-child {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-lbgrid__table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-lbgrid__row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-lbgrid__row--first td {
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-lbgrid__row--sub .m01-lbgrid__name {
|
||||
padding-left: var(--spacing-16);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-lbgrid__step {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-lbgrid__table td.m01-lbgrid__num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-lbgrid__src {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 고른 줄 = 굵은 테두리 · 앞 줄 = 연한 칠 + 왼쪽 막대(색만으로 뜻을 안 전함) */
|
||||
.m01-lbgrid__row--on td {
|
||||
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-lbgrid__row--pre td {
|
||||
background: color-mix(in srgb, var(--color-warning) 28%, transparent);
|
||||
}
|
||||
|
||||
.m01-lbgrid__row--pre td:first-child {
|
||||
box-shadow: inset 4px 0 0 var(--color-warning);
|
||||
}
|
||||
|
||||
.m01-lbgrid__input {
|
||||
box-sizing: border-box;
|
||||
width: 96px;
|
||||
height: var(--control-height-sm);
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.m01-lbgrid__msg {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-lbgrid__note,
|
||||
.m01-lbgrid__hint {
|
||||
margin: var(--spacing-4) 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
/* =============================================================================
|
||||
* 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("찾기(");
|
||||
}),
|
||||
...lookupRefs.filter((r) => r.k === "lookup" && r.id !== lk.id),
|
||||
],
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user