feat(m01-easycalc): 쉬운 단가산출 큰 창 틀 · 시작 화면 · 고치는 화면 위 띠(1걸음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
@@ -1,33 +1,400 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Entry.ts
|
||||
* 쉬운 단가산출 시험 길 — 들어가는 단추 + 빈 창(틀만). 이 폴더째 지워도 됨.
|
||||
* 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면 틀(위 띠 · 자리들)
|
||||
* 모양은 `docs/raw/M01_쉬운단가/설계.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄).
|
||||
* 1걸음 = 틀만 — 카드 · 설계 때 정하는 값 · 실무 일위대가표 · 저장 · 지우기 · 되돌리기는 2걸음에 이음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
createTag,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
fetchLogic,
|
||||
fetchLogics,
|
||||
type LogicRow,
|
||||
type LogicSummary,
|
||||
} from "../M01_MasterData_UI_Logic_Api";
|
||||
import "./M01_EasyCalc_Style.css";
|
||||
|
||||
const TEXT = {
|
||||
Open: ["쉬운 단가산출(시험)", "Easy unit-price (trial)"],
|
||||
Title: ["쉬운 단가산출", "Easy unit-price"],
|
||||
Ready: ["준비 중", "Coming soon"],
|
||||
CloseWindow: ["창 닫기", "Close window"],
|
||||
Close: ["닫기", "Close"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"],
|
||||
Start_Ask: ["무엇과 비슷한가요?", "What is it similar to?"],
|
||||
Start_Search: ["이름으로 찾기 — 예: 거푸집", "Search by name — e.g. formwork"],
|
||||
Start_Blank: ["빈 종이로 새로 만들기", "Start from a blank sheet"],
|
||||
Start_Mine: ["내가 만든 것", "Made by me"],
|
||||
Start_MineNone: ["아직 없음", "None yet"],
|
||||
Start_Found: ["찾은 것", "Found"],
|
||||
Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"],
|
||||
Start_None: ["찾은 것 없음", "Nothing found"],
|
||||
Base: ["품셈 기본", "Standard"],
|
||||
Mine: ["내가 만든 것", "Made by me"],
|
||||
CannotOpen: ["쉬운 길로 못 엶", "Not for the easy view"],
|
||||
Per: ["{u} 당", "per {u}"],
|
||||
Name: ["이름", "Name"],
|
||||
NamePlaceholder: ["예: 거푸집 설치", "e.g. formwork setup"],
|
||||
Spec: ["크기", "Size"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Total: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"],
|
||||
Undo: ["되돌리기", "Undo"],
|
||||
Save: ["저장", "Save"],
|
||||
Delete: ["지우기", "Delete"],
|
||||
Unsaved: ["저장 안 함", "Not saved"],
|
||||
BaseNote: [
|
||||
"품셈 기본은 읽기만 — 고치려면 아래 단추로 내 것을 만듦",
|
||||
"Standard items are read-only — use the button below to make your own copy",
|
||||
],
|
||||
BaseCopy: ["이걸 바탕으로 고치기", "Edit a copy of this"],
|
||||
Trial: ["설계 때 정하는 값", "Values set at design time"],
|
||||
Cards: ["무엇이 들어가나", "What goes in"],
|
||||
Sheet: ["실무 일위대가표로 보기", "View as a unit-cost sheet"],
|
||||
NotYet: ["준비 중", "Coming soon"],
|
||||
Leave_Ask: [
|
||||
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
|
||||
"There are unsaved changes — close without saving?",
|
||||
],
|
||||
Leave_Ok: ["저장 안 하고 닫기", "Close without saving"],
|
||||
} as const;
|
||||
|
||||
const te = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||||
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,
|
||||
);
|
||||
|
||||
/** 목록에 한 번에 보이는 줄 수 */
|
||||
const SHOWN = 50;
|
||||
|
||||
/** 고치는 화면이 쥔 것 — 2걸음에 Model 의 EasyDoc 으로 바꿈 */
|
||||
interface Paper {
|
||||
key: string | null;
|
||||
version: string | null;
|
||||
isBase: boolean;
|
||||
name: string;
|
||||
spec: string;
|
||||
resultUnit: string;
|
||||
row: LogicRow | null;
|
||||
}
|
||||
|
||||
const isMine = (key: string): boolean => key.startsWith("GX");
|
||||
/** 돈이 아닌 결과(작업량 등) — 쉬운 길은 「원/…」 만 엶 */
|
||||
const canOpen = (item: LogicSummary): boolean => item.결과단위.startsWith("원/");
|
||||
/** 「원/㎡」 → 「㎡」 */
|
||||
const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, "");
|
||||
/** 「㎡」 → 「1㎡」 · 「100m」 은 그대로 */
|
||||
const amountOf = (resultUnit: string): string => {
|
||||
const unit = unitOf(resultUnit);
|
||||
return /^\d/.test(unit) ? unit : `1${unit}`;
|
||||
};
|
||||
|
||||
/** 「추가」 옆에 놓는 단추 — 누르면 큰 창 */
|
||||
export function createEasyCalcButton(): HTMLButtonElement {
|
||||
return createButton({
|
||||
label: te("Open"),
|
||||
variant: "filled",
|
||||
onClick: () =>
|
||||
openModal({
|
||||
title: te("Title"),
|
||||
closeLabel: te("Close"),
|
||||
mount: (body) =>
|
||||
body.append(el("p", { className: "m01-easycalc__ready", text: te("Ready") })),
|
||||
return createButton({ label: te("Open"), variant: "filled", onClick: openEasyCalc });
|
||||
}
|
||||
|
||||
function openEasyCalc(): void {
|
||||
let body: HTMLElement;
|
||||
let paper: Paper | null = null;
|
||||
let dirty = false;
|
||||
let logics: LogicSummary[] | null = null;
|
||||
let query = "";
|
||||
|
||||
/** 저장 안 한 것이 있으면 한 번 물음 — 닫아도 되면 true */
|
||||
const mayLeave = async (): Promise<boolean> =>
|
||||
!paper || !dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
|
||||
|
||||
const notYet = (): void => showToast(te("NotYet"), "info");
|
||||
|
||||
/* ---------- 시작 화면 ---------- */
|
||||
|
||||
const listRow = (item: LogicSummary): HTMLElement => {
|
||||
const ok = canOpen(item);
|
||||
const row = el("button", {
|
||||
className: "m01-easycalc__item",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("span", { className: "m01-easycalc__item-name", text: item.이름 }),
|
||||
createTag(
|
||||
isMine(item.키) ? te("Mine") : te("Base"),
|
||||
isMine(item.키) ? "accent" : "neutral",
|
||||
),
|
||||
el("span", {
|
||||
className: "m01-easycalc__item-unit",
|
||||
text: ok ? te("Per", { u: amountOf(item.결과단위) }) : item.결과단위,
|
||||
}),
|
||||
...(ok ? [] : [createTag(te("CannotOpen"), "warning")]),
|
||||
],
|
||||
});
|
||||
row.disabled = !ok;
|
||||
row.addEventListener("click", () => void openPaper(item));
|
||||
return row;
|
||||
};
|
||||
|
||||
const showStart = async (): Promise<void> => {
|
||||
paper = null;
|
||||
dirty = false;
|
||||
const status = el("p", { className: "m01-easycalc__muted", text: te("Loading") });
|
||||
const mineHost = el("div", { className: "m01-easycalc__list" });
|
||||
const foundHost = el("div", { className: "m01-easycalc__list" });
|
||||
const search = createInputField({
|
||||
type: "search",
|
||||
placeholder: te("Start_Search"),
|
||||
value: query,
|
||||
onInput: (v) => {
|
||||
query = v;
|
||||
drawFound();
|
||||
},
|
||||
});
|
||||
search.root.classList.add("m01-easycalc__search");
|
||||
|
||||
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.이름.toLowerCase().includes(w)))
|
||||
: logics;
|
||||
status.textContent = !hits.length
|
||||
? te("Start_None")
|
||||
: hits.length > SHOWN
|
||||
? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN })
|
||||
: "";
|
||||
foundHost.replaceChildren(...hits.slice(0, SHOWN).map(listRow));
|
||||
};
|
||||
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-easycalc__start",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-easycalc__start-col",
|
||||
children: [
|
||||
el("h3", { className: "m01-easycalc__ask", text: te("Start_Ask") }),
|
||||
search.root,
|
||||
el("h4", { text: te("Start_Found") }),
|
||||
status,
|
||||
foundHost,
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-easycalc__start-col m01-easycalc__start-side",
|
||||
children: [
|
||||
createBigButton(te("Start_Blank"), () => openBlank()),
|
||||
el("h4", { text: te("Start_Mine") }),
|
||||
mineHost,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
search.input.focus();
|
||||
|
||||
try {
|
||||
logics ??= await fetchLogics();
|
||||
} catch (error) {
|
||||
status.textContent = te("LoadFailed", {
|
||||
v: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const mine = logics.filter((x) => isMine(x.키));
|
||||
mineHost.replaceChildren(
|
||||
...(mine.length
|
||||
? mine.map(listRow)
|
||||
: [el("p", { className: "m01-easycalc__muted", text: te("Start_MineNone") })]),
|
||||
);
|
||||
drawFound();
|
||||
};
|
||||
|
||||
/* ---------- 고치는 화면 ---------- */
|
||||
|
||||
const openBlank = (): void => {
|
||||
paper = {
|
||||
key: null,
|
||||
version: null,
|
||||
isBase: false,
|
||||
name: "",
|
||||
spec: "",
|
||||
resultUnit: unitChoices()[0] ?? "원/㎡",
|
||||
row: null,
|
||||
};
|
||||
showPaper();
|
||||
};
|
||||
|
||||
const openPaper = async (item: LogicSummary): Promise<void> => {
|
||||
body.replaceChildren(el("p", { className: "m01-easycalc__muted", text: te("Loading") }));
|
||||
try {
|
||||
const one = await fetchLogic(item.키);
|
||||
paper = {
|
||||
key: one.logic.키,
|
||||
version: one.version,
|
||||
isBase: !isMine(one.logic.키),
|
||||
name: one.logic.이름,
|
||||
spec: "",
|
||||
resultUnit: one.logic.결과단위,
|
||||
row: one.logic,
|
||||
};
|
||||
showPaper();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : String(error), "error");
|
||||
void showStart();
|
||||
}
|
||||
};
|
||||
|
||||
/** 단위 고르기 후보 — 목록에 많이 쓰인 「원/…」 차례 */
|
||||
const unitChoices = (): string[] => {
|
||||
const count = new Map<string, number>();
|
||||
for (const x of logics ?? []) {
|
||||
if (canOpen(x)) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1);
|
||||
}
|
||||
return [...count.entries()].sort((a, b) => b[1] - a[1]).map(([u]) => u);
|
||||
};
|
||||
|
||||
const showPaper = (): void => {
|
||||
const p = paper;
|
||||
if (!p) return;
|
||||
const readOnly = p.isBase;
|
||||
const unsaved = createTag(te("Unsaved"), "warning");
|
||||
unsaved.hidden = true;
|
||||
const total = el("div", { className: "m01-easycalc__total" });
|
||||
const drawTotal = (): void => {
|
||||
total.textContent = te("Total", { u: amountOf(p.resultUnit), v: "—" });
|
||||
};
|
||||
const touched = (): void => {
|
||||
dirty = true;
|
||||
unsaved.hidden = false;
|
||||
};
|
||||
|
||||
const name = createInputField({
|
||||
label: te("Name"),
|
||||
placeholder: te("NamePlaceholder"),
|
||||
value: p.name,
|
||||
onInput: (v) => {
|
||||
p.name = v;
|
||||
touched();
|
||||
},
|
||||
});
|
||||
const spec = createInputField({
|
||||
label: te("Spec"),
|
||||
value: p.spec,
|
||||
onInput: (v) => {
|
||||
p.spec = v;
|
||||
touched();
|
||||
},
|
||||
});
|
||||
const units = unitChoices();
|
||||
if (!units.includes(p.resultUnit)) units.unshift(p.resultUnit);
|
||||
const unit = createSelectField({
|
||||
label: te("Unit"),
|
||||
options: units.map((u) => ({ value: u, text: unitOf(u) })),
|
||||
value: p.resultUnit,
|
||||
disabled: readOnly,
|
||||
onChange: (v) => {
|
||||
p.resultUnit = v;
|
||||
drawTotal();
|
||||
touched();
|
||||
},
|
||||
});
|
||||
name.input.disabled = readOnly;
|
||||
spec.input.disabled = readOnly;
|
||||
name.root.classList.add("m01-easycalc__name");
|
||||
|
||||
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();
|
||||
})(),
|
||||
});
|
||||
const actions = readOnly
|
||||
? [close]
|
||||
: [
|
||||
undo,
|
||||
createButton({ label: te("Save"), onClick: notYet }),
|
||||
...(p.key && isMine(p.key)
|
||||
? [createButton({ label: te("Delete"), variant: "danger", onClick: notYet })]
|
||||
: []),
|
||||
close,
|
||||
];
|
||||
|
||||
const section = (title: string, extra = ""): HTMLElement =>
|
||||
el("section", {
|
||||
className: `m01-easycalc__part ${extra}`.trim(),
|
||||
children: [
|
||||
el("h4", { text: title }),
|
||||
el("p", { className: "m01-easycalc__muted", text: te("NotYet") }),
|
||||
],
|
||||
});
|
||||
const sheet = el("details", {
|
||||
className: "m01-easycalc__part m01-easycalc__sheet",
|
||||
children: [
|
||||
el("summary", { text: te("Sheet") }),
|
||||
el("p", { className: "m01-easycalc__muted", text: te("NotYet") }),
|
||||
],
|
||||
});
|
||||
|
||||
drawTotal();
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-easycalc__strip",
|
||||
children: [
|
||||
name.root,
|
||||
spec.root,
|
||||
unit.root,
|
||||
total,
|
||||
unsaved,
|
||||
el("div", { className: "m01-easycalc__actions", children: actions }),
|
||||
],
|
||||
}),
|
||||
...(readOnly
|
||||
? [
|
||||
el("div", {
|
||||
className: "m01-easycalc__base",
|
||||
children: [
|
||||
el("p", { text: te("BaseNote") }),
|
||||
createBigButton(te("BaseCopy"), notYet),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
section(te("Trial"), "m01-easycalc__trial"),
|
||||
section(te("Cards"), "m01-easycalc__cards"),
|
||||
sheet,
|
||||
);
|
||||
if (!readOnly) name.input.focus();
|
||||
};
|
||||
|
||||
// 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름
|
||||
openModal({
|
||||
title: te("Title"),
|
||||
closeLabel: te("CloseWindow"),
|
||||
dialogClass: "m01-easycalc",
|
||||
beforeClose: mayLeave,
|
||||
mount: (host) => {
|
||||
body = host;
|
||||
void showStart();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createBigButton(label: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = createButton({ label, variant: "filled", onClick });
|
||||
button.classList.add("m01-easycalc__big");
|
||||
return button;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,159 @@
|
||||
.m01-easycalc__ready {
|
||||
/* 쉬운 단가산출 — 큰 창 틀 · 시작 화면 · 위 띠 */
|
||||
|
||||
/* 공용 모달(위 고정)을 화면 대부분으로 — 높이를 고정해 화면이 바뀌어도 창이 안 움직임 */
|
||||
.ui-modal.m01-easycalc {
|
||||
--modal-max: 90vh;
|
||||
width: min(1280px, 96vw);
|
||||
height: var(--modal-max);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-easycalc .ui-modal__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-easycalc__muted {
|
||||
margin: 0;
|
||||
padding: var(--spacing-16) 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easycalc__big {
|
||||
min-height: 56px;
|
||||
padding: 0 var(--spacing-24);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
/* 시작 화면 — 왼쪽 찾기 · 오른쪽 새로 만들기 + 내가 만든 것 */
|
||||
.m01-easycalc__start {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
gap: var(--spacing-24);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__start-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__start-col h4 {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__ask {
|
||||
margin: 0;
|
||||
font-size: var(--text-subheading);
|
||||
}
|
||||
|
||||
.m01-easycalc__search .ui-input {
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-easycalc__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-easycalc__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-easycalc__item:hover:not(:disabled) {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.m01-easycalc__item:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.m01-easycalc__item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__item-unit {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 고치는 화면 — 위 띠 */
|
||||
.m01-easycalc__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-easycalc__strip .ui-field {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.m01-easycalc__name {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.m01-easycalc__total {
|
||||
font-size: var(--text-subheading);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-easycalc__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.m01-easycalc__base {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-easycalc__base p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__part {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.m01-easycalc__part h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__cards {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.m01-easycalc__sheet summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user