/* ============================================================================= * M01_MasterData_UI_Alias.ts * M01 별칭 모아 보기(PLAN 815-2 · 요구 764 · 765) — 본 칸 · 시스템 관리자만. * · 위 = 「승인 대기 n」 — 일반 사용자 제안(가져온 이름 · 규격 → 우리 요소) · [승인] = 그 요소 별칭에 * 콤마로 더함 · [반려] = 지움(둘 다 확인 창 뒤 바로 서버) * · 아래 = 별칭이 적힌 줄 전부(인력 · 기계 · 재료 · 유가 · 단가산출 기초 — 수기로 넣은 것도 바로) · * 갈래 거름 · 찾기 · 별칭 칸 눌러 고침(콤마로 여러 개) → [저장](그 줄 별칭만 · `/api/m01/alias/save`) · * 줄을 누르면 그 요소 화면 그 줄 · 단가산출 기초는 로직 탭 * · 서버 = `M01_MasterData_Alias.py` · 조각 나누기 = 서버(`master_alias.names`) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createBadge } from "@ui/ui_template_badge"; import { askConfirm, createButton, el, showToast } from "@ui/ui_template_elements"; import { createInputField } from "@ui/ui_template_input"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { createPanelHead } from "@ui/ui_template_panel_head"; import { createSegment } from "@ui/ui_template_segment"; import { createTable, type Column } from "@ui/ui_template_table"; import { startEdit } from "./M01_MasterData_UI_Cells"; export const ALIAS_TITLE = "별칭 모아 보기"; const KINDS = ["인력", "기계", "재료", "유가", "단가산출 기초"]; export interface AliasRow { 갈래: string; 파일: string; 키: string; 이름: string; 규격: string; 별칭: string; 조각: { 글: string; 승인: boolean }[]; 들어옴: "수기" | "승인"; } interface Waiting { id: string; 갈래: string; 키: string; 이름: string; 규격: string; 단위: string; 별칭: string; 누가?: { 이름?: string } | null; 때?: string; } interface Board { 줄: AliasRow[]; 대기: Waiting[]; } async function call(path: string, body?: unknown): Promise { const res = await fetch(`${API_BASE_URL}/m01/alias${path}`, { method: body === undefined ? "GET" : "POST", credentials: "include", headers: { "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); const got = (await res.json()) as T & { detail?: unknown }; if (!res.ok) throw new Error(String(got.detail ?? `HTTP ${res.status}`)); return got; } const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const WAIT_COLS: Column[] = [ { label: "갈래", width: "96px" }, { label: "가져온 이름 · 규격" }, { label: "단위", width: "64px" }, { label: "우리 요소" }, { label: "누가 · 때", width: "180px" }, { label: "", width: "136px" }, ]; const ROW_COLS: Column[] = [ { label: "갈래", width: "96px" }, { label: "키", width: "96px" }, { label: "이름 · 규격" }, { label: "별칭" }, { label: "들어옴", width: "72px" }, ]; /** `open` = 줄을 눌렀을 때(왼쪽 판이 그 요소 화면 · 로직 탭으로) · `setDirty` = 저장 안 한 고침 표시 */ export function buildAliasBoard(opts: { open: (row: AliasRow) => void; setDirty?: (dirty: boolean) => void; }): HTMLElement { const root = el("div", { className: "m01-alias", attrs: { "data-alias-board": "" } }); const waitHost = el("div"); const listHost = el("div"); const edits = new Map(); let board: Board = { 줄: [], 대기: [] }; let kind = ""; let q = ""; const save = createButton({ label: "저장", variant: "filled", disabled: true }); const undo = createButton({ label: "되돌림", variant: "ghost", disabled: true }); const mark = (): void => { save.disabled = undo.disabled = edits.size === 0; opts.setDirty?.(edits.size > 0); }; async function decide(w: Waiting, ok: boolean): Promise { const yes = await askConfirm({ title: ok ? "별칭 승인" : "제안 반려", message: ok ? `「${w.별칭}」 → ${w.키}\n그 요소 별칭에 더함(다음 불러오기부터 이어짐).` : `「${w.별칭}」 → ${w.키}\n반려하면 제안이 지워짐.`, okLabel: ok ? "승인" : "반려", danger: !ok, }); if (!yes) return; try { board = await call(ok ? "/approve" : "/reject", { id: w.id }); showToast(ok ? "별칭에 더함" : "반려함", "success"); paint(); } catch (error) { showToast(`${ok ? "승인" : "반려"} 못 함 — ${why(error)}`, "error"); } } const target = (key: string): string => { const one = board.줄.find((r) => r.키 === key); return one ? `${key} · ${one.이름} ${one.규격}`.trim() : key; }; function paintWaiting(): void { const rows = board.대기.map((w) => [ w.갈래, `${w.이름} ${w.규격}`.trim(), w.단위, target(w.키), `${w.누가?.이름 ?? ""} · ${(w.때 ?? "").slice(0, 16).replace("T", " ")}`, el("div", { className: "ui-toolbar", children: [ createButton({ label: "승인", variant: "filled", size: "sm", onClick: () => void decide(w, true), }), createButton({ label: "반려", variant: "ghost", size: "sm", onClick: () => void decide(w, false), }), ], }), ]); waitHost.replaceChildren( createPanelHead({ title: `승인 대기 ${board.대기.length}`, sub: "M02 에서 못 이은 줄을 사용자가 우리 요소로 고른 제안 · 승인 = 그 요소 별칭에 더함", }).root, createTable(WAIT_COLS, rows, { density: "data", empty: "승인 대기 없음" }), ); } function aliasCell(r: AliasRow): HTMLElement { const box = el("div", { className: "ui-toolbar", attrs: { "data-alias-cell": r.키 } }); const edited = edits.get(r.키); if (edited !== undefined) box.append(createBadge(edited || "(지움)", "warning")); else for (const p of r.조각) box.append(createBadge(p.글, p.승인 ? "success" : "neutral")); box.title = "눌러 고침 — 콤마로 여러 개"; box.addEventListener("click", (e) => { e.stopPropagation(); startEdit(box, edited ?? r.별칭, (text) => { if (text.trim() === r.별칭.trim()) edits.delete(r.키); else edits.set(r.키, text.trim()); mark(); paintList(); }); }); return box; } function paintList(): void { const words = q.toLowerCase().split(/\s+/).filter(Boolean); const shown = board.줄.filter( (r) => (!kind || r.갈래 === kind) && words.every((w) => `${r.키} ${r.이름} ${r.규격} ${r.별칭}`.toLowerCase().includes(w)), ); const table = createTable( ROW_COLS, shown.map((r) => [ r.갈래, r.키, `${r.이름} ${r.규격}`.trim(), aliasCell(r), createBadge(r.들어옴, r.들어옴 === "승인" ? "success" : "neutral"), ]), { density: "data", empty: "별칭이 적힌 줄 없음" }, ); table.querySelectorAll("tbody tr").forEach((tr, i) => { const r = shown[i]; if (!r) return; tr.setAttribute("data-key", r.키); tr.addEventListener("click", () => opts.open(r)); }); listHost.replaceChildren(table); } function paint(): void { paintWaiting(); paintList(); } async function load(): Promise { const stop = showLoading(listHost); try { board = await call(""); paint(); } catch (error) { listHost.replaceChildren(loadingError(why(error), () => void load())); } finally { stop(); } } save.addEventListener("click", async () => { save.disabled = true; try { for (const [key, text] of [...edits]) { board = await call("/save", { 키: key, 별칭: text }); edits.delete(key); } showToast("별칭 저장함", "success"); } catch (error) { showToast(`저장 못 함 — ${why(error)}`, "error"); } finally { mark(); paint(); } }); undo.addEventListener("click", () => { edits.clear(); mark(); paintList(); }); const kinds = createSegment({ options: [{ value: "", label: "전체" }, ...KINDS.map((k) => ({ value: k, label: k }))], value: kind, onChange: (v) => { kind = v; paintList(); }, ariaLabel: "갈래", }); const find = createInputField({ type: "search", placeholder: "키 · 이름 · 별칭 찾기", compact: true, onInput: (v) => { q = v.trim(); paintList(); }, }); const listHead = createPanelHead({ title: "별칭이 적힌 줄", sub: "수기로 넣은 것 · 승인한 것 모두 · 줄을 누르면 그 요소 화면 · 별칭 칸을 눌러 고친 뒤 [저장]", }); root.append( waitHost, listHead.root, el("div", { className: "ui-toolbar", children: [kinds.root, find.root, undo, save] }), listHost, ); void load(); return root; }