@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,618 @@
|
|
|
1
|
+
// Phase 10: time-series-first chart builder.
|
|
2
|
+
//
|
|
3
|
+
// X-axis is ALWAYS a date — the user picks `dateField` (required). `groupBy`
|
|
4
|
+
// becomes an optional *secondary* breakdown that produces multiple series
|
|
5
|
+
// (Metabase-style: pick "status" → one line per status value). Pie removed.
|
|
6
|
+
// `width` lets the chart take half or full row on the dashboard grid.
|
|
7
|
+
|
|
8
|
+
import * as React from 'react'
|
|
9
|
+
import {
|
|
10
|
+
BarChart2,
|
|
11
|
+
LineChart,
|
|
12
|
+
AreaChart,
|
|
13
|
+
Activity,
|
|
14
|
+
} from 'lucide-react'
|
|
15
|
+
import {
|
|
16
|
+
Button,
|
|
17
|
+
Dialog,
|
|
18
|
+
DialogContent,
|
|
19
|
+
DialogFooter,
|
|
20
|
+
DialogHeader,
|
|
21
|
+
DialogTitle,
|
|
22
|
+
InfoTooltip,
|
|
23
|
+
Input,
|
|
24
|
+
Label,
|
|
25
|
+
Select,
|
|
26
|
+
SelectContent,
|
|
27
|
+
SelectItem,
|
|
28
|
+
SelectTrigger,
|
|
29
|
+
SelectValue,
|
|
30
|
+
Switch,
|
|
31
|
+
} from '@modern-admin/ui'
|
|
32
|
+
import {
|
|
33
|
+
chartDefZ,
|
|
34
|
+
uuidv7,
|
|
35
|
+
type AggregationOpName,
|
|
36
|
+
type ChartDef,
|
|
37
|
+
type ChartDefInput,
|
|
38
|
+
type ChartVisualisation,
|
|
39
|
+
type TimeRange,
|
|
40
|
+
type TimeRangePreset,
|
|
41
|
+
} from '@modern-admin/core'
|
|
42
|
+
import { useI18n } from '../i18n.js'
|
|
43
|
+
import { useResources } from '../hooks.js'
|
|
44
|
+
import { ReferenceCombobox } from '../reference.js'
|
|
45
|
+
import type { PropertyJSON } from '../types.js'
|
|
46
|
+
|
|
47
|
+
export interface ChartBuilderDialogProps {
|
|
48
|
+
/** Pre-populate for editing an existing chart. */
|
|
49
|
+
initial?: ChartDef
|
|
50
|
+
onSave(input: ChartDefInput): void
|
|
51
|
+
onClose(): void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const VIS_OPTIONS: { value: ChartVisualisation; icon: React.ReactElement; labelKey: string }[] = [
|
|
55
|
+
{ value: 'kpi', icon: <Activity className="size-4" />, labelKey: 'dashboard:visKpi' },
|
|
56
|
+
{ value: 'line', icon: <LineChart className="size-4" />, labelKey: 'dashboard:visLine' },
|
|
57
|
+
{ value: 'area', icon: <AreaChart className="size-4" />, labelKey: 'dashboard:visArea' },
|
|
58
|
+
{ value: 'bar', icon: <BarChart2 className="size-4" />, labelKey: 'dashboard:visBar' },
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
const NONE = '__none__'
|
|
62
|
+
const METRICS: AggregationOpName[] = ['count', 'sum', 'avg', 'min', 'max']
|
|
63
|
+
// `custom` is intentionally excluded from the builder — the builder sets a
|
|
64
|
+
// default interval; ad-hoc custom ranges are picked on the widget toolbar.
|
|
65
|
+
const PRESETS: Exclude<TimeRangePreset, 'custom'>[] = ['7d', '30d', '90d', '1y', 'all']
|
|
66
|
+
|
|
67
|
+
/** Date-ish heuristic for the dateField selector when adapter metadata is sparse. */
|
|
68
|
+
function isDateProperty(p: { type?: string; path: string }): boolean {
|
|
69
|
+
if (p.type === 'date' || p.type === 'datetime') return true
|
|
70
|
+
const lower = p.path.toLowerCase()
|
|
71
|
+
return lower.endsWith('at') || lower.endsWith('_at') || lower.endsWith('date')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Properties that make sense as a `groupBy` axis: anything that resolves to
|
|
76
|
+
* a discrete column value the adapter can group on. Virtual relation columns
|
|
77
|
+
* (`type: 'reference'` with `isArray: false` and no FK suffix) carry full
|
|
78
|
+
* record objects in their values and would serialise to `[object Object]` —
|
|
79
|
+
* exclude them so the user picks the underlying FK column instead.
|
|
80
|
+
*/
|
|
81
|
+
function isGroupable(p: { path: string; type?: string; isArray?: boolean }): boolean {
|
|
82
|
+
if (isDateProperty(p)) return false
|
|
83
|
+
if (p.type === 'reference' && !p.isArray) {
|
|
84
|
+
// Allow scalar FK columns (named `*Id`/`*_id` with type 'reference' set
|
|
85
|
+
// via adapter override) — they group cleanly to id strings. Reject virtual
|
|
86
|
+
// relation fields whose values are objects.
|
|
87
|
+
const path = p.path
|
|
88
|
+
return path.endsWith('Id') || path.endsWith('_id')
|
|
89
|
+
}
|
|
90
|
+
if (p.type === 'json' || p.type === 'mixed') return false
|
|
91
|
+
if (p.type === 'richtext' || p.type === 'markdown' || p.type === 'textarea') return false
|
|
92
|
+
if (p.type === 'previewMedia' || p.type === 'file') return false
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function ChartBuilderDialog({
|
|
97
|
+
initial,
|
|
98
|
+
onSave,
|
|
99
|
+
onClose,
|
|
100
|
+
}: ChartBuilderDialogProps): React.ReactElement {
|
|
101
|
+
const { t } = useI18n()
|
|
102
|
+
const resources = useResources()
|
|
103
|
+
|
|
104
|
+
const [title, setTitle] = React.useState(initial?.title ?? '')
|
|
105
|
+
const [visualisation, setVisualisation] = React.useState<ChartVisualisation>(
|
|
106
|
+
initial?.visualisation ?? 'area',
|
|
107
|
+
)
|
|
108
|
+
const [resourceId, setResourceId] = React.useState(
|
|
109
|
+
initial?.resource ?? resources[0]?.id ?? '',
|
|
110
|
+
)
|
|
111
|
+
const [dateField, setDateField] = React.useState(initial?.dateField ?? '')
|
|
112
|
+
const [metric, setMetric] = React.useState<AggregationOpName>(initial?.metric ?? 'count')
|
|
113
|
+
const [field, setField] = React.useState(initial?.field ?? '')
|
|
114
|
+
const [groupBy, setGroupBy] = React.useState(initial?.groupBy ?? '')
|
|
115
|
+
const [groupByLabelResource, setGroupByLabelResource] = React.useState(
|
|
116
|
+
initial?.groupByLabelResource ?? '',
|
|
117
|
+
)
|
|
118
|
+
const [topN, setTopN] = React.useState(initial?.topN ?? 10)
|
|
119
|
+
// The builder only sets a default time-range preset; custom ranges are
|
|
120
|
+
// picked on the widget toolbar. If the saved chart is on 'custom', fall
|
|
121
|
+
// back to the most useful preset for the builder UI.
|
|
122
|
+
const [preset, setPreset] = React.useState<Exclude<TimeRangePreset, 'custom'>>(
|
|
123
|
+
initial && initial.timeRange.preset !== 'custom' ? initial.timeRange.preset : '30d',
|
|
124
|
+
)
|
|
125
|
+
const [filters, setFilters] = React.useState<Record<string, string>>(
|
|
126
|
+
initial?.filters ?? {},
|
|
127
|
+
)
|
|
128
|
+
const [quickFilters, setQuickFilters] = React.useState<string[]>(
|
|
129
|
+
initial?.quickFilters ?? [],
|
|
130
|
+
)
|
|
131
|
+
const [order, setOrder] = React.useState<number>(initial?.order ?? 0)
|
|
132
|
+
const [errors, setErrors] = React.useState<Record<string, string>>({})
|
|
133
|
+
|
|
134
|
+
const resource = resources.find((r) => r.id === resourceId)
|
|
135
|
+
const properties = resource?.properties ?? []
|
|
136
|
+
const dateProps = properties.filter(isDateProperty)
|
|
137
|
+
const numericProps = properties.filter(
|
|
138
|
+
(p) => p.type === 'number' || p.type === 'float' || p.type === 'currency',
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
// Auto-select dateField when resource changes (skip the very first
|
|
142
|
+
// render of edit-mode so we keep the saved value).
|
|
143
|
+
React.useEffect(() => {
|
|
144
|
+
if (initial?.resource === resourceId) return
|
|
145
|
+
setDateField(dateProps[0]?.path ?? '')
|
|
146
|
+
setField('')
|
|
147
|
+
setGroupBy('')
|
|
148
|
+
setGroupByLabelResource('')
|
|
149
|
+
setFilters({})
|
|
150
|
+
setQuickFilters([])
|
|
151
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
152
|
+
}, [resourceId])
|
|
153
|
+
|
|
154
|
+
// When opening an existing chart that predates the groupByLabelResource
|
|
155
|
+
// feature (groupByLabelResource is empty but groupBy is set): auto-derive
|
|
156
|
+
// the label resource so the user gets resolved labels without re-saving.
|
|
157
|
+
React.useEffect(() => {
|
|
158
|
+
if (groupByLabelResource || !groupBy || properties.length === 0) return
|
|
159
|
+
setGroupByLabelResource(resolveGroupByLabelResource(groupBy, properties))
|
|
160
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
161
|
+
}, [groupBy, properties])
|
|
162
|
+
|
|
163
|
+
const isKpi = visualisation === 'kpi'
|
|
164
|
+
|
|
165
|
+
const buildTimeRange = (): TimeRange => ({ preset })
|
|
166
|
+
|
|
167
|
+
const handleFilterChange = (path: string, value: string): void => {
|
|
168
|
+
setFilters((prev) => {
|
|
169
|
+
const next = { ...prev }
|
|
170
|
+
if (value === '') delete next[path]
|
|
171
|
+
else next[path] = value
|
|
172
|
+
return next
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const toggleQuickFilter = (path: string): void => {
|
|
177
|
+
setQuickFilters((prev) =>
|
|
178
|
+
prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path],
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const handleSave = (): void => {
|
|
183
|
+
const now = new Date().toISOString()
|
|
184
|
+
// step is not user-editable in the builder; keep the previous value (or
|
|
185
|
+
// pick a sane default) so chartDefZ stays valid. The widget toolbar
|
|
186
|
+
// remains the place to tweak step on the fly.
|
|
187
|
+
const savedStep = isKpi
|
|
188
|
+
? 'all'
|
|
189
|
+
: initial && initial.step !== 'all'
|
|
190
|
+
? initial.step
|
|
191
|
+
: 'day'
|
|
192
|
+
const candidate: ChartDefInput = {
|
|
193
|
+
id: initial?.id ?? uuidv7(),
|
|
194
|
+
title: title.trim() || resource?.name || resourceId,
|
|
195
|
+
resource: resourceId,
|
|
196
|
+
visualisation,
|
|
197
|
+
dateField,
|
|
198
|
+
step: savedStep,
|
|
199
|
+
metric,
|
|
200
|
+
width: initial?.width ?? 'half',
|
|
201
|
+
topN,
|
|
202
|
+
filters,
|
|
203
|
+
quickFilters: quickFilters.filter((p) => properties.some((q) => q.path === p)),
|
|
204
|
+
timeRange: buildTimeRange(),
|
|
205
|
+
order,
|
|
206
|
+
// Preserve groupId on edit — the builder does not (yet) change group
|
|
207
|
+
// membership directly; groups are managed from the dashboard tab strip.
|
|
208
|
+
...(initial?.groupId ? { groupId: initial.groupId } : {}),
|
|
209
|
+
createdAt: initial?.createdAt ?? now,
|
|
210
|
+
updatedAt: now,
|
|
211
|
+
...(metric !== 'count' && field ? { field } : {}),
|
|
212
|
+
...(!isKpi && groupBy ? { groupBy } : {}),
|
|
213
|
+
...(!isKpi && groupBy && groupByLabelResource ? { groupByLabelResource } : {}),
|
|
214
|
+
}
|
|
215
|
+
const result = chartDefZ.safeParse(candidate)
|
|
216
|
+
if (!result.success) {
|
|
217
|
+
const fieldErrs: Record<string, string> = {}
|
|
218
|
+
for (const issue of result.error.issues) {
|
|
219
|
+
const key = issue.path[0]
|
|
220
|
+
if (typeof key === 'string' && !fieldErrs[key]) fieldErrs[key] = issue.message
|
|
221
|
+
}
|
|
222
|
+
setErrors(fieldErrs)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
setErrors({})
|
|
226
|
+
onSave(candidate)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<Dialog open onOpenChange={(open) => { if (!open) onClose() }}>
|
|
231
|
+
<DialogContent className="w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
232
|
+
<DialogHeader>
|
|
233
|
+
<DialogTitle>
|
|
234
|
+
{initial ? t('chart:editChart') : t('chart:newChart')}
|
|
235
|
+
</DialogTitle>
|
|
236
|
+
</DialogHeader>
|
|
237
|
+
|
|
238
|
+
<div className="space-y-4 py-1">
|
|
239
|
+
{/* Title + order */}
|
|
240
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_8rem]">
|
|
241
|
+
<div className="space-y-1.5">
|
|
242
|
+
<Label htmlFor="chart-title">{t('chart:title')}</Label>
|
|
243
|
+
<Input
|
|
244
|
+
id="chart-title"
|
|
245
|
+
placeholder={t('chart:titlePlaceholder')}
|
|
246
|
+
value={title}
|
|
247
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
248
|
+
/>
|
|
249
|
+
</div>
|
|
250
|
+
<div className="space-y-1.5">
|
|
251
|
+
<div className="flex items-center gap-1.5">
|
|
252
|
+
<Label htmlFor="chart-order">{t('chart:order')}</Label>
|
|
253
|
+
<InfoTooltip content={t('chart:orderHint')} />
|
|
254
|
+
</div>
|
|
255
|
+
<Input
|
|
256
|
+
id="chart-order"
|
|
257
|
+
type="number"
|
|
258
|
+
step={1}
|
|
259
|
+
value={order}
|
|
260
|
+
onChange={(e) => setOrder(Number.isFinite(Number(e.target.value)) ? Math.trunc(Number(e.target.value)) : 0)}
|
|
261
|
+
/>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
{/* Visualisation (incl. KPI) */}
|
|
266
|
+
<div className="space-y-1.5">
|
|
267
|
+
<Label>{t('dashboard:vis')}</Label>
|
|
268
|
+
<div className="flex flex-wrap gap-2">
|
|
269
|
+
{VIS_OPTIONS.map(({ value, icon, labelKey }) => (
|
|
270
|
+
<Button
|
|
271
|
+
key={value}
|
|
272
|
+
type="button"
|
|
273
|
+
variant={visualisation === value ? 'default' : 'outline'}
|
|
274
|
+
size="sm"
|
|
275
|
+
className="flex-1 min-w-[5rem] gap-1.5 capitalize"
|
|
276
|
+
onClick={() => setVisualisation(value)}
|
|
277
|
+
>
|
|
278
|
+
{icon}
|
|
279
|
+
<span className="hidden sm:inline">{t(labelKey)}</span>
|
|
280
|
+
</Button>
|
|
281
|
+
))}
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
{/* Resource */}
|
|
286
|
+
<div className="space-y-1.5">
|
|
287
|
+
<Label htmlFor="chart-resource">{t('chart:resource')}</Label>
|
|
288
|
+
<Select value={resourceId} onValueChange={(v) => setResourceId(v)}>
|
|
289
|
+
<SelectTrigger id="chart-resource">
|
|
290
|
+
<SelectValue />
|
|
291
|
+
</SelectTrigger>
|
|
292
|
+
<SelectContent>
|
|
293
|
+
{resources.map((r) => (
|
|
294
|
+
<SelectItem key={r.id} value={r.id}>
|
|
295
|
+
{r.name}
|
|
296
|
+
{r.name !== r.id && (
|
|
297
|
+
<span className="ml-1.5 text-xs text-muted-foreground">({r.id})</span>
|
|
298
|
+
)}
|
|
299
|
+
</SelectItem>
|
|
300
|
+
))}
|
|
301
|
+
</SelectContent>
|
|
302
|
+
</Select>
|
|
303
|
+
{errors.resource && (
|
|
304
|
+
<p className="text-xs text-destructive">{errors.resource}</p>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
{/* Date field — required, drives the X-axis bucketing */}
|
|
309
|
+
<div className="space-y-1.5">
|
|
310
|
+
<Label htmlFor="chart-datefield">{t('dashboard:builder.dateField')}</Label>
|
|
311
|
+
<Select
|
|
312
|
+
value={dateField || NONE}
|
|
313
|
+
onValueChange={(v) => setDateField(v === NONE ? '' : v)}
|
|
314
|
+
>
|
|
315
|
+
<SelectTrigger id="chart-datefield">
|
|
316
|
+
<SelectValue placeholder={t('chart:selectField')} />
|
|
317
|
+
</SelectTrigger>
|
|
318
|
+
<SelectContent>
|
|
319
|
+
<SelectItem value={NONE}>{t('chart:selectField')}</SelectItem>
|
|
320
|
+
{(dateProps.length > 0 ? dateProps : properties).map((p) => (
|
|
321
|
+
<SelectItem key={p.path} value={p.path}>{p.label}</SelectItem>
|
|
322
|
+
))}
|
|
323
|
+
</SelectContent>
|
|
324
|
+
</Select>
|
|
325
|
+
<p className="text-xs text-muted-foreground">
|
|
326
|
+
{t('dashboard:builder.dateFieldHint')}
|
|
327
|
+
</p>
|
|
328
|
+
{errors.dateField && (
|
|
329
|
+
<p className="text-xs text-destructive">{errors.dateField}</p>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
{/* Metric + field */}
|
|
334
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
335
|
+
<div className="space-y-1.5">
|
|
336
|
+
<Label htmlFor="chart-metric">{t('chart:metric')}</Label>
|
|
337
|
+
<Select value={metric} onValueChange={(v) => setMetric(v as AggregationOpName)}>
|
|
338
|
+
<SelectTrigger id="chart-metric">
|
|
339
|
+
<SelectValue />
|
|
340
|
+
</SelectTrigger>
|
|
341
|
+
<SelectContent>
|
|
342
|
+
{METRICS.map((m) => (
|
|
343
|
+
<SelectItem key={m} value={m}>{t(`dashboard:metric${cap(m)}`)}</SelectItem>
|
|
344
|
+
))}
|
|
345
|
+
</SelectContent>
|
|
346
|
+
</Select>
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
{metric !== 'count' && (
|
|
350
|
+
<div className="space-y-1.5">
|
|
351
|
+
<Label htmlFor="chart-field">{t('chart:aggregateField')}</Label>
|
|
352
|
+
<Select
|
|
353
|
+
value={field || NONE}
|
|
354
|
+
onValueChange={(v) => setField(v === NONE ? '' : v)}
|
|
355
|
+
>
|
|
356
|
+
<SelectTrigger id="chart-field">
|
|
357
|
+
<SelectValue placeholder={t('chart:selectField')} />
|
|
358
|
+
</SelectTrigger>
|
|
359
|
+
<SelectContent>
|
|
360
|
+
<SelectItem value={NONE}>{t('chart:selectField')}</SelectItem>
|
|
361
|
+
{numericProps.map((p) => (
|
|
362
|
+
<SelectItem key={p.path} value={p.path}>{p.label}</SelectItem>
|
|
363
|
+
))}
|
|
364
|
+
</SelectContent>
|
|
365
|
+
</Select>
|
|
366
|
+
{errors.field && (
|
|
367
|
+
<p className="text-xs text-destructive">{errors.field}</p>
|
|
368
|
+
)}
|
|
369
|
+
</div>
|
|
370
|
+
)}
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
{/* Secondary groupBy + topN — non-KPI only */}
|
|
374
|
+
{!isKpi && (
|
|
375
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
376
|
+
<div className="space-y-1.5">
|
|
377
|
+
<Label htmlFor="chart-groupby">
|
|
378
|
+
{t('dashboard:builder.secondaryGroupBy')}
|
|
379
|
+
</Label>
|
|
380
|
+
<Select
|
|
381
|
+
value={groupBy || NONE}
|
|
382
|
+
onValueChange={(v) => {
|
|
383
|
+
const path = v === NONE ? '' : v
|
|
384
|
+
setGroupBy(path)
|
|
385
|
+
setGroupByLabelResource(resolveGroupByLabelResource(path, properties))
|
|
386
|
+
}}
|
|
387
|
+
>
|
|
388
|
+
<SelectTrigger id="chart-groupby">
|
|
389
|
+
<SelectValue />
|
|
390
|
+
</SelectTrigger>
|
|
391
|
+
<SelectContent>
|
|
392
|
+
<SelectItem value={NONE}>{t('dashboard:builder.noBreakdown')}</SelectItem>
|
|
393
|
+
{properties
|
|
394
|
+
.filter((p) => isGroupable(p) && p.path !== dateField)
|
|
395
|
+
.map((p) => (
|
|
396
|
+
<SelectItem key={p.path} value={p.path}>{p.label}</SelectItem>
|
|
397
|
+
))}
|
|
398
|
+
</SelectContent>
|
|
399
|
+
</Select>
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
{groupBy && (
|
|
403
|
+
<div className="space-y-1.5">
|
|
404
|
+
<Label htmlFor="chart-topn">{t('dashboard:builder.topN')}</Label>
|
|
405
|
+
<Input
|
|
406
|
+
id="chart-topn"
|
|
407
|
+
type="number"
|
|
408
|
+
min={1}
|
|
409
|
+
max={50}
|
|
410
|
+
value={topN}
|
|
411
|
+
onChange={(e) =>
|
|
412
|
+
setTopN(Math.max(1, Math.min(50, Number(e.target.value) || 10)))
|
|
413
|
+
}
|
|
414
|
+
/>
|
|
415
|
+
</div>
|
|
416
|
+
)}
|
|
417
|
+
</div>
|
|
418
|
+
)}
|
|
419
|
+
|
|
420
|
+
{/* Default time-range preset. Custom ranges are picked on the
|
|
421
|
+
widget toolbar — not in the builder. */}
|
|
422
|
+
<div className="space-y-1.5">
|
|
423
|
+
<Label>{t('dashboard:builder.range')}</Label>
|
|
424
|
+
<div className="flex flex-wrap gap-2">
|
|
425
|
+
{PRESETS.map((p) => (
|
|
426
|
+
<Button
|
|
427
|
+
key={p}
|
|
428
|
+
type="button"
|
|
429
|
+
variant={preset === p ? 'default' : 'outline'}
|
|
430
|
+
size="sm"
|
|
431
|
+
className="flex-1 min-w-[4rem]"
|
|
432
|
+
onClick={() => setPreset(p)}
|
|
433
|
+
>
|
|
434
|
+
{t(`dashboard:range.${p}`)}
|
|
435
|
+
</Button>
|
|
436
|
+
))}
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
|
|
440
|
+
{/* Filters — one row per property, with a checkbox to mark the
|
|
441
|
+
filter as a "quick filter" (exposed above the chart for inline
|
|
442
|
+
tweaking on the dashboard). Reference fields use a combobox. */}
|
|
443
|
+
{properties.length > 0 && (
|
|
444
|
+
<div className="space-y-1.5">
|
|
445
|
+
<Label>{t('dashboard:builder.filters')}</Label>
|
|
446
|
+
<p className="text-xs text-muted-foreground">
|
|
447
|
+
{t('dashboard:builder.filtersHint')}
|
|
448
|
+
</p>
|
|
449
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-3">
|
|
450
|
+
{properties.filter(isFilterable).map((p) => {
|
|
451
|
+
const exposed = quickFilters.includes(p.path)
|
|
452
|
+
return (
|
|
453
|
+
<div key={p.path} className="space-y-1">
|
|
454
|
+
<div className="flex items-center justify-between gap-2">
|
|
455
|
+
<Label
|
|
456
|
+
htmlFor={`flt-${p.path}`}
|
|
457
|
+
className="text-xs text-muted-foreground"
|
|
458
|
+
>
|
|
459
|
+
{p.label}
|
|
460
|
+
</Label>
|
|
461
|
+
<Switch
|
|
462
|
+
checked={exposed}
|
|
463
|
+
onCheckedChange={() => toggleQuickFilter(p.path)}
|
|
464
|
+
aria-label={t('dashboard:builder.quickFilterToggle').replace(
|
|
465
|
+
'{field}',
|
|
466
|
+
p.label,
|
|
467
|
+
)}
|
|
468
|
+
title={t('dashboard:builder.quickFilterHint')}
|
|
469
|
+
/>
|
|
470
|
+
</div>
|
|
471
|
+
<FilterInput
|
|
472
|
+
property={p}
|
|
473
|
+
value={filters[p.path] ?? ''}
|
|
474
|
+
onChange={(v) => handleFilterChange(p.path, v)}
|
|
475
|
+
/>
|
|
476
|
+
</div>
|
|
477
|
+
)
|
|
478
|
+
})}
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
)}
|
|
482
|
+
</div>
|
|
483
|
+
|
|
484
|
+
<DialogFooter>
|
|
485
|
+
<Button variant="outline" onClick={onClose}>{t('common:cancel')}</Button>
|
|
486
|
+
<Button onClick={handleSave} disabled={!resourceId || !dateField}>
|
|
487
|
+
{t('chart:saveChart')}
|
|
488
|
+
</Button>
|
|
489
|
+
</DialogFooter>
|
|
490
|
+
</DialogContent>
|
|
491
|
+
</Dialog>
|
|
492
|
+
)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Property is exposable as a filter. Excludes ids, array fields, free-form
|
|
499
|
+
* blobs (json/mixed/richtext), media fields, and virtual relation columns
|
|
500
|
+
* (we keep the underlying FK column so we don't list `authorId` and `author`
|
|
501
|
+
* twice).
|
|
502
|
+
*/
|
|
503
|
+
function isFilterable(p: PropertyJSON): boolean {
|
|
504
|
+
if (p.isId) return false
|
|
505
|
+
if (p.isArray) return false
|
|
506
|
+
if (p.type === 'json' || p.type === 'mixed') return false
|
|
507
|
+
if (p.type === 'richtext' || p.type === 'markdown' || p.type === 'textarea') return false
|
|
508
|
+
if (p.type === 'previewMedia' || p.type === 'file') return false
|
|
509
|
+
// Drop virtual relation columns (full objects) — keep FK siblings instead.
|
|
510
|
+
if (p.type === 'reference' && !p.isArray) {
|
|
511
|
+
const path = p.path
|
|
512
|
+
if (!(path.endsWith('Id') || path.endsWith('_id'))) return false
|
|
513
|
+
}
|
|
514
|
+
return true
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Renders an inline input for one filter row in the builder. Reference
|
|
519
|
+
* properties get the same combobox the resource forms use; enums/booleans
|
|
520
|
+
* get a Select; numerics get a number input; everything else falls back to
|
|
521
|
+
* a plain text input.
|
|
522
|
+
*/
|
|
523
|
+
function FilterInput({
|
|
524
|
+
property,
|
|
525
|
+
value,
|
|
526
|
+
onChange,
|
|
527
|
+
}: {
|
|
528
|
+
property: PropertyJSON
|
|
529
|
+
value: string
|
|
530
|
+
onChange(next: string): void
|
|
531
|
+
}): React.ReactElement {
|
|
532
|
+
if (property.reference) {
|
|
533
|
+
return (
|
|
534
|
+
<ReferenceCombobox
|
|
535
|
+
referenceResourceId={property.reference}
|
|
536
|
+
value={value || null}
|
|
537
|
+
onChange={(next) => onChange(next == null ? '' : String(next))}
|
|
538
|
+
/>
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
if (property.availableValues && property.availableValues.length > 0) {
|
|
542
|
+
return (
|
|
543
|
+
<Select
|
|
544
|
+
value={value || NONE}
|
|
545
|
+
onValueChange={(v) => onChange(v === NONE ? '' : v)}
|
|
546
|
+
>
|
|
547
|
+
<SelectTrigger id={`flt-${property.path}`}>
|
|
548
|
+
<SelectValue />
|
|
549
|
+
</SelectTrigger>
|
|
550
|
+
<SelectContent>
|
|
551
|
+
<SelectItem value={NONE}>—</SelectItem>
|
|
552
|
+
{property.availableValues.map((opt) => (
|
|
553
|
+
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
|
554
|
+
))}
|
|
555
|
+
</SelectContent>
|
|
556
|
+
</Select>
|
|
557
|
+
)
|
|
558
|
+
}
|
|
559
|
+
if (property.type === 'boolean') {
|
|
560
|
+
return (
|
|
561
|
+
<Select
|
|
562
|
+
value={value || NONE}
|
|
563
|
+
onValueChange={(v) => onChange(v === NONE ? '' : v)}
|
|
564
|
+
>
|
|
565
|
+
<SelectTrigger id={`flt-${property.path}`}>
|
|
566
|
+
<SelectValue />
|
|
567
|
+
</SelectTrigger>
|
|
568
|
+
<SelectContent>
|
|
569
|
+
<SelectItem value={NONE}>—</SelectItem>
|
|
570
|
+
<SelectItem value="true">true</SelectItem>
|
|
571
|
+
<SelectItem value="false">false</SelectItem>
|
|
572
|
+
</SelectContent>
|
|
573
|
+
</Select>
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
const isNumeric =
|
|
577
|
+
property.type === 'number' || property.type === 'float' || property.type === 'currency'
|
|
578
|
+
return (
|
|
579
|
+
<Input
|
|
580
|
+
id={`flt-${property.path}`}
|
|
581
|
+
type={isNumeric ? 'number' : 'text'}
|
|
582
|
+
value={value}
|
|
583
|
+
onChange={(e) => onChange(e.target.value)}
|
|
584
|
+
/>
|
|
585
|
+
)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Resolve which resource to use for groupBy label resolution.
|
|
590
|
+
*
|
|
591
|
+
* 1. If the property itself is `type: 'reference'` (virtual relation field),
|
|
592
|
+
* use its `reference` resource id directly — same mechanism as the record
|
|
593
|
+
* title display in resource forms.
|
|
594
|
+
* 2. If it is a raw FK column (e.g. `authorId`), look for a sibling property
|
|
595
|
+
* whose path is the FK name without the trailing `Id`/`_id` suffix AND
|
|
596
|
+
* has `reference` set. Covers the Prisma/Drizzle naming convention.
|
|
597
|
+
*/
|
|
598
|
+
function resolveGroupByLabelResource(
|
|
599
|
+
path: string,
|
|
600
|
+
properties: ReadonlyArray<{ path: string; type: string; reference: string | null }>,
|
|
601
|
+
): string {
|
|
602
|
+
if (!path) return ''
|
|
603
|
+
const prop = properties.find((p) => p.path === path)
|
|
604
|
+
if (!prop) return ''
|
|
605
|
+
// Direct reference property (virtual relation).
|
|
606
|
+
if (prop.reference) return prop.reference
|
|
607
|
+
// FK column heuristic: strip Id / _id suffix and look for sibling.
|
|
608
|
+
const base = prop.path.endsWith('_id')
|
|
609
|
+
? prop.path.slice(0, -3)
|
|
610
|
+
: prop.path.endsWith('Id')
|
|
611
|
+
? prop.path.slice(0, -2)
|
|
612
|
+
: ''
|
|
613
|
+
if (base) {
|
|
614
|
+
const sibling = properties.find((p) => p.path === base && p.reference)
|
|
615
|
+
if (sibling?.reference) return sibling.reference
|
|
616
|
+
}
|
|
617
|
+
return ''
|
|
618
|
+
}
|