From 364d22ff6fac4a9f09e647f54bf618943bcdb9b0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:37:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EC=83=88=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EB=91=90=20=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=20=E2=80=94=20=E3=80=8C=EC=83=88=20=ED=95=AD=EB=AA=A9=E3=80=8D?= =?UTF-8?q?=20=E2=86=92=20=EC=83=81=EC=84=B8=20=EC=B9=B8=20[=EC=83=88?= =?UTF-8?q?=EB=A1=9C=20=EB=A7=8C=EB=93=A4=EA=B8=B0]=20[=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1]=20=C2=B7=20=EB=B3=B5=EC=82=AC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=3D=20=ED=83=AD=20+=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=E2=86=92=20=EC=A7=80=EA=B8=88=20=EC=B8=B5=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B8=20=ED=8F=B4=EB=8D=94=20=C2=B7=20=EC=83=88=20?= =?UTF-8?q?=ED=82=A4=20=C2=B7=20=EC=83=88=20=EB=B2=88=ED=98=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 H4 — 나무 copyIn(붙여넣기 길 · 새 열 하나면 그 열 고름) · folder 손잡이 · ItemPick buildTwoWay(빈 카드도 씀). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemPick.css | 20 +++++++ .../M02_MasterTemplete_ItemPick.ts | 52 ++++++++++++++++++- .../M02_MasterTemplete_TreePanel.ts | 42 +++++++++++++-- .../M02_MasterTemplete_UI_Page.ts | 29 ++++++++++- 4 files changed, 135 insertions(+), 8 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css index 70f57c665..a6713f59b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css @@ -13,3 +13,23 @@ .ui-modal.m02-itempick__dialog { width: min(960px, 94vw); } + +/* 두 단추 화면 — 새 구조물 조합 · 빈 카드 */ +.m02-itempick__two { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; +} + +.m02-itempick__note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-itempick__buttons { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index dc1fbc122..54cc67a51 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -7,7 +7,7 @@ * 다른 사람 = 사람 고르기 하나(`fetchOwners`) · 실무문서 = 마스터 일위대가 조합 줄(일위대가 고를 때만). * ========================================================================== */ -import { createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements"; +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 { fetchOwners, type Layer } from "./M02_MasterTemplete_Api_Fetch"; @@ -30,6 +30,8 @@ const TEXT = { person: "사람", noPeople: "다른 사람이 없음", failed: "목록을 못 받음 — {value}", + makeNew: "새로 만들기", + makeCopy: "복사 생성", }; /** 고른 곳 — `practice` = 실무문서(마스터 일위대가 조합) */ @@ -146,6 +148,54 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } { }; } +export interface TwoWayOptions { + /** 위 안내 한 줄 */ + note: string; + /** [새로 만들기] — 지금 길(창 · 빈 문서) */ + onNew: () => void; + /** [복사 생성] 뒤 고를 목록 */ + picker: Omit; + /** 줄 누름 → 확인 글(취소면 그대로 고름) */ + ask: (picked: Picked) => string; + /** 확인 뒤 — 복사(실패 알림은 부르는 쪽) */ + onCopy: (picked: Picked) => Promise; +} + +/** [새로 만들기] [복사 생성] 두 단추(39-5 · 답 2) — 복사 생성을 누르면 같은 칸 아래 탭 + 목록 */ +export function buildTwoWay(opt: TwoWayOptions): HTMLElement { + const fresh = createButton({ label: TEXT.makeNew, variant: "filled" }); + const copy = createButton({ label: TEXT.makeCopy, variant: "ghost" }); + const below = el("div", { className: "m02-itempick__below" }); + let busy = false; + fresh.addEventListener("click", opt.onNew); + copy.addEventListener("click", () => { + if (below.firstChild) return; + copy.classList.add("is-active"); + const picker = buildPicker({ + ...opt.picker, + onPick: (picked) => + void (async () => { + if (busy || !(await showConfirmDialog(opt.ask(picked)))) return; + busy = true; // 연달아 눌러도 한 번만 + try { + await opt.onCopy(picked); + } finally { + busy = false; + } + })(), + }); + below.append(picker.root); + }); + return el("div", { + className: "m02-itempick__two", + children: [ + el("p", { className: "m02-itempick__note", text: opt.note }), + el("div", { className: "m02-itempick__buttons", children: [fresh, copy] }), + below, + ], + }); +} + /** 창으로 고름 — 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */ export function pickFrom( opt: Omit & { title: string; ask?: (picked: Picked) => string }, diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 014e9c9b1..bb070c057 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -107,6 +107,14 @@ export interface TreePanelHandle { select: (colId: string | null) => void; /** 「새 항목」 — 고른 폴더 안(없으면 맨 위)에 항목 추가 창 */ newItem: () => void; + /** 고른 폴더(하나) — 없으면 맨 위(`[]`) · 새 항목 · 복사 생성이 들어갈 자리 */ + folder: () => string[]; + /** 복사 생성(39-5) — 다른 곳(`from`) 항목 키 하나를 이 층 `dest` 에 새 키로 붙이고 그 열을 고름 */ + copyIn: ( + from: { layer: Layer; owner: string | null }, + key: string, + dest: string[], + ) => Promise; /** 표 머리에서 온 나무 고침(이름 바꾸기 · PLAN 31-1) — 메뉴 고침과 같은 길(저장 물음 · 막힘 안내 · 다시 그림) */ editFromTable: (body: TreeEdit, colId: string | null) => Promise; } @@ -417,8 +425,9 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { // 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로 const gone = note?.지운항목 ?? []; const made = note?.열; - const col = - typeof made === "string" ? made : keepCol && !gone.includes(keepCol) ? keepCol : null; + // 붙여넣기로 새 열이 하나면(복사 생성) 그 열 + const one = typeof made === "string" ? made : made?.length === 1 ? made[0] : null; + const col = one ?? (keepCol && !gone.includes(keepCol) ? keepCol : null); activeCol = col; const parent = note?.경로 ? note.경로.slice(0, -1) : []; parent.forEach((_, i) => open.add(folderIdOf(parent.slice(0, i + 1)))); @@ -643,13 +652,36 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { }); /** 상세 제목 줄 「새 항목」(30-5) — 나무 「항목 추가」와 같은 창 · 고른 폴더(하나)가 있으면 그 안 · 아니면 맨 위 */ - const newItem = (): void => { + const folder = (): string[] => { const at = anchor && picked.has(anchor) ? targets.get(anchor) : undefined; - void addItem(at?.kind === "folder" ? at.path : []); + return at?.kind === "folder" ? at.path : []; + }; + const newItem = (): void => void addItem(folder()); + + const copyIn = async ( + from: { layer: Layer; owner: string | null }, + key: string, + dest: string[], + ): Promise => { + const run = api.paste; + if (!run || !opt.canEdit || !(await ready())) return; + await apply(() => + run(opt.layer, owner, { kind: clipKind, from, refs: [{ 경로: [], 키: key }] }, dest), + ); }; const editFromTable = async (body: TreeEdit, colId: string | null): Promise => { if (opt.canEdit && (await ready())) await edit(body, colId); }; - return { root, search: search.root, add: tools, refresh, select, newItem, editFromTable }; + return { + root, + search: search.root, + add: tools, + refresh, + select, + newItem, + folder, + copyIn, + editFromTable, + }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 9b792a9ce..4f4d2a852 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -43,8 +43,13 @@ import { import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main"; import { buildSide, type AllKind } from "./M02_MasterTemplete_UI_Side"; import { buildAllItems } from "./M02_MasterTemplete_ItemList"; +import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick"; import "./M02_MasterTemplete_UI_Style.css"; +const NEW_NOTE = "새 항목이 들어갈 자리 — {value} · 나무에서 폴더를 고르면 그 안"; +const NEW_ASK = + "{from} 「{name}」 을(를) 복사해 「{to}」 에 새 항목으로 만듦 — 새 번호 · 원래 이름"; + /** 「전체 항목」 화면 제목 앞마디 — 하위 이름 */ const ALL_TITLE = { structure: "M02_KindStructure", @@ -130,8 +135,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise tree.newItem()) } : {}), + // 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 상세 칸에 [새로 만들기] [복사 생성](39-5) + ...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => openNew()) } : {}), onColumn: (column) => { readOnlyBar?.setColumn(column); tree.select(column); @@ -145,6 +150,26 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise { + const where = (): string => tree.folder().join(" / ") || L("M02_TreeTop"); + main.showView( + L("M02_TreeNewItem"), + buildTwoWay({ + note: NEW_NOTE.replace("{value}", where()), + onNew: () => tree.newItem(), + picker: { user, company, kind: "item" }, + ask: (picked) => + NEW_ASK.replace("{name}", pickedName(picked)) + .replace("{from}", picked.from.label) + .replace("{to}", where()), + onCopy: async (picked) => { + if ("row" in picked) await tree.copyIn(picked.from, picked.row.key, tree.folder()); + }, + }), + ); + }; // 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음 const home: Selection = { layer,