From b0d908c68bd3f25644054c57e24367a54cd58449 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:01:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(m02-cf):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20=C2=B7=20=EC=8B=9C=ED=97=98=ED=8C=90=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=C2=B7=20=EC=8B=9C=EC=9E=91=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=C2=B7=20=EA=B3=A0=EC=B9=98=EB=8A=94=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=ED=8B=80(1=EA=B1=B8=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 4 + M02_MasterTemplete/CostFlow/M02_CF_Entry.css | 155 +++++++++++ M02_MasterTemplete/CostFlow/M02_CF_Entry.ts | 260 +++++++++++++++++++ 3 files changed, 419 insertions(+) create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_Entry.css create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_Entry.ts diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 18f02794c..1daa1b757 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -45,6 +45,8 @@ import { createEasyCalcButton } from "./EasyCalc/M01_EasyCalc_Entry"; import { createLogicBuilderButton } from "./LogicBuilder/M01_LB_Entry"; // 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움 import { createSheetLogicButton } from "./SheetLogic/M01_SL_Entry"; +// 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움 +import { createCostFlowButton } from "../M02_MasterTemplete/CostFlow/M02_CF_Entry"; import "./M01_MasterData_UI_Logic_Style.css"; /** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */ @@ -410,6 +412,8 @@ export async function mountM01LogicLab( createLogicBuilderButton(), // 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움 createSheetLogicButton(), + // 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움 + createCostFlowButton(), createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), discardButton, saveButton, diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Entry.css b/M02_MasterTemplete/CostFlow/M02_CF_Entry.css new file mode 100644 index 000000000..a967d85f7 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_Entry.css @@ -0,0 +1,155 @@ +/* 단가산출 다이어그램 — 화면 전체를 덮는 시험판 페이지 · 페이지 하나만 세로로 밀림(안쪽 스크롤 없음) */ + +.m02-cf { + position: fixed; + inset: 0; + z-index: 1000; /* 공용 모달과 같은 층 — 뒤에 여는 물음 · 고르기 창은 그 위(--z-confirm 1050) */ + overflow-x: hidden; + overflow-y: auto; + background: var(--color-canvas); + color: var(--color-text); + font-size: var(--text-body); +} + +.m02-cf:focus { + outline: none; +} + +.m02-cf__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +/* 위 띠 — 밀어도 위에 붙어 있음 */ +.m02-cf__head { + position: sticky; + top: 0; + z-index: 2; + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-12); + padding: var(--spacing-12) var(--spacing-24); + border-bottom: 1px solid var(--color-border); + background: var(--color-surface-raised); +} + +.m02-cf__title { + margin: 0; + font-size: var(--text-subheading); +} + +.m02-cf__trial { + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-cf__strip { + display: flex; + flex: 1 1 480px; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-12); +} + +.m02-cf__name { + flex: 1 1 260px; +} + +.m02-cf__actions { + display: flex; + gap: var(--spacing-8); + margin-left: auto; +} + +.m02-cf__body { + padding: var(--spacing-16) var(--spacing-24) var(--spacing-24); +} + +.m02-cf__big { + min-height: 56px; + padding: 0 var(--spacing-24); + font-size: var(--text-body); +} + +/* 시작 화면 */ +.m02-cf__start { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: var(--spacing-24); +} + +.m02-cf__start-col { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.m02-cf__start-col h3 { + margin: var(--spacing-8) 0 0; +} + +/* 고치는 화면 — 위 다이어그램(크게) · 아래 고른 상자 판(크게) + 원가 */ +.m02-cf__layout { + display: flex; + flex-direction: column; + gap: var(--spacing-16); +} + +.m02-cf__part { + display: flex; + flex-direction: column; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); +} + +.m02-cf__part h3 { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-bottom: 1px solid var(--color-border); +} + +.m02-cf__part-body { + padding: var(--spacing-12); +} + +.m02-cf__diagram .m02-cf__part-body { + min-height: 60vh; +} + +.m02-cf__lower { + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr); + gap: var(--spacing-16); + align-items: start; +} + +.m02-cf__box .m02-cf__part-body { + min-height: 50vh; +} + +.m02-cf__box-slots { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--spacing-12); +} + +.m02-cf__slot { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.m02-cf__slot h4 { + margin: 0; +} + +@media (max-width: 1100px) { + .m02-cf__lower, + .m02-cf__start, + .m02-cf__box-slots { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts new file mode 100644 index 000000000..c1e8bad94 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts @@ -0,0 +1,260 @@ +/* ============================================================================= + * M02_CF_Entry.ts + * 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 틀 + * 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 10 · 12 · 13 · 이 폴더째 지워도 됨 + * (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). CostFlow 의 다른 파일은 import() 로 늦게. + * 1걸음 = 틀 — 다이어그램 · 고른 상자 판 · 원가 합계 · 저장 · 지우기 · 실행 취소 · 견본은 2걸음에 이음. + * 바깥 페이지 하나만 세로로 밀림(안쪽 스크롤 없음). + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createSelectField, + createTag, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; +import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import type { CfLogic } from "./M02_CF_Model"; +import "./M02_CF_Entry.css"; + +const TEXT = { + Open: ["단가산출 다이어그램(시험)", "Cost diagram (trial)"], + Title: ["단가산출 다이어그램", "Cost diagram"], + Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], + Close: ["닫기", "Close"], + Loading: ["불러오는 중", "Loading"], + Start_New: ["새로 만들기", "New"], + Start_Mine: ["내 로직", "My logics"], + Start_MineNone: ["아직 없음", "None yet"], + Start_Samples: ["견본", "Samples"], + Name: ["로직 이름", "Logic name"], + NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"], + Unit: ["결과 단위", "Result unit"], + Undo: ["실행 취소", "Undo"], + Save: ["저장", "Save"], + Delete: ["지우기", "Delete"], + Unsaved: ["저장 안 함", "Not saved"], + Diagram: ["다이어그램", "Diagram"], + Box: ["고른 상자", "Chosen box"], + Panel_Input: ["입력", "Input"], + Panel_Function: ["함수", "Function"], + Panel_Output: ["출력", "Output"], + Summary: ["원가", "Cost"], + NotYet: ["준비 중", "Coming soon"], + Leave_Ask: [ + "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", + "There are unsaved changes — close without saving?", + ], + Leave_Ok: ["저장 안 하고 닫기", "Close without saving"], +} as const; + +type TextKey = keyof typeof TEXT; + +const te = (key: TextKey): string => + (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string; + +const muted = (text: string): HTMLElement => el("p", { className: "m02-cf__muted", text }); + +/* ponytail: Model(sub7) · Api(sub4) · Sample(sub6) 속이 「준비 중」 인 동안만 대신함 — 채워지면 catch 를 지움 */ +const blankLogic = (name: string, unit: string): Promise => + import("./M02_CF_Model").then((m) => m.newLogic(name, unit)).catch(() => null); + +/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ +async function unitChoices(): Promise { + const count = new Map(); + try { + for (const x of await fetchLogics()) { + if (x.결과단위.startsWith("원/")) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1); + } + } catch { + /* 못 받으면 기본 하나 */ + } + const units = [...count.entries()] + .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) + .sort((a, b) => b[1] - a[1]) + .map(([u]) => u); + return units.length ? units : ["원/㎡"]; +} + +/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */ +export function createCostFlowButton(): HTMLButtonElement { + return createButton({ label: te("Open"), variant: "filled", onClick: openCostFlow }); +} + +function openCostFlow(): void { + /** 고치는 화면이 열려 있을 때만 */ + let paper: { dirty(): boolean } | null = null; + let units: string[] | null = null; + + const mayLeave = async (): Promise => + !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); + + const body = el("main", { className: "m02-cf__body" }); + const strip = el("div", { className: "m02-cf__strip" }); + const close = createButton({ + label: te("Close"), + variant: "ghost", + onClick: () => + void (async () => { + if (await mayLeave()) shut(); + })(), + }); + const page = el("div", { + className: "m02-cf", + attrs: { role: "dialog", "aria-modal": "true", "aria-label": te("Title") }, + children: [ + el("header", { + className: "m02-cf__head", + children: [ + el("h2", { className: "m02-cf__title", text: te("Title") }), + el("span", { className: "m02-cf__trial", text: te("Trial") }), + strip, + close, + ], + }), + body, + ], + }); + const prevOverflow = document.body.style.overflow; + const shut = (): void => { + page.remove(); + document.body.style.overflow = prevOverflow; + }; + page.addEventListener("keydown", (ev) => { + if (ev.key !== "Escape" || ev.defaultPrevented) return; + void (async () => { + if (await mayLeave()) shut(); + })(); + }); + + /* ---------- 시작 화면 ---------- */ + + const showStart = (): void => { + paper = null; + strip.replaceChildren(); + body.replaceChildren( + el("div", { + className: "m02-cf__start", + children: [ + el("section", { + className: "m02-cf__start-col", + children: [ + bigButton(te("Start_New"), () => void openNew()), + el("h3", { text: te("Start_Mine") }), + muted(te("Start_MineNone")), + ], + }), + el("section", { + className: "m02-cf__start-col", + children: [el("h3", { text: te("Start_Samples") }), muted(te("NotYet"))], + }), + ], + }), + ); + }; + + /* ---------- 고치는 화면 ---------- */ + + const openNew = async (): Promise => { + units ??= await unitChoices(); + const lg = await blankLogic("", units[0]); + showPaper(lg, units); + }; + + const showPaper = (lg: CfLogic | null, choices: string[]): void => { + let dirty = false; + paper = { dirty: () => dirty }; + const unsaved = createTag(te("Unsaved"), "warning"); + unsaved.hidden = true; + const touched = (): void => { + dirty = true; + unsaved.hidden = false; + }; + const name = createInputField({ + label: te("Name"), + placeholder: te("NamePlaceholder"), + value: lg?.name ?? "", + onInput: touched, + }); + name.root.classList.add("m02-cf__name"); + const unit = createSelectField({ + label: te("Unit"), + options: choices.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), + value: lg?.resultUnit ?? choices[0], + onChange: touched, + }); + const notYet = (): void => showToast(te("NotYet"), "info"); + const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); + undo.disabled = true; + strip.replaceChildren( + name.root, + unit.root, + unsaved, + el("div", { + className: "m02-cf__actions", + children: [ + undo, + createButton({ label: te("Save"), onClick: notYet }), + createButton({ label: te("Delete"), variant: "danger", onClick: notYet }), + ], + }), + ); + body.replaceChildren( + el("div", { + className: "m02-cf__layout", + children: [ + part(te("Diagram"), "m02-cf__diagram", [muted(te("NotYet"))]), + el("div", { + className: "m02-cf__lower", + children: [ + part(te("Box"), "m02-cf__box", [ + el("div", { + className: "m02-cf__box-slots", + children: [ + slot(te("Panel_Input")), + slot(te("Panel_Function")), + slot(te("Panel_Output")), + ], + }), + ]), + part(te("Summary"), "m02-cf__summary", [muted(te("NotYet"))]), + ], + }), + ], + }), + ); + name.input.focus(); + }; + + showStart(); + document.body.style.overflow = "hidden"; // 뒤 페이지는 안 밀리고 시험판 페이지만 밀림 + document.body.append(page); + page.tabIndex = -1; + page.focus(); +} + +/** 고치는 화면의 큰 자리 — 제목 + 몸(안쪽 스크롤 없음 · 내용만큼 늘어남) */ +function part(title: string, className: string, children: HTMLElement[]): HTMLElement { + return el("section", { + className: `m02-cf__part ${className}`, + children: [el("h3", { text: title }), el("div", { className: "m02-cf__part-body", children })], + }); +} + +/** 고른 상자 판의 종류별 자리 — 2걸음에 입력 · 함수 · 출력 판이 들어옴 */ +function slot(title: string): HTMLElement { + return el("div", { + className: "m02-cf__slot", + children: [el("h4", { text: title }), muted(te("NotYet"))], + }); +} + +function bigButton(label: string, onClick: () => void): HTMLButtonElement { + const button = createButton({ label, variant: "filled", onClick }); + button.classList.add("m02-cf__big"); + return button; +}