/* ============================================================================= * ui_template_item_picker_detail.ts * 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b · 39-1c) — 카드 = 도면 · 일위대가 · 면적산출 · * 넣을 값. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `M02_Labor_Values`) + 일위대가 카드 * (`ui_template_item_picker_labor`)를 늦게 받아 씀(B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`). * 넣을 값 = 일위대가 담은 로직 입력 전부(복사본이면 복사본 입력값). * * 자료 자리 — **그 구조물의 복사본**(폴더 = 구조물 id · 같은 항목을 고른 구조물도 복사본은 따로)을 읽고, * 그 구조물에 아직 없으면 **원본 층**을 읽음(읽기만). [수정] 은 복사본만 고침 — 아직이면 「이 구조물로 가져와 고침」 * (미리 가져오기 뒤 편집). [재적용] = 원본을 다시 가져옴(덮이는 것을 확인 창으로 알림) · * [개인에 저장] = 이 구조물 복사본을 개인 층 새 항목으로. 마스터 · 회사 · 개인 원본은 이 모달이 안 바꿈 * (개인에 저장 · 새 항목은 개인 층에 **새 항목**을 더할 뿐). 편집 화면은 `ui_template_item_picker_edit.ts`. * ========================================================================== */ import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements"; import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards"; import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource"; import type { InputsValueHandle } from "../M02_MasterTemplete/M02_Labor_Values"; import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model"; import type { LaborCardHandle } from "./ui_template_item_picker_labor"; import { askFields } from "./ui_template_item_picker_ask"; import { folderLines, itemName, layerOf, pathOf, rowTip, type PickerLayer, type PickerRow, } from "./ui_template_item_picker_data"; import { laborOf, laborValueData, PART_OF, projectItems, sameItem, structureFolder, type CreatedItem, type ProjectItemInfo, type ProjectItems, } from "./ui_template_item_picker_project"; /** 편집 화면을 여는 데 필요한 것 — 「넣을 값」 도 한 종류 */ export interface EditRequest { kind: EditKind | "inputs"; row: PickerRow; info: ProjectItemInfo; items: ProjectItems; projectId: string; } export interface ItemDetailOptions { /** 프로젝트 안에서 열렸으면 그 id — 없으면 원본 보기만(고침 없음) */ projectId: string | null; /** 고르는 구조물의 id(`structure_id` · 관 지점 `pipe@측점`) — 아직 추가 전이면 null = 복사본 없음 */ structureId: string | null; layerLabels: Record; hint: string; unit: string; onEdit: (request: EditRequest) => void; /** [개인에 저장] 으로 개인 층에 새 항목이 생김 — 나무를 다시 받고 그 항목을 고름 */ onCreated: (created: CreatedItem) => void; } export interface ItemDetail { root: HTMLElement; show: (row: PickerRow | null) => void; /** 편집을 마치고 돌아옴 — 지금 항목을 다시 읽음 */ reload: () => void; /** 이 구조물 복사본에 고침이 생김 — 목록을 다시 받지 않고 표시만 */ markEdited: () => void; } const TEXT = { importEdit: "이 구조물로 가져와 고침", noStructure: "구조물을 먼저 추가하면 이 항목을 그 구조물로 가져와 고칠 수 있음 — 지금은 원본을 보고 있음", notImported: "이 구조물에는 아직 안 가져옴 — 원본을 보고 있음", otherItem: "이 구조물의 복사본은 「{value}」 — [수정]하면 이 항목으로 바뀜", otherEdited: "이 구조물의 복사본 「{value}」 에서 고친 것이 있음 — 이 항목으로 바꾸면 그 고침은 _지난 폴더로 옮겨짐. 바꿀까요?", imported: "이 구조물 복사본 · 가져옴 {value}", edited: "고침 있음", changed: "마스터가 바뀜 — 복사본은 가져온 때 그대로", gone: "원본이 없어짐 — 복사본은 그대로 씀", reapply: "재적용", reapplyTip: "원본을 다시 가져옴 — 이 구조물 복사본에서 고친 것(도면 · 시트)이 원본 것으로 덮임", reapplyAsk: "원본을 다시 가져옴 — 이 구조물 복사본에서 고친 도면 · 면적산출이 원본 것으로 덮임(넣은 값은 같은 키면 남음). 재적용할까요?", reapplied: "재적용했음 — 덮인 것: {value}", reappliedNone: "재적용했음 — 덮인 것 없음(원본과 같았음)", reapplyFailed: "재적용하지 못함 — {value}", saveAs: "개인에 저장", saveAsTip: "이 구조물 복사본을 내 개인 층의 새 항목으로 저장 — 단가 · 넣은 값은 안 가져감", saveAsTitle: "개인에 저장", saveAsNote: "이 구조물 복사본을 개인 층 새 항목으로 저장함 — 경로는 폴더 이름을 「/」 로 이음", pathLabel: "경로(폴더)", nameLabel: "이름", saved: "개인 층에 저장했음", saveFailed: "개인에 저장하지 못함 — {value}", ok: "확인", cancel: "취소", close: "닫기", empty: "{label} 을(를) 넣을 것", importFailed: "이 구조물로 가져오지 못함 — {value}", listFailed: "프로젝트 항목을 못 읽음 — {value}", calc: "복사본 계산 — 계 {total}원 · 빠진 입력 {gaps}개", calcStuck: " · 멈춘 로직 {stuck}개", noCopy: "복사본에 없음", price: "일위대가", parts: { 도면: "도면", 면적: "면적산출", 일위대가: "일위대가", } as Record, }; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const fill = (text: string, value: string): string => text.replace("{value}", value); const dateOf = (iso?: string | null): string => (iso ? iso.slice(0, 10) : ""); export function createItemDetail(opts: ItemDetailOptions): ItemDetail { const root = el("div", { className: "ui-item-picker__detail" }); const head = el("div", { className: "ui-item-picker__head" }); const status = el("div", { className: "ui-item-picker__status", attrs: { hidden: "" } }); const cardsHost = el("div", { className: "ui-item-picker__cards" }); root.append(head, status, cardsHost); let current: PickerRow | null = null; let seq = 0; let infos: ProjectItemInfo[] | null = null; let cards: ItemCardsHandle | null = null; let inputs: InputsValueHandle | null = null; let labor: LaborCardHandle | null = null; let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() }; /** 이 구조물에 있는 복사본 — 고른 항목의 것일 때만(다른 항목의 것이면 `other`) */ let info: ProjectItemInfo | null = null; let other: ProjectItemInfo | null = null; const items: ProjectItems | null = opts.projectId ? projectItems(opts.projectId) : null; const folder = opts.structureId ? structureFolder(opts.structureId) : null; /** 복사본을 고칠 수 있나 — 프로젝트 안 · 구조물이 있을 때 */ const canCopy = !!items && !!folder; const small = (text: string): HTMLElement => el("p", { className: "ui-item-picker__where", text }); const paintHead = (row: PickerRow): void => { const where = [opts.layerLabels[layerOf(row)], ...pathOf(row).slice(0, -1)] .filter(Boolean) .join(" › "); const pairs = folderLines(row).filter((line) => line.kind === "unitPrice"); head.replaceChildren( el("h4", { className: "ui-item-picker__name", text: itemName(row), attrs: { title: rowTip(row, { layers: opts.layerLabels, unclassified: "" }) }, }), small(where), ...(row.단위 ? [small(`${opts.unit} ${row.단위}`)] : []), ...pairs .filter((line) => line.name || line.key) .map((line) => small(`${TEXT.price} ${line.name || line.key}`)), ); }; /** 이 구조물 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) */ const makeSource = (row: PickerRow): ItemSource => info && opts.projectId ? { layer: "project", projectId: opts.projectId, read: async (part) => { const doc = await items?.read(info!.폴더, PART_OF[part]); if (doc == null) throw new Error(TEXT.noCopy); return { 문서: doc }; }, } : { layer: layerOf(row), projectId: null, read: (part, column) => readTemplate(layerOf(row), part, column, null), }; const badge = (text: string, className = ""): HTMLElement => el("span", { className: `ui-item-picker__badge ${className}`.trim(), text }); const paintStatus = (row: PickerRow, mine: number): void => { if (!items) return void (status.hidden = true); const nodes: (HTMLElement | string)[] = []; if (!folder) nodes.push(TEXT.noStructure); else if (other) nodes.push(fill(TEXT.otherItem, other.이름 ?? other.원본.key)); else if (!info) nodes.push(TEXT.notImported); else { nodes.push(fill(TEXT.imported, dateOf(info.가져온날))); if (info.고침) nodes.push(badge(TEXT.edited)); if (info.원본없음) nodes.push(badge(TEXT.gone, "is-warn")); else if (info.마스터바뀜) nodes.push(badge(TEXT.changed, "is-warn")); const reapply = createButton({ label: TEXT.reapply, variant: "ghost", size: "sm" }); reapply.title = TEXT.reapplyTip; reapply.disabled = !!info.원본없음; reapply.addEventListener("click", () => void reapplyNow(row)); const saveAs = createButton({ label: TEXT.saveAs, variant: "ghost", size: "sm" }); saveAs.title = TEXT.saveAsTip; saveAs.addEventListener("click", () => void saveAsNow(row)); nodes.push(reapply, saveAs); } status.replaceChildren(...nodes); status.hidden = false; if (info) void paintCalc(info, mine); }; /** 복사본만으로 계산한 계 · 빠진 입력을 한 줄로(값을 넣고 돌아오면 달라짐) */ async function paintCalc(from: ProjectItemInfo, mine: number): Promise { const got = await items?.calc(from.폴더).catch(() => null); if (!got || mine !== seq) return; const total = Math.round(got.total).toLocaleString(); status.append( el("span", { className: "ui-item-picker__calc", text: TEXT.calc.replace("{total}", total).replace("{gaps}", String(got.gaps)) + (got.stuck ? TEXT.calcStuck.replace("{stuck}", String(got.stuck)) : ""), }), ); } /** 목록은 모달에서 한 번만 받고 바뀐 줄만 갈아 끼움 */ const upsert = (next: ProjectItemInfo): void => { infos = [...(infos ?? []).filter((one) => one.폴더 !== next.폴더), next]; }; async function reapplyNow(row: PickerRow): Promise { if (!items || !info || !(await showConfirmDialog(TEXT.reapplyAsk, TEXT.reapply))) return; try { const got = await items.reapply(info); upsert(got.info); const names = got.covered.map((part) => TEXT.parts[part] ?? part); showToast( names.length ? fill(TEXT.reapplied, names.join(" · ")) : TEXT.reappliedNone, "success", ); show(row); } catch (error) { showToast(fill(TEXT.reapplyFailed, why(error)), "error"); } } async function saveAsNow(row: PickerRow): Promise { if (!items || !info) return; const got = await askFields({ title: TEXT.saveAsTitle, note: TEXT.saveAsNote, fields: [ { key: "path", label: TEXT.pathLabel, value: pathOf(row).slice(0, -1).join("/") }, { key: "name", label: TEXT.nameLabel, value: itemName(row), required: true }, ], okLabel: TEXT.ok, cancelLabel: TEXT.cancel, closeLabel: TEXT.close, emptyText: TEXT.empty, }); if (!got) return; try { const path = got.path .split("/") .map((part) => part.trim()) .filter(Boolean); const created = await items.saveAs(info.폴더, path, got.name); showToast(TEXT.saved, "success"); opts.onCreated(created); } catch (error) { showToast(fill(TEXT.saveFailed, why(error)), "error"); // 회사 없음 409 등 서버 까닭 그대로 } } /** [수정] — 이 구조물에 아직 없으면(또는 다른 항목이면) 먼저 이 구조물로 가져옴 */ async function edit(kind: EditKind | "inputs"): Promise { const row = current; if (!row || !items || !opts.projectId || !opts.structureId) return; if (!info) { const ask = other?.고침 ? fill(TEXT.otherEdited, other.이름 ?? other.원본.key) : null; if (ask && !(await showConfirmDialog(ask))) return; try { info = await items.importItem(row, opts.structureId); other = null; upsert(info); } catch (error) { showToast(fill(TEXT.importFailed, why(error)), "error"); return; } } opts.onEdit({ kind, row, info, items, projectId: opts.projectId }); } /** 일위대가 카드 · 넣을 값 카드 — 복사본이 있으면 복사본(값도) · 아니면 원본 층(39-1c) */ async function paintLabor(row: PickerRow, mine: number): Promise { if (!inputs || !labor) return; const stops = [labor.loading(), inputs.loading()]; // 카드 안 「불러오는 중」(M02 31-7) try { const copy = info && items ? info.폴더 : null; const summary = await laborOf(row, copy ? items : null, copy); const values = copy ? (((await items?.read(copy, "입력값")) as ValueDoc | null) ?? {}) : {}; const data = summary ? await laborValueData(summary.로직, values) : null; stops.forEach((stop) => stop()); if (mine !== seq) return; labor.set(summary); inputs.set(data); } catch (error) { stops.forEach((stop) => stop()); if (mine !== seq) return; const retry = (): void => void paintLabor(row, seq); labor.set(null, why(error), retry); inputs.set(null, why(error), retry); } } async function ensureCards(): Promise { if (cards && inputs && labor) return; const [cardModule, inputModule, laborModule] = await Promise.all([ import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"), import("../M02_MasterTemplete/M02_Labor_Values"), import("./ui_template_item_picker_labor"), ]); labor = laborModule.createLaborCard(); inputs = inputModule.createInputsValueCard({ mode: "view", onEdit: () => void edit("inputs") }); cards = cardModule.createItemCards({ source: () => source, editable: () => canCopy, onEdit: (kind) => void edit(kind), warnOnFail: () => false, extraCards: [inputs.root], }); // 차례 = 도면 · 일위대가 · 면적산출 — 일위대가 카드를 도면 카드 바로 뒤에 cards.root.insertBefore(labor.root, cards.root.children[1] ?? null); cardsHost.replaceChildren(cards.root); } function show(row: PickerRow | null): void { const mine = ++seq; current = row; if (!row) { head.replaceChildren(el("p", { className: "ui-item-picker__hint", text: opts.hint })); status.hidden = true; cardsHost.replaceChildren(); cards = null; inputs = null; labor = null; return; } paintHead(row); void (async () => { try { if (items && folder) infos ??= await items.list(); } catch (error) { if (mine === seq) showToast(fill(TEXT.listFailed, why(error)), "error"); infos = []; } if (mine !== seq) return; const own = infos?.find((one) => one.폴더 === folder) ?? null; info = own && sameItem(own, row) ? own : null; other = own && !info ? own : null; source = makeSource(row); await ensureCards(); if (mine !== seq || !cards || !inputs || !labor) return; const label = !info && canCopy ? TEXT.importEdit : null; cards.relabel({ structure: label, area: label }); inputs.setEditLabel(label ?? "넣을 값 수정"); cards.root.hidden = false; paintStatus(row, mine); void cards.load({ id: row.열, label: itemName(row) }); void paintLabor(row, mine); })(); } return { root, show, reload: () => show(current), markEdited: () => { if (info) upsert({ ...info, 고침: true }); }, }; }