@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bin/goerp-features.mjs +11 -1
  3. package/features/workspaces/README.md +72 -0
  4. package/features/workspaces/migrations/0001_init.sql +63 -0
  5. package/features/workspaces/migrations/0002_delegation-columns.sql +34 -0
  6. package/features/workspaces/schema.prisma +56 -0
  7. package/package.json +2 -1
  8. package/scripts/feature-sync.mjs +31 -3
  9. package/src/branch-scope/context.ts +20 -37
  10. package/src/features/__tests__/feature-sync.test.ts +41 -0
  11. package/src/guardrails/__tests__/guardrails.test.ts +47 -0
  12. package/src/guardrails/primitives.ts +14 -1
  13. package/src/guardrails/rules/one-door.ts +23 -0
  14. package/src/guardrails/scanner.ts +9 -0
  15. package/src/guardrails/types.ts +7 -0
  16. package/src/user/__tests__/user-service-scope.test.ts +148 -0
  17. package/src/user/components/unified-profile-dialog.tsx +160 -0
  18. package/src/user/pages/users-client-page.tsx +12 -0
  19. package/src/user/user-service.ts +64 -10
  20. package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
  21. package/src/workspace/__tests__/workspace-member-handlers.test.ts +407 -0
  22. package/src/workspace/__tests__/workspace-route-handlers.test.ts +449 -0
  23. package/src/workspace/__tests__/workspace-scope.test.ts +592 -0
  24. package/src/workspace/__tests__/workspace-service.test.ts +339 -0
  25. package/src/workspace/components/scope-level-select.tsx +91 -0
  26. package/src/workspace/components/workspace-members-panel.tsx +454 -0
  27. package/src/workspace/components/workspace-org-block.tsx +293 -0
  28. package/src/workspace/components/workspace-switcher.tsx +139 -0
  29. package/src/workspace/components/workspace-tree-view.tsx +301 -0
  30. package/src/workspace/context.ts +78 -0
  31. package/src/workspace/delegation.ts +400 -0
  32. package/src/workspace/guard.ts +138 -0
  33. package/src/workspace/index.ts +173 -0
  34. package/src/workspace/pages/workspace-list-page.tsx +802 -0
  35. package/src/workspace/route-handlers.ts +550 -0
  36. package/src/workspace/scope.ts +396 -0
  37. package/src/workspace/service.ts +301 -0
  38. package/src/workspace/tree.ts +193 -0
  39. 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
+ }