Files
Aislo/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts
T
eomsangdonandClaude Opus 5.5 702ab1daa8 feat(714-2): 공용 단가산출 계산서 묶음 꼴 — 인력 · 재료 · 경비(장비) · 하위(714-1 줄 덧칸 묶음) · 장비 · 하위 로직 = 한 줄 + 노 · 재 · 경 칸 · 누르면 몫 식 석 줄 펼침 · 묶음 머리 기준단위 · 환산 근거 · 쓰는 입력 칸 · 맨 아래 비목 소계 셋 + 계
- calc_sheet.ts bundle 꼴 · bundleLines(순수 · 같은 이름 k 번째끼리 짝) · 새 calc_sheet.css(장비 줄 누름 · 몫 들여씀)
- M01 「인력·재료·경비」 = bundle 꼴 · 변수 칸 = 서버 쓰는입력
- 714-3 텍스트 수식 단추도 서버 쓰는입력 먼저(조건 · 재료 고르기 입력까지) · 없으면 조각
- 704-2 M02 계산서 보기 = 비목 꼴 그대로(DOM 전후 같음)
- 장비 펼침 넉 줄(손료 · 주연료 · 잡품 · 운전사) = sub3 「펼침」 덧칸 뒤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:30:33 +09:00

204 lines
8.7 KiB
TypeScript

/* =============================================================================
* M01_MasterData_UI_LogicLab_Formula.ts
* 「텍스트 수식」 컨테이너 — 서버(`POST /text`)가 준 줄을 그대로 찍음(화면에서 다시 계산하지 않음)
* 두 칸 — 왼쪽 = 변수 이름 식(`이름글`) · 오른쪽 = 값이 들어간 식(`글`) · 줄은 `짝` 번호로 같은 높이
* 한쪽에 올리거나 누르면 같은 줄이 밝아짐 · 비목 제목 · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게
* 714-3 — 줄이 쓰는 입력(서버 `쓰는입력` · 없으면 표 찾기 행 값 · 이름 조각)을 오른쪽 칸 아래 작은 단추로 · 누르면 그 자리에 고르기/글 칸 →
* 바꾸면 `onInput`(시험 계산 입력 고침 + 다시 셈) · 정본은 안 바뀜(요구서 4-1 확정 8)
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import type { LogicInput, TextAnswer, TextLine } from "./M01_MasterData_UI_Logic_Api";
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
import "./M01_MasterData_UI_LogicLab_Formula.css";
/** 로직 열자마자 값 없이 부른 미리보기 응답은 `계산됨` 을 false 로 표시 — [계산] 을 거친 응답은 안 붙어 true 취급 */
export type PreviewTextAnswer = TextAnswer & { 계산됨?: boolean };
export interface FormulaContext {
text: PreviewTextAnswer | null;
/** 714-3 — 시험 계산 입력 · 지금 값 · 고친 뒤 할 일(값 넣고 다시 셈) · 없으면 단추 없음 */
inputs?: LogicInput[];
values?: Record<string, string>;
onInput?: (name: string, value: string) => void;
}
/* ── 순수 셈: 줄이 쓰는 입력 ── */
/** 줄의 조각에서 시험 입력 이름 → 그 줄이 쓴 값 — 표 찾기 행 열쇠 · 이름 조각(`값` 이 붙은 이름) · 차례 = 처음 나온 차례 */
export function usedInputs(
line: Pick<TextLine, "조각" | "요소조각">,
names: string[],
): [string, string][] {
const out = new Map<string, string>();
for (const p of [...(line.조각 ?? []), ...(line.요소조각 ?? [])]) {
const row = p.kind === "표" ? p.참조?.행 : null;
if (row && typeof row === "object")
for (const [k, v] of Object.entries(row))
if (names.includes(k) && !out.has(k)) out.set(k, String(v));
if (p.kind === "값" && p.값 !== undefined && names.includes(p.글) && !out.has(p.글))
out.set(p.글, String(p.값));
}
return [...out];
}
/* ── 순수 셈 끝 ── */
/** 입력 단추 하나 — 누르면 그 자리에 고르기(고르기 있는 입력) · 글 칸 · 바꾸면 `onInput` */
function inputChip(spec: LogicInput, shown: string, onInput: (v: string) => void): HTMLElement {
const wrap = el("span", { className: "m01lab-fx__var", attrs: { "data-fx-var": spec.이름 } });
const chip = createButton({
label: `${spec.이름} ${shown}${spec.단위 ? ` ${spec.단위}` : ""}`,
variant: "ghost",
size: 24,
onClick: (ev) => {
ev.stopPropagation(); // 줄 누름(밝히기 고정)과 안 겹치게
let over = false; // Enter 뒤 blur · 고름 뒤 blur 가 두 번 부르지 않게
const done = (v: string | null): void => {
if (over) return;
over = true;
if (v !== null && v.trim() !== "" && v !== shown) onInput(v.trim());
else wrap.replaceChildren(chip);
};
let field: HTMLElement;
if (spec.고르기?.length) {
const pick = createSelectField({
options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
value: shown,
size: 24,
ariaLabel: spec.이름,
onChange: (v) => done(v),
});
pick.select.addEventListener("blur", () => done(null));
field = pick.root;
} else {
const box = createInputField({ value: shown, size: 24 });
box.input.setAttribute("aria-label", spec.이름);
box.input.addEventListener("keydown", (k) => {
if (k.isComposing) return;
if (k.key === "Enter") done(box.input.value);
if (k.key === "Escape") (k.preventDefault(), done(null));
});
box.input.addEventListener("blur", () => done(box.input.value));
field = box.root;
}
field.addEventListener("click", (e) => e.stopPropagation());
wrap.replaceChildren(field);
field.querySelector<HTMLElement>("select, input")?.focus();
},
});
chip.title = tl("Formula_Var");
wrap.append(chip);
return wrap;
}
const muted = (text: string): HTMLElement => el("p", { className: "ui-text ui-text--muted", text });
export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
const text = ctx.text;
if (!text) {
host.replaceChildren(muted(tl("Formula_Slot")));
return;
}
if (!text.ok) {
host.replaceChildren(
el("div", {
className: "ui-banner ui-banner--danger m01-logic__reasons",
children: [
el("strong", { text: tx("Calc_Stopped") }),
el("div", { text: safeReason(text.reason) }),
],
}),
);
return;
}
const heads = el("div", {
className: "m01lab-fx__head",
children: [
el("strong", { text: tl("Formula_Left") }),
el("strong", { text: tl("Formula_Right") }),
],
});
const rows: HTMLElement[] = [];
const specs = ctx.onInput ? (ctx.inputs ?? []) : [];
const vars = (line: TextLine): HTMLElement[] => {
const names = specs.map((x) => x.이름);
const seen = new Map(usedInputs(line, names));
// 서버 「쓰는입력」(714-1 · 조건 · 재료 고르기 · 중간값까지) 이 있으면 그것 · 없으면 조각에서
const used: [string, string][] = line.쓰는입력
? line.쓰는입력.filter((n) => names.includes(n)).map((n) => [n, seen.get(n) ?? ""])
: [...seen];
if (!used.length || !ctx.onInput) return [];
const onInput = ctx.onInput;
return [
el("div", {
className: "m01lab-fx__vars",
children: used.map(([name, was]) =>
inputChip(
specs.find((x) => x.이름 === name)!,
ctx.values?.[name] || was,
(v) => onInput(name, v),
),
),
}),
];
};
const calculated = text.계산됨 !== false; // [계산] 뒤 응답은 표식이 안 붙음 — 미리보기만 false
const light = (pair: string, on: boolean): void =>
rows.forEach((r) => r.dataset.pair === pair && r.classList.toggle("is-on", on));
const blocks = text.groups.flatMap((g) => [
el("h4", { className: "m01lab__group m01lab-fx__wide", text: g.비목 }),
...g.줄.map((line, i) => {
const pair = String(line.짝 ?? `${g.비목}${i}`);
const row = el("div", {
className: `m01lab-fx__row m01lab__text${line.까닭 ? " m01lab__text--off" : ""}`,
attrs: { "data-text-line": line.이름, "data-pair": pair },
children: [
el("div", { className: "m01lab-fx__cell", text: line.이름글 ?? line.이름 }),
el("div", {
className: "m01lab-fx__cell",
children: calculated
? [
el("div", { text: `${line.이름} : ${line.글}` }),
...vars(line),
...(line.까닭
? [
el("div", {
className: "ui-text ui-text--muted",
text: safeReason(line.까닭),
}),
]
: []),
]
: [el("div", { className: "ui-text ui-text--muted", text: tl("Formula_Slot") })],
}),
],
});
row.addEventListener("mouseenter", () => light(pair, true));
row.addEventListener("mouseleave", () => light(pair, false));
row.addEventListener("click", () => {
const pinned = row.classList.toggle("is-pin");
rows.forEach((r) => r !== row && r.classList.remove("is-pin"));
if (!pinned) row.classList.remove("is-pin");
});
rows.push(row);
return row;
}),
el("p", {
className: "m01lab__subtotal-text m01lab-fx__wide",
attrs: { "data-text-sub": String(g.소계) },
text: `${g.비목} ${tl("Subtotal")} ${formatMoney(g.소계)}${g.끝수 ? ` (${g.끝수})` : ""}`,
}),
]);
blocks.unshift(heads);
blocks.push(
el("p", {
className: "m01lab__total m01lab-fx__wide",
attrs: { "data-text-sum": String(text.계) },
text: `${tl("Total")} ${formatMoney(text.계)}`,
}),
);
host.replaceChildren(el("div", { className: "m01lab-fx", children: blocks }));
}