@goplusvn/core 0.1.22 → 0.1.24

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.
@@ -1,25 +1,1421 @@
1
- import { EntityCrudPage } from "../../crud/pages/entity-crud-page";
1
+ "use client";
2
2
 
3
- interface ResourceListPageProps {
4
- lang: string;
5
- session: any;
6
- dictionary: any;
7
- config: any;
3
+ // Trang "Quản lý tài nguyên" — nhóm tài nguyên theo section, bung hàng để
4
+ // xem/sửa toàn bộ action ngay tại chỗ. Bề mặt trắng (bg-card), nút sửa LUÔN
5
+ // hiển thị (không ẩn sau hover — mobile không có hover).
6
+ //
7
+ // API contract: /api/resources (+/sync, /:id), /api/actions.
8
+
9
+ import { useEffect, useMemo, useState } from "react";
10
+ import { toast } from "sonner";
11
+ import {
12
+ Box,
13
+ Boxes,
14
+ Check,
15
+ ChevronDown,
16
+ ChevronLeft,
17
+ ChevronRight,
18
+ ClipboardList,
19
+ DatabaseBackup,
20
+ Layers,
21
+ Pencil,
22
+ Plus,
23
+ RefreshCw,
24
+ Search,
25
+ Settings,
26
+ Shield,
27
+ ShoppingBag,
28
+ Trash2,
29
+ Truck,
30
+ Users,
31
+ Wallet,
32
+ Zap,
33
+ } from "lucide-react";
34
+
35
+ import {
36
+ Badge,
37
+ Button,
38
+ CompactStatBar,
39
+ type CompactStatItem,
40
+ ConfirmDialog,
41
+ Dialog,
42
+ DialogContent,
43
+ DialogFooter,
44
+ DialogHeader,
45
+ DialogTitle,
46
+ DynamicIcon,
47
+ Input,
48
+ Label,
49
+ ListToolbar,
50
+ ScrollArea,
51
+ Select,
52
+ SelectContent,
53
+ SelectItem,
54
+ SelectTrigger,
55
+ SelectValue,
56
+ Skeleton,
57
+ Textarea,
58
+ } from "../../ui";
59
+ import { cn } from "../../utils";
60
+ import { errorMessage, throwFetchError } from "../lib/fetch-error";
61
+
62
+ // --- Types ---
63
+ interface Resource {
64
+ id: string;
65
+ code: string;
66
+ name: string;
67
+ group: string;
68
+ description?: string;
69
+ icon?: string;
70
+ actions: string[];
71
+ config?: any;
72
+ }
73
+
74
+ interface Action {
75
+ id: string;
76
+ code: string;
77
+ name: string;
78
+ description?: string;
79
+ }
80
+
81
+ // --- Group metadata: one muted hue per group so sections are visually
82
+ // distinct without going back to the old "rainbow" look. `iconCls` tints the
83
+ // group icon + each resource avatar. ---
84
+ const GROUP_META: Record<
85
+ string,
86
+ { icon: any; label: string; iconCls: string }
87
+ > = {
88
+ sales: {
89
+ icon: ShoppingBag,
90
+ label: "Bán hàng",
91
+ iconCls:
92
+ "text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/15",
93
+ },
94
+ purchase: {
95
+ icon: ClipboardList,
96
+ label: "Mua hàng",
97
+ iconCls:
98
+ "text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/15",
99
+ },
100
+ inventory: {
101
+ icon: Truck,
102
+ label: "Kho",
103
+ iconCls:
104
+ "text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/15",
105
+ },
106
+ finance: {
107
+ icon: Wallet,
108
+ label: "Tài chính",
109
+ iconCls:
110
+ "text-violet-600 bg-violet-100 dark:text-violet-400 dark:bg-violet-500/15",
111
+ },
112
+ hr: {
113
+ icon: Users,
114
+ label: "Nhân sự",
115
+ iconCls:
116
+ "text-rose-600 bg-rose-100 dark:text-rose-400 dark:bg-rose-500/15",
117
+ },
118
+ master: {
119
+ icon: Boxes,
120
+ label: "Danh mục",
121
+ iconCls:
122
+ "text-cyan-600 bg-cyan-100 dark:text-cyan-400 dark:bg-cyan-500/15",
123
+ },
124
+ users: {
125
+ icon: Users,
126
+ label: "Người dùng",
127
+ iconCls:
128
+ "text-fuchsia-600 bg-fuchsia-100 dark:text-fuchsia-400 dark:bg-fuchsia-500/15",
129
+ },
130
+ system: {
131
+ icon: Settings,
132
+ label: "Hệ thống",
133
+ iconCls:
134
+ "text-slate-600 bg-slate-200 dark:text-slate-300 dark:bg-slate-500/20",
135
+ },
136
+ other: {
137
+ icon: Box,
138
+ label: "Khác",
139
+ iconCls:
140
+ "text-slate-600 bg-slate-200 dark:text-slate-300 dark:bg-slate-500/20",
141
+ },
142
+ };
143
+
144
+ const GROUP_ORDER = [
145
+ "sales",
146
+ "purchase",
147
+ "inventory",
148
+ "finance",
149
+ "hr",
150
+ "master",
151
+ "users",
152
+ "system",
153
+ "other",
154
+ ];
155
+
156
+ const getGroupMeta = (group: string) =>
157
+ GROUP_META[group?.toLowerCase()] || {
158
+ icon: Box,
159
+ label: group || "Khác",
160
+ iconCls:
161
+ "text-slate-600 bg-slate-200 dark:text-slate-300 dark:bg-slate-500/20",
162
+ };
163
+
164
+ // --- Action chips colored by permission category (read/create/update/delete/
165
+ // approve). Most domain-specific codes fall through to neutral, which keeps
166
+ // the palette calm instead of rainbow. ---
167
+ const ACTION_TONES: Record<string, string> = {
168
+ read: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-300",
169
+ create:
170
+ "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300",
171
+ update:
172
+ "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-300",
173
+ delete:
174
+ "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300",
175
+ approve:
176
+ "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300",
177
+ neutral: "border-border bg-muted text-foreground/80",
178
+ };
179
+
180
+ const getActionTone = (code: string): string => {
181
+ if (/^(view|read|get|list|export|print|sync)/.test(code)) return "read";
182
+ if (/^(create|add|new)/.test(code)) return "create";
183
+ if (/^(update|edit|change|set)/.test(code)) return "update";
184
+ if (/^(delete|remove)/.test(code)) return "delete";
185
+ if (/^(approve|confirm|sign|verify|accept)/.test(code)) return "approve";
186
+ return "neutral";
187
+ };
188
+
189
+ // 4 hành động CRUD chuẩn → nhóm "Cơ bản" (thứ tự cố định Xem → Thêm → Sửa →
190
+ // Xóa). Mọi action khác vào nhóm "Chức năng" (sắp A→Z cho dễ tìm).
191
+ const BASIC_ORDER = [
192
+ "view",
193
+ "read",
194
+ "create",
195
+ "add",
196
+ "update",
197
+ "edit",
198
+ "delete",
199
+ "remove",
200
+ ];
201
+
202
+ const categorizeActions = (actions: Action[]) => {
203
+ const basic: Action[] = [];
204
+ const functional: Action[] = [];
205
+ for (const a of actions) {
206
+ if (BASIC_ORDER.includes(a.code)) basic.push(a);
207
+ else functional.push(a);
208
+ }
209
+ basic.sort(
210
+ (x, y) => BASIC_ORDER.indexOf(x.code) - BASIC_ORDER.indexOf(y.code),
211
+ );
212
+ functional.sort((x, y) => x.name.localeCompare(y.name, "vi"));
213
+ return { basic, functional };
214
+ };
215
+
216
+ // Diacritic-insensitive normalize (so "duyet" matches "Duyệt")
217
+ const normalize = (s: string) =>
218
+ (s || "")
219
+ .toLowerCase()
220
+ .normalize("NFD")
221
+ .replace(/[\u0300-\u036f]/g, "")
222
+ .replace(/đ/g, "d");
223
+
224
+ export function ResourceListPage() {
225
+ const [loading, setLoading] = useState(true);
226
+ const [resources, setResources] = useState<Resource[]>([]);
227
+ const [allActions, setAllActions] = useState<Action[]>([]);
228
+ const [searchTerm, setSearchTerm] = useState("");
229
+ const [isRefreshing, setIsRefreshing] = useState(false);
230
+ const [isSyncing, setIsSyncing] = useState(false);
231
+ const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
232
+ new Set(),
233
+ );
234
+
235
+ // Hàng nào đang bung để xem/sửa toàn bộ action (inline, không cần mở Sheet)
236
+ const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
237
+
238
+ // Resource Dialog State
239
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
240
+ const [editingResource, setEditingResource] = useState<Resource | null>(
241
+ null,
242
+ );
243
+ const [formData, setFormData] = useState({
244
+ code: "",
245
+ name: "",
246
+ group: "",
247
+ description: "",
248
+ });
249
+
250
+ // Action Manager State
251
+ const [isActionManagerOpen, setIsActionManagerOpen] = useState(false);
252
+ const [actionFormMode, setActionFormMode] = useState<
253
+ "list" | "create" | "edit"
254
+ >("list");
255
+ const [editingAction, setEditingAction] = useState<Action | null>(null);
256
+ const [actionFormData, setActionFormData] = useState({
257
+ code: "",
258
+ name: "",
259
+ description: "",
260
+ });
261
+
262
+ // Confirmation state
263
+ const [confirmOpen, setConfirmOpen] = useState(false);
264
+ const [confirmType, setConfirmType] = useState<"action" | "resource" | null>(
265
+ null,
266
+ );
267
+ const [itemToDelete, setItemToDelete] = useState<{
268
+ id: string;
269
+ name: string;
270
+ } | null>(null);
271
+ const [isDeleting, setIsDeleting] = useState(false);
272
+
273
+ // --- Data Fetching ---
274
+ const fetchData = async () => {
275
+ try {
276
+ setLoading(true);
277
+ const [resResources, resActions] = await Promise.all([
278
+ fetch("/api/resources?pageSize=1000"),
279
+ fetch("/api/actions?pageSize=1000"),
280
+ ]);
281
+
282
+ if (!resResources.ok || !resActions.ok)
283
+ throw new Error("Failed to fetch data");
284
+
285
+ const resourcesData = await resResources.json();
286
+ const actionsData = await resActions.json();
287
+
288
+ const mappedResources = (resourcesData.items || []).map((r: any) => {
289
+ let actions: string[] = [];
290
+ try {
291
+ if (r.config) {
292
+ const configObj =
293
+ typeof r.config === "string" ? JSON.parse(r.config) : r.config;
294
+ if (configObj.actions && Array.isArray(configObj.actions)) {
295
+ actions = configObj.actions;
296
+ }
297
+ }
298
+ } catch (_catchError) {
299
+ // Ignore invalid config JSON
300
+ }
301
+ return { ...r, actions };
302
+ });
303
+
304
+ setResources(mappedResources);
305
+ setAllActions(actionsData.items || []);
306
+ } catch (error) {
307
+ toast.error(errorMessage(error, "Lỗi tải dữ liệu"));
308
+ } finally {
309
+ setLoading(false);
310
+ setIsRefreshing(false);
311
+ }
312
+ };
313
+
314
+ useEffect(() => {
315
+ fetchData();
316
+ // eslint-disable-next-line react-hooks/exhaustive-deps
317
+ }, []);
318
+
319
+ const handleRefresh = () => {
320
+ setIsRefreshing(true);
321
+ fetchData();
322
+ };
323
+
324
+ // --- Sync Resource ---
325
+ const handleSync = async () => {
326
+ try {
327
+ setIsSyncing(true);
328
+ const res = await fetch("/api/resources/sync", {
329
+ method: "POST",
330
+ });
331
+
332
+ if (!res.ok) {
333
+ await throwFetchError(res, "Lỗi khi đồng bộ tài nguyên");
334
+ }
335
+
336
+ const data = await res.json();
337
+ toast.success(data.message || "Đồng bộ tài nguyên thành công");
338
+
339
+ // Auto refresh data
340
+ handleRefresh();
341
+ } catch (error: any) {
342
+ toast.error(errorMessage(error, "Không thể đồng bộ tài nguyên"));
343
+ } finally {
344
+ setIsSyncing(false);
345
+ }
346
+ };
347
+
348
+ // Quick lookup: action code -> action (for rendering granted chips by name)
349
+ const actionMap = useMemo(() => {
350
+ const map = new Map<string, Action>();
351
+ for (const a of allActions) map.set(a.code, a);
352
+ return map;
353
+ }, [allActions]);
354
+
355
+ // Chia action thành nhóm Cơ bản / Chức năng (Chức năng đã sắp A→Z) — dùng
356
+ // chung cho mọi hàng khi bung.
357
+ const categorizedActions = useMemo(
358
+ () => categorizeActions(allActions),
359
+ [allActions],
360
+ );
361
+
362
+ // --- Search: match a resource by its own text OR by any granted action
363
+ // (so "duyệt" finds resources that can approve). Resources without a match
364
+ // are hidden; everything else is grouped + ordered by section. ---
365
+ const { sections, filteredResourceTotal, isEmpty } = useMemo(() => {
366
+ const q = normalize(searchTerm.trim());
367
+
368
+ const matches = (r: Resource) => {
369
+ if (!q) return true;
370
+ if (
371
+ normalize(r.name).includes(q) ||
372
+ normalize(r.code).includes(q) ||
373
+ normalize(r.group || "").includes(q) ||
374
+ normalize(getGroupMeta(r.group).label).includes(q)
375
+ )
376
+ return true;
377
+ // match on a granted action's name/code
378
+ return (r.actions || []).some((code) => {
379
+ const a = actionMap.get(code);
380
+ return (
381
+ normalize(a?.name || code).includes(q) || normalize(code).includes(q)
382
+ );
383
+ });
384
+ };
385
+
386
+ const rows = resources.filter(matches);
387
+
388
+ if (q && rows.length === 0) {
389
+ return {
390
+ sections: [] as {
391
+ group: string;
392
+ meta: { icon: any; label: string; iconCls: string };
393
+ items: Resource[];
394
+ }[],
395
+ filteredResourceTotal: 0,
396
+ isEmpty: true,
397
+ };
398
+ }
399
+
400
+ const grouped: Record<string, Resource[]> = {};
401
+ for (const r of rows) {
402
+ const g = (r.group || "other").toLowerCase();
403
+ (grouped[g] ||= []).push(r);
404
+ }
405
+
406
+ const orderedSections = Object.keys(grouped)
407
+ .sort((a, b) => {
408
+ const ia = GROUP_ORDER.indexOf(a);
409
+ const ib = GROUP_ORDER.indexOf(b);
410
+ if (ia !== -1 && ib !== -1) return ia - ib;
411
+ if (ia !== -1) return -1;
412
+ if (ib !== -1) return 1;
413
+ return a.localeCompare(b);
414
+ })
415
+ .map((group) => ({
416
+ group,
417
+ meta: getGroupMeta(group),
418
+ items: grouped[group].sort((x, y) => x.name.localeCompare(y.name)),
419
+ }));
420
+
421
+ return {
422
+ sections: orderedSections,
423
+ filteredResourceTotal: rows.length,
424
+ isEmpty: false,
425
+ };
426
+ }, [resources, actionMap, searchTerm]);
427
+
428
+ const toggleGroup = (group: string) => {
429
+ setCollapsedGroups((prev) => {
430
+ const next = new Set(prev);
431
+ if (next.has(group)) next.delete(group);
432
+ else next.add(group);
433
+ return next;
434
+ });
435
+ };
436
+
437
+ // --- Resource Actions ---
438
+ const handleUpdateActions = async (
439
+ resource: Resource,
440
+ newActions: string[],
441
+ ) => {
442
+ try {
443
+ // Optimistic Update
444
+ setResources((prev) =>
445
+ prev.map((r) =>
446
+ r.id === resource.id ? { ...r, actions: newActions } : r,
447
+ ),
448
+ );
449
+
450
+ // API Call
451
+ let currentConfig: any = {};
452
+ try {
453
+ if (typeof resource.config === "string") {
454
+ currentConfig = JSON.parse(resource.config);
455
+ } else if (typeof resource.config === "object") {
456
+ currentConfig = resource.config || {};
457
+ }
458
+ } catch (_catchError) {
459
+ // Ignore invalid config JSON
460
+ }
461
+
462
+ const newConfig = { ...currentConfig, actions: newActions };
463
+
464
+ const res = await fetch(`/api/resources/${resource.id}`, {
465
+ method: "PUT",
466
+ headers: { "Content-Type": "application/json" },
467
+ body: JSON.stringify({
468
+ ...resource,
469
+ config: JSON.stringify(newConfig),
470
+ }),
471
+ });
472
+
473
+ if (!res.ok) {
474
+ await throwFetchError(res, "Update failed");
475
+ }
476
+ } catch (error) {
477
+ toast.error(errorMessage(error, "Không thể cập nhật quyền"));
478
+ }
479
+ };
480
+
481
+ const toggleAction = (resource: Resource, actionCode: string) => {
482
+ const currentActions = resource.actions || [];
483
+ const newActions = currentActions.includes(actionCode)
484
+ ? currentActions.filter((a) => a !== actionCode)
485
+ : [...currentActions, actionCode];
486
+ handleUpdateActions(resource, newActions);
487
+ };
488
+
489
+ // --- Bung hàng để xem/sửa toàn bộ action ngay tại chỗ ---
490
+ const toggleRow = (id: string) => {
491
+ setExpandedRows((prev) => {
492
+ const next = new Set(prev);
493
+ if (next.has(id)) next.delete(id);
494
+ else next.add(id);
495
+ return next;
496
+ });
497
+ };
498
+
499
+ // --- Resource Dialog Handlers ---
500
+ const openNewResource = () => {
501
+ setEditingResource(null);
502
+ setFormData({ code: "", name: "", group: "", description: "" });
503
+ setIsDialogOpen(true);
504
+ };
505
+
506
+ const openEditResource = (resource: Resource) => {
507
+ setEditingResource(resource);
508
+ setFormData({
509
+ code: resource.code,
510
+ name: resource.name,
511
+ group: resource.group || "",
512
+ description: resource.description || "",
513
+ });
514
+ setIsDialogOpen(true);
515
+ };
516
+
517
+ const handleSubmitResource = async () => {
518
+ if (!formData.code || !formData.name) {
519
+ toast.error("Vui lòng nhập Mã và Tên tài nguyên");
520
+ return;
521
+ }
522
+
523
+ try {
524
+ const payload = {
525
+ ...formData,
526
+ status: "active", // default status
527
+ };
528
+
529
+ let res;
530
+ if (editingResource) {
531
+ // Update
532
+ res = await fetch(`/api/resources/${editingResource.id}`, {
533
+ method: "PUT",
534
+ headers: { "Content-Type": "application/json" },
535
+ body: JSON.stringify({ id: editingResource.id, ...payload }),
536
+ });
537
+ } else {
538
+ // Create
539
+ res = await fetch("/api/resources", {
540
+ method: "POST",
541
+ headers: { "Content-Type": "application/json" },
542
+ body: JSON.stringify(payload),
543
+ });
544
+ }
545
+
546
+ if (!res.ok) {
547
+ await throwFetchError(res, "Operation failed");
548
+ }
549
+
550
+ const savedResource = await res.json();
551
+ toast.success(
552
+ editingResource ? "Cập nhật thành công" : "Tạo mới thành công",
553
+ );
554
+
555
+ // Update local state
556
+ if (editingResource) {
557
+ setResources((prev) =>
558
+ prev.map((r) =>
559
+ r.id === savedResource.id
560
+ ? { ...savedResource, actions: r.actions }
561
+ : r,
562
+ ),
563
+ );
564
+ } else {
565
+ // For new resource, actions starts empty
566
+ setResources((prev) => [{ ...savedResource, actions: [] }, ...prev]);
567
+ }
568
+
569
+ setIsDialogOpen(false);
570
+ } catch (error: any) {
571
+ toast.error(errorMessage(error, "Có lỗi xảy ra"));
572
+ }
573
+ };
574
+
575
+ // --- Action Manager Handlers ---
576
+ const openActionManager = () => {
577
+ setIsActionManagerOpen(true);
578
+ setActionFormMode("list");
579
+ };
580
+
581
+ const openNewAction = () => {
582
+ setEditingAction(null);
583
+ setActionFormData({ code: "", name: "", description: "" });
584
+ setActionFormMode("create");
585
+ };
586
+
587
+ const openEditAction = (action: Action) => {
588
+ setEditingAction(action);
589
+ setActionFormData({
590
+ code: action.code,
591
+ name: action.name,
592
+ description: action.description || "",
593
+ });
594
+ setActionFormMode("edit");
595
+ };
596
+
597
+ const handleSubmitAction = async () => {
598
+ if (!actionFormData.code || !actionFormData.name) {
599
+ toast.error("Vui lòng nhập Mã và Tên hành động");
600
+ return;
601
+ }
602
+
603
+ try {
604
+ const payload = {
605
+ ...actionFormData,
606
+ status: "active",
607
+ description: actionFormData.description,
608
+ };
609
+
610
+ let res;
611
+ if (actionFormMode === "edit" && editingAction) {
612
+ res = await fetch("/api/actions", {
613
+ method: "PUT",
614
+ headers: { "Content-Type": "application/json" },
615
+ body: JSON.stringify({ id: editingAction.id, ...payload }),
616
+ });
617
+ } else {
618
+ res = await fetch("/api/actions", {
619
+ method: "POST",
620
+ headers: { "Content-Type": "application/json" },
621
+ body: JSON.stringify(payload),
622
+ });
623
+ }
624
+
625
+ if (!res.ok) {
626
+ await throwFetchError(res, "Operation failed");
627
+ }
628
+
629
+ const savedAction = await res.json();
630
+
631
+ if (actionFormMode === "edit") {
632
+ setAllActions((prev) =>
633
+ prev.map((a) => (a.id === savedAction.id ? savedAction : a)),
634
+ );
635
+ toast.success("Cập nhật hành động thành công");
636
+ } else {
637
+ setAllActions((prev) => [savedAction, ...prev]);
638
+ toast.success("Tạo hành động thành công");
639
+ }
640
+ setActionFormMode("list");
641
+ } catch (error: any) {
642
+ toast.error(errorMessage(error, "Có lỗi xảy ra"));
643
+ }
644
+ };
645
+
646
+ const handleDeleteAction = (id: string, code: string) => {
647
+ setItemToDelete({ id, name: code });
648
+ setConfirmType("action");
649
+ setConfirmOpen(true);
650
+ };
651
+
652
+ const handleDeleteResource = () => {
653
+ if (!editingResource) return;
654
+ setItemToDelete({ id: editingResource.id, name: editingResource.code });
655
+ setConfirmType("resource");
656
+ setConfirmOpen(true);
657
+ };
658
+
659
+ const handleConfirmDelete = async () => {
660
+ if (!itemToDelete || !confirmType) return;
661
+ setIsDeleting(true);
662
+ try {
663
+ if (confirmType === "action") {
664
+ const res = await fetch(`/api/actions?id=${itemToDelete.id}`, {
665
+ method: "DELETE",
666
+ });
667
+ if (!res.ok) {
668
+ await throwFetchError(res, "Delete failed");
669
+ }
670
+ setAllActions((prev) => prev.filter((a) => a.id !== itemToDelete.id));
671
+ toast.success("Xóa hành động thành công");
672
+ } else if (confirmType === "resource") {
673
+ const res = await fetch(`/api/resources?id=${itemToDelete.id}`, {
674
+ method: "DELETE",
675
+ });
676
+ if (!res.ok) {
677
+ await throwFetchError(res, "Delete failed");
678
+ }
679
+ setResources((prev) => prev.filter((r) => r.id !== itemToDelete.id));
680
+ toast.success("Xóa tài nguyên thành công");
681
+ setIsDialogOpen(false);
682
+ }
683
+ setConfirmOpen(false);
684
+ setItemToDelete(null);
685
+ setConfirmType(null);
686
+ } catch (error) {
687
+ toast.error(
688
+ errorMessage(
689
+ error,
690
+ `Không thể xóa ${confirmType === "action" ? "hành động" : "tài nguyên"}`,
691
+ ),
692
+ );
693
+ } finally {
694
+ setIsDeleting(false);
695
+ }
696
+ };
697
+
698
+ const isSearching = searchTerm.trim().length > 0;
699
+ const hasNoResources = !loading && resources.length === 0;
700
+
701
+ const totalGroups = new Set(
702
+ resources.map((r) => (r.group || "other").toLowerCase()),
703
+ ).size;
704
+
705
+ const statItems: CompactStatItem[] = [
706
+ {
707
+ id: "resources",
708
+ label: "TÀI NGUYÊN",
709
+ value: resources.length,
710
+ icon: Shield,
711
+ colorTheme: "dark",
712
+ },
713
+ {
714
+ id: "groups",
715
+ label: "NHÓM QUYỀN",
716
+ value: totalGroups,
717
+ icon: Layers,
718
+ colorTheme: "green",
719
+ },
720
+ {
721
+ id: "actions",
722
+ label: "HÀNH ĐỘNG",
723
+ value: allActions.length,
724
+ icon: Zap,
725
+ colorTheme: "orange",
726
+ },
727
+ ];
728
+
729
+ // Utility icon buttons (sit on the search row to keep the header title roomy)
730
+ const utilityButtons = (
731
+ <>
732
+ <Button
733
+ variant="outline"
734
+ size="icon"
735
+ onClick={handleRefresh}
736
+ disabled={isRefreshing}
737
+ title="Làm mới"
738
+ >
739
+ <RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
740
+ </Button>
741
+ <Button
742
+ variant="outline"
743
+ size="icon"
744
+ onClick={handleSync}
745
+ disabled={isSyncing}
746
+ title="Đồng bộ tài nguyên"
747
+ >
748
+ <DatabaseBackup
749
+ className={cn("h-4 w-4", isSyncing && "animate-pulse")}
750
+ />
751
+ </Button>
752
+ <Button
753
+ variant="outline"
754
+ size="icon"
755
+ onClick={openActionManager}
756
+ title="Quản lý hành động"
757
+ >
758
+ <Zap className="h-4 w-4" />
759
+ </Button>
760
+ </>
761
+ );
762
+
763
+ return (
764
+ <div className="flex flex-col gap-2">
765
+ <CompactStatBar items={statItems} />
766
+
767
+ {/* Toolbar — đồng bộ ListToolbar (tiện ích + Thêm tài nguyên) */}
768
+ <ListToolbar
769
+ search={searchTerm}
770
+ onSearchChange={setSearchTerm}
771
+ searchPlaceholder="Tìm tài nguyên hoặc hành động…"
772
+ isFiltered={isSearching}
773
+ onReset={() => setSearchTerm("")}
774
+ onCreate={openNewResource}
775
+ createLabel="Thêm tài nguyên"
776
+ tableControls={utilityButtons}
777
+ />
778
+
779
+ {isSearching && !isEmpty && (
780
+ <p className="text-xs text-muted-foreground">
781
+ {filteredResourceTotal} tài nguyên khớp
782
+ </p>
783
+ )}
784
+
785
+ {/* --- Body --- */}
786
+ {loading && !isRefreshing ? (
787
+ <div className="space-y-2 rounded-lg border border-border bg-card p-4">
788
+ {Array.from({ length: 7 }).map((_, i) => (
789
+ <Skeleton key={i} className="h-14 w-full" />
790
+ ))}
791
+ </div>
792
+ ) : isEmpty || (hasNoResources && !isSearching) ? (
793
+ <EmptyState
794
+ searching={isSearching}
795
+ onClear={() => setSearchTerm("")}
796
+ onCreate={openNewResource}
797
+ />
798
+ ) : (
799
+ <div className="space-y-3">
800
+ {sections.map((section) => {
801
+ const collapsed = collapsedGroups.has(section.group);
802
+ const GroupIcon = section.meta.icon;
803
+ return (
804
+ <div
805
+ key={section.group}
806
+ className="overflow-hidden rounded-lg border border-border bg-card shadow-sm"
807
+ >
808
+ {/* Section header — nền trắng, phân tách bằng border */}
809
+ <button
810
+ type="button"
811
+ onClick={() => toggleGroup(section.group)}
812
+ className="flex w-full items-center gap-2 border-b border-border bg-card px-4 py-2.5 text-left transition-colors hover:bg-muted/40"
813
+ >
814
+ {collapsed ? (
815
+ <ChevronRight className="h-4 w-4 text-muted-foreground" />
816
+ ) : (
817
+ <ChevronDown className="h-4 w-4 text-muted-foreground" />
818
+ )}
819
+ <span
820
+ className={cn(
821
+ "flex h-6 w-6 items-center justify-center rounded-md",
822
+ section.meta.iconCls,
823
+ )}
824
+ >
825
+ <GroupIcon className="h-3.5 w-3.5" />
826
+ </span>
827
+ <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
828
+ {section.meta.label}
829
+ </span>
830
+ <Badge
831
+ variant="secondary"
832
+ className="ml-1 h-5 px-1.5 font-normal"
833
+ >
834
+ {section.items.length}
835
+ </Badge>
836
+ </button>
837
+
838
+ {/* Resource rows */}
839
+ {!collapsed && (
840
+ <ul className="divide-y divide-border">
841
+ {section.items.map((resource) => (
842
+ <ResourceRow
843
+ key={resource.id}
844
+ resource={resource}
845
+ actionMap={actionMap}
846
+ allActions={allActions}
847
+ categorized={categorizedActions}
848
+ expanded={expandedRows.has(resource.id)}
849
+ onToggleExpand={() => toggleRow(resource.id)}
850
+ onToggleAction={toggleAction}
851
+ onUpdateActions={handleUpdateActions}
852
+ onEditResource={openEditResource}
853
+ />
854
+ ))}
855
+ </ul>
856
+ )}
857
+ </div>
858
+ );
859
+ })}
860
+ </div>
861
+ )}
862
+
863
+ {/* --- Create/Edit Resource Dialog --- */}
864
+ <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
865
+ <DialogContent className="sm:max-w-[460px]">
866
+ <DialogHeader>
867
+ <DialogTitle>
868
+ {editingResource ? "Chỉnh sửa tài nguyên" : "Thêm tài nguyên mới"}
869
+ </DialogTitle>
870
+ </DialogHeader>
871
+ <div className="grid gap-4 py-2">
872
+ <div className="grid gap-2">
873
+ <Label htmlFor="code">Mã (Code)</Label>
874
+ <Input
875
+ id="code"
876
+ value={formData.code}
877
+ onChange={(e) =>
878
+ setFormData({ ...formData, code: e.target.value })
879
+ }
880
+ placeholder="vd: sales-order"
881
+ disabled={!!editingResource} // Disable code editing
882
+ />
883
+ </div>
884
+ <div className="grid gap-2">
885
+ <Label htmlFor="name">Tên</Label>
886
+ <Input
887
+ id="name"
888
+ value={formData.name}
889
+ onChange={(e) =>
890
+ setFormData({ ...formData, name: e.target.value })
891
+ }
892
+ placeholder="vd: Đơn bán hàng"
893
+ />
894
+ </div>
895
+ <div className="grid gap-2">
896
+ <Label htmlFor="group">Nhóm</Label>
897
+ <Select
898
+ value={formData.group}
899
+ onValueChange={(val) =>
900
+ setFormData({ ...formData, group: val })
901
+ }
902
+ >
903
+ <SelectTrigger id="group">
904
+ <SelectValue placeholder="Chọn nhóm" />
905
+ </SelectTrigger>
906
+ <SelectContent>
907
+ <SelectItem value="sales">Bán hàng</SelectItem>
908
+ <SelectItem value="purchase">Mua hàng</SelectItem>
909
+ <SelectItem value="inventory">Kho</SelectItem>
910
+ <SelectItem value="finance">Tài chính</SelectItem>
911
+ <SelectItem value="hr">Nhân sự</SelectItem>
912
+ <SelectItem value="master">Danh mục</SelectItem>
913
+ <SelectItem value="users">Người dùng</SelectItem>
914
+ <SelectItem value="system">Hệ thống</SelectItem>
915
+ <SelectItem value="other">Khác</SelectItem>
916
+ </SelectContent>
917
+ </Select>
918
+ </div>
919
+ <div className="grid gap-2">
920
+ <Label htmlFor="desc">Mô tả</Label>
921
+ <Textarea
922
+ id="desc"
923
+ value={formData.description}
924
+ onChange={(e) =>
925
+ setFormData({ ...formData, description: e.target.value })
926
+ }
927
+ placeholder="Mô tả ngắn gọn…"
928
+ />
929
+ </div>
930
+ </div>
931
+ <DialogFooter className="gap-2">
932
+ {editingResource && (
933
+ <Button
934
+ variant="destructive"
935
+ onClick={handleDeleteResource}
936
+ className="mr-auto"
937
+ >
938
+ Xóa
939
+ </Button>
940
+ )}
941
+ <Button variant="outline" onClick={() => setIsDialogOpen(false)}>
942
+ Hủy
943
+ </Button>
944
+ <Button variant="accent" onClick={handleSubmitResource}>
945
+ {editingResource ? "Cập nhật" : "Tạo mới"}
946
+ </Button>
947
+ </DialogFooter>
948
+ </DialogContent>
949
+ </Dialog>
950
+
951
+ {/* --- Action Manager Dialog --- */}
952
+ <Dialog open={isActionManagerOpen} onOpenChange={setIsActionManagerOpen}>
953
+ <DialogContent className="flex max-h-[85vh] flex-col sm:max-w-lg">
954
+ <DialogHeader>
955
+ <DialogTitle className="flex items-center gap-2">
956
+ {actionFormMode !== "list" && (
957
+ <Button
958
+ variant="ghost"
959
+ size="icon"
960
+ className="h-6 w-6"
961
+ onClick={() => setActionFormMode("list")}
962
+ >
963
+ <ChevronLeft className="h-4 w-4" />
964
+ </Button>
965
+ )}
966
+ {actionFormMode === "list" && "Quản lý hành động"}
967
+ {actionFormMode === "create" && "Thêm hành động mới"}
968
+ {actionFormMode === "edit" && "Chỉnh sửa hành động"}
969
+ </DialogTitle>
970
+ </DialogHeader>
971
+
972
+ <div className="flex flex-1 flex-col overflow-hidden">
973
+ {actionFormMode === "list" && (
974
+ <>
975
+ <div className="mb-4 flex justify-end">
976
+ <Button
977
+ size="sm"
978
+ variant="accent"
979
+ onClick={openNewAction}
980
+ prependIcon={<Plus className="h-4 w-4" />}
981
+ >
982
+ Thêm hành động
983
+ </Button>
984
+ </div>
985
+ <ScrollArea className="-mx-6 flex-1 px-6">
986
+ <div className="space-y-2">
987
+ {allActions.map((action) => (
988
+ <div
989
+ key={action.id}
990
+ className="flex items-center justify-between rounded-lg border border-border bg-card p-3 transition-colors hover:bg-muted/40"
991
+ >
992
+ <div className="flex min-w-0 items-center gap-3">
993
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
994
+ <Zap className="h-4 w-4" />
995
+ </div>
996
+ <div className="min-w-0">
997
+ <div className="text-sm font-medium">
998
+ {action.name}
999
+ </div>
1000
+ <div className="font-mono text-xs text-muted-foreground">
1001
+ {action.code}
1002
+ </div>
1003
+ {action.description && (
1004
+ <div className="mt-0.5 line-clamp-1 text-[11px] text-muted-foreground/80">
1005
+ {action.description}
1006
+ </div>
1007
+ )}
1008
+ </div>
1009
+ </div>
1010
+ <div className="flex items-center gap-1">
1011
+ <Button
1012
+ variant="ghost"
1013
+ size="icon"
1014
+ className="h-8 w-8 text-muted-foreground hover:text-foreground"
1015
+ onClick={() => openEditAction(action)}
1016
+ >
1017
+ <Pencil className="h-4 w-4" />
1018
+ </Button>
1019
+ <Button
1020
+ variant="ghost"
1021
+ size="icon"
1022
+ className="h-8 w-8 text-muted-foreground hover:text-destructive"
1023
+ onClick={() =>
1024
+ handleDeleteAction(action.id, action.code)
1025
+ }
1026
+ >
1027
+ <Trash2 className="h-4 w-4" />
1028
+ </Button>
1029
+ </div>
1030
+ </div>
1031
+ ))}
1032
+ </div>
1033
+ </ScrollArea>
1034
+ </>
1035
+ )}
1036
+
1037
+ {(actionFormMode === "create" || actionFormMode === "edit") && (
1038
+ <div className="space-y-4 py-2">
1039
+ <div className="grid gap-2">
1040
+ <Label htmlFor="action-code">Mã hành động</Label>
1041
+ <Input
1042
+ id="action-code"
1043
+ placeholder="vd: view-report"
1044
+ value={actionFormData.code}
1045
+ onChange={(e) =>
1046
+ setActionFormData((prev) => ({
1047
+ ...prev,
1048
+ code: e.target.value,
1049
+ }))
1050
+ }
1051
+ disabled={actionFormMode === "edit"}
1052
+ />
1053
+ <p className="text-[11px] text-muted-foreground">
1054
+ Mã duy nhất dùng để kiểm tra quyền.
1055
+ </p>
1056
+ </div>
1057
+ <div className="grid gap-2">
1058
+ <Label htmlFor="action-name">Tên hiển thị</Label>
1059
+ <Input
1060
+ id="action-name"
1061
+ placeholder="vd: Xem báo cáo"
1062
+ value={actionFormData.name}
1063
+ onChange={(e) =>
1064
+ setActionFormData((prev) => ({
1065
+ ...prev,
1066
+ name: e.target.value,
1067
+ }))
1068
+ }
1069
+ />
1070
+ </div>
1071
+ <div className="grid gap-2">
1072
+ <Label htmlFor="action-desc">Mô tả</Label>
1073
+ <Textarea
1074
+ id="action-desc"
1075
+ placeholder="Mô tả chi tiết về hành động này…"
1076
+ value={actionFormData.description}
1077
+ onChange={(e) =>
1078
+ setActionFormData((prev) => ({
1079
+ ...prev,
1080
+ description: e.target.value,
1081
+ }))
1082
+ }
1083
+ />
1084
+ </div>
1085
+ </div>
1086
+ )}
1087
+ </div>
1088
+
1089
+ <DialogFooter className="mt-4 border-t pt-4">
1090
+ {actionFormMode === "list" ? (
1091
+ <Button
1092
+ variant="outline"
1093
+ onClick={() => setIsActionManagerOpen(false)}
1094
+ >
1095
+ Đóng
1096
+ </Button>
1097
+ ) : (
1098
+ <>
1099
+ <Button
1100
+ variant="outline"
1101
+ onClick={() => setActionFormMode("list")}
1102
+ >
1103
+ Quay lại
1104
+ </Button>
1105
+ <Button variant="accent" onClick={handleSubmitAction}>
1106
+ {actionFormMode === "create" ? "Tạo mới" : "Cập nhật"}
1107
+ </Button>
1108
+ </>
1109
+ )}
1110
+ </DialogFooter>
1111
+ </DialogContent>
1112
+ </Dialog>
1113
+
1114
+ <ConfirmDialog
1115
+ open={confirmOpen}
1116
+ onOpenChange={setConfirmOpen}
1117
+ title="Xác nhận xóa"
1118
+ description={`Bạn có chắc chắn muốn xóa ${
1119
+ confirmType === "action" ? "hành động" : "tài nguyên"
1120
+ } "${itemToDelete?.name}" không? Hành động này không thể hoàn tác.`}
1121
+ onConfirm={handleConfirmDelete}
1122
+ loading={isDeleting}
1123
+ />
1124
+ </div>
1125
+ );
1126
+ }
1127
+
1128
+ // --- Resource row: tóm tắt quyền đã gán; bấm để bung xem/sửa TOÀN BỘ action ---
1129
+ const MAX_CHIPS = 8;
1130
+
1131
+ function ResourceRow({
1132
+ resource,
1133
+ actionMap,
1134
+ allActions,
1135
+ categorized,
1136
+ expanded,
1137
+ onToggleExpand,
1138
+ onToggleAction,
1139
+ onUpdateActions,
1140
+ onEditResource,
1141
+ }: {
1142
+ resource: Resource;
1143
+ actionMap: Map<string, Action>;
1144
+ allActions: Action[];
1145
+ categorized: { basic: Action[]; functional: Action[] };
1146
+ expanded: boolean;
1147
+ onToggleExpand: () => void;
1148
+ onToggleAction: (resource: Resource, actionCode: string) => void;
1149
+ onUpdateActions: (resource: Resource, actions: string[]) => void;
1150
+ onEditResource: (resource: Resource) => void;
1151
+ }) {
1152
+ const granted = resource.actions || [];
1153
+ const grantedSet = new Set(granted);
1154
+ const shown = granted.slice(0, MAX_CHIPS);
1155
+ const overflow = granted.length - shown.length;
1156
+
1157
+ // 1 dòng action trong panel bung: ô tick (tô màu theo loại khi đã cấp) +
1158
+ // tên + mô tả chức năng.
1159
+ const renderItem = (action: Action) => {
1160
+ const on = grantedSet.has(action.code);
1161
+ return (
1162
+ <button
1163
+ key={action.code}
1164
+ type="button"
1165
+ onClick={() => onToggleAction(resource, action.code)}
1166
+ className="mb-0.5 flex w-full break-inside-avoid items-start gap-2 rounded px-1.5 py-1 text-left transition-colors hover:bg-muted/50"
1167
+ >
1168
+ <span
1169
+ className={cn(
1170
+ "mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border",
1171
+ on
1172
+ ? ACTION_TONES[getActionTone(action.code)]
1173
+ : "border-border bg-transparent text-transparent",
1174
+ )}
1175
+ >
1176
+ <Check className="h-3 w-3" />
1177
+ </span>
1178
+ <span className="min-w-0 flex-1">
1179
+ <span
1180
+ className={cn(
1181
+ "block text-xs leading-tight",
1182
+ on ? "font-medium text-foreground" : "text-muted-foreground",
1183
+ )}
1184
+ >
1185
+ {action.name}
1186
+ </span>
1187
+ {action.description && (
1188
+ <span className="mt-0.5 block text-[10px] leading-tight text-muted-foreground/80">
1189
+ {action.description}
1190
+ </span>
1191
+ )}
1192
+ </span>
1193
+ </button>
1194
+ );
1195
+ };
1196
+
1197
+ // 1 nhóm action (Cơ bản / Chức năng) → tách Đã cấp / Chưa cấp, mỗi phần là
1198
+ // danh sách tối đa 3 cột.
1199
+ const renderCategory = (label: string, list: Action[]) => {
1200
+ if (list.length === 0) return null;
1201
+ const grantedList = list.filter((a) => grantedSet.has(a.code));
1202
+ const ungrantedList = list.filter((a) => !grantedSet.has(a.code));
1203
+ return (
1204
+ <div key={label}>
1205
+ <p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-foreground/70">
1206
+ {label}
1207
+ </p>
1208
+ <div className="space-y-2">
1209
+ {grantedList.length > 0 && (
1210
+ <div>
1211
+ <p className="mb-1 flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
1212
+ <Check className="h-3 w-3" /> Đã cấp · {grantedList.length}
1213
+ </p>
1214
+ <div className="columns-1 gap-x-6 sm:columns-2 lg:columns-3">
1215
+ {grantedList.map(renderItem)}
1216
+ </div>
1217
+ </div>
1218
+ )}
1219
+ {ungrantedList.length > 0 && (
1220
+ <div>
1221
+ <p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
1222
+ Chưa cấp · {ungrantedList.length}
1223
+ </p>
1224
+ <div className="columns-1 gap-x-6 sm:columns-2 lg:columns-3">
1225
+ {ungrantedList.map(renderItem)}
1226
+ </div>
1227
+ </div>
1228
+ )}
1229
+ </div>
1230
+ </div>
1231
+ );
1232
+ };
1233
+
1234
+ return (
1235
+ <li>
1236
+ <div
1237
+ role="button"
1238
+ tabIndex={0}
1239
+ aria-expanded={expanded}
1240
+ onClick={onToggleExpand}
1241
+ onKeyDown={(e) => {
1242
+ if (e.key === "Enter" || e.key === " ") {
1243
+ e.preventDefault();
1244
+ onToggleExpand();
1245
+ }
1246
+ }}
1247
+ className="flex cursor-pointer items-center gap-3 bg-card px-4 py-3 transition-colors hover:bg-muted/40"
1248
+ >
1249
+ {/* Icon — tinted by the resource's group */}
1250
+ <div
1251
+ className={cn(
1252
+ "flex h-9 w-9 shrink-0 items-center justify-center rounded-md",
1253
+ getGroupMeta(resource.group).iconCls,
1254
+ )}
1255
+ >
1256
+ <DynamicIcon
1257
+ name={(resource.icon as any) || "FileCode"}
1258
+ className="h-4 w-4"
1259
+ />
1260
+ </div>
1261
+
1262
+ {/* Identity + granted-chip summary (ẩn khi đã bung — bên dưới hiện đủ) */}
1263
+ <div className="min-w-0 flex-1">
1264
+ <div className="flex items-baseline gap-2">
1265
+ <span className="truncate text-sm font-medium text-foreground">
1266
+ {resource.name}
1267
+ </span>
1268
+ <code className="hidden shrink-0 font-mono text-[11px] text-muted-foreground sm:inline">
1269
+ {resource.code}
1270
+ </code>
1271
+ </div>
1272
+ <code className="mt-0.5 block truncate font-mono text-[11px] text-muted-foreground sm:hidden">
1273
+ {resource.code}
1274
+ </code>
1275
+ {!expanded && (
1276
+ <div className="mt-1 hidden flex-wrap items-center gap-1 sm:flex">
1277
+ {granted.length === 0 ? (
1278
+ <span className="text-xs italic text-muted-foreground">
1279
+ Chưa gán quyền
1280
+ </span>
1281
+ ) : (
1282
+ <>
1283
+ {shown.map((code) => (
1284
+ <span
1285
+ key={code}
1286
+ className={cn(
1287
+ "inline-flex items-center rounded border px-1.5 py-0.5 text-[11px]",
1288
+ ACTION_TONES[getActionTone(code)],
1289
+ )}
1290
+ >
1291
+ {actionMap.get(code)?.name || code}
1292
+ </span>
1293
+ ))}
1294
+ {overflow > 0 && (
1295
+ <span className="text-[11px] text-muted-foreground">
1296
+ +{overflow}
1297
+ </span>
1298
+ )}
1299
+ </>
1300
+ )}
1301
+ </div>
1302
+ )}
1303
+ </div>
1304
+
1305
+ {/* Count + actions — nút sửa luôn hiện (mobile không có hover) */}
1306
+ <Badge variant="secondary" className="shrink-0 font-normal">
1307
+ {granted.length}/{allActions.length} quyền
1308
+ </Badge>
1309
+ <button
1310
+ type="button"
1311
+ onClick={(e) => {
1312
+ e.stopPropagation();
1313
+ onEditResource(resource);
1314
+ }}
1315
+ className="shrink-0 rounded-md border border-border bg-card p-1.5 text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
1316
+ title="Sửa thông tin tài nguyên"
1317
+ >
1318
+ <Pencil className="h-3.5 w-3.5" />
1319
+ </button>
1320
+ <ChevronRight
1321
+ className={cn(
1322
+ "h-4 w-4 shrink-0 text-muted-foreground/60 transition-transform",
1323
+ expanded && "rotate-90",
1324
+ )}
1325
+ />
1326
+ </div>
1327
+
1328
+ {/* Bung: toàn bộ action của resource, bấm chip để bật/tắt ngay — nền trắng */}
1329
+ {expanded && (
1330
+ <div className="border-t border-border bg-card px-4 py-3">
1331
+ <div className="mb-2 flex items-center justify-between gap-2">
1332
+ <span className="text-xs text-muted-foreground">
1333
+ Đã gán{" "}
1334
+ <span className="font-medium text-foreground">
1335
+ {granted.length}
1336
+ </span>
1337
+ /{allActions.length} quyền
1338
+ </span>
1339
+ <div className="flex items-center gap-1">
1340
+ <Button
1341
+ variant="ghost"
1342
+ size="sm"
1343
+ className="h-7 px-2 text-xs"
1344
+ onClick={() =>
1345
+ onUpdateActions(
1346
+ resource,
1347
+ allActions.map((a) => a.code),
1348
+ )
1349
+ }
1350
+ disabled={granted.length === allActions.length}
1351
+ >
1352
+ Chọn tất cả
1353
+ </Button>
1354
+ <Button
1355
+ variant="ghost"
1356
+ size="sm"
1357
+ className="h-7 px-2 text-xs"
1358
+ onClick={() => onUpdateActions(resource, [])}
1359
+ disabled={granted.length === 0}
1360
+ >
1361
+ Bỏ tất cả
1362
+ </Button>
1363
+ </div>
1364
+ </div>
1365
+
1366
+ {allActions.length === 0 ? (
1367
+ <p className="py-2 text-xs text-muted-foreground">
1368
+ Chưa có hành động nào. Tạo hành động ở mục “Quản lý hành động”.
1369
+ </p>
1370
+ ) : (
1371
+ <div className="space-y-3">
1372
+ {renderCategory("Cơ bản", categorized.basic)}
1373
+ {renderCategory("Chức năng", categorized.functional)}
1374
+ </div>
1375
+ )}
1376
+ </div>
1377
+ )}
1378
+ </li>
1379
+ );
8
1380
  }
9
1381
 
10
- export function ResourceListPage({
11
- lang,
12
- session,
13
- dictionary,
14
- config,
15
- }: ResourceListPageProps) {
1382
+ // --- Empty state ---
1383
+ function EmptyState({
1384
+ searching,
1385
+ onClear,
1386
+ onCreate,
1387
+ }: {
1388
+ searching: boolean;
1389
+ onClear: () => void;
1390
+ onCreate: () => void;
1391
+ }) {
16
1392
  return (
17
- <EntityCrudPage
18
- entity="resources"
19
- lang={lang}
20
- session={session}
21
- dictionary={dictionary}
22
- config={config}
23
- />
1393
+ <div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card py-20 text-center">
1394
+ <div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted">
1395
+ <Search className="h-6 w-6 text-muted-foreground" />
1396
+ </div>
1397
+ <h3 className="text-base font-semibold text-foreground">
1398
+ {searching ? "Không tìm thấy kết quả" : "Chưa có tài nguyên nào"}
1399
+ </h3>
1400
+ <p className="mt-1 max-w-sm text-sm text-muted-foreground">
1401
+ {searching
1402
+ ? "Không có tài nguyên hay hành động nào khớp từ khóa. Thử từ khóa khác."
1403
+ : "Bắt đầu bằng cách thêm tài nguyên mới hoặc đồng bộ từ hệ thống."}
1404
+ </p>
1405
+ {searching ? (
1406
+ <Button variant="outline" className="mt-4" onClick={onClear}>
1407
+ Xóa tìm kiếm
1408
+ </Button>
1409
+ ) : (
1410
+ <Button
1411
+ variant="accent"
1412
+ className="mt-4"
1413
+ onClick={onCreate}
1414
+ prependIcon={<Plus className="h-4 w-4" />}
1415
+ >
1416
+ Thêm tài nguyên
1417
+ </Button>
1418
+ )}
1419
+ </div>
24
1420
  );
25
1421
  }