diff --git a/M01_MasterData/M01_MasterData_UI_Logic_CView.ts b/M01_MasterData/M01_MasterData_UI_Logic_CView.ts new file mode 100644 index 00000000..a7faf6ff --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_CView.ts @@ -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; + +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 = {}; + // 고르기 칸은 첫 값으로 시작 — 설계자가 바로 흐름을 보게(수 칸은 비워 둠) + 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(); + const subs = new Map(); + 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 = {}; + 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, + 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") })]), + ], + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_CView_Model.ts b/M01_MasterData/M01_MasterData_UI_Logic_CView_Model.ts new file mode 100644 index 00000000..7e5f321c --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_CView_Model.ts @@ -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)[name]; + +export function buildFlow( + row: LogicRow, + answer: CalcAnswer | null, + prices: Record, + values: Record, +): 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): 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): 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, +): 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 | 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 | 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.출처], + ]), + }, + ], + }; +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_CView_Style.css b/M01_MasterData/M01_MasterData_UI_Logic_CView_Style.css new file mode 100644 index 00000000..e0a468ce --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_CView_Style.css @@ -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); +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Page.ts b/M01_MasterData/M01_MasterData_UI_Logic_Page.ts index f51f9ab6..6768a0c7 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Page.ts @@ -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; } -/** 두 컨테이너(옛 호표 화면 · 쉽게 보기·만들기)가 서로의 저장 안 한 것을 덮지 않게 캐시 칸을 가름 */ -const cacheKey = (easy: boolean): string => (easy ? "m01_logic_drafts_easy" : "m01_logic_drafts"); +/** 세 컨테이너(옛 호표 화면 · 쉽게 보기·만들기 · 흐름 보기)가 서로의 저장 안 한 것을 덮지 않게 캐시 칸을 가름 */ +export type LogicMode = "classic" | "easy" | "cview"; +const SUFFIX: Record = { classic: "", easy: "_easy", cview: "_cview" }; +const cacheKey = (mode: LogicMode): string => `m01_logic_drafts${SUFFIX[mode]}`; const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; -function loadDrafts(easy: boolean): Record { +function loadDrafts(mode: LogicMode): Record { try { - return JSON.parse(sessionStorage.getItem(cacheKey(easy)) ?? "{}") as Record; + return JSON.parse(sessionStorage.getItem(cacheKey(mode)) ?? "{}") as Record; } catch { return {}; } @@ -82,14 +85,19 @@ export async function mountM01Logic( host: HTMLElement, side: SideHandle, openKey?: string, - easy = false, + mode: LogicMode = "classic", ): Promise { - 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 => { diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts index 8f4f05f2..36c4ca15 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts @@ -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: [ diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index f47ca4ef..82e6fca0 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -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], }), ], }); diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index 8b7c0e95..b8468840 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -77,6 +77,7 @@ export interface SideHandle { logicHost: HTMLElement; /** 「로직 쉽게 보기·만들기」 컨테이너 안(같은 로직 목록이 들어갈 자리) */ easyHost: HTMLElement; + cviewHost: HTMLElement; /** 그룹의 파일 판본을 다시 받음(저장 뒤) — 돌려받는 것 = 새 목록 */ refresh: (group: string) => Promise; 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(); const bodies = new Map(); @@ -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 }; }