@pattern-stack/frontend-patterns 0.0.4 → 0.0.6
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/dist/atoms/composed/SalesPanel/SalesPanel.d.ts +19 -0
- package/dist/atoms/composed/SalesPanel/SalesPanel.d.ts.map +1 -0
- package/dist/atoms/composed/SalesPanel/index.d.ts +2 -0
- package/dist/atoms/composed/SalesPanel/index.d.ts.map +1 -0
- package/dist/atoms/composed/SalesPanel/mockSalesData.d.ts +63 -0
- package/dist/atoms/composed/SalesPanel/mockSalesData.d.ts.map +1 -0
- package/dist/atoms/composed/index.d.ts +1 -0
- package/dist/atoms/composed/index.d.ts.map +1 -1
- package/dist/atoms/types/entity-config.d.ts +117 -0
- package/dist/atoms/types/entity-config.d.ts.map +1 -0
- package/dist/atoms/types/index.d.ts +2 -0
- package/dist/atoms/types/index.d.ts.map +1 -1
- package/dist/atoms/types/navigation.d.ts +30 -0
- package/dist/atoms/types/navigation.d.ts.map +1 -0
- package/dist/atoms/ui/ErrorBoundary.d.ts +1 -1
- package/dist/atoms/ui/button.d.ts +1 -1
- package/dist/atoms/utils/icon-resolver.d.ts +72 -0
- package/dist/atoms/utils/icon-resolver.d.ts.map +1 -0
- package/dist/atoms/utils/metric-engine.d.ts +30 -0
- package/dist/atoms/utils/metric-engine.d.ts.map +1 -0
- package/dist/atoms/utils/utils.d.ts +2 -0
- package/dist/atoms/utils/utils.d.ts.map +1 -1
- package/dist/features/auth/components/ProtectedRoute.d.ts +1 -1
- package/dist/frontend-patterns.css +1 -1
- package/dist/index.es.js +402 -14
- package/dist/index.es.js.map +1 -1
- package/dist/index.js +402 -14
- package/dist/index.js.map +1 -1
- package/dist/molecules/layout/DashboardWithSidePanel/DashboardWithSidePanel.d.ts +16 -0
- package/dist/molecules/layout/DashboardWithSidePanel/DashboardWithSidePanel.d.ts.map +1 -0
- package/dist/molecules/layout/DashboardWithSidePanel/index.d.ts +2 -0
- package/dist/molecules/layout/DashboardWithSidePanel/index.d.ts.map +1 -0
- package/dist/molecules/layout/NavigationContext.d.ts +15 -0
- package/dist/molecules/layout/NavigationContext.d.ts.map +1 -0
- package/dist/molecules/layout/Sidebar.d.ts.map +1 -1
- package/dist/molecules/layout/SidebarButton/SidebarButton.d.ts +2 -0
- package/dist/molecules/layout/SidebarButton/SidebarButton.d.ts.map +1 -1
- package/dist/molecules/layout/index.d.ts +3 -0
- package/dist/molecules/layout/index.d.ts.map +1 -1
- package/dist/templates/factory.d.ts +2 -1
- package/dist/templates/factory.d.ts.map +1 -1
- package/dist/templates/index.d.ts.map +1 -1
- package/package.json +7 -3
- package/src/App.tsx +11 -1
- package/src/__tests__/atoms/composed/databadge.test.tsx +106 -0
- package/src/__tests__/atoms/composed/statcard.test.tsx +133 -0
- package/src/__tests__/atoms/utils/icon-resolver.test.tsx +140 -0
- package/src/atoms/composed/SalesPanel/SalesPanel.tsx +116 -0
- package/src/atoms/composed/SalesPanel/index.ts +1 -0
- package/src/atoms/composed/SalesPanel/mockSalesData.ts +151 -0
- package/src/atoms/composed/index.ts +1 -0
- package/src/atoms/types/entity-config.ts +127 -0
- package/src/atoms/types/index.ts +3 -1
- package/src/atoms/types/navigation.ts +43 -0
- package/src/atoms/utils/icon-resolver.tsx +54 -0
- package/src/atoms/utils/metric-engine.ts +236 -0
- package/src/atoms/utils/utils.ts +4 -2
- package/src/molecules/layout/DashboardWithSidePanel/DashboardWithSidePanel.tsx +42 -0
- package/src/molecules/layout/DashboardWithSidePanel/index.ts +1 -0
- package/src/molecules/layout/NavigationContext.tsx +63 -0
- package/src/molecules/layout/Sidebar.tsx +10 -23
- package/src/molecules/layout/SidebarButton/SidebarButton.tsx +32 -10
- package/src/molecules/layout/index.ts +4 -1
- package/src/organisms/entity/CategoryBreakdownPanel.tsx +427 -0
- package/src/organisms/entity/EntityListPanel.tsx +339 -0
- package/src/organisms/entity/MetricsOverviewPanel.tsx +236 -0
- package/src/organisms/entity/TrendAnalysisPanel.tsx +337 -0
- package/src/organisms/entity/index.ts +4 -0
- package/src/organisms/index.ts +5 -1
- package/src/pages/AdminShowcase/AdminDashboardShowcase.tsx +77 -75
- package/src/pages/AdminShowcase/SalesPerformanceDashboard.tsx +158 -0
- package/src/pages/AdminShowcase/index.tsx +2 -1
- package/src/pages/EntityShowcase/EntityManagementShowcase.tsx +137 -0
- package/src/pages/EntityShowcase/EntityPerformanceShowcase.tsx +117 -0
- package/src/pages/EntityShowcase/index.ts +2 -0
- package/src/pages/EntityTemplateExample.tsx +229 -0
- package/src/pages/TestEntityTemplate.tsx +40 -0
- package/src/pages/index.ts +2 -1
- package/src/templates/entity/EntityManagementTemplate.tsx +430 -0
- package/src/templates/entity/EntityPerformanceDashboardTemplate.tsx +277 -0
- package/src/templates/entity/configs/financial-config.ts +141 -0
- package/src/templates/entity/configs/index.ts +1 -0
- package/src/templates/entity/index.ts +3 -0
- package/src/templates/factory.tsx +14 -7
- package/src/templates/financial/FinancialDashboardTemplate.tsx +326 -0
- package/src/templates/index.ts +4 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { DataTable } from '../../atoms/composed/DataTable';
|
|
3
|
+
import type { Column } from '../../atoms/composed/DataTable';
|
|
4
|
+
import { Button } from '../../atoms/ui/button';
|
|
5
|
+
import { Badge } from '../../atoms/ui/Badge';
|
|
6
|
+
import { Card } from '../../atoms/ui/card';
|
|
7
|
+
import type { EntityData, EntityTemplateConfig, ActionConfig } from '../../atoms/types';
|
|
8
|
+
import { cn } from '../../atoms/utils/utils';
|
|
9
|
+
import { Download, Filter, Plus, RefreshCw } from 'lucide-react';
|
|
10
|
+
|
|
11
|
+
export interface EntityListPanelProps<T extends EntityData> {
|
|
12
|
+
config: EntityTemplateConfig<T>;
|
|
13
|
+
data: T[];
|
|
14
|
+
isLoading?: boolean;
|
|
15
|
+
onRowClick?: (item: T) => void;
|
|
16
|
+
onAction?: (action: ActionConfig, selectedItems: T[]) => void;
|
|
17
|
+
className?: string;
|
|
18
|
+
|
|
19
|
+
// Table configuration
|
|
20
|
+
columns?: Column<T>[];
|
|
21
|
+
showSearch?: boolean;
|
|
22
|
+
showPagination?: boolean;
|
|
23
|
+
pageSize?: number;
|
|
24
|
+
searchPlaceholder?: string;
|
|
25
|
+
|
|
26
|
+
// Business logic features
|
|
27
|
+
enableSelection?: boolean;
|
|
28
|
+
enableBulkActions?: boolean;
|
|
29
|
+
enableExport?: boolean;
|
|
30
|
+
enableFiltering?: boolean;
|
|
31
|
+
enableRefresh?: boolean;
|
|
32
|
+
|
|
33
|
+
// Extension points
|
|
34
|
+
renderToolbar?: () => React.ReactNode;
|
|
35
|
+
renderBulkActions?: (selectedItems: T[]) => React.ReactNode;
|
|
36
|
+
renderCustomColumn?: (column: Column<T>, item: T) => React.ReactNode;
|
|
37
|
+
renderEmptyState?: () => React.ReactNode;
|
|
38
|
+
headerSlot?: React.ReactNode;
|
|
39
|
+
footerSlot?: React.ReactNode;
|
|
40
|
+
|
|
41
|
+
// Event handlers
|
|
42
|
+
onExport?: (data: T[]) => void;
|
|
43
|
+
onRefresh?: () => void;
|
|
44
|
+
onFilter?: (filters: Record<string, unknown>) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const EntityListPanel = <T extends EntityData>({
|
|
48
|
+
config,
|
|
49
|
+
data,
|
|
50
|
+
isLoading = false,
|
|
51
|
+
onRowClick,
|
|
52
|
+
onAction,
|
|
53
|
+
className,
|
|
54
|
+
columns: customColumns,
|
|
55
|
+
showSearch = true,
|
|
56
|
+
showPagination = true,
|
|
57
|
+
pageSize = 10,
|
|
58
|
+
searchPlaceholder,
|
|
59
|
+
enableSelection = false,
|
|
60
|
+
enableBulkActions = false,
|
|
61
|
+
enableExport = true,
|
|
62
|
+
enableFiltering = false,
|
|
63
|
+
enableRefresh = true,
|
|
64
|
+
renderToolbar,
|
|
65
|
+
renderBulkActions,
|
|
66
|
+
renderCustomColumn,
|
|
67
|
+
renderEmptyState,
|
|
68
|
+
headerSlot,
|
|
69
|
+
footerSlot,
|
|
70
|
+
onExport,
|
|
71
|
+
onRefresh
|
|
72
|
+
}: EntityListPanelProps<T>) => {
|
|
73
|
+
const [selectedItems, setSelectedItems] = React.useState<T[]>([]);
|
|
74
|
+
const [showFilters, setShowFilters] = React.useState(false);
|
|
75
|
+
|
|
76
|
+
// Generate columns based on config if not provided
|
|
77
|
+
const tableColumns = useMemo(() => {
|
|
78
|
+
if (customColumns) return customColumns;
|
|
79
|
+
|
|
80
|
+
const generatedColumns: Column<T>[] = [];
|
|
81
|
+
|
|
82
|
+
// Add selection column if enabled
|
|
83
|
+
if (enableSelection) {
|
|
84
|
+
generatedColumns.push({
|
|
85
|
+
key: '__selection',
|
|
86
|
+
header: (
|
|
87
|
+
<input
|
|
88
|
+
type="checkbox"
|
|
89
|
+
checked={selectedItems.length === data.length && data.length > 0}
|
|
90
|
+
onChange={(e) => {
|
|
91
|
+
if (e.target.checked) {
|
|
92
|
+
setSelectedItems([...data]);
|
|
93
|
+
} else {
|
|
94
|
+
setSelectedItems([]);
|
|
95
|
+
}
|
|
96
|
+
}}
|
|
97
|
+
/>
|
|
98
|
+
),
|
|
99
|
+
cell: (item: T) => (
|
|
100
|
+
<input
|
|
101
|
+
type="checkbox"
|
|
102
|
+
checked={selectedItems.some(selected => selected.id === item.id)}
|
|
103
|
+
onChange={(e) => {
|
|
104
|
+
if (e.target.checked) {
|
|
105
|
+
setSelectedItems(prev => [...prev, item]);
|
|
106
|
+
} else {
|
|
107
|
+
setSelectedItems(prev => prev.filter(selected => selected.id !== item.id));
|
|
108
|
+
}
|
|
109
|
+
}}
|
|
110
|
+
/>
|
|
111
|
+
),
|
|
112
|
+
sortable: false,
|
|
113
|
+
width: '50px'
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Auto-generate columns from first data item
|
|
118
|
+
if (data.length > 0) {
|
|
119
|
+
const sampleItem = data[0];
|
|
120
|
+
Object.keys(sampleItem).forEach(key => {
|
|
121
|
+
if (key === 'id') return; // Skip ID column usually
|
|
122
|
+
|
|
123
|
+
const isStatus = key.toLowerCase().includes('status');
|
|
124
|
+
const isCategory = key.toLowerCase().includes('category') || key.toLowerCase().includes('type');
|
|
125
|
+
const isDate = key.toLowerCase().includes('date') || key.toLowerCase().includes('time');
|
|
126
|
+
const isAmount = key.toLowerCase().includes('amount') || key.toLowerCase().includes('price') || key.toLowerCase().includes('cost');
|
|
127
|
+
|
|
128
|
+
generatedColumns.push({
|
|
129
|
+
key,
|
|
130
|
+
header: key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1'),
|
|
131
|
+
type: isStatus ? 'status' : isCategory ? 'category' : 'default',
|
|
132
|
+
sortable: true,
|
|
133
|
+
cell: renderCustomColumn ? (item: T) => {
|
|
134
|
+
const column = { key, header: '', type: isStatus ? 'status' : isCategory ? 'category' : 'default' } as Column<T>;
|
|
135
|
+
const customContent = renderCustomColumn(column, item);
|
|
136
|
+
if (customContent) return customContent;
|
|
137
|
+
|
|
138
|
+
// Default rendering with smart formatting
|
|
139
|
+
const value = item[key];
|
|
140
|
+
if (isDate && value) {
|
|
141
|
+
return new Date(value as string).toLocaleDateString();
|
|
142
|
+
}
|
|
143
|
+
if (isAmount && typeof value === 'number') {
|
|
144
|
+
return new Intl.NumberFormat('en-US', {
|
|
145
|
+
style: 'currency',
|
|
146
|
+
currency: 'USD'
|
|
147
|
+
}).format(value);
|
|
148
|
+
}
|
|
149
|
+
return value?.toString() || '-';
|
|
150
|
+
} : undefined
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return generatedColumns;
|
|
156
|
+
}, [customColumns, data, enableSelection, selectedItems, renderCustomColumn]);
|
|
157
|
+
|
|
158
|
+
const handleExport = () => {
|
|
159
|
+
if (onExport) {
|
|
160
|
+
onExport(selectedItems.length > 0 ? selectedItems : data);
|
|
161
|
+
} else {
|
|
162
|
+
// Default CSV export
|
|
163
|
+
const headers = tableColumns.filter(col => col.key !== '__selection').map(col => col.key);
|
|
164
|
+
const csvContent = [
|
|
165
|
+
headers.join(','),
|
|
166
|
+
...(selectedItems.length > 0 ? selectedItems : data).map(item =>
|
|
167
|
+
headers.map(header => item[header] || '').join(',')
|
|
168
|
+
)
|
|
169
|
+
].join('\n');
|
|
170
|
+
|
|
171
|
+
const blob = new Blob([csvContent], { type: 'text/csv' });
|
|
172
|
+
const url = URL.createObjectURL(blob);
|
|
173
|
+
const a = document.createElement('a');
|
|
174
|
+
a.href = url;
|
|
175
|
+
a.download = `${config.display.title.toLowerCase().replace(/\s+/g, '-')}-export.csv`;
|
|
176
|
+
a.click();
|
|
177
|
+
URL.revokeObjectURL(url);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const renderToolbarSection = () => {
|
|
182
|
+
return (
|
|
183
|
+
<div className="flex items-center justify-between gap-4 mb-4">
|
|
184
|
+
<div className="flex items-center gap-2">
|
|
185
|
+
<h3 className="text-lg font-semibold text-foreground">
|
|
186
|
+
{config.display.title}
|
|
187
|
+
</h3>
|
|
188
|
+
{config.display.description && (
|
|
189
|
+
<Badge variant="outline" className="text-xs">
|
|
190
|
+
{data.length} items
|
|
191
|
+
</Badge>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div className="flex items-center gap-2">
|
|
196
|
+
{renderToolbar && renderToolbar()}
|
|
197
|
+
|
|
198
|
+
{enableFiltering && (
|
|
199
|
+
<Button
|
|
200
|
+
variant="outline"
|
|
201
|
+
size="sm"
|
|
202
|
+
onClick={() => setShowFilters(!showFilters)}
|
|
203
|
+
className={cn(showFilters && "bg-muted")}
|
|
204
|
+
>
|
|
205
|
+
<Filter className="w-4 h-4 mr-2" />
|
|
206
|
+
Filters
|
|
207
|
+
</Button>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
{enableRefresh && (
|
|
211
|
+
<Button
|
|
212
|
+
variant="outline"
|
|
213
|
+
size="sm"
|
|
214
|
+
onClick={onRefresh}
|
|
215
|
+
disabled={isLoading}
|
|
216
|
+
>
|
|
217
|
+
<RefreshCw className={cn("w-4 h-4 mr-2", isLoading && "animate-spin")} />
|
|
218
|
+
Refresh
|
|
219
|
+
</Button>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
{enableExport && (
|
|
223
|
+
<Button
|
|
224
|
+
variant="outline"
|
|
225
|
+
size="sm"
|
|
226
|
+
onClick={handleExport}
|
|
227
|
+
disabled={data.length === 0}
|
|
228
|
+
>
|
|
229
|
+
<Download className="w-4 h-4 mr-2" />
|
|
230
|
+
Export
|
|
231
|
+
</Button>
|
|
232
|
+
)}
|
|
233
|
+
|
|
234
|
+
{config.actions?.filter(action => action.type === 'primary').map(action => (
|
|
235
|
+
<Button
|
|
236
|
+
key={action.label}
|
|
237
|
+
size="sm"
|
|
238
|
+
onClick={() => onAction?.(action, [])}
|
|
239
|
+
disabled={isLoading}
|
|
240
|
+
>
|
|
241
|
+
{action.icon && <action.icon className="w-4 h-4 mr-2" />}
|
|
242
|
+
{action.label}
|
|
243
|
+
</Button>
|
|
244
|
+
))}
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const renderBulkActionsSection = () => {
|
|
251
|
+
if (!enableBulkActions || selectedItems.length === 0) return null;
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<div className="flex items-center justify-between bg-muted/50 p-3 rounded-md mb-4">
|
|
255
|
+
<div className="flex items-center gap-2">
|
|
256
|
+
<span className="text-sm text-muted-foreground">
|
|
257
|
+
{selectedItems.length} items selected
|
|
258
|
+
</span>
|
|
259
|
+
<Button
|
|
260
|
+
variant="ghost"
|
|
261
|
+
size="sm"
|
|
262
|
+
onClick={() => setSelectedItems([])}
|
|
263
|
+
>
|
|
264
|
+
Clear selection
|
|
265
|
+
</Button>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<div className="flex items-center gap-2">
|
|
269
|
+
{renderBulkActions && renderBulkActions(selectedItems)}
|
|
270
|
+
|
|
271
|
+
{config.actions?.filter(action => action.type !== 'primary').map(action => (
|
|
272
|
+
<Button
|
|
273
|
+
key={action.label}
|
|
274
|
+
variant={action.type === 'danger' ? 'destructive' : 'outline'}
|
|
275
|
+
size="sm"
|
|
276
|
+
onClick={() => onAction?.(action, selectedItems)}
|
|
277
|
+
>
|
|
278
|
+
{action.icon && <action.icon className="w-4 h-4 mr-2" />}
|
|
279
|
+
{action.label}
|
|
280
|
+
</Button>
|
|
281
|
+
))}
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const renderEmptyStateSection = () => {
|
|
288
|
+
if (data.length > 0) return null;
|
|
289
|
+
|
|
290
|
+
if (renderEmptyState) {
|
|
291
|
+
return renderEmptyState();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<div className="text-center py-12">
|
|
296
|
+
<div className="text-muted-foreground mb-4">
|
|
297
|
+
No {config.display.title.toLowerCase()} found
|
|
298
|
+
</div>
|
|
299
|
+
{config.actions?.find(action => action.type === 'primary') && (
|
|
300
|
+
<Button onClick={() => {
|
|
301
|
+
const primaryAction = config.actions?.find(action => action.type === 'primary');
|
|
302
|
+
if (primaryAction) onAction?.(primaryAction, []);
|
|
303
|
+
}}>
|
|
304
|
+
<Plus className="w-4 h-4 mr-2" />
|
|
305
|
+
Add {config.display.title.slice(0, -1)}
|
|
306
|
+
</Button>
|
|
307
|
+
)}
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<Card className={cn('p-6', className)} category={config.display.category} data-component-name="EntityListPanel">
|
|
314
|
+
{headerSlot}
|
|
315
|
+
|
|
316
|
+
{renderToolbarSection()}
|
|
317
|
+
{renderBulkActionsSection()}
|
|
318
|
+
|
|
319
|
+
{data.length === 0 ? (
|
|
320
|
+
renderEmptyStateSection()
|
|
321
|
+
) : (
|
|
322
|
+
<DataTable
|
|
323
|
+
data={data}
|
|
324
|
+
columns={tableColumns}
|
|
325
|
+
isLoading={isLoading}
|
|
326
|
+
showSearch={showSearch}
|
|
327
|
+
showPagination={showPagination}
|
|
328
|
+
pageSize={pageSize}
|
|
329
|
+
searchPlaceholder={searchPlaceholder || `Search ${config.display.title.toLowerCase()}...`}
|
|
330
|
+
onRowClick={onRowClick}
|
|
331
|
+
hover={!!onRowClick}
|
|
332
|
+
emptyMessage={`No ${config.display.title.toLowerCase()} found`}
|
|
333
|
+
/>
|
|
334
|
+
)}
|
|
335
|
+
|
|
336
|
+
{footerSlot}
|
|
337
|
+
</Card>
|
|
338
|
+
);
|
|
339
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { StatCard } from '../../atoms/composed/StatCard';
|
|
3
|
+
import { MetricCalculationEngine } from '../../atoms/utils/metric-engine';
|
|
4
|
+
import type { MetricConfig, EntityData, MetricValue } from '../../atoms/types';
|
|
5
|
+
import { cn } from '../../atoms/utils/utils';
|
|
6
|
+
|
|
7
|
+
export interface MetricsOverviewPanelProps {
|
|
8
|
+
metrics: MetricConfig[];
|
|
9
|
+
data: EntityData[];
|
|
10
|
+
previousData?: EntityData[];
|
|
11
|
+
isLoading?: boolean;
|
|
12
|
+
onMetricClick?: (metric: MetricConfig, value: MetricValue) => void;
|
|
13
|
+
className?: string;
|
|
14
|
+
layout?: 'grid' | 'horizontal' | 'vertical';
|
|
15
|
+
columns?: 2 | 3 | 4;
|
|
16
|
+
category?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
|
17
|
+
|
|
18
|
+
// Extension points
|
|
19
|
+
renderCustomMetric?: (metric: MetricConfig, value: MetricValue, index: number) => React.ReactNode;
|
|
20
|
+
renderHeader?: () => React.ReactNode;
|
|
21
|
+
renderFooter?: () => React.ReactNode;
|
|
22
|
+
headerSlot?: React.ReactNode;
|
|
23
|
+
footerSlot?: React.ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const MetricsOverviewPanel: React.FC<MetricsOverviewPanelProps> = ({
|
|
27
|
+
metrics,
|
|
28
|
+
data,
|
|
29
|
+
previousData,
|
|
30
|
+
isLoading = false,
|
|
31
|
+
onMetricClick,
|
|
32
|
+
className,
|
|
33
|
+
layout = 'grid',
|
|
34
|
+
columns = 4,
|
|
35
|
+
category,
|
|
36
|
+
renderCustomMetric,
|
|
37
|
+
renderHeader,
|
|
38
|
+
renderFooter,
|
|
39
|
+
headerSlot,
|
|
40
|
+
footerSlot
|
|
41
|
+
}) => {
|
|
42
|
+
const calculatedMetrics = React.useMemo(() => {
|
|
43
|
+
if (isLoading || !data?.length || !metrics?.length) return {};
|
|
44
|
+
|
|
45
|
+
return metrics.reduce((acc, metric) => {
|
|
46
|
+
acc[metric.key] = MetricCalculationEngine.calculateMetric(
|
|
47
|
+
metric,
|
|
48
|
+
data,
|
|
49
|
+
previousData
|
|
50
|
+
);
|
|
51
|
+
return acc;
|
|
52
|
+
}, {} as Record<string, MetricValue>);
|
|
53
|
+
}, [metrics, data, previousData, isLoading]);
|
|
54
|
+
|
|
55
|
+
const getLayoutClasses = () => {
|
|
56
|
+
switch (layout) {
|
|
57
|
+
case 'horizontal':
|
|
58
|
+
return 'flex flex-wrap gap-4';
|
|
59
|
+
case 'vertical':
|
|
60
|
+
return 'flex flex-col gap-4';
|
|
61
|
+
case 'grid':
|
|
62
|
+
default:
|
|
63
|
+
return cn(
|
|
64
|
+
'grid gap-4',
|
|
65
|
+
{
|
|
66
|
+
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4': columns === 4,
|
|
67
|
+
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3': columns === 3,
|
|
68
|
+
'grid-cols-1 sm:grid-cols-2': columns === 2
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const renderMetricCard = (metric: MetricConfig, index: number) => {
|
|
75
|
+
if (renderCustomMetric) {
|
|
76
|
+
const customContent = renderCustomMetric(metric, calculatedMetrics[metric.key], index);
|
|
77
|
+
if (customContent) return customContent;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const metricValue = calculatedMetrics[metric.key];
|
|
81
|
+
|
|
82
|
+
if (isLoading || !metricValue) {
|
|
83
|
+
return (
|
|
84
|
+
<StatCard
|
|
85
|
+
key={metric.key}
|
|
86
|
+
title={metric.label}
|
|
87
|
+
value=""
|
|
88
|
+
isLoading={true}
|
|
89
|
+
category={category}
|
|
90
|
+
icon={metric.icon ? <metric.icon /> : undefined}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const trend = metricValue.trend !== 'neutral' && metricValue.previous
|
|
96
|
+
? {
|
|
97
|
+
value: Number(((metricValue.current - metricValue.previous) / Math.abs(metricValue.previous) * 100).toFixed(1)),
|
|
98
|
+
label: 'vs last period'
|
|
99
|
+
}
|
|
100
|
+
: undefined;
|
|
101
|
+
|
|
102
|
+
const formatTargetValue = (value: number) => {
|
|
103
|
+
switch (metric.type) {
|
|
104
|
+
case 'currency':
|
|
105
|
+
return new Intl.NumberFormat('en-US', {
|
|
106
|
+
style: 'currency',
|
|
107
|
+
currency: 'USD'
|
|
108
|
+
}).format(value);
|
|
109
|
+
case 'percentage':
|
|
110
|
+
return `${value}%`;
|
|
111
|
+
default:
|
|
112
|
+
return value.toString();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const subtitle = metricValue.target
|
|
117
|
+
? `Target: ${formatTargetValue(metricValue.target)}`
|
|
118
|
+
: undefined;
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<StatCard
|
|
122
|
+
key={metric.key}
|
|
123
|
+
title={metric.label}
|
|
124
|
+
value={metricValue.formattedValue}
|
|
125
|
+
subtitle={subtitle}
|
|
126
|
+
trend={trend}
|
|
127
|
+
category={category}
|
|
128
|
+
icon={metric.icon ? <metric.icon /> : undefined}
|
|
129
|
+
onClick={onMetricClick ? () => onMetricClick(metric, metricValue) : undefined}
|
|
130
|
+
valueTooltip={`Current: ${metricValue.formattedValue}${metricValue.previous ? ` | Previous: ${formatTargetValue(metricValue.previous)}` : ''}`}
|
|
131
|
+
iconTooltip={`View ${metric.label} details`}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className={cn('space-y-4', className)} data-component-name="MetricsOverviewPanel">
|
|
138
|
+
{/* Header Extension Point */}
|
|
139
|
+
{headerSlot}
|
|
140
|
+
{renderHeader && renderHeader()}
|
|
141
|
+
|
|
142
|
+
{/* Metrics Grid */}
|
|
143
|
+
<div className={getLayoutClasses()}>
|
|
144
|
+
{metrics.map((metric, index) => renderMetricCard(metric, index))}
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{/* Footer Extension Point */}
|
|
148
|
+
{renderFooter && renderFooter()}
|
|
149
|
+
{footerSlot}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Enhanced version with insights
|
|
155
|
+
export interface MetricsOverviewWithInsightsPanelProps extends MetricsOverviewPanelProps {
|
|
156
|
+
showInsights?: boolean;
|
|
157
|
+
insightThresholds?: Record<string, { warning: number; critical: number }>;
|
|
158
|
+
renderInsight?: (insight: { type: 'positive' | 'negative' | 'neutral'; message: string; value?: number }, index: number) => React.ReactNode;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const MetricsOverviewWithInsightsPanel: React.FC<MetricsOverviewWithInsightsPanelProps> = ({
|
|
162
|
+
showInsights = true,
|
|
163
|
+
insightThresholds,
|
|
164
|
+
renderInsight,
|
|
165
|
+
...props
|
|
166
|
+
}) => {
|
|
167
|
+
// Ensure metrics is always an array
|
|
168
|
+
const safeMetrics = props.metrics || [];
|
|
169
|
+
const safeData = props.data || [];
|
|
170
|
+
|
|
171
|
+
const calculatedMetrics = React.useMemo(() => {
|
|
172
|
+
if (props.isLoading || !safeData.length || !safeMetrics.length) return {};
|
|
173
|
+
|
|
174
|
+
return safeMetrics.reduce((acc, metric) => {
|
|
175
|
+
acc[metric.key] = MetricCalculationEngine.calculateMetric(
|
|
176
|
+
metric,
|
|
177
|
+
safeData,
|
|
178
|
+
props.previousData
|
|
179
|
+
);
|
|
180
|
+
return acc;
|
|
181
|
+
}, {} as Record<string, MetricValue>);
|
|
182
|
+
}, [safeMetrics, safeData, props.previousData, props.isLoading]);
|
|
183
|
+
|
|
184
|
+
const insights = React.useMemo(() => {
|
|
185
|
+
if (!showInsights || props.isLoading || Object.keys(calculatedMetrics).length === 0) {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return MetricCalculationEngine.detectInsights(calculatedMetrics, insightThresholds);
|
|
190
|
+
}, [calculatedMetrics, showInsights, insightThresholds, props.isLoading]);
|
|
191
|
+
|
|
192
|
+
const renderInsightsSection = () => {
|
|
193
|
+
if (!showInsights || insights.length === 0) return null;
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<div className="space-y-2">
|
|
197
|
+
<h3 className="text-sm font-semibold text-foreground">Key Insights</h3>
|
|
198
|
+
<div className="space-y-1">
|
|
199
|
+
{insights.map((insight, index) => {
|
|
200
|
+
if (renderInsight) {
|
|
201
|
+
const customInsight = renderInsight(insight, index);
|
|
202
|
+
if (customInsight) return customInsight;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const colorClass = {
|
|
206
|
+
positive: 'text-status-success',
|
|
207
|
+
negative: 'text-status-error',
|
|
208
|
+
neutral: 'text-muted-foreground'
|
|
209
|
+
}[insight.type];
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<div
|
|
213
|
+
key={index}
|
|
214
|
+
className={cn('text-sm', colorClass)}
|
|
215
|
+
>
|
|
216
|
+
• {insight.message}
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
})}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<MetricsOverviewPanel
|
|
227
|
+
{...props}
|
|
228
|
+
footerSlot={
|
|
229
|
+
<>
|
|
230
|
+
{renderInsightsSection()}
|
|
231
|
+
{props.footerSlot}
|
|
232
|
+
</>
|
|
233
|
+
}
|
|
234
|
+
/>
|
|
235
|
+
);
|
|
236
|
+
};
|