diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 69431399..583cc173 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -59,6 +59,9 @@ export interface SpreadsheetOptions { tabs?: boolean; /** 아래 알약 막대(상태줄 아래) — 입력값 · 자재를 칸에 연결(`spreadsheet_links.ts` · 이때만 불러옴) */ links?: LinkOptions; + /** 미리보기 수정(카드 안 작은 표) — 도구 모음 · 오른쪽 클릭 메뉴 없이 수식 입력 줄 · 칸 고침 · 단축키 · + * 알약 막대만. 다른 화면 모양은 그대로 */ + preview?: boolean; } export interface SpreadsheetHandle { @@ -99,8 +102,9 @@ export function createSpreadsheet( ): SpreadsheetHandle { const readOnly = !!opts.readOnly; const noTabs = opts.tabs === false; + const bare = readOnly || !!opts.preview; // 도구 모음 · 메뉴 없음 const root = el("div", { - className: `ss${readOnly ? " ss--readonly" : ""}${noTabs ? " ss--notabs" : ""}`, + className: `ss${readOnly ? " ss--readonly" : ""}${noTabs ? " ss--notabs" : ""}${opts.preview ? " ss--preview" : ""}`, }); host.append(root); const firstSheet = (b: Workbook) => b.시트.find((s) => s.id === b.활성) ?? b.시트[0]; @@ -128,9 +132,9 @@ export function createSpreadsheet( ctx.grid = grid; const ed = createEditor(ctx); ctx.editor = ed; - const toolbar: PartHandle | null = readOnly ? null : mountToolbar(ctx); + const toolbar: PartHandle | null = bare ? null : mountToolbar(ctx); const tabs: PartHandle | null = noTabs ? null : mountTabs(ctx); - const menu: PartHandle | null = readOnly ? null : attachMenu(ctx); + const menu: PartHandle | null = bare ? null : attachMenu(ctx); const clip = attachClipboard(ctx); const status = createStatusBar(ctx); const others = [toolbar, tabs, menu, clip, status].filter((p): p is PartHandle => !!p); diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index 8d7c6bd1..4d5b1a6a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -6,12 +6,18 @@ * 자료 자리(`ItemSource`)가 정함 · 카드는 몰라도 됨. 모양(클래스 · DOM)은 옮기기 전과 같음. * 면적산출 · 재료산출 = 스프레드시트 · 부품은 카드를 열 때만 `import()`(다른 페이지 번들에 안 섞임). * 덧붙는 카드는 `extraCards` 로 끼움 — 선택 입력 카드(`M02_MasterTemplete_InputsCard.ts`)가 그 자리. + * `sheetEdit` 가 있으면 면적산출 · 재료산출 카드를 미리보기에서 고침(31-3) — 스프레드시트 `preview`(메뉴 없이 + * 수식 입력 줄 · 칸 고침 · 단축키) + 아래 알약 줄 + 카드 [저장] · 없으면 보기만(고르기 모달). * ========================================================================== */ import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { showLoading } from "@ui/ui_template_loading"; -import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; +import type { + LinkOptions, + SpreadsheetHandle, + Workbook, +} from "../A00_Common/spreadsheet/spreadsheet"; import type { StructureDoc, StructureNumbers } from "./M02_MasterTemplete_Api_Fetch"; import { mountDrawingCard, type DrawingCardHandle } from "./M02_MasterTemplete_DrawingCard"; import type { ItemPart, ItemSource } from "./M02_MasterTemplete_ItemSource"; @@ -41,7 +47,13 @@ const TEXT = { noFieldNumber: "{value} 없음", noSheet: "스프레드시트를 못 띄움 — {value}", readFailed: "구조물 도면을 못 읽음 — {value}", +<<<<<<< HEAD discard: "도면 카드에 저장 안 한 고침이 있음 — 버리고 다른 항목을 열까요?", +======= + save: "저장", + saved: "저장했음", + saveFailed: "저장 못 함 — {value}", +>>>>>>> origin/sub_laptop_8 }; const why = (error: unknown): string => (error instanceof Error ? error.message : ""); @@ -72,8 +84,37 @@ export interface ItemCardsOptions { warnOnFail?: (column: string) => boolean; /** 세 카드 뒤에 덧붙는 카드(선택 입력 …) */ extraCards?: HTMLElement[]; +<<<<<<< HEAD /** 도면 카드에서 저장 안 한 고침이 생김 · 없어짐(PLAN 31-4) */ onDirty?: (dirty: boolean) => void; +======= + /** 면적산출 · 재료산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */ + sheetEdit?: SheetEditOptions; +} + +/** 카드 스프레드시트를 다루는 손잡이 — 알약 · 자재 조합 판이 씀 */ +export interface SheetCardControl { + column: string; + label: string; + host: HTMLElement; + handle: () => SpreadsheetHandle | null; + /** 시트만 다시 붙임(저장 안 한 고침은 그대로) — 새 알약 목록으로 */ + remount: (links: LinkOptions | undefined) => void; + /** 알약 · 판에서 시트 문서를 고쳤음 */ + touched: () => void; +} + +export interface SheetEditOptions { + /** 지금 고칠 수 있나 — 부를 때마다 다시 봄 */ + can: () => boolean; + /** 아래 알약 줄(입력 연결) · 카드 아래 덧붙는 판(재료산출 = 자재 조합) — 전체 편집기와 같은 부품 */ + links: ( + kind: SheetKind, + control: SheetCardControl, + ) => Promise<{ links?: LinkOptions; below?: HTMLElement; refresh?: () => void }>; + /** 카드 [저장] — 전체 편집기와 같은 저장 길 · 새 판을 돌려줌 */ + save: (kind: SheetKind, column: string, doc: unknown, version: string) => Promise; +>>>>>>> origin/sub_laptop_8 } export interface ItemCardsHandle { @@ -86,8 +127,15 @@ export interface ItemCardsHandle { relabel: (labels: Partial>) => void; /** 지금 보이는 열 id */ column: () => string | null; +<<<<<<< HEAD /** 도면 카드에 저장 안 한 고침이 있나 — 페이지가 떠날 때 물음 */ dirty: () => boolean; +======= + /** 카드에 저장 안 한 고침이 있나(31-3) */ + dirty: () => boolean; + /** 고친 카드를 모두 저장 — 실패하면 던짐 */ + saveAll: () => Promise; +>>>>>>> origin/sub_laptop_8 destroy: () => void; } @@ -124,6 +172,10 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { doc: Workbook | null; number: string; handle: SpreadsheetHandle | null; + /** 카드 고침(31-3) — [저장] · 읽은 판 · 저장 안 한 고침 */ + save: HTMLButtonElement; + version: string; + dirty: boolean; } const sheetCard = (kind: SheetKind): SheetCard => { const no = el("span", { className: "m02-struct__no" }); @@ -133,12 +185,14 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { size: "sm", }); const host = el("div", { className: "m02-struct__sheet" }); + const save = createButton({ label: TEXT.save, variant: "filled", size: "sm" }); + save.hidden = true; editButton.addEventListener("click", () => opts.onEdit(kind)); - return { + const card: SheetCard = { kind, root: itemCard( PART_WORD[kind], - el("div", { className: "m02-struct__subhead", children: [no, editButton] }), + el("div", { className: "m02-struct__subhead", children: [no, editButton, save] }), host, ), no, @@ -147,7 +201,12 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { doc: null, number: "", handle: null, + save, + version: "", + dirty: false, }; + save.addEventListener("click", () => void saveCard(card).catch(() => undefined)); + return card; }; const sheets = [sheetCard("area"), sheetCard("material")]; edit.addEventListener("click", () => opts.onEdit("structure")); @@ -176,9 +235,97 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { for (const c of sheets) { c.handle?.destroy(); c.handle = null; + c.dirty = false; + c.save.hidden = true; } }; +<<<<<<< HEAD +======= + /** 카드 [저장] — 읽은 문서(번호 등 계약 밖 칸)에 시트를 덮어 저장(전체 편집기 `doSave` 와 같은 모양) */ + async function saveCard(c: SheetCard): Promise { + const at = column; + if (!opts.sheetEdit || !c.handle || !c.dirty || !at) return; + const doc = { + ...(c.doc as unknown as Record), + ...c.handle.getDoc(), + } as Workbook; + c.save.disabled = true; + try { + c.version = await opts.sheetEdit.save(c.kind, at, doc, c.version); + c.doc = doc; + c.dirty = false; + showToast(TEXT.saved, "success"); + } catch (error) { + showToast(fill(TEXT.saveFailed, why(error)), "error"); + throw error; + } finally { + c.save.disabled = !c.dirty; + } + } + + /** 미리보기 수정 — 메뉴 없이 수식 입력 줄 · 칸 고침 · 아래 알약 줄 · 덧붙는 판(재료산출 = 자재 조합) */ + const paintEditable = async ( + c: SheetCard, + mine: number, + create: (typeof import("../A00_Common/spreadsheet/spreadsheet"))["createSpreadsheet"], + ): Promise => { + const grid = el("div", { className: "m02-struct__sheet-grid" }); + c.host.classList.add("is-edit"); + c.host.replaceChildren(grid); + const mountGrid = (doc: Workbook, links: LinkOptions | undefined): void => { + c.handle?.destroy(); + c.handle = create(grid, doc, { + preview: true, + tabs: false, + links, + onChange: () => { + c.dirty = true; + c.save.disabled = false; + extra.refresh?.(); + }, + }); + }; + const control: SheetCardControl = { + column: column ?? "", + label: columnName.textContent ?? "", + host: c.host, + handle: () => c.handle, + remount: (links) => { + const kept = c.handle?.getDoc(); + if (kept) mountGrid(kept, links); + }, + touched: () => { + c.dirty = true; + c.save.disabled = false; + }, + }; + // 알약을 못 받아도 칸은 고침 — 알약 줄만 없음 + const extra: Awaited> = await opts + .sheetEdit!.links(c.kind, control) + .catch(() => ({})); + if (mine !== seq || !c.doc) return; + mountGrid(c.doc, extra.links); + if (extra.below) c.host.append(extra.below); + c.save.hidden = false; + c.save.disabled = true; + }; + + /** 미리보기 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(); + }; + +>>>>>>> origin/sub_laptop_8 /** 스프레드시트 미리보기 — 보기만(고침은 [수정] 이 여는 전체 화면) · 부품을 늦게 받음 · * 그 사이 열이 바뀌면 버림 */ const paintSheet = async (c: SheetCard, mine: number): Promise => { @@ -187,6 +334,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { try { const { createSpreadsheet } = await loadSpreadsheet(); if (mine !== seq || !c.doc) return; + if (opts.sheetEdit?.can()) return await paintEditable(c, mine, createSpreadsheet); 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)))); @@ -245,6 +393,8 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { for (const c of sheets) { c.doc = null; c.number = ""; + c.version = ""; + c.host.classList.remove("is-edit"); c.host.replaceChildren(); } const source = opts.source(); @@ -268,6 +418,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { const c = sheets[i]; const book = got.value.문서 as Workbook & Record; c.doc = book; + c.version = String((got.value as { 판?: string | null }).판 ?? ""); c.number = String(book?.[PART_FIELD[c.kind]] ?? ""); }); paintBody(mine); @@ -287,7 +438,14 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { } }, column: () => column, +<<<<<<< HEAD dirty: () => !!preview?.dirty(), +======= + dirty: () => sheets.some((c) => c.dirty), + saveAll: async () => { + for (const c of sheets) await saveCard(c); + }, +>>>>>>> origin/sub_laptop_8 destroy: () => { seq += 1; drop(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index fd01712c..03749cca 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -215,3 +215,22 @@ color: var(--color-text-muted); font-variant-numeric: tabular-nums; } + +/* 면적산출 · 재료산출 카드 미리보기 수정(31-3) — 시트는 카드 높이 · 아래 자재 조합 판은 카드 안에서 스크롤 */ +.m02-struct__sheet.is-edit { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + overflow: auto; +} + +.m02-struct__sheet-grid { + flex: 0 0 auto; + height: var(--m02-struct-body-min); + min-width: 0; +} + +/* 알약 줄 [자재 추가] 창은 시트 칸 안에서 위로 열림 — 카드 높이에 맞게 목록을 줄임(안 잘리게) */ +.m02-struct__sheet-grid .ss-links__list { + max-height: 8rem; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index af5edc68..c1475dc1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -32,6 +32,7 @@ import { fetchStructureNumbers, getActiveLayer, readTemplate, + saveTemplate, type StructureNumbers, type TreeEdit, } from "./M02_MasterTemplete_Api_Fetch"; @@ -40,8 +41,11 @@ import { PART_FIELD, PART_WORD, type EditKind, + type SheetCardControl, + type SheetEditOptions, type SheetKind, } from "./M02_MasterTemplete_ItemCards"; +import { buildMaterialPanel, loadMaterialState, toPills } from "./M02_MasterTemplete_Materials"; import { layerSource } from "./M02_MasterTemplete_ItemSource"; import { createInputsCard, inputPills, type InputsCardHandle } from "./M02_MasterTemplete_Inputs"; import "./M02_MasterTemplete_Structure.css"; @@ -66,6 +70,7 @@ const TEXT = { noPrice: "이 열에 일위대가가 없어 알약 없음 — 구조물 조합 일위대가 줄에서 고름", noInputs: "일위대가 {value} 에 입력값이 없어 알약 없음", inputsFailed: "일위대가 입력값을 못 받음 — {value}", + discardCards: "면적산출 · 재료산출 카드에 저장 안 한 고침이 있음 — 버리고 넘어갈까?", }; /** 집계표 열 하나에 딸린 문서 셋 — 번호 칸 · 이름 · 복사 */ @@ -297,6 +302,63 @@ export async function pillsFor( } } +/** 카드 미리보기 수정의 알약 · 자재 조합 판(31-3) — 전체 편집기(`UI_Main` `mountSheet`)와 같은 부품 · + * 면적 = 그 열 일위대가 입력값(`pillsFor`) · 재료 = 자재 조합 담은 자재 + 선택 입력 · 아래 자재 조합 판 */ +async function cardLinks( + kind: SheetKind, + ctl: SheetCardControl, + isAdmin: boolean, +): ReturnType { + if (kind === "area") return { links: (await pillsFor("area", ctl.column)).links }; + const [state, own] = await Promise.all([ + loadMaterialState(ctl.column), + inputPills(ctl.column, "재료"), // 「선택 입력」 카드가 재료산출로 고른 입력 + ]); + let items = state.combo?.담은자재 ?? []; + const links = (): LinkOptions => ({ + 항목: [...toPills(items), ...own], + 모드: "자재", + 자재찾기: searchMaterials, + // 알약 막대에서 추가 · 빼기 — 아래 판 표를 같은 목록으로 + onItemsChange: (list) => { + items = list.flatMap((i) => (i.자재 ? [{ ...i.자재 }] : [])); + panel.setItems(items); + }, + }); + const panel = buildMaterialPanel({ + column: ctl.column, + label: ctl.label, + isAdmin, + state, + links: () => + ctl + .handle() + ?.getDoc() + .시트.flatMap((s) => s.연결 ?? []) ?? [], + // 판 표에서 고침 — 알약 막대만 이 목록으로 다시 만듦(저장 안 한 시트 고침은 그대로) + onChange: (list) => { + items = list; + ctl.remount(links()); + }, + goTo: (code) => + ctl.host + .querySelector(`.ss-link.is-linked[data-key="${CSS.escape(code)}"]`) + ?.click(), + unlink: (codes) => { + const book = ctl.handle()?.getDoc(); + if (!book) return; + for (const sheet of book.시트) { + const rest = (sheet.연결 ?? []).filter((l) => !codes.includes(l.자재?.코드 ?? l.키)); + if (rest.length) sheet.연결 = rest; + else delete sheet.연결; + } + ctl.handle()?.setDoc(book); + ctl.touched(); + }, + }); + return { links: links(), below: panel.root, refresh: panel.refresh }; +} + export interface StructurePanelOptions { /** 시스템 관리자만 저장함(구조물 도면은 서버가 미리 만들어 둠) */ isAdmin: boolean; @@ -307,6 +369,8 @@ export interface StructurePanelOptions { confirmLeave: () => Promise; /** 「선택 입력」 카드에서 자리를 골랐음 — 저장 안 한 고침 표시 */ onDirty?: () => void; + /** 카드 고침을 버리지 않고 머묾 — 표의 고른 열을 되돌림(31-3) */ + keepColumn?: (colId: string) => void; } export interface StructurePanelHandle { @@ -317,6 +381,10 @@ export interface StructurePanelHandle { reload: () => Promise; /** 「선택 입력」 카드에서 고른 자리를 서버에 저장 — 집계표 [저장] 뒤에 부름(실패하면 던짐) */ saveInputs: () => Promise; + /** 면적산출 · 재료산출 카드에 저장 안 한 고침(31-3) */ + dirty: () => boolean; + /** 고친 카드 저장 — 집계표 [저장] 이 카드를 다시 읽기 전에 부름(실패하면 던짐) */ + saveCards: () => Promise; /** 고른 열이 바뀜 — 같은 열이면 이름만 새로(CAD 를 다시 안 띄움) */ show: (colId: string | null, columns: StructureColumn[]) => void; /** 도면 카드에 저장 안 한 고침(31-4 · 카드 [저장] 몫) — 페이지가 떠날 때 함께 물음 */ @@ -348,6 +416,13 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane // 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」 warnOnFail: (id) => !!numbers[id], extraCards: [inputs.root], + // 면적산출 · 재료산출 카드 미리보기 수정(31-3) — 전체 편집기와 같은 알약 · 저장 길 + sheetEdit: { + can: () => opts.isAdmin, + links: (kind, ctl) => cardLinks(kind, ctl, opts.isAdmin), + save: async (kind, at, doc, version) => + (await saveTemplate(getActiveLayer(), kind, at, null, version, doc)).판, + }, }); const root = cards.root; @@ -364,6 +439,15 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane /** 글 열(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 && cards.dirty()) { + // 카드에 저장 안 한 고침 — 버릴지 물음 · 머물면 표의 고른 열을 되돌림 + const from = column; + void showConfirmDialog(TEXT.discardCards).then((ok) => { + if (ok) void load(col); + else if (from) opts.keepColumn?.(from); + }); + return; + } if ((col?.id ?? null) !== column) return void load(col); if (col) cards.setLabel((columnLabel = columnHead(col))); // 머리를 고쳤을 수 있음 }; @@ -438,6 +522,10 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane }, saveInputs: () => inputs.save(), dirty: () => cards.dirty(), +<<<<<<< HEAD +======= + saveCards: () => cards.saveAll(), +>>>>>>> origin/sub_laptop_8 show, refresh: (columns) => { const at = column; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 93db8a87..bb934ef8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -194,16 +194,26 @@ export function createMain( let cpanel: ComboPanel | null = null; // 면적산출 · 재료산출 오른쪽 조합 패널(프로젝트 층 아님) let seq = 0; let dirty = false; + /** 저장 안 한 고침 — 이 화면 + 면적산출 · 재료산출 카드(31-3) */ + const busy = (): boolean => dirty || !!panel?.dirty(); const guard = (event: BeforeUnloadEvent): void => { if (!root.isConnected) return window.removeEventListener("beforeunload", guard); +<<<<<<< HEAD if (!dirty && !panel?.dirty()) return; +======= + if (!busy()) return; +>>>>>>> origin/sub_laptop_8 event.preventDefault(); event.returnValue = ""; }; window.addEventListener("beforeunload", guard); const confirmLeave = async (): Promise => { +<<<<<<< HEAD if (!dirty && !panel?.dirty()) return true; // 도면 카드 고침(31-4)도 같은 물음 +======= + if (!busy()) return true; +>>>>>>> origin/sub_laptop_8 if (!(await showConfirmDialog(L("M02_DiscardConfirm")))) { // 취소 — 초점을 시트(고른 칸 입력 자리) · 집계표 · 도면으로 돌려 키보드 그대로 host @@ -250,6 +260,7 @@ export function createMain( isAdmin, confirmLeave, onDirty: () => (dirty = true), // 「선택 입력」 카드에서 자리를 고름 — 집계표 [저장]으로 함께 저장 + keepColumn: (colId) => sheet?.selectColumn(colId), // 카드 고침을 안 버림 — 고른 열 되돌림 // 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶 onEdit: (column, label, kind) => { void open({ layer, projectId: null, kind, name: column, label }); @@ -540,6 +551,7 @@ export function createMain( // 구조물집계표 — 서버가 새 열 파일 · 번호를 만듦 · 번호를 다시 받아 도면 줄 · 카드를 새로 // (안 하면 새 열은 다시 열 때까지 「도면 없음」 · 번호 빈칸) if (panel && sheet) { + await panel.saveCards(); // 카드 고침(31-3) — 카드를 다시 읽기 전에 await panel.reload(); sheet.recalc(); panel.refresh(sheet.getDoc()?.열 ?? []); @@ -591,7 +603,7 @@ export function createMain( } async function settle(): Promise { - if (!dirty) return "clean"; + if (!busy()) return "clean"; const choice = await askSettle(); if (choice === "cancel") return false; if (choice === "save") { diff --git a/resources/tester/test_m02_card_sheet_edit.py b/resources/tester/test_m02_card_sheet_edit.py new file mode 100644 index 00000000..ce994a23 --- /dev/null +++ b/resources/tester/test_m02_card_sheet_edit.py @@ -0,0 +1,78 @@ +"""면적산출 · 재료산출 카드 미리보기 수정(PLAN 31-3) — 화면 조작은 ORCA. + +· 스프레드시트 `preview` = 도구 모음 · 오른쪽 클릭 메뉴 없음 · 수식 입력 줄 · 칸 고침 + (다른 화면 그대로) +· 카드 = `sheetEdit` 가 있을 때만 고침(M02 구조물 조합) · 고르기 모달은 보기만 그대로 +· 알약 · 자재 조합 판 = 전체 편집기와 같은 부품 · 카드 [저장] = 같은 저장 길 · 저장 안 한 고침 물음 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_스프레드시트_미리보기_수정_옵션(): + src = _text(ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts") + assert "preview?: boolean;" in src + assert "const bare = readOnly || !!opts.preview;" in src + assert "bare ? null : mountToolbar(ctx)" in src and "bare ? null : attachMenu(ctx)" in src + # 편집 · 수식 입력 줄은 readOnly 만 막음 — preview 는 고칠 수 있음 + assert "const readOnly = !!opts.readOnly;" in src + assert re.search(r"for \(const node of \[toolbar\?\.root, ed\.bar, grid\.root", src) + + +def test_카드는_sheetEdit_있을_때만_고침(): + cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts") + assert "sheetEdit?: SheetEditOptions;" in cards + assert ( + "if (opts.sheetEdit?.can()) return await paintEditable(c, mine, createSpreadsheet);" + in cards + ) + body = cards.split("const paintEditable")[1].split("const paintSheet")[0] + assert "preview: true," in body and "links," in body + assert "c.host.append(extra.below)" in body # 재료산출 = 자재 조합 판 + # 보기만 길 그대로(고르기 모달) + assert "createSpreadsheet(c.host, c.doc, { readOnly: true, tabs: false })" in cards + detail = _text(ROOT / "ui_template" / "ui_template_item_picker_detail.ts") + assert "sheetEdit" not in detail + + +def test_카드_저장은_읽은_문서에_시트를_덮음(): + cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts") + save = cards.split("async function saveCard")[1].split("\n }\n")[0] + assert "...(c.doc as unknown as Record)," in save + assert "...c.handle.getDoc()," in save + assert "c.version = await opts.sheetEdit.save(c.kind, at, doc, c.version);" in save + assert 'c.version = String((got.value as { 판?: string | null }).판 ?? "");' in cards + struct = _text(M02 / "M02_MasterTemplete_Structure.ts") + assert "saveTemplate(getActiveLayer(), kind, at, null, version, doc)" in struct + + +def test_알약_자재_조합_판은_전체_편집기와_같은_부품(): + struct = _text(M02 / "M02_MasterTemplete_Structure.ts") + links = struct.split("async function cardLinks")[1].split("export interface")[0] + assert 'pillsFor("area", ctl.column)' in links + assert "loadMaterialState(ctl.column)" in links and 'inputPills(ctl.column, "재료")' in links + assert "toPills(items)" in links and "자재찾기: searchMaterials" in links + assert "buildMaterialPanel({" in links and "ctl.remount(links())" in links + assert "can: () => opts.isAdmin" in struct + + +def test_저장_안_한_카드_고침_물음(): + struct = _text(M02 / "M02_MasterTemplete_Structure.ts") + show = struct.split("const show = ")[1].split("const pickPrice")[0] + assert "cards.dirty()" in show and "showConfirmDialog(TEXT.discardCards)" in show + assert "opts.keepColumn?.(from)" in show + main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") + assert "const busy = (): boolean => dirty || !!panel?.dirty();" in main + assert main.count("busy()") >= 3 # 떠나기 · 새로고침 막기 · 나무 고침(settle) + assert main.index("await panel.saveCards();") < main.index("await panel.reload();") + assert "keepColumn: (colId) => sheet?.selectColumn(colId)" in main