@goplusvn/core 0.1.46 → 0.1.48

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.
@@ -5,7 +5,7 @@
5
5
  // vai trò là MẶC ĐỊNH (toggle List/Grid). Bấm thẻ bung editor quyền
6
6
  // (ResourceActionsEditor dùng chung dòng/thẻ); xóa trực tiếp từ thẻ.
7
7
  // API contract: /api/resources (+/sync, /:id), /api/actions.
8
- import { useEffect, useMemo, useState } from "react"
8
+ import { useEffect, useMemo, useState } from "react";
9
9
  import {
10
10
  Badge,
11
11
  Button,
@@ -26,8 +26,8 @@ import {
26
26
  SelectValue,
27
27
  Skeleton,
28
28
  Textarea,
29
- } from "../../ui"
30
- import { toast } from "sonner"
29
+ } from "../../ui";
30
+ import { toast } from "sonner";
31
31
  import {
32
32
  Box,
33
33
  Boxes,
@@ -37,8 +37,6 @@ import {
37
37
  ChevronRight,
38
38
  ClipboardList,
39
39
  DatabaseBackup,
40
- LayoutGrid,
41
- List,
42
40
  Pencil,
43
41
  Plus,
44
42
  RefreshCw,
@@ -50,111 +48,37 @@ import {
50
48
  Truck,
51
49
  Users,
52
50
  Wallet,
53
- X,
54
51
  Zap,
55
- } from "lucide-react"
56
-
57
- import { errorMessage, throwFetchError } from "../lib/fetch-error"
58
- import { cn } from "../../utils"
52
+ } from "lucide-react";
59
53
 
54
+ import { errorMessage, throwFetchError } from "../lib/fetch-error";
55
+ import { deleteActionById, saveAction } from "./lib/action-api";
56
+ import { RbacPageBar, RbacStickyToolbar } from "./lib/rbac-page-shell";
57
+ import { cn } from "../../utils";
58
+ import { getGroupMeta, getGroupOrder } from "./lib/group-taxonomy";
60
59
 
61
60
  // --- Types ---
62
61
  interface Resource {
63
- id: string
64
- code: string
65
- name: string
66
- group: string
67
- description?: string
68
- icon?: string
69
- actions: string[]
70
- config?: any
62
+ id: string;
63
+ code: string;
64
+ name: string;
65
+ group: string;
66
+ description?: string;
67
+ icon?: string;
68
+ actions: string[];
69
+ config?: any;
71
70
  }
72
71
 
73
72
  interface Action {
74
- id: string
75
- code: string
76
- name: string
77
- description?: string
73
+ id: string;
74
+ code: string;
75
+ name: string;
76
+ description?: string;
78
77
  }
79
78
 
80
79
  // --- Group metadata: one muted hue per group ---
81
- const GROUP_META: Record<
82
- string,
83
- { icon: any; label: string; iconCls: string }
84
- > = {
85
- sales: {
86
- icon: ShoppingBag,
87
- label: "Bán hàng",
88
- iconCls: "text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/15",
89
- },
90
- purchase: {
91
- icon: ClipboardList,
92
- label: "Mua hàng",
93
- iconCls:
94
- "text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/15",
95
- },
96
- inventory: {
97
- icon: Truck,
98
- label: "Kho",
99
- iconCls:
100
- "text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/15",
101
- },
102
- finance: {
103
- icon: Wallet,
104
- label: "Tài chính",
105
- iconCls:
106
- "text-violet-600 bg-violet-100 dark:text-violet-400 dark:bg-violet-500/15",
107
- },
108
- hr: {
109
- icon: Users,
110
- label: "Nhân sự",
111
- iconCls: "text-rose-600 bg-rose-100 dark:text-rose-400 dark:bg-rose-500/15",
112
- },
113
- master: {
114
- icon: Boxes,
115
- label: "Danh mục",
116
- iconCls: "text-cyan-600 bg-cyan-100 dark:text-cyan-400 dark:bg-cyan-500/15",
117
- },
118
- users: {
119
- icon: Users,
120
- label: "Người dùng",
121
- iconCls:
122
- "text-fuchsia-600 bg-fuchsia-100 dark:text-fuchsia-400 dark:bg-fuchsia-500/15",
123
- },
124
- system: {
125
- icon: Settings,
126
- label: "Hệ thống",
127
- iconCls:
128
- "text-slate-600 bg-slate-200 dark:text-slate-300 dark:bg-slate-500/20",
129
- },
130
- other: {
131
- icon: Box,
132
- label: "Khác",
133
- iconCls:
134
- "text-slate-600 bg-slate-200 dark:text-slate-300 dark:bg-slate-500/20",
135
- },
136
- }
137
-
138
- const GROUP_ORDER = [
139
- "sales",
140
- "purchase",
141
- "inventory",
142
- "finance",
143
- "hr",
144
- "master",
145
- "users",
146
- "system",
147
- "other",
148
- ]
149
-
150
- const getGroupMeta = (group: string) =>
151
- GROUP_META[group?.toLowerCase()] || {
152
- icon: Box,
153
- label: group || "Khác",
154
- iconCls:
155
- "text-slate-600 bg-slate-200 dark:text-slate-300 dark:bg-slate-500/20",
156
- }
157
-
80
+ // Taxonomy nhóm resource: default + registerResourceGroupMeta cho app —
81
+ // sống ở ./lib/group-taxonomy (OCP: app thêm nhóm không cần sửa core).
158
82
  // --- Action chips colored by permission category ---
159
83
  const ACTION_TONES: Record<string, string> = {
160
84
  read: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-300",
@@ -167,16 +91,16 @@ const ACTION_TONES: Record<string, string> = {
167
91
  approve:
168
92
  "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300",
169
93
  neutral: "border-border bg-muted text-foreground/80",
170
- }
94
+ };
171
95
 
172
96
  const getActionTone = (code: string): string => {
173
- if (/^(view|read|get|list|export|print|sync)/.test(code)) return "read"
174
- if (/^(create|add|new)/.test(code)) return "create"
175
- if (/^(update|edit|change|set)/.test(code)) return "update"
176
- if (/^(delete|remove)/.test(code)) return "delete"
177
- if (/^(approve|confirm|sign|verify|accept)/.test(code)) return "approve"
178
- return "neutral"
179
- }
97
+ if (/^(view|read|get|list|export|print|sync)/.test(code)) return "read";
98
+ if (/^(create|add|new)/.test(code)) return "create";
99
+ if (/^(update|edit|change|set)/.test(code)) return "update";
100
+ if (/^(delete|remove)/.test(code)) return "delete";
101
+ if (/^(approve|confirm|sign|verify|accept)/.test(code)) return "approve";
102
+ return "neutral";
103
+ };
180
104
 
181
105
  // 4 hành động CRUD chuẩn → nhóm "Cơ bản"; còn lại vào "Chức năng" (A→Z).
182
106
  const BASIC_ORDER = [
@@ -188,21 +112,21 @@ const BASIC_ORDER = [
188
112
  "edit",
189
113
  "delete",
190
114
  "remove",
191
- ]
115
+ ];
192
116
 
193
117
  const categorizeActions = (actions: Action[]) => {
194
- const basic: Action[] = []
195
- const functional: Action[] = []
118
+ const basic: Action[] = [];
119
+ const functional: Action[] = [];
196
120
  for (const a of actions) {
197
- if (BASIC_ORDER.includes(a.code)) basic.push(a)
198
- else functional.push(a)
121
+ if (BASIC_ORDER.includes(a.code)) basic.push(a);
122
+ else functional.push(a);
199
123
  }
200
124
  basic.sort(
201
- (x, y) => BASIC_ORDER.indexOf(x.code) - BASIC_ORDER.indexOf(y.code)
202
- )
203
- functional.sort((x, y) => x.name.localeCompare(y.name, "vi"))
204
- return { basic, functional }
205
- }
125
+ (x, y) => BASIC_ORDER.indexOf(x.code) - BASIC_ORDER.indexOf(y.code),
126
+ );
127
+ functional.sort((x, y) => x.name.localeCompare(y.name, "vi"));
128
+ return { basic, functional };
129
+ };
206
130
 
207
131
  // Diacritic-insensitive normalize (so "duyet" matches "Duyệt")
208
132
  const normalize = (s: string) =>
@@ -210,249 +134,253 @@ const normalize = (s: string) =>
210
134
  .toLowerCase()
211
135
  .normalize("NFD")
212
136
  .replace(/[\u0300-\u036f]/g, "")
213
- .replace(/đ/g, "d")
137
+ .replace(/đ/g, "d");
214
138
 
215
139
  export interface ResourceListPageProps {
216
140
  /** Danh mục do Permission Registry trong code quản lý: ẩn nút Thêm/Sync/
217
141
  * Action-manager, thao tác sửa/xóa hiện toast hướng dẫn thay vì mở dialog. */
218
- readOnly?: boolean
142
+ readOnly?: boolean;
219
143
  }
220
144
 
221
- export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {}) {
145
+ export function ResourceListPage({
146
+ readOnly = false,
147
+ }: ResourceListPageProps = {}) {
222
148
  const registryToast = () =>
223
149
  toast.info("Danh mục do Permission Registry quản lý", {
224
150
  description:
225
151
  "Sửa file <feature>.permissions.ts trong code rồi chạy rbac-sync — không chỉnh tay ở đây.",
226
- })
152
+ });
227
153
  const guardWrite = <T extends (...args: any[]) => any>(fn: T): T =>
228
- (readOnly ? ((() => registryToast()) as unknown as T) : fn)
229
- const [loading, setLoading] = useState(true)
230
- const [resources, setResources] = useState<Resource[]>([])
231
- const [allActions, setAllActions] = useState<Action[]>([])
232
- const [searchTerm, setSearchTerm] = useState("")
233
- const [isRefreshing, setIsRefreshing] = useState(false)
234
- const [isSyncing, setIsSyncing] = useState(false)
235
- const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
236
- const [view, setView] = useState<"list" | "grid">("grid")
154
+ readOnly ? ((() => registryToast()) as unknown as T) : fn;
155
+ const [loading, setLoading] = useState(true);
156
+ const [resources, setResources] = useState<Resource[]>([]);
157
+ const [allActions, setAllActions] = useState<Action[]>([]);
158
+ const [searchTerm, setSearchTerm] = useState("");
159
+ const [isRefreshing, setIsRefreshing] = useState(false);
160
+ const [isSyncing, setIsSyncing] = useState(false);
161
+ const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
162
+ new Set(),
163
+ );
164
+ const [view, setView] = useState<"list" | "grid">("grid");
237
165
 
238
166
  // Hàng nào đang bung để xem/sửa toàn bộ action
239
- const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
167
+ const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
240
168
 
241
169
  // Resource Dialog State
242
- const [isDialogOpen, setIsDialogOpen] = useState(false)
243
- const [editingResource, setEditingResource] = useState<Resource | null>(null)
170
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
171
+ const [editingResource, setEditingResource] = useState<Resource | null>(null);
244
172
  const [formData, setFormData] = useState({
245
173
  code: "",
246
174
  name: "",
247
175
  group: "",
248
176
  description: "",
249
- })
177
+ });
250
178
 
251
179
  // Action Manager State
252
- const [isActionManagerOpen, setIsActionManagerOpen] = useState(false)
180
+ const [isActionManagerOpen, setIsActionManagerOpen] = useState(false);
253
181
  const [actionFormMode, setActionFormMode] = useState<
254
182
  "list" | "create" | "edit"
255
- >("list")
256
- const [editingAction, setEditingAction] = useState<Action | null>(null)
183
+ >("list");
184
+ const [editingAction, setEditingAction] = useState<Action | null>(null);
257
185
  const [actionFormData, setActionFormData] = useState({
258
186
  code: "",
259
187
  name: "",
260
188
  description: "",
261
- })
189
+ });
262
190
 
263
191
  // Confirmation state
264
- const [confirmOpen, setConfirmOpen] = useState(false)
192
+ const [confirmOpen, setConfirmOpen] = useState(false);
265
193
  const [confirmType, setConfirmType] = useState<"action" | "resource" | null>(
266
- null
267
- )
194
+ null,
195
+ );
268
196
  const [itemToDelete, setItemToDelete] = useState<{
269
- id: string
270
- name: string
271
- } | null>(null)
272
- const [isDeleting, setIsDeleting] = useState(false)
197
+ id: string;
198
+ name: string;
199
+ } | null>(null);
200
+ const [isDeleting, setIsDeleting] = useState(false);
273
201
 
274
202
  // --- Data Fetching ---
275
203
  const fetchData = async () => {
276
204
  try {
277
- setLoading(true)
205
+ setLoading(true);
278
206
  const [resResources, resActions] = await Promise.all([
279
207
  fetch("/api/resources?pageSize=1000"),
280
208
  fetch("/api/actions?pageSize=1000"),
281
- ])
209
+ ]);
282
210
 
283
211
  if (!resResources.ok || !resActions.ok)
284
- throw new Error("Failed to fetch data")
212
+ throw new Error("Failed to fetch data");
285
213
 
286
- const resourcesData = await resResources.json()
287
- const actionsData = await resActions.json()
214
+ const resourcesData = await resResources.json();
215
+ const actionsData = await resActions.json();
288
216
 
289
217
  const mappedResources = (resourcesData.items || []).map((r: any) => {
290
- let actions: string[] = []
218
+ let actions: string[] = [];
291
219
  try {
292
220
  if (r.config) {
293
221
  const configObj =
294
- typeof r.config === "string" ? JSON.parse(r.config) : r.config
222
+ typeof r.config === "string" ? JSON.parse(r.config) : r.config;
295
223
  if (configObj.actions && Array.isArray(configObj.actions)) {
296
- actions = configObj.actions
224
+ actions = configObj.actions;
297
225
  }
298
226
  }
299
227
  } catch (_catchError) {
300
228
  // Ignore invalid config JSON
301
229
  }
302
- return { ...r, actions }
303
- })
230
+ return { ...r, actions };
231
+ });
304
232
 
305
- setResources(mappedResources)
306
- setAllActions(actionsData.items || [])
233
+ setResources(mappedResources);
234
+ setAllActions(actionsData.items || []);
307
235
  } catch (error) {
308
- toast.error(errorMessage(error, "Lỗi tải dữ liệu"))
236
+ toast.error(errorMessage(error, "Lỗi tải dữ liệu"));
309
237
  } finally {
310
- setLoading(false)
311
- setIsRefreshing(false)
238
+ setLoading(false);
239
+ setIsRefreshing(false);
312
240
  }
313
- }
241
+ };
314
242
 
315
243
  useEffect(() => {
316
- fetchData()
317
- }, [])
244
+ fetchData();
245
+ }, []);
318
246
 
319
247
  const handleRefresh = () => {
320
- setIsRefreshing(true)
321
- fetchData()
322
- }
248
+ setIsRefreshing(true);
249
+ fetchData();
250
+ };
323
251
 
324
252
  // --- Sync Resource ---
325
253
  const handleSync = async () => {
326
254
  try {
327
- setIsSyncing(true)
255
+ setIsSyncing(true);
328
256
  const res = await fetch("/api/resources/sync", {
329
257
  method: "POST",
330
- })
258
+ });
331
259
 
332
260
  if (!res.ok) {
333
- await throwFetchError(res, "Lỗi khi đồng bộ tài nguyên")
261
+ await throwFetchError(res, "Lỗi khi đồng bộ tài nguyên");
334
262
  }
335
263
 
336
- const data = await res.json()
337
- toast.success(data.message || "Đồng bộ tài nguyên thành công")
264
+ const data = await res.json();
265
+ toast.success(data.message || "Đồng bộ tài nguyên thành công");
338
266
 
339
- handleRefresh()
267
+ handleRefresh();
340
268
  } catch (error: any) {
341
- toast.error(errorMessage(error, "Không thể đồng bộ tài nguyên"))
269
+ toast.error(errorMessage(error, "Không thể đồng bộ tài nguyên"));
342
270
  } finally {
343
- setIsSyncing(false)
271
+ setIsSyncing(false);
344
272
  }
345
- }
273
+ };
346
274
 
347
275
  // Quick lookup: action code -> action
348
276
  const actionMap = useMemo(() => {
349
- const map = new Map<string, Action>()
350
- for (const a of allActions) map.set(a.code, a)
351
- return map
352
- }, [allActions])
277
+ const map = new Map<string, Action>();
278
+ for (const a of allActions) map.set(a.code, a);
279
+ return map;
280
+ }, [allActions]);
353
281
 
354
282
  const categorizedActions = useMemo(
355
283
  () => categorizeActions(allActions),
356
- [allActions]
357
- )
284
+ [allActions],
285
+ );
358
286
 
359
287
  // --- Search: match resource text OR granted action name ---
360
288
  const { sections, filteredResourceTotal, isEmpty } = useMemo(() => {
361
- const q = normalize(searchTerm.trim())
289
+ const q = normalize(searchTerm.trim());
362
290
 
363
291
  const matches = (r: Resource) => {
364
- if (!q) return true
292
+ if (!q) return true;
365
293
  if (
366
294
  normalize(r.name).includes(q) ||
367
295
  normalize(r.code).includes(q) ||
368
296
  normalize(r.group || "").includes(q) ||
369
297
  normalize(getGroupMeta(r.group).label).includes(q)
370
298
  )
371
- return true
299
+ return true;
372
300
  return (r.actions || []).some((code) => {
373
- const a = actionMap.get(code)
301
+ const a = actionMap.get(code);
374
302
  return (
375
303
  normalize(a?.name || code).includes(q) || normalize(code).includes(q)
376
- )
377
- })
378
- }
304
+ );
305
+ });
306
+ };
379
307
 
380
- const rows = resources.filter(matches)
308
+ const rows = resources.filter(matches);
381
309
 
382
310
  if (q && rows.length === 0) {
383
311
  return {
384
312
  sections: [] as {
385
- group: string
386
- meta: { icon: any; label: string; iconCls: string }
387
- items: Resource[]
313
+ group: string;
314
+ meta: { icon: any; label: string; iconCls: string };
315
+ items: Resource[];
388
316
  }[],
389
317
  filteredResourceTotal: 0,
390
318
  isEmpty: true,
391
- }
319
+ };
392
320
  }
393
321
 
394
- const grouped: Record<string, Resource[]> = {}
322
+ const grouped: Record<string, Resource[]> = {};
395
323
  for (const r of rows) {
396
- const g = (r.group || "other").toLowerCase()
397
- ;(grouped[g] ||= []).push(r)
324
+ const g = (r.group || "other").toLowerCase();
325
+ (grouped[g] ||= []).push(r);
398
326
  }
399
327
 
400
328
  const orderedSections = Object.keys(grouped)
401
329
  .sort((a, b) => {
402
- const ia = GROUP_ORDER.indexOf(a)
403
- const ib = GROUP_ORDER.indexOf(b)
404
- if (ia !== -1 && ib !== -1) return ia - ib
405
- if (ia !== -1) return -1
406
- if (ib !== -1) return 1
407
- return a.localeCompare(b)
330
+ const ia = getGroupOrder().indexOf(a);
331
+ const ib = getGroupOrder().indexOf(b);
332
+ if (ia !== -1 && ib !== -1) return ia - ib;
333
+ if (ia !== -1) return -1;
334
+ if (ib !== -1) return 1;
335
+ return a.localeCompare(b);
408
336
  })
409
337
  .map((group) => ({
410
338
  group,
411
339
  meta: getGroupMeta(group),
412
340
  items: grouped[group].sort((x, y) => x.name.localeCompare(y.name)),
413
- }))
341
+ }));
414
342
 
415
343
  return {
416
344
  sections: orderedSections,
417
345
  filteredResourceTotal: rows.length,
418
346
  isEmpty: false,
419
- }
420
- }, [resources, actionMap, searchTerm])
347
+ };
348
+ }, [resources, actionMap, searchTerm]);
421
349
 
422
350
  const toggleGroup = (group: string) => {
423
351
  setCollapsedGroups((prev) => {
424
- const next = new Set(prev)
425
- if (next.has(group)) next.delete(group)
426
- else next.add(group)
427
- return next
428
- })
429
- }
352
+ const next = new Set(prev);
353
+ if (next.has(group)) next.delete(group);
354
+ else next.add(group);
355
+ return next;
356
+ });
357
+ };
430
358
 
431
359
  // --- Resource Actions ---
432
360
  const handleUpdateActions = async (
433
361
  resource: Resource,
434
- newActions: string[]
362
+ newActions: string[],
435
363
  ) => {
436
364
  try {
437
365
  // Optimistic Update
438
366
  setResources((prev) =>
439
367
  prev.map((r) =>
440
- r.id === resource.id ? { ...r, actions: newActions } : r
441
- )
442
- )
368
+ r.id === resource.id ? { ...r, actions: newActions } : r,
369
+ ),
370
+ );
443
371
 
444
- let currentConfig: any = {}
372
+ let currentConfig: any = {};
445
373
  try {
446
374
  if (typeof resource.config === "string") {
447
- currentConfig = JSON.parse(resource.config)
375
+ currentConfig = JSON.parse(resource.config);
448
376
  } else if (typeof resource.config === "object") {
449
- currentConfig = resource.config || {}
377
+ currentConfig = resource.config || {};
450
378
  }
451
379
  } catch (_catchError) {
452
380
  // Ignore invalid config JSON
453
381
  }
454
382
 
455
- const newConfig = { ...currentConfig, actions: newActions }
383
+ const newConfig = { ...currentConfig, actions: newActions };
456
384
 
457
385
  const res = await fetch(`/api/resources/${resource.id}`, {
458
386
  method: "PUT",
@@ -461,253 +389,232 @@ export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {
461
389
  ...resource,
462
390
  config: JSON.stringify(newConfig),
463
391
  }),
464
- })
392
+ });
465
393
 
466
394
  if (!res.ok) {
467
- await throwFetchError(res, "Update failed")
395
+ await throwFetchError(res, "Update failed");
468
396
  }
469
397
  } catch (error) {
470
- toast.error(errorMessage(error, "Không thể cập nhật quyền"))
398
+ toast.error(errorMessage(error, "Không thể cập nhật quyền"));
471
399
  }
472
- }
400
+ };
473
401
 
474
402
  const toggleAction = (resource: Resource, actionCode: string) => {
475
- const currentActions = resource.actions || []
403
+ const currentActions = resource.actions || [];
476
404
  const newActions = currentActions.includes(actionCode)
477
405
  ? currentActions.filter((a) => a !== actionCode)
478
- : [...currentActions, actionCode]
479
- handleUpdateActions(resource, newActions)
480
- }
406
+ : [...currentActions, actionCode];
407
+ handleUpdateActions(resource, newActions);
408
+ };
481
409
 
482
410
  const toggleRow = (id: string) => {
483
411
  setExpandedRows((prev) => {
484
- const next = new Set(prev)
485
- if (next.has(id)) next.delete(id)
486
- else next.add(id)
487
- return next
488
- })
489
- }
412
+ const next = new Set(prev);
413
+ if (next.has(id)) next.delete(id);
414
+ else next.add(id);
415
+ return next;
416
+ });
417
+ };
490
418
 
491
419
  // --- Resource Dialog Handlers ---
492
420
  const openNewResource = () => {
493
- setEditingResource(null)
494
- setFormData({ code: "", name: "", group: "", description: "" })
495
- setIsDialogOpen(true)
496
- }
421
+ setEditingResource(null);
422
+ setFormData({ code: "", name: "", group: "", description: "" });
423
+ setIsDialogOpen(true);
424
+ };
497
425
 
498
426
  const openEditResource = (resource: Resource) => {
499
- setEditingResource(resource)
427
+ setEditingResource(resource);
500
428
  setFormData({
501
429
  code: resource.code,
502
430
  name: resource.name,
503
431
  group: resource.group || "",
504
432
  description: resource.description || "",
505
- })
506
- setIsDialogOpen(true)
507
- }
433
+ });
434
+ setIsDialogOpen(true);
435
+ };
508
436
 
509
437
  const handleSubmitResource = async () => {
510
438
  if (!formData.code || !formData.name) {
511
- toast.error("Vui lòng nhập Mã và Tên tài nguyên")
512
- return
439
+ toast.error("Vui lòng nhập Mã và Tên tài nguyên");
440
+ return;
513
441
  }
514
442
 
515
443
  try {
516
444
  const payload = {
517
445
  ...formData,
518
446
  status: "active",
519
- }
447
+ };
520
448
 
521
- let res
449
+ let res;
522
450
  if (editingResource) {
523
451
  res = await fetch(`/api/resources/${editingResource.id}`, {
524
452
  method: "PUT",
525
453
  headers: { "Content-Type": "application/json" },
526
454
  body: JSON.stringify({ id: editingResource.id, ...payload }),
527
- })
455
+ });
528
456
  } else {
529
457
  res = await fetch("/api/resources", {
530
458
  method: "POST",
531
459
  headers: { "Content-Type": "application/json" },
532
460
  body: JSON.stringify(payload),
533
- })
461
+ });
534
462
  }
535
463
 
536
464
  if (!res.ok) {
537
- await throwFetchError(res, "Operation failed")
465
+ await throwFetchError(res, "Operation failed");
538
466
  }
539
467
 
540
- const savedResource = await res.json()
468
+ const savedResource = await res.json();
541
469
  toast.success(
542
- editingResource ? "Cập nhật thành công" : "Tạo mới thành công"
543
- )
470
+ editingResource ? "Cập nhật thành công" : "Tạo mới thành công",
471
+ );
544
472
 
545
473
  if (editingResource) {
546
474
  setResources((prev) =>
547
475
  prev.map((r) =>
548
476
  r.id === savedResource.id
549
477
  ? { ...savedResource, actions: r.actions }
550
- : r
551
- )
552
- )
478
+ : r,
479
+ ),
480
+ );
553
481
  } else {
554
- setResources((prev) => [{ ...savedResource, actions: [] }, ...prev])
482
+ setResources((prev) => [{ ...savedResource, actions: [] }, ...prev]);
555
483
  }
556
484
 
557
- setIsDialogOpen(false)
485
+ setIsDialogOpen(false);
558
486
  } catch (error: any) {
559
- toast.error(errorMessage(error, "Có lỗi xảy ra"))
487
+ toast.error(errorMessage(error, "Có lỗi xảy ra"));
560
488
  }
561
- }
489
+ };
562
490
 
563
491
  // --- Action Manager Handlers ---
564
492
  const openActionManager = () => {
565
- setIsActionManagerOpen(true)
566
- setActionFormMode("list")
567
- }
493
+ setIsActionManagerOpen(true);
494
+ setActionFormMode("list");
495
+ };
568
496
 
569
497
  const openNewAction = () => {
570
- setEditingAction(null)
571
- setActionFormData({ code: "", name: "", description: "" })
572
- setActionFormMode("create")
573
- }
498
+ setEditingAction(null);
499
+ setActionFormData({ code: "", name: "", description: "" });
500
+ setActionFormMode("create");
501
+ };
574
502
 
575
503
  const openEditAction = (action: Action) => {
576
- setEditingAction(action)
504
+ setEditingAction(action);
577
505
  setActionFormData({
578
506
  code: action.code,
579
507
  name: action.name,
580
508
  description: action.description || "",
581
- })
582
- setActionFormMode("edit")
583
- }
509
+ });
510
+ setActionFormMode("edit");
511
+ };
584
512
 
585
513
  const handleSubmitAction = async () => {
586
514
  if (!actionFormData.code || !actionFormData.name) {
587
- toast.error("Vui lòng nhập Mã và Tên hành động")
588
- return
515
+ toast.error("Vui lòng nhập Mã và Tên hành động");
516
+ return;
589
517
  }
590
518
 
591
519
  try {
592
- const payload = {
593
- ...actionFormData,
594
- status: "active",
595
- description: actionFormData.description,
596
- }
597
-
598
- let res
599
- if (actionFormMode === "edit" && editingAction) {
600
- res = await fetch("/api/actions", {
601
- method: "PUT",
602
- headers: { "Content-Type": "application/json" },
603
- body: JSON.stringify({ id: editingAction.id, ...payload }),
604
- })
605
- } else {
606
- res = await fetch("/api/actions", {
607
- method: "POST",
608
- headers: { "Content-Type": "application/json" },
609
- body: JSON.stringify(payload),
610
- })
611
- }
612
-
613
- if (!res.ok) {
614
- await throwFetchError(res, "Operation failed")
615
- }
616
-
617
- const savedAction = await res.json()
520
+ const savedAction = await saveAction<Action>(
521
+ actionFormData,
522
+ actionFormMode === "edit" && editingAction
523
+ ? editingAction.id
524
+ : undefined,
525
+ );
618
526
 
619
527
  if (actionFormMode === "edit") {
620
528
  setAllActions((prev) =>
621
- prev.map((a) => (a.id === savedAction.id ? savedAction : a))
622
- )
623
- toast.success("Cập nhật hành động thành công")
529
+ prev.map((a) => (a.id === savedAction.id ? savedAction : a)),
530
+ );
531
+ toast.success("Cập nhật hành động thành công");
624
532
  } else {
625
- setAllActions((prev) => [savedAction, ...prev])
626
- toast.success("Tạo hành động thành công")
533
+ setAllActions((prev) => [savedAction, ...prev]);
534
+ toast.success("Tạo hành động thành công");
627
535
  }
628
- setActionFormMode("list")
536
+ setActionFormMode("list");
629
537
  } catch (error: any) {
630
- toast.error(errorMessage(error, "Có lỗi xảy ra"))
538
+ toast.error(errorMessage(error, "Có lỗi xảy ra"));
631
539
  }
632
- }
540
+ };
633
541
 
634
542
  const handleDeleteAction = (id: string, code: string) => {
635
- setItemToDelete({ id, name: code })
636
- setConfirmType("action")
637
- setConfirmOpen(true)
638
- }
543
+ setItemToDelete({ id, name: code });
544
+ setConfirmType("action");
545
+ setConfirmOpen(true);
546
+ };
639
547
 
640
548
  const handleDeleteResource = () => {
641
- if (!editingResource) return
642
- setItemToDelete({ id: editingResource.id, name: editingResource.code })
643
- setConfirmType("resource")
644
- setConfirmOpen(true)
645
- }
549
+ if (!editingResource) return;
550
+ setItemToDelete({ id: editingResource.id, name: editingResource.code });
551
+ setConfirmType("resource");
552
+ setConfirmOpen(true);
553
+ };
646
554
 
647
555
  // Xóa trực tiếp từ thẻ (không cần mở dialog sửa)
648
556
  const requestDeleteResource = (resource: Resource) => {
649
- setItemToDelete({ id: resource.id, name: resource.code })
650
- setConfirmType("resource")
651
- setConfirmOpen(true)
652
- }
557
+ setItemToDelete({ id: resource.id, name: resource.code });
558
+ setConfirmType("resource");
559
+ setConfirmOpen(true);
560
+ };
653
561
 
654
562
  const handleConfirmDelete = async () => {
655
- if (!itemToDelete || !confirmType) return
656
- setIsDeleting(true)
563
+ if (!itemToDelete || !confirmType) return;
564
+ setIsDeleting(true);
657
565
  try {
658
566
  if (confirmType === "action") {
659
- const res = await fetch(`/api/actions?id=${itemToDelete.id}`, {
660
- method: "DELETE",
661
- })
662
- if (!res.ok) {
663
- await throwFetchError(res, "Delete failed")
664
- }
665
- setAllActions((prev) => prev.filter((a) => a.id !== itemToDelete.id))
666
- toast.success("Xóa hành động thành công")
567
+ await deleteActionById(itemToDelete.id);
568
+ setAllActions((prev) => prev.filter((a) => a.id !== itemToDelete.id));
569
+ toast.success("Xóa hành động thành công");
667
570
  } else if (confirmType === "resource") {
668
571
  const res = await fetch(`/api/resources?id=${itemToDelete.id}`, {
669
572
  method: "DELETE",
670
- })
573
+ });
671
574
  if (!res.ok) {
672
- await throwFetchError(res, "Delete failed")
575
+ await throwFetchError(res, "Delete failed");
673
576
  }
674
- setResources((prev) => prev.filter((r) => r.id !== itemToDelete.id))
675
- toast.success("Xóa tài nguyên thành công")
676
- setIsDialogOpen(false)
577
+ setResources((prev) => prev.filter((r) => r.id !== itemToDelete.id));
578
+ toast.success("Xóa tài nguyên thành công");
579
+ setIsDialogOpen(false);
677
580
  }
678
- setConfirmOpen(false)
679
- setItemToDelete(null)
680
- setConfirmType(null)
581
+ setConfirmOpen(false);
582
+ setItemToDelete(null);
583
+ setConfirmType(null);
681
584
  } catch (error) {
682
- toast.error(errorMessage(error, `Không thể xóa ${
683
- confirmType === "action" ? "hành động" : "tài nguyên"
684
- }`))
585
+ toast.error(
586
+ errorMessage(
587
+ error,
588
+ `Không thể xóa ${
589
+ confirmType === "action" ? "hành động" : "tài nguyên"
590
+ }`,
591
+ ),
592
+ );
685
593
  } finally {
686
- setIsDeleting(false)
594
+ setIsDeleting(false);
687
595
  }
688
- }
596
+ };
689
597
 
690
- const isSearching = searchTerm.trim().length > 0
691
- const hasNoResources = !loading && resources.length === 0
598
+ const isSearching = searchTerm.trim().length > 0;
599
+ const hasNoResources = !loading && resources.length === 0;
692
600
 
693
601
  const totalGroups = new Set(
694
- resources.map((r) => (r.group || "other").toLowerCase())
695
- ).size
602
+ resources.map((r) => (r.group || "other").toLowerCase()),
603
+ ).size;
696
604
 
697
605
  return (
698
606
  <div className="flex flex-col">
699
607
  {/* ===== Bar navy 1 dòng — số liệu + tiện ích + Thêm tài nguyên ===== */}
700
- <div className="-mx-4 -mt-4 flex flex-wrap items-center gap-1.5 bg-sidebar px-3 py-2 text-sidebar-foreground shadow-md md:-mx-8 lg:mx-3 lg:mt-1 lg:rounded-xl lg:px-4">
701
- <span className="flex h-8 w-8 shrink-0 items-center justify-center">
702
- <Shield className="h-4 w-4 text-primary-foreground/80" />
703
- </span>
704
- <h1 className="min-w-0 flex-1 truncate px-1 text-base font-bold text-primary-foreground sm:text-lg">
705
- Quản tài nguyên
706
- </h1>
707
- <span className="hidden shrink-0 text-xs tabular-nums text-primary-foreground/70 md:inline">
708
- {resources.length} tài nguyên · {totalGroups} nhóm ·{" "}
709
- {allActions.length} hành động
710
- </span>
608
+ <RbacPageBar
609
+ icon={<Shield className="h-4 w-4 text-primary-foreground/80" />}
610
+ title="Quản tài nguyên"
611
+ subtitle={
612
+ <>
613
+ {resources.length} tài nguyên · {totalGroups} nhóm ·{" "}
614
+ {allActions.length} hành động
615
+ </>
616
+ }
617
+ >
711
618
  <Button
712
619
  variant="ghost"
713
620
  size="icon"
@@ -753,7 +660,7 @@ export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {
753
660
  </Button>
754
661
  </>
755
662
  )}
756
- </div>
663
+ </RbacPageBar>
757
664
 
758
665
  {readOnly && (
759
666
  <div className="mt-3 flex items-start gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground lg:mx-3">
@@ -774,73 +681,17 @@ export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {
774
681
  {/* ===== Khối trắng liền: toolbar sticky + nhóm + hàng tài nguyên ===== */}
775
682
  <div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
776
683
  {/* Toolbar sticky */}
777
- <div className="sticky top-0 z-20 flex flex-wrap items-center gap-2 border-b border-border bg-card/95 px-3 py-2 backdrop-blur supports-[backdrop-filter]:bg-card/85 sm:px-4">
778
- <div className="relative min-w-[140px] flex-1 sm:max-w-80">
779
- <Search className="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
780
- <Input
781
- placeholder="Tìm tài nguyên hoặc hành động…"
782
- className="h-8 w-full rounded-md border border-border bg-card pl-8 pr-8 text-sm"
783
- value={searchTerm}
784
- onChange={(e) => setSearchTerm(e.target.value)}
785
- />
786
- {searchTerm && (
787
- <button
788
- type="button"
789
- onClick={() => setSearchTerm("")}
790
- className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
791
- title="Xóa tìm kiếm"
792
- >
793
- <X className="h-3.5 w-3.5" />
794
- </button>
795
- )}
796
- </div>
797
- <p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
798
- {isSearching && !isEmpty ? (
799
- <>
800
- <span className="font-semibold tabular-nums text-foreground">
801
- {filteredResourceTotal}
802
- </span>{" "}
803
- khớp
804
- </>
805
- ) : (
806
- <>
807
- <span className="font-semibold tabular-nums text-foreground">
808
- {resources.length}
809
- </span>{" "}
810
- tài nguyên
811
- </>
812
- )}
813
- </p>
814
- {/* Chuyển dạng hiển thị: dòng / thẻ */}
815
- <div className="flex shrink-0 overflow-hidden rounded-md border border-border">
816
- <button
817
- type="button"
818
- onClick={() => setView("list")}
819
- title="Dạng dòng"
820
- className={cn(
821
- "px-2 py-1.5 transition-colors",
822
- view === "list"
823
- ? "bg-muted text-foreground"
824
- : "bg-card text-muted-foreground hover:text-foreground"
825
- )}
826
- >
827
- <List className="h-3.5 w-3.5" />
828
- </button>
829
- <button
830
- type="button"
831
- onClick={() => setView("grid")}
832
- title="Dạng thẻ"
833
- className={cn(
834
- "border-l border-border px-2 py-1.5 transition-colors",
835
- view === "grid"
836
- ? "bg-muted text-foreground"
837
- : "bg-card text-muted-foreground hover:text-foreground"
838
- )}
839
- >
840
- <LayoutGrid className="h-3.5 w-3.5" />
841
- </button>
842
- </div>
843
- </div>
684
+ <RbacStickyToolbar
685
+ searchTerm={searchTerm}
686
+ onSearchChange={setSearchTerm}
687
+ searchPlaceholder="Tìm tài nguyên hoặc hành động…"
688
+ searchMaxWidthClass="sm:max-w-80"
689
+ count={resources.length}
690
+ countLabel="tài nguyên"
691
+ matchedCount={isEmpty ? undefined : filteredResourceTotal}
692
+ view={view}
693
+ onViewChange={setView}
694
+ />
844
695
 
845
696
  {/* --- Body --- */}
846
697
  {loading && !isRefreshing ? (
@@ -857,8 +708,8 @@ export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {
857
708
  />
858
709
  ) : (
859
710
  sections.map((section) => {
860
- const collapsed = collapsedGroups.has(section.group)
861
- const GroupIcon = section.meta.icon
711
+ const collapsed = collapsedGroups.has(section.group);
712
+ const GroupIcon = section.meta.icon;
862
713
  return (
863
714
  <section key={section.group}>
864
715
  {/* Dòng tiêu đề nhóm — mảnh, trong cùng khối */}
@@ -875,7 +726,7 @@ export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {
875
726
  <span
876
727
  className={cn(
877
728
  "flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
878
- section.meta.iconCls
729
+ section.meta.iconCls,
879
730
  )}
880
731
  >
881
732
  <GroupIcon className="h-3.5 w-3.5" />
@@ -928,7 +779,7 @@ export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {
928
779
  </div>
929
780
  ))}
930
781
  </section>
931
- )
782
+ );
932
783
  })
933
784
  )}
934
785
  </div>
@@ -1195,7 +1046,7 @@ export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {
1195
1046
  loading={isDeleting}
1196
1047
  />
1197
1048
  </div>
1198
- )
1049
+ );
1199
1050
  }
1200
1051
 
1201
1052
  // --- Editor quyền của 1 tài nguyên (dùng chung cho dòng bung + thẻ bung):
@@ -1207,17 +1058,17 @@ function ResourceActionsEditor({
1207
1058
  onToggleAction,
1208
1059
  onUpdateActions,
1209
1060
  }: {
1210
- resource: Resource
1211
- allActions: Action[]
1212
- categorized: { basic: Action[]; functional: Action[] }
1213
- onToggleAction: (resource: Resource, actionCode: string) => void
1214
- onUpdateActions: (resource: Resource, actions: string[]) => void
1061
+ resource: Resource;
1062
+ allActions: Action[];
1063
+ categorized: { basic: Action[]; functional: Action[] };
1064
+ onToggleAction: (resource: Resource, actionCode: string) => void;
1065
+ onUpdateActions: (resource: Resource, actions: string[]) => void;
1215
1066
  }) {
1216
- const granted = resource.actions || []
1217
- const grantedSet = new Set(granted)
1067
+ const granted = resource.actions || [];
1068
+ const grantedSet = new Set(granted);
1218
1069
 
1219
1070
  const renderItem = (action: Action) => {
1220
- const on = grantedSet.has(action.code)
1071
+ const on = grantedSet.has(action.code);
1221
1072
  return (
1222
1073
  <button
1223
1074
  key={action.code}
@@ -1230,7 +1081,7 @@ function ResourceActionsEditor({
1230
1081
  "mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border",
1231
1082
  on
1232
1083
  ? ACTION_TONES[getActionTone(action.code)]
1233
- : "border-border bg-transparent text-transparent"
1084
+ : "border-border bg-transparent text-transparent",
1234
1085
  )}
1235
1086
  >
1236
1087
  <Check className="h-3 w-3" />
@@ -1239,7 +1090,7 @@ function ResourceActionsEditor({
1239
1090
  <span
1240
1091
  className={cn(
1241
1092
  "block text-xs leading-tight",
1242
- on ? "font-medium text-foreground" : "text-muted-foreground"
1093
+ on ? "font-medium text-foreground" : "text-muted-foreground",
1243
1094
  )}
1244
1095
  >
1245
1096
  {action.name}
@@ -1251,13 +1102,13 @@ function ResourceActionsEditor({
1251
1102
  )}
1252
1103
  </span>
1253
1104
  </button>
1254
- )
1255
- }
1105
+ );
1106
+ };
1256
1107
 
1257
1108
  const renderCategory = (label: string, list: Action[]) => {
1258
- if (list.length === 0) return null
1259
- const grantedList = list.filter((a) => grantedSet.has(a.code))
1260
- const ungrantedList = list.filter((a) => !grantedSet.has(a.code))
1109
+ if (list.length === 0) return null;
1110
+ const grantedList = list.filter((a) => grantedSet.has(a.code));
1111
+ const ungrantedList = list.filter((a) => !grantedSet.has(a.code));
1261
1112
  return (
1262
1113
  <div key={label}>
1263
1114
  <p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-foreground/70">
@@ -1286,8 +1137,8 @@ function ResourceActionsEditor({
1286
1137
  )}
1287
1138
  </div>
1288
1139
  </div>
1289
- )
1290
- }
1140
+ );
1141
+ };
1291
1142
 
1292
1143
  return (
1293
1144
  <div>
@@ -1305,7 +1156,7 @@ function ResourceActionsEditor({
1305
1156
  onClick={() =>
1306
1157
  onUpdateActions(
1307
1158
  resource,
1308
- allActions.map((a) => a.code)
1159
+ allActions.map((a) => a.code),
1309
1160
  )
1310
1161
  }
1311
1162
  disabled={granted.length === allActions.length}
@@ -1335,11 +1186,11 @@ function ResourceActionsEditor({
1335
1186
  </div>
1336
1187
  )}
1337
1188
  </div>
1338
- )
1189
+ );
1339
1190
  }
1340
1191
 
1341
1192
  // --- Resource row (chế độ dòng): tóm tắt quyền; bấm để bung editor ---
1342
- const MAX_CHIPS = 8
1193
+ const MAX_CHIPS = 8;
1343
1194
 
1344
1195
  function ResourceRow({
1345
1196
  resource,
@@ -1352,19 +1203,19 @@ function ResourceRow({
1352
1203
  onUpdateActions,
1353
1204
  onEditResource,
1354
1205
  }: {
1355
- resource: Resource
1356
- actionMap: Map<string, Action>
1357
- allActions: Action[]
1358
- categorized: { basic: Action[]; functional: Action[] }
1359
- expanded: boolean
1360
- onToggleExpand: () => void
1361
- onToggleAction: (resource: Resource, actionCode: string) => void
1362
- onUpdateActions: (resource: Resource, actions: string[]) => void
1363
- onEditResource: (resource: Resource) => void
1206
+ resource: Resource;
1207
+ actionMap: Map<string, Action>;
1208
+ allActions: Action[];
1209
+ categorized: { basic: Action[]; functional: Action[] };
1210
+ expanded: boolean;
1211
+ onToggleExpand: () => void;
1212
+ onToggleAction: (resource: Resource, actionCode: string) => void;
1213
+ onUpdateActions: (resource: Resource, actions: string[]) => void;
1214
+ onEditResource: (resource: Resource) => void;
1364
1215
  }) {
1365
- const granted = resource.actions || []
1366
- const shown = granted.slice(0, MAX_CHIPS)
1367
- const overflow = granted.length - shown.length
1216
+ const granted = resource.actions || [];
1217
+ const shown = granted.slice(0, MAX_CHIPS);
1218
+ const overflow = granted.length - shown.length;
1368
1219
 
1369
1220
  return (
1370
1221
  <li>
@@ -1375,8 +1226,8 @@ function ResourceRow({
1375
1226
  onClick={onToggleExpand}
1376
1227
  onKeyDown={(e) => {
1377
1228
  if (e.key === "Enter" || e.key === " ") {
1378
- e.preventDefault()
1379
- onToggleExpand()
1229
+ e.preventDefault();
1230
+ onToggleExpand();
1380
1231
  }
1381
1232
  }}
1382
1233
  className="flex cursor-pointer items-center gap-3 bg-card px-4 py-3 transition-colors hover:bg-muted/40"
@@ -1385,7 +1236,7 @@ function ResourceRow({
1385
1236
  <div
1386
1237
  className={cn(
1387
1238
  "flex h-9 w-9 shrink-0 items-center justify-center rounded-md",
1388
- getGroupMeta(resource.group).iconCls
1239
+ getGroupMeta(resource.group).iconCls,
1389
1240
  )}
1390
1241
  >
1391
1242
  <DynamicIcon
@@ -1420,7 +1271,7 @@ function ResourceRow({
1420
1271
  key={code}
1421
1272
  className={cn(
1422
1273
  "inline-flex items-center rounded border px-1.5 py-0.5 text-[11px]",
1423
- ACTION_TONES[getActionTone(code)]
1274
+ ACTION_TONES[getActionTone(code)],
1424
1275
  )}
1425
1276
  >
1426
1277
  {actionMap.get(code)?.name || code}
@@ -1444,8 +1295,8 @@ function ResourceRow({
1444
1295
  <button
1445
1296
  type="button"
1446
1297
  onClick={(e) => {
1447
- e.stopPropagation()
1448
- onEditResource(resource)
1298
+ e.stopPropagation();
1299
+ onEditResource(resource);
1449
1300
  }}
1450
1301
  className="shrink-0 rounded-md border border-border bg-card p-1.5 text-amber-500 shadow-sm transition-colors hover:border-amber-300 hover:bg-amber-50 hover:text-amber-600 dark:hover:border-amber-500/40 dark:hover:bg-amber-500/10"
1451
1302
  title="Sửa thông tin tài nguyên"
@@ -1455,7 +1306,7 @@ function ResourceRow({
1455
1306
  <ChevronRight
1456
1307
  className={cn(
1457
1308
  "h-4 w-4 shrink-0 text-muted-foreground/60 transition-transform",
1458
- expanded && "rotate-90"
1309
+ expanded && "rotate-90",
1459
1310
  )}
1460
1311
  />
1461
1312
  </div>
@@ -1473,13 +1324,13 @@ function ResourceRow({
1473
1324
  </div>
1474
1325
  )}
1475
1326
  </li>
1476
- )
1327
+ );
1477
1328
  }
1478
1329
 
1479
1330
  // --- Thẻ tài nguyên (grid, MẶC ĐỊNH) — phong cách thẻ vai trò/hành động:
1480
1331
  // icon nhóm màu + tên/mã + #stt, chip quyền preview, KHỐI SỐ LIỆU đóng khung,
1481
1332
  // hàng nút Sửa/Xóa. Bấm thẻ bung editor quyền (thẻ giãn full hàng). ---
1482
- const CARD_MAX_CHIPS = 6
1333
+ const CARD_MAX_CHIPS = 6;
1483
1334
 
1484
1335
  function ResourceCard({
1485
1336
  index,
@@ -1494,22 +1345,22 @@ function ResourceCard({
1494
1345
  onEditResource,
1495
1346
  onDeleteResource,
1496
1347
  }: {
1497
- index: number
1498
- resource: Resource
1499
- actionMap: Map<string, Action>
1500
- allActions: Action[]
1501
- categorized: { basic: Action[]; functional: Action[] }
1502
- expanded: boolean
1503
- onToggleExpand: () => void
1504
- onToggleAction: (resource: Resource, actionCode: string) => void
1505
- onUpdateActions: (resource: Resource, actions: string[]) => void
1506
- onEditResource: (resource: Resource) => void
1507
- onDeleteResource: (resource: Resource) => void
1348
+ index: number;
1349
+ resource: Resource;
1350
+ actionMap: Map<string, Action>;
1351
+ allActions: Action[];
1352
+ categorized: { basic: Action[]; functional: Action[] };
1353
+ expanded: boolean;
1354
+ onToggleExpand: () => void;
1355
+ onToggleAction: (resource: Resource, actionCode: string) => void;
1356
+ onUpdateActions: (resource: Resource, actions: string[]) => void;
1357
+ onEditResource: (resource: Resource) => void;
1358
+ onDeleteResource: (resource: Resource) => void;
1508
1359
  }) {
1509
- const granted = resource.actions || []
1510
- const shown = granted.slice(0, CARD_MAX_CHIPS)
1511
- const overflow = granted.length - shown.length
1512
- const ungrantedCount = Math.max(allActions.length - granted.length, 0)
1360
+ const granted = resource.actions || [];
1361
+ const shown = granted.slice(0, CARD_MAX_CHIPS);
1362
+ const overflow = granted.length - shown.length;
1363
+ const ungrantedCount = Math.max(allActions.length - granted.length, 0);
1513
1364
 
1514
1365
  return (
1515
1366
  <div
@@ -1519,13 +1370,13 @@ function ResourceCard({
1519
1370
  onClick={onToggleExpand}
1520
1371
  onKeyDown={(e) => {
1521
1372
  if (e.key === "Enter" || e.key === " ") {
1522
- e.preventDefault()
1523
- onToggleExpand()
1373
+ e.preventDefault();
1374
+ onToggleExpand();
1524
1375
  }
1525
1376
  }}
1526
1377
  className={cn(
1527
1378
  "cursor-pointer overflow-hidden rounded-[12px] border border-border bg-card p-4 shadow-sm transition-all hover:border-primary/40 hover:shadow-md",
1528
- expanded && "col-span-full border-primary/40"
1379
+ expanded && "col-span-full border-primary/40",
1529
1380
  )}
1530
1381
  >
1531
1382
  {/* Header: icon nhóm màu + tên/mã | #stt */}
@@ -1534,7 +1385,7 @@ function ResourceCard({
1534
1385
  <div
1535
1386
  className={cn(
1536
1387
  "flex h-8 w-8 shrink-0 items-center justify-center rounded-lg",
1537
- getGroupMeta(resource.group).iconCls
1388
+ getGroupMeta(resource.group).iconCls,
1538
1389
  )}
1539
1390
  >
1540
1391
  <DynamicIcon
@@ -1569,7 +1420,7 @@ function ResourceCard({
1569
1420
  key={code}
1570
1421
  className={cn(
1571
1422
  "inline-flex items-center rounded border px-1.5 py-0.5 text-[11px]",
1572
- ACTION_TONES[getActionTone(code)]
1423
+ ACTION_TONES[getActionTone(code)],
1573
1424
  )}
1574
1425
  >
1575
1426
  {actionMap.get(code)?.name || code}
@@ -1611,8 +1462,8 @@ function ResourceCard({
1611
1462
  size="sm"
1612
1463
  className="h-8 flex-1 rounded-[8px] bg-card text-[12px] font-medium shadow-sm transition-colors hover:border-amber-300 hover:bg-amber-50 dark:hover:border-amber-500/40 dark:hover:bg-amber-500/10"
1613
1464
  onClick={(e) => {
1614
- e.stopPropagation()
1615
- onEditResource(resource)
1465
+ e.stopPropagation();
1466
+ onEditResource(resource);
1616
1467
  }}
1617
1468
  >
1618
1469
  <Pencil className="mr-1.5 h-3.5 w-3.5 text-amber-500" />
@@ -1623,8 +1474,8 @@ function ResourceCard({
1623
1474
  size="sm"
1624
1475
  className="h-8 flex-1 rounded-[8px] bg-card text-[12px] font-medium text-destructive shadow-sm transition-colors hover:bg-destructive/10 hover:text-destructive"
1625
1476
  onClick={(e) => {
1626
- e.stopPropagation()
1627
- onDeleteResource(resource)
1477
+ e.stopPropagation();
1478
+ onDeleteResource(resource);
1628
1479
  }}
1629
1480
  >
1630
1481
  <Trash2 className="mr-1.5 h-3.5 w-3.5" />
@@ -1648,7 +1499,7 @@ function ResourceCard({
1648
1499
  </div>
1649
1500
  )}
1650
1501
  </div>
1651
- )
1502
+ );
1652
1503
  }
1653
1504
 
1654
1505
  // --- Empty state ---
@@ -1657,9 +1508,9 @@ function EmptyState({
1657
1508
  onClear,
1658
1509
  onCreate,
1659
1510
  }: {
1660
- searching: boolean
1661
- onClear: () => void
1662
- onCreate: () => void
1511
+ searching: boolean;
1512
+ onClear: () => void;
1513
+ onCreate: () => void;
1663
1514
  }) {
1664
1515
  return (
1665
1516
  <div className="flex flex-col items-center justify-center py-20 text-center">
@@ -1689,5 +1540,5 @@ function EmptyState({
1689
1540
  </Button>
1690
1541
  )}
1691
1542
  </div>
1692
- )
1543
+ );
1693
1544
  }