Files
Aislo/M01_MasterData/M01_MasterData_UI_Test_C.ts
T

285 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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 { 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(" · ") })]
: []),
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") })]),
],
});
}