@goplusvn/core 0.1.70 → 0.1.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/bin/goerp-init.mjs +15 -0
  3. package/package.json +2 -1
  4. package/src/auth/index.ts +5 -1
  5. package/src/auth/proxy-gate.ts +21 -2
  6. package/src/configs/entities/departments.config.ts +1 -0
  7. package/src/configs/entities/material-categories.config.ts +1 -0
  8. package/src/cron/__tests__/cron-schedule.test.ts +76 -0
  9. package/src/cron/cron-schedule.ts +128 -0
  10. package/src/cron/simple-cron-job.ts +83 -69
  11. package/src/crud/components/crud-export-button.tsx +3 -2
  12. package/src/crud/components/crud-page.tsx +56 -25
  13. package/src/crud/components/crud-row-actions.tsx +16 -4
  14. package/src/crud/components/crud-table.tsx +13 -1
  15. package/src/crud/crud-route-handlers.test.ts +352 -0
  16. package/src/crud/crud-route-handlers.ts +260 -13
  17. package/src/crud/index.ts +3 -0
  18. package/src/crud/lib/coerce.test.ts +118 -0
  19. package/src/crud/lib/coerce.ts +95 -0
  20. package/src/crud/lib/crud-utils.ts +4 -0
  21. package/src/crud/lib/entity-endpoints.test.ts +41 -0
  22. package/src/crud/lib/entity-endpoints.ts +23 -0
  23. package/src/crud/lib/errors.ts +14 -0
  24. package/src/crud/lib/import-request.ts +99 -0
  25. package/src/crud/lib/mutation-builder.test.ts +114 -0
  26. package/src/crud/lib/mutation-builder.ts +27 -34
  27. package/src/crud/lib/permissions.test.ts +57 -0
  28. package/src/crud/lib/permissions.ts +25 -13
  29. package/src/crud/lib/query-builder.ts +13 -6
  30. package/src/crud/lib/translate-config.ts +16 -2
  31. package/src/crud/pages/entity-crud-page.tsx +11 -8
  32. package/src/crud/server-service.test.ts +112 -0
  33. package/src/crud/server-service.ts +52 -27
  34. package/src/crud/server.ts +11 -1
  35. package/src/guardrails/index.ts +1 -0
  36. package/src/guardrails/rules/auth.ts +54 -1
  37. package/src/guardrails/rules/design.ts +3 -2
  38. package/src/guardrails/types.ts +17 -8
  39. package/src/providers/brand-theme.ts +20 -0
  40. package/src/rbac/pages/permission-catalog-pages.tsx +3 -1
  41. package/src/security/index.ts +2 -0
  42. package/src/security/pages/sessions-page.tsx +516 -0
  43. package/src/types/index.ts +75 -10
  44. package/src/ui/auth/sign-in-form.tsx +64 -9
  45. package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
  46. package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
  47. package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
  48. package/src/ui/data-display/data-table/data-table.tsx +68 -23
  49. package/src/ui/data-display/data-table-pagination.tsx +25 -8
  50. package/src/ui/data-display/index.tsx +1 -0
  51. package/src/ui/data-display/use-client-pagination.ts +48 -0
  52. package/src/ui/errors/error-view.tsx +164 -0
  53. package/src/ui/errors/index.ts +2 -0
  54. package/src/ui/errors/not-found-view.tsx +44 -0
  55. package/src/ui/index.tsx +1 -0
  56. package/src/ui/layout/logo.tsx +54 -19
  57. package/src/user/pages/users-client-page.tsx +12 -7
  58. package/templates/starter-app/.env.example +4 -0
  59. package/templates/starter-app/AGENTS.md +1 -1
  60. package/templates/starter-app/README.md +6 -2
  61. package/templates/starter-app/husky/pre-commit +10 -0
  62. package/templates/starter-app/package.json +27 -2
  63. package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
  64. package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
  65. package/templates/starter-app/prisma/schema/organization.prisma +20 -0
  66. package/templates/starter-app/prisma/schema/system.prisma +74 -0
  67. package/templates/starter-app/prisma/seed.ts +7 -5
  68. package/templates/starter-app/scripts/migration-new.mjs +89 -0
  69. package/templates/starter-app/scripts/rbac-sync.ts +49 -21
  70. package/templates/starter-app/src/__tests__/architecture.test.ts +11 -2
  71. package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +37 -0
  72. package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +68 -0
  73. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +33 -0
  74. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +195 -0
  75. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +29 -0
  76. package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
  77. package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
  78. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
  79. package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
  80. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +13 -3
  81. package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
  82. package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
  83. package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
  84. package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
  85. package/templates/starter-app/src/app/[lang]/(main)/security/sessions/page.tsx +24 -0
  86. package/templates/starter-app/src/app/[lang]/(main)/system-categories/page.tsx +30 -0
  87. package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +62 -0
  88. package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +41 -0
  89. package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +157 -0
  90. package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +69 -0
  91. package/templates/starter-app/src/app/[lang]/[...not-found]/page.tsx +9 -0
  92. package/templates/starter-app/src/app/[lang]/error.tsx +26 -0
  93. package/templates/starter-app/src/app/[lang]/layout.tsx +2 -2
  94. package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
  95. package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
  96. package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
  97. package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
  98. package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
  99. package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
  100. package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
  101. package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
  102. package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
  103. package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
  104. package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
  105. package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
  106. package/templates/starter-app/src/app/api/branches/route.ts +26 -0
  107. package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
  108. package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
  109. package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
  110. package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
  111. package/templates/starter-app/src/app/api/departments/route.ts +26 -0
  112. package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
  113. package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
  114. package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
  115. package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
  116. package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
  117. package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
  118. package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
  119. package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
  120. package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
  121. package/templates/starter-app/src/app/api/roles/route.ts +2 -1
  122. package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +52 -0
  123. package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +44 -0
  124. package/templates/starter-app/src/app/api/security/sessions/route.ts +101 -0
  125. package/templates/starter-app/src/app/api/suppliers/route.ts +13 -0
  126. package/templates/starter-app/src/app/api/system-categories/route.ts +179 -0
  127. package/templates/starter-app/src/app/api/system-category-groups/route.ts +149 -0
  128. package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
  129. package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
  130. package/templates/starter-app/src/app/api/upload/route.ts +1 -2
  131. package/templates/starter-app/src/app/api/users/[id]/route.ts +230 -0
  132. package/templates/starter-app/src/app/api/users/route.ts +141 -0
  133. package/templates/starter-app/src/app/global-error.tsx +135 -0
  134. package/templates/starter-app/src/app/globals.css +0 -1
  135. package/templates/starter-app/src/app/icon.svg +6 -0
  136. package/templates/starter-app/src/app/manifest.ts +26 -0
  137. package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
  138. package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
  139. package/templates/starter-app/src/configs/entities/index.ts +5 -1
  140. package/templates/starter-app/src/configs/entities/job-titles.config.ts +121 -0
  141. package/templates/starter-app/src/configs/entities/system-alerts.config.ts +93 -0
  142. package/templates/starter-app/src/configs/entities/users.config.ts +80 -0
  143. package/templates/starter-app/src/configs/i18n.ts +6 -6
  144. package/templates/starter-app/src/configs/permissions/index.ts +37 -0
  145. package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +11 -0
  146. package/templates/starter-app/src/configs/permissions/menu-tree.ts +37 -0
  147. package/templates/starter-app/src/configs/permissions/system.permissions.ts +29 -0
  148. package/templates/starter-app/src/configs/tenant.ts +2 -1
  149. package/templates/starter-app/src/data/dictionary.ts +77 -4
  150. package/templates/starter-app/src/data/navigations.ts +60 -1
  151. package/templates/starter-app/src/instrumentation.ts +5 -16
  152. package/templates/starter-app/src/lib/action-guard.ts +25 -0
  153. package/templates/starter-app/src/lib/api-handler.ts +27 -19
  154. package/templates/starter-app/src/lib/auth.ts +3 -1
  155. package/templates/starter-app/src/lib/better-auth.ts +8 -0
  156. package/templates/starter-app/src/lib/branch-scope.ts +1 -1
  157. package/templates/starter-app/src/lib/crud/index.ts +5 -10
  158. package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
  159. package/templates/starter-app/src/lib/logger.ts +11 -4
  160. package/templates/starter-app/src/lib/page-guard.ts +2 -3
  161. package/templates/starter-app/src/lib/prisma.ts +16 -7
  162. package/templates/starter-app/src/lib/rbac/access.ts +2 -2
  163. package/templates/starter-app/src/lib/storage.ts +3 -1
  164. package/templates/starter-app/src/providers/index.tsx +2 -1
  165. package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
  166. package/templates/starter-app/src/providers/theme-provider.tsx +27 -12
  167. package/templates/starter-app/src/proxy.ts +8 -3
  168. package/templates/starter-app/src/server/services/company-service.ts +49 -0
  169. package/templates/starter-app/src/server/services/notification-service.ts +6 -6
  170. package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
  171. package/templates/starter-app/src/server/services/user-service.ts +104 -0
  172. package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
  173. package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
  174. package/templates/starter-app/tsconfig.json +10 -0
  175. package/templates/starter-app/vitest.config.ts +17 -1
@@ -13,7 +13,12 @@
13
13
  // // src/app/api/crud/[entity]/[id]/route.ts
14
14
  // export const { GET, PUT, PATCH, DELETE } = createCrudItemHandlers({ getSession, getEntityConfig, service: crudService });
15
15
 
16
- import type { EntityConfig } from "../types";
16
+ import type { ActiveFilter, EntityConfig, ImportResult } from "../types";
17
+ import { toCsv } from "../export/to-csv";
18
+ import { coerceFieldValue } from "./lib/coerce";
19
+ import { CrudRequestError } from "./lib/errors";
20
+ import { exportToJSON, exportToXLSX } from "./lib/import-export-service";
21
+ import { readImportRows } from "./lib/import-request";
17
22
  import { getCrudPermissions } from "./lib/permissions";
18
23
  import type { ServerCrudService } from "./server-service";
19
24
 
@@ -42,13 +47,46 @@ function makeServiceResolver(
42
47
  }
43
48
 
44
49
  const json = (data: unknown, status = 200) =>
45
- new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
50
+ new Response(JSON.stringify(data), {
51
+ status,
52
+ headers: { "content-type": "application/json" },
53
+ });
46
54
 
47
55
  const unauthorized = () => json({ error: "Unauthorized" }, 401);
48
56
  const forbidden = () => json({ error: "Forbidden" }, 403);
49
57
  const unknownEntity = () => json({ error: "Unknown entity" }, 404);
50
58
 
51
- async function resolvePerms(session: any, config: EntityConfig, entity: string) {
59
+ // JSON hỏng trên query param lỗi CLIENT → CrudRequestError để catch chung
60
+ // trả 400 (trước đây JSON.parse ném thẳng → 500 khó hiểu).
61
+ function parseJsonParam(raw: string | null, name: string): unknown {
62
+ if (!raw) return undefined;
63
+ try {
64
+ return JSON.parse(raw);
65
+ } catch {
66
+ throw new CrudRequestError(`Query param "${name}" không phải JSON hợp lệ`);
67
+ }
68
+ }
69
+
70
+ // Ép giá trị filter theo kiểu field trong config — URL chỉ chở chuỗi, cột
71
+ // Int/Boolean/DateTime cần giá trị đúng kiểu trước khi vào Prisma.
72
+ function coerceFilters(
73
+ config: EntityConfig,
74
+ filters: unknown,
75
+ ): ActiveFilter[] | undefined {
76
+ if (!Array.isArray(filters)) return undefined;
77
+ return filters.map((filter) => {
78
+ const f = filter as ActiveFilter;
79
+ const field = config.fields.find((fd) => fd.name === f?.name);
80
+ if (!field) return f; // field lạ/dotted path: query-builder tự allowlist
81
+ return { ...f, value: coerceFieldValue(field, f.value) };
82
+ });
83
+ }
84
+
85
+ async function resolvePerms(
86
+ session: any,
87
+ config: EntityConfig,
88
+ entity: string,
89
+ ) {
52
90
  return getCrudPermissions(session, config.permissionResource ?? entity);
53
91
  }
54
92
 
@@ -56,10 +94,16 @@ async function resolvePerms(session: any, config: EntityConfig, entity: string)
56
94
  export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
57
95
  const { getSession, getEntityConfig, onError } = deps;
58
96
  const resolveService = makeServiceResolver(deps.service);
59
- const fail = (e: unknown, req: Request) =>
60
- onError ? onError(e, req) : json({ error: "Internal error" }, 500);
97
+ const fail = (e: unknown, req: Request) => {
98
+ if (e instanceof CrudRequestError)
99
+ return json({ error: e.message }, e.status);
100
+ return onError ? onError(e, req) : json({ error: "Internal error" }, 500);
101
+ };
61
102
 
62
- async function GET(req: Request, ctx: { params: Promise<{ entity: string }> }) {
103
+ async function GET(
104
+ req: Request,
105
+ ctx: { params: Promise<{ entity: string }> },
106
+ ) {
63
107
  const { entity } = await ctx.params;
64
108
  try {
65
109
  const session = await getSession();
@@ -75,9 +119,16 @@ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
75
119
  pageSize: parseInt(sp.get("pageSize") || "10", 10),
76
120
  search: sp.get("search") || undefined,
77
121
  sort: sp.get("sortField")
78
- ? { field: sp.get("sortField")!, direction: (sp.get("sortDirection") as "asc" | "desc") || "asc" }
122
+ ? {
123
+ field: sp.get("sortField")!,
124
+ direction: (sp.get("sortDirection") as "asc" | "desc") || "asc",
125
+ }
79
126
  : undefined,
80
- filters: sp.get("filters") ? JSON.parse(sp.get("filters")!) : undefined,
127
+ filters: coerceFilters(
128
+ config,
129
+ parseJsonParam(sp.get("filters"), "filters"),
130
+ ),
131
+ filterTree: parseJsonParam(sp.get("filterTree"), "filterTree"),
81
132
  };
82
133
  const service = await resolveService(entity);
83
134
  const data = await service.list(entity, config, params as any);
@@ -87,7 +138,10 @@ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
87
138
  }
88
139
  }
89
140
 
90
- async function POST(req: Request, ctx: { params: Promise<{ entity: string }> }) {
141
+ async function POST(
142
+ req: Request,
143
+ ctx: { params: Promise<{ entity: string }> },
144
+ ) {
91
145
  const { entity } = await ctx.params;
92
146
  try {
93
147
  const session = await getSession();
@@ -101,7 +155,8 @@ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
101
155
  const service = await resolveService(entity);
102
156
  if (Array.isArray(body)) {
103
157
  const results = [];
104
- for (const item of body) results.push(await service.create(entity, item, config));
158
+ for (const item of body)
159
+ results.push(await service.create(entity, item, config));
105
160
  return json(results);
106
161
  }
107
162
  return json(await service.create(entity, body, config));
@@ -117,8 +172,11 @@ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
117
172
  export function createCrudItemHandlers(deps: CrudHandlerDeps) {
118
173
  const { getSession, getEntityConfig, onError } = deps;
119
174
  const resolveService = makeServiceResolver(deps.service);
120
- const fail = (e: unknown, req: Request) =>
121
- onError ? onError(e, req) : json({ error: "Internal error" }, 500);
175
+ const fail = (e: unknown, req: Request) => {
176
+ if (e instanceof CrudRequestError)
177
+ return json({ error: e.message }, e.status);
178
+ return onError ? onError(e, req) : json({ error: "Internal error" }, 500);
179
+ };
122
180
 
123
181
  type Ctx = { params: Promise<{ entity: string; id: string }> };
124
182
 
@@ -168,7 +226,8 @@ export function createCrudItemHandlers(deps: CrudHandlerDeps) {
168
226
  const perms = await resolvePerms(session, config, entity);
169
227
  if (!perms.delete) return forbidden();
170
228
  const service = await resolveService(entity);
171
- await service.delete(entity, id);
229
+ // Truyền config để service dùng đúng idField/idKind/modelName.
230
+ await service.delete(entity, id, config);
172
231
  return new Response(null, { status: 204 });
173
232
  } catch (e) {
174
233
  return fail(e, req);
@@ -177,3 +236,191 @@ export function createCrudItemHandlers(deps: CrudHandlerDeps) {
177
236
 
178
237
  return { GET, PUT, PATCH: PUT, DELETE };
179
238
  }
239
+
240
+ // ── Export/Import generic — `/api/crud/[entity]/export|/import` ─────────────
241
+ // Nút Export/ImportDialog của CrudPage mặc định trỏ về đúng 2 URL này
242
+ // (getEntityEndpoints: `${apiEndpoint}/export|/import`) — không có handler thì
243
+ // bật feature export/import là 404 ngay cả trong template.
244
+
245
+ export interface CrudExportHandlerDeps extends CrudHandlerDeps {
246
+ /** Số dòng mỗi lần gọi service.list khi kéo trang. Default 500. */
247
+ pageSize?: number;
248
+ /** Backstop chống kéo cả bảng khổng lồ trong một request. Default 10 000. */
249
+ maxRows?: number;
250
+ }
251
+
252
+ // GET `/api/crud/[entity]/export?format=xlsx|csv|json` — tôn trọng
253
+ // search/sort/filters/filterTree đang áp trên bảng (client gửi kèm query).
254
+ export function createCrudExportHandler(deps: CrudExportHandlerDeps) {
255
+ const { getSession, getEntityConfig, onError } = deps;
256
+ const pageSize = deps.pageSize ?? 500;
257
+ const maxRows = deps.maxRows ?? 10_000;
258
+ const resolveService = makeServiceResolver(deps.service);
259
+ const fail = (e: unknown, req: Request) => {
260
+ if (e instanceof CrudRequestError)
261
+ return json({ error: e.message }, e.status);
262
+ return onError ? onError(e, req) : json({ error: "Internal error" }, 500);
263
+ };
264
+
265
+ async function GET(
266
+ req: Request,
267
+ ctx: { params: Promise<{ entity: string }> },
268
+ ) {
269
+ const { entity } = await ctx.params;
270
+ try {
271
+ const session = await getSession();
272
+ if (!session) return unauthorized();
273
+ const config = getEntityConfig(entity);
274
+ if (!config) return unknownEntity();
275
+ const perms = await resolvePerms(session, config, entity);
276
+ if (!perms.export) return forbidden();
277
+
278
+ const sp = new URL(req.url).searchParams;
279
+ const format = sp.get("format") || "xlsx";
280
+ if (format !== "xlsx" && format !== "csv" && format !== "json") {
281
+ throw new CrudRequestError(
282
+ `Format "${format}" không hỗ trợ (xlsx|csv|json)`,
283
+ );
284
+ }
285
+
286
+ const search = sp.get("search") || undefined;
287
+ const sort = sp.get("sortField")
288
+ ? {
289
+ field: sp.get("sortField")!,
290
+ direction: (sp.get("sortDirection") as "asc" | "desc") || "asc",
291
+ }
292
+ : undefined;
293
+ const filters = coerceFilters(
294
+ config,
295
+ parseJsonParam(sp.get("filters"), "filters"),
296
+ );
297
+ const filterTree = parseJsonParam(sp.get("filterTree"), "filterTree");
298
+
299
+ const service = await resolveService(entity);
300
+ const rows: Record<string, unknown>[] = [];
301
+ let page = 1;
302
+ for (;;) {
303
+ const result = await service.list(entity, config, {
304
+ page,
305
+ pageSize,
306
+ search,
307
+ sort,
308
+ filters,
309
+ filterTree,
310
+ } as any);
311
+ rows.push(...(result.data as Record<string, unknown>[]));
312
+ if (result.data.length < pageSize || rows.length >= maxRows) break;
313
+ page += 1;
314
+ }
315
+ if (rows.length > maxRows) rows.length = maxRows;
316
+
317
+ // Tên file lấy từ entity slug (ASCII) — config.name có thể chứa tiếng
318
+ // Việt, đưa thẳng vào Content-Disposition là hỏng header.
319
+ const filename = `${entity}-export-${new Date().toISOString().slice(0, 10)}`;
320
+
321
+ if (format === "json") {
322
+ return new Response(exportToJSON(rows), {
323
+ headers: {
324
+ "content-type": "application/json",
325
+ "content-disposition": `attachment; filename="${filename}.json"`,
326
+ },
327
+ });
328
+ }
329
+ if (format === "csv") {
330
+ // BOM để Excel mở đúng UTF-8 tiếng Việt.
331
+ return new Response("\uFEFF" + toCsv(rows), {
332
+ headers: {
333
+ "content-type": "text/csv; charset=utf-8",
334
+ "content-disposition": `attachment; filename="${filename}.csv"`,
335
+ },
336
+ });
337
+ }
338
+ return new Response(await exportToXLSX(rows), {
339
+ headers: {
340
+ "content-type":
341
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
342
+ "content-disposition": `attachment; filename="${filename}.xlsx"`,
343
+ },
344
+ });
345
+ } catch (e) {
346
+ return fail(e, req);
347
+ }
348
+ }
349
+
350
+ return { GET };
351
+ }
352
+
353
+ export interface CrudImportHandlerDeps extends CrudHandlerDeps {
354
+ /**
355
+ * Header legacy → tên cột theo entity (file mẫu tiếng Việt của hệ cũ…).
356
+ * Label trong EntityConfig và tên cột máy luôn được nhận sẵn, không cần khai.
357
+ */
358
+ legacyHeaders?: (entity: string) => Record<string, string> | undefined;
359
+ }
360
+
361
+ // POST `/api/crud/[entity]/import` — nhận multipart {file, format} từ
362
+ // ImportDialog của core LẪN JSON array (client tự parse). Mỗi dòng đi qua
363
+ // service.create (coercion + validate + audit của engine áp y như tạo tay);
364
+ // dòng lỗi không chặn các dòng sau, gom vào ImportResult.errors.
365
+ export function createCrudImportHandler(deps: CrudImportHandlerDeps) {
366
+ const { getSession, getEntityConfig, onError } = deps;
367
+ const resolveService = makeServiceResolver(deps.service);
368
+ const fail = (e: unknown, req: Request) => {
369
+ if (e instanceof CrudRequestError)
370
+ return json({ error: e.message }, e.status);
371
+ return onError ? onError(e, req) : json({ error: "Internal error" }, 500);
372
+ };
373
+
374
+ async function POST(
375
+ req: Request,
376
+ ctx: { params: Promise<{ entity: string }> },
377
+ ) {
378
+ const { entity } = await ctx.params;
379
+ try {
380
+ const session = await getSession();
381
+ if (!session) return unauthorized();
382
+ const config = getEntityConfig(entity);
383
+ if (!config) return unknownEntity();
384
+ const perms = await resolvePerms(session, config, entity);
385
+ if (!perms.import) return forbidden();
386
+
387
+ const rows = await readImportRows(
388
+ req,
389
+ config,
390
+ deps.legacyHeaders?.(entity),
391
+ );
392
+ if (!rows) {
393
+ throw new CrudRequestError(
394
+ "Body import không hợp lệ — cần file (multipart) hoặc JSON array",
395
+ );
396
+ }
397
+
398
+ const service = await resolveService(entity);
399
+ const result: ImportResult = {
400
+ success: true,
401
+ imported: 0,
402
+ failed: 0,
403
+ errors: [],
404
+ };
405
+ for (const [index, row] of rows.entries()) {
406
+ try {
407
+ await service.create(entity, row, config);
408
+ result.imported += 1;
409
+ } catch (e) {
410
+ result.failed += 1;
411
+ result.errors.push({
412
+ row: index + 1,
413
+ field: "",
414
+ message: e instanceof Error ? e.message : String(e),
415
+ });
416
+ }
417
+ }
418
+ result.success = result.failed === 0;
419
+ return json(result);
420
+ } catch (e) {
421
+ return fail(e, req);
422
+ }
423
+ }
424
+
425
+ return { POST };
426
+ }
package/src/crud/index.ts CHANGED
@@ -28,8 +28,11 @@ export * from "./components/crud-virtual-table";
28
28
  export { EntityCrudPage } from "./pages/entity-crud-page";
29
29
 
30
30
  // Re-export lib logic
31
+ export * from "./lib/coerce";
31
32
  export * from "./lib/crud-service";
32
33
  export * from "./lib/crud-utils";
34
+ export * from "./lib/entity-endpoints";
35
+ export * from "./lib/errors";
33
36
  export * from "./lib/crud-validator";
34
37
  export * from "./lib/data-loader";
35
38
  export * from "./lib/field-calculator";
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { coerceFieldValue } from "./coerce";
3
+ import type { FieldConfig } from "../../types";
4
+
5
+ const field = (over: Partial<FieldConfig>): FieldConfig => ({
6
+ name: "f",
7
+ label: "F",
8
+ type: "text",
9
+ ...over,
10
+ });
11
+
12
+ describe("coerceFieldValue", () => {
13
+ it("giữ nguyên undefined/null", () => {
14
+ const f = field({ type: "number" });
15
+ expect(coerceFieldValue(f, undefined)).toBeUndefined();
16
+ expect(coerceFieldValue(f, null)).toBeNull();
17
+ });
18
+
19
+ it("number: chuỗi số → number, chuỗi rỗng → null, chuỗi rác giữ nguyên", () => {
20
+ const f = field({ type: "number" });
21
+ expect(coerceFieldValue(f, "5")).toBe(5);
22
+ expect(coerceFieldValue(f, "5.5")).toBe(5.5);
23
+ expect(coerceFieldValue(f, "")).toBeNull();
24
+ expect(coerceFieldValue(f, " ")).toBeNull();
25
+ expect(coerceFieldValue(f, "abc")).toBe("abc");
26
+ expect(coerceFieldValue(f, 7)).toBe(7);
27
+ });
28
+
29
+ it("boolean: các chuỗi truthy quy ước → true", () => {
30
+ const f = field({ type: "boolean" });
31
+ expect(coerceFieldValue(f, "true")).toBe(true);
32
+ expect(coerceFieldValue(f, "active")).toBe(true);
33
+ expect(coerceFieldValue(f, "1")).toBe(true);
34
+ expect(coerceFieldValue(f, "on")).toBe(true);
35
+ expect(coerceFieldValue(f, "false")).toBe(false);
36
+ expect(coerceFieldValue(f, "inactive")).toBe(false);
37
+ });
38
+
39
+ it("switch có cặp option → map về value của option, không phải boolean", () => {
40
+ const f = field({
41
+ type: "switch",
42
+ options: [
43
+ { label: "Hoạt động", value: "active" },
44
+ { label: "Vô hiệu", value: "inactive" },
45
+ ],
46
+ });
47
+ expect(coerceFieldValue(f, "active")).toBe("active");
48
+ expect(coerceFieldValue(f, "inactive")).toBe("inactive");
49
+ expect(coerceFieldValue(f, true)).toBe("active");
50
+ expect(coerceFieldValue(f, false)).toBe("inactive");
51
+ });
52
+
53
+ it("date/datetime: chuỗi ISO → Date, chuỗi rỗng → null, chuỗi rác giữ nguyên", () => {
54
+ const f = field({ type: "datetime" });
55
+ const out = coerceFieldValue(f, "2026-01-02T00:00:00.000Z");
56
+ expect(out).toBeInstanceOf(Date);
57
+ expect((out as Date).toISOString()).toBe("2026-01-02T00:00:00.000Z");
58
+ expect(coerceFieldValue(f, "")).toBeNull();
59
+ expect(coerceFieldValue(f, "not-a-date")).toBe("not-a-date");
60
+ });
61
+
62
+ it('FK: "" → undefined cho relation và *Id dạng select', () => {
63
+ expect(
64
+ coerceFieldValue(field({ name: "department", type: "relation" }), ""),
65
+ ).toBeUndefined();
66
+ expect(
67
+ coerceFieldValue(field({ name: "departmentId", type: "select" }), ""),
68
+ ).toBeUndefined();
69
+ });
70
+
71
+ it('*Id scalar (kiểu text, không phải FK) giữ nguyên ""', () => {
72
+ expect(
73
+ coerceFieldValue(field({ name: "citizenId", type: "text" }), ""),
74
+ ).toBe("");
75
+ });
76
+
77
+ it("mảng: ép từng phần tử (giá trị filter in/notIn)", () => {
78
+ const f = field({ type: "integer" });
79
+ expect(coerceFieldValue(f, ["1", "2", 3])).toEqual([1, 2, 3]);
80
+ });
81
+
82
+ it('FK snake_case: "" → undefined cho *_id/*_ids dạng select', () => {
83
+ expect(
84
+ coerceFieldValue(field({ name: "meal_time_id", type: "select" }), ""),
85
+ ).toBeUndefined();
86
+ expect(
87
+ coerceFieldValue(
88
+ field({ name: "default_meal_time_ids", type: "multiselect" }),
89
+ "",
90
+ ),
91
+ ).toBeUndefined();
92
+ });
93
+
94
+ it('valueType "int": select/multiselect chở chuỗi → number (FK Int autoincrement)', () => {
95
+ const f = field({
96
+ name: "meal_time_id",
97
+ type: "select",
98
+ valueType: "int",
99
+ });
100
+ expect(coerceFieldValue(f, "3")).toBe(3);
101
+ expect(coerceFieldValue(f, 3)).toBe(3);
102
+ expect(coerceFieldValue(f, "")).toBeUndefined(); // FK "" = bỏ trống
103
+ expect(coerceFieldValue(f, "abc")).toBe("abc"); // rác giữ nguyên cho validate
104
+
105
+ const multi = field({
106
+ name: "default_meal_time_ids",
107
+ type: "multiselect",
108
+ valueType: "int",
109
+ });
110
+ expect(coerceFieldValue(multi, ["1", "2", 3])).toEqual([1, 2, 3]);
111
+ });
112
+
113
+ it('valueType "int" trên select KHÔNG phải FK: "" → null (cột Int nullable)', () => {
114
+ const f = field({ name: "level", type: "select", valueType: "int" });
115
+ expect(coerceFieldValue(f, "2")).toBe(2);
116
+ expect(coerceFieldValue(f, "")).toBeNull();
117
+ });
118
+ });
@@ -0,0 +1,95 @@
1
+ import type { FieldConfig } from "../../types";
2
+
3
+ function optionValue(option: unknown): unknown {
4
+ return typeof option === "object" && option !== null && "value" in option
5
+ ? (option as { value: unknown }).value
6
+ : option;
7
+ }
8
+
9
+ /**
10
+ * Ép kiểu MỘT giá trị theo FieldConfig — điểm coercion tập trung, dùng chung
11
+ * cho body create/update (mutation-builder) và giá trị filter từ query string
12
+ * (route-handlers). Client gửi chuỗi (form/URL) là chuyện thường; không ép thì
13
+ * Prisma nổ type mismatch với cột Int/Boolean/DateTime.
14
+ *
15
+ * Trả `undefined` nghĩa là "coi như bỏ trống" — với cột FK, chuỗi rỗng ""
16
+ * phải thành undefined chứ không được lọt xuống DB (nổ P2003).
17
+ */
18
+ export function coerceFieldValue(field: FieldConfig, value: unknown): unknown {
19
+ if (value === undefined || value === null) return value;
20
+ if (Array.isArray(value)) {
21
+ return value.map((v) => coerceFieldValue(field, v));
22
+ }
23
+
24
+ // BẪY empty-string FK: "" vào cột FK là "bỏ trống", không phải giá trị —
25
+ // trả undefined để mutation disconnect/bỏ key, filter thì bỏ qua điều kiện.
26
+ // CHỈ áp cho field thực sự là FK (relation, hoặc *Id/*_id dạng select) —
27
+ // nhận CẢ HAI convention camelCase (`branchId`) và snake_case
28
+ // (`meal_time_id`, `default_meal_time_ids`); *Id scalar kiểu text
29
+ // (citizenId, targetId…) giữ nguyên "".
30
+ const fkName =
31
+ (field.name.endsWith("Id") && field.name.length > 2) ||
32
+ /_ids?$/.test(field.name);
33
+ const isForeignKey =
34
+ field.type === "relation" ||
35
+ Boolean(field.relation) ||
36
+ (fkName && (field.type === "select" || field.type === "multiselect"));
37
+ if (value === "" && isForeignKey) {
38
+ return undefined;
39
+ }
40
+
41
+ // Cột Int đứng sau select/multiselect/relation (FK autoincrement): `type`
42
+ // không nói lên kiểu cột nên phải khai `valueType: "int"` trong FieldConfig.
43
+ // Chuỗi không phải số giữ nguyên cho tầng validate/Prisma báo.
44
+ if (field.valueType === "int" && typeof value === "string") {
45
+ if (value.trim() === "") return null;
46
+ const num = Number(value);
47
+ return Number.isNaN(num) ? value : num;
48
+ }
49
+
50
+ switch (field.type) {
51
+ case "number":
52
+ case "integer": {
53
+ if (typeof value !== "string") return value;
54
+ if (value.trim() === "") return null;
55
+ const num = Number(value);
56
+ // Chuỗi không phải số: giữ nguyên cho tầng validate/Prisma báo, không âm thầm NaN.
57
+ return Number.isNaN(num) ? value : num;
58
+ }
59
+
60
+ case "boolean":
61
+ case "switch": {
62
+ let isTrue: boolean;
63
+ if (typeof value === "string") {
64
+ const lv = value.toLowerCase();
65
+ isTrue =
66
+ lv === "true" || lv === "active" || value === "1" || value === "on";
67
+ } else {
68
+ isTrue = Boolean(value);
69
+ }
70
+ // switch có cặp option = cột string 2 trạng thái (active/inactive…),
71
+ // map về value của option chứ không phải boolean.
72
+ if (
73
+ field.type === "switch" &&
74
+ field.options &&
75
+ field.options.length >= 2
76
+ ) {
77
+ return isTrue
78
+ ? optionValue(field.options[0])
79
+ : optionValue(field.options[1]);
80
+ }
81
+ return isTrue;
82
+ }
83
+
84
+ case "date":
85
+ case "datetime": {
86
+ if (typeof value !== "string") return value;
87
+ if (value.trim() === "") return null;
88
+ const date = new Date(value);
89
+ return Number.isNaN(date.getTime()) ? value : date;
90
+ }
91
+
92
+ default:
93
+ return value;
94
+ }
95
+ }
@@ -31,6 +31,10 @@ export function buildQueryString(params: CrudQueryParams): string {
31
31
  searchParams.append("filters", JSON.stringify(params.filters));
32
32
  }
33
33
 
34
+ if (params.filterTree) {
35
+ searchParams.append("filterTree", JSON.stringify(params.filterTree));
36
+ }
37
+
34
38
  return searchParams.toString();
35
39
  }
36
40
 
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { getEntityEndpoints } from "./entity-endpoints";
3
+ import type { EntityConfig } from "../../types";
4
+
5
+ const base: EntityConfig = {
6
+ name: "job-title",
7
+ label: "Chức danh",
8
+ pluralLabel: "Chức danh",
9
+ apiEndpoint: "/api/crud/job-titles",
10
+ idField: "id",
11
+ displayField: "name",
12
+ fields: [],
13
+ };
14
+
15
+ describe("getEntityEndpoints", () => {
16
+ it("default: suy từ apiEndpoint theo cùng một quy tắc", () => {
17
+ expect(getEntityEndpoints(base)).toEqual({
18
+ import: "/api/crud/job-titles/import",
19
+ export: "/api/crud/job-titles/export",
20
+ });
21
+ });
22
+
23
+ it("apiEndpoint có query params: giữ query sau path import/export", () => {
24
+ const config = { ...base, apiEndpoint: "/api/stocks?type=in" };
25
+ expect(getEntityEndpoints(config)).toEqual({
26
+ import: "/api/stocks/import?type=in",
27
+ export: "/api/stocks/export?type=in",
28
+ });
29
+ });
30
+
31
+ it("config.endpoints override từng endpoint riêng lẻ", () => {
32
+ const config = {
33
+ ...base,
34
+ endpoints: { export: "/api/reports/job-titles/xlsx" },
35
+ };
36
+ expect(getEntityEndpoints(config)).toEqual({
37
+ import: "/api/crud/job-titles/import",
38
+ export: "/api/reports/job-titles/xlsx",
39
+ });
40
+ });
41
+ });
@@ -0,0 +1,23 @@
1
+ import type { EntityConfig } from "../../types";
2
+
3
+ /**
4
+ * Endpoint import/export của entity — MỘT quy tắc duy nhất cho cả ImportDialog
5
+ * lẫn CrudExportButton (trước đây hai nơi tự build hai kiểu khác nhau → 404).
6
+ *
7
+ * Default: `${base}/import|/export` với base = apiEndpoint bỏ query string,
8
+ * query params (nếu có) được giữ lại nối sau path. Override từng cái qua
9
+ * `config.endpoints`.
10
+ */
11
+ export function getEntityEndpoints(config: EntityConfig): {
12
+ import: string;
13
+ export: string;
14
+ } {
15
+ const [base, query] = config.apiEndpoint.split("?");
16
+ const derive = (suffix: string) =>
17
+ `${base}/${suffix}${query ? `?${query}` : ""}`;
18
+
19
+ return {
20
+ import: config.endpoints?.import ?? derive("import"),
21
+ export: config.endpoints?.export ?? derive("export"),
22
+ };
23
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Lỗi request CRUD do client gửi sai (id sai kiểu, filters/filterTree hỏng…).
3
+ * Route-handler bắt instanceof để trả đúng status (mặc định 400) thay vì rơi
4
+ * xuống onError/500 chung.
5
+ */
6
+ export class CrudRequestError extends Error {
7
+ readonly status: number;
8
+
9
+ constructor(message: string, status = 400) {
10
+ super(message);
11
+ this.name = "CrudRequestError";
12
+ this.status = status;
13
+ }
14
+ }