@goplusvn/core 0.1.71 → 0.1.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/package.json +2 -1
  2. package/src/auth/index.ts +5 -1
  3. package/src/configs/entities/departments.config.ts +1 -0
  4. package/src/configs/entities/material-categories.config.ts +1 -0
  5. package/src/crud/components/crud-export-button.tsx +3 -2
  6. package/src/crud/components/crud-page.tsx +56 -25
  7. package/src/crud/components/crud-row-actions.tsx +16 -4
  8. package/src/crud/crud-route-handlers.test.ts +352 -0
  9. package/src/crud/crud-route-handlers.ts +260 -13
  10. package/src/crud/index.ts +3 -0
  11. package/src/crud/lib/coerce.test.ts +118 -0
  12. package/src/crud/lib/coerce.ts +95 -0
  13. package/src/crud/lib/crud-utils.ts +4 -0
  14. package/src/crud/lib/entity-endpoints.test.ts +41 -0
  15. package/src/crud/lib/entity-endpoints.ts +23 -0
  16. package/src/crud/lib/errors.ts +14 -0
  17. package/src/crud/lib/import-request.ts +99 -0
  18. package/src/crud/lib/mutation-builder.test.ts +114 -0
  19. package/src/crud/lib/mutation-builder.ts +27 -34
  20. package/src/crud/lib/permissions.test.ts +57 -0
  21. package/src/crud/lib/permissions.ts +25 -13
  22. package/src/crud/lib/query-builder.ts +13 -6
  23. package/src/crud/pages/entity-crud-page.tsx +11 -8
  24. package/src/crud/server-service.test.ts +112 -0
  25. package/src/crud/server-service.ts +52 -27
  26. package/src/crud/server.ts +11 -1
  27. package/src/guardrails/rules/design.ts +3 -2
  28. package/src/types/index.ts +75 -10
  29. package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
  30. package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
  31. package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
  32. package/src/ui/data-display/data-table/data-table.tsx +68 -23
  33. package/src/ui/data-display/data-table-pagination.tsx +25 -8
  34. package/src/ui/data-display/index.tsx +1 -0
  35. package/src/ui/data-display/use-client-pagination.ts +48 -0
  36. package/templates/starter-app/AGENTS.md +1 -1
  37. package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
  38. package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
  39. package/templates/starter-app/scripts/rbac-sync.ts +49 -21
  40. package/templates/starter-app/src/__tests__/architecture.test.ts +3 -2
  41. package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +4 -4
  42. package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +1 -2
  43. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +7 -5
  44. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +20 -9
  45. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +2 -1
  46. package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
  47. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
  48. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +4 -4
  49. package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +5 -5
  50. package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
  51. package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
  52. package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
  53. package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +4 -2
  54. package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +3 -1
  55. package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +13 -5
  56. package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +3 -2
  57. package/templates/starter-app/src/app/[lang]/layout.tsx +2 -2
  58. package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
  59. package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
  60. package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
  61. package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
  62. package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
  63. package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
  64. package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
  65. package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
  66. package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
  67. package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
  68. package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
  69. package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
  70. package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
  71. package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
  72. package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
  73. package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
  74. package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
  75. package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
  76. package/templates/starter-app/src/app/api/job-titles/route.ts +1 -1
  77. package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
  78. package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
  79. package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
  80. package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
  81. package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
  82. package/templates/starter-app/src/app/api/roles/route.ts +2 -1
  83. package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +5 -5
  84. package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +1 -1
  85. package/templates/starter-app/src/app/api/security/sessions/route.ts +2 -2
  86. package/templates/starter-app/src/app/api/system-categories/route.ts +39 -14
  87. package/templates/starter-app/src/app/api/system-category-groups/route.ts +32 -14
  88. package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
  89. package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
  90. package/templates/starter-app/src/app/api/upload/route.ts +1 -2
  91. package/templates/starter-app/src/app/api/users/[id]/route.ts +20 -12
  92. package/templates/starter-app/src/app/api/users/route.ts +11 -9
  93. package/templates/starter-app/src/app/globals.css +0 -1
  94. package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
  95. package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
  96. package/templates/starter-app/src/configs/entities/index.ts +1 -1
  97. package/templates/starter-app/src/configs/entities/job-titles.config.ts +2 -0
  98. package/templates/starter-app/src/configs/entities/system-alerts.config.ts +2 -0
  99. package/templates/starter-app/src/configs/entities/users.config.ts +15 -2
  100. package/templates/starter-app/src/configs/i18n.ts +6 -6
  101. package/templates/starter-app/src/configs/permissions/menu-tree.ts +1 -1
  102. package/templates/starter-app/src/data/dictionary.ts +2 -1
  103. package/templates/starter-app/src/data/navigations.ts +12 -2
  104. package/templates/starter-app/src/lib/action-guard.ts +3 -4
  105. package/templates/starter-app/src/lib/api-handler.ts +27 -19
  106. package/templates/starter-app/src/lib/auth.ts +3 -1
  107. package/templates/starter-app/src/lib/branch-scope.ts +1 -1
  108. package/templates/starter-app/src/lib/crud/index.ts +5 -12
  109. package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
  110. package/templates/starter-app/src/lib/logger.ts +11 -4
  111. package/templates/starter-app/src/lib/page-guard.ts +2 -3
  112. package/templates/starter-app/src/lib/prisma.ts +13 -5
  113. package/templates/starter-app/src/lib/rbac/access.ts +2 -2
  114. package/templates/starter-app/src/lib/storage.ts +3 -1
  115. package/templates/starter-app/src/providers/index.tsx +2 -1
  116. package/templates/starter-app/src/providers/theme-provider.tsx +4 -3
  117. package/templates/starter-app/src/server/services/company-service.ts +4 -2
  118. package/templates/starter-app/src/server/services/notification-service.ts +6 -6
  119. package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
  120. package/templates/starter-app/src/server/services/user-service.ts +2 -2
  121. package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
  122. package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
  123. package/templates/starter-app/tsconfig.json +8 -0
  124. package/templates/starter-app/vitest.config.ts +17 -1
@@ -7,16 +7,15 @@
7
7
  // lives here so every app stops re-implementing it.
8
8
  //
9
9
  // Usage (app side):
10
- // import { createServerCrudService, getModelName } from "@goerp/core/crud/server";
10
+ // import { createServerCrudService } from "@goerp/core/crud/server";
11
11
  // import { prisma } from "@/lib/prisma";
12
- // const MODEL_MAP = { customers: "customer", "fee-schedules": "feeSchedule" };
13
- // export const crudService = createServerCrudService({
14
- // prisma,
15
- // getModelName: (e) => getModelName(e, MODEL_MAP),
16
- // });
12
+ // export const crudService = createServerCrudService({ prisma });
13
+ // Tên model Prisma khai ngay trong EntityConfig (`modelName: "feeSchedule"`),
14
+ // không cần MODEL_MAP riêng; thiếu modelName thì fallback convention getModelName.
17
15
 
18
16
  import { buildListQuery } from "./lib/query-builder";
19
17
  import { prepareMutationData } from "./lib/mutation-builder";
18
+ import { CrudRequestError } from "./lib/errors";
20
19
  import type { CrudQueryParams, CrudResponse, EntityConfig } from "../types";
21
20
  import { serializeDecimalFields } from "../utils/serialize";
22
21
 
@@ -24,14 +23,16 @@ type PrismaLike = Record<string, any>;
24
23
 
25
24
  export interface ServerCrudService {
26
25
  list(entity: string, config: EntityConfig, params: CrudQueryParams): Promise<CrudResponse>;
27
- /** `config` (tùy chọn) để áp cùng `include` như list — detail không hiện raw FK id. */
26
+ /** `config` (tùy chọn) để áp cùng `include` như list — detail không hiện raw FK id.
27
+ * Mọi method nhận id dạng chuỗi thô từ URL; entity `idKind: "int"` được ép
28
+ * Number bên trong (sai định dạng → {@link CrudRequestError} 400). */
28
29
  getById(entity: string, id: string, config?: EntityConfig): Promise<any>;
29
30
  /** `tx` (tùy chọn) để app chạy create CÙNG transaction với sinh số phiếu
30
31
  * advisory-lock (document-number yêu cầu cùng tx với insert). */
31
32
  create(entity: string, data: any, config?: EntityConfig, tx?: PrismaLike): Promise<any>;
32
33
  update(entity: string, id: string, data: any, config?: EntityConfig, tx?: PrismaLike): Promise<any>;
33
- delete(entity: string, id: string): Promise<any>;
34
- deleteMany(entity: string, ids: string[]): Promise<any>;
34
+ delete(entity: string, id: string, config?: EntityConfig): Promise<any>;
35
+ deleteMany(entity: string, ids: string[], config?: EntityConfig): Promise<any>;
35
36
  }
36
37
 
37
38
  export interface ServerCrudLogger {
@@ -79,13 +80,31 @@ const DEFAULT_SYSTEM_FIELDS = [
79
80
 
80
81
  const MAX_PAGE_SIZE = 200;
81
82
 
82
- // Default plural→model resolver: strip trailing "s", camelCase kebab. Apps with
83
- // irregular names pass a map: getModelName(entity, { "fee-schedules": "feeSchedule" }).
83
+ // Default plural→model resolver: strip trailing "s", camelCase kebab. Ưu tiên
84
+ // khai `modelName` ngay trong EntityConfig; map đây chỉ còn cho chỗ gọi cũ
85
+ // chưa có config trong tay.
84
86
  export function getModelName(entity: string, map?: Record<string, string>): string {
85
87
  if (map && map[entity]) return map[entity];
86
88
  return entity.replace(/s$/, "").replace(/-([a-z])/g, (_, c) => c.toUpperCase());
87
89
  }
88
90
 
91
+ // Ép id từ URL theo idKind của entity — "abc" vào cột Int phải trả 400 rõ
92
+ // ràng thay vì để Prisma nổ 500.
93
+ function coerceIdParam(
94
+ entity: string,
95
+ config: EntityConfig | undefined,
96
+ id: string,
97
+ ): string | number {
98
+ if (config?.idKind !== "int") return id;
99
+ const num = Number(id);
100
+ if (!Number.isInteger(num)) {
101
+ throw new CrudRequestError(
102
+ `Id không hợp lệ cho "${entity}": "${id}" (idKind=int cần số nguyên)`,
103
+ );
104
+ }
105
+ return num;
106
+ }
107
+
89
108
  // Opt-in audit-name resolver. Overwrites createdByName/updatedByName from a User
90
109
  // model. `nameField` handles schema drift (vinhhoa: "name", wu: "fullName").
91
110
  export function createAuditUserNameResolver(opts: {
@@ -130,18 +149,18 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
130
149
 
131
150
  const systemFields = deps.systemFields ?? DEFAULT_SYSTEM_FIELDS;
132
151
 
133
- const model = (entity: string, client: PrismaLike = prisma) => {
134
- const name = resolveModel(entity);
152
+ // config.modelName thắng convention/MODEL_MAP một registry duy nhất
153
+ // entity config, app không phải nuôi map song song.
154
+ const model = (entity: string, config?: EntityConfig, client: PrismaLike = prisma) => {
155
+ const name = config?.modelName ?? resolveModel(entity);
135
156
  const m = client[name];
136
157
  if (!m) throw new Error(`Prisma model not found for entity: ${entity} (${name})`);
137
158
  return m;
138
159
  };
139
160
 
140
-
141
-
142
161
  return {
143
162
  async list(entity, config, params) {
144
- const prismaModel = model(entity);
163
+ const prismaModel = model(entity, config);
145
164
 
146
165
  const {
147
166
  where,
@@ -174,12 +193,13 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
174
193
  },
175
194
 
176
195
  async getById(entity, id, config) {
177
- const prismaModel = model(entity);
196
+ const prismaModel = model(entity, config);
178
197
  // Áp cùng include như list — detail dialog hiện label relation thay raw FK id.
179
198
  const include: any = {};
180
199
  if (config?.include?.length) config.include.forEach((inc) => (include[inc] = true));
181
200
  const includeOption = Object.keys(include).length ? { include } : {};
182
- const result = await prismaModel.findUnique({ where: { id }, ...includeOption });
201
+ const where = { [config?.idField ?? "id"]: coerceIdParam(entity, config, id) };
202
+ const result = await prismaModel.findUnique({ where, ...includeOption });
183
203
  if (!result) return null;
184
204
  const serialized = serializeDecimalFields(result);
185
205
  const [resolved] = await resolveAuditNames([serialized]);
@@ -187,7 +207,7 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
187
207
  },
188
208
 
189
209
  async create(entity, data, config, tx) {
190
- const prismaModel = model(entity, tx ?? prisma);
210
+ const prismaModel = model(entity, config, tx ?? prisma);
191
211
  try {
192
212
  const prismaData = prepareMutationData({
193
213
  data,
@@ -206,7 +226,8 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
206
226
  },
207
227
 
208
228
  async update(entity, id, data, config, tx) {
209
- const prismaModel = model(entity, tx ?? prisma);
229
+ const prismaModel = model(entity, config, tx ?? prisma);
230
+ const where = { [config?.idField ?? "id"]: coerceIdParam(entity, config, id) };
210
231
  try {
211
232
  const prismaData = prepareMutationData({
212
233
  data,
@@ -216,27 +237,31 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
216
237
  relationFieldSkip: deps.relationFieldSkip,
217
238
  logger: log,
218
239
  });
219
- return serializeDecimalFields(await prismaModel.update({ where: { id }, data: prismaData }));
240
+ return serializeDecimalFields(await prismaModel.update({ where, data: prismaData }));
220
241
  } catch (error) {
221
242
  log.error(`Error updating ${entity}:`, error);
222
243
  throw error;
223
244
  }
224
245
  },
225
246
 
226
- async delete(entity, id) {
227
- const prismaModel = model(entity);
247
+ async delete(entity, id, config) {
248
+ const prismaModel = model(entity, config);
249
+ const where = { [config?.idField ?? "id"]: coerceIdParam(entity, config, id) };
228
250
  try {
229
- return await prismaModel.delete({ where: { id } });
251
+ return await prismaModel.delete({ where });
230
252
  } catch (error) {
231
253
  log.error(`Error deleting ${entity}:`, error);
232
254
  throw error;
233
255
  }
234
256
  },
235
257
 
236
- async deleteMany(entity, ids) {
237
- const prismaModel = model(entity);
258
+ async deleteMany(entity, ids, config) {
259
+ const prismaModel = model(entity, config);
260
+ // Ép cả mảng — một phần tử sai định dạng là 400 cả request, không xóa nửa vời.
261
+ const idField = config?.idField ?? "id";
262
+ const where = { [idField]: { in: ids.map((id) => coerceIdParam(entity, config, id)) } };
238
263
  try {
239
- return await prismaModel.deleteMany({ where: { id: { in: ids } } });
264
+ return await prismaModel.deleteMany({ where });
240
265
  } catch (error) {
241
266
  log.error(`Error deleting many ${entity}:`, error);
242
267
  throw error;
@@ -22,6 +22,16 @@ export type {
22
22
  export {
23
23
  createCrudCollectionHandlers,
24
24
  createCrudItemHandlers,
25
+ createCrudExportHandler,
26
+ createCrudImportHandler,
25
27
  } from './crud-route-handlers'
26
- export type { CrudHandlerDeps } from './crud-route-handlers'
28
+ export type {
29
+ CrudHandlerDeps,
30
+ CrudExportHandlerDeps,
31
+ CrudImportHandlerDeps,
32
+ } from './crud-route-handlers'
33
+ export { readImportRows, coerceBooleanCell } from './lib/import-request'
27
34
  export { compileFilterTree, conditionForOperator, type FilterTreeLeaf, type FilterTreeNode } from "./lib/filter-tree";
35
+ export { CrudRequestError } from "./lib/errors";
36
+ export { coerceFieldValue } from "./lib/coerce";
37
+ export { getEntityEndpoints } from "./lib/entity-endpoints";
@@ -72,12 +72,13 @@ export const designRules: GuardrailRule[] = [
72
72
 
73
73
  {
74
74
  id: "design/list-table-uses-kit",
75
- title: "bảng mới trong app/ dùng DataTableWrapper, không tự dựng <Table>",
75
+ title:
76
+ "bảng mới trong app/ dùng DataTable (@goerp/core/ui — import { DataTable }), không tự dựng <Table>",
76
77
  why:
77
78
  "Bảng tự dựng lại thiếu một trong: header dính, phân trang, trạng thái " +
78
79
  "rỗng, hàng bấm được, footer tổng. Mỗi bảng thiếu một thứ khác nhau, và " +
79
80
  "người dùng học lại thao tác ở mỗi trang.",
80
- fix: "Dùng `DataTableWrapper` + cột tanstack trong `*-columns.tsx`. Thật sự phải tự dựng thì thêm vào allowlist kèm lý do.",
81
+ fix: "Dùng `DataTable` (@goerp/core/ui — `import { DataTable }`) + cột tanstack trong `*-columns.tsx`. Thật sự phải tự dựng thì thêm vào allowlist kèm lý do.",
81
82
  run(ctx) {
82
83
  const allowed = new Set(ctx.options.allowlists["design/list-table-uses-kit"] ?? []);
83
84
  const handRolled = ctx.files
@@ -302,7 +302,28 @@ export interface EntityConfig {
302
302
  */
303
303
  permissionResource?: string;
304
304
  apiEndpoint: string;
305
+ /**
306
+ * Endpoint import/export. Không khai thì suy từ apiEndpoint theo CÙNG MỘT
307
+ * quy tắc: `${base}/import|/export` (giữ nguyên query params nếu có) —
308
+ * xem getEntityEndpoints ở crud/lib/entity-endpoints.
309
+ */
310
+ endpoints?: {
311
+ import?: string;
312
+ export?: string;
313
+ };
314
+ /**
315
+ * Tên model Prisma (camelCase, vd "jobTitle"). Không khai thì engine suy
316
+ * theo convention từ entity key (bỏ "s" cuối + camelCase kebab) — khai ở
317
+ * đây thay cho MODEL_MAP riêng phía app.
318
+ */
319
+ modelName?: string;
305
320
  idField: string;
321
+ /**
322
+ * Kiểu khóa chính. "string" (default): create thiếu id sẽ tự sinh UUID.
323
+ * "int": KHÔNG BAO GIỜ tự sinh (DB autoincrement), id trên URL được ép
324
+ * Number — sai định dạng trả 400.
325
+ */
326
+ idKind?: "string" | "int";
306
327
  displayField: string;
307
328
  fields: FieldConfig[];
308
329
  filters?: FilterConfig[];
@@ -345,19 +366,32 @@ export interface FormModeConfig {
345
366
  sheetSide?: "top" | "right" | "bottom" | "left";
346
367
  }
347
368
 
369
+ /** Các action built-in có icon + hành vi mặc định trong CrudPage/CrudRowActions. */
370
+ export type BuiltinRowActionKind =
371
+ | "copy"
372
+ | "duplicate"
373
+ | "view"
374
+ | "archive"
375
+ | "custom"
376
+ | "approve"
377
+ | "reject"
378
+ | "set-default"
379
+ | "view-history";
380
+
348
381
  export interface RowAction {
349
382
  label: string;
350
383
  icon?: string;
351
- action:
352
- | "copy"
353
- | "duplicate"
354
- | "view"
355
- | "archive"
356
- | "custom"
357
- | "approve"
358
- | "reject"
359
- | "set-default"
360
- | "view-history";
384
+ /**
385
+ * Built-in giữ hành vi/icon cũ; chuỗi tự do = action tự khai — đi kèm
386
+ * `urlTemplate` (dispatcher HTTP của CrudPage) hoặc handler app-side.
387
+ * `(string & {})` để vẫn gợi ý được literal built-in.
388
+ */
389
+ action: BuiltinRowActionKind | (string & {});
390
+ /**
391
+ * handler/visibleWhen/transformData KHÔNG sống sót qua serializeConfig
392
+ * (RSC → client) — chỉ dùng khi config đi thẳng vào CrudPage phía client.
393
+ * Cần action serialize an toàn thì dùng bộ urlTemplate/method bên dưới.
394
+ */
361
395
  handler?: (
362
396
  rowId: string,
363
397
  rowData: Record<string, unknown>,
@@ -372,6 +406,24 @@ export interface RowAction {
372
406
  | "link";
373
407
  excludeFields?: string[];
374
408
  transformData?: (rowData: Record<string, unknown>) => Record<string, unknown>;
409
+
410
+ // ── Biến thể HTTP thuần data (serialize qua RSC được) ──
411
+ /** URL gọi khi bấm — "{id}" được thay bằng id dòng, vd "/api/employees/{id}/reset-password". */
412
+ urlTemplate?: string;
413
+ /** Method cho urlTemplate. Default: POST. */
414
+ method?: "POST" | "PATCH" | "DELETE";
415
+ /** Có khai thì window.confirm trước khi gọi. */
416
+ confirmMessage?: string;
417
+ /** Toast khi thành công. Không khai thì dùng message "updated" chung. */
418
+ successMessage?: string;
419
+ /**
420
+ * Gate hiển thị + thực thi theo action code trong quyền. Nhận cả 8 action
421
+ * CRUD chuẩn ("update", "approve"…) lẫn CUSTOM ACTION khai trong registry
422
+ * ("reset-password", "resync"…) — EntityCrudPage tự gom các permissionAction
423
+ * custom trong rowActions và hỏi checkPermission cho từng cái. Fail-closed:
424
+ * action không có trong map quyền thì nút bị chặn.
425
+ */
426
+ permissionAction?: string;
375
427
  }
376
428
 
377
429
  // ============================================================================
@@ -402,6 +454,13 @@ export interface FieldConfig {
402
454
  name: string;
403
455
  label: string;
404
456
  type: FieldType;
457
+ /**
458
+ * Kiểu GIÁ TRỊ thô của field khi kiểu cột không suy được từ `type` — cột FK
459
+ * Int autoincrement hiện qua select/multiselect/relation: form và URL chở
460
+ * chuỗi "3", khai `valueType: "int"` để coercion tập trung (coerceFieldValue)
461
+ * ép Number trước khi vào Prisma. Default: "string" (giữ nguyên).
462
+ */
463
+ valueType?: "string" | "int";
405
464
  required?: boolean;
406
465
  defaultValue?: unknown;
407
466
  placeholder?: string;
@@ -658,6 +717,12 @@ export interface CrudPermissions {
658
717
  import?: boolean;
659
718
  approve?: boolean;
660
719
  reject?: boolean;
720
+ /**
721
+ * Custom action từ registry (vd "reset-password") — getCrudPermissions nhận
722
+ * danh sách extraActions và điền vào đây; RowAction.permissionAction tra
723
+ * thẳng map này, thiếu key = false (fail-closed).
724
+ */
725
+ [action: string]: boolean | undefined;
661
726
  }
662
727
 
663
728
  export interface CrudFeatures {
@@ -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
  }