feat(M02): 구조물 조합 카드 빈 상태 [새로 만들기] · [복사 생성] 없앰 · 일위대가 · 면적산출 가져오기 = 함께 덮어씀(43-9b)

- 빈 카드도 보통 카드 — 빈 몸 + 가져오기 · 수정 · 삭제(새 항목 칸 셋은 서버가 자동 생성)
- 일위대가 · 면적산출 카드 가져오기 · 집계표 줄 조회 후 선택 = copyLaborAreaFrom(알약 연결 포함) · 도면은 그 칸만
- 가져오기 창 안내 한 줄 「일위대가 · 면적산출 함께 가져옴」(pickFrom note)
- 화면 검증 시나리오 ui_checks/43-9b_빈카드_함께가져오기.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-05 00:51:27 +09:00
co-authored by Claude Opus 5.5
parent e909f8efc3
commit e1f7b5f62d
5 changed files with 155 additions and 86 deletions
@@ -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<void>;
/** 일위대가 카드(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<CardKind>();
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<void> => {
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<void> => {
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;
@@ -225,9 +225,13 @@ export function buildTwoWay(opt: TwoWayOptions): HTMLElement {
});
}
/** 창으로 고름 — 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */
/** 창으로 고름 — 위 안내 한 줄(`note` · 있으면) · 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */
export function pickFrom(
opt: Omit<PickerOptions, "onPick"> & { title: string; ask?: (picked: Picked) => string },
opt: Omit<PickerOptions, "onPick"> & {
title: string;
note?: string;
ask?: (picked: Picked) => string;
},
): Promise<Picked | null> {
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<PickerOptions, "onPick" | "user" | "company"> & {
title: string;
note?: string;
ask?: (picked: Picked) => string;
},
): Promise<Picked | null> {
@@ -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;
@@ -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<void> => {
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<typeof copyPartFrom>[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 },
);
@@ -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);
})();