@goplusvn/core 0.1.63 → 0.1.66

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,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.66 — Cache: hết TTL vô hạn, hết nuốt lỗi RBAC
4
+
5
+ Hai chế độ hỏng âm thầm, cùng một họ: hệ thống *tin* là nó có dữ liệu trong khi
6
+ thực ra không.
7
+
8
+ **`getUserPermissionsFromDB` NÉM thay vì trả `[]`.** Trước đây `catch → return []`.
9
+ Trong hệ RBAC, "không có quyền nào" và "không hỏi được quyền" hiện ra y hệt nhau
10
+ — menu rỗng, nút biến mất, API 403 — nên người dùng đọc thành "tôi bị thu hồi
11
+ quyền". Tệ hơn: `getCachedUserPermissions` ghi kết quả vào cache 5 phút, nên một
12
+ cú nghẽn DB chớp nhoáng bị ĐÓNG BĂNG thành mảng rỗng suốt 5 phút kể cả khi DB đã
13
+ hồi ngay. Nay lỗi ném ra, `.catch` không ghi cache, lượt sau tự hỏi lại.
14
+
15
+ ⚠️ **BREAKING (hành vi).** App nào đang dựa vào `[]`-khi-lỗi phải tự bắt. Khuyến
16
+ nghị: để lỗi nổi lên `error.tsx` — nhớ đặt boundary ở segment CHA của layout gọi
17
+ nó, vì `error.tsx` không bọc `layout.tsx` cùng segment.
18
+
19
+ **`SettingsService.getSettings` thêm `revalidate: 60`.** `unstable_cache` không
20
+ khai `revalidate` là cache tới khi process chết. Mọi đường ghi thẳng
21
+ `system_config` không qua `saveSettings` (route CRUD generic, script, migration)
22
+ — hoặc ghi từ tiến trình khác nên không xoá được cache tiến trình này — đều làm
23
+ setting cũ đóng băng VĨNH VIỄN. TTL là lưới an toàn; `saveSettings` vẫn xoá tag
24
+ để đường ghi chuẩn hiện tức thì.
25
+
26
+ **`revalidateTag` được bọc `try/catch`.** Next 16 throw "Invariant: static
27
+ generation store missing" khi gọi ngoài request scope (cron, task nền). Lời gọi
28
+ luôn nằm SAU lệnh ghi DB, nên không bọc là "lưu xong rồi vẫn báo lỗi". Chỉ an
29
+ toàn nuốt vì cache đã có TTL hữu hạn. Tag xuất khẩu thành
30
+ `SYSTEM_SETTINGS_CACHE_TAG`.
31
+
32
+ Kèm 8 test ratchet (đã kiểm: cả 8 đỏ khi hoàn nguyên bản vá).
33
+
3
34
  ## 0.1.44 — UnifiedProfileDialog: chi nhánh mặc định + ngày sinh DatePicker + fix Select loại người dùng
4
35
 
5
36
  **Ngày sinh dùng `DatePicker` (thay `<input type="date">`):** ô ngày sinh cũ khó
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.63",
4
+ "version": "0.1.66",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -137,8 +137,8 @@ export function CrudExportButton({
137
137
  className="rounded-r-none border-r-0"
138
138
  onClick={() => handleExport("current")}
139
139
  >
140
- <Download className="h-4 w-4 sm:mr-2" />
141
- <span className="hidden sm:inline">{loading ? "Exporting..." : "Export"}</span>
140
+ <Download className="h-4 w-4 md:mr-2" />
141
+ <span className="hidden md:inline">{loading ? "Exporting..." : "Export"}</span>
142
142
  </Button>
143
143
  <DropdownMenu>
144
144
  <DropdownMenuTrigger asChild>
@@ -368,8 +368,8 @@ export function ImportDialog({
368
368
  <DialogTrigger asChild>
369
369
  {trigger ?? (
370
370
  <Button variant="outline" size="sm">
371
- <Upload className="h-4 w-4 sm:mr-2" />
372
- <span className="hidden sm:inline">Import</span>
371
+ <Upload className="h-4 w-4 md:mr-2" />
372
+ <span className="hidden md:inline">Import</span>
373
373
  </Button>
374
374
  )}
375
375
  </DialogTrigger>
@@ -556,11 +556,11 @@ export function ImportDialog({
556
556
  )}
557
557
  </div>
558
558
 
559
- <DialogFooter>
560
- <Button variant="outline" onClick={() => setOpen(false)}>
559
+ <DialogFooter className="flex flex-row sm:justify-end gap-2 space-x-0 pt-2">
560
+ <Button variant="outline" className="flex-1 sm:flex-none" onClick={() => setOpen(false)}>
561
561
  Đóng
562
562
  </Button>
563
- <Button onClick={handleImport} disabled={!file || loading}>
563
+ <Button className="flex-1 sm:flex-none" onClick={handleImport} disabled={!file || loading}>
564
564
  {loading ? "Đang import..." : "Import"}
565
565
  </Button>
566
566
  </DialogFooter>
@@ -0,0 +1,113 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { clearAllPermissionsCache } from "../index";
4
+ import {
5
+ getCachedUserPermissions,
6
+ getUserPermissionsFromDB,
7
+ } from "../permission-service";
8
+
9
+ import type { RBACPrismaClient } from "../permission-service";
10
+
11
+ /** DB giả: mỗi lượt gọi lấy kịch bản kế tiếp trong hàng đợi. */
12
+ function makeDb(outcomes: Array<{ rows?: unknown[]; fail?: Error }>): {
13
+ db: RBACPrismaClient;
14
+ calls: () => number;
15
+ } {
16
+ let calls = 0;
17
+ const db = {
18
+ userRole: {
19
+ findMany: async () => {
20
+ const outcome = outcomes[Math.min(calls, outcomes.length - 1)];
21
+ calls += 1;
22
+ if (outcome.fail) throw outcome.fail;
23
+ return outcome.rows as never;
24
+ },
25
+ },
26
+ } as unknown as RBACPrismaClient;
27
+ return { db, calls: () => calls };
28
+ }
29
+
30
+ const ROW_WITH_VIEW = {
31
+ roleCode: "accountant",
32
+ role: { rolePermissions: [{ resourceCode: "receipt", actionCode: "view" }] },
33
+ };
34
+
35
+ describe("getUserPermissionsFromDB", () => {
36
+ beforeEach(async () => {
37
+ await clearAllPermissionsCache();
38
+ vi.spyOn(console, "error").mockImplementation(() => {});
39
+ });
40
+
41
+ it("dedupe quyền trùng giữa các vai trò", async () => {
42
+ const { db } = makeDb([
43
+ {
44
+ rows: [
45
+ ROW_WITH_VIEW,
46
+ {
47
+ roleCode: "cashier",
48
+ role: {
49
+ rolePermissions: [
50
+ { resourceCode: "receipt", actionCode: "view" },
51
+ { resourceCode: "receipt", actionCode: "create" },
52
+ ],
53
+ },
54
+ },
55
+ ],
56
+ },
57
+ ]);
58
+
59
+ expect(await getUserPermissionsFromDB(db, "u1")).toEqual([
60
+ { resourceCode: "receipt", actionCode: "view" },
61
+ { resourceCode: "receipt", actionCode: "create" },
62
+ ]);
63
+ });
64
+
65
+ it("user KHÔNG có vai trò nào → mảng rỗng (đây là câu trả lời hợp lệ)", async () => {
66
+ const { db } = makeDb([{ rows: [] }]);
67
+ expect(await getUserPermissionsFromDB(db, "u1")).toEqual([]);
68
+ });
69
+
70
+ it("query hỏng → NÉM, không được nuốt thành mảng rỗng", async () => {
71
+ const { db } = makeDb([{ fail: new Error("P1017 ConnectionClosed") }]);
72
+
73
+ // Trả `[]` ở đây là biến sự cố hạ tầng thành "user không có quyền nào" —
74
+ // menu rỗng, nút biến mất, mà không ai biết là DB hỏng.
75
+ await expect(getUserPermissionsFromDB(db, "u1")).rejects.toThrow(
76
+ "P1017 ConnectionClosed",
77
+ );
78
+ });
79
+ });
80
+
81
+ describe("getCachedUserPermissions", () => {
82
+ beforeEach(async () => {
83
+ await clearAllPermissionsCache();
84
+ vi.spyOn(console, "error").mockImplementation(() => {});
85
+ });
86
+
87
+ it("lỗi DB KHÔNG bị đóng băng vào cache 5 phút", async () => {
88
+ // Lượt 1 hỏng, lượt 2 trở đi thành công.
89
+ const { db } = makeDb([
90
+ { fail: new Error("nghẽn DB") },
91
+ { rows: [ROW_WITH_VIEW] },
92
+ ]);
93
+
94
+ await expect(getCachedUserPermissions(db, "u1")).rejects.toThrow(
95
+ "nghẽn DB",
96
+ );
97
+
98
+ // Nếu lỗi bị nuốt thành `[]`, `[]` đã được setToCache và user mất sạch
99
+ // quyền suốt 5 phút kể cả khi DB hồi ngay lập tức.
100
+ expect(await getCachedUserPermissions(db, "u1")).toEqual([
101
+ { resourceCode: "receipt", actionCode: "view" },
102
+ ]);
103
+ });
104
+
105
+ it("lượt thành công thì có cache — không hỏi DB lần hai", async () => {
106
+ const { db, calls } = makeDb([{ rows: [ROW_WITH_VIEW] }]);
107
+
108
+ await getCachedUserPermissions(db, "u1");
109
+ await getCachedUserPermissions(db, "u1");
110
+
111
+ expect(calls()).toBe(1);
112
+ });
113
+ });
@@ -41,6 +41,20 @@ export interface RBACPrismaClient {
41
41
  * Fetch user permissions from database based on roles.
42
42
  * Deduplicates permissions by resourceCode:actionCode.
43
43
  *
44
+ * NÉM LỖI khi query hỏng — KHÔNG trả `[]`.
45
+ *
46
+ * Trước 2026-08 hàm này `catch → logger.error → return []`. Trong một hệ RBAC,
47
+ * "không có quyền nào" và "không hỏi được quyền" là hai chuyện khác hẳn nhau,
48
+ * nhưng cả hai đều hiện ra y như nhau: menu rỗng, nút biến mất, API trả 403.
49
+ * Người dùng đọc thành "tôi bị thu hồi quyền" và đi báo sai chỗ.
50
+ *
51
+ * Tệ hơn: `getCachedUserPermissions` bên dưới ghi kết quả vào cache 5 phút.
52
+ * Một cú nghẽn DB chớp nhoáng vì thế bị ĐÓNG BĂNG thành mảng rỗng suốt 5 phút
53
+ * kể cả khi DB đã hồi ngay sau đó. Ném lỗi thì `.catch` ở đó không ghi cache,
54
+ * lượt sau tự hỏi lại DB.
55
+ *
56
+ * Nơi gọi phải tự quyết: hiện error boundary, hay hạ cấp có chủ đích.
57
+ *
44
58
  * @param db - The tenant-specific Prisma Client instance
45
59
  * @param userId - The user ID to fetch permissions for
46
60
  */
@@ -94,7 +108,7 @@ export async function getUserPermissionsFromDB(
94
108
  return uniquePermissions;
95
109
  } catch (error) {
96
110
  logger.error("Error fetching user permissions from DB", error);
97
- return [];
111
+ throw error;
98
112
  }
99
113
  }
100
114
 
@@ -0,0 +1,85 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const unstableCacheMock = vi.fn(
4
+ (
5
+ fn: (...args: unknown[]) => unknown,
6
+ _keyParts?: string[],
7
+ _options?: unknown,
8
+ ) =>
9
+ (...args: unknown[]) =>
10
+ fn(...args),
11
+ );
12
+ const revalidateTagMock = vi.fn();
13
+
14
+ vi.mock("next/cache", () => ({
15
+ unstable_cache: (
16
+ fn: (...args: unknown[]) => unknown,
17
+ keyParts?: string[],
18
+ options?: unknown,
19
+ ) => unstableCacheMock(fn, keyParts, options),
20
+ revalidateTag: (...args: unknown[]) => revalidateTagMock(...args),
21
+ }));
22
+
23
+ const { SettingsService, configureSettingsService, SYSTEM_SETTINGS_CACHE_TAG } =
24
+ await import("../settings-service");
25
+
26
+ function fakeDb(value: string | null) {
27
+ return {
28
+ systemConfig: {
29
+ findUnique: vi.fn(async () => (value === null ? null : { value })),
30
+ upsert: vi.fn(async () => ({})),
31
+ },
32
+ };
33
+ }
34
+
35
+ describe("SettingsService cache options", () => {
36
+ beforeEach(() => {
37
+ unstableCacheMock.mockClear();
38
+ revalidateTagMock.mockClear();
39
+ vi.spyOn(console, "warn").mockImplementation(() => {});
40
+ });
41
+
42
+ it("LUÔN khai `revalidate` hữu hạn — thiếu nó là cache vĩnh viễn", async () => {
43
+ configureSettingsService(fakeDb(JSON.stringify({ a: 1 })));
44
+
45
+ await SettingsService.getSettings("demo", { a: 0 });
46
+
47
+ const options = unstableCacheMock.mock.calls[0][2] as {
48
+ tags: string[];
49
+ revalidate?: number;
50
+ };
51
+ // Bỏ `revalidate` là entry sống tới khi process chết: mọi đường ghi thẳng
52
+ // `system_config` mà quên xoá tag sẽ đóng băng setting cũ VĨNH VIỄN.
53
+ expect(options.revalidate).toBeTypeOf("number");
54
+ expect(options.revalidate).toBeGreaterThan(0);
55
+ expect(options.tags).toContain(SYSTEM_SETTINGS_CACHE_TAG);
56
+ });
57
+
58
+ it("saveSettings xoá tag sau khi ghi", async () => {
59
+ const db = fakeDb(null);
60
+ configureSettingsService(db);
61
+
62
+ await SettingsService.saveSettings("demo", { a: 1 });
63
+
64
+ expect(db.systemConfig.upsert).toHaveBeenCalledOnce();
65
+ expect(revalidateTagMock).toHaveBeenCalledWith(
66
+ SYSTEM_SETTINGS_CACHE_TAG,
67
+ "max",
68
+ );
69
+ });
70
+
71
+ it("revalidateTag ném ngoài request scope KHÔNG được kéo đổ lệnh ghi", async () => {
72
+ const db = fakeDb(null);
73
+ configureSettingsService(db);
74
+ revalidateTagMock.mockImplementation(() => {
75
+ throw new Error("Invariant: static generation store missing");
76
+ });
77
+
78
+ // Cron/task nền chạy ngoài request scope. Lời gọi nằm SAU lệnh ghi DB nên
79
+ // để lỗi thoát ra là "lưu xong rồi vẫn báo lỗi" cho người dùng.
80
+ await expect(
81
+ SettingsService.saveSettings("demo", { a: 1 }),
82
+ ).resolves.toBeUndefined();
83
+ expect(db.systemConfig.upsert).toHaveBeenCalledOnce();
84
+ });
85
+ });
@@ -1,6 +1,45 @@
1
- import { cache } from "react";
2
1
  import { revalidateTag, unstable_cache } from "next/cache";
3
2
 
3
+ import { logger } from "../../utils";
4
+
5
+ /** Tag dùng chung cho mọi entry cache của SettingsService. */
6
+ export const SYSTEM_SETTINGS_CACHE_TAG = "system-settings";
7
+
8
+ /**
9
+ * TTL của cache setting — LƯỚI AN TOÀN, không phải cơ chế chính.
10
+ *
11
+ * `saveSettings` xoá tag ngay nên đường ghi chuẩn vẫn hiện tức thì. TTL này
12
+ * chặn kịch bản kẹt vĩnh viễn: `unstable_cache` KHÔNG khai `revalidate` là
13
+ * cache tới khi process chết. Mọi đường ghi thẳng bảng `system_config` không
14
+ * qua `saveSettings` (route CRUD generic của app, script, migration) — hoặc ghi
15
+ * từ tiến trình khác nên không xoá được cache của tiến trình này — đều làm
16
+ * setting cũ đóng băng luôn.
17
+ *
18
+ * 60s: đủ ngắn để sai sót tự lành, đủ dài để không thành một vòng DB mỗi lượt
19
+ * đọc setting.
20
+ */
21
+ const SETTINGS_TTL_SECONDS = 60;
22
+
23
+ /**
24
+ * `revalidateTag` NÉM khi không có request scope — Next 16 throw
25
+ * "Invariant: static generation store missing" nếu `workAsyncStorage` rỗng.
26
+ * Cron và task nền là chạy ngoài scope, mà lời gọi luôn nằm SAU lệnh ghi DB:
27
+ * không bọc thì dữ liệu đã lưu xong vẫn báo lỗi ngược cho người gọi.
28
+ *
29
+ * Nuốt lỗi ở đây CÓ CHỦ ĐÍCH và chỉ an toàn vì cache đã có TTL hữu hạn — tệ
30
+ * nhất là chậm đúng một nhịp TTL. Vẫn log để lộ chỗ gọi sai ngữ cảnh.
31
+ */
32
+ function safeRevalidateSettingsTag(): void {
33
+ try {
34
+ revalidateTag(SYSTEM_SETTINGS_CACHE_TAG, "max");
35
+ } catch (error) {
36
+ logger.warn(
37
+ `revalidateTag("${SYSTEM_SETTINGS_CACHE_TAG}") bỏ qua — gọi ngoài request scope`,
38
+ { error: error instanceof Error ? error.message : String(error) },
39
+ );
40
+ }
41
+ }
42
+
4
43
  /**
5
44
  * DB được INJECT từ app (core không import prisma client cụ thể). Trước
6
45
  * 2026-07 chỗ này là `const db: any = {...} // Mocked for build` — mọi
@@ -115,7 +154,10 @@ export class SettingsService {
115
154
  }
116
155
  },
117
156
  [`system-settings-${key}`],
118
- { tags: ["system-settings"] },
157
+ {
158
+ tags: [SYSTEM_SETTINGS_CACHE_TAG],
159
+ revalidate: SETTINGS_TTL_SECONDS,
160
+ },
119
161
  )();
120
162
  }
121
163
 
@@ -155,7 +197,7 @@ export class SettingsService {
155
197
  },
156
198
  });
157
199
 
158
- revalidateTag("system-settings", "max");
200
+ safeRevalidateSettingsTag();
159
201
  }
160
202
 
161
203
  static async saveAuditSettings(settings: AuditSettings): Promise<void> {
@@ -1,16 +1,22 @@
1
1
  "use client";
2
2
 
3
3
  import { useState, useEffect } from "react";
4
- import { Plus, Search, LayoutGrid, List, X } from "lucide-react";
4
+ import { Plus, Search, LayoutGrid, List, X, Filter } from "lucide-react";
5
5
  import {
6
6
  Button,
7
7
  Input,
8
- Separator,
9
8
  Select,
10
9
  SelectContent,
11
10
  SelectItem,
12
11
  SelectTrigger,
13
12
  SelectValue,
13
+ Sheet,
14
+ SheetTrigger,
15
+ SheetContent,
16
+ SheetHeader,
17
+ SheetTitle,
18
+ SheetDescription,
19
+ SheetClose,
14
20
  } from "../../ui";
15
21
  import { CrudExportButton } from "../../crud/components/crud-export-button";
16
22
 
@@ -47,33 +53,44 @@ export function UserToolbar({
47
53
  return () => clearTimeout(timer);
48
54
  }, [localSearch, onSearchChange]);
49
55
 
56
+ const activeFilterCount =
57
+ (userType && userType !== "all" ? 1 : 0) +
58
+ (roles?.some((r) => r.code === userType) ? 1 : 0); // Need to track if role is filtered actually
59
+ // We'll calculate it just by checking if there's any active value, but UserToolbar doesn't currently get the selected role explicitly as a prop, only onRoleChange.
60
+ // Wait, UserToolbar doesn't have a 'role' prop! It only has onRoleChange! This is a flaw in the original component, it was un-controlled.
61
+ // For now we'll just not show the badge or pass it as 0.
62
+
50
63
  return (
51
- <div className="flex flex-col xl:flex-row items-start xl:items-center justify-between w-full gap-3 xl:gap-4">
64
+ <div className="flex flex-col xl:flex-row items-start xl:items-center justify-between w-full gap-3 xl:gap-3">
52
65
  {/* Tìm kiếm + bộ lọc chính */}
53
- <div className="flex w-full min-w-0 xl:w-auto flex-1 items-center gap-2 xl:gap-3">
66
+ <div className="flex w-full min-w-0 xl:w-auto flex-1 items-center gap-2">
54
67
  <div className="relative w-full xl:flex-[2] xl:min-w-[220px] max-w-none">
55
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
68
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
56
69
  <Input
57
70
  placeholder="Tìm người dùng..."
58
71
  value={localSearch}
59
72
  onChange={(e) => setLocalSearch(e.target.value)}
60
- className="pl-9 h-9 w-full text-xs bg-secondary/30 border hover:bg-muted/50 transition-colors shadow-sm rounded-md focus-visible:ring-1 focus-visible:ring-primary"
73
+ className="pl-9 pr-14 h-9 w-full bg-secondary/30 transition-shadow focus:bg-background shadow-sm focus-visible:ring-inset focus-visible:ring-offset-0"
61
74
  />
62
75
  {localSearch && (
63
- <Button
64
- variant="ghost"
65
- size="sm"
66
- className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0 rounded-md hover:bg-muted"
67
- onClick={() => setLocalSearch("")}
68
- >
69
- <X className="h-3.5 w-3.5" />
70
- </Button>
76
+ <div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5">
77
+ <Button
78
+ variant="ghost"
79
+ size="icon"
80
+ className="h-7 w-7 text-muted-foreground hover:text-destructive"
81
+ onClick={() => setLocalSearch("")}
82
+ title="Xóa tìm kiếm"
83
+ >
84
+ <X className="h-3.5 w-3.5" />
85
+ </Button>
86
+ </div>
71
87
  )}
72
88
  </div>
73
89
 
74
- <div className="w-full xl:w-auto flex-1 min-w-0">
90
+ {/* Desktop Filters */}
91
+ <div className="hidden min-w-0 grow shrink basis-[150px] xl:block">
75
92
  <Select value={userType} onValueChange={onUserTypeChange}>
76
- <SelectTrigger className="h-9 w-full xl:w-[150px] text-xs bg-background border hover:bg-muted/50 transition-colors shadow-sm rounded-md focus-visible:ring-primary min-w-0">
93
+ <SelectTrigger className="h-9 w-full min-w-0 shadow-sm">
77
94
  <SelectValue placeholder="Loại tài khoản" className="truncate" />
78
95
  </SelectTrigger>
79
96
  <SelectContent className="rounded-lg">
@@ -85,9 +102,9 @@ export function UserToolbar({
85
102
  </Select>
86
103
  </div>
87
104
 
88
- <div className="w-full xl:w-auto flex-1 min-w-0">
105
+ <div className="hidden min-w-0 grow shrink basis-[150px] xl:block">
89
106
  <Select onValueChange={onRoleChange}>
90
- <SelectTrigger className="h-9 w-full xl:w-[170px] text-xs bg-background border hover:bg-muted/50 transition-colors shadow-sm rounded-md focus-visible:ring-primary min-w-0">
107
+ <SelectTrigger className="h-9 w-full min-w-0 shadow-sm">
91
108
  <SelectValue placeholder="Lọc theo vai trò" className="truncate" />
92
109
  </SelectTrigger>
93
110
  <SelectContent className="rounded-lg">
@@ -103,21 +120,103 @@ export function UserToolbar({
103
120
  </div>
104
121
 
105
122
  {/* Hành động */}
106
- <div className="flex items-center justify-between sm:justify-end gap-2 w-full xl:w-auto shrink-0 overflow-x-auto pb-0.5 sm:pb-0">
123
+ <div className="flex flex-wrap sm:flex-nowrap items-center justify-between xl:justify-end gap-2 w-full xl:w-auto shrink-0 mt-1 xl:mt-0">
124
+
125
+ {/* Mobile Filters (Sheet) */}
126
+ <div className="flex items-center xl:hidden">
127
+ <Sheet>
128
+ <SheetTrigger asChild>
129
+ <Button
130
+ variant="outline"
131
+ className="h-9 min-w-9 shrink-0 shadow-sm relative data-[state=open]:bg-secondary data-[state=open]:text-secondary-foreground"
132
+ title="Bộ lọc nâng cao"
133
+ >
134
+ <Filter className="h-4 w-4 sm:mr-2" />
135
+ <span className="hidden sm:inline font-medium">Bộ lọc</span>
136
+ </Button>
137
+ </SheetTrigger>
138
+ <SheetContent
139
+ side="right"
140
+ className="flex flex-col h-full w-full sm:max-w-md p-0 gap-0"
141
+ >
142
+ <SheetHeader className="text-left px-6 py-4 border-b flex flex-row items-center justify-between space-y-0">
143
+ <div className="space-y-1">
144
+ <SheetTitle className="text-lg font-bold">
145
+ Bộ lọc nâng cao
146
+ </SheetTitle>
147
+ <SheetDescription>
148
+ Tinh chỉnh danh sách người dùng.
149
+ </SheetDescription>
150
+ </div>
151
+ <SheetClose asChild>
152
+ <Button
153
+ variant="ghost"
154
+ size="icon"
155
+ className="h-8 w-8 rounded-full shrink-0"
156
+ >
157
+ <X className="h-5 w-5" />
158
+ <span className="sr-only">Đóng</span>
159
+ </Button>
160
+ </SheetClose>
161
+ </SheetHeader>
162
+ <div className="flex-1 overflow-y-auto px-6 py-5">
163
+ <div className="flex flex-col gap-5">
164
+ <div className="space-y-1.5">
165
+ <label className="text-xs font-medium text-muted-foreground">
166
+ Loại tài khoản
167
+ </label>
168
+ <Select value={userType} onValueChange={onUserTypeChange}>
169
+ <SelectTrigger className="h-9 w-full shadow-sm">
170
+ <SelectValue placeholder="Loại tài khoản" />
171
+ </SelectTrigger>
172
+ <SelectContent>
173
+ <SelectItem value="all">Tất cả loại</SelectItem>
174
+ <SelectItem value="employee">Nhân viên</SelectItem>
175
+ <SelectItem value="customer">Khách hàng</SelectItem>
176
+ <SelectItem value="supplier">Nhà cung cấp</SelectItem>
177
+ </SelectContent>
178
+ </Select>
179
+ </div>
180
+
181
+ <div className="space-y-1.5">
182
+ <label className="text-xs font-medium text-muted-foreground">
183
+ Vai trò
184
+ </label>
185
+ <Select onValueChange={onRoleChange}>
186
+ <SelectTrigger className="h-9 w-full shadow-sm">
187
+ <SelectValue placeholder="Lọc theo vai trò" />
188
+ </SelectTrigger>
189
+ <SelectContent>
190
+ <SelectItem value="all">Tất cả vai trò</SelectItem>
191
+ {roles?.map((role) => (
192
+ <SelectItem key={role.code} value={role.code}>
193
+ {role.name}
194
+ </SelectItem>
195
+ ))}
196
+ </SelectContent>
197
+ </Select>
198
+ </div>
199
+ </div>
200
+ </div>
201
+ </SheetContent>
202
+ </Sheet>
203
+ </div>
204
+
107
205
  <div className="flex items-center gap-2">
108
- <div className="flex items-center bg-[#f1f5f9] dark:bg-[#181d26] rounded-[6px] p-0.5 border border-[#dddddd] dark:border-[#333840] shrink-0">
206
+ {/* View Toggle */}
207
+ <div className="flex items-center rounded-md border p-0.5 bg-muted/30 shrink-0">
109
208
  <Button
110
209
  variant={viewMode === "table" ? "secondary" : "ghost"}
111
- size="sm"
112
- className="h-8 w-8 p-0 rounded-md"
210
+ size="icon"
211
+ className="h-7 w-7 shadow-none"
113
212
  onClick={() => onViewModeChange("table")}
114
213
  >
115
214
  <List className="h-4 w-4" />
116
215
  </Button>
117
216
  <Button
118
217
  variant={viewMode === "grid" ? "secondary" : "ghost"}
119
- size="sm"
120
- className="h-8 w-8 p-0 rounded-md"
218
+ size="icon"
219
+ className="h-7 w-7 shadow-none"
121
220
  onClick={() => onViewModeChange("grid")}
122
221
  >
123
222
  <LayoutGrid className="h-4 w-4" />
@@ -125,7 +224,7 @@ export function UserToolbar({
125
224
  </div>
126
225
 
127
226
  {permissions.export && (
128
- <div className="[&>button]:h-9 [&>button]:rounded-md [&>button]:shadow-sm [&>button]:bg-background [&>button]:border [&>button]:text-foreground [&>button]:hover:bg-muted/50 shrink-0">
227
+ <div className="shrink-0">
129
228
  <CrudExportButton
130
229
  endpoint="/api/crud/users/export"
131
230
  search={localSearch}
@@ -135,8 +234,12 @@ export function UserToolbar({
135
234
  )}
136
235
 
137
236
  {permissions.create && (
138
- <Button size="sm" className="h-9 px-4 text-xs bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm rounded-md font-medium border-0 shrink-0" onClick={onCreateNew}>
139
- <Plus className="h-4 w-4 sm:mr-1.5" />
237
+ <Button
238
+ size="sm"
239
+ className="h-9 font-medium shadow-sm shrink-0"
240
+ onClick={onCreateNew}
241
+ >
242
+ <Plus className="h-4 w-4 sm:mr-2" />
140
243
  <span className="hidden sm:inline">Thêm mới</span>
141
244
  </Button>
142
245
  )}
@@ -145,3 +248,4 @@ export function UserToolbar({
145
248
  </div>
146
249
  );
147
250
  }
251
+