@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.
- package/CHANGELOG.md +18 -0
- package/package.json +1 -1
- package/src/rbac/pages/action-list-page.tsx +568 -238
- package/src/rbac/pages/resource-list-page.tsx +774 -535
- package/src/rbac/pages/role-form-page.tsx +354 -281
|
@@ -1,25 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
// Trang "Quản lý hành động" —
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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ò là
|
|
5
|
+
// MẶC ĐỊNH (toggle List/Grid); dòng có #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
|
|
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 {
|
|
39
|
-
import {
|
|
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 [
|
|
83
|
-
const [
|
|
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
|
-
|
|
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(
|
|
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
|
-
<
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
{/*
|
|
254
|
-
<div className="
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
<
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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={() =>
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
398
|
-
)
|
|
513
|
+
</div>
|
|
514
|
+
)
|
|
399
515
|
}
|
|
400
516
|
|
|
401
|
-
// --- Một dòng hành động
|
|
402
|
-
//
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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-
|
|
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-
|
|
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
|
-
//
|
|
517
|
-
//
|
|
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, mô 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 |
|
|
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-
|
|
567
|
-
<div className="flex w-28 shrink-0 items-baseline gap-1.5 pt-
|
|
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
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
}
|