feat(m02-cf): 단가산출 다이어그램 잇기 — 열기 · 견본 복사 · 입력 값 · 다이어그램 · 함수/출력 판 · 원가 · 저장(서버 대조) · 지우기 · 실행 취소
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
@@ -115,10 +115,6 @@
|
||||
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);
|
||||
@@ -189,3 +185,14 @@
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-cf__note {
|
||||
margin: 0 0 var(--spacing-12);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.m02-cf__note:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* =============================================================================
|
||||
* M02_CF_Entry.ts
|
||||
* 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 틀
|
||||
* 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기
|
||||
* 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 10 · 12 · 13 · 이 폴더째 지워도 됨
|
||||
* (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). CostFlow 의 다른 파일은 import() 로 늦게.
|
||||
* 1걸음 = 틀 — 다이어그램 · 고른 상자 판 · 원가 합계 · 저장 · 지우기 · 실행 취소 · 견본은 2걸음에 이음.
|
||||
* 잇기: 열기(cfGet · 견본 sampleLoad) → 입력 값(cfValues) → renderDiagram · 고른 상자 판(종류별) · renderSummary
|
||||
* → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장 cfSave(서버 원가와 화면 대조) · 지우기 · 실행 취소 · Ctrl+Z · Ctrl+S.
|
||||
* 바깥 페이지 하나만 세로로 밀림(안쪽 스크롤 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -17,21 +18,47 @@ import {
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { CfListItem } from "./M02_CF_Api";
|
||||
import type { CfLogic } from "./M02_CF_Model";
|
||||
import type { CfListItem, CfQuery } from "./M02_CF_Api";
|
||||
import type { CfEvalResult, CfValues } from "./M02_CF_Eval";
|
||||
import type { CfKind, CfLogic, CfPort } from "./M02_CF_Model";
|
||||
import "./M02_CF_Entry.css";
|
||||
|
||||
/** CostFlow 의 다른 파일 — 페이지를 열 때 한 번에 불러옴(엔진이 M01 첫 묶음에 안 들어가게) */
|
||||
const loadMods = () =>
|
||||
Promise.all([
|
||||
import("./M02_CF_Model"),
|
||||
import("./M02_CF_Eval"),
|
||||
import("./M02_CF_Api"),
|
||||
import("./M02_CF_Diagram"),
|
||||
import("./M02_CF_FuncPanel"),
|
||||
import("./M02_CF_OutputPanel"),
|
||||
import("./M02_CF_Summary"),
|
||||
import("./M02_CF_Sample"),
|
||||
]).then(([model, ev, api, diagram, func, output, summary, sample]) => ({
|
||||
model,
|
||||
ev,
|
||||
api,
|
||||
diagram,
|
||||
func,
|
||||
output,
|
||||
summary,
|
||||
sample,
|
||||
}));
|
||||
type Mods = Awaited<ReturnType<typeof loadMods>>;
|
||||
|
||||
const TEXT = {
|
||||
Open: ["단가산출 다이어그램(시험)", "Cost diagram (trial)"],
|
||||
Title: ["단가산출 다이어그램", "Cost diagram"],
|
||||
Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"],
|
||||
Close: ["닫기", "Close"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"],
|
||||
Start_New: ["새로 만들기", "New"],
|
||||
Start_Mine: ["내 로직", "My logics"],
|
||||
Start_MineNone: ["아직 없음", "None yet"],
|
||||
Start_Samples: ["견본", "Samples"],
|
||||
Start_Samples: ["견본 — 복사해서 열기", "Samples — open a copy"],
|
||||
Name: ["로직 이름", "Logic name"],
|
||||
NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"],
|
||||
Unit: ["결과 단위", "Result unit"],
|
||||
@@ -41,11 +68,24 @@ const TEXT = {
|
||||
Unsaved: ["저장 안 함", "Not saved"],
|
||||
Diagram: ["다이어그램", "Diagram"],
|
||||
Box: ["고른 상자", "Chosen box"],
|
||||
Panel_Input: ["입력", "Input"],
|
||||
Panel_Function: ["함수", "Function"],
|
||||
Panel_Output: ["출력", "Output"],
|
||||
PickBox: ["다이어그램에서 상자를 고르면 여기에 그 상자 판이 보임", "Choose a box in the diagram"],
|
||||
InputSoon: ["입력 상자 판은 곧 붙음", "The input box panel is coming"],
|
||||
Summary: ["원가", "Cost"],
|
||||
NotYet: ["준비 중", "Coming soon"],
|
||||
NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"],
|
||||
SaveAnyway: [
|
||||
"고칠 곳이 있음 — {v} · 그래도 저장할까요?",
|
||||
"There are issues — {v} · Save anyway?",
|
||||
],
|
||||
Saving: ["저장 중", "Saving"],
|
||||
Saved: ["저장했음", "Saved"],
|
||||
SaveFailed: ["저장 못 함 — {v}", "Not saved — {v}"],
|
||||
Differs: [
|
||||
"저장 때 서버가 다시 셈한 원가 {s} 원 — 화면 값 {c} 원과 다름",
|
||||
"Recalculated on save: {s} — differs from the screen value {c}",
|
||||
],
|
||||
Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"],
|
||||
Deleted: ["지웠음", "Deleted"],
|
||||
Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"],
|
||||
Leave_Ask: [
|
||||
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
|
||||
"There are unsaved changes — close without saving?",
|
||||
@@ -55,17 +95,14 @@ const TEXT = {
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
|
||||
const te = (key: TextKey): string =>
|
||||
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string;
|
||||
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 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);
|
||||
const loadList = (): Promise<CfListItem[]> =>
|
||||
import("./M02_CF_Api").then((m) => m.cfList()).catch(() => []);
|
||||
|
||||
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
|
||||
async function unitChoices(): Promise<string[]> {
|
||||
const count = new Map<string, number>();
|
||||
@@ -83,6 +120,44 @@ async function unitChoices(): Promise<string[]> {
|
||||
return units.length ? units : ["원/㎡"];
|
||||
}
|
||||
|
||||
/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 표(알약 「표」) · 사용자 요소 · 변수는 Eval 이 스스로 봄 */
|
||||
function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string }[] {
|
||||
return lg.boxes.flatMap((b) => {
|
||||
const inp = b.input;
|
||||
if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return [];
|
||||
const group = inp.group;
|
||||
const els = inp.elements
|
||||
.filter((e) => e.ref && !e.ref.startsWith("UE-"))
|
||||
.map((e) => ({
|
||||
q: { id: `${b.id}\u0001${e.name}`, group, ref: e.ref, unit: e.unit },
|
||||
box: b.id,
|
||||
port: e.name,
|
||||
}));
|
||||
const table = inp.table
|
||||
? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }]
|
||||
: [];
|
||||
return [...els, ...table];
|
||||
});
|
||||
}
|
||||
|
||||
/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */
|
||||
async function fetchValues(M: Mods, lg: CfLogic): Promise<{ values: CfValues; errors: string[] }> {
|
||||
const asks = queriesOf(lg);
|
||||
if (!asks.length) return { values: {}, errors: [] };
|
||||
const answers = await M.api.cfValues(
|
||||
asks.map((a) => a.q),
|
||||
lg.userElements as never,
|
||||
);
|
||||
const values: CfValues = {};
|
||||
const errors: string[] = [];
|
||||
for (const a of asks) {
|
||||
const got = answers.find((x) => x.id === a.q.id);
|
||||
if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`);
|
||||
else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null;
|
||||
}
|
||||
return { values, errors };
|
||||
}
|
||||
|
||||
/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */
|
||||
export function createCostFlowButton(): HTMLButtonElement {
|
||||
return createButton({ label: te("Open"), variant: "filled", onClick: openCostFlow });
|
||||
@@ -90,8 +165,9 @@ export function createCostFlowButton(): HTMLButtonElement {
|
||||
|
||||
function openCostFlow(): void {
|
||||
/** 고치는 화면이 열려 있을 때만 */
|
||||
let paper: { dirty(): boolean } | null = null;
|
||||
let paper: { dirty(): boolean; save(): Promise<void>; undo(): void } | null = null;
|
||||
let units: string[] | null = null;
|
||||
const mods = loadMods();
|
||||
|
||||
const mayLeave = async (): Promise<boolean> =>
|
||||
!paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
|
||||
@@ -103,7 +179,7 @@ function openCostFlow(): void {
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void (async () => {
|
||||
if (await mayLeave()) shut();
|
||||
if (await mayLeave()) paper ? void showStart() : shut();
|
||||
})(),
|
||||
});
|
||||
const page = el("div", {
|
||||
@@ -128,42 +204,52 @@ function openCostFlow(): void {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
page.addEventListener("keydown", (ev) => {
|
||||
if (ev.key !== "Escape" || ev.defaultPrevented) return;
|
||||
void (async () => {
|
||||
if (await mayLeave()) shut();
|
||||
})();
|
||||
if (ev.defaultPrevented) return;
|
||||
if (ev.key === "Escape") {
|
||||
void (async () => {
|
||||
if (await mayLeave()) shut();
|
||||
})();
|
||||
return;
|
||||
}
|
||||
if (!paper || !(ev.ctrlKey || ev.metaKey)) return;
|
||||
const key = ev.key.toLowerCase();
|
||||
if (key === "z" && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
paper.undo();
|
||||
} else if (key === "s") {
|
||||
ev.preventDefault();
|
||||
void paper.save();
|
||||
}
|
||||
});
|
||||
|
||||
const run = (job: (M: Mods) => Promise<void>): void => {
|
||||
body.replaceChildren(muted(te("Loading")));
|
||||
void mods.then(job).catch((e: unknown) => {
|
||||
showToast(te("OpenFailed", { v: e instanceof Error ? e.message : String(e) }), "error");
|
||||
void showStart();
|
||||
});
|
||||
};
|
||||
|
||||
/* ---------- 시작 화면 ---------- */
|
||||
|
||||
const listRow = (item: CfListItem): HTMLElement => {
|
||||
const listRow = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => {
|
||||
const row = el("button", {
|
||||
className: "m02-cf__item",
|
||||
attrs: { type: "button", title: item.saved },
|
||||
attrs: { type: "button", title },
|
||||
children: [
|
||||
el("span", { className: "m02-cf__item-name", text: item.name }),
|
||||
el("span", { className: "m02-cf__item-unit", text: item.resultUnit }),
|
||||
el("span", { className: "m02-cf__item-name", text: name }),
|
||||
el("span", { className: "m02-cf__item-unit", text: unit }),
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", () => showToast(te("NotYet"), "info"));
|
||||
row.addEventListener("click", onClick);
|
||||
return row;
|
||||
};
|
||||
|
||||
const showStart = (): void => {
|
||||
const showStart = async (): Promise<void> => {
|
||||
paper = null;
|
||||
strip.replaceChildren();
|
||||
const mineHost = el("div", { className: "m02-cf__list", children: [muted(te("Loading"))] });
|
||||
const sampleHost = el("div", { className: "m02-cf__list", children: [muted(te("Loading"))] });
|
||||
void loadList().then((all) => {
|
||||
const mine = all.filter((x) => !x.sample);
|
||||
const samples = all.filter((x) => x.sample);
|
||||
mineHost.replaceChildren(
|
||||
...(mine.length ? mine.map(listRow) : [muted(te("Start_MineNone"))]),
|
||||
);
|
||||
sampleHost.replaceChildren(
|
||||
...(samples.length ? samples.map(listRow) : [muted(te("Start_MineNone"))]),
|
||||
);
|
||||
});
|
||||
const sampleHost = el("div", { className: "m02-cf__list" });
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-cf__start",
|
||||
@@ -171,7 +257,9 @@ function openCostFlow(): void {
|
||||
el("section", {
|
||||
className: "m02-cf__start-col",
|
||||
children: [
|
||||
bigButton(te("Start_New"), () => void openNew()),
|
||||
bigButton(te("Start_New"), () =>
|
||||
run(async (M) => showPaper(M, M.model.newLogic("", (await units_())[0]), false)),
|
||||
),
|
||||
el("h3", { text: te("Start_Mine") }),
|
||||
mineHost,
|
||||
],
|
||||
@@ -183,41 +271,231 @@ function openCostFlow(): void {
|
||||
],
|
||||
}),
|
||||
);
|
||||
const M = await mods;
|
||||
sampleHost.replaceChildren(
|
||||
...M.sample.sampleList().map((s) =>
|
||||
listRow(
|
||||
s.name,
|
||||
s.resultUnit,
|
||||
() =>
|
||||
run(async (M2) => {
|
||||
const lg = M2.sample.sampleLoad(s.id);
|
||||
if (lg) await showPaper(M2, lg, false);
|
||||
}),
|
||||
s.note,
|
||||
),
|
||||
),
|
||||
);
|
||||
try {
|
||||
const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample);
|
||||
mineHost.replaceChildren(
|
||||
...(mine.length
|
||||
? mine.map((x) =>
|
||||
listRow(
|
||||
x.name,
|
||||
x.resultUnit,
|
||||
() => run(async (M2) => showPaper(M2, await M2.api.cfGet(x.key), true)),
|
||||
x.saved,
|
||||
),
|
||||
)
|
||||
: [muted(te("Start_MineNone"))]),
|
||||
);
|
||||
} catch (e) {
|
||||
mineHost.replaceChildren(muted(M.api.cfPlain(e)));
|
||||
}
|
||||
};
|
||||
const units_ = async (): Promise<string[]> => (units ??= await unitChoices());
|
||||
|
||||
/* ---------- 고치는 화면 ---------- */
|
||||
|
||||
const openNew = async (): Promise<void> => {
|
||||
units ??= await unitChoices();
|
||||
const lg = await blankLogic("", units[0]);
|
||||
showPaper(lg, units);
|
||||
};
|
||||
const showPaper = async (M: Mods, first: CfLogic, saved: boolean): Promise<void> => {
|
||||
const choices = await units_();
|
||||
let lg = first;
|
||||
let savedLg: CfLogic | null = saved ? first : null;
|
||||
let stack: CfLogic[] = [];
|
||||
let selected: string | null = null;
|
||||
let values: CfValues = {};
|
||||
let valueSig = "";
|
||||
let notes: string[] = [];
|
||||
let result: CfEvalResult;
|
||||
let busy = false;
|
||||
|
||||
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 note = el("p", { className: "m02-cf__note", attrs: { role: "status" } });
|
||||
const diagramHost = el("div");
|
||||
const pickHint = muted(te("PickBox"));
|
||||
// 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만(갈아끼우면 편집기가 있다고 여김)
|
||||
const funcHost = el("div");
|
||||
const outHost = el("div");
|
||||
const inHost = el("div", { children: [muted(te("InputSoon"))] });
|
||||
const summaryHost = el("div");
|
||||
|
||||
const evalNow = (): CfEvalResult => {
|
||||
try {
|
||||
return M.ev.evalLogic(lg, values);
|
||||
} catch (e) {
|
||||
const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
|
||||
return { ports: {}, outputs: {}, cost, errors: [M.api.cfPlain(e)] };
|
||||
}
|
||||
};
|
||||
|
||||
const edit = (next: CfLogic): void => {
|
||||
if (next === lg) return;
|
||||
stack = [...stack, lg];
|
||||
lg = next;
|
||||
redraw();
|
||||
};
|
||||
/** 다이어그램이 부른 바꿈 — 다이어그램이 제 옛 그림으로 한 번 더 그려도 다음 틱에 새로 */
|
||||
const editFromDiagram = (next: CfLogic): void => {
|
||||
edit(next);
|
||||
setTimeout(redraw);
|
||||
};
|
||||
|
||||
/** 입력 값 다시 받기 — 물음이 바뀐 때만 · 같은 조작의 뒤끝이라 기록을 더 쌓지 않음 */
|
||||
const refreshValues = (): void => {
|
||||
const sig = JSON.stringify(queriesOf(lg).map((a) => a.q));
|
||||
if (sig === valueSig) return;
|
||||
valueSig = sig;
|
||||
void fetchValues(M, lg).then(
|
||||
(got) => {
|
||||
if (sig !== valueSig) return;
|
||||
values = got.values;
|
||||
notes = got.errors.length ? [te("Values", { v: got.errors.join(" · ") })] : [];
|
||||
redraw();
|
||||
},
|
||||
(e: unknown) => {
|
||||
notes = [te("Values", { v: M.api.cfPlain(e) })];
|
||||
redraw();
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const name = createInputField({
|
||||
label: te("Name"),
|
||||
placeholder: te("NamePlaceholder"),
|
||||
value: lg?.name ?? "",
|
||||
onInput: touched,
|
||||
value: lg.name,
|
||||
onInput: (v) => edit({ ...lg, name: v }),
|
||||
});
|
||||
name.root.classList.add("m02-cf__name");
|
||||
const unitList = choices.includes(lg.resultUnit) ? choices : [lg.resultUnit, ...choices];
|
||||
const unit = createSelectField({
|
||||
label: te("Unit"),
|
||||
options: choices.map((u) => ({ value: u, text: u.replace(/^원\//, "") })),
|
||||
value: lg?.resultUnit ?? choices[0],
|
||||
onChange: touched,
|
||||
options: unitList.map((u) => ({ value: u, text: u.replace(/^원\//, "") })),
|
||||
value: lg.resultUnit,
|
||||
onChange: (v) => edit({ ...lg, resultUnit: v }),
|
||||
});
|
||||
|
||||
const diagramOn = {
|
||||
select: (id: string | null): void => {
|
||||
selected = id;
|
||||
drawPanel();
|
||||
},
|
||||
move: (id: string, x: number, y: number): void => edit(M.model.moveBox(lg, id, x, y)),
|
||||
link: (from: CfPort, to: CfPort): string | null => {
|
||||
const got = M.model.link(lg, from, to);
|
||||
if (got.why) return got.why;
|
||||
editFromDiagram(got.lg);
|
||||
return null;
|
||||
},
|
||||
unlink: (id: string): void => editFromDiagram(M.model.unlink(lg, id)),
|
||||
add: (kind: CfKind, x: number, y: number): void => {
|
||||
const got = M.model.addBox(lg, kind, x, y);
|
||||
selected = got.box.id;
|
||||
editFromDiagram(got.lg);
|
||||
},
|
||||
remove: (id: string): void => {
|
||||
if (selected === id) selected = null;
|
||||
editFromDiagram(M.model.removeBox(lg, id));
|
||||
},
|
||||
};
|
||||
|
||||
const drawPanel = (): void => {
|
||||
const box = lg.boxes.find((b) => b.id === selected);
|
||||
pickHint.hidden = !!box;
|
||||
funcHost.hidden = box?.kind !== "function";
|
||||
outHost.hidden = box?.kind !== "output";
|
||||
inHost.hidden = box?.kind !== "input";
|
||||
if (box?.kind === "function") M.func.renderFuncPanel(funcHost, lg, box.id, { change: edit });
|
||||
if (box?.kind === "output")
|
||||
M.output.renderOutputPanel(outHost, lg, box.id, { change: edit }, { result });
|
||||
};
|
||||
|
||||
const redraw = (): void => {
|
||||
result = evalNow();
|
||||
refreshValues();
|
||||
unsaved.hidden = lg === savedLg;
|
||||
undo.disabled = !stack.length;
|
||||
deleteButton.hidden = !lg.key;
|
||||
M.diagram.renderDiagram(diagramHost, lg, diagramOn, { values: result.ports, selected });
|
||||
drawPanel();
|
||||
M.summary.renderSummary(summaryHost, lg, result, { change: edit });
|
||||
note.textContent = [...notes, ...result.errors].slice(0, 4).join("\n");
|
||||
};
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (busy) return;
|
||||
if (!lg.name.trim()) {
|
||||
note.textContent = te("NoName");
|
||||
return void name.input.focus();
|
||||
}
|
||||
let problems: string[] = [];
|
||||
try {
|
||||
problems = M.ev.checkLogic(lg, values);
|
||||
} catch {
|
||||
problems = [];
|
||||
}
|
||||
const ask = te("SaveAnyway", { v: problems.slice(0, 3).join(" · ") });
|
||||
if (problems.length && !(await showConfirmDialog(ask, te("Save")))) return;
|
||||
busy = true;
|
||||
note.textContent = te("Saving");
|
||||
const screen = result.cost.계;
|
||||
try {
|
||||
const got = await M.api.cfSave(lg);
|
||||
lg = got.logic;
|
||||
savedLg = lg;
|
||||
stack = [];
|
||||
redraw();
|
||||
const server = got.values.cost.계;
|
||||
if (Math.abs(server - screen) > 0.5)
|
||||
notes = [te("Differs", { s: formatMoney(server), c: formatMoney(screen) })];
|
||||
note.textContent = [...notes, ...result.errors].join("\n");
|
||||
showToast(te("Saved"), "success");
|
||||
} catch (e) {
|
||||
note.textContent = te("SaveFailed", { v: M.api.cfPlain(e) });
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
const doUndo = (): void => {
|
||||
const back = stack[stack.length - 1];
|
||||
if (!back) return;
|
||||
stack = stack.slice(0, -1);
|
||||
lg = back;
|
||||
name.input.value = lg.name;
|
||||
unit.select.value = lg.resultUnit;
|
||||
redraw();
|
||||
};
|
||||
|
||||
const doDelete = async (): Promise<void> => {
|
||||
if (!lg.key || busy) return;
|
||||
if (!(await showConfirmDialog(te("Delete_Ask", { v: lg.name }), te("Delete")))) return;
|
||||
try {
|
||||
await M.api.cfDelete(lg.key);
|
||||
savedLg = lg;
|
||||
showToast(te("Deleted"), "success");
|
||||
void showStart();
|
||||
} catch (e) {
|
||||
note.textContent = M.api.cfPlain(e);
|
||||
}
|
||||
};
|
||||
|
||||
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: doUndo });
|
||||
const deleteButton = createButton({
|
||||
label: te("Delete"),
|
||||
variant: "danger",
|
||||
onClick: () => void doDelete(),
|
||||
});
|
||||
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,
|
||||
@@ -226,39 +504,33 @@ function openCostFlow(): void {
|
||||
className: "m02-cf__actions",
|
||||
children: [
|
||||
undo,
|
||||
createButton({ label: te("Save"), onClick: notYet }),
|
||||
createButton({ label: te("Delete"), variant: "danger", onClick: notYet }),
|
||||
createButton({ label: te("Save"), onClick: () => void save() }),
|
||||
deleteButton,
|
||||
],
|
||||
}),
|
||||
);
|
||||
body.replaceChildren(
|
||||
note,
|
||||
el("div", {
|
||||
className: "m02-cf__layout",
|
||||
children: [
|
||||
part(te("Diagram"), "m02-cf__diagram", [muted(te("NotYet"))]),
|
||||
part(te("Diagram"), "m02-cf__diagram", [diagramHost]),
|
||||
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"))]),
|
||||
part(te("Box"), "m02-cf__box", [pickHint, inHost, funcHost, outHost]),
|
||||
part(te("Summary"), "m02-cf__summary", [summaryHost]),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
name.input.focus();
|
||||
paper = { dirty: () => lg !== savedLg, save, undo: doUndo };
|
||||
redraw();
|
||||
if (!lg.name) name.input.focus();
|
||||
};
|
||||
|
||||
showStart();
|
||||
void showStart();
|
||||
document.body.style.overflow = "hidden"; // 뒤 페이지는 안 밀리고 시험판 페이지만 밀림
|
||||
document.body.append(page);
|
||||
page.tabIndex = -1;
|
||||
@@ -273,14 +545,6 @@ function part(title: string, className: string, children: HTMLElement[]): HTMLEl
|
||||
});
|
||||
}
|
||||
|
||||
/** 고른 상자 판의 종류별 자리 — 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");
|
||||
|
||||
Reference in New Issue
Block a user