/* ============================================================================= * M02_MasterTemplete_Inputs.ts * 구조물집계표 아래 넷째 카드 「선택 입력」(PLAN 25-2c) — 고른 열(항목) 일위대가의 로직마다 묶어 * 입력 목록을 보이고, 입력마다 자리(면적산출 알약 · 재료산출 알약 · 선택 · 안 씀)를 사용자가 고름. * * · 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음 · 이름이 같은 입력도 로직마다 따로. * · 마스터 층에서는 값을 받지 않음 — 자리 연결만(값은 프로젝트 복사본 몫 · 25-2b). * · 저장은 집계표 [저장]과 같은 단추 흐름 — 여기는 고른 것을 초안으로 들고 있다가 `save()` 가 서버로 보냄 * (자동저장 없음). 고름이 바뀌면 `onDirty` 로 메인에 알림(떠날 때 물음). * · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`). * ========================================================================== */ import { el } from "@ui/ui_template_elements"; import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links"; import { fetchItemInputs, fetchItemRows, getActiveLayer, readInputLinks, saveInputLinks, type InputSeat, type ItemInputs, type ItemRow, type Layer, } from "./M02_MasterTemplete_Api_Fetch"; import { autoText, countOf, inputKey, linesOf, pillsOf, seatsOf, SEAT_OPTIONS, valueHint, type SeatMap, } from "./M02_MasterTemplete_Inputs_Model"; import "./M02_MasterTemplete_Inputs.css"; const TEXT = { title: "선택 입력", count: "로직 {logics} · 입력 {inputs}", chosen: "면적산출 {area} · 재료산출 {material} · 선택 {pick}", auto: "자동값", noItem: "집계표를 먼저 저장하면 입력 목록이 나옴", noCombo: "일위대가를 먼저 고름 — 집계표 일위대가 줄에서 고름", unsavedCombo: "고른 일위대가는 집계표를 저장하면 입력 목록이 나옴", noLogic: "이 일위대가에 담긴 로직이 없음", missing: "빠진 로직 {value}", failed: "입력 목록을 못 받음 — {value}", loading: "입력 목록을 받는 중…", seat: "자리", }; const why = (error: unknown): string => (error instanceof Error ? error.message : ""); const fill = (text: string, values: Record): string => text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? "")); /** 열 하나의 초안 — 서버 목록 · 판 · 고른 자리 · 바뀜 */ interface Draft { key: string; 판: string | null; list: ItemInputs; seats: SeatMap; dirty: boolean; } export interface InputsCardOptions { /** 이 층에서 고칠 수 있나 — 아니면 자리 고르기가 잠김 */ isAdmin: boolean; /** 고름이 바뀜 — 메인의 「저장 안 한 고침」 표시 */ onDirty: () => void; } export interface InputsCardHandle { root: HTMLElement; /** 고른 열 — `combo` = 집계표에 지금 고른 일위대가(저장 전일 수 있음) · null 이면 카드 비움 */ show: (colId: string | null, combo: string | null) => Promise; /** 고른 게 저장 안 됐나 */ dirty: () => boolean; /** 바뀐 열마다 서버에 저장 — 실패하면 던짐(판이 다르면 `StaleError`) */ save: () => Promise; /** 항목 목록(열 ↔ 키)을 다시 받게 함 — 집계표 [저장] 뒤 */ invalidate: () => void; destroy: () => void; } export function createInputsCard(opts: InputsCardOptions): InputsCardHandle { const drafts = new Map(); let rows: Promise | null = null; let seq = 0; const summary = el("div", { className: "m02-inputs__summary" }); const body = el("div", { className: "m02-inputs__body" }); const root = el("section", { className: "m02-struct__card m02-inputs ui-collapsible ui-sidebar-section ui-sidebar-card", children: [el("h3", { className: "ui-collapsible__title", text: TEXT.title }), summary, body], }); const note = (text: string): HTMLElement => el("p", { className: "m02-struct__empty", text }); const clear = (): void => { summary.replaceChildren(); body.replaceChildren(); }; const itemRows = (): Promise => (rows ??= fetchItemRows(getActiveLayer()).catch((error: unknown) => { rows = null; throw error; })); function paintSummary(draft: Draft): void { const count = countOf(draft.list); // 목록에 있는 입력의 자리만 셈 — 서버 목록에서 빠진 입력의 옛 고름은 안 셈(저장 때도 빠짐) const listed = new Set( draft.list.로직.flatMap((l) => l.입력.map((i) => inputKey(l.로직, i.이름))), ); const seats = [...draft.seats].filter(([key]) => listed.has(key)).map(([, seat]) => seat); const by = (seat: InputSeat): number => seats.filter((s) => s === seat).length; summary.replaceChildren( el("span", { className: "m02-inputs__count", text: fill(TEXT.count, count) }), el("span", { className: "m02-inputs__chosen", text: fill(TEXT.chosen, { area: by("면적"), material: by("재료"), pick: by("선택") }), }), ); } function paint(draft: Draft): void { paintSummary(draft); const groups = draft.list.로직.map((logic) => el("div", { className: "m02-inputs__logic", attrs: { "data-logic": logic.로직 }, children: [ el("h4", { className: "m02-inputs__logic-title", children: [ el("span", { text: logic.이름 }), ...(logic.결과단위 ? [el("small", { text: logic.결과단위 })] : []), el("small", { className: "m02-inputs__logic-key", text: logic.로직 }), ], }), ...logic.입력.map((input) => { const key = inputKey(logic.로직, input.이름); const select = el("select", { className: "m02-inputs__seat", attrs: { "aria-label": `${logic.이름} ${input.이름} ${TEXT.seat}` }, children: SEAT_OPTIONS.map((o) => el("option", { text: o.label, attrs: { value: o.value } }), ), }) as HTMLSelectElement; select.value = draft.seats.get(key) ?? ""; select.disabled = !opts.isAdmin; select.addEventListener("change", () => { if (select.value) draft.seats.set(key, select.value as InputSeat); else draft.seats.delete(key); draft.dirty = true; paintSummary(draft); opts.onDirty(); }); const hint = valueHint(input); const auto = autoText(input); return el("div", { className: "m02-inputs__row", attrs: { "data-input": input.이름 }, children: [ el("div", { className: "m02-inputs__info", children: [ el("span", { className: "m02-inputs__name", text: input.이름 }), ...(input.단위 ? [el("small", { text: input.단위 })] : []), ...(input.설명 ? [el("small", { className: "m02-inputs__desc", text: input.설명 })] : []), ...(hint ? [el("small", { className: "m02-inputs__hint", text: hint })] : []), ...(auto ? [ el("small", { className: "m02-inputs__auto", text: `${TEXT.auto} ${auto}`, }), ] : []), ], }), select, ], }); }), ], }), ); const missing = draft.list.빠진로직; body.replaceChildren( ...(groups.length ? groups : [note(TEXT.noLogic)]), ...(missing.length ? [note(fill(TEXT.missing, { value: missing.join(", ") }))] : []), ); } async function show(colId: string | null, combo: string | null): Promise { const mine = ++seq; clear(); if (!colId) return; const kept = drafts.get(colId); if (kept) return paint(kept); body.replaceChildren(note(TEXT.loading)); try { const layer: Layer = getActiveLayer(); const row = (await itemRows()).find((r) => r.열 === colId); if (mine !== seq) return; if (!row) return void body.replaceChildren(note(combo ? TEXT.unsavedCombo : TEXT.noItem)); if (!row.일위대가) return void body.replaceChildren(note(combo ? TEXT.unsavedCombo : TEXT.noCombo)); const [list, links] = await Promise.all([ fetchItemInputs(layer, row.key), readInputLinks(layer, row.key), ]); if (mine !== seq) return; if (!list.일위대가) return void body.replaceChildren(note(TEXT.noCombo)); const draft: Draft = { key: row.key, 판: links.판, list, seats: seatsOf(links), dirty: false, }; drafts.set(colId, draft); paint(draft); } catch (error) { if (mine === seq) body.replaceChildren(note(fill(TEXT.failed, { value: why(error) }))); } } return { root, show, dirty: () => [...drafts.values()].some((d) => d.dirty), save: async () => { const layer = getActiveLayer(); for (const draft of drafts.values()) { if (!draft.dirty) continue; const got = await saveInputLinks( layer, draft.key, draft.판, linesOf(draft.list, draft.seats), ); draft.판 = got.판; draft.dirty = false; } }, invalidate: () => { rows = null; for (const [id, draft] of drafts) if (!draft.dirty) drafts.delete(id); // 서버 것으로 다시 읽음 }, destroy: () => { seq += 1; root.remove(); }, }; } /** 그 열의 면적산출 · 재료산출 편집 화면 알약 — 자리를 그 시트로 고른 입력(프로젝트 층은 25-2b 몫이라 없음) */ export async function inputPills(column: string, seat: "면적" | "재료"): Promise { const layer = getActiveLayer(); if (layer === "project") return []; try { const row = (await fetchItemRows(layer)).find((r) => r.열 === column); if (!row?.일위대가) return []; const [list, links] = await Promise.all([ fetchItemInputs(layer, row.key), readInputLinks(layer, row.key), ]); return pillsOf(list, links, seat); } catch { return []; // 서버 길이 아직 없거나 못 읽어도 시트 편집은 그대로 } }