/* ============================================================================= * M02_Labor_Values.ts * 넣을 값 칸(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs.ts` 에서 옮김) — 값 칸 규칙 한 벌: * 고르기 = 목록 · 범위 = 숫자(밖이면 붉은 글) · 빈 칸 = 자동값(흐리게 · placeholder). * · `createValueControl` — 칸 하나(일위대가 입력 모달 · 프로젝트 값 카드가 같이 씀) * · `createInputsValueCard` — 프로젝트 고르기 모달 값 카드(옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄) * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import type { ItemInput, ItemInputs, ItemLogic, InputLinks } from "./M02_MasterTemplete_Api_Fetch"; import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell"; import { inputKey, pickedGroups, seatsOf } from "./M02_MasterTemplete_Inputs_Model"; import { choicesOf, isOutside, rangeOf, storedOf, type ValueDoc } from "./M02_Labor_Values_Model"; import "./M02_Labor_Values.css"; const fill = (text: string, values: Record): string => text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? "")); const CONTROL_TEXT = { auto: "자동값", outOfRange: "범위 {value} 밖" }; const autoValue = (input: ItemInput): string | number | null => { const auto = input.자동값; return typeof auto === "string" || typeof auto === "number" ? auto : null; }; export interface ValueControl { control: HTMLInputElement | HTMLSelectElement; /** 범위 밖 글(빈 글이면 비움) */ error: HTMLElement; /** 지금 범위 밖인가 — 부르는 쪽이 셈(값 카드 「막힌 칸 수」) */ wasBad: boolean; } /** 값 칸 하나 — `value` = 저장 값(null = 자동값) · 바뀔 때마다 `onChange(저장 값, 범위 밖)` */ export function createValueControl( input: ItemInput, value: string | number | null, onChange: (value: string | number | null, bad: boolean) => void, ): ValueControl { const auto = autoValue(input); 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; if (control instanceof HTMLSelectElement) { control.append( el("option", { attrs: { value: "" }, text: auto === null ? "—" : `${CONTROL_TEXT.auto} ${String(auto)}`, }), ...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })), ); } const empty = value === null || value === ""; control.value = empty ? "" : String(value); control.classList.toggle("is-auto", empty); const error = el("span", { className: "m02-inputval__error" }); const out: ValueControl = { control, error, wasBad: false }; control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", () => { const raw = control.value; const bad = isOutside(input, raw); control.setAttribute("aria-invalid", bad ? "true" : "false"); error.textContent = bad ? fill(CONTROL_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` }) : ""; control.classList.toggle("is-auto", raw === ""); onChange(storedOf(input, raw), bad); }); return out; } /* ── 프로젝트 복사본에서 값 받기(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, retry?: () => void) => void; /** 목록을 받는 동안 0.3초 넘으면 「불러오는 중」(31-7) — 돌려받은 함수 = 끝 */ loading: () => () => void; /** 지금 값(확인한 것 · 값이 든 것만) */ getValues: () => ValueDoc; /** 범위 밖 값이 없나 */ isValid: () => boolean; /** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */ setEditLabel: (label: string) => void; } const VALUE_TEXT = { title: "선택 입력", missing: "빠진 로직 {value}", edit: "선택 입력 수정", none: "선택으로 이은 입력이 없음", auto: "자동값", unconfirmed: "확인 안 함", confirmed: "✓ 확인함", confirmBox: "확인", 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" }); // 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [선택 입력 수정] · 상태 글은 목록 칸에 const shell = cardShell({ title: VALUE_TEXT.title, sub: [summary], body: el("div", { className: "m02-inputval__wrap", children: [places, body] }), stateHost: body, }); shell.tools.append(edit); const root = shell.root; 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 = autoValue; 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 field = createValueControl(input, stored.값, (value, bad) => { if (bad !== field.wasBad) invalid += bad ? 1 : -1; field.wasBad = bad; // 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님) put(logic.로직, input.이름, { 값: bad ? null : value, 확인: value !== null && !bad }); box.checked = value !== null && !bad; emit(); }); const { control, error } = field; const box = el("input", { attrs: { type: "checkbox", title: VALUE_TEXT.confirmBox, "aria-label": VALUE_TEXT.confirmBox, }, }) as HTMLInputElement; box.checked = stored.확인; // 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림 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, retry?: () => void): void => { invalid = 0; paintSummary(); if (!data) { places.textContent = ""; if (note && retry) return shell.fail(fill(VALUE_TEXT.noList, { value: note }), retry); return shell.note(note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none); } 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(VALUE_TEXT.missing, { value: data.list.빠진로직.join(", ") }), }), ] : []; if (!shown.length) { body.replaceChildren(cardNote(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, retry) => { data = next; values = next ? structuredClone(next.values) : {}; paint(note, retry); }, loading: () => shell.loading(), 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; }, }; }