diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index 3b3a24b3f..d00f83160 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -65,6 +65,11 @@ const TEXT = { sheetHelp: "단축키 — 칸을 고르고 바로 침", pills: "알약", pillsTip: "아래 알약 줄 보임 · 숨김", + take: "복사해 가져오기", + takeTip: "다른 항목의 {value} 을(를) 골라 이 칸에 덮어씀 — 번호 · 열은 그대로", + blank: "비어 있음 — 새로 만들거나 다른 항목에서 복사해 옴", + makeNew: "새로 만들기", + makeCopy: "복사 생성", }; /** 엑셀 카드 단축키 — 스프레드시트 미리보기(`spreadsheet_keys.ts`)가 받는 것만 */ @@ -81,6 +86,11 @@ const SHEET_KEYS: [string, string][] = [ ]; const why = (error: unknown): string => (error instanceof Error ? error.message : ""); +/** 빈 통합문서 — 값 · 식이 든 칸도 알약 연결도 하나도 없음 */ +const emptyBook = (book: Workbook): boolean => + (book.시트 ?? []).every( + (s) => !s.연결?.length && Object.values(s.칸 ?? {}).every((c) => (c.값 ?? "") === "" && !c.식), + ); const fill = (text: string, value: string): string => text.replace("{value}", value); /** 스프레드시트는 면적산출 · 재료산출 카드를 열 때만 받음 */ const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet"); @@ -102,6 +112,8 @@ export interface ItemCardsOptions { onDirty?: (dirty: boolean) => void; /** 면적산출 · 재료산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */ sheetEdit?: SheetEditOptions; + /** 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 다른 항목의 이 칸을 골라 덮어씀 · 없으면 단추 없음 */ + onImport?: (kind: EditKind) => void; } /** 카드 스프레드시트를 다루는 손잡이 — 알약 · 자재 조합 판이 씀 */ @@ -154,6 +166,8 @@ interface Card { saver: SaveControl; help: HTMLElement; edit: HTMLButtonElement; + /** 「복사해 가져오기」 — 찬 카드만 */ + take: HTMLButtonElement; no: HTMLElement; name: HTMLElement; } @@ -165,6 +179,8 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { let docVersion: string | null = null; let preview: DrawingCardHandle | null = null; let seq = 0; + /** 빈 카드에서 [새로 만들기] 를 누른 칸 — 빈 문서라도 미리보기 편집을 띄움(열이 바뀌면 비움) */ + const started = new Set(); const numberOf = (kind: EditKind, id: string): string => opts.numberOf?.(kind, id) ?? ""; const anyDirty = (): boolean => cards.some((c) => c.saver.dirty()); @@ -192,8 +208,44 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { size: "sm", }); edit.addEventListener("click", () => opts.onEdit(kind)); - shell.tools.append(help, saver.button, edit); - return { kind, shell, saver, help, edit, no, name }; + const take = createButton({ label: TEXT.take, variant: "ghost", size: "sm" }); + take.title = fill(TEXT.takeTip, PART_WORD[kind]); + take.addEventListener("click", () => opts.onImport?.(kind)); + shell.tools.append(help, saver.button, take, edit); + return { kind, shell, saver, help, edit, take, no, name }; + }; + + /** 빈 카드(39-5) — 고칠 수 있고(가져오기 길이 있는 M02 화면) [새로 만들기] 를 안 눌렀으면 두 단추 · 아니면 미리보기 */ + const blank = (c: Card, empty: boolean): boolean => { + const show = empty && opts.editable() && !!opts.onImport && !started.has(c.kind); + c.take.hidden = show || !opts.editable() || !opts.onImport; + return show; + }; + /** 빈 카드 몸 — [새로 만들기] = 그 자리에서 빈 미리보기 편집 · [복사 생성] = 가져오기 창 */ + const startBox = (c: Card, paint: () => void): HTMLElement => { + const fresh = createButton({ + label: TEXT.makeNew, + variant: "ghost", + size: "sm", + onClick: () => { + started.add(c.kind); + paint(); + }, + }); + const copy = createButton({ + label: TEXT.makeCopy, + variant: "ghost", + size: "sm", + onClick: () => opts.onImport?.(c.kind), + }); + copy.hidden = !opts.onImport; + return el("div", { + className: "m02-struct__start", + children: [ + el("p", { className: "m02-struct__empty", text: TEXT.blank }), + el("div", { className: "m02-struct__start-row", children: [fresh, copy] }), + ], + }); }; const previewHost = el("div", { className: "m02-struct__preview" }); @@ -328,6 +380,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { * 그 사이 열이 바뀌면 버림 */ const paintSheet = async (c: SheetCard, mine: number): Promise => { if (!c.doc) return; + if (blank(c, emptyBook(c.doc))) + return void c.host.replaceChildren(startBox(c, () => void paintSheet(c, seq))); + c.host.replaceChildren(); try { const { createSpreadsheet } = await loadSpreadsheet(); if (mine !== seq || !c.doc) return; @@ -348,14 +403,21 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { c.number || numberOf(c.kind, at) || fill(TEXT.noFieldNumber, PART_FIELD[c.kind]); void paintSheet(c, mine); } + paintDrawing(); + }; + + /** 도면 미리보기 — 빈 도면이면 두 단추(39-5) */ + const paintDrawing = (): void => { if (!doc) return; previewHost.replaceChildren(); + if (blank(info, !doc.도면?.entities?.length)) + return void previewHost.append(startBox(info, paintDrawing)); // 미리보기 — 고칠 수 있으면 메뉴 없이 단축키로 고치고 카드 [저장](31-4 · `DrawingCard`) preview = mountDrawingCard(previewHost, { doc, version: docVersion, source: opts.source(), - column: at, + column: column ?? "", editable: opts.editable(), saver: info.saver, }); @@ -384,6 +446,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { return; } root.hidden = false; + if (col.id !== column) started.clear(); column = col.id; label = col.label; for (const c of cards) c.name.textContent = label; diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index 3ee87e241..4fd1b1215 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -9,7 +9,7 @@ import { createButton, createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { fetchSessionUser, type SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { fetchOwners, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import { buildItemList, @@ -20,7 +20,13 @@ import { type ListKind, } from "./M02_MasterTemplete_ItemList"; import { fetchCombos, type ComboSummary } from "./M02_MasterTemplete_Labor_Api"; -import { ownOwner, readableSources, type Source } from "./M02_MasterTemplete_Layer"; +import { + entryFromHash, + ownOwner, + readableSources, + viewCompany, + type Source, +} from "./M02_MasterTemplete_Layer"; import { leafName } from "./M02_MasterTemplete_TreePanel_Model"; import "./M02_MasterTemplete_TreePanel.css"; import "./M02_MasterTemplete_ItemPick.css"; @@ -236,3 +242,14 @@ export function pickFrom( }); }); } + +/** 지금 화면의 사람 · 볼 회사로 `pickFrom`(카드 · 집계표 줄처럼 페이지 값을 못 받는 곳) */ +export async function pickHere( + opt: Omit & { + title: string; + ask?: (picked: Picked) => string; + }, +): Promise { + const user = await fetchSessionUser().catch(() => null); + return pickFrom({ ...opt, user, company: viewCompany(entryFromHash(), user) }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index 57eaacbce..a28d80de4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -196,6 +196,22 @@ color: var(--color-text-secondary); } +/* 빈 카드(39-5) — 안내 한 줄 + [새로 만들기] [복사 생성] */ +.m02-struct__start { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-8); + padding-bottom: var(--spacing-16); +} + +.m02-struct__start-row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + padding: 0 var(--spacing-16); +} + /* 조회 후 선택 창 — 찾기 칸 + 목록(번호 · 이름) */ .m02-pick__list { display: flex; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 30a1b1bea..c473b08f0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -31,6 +31,7 @@ import { copyStructure, fetchStructureNumbers, getActiveLayer, + getActiveOwner, readTemplate, saveTemplate, type StructureNumbers, @@ -47,6 +48,7 @@ import { } from "./M02_MasterTemplete_ItemCards"; import { buildMaterialPanel, loadMaterialState, toPills } from "./M02_MasterTemplete_Materials"; import { layerSource } from "./M02_MasterTemplete_ItemSource"; +import { pickHere, pickedName, type From } from "./M02_MasterTemplete_ItemPick"; import { createInputsCard, inputPills, type InputsCardHandle } from "./M02_MasterTemplete_Inputs"; import "./M02_MasterTemplete_Structure.css"; @@ -66,6 +68,7 @@ const TEXT = { overwrite: "{from} 의 {what} 내용을 {to} 에 덮어씀 — 번호 · 열은 그대로", pickCopy: "{value} 복사해 가져오기", copied: "복사해 가져옴", + acrossLater: "다른 곳(층 · 사람)에서 가져오기는 서버 길이 선 뒤 — 지금은 같은 곳만", copyFailed: "복사 실패 — {value}", noPrice: "이 열에 일위대가가 없어 알약 없음 — 구조물 조합 일위대가 줄에서 고름", noInputs: "일위대가 {value} 에 입력값이 없어 알약 없음", @@ -414,6 +417,8 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane // 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」 warnOnFail: (id) => !!numbers[id], extraCards: [inputs.root], + // 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 집계표 줄 「조회 후 선택」 과 같은 길 + onImport: (kind) => void pickCopy(kind, shown ?? undefined), // 면적산출 · 재료산출 카드 미리보기 수정(31-3) — 전체 편집기와 같은 알약 · 저장 길 sheetEdit: { can: () => opts.isAdmin, @@ -457,37 +462,31 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane return got ? got.key : null; }; - /** 다른 열의 도면 · 면적산출 · 재료산출을 골라 내용만 복사 — 덮어쓰기 확인 · 보고 있던 열이면 다시 읽음 */ - const pickCopy = async ( - kind: EditKind, - colId: string, - columns: StructureColumn[], - ): Promise => { - const field = PART_FIELD[kind]; + /** 다른 항목의 도면 · 면적산출(…)을 골라 내용만 복사(39-5) — 탭 = 읽을 수 있는 곳 · 덮어쓰기 확인 · + * 번호 · 열은 그대로 · 보고 있던 열이면 다시 읽음 · 집계표 줄 「조회 후 선택」 · 카드 「복사해 가져오기」가 씀 */ + const pickCopy = async (kind: EditKind, to: StructureColumn | undefined): Promise => { const word = PART_WORD[kind]; - const copy = COPY[kind]; - const to = columns.find((c) => c.id === colId); - const toNo = numbers[colId]?.[field]; + const toNo = to && numbers[to.id]?.[PART_FIELD[kind]]; if (!to || !toNo) return void showToast(TEXT.saveFirst, "error"); - const items = columns - .filter((c) => c.id !== colId && numbers[c.id]?.[field]) - .map((c) => ({ value: c.id, key: numbers[c.id][field]!, label: columnHead(c) })); - const from = await choose( - `${fill(TEXT.pickCopy, word)} — ${toNo} ${columnHead(to)}`, - items, - false, - ); - const src = columns.find((c) => c.id === from); - if (!from || !src) return; - const ask = TEXT.overwrite - .replace("{from}", `${numbers[from][field]} ${columnHead(src)}`) - .replace("{what}", word) - .replace("{to}", `${toNo} ${columnHead(to)}`); - if (!(await showConfirmDialog(ask))) return; + const target = `${toNo} ${columnHead(to)}`; + const here = { layer: getActiveLayer(), owner: getActiveOwner() }; + const same = (from: From): boolean => from.layer === here.layer && from.owner === here.owner; + const picked = await pickHere({ + title: `${fill(TEXT.pickCopy, word)} — ${target}`, + kind: kind === "material" ? "item" : kind, + skip: (from, row) => same(from) && row.열 === to.id, + ask: (p) => + TEXT.overwrite + .replace("{from}", `${p.from.label} ${pickedName(p)}`) + .replace("{what}", word) + .replace("{to}", target), + }); + if (!picked || !("row" in picked)) return; try { - await copy(colId, from); + if (!same(picked.from)) throw new Error(TEXT.acrossLater); // 층 넘는 복사 길(39-1b) 뒤에 이음 + await COPY[kind](to.id, picked.row.열); showToast(TEXT.copied, "success"); - if (column === colId) void load(shown); + if (column === to.id) void load(shown); } catch (error) { showToast(fill(TEXT.copyFailed, why(error)), "error"); } @@ -500,7 +499,11 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane ): Promise => { if (!opts.isAdmin || !colId) return null; if (row === "s:price") return pickPrice(colId, columns); - if (ROW_KIND[row]) await pickCopy(ROW_KIND[row], colId, columns); + if (ROW_KIND[row]) + await pickCopy( + ROW_KIND[row], + columns.find((c) => c.id === colId), + ); return null; }; diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index f0070d654..4972c8711 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -40,7 +40,7 @@ def test_저장_단추_알림_한_벌(): def test_네_카드가_틀을_씀_수정_단추_한_모양(): cards = _text("M02_MasterTemplete_ItemCards.ts") assert "const shell = cardShell({ title, body, sub: [no, name] });" in cards - assert "shell.tools.append(help, saver.button, edit);" in cards + assert "shell.tools.append(help, saver.button, take, edit);" in cards # 복사해 가져오기(39-5) assert 'variant: "filled"' not in cards # [수정] 은 모두 빈 단추(ghost) assert "c.saver.button.hidden = c.help.hidden = !can;" in cards # [저장] · ? 늘 보임 assert "DRAWING_KEYS" in cards and "SHEET_KEYS" in cards diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index b10a492d8..51b58e851 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -224,7 +224,7 @@ def test_표_도구_막대에_줄_더하기_줄_지우기_손_입력_열_없음( def test_상세_화면_돌아가기는_집계표로_그_열_고른_채(): main = _text(MAIN) - assert "children: [title, back, slot, reload, save, " in main # 제목 바로 오른쪽 + assert "children: [back, title, slot, reload, save, " in main # M01 꼴 — 제목 앞(39-5) assert "!(sel.kind in NUMBER_FIELD)" in main # 구조물 도면 · 면적산출 · 재료산출만 body = main.split('back.addEventListener("click"')[1] assert "confirmLeave()" in body and "STRUCTURE_TABLE" in body