@goplusvn/core 0.1.22 → 0.1.23

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,704 @@
1
+ "use client";
2
+
3
+ // Trang tạo/sửa VAI TRÒ — thông tin chung (kiểu Notion) + ma trận phân quyền
4
+ // theo nhóm tài nguyên. Bề mặt trắng (bg-card) thay cho các mảng bg-muted xám.
5
+ //
6
+ // API contract: POST /api/roles, PUT /api/roles/:id.
7
+
8
+ import * as React from "react";
9
+ import { useRouter } from "next/navigation";
10
+ import { toast } from "sonner";
11
+ import {
12
+ ArrowLeft,
13
+ Check,
14
+ ChevronDown,
15
+ ChevronRight,
16
+ Save,
17
+ Search,
18
+ X,
19
+ } from "lucide-react";
20
+
21
+ import {
22
+ Badge,
23
+ Button,
24
+ DynamicIcon,
25
+ Input,
26
+ Select,
27
+ SelectContent,
28
+ SelectItem,
29
+ SelectTrigger,
30
+ SelectValue,
31
+ Switch,
32
+ } from "../../ui";
33
+ import { cn } from "../../utils";
34
+ import { errorMessage, throwFetchError } from "../lib/fetch-error";
35
+ import type { CrudPermissions } from "../../types";
36
+
37
+ export interface ActionLabelConfig {
38
+ label: string;
39
+ icon: string;
40
+ color: string;
41
+ }
42
+
43
+ // Nhãn/icon mặc định cho các action CRUD chuẩn — app truyền `actionLabels`
44
+ // để bổ sung/ghi đè cho các action nghiệp vụ riêng.
45
+ const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
46
+ view: { label: "Xem", icon: "👁️", color: "text-blue-600" },
47
+ create: { label: "Tạo", icon: "➕", color: "text-green-600" },
48
+ update: { label: "Sửa", icon: "✏️", color: "text-yellow-600" },
49
+ delete: { label: "Xóa", icon: "🗑️", color: "text-destructive" },
50
+ import: { label: "Import", icon: "📥", color: "text-purple-600" },
51
+ export: { label: "Export", icon: "📤", color: "text-indigo-600" },
52
+ approve: { label: "Duyệt", icon: "✅", color: "text-emerald-600" },
53
+ manage: { label: "Quản lý", icon: "🔧", color: "text-orange-600" },
54
+ edit_items: { label: "Sửa chi tiết", icon: "📝", color: "text-amber-600" },
55
+ delete_items: { label: "Xóa chi tiết", icon: "❌", color: "text-rose-600" },
56
+ };
57
+
58
+ export interface RoleFormPageProps {
59
+ dictionary: any;
60
+ permissions?: CrudPermissions;
61
+ lang: string;
62
+ resources: Array<{
63
+ id: string;
64
+ code: string;
65
+ name: string;
66
+ group: string | null;
67
+ description: string | null;
68
+ icon: string | null;
69
+ order: number | null;
70
+ config: any;
71
+ }>;
72
+ actions: Array<{
73
+ id: string;
74
+ code: string;
75
+ name: string;
76
+ description: string | null;
77
+ isDefault?: boolean;
78
+ }>;
79
+ mode: "create" | "edit";
80
+ initialData?: {
81
+ id: string;
82
+ name: string;
83
+ code: string;
84
+ description: string | undefined;
85
+ status: string;
86
+ permissions: string[];
87
+ };
88
+ /** Nhãn/icon bổ sung cho action nghiệp vụ riêng của app (merge đè mặc định). */
89
+ actionLabels?: Record<string, ActionLabelConfig>;
90
+ }
91
+
92
+ export function RoleFormPage({
93
+ dictionary,
94
+ permissions,
95
+ lang,
96
+ resources,
97
+ actions,
98
+ mode,
99
+ initialData,
100
+ actionLabels,
101
+ }: RoleFormPageProps) {
102
+ const router = useRouter();
103
+
104
+ const mergedActionLabels = React.useMemo(
105
+ () => ({ ...DEFAULT_ACTION_LABELS, ...actionLabels }),
106
+ [actionLabels],
107
+ );
108
+
109
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
110
+ const [searchTerm, setSearchTerm] = React.useState("");
111
+
112
+ const [formData, setFormData] = React.useState({
113
+ name: initialData?.name || "",
114
+ code: initialData?.code || "",
115
+ description: initialData?.description || "",
116
+ status: (initialData?.status || "active") as "active" | "inactive",
117
+ permissions: initialData?.permissions || ([] as string[]),
118
+ });
119
+
120
+ const [activeGroup, setActiveGroup] = React.useState<string | null>("all");
121
+ // Mặc định mở rộng tất cả (Collapsed = false)
122
+ const [collapsedGroups, setCollapsedGroups] = React.useState<
123
+ Record<string, boolean>
124
+ >({});
125
+
126
+ const toggleGroupCollapse = (groupName: string) => {
127
+ setCollapsedGroups((prev) => ({
128
+ ...prev,
129
+ [groupName]: !prev[groupName],
130
+ }));
131
+ };
132
+
133
+ // Helper to get localized text from dictionary
134
+ const getLocalizedName = React.useCallback(
135
+ (key: string | null) => {
136
+ if (!key) return "";
137
+ if (!dictionary) return key;
138
+
139
+ // Try to find key in dictionary (e.g. "menu.dashboard" or "nav.system")
140
+ const parts = key.split(".");
141
+ let current = dictionary;
142
+ for (const part of parts) {
143
+ if (current && typeof current === "object" && part in current) {
144
+ current = current[part];
145
+ } else {
146
+ return key; // fallback to key if not found
147
+ }
148
+ }
149
+ return typeof current === "string" ? current : key;
150
+ },
151
+ [dictionary],
152
+ );
153
+
154
+ // Build dynamic permission matrix
155
+ const permissionMatrix = React.useMemo(() => {
156
+ return resources.map((resource) => {
157
+ // Default actions using database configuration
158
+ let allowedActions: string[] = [];
159
+ const defaultActions = actions
160
+ .filter((a) => a.isDefault)
161
+ .map((a) => a.code);
162
+ // Fallback to hardcoded if no default actions found (safety check)
163
+ const effectiveDefaultActions =
164
+ defaultActions.length > 0
165
+ ? defaultActions
166
+ : ["view", "create", "update", "delete"];
167
+
168
+ if (resource.config) {
169
+ try {
170
+ const config =
171
+ typeof resource.config === "string"
172
+ ? JSON.parse(resource.config)
173
+ : resource.config;
174
+
175
+ if (config.actions && Array.isArray(config.actions)) {
176
+ // Case 2: Config exists -> use configured actions (even if empty)
177
+ const allowedCodes = config.actions as string[];
178
+ allowedActions = actions
179
+ .map((a) => a.code)
180
+ .filter((code) => allowedCodes.includes(code));
181
+ } else {
182
+ // Config exists but no actions array -> use defaults
183
+ allowedActions = actions
184
+ .map((a) => a.code)
185
+ .filter((code) => effectiveDefaultActions.includes(code));
186
+ }
187
+ } catch (_e) {
188
+ // Ignore config parsing errors, fallback to defaults
189
+ allowedActions = actions
190
+ .map((a) => a.code)
191
+ .filter((code) => effectiveDefaultActions.includes(code));
192
+ }
193
+ } else {
194
+ // Case 1: No config -> use defaults
195
+ allowedActions = actions
196
+ .map((a) => a.code)
197
+ .filter((code) => effectiveDefaultActions.includes(code));
198
+ }
199
+
200
+ return {
201
+ resource: resource.code,
202
+ name: getLocalizedName(resource.name),
203
+ icon: resource.icon || "📄",
204
+ group: getLocalizedName(resource.group) || "Khác",
205
+ description: resource.description,
206
+ actions: allowedActions,
207
+ };
208
+ });
209
+ }, [resources, actions, getLocalizedName]);
210
+
211
+ // Filter matrix based on search
212
+ const filteredMatrix = React.useMemo(() => {
213
+ if (!searchTerm.trim()) return permissionMatrix;
214
+
215
+ const search = searchTerm.toLowerCase();
216
+ return permissionMatrix.filter(
217
+ (resource) =>
218
+ resource.name.toLowerCase().includes(search) ||
219
+ resource.resource.toLowerCase().includes(search) ||
220
+ resource.group.toLowerCase().includes(search),
221
+ );
222
+ }, [searchTerm, permissionMatrix]);
223
+
224
+ // Group resources by group
225
+ const groupedMatrix = React.useMemo(() => {
226
+ const groups: Record<string, typeof permissionMatrix> = {};
227
+ filteredMatrix.forEach((resource) => {
228
+ const group = resource.group;
229
+ if (!groups[group]) {
230
+ groups[group] = [];
231
+ }
232
+ groups[group].push(resource);
233
+ });
234
+ return groups;
235
+ }, [filteredMatrix]);
236
+
237
+ // Set initial active group or update if filtered
238
+ React.useEffect(() => {
239
+ const groups = Object.keys(groupedMatrix);
240
+ if (groups.length > 0) {
241
+ // Keep 'all' if selected, otherwise fallback to first group if current selection invalid
242
+ if (
243
+ activeGroup !== "all" &&
244
+ (!activeGroup || !groups.includes(activeGroup))
245
+ ) {
246
+ setActiveGroup("all");
247
+ }
248
+ } else {
249
+ setActiveGroup(null);
250
+ }
251
+ }, [groupedMatrix, activeGroup]);
252
+
253
+ // Permission helpers
254
+ const hasPermission = (resource: string, action: string): boolean => {
255
+ return formData.permissions.includes(`${action}:${resource}`);
256
+ };
257
+
258
+ const togglePermission = (resource: string, action: string) => {
259
+ const permission = `${action}:${resource}`;
260
+ let newPermissions = [...formData.permissions];
261
+
262
+ if (newPermissions.includes(permission)) {
263
+ newPermissions = newPermissions.filter((p) => p !== permission);
264
+ } else {
265
+ newPermissions.push(permission);
266
+ }
267
+ setFormData({ ...formData, permissions: newPermissions });
268
+ };
269
+
270
+ const toggleAllForGroup = (groupName: string, enable: boolean) => {
271
+ const groupResources = groupedMatrix[groupName] || [];
272
+ const groupPermissions = groupResources.flatMap((r) =>
273
+ actions
274
+ .filter((a) => r.actions.includes(a.code))
275
+ .map((a) => `${a.code}:${r.resource}`),
276
+ );
277
+
278
+ let newPermissions = [...formData.permissions];
279
+
280
+ if (enable) {
281
+ // Add all missing permissions for this group
282
+ const toAdd = groupPermissions.filter((p) => !newPermissions.includes(p));
283
+ newPermissions = [...newPermissions, ...toAdd];
284
+ } else {
285
+ // Remove all permissions for this group
286
+ newPermissions = newPermissions.filter(
287
+ (p) => !groupPermissions.includes(p),
288
+ );
289
+ }
290
+
291
+ setFormData({ ...formData, permissions: newPermissions });
292
+ };
293
+
294
+ // Check if group has full permissions
295
+ const isGroupFullPermission = (groupName: string) => {
296
+ const groupResources = groupedMatrix[groupName] || [];
297
+ const groupPermissions = groupResources.flatMap((r) =>
298
+ actions
299
+ .filter((a) => r.actions.includes(a.code))
300
+ .map((a) => `${a.code}:${r.resource}`),
301
+ );
302
+ return (
303
+ groupPermissions.length > 0 &&
304
+ groupPermissions.every((p) => formData.permissions.includes(p))
305
+ );
306
+ };
307
+
308
+ const toggleAllForResource = (
309
+ resourceCode: string,
310
+ resourceActions: string[],
311
+ check: boolean,
312
+ ) => {
313
+ let newPermissions = [...formData.permissions];
314
+
315
+ resourceActions.forEach((actionCode) => {
316
+ const permission = `${actionCode}:${resourceCode}`;
317
+ if (check && !newPermissions.includes(permission)) {
318
+ newPermissions.push(permission);
319
+ } else if (!check && newPermissions.includes(permission)) {
320
+ newPermissions = newPermissions.filter((p) => p !== permission);
321
+ }
322
+ });
323
+
324
+ setFormData((prev) => ({ ...prev, permissions: newPermissions }));
325
+ };
326
+
327
+ const isResourceFullPermission = (
328
+ resourceCode: string,
329
+ resourceActions: string[],
330
+ ) => {
331
+ if (resourceActions.length === 0) return false;
332
+ return resourceActions.every((actionCode) =>
333
+ formData.permissions.includes(`${actionCode}:${resourceCode}`),
334
+ );
335
+ };
336
+
337
+ const handleSubmit = async (e: React.FormEvent) => {
338
+ e.preventDefault();
339
+ setIsSubmitting(true);
340
+
341
+ try {
342
+ const url =
343
+ mode === "create" ? "/api/roles" : `/api/roles/${initialData?.id}`;
344
+ const method = mode === "create" ? "POST" : "PUT";
345
+
346
+ const res = await fetch(url, {
347
+ method,
348
+ headers: { "Content-Type": "application/json" },
349
+ body: JSON.stringify(formData),
350
+ });
351
+
352
+ if (!res.ok) {
353
+ await throwFetchError(res, `Failed to ${mode} role`);
354
+ }
355
+
356
+ toast.success("Thành công", {
357
+ description:
358
+ mode === "create"
359
+ ? "Đã tạo vai trò mới thành công"
360
+ : "Đã cập nhật vai trò thành công",
361
+ });
362
+
363
+ router.push(`/${lang}/roles`);
364
+ router.refresh();
365
+ } catch (error: any) {
366
+ toast.error(errorMessage(error, "Lỗi"));
367
+ } finally {
368
+ setIsSubmitting(false);
369
+ }
370
+ };
371
+
372
+ return (
373
+ <div className="container max-w-7xl mx-auto py-6 space-y-6">
374
+ {/* Header & General Info (Notion Style) — thẻ trắng */}
375
+ <div className="flex flex-col lg:flex-row items-start lg:justify-between border border-border pb-4 sm:pb-6 bg-card p-4 sm:p-6 rounded-2xl shadow-sm gap-4 sm:gap-6">
376
+ <div className="flex items-start gap-2 sm:gap-4 flex-1 w-full min-w-0">
377
+ <Button
378
+ variant="ghost"
379
+ size="icon"
380
+ onClick={() => router.push(`/${lang}/roles`)}
381
+ className="mt-1 shrink-0"
382
+ >
383
+ <ArrowLeft className="h-5 w-5" />
384
+ </Button>
385
+ <div className="flex-1 space-y-3 w-full min-w-0">
386
+ <div>
387
+ <Input
388
+ id="name"
389
+ value={formData.name}
390
+ onChange={(e) =>
391
+ setFormData({ ...formData, name: e.target.value })
392
+ }
393
+ placeholder="Tên vai trò (VD: Quản lý kho)..."
394
+ className="text-xl sm:text-2xl md:text-3xl font-black h-auto py-2 border-transparent hover:border-border focus-visible:ring-0 focus-visible:border-primary focus-visible:bg-card bg-transparent px-2 -ml-2 shadow-none rounded-[8px] placeholder:text-muted-foreground/40 transition-colors w-full"
395
+ required
396
+ />
397
+ <div className="flex flex-wrap items-center gap-2 sm:gap-4 mt-2 px-1">
398
+ <div className="flex items-center gap-2">
399
+ <span className="text-[10px] sm:text-xs font-medium text-muted-foreground uppercase">
400
+ Mã:
401
+ </span>
402
+ <Input
403
+ id="code"
404
+ value={formData.code}
405
+ onChange={(e) =>
406
+ setFormData({ ...formData, code: e.target.value })
407
+ }
408
+ placeholder="VD: warehouse_manager"
409
+ disabled={mode === "edit"}
410
+ className="h-7 text-xs border border-border bg-card w-32 sm:w-48 shadow-none focus-visible:ring-1 focus-visible:ring-primary rounded-md"
411
+ />
412
+ </div>
413
+ <div className="flex items-center gap-2 bg-card px-2 sm:px-3 h-7 rounded-md border border-border shrink-0">
414
+ <span className="text-[10px] sm:text-xs font-medium text-muted-foreground uppercase">
415
+ Hoạt động:
416
+ </span>
417
+ <Switch
418
+ checked={formData.status === "active"}
419
+ onCheckedChange={(checked) =>
420
+ setFormData({
421
+ ...formData,
422
+ status: checked ? "active" : "inactive",
423
+ })
424
+ }
425
+ className="scale-75 origin-left"
426
+ />
427
+ </div>
428
+ </div>
429
+ </div>
430
+ <div className="px-1">
431
+ <Input
432
+ id="description"
433
+ value={formData.description}
434
+ onChange={(e) =>
435
+ setFormData({ ...formData, description: e.target.value })
436
+ }
437
+ placeholder="Thêm mô tả về nhiệm vụ của vai trò này..."
438
+ className="h-8 text-xs sm:text-sm border-transparent hover:border-border focus-visible:ring-0 focus-visible:bg-muted/40 shadow-none bg-transparent px-2 -ml-2 rounded-md w-full"
439
+ />
440
+ </div>
441
+ </div>
442
+ </div>
443
+
444
+ <div className="flex flex-col gap-2 shrink-0 w-full lg:w-auto lg:items-end mt-2 lg:mt-0 pl-10 sm:pl-14 lg:pl-0">
445
+ {permissions?.update === false && mode === "edit" && (
446
+ <Badge variant="destructive" className="mb-2 w-fit">
447
+ Xem chế độ chỉ đọc
448
+ </Badge>
449
+ )}
450
+ <div className="flex items-center gap-2 w-full lg:w-auto">
451
+ <Button
452
+ variant="outline"
453
+ onClick={() => router.push(`/${lang}/roles`)}
454
+ disabled={isSubmitting}
455
+ className="h-9 rounded-[8px] flex-1 lg:flex-none"
456
+ >
457
+ <X className="mr-2 h-4 w-4" />
458
+ Hủy
459
+ </Button>
460
+ <Button
461
+ onClick={handleSubmit}
462
+ disabled={isSubmitting}
463
+ className="h-9 rounded-[8px] flex-1 lg:flex-none"
464
+ >
465
+ <Save className="mr-2 h-4 w-4" />
466
+ {isSubmitting ? "Đang lưu..." : "Lưu"}
467
+ </Button>
468
+ </div>
469
+ </div>
470
+ </div>
471
+
472
+ {/* Section 2: Permissions Toolbar — thẻ trắng, input trắng */}
473
+ <div className="sticky top-4 z-20 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/80 p-3 rounded-xl border border-border shadow-sm flex flex-col md:flex-row gap-3 items-center justify-between">
474
+ <div className="flex items-center gap-3 w-full md:w-auto flex-1">
475
+ <div className="relative w-full md:w-80">
476
+ <Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
477
+ <Input
478
+ placeholder="Tìm kiếm tài nguyên..."
479
+ className="pl-9 h-9 text-sm bg-card border border-border rounded-md"
480
+ value={searchTerm}
481
+ onChange={(e) => setSearchTerm(e.target.value)}
482
+ />
483
+ </div>
484
+ <Select
485
+ value={activeGroup || "all"}
486
+ onValueChange={(val) => setActiveGroup(val === "all" ? null : val)}
487
+ >
488
+ <SelectTrigger className="w-[180px] h-9 text-sm bg-card border border-border rounded-md">
489
+ <SelectValue placeholder="Tất cả nhóm" />
490
+ </SelectTrigger>
491
+ <SelectContent className="rounded-lg">
492
+ <SelectItem value="all">Tất cả nhóm</SelectItem>
493
+ {Object.keys(groupedMatrix).map((group) => (
494
+ <SelectItem key={group} value={group}>
495
+ {group}
496
+ </SelectItem>
497
+ ))}
498
+ </SelectContent>
499
+ </Select>
500
+ </div>
501
+ <div className="flex items-center gap-2">
502
+ <Badge
503
+ variant="outline"
504
+ className="font-medium bg-card text-muted-foreground border-border h-7 px-2"
505
+ >
506
+ {filteredMatrix.length} tài nguyên
507
+ </Badge>
508
+ </div>
509
+ </div>
510
+
511
+ {/* Section 3: Permission Groups */}
512
+ <div className="space-y-8">
513
+ {Object.entries(groupedMatrix).map(([group, groupResources]) => {
514
+ // Skip if filtering by specific group
515
+ if (activeGroup && activeGroup !== "all" && activeGroup !== group)
516
+ return null;
517
+ if (groupResources.length === 0) return null;
518
+
519
+ const isFull = isGroupFullPermission(group);
520
+
521
+ return (
522
+ <div
523
+ key={group}
524
+ className="space-y-4 animate-in fade-in duration-500"
525
+ >
526
+ {/* Header nhóm — thẻ trắng, điểm nhấn là chip chevron indigo */}
527
+ <div
528
+ className="flex flex-col sm:flex-row sm:items-center justify-between bg-card p-2.5 rounded-xl border border-border mb-4 gap-3 shadow-sm cursor-pointer hover:border-primary/30 transition-colors"
529
+ onClick={() => toggleGroupCollapse(group)}
530
+ >
531
+ <div className="flex items-center gap-2.5 pl-1">
532
+ <div className="bg-primary text-primary-foreground p-1.5 rounded-md shadow-sm transition-transform duration-200">
533
+ {collapsedGroups[group] ? (
534
+ <ChevronRight className="h-4 w-4" />
535
+ ) : (
536
+ <ChevronDown className="h-4 w-4" />
537
+ )}
538
+ </div>
539
+ <div>
540
+ <h3 className="text-sm font-bold text-foreground flex items-center gap-2">
541
+ {group}
542
+ {isFull && (
543
+ <Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400 py-0 h-4.5 px-1.5 text-[9px] font-semibold">
544
+ <Check className="w-2.5 h-2.5 mr-0.5" />
545
+ Đã chọn hết
546
+ </Badge>
547
+ )}
548
+ </h3>
549
+ <p className="text-xs text-muted-foreground font-medium mt-0.5">
550
+ {groupResources.length} tài nguyên
551
+ </p>
552
+ </div>
553
+ </div>
554
+ <div className="flex gap-1.5 self-start sm:self-auto pr-1">
555
+ <Button
556
+ type="button"
557
+ variant="outline"
558
+ size="sm"
559
+ onClick={(e) => {
560
+ e.stopPropagation();
561
+ toggleAllForGroup(group, false);
562
+ }}
563
+ className="h-7 text-xs font-semibold text-muted-foreground hover:text-destructive bg-card rounded-md border"
564
+ >
565
+ Bỏ chọn tất cả
566
+ </Button>
567
+ <Button
568
+ type="button"
569
+ variant="default"
570
+ size="sm"
571
+ onClick={(e) => {
572
+ e.stopPropagation();
573
+ toggleAllForGroup(group, true);
574
+ }}
575
+ className="h-7 text-xs font-semibold rounded-md bg-primary text-primary-foreground"
576
+ >
577
+ Chọn tất cả
578
+ </Button>
579
+ </div>
580
+ </div>
581
+
582
+ {!collapsedGroups[group] && (
583
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3 animate-in fade-in slide-in-from-top-2 duration-300">
584
+ {groupResources.map((resource) => (
585
+ <div
586
+ key={resource.resource}
587
+ className="border border-border bg-card rounded-xl shadow-sm hover:border-primary/40 transition-colors overflow-hidden group"
588
+ >
589
+ {/* Header thẻ tài nguyên — trắng, chỉ phân tách bằng border */}
590
+ <div className="p-2.5 border-b border-border/60 flex items-center justify-between">
591
+ <div className="flex items-center gap-2.5">
592
+ <div className="h-7 w-7 rounded-[6px] bg-card border border-border shadow-sm flex items-center justify-center shrink-0 text-muted-foreground group-hover:text-primary transition-colors">
593
+ <DynamicIcon
594
+ name={resource.icon as any}
595
+ className="h-3.5 w-3.5"
596
+ />
597
+ </div>
598
+ <div className="flex flex-col">
599
+ <h4 className="text-sm font-bold text-foreground leading-tight">
600
+ {resource.name}
601
+ </h4>
602
+ <span className="text-[10px] text-muted-foreground mt-0.5">
603
+ {resource.resource}
604
+ </span>
605
+ </div>
606
+ </div>
607
+ <div
608
+ className="flex items-center gap-2"
609
+ onClick={(e) => e.stopPropagation()}
610
+ >
611
+ <span className="text-[10px] text-muted-foreground font-semibold hidden sm:inline">
612
+ Tất cả
613
+ </span>
614
+ <Switch
615
+ checked={isResourceFullPermission(
616
+ resource.resource,
617
+ resource.actions,
618
+ )}
619
+ onCheckedChange={(checked) =>
620
+ toggleAllForResource(
621
+ resource.resource,
622
+ resource.actions,
623
+ checked,
624
+ )
625
+ }
626
+ className="scale-75 origin-right"
627
+ />
628
+ </div>
629
+ </div>
630
+
631
+ <div className="p-2 grid grid-cols-2 gap-1.5">
632
+ {actions.map((action) => {
633
+ const isAvailable = resource.actions.includes(
634
+ action.code,
635
+ );
636
+ const isChecked = hasPermission(
637
+ resource.resource,
638
+ action.code,
639
+ );
640
+ const staticConfig = mergedActionLabels[action.code];
641
+
642
+ const config = {
643
+ label:
644
+ action.name || staticConfig?.label || action.code,
645
+ icon: staticConfig?.icon || "•",
646
+ color:
647
+ staticConfig?.color || "text-muted-foreground",
648
+ };
649
+
650
+ if (!isAvailable) return null;
651
+
652
+ return (
653
+ <div
654
+ key={action.code}
655
+ className={cn(
656
+ "flex items-center gap-2 px-2 py-1.5 rounded-[6px] border transition-all cursor-pointer relative overflow-hidden",
657
+ isChecked
658
+ ? "bg-primary/5 border-primary/20 shadow-[0_1px_2px_rgba(0,0,0,0.02)]"
659
+ : "bg-card hover:bg-muted/50 border-border",
660
+ )}
661
+ onClick={() =>
662
+ togglePermission(resource.resource, action.code)
663
+ }
664
+ >
665
+ <div
666
+ className={cn(
667
+ "flex items-center justify-center w-3.5 h-3.5 rounded-[4px] border transition-colors shrink-0",
668
+ isChecked
669
+ ? "bg-primary border-primary text-primary-foreground"
670
+ : "bg-card border-border text-transparent",
671
+ )}
672
+ >
673
+ <Check className="h-2.5 w-2.5" />
674
+ </div>
675
+ <div className="flex items-center gap-1.5 overflow-hidden">
676
+ <span className="text-xs shrink-0 opacity-80 filter grayscale">
677
+ {config.icon}
678
+ </span>
679
+ <span
680
+ className={cn(
681
+ "text-xs font-semibold truncate",
682
+ isChecked
683
+ ? "text-primary"
684
+ : "text-muted-foreground",
685
+ )}
686
+ >
687
+ {config.label}
688
+ </span>
689
+ </div>
690
+ </div>
691
+ );
692
+ })}
693
+ </div>
694
+ </div>
695
+ ))}
696
+ </div>
697
+ )}
698
+ </div>
699
+ );
700
+ })}
701
+ </div>
702
+ </div>
703
+ );
704
+ }