@goplusvn/core 0.1.71 → 0.1.73
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/package.json +2 -1
- package/src/auth/index.ts +5 -1
- package/src/branding/__tests__/identity.test.ts +101 -0
- package/src/branding/__tests__/logo-route.test.ts +143 -0
- package/src/branding/identity.ts +74 -0
- package/src/branding/index.ts +36 -0
- package/src/branding/logo-route.ts +249 -0
- package/src/branding/pwa.tsx +146 -0
- package/src/branding/types.ts +45 -0
- package/src/configs/entities/departments.config.ts +1 -0
- package/src/configs/entities/material-categories.config.ts +1 -0
- 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/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/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/rules/design.ts +3 -2
- package/src/styles/base.css +16 -0
- package/src/types/index.ts +75 -10
- 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 +80 -87
- package/src/ui/errors/index.ts +6 -1
- package/src/ui/errors/not-found-view.tsx +89 -16
- package/src/ui/errors/status-page.tsx +132 -0
- package/src/ui/layout/logo.tsx +5 -0
- package/src/ui/pages/not-found.tsx +11 -27
- package/templates/starter-app/AGENTS.md +1 -1
- package/templates/starter-app/next.config.mjs +8 -0
- package/templates/starter-app/package.json +1 -0
- 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/public/images/illustrations/characters/character-01.svg +33 -0
- package/templates/starter-app/public/images/illustrations/characters/character-02.svg +30 -0
- package/templates/starter-app/public/images/illustrations/characters/character-03.svg +30 -0
- package/templates/starter-app/public/images/illustrations/characters/character-04.svg +27 -0
- package/templates/starter-app/public/images/logos/goeat_logo.png +0 -0
- package/templates/starter-app/scripts/rbac-sync.ts +49 -21
- package/templates/starter-app/src/__tests__/architecture.test.ts +3 -2
- package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +4 -4
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +1 -2
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +7 -5
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +38 -11
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +4 -4
- package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +5 -5
- 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)/user/profile/actions.ts +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +3 -1
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +13 -5
- package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +3 -2
- package/templates/starter-app/src/app/[lang]/layout.tsx +27 -2
- 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/branding/logo/route.ts +25 -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/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 +1 -1
- 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 +5 -5
- package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +1 -1
- package/templates/starter-app/src/app/api/security/sessions/route.ts +2 -2
- package/templates/starter-app/src/app/api/system-categories/route.ts +39 -14
- package/templates/starter-app/src/app/api/system-category-groups/route.ts +32 -14
- 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 +20 -12
- package/templates/starter-app/src/app/api/users/route.ts +11 -9
- package/templates/starter-app/src/app/globals.css +0 -1
- package/templates/starter-app/src/app/layout.tsx +42 -1
- package/templates/starter-app/src/app/manifest.ts +23 -19
- 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 +1 -1
- package/templates/starter-app/src/configs/entities/job-titles.config.ts +2 -0
- package/templates/starter-app/src/configs/entities/system-alerts.config.ts +2 -0
- package/templates/starter-app/src/configs/entities/users.config.ts +15 -2
- package/templates/starter-app/src/configs/i18n.ts +6 -6
- package/templates/starter-app/src/configs/permissions/menu-tree.ts +1 -1
- package/templates/starter-app/src/configs/tenant.ts +6 -1
- package/templates/starter-app/src/data/dictionary.ts +2 -1
- package/templates/starter-app/src/data/navigations.ts +12 -2
- package/templates/starter-app/src/lib/action-guard.ts +3 -4
- 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/branch-scope.ts +1 -1
- package/templates/starter-app/src/lib/crud/index.ts +5 -12
- 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 +13 -5
- 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 +13 -2
- package/templates/starter-app/src/providers/theme-provider.tsx +4 -3
- package/templates/starter-app/src/proxy.ts +3 -1
- package/templates/starter-app/src/server/services/company-service.ts +4 -2
- 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 +2 -2
- 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 +8 -0
- package/templates/starter-app/vitest.config.ts +17 -1
|
@@ -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
|
+
}
|
|
@@ -2,14 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react"
|
|
4
4
|
import Link from "next/link"
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
CardHeader,
|
|
10
|
-
CardTitle,
|
|
11
|
-
} from "../index"
|
|
12
|
-
import { AlertTriangle, Check, Copy, Home, RefreshCw } from "lucide-react"
|
|
5
|
+
import { Button } from "../index"
|
|
6
|
+
import { Check, Copy, Home, RefreshCw } from "lucide-react"
|
|
7
|
+
|
|
8
|
+
import { StatusPage, STATUS_ILLUSTRATION } from "./status-page"
|
|
13
9
|
|
|
14
10
|
|
|
15
11
|
interface ErrorViewProps {
|
|
@@ -39,12 +35,19 @@ interface ErrorViewProps {
|
|
|
39
35
|
* `error.message` bị Next thay bằng câu chung chung nên ghi thêm chỉ đẻ rác.
|
|
40
36
|
* Vì vậy chỉ POST khi KHÔNG có digest — đó mới là lỗi runtime trình duyệt,
|
|
41
37
|
* khoảng trống mà server không thấy.
|
|
38
|
+
*
|
|
39
|
+
* **Về hình thức**: dùng chung `StatusPage` với 404/403/400 — cùng bố cục của
|
|
40
|
+
* trang 404 trong bộ kit (hình minh hoạ + mã lỗi lớn + một câu + hàng nút).
|
|
41
|
+
* Trang lỗi là chỗ người dùng đang bực; hình vẽ nét mộc làm nó bớt lạnh, nhưng
|
|
42
|
+
* KHÔNG tô màu cảnh báo lòe loẹt và không có gì nhấp nháy: đây là lúc hỏng
|
|
43
|
+
* việc, không phải lúc đùa. Mã lỗi vẫn là chip mono chép được — phần duy nhất
|
|
44
|
+
* thật sự hữu ích ở màn này.
|
|
42
45
|
*/
|
|
43
46
|
export function ErrorView({
|
|
44
47
|
error,
|
|
45
48
|
reset,
|
|
46
|
-
title = "
|
|
47
|
-
description = "
|
|
49
|
+
title = "Trang gặp sự cố",
|
|
50
|
+
description = "Lỗi kỹ thuật tạm thời, dữ liệu của bạn không mất. Bạn thử lại một lần xem sao; nếu vẫn vậy, gửi mã lỗi bên dưới cho quản trị viên.",
|
|
48
51
|
showHome = true,
|
|
49
52
|
fullScreen = false,
|
|
50
53
|
}: ErrorViewProps) {
|
|
@@ -78,87 +81,77 @@ export function ErrorView({
|
|
|
78
81
|
}
|
|
79
82
|
}
|
|
80
83
|
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
<
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
className="h-
|
|
102
|
-
|
|
103
|
-
title="Sao chép mã lỗi"
|
|
104
|
-
>
|
|
105
|
-
{copied ? (
|
|
106
|
-
<Check className="h-3.5 w-3.5 text-emerald-600" />
|
|
107
|
-
) : (
|
|
108
|
-
<Copy className="h-3.5 w-3.5" />
|
|
109
|
-
)}
|
|
110
|
-
</Button>
|
|
111
|
-
</div>
|
|
112
|
-
) : null}
|
|
113
|
-
|
|
114
|
-
{/* Chi tiết kỹ thuật chỉ mở ở máy dev. Trên production Next đã thay
|
|
115
|
-
`message` bằng câu chung chung nên hiện ra cũng không giúp gì, mà
|
|
116
|
-
còn dễ lộ đường dẫn nội bộ. */}
|
|
117
|
-
{process.env.NODE_ENV === "development" && error.stack ? (
|
|
118
|
-
<details className="rounded-md border border-border bg-muted/40 p-3">
|
|
119
|
-
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">
|
|
120
|
-
Chi tiết kỹ thuật (chỉ hiện khi chạy dev)
|
|
121
|
-
</summary>
|
|
122
|
-
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-all text-[11px] leading-relaxed text-muted-foreground">
|
|
123
|
-
{error.stack}
|
|
124
|
-
</pre>
|
|
125
|
-
</details>
|
|
126
|
-
) : null}
|
|
127
|
-
|
|
128
|
-
<div className="flex flex-wrap gap-2">
|
|
129
|
-
{reset ? (
|
|
130
|
-
<Button onClick={reset}>
|
|
131
|
-
<RefreshCw className="mr-2 h-4 w-4" />
|
|
132
|
-
Thử lại
|
|
133
|
-
</Button>
|
|
134
|
-
) : null}
|
|
135
|
-
<Button variant="outline" onClick={() => window.location.reload()}>
|
|
136
|
-
Tải lại trang
|
|
84
|
+
const details = (
|
|
85
|
+
<>
|
|
86
|
+
{error.digest ? (
|
|
87
|
+
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 py-1 pl-2.5 pr-1">
|
|
88
|
+
<span className="shrink-0 text-[11px] uppercase tracking-wide text-muted-foreground">
|
|
89
|
+
Mã lỗi
|
|
90
|
+
</span>
|
|
91
|
+
<code className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
|
|
92
|
+
{error.digest}
|
|
93
|
+
</code>
|
|
94
|
+
<Button
|
|
95
|
+
variant="ghost"
|
|
96
|
+
size="icon-xs"
|
|
97
|
+
className="shrink-0 text-muted-foreground"
|
|
98
|
+
onClick={handleCopy}
|
|
99
|
+
title="Sao chép mã lỗi"
|
|
100
|
+
>
|
|
101
|
+
{copied ? (
|
|
102
|
+
<Check className="h-3.5 w-3.5 text-emerald-600" />
|
|
103
|
+
) : (
|
|
104
|
+
<Copy className="h-3.5 w-3.5" />
|
|
105
|
+
)}
|
|
137
106
|
</Button>
|
|
138
|
-
{showHome ? (
|
|
139
|
-
<Button variant="ghost" asChild>
|
|
140
|
-
<Link href="/">
|
|
141
|
-
<Home className="mr-2 h-4 w-4" />
|
|
142
|
-
Về trang chủ
|
|
143
|
-
</Link>
|
|
144
|
-
</Button>
|
|
145
|
-
) : null}
|
|
146
107
|
</div>
|
|
147
|
-
|
|
148
|
-
</Card>
|
|
149
|
-
)
|
|
108
|
+
) : null}
|
|
150
109
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
110
|
+
{/* Chi tiết kỹ thuật chỉ mở ở máy dev. Trên production Next đã thay
|
|
111
|
+
`message` bằng câu chung chung nên hiện ra cũng không giúp gì, mà
|
|
112
|
+
còn dễ lộ đường dẫn nội bộ. */}
|
|
113
|
+
{process.env.NODE_ENV === "development" && error.stack ? (
|
|
114
|
+
<details className="group rounded-md border border-border bg-muted/40 px-2.5 py-1.5">
|
|
115
|
+
<summary className="cursor-pointer list-none text-[11px] font-medium text-muted-foreground select-none hover:text-foreground">
|
|
116
|
+
<span className="mr-1 inline-block transition-transform group-open:rotate-90">
|
|
117
|
+
›
|
|
118
|
+
</span>
|
|
119
|
+
Chi tiết kỹ thuật (chỉ hiện khi chạy dev)
|
|
120
|
+
</summary>
|
|
121
|
+
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed text-muted-foreground">
|
|
122
|
+
{error.stack}
|
|
123
|
+
</pre>
|
|
124
|
+
</details>
|
|
125
|
+
) : null}
|
|
126
|
+
</>
|
|
127
|
+
)
|
|
158
128
|
|
|
159
129
|
return (
|
|
160
|
-
<
|
|
161
|
-
|
|
162
|
-
|
|
130
|
+
<StatusPage
|
|
131
|
+
code="500"
|
|
132
|
+
title={title}
|
|
133
|
+
description={description}
|
|
134
|
+
illustration={STATUS_ILLUSTRATION.error}
|
|
135
|
+
fullScreen={fullScreen}
|
|
136
|
+
extra={error.digest || process.env.NODE_ENV === "development" ? details : null}
|
|
137
|
+
>
|
|
138
|
+
{reset ? (
|
|
139
|
+
<Button onClick={reset}>
|
|
140
|
+
<RefreshCw className="mr-1.5 h-4 w-4" />
|
|
141
|
+
Thử lại
|
|
142
|
+
</Button>
|
|
143
|
+
) : null}
|
|
144
|
+
<Button variant="outline" onClick={() => window.location.reload()}>
|
|
145
|
+
Tải lại trang
|
|
146
|
+
</Button>
|
|
147
|
+
{showHome ? (
|
|
148
|
+
<Button variant="ghost" className="text-muted-foreground" asChild>
|
|
149
|
+
<Link href="/">
|
|
150
|
+
<Home className="mr-1.5 h-4 w-4" />
|
|
151
|
+
Về trang chủ
|
|
152
|
+
</Link>
|
|
153
|
+
</Button>
|
|
154
|
+
) : null}
|
|
155
|
+
</StatusPage>
|
|
163
156
|
)
|
|
164
157
|
}
|
package/src/ui/errors/index.ts
CHANGED