feat(M01): 로직 컨테이너를 셋으로 — 「로직 흐름 보기」(예전 C형 단독 · 읽기 + 시험 계산) 되살림 · 거름 기억과 캐시 열쇠 셋이 따로
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
/* =============================================================================
|
||||
* 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") })]),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/* =============================================================================
|
||||
* 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.출처],
|
||||
]),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/* 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);
|
||||
}
|
||||
@@ -33,6 +33,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";
|
||||
@@ -61,13 +62,15 @@ interface Opened extends Draft {
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 두 컨테이너(옛 호표 화면 · 쉽게 보기·만들기)가 서로의 저장 안 한 것을 덮지 않게 캐시 칸을 가름 */
|
||||
const cacheKey = (easy: boolean): string => (easy ? "m01_logic_drafts_easy" : "m01_logic_drafts");
|
||||
/** 세 컨테이너(옛 호표 화면 · 쉽게 보기·만들기 · 흐름 보기)가 서로의 저장 안 한 것을 덮지 않게 캐시 칸을 가름 */
|
||||
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 clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
||||
|
||||
function loadDrafts(easy: boolean): Record<string, Draft> {
|
||||
function loadDrafts(mode: LogicMode): Record<string, Draft> {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem(cacheKey(easy)) ?? "{}") as Record<string, Draft>;
|
||||
return JSON.parse(sessionStorage.getItem(cacheKey(mode)) ?? "{}") as Record<string, Draft>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
@@ -82,14 +85,19 @@ export async function mountM01Logic(
|
||||
host: HTMLElement,
|
||||
side: SideHandle,
|
||||
openKey?: string,
|
||||
easy = false,
|
||||
mode: LogicMode = "classic",
|
||||
): Promise<LogicHandle> {
|
||||
let drafts = loadDrafts(easy);
|
||||
let drafts = loadDrafts(mode);
|
||||
let files: LogicFile[] = [];
|
||||
let opened: Opened | null = null;
|
||||
let calcInputs = "";
|
||||
/** 옛 컨테이너 = 호표 표 + 시험 계산 칸 · 쉽게 보기·만들기 컨테이너 = 흐름 그림 */
|
||||
const view: "flow" | "advanced" = easy ? "flow" : "advanced";
|
||||
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: "" } });
|
||||
@@ -103,7 +111,7 @@ export async function mountM01Logic(
|
||||
|
||||
const persist = (): void => {
|
||||
try {
|
||||
sessionStorage.setItem(cacheKey(easy), JSON.stringify(drafts));
|
||||
sessionStorage.setItem(cacheKey(mode), JSON.stringify(drafts));
|
||||
} catch {
|
||||
/* 캐시가 막혀도 화면 안의 고친 것은 남음 */
|
||||
}
|
||||
@@ -138,7 +146,7 @@ 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 === "flow") return; // 흐름 그림은 제 안에서 다시 셈 — 다시 그리면 펼친 상자가 접힘
|
||||
if (view !== "advanced") return; // 흐름 그림은 제 안에서 다시 셈 — 다시 그리면 펼친 상자가 접힘
|
||||
const inputs = JSON.stringify(opened.row.입력 ?? []);
|
||||
if (inputs !== calcInputs) drawCalc();
|
||||
};
|
||||
@@ -152,7 +160,7 @@ export async function mountM01Logic(
|
||||
});
|
||||
|
||||
const drawCalc = (): void => {
|
||||
if (!opened?.row || view === "flow") {
|
||||
if (!opened?.row || view !== "advanced") {
|
||||
calc.replaceChildren();
|
||||
return;
|
||||
}
|
||||
@@ -177,7 +185,7 @@ export async function mountM01Logic(
|
||||
list.root.hidden = !listing || !loaded;
|
||||
waiting.hidden = loaded;
|
||||
editor.hidden = back.hidden = listing;
|
||||
calc.hidden = listing || view === "flow";
|
||||
calc.hidden = listing || view !== "advanced";
|
||||
if (!opened) return;
|
||||
if (!opened.row) {
|
||||
editor.replaceChildren(
|
||||
@@ -187,6 +195,10 @@ export async function mountM01Logic(
|
||||
}
|
||||
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: {
|
||||
@@ -274,8 +286,12 @@ export async function mountM01Logic(
|
||||
// 왼쪽 「전체」 + 구분 → 상세구분 거름 — 고르면 목록으로 돌아와 그 범위만
|
||||
filterHost.replaceChildren(
|
||||
side.filter({
|
||||
id: easy ? "쉬움|" : "로직|",
|
||||
store: easy ? "m01.filter.로직쉬움" : "m01.filter.로직",
|
||||
id: easy ? "쉬움|" : mode === "cview" ? "흐름|" : "로직|",
|
||||
store: easy
|
||||
? "m01.filter.로직쉬움"
|
||||
: mode === "cview"
|
||||
? "m01.filter.로직흐름"
|
||||
: "m01.filter.로직",
|
||||
subs,
|
||||
total: list.total(),
|
||||
subLabel: L("M01_LaborSub"),
|
||||
@@ -441,11 +457,20 @@ export async function mountM01Logic(
|
||||
children: [
|
||||
back,
|
||||
el("h2", { text: tx("Title") }),
|
||||
pending,
|
||||
createButton({ label: tx("Bar_New"), variant: "ghost", onClick: onNew }),
|
||||
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
|
||||
discardButton,
|
||||
saveButton,
|
||||
// 흐름 보기 = 읽기 + 시험 계산만 — 만들기·지우기·저장 단추 없음
|
||||
...(mode === "cview"
|
||||
? []
|
||||
: [
|
||||
pending,
|
||||
createButton({ label: tx("Bar_New"), variant: "ghost", onClick: onNew }),
|
||||
createButton({
|
||||
label: tx("Bar_Delete"),
|
||||
variant: "danger",
|
||||
onClick: () => void onDelete(),
|
||||
}),
|
||||
discardButton,
|
||||
saveButton,
|
||||
]),
|
||||
],
|
||||
});
|
||||
host.replaceChildren(
|
||||
@@ -460,7 +485,9 @@ export async function mountM01Logic(
|
||||
],
|
||||
}),
|
||||
);
|
||||
(easy ? side.easyHost : side.logicHost).replaceChildren(filterHost);
|
||||
({ classic: side.logicHost, easy: side.easyHost, cview: side.cviewHost })[mode].replaceChildren(
|
||||
filterHost,
|
||||
);
|
||||
show(null);
|
||||
showLoadingOverlay();
|
||||
const openKeyOn = async (key: string): Promise<void> => {
|
||||
|
||||
@@ -85,6 +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"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
Confirm_Delete: [
|
||||
|
||||
@@ -55,6 +55,7 @@ function buildPage(): HTMLElement {
|
||||
let dispose = (): void => {};
|
||||
let logicMounted = false;
|
||||
let easyMounted = false;
|
||||
let cviewMounted = false;
|
||||
|
||||
/* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */
|
||||
const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") });
|
||||
@@ -71,6 +72,7 @@ function buildPage(): HTMLElement {
|
||||
});
|
||||
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 elementView = el("div", {
|
||||
className: "m01-master__elements",
|
||||
children: [head, notice, body],
|
||||
@@ -88,9 +90,10 @@ function buildPage(): HTMLElement {
|
||||
onDraftChange(refreshBar);
|
||||
refreshBar();
|
||||
|
||||
const showMode = (mode: "elements" | "logic" | "easy"): void => {
|
||||
const showMode = (mode: "elements" | "logic" | "easy" | "cview"): void => {
|
||||
logicHost.hidden = mode !== "logic";
|
||||
easyHost.hidden = mode !== "easy";
|
||||
cviewHost.hidden = mode !== "cview";
|
||||
elementView.hidden = mode !== "elements";
|
||||
};
|
||||
|
||||
@@ -205,12 +208,25 @@ function buildPage(): HTMLElement {
|
||||
if (easyMounted) return;
|
||||
easyMounted = true;
|
||||
void import("./M01_MasterData_UI_Logic_Page").then((m) =>
|
||||
m.mountM01Logic(easyHost, side, undefined, true),
|
||||
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"),
|
||||
);
|
||||
};
|
||||
|
||||
/* --- 좌측: 컨테이너 --- */
|
||||
const side = buildSide(openFile, () => openLogicTab(), openEasyTab);
|
||||
const side = buildSide(openFile, () => openLogicTab(), openEasyTab, openCViewTab);
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m01-master" });
|
||||
const main = el("main", {
|
||||
@@ -218,7 +234,7 @@ function buildPage(): HTMLElement {
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-master__panel",
|
||||
children: [elementView, logicHost, easyHost],
|
||||
children: [elementView, logicHost, easyHost, cviewHost],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface SideHandle {
|
||||
logicHost: HTMLElement;
|
||||
/** 「로직 쉽게 보기·만들기」 컨테이너 안(같은 로직 목록이 들어갈 자리) */
|
||||
easyHost: HTMLElement;
|
||||
cviewHost: HTMLElement;
|
||||
/** 그룹의 파일 판본을 다시 받음(저장 뒤) — 돌려받는 것 = 새 목록 */
|
||||
refresh: (group: string) => Promise<FileInfo[]>;
|
||||
setActive: (id: string | null) => void;
|
||||
@@ -131,11 +132,13 @@ const LEAF: Group[] = ["환율", "요율"];
|
||||
const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"];
|
||||
const LOGIC_ID = "로직|";
|
||||
const EASY_ID = "쉬움|";
|
||||
const CVIEW_ID = "흐름|";
|
||||
|
||||
export function buildSide(
|
||||
onOpen: (pick: Pick) => void,
|
||||
onLogic: () => void,
|
||||
onEasy: () => void,
|
||||
onCView: () => void,
|
||||
): SideHandle {
|
||||
const lists = new Map<Group, FileInfo[]>();
|
||||
const bodies = new Map<Group, HTMLElement>();
|
||||
@@ -419,10 +422,18 @@ export function buildSide(
|
||||
onEasy();
|
||||
});
|
||||
|
||||
const root = el("div", { className: "m01-side", children: [...groups, logic, easy] });
|
||||
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] });
|
||||
attachCollapsible(root);
|
||||
void Promise.all(GROUPS.map(refresh)).catch((error) =>
|
||||
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"),
|
||||
);
|
||||
return { root, logicHost, easyHost, refresh, setActive, filter };
|
||||
return { root, logicHost, easyHost, cviewHost, refresh, setActive, filter };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user