@goplusvn/core 0.1.21 → 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
- package/src/ui/forms/date-picker.tsx +167 -19
- package/src/ui/forms/multi-select.tsx +475 -153
|
@@ -1,25 +1,1421 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}:
|
|
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
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
}
|