diff --git a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css new file mode 100644 index 00000000..f8561d0d --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css @@ -0,0 +1,93 @@ +/* 「선택 입력」 카드 — 로직마다 묶은 입력 줄 · 값 칸 · 확인 표시. 카드 틀은 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-muted); + 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; +} + +/* 자동값 = 저장값이 아님 — 흐리게 */ +.m02-inputs__value.is-auto, +.m02-inputs__control.is-auto { + color: var(--color-text-muted); + font-style: italic; +} + +.m02-inputs__state { + color: var(--color-text-muted); + font-size: var(--text-caption); + white-space: nowrap; +} + +.m02-inputs__state.is-confirmed { + color: var(--color-success, var(--color-text)); +} + +.m02-inputs__error { + flex: 1 0 100%; + color: var(--color-danger, var(--color-text)); + font-size: var(--text-caption); +} + +.m02-inputs__control[aria-invalid="true"] { + border-color: var(--color-danger, currentcolor); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts new file mode 100644 index 00000000..9bcbcb51 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts @@ -0,0 +1,389 @@ +/* ============================================================================= + * 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; +} + +/** 고르기 목록 → 값 · 이름 짝 */ +export function choicesOf(input: LogicInput): { 값: string; 이름: string }[] { + return (input.고르기 ?? []).map((one) => + typeof one === "object" + ? { 값: String(one.값 ?? one.이름 ?? ""), 이름: String(one.이름 ?? one.값 ?? "") } + : { 값: String(one), 이름: String(one) }, + ); +} + +/** 자리가 「선택」 인 줄만 로직 차례로 묶음 — 이 카드가 보이는 입력 */ +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) : 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_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts new file mode 100644 index 00000000..394742f8 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -0,0 +1,277 @@ +/* ============================================================================= + * M02_MasterTemplete_ItemCards.ts + * 항목 카드 — 도면 정보 · 면적산출 · 재료산출(보기만 + [수정]) + 덧붙는 카드(선택 입력 …). + * M02 구조물 설계 컨테이너(`M02_MasterTemplete_Structure.ts`)에서 떼어 냄(계획서 25-2d) — 항목 고르기 + * 모달(`ui_template_item_picker_detail.ts`)이 **같은 카드**를 쓰려고. 문서를 어디서 읽는지는 + * 자료 자리(`ItemSource`)가 정함 · 카드는 몰라도 됨. 모양(클래스 · DOM)은 옮기기 전과 같음. + * 면적산출 · 재료산출 = 스프레드시트 · 부품은 카드를 열 때만 `import()`(다른 페이지 번들에 안 섞임). + * 덧붙는 카드는 `extraCards` 로 끼움 — 선택 입력 카드(`M02_MasterTemplete_InputsCard.ts`)가 그 자리. + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; +import type { StructureDoc, StructureNumbers } from "./M02_MasterTemplete_Api_Fetch"; +import { + mountDrawingTemplate, + type DrawingTemplateDoc, + type DrawingTemplateHandle, +} from "./M02_MasterTemplete_Drawing"; +import type { ItemPart, ItemSource } from "./M02_MasterTemplete_ItemSource"; +import "./M02_MasterTemplete_Structure.css"; + +/** 집계표 열 하나에 딸린 문서 셋 — 번호 칸 · 이름 */ +export type EditKind = ItemPart; +export type SheetKind = Exclude; +export const PART_FIELD: Record = { + structure: "도번", + area: "면적번", + material: "재료번", +}; +export const PART_WORD: Record = { + structure: "도면", + area: "면적산출", + material: "재료산출", +}; + +const TEXT = { + drawingInfo: "도면 정보", + edit: "도면 수정", + noNumber: "도번 없음", + noDrawing: "도면 없음", + editSheet: "{value} 수정", + noSheetDoc: "{value} 없음", + noFieldNumber: "{value} 없음", + noSheet: "스프레드시트를 못 띄움 — {value}", + readFailed: "구조물 도면을 못 읽음 — {value}", +}; + +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); +const fill = (text: string, value: string): string => text.replace("{value}", value); +/** 스프레드시트는 면적산출 · 재료산출 카드를 열 때만 받음 */ +const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet"); + +/** 카드 틀 — 제목 + 머리 + 본문(좌측 패널과 같은 공용 ui-sidebar 카드) */ +export const itemCard = (title: string, head: HTMLElement, body: HTMLElement): HTMLElement => + el("section", { + className: "m02-struct__card ui-collapsible ui-sidebar-section ui-sidebar-card", + children: [el("h3", { className: "ui-collapsible__title", text: title }), head, body], + }); + +export const itemNote = (text: string): HTMLElement => + el("p", { className: "m02-struct__empty", text }); + +export interface ItemCardsOptions { + /** 지금 자료 자리 — 열을 읽을 때마다 부름(층이 바뀌어도 따라감) */ + source: () => ItemSource; + /** [수정] 단추를 보일까 — 부를 때마다 다시 봄 */ + editable: () => boolean; + /** [도면 수정] · [면적산출 수정] · [재료산출 수정] */ + onEdit: (kind: EditKind) => void; + /** 문서에 번호가 없을 때 쓸 번호(집계표 번호 줄) */ + numberOf?: (kind: EditKind, column: string) => string | undefined; + /** 도면을 못 읽었을 때 알릴까 — 기본 알림(M02 는 번호가 있는 열만) */ + warnOnFail?: (column: string) => boolean; + /** 세 카드 뒤에 덧붙는 카드(선택 입력 …) */ + extraCards?: HTMLElement[]; +} + +export interface ItemCardsHandle { + root: HTMLElement; + /** 그 열을 읽어 카드를 그림 — `null` 이면 카드를 숨김 */ + load: (col: { id: string; label: string } | null) => Promise; + /** 이름만 새로(CAD 를 다시 안 띄움) */ + setLabel: (label: string) => void; + /** 지금 보이는 열 id */ + column: () => string | null; + destroy: () => void; +} + +export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { + let column: string | null = null; + let doc: StructureDoc | null = null; + let preview: DrawingTemplateHandle | null = null; + let seq = 0; + + const numberOf = (kind: EditKind, id: string): string => opts.numberOf?.(kind, id) ?? ""; + + const columnName = el("span", { className: "m02-struct__title" }); + const number = el("span", { className: "m02-struct__no" }); + const edit = createButton({ label: TEXT.edit, variant: "filled", size: "sm" }); + const previewHost = el("div", { className: "m02-struct__preview" }); + const infoCard = itemCard( + TEXT.drawingInfo, + el("div", { className: "m02-struct__head", children: [number, columnName, edit] }), + previewHost, + ); + + /** 면적산출 · 재료산출 카드 — 번호 · [수정] · 스프레드시트 미리보기 */ + interface SheetCard { + kind: SheetKind; + root: HTMLElement; + no: HTMLElement; + edit: HTMLButtonElement; + host: HTMLElement; + doc: Workbook | null; + number: string; + handle: SpreadsheetHandle | null; + } + const sheetCard = (kind: SheetKind): SheetCard => { + const no = el("span", { className: "m02-struct__no" }); + const editButton = createButton({ + label: fill(TEXT.editSheet, PART_WORD[kind]), + variant: "ghost", + size: "sm", + }); + const host = el("div", { className: "m02-struct__sheet" }); + editButton.addEventListener("click", () => opts.onEdit(kind)); + return { + kind, + root: itemCard( + PART_WORD[kind], + el("div", { className: "m02-struct__subhead", children: [no, editButton] }), + host, + ), + no, + edit: editButton, + host, + doc: null, + number: "", + handle: null, + }; + }; + const sheets = [sheetCard("area"), sheetCard("material")]; + edit.addEventListener("click", () => opts.onEdit("structure")); + + const root = el("div", { + className: "m02-struct ui-sidebar-panel", + attrs: { hidden: "" }, + children: [infoCard, ...sheets.map((c) => c.root), ...(opts.extraCards ?? [])], + }); + attachCollapsible(root); + + const drop = (): void => { + preview?.destroy(); + preview = null; + for (const c of sheets) { + c.handle?.destroy(); + c.handle = null; + } + }; + + /** 미리보기 CAD 는 iframe — 안쪽 도구 모음(제목줄 · 리본)을 스타일로 숨김(같은 출처라 접근 가능) */ + const hideToolbar = (frame: HTMLIFrameElement): void => { + const apply = (): void => { + const inner = frame.contentDocument; + if (!inner?.head || inner.getElementById("m02-struct-hide")) return; + const style = inner.createElement("style"); + style.id = "m02-struct-hide"; + style.textContent = ".cad-titlebar, .cad-ribbon { display: none !important; }"; + inner.head.append(style); + }; + frame.addEventListener("load", apply); + apply(); + }; + + /** 스프레드시트 미리보기 — 보기만(고침은 [수정] 이 여는 전체 화면) · 부품을 늦게 받음 · + * 그 사이 열이 바뀌면 버림 */ + const paintSheet = async (c: SheetCard, mine: number): Promise => { + if (!c.doc) + return void c.host.replaceChildren(itemNote(fill(TEXT.noSheetDoc, PART_WORD[c.kind]))); + try { + const { createSpreadsheet } = await loadSpreadsheet(); + if (mine !== seq || !c.doc) return; + c.handle = createSpreadsheet(c.host, c.doc, { readOnly: true, tabs: false }); + } catch (error) { + if (mine === seq) c.host.replaceChildren(itemNote(fill(TEXT.noSheet, why(error)))); + } + }; + + /** 미리보기 · 스프레드시트 — 문서가 없으면 없음 글(서버가 열마다 미리 만들어 둬 보통은 없음) */ + const paintBody = (mine: number): void => { + drop(); + const at = column ?? ""; + number.textContent = doc?.도번 || numberOf("structure", at) || TEXT.noNumber; + edit.hidden = !opts.editable(); + for (const c of sheets) { + c.no.textContent = + c.number || numberOf(c.kind, at) || fill(TEXT.noFieldNumber, PART_FIELD[c.kind]); + c.edit.hidden = !opts.editable(); + c.host.replaceChildren(); + void paintSheet(c, mine); + } + if (!doc) { + previewHost.replaceChildren(itemNote(TEXT.noDrawing)); + return; + } + previewHost.replaceChildren(); + const source = opts.source(); + // 미리보기 — 도면 양식과 같은 부품을 읽기만으로. 자동백업 칸은 편집 화면과 갈라 둠. + preview = mountDrawingTemplate(previewHost, doc.도면 as unknown as DrawingTemplateDoc, { + readOnly: true, + name: `preview:${column ?? ""}`, + layer: source.layer, + projectId: source.projectId, + panelsCollapsed: true, // 특성 · 도면층 패널은 접힌 채 — › 를 누르면 펼침 + preview: true, // 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 + originMarker: true, // 좌표 0,0 기준점 십자 + noArea: true, // 구조물도는 도각이 없음 — 작도 영역 단추 없음 + }); + const frame = previewHost.querySelector("iframe"); + if (frame) hideToolbar(frame); + }; + + async function load(col: { id: string; label: string } | null): Promise { + const mine = ++seq; + drop(); + if (!col) { + root.hidden = true; + column = null; + doc = null; + return; + } + root.hidden = false; + column = col.id; + columnName.textContent = col.label; + number.textContent = numberOf("structure", col.id) || TEXT.noNumber; + doc = null; + previewHost.replaceChildren(); + for (const c of sheets) { + c.doc = null; + c.number = ""; + c.host.replaceChildren(); + } + const source = opts.source(); + const [drawing, ...books] = await Promise.allSettled([ + source.read("structure", col.id), + ...sheets.map((c) => source.read(c.kind, col.id)), + ]); + if (mine !== seq) return; + if (drawing.status === "fulfilled") doc = drawing.value.문서 as StructureDoc; + // 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」 + else if (opts.warnOnFail?.(col.id) ?? true) + showToast(fill(TEXT.readFailed, why(drawing.reason)), "error"); + books.forEach((got, i) => { + if (got.status !== "fulfilled") return; + const c = sheets[i]; + const book = got.value.문서 as Workbook & Record; + c.doc = book; + c.number = String(book?.[PART_FIELD[c.kind]] ?? ""); + }); + paintBody(mine); + } + + return { + root, + load, + setLabel: (label) => { + columnName.textContent = label; + }, + column: () => column, + destroy: () => { + seq += 1; + drop(); + root.remove(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemSource.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemSource.ts new file mode 100644 index 00000000..c7553432 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemSource.ts @@ -0,0 +1,29 @@ +/* ============================================================================= + * M02_MasterTemplete_ItemSource.ts + * 항목 카드 · 편집기가 문서를 읽는 **자료 자리**(계획서 25-2d) — 층 고정(`getActiveLayer`)이던 자리를 + * 인자로 뺌. M02 화면은 지금 층(`layerSource`) · 항목 고르기 모달은 프로젝트 복사본 또는 원본 층. + * 카드(`M02_MasterTemplete_ItemCards.ts`)는 어디서 읽는지 모르고 이 창구만 부름. + * ========================================================================== */ + +import { getActiveLayer, readTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch"; + +/** 집계표 열 하나에 딸린 문서 셋 — 구조물 도면 · 면적산출 · 재료산출 */ +export type ItemPart = "structure" | "area" | "material"; + +export interface ItemSource { + /** CAD 자동백업 칸을 가르는 층 — 프로젝트 복사본이면 `project` */ + layer: Layer; + projectId: string | null; + /** 열 id 의 문서 하나 — 못 읽으면 던짐(카드가 「없음」 · 알림으로 갈라 보임) */ + read: (part: ItemPart, column: string) => Promise<{ 문서: unknown }>; +} + +/** M02 화면의 자료 자리 — 부를 때의 지금 층(`getActiveLayer`) 그대로 */ +export const layerSource = (): ItemSource => { + const layer = getActiveLayer(); + return { + layer, + projectId: null, + read: (part, column) => readTemplate(layer, part, column, null), + }; +}; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 45059bc7..1611e592 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -21,9 +21,7 @@ import { showConfirmDialog, showToast, } from "@ui/ui_template_elements"; -import { attachCollapsible } from "@ui/ui_template_collapsible"; import { openModal } from "@ui/ui_template_modal"; -import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; import { fetchComboInputs, searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; import { type ComboSummary, fetchCombos } from "../M01_MasterData/M01_MasterData_UI_Combo_Api"; @@ -34,29 +32,24 @@ import { fetchStructureNumbers, getActiveLayer, readTemplate, - type StructureDoc, type StructureNumbers, } from "./M02_MasterTemplete_Api_Fetch"; import { - mountDrawingTemplate, - type DrawingTemplateDoc, - type DrawingTemplateHandle, -} from "./M02_MasterTemplete_Drawing"; + createItemCards, + PART_FIELD, + PART_WORD, + type EditKind, + type SheetKind, +} from "./M02_MasterTemplete_ItemCards"; +import { layerSource } from "./M02_MasterTemplete_ItemSource"; import "./M02_MasterTemplete_Structure.css"; +export type { EditKind, SheetKind } from "./M02_MasterTemplete_ItemCards"; + /** 구조물 도면이 걸리는 집계표 이름 — 서버 `M02_MasterTemplete_Store.STRUCTURE_TABLE` 과 같은 글 */ export const STRUCTURE_TABLE = "구조물집계표"; const TEXT = { - drawingInfo: "도면 정보", - edit: "도면 수정", - noNumber: "도번 없음", - noDrawing: "도면 없음", - editSheet: "{value} 수정", - noSheetDoc: "{value} 없음", - noFieldNumber: "{value} 없음", - noSheet: "스프레드시트를 못 띄움 — {value}", - readFailed: "구조물 도면을 못 읽음 — {value}", close: "닫기", find: "찾기", clear: "비움", @@ -74,15 +67,10 @@ const TEXT = { }; /** 집계표 열 하나에 딸린 문서 셋 — 번호 칸 · 이름 · 복사 */ -export type EditKind = "structure" | "area" | "material"; -export type SheetKind = Exclude; -const PARTS: Record< - EditKind, - { field: keyof StructureNumbers; word: string; copy: (to: string, from: string) => Promise } -> = { - structure: { field: "도번", word: "도면", copy: copyStructure }, - area: { field: "면적번", word: "면적산출", copy: copyArea }, - material: { field: "재료번", word: "재료산출", copy: copyMaterial }, +const COPY: Record Promise> = { + structure: copyStructure, + area: copyArea, + material: copyMaterial, }; /** 집계표 줄 열쇠 → 문서 */ const ROW_KIND: Record = { @@ -93,8 +81,6 @@ const ROW_KIND: Record = { const why = (error: unknown): string => (error instanceof Error ? error.message : ""); const fill = (text: string, value: string): string => text.replace("{value}", value); -/** 스프레드시트는 면적산출 · 재료산출 카드를 열 때만 받음 */ -const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet"); /** `{열 id: {도번, 면적번, 재료번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */ export type ColumnNumbers = Record; @@ -255,185 +241,30 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane let shown: StructureColumn | null = null; let column: string | null = null; let columnLabel = ""; - let doc: StructureDoc | null = null; - let preview: DrawingTemplateHandle | null = null; - let seq = 0; - const card = (title: string, head: HTMLElement, body: HTMLElement): HTMLElement => - el("section", { - className: "m02-struct__card ui-collapsible ui-sidebar-section ui-sidebar-card", - children: [el("h3", { className: "ui-collapsible__title", text: title }), head, body], - }); - - const columnName = el("span", { className: "m02-struct__title" }); - const number = el("span", { className: "m02-struct__no" }); - const edit = createButton({ label: TEXT.edit, variant: "filled", size: "sm" }); - const previewHost = el("div", { className: "m02-struct__preview" }); - const infoCard = card( - TEXT.drawingInfo, - el("div", { className: "m02-struct__head", children: [number, columnName, edit] }), - previewHost, - ); - - /** 면적산출 · 재료산출 카드 — 번호 · [수정] · 스프레드시트 미리보기 */ - interface SheetCard { - kind: SheetKind; - root: HTMLElement; - no: HTMLElement; - edit: HTMLButtonElement; - host: HTMLElement; - doc: Workbook | null; - number: string; - handle: SpreadsheetHandle | null; - } - const sheetCard = (kind: SheetKind): SheetCard => { - const no = el("span", { className: "m02-struct__no" }); - const editButton = createButton({ - label: fill(TEXT.editSheet, PARTS[kind].word), - variant: "ghost", - size: "sm", - }); - const host = el("div", { className: "m02-struct__sheet" }); - editButton.addEventListener("click", () => void openEdit(kind)); - return { - kind, - root: card( - PARTS[kind].word, - el("div", { className: "m02-struct__subhead", children: [no, editButton] }), - host, - ), - no, - edit: editButton, - host, - doc: null, - number: "", - handle: null, - }; - }; - const sheets = [sheetCard("area"), sheetCard("material")]; - - const root = el("div", { - className: "m02-struct ui-sidebar-panel", - attrs: { hidden: "" }, - children: [infoCard, ...sheets.map((c) => c.root)], - }); - attachCollapsible(root); - - const drop = (): void => { - preview?.destroy(); - preview = null; - for (const c of sheets) { - c.handle?.destroy(); - c.handle = null; - } - }; - - const note = (text: string): HTMLElement => el("p", { className: "m02-struct__empty", text }); - - /** 미리보기 CAD 는 iframe — 안쪽 도구 모음(제목줄 · 리본)을 스타일로 숨김(같은 출처라 접근 가능) */ - const hideToolbar = (frame: HTMLIFrameElement): void => { - const apply = (): void => { - const inner = frame.contentDocument; - if (!inner?.head || inner.getElementById("m02-struct-hide")) return; - const style = inner.createElement("style"); - style.id = "m02-struct-hide"; - style.textContent = ".cad-titlebar, .cad-ribbon { display: none !important; }"; - inner.head.append(style); - }; - frame.addEventListener("load", apply); - apply(); - }; - - /** 스프레드시트 미리보기 — 보기만(고침은 [수정] 이 여는 전체 화면) · 부품을 늦게 받음 · - * 그 사이 열이 바뀌면 버림 */ - const paintSheet = async (c: SheetCard, mine: number): Promise => { - if (!c.doc) return void c.host.replaceChildren(note(fill(TEXT.noSheetDoc, PARTS[c.kind].word))); - try { - const { createSpreadsheet } = await loadSpreadsheet(); - if (mine !== seq || !c.doc) return; - c.handle = createSpreadsheet(c.host, c.doc, { readOnly: true, tabs: false }); - } catch (error) { - if (mine === seq) c.host.replaceChildren(note(fill(TEXT.noSheet, why(error)))); - } - }; - - /** 미리보기 · 스프레드시트 — 문서가 없으면 없음 글(서버가 열마다 미리 만들어 둬 보통은 없음) */ - const paintBody = (mine: number): void => { - drop(); - number.textContent = doc?.도번 || numbers[column ?? ""]?.도번 || TEXT.noNumber; - edit.hidden = !opts.isAdmin; - for (const c of sheets) { - const field = PARTS[c.kind].field; - c.no.textContent = - c.number || numbers[column ?? ""]?.[field] || fill(TEXT.noFieldNumber, field); - c.edit.hidden = !opts.isAdmin; - c.host.replaceChildren(); - void paintSheet(c, mine); - } - if (!doc) { - previewHost.replaceChildren(note(TEXT.noDrawing)); - return; - } - previewHost.replaceChildren(); - // 미리보기 — 도면 양식과 같은 부품을 읽기만으로. 자동백업 칸은 편집 화면과 갈라 둠. - preview = mountDrawingTemplate(previewHost, doc.도면 as unknown as DrawingTemplateDoc, { - readOnly: true, - name: `preview:${column ?? ""}`, - layer: getActiveLayer(), - panelsCollapsed: true, // 특성 · 도면층 패널은 접힌 채 — › 를 누르면 펼침 - preview: true, // 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 - originMarker: true, // 좌표 0,0 기준점 십자 - noArea: true, // 구조물도는 도각이 없음 — 작도 영역 단추 없음 - }); - const frame = previewHost.querySelector("iframe"); - if (frame) hideToolbar(frame); - }; - - async function load(col: StructureColumn | null): Promise { - const mine = ++seq; - drop(); - shown = col; - if (!col) { - root.hidden = true; - column = null; - doc = null; - return; - } - root.hidden = false; - column = col.id; - columnLabel = columnHead(col); - columnName.textContent = columnLabel; - number.textContent = numbers[col.id]?.도번 || TEXT.noNumber; - doc = null; - previewHost.replaceChildren(); - for (const c of sheets) { - c.doc = null; - c.number = ""; - c.host.replaceChildren(); - } - const [drawing, ...books] = await Promise.allSettled([ - readTemplate(getActiveLayer(), "structure", col.id, null), - ...sheets.map((c) => readTemplate(getActiveLayer(), c.kind, col.id, null)), - ]); - if (mine !== seq) return; - if (drawing.status === "fulfilled") doc = drawing.value.문서 as StructureDoc; + // 카드 셋은 항목 카드 한 벌(`ItemCards`) — 여기서는 지금 층에서 열 하나를 읽어 넘기기만. + const cards = createItemCards({ + source: layerSource, + editable: () => opts.isAdmin, + onEdit: (kind) => void openEdit(kind), + numberOf: (kind, id) => numbers[id]?.[PART_FIELD[kind]], // 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」 - else if (numbers[col.id]) showToast(fill(TEXT.readFailed, why(drawing.reason)), "error"); - books.forEach((got, i) => { - if (got.status !== "fulfilled") return; - const c = sheets[i]; - const book = got.value.문서 as Workbook & Record; - c.doc = book; - c.number = String(book?.[PARTS[c.kind].field] ?? ""); - }); - paintBody(mine); + warnOnFail: (id) => !!numbers[id], + }); + const root = cards.root; + + function load(col: StructureColumn | null): Promise { + shown = col; + column = col?.id ?? null; + columnLabel = col ? columnHead(col) : ""; + return cards.load(col ? { id: col.id, label: columnLabel } : null); } /** 글 열(NO · 측점 · 비고)은 구조물이 아니라 컨테이너를 닫음 */ const show = (colId: string | null, columns: StructureColumn[]): void => { const col = columns.find((c) => c.id === colId && isStructureColumn(c)) ?? null; if ((col?.id ?? null) !== column) return void load(col); - if (col) columnName.textContent = columnLabel = columnHead(col); // 머리를 고쳤을 수 있음 + if (col) cards.setLabel((columnLabel = columnHead(col))); // 머리를 고쳤을 수 있음 }; const pickPrice = async (colId: string, columns: StructureColumn[]): Promise => { @@ -462,7 +293,9 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane colId: string, columns: StructureColumn[], ): Promise => { - const { field, word, copy } = PARTS[kind]; + const field = PART_FIELD[kind]; + const word = PART_WORD[kind]; + const copy = COPY[kind]; const to = columns.find((c) => c.id === colId); const toNo = numbers[colId]?.[field]; if (!to || !toNo) return void showToast(TEXT.saveFirst, "error"); @@ -505,12 +338,10 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane const at = column; if (!at) return; // 저장 전 새 열(분류 더하기) — 문서 · 번호가 아직 없음 · 떠나면 집계표 고침까지 버리게 됨 - if (!numbers[at]?.[PARTS[kind].field]) return void showToast(TEXT.saveFirst, "info"); + if (!numbers[at]?.[PART_FIELD[kind]]) return void showToast(TEXT.saveFirst, "info"); if (!(await opts.confirmLeave())) return; opts.onEdit(at, columnLabel, kind); } - edit.addEventListener("click", () => void openEdit("structure")); - return { root, numbers: () => numbers, @@ -524,10 +355,6 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane show(at, columns); }, pick, - destroy: () => { - seq += 1; - drop(); - root.remove(); - }, + destroy: () => cards.destroy(), }; } diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index fad34b65..fd7e130f 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -21,6 +21,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store ROOT = Path(__file__).resolve().parents[2] M02 = ROOT / "M02_MasterTemplete" PANEL = M02 / "M02_MasterTemplete_Structure.ts" +CARDS = M02 / "M02_MasterTemplete_ItemCards.ts" # 카드 셋 그리기(25-2d 에서 PANEL 에서 옮김) MAIN = M02 / "M02_MasterTemplete_UI_Main.ts" SIDE = M02 / "M02_MasterTemplete_UI_Side.ts" @@ -35,7 +36,7 @@ def test_집계표_이름이_서버와_같음(): def test_소스가_700줄을_넘지_않음(): - for path in (PANEL, MAIN, M02 / "M02_MasterTemplete_Structure.css"): + for path in (PANEL, CARDS, MAIN, M02 / "M02_MasterTemplete_Structure.css"): assert len(_text(path).splitlines()) <= 700, path.name @@ -53,8 +54,8 @@ def test_소스가_700줄을_넘지_않음(): # (`{열 id: {도번, 면적번, 재료번}}`) ("M02_MasterTemplete_Api_Fetch.ts", ["fetchStructureNumbers"], SIDE), ("M02_MasterTemplete_Structure.ts", ["structureLabel", "isStructureColumn"], SIDE), - ("M02_MasterTemplete_Drawing.ts", ["mountDrawingTemplate"], PANEL), - ("../A00_Common/spreadsheet/spreadsheet.ts", ["createSpreadsheet"], PANEL), + ("M02_MasterTemplete_Drawing.ts", ["mountDrawingTemplate"], CARDS), + ("../A00_Common/spreadsheet/spreadsheet.ts", ["createSpreadsheet"], CARDS), ], ) def test_컨테이너가_쓰는_이름이_내보내진_것(source: str, names: list[str], target: Path): @@ -132,7 +133,7 @@ def test_열_머리_글_잇기(): def test_미리보기_CAD_패널은_숨기지_않고_접힌_채로_엶(): """특성 · 도면층 패널을 숨기지 않음 — 곁값 panelsCollapsed 로 접어 실음(B07 은 안 보냄).""" - panel = _text(PANEL) + panel = _text(CARDS) assert ".cad-inspector" not in panel and "panelsCollapsed: true" in panel drawing = _text(M02 / "M02_MasterTemplete_Drawing.ts") assert "panelsCollapsed: options.panelsCollapsed" in drawing