Files
Aislo/M01_MasterData/M01_MasterData_UI_Combo.ts
T

382 lines
12 KiB
TypeScript

/* =============================================================================
* M01_MasterData_UI_Combo.ts
* 일위대가 조합 화면 — 왼쪽 컨테이너(구분 · 상세구분 거름) / 오른쪽 목록 · 상세
* 상세 = 이름·구분·단위·비고 + 담은 로직 표(차례 · 키 · 이름 · 결과 단위 · 메모 · 순서) + 미리 보기
* 저장·지우기는 서버 조합 마스터(`일위대가조합.json` · 키 UA) — 길은 `Combo_Api` 한 곳
* ========================================================================== */
import {
createButton,
createInputField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { ApiError, fetchLogics, type LogicSummary } from "./M01_MasterData_UI_Logic_Api";
import {
deleteCombo,
editCombo,
fetchCombo,
fetchCombos,
newCombo,
type Combo,
type ComboSummary,
} from "./M01_MasterData_UI_Combo_Api";
import { openLogicPicker } from "./M01_MasterData_UI_Combo_Pick";
import { buildPreview } from "./M01_MasterData_UI_Combo_Preview";
import { tc } from "./M01_MasterData_UI_Combo_Text";
import type { SideHandle } from "./M01_MasterData_UI_Side";
import "./M01_MasterData_UI_Logic_Style.css";
import "./M01_MasterData_UI_LogicLab_Style.css";
const FILTER_ID = "조합|";
const blankCombo = (): Combo => ({
: "",
이름: "",
구분: null,
상세구분: null,
단위: null,
담은로직: [],
출처: "자체",
소유: "현장",
비고: null,
});
/** 서버가 준 까닭 — 422 는 검사 글 목록 · 409 는 낡은 판본 */
function reason(error: unknown): string {
if (error instanceof ApiError && typeof error.detail === "object" && error.detail) {
const d = error.detail as { errors?: string[]; stale?: string[] };
if (d.errors) return d.errors.join(" / ");
if (d.stale) return tc("Stale");
}
return error instanceof Error ? error.message : tc("Load_Failed");
}
export interface ComboHandle {
/** 컨테이너를 펼칠 때 — 목록을 새로 받아 그림 */
show: () => Promise<void>;
}
export function mountM01Combo(
host: HTMLElement,
side: SideHandle,
onOpenLogic: (key: string) => void,
): ComboHandle {
let items: ComboSummary[] = [];
let logics = new Map<string, LogicSummary>();
let pick = { sub: "", detail: "" };
/** 화면을 바꾼 횟수 — 늦게 온 답은 이 값이 달라졌으면 버림 */
let nav = 0;
/** 열어 둔 상세 — 다른 화면에 갔다 와도 그대로 돌아오게 기억 */
let current: { combo: Combo; version: string } | null = null;
let logicsReady: Promise<void> = Promise.resolve();
const filterHost = el("div");
side.comboHost.replaceChildren(filterHost);
const view = el("div", { className: "m01-logic__editor" });
host.replaceChildren(view);
/** 목록만 받음 — 로직 이름표(1300여 개)는 따로 받아 목록을 붙잡지 않음 */
const load = async (redraw = true): Promise<void> => {
const my = nav;
try {
items = await fetchCombos();
} catch (error) {
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
}
drawFilter();
if (redraw && my === nav) drawList();
};
const loadLogics = (): void => {
logicsReady = fetchLogics()
.then((all) => {
logics = new Map(all.map((x) => [x., x]));
})
.catch((error) =>
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
);
};
const show = async (): Promise<void> => {
if (!logics.size) loadLogics();
if (!items.length && !current) {
view.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Loading") }));
}
await load(false);
if (!current) drawList();
await logicsReady;
if (current) draw(current.combo, current.version);
};
/* --- 왼쪽 거름 --- */
const drawFilter = (): void => {
const subs = [...new Set(items.map((x) => x.구분).filter((v): v is string => !!v))].map(
(name) => ({
name,
book: null,
details: [...new Set(items.filter((x) => x.구분 === name).map((x) => x.상세구분))].filter(
(v): v is string => !!v,
),
}),
);
filterHost.replaceChildren(
side.filter({
id: FILTER_ID,
store: "m01.filter.조합",
subs,
total: items.length,
subLabel: tc("Sub"),
detailLabel: tc("Detail"),
onPick: (sub, detail) => {
pick = { sub, detail };
drawList();
},
}),
);
};
/* --- 목록 --- */
const toolbar = (...more: HTMLElement[]): HTMLElement =>
el("div", {
className: "m01-logic__section-head",
children: [
createButton({
label: tc("New"),
variant: "filled",
onClick: () => draw(blankCombo(), ""),
}),
...more,
],
});
const drawList = (): void => {
nav++;
current = null;
const shown = items.filter(
(x) => (!pick.sub || x.구분 === pick.sub) && (!pick.detail || x.상세구분 === pick.detail),
);
const heads = [
tc("Head_Key"),
tc("Head_Name"),
tc("Sub"),
tc("Detail"),
tc("Head_Unit"),
tc("Head_Count"),
];
const rows = shown.map((x) => {
const tr = el("tr", {
className: "m01-master__table-row",
attrs: { "data-combo-key": x. },
children: [x., x.이름, x.구분 ?? "", x.상세구분 ?? "", x.단위 ?? "", String(x.count)].map(
(v) => el("td", { text: v }),
),
});
tr.addEventListener("click", () => void open(x.));
return tr;
});
view.replaceChildren(
toolbar(),
shown.length
? el("div", {
className: "m01-master__grid-wrap",
children: [
el("table", {
className: "m01-master__grid",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: rows }),
],
}),
],
})
: el("p", { className: "m01-logic__muted", text: tc("Empty") }),
);
};
const open = async (key: string): Promise<void> => {
try {
const my = ++nav;
const one = await fetchCombo(key);
await logicsReady;
if (my !== nav) return;
draw(one.combo, one.version);
} catch (error) {
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
}
};
/* --- 상세 --- */
const draw = (combo: Combo, version: string): void => {
nav++;
current = { combo, version };
const saved = JSON.stringify(combo);
const field = (label: string, name: "이름" | "구분" | "상세구분" | "단위" | "비고") => {
const f = createInputField({ type: "text", label });
f.input.value = combo[name] ?? "";
f.input.setAttribute("data-combo-field", name);
f.input.addEventListener("input", () => {
const v = f.input.value;
if (name === "이름") combo.이름 = v;
else combo[name] = v || null;
});
return f.root;
};
const preview = buildPreview(() => combo);
const table = el("div", { className: "m01-master__grid-wrap" });
const drawTable = (): void => {
const move = (i: number, d: number): void => {
const j = i + d;
if (j < 0 || j >= combo.담은로직.length) return;
[combo.담은로직[i], combo.담은로직[j]] = [combo.담은로직[j], combo.담은로직[i]];
drawTable();
};
const trs = combo.담은로직.map((row, i) => {
const info = logics.get(row.로직);
const memo = createInputField({ type: "text" });
memo.input.value = row.메모 ?? "";
memo.input.addEventListener("input", () => (row.메모 = memo.input.value || null));
const name = el("button", {
className: "ui-btn ui-btn--ghost",
text: info?.이름 ?? row.로직,
attrs: { type: "button", title: tc("Open") },
});
name.addEventListener("click", async () => {
if (JSON.stringify(combo) !== saved && !(await showConfirmDialog(tc("LeaveAsk")))) return;
onOpenLogic(row.로직);
});
const btns = el("div", {
className: "m01-logic__chips",
children: [
createButton({ label: tc("Up"), variant: "ghost", onClick: () => move(i, -1) }),
createButton({ label: tc("Down"), variant: "ghost", onClick: () => move(i, 1) }),
createButton({
label: tc("Remove"),
variant: "ghost",
onClick: () => {
combo.담은로직.splice(i, 1);
drawTable();
},
}),
],
});
return el("tr", {
attrs: { "data-combo-logic": row.로직 },
children: [
el("td", { text: String(i + 1) }),
el("td", { text: row.로직 }),
el("td", { children: [name] }),
el("td", { text: info?.결과단위 ?? "" }),
el("td", { children: [memo.root] }),
el("td", { children: [btns] }),
],
});
});
const heads = [
tc("Col_Order"),
tc("Col_Key"),
tc("Col_Name"),
tc("Col_Unit"),
tc("Col_Memo"),
tc("Col_Move"),
];
table.replaceChildren(
el("table", {
className: "m01-master__grid",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: trs }),
],
}),
...(trs.length ? [] : [el("p", { className: "m01-logic__muted", text: tc("NoLogic") })]),
);
void preview.redraw();
};
const add = createButton({
label: tc("AddLogic"),
variant: "filled",
onClick: () =>
openLogicPicker((x) => {
if (!logics.has(x.)) logics.set(x., x);
combo.담은로직.push({ 로직: x.키, 메모: null });
drawTable();
}),
});
const save = createButton({
label: tc("Save"),
variant: "filled",
onClick: async () => {
if (!combo.이름.trim()) return showToast(tc("NeedName"), "error");
const my = nav;
try {
const key = combo. ? combo. : (await newCombo(combo)).key;
if (combo.) await editCombo(combo., version, combo);
showToast(tc("Saved"), "success");
await load(false);
if (my !== nav) {
if (current?.combo === combo) current = null; // 저장 사이에 나감 — 옛 판본을 붙들지 않음
return;
}
await open(key);
} catch (error) {
showToast(reason(error), "error");
}
},
});
const remove = createButton({
label: tc("Delete"),
variant: "ghost",
onClick: async () => {
if (!combo. || !(await showConfirmDialog(tc("DeleteAsk")))) return;
try {
await deleteCombo(combo., version);
showToast(tc("Deleted"), "success");
await load();
} catch (error) {
showToast(reason(error), "error");
}
},
});
const back = createButton({ label: "←", variant: "ghost", onClick: () => drawList() });
view.replaceChildren(
el("div", {
className: "m01-logic__section-head",
children: [back, el("h3", { text: combo.키 || tc("New") }), remove, save],
}),
el("section", {
className: "m01lab__box m01-logic__section",
children: [
el("div", {
className: "m01lab__info",
children: [
field(tc("Head_Name"), "이름"),
field(tc("Sub"), "구분"),
field(tc("Detail"), "상세구분"),
field(tc("Head_Unit"), "단위"),
field(tc("Head_Note"), "비고"),
],
}),
],
}),
el("section", {
className: "m01lab__box m01-logic__section",
children: [
el("div", {
className: "m01-logic__section-head",
children: [el("h3", { text: tc("Logics_Title") }), add],
}),
table,
],
}),
preview.root,
);
drawTable();
};
return { show };
}