@goplusvn/core 0.1.75 → 0.1.77
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/bin/goerp-features.mjs +11 -1
- package/features/workspaces/README.md +72 -0
- package/features/workspaces/migrations/0001_init.sql +63 -0
- package/features/workspaces/migrations/0002_delegation-columns.sql +34 -0
- package/features/workspaces/schema.prisma +56 -0
- package/package.json +2 -1
- package/scripts/feature-sync.mjs +31 -3
- package/src/branch-scope/context.ts +20 -37
- package/src/features/__tests__/feature-sync.test.ts +41 -0
- package/src/guardrails/__tests__/guardrails.test.ts +47 -0
- package/src/guardrails/primitives.ts +14 -1
- package/src/guardrails/rules/one-door.ts +23 -0
- package/src/guardrails/scanner.ts +9 -0
- package/src/guardrails/types.ts +7 -0
- package/src/user/__tests__/user-service-scope.test.ts +148 -0
- package/src/user/components/unified-profile-dialog.tsx +160 -0
- package/src/user/pages/users-client-page.tsx +12 -0
- package/src/user/user-service.ts +64 -10
- package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
- package/src/workspace/__tests__/workspace-member-handlers.test.ts +407 -0
- package/src/workspace/__tests__/workspace-route-handlers.test.ts +449 -0
- package/src/workspace/__tests__/workspace-scope.test.ts +592 -0
- package/src/workspace/__tests__/workspace-service.test.ts +339 -0
- package/src/workspace/components/scope-level-select.tsx +91 -0
- package/src/workspace/components/workspace-members-panel.tsx +454 -0
- package/src/workspace/components/workspace-org-block.tsx +293 -0
- package/src/workspace/components/workspace-switcher.tsx +139 -0
- package/src/workspace/components/workspace-tree-view.tsx +301 -0
- package/src/workspace/context.ts +78 -0
- package/src/workspace/delegation.ts +400 -0
- package/src/workspace/guard.ts +138 -0
- package/src/workspace/index.ts +173 -0
- package/src/workspace/pages/workspace-list-page.tsx +802 -0
- package/src/workspace/route-handlers.ts +550 -0
- package/src/workspace/scope.ts +396 -0
- package/src/workspace/service.ts +301 -0
- package/src/workspace/tree.ts +193 -0
- package/src/workspace/types.ts +182 -0
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Trang "Workspace" — hai khung: TRÁI chọn nơi, PHẢI làm việc với nơi đó.
|
|
4
|
+
//
|
|
5
|
+
// Vì sao hai khung: trang này trả lời hai câu hỏi khác cấp nhau — "cơ cấu tổ
|
|
6
|
+
// chức trông ra sao" và "trong bộ phận này có ai". Nhét cả hai vào một cột thì
|
|
7
|
+
// mở người lên là cơ cấu bị đẩy khuất, mà so hai bộ phận lại phải nhớ bằng đầu.
|
|
8
|
+
// Tách ra: cột trái luôn còn nguyên để đối chiếu và nhảy qua lại, cột phải chỉ
|
|
9
|
+
// nói về đúng một nút nên có chỗ cho mã, cấp, số liệu, nút thao tác và danh
|
|
10
|
+
// sách người — không phải bóp lại cho vừa một dòng.
|
|
11
|
+
//
|
|
12
|
+
// Dùng lại khung `RbacPageBar` của các trang RBAC thay vì tự dựng bar riêng:
|
|
13
|
+
// đây là trang quản trị cùng họ, lệch khung một chút là người dùng nhận ra ngay
|
|
14
|
+
// (L1 — tái dùng, không hand-roll).
|
|
15
|
+
//
|
|
16
|
+
// API contract: GET/POST `/api/workspaces`, PATCH/DELETE `/api/workspaces/:id`,
|
|
17
|
+
// POST `/api/workspaces/:id/move`.
|
|
18
|
+
import * as React from "react";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
ChevronRight,
|
|
22
|
+
MoreHorizontal,
|
|
23
|
+
Network,
|
|
24
|
+
Plus,
|
|
25
|
+
Search,
|
|
26
|
+
X,
|
|
27
|
+
} from "lucide-react";
|
|
28
|
+
import { toast } from "sonner";
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
Badge,
|
|
32
|
+
Button,
|
|
33
|
+
ConfirmDialog,
|
|
34
|
+
Dialog,
|
|
35
|
+
DialogContent,
|
|
36
|
+
DialogDescription,
|
|
37
|
+
DialogFooter,
|
|
38
|
+
DialogHeader,
|
|
39
|
+
DialogTitle,
|
|
40
|
+
DropdownMenu,
|
|
41
|
+
DropdownMenuContent,
|
|
42
|
+
DropdownMenuItem,
|
|
43
|
+
DropdownMenuSeparator,
|
|
44
|
+
DropdownMenuTrigger,
|
|
45
|
+
Input,
|
|
46
|
+
Label,
|
|
47
|
+
Select,
|
|
48
|
+
SelectContent,
|
|
49
|
+
SelectItem,
|
|
50
|
+
SelectTrigger,
|
|
51
|
+
SelectValue,
|
|
52
|
+
Skeleton,
|
|
53
|
+
} from "../../ui";
|
|
54
|
+
import { RbacPageBar } from "../../rbac/pages/lib/rbac-page-shell";
|
|
55
|
+
import { cn } from "../../utils";
|
|
56
|
+
import { countTreeNodes } from "../components/workspace-tree-view";
|
|
57
|
+
import type { WorkspaceTreeNode } from "../components/workspace-tree-view";
|
|
58
|
+
import {
|
|
59
|
+
WorkspaceOrgBlock,
|
|
60
|
+
filterWorkspaceTree,
|
|
61
|
+
rollupCounts,
|
|
62
|
+
} from "../components/workspace-org-block";
|
|
63
|
+
import type { WorkspaceOrgAnnotation } from "../components/workspace-org-block";
|
|
64
|
+
import { WorkspaceMembersPanel } from "../components/workspace-members-panel";
|
|
65
|
+
import type { WorkspaceKindConfig } from "../types";
|
|
66
|
+
|
|
67
|
+
export interface WorkspaceListPageProps {
|
|
68
|
+
/** Cây đã dựng sẵn ở server (`buildTree`) — trang này không tự gọi API lần đầu. */
|
|
69
|
+
initialTree: WorkspaceTreeNode[];
|
|
70
|
+
/** Nhãn cấp của app; rỗng = ẩn ô chọn cấp, mọi nút cùng một loại. */
|
|
71
|
+
kinds?: WorkspaceKindConfig[];
|
|
72
|
+
/** Nhánh người dùng được uỷ quyền quản trị (đã bung con cháu). */
|
|
73
|
+
adminIds?: string[];
|
|
74
|
+
canManageAll?: boolean;
|
|
75
|
+
canCreate?: boolean;
|
|
76
|
+
apiEndpoint?: string;
|
|
77
|
+
title?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Danh tính từng tổ chức gốc, tra theo `code` (không theo id: id đổi theo môi
|
|
80
|
+
* trường, mã thì không).
|
|
81
|
+
*
|
|
82
|
+
* Core không tự biết gốc nào là bên vận hành, gốc nào là khách hàng — trong
|
|
83
|
+
* bảng chúng là hai dòng y hệt nhau. Bỏ trống thì mọi khối vẽ trung tính.
|
|
84
|
+
*/
|
|
85
|
+
rootAnnotations?: Record<string, WorkspaceOrgAnnotation>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface FormState {
|
|
89
|
+
mode: "create" | "edit";
|
|
90
|
+
id?: string;
|
|
91
|
+
parentId: string | null;
|
|
92
|
+
parentName?: string;
|
|
93
|
+
code: string;
|
|
94
|
+
name: string;
|
|
95
|
+
kind: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Nhãn con hợp lệ dưới một nút — theo `childKinds` của cấp cha. */
|
|
99
|
+
function allowedChildKinds(
|
|
100
|
+
kinds: WorkspaceKindConfig[],
|
|
101
|
+
parentKind?: string,
|
|
102
|
+
): WorkspaceKindConfig[] {
|
|
103
|
+
if (!parentKind) return kinds;
|
|
104
|
+
const parent = kinds.find((k) => k.key === parentKind);
|
|
105
|
+
if (!parent?.childKinds) return kinds;
|
|
106
|
+
return kinds.filter((k) => parent.childKinds?.includes(k.key));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function findNode(
|
|
110
|
+
nodes: WorkspaceTreeNode[],
|
|
111
|
+
id: string,
|
|
112
|
+
): WorkspaceTreeNode | null {
|
|
113
|
+
for (const node of nodes) {
|
|
114
|
+
if (node.id === id) return node;
|
|
115
|
+
const hit = findNode(node.children, id);
|
|
116
|
+
if (hit) return hit;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Đường đi từ gốc tới một nút — để khung phải nói rõ nó đang ở nhánh nào. */
|
|
122
|
+
function pathTo(
|
|
123
|
+
nodes: WorkspaceTreeNode[],
|
|
124
|
+
id: string,
|
|
125
|
+
trail: WorkspaceTreeNode[] = [],
|
|
126
|
+
): WorkspaceTreeNode[] | null {
|
|
127
|
+
for (const node of nodes) {
|
|
128
|
+
const next = [...trail, node];
|
|
129
|
+
if (node.id === id) return next;
|
|
130
|
+
const hit = pathTo(node.children, id, next);
|
|
131
|
+
if (hit) return hit;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Làm phẳng cây thành danh sách chọn "chuyển vào nhánh nào". */
|
|
137
|
+
function flatten(
|
|
138
|
+
nodes: WorkspaceTreeNode[],
|
|
139
|
+
depth = 0,
|
|
140
|
+
): { id: string; name: string; depth: number }[] {
|
|
141
|
+
return nodes.flatMap((node) => [
|
|
142
|
+
{ id: node.id, name: node.name, depth },
|
|
143
|
+
...flatten(node.children, depth + 1),
|
|
144
|
+
]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Nút và toàn bộ con cháu — không cho chuyển một nhánh vào chính nó. */
|
|
148
|
+
function subtreeIds(node: WorkspaceTreeNode): string[] {
|
|
149
|
+
return [node.id, ...node.children.flatMap(subtreeIds)];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Dải số liệu của nút đang chọn.
|
|
154
|
+
*
|
|
155
|
+
* Tách "gán trực tiếp" khỏi "cả nhánh" chứ không gộp một số: hai con số này
|
|
156
|
+
* lệch nhau là chuyện thường (công ty 2 người, cả nhánh 7 người) và người đọc
|
|
157
|
+
* chỉ hiểu được nếu nhìn thấy cả hai cạnh nhau có nhãn rõ.
|
|
158
|
+
*/
|
|
159
|
+
function StatStrip({ items }: { items: { label: string; value: number }[] }) {
|
|
160
|
+
return (
|
|
161
|
+
// `w-fit` chứ không kéo hết bề ngang: hai ô số kéo giãn ra 700px là 700px
|
|
162
|
+
// gần như trống, trong khi bảng thành viên ngay dưới mới là phần chính.
|
|
163
|
+
<dl className="flex w-fit max-w-full flex-wrap divide-x divide-border overflow-hidden rounded-lg border border-border bg-muted/20">
|
|
164
|
+
{items.map((item) => (
|
|
165
|
+
<div key={item.label} className="min-w-[6.5rem] px-3 py-2">
|
|
166
|
+
<dd className="text-base font-semibold tabular-nums">{item.value}</dd>
|
|
167
|
+
<dt className="mt-0.5 truncate text-[11px] text-muted-foreground">
|
|
168
|
+
{item.label}
|
|
169
|
+
</dt>
|
|
170
|
+
</div>
|
|
171
|
+
))}
|
|
172
|
+
</dl>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function WorkspaceListPage({
|
|
177
|
+
initialTree,
|
|
178
|
+
kinds = [],
|
|
179
|
+
adminIds,
|
|
180
|
+
canManageAll = false,
|
|
181
|
+
canCreate = false,
|
|
182
|
+
apiEndpoint = "/api/workspaces",
|
|
183
|
+
title = "Workspace",
|
|
184
|
+
rootAnnotations,
|
|
185
|
+
}: WorkspaceListPageProps) {
|
|
186
|
+
const [tree, setTree] = React.useState(initialTree);
|
|
187
|
+
const [search, setSearch] = React.useState("");
|
|
188
|
+
const [busy, setBusy] = React.useState(false);
|
|
189
|
+
const [selectedId, setSelectedId] = React.useState<string | null>(
|
|
190
|
+
initialTree[0]?.id ?? null,
|
|
191
|
+
);
|
|
192
|
+
const [form, setForm] = React.useState<FormState | null>(null);
|
|
193
|
+
const [moving, setMoving] = React.useState<WorkspaceTreeNode | null>(null);
|
|
194
|
+
const [moveTarget, setMoveTarget] = React.useState<string>("");
|
|
195
|
+
const [deactivating, setDeactivating] =
|
|
196
|
+
React.useState<WorkspaceTreeNode | null>(null);
|
|
197
|
+
const detailRef = React.useRef<HTMLDivElement | null>(null);
|
|
198
|
+
|
|
199
|
+
const total = React.useMemo(() => countTreeNodes(tree), [tree]);
|
|
200
|
+
|
|
201
|
+
/** Tổng người trên toàn cây — con số duy nhất trả lời "hệ thống có bao nhiêu người dùng có phạm vi". */
|
|
202
|
+
const totalMembers = React.useMemo(
|
|
203
|
+
() => tree.reduce((sum, root) => sum + rollupCounts(root).members, 0),
|
|
204
|
+
[tree],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const visibleRoots = React.useMemo(
|
|
208
|
+
() => filterWorkspaceTree(tree, search),
|
|
209
|
+
[tree, search],
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const selected = React.useMemo(
|
|
213
|
+
() => (selectedId ? findNode(tree, selectedId) : null),
|
|
214
|
+
[tree, selectedId],
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Nút đang chọn biến mất (vừa xoá, vừa chuyển nhánh, cây tải lại) thì rơi về
|
|
218
|
+
// gốc đầu tiên — khung phải trống trơn không nói được gì cho ai.
|
|
219
|
+
React.useEffect(() => {
|
|
220
|
+
if (selected) return;
|
|
221
|
+
setSelectedId(tree[0]?.id ?? null);
|
|
222
|
+
}, [selected, tree]);
|
|
223
|
+
|
|
224
|
+
const canManageSelected =
|
|
225
|
+
canManageAll || (selected ? (adminIds ?? []).includes(selected.id) : false);
|
|
226
|
+
const selectedKindLabel = selected
|
|
227
|
+
? (selected.kindLabel ?? kinds.find((k) => k.key === selected.kind)?.label)
|
|
228
|
+
: undefined;
|
|
229
|
+
const breadcrumb = React.useMemo(
|
|
230
|
+
() => (selectedId ? (pathTo(tree, selectedId) ?? []) : []),
|
|
231
|
+
[selectedId, tree],
|
|
232
|
+
);
|
|
233
|
+
const selectedRollup = React.useMemo(
|
|
234
|
+
() => (selected ? rollupCounts(selected) : null),
|
|
235
|
+
[selected],
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Nhãn cấp con hợp lệ của một nút, `null` nếu nút đó hết cấp.
|
|
240
|
+
*
|
|
241
|
+
* Trả về nhãn chứ không trả `true/false`: nút "Thêm bộ phận" phải gọi đúng
|
|
242
|
+
* tên cấp mà app đặt. Hết cấp thì ẩn hẳn — mời bấm rồi báo lỗi là cách chắc
|
|
243
|
+
* chắn làm người dùng tưởng hệ thống hỏng.
|
|
244
|
+
*/
|
|
245
|
+
const addChildLabelOf = React.useCallback(
|
|
246
|
+
(node: WorkspaceTreeNode): string | null => {
|
|
247
|
+
if (kinds.find((k) => k.key === node.kind)?.canHaveChildren === false) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
return allowedChildKinds(kinds, node.kind)[0]?.label ?? null;
|
|
251
|
+
},
|
|
252
|
+
[kinds],
|
|
253
|
+
);
|
|
254
|
+
const flat = React.useMemo(() => flatten(tree), [tree]);
|
|
255
|
+
|
|
256
|
+
const reload = React.useCallback(async () => {
|
|
257
|
+
const res = await fetch(apiEndpoint, { cache: "no-store" });
|
|
258
|
+
if (!res.ok) {
|
|
259
|
+
toast.error("Không tải lại được danh sách workspace.");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
setTree(await res.json());
|
|
263
|
+
}, [apiEndpoint]);
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Số thành viên trên cây do khung phải báo về.
|
|
267
|
+
*
|
|
268
|
+
* Con số trong `initialTree` là ảnh chụp lúc server render. Thêm/gỡ người chỉ
|
|
269
|
+
* gọi API members, không đụng gì tới cây, nên nếu không đồng bộ ở đây thì hai
|
|
270
|
+
* nửa màn hình nói hai con số khác nhau — khung phải "1 thành viên" trong khi
|
|
271
|
+
* cây bên trái vẫn "0". Cập nhật tại chỗ thay vì `reload()` cả cây: ô tìm
|
|
272
|
+
* kiếm và vị trí cuộn phải giữ nguyên.
|
|
273
|
+
*/
|
|
274
|
+
const applyMemberCount = React.useCallback(
|
|
275
|
+
(workspaceId: string, count: number) => {
|
|
276
|
+
setTree((prev) => {
|
|
277
|
+
let changed = false;
|
|
278
|
+
const walk = (nodes: WorkspaceTreeNode[]): WorkspaceTreeNode[] =>
|
|
279
|
+
nodes.map((node) => {
|
|
280
|
+
if (node.id === workspaceId) {
|
|
281
|
+
if (node.memberCount === count) return node;
|
|
282
|
+
changed = true;
|
|
283
|
+
return { ...node, memberCount: count };
|
|
284
|
+
}
|
|
285
|
+
if (node.children.length === 0) return node;
|
|
286
|
+
const children = walk(node.children);
|
|
287
|
+
return children === node.children ? node : { ...node, children };
|
|
288
|
+
});
|
|
289
|
+
const next = walk(prev);
|
|
290
|
+
return changed ? next : prev;
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
[],
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Chọn một nút.
|
|
298
|
+
*
|
|
299
|
+
* Trên màn hẹp hai khung xếp chồng, khung phải nằm dưới màn hình — bấm xong
|
|
300
|
+
* mà không kéo tới thì trông như không có gì xảy ra.
|
|
301
|
+
*/
|
|
302
|
+
const selectNode = React.useCallback((node: WorkspaceTreeNode) => {
|
|
303
|
+
setSelectedId(node.id);
|
|
304
|
+
if (typeof window !== "undefined" && window.innerWidth < 1024) {
|
|
305
|
+
detailRef.current?.scrollIntoView({ block: "start" });
|
|
306
|
+
}
|
|
307
|
+
}, []);
|
|
308
|
+
|
|
309
|
+
/** Mọi lời gọi ghi đi qua đây để thông báo lỗi của server tới thẳng người dùng. */
|
|
310
|
+
const submit = React.useCallback(
|
|
311
|
+
async (url: string, method: string, body?: unknown) => {
|
|
312
|
+
setBusy(true);
|
|
313
|
+
try {
|
|
314
|
+
const res = await fetch(url, {
|
|
315
|
+
method,
|
|
316
|
+
headers: { "Content-Type": "application/json" },
|
|
317
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
318
|
+
});
|
|
319
|
+
if (!res.ok) {
|
|
320
|
+
const payload = await res.json().catch(() => null);
|
|
321
|
+
throw new Error(payload?.error ?? "Thao tác không thành công.");
|
|
322
|
+
}
|
|
323
|
+
await reload();
|
|
324
|
+
return true;
|
|
325
|
+
} catch (error) {
|
|
326
|
+
toast.error(
|
|
327
|
+
error instanceof Error ? error.message : "Thao tác không thành công.",
|
|
328
|
+
);
|
|
329
|
+
return false;
|
|
330
|
+
} finally {
|
|
331
|
+
setBusy(false);
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
[reload],
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
const openCreate = (parent?: WorkspaceTreeNode) => {
|
|
338
|
+
const options = allowedChildKinds(kinds, parent?.kind);
|
|
339
|
+
setForm({
|
|
340
|
+
mode: "create",
|
|
341
|
+
parentId: parent?.id ?? null,
|
|
342
|
+
parentName: parent?.name,
|
|
343
|
+
code: "",
|
|
344
|
+
name: "",
|
|
345
|
+
kind: options[0]?.key ?? kinds[0]?.key ?? "unit",
|
|
346
|
+
});
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const saveForm = async () => {
|
|
350
|
+
if (!form) return;
|
|
351
|
+
if (!form.code.trim() || !form.name.trim()) {
|
|
352
|
+
toast.error("Mã và tên không được để trống.");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const ok =
|
|
356
|
+
form.mode === "create"
|
|
357
|
+
? await submit(apiEndpoint, "POST", {
|
|
358
|
+
code: form.code.trim(),
|
|
359
|
+
name: form.name.trim(),
|
|
360
|
+
kind: form.kind,
|
|
361
|
+
parentId: form.parentId,
|
|
362
|
+
})
|
|
363
|
+
: await submit(`${apiEndpoint}/${form.id}`, "PATCH", {
|
|
364
|
+
code: form.code.trim(),
|
|
365
|
+
name: form.name.trim(),
|
|
366
|
+
kind: form.kind,
|
|
367
|
+
});
|
|
368
|
+
if (ok) {
|
|
369
|
+
toast.success(form.mode === "create" ? "Đã thêm." : "Đã cập nhật.");
|
|
370
|
+
setForm(null);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const confirmMove = async () => {
|
|
375
|
+
if (!moving) return;
|
|
376
|
+
const ok = await submit(`${apiEndpoint}/${moving.id}/move`, "POST", {
|
|
377
|
+
newParentId: moveTarget === "__root__" ? null : moveTarget,
|
|
378
|
+
});
|
|
379
|
+
if (ok) {
|
|
380
|
+
toast.success("Đã chuyển nhánh.");
|
|
381
|
+
setMoving(null);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const confirmDeactivate = async () => {
|
|
386
|
+
if (!deactivating) return;
|
|
387
|
+
const ok = await submit(`${apiEndpoint}/${deactivating.id}`, "DELETE");
|
|
388
|
+
if (ok) {
|
|
389
|
+
toast.success("Đã ngừng hoạt động cả nhánh.");
|
|
390
|
+
setDeactivating(null);
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
// Không cho chuyển vào chính mình hay con cháu — chặn ở dropdown luôn cho gọn.
|
|
395
|
+
const moveOptions = React.useMemo(() => {
|
|
396
|
+
if (!moving) return [];
|
|
397
|
+
const banned = new Set(subtreeIds(moving));
|
|
398
|
+
return flat.filter((item) => !banned.has(item.id));
|
|
399
|
+
}, [moving, flat]);
|
|
400
|
+
|
|
401
|
+
const kindOptions = React.useMemo(() => {
|
|
402
|
+
if (!form) return kinds;
|
|
403
|
+
const parent = form.parentId ? findNode(tree, form.parentId) : null;
|
|
404
|
+
return allowedChildKinds(kinds, parent?.kind);
|
|
405
|
+
}, [form, kinds, tree]);
|
|
406
|
+
|
|
407
|
+
const selectedChildLabel = selected ? addChildLabelOf(selected) : null;
|
|
408
|
+
|
|
409
|
+
const stats =
|
|
410
|
+
selected && selectedRollup
|
|
411
|
+
? [
|
|
412
|
+
{ label: "Gán trực tiếp", value: selected.memberCount ?? 0 },
|
|
413
|
+
{ label: "Quản trị", value: selected.adminCount ?? 0 },
|
|
414
|
+
...(selectedRollup.units > 0
|
|
415
|
+
? [
|
|
416
|
+
{
|
|
417
|
+
label: (selectedChildLabel ?? "Cấp dưới") as string,
|
|
418
|
+
value: selectedRollup.units,
|
|
419
|
+
},
|
|
420
|
+
{ label: "Cả nhánh", value: selectedRollup.members },
|
|
421
|
+
]
|
|
422
|
+
: []),
|
|
423
|
+
]
|
|
424
|
+
: [];
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<div className="space-y-3">
|
|
428
|
+
<RbacPageBar
|
|
429
|
+
icon={<Network className="h-5 w-5 text-primary-foreground" />}
|
|
430
|
+
title={title}
|
|
431
|
+
subtitle={`${tree.length} tổ chức · ${total} workspace · ${totalMembers} người`}
|
|
432
|
+
>
|
|
433
|
+
{canCreate ? (
|
|
434
|
+
<Button size="sm" onClick={() => openCreate()} disabled={busy}>
|
|
435
|
+
<Plus className="h-3.5 w-3.5" />
|
|
436
|
+
<span className="ml-1.5">Thêm tổ chức</span>
|
|
437
|
+
</Button>
|
|
438
|
+
) : null}
|
|
439
|
+
</RbacPageBar>
|
|
440
|
+
|
|
441
|
+
<div className="grid gap-3 lg:mx-3 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:items-start">
|
|
442
|
+
{/* KHUNG TRÁI — điều hướng. Ô tìm kiếm ở đây chứ không ở đầu trang: nó
|
|
443
|
+
lọc cây, không lọc thứ đang mở bên phải. */}
|
|
444
|
+
<div
|
|
445
|
+
className={cn("space-y-2", busy && "pointer-events-none opacity-60")}
|
|
446
|
+
>
|
|
447
|
+
<div className="relative">
|
|
448
|
+
<Search className="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
449
|
+
<Input
|
|
450
|
+
placeholder="Tìm theo tên hoặc mã…"
|
|
451
|
+
className="h-8 w-full rounded-md border border-border bg-card pl-8 pr-8 text-sm"
|
|
452
|
+
value={search}
|
|
453
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
454
|
+
/>
|
|
455
|
+
{search ? (
|
|
456
|
+
<button
|
|
457
|
+
type="button"
|
|
458
|
+
onClick={() => setSearch("")}
|
|
459
|
+
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
460
|
+
title="Xóa tìm kiếm"
|
|
461
|
+
>
|
|
462
|
+
<X className="h-3.5 w-3.5" />
|
|
463
|
+
</button>
|
|
464
|
+
) : null}
|
|
465
|
+
</div>
|
|
466
|
+
{search ? (
|
|
467
|
+
<p className="px-0.5 text-xs tabular-nums text-muted-foreground">
|
|
468
|
+
{countTreeNodes(visibleRoots)}/{total} khớp
|
|
469
|
+
</p>
|
|
470
|
+
) : null}
|
|
471
|
+
|
|
472
|
+
{busy && tree.length === 0 ? (
|
|
473
|
+
<div className="space-y-2 rounded-xl border border-border bg-card p-4">
|
|
474
|
+
<Skeleton className="h-9 w-full" />
|
|
475
|
+
<Skeleton className="h-9 w-5/6" />
|
|
476
|
+
</div>
|
|
477
|
+
) : visibleRoots.length === 0 ? (
|
|
478
|
+
// Trạng thái rỗng phải DẠY việc: trang này dùng vài lần một năm,
|
|
479
|
+
// không ai nhớ workspace để làm gì và ai mới cần đặt làm quản trị.
|
|
480
|
+
<div className="rounded-xl border border-border bg-card px-5 py-10 text-center">
|
|
481
|
+
<Network className="mx-auto h-8 w-8 text-muted-foreground/60" />
|
|
482
|
+
<p className="mt-3 text-sm font-medium">
|
|
483
|
+
{search
|
|
484
|
+
? "Không có workspace nào khớp."
|
|
485
|
+
: "Chưa có workspace nào."}
|
|
486
|
+
</p>
|
|
487
|
+
<p className="mx-auto mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
|
|
488
|
+
Mỗi workspace là một phạm vi dữ liệu riêng. Người được gán vào
|
|
489
|
+
đó chỉ thấy dữ liệu của đúng phạm vi ấy; ai được đặt làm{" "}
|
|
490
|
+
<strong className="font-medium text-foreground">
|
|
491
|
+
quản trị
|
|
492
|
+
</strong>{" "}
|
|
493
|
+
thì tự tạo tài khoản và phân quyền được cho người trong phạm vi
|
|
494
|
+
của mình.
|
|
495
|
+
</p>
|
|
496
|
+
{canCreate && !search ? (
|
|
497
|
+
<Button
|
|
498
|
+
size="sm"
|
|
499
|
+
variant="outline"
|
|
500
|
+
className="mt-4"
|
|
501
|
+
onClick={() => openCreate()}
|
|
502
|
+
disabled={busy}
|
|
503
|
+
>
|
|
504
|
+
<Plus className="h-3.5 w-3.5" />
|
|
505
|
+
<span className="ml-1.5">Thêm tổ chức</span>
|
|
506
|
+
</Button>
|
|
507
|
+
) : null}
|
|
508
|
+
</div>
|
|
509
|
+
) : (
|
|
510
|
+
visibleRoots.map((root) => (
|
|
511
|
+
<WorkspaceOrgBlock
|
|
512
|
+
key={root.id}
|
|
513
|
+
root={root}
|
|
514
|
+
annotation={rootAnnotations?.[root.code]}
|
|
515
|
+
activeId={selectedId}
|
|
516
|
+
onSelect={selectNode}
|
|
517
|
+
childKindLabel={addChildLabelOf}
|
|
518
|
+
/>
|
|
519
|
+
))
|
|
520
|
+
)}
|
|
521
|
+
</div>
|
|
522
|
+
|
|
523
|
+
{/* KHUNG PHẢI — hồ sơ của đúng một nút. Dính trên khi cuộn: cột trái có
|
|
524
|
+
thể dài hơn màn hình, mà thứ người ta đang làm việc là ở đây. */}
|
|
525
|
+
<div
|
|
526
|
+
ref={detailRef}
|
|
527
|
+
className={cn(
|
|
528
|
+
// Gạch dưới trong `calc` PHẢI có dấu cách hai bên (`100dvh_-_7rem`):
|
|
529
|
+
// `calc(100dvh-7rem)` là CSS sai, Tailwind bỏ qua cả class mà không
|
|
530
|
+
// báo gì — sinh ra khung phải cao vượt màn hình rất khó truy.
|
|
531
|
+
"flex min-h-[24rem] flex-col overflow-hidden rounded-xl border border-border bg-card lg:sticky lg:top-3 lg:max-h-[calc(100dvh_-_7rem)]",
|
|
532
|
+
busy && "pointer-events-none opacity-60",
|
|
533
|
+
)}
|
|
534
|
+
>
|
|
535
|
+
{selected ? (
|
|
536
|
+
<>
|
|
537
|
+
<div className="space-y-3 border-b border-border p-4">
|
|
538
|
+
{breadcrumb.length > 1 ? (
|
|
539
|
+
<nav className="flex flex-wrap items-center gap-0.5 text-xs text-muted-foreground">
|
|
540
|
+
{breadcrumb.slice(0, -1).map((node) => (
|
|
541
|
+
<React.Fragment key={node.id}>
|
|
542
|
+
<button
|
|
543
|
+
type="button"
|
|
544
|
+
onClick={() => setSelectedId(node.id)}
|
|
545
|
+
className="rounded px-1 py-0.5 hover:bg-muted hover:text-foreground"
|
|
546
|
+
>
|
|
547
|
+
{node.name}
|
|
548
|
+
</button>
|
|
549
|
+
<ChevronRight className="h-3 w-3" />
|
|
550
|
+
</React.Fragment>
|
|
551
|
+
))}
|
|
552
|
+
<span className="px-1 py-0.5 text-foreground">
|
|
553
|
+
{selected.name}
|
|
554
|
+
</span>
|
|
555
|
+
</nav>
|
|
556
|
+
) : null}
|
|
557
|
+
|
|
558
|
+
<div className="flex flex-wrap items-start gap-x-3 gap-y-2">
|
|
559
|
+
<div className="min-w-0 flex-1">
|
|
560
|
+
<h2 className="truncate text-lg font-semibold">
|
|
561
|
+
{selected.name}
|
|
562
|
+
</h2>
|
|
563
|
+
{/* `div` chứ không `p`: `Badge` render ra `div`, lồng trong
|
|
564
|
+
`p` là HTML sai và Next sẽ báo lệch hydrate. */}
|
|
565
|
+
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
|
566
|
+
<span className="tabular-nums">{selected.code}</span>
|
|
567
|
+
{selectedKindLabel ? (
|
|
568
|
+
<>
|
|
569
|
+
<span aria-hidden>·</span>
|
|
570
|
+
<Badge variant="outline">{selectedKindLabel}</Badge>
|
|
571
|
+
</>
|
|
572
|
+
) : null}
|
|
573
|
+
{selected.isActive === false ? (
|
|
574
|
+
<>
|
|
575
|
+
<span aria-hidden>·</span>
|
|
576
|
+
<span>đã ngừng hoạt động</span>
|
|
577
|
+
</>
|
|
578
|
+
) : null}
|
|
579
|
+
</div>
|
|
580
|
+
</div>
|
|
581
|
+
|
|
582
|
+
{canManageSelected ? (
|
|
583
|
+
<div className="flex shrink-0 items-center gap-1.5">
|
|
584
|
+
{canCreate && selectedChildLabel ? (
|
|
585
|
+
<Button
|
|
586
|
+
size="sm"
|
|
587
|
+
variant="outline"
|
|
588
|
+
className="h-8"
|
|
589
|
+
onClick={() => openCreate(selected)}
|
|
590
|
+
>
|
|
591
|
+
<Plus className="h-3.5 w-3.5" />
|
|
592
|
+
<span className="ml-1.5">
|
|
593
|
+
Thêm {selectedChildLabel.toLowerCase()}
|
|
594
|
+
</span>
|
|
595
|
+
</Button>
|
|
596
|
+
) : null}
|
|
597
|
+
<DropdownMenu>
|
|
598
|
+
<DropdownMenuTrigger asChild>
|
|
599
|
+
<Button
|
|
600
|
+
variant="ghost"
|
|
601
|
+
size="sm"
|
|
602
|
+
className="h-8 w-8 p-0 text-muted-foreground"
|
|
603
|
+
title="Thao tác khác"
|
|
604
|
+
>
|
|
605
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
606
|
+
</Button>
|
|
607
|
+
</DropdownMenuTrigger>
|
|
608
|
+
<DropdownMenuContent align="end">
|
|
609
|
+
<DropdownMenuItem
|
|
610
|
+
onClick={() =>
|
|
611
|
+
setForm({
|
|
612
|
+
mode: "edit",
|
|
613
|
+
id: selected.id,
|
|
614
|
+
parentId: null,
|
|
615
|
+
code: selected.code,
|
|
616
|
+
name: selected.name,
|
|
617
|
+
kind: selected.kind ?? kinds[0]?.key ?? "unit",
|
|
618
|
+
})
|
|
619
|
+
}
|
|
620
|
+
>
|
|
621
|
+
Sửa thông tin
|
|
622
|
+
</DropdownMenuItem>
|
|
623
|
+
<DropdownMenuItem
|
|
624
|
+
onClick={() => {
|
|
625
|
+
setMoving(selected);
|
|
626
|
+
setMoveTarget("__root__");
|
|
627
|
+
}}
|
|
628
|
+
>
|
|
629
|
+
Chuyển nhánh
|
|
630
|
+
</DropdownMenuItem>
|
|
631
|
+
{selected.isActive !== false ? (
|
|
632
|
+
<>
|
|
633
|
+
<DropdownMenuSeparator />
|
|
634
|
+
<DropdownMenuItem
|
|
635
|
+
className="text-destructive focus:text-destructive"
|
|
636
|
+
onClick={() => setDeactivating(selected)}
|
|
637
|
+
>
|
|
638
|
+
Ngừng hoạt động
|
|
639
|
+
</DropdownMenuItem>
|
|
640
|
+
</>
|
|
641
|
+
) : null}
|
|
642
|
+
</DropdownMenuContent>
|
|
643
|
+
</DropdownMenu>
|
|
644
|
+
</div>
|
|
645
|
+
) : (
|
|
646
|
+
// Nói THẲNG là chỉ xem, đừng để nút biến mất im lặng: người
|
|
647
|
+
// dùng sẽ đi tìm nút "Thêm" mà không hiểu vì sao không có.
|
|
648
|
+
<p className="shrink-0 self-center rounded-md border border-border bg-muted/40 px-2 py-1 text-xs text-muted-foreground">
|
|
649
|
+
Chỉ xem — ngoài phạm vi quản trị của bạn
|
|
650
|
+
</p>
|
|
651
|
+
)}
|
|
652
|
+
</div>
|
|
653
|
+
|
|
654
|
+
<StatStrip items={stats} />
|
|
655
|
+
</div>
|
|
656
|
+
|
|
657
|
+
<WorkspaceMembersPanel
|
|
658
|
+
key={selected.id}
|
|
659
|
+
workspaceId={selected.id}
|
|
660
|
+
workspaceName={selected.name}
|
|
661
|
+
kindLabel={selectedKindLabel}
|
|
662
|
+
canManage={canManageSelected}
|
|
663
|
+
apiEndpoint={apiEndpoint}
|
|
664
|
+
onCountChange={applyMemberCount}
|
|
665
|
+
/>
|
|
666
|
+
</>
|
|
667
|
+
) : (
|
|
668
|
+
<div className="m-auto max-w-sm px-6 py-12 text-center">
|
|
669
|
+
<Network className="mx-auto h-8 w-8 text-muted-foreground/60" />
|
|
670
|
+
<p className="mt-3 text-sm font-medium">
|
|
671
|
+
Chọn một tổ chức hoặc bộ phận
|
|
672
|
+
</p>
|
|
673
|
+
<p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
|
|
674
|
+
Bấm vào một dòng bên trái để xem ai đang ở trong phạm vi dữ liệu
|
|
675
|
+
đó và thêm người vào.
|
|
676
|
+
</p>
|
|
677
|
+
</div>
|
|
678
|
+
)}
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
|
|
682
|
+
<Dialog
|
|
683
|
+
open={form !== null}
|
|
684
|
+
onOpenChange={(open) => !open && setForm(null)}
|
|
685
|
+
>
|
|
686
|
+
<DialogContent>
|
|
687
|
+
<DialogHeader>
|
|
688
|
+
<DialogTitle>
|
|
689
|
+
{form?.mode === "create" ? "Thêm workspace" : "Sửa workspace"}
|
|
690
|
+
</DialogTitle>
|
|
691
|
+
{form?.parentName ? (
|
|
692
|
+
<DialogDescription>
|
|
693
|
+
Đặt dưới <strong>{form.parentName}</strong>
|
|
694
|
+
</DialogDescription>
|
|
695
|
+
) : null}
|
|
696
|
+
</DialogHeader>
|
|
697
|
+
<div className="space-y-3">
|
|
698
|
+
<div className="space-y-1.5">
|
|
699
|
+
<Label htmlFor="ws-code">Mã</Label>
|
|
700
|
+
<Input
|
|
701
|
+
id="ws-code"
|
|
702
|
+
value={form?.code ?? ""}
|
|
703
|
+
onChange={(e) =>
|
|
704
|
+
setForm((f) => (f ? { ...f, code: e.target.value } : f))
|
|
705
|
+
}
|
|
706
|
+
placeholder="VD: SPA-QC"
|
|
707
|
+
/>
|
|
708
|
+
</div>
|
|
709
|
+
<div className="space-y-1.5">
|
|
710
|
+
<Label htmlFor="ws-name">Tên</Label>
|
|
711
|
+
<Input
|
|
712
|
+
id="ws-name"
|
|
713
|
+
value={form?.name ?? ""}
|
|
714
|
+
onChange={(e) =>
|
|
715
|
+
setForm((f) => (f ? { ...f, name: e.target.value } : f))
|
|
716
|
+
}
|
|
717
|
+
placeholder="VD: Phòng QC"
|
|
718
|
+
/>
|
|
719
|
+
</div>
|
|
720
|
+
{kindOptions.length > 1 ? (
|
|
721
|
+
<div className="space-y-1.5">
|
|
722
|
+
<Label htmlFor="ws-kind">Cấp</Label>
|
|
723
|
+
<Select
|
|
724
|
+
value={form?.kind}
|
|
725
|
+
onValueChange={(v) =>
|
|
726
|
+
setForm((f) => (f ? { ...f, kind: v } : f))
|
|
727
|
+
}
|
|
728
|
+
>
|
|
729
|
+
<SelectTrigger id="ws-kind">
|
|
730
|
+
<SelectValue placeholder="Chọn cấp" />
|
|
731
|
+
</SelectTrigger>
|
|
732
|
+
<SelectContent>
|
|
733
|
+
{kindOptions.map((kind) => (
|
|
734
|
+
<SelectItem key={kind.key} value={kind.key}>
|
|
735
|
+
{kind.label}
|
|
736
|
+
</SelectItem>
|
|
737
|
+
))}
|
|
738
|
+
</SelectContent>
|
|
739
|
+
</Select>
|
|
740
|
+
</div>
|
|
741
|
+
) : null}
|
|
742
|
+
</div>
|
|
743
|
+
<DialogFooter>
|
|
744
|
+
<Button variant="outline" onClick={() => setForm(null)}>
|
|
745
|
+
Hủy
|
|
746
|
+
</Button>
|
|
747
|
+
<Button onClick={saveForm} disabled={busy}>
|
|
748
|
+
Lưu
|
|
749
|
+
</Button>
|
|
750
|
+
</DialogFooter>
|
|
751
|
+
</DialogContent>
|
|
752
|
+
</Dialog>
|
|
753
|
+
|
|
754
|
+
<Dialog
|
|
755
|
+
open={moving !== null}
|
|
756
|
+
onOpenChange={(open) => !open && setMoving(null)}
|
|
757
|
+
>
|
|
758
|
+
<DialogContent>
|
|
759
|
+
<DialogHeader>
|
|
760
|
+
<DialogTitle>Chuyển nhánh</DialogTitle>
|
|
761
|
+
<DialogDescription>
|
|
762
|
+
Chuyển <strong>{moving?.name}</strong> cùng toàn bộ cấp dưới sang
|
|
763
|
+
nhánh khác.
|
|
764
|
+
</DialogDescription>
|
|
765
|
+
</DialogHeader>
|
|
766
|
+
<Select value={moveTarget} onValueChange={setMoveTarget}>
|
|
767
|
+
<SelectTrigger>
|
|
768
|
+
<SelectValue placeholder="Chọn nhánh cha" />
|
|
769
|
+
</SelectTrigger>
|
|
770
|
+
<SelectContent>
|
|
771
|
+
<SelectItem value="__root__">— Cấp cao nhất —</SelectItem>
|
|
772
|
+
{moveOptions.map((item) => (
|
|
773
|
+
<SelectItem key={item.id} value={item.id}>
|
|
774
|
+
{" ".repeat(item.depth * 2)}
|
|
775
|
+
{item.name}
|
|
776
|
+
</SelectItem>
|
|
777
|
+
))}
|
|
778
|
+
</SelectContent>
|
|
779
|
+
</Select>
|
|
780
|
+
<DialogFooter>
|
|
781
|
+
<Button variant="outline" onClick={() => setMoving(null)}>
|
|
782
|
+
Hủy
|
|
783
|
+
</Button>
|
|
784
|
+
<Button onClick={confirmMove} disabled={busy}>
|
|
785
|
+
Chuyển
|
|
786
|
+
</Button>
|
|
787
|
+
</DialogFooter>
|
|
788
|
+
</DialogContent>
|
|
789
|
+
</Dialog>
|
|
790
|
+
|
|
791
|
+
<ConfirmDialog
|
|
792
|
+
open={deactivating !== null}
|
|
793
|
+
onOpenChange={(open) => !open && setDeactivating(null)}
|
|
794
|
+
title="Ngừng hoạt động workspace?"
|
|
795
|
+
description={`"${deactivating?.name}" và toàn bộ cấp dưới sẽ ngừng hoạt động. Dữ liệu cũ giữ nguyên, không bị xóa.`}
|
|
796
|
+
confirmText="Ngừng hoạt động"
|
|
797
|
+
variant="destructive"
|
|
798
|
+
onConfirm={confirmDeactivate}
|
|
799
|
+
/>
|
|
800
|
+
</div>
|
|
801
|
+
);
|
|
802
|
+
}
|