/* ============================================================================= * M02_MasterTemplete_ShareRequest.ts * 회사에 올리기 요청 · 승인(PLAN 706-1) — 서버 `M02_Share_Request.py` 짝(장부 = 회사 층 파일 하나). * 일반 사용자 = 자기 개인 층 구조물 조합 · 사전조합 나무 오른쪽 클릭 「회사에 올리기 요청」 → 글(선택) · * 같은 것 요청이 남아 있으면 「요청 중」(흐림) · 결과는 다음에 M02 열 때 위 띠(닫으면 봄) * 회사 관리자 = M02 위 띠 「올리기 요청 n」(어느 층이든) → 목록(요청자 · 항목 · 날짜 · 글) · [미리 보기] · * [승인](폴더 고름 = 「회사로 복사…」와 같은 길 · 원본 그대로) · [거절](사유 한 줄) * 관리자(회사 층을 고칠 수 있는 사람)는 「회사로 복사…」가 있어 요청 메뉴를 안 둠. * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; import { createBanner } from "@ui/ui_template_banner"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import { createEmpty } from "@ui/ui_template_empty"; import { createListRow } from "@ui/ui_template_list_row"; import type { TreeMenuItem } from "@ui/ui_template_tree"; import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { call, fetchPrecomboTree, fetchTree, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import { accessFor, ownOwner } from "./M02_MasterTemplete_Layer"; import { askCopyFolder } from "./M02_MasterTemplete_TreePanel_CopyTo"; import { askFolder, askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; import type { Target } from "./M02_MasterTemplete_TreePanel_Pick"; type ShareKind = "item" | "precombo"; export interface ShareRow { id: string; 종류: ShareKind; 키: string; 이름: string; 경로: string[]; 주인: string; 요청자: number; 요청자이름: string; 글: string; 날짜: string; 상태: "요청" | "승인" | "거절"; 처리자?: string; 사유?: string; 사본?: string; 봄?: boolean; } const KIND_NAME: Record = { item: "구조물 조합", precombo: "사전조합" }; const why = (error: unknown): string => (error instanceof Error ? error.message : ""); const post = (path: string, body: object = {}): Promise => call(path, { method: "POST", body: JSON.stringify(body) }); export interface ShareOptions { user: SessionUser | null; /** 볼 회사(시스템 관리자는 고른 회사) */ company: string | null; /** 지금 페이지 층 — 개인 층이면 요청 메뉴 */ layer: Layer; owner: string | null; showView: (label: string, node: HTMLElement) => void; /** 사전조합 미리 보기 — 편집 칸에 읽기만으로 */ openPrecombo: (row: ShareRow) => void; /** 구조물 조합 미리 보기 — 요청자 개인 층으로 */ openItem: (row: ShareRow) => void; /** 승인 뒤 — 나무 · 좌측 다시 읽기 */ changed: () => void; } export interface ShareHandle { /** 편집 칸 맨 위 띠 자리(관리자 「올리기 요청 n」 · 요청자 결과) */ top: HTMLElement; /** 구조물 조합 나무 줄 덧메뉴 — 자기 개인 층 항목만 */ itemMenu: (target: Target) => TreeMenuItem[]; /** 사전조합 나무 줄 덧메뉴 — 자기 개인 층만(`mine`) */ precomboMenu: (mine: boolean, key: string, name: string) => TreeMenuItem[]; } export function createShare(opt: ShareOptions): ShareHandle { const top = el("div", { attrs: { "data-share": "" } }); const companyOwner = opt.company ?? ownOwner("company", opt.user); // 요청할 사람 = 회사가 있고 회사 층을 못 고침(고칠 수 있으면 「회사로 복사…」) const asker = !!ownOwner("personal", opt.user) && !accessFor("company", companyOwner, opt.user).canWrite; const boss = !!companyOwner && accessFor("company", companyOwner, opt.user).canWrite; const q = opt.company ? `?company=${encodeURIComponent(opt.company)}` : ""; let rows: ShareRow[] = []; let listHost: HTMLElement | null = null; const pending = (kind: ShareKind, key: string): boolean => rows.some((r) => r.종류 === kind && r.키 === key && r.상태 === "요청"); const refresh = async (): Promise => { if (!asker && !boss) return; rows = (await call<{ 줄: ShareRow[] }>(`/share-requests${q}`).catch(() => ({ 줄: [] }))).줄; paintTop(); if (listHost?.isConnected) paintList(listHost); }; function paintTop(): void { if (boss) { const n = rows.filter((r) => r.상태 === "요청").length; top.replaceChildren( ...(rows.length ? [ createBanner({ tone: n ? "warning" : "info", text: n ? `회사에 올리기 요청 ${n} 건 — 승인하면 회사 층에 사본` : "올리기 요청 없음", actions: [ createButton({ label: `올리기 요청 ${n}`, size: "sm", onClick: openList }), ], }), ] : []), ); return; } const done = rows.filter((r) => r.봄 === false); top.replaceChildren( ...(done.length ? [ createBanner({ tone: done.some((r) => r.상태 === "거절") ? "warning" : "success", title: "회사에 올리기 결과", text: done .map((r) => `「${r.이름}」 ${r.상태}${r.사유 ? `(${r.사유})` : ""}`) .join(" · "), onClose: () => void post(`/share-requests/seen`).catch(() => undefined), }), ] : []), ); } async function ask(kind: ShareKind, key: string, name: string): Promise { const got = await askText( `회사에 올리기 요청 — ${name}`, [{ key: "글", label: "요청 글(선택)", optional: true }], "요청", ); if (!got) return; try { await post("/share-requests", { 종류: kind, 키: key, 글: got.글 }); showToast("요청함 — 회사 관리자가 승인하면 회사 층에 올라감", "success"); } catch (error) { showToast(`요청 못 함 — ${why(error)}`, "error"); } await refresh(); } const menu = (kind: ShareKind, key: string, name: string): TreeMenuItem[] => pending(kind, key) ? [ { label: "회사에 올리기 요청 중", run: () => {}, disabledReason: "회사 관리자가 아직 안 봄", }, ] : [{ label: "회사에 올리기 요청", run: () => void ask(kind, key, name) }]; async function approve(row: ShareRow): Promise { const title = `회사에 올리기 승인 — ${row.이름}`; const dest = row.종류 === "item" ? await askCopyFolder(fetchTree, { layer: "company", owner: companyOwner, label: title }, 1) : await askFolder( title, (await fetchPrecomboTree("company", companyOwner).catch(() => null))?.폴더 ?? [], () => false, [], "넣을 폴더", ); if (!dest) return; try { await post(`/share-requests/${row.id}/approve${q}`, { 경로: dest }); showToast(`「${row.이름}」 회사 층에 올림 — ${dest.join(" / ") || "맨 위"}`, "success"); opt.changed(); } catch (error) { showToast(`승인 못 함 — ${why(error)}`, "error"); } await refresh(); } async function reject(row: ShareRow): Promise { const got = await askText( `거절 — ${row.이름}`, [{ key: "사유", label: "사유(선택)", optional: true }], "거절", ); if (!got) return; try { await post(`/share-requests/${row.id}/reject${q}`, { 사유: got.사유 }); } catch (error) { showToast(`거절 못 함 — ${why(error)}`, "error"); } await refresh(); } function paintList(host: HTMLElement): void { const line = (r: ShareRow): HTMLElement => { const tools = r.상태 === "요청" ? el("span", { className: "ui-toolbar", children: [ createButton({ label: "미리 보기", variant: "ghost", size: "sm", onClick: () => (r.종류 === "item" ? opt.openItem(r) : opt.openPrecombo(r)), }), createButton({ label: "승인", size: "sm", onClick: () => void approve(r) }), createButton({ label: "거절", variant: "danger-ghost", size: "sm", onClick: () => void reject(r), }), ], }) : createBadge( `${r.상태}${r.사유 ? ` · ${r.사유}` : ""}`, r.상태 === "승인" ? "success" : "danger", ); const sub = [KIND_NAME[r.종류], r.요청자이름, r.날짜.slice(0, 10).replace(/-/g, "."), r.글]; return createListRow({ label: r.이름, sub: sub.filter(Boolean).join(" · "), trailing: tools, size: 36, }); }; // 기다리는 것 먼저 · 그다음 최근 처리 const order = [...rows].sort( (a, b) => Number(b.상태 === "요청") - Number(a.상태 === "요청") || (a.날짜 < b.날짜 ? 1 : -1), ); host.replaceChildren( ...(order.length ? order.map(line) : [createEmpty({ text: "올리기 요청 없음", small: true })]), ); } function openList(): void { listHost = el("div", { className: "ui-sidebar-list", attrs: { "data-share-list": "" } }); paintList(listHost); opt.showView("회사에 올리기 요청", listHost); } void refresh(); return { top, itemMenu: (t) => asker && opt.layer === "personal" && !opt.owner && t.kind === "item" ? menu("item", t.item.key, t.item.경로[t.item.경로.length - 1] ?? t.item.key) : [], precomboMenu: (mine, key, name) => (asker && mine ? menu("precombo", key, name) : []), }; }