@goplusvn/core 0.1.39 → 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 +562 -627
|
@@ -1,19 +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 v3 ("Mức truy cập 5 nấc" — chốt với chủ app 2026-07):
|
|
6
|
-
// - TAB theo phân hệ (đếm x/y realtime).
|
|
7
|
-
// - Mỗi tài nguyên MỘT DÒNG: [Ẩn | Tra cứu | Xem | Ghi | Toàn quyền]
|
|
8
|
-
// Ẩn = không menu, không quyền gì
|
|
9
|
-
// Tra cứu = chỉ chọn trong combo/selector (lookup) — KHÔNG thấy menu
|
|
10
|
-
// Xem = menu sidebar hiện + vào được trang
|
|
11
|
-
// Ghi = Xem + Tạo/Sửa/Xuất/Nhập
|
|
12
|
-
// Toàn quyền= Ghi + Xóa
|
|
13
|
-
// - Thao tác NGHIỆP VỤ (duyệt, thanh toán, MISA…) thu gọn "n/m ▸", bung ra
|
|
14
|
-
// nhóm theo LUỒNG (prop actionMeta.flow), tooltip mô tả, và tự cưỡng chế
|
|
15
|
-
// quyền tiên quyết (actionMeta.requires — mặc định cần Xem).
|
|
16
|
-
// - resourcePages: đường dẫn trang hiện dưới tên tài nguyên.
|
|
17
19
|
// API contract giữ nguyên: POST /api/roles, PUT /api/roles/:id, quyền dạng
|
|
18
20
|
// `${action}:${resource}`.
|
|
19
21
|
import * as React from "react"
|
|
@@ -21,6 +23,7 @@ import { useRouter } from "next/navigation"
|
|
|
21
23
|
import {
|
|
22
24
|
Badge,
|
|
23
25
|
Button,
|
|
26
|
+
Checkbox,
|
|
24
27
|
DynamicIcon,
|
|
25
28
|
Input,
|
|
26
29
|
Select,
|
|
@@ -33,32 +36,18 @@ import {
|
|
|
33
36
|
import { toast } from "sonner"
|
|
34
37
|
import {
|
|
35
38
|
ArrowLeft,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
Bot,
|
|
39
|
-
Check,
|
|
39
|
+
Building2,
|
|
40
|
+
Search,
|
|
40
41
|
ChevronDown,
|
|
41
42
|
ChevronRight,
|
|
42
43
|
Copy,
|
|
43
|
-
|
|
44
|
-
Eye,
|
|
44
|
+
Info,
|
|
45
45
|
LayoutTemplate,
|
|
46
|
-
PenLine,
|
|
47
|
-
Pencil,
|
|
48
|
-
Plus,
|
|
49
|
-
Printer,
|
|
50
|
-
RefreshCw,
|
|
51
46
|
Save,
|
|
52
|
-
Search,
|
|
53
|
-
Settings2,
|
|
54
|
-
Trash2,
|
|
55
|
-
Upload,
|
|
56
47
|
X,
|
|
57
|
-
Zap,
|
|
58
48
|
} from "lucide-react"
|
|
59
49
|
|
|
60
50
|
import type { CrudPermissions } from "../../types"
|
|
61
|
-
import type { LucideIcon } from "lucide-react"
|
|
62
51
|
|
|
63
52
|
import { errorMessage, throwFetchError } from "../lib/fetch-error"
|
|
64
53
|
import { cn } from "../../utils"
|
|
@@ -69,78 +58,22 @@ export interface ActionLabelConfig {
|
|
|
69
58
|
color: string
|
|
70
59
|
}
|
|
71
60
|
|
|
72
|
-
// Nhãn
|
|
61
|
+
// Nhãn action CRUD chuẩn — hiển thị như NÚT trên trang.
|
|
73
62
|
const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
|
|
74
|
-
view: { label: "Xem", icon: "👁️", color: "
|
|
75
|
-
create: { label: "Tạo", icon: "➕", color: "
|
|
76
|
-
update: { label: "Sửa", icon: "✏️", color: "
|
|
77
|
-
delete: { label: "Xóa", icon: "🗑️", color: "
|
|
78
|
-
import: { label: "
|
|
79
|
-
export: { label: "
|
|
80
|
-
approve: { label: "Duyệt", icon: "✅", color: "
|
|
81
|
-
manage: { label: "Quản lý", icon: "🔧", color: "
|
|
82
|
-
edit_items: { label: "Sửa chi tiết", icon: "📝", color: "
|
|
83
|
-
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: "" },
|
|
84
73
|
}
|
|
85
74
|
|
|
86
|
-
//
|
|
87
|
-
const
|
|
88
|
-
const STANDARD_CODES = new Set([
|
|
89
|
-
"view",
|
|
90
|
-
"lookup",
|
|
91
|
-
"delete",
|
|
92
|
-
...WRITE_CODES,
|
|
93
|
-
])
|
|
94
|
-
|
|
95
|
-
type AccessLevel = "hidden" | "lookup" | "view" | "write" | "full"
|
|
96
|
-
|
|
97
|
-
const LEVELS: Array<{ key: AccessLevel; label: string; hint: string }> = [
|
|
98
|
-
{ key: "hidden", label: "Ẩn", hint: "Không thấy menu, không quyền gì" },
|
|
99
|
-
{
|
|
100
|
-
key: "lookup",
|
|
101
|
-
label: "Tra cứu",
|
|
102
|
-
hint: "Chỉ chọn trong combo/selector — KHÔNG thấy menu",
|
|
103
|
-
},
|
|
104
|
-
{ key: "view", label: "Xem", hint: "Menu sidebar hiện + vào được trang" },
|
|
105
|
-
{ key: "write", label: "Ghi", hint: "Xem + Tạo/Sửa/Xuất/Nhập" },
|
|
106
|
-
{ key: "full", label: "Toàn quyền", hint: "Ghi + Xóa" },
|
|
107
|
-
]
|
|
108
|
-
|
|
109
|
-
// --- Icon + màu theo LOẠI action (suy từ mã) — mọi action nghiệp vụ đều có
|
|
110
|
-
// icon mà không cần cấu hình từng cái. ---
|
|
111
|
-
const ACTION_VISUAL_RULES: Array<{
|
|
112
|
-
re: RegExp
|
|
113
|
-
icon: LucideIcon
|
|
114
|
-
cls: string
|
|
115
|
-
}> = [
|
|
116
|
-
{ re: /^(view|read|get|list)/, icon: Eye, cls: "text-blue-500" },
|
|
117
|
-
{ re: /^(create|add|new)/, icon: Plus, cls: "text-emerald-500" },
|
|
118
|
-
{ re: /^(update|edit|change|set)/, icon: Pencil, cls: "text-amber-500" },
|
|
119
|
-
{ re: /^(delete|remove)/, icon: Trash2, cls: "text-rose-500" },
|
|
120
|
-
{ re: /^(cancel|void|reject)/, icon: Ban, cls: "text-rose-500" },
|
|
121
|
-
{
|
|
122
|
-
re: /^(approve|confirm|verify|accept|complete)/,
|
|
123
|
-
icon: BadgeCheck,
|
|
124
|
-
cls: "text-violet-500",
|
|
125
|
-
},
|
|
126
|
-
{ re: /^sign/, icon: PenLine, cls: "text-indigo-500" },
|
|
127
|
-
{ re: /^import/, icon: Download, cls: "text-purple-500" },
|
|
128
|
-
{ re: /^export/, icon: Upload, cls: "text-indigo-500" },
|
|
129
|
-
{ re: /^print/, icon: Printer, cls: "text-cyan-600" },
|
|
130
|
-
{ re: /^(sync|unlink)/, icon: RefreshCw, cls: "text-cyan-600" },
|
|
131
|
-
{
|
|
132
|
-
re: /^(manage|config|close|reopen)/,
|
|
133
|
-
icon: Settings2,
|
|
134
|
-
cls: "text-orange-500",
|
|
135
|
-
},
|
|
136
|
-
{ re: /^auto/, icon: Bot, cls: "text-purple-500" },
|
|
137
|
-
]
|
|
138
|
-
|
|
139
|
-
const getActionVisual = (code: string) =>
|
|
140
|
-
ACTION_VISUAL_RULES.find((r) => r.re.test(code)) ?? {
|
|
141
|
-
icon: Zap,
|
|
142
|
-
cls: "text-slate-400",
|
|
143
|
-
}
|
|
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"]
|
|
144
77
|
|
|
145
78
|
/** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
|
|
146
79
|
export interface RoleTemplate {
|
|
@@ -150,12 +83,27 @@ export interface RoleTemplate {
|
|
|
150
83
|
permissions: string[]
|
|
151
84
|
}
|
|
152
85
|
|
|
153
|
-
/** Metadata action
|
|
86
|
+
/** Metadata action (consumer truyền từ Permission Registry). */
|
|
154
87
|
export interface ActionMetaEntry {
|
|
155
88
|
label?: string
|
|
156
89
|
description?: string
|
|
157
90
|
flow?: string
|
|
158
91
|
requires?: string[]
|
|
92
|
+
level?: "staff" | "manager"
|
|
93
|
+
}
|
|
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
|
+
}>
|
|
159
107
|
}
|
|
160
108
|
|
|
161
109
|
export interface RoleFormPageProps {
|
|
@@ -189,12 +137,11 @@ export interface RoleFormPageProps {
|
|
|
189
137
|
permissions: string[]
|
|
190
138
|
}
|
|
191
139
|
actionLabels?: Record<string, ActionLabelConfig>
|
|
192
|
-
/** Bộ quyền mẫu hiện ở chế độ tạo mới (additive — không truyền thì ẩn). */
|
|
193
140
|
roleTemplates?: RoleTemplate[]
|
|
194
|
-
/** Meta action nghiệp vụ theo code (flow/description/requires) — additive. */
|
|
195
141
|
actionMeta?: Record<string, ActionMetaEntry>
|
|
196
|
-
/** Đường dẫn trang theo resource code (hiện dưới tên) — additive. */
|
|
197
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[]
|
|
198
145
|
}
|
|
199
146
|
|
|
200
147
|
export function RoleFormPage({
|
|
@@ -209,6 +156,7 @@ export function RoleFormPage({
|
|
|
209
156
|
roleTemplates,
|
|
210
157
|
actionMeta,
|
|
211
158
|
resourcePages,
|
|
159
|
+
menuTree,
|
|
212
160
|
}: RoleFormPageProps) {
|
|
213
161
|
const router = useRouter()
|
|
214
162
|
|
|
@@ -218,6 +166,7 @@ export function RoleFormPage({
|
|
|
218
166
|
)
|
|
219
167
|
|
|
220
168
|
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
|
169
|
+
const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set())
|
|
221
170
|
const [searchTerm, setSearchTerm] = React.useState("")
|
|
222
171
|
|
|
223
172
|
const [formData, setFormData] = React.useState({
|
|
@@ -228,25 +177,10 @@ export function RoleFormPage({
|
|
|
228
177
|
permissions: initialData?.permissions || ([] as string[]),
|
|
229
178
|
})
|
|
230
179
|
|
|
231
|
-
const [activeGroup, setActiveGroup] = React.useState<string>("all")
|
|
232
|
-
const [expandedRows, setExpandedRows] = React.useState<Set<string>>(
|
|
233
|
-
() => new Set()
|
|
234
|
-
)
|
|
235
|
-
|
|
236
|
-
const toggleRowExpand = (code: string) => {
|
|
237
|
-
setExpandedRows((prev) => {
|
|
238
|
-
const next = new Set(prev)
|
|
239
|
-
if (next.has(code)) next.delete(code)
|
|
240
|
-
else next.add(code)
|
|
241
|
-
return next
|
|
242
|
-
})
|
|
243
|
-
}
|
|
244
|
-
|
|
245
180
|
const getLocalizedName = React.useCallback(
|
|
246
181
|
(key: string | null) => {
|
|
247
182
|
if (!key) return ""
|
|
248
183
|
if (!dictionary) return key
|
|
249
|
-
|
|
250
184
|
const parts = key.split(".")
|
|
251
185
|
let current = dictionary
|
|
252
186
|
for (const part of parts) {
|
|
@@ -262,119 +196,113 @@ export function RoleFormPage({
|
|
|
262
196
|
)
|
|
263
197
|
|
|
264
198
|
const actionName = React.useCallback(
|
|
265
|
-
(code: string) =>
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
code
|
|
271
|
-
)
|
|
272
|
-
},
|
|
199
|
+
(code: string) =>
|
|
200
|
+
actionMeta?.[code]?.label ||
|
|
201
|
+
actions.find((a) => a.code === code)?.name ||
|
|
202
|
+
mergedActionLabels[code]?.label ||
|
|
203
|
+
code,
|
|
273
204
|
[actions, actionMeta, mergedActionLabels]
|
|
274
205
|
)
|
|
275
206
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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[] = []
|
|
279
215
|
const defaultActions = actions
|
|
280
216
|
.filter((a) => a.isDefault)
|
|
281
217
|
.map((a) => a.code)
|
|
282
|
-
const
|
|
218
|
+
const fallback =
|
|
283
219
|
defaultActions.length > 0
|
|
284
220
|
? defaultActions
|
|
285
221
|
: ["view", "create", "update", "delete"]
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (config.actions && Array.isArray(config.actions)) {
|
|
295
|
-
const allowedCodes = config.actions as string[]
|
|
296
|
-
allowedActions = actions
|
|
297
|
-
.map((a) => a.code)
|
|
298
|
-
.filter((code) => allowedCodes.includes(code))
|
|
299
|
-
} else {
|
|
300
|
-
allowedActions = actions
|
|
301
|
-
.map((a) => a.code)
|
|
302
|
-
.filter((code) => effectiveDefaultActions.includes(code))
|
|
303
|
-
}
|
|
304
|
-
} catch (_e) {
|
|
305
|
-
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
|
|
306
229
|
.map((a) => a.code)
|
|
307
|
-
.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))
|
|
308
235
|
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
.map((a) => a.code)
|
|
312
|
-
.filter((code) => effectiveDefaultActions.includes(code))
|
|
236
|
+
} catch {
|
|
237
|
+
allowed = actions.map((a) => a.code).filter((c) => fallback.includes(c))
|
|
313
238
|
}
|
|
314
|
-
|
|
315
|
-
return {
|
|
316
|
-
resource: resource.code,
|
|
239
|
+
map.set(resource.code, {
|
|
317
240
|
name: getLocalizedName(resource.name),
|
|
318
|
-
icon: resource.icon
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
actions: allowedActions,
|
|
322
|
-
businessActions: allowedActions.filter(
|
|
323
|
-
(a) => !STANDARD_CODES.has(a)
|
|
324
|
-
),
|
|
325
|
-
hasLookup: allowedActions.includes("lookup"),
|
|
326
|
-
writeCodes: WRITE_CODES.filter((c) => allowedActions.includes(c)),
|
|
327
|
-
hasDelete: allowedActions.includes("delete"),
|
|
328
|
-
}
|
|
241
|
+
icon: resource.icon,
|
|
242
|
+
allowed,
|
|
243
|
+
})
|
|
329
244
|
})
|
|
245
|
+
return map
|
|
330
246
|
}, [resources, actions, getLocalizedName])
|
|
331
247
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
(
|
|
336
|
-
|
|
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]
|
|
337
262
|
)
|
|
338
263
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
(
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
filteredMatrix.forEach((resource) => {
|
|
354
|
-
const group = resource.group
|
|
355
|
-
if (!groups[group]) {
|
|
356
|
-
groups[group] = []
|
|
357
|
-
}
|
|
358
|
-
groups[group].push(resource)
|
|
359
|
-
})
|
|
360
|
-
return groups
|
|
361
|
-
}, [filteredMatrix])
|
|
362
|
-
|
|
363
|
-
const allGroups = React.useMemo(() => {
|
|
364
|
-
const seen: string[] = []
|
|
365
|
-
permissionMatrix.forEach((r) => {
|
|
366
|
-
if (!seen.includes(r.group)) seen.push(r.group)
|
|
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)
|
|
367
278
|
})
|
|
368
|
-
return
|
|
369
|
-
}, [
|
|
279
|
+
return Array.from(byGroup.values())
|
|
280
|
+
}, [menuTree, resources, resourcePages, getLocalizedName])
|
|
370
281
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
+
)
|
|
378
306
|
|
|
379
307
|
const has = React.useCallback(
|
|
380
308
|
(resource: string, action: string) =>
|
|
@@ -382,9 +310,7 @@ export function RoleFormPage({
|
|
|
382
310
|
[formData.permissions]
|
|
383
311
|
)
|
|
384
312
|
|
|
385
|
-
const
|
|
386
|
-
fn: (set: Set<string>) => void
|
|
387
|
-
) => {
|
|
313
|
+
const mutate = (fn: (set: Set<string>) => void) => {
|
|
388
314
|
setFormData((prev) => {
|
|
389
315
|
const set = new Set(prev.permissions)
|
|
390
316
|
fn(set)
|
|
@@ -392,107 +318,110 @@ export function RoleFormPage({
|
|
|
392
318
|
})
|
|
393
319
|
}
|
|
394
320
|
|
|
395
|
-
// ──
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
if (r.hasLookup && has(r.resource, "lookup")) return "lookup"
|
|
407
|
-
return "hidden"
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
const setLevel = (r: MatrixRow, level: AccessLevel) => {
|
|
411
|
-
mutatePermissions((set) => {
|
|
412
|
-
const rm = (a: string) => set.delete(`${a}:${r.resource}`)
|
|
413
|
-
const add = (a: string) => set.add(`${a}:${r.resource}`)
|
|
414
|
-
// reset phần chuẩn
|
|
415
|
-
;["view", "lookup", "delete", ...WRITE_CODES].forEach(rm)
|
|
416
|
-
if (level === "hidden" || level === "lookup") {
|
|
417
|
-
// mất Xem → thao tác nghiệp vụ (đều cần Xem) tắt theo
|
|
418
|
-
const on = r.businessActions.filter((a) =>
|
|
419
|
-
set.has(`${a}:${r.resource}`)
|
|
420
|
-
)
|
|
421
|
-
r.businessActions.forEach(rm)
|
|
422
|
-
if (on.length > 0) {
|
|
423
|
-
toast.info(
|
|
424
|
-
`Đã tắt ${on.length} thao tác nghiệp vụ của "${r.name}" (cần mức Xem trở lên)`
|
|
425
|
-
)
|
|
426
|
-
}
|
|
427
|
-
if (level === "lookup" && r.hasLookup) add("lookup")
|
|
428
|
-
return
|
|
429
|
-
}
|
|
430
|
-
add("view")
|
|
431
|
-
if (level === "write" || level === "full") {
|
|
432
|
-
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}`))
|
|
433
331
|
}
|
|
434
|
-
if (level === "full" && r.hasDelete) add("delete")
|
|
435
332
|
})
|
|
436
333
|
}
|
|
437
334
|
|
|
438
|
-
// ──
|
|
439
|
-
const
|
|
440
|
-
const
|
|
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}`
|
|
441
340
|
if (formData.permissions.includes(key)) {
|
|
442
|
-
|
|
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)
|
|
344
|
+
)
|
|
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
|
+
)
|
|
356
|
+
}
|
|
443
357
|
return
|
|
444
358
|
}
|
|
445
|
-
const requires = actionMeta?.[code]?.requires ?? []
|
|
446
|
-
const needed = ["view", ...requires].filter(
|
|
359
|
+
const requires = (actionMeta?.[code]?.requires ?? []).filter(
|
|
447
360
|
(req) =>
|
|
448
|
-
|
|
449
|
-
!formData.permissions.includes(`${req}:${
|
|
361
|
+
info.allowed.includes(req) &&
|
|
362
|
+
!formData.permissions.includes(`${req}:${resource}`)
|
|
450
363
|
)
|
|
451
|
-
|
|
364
|
+
mutate((set) => {
|
|
452
365
|
set.add(key)
|
|
453
|
-
|
|
366
|
+
requires.forEach((req) => set.add(`${req}:${resource}`))
|
|
454
367
|
})
|
|
455
|
-
if (
|
|
368
|
+
if (requires.length > 0) {
|
|
456
369
|
toast.info(
|
|
457
|
-
`Đã bật kèm quyền tiên quyết: ${
|
|
458
|
-
.map((
|
|
370
|
+
`Đã bật kèm quyền tiên quyết: ${requires
|
|
371
|
+
.map((a) => actionName(a))
|
|
459
372
|
.join(", ")}`
|
|
460
373
|
)
|
|
461
374
|
}
|
|
462
375
|
}
|
|
463
376
|
|
|
464
|
-
// ──
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
+
}
|
|
470
383
|
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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]
|
|
475
407
|
)
|
|
476
408
|
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
+
})
|
|
482
422
|
})
|
|
483
423
|
}
|
|
484
424
|
|
|
485
|
-
const groupTabCount = React.useCallback(
|
|
486
|
-
(groupName: string) => {
|
|
487
|
-
const rows = permissionMatrix.filter((r) => r.group === groupName)
|
|
488
|
-
const codes = rows.flatMap((r) =>
|
|
489
|
-
r.actions.map((a) => `${a}:${r.resource}`)
|
|
490
|
-
)
|
|
491
|
-
return { selected: countSelected(codes), total: codes.length }
|
|
492
|
-
},
|
|
493
|
-
[permissionMatrix, countSelected]
|
|
494
|
-
)
|
|
495
|
-
|
|
496
425
|
const applyTemplate = (templateCode: string) => {
|
|
497
426
|
const tpl = roleTemplates?.find((t) => t.code === templateCode)
|
|
498
427
|
if (!tpl) return
|
|
@@ -505,29 +434,24 @@ export function RoleFormPage({
|
|
|
505
434
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
506
435
|
e.preventDefault()
|
|
507
436
|
setIsSubmitting(true)
|
|
508
|
-
|
|
509
437
|
try {
|
|
510
438
|
const url =
|
|
511
439
|
mode === "create" ? "/api/roles" : `/api/roles/${initialData?.id}`
|
|
512
440
|
const method = mode === "create" ? "POST" : "PUT"
|
|
513
|
-
|
|
514
441
|
const res = await fetch(url, {
|
|
515
442
|
method,
|
|
516
443
|
headers: { "Content-Type": "application/json" },
|
|
517
444
|
body: JSON.stringify(formData),
|
|
518
445
|
})
|
|
519
|
-
|
|
520
446
|
if (!res.ok) {
|
|
521
447
|
await throwFetchError(res, `Failed to ${mode} role`)
|
|
522
448
|
}
|
|
523
|
-
|
|
524
449
|
toast.success("Thành công", {
|
|
525
450
|
description:
|
|
526
451
|
mode === "create"
|
|
527
452
|
? "Đã tạo vai trò mới thành công"
|
|
528
453
|
: "Đã cập nhật vai trò thành công",
|
|
529
454
|
})
|
|
530
|
-
|
|
531
455
|
router.push(`/${lang}/roles`)
|
|
532
456
|
router.refresh()
|
|
533
457
|
} catch (error: any) {
|
|
@@ -537,12 +461,190 @@ export function RoleFormPage({
|
|
|
537
461
|
}
|
|
538
462
|
}
|
|
539
463
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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]
|
|
544
502
|
)
|
|
545
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
|
+
|
|
546
648
|
return (
|
|
547
649
|
<div className="flex flex-col">
|
|
548
650
|
{/* ===== 1. Header bar navy 1 DÒNG ===== */}
|
|
@@ -653,345 +755,178 @@ export function RoleFormPage({
|
|
|
653
755
|
</div>
|
|
654
756
|
|
|
655
757
|
<div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
|
|
656
|
-
{/* ===== 2. Toolbar
|
|
657
|
-
<div className="
|
|
658
|
-
<div className="
|
|
659
|
-
<
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
{
|
|
669
|
-
<
|
|
670
|
-
<
|
|
671
|
-
<
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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>
|
|
675
810
|
</SelectTrigger>
|
|
676
811
|
<SelectContent className="rounded-lg">
|
|
677
|
-
|
|
678
|
-
<
|
|
679
|
-
<span
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
<span className="text-[11px] text-muted-foreground">
|
|
683
|
-
{tpl.description}
|
|
684
|
-
</span>
|
|
685
|
-
)}
|
|
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
|
|
686
817
|
</span>
|
|
687
|
-
</
|
|
688
|
-
|
|
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)
|
|
825
|
+
</span>
|
|
826
|
+
</span>
|
|
827
|
+
</SelectItem>
|
|
689
828
|
</SelectContent>
|
|
690
829
|
</Select>
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
</
|
|
699
|
-
</
|
|
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>
|
|
839
|
+
</div>
|
|
700
840
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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>
|
|
849
|
+
|
|
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">
|
|
719
862
|
<button
|
|
720
|
-
key={group}
|
|
721
863
|
type="button"
|
|
722
|
-
onClick={() =>
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
|
726
891
|
className={cn(
|
|
727
|
-
"
|
|
728
|
-
|
|
729
|
-
? "
|
|
730
|
-
: "
|
|
892
|
+
"text-[11px] tabular-nums",
|
|
893
|
+
on === total && total > 0
|
|
894
|
+
? "font-medium text-emerald-600 dark:text-emerald-400"
|
|
895
|
+
: "text-muted-foreground"
|
|
731
896
|
)}
|
|
732
897
|
>
|
|
733
|
-
{
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
active
|
|
738
|
-
? "bg-primary-foreground/20"
|
|
739
|
-
: selected > 0
|
|
740
|
-
? selected === total
|
|
741
|
-
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
|
742
|
-
: "bg-muted text-foreground"
|
|
743
|
-
: "bg-muted"
|
|
744
|
-
)}
|
|
745
|
-
>
|
|
746
|
-
{selected}/{total}
|
|
747
|
-
</span>
|
|
748
|
-
</button>
|
|
749
|
-
)
|
|
750
|
-
})}
|
|
751
|
-
</div>
|
|
752
|
-
|
|
753
|
-
{/* Chú giải 5 nấc — 1 dòng mảnh */}
|
|
754
|
-
<div className="hidden items-center gap-3 border-t border-border/60 px-4 py-1 text-[11px] text-muted-foreground md:flex">
|
|
755
|
-
{LEVELS.map((l) => (
|
|
756
|
-
<span key={l.key} className="flex items-center gap-1">
|
|
757
|
-
<span className="font-medium text-foreground">{l.label}</span>
|
|
758
|
-
<span className="opacity-80">= {l.hint}</span>
|
|
759
|
-
</span>
|
|
760
|
-
))}
|
|
761
|
-
</div>
|
|
762
|
-
</div>
|
|
763
|
-
|
|
764
|
-
{/* ===== 3. Dòng tài nguyên: mức truy cập + thao tác nghiệp vụ ===== */}
|
|
765
|
-
{visibleGroups.length === 0 ? (
|
|
766
|
-
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
767
|
-
<Search className="mb-3 h-6 w-6 text-muted-foreground" />
|
|
768
|
-
<p className="text-sm text-muted-foreground">
|
|
769
|
-
Không có tài nguyên nào khớp bộ lọc.
|
|
770
|
-
</p>
|
|
771
|
-
</div>
|
|
772
|
-
) : (
|
|
773
|
-
visibleGroups.map(([group, groupResources]) => {
|
|
774
|
-
const codes = groupPermissionCodes(groupResources)
|
|
775
|
-
const selected = countSelected(codes)
|
|
776
|
-
const total = codes.length
|
|
777
|
-
const isFull = total > 0 && selected === total
|
|
778
|
-
|
|
779
|
-
return (
|
|
780
|
-
<section key={group}>
|
|
781
|
-
<div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
|
|
782
|
-
<h3 className="truncate text-xs font-semibold uppercase tracking-wide text-foreground">
|
|
783
|
-
{group}
|
|
784
|
-
</h3>
|
|
785
|
-
<span
|
|
786
|
-
className={cn(
|
|
787
|
-
"whitespace-nowrap text-[11px] tabular-nums",
|
|
788
|
-
isFull
|
|
789
|
-
? "font-medium text-emerald-600 dark:text-emerald-400"
|
|
790
|
-
: "text-muted-foreground"
|
|
791
|
-
)}
|
|
792
|
-
>
|
|
793
|
-
{groupResources.length} tài nguyên · {selected}/{total}
|
|
794
|
-
{isFull && <Check className="ml-0.5 inline h-3 w-3" />}
|
|
795
|
-
</span>
|
|
796
|
-
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
797
|
-
<Button
|
|
798
|
-
type="button"
|
|
799
|
-
variant="ghost"
|
|
800
|
-
size="sm"
|
|
801
|
-
onClick={() => toggleAllForGroup(groupResources, false)}
|
|
802
|
-
disabled={selected === 0}
|
|
803
|
-
className="h-6 px-2 text-xs text-muted-foreground hover:text-destructive"
|
|
804
|
-
>
|
|
805
|
-
Bỏ chọn
|
|
806
|
-
</Button>
|
|
807
|
-
<Button
|
|
808
|
-
type="button"
|
|
809
|
-
variant="ghost"
|
|
810
|
-
size="sm"
|
|
811
|
-
onClick={() => toggleAllForGroup(groupResources, true)}
|
|
812
|
-
disabled={isFull}
|
|
813
|
-
className="h-6 px-2 text-xs font-semibold text-primary hover:text-primary"
|
|
814
|
-
>
|
|
815
|
-
Chọn tất cả
|
|
816
|
-
</Button>
|
|
817
|
-
</div>
|
|
818
|
-
</div>
|
|
819
|
-
|
|
898
|
+
{on}/{total} menu
|
|
899
|
+
</span>
|
|
900
|
+
</div>
|
|
901
|
+
{!isCollapsed && (
|
|
820
902
|
<ul className="divide-y divide-border/60 border-b border-border">
|
|
821
|
-
{
|
|
822
|
-
const level = deriveLevel(r)
|
|
823
|
-
const bizOn = r.businessActions.filter((a) =>
|
|
824
|
-
has(r.resource, a)
|
|
825
|
-
).length
|
|
826
|
-
const expanded = expandedRows.has(r.resource)
|
|
827
|
-
const page = resourcePages?.[r.resource]
|
|
828
|
-
|
|
829
|
-
// Nhóm thao tác theo LUỒNG
|
|
830
|
-
const flows = new Map<string, string[]>()
|
|
831
|
-
r.businessActions.forEach((code) => {
|
|
832
|
-
const flow = actionMeta?.[code]?.flow || "Khác"
|
|
833
|
-
const list = flows.get(flow) ?? []
|
|
834
|
-
list.push(code)
|
|
835
|
-
flows.set(flow, list)
|
|
836
|
-
})
|
|
837
|
-
|
|
838
|
-
return (
|
|
839
|
-
<li key={r.resource} className="px-4 py-2.5">
|
|
840
|
-
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
|
841
|
-
{/* Tên + trang */}
|
|
842
|
-
<div className="flex min-w-0 flex-1 basis-52 items-center gap-2">
|
|
843
|
-
<DynamicIcon
|
|
844
|
-
name={r.icon as any}
|
|
845
|
-
className="h-4 w-4 shrink-0 text-muted-foreground"
|
|
846
|
-
/>
|
|
847
|
-
<div className="min-w-0">
|
|
848
|
-
<div className="truncate text-sm font-medium text-foreground">
|
|
849
|
-
{r.name}
|
|
850
|
-
</div>
|
|
851
|
-
<div className="truncate font-mono text-[10px] text-muted-foreground">
|
|
852
|
-
{page || r.resource}
|
|
853
|
-
</div>
|
|
854
|
-
</div>
|
|
855
|
-
</div>
|
|
856
|
-
|
|
857
|
-
{/* Segmented 5 nấc */}
|
|
858
|
-
<div className="flex shrink-0 overflow-hidden rounded-lg border border-border">
|
|
859
|
-
{LEVELS.map((l, i) => {
|
|
860
|
-
const disabled =
|
|
861
|
-
(l.key === "lookup" && !r.hasLookup) ||
|
|
862
|
-
(l.key === "write" &&
|
|
863
|
-
r.writeCodes.length === 0) ||
|
|
864
|
-
(l.key === "full" &&
|
|
865
|
-
r.writeCodes.length === 0 &&
|
|
866
|
-
!r.hasDelete)
|
|
867
|
-
const active = level === l.key
|
|
868
|
-
return (
|
|
869
|
-
<button
|
|
870
|
-
key={l.key}
|
|
871
|
-
type="button"
|
|
872
|
-
disabled={disabled}
|
|
873
|
-
onClick={() => setLevel(r, l.key)}
|
|
874
|
-
title={l.hint}
|
|
875
|
-
className={cn(
|
|
876
|
-
"px-2 py-1 text-[11px] font-medium transition-colors",
|
|
877
|
-
i > 0 && "border-l border-border",
|
|
878
|
-
disabled
|
|
879
|
-
? "cursor-not-allowed bg-muted/40 text-muted-foreground/40"
|
|
880
|
-
: active
|
|
881
|
-
? l.key === "hidden"
|
|
882
|
-
? "bg-muted text-foreground"
|
|
883
|
-
: "bg-primary text-primary-foreground"
|
|
884
|
-
: "bg-card text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
885
|
-
)}
|
|
886
|
-
>
|
|
887
|
-
{l.label}
|
|
888
|
-
</button>
|
|
889
|
-
)
|
|
890
|
-
})}
|
|
891
|
-
</div>
|
|
892
|
-
|
|
893
|
-
{/* Thao tác nghiệp vụ — nút thu gọn */}
|
|
894
|
-
{r.businessActions.length > 0 && (
|
|
895
|
-
<button
|
|
896
|
-
type="button"
|
|
897
|
-
onClick={() => toggleRowExpand(r.resource)}
|
|
898
|
-
className={cn(
|
|
899
|
-
"flex shrink-0 items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium transition-colors",
|
|
900
|
-
bizOn > 0
|
|
901
|
-
? "border-primary/40 bg-primary/5 text-primary"
|
|
902
|
-
: "border-border bg-card text-muted-foreground hover:text-foreground"
|
|
903
|
-
)}
|
|
904
|
-
>
|
|
905
|
-
{expanded ? (
|
|
906
|
-
<ChevronDown className="h-3 w-3" />
|
|
907
|
-
) : (
|
|
908
|
-
<ChevronRight className="h-3 w-3" />
|
|
909
|
-
)}
|
|
910
|
-
Thao tác nghiệp vụ
|
|
911
|
-
<span className="tabular-nums">
|
|
912
|
-
{bizOn}/{r.businessActions.length}
|
|
913
|
-
</span>
|
|
914
|
-
</button>
|
|
915
|
-
)}
|
|
916
|
-
</div>
|
|
917
|
-
|
|
918
|
-
{/* Panel thao tác nghiệp vụ — nhóm theo LUỒNG */}
|
|
919
|
-
{expanded && r.businessActions.length > 0 && (
|
|
920
|
-
<div className="mt-2 space-y-1.5 rounded-lg border border-border/70 bg-muted/20 px-3 py-2 sm:ml-6">
|
|
921
|
-
{level === "hidden" || level === "lookup" ? (
|
|
922
|
-
<p className="text-[11px] text-muted-foreground">
|
|
923
|
-
Cần mức <b>Xem</b> trở lên — tick một thao tác
|
|
924
|
-
sẽ tự nâng lên Xem.
|
|
925
|
-
</p>
|
|
926
|
-
) : null}
|
|
927
|
-
{Array.from(flows.entries()).map(
|
|
928
|
-
([flow, codes]) => (
|
|
929
|
-
<div
|
|
930
|
-
key={flow}
|
|
931
|
-
className="flex flex-wrap items-center gap-1.5"
|
|
932
|
-
>
|
|
933
|
-
<span className="w-24 shrink-0 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
934
|
-
{flow}
|
|
935
|
-
</span>
|
|
936
|
-
{codes.map((code) => {
|
|
937
|
-
const isChecked = has(r.resource, code)
|
|
938
|
-
const meta = actionMeta?.[code]
|
|
939
|
-
const visual = getActionVisual(code)
|
|
940
|
-
const ActionIcon = visual.icon
|
|
941
|
-
return (
|
|
942
|
-
<button
|
|
943
|
-
key={code}
|
|
944
|
-
type="button"
|
|
945
|
-
onClick={() =>
|
|
946
|
-
toggleBusinessAction(r, code)
|
|
947
|
-
}
|
|
948
|
-
title={
|
|
949
|
-
meta?.description ||
|
|
950
|
-
actionName(code)
|
|
951
|
-
}
|
|
952
|
-
className={cn(
|
|
953
|
-
"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium transition-colors",
|
|
954
|
-
isChecked
|
|
955
|
-
? "border-primary/40 bg-primary/10 text-primary"
|
|
956
|
-
: "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground"
|
|
957
|
-
)}
|
|
958
|
-
>
|
|
959
|
-
<span
|
|
960
|
-
className={cn(
|
|
961
|
-
"flex h-3 w-3 shrink-0 items-center justify-center rounded-[3px] border transition-colors",
|
|
962
|
-
isChecked
|
|
963
|
-
? "border-primary bg-primary text-primary-foreground"
|
|
964
|
-
: "border-border bg-card text-transparent"
|
|
965
|
-
)}
|
|
966
|
-
>
|
|
967
|
-
<Check
|
|
968
|
-
className="h-2 w-2"
|
|
969
|
-
strokeWidth={3}
|
|
970
|
-
/>
|
|
971
|
-
</span>
|
|
972
|
-
<ActionIcon
|
|
973
|
-
className={cn(
|
|
974
|
-
"h-3 w-3 shrink-0",
|
|
975
|
-
visual.cls,
|
|
976
|
-
!isChecked && "opacity-55"
|
|
977
|
-
)}
|
|
978
|
-
/>
|
|
979
|
-
{actionName(code)}
|
|
980
|
-
</button>
|
|
981
|
-
)
|
|
982
|
-
})}
|
|
983
|
-
</div>
|
|
984
|
-
)
|
|
985
|
-
)}
|
|
986
|
-
</div>
|
|
987
|
-
)}
|
|
988
|
-
</li>
|
|
989
|
-
)
|
|
990
|
-
})}
|
|
903
|
+
{section.items.map(renderPageRow)}
|
|
991
904
|
</ul>
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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>
|
|
995
930
|
)}
|
|
996
931
|
</div>
|
|
997
932
|
</div>
|