Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Matset.ts
T
eomsangdonandClaude Opus 5.5 4abfc5b265 refactor(M02): 자재 조합 표를 마스터 템플릿으로 옮김 · M02 좌측 「자재 조합」 컨테이너
- 표 resources/master_data/자재조합.json → resources/master_template/자재조합.json(git mv) · 키 UM 은 표 안 「다음」 번호 · _키대장 · master_keys 의 UM 뺌 · check_master 틀이 새 자리를 같이 봄
- 서버: M01 /api/m01/matset* 뺌 · M02 저장소 M02_MasterTemplete_Matset.py(목록 · 새로 · 고치기 · 지우기 · 쓰는 열 검사) · 길 GET/POST /api/m02/material-combos · PUT/DELETE /api/m02/material-combos/{키} · 갈라 내기(branchMaterialCombo)도 이 저장소로
- 화면: M01 좌측 「자재 조합」 컨테이너 뺌 · M02 좌측 「자재 조합」 컨테이너(찾기 · 키 맨 앞 목록 · 새로 · 본떠 만들기 · 지우기) · 메인 칸 상세(이름 · 비고 · 본뜬키 · 담은 자재 표 · 자재 더하기 · 빼기 · 저장) · 쓰는 열이 있으면 지우기는 확인 창 없이 알림
- 시험: test_m01_matset → test_m02_matset · test_m02_combo_branch 사본 자리 · 실제 자재 코드

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwJQ5eRrCKf3nH9EFeKvJz
2026-09-28 22:25:30 +09:00

265 lines
9.7 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Matset.ts
* 좌측 「자재 조합」 컨테이너 — 찾기 · 목록(키 맨 앞) · 새로 · 본떠 만들기 · 지우기(컨테이너 안) ·
* 줄을 누르면 메인 칸에 상세: 이름 · 비고 · 본뜬키 · 담은 자재 표(빼기) · [자재 더하기] · [저장].
* 표 = 마스터 템플릿 `자재조합.json`(키 UM · 서버 `M02_MasterTemplete_Matset.py`) · 지우기는 쓰는 열이
* 있으면 서버가 막음. 재료산출 옆 판이 갈라 낸 조합도 같은 표라 여기 보임(펼칠 때 새로 받음).
* ========================================================================== */
import {
createButton,
createInputField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import {
createMaterialCombo,
deleteMaterialCombo,
editMaterialCombo,
listMaterialCombos,
StaleError,
type MaterialCombo,
} from "./M02_MasterTemplete_Api_Fetch";
import { pickMaterial } from "./M02_MasterTemplete_Materials";
import { tm } from "./M02_MasterTemplete_Matset_Text";
export interface MatsetSectionOptions {
isAdmin: boolean;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 상세를 띄움 — 고침 표시 손잡이를 돌려받음 */
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 메인 칸을 비움(지운 뒤) */
close: () => void;
/** 상세를 열었음 — 좌측 다른 줄 고름 표시를 지움 */
onOpen: () => void;
}
export interface MatsetSection {
title: string;
body: HTMLElement;
/** 새로 · 본떠 만들기 · 지우기 줄(관리자만 · 아니면 빈 목록) */
actions: HTMLElement[];
/** 표를 새로 받아 목록을 다시 그림 */
refresh: () => Promise<void>;
/** 다른 양식을 열었음 — 고름 표시만 지움 */
clear: () => void;
}
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
export function buildMatsetSection(opt: MatsetSectionOptions): MatsetSection {
let all: MaterialCombo[] = [];
let version = "";
let used: Record<string, string[]> = {};
let q = "";
/** 열린 상세의 키 — 새(저장 전)는 빈 글 · 안 열렸으면 null */
let openKey: string | null = null;
const find = createInputField({ type: "search", placeholder: tm("Search") });
const list = el("div", { className: "m02-side__items ui-sidebar-list" });
const body = el("div", { className: "m02-side__items", children: [find.root, list] });
find.input.addEventListener("input", () => {
q = find.input.value.trim().toLowerCase();
paintList();
});
const btnNew = createButton({ label: L("M02_New"), variant: "filled", size: "sm" });
const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost", size: "sm" });
const btnDel = createButton({ label: L("M02_Delete"), variant: "danger", size: "sm" });
const actions = opt.isAdmin
? [el("div", { className: "m02-side__row", children: [btnNew, btnCopy, btnDel] })]
: [];
const paintButtons = (): void => {
btnCopy.disabled = btnDel.disabled = !openKey;
};
function paintList(): void {
const shown = all.filter((x) => !q || `${x.키} ${x.이름}`.toLowerCase().includes(q));
list.replaceChildren(
...(shown.length
? shown.map((x) => {
const b = el("button", {
className: `m02-side__item ui-sidebar-row${x.키 === openKey ? " is-active" : ""}`,
text: `${x.키} ${x.이름}`,
attrs: { type: "button", "data-matset-key": x.키 },
});
b.addEventListener("click", () => void select(x.키));
return b;
})
: [el("p", { className: "m02-side__empty", text: tm("Empty") })]),
);
}
async function refresh(): Promise<void> {
try {
({ 판: version, 줄: all, 쓰는곳: used } = await listMaterialCombos());
} catch (error) {
showToast(tm("Load_Failed", why(error)), "error");
}
paintList();
paintButtons();
}
async function select(key: string): Promise<void> {
if (key === openKey || !(await opt.confirmLeave())) return;
const row = all.find((x) => x.키 === key);
if (row) draw(structuredClone(row), false);
}
/** 메인 칸 상세 — `row` 는 고칠 사본(저장 전까지 표에 안 들어감) */
function draw(row: MaterialCombo, isNew: boolean): void {
openKey = isNew ? "" : row.키;
const edit = opt.isAdmin;
const field = (label: string, name: "이름" | "비고") => {
const f = createInputField({ type: "text", label });
f.input.value = row[name] ?? "";
f.input.disabled = !edit;
f.input.setAttribute("data-matset-field", name);
f.input.addEventListener("input", () => {
if (name === "이름") row.이름 = f.input.value;
else row.비고 = f.input.value || null;
setDirty(true);
});
return f.root;
};
const table = el("div", { className: "m02-matset__table" });
const paintItems = (): void => {
const head = el("div", {
className: "m02-mat__row m02-mat__row--head",
children: [tm("Col_Code"), tm("Col_Name"), tm("Col_Spec"), tm("Col_Unit"), "", ""].map(
(t) => el("span", { text: t }),
),
});
const rows = row.담은자재.map((m, i) => {
const acts = el("span");
if (edit) {
const del = createButton({ label: tm("Remove"), variant: "ghost", size: "sm" });
del.addEventListener("click", () => {
row.담은자재.splice(i, 1);
setDirty(true);
paintItems();
});
acts.append(del);
}
return el("div", {
className: "m02-mat__row",
attrs: { "data-matset-item": m.코드 },
children: [m.코드, m.이름 ?? "", m.규격 ?? "", m.단위 ?? "", ""]
.map((t) => el("span", { text: t }))
.concat(acts),
});
});
table.replaceChildren(
head,
...(rows.length ? rows : [el("p", { className: "m02-side__empty", text: tm("NoItem") })]),
);
};
paintItems();
const add = createButton({ label: tm("AddItem"), variant: "ghost" });
add.addEventListener("click", () =>
pickMaterial((m) => {
if (row.담은자재.some((x) => x.코드 === m.코드)) return;
row.담은자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위 });
setDirty(true);
paintItems();
}),
);
const save = createButton({ label: tm("Save"), variant: "filled" });
save.addEventListener("click", async () => {
if (!row.이름.trim()) return showToast(tm("NeedName"), "error");
if (!row.담은자재.length) return showToast(tm("NeedItem"), "error");
save.disabled = true; // 연달아 눌러도 한 번만
try {
const { 키, ...rest } = row;
const done = isNew
? await createMaterialCombo(version, rest)
: await editMaterialCombo(키, version, row);
setDirty(false);
showToast(tm("Saved"), "success");
await refresh();
draw(structuredClone(done.줄), false);
} catch (error) {
if (error instanceof StaleError) {
showToast(tm("Stale"), "error");
await refresh();
} else showToast(tm("Failed", why(error)), "error");
} finally {
save.disabled = false;
}
});
const node = el("div", {
className: "m02-matset",
children: [
el("div", {
className: "m02-matset__fields",
children: [
field(tm("Head_Name"), "이름"),
field(tm("Head_Note"), "비고"),
el("p", {
className: "m02-side__empty",
text: `${tm("Head_Origin")}: ${row.본뜬키 ?? tm("Origin_Self")}`,
attrs: { "data-matset-origin": row.본뜬키 ?? "" },
}),
],
}),
el("h3", { className: "m02-matset__head", text: tm("Items_Title") }),
table,
...(edit ? [el("div", { className: "m02-side__row", children: [add, save] })] : []),
],
});
const setDirty = opt.show(isNew ? tm("New") : `${row.키} ${row.이름}`, node);
if (isNew) setDirty(true);
opt.onOpen();
paintList();
paintButtons();
}
btnNew.addEventListener("click", async () => {
if (!(await opt.confirmLeave())) return;
draw({ 키: "", 이름: "", 담은자재: [], 본뜬키: null, 소유: "현장", 비고: null }, true);
});
btnCopy.addEventListener("click", async () => {
const from = all.find((x) => x.키 === openKey);
if (!from || !(await opt.confirmLeave())) return;
const copy = structuredClone(from);
draw({ ...copy, 키: "", 이름: tm("CopyName", from.이름), 본뜬키: from.키, 소유: "현장" }, true);
});
btnDel.addEventListener("click", async () => {
const key = openKey;
if (!key) return;
await refresh(); // 최신 판 · 쓰는 열로
// 쓰는 열이 있으면 확인 창 없이 바로 알림(서버도 막음)
const users = used[key] ?? [];
if (users.length) return showToast(tm("InUse", users.join(" · ")), "error");
if (!(await showConfirmDialog(tm("DeleteAsk", key)))) return;
try {
await deleteMaterialCombo(key, version);
showToast(tm("Deleted"), "success");
openKey = null;
opt.close();
} catch (error) {
showToast(tm("Failed", why(error)), "error");
}
await refresh();
});
paintButtons();
return {
title: tm("Title"),
body,
actions,
refresh,
clear: () => {
if (openKey === null) return;
openKey = null;
paintList();
paintButtons();
},
};
}