@goplusvn/core 0.1.41 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.41",
4
+ "version": "0.1.42",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -37,6 +37,7 @@ import { toast } from "sonner"
37
37
  import {
38
38
  ArrowLeft,
39
39
  Building2,
40
+ History,
40
41
  Search,
41
42
  ChevronDown,
42
43
  ChevronRight,
@@ -142,6 +143,8 @@ export interface RoleFormPageProps {
142
143
  resourcePages?: Record<string, string>
143
144
  /** Cây menu thật — không truyền thì tự dựng từ group của resources. */
144
145
  menuTree?: MenuTreeSection[]
146
+ /** Endpoint nhật ký đổi quyền (edit mode) — vd /api/roles/{id}/history. */
147
+ historyEndpoint?: string
145
148
  }
146
149
 
147
150
  export function RoleFormPage({
@@ -157,6 +160,7 @@ export function RoleFormPage({
157
160
  actionMeta,
158
161
  resourcePages,
159
162
  menuTree,
163
+ historyEndpoint,
160
164
  }: RoleFormPageProps) {
161
165
  const router = useRouter()
162
166
 
@@ -168,6 +172,16 @@ export function RoleFormPage({
168
172
  const [isSubmitting, setIsSubmitting] = React.useState(false)
169
173
  const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set())
170
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])
171
185
 
172
186
  const [formData, setFormData] = React.useState({
173
187
  name: initialData?.name || "",
@@ -525,6 +539,15 @@ export function RoleFormPage({
525
539
  })
526
540
  }, [offMenuResources, searching, searchTerm, resourceInfo, pageActions, actionName])
527
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
+
528
551
  // Render 1 dòng trang menu + hàng thao tác của nó
529
552
  const renderPageRow = (item: {
530
553
  title: string
@@ -754,7 +777,8 @@ export function RoleFormPage({
754
777
  </Button>
755
778
  </div>
756
779
 
757
- <div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
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">
758
782
  {/* ===== 2. Toolbar: mẫu + phạm vi chi nhánh + đếm ===== */}
759
783
  <div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2 sm:px-4">
760
784
  <div className="relative min-w-[150px] flex-1 sm:max-w-64">
@@ -929,6 +953,172 @@ export function RoleFormPage({
929
953
  </section>
930
954
  )}
931
955
  </div>
956
+
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"
995
+ >
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>
1001
+ </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>
1017
+
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"
1090
+ >
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>
1111
+ )}
1112
+ </div>
1113
+ )}
1114
+ </li>
1115
+ ))}
1116
+ </ul>
1117
+ )}
1118
+ </div>
1119
+ )}
1120
+ </div>
1121
+ )}
932
1122
  </div>
933
1123
  )
934
1124
  }
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
 
3
+ import * as React from "react";
3
4
  import { useMemo, useCallback, useState } from "react";
5
+ import { useRouter } from "next/navigation";
4
6
  import type { ReactNode } from "react";
5
7
  import { Loader2, UserX, KeyRound } from "lucide-react";
6
8
  import {
@@ -27,6 +29,7 @@ import {
27
29
  } from "../../ui";
28
30
 
29
31
  import type { CrudPermissions } from "../../types";
32
+ import { cn } from "../../utils";
30
33
 
31
34
  import { RoleStatsCards } from "../components/roles/role-stats-cards";
32
35
  import { RoleToolbar } from "../components/roles/role-toolbar";
@@ -65,6 +68,140 @@ export interface RoleListPageProps {
65
68
  importSlot?: ReactNode;
66
69
  }
67
70
 
71
+
72
+ // ── 🗺️ Heatmap role × phân hệ ────────────────────────────────────────────
73
+ // Mỗi ô = tỉ lệ quyền đã cấp của role trong phân hệ (đậm dần theo %).
74
+ function parseAllowed(resourceConfig: any, actionCodes: string[]): string[] {
75
+ try {
76
+ const config =
77
+ typeof resourceConfig === "string"
78
+ ? JSON.parse(resourceConfig)
79
+ : resourceConfig
80
+ if (config?.actions && Array.isArray(config.actions)) {
81
+ return actionCodes.filter((c) => (config.actions as string[]).includes(c))
82
+ }
83
+ } catch {
84
+ /* fallthrough */
85
+ }
86
+ return actionCodes.filter((c) =>
87
+ ["view", "create", "update", "delete"].includes(c)
88
+ )
89
+ }
90
+
91
+ function RolePermissionHeatmap({
92
+ roles,
93
+ resources,
94
+ actions,
95
+ lang,
96
+ canUpdate,
97
+ }: {
98
+ roles: Role[]
99
+ resources: RoleListPageProps["resources"]
100
+ actions: RoleListPageProps["actions"]
101
+ lang: string
102
+ canUpdate?: boolean
103
+ }) {
104
+ const router = useRouter()
105
+ const actionCodes = React.useMemo(() => actions.map((a) => a.code), [actions])
106
+
107
+ // group → danh sách "action:resource" khả dụng
108
+ const groupCodes = React.useMemo(() => {
109
+ const map = new Map<string, string[]>()
110
+ resources.forEach((r) => {
111
+ const group = r.group || "Khác"
112
+ const allowed = parseAllowed(r.config, actionCodes)
113
+ const list = map.get(group) ?? []
114
+ allowed.forEach((a) => list.push(`${a}:${r.code}`))
115
+ map.set(group, list)
116
+ })
117
+ return map
118
+ }, [resources, actionCodes])
119
+
120
+ const groups = React.useMemo(() => [...groupCodes.keys()], [groupCodes])
121
+ if (roles.length === 0 || groups.length === 0) return null
122
+
123
+ const cellFor = (role: Role, group: string) => {
124
+ const codes = groupCodes.get(group) ?? []
125
+ const granted = codes.filter((c) => role.permissions?.includes(c)).length
126
+ return { granted, total: codes.length }
127
+ }
128
+
129
+ const shade = (ratio: number) =>
130
+ ratio === 0
131
+ ? "bg-muted/60"
132
+ : ratio < 0.34
133
+ ? "bg-primary/20"
134
+ : ratio < 0.67
135
+ ? "bg-primary/45"
136
+ : ratio < 1
137
+ ? "bg-primary/70"
138
+ : "bg-primary"
139
+
140
+ return (
141
+ <div className="overflow-hidden rounded-xl border border-border bg-card shadow-sm">
142
+ <div className="flex items-center gap-2 border-b border-border px-4 py-2">
143
+ <h3 className="text-sm font-semibold text-foreground">
144
+ Bản đồ quyền theo phân hệ
145
+ </h3>
146
+ <span className="text-[11px] text-muted-foreground">
147
+ ô càng đậm = càng nhiều quyền · bấm ô để mở vai trò
148
+ </span>
149
+ </div>
150
+ <div className="overflow-x-auto">
151
+ <table className="w-full border-collapse text-xs">
152
+ <thead>
153
+ <tr>
154
+ <th className="sticky left-0 z-10 bg-card px-4 py-1.5 text-left font-medium text-muted-foreground">
155
+ Vai trò
156
+ </th>
157
+ {groups.map((g) => (
158
+ <th
159
+ key={g}
160
+ className="min-w-16 px-1 py-1.5 text-center font-medium text-muted-foreground"
161
+ title={g}
162
+ >
163
+ <span className="block max-w-20 truncate">{g}</span>
164
+ </th>
165
+ ))}
166
+ </tr>
167
+ </thead>
168
+ <tbody className="divide-y divide-border/50">
169
+ {roles.map((role) => (
170
+ <tr key={role.id} className="hover:bg-muted/30">
171
+ <td className="sticky left-0 z-10 max-w-44 truncate bg-card px-4 py-1 font-medium text-foreground">
172
+ {role.name}
173
+ </td>
174
+ {groups.map((g) => {
175
+ const { granted, total } = cellFor(role, g)
176
+ const ratio = total > 0 ? granted / total : 0
177
+ return (
178
+ <td key={g} className="px-1 py-1 text-center">
179
+ <button
180
+ type="button"
181
+ disabled={!canUpdate}
182
+ onClick={() =>
183
+ router.push(`/${lang}/roles/${role.id}/edit`)
184
+ }
185
+ title={`${role.name} · ${g}: ${granted}/${total} quyền`}
186
+ className={cn(
187
+ "mx-auto block h-5 w-10 rounded",
188
+ shade(ratio),
189
+ canUpdate && "cursor-pointer hover:ring-2 hover:ring-primary/40"
190
+ )}
191
+ aria-label={`${role.name} — ${g}: ${granted}/${total} quyền`}
192
+ />
193
+ </td>
194
+ )
195
+ })}
196
+ </tr>
197
+ ))}
198
+ </tbody>
199
+ </table>
200
+ </div>
201
+ </div>
202
+ )
203
+ }
204
+
68
205
  export function RoleListPage({
69
206
  dictionary,
70
207
  permissions,
@@ -233,6 +370,14 @@ export function RoleListPage({
233
370
  importSlot={importSlot}
234
371
  />
235
372
 
373
+ <RolePermissionHeatmap
374
+ roles={roles}
375
+ resources={resources}
376
+ actions={actions}
377
+ lang={lang}
378
+ canUpdate={canUpdate}
379
+ />
380
+
236
381
  {/* Roles Grid */}
237
382
  <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
238
383
  {roles.length === 0 && !isLoading ? (
@@ -0,0 +1,235 @@
1
+ "use client";
2
+
3
+ // 👤 QUYỀN HIỆU LỰC — user mang nhiều vai trò thì rốt cuộc thấy gì?
4
+ // Hợp nhất (union) quyền từ mọi vai trò của user rồi trả lời trực quan:
5
+ // - Sidebar preview: menu nào hiện với người này (từ menuTree).
6
+ // - Phạm vi dữ liệu: chi nhánh được gán hay tất cả (view-all-branches).
7
+ // - Quyền nhạy cảm đang có (actionMeta.flow "Nhạy cảm"/"Phê duyệt").
8
+ // Nguồn: GET /api/roles?pageSize=100 (permissions từng role) — match theo
9
+ // tên vai trò gắn trên user.
10
+ import * as React from "react"
11
+ import {
12
+ Badge,
13
+ Dialog,
14
+ DialogContent,
15
+ DialogHeader,
16
+ DialogTitle,
17
+ DynamicIcon,
18
+ } from "../../ui"
19
+ import { Building2, KeyRound, ShieldAlert } from "lucide-react"
20
+
21
+ export interface EffectiveMenuTreeSection {
22
+ title: string
23
+ icon?: string
24
+ items: Array<{
25
+ title: string
26
+ href?: string
27
+ icon?: string
28
+ resource: string
29
+ note?: string
30
+ }>
31
+ }
32
+
33
+ export interface EffectivePermissionsDialogProps {
34
+ open: boolean
35
+ onOpenChange: (open: boolean) => void
36
+ user: {
37
+ id?: string
38
+ name?: string | null
39
+ email?: string | null
40
+ /** Tên các vai trò gắn trên user (như chip hiển thị ở card). */
41
+ roles?: string[]
42
+ roleNames?: string[]
43
+ } | null
44
+ menuTree?: EffectiveMenuTreeSection[]
45
+ actionMeta?: Record<
46
+ string,
47
+ { label?: string; flow?: string; description?: string }
48
+ >
49
+ }
50
+
51
+ interface RoleLite {
52
+ id: string
53
+ name: string
54
+ code: string
55
+ permissions: string[]
56
+ }
57
+
58
+ export function EffectivePermissionsDialog({
59
+ open,
60
+ onOpenChange,
61
+ user,
62
+ menuTree,
63
+ actionMeta,
64
+ }: EffectivePermissionsDialogProps) {
65
+ const [roles, setRoles] = React.useState<RoleLite[] | null>(null)
66
+ const [loading, setLoading] = React.useState(false)
67
+
68
+ React.useEffect(() => {
69
+ if (!open || roles) return
70
+ setLoading(true)
71
+ fetch("/api/roles?pageSize=200")
72
+ .then((r) => r.json())
73
+ .then((data) => setRoles(data.items ?? []))
74
+ .catch(() => setRoles([]))
75
+ .finally(() => setLoading(false))
76
+ }, [open, roles])
77
+
78
+ const roleNameList = user?.roles ?? user?.roleNames ?? []
79
+ const userRoleNames = React.useMemo(
80
+ () => new Set(roleNameList.map((r) => r.toLowerCase())),
81
+ // eslint-disable-next-line react-hooks/exhaustive-deps
82
+ [user]
83
+ )
84
+
85
+ const matchedRoles = React.useMemo(
86
+ () =>
87
+ (roles ?? []).filter(
88
+ (r) =>
89
+ userRoleNames.has(r.name.toLowerCase()) ||
90
+ userRoleNames.has(r.code.toLowerCase())
91
+ ),
92
+ [roles, userRoleNames]
93
+ )
94
+
95
+ /** Union mọi quyền từ các vai trò. */
96
+ const effective = React.useMemo(() => {
97
+ const set = new Set<string>()
98
+ matchedRoles.forEach((r) => r.permissions?.forEach((p) => set.add(p)))
99
+ return set
100
+ }, [matchedRoles])
101
+
102
+ const has = React.useCallback(
103
+ (resource: string, action: string) => effective.has(`${action}:${resource}`),
104
+ [effective]
105
+ )
106
+
107
+ const isSuperAdmin = userRoleNames.has("super_admin")
108
+
109
+ const visibleSections = React.useMemo(() => {
110
+ if (!menuTree) return []
111
+ return menuTree
112
+ .map((section) => ({
113
+ ...section,
114
+ items: section.items.filter(
115
+ (i) => !i.note && (isSuperAdmin || has(i.resource, "view"))
116
+ ),
117
+ }))
118
+ .filter((s) => s.items.length > 0)
119
+ }, [menuTree, has, isSuperAdmin])
120
+
121
+ /** Quyền thuộc luồng nhạy cảm / phê duyệt đang bật. */
122
+ const sensitiveGrants = React.useMemo(() => {
123
+ if (!actionMeta) return []
124
+ const list: Array<{ action: string; resource: string; label: string }> = []
125
+ effective.forEach((perm) => {
126
+ const [action, resource] = perm.split(":")
127
+ const meta = actionMeta[action]
128
+ if (meta?.flow === "Nhạy cảm" || meta?.flow === "Phê duyệt") {
129
+ list.push({ action, resource, label: meta.label || action })
130
+ }
131
+ })
132
+ return list
133
+ }, [effective, actionMeta])
134
+
135
+ const branchAll =
136
+ isSuperAdmin ||
137
+ [...effective].some((p) => p.startsWith("view-all-branches:"))
138
+
139
+ return (
140
+ <Dialog open={open} onOpenChange={onOpenChange}>
141
+ <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
142
+ <DialogHeader>
143
+ <DialogTitle className="flex items-center gap-2 text-base">
144
+ <KeyRound className="h-4 w-4 text-primary" />
145
+ Quyền hiệu lực — {user?.name || user?.email || ""}
146
+ </DialogTitle>
147
+ </DialogHeader>
148
+
149
+ {/* Vai trò nguồn */}
150
+ <div className="flex flex-wrap items-center gap-1.5">
151
+ <span className="text-xs text-muted-foreground">Từ vai trò:</span>
152
+ {roleNameList.map((r) => (
153
+ <Badge key={r} variant="outline" className="text-[11px]">
154
+ {r}
155
+ </Badge>
156
+ ))}
157
+ {isSuperAdmin && (
158
+ <span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">
159
+ — Super Admin: toàn quyền hệ thống
160
+ </span>
161
+ )}
162
+ </div>
163
+
164
+ {/* Phạm vi dữ liệu */}
165
+ <div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2 text-xs">
166
+ <Building2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
167
+ <span>
168
+ Phạm vi dữ liệu:{" "}
169
+ <b>{branchAll ? "tất cả chi nhánh" : "chi nhánh được gán"}</b>
170
+ </span>
171
+ </div>
172
+
173
+ {/* Quyền nhạy cảm */}
174
+ {sensitiveGrants.length > 0 && (
175
+ <div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 dark:border-amber-900 dark:bg-amber-950/40">
176
+ <p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-amber-700 dark:text-amber-400">
177
+ <ShieldAlert className="h-3.5 w-3.5" />
178
+ Quyền nhạy cảm đang có
179
+ </p>
180
+ <div className="flex flex-wrap gap-1">
181
+ {sensitiveGrants.map((g) => (
182
+ <span
183
+ key={`${g.action}:${g.resource}`}
184
+ className="rounded bg-amber-100 px-1.5 py-0.5 text-[11px] text-amber-800 dark:bg-amber-900/60 dark:text-amber-300"
185
+ >
186
+ {g.label}
187
+ </span>
188
+ ))}
189
+ </div>
190
+ </div>
191
+ )}
192
+
193
+ {/* Sidebar preview */}
194
+ <div className="overflow-hidden rounded-lg border border-border">
195
+ <div className="border-b border-white/10 bg-sidebar px-3 py-1.5">
196
+ <p className="text-[11px] font-semibold text-primary-foreground">
197
+ Menu người này nhìn thấy
198
+ </p>
199
+ </div>
200
+ <div className="max-h-72 overflow-y-auto bg-sidebar px-2 py-2 [scrollbar-width:thin]">
201
+ {loading ? (
202
+ <p className="px-2 py-4 text-center text-[11px] text-primary-foreground/50">
203
+ Đang tải...
204
+ </p>
205
+ ) : visibleSections.length === 0 ? (
206
+ <p className="px-2 py-4 text-center text-[11px] text-primary-foreground/50">
207
+ Không thấy menu nào.
208
+ </p>
209
+ ) : (
210
+ visibleSections.map((sec) => (
211
+ <div key={sec.title} className="mb-1.5">
212
+ <p className="px-2 py-1 text-[9px] font-semibold uppercase tracking-wider text-primary-foreground/40">
213
+ {sec.title}
214
+ </p>
215
+ {sec.items.map((i) => (
216
+ <div
217
+ key={`${i.resource}:${i.title}`}
218
+ className="flex items-center gap-2 rounded-md px-2 py-1 text-primary-foreground/85"
219
+ >
220
+ <DynamicIcon
221
+ name={i.icon as any}
222
+ className="h-3.5 w-3.5 shrink-0 opacity-70"
223
+ />
224
+ <span className="truncate text-[11px]">{i.title}</span>
225
+ </div>
226
+ ))}
227
+ </div>
228
+ ))
229
+ )}
230
+ </div>
231
+ </div>
232
+ </DialogContent>
233
+ </Dialog>
234
+ )
235
+ }
@@ -15,11 +15,13 @@ import {
15
15
  AvatarImage,
16
16
  } from "../../ui";
17
17
  import { cn } from "../../utils";
18
- import { Mail, Phone, MoreHorizontal, Eye, Edit, User, Briefcase, Store, ShieldCheck, Hash, Building2 } from "lucide-react";
18
+ import { Mail, Phone, MoreHorizontal, Eye, Edit, User, Briefcase, Store, ShieldCheck, Hash, Building2 , KeyRound} from "lucide-react";
19
19
 
20
20
  interface UsersCardViewProps {
21
21
  data: any[];
22
22
  onSelect?: (user: any) => void;
23
+ /** Mở dialog "Quyền hiệu lực" cho user (additive). */
24
+ onViewPermissions?: (user: any) => void;
23
25
  }
24
26
 
25
27
  // Professional Corporate Palette
@@ -53,7 +55,11 @@ const getAvatarColor = (name: string | null) => {
53
55
  return AVATAR_PALETTE[charCode % AVATAR_PALETTE.length];
54
56
  };
55
57
 
56
- export function UsersCardView({ data, onSelect }: UsersCardViewProps) {
58
+ export function UsersCardView({
59
+ data,
60
+ onSelect,
61
+ onViewPermissions,
62
+ }: UsersCardViewProps) {
57
63
  if (!data.length) {
58
64
  return (
59
65
  <div className="flex flex-col items-center justify-center min-h-[400px] border border-[#e2e8f0] rounded-xl bg-white p-8 text-center">
@@ -192,6 +198,18 @@ export function UsersCardView({ data, onSelect }: UsersCardViewProps) {
192
198
  <Edit className="mr-2 size-3.5" />
193
199
  Chỉnh sửa
194
200
  </DropdownMenuItem>
201
+ {onViewPermissions && (
202
+ <DropdownMenuItem
203
+ onSelect={(e) => {
204
+ e.preventDefault();
205
+ setTimeout(() => onViewPermissions(user), 0);
206
+ }}
207
+ className="rounded-lg text-[13px] cursor-pointer"
208
+ >
209
+ <KeyRound className="size-3.5 mr-2 text-muted-foreground" />
210
+ Quyền hiệu lực
211
+ </DropdownMenuItem>
212
+ )}
195
213
  </DropdownMenuContent>
196
214
  </DropdownMenu>
197
215
  </div>
@@ -21,6 +21,10 @@ import { UserToolbar } from "../components/user-toolbar";
21
21
  import { UserStats } from "../components/user-stats";
22
22
  import { UsersCardView } from "../components/users-card-view";
23
23
  import { UnifiedProfileDialog } from "../components/unified-profile-dialog";
24
+ import {
25
+ EffectivePermissionsDialog,
26
+ type EffectiveMenuTreeSection,
27
+ } from "../components/effective-permissions-dialog";
24
28
 
25
29
  interface UserStats {
26
30
  totalUsers: number;
@@ -39,6 +43,9 @@ interface UsersClientPageProps {
39
43
  stats: UserStats;
40
44
  onAssignRoles?: (userId: string, roleCodes: string[]) => Promise<any>;
41
45
  onResetPassword?: (userId: string, password: string) => Promise<any>;
46
+ /** Cây menu + meta action cho dialog "Quyền hiệu lực" (additive). */
47
+ menuTree?: EffectiveMenuTreeSection[];
48
+ actionMeta?: Record<string, { label?: string; flow?: string; description?: string }>;
42
49
  }
43
50
 
44
51
  export function UsersClientPage({
@@ -50,7 +57,10 @@ export function UsersClientPage({
50
57
  stats,
51
58
  onAssignRoles,
52
59
  onResetPassword,
60
+ menuTree,
61
+ actionMeta,
53
62
  }: UsersClientPageProps) {
63
+ const [permUser, setPermUser] = useState<any | null>(null);
54
64
  const router = useRouter();
55
65
  const pathname = usePathname();
56
66
 
@@ -207,10 +217,24 @@ export function UsersClientPage({
207
217
  />
208
218
  </div>
209
219
  ) : (
210
- <UsersCardView data={initialData} onSelect={handleSelectUser} />
220
+ <UsersCardView
221
+ data={initialData}
222
+ onSelect={handleSelectUser}
223
+ onViewPermissions={menuTree ? setPermUser : undefined}
224
+ />
211
225
  )}
212
226
  </div>
213
227
 
228
+ {menuTree && (
229
+ <EffectivePermissionsDialog
230
+ open={!!permUser}
231
+ onOpenChange={(o) => !o && setPermUser(null)}
232
+ user={permUser}
233
+ menuTree={menuTree}
234
+ actionMeta={actionMeta}
235
+ />
236
+ )}
237
+
214
238
  {/* Create User Dialog */}
215
239
  <UnifiedProfileDialog
216
240
  open={userFormOpen}