@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
@@ -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.42",
4
+ "version": "0.1.43",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -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 &amp; 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
+ }