/* ============================================================================= * M01_MasterData_UI_MachineDetail.ts * M01 기계 상세 페이지(PLAN 821-2 · 요구 790-2 · 791) — 기계 양식 부품 + 원문번호 · 원문 · 구분 · * 상세구분 · 별칭 · 비고 · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인). * 고침은 초안(sessionStorage)에 바로 쌓임 → 페이지 [저장] 한 번에 서버로(자동저장 없음) · * 서버가 계수를 계수 짝으로 다시 셈 · 단위 · 출처 칸을 채움(`M01_MasterData_Machine.fix`). * 대상 = 키(EQ…) · 저장 전 새 줄 `add:<차례>` · 새 기계 `new`(비슷한 기계에서 시작이 펼쳐짐). * ========================================================================== */ import { createButton, createInputField, createSelectField, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createMachineForm, type MachineSimilar } from "@ui/ui_template_machine_form"; import { createText } from "@ui/ui_template_text"; import { setLeaveGuard } from "../A00_Common/router"; import type { Row } from "./M01_MasterData_Api_Fetch"; import { addRow, editRow, peek, removeAdd, setAdd, toggleDelete } from "./M01_MasterData_Draft"; import { fetchMachineLists, fetchMachineRow, fetchMachineUses, findMachines, type MachineLists, } from "./M01_MasterData_Machine_Api"; import type { Pick } from "./M01_MasterData_UI_Side"; export interface MachineDetailOptions { target: string; pick: Pick; back: () => void; /** 새 기계가 초안 새 줄이 되면 — 주소를 `add:<차례>` 로 */ onAdded?: (target: string) => void; } const META = ["원문번호", "원문", "구분", "상세구분", "별칭", "비고"] as const; type Meta = Record<(typeof META)[number], string>; const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v)); /** 돌려받은 함수 = 걷기 */ export function openMachineDetail(host: HTMLElement, opts: MachineDetailOptions): () => void { let alive = true; const stop = (): void => { alive = false; setLeaveGuard(null); }; void build(host, opts, () => alive).catch((error) => { showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); opts.back(); }); return stop; } async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () => boolean) { const file = opts.pick.file.file; const lists = await fetchMachineLists(); let version = opts.pick.file.version; let key = ""; let addAt: number | null = null; let orig: Row | null = null; let cur: Row; if (opts.target.startsWith("add:")) { addAt = Number(opts.target.slice(4)); const row = peek(file)?.adds[addAt]; if (!row) throw new Error(L("M01_MachineNotFound")); cur = row; } else if (opts.target === "new") { cur = blankRow(lists); } else { key = opts.target; const got = await fetchMachineRow(key); version = peek(file)?.version ?? got.version; orig = got.row; cur = peek(file)?.edits[key] ?? got.row; } if (!alive()) return; let base: Row = orig ?? cur; // 줄 칸 차례 — 고침은 원래 줄 · 새 줄은 지금 줄 const meta: Meta = Object.fromEntries(META.map((m) => [m, text(cur[m])])) as Meta; const metaOrig: Meta = Object.fromEntries(META.map((m) => [m, text((orig ?? {})[m])])) as Meta; const isNew = !key; /* --- 비슷한 기계 찾기(양식이 고르면 값 · 이름 · 규격 복사 · 여기서 원문 · 구분 복사) --- */ const found = new Map(); const findSimilar = async (q: string): Promise => { const rows = await findMachines(q); return rows.map((r) => { found.set(String(r["키"]), r); return { key: String(r["키"]), name: text(r["이름"]), spec: text(r["규격"]), tag: text(r["상세구분"] || r["구분"]), values: (r["값"] ?? {}) as Row, }; }); }; const notice = el("div"); const form = createMachineForm({ name: text(cur["이름"]), spec: text(cur["규격"]), values: (cur["값"] ?? {}) as Row, coefMode: "pumsem", fuels: lists.fuels, jobs: lists.jobs, power: lists.power, hour: lists.hour, findSimilar, onSimilar: (item) => { const r = found.get(item.key); if (!r || !isNew) return; base = { ...blankRow(lists), ...pickMeta(r), 값: r["값"] ?? {}, 비고: r["비고"] ?? null }; for (const m of ["원문", "구분", "상세구분"] as const) meta[m] = text(r[m]); paintMeta(); }, onChange: () => commit(), }); /** 양식 · 머리 칸 → 초안(이름 빈 칸 · 숫자 아닌 칸이 있으면 안 넣음) */ const commit = (): void => { const s = form.state(); const errors = [ ...s.errors, ...(s.name ? [] : [`${L("MF_Name")} · ${L("M01_MachineErrors")}`]), ]; notice.replaceChildren(...errors.map((e) => createText(e, "error"))); if (errors.length) return; const row: Row = { ...base, ...cur, 이름: s.name, 규격: s.spec, 값: s.values }; for (const m of META) { if (orig && meta[m] === metaOrig[m]) { if (m in orig) row[m] = orig[m]; else delete row[m]; // 안 고친 칸 = 원래 그대로 } else if (meta[m]) row[m] = meta[m]; else if (m === "별칭") delete row[m]; // 빈 별칭 = 칸 없음 else row[m] = m === "비고" ? null : ""; } const ordered: Row = Object.fromEntries( [...Object.keys(base), ...Object.keys(row)] .filter((k, i, all) => all.indexOf(k) === i && k in row) .map((k) => [k, row[k]]), ); if (key && orig) editRow(file, version, key, orig, ordered); else if (addAt !== null) setAdd(file, addAt, ordered); else { addRow(file, version, { 키: "", ...ordered }); addAt = (peek(file)?.adds.length ?? 1) - 1; opts.onAdded?.(`add:${addAt}`); } cur = ordered; checkSame(s.name, s.spec); }; /* --- 같은 이름 · 규격 알림 --- */ let sameTimer: number | undefined; const checkSame = (name: string, spec: string): void => { window.clearTimeout(sameTimer); sameTimer = window.setTimeout(async () => { const rows = name ? await findMachines(name).catch(() => [] as Row[]) : []; const hit = rows.find( (r) => r["키"] !== key && text(r["이름"]) === name && text(r["규격"]) === spec, ); form.setNotice(hit ? L("M01_MachineSame").replace("{value}", text(hit["키"])) : ""); }, 300); }; /* --- 머리 칸 --- */ const metaBox = el("div", { className: "m01-machine-detail__meta" }); const paintMeta = (): void => { const field = (m: (typeof META)[number], label: string): HTMLElement => { const options = m === "원문" ? lists.books : m === "구분" ? Object.keys(lists.groups) : null; const mark = (root: HTMLElement): void => { root.classList.toggle("is-changed", !isNew && meta[m] !== metaOrig[m]); }; if (options) { const list = options.includes(meta[m]) || !meta[m] ? options : [meta[m], ...options]; const f = createSelectField({ label, options: list.map((v) => ({ value: v, text: v })), value: meta[m], size: 28, }); f.select.addEventListener("change", () => { meta[m] = f.select.value; if (m === "구분" && lists.groups[meta[m]]) meta["원문"] = lists.groups[meta[m]]; mark(f.root); commit(); if (m === "구분") paintMeta(); }); mark(f.root); return f.root; } const f = createInputField({ label, value: meta[m], size: 28 }); f.input.addEventListener("input", () => { meta[m] = f.input.value; mark(f.root); commit(); }); mark(f.root); return f.root; }; metaBox.replaceChildren( field("원문번호", L("M01_MachineSourceNo")), field("원문", "원문"), field("구분", "구분"), field("상세구분", L("M01_MachineDetailGroup")), field("별칭", "별칭"), field("비고", "비고"), ); }; paintMeta(); /* --- 쓰는 곳 --- */ const usesBox = el("section", { className: "m01-machine-detail__uses" }); let uses: string[] = []; const paintUses = (): void => { usesBox.replaceChildren( el("strong", { text: `${L("M01_MachineUses")} ${uses.length}` }), uses.length ? el("ul", { children: uses.map((u) => el("li", { text: u })) }) : createText(L("M01_MachineNoUses")), ); }; if (key) void fetchMachineUses(key) .then((got) => { uses = got; paintUses(); }) .catch(() => paintUses()); else paintUses(); /* --- 단추 · 떠남 --- */ const dirty = (): boolean => addAt !== null || Boolean(key && peek(file)?.edits[key]); const leave = async (): Promise => !dirty() || (await showConfirmDialog(L("M01_MachineLeave"))); setLeaveGuard(leave); const backBtn = createButton({ label: L("M01_MachineBack"), variant: "ghost", iconStart: "chevronLeft", }); backBtn.addEventListener("click", async () => { if (await leave()) opts.back(); }); const del = createButton({ label: L("M01_MachineDelete"), variant: "danger-ghost" }); del.addEventListener("click", async () => { const ask = L("M01_MachineDeleteAsk").replace("{value}", String(uses.length)); if (!(await showConfirmDialog(uses.length ? `${ask}\n${uses.join("\n")}` : ask))) return; if (key) toggleDelete(file, version, key); else if (addAt !== null) removeAdd(file, addAt); setLeaveGuard(null); opts.back(); }); if (!key && addAt === null) del.hidden = true; const title = isNew ? L("M01_MachineNew") : `${text(cur["이름"])} ${text(cur["규격"])}`.trim(); host.replaceChildren( el("div", { className: "m01-machine-detail", children: [ el("div", { className: "ui-toolbar m01-master__bar", children: [ backBtn, el("strong", { text: title }), el("span", { className: "ui-text ui-text--muted", text: key }), del, ], }), notice, metaBox, form.root, usesBox, ], }), ); if (opts.target === "new") host.querySelector("details")?.setAttribute("open", ""); } /** 새 기계 바탕 — 칸 차례는 기계 줄과 같음 · 단위 · 출처는 서버가 채움 */ function blankRow(lists: MachineLists): Row { const group = Object.keys(lists.groups)[0] ?? ""; return { 원문번호: "", 원문: lists.groups[group] ?? lists.books[0] ?? "", 구분: group, 상세구분: "", 이름: "", 규격: "", 단위: {}, 값: {}, 출처: {}, 비고: null, }; } /** 고른 기계의 원문 · 구분 · 상세구분(새 기계 바탕) */ const pickMeta = (r: Row): Row => ({ 원문: r["원문"], 구분: r["구분"], 상세구분: r["상세구분"] });