@goplusvn/core 0.1.76 → 0.1.78
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/package.json +2 -1
- package/src/guardrails/__tests__/guardrails.test.ts +82 -0
- package/src/guardrails/rules/rbac.ts +120 -0
- package/src/navigation/index.ts +49 -0
- package/src/rbac/__tests__/landing-path.test.ts +148 -0
- package/src/rbac/__tests__/route-handlers.test.ts +147 -0
- package/src/rbac/landing-path.ts +140 -0
- package/src/rbac/pages/role-form-page.tsx +99 -0
- package/src/rbac/route-handlers.ts +22 -1
- package/src/schemas/role.schema.ts +6 -0
- package/src/ui/auth/sign-in-form.tsx +36 -4
- package/src/user/components/unified-profile-dialog.tsx +160 -0
- package/src/user/pages/users-client-page.tsx +12 -0
- package/src/workspace/__tests__/workspace-delegation.test.ts +1 -1
- package/src/workspace/__tests__/workspace-member-handlers.test.ts +407 -0
- package/src/workspace/__tests__/workspace-route-handlers.test.ts +35 -0
- package/src/workspace/__tests__/workspace-service.test.ts +1 -1
- package/src/workspace/components/scope-level-select.tsx +4 -4
- 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 +2 -2
- package/src/workspace/components/workspace-tree-view.tsx +66 -25
- package/src/workspace/delegation.ts +7 -7
- package/src/workspace/index.ts +16 -0
- package/src/workspace/pages/workspace-list-page.tsx +425 -53
- package/src/workspace/route-handlers.ts +278 -2
- package/src/workspace/service.ts +4 -4
- package/src/workspace/tree.ts +1 -1
- package/src/workspace/types.ts +1 -1
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +14 -2
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
// Trang "
|
|
3
|
+
// Trang "Workspace" — hai khung: TRÁI chọn nơi, PHẢI làm việc với nơi đó.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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).
|
|
8
15
|
//
|
|
9
16
|
// API contract: GET/POST `/api/workspaces`, PATCH/DELETE `/api/workspaces/:id`,
|
|
10
17
|
// POST `/api/workspaces/:id/move`.
|
|
11
18
|
import * as React from "react";
|
|
12
19
|
|
|
13
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
ChevronRight,
|
|
22
|
+
MoreHorizontal,
|
|
23
|
+
Network,
|
|
24
|
+
Plus,
|
|
25
|
+
Search,
|
|
26
|
+
X,
|
|
27
|
+
} from "lucide-react";
|
|
14
28
|
import { toast } from "sonner";
|
|
15
29
|
|
|
16
30
|
import {
|
|
31
|
+
Badge,
|
|
17
32
|
Button,
|
|
18
33
|
ConfirmDialog,
|
|
19
34
|
Dialog,
|
|
@@ -22,6 +37,11 @@ import {
|
|
|
22
37
|
DialogFooter,
|
|
23
38
|
DialogHeader,
|
|
24
39
|
DialogTitle,
|
|
40
|
+
DropdownMenu,
|
|
41
|
+
DropdownMenuContent,
|
|
42
|
+
DropdownMenuItem,
|
|
43
|
+
DropdownMenuSeparator,
|
|
44
|
+
DropdownMenuTrigger,
|
|
25
45
|
Input,
|
|
26
46
|
Label,
|
|
27
47
|
Select,
|
|
@@ -32,13 +52,16 @@ import {
|
|
|
32
52
|
Skeleton,
|
|
33
53
|
} from "../../ui";
|
|
34
54
|
import { RbacPageBar } from "../../rbac/pages/lib/rbac-page-shell";
|
|
35
|
-
import { Search, X } from "lucide-react";
|
|
36
55
|
import { cn } from "../../utils";
|
|
37
|
-
import {
|
|
38
|
-
countTreeNodes,
|
|
39
|
-
WorkspaceTreeView,
|
|
40
|
-
} from "../components/workspace-tree-view";
|
|
56
|
+
import { countTreeNodes } from "../components/workspace-tree-view";
|
|
41
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";
|
|
42
65
|
import type { WorkspaceKindConfig } from "../types";
|
|
43
66
|
|
|
44
67
|
export interface WorkspaceListPageProps {
|
|
@@ -52,6 +75,14 @@ export interface WorkspaceListPageProps {
|
|
|
52
75
|
canCreate?: boolean;
|
|
53
76
|
apiEndpoint?: string;
|
|
54
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>;
|
|
55
86
|
}
|
|
56
87
|
|
|
57
88
|
interface FormState {
|
|
@@ -87,6 +118,21 @@ function findNode(
|
|
|
87
118
|
return null;
|
|
88
119
|
}
|
|
89
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
|
+
|
|
90
136
|
/** Làm phẳng cây thành danh sách chọn "chuyển vào nhánh nào". */
|
|
91
137
|
function flatten(
|
|
92
138
|
nodes: WorkspaceTreeNode[],
|
|
@@ -103,6 +149,30 @@ function subtreeIds(node: WorkspaceTreeNode): string[] {
|
|
|
103
149
|
return [node.id, ...node.children.flatMap(subtreeIds)];
|
|
104
150
|
}
|
|
105
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
|
+
|
|
106
176
|
export function WorkspaceListPage({
|
|
107
177
|
initialTree,
|
|
108
178
|
kinds = [],
|
|
@@ -110,29 +180,132 @@ export function WorkspaceListPage({
|
|
|
110
180
|
canManageAll = false,
|
|
111
181
|
canCreate = false,
|
|
112
182
|
apiEndpoint = "/api/workspaces",
|
|
113
|
-
title = "
|
|
183
|
+
title = "Workspace",
|
|
184
|
+
rootAnnotations,
|
|
114
185
|
}: WorkspaceListPageProps) {
|
|
115
186
|
const [tree, setTree] = React.useState(initialTree);
|
|
116
187
|
const [search, setSearch] = React.useState("");
|
|
117
188
|
const [busy, setBusy] = React.useState(false);
|
|
189
|
+
const [selectedId, setSelectedId] = React.useState<string | null>(
|
|
190
|
+
initialTree[0]?.id ?? null,
|
|
191
|
+
);
|
|
118
192
|
const [form, setForm] = React.useState<FormState | null>(null);
|
|
119
193
|
const [moving, setMoving] = React.useState<WorkspaceTreeNode | null>(null);
|
|
120
194
|
const [moveTarget, setMoveTarget] = React.useState<string>("");
|
|
121
195
|
const [deactivating, setDeactivating] =
|
|
122
196
|
React.useState<WorkspaceTreeNode | null>(null);
|
|
197
|
+
const detailRef = React.useRef<HTMLDivElement | null>(null);
|
|
123
198
|
|
|
124
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
|
+
);
|
|
125
254
|
const flat = React.useMemo(() => flatten(tree), [tree]);
|
|
126
255
|
|
|
127
256
|
const reload = React.useCallback(async () => {
|
|
128
257
|
const res = await fetch(apiEndpoint, { cache: "no-store" });
|
|
129
258
|
if (!res.ok) {
|
|
130
|
-
toast.error("Không tải lại được danh sách
|
|
259
|
+
toast.error("Không tải lại được danh sách workspace.");
|
|
131
260
|
return;
|
|
132
261
|
}
|
|
133
262
|
setTree(await res.json());
|
|
134
263
|
}, [apiEndpoint]);
|
|
135
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
|
+
|
|
136
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. */
|
|
137
310
|
const submit = React.useCallback(
|
|
138
311
|
async (url: string, method: string, body?: unknown) => {
|
|
@@ -231,23 +404,47 @@ export function WorkspaceListPage({
|
|
|
231
404
|
return allowedChildKinds(kinds, parent?.kind);
|
|
232
405
|
}, [form, kinds, tree]);
|
|
233
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
|
+
|
|
234
426
|
return (
|
|
235
427
|
<div className="space-y-3">
|
|
236
428
|
<RbacPageBar
|
|
237
429
|
icon={<Network className="h-5 w-5 text-primary-foreground" />}
|
|
238
430
|
title={title}
|
|
239
|
-
subtitle={`${total}
|
|
431
|
+
subtitle={`${tree.length} tổ chức · ${total} workspace · ${totalMembers} người`}
|
|
240
432
|
>
|
|
241
433
|
{canCreate ? (
|
|
242
434
|
<Button size="sm" onClick={() => openCreate()} disabled={busy}>
|
|
243
|
-
|
|
435
|
+
<Plus className="h-3.5 w-3.5" />
|
|
436
|
+
<span className="ml-1.5">Thêm tổ chức</span>
|
|
244
437
|
</Button>
|
|
245
438
|
) : null}
|
|
246
439
|
</RbacPageBar>
|
|
247
440
|
|
|
248
|
-
<div className="
|
|
249
|
-
|
|
250
|
-
|
|
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">
|
|
251
448
|
<Search className="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
252
449
|
<Input
|
|
253
450
|
placeholder="Tìm theo tên hoặc mã…"
|
|
@@ -266,43 +463,218 @@ export function WorkspaceListPage({
|
|
|
266
463
|
</button>
|
|
267
464
|
) : null}
|
|
268
465
|
</div>
|
|
269
|
-
|
|
270
|
-
<
|
|
271
|
-
{total}
|
|
272
|
-
</
|
|
273
|
-
|
|
274
|
-
</p>
|
|
275
|
-
</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}
|
|
276
471
|
|
|
277
|
-
<div className={cn(busy && "pointer-events-none opacity-60")}>
|
|
278
472
|
{busy && tree.length === 0 ? (
|
|
279
|
-
<div className="space-y-2 p-4">
|
|
280
|
-
<Skeleton className="h-
|
|
281
|
-
<Skeleton className="h-
|
|
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}
|
|
282
508
|
</div>
|
|
283
509
|
) : (
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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>
|
|
306
678
|
)}
|
|
307
679
|
</div>
|
|
308
680
|
</div>
|
|
@@ -314,7 +686,7 @@ export function WorkspaceListPage({
|
|
|
314
686
|
<DialogContent>
|
|
315
687
|
<DialogHeader>
|
|
316
688
|
<DialogTitle>
|
|
317
|
-
{form?.mode === "create" ? "Thêm
|
|
689
|
+
{form?.mode === "create" ? "Thêm workspace" : "Sửa workspace"}
|
|
318
690
|
</DialogTitle>
|
|
319
691
|
{form?.parentName ? (
|
|
320
692
|
<DialogDescription>
|
|
@@ -399,7 +771,7 @@ export function WorkspaceListPage({
|
|
|
399
771
|
<SelectItem value="__root__">— Cấp cao nhất —</SelectItem>
|
|
400
772
|
{moveOptions.map((item) => (
|
|
401
773
|
<SelectItem key={item.id} value={item.id}>
|
|
402
|
-
{"
|
|
774
|
+
{" ".repeat(item.depth * 2)}
|
|
403
775
|
{item.name}
|
|
404
776
|
</SelectItem>
|
|
405
777
|
))}
|
|
@@ -419,7 +791,7 @@ export function WorkspaceListPage({
|
|
|
419
791
|
<ConfirmDialog
|
|
420
792
|
open={deactivating !== null}
|
|
421
793
|
onOpenChange={(open) => !open && setDeactivating(null)}
|
|
422
|
-
title="Ngừng hoạt động
|
|
794
|
+
title="Ngừng hoạt động workspace?"
|
|
423
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.`}
|
|
424
796
|
confirmText="Ngừng hoạt động"
|
|
425
797
|
variant="destructive"
|