@goplusvn/core 0.1.39 → 0.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/rbac/pages/role-form-page.tsx +152 -111
package/package.json
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
// Trang tạo/sửa VAI TRÒ — bar navy 1 DÒNG kiểu chi tiết đơn bán hàng.
|
|
4
4
|
//
|
|
5
|
-
// Ma trận quyền
|
|
5
|
+
// Ma trận quyền v4 — 2 TẦNG kiểu Odoo (chốt với chủ app 2026-07):
|
|
6
|
+
// TẦNG 1 (mặt tiền): mỗi PHÂN HỆ một dropdown mức
|
|
7
|
+
// [Không truy cập | Tra cứu | Xem | Nhân viên | Quản lý]
|
|
8
|
+
// — chọn mức áp trọn bộ quyền chuẩn của phân hệ; lệch chuẩn → "Tùy chỉnh".
|
|
9
|
+
// TẦNG 2 (bấm "Tùy chỉnh ▸" mới bung): chi tiết từng tài nguyên như v3:
|
|
6
10
|
// - TAB theo phân hệ (đếm x/y realtime).
|
|
7
11
|
// - Mỗi tài nguyên MỘT DÒNG: [Ẩn | Tra cứu | Xem | Ghi | Toàn quyền]
|
|
8
12
|
// Ẩn = không menu, không quyền gì
|
|
@@ -106,6 +110,24 @@ const LEVELS: Array<{ key: AccessLevel; label: string; hint: string }> = [
|
|
|
106
110
|
{ key: "full", label: "Toàn quyền", hint: "Ghi + Xóa" },
|
|
107
111
|
]
|
|
108
112
|
|
|
113
|
+
// Mức TẦNG 1 (phân hệ) — thang Odoo-style.
|
|
114
|
+
type GroupLevel = "none" | "lookup" | "view" | "staff" | "manager"
|
|
115
|
+
const GROUP_LEVELS: Array<{ key: GroupLevel; label: string; hint: string }> = [
|
|
116
|
+
{ key: "none", label: "Không truy cập", hint: "Ẩn toàn bộ phân hệ" },
|
|
117
|
+
{ key: "lookup", label: "Tra cứu", hint: "Chỉ chọn trong combo — không thấy menu" },
|
|
118
|
+
{ key: "view", label: "Xem", hint: "Thấy menu + đọc dữ liệu" },
|
|
119
|
+
{
|
|
120
|
+
key: "staff",
|
|
121
|
+
label: "Nhân viên",
|
|
122
|
+
hint: "Xem + tạo/sửa/xuất/nhập + thao tác nghiệp vụ hằng ngày",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
key: "manager",
|
|
126
|
+
label: "Quản lý",
|
|
127
|
+
hint: "Toàn quyền: thêm xóa, duyệt, hủy, sửa giá, xem giá vốn…",
|
|
128
|
+
},
|
|
129
|
+
]
|
|
130
|
+
|
|
109
131
|
// --- Icon + màu theo LOẠI action (suy từ mã) — mọi action nghiệp vụ đều có
|
|
110
132
|
// icon mà không cần cấu hình từng cái. ---
|
|
111
133
|
const ACTION_VISUAL_RULES: Array<{
|
|
@@ -156,6 +178,8 @@ export interface ActionMetaEntry {
|
|
|
156
178
|
description?: string
|
|
157
179
|
flow?: string
|
|
158
180
|
requires?: string[]
|
|
181
|
+
/** Mức tầng-phân-hệ chứa action này: staff (Nhân viên) | manager (Quản lý). */
|
|
182
|
+
level?: "staff" | "manager"
|
|
159
183
|
}
|
|
160
184
|
|
|
161
185
|
export interface RoleFormPageProps {
|
|
@@ -228,10 +252,21 @@ export function RoleFormPage({
|
|
|
228
252
|
permissions: initialData?.permissions || ([] as string[]),
|
|
229
253
|
})
|
|
230
254
|
|
|
231
|
-
const [activeGroup, setActiveGroup] = React.useState<string>("all")
|
|
232
255
|
const [expandedRows, setExpandedRows] = React.useState<Set<string>>(
|
|
233
256
|
() => new Set()
|
|
234
257
|
)
|
|
258
|
+
const [expandedGroups, setExpandedGroups] = React.useState<Set<string>>(
|
|
259
|
+
() => new Set()
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
const toggleGroupExpand = (g: string) => {
|
|
263
|
+
setExpandedGroups((prev) => {
|
|
264
|
+
const next = new Set(prev)
|
|
265
|
+
if (next.has(g)) next.delete(g)
|
|
266
|
+
else next.add(g)
|
|
267
|
+
return next
|
|
268
|
+
})
|
|
269
|
+
}
|
|
235
270
|
|
|
236
271
|
const toggleRowExpand = (code: string) => {
|
|
237
272
|
setExpandedRows((prev) => {
|
|
@@ -360,20 +395,8 @@ export function RoleFormPage({
|
|
|
360
395
|
return groups
|
|
361
396
|
}, [filteredMatrix])
|
|
362
397
|
|
|
363
|
-
const allGroups = React.useMemo(() => {
|
|
364
|
-
const seen: string[] = []
|
|
365
|
-
permissionMatrix.forEach((r) => {
|
|
366
|
-
if (!seen.includes(r.group)) seen.push(r.group)
|
|
367
|
-
})
|
|
368
|
-
return seen
|
|
369
|
-
}, [permissionMatrix])
|
|
370
|
-
|
|
371
|
-
React.useEffect(() => {
|
|
372
|
-
if (activeGroup !== "all" && !allGroups.includes(activeGroup)) {
|
|
373
|
-
setActiveGroup("all")
|
|
374
|
-
}
|
|
375
|
-
}, [allGroups, activeGroup])
|
|
376
398
|
|
|
399
|
+
// Đang search → mọi nhóm khớp tự bung phần chi tiết.
|
|
377
400
|
const searching = searchTerm.trim().length > 0
|
|
378
401
|
|
|
379
402
|
const has = React.useCallback(
|
|
@@ -435,6 +458,70 @@ export function RoleFormPage({
|
|
|
435
458
|
})
|
|
436
459
|
}
|
|
437
460
|
|
|
461
|
+
// ── TẦNG 1: mức theo PHÂN HỆ ──────────────────────────────────────────
|
|
462
|
+
const canonicalForResource = React.useCallback(
|
|
463
|
+
(r: MatrixRow, level: GroupLevel): string[] => {
|
|
464
|
+
switch (level) {
|
|
465
|
+
case "none":
|
|
466
|
+
return []
|
|
467
|
+
case "lookup":
|
|
468
|
+
return r.hasLookup ? [`lookup:${r.resource}`] : []
|
|
469
|
+
case "view":
|
|
470
|
+
return r.actions.includes("view") ? [`view:${r.resource}`] : []
|
|
471
|
+
case "staff": {
|
|
472
|
+
const staffBiz = r.businessActions.filter(
|
|
473
|
+
(a) => actionMeta?.[a]?.level !== "manager"
|
|
474
|
+
)
|
|
475
|
+
return [
|
|
476
|
+
...(r.actions.includes("view") ? ["view"] : []),
|
|
477
|
+
...r.writeCodes,
|
|
478
|
+
...staffBiz,
|
|
479
|
+
].map((a) => `${a}:${r.resource}`)
|
|
480
|
+
}
|
|
481
|
+
case "manager":
|
|
482
|
+
return r.actions.map((a) => `${a}:${r.resource}`)
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
[actionMeta]
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
const canonicalForGroup = React.useCallback(
|
|
489
|
+
(rows: MatrixRow[], level: GroupLevel) =>
|
|
490
|
+
rows.flatMap((r) => canonicalForResource(r, level)),
|
|
491
|
+
[canonicalForResource]
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
/** Mức hiện tại của phân hệ — "custom" nếu không khớp mức chuẩn nào. */
|
|
495
|
+
const deriveGroupLevel = React.useCallback(
|
|
496
|
+
(rows: MatrixRow[]): GroupLevel | "custom" => {
|
|
497
|
+
const current = new Set(
|
|
498
|
+
rows
|
|
499
|
+
.flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`))
|
|
500
|
+
.filter((c) => formData.permissions.includes(c))
|
|
501
|
+
)
|
|
502
|
+
for (const l of GROUP_LEVELS) {
|
|
503
|
+
const canonical = canonicalForGroup(rows, l.key)
|
|
504
|
+
if (
|
|
505
|
+
canonical.length === current.size &&
|
|
506
|
+
canonical.every((c) => current.has(c))
|
|
507
|
+
) {
|
|
508
|
+
return l.key
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return "custom"
|
|
512
|
+
},
|
|
513
|
+
[formData.permissions, canonicalForGroup]
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
const setGroupLevel = (rows: MatrixRow[], level: GroupLevel) => {
|
|
517
|
+
const all = rows.flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`))
|
|
518
|
+
const canonical = canonicalForGroup(rows, level)
|
|
519
|
+
mutatePermissions((set) => {
|
|
520
|
+
all.forEach((c) => set.delete(c))
|
|
521
|
+
canonical.forEach((c) => set.add(c))
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
|
|
438
525
|
// ── Thao tác nghiệp vụ + requires ─────────────────────────────────────
|
|
439
526
|
const toggleBusinessAction = (r: MatrixRow, code: string) => {
|
|
440
527
|
const key = `${code}:${r.resource}`
|
|
@@ -474,24 +561,6 @@ export function RoleFormPage({
|
|
|
474
561
|
[formData.permissions]
|
|
475
562
|
)
|
|
476
563
|
|
|
477
|
-
const toggleAllForGroup = (rows: MatrixRow[], enable: boolean) => {
|
|
478
|
-
const codes = groupPermissionCodes(rows)
|
|
479
|
-
mutatePermissions((set) => {
|
|
480
|
-
if (enable) codes.forEach((c) => set.add(c))
|
|
481
|
-
else codes.forEach((c) => set.delete(c))
|
|
482
|
-
})
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
const groupTabCount = React.useCallback(
|
|
486
|
-
(groupName: string) => {
|
|
487
|
-
const rows = permissionMatrix.filter((r) => r.group === groupName)
|
|
488
|
-
const codes = rows.flatMap((r) =>
|
|
489
|
-
r.actions.map((a) => `${a}:${r.resource}`)
|
|
490
|
-
)
|
|
491
|
-
return { selected: countSelected(codes), total: codes.length }
|
|
492
|
-
},
|
|
493
|
-
[permissionMatrix, countSelected]
|
|
494
|
-
)
|
|
495
564
|
|
|
496
565
|
const applyTemplate = (templateCode: string) => {
|
|
497
566
|
const tpl = roleTemplates?.find((t) => t.code === templateCode)
|
|
@@ -538,9 +607,7 @@ export function RoleFormPage({
|
|
|
538
607
|
}
|
|
539
608
|
|
|
540
609
|
const visibleGroups = Object.entries(groupedMatrix).filter(
|
|
541
|
-
([
|
|
542
|
-
groupResources.length > 0 &&
|
|
543
|
-
(searching || activeGroup === "all" || activeGroup === group)
|
|
610
|
+
([, groupResources]) => groupResources.length > 0
|
|
544
611
|
)
|
|
545
612
|
|
|
546
613
|
return (
|
|
@@ -698,61 +765,9 @@ export function RoleFormPage({
|
|
|
698
765
|
</p>
|
|
699
766
|
</div>
|
|
700
767
|
|
|
701
|
-
{/*
|
|
702
|
-
<div className="flex items-center gap-
|
|
703
|
-
|
|
704
|
-
type="button"
|
|
705
|
-
onClick={() => setActiveGroup("all")}
|
|
706
|
-
className={cn(
|
|
707
|
-
"shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
|
708
|
-
activeGroup === "all" && !searching
|
|
709
|
-
? "border-primary bg-primary text-primary-foreground"
|
|
710
|
-
: "border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
|
711
|
-
)}
|
|
712
|
-
>
|
|
713
|
-
Tất cả
|
|
714
|
-
</button>
|
|
715
|
-
{allGroups.map((group) => {
|
|
716
|
-
const { selected, total } = groupTabCount(group)
|
|
717
|
-
const active = activeGroup === group && !searching
|
|
718
|
-
return (
|
|
719
|
-
<button
|
|
720
|
-
key={group}
|
|
721
|
-
type="button"
|
|
722
|
-
onClick={() => {
|
|
723
|
-
setActiveGroup(group)
|
|
724
|
-
setSearchTerm("")
|
|
725
|
-
}}
|
|
726
|
-
className={cn(
|
|
727
|
-
"flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
|
728
|
-
active
|
|
729
|
-
? "border-primary bg-primary text-primary-foreground"
|
|
730
|
-
: "border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
|
731
|
-
)}
|
|
732
|
-
>
|
|
733
|
-
{group}
|
|
734
|
-
<span
|
|
735
|
-
className={cn(
|
|
736
|
-
"rounded-full px-1 text-[10px] tabular-nums",
|
|
737
|
-
active
|
|
738
|
-
? "bg-primary-foreground/20"
|
|
739
|
-
: selected > 0
|
|
740
|
-
? selected === total
|
|
741
|
-
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
|
742
|
-
: "bg-muted text-foreground"
|
|
743
|
-
: "bg-muted"
|
|
744
|
-
)}
|
|
745
|
-
>
|
|
746
|
-
{selected}/{total}
|
|
747
|
-
</span>
|
|
748
|
-
</button>
|
|
749
|
-
)
|
|
750
|
-
})}
|
|
751
|
-
</div>
|
|
752
|
-
|
|
753
|
-
{/* Chú giải 5 nấc — 1 dòng mảnh */}
|
|
754
|
-
<div className="hidden items-center gap-3 border-t border-border/60 px-4 py-1 text-[11px] text-muted-foreground md:flex">
|
|
755
|
-
{LEVELS.map((l) => (
|
|
768
|
+
{/* Chú giải mức phân hệ — 1 dòng mảnh */}
|
|
769
|
+
<div className="hidden flex-wrap items-center gap-x-3 gap-y-0.5 border-t border-border/60 px-4 py-1 text-[11px] text-muted-foreground md:flex">
|
|
770
|
+
{GROUP_LEVELS.map((l) => (
|
|
756
771
|
<span key={l.key} className="flex items-center gap-1">
|
|
757
772
|
<span className="font-medium text-foreground">{l.label}</span>
|
|
758
773
|
<span className="opacity-80">= {l.hint}</span>
|
|
@@ -775,11 +790,13 @@ export function RoleFormPage({
|
|
|
775
790
|
const selected = countSelected(codes)
|
|
776
791
|
const total = codes.length
|
|
777
792
|
const isFull = total > 0 && selected === total
|
|
793
|
+
const groupLevel = deriveGroupLevel(groupResources)
|
|
794
|
+
const groupExpanded = searching || expandedGroups.has(group)
|
|
778
795
|
|
|
779
796
|
return (
|
|
780
797
|
<section key={group}>
|
|
781
|
-
<div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
|
|
782
|
-
<h3 className="truncate text-
|
|
798
|
+
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
|
|
799
|
+
<h3 className="truncate text-sm font-semibold text-foreground">
|
|
783
800
|
{group}
|
|
784
801
|
</h3>
|
|
785
802
|
<span
|
|
@@ -793,30 +810,53 @@ export function RoleFormPage({
|
|
|
793
810
|
{groupResources.length} tài nguyên · {selected}/{total}
|
|
794
811
|
{isFull && <Check className="ml-0.5 inline h-3 w-3" />}
|
|
795
812
|
</span>
|
|
796
|
-
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
813
|
+
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
|
814
|
+
{groupLevel === "custom" && (
|
|
815
|
+
<Badge
|
|
816
|
+
variant="outline"
|
|
817
|
+
className="h-6 border-amber-300 bg-amber-50 px-1.5 text-[10px] font-medium text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400"
|
|
818
|
+
>
|
|
819
|
+
Tùy chỉnh
|
|
820
|
+
</Badge>
|
|
821
|
+
)}
|
|
822
|
+
<Select
|
|
823
|
+
value={groupLevel === "custom" ? undefined : groupLevel}
|
|
824
|
+
onValueChange={(v) =>
|
|
825
|
+
setGroupLevel(groupResources, v as GroupLevel)
|
|
826
|
+
}
|
|
804
827
|
>
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
828
|
+
<SelectTrigger className="h-7 w-40 rounded-md border border-border bg-card text-xs">
|
|
829
|
+
<SelectValue placeholder="Tùy chỉnh..." />
|
|
830
|
+
</SelectTrigger>
|
|
831
|
+
<SelectContent className="rounded-lg">
|
|
832
|
+
{GROUP_LEVELS.map((l) => (
|
|
833
|
+
<SelectItem key={l.key} value={l.key}>
|
|
834
|
+
<span className="flex flex-col">
|
|
835
|
+
<span>{l.label}</span>
|
|
836
|
+
<span className="text-[10px] text-muted-foreground">
|
|
837
|
+
{l.hint}
|
|
838
|
+
</span>
|
|
839
|
+
</span>
|
|
840
|
+
</SelectItem>
|
|
841
|
+
))}
|
|
842
|
+
</SelectContent>
|
|
843
|
+
</Select>
|
|
844
|
+
<button
|
|
808
845
|
type="button"
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
onClick={() => toggleAllForGroup(groupResources, true)}
|
|
812
|
-
disabled={isFull}
|
|
813
|
-
className="h-6 px-2 text-xs font-semibold text-primary hover:text-primary"
|
|
846
|
+
onClick={() => toggleGroupExpand(group)}
|
|
847
|
+
className="flex h-7 items-center gap-1 rounded-md border border-border bg-card px-2 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
814
848
|
>
|
|
815
|
-
|
|
816
|
-
|
|
849
|
+
{groupExpanded ? (
|
|
850
|
+
<ChevronDown className="h-3 w-3" />
|
|
851
|
+
) : (
|
|
852
|
+
<ChevronRight className="h-3 w-3" />
|
|
853
|
+
)}
|
|
854
|
+
Tùy chỉnh
|
|
855
|
+
</button>
|
|
817
856
|
</div>
|
|
818
857
|
</div>
|
|
819
858
|
|
|
859
|
+
{groupExpanded && (
|
|
820
860
|
<ul className="divide-y divide-border/60 border-b border-border">
|
|
821
861
|
{groupResources.map((r) => {
|
|
822
862
|
const level = deriveLevel(r)
|
|
@@ -989,6 +1029,7 @@ export function RoleFormPage({
|
|
|
989
1029
|
)
|
|
990
1030
|
})}
|
|
991
1031
|
</ul>
|
|
1032
|
+
)}
|
|
992
1033
|
</section>
|
|
993
1034
|
)
|
|
994
1035
|
})
|