diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 934988e52..18f02794c 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -43,6 +43,8 @@ import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; import { createEasyCalcButton } from "./EasyCalc/M01_EasyCalc_Entry"; // 로직 만들기 시험 길 — 폴더째 지우면 이 두 줄도 지움 import { createLogicBuilderButton } from "./LogicBuilder/M01_LB_Entry"; +// 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움 +import { createSheetLogicButton } from "./SheetLogic/M01_SL_Entry"; import "./M01_MasterData_UI_Logic_Style.css"; /** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */ @@ -406,6 +408,8 @@ export async function mountM01LogicLab( createEasyCalcButton(), // 로직 만들기 시험 길 — 폴더째 지우면 이 두 줄도 지움 createLogicBuilderButton(), + // 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움 + createSheetLogicButton(), createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), discardButton, saveButton, diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry.css b/M01_MasterData/SheetLogic/M01_SL_Entry.css new file mode 100644 index 000000000..4849660f5 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Entry.css @@ -0,0 +1,169 @@ +/* 시트 로직 — 큰 창 · 시작 화면 · 위 띠 · 다이어그램 / 시트 판 / 결과 자리 */ + +/* 공용 모달(위 고정)을 화면 대부분으로 — 높이 고정 */ +.ui-modal.m01-sl { + --modal-max: 92vh; + width: min(1680px, 98vw); + height: var(--modal-max); + font-size: var(--text-body); +} + +.m01-sl .ui-modal__body { + flex: 1; + min-height: 0; + gap: var(--spacing-12); +} + +.m01-sl__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m01-sl__trial-note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01-sl__big { + min-height: 56px; + padding: 0 var(--spacing-24); + font-size: var(--text-body); +} + +/* 시작 화면 — 왼쪽 새로 · 내 것 · 오른쪽 가져오기 */ +.m01-sl__start { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + gap: var(--spacing-24); + min-height: 0; + overflow: auto; +} + +.m01-sl__start-col { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.m01-sl__start-col h4, +.m01-sl__start-col h5 { + margin: var(--spacing-8) 0 0; +} + +.m01-sl__list { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m01-sl__item { + display: flex; + align-items: center; + gap: var(--spacing-8); + width: 100%; + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.m01-sl__item:hover { + border-color: var(--color-accent); +} + +.m01-sl__item-name { + flex: 1; + min-width: 0; +} + +.m01-sl__item-unit { + color: var(--color-text-muted); + font-size: var(--text-body-sm); + white-space: nowrap; +} + +/* 위 띠 */ +.m01-sl__strip { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-12); + padding-bottom: var(--spacing-12); + border-bottom: 1px solid var(--color-border); +} + +.m01-sl__name { + flex: 1 1 280px; +} + +.m01-sl__actions { + display: flex; + gap: var(--spacing-8); + margin-left: auto; +} + +/* 위 가로 다이어그램 · 아래 왼쪽 시트 판 · 아래 오른쪽 결과 */ +.m01-sl__layout { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + grid-template-rows: minmax(160px, 2fr) minmax(0, 3fr); + grid-template-areas: + "diagram diagram" + "panel result"; + gap: var(--spacing-12); +} + +.m01-sl__diagram { + grid-area: diagram; +} + +.m01-sl__panel { + grid-area: panel; +} + +.m01-sl__result { + grid-area: result; +} + +.m01-sl__part { + display: flex; + flex-direction: column; + min-height: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.m01-sl__part h4 { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-bottom: 1px solid var(--color-border); +} + +.m01-sl__part-body { + flex: 1; + min-height: 0; + overflow: auto; + padding: var(--spacing-12); +} + +/* 좁은 화면 — 자리를 위아래로 */ +@media (max-width: 1100px) { + .m01-sl__layout { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto; + grid-template-areas: + "diagram" + "panel" + "result"; + overflow: auto; + } +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry.ts b/M01_MasterData/SheetLogic/M01_SL_Entry.ts new file mode 100644 index 000000000..7078a7216 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Entry.ts @@ -0,0 +1,303 @@ +/* ============================================================================= + * M01_SL_Entry.ts + * 시트 로직(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 고치는 화면 틀(위 다이어그램 · 아래 시트 판 · 결과) · 위 띠 + * 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄 + 서버 등록 한 줄). + * 1걸음 = 틀 — 다이어그램 · 시트 판 · 결과 · 저장 · 지우기 · 실행 취소 · 가져오기는 2걸음에 이음. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createSelectField, + createTag, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { listEasy, plainError, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api"; +import "./M01_SL_Entry.css"; + +const TEXT = { + Open: ["시트 로직(시험)", "Sheet logic (trial)"], + Title: ["시트 로직", "Sheet logic"], + CloseWindow: ["창 닫기", "Close window"], + Close: ["닫기", "Close"], + Loading: ["불러오는 중", "Loading"], + LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"], + Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], + Start_Mine: ["내 시트 로직", "My sheet logics"], + Start_MineNone: ["아직 없음", "None yet"], + Start_New: ["새로 만들기(빈 판)", "New (blank)"], + Start_Import: ["기존 로직 가져오기", "Import an existing logic"], + Start_Trial: ["먼저 볼 시험 로직", "Trial logics to start with"], + Start_Search: ["이름으로 찾기 — 예: 돌쌓기", "Search by name — e.g. stone wall"], + Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"], + Start_None: ["찾은 것 없음", "Nothing found"], + Per: ["{u} 당", "per {u}"], + Name: ["이름", "Name"], + NamePlaceholder: ["예: 돌쌓기 내 방식", "e.g. my stone wall"], + Unit: ["결과 단위", "Result unit"], + Undo: ["실행 취소", "Undo"], + Save: ["저장", "Save"], + Delete: ["지우기", "Delete"], + Unsaved: ["저장 안 함", "Not saved"], + Diagram: ["상자와 선", "Boxes and lines"], + Panel: ["고른 상자의 시트", "Sheet of the chosen box"], + Result: ["결과", "Result"], + 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, vars: Record = {}): string => + Object.entries(vars).reduce( + (text, [k, v]) => text.replace(`{${k}}`, String(v)), + (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, + ); + +/** 「기존 로직 가져오기」 에 먼저 보이는 시험 로직 여섯(정함 9) */ +const TRIAL_KEYS = ["GF000208", "GF000219", "GF000025", "GF000249", "GF000133", "GC000051"]; +/** 찾은 것 목록에 한 번에 보이는 줄 수 */ +const SHOWN = 50; + +/** 「원/㎡」 → 「㎡」 · 「100㎡」 은 그대로 앞에 1 을 안 붙임 */ +const amountOf = (resultUnit: string): string => { + const unit = resultUnit.replace(/^원\//, ""); + return /^\d/.test(unit) ? unit : `1${unit}`; +}; +const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/"); + +/** 「로직 만들기(시험)」 옆에 놓는 단추 — 누르면 큰 창 */ +export function createSheetLogicButton(): HTMLButtonElement { + return createButton({ label: te("Open"), variant: "filled", onClick: openSheetLogic }); +} + +function openSheetLogic(): void { + let body: HTMLElement; + /** 고치는 화면이 열려 있을 때만 */ + let paper: { dirty(): boolean } | null = null; + let logics: EasyListItem[] | null = null; + let query = ""; + + const mayLeave = async (): Promise => + !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); + const notYet = (): void => showToast(te("NotYet"), "info"); + + /* ---------- 시작 화면 ---------- */ + + const importRow = (item: EasyListItem): HTMLElement => { + const row = el("button", { + className: "m01-sl__item", + attrs: { type: "button", title: item.where }, + children: [ + el("span", { className: "m01-sl__item-name", text: item.name }), + el("span", { + className: "m01-sl__item-unit", + text: isMoney(item.resultUnit) + ? te("Per", { u: amountOf(item.resultUnit) }) + : item.resultUnit, + }), + ], + }); + row.addEventListener("click", notYet); + return row; + }; + + const showStart = async (): Promise => { + paper = null; + const status = el("p", { className: "m01-sl__muted", text: te("Loading") }); + const trialHost = el("div", { className: "m01-sl__list" }); + const foundHost = el("div", { className: "m01-sl__list" }); + const mineHost = el("div", { + className: "m01-sl__list", + children: [muted(te("Start_MineNone"))], + }); + const search = createInputField({ + type: "search", + placeholder: te("Start_Search"), + value: query, + onInput: (v) => { + query = v; + drawFound(); + }, + }); + + const drawFound = (): void => { + if (!logics) return; + const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); + const hits = words.length + ? logics.filter((x) => words.every((w) => x.name.toLowerCase().includes(w))) + : []; + status.textContent = !words.length + ? "" + : !hits.length + ? te("Start_None") + : hits.length > SHOWN + ? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN }) + : ""; + foundHost.replaceChildren(...hits.slice(0, SHOWN).map(importRow)); + }; + + body.replaceChildren( + el("p", { className: "m01-sl__trial-note", text: te("Trial") }), + el("div", { + className: "m01-sl__start", + children: [ + el("div", { + className: "m01-sl__start-col", + children: [ + bigButton(te("Start_New"), () => showPaper()), + el("h4", { text: te("Start_Mine") }), + mineHost, + ], + }), + el("div", { + className: "m01-sl__start-col", + children: [ + el("h4", { text: te("Start_Import") }), + el("h5", { text: te("Start_Trial") }), + trialHost, + search.root, + status, + foundHost, + ], + }), + ], + }), + ); + + try { + logics ??= await listEasy(""); + } catch (error) { + status.textContent = te("LoadFailed", { v: plainError(error) }); + return; + } + const byKey = new Map(logics.map((x) => [x.key, x])); + trialHost.replaceChildren( + ...TRIAL_KEYS.flatMap((k) => { + const item = byKey.get(k); + return item ? [importRow(item)] : []; + }), + ); + status.textContent = ""; + drawFound(); + }; + + /** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ + const unitChoices = (): string[] => { + const count = new Map(); + for (const x of logics ?? []) { + if (isMoney(x.resultUnit)) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1); + } + 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 : ["원/㎡"]; + }; + + /* ---------- 고치는 화면 ---------- */ + + const showPaper = (): 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"), + onInput: touched, + }); + name.root.classList.add("m01-sl__name"); + const units = unitChoices(); + const unit = createSelectField({ + label: te("Unit"), + options: units.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), + value: units[0], + onChange: touched, + }); + const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); + undo.disabled = true; + const close = createButton({ + label: te("Close"), + variant: "ghost", + onClick: () => + void (async () => { + if (await mayLeave()) void showStart(); + })(), + }); + + body.replaceChildren( + el("div", { + className: "m01-sl__strip", + children: [ + name.root, + unit.root, + unsaved, + el("div", { + className: "m01-sl__actions", + children: [ + undo, + createButton({ label: te("Save"), onClick: notYet }), + createButton({ label: te("Delete"), variant: "danger", onClick: notYet }), + close, + ], + }), + ], + }), + el("p", { className: "m01-sl__trial-note", text: te("Trial") }), + el("div", { + className: "m01-sl__layout", + children: [ + part(te("Diagram"), "m01-sl__diagram"), + part(te("Panel"), "m01-sl__panel"), + part(te("Result"), "m01-sl__result"), + ], + }), + ); + name.input.focus(); + }; + + // 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름 + openModal({ + title: te("Title"), + closeLabel: te("CloseWindow"), + dialogClass: "m01-sl", + beforeClose: mayLeave, + mount: (host) => { + body = host; + void showStart(); + }, + }); +} + +const muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text }); + +/** 고치는 화면의 자리 하나 — 제목 + 몸(2걸음에 다이어그램 · 시트 판 · 결과가 들어옴) */ +function part(title: string, className: string): HTMLElement { + return el("section", { + className: `m01-sl__part ${className}`, + children: [ + el("h4", { text: title }), + el("div", { className: "m01-sl__part-body", children: [muted(te("NotYet"))] }), + ], + }); +} + +function bigButton(label: string, onClick: () => void): HTMLButtonElement { + const button = createButton({ label, variant: "filled", onClick }); + button.classList.add("m01-sl__big"); + return button; +}