@goplusvn/core 0.1.38 → 0.1.40
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 +483 -406
package/package.json
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// -
|
|
13
|
-
// -
|
|
5
|
+
// Ma trận quyền v4 — 2 TẦNG kiểu Odoo (chốt với chủ app 2026-07):
|
|
6
|
+
// TẦNG 1 (mặt tiền): mỗi PHÂN HỆ một dropdown mức
|
|
7
|
+
// [Không truy cập | Tra cứu | Xem | Nhân viên | Quản lý]
|
|
8
|
+
// — chọn mức áp trọn bộ quyền chuẩn của phân hệ; lệch chuẩn → "Tùy chỉnh".
|
|
9
|
+
// TẦNG 2 (bấm "Tùy chỉnh ▸" mới bung): chi tiết từng tài nguyên như v3:
|
|
10
|
+
// - TAB theo phân hệ (đếm x/y realtime).
|
|
11
|
+
// - Mỗi tài nguyên MỘT DÒNG: [Ẩn | Tra cứu | Xem | Ghi | Toàn quyền]
|
|
12
|
+
// Ẩn = không menu, không quyền gì
|
|
13
|
+
// Tra cứu = chỉ chọn trong combo/selector (lookup) — KHÔNG thấy menu
|
|
14
|
+
// Xem = menu sidebar hiện + vào được trang
|
|
15
|
+
// Ghi = Xem + Tạo/Sửa/Xuất/Nhập
|
|
16
|
+
// Toàn quyền= Ghi + Xóa
|
|
17
|
+
// - Thao tác NGHIỆP VỤ (duyệt, thanh toán, MISA…) thu gọn "n/m ▸", bung ra
|
|
18
|
+
// nhóm theo LUỒNG (prop actionMeta.flow), tooltip mô tả, và tự cưỡng chế
|
|
19
|
+
// quyền tiên quyết (actionMeta.requires — mặc định cần Xem).
|
|
20
|
+
// - resourcePages: đường dẫn trang hiện dưới tên tài nguyên.
|
|
14
21
|
// API contract giữ nguyên: POST /api/roles, PUT /api/roles/:id, quyền dạng
|
|
15
22
|
// `${action}:${resource}`.
|
|
16
23
|
import * as React from "react"
|
|
@@ -18,7 +25,6 @@ import { useRouter } from "next/navigation"
|
|
|
18
25
|
import {
|
|
19
26
|
Badge,
|
|
20
27
|
Button,
|
|
21
|
-
Checkbox,
|
|
22
28
|
DynamicIcon,
|
|
23
29
|
Input,
|
|
24
30
|
Select,
|
|
@@ -26,7 +32,6 @@ import {
|
|
|
26
32
|
SelectItem,
|
|
27
33
|
SelectTrigger,
|
|
28
34
|
SelectValue,
|
|
29
|
-
Switch,
|
|
30
35
|
getStatusMeta,
|
|
31
36
|
} from "../../ui"
|
|
32
37
|
import { toast } from "sonner"
|
|
@@ -36,6 +41,8 @@ import {
|
|
|
36
41
|
Ban,
|
|
37
42
|
Bot,
|
|
38
43
|
Check,
|
|
44
|
+
ChevronDown,
|
|
45
|
+
ChevronRight,
|
|
39
46
|
Copy,
|
|
40
47
|
Download,
|
|
41
48
|
Eye,
|
|
@@ -80,20 +87,48 @@ const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
|
|
|
80
87
|
delete_items: { label: "Xóa chi tiết", icon: "❌", color: "text-rose-600" },
|
|
81
88
|
}
|
|
82
89
|
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
// Action CHUẨN gộp vào thang "Mức truy cập" — không hiện lẻ.
|
|
91
|
+
const WRITE_CODES = ["create", "update", "export", "import"] as const
|
|
92
|
+
const STANDARD_CODES = new Set([
|
|
93
|
+
"view",
|
|
94
|
+
"lookup",
|
|
95
|
+
"delete",
|
|
96
|
+
...WRITE_CODES,
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
type AccessLevel = "hidden" | "lookup" | "view" | "write" | "full"
|
|
100
|
+
|
|
101
|
+
const LEVELS: Array<{ key: AccessLevel; label: string; hint: string }> = [
|
|
102
|
+
{ key: "hidden", label: "Ẩn", hint: "Không thấy menu, không quyền gì" },
|
|
103
|
+
{
|
|
104
|
+
key: "lookup",
|
|
105
|
+
label: "Tra cứu",
|
|
106
|
+
hint: "Chỉ chọn trong combo/selector — KHÔNG thấy menu",
|
|
107
|
+
},
|
|
108
|
+
{ key: "view", label: "Xem", hint: "Menu sidebar hiện + vào được trang" },
|
|
109
|
+
{ key: "write", label: "Ghi", hint: "Xem + Tạo/Sửa/Xuất/Nhập" },
|
|
110
|
+
{ key: "full", label: "Toàn quyền", hint: "Ghi + Xóa" },
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
// Mức TẦNG 1 (phân hệ) — thang Odoo-style.
|
|
114
|
+
type GroupLevel = "none" | "lookup" | "view" | "staff" | "manager"
|
|
115
|
+
const GROUP_LEVELS: Array<{ key: GroupLevel; label: string; hint: string }> = [
|
|
116
|
+
{ key: "none", label: "Không truy cập", hint: "Ẩn toàn bộ phân hệ" },
|
|
117
|
+
{ key: "lookup", label: "Tra cứu", hint: "Chỉ chọn trong combo — không thấy menu" },
|
|
118
|
+
{ key: "view", label: "Xem", hint: "Thấy menu + đọc dữ liệu" },
|
|
119
|
+
{
|
|
120
|
+
key: "staff",
|
|
121
|
+
label: "Nhân viên",
|
|
122
|
+
hint: "Xem + tạo/sửa/xuất/nhập + thao tác nghiệp vụ hằng ngày",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
key: "manager",
|
|
126
|
+
label: "Quản lý",
|
|
127
|
+
hint: "Toàn quyền: thêm xóa, duyệt, hủy, sửa giá, xem giá vốn…",
|
|
128
|
+
},
|
|
93
129
|
]
|
|
94
|
-
const STANDARD_CODES = new Set(STANDARD_COLUMNS.map((c) => c.code))
|
|
95
130
|
|
|
96
|
-
// --- Icon + màu theo LOẠI action (suy từ mã) — mọi action
|
|
131
|
+
// --- Icon + màu theo LOẠI action (suy từ mã) — mọi action nghiệp vụ đều có
|
|
97
132
|
// icon mà không cần cấu hình từng cái. ---
|
|
98
133
|
const ACTION_VISUAL_RULES: Array<{
|
|
99
134
|
re: RegExp
|
|
@@ -115,7 +150,11 @@ const ACTION_VISUAL_RULES: Array<{
|
|
|
115
150
|
{ re: /^export/, icon: Upload, cls: "text-indigo-500" },
|
|
116
151
|
{ re: /^print/, icon: Printer, cls: "text-cyan-600" },
|
|
117
152
|
{ re: /^(sync|unlink)/, icon: RefreshCw, cls: "text-cyan-600" },
|
|
118
|
-
{
|
|
153
|
+
{
|
|
154
|
+
re: /^(manage|config|close|reopen)/,
|
|
155
|
+
icon: Settings2,
|
|
156
|
+
cls: "text-orange-500",
|
|
157
|
+
},
|
|
119
158
|
{ re: /^auto/, icon: Bot, cls: "text-purple-500" },
|
|
120
159
|
]
|
|
121
160
|
|
|
@@ -125,8 +164,7 @@ const getActionVisual = (code: string) =>
|
|
|
125
164
|
cls: "text-slate-400",
|
|
126
165
|
}
|
|
127
166
|
|
|
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}`. */
|
|
167
|
+
/** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
|
|
130
168
|
export interface RoleTemplate {
|
|
131
169
|
code: string
|
|
132
170
|
label: string
|
|
@@ -134,6 +172,16 @@ export interface RoleTemplate {
|
|
|
134
172
|
permissions: string[]
|
|
135
173
|
}
|
|
136
174
|
|
|
175
|
+
/** Metadata action nghiệp vụ: nhóm luồng + mô tả + quyền tiên quyết. */
|
|
176
|
+
export interface ActionMetaEntry {
|
|
177
|
+
label?: string
|
|
178
|
+
description?: string
|
|
179
|
+
flow?: string
|
|
180
|
+
requires?: string[]
|
|
181
|
+
/** Mức tầng-phân-hệ chứa action này: staff (Nhân viên) | manager (Quản lý). */
|
|
182
|
+
level?: "staff" | "manager"
|
|
183
|
+
}
|
|
184
|
+
|
|
137
185
|
export interface RoleFormPageProps {
|
|
138
186
|
dictionary: any
|
|
139
187
|
permissions?: CrudPermissions
|
|
@@ -167,6 +215,10 @@ export interface RoleFormPageProps {
|
|
|
167
215
|
actionLabels?: Record<string, ActionLabelConfig>
|
|
168
216
|
/** Bộ quyền mẫu hiện ở chế độ tạo mới (additive — không truyền thì ẩn). */
|
|
169
217
|
roleTemplates?: RoleTemplate[]
|
|
218
|
+
/** Meta action nghiệp vụ theo code (flow/description/requires) — additive. */
|
|
219
|
+
actionMeta?: Record<string, ActionMetaEntry>
|
|
220
|
+
/** Đường dẫn trang theo resource code (hiện dưới tên) — additive. */
|
|
221
|
+
resourcePages?: Record<string, string>
|
|
170
222
|
}
|
|
171
223
|
|
|
172
224
|
export function RoleFormPage({
|
|
@@ -179,6 +231,8 @@ export function RoleFormPage({
|
|
|
179
231
|
initialData,
|
|
180
232
|
actionLabels,
|
|
181
233
|
roleTemplates,
|
|
234
|
+
actionMeta,
|
|
235
|
+
resourcePages,
|
|
182
236
|
}: RoleFormPageProps) {
|
|
183
237
|
const router = useRouter()
|
|
184
238
|
|
|
@@ -198,7 +252,30 @@ export function RoleFormPage({
|
|
|
198
252
|
permissions: initialData?.permissions || ([] as string[]),
|
|
199
253
|
})
|
|
200
254
|
|
|
201
|
-
const [
|
|
255
|
+
const [expandedRows, setExpandedRows] = React.useState<Set<string>>(
|
|
256
|
+
() => new Set()
|
|
257
|
+
)
|
|
258
|
+
const [expandedGroups, setExpandedGroups] = React.useState<Set<string>>(
|
|
259
|
+
() => new Set()
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
const toggleGroupExpand = (g: string) => {
|
|
263
|
+
setExpandedGroups((prev) => {
|
|
264
|
+
const next = new Set(prev)
|
|
265
|
+
if (next.has(g)) next.delete(g)
|
|
266
|
+
else next.add(g)
|
|
267
|
+
return next
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const toggleRowExpand = (code: string) => {
|
|
272
|
+
setExpandedRows((prev) => {
|
|
273
|
+
const next = new Set(prev)
|
|
274
|
+
if (next.has(code)) next.delete(code)
|
|
275
|
+
else next.add(code)
|
|
276
|
+
return next
|
|
277
|
+
})
|
|
278
|
+
}
|
|
202
279
|
|
|
203
280
|
const getLocalizedName = React.useCallback(
|
|
204
281
|
(key: string | null) => {
|
|
@@ -221,10 +298,14 @@ export function RoleFormPage({
|
|
|
221
298
|
|
|
222
299
|
const actionName = React.useCallback(
|
|
223
300
|
(code: string) => {
|
|
224
|
-
|
|
225
|
-
|
|
301
|
+
return (
|
|
302
|
+
actionMeta?.[code]?.label ||
|
|
303
|
+
actions.find((a) => a.code === code)?.name ||
|
|
304
|
+
mergedActionLabels[code]?.label ||
|
|
305
|
+
code
|
|
306
|
+
)
|
|
226
307
|
},
|
|
227
|
-
[actions, mergedActionLabels]
|
|
308
|
+
[actions, actionMeta, mergedActionLabels]
|
|
228
309
|
)
|
|
229
310
|
|
|
230
311
|
const permissionMatrix = React.useMemo(() => {
|
|
@@ -273,11 +354,18 @@ export function RoleFormPage({
|
|
|
273
354
|
group: getLocalizedName(resource.group) || "Khác",
|
|
274
355
|
description: resource.description,
|
|
275
356
|
actions: allowedActions,
|
|
276
|
-
|
|
357
|
+
businessActions: allowedActions.filter(
|
|
358
|
+
(a) => !STANDARD_CODES.has(a)
|
|
359
|
+
),
|
|
360
|
+
hasLookup: allowedActions.includes("lookup"),
|
|
361
|
+
writeCodes: WRITE_CODES.filter((c) => allowedActions.includes(c)),
|
|
362
|
+
hasDelete: allowedActions.includes("delete"),
|
|
277
363
|
}
|
|
278
364
|
})
|
|
279
365
|
}, [resources, actions, getLocalizedName])
|
|
280
366
|
|
|
367
|
+
type MatrixRow = (typeof permissionMatrix)[number]
|
|
368
|
+
|
|
281
369
|
const totalAvailablePermissions = React.useMemo(
|
|
282
370
|
() => permissionMatrix.reduce((sum, r) => sum + r.actions.length, 0),
|
|
283
371
|
[permissionMatrix]
|
|
@@ -296,7 +384,7 @@ export function RoleFormPage({
|
|
|
296
384
|
}, [searchTerm, permissionMatrix])
|
|
297
385
|
|
|
298
386
|
const groupedMatrix = React.useMemo(() => {
|
|
299
|
-
const groups: Record<string,
|
|
387
|
+
const groups: Record<string, MatrixRow[]> = {}
|
|
300
388
|
filteredMatrix.forEach((resource) => {
|
|
301
389
|
const group = resource.group
|
|
302
390
|
if (!groups[group]) {
|
|
@@ -307,147 +395,172 @@ export function RoleFormPage({
|
|
|
307
395
|
return groups
|
|
308
396
|
}, [filteredMatrix])
|
|
309
397
|
|
|
310
|
-
// Tab dựng theo TOÀN BỘ matrix (không theo search) để thanh tab ổn định.
|
|
311
|
-
const allGroups = React.useMemo(() => {
|
|
312
|
-
const seen: string[] = []
|
|
313
|
-
permissionMatrix.forEach((r) => {
|
|
314
|
-
if (!seen.includes(r.group)) seen.push(r.group)
|
|
315
|
-
})
|
|
316
|
-
return seen
|
|
317
|
-
}, [permissionMatrix])
|
|
318
|
-
|
|
319
|
-
React.useEffect(() => {
|
|
320
|
-
if (activeGroup !== "all" && !allGroups.includes(activeGroup)) {
|
|
321
|
-
setActiveGroup("all")
|
|
322
|
-
}
|
|
323
|
-
}, [allGroups, activeGroup])
|
|
324
398
|
|
|
325
|
-
// Đang search →
|
|
399
|
+
// Đang search → mọi nhóm khớp tự bung phần chi tiết.
|
|
326
400
|
const searching = searchTerm.trim().length > 0
|
|
327
401
|
|
|
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]
|
|
402
|
+
const has = React.useCallback(
|
|
403
|
+
(resource: string, action: string) =>
|
|
404
|
+
formData.permissions.includes(`${action}:${resource}`),
|
|
405
|
+
[formData.permissions]
|
|
352
406
|
)
|
|
353
407
|
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
408
|
+
const mutatePermissions = (
|
|
409
|
+
fn: (set: Set<string>) => void
|
|
410
|
+
) => {
|
|
411
|
+
setFormData((prev) => {
|
|
412
|
+
const set = new Set(prev.permissions)
|
|
413
|
+
fn(set)
|
|
414
|
+
return { ...prev, permissions: Array.from(set) }
|
|
415
|
+
})
|
|
416
|
+
}
|
|
357
417
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
)
|
|
418
|
+
// ── Mức truy cập ──────────────────────────────────────────────────────
|
|
419
|
+
const deriveLevel = (r: MatrixRow): AccessLevel => {
|
|
420
|
+
if (has(r.resource, "view")) {
|
|
421
|
+
const anyWrite = r.writeCodes.some((c) => has(r.resource, c))
|
|
422
|
+
if (!anyWrite && !(r.hasDelete && has(r.resource, "delete")))
|
|
423
|
+
return "view"
|
|
424
|
+
const fullWrite = r.writeCodes.every((c) => has(r.resource, c))
|
|
425
|
+
if (fullWrite && (!r.hasDelete || has(r.resource, "delete")))
|
|
426
|
+
return "full"
|
|
427
|
+
return "write"
|
|
365
428
|
}
|
|
429
|
+
if (r.hasLookup && has(r.resource, "lookup")) return "lookup"
|
|
430
|
+
return "hidden"
|
|
431
|
+
}
|
|
366
432
|
|
|
367
|
-
|
|
433
|
+
const setLevel = (r: MatrixRow, level: AccessLevel) => {
|
|
434
|
+
mutatePermissions((set) => {
|
|
435
|
+
const rm = (a: string) => set.delete(`${a}:${r.resource}`)
|
|
436
|
+
const add = (a: string) => set.add(`${a}:${r.resource}`)
|
|
437
|
+
// reset phần chuẩn
|
|
438
|
+
;["view", "lookup", "delete", ...WRITE_CODES].forEach(rm)
|
|
439
|
+
if (level === "hidden" || level === "lookup") {
|
|
440
|
+
// mất Xem → thao tác nghiệp vụ (đều cần Xem) tắt theo
|
|
441
|
+
const on = r.businessActions.filter((a) =>
|
|
442
|
+
set.has(`${a}:${r.resource}`)
|
|
443
|
+
)
|
|
444
|
+
r.businessActions.forEach(rm)
|
|
445
|
+
if (on.length > 0) {
|
|
446
|
+
toast.info(
|
|
447
|
+
`Đã tắt ${on.length} thao tác nghiệp vụ của "${r.name}" (cần mức Xem trở lên)`
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
if (level === "lookup" && r.hasLookup) add("lookup")
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
add("view")
|
|
454
|
+
if (level === "write" || level === "full") {
|
|
455
|
+
r.writeCodes.forEach(add)
|
|
456
|
+
}
|
|
457
|
+
if (level === "full" && r.hasDelete) add("delete")
|
|
458
|
+
})
|
|
368
459
|
}
|
|
369
460
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
461
|
+
// ── TẦNG 1: mức theo PHÂN HỆ ──────────────────────────────────────────
|
|
462
|
+
const canonicalForResource = React.useCallback(
|
|
463
|
+
(r: MatrixRow, level: GroupLevel): string[] => {
|
|
464
|
+
switch (level) {
|
|
465
|
+
case "none":
|
|
466
|
+
return []
|
|
467
|
+
case "lookup":
|
|
468
|
+
return r.hasLookup ? [`lookup:${r.resource}`] : []
|
|
469
|
+
case "view":
|
|
470
|
+
return r.actions.includes("view") ? [`view:${r.resource}`] : []
|
|
471
|
+
case "staff": {
|
|
472
|
+
const staffBiz = r.businessActions.filter(
|
|
473
|
+
(a) => actionMeta?.[a]?.level !== "manager"
|
|
474
|
+
)
|
|
475
|
+
return [
|
|
476
|
+
...(r.actions.includes("view") ? ["view"] : []),
|
|
477
|
+
...r.writeCodes,
|
|
478
|
+
...staffBiz,
|
|
479
|
+
].map((a) => `${a}:${r.resource}`)
|
|
480
|
+
}
|
|
481
|
+
case "manager":
|
|
482
|
+
return r.actions.map((a) => `${a}:${r.resource}`)
|
|
376
483
|
}
|
|
377
484
|
},
|
|
378
|
-
[
|
|
485
|
+
[actionMeta]
|
|
379
486
|
)
|
|
380
487
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
488
|
+
const canonicalForGroup = React.useCallback(
|
|
489
|
+
(rows: MatrixRow[], level: GroupLevel) =>
|
|
490
|
+
rows.flatMap((r) => canonicalForResource(r, level)),
|
|
491
|
+
[canonicalForResource]
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
/** Mức hiện tại của phân hệ — "custom" nếu không khớp mức chuẩn nào. */
|
|
495
|
+
const deriveGroupLevel = React.useCallback(
|
|
496
|
+
(rows: MatrixRow[]): GroupLevel | "custom" => {
|
|
497
|
+
const current = new Set(
|
|
498
|
+
rows
|
|
499
|
+
.flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`))
|
|
500
|
+
.filter((c) => formData.permissions.includes(c))
|
|
501
|
+
)
|
|
502
|
+
for (const l of GROUP_LEVELS) {
|
|
503
|
+
const canonical = canonicalForGroup(rows, l.key)
|
|
504
|
+
if (
|
|
505
|
+
canonical.length === current.size &&
|
|
506
|
+
canonical.every((c) => current.has(c))
|
|
507
|
+
) {
|
|
508
|
+
return l.key
|
|
509
|
+
}
|
|
389
510
|
}
|
|
511
|
+
return "custom"
|
|
390
512
|
},
|
|
391
|
-
[
|
|
513
|
+
[formData.permissions, canonicalForGroup]
|
|
392
514
|
)
|
|
393
515
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
formData.permissions.includes(`${actionCode}:${r.resource}`)
|
|
402
|
-
).length
|
|
403
|
-
return { applicable: rows.length, selected }
|
|
516
|
+
const setGroupLevel = (rows: MatrixRow[], level: GroupLevel) => {
|
|
517
|
+
const all = rows.flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`))
|
|
518
|
+
const canonical = canonicalForGroup(rows, level)
|
|
519
|
+
mutatePermissions((set) => {
|
|
520
|
+
all.forEach((c) => set.delete(c))
|
|
521
|
+
canonical.forEach((c) => set.add(c))
|
|
522
|
+
})
|
|
404
523
|
}
|
|
405
524
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
525
|
+
// ── Thao tác nghiệp vụ + requires ─────────────────────────────────────
|
|
526
|
+
const toggleBusinessAction = (r: MatrixRow, code: string) => {
|
|
527
|
+
const key = `${code}:${r.resource}`
|
|
528
|
+
if (formData.permissions.includes(key)) {
|
|
529
|
+
mutatePermissions((set) => set.delete(key))
|
|
530
|
+
return
|
|
531
|
+
}
|
|
532
|
+
const requires = actionMeta?.[code]?.requires ?? []
|
|
533
|
+
const needed = ["view", ...requires].filter(
|
|
534
|
+
(req) =>
|
|
535
|
+
r.actions.includes(req) &&
|
|
536
|
+
!formData.permissions.includes(`${req}:${r.resource}`)
|
|
409
537
|
)
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
538
|
+
mutatePermissions((set) => {
|
|
539
|
+
set.add(key)
|
|
540
|
+
needed.forEach((req) => set.add(`${req}:${r.resource}`))
|
|
541
|
+
})
|
|
542
|
+
if (needed.length > 0) {
|
|
543
|
+
toast.info(
|
|
544
|
+
`Đã bật kèm quyền tiên quyết: ${needed
|
|
545
|
+
.map((c) => actionName(c))
|
|
546
|
+
.join(", ")}`
|
|
547
|
+
)
|
|
419
548
|
}
|
|
420
|
-
setFormData({ ...formData, permissions: newPermissions })
|
|
421
549
|
}
|
|
422
550
|
|
|
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
|
-
})
|
|
551
|
+
// ── Đếm ───────────────────────────────────────────────────────────────
|
|
552
|
+
const groupPermissionCodes = React.useCallback(
|
|
553
|
+
(rows: MatrixRow[]) =>
|
|
554
|
+
rows.flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`)),
|
|
555
|
+
[]
|
|
556
|
+
)
|
|
438
557
|
|
|
439
|
-
|
|
440
|
-
|
|
558
|
+
const countSelected = React.useCallback(
|
|
559
|
+
(codes: string[]) =>
|
|
560
|
+
codes.filter((p) => formData.permissions.includes(p)).length,
|
|
561
|
+
[formData.permissions]
|
|
562
|
+
)
|
|
441
563
|
|
|
442
|
-
const isResourceFullPermission = (
|
|
443
|
-
resourceCode: string,
|
|
444
|
-
resourceActions: string[]
|
|
445
|
-
) => {
|
|
446
|
-
if (resourceActions.length === 0) return false
|
|
447
|
-
return resourceActions.every((actionCode) =>
|
|
448
|
-
formData.permissions.includes(`${actionCode}:${resourceCode}`)
|
|
449
|
-
)
|
|
450
|
-
}
|
|
451
564
|
|
|
452
565
|
const applyTemplate = (templateCode: string) => {
|
|
453
566
|
const tpl = roleTemplates?.find((t) => t.code === templateCode)
|
|
@@ -494,16 +607,12 @@ export function RoleFormPage({
|
|
|
494
607
|
}
|
|
495
608
|
|
|
496
609
|
const visibleGroups = Object.entries(groupedMatrix).filter(
|
|
497
|
-
([
|
|
498
|
-
groupResources.length > 0 &&
|
|
499
|
-
(searching || activeGroup === "all" || activeGroup === group)
|
|
610
|
+
([, groupResources]) => groupResources.length > 0
|
|
500
611
|
)
|
|
501
612
|
|
|
502
613
|
return (
|
|
503
614
|
<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. ===== */}
|
|
615
|
+
{/* ===== 1. Header bar navy 1 DÒNG ===== */}
|
|
507
616
|
<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
617
|
<Button
|
|
509
618
|
variant="ghost"
|
|
@@ -548,8 +657,6 @@ export function RoleFormPage({
|
|
|
548
657
|
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
658
|
/>
|
|
550
659
|
)}
|
|
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
660
|
{(() => {
|
|
554
661
|
const statusMeta = getStatusMeta(
|
|
555
662
|
formData.status,
|
|
@@ -613,7 +720,7 @@ export function RoleFormPage({
|
|
|
613
720
|
</div>
|
|
614
721
|
|
|
615
722
|
<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
|
|
723
|
+
{/* ===== 2. Toolbar sticky: search · bộ quyền mẫu · đếm · tab ===== */}
|
|
617
724
|
<div className="sticky top-0 z-20 border-b border-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/85">
|
|
618
725
|
<div className="flex flex-wrap items-center gap-2 px-3 py-2 sm:px-4">
|
|
619
726
|
<div className="relative min-w-[140px] flex-1 sm:max-w-64">
|
|
@@ -658,60 +765,18 @@ export function RoleFormPage({
|
|
|
658
765
|
</p>
|
|
659
766
|
</div>
|
|
660
767
|
|
|
661
|
-
{/*
|
|
662
|
-
<div className="flex items-center gap-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
? "border-primary bg-primary text-primary-foreground"
|
|
670
|
-
: "border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
|
671
|
-
)}
|
|
672
|
-
>
|
|
673
|
-
Tất cả
|
|
674
|
-
</button>
|
|
675
|
-
{allGroups.map((group) => {
|
|
676
|
-
const { selected, total } = groupTabCount(group)
|
|
677
|
-
const active = activeGroup === group && !searching
|
|
678
|
-
return (
|
|
679
|
-
<button
|
|
680
|
-
key={group}
|
|
681
|
-
type="button"
|
|
682
|
-
onClick={() => {
|
|
683
|
-
setActiveGroup(group)
|
|
684
|
-
setSearchTerm("")
|
|
685
|
-
}}
|
|
686
|
-
className={cn(
|
|
687
|
-
"flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
|
688
|
-
active
|
|
689
|
-
? "border-primary bg-primary text-primary-foreground"
|
|
690
|
-
: "border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
|
691
|
-
)}
|
|
692
|
-
>
|
|
693
|
-
{group}
|
|
694
|
-
<span
|
|
695
|
-
className={cn(
|
|
696
|
-
"rounded-full px-1 text-[10px] tabular-nums",
|
|
697
|
-
active
|
|
698
|
-
? "bg-primary-foreground/20"
|
|
699
|
-
: selected > 0
|
|
700
|
-
? selected === total
|
|
701
|
-
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
|
702
|
-
: "bg-muted text-foreground"
|
|
703
|
-
: "bg-muted"
|
|
704
|
-
)}
|
|
705
|
-
>
|
|
706
|
-
{selected}/{total}
|
|
707
|
-
</span>
|
|
708
|
-
</button>
|
|
709
|
-
)
|
|
710
|
-
})}
|
|
768
|
+
{/* Chú giải mức phân hệ — 1 dòng mảnh */}
|
|
769
|
+
<div className="hidden flex-wrap items-center gap-x-3 gap-y-0.5 border-t border-border/60 px-4 py-1 text-[11px] text-muted-foreground md:flex">
|
|
770
|
+
{GROUP_LEVELS.map((l) => (
|
|
771
|
+
<span key={l.key} className="flex items-center gap-1">
|
|
772
|
+
<span className="font-medium text-foreground">{l.label}</span>
|
|
773
|
+
<span className="opacity-80">= {l.hint}</span>
|
|
774
|
+
</span>
|
|
775
|
+
))}
|
|
711
776
|
</div>
|
|
712
777
|
</div>
|
|
713
778
|
|
|
714
|
-
{/* ===== 3.
|
|
779
|
+
{/* ===== 3. Dòng tài nguyên: mức truy cập + thao tác nghiệp vụ ===== */}
|
|
715
780
|
{visibleGroups.length === 0 ? (
|
|
716
781
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
717
782
|
<Search className="mb-3 h-6 w-6 text-muted-foreground" />
|
|
@@ -721,14 +786,17 @@ export function RoleFormPage({
|
|
|
721
786
|
</div>
|
|
722
787
|
) : (
|
|
723
788
|
visibleGroups.map(([group, groupResources]) => {
|
|
724
|
-
const
|
|
789
|
+
const codes = groupPermissionCodes(groupResources)
|
|
790
|
+
const selected = countSelected(codes)
|
|
791
|
+
const total = codes.length
|
|
725
792
|
const isFull = total > 0 && selected === total
|
|
793
|
+
const groupLevel = deriveGroupLevel(groupResources)
|
|
794
|
+
const groupExpanded = searching || expandedGroups.has(group)
|
|
726
795
|
|
|
727
796
|
return (
|
|
728
797
|
<section key={group}>
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
<h3 className="truncate text-xs font-semibold uppercase tracking-wide text-foreground">
|
|
798
|
+
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
|
|
799
|
+
<h3 className="truncate text-sm font-semibold text-foreground">
|
|
732
800
|
{group}
|
|
733
801
|
</h3>
|
|
734
802
|
<span
|
|
@@ -742,217 +810,226 @@ export function RoleFormPage({
|
|
|
742
810
|
{groupResources.length} tài nguyên · {selected}/{total}
|
|
743
811
|
{isFull && <Check className="ml-0.5 inline h-3 w-3" />}
|
|
744
812
|
</span>
|
|
745
|
-
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
813
|
+
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
|
814
|
+
{groupLevel === "custom" && (
|
|
815
|
+
<Badge
|
|
816
|
+
variant="outline"
|
|
817
|
+
className="h-6 border-amber-300 bg-amber-50 px-1.5 text-[10px] font-medium text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400"
|
|
818
|
+
>
|
|
819
|
+
Tùy chỉnh
|
|
820
|
+
</Badge>
|
|
821
|
+
)}
|
|
822
|
+
<Select
|
|
823
|
+
value={groupLevel === "custom" ? undefined : groupLevel}
|
|
824
|
+
onValueChange={(v) =>
|
|
825
|
+
setGroupLevel(groupResources, v as GroupLevel)
|
|
826
|
+
}
|
|
753
827
|
>
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
828
|
+
<SelectTrigger className="h-7 w-40 rounded-md border border-border bg-card text-xs">
|
|
829
|
+
<SelectValue placeholder="Tùy chỉnh..." />
|
|
830
|
+
</SelectTrigger>
|
|
831
|
+
<SelectContent className="rounded-lg">
|
|
832
|
+
{GROUP_LEVELS.map((l) => (
|
|
833
|
+
<SelectItem key={l.key} value={l.key}>
|
|
834
|
+
<span className="flex flex-col">
|
|
835
|
+
<span>{l.label}</span>
|
|
836
|
+
<span className="text-[10px] text-muted-foreground">
|
|
837
|
+
{l.hint}
|
|
838
|
+
</span>
|
|
839
|
+
</span>
|
|
840
|
+
</SelectItem>
|
|
841
|
+
))}
|
|
842
|
+
</SelectContent>
|
|
843
|
+
</Select>
|
|
844
|
+
<button
|
|
757
845
|
type="button"
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
onClick={() => toggleAllForGroup(group, true)}
|
|
761
|
-
disabled={isFull}
|
|
762
|
-
className="h-6 px-2 text-xs font-semibold text-primary hover:text-primary"
|
|
846
|
+
onClick={() => toggleGroupExpand(group)}
|
|
847
|
+
className="flex h-7 items-center gap-1 rounded-md border border-border bg-card px-2 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
763
848
|
>
|
|
764
|
-
|
|
765
|
-
|
|
849
|
+
{groupExpanded ? (
|
|
850
|
+
<ChevronDown className="h-3 w-3" />
|
|
851
|
+
) : (
|
|
852
|
+
<ChevronRight className="h-3 w-3" />
|
|
853
|
+
)}
|
|
854
|
+
Tùy chỉnh
|
|
855
|
+
</button>
|
|
766
856
|
</div>
|
|
767
857
|
</div>
|
|
768
858
|
|
|
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
|
-
|
|
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>
|
|
859
|
+
{groupExpanded && (
|
|
860
|
+
<ul className="divide-y divide-border/60 border-b border-border">
|
|
861
|
+
{groupResources.map((r) => {
|
|
862
|
+
const level = deriveLevel(r)
|
|
863
|
+
const bizOn = r.businessActions.filter((a) =>
|
|
864
|
+
has(r.resource, a)
|
|
865
|
+
).length
|
|
866
|
+
const expanded = expandedRows.has(r.resource)
|
|
867
|
+
const page = resourcePages?.[r.resource]
|
|
868
|
+
|
|
869
|
+
// Nhóm thao tác theo LUỒNG
|
|
870
|
+
const flows = new Map<string, string[]>()
|
|
871
|
+
r.businessActions.forEach((code) => {
|
|
872
|
+
const flow = actionMeta?.[code]?.flow || "Khác"
|
|
873
|
+
const list = flows.get(flow) ?? []
|
|
874
|
+
list.push(code)
|
|
875
|
+
flows.set(flow, list)
|
|
876
|
+
})
|
|
877
|
+
|
|
878
|
+
return (
|
|
879
|
+
<li key={r.resource} className="px-4 py-2.5">
|
|
880
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
|
881
|
+
{/* Tên + trang */}
|
|
882
|
+
<div className="flex min-w-0 flex-1 basis-52 items-center gap-2">
|
|
883
|
+
<DynamicIcon
|
|
884
|
+
name={r.icon as any}
|
|
885
|
+
className="h-4 w-4 shrink-0 text-muted-foreground"
|
|
886
|
+
/>
|
|
887
|
+
<div className="min-w-0">
|
|
888
|
+
<div className="truncate text-sm font-medium text-foreground">
|
|
889
|
+
{r.name}
|
|
890
|
+
</div>
|
|
891
|
+
<div className="truncate font-mono text-[10px] text-muted-foreground">
|
|
892
|
+
{page || r.resource}
|
|
843
893
|
</div>
|
|
844
894
|
</div>
|
|
845
|
-
</
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
895
|
+
</div>
|
|
896
|
+
|
|
897
|
+
{/* Segmented 5 nấc */}
|
|
898
|
+
<div className="flex shrink-0 overflow-hidden rounded-lg border border-border">
|
|
899
|
+
{LEVELS.map((l, i) => {
|
|
900
|
+
const disabled =
|
|
901
|
+
(l.key === "lookup" && !r.hasLookup) ||
|
|
902
|
+
(l.key === "write" &&
|
|
903
|
+
r.writeCodes.length === 0) ||
|
|
904
|
+
(l.key === "full" &&
|
|
905
|
+
r.writeCodes.length === 0 &&
|
|
906
|
+
!r.hasDelete)
|
|
907
|
+
const active = level === l.key
|
|
849
908
|
return (
|
|
850
|
-
<
|
|
851
|
-
key={
|
|
852
|
-
|
|
909
|
+
<button
|
|
910
|
+
key={l.key}
|
|
911
|
+
type="button"
|
|
912
|
+
disabled={disabled}
|
|
913
|
+
onClick={() => setLevel(r, l.key)}
|
|
914
|
+
title={l.hint}
|
|
915
|
+
className={cn(
|
|
916
|
+
"px-2 py-1 text-[11px] font-medium transition-colors",
|
|
917
|
+
i > 0 && "border-l border-border",
|
|
918
|
+
disabled
|
|
919
|
+
? "cursor-not-allowed bg-muted/40 text-muted-foreground/40"
|
|
920
|
+
: active
|
|
921
|
+
? l.key === "hidden"
|
|
922
|
+
? "bg-muted text-foreground"
|
|
923
|
+
: "bg-primary text-primary-foreground"
|
|
924
|
+
: "bg-card text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
925
|
+
)}
|
|
853
926
|
>
|
|
854
|
-
|
|
855
|
-
</
|
|
927
|
+
{l.label}
|
|
928
|
+
</button>
|
|
856
929
|
)
|
|
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
|
-
|
|
930
|
+
})}
|
|
931
|
+
</div>
|
|
932
|
+
|
|
933
|
+
{/* Thao tác nghiệp vụ — nút thu gọn */}
|
|
934
|
+
{r.businessActions.length > 0 && (
|
|
935
|
+
<button
|
|
936
|
+
type="button"
|
|
937
|
+
onClick={() => toggleRowExpand(r.resource)}
|
|
938
|
+
className={cn(
|
|
939
|
+
"flex shrink-0 items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium transition-colors",
|
|
940
|
+
bizOn > 0
|
|
941
|
+
? "border-primary/40 bg-primary/5 text-primary"
|
|
942
|
+
: "border-border bg-card text-muted-foreground hover:text-foreground"
|
|
943
|
+
)}
|
|
944
|
+
>
|
|
945
|
+
{expanded ? (
|
|
946
|
+
<ChevronDown className="h-3 w-3" />
|
|
947
|
+
) : (
|
|
948
|
+
<ChevronRight className="h-3 w-3" />
|
|
949
|
+
)}
|
|
950
|
+
Thao tác nghiệp vụ
|
|
951
|
+
<span className="tabular-nums">
|
|
952
|
+
{bizOn}/{r.businessActions.length}
|
|
953
|
+
</span>
|
|
954
|
+
</button>
|
|
955
|
+
)}
|
|
956
|
+
</div>
|
|
957
|
+
|
|
958
|
+
{/* Panel thao tác nghiệp vụ — nhóm theo LUỒNG */}
|
|
959
|
+
{expanded && r.businessActions.length > 0 && (
|
|
960
|
+
<div className="mt-2 space-y-1.5 rounded-lg border border-border/70 bg-muted/20 px-3 py-2 sm:ml-6">
|
|
961
|
+
{level === "hidden" || level === "lookup" ? (
|
|
962
|
+
<p className="text-[11px] text-muted-foreground">
|
|
963
|
+
Cần mức <b>Xem</b> trở lên — tick một thao tác
|
|
964
|
+
sẽ tự nâng lên Xem.
|
|
965
|
+
</p>
|
|
966
|
+
) : null}
|
|
967
|
+
{Array.from(flows.entries()).map(
|
|
968
|
+
([flow, codes]) => (
|
|
969
|
+
<div
|
|
970
|
+
key={flow}
|
|
971
|
+
className="flex flex-wrap items-center gap-1.5"
|
|
972
|
+
>
|
|
973
|
+
<span className="w-24 shrink-0 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
974
|
+
{flow}
|
|
975
|
+
</span>
|
|
976
|
+
{codes.map((code) => {
|
|
977
|
+
const isChecked = has(r.resource, code)
|
|
978
|
+
const meta = actionMeta?.[code]
|
|
979
|
+
const visual = getActionVisual(code)
|
|
980
|
+
const ActionIcon = visual.icon
|
|
981
|
+
return (
|
|
982
|
+
<button
|
|
983
|
+
key={code}
|
|
984
|
+
type="button"
|
|
985
|
+
onClick={() =>
|
|
986
|
+
toggleBusinessAction(r, code)
|
|
987
|
+
}
|
|
988
|
+
title={
|
|
989
|
+
meta?.description ||
|
|
990
|
+
actionName(code)
|
|
991
|
+
}
|
|
908
992
|
className={cn(
|
|
909
|
-
"flex
|
|
993
|
+
"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium transition-colors",
|
|
910
994
|
isChecked
|
|
911
|
-
? "border-primary bg-primary text-primary
|
|
912
|
-
: "border-border bg-card text-
|
|
995
|
+
? "border-primary/40 bg-primary/10 text-primary"
|
|
996
|
+
: "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground"
|
|
913
997
|
)}
|
|
914
998
|
>
|
|
915
|
-
<
|
|
916
|
-
className=
|
|
917
|
-
|
|
999
|
+
<span
|
|
1000
|
+
className={cn(
|
|
1001
|
+
"flex h-3 w-3 shrink-0 items-center justify-center rounded-[3px] border transition-colors",
|
|
1002
|
+
isChecked
|
|
1003
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
1004
|
+
: "border-border bg-card text-transparent"
|
|
1005
|
+
)}
|
|
1006
|
+
>
|
|
1007
|
+
<Check
|
|
1008
|
+
className="h-2 w-2"
|
|
1009
|
+
strokeWidth={3}
|
|
1010
|
+
/>
|
|
1011
|
+
</span>
|
|
1012
|
+
<ActionIcon
|
|
1013
|
+
className={cn(
|
|
1014
|
+
"h-3 w-3 shrink-0",
|
|
1015
|
+
visual.cls,
|
|
1016
|
+
!isChecked && "opacity-55"
|
|
1017
|
+
)}
|
|
918
1018
|
/>
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
)}
|
|
926
|
-
/>
|
|
927
|
-
{actionName(code)}
|
|
928
|
-
</button>
|
|
929
|
-
)
|
|
930
|
-
})}
|
|
931
|
-
</div>
|
|
1019
|
+
{actionName(code)}
|
|
1020
|
+
</button>
|
|
1021
|
+
)
|
|
1022
|
+
})}
|
|
1023
|
+
</div>
|
|
1024
|
+
)
|
|
932
1025
|
)}
|
|
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>
|
|
1026
|
+
</div>
|
|
1027
|
+
)}
|
|
1028
|
+
</li>
|
|
1029
|
+
)
|
|
1030
|
+
})}
|
|
1031
|
+
</ul>
|
|
1032
|
+
)}
|
|
956
1033
|
</section>
|
|
957
1034
|
)
|
|
958
1035
|
})
|