@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.
Files changed (150) hide show
  1. package/package.json +2 -1
  2. package/src/auth/index.ts +5 -1
  3. package/src/branding/__tests__/identity.test.ts +101 -0
  4. package/src/branding/__tests__/logo-route.test.ts +143 -0
  5. package/src/branding/identity.ts +74 -0
  6. package/src/branding/index.ts +36 -0
  7. package/src/branding/logo-route.ts +249 -0
  8. package/src/branding/pwa.tsx +146 -0
  9. package/src/branding/types.ts +45 -0
  10. package/src/configs/entities/departments.config.ts +1 -0
  11. package/src/configs/entities/material-categories.config.ts +1 -0
  12. package/src/crud/components/crud-export-button.tsx +3 -2
  13. package/src/crud/components/crud-page.tsx +56 -25
  14. package/src/crud/components/crud-row-actions.tsx +16 -4
  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/pages/entity-crud-page.tsx +11 -8
  31. package/src/crud/server-service.test.ts +112 -0
  32. package/src/crud/server-service.ts +52 -27
  33. package/src/crud/server.ts +11 -1
  34. package/src/guardrails/rules/design.ts +3 -2
  35. package/src/styles/base.css +16 -0
  36. package/src/types/index.ts +75 -10
  37. package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
  38. package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
  39. package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
  40. package/src/ui/data-display/data-table/data-table.tsx +68 -23
  41. package/src/ui/data-display/data-table-pagination.tsx +25 -8
  42. package/src/ui/data-display/index.tsx +1 -0
  43. package/src/ui/data-display/use-client-pagination.ts +48 -0
  44. package/src/ui/errors/error-view.tsx +80 -87
  45. package/src/ui/errors/index.ts +6 -1
  46. package/src/ui/errors/not-found-view.tsx +89 -16
  47. package/src/ui/errors/status-page.tsx +132 -0
  48. package/src/ui/layout/logo.tsx +5 -0
  49. package/src/ui/pages/not-found.tsx +11 -27
  50. package/templates/starter-app/AGENTS.md +1 -1
  51. package/templates/starter-app/next.config.mjs +8 -0
  52. package/templates/starter-app/package.json +1 -0
  53. package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
  54. package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
  55. package/templates/starter-app/public/images/illustrations/characters/character-01.svg +33 -0
  56. package/templates/starter-app/public/images/illustrations/characters/character-02.svg +30 -0
  57. package/templates/starter-app/public/images/illustrations/characters/character-03.svg +30 -0
  58. package/templates/starter-app/public/images/illustrations/characters/character-04.svg +27 -0
  59. package/templates/starter-app/public/images/logos/goeat_logo.png +0 -0
  60. package/templates/starter-app/scripts/rbac-sync.ts +49 -21
  61. package/templates/starter-app/src/__tests__/architecture.test.ts +3 -2
  62. package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +4 -4
  63. package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +1 -2
  64. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +7 -5
  65. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +38 -11
  66. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +2 -1
  67. package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
  68. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
  69. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +4 -4
  70. package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +5 -5
  71. package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
  72. package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
  73. package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
  74. package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +4 -2
  75. package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +3 -1
  76. package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +13 -5
  77. package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +3 -2
  78. package/templates/starter-app/src/app/[lang]/layout.tsx +27 -2
  79. package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
  80. package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
  81. package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
  82. package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
  83. package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
  84. package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
  85. package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
  86. package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
  87. package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
  88. package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
  89. package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
  90. package/templates/starter-app/src/app/api/branding/logo/route.ts +25 -0
  91. package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
  92. package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
  93. package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
  94. package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
  95. package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
  96. package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
  97. package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
  98. package/templates/starter-app/src/app/api/job-titles/route.ts +1 -1
  99. package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
  100. package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
  101. package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
  102. package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
  103. package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
  104. package/templates/starter-app/src/app/api/roles/route.ts +2 -1
  105. package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +5 -5
  106. package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +1 -1
  107. package/templates/starter-app/src/app/api/security/sessions/route.ts +2 -2
  108. package/templates/starter-app/src/app/api/system-categories/route.ts +39 -14
  109. package/templates/starter-app/src/app/api/system-category-groups/route.ts +32 -14
  110. package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
  111. package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
  112. package/templates/starter-app/src/app/api/upload/route.ts +1 -2
  113. package/templates/starter-app/src/app/api/users/[id]/route.ts +20 -12
  114. package/templates/starter-app/src/app/api/users/route.ts +11 -9
  115. package/templates/starter-app/src/app/globals.css +0 -1
  116. package/templates/starter-app/src/app/layout.tsx +42 -1
  117. package/templates/starter-app/src/app/manifest.ts +23 -19
  118. package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
  119. package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
  120. package/templates/starter-app/src/configs/entities/index.ts +1 -1
  121. package/templates/starter-app/src/configs/entities/job-titles.config.ts +2 -0
  122. package/templates/starter-app/src/configs/entities/system-alerts.config.ts +2 -0
  123. package/templates/starter-app/src/configs/entities/users.config.ts +15 -2
  124. package/templates/starter-app/src/configs/i18n.ts +6 -6
  125. package/templates/starter-app/src/configs/permissions/menu-tree.ts +1 -1
  126. package/templates/starter-app/src/configs/tenant.ts +6 -1
  127. package/templates/starter-app/src/data/dictionary.ts +2 -1
  128. package/templates/starter-app/src/data/navigations.ts +12 -2
  129. package/templates/starter-app/src/lib/action-guard.ts +3 -4
  130. package/templates/starter-app/src/lib/api-handler.ts +27 -19
  131. package/templates/starter-app/src/lib/auth.ts +3 -1
  132. package/templates/starter-app/src/lib/branch-scope.ts +1 -1
  133. package/templates/starter-app/src/lib/crud/index.ts +5 -12
  134. package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
  135. package/templates/starter-app/src/lib/logger.ts +11 -4
  136. package/templates/starter-app/src/lib/page-guard.ts +2 -3
  137. package/templates/starter-app/src/lib/prisma.ts +13 -5
  138. package/templates/starter-app/src/lib/rbac/access.ts +2 -2
  139. package/templates/starter-app/src/lib/storage.ts +3 -1
  140. package/templates/starter-app/src/providers/index.tsx +13 -2
  141. package/templates/starter-app/src/providers/theme-provider.tsx +4 -3
  142. package/templates/starter-app/src/proxy.ts +3 -1
  143. package/templates/starter-app/src/server/services/company-service.ts +4 -2
  144. package/templates/starter-app/src/server/services/notification-service.ts +6 -6
  145. package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
  146. package/templates/starter-app/src/server/services/user-service.ts +2 -2
  147. package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
  148. package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
  149. package/templates/starter-app/tsconfig.json +8 -0
  150. 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
- 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
+ }
@@ -2,14 +2,10 @@
2
2
 
3
3
  import { useEffect, useState } from "react"
4
4
  import Link from "next/link"
5
- import {
6
- Button,
7
- Card,
8
- CardContent,
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 = "Đã xảy ra lỗi",
47
- description = "Hệ thống gặp sự cố khi hiển thị nội dung này. Đây là lỗi kỹ thuật tạm thời dữ liệu và quyền truy cập của bạn không thay đổi. Vui lòng thử lại; nếu vẫn lỗi, gửi mã lỗi bên dưới cho quản trị viên.",
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 card = (
82
- <Card className="w-full max-w-lg">
83
- <CardHeader>
84
- <CardTitle className="flex items-center gap-2 text-destructive">
85
- <AlertTriangle className="h-5 w-5 shrink-0" />
86
- {title}
87
- </CardTitle>
88
- </CardHeader>
89
- <CardContent className="space-y-4">
90
- <p className="text-sm text-muted-foreground">{description}</p>
91
-
92
- {error.digest ? (
93
- <div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
94
- <span className="text-xs text-muted-foreground">Mã lỗi</span>
95
- <code className="flex-1 truncate font-mono text-xs text-foreground">
96
- {error.digest}
97
- </code>
98
- <Button
99
- variant="ghost"
100
- size="icon"
101
- className="h-7 w-7 shrink-0"
102
- onClick={handleCopy}
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
- </CardContent>
148
- </Card>
149
- )
108
+ ) : null}
150
109
 
151
- if (!fullScreen) {
152
- return (
153
- <div className="flex min-h-0 flex-1 items-center justify-center p-4">
154
- {card}
155
- </div>
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
- <div className="flex min-h-screen items-center justify-center bg-background p-4">
161
- {card}
162
- </div>
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
  }
@@ -1,2 +1,7 @@
1
- export { NotFoundView } from "./not-found-view";
1
+ export { StatusPage, STATUS_ILLUSTRATION } from "./status-page";
2
+ export {
3
+ NotFoundView,
4
+ ForbiddenView,
5
+ BadRequestView,
6
+ } from "./not-found-view";
2
7
  export { ErrorView } from "./error-view";