- 소요량 2059 · 계수 335 · 로직 1102 열쇠에 부문 붙임 · 찾기·로직 부르기 참조 모두 바꿈(산림품셈 로직 포함) - 검사 도구: 건설품셈 표·로직 열쇠에 부문 없으면 틀 알림 · 파일 사이 열쇠 겹침 검사 그대로 - M01 새 로직 열쇠에 부문 앞말 · _틀.md 규칙 · 시험 열쇠 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
363 lines
11 KiB
TypeScript
363 lines
11 KiB
TypeScript
/* =============================================================================
|
|
* M01_MasterData_UI_Logic_Page.ts
|
|
* 관리자 화면 — 로직 칸. 왼쪽 목록 / 가운데 일위대가표(호표) 고치기 / 오른쪽 시험 계산
|
|
* 진입: 공용 진입 파일이 `mountM01Logic(host)` 한 줄로 붙임(요소 칸은 sub_laptop_3 몫)
|
|
*
|
|
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
|
|
* 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임.
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
createButton,
|
|
el,
|
|
hideLoadingOverlay,
|
|
showConfirmDialog,
|
|
showLoadingOverlay,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import {
|
|
ApiError,
|
|
fetchLogic,
|
|
fetchLogicFiles,
|
|
fetchLogics,
|
|
saveFiles,
|
|
type CalcLine,
|
|
type ElementBrief,
|
|
type LogicFile,
|
|
type LogicRow,
|
|
type SaveFile,
|
|
} from "./M01_MasterData_UI_Logic_Api";
|
|
import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
|
|
import { buildEditor } from "./M01_MasterData_UI_Logic_Edit";
|
|
import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List";
|
|
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
|
|
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
|
import "./M01_MasterData_UI_Logic_Style.css";
|
|
|
|
/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */
|
|
interface Draft {
|
|
book: string;
|
|
file: string;
|
|
version: string;
|
|
origKey: string | null;
|
|
row: LogicRow | null;
|
|
}
|
|
|
|
interface Opened extends Draft {
|
|
id: string;
|
|
row: LogicRow | null;
|
|
original: string | null;
|
|
prices: Record<string, ElementBrief | null>;
|
|
reasons: string[];
|
|
lines: CalcLine[] | null;
|
|
values: Record<string, string>;
|
|
}
|
|
|
|
const CACHE_KEY = "m01_logic_drafts";
|
|
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
|
|
|
function loadDrafts(): Record<string, Draft> {
|
|
try {
|
|
return JSON.parse(sessionStorage.getItem(CACHE_KEY) ?? "{}") as Record<string, Draft>;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export async function mountM01Logic(host: HTMLElement): Promise<void> {
|
|
let drafts = loadDrafts();
|
|
let files: LogicFile[] = [];
|
|
let opened: Opened | null = null;
|
|
let calcInputs = "";
|
|
|
|
const editor = el("div", { className: "m01-logic__editor" });
|
|
const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } });
|
|
const calc = el("aside", { className: "m01-logic__calc" });
|
|
const pending = el("span", { className: "m01-logic__muted" });
|
|
const list = buildList((item) => void open(item.id, item.book, item.key));
|
|
|
|
const persist = (): void => {
|
|
try {
|
|
sessionStorage.setItem(CACHE_KEY, JSON.stringify(drafts));
|
|
} catch {
|
|
/* 캐시가 막혀도 화면 안의 고친 것은 남음 */
|
|
}
|
|
const marks = new Map<string, ListMark>();
|
|
const extra: ListItem[] = [];
|
|
for (const [id, d] of Object.entries(drafts)) {
|
|
marks.set(id, d.origKey === null ? "new" : d.row === null ? "deleted" : "edited");
|
|
if (d.origKey === null && d.row) {
|
|
extra.push({
|
|
id,
|
|
book: d.book,
|
|
chapter: "",
|
|
key: d.row.열쇠,
|
|
name: d.row.이름,
|
|
blocked: false,
|
|
reasons: [],
|
|
});
|
|
}
|
|
}
|
|
list.setMarks(marks, extra);
|
|
const n = Object.keys(drafts).length;
|
|
pending.textContent = n ? tx("Bar_Pending", { n }) : "";
|
|
saveButton.disabled = discardButton.disabled = n === 0;
|
|
};
|
|
|
|
const touch = (): void => {
|
|
if (!opened?.row) return;
|
|
const now = JSON.stringify(opened.row);
|
|
if (opened.origKey !== null && now === opened.original) delete drafts[opened.id];
|
|
else drafts[opened.id] = pick(opened, clone(opened.row));
|
|
persist();
|
|
const inputs = JSON.stringify(opened.row.입력 ?? []);
|
|
if (inputs !== calcInputs) drawCalc();
|
|
};
|
|
|
|
const pick = (o: Opened, row: LogicRow | null): Draft => ({
|
|
book: o.book,
|
|
file: o.file,
|
|
version: o.version,
|
|
origKey: o.origKey,
|
|
row,
|
|
});
|
|
|
|
const drawCalc = (): void => {
|
|
if (!opened?.row) {
|
|
calc.replaceChildren();
|
|
return;
|
|
}
|
|
const current = opened;
|
|
calcInputs = JSON.stringify(current.row?.입력 ?? []);
|
|
buildCalc(calc, {
|
|
book: current.book,
|
|
savedKey: current.origKey,
|
|
file: current.file,
|
|
row: current.row as LogicRow,
|
|
dirty: () => current.id in drafts,
|
|
values: current.values,
|
|
onLines: (lines) => {
|
|
current.lines = lines;
|
|
drawEditor();
|
|
},
|
|
});
|
|
};
|
|
|
|
const drawEditor = (): void => {
|
|
list.setActive(opened?.id ?? null);
|
|
if (!opened) {
|
|
editor.replaceChildren(el("p", { className: "m01-logic__empty", text: tx("Empty") }));
|
|
return;
|
|
}
|
|
if (!opened.row) {
|
|
editor.replaceChildren(
|
|
el("p", { className: "m01-logic__empty", text: `● ${tx("List_Deleted")}` }),
|
|
);
|
|
return;
|
|
}
|
|
const current = opened;
|
|
buildEditor(editor, {
|
|
row: current.row as LogicRow,
|
|
file: current.file,
|
|
isNew: current.origKey === null,
|
|
files: files.map((f) => f.file),
|
|
reasons: current.reasons,
|
|
prices: current.prices,
|
|
lines: current.lines,
|
|
onChange: touch,
|
|
onFile: (file) => {
|
|
const f = files.find((x) => x.file === file);
|
|
if (!f) return;
|
|
Object.assign(current, { file: f.file, book: f.book, version: f.version });
|
|
touch();
|
|
},
|
|
onPick: openPicker,
|
|
});
|
|
};
|
|
|
|
const show = (next: Opened | null): void => {
|
|
opened = next;
|
|
errors.hidden = true;
|
|
drawEditor();
|
|
drawCalc();
|
|
};
|
|
|
|
const open = async (id: string, book: string, key: string): Promise<void> => {
|
|
const draft = drafts[id];
|
|
if (draft?.origKey === null) {
|
|
show({ ...draft, id, original: null, prices: {}, reasons: [], lines: null, values: {} });
|
|
return;
|
|
}
|
|
try {
|
|
const one = await fetchLogic(book, key);
|
|
show({
|
|
id,
|
|
book,
|
|
file: draft?.file ?? one.file,
|
|
version: draft?.version ?? one.version,
|
|
origKey: key,
|
|
row: draft ? (draft.row === null ? null : clone(draft.row)) : clone(one.logic),
|
|
original: JSON.stringify(one.logic),
|
|
prices: one.prices,
|
|
reasons: one.reasons,
|
|
lines: null,
|
|
values: {},
|
|
});
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
|
}
|
|
};
|
|
|
|
const reload = async (): Promise<void> => {
|
|
const [logics, logicFiles] = await Promise.all([fetchLogics(), fetchLogicFiles()]);
|
|
files = logicFiles;
|
|
list.setItems(logics);
|
|
persist();
|
|
};
|
|
|
|
const onNew = (): void => {
|
|
const f = files.find((x) => x.file === opened?.file) ?? files[0];
|
|
if (!f) return;
|
|
const id = `new:${Date.now()}`;
|
|
// 건설품셈 열쇠 앞에 부문(「공통3장」 → 「공통 」)
|
|
const division = f.book === "건설품셈" ? f.chapter.replace(/\d+장$/, "") + " " : "";
|
|
const row: LogicRow = {
|
|
열쇠: `${division}${tx("New_Key")} ${Object.keys(drafts).length + 1}`,
|
|
이름: "",
|
|
결과단위: "원/",
|
|
출처: "",
|
|
소유: "공용",
|
|
입력: [],
|
|
중간: [],
|
|
호표: [],
|
|
덧줄: [],
|
|
끝수: null,
|
|
};
|
|
const next: Opened = {
|
|
id,
|
|
book: f.book,
|
|
file: f.file,
|
|
version: f.version,
|
|
origKey: null,
|
|
row,
|
|
original: null,
|
|
prices: {},
|
|
reasons: [],
|
|
lines: null,
|
|
values: {},
|
|
};
|
|
drafts[id] = pick(next, clone(row));
|
|
persist();
|
|
show(next);
|
|
};
|
|
|
|
const onDelete = async (): Promise<void> => {
|
|
if (!opened?.row) return;
|
|
if (!(await showConfirmDialog(tx("Confirm_Delete", { v: opened.row.열쇠 }), tx("Bar_Delete"))))
|
|
return;
|
|
if (opened.origKey === null) {
|
|
delete drafts[opened.id];
|
|
persist();
|
|
show(null);
|
|
return;
|
|
}
|
|
drafts[opened.id] = pick(opened, null);
|
|
opened.row = null;
|
|
persist();
|
|
show(opened);
|
|
};
|
|
|
|
const onDiscard = async (): Promise<void> => {
|
|
if (!(await showConfirmDialog(tx("Confirm_Discard"), tx("Bar_Discard")))) return;
|
|
drafts = {};
|
|
persist();
|
|
const was = opened;
|
|
if (was && was.origKey !== null)
|
|
await open(logicId(was.book, was.origKey), was.book, was.origKey);
|
|
else show(null);
|
|
};
|
|
|
|
const onSave = async (): Promise<void> => {
|
|
const byFile = new Map<string, SaveFile>();
|
|
for (const d of Object.values(drafts)) {
|
|
const part = byFile.get(d.file) ?? { file: d.file, version: d.version, changes: [] };
|
|
if (d.origKey === null) {
|
|
if (d.row) part.changes.push({ op: "add", row: d.row });
|
|
} else if (d.row === null) part.changes.push({ op: "delete", key: d.origKey });
|
|
else part.changes.push({ op: "edit", key: d.origKey, row: d.row });
|
|
byFile.set(d.file, part);
|
|
}
|
|
if (!byFile.size) {
|
|
showToast(tx("Save_Nothing"), "info");
|
|
return;
|
|
}
|
|
showLoadingOverlay();
|
|
try {
|
|
await saveFiles([...byFile.values()]);
|
|
const was = opened;
|
|
drafts = {};
|
|
await reload();
|
|
showToast(tx("Save_Done"), "success");
|
|
if (was?.row) await open(logicId(was.book, was.row.열쇠), was.book, was.row.열쇠);
|
|
else show(null);
|
|
} catch (error) {
|
|
failed(error);
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
};
|
|
|
|
const failed = (error: unknown): void => {
|
|
const detail =
|
|
error instanceof ApiError ? (error.detail as { stale?: string[]; errors?: string[] }) : null;
|
|
if (error instanceof ApiError && error.status === 409) {
|
|
showToast(tx("Save_Stale", { v: (detail?.stale ?? []).join(", ") }), "error");
|
|
} else if (error instanceof ApiError && error.status === 422) {
|
|
errors.replaceChildren(
|
|
el("strong", { text: tx("Save_Errors") }),
|
|
...(detail?.errors ?? []).map((x) => el("div", { text: x })),
|
|
);
|
|
errors.hidden = false;
|
|
} else {
|
|
showToast(error instanceof Error ? error.message : tx("Save_Failed"), "error");
|
|
}
|
|
};
|
|
|
|
const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() });
|
|
const discardButton = createButton({
|
|
label: tx("Bar_Discard"),
|
|
variant: "ghost",
|
|
onClick: () => void onDiscard(),
|
|
});
|
|
const bar = el("div", {
|
|
className: "m01-logic__bar",
|
|
children: [
|
|
el("h2", { text: tx("Title") }),
|
|
pending,
|
|
createButton({ label: tx("Bar_New"), variant: "ghost", onClick: onNew }),
|
|
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
|
|
discardButton,
|
|
saveButton,
|
|
],
|
|
});
|
|
host.replaceChildren(
|
|
el("div", {
|
|
className: "m01-logic",
|
|
children: [
|
|
list.root,
|
|
el("main", { className: "m01-logic__main", children: [bar, errors, editor] }),
|
|
calc,
|
|
],
|
|
}),
|
|
);
|
|
show(null);
|
|
showLoadingOverlay();
|
|
try {
|
|
await reload();
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
}
|