feat(M02): 카드 빈 칸 두 단추 · 「복사해 가져오기」 — 빈 카드 [새로 만들기] [복사 생성] · 찬 카드 가져오기 · 집계표 「조회 후 선택」 도 탭 + 목록 고르기

39-5 — 가져오기 = ItemPick 창(탭 · 받는 열 자신 뺌) → 덮어쓰기 확인 → 내용만 복사(번호 · 열 그대로) · 같은 곳은 지금 길 · 다른 층 · 사람은 서버 층 넘김 길(39-1b) 뒤 이음(지금은 막음 알림) · 일위대가 카드는 보기 부품(39-3) 뒤.

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-04 17:52:56 +09:00
co-authored by Claude Opus 5.5
parent 91bb01c3f3
commit 6cf72bbf11
6 changed files with 134 additions and 35 deletions
@@ -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<EditKind>();
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<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();
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;
@@ -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<PickerOptions, "onPick" | "user" | "company"> & {
title: string;
ask?: (picked: Picked) => string;
},
): Promise<Picked | null> {
const user = await fetchSessionUser().catch(() => null);
return pickFrom({ ...opt, user, company: viewCompany(entryFromHash(), user) });
}
@@ -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;
@@ -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<void> => {
const field = PART_FIELD[kind];
/** 다른 항목의 도면 · 면적산출(…)을 골라 내용만 복사(39-5) — 탭 = 읽을 수 있는 곳 · 덮어쓰기 확인 ·
* 번호 · 열은 그대로 · 보고 있던 열이면 다시 읽음 · 집계표 줄 「조회 후 선택」 · 카드 「복사해 가져오기」가 씀 */
const pickCopy = async (kind: EditKind, to: StructureColumn | undefined): Promise<void> => {
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<string | null> => {
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;
};
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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