/* ============================================================================= * M02_MasterTemplete_InputsCard.ts * 「선택 입력」 카드(계획서 25장) — 구조물 집계 항목의 일위대가 조합이 담은 단가산출 로직들의 입력을 * **로직마다 묶어** 보이는 카드. 카드 틀은 도면 · 면적산출 · 재료산출 카드와 같음(`ItemCards`). * * mode "view" = 보기만 — 자리가 「선택」 인 입력의 값(없으면 흐린 자동값 + 「확인 안 함」) * mode "value" = 값 받기 — 프로젝트 복사본(고르기 = 목록 · 범위 = 숫자 · 자동값 흐림 · 확인 표시) * mode "link" = 자리 고르기(면적산출 알약 · 재료산출 알약 · 선택 · 안 씀) — M02 세 층 공용(sub2 · 25-2c) * * 값은 로직마다 따로 — 이름이 같아도 합치지 않음(`{로직}|{입력이름}`). 입력이 없어도 자동값은 흐린 * 미리보기일 뿐 저장값이 아님(25-1: 확인 안 한 입력은 계산 때 「빠진 입력」 목록). * 자료 모양은 25-1b 약속 그대로 — 아래 타입. * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import { itemCard, itemNote } from "./M02_MasterTemplete_ItemCards"; import "./M02_MasterTemplete_InputsCard.css"; /** 입력 목록 한 입력(25-1b) — `고르기` 는 문자열 목록 · 객체 목록 둘 다 받음 */ export interface LogicInput { 이름: string; 설명?: string | null; 단위?: string | null; 고르기?: Array | null; 범위?: [number, number] | { 최소?: number; 최대?: number; min?: number; max?: number } | null; 자동값?: string | number | null; } export interface InputsLogic { 로직: string; 이름: string; 결과단위?: string | null; 입력: LogicInput[]; } /** `GET /api/m02/layers/{layer}/items/{key}/inputs` */ export interface InputsList { 일위대가: string; 이름: string; 로직: InputsLogic[]; 빠진로직: string[]; } export type InputPlace = "면적" | "재료" | "선택" | null; /** 항목 연결 한 줄 — 자리 면적 · 재료 = 그 시트 알약 · 선택 = 이 카드 */ export interface LinkRow { 로직: string; 입력: string; 자리: InputPlace; 알약?: string; } export interface LinkDoc { 판: string; 줄: LinkRow[]; } /** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` */ export type ValueDoc = Record< string, Record >; /** 입력 하나의 키 · 알약 키(25-1b) */ export const inputKey = (logic: string, name: string): string => `${logic}|${name}`; export const pillKey = (logic: string, name: string): string => `입력|${logic}|${name}`; const TEXT = { title: "선택 입력", edit: "선택 입력 수정", none: "선택으로 이은 입력이 없음", missing: "없는 로직 {value}", auto: "자동값", unconfirmed: "확인 안 함", confirmed: "✓ 확인함", confirmBox: "확인", outOfRange: "범위 {value} 밖", summary: "선택 {value}", places: "면적산출 알약 {area} · 재료산출 알약 {material}", noList: "입력 목록을 못 읽음 — {value}", }; const fill = (text: string, value: string): string => text.replace("{value}", value); const num = (value: unknown): number | undefined => typeof value === "number" && Number.isFinite(value) ? value : undefined; /** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 */ export function rangeOf(input: LogicInput): { min?: number; max?: number } | null { const raw = input.범위; if (Array.isArray(raw)) return { min: num(raw[0]), max: num(raw[1]) }; if (raw && typeof raw === "object") return { min: num(raw.최소 ?? raw.min), max: num(raw.최대 ?? raw.max) }; return null; } /** 고르기 목록 → 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */ export function choicesOf(input: LogicInput): { 값: string; 이름: string; raw: string | number }[] { return (input.고르기 ?? []).map((one) => { const raw = typeof one === "object" ? (one.값 ?? one.이름 ?? "") : one; return { 값: String(raw), 이름: typeof one === "object" ? String(one.이름 ?? raw) : String(one), raw, }; }); } /** 자리가 「선택」 인 줄만 로직 차례로 묶음 — 이 카드가 보이는 입력 */ export function pickedGroups( list: InputsList, link: LinkDoc | null, ): { logic: InputsLogic; inputs: LogicInput[] }[] { const picked = new Set( (link?.줄 ?? []) .filter((row) => row.자리 === "선택") .map((row) => inputKey(row.로직, row.입력)), ); return list.로직 .map((logic) => ({ logic, inputs: logic.입력.filter((input) => picked.has(inputKey(logic.로직, input.이름))), })) .filter((group) => group.inputs.length > 0); } export interface InputsCardData { list: InputsList; link: LinkDoc | null; values: ValueDoc; } export interface InputsCardOptions { mode: "view" | "value"; /** mode "view" — [선택 입력 수정] */ onEdit?: () => void; /** mode "value" — 값이 바뀔 때마다 */ onChange?: (values: ValueDoc) => void; } export interface InputsCardHandle { root: HTMLElement; /** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */ set: (data: InputsCardData | null, note?: string) => void; /** 지금 값(확인한 것 · 값이 든 것만) */ getValues: () => ValueDoc; /** 범위 밖 값이 없나 */ isValid: () => boolean; /** [선택 입력 수정] 글 — 아직 프로젝트로 안 가져온 항목이면 「프로젝트로 가져와 고침」 */ setEditLabel: (label: string) => void; /** 자리 「선택」 인 입력 수 · 확인한 수 */ counts: () => { total: number; confirmed: number }; } export function createInputsCard(opts: InputsCardOptions): InputsCardHandle { let data: InputsCardData | null = null; let values: ValueDoc = {}; let invalid = 0; const summary = el("span", { className: "m02-struct__no" }); const edit = createButton({ label: 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-inputs__places" }); const body = el("div", { className: "m02-struct__sheet m02-inputs" }); const root = itemCard( TEXT.title, el("div", { className: "m02-struct__subhead", children: [summary, edit], }), el("div", { className: "m02-inputs__wrap", children: [places, body] }), ); const groups = (): ReturnType => data ? pickedGroups(data.list, data.link) : []; const valueOf = (logic: string, name: string): { 값: string | number | null; 확인: boolean } => values[logic]?.[name] ?? { 값: null, 확인: false }; const isEmpty = (value: string | number | null): boolean => value === null || value === ""; const store = ( logic: string, name: string, next: { 값: string | number | null; 확인: boolean }, ) => { (values[logic] ??= {})[name] = next; }; const counts = (): { total: number; confirmed: number } => { let total = 0; let confirmed = 0; for (const { logic, inputs } of groups()) for (const input of inputs) { total += 1; if (valueOf(logic.로직, input.이름).확인) confirmed += 1; } return { total, confirmed }; }; const paintSummary = (): void => { const { total, confirmed } = counts(); summary.textContent = data ? `${fill(TEXT.summary, `${confirmed}/${total}`)}` : ""; }; /** 값 칸 하나 — 자동값은 흐리게(placeholder · 첫 줄) */ const valueCell = (logic: InputsLogic, input: LogicInput): HTMLElement => { const stored = valueOf(logic.로직, input.이름); const auto = input.자동값 ?? null; const state = el("span", { className: "m02-inputs__state" }); const paintState = (): void => { const now = valueOf(logic.로직, input.이름); state.textContent = now.확인 ? TEXT.confirmed : TEXT.unconfirmed; state.classList.toggle("is-confirmed", now.확인); }; if (opts.mode === "view") { const text = isEmpty(stored.값) ? auto : stored.값; const shown = el("span", { className: `m02-inputs__value${isEmpty(stored.값) ? " is-auto" : ""}`, text: text === null ? "—" : String(text), attrs: isEmpty(stored.값) ? { title: TEXT.auto } : {}, }); paintState(); return el("div", { className: "m02-inputs__cell", children: [shown, state] }); } const emit = (): void => { paintState(); paintSummary(); opts.onChange?.(values); }; const range = rangeOf(input); const choices = choicesOf(input); const control: HTMLInputElement | HTMLSelectElement = choices.length ? el("select", { className: "ui-select ui-select--sm m02-inputs__control" }) : el("input", { className: "ui-input m02-inputs__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), }, }); const error = el("span", { className: "m02-inputs__error" }); const box = el("input", { attrs: { type: "checkbox", title: TEXT.confirmBox, "aria-label": TEXT.confirmBox }, }); if (control instanceof HTMLSelectElement) { control.append( el("option", { attrs: { value: "" }, text: auto === null ? "—" : `${TEXT.auto} ${String(auto)}`, }), ...choices.map((one) => el("option", { attrs: { value: one.값 }, text: one.이름 })), ); control.value = isEmpty(stored.값) ? "" : String(stored.값); } else { 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(TEXT.outOfRange, `${range?.min ?? ""}~${range?.max ?? ""}`) : ""; control.classList.toggle("is-auto", raw === ""); const value: string | number | null = raw === "" ? null : range ? Number(raw) : (choices.find((one) => one.값 === raw)?.raw ?? raw); // 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님) store(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 = valueOf(logic.로직, input.이름); if (box.checked && isEmpty(now.값) && auto !== null) { control.value = String(auto); control.classList.remove("is-auto"); store(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true }); } else if (box.checked && isEmpty(now.값)) { box.checked = false; // 받아들일 값이 없음 } else { store(logic.로직, input.이름, { ...now, 확인: box.checked }); } emit(); }); paintState(); return el("div", { className: "m02-inputs__cell", children: [control, box, state, error], }); }; const paint = (note?: string): void => { invalid = 0; paintSummary(); if (!data) { places.textContent = ""; body.replaceChildren(itemNote(note ? fill(TEXT.noList, note) : TEXT.none)); return; } const rows = data.link?.줄 ?? []; const area = rows.filter((row) => row.자리 === "면적").length; const material = rows.filter((row) => row.자리 === "재료").length; places.textContent = area + material ? TEXT.places.replace("{area}", `${area}`).replace("{material}", `${material}`) : ""; const shownGroups = groups(); const missing = data.list.빠진로직.length ? [ el("p", { className: "m02-inputs__missing", text: fill(TEXT.missing, data.list.빠진로직.join(", ")), }), ] : []; if (!shownGroups.length) { body.replaceChildren(itemNote(TEXT.none), ...missing); return; } body.replaceChildren( ...shownGroups.map(({ logic, inputs }) => el("section", { className: "m02-inputs__logic", attrs: { "data-logic": logic.로직 }, children: [ el("h4", { className: "m02-inputs__logic-name", text: logic.이름 || logic.로직, attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` }, }), ...inputs.map((input) => el("div", { className: "m02-inputs__row", attrs: { "data-input": inputKey(logic.로직, input.이름) }, children: [ el("span", { className: "m02-inputs__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, one] of Object.entries(byName)) if (one.확인 || !isEmpty(one.값)) (out[logic] ??= {})[name] = one; return out; }, isValid: () => invalid === 0, setEditLabel: (label) => { (edit.querySelector(".ui-btn__label") ?? edit).textContent = label; }, counts, }; }