@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 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.39",
4
+ "version": "0.1.40",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -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 v3 ("Mức truy cập 5 nấc" chốt với chủ app 2026-07):
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
- ([group, groupResources]) =>
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
- {/* Thanh TAB phân hệ */}
702
- <div className="flex items-center gap-1 overflow-x-auto px-3 pb-2 sm:px-4 [scrollbar-width:thin]">
703
- <button
704
- type="button"
705
- onClick={() => setActiveGroup("all")}
706
- className={cn(
707
- "shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
708
- activeGroup === "all" && !searching
709
- ? "border-primary bg-primary text-primary-foreground"
710
- : "border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground"
711
- )}
712
- >
713
- Tất cả
714
- </button>
715
- {allGroups.map((group) => {
716
- const { selected, total } = groupTabCount(group)
717
- const active = activeGroup === group && !searching
718
- return (
719
- <button
720
- key={group}
721
- type="button"
722
- onClick={() => {
723
- setActiveGroup(group)
724
- setSearchTerm("")
725
- }}
726
- className={cn(
727
- "flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
728
- active
729
- ? "border-primary bg-primary text-primary-foreground"
730
- : "border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground"
731
- )}
732
- >
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-xs font-semibold uppercase tracking-wide text-foreground">
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
- <Button
798
- type="button"
799
- variant="ghost"
800
- size="sm"
801
- onClick={() => toggleAllForGroup(groupResources, false)}
802
- disabled={selected === 0}
803
- className="h-6 px-2 text-xs text-muted-foreground hover:text-destructive"
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
- Bỏ chọn
806
- </Button>
807
- <Button
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
- variant="ghost"
810
- size="sm"
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
- Chọn tất cả
816
- </Button>
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
  })