@fayz-ai/ui 0.9.0-next.1 → 0.10.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/README.md +2 -2
- package/dist/analytics/AnalyticsCard.d.ts +10 -0
- package/dist/analytics/AnalyticsCard.d.ts.map +1 -0
- package/dist/analytics/AnalyticsControls.d.ts +18 -0
- package/dist/analytics/AnalyticsControls.d.ts.map +1 -0
- package/dist/analytics/AnalyticsGrid.d.ts +16 -0
- package/dist/analytics/AnalyticsGrid.d.ts.map +1 -0
- package/dist/analytics/context.d.ts +58 -0
- package/dist/analytics/context.d.ts.map +1 -0
- package/dist/analytics/derive.d.ts +81 -0
- package/dist/analytics/derive.d.ts.map +1 -0
- package/dist/analytics/index.d.ts +13 -0
- package/dist/analytics/index.d.ts.map +1 -0
- package/dist/analytics/range.d.ts +22 -0
- package/dist/analytics/range.d.ts.map +1 -0
- package/dist/{chunk-PVIB724L.js → chunk-22EVFMS3.js} +5 -3
- package/dist/chunk-22EVFMS3.js.map +1 -0
- package/dist/{chunk-G6OKZ7WL.js → chunk-6V5MGYXH.js} +13 -9
- package/dist/chunk-6V5MGYXH.js.map +1 -0
- package/dist/{chunk-KV3MHB6N.js → chunk-H63TOSQ7.js} +196 -53
- package/dist/chunk-H63TOSQ7.js.map +1 -0
- package/dist/{chunk-OUPJDGKE.js → chunk-I6CPKAOD.js} +134 -37
- package/dist/chunk-I6CPKAOD.js.map +1 -0
- package/dist/{chunk-YRVE4FOD.js → chunk-OKZ6WQB3.js} +3 -3
- package/dist/chunk-OKZ6WQB3.js.map +1 -0
- package/dist/{chunk-S5KNM4D2.js → chunk-SEIY2F5F.js} +235 -107
- package/dist/chunk-SEIY2F5F.js.map +1 -0
- package/dist/{chunk-75EFDLST.js → chunk-VAKWUUVC.js} +157 -6
- package/dist/chunk-VAKWUUVC.js.map +1 -0
- package/dist/dashboard/OnboardingWidget.d.ts +2 -14
- package/dist/dashboard/OnboardingWidget.d.ts.map +1 -1
- package/dist/dashboard/SidebarOnboarding.d.ts +28 -0
- package/dist/dashboard/SidebarOnboarding.d.ts.map +1 -0
- package/dist/dashboard/index.d.ts +2 -0
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +4 -4
- package/dist/dashboard/types.d.ts +18 -0
- package/dist/dashboard/types.d.ts.map +1 -1
- package/dist/dashboard/useOnboardingProgress.d.ts +28 -0
- package/dist/dashboard/useOnboardingProgress.d.ts.map +1 -0
- package/dist/icons.js +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +960 -14
- package/dist/index.js.map +1 -1
- package/dist/layout/AppShell.d.ts +14 -1
- package/dist/layout/AppShell.d.ts.map +1 -1
- package/dist/layout/ModulePage.d.ts +11 -2
- package/dist/layout/ModulePage.d.ts.map +1 -1
- package/dist/layout/Sidebar.d.ts +8 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/index.js +4 -4
- package/dist/lib/cn.d.ts.map +1 -0
- package/dist/preset.d.ts +3 -0
- package/dist/preset.d.ts.map +1 -0
- package/dist/primitives/data-table.d.ts +19 -4
- package/dist/primitives/data-table.d.ts.map +1 -1
- package/dist/primitives/date-picker.d.ts +4 -2
- package/dist/primitives/date-picker.d.ts.map +1 -1
- package/dist/primitives/filter-chip.d.ts +26 -0
- package/dist/primitives/filter-chip.d.ts.map +1 -0
- package/dist/primitives/header-tabs.d.ts +32 -0
- package/dist/primitives/header-tabs.d.ts.map +1 -0
- package/dist/primitives/hint.d.ts +25 -0
- package/dist/primitives/hint.d.ts.map +1 -0
- package/dist/primitives/index.d.ts +7 -0
- package/dist/primitives/index.d.ts.map +1 -1
- package/dist/primitives/index.js +4 -4
- package/dist/primitives/list-view.d.ts +14 -1
- package/dist/primitives/list-view.d.ts.map +1 -1
- package/dist/primitives/page-header.d.ts +23 -0
- package/dist/primitives/page-header.d.ts.map +1 -0
- package/dist/primitives/stat-tiles.d.ts +30 -0
- package/dist/primitives/stat-tiles.d.ts.map +1 -0
- package/dist/primitives/switch.d.ts +11 -0
- package/dist/primitives/switch.d.ts.map +1 -0
- package/dist/stores/index.d.ts +1 -0
- package/dist/stores/index.d.ts.map +1 -1
- package/dist/stores/page-header.store.d.ts +69 -0
- package/dist/stores/page-header.store.d.ts.map +1 -0
- package/package.json +4 -13
- package/dist/chunk-75EFDLST.js.map +0 -1
- package/dist/chunk-G6OKZ7WL.js.map +0 -1
- package/dist/chunk-KV3MHB6N.js.map +0 -1
- package/dist/chunk-OUPJDGKE.js.map +0 -1
- package/dist/chunk-PVIB724L.js.map +0 -1
- package/dist/chunk-S5KNM4D2.js.map +0 -1
- package/dist/chunk-YRVE4FOD.js.map +0 -1
- package/dist/utils/cn.d.ts.map +0 -1
- package/src/dashboard/ChartWidget.tsx +0 -50
- package/src/dashboard/ChartWidgetInner.tsx +0 -83
- package/src/dashboard/DashboardCanvas.tsx +0 -210
- package/src/dashboard/DashboardCustomizeMenu.tsx +0 -53
- package/src/dashboard/DashboardGrid.tsx +0 -35
- package/src/dashboard/DashboardRange.tsx +0 -62
- package/src/dashboard/GoldCard.tsx +0 -38
- package/src/dashboard/KpiCard.tsx +0 -238
- package/src/dashboard/OnboardingWidget.tsx +0 -140
- package/src/dashboard/TableWidget.tsx +0 -51
- package/src/dashboard/define.ts +0 -53
- package/src/dashboard/icon.ts +0 -13
- package/src/dashboard/index.ts +0 -38
- package/src/dashboard/layout.ts +0 -50
- package/src/dashboard/preferences.ts +0 -71
- package/src/dashboard/types.ts +0 -41
- package/src/icons.ts +0 -66
- package/src/index.ts +0 -238
- package/src/layout/AppShell.tsx +0 -987
- package/src/layout/ContentSplit.tsx +0 -153
- package/src/layout/ModulePage.tsx +0 -466
- package/src/layout/PageTransition.tsx +0 -57
- package/src/layout/SaveBar.tsx +0 -247
- package/src/layout/Sidebar.tsx +0 -535
- package/src/layout/Topbar.tsx +0 -462
- package/src/layout/index.ts +0 -39
- package/src/layout/useCrossModuleBack.ts +0 -30
- package/src/primitives/Avatar.tsx +0 -48
- package/src/primitives/SearchCombobox.tsx +0 -216
- package/src/primitives/badge.tsx +0 -68
- package/src/primitives/breadcrumb.tsx +0 -43
- package/src/primitives/button.tsx +0 -60
- package/src/primitives/card.tsx +0 -79
- package/src/primitives/checkbox.tsx +0 -53
- package/src/primitives/confirm-dialog.tsx +0 -125
- package/src/primitives/currency-input.tsx +0 -121
- package/src/primitives/data-table.tsx +0 -160
- package/src/primitives/date-picker.tsx +0 -225
- package/src/primitives/dropdown.tsx +0 -196
- package/src/primitives/index.ts +0 -87
- package/src/primitives/input.tsx +0 -50
- package/src/primitives/list-view.tsx +0 -252
- package/src/primitives/markdown-editor.tsx +0 -75
- package/src/primitives/markdown.tsx +0 -169
- package/src/primitives/modal.tsx +0 -156
- package/src/primitives/phone-link.tsx +0 -138
- package/src/primitives/popover.tsx +0 -30
- package/src/primitives/search-select.tsx +0 -314
- package/src/primitives/segmented-control.tsx +0 -66
- package/src/primitives/select.tsx +0 -157
- package/src/primitives/separator.tsx +0 -29
- package/src/primitives/sheet.tsx +0 -181
- package/src/primitives/skeleton.tsx +0 -46
- package/src/primitives/tabs.tsx +0 -53
- package/src/primitives/time-picker.tsx +0 -148
- package/src/primitives/toast.tsx +0 -46
- package/src/primitives/tooltip.tsx +0 -28
- package/src/stores/index.ts +0 -10
- package/src/stores/layout.store.ts +0 -31
- package/src/stores/theme.store.ts +0 -457
- package/src/styles.css +0 -461
- package/src/tailwind.ts +0 -43
- package/src/theme/index.ts +0 -94
- package/src/theme/preset.ts +0 -209
- package/src/theme/presets.ts +0 -106
- package/src/utils/cn.ts +0 -6
- /package/dist/{utils → lib}/cn.d.ts +0 -0
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
import * as React from 'react'
|
|
2
|
-
import { EyeOff } from 'lucide-react'
|
|
3
|
-
import {
|
|
4
|
-
usePluginRuntime,
|
|
5
|
-
getDashboardWidgets,
|
|
6
|
-
resolvePluginComponent,
|
|
7
|
-
hashRouterAdapter,
|
|
8
|
-
type DashboardLayoutConfig,
|
|
9
|
-
type DashboardSurface,
|
|
10
|
-
type DashboardWidgetDef,
|
|
11
|
-
} from '@fayz-ai/core'
|
|
12
|
-
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../primitives/card'
|
|
13
|
-
import { DashboardGrid, type DashboardGridItem } from './DashboardGrid'
|
|
14
|
-
import { resolveDashboardLayout, type LaidOutWidget } from './layout'
|
|
15
|
-
import { useDashboardPreferences } from './preferences'
|
|
16
|
-
import { DashboardRangeProvider, DashboardRangeControl, type DashboardRange } from './DashboardRange'
|
|
17
|
-
import { DashboardCustomizeMenu } from './DashboardCustomizeMenu'
|
|
18
|
-
|
|
19
|
-
/** Wrap a widget so a hide button fades in on hover (top-right corner). */
|
|
20
|
-
function HoverHide({ id, onHide, children }: { id: string; onHide?: (id: string) => void; children: React.ReactNode }) {
|
|
21
|
-
if (!onHide) return <>{children}</>
|
|
22
|
-
return (
|
|
23
|
-
<div className="group relative h-full">
|
|
24
|
-
{children}
|
|
25
|
-
<button
|
|
26
|
-
type="button"
|
|
27
|
-
onClick={() => onHide(id)}
|
|
28
|
-
title="Hide widget"
|
|
29
|
-
aria-label="Hide widget"
|
|
30
|
-
className="absolute right-2 top-2 z-10 inline-flex h-6 w-6 items-center justify-center rounded-md border border-border bg-background/90 text-muted-foreground opacity-0 shadow-sm transition-opacity hover:text-foreground group-hover:opacity-100"
|
|
31
|
-
>
|
|
32
|
-
<EyeOff className="h-3.5 w-3.5" />
|
|
33
|
-
</button>
|
|
34
|
-
</div>
|
|
35
|
-
)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function toGridItems(laidOut: LaidOutWidget[], onHide?: (id: string) => void): DashboardGridItem[] {
|
|
39
|
-
return laidOut.map(({ def, span }) => {
|
|
40
|
-
const Component = resolvePluginComponent(def)
|
|
41
|
-
const node = Component ? <Component {...(def.props ?? {})} /> : null
|
|
42
|
-
return { id: def.id, span, node: <HoverHide id={def.id} onHide={onHide}>{node}</HoverHide> }
|
|
43
|
-
})
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
// Navigation — widgets (e.g. onboarding) call useDashboardNavigate() to route.
|
|
48
|
-
// Defaults to the shared hash router so widgets work without explicit wiring.
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
|
|
51
|
-
const DashboardNavigateContext = React.createContext<(path: string) => void>((path) =>
|
|
52
|
-
hashRouterAdapter().navigate(path),
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
export const DashboardNavigateProvider = DashboardNavigateContext.Provider
|
|
56
|
-
|
|
57
|
-
export function useDashboardNavigate(): (path: string) => void {
|
|
58
|
-
return React.useContext(DashboardNavigateContext)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
// Shared widget grid — used by DashboardCanvas (registry) and by standalone
|
|
63
|
-
// callers (e.g. the dashboard plugin's legacy DashboardPage shim). Resolves the
|
|
64
|
-
// layout, then renders each widget by span.
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
|
|
67
|
-
export function WidgetGrid({ widgets, appLayout, surface }: {
|
|
68
|
-
widgets: DashboardWidgetDef[]
|
|
69
|
-
appLayout?: DashboardLayoutConfig
|
|
70
|
-
surface: DashboardSurface
|
|
71
|
-
}) {
|
|
72
|
-
const { prefs, setVisible } = useDashboardPreferences(surface)
|
|
73
|
-
const laidOut = resolveDashboardLayout(widgets, appLayout, prefs ?? undefined)
|
|
74
|
-
return <DashboardGrid items={toGridItems(laidOut, (id) => setVisible(id, false))} />
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ---------------------------------------------------------------------------
|
|
78
|
-
// DashboardCanvas — the registry-driven renderer. Serves the global app home
|
|
79
|
-
// (surface="home", all domains) and a plugin's own overview (surface="plugin-home"
|
|
80
|
-
// + domain). Widgets a plugin registers once appear on both.
|
|
81
|
-
// ---------------------------------------------------------------------------
|
|
82
|
-
|
|
83
|
-
export interface DashboardCanvasProps {
|
|
84
|
-
surface: DashboardSurface
|
|
85
|
-
/** Restrict to one plugin's widgets (used for plugin-home). */
|
|
86
|
-
domain?: string
|
|
87
|
-
title?: string
|
|
88
|
-
subtitle?: string
|
|
89
|
-
/** Render the in-content title/subtitle. Default: true. */
|
|
90
|
-
showHeader?: boolean
|
|
91
|
-
/** App-level curation of which widgets show, their order and span. */
|
|
92
|
-
appLayout?: DashboardLayoutConfig
|
|
93
|
-
/** App-level widgets injected into this surface's grid without a plugin — e.g.
|
|
94
|
-
* an "Ask the assistant" CTA. Merged with the registry widgets, then laid out
|
|
95
|
-
* (respects each widget's span/defaultOrder). Filtered to this surface. */
|
|
96
|
-
extraWidgets?: DashboardWidgetDef[]
|
|
97
|
-
/** Show a shared time-range control (sticky, top-right). Widgets read it via
|
|
98
|
-
* useDashboardRange(). Pass `true` for defaults or an options object. */
|
|
99
|
-
range?: boolean | DashboardRangeOptions
|
|
100
|
-
/** Show the "Customize" menu so users can choose which widgets appear
|
|
101
|
-
* (persisted per surface). Default: true. */
|
|
102
|
-
customizable?: boolean
|
|
103
|
-
/** Label for the customize menu trigger. */
|
|
104
|
-
customizeLabel?: string
|
|
105
|
-
/** Initial visibility before user prefs: 'default' honors each widget's
|
|
106
|
-
* `defaultVisible` (used by the global home to curate a starting set); 'all'
|
|
107
|
-
* shows every eligible widget. Defaults to 'all' on a plugin-home, 'default'
|
|
108
|
-
* on the global home. */
|
|
109
|
-
initialVisibility?: 'default' | 'all'
|
|
110
|
-
/** Route handler for widgets that navigate (defaults to the hash router). */
|
|
111
|
-
onNavigate?: (path: string) => void
|
|
112
|
-
className?: string
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export interface DashboardRangeOptions {
|
|
116
|
-
options?: DashboardRange[]
|
|
117
|
-
default?: DashboardRange
|
|
118
|
-
onChange?: (range: DashboardRange) => void
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function DashboardCanvas({
|
|
122
|
-
surface, domain, title, subtitle, showHeader = true, appLayout, extraWidgets, range,
|
|
123
|
-
customizable = true, customizeLabel, initialVisibility, onNavigate, className,
|
|
124
|
-
}: DashboardCanvasProps) {
|
|
125
|
-
const runtime = usePluginRuntime()
|
|
126
|
-
const registryWidgets = getDashboardWidgets(runtime, { surface, domain })
|
|
127
|
-
const widgets = React.useMemo<DashboardWidgetDef[]>(() => {
|
|
128
|
-
const extra = (extraWidgets ?? []).filter((w) => {
|
|
129
|
-
if (!(w.surfaces ?? ['home', 'plugin-home']).includes(surface)) return false
|
|
130
|
-
return !domain || w.domain === domain
|
|
131
|
-
})
|
|
132
|
-
return extra.length ? [...registryWidgets, ...extra] : registryWidgets
|
|
133
|
-
}, [registryWidgets, extraWidgets, surface, domain])
|
|
134
|
-
const rangeOpts: DashboardRangeOptions | null = range ? (range === true ? {} : range) : null
|
|
135
|
-
|
|
136
|
-
const forceAll = (initialVisibility ?? (surface === 'plugin-home' ? 'all' : 'default')) === 'all'
|
|
137
|
-
const { prefs, setVisible, reset } = useDashboardPreferences(surface, domain ?? 'home')
|
|
138
|
-
const laidOut = resolveDashboardLayout(widgets, appLayout, prefs ?? undefined, forceAll)
|
|
139
|
-
const visibleIds = React.useMemo(() => new Set(laidOut.map((w) => w.def.id)), [laidOut])
|
|
140
|
-
|
|
141
|
-
const hasControls = Boolean(rangeOpts) || (customizable && widgets.length > 0)
|
|
142
|
-
const headerBlock = showHeader && (title || subtitle) ? (
|
|
143
|
-
<div>
|
|
144
|
-
{title ? <h1 className="text-2xl font-semibold tracking-tight">{title}</h1> : null}
|
|
145
|
-
{subtitle ? <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p> : null}
|
|
146
|
-
</div>
|
|
147
|
-
) : null
|
|
148
|
-
|
|
149
|
-
// Controls sit inline on the header row (right-aligned) — no separate band, so
|
|
150
|
-
// there's no empty gap above the content.
|
|
151
|
-
const topRow = (headerBlock || hasControls) ? (
|
|
152
|
-
<div className="flex items-start justify-between gap-4">
|
|
153
|
-
{headerBlock ?? <span />}
|
|
154
|
-
{hasControls ? (
|
|
155
|
-
<div className="flex shrink-0 items-center gap-2">
|
|
156
|
-
{customizable && widgets.length > 0 ? (
|
|
157
|
-
<DashboardCustomizeMenu
|
|
158
|
-
widgets={widgets}
|
|
159
|
-
visibleIds={visibleIds}
|
|
160
|
-
onToggle={setVisible}
|
|
161
|
-
onReset={reset}
|
|
162
|
-
label={customizeLabel}
|
|
163
|
-
/>
|
|
164
|
-
) : null}
|
|
165
|
-
{rangeOpts ? <DashboardRangeControl /> : null}
|
|
166
|
-
</div>
|
|
167
|
-
) : null}
|
|
168
|
-
</div>
|
|
169
|
-
) : null
|
|
170
|
-
|
|
171
|
-
const content = (
|
|
172
|
-
<div className={className ?? 'space-y-6 p-6'}>
|
|
173
|
-
{topRow}
|
|
174
|
-
|
|
175
|
-
{widgets.length === 0 ? (
|
|
176
|
-
<Card>
|
|
177
|
-
<CardHeader>
|
|
178
|
-
<CardTitle>Welcome!</CardTitle>
|
|
179
|
-
<CardDescription>Your dashboard will show key metrics and information here.</CardDescription>
|
|
180
|
-
</CardHeader>
|
|
181
|
-
<CardContent>
|
|
182
|
-
<p className="text-sm text-muted-foreground">
|
|
183
|
-
Enable plugins or register dashboard widgets to populate this view.
|
|
184
|
-
</p>
|
|
185
|
-
</CardContent>
|
|
186
|
-
</Card>
|
|
187
|
-
) : laidOut.length === 0 ? (
|
|
188
|
-
<Card>
|
|
189
|
-
<CardContent className="p-6 text-sm text-muted-foreground">
|
|
190
|
-
All widgets are hidden. Use “Customize” to choose what to show.
|
|
191
|
-
</CardContent>
|
|
192
|
-
</Card>
|
|
193
|
-
) : (
|
|
194
|
-
<DashboardGrid items={toGridItems(laidOut)} />
|
|
195
|
-
)}
|
|
196
|
-
</div>
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
const navWrapped = onNavigate
|
|
200
|
-
? <DashboardNavigateProvider value={onNavigate}>{content}</DashboardNavigateProvider>
|
|
201
|
-
: content
|
|
202
|
-
|
|
203
|
-
return rangeOpts
|
|
204
|
-
? (
|
|
205
|
-
<DashboardRangeProvider options={rangeOpts.options} defaultRange={rangeOpts.default} onChange={rangeOpts.onChange}>
|
|
206
|
-
{navWrapped}
|
|
207
|
-
</DashboardRangeProvider>
|
|
208
|
-
)
|
|
209
|
-
: navWrapped
|
|
210
|
-
}
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import * as React from 'react'
|
|
2
|
-
import { SlidersHorizontal, RotateCcw } from 'lucide-react'
|
|
3
|
-
import type { DashboardWidgetDef } from '@fayz-ai/core'
|
|
4
|
-
import { useTranslation } from '@fayz-ai/core'
|
|
5
|
-
import { Button } from '../primitives/button'
|
|
6
|
-
import {
|
|
7
|
-
Dropdown, DropdownTrigger, DropdownContent, DropdownLabel, DropdownSeparator, DropdownCheckboxItem, DropdownItem,
|
|
8
|
-
} from '../primitives/dropdown'
|
|
9
|
-
|
|
10
|
-
export interface DashboardCustomizeMenuProps {
|
|
11
|
-
/** Full set of registered widgets for this surface. */
|
|
12
|
-
widgets: DashboardWidgetDef[]
|
|
13
|
-
/** Ids currently visible. */
|
|
14
|
-
visibleIds: Set<string>
|
|
15
|
-
onToggle: (widgetId: string, visible: boolean) => void
|
|
16
|
-
onReset: () => void
|
|
17
|
-
label?: string
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Gear menu to choose which widgets appear on the dashboard (persisted). */
|
|
21
|
-
export function DashboardCustomizeMenu({ widgets, visibleIds, onToggle, onReset, label }: DashboardCustomizeMenuProps) {
|
|
22
|
-
const t = useTranslation()
|
|
23
|
-
if (widgets.length === 0) return null
|
|
24
|
-
return (
|
|
25
|
-
<Dropdown>
|
|
26
|
-
<DropdownTrigger asChild>
|
|
27
|
-
<Button variant="outline" size="sm" className="gap-1.5">
|
|
28
|
-
<SlidersHorizontal className="h-3.5 w-3.5" />
|
|
29
|
-
{label ?? 'Customize'}
|
|
30
|
-
</Button>
|
|
31
|
-
</DropdownTrigger>
|
|
32
|
-
<DropdownContent align="end" className="max-h-[70vh] w-60 overflow-y-auto">
|
|
33
|
-
<DropdownLabel>{label ?? 'Show widgets'}</DropdownLabel>
|
|
34
|
-
<DropdownSeparator />
|
|
35
|
-
{widgets.map((w) => (
|
|
36
|
-
<DropdownCheckboxItem
|
|
37
|
-
key={w.id}
|
|
38
|
-
checked={visibleIds.has(w.id)}
|
|
39
|
-
onCheckedChange={(checked) => onToggle(w.id, Boolean(checked))}
|
|
40
|
-
onSelect={(e) => e.preventDefault()}
|
|
41
|
-
>
|
|
42
|
-
{t(w.title)}
|
|
43
|
-
</DropdownCheckboxItem>
|
|
44
|
-
))}
|
|
45
|
-
<DropdownSeparator />
|
|
46
|
-
<DropdownItem onSelect={() => onReset()}>
|
|
47
|
-
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
|
48
|
-
{'Reset to defaults'}
|
|
49
|
-
</DropdownItem>
|
|
50
|
-
</DropdownContent>
|
|
51
|
-
</Dropdown>
|
|
52
|
-
)
|
|
53
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import * as React from 'react'
|
|
2
|
-
import { cn } from '../utils/cn'
|
|
3
|
-
|
|
4
|
-
export interface DashboardGridItem {
|
|
5
|
-
id: string
|
|
6
|
-
/** Column span on the 4-col grid (defaults to 1). */
|
|
7
|
-
span?: number
|
|
8
|
-
node: React.ReactNode
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/** Literal span → Tailwind classes (kept literal so the JIT compiler picks them up). */
|
|
12
|
-
const SPAN_CLASS: Record<number, string> = {
|
|
13
|
-
1: '',
|
|
14
|
-
2: 'sm:col-span-2',
|
|
15
|
-
3: 'sm:col-span-2 lg:col-span-3',
|
|
16
|
-
4: 'sm:col-span-2 lg:col-span-4',
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface DashboardGridProps {
|
|
20
|
-
items: DashboardGridItem[]
|
|
21
|
-
className?: string
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Responsive 4-col dashboard grid — the layout every dashboard shares. */
|
|
25
|
-
export function DashboardGrid({ items, className }: DashboardGridProps) {
|
|
26
|
-
return (
|
|
27
|
-
<div className={cn('grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4', className)}>
|
|
28
|
-
{items.map((item) => (
|
|
29
|
-
<div key={item.id} className={SPAN_CLASS[Math.min(Math.max(item.span ?? 1, 1), 4)] ?? ''}>
|
|
30
|
-
{item.node}
|
|
31
|
-
</div>
|
|
32
|
-
))}
|
|
33
|
-
</div>
|
|
34
|
-
)
|
|
35
|
-
}
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import * as React from 'react'
|
|
2
|
-
import { SegmentedControl } from '../primitives/segmented-control'
|
|
3
|
-
|
|
4
|
-
// ---------------------------------------------------------------------------
|
|
5
|
-
// Shared dashboard time range. The canvas provides it; widgets read it via
|
|
6
|
-
// useDashboardRange() and refetch/filter their data accordingly.
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
|
|
9
|
-
export type DashboardRange = string
|
|
10
|
-
|
|
11
|
-
export const DEFAULT_RANGE_OPTIONS: DashboardRange[] = ['7d', '30d', '90d']
|
|
12
|
-
|
|
13
|
-
interface DashboardRangeContextValue {
|
|
14
|
-
range: DashboardRange
|
|
15
|
-
setRange: (range: DashboardRange) => void
|
|
16
|
-
options: DashboardRange[]
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const DashboardRangeContext = React.createContext<DashboardRangeContextValue | null>(null)
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Read the shared dashboard range. Returns null when no range control is
|
|
23
|
-
* mounted, so widgets can stay range-agnostic.
|
|
24
|
-
*/
|
|
25
|
-
export function useDashboardRange(): DashboardRangeContextValue | null {
|
|
26
|
-
return React.useContext(DashboardRangeContext)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface DashboardRangeProviderProps {
|
|
30
|
-
options?: DashboardRange[]
|
|
31
|
-
defaultRange?: DashboardRange
|
|
32
|
-
onChange?: (range: DashboardRange) => void
|
|
33
|
-
children: React.ReactNode
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function DashboardRangeProvider({
|
|
37
|
-
options = DEFAULT_RANGE_OPTIONS, defaultRange, onChange, children,
|
|
38
|
-
}: DashboardRangeProviderProps) {
|
|
39
|
-
const [range, setRangeState] = React.useState<DashboardRange>(defaultRange ?? options[1] ?? options[0] ?? '30d')
|
|
40
|
-
const setRange = React.useCallback((r: DashboardRange) => {
|
|
41
|
-
setRangeState(r)
|
|
42
|
-
onChange?.(r)
|
|
43
|
-
}, [onChange])
|
|
44
|
-
const value = React.useMemo(() => ({ range, setRange, options }), [range, setRange, options])
|
|
45
|
-
return <DashboardRangeContext.Provider value={value}>{children}</DashboardRangeContext.Provider>
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Segmented 7d / 30d / 90d control bound to the shared dashboard range.
|
|
49
|
-
* Thin wrapper over the canonical SegmentedControl primitive. */
|
|
50
|
-
export function DashboardRangeControl({ className }: { className?: string }) {
|
|
51
|
-
const ctx = useDashboardRange()
|
|
52
|
-
if (!ctx) return null
|
|
53
|
-
return (
|
|
54
|
-
<SegmentedControl
|
|
55
|
-
options={ctx.options}
|
|
56
|
-
value={ctx.range}
|
|
57
|
-
onChange={ctx.setRange}
|
|
58
|
-
className={className}
|
|
59
|
-
aria-label="Time range"
|
|
60
|
-
/>
|
|
61
|
-
)
|
|
62
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import * as React from 'react'
|
|
2
|
-
import { cn } from '../utils/cn'
|
|
3
|
-
|
|
4
|
-
export interface GoldCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
5
|
-
/**
|
|
6
|
-
* Brand-colored hero variant — `bg-primary text-primary-foreground` with a
|
|
7
|
-
* soft branded shadow. Use for the headline card (e.g. a balance hero). When
|
|
8
|
-
* false (default) it's a plain surface card (`bg-card` + border).
|
|
9
|
-
*/
|
|
10
|
-
branded?: boolean
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* GoldCard — a generic, reusable rounded card wrapper for dashboard surfaces.
|
|
15
|
-
*
|
|
16
|
-
* The `branded` variant paints the card in the app's primary color so a single
|
|
17
|
-
* hero (balance, headline KPI, CTA) pops against the neutral grid. Both variants
|
|
18
|
-
* are internally full-width so they reflow cleanly inside the responsive
|
|
19
|
-
* DashboardGrid (full-width on mobile, cell-width on desktop).
|
|
20
|
-
*/
|
|
21
|
-
export const GoldCard = React.forwardRef<HTMLDivElement, GoldCardProps>(
|
|
22
|
-
({ branded = false, className, children, ...rest }, ref) => (
|
|
23
|
-
<div
|
|
24
|
-
ref={ref}
|
|
25
|
-
className={cn(
|
|
26
|
-
'h-full rounded-3xl p-5',
|
|
27
|
-
branded
|
|
28
|
-
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/20'
|
|
29
|
-
: 'border border-border bg-card text-card-foreground shadow-sm',
|
|
30
|
-
className,
|
|
31
|
-
)}
|
|
32
|
-
{...rest}
|
|
33
|
-
>
|
|
34
|
-
{children}
|
|
35
|
-
</div>
|
|
36
|
-
),
|
|
37
|
-
)
|
|
38
|
-
GoldCard.displayName = 'GoldCard'
|
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
import * as React from 'react'
|
|
2
|
-
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
|
3
|
-
import { cn } from '../utils/cn'
|
|
4
|
-
import { Card, CardContent } from '../primitives/card'
|
|
5
|
-
import { renderIcon } from './icon'
|
|
6
|
-
import type { KpiCurrency, IconRef, KpiFormat, KpiValue, KpiTrend } from './types'
|
|
7
|
-
|
|
8
|
-
export function formatKpiValue(value: number, format?: KpiFormat, currency?: KpiCurrency): string {
|
|
9
|
-
const locale = currency?.locale ?? 'pt-BR'
|
|
10
|
-
switch (format) {
|
|
11
|
-
case 'currency':
|
|
12
|
-
return new Intl.NumberFormat(locale, { style: 'currency', currency: currency?.code ?? 'BRL', maximumFractionDigits: 0 }).format(value)
|
|
13
|
-
case 'percent':
|
|
14
|
-
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)}%`
|
|
15
|
-
case 'duration':
|
|
16
|
-
return `${Math.round(value)} min`
|
|
17
|
-
default:
|
|
18
|
-
return new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function deriveTrend(current: number, previous: number | undefined, explicit: KpiTrend | undefined): KpiTrend | undefined {
|
|
23
|
-
if (explicit) return explicit
|
|
24
|
-
if (previous == null) return undefined
|
|
25
|
-
return current > previous ? 'up' : current < previous ? 'down' : 'neutral'
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function TrendChip({ trend, delta, invert }: { trend: KpiTrend; delta: number | null; invert?: boolean }) {
|
|
29
|
-
if (trend === 'neutral') return null
|
|
30
|
-
const good = invert ? trend === 'down' : trend === 'up'
|
|
31
|
-
const Icon = trend === 'up' ? TrendingUp : TrendingDown
|
|
32
|
-
return (
|
|
33
|
-
<span className={cn(
|
|
34
|
-
'inline-flex items-center gap-0.5 text-xs font-medium',
|
|
35
|
-
good ? 'text-emerald-600' : 'text-rose-600',
|
|
36
|
-
)}>
|
|
37
|
-
<Icon className="h-3 w-3" />
|
|
38
|
-
{delta != null ? `${delta > 0 ? '+' : ''}${delta}%` : null}
|
|
39
|
-
</span>
|
|
40
|
-
)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Trend-aware accent: green when good, red when bad, theme-info when flat. */
|
|
44
|
-
function trendColor(trend: KpiTrend | undefined, invert?: boolean): string {
|
|
45
|
-
if (!trend || trend === 'neutral') return 'hsl(var(--info))'
|
|
46
|
-
const good = invert ? trend === 'down' : trend === 'up'
|
|
47
|
-
return good ? '#10b981' : '#f43f5e'
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Tiny dependency-free SVG sparkline (oldest → newest), with a soft area fill. */
|
|
51
|
-
function Sparkline({ data, color }: { data: number[]; color: string }) {
|
|
52
|
-
const gid = React.useId()
|
|
53
|
-
if (!data || data.length < 2) return null
|
|
54
|
-
const W = 96, H = 32, P = 3
|
|
55
|
-
const min = Math.min(...data)
|
|
56
|
-
const max = Math.max(...data)
|
|
57
|
-
const span = max - min || 1
|
|
58
|
-
const step = (W - P * 2) / (data.length - 1)
|
|
59
|
-
const pts = data.map((d, i) => {
|
|
60
|
-
const x = P + i * step
|
|
61
|
-
const y = P + (H - P * 2) * (1 - (d - min) / span)
|
|
62
|
-
return [x, y] as const
|
|
63
|
-
})
|
|
64
|
-
const line = pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ')
|
|
65
|
-
const area = `${line} L${pts[pts.length - 1][0].toFixed(1)},${H} L${pts[0][0].toFixed(1)},${H} Z`
|
|
66
|
-
return (
|
|
67
|
-
<svg viewBox={`0 0 ${W} ${H}`} className="h-8 w-24 shrink-0 overflow-visible" preserveAspectRatio="none" aria-hidden>
|
|
68
|
-
<defs>
|
|
69
|
-
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
|
|
70
|
-
<stop offset="0%" stopColor={color} stopOpacity="0.22" />
|
|
71
|
-
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
|
72
|
-
</linearGradient>
|
|
73
|
-
</defs>
|
|
74
|
-
<path d={area} fill={`url(#${gid})`} />
|
|
75
|
-
<path d={line} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
|
|
76
|
-
</svg>
|
|
77
|
-
)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Semicircular radial gauge showing `value` as a fraction of `goal`. */
|
|
81
|
-
function Gauge({ value, goal, color, children }: { value: number; goal: number; color: string; children: React.ReactNode }) {
|
|
82
|
-
const pct = goal > 0 ? Math.min(1, Math.max(0, value / goal)) : 0
|
|
83
|
-
const R = 40, CX = 50, CY = 46, SW = 9
|
|
84
|
-
const len = Math.PI * R
|
|
85
|
-
const d = `M ${CX - R} ${CY} A ${R} ${R} 0 0 1 ${CX + R} ${CY}`
|
|
86
|
-
return (
|
|
87
|
-
<div className="relative mx-auto w-full max-w-[160px]">
|
|
88
|
-
<svg viewBox="0 0 100 52" className="w-full" aria-hidden>
|
|
89
|
-
<path d={d} fill="none" stroke="hsl(var(--muted))" strokeWidth={SW} strokeLinecap="round" />
|
|
90
|
-
<path
|
|
91
|
-
d={d} fill="none" stroke={color} strokeWidth={SW} strokeLinecap="round"
|
|
92
|
-
strokeDasharray={len} strokeDashoffset={len * (1 - pct)}
|
|
93
|
-
/>
|
|
94
|
-
</svg>
|
|
95
|
-
<div className="absolute inset-x-0 bottom-0 text-center text-2xl font-semibold tracking-tight text-foreground">
|
|
96
|
-
{children}
|
|
97
|
-
</div>
|
|
98
|
-
</div>
|
|
99
|
-
)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export interface KpiCardProps {
|
|
103
|
-
label: string
|
|
104
|
-
icon?: IconRef
|
|
105
|
-
/** Pre-formatted/sync value. Provide this OR `compute`. */
|
|
106
|
-
value?: React.ReactNode
|
|
107
|
-
/** Numeric value formatted via `format`. Ignored when `value` is set. */
|
|
108
|
-
amount?: number
|
|
109
|
-
/** Async loader; the card shows a skeleton until it resolves. */
|
|
110
|
-
compute?: () => Promise<KpiValue>
|
|
111
|
-
format?: KpiFormat
|
|
112
|
-
currency?: KpiCurrency
|
|
113
|
-
sub?: React.ReactNode
|
|
114
|
-
/** Trend chip inputs (for sync usage). */
|
|
115
|
-
current?: number
|
|
116
|
-
previous?: number
|
|
117
|
-
trend?: KpiTrend
|
|
118
|
-
/** For cost-style metrics where down is good (e.g. CPA). */
|
|
119
|
-
invertTrend?: boolean
|
|
120
|
-
/** Inline sparkline series (oldest → newest). Async usage: return via KpiValue.spark. */
|
|
121
|
-
spark?: number[]
|
|
122
|
-
/** Render the card as a radial gauge toward this goal value. */
|
|
123
|
-
goal?: number
|
|
124
|
-
/** Accent color for the sparkline/gauge. Defaults to a trend-aware color. */
|
|
125
|
-
accent?: string
|
|
126
|
-
loading?: boolean
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Canonical KPI card — consolidates the trend-chip card from plugin-marketing
|
|
131
|
-
* and the async MetricCard from plugin-dashboard. Use directly, or via a
|
|
132
|
-
* registered KPI widget (see defineKpiWidget).
|
|
133
|
-
*/
|
|
134
|
-
export function KpiCard(props: KpiCardProps) {
|
|
135
|
-
const { label, icon, sub, format, currency, invertTrend } = props
|
|
136
|
-
const [computed, setComputed] = React.useState<KpiValue | null>(null)
|
|
137
|
-
const [errored, setErrored] = React.useState(false)
|
|
138
|
-
|
|
139
|
-
React.useEffect(() => {
|
|
140
|
-
if (!props.compute) return
|
|
141
|
-
let cancelled = false
|
|
142
|
-
props.compute()
|
|
143
|
-
.then((v) => { if (!cancelled) setComputed(v) })
|
|
144
|
-
.catch(() => { if (!cancelled) setErrored(true) })
|
|
145
|
-
return () => { cancelled = true }
|
|
146
|
-
}, [props.compute])
|
|
147
|
-
|
|
148
|
-
const isAsync = Boolean(props.compute)
|
|
149
|
-
const loading = props.loading || (isAsync && computed === null && !errored)
|
|
150
|
-
|
|
151
|
-
let display: React.ReactNode = props.value
|
|
152
|
-
let current = props.current
|
|
153
|
-
let previous = props.previous
|
|
154
|
-
let trend = props.trend
|
|
155
|
-
let unit: string | undefined
|
|
156
|
-
let spark = props.spark
|
|
157
|
-
|
|
158
|
-
if (isAsync && computed) {
|
|
159
|
-
display = formatKpiValue(computed.value, format, currency)
|
|
160
|
-
current = computed.value
|
|
161
|
-
previous = computed.previousValue
|
|
162
|
-
trend = computed.trend
|
|
163
|
-
unit = computed.unit
|
|
164
|
-
spark = computed.spark ?? spark
|
|
165
|
-
} else if (display == null && props.amount != null) {
|
|
166
|
-
display = formatKpiValue(props.amount, format, currency)
|
|
167
|
-
current = current ?? props.amount
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const resolvedTrend = current != null ? deriveTrend(current, previous, trend) : trend
|
|
171
|
-
const delta = current != null && previous != null && previous !== 0
|
|
172
|
-
? Math.round(((current - previous) / previous) * 100)
|
|
173
|
-
: null
|
|
174
|
-
const accent = props.accent ?? trendColor(resolvedTrend, invertTrend)
|
|
175
|
-
|
|
176
|
-
const valueText = (
|
|
177
|
-
<>
|
|
178
|
-
{display}
|
|
179
|
-
{unit ? <span className="ml-1 text-sm font-normal text-muted-foreground">{unit}</span> : null}
|
|
180
|
-
</>
|
|
181
|
-
)
|
|
182
|
-
const trendChip = resolvedTrend ? <TrendChip trend={resolvedTrend} delta={delta} invert={invertTrend} /> : null
|
|
183
|
-
|
|
184
|
-
// Pick a body layout: gauge (goal set) → sparkline (series set) → plain.
|
|
185
|
-
const isGauge = props.goal != null && current != null
|
|
186
|
-
const hasSpark = Boolean(spark && spark.length >= 2)
|
|
187
|
-
|
|
188
|
-
let body: React.ReactNode
|
|
189
|
-
if (errored) {
|
|
190
|
-
body = <span className="text-sm text-muted-foreground">—</span>
|
|
191
|
-
} else if (loading) {
|
|
192
|
-
body = isGauge
|
|
193
|
-
? <div className="mx-auto h-16 w-32 animate-pulse rounded bg-muted" />
|
|
194
|
-
: <div className="h-7 w-20 animate-pulse rounded bg-muted" />
|
|
195
|
-
} else if (isGauge) {
|
|
196
|
-
body = (
|
|
197
|
-
<div>
|
|
198
|
-
<Gauge value={current!} goal={props.goal!} color={accent}>{valueText}</Gauge>
|
|
199
|
-
<div className="mt-1 flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
|
200
|
-
{trendChip}
|
|
201
|
-
<span>{sub ?? `out of ${formatKpiValue(props.goal!, format, currency)}`}</span>
|
|
202
|
-
</div>
|
|
203
|
-
</div>
|
|
204
|
-
)
|
|
205
|
-
} else if (hasSpark) {
|
|
206
|
-
body = (
|
|
207
|
-
<div className="flex items-end justify-between gap-3">
|
|
208
|
-
<div className="min-w-0">
|
|
209
|
-
<div className="text-2xl font-semibold tracking-tight text-foreground">{valueText}</div>
|
|
210
|
-
{trendChip ? <div className="mt-0.5">{trendChip}</div> : null}
|
|
211
|
-
</div>
|
|
212
|
-
<Sparkline data={spark!} color={accent} />
|
|
213
|
-
</div>
|
|
214
|
-
)
|
|
215
|
-
} else {
|
|
216
|
-
body = (
|
|
217
|
-
<div className="flex items-baseline justify-between gap-2">
|
|
218
|
-
<span className="text-2xl font-semibold tracking-tight text-foreground">{valueText}</span>
|
|
219
|
-
{trendChip ? <span className="mb-0.5">{trendChip}</span> : null}
|
|
220
|
-
</div>
|
|
221
|
-
)
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return (
|
|
225
|
-
// h-full so cards fill the (stretch) grid cell and every card in a row matches
|
|
226
|
-
// the tallest — labels/subtitles of different lengths no longer misalign them.
|
|
227
|
-
<Card className="h-full">
|
|
228
|
-
<CardContent className="flex h-full flex-col p-4">
|
|
229
|
-
<div className="mb-2 flex items-center justify-between gap-2">
|
|
230
|
-
<span className="text-sm text-muted-foreground">{label}</span>
|
|
231
|
-
<span className="text-muted-foreground">{renderIcon(icon)}</span>
|
|
232
|
-
</div>
|
|
233
|
-
{body}
|
|
234
|
-
{sub && !isGauge ? <p className="mt-0.5 text-xs text-muted-foreground">{sub}</p> : null}
|
|
235
|
-
</CardContent>
|
|
236
|
-
</Card>
|
|
237
|
-
)
|
|
238
|
-
}
|