@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.
- package/CHANGELOG.md +22 -0
- package/package.json +1 -1
- package/src/rbac/components/roles/role-card.tsx +25 -24
- package/src/rbac/components/roles/role-toolbar.tsx +70 -84
- package/src/rbac/index.ts +8 -0
- package/src/rbac/lib/fetch-error.ts +26 -0
- package/src/rbac/pages/action-list-page.tsx +581 -19
- package/src/rbac/pages/index.ts +13 -0
- package/src/rbac/pages/resource-list-page.tsx +1415 -19
- package/src/rbac/pages/role-form-page.tsx +704 -0
- package/src/rbac/pages/role-list-page.tsx +14 -12
|
@@ -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
|
+
}
|