@everystack/admin 0.1.0

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.
Files changed (38) hide show
  1. package/README.md +282 -0
  2. package/package.json +53 -0
  3. package/src/AdminRoot.tsx +34 -0
  4. package/src/adapters/EverystackAdapter.ts +218 -0
  5. package/src/adapters/types.ts +78 -0
  6. package/src/components/AvatarFieldInput.tsx +100 -0
  7. package/src/components/ImageDisplay.tsx +57 -0
  8. package/src/components/ImageFieldInput.tsx +140 -0
  9. package/src/components/dashboard/Brush.tsx +139 -0
  10. package/src/components/dashboard/DimensionBars.tsx +67 -0
  11. package/src/components/dashboard/DimensionDonut.tsx +98 -0
  12. package/src/components/dashboard/EventTable.tsx +248 -0
  13. package/src/components/dashboard/StatDials.tsx +67 -0
  14. package/src/components/dashboard/TimeHistogram.tsx +158 -0
  15. package/src/context.ts +75 -0
  16. package/src/hooks/useAdminConfig.ts +22 -0
  17. package/src/hooks/useAdminData.ts +132 -0
  18. package/src/hooks/useAggregates.ts +290 -0
  19. package/src/hooks/useRpc.ts +124 -0
  20. package/src/hooks/useStorageConfig.ts +16 -0
  21. package/src/index.ts +138 -0
  22. package/src/presets/twitter.ts +267 -0
  23. package/src/providers/AdminConfigProvider.tsx +18 -0
  24. package/src/providers/DataAdapterProvider.tsx +18 -0
  25. package/src/rpc/resource-dashboard-rpc.ts +416 -0
  26. package/src/screens/AdminDashboard.tsx +217 -0
  27. package/src/screens/AdminLayout.tsx +97 -0
  28. package/src/screens/ResourceCreate.tsx +243 -0
  29. package/src/screens/ResourceDashboard.tsx +278 -0
  30. package/src/screens/ResourceEdit.tsx +283 -0
  31. package/src/screens/ResourceList.tsx +153 -0
  32. package/src/screens/ResourceShow.tsx +171 -0
  33. package/src/screens/index.ts +14 -0
  34. package/src/types.ts +358 -0
  35. package/src/utils/naming.ts +105 -0
  36. package/src/utils/pick-image-web.ts +81 -0
  37. package/src/utils/resolve-image-url.ts +43 -0
  38. package/src/utils/upload-file.ts +93 -0
@@ -0,0 +1,416 @@
1
+ /**
2
+ * Generic Resource Dashboard RPC Factory
3
+ *
4
+ * Builds server-side aggregation queries from ResourceDashboardConfig.
5
+ * Creates two RPC handlers per resource:
6
+ * - dashboardData: Single CTE query returning time histogram, dimension counts, and stats
7
+ * - eventList: Paginated, searchable, sortable event listing
8
+ *
9
+ * All filters use whitelist validation from DimensionConfig.allowedValues.
10
+ * Table/column names come from server-side config (not user input).
11
+ *
12
+ * Usage:
13
+ * const rpcs = createResourceDashboardRpcs('log_events', config, db);
14
+ * // Wire into handler: rpc: { logEventsDashboardData: rpcs.dashboardData, ... }
15
+ */
16
+
17
+ import { sql, type SQL } from 'drizzle-orm';
18
+ import type { ResourceDashboardConfig, DimensionConfig, StatDialConfig } from '../types';
19
+
20
+ interface DbExecutor {
21
+ execute(query: unknown): Promise<{ rows?: unknown[] } | unknown[]>;
22
+ }
23
+
24
+ function extractRows(result: { rows?: unknown[] } | unknown[]): unknown[] {
25
+ if (Array.isArray(result)) return result;
26
+ return (result as { rows?: unknown[] }).rows ?? [];
27
+ }
28
+
29
+ // ────────────────────────────────────────────────────────────
30
+ // Dashboard Filters (generic — keyed by dimension column name)
31
+ // ────────────────────────────────────────────────────────────
32
+
33
+ export interface DashboardFilters {
34
+ time?: [string, string];
35
+ [dimension: string]: string[] | [string, string] | undefined;
36
+ }
37
+
38
+ // ────────────────────────────────────────────────────────────
39
+ // Dashboard Aggregates (generic — keyed by config IDs)
40
+ // ────────────────────────────────────────────────────────────
41
+
42
+ export interface DashboardAggregates {
43
+ timeHistogram: { bucket: string; count: number }[];
44
+ dimensions: Record<string, { value: string; count: number }[]>;
45
+ stats: Record<string, number>;
46
+ }
47
+
48
+ // ────────────────────────────────────────────────────────────
49
+ // Event List types
50
+ // ────────────────────────────────────────────────────────────
51
+
52
+ export interface EventListResult {
53
+ rows: Record<string, unknown>[];
54
+ total: number;
55
+ }
56
+
57
+ // ────────────────────────────────────────────────────────────
58
+ // WHERE clause builder
59
+ // ────────────────────────────────────────────────────────────
60
+
61
+ function buildWhereClause(
62
+ config: ResourceDashboardConfig,
63
+ filters: DashboardFilters,
64
+ hours: number,
65
+ ): SQL {
66
+ const conditions: SQL[] = [];
67
+ const tsCol = config.timestampColumn;
68
+
69
+ // Time window
70
+ if (filters.time && filters.time[0] && filters.time[1]) {
71
+ conditions.push(sql`${sql.raw(tsCol)} >= ${filters.time[0]}::timestamptz`);
72
+ conditions.push(sql`${sql.raw(tsCol)} <= ${filters.time[1]}::timestamptz`);
73
+ } else {
74
+ conditions.push(sql`${sql.raw(tsCol)} >= NOW() - INTERVAL '1 hour' * ${hours}`);
75
+ }
76
+
77
+ // Dimension filters
78
+ for (const dim of config.dimensions) {
79
+ const filterValue = filters[dim.column];
80
+ if (!filterValue || !Array.isArray(filterValue) || filterValue.length === 0) continue;
81
+ // Skip time tuples (already handled)
82
+ if (filterValue.length === 2 && typeof filterValue[0] === 'string' && filterValue[0].includes('T')) continue;
83
+
84
+ const allowed = new Set(dim.allowedValues ?? []);
85
+ const safeValues = (filterValue as string[]).filter(v => allowed.size === 0 || allowed.has(v));
86
+ if (safeValues.length === 0) continue;
87
+
88
+ const params = safeValues.map(v => sql`${v}`);
89
+ let inList = params[0];
90
+ for (let i = 1; i < params.length; i++) {
91
+ inList = sql`${inList}, ${params[i]}`;
92
+ }
93
+ conditions.push(sql`${sql.raw(dim.column)} IN (${inList})`);
94
+ }
95
+
96
+ if (conditions.length === 0) return sql`1=1`;
97
+ if (conditions.length === 1) return conditions[0];
98
+
99
+ let result = conditions[0];
100
+ for (let i = 1; i < conditions.length; i++) {
101
+ result = sql`${result} AND ${conditions[i]}`;
102
+ }
103
+ return result;
104
+ }
105
+
106
+ // ────────────────────────────────────────────────────────────
107
+ // Stats CTE builder
108
+ // ────────────────────────────────────────────────────────────
109
+
110
+ function buildStatSelects(stats: StatDialConfig[]): SQL {
111
+ const parts: SQL[] = [];
112
+
113
+ for (const stat of stats) {
114
+ switch (stat.value.type) {
115
+ case 'count':
116
+ parts.push(sql`COUNT(*)::int AS ${sql.raw(stat.id)}`);
117
+ break;
118
+ case 'count_where':
119
+ parts.push(
120
+ sql`COUNT(*) FILTER (WHERE ${sql.raw(stat.value.column)} = ${stat.value.equals})::int AS ${sql.raw(stat.id)}`
121
+ );
122
+ break;
123
+ case 'distinct':
124
+ parts.push(
125
+ sql`COUNT(DISTINCT ${sql.raw(stat.value.column)})::int AS ${sql.raw(stat.id)}`
126
+ );
127
+ break;
128
+ case 'table_count':
129
+ // Handled separately as a subquery
130
+ break;
131
+ }
132
+ }
133
+
134
+ if (parts.length === 0) {
135
+ return sql`COUNT(*)::int AS total`;
136
+ }
137
+
138
+ let result = parts[0];
139
+ for (let i = 1; i < parts.length; i++) {
140
+ result = sql`${result}, ${parts[i]}`;
141
+ }
142
+ return result;
143
+ }
144
+
145
+ // ────────────────────────────────────────────────────────────
146
+ // Dashboard Data RPC
147
+ // ────────────────────────────────────────────────────────────
148
+
149
+ function createDashboardDataHandler(
150
+ tableName: string,
151
+ config: ResourceDashboardConfig,
152
+ db: DbExecutor,
153
+ ): (body: unknown) => Promise<unknown> {
154
+ return async (body: unknown) => {
155
+ const params = (body as Record<string, unknown>) ?? {};
156
+ const hours = Number(params.hours ?? config.defaultHours ?? 168);
157
+ const filters = (params.filters ?? {}) as DashboardFilters;
158
+ const where = buildWhereClause(config, filters, hours);
159
+ const bucketSize = config.bucketSize ?? 'hour';
160
+
161
+ // Build dimension CTEs
162
+ const dimensionCtes: SQL[] = [];
163
+ const dimensionSelects: SQL[] = [];
164
+
165
+ for (const dim of config.dimensions) {
166
+ const cteId = `dim_${dim.column}`;
167
+ dimensionCtes.push(sql`${sql.raw(cteId)} AS (
168
+ SELECT ${sql.raw(dim.column)} AS value, COUNT(*)::int AS count
169
+ FROM filtered
170
+ GROUP BY ${sql.raw(dim.column)}
171
+ ORDER BY count DESC
172
+ )`);
173
+ dimensionSelects.push(
174
+ sql`(SELECT COALESCE(json_agg(row_to_json(d)), '[]'::json) FROM ${sql.raw(cteId)} d) AS ${sql.raw(`"dim_${dim.column}"`)}`
175
+ );
176
+ }
177
+
178
+ // Build table_count stats (secondary tables)
179
+ const tableCountStats = config.stats.filter(s => s.value.type === 'table_count');
180
+ const tableCountCtes: SQL[] = [];
181
+ const tableCountSelects: SQL[] = [];
182
+
183
+ for (const stat of tableCountStats) {
184
+ if (stat.value.type !== 'table_count') continue;
185
+ const secTable = stat.value.table;
186
+ const secTsCol = stat.value.timestampColumn ?? config.timestampColumn;
187
+ // Build a simple time-window WHERE for the secondary table
188
+ let secWhere: SQL;
189
+ if (filters.time && filters.time[0] && filters.time[1]) {
190
+ secWhere = sql`${sql.raw(secTsCol)} >= ${filters.time[0]}::timestamptz AND ${sql.raw(secTsCol)} <= ${filters.time[1]}::timestamptz`;
191
+ } else {
192
+ secWhere = sql`${sql.raw(secTsCol)} >= NOW() - INTERVAL '1 hour' * ${hours}`;
193
+ }
194
+ tableCountCtes.push(sql`${sql.raw(`sec_${stat.id}`)} AS (
195
+ SELECT COUNT(*)::int AS count FROM ${sql.raw(secTable)} WHERE ${secWhere}
196
+ )`);
197
+ tableCountSelects.push(
198
+ sql`(SELECT count FROM ${sql.raw(`sec_${stat.id}`)}) AS ${sql.raw(`"sec_${stat.id}"`)}`
199
+ );
200
+ }
201
+
202
+ // Assemble query
203
+ const statSelects = buildStatSelects(config.stats);
204
+
205
+ let allCtes = sql`filtered AS (
206
+ SELECT *
207
+ FROM ${sql.raw(tableName)}
208
+ WHERE ${where}
209
+ ),
210
+ time_agg AS (
211
+ SELECT date_trunc('${sql.raw(bucketSize)}', ${sql.raw(config.timestampColumn)})::text AS bucket,
212
+ COUNT(*)::int AS count
213
+ FROM filtered
214
+ GROUP BY 1
215
+ ORDER BY 1 ASC
216
+ ),
217
+ stats_agg AS (
218
+ SELECT ${statSelects}
219
+ FROM filtered
220
+ )`;
221
+
222
+ for (const cte of dimensionCtes) {
223
+ allCtes = sql`${allCtes}, ${cte}`;
224
+ }
225
+ for (const cte of tableCountCtes) {
226
+ allCtes = sql`${allCtes}, ${cte}`;
227
+ }
228
+
229
+ let selectClauses = sql`(SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) FROM time_agg t) AS "timeHistogram",
230
+ (SELECT row_to_json(s) FROM stats_agg s) AS stats`;
231
+
232
+ for (const sel of dimensionSelects) {
233
+ selectClauses = sql`${selectClauses}, ${sel}`;
234
+ }
235
+ for (const sel of tableCountSelects) {
236
+ selectClauses = sql`${selectClauses}, ${sel}`;
237
+ }
238
+
239
+ const result = await db.execute(sql`
240
+ WITH ${allCtes}
241
+ SELECT ${selectClauses}
242
+ `);
243
+
244
+ const rows = extractRows(result);
245
+ const row = rows[0] as Record<string, unknown> | undefined;
246
+
247
+ if (!row) {
248
+ const emptyStats: Record<string, number> = {};
249
+ for (const s of config.stats) emptyStats[s.id] = 0;
250
+ return {
251
+ timeHistogram: [],
252
+ dimensions: {},
253
+ stats: emptyStats,
254
+ } satisfies DashboardAggregates;
255
+ }
256
+
257
+ // Parse timeHistogram
258
+ const timeHistogram = (typeof row.timeHistogram === 'string'
259
+ ? JSON.parse(row.timeHistogram)
260
+ : row.timeHistogram) as DashboardAggregates['timeHistogram'] ?? [];
261
+
262
+ // Parse stats
263
+ const rawStats = (typeof row.stats === 'string'
264
+ ? JSON.parse(row.stats)
265
+ : row.stats) as Record<string, number> ?? {};
266
+
267
+ // Merge in table_count stats
268
+ for (const stat of tableCountStats) {
269
+ const key = `sec_${stat.id}`;
270
+ const val = row[key];
271
+ rawStats[stat.id] = typeof val === 'number' ? val : 0;
272
+ }
273
+
274
+ // Parse dimension counts
275
+ const dimensions: Record<string, { value: string; count: number }[]> = {};
276
+ for (const dim of config.dimensions) {
277
+ const key = `dim_${dim.column}`;
278
+ const val = row[key];
279
+ dimensions[dim.column] = (typeof val === 'string' ? JSON.parse(val) : val) as { value: string; count: number }[] ?? [];
280
+ }
281
+
282
+ return { timeHistogram, dimensions, stats: rawStats } satisfies DashboardAggregates;
283
+ };
284
+ }
285
+
286
+ // ────────────────────────────────────────────────────────────
287
+ // Event List RPC
288
+ // ────────────────────────────────────────────────────────────
289
+
290
+ function createEventListHandler(
291
+ tableName: string,
292
+ config: ResourceDashboardConfig,
293
+ db: DbExecutor,
294
+ ): (body: unknown) => Promise<unknown> {
295
+ const validSortColumns = new Set(config.listColumns);
296
+
297
+ return async (body: unknown) => {
298
+ const params = (body as Record<string, unknown>) ?? {};
299
+ const hours = Number(params.hours ?? config.defaultHours ?? 168);
300
+ const filters = (params.filters ?? {}) as DashboardFilters;
301
+ const search = params.search as string | undefined;
302
+ const sortBy = validSortColumns.has(params.sortBy as string) ? params.sortBy as string : config.timestampColumn;
303
+ const sortDir = params.sortDir === 'asc' ? 'asc' : 'desc';
304
+ const limit = Math.min(Math.max(1, Number(params.limit ?? 50)), 200);
305
+ const offset = Math.max(0, Number(params.offset ?? 0));
306
+
307
+ const where = buildWhereClause(config, filters, hours);
308
+
309
+ // Build search condition
310
+ let searchCondition: SQL = sql`1=1`;
311
+ if (search && search.trim().length > 0) {
312
+ const searchCols = config.searchColumns ?? [];
313
+ if (searchCols.length > 0) {
314
+ const pattern = `%${search.trim()}%`;
315
+ let searchSql = sql`${sql.raw(searchCols[0])} ILIKE ${pattern}`;
316
+ for (let i = 1; i < searchCols.length; i++) {
317
+ searchSql = sql`${searchSql} OR ${sql.raw(searchCols[i])} ILIKE ${pattern}`;
318
+ }
319
+ searchCondition = sql`(${searchSql})`;
320
+ }
321
+ }
322
+
323
+ // Build SELECT columns — include primary key(s) for row identification
324
+ const pkCols = config.primaryKey
325
+ ? (Array.isArray(config.primaryKey) ? config.primaryKey : [config.primaryKey])
326
+ : ['id'];
327
+ const allCols = [...new Set([...pkCols, ...config.listColumns])];
328
+
329
+ const result = await db.execute(sql`
330
+ WITH filtered AS (
331
+ SELECT ${sql.raw(allCols.join(', '))}
332
+ FROM ${sql.raw(tableName)}
333
+ WHERE ${where} AND ${searchCondition}
334
+ )
335
+ SELECT
336
+ (SELECT COUNT(*)::int FROM filtered) AS total,
337
+ (SELECT COALESCE(json_agg(row_to_json(r)), '[]'::json)
338
+ FROM (
339
+ SELECT * FROM filtered
340
+ ORDER BY ${sql.raw(sortBy)} ${sql.raw(sortDir)}
341
+ LIMIT ${limit} OFFSET ${offset}
342
+ ) r
343
+ ) AS rows
344
+ `);
345
+
346
+ const rows = extractRows(result);
347
+ const row = rows[0] as Record<string, unknown> | undefined;
348
+
349
+ if (!row) {
350
+ return { rows: [], total: 0 } satisfies EventListResult;
351
+ }
352
+
353
+ const eventRows = (typeof row.rows === 'string'
354
+ ? JSON.parse(row.rows)
355
+ : row.rows) as Record<string, unknown>[] ?? [];
356
+
357
+ return {
358
+ rows: eventRows,
359
+ total: (row.total as number) ?? 0,
360
+ } satisfies EventListResult;
361
+ };
362
+ }
363
+
364
+ // ────────────────────────────────────────────────────────────
365
+ // Public API
366
+ // ────────────────────────────────────────────────────────────
367
+
368
+ export interface ResourceDashboardRpcs {
369
+ dashboardData: (body: unknown) => Promise<unknown>;
370
+ eventList: (body: unknown) => Promise<unknown>;
371
+ }
372
+
373
+ /**
374
+ * Create dashboard + event list RPC handlers for a resource.
375
+ *
376
+ * @param tableName - Database table name (snake_case)
377
+ * @param config - ResourceDashboardConfig from ResourceConfig.dashboard
378
+ * @param db - Drizzle db executor (must support .execute())
379
+ */
380
+ export function createResourceDashboardRpcs(
381
+ tableName: string,
382
+ config: ResourceDashboardConfig,
383
+ db: DbExecutor,
384
+ ): ResourceDashboardRpcs {
385
+ return {
386
+ dashboardData: createDashboardDataHandler(tableName, config, db),
387
+ eventList: createEventListHandler(tableName, config, db),
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Create dashboard RPCs for all resources that have a dashboard config.
393
+ *
394
+ * Returns a flat Record<string, RpcHandler> ready to spread into the handler's rpc config.
395
+ * RPC names follow the pattern: {resourceName}DashboardData, {resourceName}EventList
396
+ *
397
+ * @param resources - Array of ResourceConfig (with optional .dashboard)
398
+ * @param db - Drizzle db executor
399
+ */
400
+ export function createAllDashboardRpcs(
401
+ resources: Array<{ name: string; dashboard?: ResourceDashboardConfig }>,
402
+ db: DbExecutor,
403
+ ): Record<string, (body: unknown) => Promise<unknown>> {
404
+ const rpcs: Record<string, (body: unknown) => Promise<unknown>> = {};
405
+
406
+ for (const resource of resources) {
407
+ if (!resource.dashboard) continue;
408
+ const handlers = createResourceDashboardRpcs(resource.name, resource.dashboard, db);
409
+ // Convert resource name to camelCase RPC name prefix
410
+ const prefix = resource.name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
411
+ rpcs[`${prefix}DashboardData`] = handlers.dashboardData;
412
+ rpcs[`${prefix}EventList`] = handlers.eventList;
413
+ }
414
+
415
+ return rpcs;
416
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * AdminDashboard
3
+ *
4
+ * Renders dashboard widgets from AdminConfig.dashboards[].widgets.
5
+ * Supports stat cards (useCount/useStats) and chart widgets (useGroupedCount).
6
+ * Grid width: width >= 2 → full-width, else half-width.
7
+ */
8
+
9
+ import React from 'react';
10
+ import { View, Text } from 'react-native';
11
+ import { useAdminConfig } from '../hooks/useAdminConfig';
12
+ import { useCount, useStats, useGroupedCount } from '../hooks/useAggregates';
13
+ import { BarChart, DonutChart, LineChart, type DataPoint, type DataSeries } from '@everystack/ui';
14
+ import type { WidgetConfig } from '../types';
15
+
16
+ export interface AdminDashboardProps {
17
+ /** Dashboard ID to render (defaults to first dashboard) */
18
+ dashboardId?: string;
19
+ /** Called when a widget is pressed */
20
+ onWidgetPress?: (widget: WidgetConfig) => void;
21
+ }
22
+
23
+ export function AdminDashboard({ dashboardId, onWidgetPress }: AdminDashboardProps): React.ReactElement {
24
+ const config = useAdminConfig();
25
+ const dashboard = dashboardId
26
+ ? config.dashboards?.find(d => d.id === dashboardId)
27
+ : config.dashboards?.[0];
28
+
29
+ if (!dashboard) {
30
+ return (
31
+ <View className="flex-1 items-center justify-center py-12">
32
+ <Text className="text-sm text-gray-500">No dashboard configured</Text>
33
+ </View>
34
+ );
35
+ }
36
+
37
+ return (
38
+ <View className="flex-1 p-4">
39
+ <Text className="text-xl font-bold text-gray-900 mb-1">{dashboard.title}</Text>
40
+ {dashboard.description && (
41
+ <Text className="text-sm text-gray-500 mb-4">{dashboard.description}</Text>
42
+ )}
43
+
44
+ {/* Widget grid */}
45
+ <View className="flex-row flex-wrap -mx-2">
46
+ {dashboard.widgets.map(widget => {
47
+ const isWide = (widget.grid?.width ?? 1) >= 2;
48
+ return (
49
+ <View key={widget.id} className={isWide ? 'w-full px-2 mb-4' : 'w-1/2 px-2 mb-4'}>
50
+ <WidgetDispatcher widget={widget} onPress={onWidgetPress} />
51
+ </View>
52
+ );
53
+ })}
54
+ </View>
55
+ </View>
56
+ );
57
+ }
58
+
59
+ // =============================================================================
60
+ // Widget Dispatcher
61
+ // =============================================================================
62
+
63
+ interface WidgetDispatcherProps {
64
+ widget: WidgetConfig;
65
+ onPress?: (widget: WidgetConfig) => void;
66
+ }
67
+
68
+ function WidgetDispatcher({ widget, onPress }: WidgetDispatcherProps): React.ReactElement {
69
+ switch (widget.type) {
70
+ case 'chart':
71
+ return <ChartWidget widget={widget} onPress={onPress} />;
72
+ case 'stat':
73
+ default:
74
+ return <StatWidget widget={widget} onPress={onPress} />;
75
+ }
76
+ }
77
+
78
+ // =============================================================================
79
+ // StatWidget
80
+ // =============================================================================
81
+
82
+ interface StatWidgetProps {
83
+ widget: WidgetConfig;
84
+ onPress?: (widget: WidgetConfig) => void;
85
+ }
86
+
87
+ function StatWidget({ widget, onPress }: StatWidgetProps): React.ReactElement {
88
+ const resource = widget.data?.resource ?? '';
89
+ const aggregation = widget.data?.aggregation;
90
+ const field = widget.data?.field;
91
+
92
+ // Use the appropriate hook based on aggregation type
93
+ const { count, loading: countLoading } = useCount(resource, {
94
+ skip: aggregation !== 'count' && aggregation !== undefined,
95
+ });
96
+
97
+ const { stats, loading: statsLoading } = useStats(resource, {
98
+ sum: aggregation === 'sum' ? field : undefined,
99
+ avg: aggregation === 'avg' ? field : undefined,
100
+ min: aggregation === 'min' ? field : undefined,
101
+ max: aggregation === 'max' ? field : undefined,
102
+ skip: aggregation === 'count' || aggregation === undefined,
103
+ });
104
+
105
+ const loading = countLoading || statsLoading;
106
+ let value: string | number = '—';
107
+
108
+ if (!loading) {
109
+ if (aggregation === 'count' || aggregation === undefined) {
110
+ value = count ?? 0;
111
+ } else if (aggregation && stats[aggregation] != null) {
112
+ value = formatStat(stats[aggregation] as number, widget.stat?.format);
113
+ }
114
+ }
115
+
116
+ return (
117
+ <View className="bg-white rounded-xl p-4 border border-gray-200">
118
+ <Text className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">
119
+ {widget.title}
120
+ </Text>
121
+ <Text className="text-2xl font-bold text-gray-900">
122
+ {loading ? '...' : String(value)}
123
+ </Text>
124
+ </View>
125
+ );
126
+ }
127
+
128
+ // =============================================================================
129
+ // ChartWidget
130
+ // =============================================================================
131
+
132
+ interface ChartWidgetProps {
133
+ widget: WidgetConfig;
134
+ onPress?: (widget: WidgetConfig) => void;
135
+ }
136
+
137
+ function ChartWidget({ widget, onPress }: ChartWidgetProps): React.ReactElement {
138
+ const resource = widget.data?.resource ?? '';
139
+ const groupBy = widget.data?.groupBy ?? '';
140
+ const chartType = widget.chart?.type ?? 'bar';
141
+
142
+ const { data, loading, error } = useGroupedCount(resource, groupBy, {
143
+ filter: widget.data?.query as Record<string, unknown> | undefined,
144
+ });
145
+
146
+ // Transform grouped data to chart data points
147
+ const chartData: DataPoint[] = data.map(row => ({
148
+ label: String(row[groupBy] ?? 'Unknown'),
149
+ value: row.count ?? 0,
150
+ }));
151
+
152
+ return (
153
+ <View className="bg-white rounded-xl p-4 border border-gray-200">
154
+ <Text className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
155
+ {widget.title}
156
+ </Text>
157
+
158
+ {loading ? (
159
+ <View className="h-40 items-center justify-center">
160
+ <Text className="text-sm text-gray-400">Loading...</Text>
161
+ </View>
162
+ ) : error ? (
163
+ <View className="h-40 items-center justify-center">
164
+ <Text className="text-sm text-red-400">Failed to load</Text>
165
+ </View>
166
+ ) : (
167
+ <ChartRenderer type={chartType} data={chartData} />
168
+ )}
169
+ </View>
170
+ );
171
+ }
172
+
173
+ // =============================================================================
174
+ // Chart Renderer
175
+ // =============================================================================
176
+
177
+ interface ChartRendererProps {
178
+ type: string;
179
+ data: DataPoint[];
180
+ }
181
+
182
+ function ChartRenderer({ type, data }: ChartRendererProps): React.ReactElement {
183
+ switch (type) {
184
+ case 'donut':
185
+ case 'pie':
186
+ return <DonutChart data={data} size={140} showLegend />;
187
+
188
+ case 'line':
189
+ case 'area': {
190
+ const series: DataSeries[] = [{
191
+ name: 'Value',
192
+ data,
193
+ color: '#6366f1',
194
+ }];
195
+ return <LineChart series={series} height={160} fillArea={type === 'area'} showGrid />;
196
+ }
197
+
198
+ case 'bar':
199
+ default:
200
+ return <BarChart data={data} height={160} showLabels showValues={false} orientation="horizontal" />;
201
+ }
202
+ }
203
+
204
+ // =============================================================================
205
+ // Helpers
206
+ // =============================================================================
207
+
208
+ function formatStat(value: number, format?: 'number' | 'currency' | 'percent'): string {
209
+ switch (format) {
210
+ case 'currency':
211
+ return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
212
+ case 'percent':
213
+ return `${(value * 100).toFixed(1)}%`;
214
+ default:
215
+ return value.toLocaleString();
216
+ }
217
+ }