@goplusvn/core 0.1.47 → 0.1.49

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.
@@ -6,7 +6,7 @@
6
6
  // Chi tiết sử dụng = chip màu theo loại + "+n khác".
7
7
  // API contract: GET/POST/PUT/DELETE /api/actions, GET /api/actions/usage
8
8
  // (usage optional — trang vẫn chạy khi endpoint không tồn tại).
9
- import { useEffect, useMemo, useState } from "react"
9
+ import { useEffect, useMemo, useState } from "react";
10
10
  import {
11
11
  Badge,
12
12
  Button,
@@ -20,8 +20,8 @@ import {
20
20
  Label,
21
21
  Skeleton,
22
22
  Textarea,
23
- } from "../../ui"
24
- import { toast } from "sonner"
23
+ } from "../../ui";
24
+ import { toast } from "sonner";
25
25
  import {
26
26
  BadgeCheck,
27
27
  Ban,
@@ -29,8 +29,6 @@ import {
29
29
  ChevronRight,
30
30
  Download,
31
31
  Eye,
32
- LayoutGrid,
33
- List,
34
32
  PenLine,
35
33
  Pencil,
36
34
  Plus,
@@ -40,47 +38,47 @@ import {
40
38
  Settings2,
41
39
  Trash2,
42
40
  Upload,
43
- X,
44
41
  Zap,
45
- } from "lucide-react"
42
+ } from "lucide-react";
46
43
 
47
- import type { LucideIcon } from "lucide-react"
48
-
49
- import { errorMessage, throwFetchError } from "../lib/fetch-error"
50
- import { cn } from "../../utils"
44
+ import type { LucideIcon } from "lucide-react";
51
45
 
46
+ import { errorMessage } from "../lib/fetch-error";
47
+ import { deleteActionById, saveAction } from "./lib/action-api";
48
+ import { RbacPageBar, RbacStickyToolbar } from "./lib/rbac-page-shell";
49
+ import { cn } from "../../utils";
52
50
 
53
51
  // --- Types ---
54
52
  interface Action {
55
- id: string
56
- code: string
57
- name: string
58
- description?: string
59
- isDefault?: boolean
53
+ id: string;
54
+ code: string;
55
+ name: string;
56
+ description?: string;
57
+ isDefault?: boolean;
60
58
  }
61
59
 
62
60
  interface UsageEntry {
63
- resources: { code: string; name: string }[]
64
- roles: { code: string; name: string }[]
65
- users: { id: string; name: string | null; email: string }[]
61
+ resources: { code: string; name: string }[];
62
+ roles: { code: string; name: string }[];
63
+ users: { id: string; name: string | null; email: string }[];
66
64
  }
67
65
 
68
- type UsageMap = Record<string, UsageEntry>
66
+ type UsageMap = Record<string, UsageEntry>;
69
67
 
70
- const EMPTY_USAGE: UsageEntry = { resources: [], roles: [], users: [] }
68
+ const EMPTY_USAGE: UsageEntry = { resources: [], roles: [], users: [] };
71
69
 
72
70
  const normalize = (s: string) =>
73
71
  (s || "")
74
72
  .toLowerCase()
75
73
  .normalize("NFD")
76
74
  .replace(/[\u0300-\u036f]/g, "")
77
- .replace(/đ/g, "d")
75
+ .replace(/đ/g, "d");
78
76
 
79
77
  // --- Icon + màu theo LOẠI action (đồng bộ chip quyền trang sửa vai trò) ---
80
78
  const ACTION_VISUAL_RULES: Array<{
81
- re: RegExp
82
- icon: LucideIcon
83
- cls: string
79
+ re: RegExp;
80
+ icon: LucideIcon;
81
+ cls: string;
84
82
  }> = [
85
83
  { re: /^(view|read|get|list)/, icon: Eye, cls: "text-blue-500" },
86
84
  { re: /^(create|add|new)/, icon: Plus, cls: "text-emerald-500" },
@@ -99,201 +97,184 @@ const ACTION_VISUAL_RULES: Array<{
99
97
  { re: /^sync/, icon: RefreshCw, cls: "text-cyan-600" },
100
98
  { re: /^(manage|config)/, icon: Settings2, cls: "text-orange-500" },
101
99
  { re: /^auto/, icon: Bot, cls: "text-purple-500" },
102
- ]
100
+ ];
103
101
 
104
102
  const getActionVisual = (code: string) =>
105
103
  ACTION_VISUAL_RULES.find((r) => r.re.test(code)) ?? {
106
104
  icon: Zap,
107
105
  cls: "text-slate-400",
108
- }
106
+ };
109
107
 
110
108
  interface Permissions {
111
- create?: boolean
112
- update?: boolean
113
- delete?: boolean
109
+ create?: boolean;
110
+ update?: boolean;
111
+ delete?: boolean;
114
112
  }
115
113
 
116
114
  export interface ActionListPageProps {
117
- permissions?: Permissions
115
+ permissions?: Permissions;
118
116
  /** Catalog hành động do Permission Registry trong code quản lý — ẩn mọi nút ghi. */
119
- readOnly?: boolean
117
+ readOnly?: boolean;
120
118
  }
121
119
 
122
120
  export function ActionListPage({
123
121
  permissions = {},
124
122
  readOnly = false,
125
123
  }: ActionListPageProps) {
126
- const [loading, setLoading] = useState(true)
127
- const [actions, setActions] = useState<Action[]>([])
128
- const [usage, setUsage] = useState<UsageMap>({})
129
- const [searchTerm, setSearchTerm] = useState("")
130
- const [view, setView] = useState<"list" | "grid">("grid")
131
- const [isRefreshing, setIsRefreshing] = useState(false)
132
- const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
124
+ const [loading, setLoading] = useState(true);
125
+ const [actions, setActions] = useState<Action[]>([]);
126
+ const [usage, setUsage] = useState<UsageMap>({});
127
+ const [searchTerm, setSearchTerm] = useState("");
128
+ const [view, setView] = useState<"list" | "grid">("grid");
129
+ const [isRefreshing, setIsRefreshing] = useState(false);
130
+ const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
133
131
 
134
132
  // Create/Edit dialog
135
- const [isDialogOpen, setIsDialogOpen] = useState(false)
136
- const [editing, setEditing] = useState<Action | null>(null)
133
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
134
+ const [editing, setEditing] = useState<Action | null>(null);
137
135
  const [formData, setFormData] = useState({
138
136
  code: "",
139
137
  name: "",
140
138
  description: "",
141
- })
139
+ });
142
140
 
143
141
  // Delete confirm
144
- const [confirmOpen, setConfirmOpen] = useState(false)
145
- const [toDelete, setToDelete] = useState<Action | null>(null)
146
- const [isDeleting, setIsDeleting] = useState(false)
142
+ const [confirmOpen, setConfirmOpen] = useState(false);
143
+ const [toDelete, setToDelete] = useState<Action | null>(null);
144
+ const [isDeleting, setIsDeleting] = useState(false);
147
145
 
148
- const canCreate = !readOnly && !!permissions.create
149
- const canUpdate = !readOnly && !!permissions.update
150
- const canDelete = !readOnly && !!permissions.delete
146
+ const canCreate = !readOnly && !!permissions.create;
147
+ const canUpdate = !readOnly && !!permissions.update;
148
+ const canDelete = !readOnly && !!permissions.delete;
151
149
 
152
150
  const fetchData = async () => {
153
151
  try {
154
- setLoading(true)
152
+ setLoading(true);
155
153
  const [resActions, resUsage] = await Promise.all([
156
154
  fetch("/api/actions?pageSize=1000"),
157
155
  fetch("/api/actions/usage"),
158
- ])
159
- if (!resActions.ok) throw new Error("Không tải được danh sách hành động")
156
+ ]);
157
+ if (!resActions.ok) throw new Error("Không tải được danh sách hành động");
160
158
 
161
- const actionsData = await resActions.json()
162
- setActions(actionsData.items || [])
159
+ const actionsData = await resActions.json();
160
+ setActions(actionsData.items || []);
163
161
 
164
162
  if (resUsage.ok) {
165
- const usageData = await resUsage.json()
166
- setUsage(usageData.usage || {})
163
+ const usageData = await resUsage.json();
164
+ setUsage(usageData.usage || {});
167
165
  }
168
166
  } catch (error) {
169
- toast.error(errorMessage(error, "Lỗi tải dữ liệu"))
167
+ toast.error(errorMessage(error, "Lỗi tải dữ liệu"));
170
168
  } finally {
171
- setLoading(false)
172
- setIsRefreshing(false)
169
+ setLoading(false);
170
+ setIsRefreshing(false);
173
171
  }
174
- }
172
+ };
175
173
 
176
174
  useEffect(() => {
177
- fetchData()
178
- }, [])
175
+ fetchData();
176
+ }, []);
179
177
 
180
178
  const handleRefresh = () => {
181
- setIsRefreshing(true)
182
- fetchData()
183
- }
179
+ setIsRefreshing(true);
180
+ fetchData();
181
+ };
184
182
 
185
183
  const toggleRow = (id: string) => {
186
184
  setExpandedRows((prev) => {
187
- const next = new Set(prev)
188
- if (next.has(id)) next.delete(id)
189
- else next.add(id)
190
- return next
191
- })
192
- }
185
+ const next = new Set(prev);
186
+ if (next.has(id)) next.delete(id);
187
+ else next.add(id);
188
+ return next;
189
+ });
190
+ };
193
191
 
194
192
  const filtered = useMemo(() => {
195
- const q = normalize(searchTerm.trim())
193
+ const q = normalize(searchTerm.trim());
196
194
  const list = q
197
195
  ? actions.filter(
198
196
  (a) =>
199
197
  normalize(a.name).includes(q) ||
200
198
  normalize(a.code).includes(q) ||
201
- normalize(a.description || "").includes(q)
199
+ normalize(a.description || "").includes(q),
202
200
  )
203
- : actions
204
- return [...list].sort((a, b) => a.name.localeCompare(b.name, "vi"))
205
- }, [actions, searchTerm])
201
+ : actions;
202
+ return [...list].sort((a, b) => a.name.localeCompare(b.name, "vi"));
203
+ }, [actions, searchTerm]);
206
204
 
207
205
  // --- CRUD ---
208
206
  const openNew = () => {
209
- setEditing(null)
210
- setFormData({ code: "", name: "", description: "" })
211
- setIsDialogOpen(true)
212
- }
207
+ setEditing(null);
208
+ setFormData({ code: "", name: "", description: "" });
209
+ setIsDialogOpen(true);
210
+ };
213
211
 
214
212
  const openEdit = (action: Action) => {
215
- setEditing(action)
213
+ setEditing(action);
216
214
  setFormData({
217
215
  code: action.code,
218
216
  name: action.name,
219
217
  description: action.description || "",
220
- })
221
- setIsDialogOpen(true)
222
- }
218
+ });
219
+ setIsDialogOpen(true);
220
+ };
223
221
 
224
222
  const handleSubmit = async () => {
225
223
  if (!formData.code || !formData.name) {
226
- toast.error("Vui lòng nhập Mã và Tên hành động")
227
- return
224
+ toast.error("Vui lòng nhập Mã và Tên hành động");
225
+ return;
228
226
  }
229
227
  try {
230
- const payload = { ...formData, status: "active" }
231
- const res = await fetch("/api/actions", {
232
- method: editing ? "PUT" : "POST",
233
- headers: { "Content-Type": "application/json" },
234
- body: JSON.stringify(
235
- editing ? { id: editing.id, ...payload } : payload
236
- ),
237
- })
238
- if (!res.ok) {
239
- await throwFetchError(res, "Operation failed")
240
- }
241
- const saved = await res.json()
228
+ const saved = await saveAction<Action>(formData, editing?.id);
242
229
  if (editing) {
243
- setActions((prev) => prev.map((a) => (a.id === saved.id ? saved : a)))
244
- toast.success("Cập nhật hành động thành công")
230
+ setActions((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
231
+ toast.success("Cập nhật hành động thành công");
245
232
  } else {
246
- setActions((prev) => [saved, ...prev])
247
- toast.success("Tạo hành động thành công")
233
+ setActions((prev) => [saved, ...prev]);
234
+ toast.success("Tạo hành động thành công");
248
235
  }
249
- setIsDialogOpen(false)
236
+ setIsDialogOpen(false);
250
237
  } catch (error) {
251
- toast.error(errorMessage(error, "Có lỗi xảy ra"))
238
+ toast.error(errorMessage(error, "Có lỗi xảy ra"));
252
239
  }
253
- }
240
+ };
254
241
 
255
242
  const requestDelete = (action: Action) => {
256
- setToDelete(action)
257
- setConfirmOpen(true)
258
- }
243
+ setToDelete(action);
244
+ setConfirmOpen(true);
245
+ };
259
246
 
260
247
  const handleConfirmDelete = async () => {
261
- if (!toDelete) return
262
- setIsDeleting(true)
248
+ if (!toDelete) return;
249
+ setIsDeleting(true);
263
250
  try {
264
- const res = await fetch(`/api/actions?id=${toDelete.id}`, {
265
- method: "DELETE",
266
- })
267
- if (!res.ok) {
268
- await throwFetchError(res, "Delete failed")
269
- }
270
- setActions((prev) => prev.filter((a) => a.id !== toDelete.id))
271
- toast.success("Xóa hành động thành công")
272
- setConfirmOpen(false)
273
- setToDelete(null)
251
+ await deleteActionById(toDelete.id);
252
+ setActions((prev) => prev.filter((a) => a.id !== toDelete.id));
253
+ toast.success("Xóa hành động thành công");
254
+ setConfirmOpen(false);
255
+ setToDelete(null);
274
256
  } catch (error) {
275
- toast.error(errorMessage(error, "Không thể xóa hành động"))
257
+ toast.error(errorMessage(error, "Không thể xóa hành động"));
276
258
  } finally {
277
- setIsDeleting(false)
259
+ setIsDeleting(false);
278
260
  }
279
- }
261
+ };
280
262
 
281
- const isSearching = searchTerm.trim().length > 0
263
+ const isSearching = searchTerm.trim().length > 0;
282
264
 
283
265
  return (
284
266
  <div className="flex flex-col">
285
- {/* ===== Bar navy 1 dòng — đồng bộ trang vai trò/đơn bán hàng ===== */}
286
- <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">
287
- <span className="flex h-8 w-8 shrink-0 items-center justify-center">
288
- <Zap className="h-4 w-4 text-primary-foreground/80" />
289
- </span>
290
- <h1 className="min-w-0 flex-1 truncate px-1 text-base font-bold text-primary-foreground sm:text-lg">
291
- Quản hành động
292
- </h1>
293
- <span className="hidden shrink-0 text-xs text-primary-foreground/70 md:inline">
294
- {actions.length} hành động · dùng cho tài nguyên · vai trò · người
295
- dùng
296
- </span>
267
+ {/* ===== Bar navy 1 dòng — skeleton dùng chung RbacPageBar ===== */}
268
+ <RbacPageBar
269
+ icon={<Zap className="h-4 w-4 text-primary-foreground/80" />}
270
+ title="Quản hành động"
271
+ subtitle={
272
+ <>
273
+ {actions.length} hành động · dùng cho tài nguyên · vai trò · người
274
+ dùng
275
+ </>
276
+ }
277
+ >
297
278
  {canCreate && (
298
279
  <Button
299
280
  onClick={openNew}
@@ -304,31 +285,21 @@ export function ActionListPage({
304
285
  <span className="ml-1.5 sm:hidden">Thêm</span>
305
286
  </Button>
306
287
  )}
307
- </div>
288
+ </RbacPageBar>
308
289
 
309
290
  {/* ===== Khối trắng: toolbar sticky + danh sách ===== */}
310
291
  <div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
311
292
  {/* Toolbar sticky */}
312
- <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">
313
- <div className="relative min-w-[140px] flex-1 sm:max-w-72">
314
- <Search className="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
315
- <Input
316
- placeholder="Tìm hành động (tên, mã, mô tả)…"
317
- className="h-8 w-full rounded-md border border-border bg-card pl-8 pr-8 text-sm"
318
- value={searchTerm}
319
- onChange={(e) => setSearchTerm(e.target.value)}
320
- />
321
- {searchTerm && (
322
- <button
323
- type="button"
324
- onClick={() => setSearchTerm("")}
325
- className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
326
- title="Xóa tìm kiếm"
327
- >
328
- <X className="h-3.5 w-3.5" />
329
- </button>
330
- )}
331
- </div>
293
+ <RbacStickyToolbar
294
+ searchTerm={searchTerm}
295
+ onSearchChange={setSearchTerm}
296
+ searchPlaceholder="Tìm hành động (tên, mã, mô tả)…"
297
+ count={actions.length}
298
+ countLabel="hành động"
299
+ matchedCount={filtered.length}
300
+ view={view}
301
+ onViewChange={setView}
302
+ >
332
303
  <Button
333
304
  variant="outline"
334
305
  size="icon"
@@ -341,53 +312,7 @@ export function ActionListPage({
341
312
  className={cn("h-3.5 w-3.5", isRefreshing && "animate-spin")}
342
313
  />
343
314
  </Button>
344
- <p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
345
- {isSearching ? (
346
- <>
347
- <span className="font-semibold tabular-nums text-foreground">
348
- {filtered.length}
349
- </span>{" "}
350
- khớp
351
- </>
352
- ) : (
353
- <>
354
- <span className="font-semibold tabular-nums text-foreground">
355
- {actions.length}
356
- </span>{" "}
357
- hành động
358
- </>
359
- )}
360
- </p>
361
- {/* Chuyển dạng hiển thị: dòng / thẻ */}
362
- <div className="flex shrink-0 overflow-hidden rounded-md border border-border">
363
- <button
364
- type="button"
365
- onClick={() => setView("list")}
366
- title="Dạng dòng"
367
- className={cn(
368
- "px-2 py-1.5 transition-colors",
369
- view === "list"
370
- ? "bg-muted text-foreground"
371
- : "bg-card text-muted-foreground hover:text-foreground"
372
- )}
373
- >
374
- <List className="h-3.5 w-3.5" />
375
- </button>
376
- <button
377
- type="button"
378
- onClick={() => setView("grid")}
379
- title="Dạng thẻ"
380
- className={cn(
381
- "border-l border-border px-2 py-1.5 transition-colors",
382
- view === "grid"
383
- ? "bg-muted text-foreground"
384
- : "bg-card text-muted-foreground hover:text-foreground"
385
- )}
386
- >
387
- <LayoutGrid className="h-3.5 w-3.5" />
388
- </button>
389
- </div>
390
- </div>
315
+ </RbacStickyToolbar>
391
316
 
392
317
  {/* Body */}
393
318
  {loading && !isRefreshing ? (
@@ -516,7 +441,7 @@ export function ActionListPage({
516
441
  loading={isDeleting}
517
442
  />
518
443
  </div>
519
- )
444
+ );
520
445
  }
521
446
 
522
447
  // --- Một dòng hành động: tên + mã·mô tả; số liệu dạng chữ; bung ra hiển thị
@@ -532,15 +457,15 @@ function ActionRow({
532
457
  onEdit,
533
458
  onDelete,
534
459
  }: {
535
- index: number
536
- action: Action
537
- usage: UsageEntry
538
- expanded: boolean
539
- canUpdate: boolean
540
- canDelete: boolean
541
- onToggleExpand: () => void
542
- onEdit: (a: Action) => void
543
- onDelete: (a: Action) => void
460
+ index: number;
461
+ action: Action;
462
+ usage: UsageEntry;
463
+ expanded: boolean;
464
+ canUpdate: boolean;
465
+ canDelete: boolean;
466
+ onToggleExpand: () => void;
467
+ onEdit: (a: Action) => void;
468
+ onDelete: (a: Action) => void;
544
469
  }) {
545
470
  return (
546
471
  <li>
@@ -551,8 +476,8 @@ function ActionRow({
551
476
  onClick={onToggleExpand}
552
477
  onKeyDown={(e) => {
553
478
  if (e.key === "Enter" || e.key === " ") {
554
- e.preventDefault()
555
- onToggleExpand()
479
+ e.preventDefault();
480
+ onToggleExpand();
556
481
  }
557
482
  }}
558
483
  className="flex cursor-pointer items-center gap-3 bg-card px-4 py-3 transition-colors hover:bg-muted/40"
@@ -580,8 +505,8 @@ function ActionRow({
580
505
  <button
581
506
  type="button"
582
507
  onClick={(e) => {
583
- e.stopPropagation()
584
- onEdit(action)
508
+ e.stopPropagation();
509
+ onEdit(action);
585
510
  }}
586
511
  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"
587
512
  title="Sửa hành động"
@@ -593,8 +518,8 @@ function ActionRow({
593
518
  <button
594
519
  type="button"
595
520
  onClick={(e) => {
596
- e.stopPropagation()
597
- onDelete(action)
521
+ e.stopPropagation();
522
+ onDelete(action);
598
523
  }}
599
524
  className="shrink-0 rounded-md border border-border bg-card p-1.5 text-destructive shadow-sm transition-colors hover:border-destructive/40 hover:bg-destructive/10"
600
525
  title="Xóa hành động"
@@ -605,7 +530,7 @@ function ActionRow({
605
530
  <ChevronRight
606
531
  className={cn(
607
532
  "h-4 w-4 shrink-0 text-muted-foreground/50 transition-transform",
608
- expanded && "rotate-90"
533
+ expanded && "rotate-90",
609
534
  )}
610
535
  />
611
536
  </div>
@@ -639,7 +564,7 @@ function ActionRow({
639
564
  </div>
640
565
  )}
641
566
  </li>
642
- )
567
+ );
643
568
  }
644
569
 
645
570
  // --- Thẻ hành động (grid, MẶC ĐỊNH) — phong cách thẻ VAI TRÒ: icon loại
@@ -656,18 +581,18 @@ function ActionCard({
656
581
  onEdit,
657
582
  onDelete,
658
583
  }: {
659
- index: number
660
- action: Action
661
- usage: UsageEntry
662
- expanded: boolean
663
- canUpdate: boolean
664
- canDelete: boolean
665
- onToggleExpand: () => void
666
- onEdit: (a: Action) => void
667
- onDelete: (a: Action) => void
584
+ index: number;
585
+ action: Action;
586
+ usage: UsageEntry;
587
+ expanded: boolean;
588
+ canUpdate: boolean;
589
+ canDelete: boolean;
590
+ onToggleExpand: () => void;
591
+ onEdit: (a: Action) => void;
592
+ onDelete: (a: Action) => void;
668
593
  }) {
669
- const visual = getActionVisual(action.code)
670
- const ActionIcon = visual.icon
594
+ const visual = getActionVisual(action.code);
595
+ const ActionIcon = visual.icon;
671
596
 
672
597
  return (
673
598
  <div
@@ -677,13 +602,13 @@ function ActionCard({
677
602
  onClick={onToggleExpand}
678
603
  onKeyDown={(e) => {
679
604
  if (e.key === "Enter" || e.key === " ") {
680
- e.preventDefault()
681
- onToggleExpand()
605
+ e.preventDefault();
606
+ onToggleExpand();
682
607
  }
683
608
  }}
684
609
  className={cn(
685
610
  "cursor-pointer overflow-hidden rounded-[12px] border border-border bg-card p-4 shadow-sm transition-all hover:border-primary/40 hover:shadow-md",
686
- expanded && "col-span-full border-primary/40"
611
+ expanded && "col-span-full border-primary/40",
687
612
  )}
688
613
  >
689
614
  {/* Header: icon loại + tên/mã | badge */}
@@ -758,8 +683,8 @@ function ActionCard({
758
683
  size="sm"
759
684
  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"
760
685
  onClick={(e) => {
761
- e.stopPropagation()
762
- onEdit(action)
686
+ e.stopPropagation();
687
+ onEdit(action);
763
688
  }}
764
689
  >
765
690
  <Pencil className="mr-1.5 h-3.5 w-3.5 text-amber-500" />
@@ -772,8 +697,8 @@ function ActionCard({
772
697
  size="sm"
773
698
  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"
774
699
  onClick={(e) => {
775
- e.stopPropagation()
776
- onDelete(action)
700
+ e.stopPropagation();
701
+ onDelete(action);
777
702
  }}
778
703
  >
779
704
  <Trash2 className="mr-1.5 h-3.5 w-3.5" />
@@ -811,7 +736,7 @@ function ActionCard({
811
736
  </div>
812
737
  )}
813
738
  </div>
814
- )
739
+ );
815
740
  }
816
741
 
817
742
  // Số liệu kiểu chữ: "30 tài nguyên · 8 vai trò · 30 người dùng"
@@ -819,19 +744,19 @@ function Metrics({
819
744
  usage,
820
745
  className,
821
746
  }: {
822
- usage: UsageEntry
823
- className?: string
747
+ usage: UsageEntry;
748
+ className?: string;
824
749
  }) {
825
750
  const parts = [
826
751
  { n: usage.resources.length, label: "tài nguyên" },
827
752
  { n: usage.roles.length, label: "vai trò" },
828
753
  { n: usage.users.length, label: "người dùng" },
829
- ]
754
+ ];
830
755
  return (
831
756
  <div
832
757
  className={cn(
833
758
  "shrink-0 whitespace-nowrap text-xs text-muted-foreground",
834
- className
759
+ className,
835
760
  )}
836
761
  >
837
762
  {parts.map((p, i) => (
@@ -840,7 +765,7 @@ function Metrics({
840
765
  <span
841
766
  className={cn(
842
767
  "tabular-nums",
843
- p.n > 0 && "font-medium text-foreground"
768
+ p.n > 0 && "font-medium text-foreground",
844
769
  )}
845
770
  >
846
771
  {p.n}
@@ -849,12 +774,12 @@ function Metrics({
849
774
  </span>
850
775
  ))}
851
776
  </div>
852
- )
777
+ );
853
778
  }
854
779
 
855
780
  // 1 dòng quan hệ: nhãn (small caps) + số đếm | CHIP wrap ngang (thay cho
856
781
  // danh sách phẩy khó quét). Quá MAX_DEF_CHIPS → nút "+n khác" / "Thu gọn".
857
- const MAX_DEF_CHIPS = 15
782
+ const MAX_DEF_CHIPS = 15;
858
783
 
859
784
  function DefRow({
860
785
  label,
@@ -862,14 +787,14 @@ function DefRow({
862
787
  items,
863
788
  chipClass,
864
789
  }: {
865
- label: string
866
- empty: string
867
- items: { key: string; text: string; title?: string }[]
868
- chipClass?: string
790
+ label: string;
791
+ empty: string;
792
+ items: { key: string; text: string; title?: string }[];
793
+ chipClass?: string;
869
794
  }) {
870
- const [showAll, setShowAll] = useState(false)
871
- const shown = showAll ? items : items.slice(0, MAX_DEF_CHIPS)
872
- const overflow = items.length - shown.length
795
+ const [showAll, setShowAll] = useState(false);
796
+ const shown = showAll ? items : items.slice(0, MAX_DEF_CHIPS);
797
+ const overflow = items.length - shown.length;
873
798
 
874
799
  return (
875
800
  <div className="flex flex-col gap-1 sm:flex-row sm:gap-4">
@@ -890,7 +815,7 @@ function DefRow({
890
815
  title={it.title}
891
816
  className={cn(
892
817
  "inline-flex max-w-56 items-center truncate rounded-md border px-1.5 py-0.5 text-[11px] leading-4",
893
- chipClass || "border-border bg-card text-foreground/80"
818
+ chipClass || "border-border bg-card text-foreground/80",
894
819
  )}
895
820
  >
896
821
  {it.text}
@@ -918,5 +843,5 @@ function DefRow({
918
843
  )}
919
844
  </div>
920
845
  </div>
921
- )
846
+ );
922
847
  }