- 753-1 서버 줄 덧칸 `옵션꺼짐` 을 화면 넷이 읽어 그 줄을 안 그림 — 공용 상세 박스(`LogicLab_Ho` boxCard · 장비 · 하위 몫은 다 빠졌을 때만) · 텍스트 수식(`LogicLab_Formula`) · 시험 계산 표(`Logic_Calc` answerView) · 공용 계산서(`calc_sheet` · M01 인력재료경비 · M02 `sheetGroups`) - 옵션 칸 · 소계 · 금액 · 차례 · cid 그대로(옵션 칸은 빠진 줄의 쓰는 입력도 포함) — 옵션을 바꾸면 보이는 줄이 바뀜 - M02 표 줄 `CoverRow.off` · 계산서 줄 `옵션꺼짐` - 새 지킴 test_750_2 에 753-2 대조(표 줄 off → 계산서 줄 · 소계 그대로) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
238 lines
10 KiB
TypeScript
238 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* M01_MasterData_UI_LogicLab_Formula.ts
|
|
* 「텍스트 수식」 컨테이너 — 서버(`POST /text`)가 준 줄을 그대로 찍음(화면에서 다시 계산하지 않음)
|
|
* 두 칸 — 왼쪽 = 변수 이름 식(`이름글`) · 오른쪽 = 값이 들어간 식(`글`) · 줄은 `짝` 번호로 같은 높이
|
|
* 한쪽에 올리거나 누르면 같은 줄이 밝아짐 · 비목 제목 · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게
|
|
* 737-2 — 비목(노무비 · 재료비 · 경비)마다 구분 상자 · 맨 위 옵션 고르기(그 상자 줄이 쓰는 입력 · 서버 `쓰는입력` · 없으면 표 찾기 행 값 · 이름 조각 + 어느 줄도 안 쓰는 공통 입력 · 6 → 4 → 3 → 2 → 1 열) →
|
|
* 바꾸면 `onInput`(시험 계산 입력 고침 + 다시 셈) · 정본은 안 바뀜(요구서 4-1 확정 8)
|
|
* ========================================================================== */
|
|
|
|
import { createButton, el } from "@ui/ui_template_elements";
|
|
import { createCard } from "@ui/ui_template_card";
|
|
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
|
import type { LogicInput, TextAnswer, TextLine } from "./M01_MasterData_UI_Logic_Api";
|
|
import { optionGrid, varRow } from "../A00_Common/calc_sheet";
|
|
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 { buildFoot, FOOT_KEYS, FOOT_OF, type FootKey } from "./M01_MasterData_UI_LogicLab_Foot";
|
|
import { nameTokens, tokenRow, valueTokens } from "./M01_MasterData_UI_LogicLab_FormulaTokens";
|
|
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;
|
|
/** 748-2 줄 → 그 줄 자료 모달 열기(변수 · 값을 누르면) — 없으면 글자만 */
|
|
open?: (line: TextLine) => (() => void) | undefined;
|
|
}
|
|
|
|
/* ── 순수 셈: 줄이 쓰는 입력 ── */
|
|
/** 줄의 조각에서 시험 입력 이름 → 그 줄이 쓴 값 — 표 찾기 행 열쇠 · 이름 조각(`값` 이 붙은 이름) · 차례 = 처음 나온 차례 */
|
|
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` · 722-1 계산식 칸도 씀 */
|
|
export 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: 20,
|
|
tone: "option",
|
|
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("Var_Tone")} · ${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 = (): HTMLElement =>
|
|
el("div", {
|
|
className: "m01lab-fx__head",
|
|
children: [
|
|
el("strong", { text: tl("Formula_Left") }),
|
|
el("strong", { text: tl("Formula_Right") }),
|
|
],
|
|
});
|
|
const specs = ctx.onInput ? (ctx.inputs ?? []) : [];
|
|
// 737-2 옵션 칸 = 비목 상자 맨 위 — 그 상자 줄이 쓰는 입력 + 어느 줄도 안 쓰는 입력(공통 · 737-3)
|
|
const names = specs.map((x) => x.이름);
|
|
const usedBy = (line: TextLine): string[] =>
|
|
line.쓰는입력
|
|
? line.쓰는입력.filter((n) => names.includes(n))
|
|
: usedInputs(line, names).map(([n]) => n);
|
|
const everyUsed = new Set(text.groups.flatMap((g) => g.줄.flatMap(usedBy)));
|
|
const common = names.filter((n) => !everyUsed.has(n));
|
|
const options = (g: (typeof text.groups)[number]): HTMLElement[] => {
|
|
const onInput = ctx.onInput;
|
|
if (!onInput) return [];
|
|
const mine = new Set([...g.줄.flatMap(usedBy), ...common]);
|
|
return optionGrid(
|
|
specs
|
|
.filter((x) => mine.has(x.이름))
|
|
.map((x) => varRow(x, ctx.values?.[x.이름] ?? "", (v) => onInput(x.이름, v))),
|
|
);
|
|
};
|
|
const calculated = text.계산됨 !== false; // [계산] 뒤 응답은 표식이 안 붙음 — 미리보기만 false
|
|
const onInput = ctx.onInput;
|
|
/** 변수 이름이 시험 입력이면 옵션 고르기 · 아니면 그 줄 모달 */
|
|
const spec = (name: string): LogicInput | undefined =>
|
|
onInput ? specs.find((x) => x.이름 === name) : undefined;
|
|
// 737-2 비목마다 구분 상자 — 맨 위 옵션 칸 · 아래 두 칸 식 줄들 · 소계
|
|
const boxes = text.groups.map((g) => {
|
|
const visible = g.줄.filter((l) => !l.옵션꺼짐); // 753-2 옵션으로 빠진 줄은 안 그림
|
|
const lines = visible.map((line, i) => {
|
|
const pair = String(line.짝 ?? `${g.비목}${i}`);
|
|
const open = ctx.open?.(line);
|
|
return 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",
|
|
children: [
|
|
tokenRow(nameTokens(line.이름글 ?? line.이름), open, (name) => {
|
|
const s = spec(name);
|
|
return s && onInput
|
|
? inputChip(s, ctx.values?.[name] ?? "", (v) => onInput(name, v))
|
|
: null;
|
|
}),
|
|
],
|
|
}),
|
|
el("div", {
|
|
className: "m01lab-fx__cell",
|
|
children: calculated
|
|
? [
|
|
el("div", {
|
|
className: "m01lab-fx__val",
|
|
children: [
|
|
el("span", { text: `${line.이름} :` }),
|
|
tokenRow(valueTokens(line.글), open),
|
|
],
|
|
}),
|
|
...(line.까닭
|
|
? [
|
|
el("div", {
|
|
className: "ui-text ui-text--muted",
|
|
text: safeReason(line.까닭),
|
|
}),
|
|
]
|
|
: []),
|
|
]
|
|
: [el("div", { className: "ui-text ui-text--muted", text: tl("Formula_Slot") })],
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
const card = createCard({
|
|
title: g.비목,
|
|
pad: 12,
|
|
body: [
|
|
...options(g),
|
|
el("div", {
|
|
className: "m01lab-fx",
|
|
children: [
|
|
heads(),
|
|
...lines,
|
|
el("p", {
|
|
className: "m01lab__subtotal-text m01lab-fx__wide",
|
|
attrs: { "data-text-sub": String(g.소계) },
|
|
text: `${g.비목} ${tl("Subtotal")} ${formatMoney(g.소계)}${g.끝수 ? ` (${g.끝수})` : ""}`,
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
card.classList.add("m01lab__ho-box");
|
|
card.dataset.fxBox = g.비목;
|
|
return card;
|
|
});
|
|
// 748-3 맨 아래 비목 소계 고정 줄(노무비 · 재료비 · 경비 — 계 칸 없음 · 상자 소계는 그대로)
|
|
const sums = Object.fromEntries(
|
|
FOOT_KEYS.map((k) => [k, text.groups.find((g) => FOOT_OF[g.비목] === k)?.소계 ?? 0]),
|
|
) as Record<FootKey, number>;
|
|
const divide =
|
|
text.나눔 && text.나눔 !== 1
|
|
? [muted(`${tl("Formula_Divide")} ÷ ${text.나눔}`)] // M02 750-1 단위 환산 — 식은 바탕 단위 그대로
|
|
: [];
|
|
host.replaceChildren(...divide, ...boxes, buildFoot(sums, calculated));
|
|
}
|