/* ============================================================================= * M02_MasterTemplete_Inputs.ts * 구조물집계표 아래 넷째 카드 「선택 입력」(PLAN 25-2c) — 고른 열(항목) 일위대가의 로직마다 묶어 * 입력 목록을 보이고, 입력마다 자리(면적산출 알약 · 재료산출 알약 · 선택 · 안 씀)를 사용자가 고름. * * · 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음 · 이름이 같은 입력도 로직마다 따로. * · 마스터 층에서는 값을 받지 않음 — 자리 연결만(값은 프로젝트 복사본 몫 · 25-2b). * · 저장은 집계표 [저장]과 같은 단추 흐름 — 여기는 고른 것을 초안으로 들고 있다가 `save()` 가 서버로 보냄 * (자동저장 없음). 고름이 바뀌면 `onDirty` 로 메인에 알림(떠날 때 물음). * · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`). * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links"; import { fetchItemInputs, fetchItemRows, getActiveLayer, readInputLinks, saveInputLinks, type InputLinks, type InputSeat, type ItemInput, type ItemInputs, type ItemLogic, type ItemRow, type Layer, } from "./M02_MasterTemplete_Api_Fetch"; import { autoText, choicesOf, countOf, inputKey, linesOf, pickedGroups, pillsOf, rangeOf, seatsOf, SEAT_OPTIONS, valueHint, type SeatMap, type ValueDoc, } 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 []; // 서버 길이 아직 없거나 못 읽어도 시트 편집은 그대로 } } /* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ───────────────────────────── * 같은 카드 틀 · 같은 입력 목록인데 여기서는 자리 고르기 대신 **값**을 받음: 자리가 「선택」 인 입력만 * 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님 * (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음. * mode "view" 보기만 + [선택 입력 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */ export interface InputsValueData { list: ItemInputs; link: InputLinks | null; values: ValueDoc; } export interface InputsValueOptions { mode: "view" | "value"; /** mode "view" — [선택 입력 수정] */ onEdit?: () => void; /** mode "value" — 값이 바뀔 때마다 */ onChange?: (values: ValueDoc) => void; } export interface InputsValueHandle { root: HTMLElement; /** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */ set: (data: InputsValueData | null, note?: string) => void; /** 지금 값(확인한 것 · 값이 든 것만) */ getValues: () => ValueDoc; /** 범위 밖 값이 없나 */ isValid: () => boolean; /** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */ setEditLabel: (label: string) => void; } const VALUE_TEXT = { edit: "선택 입력 수정", none: "선택으로 이은 입력이 없음", auto: "자동값", unconfirmed: "확인 안 함", confirmed: "✓ 확인함", confirmBox: "확인", outOfRange: "범위 {value} 밖", summary: "선택 {value}", places: "면적산출 알약 {area} · 재료산출 알약 {material}", noList: "입력 목록을 못 읽음 — {value}", }; type ValueEntry = ValueDoc[string][string]; export function createInputsValueCard(opts: InputsValueOptions): InputsValueHandle { let data: InputsValueData | null = null; let values: ValueDoc = {}; let invalid = 0; const summary = el("span", { className: "m02-struct__no" }); const edit = createButton({ label: VALUE_TEXT.edit, variant: "ghost", size: "sm" }); edit.hidden = opts.mode !== "view"; if (opts.onEdit) edit.addEventListener("click", opts.onEdit); const places = el("p", { className: "m02-inputval__places" }); const body = el("div", { className: "m02-struct__sheet m02-inputval" }); const root = el("section", { className: "m02-struct__card ui-collapsible ui-sidebar-section ui-sidebar-card", children: [ el("h3", { className: "ui-collapsible__title", text: TEXT.title }), el("div", { className: "m02-struct__subhead", children: [summary, edit] }), el("div", { className: "m02-inputval__wrap", children: [places, body] }), ], }); const groups = (): ReturnType => data ? pickedGroups(data.list, data.link) : []; const one = (logic: string, name: string): ValueEntry => values[logic]?.[name] ?? { 값: null, 확인: false }; const isEmpty = (value: string | number | null): boolean => value === null || value === ""; const put = (logic: string, name: string, next: ValueEntry): void => { (values[logic] ??= {})[name] = next; }; const autoOf = (input: ItemInput): string | number | null => { const auto = input.자동값; return typeof auto === "string" || typeof auto === "number" ? auto : null; }; const paintSummary = (): void => { let total = 0; let confirmed = 0; for (const { logic, inputs } of groups()) for (const input of inputs) { total += 1; if (one(logic.로직, input.이름).확인) confirmed += 1; } summary.textContent = data ? fill(VALUE_TEXT.summary, { value: `${confirmed}/${total}` }) : ""; }; /** 값 칸 하나 — 자동값은 흐리게(기울임 · placeholder · 첫 줄) */ const valueCell = (logic: ItemLogic, input: ItemInput): HTMLElement => { const stored = one(logic.로직, input.이름); const auto = autoOf(input); const state = el("span", { className: "m02-inputval__state" }); const paintState = (): void => { const now = one(logic.로직, input.이름); state.textContent = now.확인 ? VALUE_TEXT.confirmed : VALUE_TEXT.unconfirmed; state.classList.toggle("is-confirmed", now.확인); }; if (opts.mode === "view") { const text = isEmpty(stored.값) ? auto : stored.값; const shown = el("span", { className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`, text: text === null ? "—" : String(text), attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {}, }); paintState(); return el("div", { className: "m02-inputval__cell", children: [shown, state] }); } const emit = (): void => { paintState(); paintSummary(); opts.onChange?.(values); }; const range = rangeOf(input); const choices = choicesOf(input); const control = ( choices.length ? el("select", { className: "ui-select ui-select--sm m02-inputval__control" }) : el("input", { className: "ui-input m02-inputval__control", attrs: { type: range ? "number" : "text", ...(range?.min !== undefined ? { min: String(range.min) } : {}), ...(range?.max !== undefined ? { max: String(range.max) } : {}), ...(range ? { step: "any" } : {}), placeholder: auto === null ? "" : String(auto), }, }) ) as HTMLInputElement | HTMLSelectElement; const error = el("span", { className: "m02-inputval__error" }); const box = el("input", { attrs: { type: "checkbox", title: VALUE_TEXT.confirmBox, "aria-label": VALUE_TEXT.confirmBox, }, }) as HTMLInputElement; if (control instanceof HTMLSelectElement) { control.append( el("option", { attrs: { value: "" }, text: auto === null ? "—" : `${VALUE_TEXT.auto} ${String(auto)}`, }), ...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })), ); } control.value = isEmpty(stored.값) ? "" : String(stored.값); control.classList.toggle("is-auto", isEmpty(stored.값)); box.checked = stored.확인; const outside = (raw: string): boolean => { if (!range || raw.trim() === "") return false; const value = Number(raw); return ( !Number.isFinite(value) || (range.min !== undefined && value < range.min) || (range.max !== undefined && value > range.max) ); }; let wasBad = false; const changed = (): void => { const raw = control.value; const bad = outside(raw); if (bad !== wasBad) invalid += bad ? 1 : -1; wasBad = bad; control.setAttribute("aria-invalid", bad ? "true" : "false"); error.textContent = bad ? fill(VALUE_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` }) : ""; control.classList.toggle("is-auto", raw === ""); const value: string | number | null = raw === "" ? null : range ? Number(raw) : (choices.find((c) => c.값 === raw)?.raw ?? raw); // 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님) put(logic.로직, input.이름, { 값: bad ? null : value, 확인: raw !== "" && !bad }); box.checked = raw !== "" && !bad; emit(); }; control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", changed); // 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림 box.addEventListener("change", () => { const now = one(logic.로직, input.이름); if (box.checked && isEmpty(now.값) && auto !== null) { control.value = String(auto); control.classList.remove("is-auto"); put(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true }); } else if (box.checked && isEmpty(now.값)) { box.checked = false; // 받아들일 값이 없음 } else { put(logic.로직, input.이름, { ...now, 확인: box.checked }); } emit(); }); paintState(); return el("div", { className: "m02-inputval__cell", children: [control, box, state, error] }); }; const paint = (note?: string): void => { invalid = 0; paintSummary(); if (!data) { places.textContent = ""; body.replaceChildren( el("p", { className: "m02-struct__empty", text: note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none, }), ); return; } const seats = [...seatsOf(data.link).values()]; const area = seats.filter((seat) => seat === "면적").length; const material = seats.filter((seat) => seat === "재료").length; places.textContent = area + material ? fill(VALUE_TEXT.places, { area, material }) : ""; const shown = groups(); const missing = data.list.빠진로직.length ? [ el("p", { className: "m02-inputval__missing", text: fill(TEXT.missing, { value: data.list.빠진로직.join(", ") }), }), ] : []; if (!shown.length) { body.replaceChildren( el("p", { className: "m02-struct__empty", text: VALUE_TEXT.none }), ...missing, ); return; } body.replaceChildren( ...shown.map(({ logic, inputs }) => el("section", { className: "m02-inputval__logic", attrs: { "data-logic": logic.로직 }, children: [ el("h4", { className: "m02-inputval__logic-name", text: logic.이름 || logic.로직, attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` }, }), ...inputs.map((input) => el("div", { className: "m02-inputval__row", attrs: { "data-input": inputKey(logic.로직, input.이름) }, children: [ el("span", { className: "m02-inputval__name", text: input.단위 ? `${input.이름} (${input.단위})` : input.이름, attrs: input.설명 ? { title: input.설명 } : {}, }), valueCell(logic, input), ], }), ), ], }), ), ...missing, ); }; return { root, set: (next, note) => { data = next; values = next ? structuredClone(next.values) : {}; paint(note); }, getValues: () => { // 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함) const out: ValueDoc = {}; for (const [logic, byName] of Object.entries(values)) for (const [name, entry] of Object.entries(byName)) if (entry.확인 || !isEmpty(entry.값)) (out[logic] ??= {})[name] = entry; return out; }, isValid: () => invalid === 0, setEditLabel: (label) => { (edit.querySelector(".ui-btn__label") ?? edit).textContent = label; }, }; }