feat(m02-cf): 단가산출 다이어그램 단추 · 시험판 페이지 · 시작 화면 · 고치는 화면 틀(1걸음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<CfLogic | null> =>
|
||||
import("./M02_CF_Model").then((m) => m.newLogic(name, unit)).catch(() => null);
|
||||
|
||||
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
|
||||
async function unitChoices(): Promise<string[]> {
|
||||
const count = new Map<string, number>();
|
||||
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<boolean> =>
|
||||
!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<void> => {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user