@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.
- package/package.json +2 -1
- package/src/auth/index.ts +5 -1
- package/src/configs/entities/departments.config.ts +1 -0
- package/src/configs/entities/material-categories.config.ts +1 -0
- package/src/crud/components/crud-export-button.tsx +3 -2
- package/src/crud/components/crud-page.tsx +56 -25
- package/src/crud/components/crud-row-actions.tsx +16 -4
- package/src/crud/crud-route-handlers.test.ts +352 -0
- package/src/crud/crud-route-handlers.ts +260 -13
- package/src/crud/index.ts +3 -0
- package/src/crud/lib/coerce.test.ts +118 -0
- package/src/crud/lib/coerce.ts +95 -0
- package/src/crud/lib/crud-utils.ts +4 -0
- package/src/crud/lib/entity-endpoints.test.ts +41 -0
- package/src/crud/lib/entity-endpoints.ts +23 -0
- package/src/crud/lib/errors.ts +14 -0
- package/src/crud/lib/import-request.ts +99 -0
- package/src/crud/lib/mutation-builder.test.ts +114 -0
- package/src/crud/lib/mutation-builder.ts +27 -34
- package/src/crud/lib/permissions.test.ts +57 -0
- package/src/crud/lib/permissions.ts +25 -13
- package/src/crud/lib/query-builder.ts +13 -6
- package/src/crud/pages/entity-crud-page.tsx +11 -8
- package/src/crud/server-service.test.ts +112 -0
- package/src/crud/server-service.ts +52 -27
- package/src/crud/server.ts +11 -1
- package/src/guardrails/rules/design.ts +3 -2
- package/src/types/index.ts +75 -10
- package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
- package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
- package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
- package/src/ui/data-display/data-table/data-table.tsx +68 -23
- package/src/ui/data-display/data-table-pagination.tsx +25 -8
- package/src/ui/data-display/index.tsx +1 -0
- package/src/ui/data-display/use-client-pagination.ts +48 -0
- package/templates/starter-app/AGENTS.md +1 -1
- package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
- package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
- package/templates/starter-app/scripts/rbac-sync.ts +49 -21
- package/templates/starter-app/src/__tests__/architecture.test.ts +3 -2
- package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +4 -4
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +1 -2
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +7 -5
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +20 -9
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +4 -4
- package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +5 -5
- package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
- package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +3 -1
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +13 -5
- package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +3 -2
- package/templates/starter-app/src/app/[lang]/layout.tsx +2 -2
- package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
- package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
- package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
- package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
- package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
- package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
- package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
- package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
- package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
- package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
- package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
- package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
- package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
- package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
- package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
- package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
- package/templates/starter-app/src/app/api/job-titles/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
- package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
- package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
- package/templates/starter-app/src/app/api/roles/route.ts +2 -1
- package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +5 -5
- package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +1 -1
- package/templates/starter-app/src/app/api/security/sessions/route.ts +2 -2
- package/templates/starter-app/src/app/api/system-categories/route.ts +39 -14
- package/templates/starter-app/src/app/api/system-category-groups/route.ts +32 -14
- package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
- package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
- package/templates/starter-app/src/app/api/upload/route.ts +1 -2
- package/templates/starter-app/src/app/api/users/[id]/route.ts +20 -12
- package/templates/starter-app/src/app/api/users/route.ts +11 -9
- package/templates/starter-app/src/app/globals.css +0 -1
- package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
- package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
- package/templates/starter-app/src/configs/entities/index.ts +1 -1
- package/templates/starter-app/src/configs/entities/job-titles.config.ts +2 -0
- package/templates/starter-app/src/configs/entities/system-alerts.config.ts +2 -0
- package/templates/starter-app/src/configs/entities/users.config.ts +15 -2
- package/templates/starter-app/src/configs/i18n.ts +6 -6
- package/templates/starter-app/src/configs/permissions/menu-tree.ts +1 -1
- package/templates/starter-app/src/data/dictionary.ts +2 -1
- package/templates/starter-app/src/data/navigations.ts +12 -2
- package/templates/starter-app/src/lib/action-guard.ts +3 -4
- package/templates/starter-app/src/lib/api-handler.ts +27 -19
- package/templates/starter-app/src/lib/auth.ts +3 -1
- package/templates/starter-app/src/lib/branch-scope.ts +1 -1
- package/templates/starter-app/src/lib/crud/index.ts +5 -12
- package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
- package/templates/starter-app/src/lib/logger.ts +11 -4
- package/templates/starter-app/src/lib/page-guard.ts +2 -3
- package/templates/starter-app/src/lib/prisma.ts +13 -5
- package/templates/starter-app/src/lib/rbac/access.ts +2 -2
- package/templates/starter-app/src/lib/storage.ts +3 -1
- package/templates/starter-app/src/providers/index.tsx +2 -1
- package/templates/starter-app/src/providers/theme-provider.tsx +4 -3
- package/templates/starter-app/src/server/services/company-service.ts +4 -2
- package/templates/starter-app/src/server/services/notification-service.ts +6 -6
- package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
- package/templates/starter-app/src/server/services/user-service.ts +2 -2
- package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
- package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
- package/templates/starter-app/tsconfig.json +8 -0
- package/templates/starter-app/vitest.config.ts +17 -1
|
@@ -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
|
|
10
|
+
// import { createServerCrudService } from "@goerp/core/crud/server";
|
|
11
11
|
// import { prisma } from "@/lib/prisma";
|
|
12
|
-
// const
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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.
|
|
83
|
-
//
|
|
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
|
-
|
|
134
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
264
|
+
return await prismaModel.deleteMany({ where });
|
|
240
265
|
} catch (error) {
|
|
241
266
|
log.error(`Error deleting many ${entity}:`, error);
|
|
242
267
|
throw error;
|
package/src/crud/server.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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 `
|
|
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
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
356
|
-
|
|
370
|
+
pagination: {
|
|
371
|
+
// page của core 1-based, pageIndex của tanstack 0-based
|
|
372
|
+
pageIndex: pagination.page - 1,
|
|
373
|
+
pageSize: pagination.pageSize,
|
|
374
|
+
},
|
|
357
375
|
}
|
|
358
|
-
:
|
|
376
|
+
: {}),
|
|
359
377
|
},
|
|
360
378
|
onPaginationChange: handlePaginationChange,
|
|
361
379
|
manualPagination: !!pagination,
|
|
@@ -442,16 +460,30 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
442
460
|
/>
|
|
443
461
|
</>
|
|
444
462
|
) : rows.length > 0 ? (
|
|
445
|
-
rows.map((row) =>
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
row
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
463
|
+
rows.map((row) =>
|
|
464
|
+
rowMemo === false ? (
|
|
465
|
+
// rowMemo={false}: render thẳng, không memo — hàng theo kịp mọi
|
|
466
|
+
// state ngoài row data (Input controlled, cờ busy per-cell).
|
|
467
|
+
<DataTableRowInner
|
|
468
|
+
key={row.id}
|
|
469
|
+
row={row}
|
|
470
|
+
isSelected={row.getIsSelected()}
|
|
471
|
+
visibleCellsCount={row.getVisibleCells().length}
|
|
472
|
+
onRowClick={onRowClick}
|
|
473
|
+
cellClassName={cellClassName}
|
|
474
|
+
/>
|
|
475
|
+
) : (
|
|
476
|
+
<MemoizedTableRow
|
|
477
|
+
key={row.id}
|
|
478
|
+
row={row}
|
|
479
|
+
isSelected={row.getIsSelected()}
|
|
480
|
+
visibleCellsCount={row.getVisibleCells().length}
|
|
481
|
+
onRowClick={onRowClick}
|
|
482
|
+
cellClassName={cellClassName}
|
|
483
|
+
memoKey={rowMemo ? rowMemo(row.original) : undefined}
|
|
484
|
+
/>
|
|
485
|
+
),
|
|
486
|
+
)
|
|
455
487
|
) : null}
|
|
456
488
|
</TableBody>
|
|
457
489
|
</table>
|
|
@@ -504,14 +536,20 @@ interface MemoizedTableRowProps<TData> {
|
|
|
504
536
|
visibleCellsCount: number;
|
|
505
537
|
onRowClick?: (row: TData) => void;
|
|
506
538
|
cellClassName?: string;
|
|
539
|
+
/** Key từ `rowMemo(row.original)` — comparator so bằng Object.is. */
|
|
540
|
+
memoKey?: unknown;
|
|
507
541
|
}
|
|
508
542
|
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
543
|
+
// Thân hàng KHÔNG memo — dùng trực tiếp khi rowMemo={false}, và là inner của
|
|
544
|
+
// MemoizedTableRow ở dưới.
|
|
545
|
+
function DataTableRowInner<TData>({
|
|
546
|
+
row,
|
|
547
|
+
isSelected,
|
|
548
|
+
onRowClick,
|
|
549
|
+
cellClassName,
|
|
550
|
+
}: MemoizedTableRowProps<TData>) {
|
|
551
|
+
return (
|
|
552
|
+
<TableRow
|
|
515
553
|
data-state={isSelected && "selected"}
|
|
516
554
|
className={`transition-colors duration-100 even:bg-muted/30 hover:bg-accent/50 dark:hover:bg-slate-800/40 data-[state=selected]:bg-primary/5 border-b border-border/40 dark:border-slate-800 relative hover:border-l-[3px] hover:border-l-primary ${
|
|
517
555
|
onRowClick ? "cursor-pointer" : ""
|
|
@@ -549,8 +587,11 @@ const MemoizedTableRow = memo(
|
|
|
549
587
|
</TableCell>
|
|
550
588
|
))}
|
|
551
589
|
</TableRow>
|
|
552
|
-
|
|
553
|
-
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const MemoizedTableRow = memo(
|
|
594
|
+
DataTableRowInner,
|
|
554
595
|
(prevProps, nextProps) => {
|
|
555
596
|
// Basic checks
|
|
556
597
|
if (
|
|
@@ -558,7 +599,11 @@ const MemoizedTableRow = memo(
|
|
|
558
599
|
prevProps.isSelected !== nextProps.isSelected ||
|
|
559
600
|
prevProps.onRowClick !== nextProps.onRowClick ||
|
|
560
601
|
prevProps.visibleCellsCount !== nextProps.visibleCellsCount ||
|
|
561
|
-
prevProps.row.original !== nextProps.row.original
|
|
602
|
+
prevProps.row.original !== nextProps.row.original ||
|
|
603
|
+
// cellClassName đổi phải re-render (trước đây bị bỏ sót — đổi class không ăn)
|
|
604
|
+
prevProps.cellClassName !== nextProps.cellClassName ||
|
|
605
|
+
// Key từ rowMemo(row.original): state ngoài row data mà cell phụ thuộc
|
|
606
|
+
!Object.is(prevProps.memoKey, nextProps.memoKey)
|
|
562
607
|
) {
|
|
563
608
|
return false;
|
|
564
609
|
}
|