@goplusvn/core 0.1.24 → 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 +31 -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 +506 -462
|
@@ -1,23 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
// Trang tạo/sửa VAI TRÒ —
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Trang tạo/sửa VAI TRÒ — bar navy 1 DÒNG kiểu chi tiết đơn bán hàng
|
|
4
|
+
// (← tên inline · chip mã + copy · CHIP TRẠNG THÁI màu bấm-đổi · mô tả ·
|
|
5
|
+
// Hủy/Lưu), full width; khối trắng: toolbar sticky (đếm x/y quyền realtime),
|
|
6
|
+
// nhóm = dòng mảnh, tài nguyên = dòng + CHIP quyền (checkbox + icon lucide
|
|
7
|
+
// theo loại action, prop actionLabels bổ sung nhãn nghiệp vụ app).
|
|
6
8
|
// API contract: POST /api/roles, PUT /api/roles/:id.
|
|
7
|
-
|
|
8
|
-
import
|
|
9
|
-
import { useRouter } from "next/navigation";
|
|
10
|
-
import { toast } from "sonner";
|
|
11
|
-
import {
|
|
12
|
-
ArrowLeft,
|
|
13
|
-
Check,
|
|
14
|
-
ChevronDown,
|
|
15
|
-
ChevronRight,
|
|
16
|
-
Save,
|
|
17
|
-
Search,
|
|
18
|
-
X,
|
|
19
|
-
} from "lucide-react";
|
|
20
|
-
|
|
9
|
+
import * as React from "react"
|
|
10
|
+
import { useRouter } from "next/navigation"
|
|
21
11
|
import {
|
|
22
12
|
Badge,
|
|
23
13
|
Button,
|
|
@@ -29,19 +19,47 @@ import {
|
|
|
29
19
|
SelectTrigger,
|
|
30
20
|
SelectValue,
|
|
31
21
|
Switch,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
import {
|
|
35
|
-
import
|
|
22
|
+
getStatusMeta,
|
|
23
|
+
} from "../../ui"
|
|
24
|
+
import { toast } from "sonner"
|
|
25
|
+
import {
|
|
26
|
+
ArrowLeft,
|
|
27
|
+
BadgeCheck,
|
|
28
|
+
Ban,
|
|
29
|
+
Bot,
|
|
30
|
+
Check,
|
|
31
|
+
ChevronDown,
|
|
32
|
+
ChevronRight,
|
|
33
|
+
Copy,
|
|
34
|
+
Download,
|
|
35
|
+
Eye,
|
|
36
|
+
PenLine,
|
|
37
|
+
Pencil,
|
|
38
|
+
Plus,
|
|
39
|
+
Printer,
|
|
40
|
+
RefreshCw,
|
|
41
|
+
Save,
|
|
42
|
+
Search,
|
|
43
|
+
Settings2,
|
|
44
|
+
Trash2,
|
|
45
|
+
Upload,
|
|
46
|
+
X,
|
|
47
|
+
Zap,
|
|
48
|
+
} from "lucide-react"
|
|
49
|
+
|
|
50
|
+
import type { CrudPermissions } from "../../types"
|
|
51
|
+
import type { LucideIcon } from "lucide-react"
|
|
52
|
+
|
|
53
|
+
import { errorMessage, throwFetchError } from "../lib/fetch-error"
|
|
54
|
+
import { cn } from "../../utils"
|
|
36
55
|
|
|
37
56
|
export interface ActionLabelConfig {
|
|
38
|
-
label: string
|
|
39
|
-
icon: string
|
|
40
|
-
color: string
|
|
57
|
+
label: string
|
|
58
|
+
icon: string
|
|
59
|
+
color: string
|
|
41
60
|
}
|
|
42
61
|
|
|
43
|
-
// Nhãn
|
|
44
|
-
// để bổ sung/ghi đè cho các action nghiệp vụ riêng.
|
|
62
|
+
// Nhãn mặc định cho action CRUD chuẩn (giữ tương thích prop actionLabels).
|
|
45
63
|
const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
|
|
46
64
|
view: { label: "Xem", icon: "👁️", color: "text-blue-600" },
|
|
47
65
|
create: { label: "Tạo", icon: "➕", color: "text-green-600" },
|
|
@@ -53,40 +71,71 @@ const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
|
|
|
53
71
|
manage: { label: "Quản lý", icon: "🔧", color: "text-orange-600" },
|
|
54
72
|
edit_items: { label: "Sửa chi tiết", icon: "📝", color: "text-amber-600" },
|
|
55
73
|
delete_items: { label: "Xóa chi tiết", icon: "❌", color: "text-rose-600" },
|
|
56
|
-
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- Icon + màu theo LOẠI action (suy từ mã, giống ACTION_TONES trang tài
|
|
77
|
+
// nguyên) — mọi action đều có icon mà không cần cấu hình từng cái. ---
|
|
78
|
+
const ACTION_VISUAL_RULES: Array<{
|
|
79
|
+
re: RegExp
|
|
80
|
+
icon: LucideIcon
|
|
81
|
+
cls: string
|
|
82
|
+
}> = [
|
|
83
|
+
{ re: /^(view|read|get|list)/, icon: Eye, cls: "text-blue-500" },
|
|
84
|
+
{ re: /^(create|add|new)/, icon: Plus, cls: "text-emerald-500" },
|
|
85
|
+
{ re: /^(update|edit|change|set)/, icon: Pencil, cls: "text-amber-500" },
|
|
86
|
+
{ re: /^(delete|remove)/, icon: Trash2, cls: "text-rose-500" },
|
|
87
|
+
{ re: /^(cancel|void|reject)/, icon: Ban, cls: "text-rose-500" },
|
|
88
|
+
{
|
|
89
|
+
re: /^(approve|confirm|verify|accept)/,
|
|
90
|
+
icon: BadgeCheck,
|
|
91
|
+
cls: "text-violet-500",
|
|
92
|
+
},
|
|
93
|
+
{ re: /^sign/, icon: PenLine, cls: "text-indigo-500" },
|
|
94
|
+
{ re: /^import/, icon: Download, cls: "text-purple-500" },
|
|
95
|
+
{ re: /^export/, icon: Upload, cls: "text-indigo-500" },
|
|
96
|
+
{ re: /^print/, icon: Printer, cls: "text-cyan-600" },
|
|
97
|
+
{ re: /^sync/, icon: RefreshCw, cls: "text-cyan-600" },
|
|
98
|
+
{ re: /^(manage|config)/, icon: Settings2, cls: "text-orange-500" },
|
|
99
|
+
{ re: /^auto/, icon: Bot, cls: "text-purple-500" },
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
const getActionVisual = (code: string) =>
|
|
103
|
+
ACTION_VISUAL_RULES.find((r) => r.re.test(code)) ?? {
|
|
104
|
+
icon: Zap,
|
|
105
|
+
cls: "text-slate-400",
|
|
106
|
+
}
|
|
57
107
|
|
|
58
108
|
export interface RoleFormPageProps {
|
|
59
|
-
dictionary: any
|
|
60
|
-
permissions?: CrudPermissions
|
|
61
|
-
lang: string
|
|
109
|
+
dictionary: any
|
|
110
|
+
permissions?: CrudPermissions
|
|
111
|
+
lang: string
|
|
62
112
|
resources: Array<{
|
|
63
|
-
id: string
|
|
64
|
-
code: string
|
|
65
|
-
name: string
|
|
66
|
-
group: string | null
|
|
67
|
-
description: string | null
|
|
68
|
-
icon: string | null
|
|
69
|
-
order: number | null
|
|
70
|
-
config: any
|
|
71
|
-
}
|
|
113
|
+
id: string
|
|
114
|
+
code: string
|
|
115
|
+
name: string
|
|
116
|
+
group: string | null
|
|
117
|
+
description: string | null
|
|
118
|
+
icon: string | null
|
|
119
|
+
order: number | null
|
|
120
|
+
config: any
|
|
121
|
+
}>
|
|
72
122
|
actions: Array<{
|
|
73
|
-
id: string
|
|
74
|
-
code: string
|
|
75
|
-
name: string
|
|
76
|
-
description: string | null
|
|
77
|
-
isDefault?: boolean
|
|
78
|
-
}
|
|
79
|
-
mode: "create" | "edit"
|
|
123
|
+
id: string
|
|
124
|
+
code: string
|
|
125
|
+
name: string
|
|
126
|
+
description: string | null
|
|
127
|
+
isDefault?: boolean
|
|
128
|
+
}>
|
|
129
|
+
mode: "create" | "edit"
|
|
80
130
|
initialData?: {
|
|
81
|
-
id: string
|
|
82
|
-
name: string
|
|
83
|
-
code: string
|
|
84
|
-
description: string | undefined
|
|
85
|
-
status: string
|
|
86
|
-
permissions: string[]
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
actionLabels?: Record<string, ActionLabelConfig>;
|
|
131
|
+
id: string
|
|
132
|
+
name: string
|
|
133
|
+
code: string
|
|
134
|
+
description: string | undefined
|
|
135
|
+
status: string
|
|
136
|
+
permissions: string[]
|
|
137
|
+
}
|
|
138
|
+
actionLabels?: Record<string, ActionLabelConfig>
|
|
90
139
|
}
|
|
91
140
|
|
|
92
141
|
export function RoleFormPage({
|
|
@@ -99,15 +148,15 @@ export function RoleFormPage({
|
|
|
99
148
|
initialData,
|
|
100
149
|
actionLabels,
|
|
101
150
|
}: RoleFormPageProps) {
|
|
102
|
-
const router = useRouter()
|
|
151
|
+
const router = useRouter()
|
|
103
152
|
|
|
104
153
|
const mergedActionLabels = React.useMemo(
|
|
105
154
|
() => ({ ...DEFAULT_ACTION_LABELS, ...actionLabels }),
|
|
106
|
-
[actionLabels]
|
|
107
|
-
)
|
|
155
|
+
[actionLabels]
|
|
156
|
+
)
|
|
108
157
|
|
|
109
|
-
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
|
110
|
-
const [searchTerm, setSearchTerm] = React.useState("")
|
|
158
|
+
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
|
159
|
+
const [searchTerm, setSearchTerm] = React.useState("")
|
|
111
160
|
|
|
112
161
|
const [formData, setFormData] = React.useState({
|
|
113
162
|
name: initialData?.name || "",
|
|
@@ -115,86 +164,76 @@ export function RoleFormPage({
|
|
|
115
164
|
description: initialData?.description || "",
|
|
116
165
|
status: (initialData?.status || "active") as "active" | "inactive",
|
|
117
166
|
permissions: initialData?.permissions || ([] as string[]),
|
|
118
|
-
})
|
|
167
|
+
})
|
|
119
168
|
|
|
120
|
-
const [activeGroup, setActiveGroup] = React.useState<string | null>("all")
|
|
121
|
-
// Mặc định mở rộng tất cả (Collapsed = false)
|
|
169
|
+
const [activeGroup, setActiveGroup] = React.useState<string | null>("all")
|
|
122
170
|
const [collapsedGroups, setCollapsedGroups] = React.useState<
|
|
123
171
|
Record<string, boolean>
|
|
124
|
-
>({})
|
|
172
|
+
>({})
|
|
125
173
|
|
|
126
174
|
const toggleGroupCollapse = (groupName: string) => {
|
|
127
175
|
setCollapsedGroups((prev) => ({
|
|
128
176
|
...prev,
|
|
129
177
|
[groupName]: !prev[groupName],
|
|
130
|
-
}))
|
|
131
|
-
}
|
|
178
|
+
}))
|
|
179
|
+
}
|
|
132
180
|
|
|
133
|
-
// Helper to get localized text from dictionary
|
|
134
181
|
const getLocalizedName = React.useCallback(
|
|
135
182
|
(key: string | null) => {
|
|
136
|
-
if (!key) return ""
|
|
137
|
-
if (!dictionary) return key
|
|
183
|
+
if (!key) return ""
|
|
184
|
+
if (!dictionary) return key
|
|
138
185
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
let current = dictionary;
|
|
186
|
+
const parts = key.split(".")
|
|
187
|
+
let current = dictionary
|
|
142
188
|
for (const part of parts) {
|
|
143
189
|
if (current && typeof current === "object" && part in current) {
|
|
144
|
-
current = current[part]
|
|
190
|
+
current = current[part]
|
|
145
191
|
} else {
|
|
146
|
-
return key
|
|
192
|
+
return key
|
|
147
193
|
}
|
|
148
194
|
}
|
|
149
|
-
return typeof current === "string" ? current : key
|
|
195
|
+
return typeof current === "string" ? current : key
|
|
150
196
|
},
|
|
151
|
-
[dictionary]
|
|
152
|
-
)
|
|
197
|
+
[dictionary]
|
|
198
|
+
)
|
|
153
199
|
|
|
154
|
-
// Build dynamic permission matrix
|
|
155
200
|
const permissionMatrix = React.useMemo(() => {
|
|
156
201
|
return resources.map((resource) => {
|
|
157
|
-
|
|
158
|
-
let allowedActions: string[] = [];
|
|
202
|
+
let allowedActions: string[] = []
|
|
159
203
|
const defaultActions = actions
|
|
160
204
|
.filter((a) => a.isDefault)
|
|
161
|
-
.map((a) => a.code)
|
|
162
|
-
// Fallback to hardcoded if no default actions found (safety check)
|
|
205
|
+
.map((a) => a.code)
|
|
163
206
|
const effectiveDefaultActions =
|
|
164
207
|
defaultActions.length > 0
|
|
165
208
|
? defaultActions
|
|
166
|
-
: ["view", "create", "update", "delete"]
|
|
209
|
+
: ["view", "create", "update", "delete"]
|
|
167
210
|
|
|
168
211
|
if (resource.config) {
|
|
169
212
|
try {
|
|
170
213
|
const config =
|
|
171
214
|
typeof resource.config === "string"
|
|
172
215
|
? JSON.parse(resource.config)
|
|
173
|
-
: resource.config
|
|
216
|
+
: resource.config
|
|
174
217
|
|
|
175
218
|
if (config.actions && Array.isArray(config.actions)) {
|
|
176
|
-
|
|
177
|
-
const allowedCodes = config.actions as string[];
|
|
219
|
+
const allowedCodes = config.actions as string[]
|
|
178
220
|
allowedActions = actions
|
|
179
221
|
.map((a) => a.code)
|
|
180
|
-
.filter((code) => allowedCodes.includes(code))
|
|
222
|
+
.filter((code) => allowedCodes.includes(code))
|
|
181
223
|
} else {
|
|
182
|
-
// Config exists but no actions array -> use defaults
|
|
183
224
|
allowedActions = actions
|
|
184
225
|
.map((a) => a.code)
|
|
185
|
-
.filter((code) => effectiveDefaultActions.includes(code))
|
|
226
|
+
.filter((code) => effectiveDefaultActions.includes(code))
|
|
186
227
|
}
|
|
187
228
|
} catch (_e) {
|
|
188
|
-
// Ignore config parsing errors, fallback to defaults
|
|
189
229
|
allowedActions = actions
|
|
190
230
|
.map((a) => a.code)
|
|
191
|
-
.filter((code) => effectiveDefaultActions.includes(code))
|
|
231
|
+
.filter((code) => effectiveDefaultActions.includes(code))
|
|
192
232
|
}
|
|
193
233
|
} else {
|
|
194
|
-
// Case 1: No config -> use defaults
|
|
195
234
|
allowedActions = actions
|
|
196
235
|
.map((a) => a.code)
|
|
197
|
-
.filter((code) => effectiveDefaultActions.includes(code))
|
|
236
|
+
.filter((code) => effectiveDefaultActions.includes(code))
|
|
198
237
|
}
|
|
199
238
|
|
|
200
239
|
return {
|
|
@@ -204,153 +243,151 @@ export function RoleFormPage({
|
|
|
204
243
|
group: getLocalizedName(resource.group) || "Khác",
|
|
205
244
|
description: resource.description,
|
|
206
245
|
actions: allowedActions,
|
|
207
|
-
}
|
|
208
|
-
})
|
|
209
|
-
}, [resources, actions, getLocalizedName])
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
}, [resources, actions, getLocalizedName])
|
|
249
|
+
|
|
250
|
+
const totalAvailablePermissions = React.useMemo(
|
|
251
|
+
() => permissionMatrix.reduce((sum, r) => sum + r.actions.length, 0),
|
|
252
|
+
[permissionMatrix]
|
|
253
|
+
)
|
|
210
254
|
|
|
211
|
-
// Filter matrix based on search
|
|
212
255
|
const filteredMatrix = React.useMemo(() => {
|
|
213
|
-
if (!searchTerm.trim()) return permissionMatrix
|
|
256
|
+
if (!searchTerm.trim()) return permissionMatrix
|
|
214
257
|
|
|
215
|
-
const search = searchTerm.toLowerCase()
|
|
258
|
+
const search = searchTerm.toLowerCase()
|
|
216
259
|
return permissionMatrix.filter(
|
|
217
260
|
(resource) =>
|
|
218
261
|
resource.name.toLowerCase().includes(search) ||
|
|
219
262
|
resource.resource.toLowerCase().includes(search) ||
|
|
220
|
-
resource.group.toLowerCase().includes(search)
|
|
221
|
-
)
|
|
222
|
-
}, [searchTerm, permissionMatrix])
|
|
263
|
+
resource.group.toLowerCase().includes(search)
|
|
264
|
+
)
|
|
265
|
+
}, [searchTerm, permissionMatrix])
|
|
223
266
|
|
|
224
|
-
// Group resources by group
|
|
225
267
|
const groupedMatrix = React.useMemo(() => {
|
|
226
|
-
const groups: Record<string, typeof permissionMatrix> = {}
|
|
268
|
+
const groups: Record<string, typeof permissionMatrix> = {}
|
|
227
269
|
filteredMatrix.forEach((resource) => {
|
|
228
|
-
const group = resource.group
|
|
270
|
+
const group = resource.group
|
|
229
271
|
if (!groups[group]) {
|
|
230
|
-
groups[group] = []
|
|
272
|
+
groups[group] = []
|
|
231
273
|
}
|
|
232
|
-
groups[group].push(resource)
|
|
233
|
-
})
|
|
234
|
-
return groups
|
|
235
|
-
}, [filteredMatrix])
|
|
274
|
+
groups[group].push(resource)
|
|
275
|
+
})
|
|
276
|
+
return groups
|
|
277
|
+
}, [filteredMatrix])
|
|
236
278
|
|
|
237
|
-
// Set initial active group or update if filtered
|
|
238
279
|
React.useEffect(() => {
|
|
239
|
-
const groups = Object.keys(groupedMatrix)
|
|
280
|
+
const groups = Object.keys(groupedMatrix)
|
|
240
281
|
if (groups.length > 0) {
|
|
241
|
-
// Keep 'all' if selected, otherwise fallback to first group if current selection invalid
|
|
242
282
|
if (
|
|
243
283
|
activeGroup !== "all" &&
|
|
244
284
|
(!activeGroup || !groups.includes(activeGroup))
|
|
245
285
|
) {
|
|
246
|
-
setActiveGroup("all")
|
|
286
|
+
setActiveGroup("all")
|
|
247
287
|
}
|
|
248
288
|
} else {
|
|
249
|
-
setActiveGroup(null)
|
|
289
|
+
setActiveGroup(null)
|
|
250
290
|
}
|
|
251
|
-
}, [groupedMatrix, activeGroup])
|
|
291
|
+
}, [groupedMatrix, activeGroup])
|
|
252
292
|
|
|
253
|
-
// Permission helpers
|
|
254
293
|
const hasPermission = (resource: string, action: string): boolean => {
|
|
255
|
-
return formData.permissions.includes(`${action}:${resource}`)
|
|
256
|
-
}
|
|
294
|
+
return formData.permissions.includes(`${action}:${resource}`)
|
|
295
|
+
}
|
|
257
296
|
|
|
258
297
|
const togglePermission = (resource: string, action: string) => {
|
|
259
|
-
const permission = `${action}:${resource}
|
|
260
|
-
let newPermissions = [...formData.permissions]
|
|
298
|
+
const permission = `${action}:${resource}`
|
|
299
|
+
let newPermissions = [...formData.permissions]
|
|
261
300
|
|
|
262
301
|
if (newPermissions.includes(permission)) {
|
|
263
|
-
newPermissions = newPermissions.filter((p) => p !== permission)
|
|
302
|
+
newPermissions = newPermissions.filter((p) => p !== permission)
|
|
264
303
|
} else {
|
|
265
|
-
newPermissions.push(permission)
|
|
304
|
+
newPermissions.push(permission)
|
|
266
305
|
}
|
|
267
|
-
setFormData({ ...formData, permissions: newPermissions })
|
|
268
|
-
}
|
|
306
|
+
setFormData({ ...formData, permissions: newPermissions })
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const groupPermissionCodes = React.useCallback(
|
|
310
|
+
(groupName: string) => {
|
|
311
|
+
const groupResources = groupedMatrix[groupName] || []
|
|
312
|
+
return groupResources.flatMap((r) =>
|
|
313
|
+
actions
|
|
314
|
+
.filter((a) => r.actions.includes(a.code))
|
|
315
|
+
.map((a) => `${a.code}:${r.resource}`)
|
|
316
|
+
)
|
|
317
|
+
},
|
|
318
|
+
[groupedMatrix, actions]
|
|
319
|
+
)
|
|
269
320
|
|
|
270
321
|
const toggleAllForGroup = (groupName: string, enable: boolean) => {
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
actions
|
|
274
|
-
.filter((a) => r.actions.includes(a.code))
|
|
275
|
-
.map((a) => `${a.code}:${r.resource}`),
|
|
276
|
-
);
|
|
277
|
-
|
|
278
|
-
let newPermissions = [...formData.permissions];
|
|
322
|
+
const groupPermissions = groupPermissionCodes(groupName)
|
|
323
|
+
let newPermissions = [...formData.permissions]
|
|
279
324
|
|
|
280
325
|
if (enable) {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
newPermissions = [...newPermissions, ...toAdd];
|
|
326
|
+
const toAdd = groupPermissions.filter((p) => !newPermissions.includes(p))
|
|
327
|
+
newPermissions = [...newPermissions, ...toAdd]
|
|
284
328
|
} else {
|
|
285
|
-
// Remove all permissions for this group
|
|
286
329
|
newPermissions = newPermissions.filter(
|
|
287
|
-
(p) => !groupPermissions.includes(p)
|
|
288
|
-
)
|
|
330
|
+
(p) => !groupPermissions.includes(p)
|
|
331
|
+
)
|
|
289
332
|
}
|
|
290
333
|
|
|
291
|
-
setFormData({ ...formData, permissions: newPermissions })
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
);
|
|
302
|
-
return (
|
|
303
|
-
groupPermissions.length > 0 &&
|
|
304
|
-
groupPermissions.every((p) => formData.permissions.includes(p))
|
|
305
|
-
);
|
|
306
|
-
};
|
|
334
|
+
setFormData({ ...formData, permissions: newPermissions })
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const groupSelectedCount = (groupName: string) => {
|
|
338
|
+
const codes = groupPermissionCodes(groupName)
|
|
339
|
+
return {
|
|
340
|
+
selected: codes.filter((p) => formData.permissions.includes(p)).length,
|
|
341
|
+
total: codes.length,
|
|
342
|
+
}
|
|
343
|
+
}
|
|
307
344
|
|
|
308
345
|
const toggleAllForResource = (
|
|
309
346
|
resourceCode: string,
|
|
310
347
|
resourceActions: string[],
|
|
311
|
-
check: boolean
|
|
348
|
+
check: boolean
|
|
312
349
|
) => {
|
|
313
|
-
let newPermissions = [...formData.permissions]
|
|
350
|
+
let newPermissions = [...formData.permissions]
|
|
314
351
|
|
|
315
352
|
resourceActions.forEach((actionCode) => {
|
|
316
|
-
const permission = `${actionCode}:${resourceCode}
|
|
353
|
+
const permission = `${actionCode}:${resourceCode}`
|
|
317
354
|
if (check && !newPermissions.includes(permission)) {
|
|
318
|
-
newPermissions.push(permission)
|
|
355
|
+
newPermissions.push(permission)
|
|
319
356
|
} else if (!check && newPermissions.includes(permission)) {
|
|
320
|
-
newPermissions = newPermissions.filter((p) => p !== permission)
|
|
357
|
+
newPermissions = newPermissions.filter((p) => p !== permission)
|
|
321
358
|
}
|
|
322
|
-
})
|
|
359
|
+
})
|
|
323
360
|
|
|
324
|
-
setFormData((prev) => ({ ...prev, permissions: newPermissions }))
|
|
325
|
-
}
|
|
361
|
+
setFormData((prev) => ({ ...prev, permissions: newPermissions }))
|
|
362
|
+
}
|
|
326
363
|
|
|
327
364
|
const isResourceFullPermission = (
|
|
328
365
|
resourceCode: string,
|
|
329
|
-
resourceActions: string[]
|
|
366
|
+
resourceActions: string[]
|
|
330
367
|
) => {
|
|
331
|
-
if (resourceActions.length === 0) return false
|
|
368
|
+
if (resourceActions.length === 0) return false
|
|
332
369
|
return resourceActions.every((actionCode) =>
|
|
333
|
-
formData.permissions.includes(`${actionCode}:${resourceCode}`)
|
|
334
|
-
)
|
|
335
|
-
}
|
|
370
|
+
formData.permissions.includes(`${actionCode}:${resourceCode}`)
|
|
371
|
+
)
|
|
372
|
+
}
|
|
336
373
|
|
|
337
374
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
338
|
-
e.preventDefault()
|
|
339
|
-
setIsSubmitting(true)
|
|
375
|
+
e.preventDefault()
|
|
376
|
+
setIsSubmitting(true)
|
|
340
377
|
|
|
341
378
|
try {
|
|
342
379
|
const url =
|
|
343
|
-
mode === "create" ? "/api/roles" : `/api/roles/${initialData?.id}
|
|
344
|
-
const method = mode === "create" ? "POST" : "PUT"
|
|
380
|
+
mode === "create" ? "/api/roles" : `/api/roles/${initialData?.id}`
|
|
381
|
+
const method = mode === "create" ? "POST" : "PUT"
|
|
345
382
|
|
|
346
383
|
const res = await fetch(url, {
|
|
347
384
|
method,
|
|
348
385
|
headers: { "Content-Type": "application/json" },
|
|
349
386
|
body: JSON.stringify(formData),
|
|
350
|
-
})
|
|
387
|
+
})
|
|
351
388
|
|
|
352
389
|
if (!res.ok) {
|
|
353
|
-
await throwFetchError(res, `Failed to ${mode} role`)
|
|
390
|
+
await throwFetchError(res, `Failed to ${mode} role`)
|
|
354
391
|
}
|
|
355
392
|
|
|
356
393
|
toast.success("Thành công", {
|
|
@@ -358,125 +395,144 @@ export function RoleFormPage({
|
|
|
358
395
|
mode === "create"
|
|
359
396
|
? "Đã tạo vai trò mới thành công"
|
|
360
397
|
: "Đã cập nhật vai trò thành công",
|
|
361
|
-
})
|
|
398
|
+
})
|
|
362
399
|
|
|
363
|
-
router.push(`/${lang}/roles`)
|
|
364
|
-
router.refresh()
|
|
400
|
+
router.push(`/${lang}/roles`)
|
|
401
|
+
router.refresh()
|
|
365
402
|
} catch (error: any) {
|
|
366
|
-
toast.error(errorMessage(error, "Lỗi"))
|
|
403
|
+
toast.error(errorMessage(error, "Lỗi"))
|
|
367
404
|
} finally {
|
|
368
|
-
setIsSubmitting(false)
|
|
405
|
+
setIsSubmitting(false)
|
|
369
406
|
}
|
|
370
|
-
}
|
|
407
|
+
}
|
|
371
408
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
<Button
|
|
378
|
-
variant="ghost"
|
|
379
|
-
size="icon"
|
|
380
|
-
onClick={() => router.push(`/${lang}/roles`)}
|
|
381
|
-
className="mt-1 shrink-0"
|
|
382
|
-
>
|
|
383
|
-
<ArrowLeft className="h-5 w-5" />
|
|
384
|
-
</Button>
|
|
385
|
-
<div className="flex-1 space-y-3 w-full min-w-0">
|
|
386
|
-
<div>
|
|
387
|
-
<Input
|
|
388
|
-
id="name"
|
|
389
|
-
value={formData.name}
|
|
390
|
-
onChange={(e) =>
|
|
391
|
-
setFormData({ ...formData, name: e.target.value })
|
|
392
|
-
}
|
|
393
|
-
placeholder="Tên vai trò (VD: Quản lý kho)..."
|
|
394
|
-
className="text-xl sm:text-2xl md:text-3xl font-black h-auto py-2 border-transparent hover:border-border focus-visible:ring-0 focus-visible:border-primary focus-visible:bg-card bg-transparent px-2 -ml-2 shadow-none rounded-[8px] placeholder:text-muted-foreground/40 transition-colors w-full"
|
|
395
|
-
required
|
|
396
|
-
/>
|
|
397
|
-
<div className="flex flex-wrap items-center gap-2 sm:gap-4 mt-2 px-1">
|
|
398
|
-
<div className="flex items-center gap-2">
|
|
399
|
-
<span className="text-[10px] sm:text-xs font-medium text-muted-foreground uppercase">
|
|
400
|
-
Mã:
|
|
401
|
-
</span>
|
|
402
|
-
<Input
|
|
403
|
-
id="code"
|
|
404
|
-
value={formData.code}
|
|
405
|
-
onChange={(e) =>
|
|
406
|
-
setFormData({ ...formData, code: e.target.value })
|
|
407
|
-
}
|
|
408
|
-
placeholder="VD: warehouse_manager"
|
|
409
|
-
disabled={mode === "edit"}
|
|
410
|
-
className="h-7 text-xs border border-border bg-card w-32 sm:w-48 shadow-none focus-visible:ring-1 focus-visible:ring-primary rounded-md"
|
|
411
|
-
/>
|
|
412
|
-
</div>
|
|
413
|
-
<div className="flex items-center gap-2 bg-card px-2 sm:px-3 h-7 rounded-md border border-border shrink-0">
|
|
414
|
-
<span className="text-[10px] sm:text-xs font-medium text-muted-foreground uppercase">
|
|
415
|
-
Hoạt động:
|
|
416
|
-
</span>
|
|
417
|
-
<Switch
|
|
418
|
-
checked={formData.status === "active"}
|
|
419
|
-
onCheckedChange={(checked) =>
|
|
420
|
-
setFormData({
|
|
421
|
-
...formData,
|
|
422
|
-
status: checked ? "active" : "inactive",
|
|
423
|
-
})
|
|
424
|
-
}
|
|
425
|
-
className="scale-75 origin-left"
|
|
426
|
-
/>
|
|
427
|
-
</div>
|
|
428
|
-
</div>
|
|
429
|
-
</div>
|
|
430
|
-
<div className="px-1">
|
|
431
|
-
<Input
|
|
432
|
-
id="description"
|
|
433
|
-
value={formData.description}
|
|
434
|
-
onChange={(e) =>
|
|
435
|
-
setFormData({ ...formData, description: e.target.value })
|
|
436
|
-
}
|
|
437
|
-
placeholder="Thêm mô tả về nhiệm vụ của vai trò này..."
|
|
438
|
-
className="h-8 text-xs sm:text-sm border-transparent hover:border-border focus-visible:ring-0 focus-visible:bg-muted/40 shadow-none bg-transparent px-2 -ml-2 rounded-md w-full"
|
|
439
|
-
/>
|
|
440
|
-
</div>
|
|
441
|
-
</div>
|
|
442
|
-
</div>
|
|
409
|
+
const visibleGroups = Object.entries(groupedMatrix).filter(
|
|
410
|
+
([group, groupResources]) =>
|
|
411
|
+
groupResources.length > 0 &&
|
|
412
|
+
!(activeGroup && activeGroup !== "all" && activeGroup !== group)
|
|
413
|
+
)
|
|
443
414
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
415
|
+
return (
|
|
416
|
+
<div className="flex flex-col">
|
|
417
|
+
{/* ===== 1. Header bar navy 1 DÒNG — theo pattern chi tiết đơn bán
|
|
418
|
+
hàng (bg-sidebar). Mobile: chỉ ← Tên ⏻ ✕ Lưu; mã (chip) + mô tả
|
|
419
|
+
hiện từ md. Create mode: ô mã rớt xuống dòng riêng trên mobile. ===== */}
|
|
420
|
+
<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">
|
|
421
|
+
<Button
|
|
422
|
+
variant="ghost"
|
|
423
|
+
size="icon"
|
|
424
|
+
onClick={() => router.push(`/${lang}/roles`)}
|
|
425
|
+
className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
|
|
426
|
+
>
|
|
427
|
+
<ArrowLeft className="h-4 w-4" />
|
|
428
|
+
</Button>
|
|
429
|
+
<Input
|
|
430
|
+
id="name"
|
|
431
|
+
value={formData.name}
|
|
432
|
+
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
433
|
+
placeholder="Tên vai trò..."
|
|
434
|
+
className="h-8 min-w-[110px] flex-1 rounded-md border-transparent bg-transparent px-2 text-base font-bold text-primary-foreground shadow-none transition-colors placeholder:text-primary-foreground/40 hover:border-white/20 focus-visible:bg-white/10 focus-visible:ring-0 sm:text-lg"
|
|
435
|
+
required
|
|
436
|
+
/>
|
|
437
|
+
{mode === "edit" ? (
|
|
438
|
+
<span className="hidden shrink-0 items-center gap-0.5 md:flex">
|
|
439
|
+
<code className="rounded bg-white/10 px-1.5 py-1 font-mono text-[11px] text-primary-foreground/80">
|
|
440
|
+
{formData.code}
|
|
441
|
+
</code>
|
|
451
442
|
<Button
|
|
452
|
-
variant="
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
443
|
+
variant="ghost"
|
|
444
|
+
size="icon"
|
|
445
|
+
className="h-6 w-6 shrink-0 text-primary-foreground/70 hover:bg-white/10 hover:text-primary-foreground"
|
|
446
|
+
onClick={() => {
|
|
447
|
+
navigator.clipboard.writeText(formData.code)
|
|
448
|
+
toast.success(`Đã copy mã: ${formData.code}`)
|
|
449
|
+
}}
|
|
450
|
+
title="Copy mã vai trò"
|
|
456
451
|
>
|
|
457
|
-
<
|
|
458
|
-
Hủy
|
|
452
|
+
<Copy className="h-3 w-3" />
|
|
459
453
|
</Button>
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
454
|
+
</span>
|
|
455
|
+
) : (
|
|
456
|
+
<Input
|
|
457
|
+
id="code"
|
|
458
|
+
value={formData.code}
|
|
459
|
+
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
|
460
|
+
placeholder="mã: warehouse_manager"
|
|
461
|
+
className="order-last h-7 basis-full rounded-md border-white/20 bg-white/10 px-2 font-mono text-xs text-primary-foreground shadow-none placeholder:text-primary-foreground/40 focus-visible:ring-1 focus-visible:ring-white/40 sm:order-none sm:w-44 sm:basis-auto"
|
|
462
|
+
/>
|
|
463
|
+
)}
|
|
464
|
+
{/* Chip trạng thái màu (getStatusMeta.solidClass — cùng taxonomy với
|
|
465
|
+
header đơn bán hàng); bấm để đổi Hoạt động ↔ Khóa */}
|
|
466
|
+
{(() => {
|
|
467
|
+
const statusMeta = getStatusMeta(
|
|
468
|
+
formData.status,
|
|
469
|
+
formData.status === "inactive" ? "Khóa" : undefined
|
|
470
|
+
)
|
|
471
|
+
return (
|
|
472
|
+
<button
|
|
473
|
+
type="button"
|
|
474
|
+
onClick={() =>
|
|
475
|
+
setFormData({
|
|
476
|
+
...formData,
|
|
477
|
+
status: formData.status === "active" ? "inactive" : "active",
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
title="Bấm để đổi trạng thái"
|
|
481
|
+
className={cn(
|
|
482
|
+
"flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5 text-[11px] font-semibold shadow-sm transition-opacity hover:opacity-85",
|
|
483
|
+
statusMeta.solidClass
|
|
484
|
+
)}
|
|
464
485
|
>
|
|
465
|
-
<
|
|
466
|
-
{
|
|
467
|
-
</
|
|
468
|
-
|
|
469
|
-
|
|
486
|
+
<span className="h-1.5 w-1.5 rounded-full bg-white/80" />
|
|
487
|
+
{statusMeta.label}
|
|
488
|
+
</button>
|
|
489
|
+
)
|
|
490
|
+
})()}
|
|
491
|
+
<Input
|
|
492
|
+
id="description"
|
|
493
|
+
value={formData.description}
|
|
494
|
+
onChange={(e) =>
|
|
495
|
+
setFormData({ ...formData, description: e.target.value })
|
|
496
|
+
}
|
|
497
|
+
placeholder="Mô tả vai trò..."
|
|
498
|
+
className="hidden h-7 min-w-[140px] flex-1 rounded-md border-transparent bg-transparent px-2 text-xs text-primary-foreground shadow-none placeholder:text-primary-foreground/40 hover:border-white/20 focus-visible:bg-white/10 focus-visible:ring-0 md:block"
|
|
499
|
+
/>
|
|
500
|
+
{permissions?.update === false && mode === "edit" && (
|
|
501
|
+
<Badge
|
|
502
|
+
variant="destructive"
|
|
503
|
+
className="hidden shrink-0 sm:inline-flex"
|
|
504
|
+
>
|
|
505
|
+
Chỉ đọc
|
|
506
|
+
</Badge>
|
|
507
|
+
)}
|
|
508
|
+
<Button
|
|
509
|
+
variant="ghost"
|
|
510
|
+
onClick={() => router.push(`/${lang}/roles`)}
|
|
511
|
+
disabled={isSubmitting}
|
|
512
|
+
title="Hủy"
|
|
513
|
+
className="h-8 w-8 shrink-0 p-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground sm:w-auto sm:px-2.5"
|
|
514
|
+
>
|
|
515
|
+
<X className="h-4 w-4" />
|
|
516
|
+
<span className="hidden sm:ml-1.5 sm:inline">Hủy</span>
|
|
517
|
+
</Button>
|
|
518
|
+
<Button
|
|
519
|
+
onClick={handleSubmit}
|
|
520
|
+
disabled={isSubmitting}
|
|
521
|
+
className="h-8 shrink-0 bg-primary-foreground px-2.5 text-primary hover:bg-primary-foreground/90 sm:px-3"
|
|
522
|
+
>
|
|
523
|
+
<Save className="h-4 w-4" />
|
|
524
|
+
<span className="ml-1.5">{isSubmitting ? "Lưu..." : "Lưu"}</span>
|
|
525
|
+
</Button>
|
|
470
526
|
</div>
|
|
471
527
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
<div className="flex items-center gap-3
|
|
475
|
-
<div className="relative w-
|
|
476
|
-
<Search className="absolute left-
|
|
528
|
+
<div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
|
|
529
|
+
{/* ===== 2. Toolbar sticky ===== */}
|
|
530
|
+
<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">
|
|
531
|
+
<div className="relative min-w-[140px] flex-1 sm:max-w-64">
|
|
532
|
+
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
477
533
|
<Input
|
|
478
534
|
placeholder="Tìm kiếm tài nguyên..."
|
|
479
|
-
className="
|
|
535
|
+
className="h-8 rounded-md border border-border bg-card pl-8 text-sm"
|
|
480
536
|
value={searchTerm}
|
|
481
537
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
482
538
|
/>
|
|
@@ -485,7 +541,7 @@ export function RoleFormPage({
|
|
|
485
541
|
value={activeGroup || "all"}
|
|
486
542
|
onValueChange={(val) => setActiveGroup(val === "all" ? null : val)}
|
|
487
543
|
>
|
|
488
|
-
<SelectTrigger className="
|
|
544
|
+
<SelectTrigger className="h-8 w-36 rounded-md border border-border bg-card text-sm sm:w-44">
|
|
489
545
|
<SelectValue placeholder="Tất cả nhóm" />
|
|
490
546
|
</SelectTrigger>
|
|
491
547
|
<SelectContent className="rounded-lg">
|
|
@@ -497,208 +553,196 @@ export function RoleFormPage({
|
|
|
497
553
|
))}
|
|
498
554
|
</SelectContent>
|
|
499
555
|
</Select>
|
|
556
|
+
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
|
|
557
|
+
{filteredMatrix.length} tài nguyên ·{" "}
|
|
558
|
+
<span className="font-semibold tabular-nums text-foreground">
|
|
559
|
+
{formData.permissions.length}
|
|
560
|
+
</span>
|
|
561
|
+
<span className="tabular-nums">/{totalAvailablePermissions}</span>{" "}
|
|
562
|
+
quyền
|
|
563
|
+
</p>
|
|
500
564
|
</div>
|
|
501
|
-
<div className="flex items-center gap-2">
|
|
502
|
-
<Badge
|
|
503
|
-
variant="outline"
|
|
504
|
-
className="font-medium bg-card text-muted-foreground border-border h-7 px-2"
|
|
505
|
-
>
|
|
506
|
-
{filteredMatrix.length} tài nguyên
|
|
507
|
-
</Badge>
|
|
508
|
-
</div>
|
|
509
|
-
</div>
|
|
510
565
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
{
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
566
|
+
{/* ===== 3. Nhóm + tài nguyên dạng dòng ===== */}
|
|
567
|
+
{visibleGroups.length === 0 ? (
|
|
568
|
+
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
569
|
+
<Search className="mb-3 h-6 w-6 text-muted-foreground" />
|
|
570
|
+
<p className="text-sm text-muted-foreground">
|
|
571
|
+
Không có tài nguyên nào khớp bộ lọc.
|
|
572
|
+
</p>
|
|
573
|
+
</div>
|
|
574
|
+
) : (
|
|
575
|
+
visibleGroups.map(([group, groupResources]) => {
|
|
576
|
+
const collapsed = !!collapsedGroups[group]
|
|
577
|
+
const { selected, total } = groupSelectedCount(group)
|
|
578
|
+
const isFull = total > 0 && selected === total
|
|
579
|
+
|
|
580
|
+
return (
|
|
581
|
+
<section key={group}>
|
|
582
|
+
{/* Dòng tiêu đề nhóm — mảnh, không đóng khung */}
|
|
583
|
+
<div
|
|
584
|
+
role="button"
|
|
585
|
+
tabIndex={0}
|
|
586
|
+
onClick={() => toggleGroupCollapse(group)}
|
|
587
|
+
onKeyDown={(e) => {
|
|
588
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
589
|
+
e.preventDefault()
|
|
590
|
+
toggleGroupCollapse(group)
|
|
591
|
+
}
|
|
592
|
+
}}
|
|
593
|
+
className="flex cursor-pointer select-none items-center gap-2 border-b border-border bg-card px-4 py-2 transition-colors hover:bg-muted/40"
|
|
594
|
+
>
|
|
595
|
+
{collapsed ? (
|
|
596
|
+
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
597
|
+
) : (
|
|
598
|
+
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
599
|
+
)}
|
|
600
|
+
<h3 className="truncate text-xs font-semibold uppercase tracking-wide text-foreground">
|
|
601
|
+
{group}
|
|
602
|
+
</h3>
|
|
603
|
+
<span
|
|
604
|
+
className={cn(
|
|
605
|
+
"whitespace-nowrap text-[11px] tabular-nums",
|
|
606
|
+
isFull
|
|
607
|
+
? "font-medium text-emerald-600 dark:text-emerald-400"
|
|
608
|
+
: "text-muted-foreground"
|
|
537
609
|
)}
|
|
538
|
-
</div>
|
|
539
|
-
<div>
|
|
540
|
-
<h3 className="text-sm font-bold text-foreground flex items-center gap-2">
|
|
541
|
-
{group}
|
|
542
|
-
{isFull && (
|
|
543
|
-
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400 py-0 h-4.5 px-1.5 text-[9px] font-semibold">
|
|
544
|
-
<Check className="w-2.5 h-2.5 mr-0.5" />
|
|
545
|
-
Đã chọn hết
|
|
546
|
-
</Badge>
|
|
547
|
-
)}
|
|
548
|
-
</h3>
|
|
549
|
-
<p className="text-xs text-muted-foreground font-medium mt-0.5">
|
|
550
|
-
{groupResources.length} tài nguyên
|
|
551
|
-
</p>
|
|
552
|
-
</div>
|
|
553
|
-
</div>
|
|
554
|
-
<div className="flex gap-1.5 self-start sm:self-auto pr-1">
|
|
555
|
-
<Button
|
|
556
|
-
type="button"
|
|
557
|
-
variant="outline"
|
|
558
|
-
size="sm"
|
|
559
|
-
onClick={(e) => {
|
|
560
|
-
e.stopPropagation();
|
|
561
|
-
toggleAllForGroup(group, false);
|
|
562
|
-
}}
|
|
563
|
-
className="h-7 text-xs font-semibold text-muted-foreground hover:text-destructive bg-card rounded-md border"
|
|
564
610
|
>
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
onClick={(e) => {
|
|
572
|
-
e.stopPropagation();
|
|
573
|
-
toggleAllForGroup(group, true);
|
|
574
|
-
}}
|
|
575
|
-
className="h-7 text-xs font-semibold rounded-md bg-primary text-primary-foreground"
|
|
611
|
+
{groupResources.length} tài nguyên · {selected}/{total}
|
|
612
|
+
{isFull && <Check className="ml-0.5 inline h-3 w-3" />}
|
|
613
|
+
</span>
|
|
614
|
+
<div
|
|
615
|
+
className="ml-auto flex shrink-0 items-center gap-1"
|
|
616
|
+
onClick={(e) => e.stopPropagation()}
|
|
576
617
|
>
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
{groupResources.map((resource) => (
|
|
585
|
-
<div
|
|
586
|
-
key={resource.resource}
|
|
587
|
-
className="border border-border bg-card rounded-xl shadow-sm hover:border-primary/40 transition-colors overflow-hidden group"
|
|
618
|
+
<Button
|
|
619
|
+
type="button"
|
|
620
|
+
variant="ghost"
|
|
621
|
+
size="sm"
|
|
622
|
+
onClick={() => toggleAllForGroup(group, false)}
|
|
623
|
+
disabled={selected === 0}
|
|
624
|
+
className="h-6 px-2 text-xs text-muted-foreground hover:text-destructive"
|
|
588
625
|
>
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
626
|
+
Bỏ chọn
|
|
627
|
+
</Button>
|
|
628
|
+
<Button
|
|
629
|
+
type="button"
|
|
630
|
+
variant="ghost"
|
|
631
|
+
size="sm"
|
|
632
|
+
onClick={() => toggleAllForGroup(group, true)}
|
|
633
|
+
disabled={isFull}
|
|
634
|
+
className="h-6 px-2 text-xs font-semibold text-primary hover:text-primary"
|
|
635
|
+
>
|
|
636
|
+
Chọn tất cả
|
|
637
|
+
</Button>
|
|
638
|
+
</div>
|
|
639
|
+
</div>
|
|
640
|
+
|
|
641
|
+
{/* Các dòng tài nguyên */}
|
|
642
|
+
{!collapsed && (
|
|
643
|
+
<ul className="divide-y divide-border/70 border-b border-border">
|
|
644
|
+
{groupResources.map((resource) => (
|
|
645
|
+
<li key={resource.resource} className="px-4 py-2.5">
|
|
646
|
+
<div className="flex items-center gap-2">
|
|
647
|
+
<DynamicIcon
|
|
648
|
+
name={resource.icon as any}
|
|
649
|
+
className="h-4 w-4 shrink-0 text-muted-foreground"
|
|
650
|
+
/>
|
|
651
|
+
<span className="truncate text-sm font-medium text-foreground">
|
|
652
|
+
{resource.name}
|
|
613
653
|
</span>
|
|
614
|
-
<
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
654
|
+
<code className="hidden shrink-0 font-mono text-[11px] text-muted-foreground sm:inline">
|
|
655
|
+
{resource.resource}
|
|
656
|
+
</code>
|
|
657
|
+
<label className="ml-auto flex shrink-0 cursor-pointer items-center gap-1.5">
|
|
658
|
+
<span className="hidden text-[10px] font-medium uppercase text-muted-foreground sm:inline">
|
|
659
|
+
Tất cả
|
|
660
|
+
</span>
|
|
661
|
+
<Switch
|
|
662
|
+
checked={isResourceFullPermission(
|
|
621
663
|
resource.resource,
|
|
622
|
-
resource.actions
|
|
623
|
-
checked,
|
|
624
|
-
)
|
|
625
|
-
}
|
|
626
|
-
className="scale-75 origin-right"
|
|
627
|
-
/>
|
|
628
|
-
</div>
|
|
629
|
-
</div>
|
|
630
|
-
|
|
631
|
-
<div className="p-2 grid grid-cols-2 gap-1.5">
|
|
632
|
-
{actions.map((action) => {
|
|
633
|
-
const isAvailable = resource.actions.includes(
|
|
634
|
-
action.code,
|
|
635
|
-
);
|
|
636
|
-
const isChecked = hasPermission(
|
|
637
|
-
resource.resource,
|
|
638
|
-
action.code,
|
|
639
|
-
);
|
|
640
|
-
const staticConfig = mergedActionLabels[action.code];
|
|
641
|
-
|
|
642
|
-
const config = {
|
|
643
|
-
label:
|
|
644
|
-
action.name || staticConfig?.label || action.code,
|
|
645
|
-
icon: staticConfig?.icon || "•",
|
|
646
|
-
color:
|
|
647
|
-
staticConfig?.color || "text-muted-foreground",
|
|
648
|
-
};
|
|
649
|
-
|
|
650
|
-
if (!isAvailable) return null;
|
|
651
|
-
|
|
652
|
-
return (
|
|
653
|
-
<div
|
|
654
|
-
key={action.code}
|
|
655
|
-
className={cn(
|
|
656
|
-
"flex items-center gap-2 px-2 py-1.5 rounded-[6px] border transition-all cursor-pointer relative overflow-hidden",
|
|
657
|
-
isChecked
|
|
658
|
-
? "bg-primary/5 border-primary/20 shadow-[0_1px_2px_rgba(0,0,0,0.02)]"
|
|
659
|
-
: "bg-card hover:bg-muted/50 border-border",
|
|
664
|
+
resource.actions
|
|
660
665
|
)}
|
|
661
|
-
|
|
662
|
-
|
|
666
|
+
onCheckedChange={(checked) =>
|
|
667
|
+
toggleAllForResource(
|
|
668
|
+
resource.resource,
|
|
669
|
+
resource.actions,
|
|
670
|
+
checked
|
|
671
|
+
)
|
|
663
672
|
}
|
|
664
|
-
|
|
665
|
-
|
|
673
|
+
className="origin-right scale-75"
|
|
674
|
+
/>
|
|
675
|
+
</label>
|
|
676
|
+
</div>
|
|
677
|
+
|
|
678
|
+
{/* Chip quyền — icon theo loại action, bấm để bật/tắt */}
|
|
679
|
+
<div className="mt-1.5 flex flex-wrap gap-1.5 sm:pl-6">
|
|
680
|
+
{actions.map((action) => {
|
|
681
|
+
if (!resource.actions.includes(action.code))
|
|
682
|
+
return null
|
|
683
|
+
const isChecked = hasPermission(
|
|
684
|
+
resource.resource,
|
|
685
|
+
action.code
|
|
686
|
+
)
|
|
687
|
+
const label =
|
|
688
|
+
action.name ||
|
|
689
|
+
mergedActionLabels[action.code]?.label ||
|
|
690
|
+
action.code
|
|
691
|
+
const visual = getActionVisual(action.code)
|
|
692
|
+
const ActionIcon = visual.icon
|
|
693
|
+
|
|
694
|
+
return (
|
|
695
|
+
<button
|
|
696
|
+
key={action.code}
|
|
697
|
+
type="button"
|
|
698
|
+
onClick={() =>
|
|
699
|
+
togglePermission(
|
|
700
|
+
resource.resource,
|
|
701
|
+
action.code
|
|
702
|
+
)
|
|
703
|
+
}
|
|
666
704
|
className={cn(
|
|
667
|
-
"flex items-center
|
|
705
|
+
"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-colors",
|
|
668
706
|
isChecked
|
|
669
|
-
? "
|
|
670
|
-
: "bg-card border-
|
|
707
|
+
? "border-primary/40 bg-primary/10 text-primary"
|
|
708
|
+
: "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground"
|
|
671
709
|
)}
|
|
672
710
|
>
|
|
673
|
-
|
|
674
|
-
</div>
|
|
675
|
-
<div className="flex items-center gap-1.5 overflow-hidden">
|
|
676
|
-
<span className="text-xs shrink-0 opacity-80 filter grayscale">
|
|
677
|
-
{config.icon}
|
|
678
|
-
</span>
|
|
711
|
+
{/* Ô tick tường minh — nhìn là biết đã cấp hay chưa */}
|
|
679
712
|
<span
|
|
680
713
|
className={cn(
|
|
681
|
-
"
|
|
714
|
+
"flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-[4px] border transition-colors",
|
|
682
715
|
isChecked
|
|
683
|
-
? "text-primary"
|
|
684
|
-
: "text-
|
|
716
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
717
|
+
: "border-border bg-card text-transparent"
|
|
685
718
|
)}
|
|
686
719
|
>
|
|
687
|
-
|
|
720
|
+
<Check
|
|
721
|
+
className="h-2.5 w-2.5"
|
|
722
|
+
strokeWidth={3}
|
|
723
|
+
/>
|
|
688
724
|
</span>
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
725
|
+
<ActionIcon
|
|
726
|
+
className={cn(
|
|
727
|
+
"h-3.5 w-3.5 shrink-0",
|
|
728
|
+
visual.cls,
|
|
729
|
+
!isChecked && "opacity-55"
|
|
730
|
+
)}
|
|
731
|
+
/>
|
|
732
|
+
{label}
|
|
733
|
+
</button>
|
|
734
|
+
)
|
|
735
|
+
})}
|
|
736
|
+
</div>
|
|
737
|
+
</li>
|
|
738
|
+
))}
|
|
739
|
+
</ul>
|
|
740
|
+
)}
|
|
741
|
+
</section>
|
|
742
|
+
)
|
|
743
|
+
})
|
|
744
|
+
)}
|
|
701
745
|
</div>
|
|
702
746
|
</div>
|
|
703
|
-
)
|
|
747
|
+
)
|
|
704
748
|
}
|