- 가정하던 서버 함수를 실제 branchCombo · branchMaterialCombo · fetchMaterialCombo(M02 Api_Fetch)로 바꾸고 가정 파일은 열 조합 읽기만 남김 - 재료산출 알약 막대 자재 항목을 그 열 자재 조합 담은 자재로 · 알약 더하기 · 빼기(onItemsChange)와 옆 판 표를 같은 목록으로 맞춤 - 옆 판 줄을 누르면 이은 알약을 눌러 그 칸으로 이동(깜빡임) · 옆 판에서 고치면 알약 막대만 다시 만듦(시트 고침 유지) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NWHNLQYPZN7kKf1rzrrRZq
323 lines
12 KiB
TypeScript
323 lines
12 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 { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links";
|
|
import type { CellLink } from "../A00_Common/spreadsheet/spreadsheet_types";
|
|
import {
|
|
branchMaterialCombo,
|
|
fetchMaterialCombo,
|
|
type MaterialCombo,
|
|
type MaterialItem,
|
|
} from "./M02_MasterTemplete_Api_Fetch";
|
|
import { askBranch, branchToast } from "./M02_MasterTemplete_Combo";
|
|
import { readColumnBase, reasonOf } 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 MaterialState {
|
|
key: string;
|
|
others: number;
|
|
combo: MaterialCombo | null;
|
|
}
|
|
|
|
export async function loadMaterialState(column: string): Promise<MaterialState> {
|
|
const base = await readColumnBase(column);
|
|
const key = base.자재조합 ?? "";
|
|
let combo: MaterialCombo | null = null;
|
|
if (key) {
|
|
try {
|
|
combo = await fetchMaterialCombo(key);
|
|
} catch (error) {
|
|
showToast(fill(TEXT.loadFailed, { value: reasonOf(error) }), "error");
|
|
}
|
|
}
|
|
return { key, others: base.다른열자재조합, combo };
|
|
}
|
|
|
|
/** 알약 막대 자재 항목 ↔ 담은 자재 — 알약 키 = 자재 코드 */
|
|
export const toPills = (items: MaterialItem[]): LinkItem[] =>
|
|
items.map((m) => {
|
|
const 자재 = {
|
|
코드: m.코드,
|
|
이름: m.이름 ?? "",
|
|
규격: m.규격 ?? "",
|
|
단위: m.단위 ?? "",
|
|
};
|
|
return { 키: m.코드, 이름: 자재.이름, 단위: 자재.단위, 자재 };
|
|
});
|
|
|
|
export interface MaterialPanel {
|
|
root: HTMLElement;
|
|
/** 알약 막대에서 목록이 바뀜 — 표만 다시 그림(알약은 이미 맞음) */
|
|
setItems: (items: MaterialItem[]) => void;
|
|
}
|
|
|
|
export interface MaterialPanelOptions {
|
|
/** 집계표 열 id */
|
|
column: string;
|
|
state: MaterialState;
|
|
/** 표에서 더하기 · 빼기 · 되돌리기 — 알약 막대를 이 목록으로 맞춤 */
|
|
onChange: (items: MaterialItem[]) => void;
|
|
/** 이은 칸이 있는 줄을 누름 — 알약을 눌러 그 칸으로 감(깜빡임) */
|
|
goTo: (code: string) => void;
|
|
/** 구조물 이름 — 새 이름 자동 미리 채움 */
|
|
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 function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel {
|
|
let key = opts.state.key;
|
|
let others = opts.state.others;
|
|
let combo = opts.state.combo;
|
|
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.이름 ?? m.코드, cell: cells.join(", ") }),
|
|
TEXT.unlinkOk,
|
|
))
|
|
)
|
|
return;
|
|
items.splice(i, 1);
|
|
removed.push(m.코드);
|
|
paint();
|
|
opts.onChange(items);
|
|
});
|
|
acts.append(del);
|
|
}
|
|
const row = el("div", {
|
|
className: `m02-mat__row${cells.length ? " is-linked" : ""}`,
|
|
attrs: { "data-material": m.코드 },
|
|
children: [m.코드, m.이름 ?? "", m.규격 ?? "", m.단위 ?? "", cells.join(", ")]
|
|
.map((t) => el("span", { text: t }))
|
|
.concat(acts),
|
|
});
|
|
if (cells.length) row.addEventListener("click", () => opts.goTo(m.코드));
|
|
return row;
|
|
});
|
|
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();
|
|
opts.onChange(items);
|
|
}),
|
|
);
|
|
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();
|
|
opts.onChange(items);
|
|
});
|
|
buttons.push(apply, undo);
|
|
}
|
|
foot.replaceChildren(...buttons);
|
|
};
|
|
|
|
paint();
|
|
return {
|
|
root,
|
|
setItems: (list) => {
|
|
items = [...list];
|
|
paint();
|
|
},
|
|
};
|
|
}
|