방식 C 를 테스트 컨테이너에서 꺼내 로직 화면 기본 보기로 올림(옛 호표 표는 「고급」). 상자를 누르면 값·출처에 더해 쉬운 말 풀이와 원문 표 미리보기(걸린 줄 강조)가 펼쳐짐. 자체 로직은 [고치기] 로 상자 안에서 고치고 줄을 더하거나 지움 — 정본은 「본떠 만들기」 뒤. - UI_Test_C·_Model·_Style → UI_Logic_Flow·_Model·_Style - UI_Test_Pick 의 재료·기계 바꿔 고르기를 UI_Logic_Pick 에 합침 - 방식 B 의 설명 카드 로직을 UI_Logic_Note 로 옮겨 흐름 그림과 같이 씀 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
/* =============================================================================
|
|
* M01_MasterData_UI_Test_B.ts
|
|
* 로직 사용성 테스트 컨테이너 — 방식 B: 호표 + 옆 설명 카드 (PLAN.md 1-2)
|
|
*
|
|
* 접속 계약: export function render(host, logicKey) 하나.
|
|
* 읽기 + 시험 계산만 — 저장 없음 · 정본 로직 안 고침. 로직 읽기·시험 계산은 지금
|
|
* 로직 화면이 쓰는 Store·API(fetchLogic · runCalc · elements · table)를 그대로
|
|
* 씀 — 새 엔진·새 계산 없음(만약·찾기 식 자체는 서버가 풂 · 여기선 찾기(...) 가
|
|
* 가리키는 원문 표를 다시 읽어 「걸린 줄」만 강조).
|
|
*
|
|
* 줄을 누르면 옆 카드 — 원문 표 미리보기(걸린 줄 강조) · 적용된 할증 · 쉬운 말
|
|
* 풀이(식을 문장으로, 안 되면 원문 식 그대로) · 출처. 입력을 바꾸면 호표와
|
|
* 카드가 같이 바뀜(자동 계산 — [계산] 버튼 없음).
|
|
* ========================================================================== */
|
|
|
|
import { createSelectField, el } from "@ui/ui_template_elements";
|
|
import {
|
|
fetchLogic,
|
|
runCalc,
|
|
type CalcAnswer,
|
|
type CalcLine,
|
|
type HoLine,
|
|
type LogicOne,
|
|
} from "./M01_MasterData_UI_Logic_Api";
|
|
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
|
import { buildNote, relatedFinds, type EvalCtx } from "./M01_MasterData_UI_Logic_Note";
|
|
import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Logic_Pick";
|
|
import { guideLines, plainReason, tt } from "./M01_MasterData_UI_Test_Text";
|
|
|
|
/* ── 화면 상태 ────────────────────────────────────────────────────────── */
|
|
|
|
interface State {
|
|
one: LogicOne;
|
|
values: Record<string, string>;
|
|
answer: CalcAnswer | null;
|
|
selected: number;
|
|
calcSeq: number;
|
|
swaps: Swaps;
|
|
picker: HTMLElement;
|
|
}
|
|
|
|
export function render(host: HTMLElement, logicKey: string): void {
|
|
host.replaceChildren(el("p", { className: "m01-logic__muted", text: "불러오는 중…" }));
|
|
void fetchLogic(logicKey)
|
|
.then((one) => {
|
|
const swaps: Swaps = new Map();
|
|
const state: State = {
|
|
one,
|
|
// 고르기 칸은 첫 값으로 시작 — 방식 C 와 같게(바로 계산이 돎)
|
|
values: Object.fromEntries(
|
|
(one.logic.입력 ?? [])
|
|
.filter((spec) => spec.고르기?.length)
|
|
.map((spec) => [spec.이름, String(spec.고르기?.[0])]),
|
|
),
|
|
answer: null,
|
|
selected: 0,
|
|
calcSeq: 0,
|
|
swaps,
|
|
picker: pickPanel(one, swaps, () => void recalc(host, state)),
|
|
};
|
|
paint(host, state);
|
|
void recalc(host, state);
|
|
})
|
|
.catch((error) => {
|
|
host.replaceChildren(
|
|
el("p", { className: "m01-logic__reasons", text: String(error?.message ?? error) }),
|
|
);
|
|
});
|
|
}
|
|
|
|
async function recalc(host: HTMLElement, state: State): Promise<void> {
|
|
const seq = ++state.calcSeq;
|
|
const inputs: Record<string, unknown> = {};
|
|
for (const spec of state.one.logic.입력 ?? []) {
|
|
const raw = (state.values[spec.이름] ?? "").trim();
|
|
if (raw === "") continue;
|
|
const option = spec.고르기?.find((o) => String(o) === raw);
|
|
inputs[spec.이름] =
|
|
option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw);
|
|
}
|
|
try {
|
|
const answer = await runCalc({
|
|
key: state.one.logic.키,
|
|
inputs,
|
|
row: swappedRow(state.one.logic, state.swaps) ?? state.one.logic,
|
|
file: state.one.file,
|
|
});
|
|
if (seq !== state.calcSeq) return; // 최신 응답만 채택
|
|
state.answer = answer;
|
|
} catch (error) {
|
|
if (seq !== state.calcSeq) return;
|
|
state.answer = { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
paint(host, state);
|
|
}
|
|
|
|
/* ── 그리기 ───────────────────────────────────────────────────────────── */
|
|
|
|
function paint(host: HTMLElement, state: State): void {
|
|
const row = state.one.logic;
|
|
|
|
const inputWrap = el("div", { className: "m01-logic__fields" });
|
|
inputWrap.style.cssText = "display:flex;flex-wrap:wrap;gap:12px;margin-bottom:12px;";
|
|
for (const spec of row.입력 ?? []) {
|
|
let control: HTMLElement;
|
|
if (spec.고르기?.length) {
|
|
const pick = createSelectField({
|
|
options: ["", ...spec.고르기.map(String)].map((o) => ({ value: o, text: o || "(선택)" })),
|
|
value: state.values[spec.이름] ?? "",
|
|
compact: true,
|
|
onChange: (v) => {
|
|
state.values[spec.이름] = v;
|
|
void recalc(host, state);
|
|
},
|
|
});
|
|
control = pick.root;
|
|
} else {
|
|
const box = el("input", {
|
|
className: "m01-logic__input",
|
|
attrs: { type: "text", inputmode: "decimal", placeholder: tt("Enter_Value") },
|
|
});
|
|
box.value = state.values[spec.이름] ?? "";
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
box.addEventListener("input", () => {
|
|
state.values[spec.이름] = box.value;
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => void recalc(host, state), 300);
|
|
});
|
|
control = box;
|
|
}
|
|
const label = spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름;
|
|
inputWrap.append(
|
|
el("label", {
|
|
className: "m01-logic__field",
|
|
children: [el("span", { text: label }), control],
|
|
}),
|
|
);
|
|
}
|
|
|
|
const main = el("div", {});
|
|
main.style.cssText = "flex:1 1 60%;min-width:0;";
|
|
main.append(
|
|
el("h3", { text: `${row.원문번호} ${row.이름}` }),
|
|
el("ul", {
|
|
className: "m01-logic__muted",
|
|
children: guideLines("B").map((t) => el("li", { text: t })),
|
|
}),
|
|
inputWrap,
|
|
...(hasPickable(state.one.logic)
|
|
? [
|
|
el("details", {
|
|
attrs: { open: "" },
|
|
children: [el("summary", { text: tt("Mat_Title") }), state.picker],
|
|
}),
|
|
]
|
|
: []),
|
|
buildHoTable(host, state),
|
|
);
|
|
if (state.answer && !state.answer.ok) {
|
|
main.append(
|
|
el("div", {
|
|
className: "m01-logic__reasons",
|
|
children: [
|
|
el("strong", { text: "멈춤" }),
|
|
el("div", { text: plainReason(state.answer.reason) }),
|
|
],
|
|
}),
|
|
);
|
|
}
|
|
|
|
const side = el("div", { className: "m01-logic__side" });
|
|
side.style.cssText =
|
|
"flex:1 1 36%;min-width:280px;border:1px solid var(--ui-border,#ddd);border-radius:8px;padding:12px;";
|
|
buildCard(side, state);
|
|
|
|
const layout = el("div", {});
|
|
layout.style.cssText = "display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap;";
|
|
layout.append(main, side);
|
|
host.replaceChildren(layout);
|
|
}
|
|
|
|
function buildHoTable(host: HTMLElement, state: State): HTMLElement {
|
|
const lines: CalcLine[] | null = state.answer?.ok ? (state.answer.lines ?? null) : null;
|
|
const rows = (state.one.logic.호표 ?? []).map((ho, i) => {
|
|
const calc = lines?.[i];
|
|
const tr = el("tr", {
|
|
className: i === state.selected ? "m01-logic__row is-selected" : "m01-logic__row",
|
|
children: [
|
|
el("td", { text: ho.이름 ?? ho.요소 }),
|
|
el("td", { text: ho.규격 ?? "" }),
|
|
el("td", { text: calc ? `${formatNumber(calc.수량)} ${calc.단위}` : (ho.단위 ?? "") }),
|
|
el("td", { className: "m01-logic__money", text: calc ? formatNumber(calc.금액) : "" }),
|
|
],
|
|
});
|
|
tr.style.cssText =
|
|
"cursor:pointer;" + (i === state.selected ? "background:var(--ui-accent-bg,#eef4ff);" : "");
|
|
tr.addEventListener("click", () => {
|
|
state.selected = i;
|
|
paint(host, state);
|
|
});
|
|
return tr;
|
|
});
|
|
return el("table", {
|
|
className: "m01-logic__grid",
|
|
children: [
|
|
el("thead", {
|
|
children: [
|
|
el("tr", {
|
|
children: ["이름", "규격", "수량", "금액"].map((h) => el("th", { text: h })),
|
|
}),
|
|
],
|
|
}),
|
|
el("tbody", {
|
|
children: [
|
|
...rows,
|
|
// 하위 로직이 여러 줄로 풀린 경우 — 호표 줄 수보다 많은 줄도 빠짐없이 보임
|
|
...(lines ?? []).slice((state.one.logic.호표 ?? []).length).map((calc) =>
|
|
el("tr", {
|
|
children: [
|
|
el("td", { text: calc.이름 }),
|
|
el("td", { text: "" }),
|
|
el("td", { text: `${formatNumber(calc.수량)} ${calc.단위}` }),
|
|
el("td", { className: "m01-logic__money", text: formatNumber(calc.금액) }),
|
|
],
|
|
}),
|
|
),
|
|
...(state.answer?.ok && state.answer.sums
|
|
? [
|
|
el("tr", {
|
|
attrs: { "data-total": String(state.answer.sums["계"] ?? 0) },
|
|
children: [
|
|
el("td", { text: tt("Sum_Total") }),
|
|
el("td", { text: "" }),
|
|
el("td", { text: "" }),
|
|
el("td", { text: formatNumber(state.answer.sums["계"] ?? 0) }),
|
|
],
|
|
}),
|
|
]
|
|
: []),
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
|
|
function buildCard(side: HTMLElement, state: State): void {
|
|
const ho: HoLine | undefined = (state.one.logic.호표 ?? [])[state.selected];
|
|
if (!ho) {
|
|
side.replaceChildren(el("p", { className: "m01-logic__muted", text: "호표 줄이 없음" }));
|
|
return;
|
|
}
|
|
const lines = state.answer?.ok ? (state.answer.lines ?? []) : [];
|
|
const calc = lines[state.selected];
|
|
const middle = state.answer?.ok ? (state.answer.middle ?? {}) : {};
|
|
const ctx: EvalCtx = { values: state.values, middle };
|
|
const related = relatedFinds(ho.수량, state.one.logic.중간 ?? []);
|
|
|
|
side.replaceChildren(
|
|
el("h4", { text: `이 수량은 어디서 왔나 — ${ho.이름 ?? ho.요소}` }),
|
|
el("dl", {
|
|
className: "m01-logic__sums",
|
|
children: [
|
|
el("dt", { text: "지금 값" }),
|
|
el("dd", {
|
|
text: calc
|
|
? `${formatNumber(calc.수량)} ${calc.단위} · ${formatNumber(calc.금액)}원`
|
|
: "값을 넣어야 계산됨",
|
|
}),
|
|
el("dt", { text: "출처" }),
|
|
el("dd", { text: calc?.출처 ?? ho.비목 ?? "-" }),
|
|
],
|
|
}),
|
|
);
|
|
|
|
const surcharge = related.filter((r) => r.source !== "이 줄" && r.source.includes("할증"));
|
|
if (surcharge.length) {
|
|
side.append(
|
|
el("h4", { text: "적용된 할증" }),
|
|
...surcharge.map((r) =>
|
|
el("p", {
|
|
text: `${r.source} = ${middle[r.source] !== undefined ? formatNumber(middle[r.source]) : "(입력 필요)"}`,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
// 쉬운 말 풀이 · 원문 표 미리보기 · 고급(식 그대로) — 흐름 그림과 같은 카드
|
|
const note = el("div");
|
|
side.append(note);
|
|
buildNote(note, { expr: ho.수량, middles: state.one.logic.중간 ?? [], ctx });
|
|
}
|