diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 77815407..80770b88 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -186,7 +186,7 @@ export function sharedStructures(shared: DesignPageShared): StructuresSection { onPipeSelect: (chainageM) => on().onPipeSelect?.(chainageM), onReveal: () => shared.revealLeft(), }, - { includeDetail: () => shared.view() === "cross" }, + { includeDetail: () => shared.view() === "cross", projectId: shared.projectId }, ); shared.parts.structures = shared.structures; return shared.structures; diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index f025a255..98248d1c 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -53,6 +53,8 @@ export interface StructureOptionField { export interface StructuresSectionOptions { /** 참이면 `phase: "detail"` 옵션(뒷길이·돌규격·형식 …)도 폼에 그린다. */ includeDetail?: boolean | (() => boolean); + /** 집계 항목 고르기 모달이 프로젝트 복사본을 고치려면 필요 — 없으면 원본 보기만(25-2d) */ + projectId?: string | null; } /** B05 배치 폼에 그릴 옵션인가 — 상세(detail)는 B06/B07 몫이라 숨긴다. */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 7d98beeb..d38f48ae 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -497,7 +497,11 @@ export function createStructuresSection( summary.bind({ pick: () => { // canCopy — 개인 층에 쓸 수 없는 사용자의 복사는 서버가 막은 까닭을 모달이 그대로 보임 - void openItemPicker({ current: summary.get(), canCopy: true }).then((result) => { + void openItemPicker({ + current: summary.get(), + canCopy: true, + projectId: sectionOptions.projectId ?? null, + }).then((result) => { if (result.action === "cancel") return; summary.set(result.action === "apply" ? result.item : null); commitSummary(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index 394742f8..820ee158 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -82,6 +82,8 @@ export interface ItemCardsHandle { load: (col: { id: string; label: string } | null) => Promise; /** 이름만 새로(CAD 를 다시 안 띄움) */ setLabel: (label: string) => void; + /** [수정] 단추 글 — 종류마다 · `null` 은 기본 글 */ + relabel: (labels: Partial>) => void; /** 지금 보이는 열 id */ column: () => string | null; destroy: () => void; @@ -142,6 +144,16 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { }; const sheets = [sheetCard("area"), sheetCard("material")]; edit.addEventListener("click", () => opts.onEdit("structure")); + const buttons: Record = { + structure: edit, + area: sheets[0].edit, + material: sheets[1].edit, + }; + const defaults: Record = { + structure: TEXT.edit, + area: fill(TEXT.editSheet, PART_WORD.area), + material: fill(TEXT.editSheet, PART_WORD.material), + }; const root = el("div", { className: "m02-struct ui-sidebar-panel", @@ -267,6 +279,13 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { setLabel: (label) => { columnName.textContent = label; }, + relabel: (labels) => { + for (const kind of Object.keys(buttons) as EditKind[]) { + const button = buttons[kind]; + (button.querySelector(".ui-btn__label") ?? button).textContent = + labels[kind] ?? defaults[kind]; + } + }, column: () => column, destroy: () => { seq += 1; diff --git a/ui_template/ui_template_item_picker.css b/ui_template/ui_template_item_picker.css index fa14a514..508023a2 100644 --- a/ui_template/ui_template_item_picker.css +++ b/ui_template/ui_template_item_picker.css @@ -1,15 +1,16 @@ /* 항목 고르기 모달 — 왼쪽 나무 · 오른쪽 폴더 모양 · 아래 단추 */ .ui-item-picker { - width: min(900px, 94vw); + width: min(1320px, 96vw); + max-height: 94vh; } .ui-item-picker .ui-modal__body { - height: min(60vh, 520px); + height: min(80vh, 800px); } .ui-item-picker__cols { display: grid; - grid-template-columns: minmax(240px, 5fr) minmax(260px, 6fr); + grid-template-columns: minmax(220px, 3fr) minmax(320px, 9fr); gap: var(--spacing-12); flex: 1; min-height: 0; @@ -47,6 +48,115 @@ overflow: auto; } +.ui-item-picker__cols[hidden], +.ui-item-picker__actions[hidden], +.ui-item-picker__edit-host[hidden] { + display: none; +} + +.ui-item-picker__head { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +/* 프로젝트 복사본 상태 줄 — 가져온 날 · 마스터가 바뀜 · [새로 가져오기] */ +.ui-item-picker__status { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + align-items: center; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.ui-item-picker__changed { + color: var(--color-warning); +} + +.ui-item-picker__fake { + padding: 0 var(--spacing-8); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); +} + +/* 카드 넷 — M02 와 같은 카드 · 칸이 좁으면 한 줄씩(카드 하나가 300px 안 되면) */ +.ui-item-picker__cards { + display: flex; + min-width: 0; +} + +.ui-item-picker__cards .m02-struct { + flex: 1 1 0; + padding-top: 0; + border-top: 0; + min-width: 0; +} + +/* 편집 화면 — 뒤로 · 제목 · 저장 한 줄 + 편집 부품이 남는 높이를 채움 */ +.ui-item-picker__edit-host { + display: flex; + flex: 1; + min-height: 0; +} + +.ui-item-picker__edit { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + flex: 1; + min-width: 0; + min-height: 0; +} + +.ui-item-picker__edit-top { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + align-items: center; +} + +.ui-item-picker__edit-title { + flex: 1 1 12rem; + margin: 0; + color: var(--color-text-heading); + font-size: var(--text-body); + overflow-wrap: anywhere; +} + +.ui-item-picker__edit-note { + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.ui-item-picker__edit-body { + display: flex; + flex: 1; + min-height: 0; + min-width: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.ui-item-picker__edit-sheet, +.ui-item-picker__edit-cad { + flex: 1; + min-width: 0; + min-height: 0; +} + +.ui-item-picker__edit-inputs { + flex: 1; + min-width: 0; + padding: var(--spacing-8); + overflow: auto; +} + +.ui-item-picker__edit-inputs .m02-struct__card { + border: 0; +} + .ui-item-picker__name { margin: 0; font-size: var(--text-body); @@ -104,3 +214,12 @@ justify-content: flex-end; gap: var(--spacing-8); } + +/* 좁은 폭 — 나무는 위(낮게) · 상세는 아래로 쌓고 카드는 한 줄씩 */ +@media (max-width: 760px) { + .ui-item-picker__cols { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 2fr) minmax(0, 3fr); + overflow: auto; + } +} diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts index aef2bf73..5baec0eb 100644 --- a/ui_template/ui_template_item_picker.ts +++ b/ui_template/ui_template_item_picker.ts @@ -13,17 +13,12 @@ import { createButton, createInputField, el, showToast } from "./ui_template_ele import { buildTree, filterRows, - folderLines, isCurrent, - itemName, layerOf, ownerOf, pathIds, rowId, - rowTip, - shown, toSummary, - type FolderKind, type PickNode, type PickerLayer, type PickerRow, @@ -33,6 +28,8 @@ import { import { t } from "./ui_template_locale"; import { openModal } from "./ui_template_modal"; import { createTreeRow, renderTree, type TreeNode } from "./ui_template_tree"; +import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail"; +import { createEditView, type EditView } from "./ui_template_item_picker_edit"; export type { SummaryItem } from "./ui_template_item_picker_data"; @@ -41,6 +38,8 @@ export interface ItemPickerOptions { current: SummaryItem | null; /** [개인용으로 복사]를 쓸 수 있나 — 개인 층에 쓸 수 있을 때(회사 등록 뒤) */ canCopy: boolean; + /** 프로젝트 안에서 부르면 그 id — 카드 [수정] 이 프로젝트 복사본을 고침(없으면 원본 보기만 · 25-2d) */ + projectId?: string | null; } export type ItemPickerResult = @@ -48,14 +47,6 @@ export type ItemPickerResult = const MANAGE_HASH = "#/m02-master-template?layer=personal"; -const FOLDER_LABEL: Record string> = { - drawing: () => t("M02_PickerDrawing"), - area: () => t("M02_PickerArea"), - material: () => t("M02_PickerMaterial"), - combo: () => t("M02_PickerCombo"), - unitPrice: () => t("M02_PickerUnitPrice"), -}; - const labels = (): TreeLabels => ({ layers: { system: t("M02_PickerSystem"), @@ -110,7 +101,22 @@ export function openItemPicker(opts: ItemPickerOptions): Promise openEdit(request), + }); + const cols = el("div", { + className: "ui-item-picker__cols", + children: [ + el("div", { className: "ui-item-picker__left", children: [search.root, treeHost] }), + detailView.root, + ], + }); + const editHost = el("div", { className: "ui-item-picker__edit-host", attrs: { hidden: "" } }); + let editView: EditView | null = null; const applyButton = createButton({ label: t("M02_PickerApply"), variant: "filled" }); const clearButton = createButton({ label: t("M02_PickerClear"), variant: "ghost" }); const copyButton = createButton({ label: t("M02_PickerCopy"), variant: "ghost" }); @@ -126,47 +132,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { - if (!chosen) { - detail.replaceChildren(hint(t("M02_PickerHint"))); - } else { - const where = [labels().layers[layerOf(chosen)], shown(chosen.종류), shown(chosen.분류1)] - .filter(Boolean) - .join(" › "); - const small = (text: string): HTMLElement => - el("p", { className: "ui-item-picker__where", text }); - detail.replaceChildren( - el("h4", { - className: "ui-item-picker__name", - text: itemName(chosen), - attrs: { title: rowTip(chosen, labels()) }, - }), - small(where), - ...(chosen.단위 ? [small(`${t("M02_PickerUnit")} ${chosen.단위}`)] : []), - el("ul", { - className: "ui-item-picker__folder", - children: folderLines(chosen).map((line) => - el("li", { - attrs: line.name && line.key ? { title: line.key } : {}, - children: [ - el("span", { - className: "ui-item-picker__folder-label", - text: FOLDER_LABEL[line.kind](), - }), - // 이름이 있으면 이름(키는 툴팁) · 없으면 키를 작은 글로 · 둘 다 없으면 「없음」 - el("span", { - className: line.name - ? "" - : line.key - ? "ui-item-picker__key" - : "ui-item-picker__none", - text: line.name || line.key || t("M02_PickerNone"), - }), - ], - }), - ), - }), - ); - } + detailView.show(chosen); const layer = chosen ? layerOf(chosen) : null; applyButton.disabled = !chosen; clearButton.disabled = !opts.current; @@ -268,29 +234,44 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { + // 닫기 단추 · Esc · 바깥 누르기 = 취소 — 편집 중 저장 안 한 고침이 있으면 물음 + beforeClose: async () => { + if (editView && !(await editView.leave())) return false; + editView?.destroy(); resolve({ action: "cancel" }); return true; }, mount: (body) => { - body.append( - el("div", { - className: "ui-item-picker__cols", - children: [ - el("div", { className: "ui-item-picker__left", children: [search.root, treeHost] }), - detail, - ], - }), - el("div", { - className: "ui-item-picker__actions", - children: [copyButton, manageButton, clearButton, applyButton], - }), - ); + body.append(cols, editHost, actions); paintDetail(); void load(null); }, diff --git a/ui_template/ui_template_item_picker_detail.ts b/ui_template/ui_template_item_picker_detail.ts new file mode 100644 index 00000000..f7f1099e --- /dev/null +++ b/ui_template/ui_template_item_picker_detail.ts @@ -0,0 +1,264 @@ +/* ============================================================================= + * ui_template_item_picker_detail.ts + * 항목 고르기 모달 오른쪽 상세(계획서 25-2d) — 글 목록이던 것을 **카드 넷**(도면 · 면적산출 · 재료산출 · + * 선택 입력)으로. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_InputsCard`)을 늦게 받아 씀 + * (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`). + * + * 자료 자리 — 프로젝트에 가져온 항목은 **프로젝트 복사본**을 읽고, 아직이면 **원본 층**을 읽음(읽기만). + * [수정] 은 복사본만 고침 — 안 가져온 항목은 「프로젝트로 가져와 고침」(미리 가져오기 뒤 편집). + * 마스터 · 회사 · 개인 원본은 이 모달이 안 바꿈. 편집 화면은 `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 { + InputsCardHandle, + LinkDoc, + ValueDoc, +} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard"; +import { + folderLines, + itemName, + layerOf, + rowTip, + shown, + type PickerLayer, + type PickerRow, +} from "./ui_template_item_picker_data"; +import { + fakeLink, + folderOf, + loadInputs, + PART_OF, + projectItems, + 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; + layerLabels: Record; + hint: string; + unit: string; + onEdit: (request: EditRequest) => void; +} + +export interface ItemDetail { + root: HTMLElement; + show: (row: PickerRow | null) => void; + /** 편집을 마치고 돌아옴 — 지금 항목을 다시 읽음 */ + reload: () => void; +} + +const TEXT = { + importEdit: "프로젝트로 가져와 고침", + notImported: "프로젝트에 아직 안 가져옴 — 원본을 보고 있음", + imported: "프로젝트에 가져옴 {value}", + changed: "마스터가 바뀜 — 프로젝트 복사본은 가져온 때 그대로", + refresh: "새로 가져오기", + refreshAsk: "프로젝트 복사본의 고친 내용이 원본으로 덮임 — 새로 가져올까요?", + importFailed: "프로젝트로 가져오지 못함 — {value}", + refreshFailed: "새로 가져오지 못함 — {value}", + listFailed: "프로젝트 항목을 못 읽음 — {value}", + fake: "임시 저장(서버 길 준비 중)", + loading: "읽는 중…", + noCopy: "복사본에 없음", + combo: "자재조합", + price: "일위대가", +}; + +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): 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: InputsCardHandle | null = null; + let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() }; + let info: ProjectItemInfo | null = null; + const items: ProjectItems | null = opts.projectId ? projectItems(opts.projectId) : null; + + const small = (text: string): HTMLElement => + el("p", { className: "ui-item-picker__where", text }); + + const paintHead = (row: PickerRow): void => { + const where = [opts.layerLabels[layerOf(row)], shown(row.종류), shown(row.분류1)] + .filter(Boolean) + .join(" › "); + const pairs = folderLines(row).filter( + (line) => line.kind === "combo" || 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(`${line.kind === "combo" ? TEXT.combo : 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 paintStatus = async (row: PickerRow, mine: number): Promise => { + if (!items) return void (status.hidden = true); + const nodes: (HTMLElement | string)[] = []; + if (!info) nodes.push(TEXT.notImported); + else { + nodes.push(fill(TEXT.imported, dateOf(info.가져온날))); + if (items.isFake()) + nodes.push(el("span", { className: "ui-item-picker__fake", text: TEXT.fake })); + const changed = await items.masterChanged(row, info).catch(() => false); + if (mine !== seq) return; + if (changed) { + const button = createButton({ label: TEXT.refresh, variant: "ghost", size: "sm" }); + button.addEventListener("click", () => void refresh(row)); + nodes.push( + el("strong", { className: "ui-item-picker__changed", text: TEXT.changed }), + button, + ); + } + } + status.replaceChildren(...nodes); + status.hidden = false; + }; + + async function refresh(row: PickerRow): Promise { + if (!items || !info || !(await showConfirmDialog(TEXT.refreshAsk))) return; + try { + await items.refresh(row, info); + infos = null; + show(row); + } catch (error) { + showToast(fill(TEXT.refreshFailed, why(error)), "error"); + } + } + + /** [수정] — 안 가져온 항목이면 먼저 프로젝트로 가져옴 */ + async function edit(kind: EditKind | "inputs"): Promise { + const row = current; + if (!row || !items || !opts.projectId) return; + if (!info) { + try { + info = await items.importItem(row); + infos = null; + } catch (error) { + showToast(fill(TEXT.importFailed, why(error)), "error"); + return; + } + } + opts.onEdit({ kind, row, info, items, projectId: opts.projectId }); + } + + async function paintInputs(row: PickerRow, mine: number): Promise { + if (!inputs) return; + try { + const { list, fake } = await loadInputs(row); + let link: LinkDoc | null = fake ? fakeLink(list) : null; + let values: ValueDoc = {}; + if (info && items) { + link = ((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ?? link; + values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {}; + } + if (mine === seq) inputs.set({ list, link, values }); + } catch (error) { + if (mine === seq) inputs.set(null, why(error)); + } + } + + async function ensureCards(): Promise { + if (cards && inputs) return; + const [cardModule, inputModule] = await Promise.all([ + import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"), + import("../M02_MasterTemplete/M02_MasterTemplete_InputsCard"), + ]); + inputs = inputModule.createInputsCard({ mode: "view", onEdit: () => void edit("inputs") }); + cards = cardModule.createItemCards({ + source: () => source, + editable: () => !!items, + onEdit: (kind) => void edit(kind), + warnOnFail: () => false, + extraCards: [inputs.root], + }); + 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; + return; + } + paintHead(row); + void (async () => { + try { + if (items) infos ??= await items.list(); + } catch (error) { + if (mine === seq) showToast(fill(TEXT.listFailed, why(error)), "error"); + infos = []; + } + if (mine !== seq) return; + info = infos?.find((one) => one.폴더 === folderOf(row)) ?? null; + source = makeSource(row); + await ensureCards(); + if (mine !== seq || !cards || !inputs) return; + const label = info ? null : TEXT.importEdit; + cards.relabel({ structure: label, area: label, material: label }); + inputs.setEditLabel(label ?? "선택 입력 수정"); + cards.root.hidden = false; + void paintStatus(row, mine); + void cards.load({ id: row.열, label: itemName(row) }); + void paintInputs(row, mine); + })(); + } + + return { root, show, reload: () => show(current) }; +} diff --git a/ui_template/ui_template_item_picker_edit.ts b/ui_template/ui_template_item_picker_edit.ts new file mode 100644 index 00000000..e9a89b1e --- /dev/null +++ b/ui_template/ui_template_item_picker_edit.ts @@ -0,0 +1,229 @@ +/* ============================================================================= + * ui_template_item_picker_edit.ts + * 항목 고르기 모달의 **편집 화면**(계획서 25-2d) — 카드 [수정] 이 모달 안에서 이 화면으로 바뀜. + * 도면 = M02 CAD 부품 · 면적산출 · 재료산출 = M02 스프레드시트 · 선택 입력 = 값 받기 카드. + * 고침은 **프로젝트 복사본**에만 씀(`ProjectItems.write`) — 마스터 · 회사 · 개인 원본은 안 바뀜. + * [뒤로] = 선택지(나무 + 카드)로 · 저장 안 한 고침이 있으면 버릴지 물음. + * 편집 부품은 이 화면을 열 때만 `import()`(B05 · B06 번들에 안 섞임). + * ========================================================================== */ + +import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements"; +import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; +import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; +import type { DrawingTemplateHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Drawing"; +import type { + InputsCardHandle, + LinkDoc, + ValueDoc, +} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard"; +import type { StructureDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import type { EditRequest } from "./ui_template_item_picker_detail"; +import { itemName } from "./ui_template_item_picker_data"; +import { fakeLink, loadInputs, PART_OF } from "./ui_template_item_picker_project"; + +export interface EditView { + root: HTMLElement; + /** 붙인 뒤 부름 — 편집 부품을 받아 세움 */ + start: () => void; + isDirty: () => boolean; + /** 저장 안 한 고침을 버릴지 묻고, 괜찮으면 부품을 치움 — false 면 그대로 */ + leave: () => Promise; + destroy: () => void; +} + +const TEXT = { + back: "← 뒤로", + save: "저장", + saved: "프로젝트 복사본에 저장했음", + saveFailed: "저장하지 못함 — {value}", + loadFailed: "복사본을 못 읽음 — {value}", + discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?", + outOfRange: "범위 밖 값이 있어 저장하지 못함", + noAreaPill: "면적산출 알약 없음 — 선택 입력에서 자리를 면적산출로 이으면 여기 뜸", + drawing: "도면", + area: "면적산출", + material: "재료산출", + inputs: "선택 입력", + suffix: "{value} 수정", +}; + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const fill = (text: string, value: string): string => text.replace("{value}", value); + +export function createEditView(req: EditRequest, onBack: () => void): EditView { + const { kind, row, info, items } = req; + let dirty = false; + let saving = false; + let doc: unknown = null; + let drawing: DrawingTemplateHandle | null = null; + let sheet: SpreadsheetHandle | null = null; + let card: InputsCardHandle | null = null; + + const word = { + structure: TEXT.drawing, + area: TEXT.area, + material: TEXT.material, + inputs: TEXT.inputs, + }[kind]; + const back = createButton({ label: TEXT.back, variant: "ghost", size: "sm" }); + const save = createButton({ label: TEXT.save, variant: "filled", size: "sm" }); + save.disabled = true; + const note = el("span", { className: "ui-item-picker__edit-note" }); + const body = el("div", { className: "ui-item-picker__edit-body" }); + const root = el("div", { + className: "ui-item-picker__edit", + children: [ + el("div", { + className: "ui-item-picker__edit-top", + children: [ + back, + el("h4", { + className: "ui-item-picker__edit-title", + text: `${itemName(row)} — ${fill(TEXT.suffix, word)}`, + }), + note, + save, + ], + }), + body, + ], + }); + + const setDirty = (next: boolean): void => { + dirty = next; + save.disabled = !next || saving; + }; + const fail = (text: string): void => { + body.replaceChildren(el("p", { className: "ui-item-picker__hint", text })); + }; + + /** 면적산출 알약 — 항목 연결에서 자리가 「면적」 인 입력(25-1b) */ + async function areaLinks(): Promise<{ links: LinkOptions; notice?: string }> { + const { list, fake } = await loadInputs(row); + const link = + ((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ?? + (fake ? fakeLink(list) : null); + const 항목 = (link?.줄 ?? []) + .filter((one) => one.자리 === "면적") + .map((one) => ({ + 키: one.알약 ?? `입력|${one.로직}|${one.입력}`, + 이름: one.입력, + 단위: + list.로직.find((logic) => logic.로직 === one.로직)?.입력.find((x) => x.이름 === one.입력) + ?.단위 ?? "", + })); + return 항목.length + ? { links: { 항목, 모드: "입력" } } + : { links: { 항목: [], 모드: "입력" }, notice: TEXT.noAreaPill }; + } + + async function mountSheet(kindOf: "area" | "material"): Promise { + const [{ createSpreadsheet }, pills, { searchMaterials }] = await Promise.all([ + import("../A00_Common/spreadsheet/spreadsheet"), + kindOf === "area" + ? areaLinks() + : Promise.resolve({ links: undefined as LinkOptions | undefined, notice: undefined }), + import("../M01_MasterData/M01_MasterData_Api_Fetch"), + ]); + const links: LinkOptions = pills.links ?? { 항목: [], 모드: "자재", 자재찾기: searchMaterials }; + if (pills.notice) note.textContent = pills.notice; + const host = el("div", { className: "ui-item-picker__edit-sheet" }); + body.replaceChildren(host); + sheet = createSpreadsheet(host, doc as Workbook, { + readOnly: false, + tabs: false, + onChange: () => setDirty(true), + links, + }); + } + + async function mountDrawing(): Promise { + const { mountDrawingTemplate } = + await import("../M02_MasterTemplete/M02_MasterTemplete_Drawing"); + const host = el("div", { className: "ui-item-picker__edit-cad" }); + body.replaceChildren(host); + drawing = mountDrawingTemplate(host, (doc as StructureDoc).도면 as never, { + onSave: () => void doSave(), + readOnly: false, + name: `item:${info.폴더}`, + layer: "project", + projectId: req.projectId, + onChanged: (changed) => setDirty(changed), + originMarker: true, + noArea: true, + }); + } + + async function mountInputs(): Promise { + const [{ createInputsCard }, loaded] = await Promise.all([ + import("../M02_MasterTemplete/M02_MasterTemplete_InputsCard"), + loadInputs(row), + ]); + const link = + ((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ?? + (loaded.fake ? fakeLink(loaded.list) : null); + const values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {}; + card = createInputsCard({ mode: "value", onChange: () => setDirty(true) }); + card.set({ list: loaded.list, link, values }); + body.replaceChildren( + el("div", { className: "ui-item-picker__edit-inputs", children: [card.root] }), + ); + } + + async function start(): Promise { + try { + if (kind !== "inputs") { + doc = await items.read(info.폴더, PART_OF[kind]); + if (doc == null) return fail(fill(TEXT.loadFailed, "복사본에 없음")); + } + if (kind === "structure") await mountDrawing(); + else if (kind === "inputs") await mountInputs(); + else await mountSheet(kind); + } catch (error) { + fail(fill(TEXT.loadFailed, why(error))); + } + } + + async function doSave(): Promise { + if (saving) return; + saving = true; + save.disabled = true; + try { + if (kind === "inputs") { + if (!card?.isValid()) return void showToast(TEXT.outOfRange, "error"); + await items.write(info.폴더, "입력값", card.getValues()); + } else if (kind === "structure") { + const edited = await drawing?.getDoc(); + await items.write(info.폴더, "도면", { ...(doc as StructureDoc), 도면: edited }); + } else if (sheet) { + // 번호 칸은 스프레드시트가 모름 — 읽은 문서 위에 편집본을 얹음(M02 저장과 같은 규칙) + const merged = { ...(doc as Record), ...sheet.getDoc() }; + await items.write(info.폴더, PART_OF[kind], merged); + doc = merged; + } + setDirty(false); + showToast(TEXT.saved, "success"); + } catch (error) { + showToast(fill(TEXT.saveFailed, why(error)), "error"); + } finally { + saving = false; + save.disabled = !dirty; + } + } + + const destroy = (): void => { + drawing?.destroy(); + sheet?.destroy(); + drawing = null; + sheet = null; + root.remove(); + }; + const leave = async (): Promise => !dirty || showConfirmDialog(TEXT.discard); + + save.addEventListener("click", () => void doSave()); + back.addEventListener("click", async () => { + if (await leave()) onBack(); + }); + + return { root, start: () => void start(), isDirty: () => dirty, leave, destroy }; +} diff --git a/ui_template/ui_template_item_picker_project.ts b/ui_template/ui_template_item_picker_project.ts new file mode 100644 index 00000000..4ad1da45 --- /dev/null +++ b/ui_template/ui_template_item_picker_project.ts @@ -0,0 +1,284 @@ +/* ============================================================================= + * ui_template_item_picker_project.ts + * 항목 고르기 모달의 **자료 창구**(계획서 25-2d) — 프로젝트 복사본 · 원본 층 · 입력 목록을 한 곳에서 부름. + * + * 프로젝트 복사본 GET /api/projects/{id}/items 목록 + * GET|PUT /api/projects/{id}/items/{폴더}/{부분} 읽기 · 고침(sub6 · 25-1b 약속) + * POST /api/projects/{id}/items 미리 가져오기(body = 원본 참조) + * POST /api/projects/{id}/items/{폴더}/refresh 새로 가져오기 + * 입력 목록 GET /api/m02/layers/{층}/items/{키}/inputs?owner= (sub4 · 25-2a) + * + * ⚠ 이음 자리 — 위 길이 서버에 아직 없으면(`{"detail":"Not Found"}`) **약속 모양의 가짜 응답**으로 동작함: + * 복사본 = 브라우저 저장소(`aislo.itemsFake.{프로젝트}`)에 원본을 복사해 두고 거기서 읽고 고침 · + * 입력 목록 = M01 조합 · 로직 길로 짜 맞춤 · 항목 연결 = 「모든 입력이 선택」(가짜 기본값). + * 실서버가 오면 같은 함수가 실서버 응답을 그대로 씀(가짜 분기는 길이 있으면 안 탐) — `FAKE` 로 찾음. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { readTemplate, type Kind } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import type { + InputsList, + LinkDoc, + LogicInput, +} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard"; +import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data"; + +/** 복사본 폴더 안 부분 — 25-1b 약속 그대로 */ +export type ProjectPart = + "도면" | "면적" | "재료" | "자재조합" | "입력연결" | "입력값" | "고른자재"; + +/** 원본 종류 → 복사본 부분 */ +export const PART_OF: Record<"structure" | "area" | "material", ProjectPart> = { + structure: "도면", + area: "면적", + material: "재료", +}; + +export interface ProjectItemInfo { + 폴더: string; + 원본: { layer: string; owner: string | null; key: string; name?: string }; + 원본판?: string; + 가져온날?: string; + /** 원본(마스터 · 회사 · 개인)이 가져온 뒤 바뀜 */ + 마스터바뀜?: boolean; +} + +/** `{층}_{주인 슬러그}_{키}` — 서버 `snapshot_folder_name` 과 같은 글(owner 없음 = none · `/` = `-`) */ +export const folderOf = (row: PickerRow): string => + `${layerOf(row)}_${(ownerOf(row) ?? "none").replace("/", "-")}_${row.key}`; + +interface Reply { + status: number; + body: unknown; +} + +const enc = encodeURIComponent; +const reasonOf = (reply: Reply): string => { + const detail = (reply.body as { detail?: unknown } | null)?.detail; + return typeof detail === "string" && detail ? detail : `HTTP ${reply.status}`; +}; + +async function call(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + ...init, + headers: init.body ? { "Content-Type": "application/json" } : undefined, + }); + return { status: response.status, body: await response.json().catch(() => null) }; +} + +/** 길 자체가 없음(서버가 아직 안 만듦) — 「문서가 없음」의 404 와 갈라 봄 */ +const routeMissing = (reply: Reply): boolean => + reply.status === 404 && (reply.body as { detail?: unknown } | null)?.detail === "Not Found"; + +const send = (body: unknown): RequestInit => ({ method: "POST", body: JSON.stringify(body) }); + +/* ── 가짜 복사본(FAKE) — 브라우저 저장소 ─────────────────────────────────────── */ +interface FakeItem { + info: ProjectItemInfo; + parts: Partial>; +} +type FakeDb = Record; +const memory = new Map(); +const storeKey = (projectId: string): string => `aislo.itemsFake.${projectId}`; + +function fakeLoad(projectId: string): FakeDb { + const kept = memory.get(projectId); + if (kept) return kept; + try { + const raw = localStorage.getItem(storeKey(projectId)); + const db = raw ? (JSON.parse(raw) as FakeDb) : {}; + memory.set(projectId, db); + return db; + } catch { + const db: FakeDb = {}; + memory.set(projectId, db); + return db; + } +} + +function fakeSave(projectId: string): void { + try { + localStorage.setItem(storeKey(projectId), JSON.stringify(fakeLoad(projectId))); + } catch { + // 저장소가 꽉 차도 이 탭에서는 메모리 값으로 계속 됨 + } +} + +/** 원본 층에서 도면 · 면적 · 재료 문서를 읽고 판을 이은 글 — 복사본 `원본판` */ +async function readOriginals( + row: PickerRow, +): Promise<{ parts: FakeItem["parts"]; version: string }> { + const kinds: ("structure" | "area" | "material")[] = ["structure", "area", "material"]; + const got = await Promise.all( + kinds.map((kind) => readTemplate(layerOf(row), kind as Kind, row.열, null).catch(() => null)), + ); + const parts: FakeItem["parts"] = {}; + kinds.forEach((kind, index) => { + if (got[index]) parts[PART_OF[kind]] = structuredClone(got[index]!.문서); + }); + return { parts, version: got.map((one) => one?.판 ?? "").join("|") }; +} + +export interface ProjectItems { + /** 프로젝트에 가져온 항목 목록 */ + list: () => Promise; + /** 복사본 부분 하나 — 없으면 null */ + read: (folder: string, part: ProjectPart) => Promise; + /** 복사본 고침 — 못 하면 서버 까닭 그대로 던짐(권한 · 판 …) */ + write: (folder: string, part: ProjectPart, doc: unknown) => Promise; + /** 프로젝트로 미리 가져옴 */ + importItem: (row: PickerRow) => Promise; + /** 새로 가져옴 — 복사본을 원본으로 덮음 */ + refresh: (row: PickerRow, info: ProjectItemInfo) => Promise; + /** 가져온 뒤 원본이 바뀌었나 */ + masterChanged: (row: PickerRow, info: ProjectItemInfo) => Promise; + /** 가짜로 동작 중인가(서버 길이 아직 없음) */ + isFake: () => boolean; +} + +const clients = new Map(); + +/** 프로젝트 하나의 창구 — 같은 프로젝트는 한 벌(가짜 판정 · 목록 재사용) */ +export function projectItems(projectId: string): ProjectItems { + const kept = clients.get(projectId); + if (kept) return kept; + let fake = false; + const base = `/projects/${enc(projectId)}/items`; + + const client: ProjectItems = { + isFake: () => fake, + list: async () => { + if (!fake) { + const reply = await call(base); + if (reply.status === 200 && Array.isArray(reply.body)) + return reply.body as ProjectItemInfo[]; + if (!routeMissing(reply)) throw new Error(reasonOf(reply)); + fake = true; // FAKE — 서버 길이 아직 없음 + } + return Object.values(fakeLoad(projectId)).map((item) => item.info); + }, + read: async (folder, part) => { + if (!fake) { + const reply = await call(`${base}/${enc(folder)}/${enc(part)}`); + if (reply.status === 200) return reply.body; + if (reply.status === 404) return null; + throw new Error(reasonOf(reply)); + } + return fakeLoad(projectId)[folder]?.parts[part] ?? null; + }, + write: async (folder, part, doc) => { + if (!fake) { + const reply = await call(`${base}/${enc(folder)}/${enc(part)}`, { + method: "PUT", + body: JSON.stringify(doc), + }); + if (reply.status >= 400) throw new Error(reasonOf(reply)); + return; + } + const item = fakeLoad(projectId)[folder]; + if (!item) throw new Error("프로젝트에 가져오지 않은 항목"); + item.parts[part] = structuredClone(doc); + fakeSave(projectId); + }, + importItem: async (row) => { + const ref = { layer: layerOf(row), owner: ownerOf(row), key: row.key }; + if (!fake) { + const reply = await call(base, send(ref)); + if (reply.status < 300) return reply.body as ProjectItemInfo; + if (!routeMissing(reply)) throw new Error(reasonOf(reply)); + fake = true; // FAKE + } + const { parts, version } = await readOriginals(row); + const info: ProjectItemInfo = { + 폴더: folderOf(row), + 원본: { ...ref, name: row.종류 }, + 원본판: version, + 가져온날: new Date().toISOString(), + 마스터바뀜: false, + }; + fakeLoad(projectId)[info.폴더] = { info, parts }; + fakeSave(projectId); + return info; + }, + refresh: async (row, info) => { + if (!fake) { + const reply = await call(`${base}/${enc(info.폴더)}/refresh`, send({})); + if (reply.status >= 400) throw new Error(reasonOf(reply)); + return reply.body as ProjectItemInfo; + } + delete fakeLoad(projectId)[info.폴더]; + return client.importItem(row); + }, + masterChanged: async (row, info) => { + if (!fake) return info.마스터바뀜 === true; + return (await readOriginals(row)).version !== (info.원본판 ?? ""); + }, + }; + clients.set(projectId, client); + return client; +} + +/* ── 입력 목록(원본 층 기준) — 25-1b 약속 길 · 없으면 FAKE ─────────────────────── */ +const inputsCache = new Map>(); + +async function fakeInputs(row: PickerRow): Promise { + const combo = row.일위대가 ?? ""; + const got = await call(`/m01/combo?key=${enc(combo)}`); + const header = (got.body as { combo?: { 이름?: string; 담은로직?: { 로직: string }[] } } | null) + ?.combo; + if (got.status !== 200 || !header) throw new Error(reasonOf(got)); + const list: InputsList = { 일위대가: combo, 이름: header.이름 ?? combo, 로직: [], 빠진로직: [] }; + for (const one of header.담은로직 ?? []) { + const [logic, auto] = await Promise.all([ + call(`/m01/logic?key=${enc(one.로직)}`), + call(`/m01/logic/auto?key=${enc(one.로직)}`), + ]); + const row = ( + logic.body as { + logic?: { 이름?: string; 결과단위?: string; 입력?: (LogicInput & { 비고?: string })[] }; + } | null + )?.logic; + if (logic.status !== 200 || !row) { + list.빠진로직.push(one.로직); + continue; + } + const values = (auto.body as { 값?: Record } | null)?.값 ?? {}; + list.로직.push({ + 로직: one.로직, + 이름: row.이름 ?? one.로직, + 결과단위: row.결과단위 ?? null, + 입력: (row.입력 ?? []).map((input) => ({ ...input, 자동값: values[input.이름] ?? null })), + }); + } + return list; +} + +/** 원본 항목의 입력 목록 — 25-2a 길 → 없으면 가짜 */ +export function loadInputs(row: PickerRow): Promise<{ list: InputsList; fake: boolean }> { + const id = `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`; + const kept = inputsCache.get(id); + if (kept) return kept; + const made = (async () => { + const owner = ownerOf(row); + const reply = await call( + `/m02/layers/${layerOf(row)}/items/${enc(row.key)}/inputs${owner ? `?owner=${enc(owner)}` : ""}`, + ); + if (reply.status === 200) return { list: reply.body as InputsList, fake: false }; + if (!routeMissing(reply)) throw new Error(reasonOf(reply)); + return { list: await fakeInputs(row), fake: true }; // FAKE + })(); + inputsCache.set(id, made); + made.catch(() => inputsCache.delete(id)); // 못 받으면 다음에 다시 + return made; +} + +/** 가짜 항목 연결 — 모든 입력이 「선택」(실서버가 오면 그 파일을 씀) */ +export function fakeLink(list: InputsList): LinkDoc { + return { + 판: "fake", + 줄: list.로직.flatMap((logic) => + logic.입력.map((input) => ({ 로직: logic.로직, 입력: input.이름, 자리: "선택" as const })), + ), + }; +}