@goplusvn/core 0.1.42 → 0.1.43
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
package/src/rbac/pages/index.ts
CHANGED
|
@@ -11,3 +11,12 @@ export type {
|
|
|
11
11
|
export { ResourceListPage } from "./resource-list-page";
|
|
12
12
|
export { ActionListPage } from "./action-list-page";
|
|
13
13
|
export type { ActionListPageProps } from "./action-list-page";
|
|
14
|
+
export {
|
|
15
|
+
ResourceCatalogPage,
|
|
16
|
+
ActionCatalogPage,
|
|
17
|
+
} from "./permission-catalog-pages";
|
|
18
|
+
export type {
|
|
19
|
+
ResourceCatalogPageProps,
|
|
20
|
+
ActionCatalogPageProps,
|
|
21
|
+
CatalogMenuSection,
|
|
22
|
+
} from "./permission-catalog-pages";
|
|
@@ -0,0 +1,1217 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// 📖 DANH MỤC QUYỀN — trang Tài nguyên & Hành động THỐNG NHẤT ngôn ngữ với
|
|
4
|
+
// trang chi tiết Vai trò (chốt với chủ app 2026-07-24):
|
|
5
|
+
// - ResourceCatalogPage: CÂY MENU thật (menuTree) — mỗi trang liệt kê thao
|
|
6
|
+
// tác theo LUỒNG (chip + tooltip mô tả), chính sách combo, đường dẫn/note.
|
|
7
|
+
// - ActionCatalogPage: danh mục thao tác nhóm theo LUỒNG — mỗi action:
|
|
8
|
+
// nhãn, mã, mô tả, dùng ở những trang nào.
|
|
9
|
+
// CHỈ TRA CỨU: khai báo/chỉnh sửa nằm trong Permission Registry (code) +
|
|
10
|
+
// rbac-sync — banner nhắc rõ. Không có nút ghi nào.
|
|
11
|
+
import * as React from "react"
|
|
12
|
+
import { useRouter } from "next/navigation"
|
|
13
|
+
import { toast } from "sonner"
|
|
14
|
+
import {
|
|
15
|
+
Button,
|
|
16
|
+
Dialog,
|
|
17
|
+
DialogContent,
|
|
18
|
+
DialogFooter,
|
|
19
|
+
DialogHeader,
|
|
20
|
+
DialogTitle,
|
|
21
|
+
DynamicIcon,
|
|
22
|
+
Input,
|
|
23
|
+
Label,
|
|
24
|
+
Textarea,
|
|
25
|
+
} from "../../ui"
|
|
26
|
+
import {
|
|
27
|
+
BookOpen,
|
|
28
|
+
Check,
|
|
29
|
+
ShieldAlert,
|
|
30
|
+
ChevronDown,
|
|
31
|
+
ChevronRight,
|
|
32
|
+
Info,
|
|
33
|
+
Search,
|
|
34
|
+
Shield,
|
|
35
|
+
Zap,
|
|
36
|
+
} from "lucide-react"
|
|
37
|
+
|
|
38
|
+
import { cn } from "../../utils"
|
|
39
|
+
|
|
40
|
+
export interface CatalogMenuSection {
|
|
41
|
+
title: string
|
|
42
|
+
icon?: string
|
|
43
|
+
items: Array<{
|
|
44
|
+
title: string
|
|
45
|
+
href?: string
|
|
46
|
+
icon?: string
|
|
47
|
+
resource: string
|
|
48
|
+
note?: string
|
|
49
|
+
}>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CatalogActionMeta {
|
|
53
|
+
label?: string
|
|
54
|
+
description?: string
|
|
55
|
+
flow?: string
|
|
56
|
+
requires?: string[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ResourceRow {
|
|
60
|
+
id: string
|
|
61
|
+
code: string
|
|
62
|
+
name: string
|
|
63
|
+
group: string | null
|
|
64
|
+
description: string | null
|
|
65
|
+
icon: string | null
|
|
66
|
+
order: number | null
|
|
67
|
+
config: any
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ActionRow {
|
|
71
|
+
id: string
|
|
72
|
+
code: string
|
|
73
|
+
name: string
|
|
74
|
+
description: string | null
|
|
75
|
+
isDefault?: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const STANDARD_ORDER = ["view", "create", "update", "delete", "export", "import"]
|
|
79
|
+
|
|
80
|
+
const CHECK_COLOR_RULES: Array<{ re: RegExp; cls: string }> = [
|
|
81
|
+
{ re: /^(view|read|get|list)/, cls: "border-blue-500 bg-blue-500" },
|
|
82
|
+
{ re: /^(create|add|new)/, cls: "border-emerald-500 bg-emerald-500" },
|
|
83
|
+
{ re: /^(update|edit|change|set)/, cls: "border-amber-500 bg-amber-500" },
|
|
84
|
+
{ re: /^(delete|remove)/, cls: "border-rose-500 bg-rose-500" },
|
|
85
|
+
{ re: /^(cancel|void|reject)/, cls: "border-rose-500 bg-rose-500" },
|
|
86
|
+
{ re: /^(approve|confirm|verify|complete)/, cls: "border-violet-500 bg-violet-500" },
|
|
87
|
+
{ re: /^(import)/, cls: "border-purple-500 bg-purple-500" },
|
|
88
|
+
{ re: /^(export)/, cls: "border-indigo-500 bg-indigo-500" },
|
|
89
|
+
{ re: /^(sync|unlink)/, cls: "border-cyan-600 bg-cyan-600" },
|
|
90
|
+
{ re: /^(manage|config|close|reopen)/, cls: "border-orange-500 bg-orange-500" },
|
|
91
|
+
]
|
|
92
|
+
const checkColor = (code: string) =>
|
|
93
|
+
CHECK_COLOR_RULES.find((r) => r.re.test(code))?.cls ??
|
|
94
|
+
"border-primary bg-primary"
|
|
95
|
+
const HIDDEN_IN_PAGE = new Set(["view", "lookup", "view-all-branches"])
|
|
96
|
+
|
|
97
|
+
function parseAllowed(config: any, actionCodes: string[]): string[] {
|
|
98
|
+
try {
|
|
99
|
+
const c = typeof config === "string" ? JSON.parse(config) : config
|
|
100
|
+
if (c?.actions && Array.isArray(c.actions)) {
|
|
101
|
+
return actionCodes.filter((code) => (c.actions as string[]).includes(code))
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
/* ignore */
|
|
105
|
+
}
|
|
106
|
+
return actionCodes.filter((code) =>
|
|
107
|
+
["view", "create", "update", "delete"].includes(code)
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const REGISTRY_BANNER = (
|
|
112
|
+
<div className="flex items-start gap-2 border-b border-border bg-muted/30 px-4 py-2 text-[11px] text-muted-foreground">
|
|
113
|
+
<Shield className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
114
|
+
<span>
|
|
115
|
+
Danh mục do hệ thống quản lý — trang này chỉ để tra cứu. Cấp quyền cho
|
|
116
|
+
nhân viên làm ở trang <b>Vai trò</b>.
|
|
117
|
+
</span>
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
/* ═══════════════════════ TRANG TÀI NGUYÊN ═══════════════════════ */
|
|
122
|
+
|
|
123
|
+
export interface ResourceCatalogPageProps {
|
|
124
|
+
resources: ResourceRow[]
|
|
125
|
+
actions: ActionRow[]
|
|
126
|
+
menuTree?: CatalogMenuSection[]
|
|
127
|
+
actionMeta?: Record<string, CatalogActionMeta>
|
|
128
|
+
/** resource → chính sách combo: "authenticated" | "reference-lookup" | "view-only" */
|
|
129
|
+
lookupPolicies?: Record<string, string>
|
|
130
|
+
/** Cho phép gán/bỏ thao tác trực tiếp (cần quyền resource:update). */
|
|
131
|
+
editable?: boolean
|
|
132
|
+
/** Endpoint toggle — vd "/api/resources/toggle-action" (resource gửi trong body). */
|
|
133
|
+
toggleActionEndpoint?: string
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function ResourceCatalogPage({
|
|
137
|
+
resources,
|
|
138
|
+
actions,
|
|
139
|
+
menuTree,
|
|
140
|
+
actionMeta,
|
|
141
|
+
lookupPolicies,
|
|
142
|
+
editable,
|
|
143
|
+
toggleActionEndpoint,
|
|
144
|
+
}: ResourceCatalogPageProps) {
|
|
145
|
+
const [searchTerm, setSearchTerm] = React.useState("")
|
|
146
|
+
const [collapsed, setCollapsed] = React.useState<Set<string>>(
|
|
147
|
+
() => new Set()
|
|
148
|
+
)
|
|
149
|
+
// resource đang mở phần "Chưa khai" (đối chiếu như trang cũ)
|
|
150
|
+
const [showUndeclared, setShowUndeclared] = React.useState<Set<string>>(
|
|
151
|
+
() => new Set()
|
|
152
|
+
)
|
|
153
|
+
const toggleUndeclared = (code: string) =>
|
|
154
|
+
setShowUndeclared((prev) => {
|
|
155
|
+
const next = new Set(prev)
|
|
156
|
+
if (next.has(code)) next.delete(code)
|
|
157
|
+
else next.add(code)
|
|
158
|
+
return next
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const actionCodes = React.useMemo(() => actions.map((a) => a.code), [actions])
|
|
162
|
+
|
|
163
|
+
const resourceInfo = React.useMemo(() => {
|
|
164
|
+
const map = new Map<
|
|
165
|
+
string,
|
|
166
|
+
{ name: string; icon: string | null; allowed: string[] }
|
|
167
|
+
>()
|
|
168
|
+
resources.forEach((r) => {
|
|
169
|
+
map.set(r.code, {
|
|
170
|
+
name: r.name,
|
|
171
|
+
icon: r.icon,
|
|
172
|
+
allowed: parseAllowed(r.config, actionCodes),
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
return map
|
|
176
|
+
}, [resources, actionCodes])
|
|
177
|
+
|
|
178
|
+
const actionName = React.useCallback(
|
|
179
|
+
(code: string) =>
|
|
180
|
+
actionMeta?.[code]?.label ||
|
|
181
|
+
actions.find((a) => a.code === code)?.name ||
|
|
182
|
+
code,
|
|
183
|
+
[actionMeta, actions]
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
// Lớp override lạc quan khi admin tick trực tiếp (key "resource:action")
|
|
187
|
+
const [overrides, setOverrides] = React.useState<Map<string, boolean>>(
|
|
188
|
+
() => new Map()
|
|
189
|
+
)
|
|
190
|
+
const isDeclared = React.useCallback(
|
|
191
|
+
(resource: string, code: string) => {
|
|
192
|
+
const ov = overrides.get(`${resource}:${code}`)
|
|
193
|
+
if (ov !== undefined) return ov
|
|
194
|
+
return resourceInfo.get(resource)?.allowed.includes(code) ?? false
|
|
195
|
+
},
|
|
196
|
+
[overrides, resourceInfo]
|
|
197
|
+
)
|
|
198
|
+
const declaredList = React.useCallback(
|
|
199
|
+
(resource: string) =>
|
|
200
|
+
actionCodes.filter((c) => isDeclared(resource, c)),
|
|
201
|
+
[actionCodes, isDeclared]
|
|
202
|
+
)
|
|
203
|
+
const toggleDeclared = async (resource: string, code: string) => {
|
|
204
|
+
if (!editable || !toggleActionEndpoint) return
|
|
205
|
+
const next = !isDeclared(resource, code)
|
|
206
|
+
setOverrides((prev) => {
|
|
207
|
+
const m = new Map(prev)
|
|
208
|
+
m.set(`${resource}:${code}`, next)
|
|
209
|
+
return m
|
|
210
|
+
})
|
|
211
|
+
try {
|
|
212
|
+
const res = await fetch(
|
|
213
|
+
toggleActionEndpoint.replace("{code}", resource),
|
|
214
|
+
{
|
|
215
|
+
method: "POST",
|
|
216
|
+
headers: { "Content-Type": "application/json" },
|
|
217
|
+
body: JSON.stringify({ resource, action: code, enabled: next }),
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
if (!res.ok) throw new Error(await res.text())
|
|
221
|
+
toast.success(
|
|
222
|
+
next
|
|
223
|
+
? `Đã gán "${actionName(code)}"`
|
|
224
|
+
: `Đã bỏ "${actionName(code)}"`,
|
|
225
|
+
{ description: "Trang Vai trò sẽ hiện/ẩn thao tác này tương ứng." }
|
|
226
|
+
)
|
|
227
|
+
} catch {
|
|
228
|
+
// hoàn tác khi lỗi
|
|
229
|
+
setOverrides((prev) => {
|
|
230
|
+
const m = new Map(prev)
|
|
231
|
+
m.set(`${resource}:${code}`, !next)
|
|
232
|
+
return m
|
|
233
|
+
})
|
|
234
|
+
toast.error("Không cập nhật được — thử lại")
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const tree = React.useMemo<CatalogMenuSection[]>(() => {
|
|
239
|
+
if (menuTree && menuTree.length > 0) {
|
|
240
|
+
// kèm resource ngoài cây (nếu có) vào section cuối
|
|
241
|
+
const onMenu = new Set(
|
|
242
|
+
menuTree.flatMap((s) => s.items.map((i) => i.resource))
|
|
243
|
+
)
|
|
244
|
+
const stray = resources.filter(
|
|
245
|
+
(r) =>
|
|
246
|
+
!onMenu.has(r.code) &&
|
|
247
|
+
(resourceInfo.get(r.code)?.allowed ?? []).some((a) => a !== "lookup")
|
|
248
|
+
)
|
|
249
|
+
if (stray.length === 0) return menuTree
|
|
250
|
+
return [
|
|
251
|
+
...menuTree,
|
|
252
|
+
{
|
|
253
|
+
title: "Chưa gắn menu",
|
|
254
|
+
items: stray.map((r) => ({
|
|
255
|
+
title: r.name,
|
|
256
|
+
resource: r.code,
|
|
257
|
+
icon: r.icon ?? undefined,
|
|
258
|
+
})),
|
|
259
|
+
},
|
|
260
|
+
]
|
|
261
|
+
}
|
|
262
|
+
const byGroup = new Map<string, CatalogMenuSection>()
|
|
263
|
+
resources.forEach((r) => {
|
|
264
|
+
const group = r.group || "Khác"
|
|
265
|
+
const sec = byGroup.get(group) ?? { title: group, items: [] }
|
|
266
|
+
sec.items.push({
|
|
267
|
+
title: r.name,
|
|
268
|
+
resource: r.code,
|
|
269
|
+
icon: r.icon ?? undefined,
|
|
270
|
+
})
|
|
271
|
+
byGroup.set(group, sec)
|
|
272
|
+
})
|
|
273
|
+
return Array.from(byGroup.values())
|
|
274
|
+
}, [menuTree, resources, resourceInfo])
|
|
275
|
+
|
|
276
|
+
const searching = searchTerm.trim().length > 0
|
|
277
|
+
const q = searchTerm.trim().toLowerCase()
|
|
278
|
+
|
|
279
|
+
const matchItem = React.useCallback(
|
|
280
|
+
(item: CatalogMenuSection["items"][number]) => {
|
|
281
|
+
if (!searching) return true
|
|
282
|
+
if (item.title.toLowerCase().includes(q)) return true
|
|
283
|
+
if (item.href?.toLowerCase().includes(q)) return true
|
|
284
|
+
if (item.resource.toLowerCase().includes(q)) return true
|
|
285
|
+
const allowed = resourceInfo.get(item.resource)?.allowed ?? []
|
|
286
|
+
return allowed.some((code) =>
|
|
287
|
+
actionName(code).toLowerCase().includes(q)
|
|
288
|
+
)
|
|
289
|
+
},
|
|
290
|
+
[searching, q, resourceInfo, actionName]
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
const visibleTree = React.useMemo(() => {
|
|
294
|
+
if (!searching) return tree
|
|
295
|
+
return tree
|
|
296
|
+
.map((s) =>
|
|
297
|
+
s.title.toLowerCase().includes(q)
|
|
298
|
+
? s
|
|
299
|
+
: { ...s, items: s.items.filter(matchItem) }
|
|
300
|
+
)
|
|
301
|
+
.filter((s) => s.items.length > 0)
|
|
302
|
+
}, [tree, searching, q, matchItem])
|
|
303
|
+
|
|
304
|
+
const lookupLabel = (resource: string) => {
|
|
305
|
+
const policy = lookupPolicies?.[resource]
|
|
306
|
+
if (policy === "authenticated") return "Combo: tự động cho mọi nhân viên"
|
|
307
|
+
if (policy === "reference-lookup") return "Combo: cần quyền Tra cứu"
|
|
308
|
+
const allowed = resourceInfo.get(resource)?.allowed ?? []
|
|
309
|
+
if (allowed.includes("lookup")) return "Combo: theo quyền Tra cứu"
|
|
310
|
+
return null
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const toggleCollapse = (title: string) =>
|
|
314
|
+
setCollapsed((prev) => {
|
|
315
|
+
const next = new Set(prev)
|
|
316
|
+
if (next.has(title)) next.delete(title)
|
|
317
|
+
else next.add(title)
|
|
318
|
+
return next
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
return (
|
|
322
|
+
<div className="flex flex-col">
|
|
323
|
+
{/* Header bar navy */}
|
|
324
|
+
<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">
|
|
325
|
+
<span className="flex h-8 w-8 shrink-0 items-center justify-center">
|
|
326
|
+
<BookOpen className="h-4 w-4 text-primary-foreground/80" />
|
|
327
|
+
</span>
|
|
328
|
+
<h1 className="min-w-0 flex-1 truncate px-1 text-base font-bold text-primary-foreground sm:text-lg">
|
|
329
|
+
Danh mục chức năng & quyền
|
|
330
|
+
</h1>
|
|
331
|
+
<span className="hidden shrink-0 text-xs tabular-nums text-primary-foreground/70 md:inline">
|
|
332
|
+
{resources.length} chức năng · {actions.length} thao tác
|
|
333
|
+
</span>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
<div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
|
|
337
|
+
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2 sm:px-4">
|
|
338
|
+
<div className="relative min-w-[150px] flex-1 sm:max-w-72">
|
|
339
|
+
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
340
|
+
<Input
|
|
341
|
+
placeholder="Tìm chức năng / thao tác..."
|
|
342
|
+
className="h-8 rounded-md border border-border bg-card pl-8 text-sm"
|
|
343
|
+
value={searchTerm}
|
|
344
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
345
|
+
/>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
{REGISTRY_BANNER}
|
|
349
|
+
|
|
350
|
+
{visibleTree.length === 0 ? (
|
|
351
|
+
<p className="px-4 py-8 text-center text-xs text-muted-foreground">
|
|
352
|
+
Không có chức năng nào khớp "{searchTerm}".
|
|
353
|
+
</p>
|
|
354
|
+
) : (
|
|
355
|
+
visibleTree.map((section) => {
|
|
356
|
+
const isCollapsed = !searching && collapsed.has(section.title)
|
|
357
|
+
return (
|
|
358
|
+
<section key={section.title}>
|
|
359
|
+
<button
|
|
360
|
+
type="button"
|
|
361
|
+
onClick={() => toggleCollapse(section.title)}
|
|
362
|
+
className="flex w-full items-center gap-2 border-b border-border bg-muted/40 px-4 py-2 text-left hover:bg-muted/60"
|
|
363
|
+
>
|
|
364
|
+
{isCollapsed ? (
|
|
365
|
+
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
366
|
+
) : (
|
|
367
|
+
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
368
|
+
)}
|
|
369
|
+
<DynamicIcon
|
|
370
|
+
name={section.icon as any}
|
|
371
|
+
className="h-4 w-4 shrink-0 text-muted-foreground"
|
|
372
|
+
/>
|
|
373
|
+
<h3 className="text-sm font-semibold text-foreground">
|
|
374
|
+
{section.title}
|
|
375
|
+
</h3>
|
|
376
|
+
<span className="text-[11px] tabular-nums text-muted-foreground">
|
|
377
|
+
{section.items.length} chức năng
|
|
378
|
+
</span>
|
|
379
|
+
</button>
|
|
380
|
+
{!isCollapsed && (
|
|
381
|
+
<ul className="divide-y divide-border/60 border-b border-border">
|
|
382
|
+
{section.items.map((item) => {
|
|
383
|
+
const info = resourceInfo.get(item.resource)
|
|
384
|
+
if (!info) return null
|
|
385
|
+
const declaredNow = declaredList(item.resource)
|
|
386
|
+
const acts = declaredNow.filter(
|
|
387
|
+
(a) => !HIDDEN_IN_PAGE.has(a)
|
|
388
|
+
)
|
|
389
|
+
const std = STANDARD_ORDER.filter((a) =>
|
|
390
|
+
acts.includes(a)
|
|
391
|
+
)
|
|
392
|
+
const biz = acts.filter(
|
|
393
|
+
(a) => !STANDARD_ORDER.includes(a)
|
|
394
|
+
)
|
|
395
|
+
const flows = new Map<string, string[]>()
|
|
396
|
+
if (std.length > 0) flows.set("Chung", std)
|
|
397
|
+
biz.forEach((code) => {
|
|
398
|
+
const flow = actionMeta?.[code]?.flow || "Khác"
|
|
399
|
+
const list = flows.get(flow) ?? []
|
|
400
|
+
list.push(code)
|
|
401
|
+
flows.set(flow, list)
|
|
402
|
+
})
|
|
403
|
+
const combo = lookupLabel(item.resource)
|
|
404
|
+
|
|
405
|
+
return (
|
|
406
|
+
<li
|
|
407
|
+
key={`${item.resource}:${item.title}`}
|
|
408
|
+
className="px-4 py-2"
|
|
409
|
+
>
|
|
410
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
411
|
+
<DynamicIcon
|
|
412
|
+
name={(item.icon || info.icon) as any}
|
|
413
|
+
className="h-4 w-4 shrink-0 text-muted-foreground"
|
|
414
|
+
/>
|
|
415
|
+
<span className="text-sm font-medium text-foreground">
|
|
416
|
+
{item.title}
|
|
417
|
+
</span>
|
|
418
|
+
{item.href && (
|
|
419
|
+
<code className="hidden font-mono text-[10px] text-muted-foreground sm:inline">
|
|
420
|
+
{item.href}
|
|
421
|
+
</code>
|
|
422
|
+
)}
|
|
423
|
+
{item.note && (
|
|
424
|
+
<span className="hidden text-[10px] italic text-muted-foreground/80 sm:inline">
|
|
425
|
+
{item.note}
|
|
426
|
+
</span>
|
|
427
|
+
)}
|
|
428
|
+
<code className="ml-auto shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
|
429
|
+
{item.resource}
|
|
430
|
+
</code>
|
|
431
|
+
</div>
|
|
432
|
+
<div className="mt-1.5 space-y-1 pl-6">
|
|
433
|
+
{(() => {
|
|
434
|
+
const open = showUndeclared.has(item.resource)
|
|
435
|
+
const undeclaredCount =
|
|
436
|
+
actionCodes.length - declaredNow.length
|
|
437
|
+
|
|
438
|
+
const gridItem = (
|
|
439
|
+
code: string,
|
|
440
|
+
declared: boolean
|
|
441
|
+
) => {
|
|
442
|
+
const desc =
|
|
443
|
+
actionMeta?.[code]?.description ||
|
|
444
|
+
actions.find((a) => a.code === code)
|
|
445
|
+
?.description ||
|
|
446
|
+
""
|
|
447
|
+
const Wrapper: any = editable ? "button" : "div"
|
|
448
|
+
return (
|
|
449
|
+
<Wrapper
|
|
450
|
+
key={code}
|
|
451
|
+
type={editable ? "button" : undefined}
|
|
452
|
+
onClick={
|
|
453
|
+
editable
|
|
454
|
+
? () =>
|
|
455
|
+
toggleDeclared(
|
|
456
|
+
item.resource,
|
|
457
|
+
code
|
|
458
|
+
)
|
|
459
|
+
: undefined
|
|
460
|
+
}
|
|
461
|
+
className={cn(
|
|
462
|
+
"flex items-start gap-2 text-left",
|
|
463
|
+
editable &&
|
|
464
|
+
"cursor-pointer rounded-md px-1 py-0.5 transition-colors hover:bg-muted/60"
|
|
465
|
+
)}
|
|
466
|
+
>
|
|
467
|
+
<span
|
|
468
|
+
className={cn(
|
|
469
|
+
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-[4px] border",
|
|
470
|
+
declared
|
|
471
|
+
? cn(
|
|
472
|
+
checkColor(code),
|
|
473
|
+
"text-white"
|
|
474
|
+
)
|
|
475
|
+
: "border-border bg-card text-transparent"
|
|
476
|
+
)}
|
|
477
|
+
>
|
|
478
|
+
<Check
|
|
479
|
+
className="h-3 w-3"
|
|
480
|
+
strokeWidth={3}
|
|
481
|
+
/>
|
|
482
|
+
</span>
|
|
483
|
+
<span className="min-w-0">
|
|
484
|
+
<span
|
|
485
|
+
className={cn(
|
|
486
|
+
"block text-[13px] font-medium leading-tight",
|
|
487
|
+
declared
|
|
488
|
+
? "text-foreground"
|
|
489
|
+
: "text-muted-foreground"
|
|
490
|
+
)}
|
|
491
|
+
>
|
|
492
|
+
{actionName(code)}
|
|
493
|
+
</span>
|
|
494
|
+
{desc && (
|
|
495
|
+
<span className="block text-[11px] leading-snug text-muted-foreground/80">
|
|
496
|
+
{desc}
|
|
497
|
+
</span>
|
|
498
|
+
)}
|
|
499
|
+
</span>
|
|
500
|
+
</Wrapper>
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const renderSection = (
|
|
505
|
+
title: string,
|
|
506
|
+
codes: string[]
|
|
507
|
+
) => {
|
|
508
|
+
if (codes.length === 0) return null
|
|
509
|
+
const declared = codes.filter((c) =>
|
|
510
|
+
isDeclared(item.resource, c)
|
|
511
|
+
)
|
|
512
|
+
const undeclared = codes.filter(
|
|
513
|
+
(c) => !isDeclared(item.resource, c)
|
|
514
|
+
)
|
|
515
|
+
return (
|
|
516
|
+
<div key={title} className="space-y-2">
|
|
517
|
+
<p className="text-[11px] font-semibold uppercase tracking-wide text-foreground">
|
|
518
|
+
{title}
|
|
519
|
+
</p>
|
|
520
|
+
{declared.length > 0 && (
|
|
521
|
+
<>
|
|
522
|
+
<p className="flex items-center gap-1 text-[10px] font-medium uppercase text-emerald-600 dark:text-emerald-400">
|
|
523
|
+
<Check className="h-3 w-3" /> Đã cấp
|
|
524
|
+
· {declared.length}
|
|
525
|
+
</p>
|
|
526
|
+
<div className="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2 xl:grid-cols-3">
|
|
527
|
+
{declared.map((c) =>
|
|
528
|
+
gridItem(c, true)
|
|
529
|
+
)}
|
|
530
|
+
</div>
|
|
531
|
+
</>
|
|
532
|
+
)}
|
|
533
|
+
{undeclared.length > 0 && (
|
|
534
|
+
<>
|
|
535
|
+
<p className="text-[10px] font-medium uppercase text-muted-foreground">
|
|
536
|
+
Chưa cấp · {undeclared.length}
|
|
537
|
+
</p>
|
|
538
|
+
<div className="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2 xl:grid-cols-3">
|
|
539
|
+
{undeclared.map((c) =>
|
|
540
|
+
gridItem(c, false)
|
|
541
|
+
)}
|
|
542
|
+
</div>
|
|
543
|
+
</>
|
|
544
|
+
)}
|
|
545
|
+
</div>
|
|
546
|
+
)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return (
|
|
550
|
+
<>
|
|
551
|
+
{!open && (
|
|
552
|
+
<>
|
|
553
|
+
{Array.from(flows.entries()).map(
|
|
554
|
+
([flow, codes]) => (
|
|
555
|
+
<div
|
|
556
|
+
key={flow}
|
|
557
|
+
className="flex flex-wrap items-center gap-x-2 gap-y-1"
|
|
558
|
+
>
|
|
559
|
+
<span className="w-24 shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground/60">
|
|
560
|
+
{flow}
|
|
561
|
+
</span>
|
|
562
|
+
{codes.map((code) => (
|
|
563
|
+
<span
|
|
564
|
+
key={code}
|
|
565
|
+
title={
|
|
566
|
+
actionMeta?.[code]
|
|
567
|
+
?.description ||
|
|
568
|
+
actionName(code)
|
|
569
|
+
}
|
|
570
|
+
className="inline-flex items-center gap-1 rounded-md border border-primary/40 bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary"
|
|
571
|
+
>
|
|
572
|
+
<span
|
|
573
|
+
className={cn(
|
|
574
|
+
"flex h-3 w-3 shrink-0 items-center justify-center rounded-[3px] border text-white",
|
|
575
|
+
checkColor(code)
|
|
576
|
+
)}
|
|
577
|
+
>
|
|
578
|
+
<Check
|
|
579
|
+
className="h-2 w-2"
|
|
580
|
+
strokeWidth={3}
|
|
581
|
+
/>
|
|
582
|
+
</span>
|
|
583
|
+
{actionName(code)}
|
|
584
|
+
</span>
|
|
585
|
+
))}
|
|
586
|
+
</div>
|
|
587
|
+
)
|
|
588
|
+
)}
|
|
589
|
+
{combo && (
|
|
590
|
+
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/80">
|
|
591
|
+
<Info className="h-3 w-3" />
|
|
592
|
+
{combo}
|
|
593
|
+
</div>
|
|
594
|
+
)}
|
|
595
|
+
</>
|
|
596
|
+
)}
|
|
597
|
+
{open && (
|
|
598
|
+
<div className="mt-2 space-y-4 rounded-lg border border-border/70 bg-muted/20 p-3">
|
|
599
|
+
<p className="text-xs text-muted-foreground">
|
|
600
|
+
Đã gán{" "}
|
|
601
|
+
<b className="text-foreground">
|
|
602
|
+
{declaredNow.length}
|
|
603
|
+
</b>
|
|
604
|
+
/{actionCodes.length} quyền
|
|
605
|
+
{editable && (
|
|
606
|
+
<span className="ml-1 text-[10px] text-muted-foreground/80">
|
|
607
|
+
— bấm vào ô để gán/bỏ
|
|
608
|
+
</span>
|
|
609
|
+
)}
|
|
610
|
+
</p>
|
|
611
|
+
{renderSection(
|
|
612
|
+
"Cơ bản",
|
|
613
|
+
STANDARD_ORDER.filter((c) =>
|
|
614
|
+
actionCodes.includes(c)
|
|
615
|
+
)
|
|
616
|
+
)}
|
|
617
|
+
{renderSection(
|
|
618
|
+
"Chức năng",
|
|
619
|
+
actionCodes.filter(
|
|
620
|
+
(c) => !STANDARD_ORDER.includes(c)
|
|
621
|
+
)
|
|
622
|
+
)}
|
|
623
|
+
</div>
|
|
624
|
+
)}
|
|
625
|
+
<button
|
|
626
|
+
type="button"
|
|
627
|
+
onClick={() =>
|
|
628
|
+
toggleUndeclared(item.resource)
|
|
629
|
+
}
|
|
630
|
+
className="flex items-center gap-1 text-[10px] font-medium text-primary/80 hover:text-primary"
|
|
631
|
+
>
|
|
632
|
+
{open ? (
|
|
633
|
+
<ChevronDown className="h-3 w-3" />
|
|
634
|
+
) : (
|
|
635
|
+
<ChevronRight className="h-3 w-3" />
|
|
636
|
+
)}
|
|
637
|
+
{open
|
|
638
|
+
? "Thu gọn"
|
|
639
|
+
: `Xem đầy đủ: ${declaredNow.length} đã cấp / ${undeclaredCount} chưa cấp`}
|
|
640
|
+
</button>
|
|
641
|
+
</>
|
|
642
|
+
)
|
|
643
|
+
})()}
|
|
644
|
+
</div>
|
|
645
|
+
</li>
|
|
646
|
+
)
|
|
647
|
+
})}
|
|
648
|
+
</ul>
|
|
649
|
+
)}
|
|
650
|
+
</section>
|
|
651
|
+
)
|
|
652
|
+
})
|
|
653
|
+
)}
|
|
654
|
+
</div>
|
|
655
|
+
</div>
|
|
656
|
+
)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/* ═══════════════════════ TRANG HÀNH ĐỘNG ═══════════════════════ */
|
|
660
|
+
|
|
661
|
+
export interface ActionCatalogPageProps {
|
|
662
|
+
resources: ResourceRow[]
|
|
663
|
+
actions: ActionRow[]
|
|
664
|
+
actionMeta?: Record<string, CatalogActionMeta>
|
|
665
|
+
/** resource → href trang (hiện cạnh tên chức năng khi bung). */
|
|
666
|
+
resourcePages?: Record<string, string>
|
|
667
|
+
/** Cho phép thêm/sửa thao tác (cần quyền action:create / action:update). */
|
|
668
|
+
editable?: boolean
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const SENSITIVE_FLOWS = new Set(["Nhạy cảm", "Phê duyệt"])
|
|
672
|
+
|
|
673
|
+
interface RoleLite {
|
|
674
|
+
id: string
|
|
675
|
+
name: string
|
|
676
|
+
code: string
|
|
677
|
+
status?: string
|
|
678
|
+
permissions: string[]
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export function ActionCatalogPage({
|
|
682
|
+
resources,
|
|
683
|
+
actions,
|
|
684
|
+
actionMeta,
|
|
685
|
+
resourcePages,
|
|
686
|
+
editable,
|
|
687
|
+
}: ActionCatalogPageProps) {
|
|
688
|
+
const router = useRouter()
|
|
689
|
+
// Dialog thêm/sửa thao tác
|
|
690
|
+
const [editorOpen, setEditorOpen] = React.useState(false)
|
|
691
|
+
const [editing, setEditing] = React.useState<ActionRow | null>(null)
|
|
692
|
+
const [form, setForm] = React.useState({ code: "", name: "", description: "" })
|
|
693
|
+
const [saving, setSaving] = React.useState(false)
|
|
694
|
+
|
|
695
|
+
const openCreate = () => {
|
|
696
|
+
setEditing(null)
|
|
697
|
+
setForm({ code: "", name: "", description: "" })
|
|
698
|
+
setEditorOpen(true)
|
|
699
|
+
}
|
|
700
|
+
const openEdit = (a: ActionRow) => {
|
|
701
|
+
setEditing(a)
|
|
702
|
+
setForm({
|
|
703
|
+
code: a.code,
|
|
704
|
+
name: a.name,
|
|
705
|
+
description: a.description ?? "",
|
|
706
|
+
})
|
|
707
|
+
setEditorOpen(true)
|
|
708
|
+
}
|
|
709
|
+
const saveAction = async () => {
|
|
710
|
+
if (!form.code.trim() || !form.name.trim()) {
|
|
711
|
+
toast.error("Cần nhập mã và tên thao tác")
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
setSaving(true)
|
|
715
|
+
try {
|
|
716
|
+
const res = await fetch("/api/actions", {
|
|
717
|
+
method: editing ? "PUT" : "POST",
|
|
718
|
+
headers: { "Content-Type": "application/json" },
|
|
719
|
+
body: JSON.stringify({
|
|
720
|
+
...(editing ? { id: editing.id } : {}),
|
|
721
|
+
code: form.code.trim(),
|
|
722
|
+
name: form.name.trim(),
|
|
723
|
+
description: form.description.trim(),
|
|
724
|
+
status: "active",
|
|
725
|
+
isDefault: editing?.isDefault ?? false,
|
|
726
|
+
}),
|
|
727
|
+
})
|
|
728
|
+
if (!res.ok) {
|
|
729
|
+
const data = await res.json().catch(() => null)
|
|
730
|
+
throw new Error(data?.error || "Lỗi lưu thao tác")
|
|
731
|
+
}
|
|
732
|
+
toast.success(editing ? "Đã cập nhật thao tác" : "Đã tạo thao tác mới", {
|
|
733
|
+
description: editing
|
|
734
|
+
? undefined
|
|
735
|
+
: "Gán vào chức năng ở trang Tài nguyên (Xem đầy đủ → bấm ô).",
|
|
736
|
+
})
|
|
737
|
+
setEditorOpen(false)
|
|
738
|
+
router.refresh()
|
|
739
|
+
} catch (e: any) {
|
|
740
|
+
toast.error(e?.message || "Lỗi lưu thao tác")
|
|
741
|
+
} finally {
|
|
742
|
+
setSaving(false)
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
const [searchTerm, setSearchTerm] = React.useState("")
|
|
746
|
+
const [flowFilter, setFlowFilter] = React.useState<string | null>(null)
|
|
747
|
+
const [expanded, setExpanded] = React.useState<Set<string>>(() => new Set())
|
|
748
|
+
const [roles, setRoles] = React.useState<RoleLite[] | null>(null)
|
|
749
|
+
const [users, setUsers] = React.useState<Array<{
|
|
750
|
+
id: string
|
|
751
|
+
name?: string | null
|
|
752
|
+
email?: string | null
|
|
753
|
+
roleNames?: string[]
|
|
754
|
+
}> | null>(null)
|
|
755
|
+
|
|
756
|
+
const toggleExpand = (code: string) => {
|
|
757
|
+
setExpanded((prev) => {
|
|
758
|
+
const next = new Set(prev)
|
|
759
|
+
if (next.has(code)) next.delete(code)
|
|
760
|
+
else next.add(code)
|
|
761
|
+
return next
|
|
762
|
+
})
|
|
763
|
+
// lazy-load roles + users cho phần "ai đang có quyền này"
|
|
764
|
+
if (roles === null) {
|
|
765
|
+
setRoles([])
|
|
766
|
+
fetch("/api/roles?pageSize=200")
|
|
767
|
+
.then((r) => r.json())
|
|
768
|
+
.then((d) => setRoles(d.items ?? []))
|
|
769
|
+
.catch(() => setRoles([]))
|
|
770
|
+
}
|
|
771
|
+
if (users === null) {
|
|
772
|
+
setUsers([])
|
|
773
|
+
fetch("/api/users?pageSize=1000")
|
|
774
|
+
.then((r) => r.json())
|
|
775
|
+
.then((d) => setUsers(d.data ?? d.items ?? []))
|
|
776
|
+
.catch(() => setUsers([]))
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const actionCodes = React.useMemo(() => actions.map((a) => a.code), [actions])
|
|
781
|
+
|
|
782
|
+
// action → các chức năng đang khai nó
|
|
783
|
+
const usage = React.useMemo(() => {
|
|
784
|
+
const map = new Map<string, Array<{ code: string; name: string }>>()
|
|
785
|
+
resources.forEach((r) => {
|
|
786
|
+
parseAllowed(r.config, actionCodes).forEach((a) => {
|
|
787
|
+
const list = map.get(a) ?? []
|
|
788
|
+
list.push({ code: r.code, name: r.name })
|
|
789
|
+
map.set(a, list)
|
|
790
|
+
})
|
|
791
|
+
})
|
|
792
|
+
return map
|
|
793
|
+
}, [resources, actionCodes])
|
|
794
|
+
|
|
795
|
+
// action → các vai trò đang được cấp (trên ít nhất 1 chức năng)
|
|
796
|
+
const roleUsage = React.useCallback(
|
|
797
|
+
(actionCode: string) => {
|
|
798
|
+
if (!roles) return null
|
|
799
|
+
const prefix = `${actionCode}:`
|
|
800
|
+
return roles
|
|
801
|
+
.map((role) => ({
|
|
802
|
+
role,
|
|
803
|
+
count: (role.permissions ?? []).filter((perm) =>
|
|
804
|
+
perm.startsWith(prefix)
|
|
805
|
+
).length,
|
|
806
|
+
}))
|
|
807
|
+
.filter((x) => x.count > 0)
|
|
808
|
+
},
|
|
809
|
+
[roles]
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
const userHolders = React.useCallback(
|
|
813
|
+
(holderRoles: Array<{ role: RoleLite }> | null) => {
|
|
814
|
+
if (!users || !holderRoles) return null
|
|
815
|
+
const names = new Set(
|
|
816
|
+
holderRoles.map(({ role }) => role.name.toLowerCase())
|
|
817
|
+
)
|
|
818
|
+
const list: Array<{
|
|
819
|
+
id: string
|
|
820
|
+
label: string
|
|
821
|
+
superAdmin: boolean
|
|
822
|
+
}> = []
|
|
823
|
+
users.forEach((u) => {
|
|
824
|
+
const rn = (u.roleNames ?? []).map((x) => x.toLowerCase())
|
|
825
|
+
const superAdmin = rn.includes("super admin") || rn.includes("super_admin")
|
|
826
|
+
const viaRole = rn.some((x) => names.has(x))
|
|
827
|
+
if (viaRole || superAdmin) {
|
|
828
|
+
list.push({
|
|
829
|
+
id: u.id,
|
|
830
|
+
label: u.name || u.email || u.id,
|
|
831
|
+
superAdmin: superAdmin && !viaRole,
|
|
832
|
+
})
|
|
833
|
+
}
|
|
834
|
+
})
|
|
835
|
+
return list
|
|
836
|
+
},
|
|
837
|
+
[users]
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
const flows = React.useMemo(() => {
|
|
841
|
+
const map = new Map<string, ActionRow[]>()
|
|
842
|
+
actions.forEach((a) => {
|
|
843
|
+
const flow = STANDARD_ORDER.includes(a.code)
|
|
844
|
+
? "Chung (CRUD chuẩn)"
|
|
845
|
+
: actionMeta?.[a.code]?.flow ||
|
|
846
|
+
(a.code === "lookup" ? "Tra cứu (combo)" : "Khác")
|
|
847
|
+
const list = map.get(flow) ?? []
|
|
848
|
+
list.push(a)
|
|
849
|
+
map.set(flow, list)
|
|
850
|
+
})
|
|
851
|
+
return map
|
|
852
|
+
}, [actions, actionMeta])
|
|
853
|
+
|
|
854
|
+
const searching = searchTerm.trim().length > 0
|
|
855
|
+
const q = searchTerm.trim().toLowerCase()
|
|
856
|
+
|
|
857
|
+
const matches = (a: ActionRow) => {
|
|
858
|
+
if (!searching) return true
|
|
859
|
+
const label = actionMeta?.[a.code]?.label || a.name
|
|
860
|
+
return (
|
|
861
|
+
label.toLowerCase().includes(q) ||
|
|
862
|
+
a.code.toLowerCase().includes(q) ||
|
|
863
|
+
(actionMeta?.[a.code]?.description ?? "").toLowerCase().includes(q)
|
|
864
|
+
)
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return (
|
|
868
|
+
<div className="flex flex-col">
|
|
869
|
+
<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">
|
|
870
|
+
<span className="flex h-8 w-8 shrink-0 items-center justify-center">
|
|
871
|
+
<Zap className="h-4 w-4 text-primary-foreground/80" />
|
|
872
|
+
</span>
|
|
873
|
+
<h1 className="min-w-0 flex-1 truncate px-1 text-base font-bold text-primary-foreground sm:text-lg">
|
|
874
|
+
Danh mục thao tác
|
|
875
|
+
</h1>
|
|
876
|
+
<span className="hidden shrink-0 text-xs tabular-nums text-primary-foreground/70 md:inline">
|
|
877
|
+
{actions.length} thao tác
|
|
878
|
+
</span>
|
|
879
|
+
</div>
|
|
880
|
+
|
|
881
|
+
<div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
|
|
882
|
+
<div className="border-b border-border">
|
|
883
|
+
<div className="flex flex-wrap items-center gap-2 px-3 py-2 sm:px-4">
|
|
884
|
+
<div className="relative min-w-[150px] flex-1 sm:max-w-72">
|
|
885
|
+
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
886
|
+
<Input
|
|
887
|
+
placeholder="Tìm thao tác..."
|
|
888
|
+
className="h-8 rounded-md border border-border bg-card pl-8 text-sm"
|
|
889
|
+
value={searchTerm}
|
|
890
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
891
|
+
/>
|
|
892
|
+
</div>
|
|
893
|
+
{editable && (
|
|
894
|
+
<Button onClick={openCreate} className="h-8 px-3 text-sm">
|
|
895
|
+
+ Thêm thao tác
|
|
896
|
+
</Button>
|
|
897
|
+
)}
|
|
898
|
+
</div>
|
|
899
|
+
{/* Chips đếm theo nhóm — bấm để lọc */}
|
|
900
|
+
<div className="flex items-center gap-1 overflow-x-auto px-3 pb-2 sm:px-4 [scrollbar-width:thin]">
|
|
901
|
+
<button
|
|
902
|
+
type="button"
|
|
903
|
+
onClick={() => setFlowFilter(null)}
|
|
904
|
+
className={cn(
|
|
905
|
+
"shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
|
906
|
+
flowFilter === null
|
|
907
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
908
|
+
: "border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
|
909
|
+
)}
|
|
910
|
+
>
|
|
911
|
+
Tất cả
|
|
912
|
+
</button>
|
|
913
|
+
{Array.from(flows.entries()).map(([flow, list]) => {
|
|
914
|
+
const active = flowFilter === flow
|
|
915
|
+
const sensitive = SENSITIVE_FLOWS.has(flow)
|
|
916
|
+
return (
|
|
917
|
+
<button
|
|
918
|
+
key={flow}
|
|
919
|
+
type="button"
|
|
920
|
+
onClick={() => setFlowFilter(active ? null : flow)}
|
|
921
|
+
className={cn(
|
|
922
|
+
"flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
|
923
|
+
active
|
|
924
|
+
? sensitive
|
|
925
|
+
? "border-amber-500 bg-amber-500 text-white"
|
|
926
|
+
: "border-primary bg-primary text-primary-foreground"
|
|
927
|
+
: sensitive
|
|
928
|
+
? "border-amber-300 bg-amber-50 text-amber-700 hover:border-amber-400 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400"
|
|
929
|
+
: "border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
|
930
|
+
)}
|
|
931
|
+
>
|
|
932
|
+
{sensitive && <ShieldAlert className="h-3 w-3" />}
|
|
933
|
+
{flow}
|
|
934
|
+
<span
|
|
935
|
+
className={cn(
|
|
936
|
+
"rounded-full px-1 text-[10px] tabular-nums",
|
|
937
|
+
active ? "bg-white/20" : "bg-muted"
|
|
938
|
+
)}
|
|
939
|
+
>
|
|
940
|
+
{list.length}
|
|
941
|
+
</span>
|
|
942
|
+
</button>
|
|
943
|
+
)
|
|
944
|
+
})}
|
|
945
|
+
</div>
|
|
946
|
+
</div>
|
|
947
|
+
{REGISTRY_BANNER}
|
|
948
|
+
|
|
949
|
+
{Array.from(flows.entries()).map(([flow, list]) => {
|
|
950
|
+
if (flowFilter && flowFilter !== flow) return null
|
|
951
|
+
const visible = list.filter(matches)
|
|
952
|
+
if (visible.length === 0) return null
|
|
953
|
+
const sensitive = SENSITIVE_FLOWS.has(flow)
|
|
954
|
+
return (
|
|
955
|
+
<section key={flow}>
|
|
956
|
+
<div
|
|
957
|
+
className={cn(
|
|
958
|
+
"flex items-center gap-2 border-b border-border px-4 py-2",
|
|
959
|
+
sensitive
|
|
960
|
+
? "bg-amber-50 dark:bg-amber-950/40"
|
|
961
|
+
: "bg-muted/40"
|
|
962
|
+
)}
|
|
963
|
+
>
|
|
964
|
+
{sensitive && (
|
|
965
|
+
<ShieldAlert className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />
|
|
966
|
+
)}
|
|
967
|
+
<h3
|
|
968
|
+
className={cn(
|
|
969
|
+
"text-sm font-semibold",
|
|
970
|
+
sensitive
|
|
971
|
+
? "text-amber-700 dark:text-amber-400"
|
|
972
|
+
: "text-foreground"
|
|
973
|
+
)}
|
|
974
|
+
>
|
|
975
|
+
{flow}
|
|
976
|
+
</h3>
|
|
977
|
+
<span className="text-[11px] tabular-nums text-muted-foreground">
|
|
978
|
+
{visible.length} thao tác
|
|
979
|
+
</span>
|
|
980
|
+
{sensitive && (
|
|
981
|
+
<span className="text-[10px] text-amber-600/80 dark:text-amber-400/80">
|
|
982
|
+
— cân nhắc kỹ khi cấp
|
|
983
|
+
</span>
|
|
984
|
+
)}
|
|
985
|
+
</div>
|
|
986
|
+
<ul className="divide-y divide-border/60 border-b border-border">
|
|
987
|
+
{visible.map((a) => {
|
|
988
|
+
const meta = actionMeta?.[a.code]
|
|
989
|
+
const used = usage.get(a.code) ?? []
|
|
990
|
+
const isOpen = expanded.has(a.code)
|
|
991
|
+
const holders = isOpen ? roleUsage(a.code) : null
|
|
992
|
+
return (
|
|
993
|
+
<li key={a.code} className="px-4 py-2">
|
|
994
|
+
<button
|
|
995
|
+
type="button"
|
|
996
|
+
onClick={() => toggleExpand(a.code)}
|
|
997
|
+
className="flex w-full flex-wrap items-center gap-2 text-left"
|
|
998
|
+
>
|
|
999
|
+
{isOpen ? (
|
|
1000
|
+
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
1001
|
+
) : (
|
|
1002
|
+
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
1003
|
+
)}
|
|
1004
|
+
<span className="text-sm font-medium text-foreground">
|
|
1005
|
+
{meta?.label || a.name}
|
|
1006
|
+
</span>
|
|
1007
|
+
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
|
1008
|
+
{a.code}
|
|
1009
|
+
</code>
|
|
1010
|
+
<span className="ml-auto text-[11px] tabular-nums text-muted-foreground">
|
|
1011
|
+
dùng ở {used.length} chức năng
|
|
1012
|
+
</span>
|
|
1013
|
+
</button>
|
|
1014
|
+
{isOpen && editable && (
|
|
1015
|
+
<div className="mt-1 pl-5.5 sm:pl-6">
|
|
1016
|
+
<Button
|
|
1017
|
+
variant="ghost"
|
|
1018
|
+
size="sm"
|
|
1019
|
+
onClick={() => openEdit(a)}
|
|
1020
|
+
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
1021
|
+
>
|
|
1022
|
+
Sửa tên / mô tả
|
|
1023
|
+
</Button>
|
|
1024
|
+
</div>
|
|
1025
|
+
)}
|
|
1026
|
+
{(meta?.description || a.description) && (
|
|
1027
|
+
<p className="mt-0.5 pl-5.5 text-xs text-muted-foreground sm:pl-6">
|
|
1028
|
+
{meta?.description || a.description}
|
|
1029
|
+
</p>
|
|
1030
|
+
)}
|
|
1031
|
+
|
|
1032
|
+
{isOpen && (
|
|
1033
|
+
<div className="mt-2 space-y-2 rounded-lg border border-border/70 bg-muted/20 p-3 sm:ml-6">
|
|
1034
|
+
{/* Ai đang có quyền này */}
|
|
1035
|
+
<div>
|
|
1036
|
+
<p className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
1037
|
+
Vai trò đang được cấp
|
|
1038
|
+
</p>
|
|
1039
|
+
{holders === null ? (
|
|
1040
|
+
<p className="text-[11px] text-muted-foreground">
|
|
1041
|
+
Đang tải...
|
|
1042
|
+
</p>
|
|
1043
|
+
) : holders.length === 0 ? (
|
|
1044
|
+
<p className="text-[11px] text-muted-foreground">
|
|
1045
|
+
Chưa vai trò nào được cấp (Super Admin luôn
|
|
1046
|
+
có toàn quyền).
|
|
1047
|
+
</p>
|
|
1048
|
+
) : (
|
|
1049
|
+
<div className="flex flex-wrap gap-1">
|
|
1050
|
+
{holders.map(({ role, count }) => (
|
|
1051
|
+
<span
|
|
1052
|
+
key={role.id}
|
|
1053
|
+
title={`${role.name} — có "${meta?.label || a.name}" trên ${count} chức năng`}
|
|
1054
|
+
className="rounded-md border border-primary/40 bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary"
|
|
1055
|
+
>
|
|
1056
|
+
{role.name}
|
|
1057
|
+
<span className="ml-1 text-[10px] opacity-70">
|
|
1058
|
+
·{count}
|
|
1059
|
+
</span>
|
|
1060
|
+
</span>
|
|
1061
|
+
))}
|
|
1062
|
+
</div>
|
|
1063
|
+
)}
|
|
1064
|
+
</div>
|
|
1065
|
+
{/* Người dùng đang có (qua vai trò) */}
|
|
1066
|
+
<div>
|
|
1067
|
+
<p className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
1068
|
+
Người dùng đang có
|
|
1069
|
+
</p>
|
|
1070
|
+
{(() => {
|
|
1071
|
+
const uh = userHolders(holders)
|
|
1072
|
+
if (uh === null)
|
|
1073
|
+
return (
|
|
1074
|
+
<p className="text-[11px] text-muted-foreground">
|
|
1075
|
+
Đang tải...
|
|
1076
|
+
</p>
|
|
1077
|
+
)
|
|
1078
|
+
if (uh.length === 0)
|
|
1079
|
+
return (
|
|
1080
|
+
<p className="text-[11px] text-muted-foreground">
|
|
1081
|
+
Chưa người dùng nào (qua các vai trò trên).
|
|
1082
|
+
</p>
|
|
1083
|
+
)
|
|
1084
|
+
const shown = uh.slice(0, 15)
|
|
1085
|
+
return (
|
|
1086
|
+
<div className="flex flex-wrap gap-1">
|
|
1087
|
+
{shown.map((u) => (
|
|
1088
|
+
<span
|
|
1089
|
+
key={u.id}
|
|
1090
|
+
title={
|
|
1091
|
+
u.superAdmin
|
|
1092
|
+
? "Có quyền vì là Super Admin (toàn quyền)"
|
|
1093
|
+
: "Có quyền qua vai trò được cấp"
|
|
1094
|
+
}
|
|
1095
|
+
className={cn(
|
|
1096
|
+
"rounded-md border px-1.5 py-0.5 text-[11px]",
|
|
1097
|
+
u.superAdmin
|
|
1098
|
+
? "border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400"
|
|
1099
|
+
: "border-border bg-card text-foreground"
|
|
1100
|
+
)}
|
|
1101
|
+
>
|
|
1102
|
+
{u.label}
|
|
1103
|
+
{u.superAdmin && " ★"}
|
|
1104
|
+
</span>
|
|
1105
|
+
))}
|
|
1106
|
+
{uh.length > 15 && (
|
|
1107
|
+
<span className="text-[11px] text-muted-foreground">
|
|
1108
|
+
+{uh.length - 15} người khác
|
|
1109
|
+
</span>
|
|
1110
|
+
)}
|
|
1111
|
+
</div>
|
|
1112
|
+
)
|
|
1113
|
+
})()}
|
|
1114
|
+
</div>
|
|
1115
|
+
{/* Dùng ở chức năng nào */}
|
|
1116
|
+
<div>
|
|
1117
|
+
<p className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
1118
|
+
Dùng ở chức năng
|
|
1119
|
+
</p>
|
|
1120
|
+
{used.length === 0 ? (
|
|
1121
|
+
<p className="text-[11px] text-muted-foreground">
|
|
1122
|
+
Chưa chức năng nào khai thao tác này.
|
|
1123
|
+
</p>
|
|
1124
|
+
) : (
|
|
1125
|
+
<div className="flex flex-wrap gap-1">
|
|
1126
|
+
{used.map((u) => (
|
|
1127
|
+
<span
|
|
1128
|
+
key={u.code}
|
|
1129
|
+
className="rounded-md border border-border bg-card px-1.5 py-0.5 text-[11px] text-muted-foreground"
|
|
1130
|
+
>
|
|
1131
|
+
{u.name}
|
|
1132
|
+
{resourcePages?.[u.code] && (
|
|
1133
|
+
<code className="ml-1 font-mono text-[9px] opacity-70">
|
|
1134
|
+
{resourcePages[u.code]}
|
|
1135
|
+
</code>
|
|
1136
|
+
)}
|
|
1137
|
+
</span>
|
|
1138
|
+
))}
|
|
1139
|
+
</div>
|
|
1140
|
+
)}
|
|
1141
|
+
</div>
|
|
1142
|
+
</div>
|
|
1143
|
+
)}
|
|
1144
|
+
</li>
|
|
1145
|
+
)
|
|
1146
|
+
})}
|
|
1147
|
+
</ul>
|
|
1148
|
+
</section>
|
|
1149
|
+
)
|
|
1150
|
+
})}
|
|
1151
|
+
</div>
|
|
1152
|
+
|
|
1153
|
+
{/* Dialog thêm/sửa thao tác */}
|
|
1154
|
+
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
|
1155
|
+
<DialogContent className="sm:max-w-md">
|
|
1156
|
+
<DialogHeader>
|
|
1157
|
+
<DialogTitle>
|
|
1158
|
+
{editing ? "Sửa thao tác" : "Thêm thao tác mới"}
|
|
1159
|
+
</DialogTitle>
|
|
1160
|
+
</DialogHeader>
|
|
1161
|
+
<div className="space-y-3">
|
|
1162
|
+
<div className="space-y-1">
|
|
1163
|
+
<Label htmlFor="action-code">Mã (không dấu, gạch ngang)</Label>
|
|
1164
|
+
<Input
|
|
1165
|
+
id="action-code"
|
|
1166
|
+
value={form.code}
|
|
1167
|
+
disabled={!!editing}
|
|
1168
|
+
onChange={(e) =>
|
|
1169
|
+
setForm((f) => ({ ...f, code: e.target.value }))
|
|
1170
|
+
}
|
|
1171
|
+
placeholder="vd: print-label"
|
|
1172
|
+
className="font-mono"
|
|
1173
|
+
/>
|
|
1174
|
+
</div>
|
|
1175
|
+
<div className="space-y-1">
|
|
1176
|
+
<Label htmlFor="action-name">Tên hiển thị</Label>
|
|
1177
|
+
<Input
|
|
1178
|
+
id="action-name"
|
|
1179
|
+
value={form.name}
|
|
1180
|
+
onChange={(e) =>
|
|
1181
|
+
setForm((f) => ({ ...f, name: e.target.value }))
|
|
1182
|
+
}
|
|
1183
|
+
placeholder="vd: In tem giá"
|
|
1184
|
+
/>
|
|
1185
|
+
</div>
|
|
1186
|
+
<div className="space-y-1">
|
|
1187
|
+
<Label htmlFor="action-desc">
|
|
1188
|
+
Mô tả (dùng làm gì, ở trang nào)
|
|
1189
|
+
</Label>
|
|
1190
|
+
<Textarea
|
|
1191
|
+
id="action-desc"
|
|
1192
|
+
value={form.description}
|
|
1193
|
+
onChange={(e) =>
|
|
1194
|
+
setForm((f) => ({ ...f, description: e.target.value }))
|
|
1195
|
+
}
|
|
1196
|
+
rows={2}
|
|
1197
|
+
placeholder="vd: In tem giá sản phẩm từ trang Sản phẩm"
|
|
1198
|
+
/>
|
|
1199
|
+
</div>
|
|
1200
|
+
</div>
|
|
1201
|
+
<DialogFooter>
|
|
1202
|
+
<Button
|
|
1203
|
+
variant="ghost"
|
|
1204
|
+
onClick={() => setEditorOpen(false)}
|
|
1205
|
+
disabled={saving}
|
|
1206
|
+
>
|
|
1207
|
+
Hủy
|
|
1208
|
+
</Button>
|
|
1209
|
+
<Button onClick={saveAction} disabled={saving}>
|
|
1210
|
+
{saving ? "Đang lưu..." : "Lưu"}
|
|
1211
|
+
</Button>
|
|
1212
|
+
</DialogFooter>
|
|
1213
|
+
</DialogContent>
|
|
1214
|
+
</Dialog>
|
|
1215
|
+
</div>
|
|
1216
|
+
)
|
|
1217
|
+
}
|