feat(M01): 「로직 쉽게 보기·만들기」·「로직 흐름 보기」 컨테이너와 계산 코드 걷음 · 정본 로직 화면 복사본 「로직 개선 시험」 컨테이너 더함

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
2026-09-21 19:26:58 +09:00
co-authored by Claude Sonnet 5
parent 472a63cfde
commit 5b69324207
24 changed files with 471 additions and 4242 deletions
@@ -0,0 +1,430 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab.ts
* 관리자 화면 — 로직 칸. 가운데 일위대가표(호표) 고치기 / 오른쪽 시험 계산
* 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「일위대가 로직」 컨테이너(`listHost`) 안
*
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
* 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임.
* ========================================================================== */
import { t as L } from "@ui/ui_template_locale";
import {
createButton,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import {
ApiError,
fetchLogic,
fetchLogicFiles,
fetchLogics,
fetchLogicSubs,
saveFiles,
type CalcLine,
type ElementBrief,
type LogicFile,
type LogicRow,
type LogicSummary,
type SaveFile,
} from "./M01_MasterData_UI_Logic_Api";
import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
import { buildEditor } from "./M01_MasterData_UI_Logic_Edit";
import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import type { SideHandle } from "./M01_MasterData_UI_Side";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import "./M01_MasterData_UI_Logic_Style.css";
/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */
interface Draft {
/** 구분(원문 + 부문) — 목록 id 를 세움 */
sub: string;
file: string;
version: string;
origKey: string | null;
row: LogicRow | null;
}
interface Opened extends Draft {
id: string;
row: LogicRow | null;
original: string | null;
prices: Record<string, ElementBrief | null>;
reasons: string[];
lines: CalcLine[] | null;
values: Record<string, string>;
}
const cacheKey = "m01_logic_drafts_lab";
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
function loadDrafts(): Record<string, Draft> {
try {
return JSON.parse(sessionStorage.getItem(cacheKey) ?? "{}") as Record<string, Draft>;
} catch {
return {};
}
}
export interface LogicHandle {
/** 밖(요소 화면)에서 이 키를 바로 엶 — 소요량·계수 표의 「쓰는 로직」 눌렀을 때. */
openKey: (key: string) => Promise<void>;
}
export async function mountM01LogicLab(
host: HTMLElement,
side: SideHandle,
openKey?: string,
): Promise<LogicHandle> {
let drafts = loadDrafts();
let files: LogicFile[] = [];
let opened: Opened | null = null;
let calcInputs = "";
const editor = el("div", { className: "m01-logic__editor" });
const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } });
const calc = el("aside", { className: "m01-logic__calc" });
const filterHost = el("div");
const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") });
let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음
const pending = el("span", { className: "m01-logic__muted" });
const list = buildList((item) => void open(item.id, item.sub, item.key));
const back = createButton({ label: tx("Bar_Back"), variant: "ghost", onClick: () => show(null) });
const persist = (): void => {
try {
sessionStorage.setItem(cacheKey, JSON.stringify(drafts));
} catch {
/* 캐시가 막혀도 화면 안의 고친 것은 남음 */
}
const marks = new Map<string, ListMark>();
const extra: ListItem[] = [];
for (const [id, d] of Object.entries(drafts)) {
marks.set(id, d.origKey === null ? "new" : d.row === null ? "deleted" : "edited");
if (d.origKey === null && d.row) {
extra.push({
id,
sub: d.sub,
detail: "",
key: d.row.키,
number: d.row.,
name: d.row.이름,
unit: d.row.결과단위,
source: d.row.출처,
blocked: false,
reasons: [],
});
}
}
list.setMarks(marks, extra);
const n = Object.keys(drafts).length;
pending.textContent = n ? tx("Bar_Pending", { n }) : "";
saveButton.disabled = discardButton.disabled = n === 0;
};
const touch = (): void => {
if (!opened?.row) return;
const now = JSON.stringify(opened.row);
if (opened.origKey !== null && now === opened.original) delete drafts[opened.id];
else drafts[opened.id] = pick(opened, clone(opened.row));
persist();
const inputs = JSON.stringify(opened.row. ?? []);
if (inputs !== calcInputs) drawCalc();
};
const pick = (o: Opened, row: LogicRow | null): Draft => ({
sub: o.sub,
file: o.file,
version: o.version,
origKey: o.origKey,
row,
});
const drawCalc = (): void => {
if (!opened?.row) {
calc.replaceChildren();
return;
}
const current = opened;
calcInputs = JSON.stringify(current.row?. ?? []);
buildCalc(calc, {
savedKey: current.origKey,
file: current.file,
row: current.row as LogicRow,
dirty: () => current.id in drafts,
values: current.values,
onLines: (lines) => {
current.lines = lines;
drawEditor();
},
});
};
const drawEditor = (): void => {
// 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산
const listing = !opened;
list.root.hidden = !listing || !loaded;
waiting.hidden = loaded;
editor.hidden = back.hidden = listing;
calc.hidden = listing;
if (!opened) return;
if (!opened.row) {
editor.replaceChildren(
el("p", { className: "m01-logic__empty", text: `${tx("List_Deleted")}` }),
);
return;
}
const current = opened;
buildEditor(editor, {
row: current.row as LogicRow,
file: current.file,
isNew: current.origKey === null,
files: files.map((f) => f.file),
reasons: current.reasons,
prices: current.prices,
lines: current.lines,
onChange: touch,
onFile: (file) => {
const f = files.find((x) => x.file === file);
if (!f) return;
Object.assign(current, { file: f.file, sub: subOf(f), version: f.version });
touch();
},
onPick: openPicker,
});
};
/** 로직 파일 → 구분(원문 + 부문) — 「공통 03장 토공사」 의 앞말이 부문 */
const subOf = (f: LogicFile): string =>
f.book === "건설품셈" ? `${f.book} ${f.chapter.split(" ")[0]}` : String(f.book);
const show = (next: Opened | null): void => {
opened = next;
errors.hidden = true;
drawEditor();
drawCalc();
};
const open = async (id: string, sub: string, key: string): Promise<void> => {
const draft = drafts[id];
if (draft?.origKey === null) {
show({ ...draft, id, original: null, prices: {}, reasons: [], lines: null, values: {} });
return;
}
try {
const one = await fetchLogic(key);
show({
id,
sub,
file: draft?.file ?? one.file,
version: draft?.version ?? one.version,
origKey: key,
row: draft ? (draft.row === null ? null : clone(draft.row)) : clone(one.logic),
original: JSON.stringify(one.logic),
prices: one.prices,
reasons: one.reasons,
lines: null,
values: {},
});
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
let allLogics: LogicSummary[] = [];
const reload = async (): Promise<void> => {
const [logics, logicFiles, subs] = await Promise.all([
fetchLogics(),
fetchLogicFiles(),
fetchLogicSubs(),
]);
files = logicFiles;
allLogics = logics;
list.setItems(logics);
// 왼쪽 「전체」 + 구분 → 상세구분 거름 — 고르면 목록으로 돌아와 그 범위만
filterHost.replaceChildren(
side.filter({
id: "로직시험|",
store: "m01.filter.로직시험",
subs,
total: list.total(),
subLabel: L("M01_LaborSub"),
detailLabel: L("M01_LaborDetail"),
restore: true,
onPick: (sub, detail) => {
list.setPick(sub, detail);
if (opened) show(null);
},
}),
);
persist();
};
/** 옛 컨테이너의 「새로 만들기」 — 빈 줄 하나를 초안으로 세움(저장 때 서버가 키를 줌) */
const onDraftNew = (): void => {
const f = files.find((x) => x.file === opened?.file) ?? files[0];
if (!f) return;
const id = `new:${Date.now()}`;
// 원문번호는 절 번호만(부문은 구분 칸) · 키는 저장 때 서버가 줌
const row: LogicRow = {
: "",
: `${tx("New_Key")} ${Object.keys(drafts).length + 1}`,
: "",
: "원/",
: "",
: "공용",
: [],
: [],
: [],
: [],
끝수: null,
};
const next: Opened = {
id,
sub: subOf(f),
file: f.file,
version: f.version,
origKey: null,
row,
original: null,
prices: {},
reasons: [],
lines: null,
values: {},
};
drafts[id] = pick(next, clone(row));
persist();
show(next);
};
const onDelete = async (): Promise<void> => {
if (!opened?.row) return;
if (
!(await showConfirmDialog(
tx("Confirm_Delete", { v: opened.row.원문번호 || opened.row. }),
tx("Bar_Delete"),
))
)
return;
if (opened.origKey === null) {
delete drafts[opened.id];
persist();
show(null);
return;
}
drafts[opened.id] = pick(opened, null);
opened.row = null;
persist();
show(opened);
};
const onDiscard = async (): Promise<void> => {
if (!(await showConfirmDialog(tx("Confirm_Discard"), tx("Bar_Discard")))) return;
drafts = {};
persist();
const was = opened;
if (was && was.origKey !== null)
await open(logicId(was.sub, was.origKey), was.sub, was.origKey);
else show(null);
};
const onSave = async (): Promise<void> => {
const byFile = new Map<string, SaveFile>();
for (const d of Object.values(drafts)) {
const part = byFile.get(d.file) ?? { file: d.file, version: d.version, changes: [] };
if (d.origKey === null) {
if (d.row) part.changes.push({ op: "add", row: d.row });
} else if (d.row === null) part.changes.push({ op: "delete", key: d.origKey });
else part.changes.push({ op: "edit", key: d.origKey, row: d.row });
byFile.set(d.file, part);
}
if (!byFile.size) {
showToast(tx("Save_Nothing"), "info");
return;
}
showLoadingOverlay();
try {
await saveFiles([...byFile.values()]);
const was = opened;
drafts = {};
await reload();
showToast(tx("Save_Done"), "success");
if (was?.row?.) await open(logicId(was.sub, was.row.), was.sub, was.row.);
else show(null);
} catch (error) {
failed(error);
} finally {
hideLoadingOverlay();
}
};
const failed = (error: unknown): void => {
const detail =
error instanceof ApiError ? (error.detail as { stale?: string[]; errors?: string[] }) : null;
if (error instanceof ApiError && error.status === 409) {
showToast(tx("Save_Stale", { v: (detail?.stale ?? []).join(", ") }), "error");
} else if (error instanceof ApiError && error.status === 422) {
errors.replaceChildren(
el("strong", { text: tx("Save_Errors") }),
...(detail?.errors ?? []).map((x) => el("div", { text: x })),
);
errors.hidden = false;
} else {
showToast(error instanceof Error ? error.message : tx("Save_Failed"), "error");
}
};
const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() });
const discardButton = createButton({
label: tx("Bar_Discard"),
variant: "ghost",
onClick: () => void onDiscard(),
});
const bar = el("div", {
className: "m01-logic__bar",
children: [
back,
el("h2", { text: tx("Title") }),
pending,
createButton({ label: tx("Bar_New"), variant: "ghost", onClick: onDraftNew }),
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
discardButton,
saveButton,
],
});
host.replaceChildren(
el("div", {
className: "m01-logic",
children: [
el("main", {
className: "m01-logic__main",
children: [bar, errors, waiting, list.root, editor],
}),
calc,
],
}),
);
side.labHost.replaceChildren(filterHost);
show(null);
showLoadingOverlay();
const openKeyOn = async (key: string): Promise<void> => {
const found = allLogics.find((x) => x. === key);
if (found) await open(logicId(found., found.), found., found.);
else showToast(tx("Load_Failed"), "error");
};
try {
await reload();
loaded = true;
drawEditor();
if (openKey) await openKeyOn(openKey);
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
} finally {
hideLoadingOverlay();
}
return { openKey: openKeyOn };
}
@@ -1,288 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Logic_CView.ts
* 로직 테스트 — 방식 C(흐름 그림). 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계
*
* 읽기 + 시험 계산만(저장 없음 · 정본 로직은 건드리지 않음) — 로직 화면이 쓰는 같은 길
* (`fetchLogic` · `runCalc`)을 그대로 씀. 상자를 누르면 값과 출처 · 로직이 로직을 부르는
* 줄은 상자 안에서 펼침 · 설계 값을 바꾸면 흐름의 값이 바로 바뀜.
* 뼈대(어느 상자가 어디에) = `M01_MasterData_UI_Logic_CView_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_Logic_CView_Model";
import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Logic_Pick";
import { guideLines, plainReason, tx } from "./M01_MasterData_UI_Logic_Text";
import "./M01_MasterData_UI_Logic_CView_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: "m01v__muted", text: tc("Loading") }));
void fetchLogic(logicKey)
.then((one) => mount(host, one))
.catch((error: unknown) => {
host.replaceChildren(
el("p", {
className: "m01v__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: "m01v__rest" });
const stopped = el("p", { className: "m01v__bad", attrs: { hidden: "" } });
const boxView = (box: FlowBox): HTMLElement => {
const node = el("details", {
className: `m01v__box${box.bad ? " m01v__box--bad" : ""}`,
children: [
el("summary", {
children: [
el("span", { className: "m01v__label", text: box.label }),
el("span", { className: "m01v__value", text: box.value }),
...(box.note ? [el("span", { className: "m01v__note", text: box.note })] : []),
],
}),
el("dl", {
className: "m01v__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: "m01v__sub-body" });
const node = el("details", {
className: "m01v__sub",
children: [el("summary", { text: `${tc("Sub")} · ${key}` }), body],
});
const fill = (sub: LogicRow): void => {
body.replaceChildren(
el("p", { className: "m01v__muted", text: `${sub.} (${sub.}) · ${sub.}` }),
el("p", { className: "m01v__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: "m01v__muted", text: tc("Loading") }));
void fetchLogic(key)
.then((deep) => {
subs.set(key, deep.logic);
fill(deep.logic);
})
.catch(() => body.replaceChildren(el("p", { className: "m01v__bad", text: tc("Failed") })));
});
return node;
};
const columnView = (column: FlowColumn): HTMLElement => {
const head = el("h4", { className: "m01v__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: "m01v__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: "m01v__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: "m01v__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: "m01v",
children: [
el("div", {
className: "m01v__head",
children: [
el("h3", { text: `${row.} ${row.}` }),
el("span", { className: "m01v__muted", text: `${row.} · ${row.}` }),
],
}),
...(one.reasons.length
? [el("p", { className: "m01v__bad", text: one.reasons.join(" · ") })]
: []),
el("ul", {
className: "m01v__muted",
children: guideLines().map((t) => el("li", { text: t })),
}),
stopped,
...(hasPickable(row)
? [
el("details", {
attrs: { open: "" },
children: [el("summary", { text: tx("Mat_Title") }), picker],
}),
]
: []),
el("div", {
className: "m01v__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: "m01v__input",
attrs: { type: "text", inputmode: "decimal" },
});
box.placeholder = spec. ? `${spec.[0]} ${spec.[1]}` : tx("Enter_Value");
box.value = values[spec.] ?? "";
box.addEventListener("input", () => {
values[spec.] = box.value;
onChange();
});
control = box;
}
return el("label", {
className: "m01v__field",
children: [
el("span", {
className: "m01v__label",
text: spec.단위 ? `${spec.} (${spec.})` : spec.,
}),
control,
],
});
});
return el("section", {
className: "m01v__col m01v__col--input",
children: [
el("h4", { className: "m01v__col-head", text: title("입력") }),
...(fields.length ? fields : [el("p", { className: "m01v__muted", text: tc("NoInputs") })]),
],
});
}
@@ -1,238 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Logic_CView_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.],
]),
},
],
};
}
@@ -1,182 +0,0 @@
/* M01 로직 테스트 — 방식 C(흐름 그림). 왼쪽에서 오른쪽으로 칸 · 칸마다 상자 */
.m01v [hidden] {
display: none !important;
}
.m01v {
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);
}
.m01v__head {
display: flex;
align-items: baseline;
gap: var(--spacing-8);
flex-wrap: wrap;
}
.m01v__head h3 {
margin: 0;
font-size: 16px;
color: var(--color-text);
}
.m01v__flow {
display: flex;
align-items: flex-start;
gap: var(--spacing-4);
overflow-x: auto;
padding-bottom: var(--spacing-8);
}
/* 값 칸은 다시 그려도 설계 값 칸은 그대로 — 그래서 한 겹 더 있음 */
.m01v__rest {
display: contents;
}
.m01v__col {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
flex: 0 0 auto;
width: 170px;
min-width: 0;
}
.m01v__col--input {
width: 200px;
}
.m01v__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;
}
.m01v__arrow {
align-self: center;
padding-top: 28px;
color: var(--color-text-muted);
font-size: 18px;
}
.m01v__box,
.m01v__group {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
}
.m01v__group {
border-style: dashed;
background: none;
}
.m01v__group > summary {
padding: 2px 6px;
color: var(--color-text-muted);
font-size: var(--text-caption);
cursor: pointer;
}
.m01v__group > .m01v__box {
margin: 2px;
}
.m01v__box > summary {
display: flex;
flex-direction: column;
gap: 1px;
padding: 4px 6px;
cursor: pointer;
}
.m01v__box--bad {
border-color: var(--color-danger);
}
.m01v__label {
color: var(--color-text);
overflow-wrap: anywhere;
}
.m01v__value {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.m01v__note,
.m01v__muted {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.m01v__bad {
margin: 0;
color: var(--color-danger);
font-size: var(--text-caption);
}
.m01v__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);
}
.m01v__detail dt {
color: var(--color-text-muted);
}
.m01v__detail dd {
margin: 0;
overflow-wrap: anywhere;
}
.m01v__sub {
padding: 0 6px 4px;
font-size: var(--text-caption);
}
.m01v__sub > summary {
color: var(--color-text-muted);
cursor: pointer;
}
.m01v__sub-body ul {
margin: 2px 0;
padding-left: 14px;
}
.m01v__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);
}
.m01v__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);
}
@@ -1,536 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Flow.ts
* 일위대가 로직 기본 보기 — 흐름 그림. 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계
*
* 상자를 누르면 값·출처 · 원문 표 미리보기 · 쉬운 말 풀이(`…_Logic_Note.ts`) · 부르는 로직 펼치기.
* [고치기] 를 켜면 상자 안에서 바로 고치고 줄을 더하거나 지움 — 자체 로직만(정본은 「본떠 만들기」 뒤).
* 값은 모두 시험 계산(`POST /calc`)에서 옴 — 여기서 새로 셈하지 않음.
* 뼈대(어느 상자가 어디에) = `M01_MasterData_UI_Logic_Flow_Model.ts`.
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import {
fetchLogic,
runCalc,
type CalcAnswer,
type HoLine,
type LogicOne,
type LogicRow,
type NamedFormula,
} from "./M01_MasterData_UI_Logic_Api";
import {
buildFlow,
isMoney,
type FlowBox,
type FlowColumn,
type FlowKind,
} from "./M01_MasterData_UI_Logic_Flow_Model";
import { buildNote } from "./M01_MasterData_UI_Logic_Note";
import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Logic_Pick";
import { guideLines, plainReason, tx, type TextKey } from "./M01_MasterData_UI_Logic_Text";
import "./M01_MasterData_UI_Logic_Flow_Style.css";
const title = (kind: FlowKind): string => tx(`Col_${kind}` as TextKey);
/**
* 자체 로직인지 — 자체 파일(`로직_자체.json`)이거나 키가 `GX…`(계약 5장).
* 아직 저장 안 한 새 줄(키 "")도 고칠 수 있음 · 정본은 「본떠 만들기」 뒤에야 고침.
*/
export const isOwnLogic = (file: string, row: LogicRow): boolean =>
file.startsWith("로직_자체") || (row. ?? "").startsWith("GX") || (row. ?? "") === "";
export interface FlowContext {
one: LogicOne;
/** 지금 보는 줄 — 고친 것이 있으면 그 줄(`one.logic` 과 다른 객체일 수 있음) */
row: LogicRow;
/** 상자에서 고칠 수 있는지 — 자체 로직만 */
editable: boolean;
/** 고친 뒤 — 화면이 캐시에 담음 */
onChange?: () => void;
/** 「본떠 만들기」 — 정본 로직일 때만 */
onCopy?: () => void;
}
/** 흐름 그림 하나를 `host` 에 그림 — 로직 키 하나(어느 로직이 와도 돎 · 읽기 전용) */
export function render(host: HTMLElement, logicKey: string): void {
host.replaceChildren(el("p", { className: "m01c__muted", text: tx("Load_Failed") }));
void fetchLogic(logicKey)
.then((one) => mountFlow(host, { one, row: one.logic, editable: false }))
.catch((error: unknown) => {
host.replaceChildren(
el("p", {
className: "m01c__bad",
text: error instanceof Error ? error.message : tx("Load_Failed"),
}),
);
});
}
export function mountFlow(host: HTMLElement, ctx: FlowContext): void {
const one = ctx.one;
const row = ctx.row;
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, logic: row }, swaps, () => run());
const open = new Set<string>();
const subs = new Map<string, LogicRow>();
let answer: CalcAnswer | null = null;
let editing = false;
let timer: number | undefined;
const rest = el("div", { className: "m01c__rest" });
const stopped = el("p", { className: "m01c__bad", attrs: { hidden: "" } });
/** 고친 줄 — 화면에 알리고 계산을 다시(값 칸은 글쇠 자리를 지켜 늦춰 그림) */
const touched = (): void => {
ctx.onChange?.();
later();
};
/* ── 상자 ─────────────────────────────────────────────────────────── */
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 (editing) node.append(...editView(box.id));
const expr = formulaOf(box);
if (expr) node.append(noteView(expr));
if (box.logic) node.append(subView(box.logic));
return node;
};
/** 이 상자가 풀어 볼 식 — 호표 줄의 수량 · 중간 값·덧줄의 식 */
const formulaOf = (box: FlowBox): string => {
const [kind, at] = box.id.split(":");
const i = Number(at);
if (kind === "수량") return (row. ?? [])[i]?. ?? "";
if (kind === "중간") return (row. ?? [])[i]?. ?? "";
if (kind === "덧줄") return (row. ?? [])[i]?. ?? "";
return "";
};
/** 쉬운 말 풀이 + 원문 표 미리보기 — 펼칠 때 한 번만 채움(표는 서버에서 읽음) */
const noteView = (expr: string): HTMLElement => {
const body = el("div", { className: "m01c__note-body" });
const node = el("details", {
className: "m01c__sub",
children: [el("summary", { text: tx("Note_Plain") }), body],
});
node.addEventListener("toggle", () => {
if (!node.open || body.childElementCount) return;
buildNote(body, {
expr,
middles: row.중간 ?? [],
ctx: { values, middle: answer?.ok ? (answer.middle ?? {}) : {} },
});
});
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: `${tx("Flow_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: tx("Flow_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: tx("Note_Searching") }));
void fetchLogic(key)
.then((deep) => {
subs.set(key, deep.logic);
fill(deep.logic);
})
.catch(() =>
body.replaceChildren(el("p", { className: "m01c__bad", text: tx("Load_Failed") })),
);
});
return node;
};
/* ── 상자에서 고치기 ──────────────────────────────────────────────── */
/** 글 칸 하나 — 고치면 그 자리에서 줄을 바꾸고 계산만 다시(다시 그려도 글쇠 자리가 남게 `data-at`) */
const editBox = (at: string, label: string, value: string, set: (v: string) => void) => {
const input = el("input", {
className: "m01c__input",
attrs: { type: "text", "data-at": at },
});
input.value = value;
input.addEventListener("input", () => {
set(input.value);
touched();
});
return el("label", {
className: "m01c__edit",
children: [el("span", { className: "m01c__muted", text: label }), input],
});
};
const dropButton = (onClick: () => void): HTMLElement =>
createButton({
label: tx("DeleteRow"),
variant: "ghost",
onClick: () => {
onClick();
ctx.onChange?.();
draw();
},
});
const editView = (id: string): HTMLElement[] => {
const [kind, at] = id.split(":");
const i = Number(at);
if (kind === "입력") {
const spec = (row. ?? [])[i];
if (!spec) return [];
return [
el("div", {
className: "m01c__edits",
children: [
editBox(id + ":1", tx("Head_Name"), spec., (v) => (spec. = v)),
editBox(id + ":2", tx("Inputs_Unit"), spec. ?? "", (v) => {
if (v.trim()) spec. = v;
else delete spec.;
}),
editBox(id + ":3", tx("Inputs_Choices"), (spec. ?? []).join(", "), (v) => {
const items = v
.split(",")
.map((x) => x.trim())
.filter(Boolean);
// 모두 수면 수로 — 글 "35" 로 적으면 표의 35 와 안 맞음
if (items.length)
spec. = items.every((x) => !Number.isNaN(Number(x)))
? items.map(Number)
: items;
else delete spec.;
}),
dropButton(() => (row. ?? []).splice(i, 1)),
],
}),
];
}
if (kind === "수량") {
const line = (row. ?? [])[i];
if (!line) return [];
return [
el("div", {
className: "m01c__edits",
children: [
editBox(id + ":1", tx("Ho_Element"), line., (v) => (line. = v)),
editBox(id + ":2", tx("Ho_Name"), line. ?? "", (v) => (line. = v)),
editBox(id + ":3", tx("Ho_Unit"), line. ?? "", (v) => (line. = v)),
editBox(id + ":4", tx("Edit_Qty"), line., (v) => (line. = v)),
dropButton(() => (row. ?? []).splice(i, 1)),
],
}),
];
}
const list: NamedFormula[] | undefined =
kind === "중간" ? row.중간 : kind === "덧줄" ? row.덧줄 : undefined;
if (!list?.[i]) return [];
const item = list[i];
return [
el("div", {
className: "m01c__edits",
children: [
editBox(id + ":1", tx("Head_Name"), item., (v) => (item. = v)),
editBox(id + ":2", tx("Formula"), item., (v) => (item. = v)),
dropButton(() => list.splice(i, 1)),
],
}),
];
};
/** 칸 아래 「줄 더하기」 — 고치기를 켰을 때만 */
const addButton = (kind: FlowKind): HTMLElement | null => {
if (!editing) return null;
const add = (label: string, push: () => void): HTMLElement =>
createButton({
label,
variant: "ghost",
onClick: () => {
push();
ctx.onChange?.();
draw();
},
});
if (kind === "입력")
return add(tx("Edit_AddInput"), () => void (row. ??= []).push({ : "" }));
if (kind === "중간")
return add(tx("Edit_AddMiddle"), () => void (row. ??= []).push({ : "", : "" }));
if (kind === "수량")
return add(
tx("Edit_AddHo"),
() =>
void (row. ??= []).push({
: "인력",
: "",
: "",
: "인",
: "",
: "노무비",
} as HoLine),
);
if (kind === "덧줄")
return add(
tx("Edit_AddExtra"),
() => void (row. ??= []).push({ : "", : "", : "재료비", : "" }),
);
return null;
};
/* ── 칸·다시 그리기 ───────────────────────────────────────────────── */
const columnView = (column: FlowColumn): HTMLElement => {
const head = el("h4", { className: "m01c__col-head", text: title(column.kind) });
// 상자가 많으면 비목으로 묶어 접음 — 줄이 많은 로직도 한 화면에(비목 없는 줄은 「그 밖」)
const costOf = (box: FlowBox): string => box.cost ?? tx("Flow_Other");
const costs = [...new Set(column.boxes.map(costOf))];
const body =
column.boxes.length > 6 && costs.length > 1 && !editing
? 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);
const add = addButton(column.kind);
return el("section", {
className: "m01c__col",
children: [head, ...body, ...(add ? [add] : [])],
});
};
/** 다시 그려도 적던 칸으로 돌아감 — `data-at` 이 같은 칸에 글쇠와 자리를 되돌림 */
const keepFocus = (paint: () => void): void => {
const was = document.activeElement as HTMLInputElement | null;
const at = was?.dataset?.at;
const start = at ? was?.selectionStart : null;
paint();
if (!at) return;
const next = host.querySelector<HTMLInputElement>(`[data-at="${CSS.escape(at)}"]`);
if (!next) return;
next.focus();
if (start !== null && start !== undefined) next.setSelectionRange(start, start);
};
/** 고치기를 켜면 비어 있어 빠진 칸(표 찾기·덧줄)도 세움 — 거기에 줄을 더할 수 있게 */
const withEmpty = (columns: FlowColumn[]): FlowColumn[] => {
if (!editing || !isMoney(row)) return columns;
const out = [...columns];
const put = (kind: FlowKind, before: FlowKind): void => {
if (out.some((c) => c.kind === kind)) return;
const at = out.findIndex((c) => c.kind === before);
out.splice(at < 0 ? out.length : at, 0, { kind, boxes: [] });
};
put("중간", "수량");
put("덧줄", "비목");
return out;
};
const redraw = (): void => {
keepFocus(() => {
const columns = withEmpty(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 ? `${tx("Calc_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);
const draft = swapped ?? (ctx.editable ? row : undefined);
void runCalc({ key: row.키, inputs, ...(draft ? { row: draft, file: one.file } : {}) })
.then((got) => {
answer = got;
redraw();
})
.catch((error: unknown) => {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
});
};
const later = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(run, 250);
};
/* ── 틀 ───────────────────────────────────────────────────────────── */
const bar = el("div", { className: "m01c__edit-bar" });
const fillBar = (): void => {
if (ctx.editable) {
bar.replaceChildren(
createButton({
label: editing ? tx("Edit_Off") : tx("Edit_On"),
variant: editing ? "filled" : "ghost",
onClick: () => {
editing = !editing;
draw();
},
}),
);
return;
}
bar.replaceChildren(
el("span", { className: "m01c__muted", text: tx("Edit_OwnOnly") }),
createButton({
label: tx("Edit_Copy"),
variant: "ghost",
onClick: () => (ctx.onCopy ? ctx.onCopy() : showToast(tx("Edit_CopySoon"), "info")),
}),
);
};
const draw = (): void => {
fillBar();
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.}` }),
bar,
],
}),
...(one.reasons.length
? [el("p", { className: "m01c__bad", text: one.reasons.join(" · ") })]
: []),
el("ul", {
className: "m01c__muted",
children: guideLines().map((t) => el("li", { text: t })),
}),
stopped,
...(hasPickable(row)
? [
el("details", {
attrs: { open: "" },
children: [el("summary", { text: tx("Mat_Title") }), picker],
}),
]
: []),
el("div", { className: "m01c__flow", children: [inputColumn(), rest] }),
],
}),
);
redraw();
};
/**
* 설계 값 칸 — `draw()` 에서만 다시 세움(계산 때마다 다시 그리는 오른쪽과 달리
* 적던 값과 글쇠 자리가 안 날아감). 고치기를 켜면 칸 이름·단위·고르기도 여기서 고침.
*/
const inputColumn = (): HTMLElement => {
const fields = (row. ?? []).map((spec, i) => {
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;
later();
},
}).root;
} else {
const box = el("input", {
className: "m01c__input",
attrs: { type: "text", inputmode: "decimal" },
});
box.placeholder = spec. ? `${spec.[0]} ${spec.[1]}` : tx("Enter_Value");
box.value = values[spec.] ?? "";
box.addEventListener("input", () => {
values[spec.] = box.value;
later();
});
control = box;
}
return el("label", {
className: "m01c__field",
children: [
el("span", {
className: "m01c__label",
text: spec.단위 ? `${spec.} (${spec.})` : spec.,
}),
control,
...(editing ? editView(`입력:${i}`) : []),
],
});
});
const add = addButton("입력");
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: tx("Flow_NoInputs") })]),
...(add ? [add] : []),
],
});
};
draw();
run();
}
@@ -1,238 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Flow_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.],
]),
},
],
};
}
@@ -1,267 +0,0 @@
/* M01 일위대가 로직 — 흐름 그림(기본 보기). 왼쪽에서 오른쪽으로 칸 · 칸마다 상자 */
.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);
}
/* 상자 속 설명 카드 — 쉬운 말 풀이 · 원문 표 미리보기(`M01_MasterData_UI_Logic_Note.ts`) */
.m01c__note-body {
padding: 2px 0;
}
.m01c__plain {
margin: 2px 0;
overflow-wrap: anywhere;
}
.m01c__tables {
overflow-x: auto;
}
.m01c__table {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
.m01c__table th,
.m01c__table td {
padding: 1px 4px;
border-bottom: 1px solid var(--color-border);
text-align: left;
white-space: nowrap;
}
.m01c__table th {
color: var(--color-text-muted);
font-weight: 500;
}
.m01c__hit {
background: var(--color-surface);
font-weight: 600;
}
.m01c__raw pre {
margin: 2px 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* 상자에서 고치기 — [고치기] 를 켰을 때만 */
.m01c__edits {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 6px;
border-top: 1px dashed var(--color-border);
}
.m01c__edit {
display: flex;
flex-direction: column;
gap: 1px;
}
.m01c__edit-bar {
display: flex;
align-items: center;
gap: var(--spacing-8);
margin-left: auto;
}
/* 재료·기계 바꿔 고르기 칸 — `M01_MasterData_UI_Logic_Pick.ts` */
.m01-test__picks {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
}
.m01-test__pick {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-test__filters {
display: flex;
gap: var(--spacing-8);
flex-wrap: wrap;
}
+16 -96
View File
@@ -18,7 +18,6 @@ import {
} from "@ui/ui_template_elements";
import {
ApiError,
copyLogic,
fetchLogic,
fetchLogicFiles,
fetchLogics,
@@ -33,10 +32,7 @@ import {
} from "./M01_MasterData_UI_Logic_Api";
import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
import { buildEditor } from "./M01_MasterData_UI_Logic_Edit";
import { render as renderCView } from "./M01_MasterData_UI_Logic_CView";
import { isOwnLogic, mountFlow } from "./M01_MasterData_UI_Logic_Flow";
import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List";
import { openLogicWizard } from "./M01_MasterData_UI_Logic_Wizard";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import type { SideHandle } from "./M01_MasterData_UI_Side";
import { tx } from "./M01_MasterData_UI_Logic_Text";
@@ -62,15 +58,12 @@ interface Opened extends Draft {
values: Record<string, string>;
}
/** 세 컨테이너(옛 호표 화면 · 쉽게 보기·만들기 · 흐름 보기)가 서로의 저장 안 한 것을 덮지 않게 캐시 칸을 가름 */
export type LogicMode = "classic" | "easy" | "cview";
const SUFFIX: Record<LogicMode, string> = { classic: "", easy: "_easy", cview: "_cview" };
const cacheKey = (mode: LogicMode): string => `m01_logic_drafts${SUFFIX[mode]}`;
const cacheKey = "m01_logic_drafts";
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
function loadDrafts(mode: LogicMode): Record<string, Draft> {
function loadDrafts(): Record<string, Draft> {
try {
return JSON.parse(sessionStorage.getItem(cacheKey(mode)) ?? "{}") as Record<string, Draft>;
return JSON.parse(sessionStorage.getItem(cacheKey) ?? "{}") as Record<string, Draft>;
} catch {
return {};
}
@@ -85,20 +78,11 @@ export async function mountM01Logic(
host: HTMLElement,
side: SideHandle,
openKey?: string,
mode: LogicMode = "classic",
): Promise<LogicHandle> {
let drafts = loadDrafts(mode);
let drafts = loadDrafts();
let files: LogicFile[] = [];
let opened: Opened | null = null;
let calcInputs = "";
/** 옛 컨테이너 = 호표 표 + 시험 계산 칸 · 쉽게 보기·만들기 컨테이너 = 흐름 그림 */
const easy = mode === "easy";
const view: "flow" | "advanced" | "cview" = easy
? "flow"
: mode === "cview"
? "cview"
: "advanced";
const editor = el("div", { className: "m01-logic__editor" });
const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } });
const calc = el("aside", { className: "m01-logic__calc" });
@@ -111,7 +95,7 @@ export async function mountM01Logic(
const persist = (): void => {
try {
sessionStorage.setItem(cacheKey(mode), JSON.stringify(drafts));
sessionStorage.setItem(cacheKey, JSON.stringify(drafts));
} catch {
/* 캐시가 막혀도 화면 안의 고친 것은 남음 */
}
@@ -146,7 +130,6 @@ export async function mountM01Logic(
if (opened.origKey !== null && now === opened.original) delete drafts[opened.id];
else drafts[opened.id] = pick(opened, clone(opened.row));
persist();
if (view !== "advanced") return; // 흐름 그림은 제 안에서 다시 셈 — 다시 그리면 펼친 상자가 접힘
const inputs = JSON.stringify(opened.row. ?? []);
if (inputs !== calcInputs) drawCalc();
};
@@ -160,7 +143,7 @@ export async function mountM01Logic(
});
const drawCalc = (): void => {
if (!opened?.row || view !== "advanced") {
if (!opened?.row) {
calc.replaceChildren();
return;
}
@@ -180,12 +163,12 @@ export async function mountM01Logic(
};
const drawEditor = (): void => {
// 고른 로직이 없으면 목록 · 있으면 흐름 그림(기본) 또는 고급 = 호표 표 + 시험 계산
// 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산
const listing = !opened;
list.root.hidden = !listing || !loaded;
waiting.hidden = loaded;
editor.hidden = back.hidden = listing;
calc.hidden = listing || view !== "advanced";
calc.hidden = listing;
if (!opened) return;
if (!opened.row) {
editor.replaceChildren(
@@ -194,28 +177,6 @@ export async function mountM01Logic(
return;
}
const current = opened;
const shown = current.row as LogicRow;
if (view === "cview") {
renderCView(editor, current.origKey ?? current.id);
return;
}
if (view === "flow") {
mountFlow(editor, {
one: {
file: current.file,
version: current.version,
logic: shown,
blocked: false,
reasons: current.reasons,
prices: current.prices,
},
row: shown,
editable: isOwnLogic(current.file, shown),
onChange: touch,
onCopy: () => void onCopy(current),
});
return;
}
buildEditor(editor, {
row: current.row as LogicRow,
file: current.file,
@@ -286,12 +247,8 @@ export async function mountM01Logic(
// 왼쪽 「전체」 + 구분 → 상세구분 거름 — 고르면 목록으로 돌아와 그 범위만
filterHost.replaceChildren(
side.filter({
id: easy ? "쉬움|" : mode === "cview" ? "흐름|" : "로직|",
store: easy
? "m01.filter.로직쉬움"
: mode === "cview"
? "m01.filter.로직흐름"
: "m01.filter.로직",
id: "로직|",
store: "m01.filter.로직",
subs,
total: list.total(),
subLabel: L("M01_LaborSub"),
@@ -306,15 +263,6 @@ export async function mountM01Logic(
persist();
};
/** 「새로 만들기」 모달 — 저장·본떠 저장이 끝나면 그 새 자체 로직을 흐름 그림으로 바로 엶 */
const openWizard = (): void =>
openLogicWizard({
onSaved: (key) =>
void reload()
.then(() => open(logicId("자체", key), "자체", key))
.catch(failed),
});
/** 옛 컨테이너의 「새로 만들기」 — 빈 줄 하나를 초안으로 세움(저장 때 서버가 키를 줌) */
const onDraftNew = (): void => {
const f = files.find((x) => x.file === opened?.file) ?? files[0];
@@ -351,23 +299,6 @@ export async function mountM01Logic(
persist();
show(next);
};
const onNew = easy ? openWizard : onDraftNew;
/** 「본떠 만들기」 — 정본을 자체 파일로 복제하고(계약 5장) 그 새 로직을 바로 엶 */
const onCopy = async (from: Opened): Promise<void> => {
if (!from.origKey) return;
showLoadingOverlay();
try {
const made = await copyLogic(from.origKey);
await reload();
showToast(tx("Edit_Copied", { v: made.key }), "success");
await open(logicId("자체", made.key), "자체", made.key);
} catch (error) {
failed(error);
} finally {
hideLoadingOverlay();
}
};
const onDelete = async (): Promise<void> => {
if (!opened?.row) return;
@@ -457,20 +388,11 @@ export async function mountM01Logic(
children: [
back,
el("h2", { text: tx("Title") }),
// 흐름 보기 = 읽기 + 시험 계산만 — 만들기·지우기·저장 단추 없음
...(mode === "cview"
? []
: [
pending,
createButton({ label: tx("Bar_New"), variant: "ghost", onClick: onNew }),
createButton({
label: tx("Bar_Delete"),
variant: "danger",
onClick: () => void onDelete(),
}),
discardButton,
saveButton,
]),
pending,
createButton({ label: tx("Bar_New"), variant: "ghost", onClick: onDraftNew }),
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
discardButton,
saveButton,
],
});
host.replaceChildren(
@@ -485,9 +407,7 @@ export async function mountM01Logic(
],
}),
);
({ classic: side.logicHost, easy: side.easyHost, cview: side.cviewHost })[mode].replaceChildren(
filterHost,
);
side.logicHost.replaceChildren(filterHost);
show(null);
showLoadingOverlay();
const openKeyOn = async (key: string): Promise<void> => {
@@ -85,8 +85,7 @@ const TEXT = {
Save_Failed: ["저장 못 함", "Save failed"],
Save_Nothing: ["고친 것 없음", "Nothing to save"],
Load_Failed: ["불러오지 못함", "Load failed"],
CView_Title: ["로직 흐름 보기", "Logic — flow view"],
Easy_Title: ["로직 쉽게 보기·만들기", "Logic — easy view & create"],
Lab_Title: ["로직 개선 시험", "Logic — improvement lab"],
Loading: ["불러오는 중", "Loading"],
Confirm_Delete: [
"로직 「{v}」 을 지울까요? [저장] 때 파일에서 빠짐",
@@ -99,8 +98,6 @@ const TEXT = {
],
New_Key: ["새 로직", "New logic"],
/* ── 흐름 그림(기본 보기) ── */
View_Flow: ["흐름 그림", "Flow"],
View_Advanced: ["고급 — 호표 표", "Advanced — table"],
Flow_NoInputs: ["받을 값 없음", "No inputs"],
Flow_Other: ["그 밖", "Other"],
Flow_Sub: ["이 로직이 부르는 로직", "Logic called here"],
@@ -112,10 +109,6 @@ const TEXT = {
Col_덧줄: ["할증·덧줄", "Extra lines"],
Col_비목: ["비목 합계", "Cost items"],
Col_계: ["계", "Total"],
Guide_Flow: [
"왼쪽 「설계 값」을 넣으면 오른쪽으로 수량 → 단가 → 합계 순으로 흐름이 이어짐|상자를 누르면 값·출처·원문 표·쉬운 말 풀이가 펼쳐짐|빨간 글이 뜨면 그 까닭 그대로 값을 채우거나 바꿈",
"Enter design values on the left; quantity, price and total follow to the right|Click a box for values, source, the source table and a plain-words reading|Red text says what is missing",
],
/* ── 상자 속 설명 카드 ── */
Note_Plain: ["쉬운 말 풀이", "In plain words"],
Note_Table: ["원문 표 미리보기", "Source table"],
@@ -176,7 +169,6 @@ export function plainReason(reason: string): string {
}
/** 흐름 그림 머리 「이렇게 씀」 — 줄 목록 */
export const guideLines = (): string[] => tx("Guide_Flow").split("|");
/** 공용 `t()` 와 같은 뜻 — `{n}`·`{v}` 는 채움 */
export function tx(key: TextKey, fill: Record<string, string | number> = {}): string {
@@ -1,279 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Wizard.ts
* 「새로 만들기」 모달 — 질문 한 번에 하나 · 옆에 지금까지 만든 호표 미리보기
* 걸음 = 이름 → 원문 절 → 중간 값 → 호표 줄 → 설계 입력 → 할증·끝수 → 시험 계산 → 저장
* 시험 계산은 `POST /calc` 에 `row`+`file` 을 실어 보냄(파일에 안 씀) · 저장은 `POST /save` `add`
* 걸음별 화면 = `_Wizard_Steps.ts` · 값 고르기 칸 = `_Wizard_Atom.ts` · 식 조립 = `_Wizard_Model.ts`
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { ApiError, runCalc, type CalcAnswer } from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { select, textBox } from "./M01_MasterData_UI_Logic_Wizard_Atom";
import { createLogic } from "./M01_MasterData_UI_Logic_Wizard_Api";
import {
atomPlain,
buildRow,
collectInputs,
emptyDraft,
isMoney,
middleText,
qtyPlain,
type Draft,
} from "./M01_MasterData_UI_Logic_Wizard_Model";
import { STEPS, type Step } from "./M01_MasterData_UI_Logic_Wizard_Steps";
import { tw } from "./M01_MasterData_UI_Logic_Wizard_Text";
import { plainReason } from "./M01_MasterData_UI_Logic_Text";
import "./M01_MasterData_UI_Logic_Wizard_Style.css";
export interface WizardOptions {
/** 저장이 끝나면 새 키와 함께 — 방식 C 로 여는 자리 */
onSaved?: (key: string) => void;
}
const fail = (e: unknown): void =>
showToast(e instanceof Error ? e.message : tw("Failed"), "error");
/** 「새로 만들기」 모달을 엶 */
export function openLogicWizard(options: WizardOptions = {}): void {
const d: Draft = emptyDraft();
let at = 0;
let answered = false;
const testValues: Record<string, string> = {};
const steps: Step[] = [
...STEPS,
{ key: "S_Test", question: "Q_Test", render: () => testView(), ready: () => answered },
{ key: "S_Save", question: "Q_Save", render: () => saveView(), ready: () => false },
];
const body = el("div", { className: "m01w__body" });
const preview = el("aside", { className: "m01w__preview" });
const head = el("p", { className: "m01w__step" });
const back = createButton({ label: tw("Back"), variant: "ghost", onClick: () => go(at - 1) });
const next = createButton({ label: tw("Next"), onClick: () => go(at + 1) });
const close = (): void => backdrop.remove();
const dialog = el("div", {
className: "m01w",
attrs: { role: "dialog", "aria-label": tw("Title") },
children: [
el("div", {
className: "m01w__top",
children: [
el("h2", { text: tw("Title") }),
createButton({ label: tw("Close"), variant: "ghost", onClick: close }),
],
}),
head,
el("div", { className: "m01w__main", children: [body, preview] }),
el("div", { className: "m01w__nav", children: [back, next] }),
],
});
const backdrop = el("div", { className: "m01w__backdrop", children: [dialog] });
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
const changed = (): void => {
drawPreview();
next.disabled = !steps[at].ready(d);
};
const onCopied = (key: string): void => {
close();
options.onSaved?.(key);
};
const go = (to: number): void => {
at = Math.max(0, Math.min(steps.length - 1, to));
if (at === STEPS.length) answered = false; // 시험 계산 걸음에 들어올 때마다 새로
const step = steps[at];
head.textContent = tw("Step", { n: at + 1, total: steps.length, name: tw(step.key) });
body.replaceChildren(step.render({ d, changed, onCopied }));
back.disabled = at === 0;
next.hidden = at === steps.length - 1;
changed();
};
/* ── 시험 계산 ── */
const testView = (): HTMLElement => {
const out = el("div", { className: "m01w__stack" });
const inputs = collectInputs(d);
const fields = inputs.map((s) => {
testValues[s.name] ??= s.choices ? String(s.choices[0]) : "";
const meta = d.meta[s.name];
const control = s.choices
? select(
s.choices.map((c) => ({ value: String(c), text: String(c) })),
testValues[s.name],
(v) => (testValues[s.name] = v),
s.name,
)
: textBox(
testValues[s.name],
meta?.min && meta?.max ? `${meta.min} ${meta.max}` : tw("Qty_Number_Ph"),
(v) => (testValues[s.name] = v),
meta?.unit ? `${s.name} (${meta.unit})` : s.name,
);
control.dataset.input = s.name;
return control;
});
const run = createButton({
label: tw("Test_Run"),
onClick: () => {
void calc()
.then((answer) => {
answered = answer.ok;
out.replaceChildren(resultView(answer));
changed();
})
.catch(fail);
},
});
out.append(el("p", { className: "m01w__muted", text: tw("Test_Empty") }));
return el("div", {
className: "m01w__page",
children: [el("h3", { text: tw("Q_Test") }), ...fields, run, out],
});
};
const calc = async (): Promise<CalcAnswer> => {
const given: Record<string, unknown> = {};
for (const s of collectInputs(d)) {
const raw = (testValues[s.name] ?? "").trim();
if (raw === "") continue;
const option = s.choices?.find((c) => String(c) === raw);
given[s.name] = option ?? (Number.isNaN(Number(raw)) ? raw : Number(raw));
}
return runCalc({ key: "", inputs: given, row: buildRow(d) });
};
/* ── 저장 ── */
const saveView = (): HTMLElement => {
const errors = el("div", { className: "m01w__reasons", attrs: { hidden: "" } });
return el("div", {
className: "m01w__page",
children: [
el("h3", { text: tw("Q_Save") }),
el("p", { className: "m01w__muted", text: tw("Save_Owner") }),
errors,
createButton({ label: tw("Save_Do"), onClick: () => void doSave(errors) }),
],
});
};
const doSave = async (errors: HTMLElement): Promise<void> => {
errors.hidden = true;
try {
const made = await createLogic(buildRow(d));
showToast(tw("Save_Done"), "success");
close();
options.onSaved?.(made.key);
} catch (e) {
if (e instanceof ApiError && typeof e.detail === "object" && e.detail !== null) {
const list = (e.detail as { errors?: string[] }).errors ?? [];
errors.replaceChildren(...list.map((t) => el("div", { text: t })));
errors.hidden = false;
} else fail(e);
}
};
/* ── 옆 미리보기 ── */
const drawPreview = (): void => {
const rows: HTMLElement[] = [
el("h4", { text: tw("Preview") }),
el("p", {
className: "m01w__muted",
text: [d.name || "…", d.unit, d.section ? `${d.book} ${d.section}` : ""]
.filter(Boolean)
.join(" · "),
}),
];
for (const m of d.middles) {
rows.push(
el("div", {
className: "m01w__line",
text: `${tw("Preview_Middle")} ${m.name} = ${middleText(m)}`,
}),
);
}
if (isMoney(d.unit)) {
for (const l of d.lines) {
rows.push(
el("div", {
className: "m01w__line",
attrs: { "data-line": l.name },
children: [
el("strong", { text: `${l.kind} · ${l.name}` }),
el("span", { className: "m01w__muted", text: `${l.unit} · ${qtyPlain(l.qty)}` }),
],
}),
);
}
} else if (d.result.atom)
rows.push(el("div", { className: "m01w__line", text: atomPlain(d.result.atom) }));
const inputs = collectInputs(d).map((s) => s.name);
if (inputs.length) {
rows.push(
el("p", {
className: "m01w__muted",
text: `${tw("Preview_Inputs")}: ${inputs.join(" · ")}`,
}),
);
}
for (const e of d.extras.filter((x) => x.name)) {
rows.push(
el("div", {
className: "m01w__line",
text: `${tw("Preview_Extra")} ${e.name} · ${e.base} × ${e.pct}%`,
}),
);
}
preview.replaceChildren(...rows);
};
document.body.append(backdrop);
go(0);
}
function resultView(answer: CalcAnswer): HTMLElement {
if (!answer.ok) {
return el("div", {
className: "m01w__reasons",
children: [
el("strong", { text: tw("Test_Stopped") }),
el("div", { text: plainReason(answer.reason) }),
],
});
}
const parts: HTMLElement[] = [];
if (answer.lines) {
parts.push(
el("table", {
className: "m01w__grid",
children: [
el("tbody", {
children: answer.lines.map((l) =>
el("tr", {
children: [
el("td", { text: l.이름 }),
el("td", { text: `${formatNumber(l.)} ${l.}` }),
el("td", { className: "m01w__money", text: formatNumber(l.) }),
],
}),
),
}),
],
}),
);
}
const total = answer.sums?.["계"] ?? (answer.result as number | undefined);
if (total !== undefined) {
parts.push(
el("p", {
className: "m01w__total",
attrs: { "data-total": String(total) },
text: `${answer.sums ? tw("Sum_Total") : tw("Sum_Result")} ${formatNumber(total)}`,
}),
);
}
return el("div", { className: "m01w__stack", children: parts });
}
@@ -1,117 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Wizard_Api.ts
* 「새로 만들기」 모달이 쓰는 읽기 길 — 계약 `resources/master_data/_화면_계약.md` 2장
* 절 목록 API 가 서기 전 = `/tables` 를 절 번호로 찾아 원문번호가 같은 표만 남김
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { ApiError, type ElementBrief, type LogicRow } from "./M01_MasterData_UI_Logic_Api";
export interface TableBrief {
file: string;
: string;
원문번호: string;
구분: string;
이름: string;
기준?: string;
조건?: Record<string, string>;
값칸?: Record<string, string>;
?: { 공종?: string; 대상?: string[]; 로직키?: string[] };
}
/** 표 조건 칸의 값만(`/table/options`) — 줄을 통째로 받지 않음 */
export interface TableInfo {
: string;
이름: string;
조건?: Record<string, string>;
값칸?: Record<string, string>;
/** 고르기 조건 = 줄에 나온 값 차례 · 범위 조건 = [[아래, 위]…] */
값?: Record<string, (string | number)[] | [number, number][]>;
}
async function postJson<T>(path: string, body: unknown): Promise<T> {
const response = await fetch(`${API_BASE_URL}/m01${path}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText);
return data;
}
async function getJson<T>(path: string, params: Record<string, string>): Promise<T> {
const response = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, {
credentials: "include",
});
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText);
return data;
}
/** 원문 절 한 줄 — `/sections` */
export interface SectionBrief {
book: string;
division: string;
chapter: string;
chapterNo: string;
section: string;
title: string;
/** 그 절(아래 절 포함)의 마스터 표·로직 수 — 0 이면 아직 안 올린 절 */
tables: number;
logics: number;
}
export const fetchSections = (book: string, division = ""): Promise<SectionBrief[]> =>
getJson<{ sections: SectionBrief[] }>("/sections", {
book,
...(division ? { division } : {}),
limit: "2000",
}).then((d) => d.sections);
/** 그 절(아래 절 포함)의 표 — 소요량·계수 둘을 다 봄 */
export async function fetchSectionTables(book: string, section: string): Promise<TableBrief[]> {
const found = await Promise.all(
["소요량", "계수"].map((group) =>
getJson<{ tables: TableBrief[] }>("/tables", { group, section, size: "100" }),
),
);
return found.flatMap((f) => f.tables).filter((t) => t. === book);
}
const tableCache = new Map<string, Promise<TableInfo>>();
export function fetchTableInfo(file: string, key: string): Promise<TableInfo> {
let got = tableCache.get(key);
if (!got) {
got = getJson<TableInfo>("/table/options", { file, key });
tableCache.set(key, got);
}
return got;
}
/** 자체 로직 만들기 — 키(GX)는 서버가 붙임 */
export interface Made {
file: string;
version: string;
key: string;
logic: LogicRow;
}
export const createLogic = (logic: LogicRow): Promise<Made> => postJson("/logic/new", { logic });
export const copyLogic = (key: string, name: string): Promise<Made> =>
postJson("/logic/copy", { key, 이름: name });
/** 공표 직종 찾기 */
export const searchJobs = (q: string): Promise<{ items: ElementBrief[] }> =>
getJson("/pick", { kind: "job", q, limit: "100" });
/** 로직 이름·번호 찾기 — 하위 로직 고르기 */
export interface LogicBrief {
: string;
원문번호: string;
이름: string;
결과단위: string;
}
export const searchLogics = (q: string): Promise<{ logics: LogicBrief[] }> =>
getJson("/logics", { q });
@@ -1,262 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Wizard_Atom.ts
* 값 하나를 고르는 칸 — 직접 값 / 설계 입력 / 표에서 찾기 / 다른 로직 부르기
* 수량 · 조건 나누기의 각 갈래가 같이 씀 · 고른 것은 넘겨받은 Atom 에 바로 씀(식은 Model 이 조립)
* ========================================================================== */
import { createInputField, createSelectField, el, showToast } from "@ui/ui_template_elements";
import {
fetchTableInfo,
searchLogics,
type LogicBrief,
type TableBrief,
} from "./M01_MasterData_UI_Logic_Wizard_Api";
import { condChoices, tableInfo, type Atom } from "./M01_MasterData_UI_Logic_Wizard_Model";
import { tw } from "./M01_MasterData_UI_Logic_Wizard_Text";
type Option = { value: string; text: string };
export const select = (
options: Option[],
value: string,
onChange: (v: string) => void,
label?: string,
): HTMLElement => createSelectField({ options, value, onChange, compact: true, label }).root;
export const textBox = (
value: string,
placeholder: string,
onInput: (v: string) => void,
label?: string,
): HTMLElement => createInputField({ value, placeholder, onInput, label, type: "text" }).root;
/** 원자를 통째로 바꿔 씀(다른 갈래로 옮길 때) */
function reset(atom: Atom, next: Atom): void {
for (const k of Object.keys(atom)) delete (atom as unknown as Record<string, unknown>)[k];
Object.assign(atom, next);
}
const fresh = (kind: Atom["kind"]): Atom =>
kind === "value"
? { kind, value: "" }
: kind === "input"
? { kind, name: "" }
: kind === "logic"
? { kind, key: "", args: "" }
: { kind, table: "", col: "", cond: {} };
/**
* `tables` = 원문 절 단계에서 고른 표 · `onChange` = 고른 것이 바뀔 때마다
* `kinds` = 이 자리에서 고를 수 있는 갈래(기본 넷 다)
*/
export function atomEditor(
atom: Atom,
tables: TableBrief[],
onChange: () => void,
kinds: Atom["kind"][] = ["value", "table", "input", "logic"],
): HTMLElement {
const host = el("div", { className: "m01w__atom" });
const body = el("div", { className: "m01w__atom-body" });
const names: Record<Atom["kind"], string> = {
value: tw("Qty_Value"),
table: tw("Qty_Table"),
input: tw("Qty_Input"),
logic: tw("Qty_Logic"),
};
const draw = (): void => {
body.replaceChildren();
if (atom.kind === "value") body.append(valueBox(atom, onChange));
else if (atom.kind === "input") body.append(inputBox(atom, onChange));
else if (atom.kind === "logic") body.append(logicBox(atom, onChange));
else body.append(tableBox(atom, tables, onChange));
};
host.append(
select(
kinds.map((k) => ({ value: k, text: names[k] })),
atom.kind,
(v) => {
reset(atom, fresh(v as Atom["kind"]));
draw();
onChange();
},
),
body,
);
draw();
return host;
}
function valueBox(atom: Extract<Atom, { kind: "value" }>, onChange: () => void): HTMLElement {
return textBox(atom.value, tw("Qty_Number_Ph"), (v) => {
atom.value = v;
onChange();
});
}
function inputBox(atom: Extract<Atom, { kind: "input" }>, onChange: () => void): HTMLElement {
return textBox(atom.name, tw("Qty_Name"), (v) => {
atom.name = v.trim();
onChange();
});
}
function logicBox(atom: Extract<Atom, { kind: "logic" }>, onChange: () => void): HTMLElement {
const list = el("div", { className: "m01w__found" });
const key = createInputField({ value: atom.key, placeholder: tw("Qty_LogicKey"), type: "text" });
key.input.addEventListener("input", () => {
atom.key = key.input.value.trim();
onChange();
});
const find = createInputField({ placeholder: tw("Line_Logic_Find"), type: "search" });
let timer = 0;
find.input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => {
const q = find.input.value.trim();
if (!q) return list.replaceChildren();
void searchLogics(q)
.then((r) => showLogics(r.logics.slice(0, 8)))
.catch((e: unknown) => showToast(e instanceof Error ? e.message : tw("Failed"), "error"));
}, 250);
});
const showLogics = (found: LogicBrief[]): void => {
list.replaceChildren(
...found.map((l) => {
const row = el("button", {
className: "m01w__row",
attrs: { type: "button" },
text: `${l.} ${l.} · ${l.} · ${l.}`,
});
row.addEventListener("click", () => {
atom.key = l.;
key.input.value = l.;
list.replaceChildren();
onChange();
});
return row;
}),
);
};
const args = createInputField({
value: atom.args,
placeholder: tw("Qty_LogicArgs"),
type: "text",
});
args.input.addEventListener("input", () => {
atom.args = args.input.value;
onChange();
});
return el("div", { className: "m01w__stack", children: [find.root, list, key.root, args.root] });
}
function tableBox(
atom: Extract<Atom, { kind: "table" }>,
tables: TableBrief[],
onChange: () => void,
): HTMLElement {
const host = el("div", { className: "m01w__stack" });
const detail = el("div", { className: "m01w__stack" });
const drawDetail = (): void => {
const info = tableInfo.get(atom.table);
detail.replaceChildren();
if (!info) return;
detail.append(
select(
[
{ value: "", text: "—" },
...Object.keys(info. ?? {}).map((c) => ({ value: c, text: c })),
],
atom.col,
(v) => {
atom.col = v;
onChange();
},
tw("Qty_Col"),
),
);
for (const cond of Object.keys(info. ?? {})) detail.append(condRow(atom, cond, onChange));
};
const load = (key: string): void => {
const brief = tables.find((t) => t. === key);
if (!brief) return (drawDetail(), onChange());
void fetchTableInfo(brief.file, key)
.then((full) => {
tableInfo.set(key, full);
for (const c of Object.keys(full. ?? {})) atom.cond[c] ??= { ask: true };
drawDetail();
onChange();
})
.catch((e: unknown) => showToast(e instanceof Error ? e.message : tw("Failed"), "error"));
};
const choose = (key: string): void => {
atom.table = key;
atom.col = "";
atom.cond = {};
load(key);
};
host.append(
select(
[
{ value: "", text: "—" },
...tables.map((t) => ({ value: t.키, text: `${t.} · ${t.}` })),
],
atom.table,
choose,
tw("Qty_Table_Pick"),
),
detail,
);
if (atom.table && !tableInfo.has(atom.table)) load(atom.table);
else drawDetail();
return host;
}
/** 표의 조건 칸 하나 — 설계자에게 물음 / 붙박이 값 */
function condRow(
atom: Extract<Atom, { kind: "table" }>,
cond: string,
onChange: () => void,
): HTMLElement {
atom.cond[cond] ??= { ask: true };
const choices = condChoices(atom.table, cond);
const value = el("div", { className: "m01w__inline" });
const set = (v: string): void => {
atom.cond[cond] = { ask: false, value: v };
onChange();
};
const drawValue = (): void => {
value.replaceChildren();
const bind = atom.cond[cond];
if (bind.ask) return;
if (choices) {
const now = bind.value || String(choices[0]);
atom.cond[cond] = { ask: false, value: now };
value.append(
select(
choices.map((c) => ({ value: String(c), text: String(c) })),
now,
set,
),
);
} else value.append(textBox(bind.value, tw("Qty_Number_Ph"), set));
};
const mode = select(
[
{ value: "ask", text: tw("Qty_Ask") },
{ value: "fixed", text: tw("Qty_Fixed") },
],
atom.cond[cond].ask ? "ask" : "fixed",
(v) => {
atom.cond[cond] = v === "ask" ? { ask: true } : { ask: false, value: "" };
drawValue();
onChange();
},
);
drawValue();
return el("div", {
className: "m01w__inline",
children: [el("strong", { text: cond }), mode, value],
});
}
@@ -1,62 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Wizard_Copy.ts
* 본떠 만들기 — 이미 있는 로직(정본·자체)을 찾아 새 이름으로 복제(`POST /logic/copy`) · 키 GX 는 서버가 붙임
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { copyLogic, searchLogics, type LogicBrief } from "./M01_MasterData_UI_Logic_Wizard_Api";
import { tw } from "./M01_MasterData_UI_Logic_Wizard_Text";
import { fail } from "./M01_MasterData_UI_Logic_Wizard_Ui";
export function copyBox(done: (key: string) => void): HTMLElement {
const find = createInputField({ placeholder: tw("Copy_Find"), type: "search" });
const list = el("div", { className: "m01w__found" });
const name = createInputField({ placeholder: tw("Copy_Name"), type: "text" });
let from: LogicBrief | null = null;
const go = createButton({
label: tw("Copy_Do"),
disabled: true,
onClick: () => {
if (!from) return;
const target = from;
void copyLogic(target., name.input.value.trim() || `${target.} (수정)`)
.then((made) => {
showToast(tw("Copy_Done"), "success");
done(made.key);
})
.catch(fail);
},
});
let timer = 0;
find.input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => {
const q = find.input.value.trim();
if (!q) return list.replaceChildren();
void searchLogics(q)
.then((r) =>
list.replaceChildren(
...r.logics.slice(0, 8).map((l) => {
const row = el("button", {
className: "m01w__row",
attrs: { type: "button", "data-copy": l. },
text: `${l.} ${l.} · ${l.} · ${l.}`,
});
row.addEventListener("click", () => {
from = l;
name.input.value = `${l.} (수정)`;
go.disabled = false;
list.replaceChildren(row);
});
return row;
}),
),
)
.catch(fail);
}, 250);
});
return el("div", {
className: "m01w__card",
children: [el("h4", { text: tw("Copy_Title") }), find.root, list, name.root, go],
});
}
@@ -1,248 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Wizard_Model.ts
* 「새로 만들기」 — 만드는 중인 로직 한 벌(선택지) → 식 글자 → 로직 줄. 화면 없음(순수 함수)
* 식은 사람이 치지 않음 — 고른 것을 여기서 엔진 문법(`_틀.md` 8장)으로 조립
* ========================================================================== */
import type { HoLine, LogicInput, LogicRow, NamedFormula } from "./M01_MasterData_UI_Logic_Api";
import type { TableBrief, TableInfo } from "./M01_MasterData_UI_Logic_Wizard_Api";
export type Bind = { ask: true } | { ask: false; value: string };
export type Atom =
| { kind: "value"; value: string }
| { kind: "input"; name: string }
| { kind: "table"; table: string; col: string; cond: Record<string, Bind> }
| { kind: "logic"; key: string; args: string };
export interface Qty {
atom: Atom;
/** 곱할 중간 값 이름(× (1 + 값 / 100)) — 없으면 "" */
times: string;
}
export interface Branch {
input: string;
op: "<=" | "<" | ">=" | ">";
than: string;
then: Atom;
}
export interface Middle {
name: string;
branches: Branch[];
otherwise: Atom;
}
export type LineKind = "인력" | "재료" | "기계" | "로직";
export interface Line {
kind: LineKind;
/** 인력 = LB 키 · 기계 = EQ 키 · 재료 = 조건 묶음 · 로직 = 하위 로직 키 */
ref: string | { 구분: string; 상세구분?: string };
args: string;
name: string;
unit: string;
qty: Qty;
cost: string;
}
export interface Extra {
name: string;
base: string;
pct: string;
cost: string;
}
export interface InputMeta {
desc: string;
unit: string;
min: string;
max: string;
}
export interface Draft {
name: string;
unit: string;
book: string;
section: string;
tables: TableBrief[];
middles: Middle[];
lines: Line[];
result: Qty;
meta: Record<string, InputMeta>;
extras: Extra[];
round: string;
}
export const BASES: Record<string, string> = {
: "노무비",
: "재료비",
: "경비",
"노무비+재료비": "(노무비 + 재료비)",
"노무비+재료비+경비": "(노무비 + 재료비 + 경비)",
};
export const COSTS = ["노무비", "재료비", "경비"];
export const COST_OF: Record<LineKind, string> = {
: "노무비",
: "재료비",
: "경비",
: "",
};
/** 표 통째 — 고르기 목록·조건 종류를 셈할 때 씀(모달이 읽어 두는 곳) */
export const tableInfo = new Map<string, TableInfo>();
export const emptyAtom = (): Atom => ({ kind: "value", value: "" });
export const emptyQty = (): Qty => ({ atom: emptyAtom(), times: "" });
export const emptyDraft = (): Draft => ({
name: "",
unit: "원/㎡",
book: "산림품셈",
section: "",
tables: [],
middles: [],
lines: [],
result: emptyQty(),
meta: {},
extras: [],
round: "",
});
export const isMoney = (unit: string): boolean => unit.trim().startsWith("원");
const isNumber = (v: string): boolean => v.trim() !== "" && !Number.isNaN(Number(v));
const name_ = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`);
const literal = (v: string): string => (isNumber(v) ? v.trim() : `'${v}'`);
export function atomText(a: Atom): string {
if (a.kind === "value") return a.value.trim();
if (a.kind === "input") return a.name;
if (a.kind === "logic") return `로직(${a.key}${a.args.trim() ? `, ${a.args.trim()}` : ""})`;
const conds = Object.entries(a.cond).map(
([n, b]) => `, ${name_(n)}=${b.ask ? n : literal(b.value)}`,
);
return `찾기(${a.table}${conds.join("")}).${name_(a.col)}`;
}
export const qtyText = (q: Qty): string =>
q.times ? `${atomText(q.atom)} * (1 + ${q.times} / 100)` : atomText(q.atom);
export function middleText(m: Middle): string {
let out = atomText(m.otherwise);
for (const b of [...m.branches].reverse()) {
out = `만약(${b.input} ${b.op} ${b.than}, ${atomText(b.then)}, ${out})`;
}
return out;
}
/** 이 원자가 채워졌는지 — 다음으로 못 넘기는 까닭에 씀 */
export function atomReady(a: Atom): boolean {
if (a.kind === "value") return isNumber(a.value);
if (a.kind === "input") return a.name.trim() !== "";
if (a.kind === "logic") return a.key.trim() !== "";
return a.table !== "" && a.col !== "";
}
export const qtyReady = (q: Qty): boolean => atomReady(q.atom);
/** 쉬운 말 한 줄 — 미리보기 */
export function atomPlain(a: Atom): string {
if (a.kind === "value") return a.value || "?";
if (a.kind === "input") return `입력 「${a.name}`;
if (a.kind === "logic") return `로직 ${a.key} 의 결과`;
const asks = Object.entries(a.cond)
.filter(([, b]) => b.ask)
.map(([n]) => n);
const table = tableInfo.get(a.table)?. ?? a.table;
return `표 「${table}」의 「${a.col}${asks.length ? ` (${asks.join("·")} 에 따라)` : ""}`;
}
export const qtyPlain = (q: Qty): string =>
q.times ? `${atomPlain(q.atom)} × (1 + ${q.times}/100)` : atomPlain(q.atom);
/** 조건 칸 하나가 받을 수 있는 값 — 표 줄에서 뽑음(없으면 undefined = 수 칸) */
export function condChoices(table: string, cond: string): (string | number)[] | undefined {
const info = tableInfo.get(table);
if (!info || info.?.[cond] !== "고르기") return undefined;
return info.?.[cond] as (string | number)[] | undefined;
}
/** 만드는 중인 것이 모든 식에서 받는 설계 입력 이름들(등장 차례) — 이름 · 모양 */
export interface InputShape {
name: string;
choices?: (string | number)[];
}
export function collectInputs(d: Draft): InputShape[] {
const found = new Map<string, InputShape>();
const add = (name: string, choices?: (string | number)[]): void => {
const had = found.get(name);
if (!had) found.set(name, { name, ...(choices ? { choices } : {}) });
else if (choices && had.choices) {
const more = choices.filter((c) => !had.choices!.includes(c));
had.choices.push(...more);
}
};
const middleNames = new Set(d.middles.map((m) => m.name));
const seeAtom = (a: Atom): void => {
if (a.kind === "input" && a.name && !middleNames.has(a.name)) add(a.name);
if (a.kind === "table") {
for (const [n, b] of Object.entries(a.cond)) if (b.ask) add(n, condChoices(a.table, n));
}
};
for (const m of d.middles) {
for (const b of m.branches) {
if (!middleNames.has(b.input)) add(b.input);
seeAtom(b.then);
}
seeAtom(m.otherwise);
}
if (isMoney(d.unit)) for (const l of d.lines) seeAtom(l.qty.atom);
else seeAtom(d.result.atom);
return [...found.values()];
}
const inputOf = (s: InputShape, meta: InputMeta | undefined): LogicInput => {
const out: LogicInput = { 이름: s.name };
const rest: Record<string, unknown> = {};
if (meta?.desc.trim()) rest["설명"] = meta.desc.trim();
if (meta?.unit.trim()) out. = meta.unit.trim();
if (s.choices) out. = s.choices;
else if (isNumber(meta?.min ?? "") && isNumber(meta?.max ?? "")) {
out. = [Number(meta!.min), Number(meta!.max)];
}
return Object.assign(out, rest);
};
const extraOf = (e: Extra, source: string): NamedFormula => ({
이름: e.name.trim(),
: `${BASES[e.base] ?? e.base} * ${e.pct.trim()} / 100`,
비목: e.cost,
출처: source,
});
/** 만드는 중인 것 → 로직 줄(키는 서버가 저장 때 붙임 — "") */
export function buildRow(d: Draft): LogicRow {
const source = `${d.book} ${d.section}`.trim();
const row: LogicRow = {
: "",
원문번호: d.section,
: "자체",
: "",
이름: d.name.trim(),
결과단위: d.unit.trim(),
출처: source,
: "현장",
입력: collectInputs(d).map((s) => inputOf(s, d.meta[s.name])),
중간: d.middles.map((m): NamedFormula => ({ 이름: m.name, : middleText(m), 출처: source })),
};
if (isMoney(d.unit)) {
row. = d.lines.map((l): HoLine => {
const ref =
l.kind === "로직"
? `로직(${l.ref as string}${l.args.trim() ? `, ${l.args.trim()}` : ""})`
: (l.ref as unknown as HoLine["요소"]);
return {
종류: l.kind,
요소: ref,
이름: l.name,
단위: l.unit,
수량: qtyText(l.qty),
...(l.cost ? { 비목: l.cost } : {}),
};
});
row. = d.extras
.filter((e) => e.name.trim() && isNumber(e.pct))
.map((e) => extraOf(e, source));
Object.assign(row, { 끝수: d.round ? { : "계", 자리: 0, 방법: d.round } : null });
} else row. = { : qtyText(d.result) };
return row;
}
@@ -1,178 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Wizard_Source.ts
* 만들기 둘째 걸음 — 원문 절 고르기(책 → 부문 → 장 → 절) · 그 절(아래 절 포함)의 표를 용도와 함께 제시
* 절 목록 = `GET /sections` · 표 = `GET /tables?section=` — 번호를 손으로 넣지 않음
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import {
fetchSections,
fetchSectionTables,
type SectionBrief,
type TableBrief,
} from "./M01_MasterData_UI_Logic_Wizard_Api";
import { select, textBox } from "./M01_MasterData_UI_Logic_Wizard_Atom";
import type { Draft } from "./M01_MasterData_UI_Logic_Wizard_Model";
import { tw } from "./M01_MasterData_UI_Logic_Wizard_Text";
import { fail, note, page, type Step } from "./M01_MasterData_UI_Logic_Wizard_Ui";
const sections = new Map<string, Promise<SectionBrief[]>>();
const load = (book: string): Promise<SectionBrief[]> => {
let got = sections.get(book);
if (!got) sections.set(book, (got = fetchSections(book)));
return got;
};
const distinct = (rows: SectionBrief[], key: "division" | "chapter"): string[] => [
...new Set(rows.map((r) => r[key]).filter(Boolean)),
];
export const sourceStep: Step = {
key: "S_Source",
question: "Q_Source",
ready: (d) => d.section.trim() !== "",
render: ({ d, changed }) => {
const filters = el("div", { className: "m01w__inline" });
const list = el("div", { className: "m01w__found" });
const picked = el("p", { className: "m01w__picked" });
const tables = el("div", { className: "m01w__stack" });
let all: SectionBrief[] = [];
let division = "";
let chapter = "";
let q = "";
const drawTables = (): void =>
tables.replaceChildren(
...(d.section && !d.tables.length
? [note(tw("Section_None")), note(tw("Section_Skip"))]
: d.tables.map((t) => tableRow(t, d, changed))),
);
const choose = (s: SectionBrief): void => {
d.section = s.section;
d.tables = [];
picked.textContent = `${tw("Section_Picked")} · ${s.section} ${s.title}`;
changed();
drawList();
void fetchSectionTables(d.book, s.section)
.then((found) => {
d.tables = [...found]; // 처음에는 이 절의 표를 모두 씀 — 빼려면 체크를 풂
drawTables();
changed();
})
.catch(fail);
};
const options = (values: string[]): { value: string; text: string }[] =>
values.map((v) => ({ value: v, text: v }));
const draw = (): void => {
const inDivision = all.filter((s) => !division || s.division === division);
const chapters = distinct(inDivision, "chapter");
if (!chapters.includes(chapter)) chapter = chapters[0] ?? "";
const divisions = distinct(all, "division");
filters.replaceChildren(
select(
["산림품셈", "건설품셈"].map((b) => ({ value: b, text: b })),
d.book,
(v) => {
d.book = v;
d.section = "";
d.tables = [];
division = chapter = "";
picked.textContent = "";
drawTables();
changed();
start();
},
tw("Book"),
),
...(divisions.length
? [
select(
options(divisions),
division || divisions[0],
(v) => ((division = v), (chapter = ""), draw()),
tw("Division"),
),
]
: []),
select(options(chapters), chapter, (v) => ((chapter = v), draw()), tw("Chapter")),
textBox(q, tw("Section_Search"), (v) => ((q = v.trim()), drawList())),
);
drawList();
};
const drawList = (): void => {
const inChapter = all.filter(
(s) =>
(!division || s.division === division) &&
s.chapter === chapter &&
(!q || s.section.startsWith(q) || s.title.includes(q)),
);
list.replaceChildren(
...inChapter.slice(0, 80).map((s) => {
const row = el("button", {
className: `m01w__row${s.section === d.section ? " is-active" : ""}`,
attrs: { type: "button", "data-section": s.section },
text: `${s.section} ${s.title} · ${tw("Section_Counts", { t: s.tables, l: s.logics })}`,
});
row.addEventListener("click", () => choose(s));
return row;
}),
);
};
const start = (): void => {
void load(d.book)
.then((rows) => {
all = rows;
const divisions = distinct(rows, "division");
division = divisions[0] ?? "";
const own = rows.find((s) => s.section === d.section);
if (own) {
division = own.division;
chapter = own.chapter;
picked.textContent = `${tw("Section_Picked")} · ${own.section} ${own.title}`;
}
draw();
})
.catch(fail);
};
start();
drawTables();
return page("Q_Source", filters, list, picked, tables);
},
};
function tableRow(t: TableBrief, d: Draft, changed: () => void): HTMLElement {
const check = el("input", { attrs: { type: "checkbox", "data-table": t. } });
check.checked = d.tables.some((x) => x. === t.);
check.addEventListener("change", () => {
d.tables = check.checked ? [...d.tables, t] : d.tables.filter((x) => x. !== t.);
changed();
});
const usage = t.?.?.join("·") ?? "";
return el("label", {
className: "m01w__table",
children: [
check,
el("div", {
className: "m01w__stack",
children: [
el("strong", { text: `${t.} ${t.} · ${t.}` }),
el("span", {
className: "m01w__muted",
text: [
t. ? `${tw("Table_Base")} ${t.}` : "",
`${tw("Table_Cols")} ${Object.keys(t. ?? {}).join("·")}`,
usage ? `${tw("Table_Usage")} ${usage}` : "",
]
.filter(Boolean)
.join(" · "),
}),
],
}),
],
});
}
@@ -1,548 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Wizard_Steps.ts
* 「새로 만들기」 걸음별 화면 — 이름 · 원문 절 · 중간 값 · 호표 줄 · 설계 입력 · 할증·끝수
* 한 걸음 = `{name, render(ctx), ready(draft)}` · 시험 계산·저장은 모달 본체(`Wizard.ts`)가 얹음
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { searchElements, searchPrice } from "./M01_MasterData_UI_Logic_Api";
import { searchJobs, type TableBrief } from "./M01_MasterData_UI_Logic_Wizard_Api";
import { atomEditor, select, textBox } from "./M01_MasterData_UI_Logic_Wizard_Atom";
import {
BASES,
COSTS,
COST_OF,
atomReady,
collectInputs,
emptyAtom,
emptyQty,
isMoney,
qtyPlain,
qtyReady,
type Branch,
type Draft,
type Line,
type LineKind,
type Middle,
} from "./M01_MasterData_UI_Logic_Wizard_Model";
import { tw, type WizardTextKey } from "./M01_MasterData_UI_Logic_Wizard_Text";
import { copyBox } from "./M01_MasterData_UI_Logic_Wizard_Copy";
import { sourceStep } from "./M01_MasterData_UI_Logic_Wizard_Source";
import { fail, note, page, type Ctx, type Step } from "./M01_MasterData_UI_Logic_Wizard_Ui";
export type { Ctx, Step };
/* ── 1. 이름 ─────────────────────────────────────────────────────────── */
const UNITS = ["원/㎡", "원/m", "원/㎥", "원/개소", "원/본", "원/톤"];
const nameStep: Step = {
key: "S_Name",
question: "Q_Name",
ready: (d) => d.name.trim() !== "" && d.unit.trim() !== "",
render: ({ d, changed, onCopied }) => {
const other = !UNITS.includes(d.unit);
const own = el("div");
const drawOwn = (on: boolean): void =>
own.replaceChildren(
...(on
? [
textBox(d.unit, tw("Unit_Own_Ph"), (v) => {
d.unit = v;
changed();
}),
]
: []),
);
drawOwn(other);
return page(
"Q_Name",
textBox(d.name, tw("Name_Ph"), (v) => {
d.name = v;
changed();
}),
el("h4", { text: tw("Q_Unit") }),
select(
[...UNITS, tw("Unit_Other")].map((u) => ({ value: u, text: u })),
other ? tw("Unit_Other") : d.unit,
(v) => {
const isOther = v === tw("Unit_Other");
d.unit = isOther ? "" : v;
drawOwn(isOther);
changed();
},
),
own,
note(tw("Unit_Money")),
copyBox(onCopied),
);
},
};
/* ── 3. 중간 값(조건 나누기) ─────────────────────────────────────────── */
const newMiddle = (): Middle => ({ name: "", branches: [], otherwise: emptyAtom() });
const middleStep: Step = {
key: "S_Middle",
question: "Q_Middle",
ready: () => true,
render: ({ d, changed }) => {
const done = el("div", { className: "m01w__stack" });
const form = el("div", { className: "m01w__stack" });
let cur = newMiddle();
const drawDone = (): void =>
done.replaceChildren(
...d.middles.map((m, i) =>
el("div", {
className: "m01w__inline",
children: [
el("strong", { text: m.name }),
el("span", { className: "m01w__muted", text: `${m.branches.length + 1}갈래` }),
createButton({
label: tw("Line_Del"),
variant: "ghost",
onClick: () => {
d.middles.splice(i, 1);
drawDone();
changed();
},
}),
],
}),
),
);
const drawForm = (): void => {
const rows = cur.branches.map((b) => branchRow(b, d.tables, changed));
form.replaceChildren(
textBox(
cur.name,
tw("Middle_Name"),
(v) => {
cur.name = v.trim();
},
tw("Middle_Name"),
),
...rows,
createButton({
label: tw("Middle_AddIf"),
variant: "ghost",
onClick: () => {
cur.branches.push({ input: "", op: "<=", than: "", then: emptyAtom() });
drawForm();
},
}),
el("strong", { text: tw("Middle_Else") }),
atomEditor(cur.otherwise, d.tables, changed, ["value", "table", "input"]),
createButton({
label: tw("Middle_Done"),
onClick: () => {
const ok =
cur.name &&
atomReady(cur.otherwise) &&
cur.branches.every((b) => b.input && b.than.trim() && atomReady(b.then));
if (!ok) return showToast(tw("Middle_Need"), "warning");
d.middles.push(cur);
cur = newMiddle();
drawDone();
drawForm();
changed();
},
}),
);
};
drawDone();
drawForm();
return page("Q_Middle", note(tw("Middle_Note")), done, form);
},
};
function branchRow(b: Branch, tables: TableBrief[], changed: () => void): HTMLElement {
return el("div", {
className: "m01w__branch",
children: [
el("span", { text: tw("Middle_If") }),
textBox(b.input, tw("Qty_Name"), (v) => (b.input = v.trim())),
select(
["<=", "<", ">=", ">"].map((o) => ({ value: o, text: o })),
b.op,
(v) => (b.op = v as Branch["op"]),
),
textBox(b.than, tw("Qty_Number_Ph"), (v) => (b.than = v)),
el("span", { text: tw("Middle_Then") }),
atomEditor(b.then, tables, changed, ["value", "table", "input"]),
],
});
}
/* ── 4. 호표 줄 ──────────────────────────────────────────────────────── */
interface Found {
ref: string;
이름?: string;
규격?: string;
단위?: unknown;
구분?: string;
상세구분?: string;
}
/** 찾기 → 구분 → 상세구분 → 후보 — 후보를 누르면 `onPick` */
function pickBox(
search: (q: string) => Promise<Found[]>,
onPick: (f: Found) => void,
start: string,
): HTMLElement {
const box = createInputField({ value: start, placeholder: tw("Line_Find"), type: "search" });
const list = el("div", { className: "m01w__found" });
const shown = el("p", { className: "m01w__picked" });
const filters = el("div", { className: "m01w__inline" });
let all: Found[] = [];
let sub = "";
let detail = "";
const distinct = (rows: Found[], k: "구분" | "상세구분"): string[] => [
...new Set(rows.map((r) => r[k]).filter((v): v is string => !!v)),
];
const draw = (): void => {
const inSub = all.filter((f) => !sub || f. === sub);
const inDetail = inSub.filter((f) => !detail || f. === detail);
const all_ = { value: "", text: "(전체)" };
filters.replaceChildren(
select(
[all_, ...distinct(all, "구분").map((v) => ({ value: v, text: v }))],
sub,
(v) => ((sub = v), (detail = ""), draw()),
tw("Line_Sub"),
),
select(
[all_, ...distinct(inSub, "상세구분").map((v) => ({ value: v, text: v }))],
detail,
(v) => ((detail = v), draw()),
tw("Line_Detail"),
),
);
list.replaceChildren(
...inDetail.slice(0, 30).map((f) => {
const row = el("button", {
className: "m01w__row",
attrs: { type: "button", "data-ref": f.ref },
text: `${f. ?? ""} ${f. ?? ""}`.trim() + ` · ${f. ?? ""} ${f. ?? ""}`,
});
row.addEventListener("click", () => pick(f));
return row;
}),
);
};
const pick = (f: Found): void => {
shown.textContent = `${f. ?? ""} ${f. ?? ""}`.trim();
onPick(f);
};
const load = (auto: boolean): void => {
const q = box.input.value.trim();
void search(q)
.then((rows) => {
all = rows;
sub = detail = "";
draw();
const exact = rows.filter((r) => r. === q);
if (auto && exact.length === 1) pick(exact[0]);
})
.catch(fail);
};
let timer = 0;
box.input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => load(false), 250);
});
load(true);
return el("div", { className: "m01w__stack", children: [box.root, filters, list, shown] });
}
const KINDS: { kind: LineKind; label: WizardTextKey }[] = [
{ kind: "인력", label: "K_job" },
{ kind: "재료", label: "K_mat" },
{ kind: "기계", label: "K_eq" },
{ kind: "로직", label: "K_logic" },
];
const blankLine = (kind: LineKind = "인력"): Line => ({
kind,
ref: "",
args: "",
name: "",
unit: kind === "인력" ? "인" : kind === "기계" ? "시간" : "",
qty: emptyQty(),
cost: COST_OF[kind],
});
const timesPicker = (d: Draft, q: Line["qty"], changed: () => void): HTMLElement =>
select(
[
{ value: "", text: tw("Qty_NoTimes") },
...d.middles.map((m) => ({ value: m.name, text: m.name })),
],
q.times,
(v) => {
q.times = v;
changed();
},
tw("Qty_Times"),
);
const linesStep: Step = {
key: "S_Lines",
question: "Q_Lines",
ready: (d) => (isMoney(d.unit) ? d.lines.length > 0 : qtyReady(d.result)),
render: ({ d, changed }) => {
if (!isMoney(d.unit)) {
return page(
"Result_Q",
atomEditor(d.result.atom, d.tables, changed),
timesPicker(d, d.result, changed),
);
}
const done = el("div", { className: "m01w__stack" });
const form = el("div", { className: "m01w__stack" });
const chips = el("div", { className: "m01w__inline" });
let cur = blankLine();
const drawDone = (): void =>
done.replaceChildren(
...(d.lines.length
? d.lines.map((l, i) =>
el("div", {
className: "m01w__inline m01w__done",
children: [
el("strong", { text: `${l.kind} · ${l.name}` }),
el("span", { className: "m01w__muted", text: `${l.unit} · ${qtyPlain(l.qty)}` }),
createButton({
label: tw("Line_Del"),
variant: "ghost",
onClick: () => {
d.lines.splice(i, 1);
drawDone();
changed();
},
}),
],
}),
)
: [note(tw("Line_None"))]),
);
const drawForm = (start = ""): void => {
let unitBox: HTMLInputElement | null = null;
const picker = (): HTMLElement => {
const set = (f: Found): void => {
cur.name = `${f. ?? ""}${cur.kind === "기계" && f. ? ` ${f.}` : ""}`.trim();
if (cur.kind === "재료") {
cur.ref = { 구분: f.구분 ?? "", ...(f. ? { 상세구분: f.상세구분 } : {}) };
if (typeof f. === "string" && f.) cur.unit = f.;
if (unitBox) unitBox.value = cur.unit;
} else cur.ref = f.ref;
};
if (cur.kind === "로직") {
const atom = { kind: "logic" as const, key: "", args: "" };
const bridge = (): void => {
cur.ref = atom.key;
cur.args = atom.args;
cur.name ||= atom.key;
};
return atomEditor(atom, d.tables, bridge, ["logic"]);
}
const search =
cur.kind === "인력"
? (q: string) => searchJobs(q).then((r) => r.items as Found[])
: cur.kind === "재료"
? (q: string) => searchPrice(q).then((r) => r.items as Found[])
: (q: string) => searchElements("기계", q).then((r) => r.items as Found[]);
return pickBox(search, set, start);
};
const unit = createInputField({
value: cur.unit,
placeholder: tw("Line_Unit"),
type: "text",
});
unit.input.addEventListener("input", () => (cur.unit = unit.input.value.trim()));
unitBox = unit.input;
form.replaceChildren(
select(
KINDS.map((k) => ({ value: k.kind, text: tw(k.label) })),
cur.kind,
(v) => {
cur = blankLine(v as LineKind);
drawForm();
},
tw("Line_Kind"),
),
picker(),
unit.root,
el("strong", { text: tw("Line_Qty") }),
atomEditor(cur.qty.atom, d.tables, changed),
timesPicker(d, cur.qty, changed),
select(
["", ...COSTS].map((c) => ({ value: c, text: c || "(자동)" })),
cur.cost,
(v) => (cur.cost = v),
tw("Line_Cost"),
),
createButton({
label: tw("Line_Add"),
onClick: () => {
if (!cur.ref || !qtyReady(cur.qty)) return showToast(tw("Line_Need"), "warning");
d.lines.push(cur);
cur = blankLine(cur.kind);
drawDone();
drawForm();
changed();
},
}),
);
};
// 표의 값 칸에서 바로 줄 넣기 — 값 칸 이름으로 찾아 두고 수량은 그 표 칸으로
const drawChips = (): void =>
chips.replaceChildren(
...d.tables.flatMap((t) =>
Object.keys(t. ?? {}).map((col) =>
createButton({
label: `${col} (${t.})`,
variant: "pill",
onClick: () => {
const target = t.?. ?? [];
const kind: LineKind = target.includes("기계")
? "기계"
: target.includes("재료")
? "재료"
: "인력";
cur = blankLine(kind);
cur.qty.atom = { kind: "table", table: t.키, col, cond: {} };
drawForm(col);
},
}),
),
),
);
drawDone();
drawForm();
drawChips();
return page(
"Q_Lines",
done,
d.tables.length ? el("strong", { text: tw("Line_FromTable") }) : "",
chips,
form,
);
},
};
/* ── 5. 설계 입력 ────────────────────────────────────────────────────── */
const inputsStep: Step = {
key: "S_Inputs",
question: "Q_Inputs",
ready: () => true,
render: ({ d, changed }) => {
const shapes = collectInputs(d);
if (!shapes.length) return page("Q_Inputs", note(tw("Inputs_None")));
const cards = shapes.map((s) => {
const meta = (d.meta[s.name] ??= { desc: "", unit: "", min: "", max: "" });
const range = s.choices
? [note(`${tw("In_Pick")} · ${s.choices.join(" · ")}`)]
: [
note(tw("In_Number")),
el("div", {
className: "m01w__inline",
children: [
textBox(meta.min, tw("In_Range"), (v) => ((meta.min = v), changed())),
textBox(meta.max, tw("In_Range"), (v) => ((meta.max = v), changed())),
],
}),
];
return el("div", {
className: "m01w__card",
attrs: { "data-input": s.name },
children: [
el("strong", { text: s.name }),
textBox(meta.desc, tw("In_Desc"), (v) => ((meta.desc = v), changed())),
textBox(meta.unit, tw("In_Unit"), (v) => ((meta.unit = v), changed())),
...range,
],
});
});
return page("Q_Inputs", ...cards);
},
};
/* ── 6. 할증·덧줄 · 끝수 ─────────────────────────────────────────────── */
const extraStep: Step = {
key: "S_Extra",
question: "Q_Extra",
ready: () => true,
render: ({ d, changed }) => {
const money = isMoney(d.unit);
if (!money) return page("Q_Extra", note("돈이 아닌 로직 — 덧줄·끝수 없음"));
const list = el("div", { className: "m01w__stack" });
const draw = (): void =>
list.replaceChildren(
...d.extras.map((e, i) =>
el("div", {
className: "m01w__inline m01w__card",
children: [
textBox(e.name, tw("Extra_Name"), (v) => ((e.name = v), changed())),
select(
Object.keys(BASES).map((b) => ({ value: b, text: b })),
e.base,
(v) => ((e.base = v), changed()),
tw("Extra_Base"),
),
textBox(e.pct, tw("Extra_Pct"), (v) => ((e.pct = v), changed())),
select(
COSTS.map((c) => ({ value: c, text: c })),
e.cost,
(v) => ((e.cost = v), changed()),
tw("Extra_Cost"),
),
createButton({
label: tw("Line_Del"),
variant: "ghost",
onClick: () => {
d.extras.splice(i, 1);
draw();
changed();
},
}),
],
}),
),
);
draw();
return page(
"Q_Extra",
list,
createButton({
label: tw("Extra_Add"),
variant: "ghost",
onClick: () => {
d.extras.push({ name: "", base: "노무비", pct: "", cost: "경비" });
draw();
changed();
},
}),
el("h4", { text: tw("Round_Title") }),
select(
[
{ value: "", text: tw("Round_None") },
{ value: "버림", text: tw("Round_Down") },
{ value: "올림", text: tw("Round_Up") },
{ value: "반올림", text: tw("Round_Half") },
],
d.round,
(v) => {
d.round = v;
changed();
},
),
);
},
};
export const STEPS: Step[] = [nameStep, sourceStep, middleStep, linesStep, inputsStep, extraStep];
@@ -1,169 +0,0 @@
/* M01 「새로 만들기」 모달 — 접두 m01w__ */
.m01w__backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 45%);
}
.m01w {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: min(960px, 96vw);
max-height: 92vh;
padding: var(--spacing-12);
box-sizing: border-box;
border-radius: var(--radius-cards);
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
.m01w__top,
.m01w__nav,
.m01w__inline {
display: flex;
align-items: center;
gap: var(--spacing-8);
flex-wrap: wrap;
}
.m01w__top h2 {
margin: 0 auto 0 0;
font-size: 18px;
color: var(--color-text);
}
.m01w__nav {
justify-content: flex-end;
}
.m01w__main {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: var(--spacing-12);
min-height: 0;
overflow: hidden;
}
.m01w__body,
.m01w__preview {
min-height: 0;
overflow: auto;
}
.m01w__preview {
padding: var(--spacing-8);
border-left: 1px solid var(--color-border);
}
.m01w__page,
.m01w__stack,
.m01w__found {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m01w__page h3,
.m01w__page h4,
.m01w__preview h4 {
margin: 0;
color: var(--color-text);
}
.m01w__step,
.m01w__muted,
.m01w__picked {
margin: 0;
color: var(--color-text-muted);
}
.m01w__bad {
margin: 0;
color: var(--color-danger, #c0392b);
}
.m01w__reasons {
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
}
.m01w__row {
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.m01w__row:hover {
background: var(--color-bg, rgb(0 0 0 / 4%));
}
.m01w__table,
.m01w__card,
.m01w__branch,
.m01w__atom {
display: flex;
gap: var(--spacing-8);
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
}
.m01w__card,
.m01w__atom,
.m01w__branch {
flex-direction: column;
}
.m01w__atom {
padding: var(--spacing-4);
border-style: dashed;
}
.m01w__line {
display: flex;
flex-direction: column;
padding: var(--spacing-4) 0;
border-bottom: 1px solid var(--color-border);
}
.m01w__grid {
border-collapse: collapse;
}
.m01w__grid td {
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
}
.m01w__money,
.m01w__total {
text-align: right;
font-variant-numeric: tabular-nums;
}
.m01w__total {
margin: 0;
font-weight: 600;
}
@media (width <= 720px) {
.m01w__main {
grid-template-columns: 1fr;
}
}
.m01w__row.is-active {
border-color: var(--color-accent, #2b6cb0);
font-weight: 600;
}
@@ -1,160 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Wizard_Text.ts
* [, ] · (· )
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Title: ["새 일위대가 로직 만들기", "New unit-cost logic"],
Close: ["닫기", "Close"],
Back: ["이전", "Back"],
Next: ["다음", "Next"],
Step: ["{n} / {total} · {name}", "{n} / {total} · {name}"],
S_Name: ["이름", "Name"],
S_Source: ["원문 절", "Source section"],
S_Middle: ["중간 값", "Middle values"],
S_Lines: ["호표 줄", "Table lines"],
S_Inputs: ["설계 입력", "Design inputs"],
S_Extra: ["할증·끝수", "Surcharge · rounding"],
S_Test: ["시험 계산", "Trial calculation"],
S_Save: ["저장", "Save"],
Q_Name: ["무엇을 얼마만큼 하는 값인가요?", "What does this value price?"],
Name_Ph: ["예: 돌쌓기 메쌓기(인력)", "e.g. Dry stone masonry"],
Q_Unit: ["결과는 어떤 단위인가요?", "Result unit?"],
Unit_Money: [
"「원」 으로 시작하면 돈을 셈하는 로직(호표) — 아니면 값 하나를 셈",
"Units starting with 원 give money",
],
Unit_Other: ["그 밖 (직접)", "Other"],
Copy_Title: ["또는 이미 있는 로직을 본떠 만들기", "Or copy an existing logic"],
Copy_Find: ["본뜰 로직 이름·번호 찾기", "Find a logic to copy"],
Copy_Name: ["새 이름", "New name"],
Copy_Do: ["본떠서 저장", "Copy and save"],
Copy_Done: ["본떠서 저장했습니다", "Copied"],
Unit_Own_Ph: ["예: ㎥/시간", "e.g. ㎥/h"],
Q_Source: ["어느 원문 절을 옮기나요?", "Which source section?"],
Book: ["책", "Book"],
Division: ["부문", "Division"],
Chapter: ["장", "Chapter"],
Section_Search: ["절 번호·제목 찾기", "Search sections"],
Section_Counts: ["표 {t} · 로직 {l}", "{t} tables · {l} logics"],
Section_Picked: ["고른 절", "Chosen section"],
Section_None: [
"이 절에 쓰는 표가 없습니다 — 절 번호를 확인해 주세요",
"No tables in this section",
],
Section_Skip: [
"표 없이 직접 만들 수도 있습니다 — 그대로 다음",
"You can also build without tables",
],
Table_Use: ["이 표를 씀", "Use this table"],
Table_Base: ["기준", "Base"],
Table_Cols: ["값 칸", "Value columns"],
Table_Usage: ["용도", "Used for"],
Q_Middle: ["이름을 붙여 둘 값이 있나요? (예: 높이에 따라 달라지는 증가율)", "Named values?"],
Middle_Note: ["없으면 그대로 다음 — 있으면 아래에서 만듭니다", "Skip if none"],
Middle_Add: ["중간 값 더하기", "Add a value"],
Middle_Name: ["이 값의 이름", "Name"],
Middle_If: ["만약", "If"],
Middle_Then: ["이면", "then"],
Middle_Else: ["그 밖이면", "otherwise"],
Middle_AddIf: ["조건 더하기", "Add condition"],
Middle_Done: ["이 중간 값 넣기", "Add this value"],
Middle_Need: ["이름을 적어 주세요", "Name it"],
Q_Lines: ["호표에 어떤 줄을 넣을까요?", "Add lines"],
Line_Kind: ["무엇을", "Kind"],
K_job: ["인력", "Labor"],
K_mat: ["재료", "Material"],
K_eq: ["기계", "Machine"],
K_logic: ["다른 로직의 단가", "Other logic"],
Line_FromTable: ["표의 값 칸에서 바로 줄 넣기", "Add from table column"],
Line_Find: ["찾기", "Search"],
Line_Pick: ["고르기", "Pick"],
Line_Sub: ["구분", "Group"],
Line_Detail: ["상세구분", "Sub-group"],
Line_Unit: ["단위", "Unit"],
Line_Qty: ["수량", "Quantity"],
Line_Cost: ["비목", "Cost item"],
Line_Add: ["줄 넣기", "Add line"],
Line_Need: ["무엇을 쓸지 고르고 수량을 정해 주세요", "Pick an item and quantity"],
Line_None: ["아직 넣은 줄이 없습니다", "No lines yet"],
Line_Del: ["빼기", "Remove"],
Line_Logic_Find: ["로직 이름·번호 찾기", "Find logic"],
Result_Q: ["결과 값을 어떻게 셈할까요?", "How is the result computed?"],
Qty_Value: ["직접 값", "Fixed number"],
Qty_Table: ["표에서 찾기", "Look up in a table"],
Qty_Input: ["설계 입력 값", "Design input"],
Qty_Logic: ["다른 로직 부르기", "Call another logic"],
Qty_Table_Pick: ["어느 표", "Table"],
Qty_Col: ["값 칸", "Column"],
Qty_Ask: ["설계자에게 물음", "Ask designer"],
Qty_Fixed: ["붙박이 값", "Fixed"],
Qty_Times: ["× 할증·증가율 (중간 값)", "× surcharge"],
Qty_NoTimes: ["(없음)", "(none)"],
Qty_Name: ["입력 이름", "Input name"],
Qty_Number_Ph: ["수", "number"],
Qty_LogicKey: ["로직 키 (예: GF000220)", "Logic key"],
Qty_LogicArgs: ["넘길 입력 이름=값 (쉼표로 나눔)", "Args"],
Qty_Table_Manual: ["표 키 (예: QF000421)", "Table key"],
Q_Inputs: ["설계자에게 물을 값에 쉬운 말 설명을 달아 주세요", "Describe design inputs"],
Inputs_None: ["설계자에게 물을 값이 없습니다 — 그대로 다음", "No inputs"],
In_Desc: ["설명 (쉬운 말 한 줄)", "Description"],
In_Unit: ["단위", "Unit"],
In_Range: ["범위 아래 위 (수 칸)", "Range"],
In_Pick: ["고르기 목록", "Choices"],
In_Number: ["수를 넣는 칸", "Number"],
Q_Extra: ["할증·덧줄과 끝수를 정해 주세요", "Surcharge lines and rounding"],
Extra_Add: ["덧줄 더하기 (노무비의 몇 % 같은 줄)", "Add surcharge line"],
Extra_Name: ["줄 이름 (예: 공구손료 및 경장비)", "Name"],
Extra_Base: ["기준", "Base"],
Extra_Pct: ["몇 %", "%"],
Extra_Cost: ["넣을 비목", "Cost item"],
Round_Title: ["끝수", "Rounding"],
Round_None: ["처리 안 함", "None"],
Round_Down: ["원 미만 버림", "Round down"],
Round_Up: ["원 미만 올림", "Round up"],
Round_Half: ["원 미만 반올림", "Round half"],
Q_Test: ["견본 값을 넣어 계산해 보세요", "Try sample values"],
Test_Run: ["시험 계산", "Calculate"],
Test_Empty: ["값을 넣고 「시험 계산」을 누르세요", "Enter values and calculate"],
Test_Stopped: ["계산이 멈춤", "Stopped"],
Col_Name: ["이름", "Name"],
Col_Qty: ["수량", "Qty"],
Col_Amount: ["금액", "Amount"],
Sum_Total: ["합계", "Total"],
Sum_Result: ["결과 값", "Result"],
Q_Save: ["이대로 저장할까요?", "Save as is?"],
Save_Owner: [
"자체 로직으로 저장됩니다 — 키(GX…)는 저장할 때 서버가 붙임",
"Saved as your own logic; the server issues the key",
],
Save_Do: ["저장", "Save"],
Save_Done: ["저장했습니다", "Saved"],
Save_Failed: ["저장하지 못함", "Save failed"],
Failed: ["불러오지 못함", "Load failed"],
Preview: ["지금까지 만든 호표", "Lines so far"],
Preview_Name: ["이름", "Name"],
Preview_Source: ["출처", "Source"],
Preview_Inputs: ["설계 입력", "Inputs"],
Preview_Middle: ["중간 값", "Middle"],
Preview_Extra: ["덧줄", "Extra"],
} as const satisfies Record<string, readonly [string, string]>;
export type WizardTextKey = keyof typeof TEXT;
export function tw(key: WizardTextKey, 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));
}
@@ -1,31 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Wizard_Ui.ts
* · · ·
* ========================================================================== */
import { el, showToast } from "@ui/ui_template_elements";
import type { Draft } from "./M01_MasterData_UI_Logic_Wizard_Model";
import { tw, type WizardTextKey } from "./M01_MasterData_UI_Logic_Wizard_Text";
export interface Ctx {
d: Draft;
/** 값이 바뀜 — 미리보기·다음 단추 다시 셈 */
changed: () => void;
/** 본떠 만들기가 끝남 — 모달이 닫고 새 키를 넘김 */
onCopied: (key: string) => void;
}
export interface Step {
key: WizardTextKey;
question: WizardTextKey;
render: (ctx: Ctx) => HTMLElement;
ready: (d: Draft) => boolean;
}
export const page = (question: WizardTextKey, ...body: (HTMLElement | string)[]): HTMLElement =>
el("div", {
className: "m01w__page",
children: [el("h3", { text: tw(question) }), ...body],
});
export const note = (text: string): HTMLElement => el("p", { className: "m01w__muted", text });
export const fail = (e: unknown): void =>
showToast(e instanceof Error ? e.message : tw("Failed"), "error");
+12 -30
View File
@@ -54,8 +54,7 @@ function buildPage(): HTMLElement {
let query = "";
let dispose = (): void => {};
let logicMounted = false;
let easyMounted = false;
let cviewMounted = false;
let labMounted = false;
/* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */
const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") });
@@ -71,8 +70,7 @@ function buildPage(): HTMLElement {
children: [title, searchField.root, summary, drop, save],
});
const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
const easyHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
const cviewHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
const labHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
const elementView = el("div", {
className: "m01-master__elements",
children: [head, notice, body],
@@ -90,10 +88,9 @@ function buildPage(): HTMLElement {
onDraftChange(refreshBar);
refreshBar();
const showMode = (mode: "elements" | "logic" | "easy" | "cview"): void => {
const showMode = (mode: "elements" | "logic" | "lab"): void => {
logicHost.hidden = mode !== "logic";
easyHost.hidden = mode !== "easy";
cviewHost.hidden = mode !== "cview";
labHost.hidden = mode !== "lab";
elementView.hidden = mode !== "elements";
};
@@ -199,34 +196,19 @@ function buildPage(): HTMLElement {
});
};
/** 「로직 쉽게 보기·만들기」 — 같은 로직 목록에 방식 C 흐름 그림 + 새로 만들기 모달 */
const openEasyTab = (): void => {
/** 「로직 개선 시험」 — 정본 로직 화면의 복사본(개선은 여기서만) */
const openLabTab = (): void => {
dispose();
dispose = (): void => {};
current = null;
showMode("easy");
if (easyMounted) return;
easyMounted = true;
void import("./M01_MasterData_UI_Logic_Page").then((m) =>
m.mountM01Logic(easyHost, side, undefined, "easy"),
);
};
/** 「로직 흐름 보기」 — 같은 로직 목록에 방식 C 흐름 그림(읽기 + 시험 계산) */
const openCViewTab = (): void => {
dispose();
dispose = (): void => {};
current = null;
showMode("cview");
if (cviewMounted) return;
cviewMounted = true;
void import("./M01_MasterData_UI_Logic_Page").then((m) =>
m.mountM01Logic(cviewHost, side, undefined, "cview"),
);
showMode("lab");
if (labMounted) return;
labMounted = true;
void import("./M01_MasterData_UI_LogicLab").then((m) => m.mountM01LogicLab(labHost, side));
};
/* --- 좌측: 컨테이너 --- */
const side = buildSide(openFile, () => openLogicTab(), openEasyTab, openCViewTab);
const side = buildSide(openFile, () => openLogicTab(), openLabTab);
const layout = el("div", { className: "ui-workflow-layout m01-master" });
const main = el("main", {
@@ -234,7 +216,7 @@ function buildPage(): HTMLElement {
children: [
el("div", {
className: "m01-master__panel",
children: [elementView, logicHost, easyHost, cviewHost],
children: [elementView, logicHost, labHost],
}),
],
});
+12 -23
View File
@@ -75,9 +75,8 @@ export interface SideHandle {
root: HTMLElement;
/** 일위대가 로직 컨테이너 안(로직 목록이 들어갈 자리) */
logicHost: HTMLElement;
/** 「로직 쉽게 보기·만들기」 컨테이너 안(같은 로직 목록이 들어갈 자리) */
easyHost: HTMLElement;
cviewHost: HTMLElement;
/** 「로직 개선 시험」 컨테이너 안 */
labHost: HTMLElement;
/** 그룹의 파일 판본을 다시 받음(저장 뒤) — 돌려받는 것 = 새 목록 */
refresh: (group: string) => Promise<FileInfo[]>;
setActive: (id: string | null) => void;
@@ -133,14 +132,12 @@ const LEAF: Group[] = ["환율", "요율"];
/** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */
const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"];
const LOGIC_ID = "로직|";
const EASY_ID = "쉬움|";
const CVIEW_ID = "흐름|";
const LAB_ID = "로직시험|";
export function buildSide(
onOpen: (pick: Pick) => void,
onLogic: () => void,
onEasy: () => void,
onCView: () => void,
onLab: () => void,
): SideHandle {
const lists = new Map<Group, FileInfo[]>();
const bodies = new Map<Group, HTMLElement>();
@@ -427,23 +424,15 @@ export function buildSide(
onLogic();
});
const easyHost = el("div", { className: "m01-side__items" });
const easy = section(tx("Easy_Title"), easyHost);
easy.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
if (!easy.classList.contains("is-collapsed")) return;
setActive(buttons.has(EASY_ID) ? EASY_ID : null);
onEasy();
const labHost = el("div", { className: "m01-side__items" });
const lab = section(tx("Lab_Title"), labHost);
lab.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
if (!lab.classList.contains("is-collapsed")) return;
setActive(buttons.has(LAB_ID) ? LAB_ID : null);
onLab();
});
const cviewHost = el("div", { className: "m01-side__items" });
const cview = section(tx("CView_Title"), cviewHost);
cview.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
if (!cview.classList.contains("is-collapsed")) return;
setActive(buttons.has(CVIEW_ID) ? CVIEW_ID : null);
onCView();
});
const root = el("div", { className: "m01-side", children: [...groups, logic, easy, cview] });
const root = el("div", { className: "m01-side", children: [...groups, logic, lab] });
attachCollapsible(root);
void Promise.all(GROUPS.map(refresh)).catch((error) =>
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"),
@@ -451,5 +440,5 @@ export function buildSide(
const setOnPicked = (fn: () => void): void => {
onPicked = fn;
};
return { root, logicHost, easyHost, cviewHost, refresh, setActive, setOnPicked, filter };
return { root, logicHost, labHost, refresh, setActive, setOnPicked, filter };
}
-141
View File
@@ -1,141 +0,0 @@
/* M01 C( ) TS Node .
* (프론트에 JS 테스트 러너가 없어 pytest가 스크립트를 부른다 test_m01_flow_c.py)
* 확장자가 `.cjs` 까닭은 helper_b05_patch_skirt.cjs 머리말 참고(루트가 ESM). */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const source = fs.readFileSync(
path.join(__dirname, "..", "..", "M01_MasterData", "M01_MasterData_UI_Logic_Flow_Model.ts"),
"utf8",
);
const js = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const moduleBox = { exports: {} };
new Function("exports", "module", "require", js)(moduleBox.exports, moduleBox, require);
const { buildFlow, logicRef } = moduleBox.exports;
/** 산림품셈 13-4-1 메쌓기 모양 — 표 찾기 하나 · 인력 두 줄 · 덧줄 하나(할증) */
const 메쌓기 = {
: "GF000219",
원문번호: "13-4-1",
이름: "돌쌓기 메쌓기(인력)",
결과단위: "원/㎡",
출처: "산림품셈 13-4-1",
입력: [
{ 이름: "뒷길이", 단위: "㎝", 고르기: [25, 30] },
{ 이름: "높이", 단위: "m" },
],
중간: [{ 이름: "증가율", : "찾기(QF000422, 높이=높이).증가율", 출처: "산림품셈 13-4-1 주③" }],
호표: [
{
종류: "인력",
요소: "LB000033",
이름: "석공",
단위: "인",
수량: "찾기(QF000421, 뒷길이=뒷길이).석공",
비목: "노무비",
},
{
종류: "로직",
요소: "로직(GC000268, 기계='2702-0020')",
이름: "굴착기",
단위: "시간",
수량: "1 / N",
비목: "경비",
},
],
덧줄: [{ 이름: "잡재료", : "노무비 * 0.03", 비목: "재료비", 출처: "산림품셈 13-4-1 주①" }],
끝수: null,
};
const = {
ok: true,
lines: [
{ 이름: "석공", 단위: "인", 수량: 1.2, 단가: 300000, 금액: 360000, 비목: { 노무비: 360000 } },
{ 이름: "굴착기", 단위: "시간", 수량: 0.5, 단가: 80000, 금액: 40000, 비목: { 경비: 40000 } },
{ 이름: "잡재료", 단위: "식", 수량: 1, 단가: 10800, 금액: 10800, 비목: { 재료비: 10800 } },
],
sums: { 노무비: 360000, 재료비: 10800, 경비: 40000, : 410800 },
middle: { 증가율: 15 },
};
/** 돈이 아닌 로직 — 결과 식 하나 */
const 작업량 = {
: "GF000118",
원문번호: "9-16-1",
이름: "노체포설 시간당 작업량",
결과단위: "㎥/hr",
출처: "산림품셈 9-16-1",
입력: [{ 이름: "토질", 고르기: ["보통토", "암괴"] }],
중간: [{ 이름: "E", : "찾기(QF000300, 토질=토질).E" }],
결과: { : "3600 * q * E / Cm" },
};
const kinds = (columns) => columns.map((c) => c.kind);
const box = (columns, kind, i) => columns.find((c) => c.kind === kind).boxes[i];
const out = {
logicRef: [logicRef("로직(GC000268, 기계='2702-0020')"), logicRef("LB000033") ?? null],
: kinds(buildFlow(메쌓기, , {}, { 뒷길이: "25" })),
_계산전: kinds(buildFlow(메쌓기, null, {}, {})),
_돈아님: kinds(buildFlow(작업량, { ok: true, result: 42.5, middle: { E: 0.8 } }, {}, {})),
};
{
const columns = buildFlow(메쌓기, , {}, { 뒷길이: "25", 높이: "4" });
const 입력 = box(columns, "입력", 0);
const 중간 = box(columns, "중간", 0);
const 수량 = box(columns, "수량", 0);
const 단가1 = box(columns, "단가", 0);
const 단가2 = box(columns, "단가", 1);
const 덧줄 = box(columns, "덧줄", 0);
const = box(columns, "계", 0);
const 비목 = columns.find((c) => c.kind === "비목").boxes;
out. = {
입력값: 입력.value,
입력출처: 입력.detail,
중간값: 중간.value,
중간식: 중간.detail[0],
수량값: 수량.value,
수량식: 수량.detail[0],
단가노트: 단가1.note,
단가값: 단가1.value,
로직: 단가2.logic ?? null,
로직아님: 단가1.logic ?? null,
덧줄값: 덧줄.value,
덧줄식: 덧줄.detail[0],
비목: 비목.map((b) => [b.label, b.value]),
비목상세: 비목.map((b) => b.detail),
계값: .value,
묶음: columns.find((c) => c.kind === "수량").boxes.map((b) => b.cost),
id: [수량.id, 단가1.id, 덧줄.id, .id],
};
}
{
const columns = buildFlow(메쌓기, null, { LB000033: null }, {});
out.계산전 = {
수량값: box(columns, "수량", 0).value,
수량식: box(columns, "수량", 0).detail[0],
단가막힘: box(columns, "단가", 0).bad === true,
계값: box(columns, "계", 0).value,
};
}
{
const columns = buildFlow(메쌓기, { ok: false, reason: "입력 없음" }, {}, {});
out.멈춤 = { : kinds(columns), 계값: box(columns, "계", 0).value };
}
{
const columns = buildFlow(작업량, { ok: true, result: 42.5, middle: { E: 0.8 } }, {}, {});
out.돈아님 = {
중간값: box(columns, "중간", 0).value,
결과값: box(columns, "계", 0).value,
결과식: box(columns, "계", 0).detail[0],
};
}
process.stdout.write(JSON.stringify(out));
-82
View File
@@ -1,82 +0,0 @@
# -*- coding: utf-8 -*-
"""M01 로직 테스트 방식 C(흐름 그림) 뼈대 검증 — `M01_MasterData_UI_Logic_Flow_Model.ts`.
프론트에 JS 테스트 러너가 없어 Node 헬퍼(helper_m01_flow_c.cjs) TS를 트랜스파일해
돌리고, pytest 결과(JSON) 본다. 뼈대만 금액은 엔진이 답을 그대로 옮기는지.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def flow():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m01_flow_c.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_칸_차례(flow):
"""설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계."""
assert flow[""] == ["입력", "중간", "수량", "단가", "덧줄", "비목", ""]
# 계산 전에도 같은 뼈대 — 값만 비어 있음
assert flow["칸_계산전"] == flow[""]
# 돈이 아닌 로직은 호표가 없음
assert flow["칸_돈아님"] == ["입력", "중간", ""]
def test_값은_엔진_답_그대로(flow):
v = flow[""]
assert v["중간값"] == "15"
assert v["수량값"] == "1.2"
assert (v["단가노트"], v["단가값"]) == ("× 300,000", "360,000")
assert v["덧줄값"] == "10,800"
assert v["비목"] == [["노무비", "360,000"], ["재료비", "10,800"], ["경비", "40,000"]]
assert v["계값"] == "410,800"
def test_상자를_누르면_값과_출처(flow):
v = flow[""]
assert v["중간식"] == ["", "찾기(QF000422, 높이=높이).증가율"]
assert v["수량식"] == ["", "찾기(QF000421, 뒷길이=뒷길이).석공"]
assert v["덧줄식"] == ["", "노무비 * 0.03"]
assert ["단위", ""] in v["입력출처"]
# 비목 상자 = 그 비목에 들어온 줄
assert v["비목상세"] == [[["석공", "360,000"]], [["잡재료", "10,800"]], [["굴착기", "40,000"]]]
def test_로직이_로직을_부르는_줄(flow):
assert flow["logicRef"] == ["GC000268", None]
assert flow[""]["로직"] == "GC000268" # 펼칠 상자
assert flow[""]["로직아님"] is None
def test_id_는_다시_그려도_같음(flow):
assert flow[""]["id"] == ["수량:0", "단가:0", "덧줄:0", ""]
assert flow[""]["묶음"] == ["노무비", "경비"] # 비목으로 묶어 접기
def test_계산_전과_멈춤(flow):
# 값이 없어도 식은 보임 · 값 없는 요소는 막힘 표시
assert flow["계산전"]["수량값"] == ""
assert flow["계산전"]["수량식"][1].startswith("찾기(")
assert flow["계산전"]["단가막힘"] is True
assert flow["계산전"]["계값"] == ""
assert flow["멈춤"][""] == flow[""] and flow["멈춤"]["계값"] == ""
def test_돈_아닌_로직(flow):
assert flow["돈아님"]["중간값"] == "0.8"
assert flow["돈아님"]["결과값"] == "42.5"
assert flow["돈아님"]["결과식"] == ["", "3600 * q * E / Cm"]
@@ -1,58 +0,0 @@
"""M01 로직 테스트 컨테이너 — 견본 10 개가 실제 산림품셈 로직이고 막히지 않았는지.
흐름 그림(방식 C) 고급 화면은 같은 `/calc` 여기서는 10 개가 모두 선택값으로 셈이 끝까지 가는지만 .
"""
from __future__ import annotations
from M01_MasterData import M01_MasterData_Store as store
# 견본 10 개 — 복잡도(입력·표 찾기·로직 부르기·덧줄·재료)로 잰 순서 · 재는 법 `resources/master_data/scripts/로직_복잡도_재기.py`
KEYS = [
"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 중기운반 트레일러
]
def _first_inputs(logic: dict) -> dict:
"""고르기 = 첫 값 · 범위 = 아래 끝 · 나머지 = 10 (마법사가 흔히 넣는 값)."""
out = {}
for spec in logic.get("입력") or []:
if spec.get("고르기"):
out[spec["이름"]] = spec["고르기"][0]
elif spec.get("범위"):
out[spec["이름"]] = spec["범위"][0]
else:
out[spec["이름"]] = 10
return out
def test_ten_keys():
assert len(KEYS) == 10 and len(set(KEYS)) == 10
def test_keys_are_forest_logics_and_not_blocked():
for key in KEYS:
one = store.logic(key)
assert one["file"].startswith("로직_산림품셈_"), key
assert not one["blocked"], (key, one["reasons"])
def test_required_three_present():
# 메쌓기 13-4-1 · 노체 9-16-1 · 트럭 운반 10-4 — 끝까지 눌러 보는 셋
assert {"GF000219", "GF000119", "GF000137"} <= set(KEYS)
def test_calc_answers_for_each():
for key in KEYS:
one = store.logic(key)
answer = store.calc(key, _first_inputs(one["logic"]), None, None)
assert isinstance(answer, dict) and "ok" in answer, key