/* ============================================================================= * M02_MasterTemplete_Hopyo_MatPick.ts * 불러오기 「고를 재료」 판(PLAN 827-3 · 요구 811-2 · 812-1) — 단가산출 목록(`Hopyo_SheetSk`) 머리에 붙음. * · 그 파일 재료 줄을 종(이름 · 규격 · 단위 · 단서)으로 묶어 판정 · 쓰는 블록 수 · 단서 글 · 후보(엔진 차례 · 1 순위) * = 서버 `…/hopyo-materials/{키}`(첫 판 = 블록 셈 한 번 · 그 뒤 저장본) * · 후보 고름 · [찾기](재료 찾기 창 `openStackPick`) → 그 파일 같은 종 줄 전부 · [고름 풀기] * · 「우리 표에 없음」 = 안 골라도 됨(가져온 값) · 이어진 재료 = 접힌 목록(방법 · 단서 — 틀린 이음도 같은 길로 바꿈) * · 고름 = 화면 캐시 → [저장](개인 층 그 파일 자리 · 이미 만든 단가산출 n 건 적용은 물어 고를 때만) · * 저장 전 블록을 만들면 그 고름째 만듦(`pending` → from-hopyo 몸 · 서버가 저장) · 별칭 · 마스터에 안 씀 * ========================================================================== */ import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; import { attachCollapsible, createCollapsibleHead } from "@ui/ui_template_collapsible"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import { createSelectField } from "@ui/ui_template_input"; import { createListRow } from "@ui/ui_template_list_row"; import { askChoice } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; import { MAT_PICKED } from "../A00_Common/line_stack_material"; import { openStackPick } from "../A00_Common/line_stack_pick"; import { call } from "./M02_MasterTemplete_Api_Fetch"; import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; export interface MatCand { 키: string; 이름: string; 규격: string; 단위: string; 단서?: string; } interface MatKind { 종: string; 이름: string; 규격: string; 단위: string; 판정: string; 방법: string; 키: string | null; 후보: MatCand[]; 단서: string[]; 별도계상: boolean; 블록: number[]; 줄: number; 고름?: MatCand; } interface MatBoard { 지문: string; 파일: string; 종: MatKind[]; 만든: string[]; } export type MatPicks = Record; const LINKED = "이어짐"; const TONE: Record = { [LINKED]: "success", "우리 표에 없음": "neutral" }; const enc = encodeURIComponent; const why = (e: unknown): string => (e instanceof Error ? e.message : String(e)); const label = (c: { 이름: string; 규격: string }): string => `${c.이름} ${c.규격}`.trim(); export function createMatPick(opt: { scope: HopyoScope; key: string; target: HopyoScope }) { const to = `?to=${opt.target.layer}${opt.target.owner ? `&to_owner=${enc(opt.target.owner)}` : ""}`; const url = at(opt.scope, `/hopyo-materials/${enc(opt.key)}${to}`); const title = createCollapsibleHead({ title: "고를 재료", sub: "고른 것은 이 파일 안에서만 · 별칭 아님 · 「우리 표에 없음」은 안 골라도 됨" }); // prettier-ignore const body = el("div", { className: "m02-hopyo-matpick__body" }); const root = el("section", { className: "ui-collapsible m02-hopyo-matpick", attrs: { "data-matpick": opt.key }, children: [title.root, body], }); attachCollapsible(root); document.addEventListener(MAT_PICKED, () => asked && root.isConnected && void load()); // SK 상세에서 같은 파일 고름 let board: MatBoard | null = null; let asked = false; let busy = false; let linkedOpen = false; const edits = new Map(); const now = (k: MatKind): MatCand | null => edits.has(k.종) ? (edits.get(k.종) ?? null) : (k.고름 ?? null); const set = (k: MatKind, c: MatCand | null): void => { if ((c?.키 ?? null) === (k.고름?.키 ?? null)) edits.delete(k.종); else edits.set(k.종, c); draw(); }; function controls(k: MatKind): HTMLElement { const mine = now(k); const parts: HTMLElement[] = []; if (k.후보.length) { const pick = createSelectField({ options: [ { value: "", text: mine ? `고름: ${label(mine)}` : "후보에서 고름" }, ...k.후보.map((c, i) => ({ value: String(i), text: `${i ? "" : "1 순위 · "}${label(c)} ${c.단위 ? `(${c.단위})` : ""}`.trim(), })), ], compact: true, size: 24, ariaLabel: "후보에서 고름", onChange: (v) => v && set(k, k.후보[Number(v)]), }); pick.select.dataset.matCands = k.종; parts.push(pick.root); } const find = createButton({ label: "찾기", variant: "ghost", size: 24, iconStart: "search", onClick: () => openStackPick( "재료", (p) => set(k, { 키: p.키, 이름: p.이름, 규격: p.규격 ?? "", 단위: p.단위 ?? "" }), undefined, k.이름, ), }); find.dataset.matFind = k.종; parts.push(find); if (mine) { const tag = createBadge(`고름: ${label(mine)} · ${k.블록.length} 블록에 적용`, "info", { size: 18 }); // prettier-ignore tag.dataset.matPicked = k.종; const undo = createButton({ label: "고름 풀기", variant: "ghost", size: 24, iconStart: "undo", onClick: () => set(k, null) }); // prettier-ignore undo.dataset.matUnpick = k.종; parts.push(tag, undo); } return el("div", { className: "ui-toolbar", children: parts }); } function kindRow(k: MatKind): HTMLElement { const what = `${label(k)}${k.단위 ? ` (${k.단위})` : ""}` || "—"; const sub = [k.방법, `${k.블록.length} 블록 · ${k.줄} 줄`, ...k.단서] .filter(Boolean) .join(" · "); const badge = createBadge(k.별도계상 ? `별도계상 · ${k.판정}` : k.판정, TONE[k.판정] ?? "warning", { size: 18 }); // prettier-ignore const row = createListRow({ label: what, sub, trailing: badge, size: 36, title: `단산 ${k.블록.join(" · ")}` }); // prettier-ignore const box = el("div", { attrs: { "data-mat-kind": k.종 }, children: [row, controls(k)] }); if (edits.has(k.종)) box.dataset.matEdited = ""; return box; } function draw(): void { if (!board) return; const open = board.종.filter((k) => k.판정 !== LINKED); const linked = board.종.filter((k) => k.판정 === LINKED); title.setTitle(`고를 재료 ${open.length} · 이어진 재료 ${linked.length}`); root.dataset.matOpen = String(open.length); const save = createButton({ label: edits.size ? `저장(${edits.size} 종)` : "저장", variant: "filled", size: 24, disabled: !edits.size || busy, onClick: () => void persist(), }); save.dataset.matSave = ""; const head = el("div", { className: "ui-toolbar", children: [save], }); const more = el("section", { className: `ui-collapsible${linkedOpen ? "" : " is-collapsed"}`, attrs: { "data-mat-linked": "" }, children: [createCollapsibleHead({ title: `이어진 재료 ${linked.length}`, sub: "엔진이 이은 것 — 틀린 이음도 같은 길로 바꿈", level: 4 }).root, ...linked.map(kindRow)], // prettier-ignore }); more.addEventListener("click", () => queueMicrotask(() => (linkedOpen = !more.classList.contains("is-collapsed"))), ); // 다시 그려도 펼침 그대로 body.replaceChildren(head, ...open.map(kindRow), ...(linked.length ? [more] : [])); } async function load(): Promise { try { board = await call(url); draw(); } catch (e) { body.replaceChildren(createText(why(e), "error")); } } async function persist(): Promise { if (!board || !edits.size) return; const kinds = new Set(edits.keys()); const used = board.만든.length; let apply = false; if (used) { const got = await askChoice({ message: `이미 만든 단가산출 ${used} 건에도 적용할까? — 가져온 그대로인 줄만 바뀜(따로 고친 줄은 그대로)`, choices: [ { label: "이 파일 고름만", value: "no" }, { label: `단가산출 ${used} 건에도 적용`, value: "yes" }, ], }); if (got === null) return; apply = got === "yes"; } busy = true; draw(); try { const out = await call<{ 고침?: string[] }>(url, { method: "POST", body: JSON.stringify({ 고름: Object.fromEntries(edits), 만든적용: apply, 종: [...kinds] }), }); edits.clear(); showToast(`재료 고름 저장 — ${kinds.size} 종${apply ? ` · 단가산출 ${out.고침?.length ?? 0} 건 고침` : ""}`, "success"); // prettier-ignore } catch (e) { showToast(why(e), "error"); } finally { busy = false; } await load(); } return { root, /** 처음 단가산출 탭에 올 때 한 번(첫 판 = 서버가 블록을 한 번 셈) */ ensure(): void { if (asked) return; asked = true; body.replaceChildren( createText("재료 판정 읽는 중… (처음 한 번 — 블록 수만큼 걸림)", "hint"), ); void load(); }, /** 저장 안 한 고름 */ dirty: (): boolean => edits.size > 0, /** 블록 만들 때 같이 보낼 저장 안 한 고름(없으면 undefined) */ pending: (): MatPicks | undefined => (edits.size ? Object.fromEntries(edits) : undefined), /** 만들기가 고름을 저장했음 — 캐시 비우고 다시 읽음 */ reload(): void { edits.clear(); if (asked) void load(); }, }; } export type MatPickPanel = ReturnType;