@goplusvn/core 0.1.44 → 0.1.46
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 +22 -2
- package/src/auth/proxy-gate.ts +10 -2
- package/src/crud/crud-route-handlers.ts +26 -4
- package/src/crud/lib/permissions.ts +4 -1
- package/src/crud/server-service.ts +62 -14
- package/src/errors/error-handler.ts +17 -1
- package/src/errors/server-error.ts +18 -2
- package/src/system/services/settings-service.ts +51 -2
- package/src/ui/layout/top-bar-header-menubar.tsx +3 -4
- package/src/ui/shared/status-indicator.tsx +2 -0
- package/src/configs/data/navigations.ts +0 -781
- package/src/rbac/permissions.ts +0 -134
- package/src/ui/forms/editor/multi-select-example.tsx +0 -1234
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goplusvn/core",
|
|
3
3
|
"description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.46",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -54,6 +54,26 @@
|
|
|
54
54
|
"./utils/cccd-parser": "./src/utils/cccd-parser.ts",
|
|
55
55
|
"./configs/status": "./src/configs/status.ts",
|
|
56
56
|
"./configs/entities": "./src/configs/entities/index.ts",
|
|
57
|
+
"./ui/primitives/button": "./src/ui/primitives/button.tsx",
|
|
58
|
+
"./ui/primitives/input": "./src/ui/primitives/input.tsx",
|
|
59
|
+
"./ui/primitives/switch": "./src/ui/primitives/switch.tsx",
|
|
60
|
+
"./ui/primitives/label": "./src/ui/primitives/label.tsx",
|
|
61
|
+
"./ui/primitives/dialog": "./src/ui/primitives/dialog.tsx",
|
|
62
|
+
"./ui/primitives/tabs": "./src/ui/primitives/tabs.tsx",
|
|
63
|
+
"./ui/primitives/sidebar": "./src/ui/primitives/sidebar.tsx",
|
|
64
|
+
"./ui/primitives/select": "./src/ui/primitives/select.tsx",
|
|
65
|
+
"./ui/forms/multi-select": "./src/ui/forms/multi-select.tsx",
|
|
66
|
+
"./ui/data-display/collapsible": "./src/ui/data-display/collapsible.tsx",
|
|
67
|
+
"./system/services/settings-service": "./src/system/services/settings-service.ts",
|
|
68
|
+
"./system/services/system-category-service": "./src/system/services/system-category-service.ts",
|
|
69
|
+
"./system/pages/system-settings-page": "./src/system/pages/system-settings-page.tsx",
|
|
70
|
+
"./system/pages/system-category-page": "./src/system/pages/system-category-page.tsx",
|
|
71
|
+
"./rbac/role-service": "./src/rbac/role-service.ts",
|
|
72
|
+
"./rbac/resource-service": "./src/rbac/resource-service.ts",
|
|
73
|
+
"./infrastructure/cron/cron-manager": "./src/infrastructure/cron/cron-manager.ts",
|
|
74
|
+
"./infrastructure/cron/types": "./src/infrastructure/cron/types.ts",
|
|
75
|
+
"./crud/pages/entity-crud-page": "./src/crud/pages/entity-crud-page.tsx",
|
|
76
|
+
"./auth/auth-service": "./src/auth/auth-service.ts",
|
|
57
77
|
"./package.json": "./package.json",
|
|
58
78
|
"./providers/brand-theme": "./src/providers/brand-theme.ts"
|
|
59
79
|
},
|
|
@@ -158,7 +178,7 @@
|
|
|
158
178
|
"swr": "^2.3.6",
|
|
159
179
|
"tailwind-merge": "2.5.2",
|
|
160
180
|
"vaul": "1.1.2",
|
|
161
|
-
"xlsx": "0.
|
|
181
|
+
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
|
162
182
|
"zod": "3.23.8"
|
|
163
183
|
},
|
|
164
184
|
"scripts": {
|
package/src/auth/proxy-gate.ts
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
// export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.[^/]+$).*)"] };
|
|
12
12
|
|
|
13
13
|
import { NextResponse } from "next/server";
|
|
14
|
-
import { getToken } from "next-auth/jwt";
|
|
15
14
|
import type { NextRequest } from "next/server";
|
|
16
15
|
|
|
17
16
|
export interface AuthProxyOptions {
|
|
@@ -35,7 +34,16 @@ export function createAuthProxy(options: AuthProxyOptions = {}) {
|
|
|
35
34
|
const publicPages = options.publicPages ?? ["/sign-in"];
|
|
36
35
|
const signInPath = options.signInPath ?? "/sign-in";
|
|
37
36
|
const homePath = options.homePath ?? "/";
|
|
38
|
-
|
|
37
|
+
// next-auth chỉ được LAZY-load khi app không truyền getToken riêng — app đã
|
|
38
|
+
// sang Better Auth (không cài next-auth) sẽ không dính module-not-found lúc
|
|
39
|
+
// import proxy-gate (trước đây import top-level, next-auth lại chỉ nằm ở
|
|
40
|
+
// devDependencies của core).
|
|
41
|
+
const readToken =
|
|
42
|
+
options.getToken ??
|
|
43
|
+
(async (req: NextRequest) => {
|
|
44
|
+
const { getToken } = await import("next-auth/jwt");
|
|
45
|
+
return getToken({ req });
|
|
46
|
+
});
|
|
39
47
|
|
|
40
48
|
return async function proxy(request: NextRequest) {
|
|
41
49
|
const { pathname, search } = request.nextUrl;
|
|
@@ -22,11 +22,25 @@ type MaybePromise<T> = T | Promise<T>;
|
|
|
22
22
|
export interface CrudHandlerDeps {
|
|
23
23
|
getSession: () => MaybePromise<any | null>;
|
|
24
24
|
getEntityConfig: (entity: string) => EntityConfig | undefined;
|
|
25
|
-
service
|
|
25
|
+
/** Một service dùng chung, HOẶC resolver theo entity — app có service
|
|
26
|
+
* chuyên biệt (auto-gen số phiếu, stock-count…) trả service riêng cho
|
|
27
|
+
* entity đó thay vì viết factory if-chain ngoài core (seam 2026-07). */
|
|
28
|
+
service:
|
|
29
|
+
| ServerCrudService
|
|
30
|
+
| ((entity: string) => MaybePromise<ServerCrudService>);
|
|
26
31
|
/** Optional error mapper (e.g. app's serverError). Defaults to a 500 JSON. */
|
|
27
32
|
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
function makeServiceResolver(
|
|
36
|
+
service: CrudHandlerDeps["service"],
|
|
37
|
+
): (entity: string) => Promise<ServerCrudService> {
|
|
38
|
+
if (typeof service === "function") {
|
|
39
|
+
return async (entity) => await service(entity);
|
|
40
|
+
}
|
|
41
|
+
return async () => service;
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
const json = (data: unknown, status = 200) =>
|
|
31
45
|
new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
|
|
32
46
|
|
|
@@ -40,7 +54,8 @@ async function resolvePerms(session: any, config: EntityConfig, entity: string)
|
|
|
40
54
|
|
|
41
55
|
// GET/POST for the collection route `/api/crud/[entity]`.
|
|
42
56
|
export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
|
|
43
|
-
const { getSession, getEntityConfig,
|
|
57
|
+
const { getSession, getEntityConfig, onError } = deps;
|
|
58
|
+
const resolveService = makeServiceResolver(deps.service);
|
|
44
59
|
const fail = (e: unknown, req: Request) =>
|
|
45
60
|
onError ? onError(e, req) : json({ error: "Internal error" }, 500);
|
|
46
61
|
|
|
@@ -64,6 +79,7 @@ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
|
|
|
64
79
|
: undefined,
|
|
65
80
|
filters: sp.get("filters") ? JSON.parse(sp.get("filters")!) : undefined,
|
|
66
81
|
};
|
|
82
|
+
const service = await resolveService(entity);
|
|
67
83
|
const data = await service.list(entity, config, params as any);
|
|
68
84
|
return json(data);
|
|
69
85
|
} catch (e) {
|
|
@@ -82,6 +98,7 @@ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
|
|
|
82
98
|
if (!perms.create) return forbidden();
|
|
83
99
|
|
|
84
100
|
const body = await req.json();
|
|
101
|
+
const service = await resolveService(entity);
|
|
85
102
|
if (Array.isArray(body)) {
|
|
86
103
|
const results = [];
|
|
87
104
|
for (const item of body) results.push(await service.create(entity, item, config));
|
|
@@ -98,7 +115,8 @@ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
|
|
|
98
115
|
|
|
99
116
|
// GET/PUT/PATCH/DELETE for the item route `/api/crud/[entity]/[id]`.
|
|
100
117
|
export function createCrudItemHandlers(deps: CrudHandlerDeps) {
|
|
101
|
-
const { getSession, getEntityConfig,
|
|
118
|
+
const { getSession, getEntityConfig, onError } = deps;
|
|
119
|
+
const resolveService = makeServiceResolver(deps.service);
|
|
102
120
|
const fail = (e: unknown, req: Request) =>
|
|
103
121
|
onError ? onError(e, req) : json({ error: "Internal error" }, 500);
|
|
104
122
|
|
|
@@ -113,7 +131,9 @@ export function createCrudItemHandlers(deps: CrudHandlerDeps) {
|
|
|
113
131
|
if (!config) return unknownEntity();
|
|
114
132
|
const perms = await resolvePerms(session, config, entity);
|
|
115
133
|
if (!perms.read) return forbidden();
|
|
116
|
-
const
|
|
134
|
+
const service = await resolveService(entity);
|
|
135
|
+
// Truyền config để detail áp cùng include như list (label thay raw FK id).
|
|
136
|
+
const item = await service.getById(entity, id, config);
|
|
117
137
|
if (!item) return json({ error: "Not found" }, 404);
|
|
118
138
|
return json(item);
|
|
119
139
|
} catch (e) {
|
|
@@ -131,6 +151,7 @@ export function createCrudItemHandlers(deps: CrudHandlerDeps) {
|
|
|
131
151
|
const perms = await resolvePerms(session, config, entity);
|
|
132
152
|
if (!perms.update) return forbidden();
|
|
133
153
|
const body = await req.json();
|
|
154
|
+
const service = await resolveService(entity);
|
|
134
155
|
return json(await service.update(entity, id, body, config));
|
|
135
156
|
} catch (e) {
|
|
136
157
|
return fail(e, req);
|
|
@@ -146,6 +167,7 @@ export function createCrudItemHandlers(deps: CrudHandlerDeps) {
|
|
|
146
167
|
if (!config) return unknownEntity();
|
|
147
168
|
const perms = await resolvePerms(session, config, entity);
|
|
148
169
|
if (!perms.delete) return forbidden();
|
|
170
|
+
const service = await resolveService(entity);
|
|
149
171
|
await service.delete(entity, id);
|
|
150
172
|
return new Response(null, { status: 204 });
|
|
151
173
|
} catch (e) {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { CrudPermissions, EntityConfig } from "../../types";
|
|
2
2
|
import type { Session } from "../../types";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// Nguồn permission DUY NHẤT đã harden (BYPASS_AUTH chỉ hiệu lực ngoài
|
|
5
|
+
// production, admin-role check chạy trước empty-permissions guard) — bản
|
|
6
|
+
// divergent rbac/permissions.ts cũ đã xóa 2026-07 vì thiếu guard NODE_ENV.
|
|
7
|
+
import { getCrudPermissionsFromSession } from "../../auth";
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Get CRUD permissions for a user based on their session
|
|
@@ -22,9 +22,12 @@ type PrismaLike = Record<string, any>;
|
|
|
22
22
|
|
|
23
23
|
export interface ServerCrudService {
|
|
24
24
|
list(entity: string, config: EntityConfig, params: CrudQueryParams): Promise<CrudResponse>;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
/** `config` (tùy chọn) để áp cùng `include` như list — detail không hiện raw FK id. */
|
|
26
|
+
getById(entity: string, id: string, config?: EntityConfig): Promise<any>;
|
|
27
|
+
/** `tx` (tùy chọn) để app chạy create CÙNG transaction với sinh số phiếu
|
|
28
|
+
* advisory-lock (document-number yêu cầu cùng tx với insert). */
|
|
29
|
+
create(entity: string, data: any, config?: EntityConfig, tx?: PrismaLike): Promise<any>;
|
|
30
|
+
update(entity: string, id: string, data: any, config?: EntityConfig, tx?: PrismaLike): Promise<any>;
|
|
28
31
|
delete(entity: string, id: string): Promise<any>;
|
|
29
32
|
deleteMany(entity: string, ids: string[]): Promise<any>;
|
|
30
33
|
}
|
|
@@ -43,8 +46,35 @@ export interface ServerCrudDeps {
|
|
|
43
46
|
* {@link createAuditUserNameResolver} if your User model exposes a name field. */
|
|
44
47
|
resolveAuditNames?: (records: any[]) => Promise<any[]>;
|
|
45
48
|
logger?: ServerCrudLogger;
|
|
49
|
+
|
|
50
|
+
// ── Seams 2026-07: đủ điểm cắm để app KHÔNG phải fork nguyên engine ──
|
|
51
|
+
|
|
52
|
+
/** WHERE mặc định theo entity, AND vào mọi list/count — vd 2 entity chia
|
|
53
|
+
* chung 1 model: stock-in → { type: "in" }, stock-out → { type: "out" }. */
|
|
54
|
+
scopeWhere?: (
|
|
55
|
+
entity: string,
|
|
56
|
+
config: EntityConfig,
|
|
57
|
+
) => Record<string, unknown> | undefined;
|
|
58
|
+
/** Field hệ thống luôn cho qua filterValidFields dù không khai trong
|
|
59
|
+
* config.fields. Default: id + bộ audit (createdAt/updatedAt/createdBy/
|
|
60
|
+
* updatedBy). App có cột riêng (companyId…) thì truyền thêm. */
|
|
61
|
+
systemFields?: string[];
|
|
62
|
+
/** Set updatedAt = new Date() khi create nếu payload chưa có — cho model
|
|
63
|
+
* thiếu `@updatedAt`/default trong schema. Default: false. */
|
|
64
|
+
touchUpdatedAtOnCreate?: boolean;
|
|
65
|
+
/** Các cột `*Id` KHÔNG phải FK — bỏ qua transform connect/disconnect
|
|
66
|
+
* (scalar như số CCCD, mã tra cứu…). Gộp thêm vào skip-list mặc định. */
|
|
67
|
+
relationFieldSkip?: string[];
|
|
46
68
|
}
|
|
47
69
|
|
|
70
|
+
const DEFAULT_SYSTEM_FIELDS = [
|
|
71
|
+
"id",
|
|
72
|
+
"createdAt",
|
|
73
|
+
"updatedAt",
|
|
74
|
+
"createdBy",
|
|
75
|
+
"updatedBy",
|
|
76
|
+
];
|
|
77
|
+
|
|
48
78
|
const MAX_PAGE_SIZE = 200;
|
|
49
79
|
|
|
50
80
|
// Default plural→model resolver: strip trailing "s", camelCase kebab. Apps with
|
|
@@ -96,16 +126,18 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
96
126
|
error: (m, ...r) => console.error(m, ...r),
|
|
97
127
|
};
|
|
98
128
|
|
|
99
|
-
const
|
|
129
|
+
const systemFields = deps.systemFields ?? DEFAULT_SYSTEM_FIELDS;
|
|
130
|
+
|
|
131
|
+
const model = (entity: string, client: PrismaLike = prisma) => {
|
|
100
132
|
const name = resolveModel(entity);
|
|
101
|
-
const m =
|
|
133
|
+
const m = client[name];
|
|
102
134
|
if (!m) throw new Error(`Prisma model not found for entity: ${entity} (${name})`);
|
|
103
135
|
return m;
|
|
104
136
|
};
|
|
105
137
|
|
|
106
138
|
const filterValidFields = (data: any, config: EntityConfig) => {
|
|
107
139
|
const valid = new Set(config.fields.filter((f) => !(f as any).isDisplayOnly).map((f) => f.name));
|
|
108
|
-
valid.add(
|
|
140
|
+
for (const f of systemFields) valid.add(f);
|
|
109
141
|
const out: Record<string, unknown> = {};
|
|
110
142
|
for (const [k, v] of Object.entries(data)) {
|
|
111
143
|
if (valid.has(k)) out[k] = v;
|
|
@@ -143,7 +175,16 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
143
175
|
|
|
144
176
|
const transformRelationFields = (data: any, mode: "create" | "update") => {
|
|
145
177
|
const out = { ...data };
|
|
146
|
-
|
|
178
|
+
// citizenId/targetId là legacy-default từ consumer đầu tiên — app mới khai
|
|
179
|
+
// cột scalar *Id của mình qua deps.relationFieldSkip thay vì sửa core.
|
|
180
|
+
const skip = new Set([
|
|
181
|
+
"id",
|
|
182
|
+
"createdBy",
|
|
183
|
+
"updatedBy",
|
|
184
|
+
"citizenId",
|
|
185
|
+
"targetId",
|
|
186
|
+
...(deps.relationFieldSkip ?? []),
|
|
187
|
+
]);
|
|
147
188
|
for (const key of Object.keys(out)) {
|
|
148
189
|
if (skip.has(key)) continue;
|
|
149
190
|
if (key.endsWith("Id") && key.length > 2) {
|
|
@@ -181,7 +222,7 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
181
222
|
return allowedFields.has(name);
|
|
182
223
|
};
|
|
183
224
|
|
|
184
|
-
const where: any = {};
|
|
225
|
+
const where: any = { ...(deps.scopeWhere?.(entity, config) ?? {}) };
|
|
185
226
|
if (search && search.trim()) {
|
|
186
227
|
const term = search.trim();
|
|
187
228
|
const searchFields = config.fields.filter((f) => f.filterable && f.type === "text").map((f) => f.name);
|
|
@@ -253,21 +294,28 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
253
294
|
}
|
|
254
295
|
},
|
|
255
296
|
|
|
256
|
-
async getById(entity, id) {
|
|
297
|
+
async getById(entity, id, config) {
|
|
257
298
|
const prismaModel = model(entity);
|
|
258
|
-
|
|
299
|
+
// Áp cùng include như list — detail dialog hiện label relation thay raw FK id.
|
|
300
|
+
const include: any = {};
|
|
301
|
+
if (config?.include?.length) config.include.forEach((inc) => (include[inc] = true));
|
|
302
|
+
const includeOption = Object.keys(include).length ? { include } : {};
|
|
303
|
+
const result = await prismaModel.findUnique({ where: { id }, ...includeOption });
|
|
259
304
|
if (!result) return null;
|
|
260
305
|
const serialized = serializeDecimalFields(result);
|
|
261
306
|
const [resolved] = await resolveAuditNames([serialized]);
|
|
262
307
|
return resolved;
|
|
263
308
|
},
|
|
264
309
|
|
|
265
|
-
async create(entity, data, config) {
|
|
266
|
-
const prismaModel = model(entity);
|
|
310
|
+
async create(entity, data, config, tx) {
|
|
311
|
+
const prismaModel = model(entity, tx ?? prisma);
|
|
267
312
|
try {
|
|
268
313
|
let filtered = config ? filterValidFields(data, config) : data;
|
|
269
314
|
if (config) filtered = castFieldValues(filtered, config);
|
|
270
315
|
if (!filtered.id) filtered.id = crypto.randomUUID();
|
|
316
|
+
if (deps.touchUpdatedAtOnCreate && !filtered.updatedAt) {
|
|
317
|
+
filtered.updatedAt = new Date();
|
|
318
|
+
}
|
|
271
319
|
const prismaData = transformRelationFields(filtered, "create");
|
|
272
320
|
return serializeDecimalFields(await prismaModel.create({ data: prismaData }));
|
|
273
321
|
} catch (error) {
|
|
@@ -276,8 +324,8 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
276
324
|
}
|
|
277
325
|
},
|
|
278
326
|
|
|
279
|
-
async update(entity, id, data, config) {
|
|
280
|
-
const prismaModel = model(entity);
|
|
327
|
+
async update(entity, id, data, config, tx) {
|
|
328
|
+
const prismaModel = model(entity, tx ?? prisma);
|
|
281
329
|
try {
|
|
282
330
|
let filtered = config ? filterValidFields(data, config) : data;
|
|
283
331
|
if (config) filtered = castFieldValues(filtered, config);
|
|
@@ -17,11 +17,27 @@ import { AppError, toAppError } from "./app-error"
|
|
|
17
17
|
*/
|
|
18
18
|
export async function withErrorHandler(
|
|
19
19
|
handler: () => Promise<Response>,
|
|
20
|
-
options?: {
|
|
20
|
+
options?: {
|
|
21
|
+
operation?: string
|
|
22
|
+
req?: NextRequest | Request
|
|
23
|
+
/**
|
|
24
|
+
* Seam persist lỗi (2026-07): mặc định chỉ console.error — lỗi KHÔNG
|
|
25
|
+
* vào error_logs. App truyền `onError: (e, req) => serverError(e, req)`
|
|
26
|
+
* (buildServerError ở ./server-error) để lỗi được lưu bền + giữ nguyên
|
|
27
|
+
* shape response (cả hai cùng kết thúc bằng createErrorResponse).
|
|
28
|
+
*/
|
|
29
|
+
onError?: (
|
|
30
|
+
error: unknown,
|
|
31
|
+
req?: NextRequest | Request
|
|
32
|
+
) => Response | Promise<Response>
|
|
33
|
+
}
|
|
21
34
|
): Promise<Response> {
|
|
22
35
|
try {
|
|
23
36
|
return await handler()
|
|
24
37
|
} catch (error) {
|
|
38
|
+
if (options?.onError) {
|
|
39
|
+
return options.onError(error, options.req)
|
|
40
|
+
}
|
|
25
41
|
const appError = toAppError(error)
|
|
26
42
|
|
|
27
43
|
const requestContext = options?.req
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { NextResponse } from "next/server"
|
|
2
|
-
import { createHash } from "crypto"
|
|
3
2
|
|
|
4
3
|
import { AppError, toAppError } from "./app-error"
|
|
5
4
|
import { createErrorResponse } from "./error-handler"
|
|
@@ -68,7 +67,24 @@ function buildFingerprint(
|
|
|
68
67
|
/* keep raw */
|
|
69
68
|
}
|
|
70
69
|
const raw = [message.slice(0, 200), code || "", moduleTag || "", path].join("|")
|
|
71
|
-
return
|
|
70
|
+
return fingerprint16(raw)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fingerprint 16 hex để GOM NHÓM lỗi — không phải mật mã học. FNV-1a thuần
|
|
75
|
+
* (không import "crypto" của Node) vì error path bị instrumentation/proxy kéo
|
|
76
|
+
* vào Edge bundle, nơi không có module crypto (2026-07: dev webpack vỡ vì
|
|
77
|
+
* import node:crypto ở file này).
|
|
78
|
+
*/
|
|
79
|
+
function fingerprint16(raw: string): string {
|
|
80
|
+
let h1 = 0x811c9dc5
|
|
81
|
+
let h2 = 0xcbf29ce4
|
|
82
|
+
for (let i = 0; i < raw.length; i++) {
|
|
83
|
+
const c = raw.charCodeAt(i)
|
|
84
|
+
h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0
|
|
85
|
+
h2 = Math.imul(h2 ^ ((c << 1) | 1), 0x01000193) >>> 0
|
|
86
|
+
}
|
|
87
|
+
return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0")
|
|
72
88
|
}
|
|
73
89
|
|
|
74
90
|
/**
|
|
@@ -1,8 +1,54 @@
|
|
|
1
|
-
const db: any = { systemConfig: { findUnique: async () => null, upsert: async () => null } }; // Mocked for build
|
|
2
|
-
|
|
3
1
|
import { cache } from "react";
|
|
4
2
|
import { revalidateTag, unstable_cache } from "next/cache";
|
|
5
3
|
|
|
4
|
+
/**
|
|
5
|
+
* DB được INJECT từ app (core không import prisma client cụ thể). Trước
|
|
6
|
+
* 2026-07 chỗ này là `const db: any = {...} // Mocked for build` — mọi
|
|
7
|
+
* getSettings âm thầm trả default và saveSettings ghi vào hư không (gốc của
|
|
8
|
+
* regression "settings admin chỉnh không ăn"). App gọi
|
|
9
|
+
* `configureSettingsService(db)` MỘT lần ở composition root (cạnh chỗ tạo
|
|
10
|
+
* prisma client).
|
|
11
|
+
*/
|
|
12
|
+
// Structural + schema-agnostic (giống PrismaLike ở crud/server-service): args
|
|
13
|
+
// để lỏng vì generated types của Prisma mỗi app một khác — chỉ chốt shape trả
|
|
14
|
+
// về mà service thật sự đọc.
|
|
15
|
+
export interface SettingsDb {
|
|
16
|
+
systemConfig: {
|
|
17
|
+
findUnique(args: any): Promise<{ value: string | null } | null>;
|
|
18
|
+
upsert(args: any): Promise<unknown>;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let configuredDb: SettingsDb | null = null;
|
|
23
|
+
let warnedMissingDb = false;
|
|
24
|
+
|
|
25
|
+
export function configureSettingsService(db: SettingsDb): void {
|
|
26
|
+
configuredDb = db;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Đọc: thiếu db → cảnh báo TO (một lần) + trả default — không sập build/SSR. */
|
|
30
|
+
function dbForRead(): SettingsDb | null {
|
|
31
|
+
if (configuredDb) return configuredDb;
|
|
32
|
+
if (!warnedMissingDb) {
|
|
33
|
+
warnedMissingDb = true;
|
|
34
|
+
console.warn(
|
|
35
|
+
"[SettingsService] Chưa configureSettingsService(db) — mọi getSettings " +
|
|
36
|
+
"đang trả DEFAULT (setting admin chỉnh sẽ không có hiệu lực). " +
|
|
37
|
+
"Gọi configureSettingsService(db) ở composition root của app.",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Ghi: thiếu db → THROW. Ghi-vào-hư-không tệ hơn lỗi ồn ào. */
|
|
44
|
+
function dbForWrite(): SettingsDb {
|
|
45
|
+
if (configuredDb) return configuredDb;
|
|
46
|
+
throw new Error(
|
|
47
|
+
"[SettingsService] saveSettings cần configureSettingsService(db) — " +
|
|
48
|
+
"từ chối ghi vào hư không (trước đây mock db nuốt mất dữ liệu).",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
6
52
|
export interface AuditSettings {
|
|
7
53
|
retentionDays: number;
|
|
8
54
|
enabledResources: string[];
|
|
@@ -52,6 +98,8 @@ const DEFAULT_SALES_RULES_SETTINGS: SalesRulesSettings = {
|
|
|
52
98
|
export class SettingsService {
|
|
53
99
|
/** Get all settings cached */
|
|
54
100
|
static async getSettings<T>(key: string, defaultValue: T): Promise<T> {
|
|
101
|
+
const db = dbForRead();
|
|
102
|
+
if (!db) return defaultValue;
|
|
55
103
|
return unstable_cache(
|
|
56
104
|
async () => {
|
|
57
105
|
const config = await db.systemConfig.findUnique({
|
|
@@ -88,6 +136,7 @@ export class SettingsService {
|
|
|
88
136
|
value: T,
|
|
89
137
|
category = "general",
|
|
90
138
|
): Promise<void> {
|
|
139
|
+
const db = dbForWrite();
|
|
91
140
|
await db.systemConfig.upsert({
|
|
92
141
|
where: { key },
|
|
93
142
|
update: {
|
|
@@ -12,8 +12,6 @@ import type {
|
|
|
12
12
|
NavigationType,
|
|
13
13
|
} from "../../types";
|
|
14
14
|
|
|
15
|
-
import { navigationsData as defaultNavigationsData } from "../../configs/data/navigations";
|
|
16
|
-
|
|
17
15
|
import { ensureLocalizedPathname } from "../../utils";
|
|
18
16
|
import { i18n } from "../../configs";
|
|
19
17
|
import {
|
|
@@ -48,8 +46,9 @@ export function TopBarHeaderMenubar({
|
|
|
48
46
|
|
|
49
47
|
const locale = (params.lang as LocaleType) || i18n.defaultLocale;
|
|
50
48
|
|
|
51
|
-
//
|
|
52
|
-
|
|
49
|
+
// Navigation do APP truyền vào — core không bake menu demo nào nữa
|
|
50
|
+
// (trước 2026-07 default là 781 dòng menu mẫu GoEat ship trong npm).
|
|
51
|
+
const navItems = navigation ?? [];
|
|
53
52
|
|
|
54
53
|
// SAP Fiori Logic: Show first 5 items, group rest under "More"
|
|
55
54
|
const MAX_VISIBLE_ITEMS = 5;
|
|
@@ -46,6 +46,8 @@ const STATUS_MAP: Record<string, { color: string; dotClass: string; label: strin
|
|
|
46
46
|
accounting_revision: { color: "text-warning-text", dotClass: "bg-warning", label: "Chỉnh sửa" },
|
|
47
47
|
waiting_supplier:{ color: "text-warning-text", dotClass: "bg-warning", label: "Chờ NCC" },
|
|
48
48
|
partially_received: { color: "text-warning-text", dotClass: "bg-warning", label: "Nhận một phần" },
|
|
49
|
+
// Key dữ liệu THẬT của PurchaseOrder vinhhoa (bản local từng drift vì core thiếu key này — 2026-07 hợp nhất về core).
|
|
50
|
+
partial_received: { color: "text-warning-text", dotClass: "bg-warning", label: "Nhận một phần" },
|
|
49
51
|
delivery_adjustment: { color: "text-warning-text", dotClass: "bg-warning", label: "Điều chỉnh giao hàng" },
|
|
50
52
|
|
|
51
53
|
// Info / In-progress
|