@asteby/metacore-runtime-react 18.16.0 → 18.17.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 (35) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/dashboard-grid.d.ts +6 -0
  3. package/dist/dashboard-grid.d.ts.map +1 -0
  4. package/dist/dashboard-grid.js +127 -0
  5. package/dist/dashboard-types.d.ts +130 -0
  6. package/dist/dashboard-types.d.ts.map +1 -0
  7. package/dist/dashboard-types.js +7 -0
  8. package/dist/index.d.ts +6 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +5 -0
  11. package/dist/permissions-manager.d.ts.map +1 -1
  12. package/dist/permissions-manager.js +16 -7
  13. package/dist/widgets/renderers.d.ts +19 -0
  14. package/dist/widgets/renderers.d.ts.map +1 -0
  15. package/dist/widgets/renderers.js +78 -0
  16. package/dist/widgets/widget-card.d.ts +34 -0
  17. package/dist/widgets/widget-card.d.ts.map +1 -0
  18. package/dist/widgets/widget-card.js +30 -0
  19. package/dist/widgets/widget-format.d.ts +42 -0
  20. package/dist/widgets/widget-format.d.ts.map +1 -0
  21. package/dist/widgets/widget-format.js +138 -0
  22. package/dist/widgets/widget-renderer.d.ts +27 -0
  23. package/dist/widgets/widget-renderer.d.ts.map +1 -0
  24. package/dist/widgets/widget-renderer.js +66 -0
  25. package/package.json +4 -3
  26. package/src/__tests__/dashboard-grid.test.tsx +222 -0
  27. package/src/__tests__/permissions-manager.test.tsx +55 -42
  28. package/src/dashboard-grid.tsx +206 -0
  29. package/src/dashboard-types.ts +178 -0
  30. package/src/index.ts +56 -0
  31. package/src/permissions-manager.tsx +90 -65
  32. package/src/widgets/renderers.tsx +336 -0
  33. package/src/widgets/widget-card.tsx +125 -0
  34. package/src/widgets/widget-format.ts +181 -0
  35. package/src/widgets/widget-renderer.tsx +181 -0
@@ -184,50 +184,59 @@ describe('helpers puros', () => {
184
184
  })
185
185
  })
186
186
 
187
- describe('PermissionsManager (lista plana, shape nuevo)', () => {
187
+ describe('PermissionsManager (módulo como combobox agrupado)', () => {
188
+ // The module picker is a grouped combobox (same pattern as the role
189
+ // selector): open it, then click the module's option.
190
+ const moduleTrigger = () => {
191
+ // Two role="combobox" triggers: [0] role selector, [1] module selector.
192
+ const triggers = screen.getAllByRole('combobox')
193
+ return triggers[triggers.length - 1]
194
+ }
195
+ const selectModule = async (name: RegExp) => {
196
+ fireEvent.click(moduleTrigger())
197
+ fireEvent.click(await screen.findByRole('option', { name }))
198
+ }
199
+
188
200
  it('renderiza catálogo, auto-selecciona rol y primer módulo, contador N/M', async () => {
189
201
  const props = makeProps()
190
202
  render(<PermissionsManager {...props} />)
191
203
 
192
- // Primer módulo = "Usuarios" (grupo sin título, va primero).
193
- expect(await screen.findAllByText('Usuarios')).toBeTruthy()
204
+ // Primer módulo = "Usuarios" (auto-seleccionado) su nombre en el trigger.
205
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
194
206
  expect(props.loadRolePermissions).toHaveBeenCalledWith('r1')
195
207
 
196
- // Headers de grupo grises (no colapsables): el del grupo con título.
197
- expect(screen.getByText('Punto de venta')).toBeTruthy()
198
- // Filas de módulo de la lista plana.
199
- expect(screen.getByRole('button', { name: /Pedidos POS/ })).toBeTruthy()
200
- expect(screen.getByRole('button', { name: /Terminal/ })).toBeTruthy()
208
+ // Al abrir el combobox: grupos (CommandGroup heading) + opciones.
209
+ fireEvent.click(moduleTrigger())
210
+ expect(await screen.findByText('Punto de venta')).toBeTruthy()
211
+ expect(screen.getByRole('option', { name: /Pedidos POS/ })).toBeTruthy()
212
+ expect(screen.getByRole('option', { name: /Terminal/ })).toBeTruthy()
201
213
 
202
214
  // Generales presentes con descripción.
203
215
  expect(screen.getByText('Permisos Generales')).toBeTruthy()
204
216
  expect(screen.getByText('Trabajar fuera de horario')).toBeTruthy()
205
217
  })
206
218
 
207
- it('CERO acordeones: el header de grupo es un heading, no un botón colapsable', async () => {
219
+ it('CERO acordeones: el módulo es un combobox, no un folder colapsable', async () => {
208
220
  const props = makeProps()
209
221
  render(<PermissionsManager {...props} />)
210
- await screen.findAllByText('Usuarios')
211
- // El header gris "Punto de venta" es un heading, NO un button (sin folder/acordeón).
212
- const header = screen.getByText('Punto de venta')
213
- expect(header.closest('button')).toBeNull()
214
- expect(header.getAttribute('role')).toBe('heading')
215
- // No existe ningún botón cuyo accesible name sea el título del grupo
216
- // (lo que delataría un CollapsibleTrigger).
222
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
223
+ // El header de grupo "Punto de venta" vive DENTRO del popover (no en la
224
+ // columna), así que no existe hasta abrir el combobox — nada de folders.
225
+ expect(screen.queryByText('Punto de venta')).toBeNull()
217
226
  expect(screen.queryByRole('button', { name: 'Punto de venta' })).toBeNull()
227
+ // El trigger del módulo es un combobox accesible.
228
+ expect(moduleTrigger().getAttribute('role')).toBe('combobox')
218
229
  })
219
230
 
220
- it('click directo en una fila selecciona el módulo y muestra su grid', async () => {
231
+ it('seleccionar un módulo en el combobox muestra su grid', async () => {
221
232
  const props = makeProps()
222
233
  render(<PermissionsManager {...props} />)
223
- await screen.findAllByText('Usuarios')
234
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
224
235
 
225
- // Selecciono "Pedidos POS" → su grid aparece a la derecha.
226
- fireEvent.click(screen.getByRole('button', { name: /Pedidos POS/ }))
236
+ await selectModule(/Pedidos POS/)
227
237
  expect(await screen.findByText('Pagar')).toBeTruthy()
228
238
 
229
- // Selecciono el screen "Terminal" → acción "Acceder".
230
- fireEvent.click(screen.getByRole('button', { name: /Terminal/ }))
239
+ await selectModule(/Terminal/)
231
240
  expect(await screen.findByText('Acceder')).toBeTruthy()
232
241
  await waitFor(() => expect(screen.queryByText('Pagar')).toBeNull())
233
242
  })
@@ -235,9 +244,9 @@ describe('PermissionsManager (lista plana, shape nuevo)', () => {
235
244
  it('marcar el screen "Acceder" produce capability screen.<navKey>.access', async () => {
236
245
  const props = makeProps()
237
246
  render(<PermissionsManager {...props} />)
238
- await screen.findAllByText('Usuarios')
247
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
239
248
 
240
- fireEvent.click(screen.getByRole('button', { name: /Terminal/ }))
249
+ await selectModule(/Terminal/)
241
250
  await screen.findByText('Acceder')
242
251
  fireEvent.click(screen.getByRole('checkbox', { name: /Acceder/ }))
243
252
  fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
@@ -252,9 +261,9 @@ describe('PermissionsManager (lista plana, shape nuevo)', () => {
252
261
  it('marcar una acción + un general y guardar llama sync con el set completo', async () => {
253
262
  const props = makeProps()
254
263
  render(<PermissionsManager {...props} />)
255
- await screen.findAllByText('Usuarios')
264
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
256
265
 
257
- fireEvent.click(screen.getByRole('button', { name: /Pedidos POS/ }))
266
+ await selectModule(/Pedidos POS/)
258
267
  await screen.findByText('Pagar')
259
268
  fireEvent.click(screen.getByRole('checkbox', { name: /Pagar/ }))
260
269
  fireEvent.click(screen.getByRole('checkbox', { name: /Trabajar fuera de horario/ }))
@@ -275,8 +284,8 @@ describe('PermissionsManager (lista plana, shape nuevo)', () => {
275
284
  it('marcar todo / limpiar operan sobre el módulo activo', async () => {
276
285
  const props = makeProps()
277
286
  render(<PermissionsManager {...props} />)
278
- await screen.findAllByText('Usuarios')
279
- fireEvent.click(screen.getByRole('button', { name: /Pedidos POS/ }))
287
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
288
+ await selectModule(/Pedidos POS/)
280
289
  await screen.findByText('Pagar')
281
290
 
282
291
  fireEvent.click(screen.getByRole('button', { name: /Marcar todo/ }))
@@ -297,29 +306,31 @@ describe('PermissionsManager (lista plana, shape nuevo)', () => {
297
306
  it('guardar deshabilitado sin cambios', async () => {
298
307
  const props = makeProps()
299
308
  render(<PermissionsManager {...props} />)
300
- await screen.findAllByText('Usuarios')
309
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
301
310
  const save = screen.getByRole('button', { name: /Guardar permisos/ }) as HTMLButtonElement
302
311
  expect(save.disabled).toBe(true)
303
312
  })
304
313
 
305
- it('la búsqueda filtra las filas de la lista plana', async () => {
314
+ it('el combobox de módulo filtra por búsqueda', async () => {
306
315
  const props = makeProps()
307
316
  render(<PermissionsManager {...props} />)
308
- await screen.findAllByText('Usuarios')
317
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
309
318
 
310
- fireEvent.change(screen.getByLabelText('Buscar módulo'), {
319
+ fireEvent.click(moduleTrigger())
320
+ fireEvent.change(await screen.findByPlaceholderText('Buscar módulo…'), {
311
321
  target: { value: 'terminal' },
312
322
  })
313
- // Solo el módulo con match permanece como fila; otros se ocultan.
314
- expect(screen.getByRole('button', { name: /Terminal/ })).toBeTruthy()
315
- expect(screen.queryByRole('button', { name: /Pedidos POS/ })).toBeNull()
316
- expect(screen.queryByRole('button', { name: /Usuarios/ })).toBeNull()
323
+ await waitFor(() =>
324
+ expect(screen.getByRole('option', { name: /Terminal/ })).toBeTruthy(),
325
+ )
326
+ expect(screen.queryByRole('option', { name: /Pedidos POS/ })).toBeNull()
327
+ expect(screen.queryByRole('option', { name: /Usuarios/ })).toBeNull()
317
328
  })
318
329
 
319
330
  it('oculta Nuevo rol / Editar / Eliminar cuando no hay mutators de rol', async () => {
320
331
  const props = makeProps()
321
332
  render(<PermissionsManager {...props} />)
322
- await screen.findAllByText('Usuarios')
333
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
323
334
  expect(screen.queryByRole('button', { name: /Nuevo rol/ })).toBeNull()
324
335
  expect(screen.queryByRole('button', { name: 'Editar rol' })).toBeNull()
325
336
  expect(screen.queryByRole('button', { name: 'Eliminar rol' })).toBeNull()
@@ -331,7 +342,7 @@ describe('PermissionsManager (lista plana, shape nuevo)', () => {
331
342
  deleteRole: vi.fn(async () => {}),
332
343
  })
333
344
  render(<PermissionsManager {...props} />)
334
- await screen.findAllByText('Usuarios')
345
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
335
346
  expect(screen.queryByRole('button', { name: 'Quitar rol seleccionado' })).toBeNull()
336
347
  expect(screen.getByRole('button', { name: 'Editar rol' })).toBeTruthy()
337
348
  expect(screen.getByRole('button', { name: 'Eliminar rol' })).toBeTruthy()
@@ -344,7 +355,7 @@ describe('PermissionsManager (lista plana, shape nuevo)', () => {
344
355
  deleteRole: vi.fn(async () => {}),
345
356
  })
346
357
  render(<PermissionsManager {...props} />)
347
- await screen.findAllByText('Usuarios')
358
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
348
359
  expect(screen.getByRole('button', { name: /Nuevo rol/ })).toBeTruthy()
349
360
  expect(screen.getByRole('button', { name: 'Editar rol' })).toBeTruthy()
350
361
  expect(screen.getByRole('button', { name: 'Eliminar rol' })).toBeTruthy()
@@ -358,11 +369,13 @@ describe('PermissionsManager (retrocompat shape viejo {modules})', () => {
358
369
 
359
370
  // Auto-selección del primer módulo legacy (pos_orders → grupo "Punto de venta").
360
371
  expect(await screen.findByText('Pagar')).toBeTruthy()
361
- // Header gris derivado del addon.
362
- expect(screen.getByText('Punto de venta')).toBeTruthy()
372
+ // Los grupos derivados del addon viven dentro del combobox de módulo.
373
+ const triggers = screen.getAllByRole('combobox')
374
+ fireEvent.click(triggers[triggers.length - 1])
375
+ expect(await screen.findByText('Punto de venta')).toBeTruthy()
363
376
  // El grupo Sistema (users sin addon) también.
364
377
  expect(screen.getByText('Sistema')).toBeTruthy()
365
- expect(screen.getByRole('button', { name: /Usuarios/ })).toBeTruthy()
378
+ expect(screen.getByRole('option', { name: /Usuarios/ })).toBeTruthy()
366
379
  })
367
380
 
368
381
  it('legacy: click + guardar produce capabilities correctas', async () => {
@@ -0,0 +1,206 @@
1
+ // DashboardGrid — the modular dashboard surface. Renders the union of
2
+ // declarative + federated widgets in a responsive 4-column grid, grouped with
3
+ // headings, honouring per-widget size, permission gating (useCan), batch data
4
+ // loading with per-widget skeletons, isolated per-widget errors, and a pro
5
+ // global empty state. See CONTRACT-dashboard-widgets.md §4.
6
+
7
+ import * as React from 'react'
8
+ import { useTranslation } from 'react-i18next'
9
+ import { cn } from '@asteby/metacore-ui/lib'
10
+ import { useCan, usePermissionsActive } from './permissions-context'
11
+ import { DynamicIcon } from './dynamic-icon'
12
+ import type {
13
+ DashboardGridProps,
14
+ DashboardGridStrings,
15
+ DashboardWidgetGroup,
16
+ DashboardWidgetSpec,
17
+ } from './dashboard-types'
18
+ import { WidgetRenderer, WidgetSkeleton, SIZE_CLASS } from './widgets/widget-renderer'
19
+
20
+ const DEFAULT_STRINGS: DashboardGridStrings = {
21
+ emptyTitle: 'Your dashboard is empty',
22
+ emptyDescription:
23
+ 'Install an addon with dashboard widgets to start seeing metrics here.',
24
+ widgetError: 'Could not load this widget.',
25
+ widgetEmpty: 'No data yet.',
26
+ }
27
+
28
+ /** Normalizes flat `widgets` + `groups` into an ordered group list. */
29
+ export function normalizeGroups(
30
+ groups?: DashboardWidgetGroup[],
31
+ widgets?: DashboardWidgetSpec[],
32
+ ): DashboardWidgetGroup[] {
33
+ if (groups && groups.length > 0) return groups
34
+ if (!widgets || widgets.length === 0) return []
35
+ // Group flat widgets by `group` (preserve first-seen order), sort each
36
+ // group by `order` (default 100), keep insertion order across groups.
37
+ const map = new Map<string, DashboardWidgetSpec[]>()
38
+ for (const w of widgets) {
39
+ const key = w.group ?? ''
40
+ const arr = map.get(key) ?? []
41
+ arr.push(w)
42
+ map.set(key, arr)
43
+ }
44
+ return Array.from(map.entries()).map(([title, ws]) => ({
45
+ title,
46
+ widgets: [...ws].sort((a, b) => (a.order ?? 100) - (b.order ?? 100)),
47
+ }))
48
+ }
49
+
50
+ /** Collects every widget key across groups (for the batch loader). */
51
+ function allKeys(groups: DashboardWidgetGroup[]): string[] {
52
+ const keys: string[] = []
53
+ for (const g of groups) for (const w of g.widgets) keys.push(w.key)
54
+ return keys
55
+ }
56
+
57
+ export function DashboardGrid({
58
+ groups,
59
+ widgets,
60
+ loadData,
61
+ isAdmin,
62
+ locale,
63
+ currency,
64
+ className,
65
+ strings,
66
+ }: DashboardGridProps) {
67
+ const { t } = useTranslation()
68
+ const can = useCan()
69
+ const permissionsActive = usePermissionsActive()
70
+ const s = { ...DEFAULT_STRINGS, ...strings }
71
+
72
+ // i18n helper: translate a key, falling back to the raw key when missing
73
+ // (specs ship raw i18n keys; the host bundle may or may not have them).
74
+ const tr = React.useCallback(
75
+ (key?: string, fallback?: string): string => {
76
+ if (!key) return fallback ?? ''
77
+ const out = t(key)
78
+ return out === key ? fallback ?? key : out
79
+ },
80
+ [t],
81
+ )
82
+
83
+ // Permission gating: admin bypass; without a PermissionsProvider everything
84
+ // is visible (retrocompat). With one, honour each widget's `permission`.
85
+ const visibleGroups = React.useMemo(() => {
86
+ const norm = normalizeGroups(groups, widgets)
87
+ const gate = (w: DashboardWidgetSpec): boolean => {
88
+ if (isAdmin) return true
89
+ if (!permissionsActive) return true
90
+ if (!w.permission) return true
91
+ return can(w.permission)
92
+ }
93
+ return norm
94
+ .map((g) => ({ ...g, widgets: g.widgets.filter(gate) }))
95
+ .filter((g) => g.widgets.length > 0)
96
+ }, [groups, widgets, isAdmin, permissionsActive, can])
97
+
98
+ const keys = React.useMemo(() => allKeys(visibleGroups), [visibleGroups])
99
+ const keySig = keys.join(',')
100
+
101
+ const [data, setData] = React.useState<
102
+ Record<string, import('./dashboard-types').WidgetData> | null
103
+ >(null)
104
+ const [loading, setLoading] = React.useState(true)
105
+
106
+ React.useEffect(() => {
107
+ let cancelled = false
108
+ if (keys.length === 0) {
109
+ setLoading(false)
110
+ setData({})
111
+ return
112
+ }
113
+ setLoading(true)
114
+ loadData(keys)
115
+ .then((res) => {
116
+ if (!cancelled) {
117
+ setData(res ?? {})
118
+ setLoading(false)
119
+ }
120
+ })
121
+ .catch(() => {
122
+ // Batch failure: still render the grid; every widget falls to
123
+ // its empty/error state rather than blanking the page.
124
+ if (!cancelled) {
125
+ setData({})
126
+ setLoading(false)
127
+ }
128
+ })
129
+ return () => {
130
+ cancelled = true
131
+ }
132
+ // eslint-disable-next-line react-hooks/exhaustive-deps
133
+ }, [keySig, loadData])
134
+
135
+ // Global empty state (no widgets at all / none visible after gating).
136
+ if (visibleGroups.length === 0) {
137
+ return (
138
+ <div
139
+ data-testid="dashboard-empty"
140
+ className={cn(
141
+ 'flex min-h-[40vh] flex-col items-center justify-center rounded-xl border border-dashed border-border/60 p-10 text-center',
142
+ className,
143
+ )}
144
+ >
145
+ <div className="mb-4 flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
146
+ <DynamicIcon name="LayoutDashboard" className="size-7" />
147
+ </div>
148
+ <h3 className="text-base font-semibold text-foreground">
149
+ {tr(undefined, s.emptyTitle) || s.emptyTitle}
150
+ </h3>
151
+ <p className="mt-1 max-w-sm text-sm text-muted-foreground">
152
+ {s.emptyDescription}
153
+ </p>
154
+ </div>
155
+ )
156
+ }
157
+
158
+ return (
159
+ <div data-testid="dashboard-grid" className={cn('flex flex-col gap-8', className)}>
160
+ {visibleGroups.map((group) => (
161
+ <section key={group.title || '__default'} className="flex flex-col gap-3">
162
+ {group.title && (
163
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
164
+ {tr(group.title, group.title)}
165
+ </h2>
166
+ )}
167
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
168
+ {group.widgets.map((spec) => {
169
+ const size = spec.size ?? 'sm'
170
+ return (
171
+ <div key={spec.key} className={SIZE_CLASS[size]}>
172
+ {loading ? (
173
+ <WidgetSkeleton
174
+ spec={{
175
+ ...spec,
176
+ title: tr(spec.title, spec.title),
177
+ subtitle: tr(spec.subtitle, spec.subtitle),
178
+ }}
179
+ />
180
+ ) : (
181
+ <WidgetRenderer
182
+ spec={{
183
+ ...spec,
184
+ title: tr(spec.title, spec.title),
185
+ subtitle: tr(spec.subtitle, spec.subtitle),
186
+ }}
187
+ data={data?.[spec.key]}
188
+ locale={locale}
189
+ currency={currency}
190
+ emptyText={
191
+ spec.empty
192
+ ? tr(spec.empty, spec.empty)
193
+ : s.widgetEmpty
194
+ }
195
+ errorText={s.widgetError}
196
+ />
197
+ )}
198
+ </div>
199
+ )
200
+ })}
201
+ </div>
202
+ </section>
203
+ ))}
204
+ </div>
205
+ )
206
+ }
@@ -0,0 +1,178 @@
1
+ // Dashboard widget types — the SDK-facing surface of the modular dashboard
2
+ // contract (CONTRACT-dashboard-widgets.md §1, §3, §4). The host (ops/kernel)
3
+ // computes the data and ships the specs as raw i18n keys; the SDK renders.
4
+ //
5
+ // These mirror the v3 `contributions.dashboard[]` shape so a host can forward
6
+ // the backend response straight into <DashboardGrid> without remapping.
7
+
8
+ /** Widget renderer kinds. `custom` defers to a federated slot component. */
9
+ export type WidgetKind =
10
+ | 'stat'
11
+ | 'bar'
12
+ | 'line'
13
+ | 'area'
14
+ | 'pie'
15
+ | 'donut'
16
+ | 'list'
17
+ | 'progress'
18
+ | 'custom'
19
+
20
+ /** Grid footprint in a 4-column grid: sm=1, md=2, lg=3, full=4. */
21
+ export type WidgetSize = 'sm' | 'md' | 'lg' | 'full'
22
+
23
+ /** Value format applied to the scalar value and to series values. */
24
+ export type WidgetFormat = 'number' | 'currency' | 'percent' | 'compact'
25
+
26
+ /** Accent color token (theme CSS vars). */
27
+ export type WidgetAccent =
28
+ | 'emerald'
29
+ | 'sky'
30
+ | 'violet'
31
+ | 'amber'
32
+ | 'rose'
33
+ | 'slate'
34
+
35
+ /** Aggregation kinds for declarative queries. */
36
+ export type WidgetAggregate = 'count' | 'sum' | 'avg' | 'min' | 'max'
37
+
38
+ /** Where-clause operators (mirror the list builder). */
39
+ export interface WidgetWhereOp {
40
+ eq?: unknown
41
+ neq?: unknown
42
+ gt?: unknown
43
+ gte?: unknown
44
+ lt?: unknown
45
+ lte?: unknown
46
+ contains?: unknown
47
+ }
48
+
49
+ /**
50
+ * Declarative aggregation query (kinds other than `custom`). The host resolves
51
+ * the logical table from `model` and computes the aggregate org-scoped.
52
+ */
53
+ export interface DashboardWidgetQuery {
54
+ model: string
55
+ aggregate: WidgetAggregate
56
+ field?: string
57
+ where?: Record<string, unknown | WidgetWhereOp>
58
+ group_by?: string
59
+ label_field?: string
60
+ date_field?: string
61
+ interval?: 'day' | 'week' | 'month'
62
+ range?:
63
+ | 'this_day'
64
+ | 'last_7_days'
65
+ | 'last_30_days'
66
+ | 'last_12_months'
67
+ | 'this_month'
68
+ | 'this_year'
69
+ | 'all'
70
+ order?: 'asc' | 'desc'
71
+ limit?: number
72
+ }
73
+
74
+ /** Optional delta comparison against the previous window → `+14.2%` chip. */
75
+ export interface DashboardWidgetCompare {
76
+ to: 'previous_period'
77
+ }
78
+
79
+ /**
80
+ * A single dashboard widget spec (v3 contract §1). The host ships these as raw
81
+ * i18n keys (`title`, `subtitle`, `group`, `empty`); the grid translates them.
82
+ */
83
+ export interface DashboardWidgetSpec {
84
+ /** Unique within the addon. Capability = `<addon>.dashboard.<key>`. */
85
+ key: string
86
+ /** i18n key for the title. */
87
+ title: string
88
+ /** i18n key for the subtitle. */
89
+ subtitle?: string
90
+ /** lucide icon slug. */
91
+ icon?: string
92
+ kind: WidgetKind
93
+ size?: WidgetSize
94
+ /** i18n key for the group heading. */
95
+ group?: string
96
+ /** Order within the group. */
97
+ order?: number
98
+ accent?: WidgetAccent
99
+ format?: WidgetFormat
100
+ /** Capability gating the widget (useCan). */
101
+ permission?: string
102
+ /** i18n key for the per-widget empty state. */
103
+ empty?: string
104
+
105
+ // declarative kinds
106
+ query?: DashboardWidgetQuery
107
+ compare?: DashboardWidgetCompare
108
+
109
+ // custom (federated) kind
110
+ slot?: string
111
+ expose?: string
112
+ }
113
+
114
+ /** A bucket of an aggregated series. */
115
+ export interface WidgetSeriesPoint {
116
+ key: string
117
+ label: string
118
+ value: number
119
+ }
120
+
121
+ /**
122
+ * The computed data for one widget (CONTRACT §3). `value` for scalars,
123
+ * `series` for bucketed/temporal aggregates, `delta` for the compare chip
124
+ * (fraction, e.g. `0.142` → `+14.2%`).
125
+ */
126
+ export interface WidgetData {
127
+ value?: number
128
+ delta?: number
129
+ series?: WidgetSeriesPoint[]
130
+ }
131
+
132
+ /** A titled group of widgets (CONTRACT §3 — backend grouping/order). */
133
+ export interface DashboardWidgetGroup {
134
+ /** i18n key for the group heading. */
135
+ title: string
136
+ widgets: DashboardWidgetSpec[]
137
+ }
138
+
139
+ /**
140
+ * Loads the data for a batch of widget keys. The host runs the aggregation and
141
+ * returns a `{ [key]: WidgetData }` map. Keys missing from the result render
142
+ * their empty state.
143
+ */
144
+ export type LoadWidgetData = (
145
+ keys: string[],
146
+ ) => Promise<Record<string, WidgetData>>
147
+
148
+ /** Props for <DashboardGrid>. */
149
+ export interface DashboardGridProps {
150
+ /** Pre-grouped widgets (backend grouping/order). */
151
+ groups?: DashboardWidgetGroup[]
152
+ /** Flat widget list — grouped client-side by `group`/`order`. */
153
+ widgets?: DashboardWidgetSpec[]
154
+ /** Batch loader for widget data (org-scoped, host-side aggregation). */
155
+ loadData: LoadWidgetData
156
+ /** When true, bypass permission gating (admin/owner sees everything). */
157
+ isAdmin?: boolean
158
+ /** BCP-47 locale for number/currency/date formatting. */
159
+ locale?: string
160
+ /** ISO-4217 currency for `format: 'currency'` widgets. */
161
+ currency?: string
162
+ /** Extra class on the grid root. */
163
+ className?: string
164
+ /** Optional override for empty/loading/error copy. */
165
+ strings?: Partial<DashboardGridStrings>
166
+ }
167
+
168
+ /** Translatable copy used by the grid chrome. Defaults are English. */
169
+ export interface DashboardGridStrings {
170
+ /** Global empty state title (no widgets at all). */
171
+ emptyTitle: string
172
+ /** Global empty state description. */
173
+ emptyDescription: string
174
+ /** Per-widget error message. */
175
+ widgetError: string
176
+ /** Per-widget empty (no data) fallback when the spec has no `empty` key. */
177
+ widgetEmpty: string
178
+ }
package/src/index.ts CHANGED
@@ -181,3 +181,59 @@ export {
181
181
  ActivityTimeline,
182
182
  type ActivityTimelineProps,
183
183
  } from './activity-timeline'
184
+ export {
185
+ DashboardGrid,
186
+ normalizeGroups,
187
+ } from './dashboard-grid'
188
+ export type {
189
+ WidgetKind,
190
+ WidgetSize,
191
+ WidgetFormat,
192
+ WidgetAccent,
193
+ WidgetAggregate,
194
+ WidgetWhereOp,
195
+ DashboardWidgetQuery,
196
+ DashboardWidgetCompare,
197
+ DashboardWidgetSpec,
198
+ WidgetSeriesPoint,
199
+ WidgetData,
200
+ DashboardWidgetGroup,
201
+ LoadWidgetData,
202
+ DashboardGridProps,
203
+ DashboardGridStrings,
204
+ } from './dashboard-types'
205
+ export {
206
+ StatWidget,
207
+ BarWidget,
208
+ LineWidget,
209
+ AreaWidget,
210
+ PieWidget,
211
+ DonutWidget,
212
+ ListWidget,
213
+ ProgressWidget,
214
+ type WidgetRenderProps,
215
+ } from './widgets/renderers'
216
+ export {
217
+ WidgetRenderer,
218
+ WidgetSkeleton,
219
+ SIZE_SPAN,
220
+ SIZE_CLASS,
221
+ type WidgetRendererProps,
222
+ } from './widgets/widget-renderer'
223
+ export {
224
+ WidgetCard,
225
+ DeltaChip,
226
+ WidgetEmpty,
227
+ WidgetError,
228
+ type WidgetCardProps,
229
+ } from './widgets/widget-card'
230
+ export {
231
+ formatWidgetValue,
232
+ formatAxisTick,
233
+ formatDelta,
234
+ accentClasses,
235
+ paletteColor,
236
+ CHART_PALETTE,
237
+ type AccentClasses,
238
+ type WidgetFormatCtx,
239
+ } from './widgets/widget-format'