/* ============================================================================= * M02_MasterTemplete_Labor_Edit.ts * 일위대가 라이브러리 오른쪽 — 조합 목록(찾기) / 상세(이름 · 구분 · 상세구분 · 단위 · 비고 + 담은 로직 표 * (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서) + 미리 보기). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b). * 저장 · 지우기 = 고른 층 조합 표(마스터 UA · 회사 UC · 개인 UP · `Labor_Api`) · 읽기만 층은 단추 없음. * ========================================================================== */ import { createButton, createInputField, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { ApiError, type LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { deleteCombo, editCombo, fetchCombo, fetchLibLogics, newCombo, type Combo, type ComboSummary, type LaborScope, } from "./M02_MasterTemplete_Labor_Api"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { buildPreview } from "./M02_MasterTemplete_Labor_Preview"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; import "./M02_MasterTemplete_Labor.css"; const blankCombo = (path: string[] = []): Combo => ({ 키: "", 이름: "", 구분: path[0] ?? null, 상세구분: path[1] ?? null, 단위: null, 담은로직: [], 출처: "자체", 소유: "현장", 본뜬키: null, 비고: null, }); /** 서버가 준 까닭 — 422 는 검사 글 목록 · 409 는 낡은 판본 · 쓰는 곳 */ export function reason(error: unknown): string { if (error instanceof ApiError && typeof error.detail === "object" && error.detail) { const d = error.detail as { errors?: string[]; stale?: string[] }; // 서버 검사 글 머리(「파일.json · 키 」)는 떼고 보임 — 새로 만들 때 키는 아직 없는 번호라 헷갈림 if (d.errors) return d.errors .map((x) => x.replace(/^(\S+\.json · )?(U[AM]\d{6} |U[CP]\d+-\d{6} )?/, "")) .join(" / "); if (d.stale) return tc("Stale"); } return error instanceof Error ? error.message : tc("Load_Failed"); } export interface LaborEditOptions { scope: LaborScope; canEdit: boolean; /** 저장 · 지움 뒤 — 나무 · 목록을 다시 받고 그 조합(`key`)을 고름 */ onSaved: (key: string | null) => Promise; /** 저장 안 한 고침이 있나 — 페이지가 떠날 때 물음 */ onDirty: (dirty: boolean) => void; } export interface LaborEditor { root: HTMLElement; /** 나무가 받은 목록 — 상세가 안 열려 있으면 목록을 다시 그림 */ setList: (items: ComboSummary[]) => void; open: (key: string) => Promise; /** 나무 「조합 더하기」 — 그 폴더(구분 · 상세구분)를 채운 빈 조합 */ startNew: (path: string[]) => void; showList: () => void; isDirty: () => boolean; /** 열린 조합 키 — 목록이면 null · 새 조합이면 빈 글 */ current: () => string | null; } export function createLaborEditor(opt: LaborEditOptions): LaborEditor { const edit = opt.canEdit; let items: ComboSummary[] = []; let logics = new Map(); /** 화면을 바꾼 횟수 — 늦게 온 답은 이 값이 달라졌으면 버림 */ let nav = 0; /** `saved` = 연 때의 모양 — 「저장 안 한 고침」 판정 */ let current: { combo: Combo; version: string; saved: string } | null = null; let q = ""; const view = el("div", { className: "m01-logic__editor m01-combo m02-labor__edit" }); const find = createInputField({ type: "search", placeholder: tc("Search") }); find.input.addEventListener("input", () => { q = find.input.value.trim().toLowerCase(); if (!current) drawList(); }); const logicsReady = fetchLibLogics() .then((all) => { logics = new Map(all.map((x) => [x.키, x])); }) .catch((error) => showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"), ); const dirty = (): boolean => !!current && JSON.stringify(current.combo) !== current.saved; const markDirty = (): void => opt.onDirty(dirty()); /* --- 목록 --- */ const drawList = (): void => { nav++; current = null; opt.onDirty(false); const shown = items.filter((x) => !q || `${x.키} ${x.이름}`.toLowerCase().includes(q)); const heads = [ tc("Head_Key"), tc("Head_Name"), tc("Sub"), tc("Detail"), tc("Head_Unit"), tc("Head_Count"), tc("Head_Origin"), ]; 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), x.본뜬키 ?? tc("Origin_Self"), ].map((v) => el("td", { text: v, attrs: { title: v } })), }); tr.addEventListener("click", () => void open(x.키)); return tr; }); view.replaceChildren( el("div", { className: "m01-logic__section-head", children: [ find.root, ...(edit ? [createButton({ label: tc("New"), variant: "filled", onClick: () => startNew([]) })] : [el("span", { className: "m01-logic__muted", text: tc("ReadOnly") })]), ], }), 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(items.length ? "NoMatch" : "Empty") }), ); }; const open = async (key: string): Promise => { 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 startNew = (path: string[]): void => draw(blankCombo(path), ""); /* --- 상세 --- */ const draw = (combo: Combo, version: string, saved = JSON.stringify(combo)): void => { nav++; current = { combo, version, saved }; opt.onDirty(dirty()); const field = (label: string, name: "이름" | "구분" | "상세구분" | "단위" | "비고") => { const f = createInputField({ type: "text", label }); f.input.value = combo[name] ?? ""; f.input.readOnly = !edit; f.input.setAttribute("data-combo-field", name); if (name === "이름") f.input.maxLength = 60; // 서버 한도(`MAX_이름`)와 같음 f.input.addEventListener("input", () => { const v = f.input.value; if (name === "이름") combo.이름 = v; else combo[name] = v || null; markDirty(); }); 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(); markDirty(); }; const trs = combo.담은로직.map((row, i) => { const info = logics.get(row.로직); const memo = createInputField({ type: "text" }); memo.input.value = row.메모 ?? ""; memo.input.readOnly = !edit; memo.input.addEventListener("input", () => { row.메모 = memo.input.value || null; markDirty(); }); const small = (label: string, run: () => void): HTMLElement => createButton({ label, variant: "ghost", size: "sm", onClick: run }); const btns = el("div", { className: "m01-logic__chips", children: edit ? [ small(tc("Up"), () => move(i, -1)), small(tc("Down"), () => move(i, 1)), small(tc("Remove"), () => { combo.담은로직.splice(i, 1); drawTable(); markDirty(); }), ] : [], }); return el("tr", { attrs: { "data-combo-logic": row.로직 }, children: [ el("td", { text: String(i + 1) }), el("td", { text: row.로직 }), el("td", { text: info?.이름 ?? row.로직 }), 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(); markDirty(); }), }); const save = createButton({ label: tc("Save"), variant: "filled", onClick: async () => { if (!combo.이름.trim()) return showToast(tc("NeedName"), "error"); save.disabled = true; // 연달아 눌러도 한 번만 try { let key = combo.키; if (key) await editCombo(key, version, combo, opt.scope); else key = (await newCombo(combo, opt.scope)).key; showToast(tc("Saved"), "success"); current = null; // 저장함 — 다시 여는 동안 「고침 있음」 으로 안 물음 opt.onDirty(false); await opt.onSaved(key); await open(key); } catch (error) { showToast(reason(error), "error"); } finally { save.disabled = false; } }, }); const remove = createButton({ label: tc("Delete"), variant: "ghost", onClick: async () => { if ( !combo.키 || !(await showConfirmDialog(`${combo.키} ${combo.이름} — ${tc("DeleteAsk")}`)) ) return; try { await deleteCombo(combo.키, version, opt.scope); showToast(tc("Deleted"), "success"); current = null; await opt.onSaved(null); drawList(); } catch (error) { showToast(reason(error), "error"); } }, }); const back = createButton({ label: "←", variant: "ghost", onClick: async () => { if (dirty() && !(await showConfirmDialog(tc("BackAsk")))) return; drawList(); }, }); const origin = createInputField({ type: "text", label: tc("Head_Origin") }); origin.input.value = combo.본뜬키 ?? tc("Origin_Self"); origin.input.readOnly = true; origin.input.setAttribute("data-combo-origin", combo.본뜬키 ?? ""); view.replaceChildren( el("div", { className: "m01-logic__section-head", children: [ back, el("h3", { text: combo.키 || tc("New") }), ...(edit ? [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"), "비고"), origin.root, ], }), ], }), el("section", { className: "m01lab__box m01-logic__section", children: [ el("div", { className: "m01-logic__section-head", children: [el("h3", { text: tc("Logics_Title") }), ...(edit ? [add] : [])], }), table, ], }), preview.root, ); drawTable(); }; view.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Loading") })); return { root: view, setList: (next) => { items = next; if (!current) drawList(); }, open, startNew, showList: drawList, isDirty: dirty, current: () => current?.combo.키 ?? null, }; }