@goplusvn/core 0.1.25 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,33 @@
1
1
  "use client";
2
2
 
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 hover).
6
- //
3
+ // Trang "Quản lý tài nguyên" — bar navy 1 dòng (số liệu + tiện ích + Thêm)
4
+ // + MỘT khối trắng liền: toolbar sticky, nhóm = dòng mảnh, GRID thẻ kiểu thẻ
5
+ // vai trò MẶC ĐỊNH (toggle List/Grid). Bấm thẻ bung editor quyền
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
-
9
- import { useEffect, useMemo, useState } from "react";
10
- import { toast } from "sonner";
8
+ import { useEffect, useMemo, useState } from "react"
9
+ import {
10
+ Badge,
11
+ Button,
12
+ ConfirmDialog,
13
+ Dialog,
14
+ DialogContent,
15
+ DialogFooter,
16
+ DialogHeader,
17
+ DialogTitle,
18
+ DynamicIcon,
19
+ Input,
20
+ Label,
21
+ ScrollArea,
22
+ Select,
23
+ SelectContent,
24
+ SelectItem,
25
+ SelectTrigger,
26
+ SelectValue,
27
+ Skeleton,
28
+ Textarea,
29
+ } from "../../ui"
30
+ import { toast } from "sonner"
11
31
  import {
12
32
  Box,
13
33
  Boxes,
@@ -17,7 +37,8 @@ import {
17
37
  ChevronRight,
18
38
  ClipboardList,
19
39
  DatabaseBackup,
20
- Layers,
40
+ LayoutGrid,
41
+ List,
21
42
  Pencil,
22
43
  Plus,
23
44
  RefreshCw,
@@ -29,58 +50,34 @@ import {
29
50
  Truck,
30
51
  Users,
31
52
  Wallet,
53
+ X,
32
54
  Zap,
33
- } from "lucide-react";
55
+ } from "lucide-react"
56
+
57
+ import { errorMessage, throwFetchError } from "../lib/fetch-error"
58
+ import { cn } from "../../utils"
34
59
 
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
60
 
62
61
  // --- Types ---
63
62
  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;
63
+ id: string
64
+ code: string
65
+ name: string
66
+ group: string
67
+ description?: string
68
+ icon?: string
69
+ actions: string[]
70
+ config?: any
72
71
  }
73
72
 
74
73
  interface Action {
75
- id: string;
76
- code: string;
77
- name: string;
78
- description?: string;
74
+ id: string
75
+ code: string
76
+ name: string
77
+ description?: string
79
78
  }
80
79
 
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. ---
80
+ // --- Group metadata: one muted hue per group ---
84
81
  const GROUP_META: Record<
85
82
  string,
86
83
  { icon: any; label: string; iconCls: string }
@@ -88,8 +85,7 @@ const GROUP_META: Record<
88
85
  sales: {
89
86
  icon: ShoppingBag,
90
87
  label: "Bán hàng",
91
- iconCls:
92
- "text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/15",
88
+ iconCls: "text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/15",
93
89
  },
94
90
  purchase: {
95
91
  icon: ClipboardList,
@@ -112,14 +108,12 @@ const GROUP_META: Record<
112
108
  hr: {
113
109
  icon: Users,
114
110
  label: "Nhân sự",
115
- iconCls:
116
- "text-rose-600 bg-rose-100 dark:text-rose-400 dark:bg-rose-500/15",
111
+ iconCls: "text-rose-600 bg-rose-100 dark:text-rose-400 dark:bg-rose-500/15",
117
112
  },
118
113
  master: {
119
114
  icon: Boxes,
120
115
  label: "Danh mục",
121
- iconCls:
122
- "text-cyan-600 bg-cyan-100 dark:text-cyan-400 dark:bg-cyan-500/15",
116
+ iconCls: "text-cyan-600 bg-cyan-100 dark:text-cyan-400 dark:bg-cyan-500/15",
123
117
  },
124
118
  users: {
125
119
  icon: Users,
@@ -139,7 +133,7 @@ const GROUP_META: Record<
139
133
  iconCls:
140
134
  "text-slate-600 bg-slate-200 dark:text-slate-300 dark:bg-slate-500/20",
141
135
  },
142
- };
136
+ }
143
137
 
144
138
  const GROUP_ORDER = [
145
139
  "sales",
@@ -151,7 +145,7 @@ const GROUP_ORDER = [
151
145
  "users",
152
146
  "system",
153
147
  "other",
154
- ];
148
+ ]
155
149
 
156
150
  const getGroupMeta = (group: string) =>
157
151
  GROUP_META[group?.toLowerCase()] || {
@@ -159,11 +153,9 @@ const getGroupMeta = (group: string) =>
159
153
  label: group || "Khác",
160
154
  iconCls:
161
155
  "text-slate-600 bg-slate-200 dark:text-slate-300 dark:bg-slate-500/20",
162
- };
156
+ }
163
157
 
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. ---
158
+ // --- Action chips colored by permission category ---
167
159
  const ACTION_TONES: Record<string, string> = {
168
160
  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
161
  create:
@@ -175,19 +167,18 @@ const ACTION_TONES: Record<string, string> = {
175
167
  approve:
176
168
  "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300",
177
169
  neutral: "border-border bg-muted text-foreground/80",
178
- };
170
+ }
179
171
 
180
172
  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).
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
+ }
180
+
181
+ // 4 hành động CRUD chuẩn → nhóm "Cơ bản"; còn lại vào "Chức năng" (AZ).
191
182
  const BASIC_ORDER = [
192
183
  "view",
193
184
  "read",
@@ -197,21 +188,21 @@ const BASIC_ORDER = [
197
188
  "edit",
198
189
  "delete",
199
190
  "remove",
200
- ];
191
+ ]
201
192
 
202
193
  const categorizeActions = (actions: Action[]) => {
203
- const basic: Action[] = [];
204
- const functional: Action[] = [];
194
+ const basic: Action[] = []
195
+ const functional: Action[] = []
205
196
  for (const a of actions) {
206
- if (BASIC_ORDER.includes(a.code)) basic.push(a);
207
- else functional.push(a);
197
+ if (BASIC_ORDER.includes(a.code)) basic.push(a)
198
+ else functional.push(a)
208
199
  }
209
200
  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
- };
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
+ }
215
206
 
216
207
  // Diacritic-insensitive normalize (so "duyet" matches "Duyệt")
217
208
  const normalize = (s: string) =>
@@ -219,247 +210,236 @@ const normalize = (s: string) =>
219
210
  .toLowerCase()
220
211
  .normalize("NFD")
221
212
  .replace(/[\u0300-\u036f]/g, "")
222
- .replace(/đ/g, "d");
213
+ .replace(/đ/g, "d")
223
214
 
224
215
  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());
216
+ const [loading, setLoading] = useState(true)
217
+ const [resources, setResources] = useState<Resource[]>([])
218
+ const [allActions, setAllActions] = useState<Action[]>([])
219
+ const [searchTerm, setSearchTerm] = useState("")
220
+ const [isRefreshing, setIsRefreshing] = useState(false)
221
+ const [isSyncing, setIsSyncing] = useState(false)
222
+ const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
223
+ const [view, setView] = useState<"list" | "grid">("grid")
224
+
225
+ // Hàng nào đang bung để xem/sửa toàn bộ action
226
+ const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
237
227
 
238
228
  // Resource Dialog State
239
- const [isDialogOpen, setIsDialogOpen] = useState(false);
240
- const [editingResource, setEditingResource] = useState<Resource | null>(
241
- null,
242
- );
229
+ const [isDialogOpen, setIsDialogOpen] = useState(false)
230
+ const [editingResource, setEditingResource] = useState<Resource | null>(null)
243
231
  const [formData, setFormData] = useState({
244
232
  code: "",
245
233
  name: "",
246
234
  group: "",
247
235
  description: "",
248
- });
236
+ })
249
237
 
250
238
  // Action Manager State
251
- const [isActionManagerOpen, setIsActionManagerOpen] = useState(false);
239
+ const [isActionManagerOpen, setIsActionManagerOpen] = useState(false)
252
240
  const [actionFormMode, setActionFormMode] = useState<
253
241
  "list" | "create" | "edit"
254
- >("list");
255
- const [editingAction, setEditingAction] = useState<Action | null>(null);
242
+ >("list")
243
+ const [editingAction, setEditingAction] = useState<Action | null>(null)
256
244
  const [actionFormData, setActionFormData] = useState({
257
245
  code: "",
258
246
  name: "",
259
247
  description: "",
260
- });
248
+ })
261
249
 
262
250
  // Confirmation state
263
- const [confirmOpen, setConfirmOpen] = useState(false);
251
+ const [confirmOpen, setConfirmOpen] = useState(false)
264
252
  const [confirmType, setConfirmType] = useState<"action" | "resource" | null>(
265
- null,
266
- );
253
+ null
254
+ )
267
255
  const [itemToDelete, setItemToDelete] = useState<{
268
- id: string;
269
- name: string;
270
- } | null>(null);
271
- const [isDeleting, setIsDeleting] = useState(false);
256
+ id: string
257
+ name: string
258
+ } | null>(null)
259
+ const [isDeleting, setIsDeleting] = useState(false)
272
260
 
273
261
  // --- Data Fetching ---
274
262
  const fetchData = async () => {
275
263
  try {
276
- setLoading(true);
264
+ setLoading(true)
277
265
  const [resResources, resActions] = await Promise.all([
278
266
  fetch("/api/resources?pageSize=1000"),
279
267
  fetch("/api/actions?pageSize=1000"),
280
- ]);
268
+ ])
281
269
 
282
270
  if (!resResources.ok || !resActions.ok)
283
- throw new Error("Failed to fetch data");
271
+ throw new Error("Failed to fetch data")
284
272
 
285
- const resourcesData = await resResources.json();
286
- const actionsData = await resActions.json();
273
+ const resourcesData = await resResources.json()
274
+ const actionsData = await resActions.json()
287
275
 
288
276
  const mappedResources = (resourcesData.items || []).map((r: any) => {
289
- let actions: string[] = [];
277
+ let actions: string[] = []
290
278
  try {
291
279
  if (r.config) {
292
280
  const configObj =
293
- typeof r.config === "string" ? JSON.parse(r.config) : r.config;
281
+ typeof r.config === "string" ? JSON.parse(r.config) : r.config
294
282
  if (configObj.actions && Array.isArray(configObj.actions)) {
295
- actions = configObj.actions;
283
+ actions = configObj.actions
296
284
  }
297
285
  }
298
286
  } catch (_catchError) {
299
287
  // Ignore invalid config JSON
300
288
  }
301
- return { ...r, actions };
302
- });
289
+ return { ...r, actions }
290
+ })
303
291
 
304
- setResources(mappedResources);
305
- setAllActions(actionsData.items || []);
292
+ setResources(mappedResources)
293
+ setAllActions(actionsData.items || [])
306
294
  } catch (error) {
307
- toast.error(errorMessage(error, "Lỗi tải dữ liệu"));
295
+ toast.error(errorMessage(error, "Lỗi tải dữ liệu"))
308
296
  } finally {
309
- setLoading(false);
310
- setIsRefreshing(false);
297
+ setLoading(false)
298
+ setIsRefreshing(false)
311
299
  }
312
- };
300
+ }
313
301
 
314
302
  useEffect(() => {
315
- fetchData();
316
- // eslint-disable-next-line react-hooks/exhaustive-deps
317
- }, []);
303
+ fetchData()
304
+ }, [])
318
305
 
319
306
  const handleRefresh = () => {
320
- setIsRefreshing(true);
321
- fetchData();
322
- };
307
+ setIsRefreshing(true)
308
+ fetchData()
309
+ }
323
310
 
324
311
  // --- Sync Resource ---
325
312
  const handleSync = async () => {
326
313
  try {
327
- setIsSyncing(true);
314
+ setIsSyncing(true)
328
315
  const res = await fetch("/api/resources/sync", {
329
316
  method: "POST",
330
- });
317
+ })
331
318
 
332
319
  if (!res.ok) {
333
- await throwFetchError(res, "Lỗi khi đồng bộ tài nguyên");
320
+ await throwFetchError(res, "Lỗi khi đồng bộ tài nguyên")
334
321
  }
335
322
 
336
- const data = await res.json();
337
- toast.success(data.message || "Đồng bộ tài nguyên thành công");
323
+ const data = await res.json()
324
+ toast.success(data.message || "Đồng bộ tài nguyên thành công")
338
325
 
339
- // Auto refresh data
340
- handleRefresh();
326
+ handleRefresh()
341
327
  } catch (error: any) {
342
- toast.error(errorMessage(error, "Không thể đồng bộ tài nguyên"));
328
+ toast.error(errorMessage(error, "Không thể đồng bộ tài nguyên"))
343
329
  } finally {
344
- setIsSyncing(false);
330
+ setIsSyncing(false)
345
331
  }
346
- };
332
+ }
347
333
 
348
- // Quick lookup: action code -> action (for rendering granted chips by name)
334
+ // Quick lookup: action code -> action
349
335
  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]);
336
+ const map = new Map<string, Action>()
337
+ for (const a of allActions) map.set(a.code, a)
338
+ return map
339
+ }, [allActions])
354
340
 
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
341
  const categorizedActions = useMemo(
358
342
  () => categorizeActions(allActions),
359
- [allActions],
360
- );
343
+ [allActions]
344
+ )
361
345
 
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. ---
346
+ // --- Search: match resource text OR granted action name ---
365
347
  const { sections, filteredResourceTotal, isEmpty } = useMemo(() => {
366
- const q = normalize(searchTerm.trim());
348
+ const q = normalize(searchTerm.trim())
367
349
 
368
350
  const matches = (r: Resource) => {
369
- if (!q) return true;
351
+ if (!q) return true
370
352
  if (
371
353
  normalize(r.name).includes(q) ||
372
354
  normalize(r.code).includes(q) ||
373
355
  normalize(r.group || "").includes(q) ||
374
356
  normalize(getGroupMeta(r.group).label).includes(q)
375
357
  )
376
- return true;
377
- // match on a granted action's name/code
358
+ return true
378
359
  return (r.actions || []).some((code) => {
379
- const a = actionMap.get(code);
360
+ const a = actionMap.get(code)
380
361
  return (
381
362
  normalize(a?.name || code).includes(q) || normalize(code).includes(q)
382
- );
383
- });
384
- };
363
+ )
364
+ })
365
+ }
385
366
 
386
- const rows = resources.filter(matches);
367
+ const rows = resources.filter(matches)
387
368
 
388
369
  if (q && rows.length === 0) {
389
370
  return {
390
371
  sections: [] as {
391
- group: string;
392
- meta: { icon: any; label: string; iconCls: string };
393
- items: Resource[];
372
+ group: string
373
+ meta: { icon: any; label: string; iconCls: string }
374
+ items: Resource[]
394
375
  }[],
395
376
  filteredResourceTotal: 0,
396
377
  isEmpty: true,
397
- };
378
+ }
398
379
  }
399
380
 
400
- const grouped: Record<string, Resource[]> = {};
381
+ const grouped: Record<string, Resource[]> = {}
401
382
  for (const r of rows) {
402
- const g = (r.group || "other").toLowerCase();
403
- (grouped[g] ||= []).push(r);
383
+ const g = (r.group || "other").toLowerCase()
384
+ ;(grouped[g] ||= []).push(r)
404
385
  }
405
386
 
406
387
  const orderedSections = Object.keys(grouped)
407
388
  .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);
389
+ const ia = GROUP_ORDER.indexOf(a)
390
+ const ib = GROUP_ORDER.indexOf(b)
391
+ if (ia !== -1 && ib !== -1) return ia - ib
392
+ if (ia !== -1) return -1
393
+ if (ib !== -1) return 1
394
+ return a.localeCompare(b)
414
395
  })
415
396
  .map((group) => ({
416
397
  group,
417
398
  meta: getGroupMeta(group),
418
399
  items: grouped[group].sort((x, y) => x.name.localeCompare(y.name)),
419
- }));
400
+ }))
420
401
 
421
402
  return {
422
403
  sections: orderedSections,
423
404
  filteredResourceTotal: rows.length,
424
405
  isEmpty: false,
425
- };
426
- }, [resources, actionMap, searchTerm]);
406
+ }
407
+ }, [resources, actionMap, searchTerm])
427
408
 
428
409
  const toggleGroup = (group: string) => {
429
410
  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
- };
411
+ const next = new Set(prev)
412
+ if (next.has(group)) next.delete(group)
413
+ else next.add(group)
414
+ return next
415
+ })
416
+ }
436
417
 
437
418
  // --- Resource Actions ---
438
419
  const handleUpdateActions = async (
439
420
  resource: Resource,
440
- newActions: string[],
421
+ newActions: string[]
441
422
  ) => {
442
423
  try {
443
424
  // Optimistic Update
444
425
  setResources((prev) =>
445
426
  prev.map((r) =>
446
- r.id === resource.id ? { ...r, actions: newActions } : r,
447
- ),
448
- );
427
+ r.id === resource.id ? { ...r, actions: newActions } : r
428
+ )
429
+ )
449
430
 
450
- // API Call
451
- let currentConfig: any = {};
431
+ let currentConfig: any = {}
452
432
  try {
453
433
  if (typeof resource.config === "string") {
454
- currentConfig = JSON.parse(resource.config);
434
+ currentConfig = JSON.parse(resource.config)
455
435
  } else if (typeof resource.config === "object") {
456
- currentConfig = resource.config || {};
436
+ currentConfig = resource.config || {}
457
437
  }
458
438
  } catch (_catchError) {
459
439
  // Ignore invalid config JSON
460
440
  }
461
441
 
462
- const newConfig = { ...currentConfig, actions: newActions };
442
+ const newConfig = { ...currentConfig, actions: newActions }
463
443
 
464
444
  const res = await fetch(`/api/resources/${resource.id}`, {
465
445
  method: "PUT",
@@ -468,136 +448,131 @@ export function ResourceListPage() {
468
448
  ...resource,
469
449
  config: JSON.stringify(newConfig),
470
450
  }),
471
- });
451
+ })
472
452
 
473
453
  if (!res.ok) {
474
- await throwFetchError(res, "Update failed");
454
+ await throwFetchError(res, "Update failed")
475
455
  }
476
456
  } catch (error) {
477
- toast.error(errorMessage(error, "Không thể cập nhật quyền"));
457
+ toast.error(errorMessage(error, "Không thể cập nhật quyền"))
478
458
  }
479
- };
459
+ }
480
460
 
481
461
  const toggleAction = (resource: Resource, actionCode: string) => {
482
- const currentActions = resource.actions || [];
462
+ const currentActions = resource.actions || []
483
463
  const newActions = currentActions.includes(actionCode)
484
464
  ? currentActions.filter((a) => a !== actionCode)
485
- : [...currentActions, actionCode];
486
- handleUpdateActions(resource, newActions);
487
- };
465
+ : [...currentActions, actionCode]
466
+ handleUpdateActions(resource, newActions)
467
+ }
488
468
 
489
- // --- Bung hàng để xem/sửa toàn bộ action ngay tại chỗ ---
490
469
  const toggleRow = (id: string) => {
491
470
  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
- };
471
+ const next = new Set(prev)
472
+ if (next.has(id)) next.delete(id)
473
+ else next.add(id)
474
+ return next
475
+ })
476
+ }
498
477
 
499
478
  // --- Resource Dialog Handlers ---
500
479
  const openNewResource = () => {
501
- setEditingResource(null);
502
- setFormData({ code: "", name: "", group: "", description: "" });
503
- setIsDialogOpen(true);
504
- };
480
+ setEditingResource(null)
481
+ setFormData({ code: "", name: "", group: "", description: "" })
482
+ setIsDialogOpen(true)
483
+ }
505
484
 
506
485
  const openEditResource = (resource: Resource) => {
507
- setEditingResource(resource);
486
+ setEditingResource(resource)
508
487
  setFormData({
509
488
  code: resource.code,
510
489
  name: resource.name,
511
490
  group: resource.group || "",
512
491
  description: resource.description || "",
513
- });
514
- setIsDialogOpen(true);
515
- };
492
+ })
493
+ setIsDialogOpen(true)
494
+ }
516
495
 
517
496
  const handleSubmitResource = async () => {
518
497
  if (!formData.code || !formData.name) {
519
- toast.error("Vui lòng nhập Mã và Tên tài nguyên");
520
- return;
498
+ toast.error("Vui lòng nhập Mã và Tên tài nguyên")
499
+ return
521
500
  }
522
501
 
523
502
  try {
524
503
  const payload = {
525
504
  ...formData,
526
- status: "active", // default status
527
- };
505
+ status: "active",
506
+ }
528
507
 
529
- let res;
508
+ let res
530
509
  if (editingResource) {
531
- // Update
532
510
  res = await fetch(`/api/resources/${editingResource.id}`, {
533
511
  method: "PUT",
534
512
  headers: { "Content-Type": "application/json" },
535
513
  body: JSON.stringify({ id: editingResource.id, ...payload }),
536
- });
514
+ })
537
515
  } else {
538
- // Create
539
516
  res = await fetch("/api/resources", {
540
517
  method: "POST",
541
518
  headers: { "Content-Type": "application/json" },
542
519
  body: JSON.stringify(payload),
543
- });
520
+ })
544
521
  }
545
522
 
546
523
  if (!res.ok) {
547
- await throwFetchError(res, "Operation failed");
524
+ await throwFetchError(res, "Operation failed")
548
525
  }
549
526
 
550
- const savedResource = await res.json();
527
+ const savedResource = await res.json()
551
528
  toast.success(
552
- editingResource ? "Cập nhật thành công" : "Tạo mới thành công",
553
- );
529
+ editingResource ? "Cập nhật thành công" : "Tạo mới thành công"
530
+ )
554
531
 
555
- // Update local state
556
532
  if (editingResource) {
557
533
  setResources((prev) =>
558
534
  prev.map((r) =>
559
535
  r.id === savedResource.id
560
536
  ? { ...savedResource, actions: r.actions }
561
- : r,
562
- ),
563
- );
537
+ : r
538
+ )
539
+ )
564
540
  } else {
565
- // For new resource, actions starts empty
566
- setResources((prev) => [{ ...savedResource, actions: [] }, ...prev]);
541
+ setResources((prev) => [{ ...savedResource, actions: [] }, ...prev])
567
542
  }
568
543
 
569
- setIsDialogOpen(false);
544
+ setIsDialogOpen(false)
570
545
  } catch (error: any) {
571
- toast.error(errorMessage(error, "Có lỗi xảy ra"));
546
+ toast.error(errorMessage(error, "Có lỗi xảy ra"))
572
547
  }
573
- };
548
+ }
574
549
 
575
550
  // --- Action Manager Handlers ---
576
551
  const openActionManager = () => {
577
- setIsActionManagerOpen(true);
578
- setActionFormMode("list");
579
- };
552
+ setIsActionManagerOpen(true)
553
+ setActionFormMode("list")
554
+ }
580
555
 
581
556
  const openNewAction = () => {
582
- setEditingAction(null);
583
- setActionFormData({ code: "", name: "", description: "" });
584
- setActionFormMode("create");
585
- };
557
+ setEditingAction(null)
558
+ setActionFormData({ code: "", name: "", description: "" })
559
+ setActionFormMode("create")
560
+ }
586
561
 
587
562
  const openEditAction = (action: Action) => {
588
- setEditingAction(action);
563
+ setEditingAction(action)
589
564
  setActionFormData({
590
565
  code: action.code,
591
566
  name: action.name,
592
567
  description: action.description || "",
593
- });
594
- setActionFormMode("edit");
595
- };
568
+ })
569
+ setActionFormMode("edit")
570
+ }
596
571
 
597
572
  const handleSubmitAction = async () => {
598
573
  if (!actionFormData.code || !actionFormData.name) {
599
- toast.error("Vui lòng nhập Mã và Tên hành động");
600
- return;
574
+ toast.error("Vui lòng nhập Mã và Tên hành động")
575
+ return
601
576
  }
602
577
 
603
578
  try {
@@ -605,260 +580,325 @@ export function ResourceListPage() {
605
580
  ...actionFormData,
606
581
  status: "active",
607
582
  description: actionFormData.description,
608
- };
583
+ }
609
584
 
610
- let res;
585
+ let res
611
586
  if (actionFormMode === "edit" && editingAction) {
612
587
  res = await fetch("/api/actions", {
613
588
  method: "PUT",
614
589
  headers: { "Content-Type": "application/json" },
615
590
  body: JSON.stringify({ id: editingAction.id, ...payload }),
616
- });
591
+ })
617
592
  } else {
618
593
  res = await fetch("/api/actions", {
619
594
  method: "POST",
620
595
  headers: { "Content-Type": "application/json" },
621
596
  body: JSON.stringify(payload),
622
- });
597
+ })
623
598
  }
624
599
 
625
600
  if (!res.ok) {
626
- await throwFetchError(res, "Operation failed");
601
+ await throwFetchError(res, "Operation failed")
627
602
  }
628
603
 
629
- const savedAction = await res.json();
604
+ const savedAction = await res.json()
630
605
 
631
606
  if (actionFormMode === "edit") {
632
607
  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");
608
+ prev.map((a) => (a.id === savedAction.id ? savedAction : a))
609
+ )
610
+ toast.success("Cập nhật hành động thành công")
636
611
  } else {
637
- setAllActions((prev) => [savedAction, ...prev]);
638
- toast.success("Tạo hành động thành công");
612
+ setAllActions((prev) => [savedAction, ...prev])
613
+ toast.success("Tạo hành động thành công")
639
614
  }
640
- setActionFormMode("list");
615
+ setActionFormMode("list")
641
616
  } catch (error: any) {
642
- toast.error(errorMessage(error, "Có lỗi xảy ra"));
617
+ toast.error(errorMessage(error, "Có lỗi xảy ra"))
643
618
  }
644
- };
619
+ }
645
620
 
646
621
  const handleDeleteAction = (id: string, code: string) => {
647
- setItemToDelete({ id, name: code });
648
- setConfirmType("action");
649
- setConfirmOpen(true);
650
- };
622
+ setItemToDelete({ id, name: code })
623
+ setConfirmType("action")
624
+ setConfirmOpen(true)
625
+ }
651
626
 
652
627
  const handleDeleteResource = () => {
653
- if (!editingResource) return;
654
- setItemToDelete({ id: editingResource.id, name: editingResource.code });
655
- setConfirmType("resource");
656
- setConfirmOpen(true);
657
- };
628
+ if (!editingResource) return
629
+ setItemToDelete({ id: editingResource.id, name: editingResource.code })
630
+ setConfirmType("resource")
631
+ setConfirmOpen(true)
632
+ }
633
+
634
+ // Xóa trực tiếp từ thẻ (không cần mở dialog sửa)
635
+ const requestDeleteResource = (resource: Resource) => {
636
+ setItemToDelete({ id: resource.id, name: resource.code })
637
+ setConfirmType("resource")
638
+ setConfirmOpen(true)
639
+ }
658
640
 
659
641
  const handleConfirmDelete = async () => {
660
- if (!itemToDelete || !confirmType) return;
661
- setIsDeleting(true);
642
+ if (!itemToDelete || !confirmType) return
643
+ setIsDeleting(true)
662
644
  try {
663
645
  if (confirmType === "action") {
664
646
  const res = await fetch(`/api/actions?id=${itemToDelete.id}`, {
665
647
  method: "DELETE",
666
- });
648
+ })
667
649
  if (!res.ok) {
668
- await throwFetchError(res, "Delete failed");
650
+ await throwFetchError(res, "Delete failed")
669
651
  }
670
- setAllActions((prev) => prev.filter((a) => a.id !== itemToDelete.id));
671
- toast.success("Xóa hành động thành công");
652
+ setAllActions((prev) => prev.filter((a) => a.id !== itemToDelete.id))
653
+ toast.success("Xóa hành động thành công")
672
654
  } else if (confirmType === "resource") {
673
655
  const res = await fetch(`/api/resources?id=${itemToDelete.id}`, {
674
656
  method: "DELETE",
675
- });
657
+ })
676
658
  if (!res.ok) {
677
- await throwFetchError(res, "Delete failed");
659
+ await throwFetchError(res, "Delete failed")
678
660
  }
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);
661
+ setResources((prev) => prev.filter((r) => r.id !== itemToDelete.id))
662
+ toast.success("Xóa tài nguyên thành công")
663
+ setIsDialogOpen(false)
682
664
  }
683
- setConfirmOpen(false);
684
- setItemToDelete(null);
685
- setConfirmType(null);
665
+ setConfirmOpen(false)
666
+ setItemToDelete(null)
667
+ setConfirmType(null)
686
668
  } 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
- );
669
+ toast.error(errorMessage(error, `Không thể xóa ${
670
+ confirmType === "action" ? "hành động" : "tài nguyên"
671
+ }`))
693
672
  } finally {
694
- setIsDeleting(false);
673
+ setIsDeleting(false)
695
674
  }
696
- };
675
+ }
697
676
 
698
- const isSearching = searchTerm.trim().length > 0;
699
- const hasNoResources = !loading && resources.length === 0;
677
+ const isSearching = searchTerm.trim().length > 0
678
+ const hasNoResources = !loading && resources.length === 0
700
679
 
701
680
  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
- );
681
+ resources.map((r) => (r.group || "other").toLowerCase())
682
+ ).size
762
683
 
763
684
  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
- )}
685
+ <div className="flex flex-col">
686
+ {/* ===== Bar navy 1 dòng — số liệu + tiện ích + Thêm tài nguyên ===== */}
687
+ <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">
688
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center">
689
+ <Shield className="h-4 w-4 text-primary-foreground/80" />
690
+ </span>
691
+ <h1 className="min-w-0 flex-1 truncate px-1 text-base font-bold text-primary-foreground sm:text-lg">
692
+ Quản tài nguyên
693
+ </h1>
694
+ <span className="hidden shrink-0 text-xs tabular-nums text-primary-foreground/70 md:inline">
695
+ {resources.length} tài nguyên · {totalGroups} nhóm ·{" "}
696
+ {allActions.length} hành động
697
+ </span>
698
+ <Button
699
+ variant="ghost"
700
+ size="icon"
701
+ onClick={handleRefresh}
702
+ disabled={isRefreshing}
703
+ title="Làm mới"
704
+ className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
705
+ >
706
+ <RefreshCw
707
+ className={cn("h-4 w-4", isRefreshing && "animate-spin")}
708
+ />
709
+ </Button>
710
+ <Button
711
+ variant="ghost"
712
+ size="icon"
713
+ onClick={handleSync}
714
+ disabled={isSyncing}
715
+ title="Đồng bộ tài nguyên"
716
+ className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
717
+ >
718
+ <DatabaseBackup
719
+ className={cn("h-4 w-4", isSyncing && "animate-pulse")}
720
+ />
721
+ </Button>
722
+ <Button
723
+ variant="ghost"
724
+ size="icon"
725
+ onClick={openActionManager}
726
+ title="Quản lý hành động"
727
+ className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
728
+ >
729
+ <Zap className="h-4 w-4" />
730
+ </Button>
731
+ <Button
732
+ onClick={openNewResource}
733
+ className="h-8 shrink-0 bg-primary-foreground px-2.5 text-primary hover:bg-primary-foreground/90 sm:px-3"
734
+ >
735
+ <Plus className="h-4 w-4" />
736
+ <span className="ml-1.5 hidden sm:inline">Thêm tài nguyên</span>
737
+ <span className="ml-1.5 sm:hidden">Thêm</span>
738
+ </Button>
739
+ </div>
784
740
 
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
- ))}
741
+ {/* ===== Khối trắng liền: toolbar sticky + nhóm + hàng tài nguyên ===== */}
742
+ <div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
743
+ {/* Toolbar sticky */}
744
+ <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">
745
+ <div className="relative min-w-[140px] flex-1 sm:max-w-80">
746
+ <Search className="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
747
+ <Input
748
+ placeholder="Tìm tài nguyên hoặc hành động…"
749
+ className="h-8 w-full rounded-md border border-border bg-card pl-8 pr-8 text-sm"
750
+ value={searchTerm}
751
+ onChange={(e) => setSearchTerm(e.target.value)}
752
+ />
753
+ {searchTerm && (
754
+ <button
755
+ type="button"
756
+ onClick={() => setSearchTerm("")}
757
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
758
+ title="Xóa tìm kiếm"
759
+ >
760
+ <X className="h-3.5 w-3.5" />
761
+ </button>
762
+ )}
763
+ </div>
764
+ <p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
765
+ {isSearching && !isEmpty ? (
766
+ <>
767
+ <span className="font-semibold tabular-nums text-foreground">
768
+ {filteredResourceTotal}
769
+ </span>{" "}
770
+ khớp
771
+ </>
772
+ ) : (
773
+ <>
774
+ <span className="font-semibold tabular-nums text-foreground">
775
+ {resources.length}
776
+ </span>{" "}
777
+ tài nguyên
778
+ </>
779
+ )}
780
+ </p>
781
+ {/* Chuyển dạng hiển thị: dòng / thẻ */}
782
+ <div className="flex shrink-0 overflow-hidden rounded-md border border-border">
783
+ <button
784
+ type="button"
785
+ onClick={() => setView("list")}
786
+ title="Dạng dòng"
787
+ className={cn(
788
+ "px-2 py-1.5 transition-colors",
789
+ view === "list"
790
+ ? "bg-muted text-foreground"
791
+ : "bg-card text-muted-foreground hover:text-foreground"
792
+ )}
793
+ >
794
+ <List className="h-3.5 w-3.5" />
795
+ </button>
796
+ <button
797
+ type="button"
798
+ onClick={() => setView("grid")}
799
+ title="Dạng thẻ"
800
+ className={cn(
801
+ "border-l border-border px-2 py-1.5 transition-colors",
802
+ view === "grid"
803
+ ? "bg-muted text-foreground"
804
+ : "bg-card text-muted-foreground hover:text-foreground"
805
+ )}
806
+ >
807
+ <LayoutGrid className="h-3.5 w-3.5" />
808
+ </button>
809
+ </div>
791
810
  </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;
811
+
812
+ {/* --- Body --- */}
813
+ {loading && !isRefreshing ? (
814
+ <div className="space-y-2 p-4">
815
+ {Array.from({ length: 7 }).map((_, i) => (
816
+ <Skeleton key={i} className="h-14 w-full" />
817
+ ))}
818
+ </div>
819
+ ) : isEmpty || (hasNoResources && !isSearching) ? (
820
+ <EmptyState
821
+ searching={isSearching}
822
+ onClear={() => setSearchTerm("")}
823
+ onCreate={openNewResource}
824
+ />
825
+ ) : (
826
+ sections.map((section) => {
827
+ const collapsed = collapsedGroups.has(section.group)
828
+ const GroupIcon = section.meta.icon
803
829
  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 */}
830
+ <section key={section.group}>
831
+ {/* Dòng tiêu đề nhóm — mảnh, trong cùng khối */}
809
832
  <button
810
833
  type="button"
811
834
  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"
835
+ className="flex w-full items-center gap-2 border-b border-border bg-card px-4 py-2 text-left transition-colors hover:bg-muted/40"
813
836
  >
814
837
  {collapsed ? (
815
- <ChevronRight className="h-4 w-4 text-muted-foreground" />
838
+ <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
816
839
  ) : (
817
- <ChevronDown className="h-4 w-4 text-muted-foreground" />
840
+ <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
818
841
  )}
819
842
  <span
820
843
  className={cn(
821
- "flex h-6 w-6 items-center justify-center rounded-md",
822
- section.meta.iconCls,
844
+ "flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
845
+ section.meta.iconCls
823
846
  )}
824
847
  >
825
848
  <GroupIcon className="h-3.5 w-3.5" />
826
849
  </span>
827
- <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
850
+ <span className="truncate text-xs font-semibold uppercase tracking-wide text-foreground">
828
851
  {section.meta.label}
829
852
  </span>
830
- <Badge
831
- variant="secondary"
832
- className="ml-1 h-5 px-1.5 font-normal"
833
- >
834
- {section.items.length}
835
- </Badge>
853
+ <span className="whitespace-nowrap text-[11px] tabular-nums text-muted-foreground">
854
+ {section.items.length} tài nguyên
855
+ </span>
836
856
  </button>
837
857
 
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
- )}
858
+ {/* Resource rows / cards */}
859
+ {!collapsed &&
860
+ (view === "list" ? (
861
+ <ul className="divide-y divide-border/70 border-b border-border">
862
+ {section.items.map((resource) => (
863
+ <ResourceRow
864
+ key={resource.id}
865
+ resource={resource}
866
+ actionMap={actionMap}
867
+ allActions={allActions}
868
+ categorized={categorizedActions}
869
+ expanded={expandedRows.has(resource.id)}
870
+ onToggleExpand={() => toggleRow(resource.id)}
871
+ onToggleAction={toggleAction}
872
+ onUpdateActions={handleUpdateActions}
873
+ onEditResource={openEditResource}
874
+ />
875
+ ))}
876
+ </ul>
877
+ ) : (
878
+ <div className="grid grid-cols-1 gap-3 border-b border-border p-3 sm:grid-cols-2 xl:grid-cols-3">
879
+ {section.items.map((resource, index) => (
880
+ <ResourceCard
881
+ key={resource.id}
882
+ index={index}
883
+ resource={resource}
884
+ actionMap={actionMap}
885
+ allActions={allActions}
886
+ categorized={categorizedActions}
887
+ expanded={expandedRows.has(resource.id)}
888
+ onToggleExpand={() => toggleRow(resource.id)}
889
+ onToggleAction={toggleAction}
890
+ onUpdateActions={handleUpdateActions}
891
+ onEditResource={openEditResource}
892
+ onDeleteResource={requestDeleteResource}
893
+ />
894
+ ))}
895
+ </div>
896
+ ))}
897
+ </section>
898
+ )
899
+ })
900
+ )}
901
+ </div>
862
902
 
863
903
  {/* --- Create/Edit Resource Dialog --- */}
864
904
  <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
@@ -878,7 +918,7 @@ export function ResourceListPage() {
878
918
  setFormData({ ...formData, code: e.target.value })
879
919
  }
880
920
  placeholder="vd: sales-order"
881
- disabled={!!editingResource} // Disable code editing
921
+ disabled={!!editingResource}
882
922
  />
883
923
  </div>
884
924
  <div className="grid gap-2">
@@ -1122,42 +1162,29 @@ export function ResourceListPage() {
1122
1162
  loading={isDeleting}
1123
1163
  />
1124
1164
  </div>
1125
- );
1165
+ )
1126
1166
  }
1127
1167
 
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({
1168
+ // --- Editor quyền của 1 tài nguyên (dùng chung cho dòng bung + thẻ bung):
1169
+ // bản/Chức năng, tách Đã cấp/Chưa cấp, bấm để bật/tắt ngay. ---
1170
+ function ResourceActionsEditor({
1132
1171
  resource,
1133
- actionMap,
1134
1172
  allActions,
1135
1173
  categorized,
1136
- expanded,
1137
- onToggleExpand,
1138
1174
  onToggleAction,
1139
1175
  onUpdateActions,
1140
- onEditResource,
1141
1176
  }: {
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;
1177
+ resource: Resource
1178
+ allActions: Action[]
1179
+ categorized: { basic: Action[]; functional: Action[] }
1180
+ onToggleAction: (resource: Resource, actionCode: string) => void
1181
+ onUpdateActions: (resource: Resource, actions: string[]) => void
1151
1182
  }) {
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;
1183
+ const granted = resource.actions || []
1184
+ const grantedSet = new Set(granted)
1156
1185
 
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
1186
  const renderItem = (action: Action) => {
1160
- const on = grantedSet.has(action.code);
1187
+ const on = grantedSet.has(action.code)
1161
1188
  return (
1162
1189
  <button
1163
1190
  key={action.code}
@@ -1170,7 +1197,7 @@ function ResourceRow({
1170
1197
  "mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border",
1171
1198
  on
1172
1199
  ? ACTION_TONES[getActionTone(action.code)]
1173
- : "border-border bg-transparent text-transparent",
1200
+ : "border-border bg-transparent text-transparent"
1174
1201
  )}
1175
1202
  >
1176
1203
  <Check className="h-3 w-3" />
@@ -1179,7 +1206,7 @@ function ResourceRow({
1179
1206
  <span
1180
1207
  className={cn(
1181
1208
  "block text-xs leading-tight",
1182
- on ? "font-medium text-foreground" : "text-muted-foreground",
1209
+ on ? "font-medium text-foreground" : "text-muted-foreground"
1183
1210
  )}
1184
1211
  >
1185
1212
  {action.name}
@@ -1191,15 +1218,13 @@ function ResourceRow({
1191
1218
  )}
1192
1219
  </span>
1193
1220
  </button>
1194
- );
1195
- };
1221
+ )
1222
+ }
1196
1223
 
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
1224
  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));
1225
+ if (list.length === 0) return null
1226
+ const grantedList = list.filter((a) => grantedSet.has(a.code))
1227
+ const ungrantedList = list.filter((a) => !grantedSet.has(a.code))
1203
1228
  return (
1204
1229
  <div key={label}>
1205
1230
  <p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-foreground/70">
@@ -1228,8 +1253,85 @@ function ResourceRow({
1228
1253
  )}
1229
1254
  </div>
1230
1255
  </div>
1231
- );
1232
- };
1256
+ )
1257
+ }
1258
+
1259
+ return (
1260
+ <div>
1261
+ <div className="mb-2 flex items-center justify-between gap-2">
1262
+ <span className="text-xs text-muted-foreground">
1263
+ Đã gán{" "}
1264
+ <span className="font-medium text-foreground">{granted.length}</span>/
1265
+ {allActions.length} quyền
1266
+ </span>
1267
+ <div className="flex items-center gap-1">
1268
+ <Button
1269
+ variant="ghost"
1270
+ size="sm"
1271
+ className="h-7 px-2 text-xs"
1272
+ onClick={() =>
1273
+ onUpdateActions(
1274
+ resource,
1275
+ allActions.map((a) => a.code)
1276
+ )
1277
+ }
1278
+ disabled={granted.length === allActions.length}
1279
+ >
1280
+ Chọn tất cả
1281
+ </Button>
1282
+ <Button
1283
+ variant="ghost"
1284
+ size="sm"
1285
+ className="h-7 px-2 text-xs"
1286
+ onClick={() => onUpdateActions(resource, [])}
1287
+ disabled={granted.length === 0}
1288
+ >
1289
+ Bỏ tất cả
1290
+ </Button>
1291
+ </div>
1292
+ </div>
1293
+
1294
+ {allActions.length === 0 ? (
1295
+ <p className="py-2 text-xs text-muted-foreground">
1296
+ Chưa có hành động nào. Tạo hành động ở mục "Quản lý hành động".
1297
+ </p>
1298
+ ) : (
1299
+ <div className="space-y-3">
1300
+ {renderCategory("Cơ bản", categorized.basic)}
1301
+ {renderCategory("Chức năng", categorized.functional)}
1302
+ </div>
1303
+ )}
1304
+ </div>
1305
+ )
1306
+ }
1307
+
1308
+ // --- Resource row (chế độ dòng): tóm tắt quyền; bấm để bung editor ---
1309
+ const MAX_CHIPS = 8
1310
+
1311
+ function ResourceRow({
1312
+ resource,
1313
+ actionMap,
1314
+ allActions,
1315
+ categorized,
1316
+ expanded,
1317
+ onToggleExpand,
1318
+ onToggleAction,
1319
+ onUpdateActions,
1320
+ onEditResource,
1321
+ }: {
1322
+ resource: Resource
1323
+ actionMap: Map<string, Action>
1324
+ allActions: Action[]
1325
+ categorized: { basic: Action[]; functional: Action[] }
1326
+ expanded: boolean
1327
+ onToggleExpand: () => void
1328
+ onToggleAction: (resource: Resource, actionCode: string) => void
1329
+ onUpdateActions: (resource: Resource, actions: string[]) => void
1330
+ onEditResource: (resource: Resource) => void
1331
+ }) {
1332
+ const granted = resource.actions || []
1333
+ const shown = granted.slice(0, MAX_CHIPS)
1334
+ const overflow = granted.length - shown.length
1233
1335
 
1234
1336
  return (
1235
1337
  <li>
@@ -1240,8 +1342,8 @@ function ResourceRow({
1240
1342
  onClick={onToggleExpand}
1241
1343
  onKeyDown={(e) => {
1242
1344
  if (e.key === "Enter" || e.key === " ") {
1243
- e.preventDefault();
1244
- onToggleExpand();
1345
+ e.preventDefault()
1346
+ onToggleExpand()
1245
1347
  }
1246
1348
  }}
1247
1349
  className="flex cursor-pointer items-center gap-3 bg-card px-4 py-3 transition-colors hover:bg-muted/40"
@@ -1250,7 +1352,7 @@ function ResourceRow({
1250
1352
  <div
1251
1353
  className={cn(
1252
1354
  "flex h-9 w-9 shrink-0 items-center justify-center rounded-md",
1253
- getGroupMeta(resource.group).iconCls,
1355
+ getGroupMeta(resource.group).iconCls
1254
1356
  )}
1255
1357
  >
1256
1358
  <DynamicIcon
@@ -1259,7 +1361,7 @@ function ResourceRow({
1259
1361
  />
1260
1362
  </div>
1261
1363
 
1262
- {/* Identity + granted-chip summary (ẩn khi đã bung — bên dưới hiện đủ) */}
1364
+ {/* Identity + granted-chip summary */}
1263
1365
  <div className="min-w-0 flex-1">
1264
1366
  <div className="flex items-baseline gap-2">
1265
1367
  <span className="truncate text-sm font-medium text-foreground">
@@ -1285,7 +1387,7 @@ function ResourceRow({
1285
1387
  key={code}
1286
1388
  className={cn(
1287
1389
  "inline-flex items-center rounded border px-1.5 py-0.5 text-[11px]",
1288
- ACTION_TONES[getActionTone(code)],
1390
+ ACTION_TONES[getActionTone(code)]
1289
1391
  )}
1290
1392
  >
1291
1393
  {actionMap.get(code)?.name || code}
@@ -1309,10 +1411,10 @@ function ResourceRow({
1309
1411
  <button
1310
1412
  type="button"
1311
1413
  onClick={(e) => {
1312
- e.stopPropagation();
1313
- onEditResource(resource);
1414
+ e.stopPropagation()
1415
+ onEditResource(resource)
1314
1416
  }}
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"
1417
+ 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"
1316
1418
  title="Sửa thông tin tài nguyên"
1317
1419
  >
1318
1420
  <Pencil className="h-3.5 w-3.5" />
@@ -1320,63 +1422,200 @@ function ResourceRow({
1320
1422
  <ChevronRight
1321
1423
  className={cn(
1322
1424
  "h-4 w-4 shrink-0 text-muted-foreground/60 transition-transform",
1323
- expanded && "rotate-90",
1425
+ expanded && "rotate-90"
1324
1426
  )}
1325
1427
  />
1326
1428
  </div>
1327
1429
 
1328
- {/* Bung: toàn bộ action của resource, bấm chip để bật/tắt ngay — nền trắng */}
1430
+ {/* Bung: editor quyền — nền trắng */}
1329
1431
  {expanded && (
1330
1432
  <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>
1433
+ <ResourceActionsEditor
1434
+ resource={resource}
1435
+ allActions={allActions}
1436
+ categorized={categorized}
1437
+ onToggleAction={onToggleAction}
1438
+ onUpdateActions={onUpdateActions}
1439
+ />
1440
+ </div>
1441
+ )}
1442
+ </li>
1443
+ )
1444
+ }
1445
+
1446
+ // --- Thẻ tài nguyên (grid, MẶC ĐỊNH) — phong cách thẻ vai trò/hành động:
1447
+ // icon nhóm màu + tên/mã + #stt, chip quyền preview, KHỐI SỐ LIỆU đóng khung,
1448
+ // hàng nút Sửa/Xóa. Bấm thẻ bung editor quyền (thẻ giãn full hàng). ---
1449
+ const CARD_MAX_CHIPS = 6
1450
+
1451
+ function ResourceCard({
1452
+ index,
1453
+ resource,
1454
+ actionMap,
1455
+ allActions,
1456
+ categorized,
1457
+ expanded,
1458
+ onToggleExpand,
1459
+ onToggleAction,
1460
+ onUpdateActions,
1461
+ onEditResource,
1462
+ onDeleteResource,
1463
+ }: {
1464
+ index: number
1465
+ resource: Resource
1466
+ actionMap: Map<string, Action>
1467
+ allActions: Action[]
1468
+ categorized: { basic: Action[]; functional: Action[] }
1469
+ expanded: boolean
1470
+ onToggleExpand: () => void
1471
+ onToggleAction: (resource: Resource, actionCode: string) => void
1472
+ onUpdateActions: (resource: Resource, actions: string[]) => void
1473
+ onEditResource: (resource: Resource) => void
1474
+ onDeleteResource: (resource: Resource) => void
1475
+ }) {
1476
+ const granted = resource.actions || []
1477
+ const shown = granted.slice(0, CARD_MAX_CHIPS)
1478
+ const overflow = granted.length - shown.length
1479
+ const ungrantedCount = Math.max(allActions.length - granted.length, 0)
1480
+
1481
+ return (
1482
+ <div
1483
+ role="button"
1484
+ tabIndex={0}
1485
+ aria-expanded={expanded}
1486
+ onClick={onToggleExpand}
1487
+ onKeyDown={(e) => {
1488
+ if (e.key === "Enter" || e.key === " ") {
1489
+ e.preventDefault()
1490
+ onToggleExpand()
1491
+ }
1492
+ }}
1493
+ className={cn(
1494
+ "cursor-pointer overflow-hidden rounded-[12px] border border-border bg-card p-4 shadow-sm transition-all hover:border-primary/40 hover:shadow-md",
1495
+ expanded && "col-span-full border-primary/40"
1496
+ )}
1497
+ >
1498
+ {/* Header: icon nhóm màu + tên/mã | #stt */}
1499
+ <div className="flex items-start justify-between gap-2">
1500
+ <div className="flex min-w-0 items-center gap-2.5">
1501
+ <div
1502
+ className={cn(
1503
+ "flex h-8 w-8 shrink-0 items-center justify-center rounded-lg",
1504
+ getGroupMeta(resource.group).iconCls
1505
+ )}
1506
+ >
1507
+ <DynamicIcon
1508
+ name={(resource.icon as any) || "FileCode"}
1509
+ className="h-4 w-4"
1510
+ />
1511
+ </div>
1512
+ <div className="min-w-0">
1513
+ <div className="truncate text-[15px] font-bold leading-tight text-foreground">
1514
+ {resource.name}
1363
1515
  </div>
1516
+ <code className="font-mono text-[11px] text-muted-foreground">
1517
+ {resource.code}
1518
+ </code>
1364
1519
  </div>
1520
+ </div>
1521
+ <span className="shrink-0 font-mono text-[11px] tabular-nums text-muted-foreground/60">
1522
+ #{index + 1}
1523
+ </span>
1524
+ </div>
1365
1525
 
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
- )}
1526
+ {/* Preview chip quyền đã cấp (giữ chiều cao đều thẻ) */}
1527
+ <div className="mt-2 flex min-h-[3.25rem] flex-wrap content-start items-start gap-1">
1528
+ {granted.length === 0 ? (
1529
+ <span className="text-xs italic text-muted-foreground">
1530
+ Chưa gán quyền
1531
+ </span>
1532
+ ) : (
1533
+ <>
1534
+ {shown.map((code) => (
1535
+ <span
1536
+ key={code}
1537
+ className={cn(
1538
+ "inline-flex items-center rounded border px-1.5 py-0.5 text-[11px]",
1539
+ ACTION_TONES[getActionTone(code)]
1540
+ )}
1541
+ >
1542
+ {actionMap.get(code)?.name || code}
1543
+ </span>
1544
+ ))}
1545
+ {overflow > 0 && (
1546
+ <span className="px-1 text-[11px] text-muted-foreground">
1547
+ +{overflow}
1548
+ </span>
1549
+ )}
1550
+ </>
1551
+ )}
1552
+ </div>
1553
+
1554
+ {/* Khối số liệu đóng khung — như thẻ vai trò */}
1555
+ <div className="flex items-center justify-between rounded-lg border border-border bg-card p-2.5">
1556
+ <div className="flex flex-1 flex-col items-center border-r border-border">
1557
+ <span className="mb-1 text-[16px] font-black leading-none tabular-nums text-primary">
1558
+ {granted.length}
1559
+ </span>
1560
+ <span className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground">
1561
+ Đã cấp
1562
+ </span>
1563
+ </div>
1564
+ <div className="flex flex-1 flex-col items-center">
1565
+ <span className="mb-1 text-[16px] font-black leading-none tabular-nums text-muted-foreground">
1566
+ {ungrantedCount}
1567
+ </span>
1568
+ <span className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground">
1569
+ Chưa cấp
1570
+ </span>
1571
+ </div>
1572
+ </div>
1573
+
1574
+ {/* Hàng nút dưới chân */}
1575
+ <div className="flex gap-2 pt-3">
1576
+ <Button
1577
+ variant="outline"
1578
+ size="sm"
1579
+ 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"
1580
+ onClick={(e) => {
1581
+ e.stopPropagation()
1582
+ onEditResource(resource)
1583
+ }}
1584
+ >
1585
+ <Pencil className="mr-1.5 h-3.5 w-3.5 text-amber-500" />
1586
+ Sửa
1587
+ </Button>
1588
+ <Button
1589
+ variant="outline"
1590
+ size="sm"
1591
+ 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"
1592
+ onClick={(e) => {
1593
+ e.stopPropagation()
1594
+ onDeleteResource(resource)
1595
+ }}
1596
+ >
1597
+ <Trash2 className="mr-1.5 h-3.5 w-3.5" />
1598
+ Xóa
1599
+ </Button>
1600
+ </div>
1601
+
1602
+ {/* Bung: editor quyền (thẻ giãn full hàng); chặn click lan ra card */}
1603
+ {expanded && (
1604
+ <div
1605
+ className="mt-3 border-t border-border pt-3"
1606
+ onClick={(e) => e.stopPropagation()}
1607
+ >
1608
+ <ResourceActionsEditor
1609
+ resource={resource}
1610
+ allActions={allActions}
1611
+ categorized={categorized}
1612
+ onToggleAction={onToggleAction}
1613
+ onUpdateActions={onUpdateActions}
1614
+ />
1376
1615
  </div>
1377
1616
  )}
1378
- </li>
1379
- );
1617
+ </div>
1618
+ )
1380
1619
  }
1381
1620
 
1382
1621
  // --- Empty state ---
@@ -1385,12 +1624,12 @@ function EmptyState({
1385
1624
  onClear,
1386
1625
  onCreate,
1387
1626
  }: {
1388
- searching: boolean;
1389
- onClear: () => void;
1390
- onCreate: () => void;
1627
+ searching: boolean
1628
+ onClear: () => void
1629
+ onCreate: () => void
1391
1630
  }) {
1392
1631
  return (
1393
- <div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card py-20 text-center">
1632
+ <div className="flex flex-col items-center justify-center py-20 text-center">
1394
1633
  <div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted">
1395
1634
  <Search className="h-6 w-6 text-muted-foreground" />
1396
1635
  </div>
@@ -1417,5 +1656,5 @@ function EmptyState({
1417
1656
  </Button>
1418
1657
  )}
1419
1658
  </div>
1420
- );
1659
+ )
1421
1660
  }