@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.
Files changed (30) hide show
  1. package/package.json +2 -1
  2. package/src/guardrails/__tests__/guardrails.test.ts +82 -0
  3. package/src/guardrails/rules/rbac.ts +120 -0
  4. package/src/navigation/index.ts +49 -0
  5. package/src/rbac/__tests__/landing-path.test.ts +148 -0
  6. package/src/rbac/__tests__/route-handlers.test.ts +147 -0
  7. package/src/rbac/landing-path.ts +140 -0
  8. package/src/rbac/pages/role-form-page.tsx +99 -0
  9. package/src/rbac/route-handlers.ts +22 -1
  10. package/src/schemas/role.schema.ts +6 -0
  11. package/src/ui/auth/sign-in-form.tsx +36 -4
  12. package/src/user/components/unified-profile-dialog.tsx +160 -0
  13. package/src/user/pages/users-client-page.tsx +12 -0
  14. package/src/workspace/__tests__/workspace-delegation.test.ts +1 -1
  15. package/src/workspace/__tests__/workspace-member-handlers.test.ts +407 -0
  16. package/src/workspace/__tests__/workspace-route-handlers.test.ts +35 -0
  17. package/src/workspace/__tests__/workspace-service.test.ts +1 -1
  18. package/src/workspace/components/scope-level-select.tsx +4 -4
  19. package/src/workspace/components/workspace-members-panel.tsx +454 -0
  20. package/src/workspace/components/workspace-org-block.tsx +293 -0
  21. package/src/workspace/components/workspace-switcher.tsx +2 -2
  22. package/src/workspace/components/workspace-tree-view.tsx +66 -25
  23. package/src/workspace/delegation.ts +7 -7
  24. package/src/workspace/index.ts +16 -0
  25. package/src/workspace/pages/workspace-list-page.tsx +425 -53
  26. package/src/workspace/route-handlers.ts +278 -2
  27. package/src/workspace/service.ts +4 -4
  28. package/src/workspace/tree.ts +1 -1
  29. package/src/workspace/types.ts +1 -1
  30. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +14 -2
@@ -1,19 +1,34 @@
1
1
  "use client";
2
2
 
3
- // Trang "Không gian làm việc" cây tổ chức + thao tác nhánh.
3
+ // Trang "Workspace" hai khung: TRÁI chọn nơi, PHẢI làm việc với nơi đó.
4
4
  //
5
- // Dùng lại khung `RbacPageBar` / `RbacStickyToolbar` của các trang RBAC thay
6
- // tự dựng bar riêng: đây trang quản trị cùng họ, lệch khung một chút là người
7
- // dùng nhận ra ngay (L1 tái dùng, không hand-roll).
5
+ // 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" "trong bộ phận này ai". Nhét cả hai vào một cột thì
7
+ // mở người lên cấu bị đẩy khuất, 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 { Network } from "lucide-react";
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 = "Không gian làm việc",
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 không gian.");
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} không gian`}
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
- Thêm không gian gốc
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="overflow-hidden rounded-xl border border-border bg-card lg:mx-3">
249
- <div className="sticky top-0 z-20 flex flex-wrap items-center gap-2 border-b border-border bg-card/95 px-3 py-2 backdrop-blur sm:px-4">
250
- <div className="relative min-w-[140px] flex-1 sm:max-w-72">
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:
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
- <p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
270
- <span className="font-semibold tabular-nums text-foreground">
271
- {total}
272
- </span>{" "}
273
- không gian
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-6 w-full" />
281
- <Skeleton className="h-6 w-5/6" />
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
- <WorkspaceTreeView
285
- nodes={tree}
286
- adminIds={adminIds}
287
- canManageAll={canManageAll}
288
- search={search}
289
- onAddChild={canCreate ? openCreate : undefined}
290
- onEdit={(node) =>
291
- setForm({
292
- mode: "edit",
293
- id: node.id,
294
- parentId: null,
295
- code: node.code,
296
- name: node.name,
297
- kind: node.kind ?? kinds[0]?.key ?? "unit",
298
- })
299
- }
300
- onMove={(node) => {
301
- setMoving(node);
302
- setMoveTarget("__root__");
303
- }}
304
- onDeactivate={setDeactivating}
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 không gian" : "Sửa không gian"}
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
- {" ".repeat(item.depth * 2)}
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 không gian?"
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"