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:
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user