@goplusvn/core 0.1.37 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.37",
4
+ "version": "0.1.39",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -115,9 +115,14 @@ interface Permissions {
115
115
 
116
116
  export interface ActionListPageProps {
117
117
  permissions?: Permissions
118
+ /** Catalog hành động do Permission Registry trong code quản lý — ẩn mọi nút ghi. */
119
+ readOnly?: boolean
118
120
  }
119
121
 
120
- export function ActionListPage({ permissions = {} }: ActionListPageProps) {
122
+ export function ActionListPage({
123
+ permissions = {},
124
+ readOnly = false,
125
+ }: ActionListPageProps) {
121
126
  const [loading, setLoading] = useState(true)
122
127
  const [actions, setActions] = useState<Action[]>([])
123
128
  const [usage, setUsage] = useState<UsageMap>({})
@@ -140,9 +145,9 @@ export function ActionListPage({ permissions = {} }: ActionListPageProps) {
140
145
  const [toDelete, setToDelete] = useState<Action | null>(null)
141
146
  const [isDeleting, setIsDeleting] = useState(false)
142
147
 
143
- const canCreate = !!permissions.create
144
- const canUpdate = !!permissions.update
145
- const canDelete = !!permissions.delete
148
+ const canCreate = !readOnly && !!permissions.create
149
+ const canUpdate = !readOnly && !!permissions.update
150
+ const canDelete = !readOnly && !!permissions.delete
146
151
 
147
152
  const fetchData = async () => {
148
153
  try {
@@ -212,7 +212,20 @@ const normalize = (s: string) =>
212
212
  .replace(/[\u0300-\u036f]/g, "")
213
213
  .replace(/đ/g, "d")
214
214
 
215
- export function ResourceListPage() {
215
+ export interface ResourceListPageProps {
216
+ /** Danh mục do Permission Registry trong code quản lý: ẩn nút Thêm/Sync/
217
+ * Action-manager, thao tác sửa/xóa hiện toast hướng dẫn thay vì mở dialog. */
218
+ readOnly?: boolean
219
+ }
220
+
221
+ export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {}) {
222
+ const registryToast = () =>
223
+ toast.info("Danh mục do Permission Registry quản lý", {
224
+ description:
225
+ "Sửa file <feature>.permissions.ts trong code rồi chạy rbac-sync — không chỉnh tay ở đây.",
226
+ })
227
+ const guardWrite = <T extends (...args: any[]) => any>(fn: T): T =>
228
+ (readOnly ? ((() => registryToast()) as unknown as T) : fn)
216
229
  const [loading, setLoading] = useState(true)
217
230
  const [resources, setResources] = useState<Resource[]>([])
218
231
  const [allActions, setAllActions] = useState<Action[]>([])
@@ -707,37 +720,57 @@ export function ResourceListPage() {
707
720
  className={cn("h-4 w-4", isRefreshing && "animate-spin")}
708
721
  />
709
722
  </Button>
710
- <Button
711
- variant="ghost"
712
- size="icon"
713
- onClick={handleSync}
714
- disabled={isSyncing}
715
- title="Đồng bộ tài nguyên"
716
- className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
717
- >
718
- <DatabaseBackup
719
- className={cn("h-4 w-4", isSyncing && "animate-pulse")}
720
- />
721
- </Button>
722
- <Button
723
- variant="ghost"
724
- size="icon"
725
- onClick={openActionManager}
726
- title="Quản lý hành động"
727
- className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
728
- >
729
- <Zap className="h-4 w-4" />
730
- </Button>
731
- <Button
732
- onClick={openNewResource}
733
- className="h-8 shrink-0 bg-primary-foreground px-2.5 text-primary hover:bg-primary-foreground/90 sm:px-3"
734
- >
735
- <Plus className="h-4 w-4" />
736
- <span className="ml-1.5 hidden sm:inline">Thêm tài nguyên</span>
737
- <span className="ml-1.5 sm:hidden">Thêm</span>
738
- </Button>
723
+ {!readOnly && (
724
+ <>
725
+ <Button
726
+ variant="ghost"
727
+ size="icon"
728
+ onClick={handleSync}
729
+ disabled={isSyncing}
730
+ title="Đồng bộ tài nguyên"
731
+ className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
732
+ >
733
+ <DatabaseBackup
734
+ className={cn("h-4 w-4", isSyncing && "animate-pulse")}
735
+ />
736
+ </Button>
737
+ <Button
738
+ variant="ghost"
739
+ size="icon"
740
+ onClick={openActionManager}
741
+ title="Quản lý hành động"
742
+ className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
743
+ >
744
+ <Zap className="h-4 w-4" />
745
+ </Button>
746
+ <Button
747
+ onClick={openNewResource}
748
+ className="h-8 shrink-0 bg-primary-foreground px-2.5 text-primary hover:bg-primary-foreground/90 sm:px-3"
749
+ >
750
+ <Plus className="h-4 w-4" />
751
+ <span className="ml-1.5 hidden sm:inline">Thêm tài nguyên</span>
752
+ <span className="ml-1.5 sm:hidden">Thêm</span>
753
+ </Button>
754
+ </>
755
+ )}
739
756
  </div>
740
757
 
758
+ {readOnly && (
759
+ <div className="mt-3 flex items-start gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground lg:mx-3">
760
+ <Shield className="mt-0.5 h-3.5 w-3.5 shrink-0" />
761
+ <span>
762
+ Danh mục tài nguyên &amp; hành động do{" "}
763
+ <span className="font-medium text-foreground">
764
+ Permission Registry
765
+ </span>{" "}
766
+ trong code quản lý (file{" "}
767
+ <code className="font-mono">&lt;feature&gt;.permissions.ts</code> +{" "}
768
+ <code className="font-mono">rbac-sync</code>). Trang này chỉ để tra
769
+ cứu; cấp quyền cho vai trò làm ở trang Vai trò.
770
+ </span>
771
+ </div>
772
+ )}
773
+
741
774
  {/* ===== Khối trắng liền: toolbar sticky + nhóm + hàng tài nguyên ===== */}
742
775
  <div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
743
776
  {/* Toolbar sticky */}
@@ -868,9 +901,9 @@ export function ResourceListPage() {
868
901
  categorized={categorizedActions}
869
902
  expanded={expandedRows.has(resource.id)}
870
903
  onToggleExpand={() => toggleRow(resource.id)}
871
- onToggleAction={toggleAction}
872
- onUpdateActions={handleUpdateActions}
873
- onEditResource={openEditResource}
904
+ onToggleAction={guardWrite(toggleAction)}
905
+ onUpdateActions={guardWrite(handleUpdateActions)}
906
+ onEditResource={guardWrite(openEditResource)}
874
907
  />
875
908
  ))}
876
909
  </ul>
@@ -886,10 +919,10 @@ export function ResourceListPage() {
886
919
  categorized={categorizedActions}
887
920
  expanded={expandedRows.has(resource.id)}
888
921
  onToggleExpand={() => toggleRow(resource.id)}
889
- onToggleAction={toggleAction}
890
- onUpdateActions={handleUpdateActions}
891
- onEditResource={openEditResource}
892
- onDeleteResource={requestDeleteResource}
922
+ onToggleAction={guardWrite(toggleAction)}
923
+ onUpdateActions={guardWrite(handleUpdateActions)}
924
+ onEditResource={guardWrite(openEditResource)}
925
+ onDeleteResource={guardWrite(requestDeleteResource)}
893
926
  />
894
927
  ))}
895
928
  </div>
@@ -1,11 +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
4
- // (← tên inline · chip mã + copy · CHIP TRẠNG THÁI màu bấm-đổi · mô tả ·
5
- // Hủy/Lưu), full width; khối trắng: toolbar sticky (đếm x/y quyền realtime),
6
- // nhóm = dòng mảnh, tài nguyên = dòng + CHIP quyền (checkbox + icon lucide
7
- // theo loại action, prop actionLabels bổ sung nhãn nghiệp vụ app).
8
- // API contract: POST /api/roles, PUT /api/roles/:id.
3
+ // Trang tạo/sửa VAI TRÒ — bar navy 1 DÒNG kiểu chi tiết đơn bán hàng.
4
+ //
5
+ // Ma trận quyền v3 ("Mức truy cập 5 nấc" chốt với chủ app 2026-07):
6
+ // - TAB theo phân hệ (đếm x/y realtime).
7
+ // - Mỗi tài nguyên MỘT DÒNG: [Ẩn | Tra cứu | Xem | Ghi | Toàn quyền]
8
+ // Ẩn = không menu, không quyền
9
+ // Tra cứu = chỉ chọn trong combo/selector (lookup) — KHÔNG thấy menu
10
+ // Xem = menu sidebar hiện + vào được trang
11
+ // Ghi = Xem + Tạo/Sửa/Xuất/Nhập
12
+ // Toàn quyền= Ghi + Xóa
13
+ // - Thao tác NGHIỆP VỤ (duyệt, thanh toán, MISA…) thu gọn "n/m ▸", bung ra
14
+ // nhóm theo LUỒNG (prop actionMeta.flow), tooltip mô tả, và tự cưỡng chế
15
+ // quyền tiên quyết (actionMeta.requires — mặc định cần Xem).
16
+ // - resourcePages: đường dẫn trang hiện dưới tên tài nguyên.
17
+ // API contract giữ nguyên: POST /api/roles, PUT /api/roles/:id, quyền dạng
18
+ // `${action}:${resource}`.
9
19
  import * as React from "react"
10
20
  import { useRouter } from "next/navigation"
11
21
  import {
@@ -18,7 +28,6 @@ import {
18
28
  SelectItem,
19
29
  SelectTrigger,
20
30
  SelectValue,
21
- Switch,
22
31
  getStatusMeta,
23
32
  } from "../../ui"
24
33
  import { toast } from "sonner"
@@ -33,6 +42,7 @@ import {
33
42
  Copy,
34
43
  Download,
35
44
  Eye,
45
+ LayoutTemplate,
36
46
  PenLine,
37
47
  Pencil,
38
48
  Plus,
@@ -73,8 +83,31 @@ const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
73
83
  delete_items: { label: "Xóa chi tiết", icon: "❌", color: "text-rose-600" },
74
84
  }
75
85
 
76
- // --- Icon + màu theo LOẠI action (suy từ mã, giống ACTION_TONES trang tài
77
- // nguyên) mọi action đều icon mà không cần cấu hình từng cái. ---
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. ---
78
111
  const ACTION_VISUAL_RULES: Array<{
79
112
  re: RegExp
80
113
  icon: LucideIcon
@@ -86,7 +119,7 @@ const ACTION_VISUAL_RULES: Array<{
86
119
  { re: /^(delete|remove)/, icon: Trash2, cls: "text-rose-500" },
87
120
  { re: /^(cancel|void|reject)/, icon: Ban, cls: "text-rose-500" },
88
121
  {
89
- re: /^(approve|confirm|verify|accept)/,
122
+ re: /^(approve|confirm|verify|accept|complete)/,
90
123
  icon: BadgeCheck,
91
124
  cls: "text-violet-500",
92
125
  },
@@ -94,8 +127,12 @@ const ACTION_VISUAL_RULES: Array<{
94
127
  { re: /^import/, icon: Download, cls: "text-purple-500" },
95
128
  { re: /^export/, icon: Upload, cls: "text-indigo-500" },
96
129
  { re: /^print/, icon: Printer, cls: "text-cyan-600" },
97
- { re: /^sync/, icon: RefreshCw, cls: "text-cyan-600" },
98
- { re: /^(manage|config)/, icon: Settings2, cls: "text-orange-500" },
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
+ },
99
136
  { re: /^auto/, icon: Bot, cls: "text-purple-500" },
100
137
  ]
101
138
 
@@ -105,6 +142,22 @@ const getActionVisual = (code: string) =>
105
142
  cls: "text-slate-400",
106
143
  }
107
144
 
145
+ /** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
146
+ export interface RoleTemplate {
147
+ code: string
148
+ label: string
149
+ description?: string
150
+ permissions: string[]
151
+ }
152
+
153
+ /** Metadata action nghiệp vụ: nhóm luồng + mô tả + quyền tiên quyết. */
154
+ export interface ActionMetaEntry {
155
+ label?: string
156
+ description?: string
157
+ flow?: string
158
+ requires?: string[]
159
+ }
160
+
108
161
  export interface RoleFormPageProps {
109
162
  dictionary: any
110
163
  permissions?: CrudPermissions
@@ -136,6 +189,12 @@ export interface RoleFormPageProps {
136
189
  permissions: string[]
137
190
  }
138
191
  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
+ roleTemplates?: RoleTemplate[]
194
+ /** Meta action nghiệp vụ theo code (flow/description/requires) — additive. */
195
+ actionMeta?: Record<string, ActionMetaEntry>
196
+ /** Đường dẫn trang theo resource code (hiện dưới tên) — additive. */
197
+ resourcePages?: Record<string, string>
139
198
  }
140
199
 
141
200
  export function RoleFormPage({
@@ -147,6 +206,9 @@ export function RoleFormPage({
147
206
  mode,
148
207
  initialData,
149
208
  actionLabels,
209
+ roleTemplates,
210
+ actionMeta,
211
+ resourcePages,
150
212
  }: RoleFormPageProps) {
151
213
  const router = useRouter()
152
214
 
@@ -166,16 +228,18 @@ export function RoleFormPage({
166
228
  permissions: initialData?.permissions || ([] as string[]),
167
229
  })
168
230
 
169
- const [activeGroup, setActiveGroup] = React.useState<string | null>("all")
170
- const [collapsedGroups, setCollapsedGroups] = React.useState<
171
- Record<string, boolean>
172
- >({})
231
+ const [activeGroup, setActiveGroup] = React.useState<string>("all")
232
+ const [expandedRows, setExpandedRows] = React.useState<Set<string>>(
233
+ () => new Set()
234
+ )
173
235
 
174
- const toggleGroupCollapse = (groupName: string) => {
175
- setCollapsedGroups((prev) => ({
176
- ...prev,
177
- [groupName]: !prev[groupName],
178
- }))
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
+ })
179
243
  }
180
244
 
181
245
  const getLocalizedName = React.useCallback(
@@ -197,6 +261,18 @@ export function RoleFormPage({
197
261
  [dictionary]
198
262
  )
199
263
 
264
+ 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
+ },
273
+ [actions, actionMeta, mergedActionLabels]
274
+ )
275
+
200
276
  const permissionMatrix = React.useMemo(() => {
201
277
  return resources.map((resource) => {
202
278
  let allowedActions: string[] = []
@@ -243,10 +319,18 @@ export function RoleFormPage({
243
319
  group: getLocalizedName(resource.group) || "Khác",
244
320
  description: resource.description,
245
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"),
246
328
  }
247
329
  })
248
330
  }, [resources, actions, getLocalizedName])
249
331
 
332
+ type MatrixRow = (typeof permissionMatrix)[number]
333
+
250
334
  const totalAvailablePermissions = React.useMemo(
251
335
  () => permissionMatrix.reduce((sum, r) => sum + r.actions.length, 0),
252
336
  [permissionMatrix]
@@ -265,7 +349,7 @@ export function RoleFormPage({
265
349
  }, [searchTerm, permissionMatrix])
266
350
 
267
351
  const groupedMatrix = React.useMemo(() => {
268
- const groups: Record<string, typeof permissionMatrix> = {}
352
+ const groups: Record<string, MatrixRow[]> = {}
269
353
  filteredMatrix.forEach((resource) => {
270
354
  const group = resource.group
271
355
  if (!groups[group]) {
@@ -276,99 +360,146 @@ export function RoleFormPage({
276
360
  return groups
277
361
  }, [filteredMatrix])
278
362
 
363
+ const allGroups = React.useMemo(() => {
364
+ const seen: string[] = []
365
+ permissionMatrix.forEach((r) => {
366
+ if (!seen.includes(r.group)) seen.push(r.group)
367
+ })
368
+ return seen
369
+ }, [permissionMatrix])
370
+
279
371
  React.useEffect(() => {
280
- const groups = Object.keys(groupedMatrix)
281
- if (groups.length > 0) {
282
- if (
283
- activeGroup !== "all" &&
284
- (!activeGroup || !groups.includes(activeGroup))
285
- ) {
286
- setActiveGroup("all")
287
- }
288
- } else {
289
- setActiveGroup(null)
372
+ if (activeGroup !== "all" && !allGroups.includes(activeGroup)) {
373
+ setActiveGroup("all")
290
374
  }
291
- }, [groupedMatrix, activeGroup])
292
-
293
- const hasPermission = (resource: string, action: string): boolean => {
294
- return formData.permissions.includes(`${action}:${resource}`)
295
- }
375
+ }, [allGroups, activeGroup])
296
376
 
297
- const togglePermission = (resource: string, action: string) => {
298
- const permission = `${action}:${resource}`
299
- let newPermissions = [...formData.permissions]
377
+ const searching = searchTerm.trim().length > 0
300
378
 
301
- if (newPermissions.includes(permission)) {
302
- newPermissions = newPermissions.filter((p) => p !== permission)
303
- } else {
304
- newPermissions.push(permission)
305
- }
306
- setFormData({ ...formData, permissions: newPermissions })
307
- }
308
-
309
- const groupPermissionCodes = React.useCallback(
310
- (groupName: string) => {
311
- const groupResources = groupedMatrix[groupName] || []
312
- return groupResources.flatMap((r) =>
313
- actions
314
- .filter((a) => r.actions.includes(a.code))
315
- .map((a) => `${a.code}:${r.resource}`)
316
- )
317
- },
318
- [groupedMatrix, actions]
379
+ const has = React.useCallback(
380
+ (resource: string, action: string) =>
381
+ formData.permissions.includes(`${action}:${resource}`),
382
+ [formData.permissions]
319
383
  )
320
384
 
321
- const toggleAllForGroup = (groupName: string, enable: boolean) => {
322
- const groupPermissions = groupPermissionCodes(groupName)
323
- let newPermissions = [...formData.permissions]
385
+ const mutatePermissions = (
386
+ fn: (set: Set<string>) => void
387
+ ) => {
388
+ setFormData((prev) => {
389
+ const set = new Set(prev.permissions)
390
+ fn(set)
391
+ return { ...prev, permissions: Array.from(set) }
392
+ })
393
+ }
324
394
 
325
- if (enable) {
326
- const toAdd = groupPermissions.filter((p) => !newPermissions.includes(p))
327
- newPermissions = [...newPermissions, ...toAdd]
328
- } else {
329
- newPermissions = newPermissions.filter(
330
- (p) => !groupPermissions.includes(p)
331
- )
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"
332
405
  }
406
+ if (r.hasLookup && has(r.resource, "lookup")) return "lookup"
407
+ return "hidden"
408
+ }
333
409
 
334
- setFormData({ ...formData, permissions: newPermissions })
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)
433
+ }
434
+ if (level === "full" && r.hasDelete) add("delete")
435
+ })
335
436
  }
336
437
 
337
- const groupSelectedCount = (groupName: string) => {
338
- const codes = groupPermissionCodes(groupName)
339
- return {
340
- selected: codes.filter((p) => formData.permissions.includes(p)).length,
341
- total: codes.length,
438
+ // ── Thao tác nghiệp vụ + requires ─────────────────────────────────────
439
+ const toggleBusinessAction = (r: MatrixRow, code: string) => {
440
+ const key = `${code}:${r.resource}`
441
+ if (formData.permissions.includes(key)) {
442
+ mutatePermissions((set) => set.delete(key))
443
+ return
444
+ }
445
+ const requires = actionMeta?.[code]?.requires ?? []
446
+ const needed = ["view", ...requires].filter(
447
+ (req) =>
448
+ r.actions.includes(req) &&
449
+ !formData.permissions.includes(`${req}:${r.resource}`)
450
+ )
451
+ mutatePermissions((set) => {
452
+ set.add(key)
453
+ needed.forEach((req) => set.add(`${req}:${r.resource}`))
454
+ })
455
+ if (needed.length > 0) {
456
+ toast.info(
457
+ `Đã bật kèm quyền tiên quyết: ${needed
458
+ .map((c) => actionName(c))
459
+ .join(", ")}`
460
+ )
342
461
  }
343
462
  }
344
463
 
345
- const toggleAllForResource = (
346
- resourceCode: string,
347
- resourceActions: string[],
348
- check: boolean
349
- ) => {
350
- let newPermissions = [...formData.permissions]
351
-
352
- resourceActions.forEach((actionCode) => {
353
- const permission = `${actionCode}:${resourceCode}`
354
- if (check && !newPermissions.includes(permission)) {
355
- newPermissions.push(permission)
356
- } else if (!check && newPermissions.includes(permission)) {
357
- newPermissions = newPermissions.filter((p) => p !== permission)
358
- }
359
- })
464
+ // ── Đếm ───────────────────────────────────────────────────────────────
465
+ const groupPermissionCodes = React.useCallback(
466
+ (rows: MatrixRow[]) =>
467
+ rows.flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`)),
468
+ []
469
+ )
470
+
471
+ const countSelected = React.useCallback(
472
+ (codes: string[]) =>
473
+ codes.filter((p) => formData.permissions.includes(p)).length,
474
+ [formData.permissions]
475
+ )
360
476
 
361
- setFormData((prev) => ({ ...prev, permissions: newPermissions }))
477
+ const toggleAllForGroup = (rows: MatrixRow[], enable: boolean) => {
478
+ const codes = groupPermissionCodes(rows)
479
+ mutatePermissions((set) => {
480
+ if (enable) codes.forEach((c) => set.add(c))
481
+ else codes.forEach((c) => set.delete(c))
482
+ })
362
483
  }
363
484
 
364
- const isResourceFullPermission = (
365
- resourceCode: string,
366
- resourceActions: string[]
367
- ) => {
368
- if (resourceActions.length === 0) return false
369
- return resourceActions.every((actionCode) =>
370
- formData.permissions.includes(`${actionCode}:${resourceCode}`)
371
- )
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
+ const applyTemplate = (templateCode: string) => {
497
+ const tpl = roleTemplates?.find((t) => t.code === templateCode)
498
+ if (!tpl) return
499
+ setFormData((prev) => ({ ...prev, permissions: [...tpl.permissions] }))
500
+ toast.success(`Đã áp bộ quyền mẫu "${tpl.label}"`, {
501
+ description: `${tpl.permissions.length} quyền — chỉnh tiếp rồi bấm Lưu.`,
502
+ })
372
503
  }
373
504
 
374
505
  const handleSubmit = async (e: React.FormEvent) => {
@@ -409,14 +540,12 @@ export function RoleFormPage({
409
540
  const visibleGroups = Object.entries(groupedMatrix).filter(
410
541
  ([group, groupResources]) =>
411
542
  groupResources.length > 0 &&
412
- !(activeGroup && activeGroup !== "all" && activeGroup !== group)
543
+ (searching || activeGroup === "all" || activeGroup === group)
413
544
  )
414
545
 
415
546
  return (
416
547
  <div className="flex flex-col">
417
- {/* ===== 1. Header bar navy 1 DÒNG theo pattern chi tiết đơn bán
418
- hàng (bg-sidebar). Mobile: chỉ ← Tên ⏻ ✕ Lưu; mã (chip) + mô tả
419
- hiện từ md. Create mode: ô mã rớt xuống dòng riêng trên mobile. ===== */}
548
+ {/* ===== 1. Header bar navy 1 DÒNG ===== */}
420
549
  <div className="-mx-4 -mt-4 flex flex-wrap items-center gap-1.5 bg-sidebar px-3 py-2 text-sidebar-foreground shadow-md md:-mx-8 lg:mx-3 lg:mt-1 lg:rounded-xl lg:px-4">
421
550
  <Button
422
551
  variant="ghost"
@@ -461,8 +590,6 @@ export function RoleFormPage({
461
590
  className="order-last h-7 basis-full rounded-md border-white/20 bg-white/10 px-2 font-mono text-xs text-primary-foreground shadow-none placeholder:text-primary-foreground/40 focus-visible:ring-1 focus-visible:ring-white/40 sm:order-none sm:w-44 sm:basis-auto"
462
591
  />
463
592
  )}
464
- {/* Chip trạng thái màu (getStatusMeta.solidClass — cùng taxonomy với
465
- header đơn bán hàng); bấm để đổi Hoạt động ↔ Khóa */}
466
593
  {(() => {
467
594
  const statusMeta = getStatusMeta(
468
595
  formData.status,
@@ -526,44 +653,115 @@ export function RoleFormPage({
526
653
  </div>
527
654
 
528
655
  <div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
529
- {/* ===== 2. Toolbar sticky ===== */}
530
- <div className="sticky top-0 z-20 flex flex-wrap items-center gap-2 border-b border-border bg-card/95 px-3 py-2 backdrop-blur supports-[backdrop-filter]:bg-card/85 sm:px-4">
531
- <div className="relative min-w-[140px] flex-1 sm:max-w-64">
532
- <Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
533
- <Input
534
- placeholder="Tìm kiếm tài nguyên..."
535
- className="h-8 rounded-md border border-border bg-card pl-8 text-sm"
536
- value={searchTerm}
537
- onChange={(e) => setSearchTerm(e.target.value)}
538
- />
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>
675
+ </SelectTrigger>
676
+ <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
+ )}
686
+ </span>
687
+ </SelectItem>
688
+ ))}
689
+ </SelectContent>
690
+ </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>
539
699
  </div>
540
- <Select
541
- value={activeGroup || "all"}
542
- onValueChange={(val) => setActiveGroup(val === "all" ? null : val)}
543
- >
544
- <SelectTrigger className="h-8 w-36 rounded-md border border-border bg-card text-sm sm:w-44">
545
- <SelectValue placeholder="Tất cả nhóm" />
546
- </SelectTrigger>
547
- <SelectContent className="rounded-lg">
548
- <SelectItem value="all">Tất cả nhóm</SelectItem>
549
- {Object.keys(groupedMatrix).map((group) => (
550
- <SelectItem key={group} value={group}>
700
+
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 (
719
+ <button
720
+ key={group}
721
+ type="button"
722
+ onClick={() => {
723
+ setActiveGroup(group)
724
+ setSearchTerm("")
725
+ }}
726
+ 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"
731
+ )}
732
+ >
551
733
  {group}
552
- </SelectItem>
553
- ))}
554
- </SelectContent>
555
- </Select>
556
- <p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
557
- {filteredMatrix.length} tài nguyên ·{" "}
558
- <span className="font-semibold tabular-nums text-foreground">
559
- {formData.permissions.length}
560
- </span>
561
- <span className="tabular-nums">/{totalAvailablePermissions}</span>{" "}
562
- quyền
563
- </p>
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>
564
762
  </div>
565
763
 
566
- {/* ===== 3. Nhóm + tài nguyên dạng dòng ===== */}
764
+ {/* ===== 3. Dòng tài nguyên: mức truy cập + thao tác nghiệp vụ ===== */}
567
765
  {visibleGroups.length === 0 ? (
568
766
  <div className="flex flex-col items-center justify-center py-16 text-center">
569
767
  <Search className="mb-3 h-6 w-6 text-muted-foreground" />
@@ -573,30 +771,14 @@ export function RoleFormPage({
573
771
  </div>
574
772
  ) : (
575
773
  visibleGroups.map(([group, groupResources]) => {
576
- const collapsed = !!collapsedGroups[group]
577
- const { selected, total } = groupSelectedCount(group)
774
+ const codes = groupPermissionCodes(groupResources)
775
+ const selected = countSelected(codes)
776
+ const total = codes.length
578
777
  const isFull = total > 0 && selected === total
579
778
 
580
779
  return (
581
780
  <section key={group}>
582
- {/* Dòng tiêu đề nhóm mảnh, không đóng khung */}
583
- <div
584
- role="button"
585
- tabIndex={0}
586
- onClick={() => toggleGroupCollapse(group)}
587
- onKeyDown={(e) => {
588
- if (e.key === "Enter" || e.key === " ") {
589
- e.preventDefault()
590
- toggleGroupCollapse(group)
591
- }
592
- }}
593
- className="flex cursor-pointer select-none items-center gap-2 border-b border-border bg-card px-4 py-2 transition-colors hover:bg-muted/40"
594
- >
595
- {collapsed ? (
596
- <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
597
- ) : (
598
- <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
599
- )}
781
+ <div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
600
782
  <h3 className="truncate text-xs font-semibold uppercase tracking-wide text-foreground">
601
783
  {group}
602
784
  </h3>
@@ -611,15 +793,12 @@ export function RoleFormPage({
611
793
  {groupResources.length} tài nguyên · {selected}/{total}
612
794
  {isFull && <Check className="ml-0.5 inline h-3 w-3" />}
613
795
  </span>
614
- <div
615
- className="ml-auto flex shrink-0 items-center gap-1"
616
- onClick={(e) => e.stopPropagation()}
617
- >
796
+ <div className="ml-auto flex shrink-0 items-center gap-1">
618
797
  <Button
619
798
  type="button"
620
799
  variant="ghost"
621
800
  size="sm"
622
- onClick={() => toggleAllForGroup(group, false)}
801
+ onClick={() => toggleAllForGroup(groupResources, false)}
623
802
  disabled={selected === 0}
624
803
  className="h-6 px-2 text-xs text-muted-foreground hover:text-destructive"
625
804
  >
@@ -629,7 +808,7 @@ export function RoleFormPage({
629
808
  type="button"
630
809
  variant="ghost"
631
810
  size="sm"
632
- onClick={() => toggleAllForGroup(group, true)}
811
+ onClick={() => toggleAllForGroup(groupResources, true)}
633
812
  disabled={isFull}
634
813
  className="h-6 px-2 text-xs font-semibold text-primary hover:text-primary"
635
814
  >
@@ -638,106 +817,178 @@ export function RoleFormPage({
638
817
  </div>
639
818
  </div>
640
819
 
641
- {/* Các dòng tài nguyên */}
642
- {!collapsed && (
643
- <ul className="divide-y divide-border/70 border-b border-border">
644
- {groupResources.map((resource) => (
645
- <li key={resource.resource} className="px-4 py-2.5">
646
- <div className="flex items-center gap-2">
647
- <DynamicIcon
648
- name={resource.icon as any}
649
- className="h-4 w-4 shrink-0 text-muted-foreground"
650
- />
651
- <span className="truncate text-sm font-medium text-foreground">
652
- {resource.name}
653
- </span>
654
- <code className="hidden shrink-0 font-mono text-[11px] text-muted-foreground sm:inline">
655
- {resource.resource}
656
- </code>
657
- <label className="ml-auto flex shrink-0 cursor-pointer items-center gap-1.5">
658
- <span className="hidden text-[10px] font-medium uppercase text-muted-foreground sm:inline">
659
- Tất cả
660
- </span>
661
- <Switch
662
- checked={isResourceFullPermission(
663
- resource.resource,
664
- resource.actions
665
- )}
666
- onCheckedChange={(checked) =>
667
- toggleAllForResource(
668
- resource.resource,
669
- resource.actions,
670
- checked
671
- )
672
- }
673
- className="origin-right scale-75"
820
+ <ul className="divide-y divide-border/60 border-b border-border">
821
+ {groupResources.map((r) => {
822
+ const level = deriveLevel(r)
823
+ const bizOn = r.businessActions.filter((a) =>
824
+ has(r.resource, a)
825
+ ).length
826
+ const expanded = expandedRows.has(r.resource)
827
+ const page = resourcePages?.[r.resource]
828
+
829
+ // Nhóm thao tác theo LUỒNG
830
+ const flows = new Map<string, string[]>()
831
+ r.businessActions.forEach((code) => {
832
+ const flow = actionMeta?.[code]?.flow || "Khác"
833
+ const list = flows.get(flow) ?? []
834
+ list.push(code)
835
+ flows.set(flow, list)
836
+ })
837
+
838
+ return (
839
+ <li key={r.resource} className="px-4 py-2.5">
840
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
841
+ {/* Tên + trang */}
842
+ <div className="flex min-w-0 flex-1 basis-52 items-center gap-2">
843
+ <DynamicIcon
844
+ name={r.icon as any}
845
+ className="h-4 w-4 shrink-0 text-muted-foreground"
674
846
  />
675
- </label>
676
- </div>
677
-
678
- {/* Chip quyền — icon theo loại action, bấm để bật/tắt */}
679
- <div className="mt-1.5 flex flex-wrap gap-1.5 sm:pl-6">
680
- {actions.map((action) => {
681
- if (!resource.actions.includes(action.code))
682
- return null
683
- const isChecked = hasPermission(
684
- resource.resource,
685
- action.code
686
- )
687
- const label =
688
- action.name ||
689
- mergedActionLabels[action.code]?.label ||
690
- action.code
691
- const visual = getActionVisual(action.code)
692
- const ActionIcon = visual.icon
693
-
694
- return (
695
- <button
696
- key={action.code}
697
- type="button"
698
- onClick={() =>
699
- togglePermission(
700
- resource.resource,
701
- action.code
702
- )
703
- }
704
- className={cn(
705
- "inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-colors",
706
- isChecked
707
- ? "border-primary/40 bg-primary/10 text-primary"
708
- : "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground"
709
- )}
710
- >
711
- {/* Ô tick tường minh — nhìn là biết đã cấp hay chưa */}
712
- <span
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}
713
875
  className={cn(
714
- "flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-[4px] border transition-colors",
715
- isChecked
716
- ? "border-primary bg-primary text-primary-foreground"
717
- : "border-border bg-card text-transparent"
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"
718
885
  )}
719
886
  >
720
- <Check
721
- className="h-2.5 w-2.5"
722
- strokeWidth={3}
723
- />
724
- </span>
725
- <ActionIcon
726
- className={cn(
727
- "h-3.5 w-3.5 shrink-0",
728
- visual.cls,
729
- !isChecked && "opacity-55"
730
- )}
731
- />
732
- {label}
733
- </button>
734
- )
735
- })}
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
+ )}
736
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
+ )}
737
988
  </li>
738
- ))}
739
- </ul>
740
- )}
989
+ )
990
+ })}
991
+ </ul>
741
992
  </section>
742
993
  )
743
994
  })