@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.
- package/CHANGELOG.md +45 -0
- package/bin/goerp-init.mjs +15 -0
- package/package.json +2 -1
- package/src/auth/index.ts +5 -1
- package/src/auth/proxy-gate.ts +21 -2
- package/src/configs/entities/departments.config.ts +1 -0
- package/src/configs/entities/material-categories.config.ts +1 -0
- package/src/cron/__tests__/cron-schedule.test.ts +76 -0
- package/src/cron/cron-schedule.ts +128 -0
- package/src/cron/simple-cron-job.ts +83 -69
- package/src/crud/components/crud-export-button.tsx +3 -2
- package/src/crud/components/crud-page.tsx +56 -25
- package/src/crud/components/crud-row-actions.tsx +16 -4
- package/src/crud/components/crud-table.tsx +13 -1
- package/src/crud/crud-route-handlers.test.ts +352 -0
- package/src/crud/crud-route-handlers.ts +260 -13
- package/src/crud/index.ts +3 -0
- package/src/crud/lib/coerce.test.ts +118 -0
- package/src/crud/lib/coerce.ts +95 -0
- package/src/crud/lib/crud-utils.ts +4 -0
- package/src/crud/lib/entity-endpoints.test.ts +41 -0
- package/src/crud/lib/entity-endpoints.ts +23 -0
- package/src/crud/lib/errors.ts +14 -0
- package/src/crud/lib/import-request.ts +99 -0
- package/src/crud/lib/mutation-builder.test.ts +114 -0
- package/src/crud/lib/mutation-builder.ts +27 -34
- package/src/crud/lib/permissions.test.ts +57 -0
- package/src/crud/lib/permissions.ts +25 -13
- package/src/crud/lib/query-builder.ts +13 -6
- package/src/crud/lib/translate-config.ts +16 -2
- package/src/crud/pages/entity-crud-page.tsx +11 -8
- package/src/crud/server-service.test.ts +112 -0
- package/src/crud/server-service.ts +52 -27
- package/src/crud/server.ts +11 -1
- package/src/guardrails/index.ts +1 -0
- package/src/guardrails/rules/auth.ts +54 -1
- package/src/guardrails/rules/design.ts +3 -2
- package/src/guardrails/types.ts +17 -8
- package/src/providers/brand-theme.ts +20 -0
- package/src/rbac/pages/permission-catalog-pages.tsx +3 -1
- package/src/security/index.ts +2 -0
- package/src/security/pages/sessions-page.tsx +516 -0
- package/src/types/index.ts +75 -10
- package/src/ui/auth/sign-in-form.tsx +64 -9
- package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
- package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
- package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
- package/src/ui/data-display/data-table/data-table.tsx +68 -23
- package/src/ui/data-display/data-table-pagination.tsx +25 -8
- package/src/ui/data-display/index.tsx +1 -0
- package/src/ui/data-display/use-client-pagination.ts +48 -0
- package/src/ui/errors/error-view.tsx +164 -0
- package/src/ui/errors/index.ts +2 -0
- package/src/ui/errors/not-found-view.tsx +44 -0
- package/src/ui/index.tsx +1 -0
- package/src/ui/layout/logo.tsx +54 -19
- package/src/user/pages/users-client-page.tsx +12 -7
- package/templates/starter-app/.env.example +4 -0
- package/templates/starter-app/AGENTS.md +1 -1
- package/templates/starter-app/README.md +6 -2
- package/templates/starter-app/husky/pre-commit +10 -0
- package/templates/starter-app/package.json +27 -2
- package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
- package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
- package/templates/starter-app/prisma/schema/organization.prisma +20 -0
- package/templates/starter-app/prisma/schema/system.prisma +74 -0
- package/templates/starter-app/prisma/seed.ts +7 -5
- package/templates/starter-app/scripts/migration-new.mjs +89 -0
- package/templates/starter-app/scripts/rbac-sync.ts +49 -21
- package/templates/starter-app/src/__tests__/architecture.test.ts +11 -2
- package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +37 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +68 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +33 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +195 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +29 -0
- package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +13 -3
- package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
- package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/security/sessions/page.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/system-categories/page.tsx +30 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +62 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +41 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +157 -0
- package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +69 -0
- package/templates/starter-app/src/app/[lang]/[...not-found]/page.tsx +9 -0
- package/templates/starter-app/src/app/[lang]/error.tsx +26 -0
- package/templates/starter-app/src/app/[lang]/layout.tsx +2 -2
- package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
- package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
- package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
- package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
- package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
- package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
- package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
- package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
- package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
- package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
- package/templates/starter-app/src/app/api/branches/route.ts +26 -0
- package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
- package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
- package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
- package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
- package/templates/starter-app/src/app/api/departments/route.ts +26 -0
- package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
- package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
- package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
- package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
- package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
- package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
- package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
- package/templates/starter-app/src/app/api/roles/route.ts +2 -1
- package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +52 -0
- package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +44 -0
- package/templates/starter-app/src/app/api/security/sessions/route.ts +101 -0
- package/templates/starter-app/src/app/api/suppliers/route.ts +13 -0
- package/templates/starter-app/src/app/api/system-categories/route.ts +179 -0
- package/templates/starter-app/src/app/api/system-category-groups/route.ts +149 -0
- package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
- package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
- package/templates/starter-app/src/app/api/upload/route.ts +1 -2
- package/templates/starter-app/src/app/api/users/[id]/route.ts +230 -0
- package/templates/starter-app/src/app/api/users/route.ts +141 -0
- package/templates/starter-app/src/app/global-error.tsx +135 -0
- package/templates/starter-app/src/app/globals.css +0 -1
- package/templates/starter-app/src/app/icon.svg +6 -0
- package/templates/starter-app/src/app/manifest.ts +26 -0
- package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
- package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
- package/templates/starter-app/src/configs/entities/index.ts +5 -1
- package/templates/starter-app/src/configs/entities/job-titles.config.ts +121 -0
- package/templates/starter-app/src/configs/entities/system-alerts.config.ts +93 -0
- package/templates/starter-app/src/configs/entities/users.config.ts +80 -0
- package/templates/starter-app/src/configs/i18n.ts +6 -6
- package/templates/starter-app/src/configs/permissions/index.ts +37 -0
- package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +11 -0
- package/templates/starter-app/src/configs/permissions/menu-tree.ts +37 -0
- package/templates/starter-app/src/configs/permissions/system.permissions.ts +29 -0
- package/templates/starter-app/src/configs/tenant.ts +2 -1
- package/templates/starter-app/src/data/dictionary.ts +77 -4
- package/templates/starter-app/src/data/navigations.ts +60 -1
- package/templates/starter-app/src/instrumentation.ts +5 -16
- package/templates/starter-app/src/lib/action-guard.ts +25 -0
- package/templates/starter-app/src/lib/api-handler.ts +27 -19
- package/templates/starter-app/src/lib/auth.ts +3 -1
- package/templates/starter-app/src/lib/better-auth.ts +8 -0
- package/templates/starter-app/src/lib/branch-scope.ts +1 -1
- package/templates/starter-app/src/lib/crud/index.ts +5 -10
- package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
- package/templates/starter-app/src/lib/logger.ts +11 -4
- package/templates/starter-app/src/lib/page-guard.ts +2 -3
- package/templates/starter-app/src/lib/prisma.ts +16 -7
- package/templates/starter-app/src/lib/rbac/access.ts +2 -2
- package/templates/starter-app/src/lib/storage.ts +3 -1
- package/templates/starter-app/src/providers/index.tsx +2 -1
- package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
- package/templates/starter-app/src/providers/theme-provider.tsx +27 -12
- package/templates/starter-app/src/proxy.ts +8 -3
- package/templates/starter-app/src/server/services/company-service.ts +49 -0
- package/templates/starter-app/src/server/services/notification-service.ts +6 -6
- package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
- package/templates/starter-app/src/server/services/user-service.ts +104 -0
- package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
- package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
- package/templates/starter-app/tsconfig.json +10 -0
- 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
|
-
|
|
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
|
-
|
|
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(
|
|
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>
|
|
182
|
+
<FormLabel>{identifierLabel ?? preset.label}</FormLabel>
|
|
129
183
|
<FormControl>
|
|
130
184
|
<Input
|
|
131
|
-
type=
|
|
132
|
-
|
|
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
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
row
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
543
|
+
// Thân hàng KHÔNG memo — dùng trực tiếp khi rowMemo={false}, và là 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
|
-
|
|
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
|
|
41
|
-
const effPageSize = pageSize ?? state
|
|
42
|
-
const effCurrentPage = currentPage ?? state.pageIndex + 1;
|
|
43
|
-
const total = totalItems ?? table
|
|
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
|
-
|
|
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) =>
|
|
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
|
+
}
|