Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
/* =============================================================================
|
||
* M01_MasterData_UI_Test_C.ts
|
||
* 로직 테스트 — 방식 C(흐름 그림). 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계
|
||
*
|
||
* 읽기 + 시험 계산만(저장 없음 · 정본 로직은 건드리지 않음) — 로직 화면이 쓰는 같은 길
|
||
* (`fetchLogic` · `runCalc`)을 그대로 씀. 상자를 누르면 값과 출처 · 로직이 로직을 부르는
|
||
* 줄은 상자 안에서 펼침 · 설계 값을 바꾸면 흐름의 값이 바로 바뀜.
|
||
* 뼈대(어느 상자가 어디에) = `M01_MasterData_UI_Test_C_Model.ts`.
|
||
* ========================================================================== */
|
||
|
||
import { createSelectField, el, showToast } from "@ui/ui_template_elements";
|
||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||
import {
|
||
fetchLogic,
|
||
runCalc,
|
||
type CalcAnswer,
|
||
type LogicOne,
|
||
type LogicRow,
|
||
} from "./M01_MasterData_UI_Logic_Api";
|
||
import {
|
||
buildFlow,
|
||
type FlowBox,
|
||
type FlowColumn,
|
||
type FlowKind,
|
||
} from "./M01_MasterData_UI_Test_C_Model";
|
||
import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Test_Pick";
|
||
import { guideLines, plainReason, tt } from "./M01_MasterData_UI_Test_Text";
|
||
import "./M01_MasterData_UI_Test_C_Style.css";
|
||
|
||
const TEXT = {
|
||
Loading: ["불러오는 중", "Loading"],
|
||
Failed: ["불러오지 못함", "Load failed"],
|
||
Stopped: ["멈춤", "Stopped"],
|
||
NoInputs: ["받을 값 없음", "No inputs"],
|
||
Other: ["그 밖", "Other"],
|
||
Sub: ["이 로직이 부르는 로직", "Logic called here"],
|
||
SubLines: ["호표", "Unit-cost lines"],
|
||
Col_입력: ["설계 값", "Design values"],
|
||
Col_중간: ["표 찾기", "Table lookup"],
|
||
Col_수량: ["수량", "Qty"],
|
||
Col_단가: ["× 단가", "× Unit price"],
|
||
Col_덧줄: ["할증·덧줄", "Extra lines"],
|
||
Col_비목: ["비목 합계", "Cost items"],
|
||
Col_계: ["계", "Total"],
|
||
} as const satisfies Record<string, readonly [string, string]>;
|
||
|
||
const tc = (key: keyof typeof TEXT): string =>
|
||
TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0];
|
||
|
||
const title = (kind: FlowKind): string => tc(`Col_${kind}` as keyof typeof TEXT);
|
||
|
||
/** 흐름 그림 하나를 `host` 에 그림 — 로직 키 하나(어느 로직이 와도 돎) */
|
||
export function render(host: HTMLElement, logicKey: string): void {
|
||
host.replaceChildren(el("p", { className: "m01c__muted", text: tc("Loading") }));
|
||
void fetchLogic(logicKey)
|
||
.then((one) => mount(host, one))
|
||
.catch((error: unknown) => {
|
||
host.replaceChildren(
|
||
el("p", {
|
||
className: "m01c__bad",
|
||
text: error instanceof Error ? error.message : tc("Failed"),
|
||
}),
|
||
);
|
||
});
|
||
}
|
||
|
||
function mount(host: HTMLElement, one: LogicOne): void {
|
||
const row = one.logic;
|
||
const values: Record<string, string> = {};
|
||
// 고르기 칸은 첫 값으로 시작 — 설계자가 바로 흐름을 보게(수 칸은 비워 둠)
|
||
for (const spec of row.입력 ?? []) {
|
||
if (spec.고르기?.length) values[spec.이름] = String(spec.고르기[0]);
|
||
}
|
||
const swaps: Swaps = new Map();
|
||
const picker = pickPanel(one, swaps, () => run());
|
||
const open = new Set<string>();
|
||
const subs = new Map<string, LogicRow>();
|
||
let answer: CalcAnswer | null = null;
|
||
let timer: number | undefined;
|
||
|
||
const rest = el("div", { className: "m01c__rest" });
|
||
const stopped = el("p", { className: "m01c__bad", attrs: { hidden: "" } });
|
||
|
||
const boxView = (box: FlowBox): HTMLElement => {
|
||
const node = el("details", {
|
||
className: `m01c__box${box.bad ? " m01c__box--bad" : ""}`,
|
||
children: [
|
||
el("summary", {
|
||
children: [
|
||
el("span", { className: "m01c__label", text: box.label }),
|
||
el("span", { className: "m01c__value", text: box.value }),
|
||
...(box.note ? [el("span", { className: "m01c__note", text: box.note })] : []),
|
||
],
|
||
}),
|
||
el("dl", {
|
||
className: "m01c__detail",
|
||
children: box.detail.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", { text: v })]),
|
||
}),
|
||
],
|
||
});
|
||
if (open.has(box.id)) node.open = true;
|
||
node.addEventListener("toggle", () => (node.open ? open.add(box.id) : open.delete(box.id)));
|
||
if (box.logic) node.append(subView(box.logic));
|
||
return node;
|
||
};
|
||
|
||
/** 로직이 부르는 로직 — 펼칠 때 한 번 읽어 그 호표를 상자 안에 보임 */
|
||
const subView = (key: string): HTMLElement => {
|
||
const body = el("div", { className: "m01c__sub-body" });
|
||
const node = el("details", {
|
||
className: "m01c__sub",
|
||
children: [el("summary", { text: `${tc("Sub")} · ${key}` }), body],
|
||
});
|
||
const fill = (sub: LogicRow): void => {
|
||
body.replaceChildren(
|
||
el("p", { className: "m01c__muted", text: `${sub.이름} (${sub.결과단위}) · ${sub.출처}` }),
|
||
el("p", { className: "m01c__muted", text: tc("SubLines") }),
|
||
el("ul", {
|
||
children: (sub.호표 ?? []).map((item) =>
|
||
el("li", { text: `${item.이름 ?? item.요소} · ${item.수량}` }),
|
||
),
|
||
}),
|
||
);
|
||
};
|
||
node.addEventListener("toggle", () => {
|
||
if (!node.open || body.childElementCount) return;
|
||
const had = subs.get(key);
|
||
if (had) {
|
||
fill(had);
|
||
return;
|
||
}
|
||
body.replaceChildren(el("p", { className: "m01c__muted", text: tc("Loading") }));
|
||
void fetchLogic(key)
|
||
.then((deep) => {
|
||
subs.set(key, deep.logic);
|
||
fill(deep.logic);
|
||
})
|
||
.catch(() => body.replaceChildren(el("p", { className: "m01c__bad", text: tc("Failed") })));
|
||
});
|
||
return node;
|
||
};
|
||
|
||
const columnView = (column: FlowColumn): HTMLElement => {
|
||
const head = el("h4", { className: "m01c__col-head", text: title(column.kind) });
|
||
// 상자가 많으면 비목으로 묶어 접음 — 줄이 많은 로직도 한 화면에(비목 없는 줄은 「그 밖」)
|
||
const costOf = (box: FlowBox): string => box.cost ?? tc("Other");
|
||
const costs = [...new Set(column.boxes.map(costOf))];
|
||
const body =
|
||
column.boxes.length > 6 && costs.length > 1
|
||
? costs.map((cost) =>
|
||
el("details", {
|
||
className: "m01c__group",
|
||
attrs: { open: "" },
|
||
children: [
|
||
el("summary", { text: cost }),
|
||
...column.boxes.filter((b) => costOf(b) === cost).map(boxView),
|
||
],
|
||
}),
|
||
)
|
||
: column.boxes.map(boxView);
|
||
return el("section", { className: "m01c__col", children: [head, ...body] });
|
||
};
|
||
|
||
const redraw = (): void => {
|
||
const columns = buildFlow(row, answer, one.prices, values);
|
||
const nodes: HTMLElement[] = [];
|
||
for (const column of columns.slice(1)) {
|
||
nodes.push(el("span", { className: "m01c__arrow", text: "›" }), columnView(column));
|
||
}
|
||
rest.replaceChildren(...nodes);
|
||
const reason = answer && !answer.ok ? answer.reason : "";
|
||
stopped.textContent = reason ? `${tc("Stopped")} — ${plainReason(reason)}` : "";
|
||
stopped.hidden = !reason;
|
||
};
|
||
|
||
const run = (): void => {
|
||
const inputs: Record<string, unknown> = {};
|
||
for (const spec of row.입력 ?? []) {
|
||
const raw = (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);
|
||
}
|
||
const swapped = swappedRow(row, swaps);
|
||
void runCalc({ key: row.키, inputs, ...(swapped ? { row: swapped, file: one.file } : {}) })
|
||
.then((got) => {
|
||
answer = got;
|
||
redraw();
|
||
})
|
||
.catch((error: unknown) => {
|
||
showToast(error instanceof Error ? error.message : tc("Failed"), "error");
|
||
});
|
||
};
|
||
|
||
const later = (): void => {
|
||
window.clearTimeout(timer);
|
||
timer = window.setTimeout(run, 250);
|
||
};
|
||
|
||
host.replaceChildren(
|
||
el("div", {
|
||
className: "m01c",
|
||
children: [
|
||
el("div", {
|
||
className: "m01c__head",
|
||
children: [
|
||
el("h3", { text: `${row.원문번호} ${row.이름}` }),
|
||
el("span", { className: "m01c__muted", text: `${row.결과단위} · ${row.출처}` }),
|
||
],
|
||
}),
|
||
...(one.reasons.length
|
||
? [el("p", { className: "m01c__bad", text: one.reasons.join(" · ") })]
|
||
: []),
|
||
el("ul", {
|
||
className: "m01c__muted",
|
||
children: guideLines("C").map((t) => el("li", { text: t })),
|
||
}),
|
||
stopped,
|
||
...(hasPickable(row)
|
||
? [
|
||
el("details", {
|
||
attrs: { open: "" },
|
||
children: [el("summary", { text: tt("Mat_Title") }), picker],
|
||
}),
|
||
]
|
||
: []),
|
||
el("div", {
|
||
className: "m01c__flow",
|
||
children: [inputColumn(row, values, later), rest],
|
||
}),
|
||
],
|
||
}),
|
||
);
|
||
redraw();
|
||
run();
|
||
}
|
||
|
||
/** 설계 값 칸 — 한 번만 세움(다시 그려도 적던 값·글쇠 자리가 안 날아감) */
|
||
function inputColumn(
|
||
row: LogicRow,
|
||
values: Record<string, string>,
|
||
onChange: () => void,
|
||
): HTMLElement {
|
||
const fields = (row.입력 ?? []).map((spec) => {
|
||
let control: HTMLElement;
|
||
if (spec.고르기?.length) {
|
||
control = createSelectField({
|
||
options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
|
||
value: values[spec.이름] ?? "",
|
||
compact: true,
|
||
onChange: (v) => {
|
||
values[spec.이름] = v;
|
||
onChange();
|
||
},
|
||
}).root;
|
||
} else {
|
||
const box = el("input", {
|
||
className: "m01c__input",
|
||
attrs: { type: "text", inputmode: "decimal" },
|
||
});
|
||
box.placeholder = spec.범위 ? `${spec.범위[0]} ∼ ${spec.범위[1]}` : tt("Enter_Value");
|
||
box.value = values[spec.이름] ?? "";
|
||
box.addEventListener("input", () => {
|
||
values[spec.이름] = box.value;
|
||
onChange();
|
||
});
|
||
control = box;
|
||
}
|
||
return el("label", {
|
||
className: "m01c__field",
|
||
children: [
|
||
el("span", {
|
||
className: "m01c__label",
|
||
text: spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름,
|
||
}),
|
||
control,
|
||
],
|
||
});
|
||
});
|
||
return el("section", {
|
||
className: "m01c__col m01c__col--input",
|
||
children: [
|
||
el("h4", { className: "m01c__col-head", text: title("입력") }),
|
||
...(fields.length ? fields : [el("p", { className: "m01c__muted", text: tc("NoInputs") })]),
|
||
],
|
||
});
|
||
}
|