@goplusvn/core 0.1.79 → 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.
@@ -0,0 +1,492 @@
1
+ "use client";
2
+
3
+ // Hộp thoại "Thêm thành viên vào một không gian".
4
+ //
5
+ // Vì sao là HỘP THOẠI chứ không phải ô mở ra trong panel: đây là việc TÌM —
6
+ // gõ mã nhân viên, đổi chi nhánh, đổi vai trò, lọc theo bộ phận đang ở, tick
7
+ // vài chục dòng. Nhét vào panel thì danh sách ứng viên cao đúng 224px, nằm
8
+ // ngay trên danh sách thành viên hiện có cũng gồm tên + email, và hai danh
9
+ // sách giống hệt nhau dính nhau là chỗ chắc chắn gán nhầm.
10
+ //
11
+ // Khuôn lấy từ các hộp chọn của vinhhoa (`purchase-order-product-selector`,
12
+ // `branch-select-dialog`): thanh công cụ dính trên nền `card`, vùng kết quả
13
+ // cuộn riêng trên nền `muted`, chân hộp là nơi duy nhất có nút hành động.
14
+
15
+ import * as React from "react";
16
+
17
+ import { Building2, Loader2, Network, Search, ShieldCheck } from "lucide-react";
18
+ import { toast } from "sonner";
19
+
20
+ import {
21
+ Button,
22
+ Checkbox,
23
+ Dialog,
24
+ DialogContent,
25
+ DialogDescription,
26
+ DialogFooter,
27
+ DialogHeader,
28
+ DialogTitle,
29
+ Input,
30
+ Select,
31
+ SelectContent,
32
+ SelectItem,
33
+ SelectTrigger,
34
+ SelectValue,
35
+ } from "../../ui";
36
+ import { cn } from "../../utils";
37
+
38
+ /**
39
+ * Radix `SelectItem` cấm `value=""` (chuỗi rỗng là "chưa chọn" của chính nó),
40
+ * nên "tất cả" phải mang một giá trị canh gác. Không được gửi giá trị này lên
41
+ * server — chỗ đổi bộ lọc quy nó về chuỗi rỗng.
42
+ */
43
+ const ALL_FILTER = "__all__";
44
+
45
+ /**
46
+ * Một trục lọc do app khai (chi nhánh, vai trò…). Core không biết chúng nghĩa
47
+ * là gì — `key` được gửi thẳng lên query string, và `deps.candidateFilter` ở
48
+ * phía route dịch nó thành `where` của Prisma.
49
+ */
50
+ export interface CandidateFilter {
51
+ key: string;
52
+ label: string;
53
+ options: { value: string; label: string }[];
54
+ }
55
+
56
+ /** Danh tính đi kèm mỗi dòng người dùng — do route trả về. */
57
+ export interface WorkspaceUserIdentity {
58
+ /** Mã nhân viên. Vắng khi app không khai `memberIdentity`. */
59
+ code?: string | null;
60
+ branch?: string | null;
61
+ roles?: string[];
62
+ /** Các không gian KHÁC người này đang thuộc về (đã trừ nút đang mở). */
63
+ workspaces?: string[];
64
+ }
65
+
66
+ export interface WorkspaceCandidate extends WorkspaceUserIdentity {
67
+ id: string;
68
+ name: string | null;
69
+ email: string | null;
70
+ isActive?: boolean;
71
+ }
72
+
73
+ /**
74
+ * Dòng phụ dưới tên: chi nhánh · vai trò · đang ở đâu.
75
+ *
76
+ * Một dòng chứ không phải ba cột: bên phải mỗi dòng còn huy hiệu và menu thao
77
+ * tác, nhồi thêm cột thì tên — thứ người ta quét mắt tìm — bị ép còn vài chữ.
78
+ * Rỗng thì không vẽ gì, không để lại dòng trống.
79
+ */
80
+ export function WorkspaceIdentityLine({
81
+ identity,
82
+ workspacePrefix,
83
+ }: {
84
+ identity: WorkspaceUserIdentity;
85
+ workspacePrefix: string;
86
+ }) {
87
+ const roles = identity.roles ?? [];
88
+ const workspaces = identity.workspaces ?? [];
89
+ if (!identity.branch && roles.length === 0 && workspaces.length === 0) {
90
+ return null;
91
+ }
92
+ return (
93
+ <span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted-foreground">
94
+ {identity.branch ? (
95
+ <span className="inline-flex max-w-40 items-center gap-1">
96
+ <Building2 className="h-3 w-3 shrink-0" />
97
+ <span className="truncate">{identity.branch}</span>
98
+ </span>
99
+ ) : null}
100
+ {roles.length > 0 ? (
101
+ <span
102
+ className="inline-flex max-w-52 items-center gap-1"
103
+ title={roles.join(", ")}
104
+ >
105
+ <ShieldCheck className="h-3 w-3 shrink-0" />
106
+ <span className="truncate">{roles.join(" · ")}</span>
107
+ </span>
108
+ ) : null}
109
+ {workspaces.length > 0 ? (
110
+ <span
111
+ className="inline-flex max-w-52 items-center gap-1"
112
+ title={workspaces.join(", ")}
113
+ >
114
+ <Network className="h-3 w-3 shrink-0" />
115
+ <span className="truncate">
116
+ {workspacePrefix} {workspaces.join(", ")}
117
+ </span>
118
+ </span>
119
+ ) : null}
120
+ </span>
121
+ );
122
+ }
123
+
124
+ export interface WorkspaceMemberPickerDialogProps {
125
+ open: boolean;
126
+ onOpenChange: (open: boolean) => void;
127
+ /** Nút đang nhận người. `null` = chưa chọn gì, hộp không mở được. */
128
+ workspaceId: string | null;
129
+ workspaceName?: string | null;
130
+ /** Nhãn cấp của app ("Đơn vị" / "Bộ phận") — core không tự đặt tên. */
131
+ kindLabel?: string;
132
+ apiEndpoint?: string;
133
+ /** Trục lọc của app: chi nhánh, vai trò… */
134
+ filters?: CandidateFilter[];
135
+ /**
136
+ * Danh sách "đang ở bộ phận nào" — trục lọc CORE tự phục vụ được, vì bảng
137
+ * `userWorkspace` là của core. Trang cha làm phẳng cây rồi đưa vào đây.
138
+ */
139
+ workspaceOptions?: { id: string; name: string; depth: number }[];
140
+ /** Gán xong: trang cha tải lại danh sách thành viên. */
141
+ onDone?: () => void;
142
+ }
143
+
144
+ export function WorkspaceMemberPickerDialog({
145
+ open,
146
+ onOpenChange,
147
+ workspaceId,
148
+ workspaceName,
149
+ kindLabel,
150
+ apiEndpoint = "/api/workspaces",
151
+ filters,
152
+ workspaceOptions,
153
+ onDone,
154
+ }: WorkspaceMemberPickerDialogProps) {
155
+ const [query, setQuery] = React.useState("");
156
+ const [filterValues, setFilterValues] = React.useState<
157
+ Record<string, string>
158
+ >({});
159
+ const [candidates, setCandidates] = React.useState<WorkspaceCandidate[]>([]);
160
+ const [searching, setSearching] = React.useState(false);
161
+ const [selected, setSelected] = React.useState<Set<string>>(new Set());
162
+ const [assigning, setAssigning] = React.useState(false);
163
+
164
+ const base = workspaceId ? `${apiEndpoint}/${workspaceId}/members` : null;
165
+ // Chuỗi hoá để đưa vào deps của effect: để nguyên object thì mỗi lần render
166
+ // lại là một tham chiếu mới, effect chạy vô hạn.
167
+ const filterKey = JSON.stringify(filterValues);
168
+
169
+ // Mở lại hộp là một phiên tìm MỚI: giữ lại tick cũ thì người dùng bấm
170
+ // "Chuyển" và kéo theo cả những người họ chọn cho một bộ phận khác.
171
+ React.useEffect(() => {
172
+ if (!open) return;
173
+ setQuery("");
174
+ setFilterValues({});
175
+ setSelected(new Set());
176
+ }, [open, workspaceId]);
177
+
178
+ // Gõ tới đâu hỏi tới đó, có hoãn để không bắn mỗi phím một request.
179
+ React.useEffect(() => {
180
+ if (!open || !base) return;
181
+ let cancelled = false;
182
+ setSearching(true);
183
+ const timer = setTimeout(async () => {
184
+ try {
185
+ const params = new URLSearchParams({
186
+ candidates: "1",
187
+ q: query,
188
+ take: "200",
189
+ });
190
+ for (const [key, value] of Object.entries(
191
+ JSON.parse(filterKey) as Record<string, string>,
192
+ )) {
193
+ if (value) params.set(key, value);
194
+ }
195
+ const res = await fetch(`${base}?${params.toString()}`, {
196
+ cache: "no-store",
197
+ });
198
+ if (!res.ok) throw new Error((await res.json())?.error ?? "Lỗi tìm");
199
+ const data = await res.json();
200
+ if (!cancelled) setCandidates(data);
201
+ } catch (error) {
202
+ if (!cancelled) {
203
+ setCandidates([]);
204
+ toast.error(
205
+ error instanceof Error
206
+ ? error.message
207
+ : "Không tìm được người dùng.",
208
+ );
209
+ }
210
+ } finally {
211
+ if (!cancelled) setSearching(false);
212
+ }
213
+ }, 250);
214
+ return () => {
215
+ cancelled = true;
216
+ clearTimeout(timer);
217
+ };
218
+ }, [open, base, query, filterKey]);
219
+
220
+ const setFilter = (key: string, value: string) => {
221
+ setFilterValues((prev) => ({
222
+ ...prev,
223
+ [key]: value === ALL_FILTER ? "" : value,
224
+ }));
225
+ // Đổi bộ lọc là danh sách đổi hẳn — giữ tick cũ thì người dùng bấm
226
+ // "Chuyển" và kéo theo cả những người họ không còn nhìn thấy trên màn hình.
227
+ setSelected(new Set());
228
+ };
229
+
230
+ /**
231
+ * Gán nhiều người một lượt.
232
+ *
233
+ * `move` GỠ mọi không gian khác của người được chọn. Đó là điểm dễ hiểu nhầm
234
+ * nhất của cả màn hình này: phạm vi đọc là HỢP các không gian một người thuộc
235
+ * về, nên nếu chỉ THÊM mà không gỡ thì người vừa được "gán vào bộ phận Kho"
236
+ * vẫn còn membership gốc và vẫn thấy toàn hệ thống — bấm xong tưởng đã cô lập
237
+ * mà thực ra không đổi gì.
238
+ */
239
+ async function assign(move: boolean) {
240
+ if (!base || selected.size === 0) return;
241
+ setAssigning(true);
242
+ try {
243
+ const res = await fetch(base, {
244
+ method: "POST",
245
+ headers: { "content-type": "application/json" },
246
+ body: JSON.stringify({
247
+ userIds: [...selected],
248
+ mode: move ? "move" : "add",
249
+ }),
250
+ });
251
+ const payload = await res.json().catch(() => ({}));
252
+ if (!res.ok) throw new Error(payload?.error ?? "Gán không thành công.");
253
+
254
+ const failed: { userId: string; error: string }[] = payload.failed ?? [];
255
+ if (payload.changed > 0) {
256
+ toast.success(
257
+ move
258
+ ? `Đã chuyển ${payload.changed} người vào ${workspaceName ?? "không gian này"}.`
259
+ : `Đã thêm ${payload.changed} người.`,
260
+ );
261
+ }
262
+ // Từng dòng hỏng có lý do RIÊNG (ngoài phạm vi, tài khoản được bảo vệ…).
263
+ // Gộp thành một câu "có lỗi" thì người dùng không biết bỏ tick ai ra.
264
+ if (failed.length > 0) {
265
+ toast.error(
266
+ `${failed.length} người không gán được: ${failed[0].error}` +
267
+ (failed.length > 1 ? " (và các lỗi khác)" : ""),
268
+ );
269
+ }
270
+ onDone?.();
271
+ if (failed.length === 0) onOpenChange(false);
272
+ else setSelected(new Set(failed.map((f) => f.userId)));
273
+ } catch (error) {
274
+ toast.error(error instanceof Error ? error.message : "Gán lỗi.");
275
+ } finally {
276
+ setAssigning(false);
277
+ }
278
+ }
279
+
280
+ const allChecked =
281
+ candidates.length > 0 && selected.size === candidates.length;
282
+
283
+ return (
284
+ <Dialog open={open} onOpenChange={onOpenChange}>
285
+ {/* `p-0 gap-0` + ba tầng cuộn riêng: thanh công cụ và chân hộp phải đứng
286
+ yên khi danh sách 200 dòng cuộn qua. */}
287
+ <DialogContent className="flex h-[85vh] max-w-3xl flex-col gap-0 p-0">
288
+ <DialogHeader className="border-b border-border p-4 text-left">
289
+ <DialogTitle>
290
+ Thêm thành viên vào {workspaceName ?? "không gian này"}
291
+ </DialogTitle>
292
+ <DialogDescription>
293
+ Tìm theo họ tên, mã nhân viên hoặc email; lọc theo chi nhánh, vai
294
+ trò, bộ phận đang ở. Tick nhiều dòng rồi gán một lượt.
295
+ </DialogDescription>
296
+ </DialogHeader>
297
+
298
+ <div className="space-y-2.5 border-b border-border bg-card p-4">
299
+ <div className="relative">
300
+ <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
301
+ <Input
302
+ autoFocus
303
+ placeholder="Họ tên, mã nhân viên, email…"
304
+ className="h-10 pl-9"
305
+ value={query}
306
+ onChange={(e) => setQuery(e.target.value)}
307
+ />
308
+ </div>
309
+
310
+ <div className="flex flex-wrap items-center gap-2">
311
+ {workspaceOptions && workspaceOptions.length > 1 ? (
312
+ <Select
313
+ value={filterValues.inWorkspaceId || ALL_FILTER}
314
+ onValueChange={(value) => setFilter("inWorkspaceId", value)}
315
+ >
316
+ <SelectTrigger className="h-9 w-auto min-w-44 text-sm">
317
+ <SelectValue placeholder="Đang ở" />
318
+ </SelectTrigger>
319
+ <SelectContent>
320
+ <SelectItem value={ALL_FILTER}>
321
+ Đang ở: mọi bộ phận
322
+ </SelectItem>
323
+ {workspaceOptions
324
+ .filter((option) => option.id !== workspaceId)
325
+ .map((option) => (
326
+ <SelectItem key={option.id} value={option.id}>
327
+ {" ".repeat(option.depth * 2)}
328
+ {option.name}
329
+ </SelectItem>
330
+ ))}
331
+ </SelectContent>
332
+ </Select>
333
+ ) : null}
334
+
335
+ {filters?.map((filter) => (
336
+ <Select
337
+ key={filter.key}
338
+ value={filterValues[filter.key] || ALL_FILTER}
339
+ onValueChange={(value) => setFilter(filter.key, value)}
340
+ >
341
+ <SelectTrigger className="h-9 w-auto min-w-40 text-sm">
342
+ <SelectValue placeholder={filter.label} />
343
+ </SelectTrigger>
344
+ <SelectContent>
345
+ <SelectItem value={ALL_FILTER}>
346
+ {filter.label}: tất cả
347
+ </SelectItem>
348
+ {filter.options.map((option) => (
349
+ <SelectItem key={option.value} value={option.value}>
350
+ {option.label}
351
+ </SelectItem>
352
+ ))}
353
+ </SelectContent>
354
+ </Select>
355
+ ))}
356
+
357
+ {candidates.length > 0 ? (
358
+ <label className="ml-auto flex cursor-pointer items-center gap-2 text-xs text-muted-foreground">
359
+ <Checkbox
360
+ checked={allChecked}
361
+ onCheckedChange={(checked) =>
362
+ setSelected(
363
+ checked
364
+ ? new Set(candidates.map((c) => c.id))
365
+ : new Set(),
366
+ )
367
+ }
368
+ />
369
+ Chọn tất cả {candidates.length} người đang hiện
370
+ </label>
371
+ ) : null}
372
+ </div>
373
+ </div>
374
+
375
+ <div className="min-h-0 flex-1 space-y-1 overflow-y-auto bg-muted/30 p-2">
376
+ {searching && candidates.length === 0 ? (
377
+ <p className="px-2 py-6 text-center text-sm text-muted-foreground">
378
+ Đang tìm…
379
+ </p>
380
+ ) : candidates.length === 0 ? (
381
+ // Danh sách rỗng ở đây gần như luôn là "ngoài phạm vi", không phải
382
+ // "hệ thống không có ai" — nói thẳng để khỏi tưởng mất dữ liệu.
383
+ <div className="mx-auto max-w-sm px-6 py-14 text-center">
384
+ <Search className="mx-auto h-8 w-8 text-muted-foreground/50" />
385
+ <p className="mt-3 text-sm font-medium">
386
+ Không có người dùng nào khớp
387
+ </p>
388
+ <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
389
+ Danh sách chỉ gồm người trong phạm vi quản trị của bạn và chưa
390
+ thuộc {kindLabel?.toLowerCase() ?? "không gian"} này.
391
+ </p>
392
+ </div>
393
+ ) : (
394
+ candidates.map((c) => {
395
+ const checked = selected.has(c.id);
396
+ return (
397
+ <label
398
+ key={c.id}
399
+ className={cn(
400
+ "flex cursor-pointer items-center gap-3 rounded-lg border bg-card px-3 py-2 transition-colors",
401
+ checked
402
+ ? "border-primary/40 bg-accent"
403
+ : "border-transparent hover:border-border",
404
+ )}
405
+ >
406
+ <Checkbox
407
+ checked={checked}
408
+ onCheckedChange={(value) =>
409
+ setSelected((prev) => {
410
+ const next = new Set(prev);
411
+ if (value) next.add(c.id);
412
+ else next.delete(c.id);
413
+ return next;
414
+ })
415
+ }
416
+ />
417
+ <span className="min-w-0 flex-1">
418
+ <span className="flex items-center gap-2">
419
+ <span className="truncate text-sm font-medium">
420
+ {c.name ?? c.email}
421
+ </span>
422
+ {/* Mã nhân viên nằm CẠNH TÊN, không ở dòng phụ: đây là
423
+ thứ phân biệt hai người trùng tên, mà trùng tên là
424
+ chuyện thường ở danh sách 700 người. */}
425
+ {c.code ? (
426
+ <span className="shrink-0 rounded border border-border bg-muted px-1.5 py-px text-[10px] font-medium tabular-nums text-muted-foreground">
427
+ {c.code}
428
+ </span>
429
+ ) : null}
430
+ {c.isActive === false ? (
431
+ <span className="shrink-0 text-[11px] text-muted-foreground">
432
+ (ngừng hoạt động)
433
+ </span>
434
+ ) : null}
435
+ </span>
436
+ {c.name && c.email ? (
437
+ <span className="block truncate text-xs text-muted-foreground">
438
+ {c.email}
439
+ </span>
440
+ ) : null}
441
+ <WorkspaceIdentityLine identity={c} workspacePrefix="đang ở:" />
442
+ </span>
443
+ </label>
444
+ );
445
+ })
446
+ )}
447
+ </div>
448
+
449
+ {/* Hai nút, hai nghĩa khác nhau, nên nhãn phải nói ra HỆ QUẢ chứ không
450
+ phải "Lưu": "Chuyển" gỡ nơi cũ (⇒ cô lập thật), "Thêm" giữ nguyên
451
+ nơi cũ (⇒ người đó thấy CẢ HAI nhánh). */}
452
+ <DialogFooter className="flex-col items-stretch gap-2 border-t border-border p-3 sm:flex-row sm:items-center">
453
+ <p className="mr-auto text-xs text-muted-foreground">
454
+ {selected.size > 0 ? (
455
+ <>
456
+ <span className="font-medium tabular-nums text-foreground">
457
+ Đã chọn {selected.size}
458
+ </span>{" "}
459
+ — “Chuyển” gỡ họ khỏi các không gian khác, đó là điều kiện để họ
460
+ chỉ còn thấy dữ liệu ở đây.
461
+ </>
462
+ ) : (
463
+ "Tick vào người cần gán."
464
+ )}
465
+ </p>
466
+ <Button
467
+ variant="outline"
468
+ size="sm"
469
+ disabled={assigning || selected.size === 0}
470
+ onClick={() => void assign(false)}
471
+ >
472
+ Thêm, giữ cả nơi cũ
473
+ </Button>
474
+ <Button
475
+ size="sm"
476
+ disabled={assigning || selected.size === 0}
477
+ onClick={() => void assign(true)}
478
+ >
479
+ {assigning ? (
480
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
481
+ ) : null}
482
+ <span className={assigning ? "ml-1.5" : ""}>
483
+ {selected.size > 0
484
+ ? `Chuyển ${selected.size} người vào đây`
485
+ : "Chuyển vào đây"}
486
+ </span>
487
+ </Button>
488
+ </DialogFooter>
489
+ </DialogContent>
490
+ </Dialog>
491
+ );
492
+ }