@goplusvn/core 0.1.13 → 0.1.14
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/CHANGELOG.md +22 -0
- package/package.json +3 -1
- package/src/auth/proxy-gate.ts +80 -0
- package/src/crud/crud-route-handlers.ts +157 -0
- package/src/crud/server-service.ts +312 -0
- package/src/crud/server.ts +18 -0
- package/src/rbac/role-service.ts +40 -33
- package/src/ui/index.tsx +1 -0
- package/src/ui/layout/page-tabs.tsx +8 -42
- package/src/ui/primitives/index.tsx +1 -0
- package/src/ui/shared/index.ts +6 -0
- package/src/ui/shared/page-header.tsx +57 -0
- package/src/ui/shared/status-indicator.tsx +173 -0
- package/src/ui/shared/table-styles.ts +47 -0
- package/src/ui/shared/table-sum-footer.tsx +41 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.14 — Init-completion: server-CRUD engine, auth gate, schema-tolerant RBAC, shared UI
|
|
4
|
+
|
|
5
|
+
Đúc kết từ việc dựng app mới (wu-vpbank): những thứ MỖI app phải tự viết lại nay
|
|
6
|
+
đưa vào core (xem `docs/CORE-INIT-COMPLETION-PLAN.md`). Toàn bộ **additive, backward-
|
|
7
|
+
compatible** — 4 app (vinhhoa/thingtodo/wu ^0.1.13, tanloc ^0.1.3) an toàn.
|
|
8
|
+
|
|
9
|
+
- **Server-CRUD engine** (`@goerp/core/crud/server`): `createServerCrudService`
|
|
10
|
+
(Prisma-agnostic qua DI), `getModelName`, `createAuditUserNameResolver` (schema-
|
|
11
|
+
tolerant tên user), `createCrudCollectionHandlers`/`createCrudItemHandlers` (route
|
|
12
|
+
Next.js tự gác quyền). App bỏ ~350 LOC engine tự chế.
|
|
13
|
+
- **Auth request-gate** (`@goerp/core/auth/proxy-gate`): `createAuthProxy` (default-
|
|
14
|
+
deny) + `unauthorizedResponse`. proxy.ts app còn ~10 LOC.
|
|
15
|
+
- **RBAC schema-tolerant**: `getRolesData(db, params, schema?)` nhận field-map
|
|
16
|
+
(`userNameField`/`userActiveField`/`userImageField`/`roleTimestamps`) → app schema
|
|
17
|
+
khác (User.fullName/active, Role không timestamps) không còn crash/tự chế.
|
|
18
|
+
- **Shared UI** (`@goerp/core/ui`): PageHeader, StatusIndicator + getStatusMeta,
|
|
19
|
+
SumFooterCell + sumBy, table-styles (mẫu bảng đơn bán hàng, subpath server-safe
|
|
20
|
+
`@goerp/core/ui/shared/table-styles`); export DynamicIcon.
|
|
21
|
+
|
|
22
|
+
Verify: core tsc 0 lỗi; wu consume toàn bộ (tsc 0/0 + render/CRUD/auth chạy đúng);
|
|
23
|
+
golden vinhhoa tsc 20=20 (0 regression).
|
|
24
|
+
|
|
3
25
|
## 0.1.13 — Customizer: make radius, density & inset/floating actually apply
|
|
4
26
|
|
|
5
27
|
The Customizer exposed controls that stored a value but changed nothing on
|
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.14",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
},
|
|
37
37
|
"./assets/*": "./src/assets/*",
|
|
38
38
|
"./styles/*": "./src/styles/*",
|
|
39
|
+
"./auth/proxy-gate": "./src/auth/proxy-gate.ts",
|
|
40
|
+
"./ui/shared/table-styles": "./src/ui/shared/table-styles.ts",
|
|
39
41
|
"./errors/app-error": "./src/errors/app-error.ts",
|
|
40
42
|
"./errors/error-handler": "./src/errors/error-handler.ts",
|
|
41
43
|
"./errors/server-error": "./src/errors/server-error.ts",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// @goerp/core/auth/proxy-gate — server-only request-gate for the Next.js
|
|
2
|
+
// proxy/middleware. Default-DENY authentication (authN); route-level authZ still
|
|
3
|
+
// happens via getCrudPermissions/checkPermission. Isolated in its own subpath so
|
|
4
|
+
// `next/server` + `next-auth/jwt` never leak into client bundles via the auth barrel.
|
|
5
|
+
//
|
|
6
|
+
// Usage (app side):
|
|
7
|
+
// // src/proxy.ts
|
|
8
|
+
// import { createAuthProxy } from "@goerp/core/auth/proxy-gate";
|
|
9
|
+
// export const proxy = createAuthProxy({ homePath: "/vi" });
|
|
10
|
+
// export default proxy;
|
|
11
|
+
// export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.[^/]+$).*)"] };
|
|
12
|
+
|
|
13
|
+
import { NextResponse } from "next/server";
|
|
14
|
+
import { getToken } from "next-auth/jwt";
|
|
15
|
+
import type { NextRequest } from "next/server";
|
|
16
|
+
|
|
17
|
+
export interface AuthProxyOptions {
|
|
18
|
+
/** API prefixes served without a session (NextAuth + public). Default: /api/auth, /api/public. */
|
|
19
|
+
publicApiPrefixes?: string[];
|
|
20
|
+
/** Pages reachable while logged out. Default: /sign-in. */
|
|
21
|
+
publicPages?: string[];
|
|
22
|
+
/** Where to send unauthenticated page requests. Default: /sign-in. */
|
|
23
|
+
signInPath?: string;
|
|
24
|
+
/** Where to send a logged-in user who hits a guest page. Default: "/". */
|
|
25
|
+
homePath?: string;
|
|
26
|
+
/** Override token reader (tests / custom JWT). Default: next-auth getToken. */
|
|
27
|
+
getToken?: (req: NextRequest) => Promise<unknown | null>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const startsWithAny = (pathname: string, list: string[]) =>
|
|
31
|
+
list.some((p) => pathname === p || pathname.startsWith(`${p}/`));
|
|
32
|
+
|
|
33
|
+
export function createAuthProxy(options: AuthProxyOptions = {}) {
|
|
34
|
+
const publicApiPrefixes = options.publicApiPrefixes ?? ["/api/auth", "/api/public"];
|
|
35
|
+
const publicPages = options.publicPages ?? ["/sign-in"];
|
|
36
|
+
const signInPath = options.signInPath ?? "/sign-in";
|
|
37
|
+
const homePath = options.homePath ?? "/";
|
|
38
|
+
const readToken = options.getToken ?? ((req: NextRequest) => getToken({ req }));
|
|
39
|
+
|
|
40
|
+
return async function proxy(request: NextRequest) {
|
|
41
|
+
const { pathname, search } = request.nextUrl;
|
|
42
|
+
|
|
43
|
+
// API routes that authenticate themselves (NextAuth) or are public → pass.
|
|
44
|
+
if (startsWithAny(pathname, publicApiPrefixes)) return NextResponse.next();
|
|
45
|
+
|
|
46
|
+
const token = await readToken(request);
|
|
47
|
+
|
|
48
|
+
// Guest pages (/sign-in): bounce an already-authed user to home.
|
|
49
|
+
if (startsWithAny(pathname, publicPages)) {
|
|
50
|
+
if (token) {
|
|
51
|
+
const url = request.nextUrl.clone();
|
|
52
|
+
url.pathname = homePath;
|
|
53
|
+
url.search = "";
|
|
54
|
+
return NextResponse.redirect(url);
|
|
55
|
+
}
|
|
56
|
+
return NextResponse.next();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// API: default-deny (each route still does its own authZ).
|
|
60
|
+
if (pathname.startsWith("/api")) {
|
|
61
|
+
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
62
|
+
return NextResponse.next();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Pages: must be signed in; preserve callbackUrl.
|
|
66
|
+
if (!token) {
|
|
67
|
+
const url = request.nextUrl.clone();
|
|
68
|
+
url.pathname = signInPath;
|
|
69
|
+
url.search = "";
|
|
70
|
+
if (pathname !== "/") url.searchParams.set("callbackUrl", pathname + search);
|
|
71
|
+
return NextResponse.redirect(url);
|
|
72
|
+
}
|
|
73
|
+
return NextResponse.next();
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 401 JSON for API route catch/guard blocks. */
|
|
78
|
+
export function unauthorizedResponse() {
|
|
79
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
80
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
// Next.js route-handler factories for the generic CRUD engine. Wires session +
|
|
3
|
+
// RBAC permission gate + entity config + {@link ServerCrudService} so an app's
|
|
4
|
+
// route file is one line:
|
|
5
|
+
//
|
|
6
|
+
// // src/app/api/crud/[entity]/route.ts
|
|
7
|
+
// import { createCrudCollectionHandlers } from "@goerp/core/crud/server";
|
|
8
|
+
// import { getSession } from "@/lib/auth";
|
|
9
|
+
// import { getEntityConfig } from "@/configs/entities";
|
|
10
|
+
// import { crudService } from "@/lib/crud";
|
|
11
|
+
// export const { GET, POST } = createCrudCollectionHandlers({ getSession, getEntityConfig, service: crudService });
|
|
12
|
+
//
|
|
13
|
+
// // src/app/api/crud/[entity]/[id]/route.ts
|
|
14
|
+
// export const { GET, PUT, PATCH, DELETE } = createCrudItemHandlers({ getSession, getEntityConfig, service: crudService });
|
|
15
|
+
|
|
16
|
+
import type { EntityConfig } from "../types";
|
|
17
|
+
import { getCrudPermissions } from "./lib/permissions";
|
|
18
|
+
import type { ServerCrudService } from "./server-service";
|
|
19
|
+
|
|
20
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
21
|
+
|
|
22
|
+
export interface CrudHandlerDeps {
|
|
23
|
+
getSession: () => MaybePromise<any | null>;
|
|
24
|
+
getEntityConfig: (entity: string) => EntityConfig | undefined;
|
|
25
|
+
service: ServerCrudService;
|
|
26
|
+
/** Optional error mapper (e.g. app's serverError). Defaults to a 500 JSON. */
|
|
27
|
+
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const json = (data: unknown, status = 200) =>
|
|
31
|
+
new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
|
|
32
|
+
|
|
33
|
+
const unauthorized = () => json({ error: "Unauthorized" }, 401);
|
|
34
|
+
const forbidden = () => json({ error: "Forbidden" }, 403);
|
|
35
|
+
const unknownEntity = () => json({ error: "Unknown entity" }, 404);
|
|
36
|
+
|
|
37
|
+
async function resolvePerms(session: any, config: EntityConfig, entity: string) {
|
|
38
|
+
return getCrudPermissions(session, config.permissionResource ?? entity);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// GET/POST for the collection route `/api/crud/[entity]`.
|
|
42
|
+
export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
|
|
43
|
+
const { getSession, getEntityConfig, service, onError } = deps;
|
|
44
|
+
const fail = (e: unknown, req: Request) =>
|
|
45
|
+
onError ? onError(e, req) : json({ error: "Internal error" }, 500);
|
|
46
|
+
|
|
47
|
+
async function GET(req: Request, ctx: { params: Promise<{ entity: string }> }) {
|
|
48
|
+
const { entity } = await ctx.params;
|
|
49
|
+
try {
|
|
50
|
+
const session = await getSession();
|
|
51
|
+
if (!session) return unauthorized();
|
|
52
|
+
const config = getEntityConfig(entity);
|
|
53
|
+
if (!config) return unknownEntity();
|
|
54
|
+
const perms = await resolvePerms(session, config, entity);
|
|
55
|
+
if (!perms.read) return forbidden();
|
|
56
|
+
|
|
57
|
+
const sp = new URL(req.url).searchParams;
|
|
58
|
+
const params = {
|
|
59
|
+
page: parseInt(sp.get("page") || "1", 10),
|
|
60
|
+
pageSize: parseInt(sp.get("pageSize") || "10", 10),
|
|
61
|
+
search: sp.get("search") || undefined,
|
|
62
|
+
sort: sp.get("sortField")
|
|
63
|
+
? { field: sp.get("sortField")!, direction: (sp.get("sortDirection") as "asc" | "desc") || "asc" }
|
|
64
|
+
: undefined,
|
|
65
|
+
filters: sp.get("filters") ? JSON.parse(sp.get("filters")!) : undefined,
|
|
66
|
+
};
|
|
67
|
+
const data = await service.list(entity, config, params as any);
|
|
68
|
+
return json(data);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return fail(e, req);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function POST(req: Request, ctx: { params: Promise<{ entity: string }> }) {
|
|
75
|
+
const { entity } = await ctx.params;
|
|
76
|
+
try {
|
|
77
|
+
const session = await getSession();
|
|
78
|
+
if (!session) return unauthorized();
|
|
79
|
+
const config = getEntityConfig(entity);
|
|
80
|
+
if (!config) return unknownEntity();
|
|
81
|
+
const perms = await resolvePerms(session, config, entity);
|
|
82
|
+
if (!perms.create) return forbidden();
|
|
83
|
+
|
|
84
|
+
const body = await req.json();
|
|
85
|
+
if (Array.isArray(body)) {
|
|
86
|
+
const results = [];
|
|
87
|
+
for (const item of body) results.push(await service.create(entity, item, config));
|
|
88
|
+
return json(results);
|
|
89
|
+
}
|
|
90
|
+
return json(await service.create(entity, body, config));
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return fail(e, req);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { GET, POST };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// GET/PUT/PATCH/DELETE for the item route `/api/crud/[entity]/[id]`.
|
|
100
|
+
export function createCrudItemHandlers(deps: CrudHandlerDeps) {
|
|
101
|
+
const { getSession, getEntityConfig, service, onError } = deps;
|
|
102
|
+
const fail = (e: unknown, req: Request) =>
|
|
103
|
+
onError ? onError(e, req) : json({ error: "Internal error" }, 500);
|
|
104
|
+
|
|
105
|
+
type Ctx = { params: Promise<{ entity: string; id: string }> };
|
|
106
|
+
|
|
107
|
+
async function GET(req: Request, ctx: Ctx) {
|
|
108
|
+
const { entity, id } = await ctx.params;
|
|
109
|
+
try {
|
|
110
|
+
const session = await getSession();
|
|
111
|
+
if (!session) return unauthorized();
|
|
112
|
+
const config = getEntityConfig(entity);
|
|
113
|
+
if (!config) return unknownEntity();
|
|
114
|
+
const perms = await resolvePerms(session, config, entity);
|
|
115
|
+
if (!perms.read) return forbidden();
|
|
116
|
+
const item = await service.getById(entity, id);
|
|
117
|
+
if (!item) return json({ error: "Not found" }, 404);
|
|
118
|
+
return json(item);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return fail(e, req);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function PUT(req: Request, ctx: Ctx) {
|
|
125
|
+
const { entity, id } = await ctx.params;
|
|
126
|
+
try {
|
|
127
|
+
const session = await getSession();
|
|
128
|
+
if (!session) return unauthorized();
|
|
129
|
+
const config = getEntityConfig(entity);
|
|
130
|
+
if (!config) return unknownEntity();
|
|
131
|
+
const perms = await resolvePerms(session, config, entity);
|
|
132
|
+
if (!perms.update) return forbidden();
|
|
133
|
+
const body = await req.json();
|
|
134
|
+
return json(await service.update(entity, id, body, config));
|
|
135
|
+
} catch (e) {
|
|
136
|
+
return fail(e, req);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function DELETE(req: Request, ctx: Ctx) {
|
|
141
|
+
const { entity, id } = await ctx.params;
|
|
142
|
+
try {
|
|
143
|
+
const session = await getSession();
|
|
144
|
+
if (!session) return unauthorized();
|
|
145
|
+
const config = getEntityConfig(entity);
|
|
146
|
+
if (!config) return unknownEntity();
|
|
147
|
+
const perms = await resolvePerms(session, config, entity);
|
|
148
|
+
if (!perms.delete) return forbidden();
|
|
149
|
+
await service.delete(entity, id);
|
|
150
|
+
return new Response(null, { status: 204 });
|
|
151
|
+
} catch (e) {
|
|
152
|
+
return fail(e, req);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { GET, PUT, PATCH: PUT, DELETE };
|
|
157
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
// Generic server-side CRUD engine (Prisma-backed) for @goerp/core.
|
|
3
|
+
//
|
|
4
|
+
// Core is schema-agnostic, so this is a FACTORY: the consuming app injects its
|
|
5
|
+
// own Prisma client + entity→model resolver. The engine itself (query building,
|
|
6
|
+
// filter/sort/search allowlist, relation connect/disconnect, Decimal serialize)
|
|
7
|
+
// lives here so every app stops re-implementing it.
|
|
8
|
+
//
|
|
9
|
+
// Usage (app side):
|
|
10
|
+
// import { createServerCrudService, getModelName } from "@goerp/core/crud/server";
|
|
11
|
+
// import { prisma } from "@/lib/prisma";
|
|
12
|
+
// const MODEL_MAP = { customers: "customer", "fee-schedules": "feeSchedule" };
|
|
13
|
+
// export const crudService = createServerCrudService({
|
|
14
|
+
// prisma,
|
|
15
|
+
// getModelName: (e) => getModelName(e, MODEL_MAP),
|
|
16
|
+
// });
|
|
17
|
+
|
|
18
|
+
import type { CrudQueryParams, CrudResponse, EntityConfig } from "../types";
|
|
19
|
+
import { serializeDecimalFields } from "../utils/serialize";
|
|
20
|
+
|
|
21
|
+
type PrismaLike = Record<string, any>;
|
|
22
|
+
|
|
23
|
+
export interface ServerCrudService {
|
|
24
|
+
list(entity: string, config: EntityConfig, params: CrudQueryParams): Promise<CrudResponse>;
|
|
25
|
+
getById(entity: string, id: string): Promise<any>;
|
|
26
|
+
create(entity: string, data: any, config?: EntityConfig): Promise<any>;
|
|
27
|
+
update(entity: string, id: string, data: any, config?: EntityConfig): Promise<any>;
|
|
28
|
+
delete(entity: string, id: string): Promise<any>;
|
|
29
|
+
deleteMany(entity: string, ids: string[]): Promise<any>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ServerCrudLogger {
|
|
33
|
+
warn(message: string, ...rest: unknown[]): void;
|
|
34
|
+
error(message: string, ...rest: unknown[]): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ServerCrudDeps {
|
|
38
|
+
/** The app's PrismaClient instance. */
|
|
39
|
+
prisma: PrismaLike;
|
|
40
|
+
/** Resolve a plural entity key → Prisma model name. Defaults to {@link getModelName}. */
|
|
41
|
+
getModelName?: (entity: string) => string;
|
|
42
|
+
/** Optional: enrich rows with createdByName/updatedByName. Schema-tolerant — inject
|
|
43
|
+
* {@link createAuditUserNameResolver} if your User model exposes a name field. */
|
|
44
|
+
resolveAuditNames?: (records: any[]) => Promise<any[]>;
|
|
45
|
+
logger?: ServerCrudLogger;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const MAX_PAGE_SIZE = 200;
|
|
49
|
+
|
|
50
|
+
// Default plural→model resolver: strip trailing "s", camelCase kebab. Apps with
|
|
51
|
+
// irregular names pass a map: getModelName(entity, { "fee-schedules": "feeSchedule" }).
|
|
52
|
+
export function getModelName(entity: string, map?: Record<string, string>): string {
|
|
53
|
+
if (map && map[entity]) return map[entity];
|
|
54
|
+
return entity.replace(/s$/, "").replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Opt-in audit-name resolver. Overwrites createdByName/updatedByName from a User
|
|
58
|
+
// model. `nameField` handles schema drift (vinhhoa: "name", wu: "fullName").
|
|
59
|
+
export function createAuditUserNameResolver(opts: {
|
|
60
|
+
prisma: PrismaLike;
|
|
61
|
+
userModel?: string;
|
|
62
|
+
nameField?: string;
|
|
63
|
+
}): (records: any[]) => Promise<any[]> {
|
|
64
|
+
const { prisma, userModel = "user", nameField = "name" } = opts;
|
|
65
|
+
return async (records: any[]) => {
|
|
66
|
+
if (!records.length) return records;
|
|
67
|
+
const ids = new Set<string>();
|
|
68
|
+
for (const r of records) {
|
|
69
|
+
if (r?.createdBy) ids.add(r.createdBy);
|
|
70
|
+
if (r?.updatedBy) ids.add(r.updatedBy);
|
|
71
|
+
}
|
|
72
|
+
if (!ids.size) return records;
|
|
73
|
+
try {
|
|
74
|
+
const users = await prisma[userModel].findMany({
|
|
75
|
+
where: { id: { in: Array.from(ids) } },
|
|
76
|
+
select: { id: true, [nameField]: true },
|
|
77
|
+
});
|
|
78
|
+
const nameOf = new Map<string, string>(users.map((u: any) => [u.id, u[nameField] || u.id]));
|
|
79
|
+
return records.map((r) => ({
|
|
80
|
+
...r,
|
|
81
|
+
createdByName: r.createdBy ? nameOf.get(r.createdBy) ?? r.createdBy : null,
|
|
82
|
+
updatedByName: r.updatedBy ? nameOf.get(r.updatedBy) ?? r.updatedBy : null,
|
|
83
|
+
}));
|
|
84
|
+
} catch {
|
|
85
|
+
return records;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService {
|
|
91
|
+
const prisma = deps.prisma;
|
|
92
|
+
const resolveModel = deps.getModelName ?? ((e: string) => getModelName(e));
|
|
93
|
+
const resolveAuditNames = deps.resolveAuditNames ?? (async (r: any[]) => r);
|
|
94
|
+
const log: ServerCrudLogger = deps.logger ?? {
|
|
95
|
+
warn: (m, ...r) => console.warn(m, ...r),
|
|
96
|
+
error: (m, ...r) => console.error(m, ...r),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const model = (entity: string) => {
|
|
100
|
+
const name = resolveModel(entity);
|
|
101
|
+
const m = prisma[name];
|
|
102
|
+
if (!m) throw new Error(`Prisma model not found for entity: ${entity} (${name})`);
|
|
103
|
+
return m;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const filterValidFields = (data: any, config: EntityConfig) => {
|
|
107
|
+
const valid = new Set(config.fields.filter((f) => !(f as any).isDisplayOnly).map((f) => f.name));
|
|
108
|
+
valid.add("id");
|
|
109
|
+
const out: Record<string, unknown> = {};
|
|
110
|
+
for (const [k, v] of Object.entries(data)) {
|
|
111
|
+
if (valid.has(k)) out[k] = v;
|
|
112
|
+
else log.warn(`Filtering out invalid field "${k}" for entity "${config.name}"`);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const castFieldValues = (data: any, config: EntityConfig) => {
|
|
118
|
+
const out = { ...data };
|
|
119
|
+
for (const field of config.fields) {
|
|
120
|
+
const value = out[field.name];
|
|
121
|
+
if (value === undefined || value === null) continue;
|
|
122
|
+
if (field.type === "boolean" || field.type === "switch") {
|
|
123
|
+
let isTrue: boolean;
|
|
124
|
+
if (typeof value === "string") {
|
|
125
|
+
const lv = value.toLowerCase();
|
|
126
|
+
isTrue = lv === "true" || lv === "active" || value === "1" || value === "on";
|
|
127
|
+
} else isTrue = Boolean(value);
|
|
128
|
+
if (field.type === "switch" && field.options && field.options.length >= 2) {
|
|
129
|
+
out[field.name] = isTrue ? (field.options[0] as any).value : (field.options[1] as any).value;
|
|
130
|
+
} else out[field.name] = isTrue;
|
|
131
|
+
} else if (field.type === "number" || (field.type as string) === "integer") {
|
|
132
|
+
if (typeof value === "string") {
|
|
133
|
+
if (value.trim() === "") out[field.name] = null;
|
|
134
|
+
else {
|
|
135
|
+
const num = Number(value);
|
|
136
|
+
if (!isNaN(num)) out[field.name] = num;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const transformRelationFields = (data: any, mode: "create" | "update") => {
|
|
145
|
+
const out = { ...data };
|
|
146
|
+
const skip = new Set(["id", "createdBy", "updatedBy", "citizenId", "targetId"]);
|
|
147
|
+
for (const key of Object.keys(out)) {
|
|
148
|
+
if (skip.has(key)) continue;
|
|
149
|
+
if (key.endsWith("Id") && key.length > 2) {
|
|
150
|
+
const rel = key.slice(0, -2);
|
|
151
|
+
const value = out[key];
|
|
152
|
+
if (value && typeof value === "string" && value.trim() !== "") {
|
|
153
|
+
out[rel] = { connect: { id: value } };
|
|
154
|
+
delete out[key];
|
|
155
|
+
} else if (value === null || value === undefined || value === "") {
|
|
156
|
+
if (mode === "update") out[rel] = { disconnect: true };
|
|
157
|
+
delete out[key];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
async list(entity, config, params) {
|
|
166
|
+
const prismaModel = model(entity);
|
|
167
|
+
const { page = 1, pageSize = 10, search, sort, filters } = params;
|
|
168
|
+
const safePage = Math.max(1, Number(page) || 1);
|
|
169
|
+
const safePageSize = Math.min(Math.max(1, Number(pageSize) || 10), MAX_PAGE_SIZE);
|
|
170
|
+
const skip = (safePage - 1) * safePageSize;
|
|
171
|
+
const take = safePageSize;
|
|
172
|
+
|
|
173
|
+
const allowedFields = new Set<string>([
|
|
174
|
+
...config.fields.map((f) => f.name),
|
|
175
|
+
"id", "createdAt", "updatedAt", "createdBy", "updatedBy", config.idField || "id",
|
|
176
|
+
]);
|
|
177
|
+
const allowedRelations = new Set<string>(config.include || []);
|
|
178
|
+
const isAllowed = (name: string) => {
|
|
179
|
+
if (!name) return false;
|
|
180
|
+
if (name.includes(".")) return allowedRelations.has(name.split(".")[0]);
|
|
181
|
+
return allowedFields.has(name);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const where: any = {};
|
|
185
|
+
if (search && search.trim()) {
|
|
186
|
+
const term = search.trim();
|
|
187
|
+
const searchFields = config.fields.filter((f) => f.filterable && f.type === "text").map((f) => f.name);
|
|
188
|
+
if (searchFields.length) where.OR = searchFields.map((f) => ({ [f]: { contains: term, mode: "insensitive" } }));
|
|
189
|
+
}
|
|
190
|
+
if (filters && filters.length) {
|
|
191
|
+
for (const filter of filters) {
|
|
192
|
+
const { name, value, operator } = filter as any;
|
|
193
|
+
if (value === undefined || value === null || (Array.isArray(value) && value.length === 0)) continue;
|
|
194
|
+
if (!isAllowed(name)) {
|
|
195
|
+
log.warn(`Ignoring filter on disallowed field "${name}" for entity "${entity}"`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
let target = where;
|
|
199
|
+
let key = name;
|
|
200
|
+
if (name.includes(".")) {
|
|
201
|
+
const parts = name.split(".");
|
|
202
|
+
key = parts.pop()!;
|
|
203
|
+
for (const p of parts) { if (!target[p]) target[p] = {}; target = target[p]; }
|
|
204
|
+
}
|
|
205
|
+
const op = operator as string;
|
|
206
|
+
if (op === "contains") target[key] = { contains: value, mode: "insensitive" };
|
|
207
|
+
else if (op === "in") target[key] = { in: value };
|
|
208
|
+
else if (op === "notIn") target[key] = { notIn: value };
|
|
209
|
+
else if (op === "eq") target[key] = value;
|
|
210
|
+
else if (op === "ne") target[key] = { not: value };
|
|
211
|
+
else if (op === "gt") target[key] = { gt: value };
|
|
212
|
+
else if (op === "gte") target[key] = { gte: value };
|
|
213
|
+
else if (op === "lt") target[key] = { lt: value };
|
|
214
|
+
else if (op === "lte") target[key] = { lte: value };
|
|
215
|
+
else if (op === "startsWith") target[key] = { startsWith: value, mode: "insensitive" };
|
|
216
|
+
else if (op === "endsWith") target[key] = { endsWith: value, mode: "insensitive" };
|
|
217
|
+
else if (op === "isNull") target[key] = null;
|
|
218
|
+
else if (op === "isNotNull") target[key] = { not: null };
|
|
219
|
+
else target[key] = value;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const orderBy: any = {};
|
|
224
|
+
const applySort = (field: string, direction: any) => {
|
|
225
|
+
if (field.includes(".")) {
|
|
226
|
+
const parts = field.split(".");
|
|
227
|
+
const leaf = parts.pop()!;
|
|
228
|
+
let t = orderBy;
|
|
229
|
+
for (const p of parts) { t[p] = t[p] || {}; t = t[p]; }
|
|
230
|
+
t[leaf] = direction;
|
|
231
|
+
} else orderBy[field] = direction;
|
|
232
|
+
};
|
|
233
|
+
if (sort && isAllowed(sort.field)) applySort(sort.field, sort.direction);
|
|
234
|
+
else if (config.defaultSort) applySort(config.defaultSort.field, config.defaultSort.direction);
|
|
235
|
+
else if (config.fields.some((f) => f.name === "createdAt")) orderBy.createdAt = "desc";
|
|
236
|
+
else orderBy[config.idField || "id"] = "desc";
|
|
237
|
+
|
|
238
|
+
const include: any = {};
|
|
239
|
+
if (config.include?.length) config.include.forEach((inc) => (include[inc] = true));
|
|
240
|
+
const includeOption = Object.keys(include).length ? { include } : {};
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const [total, data] = await Promise.all([
|
|
244
|
+
prismaModel.count({ where }),
|
|
245
|
+
prismaModel.findMany({ where, orderBy, skip, take, ...includeOption }),
|
|
246
|
+
]);
|
|
247
|
+
const serialized = serializeDecimalFields(data);
|
|
248
|
+
const resolved = await resolveAuditNames(serialized as any[]);
|
|
249
|
+
return { data: resolved, total, page: safePage, pageSize: safePageSize } as CrudResponse;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
log.error(`Error listing ${entity}:`, error);
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
async getById(entity, id) {
|
|
257
|
+
const prismaModel = model(entity);
|
|
258
|
+
const result = await prismaModel.findUnique({ where: { id } });
|
|
259
|
+
if (!result) return null;
|
|
260
|
+
const serialized = serializeDecimalFields(result);
|
|
261
|
+
const [resolved] = await resolveAuditNames([serialized]);
|
|
262
|
+
return resolved;
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
async create(entity, data, config) {
|
|
266
|
+
const prismaModel = model(entity);
|
|
267
|
+
try {
|
|
268
|
+
let filtered = config ? filterValidFields(data, config) : data;
|
|
269
|
+
if (config) filtered = castFieldValues(filtered, config);
|
|
270
|
+
if (!filtered.id) filtered.id = crypto.randomUUID();
|
|
271
|
+
const prismaData = transformRelationFields(filtered, "create");
|
|
272
|
+
return serializeDecimalFields(await prismaModel.create({ data: prismaData }));
|
|
273
|
+
} catch (error) {
|
|
274
|
+
log.error(`Error creating ${entity}:`, error);
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
async update(entity, id, data, config) {
|
|
280
|
+
const prismaModel = model(entity);
|
|
281
|
+
try {
|
|
282
|
+
let filtered = config ? filterValidFields(data, config) : data;
|
|
283
|
+
if (config) filtered = castFieldValues(filtered, config);
|
|
284
|
+
const prismaData = transformRelationFields(filtered, "update");
|
|
285
|
+
return serializeDecimalFields(await prismaModel.update({ where: { id }, data: prismaData }));
|
|
286
|
+
} catch (error) {
|
|
287
|
+
log.error(`Error updating ${entity}:`, error);
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
async delete(entity, id) {
|
|
293
|
+
const prismaModel = model(entity);
|
|
294
|
+
try {
|
|
295
|
+
return await prismaModel.delete({ where: { id } });
|
|
296
|
+
} catch (error) {
|
|
297
|
+
log.error(`Error deleting ${entity}:`, error);
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
async deleteMany(entity, ids) {
|
|
303
|
+
const prismaModel = model(entity);
|
|
304
|
+
try {
|
|
305
|
+
return await prismaModel.deleteMany({ where: { id: { in: ids } } });
|
|
306
|
+
} catch (error) {
|
|
307
|
+
log.error(`Error deleting many ${entity}:`, error);
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
package/src/crud/server.ts
CHANGED
|
@@ -6,3 +6,21 @@ export {
|
|
|
6
6
|
getCrudPermissions,
|
|
7
7
|
mergePermissions,
|
|
8
8
|
} from './lib/permissions'
|
|
9
|
+
|
|
10
|
+
// Generic Prisma-backed CRUD engine + Next.js route-handler factories.
|
|
11
|
+
// Apps inject their PrismaClient + entity→model map; the engine lives in core.
|
|
12
|
+
export {
|
|
13
|
+
createServerCrudService,
|
|
14
|
+
getModelName,
|
|
15
|
+
createAuditUserNameResolver,
|
|
16
|
+
} from './server-service'
|
|
17
|
+
export type {
|
|
18
|
+
ServerCrudService,
|
|
19
|
+
ServerCrudDeps,
|
|
20
|
+
ServerCrudLogger,
|
|
21
|
+
} from './server-service'
|
|
22
|
+
export {
|
|
23
|
+
createCrudCollectionHandlers,
|
|
24
|
+
createCrudItemHandlers,
|
|
25
|
+
} from './crud-route-handlers'
|
|
26
|
+
export type { CrudHandlerDeps } from './crud-route-handlers'
|
package/src/rbac/role-service.ts
CHANGED
|
@@ -18,6 +18,23 @@ export type RoleFilters = {
|
|
|
18
18
|
status?: string;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
// Schema-tolerance seam: apps whose User/Role tables diverge from the default
|
|
22
|
+
// (name/email/image/isActive + Role.createdAt/updatedAt) pass field overrides so
|
|
23
|
+
// getRolesData works without forking. E.g. wu-vpbank:
|
|
24
|
+
// { userNameField: "fullName", userActiveField: "active", userImageField: null, roleTimestamps: false }
|
|
25
|
+
export type RoleServiceSchema = {
|
|
26
|
+
/** User display-name column. Default "name". */
|
|
27
|
+
userNameField?: string;
|
|
28
|
+
/** User email column. Default "email". */
|
|
29
|
+
userEmailField?: string;
|
|
30
|
+
/** User avatar column, or null if the table has none. Default "image". */
|
|
31
|
+
userImageField?: string | null;
|
|
32
|
+
/** User active-flag column. Default "isActive". */
|
|
33
|
+
userActiveField?: string;
|
|
34
|
+
/** Whether Role has createdAt/updatedAt. Default true; false → order by name, blank timestamps. */
|
|
35
|
+
roleTimestamps?: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
21
38
|
export type RoleData = {
|
|
22
39
|
id: string;
|
|
23
40
|
name: string;
|
|
@@ -68,6 +85,7 @@ export interface RolePrismaClient {
|
|
|
68
85
|
export async function getRolesData(
|
|
69
86
|
db: RolePrismaClient,
|
|
70
87
|
params: RoleFilters = {},
|
|
88
|
+
schema: RoleServiceSchema = {},
|
|
71
89
|
): Promise<{
|
|
72
90
|
total: number;
|
|
73
91
|
page: number;
|
|
@@ -76,6 +94,13 @@ export async function getRolesData(
|
|
|
76
94
|
}> {
|
|
77
95
|
const { page = 1, pageSize = 10, search, status } = params;
|
|
78
96
|
|
|
97
|
+
// Schema knobs (default to the canonical name/email/image/isActive + timestamps).
|
|
98
|
+
const nameField = schema.userNameField ?? "name";
|
|
99
|
+
const emailField = schema.userEmailField ?? "email";
|
|
100
|
+
const imageField = schema.userImageField === undefined ? "image" : schema.userImageField;
|
|
101
|
+
const activeField = schema.userActiveField ?? "isActive";
|
|
102
|
+
const hasTimestamps = schema.roleTimestamps ?? true;
|
|
103
|
+
|
|
79
104
|
const whereConditions: any[] = [];
|
|
80
105
|
|
|
81
106
|
if (search) {
|
|
@@ -94,42 +119,24 @@ export async function getRolesData(
|
|
|
94
119
|
|
|
95
120
|
const where: any = whereConditions.length > 0 ? { AND: whereConditions } : {};
|
|
96
121
|
|
|
122
|
+
// Build the user select from the (possibly overridden) field names.
|
|
123
|
+
const userSelect: Record<string, boolean> = { id: true, [nameField]: true, [emailField]: true };
|
|
124
|
+
if (imageField) userSelect[imageField] = true;
|
|
125
|
+
if (activeField) userSelect[activeField] = true;
|
|
126
|
+
|
|
97
127
|
const [total, items] = await Promise.all([
|
|
98
128
|
db.role.count({ where }),
|
|
99
129
|
db.role.findMany({
|
|
100
130
|
where,
|
|
101
|
-
orderBy: { createdAt: "desc" },
|
|
131
|
+
orderBy: hasTimestamps ? { createdAt: "desc" } : { name: "asc" },
|
|
102
132
|
skip: (page - 1) * pageSize,
|
|
103
133
|
take: pageSize,
|
|
104
134
|
include: {
|
|
105
|
-
userRoles: {
|
|
106
|
-
include: {
|
|
107
|
-
user: {
|
|
108
|
-
select: {
|
|
109
|
-
id: true,
|
|
110
|
-
name: true,
|
|
111
|
-
email: true,
|
|
112
|
-
image: true,
|
|
113
|
-
isActive: true,
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
},
|
|
135
|
+
userRoles: { include: { user: { select: userSelect } } },
|
|
118
136
|
rolePermissions: {
|
|
119
137
|
include: {
|
|
120
|
-
resource: {
|
|
121
|
-
|
|
122
|
-
code: true,
|
|
123
|
-
name: true,
|
|
124
|
-
icon: true,
|
|
125
|
-
},
|
|
126
|
-
},
|
|
127
|
-
action: {
|
|
128
|
-
select: {
|
|
129
|
-
code: true,
|
|
130
|
-
name: true,
|
|
131
|
-
},
|
|
132
|
-
},
|
|
138
|
+
resource: { select: { code: true, name: true, icon: true } },
|
|
139
|
+
action: { select: { code: true, name: true } },
|
|
133
140
|
},
|
|
134
141
|
},
|
|
135
142
|
},
|
|
@@ -150,13 +157,13 @@ export async function getRolesData(
|
|
|
150
157
|
usersCount: role.userRoles.length,
|
|
151
158
|
users: role.userRoles.map((ur: any) => ({
|
|
152
159
|
id: ur.user.id,
|
|
153
|
-
name: ur.user
|
|
154
|
-
email: ur.user
|
|
155
|
-
image: ur.user
|
|
156
|
-
isActive: ur.user
|
|
160
|
+
name: ur.user[nameField] ?? null,
|
|
161
|
+
email: ur.user[emailField] ?? null,
|
|
162
|
+
image: imageField ? ur.user[imageField] ?? null : null,
|
|
163
|
+
isActive: activeField ? Boolean(ur.user[activeField]) : true,
|
|
157
164
|
})),
|
|
158
|
-
createdAt: role.createdAt.toISOString(),
|
|
159
|
-
updatedAt: role.updatedAt.toISOString(),
|
|
165
|
+
createdAt: hasTimestamps && role.createdAt ? role.createdAt.toISOString() : "",
|
|
166
|
+
updatedAt: hasTimestamps && role.updatedAt ? role.updatedAt.toISOString() : "",
|
|
160
167
|
createdBy: role.createdBy || undefined,
|
|
161
168
|
updatedBy: role.updatedBy || undefined,
|
|
162
169
|
}));
|
package/src/ui/index.tsx
CHANGED
|
@@ -85,44 +85,10 @@ export function PageTabs({
|
|
|
85
85
|
currentTabIndex >= 0 && currentTabIndex < sortedTabs.length - 1;
|
|
86
86
|
const hasOtherTabs = sortedTabs.length > 1;
|
|
87
87
|
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// Generate consistent hash from path
|
|
93
|
-
const normalizedPath = path.replace(/^\/[a-z]{2}(\/|$)/, "/");
|
|
94
|
-
const hash = normalizedPath.split("").reduce((acc, char) => {
|
|
95
|
-
return (acc << 5) - acc + char.charCodeAt(0);
|
|
96
|
-
}, 0);
|
|
97
|
-
|
|
98
|
-
// Refined gradient palette - darker and more visible (using dark theme colors for both modes)
|
|
99
|
-
const gradients = [
|
|
100
|
-
"bg-gradient-to-r from-slate-700 to-slate-600",
|
|
101
|
-
"bg-gradient-to-r from-zinc-700 to-zinc-600",
|
|
102
|
-
"bg-gradient-to-r from-stone-700 to-stone-600",
|
|
103
|
-
"bg-gradient-to-r from-neutral-700 to-neutral-600",
|
|
104
|
-
"bg-gradient-to-r from-blue-700 to-blue-600",
|
|
105
|
-
"bg-gradient-to-r from-indigo-700 to-indigo-600",
|
|
106
|
-
"bg-gradient-to-r from-purple-700 to-purple-600",
|
|
107
|
-
"bg-gradient-to-r from-violet-700 to-violet-600",
|
|
108
|
-
"bg-gradient-to-r from-fuchsia-700 to-fuchsia-600",
|
|
109
|
-
"bg-gradient-to-r from-pink-700 to-pink-600",
|
|
110
|
-
"bg-gradient-to-r from-rose-700 to-rose-600",
|
|
111
|
-
"bg-gradient-to-r from-red-700 to-red-600",
|
|
112
|
-
"bg-gradient-to-r from-orange-700 to-orange-600",
|
|
113
|
-
"bg-gradient-to-r from-amber-700 to-amber-600",
|
|
114
|
-
"bg-gradient-to-r from-yellow-700 to-yellow-600",
|
|
115
|
-
"bg-gradient-to-r from-lime-700 to-lime-600",
|
|
116
|
-
"bg-gradient-to-r from-green-700 to-green-600",
|
|
117
|
-
"bg-gradient-to-r from-emerald-700 to-emerald-600",
|
|
118
|
-
"bg-gradient-to-r from-teal-700 to-teal-600",
|
|
119
|
-
"bg-gradient-to-r from-cyan-700 to-cyan-600",
|
|
120
|
-
"bg-gradient-to-r from-sky-700 to-sky-600",
|
|
121
|
-
];
|
|
122
|
-
|
|
123
|
-
// Select gradient based on hash to ensure consistency
|
|
124
|
-
const gradientIndex = Math.abs(hash) % gradients.length;
|
|
125
|
-
return gradients[gradientIndex];
|
|
88
|
+
// Neutral, single-style tab background (was a per-path rainbow gradient).
|
|
89
|
+
// Active tabs sit on the page surface; inactive tabs are a muted chip.
|
|
90
|
+
const getTabBackgroundColor = (_path: string, isActive: boolean) => {
|
|
91
|
+
return isActive ? "bg-background" : "bg-muted/60";
|
|
126
92
|
};
|
|
127
93
|
|
|
128
94
|
return (
|
|
@@ -163,21 +129,21 @@ export function PageTabs({
|
|
|
163
129
|
className={cn(
|
|
164
130
|
"group relative flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-all duration-200",
|
|
165
131
|
"border border-transparent",
|
|
166
|
-
"
|
|
132
|
+
"cursor-pointer",
|
|
167
133
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
|
168
134
|
getTabBackgroundColor(tab.path, isActive),
|
|
169
135
|
variant === "default" &&
|
|
170
136
|
(isActive
|
|
171
137
|
? "bg-background text-foreground border-b-2 border-primary shadow-sm shadow-primary/10 border-t border-x border-b-0 rounded-t-md -mb-px z-10"
|
|
172
|
-
: "text-
|
|
138
|
+
: "text-muted-foreground hover:bg-muted hover:text-foreground border-b border-border rounded-t-md"),
|
|
173
139
|
variant === "header" &&
|
|
174
140
|
(isActive
|
|
175
141
|
? "bg-background text-foreground border-b-2 border-primary shadow-sm shadow-primary/10 border-t border-x rounded-t-md z-10"
|
|
176
|
-
: "text-
|
|
142
|
+
: "text-muted-foreground hover:bg-muted hover:text-foreground border-b border-border rounded-t-md"),
|
|
177
143
|
!isLast &&
|
|
178
144
|
!isActive &&
|
|
179
145
|
variant === "default" &&
|
|
180
|
-
"border-r border-
|
|
146
|
+
"border-r border-border",
|
|
181
147
|
)}
|
|
182
148
|
>
|
|
183
149
|
{/* Tab number indicator (for keyboard shortcuts) */}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @goerp/core/ui shared app-level components (promoted from vinhhoa/wu so apps
|
|
2
|
+
// stop copying them). Re-exported through the ui barrel.
|
|
3
|
+
export * from "./page-header";
|
|
4
|
+
export * from "./status-indicator";
|
|
5
|
+
export * from "./table-sum-footer";
|
|
6
|
+
export * from "./table-styles";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PageHeader — standardized page header (title + description + icon + actions).
|
|
5
|
+
* Promoted from vinhhoa/wu; every app was copying this.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { memo } from "react";
|
|
9
|
+
import { cn } from "../../utils";
|
|
10
|
+
|
|
11
|
+
export interface PageHeaderProps {
|
|
12
|
+
/** Page title (required) */
|
|
13
|
+
title: string;
|
|
14
|
+
/** Optional subtitle/description */
|
|
15
|
+
description?: string;
|
|
16
|
+
/** Optional icon displayed before the title */
|
|
17
|
+
icon?: React.ReactNode;
|
|
18
|
+
/** Right-side actions (buttons, filters, etc.) */
|
|
19
|
+
actions?: React.ReactNode;
|
|
20
|
+
/** Breadcrumb node above the title */
|
|
21
|
+
breadcrumbs?: React.ReactNode;
|
|
22
|
+
/** Extra content below the title/actions row */
|
|
23
|
+
children?: React.ReactNode;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const PageHeader = memo(function PageHeader({
|
|
28
|
+
title,
|
|
29
|
+
description,
|
|
30
|
+
icon,
|
|
31
|
+
actions,
|
|
32
|
+
breadcrumbs,
|
|
33
|
+
children,
|
|
34
|
+
className,
|
|
35
|
+
}: PageHeaderProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div className={cn("space-y-1", className)}>
|
|
38
|
+
{breadcrumbs && <div className="text-xs text-text-tertiary">{breadcrumbs}</div>}
|
|
39
|
+
|
|
40
|
+
<div className="flex items-center justify-between gap-4">
|
|
41
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
42
|
+
{icon && <div className="flex-shrink-0 text-text-secondary">{icon}</div>}
|
|
43
|
+
<div className="min-w-0">
|
|
44
|
+
<h1 className="truncate text-lg font-semibold text-text-primary">{title}</h1>
|
|
45
|
+
{description && (
|
|
46
|
+
<p className="mt-0.5 truncate text-sm text-text-secondary">{description}</p>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
{actions && <div className="flex flex-shrink-0 items-center gap-2">{actions}</div>}
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
{children && <div className="mt-3">{children}</div>}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* StatusIndicator — Semantic status dot + text component
|
|
5
|
+
* Inspired by Plane's priority-icon pattern
|
|
6
|
+
*
|
|
7
|
+
* Maps ERP status strings to semantic colors automatically.
|
|
8
|
+
* Supports custom color overrides for non-standard statuses.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* <StatusIndicator status="active" />
|
|
12
|
+
* <StatusIndicator status="pending" />
|
|
13
|
+
* <StatusIndicator status="cancelled" label="Đã hủy" />
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React, { memo, useMemo } from "react"
|
|
17
|
+
import { cn } from "../../utils"
|
|
18
|
+
|
|
19
|
+
// ── Status → Semantic Color Map ──────────────────────────────
|
|
20
|
+
const STATUS_MAP: Record<string, { color: string; dotClass: string; label: string }> = {
|
|
21
|
+
// Active / Positive
|
|
22
|
+
active: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Hoạt động" },
|
|
23
|
+
completed: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Hoàn thành" },
|
|
24
|
+
delivered: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Đã giao" },
|
|
25
|
+
paid: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Đã thanh toán" },
|
|
26
|
+
approved: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Đã duyệt" },
|
|
27
|
+
director_approved: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Giám đốc đã duyệt" },
|
|
28
|
+
confirmed: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Đã xác nhận" },
|
|
29
|
+
received: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Đã nhận" },
|
|
30
|
+
delivery_completed: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Hoàn thành" },
|
|
31
|
+
supplier_confirmed: { color: "text-success-text", dotClass: "bg-success-semantic", label: "NCC xác nhận" },
|
|
32
|
+
|
|
33
|
+
// Pending / Warning
|
|
34
|
+
pending: { color: "text-warning-text", dotClass: "bg-warning", label: "Chờ xử lý" },
|
|
35
|
+
processing: { color: "text-warning-text", dotClass: "bg-warning", label: "Đang xử lý" },
|
|
36
|
+
partial: { color: "text-warning-text", dotClass: "bg-warning", label: "Một phần" },
|
|
37
|
+
partially_paid:{ color: "text-warning-text", dotClass: "bg-warning", label: "TT một phần" },
|
|
38
|
+
waiting: { color: "text-warning-text", dotClass: "bg-warning", label: "Đang chờ" },
|
|
39
|
+
sent: { color: "text-warning-text", dotClass: "bg-warning", label: "Chờ duyệt" },
|
|
40
|
+
pending_l1: { color: "text-warning-text", dotClass: "bg-warning", label: "Chờ duyệt L1" },
|
|
41
|
+
pending_l2: { color: "text-warning-text", dotClass: "bg-warning", label: "Chờ duyệt L2" },
|
|
42
|
+
pending_l3: { color: "text-warning-text", dotClass: "bg-warning", label: "Chờ duyệt L3" },
|
|
43
|
+
pending_control: { color: "text-warning-text", dotClass: "bg-warning", label: "Chờ kiểm soát" },
|
|
44
|
+
accounting_pending: { color: "text-warning-text", dotClass: "bg-warning", label: "Chờ KT duyệt" },
|
|
45
|
+
director_pending: { color: "text-warning-text", dotClass: "bg-warning", label: "Chờ BGĐ duyệt" },
|
|
46
|
+
accounting_revision: { color: "text-warning-text", dotClass: "bg-warning", label: "Chỉnh sửa" },
|
|
47
|
+
waiting_supplier:{ color: "text-warning-text", dotClass: "bg-warning", label: "Chờ NCC" },
|
|
48
|
+
partially_received: { color: "text-warning-text", dotClass: "bg-warning", label: "Nhận một phần" },
|
|
49
|
+
delivery_adjustment: { color: "text-warning-text", dotClass: "bg-warning", label: "Điều chỉnh giao hàng" },
|
|
50
|
+
|
|
51
|
+
// Info / In-progress
|
|
52
|
+
ordered: { color: "text-info-text", dotClass: "bg-info", label: "Đã đặt" },
|
|
53
|
+
shipping: { color: "text-info-text", dotClass: "bg-info", label: "Đang giao" },
|
|
54
|
+
in_transit: { color: "text-info-text", dotClass: "bg-info", label: "Đang vận chuyển" },
|
|
55
|
+
delivering: { color: "text-info-text", dotClass: "bg-info", label: "Đang giao hàng" },
|
|
56
|
+
accounting_approved: { color: "text-info-text", dotClass: "bg-info", label: "Kế toán đã duyệt" },
|
|
57
|
+
|
|
58
|
+
// Danger / Negative
|
|
59
|
+
cancelled: { color: "text-danger-text", dotClass: "bg-danger", label: "Đã hủy" },
|
|
60
|
+
overdue: { color: "text-danger-text", dotClass: "bg-danger", label: "Quá hạn" },
|
|
61
|
+
rejected: { color: "text-danger-text", dotClass: "bg-danger", label: "Từ chối" },
|
|
62
|
+
failed: { color: "text-danger-text", dotClass: "bg-danger", label: "Thất bại" },
|
|
63
|
+
inactive: { color: "text-danger-text", dotClass: "bg-danger", label: "Ngừng hoạt động" },
|
|
64
|
+
unpaid: { color: "text-danger-text", dotClass: "bg-danger", label: "Chưa thanh toán" },
|
|
65
|
+
refunded: { color: "text-danger-text", dotClass: "bg-danger", label: "Đã hoàn tiền" },
|
|
66
|
+
returned: { color: "text-danger-text", dotClass: "bg-danger", label: "Hoàn trả" },
|
|
67
|
+
|
|
68
|
+
// Neutral
|
|
69
|
+
draft: { color: "text-text-tertiary", dotClass: "bg-text-tertiary", label: "Nháp" },
|
|
70
|
+
archived: { color: "text-text-disabled", dotClass: "bg-text-disabled", label: "Lưu trữ" },
|
|
71
|
+
unknown: { color: "text-text-tertiary", dotClass: "bg-text-tertiary", label: "Không xác định" },
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Tone (text color) → Badge variant ───────────────────────
|
|
75
|
+
// Lets callers render the SAME status taxonomy as a filled colored Badge
|
|
76
|
+
// (bg + text) instead of a bare dot. Single source of truth = STATUS_MAP.
|
|
77
|
+
const TONE_TO_BADGE_VARIANT: Record<
|
|
78
|
+
string,
|
|
79
|
+
"success" | "warning" | "danger" | "info" | "secondary"
|
|
80
|
+
> = {
|
|
81
|
+
"text-success-text": "success",
|
|
82
|
+
"text-warning-text": "warning",
|
|
83
|
+
"text-danger-text": "danger",
|
|
84
|
+
"text-info-text": "info",
|
|
85
|
+
"text-text-tertiary": "secondary",
|
|
86
|
+
"text-text-disabled": "secondary",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── "Solid / Trầm sang" (direction D) chip styles ────────────
|
|
90
|
+
// Deep step-700 fills + white text. The subtle Badge variants (green-3 etc.)
|
|
91
|
+
// wash out on dark surfaces like the navy order header; these read crisply on
|
|
92
|
+
// both white and navy. Keyed by Badge variant so it tracks the same taxonomy.
|
|
93
|
+
const SOLID_STATUS_CLASS: Record<string, string> = {
|
|
94
|
+
success: "bg-emerald-700 text-white",
|
|
95
|
+
warning: "bg-amber-700 text-white",
|
|
96
|
+
danger: "bg-red-700 text-white",
|
|
97
|
+
info: "bg-blue-700 text-white",
|
|
98
|
+
secondary: "bg-slate-600 text-white",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve a status string to its canonical label + colors + matching Badge
|
|
103
|
+
* variant. Use when you need a filled colored chip (e.g. the order header)
|
|
104
|
+
* rather than the dot-only `StatusIndicator`.
|
|
105
|
+
*
|
|
106
|
+
* - `badgeVariant`: the subtle semantic Badge variant (good on white surfaces).
|
|
107
|
+
* - `solidClass`: deep "Trầm sang" fill + white text (good on navy/dark bars).
|
|
108
|
+
*/
|
|
109
|
+
export function getStatusMeta(status: string, labelOverride?: string) {
|
|
110
|
+
const key = status?.toLowerCase().replace(/\s+/g, "_") ?? "unknown"
|
|
111
|
+
const resolved = STATUS_MAP[key] ?? STATUS_MAP.unknown
|
|
112
|
+
const badgeVariant = TONE_TO_BADGE_VARIANT[resolved.color] ?? "secondary"
|
|
113
|
+
return {
|
|
114
|
+
label: labelOverride ?? resolved.label,
|
|
115
|
+
color: resolved.color,
|
|
116
|
+
dotClass: resolved.dotClass,
|
|
117
|
+
badgeVariant,
|
|
118
|
+
solidClass: SOLID_STATUS_CLASS[badgeVariant] ?? SOLID_STATUS_CLASS.secondary,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface StatusIndicatorProps {
|
|
123
|
+
/** Status key (e.g., "active", "pending", "cancelled") */
|
|
124
|
+
status: string
|
|
125
|
+
/** Override the default Vietnamese label */
|
|
126
|
+
label?: string
|
|
127
|
+
/** Size variant */
|
|
128
|
+
size?: "sm" | "md" | "lg"
|
|
129
|
+
/** Show dot only (no text) */
|
|
130
|
+
dotOnly?: boolean
|
|
131
|
+
/** Custom dot color class override */
|
|
132
|
+
dotColor?: string
|
|
133
|
+
/** Additional CSS classes */
|
|
134
|
+
className?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const StatusIndicator = memo(function StatusIndicator({
|
|
138
|
+
status,
|
|
139
|
+
label: labelOverride,
|
|
140
|
+
size = "md",
|
|
141
|
+
dotOnly = false,
|
|
142
|
+
dotColor,
|
|
143
|
+
className,
|
|
144
|
+
}: StatusIndicatorProps) {
|
|
145
|
+
const resolved = useMemo(() => {
|
|
146
|
+
const key = status?.toLowerCase().replace(/\s+/g, "_") ?? "unknown"
|
|
147
|
+
return STATUS_MAP[key] ?? STATUS_MAP.unknown
|
|
148
|
+
}, [status])
|
|
149
|
+
|
|
150
|
+
const dotSizeClass = size === "sm" ? "size-1.5" : size === "lg" ? "size-3" : "size-2"
|
|
151
|
+
const textSizeClass = size === "sm" ? "text-[11px]" : size === "lg" ? "text-sm" : "text-xs"
|
|
152
|
+
const displayLabel = labelOverride ?? resolved.label
|
|
153
|
+
|
|
154
|
+
if (dotOnly) {
|
|
155
|
+
return (
|
|
156
|
+
<span
|
|
157
|
+
className={cn("inline-block rounded-full", dotColor ?? resolved.dotClass, dotSizeClass, className)}
|
|
158
|
+
title={displayLabel}
|
|
159
|
+
/>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<span className={cn("inline-flex items-center gap-1.5", className)}>
|
|
165
|
+
<span
|
|
166
|
+
className={cn("inline-block rounded-full flex-shrink-0", dotColor ?? resolved.dotClass, dotSizeClass)}
|
|
167
|
+
/>
|
|
168
|
+
<span className={cn("font-medium", resolved.color, textSizeClass)}>
|
|
169
|
+
{displayLabel}
|
|
170
|
+
</span>
|
|
171
|
+
</span>
|
|
172
|
+
)
|
|
173
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Hợp đồng style bảng CHUẨN của app — trích từ bảng "Đơn bán hàng" của vinhhoa
|
|
2
|
+
// để MỌI trang có bảng (trừ trang CRUD generic) dùng lại đồng nhất: header dính,
|
|
3
|
+
// hàng zebra + viền trái primary khi hover, số tiền vàng, footer dính.
|
|
4
|
+
//
|
|
5
|
+
// Cách dùng: import các hằng này thay vì tự viết class rời rạc.
|
|
6
|
+
// <div className={tableShell}><div className={tableScroll}>
|
|
7
|
+
// <Table className={cn(tableBase, "min-w-[1200px]")}>
|
|
8
|
+
// <TableHeader className={theadSticky}><TableRow className={headerRow}>
|
|
9
|
+
// <TableHead className={thClass}>…</TableHead>
|
|
10
|
+
// …
|
|
11
|
+
// <TableRow className={bodyRow} onClick={…}>
|
|
12
|
+
// <TableCell className={sttCell}>{i+1}</TableCell>
|
|
13
|
+
// <TableCell className={moneyCell} style={{ color: MONEY_GOLD }}>…</TableCell>
|
|
14
|
+
|
|
15
|
+
export const tableShell =
|
|
16
|
+
"flex h-full flex-col overflow-hidden rounded-md border border-border bg-card shadow-sm";
|
|
17
|
+
|
|
18
|
+
export const tableScroll = "relative min-h-0 flex-1 overflow-auto show-scrollbar";
|
|
19
|
+
|
|
20
|
+
// w-full ép bảng GIÃN HẾT chiều rộng khung (core mặc định w-max → co cụm theo nội
|
|
21
|
+
// dung trên màn rộng; w-full đè lại). Kèm min-w-[Xpx] ở từng bảng để cuộn ngang
|
|
22
|
+
// khi màn hẹp. border-separate để nền header/footer dính không bị viền cell xuyên qua.
|
|
23
|
+
export const tableBase = "w-full border-separate border-spacing-0";
|
|
24
|
+
|
|
25
|
+
export const theadSticky = "sticky top-0 z-20 bg-card";
|
|
26
|
+
|
|
27
|
+
export const headerRow = "border-b-2 border-border hover:bg-transparent";
|
|
28
|
+
|
|
29
|
+
export const thClass = "whitespace-nowrap bg-card py-2.5 text-xs font-semibold text-foreground";
|
|
30
|
+
|
|
31
|
+
// Hàng dữ liệu: con trỏ tay, zebra, hover đổi nền + viền trái primary (không xê dịch).
|
|
32
|
+
export const bodyRow =
|
|
33
|
+
"group relative cursor-pointer border-b border-l-[3px] border-border/40 border-l-transparent transition-colors even:bg-muted/30 hover:border-l-primary hover:bg-accent/50 dark:hover:bg-slate-800/40";
|
|
34
|
+
|
|
35
|
+
// Hàng không bấm được (chỉ hiển thị) — bỏ con trỏ tay.
|
|
36
|
+
export const bodyRowStatic =
|
|
37
|
+
"group relative border-b border-l-[3px] border-border/40 border-l-transparent transition-colors even:bg-muted/30 hover:bg-accent/40";
|
|
38
|
+
|
|
39
|
+
export const sttCell = "text-center text-xs tabular-nums text-muted-foreground";
|
|
40
|
+
|
|
41
|
+
export const moneyCell = "whitespace-nowrap text-right font-bold tabular-nums";
|
|
42
|
+
|
|
43
|
+
// Vàng tiền tệ dùng thống nhất toàn app (giống ô "Tổng tiền" đơn bán hàng).
|
|
44
|
+
export const MONEY_GOLD = "#c8860b";
|
|
45
|
+
|
|
46
|
+
export const tfootSticky =
|
|
47
|
+
"sticky bottom-0 z-20 border-t-2 border-border bg-card [&>tr]:hover:bg-transparent";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { TableCell } from "../primitives";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Ô tổng dùng chung cho dòng <TableFooter> của các bảng danh sách — bám mẫu
|
|
9
|
+
* đơn bán hàng vinhhoa. `value` là tổng TOÀN BỘ kết quả sau lọc (mọi trang),
|
|
10
|
+
* không phải tổng của riêng trang hiển thị.
|
|
11
|
+
*/
|
|
12
|
+
export function SumFooterCell({
|
|
13
|
+
value,
|
|
14
|
+
note,
|
|
15
|
+
align = "right",
|
|
16
|
+
colSpan,
|
|
17
|
+
className,
|
|
18
|
+
}: {
|
|
19
|
+
value: string;
|
|
20
|
+
note?: string;
|
|
21
|
+
align?: "left" | "center" | "right";
|
|
22
|
+
colSpan?: number;
|
|
23
|
+
className?: string;
|
|
24
|
+
}) {
|
|
25
|
+
const alignText = align === "left" ? "text-left" : align === "center" ? "text-center" : "text-right";
|
|
26
|
+
const alignItems = align === "left" ? "items-start" : align === "center" ? "items-center" : "items-end";
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<TableCell colSpan={colSpan} className={cn("whitespace-nowrap bg-card py-2 tabular-nums", alignText, className)}>
|
|
30
|
+
<div className={cn("flex flex-col leading-tight", alignItems)}>
|
|
31
|
+
<span className="font-bold text-foreground">{value}</span>
|
|
32
|
+
{note != null && <span className="text-[10px] font-medium text-muted-foreground">{note}</span>}
|
|
33
|
+
</div>
|
|
34
|
+
</TableCell>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Cộng nhanh một trường số trên mảng dòng (tổng-trang phía client). */
|
|
39
|
+
export function sumBy<T>(rows: T[], pick: (row: T) => number | null | undefined) {
|
|
40
|
+
return rows.reduce((acc, row) => acc + (Number(pick(row)) || 0), 0);
|
|
41
|
+
}
|