@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.
@@ -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,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";