@goplusvn/core 0.1.24 → 0.1.25
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/CHANGELOG.md +13 -0
- package/package.json +1 -1
- package/src/rbac/pages/role-form-page.tsx +243 -272
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.25 — RoleFormPage bố cục "một khối liền" (gọn)
|
|
4
|
+
|
|
5
|
+
Trang chi tiết vai trò trước đây phình: header chiếm 1/3 màn, mỗi nhóm là card
|
|
6
|
+
header riêng + lưới card tài nguyên lồng 3 lớp viền. Viết lại thành MỘT
|
|
7
|
+
container bg-card duy nhất:
|
|
8
|
+
- Header mỏng 2 dòng (← tên inline-edit · mã · switch hoạt động · mô tả | Hủy/Lưu).
|
|
9
|
+
- Toolbar sticky: tìm kiếm + lọc nhóm + "n tài nguyên · x/y quyền" (đếm quyền
|
|
10
|
+
đã chọn realtime).
|
|
11
|
+
- Nhóm = dòng tiêu đề mảnh: collapse + "k tài nguyên · x/y" + Bỏ chọn/Chọn tất cả.
|
|
12
|
+
- Tài nguyên = dòng (icon · tên · mã | switch Tất cả) + CHIP quyền bấm trực
|
|
13
|
+
tiếp wrap ngang (bỏ ô emoji, check + label, tint primary khi chọn).
|
|
14
|
+
Props không đổi (actionLabels giữ tương thích — chip mới chỉ dùng label).
|
|
15
|
+
|
|
3
16
|
## 0.1.24 — Canvas app tông TRẮNG + fix multi-select tràn nút đóng
|
|
4
17
|
|
|
5
18
|
- **Canvas toàn app hết ngả xám**: `SidebarInset` (layout dọc) và
|
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
// Trang tạo/sửa VAI TRÒ —
|
|
4
|
-
//
|
|
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.
|
|
5
9
|
//
|
|
6
10
|
// API contract: POST /api/roles, PUT /api/roles/:id.
|
|
7
11
|
|
|
@@ -40,8 +44,9 @@ export interface ActionLabelConfig {
|
|
|
40
44
|
color: string;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
// Nhãn
|
|
44
|
-
//
|
|
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.)
|
|
45
50
|
const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
|
|
46
51
|
view: { label: "Xem", icon: "👁️", color: "text-blue-600" },
|
|
47
52
|
create: { label: "Tạo", icon: "➕", color: "text-green-600" },
|
|
@@ -85,7 +90,7 @@ export interface RoleFormPageProps {
|
|
|
85
90
|
status: string;
|
|
86
91
|
permissions: string[];
|
|
87
92
|
};
|
|
88
|
-
/** Nhãn
|
|
93
|
+
/** Nhãn bổ sung cho action nghiệp vụ riêng của app (merge đè mặc định). */
|
|
89
94
|
actionLabels?: Record<string, ActionLabelConfig>;
|
|
90
95
|
}
|
|
91
96
|
|
|
@@ -136,7 +141,6 @@ export function RoleFormPage({
|
|
|
136
141
|
if (!key) return "";
|
|
137
142
|
if (!dictionary) return key;
|
|
138
143
|
|
|
139
|
-
// Try to find key in dictionary (e.g. "menu.dashboard" or "nav.system")
|
|
140
144
|
const parts = key.split(".");
|
|
141
145
|
let current = dictionary;
|
|
142
146
|
for (const part of parts) {
|
|
@@ -154,12 +158,10 @@ export function RoleFormPage({
|
|
|
154
158
|
// Build dynamic permission matrix
|
|
155
159
|
const permissionMatrix = React.useMemo(() => {
|
|
156
160
|
return resources.map((resource) => {
|
|
157
|
-
// Default actions using database configuration
|
|
158
161
|
let allowedActions: string[] = [];
|
|
159
162
|
const defaultActions = actions
|
|
160
163
|
.filter((a) => a.isDefault)
|
|
161
164
|
.map((a) => a.code);
|
|
162
|
-
// Fallback to hardcoded if no default actions found (safety check)
|
|
163
165
|
const effectiveDefaultActions =
|
|
164
166
|
defaultActions.length > 0
|
|
165
167
|
? defaultActions
|
|
@@ -173,25 +175,21 @@ export function RoleFormPage({
|
|
|
173
175
|
: resource.config;
|
|
174
176
|
|
|
175
177
|
if (config.actions && Array.isArray(config.actions)) {
|
|
176
|
-
// Case 2: Config exists -> use configured actions (even if empty)
|
|
177
178
|
const allowedCodes = config.actions as string[];
|
|
178
179
|
allowedActions = actions
|
|
179
180
|
.map((a) => a.code)
|
|
180
181
|
.filter((code) => allowedCodes.includes(code));
|
|
181
182
|
} else {
|
|
182
|
-
// Config exists but no actions array -> use defaults
|
|
183
183
|
allowedActions = actions
|
|
184
184
|
.map((a) => a.code)
|
|
185
185
|
.filter((code) => effectiveDefaultActions.includes(code));
|
|
186
186
|
}
|
|
187
187
|
} catch (_e) {
|
|
188
|
-
// Ignore config parsing errors, fallback to defaults
|
|
189
188
|
allowedActions = actions
|
|
190
189
|
.map((a) => a.code)
|
|
191
190
|
.filter((code) => effectiveDefaultActions.includes(code));
|
|
192
191
|
}
|
|
193
192
|
} else {
|
|
194
|
-
// Case 1: No config -> use defaults
|
|
195
193
|
allowedActions = actions
|
|
196
194
|
.map((a) => a.code)
|
|
197
195
|
.filter((code) => effectiveDefaultActions.includes(code));
|
|
@@ -208,6 +206,12 @@ export function RoleFormPage({
|
|
|
208
206
|
});
|
|
209
207
|
}, [resources, actions, getLocalizedName]);
|
|
210
208
|
|
|
209
|
+
// Tổng số quyền khả dụng (mẫu số của "x/y quyền" trên toolbar)
|
|
210
|
+
const totalAvailablePermissions = React.useMemo(
|
|
211
|
+
() => permissionMatrix.reduce((sum, r) => sum + r.actions.length, 0),
|
|
212
|
+
[permissionMatrix],
|
|
213
|
+
);
|
|
214
|
+
|
|
211
215
|
// Filter matrix based on search
|
|
212
216
|
const filteredMatrix = React.useMemo(() => {
|
|
213
217
|
if (!searchTerm.trim()) return permissionMatrix;
|
|
@@ -238,7 +242,6 @@ export function RoleFormPage({
|
|
|
238
242
|
React.useEffect(() => {
|
|
239
243
|
const groups = Object.keys(groupedMatrix);
|
|
240
244
|
if (groups.length > 0) {
|
|
241
|
-
// Keep 'all' if selected, otherwise fallback to first group if current selection invalid
|
|
242
245
|
if (
|
|
243
246
|
activeGroup !== "all" &&
|
|
244
247
|
(!activeGroup || !groups.includes(activeGroup))
|
|
@@ -267,22 +270,26 @@ export function RoleFormPage({
|
|
|
267
270
|
setFormData({ ...formData, permissions: newPermissions });
|
|
268
271
|
};
|
|
269
272
|
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
273
|
+
const groupPermissionCodes = React.useCallback(
|
|
274
|
+
(groupName: string) => {
|
|
275
|
+
const groupResources = groupedMatrix[groupName] || [];
|
|
276
|
+
return groupResources.flatMap((r) =>
|
|
277
|
+
actions
|
|
278
|
+
.filter((a) => r.actions.includes(a.code))
|
|
279
|
+
.map((a) => `${a.code}:${r.resource}`),
|
|
280
|
+
);
|
|
281
|
+
},
|
|
282
|
+
[groupedMatrix, actions],
|
|
283
|
+
);
|
|
277
284
|
|
|
285
|
+
const toggleAllForGroup = (groupName: string, enable: boolean) => {
|
|
286
|
+
const groupPermissions = groupPermissionCodes(groupName);
|
|
278
287
|
let newPermissions = [...formData.permissions];
|
|
279
288
|
|
|
280
289
|
if (enable) {
|
|
281
|
-
// Add all missing permissions for this group
|
|
282
290
|
const toAdd = groupPermissions.filter((p) => !newPermissions.includes(p));
|
|
283
291
|
newPermissions = [...newPermissions, ...toAdd];
|
|
284
292
|
} else {
|
|
285
|
-
// Remove all permissions for this group
|
|
286
293
|
newPermissions = newPermissions.filter(
|
|
287
294
|
(p) => !groupPermissions.includes(p),
|
|
288
295
|
);
|
|
@@ -291,18 +298,13 @@ export function RoleFormPage({
|
|
|
291
298
|
setFormData({ ...formData, permissions: newPermissions });
|
|
292
299
|
};
|
|
293
300
|
|
|
294
|
-
//
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
);
|
|
302
|
-
return (
|
|
303
|
-
groupPermissions.length > 0 &&
|
|
304
|
-
groupPermissions.every((p) => formData.permissions.includes(p))
|
|
305
|
-
);
|
|
301
|
+
// Số quyền đã chọn trong 1 nhóm (tử số hiển thị trên dòng nhóm)
|
|
302
|
+
const groupSelectedCount = (groupName: string) => {
|
|
303
|
+
const codes = groupPermissionCodes(groupName);
|
|
304
|
+
return {
|
|
305
|
+
selected: codes.filter((p) => formData.permissions.includes(p)).length,
|
|
306
|
+
total: codes.length,
|
|
307
|
+
};
|
|
306
308
|
};
|
|
307
309
|
|
|
308
310
|
const toggleAllForResource = (
|
|
@@ -369,21 +371,27 @@ export function RoleFormPage({
|
|
|
369
371
|
}
|
|
370
372
|
};
|
|
371
373
|
|
|
374
|
+
const visibleGroups = Object.entries(groupedMatrix).filter(
|
|
375
|
+
([group, groupResources]) =>
|
|
376
|
+
groupResources.length > 0 &&
|
|
377
|
+
!(activeGroup && activeGroup !== "all" && activeGroup !== group),
|
|
378
|
+
);
|
|
379
|
+
|
|
372
380
|
return (
|
|
373
|
-
<div className="container max-w-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
<div className="flex
|
|
377
|
-
<
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
<div>
|
|
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">
|
|
386
|
+
<Button
|
|
387
|
+
variant="ghost"
|
|
388
|
+
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">
|
|
387
395
|
<Input
|
|
388
396
|
id="name"
|
|
389
397
|
value={formData.name}
|
|
@@ -391,12 +399,12 @@ export function RoleFormPage({
|
|
|
391
399
|
setFormData({ ...formData, name: e.target.value })
|
|
392
400
|
}
|
|
393
401
|
placeholder="Tên vai trò (VD: Quản lý kho)..."
|
|
394
|
-
className="
|
|
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"
|
|
395
403
|
required
|
|
396
404
|
/>
|
|
397
|
-
<div className="flex flex-wrap items-center gap-
|
|
398
|
-
<div className="flex items-center gap-
|
|
399
|
-
<span className="text-[10px]
|
|
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">
|
|
400
408
|
Mã:
|
|
401
409
|
</span>
|
|
402
410
|
<Input
|
|
@@ -407,12 +415,12 @@ export function RoleFormPage({
|
|
|
407
415
|
}
|
|
408
416
|
placeholder="VD: warehouse_manager"
|
|
409
417
|
disabled={mode === "edit"}
|
|
410
|
-
className="h-7
|
|
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"
|
|
411
419
|
/>
|
|
412
420
|
</div>
|
|
413
|
-
<
|
|
414
|
-
<span className="text-[10px]
|
|
415
|
-
Hoạt động
|
|
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
|
|
416
424
|
</span>
|
|
417
425
|
<Switch
|
|
418
426
|
checked={formData.status === "active"}
|
|
@@ -422,61 +430,53 @@ export function RoleFormPage({
|
|
|
422
430
|
status: checked ? "active" : "inactive",
|
|
423
431
|
})
|
|
424
432
|
}
|
|
425
|
-
className="scale-75
|
|
433
|
+
className="origin-left scale-75"
|
|
426
434
|
/>
|
|
427
|
-
</
|
|
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
|
+
/>
|
|
428
445
|
</div>
|
|
429
446
|
</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
447
|
</div>
|
|
442
|
-
</div>
|
|
443
448
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
</Badge>
|
|
449
|
-
)}
|
|
450
|
-
<div className="flex items-center gap-2 w-full lg:w-auto">
|
|
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
|
+
)}
|
|
451
453
|
<Button
|
|
452
454
|
variant="outline"
|
|
453
455
|
onClick={() => router.push(`/${lang}/roles`)}
|
|
454
456
|
disabled={isSubmitting}
|
|
455
|
-
className="h-
|
|
457
|
+
className="h-8"
|
|
456
458
|
>
|
|
457
|
-
<X className="mr-
|
|
459
|
+
<X className="mr-1.5 h-4 w-4" />
|
|
458
460
|
Hủy
|
|
459
461
|
</Button>
|
|
460
462
|
<Button
|
|
461
463
|
onClick={handleSubmit}
|
|
462
464
|
disabled={isSubmitting}
|
|
463
|
-
className="h-
|
|
465
|
+
className="h-8"
|
|
464
466
|
>
|
|
465
|
-
<Save className="mr-
|
|
467
|
+
<Save className="mr-1.5 h-4 w-4" />
|
|
466
468
|
{isSubmitting ? "Đang lưu..." : "Lưu"}
|
|
467
469
|
</Button>
|
|
468
470
|
</div>
|
|
469
471
|
</div>
|
|
470
|
-
</div>
|
|
471
472
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
473
|
+
{/* ===== 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">
|
|
476
|
+
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
477
477
|
<Input
|
|
478
478
|
placeholder="Tìm kiếm tài nguyên..."
|
|
479
|
-
className="
|
|
479
|
+
className="h-8 rounded-md border border-border bg-card pl-8 text-sm"
|
|
480
480
|
value={searchTerm}
|
|
481
481
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
482
482
|
/>
|
|
@@ -485,7 +485,7 @@ export function RoleFormPage({
|
|
|
485
485
|
value={activeGroup || "all"}
|
|
486
486
|
onValueChange={(val) => setActiveGroup(val === "all" ? null : val)}
|
|
487
487
|
>
|
|
488
|
-
<SelectTrigger className="
|
|
488
|
+
<SelectTrigger className="h-8 w-full rounded-md border border-border bg-card text-sm sm:w-44">
|
|
489
489
|
<SelectValue placeholder="Tất cả nhóm" />
|
|
490
490
|
</SelectTrigger>
|
|
491
491
|
<SelectContent className="rounded-lg">
|
|
@@ -497,207 +497,178 @@ export function RoleFormPage({
|
|
|
497
497
|
))}
|
|
498
498
|
</SelectContent>
|
|
499
499
|
</Select>
|
|
500
|
+
<p className="whitespace-nowrap text-xs text-muted-foreground sm:ml-auto">
|
|
501
|
+
{filteredMatrix.length} tài nguyên ·{" "}
|
|
502
|
+
<span className="font-semibold tabular-nums text-foreground">
|
|
503
|
+
{formData.permissions.length}
|
|
504
|
+
</span>
|
|
505
|
+
<span className="tabular-nums">/{totalAvailablePermissions}</span>{" "}
|
|
506
|
+
quyền
|
|
507
|
+
</p>
|
|
500
508
|
</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
|
-
|
|
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
509
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
<
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
>
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
510
|
+
{/* ===== 3. Nhóm + tài nguyên dạng dòng ===== */}
|
|
511
|
+
{visibleGroups.length === 0 ? (
|
|
512
|
+
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
513
|
+
<Search className="mb-3 h-6 w-6 text-muted-foreground" />
|
|
514
|
+
<p className="text-sm text-muted-foreground">
|
|
515
|
+
Không có tài nguyên nào khớp bộ lọc.
|
|
516
|
+
</p>
|
|
517
|
+
</div>
|
|
518
|
+
) : (
|
|
519
|
+
visibleGroups.map(([group, groupResources]) => {
|
|
520
|
+
const collapsed = !!collapsedGroups[group];
|
|
521
|
+
const { selected, total } = groupSelectedCount(group);
|
|
522
|
+
const isFull = total > 0 && selected === total;
|
|
523
|
+
|
|
524
|
+
return (
|
|
525
|
+
<section key={group}>
|
|
526
|
+
{/* Dòng tiêu đề nhóm — mảnh, không đóng khung */}
|
|
527
|
+
<div
|
|
528
|
+
role="button"
|
|
529
|
+
tabIndex={0}
|
|
530
|
+
onClick={() => toggleGroupCollapse(group)}
|
|
531
|
+
onKeyDown={(e) => {
|
|
532
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
533
|
+
e.preventDefault();
|
|
534
|
+
toggleGroupCollapse(group);
|
|
535
|
+
}
|
|
536
|
+
}}
|
|
537
|
+
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"
|
|
538
|
+
>
|
|
539
|
+
{collapsed ? (
|
|
540
|
+
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
541
|
+
) : (
|
|
542
|
+
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
543
|
+
)}
|
|
544
|
+
<h3 className="truncate text-xs font-semibold uppercase tracking-wide text-foreground">
|
|
545
|
+
{group}
|
|
546
|
+
</h3>
|
|
547
|
+
<span
|
|
548
|
+
className={cn(
|
|
549
|
+
"whitespace-nowrap text-[11px] tabular-nums",
|
|
550
|
+
isFull
|
|
551
|
+
? "font-medium text-emerald-600 dark:text-emerald-400"
|
|
552
|
+
: "text-muted-foreground",
|
|
537
553
|
)}
|
|
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
554
|
>
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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"
|
|
555
|
+
{groupResources.length} tài nguyên · {selected}/{total}
|
|
556
|
+
{isFull && <Check className="ml-0.5 inline h-3 w-3" />}
|
|
557
|
+
</span>
|
|
558
|
+
<div
|
|
559
|
+
className="ml-auto flex shrink-0 items-center gap-1"
|
|
560
|
+
onClick={(e) => e.stopPropagation()}
|
|
576
561
|
>
|
|
577
|
-
|
|
578
|
-
|
|
562
|
+
<Button
|
|
563
|
+
type="button"
|
|
564
|
+
variant="ghost"
|
|
565
|
+
size="sm"
|
|
566
|
+
onClick={() => toggleAllForGroup(group, false)}
|
|
567
|
+
disabled={selected === 0}
|
|
568
|
+
className="h-6 px-2 text-xs text-muted-foreground hover:text-destructive"
|
|
569
|
+
>
|
|
570
|
+
Bỏ chọn
|
|
571
|
+
</Button>
|
|
572
|
+
<Button
|
|
573
|
+
type="button"
|
|
574
|
+
variant="ghost"
|
|
575
|
+
size="sm"
|
|
576
|
+
onClick={() => toggleAllForGroup(group, true)}
|
|
577
|
+
disabled={isFull}
|
|
578
|
+
className="h-6 px-2 text-xs font-semibold text-primary hover:text-primary"
|
|
579
|
+
>
|
|
580
|
+
Chọn tất cả
|
|
581
|
+
</Button>
|
|
582
|
+
</div>
|
|
579
583
|
</div>
|
|
580
|
-
</div>
|
|
581
584
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
key={resource.resource}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
<
|
|
593
|
-
|
|
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ả
|
|
585
|
+
{/* Các dòng tài nguyên */}
|
|
586
|
+
{!collapsed && (
|
|
587
|
+
<ul className="divide-y divide-border/70 border-b border-border">
|
|
588
|
+
{groupResources.map((resource) => (
|
|
589
|
+
<li key={resource.resource} className="px-4 py-2.5">
|
|
590
|
+
<div className="flex items-center gap-2">
|
|
591
|
+
<DynamicIcon
|
|
592
|
+
name={resource.icon as any}
|
|
593
|
+
className="h-4 w-4 shrink-0 text-muted-foreground"
|
|
594
|
+
/>
|
|
595
|
+
<span className="truncate text-sm font-medium text-foreground">
|
|
596
|
+
{resource.name}
|
|
613
597
|
</span>
|
|
614
|
-
<
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
598
|
+
<code className="hidden shrink-0 font-mono text-[11px] text-muted-foreground sm:inline">
|
|
599
|
+
{resource.resource}
|
|
600
|
+
</code>
|
|
601
|
+
<label className="ml-auto flex shrink-0 cursor-pointer items-center gap-1.5">
|
|
602
|
+
<span className="hidden text-[10px] font-medium uppercase text-muted-foreground sm:inline">
|
|
603
|
+
Tất cả
|
|
604
|
+
</span>
|
|
605
|
+
<Switch
|
|
606
|
+
checked={isResourceFullPermission(
|
|
621
607
|
resource.resource,
|
|
622
608
|
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",
|
|
660
609
|
)}
|
|
661
|
-
|
|
662
|
-
|
|
610
|
+
onCheckedChange={(checked) =>
|
|
611
|
+
toggleAllForResource(
|
|
612
|
+
resource.resource,
|
|
613
|
+
resource.actions,
|
|
614
|
+
checked,
|
|
615
|
+
)
|
|
663
616
|
}
|
|
664
|
-
|
|
665
|
-
|
|
617
|
+
className="origin-right scale-75"
|
|
618
|
+
/>
|
|
619
|
+
</label>
|
|
620
|
+
</div>
|
|
621
|
+
|
|
622
|
+
{/* Chip quyền — bấm để bật/tắt, wrap ngang */}
|
|
623
|
+
<div className="mt-1.5 flex flex-wrap gap-1.5 sm:pl-6">
|
|
624
|
+
{actions.map((action) => {
|
|
625
|
+
if (!resource.actions.includes(action.code))
|
|
626
|
+
return null;
|
|
627
|
+
const isChecked = hasPermission(
|
|
628
|
+
resource.resource,
|
|
629
|
+
action.code,
|
|
630
|
+
);
|
|
631
|
+
const label =
|
|
632
|
+
action.name ||
|
|
633
|
+
mergedActionLabels[action.code]?.label ||
|
|
634
|
+
action.code;
|
|
635
|
+
|
|
636
|
+
return (
|
|
637
|
+
<button
|
|
638
|
+
key={action.code}
|
|
639
|
+
type="button"
|
|
640
|
+
onClick={() =>
|
|
641
|
+
togglePermission(
|
|
642
|
+
resource.resource,
|
|
643
|
+
action.code,
|
|
644
|
+
)
|
|
645
|
+
}
|
|
666
646
|
className={cn(
|
|
667
|
-
"flex items-center
|
|
647
|
+
"inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors",
|
|
668
648
|
isChecked
|
|
669
|
-
? "
|
|
670
|
-
: "bg-card border-
|
|
649
|
+
? "border-primary/30 bg-primary/5 text-primary"
|
|
650
|
+
: "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground",
|
|
671
651
|
)}
|
|
672
652
|
>
|
|
673
|
-
<Check
|
|
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>
|
|
679
|
-
<span
|
|
653
|
+
<Check
|
|
680
654
|
className={cn(
|
|
681
|
-
"
|
|
682
|
-
isChecked
|
|
683
|
-
? "text-primary"
|
|
684
|
-
: "text-muted-foreground",
|
|
655
|
+
"h-3 w-3",
|
|
656
|
+
!isChecked && "opacity-0",
|
|
685
657
|
)}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
})}
|
|
658
|
+
/>
|
|
659
|
+
{label}
|
|
660
|
+
</button>
|
|
661
|
+
);
|
|
662
|
+
})}
|
|
663
|
+
</div>
|
|
664
|
+
</li>
|
|
665
|
+
))}
|
|
666
|
+
</ul>
|
|
667
|
+
)}
|
|
668
|
+
</section>
|
|
669
|
+
);
|
|
670
|
+
})
|
|
671
|
+
)}
|
|
701
672
|
</div>
|
|
702
673
|
</div>
|
|
703
674
|
);
|