@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,25 +1,12 @@
1
1
  "use client";
2
2
 
3
- // Trang "Quản lý hành động" — danh sách hành động RBAC kèm ngữ cảnh sử dụng
4
- // (tài nguyên · vai trò · người dùng). Bề mặt trắng (bg-card), nút tác vụ
5
- // LUÔN hiển thị (không ẩn sau hover mobile không hover).
6
- //
3
+ // Trang "Quản lý hành động" — bar navy 1 dòng (full-bleed mobile / nổi lg)
4
+ // + khối trắng (toolbar sticky + danh sách). GRID thẻ kiểu thẻ vai trò
5
+ // MẶC ĐỊNH (toggle List/Grid); dòng #stt, nút sửa VÀNG / xóa ĐỎ luôn hiện.
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
- // (usage optional — trang vẫn chạy khi endpoint không tồn tại).
9
-
10
- import { useEffect, useMemo, useState } from "react";
11
- import { toast } from "sonner";
12
- import {
13
- ChevronRight,
14
- Pencil,
15
- Plus,
16
- RefreshCw,
17
- Search,
18
- Trash2,
19
- X,
20
- Zap,
21
- } from "lucide-react";
22
-
8
+ // (usage optional — trang vẫn chạy khi endpoint không tồn tại).
9
+ import { useEffect, useMemo, useState } from "react"
23
10
  import {
24
11
  Badge,
25
12
  Button,
@@ -31,289 +18,401 @@ import {
31
18
  DialogTitle,
32
19
  Input,
33
20
  Label,
34
- PageHeader,
35
21
  Skeleton,
36
22
  Textarea,
37
- } from "../../ui";
38
- import { cn } from "../../utils";
39
- import { errorMessage, throwFetchError } from "../lib/fetch-error";
23
+ } from "../../ui"
24
+ import { toast } from "sonner"
25
+ import {
26
+ BadgeCheck,
27
+ Ban,
28
+ Bot,
29
+ ChevronRight,
30
+ Download,
31
+ Eye,
32
+ LayoutGrid,
33
+ List,
34
+ PenLine,
35
+ Pencil,
36
+ Plus,
37
+ Printer,
38
+ RefreshCw,
39
+ Search,
40
+ Settings2,
41
+ Trash2,
42
+ Upload,
43
+ X,
44
+ Zap,
45
+ } from "lucide-react"
46
+
47
+ import type { LucideIcon } from "lucide-react"
48
+
49
+ import { errorMessage, throwFetchError } from "../lib/fetch-error"
50
+ import { cn } from "../../utils"
51
+
40
52
 
41
53
  // --- Types ---
42
54
  interface Action {
43
- id: string;
44
- code: string;
45
- name: string;
46
- description?: string;
47
- isDefault?: boolean;
55
+ id: string
56
+ code: string
57
+ name: string
58
+ description?: string
59
+ isDefault?: boolean
48
60
  }
49
61
 
50
62
  interface UsageEntry {
51
- resources: { code: string; name: string }[];
52
- roles: { code: string; name: string }[];
53
- users: { id: string; name: string | null; email: string }[];
63
+ resources: { code: string; name: string }[]
64
+ roles: { code: string; name: string }[]
65
+ users: { id: string; name: string | null; email: string }[]
54
66
  }
55
67
 
56
- type UsageMap = Record<string, UsageEntry>;
68
+ type UsageMap = Record<string, UsageEntry>
57
69
 
58
- const EMPTY_USAGE: UsageEntry = { resources: [], roles: [], users: [] };
70
+ const EMPTY_USAGE: UsageEntry = { resources: [], roles: [], users: [] }
59
71
 
60
72
  const normalize = (s: string) =>
61
73
  (s || "")
62
74
  .toLowerCase()
63
75
  .normalize("NFD")
64
76
  .replace(/[\u0300-\u036f]/g, "")
65
- .replace(/đ/g, "d");
77
+ .replace(/đ/g, "d")
78
+
79
+ // --- Icon + màu theo LOẠI action (đồng bộ chip quyền trang sửa vai trò) ---
80
+ const ACTION_VISUAL_RULES: Array<{
81
+ re: RegExp
82
+ icon: LucideIcon
83
+ cls: string
84
+ }> = [
85
+ { re: /^(view|read|get|list)/, icon: Eye, cls: "text-blue-500" },
86
+ { re: /^(create|add|new)/, icon: Plus, cls: "text-emerald-500" },
87
+ { re: /^(update|edit|change|set)/, icon: Pencil, cls: "text-amber-500" },
88
+ { re: /^(delete|remove)/, icon: Trash2, cls: "text-rose-500" },
89
+ { re: /^(cancel|void|reject)/, icon: Ban, cls: "text-rose-500" },
90
+ {
91
+ re: /^(approve|confirm|verify|accept)/,
92
+ icon: BadgeCheck,
93
+ cls: "text-violet-500",
94
+ },
95
+ { re: /^sign/, icon: PenLine, cls: "text-indigo-500" },
96
+ { re: /^import/, icon: Download, cls: "text-purple-500" },
97
+ { re: /^export/, icon: Upload, cls: "text-indigo-500" },
98
+ { re: /^print/, icon: Printer, cls: "text-cyan-600" },
99
+ { re: /^sync/, icon: RefreshCw, cls: "text-cyan-600" },
100
+ { re: /^(manage|config)/, icon: Settings2, cls: "text-orange-500" },
101
+ { re: /^auto/, icon: Bot, cls: "text-purple-500" },
102
+ ]
103
+
104
+ const getActionVisual = (code: string) =>
105
+ ACTION_VISUAL_RULES.find((r) => r.re.test(code)) ?? {
106
+ icon: Zap,
107
+ cls: "text-slate-400",
108
+ }
66
109
 
67
110
  interface Permissions {
68
- create?: boolean;
69
- update?: boolean;
70
- delete?: boolean;
111
+ create?: boolean
112
+ update?: boolean
113
+ delete?: boolean
71
114
  }
72
115
 
73
116
  export interface ActionListPageProps {
74
- permissions?: Permissions;
117
+ permissions?: Permissions
75
118
  }
76
119
 
77
120
  export function ActionListPage({ permissions = {} }: ActionListPageProps) {
78
- const [loading, setLoading] = useState(true);
79
- const [actions, setActions] = useState<Action[]>([]);
80
- const [usage, setUsage] = useState<UsageMap>({});
81
- const [searchTerm, setSearchTerm] = useState("");
82
- const [isRefreshing, setIsRefreshing] = useState(false);
83
- const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
121
+ const [loading, setLoading] = useState(true)
122
+ const [actions, setActions] = useState<Action[]>([])
123
+ const [usage, setUsage] = useState<UsageMap>({})
124
+ const [searchTerm, setSearchTerm] = useState("")
125
+ const [view, setView] = useState<"list" | "grid">("grid")
126
+ const [isRefreshing, setIsRefreshing] = useState(false)
127
+ const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
84
128
 
85
129
  // Create/Edit dialog
86
- const [isDialogOpen, setIsDialogOpen] = useState(false);
87
- const [editing, setEditing] = useState<Action | null>(null);
130
+ const [isDialogOpen, setIsDialogOpen] = useState(false)
131
+ const [editing, setEditing] = useState<Action | null>(null)
88
132
  const [formData, setFormData] = useState({
89
133
  code: "",
90
134
  name: "",
91
135
  description: "",
92
- });
136
+ })
93
137
 
94
138
  // Delete confirm
95
- const [confirmOpen, setConfirmOpen] = useState(false);
96
- const [toDelete, setToDelete] = useState<Action | null>(null);
97
- const [isDeleting, setIsDeleting] = useState(false);
139
+ const [confirmOpen, setConfirmOpen] = useState(false)
140
+ const [toDelete, setToDelete] = useState<Action | null>(null)
141
+ const [isDeleting, setIsDeleting] = useState(false)
98
142
 
99
- const canCreate = !!permissions.create;
100
- const canUpdate = !!permissions.update;
101
- const canDelete = !!permissions.delete;
143
+ const canCreate = !!permissions.create
144
+ const canUpdate = !!permissions.update
145
+ const canDelete = !!permissions.delete
102
146
 
103
147
  const fetchData = async () => {
104
148
  try {
105
- setLoading(true);
149
+ setLoading(true)
106
150
  const [resActions, resUsage] = await Promise.all([
107
151
  fetch("/api/actions?pageSize=1000"),
108
152
  fetch("/api/actions/usage"),
109
- ]);
110
- if (!resActions.ok) throw new Error("Không tải được danh sách hành động");
153
+ ])
154
+ if (!resActions.ok) throw new Error("Không tải được danh sách hành động")
111
155
 
112
- const actionsData = await resActions.json();
113
- setActions(actionsData.items || []);
156
+ const actionsData = await resActions.json()
157
+ setActions(actionsData.items || [])
114
158
 
115
159
  if (resUsage.ok) {
116
- const usageData = await resUsage.json();
117
- setUsage(usageData.usage || {});
160
+ const usageData = await resUsage.json()
161
+ setUsage(usageData.usage || {})
118
162
  }
119
163
  } catch (error) {
120
- toast.error(errorMessage(error, "Lỗi tải dữ liệu"));
164
+ toast.error(errorMessage(error, "Lỗi tải dữ liệu"))
121
165
  } finally {
122
- setLoading(false);
123
- setIsRefreshing(false);
166
+ setLoading(false)
167
+ setIsRefreshing(false)
124
168
  }
125
- };
169
+ }
126
170
 
127
171
  useEffect(() => {
128
- fetchData();
129
- // eslint-disable-next-line react-hooks/exhaustive-deps
130
- }, []);
172
+ fetchData()
173
+ }, [])
131
174
 
132
175
  const handleRefresh = () => {
133
- setIsRefreshing(true);
134
- fetchData();
135
- };
176
+ setIsRefreshing(true)
177
+ fetchData()
178
+ }
136
179
 
137
180
  const toggleRow = (id: string) => {
138
181
  setExpandedRows((prev) => {
139
- const next = new Set(prev);
140
- if (next.has(id)) next.delete(id);
141
- else next.add(id);
142
- return next;
143
- });
144
- };
182
+ const next = new Set(prev)
183
+ if (next.has(id)) next.delete(id)
184
+ else next.add(id)
185
+ return next
186
+ })
187
+ }
145
188
 
146
189
  const filtered = useMemo(() => {
147
- const q = normalize(searchTerm.trim());
190
+ const q = normalize(searchTerm.trim())
148
191
  const list = q
149
192
  ? actions.filter(
150
193
  (a) =>
151
194
  normalize(a.name).includes(q) ||
152
195
  normalize(a.code).includes(q) ||
153
- normalize(a.description || "").includes(q),
196
+ normalize(a.description || "").includes(q)
154
197
  )
155
- : actions;
156
- return [...list].sort((a, b) => a.name.localeCompare(b.name, "vi"));
157
- }, [actions, searchTerm]);
198
+ : actions
199
+ return [...list].sort((a, b) => a.name.localeCompare(b.name, "vi"))
200
+ }, [actions, searchTerm])
158
201
 
159
202
  // --- CRUD ---
160
203
  const openNew = () => {
161
- setEditing(null);
162
- setFormData({ code: "", name: "", description: "" });
163
- setIsDialogOpen(true);
164
- };
204
+ setEditing(null)
205
+ setFormData({ code: "", name: "", description: "" })
206
+ setIsDialogOpen(true)
207
+ }
165
208
 
166
209
  const openEdit = (action: Action) => {
167
- setEditing(action);
210
+ setEditing(action)
168
211
  setFormData({
169
212
  code: action.code,
170
213
  name: action.name,
171
214
  description: action.description || "",
172
- });
173
- setIsDialogOpen(true);
174
- };
215
+ })
216
+ setIsDialogOpen(true)
217
+ }
175
218
 
176
219
  const handleSubmit = async () => {
177
220
  if (!formData.code || !formData.name) {
178
- toast.error("Vui lòng nhập Mã và Tên hành động");
179
- return;
221
+ toast.error("Vui lòng nhập Mã và Tên hành động")
222
+ return
180
223
  }
181
224
  try {
182
- const payload = { ...formData, status: "active" };
225
+ const payload = { ...formData, status: "active" }
183
226
  const res = await fetch("/api/actions", {
184
227
  method: editing ? "PUT" : "POST",
185
228
  headers: { "Content-Type": "application/json" },
186
- body: JSON.stringify(editing ? { id: editing.id, ...payload } : payload),
187
- });
229
+ body: JSON.stringify(
230
+ editing ? { id: editing.id, ...payload } : payload
231
+ ),
232
+ })
188
233
  if (!res.ok) {
189
- await throwFetchError(res, "Operation failed");
234
+ await throwFetchError(res, "Operation failed")
190
235
  }
191
- const saved = await res.json();
236
+ const saved = await res.json()
192
237
  if (editing) {
193
- setActions((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
194
- toast.success("Cập nhật hành động thành công");
238
+ setActions((prev) => prev.map((a) => (a.id === saved.id ? saved : a)))
239
+ toast.success("Cập nhật hành động thành công")
195
240
  } else {
196
- setActions((prev) => [saved, ...prev]);
197
- toast.success("Tạo hành động thành công");
241
+ setActions((prev) => [saved, ...prev])
242
+ toast.success("Tạo hành động thành công")
198
243
  }
199
- setIsDialogOpen(false);
244
+ setIsDialogOpen(false)
200
245
  } catch (error) {
201
- toast.error(errorMessage(error, "Có lỗi xảy ra"));
246
+ toast.error(errorMessage(error, "Có lỗi xảy ra"))
202
247
  }
203
- };
248
+ }
204
249
 
205
250
  const requestDelete = (action: Action) => {
206
- setToDelete(action);
207
- setConfirmOpen(true);
208
- };
251
+ setToDelete(action)
252
+ setConfirmOpen(true)
253
+ }
209
254
 
210
255
  const handleConfirmDelete = async () => {
211
- if (!toDelete) return;
212
- setIsDeleting(true);
256
+ if (!toDelete) return
257
+ setIsDeleting(true)
213
258
  try {
214
259
  const res = await fetch(`/api/actions?id=${toDelete.id}`, {
215
260
  method: "DELETE",
216
- });
261
+ })
217
262
  if (!res.ok) {
218
- await throwFetchError(res, "Delete failed");
263
+ await throwFetchError(res, "Delete failed")
219
264
  }
220
- setActions((prev) => prev.filter((a) => a.id !== toDelete.id));
221
- toast.success("Xóa hành động thành công");
222
- setConfirmOpen(false);
223
- setToDelete(null);
265
+ setActions((prev) => prev.filter((a) => a.id !== toDelete.id))
266
+ toast.success("Xóa hành động thành công")
267
+ setConfirmOpen(false)
268
+ setToDelete(null)
224
269
  } catch (error) {
225
- toast.error(errorMessage(error, "Không thể xóa hành động"));
270
+ toast.error(errorMessage(error, "Không thể xóa hành động"))
226
271
  } finally {
227
- setIsDeleting(false);
272
+ setIsDeleting(false)
228
273
  }
229
- };
274
+ }
230
275
 
231
- const isSearching = searchTerm.trim().length > 0;
276
+ const isSearching = searchTerm.trim().length > 0
232
277
 
233
278
  return (
234
- <section className="w-full space-y-4 px-4 pb-6 pt-2 md:px-6 lg:px-8">
235
- <PageHeader
236
- title="Quản hành động"
237
- description="Danh sách hành động ngữ cảnh sử dụng (tài nguyên · vai trò · người dùng)"
238
- icon={<Zap className="h-5 w-5" />}
239
- actions={
240
- canCreate ? (
241
- <Button
242
- variant="accent"
243
- onClick={openNew}
244
- prependIcon={<Plus className="h-4 w-4" />}
245
- >
246
- <span className="hidden sm:inline">Thêm hành động</span>
247
- <span className="sm:hidden">Thêm</span>
248
- </Button>
249
- ) : undefined
250
- }
251
- />
279
+ <div className="flex flex-col">
280
+ {/* ===== Bar navy 1 dòng — đồng bộ trang vai trò/đơn bán hàng ===== */}
281
+ <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">
282
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center">
283
+ <Zap className="h-4 w-4 text-primary-foreground/80" />
284
+ </span>
285
+ <h1 className="min-w-0 flex-1 truncate px-1 text-base font-bold text-primary-foreground sm:text-lg">
286
+ Quản lý hành động
287
+ </h1>
288
+ <span className="hidden shrink-0 text-xs text-primary-foreground/70 md:inline">
289
+ {actions.length} hành động · dùng cho tài nguyên · vai trò · người
290
+ dùng
291
+ </span>
292
+ {canCreate && (
293
+ <Button
294
+ onClick={openNew}
295
+ className="h-8 shrink-0 bg-primary-foreground px-2.5 text-primary hover:bg-primary-foreground/90 sm:px-3"
296
+ >
297
+ <Plus className="h-4 w-4" />
298
+ <span className="ml-1.5 hidden sm:inline">Thêm hành động</span>
299
+ <span className="ml-1.5 sm:hidden">Thêm</span>
300
+ </Button>
301
+ )}
302
+ </div>
252
303
 
253
- {/* Search + refresh */}
254
- <div className="flex flex-wrap items-center gap-2">
255
- <div className="relative min-w-0 flex-1 sm:max-w-sm sm:flex-none">
256
- <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
257
- <Input
258
- placeholder="Tìm hành động (tên, mã, tả)…"
259
- className="h-9 w-full bg-card pl-9 pr-9"
260
- value={searchTerm}
261
- onChange={(e) => setSearchTerm(e.target.value)}
262
- />
263
- {searchTerm && (
304
+ {/* ===== Khối trắng: toolbar sticky + danh sách ===== */}
305
+ <div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
306
+ {/* Toolbar sticky */}
307
+ <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">
308
+ <div className="relative min-w-[140px] flex-1 sm:max-w-72">
309
+ <Search className="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
310
+ <Input
311
+ placeholder="Tìm hành động (tên, mã, mô tả)…"
312
+ className="h-8 w-full rounded-md border border-border bg-card pl-8 pr-8 text-sm"
313
+ value={searchTerm}
314
+ onChange={(e) => setSearchTerm(e.target.value)}
315
+ />
316
+ {searchTerm && (
317
+ <button
318
+ type="button"
319
+ onClick={() => setSearchTerm("")}
320
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
321
+ title="Xóa tìm kiếm"
322
+ >
323
+ <X className="h-3.5 w-3.5" />
324
+ </button>
325
+ )}
326
+ </div>
327
+ <Button
328
+ variant="outline"
329
+ size="icon"
330
+ onClick={handleRefresh}
331
+ disabled={isRefreshing}
332
+ title="Làm mới"
333
+ className="h-8 w-8 shrink-0"
334
+ >
335
+ <RefreshCw
336
+ className={cn("h-3.5 w-3.5", isRefreshing && "animate-spin")}
337
+ />
338
+ </Button>
339
+ <p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
340
+ {isSearching ? (
341
+ <>
342
+ <span className="font-semibold tabular-nums text-foreground">
343
+ {filtered.length}
344
+ </span>{" "}
345
+ khớp
346
+ </>
347
+ ) : (
348
+ <>
349
+ <span className="font-semibold tabular-nums text-foreground">
350
+ {actions.length}
351
+ </span>{" "}
352
+ hành động
353
+ </>
354
+ )}
355
+ </p>
356
+ {/* Chuyển dạng hiển thị: dòng / thẻ */}
357
+ <div className="flex shrink-0 overflow-hidden rounded-md border border-border">
264
358
  <button
265
359
  type="button"
266
- onClick={() => setSearchTerm("")}
267
- className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
268
- title="Xóa tìm kiếm"
360
+ onClick={() => setView("list")}
361
+ title="Dạng dòng"
362
+ className={cn(
363
+ "px-2 py-1.5 transition-colors",
364
+ view === "list"
365
+ ? "bg-muted text-foreground"
366
+ : "bg-card text-muted-foreground hover:text-foreground"
367
+ )}
269
368
  >
270
- <X className="h-4 w-4" />
369
+ <List className="h-3.5 w-3.5" />
271
370
  </button>
272
- )}
371
+ <button
372
+ type="button"
373
+ onClick={() => setView("grid")}
374
+ title="Dạng thẻ"
375
+ className={cn(
376
+ "border-l border-border px-2 py-1.5 transition-colors",
377
+ view === "grid"
378
+ ? "bg-muted text-foreground"
379
+ : "bg-card text-muted-foreground hover:text-foreground"
380
+ )}
381
+ >
382
+ <LayoutGrid className="h-3.5 w-3.5" />
383
+ </button>
384
+ </div>
273
385
  </div>
274
- <Button
275
- variant="outline"
276
- size="icon"
277
- onClick={handleRefresh}
278
- disabled={isRefreshing}
279
- title="Làm mới"
280
- >
281
- <RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
282
- </Button>
283
- {isSearching && (
284
- <Badge variant="secondary" className="ml-auto font-normal sm:ml-1">
285
- {filtered.length} hành động
286
- </Badge>
287
- )}
288
- </div>
289
386
 
290
- {/* Body */}
291
- {loading && !isRefreshing ? (
292
- <div className="space-y-2 rounded-lg border border-border bg-card p-4">
293
- {Array.from({ length: 8 }).map((_, i) => (
294
- <Skeleton key={i} className="h-14 w-full" />
295
- ))}
296
- </div>
297
- ) : filtered.length === 0 ? (
298
- <div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card py-20 text-center">
299
- <div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted">
300
- <Search className="h-6 w-6 text-muted-foreground" />
387
+ {/* Body */}
388
+ {loading && !isRefreshing ? (
389
+ <div className="space-y-2 p-4">
390
+ {Array.from({ length: 8 }).map((_, i) => (
391
+ <Skeleton key={i} className="h-14 w-full" />
392
+ ))}
301
393
  </div>
302
- <h3 className="text-base font-semibold text-foreground">
303
- {isSearching ? "Không tìm thấy hành động" : "Chưa có hành động nào"}
304
- </h3>
305
- <p className="mt-1 max-w-sm text-sm text-muted-foreground">
306
- {isSearching
307
- ? "Thử từ khóa khác."
308
- : "Tạo hành động đầu tiên để bắt đầu phân quyền."}
309
- </p>
310
- </div>
311
- ) : (
312
- <div className="overflow-hidden rounded-lg border border-border bg-card shadow-sm">
394
+ ) : filtered.length === 0 ? (
395
+ <div className="flex flex-col items-center justify-center py-20 text-center">
396
+ <div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted">
397
+ <Search className="h-6 w-6 text-muted-foreground" />
398
+ </div>
399
+ <h3 className="text-base font-semibold text-foreground">
400
+ {isSearching
401
+ ? "Không tìm thấy hành động"
402
+ : "Chưa có hành động nào"}
403
+ </h3>
404
+ <p className="mt-1 max-w-sm text-sm text-muted-foreground">
405
+ {isSearching
406
+ ? "Thử từ khóa khác."
407
+ : "Tạo hành động đầu tiên để bắt đầu phân quyền."}
408
+ </p>
409
+ </div>
410
+ ) : view === "list" ? (
313
411
  <ul className="divide-y divide-border">
314
- {filtered.map((action) => (
412
+ {filtered.map((action, index) => (
315
413
  <ActionRow
316
414
  key={action.id}
415
+ index={index}
317
416
  action={action}
318
417
  usage={usage[action.code] || EMPTY_USAGE}
319
418
  expanded={expandedRows.has(action.id)}
@@ -325,8 +424,25 @@ export function ActionListPage({ permissions = {} }: ActionListPageProps) {
325
424
  />
326
425
  ))}
327
426
  </ul>
328
- </div>
329
- )}
427
+ ) : (
428
+ <div className="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 xl:grid-cols-3">
429
+ {filtered.map((action, index) => (
430
+ <ActionCard
431
+ key={action.id}
432
+ index={index}
433
+ action={action}
434
+ usage={usage[action.code] || EMPTY_USAGE}
435
+ expanded={expandedRows.has(action.id)}
436
+ canUpdate={canUpdate}
437
+ canDelete={canDelete}
438
+ onToggleExpand={() => toggleRow(action.id)}
439
+ onEdit={openEdit}
440
+ onDelete={requestDelete}
441
+ />
442
+ ))}
443
+ </div>
444
+ )}
445
+ </div>
330
446
 
331
447
  {/* Create / Edit dialog */}
332
448
  <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
@@ -394,14 +510,14 @@ export function ActionListPage({ permissions = {} }: ActionListPageProps) {
394
510
  onConfirm={handleConfirmDelete}
395
511
  loading={isDeleting}
396
512
  />
397
- </section>
398
- );
513
+ </div>
514
+ )
399
515
  }
400
516
 
401
- // --- Một dòng hành động (editorial): tên + mã·mô tả; số liệu dạng chữ; bung ra
402
- // hiển thị quan hệ dạng DANH SÁCH CHỮ. Nút sửa/xóa hiển thị thường trực để
403
- // dùng được trên mobile (không có trạng thái hover). ---
517
+ // --- Một dòng hành động: tên + mã·mô tả; số liệu dạng chữ; bung ra hiển thị
518
+ // quan hệ dạng danh sách chữ. Nút sửa/xóa LUÔN hiện (mobile không có hover). ---
404
519
  function ActionRow({
520
+ index,
405
521
  action,
406
522
  usage,
407
523
  expanded,
@@ -411,14 +527,15 @@ function ActionRow({
411
527
  onEdit,
412
528
  onDelete,
413
529
  }: {
414
- action: Action;
415
- usage: UsageEntry;
416
- expanded: boolean;
417
- canUpdate: boolean;
418
- canDelete: boolean;
419
- onToggleExpand: () => void;
420
- onEdit: (a: Action) => void;
421
- onDelete: (a: Action) => void;
530
+ index: number
531
+ action: Action
532
+ usage: UsageEntry
533
+ expanded: boolean
534
+ canUpdate: boolean
535
+ canDelete: boolean
536
+ onToggleExpand: () => void
537
+ onEdit: (a: Action) => void
538
+ onDelete: (a: Action) => void
422
539
  }) {
423
540
  return (
424
541
  <li>
@@ -429,12 +546,16 @@ function ActionRow({
429
546
  onClick={onToggleExpand}
430
547
  onKeyDown={(e) => {
431
548
  if (e.key === "Enter" || e.key === " ") {
432
- e.preventDefault();
433
- onToggleExpand();
549
+ e.preventDefault()
550
+ onToggleExpand()
434
551
  }
435
552
  }}
436
553
  className="flex cursor-pointer items-center gap-3 bg-card px-4 py-3 transition-colors hover:bg-muted/40"
437
554
  >
555
+ {/* Số thứ tự */}
556
+ <span className="w-6 shrink-0 text-right font-mono text-[11px] tabular-nums text-muted-foreground/60">
557
+ {index + 1}
558
+ </span>
438
559
  {/* Tên + mã · mô tả */}
439
560
  <div className="min-w-0 flex-1">
440
561
  <div className="truncate text-sm font-medium text-foreground">
@@ -454,10 +575,10 @@ function ActionRow({
454
575
  <button
455
576
  type="button"
456
577
  onClick={(e) => {
457
- e.stopPropagation();
458
- onEdit(action);
578
+ e.stopPropagation()
579
+ onEdit(action)
459
580
  }}
460
- 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"
581
+ 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"
461
582
  title="Sửa hành động"
462
583
  >
463
584
  <Pencil className="h-3.5 w-3.5" />
@@ -467,10 +588,10 @@ function ActionRow({
467
588
  <button
468
589
  type="button"
469
590
  onClick={(e) => {
470
- e.stopPropagation();
471
- onDelete(action);
591
+ e.stopPropagation()
592
+ onDelete(action)
472
593
  }}
473
- className="shrink-0 rounded-md border border-border bg-card p-1.5 text-muted-foreground shadow-sm transition-colors hover:border-destructive/30 hover:bg-destructive/10 hover:text-destructive"
594
+ 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"
474
595
  title="Xóa hành động"
475
596
  >
476
597
  <Trash2 className="h-3.5 w-3.5" />
@@ -479,7 +600,7 @@ function ActionRow({
479
600
  <ChevronRight
480
601
  className={cn(
481
602
  "h-4 w-4 shrink-0 text-muted-foreground/50 transition-transform",
482
- expanded && "rotate-90",
603
+ expanded && "rotate-90"
483
604
  )}
484
605
  />
485
606
  </div>
@@ -491,16 +612,19 @@ function ActionRow({
491
612
  <DefRow
492
613
  label="Tài nguyên"
493
614
  empty="Chưa gán cho tài nguyên nào"
615
+ chipClass="border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-300"
494
616
  items={usage.resources.map((r) => ({ key: r.code, text: r.name }))}
495
617
  />
496
618
  <DefRow
497
619
  label="Vai trò"
498
620
  empty="Chưa thuộc vai trò nào"
621
+ chipClass="border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300"
499
622
  items={usage.roles.map((r) => ({ key: r.code, text: r.name }))}
500
623
  />
501
624
  <DefRow
502
625
  label="Người dùng"
503
626
  empty="Chưa có người dùng nào được cấp"
627
+ chipClass="border-border bg-muted/40 text-foreground/80"
504
628
  items={usage.users.map((u) => ({
505
629
  key: u.id,
506
630
  text: u.name || u.email,
@@ -510,28 +634,199 @@ function ActionRow({
510
634
  </div>
511
635
  )}
512
636
  </li>
513
- );
637
+ )
514
638
  }
515
639
 
516
- // Số liệu kiểu chữ: "30 tài nguyên · 8 vai trò · 30 người dùng" (số tabular,
517
- // đậm khi > 0; nhãn + dấu chấm phân tách màu nhạt).
640
+ // --- Thẻ hành động (grid, MẶC ĐỊNH) phong cách thẻ VAI TRÒ: icon loại
641
+ // hành động + tên/mã, badge Mặc định, tả 2 dòng, KHỐI SỐ LIỆU đóng khung
642
+ // (số to 3 cột), hàng nút Sửa/Xóa dưới chân. Bấm thẻ bung chi tiết (full hàng). ---
643
+ function ActionCard({
644
+ index,
645
+ action,
646
+ usage,
647
+ expanded,
648
+ canUpdate,
649
+ canDelete,
650
+ onToggleExpand,
651
+ onEdit,
652
+ onDelete,
653
+ }: {
654
+ index: number
655
+ action: Action
656
+ usage: UsageEntry
657
+ expanded: boolean
658
+ canUpdate: boolean
659
+ canDelete: boolean
660
+ onToggleExpand: () => void
661
+ onEdit: (a: Action) => void
662
+ onDelete: (a: Action) => void
663
+ }) {
664
+ const visual = getActionVisual(action.code)
665
+ const ActionIcon = visual.icon
666
+
667
+ return (
668
+ <div
669
+ role="button"
670
+ tabIndex={0}
671
+ aria-expanded={expanded}
672
+ onClick={onToggleExpand}
673
+ onKeyDown={(e) => {
674
+ if (e.key === "Enter" || e.key === " ") {
675
+ e.preventDefault()
676
+ onToggleExpand()
677
+ }
678
+ }}
679
+ className={cn(
680
+ "cursor-pointer overflow-hidden rounded-[12px] border border-border bg-card p-4 shadow-sm transition-all hover:border-primary/40 hover:shadow-md",
681
+ expanded && "col-span-full border-primary/40"
682
+ )}
683
+ >
684
+ {/* Header: icon loại + tên/mã | badge */}
685
+ <div className="flex items-start justify-between gap-2">
686
+ <div className="flex min-w-0 items-center gap-2.5">
687
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border bg-card shadow-sm">
688
+ <ActionIcon className={cn("h-4 w-4", visual.cls)} />
689
+ </span>
690
+ <div className="min-w-0">
691
+ <div className="truncate text-[15px] font-bold leading-tight text-foreground">
692
+ {action.name}
693
+ </div>
694
+ <code className="font-mono text-[11px] text-muted-foreground">
695
+ {action.code}
696
+ </code>
697
+ </div>
698
+ </div>
699
+ <div className="flex shrink-0 items-center gap-1.5">
700
+ {action.isDefault && (
701
+ <Badge
702
+ variant="outline"
703
+ className="h-5 border-indigo-200 bg-indigo-50 px-1.5 text-[10px] font-semibold text-indigo-600 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-400"
704
+ >
705
+ Mặc định
706
+ </Badge>
707
+ )}
708
+ <span className="font-mono text-[11px] tabular-nums text-muted-foreground/60">
709
+ #{index + 1}
710
+ </span>
711
+ </div>
712
+ </div>
713
+
714
+ {/* Mô tả 2 dòng (giữ chiều cao đều thẻ) */}
715
+ <p className="mt-2 line-clamp-2 min-h-[2.5rem] text-[13px] leading-relaxed text-muted-foreground">
716
+ {action.description || "Chưa có mô tả cho hành động này."}
717
+ </p>
718
+
719
+ {/* Khối số liệu đóng khung — 3 cột số to như thẻ vai trò */}
720
+ <div className="flex items-center justify-between rounded-lg border border-border bg-card p-2.5">
721
+ <div className="flex flex-1 flex-col items-center border-r border-border">
722
+ <span className="mb-1 text-[16px] font-black leading-none tabular-nums text-blue-600 dark:text-blue-400">
723
+ {usage.resources.length}
724
+ </span>
725
+ <span className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground">
726
+ Tài nguyên
727
+ </span>
728
+ </div>
729
+ <div className="flex flex-1 flex-col items-center border-r border-border">
730
+ <span className="mb-1 text-[16px] font-black leading-none tabular-nums text-violet-600 dark:text-violet-400">
731
+ {usage.roles.length}
732
+ </span>
733
+ <span className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground">
734
+ Vai trò
735
+ </span>
736
+ </div>
737
+ <div className="flex flex-1 flex-col items-center">
738
+ <span className="mb-1 text-[16px] font-black leading-none tabular-nums text-emerald-600 dark:text-emerald-500">
739
+ {usage.users.length}
740
+ </span>
741
+ <span className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground">
742
+ Người dùng
743
+ </span>
744
+ </div>
745
+ </div>
746
+
747
+ {/* Hàng nút dưới chân — như thẻ vai trò */}
748
+ {(canUpdate || canDelete) && (
749
+ <div className="flex gap-2 pt-3">
750
+ {canUpdate && (
751
+ <Button
752
+ variant="outline"
753
+ size="sm"
754
+ 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"
755
+ onClick={(e) => {
756
+ e.stopPropagation()
757
+ onEdit(action)
758
+ }}
759
+ >
760
+ <Pencil className="mr-1.5 h-3.5 w-3.5 text-amber-500" />
761
+ Sửa
762
+ </Button>
763
+ )}
764
+ {canDelete && (
765
+ <Button
766
+ variant="outline"
767
+ size="sm"
768
+ 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"
769
+ onClick={(e) => {
770
+ e.stopPropagation()
771
+ onDelete(action)
772
+ }}
773
+ >
774
+ <Trash2 className="mr-1.5 h-3.5 w-3.5" />
775
+ Xóa
776
+ </Button>
777
+ )}
778
+ </div>
779
+ )}
780
+
781
+ {/* Bung: chi tiết chip (thẻ giãn full hàng) */}
782
+ {expanded && (
783
+ <div className="mt-3 space-y-2.5 border-t border-border pt-3">
784
+ <DefRow
785
+ label="Tài nguyên"
786
+ empty="Chưa gán cho tài nguyên nào"
787
+ chipClass="border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-300"
788
+ items={usage.resources.map((r) => ({ key: r.code, text: r.name }))}
789
+ />
790
+ <DefRow
791
+ label="Vai trò"
792
+ empty="Chưa thuộc vai trò nào"
793
+ chipClass="border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300"
794
+ items={usage.roles.map((r) => ({ key: r.code, text: r.name }))}
795
+ />
796
+ <DefRow
797
+ label="Người dùng"
798
+ empty="Chưa có người dùng nào được cấp"
799
+ chipClass="border-border bg-muted/40 text-foreground/80"
800
+ items={usage.users.map((u) => ({
801
+ key: u.id,
802
+ text: u.name || u.email,
803
+ title: u.email,
804
+ }))}
805
+ />
806
+ </div>
807
+ )}
808
+ </div>
809
+ )
810
+ }
811
+
812
+ // Số liệu kiểu chữ: "30 tài nguyên · 8 vai trò · 30 người dùng"
518
813
  function Metrics({
519
814
  usage,
520
815
  className,
521
816
  }: {
522
- usage: UsageEntry;
523
- className?: string;
817
+ usage: UsageEntry
818
+ className?: string
524
819
  }) {
525
820
  const parts = [
526
821
  { n: usage.resources.length, label: "tài nguyên" },
527
822
  { n: usage.roles.length, label: "vai trò" },
528
823
  { n: usage.users.length, label: "người dùng" },
529
- ];
824
+ ]
530
825
  return (
531
826
  <div
532
827
  className={cn(
533
828
  "shrink-0 whitespace-nowrap text-xs text-muted-foreground",
534
- className,
829
+ className
535
830
  )}
536
831
  >
537
832
  {parts.map((p, i) => (
@@ -540,7 +835,7 @@ function Metrics({
540
835
  <span
541
836
  className={cn(
542
837
  "tabular-nums",
543
- p.n > 0 && "font-medium text-foreground",
838
+ p.n > 0 && "font-medium text-foreground"
544
839
  )}
545
840
  >
546
841
  {p.n}
@@ -549,39 +844,74 @@ function Metrics({
549
844
  </span>
550
845
  ))}
551
846
  </div>
552
- );
847
+ )
553
848
  }
554
849
 
555
- // 1 dòng quan hệ: nhãn (small caps) + số đếm | danh sách tên phân tách dấu phẩy.
850
+ // 1 dòng quan hệ: nhãn (small caps) + số đếm | CHIP wrap ngang (thay cho
851
+ // danh sách phẩy khó quét). Quá MAX_DEF_CHIPS → nút "+n khác" / "Thu gọn".
852
+ const MAX_DEF_CHIPS = 15
853
+
556
854
  function DefRow({
557
855
  label,
558
856
  empty,
559
857
  items,
858
+ chipClass,
560
859
  }: {
561
- label: string;
562
- empty: string;
563
- items: { key: string; text: string; title?: string }[];
860
+ label: string
861
+ empty: string
862
+ items: { key: string; text: string; title?: string }[]
863
+ chipClass?: string
564
864
  }) {
865
+ const [showAll, setShowAll] = useState(false)
866
+ const shown = showAll ? items : items.slice(0, MAX_DEF_CHIPS)
867
+ const overflow = items.length - shown.length
868
+
565
869
  return (
566
- <div className="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
567
- <div className="flex w-28 shrink-0 items-baseline gap-1.5 pt-px text-[11px] uppercase tracking-wide text-muted-foreground">
870
+ <div className="flex flex-col gap-1 sm:flex-row sm:gap-4">
871
+ <div className="flex w-28 shrink-0 items-baseline gap-1.5 pt-0.5 text-[11px] uppercase tracking-wide text-muted-foreground">
568
872
  <span className="font-medium">{label}</span>
569
873
  <span className="tabular-nums text-muted-foreground/60">
570
874
  {items.length}
571
875
  </span>
572
876
  </div>
573
- <div className="flex-1 text-xs leading-relaxed text-foreground/80">
877
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
574
878
  {items.length === 0 ? (
575
- <span className="italic text-muted-foreground">{empty}</span>
879
+ <span className="text-xs italic text-muted-foreground">{empty}</span>
576
880
  ) : (
577
- items.map((it, i) => (
578
- <span key={it.key}>
579
- {i > 0 ? ", " : ""}
580
- <span title={it.title}>{it.text}</span>
581
- </span>
582
- ))
881
+ <>
882
+ {shown.map((it) => (
883
+ <span
884
+ key={it.key}
885
+ title={it.title}
886
+ className={cn(
887
+ "inline-flex max-w-56 items-center truncate rounded-md border px-1.5 py-0.5 text-[11px] leading-4",
888
+ chipClass || "border-border bg-card text-foreground/80"
889
+ )}
890
+ >
891
+ {it.text}
892
+ </span>
893
+ ))}
894
+ {overflow > 0 && (
895
+ <button
896
+ type="button"
897
+ onClick={() => setShowAll(true)}
898
+ className="rounded-md px-1.5 py-0.5 text-[11px] font-medium text-primary hover:bg-primary/5"
899
+ >
900
+ +{overflow} khác
901
+ </button>
902
+ )}
903
+ {showAll && items.length > MAX_DEF_CHIPS && (
904
+ <button
905
+ type="button"
906
+ onClick={() => setShowAll(false)}
907
+ className="rounded-md px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-muted/50"
908
+ >
909
+ Thu gọn
910
+ </button>
911
+ )}
912
+ </>
583
913
  )}
584
914
  </div>
585
915
  </div>
586
- );
916
+ )
587
917
  }