@goplusvn/core 0.1.38 → 0.1.39

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.38",
4
+ "version": "0.1.39",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -1,16 +1,19 @@
1
1
  "use client";
2
2
 
3
- // Trang tạo/sửa VAI TRÒ — bar navy 1 DÒNG kiểu chi tiết đơn bán hàng
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.
3
+ // Trang tạo/sửa VAI TRÒ — bar navy 1 DÒNG kiểu chi tiết đơn bán hàng.
6
4
  //
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ệ 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.
5
+ // Ma trận quyền v3 ("Mức truy cập 5 nấc" chốt với chủ app 2026-07):
6
+ // - TAB theo phân hệ (đếm x/y realtime).
7
+ // - Mỗi tài nguyên MỘT DÒNG: [Ẩn | Tra cứu | Xem | Ghi | Toàn quyền]
8
+ // Ẩn = không menu, không quyền
9
+ // Tra cứu = chỉ chọn trong combo/selector (lookup) KHÔNG thấy menu
10
+ // Xem = menu sidebar hiện + vào được trang
11
+ // Ghi = Xem + Tạo/Sửa/Xuất/Nhập
12
+ // Toàn quyền= Ghi + Xóa
13
+ // - Thao tác NGHIỆP VỤ (duyệt, thanh toán, MISA…) thu gọn "n/m ▸", bung ra
14
+ // nhóm theo LUỒNG (prop actionMeta.flow), tooltip mô tả, và tự cưỡng chế
15
+ // quyền tiên quyết (actionMeta.requires — mặc định cần Xem).
16
+ // - resourcePages: đường dẫn trang hiện dưới tên tài nguyên.
14
17
  // API contract giữ nguyên: POST /api/roles, PUT /api/roles/:id, quyền dạng
15
18
  // `${action}:${resource}`.
16
19
  import * as React from "react"
@@ -18,7 +21,6 @@ import { useRouter } from "next/navigation"
18
21
  import {
19
22
  Badge,
20
23
  Button,
21
- Checkbox,
22
24
  DynamicIcon,
23
25
  Input,
24
26
  Select,
@@ -26,7 +28,6 @@ import {
26
28
  SelectItem,
27
29
  SelectTrigger,
28
30
  SelectValue,
29
- Switch,
30
31
  getStatusMeta,
31
32
  } from "../../ui"
32
33
  import { toast } from "sonner"
@@ -36,6 +37,8 @@ import {
36
37
  Ban,
37
38
  Bot,
38
39
  Check,
40
+ ChevronDown,
41
+ ChevronRight,
39
42
  Copy,
40
43
  Download,
41
44
  Eye,
@@ -80,20 +83,30 @@ const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
80
83
  delete_items: { label: "Xóa chi tiết", icon: "❌", color: "text-rose-600" },
81
84
  }
82
85
 
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" },
86
+ // Action CHUẨN gộp vào thang "Mức truy cập" không hiện lẻ.
87
+ const WRITE_CODES = ["create", "update", "export", "import"] as const
88
+ const STANDARD_CODES = new Set([
89
+ "view",
90
+ "lookup",
91
+ "delete",
92
+ ...WRITE_CODES,
93
+ ])
94
+
95
+ type AccessLevel = "hidden" | "lookup" | "view" | "write" | "full"
96
+
97
+ const LEVELS: Array<{ key: AccessLevel; label: string; hint: string }> = [
98
+ { key: "hidden", label: "Ẩn", hint: "Không thấy menu, không quyền gì" },
99
+ {
100
+ key: "lookup",
101
+ label: "Tra cứu",
102
+ hint: "Chỉ chọn trong combo/selector — KHÔNG thấy menu",
103
+ },
104
+ { key: "view", label: "Xem", hint: "Menu sidebar hiện + vào được trang" },
105
+ { key: "write", label: "Ghi", hint: "Xem + Tạo/Sửa/Xuất/Nhập" },
106
+ { key: "full", label: "Toàn quyền", hint: "Ghi + Xóa" },
93
107
  ]
94
- const STANDARD_CODES = new Set(STANDARD_COLUMNS.map((c) => c.code))
95
108
 
96
- // --- Icon + màu theo LOẠI action (suy từ mã) — mọi action đặc thù đều có
109
+ // --- Icon + màu theo LOẠI action (suy từ mã) — mọi action nghiệp vụ đều có
97
110
  // icon mà không cần cấu hình từng cái. ---
98
111
  const ACTION_VISUAL_RULES: Array<{
99
112
  re: RegExp
@@ -115,7 +128,11 @@ const ACTION_VISUAL_RULES: Array<{
115
128
  { re: /^export/, icon: Upload, cls: "text-indigo-500" },
116
129
  { re: /^print/, icon: Printer, cls: "text-cyan-600" },
117
130
  { re: /^(sync|unlink)/, icon: RefreshCw, cls: "text-cyan-600" },
118
- { re: /^(manage|config|close|reopen)/, icon: Settings2, cls: "text-orange-500" },
131
+ {
132
+ re: /^(manage|config|close|reopen)/,
133
+ icon: Settings2,
134
+ cls: "text-orange-500",
135
+ },
119
136
  { re: /^auto/, icon: Bot, cls: "text-purple-500" },
120
137
  ]
121
138
 
@@ -125,8 +142,7 @@ const getActionVisual = (code: string) =>
125
142
  cls: "text-slate-400",
126
143
  }
127
144
 
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}`. */
145
+ /** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
130
146
  export interface RoleTemplate {
131
147
  code: string
132
148
  label: string
@@ -134,6 +150,14 @@ export interface RoleTemplate {
134
150
  permissions: string[]
135
151
  }
136
152
 
153
+ /** Metadata action nghiệp vụ: nhóm luồng + mô tả + quyền tiên quyết. */
154
+ export interface ActionMetaEntry {
155
+ label?: string
156
+ description?: string
157
+ flow?: string
158
+ requires?: string[]
159
+ }
160
+
137
161
  export interface RoleFormPageProps {
138
162
  dictionary: any
139
163
  permissions?: CrudPermissions
@@ -167,6 +191,10 @@ export interface RoleFormPageProps {
167
191
  actionLabels?: Record<string, ActionLabelConfig>
168
192
  /** Bộ quyền mẫu hiện ở chế độ tạo mới (additive — không truyền thì ẩn). */
169
193
  roleTemplates?: RoleTemplate[]
194
+ /** Meta action nghiệp vụ theo code (flow/description/requires) — additive. */
195
+ actionMeta?: Record<string, ActionMetaEntry>
196
+ /** Đường dẫn trang theo resource code (hiện dưới tên) — additive. */
197
+ resourcePages?: Record<string, string>
170
198
  }
171
199
 
172
200
  export function RoleFormPage({
@@ -179,6 +207,8 @@ export function RoleFormPage({
179
207
  initialData,
180
208
  actionLabels,
181
209
  roleTemplates,
210
+ actionMeta,
211
+ resourcePages,
182
212
  }: RoleFormPageProps) {
183
213
  const router = useRouter()
184
214
 
@@ -199,6 +229,18 @@ export function RoleFormPage({
199
229
  })
200
230
 
201
231
  const [activeGroup, setActiveGroup] = React.useState<string>("all")
232
+ const [expandedRows, setExpandedRows] = React.useState<Set<string>>(
233
+ () => new Set()
234
+ )
235
+
236
+ const toggleRowExpand = (code: string) => {
237
+ setExpandedRows((prev) => {
238
+ const next = new Set(prev)
239
+ if (next.has(code)) next.delete(code)
240
+ else next.add(code)
241
+ return next
242
+ })
243
+ }
202
244
 
203
245
  const getLocalizedName = React.useCallback(
204
246
  (key: string | null) => {
@@ -221,10 +263,14 @@ export function RoleFormPage({
221
263
 
222
264
  const actionName = React.useCallback(
223
265
  (code: string) => {
224
- const fromDb = actions.find((a) => a.code === code)?.name
225
- return fromDb || mergedActionLabels[code]?.label || code
266
+ return (
267
+ actionMeta?.[code]?.label ||
268
+ actions.find((a) => a.code === code)?.name ||
269
+ mergedActionLabels[code]?.label ||
270
+ code
271
+ )
226
272
  },
227
- [actions, mergedActionLabels]
273
+ [actions, actionMeta, mergedActionLabels]
228
274
  )
229
275
 
230
276
  const permissionMatrix = React.useMemo(() => {
@@ -273,11 +319,18 @@ export function RoleFormPage({
273
319
  group: getLocalizedName(resource.group) || "Khác",
274
320
  description: resource.description,
275
321
  actions: allowedActions,
276
- extraActions: allowedActions.filter((a) => !STANDARD_CODES.has(a)),
322
+ businessActions: allowedActions.filter(
323
+ (a) => !STANDARD_CODES.has(a)
324
+ ),
325
+ hasLookup: allowedActions.includes("lookup"),
326
+ writeCodes: WRITE_CODES.filter((c) => allowedActions.includes(c)),
327
+ hasDelete: allowedActions.includes("delete"),
277
328
  }
278
329
  })
279
330
  }, [resources, actions, getLocalizedName])
280
331
 
332
+ type MatrixRow = (typeof permissionMatrix)[number]
333
+
281
334
  const totalAvailablePermissions = React.useMemo(
282
335
  () => permissionMatrix.reduce((sum, r) => sum + r.actions.length, 0),
283
336
  [permissionMatrix]
@@ -296,7 +349,7 @@ export function RoleFormPage({
296
349
  }, [searchTerm, permissionMatrix])
297
350
 
298
351
  const groupedMatrix = React.useMemo(() => {
299
- const groups: Record<string, typeof permissionMatrix> = {}
352
+ const groups: Record<string, MatrixRow[]> = {}
300
353
  filteredMatrix.forEach((resource) => {
301
354
  const group = resource.group
302
355
  if (!groups[group]) {
@@ -307,7 +360,6 @@ export function RoleFormPage({
307
360
  return groups
308
361
  }, [filteredMatrix])
309
362
 
310
- // Tab dựng theo TOÀN BỘ matrix (không theo search) để thanh tab ổn định.
311
363
  const allGroups = React.useMemo(() => {
312
364
  const seen: string[] = []
313
365
  permissionMatrix.forEach((r) => {
@@ -322,133 +374,125 @@ export function RoleFormPage({
322
374
  }
323
375
  }, [allGroups, activeGroup])
324
376
 
325
- // Đang search → hiện mọi nhóm khớp, bỏ qua tab đang chọn.
326
377
  const searching = searchTerm.trim().length > 0
327
378
 
328
- const hasPermission = (resource: string, action: string): boolean => {
329
- return formData.permissions.includes(`${action}:${resource}`)
330
- }
331
-
332
- const togglePermission = (resource: string, action: string) => {
333
- const permission = `${action}:${resource}`
334
- let newPermissions = [...formData.permissions]
335
-
336
- if (newPermissions.includes(permission)) {
337
- newPermissions = newPermissions.filter((p) => p !== permission)
338
- } else {
339
- newPermissions.push(permission)
340
- }
341
- setFormData({ ...formData, permissions: newPermissions })
342
- }
343
-
344
- const groupPermissionCodes = React.useCallback(
345
- (groupName: string) => {
346
- const groupResources = groupedMatrix[groupName] || []
347
- return groupResources.flatMap((r) =>
348
- r.actions.map((a) => `${a}:${r.resource}`)
349
- )
350
- },
351
- [groupedMatrix]
379
+ const has = React.useCallback(
380
+ (resource: string, action: string) =>
381
+ formData.permissions.includes(`${action}:${resource}`),
382
+ [formData.permissions]
352
383
  )
353
384
 
354
- const toggleAllForGroup = (groupName: string, enable: boolean) => {
355
- const groupPermissions = groupPermissionCodes(groupName)
356
- let newPermissions = [...formData.permissions]
385
+ const mutatePermissions = (
386
+ fn: (set: Set<string>) => void
387
+ ) => {
388
+ setFormData((prev) => {
389
+ const set = new Set(prev.permissions)
390
+ fn(set)
391
+ return { ...prev, permissions: Array.from(set) }
392
+ })
393
+ }
357
394
 
358
- if (enable) {
359
- const toAdd = groupPermissions.filter((p) => !newPermissions.includes(p))
360
- newPermissions = [...newPermissions, ...toAdd]
361
- } else {
362
- newPermissions = newPermissions.filter(
363
- (p) => !groupPermissions.includes(p)
364
- )
395
+ // ── Mức truy cập ──────────────────────────────────────────────────────
396
+ const deriveLevel = (r: MatrixRow): AccessLevel => {
397
+ if (has(r.resource, "view")) {
398
+ const anyWrite = r.writeCodes.some((c) => has(r.resource, c))
399
+ if (!anyWrite && !(r.hasDelete && has(r.resource, "delete")))
400
+ return "view"
401
+ const fullWrite = r.writeCodes.every((c) => has(r.resource, c))
402
+ if (fullWrite && (!r.hasDelete || has(r.resource, "delete")))
403
+ return "full"
404
+ return "write"
365
405
  }
366
-
367
- setFormData({ ...formData, permissions: newPermissions })
406
+ if (r.hasLookup && has(r.resource, "lookup")) return "lookup"
407
+ return "hidden"
368
408
  }
369
409
 
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,
410
+ const setLevel = (r: MatrixRow, level: AccessLevel) => {
411
+ mutatePermissions((set) => {
412
+ const rm = (a: string) => set.delete(`${a}:${r.resource}`)
413
+ const add = (a: string) => set.add(`${a}:${r.resource}`)
414
+ // reset phần chuẩn
415
+ ;["view", "lookup", "delete", ...WRITE_CODES].forEach(rm)
416
+ if (level === "hidden" || level === "lookup") {
417
+ // mất Xem → thao tác nghiệp vụ (đều cần Xem) tắt theo
418
+ const on = r.businessActions.filter((a) =>
419
+ set.has(`${a}:${r.resource}`)
420
+ )
421
+ r.businessActions.forEach(rm)
422
+ if (on.length > 0) {
423
+ toast.info(
424
+ `Đã tắt ${on.length} thao tác nghiệp vụ của "${r.name}" (cần mức Xem trở lên)`
425
+ )
426
+ }
427
+ if (level === "lookup" && r.hasLookup) add("lookup")
428
+ return
376
429
  }
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,
430
+ add("view")
431
+ if (level === "write" || level === "full") {
432
+ r.writeCodes.forEach(add)
389
433
  }
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 }
434
+ if (level === "full" && r.hasDelete) add("delete")
435
+ })
404
436
  }
405
437
 
406
- const toggleColumnForGroup = (groupName: string, actionCode: string) => {
407
- const rows = (groupedMatrix[groupName] || []).filter((r) =>
408
- r.actions.includes(actionCode)
438
+ // ── Thao tác nghiệp vụ + requires ─────────────────────────────────────
439
+ const toggleBusinessAction = (r: MatrixRow, code: string) => {
440
+ const key = `${code}:${r.resource}`
441
+ if (formData.permissions.includes(key)) {
442
+ mutatePermissions((set) => set.delete(key))
443
+ return
444
+ }
445
+ const requires = actionMeta?.[code]?.requires ?? []
446
+ const needed = ["view", ...requires].filter(
447
+ (req) =>
448
+ r.actions.includes(req) &&
449
+ !formData.permissions.includes(`${req}:${r.resource}`)
409
450
  )
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]
451
+ mutatePermissions((set) => {
452
+ set.add(key)
453
+ needed.forEach((req) => set.add(`${req}:${r.resource}`))
454
+ })
455
+ if (needed.length > 0) {
456
+ toast.info(
457
+ `Đã bật kèm quyền tiên quyết: ${needed
458
+ .map((c) => actionName(c))
459
+ .join(", ")}`
460
+ )
419
461
  }
420
- setFormData({ ...formData, permissions: newPermissions })
421
462
  }
422
463
 
423
- const toggleAllForResource = (
424
- resourceCode: string,
425
- resourceActions: string[],
426
- check: boolean
427
- ) => {
428
- let newPermissions = [...formData.permissions]
429
-
430
- resourceActions.forEach((actionCode) => {
431
- const permission = `${actionCode}:${resourceCode}`
432
- if (check && !newPermissions.includes(permission)) {
433
- newPermissions.push(permission)
434
- } else if (!check && newPermissions.includes(permission)) {
435
- newPermissions = newPermissions.filter((p) => p !== permission)
436
- }
437
- })
464
+ // ── Đếm ───────────────────────────────────────────────────────────────
465
+ const groupPermissionCodes = React.useCallback(
466
+ (rows: MatrixRow[]) =>
467
+ rows.flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`)),
468
+ []
469
+ )
438
470
 
439
- setFormData((prev) => ({ ...prev, permissions: newPermissions }))
440
- }
471
+ const countSelected = React.useCallback(
472
+ (codes: string[]) =>
473
+ codes.filter((p) => formData.permissions.includes(p)).length,
474
+ [formData.permissions]
475
+ )
441
476
 
442
- const isResourceFullPermission = (
443
- resourceCode: string,
444
- resourceActions: string[]
445
- ) => {
446
- if (resourceActions.length === 0) return false
447
- return resourceActions.every((actionCode) =>
448
- formData.permissions.includes(`${actionCode}:${resourceCode}`)
449
- )
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
+ })
450
483
  }
451
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
+
452
496
  const applyTemplate = (templateCode: string) => {
453
497
  const tpl = roleTemplates?.find((t) => t.code === templateCode)
454
498
  if (!tpl) return
@@ -501,9 +545,7 @@ export function RoleFormPage({
501
545
 
502
546
  return (
503
547
  <div className="flex flex-col">
504
- {/* ===== 1. Header bar navy 1 DÒNG theo pattern chi tiết đơn bán
505
- hàng (bg-sidebar). Mobile: chỉ ← Tên ⏻ ✕ Lưu; mã (chip) + mô tả
506
- hiện từ md. Create mode: ô mã rớt xuống dòng riêng trên mobile. ===== */}
548
+ {/* ===== 1. Header bar navy 1 DÒNG ===== */}
507
549
  <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">
508
550
  <Button
509
551
  variant="ghost"
@@ -548,8 +590,6 @@ export function RoleFormPage({
548
590
  className="order-last h-7 basis-full rounded-md border-white/20 bg-white/10 px-2 font-mono text-xs text-primary-foreground shadow-none placeholder:text-primary-foreground/40 focus-visible:ring-1 focus-visible:ring-white/40 sm:order-none sm:w-44 sm:basis-auto"
549
591
  />
550
592
  )}
551
- {/* Chip trạng thái màu (getStatusMeta.solidClass — cùng taxonomy với
552
- header đơn bán hàng); bấm để đổi Hoạt động ↔ Khóa */}
553
593
  {(() => {
554
594
  const statusMeta = getStatusMeta(
555
595
  formData.status,
@@ -613,7 +653,7 @@ export function RoleFormPage({
613
653
  </div>
614
654
 
615
655
  <div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
616
- {/* ===== 2. Toolbar sticky: search · bộ quyền mẫu (create) · đếm ===== */}
656
+ {/* ===== 2. Toolbar sticky: search · bộ quyền mẫu · đếm · tab ===== */}
617
657
  <div className="sticky top-0 z-20 border-b border-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/85">
618
658
  <div className="flex flex-wrap items-center gap-2 px-3 py-2 sm:px-4">
619
659
  <div className="relative min-w-[140px] flex-1 sm:max-w-64">
@@ -658,7 +698,7 @@ export function RoleFormPage({
658
698
  </p>
659
699
  </div>
660
700
 
661
- {/* Thanh TAB phân hệ — cuộn ngang, đếm realtime */}
701
+ {/* Thanh TAB phân hệ */}
662
702
  <div className="flex items-center gap-1 overflow-x-auto px-3 pb-2 sm:px-4 [scrollbar-width:thin]">
663
703
  <button
664
704
  type="button"
@@ -709,9 +749,19 @@ export function RoleFormPage({
709
749
  )
710
750
  })}
711
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) => (
756
+ <span key={l.key} className="flex items-center gap-1">
757
+ <span className="font-medium text-foreground">{l.label}</span>
758
+ <span className="opacity-80">= {l.hint}</span>
759
+ </span>
760
+ ))}
761
+ </div>
712
762
  </div>
713
763
 
714
- {/* ===== 3. Bảng quyền theo phân hệ ===== */}
764
+ {/* ===== 3. Dòng tài nguyên: mức truy cập + thao tác nghiệp vụ ===== */}
715
765
  {visibleGroups.length === 0 ? (
716
766
  <div className="flex flex-col items-center justify-center py-16 text-center">
717
767
  <Search className="mb-3 h-6 w-6 text-muted-foreground" />
@@ -721,12 +771,13 @@ export function RoleFormPage({
721
771
  </div>
722
772
  ) : (
723
773
  visibleGroups.map(([group, groupResources]) => {
724
- const { selected, total } = groupSelectedCount(group)
774
+ const codes = groupPermissionCodes(groupResources)
775
+ const selected = countSelected(codes)
776
+ const total = codes.length
725
777
  const isFull = total > 0 && selected === total
726
778
 
727
779
  return (
728
780
  <section key={group}>
729
- {/* Tiêu đề nhóm */}
730
781
  <div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
731
782
  <h3 className="truncate text-xs font-semibold uppercase tracking-wide text-foreground">
732
783
  {group}
@@ -747,7 +798,7 @@ export function RoleFormPage({
747
798
  type="button"
748
799
  variant="ghost"
749
800
  size="sm"
750
- onClick={() => toggleAllForGroup(group, false)}
801
+ onClick={() => toggleAllForGroup(groupResources, false)}
751
802
  disabled={selected === 0}
752
803
  className="h-6 px-2 text-xs text-muted-foreground hover:text-destructive"
753
804
  >
@@ -757,7 +808,7 @@ export function RoleFormPage({
757
808
  type="button"
758
809
  variant="ghost"
759
810
  size="sm"
760
- onClick={() => toggleAllForGroup(group, true)}
811
+ onClick={() => toggleAllForGroup(groupResources, true)}
761
812
  disabled={isFull}
762
813
  className="h-6 px-2 text-xs font-semibold text-primary hover:text-primary"
763
814
  >
@@ -766,193 +817,178 @@ export function RoleFormPage({
766
817
  </div>
767
818
  </div>
768
819
 
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>
820
+ <ul className="divide-y divide-border/60 border-b border-border">
821
+ {groupResources.map((r) => {
822
+ const level = deriveLevel(r)
823
+ const bizOn = r.businessActions.filter((a) =>
824
+ has(r.resource, a)
825
+ ).length
826
+ const expanded = expandedRows.has(r.resource)
827
+ const page = resourcePages?.[r.resource]
828
+
829
+ // Nhóm thao tác theo LUỒNG
830
+ const flows = new Map<string, string[]>()
831
+ r.businessActions.forEach((code) => {
832
+ const flow = actionMeta?.[code]?.flow || "Khác"
833
+ const list = flows.get(flow) ?? []
834
+ list.push(code)
835
+ flows.set(flow, list)
836
+ })
837
+
838
+ return (
839
+ <li key={r.resource} className="px-4 py-2.5">
840
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
841
+ {/* Tên + trang */}
842
+ <div className="flex min-w-0 flex-1 basis-52 items-center gap-2">
843
+ <DynamicIcon
844
+ name={r.icon as any}
845
+ className="h-4 w-4 shrink-0 text-muted-foreground"
846
+ />
847
+ <div className="min-w-0">
848
+ <div className="truncate text-sm font-medium text-foreground">
849
+ {r.name}
850
+ </div>
851
+ <div className="truncate font-mono text-[10px] text-muted-foreground">
852
+ {page || r.resource}
843
853
  </div>
844
854
  </div>
845
- </td>
846
- {STANDARD_COLUMNS.map((col) => {
847
- const declared = resource.actions.includes(col.code)
848
- if (!declared) {
855
+ </div>
856
+
857
+ {/* Segmented 5 nấc */}
858
+ <div className="flex shrink-0 overflow-hidden rounded-lg border border-border">
859
+ {LEVELS.map((l, i) => {
860
+ const disabled =
861
+ (l.key === "lookup" && !r.hasLookup) ||
862
+ (l.key === "write" &&
863
+ r.writeCodes.length === 0) ||
864
+ (l.key === "full" &&
865
+ r.writeCodes.length === 0 &&
866
+ !r.hasDelete)
867
+ const active = level === l.key
849
868
  return (
850
- <td
851
- key={col.code}
852
- className="px-1 py-2 text-center text-muted-foreground/30"
869
+ <button
870
+ key={l.key}
871
+ type="button"
872
+ disabled={disabled}
873
+ onClick={() => setLevel(r, l.key)}
874
+ title={l.hint}
875
+ className={cn(
876
+ "px-2 py-1 text-[11px] font-medium transition-colors",
877
+ i > 0 && "border-l border-border",
878
+ disabled
879
+ ? "cursor-not-allowed bg-muted/40 text-muted-foreground/40"
880
+ : active
881
+ ? l.key === "hidden"
882
+ ? "bg-muted text-foreground"
883
+ : "bg-primary text-primary-foreground"
884
+ : "bg-card text-muted-foreground hover:bg-muted hover:text-foreground"
885
+ )}
853
886
  >
854
- ·
855
- </td>
887
+ {l.label}
888
+ </button>
856
889
  )
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
890
+ })}
891
+ </div>
892
+
893
+ {/* Thao tác nghiệp vụ — nút thu gọn */}
894
+ {r.businessActions.length > 0 && (
895
+ <button
896
+ type="button"
897
+ onClick={() => toggleRowExpand(r.resource)}
898
+ className={cn(
899
+ "flex shrink-0 items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium transition-colors",
900
+ bizOn > 0
901
+ ? "border-primary/40 bg-primary/5 text-primary"
902
+ : "border-border bg-card text-muted-foreground hover:text-foreground"
903
+ )}
904
+ >
905
+ {expanded ? (
906
+ <ChevronDown className="h-3 w-3" />
907
+ ) : (
908
+ <ChevronRight className="h-3 w-3" />
909
+ )}
910
+ Thao tác nghiệp vụ
911
+ <span className="tabular-nums">
912
+ {bizOn}/{r.businessActions.length}
913
+ </span>
914
+ </button>
915
+ )}
916
+ </div>
917
+
918
+ {/* Panel thao tác nghiệp vụ — nhóm theo LUỒNG */}
919
+ {expanded && r.businessActions.length > 0 && (
920
+ <div className="mt-2 space-y-1.5 rounded-lg border border-border/70 bg-muted/20 px-3 py-2 sm:ml-6">
921
+ {level === "hidden" || level === "lookup" ? (
922
+ <p className="text-[11px] text-muted-foreground">
923
+ Cần mức <b>Xem</b> trở lên — tick một thao tác
924
+ sẽ tự nâng lên Xem.
925
+ </p>
926
+ ) : null}
927
+ {Array.from(flows.entries()).map(
928
+ ([flow, codes]) => (
929
+ <div
930
+ key={flow}
931
+ className="flex flex-wrap items-center gap-1.5"
932
+ >
933
+ <span className="w-24 shrink-0 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
934
+ {flow}
935
+ </span>
936
+ {codes.map((code) => {
937
+ const isChecked = has(r.resource, code)
938
+ const meta = actionMeta?.[code]
939
+ const visual = getActionVisual(code)
940
+ const ActionIcon = visual.icon
941
+ return (
942
+ <button
943
+ key={code}
944
+ type="button"
945
+ onClick={() =>
946
+ toggleBusinessAction(r, code)
947
+ }
948
+ title={
949
+ meta?.description ||
950
+ actionName(code)
951
+ }
908
952
  className={cn(
909
- "flex h-3 w-3 shrink-0 items-center justify-center rounded-[3px] border transition-colors",
953
+ "inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium transition-colors",
910
954
  isChecked
911
- ? "border-primary bg-primary text-primary-foreground"
912
- : "border-border bg-card text-transparent"
955
+ ? "border-primary/40 bg-primary/10 text-primary"
956
+ : "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground"
913
957
  )}
914
958
  >
915
- <Check
916
- className="h-2 w-2"
917
- strokeWidth={3}
959
+ <span
960
+ className={cn(
961
+ "flex h-3 w-3 shrink-0 items-center justify-center rounded-[3px] border transition-colors",
962
+ isChecked
963
+ ? "border-primary bg-primary text-primary-foreground"
964
+ : "border-border bg-card text-transparent"
965
+ )}
966
+ >
967
+ <Check
968
+ className="h-2 w-2"
969
+ strokeWidth={3}
970
+ />
971
+ </span>
972
+ <ActionIcon
973
+ className={cn(
974
+ "h-3 w-3 shrink-0",
975
+ visual.cls,
976
+ !isChecked && "opacity-55"
977
+ )}
918
978
  />
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>
979
+ {actionName(code)}
980
+ </button>
981
+ )
982
+ })}
983
+ </div>
984
+ )
932
985
  )}
933
- </td>
934
- <td className="px-2 py-2 text-right">
935
- <Switch
936
- checked={isResourceFullPermission(
937
- resource.resource,
938
- resource.actions
939
- )}
940
- onCheckedChange={(checked) =>
941
- toggleAllForResource(
942
- resource.resource,
943
- resource.actions,
944
- checked
945
- )
946
- }
947
- className="origin-right scale-75"
948
- aria-label={`Toàn quyền ${resource.name}`}
949
- />
950
- </td>
951
- </tr>
952
- ))}
953
- </tbody>
954
- </table>
955
- </div>
986
+ </div>
987
+ )}
988
+ </li>
989
+ )
990
+ })}
991
+ </ul>
956
992
  </section>
957
993
  )
958
994
  })