@goplusvn/core 0.1.37 → 0.1.38

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.37",
4
+ "version": "0.1.38",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -115,9 +115,14 @@ interface Permissions {
115
115
 
116
116
  export interface ActionListPageProps {
117
117
  permissions?: Permissions
118
+ /** Catalog hành động do Permission Registry trong code quản lý — ẩn mọi nút ghi. */
119
+ readOnly?: boolean
118
120
  }
119
121
 
120
- export function ActionListPage({ permissions = {} }: ActionListPageProps) {
122
+ export function ActionListPage({
123
+ permissions = {},
124
+ readOnly = false,
125
+ }: ActionListPageProps) {
121
126
  const [loading, setLoading] = useState(true)
122
127
  const [actions, setActions] = useState<Action[]>([])
123
128
  const [usage, setUsage] = useState<UsageMap>({})
@@ -140,9 +145,9 @@ export function ActionListPage({ permissions = {} }: ActionListPageProps) {
140
145
  const [toDelete, setToDelete] = useState<Action | null>(null)
141
146
  const [isDeleting, setIsDeleting] = useState(false)
142
147
 
143
- const canCreate = !!permissions.create
144
- const canUpdate = !!permissions.update
145
- const canDelete = !!permissions.delete
148
+ const canCreate = !readOnly && !!permissions.create
149
+ const canUpdate = !readOnly && !!permissions.update
150
+ const canDelete = !readOnly && !!permissions.delete
146
151
 
147
152
  const fetchData = async () => {
148
153
  try {
@@ -212,7 +212,20 @@ const normalize = (s: string) =>
212
212
  .replace(/[\u0300-\u036f]/g, "")
213
213
  .replace(/đ/g, "d")
214
214
 
215
- export function ResourceListPage() {
215
+ export interface ResourceListPageProps {
216
+ /** Danh mục do Permission Registry trong code quản lý: ẩn nút Thêm/Sync/
217
+ * Action-manager, thao tác sửa/xóa hiện toast hướng dẫn thay vì mở dialog. */
218
+ readOnly?: boolean
219
+ }
220
+
221
+ export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {}) {
222
+ const registryToast = () =>
223
+ toast.info("Danh mục do Permission Registry quản lý", {
224
+ description:
225
+ "Sửa file <feature>.permissions.ts trong code rồi chạy rbac-sync — không chỉnh tay ở đây.",
226
+ })
227
+ const guardWrite = <T extends (...args: any[]) => any>(fn: T): T =>
228
+ (readOnly ? ((() => registryToast()) as unknown as T) : fn)
216
229
  const [loading, setLoading] = useState(true)
217
230
  const [resources, setResources] = useState<Resource[]>([])
218
231
  const [allActions, setAllActions] = useState<Action[]>([])
@@ -707,37 +720,57 @@ export function ResourceListPage() {
707
720
  className={cn("h-4 w-4", isRefreshing && "animate-spin")}
708
721
  />
709
722
  </Button>
710
- <Button
711
- variant="ghost"
712
- size="icon"
713
- onClick={handleSync}
714
- disabled={isSyncing}
715
- title="Đồng bộ tài nguyên"
716
- className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
717
- >
718
- <DatabaseBackup
719
- className={cn("h-4 w-4", isSyncing && "animate-pulse")}
720
- />
721
- </Button>
722
- <Button
723
- variant="ghost"
724
- size="icon"
725
- onClick={openActionManager}
726
- title="Quản lý hành động"
727
- className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
728
- >
729
- <Zap className="h-4 w-4" />
730
- </Button>
731
- <Button
732
- onClick={openNewResource}
733
- className="h-8 shrink-0 bg-primary-foreground px-2.5 text-primary hover:bg-primary-foreground/90 sm:px-3"
734
- >
735
- <Plus className="h-4 w-4" />
736
- <span className="ml-1.5 hidden sm:inline">Thêm tài nguyên</span>
737
- <span className="ml-1.5 sm:hidden">Thêm</span>
738
- </Button>
723
+ {!readOnly && (
724
+ <>
725
+ <Button
726
+ variant="ghost"
727
+ size="icon"
728
+ onClick={handleSync}
729
+ disabled={isSyncing}
730
+ title="Đồng bộ tài nguyên"
731
+ className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
732
+ >
733
+ <DatabaseBackup
734
+ className={cn("h-4 w-4", isSyncing && "animate-pulse")}
735
+ />
736
+ </Button>
737
+ <Button
738
+ variant="ghost"
739
+ size="icon"
740
+ onClick={openActionManager}
741
+ title="Quản lý hành động"
742
+ className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
743
+ >
744
+ <Zap className="h-4 w-4" />
745
+ </Button>
746
+ <Button
747
+ onClick={openNewResource}
748
+ className="h-8 shrink-0 bg-primary-foreground px-2.5 text-primary hover:bg-primary-foreground/90 sm:px-3"
749
+ >
750
+ <Plus className="h-4 w-4" />
751
+ <span className="ml-1.5 hidden sm:inline">Thêm tài nguyên</span>
752
+ <span className="ml-1.5 sm:hidden">Thêm</span>
753
+ </Button>
754
+ </>
755
+ )}
739
756
  </div>
740
757
 
758
+ {readOnly && (
759
+ <div className="mt-3 flex items-start gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground lg:mx-3">
760
+ <Shield className="mt-0.5 h-3.5 w-3.5 shrink-0" />
761
+ <span>
762
+ Danh mục tài nguyên &amp; hành động do{" "}
763
+ <span className="font-medium text-foreground">
764
+ Permission Registry
765
+ </span>{" "}
766
+ trong code quản lý (file{" "}
767
+ <code className="font-mono">&lt;feature&gt;.permissions.ts</code> +{" "}
768
+ <code className="font-mono">rbac-sync</code>). Trang này chỉ để tra
769
+ cứu; cấp quyền cho vai trò làm ở trang Vai trò.
770
+ </span>
771
+ </div>
772
+ )}
773
+
741
774
  {/* ===== Khối trắng liền: toolbar sticky + nhóm + hàng tài nguyên ===== */}
742
775
  <div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
743
776
  {/* Toolbar sticky */}
@@ -868,9 +901,9 @@ export function ResourceListPage() {
868
901
  categorized={categorizedActions}
869
902
  expanded={expandedRows.has(resource.id)}
870
903
  onToggleExpand={() => toggleRow(resource.id)}
871
- onToggleAction={toggleAction}
872
- onUpdateActions={handleUpdateActions}
873
- onEditResource={openEditResource}
904
+ onToggleAction={guardWrite(toggleAction)}
905
+ onUpdateActions={guardWrite(handleUpdateActions)}
906
+ onEditResource={guardWrite(openEditResource)}
874
907
  />
875
908
  ))}
876
909
  </ul>
@@ -886,10 +919,10 @@ export function ResourceListPage() {
886
919
  categorized={categorizedActions}
887
920
  expanded={expandedRows.has(resource.id)}
888
921
  onToggleExpand={() => toggleRow(resource.id)}
889
- onToggleAction={toggleAction}
890
- onUpdateActions={handleUpdateActions}
891
- onEditResource={openEditResource}
892
- onDeleteResource={requestDeleteResource}
922
+ onToggleAction={guardWrite(toggleAction)}
923
+ onUpdateActions={guardWrite(handleUpdateActions)}
924
+ onEditResource={guardWrite(openEditResource)}
925
+ onDeleteResource={guardWrite(requestDeleteResource)}
893
926
  />
894
927
  ))}
895
928
  </div>
@@ -2,15 +2,23 @@
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
  // (← tên inline · chip mã + copy · CHIP TRẠNG THÁI màu bấm-đổi · mô tả ·
5
- // Hủy/Lưu), full width; khối trắng: toolbar sticky (đếm x/y quyền realtime),
6
- // nhóm = dòng mảnh, tài nguyên = dòng + CHIP quyền (checkbox + icon lucide
7
- // theo loại action, prop actionLabels bổ sung nhãn nghiệp vụ app).
8
- // API contract: POST /api/roles, PUT /api/roles/:id.
5
+ // Hủy/Lưu), full width.
6
+ //
7
+ // Ma trận quyền (redesign 2026-07 "Phase 3" chuẩn ERP):
8
+ // - TAB theo phân hệ (group) thay dropdown — cuộn ngang, đếm x/y realtime.
9
+ // - Mỗi phân hệ là BẢNG cột chuẩn: Xem/Tạo/Sửa/Xóa/Xuất/Nhập/Tra cứu
10
+ // (checkbox; "·" khi resource không khai action đó) + cột "Đặc thù" chứa
11
+ // chip action nghiệp vụ (duyệt, sửa giá, đồng bộ MISA…) + switch "Tất cả".
12
+ // - Bấm TIÊU ĐỀ CỘT để tick/bỏ cả cột trong phân hệ.
13
+ // - `roleTemplates` (tùy chọn, additive): bộ quyền mẫu khi TẠO role mới.
14
+ // API contract giữ nguyên: POST /api/roles, PUT /api/roles/:id, quyền dạng
15
+ // `${action}:${resource}`.
9
16
  import * as React from "react"
10
17
  import { useRouter } from "next/navigation"
11
18
  import {
12
19
  Badge,
13
20
  Button,
21
+ Checkbox,
14
22
  DynamicIcon,
15
23
  Input,
16
24
  Select,
@@ -28,11 +36,10 @@ import {
28
36
  Ban,
29
37
  Bot,
30
38
  Check,
31
- ChevronDown,
32
- ChevronRight,
33
39
  Copy,
34
40
  Download,
35
41
  Eye,
42
+ LayoutTemplate,
36
43
  PenLine,
37
44
  Pencil,
38
45
  Plus,
@@ -73,8 +80,21 @@ const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
73
80
  delete_items: { label: "Xóa chi tiết", icon: "❌", color: "text-rose-600" },
74
81
  }
75
82
 
76
- // --- Icon + màu theo LOẠI action (suy từ mã, giống ACTION_TONES trang tài
77
- // nguyên) mọi action đều icon mà không cần cấu hình từng cái. ---
83
+ // Cột chuẩn của bảng quyền thứ tự cố định, nhãn ngắn. Action ngoài danh
84
+ // sách này rơi vào cột "Đặc thù".
85
+ const STANDARD_COLUMNS: Array<{ code: string; label: string }> = [
86
+ { code: "view", label: "Xem" },
87
+ { code: "create", label: "Tạo" },
88
+ { code: "update", label: "Sửa" },
89
+ { code: "delete", label: "Xóa" },
90
+ { code: "export", label: "Xuất" },
91
+ { code: "import", label: "Nhập" },
92
+ { code: "lookup", label: "Tra cứu" },
93
+ ]
94
+ const STANDARD_CODES = new Set(STANDARD_COLUMNS.map((c) => c.code))
95
+
96
+ // --- Icon + màu theo LOẠI action (suy từ mã) — mọi action đặc thù đều có
97
+ // icon mà không cần cấu hình từng cái. ---
78
98
  const ACTION_VISUAL_RULES: Array<{
79
99
  re: RegExp
80
100
  icon: LucideIcon
@@ -86,7 +106,7 @@ const ACTION_VISUAL_RULES: Array<{
86
106
  { re: /^(delete|remove)/, icon: Trash2, cls: "text-rose-500" },
87
107
  { re: /^(cancel|void|reject)/, icon: Ban, cls: "text-rose-500" },
88
108
  {
89
- re: /^(approve|confirm|verify|accept)/,
109
+ re: /^(approve|confirm|verify|accept|complete)/,
90
110
  icon: BadgeCheck,
91
111
  cls: "text-violet-500",
92
112
  },
@@ -94,8 +114,8 @@ const ACTION_VISUAL_RULES: Array<{
94
114
  { re: /^import/, icon: Download, cls: "text-purple-500" },
95
115
  { re: /^export/, icon: Upload, cls: "text-indigo-500" },
96
116
  { re: /^print/, icon: Printer, cls: "text-cyan-600" },
97
- { re: /^sync/, icon: RefreshCw, cls: "text-cyan-600" },
98
- { re: /^(manage|config)/, icon: Settings2, cls: "text-orange-500" },
117
+ { re: /^(sync|unlink)/, icon: RefreshCw, cls: "text-cyan-600" },
118
+ { re: /^(manage|config|close|reopen)/, icon: Settings2, cls: "text-orange-500" },
99
119
  { re: /^auto/, icon: Bot, cls: "text-purple-500" },
100
120
  ]
101
121
 
@@ -105,6 +125,15 @@ const getActionVisual = (code: string) =>
105
125
  cls: "text-slate-400",
106
126
  }
107
127
 
128
+ /** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa, vd từ
129
+ * Permission Registry của app). permissions dạng `${action}:${resource}`. */
130
+ export interface RoleTemplate {
131
+ code: string
132
+ label: string
133
+ description?: string
134
+ permissions: string[]
135
+ }
136
+
108
137
  export interface RoleFormPageProps {
109
138
  dictionary: any
110
139
  permissions?: CrudPermissions
@@ -136,6 +165,8 @@ export interface RoleFormPageProps {
136
165
  permissions: string[]
137
166
  }
138
167
  actionLabels?: Record<string, ActionLabelConfig>
168
+ /** Bộ quyền mẫu hiện ở chế độ tạo mới (additive — không truyền thì ẩn). */
169
+ roleTemplates?: RoleTemplate[]
139
170
  }
140
171
 
141
172
  export function RoleFormPage({
@@ -147,6 +178,7 @@ export function RoleFormPage({
147
178
  mode,
148
179
  initialData,
149
180
  actionLabels,
181
+ roleTemplates,
150
182
  }: RoleFormPageProps) {
151
183
  const router = useRouter()
152
184
 
@@ -166,17 +198,7 @@ export function RoleFormPage({
166
198
  permissions: initialData?.permissions || ([] as string[]),
167
199
  })
168
200
 
169
- const [activeGroup, setActiveGroup] = React.useState<string | null>("all")
170
- const [collapsedGroups, setCollapsedGroups] = React.useState<
171
- Record<string, boolean>
172
- >({})
173
-
174
- const toggleGroupCollapse = (groupName: string) => {
175
- setCollapsedGroups((prev) => ({
176
- ...prev,
177
- [groupName]: !prev[groupName],
178
- }))
179
- }
201
+ const [activeGroup, setActiveGroup] = React.useState<string>("all")
180
202
 
181
203
  const getLocalizedName = React.useCallback(
182
204
  (key: string | null) => {
@@ -197,6 +219,14 @@ export function RoleFormPage({
197
219
  [dictionary]
198
220
  )
199
221
 
222
+ const actionName = React.useCallback(
223
+ (code: string) => {
224
+ const fromDb = actions.find((a) => a.code === code)?.name
225
+ return fromDb || mergedActionLabels[code]?.label || code
226
+ },
227
+ [actions, mergedActionLabels]
228
+ )
229
+
200
230
  const permissionMatrix = React.useMemo(() => {
201
231
  return resources.map((resource) => {
202
232
  let allowedActions: string[] = []
@@ -243,6 +273,7 @@ export function RoleFormPage({
243
273
  group: getLocalizedName(resource.group) || "Khác",
244
274
  description: resource.description,
245
275
  actions: allowedActions,
276
+ extraActions: allowedActions.filter((a) => !STANDARD_CODES.has(a)),
246
277
  }
247
278
  })
248
279
  }, [resources, actions, getLocalizedName])
@@ -276,19 +307,23 @@ export function RoleFormPage({
276
307
  return groups
277
308
  }, [filteredMatrix])
278
309
 
310
+ // Tab dựng theo TOÀN BỘ matrix (không theo search) để thanh tab ổn định.
311
+ const allGroups = React.useMemo(() => {
312
+ const seen: string[] = []
313
+ permissionMatrix.forEach((r) => {
314
+ if (!seen.includes(r.group)) seen.push(r.group)
315
+ })
316
+ return seen
317
+ }, [permissionMatrix])
318
+
279
319
  React.useEffect(() => {
280
- const groups = Object.keys(groupedMatrix)
281
- if (groups.length > 0) {
282
- if (
283
- activeGroup !== "all" &&
284
- (!activeGroup || !groups.includes(activeGroup))
285
- ) {
286
- setActiveGroup("all")
287
- }
288
- } else {
289
- setActiveGroup(null)
320
+ if (activeGroup !== "all" && !allGroups.includes(activeGroup)) {
321
+ setActiveGroup("all")
290
322
  }
291
- }, [groupedMatrix, activeGroup])
323
+ }, [allGroups, activeGroup])
324
+
325
+ // Đang search → hiện mọi nhóm khớp, bỏ qua tab đang chọn.
326
+ const searching = searchTerm.trim().length > 0
292
327
 
293
328
  const hasPermission = (resource: string, action: string): boolean => {
294
329
  return formData.permissions.includes(`${action}:${resource}`)
@@ -310,12 +345,10 @@ export function RoleFormPage({
310
345
  (groupName: string) => {
311
346
  const groupResources = groupedMatrix[groupName] || []
312
347
  return groupResources.flatMap((r) =>
313
- actions
314
- .filter((a) => r.actions.includes(a.code))
315
- .map((a) => `${a.code}:${r.resource}`)
348
+ r.actions.map((a) => `${a}:${r.resource}`)
316
349
  )
317
350
  },
318
- [groupedMatrix, actions]
351
+ [groupedMatrix]
319
352
  )
320
353
 
321
354
  const toggleAllForGroup = (groupName: string, enable: boolean) => {
@@ -334,12 +367,57 @@ export function RoleFormPage({
334
367
  setFormData({ ...formData, permissions: newPermissions })
335
368
  }
336
369
 
337
- const groupSelectedCount = (groupName: string) => {
338
- const codes = groupPermissionCodes(groupName)
339
- return {
340
- selected: codes.filter((p) => formData.permissions.includes(p)).length,
341
- total: codes.length,
370
+ const groupSelectedCount = React.useCallback(
371
+ (groupName: string) => {
372
+ const codes = groupPermissionCodes(groupName)
373
+ return {
374
+ selected: codes.filter((p) => formData.permissions.includes(p)).length,
375
+ total: codes.length,
376
+ }
377
+ },
378
+ [groupPermissionCodes, formData.permissions]
379
+ )
380
+
381
+ // Đếm theo nhóm cho TAB (không phụ thuộc search — dùng permissionMatrix).
382
+ const groupTabCount = React.useCallback(
383
+ (groupName: string) => {
384
+ const rs = permissionMatrix.filter((r) => r.group === groupName)
385
+ const codes = rs.flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`))
386
+ return {
387
+ selected: codes.filter((p) => formData.permissions.includes(p)).length,
388
+ total: codes.length,
389
+ }
390
+ },
391
+ [permissionMatrix, formData.permissions]
392
+ )
393
+
394
+ // Tick/bỏ nguyên MỘT CỘT action trong một nhóm (chỉ resource có khai action).
395
+ const columnState = (groupName: string, actionCode: string) => {
396
+ const rows = (groupedMatrix[groupName] || []).filter((r) =>
397
+ r.actions.includes(actionCode)
398
+ )
399
+ if (rows.length === 0) return { applicable: 0, selected: 0 }
400
+ const selected = rows.filter((r) =>
401
+ formData.permissions.includes(`${actionCode}:${r.resource}`)
402
+ ).length
403
+ return { applicable: rows.length, selected }
404
+ }
405
+
406
+ const toggleColumnForGroup = (groupName: string, actionCode: string) => {
407
+ const rows = (groupedMatrix[groupName] || []).filter((r) =>
408
+ r.actions.includes(actionCode)
409
+ )
410
+ if (rows.length === 0) return
411
+ const codes = rows.map((r) => `${actionCode}:${r.resource}`)
412
+ const allOn = codes.every((c) => formData.permissions.includes(c))
413
+ let newPermissions = [...formData.permissions]
414
+ if (allOn) {
415
+ newPermissions = newPermissions.filter((p) => !codes.includes(p))
416
+ } else {
417
+ const toAdd = codes.filter((c) => !newPermissions.includes(c))
418
+ newPermissions = [...newPermissions, ...toAdd]
342
419
  }
420
+ setFormData({ ...formData, permissions: newPermissions })
343
421
  }
344
422
 
345
423
  const toggleAllForResource = (
@@ -371,6 +449,15 @@ export function RoleFormPage({
371
449
  )
372
450
  }
373
451
 
452
+ const applyTemplate = (templateCode: string) => {
453
+ const tpl = roleTemplates?.find((t) => t.code === templateCode)
454
+ if (!tpl) return
455
+ setFormData((prev) => ({ ...prev, permissions: [...tpl.permissions] }))
456
+ toast.success(`Đã áp bộ quyền mẫu "${tpl.label}"`, {
457
+ description: `${tpl.permissions.length} quyền — chỉnh tiếp rồi bấm Lưu.`,
458
+ })
459
+ }
460
+
374
461
  const handleSubmit = async (e: React.FormEvent) => {
375
462
  e.preventDefault()
376
463
  setIsSubmitting(true)
@@ -409,7 +496,7 @@ export function RoleFormPage({
409
496
  const visibleGroups = Object.entries(groupedMatrix).filter(
410
497
  ([group, groupResources]) =>
411
498
  groupResources.length > 0 &&
412
- !(activeGroup && activeGroup !== "all" && activeGroup !== group)
499
+ (searching || activeGroup === "all" || activeGroup === group)
413
500
  )
414
501
 
415
502
  return (
@@ -526,44 +613,105 @@ export function RoleFormPage({
526
613
  </div>
527
614
 
528
615
  <div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
529
- {/* ===== 2. Toolbar sticky ===== */}
530
- <div className="sticky top-0 z-20 flex flex-wrap items-center gap-2 border-b border-border bg-card/95 px-3 py-2 backdrop-blur supports-[backdrop-filter]:bg-card/85 sm:px-4">
531
- <div className="relative min-w-[140px] flex-1 sm:max-w-64">
532
- <Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
533
- <Input
534
- placeholder="Tìm kiếm tài nguyên..."
535
- className="h-8 rounded-md border border-border bg-card pl-8 text-sm"
536
- value={searchTerm}
537
- onChange={(e) => setSearchTerm(e.target.value)}
538
- />
616
+ {/* ===== 2. Toolbar sticky: search · bộ quyền mẫu (create) · đếm ===== */}
617
+ <div className="sticky top-0 z-20 border-b border-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/85">
618
+ <div className="flex flex-wrap items-center gap-2 px-3 py-2 sm:px-4">
619
+ <div className="relative min-w-[140px] flex-1 sm:max-w-64">
620
+ <Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
621
+ <Input
622
+ placeholder="Tìm kiếm tài nguyên..."
623
+ className="h-8 rounded-md border border-border bg-card pl-8 text-sm"
624
+ value={searchTerm}
625
+ onChange={(e) => setSearchTerm(e.target.value)}
626
+ />
627
+ </div>
628
+ {mode === "create" && (roleTemplates?.length ?? 0) > 0 && (
629
+ <Select onValueChange={applyTemplate}>
630
+ <SelectTrigger className="h-8 w-44 rounded-md border border-border bg-card text-sm sm:w-52">
631
+ <span className="flex items-center gap-1.5 truncate">
632
+ <LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
633
+ <SelectValue placeholder="Bộ quyền mẫu..." />
634
+ </span>
635
+ </SelectTrigger>
636
+ <SelectContent className="rounded-lg">
637
+ {roleTemplates!.map((tpl) => (
638
+ <SelectItem key={tpl.code} value={tpl.code}>
639
+ <span className="flex flex-col">
640
+ <span>{tpl.label}</span>
641
+ {tpl.description && (
642
+ <span className="text-[11px] text-muted-foreground">
643
+ {tpl.description}
644
+ </span>
645
+ )}
646
+ </span>
647
+ </SelectItem>
648
+ ))}
649
+ </SelectContent>
650
+ </Select>
651
+ )}
652
+ <p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
653
+ <span className="font-semibold tabular-nums text-foreground">
654
+ {formData.permissions.length}
655
+ </span>
656
+ <span className="tabular-nums">/{totalAvailablePermissions}</span>{" "}
657
+ quyền
658
+ </p>
539
659
  </div>
540
- <Select
541
- value={activeGroup || "all"}
542
- onValueChange={(val) => setActiveGroup(val === "all" ? null : val)}
543
- >
544
- <SelectTrigger className="h-8 w-36 rounded-md border border-border bg-card text-sm sm:w-44">
545
- <SelectValue placeholder="Tất cả nhóm" />
546
- </SelectTrigger>
547
- <SelectContent className="rounded-lg">
548
- <SelectItem value="all">Tất cả nhóm</SelectItem>
549
- {Object.keys(groupedMatrix).map((group) => (
550
- <SelectItem key={group} value={group}>
660
+
661
+ {/* Thanh TAB phân hệ — cuộn ngang, đếm realtime */}
662
+ <div className="flex items-center gap-1 overflow-x-auto px-3 pb-2 sm:px-4 [scrollbar-width:thin]">
663
+ <button
664
+ type="button"
665
+ onClick={() => setActiveGroup("all")}
666
+ className={cn(
667
+ "shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
668
+ activeGroup === "all" && !searching
669
+ ? "border-primary bg-primary text-primary-foreground"
670
+ : "border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground"
671
+ )}
672
+ >
673
+ Tất cả
674
+ </button>
675
+ {allGroups.map((group) => {
676
+ const { selected, total } = groupTabCount(group)
677
+ const active = activeGroup === group && !searching
678
+ return (
679
+ <button
680
+ key={group}
681
+ type="button"
682
+ onClick={() => {
683
+ setActiveGroup(group)
684
+ setSearchTerm("")
685
+ }}
686
+ className={cn(
687
+ "flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
688
+ active
689
+ ? "border-primary bg-primary text-primary-foreground"
690
+ : "border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground"
691
+ )}
692
+ >
551
693
  {group}
552
- </SelectItem>
553
- ))}
554
- </SelectContent>
555
- </Select>
556
- <p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
557
- {filteredMatrix.length} tài nguyên ·{" "}
558
- <span className="font-semibold tabular-nums text-foreground">
559
- {formData.permissions.length}
560
- </span>
561
- <span className="tabular-nums">/{totalAvailablePermissions}</span>{" "}
562
- quyền
563
- </p>
694
+ <span
695
+ className={cn(
696
+ "rounded-full px-1 text-[10px] tabular-nums",
697
+ active
698
+ ? "bg-primary-foreground/20"
699
+ : selected > 0
700
+ ? selected === total
701
+ ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
702
+ : "bg-muted text-foreground"
703
+ : "bg-muted"
704
+ )}
705
+ >
706
+ {selected}/{total}
707
+ </span>
708
+ </button>
709
+ )
710
+ })}
711
+ </div>
564
712
  </div>
565
713
 
566
- {/* ===== 3. Nhóm + tài nguyên dạng dòng ===== */}
714
+ {/* ===== 3. Bảng quyền theo phân hệ ===== */}
567
715
  {visibleGroups.length === 0 ? (
568
716
  <div className="flex flex-col items-center justify-center py-16 text-center">
569
717
  <Search className="mb-3 h-6 w-6 text-muted-foreground" />
@@ -573,30 +721,13 @@ export function RoleFormPage({
573
721
  </div>
574
722
  ) : (
575
723
  visibleGroups.map(([group, groupResources]) => {
576
- const collapsed = !!collapsedGroups[group]
577
724
  const { selected, total } = groupSelectedCount(group)
578
725
  const isFull = total > 0 && selected === total
579
726
 
580
727
  return (
581
728
  <section key={group}>
582
- {/* Dòng tiêu đề nhóm — mảnh, không đóng khung */}
583
- <div
584
- role="button"
585
- tabIndex={0}
586
- onClick={() => toggleGroupCollapse(group)}
587
- onKeyDown={(e) => {
588
- if (e.key === "Enter" || e.key === " ") {
589
- e.preventDefault()
590
- toggleGroupCollapse(group)
591
- }
592
- }}
593
- className="flex cursor-pointer select-none items-center gap-2 border-b border-border bg-card px-4 py-2 transition-colors hover:bg-muted/40"
594
- >
595
- {collapsed ? (
596
- <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
597
- ) : (
598
- <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
599
- )}
729
+ {/* Tiêu đề nhóm */}
730
+ <div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
600
731
  <h3 className="truncate text-xs font-semibold uppercase tracking-wide text-foreground">
601
732
  {group}
602
733
  </h3>
@@ -611,10 +742,7 @@ export function RoleFormPage({
611
742
  {groupResources.length} tài nguyên · {selected}/{total}
612
743
  {isFull && <Check className="ml-0.5 inline h-3 w-3" />}
613
744
  </span>
614
- <div
615
- className="ml-auto flex shrink-0 items-center gap-1"
616
- onClick={(e) => e.stopPropagation()}
617
- >
745
+ <div className="ml-auto flex shrink-0 items-center gap-1">
618
746
  <Button
619
747
  type="button"
620
748
  variant="ghost"
@@ -638,26 +766,172 @@ export function RoleFormPage({
638
766
  </div>
639
767
  </div>
640
768
 
641
- {/* Các dòng tài nguyên */}
642
- {!collapsed && (
643
- <ul className="divide-y divide-border/70 border-b border-border">
644
- {groupResources.map((resource) => (
645
- <li key={resource.resource} className="px-4 py-2.5">
646
- <div className="flex items-center gap-2">
647
- <DynamicIcon
648
- name={resource.icon as any}
649
- className="h-4 w-4 shrink-0 text-muted-foreground"
650
- />
651
- <span className="truncate text-sm font-medium text-foreground">
652
- {resource.name}
653
- </span>
654
- <code className="hidden shrink-0 font-mono text-[11px] text-muted-foreground sm:inline">
655
- {resource.resource}
656
- </code>
657
- <label className="ml-auto flex shrink-0 cursor-pointer items-center gap-1.5">
658
- <span className="hidden text-[10px] font-medium uppercase text-muted-foreground sm:inline">
659
- Tất cả
660
- </span>
769
+ {/* Bảng cột chuẩn */}
770
+ <div className="overflow-x-auto border-b border-border">
771
+ <table className="w-full min-w-[760px] border-collapse text-sm">
772
+ <thead>
773
+ <tr className="border-b border-border/70">
774
+ <th className="w-56 px-4 py-1.5 text-left text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
775
+ Tài nguyên
776
+ </th>
777
+ {STANDARD_COLUMNS.map((col) => {
778
+ const { applicable, selected: colSel } = columnState(
779
+ group,
780
+ col.code
781
+ )
782
+ const colFull =
783
+ applicable > 0 && colSel === applicable
784
+ return (
785
+ <th key={col.code} className="w-14 px-1 py-1.5">
786
+ <button
787
+ type="button"
788
+ onClick={() =>
789
+ toggleColumnForGroup(group, col.code)
790
+ }
791
+ disabled={applicable === 0}
792
+ title={
793
+ applicable === 0
794
+ ? "Không tài nguyên nào có quyền này"
795
+ : `Bấm để ${colFull ? "bỏ" : "chọn"} cả cột (${colSel}/${applicable})`
796
+ }
797
+ className={cn(
798
+ "mx-auto flex flex-col items-center gap-0.5 rounded px-1 py-0.5 text-[11px] font-medium transition-colors",
799
+ applicable === 0
800
+ ? "cursor-default text-muted-foreground/40"
801
+ : colFull
802
+ ? "text-primary hover:bg-primary/10"
803
+ : "text-muted-foreground hover:bg-muted hover:text-foreground"
804
+ )}
805
+ >
806
+ {col.label}
807
+ {applicable > 0 && (
808
+ <span className="text-[9px] tabular-nums opacity-70">
809
+ {colSel}/{applicable}
810
+ </span>
811
+ )}
812
+ </button>
813
+ </th>
814
+ )
815
+ })}
816
+ <th className="px-2 py-1.5 text-left text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
817
+ Đặc thù
818
+ </th>
819
+ <th className="w-16 px-2 py-1.5 text-right text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
820
+ Tất cả
821
+ </th>
822
+ </tr>
823
+ </thead>
824
+ <tbody className="divide-y divide-border/60">
825
+ {groupResources.map((resource) => (
826
+ <tr
827
+ key={resource.resource}
828
+ className="transition-colors hover:bg-muted/30"
829
+ >
830
+ <td className="px-4 py-2">
831
+ <div className="flex items-center gap-2">
832
+ <DynamicIcon
833
+ name={resource.icon as any}
834
+ className="h-4 w-4 shrink-0 text-muted-foreground"
835
+ />
836
+ <div className="min-w-0">
837
+ <div className="truncate font-medium text-foreground">
838
+ {resource.name}
839
+ </div>
840
+ <code className="block truncate font-mono text-[10px] text-muted-foreground">
841
+ {resource.resource}
842
+ </code>
843
+ </div>
844
+ </div>
845
+ </td>
846
+ {STANDARD_COLUMNS.map((col) => {
847
+ const declared = resource.actions.includes(col.code)
848
+ if (!declared) {
849
+ return (
850
+ <td
851
+ key={col.code}
852
+ className="px-1 py-2 text-center text-muted-foreground/30"
853
+ >
854
+ ·
855
+ </td>
856
+ )
857
+ }
858
+ return (
859
+ <td key={col.code} className="px-1 py-2 text-center">
860
+ <Checkbox
861
+ checked={hasPermission(
862
+ resource.resource,
863
+ col.code
864
+ )}
865
+ onCheckedChange={() =>
866
+ togglePermission(
867
+ resource.resource,
868
+ col.code
869
+ )
870
+ }
871
+ aria-label={`${actionName(col.code)} ${resource.name}`}
872
+ className="mx-auto"
873
+ />
874
+ </td>
875
+ )
876
+ })}
877
+ <td className="px-2 py-2">
878
+ {resource.extraActions.length === 0 ? (
879
+ <span className="text-muted-foreground/30">·</span>
880
+ ) : (
881
+ <div className="flex max-w-72 flex-wrap gap-1">
882
+ {resource.extraActions.map((code) => {
883
+ const isChecked = hasPermission(
884
+ resource.resource,
885
+ code
886
+ )
887
+ const visual = getActionVisual(code)
888
+ const ActionIcon = visual.icon
889
+ return (
890
+ <button
891
+ key={code}
892
+ type="button"
893
+ onClick={() =>
894
+ togglePermission(
895
+ resource.resource,
896
+ code
897
+ )
898
+ }
899
+ title={code}
900
+ className={cn(
901
+ "inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium transition-colors",
902
+ isChecked
903
+ ? "border-primary/40 bg-primary/10 text-primary"
904
+ : "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground"
905
+ )}
906
+ >
907
+ <span
908
+ className={cn(
909
+ "flex h-3 w-3 shrink-0 items-center justify-center rounded-[3px] border transition-colors",
910
+ isChecked
911
+ ? "border-primary bg-primary text-primary-foreground"
912
+ : "border-border bg-card text-transparent"
913
+ )}
914
+ >
915
+ <Check
916
+ className="h-2 w-2"
917
+ strokeWidth={3}
918
+ />
919
+ </span>
920
+ <ActionIcon
921
+ className={cn(
922
+ "h-3 w-3 shrink-0",
923
+ visual.cls,
924
+ !isChecked && "opacity-55"
925
+ )}
926
+ />
927
+ {actionName(code)}
928
+ </button>
929
+ )
930
+ })}
931
+ </div>
932
+ )}
933
+ </td>
934
+ <td className="px-2 py-2 text-right">
661
935
  <Switch
662
936
  checked={isResourceFullPermission(
663
937
  resource.resource,
@@ -671,73 +945,14 @@ export function RoleFormPage({
671
945
  )
672
946
  }
673
947
  className="origin-right scale-75"
948
+ aria-label={`Toàn quyền ${resource.name}`}
674
949
  />
675
- </label>
676
- </div>
677
-
678
- {/* Chip quyền — icon theo loại action, bấm để bật/tắt */}
679
- <div className="mt-1.5 flex flex-wrap gap-1.5 sm:pl-6">
680
- {actions.map((action) => {
681
- if (!resource.actions.includes(action.code))
682
- return null
683
- const isChecked = hasPermission(
684
- resource.resource,
685
- action.code
686
- )
687
- const label =
688
- action.name ||
689
- mergedActionLabels[action.code]?.label ||
690
- action.code
691
- const visual = getActionVisual(action.code)
692
- const ActionIcon = visual.icon
693
-
694
- return (
695
- <button
696
- key={action.code}
697
- type="button"
698
- onClick={() =>
699
- togglePermission(
700
- resource.resource,
701
- action.code
702
- )
703
- }
704
- className={cn(
705
- "inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-colors",
706
- isChecked
707
- ? "border-primary/40 bg-primary/10 text-primary"
708
- : "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground"
709
- )}
710
- >
711
- {/* Ô tick tường minh — nhìn là biết đã cấp hay chưa */}
712
- <span
713
- className={cn(
714
- "flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-[4px] border transition-colors",
715
- isChecked
716
- ? "border-primary bg-primary text-primary-foreground"
717
- : "border-border bg-card text-transparent"
718
- )}
719
- >
720
- <Check
721
- className="h-2.5 w-2.5"
722
- strokeWidth={3}
723
- />
724
- </span>
725
- <ActionIcon
726
- className={cn(
727
- "h-3.5 w-3.5 shrink-0",
728
- visual.cls,
729
- !isChecked && "opacity-55"
730
- )}
731
- />
732
- {label}
733
- </button>
734
- )
735
- })}
736
- </div>
737
- </li>
738
- ))}
739
- </ul>
740
- )}
950
+ </td>
951
+ </tr>
952
+ ))}
953
+ </tbody>
954
+ </table>
955
+ </div>
741
956
  </section>
742
957
  )
743
958
  })