feat(회사에 올리기 화면): 나무 우클릭 요청 · 요청 중 · 회사 층 「올리기 요청 n」 띠 · 목록 미리 보기 · 승인 · 거절 · 결과 띠 (PLAN 706-1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 14:55:46 +09:00
co-authored by Claude Opus 5.5
parent 74352ec6b8
commit f0be3cd766
5 changed files with 296 additions and 4 deletions
@@ -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<ShareKind, string> = { item: "구조물 조합", precombo: "사전조합" };
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
const post = <T>(path: string, body: object = {}): Promise<T> =>
call<T>(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<void> => {
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<void> {
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<void> {
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<void> {
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) : []),
};
}
@@ -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) : []),
];
};
@@ -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<Mounte
const key = one === "master" ? "Master" : one === "company" ? "Company" : "Personal";
return [{ layer: to, owner: toOwner, label: L(`M02_TreeCopyTo${key}`) }];
});
// 회사에 올리기 요청 · 승인(706-1) — 요청 = 개인 층 나무 덧메뉴 · 승인 = 회사 층 위 띠 · 결과 띠
const share = createShare({
user,
company,
layer,
owner,
showView: (label, node) =>
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<Mounte
() => 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<Mounte
layerBar("구조물조합", tree.add, () => 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" });
@@ -69,7 +69,7 @@ export interface SideOptions {
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
tree?: HTMLElement[];
/** 「일위대가 사전조합」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */
precombo: Pick<PrecomboOptions, "scope" | "bar">;
precombo: Pick<PrecomboOptions, "scope" | "bar" | "more">;
}
export interface SideHandle {
@@ -86,6 +86,8 @@ export interface PrecomboOptions {
confirmLeave: () => Promise<boolean>;
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);