@goplusvn/core 0.1.39 → 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,19 +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 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 gì
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.
17
19
  // API contract giữ nguyên: POST /api/roles, PUT /api/roles/:id, quyền dạng
18
20
  // `${action}:${resource}`.
19
21
  import * as React from "react"
@@ -21,6 +23,7 @@ import { useRouter } from "next/navigation"
21
23
  import {
22
24
  Badge,
23
25
  Button,
26
+ Checkbox,
24
27
  DynamicIcon,
25
28
  Input,
26
29
  Select,
@@ -33,32 +36,18 @@ import {
33
36
  import { toast } from "sonner"
34
37
  import {
35
38
  ArrowLeft,
36
- BadgeCheck,
37
- Ban,
38
- Bot,
39
- Check,
39
+ Building2,
40
+ Search,
40
41
  ChevronDown,
41
42
  ChevronRight,
42
43
  Copy,
43
- Download,
44
- Eye,
44
+ Info,
45
45
  LayoutTemplate,
46
- PenLine,
47
- Pencil,
48
- Plus,
49
- Printer,
50
- RefreshCw,
51
46
  Save,
52
- Search,
53
- Settings2,
54
- Trash2,
55
- Upload,
56
47
  X,
57
- Zap,
58
48
  } from "lucide-react"
59
49
 
60
50
  import type { CrudPermissions } from "../../types"
61
- import type { LucideIcon } from "lucide-react"
62
51
 
63
52
  import { errorMessage, throwFetchError } from "../lib/fetch-error"
64
53
  import { cn } from "../../utils"
@@ -69,78 +58,22 @@ export interface ActionLabelConfig {
69
58
  color: string
70
59
  }
71
60
 
72
- // 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.
73
62
  const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
74
- view: { label: "Xem", icon: "👁️", color: "text-blue-600" },
75
- create: { label: "Tạo", icon: "➕", color: "text-green-600" },
76
- update: { label: "Sửa", icon: "✏️", color: "text-yellow-600" },
77
- delete: { label: "Xóa", icon: "🗑️", color: "text-destructive" },
78
- import: { label: "Import", icon: "📥", color: "text-purple-600" },
79
- export: { label: "Export", icon: "📤", color: "text-indigo-600" },
80
- approve: { label: "Duyệt", icon: "✅", color: "text-emerald-600" },
81
- manage: { label: "Quản lý", icon: "🔧", color: "text-orange-600" },
82
- edit_items: { label: "Sửa chi tiết", icon: "📝", color: "text-amber-600" },
83
- 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: "" },
84
73
  }
85
74
 
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" },
107
- ]
108
-
109
- // --- Icon + màu theo LOẠI action (suy từ mã) — mọi action nghiệp vụ đều có
110
- // icon mà không cần cấu hình từng cái. ---
111
- const ACTION_VISUAL_RULES: Array<{
112
- re: RegExp
113
- icon: LucideIcon
114
- cls: string
115
- }> = [
116
- { re: /^(view|read|get|list)/, icon: Eye, cls: "text-blue-500" },
117
- { re: /^(create|add|new)/, icon: Plus, cls: "text-emerald-500" },
118
- { re: /^(update|edit|change|set)/, icon: Pencil, cls: "text-amber-500" },
119
- { re: /^(delete|remove)/, icon: Trash2, cls: "text-rose-500" },
120
- { re: /^(cancel|void|reject)/, icon: Ban, cls: "text-rose-500" },
121
- {
122
- re: /^(approve|confirm|verify|accept|complete)/,
123
- icon: BadgeCheck,
124
- cls: "text-violet-500",
125
- },
126
- { re: /^sign/, icon: PenLine, cls: "text-indigo-500" },
127
- { re: /^import/, icon: Download, cls: "text-purple-500" },
128
- { re: /^export/, icon: Upload, cls: "text-indigo-500" },
129
- { re: /^print/, icon: Printer, cls: "text-cyan-600" },
130
- { re: /^(sync|unlink)/, icon: RefreshCw, cls: "text-cyan-600" },
131
- {
132
- re: /^(manage|config|close|reopen)/,
133
- icon: Settings2,
134
- cls: "text-orange-500",
135
- },
136
- { re: /^auto/, icon: Bot, cls: "text-purple-500" },
137
- ]
138
-
139
- const getActionVisual = (code: string) =>
140
- ACTION_VISUAL_RULES.find((r) => r.re.test(code)) ?? {
141
- icon: Zap,
142
- cls: "text-slate-400",
143
- }
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"]
144
77
 
145
78
  /** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
146
79
  export interface RoleTemplate {
@@ -150,12 +83,27 @@ export interface RoleTemplate {
150
83
  permissions: string[]
151
84
  }
152
85
 
153
- /** 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). */
154
87
  export interface ActionMetaEntry {
155
88
  label?: string
156
89
  description?: string
157
90
  flow?: string
158
91
  requires?: string[]
92
+ level?: "staff" | "manager"
93
+ }
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
+ }>
159
107
  }
160
108
 
161
109
  export interface RoleFormPageProps {
@@ -189,12 +137,11 @@ export interface RoleFormPageProps {
189
137
  permissions: string[]
190
138
  }
191
139
  actionLabels?: Record<string, ActionLabelConfig>
192
- /** Bộ quyền mẫu hiện ở chế độ tạo mới (additive — không truyền thì ẩn). */
193
140
  roleTemplates?: RoleTemplate[]
194
- /** Meta action nghiệp vụ theo code (flow/description/requires) — additive. */
195
141
  actionMeta?: Record<string, ActionMetaEntry>
196
- /** Đường dẫn trang theo resource code (hiện dưới tên) — additive. */
197
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[]
198
145
  }
199
146
 
200
147
  export function RoleFormPage({
@@ -209,6 +156,7 @@ export function RoleFormPage({
209
156
  roleTemplates,
210
157
  actionMeta,
211
158
  resourcePages,
159
+ menuTree,
212
160
  }: RoleFormPageProps) {
213
161
  const router = useRouter()
214
162
 
@@ -218,6 +166,7 @@ export function RoleFormPage({
218
166
  )
219
167
 
220
168
  const [isSubmitting, setIsSubmitting] = React.useState(false)
169
+ const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set())
221
170
  const [searchTerm, setSearchTerm] = React.useState("")
222
171
 
223
172
  const [formData, setFormData] = React.useState({
@@ -228,25 +177,10 @@ export function RoleFormPage({
228
177
  permissions: initialData?.permissions || ([] as string[]),
229
178
  })
230
179
 
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
- }
244
-
245
180
  const getLocalizedName = React.useCallback(
246
181
  (key: string | null) => {
247
182
  if (!key) return ""
248
183
  if (!dictionary) return key
249
-
250
184
  const parts = key.split(".")
251
185
  let current = dictionary
252
186
  for (const part of parts) {
@@ -262,119 +196,113 @@ export function RoleFormPage({
262
196
  )
263
197
 
264
198
  const actionName = React.useCallback(
265
- (code: string) => {
266
- return (
267
- actionMeta?.[code]?.label ||
268
- actions.find((a) => a.code === code)?.name ||
269
- mergedActionLabels[code]?.label ||
270
- code
271
- )
272
- },
199
+ (code: string) =>
200
+ actionMeta?.[code]?.label ||
201
+ actions.find((a) => a.code === code)?.name ||
202
+ mergedActionLabels[code]?.label ||
203
+ code,
273
204
  [actions, actionMeta, mergedActionLabels]
274
205
  )
275
206
 
276
- const permissionMatrix = React.useMemo(() => {
277
- return resources.map((resource) => {
278
- 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[] = []
279
215
  const defaultActions = actions
280
216
  .filter((a) => a.isDefault)
281
217
  .map((a) => a.code)
282
- const effectiveDefaultActions =
218
+ const fallback =
283
219
  defaultActions.length > 0
284
220
  ? defaultActions
285
221
  : ["view", "create", "update", "delete"]
286
-
287
- if (resource.config) {
288
- try {
289
- const config =
290
- typeof resource.config === "string"
291
- ? JSON.parse(resource.config)
292
- : resource.config
293
-
294
- if (config.actions && Array.isArray(config.actions)) {
295
- const allowedCodes = config.actions as string[]
296
- allowedActions = actions
297
- .map((a) => a.code)
298
- .filter((code) => allowedCodes.includes(code))
299
- } else {
300
- allowedActions = actions
301
- .map((a) => a.code)
302
- .filter((code) => effectiveDefaultActions.includes(code))
303
- }
304
- } catch (_e) {
305
- 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
306
229
  .map((a) => a.code)
307
- .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))
308
235
  }
309
- } else {
310
- allowedActions = actions
311
- .map((a) => a.code)
312
- .filter((code) => effectiveDefaultActions.includes(code))
236
+ } catch {
237
+ allowed = actions.map((a) => a.code).filter((c) => fallback.includes(c))
313
238
  }
314
-
315
- return {
316
- resource: resource.code,
239
+ map.set(resource.code, {
317
240
  name: getLocalizedName(resource.name),
318
- icon: resource.icon || "📄",
319
- group: getLocalizedName(resource.group) || "Khác",
320
- description: resource.description,
321
- actions: allowedActions,
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"),
328
- }
241
+ icon: resource.icon,
242
+ allowed,
243
+ })
329
244
  })
245
+ return map
330
246
  }, [resources, actions, getLocalizedName])
331
247
 
332
- type MatrixRow = (typeof permissionMatrix)[number]
333
-
334
- const totalAvailablePermissions = React.useMemo(
335
- () => permissionMatrix.reduce((sum, r) => sum + r.actions.length, 0),
336
- [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]
337
262
  )
338
263
 
339
- const filteredMatrix = React.useMemo(() => {
340
- if (!searchTerm.trim()) return permissionMatrix
341
-
342
- const search = searchTerm.toLowerCase()
343
- return permissionMatrix.filter(
344
- (resource) =>
345
- resource.name.toLowerCase().includes(search) ||
346
- resource.resource.toLowerCase().includes(search) ||
347
- resource.group.toLowerCase().includes(search)
348
- )
349
- }, [searchTerm, permissionMatrix])
350
-
351
- const groupedMatrix = React.useMemo(() => {
352
- const groups: Record<string, MatrixRow[]> = {}
353
- filteredMatrix.forEach((resource) => {
354
- const group = resource.group
355
- if (!groups[group]) {
356
- groups[group] = []
357
- }
358
- groups[group].push(resource)
359
- })
360
- return groups
361
- }, [filteredMatrix])
362
-
363
- const allGroups = React.useMemo(() => {
364
- const seen: string[] = []
365
- permissionMatrix.forEach((r) => {
366
- if (!seen.includes(r.group)) seen.push(r.group)
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)
367
278
  })
368
- return seen
369
- }, [permissionMatrix])
279
+ return Array.from(byGroup.values())
280
+ }, [menuTree, resources, resourcePages, getLocalizedName])
370
281
 
371
- React.useEffect(() => {
372
- if (activeGroup !== "all" && !allGroups.includes(activeGroup)) {
373
- setActiveGroup("all")
374
- }
375
- }, [allGroups, activeGroup])
376
-
377
- 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
+ )
378
306
 
379
307
  const has = React.useCallback(
380
308
  (resource: string, action: string) =>
@@ -382,9 +310,7 @@ export function RoleFormPage({
382
310
  [formData.permissions]
383
311
  )
384
312
 
385
- const mutatePermissions = (
386
- fn: (set: Set<string>) => void
387
- ) => {
313
+ const mutate = (fn: (set: Set<string>) => void) => {
388
314
  setFormData((prev) => {
389
315
  const set = new Set(prev.permissions)
390
316
  fn(set)
@@ -392,107 +318,110 @@ export function RoleFormPage({
392
318
  })
393
319
  }
394
320
 
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"
405
- }
406
- if (r.hasLookup && has(r.resource, "lookup")) return "lookup"
407
- return "hidden"
408
- }
409
-
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
429
- }
430
- add("view")
431
- if (level === "write" || level === "full") {
432
- 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}`))
433
331
  }
434
- if (level === "full" && r.hasDelete) add("delete")
435
332
  })
436
333
  }
437
334
 
438
- // ── Thao tác nghiệp vụ + requires ─────────────────────────────────────
439
- const toggleBusinessAction = (r: MatrixRow, code: string) => {
440
- const key = `${code}:${r.resource}`
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}`
441
340
  if (formData.permissions.includes(key)) {
442
- mutatePermissions((set) => set.delete(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)
344
+ )
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
+ )
356
+ }
443
357
  return
444
358
  }
445
- const requires = actionMeta?.[code]?.requires ?? []
446
- const needed = ["view", ...requires].filter(
359
+ const requires = (actionMeta?.[code]?.requires ?? []).filter(
447
360
  (req) =>
448
- r.actions.includes(req) &&
449
- !formData.permissions.includes(`${req}:${r.resource}`)
361
+ info.allowed.includes(req) &&
362
+ !formData.permissions.includes(`${req}:${resource}`)
450
363
  )
451
- mutatePermissions((set) => {
364
+ mutate((set) => {
452
365
  set.add(key)
453
- needed.forEach((req) => set.add(`${req}:${r.resource}`))
366
+ requires.forEach((req) => set.add(`${req}:${resource}`))
454
367
  })
455
- if (needed.length > 0) {
368
+ if (requires.length > 0) {
456
369
  toast.info(
457
- `Đã bật kèm quyền tiên quyết: ${needed
458
- .map((c) => actionName(c))
370
+ `Đã bật kèm quyền tiên quyết: ${requires
371
+ .map((a) => actionName(a))
459
372
  .join(", ")}`
460
373
  )
461
374
  }
462
375
  }
463
376
 
464
- // ── Đếm ───────────────────────────────────────────────────────────────
465
- const groupPermissionCodes = React.useCallback(
466
- (rows: MatrixRow[]) =>
467
- rows.flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`)),
468
- []
469
- )
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
+ }
470
383
 
471
- const countSelected = React.useCallback(
472
- (codes: string[]) =>
473
- codes.filter((p) => formData.permissions.includes(p)).length,
474
- [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]
475
407
  )
476
408
 
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))
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
+ })
482
422
  })
483
423
  }
484
424
 
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
-
496
425
  const applyTemplate = (templateCode: string) => {
497
426
  const tpl = roleTemplates?.find((t) => t.code === templateCode)
498
427
  if (!tpl) return
@@ -505,29 +434,24 @@ export function RoleFormPage({
505
434
  const handleSubmit = async (e: React.FormEvent) => {
506
435
  e.preventDefault()
507
436
  setIsSubmitting(true)
508
-
509
437
  try {
510
438
  const url =
511
439
  mode === "create" ? "/api/roles" : `/api/roles/${initialData?.id}`
512
440
  const method = mode === "create" ? "POST" : "PUT"
513
-
514
441
  const res = await fetch(url, {
515
442
  method,
516
443
  headers: { "Content-Type": "application/json" },
517
444
  body: JSON.stringify(formData),
518
445
  })
519
-
520
446
  if (!res.ok) {
521
447
  await throwFetchError(res, `Failed to ${mode} role`)
522
448
  }
523
-
524
449
  toast.success("Thành công", {
525
450
  description:
526
451
  mode === "create"
527
452
  ? "Đã tạo vai trò mới thành công"
528
453
  : "Đã cập nhật vai trò thành công",
529
454
  })
530
-
531
455
  router.push(`/${lang}/roles`)
532
456
  router.refresh()
533
457
  } catch (error: any) {
@@ -537,12 +461,190 @@ export function RoleFormPage({
537
461
  }
538
462
  }
539
463
 
540
- const visibleGroups = Object.entries(groupedMatrix).filter(
541
- ([group, groupResources]) =>
542
- groupResources.length > 0 &&
543
- (searching || activeGroup === "all" || activeGroup === group)
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]
544
502
  )
545
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
+
546
648
  return (
547
649
  <div className="flex flex-col">
548
650
  {/* ===== 1. Header bar navy 1 DÒNG ===== */}
@@ -653,345 +755,178 @@ export function RoleFormPage({
653
755
  </div>
654
756
 
655
757
  <div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
656
- {/* ===== 2. Toolbar sticky: search · bộ quyền mẫu · đếm · tab ===== */}
657
- <div className="sticky top-0 z-20 border-b border-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/85">
658
- <div className="flex flex-wrap items-center gap-2 px-3 py-2 sm:px-4">
659
- <div className="relative min-w-[140px] flex-1 sm:max-w-64">
660
- <Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
661
- <Input
662
- placeholder="Tìm kiếm tài nguyên..."
663
- className="h-8 rounded-md border border-border bg-card pl-8 text-sm"
664
- value={searchTerm}
665
- onChange={(e) => setSearchTerm(e.target.value)}
666
- />
667
- </div>
668
- {mode === "create" && (roleTemplates?.length ?? 0) > 0 && (
669
- <Select onValueChange={applyTemplate}>
670
- <SelectTrigger className="h-8 w-44 rounded-md border border-border bg-card text-sm sm:w-52">
671
- <span className="flex items-center gap-1.5 truncate">
672
- <LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
673
- <SelectValue placeholder="Bộ quyền mẫu..." />
674
- </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>
675
810
  </SelectTrigger>
676
811
  <SelectContent className="rounded-lg">
677
- {roleTemplates!.map((tpl) => (
678
- <SelectItem key={tpl.code} value={tpl.code}>
679
- <span className="flex flex-col">
680
- <span>{tpl.label}</span>
681
- {tpl.description && (
682
- <span className="text-[11px] text-muted-foreground">
683
- {tpl.description}
684
- </span>
685
- )}
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
686
817
  </span>
687
- </SelectItem>
688
- ))}
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)
825
+ </span>
826
+ </span>
827
+ </SelectItem>
689
828
  </SelectContent>
690
829
  </Select>
691
- )}
692
- <p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
693
- <span className="font-semibold tabular-nums text-foreground">
694
- {formData.permissions.length}
695
- </span>
696
- <span className="tabular-nums">/{totalAvailablePermissions}</span>{" "}
697
- quyền
698
- </p>
699
- </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>
839
+ </div>
700
840
 
701
- {/* Thanh TAB phân hệ */}
702
- <div className="flex items-center gap-1 overflow-x-auto px-3 pb-2 sm:px-4 [scrollbar-width:thin]">
703
- <button
704
- type="button"
705
- onClick={() => setActiveGroup("all")}
706
- className={cn(
707
- "shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
708
- activeGroup === "all" && !searching
709
- ? "border-primary bg-primary text-primary-foreground"
710
- : "border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground"
711
- )}
712
- >
713
- Tất cả
714
- </button>
715
- {allGroups.map((group) => {
716
- const { selected, total } = groupTabCount(group)
717
- const active = activeGroup === group && !searching
718
- return (
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>
849
+
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">
719
862
  <button
720
- key={group}
721
863
  type="button"
722
- onClick={() => {
723
- setActiveGroup(group)
724
- setSearchTerm("")
725
- }}
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
726
891
  className={cn(
727
- "flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
728
- active
729
- ? "border-primary bg-primary text-primary-foreground"
730
- : "border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground"
892
+ "text-[11px] tabular-nums",
893
+ on === total && total > 0
894
+ ? "font-medium text-emerald-600 dark:text-emerald-400"
895
+ : "text-muted-foreground"
731
896
  )}
732
897
  >
733
- {group}
734
- <span
735
- className={cn(
736
- "rounded-full px-1 text-[10px] tabular-nums",
737
- active
738
- ? "bg-primary-foreground/20"
739
- : selected > 0
740
- ? selected === total
741
- ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
742
- : "bg-muted text-foreground"
743
- : "bg-muted"
744
- )}
745
- >
746
- {selected}/{total}
747
- </span>
748
- </button>
749
- )
750
- })}
751
- </div>
752
-
753
- {/* Chú giải 5 nấc — 1 dòng mảnh */}
754
- <div className="hidden items-center gap-3 border-t border-border/60 px-4 py-1 text-[11px] text-muted-foreground md:flex">
755
- {LEVELS.map((l) => (
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>
762
- </div>
763
-
764
- {/* ===== 3. Dòng tài nguyên: mức truy cập + thao tác nghiệp vụ ===== */}
765
- {visibleGroups.length === 0 ? (
766
- <div className="flex flex-col items-center justify-center py-16 text-center">
767
- <Search className="mb-3 h-6 w-6 text-muted-foreground" />
768
- <p className="text-sm text-muted-foreground">
769
- Không có tài nguyên nào khớp bộ lọc.
770
- </p>
771
- </div>
772
- ) : (
773
- visibleGroups.map(([group, groupResources]) => {
774
- const codes = groupPermissionCodes(groupResources)
775
- const selected = countSelected(codes)
776
- const total = codes.length
777
- const isFull = total > 0 && selected === total
778
-
779
- return (
780
- <section key={group}>
781
- <div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
782
- <h3 className="truncate text-xs font-semibold uppercase tracking-wide text-foreground">
783
- {group}
784
- </h3>
785
- <span
786
- className={cn(
787
- "whitespace-nowrap text-[11px] tabular-nums",
788
- isFull
789
- ? "font-medium text-emerald-600 dark:text-emerald-400"
790
- : "text-muted-foreground"
791
- )}
792
- >
793
- {groupResources.length} tài nguyên · {selected}/{total}
794
- {isFull && <Check className="ml-0.5 inline h-3 w-3" />}
795
- </span>
796
- <div className="ml-auto flex shrink-0 items-center gap-1">
797
- <Button
798
- type="button"
799
- variant="ghost"
800
- size="sm"
801
- onClick={() => toggleAllForGroup(groupResources, false)}
802
- disabled={selected === 0}
803
- className="h-6 px-2 text-xs text-muted-foreground hover:text-destructive"
804
- >
805
- Bỏ chọn
806
- </Button>
807
- <Button
808
- type="button"
809
- variant="ghost"
810
- size="sm"
811
- onClick={() => toggleAllForGroup(groupResources, true)}
812
- disabled={isFull}
813
- className="h-6 px-2 text-xs font-semibold text-primary hover:text-primary"
814
- >
815
- Chọn tất cả
816
- </Button>
817
- </div>
818
- </div>
819
-
898
+ {on}/{total} menu
899
+ </span>
900
+ </div>
901
+ {!isCollapsed && (
820
902
  <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}
853
- </div>
854
- </div>
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
868
- return (
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
- )}
886
- >
887
- {l.label}
888
- </button>
889
- )
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
- }
952
- className={cn(
953
- "inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium transition-colors",
954
- isChecked
955
- ? "border-primary/40 bg-primary/10 text-primary"
956
- : "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground"
957
- )}
958
- >
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
- )}
978
- />
979
- {actionName(code)}
980
- </button>
981
- )
982
- })}
983
- </div>
984
- )
985
- )}
986
- </div>
987
- )}
988
- </li>
989
- )
990
- })}
903
+ {section.items.map(renderPageRow)}
991
904
  </ul>
992
- </section>
993
- )
994
- })
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>
995
930
  )}
996
931
  </div>
997
932
  </div>