@goplusvn/core 0.1.76 → 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.
@@ -121,6 +121,15 @@ function fakeDb() {
121
121
  return rows.filter((r) => r.parentId === args?.where?.parentId).length;
122
122
  },
123
123
  },
124
+ // GET đếm quản trị bằng `groupBy` riêng: `_count` của Prisma không lọc được
125
+ // theo cờ `isAdmin`. Chỉ `spa` có một quản trị — để test phân biệt được
126
+ // "đếm thật" với "trả 0 cho mọi nút".
127
+ userWorkspace: {
128
+ async groupBy(args: any) {
129
+ calls.push({ fn: "userWorkspace.groupBy", args });
130
+ return [{ workspaceId: "spa", _count: { _all: 1 } }];
131
+ },
132
+ },
124
133
  };
125
134
  }
126
135
 
@@ -214,6 +223,32 @@ describe("workspace route handlers — GET", () => {
214
223
  ]);
215
224
  });
216
225
 
226
+ /**
227
+ * Cây tải lại sau mỗi lần thêm/sửa phải mang ĐỦ những gì trang server render
228
+ * đã có. Thiếu `kindLabel` thì huy hiệu cấp lặng lẽ biến mất, thiếu
229
+ * `adminCount` thì cột "quản trị" tụt về 0 — cả hai đều không đỏ ở đâu cả,
230
+ * người dùng chỉ thấy số tự nhiên đổi sau một thao tác không liên quan.
231
+ */
232
+ it("trả kèm kindLabel và adminCount cho cây tải lại", async () => {
233
+ const { GET } = createWorkspaceCollectionHandlers({
234
+ ...(deps(db, opsAdmin) as any),
235
+ kinds: [
236
+ { key: "unit", label: "Đơn vị" },
237
+ { key: "department", label: "Bộ phận" },
238
+ ],
239
+ });
240
+ const tree = await (
241
+ await GET(new Request("http://test/api/workspaces"))
242
+ ).json();
243
+
244
+ const spa = tree[0].children.find((c: any) => c.id === "spa");
245
+ expect(spa.kindLabel).toBe("Đơn vị");
246
+ expect(spa.adminCount).toBe(1);
247
+ // Nút không có quản trị nào phải là 0, không phải `undefined`.
248
+ expect(spa.children[0].adminCount).toBe(0);
249
+ expect(spa.children[0].kindLabel).toBe("Bộ phận");
250
+ });
251
+
217
252
  it("chưa đăng nhập → 401, không chạm DB", async () => {
218
253
  const { GET } = createWorkspaceCollectionHandlers(deps(db, null) as any);
219
254
  const res = await GET(new Request("http://test/api/workspaces"));
@@ -199,7 +199,7 @@ describe("createWorkspace", () => {
199
199
  kind: "department",
200
200
  parentId: "spa-qc",
201
201
  }),
202
- ).rejects.toThrow(/không được có không gian con/);
202
+ ).rejects.toThrow(/không được có workspace con/);
203
203
  });
204
204
 
205
205
  it("kind ngoài childKinds của cha thì chặn", async () => {
@@ -22,8 +22,8 @@ import type { ScopeLevel } from "../types";
22
22
  const HINTS: Record<ScopeLevel, string> = {
23
23
  none: "Không thấy gì",
24
24
  own: "Chỉ bản ghi do chính mình tạo",
25
- workspace: "Đúng không gian được gán",
26
- subtree: "Không gian được gán và cấp dưới",
25
+ workspace: "Đúng workspace được gán",
26
+ subtree: "Workspace được gán và cấp dưới",
27
27
  all: "Toàn hệ thống",
28
28
  };
29
29
 
@@ -35,14 +35,14 @@ export interface ScopeLevelSelectProps {
35
35
  * dropdown thay vì để họ chọn rồi server mới từ chối.
36
36
  */
37
37
  maxLevel?: ScopeLevel;
38
- /** Nhãn thay cho "Không gian" theo app: "Chi nhánh", "Đơn vị", "Phòng ban". */
38
+ /** Nhãn thay cho "Workspace" theo app: "Chi nhánh", "Đơn vị", "Phòng ban". */
39
39
  kindLabel?: string;
40
40
  disabled?: boolean;
41
41
  className?: string;
42
42
  id?: string;
43
43
  }
44
44
 
45
- /** Nhãn nấc, đã thay chữ "Không gian" bằng nhãn của app. */
45
+ /** Nhãn nấc, đã thay chữ "Workspace" bằng nhãn của app. */
46
46
  export function scopeLevelLabel(level: ScopeLevel, kindLabel?: string): string {
47
47
  const base = SCOPE_LEVEL_LABELS[level];
48
48
  if (!kindLabel) return base;
@@ -0,0 +1,454 @@
1
+ "use client";
2
+
3
+ // Cột "Thành viên" của trang Không gian làm việc.
4
+ //
5
+ // Đây là chỗ DUY NHẤT trên giao diện ghi bảng membership. Nút bị ẩn ở đây không
6
+ // phải hàng rào — hàng rào nằm ở `createWorkspaceMemberHandlers`; panel này chỉ
7
+ // có nhiệm vụ đừng mời người dùng bấm vào thứ chắc chắn 403.
8
+
9
+ import * as React from "react";
10
+
11
+ import {
12
+ Loader2,
13
+ MoreHorizontal,
14
+ Search,
15
+ Shield,
16
+ Trash2,
17
+ UserPlus,
18
+ Users,
19
+ X,
20
+ } from "lucide-react";
21
+ import { toast } from "sonner";
22
+
23
+ import {
24
+ Badge,
25
+ Button,
26
+ DropdownMenu,
27
+ DropdownMenuContent,
28
+ DropdownMenuItem,
29
+ DropdownMenuSeparator,
30
+ DropdownMenuTrigger,
31
+ Input,
32
+ Skeleton,
33
+ } from "../../ui";
34
+ import { cn } from "../../utils";
35
+
36
+ /** Chữ cái đầu cho ô avatar — hai ký tự là đủ nhận diện, ba trở lên thành rối. */
37
+ function initials(name?: string | null, email?: string | null): string {
38
+ const source = (name ?? email ?? "?").trim();
39
+ const words = source.split(/\s+/).filter(Boolean);
40
+ if (words.length >= 2) {
41
+ return (
42
+ words[words.length - 2][0] + words[words.length - 1][0]
43
+ ).toUpperCase();
44
+ }
45
+ return source.slice(0, 2).toUpperCase();
46
+ }
47
+
48
+ export interface WorkspaceMember {
49
+ userId: string;
50
+ name: string | null;
51
+ email: string | null;
52
+ isActive?: boolean;
53
+ isAdmin?: boolean;
54
+ isDefault?: boolean;
55
+ }
56
+
57
+ interface Candidate {
58
+ id: string;
59
+ name: string | null;
60
+ email: string | null;
61
+ isActive?: boolean;
62
+ }
63
+
64
+ export interface WorkspaceMembersPanelProps {
65
+ /** Nút đang chọn trên cây; `null` = chưa chọn gì. */
66
+ workspaceId: string | null;
67
+ workspaceName?: string | null;
68
+ /** Nhãn cấp của app ("Đơn vị" / "Phòng ban" / "Chi nhánh") — core không tự đặt tên. */
69
+ kindLabel?: string;
70
+ /** Người dùng có quản trị được ĐÚNG nút này không (đã tính ở server). */
71
+ canManage?: boolean;
72
+ apiEndpoint?: string;
73
+ /**
74
+ * Báo số thành viên THẬT sau mỗi lần tải/thêm/gỡ, để cây bên trái cập nhật.
75
+ *
76
+ * Không có nó thì con số trên cây là ảnh chụp lúc server render: thêm người
77
+ * xong, panel hiện 1 mà cây vẫn "0 người" — người dùng đọc hai con số mâu
78
+ * thuẫn trên cùng một màn hình và không biết tin cái nào.
79
+ */
80
+ onCountChange?: (workspaceId: string, count: number) => void;
81
+ }
82
+
83
+ export function WorkspaceMembersPanel({
84
+ workspaceId,
85
+ workspaceName,
86
+ kindLabel,
87
+ canManage = false,
88
+ apiEndpoint = "/api/workspaces",
89
+ onCountChange,
90
+ }: WorkspaceMembersPanelProps) {
91
+ const [members, setMembers] = React.useState<WorkspaceMember[]>([]);
92
+ const [loading, setLoading] = React.useState(false);
93
+ const [busyUserId, setBusyUserId] = React.useState<string | null>(null);
94
+ const [adding, setAdding] = React.useState(false);
95
+ const [query, setQuery] = React.useState("");
96
+ const [candidates, setCandidates] = React.useState<Candidate[]>([]);
97
+ const [searching, setSearching] = React.useState(false);
98
+
99
+ const base = workspaceId ? `${apiEndpoint}/${workspaceId}/members` : null;
100
+
101
+ // Giữ callback trong ref: trang cha thường truyền hàm inline, để nó vào deps
102
+ // của `reload` là mỗi lần cha render lại thì panel gọi API lại một vòng.
103
+ const countRef = React.useRef(onCountChange);
104
+ React.useEffect(() => {
105
+ countRef.current = onCountChange;
106
+ }, [onCountChange]);
107
+
108
+ const reload = React.useCallback(async () => {
109
+ if (!base) {
110
+ setMembers([]);
111
+ return;
112
+ }
113
+ setLoading(true);
114
+ try {
115
+ const res = await fetch(base, { cache: "no-store" });
116
+ if (!res.ok) throw new Error((await res.json())?.error ?? "Lỗi tải");
117
+ const list: WorkspaceMember[] = await res.json();
118
+ setMembers(list);
119
+ if (workspaceId) countRef.current?.(workspaceId, list.length);
120
+ } catch (error) {
121
+ toast.error(
122
+ error instanceof Error ? error.message : "Không tải được thành viên.",
123
+ );
124
+ setMembers([]);
125
+ } finally {
126
+ setLoading(false);
127
+ }
128
+ }, [base, workspaceId]);
129
+
130
+ React.useEffect(() => {
131
+ void reload();
132
+ // Đổi nút thì đóng luôn ô thêm — để mở là người dùng tưởng đang thêm vào nút cũ.
133
+ setAdding(false);
134
+ setQuery("");
135
+ setCandidates([]);
136
+ }, [reload]);
137
+
138
+ // Tìm ứng viên: gõ tới đâu hỏi tới đó, có hoãn để không bắn mỗi phím một request.
139
+ React.useEffect(() => {
140
+ if (!adding || !base) return;
141
+ let cancelled = false;
142
+ setSearching(true);
143
+ const timer = setTimeout(async () => {
144
+ try {
145
+ const url = `${base}?candidates=1&q=${encodeURIComponent(query)}`;
146
+ const res = await fetch(url, { cache: "no-store" });
147
+ if (!res.ok) throw new Error((await res.json())?.error ?? "Lỗi tìm");
148
+ const data = await res.json();
149
+ if (!cancelled) setCandidates(data);
150
+ } catch (error) {
151
+ if (!cancelled) {
152
+ setCandidates([]);
153
+ toast.error(
154
+ error instanceof Error
155
+ ? error.message
156
+ : "Không tìm được người dùng.",
157
+ );
158
+ }
159
+ } finally {
160
+ if (!cancelled) setSearching(false);
161
+ }
162
+ }, 250);
163
+ return () => {
164
+ cancelled = true;
165
+ clearTimeout(timer);
166
+ };
167
+ }, [adding, query, base]);
168
+
169
+ async function mutate(
170
+ init: RequestInit & { url: string },
171
+ userId: string,
172
+ okMessage: string,
173
+ ) {
174
+ setBusyUserId(userId);
175
+ try {
176
+ const res = await fetch(init.url, init);
177
+ const payload = await res.json().catch(() => ({}));
178
+ if (!res.ok) {
179
+ // Câu từ server là câu giải thích VÌ SAO (D1/D3/D5/D7) — hiển thị nguyên
180
+ // văn, đừng nuốt thành "Có lỗi xảy ra".
181
+ throw new Error(payload?.error ?? "Thao tác không thành công.");
182
+ }
183
+ toast.success(okMessage);
184
+ await reload();
185
+ } catch (error) {
186
+ toast.error(error instanceof Error ? error.message : "Thao tác lỗi.");
187
+ } finally {
188
+ setBusyUserId(null);
189
+ }
190
+ }
191
+
192
+ const add = (userId: string) =>
193
+ mutate(
194
+ {
195
+ url: base!,
196
+ method: "POST",
197
+ headers: { "content-type": "application/json" },
198
+ body: JSON.stringify({ userId }),
199
+ },
200
+ userId,
201
+ "Đã thêm vào " + (kindLabel?.toLowerCase() ?? "workspace"),
202
+ );
203
+
204
+ const toggleAdmin = (member: WorkspaceMember) =>
205
+ mutate(
206
+ {
207
+ url: base!,
208
+ method: "POST",
209
+ headers: { "content-type": "application/json" },
210
+ body: JSON.stringify({
211
+ userId: member.userId,
212
+ isAdmin: !member.isAdmin,
213
+ isDefault: member.isDefault,
214
+ }),
215
+ },
216
+ member.userId,
217
+ member.isAdmin ? "Đã bỏ quyền quản trị." : "Đã đặt làm quản trị.",
218
+ );
219
+
220
+ const remove = (userId: string) =>
221
+ mutate(
222
+ { url: `${base}?userId=${encodeURIComponent(userId)}`, method: "DELETE" },
223
+ userId,
224
+ "Đã gỡ khỏi " + (kindLabel?.toLowerCase() ?? "workspace"),
225
+ );
226
+
227
+ if (!workspaceId) {
228
+ return (
229
+ <div className="flex h-full min-h-40 items-center justify-center p-6 text-center text-sm text-muted-foreground">
230
+ Chọn một {kindLabel?.toLowerCase() ?? "workspace"} ở bên trái để xem và
231
+ gán người dùng.
232
+ </div>
233
+ );
234
+ }
235
+
236
+ return (
237
+ <div className="flex h-full flex-col">
238
+ {/* Thanh của DANH SÁCH, không phải của không gian: tên/mã/cấp đã nằm ở
239
+ đầu vùng chi tiết bên trên, lặp lại ở đây chỉ tốn dòng. */}
240
+ <div className="flex items-center gap-2 border-b border-border px-3 py-2">
241
+ <Users className="h-4 w-4 shrink-0 text-muted-foreground" />
242
+ <p className="text-sm font-medium">
243
+ Thành viên
244
+ {!loading ? (
245
+ <span className="ml-1.5 tabular-nums text-muted-foreground">
246
+ {members.length}
247
+ </span>
248
+ ) : null}
249
+ </p>
250
+ {canManage ? (
251
+ <Button
252
+ size="sm"
253
+ variant={adding ? "secondary" : "default"}
254
+ className="ml-auto"
255
+ onClick={() => setAdding((v) => !v)}
256
+ >
257
+ {adding ? (
258
+ <X className="h-3.5 w-3.5" />
259
+ ) : (
260
+ <UserPlus className="h-3.5 w-3.5" />
261
+ )}
262
+ <span className="ml-1.5">
263
+ {adding ? "Đóng" : "Thêm thành viên"}
264
+ </span>
265
+ </Button>
266
+ ) : null}
267
+ </div>
268
+
269
+ {/* Nền + vạch dưới của ô thêm là BẮT BUỘC: ngay bên dưới là danh sách
270
+ thành viên hiện có, cũng gồm tên + email. Bỏ ranh giới thì người vừa
271
+ được thêm hiện hai lần sát nhau (một dòng ứng viên, một dòng thành
272
+ viên) mà không có gì nói đó là hai danh sách khác nhau. */}
273
+ {adding ? (
274
+ <div className="border-b border-border bg-muted/30 p-3">
275
+ <div className="relative max-w-md">
276
+ <Search className="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
277
+ <Input
278
+ autoFocus
279
+ placeholder="Tìm theo tên hoặc email…"
280
+ className="h-8 pl-8 text-sm"
281
+ value={query}
282
+ onChange={(e) => setQuery(e.target.value)}
283
+ />
284
+ </div>
285
+ <div className="mt-2 max-h-56 max-w-md space-y-0.5 overflow-y-auto">
286
+ {searching ? (
287
+ <p className="px-2 py-3 text-xs text-muted-foreground">
288
+ Đang tìm…
289
+ </p>
290
+ ) : candidates.length === 0 ? (
291
+ // Danh sách rỗng ở đây gần như luôn là "ngoài phạm vi", không phải
292
+ // "hệ thống không có ai" — nói thẳng để khỏi tưởng mất dữ liệu.
293
+ <p className="px-2 py-3 text-xs text-muted-foreground">
294
+ Không có người dùng nào trong phạm vi quản trị của bạn khớp tìm
295
+ kiếm.
296
+ </p>
297
+ ) : (
298
+ candidates.map((c) => (
299
+ <button
300
+ key={c.id}
301
+ type="button"
302
+ disabled={busyUserId === c.id}
303
+ onClick={() => add(c.id)}
304
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent disabled:opacity-50"
305
+ >
306
+ <span className="min-w-0 flex-1">
307
+ <span className="block truncate text-sm">
308
+ {c.name ?? c.email}
309
+ </span>
310
+ {c.name && c.email ? (
311
+ <span className="block truncate text-xs text-muted-foreground">
312
+ {c.email}
313
+ </span>
314
+ ) : null}
315
+ </span>
316
+ {busyUserId === c.id ? (
317
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
318
+ ) : (
319
+ <UserPlus className="h-3.5 w-3.5 text-muted-foreground" />
320
+ )}
321
+ </button>
322
+ ))
323
+ )}
324
+ </div>
325
+ </div>
326
+ ) : null}
327
+
328
+ <div className="min-h-0 flex-1 overflow-y-auto">
329
+ {loading ? (
330
+ <div className="space-y-2 p-3">
331
+ <Skeleton className="h-10 w-full" />
332
+ <Skeleton className="h-10 w-5/6" />
333
+ </div>
334
+ ) : members.length === 0 ? (
335
+ // Đang mở ô thêm thì bảng hướng dẫn im lặng: người dùng đã làm đúng
336
+ // việc nó bảo làm, để lại chỉ tổ tranh chỗ với danh sách ứng viên.
337
+ adding ? null : (
338
+ // Trạng thái rỗng phải DẠY việc, không chỉ báo trống: nói thêm người
339
+ // vào đây thì họ được gì, và ai mới cần đặt làm quản trị.
340
+ <div className="mx-auto max-w-sm px-6 py-12 text-center">
341
+ <Users className="mx-auto h-8 w-8 text-muted-foreground/60" />
342
+ <p className="mt-3 text-sm font-medium">
343
+ Chưa có ai trong {kindLabel?.toLowerCase() ?? "workspace"} này
344
+ </p>
345
+ <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
346
+ Người được thêm vào đây sẽ thấy dữ liệu của{" "}
347
+ {workspaceName ?? "workspace này"}. Đặt một người làm{" "}
348
+ <strong className="font-medium text-foreground">
349
+ quản trị
350
+ </strong>{" "}
351
+ thì họ tự tạo được tài khoản và phân quyền trong phạm vi đó.
352
+ </p>
353
+ {canManage ? (
354
+ <Button
355
+ size="sm"
356
+ className="mt-4"
357
+ onClick={() => setAdding(true)}
358
+ >
359
+ <UserPlus className="h-3.5 w-3.5" />
360
+ <span className="ml-1.5">Thêm thành viên</span>
361
+ </Button>
362
+ ) : null}
363
+ </div>
364
+ )
365
+ ) : (
366
+ <ul>
367
+ {members.map((m) => (
368
+ <li
369
+ key={m.userId}
370
+ className={cn(
371
+ "flex items-center gap-3 border-b border-border/60 px-3 py-2 last:border-b-0",
372
+ busyUserId === m.userId && "opacity-50",
373
+ )}
374
+ >
375
+ <span
376
+ aria-hidden
377
+ className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] font-semibold text-muted-foreground"
378
+ >
379
+ {initials(m.name, m.email)}
380
+ </span>
381
+
382
+ <span className="min-w-0 flex-1">
383
+ <span className="block truncate text-sm font-medium">
384
+ {m.name ?? m.email}
385
+ {m.isActive === false ? (
386
+ <span className="ml-1.5 text-xs font-normal text-muted-foreground">
387
+ (ngừng hoạt động)
388
+ </span>
389
+ ) : null}
390
+ </span>
391
+ {m.name && m.email ? (
392
+ <span className="block truncate text-xs text-muted-foreground">
393
+ {m.email}
394
+ </span>
395
+ ) : null}
396
+ </span>
397
+
398
+ {/* Cột vai trò: mọi dòng đều có chữ, không chỉ dòng quản trị —
399
+ có chữ ở cả hai trạng thái thì mới đọc ra đây là một CỘT. */}
400
+ <span className="shrink-0">
401
+ {m.isAdmin ? (
402
+ <Badge variant="secondary" className="gap-1">
403
+ <Shield className="h-3 w-3" />
404
+ Quản trị
405
+ </Badge>
406
+ ) : (
407
+ <span className="text-xs text-muted-foreground">
408
+ Thành viên
409
+ </span>
410
+ )}
411
+ </span>
412
+
413
+ {canManage ? (
414
+ <DropdownMenu>
415
+ <DropdownMenuTrigger asChild>
416
+ <Button
417
+ size="sm"
418
+ variant="ghost"
419
+ className="h-7 w-7 shrink-0 p-0 text-muted-foreground"
420
+ disabled={busyUserId === m.userId}
421
+ title="Thao tác"
422
+ >
423
+ {busyUserId === m.userId ? (
424
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
425
+ ) : (
426
+ <MoreHorizontal className="h-3.5 w-3.5" />
427
+ )}
428
+ </Button>
429
+ </DropdownMenuTrigger>
430
+ <DropdownMenuContent align="end">
431
+ <DropdownMenuItem onClick={() => toggleAdmin(m)}>
432
+ {m.isAdmin ? "Bỏ quyền quản trị" : "Đặt làm quản trị"}
433
+ </DropdownMenuItem>
434
+ <DropdownMenuSeparator />
435
+ <DropdownMenuItem
436
+ className="text-destructive focus:text-destructive"
437
+ onClick={() => remove(m.userId)}
438
+ >
439
+ <Trash2 className="h-3.5 w-3.5" />
440
+ <span className="ml-2">
441
+ Gỡ khỏi {kindLabel?.toLowerCase() ?? "workspace"}
442
+ </span>
443
+ </DropdownMenuItem>
444
+ </DropdownMenuContent>
445
+ </DropdownMenu>
446
+ ) : null}
447
+ </li>
448
+ ))}
449
+ </ul>
450
+ )}
451
+ </div>
452
+ </div>
453
+ );
454
+ }