@goplusvn/core 0.1.38 → 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.38",
4
+ "version": "0.1.40",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -1,16 +1,23 @@
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 v42 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:
10
+ // - TAB theo phân hệ (đếm x/y realtime).
11
+ // - Mỗi tài nguyên MỘT DÒNG: [Ẩn | Tra cứu | Xem | Ghi | Toàn quyền]
12
+ // Ẩn = không menu, không quyền gì
13
+ // Tra cứu = chỉ chọn trong combo/selector (lookup) — KHÔNG thấy menu
14
+ // Xem = menu sidebar hiện + vào được trang
15
+ // Ghi = Xem + Tạo/Sửa/Xuất/Nhập
16
+ // Toàn quyền= Ghi + Xóa
17
+ // - Thao tác NGHIỆP VỤ (duyệt, thanh toán, MISA…) thu gọn "n/m ▸", bung ra
18
+ // nhóm theo LUỒNG (prop actionMeta.flow), tooltip mô tả, và tự cưỡng chế
19
+ // quyền tiên quyết (actionMeta.requires — mặc định cần Xem).
20
+ // - resourcePages: đường dẫn trang hiện dưới tên tài nguyên.
14
21
  // API contract giữ nguyên: POST /api/roles, PUT /api/roles/:id, quyền dạng
15
22
  // `${action}:${resource}`.
16
23
  import * as React from "react"
@@ -18,7 +25,6 @@ import { useRouter } from "next/navigation"
18
25
  import {
19
26
  Badge,
20
27
  Button,
21
- Checkbox,
22
28
  DynamicIcon,
23
29
  Input,
24
30
  Select,
@@ -26,7 +32,6 @@ import {
26
32
  SelectItem,
27
33
  SelectTrigger,
28
34
  SelectValue,
29
- Switch,
30
35
  getStatusMeta,
31
36
  } from "../../ui"
32
37
  import { toast } from "sonner"
@@ -36,6 +41,8 @@ import {
36
41
  Ban,
37
42
  Bot,
38
43
  Check,
44
+ ChevronDown,
45
+ ChevronRight,
39
46
  Copy,
40
47
  Download,
41
48
  Eye,
@@ -80,20 +87,48 @@ const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
80
87
  delete_items: { label: "Xóa chi tiết", icon: "❌", color: "text-rose-600" },
81
88
  }
82
89
 
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" },
90
+ // Action CHUẨN gộp vào thang "Mức truy cập" không hiện lẻ.
91
+ const WRITE_CODES = ["create", "update", "export", "import"] as const
92
+ const STANDARD_CODES = new Set([
93
+ "view",
94
+ "lookup",
95
+ "delete",
96
+ ...WRITE_CODES,
97
+ ])
98
+
99
+ type AccessLevel = "hidden" | "lookup" | "view" | "write" | "full"
100
+
101
+ const LEVELS: Array<{ key: AccessLevel; label: string; hint: string }> = [
102
+ { key: "hidden", label: "Ẩn", hint: "Không thấy menu, không quyền gì" },
103
+ {
104
+ key: "lookup",
105
+ label: "Tra cứu",
106
+ hint: "Chỉ chọn trong combo/selector — KHÔNG thấy menu",
107
+ },
108
+ { key: "view", label: "Xem", hint: "Menu sidebar hiện + vào được trang" },
109
+ { key: "write", label: "Ghi", hint: "Xem + Tạo/Sửa/Xuất/Nhập" },
110
+ { key: "full", label: "Toàn quyền", hint: "Ghi + Xóa" },
111
+ ]
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
+ },
93
129
  ]
94
- const STANDARD_CODES = new Set(STANDARD_COLUMNS.map((c) => c.code))
95
130
 
96
- // --- Icon + màu theo LOẠI action (suy từ mã) — mọi action đặc thù đều có
131
+ // --- Icon + màu theo LOẠI action (suy từ mã) — mọi action nghiệp vụ đều có
97
132
  // icon mà không cần cấu hình từng cái. ---
98
133
  const ACTION_VISUAL_RULES: Array<{
99
134
  re: RegExp
@@ -115,7 +150,11 @@ const ACTION_VISUAL_RULES: Array<{
115
150
  { re: /^export/, icon: Upload, cls: "text-indigo-500" },
116
151
  { re: /^print/, icon: Printer, cls: "text-cyan-600" },
117
152
  { re: /^(sync|unlink)/, icon: RefreshCw, cls: "text-cyan-600" },
118
- { re: /^(manage|config|close|reopen)/, icon: Settings2, cls: "text-orange-500" },
153
+ {
154
+ re: /^(manage|config|close|reopen)/,
155
+ icon: Settings2,
156
+ cls: "text-orange-500",
157
+ },
119
158
  { re: /^auto/, icon: Bot, cls: "text-purple-500" },
120
159
  ]
121
160
 
@@ -125,8 +164,7 @@ const getActionVisual = (code: string) =>
125
164
  cls: "text-slate-400",
126
165
  }
127
166
 
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}`. */
167
+ /** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
130
168
  export interface RoleTemplate {
131
169
  code: string
132
170
  label: string
@@ -134,6 +172,16 @@ export interface RoleTemplate {
134
172
  permissions: string[]
135
173
  }
136
174
 
175
+ /** Metadata action nghiệp vụ: nhóm luồng + mô tả + quyền tiên quyết. */
176
+ export interface ActionMetaEntry {
177
+ label?: string
178
+ description?: string
179
+ flow?: string
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"
183
+ }
184
+
137
185
  export interface RoleFormPageProps {
138
186
  dictionary: any
139
187
  permissions?: CrudPermissions
@@ -167,6 +215,10 @@ export interface RoleFormPageProps {
167
215
  actionLabels?: Record<string, ActionLabelConfig>
168
216
  /** Bộ quyền mẫu hiện ở chế độ tạo mới (additive — không truyền thì ẩn). */
169
217
  roleTemplates?: RoleTemplate[]
218
+ /** Meta action nghiệp vụ theo code (flow/description/requires) — additive. */
219
+ actionMeta?: Record<string, ActionMetaEntry>
220
+ /** Đường dẫn trang theo resource code (hiện dưới tên) — additive. */
221
+ resourcePages?: Record<string, string>
170
222
  }
171
223
 
172
224
  export function RoleFormPage({
@@ -179,6 +231,8 @@ export function RoleFormPage({
179
231
  initialData,
180
232
  actionLabels,
181
233
  roleTemplates,
234
+ actionMeta,
235
+ resourcePages,
182
236
  }: RoleFormPageProps) {
183
237
  const router = useRouter()
184
238
 
@@ -198,7 +252,30 @@ export function RoleFormPage({
198
252
  permissions: initialData?.permissions || ([] as string[]),
199
253
  })
200
254
 
201
- const [activeGroup, setActiveGroup] = React.useState<string>("all")
255
+ const [expandedRows, setExpandedRows] = React.useState<Set<string>>(
256
+ () => new Set()
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
+ }
270
+
271
+ const toggleRowExpand = (code: string) => {
272
+ setExpandedRows((prev) => {
273
+ const next = new Set(prev)
274
+ if (next.has(code)) next.delete(code)
275
+ else next.add(code)
276
+ return next
277
+ })
278
+ }
202
279
 
203
280
  const getLocalizedName = React.useCallback(
204
281
  (key: string | null) => {
@@ -221,10 +298,14 @@ export function RoleFormPage({
221
298
 
222
299
  const actionName = React.useCallback(
223
300
  (code: string) => {
224
- const fromDb = actions.find((a) => a.code === code)?.name
225
- return fromDb || mergedActionLabels[code]?.label || code
301
+ return (
302
+ actionMeta?.[code]?.label ||
303
+ actions.find((a) => a.code === code)?.name ||
304
+ mergedActionLabels[code]?.label ||
305
+ code
306
+ )
226
307
  },
227
- [actions, mergedActionLabels]
308
+ [actions, actionMeta, mergedActionLabels]
228
309
  )
229
310
 
230
311
  const permissionMatrix = React.useMemo(() => {
@@ -273,11 +354,18 @@ export function RoleFormPage({
273
354
  group: getLocalizedName(resource.group) || "Khác",
274
355
  description: resource.description,
275
356
  actions: allowedActions,
276
- extraActions: allowedActions.filter((a) => !STANDARD_CODES.has(a)),
357
+ businessActions: allowedActions.filter(
358
+ (a) => !STANDARD_CODES.has(a)
359
+ ),
360
+ hasLookup: allowedActions.includes("lookup"),
361
+ writeCodes: WRITE_CODES.filter((c) => allowedActions.includes(c)),
362
+ hasDelete: allowedActions.includes("delete"),
277
363
  }
278
364
  })
279
365
  }, [resources, actions, getLocalizedName])
280
366
 
367
+ type MatrixRow = (typeof permissionMatrix)[number]
368
+
281
369
  const totalAvailablePermissions = React.useMemo(
282
370
  () => permissionMatrix.reduce((sum, r) => sum + r.actions.length, 0),
283
371
  [permissionMatrix]
@@ -296,7 +384,7 @@ export function RoleFormPage({
296
384
  }, [searchTerm, permissionMatrix])
297
385
 
298
386
  const groupedMatrix = React.useMemo(() => {
299
- const groups: Record<string, typeof permissionMatrix> = {}
387
+ const groups: Record<string, MatrixRow[]> = {}
300
388
  filteredMatrix.forEach((resource) => {
301
389
  const group = resource.group
302
390
  if (!groups[group]) {
@@ -307,147 +395,172 @@ export function RoleFormPage({
307
395
  return groups
308
396
  }, [filteredMatrix])
309
397
 
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
-
319
- React.useEffect(() => {
320
- if (activeGroup !== "all" && !allGroups.includes(activeGroup)) {
321
- setActiveGroup("all")
322
- }
323
- }, [allGroups, activeGroup])
324
398
 
325
- // Đang search → hiện mọi nhóm khớp, bỏ qua tab đang chọn.
399
+ // Đang search → mọi nhóm khớp tự bung phần chi tiết.
326
400
  const searching = searchTerm.trim().length > 0
327
401
 
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]
402
+ const has = React.useCallback(
403
+ (resource: string, action: string) =>
404
+ formData.permissions.includes(`${action}:${resource}`),
405
+ [formData.permissions]
352
406
  )
353
407
 
354
- const toggleAllForGroup = (groupName: string, enable: boolean) => {
355
- const groupPermissions = groupPermissionCodes(groupName)
356
- let newPermissions = [...formData.permissions]
408
+ const mutatePermissions = (
409
+ fn: (set: Set<string>) => void
410
+ ) => {
411
+ setFormData((prev) => {
412
+ const set = new Set(prev.permissions)
413
+ fn(set)
414
+ return { ...prev, permissions: Array.from(set) }
415
+ })
416
+ }
357
417
 
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
- )
418
+ // ── Mức truy cập ──────────────────────────────────────────────────────
419
+ const deriveLevel = (r: MatrixRow): AccessLevel => {
420
+ if (has(r.resource, "view")) {
421
+ const anyWrite = r.writeCodes.some((c) => has(r.resource, c))
422
+ if (!anyWrite && !(r.hasDelete && has(r.resource, "delete")))
423
+ return "view"
424
+ const fullWrite = r.writeCodes.every((c) => has(r.resource, c))
425
+ if (fullWrite && (!r.hasDelete || has(r.resource, "delete")))
426
+ return "full"
427
+ return "write"
365
428
  }
429
+ if (r.hasLookup && has(r.resource, "lookup")) return "lookup"
430
+ return "hidden"
431
+ }
366
432
 
367
- setFormData({ ...formData, permissions: newPermissions })
433
+ const setLevel = (r: MatrixRow, level: AccessLevel) => {
434
+ mutatePermissions((set) => {
435
+ const rm = (a: string) => set.delete(`${a}:${r.resource}`)
436
+ const add = (a: string) => set.add(`${a}:${r.resource}`)
437
+ // reset phần chuẩn
438
+ ;["view", "lookup", "delete", ...WRITE_CODES].forEach(rm)
439
+ if (level === "hidden" || level === "lookup") {
440
+ // mất Xem → thao tác nghiệp vụ (đều cần Xem) tắt theo
441
+ const on = r.businessActions.filter((a) =>
442
+ set.has(`${a}:${r.resource}`)
443
+ )
444
+ r.businessActions.forEach(rm)
445
+ if (on.length > 0) {
446
+ toast.info(
447
+ `Đã tắt ${on.length} thao tác nghiệp vụ của "${r.name}" (cần mức Xem trở lên)`
448
+ )
449
+ }
450
+ if (level === "lookup" && r.hasLookup) add("lookup")
451
+ return
452
+ }
453
+ add("view")
454
+ if (level === "write" || level === "full") {
455
+ r.writeCodes.forEach(add)
456
+ }
457
+ if (level === "full" && r.hasDelete) add("delete")
458
+ })
368
459
  }
369
460
 
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,
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}`)
376
483
  }
377
484
  },
378
- [groupPermissionCodes, formData.permissions]
485
+ [actionMeta]
379
486
  )
380
487
 
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,
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
+ }
389
510
  }
511
+ return "custom"
390
512
  },
391
- [permissionMatrix, formData.permissions]
513
+ [formData.permissions, canonicalForGroup]
392
514
  )
393
515
 
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 }
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
+ })
404
523
  }
405
524
 
406
- const toggleColumnForGroup = (groupName: string, actionCode: string) => {
407
- const rows = (groupedMatrix[groupName] || []).filter((r) =>
408
- r.actions.includes(actionCode)
525
+ // ── Thao tác nghiệp vụ + requires ─────────────────────────────────────
526
+ const toggleBusinessAction = (r: MatrixRow, code: string) => {
527
+ const key = `${code}:${r.resource}`
528
+ if (formData.permissions.includes(key)) {
529
+ mutatePermissions((set) => set.delete(key))
530
+ return
531
+ }
532
+ const requires = actionMeta?.[code]?.requires ?? []
533
+ const needed = ["view", ...requires].filter(
534
+ (req) =>
535
+ r.actions.includes(req) &&
536
+ !formData.permissions.includes(`${req}:${r.resource}`)
409
537
  )
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]
538
+ mutatePermissions((set) => {
539
+ set.add(key)
540
+ needed.forEach((req) => set.add(`${req}:${r.resource}`))
541
+ })
542
+ if (needed.length > 0) {
543
+ toast.info(
544
+ `Đã bật kèm quyền tiên quyết: ${needed
545
+ .map((c) => actionName(c))
546
+ .join(", ")}`
547
+ )
419
548
  }
420
- setFormData({ ...formData, permissions: newPermissions })
421
549
  }
422
550
 
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
- })
551
+ // ── Đếm ───────────────────────────────────────────────────────────────
552
+ const groupPermissionCodes = React.useCallback(
553
+ (rows: MatrixRow[]) =>
554
+ rows.flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`)),
555
+ []
556
+ )
438
557
 
439
- setFormData((prev) => ({ ...prev, permissions: newPermissions }))
440
- }
558
+ const countSelected = React.useCallback(
559
+ (codes: string[]) =>
560
+ codes.filter((p) => formData.permissions.includes(p)).length,
561
+ [formData.permissions]
562
+ )
441
563
 
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
- )
450
- }
451
564
 
452
565
  const applyTemplate = (templateCode: string) => {
453
566
  const tpl = roleTemplates?.find((t) => t.code === templateCode)
@@ -494,16 +607,12 @@ export function RoleFormPage({
494
607
  }
495
608
 
496
609
  const visibleGroups = Object.entries(groupedMatrix).filter(
497
- ([group, groupResources]) =>
498
- groupResources.length > 0 &&
499
- (searching || activeGroup === "all" || activeGroup === group)
610
+ ([, groupResources]) => groupResources.length > 0
500
611
  )
501
612
 
502
613
  return (
503
614
  <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. ===== */}
615
+ {/* ===== 1. Header bar navy 1 DÒNG ===== */}
507
616
  <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
617
  <Button
509
618
  variant="ghost"
@@ -548,8 +657,6 @@ export function RoleFormPage({
548
657
  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
658
  />
550
659
  )}
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
660
  {(() => {
554
661
  const statusMeta = getStatusMeta(
555
662
  formData.status,
@@ -613,7 +720,7 @@ export function RoleFormPage({
613
720
  </div>
614
721
 
615
722
  <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 ===== */}
723
+ {/* ===== 2. Toolbar sticky: search · bộ quyền mẫu · đếm · tab ===== */}
617
724
  <div className="sticky top-0 z-20 border-b border-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/85">
618
725
  <div className="flex flex-wrap items-center gap-2 px-3 py-2 sm:px-4">
619
726
  <div className="relative min-w-[140px] flex-1 sm:max-w-64">
@@ -658,60 +765,18 @@ export function RoleFormPage({
658
765
  </p>
659
766
  </div>
660
767
 
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
- >
693
- {group}
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
- })}
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) => (
771
+ <span key={l.key} className="flex items-center gap-1">
772
+ <span className="font-medium text-foreground">{l.label}</span>
773
+ <span className="opacity-80">= {l.hint}</span>
774
+ </span>
775
+ ))}
711
776
  </div>
712
777
  </div>
713
778
 
714
- {/* ===== 3. Bảng quyền theo phân hệ ===== */}
779
+ {/* ===== 3. Dòng tài nguyên: mức truy cập + thao tác nghiệp vụ ===== */}
715
780
  {visibleGroups.length === 0 ? (
716
781
  <div className="flex flex-col items-center justify-center py-16 text-center">
717
782
  <Search className="mb-3 h-6 w-6 text-muted-foreground" />
@@ -721,14 +786,17 @@ export function RoleFormPage({
721
786
  </div>
722
787
  ) : (
723
788
  visibleGroups.map(([group, groupResources]) => {
724
- const { selected, total } = groupSelectedCount(group)
789
+ const codes = groupPermissionCodes(groupResources)
790
+ const selected = countSelected(codes)
791
+ const total = codes.length
725
792
  const isFull = total > 0 && selected === total
793
+ const groupLevel = deriveGroupLevel(groupResources)
794
+ const groupExpanded = searching || expandedGroups.has(group)
726
795
 
727
796
  return (
728
797
  <section key={group}>
729
- {/* Tiêu đề nhóm */}
730
- <div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
731
- <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">
732
800
  {group}
733
801
  </h3>
734
802
  <span
@@ -742,217 +810,226 @@ export function RoleFormPage({
742
810
  {groupResources.length} tài nguyên · {selected}/{total}
743
811
  {isFull && <Check className="ml-0.5 inline h-3 w-3" />}
744
812
  </span>
745
- <div className="ml-auto flex shrink-0 items-center gap-1">
746
- <Button
747
- type="button"
748
- variant="ghost"
749
- size="sm"
750
- onClick={() => toggleAllForGroup(group, false)}
751
- disabled={selected === 0}
752
- 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
+ }
753
827
  >
754
- Bỏ chọn
755
- </Button>
756
- <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
757
845
  type="button"
758
- variant="ghost"
759
- size="sm"
760
- onClick={() => toggleAllForGroup(group, true)}
761
- disabled={isFull}
762
- 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"
763
848
  >
764
- Chọn tất cả
765
- </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>
766
856
  </div>
767
857
  </div>
768
858
 
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>
859
+ {groupExpanded && (
860
+ <ul className="divide-y divide-border/60 border-b border-border">
861
+ {groupResources.map((r) => {
862
+ const level = deriveLevel(r)
863
+ const bizOn = r.businessActions.filter((a) =>
864
+ has(r.resource, a)
865
+ ).length
866
+ const expanded = expandedRows.has(r.resource)
867
+ const page = resourcePages?.[r.resource]
868
+
869
+ // Nhóm thao tác theo LUỒNG
870
+ const flows = new Map<string, string[]>()
871
+ r.businessActions.forEach((code) => {
872
+ const flow = actionMeta?.[code]?.flow || "Khác"
873
+ const list = flows.get(flow) ?? []
874
+ list.push(code)
875
+ flows.set(flow, list)
876
+ })
877
+
878
+ return (
879
+ <li key={r.resource} className="px-4 py-2.5">
880
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
881
+ {/* Tên + trang */}
882
+ <div className="flex min-w-0 flex-1 basis-52 items-center gap-2">
883
+ <DynamicIcon
884
+ name={r.icon as any}
885
+ className="h-4 w-4 shrink-0 text-muted-foreground"
886
+ />
887
+ <div className="min-w-0">
888
+ <div className="truncate text-sm font-medium text-foreground">
889
+ {r.name}
890
+ </div>
891
+ <div className="truncate font-mono text-[10px] text-muted-foreground">
892
+ {page || r.resource}
843
893
  </div>
844
894
  </div>
845
- </td>
846
- {STANDARD_COLUMNS.map((col) => {
847
- const declared = resource.actions.includes(col.code)
848
- if (!declared) {
895
+ </div>
896
+
897
+ {/* Segmented 5 nấc */}
898
+ <div className="flex shrink-0 overflow-hidden rounded-lg border border-border">
899
+ {LEVELS.map((l, i) => {
900
+ const disabled =
901
+ (l.key === "lookup" && !r.hasLookup) ||
902
+ (l.key === "write" &&
903
+ r.writeCodes.length === 0) ||
904
+ (l.key === "full" &&
905
+ r.writeCodes.length === 0 &&
906
+ !r.hasDelete)
907
+ const active = level === l.key
849
908
  return (
850
- <td
851
- key={col.code}
852
- className="px-1 py-2 text-center text-muted-foreground/30"
909
+ <button
910
+ key={l.key}
911
+ type="button"
912
+ disabled={disabled}
913
+ onClick={() => setLevel(r, l.key)}
914
+ title={l.hint}
915
+ className={cn(
916
+ "px-2 py-1 text-[11px] font-medium transition-colors",
917
+ i > 0 && "border-l border-border",
918
+ disabled
919
+ ? "cursor-not-allowed bg-muted/40 text-muted-foreground/40"
920
+ : active
921
+ ? l.key === "hidden"
922
+ ? "bg-muted text-foreground"
923
+ : "bg-primary text-primary-foreground"
924
+ : "bg-card text-muted-foreground hover:bg-muted hover:text-foreground"
925
+ )}
853
926
  >
854
- ·
855
- </td>
927
+ {l.label}
928
+ </button>
856
929
  )
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
930
+ })}
931
+ </div>
932
+
933
+ {/* Thao tác nghiệp vụ — nút thu gọn */}
934
+ {r.businessActions.length > 0 && (
935
+ <button
936
+ type="button"
937
+ onClick={() => toggleRowExpand(r.resource)}
938
+ className={cn(
939
+ "flex shrink-0 items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium transition-colors",
940
+ bizOn > 0
941
+ ? "border-primary/40 bg-primary/5 text-primary"
942
+ : "border-border bg-card text-muted-foreground hover:text-foreground"
943
+ )}
944
+ >
945
+ {expanded ? (
946
+ <ChevronDown className="h-3 w-3" />
947
+ ) : (
948
+ <ChevronRight className="h-3 w-3" />
949
+ )}
950
+ Thao tác nghiệp vụ
951
+ <span className="tabular-nums">
952
+ {bizOn}/{r.businessActions.length}
953
+ </span>
954
+ </button>
955
+ )}
956
+ </div>
957
+
958
+ {/* Panel thao tác nghiệp vụ — nhóm theo LUỒNG */}
959
+ {expanded && r.businessActions.length > 0 && (
960
+ <div className="mt-2 space-y-1.5 rounded-lg border border-border/70 bg-muted/20 px-3 py-2 sm:ml-6">
961
+ {level === "hidden" || level === "lookup" ? (
962
+ <p className="text-[11px] text-muted-foreground">
963
+ Cần mức <b>Xem</b> trở lên — tick một thao tác
964
+ sẽ tự nâng lên Xem.
965
+ </p>
966
+ ) : null}
967
+ {Array.from(flows.entries()).map(
968
+ ([flow, codes]) => (
969
+ <div
970
+ key={flow}
971
+ className="flex flex-wrap items-center gap-1.5"
972
+ >
973
+ <span className="w-24 shrink-0 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
974
+ {flow}
975
+ </span>
976
+ {codes.map((code) => {
977
+ const isChecked = has(r.resource, code)
978
+ const meta = actionMeta?.[code]
979
+ const visual = getActionVisual(code)
980
+ const ActionIcon = visual.icon
981
+ return (
982
+ <button
983
+ key={code}
984
+ type="button"
985
+ onClick={() =>
986
+ toggleBusinessAction(r, code)
987
+ }
988
+ title={
989
+ meta?.description ||
990
+ actionName(code)
991
+ }
908
992
  className={cn(
909
- "flex h-3 w-3 shrink-0 items-center justify-center rounded-[3px] border transition-colors",
993
+ "inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium transition-colors",
910
994
  isChecked
911
- ? "border-primary bg-primary text-primary-foreground"
912
- : "border-border bg-card text-transparent"
995
+ ? "border-primary/40 bg-primary/10 text-primary"
996
+ : "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground"
913
997
  )}
914
998
  >
915
- <Check
916
- className="h-2 w-2"
917
- strokeWidth={3}
999
+ <span
1000
+ className={cn(
1001
+ "flex h-3 w-3 shrink-0 items-center justify-center rounded-[3px] border transition-colors",
1002
+ isChecked
1003
+ ? "border-primary bg-primary text-primary-foreground"
1004
+ : "border-border bg-card text-transparent"
1005
+ )}
1006
+ >
1007
+ <Check
1008
+ className="h-2 w-2"
1009
+ strokeWidth={3}
1010
+ />
1011
+ </span>
1012
+ <ActionIcon
1013
+ className={cn(
1014
+ "h-3 w-3 shrink-0",
1015
+ visual.cls,
1016
+ !isChecked && "opacity-55"
1017
+ )}
918
1018
  />
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>
1019
+ {actionName(code)}
1020
+ </button>
1021
+ )
1022
+ })}
1023
+ </div>
1024
+ )
932
1025
  )}
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>
1026
+ </div>
1027
+ )}
1028
+ </li>
1029
+ )
1030
+ })}
1031
+ </ul>
1032
+ )}
956
1033
  </section>
957
1034
  )
958
1035
  })