@goplusvn/core 0.1.40 → 0.1.41

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.
@@ -1,23 +1,21 @@
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.
3
+ // Trang tạo/sửa VAI TRÒ — v6 "CÂY MENU" (chốt với chủ app 2026-07-24):
4
+ //
5
+ // Phân quyền theo ĐÚNG cái nhân viên nhìn thấy — cây menu thật của app
6
+ // (prop menuTree, dựng từ navigations):
7
+ // ▼ Nhóm menu (Bán hàng, Kho hàng…) [checkbox cả nhóm]
8
+ // ☑ Trang (POS Bán hàng · /sales/pos) ← tick = THẤY MENU + vào trang
9
+ // trong trang: ☑ Tạo ☑ Sửa ☐ Xóa ☐ nút Xác nhận TT ☐ Xem giá vốn…
10
+ // ← nút thật trên trang đó
11
+ // - "Phạm vi dữ liệu" (chi nhánh) = 1 dropdown đầu trang → map quyền
12
+ // view-all-branches trên mọi tài nguyên có khai.
13
+ // - Combo/select: dòng thông báo "tự động, không cần cấp" (lookupPolicy
14
+ // hệ thống lo) — admin không phải nghĩ về combo.
15
+ // - Quyền không gắn menu (dialog/route ẩn) gom vào mục cuối.
16
+ // - Tick thao tác có `requires` → tự bật quyền tiên quyết (toast);
17
+ // bỏ tick 1 quyền → tự tắt các thao tác phụ thuộc nó.
4
18
  //
5
- // Ma trận quyền v4 — 2 TẦNG kiểu Odoo (chốt với chủ app 2026-07):
6
- // TẦNG 1 (mặt tiền): mỗi PHÂN HỆ một dropdown mức
7
- // [Không truy cập | Tra cứu | Xem | Nhân viên | Quản lý]
8
- // — chọn mức áp trọn bộ quyền chuẩn của phân hệ; lệch chuẩn → "Tùy chỉnh".
9
- // TẦNG 2 (bấm "Tùy chỉnh ▸" mới bung): chi tiết từng tài nguyên như v3:
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.
21
19
  // API contract giữ nguyên: POST /api/roles, PUT /api/roles/:id, quyền dạng
22
20
  // `${action}:${resource}`.
23
21
  import * as React from "react"
@@ -25,6 +23,7 @@ import { useRouter } from "next/navigation"
25
23
  import {
26
24
  Badge,
27
25
  Button,
26
+ Checkbox,
28
27
  DynamicIcon,
29
28
  Input,
30
29
  Select,
@@ -37,32 +36,18 @@ import {
37
36
  import { toast } from "sonner"
38
37
  import {
39
38
  ArrowLeft,
40
- BadgeCheck,
41
- Ban,
42
- Bot,
43
- Check,
39
+ Building2,
40
+ Search,
44
41
  ChevronDown,
45
42
  ChevronRight,
46
43
  Copy,
47
- Download,
48
- Eye,
44
+ Info,
49
45
  LayoutTemplate,
50
- PenLine,
51
- Pencil,
52
- Plus,
53
- Printer,
54
- RefreshCw,
55
46
  Save,
56
- Search,
57
- Settings2,
58
- Trash2,
59
- Upload,
60
47
  X,
61
- Zap,
62
48
  } from "lucide-react"
63
49
 
64
50
  import type { CrudPermissions } from "../../types"
65
- import type { LucideIcon } from "lucide-react"
66
51
 
67
52
  import { errorMessage, throwFetchError } from "../lib/fetch-error"
68
53
  import { cn } from "../../utils"
@@ -73,96 +58,22 @@ export interface ActionLabelConfig {
73
58
  color: string
74
59
  }
75
60
 
76
- // Nhãn mặc định cho action CRUD chuẩn (giữ tương thích prop actionLabels).
61
+ // Nhãn action CRUD chuẩn hiển thị như NÚT trên trang.
77
62
  const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
78
- view: { label: "Xem", icon: "👁️", color: "text-blue-600" },
79
- create: { label: "Tạo", icon: "➕", color: "text-green-600" },
80
- update: { label: "Sửa", icon: "✏️", color: "text-yellow-600" },
81
- delete: { label: "Xóa", icon: "🗑️", color: "text-destructive" },
82
- import: { label: "Import", icon: "📥", color: "text-purple-600" },
83
- export: { label: "Export", icon: "📤", color: "text-indigo-600" },
84
- approve: { label: "Duyệt", icon: "✅", color: "text-emerald-600" },
85
- manage: { label: "Quản lý", icon: "🔧", color: "text-orange-600" },
86
- edit_items: { label: "Sửa chi tiết", icon: "📝", color: "text-amber-600" },
87
- delete_items: { label: "Xóa chi tiết", icon: "❌", color: "text-rose-600" },
63
+ view: { label: "Xem", icon: "👁️", color: "" },
64
+ create: { label: "Tạo mới", icon: "➕", color: "" },
65
+ update: { label: "Sửa", icon: "✏️", color: "" },
66
+ delete: { label: "Xóa", icon: "🗑️", color: "" },
67
+ import: { label: "Nhập Excel", icon: "📥", color: "" },
68
+ export: { label: "Xuất Excel", icon: "📤", color: "" },
69
+ approve: { label: "Duyệt", icon: "✅", color: "" },
70
+ manage: { label: "Quản lý", icon: "🔧", color: "" },
71
+ edit_items: { label: "Sửa chi tiết", icon: "📝", color: "" },
72
+ delete_items: { label: "Xóa chi tiết", icon: "❌", color: "" },
88
73
  }
89
74
 
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
- },
129
- ]
130
-
131
- // --- Icon + màu theo LOẠI action (suy từ mã) — mọi action nghiệp vụ đều có
132
- // icon mà không cần cấu hình từng cái. ---
133
- const ACTION_VISUAL_RULES: Array<{
134
- re: RegExp
135
- icon: LucideIcon
136
- cls: string
137
- }> = [
138
- { re: /^(view|read|get|list)/, icon: Eye, cls: "text-blue-500" },
139
- { re: /^(create|add|new)/, icon: Plus, cls: "text-emerald-500" },
140
- { re: /^(update|edit|change|set)/, icon: Pencil, cls: "text-amber-500" },
141
- { re: /^(delete|remove)/, icon: Trash2, cls: "text-rose-500" },
142
- { re: /^(cancel|void|reject)/, icon: Ban, cls: "text-rose-500" },
143
- {
144
- re: /^(approve|confirm|verify|accept|complete)/,
145
- icon: BadgeCheck,
146
- cls: "text-violet-500",
147
- },
148
- { re: /^sign/, icon: PenLine, cls: "text-indigo-500" },
149
- { re: /^import/, icon: Download, cls: "text-purple-500" },
150
- { re: /^export/, icon: Upload, cls: "text-indigo-500" },
151
- { re: /^print/, icon: Printer, cls: "text-cyan-600" },
152
- { re: /^(sync|unlink)/, icon: RefreshCw, cls: "text-cyan-600" },
153
- {
154
- re: /^(manage|config|close|reopen)/,
155
- icon: Settings2,
156
- cls: "text-orange-500",
157
- },
158
- { re: /^auto/, icon: Bot, cls: "text-purple-500" },
159
- ]
160
-
161
- const getActionVisual = (code: string) =>
162
- ACTION_VISUAL_RULES.find((r) => r.re.test(code)) ?? {
163
- icon: Zap,
164
- cls: "text-slate-400",
165
- }
75
+ // Thứ tự hiển thị action trong một trang: CRUD chuẩn trước, nghiệp vụ sau.
76
+ const STANDARD_ORDER = ["create", "update", "delete", "export", "import"]
166
77
 
167
78
  /** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
168
79
  export interface RoleTemplate {
@@ -172,16 +83,29 @@ export interface RoleTemplate {
172
83
  permissions: string[]
173
84
  }
174
85
 
175
- /** Metadata action nghiệp vụ: nhóm luồng + mô tả + quyền tiên quyết. */
86
+ /** Metadata action (consumer truyền từ Permission Registry). */
176
87
  export interface ActionMetaEntry {
177
88
  label?: string
178
89
  description?: string
179
90
  flow?: string
180
91
  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
92
  level?: "staff" | "manager"
183
93
  }
184
94
 
95
+ /** Cây menu thật của app — dựng từ navigations. */
96
+ export interface MenuTreeSection {
97
+ title: string
98
+ icon?: string
99
+ items: Array<{
100
+ title: string
101
+ href?: string
102
+ icon?: string
103
+ resource: string
104
+ /** Vị trí thật khi không phải menu: "nút Cấu hình trong trang Mua hàng"… */
105
+ note?: string
106
+ }>
107
+ }
108
+
185
109
  export interface RoleFormPageProps {
186
110
  dictionary: any
187
111
  permissions?: CrudPermissions
@@ -213,12 +137,11 @@ export interface RoleFormPageProps {
213
137
  permissions: string[]
214
138
  }
215
139
  actionLabels?: Record<string, ActionLabelConfig>
216
- /** Bộ quyền mẫu hiện ở chế độ tạo mới (additive — không truyền thì ẩn). */
217
140
  roleTemplates?: RoleTemplate[]
218
- /** Meta action nghiệp vụ theo code (flow/description/requires) — additive. */
219
141
  actionMeta?: Record<string, ActionMetaEntry>
220
- /** Đường dẫn trang theo resource code (hiện dưới tên) — additive. */
221
142
  resourcePages?: Record<string, string>
143
+ /** Cây menu thật — không truyền thì tự dựng từ group của resources. */
144
+ menuTree?: MenuTreeSection[]
222
145
  }
223
146
 
224
147
  export function RoleFormPage({
@@ -233,6 +156,7 @@ export function RoleFormPage({
233
156
  roleTemplates,
234
157
  actionMeta,
235
158
  resourcePages,
159
+ menuTree,
236
160
  }: RoleFormPageProps) {
237
161
  const router = useRouter()
238
162
 
@@ -242,6 +166,7 @@ export function RoleFormPage({
242
166
  )
243
167
 
244
168
  const [isSubmitting, setIsSubmitting] = React.useState(false)
169
+ const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set())
245
170
  const [searchTerm, setSearchTerm] = React.useState("")
246
171
 
247
172
  const [formData, setFormData] = React.useState({
@@ -252,36 +177,10 @@ export function RoleFormPage({
252
177
  permissions: initialData?.permissions || ([] as string[]),
253
178
  })
254
179
 
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
- }
279
-
280
180
  const getLocalizedName = React.useCallback(
281
181
  (key: string | null) => {
282
182
  if (!key) return ""
283
183
  if (!dictionary) return key
284
-
285
184
  const parts = key.split(".")
286
185
  let current = dictionary
287
186
  for (const part of parts) {
@@ -297,107 +196,113 @@ export function RoleFormPage({
297
196
  )
298
197
 
299
198
  const actionName = React.useCallback(
300
- (code: string) => {
301
- return (
302
- actionMeta?.[code]?.label ||
303
- actions.find((a) => a.code === code)?.name ||
304
- mergedActionLabels[code]?.label ||
305
- code
306
- )
307
- },
199
+ (code: string) =>
200
+ actionMeta?.[code]?.label ||
201
+ actions.find((a) => a.code === code)?.name ||
202
+ mergedActionLabels[code]?.label ||
203
+ code,
308
204
  [actions, actionMeta, mergedActionLabels]
309
205
  )
310
206
 
311
- const permissionMatrix = React.useMemo(() => {
312
- return resources.map((resource) => {
313
- let allowedActions: string[] = []
207
+ // ── Resource action khai báo ────────────────────────────────────────
208
+ const resourceInfo = React.useMemo(() => {
209
+ const map = new Map<
210
+ string,
211
+ { name: string; icon: string | null; allowed: string[] }
212
+ >()
213
+ resources.forEach((resource) => {
214
+ let allowed: string[] = []
314
215
  const defaultActions = actions
315
216
  .filter((a) => a.isDefault)
316
217
  .map((a) => a.code)
317
- const effectiveDefaultActions =
218
+ const fallback =
318
219
  defaultActions.length > 0
319
220
  ? defaultActions
320
221
  : ["view", "create", "update", "delete"]
321
-
322
- if (resource.config) {
323
- try {
324
- const config =
325
- typeof resource.config === "string"
326
- ? JSON.parse(resource.config)
327
- : resource.config
328
-
329
- if (config.actions && Array.isArray(config.actions)) {
330
- const allowedCodes = config.actions as string[]
331
- allowedActions = actions
332
- .map((a) => a.code)
333
- .filter((code) => allowedCodes.includes(code))
334
- } else {
335
- allowedActions = actions
336
- .map((a) => a.code)
337
- .filter((code) => effectiveDefaultActions.includes(code))
338
- }
339
- } catch (_e) {
340
- allowedActions = actions
222
+ try {
223
+ const config =
224
+ typeof resource.config === "string"
225
+ ? JSON.parse(resource.config)
226
+ : resource.config
227
+ if (config?.actions && Array.isArray(config.actions)) {
228
+ allowed = actions
341
229
  .map((a) => a.code)
342
- .filter((code) => effectiveDefaultActions.includes(code))
230
+ .filter((c) => (config.actions as string[]).includes(c))
231
+ } else {
232
+ allowed = actions
233
+ .map((a) => a.code)
234
+ .filter((c) => fallback.includes(c))
343
235
  }
344
- } else {
345
- allowedActions = actions
346
- .map((a) => a.code)
347
- .filter((code) => effectiveDefaultActions.includes(code))
236
+ } catch {
237
+ allowed = actions.map((a) => a.code).filter((c) => fallback.includes(c))
348
238
  }
349
-
350
- return {
351
- resource: resource.code,
239
+ map.set(resource.code, {
352
240
  name: getLocalizedName(resource.name),
353
- icon: resource.icon || "📄",
354
- group: getLocalizedName(resource.group) || "Khác",
355
- description: resource.description,
356
- actions: allowedActions,
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"),
363
- }
241
+ icon: resource.icon,
242
+ allowed,
243
+ })
364
244
  })
245
+ return map
365
246
  }, [resources, actions, getLocalizedName])
366
247
 
367
- type MatrixRow = (typeof permissionMatrix)[number]
368
-
369
- const totalAvailablePermissions = React.useMemo(
370
- () => permissionMatrix.reduce((sum, r) => sum + r.actions.length, 0),
371
- [permissionMatrix]
248
+ /** Action hiển thị dưới một trang (bỏ view = chính checkbox trang; bỏ lookup
249
+ * = combo tự động). CRUD chuẩn trước, nghiệp vụ sau. */
250
+ const pageActions = React.useCallback(
251
+ (resource: string) => {
252
+ const info = resourceInfo.get(resource)
253
+ if (!info) return []
254
+ const rest = info.allowed.filter(
255
+ (a) => a !== "view" && a !== "lookup" && a !== "view-all-branches"
256
+ )
257
+ const std = STANDARD_ORDER.filter((a) => rest.includes(a))
258
+ const biz = rest.filter((a) => !STANDARD_ORDER.includes(a))
259
+ return [...std, ...biz]
260
+ },
261
+ [resourceInfo]
372
262
  )
373
263
 
374
- const filteredMatrix = React.useMemo(() => {
375
- if (!searchTerm.trim()) return permissionMatrix
376
-
377
- const search = searchTerm.toLowerCase()
378
- return permissionMatrix.filter(
379
- (resource) =>
380
- resource.name.toLowerCase().includes(search) ||
381
- resource.resource.toLowerCase().includes(search) ||
382
- resource.group.toLowerCase().includes(search)
383
- )
384
- }, [searchTerm, permissionMatrix])
385
-
386
- const groupedMatrix = React.useMemo(() => {
387
- const groups: Record<string, MatrixRow[]> = {}
388
- filteredMatrix.forEach((resource) => {
389
- const group = resource.group
390
- if (!groups[group]) {
391
- groups[group] = []
392
- }
393
- groups[group].push(resource)
264
+ // ── Cây menu (prop, hoặc tự dựng từ group) ────────────────────────────
265
+ const tree = React.useMemo<MenuTreeSection[]>(() => {
266
+ if (menuTree && menuTree.length > 0) return menuTree
267
+ const byGroup = new Map<string, MenuTreeSection>()
268
+ resources.forEach((r) => {
269
+ const group = getLocalizedName(r.group) || "Khác"
270
+ const section = byGroup.get(group) ?? { title: group, items: [] }
271
+ section.items.push({
272
+ title: getLocalizedName(r.name),
273
+ href: resourcePages?.[r.code],
274
+ icon: r.icon ?? undefined,
275
+ resource: r.code,
276
+ })
277
+ byGroup.set(group, section)
394
278
  })
395
- return groups
396
- }, [filteredMatrix])
279
+ return Array.from(byGroup.values())
280
+ }, [menuTree, resources, resourcePages, getLocalizedName])
397
281
 
398
-
399
- // Đang search mọi nhóm khớp tự bung phần chi tiết.
400
- const searching = searchTerm.trim().length > 0
282
+ /** Resource có action khai nhưng KHÔNG nằm trên menu nào. */
283
+ const offMenuResources = React.useMemo(() => {
284
+ const onMenu = new Set(
285
+ tree.flatMap((s) => s.items.map((i) => i.resource))
286
+ )
287
+ return resources
288
+ .map((r) => r.code)
289
+ .filter((code) => {
290
+ if (onMenu.has(code)) return false
291
+ const info = resourceInfo.get(code)
292
+ if (!info) return false
293
+ // chỉ còn lookup (vd resource "reference") → thuộc phần combo tự động
294
+ return info.allowed.some((a) => a !== "lookup")
295
+ })
296
+ }, [tree, resources, resourceInfo])
297
+
298
+ const totalAvailable = React.useMemo(
299
+ () =>
300
+ Array.from(resourceInfo.values()).reduce(
301
+ (s, r) => s + r.allowed.length,
302
+ 0
303
+ ),
304
+ [resourceInfo]
305
+ )
401
306
 
402
307
  const has = React.useCallback(
403
308
  (resource: string, action: string) =>
@@ -405,9 +310,7 @@ export function RoleFormPage({
405
310
  [formData.permissions]
406
311
  )
407
312
 
408
- const mutatePermissions = (
409
- fn: (set: Set<string>) => void
410
- ) => {
313
+ const mutate = (fn: (set: Set<string>) => void) => {
411
314
  setFormData((prev) => {
412
315
  const set = new Set(prev.permissions)
413
316
  fn(set)
@@ -415,152 +318,109 @@ export function RoleFormPage({
415
318
  })
416
319
  }
417
320
 
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"
428
- }
429
- if (r.hasLookup && has(r.resource, "lookup")) return "lookup"
430
- return "hidden"
431
- }
432
-
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)
321
+ // ── Tick trang (menu) ─────────────────────────────────────────────────
322
+ const togglePage = (resource: string, checked: boolean) => {
323
+ const info = resourceInfo.get(resource)
324
+ if (!info) return
325
+ mutate((set) => {
326
+ if (checked) {
327
+ if (info.allowed.includes("view")) set.add(`view:${resource}`)
328
+ } else {
329
+ // mất menu → mọi quyền của trang tắt theo
330
+ info.allowed.forEach((a) => set.delete(`${a}:${resource}`))
456
331
  }
457
- if (level === "full" && r.hasDelete) add("delete")
458
332
  })
459
333
  }
460
334
 
461
- // ── TẦNG 1: mức theo PHÂN HỆ ──────────────────────────────────────────
462
- const canonicalForResource = React.useCallback(
463
- (r: MatrixRow, level: GroupLevel): string[] => {
464
- switch (level) {
465
- case "none":
466
- return []
467
- case "lookup":
468
- return r.hasLookup ? [`lookup:${r.resource}`] : []
469
- case "view":
470
- return r.actions.includes("view") ? [`view:${r.resource}`] : []
471
- case "staff": {
472
- const staffBiz = r.businessActions.filter(
473
- (a) => actionMeta?.[a]?.level !== "manager"
474
- )
475
- return [
476
- ...(r.actions.includes("view") ? ["view"] : []),
477
- ...r.writeCodes,
478
- ...staffBiz,
479
- ].map((a) => `${a}:${r.resource}`)
480
- }
481
- case "manager":
482
- return r.actions.map((a) => `${a}:${r.resource}`)
483
- }
484
- },
485
- [actionMeta]
486
- )
487
-
488
- const canonicalForGroup = React.useCallback(
489
- (rows: MatrixRow[], level: GroupLevel) =>
490
- rows.flatMap((r) => canonicalForResource(r, level)),
491
- [canonicalForResource]
492
- )
493
-
494
- /** Mức hiện tại của phân hệ — "custom" nếu không khớp mức chuẩn nào. */
495
- const deriveGroupLevel = React.useCallback(
496
- (rows: MatrixRow[]): GroupLevel | "custom" => {
497
- const current = new Set(
498
- rows
499
- .flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`))
500
- .filter((c) => formData.permissions.includes(c))
335
+ // ── Tick thao tác trong trang (requires 2 chiều) ──────────────────────
336
+ const toggleAction = (resource: string, code: string) => {
337
+ const info = resourceInfo.get(resource)
338
+ if (!info) return
339
+ const key = `${code}:${resource}`
340
+ if (formData.permissions.includes(key)) {
341
+ // tắt X → tắt luôn các thao tác phụ thuộc X
342
+ const dependents = info.allowed.filter((a) =>
343
+ (actionMeta?.[a]?.requires ?? []).includes(code)
501
344
  )
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
- }
345
+ const on = dependents.filter((a) =>
346
+ formData.permissions.includes(`${a}:${resource}`)
347
+ )
348
+ mutate((set) => {
349
+ set.delete(key)
350
+ on.forEach((a) => set.delete(`${a}:${resource}`))
351
+ })
352
+ if (on.length > 0) {
353
+ toast.info(
354
+ `Đã tắt kèm: ${on.map((a) => actionName(a)).join(", ")} (phụ thuộc "${actionName(code)}")`
355
+ )
510
356
  }
511
- return "custom"
512
- },
513
- [formData.permissions, canonicalForGroup]
514
- )
515
-
516
- const setGroupLevel = (rows: MatrixRow[], level: GroupLevel) => {
517
- const all = rows.flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`))
518
- const canonical = canonicalForGroup(rows, level)
519
- mutatePermissions((set) => {
520
- all.forEach((c) => set.delete(c))
521
- canonical.forEach((c) => set.add(c))
522
- })
523
- }
524
-
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
357
  return
531
358
  }
532
- const requires = actionMeta?.[code]?.requires ?? []
533
- const needed = ["view", ...requires].filter(
359
+ const requires = (actionMeta?.[code]?.requires ?? []).filter(
534
360
  (req) =>
535
- r.actions.includes(req) &&
536
- !formData.permissions.includes(`${req}:${r.resource}`)
361
+ info.allowed.includes(req) &&
362
+ !formData.permissions.includes(`${req}:${resource}`)
537
363
  )
538
- mutatePermissions((set) => {
364
+ mutate((set) => {
539
365
  set.add(key)
540
- needed.forEach((req) => set.add(`${req}:${r.resource}`))
366
+ requires.forEach((req) => set.add(`${req}:${resource}`))
541
367
  })
542
- if (needed.length > 0) {
368
+ if (requires.length > 0) {
543
369
  toast.info(
544
- `Đã bật kèm quyền tiên quyết: ${needed
545
- .map((c) => actionName(c))
370
+ `Đã bật kèm quyền tiên quyết: ${requires
371
+ .map((a) => actionName(a))
546
372
  .join(", ")}`
547
373
  )
548
374
  }
549
375
  }
550
376
 
551
- // ── Đếm ───────────────────────────────────────────────────────────────
552
- const groupPermissionCodes = React.useCallback(
553
- (rows: MatrixRow[]) =>
554
- rows.flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`)),
555
- []
556
- )
377
+ // ── Tick cả nhóm menu ─────────────────────────────────────────────────
378
+ const sectionState = (section: MenuTreeSection) => {
379
+ const uniq = [...new Set(section.items.map((i) => i.resource))]
380
+ const on = uniq.filter((r) => has(r, "view")).length
381
+ return { total: uniq.length, on }
382
+ }
557
383
 
558
- const countSelected = React.useCallback(
559
- (codes: string[]) =>
560
- codes.filter((p) => formData.permissions.includes(p)).length,
561
- [formData.permissions]
384
+ const toggleSection = (section: MenuTreeSection, checked: boolean) => {
385
+ const uniq = [...new Set(section.items.map((i) => i.resource))]
386
+ uniq.forEach((r) => togglePageBatch(r, checked))
387
+ function togglePageBatch(resource: string, on: boolean) {
388
+ const info = resourceInfo.get(resource)
389
+ if (!info) return
390
+ mutate((set) => {
391
+ if (on) {
392
+ if (info.allowed.includes("view")) set.add(`view:${resource}`)
393
+ } else {
394
+ info.allowed.forEach((a) => set.delete(`${a}:${resource}`))
395
+ }
396
+ })
397
+ }
398
+ }
399
+
400
+ // ── Phạm vi dữ liệu chi nhánh (view-all-branches) ─────────────────────
401
+ const branchScopeResources = React.useMemo(
402
+ () =>
403
+ Array.from(resourceInfo.entries())
404
+ .filter(([, info]) => info.allowed.includes("view-all-branches"))
405
+ .map(([code]) => code),
406
+ [resourceInfo]
562
407
  )
563
408
 
409
+ const branchScope: "assigned" | "all" = React.useMemo(() => {
410
+ if (branchScopeResources.length === 0) return "assigned"
411
+ return branchScopeResources.every((r) => has(r, "view-all-branches"))
412
+ ? "all"
413
+ : "assigned"
414
+ }, [branchScopeResources, has])
415
+
416
+ const setBranchScope = (scope: "assigned" | "all") => {
417
+ mutate((set) => {
418
+ branchScopeResources.forEach((r) => {
419
+ if (scope === "all") set.add(`view-all-branches:${r}`)
420
+ else set.delete(`view-all-branches:${r}`)
421
+ })
422
+ })
423
+ }
564
424
 
565
425
  const applyTemplate = (templateCode: string) => {
566
426
  const tpl = roleTemplates?.find((t) => t.code === templateCode)
@@ -574,29 +434,24 @@ export function RoleFormPage({
574
434
  const handleSubmit = async (e: React.FormEvent) => {
575
435
  e.preventDefault()
576
436
  setIsSubmitting(true)
577
-
578
437
  try {
579
438
  const url =
580
439
  mode === "create" ? "/api/roles" : `/api/roles/${initialData?.id}`
581
440
  const method = mode === "create" ? "POST" : "PUT"
582
-
583
441
  const res = await fetch(url, {
584
442
  method,
585
443
  headers: { "Content-Type": "application/json" },
586
444
  body: JSON.stringify(formData),
587
445
  })
588
-
589
446
  if (!res.ok) {
590
447
  await throwFetchError(res, `Failed to ${mode} role`)
591
448
  }
592
-
593
449
  toast.success("Thành công", {
594
450
  description:
595
451
  mode === "create"
596
452
  ? "Đã tạo vai trò mới thành công"
597
453
  : "Đã cập nhật vai trò thành công",
598
454
  })
599
-
600
455
  router.push(`/${lang}/roles`)
601
456
  router.refresh()
602
457
  } catch (error: any) {
@@ -606,10 +461,190 @@ export function RoleFormPage({
606
461
  }
607
462
  }
608
463
 
609
- const visibleGroups = Object.entries(groupedMatrix).filter(
610
- ([, groupResources]) => groupResources.length > 0
464
+ // Mở trang: tự thu gọn các nhóm chưa có menu nào được cấp — trang ngắn hẳn.
465
+ const didInitCollapse = React.useRef(false)
466
+ React.useEffect(() => {
467
+ if (didInitCollapse.current) return
468
+ didInitCollapse.current = true
469
+ const zero = tree
470
+ .filter((section) => {
471
+ const uniq = [...new Set(section.items.map((i) => i.resource))]
472
+ return !uniq.some((r) => has(r, "view"))
473
+ })
474
+ .map((s) => s.title)
475
+ if (zero.length > 0) setCollapsed(new Set(zero))
476
+ // eslint-disable-next-line react-hooks/exhaustive-deps
477
+ }, [])
478
+
479
+ const toggleCollapse = (title: string) => {
480
+ setCollapsed((prev) => {
481
+ const next = new Set(prev)
482
+ if (next.has(title)) next.delete(title)
483
+ else next.add(title)
484
+ return next
485
+ })
486
+ }
487
+
488
+ // ── Search: lọc theo tên menu / đường dẫn / tên thao tác ─────────────
489
+ const searching = searchTerm.trim().length > 0
490
+ const matchItem = React.useCallback(
491
+ (item: { title: string; href?: string; resource: string }) => {
492
+ if (!searching) return true
493
+ const q = searchTerm.trim().toLowerCase()
494
+ if (item.title.toLowerCase().includes(q)) return true
495
+ if (item.href?.toLowerCase().includes(q)) return true
496
+ if (item.resource.toLowerCase().includes(q)) return true
497
+ return pageActions(item.resource).some((code) =>
498
+ actionName(code).toLowerCase().includes(q)
499
+ )
500
+ },
501
+ [searching, searchTerm, pageActions, actionName]
611
502
  )
612
503
 
504
+ const visibleTree = React.useMemo(() => {
505
+ if (!searching) return tree
506
+ const q = searchTerm.trim().toLowerCase()
507
+ return tree
508
+ .map((section) => {
509
+ if (section.title.toLowerCase().includes(q)) return section
510
+ return { ...section, items: section.items.filter(matchItem) }
511
+ })
512
+ .filter((section) => section.items.length > 0)
513
+ }, [tree, searching, searchTerm, matchItem])
514
+
515
+ const visibleOffMenu = React.useMemo(() => {
516
+ if (!searching) return offMenuResources
517
+ const q = searchTerm.trim().toLowerCase()
518
+ return offMenuResources.filter((code) => {
519
+ const info = resourceInfo.get(code)
520
+ if (info?.name.toLowerCase().includes(q)) return true
521
+ if (code.toLowerCase().includes(q)) return true
522
+ return pageActions(code).some((c) =>
523
+ actionName(c).toLowerCase().includes(q)
524
+ )
525
+ })
526
+ }, [offMenuResources, searching, searchTerm, resourceInfo, pageActions, actionName])
527
+
528
+ // Render 1 dòng trang menu + hàng thao tác của nó
529
+ const renderPageRow = (item: {
530
+ title: string
531
+ href?: string
532
+ icon?: string
533
+ resource: string
534
+ note?: string
535
+ }) => {
536
+ const info = resourceInfo.get(item.resource)
537
+ if (!info) return null
538
+ const pageOn = has(item.resource, "view")
539
+ const acts = pageActions(item.resource)
540
+ const actsOn = acts.filter((a) => has(item.resource, a)).length
541
+
542
+ return (
543
+ <li key={`${item.resource}:${item.title}`} className="px-4 py-2">
544
+ <label className="flex cursor-pointer items-center gap-2.5">
545
+ <Checkbox
546
+ checked={pageOn}
547
+ onCheckedChange={(c) => togglePage(item.resource, c === true)}
548
+ aria-label={`Menu ${item.title}`}
549
+ />
550
+ <DynamicIcon
551
+ name={(item.icon || info.icon) as any}
552
+ className="h-4 w-4 shrink-0 text-muted-foreground"
553
+ />
554
+ <span
555
+ className={cn(
556
+ "text-sm font-medium",
557
+ pageOn ? "text-foreground" : "text-muted-foreground"
558
+ )}
559
+ >
560
+ {item.title}
561
+ </span>
562
+ {item.href && (
563
+ <code className="hidden font-mono text-[10px] text-muted-foreground sm:inline">
564
+ {item.href}
565
+ </code>
566
+ )}
567
+ {item.note && (
568
+ <span className="hidden text-[10px] italic text-muted-foreground/80 sm:inline">
569
+ {item.note}
570
+ </span>
571
+ )}
572
+ {!pageOn && (
573
+ <span
574
+ className="text-[10px] text-muted-foreground/60"
575
+ title="Vai trò này không thấy menu và không vào được trang"
576
+ >
577
+ · ẩn
578
+ </span>
579
+ )}
580
+ {pageOn && acts.length > 0 && (
581
+ <span className="ml-auto text-[10px] tabular-nums text-muted-foreground">
582
+ {actsOn}/{acts.length} thao tác
583
+ </span>
584
+ )}
585
+ </label>
586
+
587
+ {/* Thao tác (nút) trong trang — nhóm theo LUỒNG, mỗi luồng 1 dòng */}
588
+ {pageOn && acts.length > 0 && (
589
+ <div className="mt-1.5 space-y-1 pl-7 sm:pl-9">
590
+ {(() => {
591
+ // "Chung" = CRUD chuẩn; còn lại theo actionMeta.flow
592
+ const flows = new Map<string, string[]>()
593
+ acts.forEach((code) => {
594
+ const flow = STANDARD_ORDER.includes(code)
595
+ ? "Chung"
596
+ : actionMeta?.[code]?.flow || "Khác"
597
+ const list = flows.get(flow) ?? []
598
+ list.push(code)
599
+ flows.set(flow, list)
600
+ })
601
+ return Array.from(flows.entries()).map(([flow, codes]) => (
602
+ <div
603
+ key={flow}
604
+ className="flex flex-wrap items-center gap-x-3 gap-y-1"
605
+ >
606
+ <span className="w-24 shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground/60">
607
+ {flow}
608
+ </span>
609
+ {codes.map((code) => {
610
+ const meta = actionMeta?.[code]
611
+ const checked = has(item.resource, code)
612
+ return (
613
+ <label
614
+ key={code}
615
+ title={meta?.description || actionName(code)}
616
+ className="flex cursor-pointer items-center gap-1.5"
617
+ >
618
+ <Checkbox
619
+ checked={checked}
620
+ onCheckedChange={() =>
621
+ toggleAction(item.resource, code)
622
+ }
623
+ className="h-3.5 w-3.5"
624
+ aria-label={`${actionName(code)} — ${item.title}`}
625
+ />
626
+ <span
627
+ className={cn(
628
+ "text-xs",
629
+ checked
630
+ ? "text-foreground"
631
+ : "text-muted-foreground"
632
+ )}
633
+ >
634
+ {actionName(code)}
635
+ </span>
636
+ </label>
637
+ )
638
+ })}
639
+ </div>
640
+ ))
641
+ })()}
642
+ </div>
643
+ )}
644
+ </li>
645
+ )
646
+ }
647
+
613
648
  return (
614
649
  <div className="flex flex-col">
615
650
  {/* ===== 1. Header bar navy 1 DÒNG ===== */}
@@ -720,319 +755,178 @@ export function RoleFormPage({
720
755
  </div>
721
756
 
722
757
  <div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
723
- {/* ===== 2. Toolbar sticky: search · bộ quyền mẫu · đếm · tab ===== */}
724
- <div className="sticky top-0 z-20 border-b border-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/85">
725
- <div className="flex flex-wrap items-center gap-2 px-3 py-2 sm:px-4">
726
- <div className="relative min-w-[140px] flex-1 sm:max-w-64">
727
- <Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
728
- <Input
729
- placeholder="Tìm kiếm tài nguyên..."
730
- className="h-8 rounded-md border border-border bg-card pl-8 text-sm"
731
- value={searchTerm}
732
- onChange={(e) => setSearchTerm(e.target.value)}
733
- />
734
- </div>
735
- {mode === "create" && (roleTemplates?.length ?? 0) > 0 && (
736
- <Select onValueChange={applyTemplate}>
737
- <SelectTrigger className="h-8 w-44 rounded-md border border-border bg-card text-sm sm:w-52">
738
- <span className="flex items-center gap-1.5 truncate">
739
- <LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
740
- <SelectValue placeholder="Bộ quyền mẫu..." />
741
- </span>
758
+ {/* ===== 2. Toolbar: mẫu + phạm vi chi nhánh + đếm ===== */}
759
+ <div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2 sm:px-4">
760
+ <div className="relative min-w-[150px] flex-1 sm:max-w-64">
761
+ <Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
762
+ <Input
763
+ placeholder="Tìm menu / thao tác..."
764
+ className="h-8 rounded-md border border-border bg-card pl-8 text-sm"
765
+ value={searchTerm}
766
+ onChange={(e) => setSearchTerm(e.target.value)}
767
+ />
768
+ </div>
769
+ {mode === "create" && (roleTemplates?.length ?? 0) > 0 && (
770
+ <Select onValueChange={applyTemplate}>
771
+ <SelectTrigger className="h-8 w-48 rounded-md border border-border bg-card text-sm">
772
+ <span className="flex items-center gap-1.5 truncate">
773
+ <LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
774
+ <SelectValue placeholder="Bộ quyền mẫu..." />
775
+ </span>
776
+ </SelectTrigger>
777
+ <SelectContent className="rounded-lg">
778
+ {roleTemplates!.map((tpl) => (
779
+ <SelectItem key={tpl.code} value={tpl.code}>
780
+ <span className="flex flex-col">
781
+ <span>{tpl.label}</span>
782
+ {tpl.description && (
783
+ <span className="text-[11px] text-muted-foreground">
784
+ {tpl.description}
785
+ </span>
786
+ )}
787
+ </span>
788
+ </SelectItem>
789
+ ))}
790
+ </SelectContent>
791
+ </Select>
792
+ )}
793
+
794
+ {branchScopeResources.length > 0 && (
795
+ <div className="flex items-center gap-1.5">
796
+ <Building2 className="h-3.5 w-3.5 text-muted-foreground" />
797
+ <span className="text-xs text-muted-foreground">
798
+ Phạm vi dữ liệu:
799
+ </span>
800
+ <Select
801
+ value={branchScope}
802
+ onValueChange={(v) => setBranchScope(v as "assigned" | "all")}
803
+ >
804
+ <SelectTrigger className="h-8 w-52 rounded-md border border-border bg-card text-sm">
805
+ <SelectValue>
806
+ {branchScope === "all"
807
+ ? "Tất cả chi nhánh"
808
+ : "Chỉ chi nhánh được gán"}
809
+ </SelectValue>
742
810
  </SelectTrigger>
743
811
  <SelectContent className="rounded-lg">
744
- {roleTemplates!.map((tpl) => (
745
- <SelectItem key={tpl.code} value={tpl.code}>
746
- <span className="flex flex-col">
747
- <span>{tpl.label}</span>
748
- {tpl.description && (
749
- <span className="text-[11px] text-muted-foreground">
750
- {tpl.description}
751
- </span>
752
- )}
812
+ <SelectItem value="assigned">
813
+ <span className="flex flex-col">
814
+ <span>Chỉ chi nhánh được gán</span>
815
+ <span className="text-[11px] text-muted-foreground">
816
+ Theo chi nhánh gán ở trang Người dùng
817
+ </span>
818
+ </span>
819
+ </SelectItem>
820
+ <SelectItem value="all">
821
+ <span className="flex flex-col">
822
+ <span>Tất cả chi nhánh</span>
823
+ <span className="text-[11px] text-muted-foreground">
824
+ Xem dữ liệu mọi chi nhánh (quản lý vùng/kế toán tổng)
753
825
  </span>
754
- </SelectItem>
755
- ))}
826
+ </span>
827
+ </SelectItem>
756
828
  </SelectContent>
757
829
  </Select>
758
- )}
759
- <p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
760
- <span className="font-semibold tabular-nums text-foreground">
761
- {formData.permissions.length}
762
- </span>
763
- <span className="tabular-nums">/{totalAvailablePermissions}</span>{" "}
764
- quyền
765
- </p>
766
- </div>
767
-
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
- ))}
776
- </div>
830
+ </div>
831
+ )}
832
+
833
+ <p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
834
+ <span className="font-semibold tabular-nums text-foreground">
835
+ {formData.permissions.length}
836
+ </span>
837
+ <span className="tabular-nums">/{totalAvailable}</span> quyền
838
+ </p>
777
839
  </div>
778
840
 
779
- {/* ===== 3. Dòng tài nguyên: mức truy cập + thao tác nghiệp vụ ===== */}
780
- {visibleGroups.length === 0 ? (
781
- <div className="flex flex-col items-center justify-center py-16 text-center">
782
- <Search className="mb-3 h-6 w-6 text-muted-foreground" />
783
- <p className="text-sm text-muted-foreground">
784
- Không tài nguyên nào khớp bộ lọc.
785
- </p>
786
- </div>
787
- ) : (
788
- visibleGroups.map(([group, groupResources]) => {
789
- const codes = groupPermissionCodes(groupResources)
790
- const selected = countSelected(codes)
791
- const total = codes.length
792
- const isFull = total > 0 && selected === total
793
- const groupLevel = deriveGroupLevel(groupResources)
794
- const groupExpanded = searching || expandedGroups.has(group)
795
-
796
- return (
797
- <section key={group}>
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">
800
- {group}
801
- </h3>
802
- <span
803
- className={cn(
804
- "whitespace-nowrap text-[11px] tabular-nums",
805
- isFull
806
- ? "font-medium text-emerald-600 dark:text-emerald-400"
807
- : "text-muted-foreground"
808
- )}
809
- >
810
- {groupResources.length} tài nguyên · {selected}/{total}
811
- {isFull && <Check className="ml-0.5 inline h-3 w-3" />}
812
- </span>
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
- }
827
- >
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
845
- type="button"
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"
848
- >
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>
856
- </div>
857
- </div>
841
+ {/* Combo tự động admin khỏi lo */}
842
+ <div className="flex items-start gap-2 border-b border-border bg-muted/30 px-4 py-1.5 text-[11px] text-muted-foreground">
843
+ <Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
844
+ <span>
845
+ Ô chọn khách / sản phẩm / kho… trong các form <b>tự hoạt động</b>
846
+ không cần cấp quyền đây.
847
+ </span>
848
+ </div>
858
849
 
859
- {groupExpanded && (
850
+ {/* ===== 3. Cây menu ===== */}
851
+ {searching && visibleTree.length === 0 && visibleOffMenu.length === 0 && (
852
+ <p className="px-4 py-8 text-center text-xs text-muted-foreground">
853
+ Không có menu / thao tác nào khớp "{searchTerm}".
854
+ </p>
855
+ )}
856
+ {visibleTree.map((section) => {
857
+ const { total, on } = sectionState(section)
858
+ const isCollapsed = !searching && collapsed.has(section.title)
859
+ return (
860
+ <section key={section.title}>
861
+ <div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
862
+ <button
863
+ type="button"
864
+ onClick={() => toggleCollapse(section.title)}
865
+ className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground"
866
+ aria-label={
867
+ isCollapsed ? "Mở nhóm menu" : "Thu gọn nhóm menu"
868
+ }
869
+ >
870
+ {isCollapsed ? (
871
+ <ChevronRight className="h-4 w-4" />
872
+ ) : (
873
+ <ChevronDown className="h-4 w-4" />
874
+ )}
875
+ </button>
876
+ <Checkbox
877
+ checked={
878
+ on === 0 ? false : on === total ? true : "indeterminate"
879
+ }
880
+ onCheckedChange={(c) => toggleSection(section, c === true)}
881
+ aria-label={`Cả nhóm ${section.title}`}
882
+ />
883
+ <DynamicIcon
884
+ name={section.icon as any}
885
+ className="h-4 w-4 shrink-0 text-muted-foreground"
886
+ />
887
+ <h3 className="text-sm font-semibold text-foreground">
888
+ {section.title}
889
+ </h3>
890
+ <span
891
+ className={cn(
892
+ "text-[11px] tabular-nums",
893
+ on === total && total > 0
894
+ ? "font-medium text-emerald-600 dark:text-emerald-400"
895
+ : "text-muted-foreground"
896
+ )}
897
+ >
898
+ {on}/{total} menu
899
+ </span>
900
+ </div>
901
+ {!isCollapsed && (
860
902
  <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}
893
- </div>
894
- </div>
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
908
- return (
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
- )}
926
- >
927
- {l.label}
928
- </button>
929
- )
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
- }
992
- className={cn(
993
- "inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium transition-colors",
994
- isChecked
995
- ? "border-primary/40 bg-primary/10 text-primary"
996
- : "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground"
997
- )}
998
- >
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
- )}
1018
- />
1019
- {actionName(code)}
1020
- </button>
1021
- )
1022
- })}
1023
- </div>
1024
- )
1025
- )}
1026
- </div>
1027
- )}
1028
- </li>
1029
- )
1030
- })}
903
+ {section.items.map(renderPageRow)}
1031
904
  </ul>
1032
- )}
1033
- </section>
1034
- )
1035
- })
905
+ )}
906
+ </section>
907
+ )
908
+ })}
909
+
910
+ {/* ===== 4. Quyền không gắn menu (dialog / route ẩn) ===== */}
911
+ {visibleOffMenu.length > 0 && (
912
+ <section>
913
+ <div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
914
+ <h3 className="text-sm font-semibold text-foreground">
915
+ Chức năng không có menu riêng
916
+ </h3>
917
+ <span className="text-[11px] text-muted-foreground">
918
+ dialog / thao tác nằm trong trang khác
919
+ </span>
920
+ </div>
921
+ <ul className="divide-y divide-border/60">
922
+ {visibleOffMenu.map((code) =>
923
+ renderPageRow({
924
+ title: resourceInfo.get(code)?.name || code,
925
+ resource: code,
926
+ })
927
+ )}
928
+ </ul>
929
+ </section>
1036
930
  )}
1037
931
  </div>
1038
932
  </div>