@asteby/metacore-runtime-react 18.16.1 → 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.
- package/CHANGELOG.md +24 -0
- package/dist/dashboard-grid.d.ts +6 -0
- package/dist/dashboard-grid.d.ts.map +1 -0
- package/dist/dashboard-grid.js +127 -0
- package/dist/dashboard-types.d.ts +130 -0
- package/dist/dashboard-types.d.ts.map +1 -0
- package/dist/dashboard-types.js +7 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/widgets/renderers.d.ts +19 -0
- package/dist/widgets/renderers.d.ts.map +1 -0
- package/dist/widgets/renderers.js +78 -0
- package/dist/widgets/widget-card.d.ts +34 -0
- package/dist/widgets/widget-card.d.ts.map +1 -0
- package/dist/widgets/widget-card.js +30 -0
- package/dist/widgets/widget-format.d.ts +42 -0
- package/dist/widgets/widget-format.d.ts.map +1 -0
- package/dist/widgets/widget-format.js +138 -0
- package/dist/widgets/widget-renderer.d.ts +27 -0
- package/dist/widgets/widget-renderer.d.ts.map +1 -0
- package/dist/widgets/widget-renderer.js +66 -0
- package/package.json +2 -1
- package/src/__tests__/dashboard-grid.test.tsx +222 -0
- package/src/dashboard-grid.tsx +206 -0
- package/src/dashboard-types.ts +178 -0
- package/src/index.ts +56 -0
- package/src/widgets/renderers.tsx +336 -0
- package/src/widgets/widget-card.tsx +125 -0
- package/src/widgets/widget-format.ts +181 -0
- package/src/widgets/widget-renderer.tsx +181 -0
|
@@ -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'
|
|
@@ -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
|
+
}
|