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