@goplusvn/core 0.1.13 → 0.1.15
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 +65 -39
- 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/confirm-dialog.tsx +66 -0
- package/src/ui/shared/index.ts +9 -0
- package/src/ui/shared/list-toolbar.tsx +263 -0
- package/src/ui/shared/page-header.tsx +57 -0
- package/src/ui/shared/stat-bar.tsx +38 -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
|
@@ -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,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// ConfirmDialog — promoted from vinhhoa/wu (every app copied it). Imports from
|
|
4
|
+
// sub-barrels (not ../index) to avoid a circular ui/shared ↔ ui/index import.
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
import { Button } from "../primitives";
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
DialogHeader,
|
|
13
|
+
DialogTitle,
|
|
14
|
+
} from "../feedback";
|
|
15
|
+
import { AlertTriangle, Loader2 } from "lucide-react";
|
|
16
|
+
|
|
17
|
+
export interface ConfirmDialogProps {
|
|
18
|
+
open: boolean;
|
|
19
|
+
onOpenChange: (open: boolean) => void;
|
|
20
|
+
title: string;
|
|
21
|
+
description: string;
|
|
22
|
+
onConfirm: () => void;
|
|
23
|
+
loading?: boolean;
|
|
24
|
+
confirmText?: string;
|
|
25
|
+
cancelText?: string;
|
|
26
|
+
variant?: "default" | "destructive";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ConfirmDialog({
|
|
30
|
+
open,
|
|
31
|
+
onOpenChange,
|
|
32
|
+
title,
|
|
33
|
+
description,
|
|
34
|
+
onConfirm,
|
|
35
|
+
loading = false,
|
|
36
|
+
confirmText = "Xác nhận",
|
|
37
|
+
cancelText = "Hủy",
|
|
38
|
+
variant = "destructive",
|
|
39
|
+
}: ConfirmDialogProps) {
|
|
40
|
+
return (
|
|
41
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
42
|
+
<DialogContent>
|
|
43
|
+
<DialogHeader>
|
|
44
|
+
<DialogTitle className="flex items-center gap-2">
|
|
45
|
+
{variant === "destructive" && <AlertTriangle className="h-5 w-5 text-destructive" />}
|
|
46
|
+
{title}
|
|
47
|
+
</DialogTitle>
|
|
48
|
+
<DialogDescription>{description}</DialogDescription>
|
|
49
|
+
</DialogHeader>
|
|
50
|
+
<DialogFooter>
|
|
51
|
+
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
|
52
|
+
{cancelText}
|
|
53
|
+
</Button>
|
|
54
|
+
<Button
|
|
55
|
+
variant={variant === "destructive" ? "destructive" : "default"}
|
|
56
|
+
onClick={onConfirm}
|
|
57
|
+
disabled={loading}
|
|
58
|
+
>
|
|
59
|
+
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
60
|
+
{confirmText}
|
|
61
|
+
</Button>
|
|
62
|
+
</DialogFooter>
|
|
63
|
+
</DialogContent>
|
|
64
|
+
</Dialog>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
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";
|
|
7
|
+
export * from "./confirm-dialog";
|
|
8
|
+
export * from "./stat-bar";
|
|
9
|
+
export * from "./list-toolbar";
|