From db1bb9aa872e1dd578133717c5f69f189ea3f38c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 23:18:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EC=A7=91=EA=B3=84=ED=91=9C=20=EB=84=B7=EC=A7=B8=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=E3=80=8C=EC=84=A0=ED=83=9D=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=E3=80=8D=20=E2=80=94=20=EB=A1=9C=EC=A7=81=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EA=B3=A0=EB=A5=B4=EA=B8=B0(=EB=A9=B4=EC=A0=81=20?= =?UTF-8?q?=C2=B7=20=EC=9E=AC=EB=A3=8C=20=C2=B7=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=C2=B7=20=EC=95=88=20=EC=94=80)=20=C2=B7=20=EC=95=8C=EC=95=BD?= =?UTF-8?q?=20=EC=A4=84=EC=97=90=20=EB=9C=B8=20(PLAN=2025-2c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카드 = 로직 이름 · 결과단위 묶음 아래 입력 줄(이름 · 설명 · 단위 · 고르기/범위 · 자동값 흐리게) + 자리 상자 · 처음은 모두 안 씀 - 저장 = 집계표 [저장]에 이어 PUT input-links(자동저장 없음) · 읽기만 층은 상자 잠금 - 면적 · 재료로 고른 입력은 그 시트 알약 줄에 「로직 이름 · 입력 이름」 · 자재 모드에서도 [목록에서 빼기] 막음(고정) - 알약 줄 시험을 합계 줄 아래 자리에 맞춤(25-0 뒤 어긋난 것) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- A00_Common/spreadsheet/spreadsheet_links.ts | 4 +- .../M02_MasterTemplete_Api_Fetch.ts | 70 +++++ .../M02_MasterTemplete_Inputs.css | 105 +++++++ .../M02_MasterTemplete_Inputs.ts | 278 ++++++++++++++++++ .../M02_MasterTemplete_Inputs_Model.ts | 103 +++++++ .../M02_MasterTemplete_Structure.css | 2 +- .../M02_MasterTemplete_Structure.ts | 27 +- .../M02_MasterTemplete_UI_Main.ts | 8 +- resources/tester/helper_m02_inputs.cjs | 56 ++++ .../spreadsheet/test_spreadsheet_d_input.py | 6 +- resources/tester/test_m02_inputs.py | 80 +++++ 11 files changed, 728 insertions(+), 11 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Inputs.css create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Inputs.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts create mode 100644 resources/tester/helper_m02_inputs.cjs create mode 100644 resources/tester/test_m02_inputs.py diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index e81717ff..963f0a68 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -35,6 +35,8 @@ export interface LinkItem { 단위?: string; /** 자재 알약 — 연결하면 이 정보가 칸 연결에 들어감 */ 자재?: LinkMaterial; + /** 호스트가 정한 알약 — 자재 모드에서도 [목록에서 빼기]가 안 뜸(예: 선택 입력 카드가 자리를 고른 입력) */ + 고정?: boolean; } export interface LinkOptions { @@ -208,7 +210,7 @@ export function mountLinks( }; b.addEventListener("contextmenu", (e) => { e.preventDefault(); - const inList = opts?.모드 === "자재" && items.some((i) => i.키 === item.키); + const inList = opts?.모드 === "자재" && !item.고정 && items.some((i) => i.키 === item.키); if (ctx.readOnly || !(hit || inList)) return; const o = ctx.root.getBoundingClientRect(); // 메뉴는 부품 안 절대 위치 — 누른 자리에 뜨게 menu.open(e.clientX - o.left, e.clientY - o.top, [ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 95d6bd83..52499b89 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -333,3 +333,73 @@ export const fetchSources = (id: string): Promise => call(`/projects/${ /** 설계값을 채운 표 문서 — 서버가 그때그때 채움 · 저장 안 함 */ export const readFilled = (id: string, name: string): Promise => call(`/projects/${enc(id)}/tables/${enc(name)}/filled`); + +/* --- 선택 입력 (PLAN 25-1b) — 항목(집계표 열 하나) 일위대가의 로직별 입력 목록 · 입력 자리 연결 --- + * 길은 층 항목 길(`/layers/{층}/items/{SS 키}/…`) · 층 폴더 저장은 연결만(값은 안 받음). */ + +/** 층 항목 목록 한 줄 — 열 id ↔ SS 키 */ +export interface ItemRow { + key: string; + 열: string; + 일위대가: string | null; +} + +/** 로직의 입력 하나 — 고르기(선택지) · 범위 · 자동값은 보이기만 */ +export interface ItemInput { + 이름: string; + 설명?: string; + 단위?: string; + 고르기?: unknown[]; + 범위?: unknown; + 자동값?: unknown; +} + +export interface ItemLogic { + 로직: string; + 이름: string; + 결과단위?: string; + 입력: ItemInput[]; +} + +export interface ItemInputs { + 일위대가: string | null; + 이름?: string; + 로직: ItemLogic[]; + 빠진로직: string[]; +} + +/** 입력 자리 — 면적산출 알약 · 재료산출 알약 · 선택 컨테이너 · 안 씀(null) */ +export type InputSeat = "면적" | "재료" | "선택"; + +export interface InputLine { + 로직: string; + 입력: string; + 자리: InputSeat | null; + 알약?: string; +} + +export interface InputLinks { + 판: string | null; + 줄: InputLine[]; +} + +const itemPath = (layer: Layer, key: string, tail: string): string => + `/layers/${layer}/items/${enc(key)}/${tail}`; + +export const fetchItemRows = (layer: Layer): Promise => + call(`/items?layer=${layer}`); + +export const fetchItemInputs = (layer: Layer, key: string): Promise => + call(itemPath(layer, key, "inputs")); + +export const readInputLinks = (layer: Layer, key: string): Promise => + call(itemPath(layer, key, "input-links")); + +/** 판이 다르면 409(`StaleError`) · 없는 키는 422 */ +export const saveInputLinks = ( + layer: Layer, + key: string, + 판: string | null, + 줄: InputLine[], +): Promise<{ 판: string }> => + call(itemPath(layer, key, "input-links"), { method: "PUT", body: JSON.stringify({ 판, 줄 }) }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.css b/M02_MasterTemplete/M02_MasterTemplete_Inputs.css new file mode 100644 index 00000000..fff107c9 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.css @@ -0,0 +1,105 @@ +/* 「선택 입력」 카드 — 로직마다 묶은 입력 줄 + 줄마다 자리 고르기. 색은 테마 토큰. */ + +.m02-inputs__summary { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-12); + align-items: baseline; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m02-inputs__count { + color: var(--color-text); + font-weight: 600; +} + +/* 카드 본문이 남는 높이를 채우고 넘치면 안에서 스크롤 */ +.m02-inputs__body { + display: flex; + flex: 1 1 0; + flex-direction: column; + gap: var(--spacing-12); + min-height: var(--m02-struct-body-min); + min-width: 0; + overflow: auto; + padding: var(--spacing-8) 0; +} + +.m02-inputs__logic { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m02-inputs__logic-title { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-8); + align-items: baseline; + margin: 0; + padding-bottom: var(--spacing-4); + border-bottom: 1px solid var(--color-border); + color: var(--color-text); + font-size: var(--text-body-sm); + overflow-wrap: anywhere; +} + +.m02-inputs__logic-title small, +.m02-inputs__row small { + color: var(--color-text-muted); + font-size: var(--text-caption); + font-weight: 400; +} + +.m02-inputs__row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-8); + align-items: center; + justify-content: space-between; + padding: var(--spacing-4) 0; +} + +.m02-inputs__info { + display: flex; + flex: 1 1 12rem; + flex-wrap: wrap; + gap: 2px var(--spacing-8); + align-items: baseline; + min-width: 0; + overflow-wrap: anywhere; +} + +.m02-inputs__name { + color: var(--color-text); + font-size: var(--text-body-sm); +} + +.m02-inputs__desc, +.m02-inputs__hint { + flex-basis: 100%; +} + +/* 자동값 — 흐리게(입력 값이 아니라 보기용 첫 선택지) */ +.m02-inputs__auto { + flex-basis: 100%; + opacity: 0.6; + font-style: italic; +} + +.m02-inputs__seat { + flex: none; + max-width: 100%; + padding: 4px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm, 4px); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + font-size: var(--text-body-sm); +} + +.m02-inputs__seat:disabled { + opacity: 0.7; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts new file mode 100644 index 00000000..3462dd1b --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts @@ -0,0 +1,278 @@ +/* ============================================================================= + * M02_MasterTemplete_Inputs.ts + * 구조물집계표 아래 넷째 카드 「선택 입력」(PLAN 25-2c) — 고른 열(항목) 일위대가의 로직마다 묶어 + * 입력 목록을 보이고, 입력마다 자리(면적산출 알약 · 재료산출 알약 · 선택 · 안 씀)를 사용자가 고름. + * + * · 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음 · 이름이 같은 입력도 로직마다 따로. + * · 마스터 층에서는 값을 받지 않음 — 자리 연결만(값은 프로젝트 복사본 몫 · 25-2b). + * · 저장은 집계표 [저장]과 같은 단추 흐름 — 여기는 고른 것을 초안으로 들고 있다가 `save()` 가 서버로 보냄 + * (자동저장 없음). 고름이 바뀌면 `onDirty` 로 메인에 알림(떠날 때 물음). + * · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links"; +import { + fetchItemInputs, + fetchItemRows, + getActiveLayer, + readInputLinks, + saveInputLinks, + type InputSeat, + type ItemInputs, + type ItemRow, + type Layer, +} from "./M02_MasterTemplete_Api_Fetch"; +import { + autoText, + countOf, + inputKey, + linesOf, + pillsOf, + seatsOf, + SEAT_OPTIONS, + valueHint, + type SeatMap, +} from "./M02_MasterTemplete_Inputs_Model"; +import "./M02_MasterTemplete_Inputs.css"; + +const TEXT = { + title: "선택 입력", + count: "로직 {logics} · 입력 {inputs}", + chosen: "면적산출 {area} · 재료산출 {material} · 선택 {pick}", + auto: "자동값", + noItem: "집계표를 먼저 저장하면 입력 목록이 나옴", + noCombo: "일위대가를 먼저 고름 — 집계표 일위대가 줄에서 고름", + unsavedCombo: "고른 일위대가는 집계표를 저장하면 입력 목록이 나옴", + noLogic: "이 일위대가에 담긴 로직이 없음", + missing: "빠진 로직 {value}", + failed: "입력 목록을 못 받음 — {value}", + loading: "입력 목록을 받는 중…", + seat: "자리", +}; + +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); +const fill = (text: string, values: Record): string => + text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? "")); + +/** 열 하나의 초안 — 서버 목록 · 판 · 고른 자리 · 바뀜 */ +interface Draft { + key: string; + 판: string | null; + list: ItemInputs; + seats: SeatMap; + dirty: boolean; +} + +export interface InputsCardOptions { + /** 이 층에서 고칠 수 있나 — 아니면 자리 고르기가 잠김 */ + isAdmin: boolean; + /** 고름이 바뀜 — 메인의 「저장 안 한 고침」 표시 */ + onDirty: () => void; +} + +export interface InputsCardHandle { + root: HTMLElement; + /** 고른 열 — `combo` = 집계표에 지금 고른 일위대가(저장 전일 수 있음) · null 이면 카드 비움 */ + show: (colId: string | null, combo: string | null) => Promise; + /** 고른 게 저장 안 됐나 */ + dirty: () => boolean; + /** 바뀐 열마다 서버에 저장 — 실패하면 던짐(판이 다르면 `StaleError`) */ + save: () => Promise; + /** 항목 목록(열 ↔ 키)을 다시 받게 함 — 집계표 [저장] 뒤 */ + invalidate: () => void; + destroy: () => void; +} + +export function createInputsCard(opts: InputsCardOptions): InputsCardHandle { + const drafts = new Map(); + let rows: Promise | null = null; + let seq = 0; + + const summary = el("div", { className: "m02-inputs__summary" }); + const body = el("div", { className: "m02-inputs__body" }); + const root = el("section", { + className: "m02-struct__card m02-inputs ui-collapsible ui-sidebar-section ui-sidebar-card", + children: [el("h3", { className: "ui-collapsible__title", text: TEXT.title }), summary, body], + }); + + const note = (text: string): HTMLElement => el("p", { className: "m02-struct__empty", text }); + const clear = (): void => { + summary.replaceChildren(); + body.replaceChildren(); + }; + + const itemRows = (): Promise => + (rows ??= fetchItemRows(getActiveLayer()).catch((error: unknown) => { + rows = null; + throw error; + })); + + function paintSummary(draft: Draft): void { + const count = countOf(draft.list); + // 목록에 있는 입력의 자리만 셈 — 서버 목록에서 빠진 입력의 옛 고름은 안 셈(저장 때도 빠짐) + const listed = new Set( + draft.list.로직.flatMap((l) => l.입력.map((i) => inputKey(l.로직, i.이름))), + ); + const seats = [...draft.seats].filter(([key]) => listed.has(key)).map(([, seat]) => seat); + const by = (seat: InputSeat): number => seats.filter((s) => s === seat).length; + summary.replaceChildren( + el("span", { className: "m02-inputs__count", text: fill(TEXT.count, count) }), + el("span", { + className: "m02-inputs__chosen", + text: fill(TEXT.chosen, { area: by("면적"), material: by("재료"), pick: by("선택") }), + }), + ); + } + + function paint(draft: Draft): void { + paintSummary(draft); + const groups = draft.list.로직.map((logic) => + el("div", { + className: "m02-inputs__logic", + attrs: { "data-logic": logic.로직 }, + children: [ + el("h4", { + className: "m02-inputs__logic-title", + children: [ + el("span", { text: logic.이름 }), + ...(logic.결과단위 ? [el("small", { text: logic.결과단위 })] : []), + el("small", { className: "m02-inputs__logic-key", text: logic.로직 }), + ], + }), + ...logic.입력.map((input) => { + const key = inputKey(logic.로직, input.이름); + const select = el("select", { + className: "m02-inputs__seat", + attrs: { "aria-label": `${logic.이름} ${input.이름} ${TEXT.seat}` }, + children: SEAT_OPTIONS.map((o) => + el("option", { text: o.label, attrs: { value: o.value } }), + ), + }) as HTMLSelectElement; + select.value = draft.seats.get(key) ?? ""; + select.disabled = !opts.isAdmin; + select.addEventListener("change", () => { + if (select.value) draft.seats.set(key, select.value as InputSeat); + else draft.seats.delete(key); + draft.dirty = true; + paintSummary(draft); + opts.onDirty(); + }); + const hint = valueHint(input); + const auto = autoText(input); + return el("div", { + className: "m02-inputs__row", + attrs: { "data-input": input.이름 }, + children: [ + el("div", { + className: "m02-inputs__info", + children: [ + el("span", { className: "m02-inputs__name", text: input.이름 }), + ...(input.단위 ? [el("small", { text: input.단위 })] : []), + ...(input.설명 + ? [el("small", { className: "m02-inputs__desc", text: input.설명 })] + : []), + ...(hint ? [el("small", { className: "m02-inputs__hint", text: hint })] : []), + ...(auto + ? [ + el("small", { + className: "m02-inputs__auto", + text: `${TEXT.auto} ${auto}`, + }), + ] + : []), + ], + }), + select, + ], + }); + }), + ], + }), + ); + const missing = draft.list.빠진로직; + body.replaceChildren( + ...(groups.length ? groups : [note(TEXT.noLogic)]), + ...(missing.length ? [note(fill(TEXT.missing, { value: missing.join(", ") }))] : []), + ); + } + + async function show(colId: string | null, combo: string | null): Promise { + const mine = ++seq; + clear(); + if (!colId) return; + const kept = drafts.get(colId); + if (kept) return paint(kept); + body.replaceChildren(note(TEXT.loading)); + try { + const layer: Layer = getActiveLayer(); + const row = (await itemRows()).find((r) => r.열 === colId); + if (mine !== seq) return; + if (!row) return void body.replaceChildren(note(combo ? TEXT.unsavedCombo : TEXT.noItem)); + if (!row.일위대가) + return void body.replaceChildren(note(combo ? TEXT.unsavedCombo : TEXT.noCombo)); + const [list, links] = await Promise.all([ + fetchItemInputs(layer, row.key), + readInputLinks(layer, row.key), + ]); + if (mine !== seq) return; + if (!list.일위대가) return void body.replaceChildren(note(TEXT.noCombo)); + const draft: Draft = { + key: row.key, + 판: links.판, + list, + seats: seatsOf(links), + dirty: false, + }; + drafts.set(colId, draft); + paint(draft); + } catch (error) { + if (mine === seq) body.replaceChildren(note(fill(TEXT.failed, { value: why(error) }))); + } + } + + return { + root, + show, + dirty: () => [...drafts.values()].some((d) => d.dirty), + save: async () => { + const layer = getActiveLayer(); + for (const draft of drafts.values()) { + if (!draft.dirty) continue; + const got = await saveInputLinks( + layer, + draft.key, + draft.판, + linesOf(draft.list, draft.seats), + ); + draft.판 = got.판; + draft.dirty = false; + } + }, + invalidate: () => { + rows = null; + for (const [id, draft] of drafts) if (!draft.dirty) drafts.delete(id); // 서버 것으로 다시 읽음 + }, + destroy: () => { + seq += 1; + root.remove(); + }, + }; +} + +/** 그 열의 면적산출 · 재료산출 편집 화면 알약 — 자리를 그 시트로 고른 입력(프로젝트 층은 25-2b 몫이라 없음) */ +export async function inputPills(column: string, seat: "면적" | "재료"): Promise { + const layer = getActiveLayer(); + if (layer === "project") return []; + try { + const row = (await fetchItemRows(layer)).find((r) => r.열 === column); + if (!row?.일위대가) return []; + const [list, links] = await Promise.all([ + fetchItemInputs(layer, row.key), + readInputLinks(layer, row.key), + ]); + return pillsOf(list, links, seat); + } catch { + return []; // 서버 길이 아직 없거나 못 읽어도 시트 편집은 그대로 + } +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts new file mode 100644 index 00000000..f6baf434 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts @@ -0,0 +1,103 @@ +/* ============================================================================= + * M02_MasterTemplete_Inputs_Model.ts + * 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록 · 값 글. + * 약속 = PLAN 25-1b: 입력 키 `{로직키}|{입력이름}` · 알약 키 `입력|{로직키}|{입력이름}` · + * 같은 이름의 입력도 로직마다 따로. 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음. + * ========================================================================== */ + +import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links"; +import type { + InputLine, + InputLinks, + InputSeat, + ItemInput, + ItemInputs, +} from "./M02_MasterTemplete_Api_Fetch"; + +/** 화면 글 ↔ 자리 — 「안 씀」 = 빈 글 */ +export const SEAT_OPTIONS: { value: "" | InputSeat; label: string }[] = [ + { value: "", label: "안 씀" }, + { value: "면적", label: "면적산출 알약" }, + { value: "재료", label: "재료산출 알약" }, + { value: "선택", label: "선택" }, +]; + +export const inputKey = (logic: string, name: string): string => `${logic}|${name}`; +export const pillKey = (logic: string, name: string): string => `입력|${logic}|${name}`; + +/** 자리 고름 표 — 키 = 입력 키 · 값 = 자리(안 쓰면 키 없음) */ +export type SeatMap = Map; + +export function seatsOf(links: InputLinks | null): SeatMap { + const out: SeatMap = new Map(); + for (const line of links?.줄 ?? []) + if (line.자리) out.set(inputKey(line.로직, line.입력), line.자리); + return out; +} + +/** 저장할 줄 — 자리를 고른 것만(없는 것 = 안 씀) · 면적 · 재료는 알약 키를 같이 */ +export function linesOf(list: ItemInputs, seats: SeatMap): InputLine[] { + const out: InputLine[] = []; + for (const logic of list.로직) + for (const input of logic.입력) { + const seat = seats.get(inputKey(logic.로직, input.이름)); + if (!seat) continue; + out.push({ + 로직: logic.로직, + 입력: input.이름, + 자리: seat, + ...(seat === "선택" ? {} : { 알약: pillKey(logic.로직, input.이름) }), + }); + } + return out; +} + +export const countOf = (list: ItemInputs): { logics: number; inputs: number } => ({ + logics: list.로직.length, + inputs: list.로직.reduce((sum, logic) => sum + logic.입력.length, 0), +}); + +/** 그 시트(면적산출 · 재료산출)의 알약 — 고른 자리가 그 시트인 입력만 · 「로직 이름 · 입력 이름」 */ +export function pillsOf( + list: ItemInputs, + links: InputLinks | null, + seat: "면적" | "재료", +): LinkItem[] { + const seats = seatsOf(links); + const out: LinkItem[] = []; + for (const logic of list.로직) + for (const input of logic.입력) { + if (seats.get(inputKey(logic.로직, input.이름)) !== seat) continue; + out.push({ + 키: pillKey(logic.로직, input.이름), + 이름: `${logic.이름} · ${input.이름}`, + ...(input.단위 ? { 단위: input.단위 } : {}), + 고정: true, + }); + } + return out; +} + +const text = (value: unknown): string => + value === null || value === undefined + ? "" + : typeof value === "object" + ? JSON.stringify(value) + : String(value); + +/** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */ +export function valueHint(input: ItemInput): string { + if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / "); + const range = input.범위; + if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`; + if (range && typeof range === "object") { + const r = range as Record; + const lo = r.최소 ?? r.min; + const hi = r.최대 ?? r.max; + if (lo !== undefined || hi !== undefined) return `${text(lo)} ~ ${text(hi)}`; + } + return text(range); +} + +/** 자동값 글 — 없으면 빈 글 */ +export const autoText = (input: ItemInput): string => text(input.자동값); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index e291744e..de26589e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -13,7 +13,7 @@ display: grid; flex: 1 1 0; /* 메인 칸(창 높이)에서 표 아래 남은 높이를 다 차지 */ grid-auto-rows: minmax(auto, 1fr); /* 카드 셋 같은 높이 */ - grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); padding-top: var(--spacing-12); border-top: 1px solid var(--color-border); min-width: 0; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 45059bc7..ab0871e3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -42,6 +42,7 @@ import { type DrawingTemplateDoc, type DrawingTemplateHandle, } from "./M02_MasterTemplete_Drawing"; +import { createInputsCard, inputPills, type InputsCardHandle } from "./M02_MasterTemplete_Inputs"; import "./M02_MasterTemplete_Structure.css"; /** 구조물 도면이 걸리는 집계표 이름 — 서버 `M02_MasterTemplete_Store.STRUCTURE_TABLE` 과 같은 글 */ @@ -207,8 +208,12 @@ export async function pillsFor( kind: SheetKind, column: string, ): Promise<{ links: LinkOptions; notice?: string }> { - if (kind === "material") return { links: { 항목: [], 모드: "자재", 자재찾기: searchMaterials } }; - const none = (notice: string) => ({ links: { 항목: [], 모드: "입력" as const }, notice }); + if (kind === "material") { + const own = await inputPills(column, "재료"); + return { links: { 항목: own, 모드: "자재", 자재찾기: searchMaterials } }; + } + const own = await inputPills(column, "면적"); // 선택 입력 카드가 면적산출로 고른 입력 + const none = (notice: string) => ({ links: { 항목: own, 모드: "입력" as const }, notice }); const table = await readTemplate(getActiveLayer(), "table", STRUCTURE_TABLE, null).catch( () => null, ); @@ -217,7 +222,7 @@ export async function pillsFor( if (!combo) return none(TEXT.noPrice); try { const got = await fetchComboInputs(combo); - const 항목 = got.물량.map((q) => ({ 키: q.키, 이름: q.이름, 단위: q.단위 })); + const 항목 = [...got.물량.map((q) => ({ 키: q.키, 이름: q.이름, 단위: q.단위 })), ...own]; return 항목.length ? { links: { 항목, 모드: "입력" } } : none(fill(TEXT.noInputs, combo)); } catch (error) { return none(fill(TEXT.inputsFailed, why(error))); @@ -232,6 +237,8 @@ export interface StructurePanelOptions { onEdit: (column: string, label: string, kind: EditKind) => void; /** 집계표에 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */ confirmLeave: () => Promise; + /** 「선택 입력」 카드에서 자리를 골랐음 — 저장 안 한 고침 표시 */ + onDirty?: () => void; } export interface StructurePanelHandle { @@ -240,6 +247,8 @@ export interface StructurePanelHandle { numbers: () => ColumnNumbers; /** 번호 목록 다시 받기 */ reload: () => Promise; + /** 「선택 입력」 카드에서 고른 자리를 서버에 저장 — 집계표 [저장] 뒤에 부름(실패하면 던짐) */ + saveInputs: () => Promise; /** 고른 열이 바뀜 — 같은 열이면 이름만 새로(CAD 를 다시 안 띄움) */ show: (colId: string | null, columns: StructureColumn[]) => void; /** 보던 열을 다시 읽음 — 집계표 [저장] 뒤(서버가 새 열 파일 · 번호를 만듦) */ @@ -311,11 +320,15 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane }; }; const sheets = [sheetCard("area"), sheetCard("material")]; + const inputs: InputsCardHandle = createInputsCard({ + isAdmin: opts.isAdmin, + onDirty: () => opts.onDirty?.(), + }); const root = el("div", { className: "m02-struct ui-sidebar-panel", attrs: { hidden: "" }, - children: [infoCard, ...sheets.map((c) => c.root)], + children: [infoCard, ...sheets.map((c) => c.root), inputs.root], }); attachCollapsible(root); @@ -397,6 +410,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane root.hidden = true; column = null; doc = null; + void inputs.show(null, null); return; } root.hidden = false; @@ -427,6 +441,8 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane c.number = String(book?.[PARTS[c.kind].field] ?? ""); }); paintBody(mine); + // 집계표에 지금 고른 일위대가(저장 전일 수 있음) — 카드가 「먼저 저장」 안내를 고름 + void inputs.show(col.id, (col as { 일위대가?: string | null }).일위대가 ?? null); } /** 글 열(NO · 측점 · 비고)은 구조물이 아니라 컨테이너를 닫음 */ @@ -516,7 +532,9 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane numbers: () => numbers, reload: async () => { numbers = await fetchStructureNumbers().catch(() => ({})); + inputs.invalidate(); // 새 열 · 바뀐 일위대가를 서버 항목 목록에서 다시 찾게 }, + saveInputs: () => inputs.save(), show, refresh: (columns) => { const at = column; @@ -527,6 +545,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane destroy: () => { seq += 1; drop(); + inputs.destroy(); root.remove(); }, }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 76bc23f3..cad56f18 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -23,6 +23,7 @@ import { type StructureColumn, type StructurePanelHandle, } from "./M02_MasterTemplete_Structure"; +import { inputPills } from "./M02_MasterTemplete_Inputs"; import { buildComboBar } from "./M02_MasterTemplete_Combo"; import { buildComboPanel, type ComboPanel } from "./M02_MasterTemplete_ComboPanel"; import { @@ -197,6 +198,7 @@ export function createMain( const made = createStructurePanel({ isAdmin, confirmLeave, + onDirty: () => (dirty = true), // 「선택 입력」 카드에서 자리를 고름 — 집계표 [저장]으로 함께 저장 // 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶 onEdit: (column, label, kind) => { void open({ layer, projectId: null, kind, name: column, label }); @@ -221,16 +223,17 @@ export function createMain( try { const master = at.layer !== "project" && kind === "material"; if (master && !matState) matState = await loadMaterialState(at.name); - const [{ createSpreadsheet }, pills] = await Promise.all([ + const [{ createSpreadsheet }, pills, own] = await Promise.all([ import("../A00_Common/spreadsheet/spreadsheet"), master ? null : pillsFor(kind, at.name), + master ? inputPills(at.name, "재료") : [], // 「선택 입력」 카드가 재료산출로 고른 입력 ]); if (mine !== seq) return; if (pills?.notice) showNotice([pills.notice]); const links: LinkOptions = pills?.links ?? ({ - 항목: toPills(pillItems ?? matState?.combo?.담은자재 ?? []), + 항목: [...toPills(pillItems ?? matState?.combo?.담은자재 ?? []), ...own], 모드: "자재", 자재찾기: searchMaterials, // 알약 막대에서 더하기 · 빼기 — 옆 판 표를 같은 목록으로 @@ -468,6 +471,7 @@ export function createMain( await panel.reload(); sheet.recalc(); panel.refresh(sheet.getDoc()?.열 ?? []); + await panel.saveInputs(); // 「선택 입력」 카드의 자리 고름 — 같은 [저장]에서 } showNotice([]); showToast(L("M02_Saved"), "success"); diff --git a/resources/tester/helper_m02_inputs.cjs b/resources/tester/helper_m02_inputs.cjs new file mode 100644 index 00000000..6f014a6a --- /dev/null +++ b/resources/tester/helper_m02_inputs.cjs @@ -0,0 +1,56 @@ +/* M02 「선택 입력」 카드 셈(`M02_MasterTemplete_Inputs_Model.ts`) 단위검증 헬퍼 — + * 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 낸다. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const m = box.exports; + +// 같은 이름(높이)의 입력이 두 로직에 있음 +const list = { + 일위대가: "UA000001", + 로직: [ + { + 로직: "GC1", + 이름: "돌쌓기", + 입력: [ + { 이름: "높이", 단위: "m", 범위: [0.5, 5], 자동값: 1.2 }, + { 이름: "종류", 고르기: ["막돌", "견치돌"] }, + ], + }, + { 로직: "GC2", 이름: "타설", 입력: [{ 이름: "높이", 범위: { 최소: 0, 최대: 3 } }, { 이름: "배합" }] }, + ], + 빠진로직: [], +}; +const seats = m.seatsOf({ + 판: "v1", + 줄: [ + { 로직: "GC1", 입력: "높이", 자리: "면적", 알약: "입력|GC1|높이" }, + { 로직: "GC2", 입력: "높이", 자리: "면적" }, + { 로직: "GC2", 입력: "배합", 자리: "재료" }, + { 로직: "GC1", 입력: "종류", 자리: null }, + { 로직: "GC9", 입력: "없는것", 자리: "선택" }, + ], +}); +const links = { 판: "v1", 줄: [...[...seats].map(([k, 자리]) => ({ 로직: k.split("|")[0], 입력: k.split("|")[1], 자리 }))] }; +const pick = new Map(seats); +pick.set(m.inputKey("GC1", "종류"), "선택"); +process.stdout.write( + JSON.stringify({ + keys: [m.inputKey("GC1", "높이"), m.pillKey("GC2", "높이")], + seatKeys: [...seats.keys()], + count: m.countOf(list), + area: m.pillsOf(list, links, "면적").map((p) => [p.키, p.이름, p.단위 ?? null, p.고정]), + material: m.pillsOf(list, links, "재료").map((p) => [p.키, p.이름]), + lines: m.linesOf(list, pick), + none: m.linesOf(list, new Map()), + hints: list.로직.flatMap((l) => l.입력).map((i) => [m.valueHint(i), m.autoText(i)]), + seatOptions: m.SEAT_OPTIONS.map((o) => o.label), + }), +); diff --git a/resources/tester/spreadsheet/test_spreadsheet_d_input.py b/resources/tester/spreadsheet/test_spreadsheet_d_input.py index bb765680..ebd4f575 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_d_input.py +++ b/resources/tester/spreadsheet/test_spreadsheet_d_input.py @@ -113,13 +113,13 @@ def test_links_follow_rows_cols(r): assert r["links"] == ["L:B7,W:D5", "L:B7", "L:B7,W:D5", "L:B5,W:D3", "D1"] -def test_links_bar_sits_under_sheet(): - # 알약 막대 = 시트 아래 · 상태줄 위 · 자재 고르기 창은 막대 위로 엶 +def test_links_bar_sits_under_statusbar(): + # 알약 막대 = 시트 · 합계 줄(상태줄) 아래 맨 끝 · 자재 고르기 창은 막대 위로 엶 with open( os.path.join(HERE, "../../../A00_Common/spreadsheet/spreadsheet_links.ts"), encoding="utf-8" ) as f: src = f.read() - assert 'insertBefore(bar, ctx.root.querySelector(":scope > .ss-statusbar"))' in src + assert 'ctx.root.querySelector(":scope > .ss-statusbar")?.after(bar)' in src assert "pick.style.bottom = `${o.bottom - bar.getBoundingClientRect().top + 4}px`" in src diff --git a/resources/tester/test_m02_inputs.py b/resources/tester/test_m02_inputs.py new file mode 100644 index 00000000..a6b87549 --- /dev/null +++ b/resources/tester/test_m02_inputs.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""M02 「선택 입력」 카드(PLAN 25-2c) 셈 회귀검증 — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록. + +같은 이름의 입력도 로직마다 따로(키 `{로직}|{이름}`) · 처음은 모두 「안 씀」(저장 줄 없음) · +면적 · 재료로 고른 입력만 그 시트 알약(`입력|{로직}|{이름}` · 「로직 이름 · 입력 이름」). +Node 헬퍼(`helper_m02_inputs.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.join(HERE, "..", "..") + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_inputs.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_키_모양(results): + assert results["keys"] == ["GC1|높이", "입력|GC2|높이"] + + +def test_자리는_고른_것만_안_쓰면_없음(results): + assert results["seatKeys"] == ["GC1|높이", "GC2|높이", "GC2|배합", "GC9|없는것"] + assert results["none"] == [] # 처음은 모두 안 씀 — 저장할 줄 없음 + + +def test_로직_수_입력_수(results): + assert results["count"] == {"logics": 2, "inputs": 4} + + +def test_같은_이름_입력도_로직마다_따로_알약(results): + # 높이가 두 로직에 있어도 알약 둘 · 이름은 「로직 이름 · 입력 이름」 · 목록에서 못 뺌(고정) + assert results["area"] == [ + ["입력|GC1|높이", "돌쌓기 · 높이", "m", True], + ["입력|GC2|높이", "타설 · 높이", None, True], + ] + assert results["material"] == [["입력|GC2|배합", "타설 · 배합"]] + + +def test_저장_줄은_고른_것만_알약_키_함께(results): + assert results["lines"] == [ + {"로직": "GC1", "입력": "높이", "자리": "면적", "알약": "입력|GC1|높이"}, + {"로직": "GC1", "입력": "종류", "자리": "선택"}, # 선택은 알약 없음 + {"로직": "GC2", "입력": "높이", "자리": "면적", "알약": "입력|GC2|높이"}, + {"로직": "GC2", "입력": "배합", "자리": "재료", "알약": "입력|GC2|배합"}, + ] + + +def test_고르기_범위_자동값_글(results): + assert results["hints"] == [ + ["0.5 ~ 5", "1.2"], + ["막돌 / 견치돌", ""], + ["0 ~ 3", ""], + ["", ""], + ] + assert results["seatOptions"] == ["안 씀", "면적산출 알약", "재료산출 알약", "선택"] + + +def test_카드는_집계표_카드_옆_넷째_그리고_700줄_안(): + page = os.path.join(ROOT, "M02_MasterTemplete") + src = open(os.path.join(page, "M02_MasterTemplete_Structure.ts"), encoding="utf-8").read() + assert "inputs.root" in src and "createInputsCard" in src + for name in os.listdir(page): + if name.endswith((".ts", ".py")): + n = len(open(os.path.join(page, name), encoding="utf-8").read().splitlines()) + assert n <= 700, (name, n)