@goplusvn/core 0.1.24 → 0.1.26

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,13 @@
1
1
  "use client";
2
2
 
3
- // Trang tạo/sửa VAI TRÒ — thông tin chung (kiểu Notion) + ma trận phân quyền
4
- // theo nhóm tài nguyên. Bề mặt trắng (bg-card) thay cho các mảng bg-muted xám.
5
- //
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 + 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).
6
8
  // API contract: POST /api/roles, PUT /api/roles/:id.
7
-
8
- import * as React from "react";
9
- import { useRouter } from "next/navigation";
10
- import { toast } from "sonner";
11
- import {
12
- ArrowLeft,
13
- Check,
14
- ChevronDown,
15
- ChevronRight,
16
- Save,
17
- Search,
18
- X,
19
- } from "lucide-react";
20
-
9
+ import * as React from "react"
10
+ import { useRouter } from "next/navigation"
21
11
  import {
22
12
  Badge,
23
13
  Button,
@@ -29,19 +19,47 @@ import {
29
19
  SelectTrigger,
30
20
  SelectValue,
31
21
  Switch,
32
- } from "../../ui";
33
- import { cn } from "../../utils";
34
- import { errorMessage, throwFetchError } from "../lib/fetch-error";
35
- import type { CrudPermissions } from "../../types";
22
+ getStatusMeta,
23
+ } from "../../ui"
24
+ import { toast } from "sonner"
25
+ import {
26
+ ArrowLeft,
27
+ BadgeCheck,
28
+ Ban,
29
+ Bot,
30
+ Check,
31
+ ChevronDown,
32
+ ChevronRight,
33
+ Copy,
34
+ Download,
35
+ Eye,
36
+ PenLine,
37
+ Pencil,
38
+ Plus,
39
+ Printer,
40
+ RefreshCw,
41
+ Save,
42
+ Search,
43
+ Settings2,
44
+ Trash2,
45
+ Upload,
46
+ X,
47
+ Zap,
48
+ } from "lucide-react"
49
+
50
+ import type { CrudPermissions } from "../../types"
51
+ import type { LucideIcon } from "lucide-react"
52
+
53
+ import { errorMessage, throwFetchError } from "../lib/fetch-error"
54
+ import { cn } from "../../utils"
36
55
 
37
56
  export interface ActionLabelConfig {
38
- label: string;
39
- icon: string;
40
- color: string;
57
+ label: string
58
+ icon: string
59
+ color: string
41
60
  }
42
61
 
43
- // Nhãn/icon mặc định cho các action CRUD chuẩn app truyền `actionLabels`
44
- // để bổ sung/ghi đè cho các action nghiệp vụ riêng.
62
+ // Nhãn mặc định cho action CRUD chuẩn (giữ tương thích prop actionLabels).
45
63
  const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
46
64
  view: { label: "Xem", icon: "👁️", color: "text-blue-600" },
47
65
  create: { label: "Tạo", icon: "➕", color: "text-green-600" },
@@ -53,40 +71,71 @@ const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
53
71
  manage: { label: "Quản lý", icon: "🔧", color: "text-orange-600" },
54
72
  edit_items: { label: "Sửa chi tiết", icon: "📝", color: "text-amber-600" },
55
73
  delete_items: { label: "Xóa chi tiết", icon: "❌", color: "text-rose-600" },
56
- };
74
+ }
75
+
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 có icon mà không cần cấu hình từng cái. ---
78
+ const ACTION_VISUAL_RULES: Array<{
79
+ re: RegExp
80
+ icon: LucideIcon
81
+ cls: string
82
+ }> = [
83
+ { re: /^(view|read|get|list)/, icon: Eye, cls: "text-blue-500" },
84
+ { re: /^(create|add|new)/, icon: Plus, cls: "text-emerald-500" },
85
+ { re: /^(update|edit|change|set)/, icon: Pencil, cls: "text-amber-500" },
86
+ { re: /^(delete|remove)/, icon: Trash2, cls: "text-rose-500" },
87
+ { re: /^(cancel|void|reject)/, icon: Ban, cls: "text-rose-500" },
88
+ {
89
+ re: /^(approve|confirm|verify|accept)/,
90
+ icon: BadgeCheck,
91
+ cls: "text-violet-500",
92
+ },
93
+ { re: /^sign/, icon: PenLine, cls: "text-indigo-500" },
94
+ { re: /^import/, icon: Download, cls: "text-purple-500" },
95
+ { re: /^export/, icon: Upload, cls: "text-indigo-500" },
96
+ { 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" },
99
+ { re: /^auto/, icon: Bot, cls: "text-purple-500" },
100
+ ]
101
+
102
+ const getActionVisual = (code: string) =>
103
+ ACTION_VISUAL_RULES.find((r) => r.re.test(code)) ?? {
104
+ icon: Zap,
105
+ cls: "text-slate-400",
106
+ }
57
107
 
58
108
  export interface RoleFormPageProps {
59
- dictionary: any;
60
- permissions?: CrudPermissions;
61
- lang: string;
109
+ dictionary: any
110
+ permissions?: CrudPermissions
111
+ lang: string
62
112
  resources: Array<{
63
- id: string;
64
- code: string;
65
- name: string;
66
- group: string | null;
67
- description: string | null;
68
- icon: string | null;
69
- order: number | null;
70
- config: any;
71
- }>;
113
+ id: string
114
+ code: string
115
+ name: string
116
+ group: string | null
117
+ description: string | null
118
+ icon: string | null
119
+ order: number | null
120
+ config: any
121
+ }>
72
122
  actions: Array<{
73
- id: string;
74
- code: string;
75
- name: string;
76
- description: string | null;
77
- isDefault?: boolean;
78
- }>;
79
- mode: "create" | "edit";
123
+ id: string
124
+ code: string
125
+ name: string
126
+ description: string | null
127
+ isDefault?: boolean
128
+ }>
129
+ mode: "create" | "edit"
80
130
  initialData?: {
81
- id: string;
82
- name: string;
83
- code: string;
84
- description: string | undefined;
85
- status: string;
86
- permissions: string[];
87
- };
88
- /** Nhãn/icon bổ sung cho action nghiệp vụ riêng của app (merge đè mặc định). */
89
- actionLabels?: Record<string, ActionLabelConfig>;
131
+ id: string
132
+ name: string
133
+ code: string
134
+ description: string | undefined
135
+ status: string
136
+ permissions: string[]
137
+ }
138
+ actionLabels?: Record<string, ActionLabelConfig>
90
139
  }
91
140
 
92
141
  export function RoleFormPage({
@@ -99,15 +148,15 @@ export function RoleFormPage({
99
148
  initialData,
100
149
  actionLabels,
101
150
  }: RoleFormPageProps) {
102
- const router = useRouter();
151
+ const router = useRouter()
103
152
 
104
153
  const mergedActionLabels = React.useMemo(
105
154
  () => ({ ...DEFAULT_ACTION_LABELS, ...actionLabels }),
106
- [actionLabels],
107
- );
155
+ [actionLabels]
156
+ )
108
157
 
109
- const [isSubmitting, setIsSubmitting] = React.useState(false);
110
- const [searchTerm, setSearchTerm] = React.useState("");
158
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
159
+ const [searchTerm, setSearchTerm] = React.useState("")
111
160
 
112
161
  const [formData, setFormData] = React.useState({
113
162
  name: initialData?.name || "",
@@ -115,86 +164,76 @@ export function RoleFormPage({
115
164
  description: initialData?.description || "",
116
165
  status: (initialData?.status || "active") as "active" | "inactive",
117
166
  permissions: initialData?.permissions || ([] as string[]),
118
- });
167
+ })
119
168
 
120
- const [activeGroup, setActiveGroup] = React.useState<string | null>("all");
121
- // Mặc định mở rộng tất cả (Collapsed = false)
169
+ const [activeGroup, setActiveGroup] = React.useState<string | null>("all")
122
170
  const [collapsedGroups, setCollapsedGroups] = React.useState<
123
171
  Record<string, boolean>
124
- >({});
172
+ >({})
125
173
 
126
174
  const toggleGroupCollapse = (groupName: string) => {
127
175
  setCollapsedGroups((prev) => ({
128
176
  ...prev,
129
177
  [groupName]: !prev[groupName],
130
- }));
131
- };
178
+ }))
179
+ }
132
180
 
133
- // Helper to get localized text from dictionary
134
181
  const getLocalizedName = React.useCallback(
135
182
  (key: string | null) => {
136
- if (!key) return "";
137
- if (!dictionary) return key;
183
+ if (!key) return ""
184
+ if (!dictionary) return key
138
185
 
139
- // Try to find key in dictionary (e.g. "menu.dashboard" or "nav.system")
140
- const parts = key.split(".");
141
- let current = dictionary;
186
+ const parts = key.split(".")
187
+ let current = dictionary
142
188
  for (const part of parts) {
143
189
  if (current && typeof current === "object" && part in current) {
144
- current = current[part];
190
+ current = current[part]
145
191
  } else {
146
- return key; // fallback to key if not found
192
+ return key
147
193
  }
148
194
  }
149
- return typeof current === "string" ? current : key;
195
+ return typeof current === "string" ? current : key
150
196
  },
151
- [dictionary],
152
- );
197
+ [dictionary]
198
+ )
153
199
 
154
- // Build dynamic permission matrix
155
200
  const permissionMatrix = React.useMemo(() => {
156
201
  return resources.map((resource) => {
157
- // Default actions using database configuration
158
- let allowedActions: string[] = [];
202
+ let allowedActions: string[] = []
159
203
  const defaultActions = actions
160
204
  .filter((a) => a.isDefault)
161
- .map((a) => a.code);
162
- // Fallback to hardcoded if no default actions found (safety check)
205
+ .map((a) => a.code)
163
206
  const effectiveDefaultActions =
164
207
  defaultActions.length > 0
165
208
  ? defaultActions
166
- : ["view", "create", "update", "delete"];
209
+ : ["view", "create", "update", "delete"]
167
210
 
168
211
  if (resource.config) {
169
212
  try {
170
213
  const config =
171
214
  typeof resource.config === "string"
172
215
  ? JSON.parse(resource.config)
173
- : resource.config;
216
+ : resource.config
174
217
 
175
218
  if (config.actions && Array.isArray(config.actions)) {
176
- // Case 2: Config exists -> use configured actions (even if empty)
177
- const allowedCodes = config.actions as string[];
219
+ const allowedCodes = config.actions as string[]
178
220
  allowedActions = actions
179
221
  .map((a) => a.code)
180
- .filter((code) => allowedCodes.includes(code));
222
+ .filter((code) => allowedCodes.includes(code))
181
223
  } else {
182
- // Config exists but no actions array -> use defaults
183
224
  allowedActions = actions
184
225
  .map((a) => a.code)
185
- .filter((code) => effectiveDefaultActions.includes(code));
226
+ .filter((code) => effectiveDefaultActions.includes(code))
186
227
  }
187
228
  } catch (_e) {
188
- // Ignore config parsing errors, fallback to defaults
189
229
  allowedActions = actions
190
230
  .map((a) => a.code)
191
- .filter((code) => effectiveDefaultActions.includes(code));
231
+ .filter((code) => effectiveDefaultActions.includes(code))
192
232
  }
193
233
  } else {
194
- // Case 1: No config -> use defaults
195
234
  allowedActions = actions
196
235
  .map((a) => a.code)
197
- .filter((code) => effectiveDefaultActions.includes(code));
236
+ .filter((code) => effectiveDefaultActions.includes(code))
198
237
  }
199
238
 
200
239
  return {
@@ -204,153 +243,151 @@ export function RoleFormPage({
204
243
  group: getLocalizedName(resource.group) || "Khác",
205
244
  description: resource.description,
206
245
  actions: allowedActions,
207
- };
208
- });
209
- }, [resources, actions, getLocalizedName]);
246
+ }
247
+ })
248
+ }, [resources, actions, getLocalizedName])
249
+
250
+ const totalAvailablePermissions = React.useMemo(
251
+ () => permissionMatrix.reduce((sum, r) => sum + r.actions.length, 0),
252
+ [permissionMatrix]
253
+ )
210
254
 
211
- // Filter matrix based on search
212
255
  const filteredMatrix = React.useMemo(() => {
213
- if (!searchTerm.trim()) return permissionMatrix;
256
+ if (!searchTerm.trim()) return permissionMatrix
214
257
 
215
- const search = searchTerm.toLowerCase();
258
+ const search = searchTerm.toLowerCase()
216
259
  return permissionMatrix.filter(
217
260
  (resource) =>
218
261
  resource.name.toLowerCase().includes(search) ||
219
262
  resource.resource.toLowerCase().includes(search) ||
220
- resource.group.toLowerCase().includes(search),
221
- );
222
- }, [searchTerm, permissionMatrix]);
263
+ resource.group.toLowerCase().includes(search)
264
+ )
265
+ }, [searchTerm, permissionMatrix])
223
266
 
224
- // Group resources by group
225
267
  const groupedMatrix = React.useMemo(() => {
226
- const groups: Record<string, typeof permissionMatrix> = {};
268
+ const groups: Record<string, typeof permissionMatrix> = {}
227
269
  filteredMatrix.forEach((resource) => {
228
- const group = resource.group;
270
+ const group = resource.group
229
271
  if (!groups[group]) {
230
- groups[group] = [];
272
+ groups[group] = []
231
273
  }
232
- groups[group].push(resource);
233
- });
234
- return groups;
235
- }, [filteredMatrix]);
274
+ groups[group].push(resource)
275
+ })
276
+ return groups
277
+ }, [filteredMatrix])
236
278
 
237
- // Set initial active group or update if filtered
238
279
  React.useEffect(() => {
239
- const groups = Object.keys(groupedMatrix);
280
+ const groups = Object.keys(groupedMatrix)
240
281
  if (groups.length > 0) {
241
- // Keep 'all' if selected, otherwise fallback to first group if current selection invalid
242
282
  if (
243
283
  activeGroup !== "all" &&
244
284
  (!activeGroup || !groups.includes(activeGroup))
245
285
  ) {
246
- setActiveGroup("all");
286
+ setActiveGroup("all")
247
287
  }
248
288
  } else {
249
- setActiveGroup(null);
289
+ setActiveGroup(null)
250
290
  }
251
- }, [groupedMatrix, activeGroup]);
291
+ }, [groupedMatrix, activeGroup])
252
292
 
253
- // Permission helpers
254
293
  const hasPermission = (resource: string, action: string): boolean => {
255
- return formData.permissions.includes(`${action}:${resource}`);
256
- };
294
+ return formData.permissions.includes(`${action}:${resource}`)
295
+ }
257
296
 
258
297
  const togglePermission = (resource: string, action: string) => {
259
- const permission = `${action}:${resource}`;
260
- let newPermissions = [...formData.permissions];
298
+ const permission = `${action}:${resource}`
299
+ let newPermissions = [...formData.permissions]
261
300
 
262
301
  if (newPermissions.includes(permission)) {
263
- newPermissions = newPermissions.filter((p) => p !== permission);
302
+ newPermissions = newPermissions.filter((p) => p !== permission)
264
303
  } else {
265
- newPermissions.push(permission);
304
+ newPermissions.push(permission)
266
305
  }
267
- setFormData({ ...formData, permissions: newPermissions });
268
- };
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]
319
+ )
269
320
 
270
321
  const toggleAllForGroup = (groupName: string, enable: boolean) => {
271
- const groupResources = groupedMatrix[groupName] || [];
272
- const groupPermissions = groupResources.flatMap((r) =>
273
- actions
274
- .filter((a) => r.actions.includes(a.code))
275
- .map((a) => `${a.code}:${r.resource}`),
276
- );
277
-
278
- let newPermissions = [...formData.permissions];
322
+ const groupPermissions = groupPermissionCodes(groupName)
323
+ let newPermissions = [...formData.permissions]
279
324
 
280
325
  if (enable) {
281
- // Add all missing permissions for this group
282
- const toAdd = groupPermissions.filter((p) => !newPermissions.includes(p));
283
- newPermissions = [...newPermissions, ...toAdd];
326
+ const toAdd = groupPermissions.filter((p) => !newPermissions.includes(p))
327
+ newPermissions = [...newPermissions, ...toAdd]
284
328
  } else {
285
- // Remove all permissions for this group
286
329
  newPermissions = newPermissions.filter(
287
- (p) => !groupPermissions.includes(p),
288
- );
330
+ (p) => !groupPermissions.includes(p)
331
+ )
289
332
  }
290
333
 
291
- setFormData({ ...formData, permissions: newPermissions });
292
- };
293
-
294
- // Check if group has full permissions
295
- const isGroupFullPermission = (groupName: string) => {
296
- const groupResources = groupedMatrix[groupName] || [];
297
- const groupPermissions = groupResources.flatMap((r) =>
298
- actions
299
- .filter((a) => r.actions.includes(a.code))
300
- .map((a) => `${a.code}:${r.resource}`),
301
- );
302
- return (
303
- groupPermissions.length > 0 &&
304
- groupPermissions.every((p) => formData.permissions.includes(p))
305
- );
306
- };
334
+ setFormData({ ...formData, permissions: newPermissions })
335
+ }
336
+
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,
342
+ }
343
+ }
307
344
 
308
345
  const toggleAllForResource = (
309
346
  resourceCode: string,
310
347
  resourceActions: string[],
311
- check: boolean,
348
+ check: boolean
312
349
  ) => {
313
- let newPermissions = [...formData.permissions];
350
+ let newPermissions = [...formData.permissions]
314
351
 
315
352
  resourceActions.forEach((actionCode) => {
316
- const permission = `${actionCode}:${resourceCode}`;
353
+ const permission = `${actionCode}:${resourceCode}`
317
354
  if (check && !newPermissions.includes(permission)) {
318
- newPermissions.push(permission);
355
+ newPermissions.push(permission)
319
356
  } else if (!check && newPermissions.includes(permission)) {
320
- newPermissions = newPermissions.filter((p) => p !== permission);
357
+ newPermissions = newPermissions.filter((p) => p !== permission)
321
358
  }
322
- });
359
+ })
323
360
 
324
- setFormData((prev) => ({ ...prev, permissions: newPermissions }));
325
- };
361
+ setFormData((prev) => ({ ...prev, permissions: newPermissions }))
362
+ }
326
363
 
327
364
  const isResourceFullPermission = (
328
365
  resourceCode: string,
329
- resourceActions: string[],
366
+ resourceActions: string[]
330
367
  ) => {
331
- if (resourceActions.length === 0) return false;
368
+ if (resourceActions.length === 0) return false
332
369
  return resourceActions.every((actionCode) =>
333
- formData.permissions.includes(`${actionCode}:${resourceCode}`),
334
- );
335
- };
370
+ formData.permissions.includes(`${actionCode}:${resourceCode}`)
371
+ )
372
+ }
336
373
 
337
374
  const handleSubmit = async (e: React.FormEvent) => {
338
- e.preventDefault();
339
- setIsSubmitting(true);
375
+ e.preventDefault()
376
+ setIsSubmitting(true)
340
377
 
341
378
  try {
342
379
  const url =
343
- mode === "create" ? "/api/roles" : `/api/roles/${initialData?.id}`;
344
- const method = mode === "create" ? "POST" : "PUT";
380
+ mode === "create" ? "/api/roles" : `/api/roles/${initialData?.id}`
381
+ const method = mode === "create" ? "POST" : "PUT"
345
382
 
346
383
  const res = await fetch(url, {
347
384
  method,
348
385
  headers: { "Content-Type": "application/json" },
349
386
  body: JSON.stringify(formData),
350
- });
387
+ })
351
388
 
352
389
  if (!res.ok) {
353
- await throwFetchError(res, `Failed to ${mode} role`);
390
+ await throwFetchError(res, `Failed to ${mode} role`)
354
391
  }
355
392
 
356
393
  toast.success("Thành công", {
@@ -358,125 +395,144 @@ export function RoleFormPage({
358
395
  mode === "create"
359
396
  ? "Đã tạo vai trò mới thành công"
360
397
  : "Đã cập nhật vai trò thành công",
361
- });
398
+ })
362
399
 
363
- router.push(`/${lang}/roles`);
364
- router.refresh();
400
+ router.push(`/${lang}/roles`)
401
+ router.refresh()
365
402
  } catch (error: any) {
366
- toast.error(errorMessage(error, "Lỗi"));
403
+ toast.error(errorMessage(error, "Lỗi"))
367
404
  } finally {
368
- setIsSubmitting(false);
405
+ setIsSubmitting(false)
369
406
  }
370
- };
407
+ }
371
408
 
372
- return (
373
- <div className="container max-w-7xl mx-auto py-6 space-y-6">
374
- {/* Header & General Info (Notion Style) — thẻ trắng */}
375
- <div className="flex flex-col lg:flex-row items-start lg:justify-between border border-border pb-4 sm:pb-6 bg-card p-4 sm:p-6 rounded-2xl shadow-sm gap-4 sm:gap-6">
376
- <div className="flex items-start gap-2 sm:gap-4 flex-1 w-full min-w-0">
377
- <Button
378
- variant="ghost"
379
- size="icon"
380
- onClick={() => router.push(`/${lang}/roles`)}
381
- className="mt-1 shrink-0"
382
- >
383
- <ArrowLeft className="h-5 w-5" />
384
- </Button>
385
- <div className="flex-1 space-y-3 w-full min-w-0">
386
- <div>
387
- <Input
388
- id="name"
389
- value={formData.name}
390
- onChange={(e) =>
391
- setFormData({ ...formData, name: e.target.value })
392
- }
393
- placeholder="Tên vai trò (VD: Quản lý kho)..."
394
- className="text-xl sm:text-2xl md:text-3xl font-black h-auto py-2 border-transparent hover:border-border focus-visible:ring-0 focus-visible:border-primary focus-visible:bg-card bg-transparent px-2 -ml-2 shadow-none rounded-[8px] placeholder:text-muted-foreground/40 transition-colors w-full"
395
- required
396
- />
397
- <div className="flex flex-wrap items-center gap-2 sm:gap-4 mt-2 px-1">
398
- <div className="flex items-center gap-2">
399
- <span className="text-[10px] sm:text-xs font-medium text-muted-foreground uppercase">
400
- Mã:
401
- </span>
402
- <Input
403
- id="code"
404
- value={formData.code}
405
- onChange={(e) =>
406
- setFormData({ ...formData, code: e.target.value })
407
- }
408
- placeholder="VD: warehouse_manager"
409
- disabled={mode === "edit"}
410
- className="h-7 text-xs border border-border bg-card w-32 sm:w-48 shadow-none focus-visible:ring-1 focus-visible:ring-primary rounded-md"
411
- />
412
- </div>
413
- <div className="flex items-center gap-2 bg-card px-2 sm:px-3 h-7 rounded-md border border-border shrink-0">
414
- <span className="text-[10px] sm:text-xs font-medium text-muted-foreground uppercase">
415
- Hoạt động:
416
- </span>
417
- <Switch
418
- checked={formData.status === "active"}
419
- onCheckedChange={(checked) =>
420
- setFormData({
421
- ...formData,
422
- status: checked ? "active" : "inactive",
423
- })
424
- }
425
- className="scale-75 origin-left"
426
- />
427
- </div>
428
- </div>
429
- </div>
430
- <div className="px-1">
431
- <Input
432
- id="description"
433
- value={formData.description}
434
- onChange={(e) =>
435
- setFormData({ ...formData, description: e.target.value })
436
- }
437
- placeholder="Thêm mô tả về nhiệm vụ của vai trò này..."
438
- className="h-8 text-xs sm:text-sm border-transparent hover:border-border focus-visible:ring-0 focus-visible:bg-muted/40 shadow-none bg-transparent px-2 -ml-2 rounded-md w-full"
439
- />
440
- </div>
441
- </div>
442
- </div>
409
+ const visibleGroups = Object.entries(groupedMatrix).filter(
410
+ ([group, groupResources]) =>
411
+ groupResources.length > 0 &&
412
+ !(activeGroup && activeGroup !== "all" && activeGroup !== group)
413
+ )
443
414
 
444
- <div className="flex flex-col gap-2 shrink-0 w-full lg:w-auto lg:items-end mt-2 lg:mt-0 pl-10 sm:pl-14 lg:pl-0">
445
- {permissions?.update === false && mode === "edit" && (
446
- <Badge variant="destructive" className="mb-2 w-fit">
447
- Xem chế độ chỉ đọc
448
- </Badge>
449
- )}
450
- <div className="flex items-center gap-2 w-full lg:w-auto">
415
+ return (
416
+ <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. ===== */}
420
+ <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
+ <Button
422
+ variant="ghost"
423
+ size="icon"
424
+ onClick={() => router.push(`/${lang}/roles`)}
425
+ className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
426
+ >
427
+ <ArrowLeft className="h-4 w-4" />
428
+ </Button>
429
+ <Input
430
+ id="name"
431
+ value={formData.name}
432
+ onChange={(e) => setFormData({ ...formData, name: e.target.value })}
433
+ placeholder="Tên vai trò..."
434
+ className="h-8 min-w-[110px] flex-1 rounded-md border-transparent bg-transparent px-2 text-base font-bold text-primary-foreground shadow-none transition-colors placeholder:text-primary-foreground/40 hover:border-white/20 focus-visible:bg-white/10 focus-visible:ring-0 sm:text-lg"
435
+ required
436
+ />
437
+ {mode === "edit" ? (
438
+ <span className="hidden shrink-0 items-center gap-0.5 md:flex">
439
+ <code className="rounded bg-white/10 px-1.5 py-1 font-mono text-[11px] text-primary-foreground/80">
440
+ {formData.code}
441
+ </code>
451
442
  <Button
452
- variant="outline"
453
- onClick={() => router.push(`/${lang}/roles`)}
454
- disabled={isSubmitting}
455
- className="h-9 rounded-[8px] flex-1 lg:flex-none"
443
+ variant="ghost"
444
+ size="icon"
445
+ className="h-6 w-6 shrink-0 text-primary-foreground/70 hover:bg-white/10 hover:text-primary-foreground"
446
+ onClick={() => {
447
+ navigator.clipboard.writeText(formData.code)
448
+ toast.success(`Đã copy mã: ${formData.code}`)
449
+ }}
450
+ title="Copy mã vai trò"
456
451
  >
457
- <X className="mr-2 h-4 w-4" />
458
- Hủy
452
+ <Copy className="h-3 w-3" />
459
453
  </Button>
460
- <Button
461
- onClick={handleSubmit}
462
- disabled={isSubmitting}
463
- className="h-9 rounded-[8px] flex-1 lg:flex-none"
454
+ </span>
455
+ ) : (
456
+ <Input
457
+ id="code"
458
+ value={formData.code}
459
+ onChange={(e) => setFormData({ ...formData, code: e.target.value })}
460
+ placeholder="mã: warehouse_manager"
461
+ 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
+ />
463
+ )}
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
+ {(() => {
467
+ const statusMeta = getStatusMeta(
468
+ formData.status,
469
+ formData.status === "inactive" ? "Khóa" : undefined
470
+ )
471
+ return (
472
+ <button
473
+ type="button"
474
+ onClick={() =>
475
+ setFormData({
476
+ ...formData,
477
+ status: formData.status === "active" ? "inactive" : "active",
478
+ })
479
+ }
480
+ title="Bấm để đổi trạng thái"
481
+ className={cn(
482
+ "flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5 text-[11px] font-semibold shadow-sm transition-opacity hover:opacity-85",
483
+ statusMeta.solidClass
484
+ )}
464
485
  >
465
- <Save className="mr-2 h-4 w-4" />
466
- {isSubmitting ? "Đang lưu..." : "Lưu"}
467
- </Button>
468
- </div>
469
- </div>
486
+ <span className="h-1.5 w-1.5 rounded-full bg-white/80" />
487
+ {statusMeta.label}
488
+ </button>
489
+ )
490
+ })()}
491
+ <Input
492
+ id="description"
493
+ value={formData.description}
494
+ onChange={(e) =>
495
+ setFormData({ ...formData, description: e.target.value })
496
+ }
497
+ placeholder="Mô tả vai trò..."
498
+ className="hidden h-7 min-w-[140px] flex-1 rounded-md border-transparent bg-transparent px-2 text-xs text-primary-foreground shadow-none placeholder:text-primary-foreground/40 hover:border-white/20 focus-visible:bg-white/10 focus-visible:ring-0 md:block"
499
+ />
500
+ {permissions?.update === false && mode === "edit" && (
501
+ <Badge
502
+ variant="destructive"
503
+ className="hidden shrink-0 sm:inline-flex"
504
+ >
505
+ Chỉ đọc
506
+ </Badge>
507
+ )}
508
+ <Button
509
+ variant="ghost"
510
+ onClick={() => router.push(`/${lang}/roles`)}
511
+ disabled={isSubmitting}
512
+ title="Hủy"
513
+ className="h-8 w-8 shrink-0 p-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground sm:w-auto sm:px-2.5"
514
+ >
515
+ <X className="h-4 w-4" />
516
+ <span className="hidden sm:ml-1.5 sm:inline">Hủy</span>
517
+ </Button>
518
+ <Button
519
+ onClick={handleSubmit}
520
+ disabled={isSubmitting}
521
+ className="h-8 shrink-0 bg-primary-foreground px-2.5 text-primary hover:bg-primary-foreground/90 sm:px-3"
522
+ >
523
+ <Save className="h-4 w-4" />
524
+ <span className="ml-1.5">{isSubmitting ? "Lưu..." : "Lưu"}</span>
525
+ </Button>
470
526
  </div>
471
527
 
472
- {/* Section 2: Permissions Toolbar thẻ trắng, input trắng */}
473
- <div className="sticky top-4 z-20 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/80 p-3 rounded-xl border border-border shadow-sm flex flex-col md:flex-row gap-3 items-center justify-between">
474
- <div className="flex items-center gap-3 w-full md:w-auto flex-1">
475
- <div className="relative w-full md:w-80">
476
- <Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
528
+ <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" />
477
533
  <Input
478
534
  placeholder="Tìm kiếm tài nguyên..."
479
- className="pl-9 h-9 text-sm bg-card border border-border rounded-md"
535
+ className="h-8 rounded-md border border-border bg-card pl-8 text-sm"
480
536
  value={searchTerm}
481
537
  onChange={(e) => setSearchTerm(e.target.value)}
482
538
  />
@@ -485,7 +541,7 @@ export function RoleFormPage({
485
541
  value={activeGroup || "all"}
486
542
  onValueChange={(val) => setActiveGroup(val === "all" ? null : val)}
487
543
  >
488
- <SelectTrigger className="w-[180px] h-9 text-sm bg-card border border-border rounded-md">
544
+ <SelectTrigger className="h-8 w-36 rounded-md border border-border bg-card text-sm sm:w-44">
489
545
  <SelectValue placeholder="Tất cả nhóm" />
490
546
  </SelectTrigger>
491
547
  <SelectContent className="rounded-lg">
@@ -497,208 +553,196 @@ export function RoleFormPage({
497
553
  ))}
498
554
  </SelectContent>
499
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>
500
564
  </div>
501
- <div className="flex items-center gap-2">
502
- <Badge
503
- variant="outline"
504
- className="font-medium bg-card text-muted-foreground border-border h-7 px-2"
505
- >
506
- {filteredMatrix.length} tài nguyên
507
- </Badge>
508
- </div>
509
- </div>
510
565
 
511
- {/* Section 3: Permission Groups */}
512
- <div className="space-y-8">
513
- {Object.entries(groupedMatrix).map(([group, groupResources]) => {
514
- // Skip if filtering by specific group
515
- if (activeGroup && activeGroup !== "all" && activeGroup !== group)
516
- return null;
517
- if (groupResources.length === 0) return null;
518
-
519
- const isFull = isGroupFullPermission(group);
520
-
521
- return (
522
- <div
523
- key={group}
524
- className="space-y-4 animate-in fade-in duration-500"
525
- >
526
- {/* Header nhóm — thẻ trắng, điểm nhấn là chip chevron indigo */}
527
- <div
528
- className="flex flex-col sm:flex-row sm:items-center justify-between bg-card p-2.5 rounded-xl border border-border mb-4 gap-3 shadow-sm cursor-pointer hover:border-primary/30 transition-colors"
529
- onClick={() => toggleGroupCollapse(group)}
530
- >
531
- <div className="flex items-center gap-2.5 pl-1">
532
- <div className="bg-primary text-primary-foreground p-1.5 rounded-md shadow-sm transition-transform duration-200">
533
- {collapsedGroups[group] ? (
534
- <ChevronRight className="h-4 w-4" />
535
- ) : (
536
- <ChevronDown className="h-4 w-4" />
566
+ {/* ===== 3. Nhóm + tài nguyên dạng dòng ===== */}
567
+ {visibleGroups.length === 0 ? (
568
+ <div className="flex flex-col items-center justify-center py-16 text-center">
569
+ <Search className="mb-3 h-6 w-6 text-muted-foreground" />
570
+ <p className="text-sm text-muted-foreground">
571
+ Không có tài nguyên nào khớp bộ lọc.
572
+ </p>
573
+ </div>
574
+ ) : (
575
+ visibleGroups.map(([group, groupResources]) => {
576
+ const collapsed = !!collapsedGroups[group]
577
+ const { selected, total } = groupSelectedCount(group)
578
+ const isFull = total > 0 && selected === total
579
+
580
+ return (
581
+ <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
+ )}
600
+ <h3 className="truncate text-xs font-semibold uppercase tracking-wide text-foreground">
601
+ {group}
602
+ </h3>
603
+ <span
604
+ className={cn(
605
+ "whitespace-nowrap text-[11px] tabular-nums",
606
+ isFull
607
+ ? "font-medium text-emerald-600 dark:text-emerald-400"
608
+ : "text-muted-foreground"
537
609
  )}
538
- </div>
539
- <div>
540
- <h3 className="text-sm font-bold text-foreground flex items-center gap-2">
541
- {group}
542
- {isFull && (
543
- <Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400 py-0 h-4.5 px-1.5 text-[9px] font-semibold">
544
- <Check className="w-2.5 h-2.5 mr-0.5" />
545
- Đã chọn hết
546
- </Badge>
547
- )}
548
- </h3>
549
- <p className="text-xs text-muted-foreground font-medium mt-0.5">
550
- {groupResources.length} tài nguyên
551
- </p>
552
- </div>
553
- </div>
554
- <div className="flex gap-1.5 self-start sm:self-auto pr-1">
555
- <Button
556
- type="button"
557
- variant="outline"
558
- size="sm"
559
- onClick={(e) => {
560
- e.stopPropagation();
561
- toggleAllForGroup(group, false);
562
- }}
563
- className="h-7 text-xs font-semibold text-muted-foreground hover:text-destructive bg-card rounded-md border"
564
610
  >
565
- Bỏ chọn tất cả
566
- </Button>
567
- <Button
568
- type="button"
569
- variant="default"
570
- size="sm"
571
- onClick={(e) => {
572
- e.stopPropagation();
573
- toggleAllForGroup(group, true);
574
- }}
575
- className="h-7 text-xs font-semibold rounded-md bg-primary text-primary-foreground"
611
+ {groupResources.length} tài nguyên · {selected}/{total}
612
+ {isFull && <Check className="ml-0.5 inline h-3 w-3" />}
613
+ </span>
614
+ <div
615
+ className="ml-auto flex shrink-0 items-center gap-1"
616
+ onClick={(e) => e.stopPropagation()}
576
617
  >
577
- Chọn tất cả
578
- </Button>
579
- </div>
580
- </div>
581
-
582
- {!collapsedGroups[group] && (
583
- <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3 animate-in fade-in slide-in-from-top-2 duration-300">
584
- {groupResources.map((resource) => (
585
- <div
586
- key={resource.resource}
587
- className="border border-border bg-card rounded-xl shadow-sm hover:border-primary/40 transition-colors overflow-hidden group"
618
+ <Button
619
+ type="button"
620
+ variant="ghost"
621
+ size="sm"
622
+ onClick={() => toggleAllForGroup(group, false)}
623
+ disabled={selected === 0}
624
+ className="h-6 px-2 text-xs text-muted-foreground hover:text-destructive"
588
625
  >
589
- {/* Header thẻ tài nguyên — trắng, chỉ phân tách bằng border */}
590
- <div className="p-2.5 border-b border-border/60 flex items-center justify-between">
591
- <div className="flex items-center gap-2.5">
592
- <div className="h-7 w-7 rounded-[6px] bg-card border border-border shadow-sm flex items-center justify-center shrink-0 text-muted-foreground group-hover:text-primary transition-colors">
593
- <DynamicIcon
594
- name={resource.icon as any}
595
- className="h-3.5 w-3.5"
596
- />
597
- </div>
598
- <div className="flex flex-col">
599
- <h4 className="text-sm font-bold text-foreground leading-tight">
600
- {resource.name}
601
- </h4>
602
- <span className="text-[10px] text-muted-foreground mt-0.5">
603
- {resource.resource}
604
- </span>
605
- </div>
606
- </div>
607
- <div
608
- className="flex items-center gap-2"
609
- onClick={(e) => e.stopPropagation()}
610
- >
611
- <span className="text-[10px] text-muted-foreground font-semibold hidden sm:inline">
612
- Tất cả
626
+ Bỏ chọn
627
+ </Button>
628
+ <Button
629
+ type="button"
630
+ variant="ghost"
631
+ size="sm"
632
+ onClick={() => toggleAllForGroup(group, true)}
633
+ disabled={isFull}
634
+ className="h-6 px-2 text-xs font-semibold text-primary hover:text-primary"
635
+ >
636
+ Chọn tất cả
637
+ </Button>
638
+ </div>
639
+ </div>
640
+
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}
613
653
  </span>
614
- <Switch
615
- checked={isResourceFullPermission(
616
- resource.resource,
617
- resource.actions,
618
- )}
619
- onCheckedChange={(checked) =>
620
- toggleAllForResource(
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(
621
663
  resource.resource,
622
- resource.actions,
623
- checked,
624
- )
625
- }
626
- className="scale-75 origin-right"
627
- />
628
- </div>
629
- </div>
630
-
631
- <div className="p-2 grid grid-cols-2 gap-1.5">
632
- {actions.map((action) => {
633
- const isAvailable = resource.actions.includes(
634
- action.code,
635
- );
636
- const isChecked = hasPermission(
637
- resource.resource,
638
- action.code,
639
- );
640
- const staticConfig = mergedActionLabels[action.code];
641
-
642
- const config = {
643
- label:
644
- action.name || staticConfig?.label || action.code,
645
- icon: staticConfig?.icon || "•",
646
- color:
647
- staticConfig?.color || "text-muted-foreground",
648
- };
649
-
650
- if (!isAvailable) return null;
651
-
652
- return (
653
- <div
654
- key={action.code}
655
- className={cn(
656
- "flex items-center gap-2 px-2 py-1.5 rounded-[6px] border transition-all cursor-pointer relative overflow-hidden",
657
- isChecked
658
- ? "bg-primary/5 border-primary/20 shadow-[0_1px_2px_rgba(0,0,0,0.02)]"
659
- : "bg-card hover:bg-muted/50 border-border",
664
+ resource.actions
660
665
  )}
661
- onClick={() =>
662
- togglePermission(resource.resource, action.code)
666
+ onCheckedChange={(checked) =>
667
+ toggleAllForResource(
668
+ resource.resource,
669
+ resource.actions,
670
+ checked
671
+ )
663
672
  }
664
- >
665
- <div
673
+ className="origin-right scale-75"
674
+ />
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
+ }
666
704
  className={cn(
667
- "flex items-center justify-center w-3.5 h-3.5 rounded-[4px] border transition-colors shrink-0",
705
+ "inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-colors",
668
706
  isChecked
669
- ? "bg-primary border-primary text-primary-foreground"
670
- : "bg-card border-border text-transparent",
707
+ ? "border-primary/40 bg-primary/10 text-primary"
708
+ : "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground"
671
709
  )}
672
710
  >
673
- <Check className="h-2.5 w-2.5" />
674
- </div>
675
- <div className="flex items-center gap-1.5 overflow-hidden">
676
- <span className="text-xs shrink-0 opacity-80 filter grayscale">
677
- {config.icon}
678
- </span>
711
+ {/* Ô tick tường minh — nhìn là biết đã cấp hay chưa */}
679
712
  <span
680
713
  className={cn(
681
- "text-xs font-semibold truncate",
714
+ "flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-[4px] border transition-colors",
682
715
  isChecked
683
- ? "text-primary"
684
- : "text-muted-foreground",
716
+ ? "border-primary bg-primary text-primary-foreground"
717
+ : "border-border bg-card text-transparent"
685
718
  )}
686
719
  >
687
- {config.label}
720
+ <Check
721
+ className="h-2.5 w-2.5"
722
+ strokeWidth={3}
723
+ />
688
724
  </span>
689
- </div>
690
- </div>
691
- );
692
- })}
693
- </div>
694
- </div>
695
- ))}
696
- </div>
697
- )}
698
- </div>
699
- );
700
- })}
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
+ })}
736
+ </div>
737
+ </li>
738
+ ))}
739
+ </ul>
740
+ )}
741
+ </section>
742
+ )
743
+ })
744
+ )}
701
745
  </div>
702
746
  </div>
703
- );
747
+ )
704
748
  }