From 3a59f715f6c035748207b520560b2549eabb5b05 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 00:06:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui=5Ftemplate):=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EB=AA=A8=EB=8B=AC=EC=9D=84=20?= =?UTF-8?q?=EC=8B=A4=EC=84=9C=EB=B2=84=EC=97=90=20=EC=9D=B4=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EC=84=A0=ED=83=9D=20=EC=9E=85=EB=A0=A5=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=EB=8A=94=20sub2=20=ED=8C=8C=EC=9D=BC=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=8C=20(PLAN=2025-2d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 복사본 창구를 sub6 길 모양({항목} · {종류, 판, 내용} · PUT {판, 내용} · 409 message · refresh · calc)으로 — 가짜 응답 지움 - 입력 목록 · 항목 연결은 sub4 길(fetchItemInputs · readInputLinks) · 알약은 pillsOf - 값 받기 카드를 M02_MasterTemplete_Inputs.ts 에 모드 view · value 로 더함(InputsCard 지움 · 두 벌 금지) · 셈은 _Inputs_Model - 상태 줄에 복사본 계산 한 줄(계 · 빠진 입력) · 목록은 모달에서 한 번만 받아 들고 있음 - Structure.ts: sub2 의 선택 입력 카드를 ItemCards 의 extraCards 로 다시 얹음 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../M02_MasterTemplete_Inputs.css | 98 +++++ .../M02_MasterTemplete_Inputs.ts | 297 ++++++++++++- .../M02_MasterTemplete_InputsCard.css | 97 ----- .../M02_MasterTemplete_InputsCard.ts | 397 ------------------ .../M02_MasterTemplete_Inputs_Model.ts | 48 +++ .../tester/helper_item_picker_project.cjs | 315 ++++++-------- resources/tester/test_item_picker_detail.py | 126 +++--- ui_template/ui_template_item_picker_detail.ts | 64 +-- ui_template/ui_template_item_picker_edit.ts | 71 ++-- .../ui_template_item_picker_project.ts | 302 +++++-------- 10 files changed, 803 insertions(+), 1012 deletions(-) delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_InputsCard.css delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.css b/M02_MasterTemplete/M02_MasterTemplete_Inputs.css index fff107c9..d90721be 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.css @@ -103,3 +103,101 @@ .m02-inputs__seat:disabled { opacity: 0.7; } + +/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 값 · 확인 표시 카드 ── */ +.m02-inputval__wrap { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: var(--m02-struct-body-min, 16rem); + min-width: 0; + gap: var(--spacing-8); +} + +.m02-inputval { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + overflow: auto; + min-height: 0; + min-width: 0; +} + +.m02-inputval__places, +.m02-inputval__missing { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.m02-inputval__logic { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m02-inputval__logic-name { + margin: 0; + color: var(--color-text); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-inputval__row { + display: grid; + grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr); + gap: var(--spacing-8); + align-items: center; + min-width: 0; +} + +.m02-inputval__name { + color: var(--color-text); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-inputval__cell { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-8); + align-items: center; + min-width: 0; +} + +.m02-inputval__control { + flex: 1 1 6rem; + min-width: 0; + max-width: 24rem; +} + +/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */ +.m02-inputval__value.is-auto, +.m02-inputval__control.is-auto, +.m02-inputval__control::placeholder { + color: var(--color-text-secondary); + font-style: italic; + opacity: 1; +} + +.m02-inputval__state { + color: var(--color-text-secondary); + font-size: var(--text-caption); + white-space: nowrap; +} + +.m02-inputval__state.is-confirmed { + color: var(--color-text); + font-weight: 600; +} + +.m02-inputval__error { + flex: 1 0 100%; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m02-inputval__control[aria-invalid="true"] { + border-color: var(--color-danger); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts index 3462dd1b..77a3c59c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts @@ -10,7 +10,7 @@ * · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`). * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; +import { createButton, el } from "@ui/ui_template_elements"; import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links"; import { fetchItemInputs, @@ -18,21 +18,28 @@ import { 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"; @@ -276,3 +283,291 @@ export async function inputPills(column: string, seat: "면적" | "재료"): Pro 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; + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css deleted file mode 100644 index 66fb2c30..00000000 --- a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css +++ /dev/null @@ -1,97 +0,0 @@ -/* 「선택 입력」 카드 — 로직마다 묶은 입력 줄 · 값 칸 · 확인 표시. 카드 틀은 M02_MasterTemplete_Structure.css */ -.m02-inputs__wrap { - display: flex; - flex-direction: column; - flex: 1 1 0; - min-height: var(--m02-struct-body-min, 16rem); - min-width: 0; - gap: var(--spacing-8); -} - -.m02-inputs { - display: flex; - flex-direction: column; - gap: var(--spacing-12); - overflow: auto; - min-height: 0; - min-width: 0; -} - -.m02-inputs__places, -.m02-inputs__missing { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.m02-inputs__logic { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - min-width: 0; -} - -.m02-inputs__logic-name { - margin: 0; - color: var(--color-text); - font-size: var(--text-caption); - overflow-wrap: anywhere; -} - -.m02-inputs__row { - display: grid; - grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr); - gap: var(--spacing-8); - align-items: center; - min-width: 0; -} - -.m02-inputs__name { - color: var(--color-text); - font-size: var(--text-caption); - overflow-wrap: anywhere; -} - -.m02-inputs__cell { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-4) var(--spacing-8); - align-items: center; - min-width: 0; -} - -.m02-inputs__control { - flex: 1 1 6rem; - min-width: 0; - max-width: 24rem; -} - -/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */ -.m02-inputs__value.is-auto, -.m02-inputs__control.is-auto, -.m02-inputs__control::placeholder { - color: var(--color-text-secondary); - font-style: italic; - opacity: 1; -} - -.m02-inputs__state { - color: var(--color-text-secondary); - font-size: var(--text-caption); - white-space: nowrap; -} - -.m02-inputs__state.is-confirmed { - color: var(--color-text); - font-weight: 600; -} - -.m02-inputs__error { - flex: 1 0 100%; - color: var(--color-danger); - font-size: var(--text-caption); -} - -.m02-inputs__control[aria-invalid="true"] { - border-color: var(--color-danger); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts deleted file mode 100644 index ab480e5e..00000000 --- a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts +++ /dev/null @@ -1,397 +0,0 @@ -/* ============================================================================= - * 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, - }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts index f6baf434..a6ab719f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts @@ -101,3 +101,51 @@ export function valueHint(input: ItemInput): string { /** 자동값 글 — 없으면 빈 글 */ export const autoText = (input: ItemInput): string => text(input.자동값); + +/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 자리 「선택」 인 입력만 로직마다 묶어 값 · 확인 표시 ── */ + +/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` (서버 검사 모양 · 값은 수 · 글 · 빈 값) */ +export type ValueDoc = Record< + string, + Record +>; + +const number = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +/** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 — 범위가 아니면 null */ +export function rangeOf(input: ItemInput): { min?: number; max?: number } | null { + const raw = input.범위; + if (Array.isArray(raw)) return { min: number(raw[0]), max: number(raw[1]) }; + if (raw && typeof raw === "object") { + const r = raw as Record; + return { min: number(r.최소 ?? r.min), max: number(r.최대 ?? r.max) }; + } + return null; +} + +/** 고르기 목록 → 화면 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */ +export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw: string | number }[] { + return (input.고르기 ?? []).map((one) => { + const object = + typeof one === "object" && one !== null ? (one as Record) : null; + const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number; + return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw }; + }); +} + +/** 자리가 「선택」 인 입력만 로직 차례로 묶음 — 값 받기 카드가 보이는 입력 */ +export function pickedGroups( + list: ItemInputs, + links: InputLinks | null, +): { logic: ItemInputs["로직"][number]; inputs: ItemInput[] }[] { + const picked = new Set( + [...seatsOf(links)].filter(([, seat]) => seat === "선택").map(([key]) => key), + ); + return list.로직 + .map((logic) => ({ + logic, + inputs: logic.입력.filter((input) => picked.has(inputKey(logic.로직, input.이름))), + })) + .filter((group) => group.inputs.length > 0); +} diff --git a/resources/tester/helper_item_picker_project.cjs b/resources/tester/helper_item_picker_project.cjs index 45749bfe..25343dbf 100644 --- a/resources/tester/helper_item_picker_project.cjs +++ b/resources/tester/helper_item_picker_project.cjs @@ -1,9 +1,9 @@ /* 항목 고르기 모달 자료 창구 시험 헬퍼(계획서 25-2d) — `ui_template_item_picker_project.ts` 와 - * 선택 입력 카드의 순수 함수를 트랜스파일해 가짜 서버(fetch 대역) 위에서 돌린다. 결과는 JSON 한 벌. - * ① 가짜 복사본(서버 길 없음): 가져오기 · 고침 · 다시 읽기 · 마스터가 바뀜 · 새로 가져오기 · 원본 그대로 - * ② 실서버 모양(약속 길): 목록 · 읽기 · 고침 PUT · 권한 없음(403) 까닭 그대로 · 가져오기 · 새로 가져오기 - * ③ 입력 목록: 약속 길 그대로 · 없으면 M01 길로 짜 맞춤 · 가짜 연결(모두 선택) - * ④ 카드 순수 함수: 범위 · 고르기 원래 모양 · 자리 「선택」 만 묶음 + * 선택 입력 카드 셈(`M02_MasterTemplete_Inputs_Model.ts`)을 트랜스파일해 가짜 서버(fetch 대역) 위에서 돌린다. + * 서버 모양은 sub6 복사본 길(`M02_Project_Items_Router.py`) · sub4 입력 길 그대로. 결과는 JSON 한 벌. + * ① 폴더 이름 · 목록 {항목} · 가져오기 POST · 읽기 {종류, 판, 내용} · 고침 PUT {판, 내용}(판 · 409 · 403) + * ② 새로 가져오기 · 계산 한 줄 · 입력 목록(한 번만 받음) + * ③ 카드 셈: 범위 · 고르기 원래 모양 · 자리 「선택」 만 묶음 * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. */ const fs = require("fs"); @@ -13,12 +13,7 @@ const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "t const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); -const STUBS = { - "@config/config_frontend": { API_BASE_URL: "/api" }, - // 카드 순수 함수만 볼 것 — 화면 부품은 안 씀 - "@ui/ui_template_elements": { el: () => ({}), createButton: () => ({}) }, - "./M02_MasterTemplete_ItemCards": { itemCard: () => ({}), itemNote: () => ({}) }, -}; +const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } }; function loadTs(file) { const full = path.resolve(file); @@ -39,228 +34,174 @@ function loadTs(file) { return box.exports; } -// ── 브라우저 대역 ───────────────────────────────────────────────────────────── -const store = new Map(); -globalThis.localStorage = { - getItem: (k) => (store.has(k) ? store.get(k) : null), - setItem: (k, v) => void store.set(k, String(v)), -}; - -/** 원본 층(시스템) — 열 rv 의 도면 · 면적 · 재료 · 판은 바뀔 수 있음 */ -const original = { - version: "v1", - docs: { - structure: { 양식: "구조물도면", 도번: "SD01", 도면: { entities: [1, 2] } }, - area: { 면적번: "SA01", 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, - material: { 재료번: "SM01", 시트: [{ id: "s", 칸: {} }] }, - }, -}; -const originalSnapshot = () => JSON.stringify(original); - const reply = (status, body) => ({ status, ok: status >= 200 && status < 300, json: async () => body, }); -/** 가짜 서버 — mode: "fake"(프로젝트 길 없음) | "real"(약속 길) */ -const state = { mode: "fake", readOnly: false, inputsRoute: false, calls: [], server: {} }; +/** 가짜 서버 — 복사본 폴더마다 부분 파일 · 판(글자) */ +const state = { readOnly: false, calls: [], folders: {}, inputsHits: 0 }; +const versionOf = (content) => `v${JSON.stringify(content ?? null).length}`; + +const INPUTS = { + 일위대가: "UA000004", + 이름: "기슭막이", + 로직: [ + { + 로직: "GA1", + 이름: "로직 A", + 결과단위: "원/m", + 입력: [ + { 이름: "지역", 고르기: ["전국평균", "서울"], 자동값: "전국평균" }, + { 이름: "소량감률", 단위: "%", 범위: [0, 50], 자동값: 0 }, + ], + }, + { 로직: "GA2", 이름: "로직 B", 입력: [{ 이름: "지역", 고르기: [15, 16] }] }, + ], + 빠진로직: ["GX"], +}; globalThis.fetch = async (url, init = {}) => { const method = init.method ?? "GET"; const body = init.body ? JSON.parse(init.body) : undefined; state.calls.push({ method, url, body }); - const m = /^\/api\/m02\/templates\/(structure|area|material)\/(.+)$/.exec(url); - if (m) { + if (/^\/api\/m02\/layers\/\w+\/items\/[^/]+\/inputs$/.test(url)) { + state.inputsHits += 1; + return reply(200, INPUTS); + } + const m = /^\/api\/projects\/([^/]+)\/items(?:\/([^/]+)(?:\/([^/]+))?)?$/.exec(url); + if (!m) return reply(404, { detail: "Not Found" }); + const [, , folderEnc, partEnc] = m; + if (!folderEnc && method === "GET") + return reply(200, { 항목: Object.values(state.folders).map((x) => x.info) }); + if (!folderEnc && method === "POST") { + const folder = `${body.layer}_${(body.owner ?? "none").replace(/\//g, "-")}_${body.key}`; + state.folders[folder] = { + info: { 폴더: folder, 원본: body, 마스터바뀜: false, 가져온날: "2026-09-30T01:00:00" }, + parts: { 면적: { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, 입력값: null }, + }; + return reply(200, { 폴더: folder, 만듦: true, 가져온날: "2026-09-30T01:00:00" }); + } + const item = state.folders[decodeURIComponent(folderEnc)]; + if (!item) return reply(404, { detail: "복사본이 없음" }); + const part = partEnc && decodeURIComponent(partEnc); + if (part === "refresh") { + item.parts.면적 = { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }; + return reply(200, { 폴더: item.info.폴더, 가져온날: "2026-09-30T02:00:00", 원본판: "새판" }); + } + if (part === "calc") return reply(200, { - 종류: m[1], - 이름: decodeURIComponent(m[2]), - 판: original.version, - 문서: JSON.parse(JSON.stringify(original.docs[m[1]])), + 줄: [], + 계: 12345.6, + 빠진입력: [ + { 로직: "GA1", 입력: "지역" }, + { 로직: "GA2", 입력: "지역" }, + ], + 멈춤: ["GA1"], }); + if (method === "GET") { + const content = item.parts[part] ?? null; + return reply(200, { 종류: part, 판: versionOf(content), 내용: content }); } - const projects = /^\/api\/projects\/([^/]+)\/items(?:\/([^/]+)(?:\/([^/]+))?)?$/.exec(url); - if (projects) { - if (state.mode === "fake") return reply(404, { detail: "Not Found" }); - const [, , folder, part] = projects; - if (!folder && method === "GET") - return reply( - 200, - Object.values(state.server).map((x) => x.info), - ); - if (!folder && method === "POST") { - const key = `${body.layer}_${body.owner ?? "none"}_${body.key}`; - state.server[key] = { - info: { 폴더: key, 원본: body, 원본판: original.version, 가져온날: "2026-09-29" }, - parts: { - 도면: JSON.parse(JSON.stringify(original.docs.structure)), - 면적: JSON.parse(JSON.stringify(original.docs.area)), - 재료: JSON.parse(JSON.stringify(original.docs.material)), - }, - }; - return reply(200, state.server[key].info); - } - const item = state.server[decodeURIComponent(folder)]; - if (!item) return reply(404, { detail: "없는 폴더" }); - if (part === "refresh" && method === "POST") { - item.parts.면적 = JSON.parse(JSON.stringify(original.docs.area)); - item.info.원본판 = original.version; - return reply(200, item.info); - } - if (method === "GET") { - const doc = item.parts[decodeURIComponent(part)]; - return doc === undefined ? reply(404, { detail: "부분 없음" }) : reply(200, doc); - } - if (method === "PUT") { - if (state.readOnly) return reply(403, { detail: "프로젝트 편집 권한이 없음" }); - item.parts[decodeURIComponent(part)] = body; - return reply(200, { ok: true }); - } - } - const inputs = /^\/api\/m02\/layers\/(\w+)\/items\/([^/]+)\/inputs/.exec(url); - if (inputs) { - return state.inputsRoute - ? reply(200, { 일위대가: "UAREAL", 이름: "실서버", 로직: [], 빠진로직: [] }) - : reply(404, { detail: "Not Found" }); - } - if (url.startsWith("/api/m01/combo?key=")) { - return reply(200, { - combo: { 이름: "기슭막이", 담은로직: [{ 로직: "GA1" }, { 로직: "GA2" }, { 로직: "GX" }] }, - }); - } - if (url.startsWith("/api/m01/logic/auto?key=")) { - return reply(200, { 값: { 지역: "전국평균", 소량감률: 0 } }); - } - const logic = /^\/api\/m01\/logic\?key=(\w+)/.exec(url); - if (logic) { - if (logic[1] === "GX") return reply(404, { detail: "없는 로직" }); - return reply(200, { - logic: { - 이름: `로직 ${logic[1]}`, - 결과단위: "원/m", - 입력: [ - { 이름: "지역", 고르기: ["전국평균", "서울"] }, - { 이름: "소량감률", 단위: "%", 범위: [0, 50] }, - ], - }, - }); + if (method === "PUT") { + if (state.readOnly) return reply(403, { detail: "프로젝트 편집 권한이 없음" }); + const current = versionOf(item.parts[part] ?? null); + if (body.판 !== current) + return reply(409, { detail: { message: "다른 곳에서 먼저 고쳤습니다", 판: current } }); + item.parts[part] = body.내용; + return reply(200, { 종류: part, 판: versionOf(body.내용) }); } return reply(404, { detail: "Not Found" }); }; const project = loadTs(path.join(ROOT, "ui_template", "ui_template_item_picker_project.ts")); -const card = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_InputsCard.ts")); +const model = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts")); -const row = (over = {}) => ({ - key: "SS000001", - 열: "rv", - layer: "system", - owner: null, - 일위대가: "UA1", - ...over, -}); -const clone = (x) => JSON.parse(JSON.stringify(x)); +const row = (over = {}) => ({ key: "SS000001", 열: "rv", layer: "system", owner: null, ...over }); async function main() { const out = {}; - - // ① 폴더 이름 — 서버 `snapshot_folder_name` 과 같은 글 out.folders = [ project.folderOf(row()), - project.folderOf(row({ layer: "company", owner: "3", key: "SS000001" })), + project.folderOf(row({ layer: "company", owner: "3" })), project.folderOf(row({ layer: "personal", owner: "3/12", key: "SS000002" })), ]; - // ② 가짜 복사본 — 서버 길이 없을 때 - state.mode = "fake"; - const fake = project.projectItems("P-FAKE"); - const before = originalSnapshot(); - out.fake = { list: await fake.list() }; - out.fake.isFake = fake.isFake(); - const info = await fake.importItem(row()); - out.fake.info = info; - out.fake.readBack = await fake.read(info.폴더, "면적"); - await fake.write(info.폴더, "면적", { - ...out.fake.readBack, - 시트: [{ id: "s", 칸: { H10: { 값: 12345 } } }], - }); - await fake.write(info.폴더, "입력값", { GA1: { 지역: { 값: "서울", 확인: true } } }); - out.fake.afterWrite = await fake.read(info.폴더, "면적"); - out.fake.listAfter = (await fake.list()).map((one) => one.폴더); - out.fake.changedNow = await fake.masterChanged(row(), info); - original.version = "v2"; - out.fake.changedAfterMaster = await fake.masterChanged(row(), info); - await fake.refresh(row(), info); - out.fake.afterRefresh = await fake.read(info.폴더, "면적"); - out.fake.valuesKept = await fake.read(info.폴더, "입력값"); - out.fake.changedAfterRefresh = await fake.masterChanged(row(), (await fake.list())[0]); - out.fake.originalUntouched = before.replace('"v1"', '"v2"') === originalSnapshot(); - out.fake.storageKeys = [...store.keys()]; - await fake.write("없는폴더", "면적", {}).then( - () => (out.fake.writeMissing = "성공"), - (error) => (out.fake.writeMissing = error.message), + const items = project.projectItems("P1"); + out.listEmpty = await items.list(); + const info = await items.importItem(row({ layer: "personal", owner: "1/3" })); + out.info = info; + out.importCall = state.calls.find((c) => c.method === "POST"); + out.list = (await items.list()).map((x) => x.폴더); + + out.read = await items.read(info.폴더, "면적"); + out.readNullContent = await items.read(info.폴더, "입력값"); // 내용 null = 아직 없음 + out.readMissing = await items.read("personal_9-9_SS000009", "면적"); // 없는 폴더 404 = null + await items.write(info.폴더, "면적", { 시트: [{ id: "s", 칸: { H10: { 값: 12345 } } }] }); + out.afterWrite = state.folders[info.폴더].parts.면적; + // 판: 읽은 판으로 씀 · 쓴 뒤 새 판을 들고 있어 이어서 또 쓸 수 있음 + await items.write(info.폴더, "면적", { 시트: [{ id: "s", 칸: { H10: { 값: 999 } } }] }); + out.secondWrite = state.folders[info.폴더].parts.면적.시트[0].칸.H10; + // 읽은 적 없는 부분도 쓰기 전에 판을 먼저 앎 + const getsBefore = state.calls.filter((c) => c.method === "GET").length; + await items.write(info.폴더, "고른자재", { GA1: { 0: "MT000001" } }); + out.writeWithoutReadGets = state.calls.filter((c) => c.method === "GET").length - getsBefore; + await items.write(info.폴더, "입력값", { GA1: { 지역: { 값: "서울", 확인: true } } }); + out.valuesSaved = state.folders[info.폴더].parts.입력값; + + // 다른 곳에서 먼저 고침(판이 달라짐) → 409 까닭(`message`) 그대로 + state.folders[info.폴더].parts.면적 = { 시트: [], 남이: true }; + await items.write(info.폴더, "면적", {}).then( + () => (out.stale = "성공"), + (error) => (out.stale = error.message), ); - original.version = "v1"; - // ③ 실서버 모양 — 약속 길 - state.mode = "real"; - state.calls = []; - const real = project.projectItems("P-REAL"); - out.real = { list: await real.list(), isFake: real.isFake() }; - const rinfo = await real.importItem(row({ layer: "personal", owner: "1/3" })); - out.real.info = rinfo; - out.real.importCall = state.calls.find((c) => c.method === "POST"); - out.real.read = await real.read(rinfo.폴더, "면적"); - out.real.readMissing = await real.read(rinfo.폴더, "고른자재"); - await real.write(rinfo.폴더, "입력값", { L: { 이름: { 값: 1, 확인: true } } }); - out.real.putCall = state.calls.filter((c) => c.method === "PUT").map((c) => c.url); state.readOnly = true; - await real.write(rinfo.폴더, "면적", {}).then( - () => (out.real.readOnlyWrite = "성공"), - (error) => (out.real.readOnlyWrite = error.message), + await items.write(info.폴더, "입력값", {}).then( + () => (out.readOnly = "성공"), + (error) => (out.readOnly = error.message), ); state.readOnly = false; - await real.refresh(row({ layer: "personal", owner: "1/3" }), rinfo); - out.real.refreshCall = state.calls + + const fresh = await items.refresh(info); + out.refresh = fresh; + out.refreshCall = state.calls .filter((c) => /refresh$/.test(c.url)) .map((c) => `${c.method} ${c.url}`); - out.real.changedFlag = await real.masterChanged(row(), { ...rinfo, 마스터바뀜: true }); + out.calc = await items.calc(info.폴더); + out.calcMissing = await items.calc("없는폴더"); - // ④ 입력 목록 - state.inputsRoute = false; - const madeUp = await project.loadInputs(row({ key: "SS000009" })); - out.inputs = { - fake: madeUp.fake, - logics: madeUp.list.로직.map((l) => [l.로직, l.입력.map((i) => i.이름)]), - missing: madeUp.list.빠진로직, - auto: madeUp.list.로직[0].입력.map((i) => i.자동값), - link: project.fakeLink(madeUp.list).줄.length, - linkPlaces: [...new Set(project.fakeLink(madeUp.list).줄.map((r) => r.자리))], - }; - state.inputsRoute = true; - const realInputs = await project.loadInputs(row({ key: "SS000010" })); - out.inputs.real = { fake: realInputs.fake, name: realInputs.list.이름 }; + // 입력 목록 — 같은 항목은 한 번만 받음 + const first = await project.loadInputs(row()); + await project.loadInputs(row()); + out.inputsHits = state.inputsHits; + out.inputsLogics = first.로직.map((l) => [l.로직, l.입력.map((i) => i.이름)]); - // ⑤ 카드 순수 함수 - out.card = { + // 카드 셈 + out.model = { range: [ - card.rangeOf({ 이름: "a", 범위: [0, 50] }), - card.rangeOf({ 이름: "b", 범위: { 최소: 1, 최대: 2 } }), - card.rangeOf({ 이름: "c" }), + model.rangeOf({ 이름: "a", 범위: [0, 50] }), + model.rangeOf({ 이름: "b", 범위: { 최소: 1, 최대: 2 } }), + model.rangeOf({ 이름: "c" }), ], - choices: card.choicesOf({ 이름: "d", 고르기: [15, "16", { 값: 18, 이름: "열여덟" }] }), - keys: [card.inputKey("GA1", "지역"), card.pillKey("GA1", "지역")], - groups: card - .pickedGroups(madeUp.list, { + choices: model.choicesOf({ 이름: "d", 고르기: [15, "16", { 값: 18, 이름: "열여덟" }] }), + groups: model + .pickedGroups(first, { 판: "x", 줄: [ { 로직: "GA1", 입력: "지역", 자리: "선택" }, { 로직: "GA1", 입력: "소량감률", 자리: "면적" }, - { 로직: "GA2", 입력: "소량감률", 자리: "선택" }, + { 로직: "GA2", 입력: "지역", 자리: "선택" }, ], }) .map((g) => [g.logic.로직, g.inputs.map((i) => i.이름)]), - noLink: card.pickedGroups(madeUp.list, null).length, + noLink: model.pickedGroups(first, null).length, + pills: model.pillsOf( + first, + { 판: "x", 줄: [{ 로직: "GA1", 입력: "소량감률", 자리: "면적" }] }, + "면적", + ), }; return out; } diff --git a/resources/tester/test_item_picker_detail.py b/resources/tester/test_item_picker_detail.py index 7becafeb..63956516 100644 --- a/resources/tester/test_item_picker_detail.py +++ b/resources/tester/test_item_picker_detail.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -"""항목 고르기 모달 상세(계획서 25-2d) — 자료 창구 · 선택 입력 카드 · 한 벌 지킴. +"""항목 고르기 모달 상세(계획서 25-2d) — 복사본 창구 · 선택 입력 카드 셈 · 한 벌 지킴. -① 프로젝트 복사본 창구(`ui_template_item_picker_project.ts`)를 Node 로 돌려(가짜 서버 fetch 대역) 본다: - 서버 길이 없을 때의 가짜 복사본(가져오기 · 고침 · 마스터가 바뀜 · 새로 가져오기 · **원본 그대로**) · - 약속 길(목록 · 읽기 · PUT · 403 까닭 그대로 · 가져오기 · 새로 가져오기) · 입력 목록(약속 길 · M01 로 짜 맞춤). +① 프로젝트 복사본 창구(`ui_template_item_picker_project.ts`)를 Node 로 돌려(sub6 길 모양의 가짜 서버 fetch 대역) + 본다: 목록 `{항목}` · 가져오기 POST · 읽기 `{종류, 판, 내용}` · 고침 PUT `{판, 내용}`(판 · 409 · 403 까닭 그대로) · + 새로 가져오기 · 계산 한 줄 · 입력 목록 한 번만 받음 · 카드 셈(범위 · 고르기 원래 모양 · 자리 「선택」). ② 모달이 카드 · 편집기를 M02 와 같은 한 벌로 쓰는지, 원본 층에 쓰는 길을 안 부르는지 소스로 지킨다. """ @@ -43,75 +43,62 @@ def test_폴더_이름은_서버_snapshot_folder_name_과_같은_글(out): {"layer": "personal", "owner": "3/12", "key": "SS000002"}, ] assert out["folders"] == [snapshot_folder_name(row) for row in rows] + assert out["info"]["폴더"] == snapshot_folder_name( + {"layer": "personal", "owner": "1/3", "key": "SS000001"} + ) -def test_가짜_복사본_가져와_고치고_다시_읽으면_고친_값(out): - fake = out["fake"] - assert fake["isFake"] is True and fake["list"] == [] # 서버 길이 없음 → 가짜 - assert fake["info"]["폴더"] == "system_none_SS000001" - assert fake["readBack"]["시트"][0]["칸"] == {"A1": {"값": 1}} # 가져온 그대로 - assert fake["afterWrite"]["시트"][0]["칸"] == {"H10": {"값": 12345}} # 고침이 남음 - assert fake["listAfter"] == ["system_none_SS000001"] - - -def test_가짜_복사본을_고쳐도_원본은_그대로(out): - assert out["fake"]["originalUntouched"] is True - assert out["fake"]["storageKeys"] == ["aislo.itemsFake.P-FAKE"] # 브라우저 저장소 한 칸만 - - -def test_마스터가_바뀌면_표시_새로_가져오면_원본으로_덮고_넣은_값은_남김(out): - fake = out["fake"] - assert fake["changedNow"] is False - assert fake["changedAfterMaster"] is True - assert fake["afterRefresh"]["시트"][0]["칸"] == {"A1": {"값": 1}} # 고침이 원본으로 덮임 - assert fake["valuesKept"]["GA1"]["지역"] == { - "값": "서울", - "확인": True, - } # 사용자가 넣은 값은 남음 - assert fake["changedAfterRefresh"] is False - - -def test_안_가져온_항목에_쓰면_까닭을_던짐(out): - assert out["fake"]["writeMissing"] == "프로젝트에 가져오지 않은 항목" - - -def test_약속_길_목록_읽기_고침_가져오기_새로_가져오기(out): - real = out["real"] - assert real["isFake"] is False # 길이 있으면 가짜를 안 탐 - assert real["importCall"] == { +def test_가져오기는_POST_원본_참조_목록은_항목(out): + assert out["listEmpty"] == [] + assert out["importCall"] == { "method": "POST", - "url": "/api/projects/P-REAL/items", + "url": "/api/projects/P1/items", "body": {"layer": "personal", "owner": "1/3", "key": "SS000001"}, } - assert real["readMissing"] is None # 부분이 없음 = null - assert len(real["putCall"]) == 1 and real["putCall"][0].endswith("/%EC%9E%85%EB%A0%A5%EA%B0%92") - assert real["refreshCall"][0].startswith("POST /api/projects/P-REAL/items/") - assert real["refreshCall"][0].endswith("/refresh") - assert real["changedFlag"] is True # 실서버는 `마스터바뀜` 을 그대로 씀 + assert out["list"] == [out["info"]["폴더"]] -def test_권한_없음은_서버_까닭_그대로(out): - assert out["real"]["readOnlyWrite"] == "프로젝트 편집 권한이 없음" +def test_읽기는_내용만_돌려주고_없으면_null(out): + assert out["read"]["시트"][0]["칸"] == {"A1": {"값": 1}} + assert out["readNullContent"] is None # 파일이 아직 없음(내용 null) + assert out["readMissing"] is None # 없는 폴더 404 -def test_입력_목록_약속_길이_있으면_그대로_없으면_M01_로_짜_맞춤(out): - inputs = out["inputs"] - assert inputs["real"] == {"fake": False, "name": "실서버"} - assert inputs["fake"] is True - # 로직마다 갈라 — 이름이 같아도 합치지 않음 · 없는 로직은 빠진로직 - assert inputs["logics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역", "소량감률"]]] - assert inputs["missing"] == ["GX"] - assert inputs["auto"] == ["전국평균", 0] - assert inputs["link"] == 4 and inputs["linkPlaces"] == ["선택"] # 가짜 연결 = 모두 선택 +def test_고침은_읽은_판으로_쓰고_쓴_뒤_새_판을_들고_있음(out): + assert out["afterWrite"]["시트"][0]["칸"] == {"H10": {"값": 12345}} + assert out["secondWrite"] == {"값": 999} # 이어서 또 써도 판이 맞음 + assert out["writeWithoutReadGets"] == 1 # 읽은 적 없는 부분은 쓰기 전에 판을 한 번 읽음 + assert out["valuesSaved"]["GA1"]["지역"] == {"값": "서울", "확인": True} -def test_선택_입력_카드_순수_함수(out): - card = out["card"] - assert card["range"] == [{"min": 0, "max": 50}, {"min": 1, "max": 2}, None] - assert [c["raw"] for c in card["choices"]] == [15, "16", 18] # 저장은 원래 모양 - assert card["keys"] == ["GA1|지역", "입력|GA1|지역"] # 25-1b 키 모양 - assert card["groups"] == [["GA1", ["지역"]], ["GA2", ["소량감률"]]] # 자리 「선택」 만 - assert card["noLink"] == 0 # 연결이 없으면 아무것도 안 보임 +def test_판이_달라지면_409_까닭_권한_없으면_서버_까닭_그대로(out): + assert out["stale"] == "다른 곳에서 먼저 고쳤습니다" + assert out["readOnly"] == "프로젝트 편집 권한이 없음" + + +def test_새로_가져오기와_계산_한_줄(out): + assert out["refreshCall"][0].startswith("POST /api/projects/P1/items/") + assert out["refreshCall"][0].endswith("/refresh") + assert out["refresh"]["마스터바뀜"] is False and out["refresh"]["원본판"] == "새판" + assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1} + assert out["calcMissing"] is None # 없는 복사본 = 계산 줄 없음 + + +def test_입력_목록은_한_번만_받고_로직마다_갈라_둠(out): + assert out["inputsHits"] == 1 + assert out["inputsLogics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역"]]] + + +def test_선택_입력_카드_셈(out): + model = out["model"] + assert model["range"] == [{"min": 0, "max": 50}, {"min": 1, "max": 2}, None] + assert [c["raw"] for c in model["choices"]] == [15, "16", 18] # 저장은 원래 모양 + assert model["groups"] == [ + ["GA1", ["지역"]], + ["GA2", ["지역"]], + ] # 같은 이름도 로직마다 · 「선택」 만 + assert model["noLink"] == 0 # 연결이 없으면 아무것도 안 보임 + assert model["pills"][0]["키"] == "입력|GA1|소량감률" # 25-1b 알약 키 def test_모달은_M02_와_같은_카드_한_벌을_씀(): @@ -128,6 +115,17 @@ def test_모달은_M02_와_같은_카드_한_벌을_씀(): assert cards.count("mountDrawingTemplate(") == 1 +def test_선택_입력_카드는_sub2_파일_한_벌(out): + """두 벌 금지 — 모달의 값 받기 카드는 `M02_MasterTemplete_Inputs.ts`(M02 카드와 같은 파일).""" + assert not (M02 / "M02_MasterTemplete_InputsCard.ts").exists() + inputs = (M02 / "M02_MasterTemplete_Inputs.ts").read_text(encoding="utf-8") + assert "export function createInputsCard(" in inputs # M02 자리 고르기(link) + assert "export function createInputsValueCard(" in inputs # 모달 값 받기(view · value) + for name in ("ui_template_item_picker_detail.ts", "ui_template_item_picker_edit.ts"): + text = (UI / name).read_text(encoding="utf-8") + assert "M02_MasterTemplete_Inputs" in text and 'InputsCard"' not in text, name + + def test_모달은_원본_층에_쓰는_길을_안_부름(): """마스터 · 회사 · 개인 원본은 안 바뀜 — 저장 길은 프로젝트 복사본(`items.write`)뿐.""" for name in ( @@ -141,6 +139,7 @@ def test_모달은_원본_층에_쓰는_길을_안_부름(): "deleteTemplate(", "deleteLayerTemplate(", "copyToPersonal(", + "saveInputLinks(", ): assert forbidden not in text, f"{name} 이 {forbidden} 를 부름" edit = (UI / "ui_template_item_picker_edit.ts").read_text(encoding="utf-8") @@ -161,6 +160,7 @@ def test_소스가_700줄을_넘지_않음(): *sorted(UI.glob("ui_template_item_picker*.ts")), M02 / "M02_MasterTemplete_ItemCards.ts", M02 / "M02_MasterTemplete_ItemSource.ts", - M02 / "M02_MasterTemplete_InputsCard.ts", + M02 / "M02_MasterTemplete_Inputs.ts", + M02 / "M02_MasterTemplete_Inputs_Model.ts", ): assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name diff --git a/ui_template/ui_template_item_picker_detail.ts b/ui_template/ui_template_item_picker_detail.ts index f7f1099e..7abeeda4 100644 --- a/ui_template/ui_template_item_picker_detail.ts +++ b/ui_template/ui_template_item_picker_detail.ts @@ -1,7 +1,7 @@ /* ============================================================================= * ui_template_item_picker_detail.ts * 항목 고르기 모달 오른쪽 상세(계획서 25-2d) — 글 목록이던 것을 **카드 넷**(도면 · 면적산출 · 재료산출 · - * 선택 입력)으로. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_InputsCard`)을 늦게 받아 씀 + * 선택 입력)으로. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_Inputs`)을 늦게 받아 씀 * (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`). * * 자료 자리 — 프로젝트에 가져온 항목은 **프로젝트 복사본**을 읽고, 아직이면 **원본 층**을 읽음(읽기만). @@ -10,14 +10,15 @@ * ========================================================================== */ import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements"; -import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import { + readInputLinks, + readTemplate, + type InputLinks, +} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards"; import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource"; -import type { - InputsCardHandle, - LinkDoc, - ValueDoc, -} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard"; +import type { InputsValueHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs"; +import type { ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model"; import { folderLines, itemName, @@ -28,7 +29,6 @@ import { type PickerRow, } from "./ui_template_item_picker_data"; import { - fakeLink, folderOf, loadInputs, PART_OF, @@ -72,7 +72,8 @@ const TEXT = { importFailed: "프로젝트로 가져오지 못함 — {value}", refreshFailed: "새로 가져오지 못함 — {value}", listFailed: "프로젝트 항목을 못 읽음 — {value}", - fake: "임시 저장(서버 길 준비 중)", + calc: "복사본 계산 — 계 {total}원 · 빠진 입력 {gaps}개", + calcStuck: " · 멈춘 로직 {stuck}개", loading: "읽는 중…", noCopy: "복사본에 없음", combo: "자재조합", @@ -81,7 +82,7 @@ const TEXT = { const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const fill = (text: string, value: string): string => text.replace("{value}", value); -const dateOf = (iso?: string): string => (iso ? iso.slice(0, 10) : ""); +const dateOf = (iso?: string | null): string => (iso ? iso.slice(0, 10) : ""); export function createItemDetail(opts: ItemDetailOptions): ItemDetail { const root = el("div", { className: "ui-item-picker__detail" }); @@ -94,7 +95,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { let seq = 0; let infos: ProjectItemInfo[] | null = null; let cards: ItemCardsHandle | null = null; - let inputs: InputsCardHandle | null = null; + let inputs: InputsValueHandle | null = null; let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() }; let info: ProjectItemInfo | null = null; const items: ProjectItems | null = opts.projectId ? projectItems(opts.projectId) : null; @@ -149,11 +150,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { if (!info) nodes.push(TEXT.notImported); else { nodes.push(fill(TEXT.imported, dateOf(info.가져온날))); - if (items.isFake()) - nodes.push(el("span", { className: "ui-item-picker__fake", text: TEXT.fake })); - const changed = await items.masterChanged(row, info).catch(() => false); - if (mine !== seq) return; - if (changed) { + if (info.마스터바뀜) { const button = createButton({ label: TEXT.refresh, variant: "ghost", size: "sm" }); button.addEventListener("click", () => void refresh(row)); nodes.push( @@ -164,13 +161,29 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { } status.replaceChildren(...nodes); status.hidden = false; + if (info) void paintCalc(info, mine); }; + /** 복사본만으로 계산한 계 · 빠진 입력을 한 줄로(값을 넣고 돌아오면 달라짐) */ + async function paintCalc(from: ProjectItemInfo, mine: number): Promise { + const got = await items?.calc(from.폴더).catch(() => null); + if (!got || mine !== seq) return; + const total = Math.round(got.total).toLocaleString(); + status.append( + el("span", { + className: "ui-item-picker__calc", + text: + TEXT.calc.replace("{total}", total).replace("{gaps}", String(got.gaps)) + + (got.stuck ? TEXT.calcStuck.replace("{stuck}", String(got.stuck)) : ""), + }), + ); + } + async function refresh(row: PickerRow): Promise { if (!items || !info || !(await showConfirmDialog(TEXT.refreshAsk))) return; try { - await items.refresh(row, info); - infos = null; + const fresh = await items.refresh(info); + infos = (infos ?? []).map((one) => (one.폴더 === fresh.폴더 ? fresh : one)); show(row); } catch (error) { showToast(fill(TEXT.refreshFailed, why(error)), "error"); @@ -184,7 +197,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { if (!info) { try { info = await items.importItem(row); - infos = null; + infos = [...(infos ?? []), info]; } catch (error) { showToast(fill(TEXT.importFailed, why(error)), "error"); return; @@ -196,12 +209,15 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { async function paintInputs(row: PickerRow, mine: number): Promise { if (!inputs) return; try { - const { list, fake } = await loadInputs(row); - let link: LinkDoc | null = fake ? fakeLink(list) : null; + const list = await loadInputs(row); + let link: InputLinks | null; let values: ValueDoc = {}; if (info && items) { - link = ((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ?? link; + // 복사본 것(가져온 때 그대로) — 값도 복사본에서 + link = (await items.read(info.폴더, "입력연결")) as InputLinks | null; values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {}; + } else { + link = await readInputLinks(layerOf(row), row.key).catch(() => null); } if (mine === seq) inputs.set({ list, link, values }); } catch (error) { @@ -213,9 +229,9 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { if (cards && inputs) return; const [cardModule, inputModule] = await Promise.all([ import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"), - import("../M02_MasterTemplete/M02_MasterTemplete_InputsCard"), + import("../M02_MasterTemplete/M02_MasterTemplete_Inputs"), ]); - inputs = inputModule.createInputsCard({ mode: "view", onEdit: () => void edit("inputs") }); + inputs = inputModule.createInputsValueCard({ mode: "view", onEdit: () => void edit("inputs") }); cards = cardModule.createItemCards({ source: () => source, editable: () => !!items, diff --git a/ui_template/ui_template_item_picker_edit.ts b/ui_template/ui_template_item_picker_edit.ts index e9a89b1e..abe7996c 100644 --- a/ui_template/ui_template_item_picker_edit.ts +++ b/ui_template/ui_template_item_picker_edit.ts @@ -11,15 +11,12 @@ import { createButton, el, showConfirmDialog, showToast } from "./ui_template_el import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; import type { DrawingTemplateHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Drawing"; -import type { - InputsCardHandle, - LinkDoc, - ValueDoc, -} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard"; -import type { StructureDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import type { InputsValueHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs"; +import { pillsOf, type ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model"; +import type { InputLinks, StructureDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import type { EditRequest } from "./ui_template_item_picker_detail"; import { itemName } from "./ui_template_item_picker_data"; -import { fakeLink, loadInputs, PART_OF } from "./ui_template_item_picker_project"; +import { loadInputs, PART_OF } from "./ui_template_item_picker_project"; export interface EditView { root: HTMLElement; @@ -39,7 +36,8 @@ const TEXT = { loadFailed: "복사본을 못 읽음 — {value}", discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?", outOfRange: "범위 밖 값이 있어 저장하지 못함", - noAreaPill: "면적산출 알약 없음 — 선택 입력에서 자리를 면적산출로 이으면 여기 뜸", + noAreaPill: + "면적산출 알약 없음 — 선택 입력에서 자리를 면적산출로 이으면 여기 뜸(마스터 · 회사 · 개인 층에서)", drawing: "도면", area: "면적산출", material: "재료산출", @@ -57,7 +55,7 @@ export function createEditView(req: EditRequest, onBack: () => void): EditView { let doc: unknown = null; let drawing: DrawingTemplateHandle | null = null; let sheet: SpreadsheetHandle | null = null; - let card: InputsCardHandle | null = null; + let card: InputsValueHandle | null = null; const word = { structure: TEXT.drawing, @@ -97,36 +95,29 @@ export function createEditView(req: EditRequest, onBack: () => void): EditView { body.replaceChildren(el("p", { className: "ui-item-picker__hint", text })); }; - /** 면적산출 알약 — 항목 연결에서 자리가 「면적」 인 입력(25-1b) */ - async function areaLinks(): Promise<{ links: LinkOptions; notice?: string }> { - const { list, fake } = await loadInputs(row); - const link = - ((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ?? - (fake ? fakeLink(list) : null); - const 항목 = (link?.줄 ?? []) - .filter((one) => one.자리 === "면적") - .map((one) => ({ - 키: one.알약 ?? `입력|${one.로직}|${one.입력}`, - 이름: one.입력, - 단위: - list.로직.find((logic) => logic.로직 === one.로직)?.입력.find((x) => x.이름 === one.입력) - ?.단위 ?? "", - })); - return 항목.length - ? { links: { 항목, 모드: "입력" } } - : { links: { 항목: [], 모드: "입력" }, notice: TEXT.noAreaPill }; + /** 복사본의 항목 연결 — 자리가 면적 · 재료인 입력이 그 시트 알약(25-1b · 키 모양은 `pillsOf`) */ + const linkOf = async (): Promise => + (await items.read(info.폴더, "입력연결")) as InputLinks | null; + + async function sheetLinks(kindOf: "area" | "material"): Promise { + const [list, link, { searchMaterials }] = await Promise.all([ + loadInputs(row), + linkOf(), + import("../M01_MasterData/M01_MasterData_Api_Fetch"), + ]); + const 항목 = pillsOf(list, link, kindOf === "area" ? "면적" : "재료"); + if (kindOf === "area") { + if (!항목.length) note.textContent = TEXT.noAreaPill; + return { 항목, 모드: "입력" }; + } + return { 항목, 모드: "자재", 자재찾기: searchMaterials }; } async function mountSheet(kindOf: "area" | "material"): Promise { - const [{ createSpreadsheet }, pills, { searchMaterials }] = await Promise.all([ + const [{ createSpreadsheet }, links] = await Promise.all([ import("../A00_Common/spreadsheet/spreadsheet"), - kindOf === "area" - ? areaLinks() - : Promise.resolve({ links: undefined as LinkOptions | undefined, notice: undefined }), - import("../M01_MasterData/M01_MasterData_Api_Fetch"), + sheetLinks(kindOf), ]); - const links: LinkOptions = pills.links ?? { 항목: [], 모드: "자재", 자재찾기: searchMaterials }; - if (pills.notice) note.textContent = pills.notice; const host = el("div", { className: "ui-item-picker__edit-sheet" }); body.replaceChildren(host); sheet = createSpreadsheet(host, doc as Workbook, { @@ -155,16 +146,14 @@ export function createEditView(req: EditRequest, onBack: () => void): EditView { } async function mountInputs(): Promise { - const [{ createInputsCard }, loaded] = await Promise.all([ - import("../M02_MasterTemplete/M02_MasterTemplete_InputsCard"), + const [{ createInputsValueCard }, list, link] = await Promise.all([ + import("../M02_MasterTemplete/M02_MasterTemplete_Inputs"), loadInputs(row), + linkOf(), ]); - const link = - ((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ?? - (loaded.fake ? fakeLink(loaded.list) : null); const values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {}; - card = createInputsCard({ mode: "value", onChange: () => setDirty(true) }); - card.set({ list: loaded.list, link, values }); + card = createInputsValueCard({ mode: "value", onChange: () => setDirty(true) }); + card.set({ list, link, values }); body.replaceChildren( el("div", { className: "ui-item-picker__edit-inputs", children: [card.root] }), ); diff --git a/ui_template/ui_template_item_picker_project.ts b/ui_template/ui_template_item_picker_project.ts index d06fd77f..7275b48a 100644 --- a/ui_template/ui_template_item_picker_project.ts +++ b/ui_template/ui_template_item_picker_project.ts @@ -1,26 +1,21 @@ /* ============================================================================= * ui_template_item_picker_project.ts - * 항목 고르기 모달의 **자료 창구**(계획서 25-2d) — 프로젝트 복사본 · 원본 층 · 입력 목록을 한 곳에서 부름. + * 항목 고르기 모달의 **자료 창구**(계획서 25-2d) — 프로젝트 복사본 · 입력 목록 · 계산을 한 곳에서 부름. * - * 프로젝트 복사본 GET /api/projects/{id}/items 목록 - * GET|PUT /api/projects/{id}/items/{폴더}/{부분} 읽기 · 고침(sub6 · 25-1b 약속) - * POST /api/projects/{id}/items 미리 가져오기(body = 원본 참조) - * POST /api/projects/{id}/items/{폴더}/refresh 새로 가져오기 - * 입력 목록 GET /api/m02/layers/{층}/items/{키}/inputs?owner= (sub4 · 25-2a) - * - * ⚠ 이음 자리 — 위 길이 서버에 아직 없으면(`{"detail":"Not Found"}`) **약속 모양의 가짜 응답**으로 동작함: - * 복사본 = 브라우저 저장소(`aislo.itemsFake.{프로젝트}`)에 원본을 복사해 두고 거기서 읽고 고침 · - * 입력 목록 = M01 조합 · 로직 길로 짜 맞춤 · 항목 연결 = 「모든 입력이 선택」(가짜 기본값). - * 실서버가 오면 같은 함수가 실서버 응답을 그대로 씀(가짜 분기는 길이 있으면 안 탐) — `FAKE` 로 찾음. + * 프로젝트 복사본(sub6 · 25-2b) + * GET /api/projects/{id}/items → {항목: [{폴더, 이름, 원본, 원본판, 가져온날, 마스터바뀜, …}]} + * POST /api/projects/{id}/items {layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날} + * GET|PUT /api/projects/{id}/items/{폴더}/{부분} → {종류, 판, 내용} · 고침은 {판, 내용}(판이 다르면 409) + * POST …/{폴더}/refresh 새로 가져오기(입력값은 같은 키면 살림) + * POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력) + * 입력 목록 · 항목 연결(sub4 · 25-2a) — `M02_MasterTemplete_Api_Fetch` 의 `fetchItemInputs` · `readInputLinks` * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import { readTemplate, type Kind } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; -import type { - InputsList, - LinkDoc, - LogicInput, -} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard"; +import { + fetchItemInputs, + type ItemInputs, +} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data"; /** 복사본 폴더 안 부분 — 25-1b 약속 그대로 */ @@ -36,27 +31,49 @@ export const PART_OF: Record<"structure" | "area" | "material", ProjectPart> = { export interface ProjectItemInfo { 폴더: string; + 이름?: string | null; 원본: { layer: string; owner: string | null; key: string; name?: string }; - 원본판?: string; - 가져온날?: string; + 원본판?: string | null; + 가져온날?: string | null; /** 원본(마스터 · 회사 · 개인)이 가져온 뒤 바뀜 */ 마스터바뀜?: boolean; + 원본없음?: boolean; + 구조물?: string[]; + 고침?: boolean; } /** `{층}_{주인 슬러그}_{키}` — 서버 `snapshot_folder_name` 과 같은 글(owner 없음 = none · `/` = `-`) */ export const folderOf = (row: PickerRow): string => `${layerOf(row)}_${(ownerOf(row) ?? "none").replace(/\//g, "-")}_${row.key}`; +/** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빠진 입력 수 · 멈춘 로직 수 */ +export interface ProjectCalc { + total: number; + gaps: number; + stuck: number; +} + interface Reply { status: number; body: unknown; } const enc = encodeURIComponent; -const reasonOf = (reply: Reply): string => { + +/** 서버가 막은 까닭 그대로 — 문장 · `{message}`(409) · 입력 검사 목록(`[{msg}]`) */ +function reasonOf(reply: Reply): string { const detail = (reply.body as { detail?: unknown } | null)?.detail; - return typeof detail === "string" && detail ? detail : `HTTP ${reply.status}`; -}; + if (typeof detail === "string" && detail) return detail; + const message = (detail as { message?: unknown } | null)?.message; + if (typeof message === "string" && message) return message; + if (Array.isArray(detail)) { + const lines = detail + .map((one) => String((one as { msg?: unknown })?.msg ?? "")) + .filter(Boolean); + if (lines.length) return lines.join(" · "); + } + return `HTTP ${reply.status}`; +} async function call(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -67,224 +84,105 @@ async function call(path: string, init: RequestInit = {}): Promise { return { status: response.status, body: await response.json().catch(() => null) }; } -/** 길 자체가 없음(서버가 아직 안 만듦) — 「문서가 없음」의 404 와 갈라 봄 */ -const routeMissing = (reply: Reply): boolean => - reply.status === 404 && (reply.body as { detail?: unknown } | null)?.detail === "Not Found"; - -const send = (body: unknown): RequestInit => ({ method: "POST", body: JSON.stringify(body) }); - -/* ── 가짜 복사본(FAKE) — 브라우저 저장소 ─────────────────────────────────────── */ -interface FakeItem { - info: ProjectItemInfo; - parts: Partial>; -} -type FakeDb = Record; -const memory = new Map(); -const storeKey = (projectId: string): string => `aislo.itemsFake.${projectId}`; - -function fakeLoad(projectId: string): FakeDb { - const kept = memory.get(projectId); - if (kept) return kept; - try { - const raw = localStorage.getItem(storeKey(projectId)); - const db = raw ? (JSON.parse(raw) as FakeDb) : {}; - memory.set(projectId, db); - return db; - } catch { - const db: FakeDb = {}; - memory.set(projectId, db); - return db; - } -} - -function fakeSave(projectId: string): void { - try { - localStorage.setItem(storeKey(projectId), JSON.stringify(fakeLoad(projectId))); - } catch { - // 저장소가 꽉 차도 이 탭에서는 메모리 값으로 계속 됨 - } -} - -/** 원본 층에서 도면 · 면적 · 재료 문서를 읽고 판을 이은 글 — 복사본 `원본판` */ -async function readOriginals( - row: PickerRow, -): Promise<{ parts: FakeItem["parts"]; version: string }> { - const kinds: ("structure" | "area" | "material")[] = ["structure", "area", "material"]; - const got = await Promise.all( - kinds.map((kind) => readTemplate(layerOf(row), kind as Kind, row.열, null).catch(() => null)), - ); - const parts: FakeItem["parts"] = {}; - kinds.forEach((kind, index) => { - if (got[index]) parts[PART_OF[kind]] = structuredClone(got[index]!.문서); - }); - return { parts, version: got.map((one) => one?.판 ?? "").join("|") }; -} +const post = (body: unknown): RequestInit => ({ method: "POST", body: JSON.stringify(body) }); export interface ProjectItems { - /** 프로젝트에 가져온 항목 목록 */ + /** 프로젝트에 가져온 항목 목록 — 서버가 항목당 0.5초쯤 걸려 모달이 열 때 한 번만 부르고 들고 있음 */ list: () => Promise; - /** 복사본 부분 하나 — 없으면 null */ + /** 복사본 부분 하나(`내용`) — 없으면 null · 판은 고칠 때를 위해 들고 있음 */ read: (folder: string, part: ProjectPart) => Promise; - /** 복사본 고침 — 못 하면 서버 까닭 그대로 던짐(권한 · 판 …) */ + /** 복사본 고침 — 못 하면 서버 까닭 그대로 던짐(권한 · 판이 다름 …) */ write: (folder: string, part: ProjectPart, doc: unknown) => Promise; /** 프로젝트로 미리 가져옴 */ importItem: (row: PickerRow) => Promise; - /** 새로 가져옴 — 복사본을 원본으로 덮음 */ - refresh: (row: PickerRow, info: ProjectItemInfo) => Promise; - /** 가져온 뒤 원본이 바뀌었나 */ - masterChanged: (row: PickerRow, info: ProjectItemInfo) => Promise; - /** 가짜로 동작 중인가(서버 길이 아직 없음) */ - isFake: () => boolean; + /** 새로 가져옴 — 복사본을 원본으로 덮음(넣은 값은 같은 키면 서버가 살림) */ + refresh: (info: ProjectItemInfo) => Promise; + /** 복사본만으로 계산 — 계산할 것이 없거나 못 하면 null */ + calc: (folder: string) => Promise; } const clients = new Map(); -/** 프로젝트 하나의 창구 — 같은 프로젝트는 한 벌(가짜 판정 · 목록 재사용) */ +/** 프로젝트 하나의 창구 — 같은 프로젝트는 한 벌(고칠 때 쓸 판을 들고 있음) */ export function projectItems(projectId: string): ProjectItems { const kept = clients.get(projectId); if (kept) return kept; - let fake = false; const base = `/projects/${enc(projectId)}/items`; + const versions = new Map(); + const at = (folder: string, part: string): string => `${base}/${enc(folder)}/${enc(part)}`; const client: ProjectItems = { - isFake: () => fake, list: async () => { - if (!fake) { - const reply = await call(base); - if (reply.status === 200 && Array.isArray(reply.body)) - return reply.body as ProjectItemInfo[]; - if (!routeMissing(reply)) throw new Error(reasonOf(reply)); - fake = true; // FAKE — 서버 길이 아직 없음 - } - return Object.values(fakeLoad(projectId)).map((item) => item.info); + const reply = await call(base); + const rows = (reply.body as { 항목?: ProjectItemInfo[] } | null)?.항목; + if (reply.status !== 200 || !Array.isArray(rows)) throw new Error(reasonOf(reply)); + return rows; }, read: async (folder, part) => { - if (!fake) { - const reply = await call(`${base}/${enc(folder)}/${enc(part)}`); - if (reply.status === 200) return reply.body; - if (reply.status === 404) return null; - throw new Error(reasonOf(reply)); - } - return fakeLoad(projectId)[folder]?.parts[part] ?? null; + const reply = await call(at(folder, part)); + if (reply.status === 404) return null; + if (reply.status !== 200) throw new Error(reasonOf(reply)); + const got = reply.body as { 판?: string | null; 내용?: unknown }; + versions.set(`${folder}/${part}`, got.판 ?? null); + return got.내용 ?? null; }, write: async (folder, part, doc) => { - if (!fake) { - const reply = await call(`${base}/${enc(folder)}/${enc(part)}`, { - method: "PUT", - body: JSON.stringify(doc), - }); - if (reply.status >= 400) throw new Error(reasonOf(reply)); - return; - } - const item = fakeLoad(projectId)[folder]; - if (!item) throw new Error("프로젝트에 가져오지 않은 항목"); - item.parts[part] = structuredClone(doc); - fakeSave(projectId); + const key = `${folder}/${part}`; + if (!versions.has(key)) await client.read(folder, part); // 고칠 판을 먼저 앎 + const reply = await call(at(folder, part), { + method: "PUT", + body: JSON.stringify({ 판: versions.get(key) ?? null, 내용: doc }), + }); + if (reply.status >= 400) throw new Error(reasonOf(reply)); + versions.set(key, (reply.body as { 판?: string } | null)?.판 ?? null); }, importItem: async (row) => { const ref = { layer: layerOf(row), owner: ownerOf(row), key: row.key }; - if (!fake) { - const reply = await call(base, send(ref)); - if (reply.status < 300) return reply.body as ProjectItemInfo; - if (!routeMissing(reply)) throw new Error(reasonOf(reply)); - fake = true; // FAKE - } - const { parts, version } = await readOriginals(row); - const info: ProjectItemInfo = { - 폴더: folderOf(row), - 원본: { ...ref, name: row.종류 }, - 원본판: version, - 가져온날: new Date().toISOString(), + const reply = await call(base, post(ref)); + if (reply.status >= 300) throw new Error(reasonOf(reply)); + const got = reply.body as { 폴더: string; 가져온날?: string | null }; + return { 폴더: got.폴더, 원본: ref, 가져온날: got.가져온날, 마스터바뀜: false }; + }, + refresh: async (info) => { + const reply = await call(`${base}/${enc(info.폴더)}/refresh`, post({})); + if (reply.status >= 300) throw new Error(reasonOf(reply)); + for (const key of [...versions.keys()]) + if (key.startsWith(`${info.폴더}/`)) versions.delete(key); + inputsCache.clear(); // 원본이 바뀌었을 수 있어 입력 목록도 다시 받음 + const got = reply.body as { 가져온날?: string | null; 원본판?: string | null }; + return { + ...info, + 가져온날: got.가져온날, + 원본판: got.원본판, 마스터바뀜: false, + 고침: false, }; - fakeLoad(projectId)[info.폴더] = { info, parts }; - fakeSave(projectId); - return info; }, - refresh: async (row, info) => { - if (!fake) { - const reply = await call(`${base}/${enc(info.폴더)}/refresh`, send({})); - if (reply.status >= 400) throw new Error(reasonOf(reply)); - return reply.body as ProjectItemInfo; - } - // 원본에서 온 문서(도면 · 면적 · 재료)만 새로 덮음 — 사용자가 넣은 값(입력값 · 고른자재)은 그대로 - const kept = fakeLoad(projectId)[info.폴더]?.parts; - const fresh = await client.importItem(row); - const item = fakeLoad(projectId)[fresh.폴더]; - for (const part of ["입력값", "고른자재"] as const) - if (kept?.[part] !== undefined && item) item.parts[part] = kept[part]; - fakeSave(projectId); - return fresh; - }, - masterChanged: async (row, info) => { - if (!fake) return info.마스터바뀜 === true; - return (await readOriginals(row)).version !== (info.원본판 ?? ""); + calc: async (folder) => { + const reply = await call(`${base}/${enc(folder)}/calc`, post({})); + if (reply.status !== 200) return null; + const got = reply.body as { 계?: number; 빠진입력?: unknown[]; 멈춤?: unknown[] }; + return { + total: Number(got.계 ?? 0), + gaps: got.빠진입력?.length ?? 0, + stuck: got.멈춤?.length ?? 0, + }; }, }; clients.set(projectId, client); return client; } -/* ── 입력 목록(원본 층 기준) — 25-1b 약속 길 · 없으면 FAKE ─────────────────────── */ -const inputsCache = new Map>(); +/* ── 입력 목록(원본 층 기준) — 같은 항목은 한 번만 받음 ───────────────────────── */ +const inputsCache = new Map>(); -async function fakeInputs(row: PickerRow): Promise { - const combo = row.일위대가 ?? ""; - const got = await call(`/m01/combo?key=${enc(combo)}`); - const header = (got.body as { combo?: { 이름?: string; 담은로직?: { 로직: string }[] } } | null) - ?.combo; - if (got.status !== 200 || !header) throw new Error(reasonOf(got)); - const list: InputsList = { 일위대가: combo, 이름: header.이름 ?? combo, 로직: [], 빠진로직: [] }; - for (const one of header.담은로직 ?? []) { - const [logic, auto] = await Promise.all([ - call(`/m01/logic?key=${enc(one.로직)}`), - call(`/m01/logic/auto?key=${enc(one.로직)}`), - ]); - const row = ( - logic.body as { - logic?: { 이름?: string; 결과단위?: string; 입력?: (LogicInput & { 비고?: string })[] }; - } | null - )?.logic; - if (logic.status !== 200 || !row) { - list.빠진로직.push(one.로직); - continue; - } - const values = (auto.body as { 값?: Record } | null)?.값 ?? {}; - list.로직.push({ - 로직: one.로직, - 이름: row.이름 ?? one.로직, - 결과단위: row.결과단위 ?? null, - 입력: (row.입력 ?? []).map((input) => ({ ...input, 자동값: values[input.이름] ?? null })), - }); - } - return list; -} - -/** 원본 항목의 입력 목록 — 25-2a 길 → 없으면 가짜 */ -export function loadInputs(row: PickerRow): Promise<{ list: InputsList; fake: boolean }> { +/** 원본 항목의 입력 목록(로직마다 갈라 · 합치지 않음) — 못 받으면 다음에 다시 */ +export function loadInputs(row: PickerRow): Promise { const id = `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`; const kept = inputsCache.get(id); if (kept) return kept; - const made = (async () => { - const owner = ownerOf(row); - const reply = await call( - `/m02/layers/${layerOf(row)}/items/${enc(row.key)}/inputs${owner ? `?owner=${enc(owner)}` : ""}`, - ); - if (reply.status === 200) return { list: reply.body as InputsList, fake: false }; - if (!routeMissing(reply)) throw new Error(reasonOf(reply)); - return { list: await fakeInputs(row), fake: true }; // FAKE - })(); + const made = fetchItemInputs(layerOf(row), row.key); inputsCache.set(id, made); - made.catch(() => inputsCache.delete(id)); // 못 받으면 다음에 다시 + made.catch(() => inputsCache.delete(id)); return made; } - -/** 가짜 항목 연결 — 모든 입력이 「선택」(실서버가 오면 그 파일을 씀) */ -export function fakeLink(list: InputsList): LinkDoc { - return { - 판: "fake", - 줄: list.로직.flatMap((logic) => - logic.입력.map((input) => ({ 로직: logic.로직, 입력: input.이름, 자리: "선택" as const })), - ), - }; -}