@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 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.13",
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
+ }
@@ -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'
@@ -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
- select: {
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.name,
154
- email: ur.user.email,
155
- image: ur.user.image,
156
- isActive: ur.user.isActive,
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
@@ -15,3 +15,4 @@ export * from "./auth";
15
15
  // export * from "./crud"
16
16
  export * from "./management";
17
17
  export * from "./pages/not-found";
18
+ export * from "./shared";
@@ -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
- // Generate unique gradient background for each tab based on path
89
- const getTabBackgroundColor = (path: string, isActive: boolean) => {
90
- if (isActive) return "bg-background";
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
- "hover:brightness-105 cursor-pointer",
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-white hover:text-white border-b border-white/10 rounded-t-md"),
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-white hover:text-white border-b border-white/10 rounded-t-md"),
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-white/10",
146
+ "border-r border-border",
181
147
  )}
182
148
  >
183
149
  {/* Tab number indicator (for keyboard shortcuts) */}
@@ -132,3 +132,4 @@ export * from "./toggle";
132
132
  export * from "./toggle-group";
133
133
  export * from "./combobox";
134
134
  export * from "./label";
135
+ export * from "./dynamic-icon";
@@ -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
+ }