diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index ac1649609..fd7dc30a3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -299,6 +299,24 @@ export const fetchStructureOptions = (): Promise => call("/structure export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise => call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) }); +/** 항목 하나 복사해 가져오기(39-5 · 39-1b) — 받는 열(`column`)에 덮어씀 · 번호 · 열은 그대로 · + * `source` = 원본 열 id(실무문서면 조합 키) · `from` 없음 = 지금 층 · 층 · 주인 = 다른 층(원본 읽기 권한) · + * `practice` = 실무문서 조합 → 일위대가(시스템 관리자만) */ +export const copyPartFrom = async ( + kind: "structure" | "labor" | "area" | "material", + column: string, + source: string, + from: { layer: Layer | "practice"; owner: string | null } | null, +): Promise => { + const q = new URLSearchParams({ from: source }); + if (from) q.set("from_layer", from.layer); + if (from?.owner) q.set("from_owner", from.owner); + const part = kind === "structure" ? "structures" : kind; + await call(owned(activeLayer, `/layers/${activeLayer}/${part}/${enc(column)}/copy?${q}`), { + method: "POST", + }); +}; + /** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */ export const copyStructure = async (column: string, from: string): Promise => { await scoped(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index d00f83160..b6a2563b3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -35,18 +35,23 @@ import { type DrawingCardHandle, } from "./M02_MasterTemplete_DrawingCard"; import type { ItemPart, ItemSource } from "./M02_MasterTemplete_ItemSource"; +import type { LaborViewHandle } from "./M02_MasterTemplete_Labor_View"; import "./M02_MasterTemplete_Structure.css"; /** 집계표 열 하나에 딸린 문서 셋 — 번호 칸 · 이름 */ export type EditKind = ItemPart; +/** 카드 종류 — 열 문서 셋 + 일위대가(39장 · 보기 갈래 카드 · 고침은 일위대가 상세) */ +export type CardKind = EditKind | "labor"; export type SheetKind = Exclude; -export const PART_FIELD: Record = { +export const PART_FIELD: Record = { structure: "도번", + labor: "일위대가번", area: "면적번", material: "재료번", }; -export const PART_WORD: Record = { +export const PART_WORD: Record = { structure: "도면", + labor: "일위대가", area: "면적산출", material: "재료산출", }; @@ -103,7 +108,7 @@ export interface ItemCardsOptions { /** [도면 수정] · [면적산출 수정] · [재료산출 수정] */ onEdit: (kind: EditKind) => void; /** 문서에 번호가 없을 때 쓸 번호(집계표 번호 줄) */ - numberOf?: (kind: EditKind, column: string) => string | undefined; + numberOf?: (kind: CardKind, column: string) => string | undefined; /** 못 읽었을 때 카드에 까닭을 보일까 — 기본 보임(M02 는 번호가 있는 열만 · 없는 열은 「없음」) */ warnOnFail?: (column: string) => boolean; /** 세 카드 뒤에 덧붙는 카드(선택 입력 …) */ @@ -113,7 +118,10 @@ export interface ItemCardsOptions { /** 면적산출 · 재료산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */ sheetEdit?: SheetEditOptions; /** 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 다른 항목의 이 칸을 골라 덮어씀 · 없으면 단추 없음 */ - onImport?: (kind: EditKind) => void; + onImport?: (kind: CardKind) => void; + /** 일위대가 카드(39-5 · 몸 = 일위대가 보기 갈래 `Labor_View`) — 도면 다음 · 지금 층에서 읽음 · + * [일위대가 수정] · 빈 카드 [새로 만들기] = 이것(일위대가 상세) */ + labor?: () => void; } /** 카드 스프레드시트를 다루는 손잡이 — 알약 · 자재 조합 판이 씀 */ @@ -161,7 +169,7 @@ export interface ItemCardsHandle { /** 카드 하나 — 틀 · [저장] · ? · [수정] · 부제 번호 · 이름 */ interface Card { - kind: EditKind; + kind: CardKind; shell: CardShell; saver: SaveControl; help: HTMLElement; @@ -180,15 +188,15 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { let preview: DrawingCardHandle | null = null; let seq = 0; /** 빈 카드에서 [새로 만들기] 를 누른 칸 — 빈 문서라도 미리보기 편집을 띄움(열이 바뀌면 비움) */ - const started = new Set(); + const started = new Set(); - const numberOf = (kind: EditKind, id: string): string => opts.numberOf?.(kind, id) ?? ""; + const numberOf = (kind: CardKind, id: string): string => opts.numberOf?.(kind, id) ?? ""; const anyDirty = (): boolean => cards.some((c) => c.saver.dirty()); const reload = (): void => void load(column ? { id: column, label } : null, true); /** 카드 틀 하나 — 제목 줄 오른쪽 = ? · [저장] · [〇〇 수정](빈 단추 한 모양) · 부제 = 번호 · 이름 */ const makeCard = ( - kind: EditKind, + kind: CardKind, title: string, body: HTMLElement, save: () => Promise, @@ -207,7 +215,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { variant: "ghost", size: "sm", }); - edit.addEventListener("click", () => opts.onEdit(kind)); + edit.addEventListener("click", () => (kind === "labor" ? opts.labor?.() : opts.onEdit(kind))); const take = createButton({ label: TEXT.take, variant: "ghost", size: "sm" }); take.title = fill(TEXT.takeTip, PART_WORD[kind]); take.addEventListener("click", () => opts.onImport?.(kind)); @@ -290,11 +298,38 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { }); paintPills(readStateRaw("m02-card-pills") === "true"); sheets[0].shell.tools.prepend(pills); - const cards: Card[] = [info, ...sheets]; - const defaults: Record = { + // 일위대가 카드 — 보기만(고침 = [일위대가 수정] 상세) · 부품은 처음 열 때 받음 + const laborHost = el("div", { className: "m02-struct__labor" }); + const laborCard = opts.labor + ? makeCard("labor", PART_WORD.labor, laborHost, () => Promise.resolve()) + : null; + let laborView: LaborViewHandle | null = null; + /** 일위대가 카드 — 그 열 문서 · calc 를 받아 그림 · 빈 일위대가(로직 · 자재 없음)면 두 단추 */ + const paintLabor = async (at: string, mine: number): Promise => { + if (!laborCard) return; + try { + laborView ??= (await import("./M02_MasterTemplete_Labor_View")).createLaborView(); + } catch (error) { + if (mine === seq) laborCard.shell.fail(fill(TEXT.noSheet, why(error)), reload); + return; + } + if (mine !== seq) return; + const view = laborView; + laborHost.replaceChildren(view.root); + await view.show(opts.source().layer, at); + if (mine !== seq) return; + const d = view.doc(); + laborCard.no.textContent = + d?.일위대가번 || numberOf("labor", at) || fill(TEXT.noFieldNumber, PART_FIELD.labor); + if (blank(laborCard, !!d && !d.담은로직.length && !d.자재.length)) + laborHost.replaceChildren(startBox(laborCard, () => opts.labor?.())); + }; + const cards: Card[] = [info, ...(laborCard ? [laborCard] : []), ...sheets]; + const defaults: Record = { structure: TEXT.edit, area: fill(TEXT.editSheet, PART_WORD.area), material: fill(TEXT.editSheet, PART_WORD.material), + labor: fill(TEXT.editSheet, PART_WORD.labor), }; const root = el("div", { @@ -309,7 +344,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { const drawEdit = drawingEditable(opts.editable(), opts.source()); const sheetEdit = !!opts.sheetEdit?.can(); for (const c of cards) { - const can = c.kind === "structure" ? drawEdit : sheetEdit; + const can = c.kind === "structure" ? drawEdit : c.kind !== "labor" && sheetEdit; c.saver.button.hidden = c.help.hidden = !can; c.edit.hidden = !opts.editable(); } @@ -454,6 +489,8 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { doc = null; docVersion = null; previewHost.replaceChildren(); + laborHost.replaceChildren(); + void paintLabor(col.id, mine); for (const c of sheets) { c.doc = null; c.number = ""; @@ -496,7 +533,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { relabel: (labels) => { for (const c of cards) { (c.edit.querySelector(".ui-btn__label") ?? c.edit).textContent = - labels[c.kind] ?? defaults[c.kind]; + (c.kind === "labor" ? null : labels[c.kind]) ?? defaults[c.kind]; } }, column: () => column, @@ -511,6 +548,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { destroy: () => { seq += 1; drop(); + laborView?.destroy(); root.remove(); }, }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 77bf194ff..36da5cb24 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -26,9 +26,7 @@ import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; import { fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api"; import { - copyArea, - copyMaterial, - copyStructure, + copyPartFrom, fetchLaborPills, fetchStructureNumbers, getActiveLayer, @@ -41,7 +39,7 @@ import { createItemCards, PART_FIELD, PART_WORD, - type EditKind, + type CardKind, type SheetCardControl, type SheetEditOptions, type SheetKind, @@ -52,7 +50,7 @@ import { pickHere, pickedName, type From } from "./M02_MasterTemplete_ItemPick"; import { createInputsCard, inputPills, type InputsCardHandle } from "./M02_MasterTemplete_Inputs"; import "./M02_MasterTemplete_Structure.css"; -export type { EditKind, SheetKind } from "./M02_MasterTemplete_ItemCards"; +export type { CardKind, EditKind, SheetKind } from "./M02_MasterTemplete_ItemCards"; /** 구조물 도면이 걸리는 집계표 이름 — 서버 `M02_MasterTemplete_Store.STRUCTURE_TABLE` 과 같은 글 */ export const STRUCTURE_TABLE = "구조물집계표"; @@ -62,27 +60,20 @@ const TEXT = { find: "찾기", clear: "비움", none: "고를 것이 없음", - pickPrice: "일위대가 고르기", combosFailed: "일위대가 목록을 못 받음 — {value}", saveFirst: "새 열은 구조물 조합을 먼저 저장해야 번호가 생김", overwrite: "{from} 의 {what} 내용을 {to} 에 덮어씀 — 번호 · 열은 그대로", pickCopy: "{value} 복사해 가져오기", copied: "복사해 가져옴", - acrossLater: "다른 곳(층 · 사람)에서 가져오기는 서버 길이 선 뒤 — 지금은 같은 곳만", copyFailed: "복사 실패 — {value}", noPills: "이 열 일위대가에 담은 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음", pillsFailed: "일위대가 알약을 못 받음 — {value}", discardCards: "카드(도면 · 면적산출 · 재료산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?", }; -/** 집계표 열 하나에 딸린 문서 셋 — 번호 칸 · 이름 · 복사 */ -const COPY: Record Promise> = { - structure: copyStructure, - area: copyArea, - material: copyMaterial, -}; /** 집계표 줄 열쇠 → 문서 */ -const ROW_KIND: Record = { +const ROW_KIND: Record = { + "s:price": "labor", // 일위대가 = 열마다 문서(39장) — 다른 항목 것을 복사해 옴 "s:drawing": "structure", "s:area": "area", "s:material": "material", @@ -366,7 +357,7 @@ export interface StructurePanelOptions { isAdmin: boolean; /** [도면 수정] · [면적산출 수정] · [재료산출 수정] — 그 열을 CAD · 스프레드시트 전체 편집으로 엶 * (페이지의 `open`) · label 은 화면 제목에 씀 */ - onEdit: (column: string, label: string, kind: EditKind) => void; + onEdit: (column: string, label: string, kind: CardKind) => void; /** 집계표에 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */ confirmLeave: () => Promise; /** 「선택 입력」 카드에서 자리를 골랐음 — 저장 안 한 고침 표시 */ @@ -418,6 +409,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane extraCards: [inputs.root], // 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 집계표 줄 「조회 후 선택」 과 같은 길 onImport: (kind) => void pickCopy(kind, shown ?? undefined), + labor: () => void openEdit("labor"), // 일위대가 카드(보기 갈래 · 39-3) — 도면 다음 // 면적산출 · 재료산출 카드 미리보기 수정(31-3) — 전체 편집기와 같은 알약 · 저장 길 sheetEdit: { can: () => opts.isAdmin, @@ -455,18 +447,14 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane if (col) cards.setLabel((columnLabel = columnHead(col))); // 머리를 고쳤을 수 있음 }; - const pickPrice = async (colId: string, columns: StructureColumn[]): Promise => { - const col = columns.find((c) => c.id === colId); - const got = await pickCombo(col ? `${TEXT.pickPrice} — ${columnHead(col)}` : TEXT.pickPrice); - return got ? got.key : null; - }; - /** 다른 항목의 도면 · 면적산출(…)을 골라 내용만 복사(39-5) — 탭 = 읽을 수 있는 곳 · 덮어쓰기 확인 · * 번호 · 열은 그대로 · 보고 있던 열이면 다시 읽음 · 집계표 줄 「조회 후 선택」 · 카드 「복사해 가져오기」가 씀 */ - const pickCopy = async (kind: EditKind, to: StructureColumn | undefined): Promise => { + const pickCopy = async (kind: CardKind, to: StructureColumn | undefined): Promise => { const word = PART_WORD[kind]; const toNo = to && numbers[to.id]?.[PART_FIELD[kind]]; if (!to || !toNo) return void showToast(TEXT.saveFirst, "error"); + // 카드에 저장 안 한 고침 — 가져온 뒤 카드를 다시 읽어 버려지니 먼저 물음 + if (cards.dirty() && !(await showConfirmDialog(TEXT.discardCards))) 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; @@ -480,10 +468,18 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane .replace("{what}", word) .replace("{to}", target), }); - if (!picked || !("row" in picked)) return; + if (!picked) return; try { - if (!same(picked.from)) throw new Error(TEXT.acrossLater); // 층 넘는 복사 길(39-1b) 뒤에 이음 - await COPY[kind](to.id, picked.row.열); + // 같은 곳 = 다른 열 · 다른 층 · 사람 = 원본 층 · 실무문서 = 조합 키 → 일위대가(39-1b) + if ("combo" in picked) + await copyPartFrom(kind, to.id, picked.combo.키, { layer: "practice", owner: null }); + else + await copyPartFrom( + kind, + to.id, + picked.row.열, + same(picked.from) ? null : { layer: picked.from.layer, owner: picked.from.owner }, + ); showToast(TEXT.copied, "success"); if (column === to.id) void load(shown); } catch (error) { @@ -497,7 +493,6 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane columns: StructureColumn[], ): Promise => { if (!opts.isAdmin || !colId) return null; - if (row === "s:price") return pickPrice(colId, columns); if (ROW_KIND[row]) await pickCopy( ROW_KIND[row], @@ -506,7 +501,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane return null; }; - async function openEdit(kind: EditKind): Promise { + async function openEdit(kind: CardKind): Promise { const at = column; if (!at) return; // 저장 전 새 열(분류 더하기) — 문서 · 번호가 아직 없음 · 떠나면 집계표 고침까지 버리게 됨 diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index efc277f22..bb62f06db 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -139,7 +139,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise openNew()) } : {}), + ...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => void openNew()) } : {}), onColumn: (column) => { readOnlyBar?.setColumn(column); tree.select(column); @@ -155,7 +155,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise { + const openNew = async (): Promise => { + if (!(await main.confirmLeave())) return; // 집계표 · 카드에 저장 안 한 고침 — 버릴지 물음 const where = (): string => tree.folder().join(" / ") || L("M02_TreeTop"); main.showView( L("M02_TreeNewItem"), diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index ce9c04253..778e9787d 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -306,7 +306,7 @@ def test_구조물_조합_제목_줄_새_항목(): ) assert "if (extras.newItem) extras.newItem.hidden = !combo;" in main page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") - assert "...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => openNew()) } : {})" in page + assert "...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => void openNew()) } : {})" in page panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert 'return at?.kind === "folder" ? at.path : [];' in panel # 고른 폴더(39-5 folder()) assert "const newItem = (): void => void addItem(folder());" in panel