@goplusvn/core 0.1.40 → 0.1.42
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 +752 -668
- package/src/rbac/pages/role-list-page.tsx +145 -0
- package/src/user/components/effective-permissions-dialog.tsx +235 -0
- package/src/user/components/users-card-view.tsx +20 -2
- package/src/user/pages/users-client-page.tsx +25 -1
|
@@ -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,19 @@ import {
|
|
|
37
36
|
import { toast } from "sonner"
|
|
38
37
|
import {
|
|
39
38
|
ArrowLeft,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
Check,
|
|
39
|
+
Building2,
|
|
40
|
+
History,
|
|
41
|
+
Search,
|
|
44
42
|
ChevronDown,
|
|
45
43
|
ChevronRight,
|
|
46
44
|
Copy,
|
|
47
|
-
|
|
48
|
-
Eye,
|
|
45
|
+
Info,
|
|
49
46
|
LayoutTemplate,
|
|
50
|
-
PenLine,
|
|
51
|
-
Pencil,
|
|
52
|
-
Plus,
|
|
53
|
-
Printer,
|
|
54
|
-
RefreshCw,
|
|
55
47
|
Save,
|
|
56
|
-
Search,
|
|
57
|
-
Settings2,
|
|
58
|
-
Trash2,
|
|
59
|
-
Upload,
|
|
60
48
|
X,
|
|
61
|
-
Zap,
|
|
62
49
|
} from "lucide-react"
|
|
63
50
|
|
|
64
51
|
import type { CrudPermissions } from "../../types"
|
|
65
|
-
import type { LucideIcon } from "lucide-react"
|
|
66
52
|
|
|
67
53
|
import { errorMessage, throwFetchError } from "../lib/fetch-error"
|
|
68
54
|
import { cn } from "../../utils"
|
|
@@ -73,96 +59,22 @@ export interface ActionLabelConfig {
|
|
|
73
59
|
color: string
|
|
74
60
|
}
|
|
75
61
|
|
|
76
|
-
// Nhãn
|
|
62
|
+
// Nhãn action CRUD chuẩn — hiển thị như NÚT trên trang.
|
|
77
63
|
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: "
|
|
64
|
+
view: { label: "Xem", icon: "👁️", color: "" },
|
|
65
|
+
create: { label: "Tạo mới", icon: "➕", color: "" },
|
|
66
|
+
update: { label: "Sửa", icon: "✏️", color: "" },
|
|
67
|
+
delete: { label: "Xóa", icon: "🗑️", color: "" },
|
|
68
|
+
import: { label: "Nhập Excel", icon: "📥", color: "" },
|
|
69
|
+
export: { label: "Xuất Excel", icon: "📤", color: "" },
|
|
70
|
+
approve: { label: "Duyệt", icon: "✅", color: "" },
|
|
71
|
+
manage: { label: "Quản lý", icon: "🔧", color: "" },
|
|
72
|
+
edit_items: { label: "Sửa chi tiết", icon: "📝", color: "" },
|
|
73
|
+
delete_items: { label: "Xóa chi tiết", icon: "❌", color: "" },
|
|
88
74
|
}
|
|
89
75
|
|
|
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
|
-
}
|
|
76
|
+
// Thứ tự hiển thị action trong một trang: CRUD chuẩn trước, nghiệp vụ sau.
|
|
77
|
+
const STANDARD_ORDER = ["create", "update", "delete", "export", "import"]
|
|
166
78
|
|
|
167
79
|
/** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
|
|
168
80
|
export interface RoleTemplate {
|
|
@@ -172,16 +84,29 @@ export interface RoleTemplate {
|
|
|
172
84
|
permissions: string[]
|
|
173
85
|
}
|
|
174
86
|
|
|
175
|
-
/** Metadata action
|
|
87
|
+
/** Metadata action (consumer truyền từ Permission Registry). */
|
|
176
88
|
export interface ActionMetaEntry {
|
|
177
89
|
label?: string
|
|
178
90
|
description?: string
|
|
179
91
|
flow?: string
|
|
180
92
|
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
93
|
level?: "staff" | "manager"
|
|
183
94
|
}
|
|
184
95
|
|
|
96
|
+
/** Cây menu thật của app — dựng từ navigations. */
|
|
97
|
+
export interface MenuTreeSection {
|
|
98
|
+
title: string
|
|
99
|
+
icon?: string
|
|
100
|
+
items: Array<{
|
|
101
|
+
title: string
|
|
102
|
+
href?: string
|
|
103
|
+
icon?: string
|
|
104
|
+
resource: string
|
|
105
|
+
/** Vị trí thật khi không phải menu: "nút Cấu hình trong trang Mua hàng"… */
|
|
106
|
+
note?: string
|
|
107
|
+
}>
|
|
108
|
+
}
|
|
109
|
+
|
|
185
110
|
export interface RoleFormPageProps {
|
|
186
111
|
dictionary: any
|
|
187
112
|
permissions?: CrudPermissions
|
|
@@ -213,12 +138,13 @@ export interface RoleFormPageProps {
|
|
|
213
138
|
permissions: string[]
|
|
214
139
|
}
|
|
215
140
|
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
141
|
roleTemplates?: RoleTemplate[]
|
|
218
|
-
/** Meta action nghiệp vụ theo code (flow/description/requires) — additive. */
|
|
219
142
|
actionMeta?: Record<string, ActionMetaEntry>
|
|
220
|
-
/** Đường dẫn trang theo resource code (hiện dưới tên) — additive. */
|
|
221
143
|
resourcePages?: Record<string, string>
|
|
144
|
+
/** Cây menu thật — không truyền thì tự dựng từ group của resources. */
|
|
145
|
+
menuTree?: MenuTreeSection[]
|
|
146
|
+
/** Endpoint nhật ký đổi quyền (edit mode) — vd /api/roles/{id}/history. */
|
|
147
|
+
historyEndpoint?: string
|
|
222
148
|
}
|
|
223
149
|
|
|
224
150
|
export function RoleFormPage({
|
|
@@ -233,6 +159,8 @@ export function RoleFormPage({
|
|
|
233
159
|
roleTemplates,
|
|
234
160
|
actionMeta,
|
|
235
161
|
resourcePages,
|
|
162
|
+
menuTree,
|
|
163
|
+
historyEndpoint,
|
|
236
164
|
}: RoleFormPageProps) {
|
|
237
165
|
const router = useRouter()
|
|
238
166
|
|
|
@@ -242,7 +170,18 @@ export function RoleFormPage({
|
|
|
242
170
|
)
|
|
243
171
|
|
|
244
172
|
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
|
173
|
+
const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set())
|
|
245
174
|
const [searchTerm, setSearchTerm] = React.useState("")
|
|
175
|
+
const [historyOpen, setHistoryOpen] = React.useState(false)
|
|
176
|
+
const [history, setHistory] = React.useState<any[] | null>(null)
|
|
177
|
+
|
|
178
|
+
React.useEffect(() => {
|
|
179
|
+
if (!historyOpen || history || !historyEndpoint) return
|
|
180
|
+
fetch(historyEndpoint)
|
|
181
|
+
.then((r) => r.json())
|
|
182
|
+
.then((d) => setHistory(d.items ?? []))
|
|
183
|
+
.catch(() => setHistory([]))
|
|
184
|
+
}, [historyOpen, history, historyEndpoint])
|
|
246
185
|
|
|
247
186
|
const [formData, setFormData] = React.useState({
|
|
248
187
|
name: initialData?.name || "",
|
|
@@ -252,36 +191,10 @@ export function RoleFormPage({
|
|
|
252
191
|
permissions: initialData?.permissions || ([] as string[]),
|
|
253
192
|
})
|
|
254
193
|
|
|
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
194
|
const getLocalizedName = React.useCallback(
|
|
281
195
|
(key: string | null) => {
|
|
282
196
|
if (!key) return ""
|
|
283
197
|
if (!dictionary) return key
|
|
284
|
-
|
|
285
198
|
const parts = key.split(".")
|
|
286
199
|
let current = dictionary
|
|
287
200
|
for (const part of parts) {
|
|
@@ -297,107 +210,113 @@ export function RoleFormPage({
|
|
|
297
210
|
)
|
|
298
211
|
|
|
299
212
|
const actionName = React.useCallback(
|
|
300
|
-
(code: string) =>
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
code
|
|
306
|
-
)
|
|
307
|
-
},
|
|
213
|
+
(code: string) =>
|
|
214
|
+
actionMeta?.[code]?.label ||
|
|
215
|
+
actions.find((a) => a.code === code)?.name ||
|
|
216
|
+
mergedActionLabels[code]?.label ||
|
|
217
|
+
code,
|
|
308
218
|
[actions, actionMeta, mergedActionLabels]
|
|
309
219
|
)
|
|
310
220
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
221
|
+
// ── Resource → action khai báo ────────────────────────────────────────
|
|
222
|
+
const resourceInfo = React.useMemo(() => {
|
|
223
|
+
const map = new Map<
|
|
224
|
+
string,
|
|
225
|
+
{ name: string; icon: string | null; allowed: string[] }
|
|
226
|
+
>()
|
|
227
|
+
resources.forEach((resource) => {
|
|
228
|
+
let allowed: string[] = []
|
|
314
229
|
const defaultActions = actions
|
|
315
230
|
.filter((a) => a.isDefault)
|
|
316
231
|
.map((a) => a.code)
|
|
317
|
-
const
|
|
232
|
+
const fallback =
|
|
318
233
|
defaultActions.length > 0
|
|
319
234
|
? defaultActions
|
|
320
235
|
: ["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
|
|
236
|
+
try {
|
|
237
|
+
const config =
|
|
238
|
+
typeof resource.config === "string"
|
|
239
|
+
? JSON.parse(resource.config)
|
|
240
|
+
: resource.config
|
|
241
|
+
if (config?.actions && Array.isArray(config.actions)) {
|
|
242
|
+
allowed = actions
|
|
341
243
|
.map((a) => a.code)
|
|
342
|
-
.filter((
|
|
244
|
+
.filter((c) => (config.actions as string[]).includes(c))
|
|
245
|
+
} else {
|
|
246
|
+
allowed = actions
|
|
247
|
+
.map((a) => a.code)
|
|
248
|
+
.filter((c) => fallback.includes(c))
|
|
343
249
|
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
.map((a) => a.code)
|
|
347
|
-
.filter((code) => effectiveDefaultActions.includes(code))
|
|
250
|
+
} catch {
|
|
251
|
+
allowed = actions.map((a) => a.code).filter((c) => fallback.includes(c))
|
|
348
252
|
}
|
|
349
|
-
|
|
350
|
-
return {
|
|
351
|
-
resource: resource.code,
|
|
253
|
+
map.set(resource.code, {
|
|
352
254
|
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
|
-
}
|
|
255
|
+
icon: resource.icon,
|
|
256
|
+
allowed,
|
|
257
|
+
})
|
|
364
258
|
})
|
|
259
|
+
return map
|
|
365
260
|
}, [resources, actions, getLocalizedName])
|
|
366
261
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
(
|
|
371
|
-
|
|
262
|
+
/** Action hiển thị dưới một trang (bỏ view = chính checkbox trang; bỏ lookup
|
|
263
|
+
* = combo tự động). CRUD chuẩn trước, nghiệp vụ sau. */
|
|
264
|
+
const pageActions = React.useCallback(
|
|
265
|
+
(resource: string) => {
|
|
266
|
+
const info = resourceInfo.get(resource)
|
|
267
|
+
if (!info) return []
|
|
268
|
+
const rest = info.allowed.filter(
|
|
269
|
+
(a) => a !== "view" && a !== "lookup" && a !== "view-all-branches"
|
|
270
|
+
)
|
|
271
|
+
const std = STANDARD_ORDER.filter((a) => rest.includes(a))
|
|
272
|
+
const biz = rest.filter((a) => !STANDARD_ORDER.includes(a))
|
|
273
|
+
return [...std, ...biz]
|
|
274
|
+
},
|
|
275
|
+
[resourceInfo]
|
|
372
276
|
)
|
|
373
277
|
|
|
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)
|
|
278
|
+
// ── Cây menu (prop, hoặc tự dựng từ group) ────────────────────────────
|
|
279
|
+
const tree = React.useMemo<MenuTreeSection[]>(() => {
|
|
280
|
+
if (menuTree && menuTree.length > 0) return menuTree
|
|
281
|
+
const byGroup = new Map<string, MenuTreeSection>()
|
|
282
|
+
resources.forEach((r) => {
|
|
283
|
+
const group = getLocalizedName(r.group) || "Khác"
|
|
284
|
+
const section = byGroup.get(group) ?? { title: group, items: [] }
|
|
285
|
+
section.items.push({
|
|
286
|
+
title: getLocalizedName(r.name),
|
|
287
|
+
href: resourcePages?.[r.code],
|
|
288
|
+
icon: r.icon ?? undefined,
|
|
289
|
+
resource: r.code,
|
|
290
|
+
})
|
|
291
|
+
byGroup.set(group, section)
|
|
394
292
|
})
|
|
395
|
-
return
|
|
396
|
-
}, [
|
|
293
|
+
return Array.from(byGroup.values())
|
|
294
|
+
}, [menuTree, resources, resourcePages, getLocalizedName])
|
|
397
295
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
296
|
+
/** Resource có action khai nhưng KHÔNG nằm trên menu nào. */
|
|
297
|
+
const offMenuResources = React.useMemo(() => {
|
|
298
|
+
const onMenu = new Set(
|
|
299
|
+
tree.flatMap((s) => s.items.map((i) => i.resource))
|
|
300
|
+
)
|
|
301
|
+
return resources
|
|
302
|
+
.map((r) => r.code)
|
|
303
|
+
.filter((code) => {
|
|
304
|
+
if (onMenu.has(code)) return false
|
|
305
|
+
const info = resourceInfo.get(code)
|
|
306
|
+
if (!info) return false
|
|
307
|
+
// chỉ còn lookup (vd resource "reference") → thuộc phần combo tự động
|
|
308
|
+
return info.allowed.some((a) => a !== "lookup")
|
|
309
|
+
})
|
|
310
|
+
}, [tree, resources, resourceInfo])
|
|
311
|
+
|
|
312
|
+
const totalAvailable = React.useMemo(
|
|
313
|
+
() =>
|
|
314
|
+
Array.from(resourceInfo.values()).reduce(
|
|
315
|
+
(s, r) => s + r.allowed.length,
|
|
316
|
+
0
|
|
317
|
+
),
|
|
318
|
+
[resourceInfo]
|
|
319
|
+
)
|
|
401
320
|
|
|
402
321
|
const has = React.useCallback(
|
|
403
322
|
(resource: string, action: string) =>
|
|
@@ -405,9 +324,7 @@ export function RoleFormPage({
|
|
|
405
324
|
[formData.permissions]
|
|
406
325
|
)
|
|
407
326
|
|
|
408
|
-
const
|
|
409
|
-
fn: (set: Set<string>) => void
|
|
410
|
-
) => {
|
|
327
|
+
const mutate = (fn: (set: Set<string>) => void) => {
|
|
411
328
|
setFormData((prev) => {
|
|
412
329
|
const set = new Set(prev.permissions)
|
|
413
330
|
fn(set)
|
|
@@ -415,152 +332,109 @@ export function RoleFormPage({
|
|
|
415
332
|
})
|
|
416
333
|
}
|
|
417
334
|
|
|
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)
|
|
335
|
+
// ── Tick trang (menu) ─────────────────────────────────────────────────
|
|
336
|
+
const togglePage = (resource: string, checked: boolean) => {
|
|
337
|
+
const info = resourceInfo.get(resource)
|
|
338
|
+
if (!info) return
|
|
339
|
+
mutate((set) => {
|
|
340
|
+
if (checked) {
|
|
341
|
+
if (info.allowed.includes("view")) set.add(`view:${resource}`)
|
|
342
|
+
} else {
|
|
343
|
+
// mất menu → mọi quyền của trang tắt theo
|
|
344
|
+
info.allowed.forEach((a) => set.delete(`${a}:${resource}`))
|
|
456
345
|
}
|
|
457
|
-
if (level === "full" && r.hasDelete) add("delete")
|
|
458
346
|
})
|
|
459
347
|
}
|
|
460
348
|
|
|
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))
|
|
349
|
+
// ── Tick thao tác trong trang (requires 2 chiều) ──────────────────────
|
|
350
|
+
const toggleAction = (resource: string, code: string) => {
|
|
351
|
+
const info = resourceInfo.get(resource)
|
|
352
|
+
if (!info) return
|
|
353
|
+
const key = `${code}:${resource}`
|
|
354
|
+
if (formData.permissions.includes(key)) {
|
|
355
|
+
// tắt X → tắt luôn các thao tác phụ thuộc X
|
|
356
|
+
const dependents = info.allowed.filter((a) =>
|
|
357
|
+
(actionMeta?.[a]?.requires ?? []).includes(code)
|
|
501
358
|
)
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
) {
|
|
508
|
-
|
|
509
|
-
|
|
359
|
+
const on = dependents.filter((a) =>
|
|
360
|
+
formData.permissions.includes(`${a}:${resource}`)
|
|
361
|
+
)
|
|
362
|
+
mutate((set) => {
|
|
363
|
+
set.delete(key)
|
|
364
|
+
on.forEach((a) => set.delete(`${a}:${resource}`))
|
|
365
|
+
})
|
|
366
|
+
if (on.length > 0) {
|
|
367
|
+
toast.info(
|
|
368
|
+
`Đã tắt kèm: ${on.map((a) => actionName(a)).join(", ")} (phụ thuộc "${actionName(code)}")`
|
|
369
|
+
)
|
|
510
370
|
}
|
|
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
371
|
return
|
|
531
372
|
}
|
|
532
|
-
const requires = actionMeta?.[code]?.requires ?? []
|
|
533
|
-
const needed = ["view", ...requires].filter(
|
|
373
|
+
const requires = (actionMeta?.[code]?.requires ?? []).filter(
|
|
534
374
|
(req) =>
|
|
535
|
-
|
|
536
|
-
!formData.permissions.includes(`${req}:${
|
|
375
|
+
info.allowed.includes(req) &&
|
|
376
|
+
!formData.permissions.includes(`${req}:${resource}`)
|
|
537
377
|
)
|
|
538
|
-
|
|
378
|
+
mutate((set) => {
|
|
539
379
|
set.add(key)
|
|
540
|
-
|
|
380
|
+
requires.forEach((req) => set.add(`${req}:${resource}`))
|
|
541
381
|
})
|
|
542
|
-
if (
|
|
382
|
+
if (requires.length > 0) {
|
|
543
383
|
toast.info(
|
|
544
|
-
`Đã bật kèm quyền tiên quyết: ${
|
|
545
|
-
.map((
|
|
384
|
+
`Đã bật kèm quyền tiên quyết: ${requires
|
|
385
|
+
.map((a) => actionName(a))
|
|
546
386
|
.join(", ")}`
|
|
547
387
|
)
|
|
548
388
|
}
|
|
549
389
|
}
|
|
550
390
|
|
|
551
|
-
// ──
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
391
|
+
// ── Tick cả nhóm menu ─────────────────────────────────────────────────
|
|
392
|
+
const sectionState = (section: MenuTreeSection) => {
|
|
393
|
+
const uniq = [...new Set(section.items.map((i) => i.resource))]
|
|
394
|
+
const on = uniq.filter((r) => has(r, "view")).length
|
|
395
|
+
return { total: uniq.length, on }
|
|
396
|
+
}
|
|
557
397
|
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
398
|
+
const toggleSection = (section: MenuTreeSection, checked: boolean) => {
|
|
399
|
+
const uniq = [...new Set(section.items.map((i) => i.resource))]
|
|
400
|
+
uniq.forEach((r) => togglePageBatch(r, checked))
|
|
401
|
+
function togglePageBatch(resource: string, on: boolean) {
|
|
402
|
+
const info = resourceInfo.get(resource)
|
|
403
|
+
if (!info) return
|
|
404
|
+
mutate((set) => {
|
|
405
|
+
if (on) {
|
|
406
|
+
if (info.allowed.includes("view")) set.add(`view:${resource}`)
|
|
407
|
+
} else {
|
|
408
|
+
info.allowed.forEach((a) => set.delete(`${a}:${resource}`))
|
|
409
|
+
}
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── Phạm vi dữ liệu chi nhánh (view-all-branches) ─────────────────────
|
|
415
|
+
const branchScopeResources = React.useMemo(
|
|
416
|
+
() =>
|
|
417
|
+
Array.from(resourceInfo.entries())
|
|
418
|
+
.filter(([, info]) => info.allowed.includes("view-all-branches"))
|
|
419
|
+
.map(([code]) => code),
|
|
420
|
+
[resourceInfo]
|
|
562
421
|
)
|
|
563
422
|
|
|
423
|
+
const branchScope: "assigned" | "all" = React.useMemo(() => {
|
|
424
|
+
if (branchScopeResources.length === 0) return "assigned"
|
|
425
|
+
return branchScopeResources.every((r) => has(r, "view-all-branches"))
|
|
426
|
+
? "all"
|
|
427
|
+
: "assigned"
|
|
428
|
+
}, [branchScopeResources, has])
|
|
429
|
+
|
|
430
|
+
const setBranchScope = (scope: "assigned" | "all") => {
|
|
431
|
+
mutate((set) => {
|
|
432
|
+
branchScopeResources.forEach((r) => {
|
|
433
|
+
if (scope === "all") set.add(`view-all-branches:${r}`)
|
|
434
|
+
else set.delete(`view-all-branches:${r}`)
|
|
435
|
+
})
|
|
436
|
+
})
|
|
437
|
+
}
|
|
564
438
|
|
|
565
439
|
const applyTemplate = (templateCode: string) => {
|
|
566
440
|
const tpl = roleTemplates?.find((t) => t.code === templateCode)
|
|
@@ -574,29 +448,24 @@ export function RoleFormPage({
|
|
|
574
448
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
575
449
|
e.preventDefault()
|
|
576
450
|
setIsSubmitting(true)
|
|
577
|
-
|
|
578
451
|
try {
|
|
579
452
|
const url =
|
|
580
453
|
mode === "create" ? "/api/roles" : `/api/roles/${initialData?.id}`
|
|
581
454
|
const method = mode === "create" ? "POST" : "PUT"
|
|
582
|
-
|
|
583
455
|
const res = await fetch(url, {
|
|
584
456
|
method,
|
|
585
457
|
headers: { "Content-Type": "application/json" },
|
|
586
458
|
body: JSON.stringify(formData),
|
|
587
459
|
})
|
|
588
|
-
|
|
589
460
|
if (!res.ok) {
|
|
590
461
|
await throwFetchError(res, `Failed to ${mode} role`)
|
|
591
462
|
}
|
|
592
|
-
|
|
593
463
|
toast.success("Thành công", {
|
|
594
464
|
description:
|
|
595
465
|
mode === "create"
|
|
596
466
|
? "Đã tạo vai trò mới thành công"
|
|
597
467
|
: "Đã cập nhật vai trò thành công",
|
|
598
468
|
})
|
|
599
|
-
|
|
600
469
|
router.push(`/${lang}/roles`)
|
|
601
470
|
router.refresh()
|
|
602
471
|
} catch (error: any) {
|
|
@@ -606,10 +475,199 @@ export function RoleFormPage({
|
|
|
606
475
|
}
|
|
607
476
|
}
|
|
608
477
|
|
|
609
|
-
|
|
610
|
-
|
|
478
|
+
// Mở trang: tự thu gọn các nhóm chưa có menu nào được cấp — trang ngắn hẳn.
|
|
479
|
+
const didInitCollapse = React.useRef(false)
|
|
480
|
+
React.useEffect(() => {
|
|
481
|
+
if (didInitCollapse.current) return
|
|
482
|
+
didInitCollapse.current = true
|
|
483
|
+
const zero = tree
|
|
484
|
+
.filter((section) => {
|
|
485
|
+
const uniq = [...new Set(section.items.map((i) => i.resource))]
|
|
486
|
+
return !uniq.some((r) => has(r, "view"))
|
|
487
|
+
})
|
|
488
|
+
.map((s) => s.title)
|
|
489
|
+
if (zero.length > 0) setCollapsed(new Set(zero))
|
|
490
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
491
|
+
}, [])
|
|
492
|
+
|
|
493
|
+
const toggleCollapse = (title: string) => {
|
|
494
|
+
setCollapsed((prev) => {
|
|
495
|
+
const next = new Set(prev)
|
|
496
|
+
if (next.has(title)) next.delete(title)
|
|
497
|
+
else next.add(title)
|
|
498
|
+
return next
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ── Search: lọc theo tên menu / đường dẫn / tên thao tác ─────────────
|
|
503
|
+
const searching = searchTerm.trim().length > 0
|
|
504
|
+
const matchItem = React.useCallback(
|
|
505
|
+
(item: { title: string; href?: string; resource: string }) => {
|
|
506
|
+
if (!searching) return true
|
|
507
|
+
const q = searchTerm.trim().toLowerCase()
|
|
508
|
+
if (item.title.toLowerCase().includes(q)) return true
|
|
509
|
+
if (item.href?.toLowerCase().includes(q)) return true
|
|
510
|
+
if (item.resource.toLowerCase().includes(q)) return true
|
|
511
|
+
return pageActions(item.resource).some((code) =>
|
|
512
|
+
actionName(code).toLowerCase().includes(q)
|
|
513
|
+
)
|
|
514
|
+
},
|
|
515
|
+
[searching, searchTerm, pageActions, actionName]
|
|
611
516
|
)
|
|
612
517
|
|
|
518
|
+
const visibleTree = React.useMemo(() => {
|
|
519
|
+
if (!searching) return tree
|
|
520
|
+
const q = searchTerm.trim().toLowerCase()
|
|
521
|
+
return tree
|
|
522
|
+
.map((section) => {
|
|
523
|
+
if (section.title.toLowerCase().includes(q)) return section
|
|
524
|
+
return { ...section, items: section.items.filter(matchItem) }
|
|
525
|
+
})
|
|
526
|
+
.filter((section) => section.items.length > 0)
|
|
527
|
+
}, [tree, searching, searchTerm, matchItem])
|
|
528
|
+
|
|
529
|
+
const visibleOffMenu = React.useMemo(() => {
|
|
530
|
+
if (!searching) return offMenuResources
|
|
531
|
+
const q = searchTerm.trim().toLowerCase()
|
|
532
|
+
return offMenuResources.filter((code) => {
|
|
533
|
+
const info = resourceInfo.get(code)
|
|
534
|
+
if (info?.name.toLowerCase().includes(q)) return true
|
|
535
|
+
if (code.toLowerCase().includes(q)) return true
|
|
536
|
+
return pageActions(code).some((c) =>
|
|
537
|
+
actionName(c).toLowerCase().includes(q)
|
|
538
|
+
)
|
|
539
|
+
})
|
|
540
|
+
}, [offMenuResources, searching, searchTerm, resourceInfo, pageActions, actionName])
|
|
541
|
+
|
|
542
|
+
const permLabel = React.useCallback(
|
|
543
|
+
(perm: string) => {
|
|
544
|
+
const [action, resource] = perm.split(":")
|
|
545
|
+
const rName = resourceInfo.get(resource)?.name || resource
|
|
546
|
+
return `${actionName(action)} · ${rName}`
|
|
547
|
+
},
|
|
548
|
+
[resourceInfo, actionName]
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
// Render 1 dòng trang menu + hàng thao tác của nó
|
|
552
|
+
const renderPageRow = (item: {
|
|
553
|
+
title: string
|
|
554
|
+
href?: string
|
|
555
|
+
icon?: string
|
|
556
|
+
resource: string
|
|
557
|
+
note?: string
|
|
558
|
+
}) => {
|
|
559
|
+
const info = resourceInfo.get(item.resource)
|
|
560
|
+
if (!info) return null
|
|
561
|
+
const pageOn = has(item.resource, "view")
|
|
562
|
+
const acts = pageActions(item.resource)
|
|
563
|
+
const actsOn = acts.filter((a) => has(item.resource, a)).length
|
|
564
|
+
|
|
565
|
+
return (
|
|
566
|
+
<li key={`${item.resource}:${item.title}`} className="px-4 py-2">
|
|
567
|
+
<label className="flex cursor-pointer items-center gap-2.5">
|
|
568
|
+
<Checkbox
|
|
569
|
+
checked={pageOn}
|
|
570
|
+
onCheckedChange={(c) => togglePage(item.resource, c === true)}
|
|
571
|
+
aria-label={`Menu ${item.title}`}
|
|
572
|
+
/>
|
|
573
|
+
<DynamicIcon
|
|
574
|
+
name={(item.icon || info.icon) as any}
|
|
575
|
+
className="h-4 w-4 shrink-0 text-muted-foreground"
|
|
576
|
+
/>
|
|
577
|
+
<span
|
|
578
|
+
className={cn(
|
|
579
|
+
"text-sm font-medium",
|
|
580
|
+
pageOn ? "text-foreground" : "text-muted-foreground"
|
|
581
|
+
)}
|
|
582
|
+
>
|
|
583
|
+
{item.title}
|
|
584
|
+
</span>
|
|
585
|
+
{item.href && (
|
|
586
|
+
<code className="hidden font-mono text-[10px] text-muted-foreground sm:inline">
|
|
587
|
+
{item.href}
|
|
588
|
+
</code>
|
|
589
|
+
)}
|
|
590
|
+
{item.note && (
|
|
591
|
+
<span className="hidden text-[10px] italic text-muted-foreground/80 sm:inline">
|
|
592
|
+
{item.note}
|
|
593
|
+
</span>
|
|
594
|
+
)}
|
|
595
|
+
{!pageOn && (
|
|
596
|
+
<span
|
|
597
|
+
className="text-[10px] text-muted-foreground/60"
|
|
598
|
+
title="Vai trò này không thấy menu và không vào được trang"
|
|
599
|
+
>
|
|
600
|
+
· ẩn
|
|
601
|
+
</span>
|
|
602
|
+
)}
|
|
603
|
+
{pageOn && acts.length > 0 && (
|
|
604
|
+
<span className="ml-auto text-[10px] tabular-nums text-muted-foreground">
|
|
605
|
+
{actsOn}/{acts.length} thao tác
|
|
606
|
+
</span>
|
|
607
|
+
)}
|
|
608
|
+
</label>
|
|
609
|
+
|
|
610
|
+
{/* Thao tác (nút) trong trang — nhóm theo LUỒNG, mỗi luồng 1 dòng */}
|
|
611
|
+
{pageOn && acts.length > 0 && (
|
|
612
|
+
<div className="mt-1.5 space-y-1 pl-7 sm:pl-9">
|
|
613
|
+
{(() => {
|
|
614
|
+
// "Chung" = CRUD chuẩn; còn lại theo actionMeta.flow
|
|
615
|
+
const flows = new Map<string, string[]>()
|
|
616
|
+
acts.forEach((code) => {
|
|
617
|
+
const flow = STANDARD_ORDER.includes(code)
|
|
618
|
+
? "Chung"
|
|
619
|
+
: actionMeta?.[code]?.flow || "Khác"
|
|
620
|
+
const list = flows.get(flow) ?? []
|
|
621
|
+
list.push(code)
|
|
622
|
+
flows.set(flow, list)
|
|
623
|
+
})
|
|
624
|
+
return Array.from(flows.entries()).map(([flow, codes]) => (
|
|
625
|
+
<div
|
|
626
|
+
key={flow}
|
|
627
|
+
className="flex flex-wrap items-center gap-x-3 gap-y-1"
|
|
628
|
+
>
|
|
629
|
+
<span className="w-24 shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground/60">
|
|
630
|
+
{flow}
|
|
631
|
+
</span>
|
|
632
|
+
{codes.map((code) => {
|
|
633
|
+
const meta = actionMeta?.[code]
|
|
634
|
+
const checked = has(item.resource, code)
|
|
635
|
+
return (
|
|
636
|
+
<label
|
|
637
|
+
key={code}
|
|
638
|
+
title={meta?.description || actionName(code)}
|
|
639
|
+
className="flex cursor-pointer items-center gap-1.5"
|
|
640
|
+
>
|
|
641
|
+
<Checkbox
|
|
642
|
+
checked={checked}
|
|
643
|
+
onCheckedChange={() =>
|
|
644
|
+
toggleAction(item.resource, code)
|
|
645
|
+
}
|
|
646
|
+
className="h-3.5 w-3.5"
|
|
647
|
+
aria-label={`${actionName(code)} — ${item.title}`}
|
|
648
|
+
/>
|
|
649
|
+
<span
|
|
650
|
+
className={cn(
|
|
651
|
+
"text-xs",
|
|
652
|
+
checked
|
|
653
|
+
? "text-foreground"
|
|
654
|
+
: "text-muted-foreground"
|
|
655
|
+
)}
|
|
656
|
+
>
|
|
657
|
+
{actionName(code)}
|
|
658
|
+
</span>
|
|
659
|
+
</label>
|
|
660
|
+
)
|
|
661
|
+
})}
|
|
662
|
+
</div>
|
|
663
|
+
))
|
|
664
|
+
})()}
|
|
665
|
+
</div>
|
|
666
|
+
)}
|
|
667
|
+
</li>
|
|
668
|
+
)
|
|
669
|
+
}
|
|
670
|
+
|
|
613
671
|
return (
|
|
614
672
|
<div className="flex flex-col">
|
|
615
673
|
{/* ===== 1. Header bar navy 1 DÒNG ===== */}
|
|
@@ -719,322 +777,348 @@ export function RoleFormPage({
|
|
|
719
777
|
</Button>
|
|
720
778
|
</div>
|
|
721
779
|
|
|
722
|
-
<div className="mt-3
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
780
|
+
<div className="mt-3 flex items-start gap-3 lg:mx-3">
|
|
781
|
+
<div className="min-w-0 flex-1 overflow-hidden rounded-xl border border-border bg-card shadow-sm">
|
|
782
|
+
{/* ===== 2. Toolbar: mẫu + phạm vi chi nhánh + đếm ===== */}
|
|
783
|
+
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2 sm:px-4">
|
|
784
|
+
<div className="relative min-w-[150px] flex-1 sm:max-w-64">
|
|
785
|
+
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
786
|
+
<Input
|
|
787
|
+
placeholder="Tìm menu / thao tác..."
|
|
788
|
+
className="h-8 rounded-md border border-border bg-card pl-8 text-sm"
|
|
789
|
+
value={searchTerm}
|
|
790
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
791
|
+
/>
|
|
792
|
+
</div>
|
|
793
|
+
{mode === "create" && (roleTemplates?.length ?? 0) > 0 && (
|
|
794
|
+
<Select onValueChange={applyTemplate}>
|
|
795
|
+
<SelectTrigger className="h-8 w-48 rounded-md border border-border bg-card text-sm">
|
|
796
|
+
<span className="flex items-center gap-1.5 truncate">
|
|
797
|
+
<LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
798
|
+
<SelectValue placeholder="Bộ quyền mẫu..." />
|
|
799
|
+
</span>
|
|
800
|
+
</SelectTrigger>
|
|
801
|
+
<SelectContent className="rounded-lg">
|
|
802
|
+
{roleTemplates!.map((tpl) => (
|
|
803
|
+
<SelectItem key={tpl.code} value={tpl.code}>
|
|
804
|
+
<span className="flex flex-col">
|
|
805
|
+
<span>{tpl.label}</span>
|
|
806
|
+
{tpl.description && (
|
|
807
|
+
<span className="text-[11px] text-muted-foreground">
|
|
808
|
+
{tpl.description}
|
|
809
|
+
</span>
|
|
810
|
+
)}
|
|
811
|
+
</span>
|
|
812
|
+
</SelectItem>
|
|
813
|
+
))}
|
|
814
|
+
</SelectContent>
|
|
815
|
+
</Select>
|
|
816
|
+
)}
|
|
817
|
+
|
|
818
|
+
{branchScopeResources.length > 0 && (
|
|
819
|
+
<div className="flex items-center gap-1.5">
|
|
820
|
+
<Building2 className="h-3.5 w-3.5 text-muted-foreground" />
|
|
821
|
+
<span className="text-xs text-muted-foreground">
|
|
822
|
+
Phạm vi dữ liệu:
|
|
823
|
+
</span>
|
|
824
|
+
<Select
|
|
825
|
+
value={branchScope}
|
|
826
|
+
onValueChange={(v) => setBranchScope(v as "assigned" | "all")}
|
|
827
|
+
>
|
|
828
|
+
<SelectTrigger className="h-8 w-52 rounded-md border border-border bg-card text-sm">
|
|
829
|
+
<SelectValue>
|
|
830
|
+
{branchScope === "all"
|
|
831
|
+
? "Tất cả chi nhánh"
|
|
832
|
+
: "Chỉ chi nhánh được gán"}
|
|
833
|
+
</SelectValue>
|
|
742
834
|
</SelectTrigger>
|
|
743
835
|
<SelectContent className="rounded-lg">
|
|
744
|
-
|
|
745
|
-
<
|
|
746
|
-
<span
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
<span className="text-[11px] text-muted-foreground">
|
|
750
|
-
{tpl.description}
|
|
751
|
-
</span>
|
|
752
|
-
)}
|
|
836
|
+
<SelectItem value="assigned">
|
|
837
|
+
<span className="flex flex-col">
|
|
838
|
+
<span>Chỉ chi nhánh được gán</span>
|
|
839
|
+
<span className="text-[11px] text-muted-foreground">
|
|
840
|
+
Theo chi nhánh gán ở trang Người dùng
|
|
753
841
|
</span>
|
|
754
|
-
</
|
|
755
|
-
|
|
842
|
+
</span>
|
|
843
|
+
</SelectItem>
|
|
844
|
+
<SelectItem value="all">
|
|
845
|
+
<span className="flex flex-col">
|
|
846
|
+
<span>Tất cả chi nhánh</span>
|
|
847
|
+
<span className="text-[11px] text-muted-foreground">
|
|
848
|
+
Xem dữ liệu mọi chi nhánh (quản lý vùng/kế toán tổng)
|
|
849
|
+
</span>
|
|
850
|
+
</span>
|
|
851
|
+
</SelectItem>
|
|
756
852
|
</SelectContent>
|
|
757
853
|
</Select>
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
</
|
|
766
|
-
</
|
|
854
|
+
</div>
|
|
855
|
+
)}
|
|
856
|
+
|
|
857
|
+
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
|
|
858
|
+
<span className="font-semibold tabular-nums text-foreground">
|
|
859
|
+
{formData.permissions.length}
|
|
860
|
+
</span>
|
|
861
|
+
<span className="tabular-nums">/{totalAvailable}</span> quyền
|
|
862
|
+
</p>
|
|
863
|
+
</div>
|
|
767
864
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
))}
|
|
776
|
-
</div>
|
|
865
|
+
{/* Combo tự động — admin khỏi lo */}
|
|
866
|
+
<div className="flex items-start gap-2 border-b border-border bg-muted/30 px-4 py-1.5 text-[11px] text-muted-foreground">
|
|
867
|
+
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
868
|
+
<span>
|
|
869
|
+
Ô chọn khách / sản phẩm / kho… trong các form <b>tự hoạt động</b> —
|
|
870
|
+
không cần cấp quyền ở đây.
|
|
871
|
+
</span>
|
|
777
872
|
</div>
|
|
778
873
|
|
|
779
|
-
{/* ===== 3.
|
|
780
|
-
{
|
|
781
|
-
<
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
874
|
+
{/* ===== 3. Cây menu ===== */}
|
|
875
|
+
{searching && visibleTree.length === 0 && visibleOffMenu.length === 0 && (
|
|
876
|
+
<p className="px-4 py-8 text-center text-xs text-muted-foreground">
|
|
877
|
+
Không có menu / thao tác nào khớp "{searchTerm}".
|
|
878
|
+
</p>
|
|
879
|
+
)}
|
|
880
|
+
{visibleTree.map((section) => {
|
|
881
|
+
const { total, on } = sectionState(section)
|
|
882
|
+
const isCollapsed = !searching && collapsed.has(section.title)
|
|
883
|
+
return (
|
|
884
|
+
<section key={section.title}>
|
|
885
|
+
<div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
|
|
886
|
+
<button
|
|
887
|
+
type="button"
|
|
888
|
+
onClick={() => toggleCollapse(section.title)}
|
|
889
|
+
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground"
|
|
890
|
+
aria-label={
|
|
891
|
+
isCollapsed ? "Mở nhóm menu" : "Thu gọn nhóm menu"
|
|
892
|
+
}
|
|
893
|
+
>
|
|
894
|
+
{isCollapsed ? (
|
|
895
|
+
<ChevronRight className="h-4 w-4" />
|
|
896
|
+
) : (
|
|
897
|
+
<ChevronDown className="h-4 w-4" />
|
|
898
|
+
)}
|
|
899
|
+
</button>
|
|
900
|
+
<Checkbox
|
|
901
|
+
checked={
|
|
902
|
+
on === 0 ? false : on === total ? true : "indeterminate"
|
|
903
|
+
}
|
|
904
|
+
onCheckedChange={(c) => toggleSection(section, c === true)}
|
|
905
|
+
aria-label={`Cả nhóm ${section.title}`}
|
|
906
|
+
/>
|
|
907
|
+
<DynamicIcon
|
|
908
|
+
name={section.icon as any}
|
|
909
|
+
className="h-4 w-4 shrink-0 text-muted-foreground"
|
|
910
|
+
/>
|
|
911
|
+
<h3 className="text-sm font-semibold text-foreground">
|
|
912
|
+
{section.title}
|
|
913
|
+
</h3>
|
|
914
|
+
<span
|
|
915
|
+
className={cn(
|
|
916
|
+
"text-[11px] tabular-nums",
|
|
917
|
+
on === total && total > 0
|
|
918
|
+
? "font-medium text-emerald-600 dark:text-emerald-400"
|
|
919
|
+
: "text-muted-foreground"
|
|
920
|
+
)}
|
|
921
|
+
>
|
|
922
|
+
{on}/{total} menu
|
|
923
|
+
</span>
|
|
924
|
+
</div>
|
|
925
|
+
{!isCollapsed && (
|
|
926
|
+
<ul className="divide-y divide-border/60 border-b border-border">
|
|
927
|
+
{section.items.map(renderPageRow)}
|
|
928
|
+
</ul>
|
|
929
|
+
)}
|
|
930
|
+
</section>
|
|
931
|
+
)
|
|
932
|
+
})}
|
|
933
|
+
|
|
934
|
+
{/* ===== 4. Quyền không gắn menu (dialog / route ẩn) ===== */}
|
|
935
|
+
{visibleOffMenu.length > 0 && (
|
|
936
|
+
<section>
|
|
937
|
+
<div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
|
|
938
|
+
<h3 className="text-sm font-semibold text-foreground">
|
|
939
|
+
Chức năng không có menu riêng
|
|
940
|
+
</h3>
|
|
941
|
+
<span className="text-[11px] text-muted-foreground">
|
|
942
|
+
dialog / thao tác nằm trong trang khác
|
|
943
|
+
</span>
|
|
944
|
+
</div>
|
|
945
|
+
<ul className="divide-y divide-border/60">
|
|
946
|
+
{visibleOffMenu.map((code) =>
|
|
947
|
+
renderPageRow({
|
|
948
|
+
title: resourceInfo.get(code)?.name || code,
|
|
949
|
+
resource: code,
|
|
950
|
+
})
|
|
951
|
+
)}
|
|
952
|
+
</ul>
|
|
953
|
+
</section>
|
|
954
|
+
)}
|
|
955
|
+
</div>
|
|
795
956
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
957
|
+
{/* ===== 🪞 PREVIEW SIDEBAR SỐNG — nhân viên mang vai trò này sẽ thấy ===== */}
|
|
958
|
+
<aside className="sticky top-4 hidden w-60 shrink-0 overflow-hidden rounded-xl border border-border shadow-sm xl:block">
|
|
959
|
+
<div className="border-b border-white/10 bg-sidebar px-3 py-2">
|
|
960
|
+
<p className="text-xs font-semibold text-primary-foreground">
|
|
961
|
+
Nhân viên sẽ thấy
|
|
962
|
+
</p>
|
|
963
|
+
<p className="text-[10px] text-primary-foreground/60">
|
|
964
|
+
Sidebar cập nhật theo từng ô tick
|
|
965
|
+
</p>
|
|
966
|
+
</div>
|
|
967
|
+
<div className="max-h-[70vh] overflow-y-auto bg-sidebar px-2 py-2 [scrollbar-width:thin]">
|
|
968
|
+
{(() => {
|
|
969
|
+
const visibleSections = tree
|
|
970
|
+
.map((section) => ({
|
|
971
|
+
...section,
|
|
972
|
+
items: section.items.filter(
|
|
973
|
+
(i) => !i.note && has(i.resource, "view")
|
|
974
|
+
),
|
|
975
|
+
}))
|
|
976
|
+
.filter((sec) => sec.items.length > 0)
|
|
977
|
+
if (visibleSections.length === 0) {
|
|
978
|
+
return (
|
|
979
|
+
<p className="px-2 py-6 text-center text-[11px] text-primary-foreground/50">
|
|
980
|
+
Chưa có menu nào —
|
|
981
|
+
<br />
|
|
982
|
+
vai trò này không thấy gì trên sidebar.
|
|
983
|
+
</p>
|
|
984
|
+
)
|
|
985
|
+
}
|
|
986
|
+
return visibleSections.map((sec) => (
|
|
987
|
+
<div key={sec.title} className="mb-1.5">
|
|
988
|
+
<p className="px-2 py-1 text-[9px] font-semibold uppercase tracking-wider text-primary-foreground/40">
|
|
989
|
+
{sec.title}
|
|
990
|
+
</p>
|
|
991
|
+
{sec.items.map((i) => (
|
|
992
|
+
<div
|
|
993
|
+
key={`${i.resource}:${i.title}`}
|
|
994
|
+
className="flex items-center gap-2 rounded-md px-2 py-1 text-primary-foreground/85"
|
|
809
995
|
>
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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>
|
|
996
|
+
<DynamicIcon
|
|
997
|
+
name={i.icon as any}
|
|
998
|
+
className="h-3.5 w-3.5 shrink-0 opacity-70"
|
|
999
|
+
/>
|
|
1000
|
+
<span className="truncate text-[11px]">{i.title}</span>
|
|
856
1001
|
</div>
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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>
|
|
1002
|
+
))}
|
|
1003
|
+
</div>
|
|
1004
|
+
))
|
|
1005
|
+
})()}
|
|
1006
|
+
</div>
|
|
1007
|
+
{branchScopeResources.length > 0 && (
|
|
1008
|
+
<div className="border-t border-white/10 bg-sidebar px-3 py-1.5 text-[10px] text-primary-foreground/60">
|
|
1009
|
+
Dữ liệu:{" "}
|
|
1010
|
+
{branchScope === "all"
|
|
1011
|
+
? "tất cả chi nhánh"
|
|
1012
|
+
: "chi nhánh được gán"}
|
|
1013
|
+
</div>
|
|
1014
|
+
)}
|
|
1015
|
+
</aside>
|
|
1016
|
+
</div>
|
|
932
1017
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1018
|
+
{/* ===== 🕓 Nhật ký thay đổi quyền ===== */}
|
|
1019
|
+
{mode === "edit" && historyEndpoint && (
|
|
1020
|
+
<div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
|
|
1021
|
+
<button
|
|
1022
|
+
type="button"
|
|
1023
|
+
onClick={() => setHistoryOpen((o) => !o)}
|
|
1024
|
+
className="flex w-full items-center gap-2 px-4 py-2 text-left hover:bg-muted/30"
|
|
1025
|
+
>
|
|
1026
|
+
<History className="h-4 w-4 text-muted-foreground" />
|
|
1027
|
+
<span className="text-sm font-semibold text-foreground">
|
|
1028
|
+
Nhật ký thay đổi quyền
|
|
1029
|
+
</span>
|
|
1030
|
+
<span className="text-[11px] text-muted-foreground">
|
|
1031
|
+
ai đổi gì, lúc nào
|
|
1032
|
+
</span>
|
|
1033
|
+
<span className="ml-auto text-xs text-muted-foreground">
|
|
1034
|
+
{historyOpen ? "Thu gọn" : "Xem"}
|
|
1035
|
+
</span>
|
|
1036
|
+
</button>
|
|
1037
|
+
{historyOpen && (
|
|
1038
|
+
<div className="border-t border-border">
|
|
1039
|
+
{history === null ? (
|
|
1040
|
+
<p className="px-4 py-4 text-center text-xs text-muted-foreground">
|
|
1041
|
+
Đang tải...
|
|
1042
|
+
</p>
|
|
1043
|
+
) : history.length === 0 ? (
|
|
1044
|
+
<p className="px-4 py-4 text-center text-xs text-muted-foreground">
|
|
1045
|
+
Chưa có thay đổi nào được ghi nhận.
|
|
1046
|
+
</p>
|
|
1047
|
+
) : (
|
|
1048
|
+
<ul className="divide-y divide-border/60">
|
|
1049
|
+
{history.map((e) => (
|
|
1050
|
+
<li key={e.id} className="px-4 py-2 text-xs">
|
|
1051
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
|
1052
|
+
<span className="tabular-nums text-muted-foreground">
|
|
1053
|
+
{new Date(e.at).toLocaleString("vi-VN", {
|
|
1054
|
+
hour: "2-digit",
|
|
1055
|
+
minute: "2-digit",
|
|
1056
|
+
day: "2-digit",
|
|
1057
|
+
month: "2-digit",
|
|
1058
|
+
year: "numeric",
|
|
1059
|
+
})}
|
|
1060
|
+
</span>
|
|
1061
|
+
{e.by && (
|
|
1062
|
+
<span className="font-medium text-foreground">
|
|
1063
|
+
{e.by}
|
|
1064
|
+
</span>
|
|
1065
|
+
)}
|
|
1066
|
+
{e.action === "create" && (
|
|
1067
|
+
<span className="text-muted-foreground">
|
|
1068
|
+
tạo vai trò
|
|
1069
|
+
</span>
|
|
1070
|
+
)}
|
|
1071
|
+
{e.nameChange && (
|
|
1072
|
+
<span className="text-muted-foreground">
|
|
1073
|
+
đổi tên "{e.nameChange.from}" → "{e.nameChange.to}"
|
|
1074
|
+
</span>
|
|
1075
|
+
)}
|
|
1076
|
+
{e.statusChange && (
|
|
1077
|
+
<span className="text-muted-foreground">
|
|
1078
|
+
{e.statusChange.to === "active"
|
|
1079
|
+
? "mở khóa vai trò"
|
|
1080
|
+
: "khóa vai trò"}
|
|
1081
|
+
</span>
|
|
1082
|
+
)}
|
|
1083
|
+
</div>
|
|
1084
|
+
{(e.added?.length > 0 || e.removed?.length > 0) && (
|
|
1085
|
+
<div className="mt-1 flex flex-wrap gap-1">
|
|
1086
|
+
{e.added.slice(0, 8).map((p: string) => (
|
|
1087
|
+
<span
|
|
1088
|
+
key={p}
|
|
1089
|
+
className="rounded bg-emerald-50 px-1.5 py-0.5 text-[11px] text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
|
944
1090
|
>
|
|
945
|
-
{
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1091
|
+
+{permLabel(p)}
|
|
1092
|
+
</span>
|
|
1093
|
+
))}
|
|
1094
|
+
{e.added.length > 8 && (
|
|
1095
|
+
<span className="text-[11px] text-muted-foreground">
|
|
1096
|
+
+{e.added.length - 8} quyền khác
|
|
1097
|
+
</span>
|
|
1098
|
+
)}
|
|
1099
|
+
{e.removed.slice(0, 8).map((p: string) => (
|
|
1100
|
+
<span
|
|
1101
|
+
key={p}
|
|
1102
|
+
className="rounded bg-rose-50 px-1.5 py-0.5 text-[11px] text-rose-700 line-through dark:bg-rose-950 dark:text-rose-400"
|
|
1103
|
+
>
|
|
1104
|
+
{permLabel(p)}
|
|
1105
|
+
</span>
|
|
1106
|
+
))}
|
|
1107
|
+
{e.removed.length > 8 && (
|
|
1108
|
+
<span className="text-[11px] text-muted-foreground">
|
|
1109
|
+
−{e.removed.length - 8} quyền khác
|
|
1110
|
+
</span>
|
|
955
1111
|
)}
|
|
956
1112
|
</div>
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
})}
|
|
1113
|
+
)}
|
|
1114
|
+
</li>
|
|
1115
|
+
))}
|
|
1031
1116
|
</ul>
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
</div>
|
|
1117
|
+
)}
|
|
1118
|
+
</div>
|
|
1119
|
+
)}
|
|
1120
|
+
</div>
|
|
1121
|
+
)}
|
|
1038
1122
|
</div>
|
|
1039
1123
|
)
|
|
1040
1124
|
}
|