- 표 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
265 lines
9.7 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
}
|