@goplusvn/core 0.1.40 → 0.1.41
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 +565 -671
|
@@ -1,23 +1,21 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
// Trang tạo/sửa VAI TRÒ —
|
|
3
|
+
// Trang tạo/sửa VAI TRÒ — v6 "CÂY MENU" (chốt với chủ app 2026-07-24):
|
|
4
|
+
//
|
|
5
|
+
// Phân quyền theo ĐÚNG cái nhân viên nhìn thấy — cây menu thật của app
|
|
6
|
+
// (prop menuTree, dựng từ navigations):
|
|
7
|
+
// ▼ Nhóm menu (Bán hàng, Kho hàng…) [checkbox cả nhóm]
|
|
8
|
+
// ☑ Trang (POS Bán hàng · /sales/pos) ← tick = THẤY MENU + vào trang
|
|
9
|
+
// trong trang: ☑ Tạo ☑ Sửa ☐ Xóa ☐ nút Xác nhận TT ☐ Xem giá vốn…
|
|
10
|
+
// ← nút thật trên trang đó
|
|
11
|
+
// - "Phạm vi dữ liệu" (chi nhánh) = 1 dropdown đầu trang → map quyền
|
|
12
|
+
// view-all-branches trên mọi tài nguyên có khai.
|
|
13
|
+
// - Combo/select: dòng thông báo "tự động, không cần cấp" (lookupPolicy
|
|
14
|
+
// hệ thống lo) — admin không phải nghĩ về combo.
|
|
15
|
+
// - Quyền không gắn menu (dialog/route ẩn) gom vào mục cuối.
|
|
16
|
+
// - Tick thao tác có `requires` → tự bật quyền tiên quyết (toast);
|
|
17
|
+
// bỏ tick 1 quyền → tự tắt các thao tác phụ thuộc nó.
|
|
4
18
|
//
|
|
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.
|
|
21
19
|
// API contract giữ nguyên: POST /api/roles, PUT /api/roles/:id, quyền dạng
|
|
22
20
|
// `${action}:${resource}`.
|
|
23
21
|
import * as React from "react"
|
|
@@ -25,6 +23,7 @@ import { useRouter } from "next/navigation"
|
|
|
25
23
|
import {
|
|
26
24
|
Badge,
|
|
27
25
|
Button,
|
|
26
|
+
Checkbox,
|
|
28
27
|
DynamicIcon,
|
|
29
28
|
Input,
|
|
30
29
|
Select,
|
|
@@ -37,32 +36,18 @@ import {
|
|
|
37
36
|
import { toast } from "sonner"
|
|
38
37
|
import {
|
|
39
38
|
ArrowLeft,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
Bot,
|
|
43
|
-
Check,
|
|
39
|
+
Building2,
|
|
40
|
+
Search,
|
|
44
41
|
ChevronDown,
|
|
45
42
|
ChevronRight,
|
|
46
43
|
Copy,
|
|
47
|
-
|
|
48
|
-
Eye,
|
|
44
|
+
Info,
|
|
49
45
|
LayoutTemplate,
|
|
50
|
-
PenLine,
|
|
51
|
-
Pencil,
|
|
52
|
-
Plus,
|
|
53
|
-
Printer,
|
|
54
|
-
RefreshCw,
|
|
55
46
|
Save,
|
|
56
|
-
Search,
|
|
57
|
-
Settings2,
|
|
58
|
-
Trash2,
|
|
59
|
-
Upload,
|
|
60
47
|
X,
|
|
61
|
-
Zap,
|
|
62
48
|
} from "lucide-react"
|
|
63
49
|
|
|
64
50
|
import type { CrudPermissions } from "../../types"
|
|
65
|
-
import type { LucideIcon } from "lucide-react"
|
|
66
51
|
|
|
67
52
|
import { errorMessage, throwFetchError } from "../lib/fetch-error"
|
|
68
53
|
import { cn } from "../../utils"
|
|
@@ -73,96 +58,22 @@ export interface ActionLabelConfig {
|
|
|
73
58
|
color: string
|
|
74
59
|
}
|
|
75
60
|
|
|
76
|
-
// Nhãn
|
|
61
|
+
// Nhãn action CRUD chuẩn — hiển thị như NÚT trên trang.
|
|
77
62
|
const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
|
|
78
|
-
view: { label: "Xem", icon: "👁️", color: "
|
|
79
|
-
create: { label: "Tạo", icon: "➕", color: "
|
|
80
|
-
update: { label: "Sửa", icon: "✏️", color: "
|
|
81
|
-
delete: { label: "Xóa", icon: "🗑️", color: "
|
|
82
|
-
import: { label: "
|
|
83
|
-
export: { label: "
|
|
84
|
-
approve: { label: "Duyệt", icon: "✅", color: "
|
|
85
|
-
manage: { label: "Quản lý", icon: "🔧", color: "
|
|
86
|
-
edit_items: { label: "Sửa chi tiết", icon: "📝", color: "
|
|
87
|
-
delete_items: { label: "Xóa chi tiết", icon: "❌", color: "
|
|
63
|
+
view: { label: "Xem", icon: "👁️", color: "" },
|
|
64
|
+
create: { label: "Tạo mới", icon: "➕", color: "" },
|
|
65
|
+
update: { label: "Sửa", icon: "✏️", color: "" },
|
|
66
|
+
delete: { label: "Xóa", icon: "🗑️", color: "" },
|
|
67
|
+
import: { label: "Nhập Excel", icon: "📥", color: "" },
|
|
68
|
+
export: { label: "Xuất Excel", icon: "📤", color: "" },
|
|
69
|
+
approve: { label: "Duyệt", icon: "✅", color: "" },
|
|
70
|
+
manage: { label: "Quản lý", icon: "🔧", color: "" },
|
|
71
|
+
edit_items: { label: "Sửa chi tiết", icon: "📝", color: "" },
|
|
72
|
+
delete_items: { label: "Xóa chi tiết", icon: "❌", color: "" },
|
|
88
73
|
}
|
|
89
74
|
|
|
90
|
-
//
|
|
91
|
-
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
|
-
},
|
|
129
|
-
]
|
|
130
|
-
|
|
131
|
-
// --- Icon + màu theo LOẠI action (suy từ mã) — mọi action nghiệp vụ đều có
|
|
132
|
-
// icon mà không cần cấu hình từng cái. ---
|
|
133
|
-
const ACTION_VISUAL_RULES: Array<{
|
|
134
|
-
re: RegExp
|
|
135
|
-
icon: LucideIcon
|
|
136
|
-
cls: string
|
|
137
|
-
}> = [
|
|
138
|
-
{ re: /^(view|read|get|list)/, icon: Eye, cls: "text-blue-500" },
|
|
139
|
-
{ re: /^(create|add|new)/, icon: Plus, cls: "text-emerald-500" },
|
|
140
|
-
{ re: /^(update|edit|change|set)/, icon: Pencil, cls: "text-amber-500" },
|
|
141
|
-
{ re: /^(delete|remove)/, icon: Trash2, cls: "text-rose-500" },
|
|
142
|
-
{ re: /^(cancel|void|reject)/, icon: Ban, cls: "text-rose-500" },
|
|
143
|
-
{
|
|
144
|
-
re: /^(approve|confirm|verify|accept|complete)/,
|
|
145
|
-
icon: BadgeCheck,
|
|
146
|
-
cls: "text-violet-500",
|
|
147
|
-
},
|
|
148
|
-
{ re: /^sign/, icon: PenLine, cls: "text-indigo-500" },
|
|
149
|
-
{ re: /^import/, icon: Download, cls: "text-purple-500" },
|
|
150
|
-
{ re: /^export/, icon: Upload, cls: "text-indigo-500" },
|
|
151
|
-
{ re: /^print/, icon: Printer, cls: "text-cyan-600" },
|
|
152
|
-
{ re: /^(sync|unlink)/, icon: RefreshCw, cls: "text-cyan-600" },
|
|
153
|
-
{
|
|
154
|
-
re: /^(manage|config|close|reopen)/,
|
|
155
|
-
icon: Settings2,
|
|
156
|
-
cls: "text-orange-500",
|
|
157
|
-
},
|
|
158
|
-
{ re: /^auto/, icon: Bot, cls: "text-purple-500" },
|
|
159
|
-
]
|
|
160
|
-
|
|
161
|
-
const getActionVisual = (code: string) =>
|
|
162
|
-
ACTION_VISUAL_RULES.find((r) => r.re.test(code)) ?? {
|
|
163
|
-
icon: Zap,
|
|
164
|
-
cls: "text-slate-400",
|
|
165
|
-
}
|
|
75
|
+
// Thứ tự hiển thị action trong một trang: CRUD chuẩn trước, nghiệp vụ sau.
|
|
76
|
+
const STANDARD_ORDER = ["create", "update", "delete", "export", "import"]
|
|
166
77
|
|
|
167
78
|
/** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
|
|
168
79
|
export interface RoleTemplate {
|
|
@@ -172,16 +83,29 @@ export interface RoleTemplate {
|
|
|
172
83
|
permissions: string[]
|
|
173
84
|
}
|
|
174
85
|
|
|
175
|
-
/** Metadata action
|
|
86
|
+
/** Metadata action (consumer truyền từ Permission Registry). */
|
|
176
87
|
export interface ActionMetaEntry {
|
|
177
88
|
label?: string
|
|
178
89
|
description?: string
|
|
179
90
|
flow?: string
|
|
180
91
|
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
92
|
level?: "staff" | "manager"
|
|
183
93
|
}
|
|
184
94
|
|
|
95
|
+
/** Cây menu thật của app — dựng từ navigations. */
|
|
96
|
+
export interface MenuTreeSection {
|
|
97
|
+
title: string
|
|
98
|
+
icon?: string
|
|
99
|
+
items: Array<{
|
|
100
|
+
title: string
|
|
101
|
+
href?: string
|
|
102
|
+
icon?: string
|
|
103
|
+
resource: string
|
|
104
|
+
/** Vị trí thật khi không phải menu: "nút Cấu hình trong trang Mua hàng"… */
|
|
105
|
+
note?: string
|
|
106
|
+
}>
|
|
107
|
+
}
|
|
108
|
+
|
|
185
109
|
export interface RoleFormPageProps {
|
|
186
110
|
dictionary: any
|
|
187
111
|
permissions?: CrudPermissions
|
|
@@ -213,12 +137,11 @@ export interface RoleFormPageProps {
|
|
|
213
137
|
permissions: string[]
|
|
214
138
|
}
|
|
215
139
|
actionLabels?: Record<string, ActionLabelConfig>
|
|
216
|
-
/** Bộ quyền mẫu hiện ở chế độ tạo mới (additive — không truyền thì ẩn). */
|
|
217
140
|
roleTemplates?: RoleTemplate[]
|
|
218
|
-
/** Meta action nghiệp vụ theo code (flow/description/requires) — additive. */
|
|
219
141
|
actionMeta?: Record<string, ActionMetaEntry>
|
|
220
|
-
/** Đường dẫn trang theo resource code (hiện dưới tên) — additive. */
|
|
221
142
|
resourcePages?: Record<string, string>
|
|
143
|
+
/** Cây menu thật — không truyền thì tự dựng từ group của resources. */
|
|
144
|
+
menuTree?: MenuTreeSection[]
|
|
222
145
|
}
|
|
223
146
|
|
|
224
147
|
export function RoleFormPage({
|
|
@@ -233,6 +156,7 @@ export function RoleFormPage({
|
|
|
233
156
|
roleTemplates,
|
|
234
157
|
actionMeta,
|
|
235
158
|
resourcePages,
|
|
159
|
+
menuTree,
|
|
236
160
|
}: RoleFormPageProps) {
|
|
237
161
|
const router = useRouter()
|
|
238
162
|
|
|
@@ -242,6 +166,7 @@ export function RoleFormPage({
|
|
|
242
166
|
)
|
|
243
167
|
|
|
244
168
|
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
|
169
|
+
const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set())
|
|
245
170
|
const [searchTerm, setSearchTerm] = React.useState("")
|
|
246
171
|
|
|
247
172
|
const [formData, setFormData] = React.useState({
|
|
@@ -252,36 +177,10 @@ export function RoleFormPage({
|
|
|
252
177
|
permissions: initialData?.permissions || ([] as string[]),
|
|
253
178
|
})
|
|
254
179
|
|
|
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
|
-
}
|
|
279
|
-
|
|
280
180
|
const getLocalizedName = React.useCallback(
|
|
281
181
|
(key: string | null) => {
|
|
282
182
|
if (!key) return ""
|
|
283
183
|
if (!dictionary) return key
|
|
284
|
-
|
|
285
184
|
const parts = key.split(".")
|
|
286
185
|
let current = dictionary
|
|
287
186
|
for (const part of parts) {
|
|
@@ -297,107 +196,113 @@ export function RoleFormPage({
|
|
|
297
196
|
)
|
|
298
197
|
|
|
299
198
|
const actionName = React.useCallback(
|
|
300
|
-
(code: string) =>
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
code
|
|
306
|
-
)
|
|
307
|
-
},
|
|
199
|
+
(code: string) =>
|
|
200
|
+
actionMeta?.[code]?.label ||
|
|
201
|
+
actions.find((a) => a.code === code)?.name ||
|
|
202
|
+
mergedActionLabels[code]?.label ||
|
|
203
|
+
code,
|
|
308
204
|
[actions, actionMeta, mergedActionLabels]
|
|
309
205
|
)
|
|
310
206
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
207
|
+
// ── Resource → action khai báo ────────────────────────────────────────
|
|
208
|
+
const resourceInfo = React.useMemo(() => {
|
|
209
|
+
const map = new Map<
|
|
210
|
+
string,
|
|
211
|
+
{ name: string; icon: string | null; allowed: string[] }
|
|
212
|
+
>()
|
|
213
|
+
resources.forEach((resource) => {
|
|
214
|
+
let allowed: string[] = []
|
|
314
215
|
const defaultActions = actions
|
|
315
216
|
.filter((a) => a.isDefault)
|
|
316
217
|
.map((a) => a.code)
|
|
317
|
-
const
|
|
218
|
+
const fallback =
|
|
318
219
|
defaultActions.length > 0
|
|
319
220
|
? defaultActions
|
|
320
221
|
: ["view", "create", "update", "delete"]
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (config.actions && Array.isArray(config.actions)) {
|
|
330
|
-
const allowedCodes = config.actions as string[]
|
|
331
|
-
allowedActions = actions
|
|
332
|
-
.map((a) => a.code)
|
|
333
|
-
.filter((code) => allowedCodes.includes(code))
|
|
334
|
-
} else {
|
|
335
|
-
allowedActions = actions
|
|
336
|
-
.map((a) => a.code)
|
|
337
|
-
.filter((code) => effectiveDefaultActions.includes(code))
|
|
338
|
-
}
|
|
339
|
-
} catch (_e) {
|
|
340
|
-
allowedActions = actions
|
|
222
|
+
try {
|
|
223
|
+
const config =
|
|
224
|
+
typeof resource.config === "string"
|
|
225
|
+
? JSON.parse(resource.config)
|
|
226
|
+
: resource.config
|
|
227
|
+
if (config?.actions && Array.isArray(config.actions)) {
|
|
228
|
+
allowed = actions
|
|
341
229
|
.map((a) => a.code)
|
|
342
|
-
.filter((
|
|
230
|
+
.filter((c) => (config.actions as string[]).includes(c))
|
|
231
|
+
} else {
|
|
232
|
+
allowed = actions
|
|
233
|
+
.map((a) => a.code)
|
|
234
|
+
.filter((c) => fallback.includes(c))
|
|
343
235
|
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
.map((a) => a.code)
|
|
347
|
-
.filter((code) => effectiveDefaultActions.includes(code))
|
|
236
|
+
} catch {
|
|
237
|
+
allowed = actions.map((a) => a.code).filter((c) => fallback.includes(c))
|
|
348
238
|
}
|
|
349
|
-
|
|
350
|
-
return {
|
|
351
|
-
resource: resource.code,
|
|
239
|
+
map.set(resource.code, {
|
|
352
240
|
name: getLocalizedName(resource.name),
|
|
353
|
-
icon: resource.icon
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
actions: allowedActions,
|
|
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"),
|
|
363
|
-
}
|
|
241
|
+
icon: resource.icon,
|
|
242
|
+
allowed,
|
|
243
|
+
})
|
|
364
244
|
})
|
|
245
|
+
return map
|
|
365
246
|
}, [resources, actions, getLocalizedName])
|
|
366
247
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
(
|
|
371
|
-
|
|
248
|
+
/** Action hiển thị dưới một trang (bỏ view = chính checkbox trang; bỏ lookup
|
|
249
|
+
* = combo tự động). CRUD chuẩn trước, nghiệp vụ sau. */
|
|
250
|
+
const pageActions = React.useCallback(
|
|
251
|
+
(resource: string) => {
|
|
252
|
+
const info = resourceInfo.get(resource)
|
|
253
|
+
if (!info) return []
|
|
254
|
+
const rest = info.allowed.filter(
|
|
255
|
+
(a) => a !== "view" && a !== "lookup" && a !== "view-all-branches"
|
|
256
|
+
)
|
|
257
|
+
const std = STANDARD_ORDER.filter((a) => rest.includes(a))
|
|
258
|
+
const biz = rest.filter((a) => !STANDARD_ORDER.includes(a))
|
|
259
|
+
return [...std, ...biz]
|
|
260
|
+
},
|
|
261
|
+
[resourceInfo]
|
|
372
262
|
)
|
|
373
263
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
(
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
filteredMatrix.forEach((resource) => {
|
|
389
|
-
const group = resource.group
|
|
390
|
-
if (!groups[group]) {
|
|
391
|
-
groups[group] = []
|
|
392
|
-
}
|
|
393
|
-
groups[group].push(resource)
|
|
264
|
+
// ── Cây menu (prop, hoặc tự dựng từ group) ────────────────────────────
|
|
265
|
+
const tree = React.useMemo<MenuTreeSection[]>(() => {
|
|
266
|
+
if (menuTree && menuTree.length > 0) return menuTree
|
|
267
|
+
const byGroup = new Map<string, MenuTreeSection>()
|
|
268
|
+
resources.forEach((r) => {
|
|
269
|
+
const group = getLocalizedName(r.group) || "Khác"
|
|
270
|
+
const section = byGroup.get(group) ?? { title: group, items: [] }
|
|
271
|
+
section.items.push({
|
|
272
|
+
title: getLocalizedName(r.name),
|
|
273
|
+
href: resourcePages?.[r.code],
|
|
274
|
+
icon: r.icon ?? undefined,
|
|
275
|
+
resource: r.code,
|
|
276
|
+
})
|
|
277
|
+
byGroup.set(group, section)
|
|
394
278
|
})
|
|
395
|
-
return
|
|
396
|
-
}, [
|
|
279
|
+
return Array.from(byGroup.values())
|
|
280
|
+
}, [menuTree, resources, resourcePages, getLocalizedName])
|
|
397
281
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
282
|
+
/** Resource có action khai nhưng KHÔNG nằm trên menu nào. */
|
|
283
|
+
const offMenuResources = React.useMemo(() => {
|
|
284
|
+
const onMenu = new Set(
|
|
285
|
+
tree.flatMap((s) => s.items.map((i) => i.resource))
|
|
286
|
+
)
|
|
287
|
+
return resources
|
|
288
|
+
.map((r) => r.code)
|
|
289
|
+
.filter((code) => {
|
|
290
|
+
if (onMenu.has(code)) return false
|
|
291
|
+
const info = resourceInfo.get(code)
|
|
292
|
+
if (!info) return false
|
|
293
|
+
// chỉ còn lookup (vd resource "reference") → thuộc phần combo tự động
|
|
294
|
+
return info.allowed.some((a) => a !== "lookup")
|
|
295
|
+
})
|
|
296
|
+
}, [tree, resources, resourceInfo])
|
|
297
|
+
|
|
298
|
+
const totalAvailable = React.useMemo(
|
|
299
|
+
() =>
|
|
300
|
+
Array.from(resourceInfo.values()).reduce(
|
|
301
|
+
(s, r) => s + r.allowed.length,
|
|
302
|
+
0
|
|
303
|
+
),
|
|
304
|
+
[resourceInfo]
|
|
305
|
+
)
|
|
401
306
|
|
|
402
307
|
const has = React.useCallback(
|
|
403
308
|
(resource: string, action: string) =>
|
|
@@ -405,9 +310,7 @@ export function RoleFormPage({
|
|
|
405
310
|
[formData.permissions]
|
|
406
311
|
)
|
|
407
312
|
|
|
408
|
-
const
|
|
409
|
-
fn: (set: Set<string>) => void
|
|
410
|
-
) => {
|
|
313
|
+
const mutate = (fn: (set: Set<string>) => void) => {
|
|
411
314
|
setFormData((prev) => {
|
|
412
315
|
const set = new Set(prev.permissions)
|
|
413
316
|
fn(set)
|
|
@@ -415,152 +318,109 @@ export function RoleFormPage({
|
|
|
415
318
|
})
|
|
416
319
|
}
|
|
417
320
|
|
|
418
|
-
// ──
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
}
|
|
429
|
-
if (r.hasLookup && has(r.resource, "lookup")) return "lookup"
|
|
430
|
-
return "hidden"
|
|
431
|
-
}
|
|
432
|
-
|
|
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)
|
|
321
|
+
// ── Tick trang (menu) ─────────────────────────────────────────────────
|
|
322
|
+
const togglePage = (resource: string, checked: boolean) => {
|
|
323
|
+
const info = resourceInfo.get(resource)
|
|
324
|
+
if (!info) return
|
|
325
|
+
mutate((set) => {
|
|
326
|
+
if (checked) {
|
|
327
|
+
if (info.allowed.includes("view")) set.add(`view:${resource}`)
|
|
328
|
+
} else {
|
|
329
|
+
// mất menu → mọi quyền của trang tắt theo
|
|
330
|
+
info.allowed.forEach((a) => set.delete(`${a}:${resource}`))
|
|
456
331
|
}
|
|
457
|
-
if (level === "full" && r.hasDelete) add("delete")
|
|
458
332
|
})
|
|
459
333
|
}
|
|
460
334
|
|
|
461
|
-
// ──
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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}`)
|
|
483
|
-
}
|
|
484
|
-
},
|
|
485
|
-
[actionMeta]
|
|
486
|
-
)
|
|
487
|
-
|
|
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))
|
|
335
|
+
// ── Tick thao tác trong trang (requires 2 chiều) ──────────────────────
|
|
336
|
+
const toggleAction = (resource: string, code: string) => {
|
|
337
|
+
const info = resourceInfo.get(resource)
|
|
338
|
+
if (!info) return
|
|
339
|
+
const key = `${code}:${resource}`
|
|
340
|
+
if (formData.permissions.includes(key)) {
|
|
341
|
+
// tắt X → tắt luôn các thao tác phụ thuộc X
|
|
342
|
+
const dependents = info.allowed.filter((a) =>
|
|
343
|
+
(actionMeta?.[a]?.requires ?? []).includes(code)
|
|
501
344
|
)
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
) {
|
|
508
|
-
|
|
509
|
-
|
|
345
|
+
const on = dependents.filter((a) =>
|
|
346
|
+
formData.permissions.includes(`${a}:${resource}`)
|
|
347
|
+
)
|
|
348
|
+
mutate((set) => {
|
|
349
|
+
set.delete(key)
|
|
350
|
+
on.forEach((a) => set.delete(`${a}:${resource}`))
|
|
351
|
+
})
|
|
352
|
+
if (on.length > 0) {
|
|
353
|
+
toast.info(
|
|
354
|
+
`Đã tắt kèm: ${on.map((a) => actionName(a)).join(", ")} (phụ thuộc "${actionName(code)}")`
|
|
355
|
+
)
|
|
510
356
|
}
|
|
511
|
-
return "custom"
|
|
512
|
-
},
|
|
513
|
-
[formData.permissions, canonicalForGroup]
|
|
514
|
-
)
|
|
515
|
-
|
|
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
|
-
})
|
|
523
|
-
}
|
|
524
|
-
|
|
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
357
|
return
|
|
531
358
|
}
|
|
532
|
-
const requires = actionMeta?.[code]?.requires ?? []
|
|
533
|
-
const needed = ["view", ...requires].filter(
|
|
359
|
+
const requires = (actionMeta?.[code]?.requires ?? []).filter(
|
|
534
360
|
(req) =>
|
|
535
|
-
|
|
536
|
-
!formData.permissions.includes(`${req}:${
|
|
361
|
+
info.allowed.includes(req) &&
|
|
362
|
+
!formData.permissions.includes(`${req}:${resource}`)
|
|
537
363
|
)
|
|
538
|
-
|
|
364
|
+
mutate((set) => {
|
|
539
365
|
set.add(key)
|
|
540
|
-
|
|
366
|
+
requires.forEach((req) => set.add(`${req}:${resource}`))
|
|
541
367
|
})
|
|
542
|
-
if (
|
|
368
|
+
if (requires.length > 0) {
|
|
543
369
|
toast.info(
|
|
544
|
-
`Đã bật kèm quyền tiên quyết: ${
|
|
545
|
-
.map((
|
|
370
|
+
`Đã bật kèm quyền tiên quyết: ${requires
|
|
371
|
+
.map((a) => actionName(a))
|
|
546
372
|
.join(", ")}`
|
|
547
373
|
)
|
|
548
374
|
}
|
|
549
375
|
}
|
|
550
376
|
|
|
551
|
-
// ──
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
377
|
+
// ── Tick cả nhóm menu ─────────────────────────────────────────────────
|
|
378
|
+
const sectionState = (section: MenuTreeSection) => {
|
|
379
|
+
const uniq = [...new Set(section.items.map((i) => i.resource))]
|
|
380
|
+
const on = uniq.filter((r) => has(r, "view")).length
|
|
381
|
+
return { total: uniq.length, on }
|
|
382
|
+
}
|
|
557
383
|
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
384
|
+
const toggleSection = (section: MenuTreeSection, checked: boolean) => {
|
|
385
|
+
const uniq = [...new Set(section.items.map((i) => i.resource))]
|
|
386
|
+
uniq.forEach((r) => togglePageBatch(r, checked))
|
|
387
|
+
function togglePageBatch(resource: string, on: boolean) {
|
|
388
|
+
const info = resourceInfo.get(resource)
|
|
389
|
+
if (!info) return
|
|
390
|
+
mutate((set) => {
|
|
391
|
+
if (on) {
|
|
392
|
+
if (info.allowed.includes("view")) set.add(`view:${resource}`)
|
|
393
|
+
} else {
|
|
394
|
+
info.allowed.forEach((a) => set.delete(`${a}:${resource}`))
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── Phạm vi dữ liệu chi nhánh (view-all-branches) ─────────────────────
|
|
401
|
+
const branchScopeResources = React.useMemo(
|
|
402
|
+
() =>
|
|
403
|
+
Array.from(resourceInfo.entries())
|
|
404
|
+
.filter(([, info]) => info.allowed.includes("view-all-branches"))
|
|
405
|
+
.map(([code]) => code),
|
|
406
|
+
[resourceInfo]
|
|
562
407
|
)
|
|
563
408
|
|
|
409
|
+
const branchScope: "assigned" | "all" = React.useMemo(() => {
|
|
410
|
+
if (branchScopeResources.length === 0) return "assigned"
|
|
411
|
+
return branchScopeResources.every((r) => has(r, "view-all-branches"))
|
|
412
|
+
? "all"
|
|
413
|
+
: "assigned"
|
|
414
|
+
}, [branchScopeResources, has])
|
|
415
|
+
|
|
416
|
+
const setBranchScope = (scope: "assigned" | "all") => {
|
|
417
|
+
mutate((set) => {
|
|
418
|
+
branchScopeResources.forEach((r) => {
|
|
419
|
+
if (scope === "all") set.add(`view-all-branches:${r}`)
|
|
420
|
+
else set.delete(`view-all-branches:${r}`)
|
|
421
|
+
})
|
|
422
|
+
})
|
|
423
|
+
}
|
|
564
424
|
|
|
565
425
|
const applyTemplate = (templateCode: string) => {
|
|
566
426
|
const tpl = roleTemplates?.find((t) => t.code === templateCode)
|
|
@@ -574,29 +434,24 @@ export function RoleFormPage({
|
|
|
574
434
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
575
435
|
e.preventDefault()
|
|
576
436
|
setIsSubmitting(true)
|
|
577
|
-
|
|
578
437
|
try {
|
|
579
438
|
const url =
|
|
580
439
|
mode === "create" ? "/api/roles" : `/api/roles/${initialData?.id}`
|
|
581
440
|
const method = mode === "create" ? "POST" : "PUT"
|
|
582
|
-
|
|
583
441
|
const res = await fetch(url, {
|
|
584
442
|
method,
|
|
585
443
|
headers: { "Content-Type": "application/json" },
|
|
586
444
|
body: JSON.stringify(formData),
|
|
587
445
|
})
|
|
588
|
-
|
|
589
446
|
if (!res.ok) {
|
|
590
447
|
await throwFetchError(res, `Failed to ${mode} role`)
|
|
591
448
|
}
|
|
592
|
-
|
|
593
449
|
toast.success("Thành công", {
|
|
594
450
|
description:
|
|
595
451
|
mode === "create"
|
|
596
452
|
? "Đã tạo vai trò mới thành công"
|
|
597
453
|
: "Đã cập nhật vai trò thành công",
|
|
598
454
|
})
|
|
599
|
-
|
|
600
455
|
router.push(`/${lang}/roles`)
|
|
601
456
|
router.refresh()
|
|
602
457
|
} catch (error: any) {
|
|
@@ -606,10 +461,190 @@ export function RoleFormPage({
|
|
|
606
461
|
}
|
|
607
462
|
}
|
|
608
463
|
|
|
609
|
-
|
|
610
|
-
|
|
464
|
+
// Mở trang: tự thu gọn các nhóm chưa có menu nào được cấp — trang ngắn hẳn.
|
|
465
|
+
const didInitCollapse = React.useRef(false)
|
|
466
|
+
React.useEffect(() => {
|
|
467
|
+
if (didInitCollapse.current) return
|
|
468
|
+
didInitCollapse.current = true
|
|
469
|
+
const zero = tree
|
|
470
|
+
.filter((section) => {
|
|
471
|
+
const uniq = [...new Set(section.items.map((i) => i.resource))]
|
|
472
|
+
return !uniq.some((r) => has(r, "view"))
|
|
473
|
+
})
|
|
474
|
+
.map((s) => s.title)
|
|
475
|
+
if (zero.length > 0) setCollapsed(new Set(zero))
|
|
476
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
477
|
+
}, [])
|
|
478
|
+
|
|
479
|
+
const toggleCollapse = (title: string) => {
|
|
480
|
+
setCollapsed((prev) => {
|
|
481
|
+
const next = new Set(prev)
|
|
482
|
+
if (next.has(title)) next.delete(title)
|
|
483
|
+
else next.add(title)
|
|
484
|
+
return next
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── Search: lọc theo tên menu / đường dẫn / tên thao tác ─────────────
|
|
489
|
+
const searching = searchTerm.trim().length > 0
|
|
490
|
+
const matchItem = React.useCallback(
|
|
491
|
+
(item: { title: string; href?: string; resource: string }) => {
|
|
492
|
+
if (!searching) return true
|
|
493
|
+
const q = searchTerm.trim().toLowerCase()
|
|
494
|
+
if (item.title.toLowerCase().includes(q)) return true
|
|
495
|
+
if (item.href?.toLowerCase().includes(q)) return true
|
|
496
|
+
if (item.resource.toLowerCase().includes(q)) return true
|
|
497
|
+
return pageActions(item.resource).some((code) =>
|
|
498
|
+
actionName(code).toLowerCase().includes(q)
|
|
499
|
+
)
|
|
500
|
+
},
|
|
501
|
+
[searching, searchTerm, pageActions, actionName]
|
|
611
502
|
)
|
|
612
503
|
|
|
504
|
+
const visibleTree = React.useMemo(() => {
|
|
505
|
+
if (!searching) return tree
|
|
506
|
+
const q = searchTerm.trim().toLowerCase()
|
|
507
|
+
return tree
|
|
508
|
+
.map((section) => {
|
|
509
|
+
if (section.title.toLowerCase().includes(q)) return section
|
|
510
|
+
return { ...section, items: section.items.filter(matchItem) }
|
|
511
|
+
})
|
|
512
|
+
.filter((section) => section.items.length > 0)
|
|
513
|
+
}, [tree, searching, searchTerm, matchItem])
|
|
514
|
+
|
|
515
|
+
const visibleOffMenu = React.useMemo(() => {
|
|
516
|
+
if (!searching) return offMenuResources
|
|
517
|
+
const q = searchTerm.trim().toLowerCase()
|
|
518
|
+
return offMenuResources.filter((code) => {
|
|
519
|
+
const info = resourceInfo.get(code)
|
|
520
|
+
if (info?.name.toLowerCase().includes(q)) return true
|
|
521
|
+
if (code.toLowerCase().includes(q)) return true
|
|
522
|
+
return pageActions(code).some((c) =>
|
|
523
|
+
actionName(c).toLowerCase().includes(q)
|
|
524
|
+
)
|
|
525
|
+
})
|
|
526
|
+
}, [offMenuResources, searching, searchTerm, resourceInfo, pageActions, actionName])
|
|
527
|
+
|
|
528
|
+
// Render 1 dòng trang menu + hàng thao tác của nó
|
|
529
|
+
const renderPageRow = (item: {
|
|
530
|
+
title: string
|
|
531
|
+
href?: string
|
|
532
|
+
icon?: string
|
|
533
|
+
resource: string
|
|
534
|
+
note?: string
|
|
535
|
+
}) => {
|
|
536
|
+
const info = resourceInfo.get(item.resource)
|
|
537
|
+
if (!info) return null
|
|
538
|
+
const pageOn = has(item.resource, "view")
|
|
539
|
+
const acts = pageActions(item.resource)
|
|
540
|
+
const actsOn = acts.filter((a) => has(item.resource, a)).length
|
|
541
|
+
|
|
542
|
+
return (
|
|
543
|
+
<li key={`${item.resource}:${item.title}`} className="px-4 py-2">
|
|
544
|
+
<label className="flex cursor-pointer items-center gap-2.5">
|
|
545
|
+
<Checkbox
|
|
546
|
+
checked={pageOn}
|
|
547
|
+
onCheckedChange={(c) => togglePage(item.resource, c === true)}
|
|
548
|
+
aria-label={`Menu ${item.title}`}
|
|
549
|
+
/>
|
|
550
|
+
<DynamicIcon
|
|
551
|
+
name={(item.icon || info.icon) as any}
|
|
552
|
+
className="h-4 w-4 shrink-0 text-muted-foreground"
|
|
553
|
+
/>
|
|
554
|
+
<span
|
|
555
|
+
className={cn(
|
|
556
|
+
"text-sm font-medium",
|
|
557
|
+
pageOn ? "text-foreground" : "text-muted-foreground"
|
|
558
|
+
)}
|
|
559
|
+
>
|
|
560
|
+
{item.title}
|
|
561
|
+
</span>
|
|
562
|
+
{item.href && (
|
|
563
|
+
<code className="hidden font-mono text-[10px] text-muted-foreground sm:inline">
|
|
564
|
+
{item.href}
|
|
565
|
+
</code>
|
|
566
|
+
)}
|
|
567
|
+
{item.note && (
|
|
568
|
+
<span className="hidden text-[10px] italic text-muted-foreground/80 sm:inline">
|
|
569
|
+
{item.note}
|
|
570
|
+
</span>
|
|
571
|
+
)}
|
|
572
|
+
{!pageOn && (
|
|
573
|
+
<span
|
|
574
|
+
className="text-[10px] text-muted-foreground/60"
|
|
575
|
+
title="Vai trò này không thấy menu và không vào được trang"
|
|
576
|
+
>
|
|
577
|
+
· ẩn
|
|
578
|
+
</span>
|
|
579
|
+
)}
|
|
580
|
+
{pageOn && acts.length > 0 && (
|
|
581
|
+
<span className="ml-auto text-[10px] tabular-nums text-muted-foreground">
|
|
582
|
+
{actsOn}/{acts.length} thao tác
|
|
583
|
+
</span>
|
|
584
|
+
)}
|
|
585
|
+
</label>
|
|
586
|
+
|
|
587
|
+
{/* Thao tác (nút) trong trang — nhóm theo LUỒNG, mỗi luồng 1 dòng */}
|
|
588
|
+
{pageOn && acts.length > 0 && (
|
|
589
|
+
<div className="mt-1.5 space-y-1 pl-7 sm:pl-9">
|
|
590
|
+
{(() => {
|
|
591
|
+
// "Chung" = CRUD chuẩn; còn lại theo actionMeta.flow
|
|
592
|
+
const flows = new Map<string, string[]>()
|
|
593
|
+
acts.forEach((code) => {
|
|
594
|
+
const flow = STANDARD_ORDER.includes(code)
|
|
595
|
+
? "Chung"
|
|
596
|
+
: actionMeta?.[code]?.flow || "Khác"
|
|
597
|
+
const list = flows.get(flow) ?? []
|
|
598
|
+
list.push(code)
|
|
599
|
+
flows.set(flow, list)
|
|
600
|
+
})
|
|
601
|
+
return Array.from(flows.entries()).map(([flow, codes]) => (
|
|
602
|
+
<div
|
|
603
|
+
key={flow}
|
|
604
|
+
className="flex flex-wrap items-center gap-x-3 gap-y-1"
|
|
605
|
+
>
|
|
606
|
+
<span className="w-24 shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground/60">
|
|
607
|
+
{flow}
|
|
608
|
+
</span>
|
|
609
|
+
{codes.map((code) => {
|
|
610
|
+
const meta = actionMeta?.[code]
|
|
611
|
+
const checked = has(item.resource, code)
|
|
612
|
+
return (
|
|
613
|
+
<label
|
|
614
|
+
key={code}
|
|
615
|
+
title={meta?.description || actionName(code)}
|
|
616
|
+
className="flex cursor-pointer items-center gap-1.5"
|
|
617
|
+
>
|
|
618
|
+
<Checkbox
|
|
619
|
+
checked={checked}
|
|
620
|
+
onCheckedChange={() =>
|
|
621
|
+
toggleAction(item.resource, code)
|
|
622
|
+
}
|
|
623
|
+
className="h-3.5 w-3.5"
|
|
624
|
+
aria-label={`${actionName(code)} — ${item.title}`}
|
|
625
|
+
/>
|
|
626
|
+
<span
|
|
627
|
+
className={cn(
|
|
628
|
+
"text-xs",
|
|
629
|
+
checked
|
|
630
|
+
? "text-foreground"
|
|
631
|
+
: "text-muted-foreground"
|
|
632
|
+
)}
|
|
633
|
+
>
|
|
634
|
+
{actionName(code)}
|
|
635
|
+
</span>
|
|
636
|
+
</label>
|
|
637
|
+
)
|
|
638
|
+
})}
|
|
639
|
+
</div>
|
|
640
|
+
))
|
|
641
|
+
})()}
|
|
642
|
+
</div>
|
|
643
|
+
)}
|
|
644
|
+
</li>
|
|
645
|
+
)
|
|
646
|
+
}
|
|
647
|
+
|
|
613
648
|
return (
|
|
614
649
|
<div className="flex flex-col">
|
|
615
650
|
{/* ===== 1. Header bar navy 1 DÒNG ===== */}
|
|
@@ -720,319 +755,178 @@ export function RoleFormPage({
|
|
|
720
755
|
</div>
|
|
721
756
|
|
|
722
757
|
<div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
|
|
723
|
-
{/* ===== 2. Toolbar
|
|
724
|
-
<div className="
|
|
725
|
-
<div className="
|
|
726
|
-
<
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
{
|
|
736
|
-
<
|
|
737
|
-
<
|
|
738
|
-
<
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
758
|
+
{/* ===== 2. Toolbar: mẫu + phạm vi chi nhánh + đếm ===== */}
|
|
759
|
+
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2 sm:px-4">
|
|
760
|
+
<div className="relative min-w-[150px] flex-1 sm:max-w-64">
|
|
761
|
+
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
762
|
+
<Input
|
|
763
|
+
placeholder="Tìm menu / thao tác..."
|
|
764
|
+
className="h-8 rounded-md border border-border bg-card pl-8 text-sm"
|
|
765
|
+
value={searchTerm}
|
|
766
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
767
|
+
/>
|
|
768
|
+
</div>
|
|
769
|
+
{mode === "create" && (roleTemplates?.length ?? 0) > 0 && (
|
|
770
|
+
<Select onValueChange={applyTemplate}>
|
|
771
|
+
<SelectTrigger className="h-8 w-48 rounded-md border border-border bg-card text-sm">
|
|
772
|
+
<span className="flex items-center gap-1.5 truncate">
|
|
773
|
+
<LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
774
|
+
<SelectValue placeholder="Bộ quyền mẫu..." />
|
|
775
|
+
</span>
|
|
776
|
+
</SelectTrigger>
|
|
777
|
+
<SelectContent className="rounded-lg">
|
|
778
|
+
{roleTemplates!.map((tpl) => (
|
|
779
|
+
<SelectItem key={tpl.code} value={tpl.code}>
|
|
780
|
+
<span className="flex flex-col">
|
|
781
|
+
<span>{tpl.label}</span>
|
|
782
|
+
{tpl.description && (
|
|
783
|
+
<span className="text-[11px] text-muted-foreground">
|
|
784
|
+
{tpl.description}
|
|
785
|
+
</span>
|
|
786
|
+
)}
|
|
787
|
+
</span>
|
|
788
|
+
</SelectItem>
|
|
789
|
+
))}
|
|
790
|
+
</SelectContent>
|
|
791
|
+
</Select>
|
|
792
|
+
)}
|
|
793
|
+
|
|
794
|
+
{branchScopeResources.length > 0 && (
|
|
795
|
+
<div className="flex items-center gap-1.5">
|
|
796
|
+
<Building2 className="h-3.5 w-3.5 text-muted-foreground" />
|
|
797
|
+
<span className="text-xs text-muted-foreground">
|
|
798
|
+
Phạm vi dữ liệu:
|
|
799
|
+
</span>
|
|
800
|
+
<Select
|
|
801
|
+
value={branchScope}
|
|
802
|
+
onValueChange={(v) => setBranchScope(v as "assigned" | "all")}
|
|
803
|
+
>
|
|
804
|
+
<SelectTrigger className="h-8 w-52 rounded-md border border-border bg-card text-sm">
|
|
805
|
+
<SelectValue>
|
|
806
|
+
{branchScope === "all"
|
|
807
|
+
? "Tất cả chi nhánh"
|
|
808
|
+
: "Chỉ chi nhánh được gán"}
|
|
809
|
+
</SelectValue>
|
|
742
810
|
</SelectTrigger>
|
|
743
811
|
<SelectContent className="rounded-lg">
|
|
744
|
-
|
|
745
|
-
<
|
|
746
|
-
<span
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
812
|
+
<SelectItem value="assigned">
|
|
813
|
+
<span className="flex flex-col">
|
|
814
|
+
<span>Chỉ chi nhánh được gán</span>
|
|
815
|
+
<span className="text-[11px] text-muted-foreground">
|
|
816
|
+
Theo chi nhánh gán ở trang Người dùng
|
|
817
|
+
</span>
|
|
818
|
+
</span>
|
|
819
|
+
</SelectItem>
|
|
820
|
+
<SelectItem value="all">
|
|
821
|
+
<span className="flex flex-col">
|
|
822
|
+
<span>Tất cả chi nhánh</span>
|
|
823
|
+
<span className="text-[11px] text-muted-foreground">
|
|
824
|
+
Xem dữ liệu mọi chi nhánh (quản lý vùng/kế toán tổng)
|
|
753
825
|
</span>
|
|
754
|
-
</
|
|
755
|
-
|
|
826
|
+
</span>
|
|
827
|
+
</SelectItem>
|
|
756
828
|
</SelectContent>
|
|
757
829
|
</Select>
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
</
|
|
766
|
-
</
|
|
767
|
-
|
|
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
|
-
))}
|
|
776
|
-
</div>
|
|
830
|
+
</div>
|
|
831
|
+
)}
|
|
832
|
+
|
|
833
|
+
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
|
|
834
|
+
<span className="font-semibold tabular-nums text-foreground">
|
|
835
|
+
{formData.permissions.length}
|
|
836
|
+
</span>
|
|
837
|
+
<span className="tabular-nums">/{totalAvailable}</span> quyền
|
|
838
|
+
</p>
|
|
777
839
|
</div>
|
|
778
840
|
|
|
779
|
-
{/*
|
|
780
|
-
|
|
781
|
-
<
|
|
782
|
-
|
|
783
|
-
<
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
) : (
|
|
788
|
-
visibleGroups.map(([group, groupResources]) => {
|
|
789
|
-
const codes = groupPermissionCodes(groupResources)
|
|
790
|
-
const selected = countSelected(codes)
|
|
791
|
-
const total = codes.length
|
|
792
|
-
const isFull = total > 0 && selected === total
|
|
793
|
-
const groupLevel = deriveGroupLevel(groupResources)
|
|
794
|
-
const groupExpanded = searching || expandedGroups.has(group)
|
|
795
|
-
|
|
796
|
-
return (
|
|
797
|
-
<section key={group}>
|
|
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">
|
|
800
|
-
{group}
|
|
801
|
-
</h3>
|
|
802
|
-
<span
|
|
803
|
-
className={cn(
|
|
804
|
-
"whitespace-nowrap text-[11px] tabular-nums",
|
|
805
|
-
isFull
|
|
806
|
-
? "font-medium text-emerald-600 dark:text-emerald-400"
|
|
807
|
-
: "text-muted-foreground"
|
|
808
|
-
)}
|
|
809
|
-
>
|
|
810
|
-
{groupResources.length} tài nguyên · {selected}/{total}
|
|
811
|
-
{isFull && <Check className="ml-0.5 inline h-3 w-3" />}
|
|
812
|
-
</span>
|
|
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
|
-
}
|
|
827
|
-
>
|
|
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
|
|
845
|
-
type="button"
|
|
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"
|
|
848
|
-
>
|
|
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>
|
|
856
|
-
</div>
|
|
857
|
-
</div>
|
|
841
|
+
{/* Combo tự động — admin khỏi lo */}
|
|
842
|
+
<div className="flex items-start gap-2 border-b border-border bg-muted/30 px-4 py-1.5 text-[11px] text-muted-foreground">
|
|
843
|
+
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
844
|
+
<span>
|
|
845
|
+
Ô chọn khách / sản phẩm / kho… trong các form <b>tự hoạt động</b> —
|
|
846
|
+
không cần cấp quyền ở đây.
|
|
847
|
+
</span>
|
|
848
|
+
</div>
|
|
858
849
|
|
|
859
|
-
|
|
850
|
+
{/* ===== 3. Cây menu ===== */}
|
|
851
|
+
{searching && visibleTree.length === 0 && visibleOffMenu.length === 0 && (
|
|
852
|
+
<p className="px-4 py-8 text-center text-xs text-muted-foreground">
|
|
853
|
+
Không có menu / thao tác nào khớp "{searchTerm}".
|
|
854
|
+
</p>
|
|
855
|
+
)}
|
|
856
|
+
{visibleTree.map((section) => {
|
|
857
|
+
const { total, on } = sectionState(section)
|
|
858
|
+
const isCollapsed = !searching && collapsed.has(section.title)
|
|
859
|
+
return (
|
|
860
|
+
<section key={section.title}>
|
|
861
|
+
<div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
|
|
862
|
+
<button
|
|
863
|
+
type="button"
|
|
864
|
+
onClick={() => toggleCollapse(section.title)}
|
|
865
|
+
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground"
|
|
866
|
+
aria-label={
|
|
867
|
+
isCollapsed ? "Mở nhóm menu" : "Thu gọn nhóm menu"
|
|
868
|
+
}
|
|
869
|
+
>
|
|
870
|
+
{isCollapsed ? (
|
|
871
|
+
<ChevronRight className="h-4 w-4" />
|
|
872
|
+
) : (
|
|
873
|
+
<ChevronDown className="h-4 w-4" />
|
|
874
|
+
)}
|
|
875
|
+
</button>
|
|
876
|
+
<Checkbox
|
|
877
|
+
checked={
|
|
878
|
+
on === 0 ? false : on === total ? true : "indeterminate"
|
|
879
|
+
}
|
|
880
|
+
onCheckedChange={(c) => toggleSection(section, c === true)}
|
|
881
|
+
aria-label={`Cả nhóm ${section.title}`}
|
|
882
|
+
/>
|
|
883
|
+
<DynamicIcon
|
|
884
|
+
name={section.icon as any}
|
|
885
|
+
className="h-4 w-4 shrink-0 text-muted-foreground"
|
|
886
|
+
/>
|
|
887
|
+
<h3 className="text-sm font-semibold text-foreground">
|
|
888
|
+
{section.title}
|
|
889
|
+
</h3>
|
|
890
|
+
<span
|
|
891
|
+
className={cn(
|
|
892
|
+
"text-[11px] tabular-nums",
|
|
893
|
+
on === total && total > 0
|
|
894
|
+
? "font-medium text-emerald-600 dark:text-emerald-400"
|
|
895
|
+
: "text-muted-foreground"
|
|
896
|
+
)}
|
|
897
|
+
>
|
|
898
|
+
{on}/{total} menu
|
|
899
|
+
</span>
|
|
900
|
+
</div>
|
|
901
|
+
{!isCollapsed && (
|
|
860
902
|
<ul className="divide-y divide-border/60 border-b border-border">
|
|
861
|
-
{
|
|
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}
|
|
893
|
-
</div>
|
|
894
|
-
</div>
|
|
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
|
|
908
|
-
return (
|
|
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
|
-
)}
|
|
926
|
-
>
|
|
927
|
-
{l.label}
|
|
928
|
-
</button>
|
|
929
|
-
)
|
|
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
|
-
}
|
|
992
|
-
className={cn(
|
|
993
|
-
"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium transition-colors",
|
|
994
|
-
isChecked
|
|
995
|
-
? "border-primary/40 bg-primary/10 text-primary"
|
|
996
|
-
: "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground"
|
|
997
|
-
)}
|
|
998
|
-
>
|
|
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
|
-
)}
|
|
1018
|
-
/>
|
|
1019
|
-
{actionName(code)}
|
|
1020
|
-
</button>
|
|
1021
|
-
)
|
|
1022
|
-
})}
|
|
1023
|
-
</div>
|
|
1024
|
-
)
|
|
1025
|
-
)}
|
|
1026
|
-
</div>
|
|
1027
|
-
)}
|
|
1028
|
-
</li>
|
|
1029
|
-
)
|
|
1030
|
-
})}
|
|
903
|
+
{section.items.map(renderPageRow)}
|
|
1031
904
|
</ul>
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
905
|
+
)}
|
|
906
|
+
</section>
|
|
907
|
+
)
|
|
908
|
+
})}
|
|
909
|
+
|
|
910
|
+
{/* ===== 4. Quyền không gắn menu (dialog / route ẩn) ===== */}
|
|
911
|
+
{visibleOffMenu.length > 0 && (
|
|
912
|
+
<section>
|
|
913
|
+
<div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
|
|
914
|
+
<h3 className="text-sm font-semibold text-foreground">
|
|
915
|
+
Chức năng không có menu riêng
|
|
916
|
+
</h3>
|
|
917
|
+
<span className="text-[11px] text-muted-foreground">
|
|
918
|
+
dialog / thao tác nằm trong trang khác
|
|
919
|
+
</span>
|
|
920
|
+
</div>
|
|
921
|
+
<ul className="divide-y divide-border/60">
|
|
922
|
+
{visibleOffMenu.map((code) =>
|
|
923
|
+
renderPageRow({
|
|
924
|
+
title: resourceInfo.get(code)?.name || code,
|
|
925
|
+
resource: code,
|
|
926
|
+
})
|
|
927
|
+
)}
|
|
928
|
+
</ul>
|
|
929
|
+
</section>
|
|
1036
930
|
)}
|
|
1037
931
|
</div>
|
|
1038
932
|
</div>
|