@goplusvn/core 0.1.78 → 0.1.81

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.
@@ -11,12 +11,10 @@ import * as React from "react";
11
11
  import {
12
12
  Loader2,
13
13
  MoreHorizontal,
14
- Search,
15
14
  Shield,
16
15
  Trash2,
17
16
  UserPlus,
18
17
  Users,
19
- X,
20
18
  } from "lucide-react";
21
19
  import { toast } from "sonner";
22
20
 
@@ -28,10 +26,19 @@ import {
28
26
  DropdownMenuItem,
29
27
  DropdownMenuSeparator,
30
28
  DropdownMenuTrigger,
31
- Input,
32
29
  Skeleton,
33
30
  } from "../../ui";
34
31
  import { cn } from "../../utils";
32
+ import {
33
+ WorkspaceIdentityLine,
34
+ WorkspaceMemberPickerDialog,
35
+ } from "./workspace-member-picker-dialog";
36
+ import type {
37
+ CandidateFilter,
38
+ WorkspaceUserIdentity,
39
+ } from "./workspace-member-picker-dialog";
40
+
41
+ export type { CandidateFilter };
35
42
 
36
43
  /** Chữ cái đầu cho ô avatar — hai ký tự là đủ nhận diện, ba trở lên thành rối. */
37
44
  function initials(name?: string | null, email?: string | null): string {
@@ -45,7 +52,7 @@ function initials(name?: string | null, email?: string | null): string {
45
52
  return source.slice(0, 2).toUpperCase();
46
53
  }
47
54
 
48
- export interface WorkspaceMember {
55
+ export interface WorkspaceMember extends WorkspaceUserIdentity {
49
56
  userId: string;
50
57
  name: string | null;
51
58
  email: string | null;
@@ -54,13 +61,6 @@ export interface WorkspaceMember {
54
61
  isDefault?: boolean;
55
62
  }
56
63
 
57
- interface Candidate {
58
- id: string;
59
- name: string | null;
60
- email: string | null;
61
- isActive?: boolean;
62
- }
63
-
64
64
  export interface WorkspaceMembersPanelProps {
65
65
  /** Nút đang chọn trên cây; `null` = chưa chọn gì. */
66
66
  workspaceId: string | null;
@@ -78,6 +78,10 @@ export interface WorkspaceMembersPanelProps {
78
78
  * thuẫn trên cùng một màn hình và không biết tin cái nào.
79
79
  */
80
80
  onCountChange?: (workspaceId: string, count: number) => void;
81
+ /** Trục lọc của app cho hộp thoại thêm thành viên (chi nhánh, vai trò…). */
82
+ bulkFilters?: CandidateFilter[];
83
+ /** Cây đã làm phẳng, cho trục lọc "đang ở bộ phận nào" của hộp thoại. */
84
+ workspaceOptions?: { id: string; name: string; depth: number }[];
81
85
  }
82
86
 
83
87
  export function WorkspaceMembersPanel({
@@ -87,14 +91,13 @@ export function WorkspaceMembersPanel({
87
91
  canManage = false,
88
92
  apiEndpoint = "/api/workspaces",
89
93
  onCountChange,
94
+ bulkFilters,
95
+ workspaceOptions,
90
96
  }: WorkspaceMembersPanelProps) {
91
97
  const [members, setMembers] = React.useState<WorkspaceMember[]>([]);
92
98
  const [loading, setLoading] = React.useState(false);
93
99
  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);
100
+ const [picking, setPicking] = React.useState(false);
98
101
 
99
102
  const base = workspaceId ? `${apiEndpoint}/${workspaceId}/members` : null;
100
103
 
@@ -129,43 +132,11 @@ export function WorkspaceMembersPanel({
129
132
 
130
133
  React.useEffect(() => {
131
134
  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([]);
135
+ // Đổi nút thì đóng luôn hộp thêm — để mở là người dùng tưởng đang thêm vào
136
+ // nút cũ, mà hộp đã gắn với nút mới rồi.
137
+ setPicking(false);
136
138
  }, [reload]);
137
139
 
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
140
  async function mutate(
170
141
  init: RequestInit & { url: string },
171
142
  userId: string,
@@ -189,18 +160,6 @@ export function WorkspaceMembersPanel({
189
160
  }
190
161
  }
191
162
 
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
163
  const toggleAdmin = (member: WorkspaceMember) =>
205
164
  mutate(
206
165
  {
@@ -250,81 +209,15 @@ export function WorkspaceMembersPanel({
250
209
  {canManage ? (
251
210
  <Button
252
211
  size="sm"
253
- variant={adding ? "secondary" : "default"}
254
212
  className="ml-auto"
255
- onClick={() => setAdding((v) => !v)}
213
+ onClick={() => setPicking(true)}
256
214
  >
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>
215
+ <UserPlus className="h-3.5 w-3.5" />
216
+ <span className="ml-1.5">Thêm thành viên</span>
265
217
  </Button>
266
218
  ) : null}
267
219
  </div>
268
220
 
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
221
  <div className="min-h-0 flex-1 overflow-y-auto">
329
222
  {loading ? (
330
223
  <div className="space-y-2 p-3">
@@ -332,36 +225,26 @@ export function WorkspaceMembersPanel({
332
225
  <Skeleton className="h-10 w-5/6" />
333
226
  </div>
334
227
  ) : 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 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 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
- )
228
+ // 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
229
+ // vào đây thì họ được gì, ai mới cần đặt làm quản trị.
230
+ <div className="mx-auto max-w-sm px-6 py-12 text-center">
231
+ <Users className="mx-auto h-8 w-8 text-muted-foreground/60" />
232
+ <p className="mt-3 text-sm font-medium">
233
+ Chưa ai trong {kindLabel?.toLowerCase() ?? "workspace"} này
234
+ </p>
235
+ <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
236
+ Người được thêm vào đây sẽ thấy dữ liệu của{" "}
237
+ {workspaceName ?? "workspace này"}. Đặt một người làm{" "}
238
+ <strong className="font-medium text-foreground">quản trị</strong>{" "}
239
+ thì họ tự tạo được tài khoản phân quyền trong phạm vi đó.
240
+ </p>
241
+ {canManage ? (
242
+ <Button size="sm" className="mt-4" onClick={() => setPicking(true)}>
243
+ <UserPlus className="h-3.5 w-3.5" />
244
+ <span className="ml-1.5">Thêm thành viên</span>
245
+ </Button>
246
+ ) : null}
247
+ </div>
365
248
  ) : (
366
249
  <ul>
367
250
  {members.map((m) => (
@@ -393,6 +276,13 @@ export function WorkspaceMembersPanel({
393
276
  {m.email}
394
277
  </span>
395
278
  ) : null}
279
+ {/* "cũng ở" chứ không phải "ở": họ chắc chắn ở nút đang mở,
280
+ cái đáng cảnh báo là những nhánh CÒN LẠI — còn nhánh nào
281
+ thì họ còn thấy dữ liệu ở đó. */}
282
+ <WorkspaceIdentityLine
283
+ identity={m}
284
+ workspacePrefix="cũng ở:"
285
+ />
396
286
  </span>
397
287
 
398
288
  {/* Cột vai trò: mọi dòng đều có chữ, không chỉ dòng quản trị —
@@ -449,6 +339,18 @@ export function WorkspaceMembersPanel({
449
339
  </ul>
450
340
  )}
451
341
  </div>
342
+
343
+ <WorkspaceMemberPickerDialog
344
+ open={picking}
345
+ onOpenChange={setPicking}
346
+ workspaceId={workspaceId}
347
+ workspaceName={workspaceName}
348
+ kindLabel={kindLabel}
349
+ apiEndpoint={apiEndpoint}
350
+ filters={bulkFilters}
351
+ workspaceOptions={workspaceOptions}
352
+ onDone={() => void reload()}
353
+ />
452
354
  </div>
453
355
  );
454
356
  }