/* ============================================================================= * M02_MasterTemplete_ItemPick.ts * 가져올 곳 고르기(PLAN 39-5) — 탭(마스터 · 회사 · 개인 · 다른 사람 · 실무문서) + 항목 목록(`ItemList`). * 복사 생성(상세 칸 안 · `buildPicker`) · 복사해 가져오기(창 · `pickFrom`)가 같이 씀. * 탭 목록 · 기본 탭 = `readableSources`(권한 몫 · `M02_MasterTemplete_Layer.ts`) · 탭은 목록만 바꿈 * (좌측 층 단추와 달리 화면을 다시 안 세움) · 모양은 층 단추(`m02-layers__btn`) 그대로. * 다른 사람 = 사람 고르기 하나(`fetchOwners`) · 실무문서 = 마스터 일위대가 조합 줄(일위대가 고를 때만). * ========================================================================== */ import { createButton, createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { fetchSessionUser, 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 { entryFromHash, ownOwner, readableSources, viewCompany, 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: "다른 사람이 없음", empty: "비어 있음", failed: "목록을 못 받음 — {value}", makeNew: "새로 만들기", makeCopy: "복사 생성", }; /** 고른 곳 — `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)); /** 층 폴더가 아직 없는 사람(서버 사람 목록 `비어있음` · 39-6) */ const empty = (o: { owner: string }): boolean => !!(o as { 비어있음?: boolean }).비어있음; 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") { // 나는 「개인」 탭 — 뺌 · 빈 사람은 「비어 있음」 · 처음 = 첫 빈 아닌 사람 const me = ownOwner("personal", opt.user); owners ??= (await fetchOwners("personal", opt.company)).filter((o) => o.owner !== me); 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: empty(o) ? `${o.name} · ${TEXT.empty}` : o.name, })), (owners.find((o) => !empty(o)) ?? owners[0]).owner, ); } owner = people.select.value; const who = owners.find((o) => o.owner === owner); label = `${tab.label} · ${who?.name ?? owner}`; if (who && empty(who)) return list.show([], ITEM_COLS[opt.kind], () => {}); // 층 폴더 없음 } 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, ], }), }; } 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 }, ): 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); }, }); }); } /** 지금 화면의 사람 · 볼 회사로 `pickFrom`(카드 · 집계표 줄처럼 페이지 값을 못 받는 곳) */ export async function pickHere( opt: Omit & { title: string; ask?: (picked: Picked) => string; }, ): Promise { const user = await fetchSessionUser().catch(() => null); return pickFrom({ ...opt, user, company: viewCompany(entryFromHash(), user) }); }