From f0be3cd766cab34cb6d618509cf2791367e79ad8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 14:55:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(=ED=9A=8C=EC=82=AC=EC=97=90=20=EC=98=AC?= =?UTF-8?q?=EB=A6=AC=EA=B8=B0=20=ED=99=94=EB=A9=B4):=20=EB=82=98=EB=AC=B4?= =?UTF-8?q?=20=EC=9A=B0=ED=81=B4=EB=A6=AD=20=EC=9A=94=EC=B2=AD=20=C2=B7=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=A4=91=20=C2=B7=20=ED=9A=8C=EC=82=AC=20?= =?UTF-8?q?=EC=B8=B5=20=E3=80=8C=EC=98=AC=EB=A6=AC=EA=B8=B0=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20n=E3=80=8D=20=EB=9D=A0=20=C2=B7=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=AF=B8=EB=A6=AC=20=EB=B3=B4=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=20=C2=B7=20=EA=B1=B0=EC=A0=88=20=C2=B7=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EB=9D=A0=20(PLAN=20706-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_ShareRequest.ts | 260 ++++++++++++++++++ .../M02_MasterTemplete_TreePanel.ts | 2 + .../M02_MasterTemplete_UI_Page.ts | 31 ++- .../M02_MasterTemplete_UI_Side.ts | 2 +- .../M02_MasterTemplete_UI_Side_Precombo.ts | 5 +- 5 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_ShareRequest.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_ShareRequest.ts b/M02_MasterTemplete/M02_MasterTemplete_ShareRequest.ts new file mode 100644 index 000000000..086a735b3 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ShareRequest.ts @@ -0,0 +1,260 @@ +/* ============================================================================= + * M02_MasterTemplete_ShareRequest.ts + * 회사에 올리기 요청 · 승인(PLAN 706-1) — 서버 `M02_Share_Request.py` 짝(장부 = 회사 층 파일 하나). + * 일반 사용자 = 자기 개인 층 구조물 조합 · 사전조합 나무 오른쪽 클릭 「회사에 올리기 요청」 → 글(선택) · + * 같은 것 요청이 남아 있으면 「요청 중」(흐림) · 결과는 다음에 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 = + opt.layer === "company" && + !!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) : []), + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 384e588c5..f8fa81004 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -85,6 +85,7 @@ export interface TreePanelOptions { }; /** 「〇〇로 복사…」 — 그 층 폴더를 골라 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로 · 39-6) */ copyTo?: CopyTarget[]; + more?: (target: Target) => TreeMenuItem[]; // 줄 하나 덧메뉴(706-1 회사에 올리기 요청) /** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */ canEdit: boolean; /** 항목을 누름 — 표에서 그 열을 고름 */ @@ -226,6 +227,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { return [ ...(opt.canEdit ? menuOf(target) : []), ...copyItem([target]), + ...(opt.more?.(target) ?? []), ...(target.kind === "folder" ? pasteItem(target.path) : []), ]; }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 7cb89166b..e64672796 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -40,6 +40,7 @@ import { STRUCTURE_TABLE, type StructureColumn, } from "./M02_MasterTemplete_Structure"; +import { createShare } from "./M02_MasterTemplete_ShareRequest"; import { createTreePanel } from "./M02_MasterTemplete_TreePanel"; import { companyOwner, @@ -91,10 +92,32 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise + void main.confirmLeave().then((ok) => ok && main.showView(label, node)), + openPrecombo: (r) => + void main.open({ + layer: "personal", + owner: r.주인, + canWrite: false, + projectId: null, + kind: "precombo", + name: r.키, + label: r.이름, + }), + openItem: (r) => void go("personal", r.주인, "구조물조합"), + changed: () => void Promise.all([side.refresh(), tree.refresh()]), + }); const tree = createTreePanel({ layer, owner, copyTo, + more: share.itemMenu, canEdit: canEdit(layer, isAdmin), onPick: (item) => void main.openTable(layer, item.열), settle: () => main.settle(), @@ -138,7 +161,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void Promise.all([side.refresh(), tree.refresh()]), () => side.paint(), { - top: readOnlyBar?.root, + top: el("div", { children: [...(readOnlyBar ? [readOnlyBar.root] : []), share.top] }), // 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 상세 칸에 [새로 만들기] [복사 생성](39-5) ...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => void openNew()) } : {}), onColumn: (column) => { @@ -222,7 +245,11 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.openTable(layer)), peopleTree("구조물조합", tree.root), ], - precombo: { scope: () => pre, bar: preBar }, + precombo: { + scope: () => pre, + bar: preBar, + more: (key, name) => share.precomboMenu(pre.root === "personal" && !pre.owner, key, name), + }, }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index a530445ff..b6b5fc63e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -69,7 +69,7 @@ export interface SideOptions { /** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */ tree?: HTMLElement[]; /** 「일위대가 사전조합」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */ - precombo: Pick; + precombo: Pick; } export interface SideHandle { diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts index 5eb4936a0..e109cc347 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts @@ -86,6 +86,8 @@ export interface PrecomboOptions { confirmLeave: () => Promise; onOpen: (sel: Selection | null) => void; getOpen: () => Selection | null; + /** 문서 줄 덧메뉴(706-1 회사에 올리기 요청) — 없거나 빈 목록이면 메뉴 없음 */ + more?: (key: string, name: string) => TreeMenuItem[]; } export interface PrecomboHandle { @@ -237,7 +239,8 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { const label = row.제목 || row.이름; const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label }; targets.set(id, { kind: "item", item }); - return { id, label, tip: row.이름, count: null, run: () => void open(row) }; + const menu = () => opt.more?.(row.이름, label) ?? []; + return { id, label, tip: row.이름, count: null, run: () => void open(row), menu }; }; const folderNode = (node: PrecomboFolder): TreeNode => { const id = folderIdOf(node.path);