Files
Aislo/M01_MasterData/M01_MasterData_UI_Logic_Page.ts
T

511 lines
16 KiB
TypeScript

/* =============================================================================
* M01_MasterData_UI_Logic_Page.ts
* 관리자 화면 — 로직 칸. 가운데 일위대가표(호표) 고치기 / 오른쪽 시험 계산
* 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「일위대가 로직」 컨테이너(`listHost`) 안
*
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
* 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임.
* ========================================================================== */
import { t as L } from "@ui/ui_template_locale";
import {
createButton,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import {
ApiError,
copyLogic,
fetchLogic,
fetchLogicFiles,
fetchLogics,
fetchLogicSubs,
saveFiles,
type CalcLine,
type ElementBrief,
type LogicFile,
type LogicRow,
type LogicSummary,
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 { render as renderCView } from "./M01_MasterData_UI_Logic_CView";
import { isOwnLogic, mountFlow } from "./M01_MasterData_UI_Logic_Flow";
import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List";
import { openLogicWizard } from "./M01_MasterData_UI_Logic_Wizard";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import type { SideHandle } from "./M01_MasterData_UI_Side";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import "./M01_MasterData_UI_Logic_Style.css";
/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */
interface Draft {
/** 구분(원문 + 부문) — 목록 id 를 세움 */
sub: 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>;
}
/** 세 컨테이너(옛 호표 화면 · 쉽게 보기·만들기 · 흐름 보기)가 서로의 저장 안 한 것을 덮지 않게 캐시 칸을 가름 */
export type LogicMode = "classic" | "easy" | "cview";
const SUFFIX: Record<LogicMode, string> = { classic: "", easy: "_easy", cview: "_cview" };
const cacheKey = (mode: LogicMode): string => `m01_logic_drafts${SUFFIX[mode]}`;
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
function loadDrafts(mode: LogicMode): Record<string, Draft> {
try {
return JSON.parse(sessionStorage.getItem(cacheKey(mode)) ?? "{}") as Record<string, Draft>;
} catch {
return {};
}
}
export interface LogicHandle {
/** 밖(요소 화면)에서 이 키를 바로 엶 — 소요량·계수 표의 「쓰는 로직」 눌렀을 때. */
openKey: (key: string) => Promise<void>;
}
export async function mountM01Logic(
host: HTMLElement,
side: SideHandle,
openKey?: string,
mode: LogicMode = "classic",
): Promise<LogicHandle> {
let drafts = loadDrafts(mode);
let files: LogicFile[] = [];
let opened: Opened | null = null;
let calcInputs = "";
/** 옛 컨테이너 = 호표 표 + 시험 계산 칸 · 쉽게 보기·만들기 컨테이너 = 흐름 그림 */
const easy = mode === "easy";
const view: "flow" | "advanced" | "cview" = easy
? "flow"
: mode === "cview"
? "cview"
: "advanced";
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 filterHost = el("div");
const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") });
let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음
const pending = el("span", { className: "m01-logic__muted" });
const list = buildList((item) => void open(item.id, item.sub, item.key));
const back = createButton({ label: tx("Bar_Back"), variant: "ghost", onClick: () => show(null) });
const persist = (): void => {
try {
sessionStorage.setItem(cacheKey(mode), 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,
sub: d.sub,
detail: "",
key: d.row.키,
number: d.row.원문번호,
name: d.row.이름,
unit: d.row.결과단위,
source: 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();
if (view !== "advanced") return; // 흐름 그림은 제 안에서 다시 셈 — 다시 그리면 펼친 상자가 접힘
const inputs = JSON.stringify(opened.row.입력 ?? []);
if (inputs !== calcInputs) drawCalc();
};
const pick = (o: Opened, row: LogicRow | null): Draft => ({
sub: o.sub,
file: o.file,
version: o.version,
origKey: o.origKey,
row,
});
const drawCalc = (): void => {
if (!opened?.row || view !== "advanced") {
calc.replaceChildren();
return;
}
const current = opened;
calcInputs = JSON.stringify(current.row?.입력 ?? []);
buildCalc(calc, {
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 => {
// 고른 로직이 없으면 목록 · 있으면 흐름 그림(기본) 또는 고급 = 호표 표 + 시험 계산
const listing = !opened;
list.root.hidden = !listing || !loaded;
waiting.hidden = loaded;
editor.hidden = back.hidden = listing;
calc.hidden = listing || view !== "advanced";
if (!opened) return;
if (!opened.row) {
editor.replaceChildren(
el("p", { className: "m01-logic__empty", text: `● ${tx("List_Deleted")}` }),
);
return;
}
const current = opened;
const shown = current.row as LogicRow;
if (view === "cview") {
renderCView(editor, current.origKey ?? current.id);
return;
}
if (view === "flow") {
mountFlow(editor, {
one: {
file: current.file,
version: current.version,
logic: shown,
blocked: false,
reasons: current.reasons,
prices: current.prices,
},
row: shown,
editable: isOwnLogic(current.file, shown),
onChange: touch,
onCopy: () => void onCopy(current),
});
return;
}
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, sub: subOf(f), version: f.version });
touch();
},
onPick: openPicker,
});
};
/** 로직 파일 → 구분(원문 + 부문) — 「공통 03장 토공사」 의 앞말이 부문 */
const subOf = (f: LogicFile): string =>
f.book === "건설품셈" ? `${f.book} ${f.chapter.split(" ")[0]}` : String(f.book);
const show = (next: Opened | null): void => {
opened = next;
errors.hidden = true;
drawEditor();
drawCalc();
};
const open = async (id: string, sub: 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(key);
show({
id,
sub,
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");
}
};
let allLogics: LogicSummary[] = [];
const reload = async (): Promise<void> => {
const [logics, logicFiles, subs] = await Promise.all([
fetchLogics(),
fetchLogicFiles(),
fetchLogicSubs(),
]);
files = logicFiles;
allLogics = logics;
list.setItems(logics);
// 왼쪽 「전체」 + 구분 → 상세구분 거름 — 고르면 목록으로 돌아와 그 범위만
filterHost.replaceChildren(
side.filter({
id: easy ? "쉬움|" : mode === "cview" ? "흐름|" : "로직|",
store: easy
? "m01.filter.로직쉬움"
: mode === "cview"
? "m01.filter.로직흐름"
: "m01.filter.로직",
subs,
total: list.total(),
subLabel: L("M01_LaborSub"),
detailLabel: L("M01_LaborDetail"),
restore: true,
onPick: (sub, detail) => {
list.setPick(sub, detail);
if (opened) show(null);
},
}),
);
persist();
};
/** 「새로 만들기」 모달 — 저장·본떠 저장이 끝나면 그 새 자체 로직을 흐름 그림으로 바로 엶 */
const openWizard = (): void =>
openLogicWizard({
onSaved: (key) =>
void reload()
.then(() => open(logicId("자체", key), "자체", key))
.catch(failed),
});
/** 옛 컨테이너의 「새로 만들기」 — 빈 줄 하나를 초안으로 세움(저장 때 서버가 키를 줌) */
const onDraftNew = (): void => {
const f = files.find((x) => x.file === opened?.file) ?? files[0];
if (!f) return;
const id = `new:${Date.now()}`;
// 원문번호는 절 번호만(부문은 구분 칸) · 키는 저장 때 서버가 줌
const row: LogicRow = {
: "",
원문번호: `${tx("New_Key")} ${Object.keys(drafts).length + 1}`,
이름: "",
결과단위: "원/",
출처: "",
소유: "공용",
입력: [],
중간: [],
호표: [],
덧줄: [],
끝수: null,
};
const next: Opened = {
id,
sub: subOf(f),
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 onNew = easy ? openWizard : onDraftNew;
/** 「본떠 만들기」 — 정본을 자체 파일로 복제하고(계약 5장) 그 새 로직을 바로 엶 */
const onCopy = async (from: Opened): Promise<void> => {
if (!from.origKey) return;
showLoadingOverlay();
try {
const made = await copyLogic(from.origKey);
await reload();
showToast(tx("Edit_Copied", { v: made.key }), "success");
await open(logicId("자체", made.key), "자체", made.key);
} catch (error) {
failed(error);
} finally {
hideLoadingOverlay();
}
};
const onDelete = async (): Promise<void> => {
if (!opened?.row) return;
if (
!(await showConfirmDialog(
tx("Confirm_Delete", { v: opened.row.원문번호 || 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.sub, was.origKey), was.sub, 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.sub, was.row.), was.sub, 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: [
back,
el("h2", { text: tx("Title") }),
// 흐름 보기 = 읽기 + 시험 계산만 — 만들기·지우기·저장 단추 없음
...(mode === "cview"
? []
: [
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: [
el("main", {
className: "m01-logic__main",
children: [bar, errors, waiting, list.root, editor],
}),
calc,
],
}),
);
({ classic: side.logicHost, easy: side.easyHost, cview: side.cviewHost })[mode].replaceChildren(
filterHost,
);
show(null);
showLoadingOverlay();
const openKeyOn = async (key: string): Promise<void> => {
const found = allLogics.find((x) => x. === key);
if (found) await open(logicId(found.구분, found.), found.구분, found.);
else showToast(tx("Load_Failed"), "error");
};
try {
await reload();
loaded = true;
drawEditor();
if (openKey) await openKeyOn(openKey);
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
} finally {
hideLoadingOverlay();
}
return { openKey: openKeyOn };
}