Files
Aislo/M01_MasterData/M01_MasterData_UI_LogicLab.ts
T
eomsangdonandClaude Sonnet 5.5 eaadb48a2b feat(796-2a): 수식 탭 화면 — 공통 수식 부품 · M01 수식 탭(첫 탭) · 고침 · 변수 창 · 옵션 가로
- ui_template_equation(.ts · _line.ts · .css): 분수 · 첨자 · 괄호 · % · 조건 칩 · 하위 로직 블록(접힘) · 결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이 · 부품 모음 견본 한 줄
- M01 LogicLab_Equation: 옵션 가로(도구줄 + 값 칸 · 찾기형 · 말줄임) · 줄 고르면 식 칸 + 숨은 아이콘 줄 · 서버 미리 셈 다시 그림 · 고침 목록 캐시 → [수식 저장](개정 확인) · 떠나기 전 버림 물음
- 변수 창(이름 · 기호 · 위 · 아래첨자 · 단위 · 값 · 보이는 자리) · 변수 더함 · 지움(쓰는 곳 있으면 서버 까닭 그대로)
- 보기 고르기 = 수식 첫 탭 · 단가산출 상세 · 시트 · 인력재료경비 · 시험계산 · 텍스트 수식은 뒤로(걷지 않음)
- test_796_2_equation_screen 새로 · test_794_1 기대 갱신(첫 탭 수식 · LogicLab 700 줄 상한 — 수식 이음 14 줄)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:46:28 +09:00

539 lines
19 KiB
TypeScript

/* =============================================================================
* M01_MasterData_UI_LogicLab.ts
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 시트(794-1 · 식 고침 = 관리자) · 단가산출 상세 · 시험 계산 / 오른쪽 원문
* 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`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,
fetchLogic,
fetchLogicFiles,
fetchLogics,
fetchLogicSubs,
saveFiles,
type CalcLine,
type TextAnswer,
type ElementBrief,
type LogicRow,
type LogicSummary,
type SaveFile,
} from "./M01_MasterData_UI_Logic_Api";
import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List";
import type { SideHandle } from "./M01_MasterData_UI_Side";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { buildLabDetail, isOwnLogic, trial } from "./M01_MasterData_UI_LogicLab_Detail";
import { setLogicOpener } from "./M01_MasterData_UI_LogicLab_Modal";
import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic";
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side";
import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View";
import { createLabSheet } from "./M01_MasterData_UI_LogicLab_Sheet";
import { createLabEquation } from "./M01_MasterData_UI_LogicLab_Equation";
import { buildHeadBar } from "./M01_MasterData_UI_Head";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import "./M01_MasterData_UI_Logic_Style.css";
import { createEmpty } from "@ui/ui_template_empty";
/** 저장 안 한 로직 하나 — 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;
text: TextAnswer | null;
/** 마지막으로 잘 받은 읽는 식 — 멈춘 뒤에도 수량 칸 조각을 이걸로 */
lastText?: TextAnswer;
ownHost: HTMLElement;
values: Record<string, string>;
}
const OWN_FILE = "로직_자체.json";
/** 시험 계산에 넘길 줄 — 시험 고침(722-2) 덮은 사본 */
const calcRow = (row: LogicRow | null): LogicRow | null => trial.apply(row);
const cacheKey = "m01_logic_drafts_lab";
const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" });
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
function loadDrafts(): Record<string, Draft> {
try {
return JSON.parse(sessionStorage.getItem(cacheKey) ?? "{}") as Record<string, Draft>;
} catch {
return {};
}
}
export interface LogicHandle {
/** 밖(요소 화면)에서 이 키를 바로 엶 — 소요량·계수 표의 「쓰는 로직」 눌렀을 때. */
openKey: (key: string) => Promise<void>;
}
export async function mountM01LogicLab(
host: HTMLElement,
side: SideHandle,
openKey?: string,
): Promise<LogicHandle> {
let drafts = loadDrafts();
let opened: Opened | null = null;
let calcInputs = "";
const admin = await fetchSessionUser()
.then((u) => u?.role === "SYSTEM_ADMIN")
.catch(() => false);
let recalc: number | undefined;
const sheet = createLabSheet({
admin,
onInput: (name, v) => opened && syncLeft(opened, { [name]: v }),
edited: () => (touch(), drawEditor(), drawCalc()), // 줄 사본 → 캐시 → [저장](기존 길)
run: () => calc.querySelector<HTMLButtonElement>("[data-calc-run]")?.click(),
});
const equation = createLabEquation({
admin,
blocked: () => !!opened && opened.id in drafts, // 시트 · 시험 쪽 저장 안 한 고침이 있으면 수식 고침 잠김
onSaved: () => {
const was = opened; // reload 가 고름을 비우므로 먼저 기억
if (was?.row?.키) void reload().then(() => open(was.id, was.sub, was.row!.키));
},
});
const editor = el("div", { className: "m01-logic__editor" });
// 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」
const panes = buildLogicSide(editor);
const views = createViewPicker(editor); // 보기 고르기 — 머리줄 [신규 로직] 왼쪽
const errors = el("div", {
className: "ui-banner ui-banner--danger m01-logic__reasons",
attrs: { hidden: "" },
});
const calc = el("div", { className: "m01lab__calc" });
const filterHost = el("div");
const waiting = el("p", { className: "ui-text ui-text--muted", text: tx("Loading") });
let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음
const pending = el("span", { className: "ui-text ui-text--muted" });
const list = buildList(
(item) => void open(item.id, item.sub, item.key),
(item, alias) => void onAlias(item, alias),
);
const back = createButton({
label: tx("Bar_Back"),
variant: "ghost",
onClick: () => void equation.canLeave().then((ok) => ok && show(null)),
});
const persist = (): void => {
try {
sessionStorage.setItem(cacheKey, JSON.stringify(drafts));
} catch {
/* 캐시가 막혀도 화면 안의 고친 것은 남음 */
}
const marks = new Map<string, ListMark>();
const extra: ListItem[] = [];
const aliases = new Map<string, string>();
for (const [id, d] of Object.entries(drafts)) {
marks.set(id, d.origKey === null ? "new" : d.row === null ? "deleted" : "edited");
if (d.row) aliases.set(id, d.row.별칭 ?? "");
if (d.origKey === null && d.row) {
extra.push({
id,
sub: d.sub,
detail: "",
key: d.row.키,
number: d.row.원문번호,
name: d.row.이름,
alias: d.row.별칭 ?? "",
unit: d.row.결과단위,
source: d.row.출처,
blocked: false,
reasons: [],
});
}
}
list.setMarks(marks, extra, aliases);
const n = Object.keys(drafts).length;
pending.textContent = n ? tx("Bar_Pending", { n }) : "";
saveButton.disabled = discardButton.disabled = n === 0;
saveButton.hidden = discardButton.hidden = 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(calcRow(opened.row)?.입력 ?? []);
if (inputs !== calcInputs) drawCalc();
if (isOwnLogic(opened.row)) {
// 자체 로직 — 고친 뒤 잠시 쉬면 다시 계산해 호표 소계와 텍스트 수식을 새로 그림
window.clearTimeout(recalc);
recalc = window.setTimeout(() => calc.querySelector("button")?.click(), 700);
}
};
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) {
calc.replaceChildren();
return;
}
const current = opened;
calcInputs = JSON.stringify(calcRow(current.row)?.입력 ?? []);
buildCalc(calc, {
savedKey: current.origKey,
file: current.file,
row: calcRow(current.row) as LogicRow,
prices: current.prices,
dirty: () => current.id in drafts || trial.has(current.row),
values: current.values,
onText: (text) => {
current.text = text;
if (text.ok) current.lastText = text;
drawEditor(); // [계산] 전 미리보기 응답도 왼쪽 이름 식 칸에 바로 비치게 — onLines 와 같은 자리
},
onAnswer: sheet.answer,
onLines: (lines) => {
current.lines = lines;
drawEditor();
},
});
};
const drawEditor = (): void => {
const listing = !opened; // 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산
list.root.hidden = !listing || !loaded;
waiting.hidden = loaded;
panes.root.hidden = !opened;
back.hidden = views.root.hidden = listing;
if (!opened) return;
if (!opened.row) {
editor.replaceChildren(createEmpty({ text: `● ${tx("List_Deleted")}`, small: true }));
return;
}
const current = opened;
buildLabDetail(editor, {
row: current.row as LogicRow,
file: current.file,
reasons: current.reasons,
prices: current.prices,
lines: current.lines,
text: current.text,
lastText: current.lastText,
values: current.values,
ownHost: current.ownHost,
onChange: touch,
onInput: (name, v) => syncLeft(current, { [name]: v }),
onTrial: drawCalc,
calcHost: calc,
admin,
sheet: sheet.root,
equation: equation.root,
});
if (current.origKey) equation.draw(current.origKey, current.version, current.row as LogicRow);
sheet.draw(current.row as LogicRow, current.original, current.values);
};
/** 텍스트 수식 · 시트 입력 → 왼쪽 시험 계산 입력(이름이 있는 칸만) · 바뀌면 다시 그리고 셈 */
const syncLeft = (o: Opened, ex: Record<string, string>): void => {
const names = new Set((calcRow(o.row)?.입력 ?? []).map((x) => x.이름));
const changed = Object.entries(ex).filter(([k, v]) => names.has(k) && o.values[k] !== v);
if (!changed.length) return;
for (const [k, v] of changed) o.values[k] = v;
drawCalc();
calc.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
};
const show = (next: Opened | null): void => {
opened = next;
panes.showSource(next?.row?.출처 ?? "");
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,
text: null,
ownHost: ownHost(),
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,
text: null,
ownHost: ownHost(),
values: {},
});
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
let allLogics: LogicSummary[] = [];
const reload = async (): Promise<void> => {
const [logics, subs] = await Promise.all([fetchLogics(), fetchLogicSubs()]);
allLogics = logics;
list.setItems(logics);
// 왼쪽 「전체」 + 구분 → 상세구분 거름 — 고르면 목록으로 돌아와 그 범위만
filterHost.replaceChildren(
side.filter({
id: "로직|",
store: "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) void equation.canLeave().then((ok) => ok && show(null));
},
}),
);
persist();
};
/** 목록 「별칭」 칸 고침(208-1) — 그 줄의 별칭만 바꿔 캐시에 · [저장] 때 다른 고침과 함께 나감 */
const onAlias = async (item: ListItem, alias: string): Promise<void> => {
const draft = drafts[item.id];
if (draft && !draft.row) return; // 지운 줄
try {
const one = draft?.origKey === null ? null : await fetchLogic(item.key);
const row = clone((draft?.row ?? one?.logic) as LogicRow);
if (alias) row.별칭 = alias;
else delete row.별칭;
if (one && JSON.stringify(row) === JSON.stringify(one.logic)) delete drafts[item.id];
else
drafts[item.id] = draft
? { ...draft, row }
: { sub: item.sub, file: one!.file, version: one!.version, origKey: item.key, row };
persist();
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
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 {
// 키 없는 새 줄은 서버가 새 키를 줌(added) — 저장 뒤 그 로직을 엶
const saved = await saveRevised((ok) => saveFiles([...byFile.values()], ok));
if (!saved) return; // 개정 확인 취소 = 캐시 그대로
const was = opened;
drafts = {};
await reload();
showToast(tx("Save_Done"), "success");
const newKey = was?.row && !was.row.키 ? saved.added?.at(-1)?.key : undefined;
const made = newKey ? allLogics.find((x) => x.키 === newKey) : undefined;
if (made) await open(logicId(made.구분, made.키), made.구분, made.키);
else 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 onNew = async (): Promise<void> => {
const units = [...new Set(allLogics.map((x) => x.결과단위).filter(Boolean))];
const info = await askBasicInfo(units.length ? units : ["원"]);
if (!info) return;
try {
const own = (await fetchLogicFiles()).find((f) => f.file === OWN_FILE);
const row: LogicRow = {
키: "",
...info,
입력: [],
중간: [],
호표: [], // 빈 시트 줄 — 자체 로직 편집기 · 시트로 채움
끝수: null,
};
const id = logicId("자체", `새${Date.now()}`);
drafts[id] = { sub: "자체", file: OWN_FILE, version: own?.version ?? "", origKey: null, row };
persist();
await open(id, "자체", "");
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_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 copyButton = createButton({
label: tl("Copy_Open"),
variant: "ghost",
onClick: () =>
opened?.origKey
? void openCopyScreen({
key: opened.origKey,
afterSave: async (newKey) => {
await reload();
await open(logicId("자체", newKey), "자체", newKey);
},
})
: showToast(tl("Copy_NeedOpen"), "info"),
});
// 제목 줄 = 요소 화면(인력 · 재료 …)과 같은 공용 조각
const bar = buildHeadBar({
lead: [back],
title: tx("Title"),
status: [pending],
buttons: [
views.root,
createButton({ label: tl("New"), variant: "filled", onClick: () => void onNew() }),
copyButton,
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
discardButton,
saveButton,
],
}).root;
host.replaceChildren(
el("div", {
className: "m01-logic",
children: [
el("main", {
className: "m01-logic__main",
children: [bar, errors, waiting, list.root, panes.root],
}),
],
}),
);
side.logicHost.replaceChildren(filterHost);
show(null);
showLoadingOverlay();
const openKeyOn = async (key: string): Promise<void> => {
if (!(await equation.canLeave())) return;
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();
}
setLogicOpener((key) => void openKeyOn(key));
return { openKey: openKeyOn };
}