@goplusvn/core 0.1.23 → 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 CHANGED
@@ -1,5 +1,29 @@
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
+
16
+ ## 0.1.24 — Canvas app tông TRẮNG + fix multi-select tràn nút đóng
17
+
18
+ - **Canvas toàn app hết ngả xám**: `SidebarInset` (layout dọc) và
19
+ `HorizontalLayout` bỏ lớp phủ `bg-muted/40` → dùng token `bg-background`
20
+ (app tự quyết tông nền; vinhhoa/wu chuộng trắng). Đây là lớp xám bao mọi
21
+ trang từng gây gotcha "khung xám bọc POS".
22
+ - **MultiSelect**: hàng "Chọn tất cả / Bỏ chọn / n/N / ✕" bị tràn — nút đóng
23
+ ✕ đẩy ra ngoài mép panel khi trigger hẹp. Redesign: panel `min-w-[15rem]`,
24
+ hàng nút gọn (h-7, px-1.5, nhóm phải `shrink-0`, nhóm trái `min-w-0`,
25
+ `overflow-hidden`), nút đóng đổi từ đỏ (dễ nhầm "xóa") → trung tính.
26
+
3
27
  ## 0.1.23 — Trang RBAC hoàn chỉnh trong core (roles/resources/actions), restyle trắng
4
28
 
5
29
  3 trang quản trị RBAC trước đây mỗi app tự chép (vinhhoa/wu-vpbank…) nay là trang
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.23",
4
+ "version": "0.1.25",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -1,7 +1,11 @@
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.
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 · · switch · 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/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.
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/icon bổ sung cho action nghiệp vụ riêng của app (merge đè mặc định). */
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 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
- );
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
- // 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
- );
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-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>
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="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"
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-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">
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 text-xs border border-border bg-card w-32 sm:w-48 shadow-none focus-visible:ring-1 focus-visible:ring-primary rounded-md"
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
- <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:
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 origin-left"
433
+ className="origin-left scale-75"
426
434
  />
427
- </div>
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
- <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">
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-9 rounded-[8px] flex-1 lg:flex-none"
457
+ className="h-8"
456
458
  >
457
- <X className="mr-2 h-4 w-4" />
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-9 rounded-[8px] flex-1 lg:flex-none"
465
+ className="h-8"
464
466
  >
465
- <Save className="mr-2 h-4 w-4" />
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
- {/* 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" />
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="pl-9 h-9 text-sm bg-card border border-border rounded-md"
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="w-[180px] h-9 text-sm bg-card border border-border rounded-md">
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
- 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" />
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 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
- 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"
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
- Chọn tất cả
578
- </Button>
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
- {!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"
588
- >
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ả
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
- <Switch
615
- checked={isResourceFullPermission(
616
- resource.resource,
617
- resource.actions,
618
- )}
619
- onCheckedChange={(checked) =>
620
- toggleAllForResource(
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
- onClick={() =>
662
- togglePermission(resource.resource, action.code)
610
+ onCheckedChange={(checked) =>
611
+ toggleAllForResource(
612
+ resource.resource,
613
+ resource.actions,
614
+ checked,
615
+ )
663
616
  }
664
- >
665
- <div
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 justify-center w-3.5 h-3.5 rounded-[4px] border transition-colors shrink-0",
647
+ "inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors",
668
648
  isChecked
669
- ? "bg-primary border-primary text-primary-foreground"
670
- : "bg-card border-border text-transparent",
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 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>
679
- <span
653
+ <Check
680
654
  className={cn(
681
- "text-xs font-semibold truncate",
682
- isChecked
683
- ? "text-primary"
684
- : "text-muted-foreground",
655
+ "h-3 w-3",
656
+ !isChecked && "opacity-0",
685
657
  )}
686
- >
687
- {config.label}
688
- </span>
689
- </div>
690
- </div>
691
- );
692
- })}
693
- </div>
694
- </div>
695
- ))}
696
- </div>
697
- )}
698
- </div>
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
  );
@@ -681,7 +681,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
681
681
  {open && (
682
682
  <div
683
683
  ref={dropdownRef}
684
- className="absolute z-[100] mt-1 w-full rounded-md border bg-popover/95 backdrop-blur-md text-popover-foreground shadow-lg shadow-black/5 animate-in fade-in-0 zoom-in-95 slide-in-from-top-1"
684
+ className="absolute z-[100] mt-1 w-full min-w-[15rem] rounded-md border bg-popover/95 backdrop-blur-md text-popover-foreground shadow-lg shadow-black/5 animate-in fade-in-0 zoom-in-95 slide-in-from-top-1"
685
685
  style={{
686
686
  top: "100%",
687
687
  left: 0,
@@ -746,14 +746,15 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
746
746
  )}
747
747
  </div>
748
748
 
749
- {/* Action Buttons */}
750
- <div className="flex items-center justify-between border-b p-1">
751
- <div className="flex items-center">
749
+ {/* Action Buttons — hàng gọn, không cho tràn: nhóm trái min-w-0,
750
+ nhóm phải shrink-0; nút đóng màu trung tính (không phải xóa) */}
751
+ <div className="flex items-center justify-between gap-1 overflow-hidden border-b p-1">
752
+ <div className="flex min-w-0 items-center">
752
753
  <Button
753
754
  type="button"
754
755
  variant="ghost"
755
756
  size="sm"
756
- className="h-8 px-2 text-xs"
757
+ className="h-7 px-1.5 text-xs"
757
758
  onClick={(e) => {
758
759
  e.stopPropagation();
759
760
  handleSelectAll();
@@ -765,7 +766,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
765
766
  type="button"
766
767
  variant="ghost"
767
768
  size="sm"
768
- className="h-8 px-2 text-xs"
769
+ className="h-7 px-1.5 text-xs"
769
770
  onClick={(e) => {
770
771
  e.stopPropagation();
771
772
  handleClearAll();
@@ -774,7 +775,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
774
775
  Bỏ chọn
775
776
  </Button>
776
777
  </div>
777
- <div className="flex items-center">
778
+ <div className="flex shrink-0 items-center">
778
779
  <span
779
780
  className="whitespace-nowrap px-1 text-[11px] tabular-nums text-muted-foreground"
780
781
  title={`Đã chọn ${value.length} / ${
@@ -790,13 +791,13 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
790
791
  size="sm"
791
792
  aria-label="Đóng"
792
793
  title="Đóng"
793
- className="h-8 w-8 shrink-0 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
794
+ className="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground"
794
795
  onClick={(e) => {
795
796
  e.stopPropagation();
796
797
  setOpen(false);
797
798
  }}
798
799
  >
799
- <X className="h-4 w-4" />
800
+ <X className="h-3.5 w-3.5" />
800
801
  </Button>
801
802
  </div>
802
803
  </div>
@@ -35,7 +35,7 @@ export function HorizontalLayout({
35
35
  searchResults={searchResults}
36
36
  searchLoading={searchLoading}
37
37
  />
38
- <div className="w-full min-h-screen flex flex-col bg-muted/40">
38
+ <div className="w-full min-h-screen flex flex-col bg-background">
39
39
  <HorizontalLayoutHeader
40
40
  dictionary={dictionary}
41
41
  navigation={navigation}
@@ -396,7 +396,9 @@ const SidebarInset = React.forwardRef<
396
396
  <main
397
397
  ref={ref}
398
398
  className={cn(
399
- "relative flex min-h-svh flex-1 flex-col bg-muted/40 transition-[margin] duration-200 ease-linear",
399
+ // Canvas app dùng token bg-background (app tự quyết tông — vinhhoa/wu
400
+ // muốn nền TRẮNG); trước đây bg-muted/40 phủ xám toàn bộ trang.
401
+ "relative flex min-h-svh flex-1 flex-col bg-background transition-[margin] duration-200 ease-linear",
400
402
  // Inset: content is a card floating with a uniform m-2 gap on ALL sides
401
403
  // (no ml-0 override) so there's a small gap between the sidebar and the
402
404
  // content/header instead of them touching.