@goplusvn/core 0.1.70 → 0.1.72

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.
Files changed (175) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/bin/goerp-init.mjs +15 -0
  3. package/package.json +2 -1
  4. package/src/auth/index.ts +5 -1
  5. package/src/auth/proxy-gate.ts +21 -2
  6. package/src/configs/entities/departments.config.ts +1 -0
  7. package/src/configs/entities/material-categories.config.ts +1 -0
  8. package/src/cron/__tests__/cron-schedule.test.ts +76 -0
  9. package/src/cron/cron-schedule.ts +128 -0
  10. package/src/cron/simple-cron-job.ts +83 -69
  11. package/src/crud/components/crud-export-button.tsx +3 -2
  12. package/src/crud/components/crud-page.tsx +56 -25
  13. package/src/crud/components/crud-row-actions.tsx +16 -4
  14. package/src/crud/components/crud-table.tsx +13 -1
  15. package/src/crud/crud-route-handlers.test.ts +352 -0
  16. package/src/crud/crud-route-handlers.ts +260 -13
  17. package/src/crud/index.ts +3 -0
  18. package/src/crud/lib/coerce.test.ts +118 -0
  19. package/src/crud/lib/coerce.ts +95 -0
  20. package/src/crud/lib/crud-utils.ts +4 -0
  21. package/src/crud/lib/entity-endpoints.test.ts +41 -0
  22. package/src/crud/lib/entity-endpoints.ts +23 -0
  23. package/src/crud/lib/errors.ts +14 -0
  24. package/src/crud/lib/import-request.ts +99 -0
  25. package/src/crud/lib/mutation-builder.test.ts +114 -0
  26. package/src/crud/lib/mutation-builder.ts +27 -34
  27. package/src/crud/lib/permissions.test.ts +57 -0
  28. package/src/crud/lib/permissions.ts +25 -13
  29. package/src/crud/lib/query-builder.ts +13 -6
  30. package/src/crud/lib/translate-config.ts +16 -2
  31. package/src/crud/pages/entity-crud-page.tsx +11 -8
  32. package/src/crud/server-service.test.ts +112 -0
  33. package/src/crud/server-service.ts +52 -27
  34. package/src/crud/server.ts +11 -1
  35. package/src/guardrails/index.ts +1 -0
  36. package/src/guardrails/rules/auth.ts +54 -1
  37. package/src/guardrails/rules/design.ts +3 -2
  38. package/src/guardrails/types.ts +17 -8
  39. package/src/providers/brand-theme.ts +20 -0
  40. package/src/rbac/pages/permission-catalog-pages.tsx +3 -1
  41. package/src/security/index.ts +2 -0
  42. package/src/security/pages/sessions-page.tsx +516 -0
  43. package/src/types/index.ts +75 -10
  44. package/src/ui/auth/sign-in-form.tsx +64 -9
  45. package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
  46. package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
  47. package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
  48. package/src/ui/data-display/data-table/data-table.tsx +68 -23
  49. package/src/ui/data-display/data-table-pagination.tsx +25 -8
  50. package/src/ui/data-display/index.tsx +1 -0
  51. package/src/ui/data-display/use-client-pagination.ts +48 -0
  52. package/src/ui/errors/error-view.tsx +164 -0
  53. package/src/ui/errors/index.ts +2 -0
  54. package/src/ui/errors/not-found-view.tsx +44 -0
  55. package/src/ui/index.tsx +1 -0
  56. package/src/ui/layout/logo.tsx +54 -19
  57. package/src/user/pages/users-client-page.tsx +12 -7
  58. package/templates/starter-app/.env.example +4 -0
  59. package/templates/starter-app/AGENTS.md +1 -1
  60. package/templates/starter-app/README.md +6 -2
  61. package/templates/starter-app/husky/pre-commit +10 -0
  62. package/templates/starter-app/package.json +27 -2
  63. package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
  64. package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
  65. package/templates/starter-app/prisma/schema/organization.prisma +20 -0
  66. package/templates/starter-app/prisma/schema/system.prisma +74 -0
  67. package/templates/starter-app/prisma/seed.ts +7 -5
  68. package/templates/starter-app/scripts/migration-new.mjs +89 -0
  69. package/templates/starter-app/scripts/rbac-sync.ts +49 -21
  70. package/templates/starter-app/src/__tests__/architecture.test.ts +11 -2
  71. package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +37 -0
  72. package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +68 -0
  73. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +33 -0
  74. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +195 -0
  75. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +29 -0
  76. package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
  77. package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
  78. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
  79. package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
  80. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +13 -3
  81. package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
  82. package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
  83. package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
  84. package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
  85. package/templates/starter-app/src/app/[lang]/(main)/security/sessions/page.tsx +24 -0
  86. package/templates/starter-app/src/app/[lang]/(main)/system-categories/page.tsx +30 -0
  87. package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +62 -0
  88. package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +41 -0
  89. package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +157 -0
  90. package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +69 -0
  91. package/templates/starter-app/src/app/[lang]/[...not-found]/page.tsx +9 -0
  92. package/templates/starter-app/src/app/[lang]/error.tsx +26 -0
  93. package/templates/starter-app/src/app/[lang]/layout.tsx +2 -2
  94. package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
  95. package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
  96. package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
  97. package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
  98. package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
  99. package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
  100. package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
  101. package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
  102. package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
  103. package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
  104. package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
  105. package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
  106. package/templates/starter-app/src/app/api/branches/route.ts +26 -0
  107. package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
  108. package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
  109. package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
  110. package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
  111. package/templates/starter-app/src/app/api/departments/route.ts +26 -0
  112. package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
  113. package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
  114. package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
  115. package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
  116. package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
  117. package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
  118. package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
  119. package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
  120. package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
  121. package/templates/starter-app/src/app/api/roles/route.ts +2 -1
  122. package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +52 -0
  123. package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +44 -0
  124. package/templates/starter-app/src/app/api/security/sessions/route.ts +101 -0
  125. package/templates/starter-app/src/app/api/suppliers/route.ts +13 -0
  126. package/templates/starter-app/src/app/api/system-categories/route.ts +179 -0
  127. package/templates/starter-app/src/app/api/system-category-groups/route.ts +149 -0
  128. package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
  129. package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
  130. package/templates/starter-app/src/app/api/upload/route.ts +1 -2
  131. package/templates/starter-app/src/app/api/users/[id]/route.ts +230 -0
  132. package/templates/starter-app/src/app/api/users/route.ts +141 -0
  133. package/templates/starter-app/src/app/global-error.tsx +135 -0
  134. package/templates/starter-app/src/app/globals.css +0 -1
  135. package/templates/starter-app/src/app/icon.svg +6 -0
  136. package/templates/starter-app/src/app/manifest.ts +26 -0
  137. package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
  138. package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
  139. package/templates/starter-app/src/configs/entities/index.ts +5 -1
  140. package/templates/starter-app/src/configs/entities/job-titles.config.ts +121 -0
  141. package/templates/starter-app/src/configs/entities/system-alerts.config.ts +93 -0
  142. package/templates/starter-app/src/configs/entities/users.config.ts +80 -0
  143. package/templates/starter-app/src/configs/i18n.ts +6 -6
  144. package/templates/starter-app/src/configs/permissions/index.ts +37 -0
  145. package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +11 -0
  146. package/templates/starter-app/src/configs/permissions/menu-tree.ts +37 -0
  147. package/templates/starter-app/src/configs/permissions/system.permissions.ts +29 -0
  148. package/templates/starter-app/src/configs/tenant.ts +2 -1
  149. package/templates/starter-app/src/data/dictionary.ts +77 -4
  150. package/templates/starter-app/src/data/navigations.ts +60 -1
  151. package/templates/starter-app/src/instrumentation.ts +5 -16
  152. package/templates/starter-app/src/lib/action-guard.ts +25 -0
  153. package/templates/starter-app/src/lib/api-handler.ts +27 -19
  154. package/templates/starter-app/src/lib/auth.ts +3 -1
  155. package/templates/starter-app/src/lib/better-auth.ts +8 -0
  156. package/templates/starter-app/src/lib/branch-scope.ts +1 -1
  157. package/templates/starter-app/src/lib/crud/index.ts +5 -10
  158. package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
  159. package/templates/starter-app/src/lib/logger.ts +11 -4
  160. package/templates/starter-app/src/lib/page-guard.ts +2 -3
  161. package/templates/starter-app/src/lib/prisma.ts +16 -7
  162. package/templates/starter-app/src/lib/rbac/access.ts +2 -2
  163. package/templates/starter-app/src/lib/storage.ts +3 -1
  164. package/templates/starter-app/src/providers/index.tsx +2 -1
  165. package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
  166. package/templates/starter-app/src/providers/theme-provider.tsx +27 -12
  167. package/templates/starter-app/src/proxy.ts +8 -3
  168. package/templates/starter-app/src/server/services/company-service.ts +49 -0
  169. package/templates/starter-app/src/server/services/notification-service.ts +6 -6
  170. package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
  171. package/templates/starter-app/src/server/services/user-service.ts +104 -0
  172. package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
  173. package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
  174. package/templates/starter-app/tsconfig.json +10 -0
  175. package/templates/starter-app/vitest.config.ts +17 -1
@@ -8,12 +8,11 @@ import { zodResolver } from "@hookform/resolvers/zod";
8
8
  import { useAuthBridge } from "./auth-bridge";
9
9
  import { useForm } from "react-hook-form";
10
10
  import { Eye, EyeOff } from "lucide-react";
11
- import type { z } from "zod"; // Add this import
12
11
 
13
12
  import type { LocaleType } from "../../types";
14
13
  import { toast } from "sonner";
14
+ import { z } from "zod";
15
15
  import { ensureLocalizedPathname, ensureRedirectPathname } from "../../utils";
16
- import { SignInSchema } from "../../schemas";
17
16
  import { useTabContentCache } from "../layout/tab-content-cache";
18
17
  import { useTabNavigation } from "../layout/tab-navigation-provider";
19
18
 
@@ -34,9 +33,64 @@ import {
34
33
  } from "../index";
35
34
  import { OAuthLinks } from "./oauth-links";
36
35
 
37
- type SignInFormType = z.infer<typeof SignInSchema>;
36
+ /**
37
+ * Ô định danh nhận gì:
38
+ * - "email" (mặc định, giữ hành vi cũ): validate + input type email.
39
+ * - "username": tên đăng nhập/mã nhân viên — text tự do.
40
+ * - "both": một ô cho cả hai; bridge của app tự phân loại (thường theo "@").
41
+ *
42
+ * Mật khẩu ở form ĐĂNG NHẬP chỉ đòi khác rỗng — luật độ phức tạp là chuyện lúc
43
+ * ĐẶT mật khẩu; siết ở đây sẽ chặn nhầm mật khẩu cũ hợp lệ import từ hệ khác.
44
+ */
45
+ export interface SignInFormProps {
46
+ identifier?: "email" | "username" | "both";
47
+ /** Ghi đè nhãn ô định danh (vd "Email hoặc mã nhân viên"). */
48
+ identifierLabel?: string;
49
+ identifierPlaceholder?: string;
50
+ }
51
+
52
+ const IDENTIFIER_PRESETS = {
53
+ email: {
54
+ label: "Email",
55
+ placeholder: "name@example.com",
56
+ inputType: "email" as const,
57
+ schema: z.string().trim().toLowerCase().email({ message: "Email không hợp lệ" }),
58
+ },
59
+ username: {
60
+ label: "Tên đăng nhập",
61
+ placeholder: "ten-dang-nhap",
62
+ inputType: "text" as const,
63
+ schema: z.string().trim().min(1, { message: "Nhập tên đăng nhập" }),
64
+ },
65
+ both: {
66
+ label: "Email hoặc tên đăng nhập",
67
+ placeholder: "name@example.com",
68
+ inputType: "text" as const,
69
+ schema: z.string().trim().min(1, { message: "Nhập email hoặc tên đăng nhập" }),
70
+ },
71
+ };
38
72
 
39
- export function SignInForm() {
73
+ type SignInFormType = {
74
+ email: string;
75
+ password: string;
76
+ rememberMe?: boolean;
77
+ };
78
+
79
+ export function SignInForm({
80
+ identifier = "email",
81
+ identifierLabel,
82
+ identifierPlaceholder,
83
+ }: SignInFormProps = {}) {
84
+ const preset = IDENTIFIER_PRESETS[identifier];
85
+ const schema = React.useMemo(
86
+ () =>
87
+ z.object({
88
+ email: preset.schema,
89
+ password: z.string().min(1, { message: "Nhập mật khẩu" }),
90
+ rememberMe: z.boolean().optional(),
91
+ }),
92
+ [preset],
93
+ );
40
94
  const { signInWithCredentials } = useAuthBridge();
41
95
  const params = useParams();
42
96
  const searchParams = useSearchParams();
@@ -50,7 +104,7 @@ export function SignInForm() {
50
104
  "/";
51
105
 
52
106
  const form = useForm<SignInFormType>({
53
- resolver: zodResolver(SignInSchema),
107
+ resolver: zodResolver(schema),
54
108
  defaultValues: {
55
109
  email: "",
56
110
  password: "",
@@ -125,11 +179,12 @@ export function SignInForm() {
125
179
  name="email"
126
180
  render={({ field }) => (
127
181
  <FormItem>
128
- <FormLabel>Email</FormLabel>
182
+ <FormLabel>{identifierLabel ?? preset.label}</FormLabel>
129
183
  <FormControl>
130
184
  <Input
131
- type="email"
132
- placeholder="name@example.com"
185
+ type={preset.inputType}
186
+ autoComplete={identifier === "email" ? "email" : "username"}
187
+ placeholder={identifierPlaceholder ?? preset.placeholder}
133
188
  {...field}
134
189
  />
135
190
  </FormControl>
@@ -210,7 +265,7 @@ export function SignInForm() {
210
265
  </div>
211
266
 
212
267
  <ButtonLoading isLoading={isSubmitting} disabled={isDisabled}>
213
- Đăng nhập với Email
268
+ Đăng nhập
214
269
  </ButtonLoading>
215
270
  <div className="-mt-4 text-center text-sm">
216
271
  Chưa có tài khoản?{" "}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Hợp đồng useClientPagination: trang 1-BASED, đổi cỡ trang tự về trang 1,
3
+ * và page tự clamp khi items co lại (xoá/lọc) — không có render trung gian
4
+ * với trang rỗng.
5
+ */
6
+ import { act, renderHook } from "@testing-library/react";
7
+ import { describe, expect, it } from "vitest";
8
+
9
+ import { useClientPagination } from "../use-client-pagination";
10
+
11
+ const items = (n: number) => Array.from({ length: n }, (_, i) => i + 1);
12
+
13
+ describe("useClientPagination", () => {
14
+ it("khởi tạo: trang 1, cắt đúng lát đầu", () => {
15
+ const { result } = renderHook(() => useClientPagination(items(25), 10));
16
+ expect(result.current.page).toBe(1);
17
+ expect(result.current.pageSize).toBe(10);
18
+ expect(result.current.total).toBe(25);
19
+ expect(result.current.pageCount).toBe(3);
20
+ expect(result.current.pageItems).toEqual(items(10));
21
+ });
22
+
23
+ it("setPage cắt đúng lát (1-based), trang cuối là phần dư", () => {
24
+ const { result } = renderHook(() => useClientPagination(items(25), 10));
25
+ act(() => result.current.setPage(3));
26
+ expect(result.current.page).toBe(3);
27
+ expect(result.current.pageItems).toEqual([21, 22, 23, 24, 25]);
28
+ });
29
+
30
+ it("setPageSize tự về trang 1", () => {
31
+ const { result } = renderHook(() => useClientPagination(items(25), 10));
32
+ act(() => result.current.setPage(3));
33
+ act(() => result.current.setPageSize(5));
34
+ expect(result.current.page).toBe(1);
35
+ expect(result.current.pageSize).toBe(5);
36
+ expect(result.current.pageItems).toEqual([1, 2, 3, 4, 5]);
37
+ });
38
+
39
+ it("items co lại thì page clamp vào pageCount, không rơi ra trang rỗng", () => {
40
+ const { result, rerender } = renderHook(
41
+ ({ data }: { data: number[] }) => useClientPagination(data, 10),
42
+ { initialProps: { data: items(25) } },
43
+ );
44
+ act(() => result.current.setPage(3));
45
+ expect(result.current.page).toBe(3);
46
+
47
+ rerender({ data: items(12) }); // còn 2 trang
48
+ expect(result.current.page).toBe(2);
49
+ expect(result.current.pageItems).toEqual([11, 12]);
50
+ });
51
+
52
+ it("danh sách rỗng: total 0 nhưng pageCount tối thiểu 1, không chia cho 0", () => {
53
+ const { result } = renderHook(() => useClientPagination([] as number[], 10));
54
+ expect(result.current.total).toBe(0);
55
+ expect(result.current.pageCount).toBe(1);
56
+ expect(result.current.page).toBe(1);
57
+ expect(result.current.pageItems).toEqual([]);
58
+ });
59
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Chốt hợp đồng memo từng hàng của DataTable:
3
+ * - mặc định memo theo identity `row.original` → cell đọc state NGOÀI row data
4
+ * bị ĐƠ (baseline, đã cắn ở bảng ma trận tanloc-spartronics);
5
+ * - `rowMemo={false}` tắt memo; `rowMemo={(row) => key}` mở memo khi key đổi;
6
+ * - `cellClassName` đổi phải re-render (bug cũ: không nằm trong comparator).
7
+ */
8
+ import { render } from "@testing-library/react";
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { DataTable } from "../data-table";
12
+ import type { ColumnDef } from "../data-table";
13
+
14
+ type RowData = { id: string; name: string };
15
+
16
+ const DATA: RowData[] = [{ id: "1", name: "x" }];
17
+
18
+ // Cột đọc state NGOÀI row data qua closure — đúng kịch bản Input controlled /
19
+ // cờ busy per-cell. Identity mảng cột phải GIỮ NGUYÊN giữa các lần render.
20
+ function makeColumns(getExternal: () => string): ColumnDef<RowData>[] {
21
+ return [
22
+ {
23
+ id: "name",
24
+ header: "Tên",
25
+ cell: ({ row }) => (
26
+ <span data-testid="cell">
27
+ {row.original.name}-{getExternal()}
28
+ </span>
29
+ ),
30
+ },
31
+ ];
32
+ }
33
+
34
+ describe("DataTable rowMemo", () => {
35
+ it("mặc định: memo theo identity row.original — state ngoài KHÔNG tới cell (baseline)", () => {
36
+ let external = "A";
37
+ const columns = makeColumns(() => external);
38
+ const { rerender, getByTestId } = render(
39
+ <DataTable data={DATA} columns={columns} />,
40
+ );
41
+ expect(getByTestId("cell").textContent).toBe("x-A");
42
+
43
+ external = "B";
44
+ rerender(<DataTable data={DATA} columns={columns} />);
45
+ // row.original không đổi identity → memo chặn, cell vẫn hiện giá trị cũ
46
+ expect(getByTestId("cell").textContent).toBe("x-A");
47
+ });
48
+
49
+ it("rowMemo={false}: tắt memo — hàng re-render theo cha, state ngoài tới cell", () => {
50
+ let external = "A";
51
+ const columns = makeColumns(() => external);
52
+ const { rerender, getByTestId } = render(
53
+ <DataTable data={DATA} columns={columns} rowMemo={false} />,
54
+ );
55
+ expect(getByTestId("cell").textContent).toBe("x-A");
56
+
57
+ external = "B";
58
+ rerender(<DataTable data={DATA} columns={columns} rowMemo={false} />);
59
+ expect(getByTestId("cell").textContent).toBe("x-B");
60
+ });
61
+
62
+ it("rowMemo={(row) => key}: key đổi thì re-render, key giữ nguyên thì vẫn chặn", () => {
63
+ let external = "A";
64
+ let version = 0;
65
+ const columns = makeColumns(() => external);
66
+ const rowMemo = () => version;
67
+ const { rerender, getByTestId } = render(
68
+ <DataTable data={DATA} columns={columns} rowMemo={rowMemo} />,
69
+ );
70
+ expect(getByTestId("cell").textContent).toBe("x-A");
71
+
72
+ // key đổi → comparator thấy khác → re-render
73
+ external = "B";
74
+ version = 1;
75
+ rerender(<DataTable data={DATA} columns={columns} rowMemo={rowMemo} />);
76
+ expect(getByTestId("cell").textContent).toBe("x-B");
77
+
78
+ // key GIỮ NGUYÊN → memo vẫn chặn dù closure đã đổi
79
+ external = "C";
80
+ rerender(<DataTable data={DATA} columns={columns} rowMemo={rowMemo} />);
81
+ expect(getByTestId("cell").textContent).toBe("x-B");
82
+ });
83
+
84
+ it("cellClassName đổi phải ăn xuống <td> (bug cũ: thiếu trong comparator)", () => {
85
+ const columns = makeColumns(() => "A");
86
+ const { rerender, container } = render(
87
+ <DataTable data={DATA} columns={columns} cellClassName="text-xs" />,
88
+ );
89
+ expect(container.querySelector("td")?.className).toContain("text-xs");
90
+
91
+ rerender(
92
+ <DataTable data={DATA} columns={columns} cellClassName="text-sm" />,
93
+ );
94
+ expect(container.querySelector("td")?.className).toContain("text-sm");
95
+ });
96
+ });
@@ -18,7 +18,12 @@ export interface ActiveFilter {
18
18
  operator: string;
19
19
  }
20
20
 
21
+ /**
22
+ * Trạng thái phân trang của core: `page` là 1-BASED (trang đầu = 1),
23
+ * KHÁC `pageIndex` 0-BASED của tanstack. Convert tay: pageIndex = page - 1.
24
+ */
21
25
  export interface PaginationState {
26
+ /** Trang hiện tại, 1-based (tanstack pageIndex = page - 1). */
22
27
  page: number;
23
28
  pageSize: number;
24
29
  }
@@ -35,6 +35,7 @@ import type { SortingState, PaginationState } from "./data-table-context";
35
35
  // ============================================================================
36
36
 
37
37
  export interface DataTablePaginationConfig {
38
+ /** Trang hiện tại, 1-BASED — KHÁC `pageIndex` 0-based của tanstack (pageIndex = page - 1). */
38
39
  page: number;
39
40
  pageSize: number;
40
41
  total: number;
@@ -155,6 +156,16 @@ export interface DataTableProps<TData> {
155
156
  headerCellClassName?: string;
156
157
  /** Cỡ chữ cho ô <td> (TableCell primitive vốn cứng "text-xs"). CRUD truyền "text-sm". */
157
158
  cellClassName?: string;
159
+
160
+ /**
161
+ * Escape hatch cho memo từng hàng. Mặc định hàng được memo theo identity
162
+ * `row.original` — cell đọc state NGOÀI row data (Input controlled, cờ busy
163
+ * per-cell, closure ngoài) sẽ ĐƠ vì identity không đổi. Khi đó:
164
+ * - `false`: tắt memo, hàng luôn re-render theo cha.
165
+ * - `(row) => key`: memo vẫn bật, comparator so sánh THÊM key trả về
166
+ * (Object.is) — trả về version/chuỗi gộp các state ngoài mà cell phụ thuộc.
167
+ */
168
+ rowMemo?: false | ((row: TData) => unknown);
158
169
  }
159
170
 
160
171
  // ============================================================================
@@ -184,6 +195,7 @@ export function DataTable<TData extends Record<string, unknown>>({
184
195
  tableClassName,
185
196
  headerCellClassName,
186
197
  cellClassName,
198
+ rowMemo,
187
199
  }: DataTableProps<TData>) {
188
200
  // Build final columns with selection and row number if enabled
189
201
  const columns = useMemo<ColumnDef<TData>[]>(() => {
@@ -350,12 +362,18 @@ export function DataTable<TData extends Record<string, unknown>>({
350
362
  getFilteredRowModel: getFilteredRowModel(),
351
363
  state: {
352
364
  sorting: tanstackSorting,
353
- pagination: pagination
365
+ // CHỈ đưa key `pagination` vào state khi controlled: `pagination: undefined`
366
+ // vẫn đè initialState của tanstack → getPaginationRowModel crash
367
+ // ("Cannot destructure 'pageSize'") và bảng uncontrolled render RỖNG im lặng.
368
+ ...(pagination
354
369
  ? {
355
- pageIndex: pagination.page - 1,
356
- pageSize: pagination.pageSize,
370
+ pagination: {
371
+ // page của core 1-based, pageIndex của tanstack 0-based
372
+ pageIndex: pagination.page - 1,
373
+ pageSize: pagination.pageSize,
374
+ },
357
375
  }
358
- : undefined,
376
+ : {}),
359
377
  },
360
378
  onPaginationChange: handlePaginationChange,
361
379
  manualPagination: !!pagination,
@@ -442,16 +460,30 @@ export function DataTable<TData extends Record<string, unknown>>({
442
460
  />
443
461
  </>
444
462
  ) : rows.length > 0 ? (
445
- rows.map((row) => (
446
- <MemoizedTableRow
447
- key={row.id}
448
- row={row}
449
- isSelected={row.getIsSelected()}
450
- visibleCellsCount={row.getVisibleCells().length}
451
- onRowClick={onRowClick}
452
- cellClassName={cellClassName}
453
- />
454
- ))
463
+ rows.map((row) =>
464
+ rowMemo === false ? (
465
+ // rowMemo={false}: render thẳng, không memo — hàng theo kịp mọi
466
+ // state ngoài row data (Input controlled, cờ busy per-cell).
467
+ <DataTableRowInner
468
+ key={row.id}
469
+ row={row}
470
+ isSelected={row.getIsSelected()}
471
+ visibleCellsCount={row.getVisibleCells().length}
472
+ onRowClick={onRowClick}
473
+ cellClassName={cellClassName}
474
+ />
475
+ ) : (
476
+ <MemoizedTableRow
477
+ key={row.id}
478
+ row={row}
479
+ isSelected={row.getIsSelected()}
480
+ visibleCellsCount={row.getVisibleCells().length}
481
+ onRowClick={onRowClick}
482
+ cellClassName={cellClassName}
483
+ memoKey={rowMemo ? rowMemo(row.original) : undefined}
484
+ />
485
+ ),
486
+ )
455
487
  ) : null}
456
488
  </TableBody>
457
489
  </table>
@@ -504,14 +536,20 @@ interface MemoizedTableRowProps<TData> {
504
536
  visibleCellsCount: number;
505
537
  onRowClick?: (row: TData) => void;
506
538
  cellClassName?: string;
539
+ /** Key từ `rowMemo(row.original)` — comparator so bằng Object.is. */
540
+ memoKey?: unknown;
507
541
  }
508
542
 
509
- // Ensure TData is maintained by making it a generic memo component, or casting it
510
- const MemoizedTableRow = memo(
511
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
512
- ({ row, isSelected, onRowClick, cellClassName }: MemoizedTableRowProps<any>) => {
513
- return (
514
- <TableRow
543
+ // Thân hàng KHÔNG memo dùng trực tiếp khi rowMemo={false}, inner của
544
+ // MemoizedTableRow dưới.
545
+ function DataTableRowInner<TData>({
546
+ row,
547
+ isSelected,
548
+ onRowClick,
549
+ cellClassName,
550
+ }: MemoizedTableRowProps<TData>) {
551
+ return (
552
+ <TableRow
515
553
  data-state={isSelected && "selected"}
516
554
  className={`transition-colors duration-100 even:bg-muted/30 hover:bg-accent/50 dark:hover:bg-slate-800/40 data-[state=selected]:bg-primary/5 border-b border-border/40 dark:border-slate-800 relative hover:border-l-[3px] hover:border-l-primary ${
517
555
  onRowClick ? "cursor-pointer" : ""
@@ -549,8 +587,11 @@ const MemoizedTableRow = memo(
549
587
  </TableCell>
550
588
  ))}
551
589
  </TableRow>
552
- );
553
- },
590
+ );
591
+ }
592
+
593
+ const MemoizedTableRow = memo(
594
+ DataTableRowInner,
554
595
  (prevProps, nextProps) => {
555
596
  // Basic checks
556
597
  if (
@@ -558,7 +599,11 @@ const MemoizedTableRow = memo(
558
599
  prevProps.isSelected !== nextProps.isSelected ||
559
600
  prevProps.onRowClick !== nextProps.onRowClick ||
560
601
  prevProps.visibleCellsCount !== nextProps.visibleCellsCount ||
561
- prevProps.row.original !== nextProps.row.original
602
+ prevProps.row.original !== nextProps.row.original ||
603
+ // cellClassName đổi phải re-render (trước đây bị bỏ sót — đổi class không ăn)
604
+ prevProps.cellClassName !== nextProps.cellClassName ||
605
+ // Key từ rowMemo(row.original): state ngoài row data mà cell phụ thuộc
606
+ !Object.is(prevProps.memoKey, nextProps.memoKey)
562
607
  ) {
563
608
  return false;
564
609
  }
@@ -25,22 +25,34 @@ import {
25
25
  } from "../primitives/client";
26
26
 
27
27
  interface DataTablePaginationProps<TData> {
28
- table: Table<TData>;
28
+ /**
29
+ * Instance tanstack. BỎ TRỐNG khi dùng thuần state: truyền đủ
30
+ * totalItems/currentPage/pageSize + onPageChange/onPageSizeChange
31
+ * (ghép với hook `useClientPagination`).
32
+ */
33
+ table?: Table<TData>;
29
34
  totalItems?: number;
35
+ /** Trang hiện tại, 1-BASED (KHÁC pageIndex 0-based của tanstack). */
30
36
  currentPage?: number;
31
37
  pageSize?: number;
38
+ /** Nhận trang 1-based. Có mặt thì THẮNG table.setPageIndex. */
39
+ onPageChange?: (page: number) => void;
40
+ /** Có mặt thì THẮNG table.setPageSize. Reset về trang 1 là việc của caller (useClientPagination đã lo). */
41
+ onPageSizeChange?: (pageSize: number) => void;
32
42
  }
33
43
 
34
- export function DataTablePagination<TData>({
44
+ export function DataTablePagination<TData = unknown>({
35
45
  table,
36
46
  totalItems,
37
47
  currentPage,
38
48
  pageSize,
49
+ onPageChange,
50
+ onPageSizeChange,
39
51
  }: DataTablePaginationProps<TData>) {
40
- const state = table.getState().pagination;
41
- const effPageSize = pageSize ?? state.pageSize;
42
- const effCurrentPage = currentPage ?? state.pageIndex + 1;
43
- const total = totalItems ?? table.getFilteredRowModel().rows.length;
52
+ const state = table?.getState().pagination;
53
+ const effPageSize = pageSize ?? state?.pageSize ?? 10;
54
+ const effCurrentPage = currentPage ?? (state ? state.pageIndex + 1 : 1);
55
+ const total = totalItems ?? table?.getFilteredRowModel().rows.length ?? 0;
44
56
  const pageCount = Math.max(1, Math.ceil(total / effPageSize));
45
57
  const startItem = total > 0 ? (effCurrentPage - 1) * effPageSize + 1 : 0;
46
58
  const endItem = total > 0 ? Math.min(effCurrentPage * effPageSize, total) : 0;
@@ -52,7 +64,8 @@ export function DataTablePagination<TData>({
52
64
 
53
65
  const goToPage = (page: number) => {
54
66
  if (page < 1 || page > pageCount) return;
55
- table.setPageIndex(page - 1);
67
+ if (onPageChange) onPageChange(page);
68
+ else table?.setPageIndex(page - 1);
56
69
  };
57
70
 
58
71
  const handlePageInputBlur = () => {
@@ -77,7 +90,11 @@ export function DataTablePagination<TData>({
77
90
  </p>
78
91
  <Select
79
92
  value={`${effPageSize}`}
80
- onValueChange={(value) => table.setPageSize(Number(value))}
93
+ onValueChange={(value) => {
94
+ const size = Number(value);
95
+ if (onPageSizeChange) onPageSizeChange(size);
96
+ else table?.setPageSize(size);
97
+ }}
81
98
  >
82
99
  <SelectTrigger className="!h-6 !min-h-0 py-0 w-[60px] sm:w-[65px] text-[11px] px-1.5 focus:ring-inset focus:ring-offset-0">
83
100
  <SelectValue placeholder={effPageSize} />
@@ -14,6 +14,7 @@ export { DataTableViewOptions } from "./data-table-view-options";
14
14
 
15
15
  export * from "./formatted-number-input";
16
16
  export * from "./data-table-pagination";
17
+ export * from "./use-client-pagination";
17
18
  export * from "./avatar";
18
19
  export * from "./kpi-card";
19
20
  export * from "./compact-stat-bar";
@@ -0,0 +1,48 @@
1
+ // @goerp/core/ui/data-display
2
+ // Hook phân trang CLIENT-STATE THUẦN cho bảng client-side (dialog/tab không
3
+ // muốn gắn URL). Ghép với <DataTablePagination totalItems currentPage pageSize
4
+ // onPageChange onPageSizeChange> (không cần table tanstack). Trang 1-based.
5
+ // Cần gắn URL thì dùng UrlPagination/ShallowPagination, KHÔNG dùng hook này.
6
+
7
+ "use client";
8
+
9
+ import { useCallback, useMemo, useState } from "react";
10
+
11
+ export interface ClientPagination<T> {
12
+ /** Trang hiện tại, 1-based — đã clamp vào [1, pageCount] khi items co lại. */
13
+ page: number;
14
+ setPage: (page: number) => void;
15
+ pageSize: number;
16
+ /** Đổi cỡ trang thì TỰ về trang 1 (tránh rơi ngoài phạm vi). */
17
+ setPageSize: (pageSize: number) => void;
18
+ /** Lát cắt items của trang hiện tại. */
19
+ pageItems: T[];
20
+ total: number;
21
+ pageCount: number;
22
+ }
23
+
24
+ export function useClientPagination<T>(
25
+ items: T[],
26
+ initialPageSize = 10,
27
+ ): ClientPagination<T> {
28
+ const [rawPage, setPage] = useState(1);
29
+ const [pageSize, setPageSizeState] = useState(initialPageSize);
30
+
31
+ const total = items.length;
32
+ const pageCount = Math.max(1, Math.ceil(total / pageSize));
33
+ // Clamp thay vì setState trong effect: items co lại (xoá/lọc) không tạo
34
+ // render trung gian với trang rỗng.
35
+ const page = Math.min(Math.max(1, rawPage), pageCount);
36
+
37
+ const pageItems = useMemo(
38
+ () => items.slice((page - 1) * pageSize, page * pageSize),
39
+ [items, page, pageSize],
40
+ );
41
+
42
+ const setPageSize = useCallback((size: number) => {
43
+ setPageSizeState(size);
44
+ setPage(1);
45
+ }, []);
46
+
47
+ return { page, setPage, pageSize, setPageSize, pageItems, total, pageCount };
48
+ }