Merge remote-tracking branches 'origin/main_desktop_1', 'origin/sub_desktop_1', 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
27 changed files with 35982 additions and 30861 deletions
+8
View File
@@ -124,6 +124,14 @@ def get_pick(kind: str, q: str = "", limit: int = 50) -> dict:
return _call(store.pick, kind, q, limit)
@router.get("/materials")
def get_materials(
sub: str, detail: str = "", spec: str = "", region: str = "", limit: int = 50
) -> dict:
"""재료 고르기 조건 안 후보 — sub = 구분 · detail = 상세구분 · spec = 규격 · region = 자재지역."""
return _call(store.materials, sub, detail, spec, region, limit)
@router.post("/calc")
def post_calc(body: CalcBody) -> dict:
return _call(store.calc, body.key, body.inputs, body.row, body.file)
+23
View File
@@ -412,6 +412,29 @@ def pick(kind: str, q: str, limit: int) -> dict:
return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]}
def _market_brief(row: dict) -> dict:
return {
"ref": str(row.get("키")),
**{k: row.get(k) for k in ("구분", "상세구분", "이름", "규격", "단위")},
"값": _lowest(row),
"관급": row.get("관급") is not None,
}
def materials(sub: str, detail: str, spec: str, region: str, limit: int) -> dict:
"""재료 고르기 조건 안 후보 목록 — 화면·테스트 컨테이너가 같이 씀.
`기본` = 시험 계산이 쓸 줄(대표 줄이 없을 때의 첫 줄)."""
whole = cm.master(folder=FOLDER)
cond = {"구분": sub, "상세구분": detail or None, "규격": spec or None}
env = {cm.mf.REGION: region} if region else {}
keys = cm.mf.candidates(whole, cond, env)
return {
"total": len(keys),
"기본": cm.mf.pick(whole, cond, env) if keys else None,
"items": [_market_brief(whole.get(k)) for k in keys[: min(max(limit, 1), 200)]],
}
# ── 시험 계산 ──────────────────────────────────────────────────────────
def _swap(files: dict, key: str, row: dict, file: str | None) -> str:
"""저장 전 시험 — 메모리 사본에서 그 로직을 고친 줄로 바꿈(없으면 `file` 에 더함) · 돌려받는 것 = 계산할 키."""
+36 -16
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* M01_MasterData_UI_Page.ts
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너 여덟: 인력·재료·기계·소요량·계수·환율·요율·일위대가 로직)
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너 아홉: 인력·재료·기계·소요량·계수·환율·요율·일위대가 로직·로직 테스트)
* / 우측 요소 표 · 표 목록 · 로직 편집
*
* 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 —
@@ -45,6 +45,7 @@ function buildPage(): HTMLElement {
let query = "";
let dispose = (): void => {};
let logicMounted = false;
let testMounted = false;
/* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */
const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") });
@@ -60,6 +61,7 @@ function buildPage(): HTMLElement {
children: [title, searchField.root, summary, drop, save],
});
const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
const testHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
const elementView = el("div", {
className: "m01-master__elements",
children: [head, notice, body],
@@ -77,14 +79,15 @@ function buildPage(): HTMLElement {
onDraftChange(refreshBar);
refreshBar();
const showMode = (logic: boolean): void => {
logicHost.hidden = !logic;
elementView.hidden = logic;
const showMode = (mode: "elements" | "logic" | "test"): void => {
logicHost.hidden = mode !== "logic";
testHost.hidden = mode !== "test";
elementView.hidden = mode !== "elements";
};
const openFile = (pick: Pick): void => {
dispose();
showMode(false);
showMode("elements");
if (pick.view) {
query = pick.view.q;
search.value = query;
@@ -162,21 +165,38 @@ function buildPage(): HTMLElement {
return [L("M01_Stale").replace("{value}", stale.map(fileLabel).join(", ")), again];
};
/* --- 좌측: 컨테이너 여덟 --- */
const side = buildSide(openFile, () => {
dispose();
dispose = (): void => {};
current = null;
showMode(true);
if (logicMounted) return;
logicMounted = true;
void import("./M01_MasterData_UI_Logic_Page").then((m) => m.mountM01Logic(logicHost, side));
});
/* --- 좌측: 컨테이너 아홉 --- */
const side = buildSide(
openFile,
() => {
dispose();
dispose = (): void => {};
current = null;
showMode("logic");
if (logicMounted) return;
logicMounted = true;
void import("./M01_MasterData_UI_Logic_Page").then((m) => m.mountM01Logic(logicHost, side));
},
() => {
dispose();
dispose = (): void => {};
current = null;
showMode("test");
if (testMounted) return;
testMounted = true;
void import("./M01_MasterData_UI_Test").then((m) => m.mountM01Test(testHost));
},
);
const layout = el("div", { className: "ui-workflow-layout m01-master" });
const main = el("main", {
className: "ui-workflow-layout__main",
children: [el("div", { className: "m01-master__panel", children: [elementView, logicHost] })],
children: [
el("div", {
className: "m01-master__panel",
children: [elementView, logicHost, testHost],
}),
],
});
const overlays = createWorkflowOverlays({
title: L("M01_Title"),
+16 -2
View File
@@ -104,8 +104,13 @@ const LEAF: Group[] = ["환율", "요율"];
/** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */
const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"];
const LOGIC_ID = "로직|";
const TEST_ID = "테스트|";
export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): SideHandle {
export function buildSide(
onOpen: (pick: Pick) => void,
onLogic: () => void,
onTest: () => void,
): SideHandle {
const lists = new Map<Group, FileInfo[]>();
const bodies = new Map<Group, HTMLElement>();
const buttons = new Map<string, HTMLElement>();
@@ -375,7 +380,16 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
onLogic();
});
const root = el("div", { className: "m01-side", children: [...groups, logic] });
// 테스트 컨테이너 — 눌러 펼치는 목록이 없는 한 줄(환율·요율과 같은 모양)
const test = section(L("M01_GroupTest"), null);
test.classList.add("m01-side__leaf");
buttons.set(TEST_ID, test);
test.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
setActive(TEST_ID);
onTest();
});
const root = el("div", { className: "m01-side", children: [...groups, logic, test] });
attachCollapsible(root);
void Promise.all(GROUPS.map(refresh)).catch((error) =>
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"),
+98
View File
@@ -0,0 +1,98 @@
/* =============================================================================
* M01_MasterData_UI_Test.ts
* 로직 테스트 컨테이너 틀 — 공종(로직) 10 개 고르기 + 방식 탭 A·B·C
* 읽기와 시험 계산만 — 저장 없음 · 정본 로직은 안 고침 · 계산은 로직 화면과 같은 `/calc`
* 접속 계약 — 방식 파일마다 `render(host, logicKey)` 하나
* ========================================================================== */
import { createSelectField, el, showToast } from "@ui/ui_template_elements";
import { fetchLogics } from "./M01_MasterData_UI_Logic_Api";
import { render as renderA } from "./M01_MasterData_UI_Test_A";
import { tt } from "./M01_MasterData_UI_Test_Text";
import "./M01_MasterData_UI_Test_Style.css";
/** 시험 대상 산림품셈 로직 10 개 — 복잡도(입력·표 찾기·로직 부르기·덧줄·재료)로 잰 순서.
* 재는 법 `resources/master_data/scripts/로직_복잡도_재기.py` · 메쌓기·노체·트럭 운반은 끝까지 눌러 보는 세 개. */
export const TEST_KEYS: readonly string[] = [
"GF000181", // 12-16 맨홀
"GF000177", // 12-12 날개벽
"GF000173", // 12-11-1 VR관(소켓식) 부설
"GF000174", // 12-11-2-1 흄관 부설
"GF000160", // 12-4 합판거푸집
"GF000182", // 12-17-1 펌프카 타설
"GF000069", // 7-11 동력상하차기 집재
"GF000219", // 13-4-1 돌쌓기 메쌓기(인력)
"GF000119", // 9-16-1 노체포설
"GF000137", // 10-4 중기운반 트레일러
];
type Render = (host: HTMLElement, logicKey: string) => void;
/** 방식 파일이 오면 여기 한 줄씩 잇는다 — null 은 준비 중 */
const METHODS: { id: string; label: () => string; render: Render | null }[] = [
{ id: "A", label: () => tt("Tab_A"), render: renderA },
{ id: "B", label: () => tt("Tab_B"), render: null },
{ id: "C", label: () => tt("Tab_C"), render: null },
];
export function mountM01Test(host: HTMLElement): void {
let logicKey = TEST_KEYS[0];
let method = METHODS[0].id;
const body = el("div", { className: "m01-test__body" });
const tabs = el("div", { className: "m01-test__tabs" });
const select = createSelectField({
label: tt("Pick_Logic"),
options: TEST_KEYS.map((k) => ({ value: k, text: k })),
value: logicKey,
onChange: (v) => {
logicKey = v;
draw();
},
});
const draw = (): void => {
tabs.replaceChildren(
...METHODS.map((m) =>
el("button", {
className: `m01-test__tab${m.id === method ? " is-active" : ""}`,
text: m.label(),
attrs: { type: "button", "data-method": m.id },
}),
),
);
tabs.querySelectorAll("button").forEach((b) =>
b.addEventListener("click", () => {
method = b.dataset.method ?? "A";
draw();
}),
);
const one = METHODS.find((m) => m.id === method);
if (one?.render) one.render(body, logicKey);
else body.replaceChildren(el("p", { className: "m01-test__muted", text: tt("Soon") }));
};
host.replaceChildren(
el("div", {
className: "m01-test",
children: [
el("div", {
className: "m01-test__bar",
children: [el("h2", { text: tt("Title") }), select.root, tabs],
}),
el("p", { className: "m01-test__muted", text: tt("Read_Only") }),
body,
],
}),
);
// 목록에서 이름을 받아 「번호 이름」 으로 바꿈 — 못 받아도 키로 쓸 수 있음
fetchLogics()
.then((all) => {
const label = new Map(all.map((r) => [r.키, `${r.원문번호} ${r.이름}`]));
select.setOptions(
TEST_KEYS.map((k) => ({ value: k, text: label.get(k) ?? k })),
logicKey,
);
})
.catch((error) => showToast(error instanceof Error ? error.message : tt("Failed"), "error"));
draw();
}
+274
View File
@@ -0,0 +1,274 @@
/* =============================================================================
* M01_MasterData_UI_Test_A.ts
* 방식 A — 질문·답 마법사: 공종 → 조건 질문(한 번에 하나) → 재료·기계 고르기 → 결과 호표
* 식은 안 보임 · 시험 계산은 로직 화면과 같은 `/calc` · 저장 없음
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import {
fetchLogic,
runCalc,
searchElements,
type CalcAnswer,
type ElementBrief,
type HoLine,
type LogicInput,
type LogicOne,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { tt } from "./M01_MasterData_UI_Test_Text";
type Step =
{ kind: "job" } | { kind: "ask"; spec: LogicInput } | { kind: "pick" } | { kind: "result" };
const PICKABLE = ["재료", "기계"];
export function render(host: HTMLElement, logicKey: string): void {
host.replaceChildren(el("p", { className: "m01-test__muted", text: tt("Loading") }));
fetchLogic(logicKey)
.then((one) => wizard(host, one))
.catch((error) => showToast(error instanceof Error ? error.message : tt("Failed"), "error"));
}
function wizard(host: HTMLElement, one: LogicOne): void {
const { logic } = one;
const values: Record<string, string> = {};
const swaps = new Map<number, ElementBrief>();
const lines = (logic.호표 ?? [])
.map((line, i) => ({ line, i }))
.filter((x) => PICKABLE.includes(x.line.종류));
const steps: Step[] = [
{ kind: "job" },
...(logic.입력 ?? []).map((spec): Step => ({ kind: "ask", spec })),
...(lines.length ? [{ kind: "pick" } as Step] : []),
{ kind: "result" },
];
let at = 0;
const go = (next: number): void => {
at = next;
draw();
};
const frame = (
title: string,
note: string,
content: HTMLElement[],
nextOk: boolean,
): HTMLButtonElement => {
const last = steps[at].kind === "result";
const back = createButton({ label: tt("Back"), variant: "ghost", onClick: () => go(at - 1) });
back.disabled = at === 0;
const next = createButton({ label: tt("Next"), onClick: () => go(at + 1) });
next.disabled = !nextOk;
const again = createButton({ label: tt("Again"), variant: "ghost", onClick: () => go(0) });
host.replaceChildren(
el("div", {
className: "m01-test__card",
children: [
el("p", {
className: "m01-test__step",
text: tt("Step", { n: at + 1, total: steps.length }),
}),
el("h3", { text: title }),
...(note ? [el("p", { className: "m01-test__muted", text: note })] : []),
...content,
el("div", { className: "m01-test__nav", children: last ? [back, again] : [back, next] }),
],
}),
);
return next;
};
const draw = (): void => {
const step = steps[at];
if (step.kind === "job") {
frame(
`${logic.원문번호} ${logic.이름}`,
tt("Job_Note"),
[el("p", { text: `${tt("Job_Title")} · ${logic.결과단위}` })],
true,
);
} else if (step.kind === "ask") drawAsk(step.spec);
else if (step.kind === "pick") drawPick();
else void drawResult();
};
const drawAsk = (spec: LogicInput): void => {
const name = spec.이름;
const ready = (): boolean => (values[name] ?? "").trim() !== "";
let next: HTMLButtonElement | null = null;
const changed = (v: string): void => {
values[name] = v;
if (next) next.disabled = !ready();
};
let control: HTMLElement;
if (spec.고르기?.length) {
control = createSelectField({
options: ["", ...spec.고르기.map(String)].map((o) => ({ value: o, text: o })),
value: values[name] ?? "",
onChange: changed,
}).root;
} else {
const box = el("input", {
className: "m01-test__input",
attrs: { type: "text", inputmode: "decimal" },
});
box.value = values[name] ?? "";
box.addEventListener("input", () => changed(box.value));
control = box;
}
const question = spec.고르기?.length
? tt("Ask_Pick", { name })
: tt("Ask_Number", { name, unit: spec.단위 ? ` (${spec.단위})` : "" });
const hint = spec.범위 ? tt("Ask_Range", { min: spec.범위[0], max: spec.범위[1] }) : "";
next = frame(question, hint, [control], ready());
};
const drawPick = (): void => {
const rows = lines.map(({ line, i }) => pickRow(line, i));
frame(
tt("Mat_Title"),
tt("Mat_Note"),
rows.length ? rows : [el("p", { text: tt("Mat_None") })],
true,
);
};
const pickRow = (line: HoLine, i: number): HTMLElement => {
const price = one.prices[line.요소]?.값;
const found = new Map<string, ElementBrief>();
const select = createSelectField({
options: [{ value: "", text: tt("Mat_Default") }],
value: "",
onChange: (ref) => {
const item = found.get(ref);
if (item) swaps.set(i, item);
else swaps.delete(i);
},
});
void searchElements(line.종류, line.이름 ?? "")
.then((r) => {
r.items.forEach((it) => found.set(it.ref, it));
select.setOptions(
[
{ value: "", text: tt("Mat_Default") },
...r.items.map((it) => ({
value: it.ref,
text: [`${it.이름 ?? ""} ${it.규격 ?? ""}`.trim(), formatNumber(it.값)]
.filter(Boolean)
.join(" · "),
})),
],
swaps.get(i)?.ref ?? "",
);
})
.catch(() => undefined);
const spec = line.규격 ? ` ${line.규격}` : "";
const unit = line.단위 ?? "";
const cost =
price !== undefined && price !== null ? ` · ${tt("Mat_Price")} ${formatNumber(price)}` : "";
return el("div", {
className: "m01-test__pick",
children: [
el("strong", { text: `${line.종류} · ${line.이름 ?? line.요소}${spec}` }),
el("span", { className: "m01-test__muted", text: `${unit}${cost}` }),
select.root,
],
});
};
const drawResult = async (): Promise<void> => {
const inputs: Record<string, unknown> = {};
for (const spec of logic.입력 ?? []) {
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 row = swaps.size
? {
...logic,
호표: (logic.호표 ?? []).map((l, i) => {
const s = swaps.get(i);
return s ? { ...l, 요소: s.ref, 이름: s.이름 ?? l.이름, 규격: s.규격 ?? l.규격 } : l;
}),
}
: undefined;
let answer: CalcAnswer;
try {
answer = await runCalc({ key: logic.키, inputs, ...(row ? { row, file: one.file } : {}) });
} catch (error) {
showToast(error instanceof Error ? error.message : tt("Failed"), "error");
return;
}
frame(tt("Result_Title"), "", resultView(answer), true);
};
draw();
}
function resultView(answer: CalcAnswer): HTMLElement[] {
if (!answer.ok) {
return [
el("div", {
className: "m01-test__reasons",
children: [
el("strong", { text: tt("Result_Stopped") }),
el("div", { text: answer.reason }),
],
}),
];
}
const out: HTMLElement[] = [];
if (answer.lines) {
out.push(
el("table", {
className: "m01-test__grid",
children: [
el("thead", {
children: [
el("tr", {
children: [tt("Col_Name"), tt("Col_Qty"), tt("Col_Amount")].map((h) =>
el("th", { text: h }),
),
}),
],
}),
el("tbody", {
children: answer.lines.map((l) =>
el("tr", {
children: [
el("td", { text: l.출처 ? `${l.이름} (${l.출처})` : l.이름 }),
el("td", { text: `${formatNumber(l.수량)} ${l.단위}` }),
el("td", { className: "m01-test__money", text: formatNumber(l.금액) }),
],
}),
),
}),
],
}),
);
}
if (answer.sums) {
out.push(
el("p", {
className: "m01-test__total",
attrs: { "data-total": String(answer.sums["계"] ?? 0) },
text: `${tt("Sum_Total")} ${formatNumber(answer.sums["계"] ?? 0)}`,
}),
);
}
if (answer.result !== undefined) {
out.push(
el("p", {
className: "m01-test__total",
attrs: { "data-total": String(answer.result) },
text: `${tt("Sum_Result")} ${formatNumber(answer.result)}`,
}),
);
}
return out;
}
+271
View File
@@ -0,0 +1,271 @@
/* =============================================================================
* 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 "./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 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")} — ${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);
}
void runCalc({ key: row.키, inputs })
.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,
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" },
});
if (spec.범위) box.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`;
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") })]),
],
});
}
@@ -0,0 +1,238 @@
/* =============================================================================
* M01_MasterData_UI_Test_C_Model.ts
* 방식 C(흐름 그림)의 뼈대 — 로직 한 줄 + 시험 계산 답 → 왼쪽에서 오른쪽 칸의 상자.
* 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계
*
* 순수 — DOM·서버를 안 씀(시험이 이 파일만 떼어 Node 로 돌림).
* 값은 모두 시험 계산 답(`POST /calc`)에서 옴 — 여기서 새로 셈하지 않음.
* ========================================================================== */
import type {
CalcAnswer,
CalcLine,
ElementBrief,
HoLine,
LogicRow,
NamedFormula,
} from "./M01_MasterData_UI_Logic_Api";
export type FlowKind = "입력" | "중간" | "수량" | "단가" | "덧줄" | "비목" | "계";
/** 흐름 상자 하나 — 누르면 `detail`(값과 출처) */
export interface FlowBox {
/** 다시 그려도 같은 id — 펼친 상자를 그대로 둠 */
id: string;
kind: FlowKind;
label: string;
value: string;
note?: string;
detail: [string, string][];
/** 로직이 로직을 부르는 줄 — 그 로직 키(펼치기) */
logic?: string;
/** 비목 — 상자가 많은 로직에서 묶기·접기 */
cost?: string;
/** 값이 없어 막힌 상자 */
bad?: boolean;
}
export interface FlowColumn {
kind: FlowKind;
boxes: FlowBox[];
}
export const COSTS = ["노무비", "재료비", "경비"];
export function fmt(value: unknown): string {
if (typeof value === "number") return value.toLocaleString("ko-KR", { maximumFractionDigits: 4 });
if (value === null || value === undefined) return "";
return typeof value === "object" ? JSON.stringify(value) : String(value);
}
/** 로직 줄의 결과가 돈인지 — 돈이면 호표·비목·계, 아니면 결과 식 하나 */
export const isMoney = (row: LogicRow): boolean =>
!("결과" in row) && (row.결과단위 ?? "").startsWith("원");
/** `로직(GC000268, 기계='…')` 에서 부르는 로직 키 */
export function logicRef(element: string): string | undefined {
return /로직\(\s*([A-Z]{1,2}\w+)/.exec(element)?.[1];
}
const keep = (pairs: [string, unknown][]): [string, string][] =>
pairs.filter(([, v]) => v !== undefined && v !== null && v !== "").map(([k, v]) => [k, fmt(v)]);
/** 파일에 있으나 화면 틀에 없는 칸(입력·호표의 출처·비고) */
const extra = (item: object, name: string): string | undefined =>
(item as Record<string, string | undefined>)[name];
export function buildFlow(
row: LogicRow,
answer: CalcAnswer | null,
prices: Record<string, ElementBrief | null>,
values: Record<string, string>,
): FlowColumn[] {
const ok = answer?.ok ? answer : null;
const lines = ok?.lines ?? [];
const middle = ok?.middle ?? {};
const columns: FlowColumn[] = [inputColumn(row, values)];
const mid = middleColumn(row, middle);
if (mid.boxes.length) columns.push(mid);
if (!isMoney(row)) {
columns.push(resultColumn(row, ok?.result));
return columns;
}
const ho = row.호표 ?? [];
columns.push(qtyColumn(ho, lines), priceColumn(ho, lines, prices));
const plus = extraColumn(row.덧줄 ?? [], lines, ho.length);
if (plus.boxes.length) columns.push(plus);
const sums = ok?.sums ?? null;
columns.push(costColumn(sums, lines), totalColumn(row, sums));
return columns;
}
function inputColumn(row: LogicRow, values: Record<string, string>): FlowColumn {
const boxes = (row.입력 ?? []).map((spec, i) => ({
id: `입력:${i}`,
kind: "입력" as const,
label: spec.이름,
value: values[spec.이름] ?? "",
note: spec.단위,
detail: keep([
["단위", spec.단위],
["고르기", (spec.고르기 ?? []).join(" · ")],
["범위", spec.범위 ? `${spec.범위[0]} ∼ ${spec.범위[1]}` : ""],
["출처", extra(spec, "출처")],
]),
}));
return { kind: "입력", boxes };
}
function middleColumn(row: LogicRow, middle: Record<string, unknown>): FlowColumn {
const boxes = (row.중간 ?? []).map((step, i) => ({
id: `중간:${i}`,
kind: "중간" as const,
label: step.이름,
value: fmt(middle[step.이름]),
note: step.식.startsWith("찾기(") ? "표 찾기" : "식",
detail: keep([
["식", step.식],
["출처", step.출처],
]),
}));
return { kind: "중간", boxes };
}
function qtyColumn(ho: HoLine[], lines: CalcLine[]): FlowColumn {
const boxes = ho.map((item, i) => ({
id: `수량:${i}`,
kind: "수량" as const,
label: item.이름 || item.요소,
value: lines[i] ? fmt(lines[i].수량) : "",
note: item.단위,
cost: item.비목,
detail: keep([
["식", item.수량],
["종류", item.종류],
["단위", item.단위],
["비고", extra(item, "비고")],
]),
}));
return { kind: "수량", boxes };
}
function priceColumn(
ho: HoLine[],
lines: CalcLine[],
prices: Record<string, ElementBrief | null>,
): FlowColumn {
const boxes = ho.map((item, i) => {
const line = lines[i];
const brief = prices[item.요소];
const price = line ? line.단가 : brief?.값;
return {
id: `단가:${i}`,
kind: "단가" as const,
label: item.이름 || item.요소,
value: line ? fmt(line.금액) : "",
note: price === undefined || price === null ? "단가 —" : `× ${fmt(price)}`,
cost: item.비목,
logic: logicRef(item.요소),
bad: !line && brief === null,
detail: keep([
["요소", item.요소],
["단가", price],
["금액", line?.금액],
["비목", item.비목 ?? Object.keys(line?.비목 ?? {}).join(" · ")],
["출처", line?.출처 ?? [brief?.이름, brief?.규격].filter(Boolean).join(" ")],
]),
};
});
return { kind: "단가", boxes };
}
function extraColumn(list: NamedFormula[], lines: CalcLine[], from: number): FlowColumn {
const boxes = list.map((item, i) => ({
id: `덧줄:${i}`,
kind: "덧줄" as const,
label: item.이름,
value: fmt(lines[from + i]?.금액),
note: item.비목,
cost: item.비목,
detail: keep([
["식", item.식],
["비목", item.비목],
["출처", item.출처],
]),
}));
return { kind: "덧줄", boxes };
}
function costColumn(sums: Record<string, number> | null, lines: CalcLine[]): FlowColumn {
const boxes = COSTS.map((cost) => ({
id: `비목:${cost}`,
kind: "비목" as const,
label: cost,
value: fmt(sums?.[cost]),
cost,
detail: lines
.filter((line) => line.비목?.[cost] !== undefined)
.map((line) => [line.이름, fmt(line.비목[cost])] as [string, string]),
}));
return { kind: "비목", boxes };
}
function totalColumn(row: LogicRow, sums: Record<string, number> | null): FlowColumn {
return {
kind: "계",
boxes: [
{
id: "계",
kind: "계",
label: row.결과단위 || "계",
value: fmt(sums?.계),
detail: keep([
...COSTS.map((c) => [c, sums?.[c]] as [string, unknown]),
["끝수", row.끝수],
["출처", row.출처],
]),
},
],
};
}
function resultColumn(row: LogicRow, result: unknown): FlowColumn {
return {
kind: "계",
boxes: [
{
id: "계",
kind: "계",
label: row.결과단위 || "결과",
value: fmt(result),
detail: keep([
["식", row.결과?.식],
["출처", row.출처],
]),
},
],
};
}
@@ -0,0 +1,182 @@
/* M01 로직 테스트 — 방식 C(흐름 그림). 왼쪽에서 오른쪽으로 칸 · 칸마다 상자 */
.m01c [hidden] {
display: none !important;
}
.m01c {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
box-sizing: border-box;
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
.m01c__head {
display: flex;
align-items: baseline;
gap: var(--spacing-8);
flex-wrap: wrap;
}
.m01c__head h3 {
margin: 0;
font-size: 16px;
color: var(--color-text);
}
.m01c__flow {
display: flex;
align-items: flex-start;
gap: var(--spacing-4);
overflow-x: auto;
padding-bottom: var(--spacing-8);
}
/* 값 칸은 다시 그려도 설계 값 칸은 그대로 — 그래서 한 겹 더 있음 */
.m01c__rest {
display: contents;
}
.m01c__col {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
flex: 0 0 auto;
width: 170px;
min-width: 0;
}
.m01c__col--input {
width: 200px;
}
.m01c__col-head {
margin: 0;
padding-bottom: 2px;
border-bottom: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: var(--text-caption);
font-weight: 600;
}
.m01c__arrow {
align-self: center;
padding-top: 28px;
color: var(--color-text-muted);
font-size: 18px;
}
.m01c__box,
.m01c__group {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
}
.m01c__group {
border-style: dashed;
background: none;
}
.m01c__group > summary {
padding: 2px 6px;
color: var(--color-text-muted);
font-size: var(--text-caption);
cursor: pointer;
}
.m01c__group > .m01c__box {
margin: 2px;
}
.m01c__box > summary {
display: flex;
flex-direction: column;
gap: 1px;
padding: 4px 6px;
cursor: pointer;
}
.m01c__box--bad {
border-color: var(--color-danger);
}
.m01c__label {
color: var(--color-text);
overflow-wrap: anywhere;
}
.m01c__value {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.m01c__note,
.m01c__muted {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.m01c__bad {
margin: 0;
color: var(--color-danger);
font-size: var(--text-caption);
}
.m01c__detail {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 1px var(--spacing-4);
margin: 0;
padding: 4px 6px;
border-top: 1px solid var(--color-border);
font-size: var(--text-caption);
}
.m01c__detail dt {
color: var(--color-text-muted);
}
.m01c__detail dd {
margin: 0;
overflow-wrap: anywhere;
}
.m01c__sub {
padding: 0 6px 4px;
font-size: var(--text-caption);
}
.m01c__sub > summary {
color: var(--color-text-muted);
cursor: pointer;
}
.m01c__sub-body ul {
margin: 2px 0;
padding-left: 14px;
}
.m01c__field {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
}
.m01c__input {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 3px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-body-sm);
}
@@ -0,0 +1,105 @@
/* M01 로직 테스트 컨테이너 — 틀 + 방식 A (방식 B·C 는 각자 파일에서 같은 접두 m01-test__ 를 씀) */
.m01-test {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
height: 100%;
padding: var(--spacing-12);
box-sizing: border-box;
overflow: auto;
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
.m01-test__bar,
.m01-test__tabs,
.m01-test__nav {
display: flex;
align-items: center;
gap: var(--spacing-8);
flex-wrap: wrap;
}
.m01-test__bar h2 {
margin: 0 auto 0 0;
font-size: 18px;
color: var(--color-text);
}
.m01-test__tab {
padding: var(--spacing-4) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: transparent;
color: inherit;
cursor: pointer;
}
.m01-test__tab.is-active {
background: var(--color-surface);
font-weight: 600;
}
.m01-test__muted {
margin: 0;
color: var(--color-text-muted);
}
.m01-test__card {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
max-width: 640px;
padding: var(--spacing-12);
border-radius: var(--radius-cards);
background: var(--color-surface);
}
.m01-test__card h3,
.m01-test__step {
margin: 0;
}
.m01-test__pick {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-test__input {
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: transparent;
color: inherit;
}
.m01-test__grid {
width: 100%;
border-collapse: collapse;
}
.m01-test__grid th,
.m01-test__grid td {
padding: var(--spacing-4);
border-bottom: 1px solid var(--color-border);
text-align: left;
}
.m01-test__money {
text-align: right !important;
font-variant-numeric: tabular-nums;
}
.m01-test__total {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.m01-test__reasons {
padding: var(--spacing-8);
border-radius: var(--radius-cards);
background: var(--color-surface);
color: var(--color-danger, #c0392b);
}
@@ -0,0 +1,53 @@
/* =============================================================================
* M01_MasterData_UI_Test_Text.ts
* 로직 테스트 컨테이너 글자 — [한국어, 영어] · 틀과 방식 파일(A·B·C)이 같이 씀
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Title: ["일위대가 로직 테스트", "Logic test"],
Pick_Logic: ["공종 고르기", "Pick a job"],
Tab_A: ["A 질문·답", "A Wizard"],
Tab_B: ["B 호표 + 설명", "B Table + notes"],
Tab_C: ["C 흐름 그림", "C Flow"],
Soon: ["준비 중", "Coming soon"],
Loading: ["불러오는 중", "Loading"],
Failed: ["불러오지 못함", "Load failed"],
Read_Only: ["시험 계산만 — 저장하지 않음", "Trial only — nothing is saved"],
Step: ["{n} / {total}", "{n} / {total}"],
Back: ["이전", "Back"],
Next: ["다음", "Next"],
Again: ["처음부터", "Start over"],
Job_Title: ["이 공종의 단가를 셈합니다", "Price this job"],
Job_Note: [
"몇 가지만 물어봅니다 — 답을 마치면 결과 표가 나옵니다",
"A few questions, then the result table",
],
Ask_Pick: ["「{name}」 — 어느 것으로 할까요?", "「{name}」 — which one?"],
Ask_Number: ["「{name}」 값을 넣어 주세요{unit}", "Enter 「{name}」{unit}"],
Ask_Range: ["범위 {min} ∼ {max}", "Range {min} ∼ {max}"],
Mat_Title: ["쓰이는 재료·기계", "Materials and machines"],
Mat_Note: [
"기본은 원문 그대로 — 바꿔 보면 결과가 달라짐(저장 안 함)",
"Default is the source as is — swapping only changes this trial",
],
Mat_None: ["이 공종은 재료·기계를 따로 고르지 않습니다", "No materials or machines to pick"],
Mat_Default: ["기본 (원문 그대로)", "Default (as in source)"],
Mat_Price: ["단가", "Unit price"],
Result_Title: ["결과", "Result"],
Result_Stopped: ["계산이 멈춤", "Calculation stopped"],
Col_Name: ["이름", "Name"],
Col_Qty: ["수량", "Qty"],
Col_Amount: ["금액", "Amount"],
Sum_Total: ["합계", "Total"],
Sum_Result: ["결과 값", "Result"],
} as const satisfies Record<string, readonly [string, string]>;
export type TestTextKey = keyof typeof TEXT;
export function tt(key: TestTextKey, fill: Record<string, string | number> = {}): string {
const entry = TEXT[key];
const text: string = entry[currentLanguageIndex as 0 | 1] ?? entry[0];
return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m));
}