diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css new file mode 100644 index 000000000..70f57c665 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css @@ -0,0 +1,15 @@ +/* 가져올 곳 고르기(PLAN 39-5) — 탭 줄 + 목록 · 창이면 목록이 넓게 */ +.m02-itempick { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; +} + +.m02-itempick .m01-master__grid-wrap { + max-height: min(60vh, calc(100vh - 300px)); +} + +.ui-modal.m02-itempick__dialog { + width: min(960px, 94vw); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts new file mode 100644 index 000000000..dc1fbc122 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -0,0 +1,178 @@ +/* ============================================================================= + * M02_MasterTemplete_ItemPick.ts + * 가져올 곳 고르기(PLAN 39-5) — 탭(마스터 · 회사 · 개인 · 다른 사람 · 실무문서) + 항목 목록(`ItemList`). + * 복사 생성(상세 칸 안 · `buildPicker`) · 복사해 가져오기(창 · `pickFrom`)가 같이 씀. + * 탭 목록 · 기본 탭 = `readableSources`(권한 몫 · `M02_MasterTemplete_Layer.ts`) · 탭은 목록만 바꿈 + * (좌측 층 단추와 달리 화면을 다시 안 세움) · 모양은 층 단추(`m02-layers__btn`) 그대로. + * 다른 사람 = 사람 고르기 하나(`fetchOwners`) · 실무문서 = 마스터 일위대가 조합 줄(일위대가 고를 때만). + * ========================================================================== */ + +import { 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"; +import { + buildItemList, + ITEM_COLS, + loadItems, + type Col, + type ItemRow, + type ListKind, +} from "./M02_MasterTemplete_ItemList"; +import { fetchCombos, type ComboSummary } from "./M02_MasterTemplete_Labor_Api"; +import { readableSources, type Source } from "./M02_MasterTemplete_Layer"; +import { leafName } from "./M02_MasterTemplete_TreePanel_Model"; +import "./M02_MasterTemplete_TreePanel.css"; +import "./M02_MasterTemplete_ItemPick.css"; + +const TEXT = { + close: "닫기", + person: "사람", + noPeople: "다른 사람이 없음", + failed: "목록을 못 받음 — {value}", +}; + +/** 고른 곳 — `practice` = 실무문서(마스터 일위대가 조합) */ +export interface From { + layer: Layer; + owner: string | null; + label: string; + practice: boolean; +} + +export type Picked = { from: From; row: ItemRow } | { from: From; combo: ComboSummary }; + +/** 고른 것 이름 — 확인 글에 씀 */ +export const pickedName = (p: Picked): string => + "row" in p ? leafName(p.row) : `${p.combo.키} ${p.combo.이름}`; + +const PRACTICE_COLS: Col[] = [ + { head: "키", get: (c) => c.키 }, + { head: "이름", get: (c) => c.이름 }, + { head: "구분", get: (c) => c.구분 ?? "" }, + { head: "상세구분", get: (c) => c.상세구분 ?? "" }, + { head: "단위", get: (c) => c.단위 ?? "" }, + { head: "담은 로직 수", get: (c) => String(c.count) }, +]; + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +export interface PickerOptions { + user: SessionUser | null; + /** 볼 회사(`viewCompany`) */ + company: string | null; + /** 칸 종류 — 항목 통째(복사 생성) · 도면 · 면적산출 · 일위대가(실무문서 탭이 붙음) */ + kind: ListKind; + /** 고를 수 없는 줄(받는 열 자신 등) — 목록에서 뺌 */ + skip?: (from: From, row: ItemRow) => boolean; + onPick: (picked: Picked) => void; +} + +/** 탭 + 목록 — 부르는 쪽이 칸 안 · 창에 붙임 */ +export function buildPicker(opt: PickerOptions): { root: HTMLElement } { + const { tabs, first } = readableSources(opt.user, opt.company, { + practice: opt.kind === "labor", + }); + const list = buildItemList(); + const people = createSelectField({ options: [], compact: true, label: TEXT.person }); + people.root.hidden = true; + let owners: { owner: string; name: string }[] | null = null; + let seq = 0; + + const buttons = tabs.map((tab) => { + const b = el("button", { + className: "m02-layers__btn", + text: tab.label, + attrs: { type: "button", "data-source": tab.id }, + }); + b.addEventListener("click", () => void show(tab)); + return b; + }); + + async function show(tab: Source): Promise { + const mine = ++seq; + buttons.forEach((b) => b.classList.toggle("is-active", b.dataset.source === tab.id)); + people.root.hidden = tab.id !== "people"; + list.message(); + try { + if (tab.id === "practice") { + const combos = await fetchCombos(); + if (mine !== seq) return; + const from = { layer: tab.layer, owner: null, label: tab.label, practice: true }; + return list.show(combos, PRACTICE_COLS, (combo) => opt.onPick({ from, combo })); + } + let owner = tab.owner; + let label = tab.label; + if (tab.id === "people") { + owners ??= await fetchOwners("personal", opt.company); + if (mine !== seq) return; + if (!owners.length) return list.message(TEXT.noPeople); + if (!people.select.value) { + people.setOptions( + owners.map((o) => ({ value: o.owner, text: o.name })), + owners[0].owner, + ); + } + owner = people.select.value; + label = `${tab.label} · ${owners.find((o) => o.owner === owner)?.name ?? owner}`; + } + const rows = await loadItems(tab.layer, owner); + if (mine !== seq) return; + const from = { layer: tab.layer, owner, label, practice: false }; + list.show( + rows.filter((row) => !opt.skip?.(from, row)), + ITEM_COLS[opt.kind], + (row) => opt.onPick({ from, row }), + ); + } catch (error) { + if (mine === seq) list.message(TEXT.failed.replace("{value}", why(error))); + } + } + + people.select.addEventListener("change", () => { + const tab = tabs.find((t) => t.id === "people"); + if (tab) void show(tab); + }); + const start = tabs.find((t) => t.id === first) ?? tabs[0]; + if (start) void show(start); + return { + root: el("div", { + className: "m02-itempick", + children: [ + el("div", { className: "m02-layers__row", children: [...buttons, people.root] }), + list.root, + ], + }), + }; +} + +/** 창으로 고름 — 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */ +export function pickFrom( + opt: Omit & { title: string; ask?: (picked: Picked) => string }, +): Promise { + return new Promise((resolve) => { + let done = false; + const finish = (value: Picked | null): void => { + if (!done) resolve(value); + done = true; + }; + openModal({ + title: opt.title, + closeLabel: TEXT.close, + dialogClass: "m02-modal m02-itempick__dialog", + beforeClose: () => (finish(null), true), + mount: (body, close) => { + const picker = buildPicker({ + ...opt, + onPick: (picked) => + void (async () => { + if (opt.ask && !(await showConfirmDialog(opt.ask(picked)))) return; + finish(picked); + close(); + })(), + }); + body.append(picker.root); + }, + }); + }); +}