@goplusvn/core 0.1.37 → 0.1.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -115,9 +115,14 @@ interface Permissions {
|
|
|
115
115
|
|
|
116
116
|
export interface ActionListPageProps {
|
|
117
117
|
permissions?: Permissions
|
|
118
|
+
/** Catalog hành động do Permission Registry trong code quản lý — ẩn mọi nút ghi. */
|
|
119
|
+
readOnly?: boolean
|
|
118
120
|
}
|
|
119
121
|
|
|
120
|
-
export function ActionListPage({
|
|
122
|
+
export function ActionListPage({
|
|
123
|
+
permissions = {},
|
|
124
|
+
readOnly = false,
|
|
125
|
+
}: ActionListPageProps) {
|
|
121
126
|
const [loading, setLoading] = useState(true)
|
|
122
127
|
const [actions, setActions] = useState<Action[]>([])
|
|
123
128
|
const [usage, setUsage] = useState<UsageMap>({})
|
|
@@ -140,9 +145,9 @@ export function ActionListPage({ permissions = {} }: ActionListPageProps) {
|
|
|
140
145
|
const [toDelete, setToDelete] = useState<Action | null>(null)
|
|
141
146
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
142
147
|
|
|
143
|
-
const canCreate = !!permissions.create
|
|
144
|
-
const canUpdate = !!permissions.update
|
|
145
|
-
const canDelete = !!permissions.delete
|
|
148
|
+
const canCreate = !readOnly && !!permissions.create
|
|
149
|
+
const canUpdate = !readOnly && !!permissions.update
|
|
150
|
+
const canDelete = !readOnly && !!permissions.delete
|
|
146
151
|
|
|
147
152
|
const fetchData = async () => {
|
|
148
153
|
try {
|
|
@@ -212,7 +212,20 @@ const normalize = (s: string) =>
|
|
|
212
212
|
.replace(/[\u0300-\u036f]/g, "")
|
|
213
213
|
.replace(/đ/g, "d")
|
|
214
214
|
|
|
215
|
-
export
|
|
215
|
+
export interface ResourceListPageProps {
|
|
216
|
+
/** Danh mục do Permission Registry trong code quản lý: ẩn nút Thêm/Sync/
|
|
217
|
+
* Action-manager, thao tác sửa/xóa hiện toast hướng dẫn thay vì mở dialog. */
|
|
218
|
+
readOnly?: boolean
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {}) {
|
|
222
|
+
const registryToast = () =>
|
|
223
|
+
toast.info("Danh mục do Permission Registry quản lý", {
|
|
224
|
+
description:
|
|
225
|
+
"Sửa file <feature>.permissions.ts trong code rồi chạy rbac-sync — không chỉnh tay ở đây.",
|
|
226
|
+
})
|
|
227
|
+
const guardWrite = <T extends (...args: any[]) => any>(fn: T): T =>
|
|
228
|
+
(readOnly ? ((() => registryToast()) as unknown as T) : fn)
|
|
216
229
|
const [loading, setLoading] = useState(true)
|
|
217
230
|
const [resources, setResources] = useState<Resource[]>([])
|
|
218
231
|
const [allActions, setAllActions] = useState<Action[]>([])
|
|
@@ -707,37 +720,57 @@ export function ResourceListPage() {
|
|
|
707
720
|
className={cn("h-4 w-4", isRefreshing && "animate-spin")}
|
|
708
721
|
/>
|
|
709
722
|
</Button>
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
723
|
+
{!readOnly && (
|
|
724
|
+
<>
|
|
725
|
+
<Button
|
|
726
|
+
variant="ghost"
|
|
727
|
+
size="icon"
|
|
728
|
+
onClick={handleSync}
|
|
729
|
+
disabled={isSyncing}
|
|
730
|
+
title="Đồng bộ tài nguyên"
|
|
731
|
+
className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
|
|
732
|
+
>
|
|
733
|
+
<DatabaseBackup
|
|
734
|
+
className={cn("h-4 w-4", isSyncing && "animate-pulse")}
|
|
735
|
+
/>
|
|
736
|
+
</Button>
|
|
737
|
+
<Button
|
|
738
|
+
variant="ghost"
|
|
739
|
+
size="icon"
|
|
740
|
+
onClick={openActionManager}
|
|
741
|
+
title="Quản lý hành động"
|
|
742
|
+
className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
|
|
743
|
+
>
|
|
744
|
+
<Zap className="h-4 w-4" />
|
|
745
|
+
</Button>
|
|
746
|
+
<Button
|
|
747
|
+
onClick={openNewResource}
|
|
748
|
+
className="h-8 shrink-0 bg-primary-foreground px-2.5 text-primary hover:bg-primary-foreground/90 sm:px-3"
|
|
749
|
+
>
|
|
750
|
+
<Plus className="h-4 w-4" />
|
|
751
|
+
<span className="ml-1.5 hidden sm:inline">Thêm tài nguyên</span>
|
|
752
|
+
<span className="ml-1.5 sm:hidden">Thêm</span>
|
|
753
|
+
</Button>
|
|
754
|
+
</>
|
|
755
|
+
)}
|
|
739
756
|
</div>
|
|
740
757
|
|
|
758
|
+
{readOnly && (
|
|
759
|
+
<div className="mt-3 flex items-start gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground lg:mx-3">
|
|
760
|
+
<Shield className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
761
|
+
<span>
|
|
762
|
+
Danh mục tài nguyên & hành động do{" "}
|
|
763
|
+
<span className="font-medium text-foreground">
|
|
764
|
+
Permission Registry
|
|
765
|
+
</span>{" "}
|
|
766
|
+
trong code quản lý (file{" "}
|
|
767
|
+
<code className="font-mono"><feature>.permissions.ts</code> +{" "}
|
|
768
|
+
<code className="font-mono">rbac-sync</code>). Trang này chỉ để tra
|
|
769
|
+
cứu; cấp quyền cho vai trò làm ở trang Vai trò.
|
|
770
|
+
</span>
|
|
771
|
+
</div>
|
|
772
|
+
)}
|
|
773
|
+
|
|
741
774
|
{/* ===== Khối trắng liền: toolbar sticky + nhóm + hàng tài nguyên ===== */}
|
|
742
775
|
<div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
|
|
743
776
|
{/* Toolbar sticky */}
|
|
@@ -868,9 +901,9 @@ export function ResourceListPage() {
|
|
|
868
901
|
categorized={categorizedActions}
|
|
869
902
|
expanded={expandedRows.has(resource.id)}
|
|
870
903
|
onToggleExpand={() => toggleRow(resource.id)}
|
|
871
|
-
onToggleAction={toggleAction}
|
|
872
|
-
onUpdateActions={handleUpdateActions}
|
|
873
|
-
onEditResource={openEditResource}
|
|
904
|
+
onToggleAction={guardWrite(toggleAction)}
|
|
905
|
+
onUpdateActions={guardWrite(handleUpdateActions)}
|
|
906
|
+
onEditResource={guardWrite(openEditResource)}
|
|
874
907
|
/>
|
|
875
908
|
))}
|
|
876
909
|
</ul>
|
|
@@ -886,10 +919,10 @@ export function ResourceListPage() {
|
|
|
886
919
|
categorized={categorizedActions}
|
|
887
920
|
expanded={expandedRows.has(resource.id)}
|
|
888
921
|
onToggleExpand={() => toggleRow(resource.id)}
|
|
889
|
-
onToggleAction={toggleAction}
|
|
890
|
-
onUpdateActions={handleUpdateActions}
|
|
891
|
-
onEditResource={openEditResource}
|
|
892
|
-
onDeleteResource={requestDeleteResource}
|
|
922
|
+
onToggleAction={guardWrite(toggleAction)}
|
|
923
|
+
onUpdateActions={guardWrite(handleUpdateActions)}
|
|
924
|
+
onEditResource={guardWrite(openEditResource)}
|
|
925
|
+
onDeleteResource={guardWrite(requestDeleteResource)}
|
|
893
926
|
/>
|
|
894
927
|
))}
|
|
895
928
|
</div>
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
// Trang tạo/sửa VAI TRÒ — bar navy 1 DÒNG kiểu chi tiết đơn bán hàng
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// Trang tạo/sửa VAI TRÒ — bar navy 1 DÒNG kiểu chi tiết đơn bán hàng.
|
|
4
|
+
//
|
|
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
|
+
// API contract giữ nguyên: POST /api/roles, PUT /api/roles/:id, quyền dạng
|
|
18
|
+
// `${action}:${resource}`.
|
|
9
19
|
import * as React from "react"
|
|
10
20
|
import { useRouter } from "next/navigation"
|
|
11
21
|
import {
|
|
@@ -18,7 +28,6 @@ import {
|
|
|
18
28
|
SelectItem,
|
|
19
29
|
SelectTrigger,
|
|
20
30
|
SelectValue,
|
|
21
|
-
Switch,
|
|
22
31
|
getStatusMeta,
|
|
23
32
|
} from "../../ui"
|
|
24
33
|
import { toast } from "sonner"
|
|
@@ -33,6 +42,7 @@ import {
|
|
|
33
42
|
Copy,
|
|
34
43
|
Download,
|
|
35
44
|
Eye,
|
|
45
|
+
LayoutTemplate,
|
|
36
46
|
PenLine,
|
|
37
47
|
Pencil,
|
|
38
48
|
Plus,
|
|
@@ -73,8 +83,31 @@ const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
|
|
|
73
83
|
delete_items: { label: "Xóa chi tiết", icon: "❌", color: "text-rose-600" },
|
|
74
84
|
}
|
|
75
85
|
|
|
76
|
-
//
|
|
77
|
-
|
|
86
|
+
// Action CHUẨN gộp vào thang "Mức truy cập" — không hiện lẻ.
|
|
87
|
+
const WRITE_CODES = ["create", "update", "export", "import"] as const
|
|
88
|
+
const STANDARD_CODES = new Set([
|
|
89
|
+
"view",
|
|
90
|
+
"lookup",
|
|
91
|
+
"delete",
|
|
92
|
+
...WRITE_CODES,
|
|
93
|
+
])
|
|
94
|
+
|
|
95
|
+
type AccessLevel = "hidden" | "lookup" | "view" | "write" | "full"
|
|
96
|
+
|
|
97
|
+
const LEVELS: Array<{ key: AccessLevel; label: string; hint: string }> = [
|
|
98
|
+
{ key: "hidden", label: "Ẩn", hint: "Không thấy menu, không quyền gì" },
|
|
99
|
+
{
|
|
100
|
+
key: "lookup",
|
|
101
|
+
label: "Tra cứu",
|
|
102
|
+
hint: "Chỉ chọn trong combo/selector — KHÔNG thấy menu",
|
|
103
|
+
},
|
|
104
|
+
{ key: "view", label: "Xem", hint: "Menu sidebar hiện + vào được trang" },
|
|
105
|
+
{ key: "write", label: "Ghi", hint: "Xem + Tạo/Sửa/Xuất/Nhập" },
|
|
106
|
+
{ key: "full", label: "Toàn quyền", hint: "Ghi + Xóa" },
|
|
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. ---
|
|
78
111
|
const ACTION_VISUAL_RULES: Array<{
|
|
79
112
|
re: RegExp
|
|
80
113
|
icon: LucideIcon
|
|
@@ -86,7 +119,7 @@ const ACTION_VISUAL_RULES: Array<{
|
|
|
86
119
|
{ re: /^(delete|remove)/, icon: Trash2, cls: "text-rose-500" },
|
|
87
120
|
{ re: /^(cancel|void|reject)/, icon: Ban, cls: "text-rose-500" },
|
|
88
121
|
{
|
|
89
|
-
re: /^(approve|confirm|verify|accept)/,
|
|
122
|
+
re: /^(approve|confirm|verify|accept|complete)/,
|
|
90
123
|
icon: BadgeCheck,
|
|
91
124
|
cls: "text-violet-500",
|
|
92
125
|
},
|
|
@@ -94,8 +127,12 @@ const ACTION_VISUAL_RULES: Array<{
|
|
|
94
127
|
{ re: /^import/, icon: Download, cls: "text-purple-500" },
|
|
95
128
|
{ re: /^export/, icon: Upload, cls: "text-indigo-500" },
|
|
96
129
|
{ re: /^print/, icon: Printer, cls: "text-cyan-600" },
|
|
97
|
-
{ re: /^sync/, icon: RefreshCw, cls: "text-cyan-600" },
|
|
98
|
-
{
|
|
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
|
+
},
|
|
99
136
|
{ re: /^auto/, icon: Bot, cls: "text-purple-500" },
|
|
100
137
|
]
|
|
101
138
|
|
|
@@ -105,6 +142,22 @@ const getActionVisual = (code: string) =>
|
|
|
105
142
|
cls: "text-slate-400",
|
|
106
143
|
}
|
|
107
144
|
|
|
145
|
+
/** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
|
|
146
|
+
export interface RoleTemplate {
|
|
147
|
+
code: string
|
|
148
|
+
label: string
|
|
149
|
+
description?: string
|
|
150
|
+
permissions: string[]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Metadata action nghiệp vụ: nhóm luồng + mô tả + quyền tiên quyết. */
|
|
154
|
+
export interface ActionMetaEntry {
|
|
155
|
+
label?: string
|
|
156
|
+
description?: string
|
|
157
|
+
flow?: string
|
|
158
|
+
requires?: string[]
|
|
159
|
+
}
|
|
160
|
+
|
|
108
161
|
export interface RoleFormPageProps {
|
|
109
162
|
dictionary: any
|
|
110
163
|
permissions?: CrudPermissions
|
|
@@ -136,6 +189,12 @@ export interface RoleFormPageProps {
|
|
|
136
189
|
permissions: string[]
|
|
137
190
|
}
|
|
138
191
|
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
|
+
roleTemplates?: RoleTemplate[]
|
|
194
|
+
/** Meta action nghiệp vụ theo code (flow/description/requires) — additive. */
|
|
195
|
+
actionMeta?: Record<string, ActionMetaEntry>
|
|
196
|
+
/** Đường dẫn trang theo resource code (hiện dưới tên) — additive. */
|
|
197
|
+
resourcePages?: Record<string, string>
|
|
139
198
|
}
|
|
140
199
|
|
|
141
200
|
export function RoleFormPage({
|
|
@@ -147,6 +206,9 @@ export function RoleFormPage({
|
|
|
147
206
|
mode,
|
|
148
207
|
initialData,
|
|
149
208
|
actionLabels,
|
|
209
|
+
roleTemplates,
|
|
210
|
+
actionMeta,
|
|
211
|
+
resourcePages,
|
|
150
212
|
}: RoleFormPageProps) {
|
|
151
213
|
const router = useRouter()
|
|
152
214
|
|
|
@@ -166,16 +228,18 @@ export function RoleFormPage({
|
|
|
166
228
|
permissions: initialData?.permissions || ([] as string[]),
|
|
167
229
|
})
|
|
168
230
|
|
|
169
|
-
const [activeGroup, setActiveGroup] = React.useState<string
|
|
170
|
-
const [
|
|
171
|
-
|
|
172
|
-
|
|
231
|
+
const [activeGroup, setActiveGroup] = React.useState<string>("all")
|
|
232
|
+
const [expandedRows, setExpandedRows] = React.useState<Set<string>>(
|
|
233
|
+
() => new Set()
|
|
234
|
+
)
|
|
173
235
|
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
+
})
|
|
179
243
|
}
|
|
180
244
|
|
|
181
245
|
const getLocalizedName = React.useCallback(
|
|
@@ -197,6 +261,18 @@ export function RoleFormPage({
|
|
|
197
261
|
[dictionary]
|
|
198
262
|
)
|
|
199
263
|
|
|
264
|
+
const actionName = React.useCallback(
|
|
265
|
+
(code: string) => {
|
|
266
|
+
return (
|
|
267
|
+
actionMeta?.[code]?.label ||
|
|
268
|
+
actions.find((a) => a.code === code)?.name ||
|
|
269
|
+
mergedActionLabels[code]?.label ||
|
|
270
|
+
code
|
|
271
|
+
)
|
|
272
|
+
},
|
|
273
|
+
[actions, actionMeta, mergedActionLabels]
|
|
274
|
+
)
|
|
275
|
+
|
|
200
276
|
const permissionMatrix = React.useMemo(() => {
|
|
201
277
|
return resources.map((resource) => {
|
|
202
278
|
let allowedActions: string[] = []
|
|
@@ -243,10 +319,18 @@ export function RoleFormPage({
|
|
|
243
319
|
group: getLocalizedName(resource.group) || "Khác",
|
|
244
320
|
description: resource.description,
|
|
245
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"),
|
|
246
328
|
}
|
|
247
329
|
})
|
|
248
330
|
}, [resources, actions, getLocalizedName])
|
|
249
331
|
|
|
332
|
+
type MatrixRow = (typeof permissionMatrix)[number]
|
|
333
|
+
|
|
250
334
|
const totalAvailablePermissions = React.useMemo(
|
|
251
335
|
() => permissionMatrix.reduce((sum, r) => sum + r.actions.length, 0),
|
|
252
336
|
[permissionMatrix]
|
|
@@ -265,7 +349,7 @@ export function RoleFormPage({
|
|
|
265
349
|
}, [searchTerm, permissionMatrix])
|
|
266
350
|
|
|
267
351
|
const groupedMatrix = React.useMemo(() => {
|
|
268
|
-
const groups: Record<string,
|
|
352
|
+
const groups: Record<string, MatrixRow[]> = {}
|
|
269
353
|
filteredMatrix.forEach((resource) => {
|
|
270
354
|
const group = resource.group
|
|
271
355
|
if (!groups[group]) {
|
|
@@ -276,99 +360,146 @@ export function RoleFormPage({
|
|
|
276
360
|
return groups
|
|
277
361
|
}, [filteredMatrix])
|
|
278
362
|
|
|
363
|
+
const allGroups = React.useMemo(() => {
|
|
364
|
+
const seen: string[] = []
|
|
365
|
+
permissionMatrix.forEach((r) => {
|
|
366
|
+
if (!seen.includes(r.group)) seen.push(r.group)
|
|
367
|
+
})
|
|
368
|
+
return seen
|
|
369
|
+
}, [permissionMatrix])
|
|
370
|
+
|
|
279
371
|
React.useEffect(() => {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (
|
|
283
|
-
activeGroup !== "all" &&
|
|
284
|
-
(!activeGroup || !groups.includes(activeGroup))
|
|
285
|
-
) {
|
|
286
|
-
setActiveGroup("all")
|
|
287
|
-
}
|
|
288
|
-
} else {
|
|
289
|
-
setActiveGroup(null)
|
|
372
|
+
if (activeGroup !== "all" && !allGroups.includes(activeGroup)) {
|
|
373
|
+
setActiveGroup("all")
|
|
290
374
|
}
|
|
291
|
-
}, [
|
|
292
|
-
|
|
293
|
-
const hasPermission = (resource: string, action: string): boolean => {
|
|
294
|
-
return formData.permissions.includes(`${action}:${resource}`)
|
|
295
|
-
}
|
|
375
|
+
}, [allGroups, activeGroup])
|
|
296
376
|
|
|
297
|
-
const
|
|
298
|
-
const permission = `${action}:${resource}`
|
|
299
|
-
let newPermissions = [...formData.permissions]
|
|
377
|
+
const searching = searchTerm.trim().length > 0
|
|
300
378
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
setFormData({ ...formData, permissions: newPermissions })
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
const groupPermissionCodes = React.useCallback(
|
|
310
|
-
(groupName: string) => {
|
|
311
|
-
const groupResources = groupedMatrix[groupName] || []
|
|
312
|
-
return groupResources.flatMap((r) =>
|
|
313
|
-
actions
|
|
314
|
-
.filter((a) => r.actions.includes(a.code))
|
|
315
|
-
.map((a) => `${a.code}:${r.resource}`)
|
|
316
|
-
)
|
|
317
|
-
},
|
|
318
|
-
[groupedMatrix, actions]
|
|
379
|
+
const has = React.useCallback(
|
|
380
|
+
(resource: string, action: string) =>
|
|
381
|
+
formData.permissions.includes(`${action}:${resource}`),
|
|
382
|
+
[formData.permissions]
|
|
319
383
|
)
|
|
320
384
|
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
385
|
+
const mutatePermissions = (
|
|
386
|
+
fn: (set: Set<string>) => void
|
|
387
|
+
) => {
|
|
388
|
+
setFormData((prev) => {
|
|
389
|
+
const set = new Set(prev.permissions)
|
|
390
|
+
fn(set)
|
|
391
|
+
return { ...prev, permissions: Array.from(set) }
|
|
392
|
+
})
|
|
393
|
+
}
|
|
324
394
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
)
|
|
395
|
+
// ── Mức truy cập ──────────────────────────────────────────────────────
|
|
396
|
+
const deriveLevel = (r: MatrixRow): AccessLevel => {
|
|
397
|
+
if (has(r.resource, "view")) {
|
|
398
|
+
const anyWrite = r.writeCodes.some((c) => has(r.resource, c))
|
|
399
|
+
if (!anyWrite && !(r.hasDelete && has(r.resource, "delete")))
|
|
400
|
+
return "view"
|
|
401
|
+
const fullWrite = r.writeCodes.every((c) => has(r.resource, c))
|
|
402
|
+
if (fullWrite && (!r.hasDelete || has(r.resource, "delete")))
|
|
403
|
+
return "full"
|
|
404
|
+
return "write"
|
|
332
405
|
}
|
|
406
|
+
if (r.hasLookup && has(r.resource, "lookup")) return "lookup"
|
|
407
|
+
return "hidden"
|
|
408
|
+
}
|
|
333
409
|
|
|
334
|
-
|
|
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)
|
|
433
|
+
}
|
|
434
|
+
if (level === "full" && r.hasDelete) add("delete")
|
|
435
|
+
})
|
|
335
436
|
}
|
|
336
437
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
438
|
+
// ── Thao tác nghiệp vụ + requires ─────────────────────────────────────
|
|
439
|
+
const toggleBusinessAction = (r: MatrixRow, code: string) => {
|
|
440
|
+
const key = `${code}:${r.resource}`
|
|
441
|
+
if (formData.permissions.includes(key)) {
|
|
442
|
+
mutatePermissions((set) => set.delete(key))
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
const requires = actionMeta?.[code]?.requires ?? []
|
|
446
|
+
const needed = ["view", ...requires].filter(
|
|
447
|
+
(req) =>
|
|
448
|
+
r.actions.includes(req) &&
|
|
449
|
+
!formData.permissions.includes(`${req}:${r.resource}`)
|
|
450
|
+
)
|
|
451
|
+
mutatePermissions((set) => {
|
|
452
|
+
set.add(key)
|
|
453
|
+
needed.forEach((req) => set.add(`${req}:${r.resource}`))
|
|
454
|
+
})
|
|
455
|
+
if (needed.length > 0) {
|
|
456
|
+
toast.info(
|
|
457
|
+
`Đã bật kèm quyền tiên quyết: ${needed
|
|
458
|
+
.map((c) => actionName(c))
|
|
459
|
+
.join(", ")}`
|
|
460
|
+
)
|
|
342
461
|
}
|
|
343
462
|
}
|
|
344
463
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
newPermissions = newPermissions.filter((p) => p !== permission)
|
|
358
|
-
}
|
|
359
|
-
})
|
|
464
|
+
// ── Đếm ───────────────────────────────────────────────────────────────
|
|
465
|
+
const groupPermissionCodes = React.useCallback(
|
|
466
|
+
(rows: MatrixRow[]) =>
|
|
467
|
+
rows.flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`)),
|
|
468
|
+
[]
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
const countSelected = React.useCallback(
|
|
472
|
+
(codes: string[]) =>
|
|
473
|
+
codes.filter((p) => formData.permissions.includes(p)).length,
|
|
474
|
+
[formData.permissions]
|
|
475
|
+
)
|
|
360
476
|
|
|
361
|
-
|
|
477
|
+
const toggleAllForGroup = (rows: MatrixRow[], enable: boolean) => {
|
|
478
|
+
const codes = groupPermissionCodes(rows)
|
|
479
|
+
mutatePermissions((set) => {
|
|
480
|
+
if (enable) codes.forEach((c) => set.add(c))
|
|
481
|
+
else codes.forEach((c) => set.delete(c))
|
|
482
|
+
})
|
|
362
483
|
}
|
|
363
484
|
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
+
const applyTemplate = (templateCode: string) => {
|
|
497
|
+
const tpl = roleTemplates?.find((t) => t.code === templateCode)
|
|
498
|
+
if (!tpl) return
|
|
499
|
+
setFormData((prev) => ({ ...prev, permissions: [...tpl.permissions] }))
|
|
500
|
+
toast.success(`Đã áp bộ quyền mẫu "${tpl.label}"`, {
|
|
501
|
+
description: `${tpl.permissions.length} quyền — chỉnh tiếp rồi bấm Lưu.`,
|
|
502
|
+
})
|
|
372
503
|
}
|
|
373
504
|
|
|
374
505
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
@@ -409,14 +540,12 @@ export function RoleFormPage({
|
|
|
409
540
|
const visibleGroups = Object.entries(groupedMatrix).filter(
|
|
410
541
|
([group, groupResources]) =>
|
|
411
542
|
groupResources.length > 0 &&
|
|
412
|
-
|
|
543
|
+
(searching || activeGroup === "all" || activeGroup === group)
|
|
413
544
|
)
|
|
414
545
|
|
|
415
546
|
return (
|
|
416
547
|
<div className="flex flex-col">
|
|
417
|
-
{/* ===== 1. Header bar navy 1 DÒNG
|
|
418
|
-
hàng (bg-sidebar). Mobile: chỉ ← Tên ⏻ ✕ Lưu; mã (chip) + mô tả
|
|
419
|
-
hiện từ md. Create mode: ô mã rớt xuống dòng riêng trên mobile. ===== */}
|
|
548
|
+
{/* ===== 1. Header bar navy 1 DÒNG ===== */}
|
|
420
549
|
<div className="-mx-4 -mt-4 flex flex-wrap items-center gap-1.5 bg-sidebar px-3 py-2 text-sidebar-foreground shadow-md md:-mx-8 lg:mx-3 lg:mt-1 lg:rounded-xl lg:px-4">
|
|
421
550
|
<Button
|
|
422
551
|
variant="ghost"
|
|
@@ -461,8 +590,6 @@ export function RoleFormPage({
|
|
|
461
590
|
className="order-last h-7 basis-full rounded-md border-white/20 bg-white/10 px-2 font-mono text-xs text-primary-foreground shadow-none placeholder:text-primary-foreground/40 focus-visible:ring-1 focus-visible:ring-white/40 sm:order-none sm:w-44 sm:basis-auto"
|
|
462
591
|
/>
|
|
463
592
|
)}
|
|
464
|
-
{/* Chip trạng thái màu (getStatusMeta.solidClass — cùng taxonomy với
|
|
465
|
-
header đơn bán hàng); bấm để đổi Hoạt động ↔ Khóa */}
|
|
466
593
|
{(() => {
|
|
467
594
|
const statusMeta = getStatusMeta(
|
|
468
595
|
formData.status,
|
|
@@ -526,44 +653,115 @@ export function RoleFormPage({
|
|
|
526
653
|
</div>
|
|
527
654
|
|
|
528
655
|
<div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
|
|
529
|
-
{/* ===== 2. Toolbar sticky ===== */}
|
|
530
|
-
<div className="sticky top-0 z-20
|
|
531
|
-
<div className="
|
|
532
|
-
<
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
656
|
+
{/* ===== 2. Toolbar sticky: search · bộ quyền mẫu · đếm · tab ===== */}
|
|
657
|
+
<div className="sticky top-0 z-20 border-b border-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/85">
|
|
658
|
+
<div className="flex flex-wrap items-center gap-2 px-3 py-2 sm:px-4">
|
|
659
|
+
<div className="relative min-w-[140px] flex-1 sm:max-w-64">
|
|
660
|
+
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
661
|
+
<Input
|
|
662
|
+
placeholder="Tìm kiếm tài nguyên..."
|
|
663
|
+
className="h-8 rounded-md border border-border bg-card pl-8 text-sm"
|
|
664
|
+
value={searchTerm}
|
|
665
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
666
|
+
/>
|
|
667
|
+
</div>
|
|
668
|
+
{mode === "create" && (roleTemplates?.length ?? 0) > 0 && (
|
|
669
|
+
<Select onValueChange={applyTemplate}>
|
|
670
|
+
<SelectTrigger className="h-8 w-44 rounded-md border border-border bg-card text-sm sm:w-52">
|
|
671
|
+
<span className="flex items-center gap-1.5 truncate">
|
|
672
|
+
<LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
673
|
+
<SelectValue placeholder="Bộ quyền mẫu..." />
|
|
674
|
+
</span>
|
|
675
|
+
</SelectTrigger>
|
|
676
|
+
<SelectContent className="rounded-lg">
|
|
677
|
+
{roleTemplates!.map((tpl) => (
|
|
678
|
+
<SelectItem key={tpl.code} value={tpl.code}>
|
|
679
|
+
<span className="flex flex-col">
|
|
680
|
+
<span>{tpl.label}</span>
|
|
681
|
+
{tpl.description && (
|
|
682
|
+
<span className="text-[11px] text-muted-foreground">
|
|
683
|
+
{tpl.description}
|
|
684
|
+
</span>
|
|
685
|
+
)}
|
|
686
|
+
</span>
|
|
687
|
+
</SelectItem>
|
|
688
|
+
))}
|
|
689
|
+
</SelectContent>
|
|
690
|
+
</Select>
|
|
691
|
+
)}
|
|
692
|
+
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
|
|
693
|
+
<span className="font-semibold tabular-nums text-foreground">
|
|
694
|
+
{formData.permissions.length}
|
|
695
|
+
</span>
|
|
696
|
+
<span className="tabular-nums">/{totalAvailablePermissions}</span>{" "}
|
|
697
|
+
quyền
|
|
698
|
+
</p>
|
|
539
699
|
</div>
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
700
|
+
|
|
701
|
+
{/* Thanh TAB phân hệ */}
|
|
702
|
+
<div className="flex items-center gap-1 overflow-x-auto px-3 pb-2 sm:px-4 [scrollbar-width:thin]">
|
|
703
|
+
<button
|
|
704
|
+
type="button"
|
|
705
|
+
onClick={() => setActiveGroup("all")}
|
|
706
|
+
className={cn(
|
|
707
|
+
"shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
|
708
|
+
activeGroup === "all" && !searching
|
|
709
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
710
|
+
: "border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
|
711
|
+
)}
|
|
712
|
+
>
|
|
713
|
+
Tất cả
|
|
714
|
+
</button>
|
|
715
|
+
{allGroups.map((group) => {
|
|
716
|
+
const { selected, total } = groupTabCount(group)
|
|
717
|
+
const active = activeGroup === group && !searching
|
|
718
|
+
return (
|
|
719
|
+
<button
|
|
720
|
+
key={group}
|
|
721
|
+
type="button"
|
|
722
|
+
onClick={() => {
|
|
723
|
+
setActiveGroup(group)
|
|
724
|
+
setSearchTerm("")
|
|
725
|
+
}}
|
|
726
|
+
className={cn(
|
|
727
|
+
"flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
|
728
|
+
active
|
|
729
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
730
|
+
: "border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
|
731
|
+
)}
|
|
732
|
+
>
|
|
551
733
|
{group}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
734
|
+
<span
|
|
735
|
+
className={cn(
|
|
736
|
+
"rounded-full px-1 text-[10px] tabular-nums",
|
|
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>
|
|
564
762
|
</div>
|
|
565
763
|
|
|
566
|
-
{/* ===== 3.
|
|
764
|
+
{/* ===== 3. Dòng tài nguyên: mức truy cập + thao tác nghiệp vụ ===== */}
|
|
567
765
|
{visibleGroups.length === 0 ? (
|
|
568
766
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
569
767
|
<Search className="mb-3 h-6 w-6 text-muted-foreground" />
|
|
@@ -573,30 +771,14 @@ export function RoleFormPage({
|
|
|
573
771
|
</div>
|
|
574
772
|
) : (
|
|
575
773
|
visibleGroups.map(([group, groupResources]) => {
|
|
576
|
-
const
|
|
577
|
-
const
|
|
774
|
+
const codes = groupPermissionCodes(groupResources)
|
|
775
|
+
const selected = countSelected(codes)
|
|
776
|
+
const total = codes.length
|
|
578
777
|
const isFull = total > 0 && selected === total
|
|
579
778
|
|
|
580
779
|
return (
|
|
581
780
|
<section key={group}>
|
|
582
|
-
|
|
583
|
-
<div
|
|
584
|
-
role="button"
|
|
585
|
-
tabIndex={0}
|
|
586
|
-
onClick={() => toggleGroupCollapse(group)}
|
|
587
|
-
onKeyDown={(e) => {
|
|
588
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
589
|
-
e.preventDefault()
|
|
590
|
-
toggleGroupCollapse(group)
|
|
591
|
-
}
|
|
592
|
-
}}
|
|
593
|
-
className="flex cursor-pointer select-none items-center gap-2 border-b border-border bg-card px-4 py-2 transition-colors hover:bg-muted/40"
|
|
594
|
-
>
|
|
595
|
-
{collapsed ? (
|
|
596
|
-
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
597
|
-
) : (
|
|
598
|
-
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
599
|
-
)}
|
|
781
|
+
<div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
|
|
600
782
|
<h3 className="truncate text-xs font-semibold uppercase tracking-wide text-foreground">
|
|
601
783
|
{group}
|
|
602
784
|
</h3>
|
|
@@ -611,15 +793,12 @@ export function RoleFormPage({
|
|
|
611
793
|
{groupResources.length} tài nguyên · {selected}/{total}
|
|
612
794
|
{isFull && <Check className="ml-0.5 inline h-3 w-3" />}
|
|
613
795
|
</span>
|
|
614
|
-
<div
|
|
615
|
-
className="ml-auto flex shrink-0 items-center gap-1"
|
|
616
|
-
onClick={(e) => e.stopPropagation()}
|
|
617
|
-
>
|
|
796
|
+
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
618
797
|
<Button
|
|
619
798
|
type="button"
|
|
620
799
|
variant="ghost"
|
|
621
800
|
size="sm"
|
|
622
|
-
onClick={() => toggleAllForGroup(
|
|
801
|
+
onClick={() => toggleAllForGroup(groupResources, false)}
|
|
623
802
|
disabled={selected === 0}
|
|
624
803
|
className="h-6 px-2 text-xs text-muted-foreground hover:text-destructive"
|
|
625
804
|
>
|
|
@@ -629,7 +808,7 @@ export function RoleFormPage({
|
|
|
629
808
|
type="button"
|
|
630
809
|
variant="ghost"
|
|
631
810
|
size="sm"
|
|
632
|
-
onClick={() => toggleAllForGroup(
|
|
811
|
+
onClick={() => toggleAllForGroup(groupResources, true)}
|
|
633
812
|
disabled={isFull}
|
|
634
813
|
className="h-6 px-2 text-xs font-semibold text-primary hover:text-primary"
|
|
635
814
|
>
|
|
@@ -638,106 +817,178 @@ export function RoleFormPage({
|
|
|
638
817
|
</div>
|
|
639
818
|
</div>
|
|
640
819
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
toggleAllForResource(
|
|
668
|
-
resource.resource,
|
|
669
|
-
resource.actions,
|
|
670
|
-
checked
|
|
671
|
-
)
|
|
672
|
-
}
|
|
673
|
-
className="origin-right scale-75"
|
|
820
|
+
<ul className="divide-y divide-border/60 border-b border-border">
|
|
821
|
+
{groupResources.map((r) => {
|
|
822
|
+
const level = deriveLevel(r)
|
|
823
|
+
const bizOn = r.businessActions.filter((a) =>
|
|
824
|
+
has(r.resource, a)
|
|
825
|
+
).length
|
|
826
|
+
const expanded = expandedRows.has(r.resource)
|
|
827
|
+
const page = resourcePages?.[r.resource]
|
|
828
|
+
|
|
829
|
+
// Nhóm thao tác theo LUỒNG
|
|
830
|
+
const flows = new Map<string, string[]>()
|
|
831
|
+
r.businessActions.forEach((code) => {
|
|
832
|
+
const flow = actionMeta?.[code]?.flow || "Khác"
|
|
833
|
+
const list = flows.get(flow) ?? []
|
|
834
|
+
list.push(code)
|
|
835
|
+
flows.set(flow, list)
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
return (
|
|
839
|
+
<li key={r.resource} className="px-4 py-2.5">
|
|
840
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
|
841
|
+
{/* Tên + trang */}
|
|
842
|
+
<div className="flex min-w-0 flex-1 basis-52 items-center gap-2">
|
|
843
|
+
<DynamicIcon
|
|
844
|
+
name={r.icon as any}
|
|
845
|
+
className="h-4 w-4 shrink-0 text-muted-foreground"
|
|
674
846
|
/>
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
}
|
|
704
|
-
className={cn(
|
|
705
|
-
"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-colors",
|
|
706
|
-
isChecked
|
|
707
|
-
? "border-primary/40 bg-primary/10 text-primary"
|
|
708
|
-
: "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground"
|
|
709
|
-
)}
|
|
710
|
-
>
|
|
711
|
-
{/* Ô tick tường minh — nhìn là biết đã cấp hay chưa */}
|
|
712
|
-
<span
|
|
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}
|
|
713
875
|
className={cn(
|
|
714
|
-
"
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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"
|
|
718
885
|
)}
|
|
719
886
|
>
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
+
)}
|
|
736
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
|
+
)}
|
|
737
988
|
</li>
|
|
738
|
-
)
|
|
739
|
-
|
|
740
|
-
|
|
989
|
+
)
|
|
990
|
+
})}
|
|
991
|
+
</ul>
|
|
741
992
|
</section>
|
|
742
993
|
)
|
|
743
994
|
})
|