@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.
@@ -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
- if (actor.scope.adminIds.length === 0) {
125
- throw new DelegationError(
126
- "NO_DELEGATION",
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 quản trị của bạn: ${outside.join(", ")}.`,
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
  }
@@ -130,13 +130,23 @@ export {
130
130
  } from "./components/workspace-tree-view";
131
131
 
132
132
  export {
133
- WorkspaceOrgBlock,
133
+ WorkspaceTreePanel,
134
134
  filterWorkspaceTree,
135
135
  rollupCounts,
136
136
  type WorkspaceOrgAnnotation,
137
- type WorkspaceOrgBlockProps,
137
+ type WorkspaceTreeActions,
138
+ type WorkspaceTreePanelProps,
138
139
  type WorkspaceRollup,
139
- } from "./components/workspace-org-block";
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
- WorkspaceOrgBlock,
59
+ WorkspaceTreePanel,
60
60
  filterWorkspaceTree,
61
61
  rollupCounts,
62
- } from "../components/workspace-org-block";
63
- import type { WorkspaceOrgAnnotation } from "../components/workspace-org-block";
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("Đã ngừng hoạt động cả nhánh.");
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,20rem)_minmax(0,1fr)] lg:items-start">
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
- 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
- ))
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 thông tin
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
- {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}
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="Ngừng hoạt động workspace?"
795
- description={`"${deactivating?.name}" toàn bộ cấp dưới sẽ ngừng hoạt động. Dữ liệu giữ nguyên, không bị xóa.`}
796
- confirmText="Ngừng hoạt động"
850
+ title="Xoá workspace?"
851
+ // Nói đây xoá MỀM: dữ liệu 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
  />