Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Materials.ts
T

326 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, branchName, 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: "담은 자재가 없음",
noItems: "담은 자재가 없어 적용 안 됨 — 자재를 하나 이상 담음",
already: "이미 담은 자재 — {value}",
unlinkAsk: "{value} 자재는 {cell} 칸에 이어져 있음 — 빼면 연결도 풂",
unlinkOk: "빼고 연결 풂",
loadFailed: "자재 조합을 못 읽음 — {value}",
askBody:
"이 자재 조합은 열 {n} 개와 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀 · 원래 조합은 그대로",
askFirst: "이 열에 새 자재 조합을 만듦 — 담은 자재 목록으로 새 조합을 만들고 이 열이 가리킴",
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;
/** 연결이 바뀜(이음 · 풂 · 되돌리기) — 이은 칸을 다시 그림 */
refresh: () => 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;
}
/** 자재 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름(좌측 자재 조합 상세도 씀) */
export 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);
if (cells.length) opts.unlink([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();
};
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 showToast(fill(TEXT.already, { value: m.코드 }), "info");
items.push(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 () => {
if (!items.length) return showToast(TEXT.noItems, "error");
const message = !key
? TEXT.askFirst
: others
? fill(TEXT.askBody, { n: others + 1 })
: TEXT.askAlone;
const name = await askBranch(message, branchName(combo?.이름 ?? "", opts.label));
if (name === null) return;
apply.disabled = true;
try {
const result = await branchMaterialCombo(opts.column, {
본뜬키: key,
담은자재: items,
이름: name,
});
branchToast(result);
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];
paint();
opts.onChange(items);
});
buttons.push(apply, undo);
}
foot.replaceChildren(...buttons);
};
paint();
return {
root,
setItems: (list) => {
items = [...list];
paint();
},
refresh: paint,
};
}