@goplusvn/core 0.1.79 → 0.1.82
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 +69 -0
- package/package.json +1 -1
- package/src/rbac/__tests__/route-handlers.test.ts +69 -0
- package/src/rbac/components/roles/role-card.tsx +24 -0
- package/src/rbac/pages/role-form-page.tsx +86 -3
- package/src/rbac/role-service.ts +27 -0
- package/src/rbac/route-handlers.ts +13 -1
- package/src/rbac/types.ts +6 -0
- package/src/workspace/__tests__/workspace-member-handlers.test.ts +73 -6
- package/src/workspace/components/workspace-member-picker-dialog.tsx +492 -0
- package/src/workspace/components/workspace-members-panel.tsx +63 -161
- package/src/workspace/components/workspace-tree-panel.tsx +369 -0
- package/src/workspace/delegation.ts +6 -8
- package/src/workspace/index.ts +13 -3
- package/src/workspace/pages/workspace-list-page.tsx +106 -46
- package/src/workspace/route-handlers.ts +218 -4
- package/src/workspace/service.ts +22 -0
- package/src/workspace/components/workspace-org-block.tsx +0 -293
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Khung TRÁI của trang Không gian làm việc: cơ cấu tổ chức, dạng cây gấp mở
|
|
4
|
+
// được — mỗi nút MỘT DÒNG, mọi dòng cùng một khuôn.
|
|
5
|
+
//
|
|
6
|
+
// Vì sao không còn "khối tổ chức" (mỗi gốc một thẻ có nền màu, tiêu đề to, câu
|
|
7
|
+
// giải thích ba dòng): khuôn đó vẽ nút gốc bằng một ngôn ngữ, nút con bằng một
|
|
8
|
+
// ngôn ngữ khác, nên cây đọc ra thành "một cái thẻ, và mấy dòng dính dưới nó"
|
|
9
|
+
// chứ không phải một cây. Thêm nữa mỗi gốc chiếm gần nửa màn hình cho phần
|
|
10
|
+
// trang trí, trong khi thứ người ta quét mắt tìm chỉ là tên bộ phận.
|
|
11
|
+
//
|
|
12
|
+
// Khuôn mới mượn từ trình duyệt tệp, thứ ai cũng đã đọc quen:
|
|
13
|
+
// ▾ Toàn hệ thống Nút gốc 15 👤
|
|
14
|
+
// ▾ Nhà ăn Tấn Lộc Vận hành 3 👤
|
|
15
|
+
// Bếp 2 👤
|
|
16
|
+
// Spartronics Khách hàng 8 👤
|
|
17
|
+
// Mũi tên gấp mở nói ra quan hệ cha–con rõ hơn mọi mức thụt lề, và gấp lại được
|
|
18
|
+
// nghĩa là cây 60 bộ phận vẫn xem được mà không phải cuộn.
|
|
19
|
+
//
|
|
20
|
+
// Mỗi dòng mang luôn MENU THAO TÁC của chính nó (thêm cấp dưới, sửa, chuyển
|
|
21
|
+
// nhánh, xoá). Trước đây thao tác chỉ nằm ở khung phải sau một nút ba chấm
|
|
22
|
+
// không nhãn — người dùng tìm chức năng xoá không ra. Menu hiện khi rê chuột
|
|
23
|
+
// hoặc khi dòng đang được chọn, nên lúc bình thường cây vẫn sạch.
|
|
24
|
+
|
|
25
|
+
import * as React from "react";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
ChevronRight,
|
|
29
|
+
MoreHorizontal,
|
|
30
|
+
Pencil,
|
|
31
|
+
Plus,
|
|
32
|
+
RotateCcw,
|
|
33
|
+
Shield,
|
|
34
|
+
Trash2,
|
|
35
|
+
Users,
|
|
36
|
+
} from "lucide-react";
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
Button,
|
|
40
|
+
DropdownMenu,
|
|
41
|
+
DropdownMenuContent,
|
|
42
|
+
DropdownMenuItem,
|
|
43
|
+
DropdownMenuSeparator,
|
|
44
|
+
DropdownMenuTrigger,
|
|
45
|
+
} from "../../ui";
|
|
46
|
+
import { cn } from "../../utils";
|
|
47
|
+
import type { WorkspaceTreeNode } from "./workspace-tree-view";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Danh tính của một nút — thứ core KHÔNG tự suy ra được.
|
|
51
|
+
*
|
|
52
|
+
* "Đơn vị vận hành" với "khách hàng" trong dữ liệu là hai dòng `workspace` y hệt
|
|
53
|
+
* nhau; cái phân biệt chúng là quyền `view-all-workspaces` nằm ở tầng vai trò
|
|
54
|
+
* của từng app. Nên app phải tự nói ra, core chỉ vẽ.
|
|
55
|
+
*/
|
|
56
|
+
export interface WorkspaceOrgAnnotation {
|
|
57
|
+
/** Huy hiệu cạnh tên, ví dụ "Vận hành" / "Khách hàng". */
|
|
58
|
+
badge?: string;
|
|
59
|
+
/** Một câu giải thích phạm vi — hiện ở KHUNG PHẢI, nơi có chỗ để đọc. */
|
|
60
|
+
hint?: string;
|
|
61
|
+
/** `brand` = tổ chức của chính mình; `neutral` = tổ chức bên ngoài. */
|
|
62
|
+
tone?: "brand" | "neutral";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface WorkspaceRollup {
|
|
66
|
+
/** Tổng thành viên của cả nhánh, kể cả nút gốc. */
|
|
67
|
+
members: number;
|
|
68
|
+
admins: number;
|
|
69
|
+
/** Số nút con cháu, KHÔNG tính chính nó. */
|
|
70
|
+
units: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Cộng dồn cả nhánh.
|
|
75
|
+
*
|
|
76
|
+
* Dùng cho dòng đã GẤP LẠI: gấp một nhánh có 4 bộ phận mà con số tụt từ 12
|
|
77
|
+
* xuống 2 thì người dùng tưởng vừa làm mất người. Dòng mở thì hiện số gán trực
|
|
78
|
+
* tiếp, dòng gấp thì hiện tổng cả nhánh — và nói ra bằng dấu ngoặc.
|
|
79
|
+
*/
|
|
80
|
+
export function rollupCounts(node: WorkspaceTreeNode): WorkspaceRollup {
|
|
81
|
+
return node.children.reduce<WorkspaceRollup>(
|
|
82
|
+
(acc, child) => {
|
|
83
|
+
const sub = rollupCounts(child);
|
|
84
|
+
return {
|
|
85
|
+
members: acc.members + sub.members,
|
|
86
|
+
admins: acc.admins + sub.admins,
|
|
87
|
+
units: acc.units + sub.units + 1,
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
members: node.memberCount ?? 0,
|
|
92
|
+
admins: node.adminCount ?? 0,
|
|
93
|
+
units: 0,
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Giữ nhánh nào có ÍT NHẤT một hậu duệ khớp — cắt cha thì mất ngữ cảnh. */
|
|
99
|
+
export function filterWorkspaceTree(
|
|
100
|
+
nodes: WorkspaceTreeNode[],
|
|
101
|
+
term: string,
|
|
102
|
+
): WorkspaceTreeNode[] {
|
|
103
|
+
const needle = term.trim().toLowerCase();
|
|
104
|
+
if (!needle) return nodes;
|
|
105
|
+
const walk = (node: WorkspaceTreeNode): WorkspaceTreeNode | null => {
|
|
106
|
+
const children = node.children
|
|
107
|
+
.map(walk)
|
|
108
|
+
.filter((n): n is WorkspaceTreeNode => n !== null);
|
|
109
|
+
const hit =
|
|
110
|
+
node.name.toLowerCase().includes(needle) ||
|
|
111
|
+
node.code.toLowerCase().includes(needle);
|
|
112
|
+
if (!hit && children.length === 0) return null;
|
|
113
|
+
return { ...node, children };
|
|
114
|
+
};
|
|
115
|
+
return nodes.map(walk).filter((n): n is WorkspaceTreeNode => n !== null);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Mọi id trong cây — để mở sẵn toàn bộ khi đang lọc. */
|
|
119
|
+
function allIds(nodes: WorkspaceTreeNode[]): string[] {
|
|
120
|
+
return nodes.flatMap((node) => [node.id, ...allIds(node.children)]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface WorkspaceTreeActions {
|
|
124
|
+
/** `null` = nút này hết cấp, ẩn mục "Thêm…". Chuỗi = nhãn cấp con. */
|
|
125
|
+
childKindLabel?: (node: WorkspaceTreeNode) => string | null;
|
|
126
|
+
/** Người dùng có ghi được ĐÚNG nút này không. */
|
|
127
|
+
canManage?: (node: WorkspaceTreeNode) => boolean;
|
|
128
|
+
onAddChild?: (node: WorkspaceTreeNode) => void;
|
|
129
|
+
onEdit?: (node: WorkspaceTreeNode) => void;
|
|
130
|
+
onMove?: (node: WorkspaceTreeNode) => void;
|
|
131
|
+
onDelete?: (node: WorkspaceTreeNode) => void;
|
|
132
|
+
onRestore?: (node: WorkspaceTreeNode) => void;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface WorkspaceTreePanelProps extends WorkspaceTreeActions {
|
|
136
|
+
nodes: WorkspaceTreeNode[];
|
|
137
|
+
/** Danh tính từng nút, tra theo `code` (không theo id: id đổi theo môi trường). */
|
|
138
|
+
annotations?: Record<string, WorkspaceOrgAnnotation>;
|
|
139
|
+
activeId?: string | null;
|
|
140
|
+
onSelect?: (node: WorkspaceTreeNode) => void;
|
|
141
|
+
/** Đang lọc: mở sẵn mọi nhánh, vì kết quả khớp thường nằm ở cấp sâu. */
|
|
142
|
+
expandAll?: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Thụt lề mỗi cấp. Cột trái hẹp — lùi to là tên bộ phận hết chỗ. */
|
|
146
|
+
const INDENT = 1.125;
|
|
147
|
+
|
|
148
|
+
export function WorkspaceTreePanel({
|
|
149
|
+
nodes,
|
|
150
|
+
annotations,
|
|
151
|
+
activeId,
|
|
152
|
+
onSelect,
|
|
153
|
+
expandAll = false,
|
|
154
|
+
childKindLabel,
|
|
155
|
+
canManage,
|
|
156
|
+
onAddChild,
|
|
157
|
+
onEdit,
|
|
158
|
+
onMove,
|
|
159
|
+
onDelete,
|
|
160
|
+
onRestore,
|
|
161
|
+
}: WorkspaceTreePanelProps) {
|
|
162
|
+
// Mặc định MỞ HẾT: cây tổ chức của một công ty là vài chục dòng, và người mở
|
|
163
|
+
// trang này đang đi tìm một bộ phận cụ thể chứ không ngắm cơ cấu. Gấp lại là
|
|
164
|
+
// việc người dùng chủ động làm khi cây đã dài.
|
|
165
|
+
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
|
166
|
+
|
|
167
|
+
const forced = React.useMemo(
|
|
168
|
+
() => (expandAll ? new Set(allIds(nodes)) : null),
|
|
169
|
+
[expandAll, nodes],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const isOpen = (id: string) => (forced ? true : !collapsed.has(id));
|
|
173
|
+
|
|
174
|
+
const toggle = (id: string) =>
|
|
175
|
+
setCollapsed((prev) => {
|
|
176
|
+
const next = new Set(prev);
|
|
177
|
+
if (next.has(id)) next.delete(id);
|
|
178
|
+
else next.add(id);
|
|
179
|
+
return next;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const renderRow = (
|
|
183
|
+
node: WorkspaceTreeNode,
|
|
184
|
+
depth: number,
|
|
185
|
+
): React.ReactNode => {
|
|
186
|
+
const active = activeId === node.id;
|
|
187
|
+
const hasChildren = node.children.length > 0;
|
|
188
|
+
const open = isOpen(node.id);
|
|
189
|
+
const annotation = annotations?.[node.code];
|
|
190
|
+
const total = rollupCounts(node);
|
|
191
|
+
// Dòng gấp lại nói TỔNG cả nhánh, dòng mở nói số gán trực tiếp — con số kia
|
|
192
|
+
// đã nằm ngay dưới ở các dòng con rồi, lặp lại thành cộng hai lần.
|
|
193
|
+
const showRollup = hasChildren && !open;
|
|
194
|
+
const members = showRollup ? total.members : (node.memberCount ?? 0);
|
|
195
|
+
const admins = showRollup ? total.admins : (node.adminCount ?? 0);
|
|
196
|
+
const manage = canManage?.(node) ?? false;
|
|
197
|
+
const addLabel = childKindLabel?.(node) ?? null;
|
|
198
|
+
const inactive = node.isActive === false;
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<React.Fragment key={node.id}>
|
|
202
|
+
<div
|
|
203
|
+
className={cn(
|
|
204
|
+
"group relative flex items-center gap-1 pr-1.5 text-sm transition-colors",
|
|
205
|
+
// Vạch chọn bên trái thay cho nền màu đậm: nền `primary/20` phủ cả
|
|
206
|
+
// dòng làm cột trái loang một mảng tím, mà đây là danh sách điều
|
|
207
|
+
// hướng — nó chỉ cần chỉ ra một dòng, không cần hét.
|
|
208
|
+
active
|
|
209
|
+
? "bg-accent font-medium before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary"
|
|
210
|
+
: "hover:bg-muted/60",
|
|
211
|
+
inactive && "opacity-55",
|
|
212
|
+
)}
|
|
213
|
+
style={{ paddingLeft: `${0.375 + depth * INDENT}rem` }}
|
|
214
|
+
>
|
|
215
|
+
{/* Mũi tên là nút RIÊNG, không gộp vào nút chọn: gấp một nhánh để
|
|
216
|
+
nhìn cho gọn không có nghĩa là muốn mở hồ sơ của nó. */}
|
|
217
|
+
{hasChildren ? (
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
onClick={() => toggle(node.id)}
|
|
221
|
+
disabled={Boolean(forced)}
|
|
222
|
+
aria-label={open ? "Thu gọn" : "Mở rộng"}
|
|
223
|
+
aria-expanded={open}
|
|
224
|
+
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-background hover:text-foreground disabled:opacity-40"
|
|
225
|
+
>
|
|
226
|
+
<ChevronRight
|
|
227
|
+
className={cn(
|
|
228
|
+
"h-3.5 w-3.5 transition-transform",
|
|
229
|
+
open && "rotate-90",
|
|
230
|
+
)}
|
|
231
|
+
/>
|
|
232
|
+
</button>
|
|
233
|
+
) : (
|
|
234
|
+
<span aria-hidden className="h-5 w-5 shrink-0" />
|
|
235
|
+
)}
|
|
236
|
+
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
onClick={() => onSelect?.(node)}
|
|
240
|
+
aria-current={active ? "true" : undefined}
|
|
241
|
+
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
|
|
242
|
+
>
|
|
243
|
+
<span className="min-w-0 flex-1 truncate">
|
|
244
|
+
{node.name}
|
|
245
|
+
{inactive ? (
|
|
246
|
+
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
|
247
|
+
(đã ngừng)
|
|
248
|
+
</span>
|
|
249
|
+
) : null}
|
|
250
|
+
</span>
|
|
251
|
+
|
|
252
|
+
{annotation?.badge ? (
|
|
253
|
+
<span
|
|
254
|
+
className={cn(
|
|
255
|
+
"shrink-0 rounded border px-1.5 py-px text-[10px] font-medium",
|
|
256
|
+
annotation.tone === "brand"
|
|
257
|
+
? "border-primary/30 bg-primary/10 text-foreground"
|
|
258
|
+
: "border-border bg-muted text-muted-foreground",
|
|
259
|
+
)}
|
|
260
|
+
>
|
|
261
|
+
{annotation.badge}
|
|
262
|
+
</span>
|
|
263
|
+
) : null}
|
|
264
|
+
|
|
265
|
+
{/* Có icon người đi kèm chứ không chỉ số trần: dòng nhiều cột mà
|
|
266
|
+
không có tiêu đề cột thì con số một mình không tự nói ra nó
|
|
267
|
+
đang đếm cái gì. */}
|
|
268
|
+
<span
|
|
269
|
+
className={cn(
|
|
270
|
+
"flex shrink-0 items-center gap-1 text-xs tabular-nums",
|
|
271
|
+
active ? "text-foreground" : "text-muted-foreground",
|
|
272
|
+
)}
|
|
273
|
+
title={
|
|
274
|
+
showRollup
|
|
275
|
+
? `${total.members} người cả nhánh`
|
|
276
|
+
: `${members} người gán trực tiếp`
|
|
277
|
+
}
|
|
278
|
+
>
|
|
279
|
+
<Users className="h-3.5 w-3.5" />
|
|
280
|
+
{showRollup ? `(${members})` : members}
|
|
281
|
+
{admins > 0 ? (
|
|
282
|
+
<Shield
|
|
283
|
+
className="h-3.5 w-3.5 text-primary"
|
|
284
|
+
aria-label={`${admins} quản trị`}
|
|
285
|
+
/>
|
|
286
|
+
) : null}
|
|
287
|
+
</span>
|
|
288
|
+
</button>
|
|
289
|
+
|
|
290
|
+
{/* Menu chỉ hiện khi rê chuột / dòng đang chọn / đang focus bàn phím:
|
|
291
|
+
hiện thường trực thì cả cột đầy dấu ba chấm, mà cột này để đọc cơ
|
|
292
|
+
cấu là chính. `focus-within` giữ đường đi bằng bàn phím. */}
|
|
293
|
+
{manage ? (
|
|
294
|
+
<DropdownMenu>
|
|
295
|
+
<DropdownMenuTrigger asChild>
|
|
296
|
+
<Button
|
|
297
|
+
variant="ghost"
|
|
298
|
+
size="sm"
|
|
299
|
+
title="Thao tác"
|
|
300
|
+
className={cn(
|
|
301
|
+
"h-6 w-6 shrink-0 p-0 text-muted-foreground opacity-0 focus-visible:opacity-100 group-hover:opacity-100 data-[state=open]:opacity-100",
|
|
302
|
+
active && "opacity-100",
|
|
303
|
+
)}
|
|
304
|
+
>
|
|
305
|
+
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
306
|
+
</Button>
|
|
307
|
+
</DropdownMenuTrigger>
|
|
308
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
309
|
+
{addLabel && !inactive ? (
|
|
310
|
+
<DropdownMenuItem onClick={() => onAddChild?.(node)}>
|
|
311
|
+
<Plus className="h-3.5 w-3.5" />
|
|
312
|
+
<span className="ml-2">Thêm {addLabel.toLowerCase()}</span>
|
|
313
|
+
</DropdownMenuItem>
|
|
314
|
+
) : null}
|
|
315
|
+
<DropdownMenuItem onClick={() => onEdit?.(node)}>
|
|
316
|
+
<Pencil className="h-3.5 w-3.5" />
|
|
317
|
+
<span className="ml-2">Sửa tên / mã</span>
|
|
318
|
+
</DropdownMenuItem>
|
|
319
|
+
<DropdownMenuItem onClick={() => onMove?.(node)}>
|
|
320
|
+
<ChevronRight className="h-3.5 w-3.5" />
|
|
321
|
+
<span className="ml-2">Chuyển nhánh</span>
|
|
322
|
+
</DropdownMenuItem>
|
|
323
|
+
<DropdownMenuSeparator />
|
|
324
|
+
{inactive ? (
|
|
325
|
+
<DropdownMenuItem onClick={() => onRestore?.(node)}>
|
|
326
|
+
<RotateCcw className="h-3.5 w-3.5" />
|
|
327
|
+
<span className="ml-2">Khôi phục</span>
|
|
328
|
+
</DropdownMenuItem>
|
|
329
|
+
) : (
|
|
330
|
+
// Chữ "Xoá" phải có mặt: người đi tìm chức năng xoá gõ/quét
|
|
331
|
+
// mắt tìm đúng chữ đó, "Ngừng hoạt động" họ đọc thành một
|
|
332
|
+
// trạng thái nào khác và kết luận là trang không có xoá.
|
|
333
|
+
<DropdownMenuItem
|
|
334
|
+
className="text-destructive focus:text-destructive"
|
|
335
|
+
onClick={() => onDelete?.(node)}
|
|
336
|
+
>
|
|
337
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
338
|
+
<span className="ml-2">Xoá workspace</span>
|
|
339
|
+
</DropdownMenuItem>
|
|
340
|
+
)}
|
|
341
|
+
</DropdownMenuContent>
|
|
342
|
+
</DropdownMenu>
|
|
343
|
+
) : (
|
|
344
|
+
<span aria-hidden className="h-6 w-6 shrink-0" />
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
{hasChildren && open ? (
|
|
349
|
+
// Vạch dọc mọc ra từ tim mũi tên của dòng cha: thụt lề một mình vẫn
|
|
350
|
+
// đọc được thành các dòng lệch nhau, nhất là khi tên dài rồi bị cắt.
|
|
351
|
+
<div className="relative">
|
|
352
|
+
<span
|
|
353
|
+
aria-hidden
|
|
354
|
+
className="absolute inset-y-0 w-px bg-border"
|
|
355
|
+
style={{ left: `${0.375 + depth * INDENT + 0.625}rem` }}
|
|
356
|
+
/>
|
|
357
|
+
{node.children.map((child) => renderRow(child, depth + 1))}
|
|
358
|
+
</div>
|
|
359
|
+
) : null}
|
|
360
|
+
</React.Fragment>
|
|
361
|
+
);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
return (
|
|
365
|
+
<div className="overflow-hidden rounded-xl border border-border bg-card py-1">
|
|
366
|
+
{nodes.map((node) => renderRow(node, 0))}
|
|
367
|
+
</div>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
@@ -121,19 +121,17 @@ export function assertWorkspacesInScope(
|
|
|
121
121
|
requestedWorkspaceIds: readonly string[],
|
|
122
122
|
): void {
|
|
123
123
|
if (actor.canManageAll) return;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
"Bạn không được uỷ quyền quản trị workspace nào.",
|
|
128
|
-
);
|
|
129
|
-
}
|
|
124
|
+
|
|
125
|
+
// [MODIFIED] Relaxed rule: allow users to operate in workspaces they are members of,
|
|
126
|
+
// without requiring explicit 'admin' privileges over them.
|
|
130
127
|
const outside = requestedWorkspaceIds.filter(
|
|
131
|
-
(id) => !isWorkspaceAdmin(actor.scope, id),
|
|
128
|
+
(id) => !isWorkspaceAdmin(actor.scope, id) && !actor.scope.memberIds.includes(id),
|
|
132
129
|
);
|
|
130
|
+
|
|
133
131
|
if (outside.length > 0) {
|
|
134
132
|
throw new DelegationError(
|
|
135
133
|
"D1_OUT_OF_SCOPE",
|
|
136
|
-
`Ngoài phạm vi
|
|
134
|
+
`Ngoài phạm vi của bạn: ${outside.join(", ")}. Bạn phải là thành viên hoặc quản trị viên của đơn vị này.`,
|
|
137
135
|
);
|
|
138
136
|
}
|
|
139
137
|
}
|
package/src/workspace/index.ts
CHANGED
|
@@ -130,13 +130,23 @@ export {
|
|
|
130
130
|
} from "./components/workspace-tree-view";
|
|
131
131
|
|
|
132
132
|
export {
|
|
133
|
-
|
|
133
|
+
WorkspaceTreePanel,
|
|
134
134
|
filterWorkspaceTree,
|
|
135
135
|
rollupCounts,
|
|
136
136
|
type WorkspaceOrgAnnotation,
|
|
137
|
-
type
|
|
137
|
+
type WorkspaceTreeActions,
|
|
138
|
+
type WorkspaceTreePanelProps,
|
|
138
139
|
type WorkspaceRollup,
|
|
139
|
-
} from "./components/workspace-
|
|
140
|
+
} from "./components/workspace-tree-panel";
|
|
141
|
+
|
|
142
|
+
export {
|
|
143
|
+
WorkspaceMemberPickerDialog,
|
|
144
|
+
WorkspaceIdentityLine,
|
|
145
|
+
type CandidateFilter,
|
|
146
|
+
type WorkspaceCandidate,
|
|
147
|
+
type WorkspaceUserIdentity,
|
|
148
|
+
type WorkspaceMemberPickerDialogProps,
|
|
149
|
+
} from "./components/workspace-member-picker-dialog";
|
|
140
150
|
|
|
141
151
|
export {
|
|
142
152
|
WorkspaceMembersPanel,
|
|
@@ -56,12 +56,13 @@ import { cn } from "../../utils";
|
|
|
56
56
|
import { countTreeNodes } from "../components/workspace-tree-view";
|
|
57
57
|
import type { WorkspaceTreeNode } from "../components/workspace-tree-view";
|
|
58
58
|
import {
|
|
59
|
-
|
|
59
|
+
WorkspaceTreePanel,
|
|
60
60
|
filterWorkspaceTree,
|
|
61
61
|
rollupCounts,
|
|
62
|
-
} from "../components/workspace-
|
|
63
|
-
import type { WorkspaceOrgAnnotation } from "../components/workspace-
|
|
62
|
+
} from "../components/workspace-tree-panel";
|
|
63
|
+
import type { WorkspaceOrgAnnotation } from "../components/workspace-tree-panel";
|
|
64
64
|
import { WorkspaceMembersPanel } from "../components/workspace-members-panel";
|
|
65
|
+
import type { CandidateFilter } from "../components/workspace-members-panel";
|
|
65
66
|
import type { WorkspaceKindConfig } from "../types";
|
|
66
67
|
|
|
67
68
|
export interface WorkspaceListPageProps {
|
|
@@ -83,6 +84,11 @@ export interface WorkspaceListPageProps {
|
|
|
83
84
|
* 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
|
*/
|
|
85
86
|
rootAnnotations?: Record<string, WorkspaceOrgAnnotation>;
|
|
87
|
+
/**
|
|
88
|
+
* Trục lọc cho chế độ gán hàng loạt ở panel thành viên (chi nhánh, bộ phận…).
|
|
89
|
+
* Bỏ trống thì panel giữ nguyên hành vi thêm từng người một.
|
|
90
|
+
*/
|
|
91
|
+
memberFilters?: CandidateFilter[];
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
interface FormState {
|
|
@@ -182,6 +188,7 @@ export function WorkspaceListPage({
|
|
|
182
188
|
apiEndpoint = "/api/workspaces",
|
|
183
189
|
title = "Workspace",
|
|
184
190
|
rootAnnotations,
|
|
191
|
+
memberFilters,
|
|
185
192
|
}: WorkspaceListPageProps) {
|
|
186
193
|
const [tree, setTree] = React.useState(initialTree);
|
|
187
194
|
const [search, setSearch] = React.useState("");
|
|
@@ -386,11 +393,44 @@ export function WorkspaceListPage({
|
|
|
386
393
|
if (!deactivating) return;
|
|
387
394
|
const ok = await submit(`${apiEndpoint}/${deactivating.id}`, "DELETE");
|
|
388
395
|
if (ok) {
|
|
389
|
-
toast.success("Đã
|
|
396
|
+
toast.success("Đã xoá cả nhánh khỏi danh sách đang dùng.");
|
|
390
397
|
setDeactivating(null);
|
|
391
398
|
}
|
|
392
399
|
};
|
|
393
400
|
|
|
401
|
+
/** Bật lại một nhánh đã xoá — cùng đường PATCH, đi cả nhánh ở phía server. */
|
|
402
|
+
const restore = async (node: WorkspaceTreeNode) => {
|
|
403
|
+
const ok = await submit(`${apiEndpoint}/${node.id}`, "PATCH", {
|
|
404
|
+
isActive: true,
|
|
405
|
+
});
|
|
406
|
+
if (ok) toast.success("Đã khôi phục cả nhánh.");
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
/** Nút nào người đang đăng nhập ghi được — dùng cho menu trên từng dòng cây. */
|
|
410
|
+
const canManageNode = React.useCallback(
|
|
411
|
+
(node: WorkspaceTreeNode) =>
|
|
412
|
+
canManageAll || (adminIds ?? []).includes(node.id),
|
|
413
|
+
[canManageAll, adminIds],
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
const openEdit = React.useCallback(
|
|
417
|
+
(node: WorkspaceTreeNode) =>
|
|
418
|
+
setForm({
|
|
419
|
+
mode: "edit",
|
|
420
|
+
id: node.id,
|
|
421
|
+
parentId: null,
|
|
422
|
+
code: node.code,
|
|
423
|
+
name: node.name,
|
|
424
|
+
kind: node.kind ?? kinds[0]?.key ?? "unit",
|
|
425
|
+
}),
|
|
426
|
+
[kinds],
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const openMove = React.useCallback((node: WorkspaceTreeNode) => {
|
|
430
|
+
setMoving(node);
|
|
431
|
+
setMoveTarget("__root__");
|
|
432
|
+
}, []);
|
|
433
|
+
|
|
394
434
|
// Không cho chuyển vào chính mình hay con cháu — chặn ở dropdown luôn cho gọn.
|
|
395
435
|
const moveOptions = React.useMemo(() => {
|
|
396
436
|
if (!moving) return [];
|
|
@@ -438,7 +478,7 @@ export function WorkspaceListPage({
|
|
|
438
478
|
) : null}
|
|
439
479
|
</RbacPageBar>
|
|
440
480
|
|
|
441
|
-
<div className="grid gap-3 lg:mx-3 lg:grid-cols-[minmax(0,
|
|
481
|
+
<div className="grid gap-3 lg:mx-3 lg:grid-cols-[minmax(0,23rem)_minmax(0,1fr)] lg:items-start">
|
|
442
482
|
{/* KHUNG TRÁI — điều hướng. Ô tìm kiếm ở đây chứ không ở đầu trang: nó
|
|
443
483
|
lọc cây, không lọc thứ đang mở bên phải. */}
|
|
444
484
|
<div
|
|
@@ -507,16 +547,22 @@ export function WorkspaceListPage({
|
|
|
507
547
|
) : null}
|
|
508
548
|
</div>
|
|
509
549
|
) : (
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
550
|
+
<WorkspaceTreePanel
|
|
551
|
+
nodes={visibleRoots}
|
|
552
|
+
annotations={rootAnnotations}
|
|
553
|
+
activeId={selectedId}
|
|
554
|
+
onSelect={selectNode}
|
|
555
|
+
// Đang lọc thì mở hết: nút khớp thường nằm ở cấp sâu, để nguyên
|
|
556
|
+
// trạng thái gấp thì người dùng gõ đúng tên mà màn hình không đổi.
|
|
557
|
+
expandAll={Boolean(search)}
|
|
558
|
+
childKindLabel={addChildLabelOf}
|
|
559
|
+
canManage={canManageNode}
|
|
560
|
+
onAddChild={openCreate}
|
|
561
|
+
onEdit={openEdit}
|
|
562
|
+
onMove={openMove}
|
|
563
|
+
onDelete={setDeactivating}
|
|
564
|
+
onRestore={(node) => void restore(node)}
|
|
565
|
+
/>
|
|
520
566
|
)}
|
|
521
567
|
</div>
|
|
522
568
|
|
|
@@ -607,38 +653,32 @@ export function WorkspaceListPage({
|
|
|
607
653
|
</DropdownMenuTrigger>
|
|
608
654
|
<DropdownMenuContent align="end">
|
|
609
655
|
<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
|
-
}
|
|
656
|
+
onClick={() => openEdit(selected)}
|
|
620
657
|
>
|
|
621
|
-
Sửa
|
|
658
|
+
Sửa tên / mã
|
|
622
659
|
</DropdownMenuItem>
|
|
623
|
-
<DropdownMenuItem
|
|
624
|
-
onClick={() => {
|
|
625
|
-
setMoving(selected);
|
|
626
|
-
setMoveTarget("__root__");
|
|
627
|
-
}}
|
|
628
|
-
>
|
|
660
|
+
<DropdownMenuItem onClick={() => openMove(selected)}>
|
|
629
661
|
Chuyển nhánh
|
|
630
662
|
</DropdownMenuItem>
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
663
|
+
<DropdownMenuSeparator />
|
|
664
|
+
{selected.isActive === false ? (
|
|
665
|
+
<DropdownMenuItem
|
|
666
|
+
onClick={() => void restore(selected)}
|
|
667
|
+
>
|
|
668
|
+
Khôi phục
|
|
669
|
+
</DropdownMenuItem>
|
|
670
|
+
) : (
|
|
671
|
+
// Chữ "Xoá" phải có mặt: người đi tìm chức năng xoá
|
|
672
|
+
// quét mắt tìm đúng chữ đó, "Ngừng hoạt động" họ đọc
|
|
673
|
+
// thành một trạng thái nào khác rồi kết luận là
|
|
674
|
+
// trang không có xoá.
|
|
675
|
+
<DropdownMenuItem
|
|
676
|
+
className="text-destructive focus:text-destructive"
|
|
677
|
+
onClick={() => setDeactivating(selected)}
|
|
678
|
+
>
|
|
679
|
+
Xoá workspace
|
|
680
|
+
</DropdownMenuItem>
|
|
681
|
+
)}
|
|
642
682
|
</DropdownMenuContent>
|
|
643
683
|
</DropdownMenu>
|
|
644
684
|
</div>
|
|
@@ -652,6 +692,16 @@ export function WorkspaceListPage({
|
|
|
652
692
|
</div>
|
|
653
693
|
|
|
654
694
|
<StatStrip items={stats} />
|
|
695
|
+
|
|
696
|
+
{/* Câu giải thích phạm vi ở ĐÂY chứ không trên cây: cột trái
|
|
697
|
+
là danh sách để quét mắt, một đoạn ba dòng chen giữa các
|
|
698
|
+
dòng làm mất luôn nhịp đọc. Bên này đang nói về đúng một
|
|
699
|
+
nút nên có chỗ. */}
|
|
700
|
+
{rootAnnotations?.[selected.code]?.hint ? (
|
|
701
|
+
<p className="text-[13px] leading-relaxed text-muted-foreground">
|
|
702
|
+
{rootAnnotations[selected.code].hint}
|
|
703
|
+
</p>
|
|
704
|
+
) : null}
|
|
655
705
|
</div>
|
|
656
706
|
|
|
657
707
|
<WorkspaceMembersPanel
|
|
@@ -662,6 +712,12 @@ export function WorkspaceListPage({
|
|
|
662
712
|
canManage={canManageSelected}
|
|
663
713
|
apiEndpoint={apiEndpoint}
|
|
664
714
|
onCountChange={applyMemberCount}
|
|
715
|
+
bulkFilters={memberFilters}
|
|
716
|
+
// Cây làm phẳng: hộp thêm thành viên lọc được "đang ở bộ phận
|
|
717
|
+
// nào", trục mà người dùng thật sự dùng ("chuyển cả tổ QC sang
|
|
718
|
+
// Kho"). Cây đầy đủ chứ không phải cây đã lọc — ô tìm bên trái
|
|
719
|
+
// lọc cây điều hướng, không lọc bộ lọc của hộp thoại.
|
|
720
|
+
workspaceOptions={flat}
|
|
665
721
|
/>
|
|
666
722
|
</>
|
|
667
723
|
) : (
|
|
@@ -791,9 +847,13 @@ export function WorkspaceListPage({
|
|
|
791
847
|
<ConfirmDialog
|
|
792
848
|
open={deactivating !== null}
|
|
793
849
|
onOpenChange={(open) => !open && setDeactivating(null)}
|
|
794
|
-
title="
|
|
795
|
-
|
|
796
|
-
|
|
850
|
+
title="Xoá workspace?"
|
|
851
|
+
// Nói rõ đây là xoá MỀM: dữ liệu cũ tham chiếu tới nút này (chứng từ,
|
|
852
|
+
// hồ sơ) vẫn còn nguyên phạm vi, và khôi phục lại được — nếu không nói
|
|
853
|
+
// ra thì người dùng hoặc sợ không dám bấm, hoặc bấm xong tưởng mất dữ
|
|
854
|
+
// liệu thật.
|
|
855
|
+
description={`"${deactivating?.name}" và toàn bộ cấp dưới sẽ bị gỡ khỏi danh sách đang dùng. Dữ liệu cũ giữ nguyên và khôi phục lại được.`}
|
|
856
|
+
confirmText="Xoá"
|
|
797
857
|
variant="destructive"
|
|
798
858
|
onConfirm={confirmDeactivate}
|
|
799
859
|
/>
|