feat(회사에 올리기 화면): 나무 우클릭 요청 · 요청 중 · 회사 층 「올리기 요청 n」 띠 · 목록 미리 보기 · 승인 · 거절 · 결과 띠 (PLAN 706-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user