@dashadmin/dash-reports 1.3.72 → 1.3.74-srcfree.1

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 (75) hide show
  1. package/dist/ReportFilters.d.ts +9 -0
  2. package/dist/ReportFiltersDrawer.d.ts +12 -0
  3. package/dist/ReportPage.d.ts +9 -0
  4. package/dist/ReportsDashboard.d.ts +7 -0
  5. package/dist/ReportsIndex.d.ts +7 -0
  6. package/dist/ReportsModule.d.ts +7 -0
  7. package/dist/TenantDashboard.d.ts +12 -0
  8. package/dist/adapters/mui/BarWidget.d.ts +3 -0
  9. package/dist/adapters/mui/DoughnutWidget.d.ts +3 -0
  10. package/dist/adapters/mui/FunnelWidget.d.ts +3 -0
  11. package/dist/adapters/mui/KpiWidget.d.ts +3 -0
  12. package/dist/adapters/mui/LineWidget.d.ts +3 -0
  13. package/dist/adapters/mui/PieWidget.d.ts +3 -0
  14. package/dist/adapters/mui/PreviewWidget.d.ts +10 -0
  15. package/dist/adapters/mui/TableWidget.d.ts +16 -0
  16. package/dist/adapters/mui/chartSetup.d.ts +2 -0
  17. package/dist/adapters/mui/chartTheme.d.ts +71 -0
  18. package/dist/adapters/mui/index.d.ts +12 -0
  19. package/dist/adapters/mui/palette.d.ts +9 -0
  20. package/dist/adapters/mui/useChartData.d.ts +14 -0
  21. package/dist/core/formatBucket.d.ts +4 -0
  22. package/dist/core/label.d.ts +3 -0
  23. package/dist/core/queryParams.d.ts +3 -0
  24. package/dist/core/types.d.ts +94 -0
  25. package/dist/core/useDashboard.d.ts +50 -0
  26. package/dist/core/useReportExport.d.ts +7 -0
  27. package/dist/core/useReportList.d.ts +7 -0
  28. package/dist/core/useReportPreview.d.ts +7 -0
  29. package/dist/core/useReportSeries.d.ts +8 -0
  30. package/dist/core/useReportSpec.d.ts +9 -0
  31. package/dist/core/useReportTable.d.ts +10 -0
  32. package/dist/core/visualizationLayout.d.ts +13 -0
  33. package/dist/core/visualizationRegistry.d.ts +14 -0
  34. package/dist/createReportsResource.d.ts +40 -0
  35. package/dist/i18n/en.d.ts +83 -0
  36. package/dist/i18n/es.d.ts +83 -0
  37. package/{src/index.tsx → dist/index.d.ts} +3 -31
  38. package/package.json +13 -10
  39. package/src/ReportFilters.tsx +0 -145
  40. package/src/ReportFiltersDrawer.tsx +0 -73
  41. package/src/ReportPage.tsx +0 -280
  42. package/src/ReportsDashboard.tsx +0 -61
  43. package/src/ReportsIndex.tsx +0 -113
  44. package/src/ReportsModule.tsx +0 -42
  45. package/src/TenantDashboard.tsx +0 -290
  46. package/src/adapters/mui/BarWidget.tsx +0 -66
  47. package/src/adapters/mui/DoughnutWidget.tsx +0 -112
  48. package/src/adapters/mui/FunnelWidget.tsx +0 -108
  49. package/src/adapters/mui/KpiWidget.tsx +0 -123
  50. package/src/adapters/mui/LineWidget.tsx +0 -70
  51. package/src/adapters/mui/PieWidget.tsx +0 -16
  52. package/src/adapters/mui/PreviewWidget.tsx +0 -123
  53. package/src/adapters/mui/TableWidget.tsx +0 -141
  54. package/src/adapters/mui/chartSetup.ts +0 -53
  55. package/src/adapters/mui/chartTheme.ts +0 -84
  56. package/src/adapters/mui/index.ts +0 -29
  57. package/src/adapters/mui/palette.ts +0 -85
  58. package/src/adapters/mui/useChartData.ts +0 -62
  59. package/src/core/formatBucket.ts +0 -73
  60. package/src/core/label.ts +0 -28
  61. package/src/core/queryParams.ts +0 -27
  62. package/src/core/types.ts +0 -157
  63. package/src/core/useDashboard.ts +0 -161
  64. package/src/core/useReportExport.ts +0 -68
  65. package/src/core/useReportList.ts +0 -51
  66. package/src/core/useReportPreview.ts +0 -68
  67. package/src/core/useReportSeries.ts +0 -59
  68. package/src/core/useReportSpec.ts +0 -79
  69. package/src/core/useReportTable.ts +0 -71
  70. package/src/core/visualizationLayout.ts +0 -34
  71. package/src/core/visualizationRegistry.ts +0 -39
  72. package/src/createReportsResource.tsx +0 -81
  73. package/src/i18n/en.ts +0 -95
  74. package/src/i18n/es.ts +0 -84
  75. /package/{src/i18n/index.ts → dist/i18n/index.d.ts} +0 -0
@@ -1,61 +0,0 @@
1
- import { Alert, Box, LinearProgress, Typography, useTheme } from '@mui/material';
2
- import { useDashboardTenants } from './core/useDashboard';
3
- import { TenantDashboard } from './TenantDashboard';
4
- import type { TranslateFn } from './core/types';
5
-
6
- export interface ReportsDashboardProps {
7
- translate: TranslateFn;
8
- title?: string;
9
- }
10
-
11
- /**
12
- * The home-dashboard stats block.
13
- *
14
- * Renders one TenantDashboard per tenant the caller manages — a TenancyAdmin
15
- * sees every restaurant, a tenant user sees exactly one (and then without a
16
- * redundant heading naming the only tenant there is).
17
- *
18
- * Always live: the window is the tenant's currently-open cash count period, and
19
- * an operator watching mid-service wants what has happened so far, not a figure
20
- * frozen earlier. Looking back at a closed period is a deliberate act via the
21
- * per-tenant period selector, and that view is served from the snapshot taken
22
- * at closing.
23
- */
24
- export function ReportsDashboard({ translate, title }: ReportsDashboardProps) {
25
- const theme = useTheme();
26
- const { tenants, loading, error } = useDashboardTenants();
27
-
28
- if (loading) return <LinearProgress />;
29
-
30
- if (error) {
31
- return (
32
- <Alert severity="warning" sx={{ my: 2 }}>
33
- {translate('dash_reports.errors.dashboard', { _: 'These stats could not be loaded.' })}
34
- </Alert>
35
- );
36
- }
37
-
38
- if (tenants.length === 0) return null;
39
-
40
- const single = tenants.length === 1;
41
-
42
- return (
43
- <Box sx={{ mt: 2 }}>
44
- <Typography variant="h5" sx={{ color: theme.palette.text.primary, mb: 2 }}>
45
- {title ?? translate('dash_reports.dashboard.title', { _: 'This period' })}
46
- </Typography>
47
-
48
- {tenants.map((entry) => (
49
- <TenantDashboard
50
- key={entry.tenant.id}
51
- tenantId={entry.tenant.id}
52
- tenantName={entry.tenant.name}
53
- translate={translate}
54
- showHeading={!single}
55
- />
56
- ))}
57
- </Box>
58
- );
59
- }
60
-
61
- export default ReportsDashboard;
@@ -1,113 +0,0 @@
1
- import { Alert, Box, Card, CardActionArea, CardContent, LinearProgress, Typography, useTheme } from '@mui/material';
2
- import { useReportList } from './core/useReportList';
3
- import { label } from './core/label';
4
- import { useReportPreview } from './core/useReportPreview';
5
- import { PreviewWidget } from './adapters/mui/PreviewWidget';
6
- import type { ReportListEntry, TranslateFn } from './core/types';
7
-
8
- export interface ReportsIndexProps {
9
- translate: TranslateFn;
10
- onOpen: (reportKey: string) => void;
11
- }
12
-
13
- /**
14
- * The report picker.
15
- *
16
- * Renders from the list endpoint alone — ONE request. The engine this replaces
17
- * fetched every report's full dataset up front to draw its landing page (a
18
- * monthly series plus one request per chart type, per report), so opening the
19
- * index cost 2N requests before anyone had chosen anything. Data is fetched
20
- * when a report is opened, not before.
21
- *
22
- * The list is already role-filtered server-side, so whatever arrives is what
23
- * this caller may see.
24
- */
25
- /**
26
- * One report's card, with its own preview request.
27
- *
28
- * A component per card rather than one batched fetch, so the previews load in
29
- * parallel and fail independently: a report whose preview errors shows a card
30
- * without a chart instead of taking the whole list down with it.
31
- */
32
- function ReportCard({
33
- report, translate, onOpen,
34
- }: { report: ReportListEntry; translate: TranslateFn; onOpen: (key: string) => void }) {
35
- const theme = useTheme();
36
- const { result, loading } = useReportPreview(
37
- report.key,
38
- report.previewGranularity,
39
- report.defaultRangeDays,
40
- );
41
-
42
- return (
43
- <Card variant="outlined">
44
- <CardActionArea onClick={() => onOpen(report.key)} sx={{ height: '100%' }}>
45
- <CardContent>
46
- <Typography variant="h6" sx={{ color: theme.palette.text.primary }}>
47
- {label(translate, report.title, report.titleText)}
48
- </Typography>
49
-
50
- <PreviewWidget
51
- result={result}
52
- loading={loading}
53
- type={report.defaultVisualization}
54
- translate={translate}
55
- metrics={report.metrics}
56
- />
57
-
58
- </CardContent>
59
- </CardActionArea>
60
- </Card>
61
- );
62
- }
63
-
64
- export function ReportsIndex({ translate, onOpen }: ReportsIndexProps) {
65
- const theme = useTheme();
66
- const { reports, loading, error } = useReportList();
67
-
68
- if (loading) return <LinearProgress />;
69
-
70
- if (error) {
71
- return (
72
- <Alert severity="error" sx={{ m: 2 }}>
73
- {translate('dash_reports.errors.list', { _: 'Reports could not be loaded.' })}
74
- </Alert>
75
- );
76
- }
77
-
78
- if (reports.length === 0) {
79
- return (
80
- <Box sx={{ p: 4 }}>
81
- <Typography variant="body1" sx={{ color: theme.palette.text.secondary }}>
82
- {translate('dash_reports.no_reports', { _: 'No reports are available for your account.' })}
83
- </Typography>
84
- </Box>
85
- );
86
- }
87
-
88
- return (
89
- <Box sx={{ p: { xs: 2, md: 3 } }}>
90
- <Typography variant="h5" sx={{ color: theme.palette.text.primary, mb: 3 }}>
91
- {translate('dash_reports.title', { _: 'Reports' })}
92
- </Typography>
93
-
94
- <Box
95
- sx={{
96
- display: 'grid',
97
- gap: 2,
98
- gridTemplateColumns: {
99
- xs: '1fr',
100
- sm: 'repeat(2, 1fr)',
101
- lg: 'repeat(3, 1fr)',
102
- },
103
- }}
104
- >
105
- {reports.map((report) => (
106
- <ReportCard key={report.key} report={report} translate={translate} onOpen={onOpen} />
107
- ))}
108
- </Box>
109
- </Box>
110
- );
111
- }
112
-
113
- export default ReportsIndex;
@@ -1,42 +0,0 @@
1
- import { Box, Button, Stack } from '@mui/material';
2
- import { useNavigate, useParams } from 'react-router-dom';
3
- import { ReportsIndex } from './ReportsIndex';
4
- import { ReportPage } from './ReportPage';
5
- import type { TranslateFn } from './core/types';
6
-
7
- export interface ReportsModuleProps {
8
- translate: TranslateFn;
9
- /** Base path this module is mounted at, e.g. '/reports'. */
10
- basePath: string;
11
- }
12
-
13
- /**
14
- * The whole reports module behind a single route.
15
- *
16
- * One resource, not one per report: the set of reports is decided by the
17
- * BACKEND registry, so hardcoding a resource per report in the frontend would
18
- * mean a frontend change (and a redeploy) every time a domain registers one —
19
- * exactly the coupling the spec-driven design exists to remove. A domain that
20
- * registers a sixth report gets it here with no frontend change at all.
21
- */
22
- export function ReportsModule({ translate, basePath }: ReportsModuleProps) {
23
- const { reportKey } = useParams();
24
- const navigate = useNavigate();
25
-
26
- if (!reportKey) {
27
- return <ReportsIndex translate={translate} onOpen={(key) => navigate(`${basePath}/${key}`)} />;
28
- }
29
-
30
- return (
31
- <Box>
32
- <Stack direction="row" sx={{ px: { xs: 2, md: 3 }, pt: 2 }}>
33
- <Button size="small" onClick={() => navigate(basePath)}>
34
- ← {translate('dash_reports.back_to_list', { _: 'All reports' })}
35
- </Button>
36
- </Stack>
37
- <ReportPage reportKey={reportKey} translate={translate} />
38
- </Box>
39
- );
40
- }
41
-
42
- export default ReportsModule;
@@ -1,290 +0,0 @@
1
- import { useState } from 'react';
2
- import {
3
- Alert, Box, Card, CardContent, Chip, FormControl, LinearProgress, MenuItem,
4
- Select, Stack, Typography, useTheme,
5
- } from '@mui/material';
6
- import { useTenantDashboard, useTenantPeriods } from './core/useDashboard';
7
- import { getVisualization } from './core/visualizationRegistry';
8
- import { label as renderLabel } from './core/label';
9
- import { visualizationBoxSx } from './core/visualizationLayout';
10
- import type { DashboardWidget } from './core/useDashboard';
11
- import type { ReportSpec, TranslateFn, VisualizationType } from './core/types';
12
- import './adapters/mui';
13
-
14
- export interface TenantDashboardProps {
15
- tenantId: string;
16
- tenantName?: string;
17
- translate: TranslateFn;
18
- /** Hide the tenant heading when only one tenant is ever shown. */
19
- showHeading?: boolean;
20
- /**
21
- * Pin to one period instead of letting the viewer choose.
22
- *
23
- * Used by the cash count show view, which is already about one specific
24
- * period — offering a picker there would let someone navigate away from the
25
- * record they are looking at without the surrounding page changing.
26
- */
27
- period?: string;
28
- /** Hide the period selector (implied when `period` is given). */
29
- hidePeriodPicker?: boolean;
30
- }
31
-
32
- /**
33
- * Tile widths as a 12-column span, chosen so rows tile exactly.
34
- *
35
- * The previous set (sm 3 / md 4) left a 1-column gap on the first row and a
36
- * 3-column gap on the second, which is why the dashboard looked ragged.
37
- */
38
- const SPAN: Record<string, number> = { sm: 4, md: 6, lg: 8, full: 12 };
39
-
40
- /**
41
- * The same, against the 6-column container breakpoint.
42
- *
43
- * `sm` is 2 rather than 3 so the three distribution tiles still tile one exact
44
- * row here; at 3 they wrapped 2+1 and left an orphan.
45
- */
46
- const SPAN_SM: Record<string, number> = { sm: 2, md: 6, lg: 6, full: 6 };
47
-
48
- /**
49
- * One tenant's period dashboard.
50
- *
51
- * Rendered once per tenant: a TenancyAdmin gets one of these per restaurant,
52
- * a tenant user gets exactly one. Each fetches its own data, so a slow or
53
- * failing tenant costs one panel rather than the page.
54
- *
55
- * The period selector defaults to the open cash count and can look back at
56
- * closed ones. That distinction is surfaced, not hidden — a closed period is
57
- * served from the snapshot taken when it was closed, and saying so is the
58
- * difference between "these numbers are final" and "these are still moving".
59
- */
60
- export function TenantDashboard({
61
- tenantId,
62
- tenantName,
63
- translate,
64
- showHeading = true,
65
- period: pinnedPeriod,
66
- hidePeriodPicker,
67
- }: TenantDashboardProps) {
68
- const theme = useTheme();
69
- const [selectedPeriod, setPeriod] = useState('current');
70
-
71
- const period = pinnedPeriod ?? selectedPeriod;
72
- const showPicker = !pinnedPeriod && !hidePeriodPicker;
73
-
74
- const { data, loading, error } = useTenantDashboard(tenantId, period);
75
- // Not fetched at all when the picker is hidden — a request whose only purpose
76
- // is to fill a control nobody will see.
77
- const { periods } = useTenantPeriods(showPicker ? tenantId : null);
78
-
79
- return (
80
- <Box sx={{ mb: 4 }}>
81
- {/* Title, period and picker share a card of their own.
82
- Loose on the page they read as page furniture rather than as the
83
- controls governing the tiles below; boxing them makes the relationship
84
- explicit and gives the header the same visual weight as the widgets. */}
85
- <Card variant="outlined" sx={{ mb: 2 }}>
86
- <Stack
87
- direction="row"
88
- sx={{
89
- alignItems: 'center',
90
- justifyContent: 'space-between',
91
- flexWrap: 'wrap',
92
- gap: 1.5,
93
- px: 2,
94
- py: 1.5,
95
- }}
96
- >
97
- {showHeading && (
98
- <Typography variant="h6" sx={{ color: theme.palette.text.primary }}>
99
- {tenantName ?? tenantId}
100
- </Typography>
101
- )}
102
-
103
- <Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
104
- {data?.period && (
105
- <Chip
106
- size="small"
107
- variant="outlined"
108
- label={`${data.period.start.slice(0, 10)} → ${data.period.end.slice(0, 10)}`}
109
- />
110
- )}
111
-
112
- {/* A closed period's figures are frozen; an open one is still moving.
113
- Conflating the two would let someone quote a number that changes
114
- under them ten minutes later. */}
115
- {data && (
116
- <Chip
117
- size="small"
118
- color={data.period?.isOpen ? 'warning' : 'default'}
119
- label={translate(
120
- data.period?.isOpen ? 'dash_reports.period.open' : 'dash_reports.period.closed',
121
- { _: data.period?.isOpen ? 'In progress' : 'Closed' },
122
- )}
123
- />
124
- )}
125
-
126
- {showPicker && periods.length > 1 && (
127
- <FormControl size="small" sx={{ minWidth: 190 }}>
128
- <Select value={selectedPeriod} onChange={(e) => setPeriod(String(e.target.value))}>
129
- {periods.map((p) => (
130
- <MenuItem key={p.id} value={p.id}>
131
- {p.id === 'current'
132
- ? translate('dash_reports.period.current', { _: 'Current period' })
133
- : p.label}
134
- </MenuItem>
135
- ))}
136
- </Select>
137
- </FormControl>
138
- )}
139
- </Stack>
140
- </Stack>
141
- </Card>
142
-
143
- {loading && <LinearProgress />}
144
-
145
- {error && (
146
- <Alert severity="warning">
147
- {translate('dash_reports.errors.dashboard', { _: 'These stats could not be loaded.' })}
148
- </Alert>
149
- )}
150
-
151
- {data && (
152
- <Box
153
- sx={{
154
- // Container queries, not viewport breakpoints. This block is
155
- // rendered both full-width and inside a 50% column, and viewport
156
- // breakpoints cannot tell those apart — a wide window would lay out
157
- // twelve columns inside a half-width box and shred the tiles.
158
- containerType: 'inline-size',
159
- display: 'grid',
160
- gap: 2,
161
- gridTemplateColumns: 'repeat(12, 1fr)',
162
- '@container (max-width: 900px)': { gridTemplateColumns: 'repeat(6, 1fr)' },
163
- '@container (max-width: 560px)': { gridTemplateColumns: '1fr' },
164
- // Without this, every tile stretches to the tallest in its row —
165
- // a short KPI tile next to a seven-stage funnel became a mostly
166
- // empty box the height of the funnel.
167
- alignItems: 'start',
168
- }}
169
- >
170
- {data.widgets.map((widget) => (
171
- <WidgetTile key={widget.key} widget={widget} translate={translate} />
172
- ))}
173
- </Box>
174
- )}
175
- </Box>
176
- );
177
- }
178
-
179
- function WidgetTile({ widget, translate }: { widget: DashboardWidget; translate: TranslateFn }) {
180
- const theme = useTheme();
181
- const span = SPAN[widget.size ?? 'md'] ?? 4;
182
-
183
- const Visualization = widget.visualization ? getVisualization(widget.visualization as VisualizationType) : null;
184
-
185
- return (
186
- <Card
187
- variant="outlined"
188
- sx={{
189
- gridColumn: `span ${span}`,
190
- // Matches the container breakpoints on the grid above.
191
- '@container (max-width: 900px)': {
192
- gridColumn: `span ${SPAN_SM[widget.size ?? 'md'] ?? 6}`,
193
- },
194
- '@container (max-width: 560px)': { gridColumn: '1 / -1' },
195
- }}
196
- >
197
- <CardContent>
198
- <Typography variant="subtitle2" sx={{ color: theme.palette.text.secondary, mb: 1 }}>
199
- {renderLabel(translate, widget.title, widget.titleText)}
200
- </Typography>
201
-
202
- {widget.error ? (
203
- // One tile's failure is one tile, never the dashboard.
204
- <Typography variant="caption" sx={{ color: theme.palette.text.secondary }}>
205
- {translate('dash_reports.errors.widget', { _: 'Unavailable' })}
206
- </Typography>
207
- ) : widget.result && Visualization && hasData(widget) ? (
208
- // Canvas widgets get a definite height; Kpi/Funnel/Table size to
209
- // their content. Without the bound, Chart.js grows the tile forever.
210
- <Box sx={visualizationBoxSx(widget.visualization, 220)}>
211
- <Visualization
212
- result={widget.result}
213
- spec={specFor(widget)}
214
- translate={translate}
215
- />
216
- </Box>
217
- ) : (
218
- <Box sx={{ py: 3, display: 'grid', placeItems: 'center' }}>
219
- <Typography variant="caption" sx={{ color: theme.palette.text.secondary }}>
220
- {translate('dash_reports.no_data', { _: 'No data for this range.' })}
221
- </Typography>
222
- </Box>
223
- )}
224
- </CardContent>
225
- </Card>
226
- );
227
- }
228
-
229
- /**
230
- * The minimal spec a widget renderer needs.
231
- *
232
- * A tile does not fetch the report's full spec — that would be a second request
233
- * per tile for information the series response already carries. The renderers
234
- * only read `metrics` (for Kpi and Funnel labels) and `dimensions` (for stable
235
- * colour slots), so this supplies exactly that.
236
- */
237
- /**
238
- * Whether a tile has anything worth drawing.
239
- *
240
- * A doughnut whose every slice is zero renders as an empty ring with a legend
241
- * of nothings — strictly worse than saying "no data", because it looks like a
242
- * chart that failed rather than a period in which nothing happened.
243
- */
244
- function hasData(widget: DashboardWidget): boolean {
245
- const result = widget.result;
246
- if (!result) return false;
247
-
248
- const anySeries = (result.series ?? []).some((s) =>
249
- (s.data ?? []).some((v) => v !== null && v !== undefined && v !== 0),
250
- );
251
-
252
- const anyTotal = Object.values(result.totals ?? {}).some(
253
- (v) => v !== null && v !== undefined && v !== 0,
254
- );
255
-
256
- return anySeries || anyTotal;
257
- }
258
-
259
- function specFor(widget: DashboardWidget): ReportSpec {
260
- // The backend ships the real definitions; the series fallback exists only
261
- // for a pivoted tile, whose series are dimension values rather than metrics.
262
- // Falling back for a metric tile would lose `decimal` and format an average
263
- // of 0.26 minutes as "0".
264
- const metrics = widget.result?.metrics
265
- ?? (widget.result?.series ?? []).map((s) => ({
266
- key: s.key,
267
- label: s.label,
268
- labelText: s.labelText,
269
- kind: 'count' as const,
270
- decimal: false,
271
- }));
272
-
273
- return {
274
- key: widget.reportKey,
275
- title: widget.title,
276
- granularities: ['total'],
277
- defaultGranularity: 'total',
278
- defaultVisualization: (widget.visualization ?? 'Bar') as VisualizationType,
279
- previewGranularity: 'total',
280
- timezone: widget.result?.timezone ?? 'UTC',
281
- dimensions: [],
282
- metrics,
283
- visualizations: [],
284
- filters: {},
285
- exporters: [],
286
- defaultRangeDays: 30,
287
- };
288
- }
289
-
290
- export default TenantDashboard;
@@ -1,66 +0,0 @@
1
- import { Bar } from 'react-chartjs-2';
2
- import { ensureChartsRegistered } from './chartSetup';
3
- import { baseChartOptions } from './chartTheme';
4
- import { useChartData } from './useChartData';
5
- import type { VisualizationProps } from '../../core/visualizationRegistry';
6
-
7
- ensureChartsRegistered();
8
-
9
- /**
10
- * Magnitude per bucket.
11
- *
12
- * Bars are thin with a small rounded cap on the data end only — the baseline
13
- * end stays square so the bar reads as anchored rather than floating. Grouped
14
- * rather than stacked by default: a stack answers "what is the total", a group
15
- * answers "how do these compare", and comparison is what a pivoted report is
16
- * for. Adjacent fills carry a surface-coloured gap instead of a stroke, so
17
- * they separate without a border drawn around every mark.
18
- */
19
- export function BarWidget(props: VisualizationProps) {
20
- const { chrome, datasets, labels, longLabels } = useChartData(props);
21
- const stacked = props.options?.stacked === true;
22
-
23
- const data = {
24
- labels,
25
- datasets: datasets.map((d) => ({
26
- label: d.label,
27
- data: d.data,
28
- backgroundColor: d.color,
29
- borderRadius: { topLeft: 4, topRight: 4, bottomLeft: 0, bottomRight: 0 },
30
- borderSkipped: 'bottom' as const,
31
- borderWidth: 0,
32
- maxBarThickness: 28,
33
- categoryPercentage: 0.7,
34
- barPercentage: 0.9,
35
- })),
36
- };
37
-
38
- const base = baseChartOptions(chrome, datasets.length > 1);
39
-
40
- return (
41
- <Bar
42
- data={data}
43
- height={props.height ?? 320}
44
- options={{
45
- ...base,
46
- scales: {
47
- ...base.scales,
48
- x: { ...base.scales.x, stacked },
49
- y: { ...base.scales.y, stacked },
50
- },
51
- plugins: {
52
- ...base.plugins,
53
- tooltip: {
54
- ...base.plugins.tooltip,
55
- callbacks: {
56
- title: (items: Array<{ dataIndex: number }>) =>
57
- longLabels[items[0]?.dataIndex] ?? '',
58
- },
59
- },
60
- },
61
- }}
62
- />
63
- );
64
- }
65
-
66
- export default BarWidget;
@@ -1,112 +0,0 @@
1
- import { useMemo } from 'react';
2
- import { useTheme } from '@mui/material';
3
- import { Doughnut } from 'react-chartjs-2';
4
- import { ensureChartsRegistered } from './chartSetup';
5
- import { chartChrome } from './chartTheme';
6
- import { label as renderLabel } from '../../core/label';
7
- import { buildSlotMap, colorForSlot, MAX_SERIES } from './palette';
8
- import type { VisualizationProps } from '../../core/visualizationRegistry';
9
-
10
- ensureChartsRegistered();
11
-
12
- /**
13
- * Composition across the whole window.
14
- *
15
- * Collapses the timeline: each series becomes its window total, so this answers
16
- * "what share" rather than "when". Only meaningful for a part-of-whole reading,
17
- * which is why the backend offers it on some reports and not others.
18
- *
19
- * A 2px surface-coloured gap between arcs separates neighbours without drawing
20
- * a stroke around every slice.
21
- */
22
- export function DoughnutWidget({ result, spec, translate, height }: VisualizationProps) {
23
- const theme = useTheme();
24
-
25
- const { labels, values, colors, chrome, total } = useMemo(() => {
26
- const c = chartChrome(theme);
27
- const pivotKey = result.meta?.pivot ?? undefined;
28
- const slots = buildSlotMap(spec, pivotKey, result.series.map((s) => s.key));
29
-
30
- const totals = result.series.map((series) => ({
31
- key: series.key,
32
- label: renderLabel(translate, series.label, series.labelText),
33
- slot: slots.get(series.key) ?? 0,
34
- value: series.data.reduce<number>((sum, v) => sum + (v ?? 0), 0),
35
- }));
36
-
37
- // Fold the tail rather than inventing a ninth hue.
38
- const kept = totals.filter((t) => t.slot < MAX_SERIES);
39
- const folded = totals.filter((t) => t.slot >= MAX_SERIES);
40
-
41
- if (folded.length > 0) {
42
- kept.push({
43
- key: '__other__',
44
- label: translate('dash_reports.other', { _: 'Other', count: folded.length }),
45
- slot: MAX_SERIES,
46
- value: folded.reduce((sum, t) => sum + t.value, 0),
47
- });
48
- }
49
-
50
- return {
51
- chrome: c,
52
- labels: kept.map((t) => t.label),
53
- values: kept.map((t) => t.value),
54
- colors: kept.map((t) => colorForSlot(t.slot, c.dark)),
55
- total: kept.reduce((sum, t) => sum + t.value, 0),
56
- };
57
- }, [result, spec, translate, theme]);
58
-
59
- return (
60
- <Doughnut
61
- height={height ?? 320}
62
- data={{
63
- labels,
64
- datasets: [{
65
- data: values,
66
- backgroundColor: colors,
67
- borderColor: chrome.surface,
68
- borderWidth: 2,
69
- }],
70
- }}
71
- options={{
72
- responsive: true,
73
- maintainAspectRatio: false,
74
- cutout: '58%',
75
- plugins: {
76
- legend: {
77
- display: true,
78
- position: 'bottom' as const,
79
- labels: {
80
- color: chrome.axisText,
81
- usePointStyle: true,
82
- pointStyle: 'circle' as const,
83
- boxWidth: 8,
84
- boxHeight: 8,
85
- padding: 16,
86
- },
87
- },
88
- tooltip: {
89
- backgroundColor: chrome.tooltipBg,
90
- titleColor: chrome.tooltipText,
91
- bodyColor: chrome.tooltipText,
92
- borderColor: chrome.grid,
93
- borderWidth: 1,
94
- padding: 10,
95
- cornerRadius: 6,
96
- usePointStyle: true,
97
- callbacks: {
98
- // The share is the point of this chart, so the tooltip states it
99
- // rather than leaving the reader to estimate an angle.
100
- label: (item: { label?: string; parsed: number }) => {
101
- const share = total > 0 ? Math.round((item.parsed / total) * 1000) / 10 : 0;
102
- return `${item.label}: ${item.parsed.toLocaleString()} (${share}%)`;
103
- },
104
- },
105
- },
106
- },
107
- }}
108
- />
109
- );
110
- }
111
-
112
- export default DoughnutWidget;