feat(m01-sl): 시트 로직 단추 · 큰 창 · 시작 화면 · 고치는 화면 틀 · 위 띠(1걸음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, string | number> = {}): 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<boolean> =>
|
||||
!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<void> => {
|
||||
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<string, number>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user