@goplusvn/core 0.1.40 → 0.1.42

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,19 @@ import {
37
36
  import { toast } from "sonner"
38
37
  import {
39
38
  ArrowLeft,
40
- BadgeCheck,
41
- Ban,
42
- Bot,
43
- Check,
39
+ Building2,
40
+ History,
41
+ Search,
44
42
  ChevronDown,
45
43
  ChevronRight,
46
44
  Copy,
47
- Download,
48
- Eye,
45
+ Info,
49
46
  LayoutTemplate,
50
- PenLine,
51
- Pencil,
52
- Plus,
53
- Printer,
54
- RefreshCw,
55
47
  Save,
56
- Search,
57
- Settings2,
58
- Trash2,
59
- Upload,
60
48
  X,
61
- Zap,
62
49
  } from "lucide-react"
63
50
 
64
51
  import type { CrudPermissions } from "../../types"
65
- import type { LucideIcon } from "lucide-react"
66
52
 
67
53
  import { errorMessage, throwFetchError } from "../lib/fetch-error"
68
54
  import { cn } from "../../utils"
@@ -73,96 +59,22 @@ export interface ActionLabelConfig {
73
59
  color: string
74
60
  }
75
61
 
76
- // Nhãn mặc định cho action CRUD chuẩn (giữ tương thích prop actionLabels).
62
+ // Nhãn action CRUD chuẩn hiển thị như NÚT trên trang.
77
63
  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" },
64
+ view: { label: "Xem", icon: "👁️", color: "" },
65
+ create: { label: "Tạo mới", icon: "➕", color: "" },
66
+ update: { label: "Sửa", icon: "✏️", color: "" },
67
+ delete: { label: "Xóa", icon: "🗑️", color: "" },
68
+ import: { label: "Nhập Excel", icon: "📥", color: "" },
69
+ export: { label: "Xuất Excel", icon: "📤", color: "" },
70
+ approve: { label: "Duyệt", icon: "✅", color: "" },
71
+ manage: { label: "Quản lý", icon: "🔧", color: "" },
72
+ edit_items: { label: "Sửa chi tiết", icon: "📝", color: "" },
73
+ delete_items: { label: "Xóa chi tiết", icon: "❌", color: "" },
88
74
  }
89
75
 
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
- }
76
+ // Thứ tự hiển thị action trong một trang: CRUD chuẩn trước, nghiệp vụ sau.
77
+ const STANDARD_ORDER = ["create", "update", "delete", "export", "import"]
166
78
 
167
79
  /** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
168
80
  export interface RoleTemplate {
@@ -172,16 +84,29 @@ export interface RoleTemplate {
172
84
  permissions: string[]
173
85
  }
174
86
 
175
- /** Metadata action nghiệp vụ: nhóm luồng + mô tả + quyền tiên quyết. */
87
+ /** Metadata action (consumer truyền từ Permission Registry). */
176
88
  export interface ActionMetaEntry {
177
89
  label?: string
178
90
  description?: string
179
91
  flow?: string
180
92
  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
93
  level?: "staff" | "manager"
183
94
  }
184
95
 
96
+ /** Cây menu thật của app — dựng từ navigations. */
97
+ export interface MenuTreeSection {
98
+ title: string
99
+ icon?: string
100
+ items: Array<{
101
+ title: string
102
+ href?: string
103
+ icon?: string
104
+ resource: string
105
+ /** Vị trí thật khi không phải menu: "nút Cấu hình trong trang Mua hàng"… */
106
+ note?: string
107
+ }>
108
+ }
109
+
185
110
  export interface RoleFormPageProps {
186
111
  dictionary: any
187
112
  permissions?: CrudPermissions
@@ -213,12 +138,13 @@ export interface RoleFormPageProps {
213
138
  permissions: string[]
214
139
  }
215
140
  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
141
  roleTemplates?: RoleTemplate[]
218
- /** Meta action nghiệp vụ theo code (flow/description/requires) — additive. */
219
142
  actionMeta?: Record<string, ActionMetaEntry>
220
- /** Đường dẫn trang theo resource code (hiện dưới tên) — additive. */
221
143
  resourcePages?: Record<string, string>
144
+ /** Cây menu thật — không truyền thì tự dựng từ group của resources. */
145
+ menuTree?: MenuTreeSection[]
146
+ /** Endpoint nhật ký đổi quyền (edit mode) — vd /api/roles/{id}/history. */
147
+ historyEndpoint?: string
222
148
  }
223
149
 
224
150
  export function RoleFormPage({
@@ -233,6 +159,8 @@ export function RoleFormPage({
233
159
  roleTemplates,
234
160
  actionMeta,
235
161
  resourcePages,
162
+ menuTree,
163
+ historyEndpoint,
236
164
  }: RoleFormPageProps) {
237
165
  const router = useRouter()
238
166
 
@@ -242,7 +170,18 @@ export function RoleFormPage({
242
170
  )
243
171
 
244
172
  const [isSubmitting, setIsSubmitting] = React.useState(false)
173
+ const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set())
245
174
  const [searchTerm, setSearchTerm] = React.useState("")
175
+ const [historyOpen, setHistoryOpen] = React.useState(false)
176
+ const [history, setHistory] = React.useState<any[] | null>(null)
177
+
178
+ React.useEffect(() => {
179
+ if (!historyOpen || history || !historyEndpoint) return
180
+ fetch(historyEndpoint)
181
+ .then((r) => r.json())
182
+ .then((d) => setHistory(d.items ?? []))
183
+ .catch(() => setHistory([]))
184
+ }, [historyOpen, history, historyEndpoint])
246
185
 
247
186
  const [formData, setFormData] = React.useState({
248
187
  name: initialData?.name || "",
@@ -252,36 +191,10 @@ export function RoleFormPage({
252
191
  permissions: initialData?.permissions || ([] as string[]),
253
192
  })
254
193
 
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
194
  const getLocalizedName = React.useCallback(
281
195
  (key: string | null) => {
282
196
  if (!key) return ""
283
197
  if (!dictionary) return key
284
-
285
198
  const parts = key.split(".")
286
199
  let current = dictionary
287
200
  for (const part of parts) {
@@ -297,107 +210,113 @@ export function RoleFormPage({
297
210
  )
298
211
 
299
212
  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
- },
213
+ (code: string) =>
214
+ actionMeta?.[code]?.label ||
215
+ actions.find((a) => a.code === code)?.name ||
216
+ mergedActionLabels[code]?.label ||
217
+ code,
308
218
  [actions, actionMeta, mergedActionLabels]
309
219
  )
310
220
 
311
- const permissionMatrix = React.useMemo(() => {
312
- return resources.map((resource) => {
313
- let allowedActions: string[] = []
221
+ // ── Resource action khai báo ────────────────────────────────────────
222
+ const resourceInfo = React.useMemo(() => {
223
+ const map = new Map<
224
+ string,
225
+ { name: string; icon: string | null; allowed: string[] }
226
+ >()
227
+ resources.forEach((resource) => {
228
+ let allowed: string[] = []
314
229
  const defaultActions = actions
315
230
  .filter((a) => a.isDefault)
316
231
  .map((a) => a.code)
317
- const effectiveDefaultActions =
232
+ const fallback =
318
233
  defaultActions.length > 0
319
234
  ? defaultActions
320
235
  : ["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
236
+ try {
237
+ const config =
238
+ typeof resource.config === "string"
239
+ ? JSON.parse(resource.config)
240
+ : resource.config
241
+ if (config?.actions && Array.isArray(config.actions)) {
242
+ allowed = actions
341
243
  .map((a) => a.code)
342
- .filter((code) => effectiveDefaultActions.includes(code))
244
+ .filter((c) => (config.actions as string[]).includes(c))
245
+ } else {
246
+ allowed = actions
247
+ .map((a) => a.code)
248
+ .filter((c) => fallback.includes(c))
343
249
  }
344
- } else {
345
- allowedActions = actions
346
- .map((a) => a.code)
347
- .filter((code) => effectiveDefaultActions.includes(code))
250
+ } catch {
251
+ allowed = actions.map((a) => a.code).filter((c) => fallback.includes(c))
348
252
  }
349
-
350
- return {
351
- resource: resource.code,
253
+ map.set(resource.code, {
352
254
  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
- }
255
+ icon: resource.icon,
256
+ allowed,
257
+ })
364
258
  })
259
+ return map
365
260
  }, [resources, actions, getLocalizedName])
366
261
 
367
- type MatrixRow = (typeof permissionMatrix)[number]
368
-
369
- const totalAvailablePermissions = React.useMemo(
370
- () => permissionMatrix.reduce((sum, r) => sum + r.actions.length, 0),
371
- [permissionMatrix]
262
+ /** Action hiển thị dưới một trang (bỏ view = chính checkbox trang; bỏ lookup
263
+ * = combo tự động). CRUD chuẩn trước, nghiệp vụ sau. */
264
+ const pageActions = React.useCallback(
265
+ (resource: string) => {
266
+ const info = resourceInfo.get(resource)
267
+ if (!info) return []
268
+ const rest = info.allowed.filter(
269
+ (a) => a !== "view" && a !== "lookup" && a !== "view-all-branches"
270
+ )
271
+ const std = STANDARD_ORDER.filter((a) => rest.includes(a))
272
+ const biz = rest.filter((a) => !STANDARD_ORDER.includes(a))
273
+ return [...std, ...biz]
274
+ },
275
+ [resourceInfo]
372
276
  )
373
277
 
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)
278
+ // ── Cây menu (prop, hoặc tự dựng từ group) ────────────────────────────
279
+ const tree = React.useMemo<MenuTreeSection[]>(() => {
280
+ if (menuTree && menuTree.length > 0) return menuTree
281
+ const byGroup = new Map<string, MenuTreeSection>()
282
+ resources.forEach((r) => {
283
+ const group = getLocalizedName(r.group) || "Khác"
284
+ const section = byGroup.get(group) ?? { title: group, items: [] }
285
+ section.items.push({
286
+ title: getLocalizedName(r.name),
287
+ href: resourcePages?.[r.code],
288
+ icon: r.icon ?? undefined,
289
+ resource: r.code,
290
+ })
291
+ byGroup.set(group, section)
394
292
  })
395
- return groups
396
- }, [filteredMatrix])
293
+ return Array.from(byGroup.values())
294
+ }, [menuTree, resources, resourcePages, getLocalizedName])
397
295
 
398
-
399
- // Đang search mọi nhóm khớp tự bung phần chi tiết.
400
- const searching = searchTerm.trim().length > 0
296
+ /** Resource có action khai nhưng KHÔNG nằm trên menu nào. */
297
+ const offMenuResources = React.useMemo(() => {
298
+ const onMenu = new Set(
299
+ tree.flatMap((s) => s.items.map((i) => i.resource))
300
+ )
301
+ return resources
302
+ .map((r) => r.code)
303
+ .filter((code) => {
304
+ if (onMenu.has(code)) return false
305
+ const info = resourceInfo.get(code)
306
+ if (!info) return false
307
+ // chỉ còn lookup (vd resource "reference") → thuộc phần combo tự động
308
+ return info.allowed.some((a) => a !== "lookup")
309
+ })
310
+ }, [tree, resources, resourceInfo])
311
+
312
+ const totalAvailable = React.useMemo(
313
+ () =>
314
+ Array.from(resourceInfo.values()).reduce(
315
+ (s, r) => s + r.allowed.length,
316
+ 0
317
+ ),
318
+ [resourceInfo]
319
+ )
401
320
 
402
321
  const has = React.useCallback(
403
322
  (resource: string, action: string) =>
@@ -405,9 +324,7 @@ export function RoleFormPage({
405
324
  [formData.permissions]
406
325
  )
407
326
 
408
- const mutatePermissions = (
409
- fn: (set: Set<string>) => void
410
- ) => {
327
+ const mutate = (fn: (set: Set<string>) => void) => {
411
328
  setFormData((prev) => {
412
329
  const set = new Set(prev.permissions)
413
330
  fn(set)
@@ -415,152 +332,109 @@ export function RoleFormPage({
415
332
  })
416
333
  }
417
334
 
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)
335
+ // ── Tick trang (menu) ─────────────────────────────────────────────────
336
+ const togglePage = (resource: string, checked: boolean) => {
337
+ const info = resourceInfo.get(resource)
338
+ if (!info) return
339
+ mutate((set) => {
340
+ if (checked) {
341
+ if (info.allowed.includes("view")) set.add(`view:${resource}`)
342
+ } else {
343
+ // mất menu → mọi quyền của trang tắt theo
344
+ info.allowed.forEach((a) => set.delete(`${a}:${resource}`))
456
345
  }
457
- if (level === "full" && r.hasDelete) add("delete")
458
346
  })
459
347
  }
460
348
 
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))
349
+ // ── Tick thao tác trong trang (requires 2 chiều) ──────────────────────
350
+ const toggleAction = (resource: string, code: string) => {
351
+ const info = resourceInfo.get(resource)
352
+ if (!info) return
353
+ const key = `${code}:${resource}`
354
+ if (formData.permissions.includes(key)) {
355
+ // tắt X → tắt luôn các thao tác phụ thuộc X
356
+ const dependents = info.allowed.filter((a) =>
357
+ (actionMeta?.[a]?.requires ?? []).includes(code)
501
358
  )
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
- }
359
+ const on = dependents.filter((a) =>
360
+ formData.permissions.includes(`${a}:${resource}`)
361
+ )
362
+ mutate((set) => {
363
+ set.delete(key)
364
+ on.forEach((a) => set.delete(`${a}:${resource}`))
365
+ })
366
+ if (on.length > 0) {
367
+ toast.info(
368
+ `Đã tắt kèm: ${on.map((a) => actionName(a)).join(", ")} (phụ thuộc "${actionName(code)}")`
369
+ )
510
370
  }
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
371
  return
531
372
  }
532
- const requires = actionMeta?.[code]?.requires ?? []
533
- const needed = ["view", ...requires].filter(
373
+ const requires = (actionMeta?.[code]?.requires ?? []).filter(
534
374
  (req) =>
535
- r.actions.includes(req) &&
536
- !formData.permissions.includes(`${req}:${r.resource}`)
375
+ info.allowed.includes(req) &&
376
+ !formData.permissions.includes(`${req}:${resource}`)
537
377
  )
538
- mutatePermissions((set) => {
378
+ mutate((set) => {
539
379
  set.add(key)
540
- needed.forEach((req) => set.add(`${req}:${r.resource}`))
380
+ requires.forEach((req) => set.add(`${req}:${resource}`))
541
381
  })
542
- if (needed.length > 0) {
382
+ if (requires.length > 0) {
543
383
  toast.info(
544
- `Đã bật kèm quyền tiên quyết: ${needed
545
- .map((c) => actionName(c))
384
+ `Đã bật kèm quyền tiên quyết: ${requires
385
+ .map((a) => actionName(a))
546
386
  .join(", ")}`
547
387
  )
548
388
  }
549
389
  }
550
390
 
551
- // ── Đếm ───────────────────────────────────────────────────────────────
552
- const groupPermissionCodes = React.useCallback(
553
- (rows: MatrixRow[]) =>
554
- rows.flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`)),
555
- []
556
- )
391
+ // ── Tick cả nhóm menu ─────────────────────────────────────────────────
392
+ const sectionState = (section: MenuTreeSection) => {
393
+ const uniq = [...new Set(section.items.map((i) => i.resource))]
394
+ const on = uniq.filter((r) => has(r, "view")).length
395
+ return { total: uniq.length, on }
396
+ }
557
397
 
558
- const countSelected = React.useCallback(
559
- (codes: string[]) =>
560
- codes.filter((p) => formData.permissions.includes(p)).length,
561
- [formData.permissions]
398
+ const toggleSection = (section: MenuTreeSection, checked: boolean) => {
399
+ const uniq = [...new Set(section.items.map((i) => i.resource))]
400
+ uniq.forEach((r) => togglePageBatch(r, checked))
401
+ function togglePageBatch(resource: string, on: boolean) {
402
+ const info = resourceInfo.get(resource)
403
+ if (!info) return
404
+ mutate((set) => {
405
+ if (on) {
406
+ if (info.allowed.includes("view")) set.add(`view:${resource}`)
407
+ } else {
408
+ info.allowed.forEach((a) => set.delete(`${a}:${resource}`))
409
+ }
410
+ })
411
+ }
412
+ }
413
+
414
+ // ── Phạm vi dữ liệu chi nhánh (view-all-branches) ─────────────────────
415
+ const branchScopeResources = React.useMemo(
416
+ () =>
417
+ Array.from(resourceInfo.entries())
418
+ .filter(([, info]) => info.allowed.includes("view-all-branches"))
419
+ .map(([code]) => code),
420
+ [resourceInfo]
562
421
  )
563
422
 
423
+ const branchScope: "assigned" | "all" = React.useMemo(() => {
424
+ if (branchScopeResources.length === 0) return "assigned"
425
+ return branchScopeResources.every((r) => has(r, "view-all-branches"))
426
+ ? "all"
427
+ : "assigned"
428
+ }, [branchScopeResources, has])
429
+
430
+ const setBranchScope = (scope: "assigned" | "all") => {
431
+ mutate((set) => {
432
+ branchScopeResources.forEach((r) => {
433
+ if (scope === "all") set.add(`view-all-branches:${r}`)
434
+ else set.delete(`view-all-branches:${r}`)
435
+ })
436
+ })
437
+ }
564
438
 
565
439
  const applyTemplate = (templateCode: string) => {
566
440
  const tpl = roleTemplates?.find((t) => t.code === templateCode)
@@ -574,29 +448,24 @@ export function RoleFormPage({
574
448
  const handleSubmit = async (e: React.FormEvent) => {
575
449
  e.preventDefault()
576
450
  setIsSubmitting(true)
577
-
578
451
  try {
579
452
  const url =
580
453
  mode === "create" ? "/api/roles" : `/api/roles/${initialData?.id}`
581
454
  const method = mode === "create" ? "POST" : "PUT"
582
-
583
455
  const res = await fetch(url, {
584
456
  method,
585
457
  headers: { "Content-Type": "application/json" },
586
458
  body: JSON.stringify(formData),
587
459
  })
588
-
589
460
  if (!res.ok) {
590
461
  await throwFetchError(res, `Failed to ${mode} role`)
591
462
  }
592
-
593
463
  toast.success("Thành công", {
594
464
  description:
595
465
  mode === "create"
596
466
  ? "Đã tạo vai trò mới thành công"
597
467
  : "Đã cập nhật vai trò thành công",
598
468
  })
599
-
600
469
  router.push(`/${lang}/roles`)
601
470
  router.refresh()
602
471
  } catch (error: any) {
@@ -606,10 +475,199 @@ export function RoleFormPage({
606
475
  }
607
476
  }
608
477
 
609
- const visibleGroups = Object.entries(groupedMatrix).filter(
610
- ([, groupResources]) => groupResources.length > 0
478
+ // Mở trang: tự thu gọn các nhóm chưa có menu nào được cấp — trang ngắn hẳn.
479
+ const didInitCollapse = React.useRef(false)
480
+ React.useEffect(() => {
481
+ if (didInitCollapse.current) return
482
+ didInitCollapse.current = true
483
+ const zero = tree
484
+ .filter((section) => {
485
+ const uniq = [...new Set(section.items.map((i) => i.resource))]
486
+ return !uniq.some((r) => has(r, "view"))
487
+ })
488
+ .map((s) => s.title)
489
+ if (zero.length > 0) setCollapsed(new Set(zero))
490
+ // eslint-disable-next-line react-hooks/exhaustive-deps
491
+ }, [])
492
+
493
+ const toggleCollapse = (title: string) => {
494
+ setCollapsed((prev) => {
495
+ const next = new Set(prev)
496
+ if (next.has(title)) next.delete(title)
497
+ else next.add(title)
498
+ return next
499
+ })
500
+ }
501
+
502
+ // ── Search: lọc theo tên menu / đường dẫn / tên thao tác ─────────────
503
+ const searching = searchTerm.trim().length > 0
504
+ const matchItem = React.useCallback(
505
+ (item: { title: string; href?: string; resource: string }) => {
506
+ if (!searching) return true
507
+ const q = searchTerm.trim().toLowerCase()
508
+ if (item.title.toLowerCase().includes(q)) return true
509
+ if (item.href?.toLowerCase().includes(q)) return true
510
+ if (item.resource.toLowerCase().includes(q)) return true
511
+ return pageActions(item.resource).some((code) =>
512
+ actionName(code).toLowerCase().includes(q)
513
+ )
514
+ },
515
+ [searching, searchTerm, pageActions, actionName]
611
516
  )
612
517
 
518
+ const visibleTree = React.useMemo(() => {
519
+ if (!searching) return tree
520
+ const q = searchTerm.trim().toLowerCase()
521
+ return tree
522
+ .map((section) => {
523
+ if (section.title.toLowerCase().includes(q)) return section
524
+ return { ...section, items: section.items.filter(matchItem) }
525
+ })
526
+ .filter((section) => section.items.length > 0)
527
+ }, [tree, searching, searchTerm, matchItem])
528
+
529
+ const visibleOffMenu = React.useMemo(() => {
530
+ if (!searching) return offMenuResources
531
+ const q = searchTerm.trim().toLowerCase()
532
+ return offMenuResources.filter((code) => {
533
+ const info = resourceInfo.get(code)
534
+ if (info?.name.toLowerCase().includes(q)) return true
535
+ if (code.toLowerCase().includes(q)) return true
536
+ return pageActions(code).some((c) =>
537
+ actionName(c).toLowerCase().includes(q)
538
+ )
539
+ })
540
+ }, [offMenuResources, searching, searchTerm, resourceInfo, pageActions, actionName])
541
+
542
+ const permLabel = React.useCallback(
543
+ (perm: string) => {
544
+ const [action, resource] = perm.split(":")
545
+ const rName = resourceInfo.get(resource)?.name || resource
546
+ return `${actionName(action)} · ${rName}`
547
+ },
548
+ [resourceInfo, actionName]
549
+ )
550
+
551
+ // Render 1 dòng trang menu + hàng thao tác của nó
552
+ const renderPageRow = (item: {
553
+ title: string
554
+ href?: string
555
+ icon?: string
556
+ resource: string
557
+ note?: string
558
+ }) => {
559
+ const info = resourceInfo.get(item.resource)
560
+ if (!info) return null
561
+ const pageOn = has(item.resource, "view")
562
+ const acts = pageActions(item.resource)
563
+ const actsOn = acts.filter((a) => has(item.resource, a)).length
564
+
565
+ return (
566
+ <li key={`${item.resource}:${item.title}`} className="px-4 py-2">
567
+ <label className="flex cursor-pointer items-center gap-2.5">
568
+ <Checkbox
569
+ checked={pageOn}
570
+ onCheckedChange={(c) => togglePage(item.resource, c === true)}
571
+ aria-label={`Menu ${item.title}`}
572
+ />
573
+ <DynamicIcon
574
+ name={(item.icon || info.icon) as any}
575
+ className="h-4 w-4 shrink-0 text-muted-foreground"
576
+ />
577
+ <span
578
+ className={cn(
579
+ "text-sm font-medium",
580
+ pageOn ? "text-foreground" : "text-muted-foreground"
581
+ )}
582
+ >
583
+ {item.title}
584
+ </span>
585
+ {item.href && (
586
+ <code className="hidden font-mono text-[10px] text-muted-foreground sm:inline">
587
+ {item.href}
588
+ </code>
589
+ )}
590
+ {item.note && (
591
+ <span className="hidden text-[10px] italic text-muted-foreground/80 sm:inline">
592
+ {item.note}
593
+ </span>
594
+ )}
595
+ {!pageOn && (
596
+ <span
597
+ className="text-[10px] text-muted-foreground/60"
598
+ title="Vai trò này không thấy menu và không vào được trang"
599
+ >
600
+ · ẩn
601
+ </span>
602
+ )}
603
+ {pageOn && acts.length > 0 && (
604
+ <span className="ml-auto text-[10px] tabular-nums text-muted-foreground">
605
+ {actsOn}/{acts.length} thao tác
606
+ </span>
607
+ )}
608
+ </label>
609
+
610
+ {/* Thao tác (nút) trong trang — nhóm theo LUỒNG, mỗi luồng 1 dòng */}
611
+ {pageOn && acts.length > 0 && (
612
+ <div className="mt-1.5 space-y-1 pl-7 sm:pl-9">
613
+ {(() => {
614
+ // "Chung" = CRUD chuẩn; còn lại theo actionMeta.flow
615
+ const flows = new Map<string, string[]>()
616
+ acts.forEach((code) => {
617
+ const flow = STANDARD_ORDER.includes(code)
618
+ ? "Chung"
619
+ : actionMeta?.[code]?.flow || "Khác"
620
+ const list = flows.get(flow) ?? []
621
+ list.push(code)
622
+ flows.set(flow, list)
623
+ })
624
+ return Array.from(flows.entries()).map(([flow, codes]) => (
625
+ <div
626
+ key={flow}
627
+ className="flex flex-wrap items-center gap-x-3 gap-y-1"
628
+ >
629
+ <span className="w-24 shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground/60">
630
+ {flow}
631
+ </span>
632
+ {codes.map((code) => {
633
+ const meta = actionMeta?.[code]
634
+ const checked = has(item.resource, code)
635
+ return (
636
+ <label
637
+ key={code}
638
+ title={meta?.description || actionName(code)}
639
+ className="flex cursor-pointer items-center gap-1.5"
640
+ >
641
+ <Checkbox
642
+ checked={checked}
643
+ onCheckedChange={() =>
644
+ toggleAction(item.resource, code)
645
+ }
646
+ className="h-3.5 w-3.5"
647
+ aria-label={`${actionName(code)} — ${item.title}`}
648
+ />
649
+ <span
650
+ className={cn(
651
+ "text-xs",
652
+ checked
653
+ ? "text-foreground"
654
+ : "text-muted-foreground"
655
+ )}
656
+ >
657
+ {actionName(code)}
658
+ </span>
659
+ </label>
660
+ )
661
+ })}
662
+ </div>
663
+ ))
664
+ })()}
665
+ </div>
666
+ )}
667
+ </li>
668
+ )
669
+ }
670
+
613
671
  return (
614
672
  <div className="flex flex-col">
615
673
  {/* ===== 1. Header bar navy 1 DÒNG ===== */}
@@ -719,322 +777,348 @@ export function RoleFormPage({
719
777
  </Button>
720
778
  </div>
721
779
 
722
- <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>
780
+ <div className="mt-3 flex items-start gap-3 lg:mx-3">
781
+ <div className="min-w-0 flex-1 overflow-hidden rounded-xl border border-border bg-card shadow-sm">
782
+ {/* ===== 2. Toolbar: mẫu + phạm vi chi nhánh + đếm ===== */}
783
+ <div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2 sm:px-4">
784
+ <div className="relative min-w-[150px] flex-1 sm:max-w-64">
785
+ <Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
786
+ <Input
787
+ placeholder="Tìm menu / thao tác..."
788
+ className="h-8 rounded-md border border-border bg-card pl-8 text-sm"
789
+ value={searchTerm}
790
+ onChange={(e) => setSearchTerm(e.target.value)}
791
+ />
792
+ </div>
793
+ {mode === "create" && (roleTemplates?.length ?? 0) > 0 && (
794
+ <Select onValueChange={applyTemplate}>
795
+ <SelectTrigger className="h-8 w-48 rounded-md border border-border bg-card text-sm">
796
+ <span className="flex items-center gap-1.5 truncate">
797
+ <LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
798
+ <SelectValue placeholder="Bộ quyền mẫu..." />
799
+ </span>
800
+ </SelectTrigger>
801
+ <SelectContent className="rounded-lg">
802
+ {roleTemplates!.map((tpl) => (
803
+ <SelectItem key={tpl.code} value={tpl.code}>
804
+ <span className="flex flex-col">
805
+ <span>{tpl.label}</span>
806
+ {tpl.description && (
807
+ <span className="text-[11px] text-muted-foreground">
808
+ {tpl.description}
809
+ </span>
810
+ )}
811
+ </span>
812
+ </SelectItem>
813
+ ))}
814
+ </SelectContent>
815
+ </Select>
816
+ )}
817
+
818
+ {branchScopeResources.length > 0 && (
819
+ <div className="flex items-center gap-1.5">
820
+ <Building2 className="h-3.5 w-3.5 text-muted-foreground" />
821
+ <span className="text-xs text-muted-foreground">
822
+ Phạm vi dữ liệu:
823
+ </span>
824
+ <Select
825
+ value={branchScope}
826
+ onValueChange={(v) => setBranchScope(v as "assigned" | "all")}
827
+ >
828
+ <SelectTrigger className="h-8 w-52 rounded-md border border-border bg-card text-sm">
829
+ <SelectValue>
830
+ {branchScope === "all"
831
+ ? "Tất cả chi nhánh"
832
+ : "Chỉ chi nhánh được gán"}
833
+ </SelectValue>
742
834
  </SelectTrigger>
743
835
  <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
- )}
836
+ <SelectItem value="assigned">
837
+ <span className="flex flex-col">
838
+ <span>Chỉ chi nhánh được gán</span>
839
+ <span className="text-[11px] text-muted-foreground">
840
+ Theo chi nhánh gán ở trang Người dùng
753
841
  </span>
754
- </SelectItem>
755
- ))}
842
+ </span>
843
+ </SelectItem>
844
+ <SelectItem value="all">
845
+ <span className="flex flex-col">
846
+ <span>Tất cả chi nhánh</span>
847
+ <span className="text-[11px] text-muted-foreground">
848
+ Xem dữ liệu mọi chi nhánh (quản lý vùng/kế toán tổng)
849
+ </span>
850
+ </span>
851
+ </SelectItem>
756
852
  </SelectContent>
757
853
  </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>
854
+ </div>
855
+ )}
856
+
857
+ <p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
858
+ <span className="font-semibold tabular-nums text-foreground">
859
+ {formData.permissions.length}
860
+ </span>
861
+ <span className="tabular-nums">/{totalAvailable}</span> quyền
862
+ </p>
863
+ </div>
767
864
 
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>
865
+ {/* Combo tự độngadmin khỏi lo */}
866
+ <div className="flex items-start gap-2 border-b border-border bg-muted/30 px-4 py-1.5 text-[11px] text-muted-foreground">
867
+ <Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
868
+ <span>
869
+ Ô chọn khách / sản phẩm / kho… trong các form <b>tự hoạt động</b>
870
+ không cần cấp quyền ở đây.
871
+ </span>
777
872
  </div>
778
873
 
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 có 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)
874
+ {/* ===== 3. Cây menu ===== */}
875
+ {searching && visibleTree.length === 0 && visibleOffMenu.length === 0 && (
876
+ <p className="px-4 py-8 text-center text-xs text-muted-foreground">
877
+ Không menu / thao tác nào khớp "{searchTerm}".
878
+ </p>
879
+ )}
880
+ {visibleTree.map((section) => {
881
+ const { total, on } = sectionState(section)
882
+ const isCollapsed = !searching && collapsed.has(section.title)
883
+ return (
884
+ <section key={section.title}>
885
+ <div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
886
+ <button
887
+ type="button"
888
+ onClick={() => toggleCollapse(section.title)}
889
+ className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground"
890
+ aria-label={
891
+ isCollapsed ? "Mở nhóm menu" : "Thu gọn nhóm menu"
892
+ }
893
+ >
894
+ {isCollapsed ? (
895
+ <ChevronRight className="h-4 w-4" />
896
+ ) : (
897
+ <ChevronDown className="h-4 w-4" />
898
+ )}
899
+ </button>
900
+ <Checkbox
901
+ checked={
902
+ on === 0 ? false : on === total ? true : "indeterminate"
903
+ }
904
+ onCheckedChange={(c) => toggleSection(section, c === true)}
905
+ aria-label={`Cả nhóm ${section.title}`}
906
+ />
907
+ <DynamicIcon
908
+ name={section.icon as any}
909
+ className="h-4 w-4 shrink-0 text-muted-foreground"
910
+ />
911
+ <h3 className="text-sm font-semibold text-foreground">
912
+ {section.title}
913
+ </h3>
914
+ <span
915
+ className={cn(
916
+ "text-[11px] tabular-nums",
917
+ on === total && total > 0
918
+ ? "font-medium text-emerald-600 dark:text-emerald-400"
919
+ : "text-muted-foreground"
920
+ )}
921
+ >
922
+ {on}/{total} menu
923
+ </span>
924
+ </div>
925
+ {!isCollapsed && (
926
+ <ul className="divide-y divide-border/60 border-b border-border">
927
+ {section.items.map(renderPageRow)}
928
+ </ul>
929
+ )}
930
+ </section>
931
+ )
932
+ })}
933
+
934
+ {/* ===== 4. Quyền không gắn menu (dialog / route ẩn) ===== */}
935
+ {visibleOffMenu.length > 0 && (
936
+ <section>
937
+ <div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
938
+ <h3 className="text-sm font-semibold text-foreground">
939
+ Chức năng không có menu riêng
940
+ </h3>
941
+ <span className="text-[11px] text-muted-foreground">
942
+ dialog / thao tác nằm trong trang khác
943
+ </span>
944
+ </div>
945
+ <ul className="divide-y divide-border/60">
946
+ {visibleOffMenu.map((code) =>
947
+ renderPageRow({
948
+ title: resourceInfo.get(code)?.name || code,
949
+ resource: code,
950
+ })
951
+ )}
952
+ </ul>
953
+ </section>
954
+ )}
955
+ </div>
795
956
 
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
- )}
957
+ {/* ===== 🪞 PREVIEW SIDEBAR SỐNG — nhân viên mang vai trò này sẽ thấy ===== */}
958
+ <aside className="sticky top-4 hidden w-60 shrink-0 overflow-hidden rounded-xl border border-border shadow-sm xl:block">
959
+ <div className="border-b border-white/10 bg-sidebar px-3 py-2">
960
+ <p className="text-xs font-semibold text-primary-foreground">
961
+ Nhân viên sẽ thấy
962
+ </p>
963
+ <p className="text-[10px] text-primary-foreground/60">
964
+ Sidebar cập nhật theo từng ô tick
965
+ </p>
966
+ </div>
967
+ <div className="max-h-[70vh] overflow-y-auto bg-sidebar px-2 py-2 [scrollbar-width:thin]">
968
+ {(() => {
969
+ const visibleSections = tree
970
+ .map((section) => ({
971
+ ...section,
972
+ items: section.items.filter(
973
+ (i) => !i.note && has(i.resource, "view")
974
+ ),
975
+ }))
976
+ .filter((sec) => sec.items.length > 0)
977
+ if (visibleSections.length === 0) {
978
+ return (
979
+ <p className="px-2 py-6 text-center text-[11px] text-primary-foreground/50">
980
+ Chưa có menu nào —
981
+ <br />
982
+ vai trò này không thấy gì trên sidebar.
983
+ </p>
984
+ )
985
+ }
986
+ return visibleSections.map((sec) => (
987
+ <div key={sec.title} className="mb-1.5">
988
+ <p className="px-2 py-1 text-[9px] font-semibold uppercase tracking-wider text-primary-foreground/40">
989
+ {sec.title}
990
+ </p>
991
+ {sec.items.map((i) => (
992
+ <div
993
+ key={`${i.resource}:${i.title}`}
994
+ className="flex items-center gap-2 rounded-md px-2 py-1 text-primary-foreground/85"
809
995
  >
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>
996
+ <DynamicIcon
997
+ name={i.icon as any}
998
+ className="h-3.5 w-3.5 shrink-0 opacity-70"
999
+ />
1000
+ <span className="truncate text-[11px]">{i.title}</span>
856
1001
  </div>
857
- </div>
858
-
859
- {groupExpanded && (
860
- <ul className="divide-y divide-border/60 border-b border-border">
861
- {groupResources.map((r) => {
862
- const level = deriveLevel(r)
863
- const bizOn = r.businessActions.filter((a) =>
864
- has(r.resource, a)
865
- ).length
866
- const expanded = expandedRows.has(r.resource)
867
- const page = resourcePages?.[r.resource]
868
-
869
- // Nhóm thao tác theo LUỒNG
870
- const flows = new Map<string, string[]>()
871
- r.businessActions.forEach((code) => {
872
- const flow = actionMeta?.[code]?.flow || "Khác"
873
- const list = flows.get(flow) ?? []
874
- list.push(code)
875
- flows.set(flow, list)
876
- })
877
-
878
- return (
879
- <li key={r.resource} className="px-4 py-2.5">
880
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
881
- {/* Tên + trang */}
882
- <div className="flex min-w-0 flex-1 basis-52 items-center gap-2">
883
- <DynamicIcon
884
- name={r.icon as any}
885
- className="h-4 w-4 shrink-0 text-muted-foreground"
886
- />
887
- <div className="min-w-0">
888
- <div className="truncate text-sm font-medium text-foreground">
889
- {r.name}
890
- </div>
891
- <div className="truncate font-mono text-[10px] text-muted-foreground">
892
- {page || r.resource}
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>
1002
+ ))}
1003
+ </div>
1004
+ ))
1005
+ })()}
1006
+ </div>
1007
+ {branchScopeResources.length > 0 && (
1008
+ <div className="border-t border-white/10 bg-sidebar px-3 py-1.5 text-[10px] text-primary-foreground/60">
1009
+ Dữ liệu:{" "}
1010
+ {branchScope === "all"
1011
+ ? "tất cả chi nhánh"
1012
+ : "chi nhánh được gán"}
1013
+ </div>
1014
+ )}
1015
+ </aside>
1016
+ </div>
932
1017
 
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
- )}
1018
+ {/* ===== 🕓 Nhật thay đổi quyền ===== */}
1019
+ {mode === "edit" && historyEndpoint && (
1020
+ <div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
1021
+ <button
1022
+ type="button"
1023
+ onClick={() => setHistoryOpen((o) => !o)}
1024
+ className="flex w-full items-center gap-2 px-4 py-2 text-left hover:bg-muted/30"
1025
+ >
1026
+ <History className="h-4 w-4 text-muted-foreground" />
1027
+ <span className="text-sm font-semibold text-foreground">
1028
+ Nhật ký thay đổi quyền
1029
+ </span>
1030
+ <span className="text-[11px] text-muted-foreground">
1031
+ ai đổi gì, lúc nào
1032
+ </span>
1033
+ <span className="ml-auto text-xs text-muted-foreground">
1034
+ {historyOpen ? "Thu gọn" : "Xem"}
1035
+ </span>
1036
+ </button>
1037
+ {historyOpen && (
1038
+ <div className="border-t border-border">
1039
+ {history === null ? (
1040
+ <p className="px-4 py-4 text-center text-xs text-muted-foreground">
1041
+ Đang tải...
1042
+ </p>
1043
+ ) : history.length === 0 ? (
1044
+ <p className="px-4 py-4 text-center text-xs text-muted-foreground">
1045
+ Chưa có thay đổi nào được ghi nhận.
1046
+ </p>
1047
+ ) : (
1048
+ <ul className="divide-y divide-border/60">
1049
+ {history.map((e) => (
1050
+ <li key={e.id} className="px-4 py-2 text-xs">
1051
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
1052
+ <span className="tabular-nums text-muted-foreground">
1053
+ {new Date(e.at).toLocaleString("vi-VN", {
1054
+ hour: "2-digit",
1055
+ minute: "2-digit",
1056
+ day: "2-digit",
1057
+ month: "2-digit",
1058
+ year: "numeric",
1059
+ })}
1060
+ </span>
1061
+ {e.by && (
1062
+ <span className="font-medium text-foreground">
1063
+ {e.by}
1064
+ </span>
1065
+ )}
1066
+ {e.action === "create" && (
1067
+ <span className="text-muted-foreground">
1068
+ tạo vai trò
1069
+ </span>
1070
+ )}
1071
+ {e.nameChange && (
1072
+ <span className="text-muted-foreground">
1073
+ đổi tên "{e.nameChange.from}" → "{e.nameChange.to}"
1074
+ </span>
1075
+ )}
1076
+ {e.statusChange && (
1077
+ <span className="text-muted-foreground">
1078
+ {e.statusChange.to === "active"
1079
+ ? "mở khóa vai trò"
1080
+ : "khóa vai trò"}
1081
+ </span>
1082
+ )}
1083
+ </div>
1084
+ {(e.added?.length > 0 || e.removed?.length > 0) && (
1085
+ <div className="mt-1 flex flex-wrap gap-1">
1086
+ {e.added.slice(0, 8).map((p: string) => (
1087
+ <span
1088
+ key={p}
1089
+ className="rounded bg-emerald-50 px-1.5 py-0.5 text-[11px] text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
944
1090
  >
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>
1091
+ +{permLabel(p)}
1092
+ </span>
1093
+ ))}
1094
+ {e.added.length > 8 && (
1095
+ <span className="text-[11px] text-muted-foreground">
1096
+ +{e.added.length - 8} quyền khác
1097
+ </span>
1098
+ )}
1099
+ {e.removed.slice(0, 8).map((p: string) => (
1100
+ <span
1101
+ key={p}
1102
+ className="rounded bg-rose-50 px-1.5 py-0.5 text-[11px] text-rose-700 line-through dark:bg-rose-950 dark:text-rose-400"
1103
+ >
1104
+ {permLabel(p)}
1105
+ </span>
1106
+ ))}
1107
+ {e.removed.length > 8 && (
1108
+ <span className="text-[11px] text-muted-foreground">
1109
+ −{e.removed.length - 8} quyền khác
1110
+ </span>
955
1111
  )}
956
1112
  </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
- })}
1113
+ )}
1114
+ </li>
1115
+ ))}
1031
1116
  </ul>
1032
- )}
1033
- </section>
1034
- )
1035
- })
1036
- )}
1037
- </div>
1117
+ )}
1118
+ </div>
1119
+ )}
1120
+ </div>
1121
+ )}
1038
1122
  </div>
1039
1123
  )
1040
1124
  }