From e1f7b5f62dd27a83791831a224bc24fa3611704c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:51:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=EC=B9=B4=EB=93=9C=20=EB=B9=88=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20[=EC=83=88=EB=A1=9C=20=EB=A7=8C=EB=93=A4=EA=B8=B0]?= =?UTF-8?q?=20=C2=B7=20[=EB=B3=B5=EC=82=AC=20=EC=83=9D=EC=84=B1]=20?= =?UTF-8?q?=EC=97=86=EC=95=B0=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20=3D=20=ED=95=A8=EA=BB=98?= =?UTF-8?q?=20=EB=8D=AE=EC=96=B4=EC=94=80(43-9b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 빈 카드도 보통 카드 — 빈 몸 + 가져오기 · 수정 · 삭제(새 항목 칸 셋은 서버가 자동 생성) - 일위대가 · 면적산출 카드 가져오기 · 집계표 줄 조회 후 선택 = copyLaborAreaFrom(알약 연결 포함) · 도면은 그 칸만 - 가져오기 창 안내 한 줄 「일위대가 · 면적산출 함께 가져옴」(pickFrom note) - 화면 검증 시나리오 ui_checks/43-9b_빈카드_함께가져오기.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemCards.ts | 71 ++-------- .../M02_MasterTemplete_ItemPick.ts | 10 +- .../M02_MasterTemplete_Structure.css | 16 --- .../M02_MasterTemplete_Structure.ts | 19 ++- .../ui_checks/43-9b_빈카드_함께가져오기.js | 125 ++++++++++++++++++ 5 files changed, 155 insertions(+), 86 deletions(-) create mode 100644 resources/tester/ui_checks/43-9b_빈카드_함께가져오기.js diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index 08bcefb1f..45cb5ede0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -79,9 +79,6 @@ const TEXT = { clearAsk: "「{name}」 의 {value} 을(를) 비울까? — 번호 · 열은 그대로 · 되돌릴 수 없음", clearFailed: "비우지 못함 — {value}", takeTip: "다른 항목의 {value} 을(를) 골라 이 칸에 덮어씀 — 번호 · 열은 그대로", - blank: "비어 있음 — 새로 만들거나 다른 항목에서 복사해 옴", - makeNew: "새로 만들기", - makeCopy: "복사 생성", }; /** 엑셀 카드 단축키 — 스프레드시트 미리보기(`spreadsheet_keys.ts`)가 받는 것만 */ @@ -98,11 +95,6 @@ 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"); @@ -124,13 +116,13 @@ export interface ItemCardsOptions { onDirty?: (dirty: boolean) => void; /** 면적산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */ sheetEdit?: SheetEditOptions; - /** 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 다른 항목의 이 칸을 골라 덮어씀 · 없으면 단추 없음 */ + /** 「가져오기」(39-5 · 43-9b) — 다른 항목의 이 칸을 골라 덮어씀 · 빈 카드도 같음 · 없으면 단추 없음 */ onImport?: (kind: CardKind) => void; /** 카드 [삭제](40-2) — 그 항목 비우기(번호 · 열 그대로 · 물음은 카드가 한 번) · 없으면 단추 없음 · - * 끝나면 카드를 다시 읽음(빈 카드 = [새로 만들기] [복사 생성]) */ + * 끝나면 카드를 다시 읽음(빈 카드 = 빈 몸 + 같은 단추) */ onClear?: (kind: CardKind) => Promise; /** 일위대가 카드(39-5 · 몸 = 일위대가 상세 카드 갈래 `Labor_Detail` · 43-4) — 도면 다음 · 지금 층에서 읽음 · - * [일위대가 수정] · 빈 카드 [새로 만들기] = 이것(일위대가 상세) */ + * [일위대가 수정] = 이것(일위대가 상세) */ labor?: () => void; } @@ -184,9 +176,9 @@ interface Card { saver: SaveControl; help: HTMLElement; edit: HTMLButtonElement; - /** 「복사해 가져오기」 — 찬 카드만 */ + /** 「가져오기」 — 빈 카드도 */ take: HTMLButtonElement; - /** [삭제] — 찬 카드만 · 쓸 수 있을 때만 */ + /** [삭제] — 쓸 수 있을 때만 */ del: HTMLButtonElement; no: HTMLElement; name: HTMLElement; @@ -199,8 +191,6 @@ 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: CardKind, id: string): string => opts.numberOf?.(kind, id) ?? ""; const anyDirty = (): boolean => cards.some((c) => c.saver.dirty()); @@ -252,44 +242,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { return void card?.shell.fail(fill(TEXT.clearFailed, why(error)), reload); } for (const c of cards) c.saver.mark(false); - started.delete(kind); reload(); } - /** 빈 카드(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; - c.del.hidden = show || !opts.editable() || !opts.onClear; // 빈 카드엔 비울 것이 없음 - 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" }); const info = makeCard("structure", TEXT.drawingInfo, previewHost, () => preview ? preview.write() : Promise.resolve(), @@ -340,7 +295,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { : null; let laborEd: LaborDetailHandle | null = null; let laborVer = ""; - /** 일위대가 카드 — 그 열 문서를 읽어 카드 갈래로 붙임 · 빈 일위대가(로직 · 자재 없음)면 두 단추 */ + /** 일위대가 카드 — 그 열 문서를 읽어 카드 갈래로 붙임(빈 일위대가도 빈 몸) */ const paintLabor = async (at: string, mine: number): Promise => { if (!laborCard) return; laborEd?.destroy(); @@ -359,11 +314,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { } if (mine !== seq) return; laborVer = got.판 ?? ""; - const d = (got.문서 ?? {}) as { 일위대가번?: string; 담은로직?: unknown[]; 자재?: unknown[] }; + const d = (got.문서 ?? {}) as { 일위대가번?: string }; laborCard.no.textContent = d.일위대가번 || numberOf("labor", at) || fill(TEXT.noFieldNumber, PART_FIELD.labor); - if (blank(laborCard, !d.담은로직?.length && !d.자재?.length)) - return void laborHost.replaceChildren(startBox(laborCard, () => opts.labor?.())); laborEd = mod.mountLaborDetail(laborHost, got.문서, { layer, column: at, @@ -407,7 +360,8 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { c.saver.button.hidden = c.help.hidden = !can; if (c.kind === "labor") c.help.hidden = true; // 일위대가 카드엔 단축키 상자 없음 c.edit.hidden = !opts.editable(); - c.del.hidden = !opts.editable() || !opts.onClear; // 빈 카드는 그린 뒤 `blank` 가 다시 숨김 + c.take.hidden = !opts.editable() || !opts.onImport; + c.del.hidden = !opts.editable() || !opts.onClear; } pills.hidden = !sheetEdit; }; @@ -476,8 +430,6 @@ 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(); @@ -502,12 +454,10 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { 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, @@ -543,7 +493,6 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { } root.hidden = false; paintPills(readStateRaw("m02-card-pills") === "true"); // 계정 취향을 늦게 받았어도 열 때마다 따름 - 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 30fbaea13..69d7b1ef4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -225,9 +225,13 @@ export function buildTwoWay(opt: TwoWayOptions): HTMLElement { }); } -/** 창으로 고름 — 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */ +/** 창으로 고름 — 위 안내 한 줄(`note` · 있으면) · 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */ export function pickFrom( - opt: Omit & { title: string; ask?: (picked: Picked) => string }, + opt: Omit & { + title: string; + note?: string; + ask?: (picked: Picked) => string; + }, ): Promise { return new Promise((resolve) => { let done = false; @@ -250,6 +254,7 @@ export function pickFrom( close(); })(), }); + if (opt.note) body.append(el("p", { className: "m02-itempick__note", text: opt.note })); body.append(picker.root); }, }); @@ -260,6 +265,7 @@ export function pickFrom( export async function pickHere( opt: Omit & { title: string; + note?: string; ask?: (picked: Picked) => string; }, ): Promise { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index aabaaa5e8..674d1bd2d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -205,22 +205,6 @@ 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 b51a094e2..0bfca0ffe 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -18,6 +18,7 @@ import { showConfirmDialog, showToast } from "@ui/ui_template_elements"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; import { clearPart, + copyLaborAreaFrom, copyPartFrom, fetchLaborPills, fetchStructureNumbers, @@ -50,6 +51,8 @@ const TEXT = { overwrite: "{from} 의 {what} 내용을 {to} 에 덮어씀 — 번호 · 열은 그대로", pickCopy: "{value} 복사해 가져오기", copied: "복사해 가져옴", + pair: "일위대가 · 면적산출", + pairNote: "일위대가 · 면적산출 함께 가져옴 — 알약 연결 포함 · 도면은 그대로", copyFailed: "복사 실패 — {value}", noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음", pillsFailed: "일위대가 알약을 못 받음 — {value}", @@ -263,8 +266,10 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane }; /** 다른 항목의 도면 · 면적산출(…)을 골라 내용만 복사(39-5) — 탭 = 읽을 수 있는 곳 · 덮어쓰기 확인 · - * 번호 · 열은 그대로 · 보고 있던 열이면 다시 읽음 · 집계표 줄 「조회 후 선택」 · 카드 「복사해 가져오기」가 씀 */ + * 번호 · 열은 그대로 · 보고 있던 열이면 다시 읽음 · 집계표 줄 「조회 후 선택」 · 카드 「가져오기」가 씀 · + * 일위대가 · 면적산출은 함께(43-9b · 알약 연결이 이어져 있음 · 실무문서면 서버가 일위대가만) · 도면은 그 칸만 */ const pickCopy = async (kind: CardKind, to: StructureColumn | undefined): Promise => { + const pair = kind !== "structure"; const word = PART_WORD[kind]; const toNo = to && numbers[to.id]?.[PART_FIELD[kind]]; if (!to || !toNo) return void showToast(TEXT.saveFirst, "error"); @@ -276,22 +281,22 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane const picked = await pickHere({ title: `${fill(TEXT.pickCopy, word)} — ${target}`, kind, + note: pair ? TEXT.pairNote : undefined, skip: (from, row) => same(from) && row.열 === to.id, ask: (p) => TEXT.overwrite .replace("{from}", `${p.from.label} ${pickedName(p)}`) - .replace("{what}", word) + .replace("{what}", pair ? TEXT.pair : word) .replace("{to}", target), }); if (!picked) return; + const copy = (source: string, from: Parameters[3]) => + pair ? copyLaborAreaFrom(to.id, source, from) : copyPartFrom(kind, to.id, source, from); try { // 같은 곳 = 다른 열 · 다른 층 · 사람 = 원본 층 · 실무문서 = 조합 키 → 일위대가(39-1b) - if ("combo" in picked) - await copyPartFrom(kind, to.id, picked.combo.키, { layer: "practice", owner: null }); + if ("combo" in picked) await copy(picked.combo.키, { layer: "practice", owner: null }); else - await copyPartFrom( - kind, - to.id, + await copy( picked.row.열, same(picked.from) ? null : { layer: picked.from.layer, owner: picked.from.owner }, ); diff --git a/resources/tester/ui_checks/43-9b_빈카드_함께가져오기.js b/resources/tester/ui_checks/43-9b_빈카드_함께가져오기.js new file mode 100644 index 000000000..2b809ce7b --- /dev/null +++ b/resources/tester/ui_checks/43-9b_빈카드_함께가져오기.js @@ -0,0 +1,125 @@ +/* 43-9b — 구조물 조합 카드 셋: 빈 카드 [새로 만들기] · [복사 생성] 없음 · 일위대가 [가져오기] = 일위대가 · 면적산출 함께 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · + * 나무에 item1(SU-000001 · 담은 로직 있음 · 면적산출 있음) + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글 · 20초 안) + * 쓰는 것: 마스터 층에 새 항목 하나(「검증43 9b」) + 그 일위대가 · 면적산출 — 끝나면 resources/master_template 사본으로 되씀 + * 기대 수치: + * - made = 새 열 id · twoButtons = 0(카드 안 「새로 만들기」 · 「복사 생성」) · tools = 카드 셋 모두 가져오기 · 수정 · 삭제 + * - note.labor = 「일위대가 · 면적산출 함께 가져옴 …」 · note.area = 같은 글 · note.drawing = null + * - after.logic = src.logic(14) · after.links = src.links(item1 알약 연결 3) · after.areaSame = true(시트 통째 = 원본) + * - drawing.before = drawing.after = 0(도면 그대로) · card.rows = src.logic(카드 다시 읽음) + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 6000) => { + for (let t = 0; t < ms; t += 150) { + const v = fn(); + if (v) return v; + await w(150); + } + return null; + }; + const vis = (b) => b && !b.hidden && b.offsetParent !== null; + const btns = (root, t) => + [...root.querySelectorAll("button")].filter((b) => vis(b) && b.innerText.trim() === t); + const api = (kind, col) => + fetch(`/api/m02/layers/system/templates/${kind}/${col}`, { credentials: "include" }).then((r) => + r.json(), + ); + const cells = (book) => + (book.시트 ?? []).flatMap((s) => + Object.entries(s.칸 ?? {}).filter(([, c]) => (c.값 ?? "") !== "" || c.식), + ); + const links = (book) => (book.시트 ?? []).reduce((n, s) => n + (s.연결?.length ?? 0), 0); + const sheets = (book) => JSON.stringify(book.시트 ?? []); + const items = () => + [...document.querySelectorAll('[data-node^="i|"]')].map((n) => n.dataset.node); + const cards = () => [...document.querySelectorAll(".m02-struct > *")]; + const out = {}; + + // ① 새 항목 — 제목 줄 「새 항목」 → [새로 만들기] → 종류 · 이름 → 확인 + const before = items(); + btns(document, "새 항목")[0].click(); + const two = await until(() => [...document.querySelectorAll(".m02-itempick__two")].find(vis)); + two.querySelector(".m02-itempick__buttons button").click(); + await w(500); + const ins = [...document.querySelectorAll("input")].filter(vis); + const set = (i, v) => { + i.value = v; + i.dispatchEvent(new Event("input", { bubbles: true })); + i.dispatchEvent(new Event("change", { bubbles: true })); + }; + set(ins[0], "검증43"); + set(ins[1], `9b-${Date.now() % 100000}`); // 같은 이름이면 안 만들어짐 + btns(document, "확인").pop().click(); + const node = await until(() => items().find((n) => !before.includes(n))); + if (!node) return JSON.stringify({ error: "새 항목 안 생김" }); + const col = node.slice(2); + out.made = col; + await until(() => cards()[1]?.querySelector(".m02-labor__edit"), 8000); + out.twoButtons = cards() + .flatMap((c) => [...c.querySelectorAll("button")]) + .filter((b) => ["새로 만들기", "복사 생성"].includes(b.innerText.trim())).length; + out.tools = cards().map((c) => + ["가져오기", "수정", "삭제"].every((t) => btns(c, t).length === 1), + ); + + // ② 가져오기 창 안내 — 도면(없음) · 면적산출(있음) 은 열어 보고 닫음 + const note = async (i) => { + btns(cards()[i], "가져오기")[0].click(); + const d = await until(() => document.querySelector(".m02-itempick__dialog")); + await w(300); + const text = d.querySelector(".m02-itempick__note")?.innerText ?? null; + return { d, text }; + }; + out.note = {}; + for (const [i, k] of [ + [0, "drawing"], + [2, "area"], + ]) { + const { d, text } = await note(i); + out.note[k] = text; + btns(d, "닫기")[0].click(); + await until(() => !document.querySelector(".m02-itempick__dialog")); + } + + // ③ 일위대가 카드 가져오기 ← item1(같은 마스터 층) + const [srcLabor, srcArea, dstDrawing] = await Promise.all([ + api("labor", "item1"), + api("area", "item1"), + api("structure", col), + ]); + out.src = { + logic: srcLabor.문서.담은로직?.length ?? 0, + links: links(srcArea.문서), + areaCells: cells(srcArea.문서).length, + }; + out.drawing = { before: dstDrawing.문서.도면?.entities?.length ?? 0 }; + const { d, text } = await note(1); + out.note.labor = text; + const row = await until(() => + [...d.querySelectorAll("tr")].find((r) => r.innerText.startsWith("SU-000001")), + ); + row.click(); + await w(400); + btns(document, "확인").pop().click(); + const rows = await until(() => { + const ed = cards()[1]?.querySelector(".m02-labor__edit"); + const n = ed?.querySelectorAll("tbody tr[data-labor-logic]").length ?? 0; + return n ? n : null; + }, 8000); + const [labor, area, drawing] = await Promise.all([ + api("labor", col), + api("area", col), + api("structure", col), + ]); + out.after = { + logic: labor.문서.담은로직?.length ?? 0, + links: links(area.문서), + areaCells: cells(area.문서).length, + areaSame: sheets(area.문서) === sheets(srcArea.문서), + }; + out.drawing.after = drawing.문서.도면?.entities?.length ?? 0; + out.card = { rows }; + return JSON.stringify(out); +})();