@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
@@ -463,7 +463,7 @@ export function PermissionsManager({
463
463
  const [saving, setSaving] = React.useState(false)
464
464
 
465
465
  const [roleOpen, setRoleOpen] = React.useState(false)
466
- const [moduleQuery, setModuleQuery] = React.useState('')
466
+ const [moduleOpen, setModuleOpen] = React.useState(false)
467
467
 
468
468
  // Pending role switch while there are unsaved changes.
469
469
  const [pendingRoleId, setPendingRoleId] = React.useState<string | null>(null)
@@ -547,12 +547,6 @@ export function PermissionsManager({
547
547
 
548
548
  const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft)
549
549
 
550
- // Flat module list, optionally filtered by the search.
551
- const visibleGroups = React.useMemo(
552
- () => filterModuleGroups(groups ?? [], moduleQuery),
553
- [groups, moduleQuery],
554
- )
555
-
556
550
  // ---- capability edits ---------------------------------------------------
557
551
  const toggleCapability = React.useCallback((cap: string) => {
558
552
  setDraft((prev) => {
@@ -887,73 +881,104 @@ export function PermissionsManager({
887
881
  </CardContent>
888
882
  </Card>
889
883
 
890
- {/* Card: Módulos (flat list, mirrors the sidebar no folders) */}
884
+ {/* Card: Módulo a grouped combobox, same pattern as the role
885
+ selector above (compact; the long flat list felt heavy). */}
891
886
  <Card>
892
887
  <CardHeader>
893
- <CardTitle className="text-base">Módulos</CardTitle>
888
+ <CardTitle className="text-base">Módulo</CardTitle>
894
889
  <CardDescription>
895
890
  Elige el módulo cuyas acciones quieres configurar.
896
891
  </CardDescription>
897
892
  </CardHeader>
898
- <CardContent className="flex flex-col gap-3">
899
- <div className="relative">
900
- <Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
901
- <Input
902
- value={moduleQuery}
903
- onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
904
- setModuleQuery(e.target.value)
905
- }
906
- placeholder="Buscar módulo…"
907
- aria-label="Buscar módulo"
908
- className="pl-8"
909
- />
910
- </div>
911
-
912
- <div
913
- role="list"
914
- aria-label="Módulos"
915
- className="-mx-1 flex max-h-[460px] flex-col gap-0.5 overflow-y-auto px-1"
916
- >
917
- {visibleGroups.length === 0 ? (
918
- <p className="px-2 py-6 text-center text-sm text-muted-foreground">
919
- Sin módulos.
920
- </p>
921
- ) : (
922
- visibleGroups.map((group, gi) => (
923
- <div
924
- key={group.title || `__untitled_${gi}`}
925
- className="flex flex-col gap-0.5"
926
- >
927
- {group.title && (
928
- <div
929
- role="heading"
930
- aria-level={3}
931
- className={cn(
932
- 'px-2 pb-1 pt-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground',
933
- gi === 0 && 'pt-1',
934
- )}
935
- >
936
- {group.title}
937
- </div>
938
- )}
939
- {group.modules.map((mod) => (
940
- <ModuleRow
941
- key={mod.key}
942
- module={mod}
943
- active={mod.key === activeModuleKey}
944
- granted={
945
- draft
946
- ? grantedCountForModule(draft, mod)
947
- : 0
893
+ <CardContent>
894
+ <Popover open={moduleOpen} onOpenChange={setModuleOpen}>
895
+ <PopoverTrigger asChild>
896
+ <Button
897
+ variant="outline"
898
+ role="combobox"
899
+ aria-expanded={moduleOpen}
900
+ className="w-full justify-between font-normal"
901
+ >
902
+ <span className="flex min-w-0 items-center gap-2">
903
+ {activeModule && (
904
+ <DynamicIcon
905
+ name={
906
+ activeModule.icon ||
907
+ (activeModule.kind === 'screen'
908
+ ? 'Eye'
909
+ : 'Square')
948
910
  }
949
- total={mod.actions.length}
950
- onSelect={() => setActiveModuleKey(mod.key)}
911
+ className="h-4 w-4 shrink-0 opacity-70"
951
912
  />
913
+ )}
914
+ <span className="truncate">
915
+ {activeModule
916
+ ? activeModule.label
917
+ : 'Seleccionar módulo…'}
918
+ </span>
919
+ </span>
920
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
921
+ </Button>
922
+ </PopoverTrigger>
923
+ <PopoverContent
924
+ className="w-[var(--radix-popover-trigger-width)] min-w-[280px] p-0"
925
+ align="start"
926
+ >
927
+ <Command>
928
+ <CommandInput placeholder="Buscar módulo…" />
929
+ <CommandList className="max-h-[360px]">
930
+ <CommandEmpty>Sin módulos.</CommandEmpty>
931
+ {(groups ?? []).map((group, gi) => (
932
+ <CommandGroup
933
+ key={group.title || `__untitled_${gi}`}
934
+ heading={group.title || undefined}
935
+ >
936
+ {group.modules.map((mod) => (
937
+ <CommandItem
938
+ key={mod.key}
939
+ value={`${group.title} ${mod.label} ${mod.key}`}
940
+ onSelect={() => {
941
+ setActiveModuleKey(mod.key)
942
+ setModuleOpen(false)
943
+ }}
944
+ >
945
+ <DynamicIcon
946
+ name={
947
+ mod.icon ||
948
+ (mod.kind === 'screen'
949
+ ? 'Eye'
950
+ : 'Square')
951
+ }
952
+ className="mr-2 h-4 w-4 shrink-0 opacity-70"
953
+ />
954
+ <span className="truncate">
955
+ {mod.label}
956
+ </span>
957
+ {draft &&
958
+ grantedCountForModule(draft, mod) >
959
+ 0 && (
960
+ <Badge
961
+ variant="secondary"
962
+ className="ml-auto shrink-0 tabular-nums"
963
+ >
964
+ {grantedCountForModule(
965
+ draft,
966
+ mod,
967
+ )}
968
+ /{mod.actions.length}
969
+ </Badge>
970
+ )}
971
+ {mod.key === activeModuleKey && (
972
+ <Check className="ml-2 h-4 w-4 shrink-0" />
973
+ )}
974
+ </CommandItem>
975
+ ))}
976
+ </CommandGroup>
952
977
  ))}
953
- </div>
954
- ))
955
- )}
956
- </div>
978
+ </CommandList>
979
+ </Command>
980
+ </PopoverContent>
981
+ </Popover>
957
982
  </CardContent>
958
983
  </Card>
959
984
  </div>
@@ -0,0 +1,336 @@
1
+ // Built-in dashboard widget renderers, one per declarative `kind`. Each takes
2
+ // the resolved spec + computed WidgetData + the format context (locale/currency)
3
+ // and renders the body INSIDE a <WidgetCard>. recharts powers the charts; colors
4
+ // come from theme CSS vars (dark-mode safe), curves are smooth, axes/legends
5
+ // compact, tooltips on.
6
+
7
+ import * as React from 'react'
8
+ import {
9
+ Area,
10
+ AreaChart,
11
+ Bar,
12
+ BarChart,
13
+ CartesianGrid,
14
+ Cell,
15
+ Line,
16
+ LineChart,
17
+ Pie,
18
+ PieChart,
19
+ ResponsiveContainer,
20
+ Tooltip,
21
+ XAxis,
22
+ YAxis,
23
+ } from 'recharts'
24
+ import { cn } from '@asteby/metacore-ui/lib'
25
+ import type { DashboardWidgetSpec, WidgetData } from '../dashboard-types'
26
+ import { WidgetCard, DeltaChip, WidgetEmpty } from './widget-card'
27
+ import {
28
+ accentClasses,
29
+ paletteColor,
30
+ formatWidgetValue,
31
+ formatAxisTick,
32
+ formatDelta,
33
+ CHART_GRID,
34
+ CHART_MUTED,
35
+ type WidgetFormatCtx,
36
+ } from './widget-format'
37
+
38
+ export interface WidgetRenderProps {
39
+ spec: DashboardWidgetSpec
40
+ data?: WidgetData
41
+ locale?: string
42
+ currency?: string
43
+ /** i18n: per-widget empty fallback (already translated). */
44
+ emptyText: string
45
+ }
46
+
47
+ const fmtCtx = (
48
+ spec: DashboardWidgetSpec,
49
+ locale?: string,
50
+ currency?: string,
51
+ ): WidgetFormatCtx => ({ format: spec.format, locale, currency })
52
+
53
+ const hasSeries = (d?: WidgetData): d is WidgetData & { series: NonNullable<WidgetData['series']> } =>
54
+ Array.isArray(d?.series) && d!.series!.length > 0
55
+
56
+ const CHART_HEIGHT = 132
57
+
58
+ // Compact recharts tooltip styled with theme tokens.
59
+ function ChartTooltip({ ctx }: { ctx: WidgetFormatCtx }) {
60
+ return (
61
+ <Tooltip
62
+ cursor={{ fill: 'var(--muted, rgba(148,163,184,0.12))', opacity: 0.4 }}
63
+ contentStyle={{
64
+ background: 'var(--popover, #fff)',
65
+ border: '1px solid var(--border, #e2e8f0)',
66
+ borderRadius: 8,
67
+ fontSize: 12,
68
+ color: 'var(--popover-foreground, #0f172a)',
69
+ boxShadow: '0 4px 16px rgba(0,0,0,0.08)',
70
+ }}
71
+ labelStyle={{ color: 'var(--muted-foreground, #64748b)', marginBottom: 2 }}
72
+ formatter={(v: number) => formatWidgetValue(Number(v), ctx)}
73
+ />
74
+ )
75
+ }
76
+
77
+ // --- stat -----------------------------------------------------------------
78
+ export function StatWidget(p: WidgetRenderProps) {
79
+ const ctx = fmtCtx(p.spec, p.locale, p.currency)
80
+ const value = p.data?.value
81
+ const delta = p.data?.delta
82
+ const hasValue = typeof value === 'number' && !Number.isNaN(value)
83
+ return (
84
+ <WidgetCard
85
+ data-testid={`widget-${p.spec.key}`}
86
+ title={p.spec.title}
87
+ subtitle={p.spec.subtitle}
88
+ icon={p.spec.icon}
89
+ accent={p.spec.accent}
90
+ headerExtra={
91
+ typeof delta === 'number' ? (
92
+ <DeltaChip delta={delta} text={formatDelta(delta, p.locale)} />
93
+ ) : undefined
94
+ }
95
+ >
96
+ {hasValue ? (
97
+ <div className="text-3xl font-semibold tabular-nums tracking-tight text-foreground">
98
+ {formatWidgetValue(value!, ctx)}
99
+ </div>
100
+ ) : (
101
+ <WidgetEmpty message={p.emptyText} />
102
+ )}
103
+ </WidgetCard>
104
+ )
105
+ }
106
+
107
+ // --- bar ------------------------------------------------------------------
108
+ export function BarWidget(p: WidgetRenderProps) {
109
+ const ctx = fmtCtx(p.spec, p.locale, p.currency)
110
+ const a = accentClasses(p.spec.accent)
111
+ return (
112
+ <WidgetCard
113
+ data-testid={`widget-${p.spec.key}`}
114
+ title={p.spec.title}
115
+ subtitle={p.spec.subtitle}
116
+ icon={p.spec.icon}
117
+ accent={p.spec.accent}
118
+ >
119
+ {hasSeries(p.data) ? (
120
+ <ResponsiveContainer width="100%" height={CHART_HEIGHT}>
121
+ <BarChart data={p.data.series} margin={{ top: 4, right: 4, left: -16, bottom: 0 }}>
122
+ <CartesianGrid vertical={false} stroke={CHART_GRID} strokeDasharray="3 3" />
123
+ <XAxis
124
+ dataKey="label"
125
+ tick={{ fontSize: 10, fill: CHART_MUTED }}
126
+ tickLine={false}
127
+ axisLine={false}
128
+ interval="preserveStartEnd"
129
+ />
130
+ <YAxis
131
+ tick={{ fontSize: 10, fill: CHART_MUTED }}
132
+ tickLine={false}
133
+ axisLine={false}
134
+ width={44}
135
+ tickFormatter={(v: number) => formatAxisTick(v, ctx)}
136
+ />
137
+ <ChartTooltip ctx={ctx} />
138
+ <Bar dataKey="value" fill={a.chartVar} radius={[4, 4, 0, 0]} maxBarSize={40} />
139
+ </BarChart>
140
+ </ResponsiveContainer>
141
+ ) : (
142
+ <WidgetEmpty message={p.emptyText} />
143
+ )}
144
+ </WidgetCard>
145
+ )
146
+ }
147
+
148
+ // --- line / area (shared) -------------------------------------------------
149
+ function TimeSeriesWidget(p: WidgetRenderProps & { variant: 'line' | 'area' }) {
150
+ const ctx = fmtCtx(p.spec, p.locale, p.currency)
151
+ const a = accentClasses(p.spec.accent)
152
+ const gradId = `wg-grad-${p.spec.key}`
153
+ return (
154
+ <WidgetCard
155
+ data-testid={`widget-${p.spec.key}`}
156
+ title={p.spec.title}
157
+ subtitle={p.spec.subtitle}
158
+ icon={p.spec.icon}
159
+ accent={p.spec.accent}
160
+ >
161
+ {hasSeries(p.data) ? (
162
+ <ResponsiveContainer width="100%" height={CHART_HEIGHT}>
163
+ {p.variant === 'area' ? (
164
+ <AreaChart data={p.data.series} margin={{ top: 4, right: 6, left: -16, bottom: 0 }}>
165
+ <defs>
166
+ <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
167
+ <stop offset="0%" stopColor={a.chartVar} stopOpacity={0.35} />
168
+ <stop offset="100%" stopColor={a.chartVar} stopOpacity={0.02} />
169
+ </linearGradient>
170
+ </defs>
171
+ <CartesianGrid vertical={false} stroke={CHART_GRID} strokeDasharray="3 3" />
172
+ <XAxis dataKey="label" tick={{ fontSize: 10, fill: CHART_MUTED }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
173
+ <YAxis tick={{ fontSize: 10, fill: CHART_MUTED }} tickLine={false} axisLine={false} width={44} tickFormatter={(v: number) => formatAxisTick(v, ctx)} />
174
+ <ChartTooltip ctx={ctx} />
175
+ <Area type="monotone" dataKey="value" stroke={a.chartVar} strokeWidth={2} fill={`url(#${gradId})`} />
176
+ </AreaChart>
177
+ ) : (
178
+ <LineChart data={p.data.series} margin={{ top: 4, right: 6, left: -16, bottom: 0 }}>
179
+ <CartesianGrid vertical={false} stroke={CHART_GRID} strokeDasharray="3 3" />
180
+ <XAxis dataKey="label" tick={{ fontSize: 10, fill: CHART_MUTED }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
181
+ <YAxis tick={{ fontSize: 10, fill: CHART_MUTED }} tickLine={false} axisLine={false} width={44} tickFormatter={(v: number) => formatAxisTick(v, ctx)} />
182
+ <ChartTooltip ctx={ctx} />
183
+ <Line type="monotone" dataKey="value" stroke={a.chartVar} strokeWidth={2} dot={false} activeDot={{ r: 4 }} />
184
+ </LineChart>
185
+ )}
186
+ </ResponsiveContainer>
187
+ ) : (
188
+ <WidgetEmpty message={p.emptyText} />
189
+ )}
190
+ </WidgetCard>
191
+ )
192
+ }
193
+
194
+ export function LineWidget(p: WidgetRenderProps) {
195
+ return <TimeSeriesWidget {...p} variant="line" />
196
+ }
197
+ export function AreaWidget(p: WidgetRenderProps) {
198
+ return <TimeSeriesWidget {...p} variant="area" />
199
+ }
200
+
201
+ // --- pie / donut (shared) -------------------------------------------------
202
+ function CircularWidget(p: WidgetRenderProps & { variant: 'pie' | 'donut' }) {
203
+ const ctx = fmtCtx(p.spec, p.locale, p.currency)
204
+ return (
205
+ <WidgetCard
206
+ data-testid={`widget-${p.spec.key}`}
207
+ title={p.spec.title}
208
+ subtitle={p.spec.subtitle}
209
+ icon={p.spec.icon}
210
+ accent={p.spec.accent}
211
+ >
212
+ {hasSeries(p.data) ? (
213
+ <div className="flex items-center gap-3">
214
+ <ResponsiveContainer width="50%" height={CHART_HEIGHT}>
215
+ <PieChart>
216
+ <ChartTooltip ctx={ctx} />
217
+ <Pie
218
+ data={p.data.series}
219
+ dataKey="value"
220
+ nameKey="label"
221
+ innerRadius={p.variant === 'donut' ? 32 : 0}
222
+ outerRadius={56}
223
+ paddingAngle={p.variant === 'donut' ? 2 : 0}
224
+ stroke="var(--background, #fff)"
225
+ strokeWidth={2}
226
+ >
227
+ {p.data.series.map((_, i) => (
228
+ <Cell key={i} fill={paletteColor(i)} />
229
+ ))}
230
+ </Pie>
231
+ </PieChart>
232
+ </ResponsiveContainer>
233
+ <ul className="flex min-w-0 flex-1 flex-col gap-1.5">
234
+ {p.data.series.slice(0, 6).map((pt, i) => (
235
+ <li key={pt.key} className="flex items-center gap-2 text-xs">
236
+ <span className="size-2.5 shrink-0 rounded-sm" style={{ background: paletteColor(i) }} />
237
+ <span className="truncate text-muted-foreground">{pt.label}</span>
238
+ <span className="ml-auto shrink-0 font-medium tabular-nums text-foreground">
239
+ {formatWidgetValue(pt.value, ctx)}
240
+ </span>
241
+ </li>
242
+ ))}
243
+ </ul>
244
+ </div>
245
+ ) : (
246
+ <WidgetEmpty message={p.emptyText} />
247
+ )}
248
+ </WidgetCard>
249
+ )
250
+ }
251
+
252
+ export function PieWidget(p: WidgetRenderProps) {
253
+ return <CircularWidget {...p} variant="pie" />
254
+ }
255
+ export function DonutWidget(p: WidgetRenderProps) {
256
+ return <CircularWidget {...p} variant="donut" />
257
+ }
258
+
259
+ // --- list (top-N with proportion bars) ------------------------------------
260
+ export function ListWidget(p: WidgetRenderProps) {
261
+ const ctx = fmtCtx(p.spec, p.locale, p.currency)
262
+ const a = accentClasses(p.spec.accent)
263
+ const series = p.data?.series ?? []
264
+ const max = series.reduce((m, s) => Math.max(m, s.value), 0) || 1
265
+ return (
266
+ <WidgetCard
267
+ data-testid={`widget-${p.spec.key}`}
268
+ title={p.spec.title}
269
+ subtitle={p.spec.subtitle}
270
+ icon={p.spec.icon}
271
+ accent={p.spec.accent}
272
+ >
273
+ {series.length > 0 ? (
274
+ <ul className="flex flex-col gap-2.5">
275
+ {series.map((pt) => (
276
+ <li key={pt.key} className="flex flex-col gap-1">
277
+ <div className="flex items-baseline justify-between gap-2 text-xs">
278
+ <span className="truncate text-foreground">{pt.label}</span>
279
+ <span className="shrink-0 font-medium tabular-nums text-muted-foreground">
280
+ {formatWidgetValue(pt.value, ctx)}
281
+ </span>
282
+ </div>
283
+ <div className={cn('h-1.5 w-full overflow-hidden rounded-full', a.track)}>
284
+ <div
285
+ className={cn('h-full rounded-full transition-all', a.bar)}
286
+ style={{ width: `${Math.max(2, (pt.value / max) * 100)}%` }}
287
+ />
288
+ </div>
289
+ </li>
290
+ ))}
291
+ </ul>
292
+ ) : (
293
+ <WidgetEmpty message={p.emptyText} />
294
+ )}
295
+ </WidgetCard>
296
+ )
297
+ }
298
+
299
+ // --- progress -------------------------------------------------------------
300
+ // A scalar rendered as a proportion bar. When `format:'percent'` the value is
301
+ // a fraction in [0,1]; otherwise it's shown as the big number with a full bar.
302
+ export function ProgressWidget(p: WidgetRenderProps) {
303
+ const ctx = fmtCtx(p.spec, p.locale, p.currency)
304
+ const a = accentClasses(p.spec.accent)
305
+ const value = p.data?.value
306
+ const hasValue = typeof value === 'number' && !Number.isNaN(value)
307
+ const pct =
308
+ p.spec.format === 'percent'
309
+ ? Math.min(100, Math.max(0, (value ?? 0) * 100))
310
+ : 100
311
+ return (
312
+ <WidgetCard
313
+ data-testid={`widget-${p.spec.key}`}
314
+ title={p.spec.title}
315
+ subtitle={p.spec.subtitle}
316
+ icon={p.spec.icon}
317
+ accent={p.spec.accent}
318
+ >
319
+ {hasValue ? (
320
+ <div className="flex flex-col gap-2">
321
+ <div className="text-2xl font-semibold tabular-nums tracking-tight text-foreground">
322
+ {formatWidgetValue(value!, ctx)}
323
+ </div>
324
+ <div className={cn('h-2 w-full overflow-hidden rounded-full', a.track)}>
325
+ <div
326
+ className={cn('h-full rounded-full transition-all duration-500', a.bar)}
327
+ style={{ width: `${pct}%` }}
328
+ />
329
+ </div>
330
+ </div>
331
+ ) : (
332
+ <WidgetEmpty message={p.emptyText} />
333
+ )}
334
+ </WidgetCard>
335
+ )
336
+ }
@@ -0,0 +1,125 @@
1
+ // Shared "pro" card chrome for every dashboard widget. Subtle border, hover
2
+ // ring, an accent icon chip, title + optional subtitle, and a subtle mount
3
+ // motion done in pure CSS (runtime-react does not ship framer-motion). The
4
+ // chrome is identical for declarative renderers AND `kind:"custom"` federated
5
+ // widgets so they visually combine in the same grid.
6
+
7
+ import * as React from 'react'
8
+ import { Card, CardContent, CardHeader } from '@asteby/metacore-ui'
9
+ import { cn } from '@asteby/metacore-ui/lib'
10
+ import { DynamicIcon, isLucideIconName } from '../dynamic-icon'
11
+ import { accentClasses } from './widget-format'
12
+ import type { WidgetAccent } from '../dashboard-types'
13
+
14
+ export interface WidgetCardProps {
15
+ title: string
16
+ subtitle?: string
17
+ icon?: string
18
+ accent?: WidgetAccent
19
+ /** Right-aligned header slot (e.g. a delta chip). */
20
+ headerExtra?: React.ReactNode
21
+ /** Body content. */
22
+ children?: React.ReactNode
23
+ className?: string
24
+ /** Forwarded for testing/automation. */
25
+ 'data-testid'?: string
26
+ }
27
+
28
+ /**
29
+ * The card frame shared by all widgets. Keep the chrome here so a single style
30
+ * change propagates to every kind (and to federated custom widgets).
31
+ */
32
+ export function WidgetCard({
33
+ title,
34
+ subtitle,
35
+ icon,
36
+ accent,
37
+ headerExtra,
38
+ children,
39
+ className,
40
+ ...rest
41
+ }: WidgetCardProps) {
42
+ const a = accentClasses(accent)
43
+ const showIcon = icon && isLucideIconName(icon)
44
+ return (
45
+ <Card
46
+ {...rest}
47
+ className={cn(
48
+ // base: subtle border, ring on hover, gentle lift, mount fade-in
49
+ 'group/widget relative flex h-full flex-col overflow-hidden',
50
+ 'border-border/60 transition-all duration-200',
51
+ 'hover:border-border hover:ring-1 hover:ring-ring/30 hover:shadow-sm',
52
+ 'motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-500',
53
+ className,
54
+ )}
55
+ >
56
+ <CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0 pb-2">
57
+ <div className="flex min-w-0 items-start gap-3">
58
+ {showIcon && (
59
+ <span
60
+ className={cn(
61
+ 'flex size-9 shrink-0 items-center justify-center rounded-lg',
62
+ a.chip,
63
+ )}
64
+ >
65
+ <DynamicIcon name={icon!} className="size-[18px]" />
66
+ </span>
67
+ )}
68
+ <div className="min-w-0">
69
+ <div className="truncate text-sm font-medium leading-tight text-foreground">
70
+ {title}
71
+ </div>
72
+ {subtitle && (
73
+ <div className="mt-0.5 truncate text-xs text-muted-foreground">
74
+ {subtitle}
75
+ </div>
76
+ )}
77
+ </div>
78
+ </div>
79
+ {headerExtra && <div className="shrink-0">{headerExtra}</div>}
80
+ </CardHeader>
81
+ <CardContent className="flex flex-1 flex-col justify-end pt-1">
82
+ {children}
83
+ </CardContent>
84
+ </Card>
85
+ )
86
+ }
87
+
88
+ /** Delta chip: green up / red down, neutral on zero. `text` is preformatted. */
89
+ export function DeltaChip({ delta, text }: { delta: number; text: string }) {
90
+ const up = delta > 0
91
+ const down = delta < 0
92
+ return (
93
+ <span
94
+ className={cn(
95
+ 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium tabular-nums',
96
+ up && 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
97
+ down && 'bg-rose-500/10 text-rose-600 dark:text-rose-400',
98
+ !up && !down && 'bg-muted text-muted-foreground',
99
+ )}
100
+ >
101
+ {up && '▲'}
102
+ {down && '▼'}
103
+ {text}
104
+ </span>
105
+ )
106
+ }
107
+
108
+ /** Centered empty state inside a widget body (no data / missing). */
109
+ export function WidgetEmpty({ message }: { message: string }) {
110
+ return (
111
+ <div className="flex flex-1 items-center justify-center py-6 text-center text-xs text-muted-foreground">
112
+ {message}
113
+ </div>
114
+ )
115
+ }
116
+
117
+ /** Per-widget error state — isolated so a broken widget never tumbles the grid. */
118
+ export function WidgetError({ message }: { message: string }) {
119
+ return (
120
+ <div className="flex flex-1 items-center justify-center gap-2 py-6 text-center text-xs text-destructive">
121
+ <DynamicIcon name="TriangleAlert" className="size-4" />
122
+ <span>{message}</span>
123
+ </div>
124
+ )
125
+ }