@goplusvn/core 0.1.14 → 0.1.16

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.16 — RBAC roles route-handler factory
4
+
5
+ Slice ⑧b. Mỗi app hand-write /api/roles + /api/roles/[id] (list users[], create/update
6
+ thay RolePermission set, delete guard theo user count). Nay ở core:
7
+ `@goerp/core/rbac/route-handlers` — `createRolesCollectionHandlers` (GET list
8
+ schema-tolerant qua getRolesData + POST create+writePermissions) +
9
+ `createRoleItemHandlers` (GET/PUT/DELETE). App inject prisma+getSession+
10
+ getCrudPermissions + schema field-map. Additive. wu thay ~90 LOC route tay → factory;
11
+ /api/roles 200 (6 roles), unauth 401, /vi/roles 200.
12
+
13
+
14
+ ## 0.1.15 — Shared UI: ConfirmDialog, StatBar, ListToolbar
15
+
16
+ Tiếp 0.1.14: promote 3 component dùng chung nữa vào `@goerp/core/ui` (ConfirmDialog,
17
+ StatBar, ListToolbar + AdvancedField) — import từ sub-barrel tránh circular.
18
+ Additive; wu shim + verify render 200.
19
+
20
+
3
21
  ## 0.1.14 — Init-completion: server-CRUD engine, auth gate, schema-tolerant RBAC, shared UI
4
22
 
5
23
  Đú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
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.14",
4
+ "version": "0.1.16",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -37,6 +37,7 @@
37
37
  "./assets/*": "./src/assets/*",
38
38
  "./styles/*": "./src/styles/*",
39
39
  "./auth/proxy-gate": "./src/auth/proxy-gate.ts",
40
+ "./rbac/route-handlers": "./src/rbac/route-handlers.ts",
40
41
  "./ui/shared/table-styles": "./src/ui/shared/table-styles.ts",
41
42
  "./errors/app-error": "./src/errors/app-error.ts",
42
43
  "./errors/error-handler": "./src/errors/error-handler.ts",
@@ -0,0 +1,182 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ // Next.js route-handler factories for the RBAC "roles" API. Every app hand-wrote
3
+ // /api/roles + /api/roles/[id] (list with users[], create/update replacing the
4
+ // RolePermission set, delete guarded by user count). Promoted to core.
5
+ //
6
+ // Permissions are the canonical "action:resource" string[] (matches RoleListPage
7
+ // + getRolesData). Schema-tolerant via RoleServiceSchema (see role-service).
8
+ //
9
+ // Usage (app side):
10
+ // // src/app/api/roles/route.ts
11
+ // import { createRolesCollectionHandlers } from "@goerp/core/rbac/route-handlers";
12
+ // export const { GET, POST } = createRolesCollectionHandlers({
13
+ // prisma, getSession, getCrudPermissions, schema: { userNameField: "fullName", ... },
14
+ // });
15
+
16
+ import { getRolesData, type RoleServiceSchema } from "./role-service";
17
+
18
+ type MaybePromise<T> = T | Promise<T>;
19
+
20
+ export interface RbacHandlerDeps {
21
+ prisma: any;
22
+ getSession: () => MaybePromise<any | null>;
23
+ /** Permission gate for resource "role". Usually getCrudPermissions from @goerp/core/crud/server. */
24
+ getCrudPermissions: (session: any, resource: string) => Promise<{ read?: boolean; create?: boolean; update?: boolean; delete?: boolean }>;
25
+ /** Schema field-map when User/Role diverge from defaults. */
26
+ schema?: RoleServiceSchema;
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
+ // permissions "action:resource" → RolePermission rows {roleCode,resourceCode,actionCode}.
34
+ async function writePermissions(tx: any, roleCode: string, permissions: string[]) {
35
+ await tx.rolePermission.deleteMany({ where: { roleCode } });
36
+ const rows = (permissions || [])
37
+ .map((p) => {
38
+ const [actionCode, resourceCode] = p.split(":");
39
+ return actionCode && resourceCode ? { roleCode, resourceCode, actionCode } : null;
40
+ })
41
+ .filter(Boolean);
42
+ if (rows.length) await tx.rolePermission.createMany({ data: rows, skipDuplicates: true });
43
+ }
44
+
45
+ // GET (list, schema-tolerant) + POST (create + permissions) for /api/roles.
46
+ export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
47
+ const { prisma, getSession, getCrudPermissions, schema, onError } = deps;
48
+ const fail = (e: unknown, req: Request) => (onError ? onError(e, req) : json({ error: "Internal error" }, 500));
49
+
50
+ async function GET(req: Request) {
51
+ try {
52
+ const session = await getSession();
53
+ if (!session) return json({ error: "Unauthorized" }, 401);
54
+ const perms = await getCrudPermissions(session, "role");
55
+ if (!perms.read) return json({ error: "Forbidden" }, 403);
56
+ const sp = new URL(req.url).searchParams;
57
+ const result = await getRolesData(
58
+ prisma,
59
+ {
60
+ page: Number(sp.get("page") || 1),
61
+ pageSize: Math.min(Number(sp.get("pageSize") || 20), 200),
62
+ search: sp.get("search")?.trim() || undefined,
63
+ status: sp.get("status")?.trim() || undefined,
64
+ },
65
+ schema,
66
+ );
67
+ return json(result);
68
+ } catch (e) {
69
+ return fail(e, req);
70
+ }
71
+ }
72
+
73
+ async function POST(req: Request) {
74
+ try {
75
+ const session = await getSession();
76
+ if (!session) return json({ error: "Unauthorized" }, 401);
77
+ const perms = await getCrudPermissions(session, "role");
78
+ if (!perms.create) return json({ error: "Forbidden" }, 403);
79
+ const body = await req.json();
80
+ const code = (body.code ?? "").trim();
81
+ const name = (body.name ?? "").trim();
82
+ if (!code || !name) return json({ error: "Thiếu mã hoặc tên vai trò" }, 400);
83
+ const permissions: string[] = Array.isArray(body.permissions) ? body.permissions : [];
84
+ const role = await prisma.$transaction(async (tx: any) => {
85
+ const created = await tx.role.create({
86
+ data: { code, name, description: body.description ?? null, status: body.status ?? "active" },
87
+ });
88
+ await writePermissions(tx, created.code, permissions);
89
+ return created;
90
+ });
91
+ return json({ id: role.id, code: role.code, name: role.name }, 201);
92
+ } catch (e) {
93
+ return fail(e, req);
94
+ }
95
+ }
96
+
97
+ return { GET, POST };
98
+ }
99
+
100
+ // GET + PUT (update + replace permissions) + DELETE (guarded by user count) for /api/roles/[id].
101
+ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
102
+ const { prisma, getSession, getCrudPermissions, onError } = deps;
103
+ const fail = (e: unknown, req: Request) => (onError ? onError(e, req) : json({ error: "Internal error" }, 500));
104
+ type Ctx = { params: Promise<{ id: string }> };
105
+
106
+ async function GET(_req: Request, ctx: Ctx) {
107
+ try {
108
+ const { id } = await ctx.params;
109
+ const session = await getSession();
110
+ if (!session) return json({ error: "Unauthorized" }, 401);
111
+ const perms = await getCrudPermissions(session, "role");
112
+ if (!perms.read) return json({ error: "Forbidden" }, 403);
113
+ const role = await prisma.role.findUnique({
114
+ where: { id },
115
+ include: { rolePermissions: { select: { resourceCode: true, actionCode: true } } },
116
+ });
117
+ if (!role) return json({ error: "Not found" }, 404);
118
+ return json({
119
+ id: role.id,
120
+ code: role.code,
121
+ name: role.name,
122
+ description: role.description ?? "",
123
+ status: role.status,
124
+ permissions: role.rolePermissions.map((p: any) => `${p.actionCode}:${p.resourceCode}`),
125
+ });
126
+ } catch (e) {
127
+ return fail(e, _req);
128
+ }
129
+ }
130
+
131
+ async function PUT(req: Request, ctx: Ctx) {
132
+ try {
133
+ const { id } = await ctx.params;
134
+ const session = await getSession();
135
+ if (!session) return json({ error: "Unauthorized" }, 401);
136
+ const perms = await getCrudPermissions(session, "role");
137
+ if (!perms.update) return json({ error: "Forbidden" }, 403);
138
+ const body = await req.json();
139
+ const existing = await prisma.role.findUnique({ where: { id } });
140
+ if (!existing) return json({ error: "Not found" }, 404);
141
+ const permissions: string[] = Array.isArray(body.permissions) ? body.permissions : [];
142
+ const role = await prisma.$transaction(async (tx: any) => {
143
+ const updated = await tx.role.update({
144
+ where: { id },
145
+ data: {
146
+ name: (body.name ?? existing.name).trim(),
147
+ description: body.description ?? existing.description,
148
+ status: body.status ?? existing.status,
149
+ },
150
+ });
151
+ await writePermissions(tx, updated.code, permissions);
152
+ return updated;
153
+ });
154
+ return json({ id: role.id, code: role.code, name: role.name });
155
+ } catch (e) {
156
+ return fail(e, req);
157
+ }
158
+ }
159
+
160
+ async function DELETE(req: Request, ctx: Ctx) {
161
+ try {
162
+ const { id } = await ctx.params;
163
+ const session = await getSession();
164
+ if (!session) return json({ error: "Unauthorized" }, 401);
165
+ const perms = await getCrudPermissions(session, "role");
166
+ if (!perms.delete) return json({ error: "Forbidden" }, 403);
167
+ const role = await prisma.role.findUnique({ where: { id }, include: { _count: { select: { userRoles: true } } } });
168
+ if (!role) return json({ error: "Not found" }, 404);
169
+ if (role._count.userRoles > 0)
170
+ return json({ error: `Vai trò còn ${role._count.userRoles} người dùng, không thể xóa` }, 400);
171
+ await prisma.$transaction(async (tx: any) => {
172
+ await tx.rolePermission.deleteMany({ where: { roleCode: role.code } });
173
+ await tx.role.delete({ where: { id } });
174
+ });
175
+ return new Response(null, { status: 204 });
176
+ } catch (e) {
177
+ return fail(e, req);
178
+ }
179
+ }
180
+
181
+ return { GET, PUT, DELETE };
182
+ }
@@ -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
+ }
@@ -4,3 +4,6 @@ export * from "./page-header";
4
4
  export * from "./status-indicator";
5
5
  export * from "./table-sum-footer";
6
6
  export * from "./table-styles";
7
+ export * from "./confirm-dialog";
8
+ export * from "./stat-bar";
9
+ export * from "./list-toolbar";
@@ -0,0 +1,263 @@
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+ import { Download, Filter, MoreHorizontal, Plus, Search, X } from "lucide-react"
5
+
6
+ import {
7
+ Button,
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuLabel,
12
+ DropdownMenuSeparator,
13
+ DropdownMenuTrigger,
14
+ Input,
15
+ } from "../primitives"
16
+ import {
17
+ Sheet,
18
+ SheetClose,
19
+ SheetContent,
20
+ SheetDescription,
21
+ SheetHeader,
22
+ SheetTitle,
23
+ SheetTrigger,
24
+ } from "../feedback"
25
+
26
+ /**
27
+ * Mô tả 1 hành động phụ trong menu "…" (data-driven để toolbar tự render đúng
28
+ * DropdownMenuItem — tránh lỗi Radix context khi truyền JSX từ nguồn khác).
29
+ */
30
+ export interface ListToolbarAction {
31
+ key: string
32
+ label: string
33
+ icon?: ReactNode
34
+ onClick: () => void
35
+ disabled?: boolean
36
+ destructive?: boolean
37
+ /** Ẩn mục (vd thiếu quyền) — toolbar tự lọc, caller cứ truyền cả danh sách. */
38
+ hidden?: boolean
39
+ }
40
+
41
+ interface ListToolbarProps {
42
+ search: string
43
+ onSearchChange: (value: string) => void
44
+ searchPlaceholder?: string
45
+ /** Bộ lọc chính — render inline cạnh ô tìm kiếm (vừa đủ 1 dòng). */
46
+ children?: ReactNode
47
+ /** Bộ lọc nâng cao — render trong Sheet; badge = advancedCount. */
48
+ advanced?: ReactNode
49
+ advancedCount?: number
50
+ /** Xóa lọc (chỉ hiện khi đang lọc). */
51
+ isFiltered?: boolean
52
+ onReset?: () => void
53
+ /** Xuất Excel — nằm trong menu "Khác" (…). */
54
+ onExport?: () => void
55
+ canExport?: boolean
56
+ /** Tạo mới — luôn ở cuối toolbar. */
57
+ onCreate?: () => void
58
+ canCreate?: boolean
59
+ createLabel?: string
60
+ /** Nút điều khiển bảng (dropdown "Hiển thị cột") — xem useTableControls. */
61
+ tableControls?: ReactNode
62
+ /** Hành động phụ trong menu "…" (vd Cấu hình, In hàng loạt). Ít dùng. */
63
+ moreActions?: ListToolbarAction[]
64
+ }
65
+
66
+ /**
67
+ * Toolbar danh sách dùng chung — khớp phong cách trang đơn bán hàng:
68
+ * [tìm kiếm] [lọc chính 1 dòng] · [Bộ lọc nâng cao + badge] [Xóa lọc] [… Xuất] [+ Tạo mới].
69
+ * Toolbar trần (không khung card), bộ lọc tràn dòng → đưa vào slot `advanced` (Sheet).
70
+ */
71
+ export function ListToolbar({
72
+ search,
73
+ onSearchChange,
74
+ searchPlaceholder = "Tìm kiếm...",
75
+ children,
76
+ advanced,
77
+ advancedCount = 0,
78
+ isFiltered,
79
+ onReset,
80
+ onExport,
81
+ canExport = true,
82
+ onCreate,
83
+ canCreate = true,
84
+ createLabel = "Tạo mới",
85
+ tableControls,
86
+ moreActions,
87
+ }: ListToolbarProps) {
88
+ const showExport = canExport && !!onExport
89
+ const actions = (moreActions ?? []).filter((a) => !a.hidden)
90
+ const showMoreMenu = showExport || actions.length > 0
91
+
92
+ return (
93
+ <div className="flex flex-col xl:flex-row items-start xl:items-center justify-between w-full gap-3 xl:gap-4">
94
+ {/* Tìm kiếm + bộ lọc chính */}
95
+ <div className="flex w-full xl:w-auto flex-1 flex-wrap items-center gap-2 xl:gap-3">
96
+ <div className="relative w-full sm:flex-1 sm:min-w-[220px] xl:flex-[2] xl:min-w-[300px]">
97
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
98
+ <Input
99
+ placeholder={searchPlaceholder}
100
+ value={search}
101
+ onChange={(e) => onSearchChange(e.target.value)}
102
+ className="pl-9 h-9 w-full bg-secondary/30 focus:bg-background shadow-sm focus-visible:ring-inset focus-visible:ring-offset-0"
103
+ />
104
+ </div>
105
+ {children}
106
+ </div>
107
+
108
+ {/* Hành động */}
109
+ <div className="flex flex-wrap sm:flex-nowrap items-center justify-between xl:justify-end gap-2 w-full xl:w-auto shrink-0">
110
+ {/* Bộ lọc nâng cao + Xóa lọc */}
111
+ <div className="flex items-center">
112
+ {advanced && (
113
+ <Sheet>
114
+ <SheetTrigger asChild>
115
+ <Button
116
+ variant="outline"
117
+ className="h-9 min-w-9 shrink-0 shadow-sm relative"
118
+ title="Bộ lọc nâng cao"
119
+ >
120
+ <Filter className="h-4 w-4 sm:mr-2" />
121
+ <span className="hidden sm:inline font-medium">Bộ lọc</span>
122
+ {advancedCount > 0 && (
123
+ <span className="absolute -top-1.5 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground border-2 border-background">
124
+ {advancedCount}
125
+ </span>
126
+ )}
127
+ </Button>
128
+ </SheetTrigger>
129
+ <SheetContent
130
+ side="right"
131
+ className="flex flex-col h-full w-full sm:max-w-md p-0 gap-0"
132
+ >
133
+ <SheetHeader className="text-left px-6 py-4 border-b flex flex-row items-center justify-between space-y-0">
134
+ <div className="space-y-1">
135
+ <SheetTitle className="text-lg font-bold">
136
+ Bộ lọc nâng cao
137
+ </SheetTitle>
138
+ <SheetDescription>
139
+ Tinh chỉnh danh sách theo các tiêu chí bên dưới.
140
+ </SheetDescription>
141
+ </div>
142
+ <SheetClose asChild>
143
+ <Button
144
+ variant="ghost"
145
+ size="icon"
146
+ className="h-8 w-8 rounded-full shrink-0"
147
+ >
148
+ <X className="h-5 w-5" />
149
+ <span className="sr-only">Đóng</span>
150
+ </Button>
151
+ </SheetClose>
152
+ </SheetHeader>
153
+ <div className="flex-1 overflow-y-auto px-6 py-5">
154
+ <div className="flex flex-col gap-5">{advanced}</div>
155
+ </div>
156
+ <div className="border-t px-6 py-4 flex items-center gap-2">
157
+ {onReset && (
158
+ <Button
159
+ variant="outline"
160
+ onClick={onReset}
161
+ className="flex-1 text-muted-foreground hover:text-destructive"
162
+ >
163
+ <X className="mr-1 h-4 w-4" /> Xóa lọc
164
+ </Button>
165
+ )}
166
+ <SheetClose asChild>
167
+ <Button className="flex-1">Xem kết quả</Button>
168
+ </SheetClose>
169
+ </div>
170
+ </SheetContent>
171
+ </Sheet>
172
+ )}
173
+ {isFiltered && onReset && (
174
+ <Button
175
+ onClick={onReset}
176
+ variant="ghost"
177
+ size="sm"
178
+ className="h-9 ml-1 text-muted-foreground hover:text-destructive hidden sm:flex"
179
+ title="Xóa tất cả bộ lọc"
180
+ >
181
+ Xóa lọc
182
+ </Button>
183
+ )}
184
+ </div>
185
+
186
+ {/* Điều khiển cột + Khác (…) + Tạo mới */}
187
+ <div className="flex items-center gap-2">
188
+ {tableControls}
189
+ {showMoreMenu && (
190
+ <DropdownMenu modal={false}>
191
+ <DropdownMenuTrigger asChild>
192
+ <Button
193
+ variant="outline"
194
+ size="sm"
195
+ className="h-9 w-9 p-0 shadow-sm"
196
+ title="Khác"
197
+ >
198
+ <MoreHorizontal className="h-4 w-4 text-muted-foreground" />
199
+ </Button>
200
+ </DropdownMenuTrigger>
201
+ <DropdownMenuContent align="end" className="w-[200px]">
202
+ <DropdownMenuLabel className="text-xs text-muted-foreground uppercase">
203
+ Hành động
204
+ </DropdownMenuLabel>
205
+ <DropdownMenuSeparator />
206
+ {showExport && (
207
+ <DropdownMenuItem
208
+ onClick={onExport}
209
+ className="gap-2 cursor-pointer"
210
+ >
211
+ <Download className="h-4 w-4" />
212
+ Xuất danh sách (Excel)
213
+ </DropdownMenuItem>
214
+ )}
215
+ {actions.map((action) => (
216
+ <DropdownMenuItem
217
+ key={action.key}
218
+ onClick={action.onClick}
219
+ disabled={action.disabled}
220
+ className={`gap-2 cursor-pointer${action.destructive ? " text-destructive focus:text-destructive" : ""}`}
221
+ >
222
+ {action.icon}
223
+ {action.label}
224
+ </DropdownMenuItem>
225
+ ))}
226
+ </DropdownMenuContent>
227
+ </DropdownMenu>
228
+ )}
229
+ {canCreate && onCreate && (
230
+ <Button
231
+ size="sm"
232
+ onClick={onCreate}
233
+ className="h-9 font-medium shadow-sm"
234
+ >
235
+ <Plus className="h-4 w-4 sm:mr-2" />
236
+ <span className="hidden sm:inline">{createLabel}</span>
237
+ </Button>
238
+ )}
239
+ </div>
240
+ </div>
241
+ </div>
242
+ )
243
+ }
244
+
245
+ /**
246
+ * Ô lọc trong Sheet nâng cao: nhãn + control, xếp dọc.
247
+ */
248
+ export function AdvancedField({
249
+ label,
250
+ children,
251
+ }: {
252
+ label: string
253
+ children: ReactNode
254
+ }) {
255
+ return (
256
+ <div className="space-y-1.5">
257
+ <label className="text-xs font-medium text-muted-foreground">
258
+ {label}
259
+ </label>
260
+ {children}
261
+ </div>
262
+ )
263
+ }
@@ -0,0 +1,38 @@
1
+ "use client";
2
+
3
+ // StatBar — horizontal KPI strip (vinhhoa style). A thin wrapper over
4
+ // CompactStatBar that lets SERVER pages pass plain data (iconName as a string)
5
+ // without importing lucide components in the server component.
6
+ import { CompactStatBar, type CompactStatItem } from "../data-display";
7
+ import {
8
+ AlertTriangle, Ban, Boxes, Building2, CheckCircle2, Clock, FileCheck2, GitCompareArrows,
9
+ KeyRound, Landmark, ListChecks, MousePointerClick, PauseCircle, Percent, ShieldAlert, ShieldCheck,
10
+ Users, Wallet, XCircle, Zap, type LucideIcon,
11
+ } from "lucide-react";
12
+
13
+ const ICONS: Record<string, LucideIcon> = {
14
+ ListChecks, CheckCircle2, PauseCircle, Ban, Clock, XCircle, GitCompareArrows,
15
+ AlertTriangle, Percent, Landmark, Building2, Wallet, FileCheck2, ShieldAlert, Users,
16
+ ShieldCheck, KeyRound, Boxes, MousePointerClick, Zap,
17
+ };
18
+
19
+ export type StatItem = {
20
+ id: string;
21
+ label: string;
22
+ value: string | number;
23
+ iconName: keyof typeof ICONS | string;
24
+ colorTheme?: CompactStatItem["colorTheme"];
25
+ isHighlighted?: boolean;
26
+ };
27
+
28
+ export function StatBar({ items }: { items: StatItem[] }) {
29
+ const mapped: CompactStatItem[] = items.map((i) => ({
30
+ id: i.id,
31
+ label: i.label,
32
+ value: i.value,
33
+ icon: ICONS[i.iconName as string] ?? ListChecks,
34
+ colorTheme: i.colorTheme,
35
+ isHighlighted: i.isHighlighted,
36
+ }));
37
+ return <CompactStatBar items={mapped} />;
38
+ }