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