@goplusvn/core 0.1.38 → 0.1.39
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/package.json +1 -1
- package/src/rbac/pages/role-form-page.tsx +364 -328
package/package.json
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
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.
|
|
3
|
+
// Trang tạo/sửa VAI TRÒ — bar navy 1 DÒNG kiểu chi tiết đơn bán hàng.
|
|
6
4
|
//
|
|
7
|
-
// Ma trận quyền (
|
|
8
|
-
// - TAB theo phân hệ (
|
|
9
|
-
// - Mỗi
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
5
|
+
// Ma trận quyền v3 ("Mức truy cập 5 nấc" — chốt với chủ app 2026-07):
|
|
6
|
+
// - TAB theo phân hệ (đếm x/y realtime).
|
|
7
|
+
// - Mỗi tài nguyên MỘT DÒNG: [Ẩn | Tra cứu | Xem | Ghi | Toàn quyền]
|
|
8
|
+
// Ẩn = không menu, không quyền gì
|
|
9
|
+
// Tra cứu = chỉ chọn trong combo/selector (lookup) — KHÔNG thấy menu
|
|
10
|
+
// Xem = menu sidebar hiện + vào được trang
|
|
11
|
+
// Ghi = Xem + Tạo/Sửa/Xuất/Nhập
|
|
12
|
+
// Toàn quyền= Ghi + Xóa
|
|
13
|
+
// - Thao tác NGHIỆP VỤ (duyệt, thanh toán, MISA…) thu gọn "n/m ▸", bung ra
|
|
14
|
+
// nhóm theo LUỒNG (prop actionMeta.flow), tooltip mô tả, và tự cưỡng chế
|
|
15
|
+
// quyền tiên quyết (actionMeta.requires — mặc định cần Xem).
|
|
16
|
+
// - resourcePages: đường dẫn trang hiện dưới tên tài nguyên.
|
|
14
17
|
// API contract giữ nguyên: POST /api/roles, PUT /api/roles/:id, quyền dạng
|
|
15
18
|
// `${action}:${resource}`.
|
|
16
19
|
import * as React from "react"
|
|
@@ -18,7 +21,6 @@ import { useRouter } from "next/navigation"
|
|
|
18
21
|
import {
|
|
19
22
|
Badge,
|
|
20
23
|
Button,
|
|
21
|
-
Checkbox,
|
|
22
24
|
DynamicIcon,
|
|
23
25
|
Input,
|
|
24
26
|
Select,
|
|
@@ -26,7 +28,6 @@ import {
|
|
|
26
28
|
SelectItem,
|
|
27
29
|
SelectTrigger,
|
|
28
30
|
SelectValue,
|
|
29
|
-
Switch,
|
|
30
31
|
getStatusMeta,
|
|
31
32
|
} from "../../ui"
|
|
32
33
|
import { toast } from "sonner"
|
|
@@ -36,6 +37,8 @@ import {
|
|
|
36
37
|
Ban,
|
|
37
38
|
Bot,
|
|
38
39
|
Check,
|
|
40
|
+
ChevronDown,
|
|
41
|
+
ChevronRight,
|
|
39
42
|
Copy,
|
|
40
43
|
Download,
|
|
41
44
|
Eye,
|
|
@@ -80,20 +83,30 @@ const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
|
|
|
80
83
|
delete_items: { label: "Xóa chi tiết", icon: "❌", color: "text-rose-600" },
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
86
|
+
// Action CHUẨN gộp vào thang "Mức truy cập" — không hiện lẻ.
|
|
87
|
+
const WRITE_CODES = ["create", "update", "export", "import"] as const
|
|
88
|
+
const STANDARD_CODES = new Set([
|
|
89
|
+
"view",
|
|
90
|
+
"lookup",
|
|
91
|
+
"delete",
|
|
92
|
+
...WRITE_CODES,
|
|
93
|
+
])
|
|
94
|
+
|
|
95
|
+
type AccessLevel = "hidden" | "lookup" | "view" | "write" | "full"
|
|
96
|
+
|
|
97
|
+
const LEVELS: Array<{ key: AccessLevel; label: string; hint: string }> = [
|
|
98
|
+
{ key: "hidden", label: "Ẩn", hint: "Không thấy menu, không quyền gì" },
|
|
99
|
+
{
|
|
100
|
+
key: "lookup",
|
|
101
|
+
label: "Tra cứu",
|
|
102
|
+
hint: "Chỉ chọn trong combo/selector — KHÔNG thấy menu",
|
|
103
|
+
},
|
|
104
|
+
{ key: "view", label: "Xem", hint: "Menu sidebar hiện + vào được trang" },
|
|
105
|
+
{ key: "write", label: "Ghi", hint: "Xem + Tạo/Sửa/Xuất/Nhập" },
|
|
106
|
+
{ key: "full", label: "Toàn quyền", hint: "Ghi + Xóa" },
|
|
93
107
|
]
|
|
94
|
-
const STANDARD_CODES = new Set(STANDARD_COLUMNS.map((c) => c.code))
|
|
95
108
|
|
|
96
|
-
// --- Icon + màu theo LOẠI action (suy từ mã) — mọi action
|
|
109
|
+
// --- Icon + màu theo LOẠI action (suy từ mã) — mọi action nghiệp vụ đều có
|
|
97
110
|
// icon mà không cần cấu hình từng cái. ---
|
|
98
111
|
const ACTION_VISUAL_RULES: Array<{
|
|
99
112
|
re: RegExp
|
|
@@ -115,7 +128,11 @@ const ACTION_VISUAL_RULES: Array<{
|
|
|
115
128
|
{ re: /^export/, icon: Upload, cls: "text-indigo-500" },
|
|
116
129
|
{ re: /^print/, icon: Printer, cls: "text-cyan-600" },
|
|
117
130
|
{ re: /^(sync|unlink)/, icon: RefreshCw, cls: "text-cyan-600" },
|
|
118
|
-
{
|
|
131
|
+
{
|
|
132
|
+
re: /^(manage|config|close|reopen)/,
|
|
133
|
+
icon: Settings2,
|
|
134
|
+
cls: "text-orange-500",
|
|
135
|
+
},
|
|
119
136
|
{ re: /^auto/, icon: Bot, cls: "text-purple-500" },
|
|
120
137
|
]
|
|
121
138
|
|
|
@@ -125,8 +142,7 @@ const getActionVisual = (code: string) =>
|
|
|
125
142
|
cls: "text-slate-400",
|
|
126
143
|
}
|
|
127
144
|
|
|
128
|
-
/** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa
|
|
129
|
-
* Permission Registry của app). permissions dạng `${action}:${resource}`. */
|
|
145
|
+
/** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
|
|
130
146
|
export interface RoleTemplate {
|
|
131
147
|
code: string
|
|
132
148
|
label: string
|
|
@@ -134,6 +150,14 @@ export interface RoleTemplate {
|
|
|
134
150
|
permissions: string[]
|
|
135
151
|
}
|
|
136
152
|
|
|
153
|
+
/** Metadata action nghiệp vụ: nhóm luồng + mô tả + quyền tiên quyết. */
|
|
154
|
+
export interface ActionMetaEntry {
|
|
155
|
+
label?: string
|
|
156
|
+
description?: string
|
|
157
|
+
flow?: string
|
|
158
|
+
requires?: string[]
|
|
159
|
+
}
|
|
160
|
+
|
|
137
161
|
export interface RoleFormPageProps {
|
|
138
162
|
dictionary: any
|
|
139
163
|
permissions?: CrudPermissions
|
|
@@ -167,6 +191,10 @@ export interface RoleFormPageProps {
|
|
|
167
191
|
actionLabels?: Record<string, ActionLabelConfig>
|
|
168
192
|
/** Bộ quyền mẫu hiện ở chế độ tạo mới (additive — không truyền thì ẩn). */
|
|
169
193
|
roleTemplates?: RoleTemplate[]
|
|
194
|
+
/** Meta action nghiệp vụ theo code (flow/description/requires) — additive. */
|
|
195
|
+
actionMeta?: Record<string, ActionMetaEntry>
|
|
196
|
+
/** Đường dẫn trang theo resource code (hiện dưới tên) — additive. */
|
|
197
|
+
resourcePages?: Record<string, string>
|
|
170
198
|
}
|
|
171
199
|
|
|
172
200
|
export function RoleFormPage({
|
|
@@ -179,6 +207,8 @@ export function RoleFormPage({
|
|
|
179
207
|
initialData,
|
|
180
208
|
actionLabels,
|
|
181
209
|
roleTemplates,
|
|
210
|
+
actionMeta,
|
|
211
|
+
resourcePages,
|
|
182
212
|
}: RoleFormPageProps) {
|
|
183
213
|
const router = useRouter()
|
|
184
214
|
|
|
@@ -199,6 +229,18 @@ export function RoleFormPage({
|
|
|
199
229
|
})
|
|
200
230
|
|
|
201
231
|
const [activeGroup, setActiveGroup] = React.useState<string>("all")
|
|
232
|
+
const [expandedRows, setExpandedRows] = React.useState<Set<string>>(
|
|
233
|
+
() => new Set()
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
const toggleRowExpand = (code: string) => {
|
|
237
|
+
setExpandedRows((prev) => {
|
|
238
|
+
const next = new Set(prev)
|
|
239
|
+
if (next.has(code)) next.delete(code)
|
|
240
|
+
else next.add(code)
|
|
241
|
+
return next
|
|
242
|
+
})
|
|
243
|
+
}
|
|
202
244
|
|
|
203
245
|
const getLocalizedName = React.useCallback(
|
|
204
246
|
(key: string | null) => {
|
|
@@ -221,10 +263,14 @@ export function RoleFormPage({
|
|
|
221
263
|
|
|
222
264
|
const actionName = React.useCallback(
|
|
223
265
|
(code: string) => {
|
|
224
|
-
|
|
225
|
-
|
|
266
|
+
return (
|
|
267
|
+
actionMeta?.[code]?.label ||
|
|
268
|
+
actions.find((a) => a.code === code)?.name ||
|
|
269
|
+
mergedActionLabels[code]?.label ||
|
|
270
|
+
code
|
|
271
|
+
)
|
|
226
272
|
},
|
|
227
|
-
[actions, mergedActionLabels]
|
|
273
|
+
[actions, actionMeta, mergedActionLabels]
|
|
228
274
|
)
|
|
229
275
|
|
|
230
276
|
const permissionMatrix = React.useMemo(() => {
|
|
@@ -273,11 +319,18 @@ export function RoleFormPage({
|
|
|
273
319
|
group: getLocalizedName(resource.group) || "Khác",
|
|
274
320
|
description: resource.description,
|
|
275
321
|
actions: allowedActions,
|
|
276
|
-
|
|
322
|
+
businessActions: allowedActions.filter(
|
|
323
|
+
(a) => !STANDARD_CODES.has(a)
|
|
324
|
+
),
|
|
325
|
+
hasLookup: allowedActions.includes("lookup"),
|
|
326
|
+
writeCodes: WRITE_CODES.filter((c) => allowedActions.includes(c)),
|
|
327
|
+
hasDelete: allowedActions.includes("delete"),
|
|
277
328
|
}
|
|
278
329
|
})
|
|
279
330
|
}, [resources, actions, getLocalizedName])
|
|
280
331
|
|
|
332
|
+
type MatrixRow = (typeof permissionMatrix)[number]
|
|
333
|
+
|
|
281
334
|
const totalAvailablePermissions = React.useMemo(
|
|
282
335
|
() => permissionMatrix.reduce((sum, r) => sum + r.actions.length, 0),
|
|
283
336
|
[permissionMatrix]
|
|
@@ -296,7 +349,7 @@ export function RoleFormPage({
|
|
|
296
349
|
}, [searchTerm, permissionMatrix])
|
|
297
350
|
|
|
298
351
|
const groupedMatrix = React.useMemo(() => {
|
|
299
|
-
const groups: Record<string,
|
|
352
|
+
const groups: Record<string, MatrixRow[]> = {}
|
|
300
353
|
filteredMatrix.forEach((resource) => {
|
|
301
354
|
const group = resource.group
|
|
302
355
|
if (!groups[group]) {
|
|
@@ -307,7 +360,6 @@ export function RoleFormPage({
|
|
|
307
360
|
return groups
|
|
308
361
|
}, [filteredMatrix])
|
|
309
362
|
|
|
310
|
-
// Tab dựng theo TOÀN BỘ matrix (không theo search) để thanh tab ổn định.
|
|
311
363
|
const allGroups = React.useMemo(() => {
|
|
312
364
|
const seen: string[] = []
|
|
313
365
|
permissionMatrix.forEach((r) => {
|
|
@@ -322,133 +374,125 @@ export function RoleFormPage({
|
|
|
322
374
|
}
|
|
323
375
|
}, [allGroups, activeGroup])
|
|
324
376
|
|
|
325
|
-
// Đang search → hiện mọi nhóm khớp, bỏ qua tab đang chọn.
|
|
326
377
|
const searching = searchTerm.trim().length > 0
|
|
327
378
|
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const togglePermission = (resource: string, action: string) => {
|
|
333
|
-
const permission = `${action}:${resource}`
|
|
334
|
-
let newPermissions = [...formData.permissions]
|
|
335
|
-
|
|
336
|
-
if (newPermissions.includes(permission)) {
|
|
337
|
-
newPermissions = newPermissions.filter((p) => p !== permission)
|
|
338
|
-
} else {
|
|
339
|
-
newPermissions.push(permission)
|
|
340
|
-
}
|
|
341
|
-
setFormData({ ...formData, permissions: newPermissions })
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
const groupPermissionCodes = React.useCallback(
|
|
345
|
-
(groupName: string) => {
|
|
346
|
-
const groupResources = groupedMatrix[groupName] || []
|
|
347
|
-
return groupResources.flatMap((r) =>
|
|
348
|
-
r.actions.map((a) => `${a}:${r.resource}`)
|
|
349
|
-
)
|
|
350
|
-
},
|
|
351
|
-
[groupedMatrix]
|
|
379
|
+
const has = React.useCallback(
|
|
380
|
+
(resource: string, action: string) =>
|
|
381
|
+
formData.permissions.includes(`${action}:${resource}`),
|
|
382
|
+
[formData.permissions]
|
|
352
383
|
)
|
|
353
384
|
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
385
|
+
const mutatePermissions = (
|
|
386
|
+
fn: (set: Set<string>) => void
|
|
387
|
+
) => {
|
|
388
|
+
setFormData((prev) => {
|
|
389
|
+
const set = new Set(prev.permissions)
|
|
390
|
+
fn(set)
|
|
391
|
+
return { ...prev, permissions: Array.from(set) }
|
|
392
|
+
})
|
|
393
|
+
}
|
|
357
394
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
)
|
|
395
|
+
// ── Mức truy cập ──────────────────────────────────────────────────────
|
|
396
|
+
const deriveLevel = (r: MatrixRow): AccessLevel => {
|
|
397
|
+
if (has(r.resource, "view")) {
|
|
398
|
+
const anyWrite = r.writeCodes.some((c) => has(r.resource, c))
|
|
399
|
+
if (!anyWrite && !(r.hasDelete && has(r.resource, "delete")))
|
|
400
|
+
return "view"
|
|
401
|
+
const fullWrite = r.writeCodes.every((c) => has(r.resource, c))
|
|
402
|
+
if (fullWrite && (!r.hasDelete || has(r.resource, "delete")))
|
|
403
|
+
return "full"
|
|
404
|
+
return "write"
|
|
365
405
|
}
|
|
366
|
-
|
|
367
|
-
|
|
406
|
+
if (r.hasLookup && has(r.resource, "lookup")) return "lookup"
|
|
407
|
+
return "hidden"
|
|
368
408
|
}
|
|
369
409
|
|
|
370
|
-
const
|
|
371
|
-
(
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
410
|
+
const setLevel = (r: MatrixRow, level: AccessLevel) => {
|
|
411
|
+
mutatePermissions((set) => {
|
|
412
|
+
const rm = (a: string) => set.delete(`${a}:${r.resource}`)
|
|
413
|
+
const add = (a: string) => set.add(`${a}:${r.resource}`)
|
|
414
|
+
// reset phần chuẩn
|
|
415
|
+
;["view", "lookup", "delete", ...WRITE_CODES].forEach(rm)
|
|
416
|
+
if (level === "hidden" || level === "lookup") {
|
|
417
|
+
// mất Xem → thao tác nghiệp vụ (đều cần Xem) tắt theo
|
|
418
|
+
const on = r.businessActions.filter((a) =>
|
|
419
|
+
set.has(`${a}:${r.resource}`)
|
|
420
|
+
)
|
|
421
|
+
r.businessActions.forEach(rm)
|
|
422
|
+
if (on.length > 0) {
|
|
423
|
+
toast.info(
|
|
424
|
+
`Đã tắt ${on.length} thao tác nghiệp vụ của "${r.name}" (cần mức Xem trở lên)`
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
if (level === "lookup" && r.hasLookup) add("lookup")
|
|
428
|
+
return
|
|
376
429
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
// Đếm theo nhóm cho TAB (không phụ thuộc search — dùng permissionMatrix).
|
|
382
|
-
const groupTabCount = React.useCallback(
|
|
383
|
-
(groupName: string) => {
|
|
384
|
-
const rs = permissionMatrix.filter((r) => r.group === groupName)
|
|
385
|
-
const codes = rs.flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`))
|
|
386
|
-
return {
|
|
387
|
-
selected: codes.filter((p) => formData.permissions.includes(p)).length,
|
|
388
|
-
total: codes.length,
|
|
430
|
+
add("view")
|
|
431
|
+
if (level === "write" || level === "full") {
|
|
432
|
+
r.writeCodes.forEach(add)
|
|
389
433
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
)
|
|
393
|
-
|
|
394
|
-
// Tick/bỏ nguyên MỘT CỘT action trong một nhóm (chỉ resource có khai action).
|
|
395
|
-
const columnState = (groupName: string, actionCode: string) => {
|
|
396
|
-
const rows = (groupedMatrix[groupName] || []).filter((r) =>
|
|
397
|
-
r.actions.includes(actionCode)
|
|
398
|
-
)
|
|
399
|
-
if (rows.length === 0) return { applicable: 0, selected: 0 }
|
|
400
|
-
const selected = rows.filter((r) =>
|
|
401
|
-
formData.permissions.includes(`${actionCode}:${r.resource}`)
|
|
402
|
-
).length
|
|
403
|
-
return { applicable: rows.length, selected }
|
|
434
|
+
if (level === "full" && r.hasDelete) add("delete")
|
|
435
|
+
})
|
|
404
436
|
}
|
|
405
437
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
438
|
+
// ── Thao tác nghiệp vụ + requires ─────────────────────────────────────
|
|
439
|
+
const toggleBusinessAction = (r: MatrixRow, code: string) => {
|
|
440
|
+
const key = `${code}:${r.resource}`
|
|
441
|
+
if (formData.permissions.includes(key)) {
|
|
442
|
+
mutatePermissions((set) => set.delete(key))
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
const requires = actionMeta?.[code]?.requires ?? []
|
|
446
|
+
const needed = ["view", ...requires].filter(
|
|
447
|
+
(req) =>
|
|
448
|
+
r.actions.includes(req) &&
|
|
449
|
+
!formData.permissions.includes(`${req}:${r.resource}`)
|
|
409
450
|
)
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
451
|
+
mutatePermissions((set) => {
|
|
452
|
+
set.add(key)
|
|
453
|
+
needed.forEach((req) => set.add(`${req}:${r.resource}`))
|
|
454
|
+
})
|
|
455
|
+
if (needed.length > 0) {
|
|
456
|
+
toast.info(
|
|
457
|
+
`Đã bật kèm quyền tiên quyết: ${needed
|
|
458
|
+
.map((c) => actionName(c))
|
|
459
|
+
.join(", ")}`
|
|
460
|
+
)
|
|
419
461
|
}
|
|
420
|
-
setFormData({ ...formData, permissions: newPermissions })
|
|
421
462
|
}
|
|
422
463
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
resourceActions.forEach((actionCode) => {
|
|
431
|
-
const permission = `${actionCode}:${resourceCode}`
|
|
432
|
-
if (check && !newPermissions.includes(permission)) {
|
|
433
|
-
newPermissions.push(permission)
|
|
434
|
-
} else if (!check && newPermissions.includes(permission)) {
|
|
435
|
-
newPermissions = newPermissions.filter((p) => p !== permission)
|
|
436
|
-
}
|
|
437
|
-
})
|
|
464
|
+
// ── Đếm ───────────────────────────────────────────────────────────────
|
|
465
|
+
const groupPermissionCodes = React.useCallback(
|
|
466
|
+
(rows: MatrixRow[]) =>
|
|
467
|
+
rows.flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`)),
|
|
468
|
+
[]
|
|
469
|
+
)
|
|
438
470
|
|
|
439
|
-
|
|
440
|
-
|
|
471
|
+
const countSelected = React.useCallback(
|
|
472
|
+
(codes: string[]) =>
|
|
473
|
+
codes.filter((p) => formData.permissions.includes(p)).length,
|
|
474
|
+
[formData.permissions]
|
|
475
|
+
)
|
|
441
476
|
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
formData.permissions.includes(`${actionCode}:${resourceCode}`)
|
|
449
|
-
)
|
|
477
|
+
const toggleAllForGroup = (rows: MatrixRow[], enable: boolean) => {
|
|
478
|
+
const codes = groupPermissionCodes(rows)
|
|
479
|
+
mutatePermissions((set) => {
|
|
480
|
+
if (enable) codes.forEach((c) => set.add(c))
|
|
481
|
+
else codes.forEach((c) => set.delete(c))
|
|
482
|
+
})
|
|
450
483
|
}
|
|
451
484
|
|
|
485
|
+
const groupTabCount = React.useCallback(
|
|
486
|
+
(groupName: string) => {
|
|
487
|
+
const rows = permissionMatrix.filter((r) => r.group === groupName)
|
|
488
|
+
const codes = rows.flatMap((r) =>
|
|
489
|
+
r.actions.map((a) => `${a}:${r.resource}`)
|
|
490
|
+
)
|
|
491
|
+
return { selected: countSelected(codes), total: codes.length }
|
|
492
|
+
},
|
|
493
|
+
[permissionMatrix, countSelected]
|
|
494
|
+
)
|
|
495
|
+
|
|
452
496
|
const applyTemplate = (templateCode: string) => {
|
|
453
497
|
const tpl = roleTemplates?.find((t) => t.code === templateCode)
|
|
454
498
|
if (!tpl) return
|
|
@@ -501,9 +545,7 @@ export function RoleFormPage({
|
|
|
501
545
|
|
|
502
546
|
return (
|
|
503
547
|
<div className="flex flex-col">
|
|
504
|
-
{/* ===== 1. Header bar navy 1 DÒNG
|
|
505
|
-
hàng (bg-sidebar). Mobile: chỉ ← Tên ⏻ ✕ Lưu; mã (chip) + mô tả
|
|
506
|
-
hiện từ md. Create mode: ô mã rớt xuống dòng riêng trên mobile. ===== */}
|
|
548
|
+
{/* ===== 1. Header bar navy 1 DÒNG ===== */}
|
|
507
549
|
<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">
|
|
508
550
|
<Button
|
|
509
551
|
variant="ghost"
|
|
@@ -548,8 +590,6 @@ export function RoleFormPage({
|
|
|
548
590
|
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"
|
|
549
591
|
/>
|
|
550
592
|
)}
|
|
551
|
-
{/* Chip trạng thái màu (getStatusMeta.solidClass — cùng taxonomy với
|
|
552
|
-
header đơn bán hàng); bấm để đổi Hoạt động ↔ Khóa */}
|
|
553
593
|
{(() => {
|
|
554
594
|
const statusMeta = getStatusMeta(
|
|
555
595
|
formData.status,
|
|
@@ -613,7 +653,7 @@ export function RoleFormPage({
|
|
|
613
653
|
</div>
|
|
614
654
|
|
|
615
655
|
<div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
|
|
616
|
-
{/* ===== 2. Toolbar sticky: search · bộ quyền mẫu
|
|
656
|
+
{/* ===== 2. Toolbar sticky: search · bộ quyền mẫu · đếm · tab ===== */}
|
|
617
657
|
<div className="sticky top-0 z-20 border-b border-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/85">
|
|
618
658
|
<div className="flex flex-wrap items-center gap-2 px-3 py-2 sm:px-4">
|
|
619
659
|
<div className="relative min-w-[140px] flex-1 sm:max-w-64">
|
|
@@ -658,7 +698,7 @@ export function RoleFormPage({
|
|
|
658
698
|
</p>
|
|
659
699
|
</div>
|
|
660
700
|
|
|
661
|
-
{/* Thanh TAB phân hệ
|
|
701
|
+
{/* Thanh TAB phân hệ */}
|
|
662
702
|
<div className="flex items-center gap-1 overflow-x-auto px-3 pb-2 sm:px-4 [scrollbar-width:thin]">
|
|
663
703
|
<button
|
|
664
704
|
type="button"
|
|
@@ -709,9 +749,19 @@ export function RoleFormPage({
|
|
|
709
749
|
)
|
|
710
750
|
})}
|
|
711
751
|
</div>
|
|
752
|
+
|
|
753
|
+
{/* Chú giải 5 nấc — 1 dòng mảnh */}
|
|
754
|
+
<div className="hidden items-center gap-3 border-t border-border/60 px-4 py-1 text-[11px] text-muted-foreground md:flex">
|
|
755
|
+
{LEVELS.map((l) => (
|
|
756
|
+
<span key={l.key} className="flex items-center gap-1">
|
|
757
|
+
<span className="font-medium text-foreground">{l.label}</span>
|
|
758
|
+
<span className="opacity-80">= {l.hint}</span>
|
|
759
|
+
</span>
|
|
760
|
+
))}
|
|
761
|
+
</div>
|
|
712
762
|
</div>
|
|
713
763
|
|
|
714
|
-
{/* ===== 3.
|
|
764
|
+
{/* ===== 3. Dòng tài nguyên: mức truy cập + thao tác nghiệp vụ ===== */}
|
|
715
765
|
{visibleGroups.length === 0 ? (
|
|
716
766
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
717
767
|
<Search className="mb-3 h-6 w-6 text-muted-foreground" />
|
|
@@ -721,12 +771,13 @@ export function RoleFormPage({
|
|
|
721
771
|
</div>
|
|
722
772
|
) : (
|
|
723
773
|
visibleGroups.map(([group, groupResources]) => {
|
|
724
|
-
const
|
|
774
|
+
const codes = groupPermissionCodes(groupResources)
|
|
775
|
+
const selected = countSelected(codes)
|
|
776
|
+
const total = codes.length
|
|
725
777
|
const isFull = total > 0 && selected === total
|
|
726
778
|
|
|
727
779
|
return (
|
|
728
780
|
<section key={group}>
|
|
729
|
-
{/* Tiêu đề nhóm */}
|
|
730
781
|
<div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
|
|
731
782
|
<h3 className="truncate text-xs font-semibold uppercase tracking-wide text-foreground">
|
|
732
783
|
{group}
|
|
@@ -747,7 +798,7 @@ export function RoleFormPage({
|
|
|
747
798
|
type="button"
|
|
748
799
|
variant="ghost"
|
|
749
800
|
size="sm"
|
|
750
|
-
onClick={() => toggleAllForGroup(
|
|
801
|
+
onClick={() => toggleAllForGroup(groupResources, false)}
|
|
751
802
|
disabled={selected === 0}
|
|
752
803
|
className="h-6 px-2 text-xs text-muted-foreground hover:text-destructive"
|
|
753
804
|
>
|
|
@@ -757,7 +808,7 @@ export function RoleFormPage({
|
|
|
757
808
|
type="button"
|
|
758
809
|
variant="ghost"
|
|
759
810
|
size="sm"
|
|
760
|
-
onClick={() => toggleAllForGroup(
|
|
811
|
+
onClick={() => toggleAllForGroup(groupResources, true)}
|
|
761
812
|
disabled={isFull}
|
|
762
813
|
className="h-6 px-2 text-xs font-semibold text-primary hover:text-primary"
|
|
763
814
|
>
|
|
@@ -766,193 +817,178 @@ export function RoleFormPage({
|
|
|
766
817
|
</div>
|
|
767
818
|
</div>
|
|
768
819
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
? "text-primary hover:bg-primary/10"
|
|
803
|
-
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
804
|
-
)}
|
|
805
|
-
>
|
|
806
|
-
{col.label}
|
|
807
|
-
{applicable > 0 && (
|
|
808
|
-
<span className="text-[9px] tabular-nums opacity-70">
|
|
809
|
-
{colSel}/{applicable}
|
|
810
|
-
</span>
|
|
811
|
-
)}
|
|
812
|
-
</button>
|
|
813
|
-
</th>
|
|
814
|
-
)
|
|
815
|
-
})}
|
|
816
|
-
<th className="px-2 py-1.5 text-left text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
817
|
-
Đặc thù
|
|
818
|
-
</th>
|
|
819
|
-
<th className="w-16 px-2 py-1.5 text-right text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
820
|
-
Tất cả
|
|
821
|
-
</th>
|
|
822
|
-
</tr>
|
|
823
|
-
</thead>
|
|
824
|
-
<tbody className="divide-y divide-border/60">
|
|
825
|
-
{groupResources.map((resource) => (
|
|
826
|
-
<tr
|
|
827
|
-
key={resource.resource}
|
|
828
|
-
className="transition-colors hover:bg-muted/30"
|
|
829
|
-
>
|
|
830
|
-
<td className="px-4 py-2">
|
|
831
|
-
<div className="flex items-center gap-2">
|
|
832
|
-
<DynamicIcon
|
|
833
|
-
name={resource.icon as any}
|
|
834
|
-
className="h-4 w-4 shrink-0 text-muted-foreground"
|
|
835
|
-
/>
|
|
836
|
-
<div className="min-w-0">
|
|
837
|
-
<div className="truncate font-medium text-foreground">
|
|
838
|
-
{resource.name}
|
|
839
|
-
</div>
|
|
840
|
-
<code className="block truncate font-mono text-[10px] text-muted-foreground">
|
|
841
|
-
{resource.resource}
|
|
842
|
-
</code>
|
|
820
|
+
<ul className="divide-y divide-border/60 border-b border-border">
|
|
821
|
+
{groupResources.map((r) => {
|
|
822
|
+
const level = deriveLevel(r)
|
|
823
|
+
const bizOn = r.businessActions.filter((a) =>
|
|
824
|
+
has(r.resource, a)
|
|
825
|
+
).length
|
|
826
|
+
const expanded = expandedRows.has(r.resource)
|
|
827
|
+
const page = resourcePages?.[r.resource]
|
|
828
|
+
|
|
829
|
+
// Nhóm thao tác theo LUỒNG
|
|
830
|
+
const flows = new Map<string, string[]>()
|
|
831
|
+
r.businessActions.forEach((code) => {
|
|
832
|
+
const flow = actionMeta?.[code]?.flow || "Khác"
|
|
833
|
+
const list = flows.get(flow) ?? []
|
|
834
|
+
list.push(code)
|
|
835
|
+
flows.set(flow, list)
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
return (
|
|
839
|
+
<li key={r.resource} className="px-4 py-2.5">
|
|
840
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
|
841
|
+
{/* Tên + trang */}
|
|
842
|
+
<div className="flex min-w-0 flex-1 basis-52 items-center gap-2">
|
|
843
|
+
<DynamicIcon
|
|
844
|
+
name={r.icon as any}
|
|
845
|
+
className="h-4 w-4 shrink-0 text-muted-foreground"
|
|
846
|
+
/>
|
|
847
|
+
<div className="min-w-0">
|
|
848
|
+
<div className="truncate text-sm font-medium text-foreground">
|
|
849
|
+
{r.name}
|
|
850
|
+
</div>
|
|
851
|
+
<div className="truncate font-mono text-[10px] text-muted-foreground">
|
|
852
|
+
{page || r.resource}
|
|
843
853
|
</div>
|
|
844
854
|
</div>
|
|
845
|
-
</
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
855
|
+
</div>
|
|
856
|
+
|
|
857
|
+
{/* Segmented 5 nấc */}
|
|
858
|
+
<div className="flex shrink-0 overflow-hidden rounded-lg border border-border">
|
|
859
|
+
{LEVELS.map((l, i) => {
|
|
860
|
+
const disabled =
|
|
861
|
+
(l.key === "lookup" && !r.hasLookup) ||
|
|
862
|
+
(l.key === "write" &&
|
|
863
|
+
r.writeCodes.length === 0) ||
|
|
864
|
+
(l.key === "full" &&
|
|
865
|
+
r.writeCodes.length === 0 &&
|
|
866
|
+
!r.hasDelete)
|
|
867
|
+
const active = level === l.key
|
|
849
868
|
return (
|
|
850
|
-
<
|
|
851
|
-
key={
|
|
852
|
-
|
|
869
|
+
<button
|
|
870
|
+
key={l.key}
|
|
871
|
+
type="button"
|
|
872
|
+
disabled={disabled}
|
|
873
|
+
onClick={() => setLevel(r, l.key)}
|
|
874
|
+
title={l.hint}
|
|
875
|
+
className={cn(
|
|
876
|
+
"px-2 py-1 text-[11px] font-medium transition-colors",
|
|
877
|
+
i > 0 && "border-l border-border",
|
|
878
|
+
disabled
|
|
879
|
+
? "cursor-not-allowed bg-muted/40 text-muted-foreground/40"
|
|
880
|
+
: active
|
|
881
|
+
? l.key === "hidden"
|
|
882
|
+
? "bg-muted text-foreground"
|
|
883
|
+
: "bg-primary text-primary-foreground"
|
|
884
|
+
: "bg-card text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
885
|
+
)}
|
|
853
886
|
>
|
|
854
|
-
|
|
855
|
-
</
|
|
887
|
+
{l.label}
|
|
888
|
+
</button>
|
|
856
889
|
)
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
/>
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
890
|
+
})}
|
|
891
|
+
</div>
|
|
892
|
+
|
|
893
|
+
{/* Thao tác nghiệp vụ — nút thu gọn */}
|
|
894
|
+
{r.businessActions.length > 0 && (
|
|
895
|
+
<button
|
|
896
|
+
type="button"
|
|
897
|
+
onClick={() => toggleRowExpand(r.resource)}
|
|
898
|
+
className={cn(
|
|
899
|
+
"flex shrink-0 items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium transition-colors",
|
|
900
|
+
bizOn > 0
|
|
901
|
+
? "border-primary/40 bg-primary/5 text-primary"
|
|
902
|
+
: "border-border bg-card text-muted-foreground hover:text-foreground"
|
|
903
|
+
)}
|
|
904
|
+
>
|
|
905
|
+
{expanded ? (
|
|
906
|
+
<ChevronDown className="h-3 w-3" />
|
|
907
|
+
) : (
|
|
908
|
+
<ChevronRight className="h-3 w-3" />
|
|
909
|
+
)}
|
|
910
|
+
Thao tác nghiệp vụ
|
|
911
|
+
<span className="tabular-nums">
|
|
912
|
+
{bizOn}/{r.businessActions.length}
|
|
913
|
+
</span>
|
|
914
|
+
</button>
|
|
915
|
+
)}
|
|
916
|
+
</div>
|
|
917
|
+
|
|
918
|
+
{/* Panel thao tác nghiệp vụ — nhóm theo LUỒNG */}
|
|
919
|
+
{expanded && r.businessActions.length > 0 && (
|
|
920
|
+
<div className="mt-2 space-y-1.5 rounded-lg border border-border/70 bg-muted/20 px-3 py-2 sm:ml-6">
|
|
921
|
+
{level === "hidden" || level === "lookup" ? (
|
|
922
|
+
<p className="text-[11px] text-muted-foreground">
|
|
923
|
+
Cần mức <b>Xem</b> trở lên — tick một thao tác
|
|
924
|
+
sẽ tự nâng lên Xem.
|
|
925
|
+
</p>
|
|
926
|
+
) : null}
|
|
927
|
+
{Array.from(flows.entries()).map(
|
|
928
|
+
([flow, codes]) => (
|
|
929
|
+
<div
|
|
930
|
+
key={flow}
|
|
931
|
+
className="flex flex-wrap items-center gap-1.5"
|
|
932
|
+
>
|
|
933
|
+
<span className="w-24 shrink-0 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
934
|
+
{flow}
|
|
935
|
+
</span>
|
|
936
|
+
{codes.map((code) => {
|
|
937
|
+
const isChecked = has(r.resource, code)
|
|
938
|
+
const meta = actionMeta?.[code]
|
|
939
|
+
const visual = getActionVisual(code)
|
|
940
|
+
const ActionIcon = visual.icon
|
|
941
|
+
return (
|
|
942
|
+
<button
|
|
943
|
+
key={code}
|
|
944
|
+
type="button"
|
|
945
|
+
onClick={() =>
|
|
946
|
+
toggleBusinessAction(r, code)
|
|
947
|
+
}
|
|
948
|
+
title={
|
|
949
|
+
meta?.description ||
|
|
950
|
+
actionName(code)
|
|
951
|
+
}
|
|
908
952
|
className={cn(
|
|
909
|
-
"flex
|
|
953
|
+
"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium transition-colors",
|
|
910
954
|
isChecked
|
|
911
|
-
? "border-primary bg-primary text-primary
|
|
912
|
-
: "border-border bg-card text-
|
|
955
|
+
? "border-primary/40 bg-primary/10 text-primary"
|
|
956
|
+
: "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground"
|
|
913
957
|
)}
|
|
914
958
|
>
|
|
915
|
-
<
|
|
916
|
-
className=
|
|
917
|
-
|
|
959
|
+
<span
|
|
960
|
+
className={cn(
|
|
961
|
+
"flex h-3 w-3 shrink-0 items-center justify-center rounded-[3px] border transition-colors",
|
|
962
|
+
isChecked
|
|
963
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
964
|
+
: "border-border bg-card text-transparent"
|
|
965
|
+
)}
|
|
966
|
+
>
|
|
967
|
+
<Check
|
|
968
|
+
className="h-2 w-2"
|
|
969
|
+
strokeWidth={3}
|
|
970
|
+
/>
|
|
971
|
+
</span>
|
|
972
|
+
<ActionIcon
|
|
973
|
+
className={cn(
|
|
974
|
+
"h-3 w-3 shrink-0",
|
|
975
|
+
visual.cls,
|
|
976
|
+
!isChecked && "opacity-55"
|
|
977
|
+
)}
|
|
918
978
|
/>
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
)}
|
|
926
|
-
/>
|
|
927
|
-
{actionName(code)}
|
|
928
|
-
</button>
|
|
929
|
-
)
|
|
930
|
-
})}
|
|
931
|
-
</div>
|
|
979
|
+
{actionName(code)}
|
|
980
|
+
</button>
|
|
981
|
+
)
|
|
982
|
+
})}
|
|
983
|
+
</div>
|
|
984
|
+
)
|
|
932
985
|
)}
|
|
933
|
-
</
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
)}
|
|
940
|
-
onCheckedChange={(checked) =>
|
|
941
|
-
toggleAllForResource(
|
|
942
|
-
resource.resource,
|
|
943
|
-
resource.actions,
|
|
944
|
-
checked
|
|
945
|
-
)
|
|
946
|
-
}
|
|
947
|
-
className="origin-right scale-75"
|
|
948
|
-
aria-label={`Toàn quyền ${resource.name}`}
|
|
949
|
-
/>
|
|
950
|
-
</td>
|
|
951
|
-
</tr>
|
|
952
|
-
))}
|
|
953
|
-
</tbody>
|
|
954
|
-
</table>
|
|
955
|
-
</div>
|
|
986
|
+
</div>
|
|
987
|
+
)}
|
|
988
|
+
</li>
|
|
989
|
+
)
|
|
990
|
+
})}
|
|
991
|
+
</ul>
|
|
956
992
|
</section>
|
|
957
993
|
)
|
|
958
994
|
})
|