@goplusvn/core 0.1.25 → 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,27 +1,13 @@
1
1
  "use client";
2
2
 
3
- // Trang tạo/sửa VAI TRÒ — bố cục "MỘT KHỐI LIỀN" cho gọn:
4
- // [header mỏng: ← tên · mã · switch · mô tả | Hủy/Lưu]
5
- // [toolbar sticky: tìm kiếm · lọc nhóm · n tài nguyên · x/y quyền]
6
- // [nhóm = dòng tiêu đề mảnh (collapse + Bỏ chọn/Chọn tất cả)]
7
- // [tài nguyên = dòng + chip quyền bấm trực tiếp, wrap ngang]
8
- // Tất cả trong 1 container bg-card duy nhất — không lồng card trong card.
9
- //
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).
10
8
  // API contract: POST /api/roles, PUT /api/roles/:id.
11
-
12
- import * as React from "react";
13
- import { useRouter } from "next/navigation";
14
- import { toast } from "sonner";
15
- import {
16
- ArrowLeft,
17
- Check,
18
- ChevronDown,
19
- ChevronRight,
20
- Save,
21
- Search,
22
- X,
23
- } from "lucide-react";
24
-
9
+ import * as React from "react"
10
+ import { useRouter } from "next/navigation"
25
11
  import {
26
12
  Badge,
27
13
  Button,
@@ -33,20 +19,47 @@ import {
33
19
  SelectTrigger,
34
20
  SelectValue,
35
21
  Switch,
36
- } from "../../ui";
37
- import { cn } from "../../utils";
38
- import { errorMessage, throwFetchError } from "../lib/fetch-error";
39
- 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"
40
55
 
41
56
  export interface ActionLabelConfig {
42
- label: string;
43
- icon: string;
44
- color: string;
57
+ label: string
58
+ icon: string
59
+ color: string
45
60
  }
46
61
 
47
- // Nhãn mặc định cho các action CRUD chuẩn app truyền `actionLabels` để bổ
48
- // sung/ghi đè cho action nghiệp vụ riêng. (icon/color giữ trong type để tương
49
- // thích ngược; bố cục chip mới chỉ dùng label cho gọn.)
62
+ // Nhãn mặc định cho action CRUD chuẩn (giữ tương thích prop actionLabels).
50
63
  const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
51
64
  view: { label: "Xem", icon: "👁️", color: "text-blue-600" },
52
65
  create: { label: "Tạo", icon: "➕", color: "text-green-600" },
@@ -58,40 +71,71 @@ const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
58
71
  manage: { label: "Quản lý", icon: "🔧", color: "text-orange-600" },
59
72
  edit_items: { label: "Sửa chi tiết", icon: "📝", color: "text-amber-600" },
60
73
  delete_items: { label: "Xóa chi tiết", icon: "❌", color: "text-rose-600" },
61
- };
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
+ }
62
107
 
63
108
  export interface RoleFormPageProps {
64
- dictionary: any;
65
- permissions?: CrudPermissions;
66
- lang: string;
109
+ dictionary: any
110
+ permissions?: CrudPermissions
111
+ lang: string
67
112
  resources: Array<{
68
- id: string;
69
- code: string;
70
- name: string;
71
- group: string | null;
72
- description: string | null;
73
- icon: string | null;
74
- order: number | null;
75
- config: any;
76
- }>;
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
+ }>
77
122
  actions: Array<{
78
- id: string;
79
- code: string;
80
- name: string;
81
- description: string | null;
82
- isDefault?: boolean;
83
- }>;
84
- mode: "create" | "edit";
123
+ id: string
124
+ code: string
125
+ name: string
126
+ description: string | null
127
+ isDefault?: boolean
128
+ }>
129
+ mode: "create" | "edit"
85
130
  initialData?: {
86
- id: string;
87
- name: string;
88
- code: string;
89
- description: string | undefined;
90
- status: string;
91
- permissions: string[];
92
- };
93
- /** Nhãn bổ sung cho action nghiệp vụ riêng của app (merge đè mặc định). */
94
- 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>
95
139
  }
96
140
 
97
141
  export function RoleFormPage({
@@ -104,15 +148,15 @@ export function RoleFormPage({
104
148
  initialData,
105
149
  actionLabels,
106
150
  }: RoleFormPageProps) {
107
- const router = useRouter();
151
+ const router = useRouter()
108
152
 
109
153
  const mergedActionLabels = React.useMemo(
110
154
  () => ({ ...DEFAULT_ACTION_LABELS, ...actionLabels }),
111
- [actionLabels],
112
- );
155
+ [actionLabels]
156
+ )
113
157
 
114
- const [isSubmitting, setIsSubmitting] = React.useState(false);
115
- const [searchTerm, setSearchTerm] = React.useState("");
158
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
159
+ const [searchTerm, setSearchTerm] = React.useState("")
116
160
 
117
161
  const [formData, setFormData] = React.useState({
118
162
  name: initialData?.name || "",
@@ -120,79 +164,76 @@ export function RoleFormPage({
120
164
  description: initialData?.description || "",
121
165
  status: (initialData?.status || "active") as "active" | "inactive",
122
166
  permissions: initialData?.permissions || ([] as string[]),
123
- });
167
+ })
124
168
 
125
- const [activeGroup, setActiveGroup] = React.useState<string | null>("all");
126
- // Mặc định mở rộng tất cả (Collapsed = false)
169
+ const [activeGroup, setActiveGroup] = React.useState<string | null>("all")
127
170
  const [collapsedGroups, setCollapsedGroups] = React.useState<
128
171
  Record<string, boolean>
129
- >({});
172
+ >({})
130
173
 
131
174
  const toggleGroupCollapse = (groupName: string) => {
132
175
  setCollapsedGroups((prev) => ({
133
176
  ...prev,
134
177
  [groupName]: !prev[groupName],
135
- }));
136
- };
178
+ }))
179
+ }
137
180
 
138
- // Helper to get localized text from dictionary
139
181
  const getLocalizedName = React.useCallback(
140
182
  (key: string | null) => {
141
- if (!key) return "";
142
- if (!dictionary) return key;
183
+ if (!key) return ""
184
+ if (!dictionary) return key
143
185
 
144
- const parts = key.split(".");
145
- let current = dictionary;
186
+ const parts = key.split(".")
187
+ let current = dictionary
146
188
  for (const part of parts) {
147
189
  if (current && typeof current === "object" && part in current) {
148
- current = current[part];
190
+ current = current[part]
149
191
  } else {
150
- return key; // fallback to key if not found
192
+ return key
151
193
  }
152
194
  }
153
- return typeof current === "string" ? current : key;
195
+ return typeof current === "string" ? current : key
154
196
  },
155
- [dictionary],
156
- );
197
+ [dictionary]
198
+ )
157
199
 
158
- // Build dynamic permission matrix
159
200
  const permissionMatrix = React.useMemo(() => {
160
201
  return resources.map((resource) => {
161
- let allowedActions: string[] = [];
202
+ let allowedActions: string[] = []
162
203
  const defaultActions = actions
163
204
  .filter((a) => a.isDefault)
164
- .map((a) => a.code);
205
+ .map((a) => a.code)
165
206
  const effectiveDefaultActions =
166
207
  defaultActions.length > 0
167
208
  ? defaultActions
168
- : ["view", "create", "update", "delete"];
209
+ : ["view", "create", "update", "delete"]
169
210
 
170
211
  if (resource.config) {
171
212
  try {
172
213
  const config =
173
214
  typeof resource.config === "string"
174
215
  ? JSON.parse(resource.config)
175
- : resource.config;
216
+ : resource.config
176
217
 
177
218
  if (config.actions && Array.isArray(config.actions)) {
178
- const allowedCodes = config.actions as string[];
219
+ const allowedCodes = config.actions as string[]
179
220
  allowedActions = actions
180
221
  .map((a) => a.code)
181
- .filter((code) => allowedCodes.includes(code));
222
+ .filter((code) => allowedCodes.includes(code))
182
223
  } else {
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
229
  allowedActions = actions
189
230
  .map((a) => a.code)
190
- .filter((code) => effectiveDefaultActions.includes(code));
231
+ .filter((code) => effectiveDefaultActions.includes(code))
191
232
  }
192
233
  } else {
193
234
  allowedActions = actions
194
235
  .map((a) => a.code)
195
- .filter((code) => effectiveDefaultActions.includes(code));
236
+ .filter((code) => effectiveDefaultActions.includes(code))
196
237
  }
197
238
 
198
239
  return {
@@ -202,157 +243,151 @@ export function RoleFormPage({
202
243
  group: getLocalizedName(resource.group) || "Khác",
203
244
  description: resource.description,
204
245
  actions: allowedActions,
205
- };
206
- });
207
- }, [resources, actions, getLocalizedName]);
246
+ }
247
+ })
248
+ }, [resources, actions, getLocalizedName])
208
249
 
209
- // Tổng số quyền khả dụng (mẫu số của "x/y quyền" trên toolbar)
210
250
  const totalAvailablePermissions = React.useMemo(
211
251
  () => permissionMatrix.reduce((sum, r) => sum + r.actions.length, 0),
212
- [permissionMatrix],
213
- );
252
+ [permissionMatrix]
253
+ )
214
254
 
215
- // Filter matrix based on search
216
255
  const filteredMatrix = React.useMemo(() => {
217
- if (!searchTerm.trim()) return permissionMatrix;
256
+ if (!searchTerm.trim()) return permissionMatrix
218
257
 
219
- const search = searchTerm.toLowerCase();
258
+ const search = searchTerm.toLowerCase()
220
259
  return permissionMatrix.filter(
221
260
  (resource) =>
222
261
  resource.name.toLowerCase().includes(search) ||
223
262
  resource.resource.toLowerCase().includes(search) ||
224
- resource.group.toLowerCase().includes(search),
225
- );
226
- }, [searchTerm, permissionMatrix]);
263
+ resource.group.toLowerCase().includes(search)
264
+ )
265
+ }, [searchTerm, permissionMatrix])
227
266
 
228
- // Group resources by group
229
267
  const groupedMatrix = React.useMemo(() => {
230
- const groups: Record<string, typeof permissionMatrix> = {};
268
+ const groups: Record<string, typeof permissionMatrix> = {}
231
269
  filteredMatrix.forEach((resource) => {
232
- const group = resource.group;
270
+ const group = resource.group
233
271
  if (!groups[group]) {
234
- groups[group] = [];
272
+ groups[group] = []
235
273
  }
236
- groups[group].push(resource);
237
- });
238
- return groups;
239
- }, [filteredMatrix]);
274
+ groups[group].push(resource)
275
+ })
276
+ return groups
277
+ }, [filteredMatrix])
240
278
 
241
- // Set initial active group or update if filtered
242
279
  React.useEffect(() => {
243
- const groups = Object.keys(groupedMatrix);
280
+ const groups = Object.keys(groupedMatrix)
244
281
  if (groups.length > 0) {
245
282
  if (
246
283
  activeGroup !== "all" &&
247
284
  (!activeGroup || !groups.includes(activeGroup))
248
285
  ) {
249
- setActiveGroup("all");
286
+ setActiveGroup("all")
250
287
  }
251
288
  } else {
252
- setActiveGroup(null);
289
+ setActiveGroup(null)
253
290
  }
254
- }, [groupedMatrix, activeGroup]);
291
+ }, [groupedMatrix, activeGroup])
255
292
 
256
- // Permission helpers
257
293
  const hasPermission = (resource: string, action: string): boolean => {
258
- return formData.permissions.includes(`${action}:${resource}`);
259
- };
294
+ return formData.permissions.includes(`${action}:${resource}`)
295
+ }
260
296
 
261
297
  const togglePermission = (resource: string, action: string) => {
262
- const permission = `${action}:${resource}`;
263
- let newPermissions = [...formData.permissions];
298
+ const permission = `${action}:${resource}`
299
+ let newPermissions = [...formData.permissions]
264
300
 
265
301
  if (newPermissions.includes(permission)) {
266
- newPermissions = newPermissions.filter((p) => p !== permission);
302
+ newPermissions = newPermissions.filter((p) => p !== permission)
267
303
  } else {
268
- newPermissions.push(permission);
304
+ newPermissions.push(permission)
269
305
  }
270
- setFormData({ ...formData, permissions: newPermissions });
271
- };
306
+ setFormData({ ...formData, permissions: newPermissions })
307
+ }
272
308
 
273
309
  const groupPermissionCodes = React.useCallback(
274
310
  (groupName: string) => {
275
- const groupResources = groupedMatrix[groupName] || [];
311
+ const groupResources = groupedMatrix[groupName] || []
276
312
  return groupResources.flatMap((r) =>
277
313
  actions
278
314
  .filter((a) => r.actions.includes(a.code))
279
- .map((a) => `${a.code}:${r.resource}`),
280
- );
315
+ .map((a) => `${a.code}:${r.resource}`)
316
+ )
281
317
  },
282
- [groupedMatrix, actions],
283
- );
318
+ [groupedMatrix, actions]
319
+ )
284
320
 
285
321
  const toggleAllForGroup = (groupName: string, enable: boolean) => {
286
- const groupPermissions = groupPermissionCodes(groupName);
287
- let newPermissions = [...formData.permissions];
322
+ const groupPermissions = groupPermissionCodes(groupName)
323
+ let newPermissions = [...formData.permissions]
288
324
 
289
325
  if (enable) {
290
- const toAdd = groupPermissions.filter((p) => !newPermissions.includes(p));
291
- newPermissions = [...newPermissions, ...toAdd];
326
+ const toAdd = groupPermissions.filter((p) => !newPermissions.includes(p))
327
+ newPermissions = [...newPermissions, ...toAdd]
292
328
  } else {
293
329
  newPermissions = newPermissions.filter(
294
- (p) => !groupPermissions.includes(p),
295
- );
330
+ (p) => !groupPermissions.includes(p)
331
+ )
296
332
  }
297
333
 
298
- setFormData({ ...formData, permissions: newPermissions });
299
- };
334
+ setFormData({ ...formData, permissions: newPermissions })
335
+ }
300
336
 
301
- // Số quyền đã chọn trong 1 nhóm (tử số hiển thị trên dòng nhóm)
302
337
  const groupSelectedCount = (groupName: string) => {
303
- const codes = groupPermissionCodes(groupName);
338
+ const codes = groupPermissionCodes(groupName)
304
339
  return {
305
340
  selected: codes.filter((p) => formData.permissions.includes(p)).length,
306
341
  total: codes.length,
307
- };
308
- };
342
+ }
343
+ }
309
344
 
310
345
  const toggleAllForResource = (
311
346
  resourceCode: string,
312
347
  resourceActions: string[],
313
- check: boolean,
348
+ check: boolean
314
349
  ) => {
315
- let newPermissions = [...formData.permissions];
350
+ let newPermissions = [...formData.permissions]
316
351
 
317
352
  resourceActions.forEach((actionCode) => {
318
- const permission = `${actionCode}:${resourceCode}`;
353
+ const permission = `${actionCode}:${resourceCode}`
319
354
  if (check && !newPermissions.includes(permission)) {
320
- newPermissions.push(permission);
355
+ newPermissions.push(permission)
321
356
  } else if (!check && newPermissions.includes(permission)) {
322
- newPermissions = newPermissions.filter((p) => p !== permission);
357
+ newPermissions = newPermissions.filter((p) => p !== permission)
323
358
  }
324
- });
359
+ })
325
360
 
326
- setFormData((prev) => ({ ...prev, permissions: newPermissions }));
327
- };
361
+ setFormData((prev) => ({ ...prev, permissions: newPermissions }))
362
+ }
328
363
 
329
364
  const isResourceFullPermission = (
330
365
  resourceCode: string,
331
- resourceActions: string[],
366
+ resourceActions: string[]
332
367
  ) => {
333
- if (resourceActions.length === 0) return false;
368
+ if (resourceActions.length === 0) return false
334
369
  return resourceActions.every((actionCode) =>
335
- formData.permissions.includes(`${actionCode}:${resourceCode}`),
336
- );
337
- };
370
+ formData.permissions.includes(`${actionCode}:${resourceCode}`)
371
+ )
372
+ }
338
373
 
339
374
  const handleSubmit = async (e: React.FormEvent) => {
340
- e.preventDefault();
341
- setIsSubmitting(true);
375
+ e.preventDefault()
376
+ setIsSubmitting(true)
342
377
 
343
378
  try {
344
379
  const url =
345
- mode === "create" ? "/api/roles" : `/api/roles/${initialData?.id}`;
346
- const method = mode === "create" ? "POST" : "PUT";
380
+ mode === "create" ? "/api/roles" : `/api/roles/${initialData?.id}`
381
+ const method = mode === "create" ? "POST" : "PUT"
347
382
 
348
383
  const res = await fetch(url, {
349
384
  method,
350
385
  headers: { "Content-Type": "application/json" },
351
386
  body: JSON.stringify(formData),
352
- });
387
+ })
353
388
 
354
389
  if (!res.ok) {
355
- await throwFetchError(res, `Failed to ${mode} role`);
390
+ await throwFetchError(res, `Failed to ${mode} role`)
356
391
  }
357
392
 
358
393
  toast.success("Thành công", {
@@ -360,119 +395,140 @@ export function RoleFormPage({
360
395
  mode === "create"
361
396
  ? "Đã tạo vai trò mới thành công"
362
397
  : "Đã cập nhật vai trò thành công",
363
- });
398
+ })
364
399
 
365
- router.push(`/${lang}/roles`);
366
- router.refresh();
400
+ router.push(`/${lang}/roles`)
401
+ router.refresh()
367
402
  } catch (error: any) {
368
- toast.error(errorMessage(error, "Lỗi"));
403
+ toast.error(errorMessage(error, "Lỗi"))
369
404
  } finally {
370
- setIsSubmitting(false);
405
+ setIsSubmitting(false)
371
406
  }
372
- };
407
+ }
373
408
 
374
409
  const visibleGroups = Object.entries(groupedMatrix).filter(
375
410
  ([group, groupResources]) =>
376
411
  groupResources.length > 0 &&
377
- !(activeGroup && activeGroup !== "all" && activeGroup !== group),
378
- );
412
+ !(activeGroup && activeGroup !== "all" && activeGroup !== group)
413
+ )
379
414
 
380
415
  return (
381
- <div className="container mx-auto max-w-6xl py-4">
382
- <div className="overflow-hidden rounded-xl border border-border bg-card shadow-sm">
383
- {/* ===== 1. Header mỏng ===== */}
384
- <div className="flex flex-col gap-3 border-b border-border px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
385
- <div className="flex min-w-0 flex-1 items-start gap-1">
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: ô 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>
386
442
  <Button
387
443
  variant="ghost"
388
444
  size="icon"
389
- onClick={() => router.push(`/${lang}/roles`)}
390
- className="mt-0.5 h-8 w-8 shrink-0"
391
- >
392
- <ArrowLeft className="h-4 w-4" />
393
- </Button>
394
- <div className="min-w-0 flex-1">
395
- <Input
396
- id="name"
397
- value={formData.name}
398
- onChange={(e) =>
399
- setFormData({ ...formData, name: e.target.value })
400
- }
401
- placeholder="Tên vai trò (VD: Quản lý kho)..."
402
- className="h-auto w-full rounded-md border-transparent bg-transparent px-2 py-1 text-lg font-bold shadow-none transition-colors placeholder:text-muted-foreground/40 hover:border-border focus-visible:border-primary focus-visible:bg-card focus-visible:ring-0 sm:text-xl"
403
- required
404
- />
405
- <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1.5 px-2">
406
- <div className="flex items-center gap-1.5">
407
- <span className="text-[10px] font-medium uppercase text-muted-foreground">
408
- Mã:
409
- </span>
410
- <Input
411
- id="code"
412
- value={formData.code}
413
- onChange={(e) =>
414
- setFormData({ ...formData, code: e.target.value })
415
- }
416
- placeholder="VD: warehouse_manager"
417
- disabled={mode === "edit"}
418
- className="h-7 w-36 rounded-md border border-border bg-card text-xs shadow-none focus-visible:ring-1 focus-visible:ring-primary sm:w-44"
419
- />
420
- </div>
421
- <label className="flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-card px-2">
422
- <span className="text-[10px] font-medium uppercase text-muted-foreground">
423
- Hoạt động
424
- </span>
425
- <Switch
426
- checked={formData.status === "active"}
427
- onCheckedChange={(checked) =>
428
- setFormData({
429
- ...formData,
430
- status: checked ? "active" : "inactive",
431
- })
432
- }
433
- className="origin-left scale-75"
434
- />
435
- </label>
436
- <Input
437
- id="description"
438
- value={formData.description}
439
- onChange={(e) =>
440
- setFormData({ ...formData, description: e.target.value })
441
- }
442
- placeholder="Thêm mô tả về vai trò này..."
443
- className="h-7 min-w-[180px] flex-1 rounded-md border-transparent bg-transparent px-2 text-xs shadow-none hover:border-border focus-visible:bg-muted/30 focus-visible:ring-0"
444
- />
445
- </div>
446
- </div>
447
- </div>
448
-
449
- <div className="flex shrink-0 items-center gap-2 self-end sm:self-start">
450
- {permissions?.update === false && mode === "edit" && (
451
- <Badge variant="destructive">Chỉ đọc</Badge>
452
- )}
453
- <Button
454
- variant="outline"
455
- onClick={() => router.push(`/${lang}/roles`)}
456
- disabled={isSubmitting}
457
- className="h-8"
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ò"
458
451
  >
459
- <X className="mr-1.5 h-4 w-4" />
460
- Hủy
452
+ <Copy className="h-3 w-3" />
461
453
  </Button>
462
- <Button
463
- onClick={handleSubmit}
464
- disabled={isSubmitting}
465
- className="h-8"
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
+ )}
466
485
  >
467
- <Save className="mr-1.5 h-4 w-4" />
468
- {isSubmitting ? "Đang lưu..." : "Lưu"}
469
- </Button>
470
- </div>
471
- </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>
526
+ </div>
472
527
 
528
+ <div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
473
529
  {/* ===== 2. Toolbar sticky ===== */}
474
- <div className="sticky top-0 z-20 flex flex-col gap-2 border-b border-border bg-card/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-card/85 sm:flex-row sm:items-center">
475
- <div className="relative w-full sm:w-64">
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">
476
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..."
@@ -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="h-8 w-full rounded-md border border-border bg-card text-sm sm:w-44">
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,7 +553,7 @@ export function RoleFormPage({
497
553
  ))}
498
554
  </SelectContent>
499
555
  </Select>
500
- <p className="whitespace-nowrap text-xs text-muted-foreground sm:ml-auto">
556
+ <p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
501
557
  {filteredMatrix.length} tài nguyên ·{" "}
502
558
  <span className="font-semibold tabular-nums text-foreground">
503
559
  {formData.permissions.length}
@@ -517,9 +573,9 @@ export function RoleFormPage({
517
573
  </div>
518
574
  ) : (
519
575
  visibleGroups.map(([group, groupResources]) => {
520
- const collapsed = !!collapsedGroups[group];
521
- const { selected, total } = groupSelectedCount(group);
522
- const isFull = total > 0 && selected === total;
576
+ const collapsed = !!collapsedGroups[group]
577
+ const { selected, total } = groupSelectedCount(group)
578
+ const isFull = total > 0 && selected === total
523
579
 
524
580
  return (
525
581
  <section key={group}>
@@ -530,8 +586,8 @@ export function RoleFormPage({
530
586
  onClick={() => toggleGroupCollapse(group)}
531
587
  onKeyDown={(e) => {
532
588
  if (e.key === "Enter" || e.key === " ") {
533
- e.preventDefault();
534
- toggleGroupCollapse(group);
589
+ e.preventDefault()
590
+ toggleGroupCollapse(group)
535
591
  }
536
592
  }}
537
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"
@@ -549,7 +605,7 @@ export function RoleFormPage({
549
605
  "whitespace-nowrap text-[11px] tabular-nums",
550
606
  isFull
551
607
  ? "font-medium text-emerald-600 dark:text-emerald-400"
552
- : "text-muted-foreground",
608
+ : "text-muted-foreground"
553
609
  )}
554
610
  >
555
611
  {groupResources.length} tài nguyên · {selected}/{total}
@@ -605,13 +661,13 @@ export function RoleFormPage({
605
661
  <Switch
606
662
  checked={isResourceFullPermission(
607
663
  resource.resource,
608
- resource.actions,
664
+ resource.actions
609
665
  )}
610
666
  onCheckedChange={(checked) =>
611
667
  toggleAllForResource(
612
668
  resource.resource,
613
669
  resource.actions,
614
- checked,
670
+ checked
615
671
  )
616
672
  }
617
673
  className="origin-right scale-75"
@@ -619,19 +675,21 @@ export function RoleFormPage({
619
675
  </label>
620
676
  </div>
621
677
 
622
- {/* Chip quyền — bấm để bật/tắt, wrap ngang */}
678
+ {/* Chip quyền — icon theo loại action, bấm để bật/tắt */}
623
679
  <div className="mt-1.5 flex flex-wrap gap-1.5 sm:pl-6">
624
680
  {actions.map((action) => {
625
681
  if (!resource.actions.includes(action.code))
626
- return null;
682
+ return null
627
683
  const isChecked = hasPermission(
628
684
  resource.resource,
629
- action.code,
630
- );
685
+ action.code
686
+ )
631
687
  const label =
632
688
  action.name ||
633
689
  mergedActionLabels[action.code]?.label ||
634
- action.code;
690
+ action.code
691
+ const visual = getActionVisual(action.code)
692
+ const ActionIcon = visual.icon
635
693
 
636
694
  return (
637
695
  <button
@@ -640,25 +698,40 @@ export function RoleFormPage({
640
698
  onClick={() =>
641
699
  togglePermission(
642
700
  resource.resource,
643
- action.code,
701
+ action.code
644
702
  )
645
703
  }
646
704
  className={cn(
647
- "inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors",
705
+ "inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-colors",
648
706
  isChecked
649
- ? "border-primary/30 bg-primary/5 text-primary"
650
- : "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground",
707
+ ? "border-primary/40 bg-primary/10 text-primary"
708
+ : "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground"
651
709
  )}
652
710
  >
653
- <Check
711
+ {/* Ô tick tường minh — nhìn là biết đã cấp hay chưa */}
712
+ <span
713
+ 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"
718
+ )}
719
+ >
720
+ <Check
721
+ className="h-2.5 w-2.5"
722
+ strokeWidth={3}
723
+ />
724
+ </span>
725
+ <ActionIcon
654
726
  className={cn(
655
- "h-3 w-3",
656
- !isChecked && "opacity-0",
727
+ "h-3.5 w-3.5 shrink-0",
728
+ visual.cls,
729
+ !isChecked && "opacity-55"
657
730
  )}
658
731
  />
659
732
  {label}
660
733
  </button>
661
- );
734
+ )
662
735
  })}
663
736
  </div>
664
737
  </li>
@@ -666,10 +739,10 @@ export function RoleFormPage({
666
739
  </ul>
667
740
  )}
668
741
  </section>
669
- );
742
+ )
670
743
  })
671
744
  )}
672
745
  </div>
673
746
  </div>
674
- );
747
+ )
675
748
  }