@modern-admin/react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/action-guard.d.ts +13 -0
- package/dist/action-guard.d.ts.map +1 -0
- package/dist/action-guard.js +15 -0
- package/dist/action-guard.js.map +1 -0
- package/dist/action-menu.d.ts +17 -0
- package/dist/action-menu.d.ts.map +1 -0
- package/dist/action-menu.jsx +80 -0
- package/dist/action-menu.jsx.map +1 -0
- package/dist/admin-app.d.ts +23 -0
- package/dist/admin-app.d.ts.map +1 -0
- package/dist/admin-app.jsx +407 -0
- package/dist/admin-app.jsx.map +1 -0
- package/dist/admin-router.d.ts +29 -0
- package/dist/admin-router.d.ts.map +1 -0
- package/dist/admin-router.jsx +215 -0
- package/dist/admin-router.jsx.map +1 -0
- package/dist/breadcrumbs.d.ts +17 -0
- package/dist/breadcrumbs.d.ts.map +1 -0
- package/dist/breadcrumbs.jsx +40 -0
- package/dist/breadcrumbs.jsx.map +1 -0
- package/dist/client.d.ts +526 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +582 -0
- package/dist/client.js.map +1 -0
- package/dist/component-loader.d.ts +10 -0
- package/dist/component-loader.d.ts.map +1 -0
- package/dist/component-loader.js +23 -0
- package/dist/component-loader.js.map +1 -0
- package/dist/components/ai-assistant-widget.d.ts +3 -0
- package/dist/components/ai-assistant-widget.d.ts.map +1 -0
- package/dist/components/ai-assistant-widget.jsx +390 -0
- package/dist/components/ai-assistant-widget.jsx.map +1 -0
- package/dist/components/ai-fill-dialog.d.ts +9 -0
- package/dist/components/ai-fill-dialog.d.ts.map +1 -0
- package/dist/components/ai-fill-dialog.jsx +105 -0
- package/dist/components/ai-fill-dialog.jsx.map +1 -0
- package/dist/components/chart-builder-dialog.d.ts +10 -0
- package/dist/components/chart-builder-dialog.d.ts.map +1 -0
- package/dist/components/chart-builder-dialog.jsx +433 -0
- package/dist/components/chart-builder-dialog.jsx.map +1 -0
- package/dist/components/chart-widget.d.ts +12 -0
- package/dist/components/chart-widget.d.ts.map +1 -0
- package/dist/components/chart-widget.jsx +365 -0
- package/dist/components/chart-widget.jsx.map +1 -0
- package/dist/components/global-search-dialog.d.ts +7 -0
- package/dist/components/global-search-dialog.d.ts.map +1 -0
- package/dist/components/global-search-dialog.jsx +187 -0
- package/dist/components/global-search-dialog.jsx.map +1 -0
- package/dist/components/group-settings-dialog.d.ts +13 -0
- package/dist/components/group-settings-dialog.d.ts.map +1 -0
- package/dist/components/group-settings-dialog.jsx +53 -0
- package/dist/components/group-settings-dialog.jsx.map +1 -0
- package/dist/components/move-chart-dialog.d.ts +18 -0
- package/dist/components/move-chart-dialog.d.ts.map +1 -0
- package/dist/components/move-chart-dialog.jsx +68 -0
- package/dist/components/move-chart-dialog.jsx.map +1 -0
- package/dist/components/reference-multi-table-dialog.d.ts +12 -0
- package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
- package/dist/components/reference-multi-table-dialog.jsx +126 -0
- package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
- package/dist/components/related-records-tabs.d.ts +8 -0
- package/dist/components/related-records-tabs.d.ts.map +1 -0
- package/dist/components/related-records-tabs.jsx +75 -0
- package/dist/components/related-records-tabs.jsx.map +1 -0
- package/dist/components/revisions-button.d.ts +7 -0
- package/dist/components/revisions-button.d.ts.map +1 -0
- package/dist/components/revisions-button.jsx +152 -0
- package/dist/components/revisions-button.jsx.map +1 -0
- package/dist/components/wizard-form.d.ts +43 -0
- package/dist/components/wizard-form.d.ts.map +1 -0
- package/dist/components/wizard-form.jsx +136 -0
- package/dist/components/wizard-form.jsx.map +1 -0
- package/dist/dashboard/time-series.d.ts +20 -0
- package/dist/dashboard/time-series.d.ts.map +1 -0
- package/dist/dashboard/time-series.js +108 -0
- package/dist/dashboard/time-series.js.map +1 -0
- package/dist/dialogs.d.ts +35 -0
- package/dist/dialogs.d.ts.map +1 -0
- package/dist/dialogs.jsx +152 -0
- package/dist/dialogs.jsx.map +1 -0
- package/dist/export.d.ts +39 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +114 -0
- package/dist/export.js.map +1 -0
- package/dist/extension-registry.d.ts +122 -0
- package/dist/extension-registry.d.ts.map +1 -0
- package/dist/extension-registry.js +93 -0
- package/dist/extension-registry.js.map +1 -0
- package/dist/header-controls.d.ts +4 -0
- package/dist/header-controls.d.ts.map +1 -0
- package/dist/header-controls.jsx +70 -0
- package/dist/header-controls.jsx.map +1 -0
- package/dist/hooks.d.ts +104 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +374 -0
- package/dist/hooks.js.map +1 -0
- package/dist/hotkey-help.d.ts +3 -0
- package/dist/hotkey-help.d.ts.map +1 -0
- package/dist/hotkey-help.jsx +32 -0
- package/dist/hotkey-help.jsx.map +1 -0
- package/dist/hotkey-registry.d.ts +18 -0
- package/dist/hotkey-registry.d.ts.map +1 -0
- package/dist/hotkey-registry.jsx +34 -0
- package/dist/hotkey-registry.jsx.map +1 -0
- package/dist/i18n.d.ts +74 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.jsx +127 -0
- package/dist/i18n.jsx.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/notify.d.ts +41 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.jsx +58 -0
- package/dist/notify.jsx.map +1 -0
- package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
- package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
- package/dist/pages/ai-assistant-settings-section.jsx +126 -0
- package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
- package/dist/pages/audit-log-page.d.ts +3 -0
- package/dist/pages/audit-log-page.d.ts.map +1 -0
- package/dist/pages/audit-log-page.jsx +354 -0
- package/dist/pages/audit-log-page.jsx.map +1 -0
- package/dist/pages/edit-page.d.ts +7 -0
- package/dist/pages/edit-page.d.ts.map +1 -0
- package/dist/pages/edit-page.jsx +614 -0
- package/dist/pages/edit-page.jsx.map +1 -0
- package/dist/pages/export-dialog.d.ts +11 -0
- package/dist/pages/export-dialog.d.ts.map +1 -0
- package/dist/pages/export-dialog.jsx +102 -0
- package/dist/pages/export-dialog.jsx.map +1 -0
- package/dist/pages/home-page.d.ts +3 -0
- package/dist/pages/home-page.d.ts.map +1 -0
- package/dist/pages/home-page.jsx +211 -0
- package/dist/pages/home-page.jsx.map +1 -0
- package/dist/pages/list-page.d.ts +42 -0
- package/dist/pages/list-page.d.ts.map +1 -0
- package/dist/pages/list-page.jsx +1596 -0
- package/dist/pages/list-page.jsx.map +1 -0
- package/dist/pages/login-page.d.ts +11 -0
- package/dist/pages/login-page.d.ts.map +1 -0
- package/dist/pages/login-page.jsx +157 -0
- package/dist/pages/login-page.jsx.map +1 -0
- package/dist/pages/settings-page.d.ts +5 -0
- package/dist/pages/settings-page.d.ts.map +1 -0
- package/dist/pages/settings-page.jsx +787 -0
- package/dist/pages/settings-page.jsx.map +1 -0
- package/dist/pages/settings-shared.d.ts +51 -0
- package/dist/pages/settings-shared.d.ts.map +1 -0
- package/dist/pages/settings-shared.jsx +66 -0
- package/dist/pages/settings-shared.jsx.map +1 -0
- package/dist/pages/show-page.d.ts +7 -0
- package/dist/pages/show-page.d.ts.map +1 -0
- package/dist/pages/show-page.jsx +147 -0
- package/dist/pages/show-page.jsx.map +1 -0
- package/dist/pages/wizard-create-page.d.ts +14 -0
- package/dist/pages/wizard-create-page.d.ts.map +1 -0
- package/dist/pages/wizard-create-page.jsx +106 -0
- package/dist/pages/wizard-create-page.jsx.map +1 -0
- package/dist/property-renderer.d.ts +8 -0
- package/dist/property-renderer.d.ts.map +1 -0
- package/dist/property-renderer.jsx +690 -0
- package/dist/property-renderer.jsx.map +1 -0
- package/dist/provider.d.ts +20 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.jsx +32 -0
- package/dist/provider.jsx.map +1 -0
- package/dist/realtime.d.ts +22 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +38 -0
- package/dist/realtime.js.map +1 -0
- package/dist/reference.d.ts +52 -0
- package/dist/reference.d.ts.map +1 -0
- package/dist/reference.jsx +224 -0
- package/dist/reference.jsx.map +1 -0
- package/dist/relations.d.ts +11 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/relations.js +36 -0
- package/dist/relations.js.map +1 -0
- package/dist/router.d.ts +82 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.jsx +187 -0
- package/dist/router.jsx.map +1 -0
- package/dist/show-when.d.ts +7 -0
- package/dist/show-when.d.ts.map +1 -0
- package/dist/show-when.js +77 -0
- package/dist/show-when.js.map +1 -0
- package/dist/types.d.ts +194 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/dist/use-dashboard-charts.d.ts +93 -0
- package/dist/use-dashboard-charts.d.ts.map +1 -0
- package/dist/use-dashboard-charts.js +263 -0
- package/dist/use-dashboard-charts.js.map +1 -0
- package/dist/use-hotkey.d.ts +17 -0
- package/dist/use-hotkey.d.ts.map +1 -0
- package/dist/use-hotkey.js +103 -0
- package/dist/use-hotkey.js.map +1 -0
- package/dist/user-directory.d.ts +18 -0
- package/dist/user-directory.d.ts.map +1 -0
- package/dist/user-directory.js +51 -0
- package/dist/user-directory.js.map +1 -0
- package/dist/validation.d.ts +22 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +338 -0
- package/dist/validation.js.map +1 -0
- package/package.json +59 -0
- package/src/action-guard.ts +20 -0
- package/src/action-menu.tsx +161 -0
- package/src/admin-app.tsx +630 -0
- package/src/admin-router.tsx +273 -0
- package/src/breadcrumbs.tsx +75 -0
- package/src/client.ts +1093 -0
- package/src/component-loader.ts +33 -0
- package/src/components/ai-assistant-widget.tsx +565 -0
- package/src/components/ai-fill-dialog.tsx +143 -0
- package/src/components/chart-builder-dialog.tsx +618 -0
- package/src/components/chart-widget.tsx +654 -0
- package/src/components/global-search-dialog.tsx +272 -0
- package/src/components/group-settings-dialog.tsx +93 -0
- package/src/components/move-chart-dialog.tsx +130 -0
- package/src/components/reference-multi-table-dialog.tsx +196 -0
- package/src/components/related-records-tabs.tsx +130 -0
- package/src/components/revisions-button.tsx +237 -0
- package/src/components/wizard-form.tsx +302 -0
- package/src/dashboard/time-series.ts +125 -0
- package/src/dialogs.tsx +265 -0
- package/src/export.ts +140 -0
- package/src/extension-registry.ts +195 -0
- package/src/header-controls.tsx +125 -0
- package/src/hooks.ts +509 -0
- package/src/hotkey-help.tsx +56 -0
- package/src/hotkey-registry.tsx +60 -0
- package/src/i18n.tsx +267 -0
- package/src/index.ts +192 -0
- package/src/notify.tsx +94 -0
- package/src/pages/ai-assistant-settings-section.tsx +167 -0
- package/src/pages/audit-log-page.tsx +580 -0
- package/src/pages/edit-page.tsx +743 -0
- package/src/pages/export-dialog.tsx +154 -0
- package/src/pages/home-page.tsx +318 -0
- package/src/pages/list-page.tsx +2645 -0
- package/src/pages/login-page.tsx +242 -0
- package/src/pages/settings-page.tsx +1143 -0
- package/src/pages/settings-shared.tsx +134 -0
- package/src/pages/show-page.tsx +223 -0
- package/src/pages/wizard-create-page.tsx +164 -0
- package/src/property-renderer.tsx +1143 -0
- package/src/provider.tsx +70 -0
- package/src/realtime.ts +55 -0
- package/src/reference.tsx +386 -0
- package/src/relations.ts +55 -0
- package/src/router.tsx +211 -0
- package/src/show-when.ts +76 -0
- package/src/types.ts +198 -0
- package/src/use-dashboard-charts.ts +362 -0
- package/src/use-hotkey.ts +128 -0
- package/src/user-directory.ts +56 -0
- package/src/validation.ts +361 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
// Dashboard tile rendering one ChartDef. Phase 10 brings:
|
|
2
|
+
// • Per-widget toolbar (step / range / width / SQL toggle) — changes persist
|
|
3
|
+
// via `onUpdate` so a tweak survives reload without re-opening the builder.
|
|
4
|
+
// • Time-series chart (date X-axis, value Y-axis) with multi-series via
|
|
5
|
+
// secondary `groupBy`. Zero-fill happens UI-side regardless of adapter.
|
|
6
|
+
// • KPI mode = `step: 'all'` — sums the single bucket and shows period-over-
|
|
7
|
+
// period delta.
|
|
8
|
+
// • Graceful degradation when the adapter cannot do time-series aggregation
|
|
9
|
+
// (e.g. non-relational DB) — shows a friendly message instead of erroring.
|
|
10
|
+
|
|
11
|
+
import * as React from 'react'
|
|
12
|
+
import {
|
|
13
|
+
MoreHorizontal,
|
|
14
|
+
Pencil,
|
|
15
|
+
Trash2,
|
|
16
|
+
RefreshCw,
|
|
17
|
+
Maximize2,
|
|
18
|
+
Minimize2,
|
|
19
|
+
Code,
|
|
20
|
+
Copy,
|
|
21
|
+
Check,
|
|
22
|
+
FolderSymlink,
|
|
23
|
+
} from 'lucide-react'
|
|
24
|
+
import {
|
|
25
|
+
Card,
|
|
26
|
+
CardContent,
|
|
27
|
+
CardHeader,
|
|
28
|
+
CardTitle,
|
|
29
|
+
TimeSeriesChart,
|
|
30
|
+
KpiCard,
|
|
31
|
+
DatePicker,
|
|
32
|
+
DropdownMenu,
|
|
33
|
+
DropdownMenuContent,
|
|
34
|
+
DropdownMenuItem,
|
|
35
|
+
DropdownMenuSeparator,
|
|
36
|
+
DropdownMenuTrigger,
|
|
37
|
+
Button,
|
|
38
|
+
Input,
|
|
39
|
+
Select,
|
|
40
|
+
SelectContent,
|
|
41
|
+
SelectItem,
|
|
42
|
+
SelectTrigger,
|
|
43
|
+
SelectValue,
|
|
44
|
+
Skeleton,
|
|
45
|
+
} from '@modern-admin/ui'
|
|
46
|
+
import { ReferenceCombobox } from '../reference.js'
|
|
47
|
+
import type { PropertyJSON } from '../types.js'
|
|
48
|
+
import type {
|
|
49
|
+
ChartDef,
|
|
50
|
+
ChartDefInput,
|
|
51
|
+
AggregationStep,
|
|
52
|
+
ChartWidth,
|
|
53
|
+
TimeRange,
|
|
54
|
+
TimeRangePreset,
|
|
55
|
+
} from '@modern-admin/core'
|
|
56
|
+
import { useTimeSeries, useResource } from '../hooks.js'
|
|
57
|
+
import { useI18n } from '../i18n.js'
|
|
58
|
+
import { resolveRange } from '../use-dashboard-charts.js'
|
|
59
|
+
import {
|
|
60
|
+
fillTimeSeries,
|
|
61
|
+
makeLabelFormatter,
|
|
62
|
+
makeTickFormatter,
|
|
63
|
+
} from '../dashboard/time-series.js'
|
|
64
|
+
import type { TimeSeriesQuery, TimeSeriesSeries } from '../client.js'
|
|
65
|
+
|
|
66
|
+
const PRESETS: TimeRangePreset[] = ['7d', '30d', '90d', '1y', 'all', 'custom']
|
|
67
|
+
const STEPS: Exclude<AggregationStep, 'all'>[] = ['day', 'week', 'month', 'year']
|
|
68
|
+
|
|
69
|
+
export interface ChartWidgetProps {
|
|
70
|
+
config: ChartDef
|
|
71
|
+
onEdit(): void
|
|
72
|
+
onDelete(): void
|
|
73
|
+
onMove?(): void
|
|
74
|
+
/** Called when the user tweaks step / range / width directly on the widget. */
|
|
75
|
+
onUpdate(input: ChartDefInput): void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function ChartWidget({
|
|
79
|
+
config,
|
|
80
|
+
onEdit,
|
|
81
|
+
onDelete,
|
|
82
|
+
onMove,
|
|
83
|
+
onUpdate,
|
|
84
|
+
}: ChartWidgetProps): React.ReactElement {
|
|
85
|
+
const { t, locale } = useI18n()
|
|
86
|
+
|
|
87
|
+
const isKpi = config.visualisation === 'kpi'
|
|
88
|
+
// KPI charts force `step: 'all'`; the schema enforces this at save time.
|
|
89
|
+
// For very wide presets, automatically coarsen granularity so the point
|
|
90
|
+
// count stays manageable — 3650 daily buckets for 'all' would render an
|
|
91
|
+
// unreadable axis and cause significant memory pressure in Recharts.
|
|
92
|
+
const renderStep: AggregationStep =
|
|
93
|
+
isKpi ? 'all'
|
|
94
|
+
: config.timeRange.preset === 'all' && (config.step === 'day' || config.step === 'week') ? 'month'
|
|
95
|
+
: config.timeRange.preset === '1y' && config.step === 'day' ? 'week'
|
|
96
|
+
: config.step
|
|
97
|
+
|
|
98
|
+
// Resolve the time-range preset to concrete from/to per render so cards
|
|
99
|
+
// automatically reflect "now" as days roll over without re-saving.
|
|
100
|
+
const range = React.useMemo(() => resolveRange(config.timeRange), [config.timeRange])
|
|
101
|
+
|
|
102
|
+
// If the saved ChartDef pre-dates the groupByLabelResource feature (i.e.,
|
|
103
|
+
// groupBy is set but groupByLabelResource is not), derive it from the
|
|
104
|
+
// resource's property metadata so labels resolve without requiring a re-save.
|
|
105
|
+
const resourceConfig = useResource(config.resource)
|
|
106
|
+
const effectiveLabelResource = React.useMemo(() => {
|
|
107
|
+
if (config.groupByLabelResource) return config.groupByLabelResource
|
|
108
|
+
if (!config.groupBy || !resourceConfig) return undefined
|
|
109
|
+
const props = resourceConfig.properties
|
|
110
|
+
const prop = props.find((p) => p.path === config.groupBy)
|
|
111
|
+
if (!prop) return undefined
|
|
112
|
+
if (prop.reference) return prop.reference
|
|
113
|
+
// FK heuristic: strip Id / _id suffix and look for a sibling reference prop.
|
|
114
|
+
const base = prop.path.endsWith('_id')
|
|
115
|
+
? prop.path.slice(0, -3)
|
|
116
|
+
: prop.path.endsWith('Id')
|
|
117
|
+
? prop.path.slice(0, -2)
|
|
118
|
+
: ''
|
|
119
|
+
if (base) {
|
|
120
|
+
const sibling = props.find((p) => p.path === base && p.reference)
|
|
121
|
+
if (sibling?.reference) return sibling.reference
|
|
122
|
+
}
|
|
123
|
+
return undefined
|
|
124
|
+
}, [config.groupBy, config.groupByLabelResource, resourceConfig])
|
|
125
|
+
|
|
126
|
+
const query = React.useMemo<TimeSeriesQuery>(
|
|
127
|
+
() => ({
|
|
128
|
+
resource: config.resource,
|
|
129
|
+
dateField: config.dateField,
|
|
130
|
+
step: renderStep as TimeSeriesQuery['step'],
|
|
131
|
+
metric: config.metric,
|
|
132
|
+
from: range.from,
|
|
133
|
+
to: range.to,
|
|
134
|
+
...(config.field ? { field: config.field } : {}),
|
|
135
|
+
...(!isKpi && config.groupBy ? { groupBy: config.groupBy } : {}),
|
|
136
|
+
...(!isKpi && config.groupBy ? { topN: config.topN } : {}),
|
|
137
|
+
...(!isKpi && config.groupBy && effectiveLabelResource
|
|
138
|
+
? { groupByLabelResource: effectiveLabelResource }
|
|
139
|
+
: {}),
|
|
140
|
+
...(Object.keys(config.filters).length ? { filters: config.filters } : {}),
|
|
141
|
+
...(isKpi ? { comparePrevious: true as const } : {}),
|
|
142
|
+
}),
|
|
143
|
+
[config, range, isKpi, renderStep, effectiveLabelResource],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
const { data, isLoading, isError, refetch, isFetching } = useTimeSeries(query)
|
|
147
|
+
|
|
148
|
+
const [showSql, setShowSql] = React.useState(false)
|
|
149
|
+
const [sqlCopied, setSqlCopied] = React.useState(false)
|
|
150
|
+
|
|
151
|
+
// Draft from/to — only committed when the user clicks Apply.
|
|
152
|
+
// Seeded from `config.timeRange` when preset changes to 'custom'.
|
|
153
|
+
const [draftFrom, setDraftFrom] = React.useState(range.from)
|
|
154
|
+
const [draftTo, setDraftTo] = React.useState(range.to)
|
|
155
|
+
|
|
156
|
+
// Draft state for quick filters exposed above the chart. The user tweaks
|
|
157
|
+
// values inline and clicks Apply to refetch — mirroring the custom-range
|
|
158
|
+
// pattern so widgets don't refetch on every keystroke.
|
|
159
|
+
const quickFilterPaths = config.quickFilters ?? []
|
|
160
|
+
const [draftFilters, setDraftFilters] = React.useState<Record<string, string>>(
|
|
161
|
+
() => seedDraftFilters(quickFilterPaths, config.filters),
|
|
162
|
+
)
|
|
163
|
+
// Re-seed whenever the saved chart definition changes externally.
|
|
164
|
+
const quickFiltersKey = quickFilterPaths.join('|')
|
|
165
|
+
const savedFiltersKey = React.useMemo(
|
|
166
|
+
() =>
|
|
167
|
+
Object.entries(config.filters)
|
|
168
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
169
|
+
.sort()
|
|
170
|
+
.join('|'),
|
|
171
|
+
[config.filters],
|
|
172
|
+
)
|
|
173
|
+
React.useEffect(() => {
|
|
174
|
+
setDraftFilters(seedDraftFilters(quickFilterPaths, config.filters))
|
|
175
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
176
|
+
}, [quickFiltersKey, savedFiltersKey])
|
|
177
|
+
|
|
178
|
+
const applyQuickFilters = (): void => {
|
|
179
|
+
const next: Record<string, string> = { ...config.filters }
|
|
180
|
+
for (const p of quickFilterPaths) {
|
|
181
|
+
const v = draftFilters[p] ?? ''
|
|
182
|
+
if (v === '') delete next[p]
|
|
183
|
+
else next[p] = v
|
|
184
|
+
}
|
|
185
|
+
update({ filters: next })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const draftDirty = quickFilterPaths.some(
|
|
189
|
+
(p) => (draftFilters[p] ?? '') !== (config.filters[p] ?? ''),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
// Mutators — persisted by the parent via `onUpdate(updatedDef)`.
|
|
193
|
+
const update = (patch: Partial<ChartDefInput>): void => {
|
|
194
|
+
onUpdate({ ...config, ...patch, updatedAt: new Date().toISOString() })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const onPresetChange = (preset: TimeRangePreset): void => {
|
|
198
|
+
if (preset === 'custom') {
|
|
199
|
+
// Seed draft with the currently resolved window — user can then narrow
|
|
200
|
+
// it and click Apply without triggering an immediate refetch.
|
|
201
|
+
setDraftFrom(range.from)
|
|
202
|
+
setDraftTo(range.to)
|
|
203
|
+
update({
|
|
204
|
+
timeRange: { preset: 'custom', from: range.from, to: range.to } as TimeRange,
|
|
205
|
+
})
|
|
206
|
+
} else {
|
|
207
|
+
update({ timeRange: { preset } as TimeRange })
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const applyCustomRange = (): void => {
|
|
212
|
+
if (draftFrom && draftTo) {
|
|
213
|
+
update({
|
|
214
|
+
timeRange: { preset: 'custom', from: draftFrom, to: draftTo } as TimeRange,
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const onStepChange = (step: AggregationStep): void => {
|
|
220
|
+
if (step === 'all') return // KPI is selected via the builder, not the toolbar
|
|
221
|
+
update({ step })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const onWidthToggle = (): void => {
|
|
225
|
+
update({ width: (config.width === 'full' ? 'half' : 'full') as ChartWidth })
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Render ────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
const heightClass = isKpi ? 'h-32' : 'h-[320px]'
|
|
231
|
+
|
|
232
|
+
const resolvedLabels = data?.resolvedLabels
|
|
233
|
+
const seriesLabel = React.useCallback(
|
|
234
|
+
(key: string): string => {
|
|
235
|
+
if (key === '__total__') return t('dashboard:seriesTotal')
|
|
236
|
+
if (key === '__other__') return t('dashboard:seriesOther')
|
|
237
|
+
if (key === '__null__') return t('dashboard:seriesNull')
|
|
238
|
+
return resolvedLabels?.[key] ?? key
|
|
239
|
+
},
|
|
240
|
+
[resolvedLabels, t],
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
const chartSeries = React.useMemo(
|
|
244
|
+
() => prepareSeries(data?.series ?? [], range, renderStep, seriesLabel),
|
|
245
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
246
|
+
[data?.series, range.from, range.to, renderStep, seriesLabel],
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
// Adapter cannot do time-series — friendly message, no toolbar churn.
|
|
250
|
+
const unsupported = data && data.supported === false
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<Card className="flex flex-col">
|
|
254
|
+
<CardHeader className="flex flex-row items-center justify-between gap-2 p-3 pb-2 space-y-0 sm:p-6 sm:pb-2">
|
|
255
|
+
<CardTitle className="text-sm font-medium truncate pr-2">
|
|
256
|
+
{config.title || t('chart:untitled')}
|
|
257
|
+
</CardTitle>
|
|
258
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
259
|
+
<Button
|
|
260
|
+
variant="ghost"
|
|
261
|
+
size="icon"
|
|
262
|
+
className="size-7"
|
|
263
|
+
onClick={onWidthToggle}
|
|
264
|
+
aria-label={
|
|
265
|
+
config.width === 'full'
|
|
266
|
+
? t('dashboard:widget.shrink')
|
|
267
|
+
: t('dashboard:widget.expand')
|
|
268
|
+
}
|
|
269
|
+
>
|
|
270
|
+
{config.width === 'full' ? (
|
|
271
|
+
<Minimize2 className="size-3.5" />
|
|
272
|
+
) : (
|
|
273
|
+
<Maximize2 className="size-3.5" />
|
|
274
|
+
)}
|
|
275
|
+
</Button>
|
|
276
|
+
{data?.sql && (
|
|
277
|
+
<Button
|
|
278
|
+
variant={showSql ? 'secondary' : 'ghost'}
|
|
279
|
+
size="icon"
|
|
280
|
+
className="size-7"
|
|
281
|
+
onClick={() => setShowSql((v) => !v)}
|
|
282
|
+
aria-label={t('dashboard:widget.toggleSql')}
|
|
283
|
+
>
|
|
284
|
+
<Code className="size-3.5" />
|
|
285
|
+
</Button>
|
|
286
|
+
)}
|
|
287
|
+
<Button
|
|
288
|
+
variant="ghost"
|
|
289
|
+
size="icon"
|
|
290
|
+
className="size-7"
|
|
291
|
+
onClick={() => refetch()}
|
|
292
|
+
disabled={isFetching}
|
|
293
|
+
aria-label={t('common:refresh')}
|
|
294
|
+
>
|
|
295
|
+
<RefreshCw className={`size-3.5 ${isFetching ? 'animate-spin' : ''}`} />
|
|
296
|
+
</Button>
|
|
297
|
+
<DropdownMenu>
|
|
298
|
+
<DropdownMenuTrigger asChild>
|
|
299
|
+
<Button
|
|
300
|
+
variant="ghost"
|
|
301
|
+
size="icon"
|
|
302
|
+
className="size-7"
|
|
303
|
+
aria-label={t('common:openMenu')}
|
|
304
|
+
>
|
|
305
|
+
<MoreHorizontal className="size-3.5" />
|
|
306
|
+
</Button>
|
|
307
|
+
</DropdownMenuTrigger>
|
|
308
|
+
<DropdownMenuContent align="end">
|
|
309
|
+
<DropdownMenuItem onClick={onEdit}>
|
|
310
|
+
<Pencil className="size-4 mr-2" />
|
|
311
|
+
{t('chart:editChart')}
|
|
312
|
+
</DropdownMenuItem>
|
|
313
|
+
{onMove && (
|
|
314
|
+
<DropdownMenuItem onClick={onMove}>
|
|
315
|
+
<FolderSymlink className="size-4 mr-2" />
|
|
316
|
+
{t('chart:moveToGroup')}
|
|
317
|
+
</DropdownMenuItem>
|
|
318
|
+
)}
|
|
319
|
+
<DropdownMenuSeparator />
|
|
320
|
+
<DropdownMenuItem
|
|
321
|
+
onClick={onDelete}
|
|
322
|
+
className="text-destructive focus:text-destructive"
|
|
323
|
+
>
|
|
324
|
+
<Trash2 className="size-4 mr-2" />
|
|
325
|
+
{t('chart:deleteChart')}
|
|
326
|
+
</DropdownMenuItem>
|
|
327
|
+
</DropdownMenuContent>
|
|
328
|
+
</DropdownMenu>
|
|
329
|
+
</div>
|
|
330
|
+
</CardHeader>
|
|
331
|
+
|
|
332
|
+
<CardContent className="flex-1 p-3 pt-0 space-y-3 sm:p-6 sm:pt-0">
|
|
333
|
+
{/* Quick filters — compact inline row, label as placeholder, Apply on the right. */}
|
|
334
|
+
{!unsupported && quickFilterPaths.length > 0 && resourceConfig && (
|
|
335
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
336
|
+
{quickFilterPaths.map((path) => {
|
|
337
|
+
const prop = resourceConfig.properties.find((p) => p.path === path)
|
|
338
|
+
if (!prop) return null
|
|
339
|
+
return (
|
|
340
|
+
<QuickFilterInput
|
|
341
|
+
key={path}
|
|
342
|
+
property={prop}
|
|
343
|
+
placeholder={prop.label}
|
|
344
|
+
value={draftFilters[path] ?? ''}
|
|
345
|
+
onChange={(v) => setDraftFilters((prev) => ({ ...prev, [path]: v }))}
|
|
346
|
+
/>
|
|
347
|
+
)
|
|
348
|
+
})}
|
|
349
|
+
<Button
|
|
350
|
+
size="sm"
|
|
351
|
+
className="h-8 px-3 text-xs shrink-0"
|
|
352
|
+
onClick={applyQuickFilters}
|
|
353
|
+
disabled={!draftDirty}
|
|
354
|
+
>
|
|
355
|
+
<Check className="size-3.5 mr-1" />
|
|
356
|
+
{t('common:apply')}
|
|
357
|
+
</Button>
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
360
|
+
|
|
361
|
+
{/* Toolbar — step + range + window display. Hidden for unsupported
|
|
362
|
+
adapters because changing knobs would have no effect. */}
|
|
363
|
+
{!unsupported && (
|
|
364
|
+
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
365
|
+
{!isKpi && (
|
|
366
|
+
<Select
|
|
367
|
+
value={config.step === 'all' ? 'day' : config.step}
|
|
368
|
+
onValueChange={(v) => onStepChange(v as AggregationStep)}
|
|
369
|
+
>
|
|
370
|
+
<SelectTrigger
|
|
371
|
+
className="h-8 px-2 text-xs w-auto"
|
|
372
|
+
aria-label={t('chart:step')}
|
|
373
|
+
>
|
|
374
|
+
<SelectValue />
|
|
375
|
+
</SelectTrigger>
|
|
376
|
+
<SelectContent>
|
|
377
|
+
{STEPS.map((s) => (
|
|
378
|
+
<SelectItem key={s} value={s}>
|
|
379
|
+
{t(`chart:step${cap(s)}`)}
|
|
380
|
+
</SelectItem>
|
|
381
|
+
))}
|
|
382
|
+
</SelectContent>
|
|
383
|
+
</Select>
|
|
384
|
+
)}
|
|
385
|
+
<Select
|
|
386
|
+
value={config.timeRange.preset}
|
|
387
|
+
onValueChange={(v) => onPresetChange(v as TimeRangePreset)}
|
|
388
|
+
>
|
|
389
|
+
<SelectTrigger
|
|
390
|
+
className="h-8 px-2 text-xs w-auto"
|
|
391
|
+
aria-label={t('dashboard:builder.range')}
|
|
392
|
+
>
|
|
393
|
+
<SelectValue />
|
|
394
|
+
</SelectTrigger>
|
|
395
|
+
<SelectContent>
|
|
396
|
+
{PRESETS.map((p) => (
|
|
397
|
+
<SelectItem key={p} value={p}>
|
|
398
|
+
{t(`dashboard:range.${p}`)}
|
|
399
|
+
</SelectItem>
|
|
400
|
+
))}
|
|
401
|
+
</SelectContent>
|
|
402
|
+
</Select>
|
|
403
|
+
{config.timeRange.preset === 'custom' ? (
|
|
404
|
+
<>
|
|
405
|
+
<DatePicker
|
|
406
|
+
value={draftFrom}
|
|
407
|
+
onChange={setDraftFrom}
|
|
408
|
+
ariaLabel={t('common:from')}
|
|
409
|
+
openCalendarLabel={t('common:openCalendar')}
|
|
410
|
+
className="w-[130px]"
|
|
411
|
+
inputClassName="h-8 text-xs"
|
|
412
|
+
/>
|
|
413
|
+
<span className="text-muted-foreground">—</span>
|
|
414
|
+
<DatePicker
|
|
415
|
+
value={draftTo}
|
|
416
|
+
onChange={setDraftTo}
|
|
417
|
+
ariaLabel={t('common:to')}
|
|
418
|
+
openCalendarLabel={t('common:openCalendar')}
|
|
419
|
+
className="w-[130px]"
|
|
420
|
+
inputClassName="h-8 text-xs"
|
|
421
|
+
/>
|
|
422
|
+
<Button
|
|
423
|
+
size="sm"
|
|
424
|
+
className="h-8 px-3 shrink-0"
|
|
425
|
+
onClick={applyCustomRange}
|
|
426
|
+
disabled={!draftFrom || !draftTo}
|
|
427
|
+
aria-label={t('common:apply')}
|
|
428
|
+
>
|
|
429
|
+
<Check className="size-3.5 mr-1" />
|
|
430
|
+
{t('common:apply')}
|
|
431
|
+
</Button>
|
|
432
|
+
</>
|
|
433
|
+
) : (
|
|
434
|
+
<span className="ml-auto tabular-nums">
|
|
435
|
+
{range.from} — {range.to}
|
|
436
|
+
</span>
|
|
437
|
+
)}
|
|
438
|
+
</div>
|
|
439
|
+
)}
|
|
440
|
+
|
|
441
|
+
{/* Body */}
|
|
442
|
+
{isLoading ? (
|
|
443
|
+
<Skeleton className={`${heightClass} w-full rounded-md`} />
|
|
444
|
+
) : isError ? (
|
|
445
|
+
<div
|
|
446
|
+
className={`flex items-center justify-center text-sm text-muted-foreground ${heightClass}`}
|
|
447
|
+
>
|
|
448
|
+
{t('chart:loadError')}
|
|
449
|
+
</div>
|
|
450
|
+
) : unsupported ? (
|
|
451
|
+
<div
|
|
452
|
+
className={`flex items-center justify-center text-center text-sm text-muted-foreground px-4 ${heightClass}`}
|
|
453
|
+
>
|
|
454
|
+
{t('dashboard:widget.unsupported')}
|
|
455
|
+
</div>
|
|
456
|
+
) : isKpi ? (
|
|
457
|
+
<KpiBody data={data} labels={kpiLabels(t)} />
|
|
458
|
+
) : (
|
|
459
|
+
<TimeSeriesChart
|
|
460
|
+
series={chartSeries}
|
|
461
|
+
height={320}
|
|
462
|
+
visualisation={config.visualisation === 'kpi' ? undefined : config.visualisation}
|
|
463
|
+
tickFormatter={makeTickFormatter(renderStep, locale)}
|
|
464
|
+
labelFormatter={makeLabelFormatter(renderStep, locale)}
|
|
465
|
+
labels={{
|
|
466
|
+
noData: t('chart:noData'),
|
|
467
|
+
showAll: t('dashboard:widget.showAll'),
|
|
468
|
+
hideAll: t('dashboard:widget.hideAll'),
|
|
469
|
+
}}
|
|
470
|
+
/>
|
|
471
|
+
)}
|
|
472
|
+
|
|
473
|
+
{/* Captured SQL — only when the user toggled and server returned it. */}
|
|
474
|
+
{data?.sql && showSql && (
|
|
475
|
+
<div className="relative">
|
|
476
|
+
<Button
|
|
477
|
+
variant="ghost"
|
|
478
|
+
size="icon"
|
|
479
|
+
className="absolute top-1 right-1 size-6"
|
|
480
|
+
onClick={() => {
|
|
481
|
+
void navigator.clipboard.writeText(data.sql ?? '')
|
|
482
|
+
setSqlCopied(true)
|
|
483
|
+
setTimeout(() => setSqlCopied(false), 2000)
|
|
484
|
+
}}
|
|
485
|
+
aria-label={sqlCopied ? t('common:copied') : t('common:copy')}
|
|
486
|
+
>
|
|
487
|
+
{sqlCopied
|
|
488
|
+
? <Check className="size-3 text-green-500" />
|
|
489
|
+
: <Copy className="size-3" />}
|
|
490
|
+
</Button>
|
|
491
|
+
<pre className="text-[11px] leading-snug bg-muted/50 border border-border rounded-md p-2 pr-8 overflow-x-auto whitespace-pre">
|
|
492
|
+
{data.sql}
|
|
493
|
+
</pre>
|
|
494
|
+
</div>
|
|
495
|
+
)}
|
|
496
|
+
</CardContent>
|
|
497
|
+
</Card>
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
|
|
504
|
+
|
|
505
|
+
const QF_NONE = '__none__'
|
|
506
|
+
|
|
507
|
+
function seedDraftFilters(
|
|
508
|
+
paths: ReadonlyArray<string>,
|
|
509
|
+
filters: Record<string, string>,
|
|
510
|
+
): Record<string, string> {
|
|
511
|
+
const out: Record<string, string> = {}
|
|
512
|
+
for (const p of paths) out[p] = filters[p] ?? ''
|
|
513
|
+
return out
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Compact inline filter input — label is passed as placeholder so no
|
|
517
|
+
* extra row is needed. Matches the `h-7 text-xs` sizing of toolbar controls. */
|
|
518
|
+
function QuickFilterInput({
|
|
519
|
+
property,
|
|
520
|
+
placeholder,
|
|
521
|
+
value,
|
|
522
|
+
onChange,
|
|
523
|
+
}: {
|
|
524
|
+
property: PropertyJSON
|
|
525
|
+
placeholder?: string
|
|
526
|
+
value: string
|
|
527
|
+
onChange(next: string): void
|
|
528
|
+
}): React.ReactElement {
|
|
529
|
+
const ph = placeholder ?? property.label
|
|
530
|
+
if (property.reference) {
|
|
531
|
+
return (
|
|
532
|
+
<div className="w-36">
|
|
533
|
+
<ReferenceCombobox
|
|
534
|
+
referenceResourceId={property.reference}
|
|
535
|
+
value={value || null}
|
|
536
|
+
onChange={(next) => onChange(next == null ? '' : String(next))}
|
|
537
|
+
placeholder={ph}
|
|
538
|
+
className="h-8 text-xs"
|
|
539
|
+
/>
|
|
540
|
+
</div>
|
|
541
|
+
)
|
|
542
|
+
}
|
|
543
|
+
if (property.availableValues && property.availableValues.length > 0) {
|
|
544
|
+
return (
|
|
545
|
+
<Select
|
|
546
|
+
value={value || QF_NONE}
|
|
547
|
+
onValueChange={(v) => onChange(v === QF_NONE ? '' : v)}
|
|
548
|
+
>
|
|
549
|
+
<SelectTrigger className="h-8 px-2 text-xs w-36">
|
|
550
|
+
<SelectValue placeholder={ph} />
|
|
551
|
+
</SelectTrigger>
|
|
552
|
+
<SelectContent>
|
|
553
|
+
<SelectItem value={QF_NONE}>{ph}</SelectItem>
|
|
554
|
+
{property.availableValues.map((opt) => (
|
|
555
|
+
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
|
556
|
+
))}
|
|
557
|
+
</SelectContent>
|
|
558
|
+
</Select>
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
if (property.type === 'boolean') {
|
|
562
|
+
return (
|
|
563
|
+
<Select
|
|
564
|
+
value={value || QF_NONE}
|
|
565
|
+
onValueChange={(v) => onChange(v === QF_NONE ? '' : v)}
|
|
566
|
+
>
|
|
567
|
+
<SelectTrigger className="h-8 px-2 text-xs w-36">
|
|
568
|
+
<SelectValue placeholder={ph} />
|
|
569
|
+
</SelectTrigger>
|
|
570
|
+
<SelectContent>
|
|
571
|
+
<SelectItem value={QF_NONE}>{ph}</SelectItem>
|
|
572
|
+
<SelectItem value="true">true</SelectItem>
|
|
573
|
+
<SelectItem value="false">false</SelectItem>
|
|
574
|
+
</SelectContent>
|
|
575
|
+
</Select>
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
const isNumeric =
|
|
579
|
+
property.type === 'number' || property.type === 'float' || property.type === 'currency'
|
|
580
|
+
return (
|
|
581
|
+
<Input
|
|
582
|
+
type={isNumeric ? 'number' : 'text'}
|
|
583
|
+
className="h-8 px-2 text-xs w-36"
|
|
584
|
+
value={value}
|
|
585
|
+
placeholder={ph}
|
|
586
|
+
onChange={(e) => onChange(e.target.value)}
|
|
587
|
+
/>
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Zero-fill every series across the resolved date range and re-tag each
|
|
593
|
+
* with its display label (so legend shows "Total" / "Other" / actual
|
|
594
|
+
* groupBy values rather than the wire-format internal keys).
|
|
595
|
+
*/
|
|
596
|
+
function prepareSeries(
|
|
597
|
+
series: ReadonlyArray<TimeSeriesSeries>,
|
|
598
|
+
range: { from: string; to: string },
|
|
599
|
+
step: AggregationStep,
|
|
600
|
+
labelFor: (key: string) => string,
|
|
601
|
+
): { key: string; label: string; points: { date: string; value: number }[] }[] {
|
|
602
|
+
const filled = fillTimeSeries(
|
|
603
|
+
series.map((s) => ({ key: s.key, points: s.points })),
|
|
604
|
+
range.from,
|
|
605
|
+
range.to,
|
|
606
|
+
step as Exclude<AggregationStep, 'all'> | 'all',
|
|
607
|
+
)
|
|
608
|
+
return filled.map((s) => ({
|
|
609
|
+
key: s.key,
|
|
610
|
+
label: labelFor(s.key),
|
|
611
|
+
points: [...s.points],
|
|
612
|
+
}))
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
interface KpiBodyProps {
|
|
616
|
+
data: { series: ReadonlyArray<TimeSeriesSeries>; previous?: ReadonlyArray<TimeSeriesSeries> } | undefined
|
|
617
|
+
labels: {
|
|
618
|
+
noData: string
|
|
619
|
+
deltaUp: string
|
|
620
|
+
deltaDown: string
|
|
621
|
+
deltaFlat: string
|
|
622
|
+
previousPeriod: string
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** KPI mode summarises the single-bucket response to one scalar. */
|
|
627
|
+
function KpiBody({ data, labels }: KpiBodyProps): React.ReactElement {
|
|
628
|
+
const value = sumAll(data?.series)
|
|
629
|
+
const prev = sumAll(data?.previous)
|
|
630
|
+
return <KpiCard value={value} previousValue={prev} labels={labels} />
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function sumAll(series: ReadonlyArray<TimeSeriesSeries> | undefined): number | null {
|
|
634
|
+
if (!series || series.length === 0) return null
|
|
635
|
+
let total = 0
|
|
636
|
+
for (const s of series) for (const p of s.points) total += p.value
|
|
637
|
+
return total
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function kpiLabels(t: (key: string) => string): {
|
|
641
|
+
noData: string
|
|
642
|
+
deltaUp: string
|
|
643
|
+
deltaDown: string
|
|
644
|
+
deltaFlat: string
|
|
645
|
+
previousPeriod: string
|
|
646
|
+
} {
|
|
647
|
+
return {
|
|
648
|
+
noData: t('chart:noData'),
|
|
649
|
+
deltaUp: t('dashboard:widget.deltaUp'),
|
|
650
|
+
deltaDown: t('dashboard:widget.deltaDown'),
|
|
651
|
+
deltaFlat: t('dashboard:widget.deltaFlat'),
|
|
652
|
+
previousPeriod: t('dashboard:widget.previousPeriod'),
|
|
653
|
+
}
|
|
654
|
+
}
|