@goplusvn/core 0.1.71 → 0.1.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/auth/index.ts +5 -1
- package/src/branding/__tests__/identity.test.ts +101 -0
- package/src/branding/__tests__/logo-route.test.ts +143 -0
- package/src/branding/identity.ts +74 -0
- package/src/branding/index.ts +36 -0
- package/src/branding/logo-route.ts +249 -0
- package/src/branding/pwa.tsx +146 -0
- package/src/branding/types.ts +45 -0
- package/src/configs/entities/departments.config.ts +1 -0
- package/src/configs/entities/material-categories.config.ts +1 -0
- package/src/crud/components/crud-export-button.tsx +3 -2
- package/src/crud/components/crud-page.tsx +56 -25
- package/src/crud/components/crud-row-actions.tsx +16 -4
- package/src/crud/crud-route-handlers.test.ts +352 -0
- package/src/crud/crud-route-handlers.ts +260 -13
- package/src/crud/index.ts +3 -0
- package/src/crud/lib/coerce.test.ts +118 -0
- package/src/crud/lib/coerce.ts +95 -0
- package/src/crud/lib/crud-utils.ts +4 -0
- package/src/crud/lib/entity-endpoints.test.ts +41 -0
- package/src/crud/lib/entity-endpoints.ts +23 -0
- package/src/crud/lib/errors.ts +14 -0
- package/src/crud/lib/import-request.ts +99 -0
- package/src/crud/lib/mutation-builder.test.ts +114 -0
- package/src/crud/lib/mutation-builder.ts +27 -34
- package/src/crud/lib/permissions.test.ts +57 -0
- package/src/crud/lib/permissions.ts +25 -13
- package/src/crud/lib/query-builder.ts +13 -6
- package/src/crud/pages/entity-crud-page.tsx +11 -8
- package/src/crud/server-service.test.ts +112 -0
- package/src/crud/server-service.ts +52 -27
- package/src/crud/server.ts +11 -1
- package/src/guardrails/rules/design.ts +3 -2
- package/src/styles/base.css +16 -0
- package/src/types/index.ts +75 -10
- package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
- package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
- package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
- package/src/ui/data-display/data-table/data-table.tsx +68 -23
- package/src/ui/data-display/data-table-pagination.tsx +25 -8
- package/src/ui/data-display/index.tsx +1 -0
- package/src/ui/data-display/use-client-pagination.ts +48 -0
- package/src/ui/errors/error-view.tsx +80 -87
- package/src/ui/errors/index.ts +6 -1
- package/src/ui/errors/not-found-view.tsx +89 -16
- package/src/ui/errors/status-page.tsx +132 -0
- package/src/ui/layout/logo.tsx +5 -0
- package/src/ui/pages/not-found.tsx +11 -27
- package/templates/starter-app/AGENTS.md +1 -1
- package/templates/starter-app/next.config.mjs +8 -0
- package/templates/starter-app/package.json +1 -0
- package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
- package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
- package/templates/starter-app/public/images/illustrations/characters/character-01.svg +33 -0
- package/templates/starter-app/public/images/illustrations/characters/character-02.svg +30 -0
- package/templates/starter-app/public/images/illustrations/characters/character-03.svg +30 -0
- package/templates/starter-app/public/images/illustrations/characters/character-04.svg +27 -0
- package/templates/starter-app/public/images/logos/goeat_logo.png +0 -0
- package/templates/starter-app/scripts/rbac-sync.ts +49 -21
- package/templates/starter-app/src/__tests__/architecture.test.ts +3 -2
- package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +4 -4
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +1 -2
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +7 -5
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +38 -11
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +4 -4
- package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +5 -5
- package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
- package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +3 -1
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +13 -5
- package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +3 -2
- package/templates/starter-app/src/app/[lang]/layout.tsx +27 -2
- package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
- package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
- package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
- package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
- package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
- package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
- package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
- package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
- package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
- package/templates/starter-app/src/app/api/branding/logo/route.ts +25 -0
- package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
- package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
- package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
- package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
- package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
- package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
- package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
- package/templates/starter-app/src/app/api/job-titles/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
- package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
- package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
- package/templates/starter-app/src/app/api/roles/route.ts +2 -1
- package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +5 -5
- package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +1 -1
- package/templates/starter-app/src/app/api/security/sessions/route.ts +2 -2
- package/templates/starter-app/src/app/api/system-categories/route.ts +39 -14
- package/templates/starter-app/src/app/api/system-category-groups/route.ts +32 -14
- package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
- package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
- package/templates/starter-app/src/app/api/upload/route.ts +1 -2
- package/templates/starter-app/src/app/api/users/[id]/route.ts +20 -12
- package/templates/starter-app/src/app/api/users/route.ts +11 -9
- package/templates/starter-app/src/app/globals.css +0 -1
- package/templates/starter-app/src/app/layout.tsx +42 -1
- package/templates/starter-app/src/app/manifest.ts +23 -19
- package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
- package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
- package/templates/starter-app/src/configs/entities/index.ts +1 -1
- package/templates/starter-app/src/configs/entities/job-titles.config.ts +2 -0
- package/templates/starter-app/src/configs/entities/system-alerts.config.ts +2 -0
- package/templates/starter-app/src/configs/entities/users.config.ts +15 -2
- package/templates/starter-app/src/configs/i18n.ts +6 -6
- package/templates/starter-app/src/configs/permissions/menu-tree.ts +1 -1
- package/templates/starter-app/src/configs/tenant.ts +6 -1
- package/templates/starter-app/src/data/dictionary.ts +2 -1
- package/templates/starter-app/src/data/navigations.ts +12 -2
- package/templates/starter-app/src/lib/action-guard.ts +3 -4
- package/templates/starter-app/src/lib/api-handler.ts +27 -19
- package/templates/starter-app/src/lib/auth.ts +3 -1
- package/templates/starter-app/src/lib/branch-scope.ts +1 -1
- package/templates/starter-app/src/lib/crud/index.ts +5 -12
- package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
- package/templates/starter-app/src/lib/logger.ts +11 -4
- package/templates/starter-app/src/lib/page-guard.ts +2 -3
- package/templates/starter-app/src/lib/prisma.ts +13 -5
- package/templates/starter-app/src/lib/rbac/access.ts +2 -2
- package/templates/starter-app/src/lib/storage.ts +3 -1
- package/templates/starter-app/src/providers/index.tsx +13 -2
- package/templates/starter-app/src/providers/theme-provider.tsx +4 -3
- package/templates/starter-app/src/proxy.ts +3 -1
- package/templates/starter-app/src/server/services/company-service.ts +4 -2
- package/templates/starter-app/src/server/services/notification-service.ts +6 -6
- package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
- package/templates/starter-app/src/server/services/user-service.ts +2 -2
- package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
- package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
- package/templates/starter-app/tsconfig.json +8 -0
- package/templates/starter-app/vitest.config.ts +17 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { getCrudPermissions, mergePermissions } from "./permissions";
|
|
3
|
+
import type { Session } from "../../types";
|
|
4
|
+
|
|
5
|
+
// Session non-admin với permissionMap gọn (resource → action codes) — đúng
|
|
6
|
+
// dạng session callback nạp vào JWT.
|
|
7
|
+
const staff = {
|
|
8
|
+
user: {
|
|
9
|
+
id: "u1",
|
|
10
|
+
permissionMap: { employee: ["view", "update", "reset-password"] },
|
|
11
|
+
},
|
|
12
|
+
} as unknown as Session;
|
|
13
|
+
|
|
14
|
+
const admin = {
|
|
15
|
+
user: { id: "a1", roles: ["SUPER_ADMIN"] },
|
|
16
|
+
} as unknown as Session;
|
|
17
|
+
|
|
18
|
+
describe("getCrudPermissions + extraActions (custom action từ registry)", () => {
|
|
19
|
+
it("điền custom action theo checkPermission, thiếu quyền = false", async () => {
|
|
20
|
+
const perms = await getCrudPermissions(staff, "employee", [
|
|
21
|
+
"reset-password",
|
|
22
|
+
"resync",
|
|
23
|
+
]);
|
|
24
|
+
expect(perms.read).toBe(true);
|
|
25
|
+
expect(perms.update).toBe(true);
|
|
26
|
+
expect(perms.create).toBe(false);
|
|
27
|
+
expect(perms["reset-password"]).toBe(true);
|
|
28
|
+
expect(perms["resync"]).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("admin có mọi custom action", async () => {
|
|
32
|
+
const perms = await getCrudPermissions(admin, "employee", ["resync"]);
|
|
33
|
+
expect(perms["resync"]).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("extraActions trùng 8 action chuẩn không ghi đè kết quả chuẩn", async () => {
|
|
37
|
+
const perms = await getCrudPermissions(staff, "employee", ["update"]);
|
|
38
|
+
expect(perms.update).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("mergePermissions giữ custom action", () => {
|
|
43
|
+
it("không khai config permissions → trả nguyên map user (kể cả custom)", () => {
|
|
44
|
+
const user = { read: true, "reset-password": true };
|
|
45
|
+
expect(mergePermissions(undefined, user)).toEqual(user);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("config permissions chỉ khai 1 key → custom action KHÔNG bị rơi mất", () => {
|
|
49
|
+
const merged = mergePermissions(
|
|
50
|
+
{ create: false },
|
|
51
|
+
{ create: true, read: true, "reset-password": true },
|
|
52
|
+
);
|
|
53
|
+
expect(merged.create).toBe(false);
|
|
54
|
+
expect(merged.read).toBe(true);
|
|
55
|
+
expect(merged["reset-password"]).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -4,20 +4,29 @@ import type { Session } from "../../types";
|
|
|
4
4
|
// Nguồn permission DUY NHẤT đã harden (BYPASS_AUTH chỉ hiệu lực ngoài
|
|
5
5
|
// production, admin-role check chạy trước empty-permissions guard) — bản
|
|
6
6
|
// divergent rbac/permissions.ts cũ đã xóa 2026-07 vì thiếu guard NODE_ENV.
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
checkPermission,
|
|
9
|
+
getActionCode,
|
|
10
|
+
getCrudPermissionsFromSession,
|
|
11
|
+
} from "../../auth";
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* Get CRUD permissions for a user based on their session
|
|
11
15
|
* Permissions are read from session (loaded from cache in session callback)
|
|
12
|
-
*
|
|
16
|
+
*
|
|
17
|
+
* `extraActions`: custom action ngoài 8 action CRUD chuẩn (khai trong registry,
|
|
18
|
+
* vd "reset-password", "resync") — mỗi cái tra checkPermission và điền vào map
|
|
19
|
+
* trả về, để RowAction.permissionAction gate được theo ĐÚNG action code thay vì
|
|
20
|
+
* mượn tạm "update". BYPASS_AUTH/admin đã xử lý bên trong checkPermission.
|
|
13
21
|
*/
|
|
14
22
|
export async function getCrudPermissions(
|
|
15
23
|
session: Session | null,
|
|
16
24
|
entity: string,
|
|
25
|
+
extraActions?: string[],
|
|
17
26
|
): Promise<CrudPermissions> {
|
|
18
27
|
// Get permissions from session (already loaded in JWT)
|
|
19
28
|
const perms = getCrudPermissionsFromSession(session, entity);
|
|
20
|
-
|
|
29
|
+
const result: CrudPermissions = {
|
|
21
30
|
create: perms.create,
|
|
22
31
|
read: perms.view,
|
|
23
32
|
update: perms.update,
|
|
@@ -27,12 +36,20 @@ export async function getCrudPermissions(
|
|
|
27
36
|
approve: perms.approve,
|
|
28
37
|
reject: perms.reject,
|
|
29
38
|
};
|
|
39
|
+
for (const action of extraActions ?? []) {
|
|
40
|
+
if (result[action] !== undefined) continue; // 8 action chuẩn đã có
|
|
41
|
+
result[action] = checkPermission(session, entity, getActionCode(action));
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
30
44
|
}
|
|
31
45
|
|
|
32
46
|
/**
|
|
33
47
|
* Merge config permissions with user permissions
|
|
34
48
|
* User permissions take precedence
|
|
35
49
|
* Config permissions are optional, so undefined means "allow if user has permission"
|
|
50
|
+
*
|
|
51
|
+
* Merge theo TOÀN BỘ key của userPermissions (kể cả custom action) — bản cũ
|
|
52
|
+
* liệt kê cứng 8 key nên custom action bị rơi mất sau merge.
|
|
36
53
|
*/
|
|
37
54
|
export function mergePermissions(
|
|
38
55
|
configPermissions: EntityConfig["permissions"],
|
|
@@ -42,14 +59,9 @@ export function mergePermissions(
|
|
|
42
59
|
return userPermissions;
|
|
43
60
|
}
|
|
44
61
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
export: (configPermissions.export ?? true) && userPermissions.export,
|
|
51
|
-
import: (configPermissions.import ?? true) && userPermissions.import,
|
|
52
|
-
approve: (configPermissions.approve ?? true) && userPermissions.approve,
|
|
53
|
-
reject: (configPermissions.reject ?? true) && userPermissions.reject,
|
|
54
|
-
};
|
|
62
|
+
const result: CrudPermissions = {};
|
|
63
|
+
for (const key of Object.keys(userPermissions)) {
|
|
64
|
+
result[key] = (configPermissions[key] ?? true) && userPermissions[key];
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
55
67
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { compileFilterTree } from "./filter-tree";
|
|
2
|
+
import { CrudRequestError } from "./errors";
|
|
2
3
|
import type { CrudQueryParams, EntityConfig } from "../../types";
|
|
3
4
|
|
|
4
5
|
export interface BuildListQueryOptions {
|
|
@@ -76,12 +77,18 @@ export function buildListQuery({
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
if (filterTree) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
let compiled;
|
|
81
|
+
try {
|
|
82
|
+
compiled = compileFilterTree(filterTree, {
|
|
83
|
+
isAllowed,
|
|
84
|
+
onDisallowed: (field) => {
|
|
85
|
+
if (onDisallowedFilter) onDisallowedFilter(field);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
} catch (e) {
|
|
89
|
+
// Cây sai cấu trúc/vượt giới hạn là lỗi CLIENT — route trả 400, không 500.
|
|
90
|
+
throw new CrudRequestError(e instanceof Error ? e.message : "filterTree không hợp lệ");
|
|
91
|
+
}
|
|
85
92
|
if (compiled) {
|
|
86
93
|
where.AND = [...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []), compiled];
|
|
87
94
|
}
|
|
@@ -110,15 +110,18 @@ export async function EntityCrudPage({
|
|
|
110
110
|
dictionary,
|
|
111
111
|
);
|
|
112
112
|
|
|
113
|
-
// Get user permissions for this entity if available in session or passed explicitly?
|
|
114
|
-
// In vinhhoa/page.tsx: getCrudPermissions(session, entity) is called.
|
|
115
|
-
// We should probably rely on `session` having what we need OR generic permissions logic.
|
|
116
|
-
// The `mergePermissions` below merges generic config perms with USER perms.
|
|
117
|
-
// We need user perms.
|
|
118
|
-
|
|
119
|
-
// We can import getCrudPermissions from ../lib/permissions if it effectively uses session
|
|
120
113
|
const { getCrudPermissions } = await import("../lib/permissions");
|
|
121
|
-
|
|
114
|
+
// Custom action trong rowActions (permissionAction ngoài 8 action CRUD
|
|
115
|
+
// chuẩn) cũng phải vào map quyền — không gom thì nút bị fail-closed ẩn
|
|
116
|
+
// vĩnh viễn dù user CÓ quyền trong registry.
|
|
117
|
+
const extraActions = (config.rowActions ?? [])
|
|
118
|
+
.map((a) => a.permissionAction)
|
|
119
|
+
.filter((a): a is string => Boolean(a));
|
|
120
|
+
const userPermissions = await getCrudPermissions(
|
|
121
|
+
session,
|
|
122
|
+
config.permissionResource ?? entity,
|
|
123
|
+
extraActions,
|
|
124
|
+
);
|
|
122
125
|
|
|
123
126
|
const permissions = mergePermissions(config.permissions, userPermissions);
|
|
124
127
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
import { createServerCrudService } from "./server-service";
|
|
4
|
+
import { CrudRequestError } from "./lib/errors";
|
|
5
|
+
import type { EntityConfig } from "../types";
|
|
6
|
+
|
|
7
|
+
const makeModel = () => ({
|
|
8
|
+
findUnique: vi.fn(async () => ({ id: 1, name: "A" })),
|
|
9
|
+
findMany: vi.fn(async () => []),
|
|
10
|
+
count: vi.fn(async () => 0),
|
|
11
|
+
create: vi.fn(async ({ data }: any) => data),
|
|
12
|
+
update: vi.fn(async () => ({ id: 1 })),
|
|
13
|
+
delete: vi.fn(async () => ({ id: 1 })),
|
|
14
|
+
deleteMany: vi.fn(async () => ({ count: 2 })),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const makeConfig = (over: Partial<EntityConfig> = {}): EntityConfig => ({
|
|
18
|
+
name: "job-title",
|
|
19
|
+
label: "Chức danh",
|
|
20
|
+
pluralLabel: "Chức danh",
|
|
21
|
+
apiEndpoint: "/api/crud/job-titles",
|
|
22
|
+
idField: "id",
|
|
23
|
+
displayField: "name",
|
|
24
|
+
fields: [
|
|
25
|
+
{ name: "id", label: "ID", type: "text" },
|
|
26
|
+
{ name: "name", label: "Tên", type: "text" },
|
|
27
|
+
],
|
|
28
|
+
...over,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const logger = { warn: vi.fn(), error: vi.fn() };
|
|
32
|
+
|
|
33
|
+
describe("createServerCrudService", () => {
|
|
34
|
+
let model: ReturnType<typeof makeModel>;
|
|
35
|
+
let prisma: Record<string, any>;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
model = makeModel();
|
|
39
|
+
// Model chỉ tồn tại dưới tên "jobTitle" — convention từ entity key khác
|
|
40
|
+
// ("weird-things") sẽ trượt nếu không đọc config.modelName.
|
|
41
|
+
prisma = { jobTitle: model };
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("ưu tiên config.modelName hơn convention getModelName", async () => {
|
|
45
|
+
const service = createServerCrudService({ prisma, logger });
|
|
46
|
+
const config = makeConfig({ modelName: "jobTitle" });
|
|
47
|
+
await service.getById("weird-things", "x", config);
|
|
48
|
+
expect(model.findUnique).toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("thiếu modelName thì fallback convention (job-titles → jobTitle)", async () => {
|
|
52
|
+
const service = createServerCrudService({ prisma, logger });
|
|
53
|
+
await service.getById("job-titles", "x", makeConfig());
|
|
54
|
+
expect(model.findUnique).toHaveBeenCalledWith(
|
|
55
|
+
expect.objectContaining({ where: { id: "x" } }),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("where dùng config.idField thay vì hardcode id", async () => {
|
|
60
|
+
const service = createServerCrudService({ prisma, logger });
|
|
61
|
+
const config = makeConfig({ modelName: "jobTitle", idField: "code" });
|
|
62
|
+
await service.delete("job-titles", "JT01", config);
|
|
63
|
+
expect(model.delete).toHaveBeenCalledWith({ where: { code: "JT01" } });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("idKind int: id trên URL được ép Number cho mọi method", async () => {
|
|
67
|
+
const service = createServerCrudService({ prisma, logger });
|
|
68
|
+
const config = makeConfig({ modelName: "jobTitle", idKind: "int" });
|
|
69
|
+
|
|
70
|
+
await service.getById("job-titles", "42", config);
|
|
71
|
+
expect(model.findUnique).toHaveBeenCalledWith(
|
|
72
|
+
expect.objectContaining({ where: { id: 42 } }),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
await service.update("job-titles", "42", { name: "B" }, config);
|
|
76
|
+
expect(model.update).toHaveBeenCalledWith(
|
|
77
|
+
expect.objectContaining({ where: { id: 42 } }),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
await service.delete("job-titles", "42", config);
|
|
81
|
+
expect(model.delete).toHaveBeenCalledWith({ where: { id: 42 } });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("idKind int: id không phải số nguyên → CrudRequestError 400", async () => {
|
|
85
|
+
const service = createServerCrudService({ prisma, logger });
|
|
86
|
+
const config = makeConfig({ modelName: "jobTitle", idKind: "int" });
|
|
87
|
+
await expect(service.getById("job-titles", "abc", config)).rejects.toThrow(
|
|
88
|
+
CrudRequestError,
|
|
89
|
+
);
|
|
90
|
+
expect(model.findUnique).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("deleteMany: ép cả mảng id, một phần tử hỏng là fail cả request", async () => {
|
|
94
|
+
const service = createServerCrudService({ prisma, logger });
|
|
95
|
+
const config = makeConfig({ modelName: "jobTitle", idKind: "int" });
|
|
96
|
+
|
|
97
|
+
await service.deleteMany("job-titles", ["1", "2"], config);
|
|
98
|
+
expect(model.deleteMany).toHaveBeenCalledWith({ where: { id: { in: [1, 2] } } });
|
|
99
|
+
|
|
100
|
+
await expect(
|
|
101
|
+
service.deleteMany("job-titles", ["1", "x"], config),
|
|
102
|
+
).rejects.toThrow(CrudRequestError);
|
|
103
|
+
expect(model.deleteMany).toHaveBeenCalledTimes(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("idKind mặc định: id giữ nguyên chuỗi (UUID)", async () => {
|
|
107
|
+
const service = createServerCrudService({ prisma, logger });
|
|
108
|
+
const config = makeConfig({ modelName: "jobTitle" });
|
|
109
|
+
await service.delete("job-titles", "uuid-1", config);
|
|
110
|
+
expect(model.delete).toHaveBeenCalledWith({ where: { id: "uuid-1" } });
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -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/styles/base.css
CHANGED
|
@@ -108,6 +108,22 @@
|
|
|
108
108
|
--radius-md: calc(var(--radius) - 2px);
|
|
109
109
|
--radius-sm: calc(var(--radius) - 4px);
|
|
110
110
|
|
|
111
|
+
/* Màn trạng thái (404/403/400/lỗi): hình và chữ nổi lên MỘT lần lúc mở.
|
|
112
|
+
Dùng kèm `motion-safe:` — người bật "giảm chuyển động" thấy trang tĩnh. */
|
|
113
|
+
--animate-status-in: status-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) both;
|
|
114
|
+
|
|
115
|
+
@keyframes status-in {
|
|
116
|
+
from {
|
|
117
|
+
opacity: 0;
|
|
118
|
+
transform: translateY(6px);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
to {
|
|
122
|
+
opacity: 1;
|
|
123
|
+
transform: none;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
111
127
|
--animate-accordion-down: accordion-down 0.2s ease-out;
|
|
112
128
|
--animate-accordion-up: accordion-up 0.2s ease-out;
|
|
113
129
|
--animate-collapsible-down: collapsible-down 0.2s ease-out;
|
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 {
|