Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Materials.ts
T
eomsangdonandClaude Sonnet 5 0c3b089719 feat(M02): 면적산출 조합 줄 · 조합 고치기 · 재료산출 자재 목록 옆 판
- 면적산출 편집 화면 알약 막대 위 한 줄(일위대가 키 · 이름 · 담은 로직 수 · 같이 쓰는 다른 열 수 · 본뜬키) + [조합 고치기](시스템 관리자만)
- 담은 로직 표(더하기 = M01 로직 찾기 · 빼기 · 차례 · 메모) → [적용] 때 갈라 내기 모달(이름 자동 미리 채움) → 새 조합 알림 → 알약만 다시 받음(시트 고침 유지)
- 재료산출 엑셀 옆 접히는 판에 자재 조합 담은 자재 표 · 더하기 · 빼기(이은 칸이면 연결도 풂 확인) · [적용] 모달
- 서버 길(branchCombo · branchMaterialCombo · 자재 조합 읽기)은 아직 안 올라와 M02_MasterTemplete_Combo_Api.ts 한 곳에 가정 모양으로 둠

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWHNLQYPZN7kKf1rzrrRZq
2026-09-28 18:51:03 +09:00

276 lines
10 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Materials.ts
* 재료산출 편집 화면 — 엑셀 옆 접히는 판: 그 열의 자재 조합(UM) 담은 자재 표 + [자재 더하기] · [빼기] · [적용].
* 자재 조합도 여러 열이 같이 쓸 수 있어 [적용] 때 일위대가처럼 모달 후 새 조합으로 갈라 냄
* (서버 `branchMaterialCombo`) · 원래 조합은 그대로. 빼는 자재가 칸에 이어져 있으면 확인 후 연결도 풂.
* ========================================================================== */
import {
createButton,
createInputField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch";
import type { CellLink } from "../A00_Common/spreadsheet/spreadsheet_types";
import { askBranch, branchToast } from "./M02_MasterTemplete_Combo";
import {
branchMaterialCombo,
fetchMaterialCombo,
readColumnBase,
reasonOf,
type MaterialCombo,
type MaterialItem,
} from "./M02_MasterTemplete_Combo_Api";
const TEXT = {
toggle: "자재 목록",
combo: "자재 조합",
branched: "본뜬 조합 {value}",
none: "자재 조합 없음 — 자재를 더하면 새로 만듦",
code: "코드",
name: "이름",
spec: "규격",
unit: "단위",
cell: "이은 칸",
remove: "빼기",
add: "자재 더하기",
apply: "적용",
undo: "되돌리기",
empty: "담은 자재가 없음",
unlinkAsk: "{value} 자재는 {cell} 칸에 이어져 있음 — 빼면 연결도 풂",
unlinkOk: "빼고 연결 풂",
loadFailed: "자재 조합을 못 읽음 — {value}",
askBody:
"이 자재 조합은 열 {n} 개와 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀 · 원래 조합은 그대로",
askAlone:
"이 자재 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀",
failed: "자재 조합을 못 고침 — {value}",
pickTitle: "자재 더하기",
pickClose: "닫기",
search: "자재 이름 · 규격 · 코드",
searching: "찾는 중…",
noMatch: "맞는 자재 없음",
searchFailed: "자재 목록을 못 받음",
};
const fill = (text: string, values: Record<string, string | number>): string =>
Object.entries(values).reduce((s, [k, v]) => s.replace(`{${k}}`, String(v)), text);
export interface MaterialPanelOptions {
/** 집계표 열 id */
column: string;
/** 구조물 이름 — 새 이름 자동 미리 채움 */
label: string;
isAdmin: boolean;
/** 지금 시트 문서의 연결 전부 */
links: () => CellLink[];
/** 자재 코드에 이어진 칸 연결을 풂(시트 문서를 고치고 고침 표시) */
unlink: (codes: string[]) => void;
}
/** 자재 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름 */
function pickMaterial(done: (item: MaterialItem) => void): void {
openModal({
title: TEXT.pickTitle,
closeLabel: TEXT.pickClose,
dialogClass: "m02-modal m02-pick",
mount: (body, close) => {
const search = createInputField({ placeholder: TEXT.search, type: "search" });
const list = el("div", { className: "m02-pick__list" });
let seq = 0;
const paint = async (): Promise<void> => {
const mine = ++seq;
list.replaceChildren(el("p", { className: "m02-struct__empty", text: TEXT.searching }));
try {
const hits = await searchMaterials(search.input.value.trim());
if (mine !== seq) return;
list.replaceChildren(
...(hits.length
? hits.map((m) => {
const row = el("button", {
className: "m02-pick__item",
attrs: { type: "button", "data-material": m.코드 },
children: [
el("span", { className: "m02-pick__key", text: m.코드 }),
`${m.이름} ${m.규격} ${m.단위}`.replace(/\s+/g, " "),
],
});
row.addEventListener("click", () => {
done(m);
close();
});
return row;
})
: [el("p", { className: "m02-struct__empty", text: TEXT.noMatch })]),
);
} catch {
if (mine === seq) showToast(TEXT.searchFailed, "error");
}
};
let timer = 0;
search.input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void paint(), 250);
});
body.append(search.root, list);
void paint();
search.input.focus();
},
});
}
/** 옆 판 — 이 열에 자재 조합이 없어도 판은 뜸(더하면 새로 만듦) */
export async function buildMaterialPanel(opts: MaterialPanelOptions): Promise<HTMLElement> {
const base = await readColumnBase(opts.column);
let key = base.자재조합 ?? "";
let others = base.다른열자재조합;
let combo: (MaterialCombo & { 본뜬키?: string | null }) | null = null;
if (key) {
try {
combo = await fetchMaterialCombo(key);
} catch (error) {
showToast(fill(TEXT.loadFailed, { value: reasonOf(error) }), "error");
}
}
let saved: MaterialItem[] = combo?.담은자재 ?? [];
let items: MaterialItem[] = [...saved];
const sig = (list: MaterialItem[]): string => JSON.stringify(list.map((m) => m.코드));
const toggle = createButton({ label: "", variant: "ghost", size: "sm" });
const title = el("div", { className: "m02-mat__title" });
const table = el("div", { className: "m02-mat__table" });
const foot = el("div", { className: "m02-mat__foot" });
const body = el("div", { className: "m02-mat__body", children: [title, table, foot] });
const root = el("aside", { className: "m02-mat", children: [toggle, body] });
toggle.addEventListener("click", () => {
root.classList.toggle("is-collapsed");
paintToggle();
});
const paintToggle = (): void => {
const open = !root.classList.contains("is-collapsed");
toggle.querySelector(".ui-btn__label")!.textContent =
`${open ? "▸" : "◂"} ${TEXT.toggle} ${items.length}`;
};
const cellsOf = (code: string): string[] =>
opts
.links()
.filter((l) => (l.자재?.코드 ?? l.키) === code)
.map((l) => l.칸);
const paint = (): void => {
title.replaceChildren(
el("span", { text: key ? `${TEXT.combo} ${key} ${combo?.이름 ?? ""}`.trim() : TEXT.none }),
...(opts.isAdmin && combo?.본뜬키
? [
el("span", {
className: "m02-combo__origin",
text: fill(TEXT.branched, { value: combo.본뜬키 }),
}),
]
: []),
);
const head = el("div", {
className: "m02-mat__row m02-mat__row--head",
children: [TEXT.code, TEXT.name, TEXT.spec, TEXT.unit, TEXT.cell, ""].map((t) =>
el("span", { text: t }),
),
});
const rows = items.map((m, i) => {
const cells = cellsOf(m.코드);
const acts = el("span", { className: "m02-mat__acts" });
if (opts.isAdmin) {
const del = createButton({ label: TEXT.remove, variant: "ghost", size: "sm" });
del.addEventListener("click", async () => {
if (
cells.length &&
!(await showConfirmDialog(
fill(TEXT.unlinkAsk, { value: m.이름, cell: cells.join(", ") }),
TEXT.unlinkOk,
))
)
return;
items.splice(i, 1);
removed.push(m.코드);
paint();
});
acts.append(del);
}
return el("div", {
className: "m02-mat__row",
attrs: { "data-material": m.코드 },
children: [m.코드, m.이름, m.규격, m.단위, cells.join(", ")]
.map((t) => el("span", { text: t }))
.concat(acts),
});
});
table.replaceChildren(
head,
...(rows.length ? rows : [el("p", { className: "m02-struct__empty", text: TEXT.empty })]),
);
paintFoot();
paintToggle();
};
/** 이번에 뺀 자재 코드 — 적용하면 이은 칸 연결도 풂 */
let removed: string[] = [];
const paintFoot = (): void => {
if (!opts.isAdmin) return foot.replaceChildren();
const add = createButton({ label: TEXT.add, variant: "ghost", size: "sm" });
add.addEventListener("click", () =>
pickMaterial((m) => {
if (items.some((x) => x.코드 === m.코드)) return;
items.push(m);
removed = removed.filter((c) => c !== m.코드);
paint();
}),
);
const buttons = [add];
if (sig(items) !== sig(saved)) {
const apply = createButton({ label: TEXT.apply, variant: "filled", size: "sm" });
apply.addEventListener("click", async () => {
const message = others ? fill(TEXT.askBody, { n: others + 1 }) : TEXT.askAlone;
const name = await askBranch(
message,
`${combo?.이름 ?? ""} · ${opts.label}`.replace(/^ · /, ""),
);
if (name === null) return;
apply.disabled = true;
try {
const result = await branchMaterialCombo(opts.column, {
본뜬키: key,
담은자재: items,
이름: name,
});
branchToast(result);
if (removed.length) opts.unlink(removed);
removed = [];
key = result.키;
others = 0;
combo = await fetchMaterialCombo(key).catch(() => null);
saved = combo?.담은자재 ?? [...items];
items = [...saved];
paint();
} catch (error) {
apply.disabled = false;
showToast(fill(TEXT.failed, { value: reasonOf(error) }), "error");
}
});
const undo = createButton({ label: TEXT.undo, variant: "ghost", size: "sm" });
undo.addEventListener("click", () => {
items = [...saved];
removed = [];
paint();
});
buttons.push(apply, undo);
}
foot.replaceChildren(...buttons);
};
paint();
return root;
}