init: rapid_dashboards app from fn_registry
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Rapid Dashboards</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "rapid-dashboards",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --host"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"recharts": "^3.8.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
052e44bcd719cd7db765d6c7fb248074
|
||||
Generated
+1628
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { DashboardShell } from './components/DashboardShell'
|
||||
import { useFilterState } from './hooks/useFilterState'
|
||||
import type { DashboardConfig } from './types'
|
||||
import type { FilterValues } from './hooks/useFilterState'
|
||||
|
||||
// Register all built-in widgets.
|
||||
import './components/widgets/register'
|
||||
|
||||
// Wails-generated bindings.
|
||||
let GetDashboardConfig: () => Promise<DashboardConfig>
|
||||
let GetWidgetData: (widgetID: string, filters: FilterValues) => Promise<Record<string, unknown>[]>
|
||||
let ListDashboards: () => Promise<DashboardInfo[]>
|
||||
let SwitchDashboard: (fileName: string) => Promise<DashboardConfig>
|
||||
|
||||
interface DashboardInfo {
|
||||
name: string
|
||||
file: string
|
||||
title: string
|
||||
theme: string
|
||||
current: boolean
|
||||
}
|
||||
|
||||
const dummyConfig: DashboardConfig = { settings: { title: 'Dev Mode', refresh: '30s', width: 1280, height: 800, columns: 12 }, theme: 'dark', queries: {}, filters: {}, sections: [] }
|
||||
|
||||
// Import bindings — they call window.go which only exists inside Wails WebView
|
||||
const bindings = await import('./wailsjs/go/main/App').catch(() => null)
|
||||
GetDashboardConfig = bindings?.GetDashboardConfig as typeof GetDashboardConfig ?? (async () => dummyConfig)
|
||||
GetWidgetData = bindings?.GetWidgetData as typeof GetWidgetData ?? (async () => [])
|
||||
ListDashboards = bindings?.ListDashboards as typeof ListDashboards ?? (async () => [])
|
||||
SwitchDashboard = bindings?.SwitchDashboard as typeof SwitchDashboard ?? (async () => dummyConfig)
|
||||
|
||||
export default function App() {
|
||||
const [config, setConfig] = useState<DashboardConfig | null>(null)
|
||||
const [dashboards, setDashboards] = useState<DashboardInfo[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [switching, setSwitching] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
GetDashboardConfig()
|
||||
.then(cfg => {
|
||||
setConfig(cfg)
|
||||
// ListDashboards may fail if runtime not ready yet — retry once
|
||||
ListDashboards()
|
||||
.then(setDashboards)
|
||||
.catch(() => setTimeout(() => ListDashboards().then(setDashboards).catch(() => {}), 1000))
|
||||
})
|
||||
.catch(err => setError(err.message ?? String(err)))
|
||||
}, [])
|
||||
|
||||
const handleSwitch = useCallback(async (fileName: string) => {
|
||||
setSwitching(true)
|
||||
try {
|
||||
const newCfg = await SwitchDashboard(fileName)
|
||||
setConfig(newCfg)
|
||||
const list = await ListDashboards()
|
||||
setDashboards(list)
|
||||
} catch (err) {
|
||||
setError((err as Error).message ?? String(err))
|
||||
} finally {
|
||||
setSwitching(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)] flex items-center justify-center p-8">
|
||||
<div className="max-w-lg space-y-4">
|
||||
<h1 className="text-xl font-semibold text-[var(--destructive)]">Dashboard Error</h1>
|
||||
<pre className="text-sm bg-[var(--card)] rounded-lg p-4 overflow-auto whitespace-pre-wrap border border-[var(--border)]">
|
||||
{error}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!config || switching) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)] flex items-center justify-center">
|
||||
<p className="text-[var(--muted-foreground)]">{switching ? 'Switching dashboard...' : 'Loading dashboard...'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <DashboardInner config={config} dashboards={dashboards} onSwitch={handleSwitch} />
|
||||
}
|
||||
|
||||
function DashboardInner({ config, dashboards, onSwitch }: {
|
||||
config: DashboardConfig
|
||||
dashboards: DashboardInfo[]
|
||||
onSwitch: (fileName: string) => void
|
||||
}) {
|
||||
const { values, setValue, reset } = useFilterState(config.filters ?? {})
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', config.theme || 'dark')
|
||||
}, [config.theme])
|
||||
|
||||
const getData = useCallback(async (widgetID: string, filters: FilterValues) => {
|
||||
return GetWidgetData(widgetID, filters)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
config={config}
|
||||
filters={values}
|
||||
onFilterChange={setValue}
|
||||
onFilterReset={reset}
|
||||
getData={getData}
|
||||
dashboards={dashboards}
|
||||
onSwitchDashboard={onSwitch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ========== DARK (default) — deep blue-grey ========== */
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
--background: oklch(8% 0.015 260);
|
||||
--foreground: oklch(95% 0.01 260);
|
||||
--muted: oklch(18% 0.02 260);
|
||||
--muted-foreground: oklch(60% 0.02 260);
|
||||
--border: oklch(15% 0.01 260);
|
||||
--primary: oklch(65% 0.22 260);
|
||||
--primary-foreground: oklch(98% 0.01 260);
|
||||
--secondary: oklch(20% 0.02 260);
|
||||
--secondary-foreground: oklch(95% 0.01 260);
|
||||
--accent: oklch(18% 0.03 260);
|
||||
--accent-foreground: oklch(95% 0.01 260);
|
||||
--destructive: oklch(55% 0.22 25);
|
||||
--destructive-foreground: oklch(98% 0.01 260);
|
||||
--card: oklch(11% 0.015 260);
|
||||
--card-foreground: oklch(95% 0.01 260);
|
||||
--popover: oklch(12% 0.015 260);
|
||||
--popover-foreground: oklch(95% 0.01 260);
|
||||
--ring: oklch(65% 0.22 260);
|
||||
--input: oklch(22% 0.02 260);
|
||||
--radius: 0.5rem;
|
||||
--success: oklch(65% 0.2 145);
|
||||
--success-foreground: oklch(98% 0.01 145);
|
||||
--chart-1: #3b82f6;
|
||||
--chart-2: #10b981;
|
||||
--chart-3: #f59e0b;
|
||||
--chart-4: #ef4444;
|
||||
--chart-5: #8b5cf6;
|
||||
}
|
||||
|
||||
/* ========== EMERALD — rich green, gold accents ========== */
|
||||
[data-theme="emerald"] {
|
||||
--background: oklch(6% 0.03 155);
|
||||
--foreground: oklch(92% 0.03 155);
|
||||
--muted: oklch(15% 0.04 155);
|
||||
--muted-foreground: oklch(58% 0.04 155);
|
||||
--border: oklch(13% 0.03 155);
|
||||
--primary: oklch(70% 0.22 155);
|
||||
--primary-foreground: oklch(10% 0.03 155);
|
||||
--secondary: oklch(18% 0.04 155);
|
||||
--secondary-foreground: oklch(90% 0.03 155);
|
||||
--accent: oklch(16% 0.05 155);
|
||||
--accent-foreground: oklch(90% 0.03 155);
|
||||
--destructive: oklch(55% 0.22 25);
|
||||
--destructive-foreground: oklch(98% 0.01 260);
|
||||
--card: oklch(9% 0.03 155);
|
||||
--card-foreground: oklch(92% 0.03 155);
|
||||
--popover: oklch(10% 0.04 155);
|
||||
--popover-foreground: oklch(92% 0.03 155);
|
||||
--ring: oklch(70% 0.22 155);
|
||||
--input: oklch(20% 0.04 155);
|
||||
--success: oklch(70% 0.22 155);
|
||||
--success-foreground: oklch(10% 0.03 155);
|
||||
--chart-1: #10b981;
|
||||
--chart-2: #f59e0b;
|
||||
--chart-3: #06b6d4;
|
||||
--chart-4: #8b5cf6;
|
||||
--chart-5: #ec4899;
|
||||
}
|
||||
|
||||
/* ========== AMBER — warm dark, orange/amber accent ========== */
|
||||
[data-theme="amber"] {
|
||||
--background: oklch(7% 0.02 55);
|
||||
--foreground: oklch(93% 0.02 55);
|
||||
--muted: oklch(16% 0.03 55);
|
||||
--muted-foreground: oklch(58% 0.03 55);
|
||||
--border: oklch(14% 0.025 55);
|
||||
--primary: oklch(72% 0.2 65);
|
||||
--primary-foreground: oklch(10% 0.02 55);
|
||||
--secondary: oklch(18% 0.03 55);
|
||||
--secondary-foreground: oklch(90% 0.02 55);
|
||||
--accent: oklch(16% 0.04 55);
|
||||
--accent-foreground: oklch(90% 0.02 55);
|
||||
--destructive: oklch(55% 0.22 25);
|
||||
--destructive-foreground: oklch(98% 0.01 260);
|
||||
--card: oklch(10% 0.025 55);
|
||||
--card-foreground: oklch(93% 0.02 55);
|
||||
--popover: oklch(11% 0.03 55);
|
||||
--popover-foreground: oklch(93% 0.02 55);
|
||||
--ring: oklch(72% 0.2 65);
|
||||
--input: oklch(20% 0.03 55);
|
||||
--success: oklch(65% 0.2 145);
|
||||
--success-foreground: oklch(98% 0.01 145);
|
||||
--chart-1: #f59e0b;
|
||||
--chart-2: #ef4444;
|
||||
--chart-3: #3b82f6;
|
||||
--chart-4: #10b981;
|
||||
--chart-5: #ec4899;
|
||||
}
|
||||
|
||||
/* ========== ROSE — dark with pink/rose accent ========== */
|
||||
[data-theme="rose"] {
|
||||
--background: oklch(7% 0.02 350);
|
||||
--foreground: oklch(93% 0.015 350);
|
||||
--muted: oklch(16% 0.03 350);
|
||||
--muted-foreground: oklch(58% 0.025 350);
|
||||
--border: oklch(14% 0.025 350);
|
||||
--primary: oklch(65% 0.22 350);
|
||||
--primary-foreground: oklch(98% 0.01 350);
|
||||
--secondary: oklch(18% 0.03 350);
|
||||
--secondary-foreground: oklch(90% 0.015 350);
|
||||
--accent: oklch(16% 0.04 350);
|
||||
--accent-foreground: oklch(90% 0.015 350);
|
||||
--destructive: oklch(55% 0.22 25);
|
||||
--destructive-foreground: oklch(98% 0.01 260);
|
||||
--card: oklch(10% 0.025 350);
|
||||
--card-foreground: oklch(93% 0.015 350);
|
||||
--popover: oklch(11% 0.03 350);
|
||||
--popover-foreground: oklch(93% 0.015 350);
|
||||
--ring: oklch(65% 0.22 350);
|
||||
--input: oklch(20% 0.03 350);
|
||||
--success: oklch(65% 0.2 145);
|
||||
--success-foreground: oklch(98% 0.01 145);
|
||||
--chart-1: #ec4899;
|
||||
--chart-2: #8b5cf6;
|
||||
--chart-3: #3b82f6;
|
||||
--chart-4: #f59e0b;
|
||||
--chart-5: #10b981;
|
||||
}
|
||||
|
||||
/* ========== LIGHT — clean light theme ========== */
|
||||
[data-theme="light"] {
|
||||
--background: oklch(98% 0.005 260);
|
||||
--foreground: oklch(15% 0.01 260);
|
||||
--muted: oklch(93% 0.01 260);
|
||||
--muted-foreground: oklch(45% 0.01 260);
|
||||
--border: oklch(88% 0.01 260);
|
||||
--primary: oklch(50% 0.22 260);
|
||||
--primary-foreground: oklch(98% 0.01 260);
|
||||
--secondary: oklch(94% 0.01 260);
|
||||
--secondary-foreground: oklch(20% 0.01 260);
|
||||
--accent: oklch(95% 0.01 260);
|
||||
--accent-foreground: oklch(20% 0.01 260);
|
||||
--destructive: oklch(50% 0.22 25);
|
||||
--destructive-foreground: oklch(98% 0.01 260);
|
||||
--card: oklch(100% 0 0);
|
||||
--card-foreground: oklch(15% 0.01 260);
|
||||
--popover: oklch(100% 0 0);
|
||||
--popover-foreground: oklch(15% 0.01 260);
|
||||
--ring: oklch(50% 0.22 260);
|
||||
--input: oklch(88% 0.01 260);
|
||||
--success: oklch(50% 0.2 145);
|
||||
--success-foreground: oklch(98% 0.01 145);
|
||||
--chart-1: #2563eb;
|
||||
--chart-2: #059669;
|
||||
--chart-3: #d97706;
|
||||
--chart-4: #dc2626;
|
||||
--chart-5: #7c3aed;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: 'Geist Variable', system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Remove card borders for seamless look on dark themes */
|
||||
[data-theme="dark"] [data-slot="card"],
|
||||
[data-theme="emerald"] [data-slot="card"],
|
||||
[data-theme="amber"] [data-slot="card"],
|
||||
[data-theme="rose"] [data-slot="card"] {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Light theme keeps subtle card shadows */
|
||||
[data-theme="light"] [data-slot="card"] {
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { FilterBar } from './FilterBar'
|
||||
import { Section } from './Section'
|
||||
import { SimpleSelect } from '@fn_library'
|
||||
import type { DashboardConfig } from '../types'
|
||||
import type { FilterValues } from '../hooks/useFilterState'
|
||||
|
||||
interface DashboardInfo {
|
||||
name: string
|
||||
file: string
|
||||
title: string
|
||||
theme: string
|
||||
current: boolean
|
||||
}
|
||||
|
||||
interface DashboardShellProps {
|
||||
config: DashboardConfig
|
||||
filters: FilterValues
|
||||
onFilterChange: (key: string, value: unknown) => void
|
||||
onFilterReset: () => void
|
||||
getData: (widgetID: string, filters: FilterValues) => Promise<Record<string, unknown>[]>
|
||||
dashboards?: DashboardInfo[]
|
||||
onSwitchDashboard?: (fileName: string) => void
|
||||
}
|
||||
|
||||
export function DashboardShell({ config, filters, onFilterChange, onFilterReset, getData, dashboards, onSwitchDashboard }: DashboardShellProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)] px-4 py-3 space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold tracking-tight">{config.settings.title}</h1>
|
||||
{dashboards && dashboards.length > 1 && onSwitchDashboard && (
|
||||
<SimpleSelect
|
||||
size="sm"
|
||||
value={dashboards.find(d => d.current)?.file ?? ''}
|
||||
onValueChange={(v) => onSwitchDashboard(v)}
|
||||
options={dashboards.map(d => ({ value: d.file, label: `${d.title} (${d.theme})` }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FilterBar
|
||||
filters={config.filters ?? {}}
|
||||
values={filters}
|
||||
onChange={onFilterChange}
|
||||
onReset={onFilterReset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
{(config.sections ?? []).map(section => (
|
||||
<Section
|
||||
key={section.id}
|
||||
section={section}
|
||||
queries={config.queries}
|
||||
filters={filters}
|
||||
globalColumns={config.settings.columns}
|
||||
getData={getData}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useRef, useEffect, useState } from 'react'
|
||||
import { SimpleSelect } from '@fn_library'
|
||||
import type { FilterDef, FilterPreset } from '../types'
|
||||
import type { FilterValues } from '../hooks/useFilterState'
|
||||
|
||||
interface FilterBarProps {
|
||||
filters: Record<string, FilterDef>
|
||||
values: FilterValues
|
||||
onChange: (key: string, value: unknown) => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
export function FilterBar({ filters, values, onChange, onReset }: FilterBarProps) {
|
||||
const entries = Object.entries(filters)
|
||||
if (entries.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{entries.map(([key, def]) => (
|
||||
<FilterControl key={key} id={key} def={def} value={values[key]} onChange={onChange} />
|
||||
))}
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors px-2 py-1 rounded"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FilterControlProps {
|
||||
id: string
|
||||
def: FilterDef
|
||||
value: unknown
|
||||
onChange: (key: string, value: unknown) => void
|
||||
}
|
||||
|
||||
function FilterControl({ id, def, value, onChange }: FilterControlProps) {
|
||||
switch (def.type) {
|
||||
case 'select':
|
||||
return <SelectFilter id={id} def={def} value={value as string} onChange={onChange} />
|
||||
case 'date_range':
|
||||
return <DateRangeFilter id={id} def={def} value={value as Record<string, string>} onChange={onChange} />
|
||||
case 'text':
|
||||
return <TextFilter id={id} def={def} value={value as string} onChange={onChange} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function SelectFilter({ id, def, value, onChange }: { id: string; def: FilterDef; value: string; onChange: (k: string, v: unknown) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-[var(--muted-foreground)]">{def.label}</label>
|
||||
<SimpleSelect
|
||||
value={value ?? ''}
|
||||
onValueChange={(v) => onChange(id, v)}
|
||||
options={def.options?.map(opt => ({ value: opt.value, label: opt.label })) ?? []}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DateRangeFilter({ id, def, value, onChange }: { id: string; def: FilterDef; value: Record<string, string>; onChange: (k: string, v: unknown) => void }) {
|
||||
const presets = def.presets ?? []
|
||||
const currentFrom = value?.from ?? ''
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-[var(--muted-foreground)]">{def.label}</label>
|
||||
<div className="flex gap-1">
|
||||
{presets.map((preset: FilterPreset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
onClick={() => onChange(id, { from: preset.from, to: preset.to })}
|
||||
className={`text-xs px-2 py-1 rounded transition-colors ${
|
||||
currentFrom === preset.from
|
||||
? 'bg-[var(--primary)] text-[var(--primary-foreground)]'
|
||||
: 'bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:bg-[var(--accent)]'
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TextFilter({ id, def, value, onChange }: { id: string; def: FilterDef; value: string; onChange: (k: string, v: unknown) => void }) {
|
||||
const [localValue, setLocalValue] = useState(value ?? '')
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const debounce = def.debounce ?? 300
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value ?? '')
|
||||
}, [value])
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = e.target.value
|
||||
setLocalValue(v)
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => onChange(id, v), debounce)
|
||||
}, [id, onChange, debounce])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-[var(--muted-foreground)]">{def.label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
placeholder={def.placeholder}
|
||||
className="text-sm bg-[var(--secondary)] text-[var(--foreground)] border border-[var(--border)] rounded px-2 py-1 outline-none focus:ring-1 focus:ring-[var(--ring)] w-48"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { WidgetRenderer } from './WidgetRenderer'
|
||||
import type { SectionDef, QueryDef } from '../types'
|
||||
import type { FilterValues } from '../hooks/useFilterState'
|
||||
|
||||
interface SectionProps {
|
||||
section: SectionDef
|
||||
queries: Record<string, QueryDef>
|
||||
filters: FilterValues
|
||||
globalColumns: number
|
||||
getData: (widgetID: string, filters: FilterValues) => Promise<Record<string, unknown>[]>
|
||||
}
|
||||
|
||||
export function Section({ section, queries, filters, globalColumns, getData }: SectionProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const columns = section.columns || globalColumns
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
className="flex items-center gap-2 cursor-pointer select-none bg-transparent border-none p-0"
|
||||
onClick={() => section.collapsible && setCollapsed(c => !c)}
|
||||
type="button"
|
||||
>
|
||||
{section.collapsible && (
|
||||
collapsed
|
||||
? <ChevronRight className="w-4 h-4 text-[var(--muted-foreground)]" />
|
||||
: <ChevronDown className="w-4 h-4 text-[var(--muted-foreground)]" />
|
||||
)}
|
||||
<h2 className="text-sm font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
{section.title}
|
||||
</h2>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{section.widgets.map(widget => (
|
||||
<div
|
||||
key={widget.id}
|
||||
style={{
|
||||
gridColumn: `span ${Math.min(widget.span || 1, columns)}`,
|
||||
gridRow: widget.rowSpan ? `span ${widget.rowSpan}` : undefined,
|
||||
}}
|
||||
>
|
||||
<WidgetRenderer
|
||||
widget={widget}
|
||||
queryDef={queries[widget.query]}
|
||||
filters={filters}
|
||||
getData={getData}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||
import { getWidget } from './widget-registry'
|
||||
import type { WidgetDef, QueryDef } from '../types'
|
||||
import type { FilterValues } from '../hooks/useFilterState'
|
||||
|
||||
interface WidgetRendererProps {
|
||||
widget: WidgetDef
|
||||
queryDef: QueryDef
|
||||
filters: FilterValues
|
||||
getData: (widgetID: string, filters: FilterValues) => Promise<Record<string, unknown>[]>
|
||||
}
|
||||
|
||||
export function WidgetRenderer({ widget, queryDef, filters, getData }: WidgetRendererProps) {
|
||||
const [data, setData] = useState<Record<string, unknown>[] | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const mountedRef = useRef(true)
|
||||
const lastDataRef = useRef<string>('')
|
||||
|
||||
const refreshSec = Math.max(Math.round((queryDef.refreshMs ?? 30000) / 1000), 1)
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
try {
|
||||
const result = await getData(widget.id, filters)
|
||||
if (!mountedRef.current) return
|
||||
|
||||
const serialized = JSON.stringify(result)
|
||||
if (serialized !== lastDataRef.current) {
|
||||
lastDataRef.current = serialized
|
||||
setData(result)
|
||||
}
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return
|
||||
setError(err as Error)
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setLoading(false)
|
||||
setCountdown(refreshSec)
|
||||
}
|
||||
}
|
||||
}, [widget.id, filters, getData, refreshSec])
|
||||
|
||||
// Initial fetch.
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
setLoading(true)
|
||||
fetch()
|
||||
return () => { mountedRef.current = false }
|
||||
}, [fetch])
|
||||
|
||||
// Refetch on interval.
|
||||
useEffect(() => {
|
||||
if (!queryDef.refreshMs || queryDef.refreshMs <= 0) return
|
||||
const interval = setInterval(fetch, queryDef.refreshMs)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetch, queryDef.refreshMs])
|
||||
|
||||
// Countdown tick.
|
||||
useEffect(() => {
|
||||
const tick = setInterval(() => {
|
||||
setCountdown(c => (c > 0 ? c - 1 : 0))
|
||||
}, 1000)
|
||||
return () => clearInterval(tick)
|
||||
}, [])
|
||||
|
||||
const Component = getWidget(widget.type)
|
||||
if (!Component) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{widget.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Unknown widget type: <code>{widget.type}</code>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<Component widget={widget} data={data} loading={loading} error={error} />
|
||||
<span className="absolute top-2 right-2 text-[10px] tabular-nums text-[var(--muted-foreground)] opacity-60">
|
||||
{countdown}s
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import type { WidgetProps } from '../types'
|
||||
|
||||
const WIDGET_REGISTRY: Record<string, ComponentType<WidgetProps>> = {}
|
||||
|
||||
export function registerWidget(type: string, component: ComponentType<WidgetProps>) {
|
||||
WIDGET_REGISTRY[type] = component
|
||||
}
|
||||
|
||||
export function getWidget(type: string): ComponentType<WidgetProps> | undefined {
|
||||
return WIDGET_REGISTRY[type]
|
||||
}
|
||||
|
||||
export function getRegisteredTypes(): string[] {
|
||||
return Object.keys(WIDGET_REGISTRY)
|
||||
}
|
||||
|
||||
export { WIDGET_REGISTRY }
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
Legend, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import type { WidgetProps } from '../../types'
|
||||
|
||||
const COLORS = [
|
||||
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
|
||||
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)',
|
||||
]
|
||||
|
||||
export function AreaChartWidget({ widget, data, loading, error }: WidgetProps) {
|
||||
const mapping = widget.mapping ?? {}
|
||||
const xKey = mapping.x as string ?? 'x'
|
||||
const options = widget.options ?? {}
|
||||
|
||||
const series = Array.isArray(mapping.series)
|
||||
? (mapping.series as Array<{ key: string; name: string; color?: string }>)
|
||||
: undefined
|
||||
|
||||
const yKey = !series ? (mapping.y as string) : undefined
|
||||
const areas = series
|
||||
? series.map((s, i) => ({ dataKey: s.key, name: s.name, color: s.color ?? COLORS[i % COLORS.length] }))
|
||||
: yKey ? [{ dataKey: yKey, name: yKey, color: COLORS[0] }] : []
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||
{widget.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && !data ? (
|
||||
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
|
||||
) : error ? (
|
||||
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={options.height as number ?? 300}>
|
||||
<AreaChart data={data ?? []} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
||||
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />}
|
||||
<XAxis dataKey={xKey} tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
|
||||
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8 }} />
|
||||
{!!options.show_legend && <Legend />}
|
||||
<defs>
|
||||
{areas.map(area => (
|
||||
<linearGradient key={`grad-${area.dataKey}`} id={`gradient-${area.dataKey}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={area.color} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={area.color} stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
{areas.map(area => (
|
||||
<Area
|
||||
key={area.dataKey}
|
||||
type="monotone"
|
||||
dataKey={area.dataKey}
|
||||
name={area.name}
|
||||
stroke={area.color}
|
||||
strokeWidth={2}
|
||||
fill={`url(#gradient-${area.dataKey})`}
|
||||
stackId={options.stacked ? 'stack' : undefined}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
Legend, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import type { WidgetProps } from '../../types'
|
||||
|
||||
const COLORS = [
|
||||
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
|
||||
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)',
|
||||
]
|
||||
|
||||
export function BarChartWidget({ widget, data, loading, error }: WidgetProps) {
|
||||
const mapping = widget.mapping ?? {}
|
||||
const xKey = mapping.x as string ?? 'x'
|
||||
const options = widget.options ?? {}
|
||||
|
||||
const series = Array.isArray(mapping.series)
|
||||
? (mapping.series as Array<{ key: string; name: string; color?: string }>)
|
||||
: undefined
|
||||
|
||||
const yKey = !series ? (mapping.y as string) : undefined
|
||||
const bars = series
|
||||
? series.map((s, i) => ({ dataKey: s.key, name: s.name, fill: s.color ?? COLORS[i % COLORS.length] }))
|
||||
: yKey ? [{ dataKey: yKey, name: yKey, fill: COLORS[0] }] : []
|
||||
|
||||
const isHorizontal = !!options.horizontal
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-1">
|
||||
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||
{widget.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && !data ? (
|
||||
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
|
||||
) : error ? (
|
||||
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={options.height as number ?? 300}>
|
||||
<BarChart
|
||||
data={data ?? []}
|
||||
layout={isHorizontal ? 'vertical' : 'horizontal'}
|
||||
margin={{ top: 5, right: 10, left: isHorizontal ? 10 : 0, bottom: 5 }}
|
||||
>
|
||||
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />}
|
||||
{isHorizontal ? (
|
||||
<>
|
||||
<YAxis
|
||||
dataKey={xKey}
|
||||
type="category"
|
||||
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
|
||||
width={140}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XAxis dataKey={xKey} tick={{ fill: 'var(--muted-foreground)', fontSize: 11 }} />
|
||||
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 11 }} />
|
||||
</>
|
||||
)}
|
||||
<Tooltip contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 12 }} />
|
||||
{!!options.show_legend && <Legend wrapperStyle={{ fontSize: 11 }} />}
|
||||
{bars.map(bar => (
|
||||
<Bar key={bar.dataKey} dataKey={bar.dataKey} name={bar.name} fill={bar.fill} radius={isHorizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { KPICard, Sparkline } from '@fn_library'
|
||||
import type { WidgetProps } from '../../types'
|
||||
|
||||
export function KPIWidget({ widget, data, loading }: WidgetProps) {
|
||||
const mapping = widget.mapping ?? {}
|
||||
const valueKey = mapping.value as string ?? 'value'
|
||||
const format = mapping.format as string
|
||||
const unitKey = mapping.unit as string | undefined
|
||||
const deltaKey = mapping.delta as string | undefined
|
||||
const deltaLabel = mapping.deltaLabel as string | undefined
|
||||
const deltaSuffix = mapping.deltaSuffix as string | undefined
|
||||
const sparklineKey = mapping.sparkline as string | undefined
|
||||
const sparklineColors = mapping.sparklineColors as string[] | undefined
|
||||
|
||||
let displayValue: string | number = '—'
|
||||
let displayUnit: string | undefined
|
||||
let delta: { value: number; isPositive: boolean; label?: string; suffix?: string } | undefined
|
||||
let sparklineData: number[] | undefined
|
||||
|
||||
if (data && data.length > 0) {
|
||||
const row = data[0]
|
||||
displayValue = formatValue(row[valueKey], format)
|
||||
|
||||
if (unitKey && row[unitKey] != null) {
|
||||
displayUnit = String(row[unitKey])
|
||||
} else if (mapping.unitLabel) {
|
||||
displayUnit = mapping.unitLabel as string
|
||||
}
|
||||
|
||||
if (deltaKey && row[deltaKey] != null) {
|
||||
const dv = Number(row[deltaKey])
|
||||
if (!isNaN(dv)) {
|
||||
delta = { value: dv, isPositive: dv >= 0, label: deltaLabel, suffix: deltaSuffix }
|
||||
}
|
||||
}
|
||||
|
||||
if (sparklineKey) {
|
||||
sparklineData = data.map(r => Number(r[sparklineKey]) || 0)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<KPICard
|
||||
label={widget.title}
|
||||
value={loading ? '...' : displayValue}
|
||||
unit={displayUnit}
|
||||
delta={delta}
|
||||
chart={sparklineData && sparklineData.length > 0 ? (
|
||||
<Sparkline
|
||||
data={sparklineData}
|
||||
variant="bar"
|
||||
colors={sparklineColors}
|
||||
width={80}
|
||||
height={32}
|
||||
/>
|
||||
) : undefined}
|
||||
size="default"
|
||||
className="border-0 shadow-none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function formatValue(raw: unknown, format?: string): string | number {
|
||||
if (raw == null) return '—'
|
||||
const num = Number(raw)
|
||||
if (isNaN(num)) return String(raw)
|
||||
if (!format) return num
|
||||
|
||||
if (format.includes('f')) {
|
||||
const match = format.match(/\.(\d+)f/)
|
||||
const decimals = match ? parseInt(match[1]) : 0
|
||||
let str = num.toFixed(decimals)
|
||||
if (format.includes(',')) {
|
||||
str = Number(str).toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
}
|
||||
if (format.startsWith('$')) str = '$' + str
|
||||
return str
|
||||
}
|
||||
if (format === ',') return num.toLocaleString()
|
||||
return num
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
Legend, Brush, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import type { WidgetProps } from '../../types'
|
||||
|
||||
const COLORS = [
|
||||
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
|
||||
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)',
|
||||
]
|
||||
|
||||
export function LineChartWidget({ widget, data, loading, error }: WidgetProps) {
|
||||
const mapping = widget.mapping ?? {}
|
||||
const xKey = mapping.x as string ?? 'x'
|
||||
const options = widget.options ?? {}
|
||||
|
||||
const series = Array.isArray(mapping.series)
|
||||
? (mapping.series as Array<{ key: string; name: string; color?: string }>)
|
||||
: undefined
|
||||
|
||||
const yKey = !series ? (mapping.y as string) : undefined
|
||||
const lines = series
|
||||
? series.map((s, i) => ({ dataKey: s.key, name: s.name, stroke: s.color ?? COLORS[i % COLORS.length] }))
|
||||
: yKey ? [{ dataKey: yKey, name: yKey, stroke: COLORS[0] }] : []
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||
{widget.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && !data ? (
|
||||
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
|
||||
) : error ? (
|
||||
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={options.height as number ?? 300}>
|
||||
<LineChart data={data ?? []} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
||||
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />}
|
||||
<XAxis dataKey={xKey} tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
|
||||
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8 }} />
|
||||
{!!options.show_legend && <Legend />}
|
||||
{lines.map(line => (
|
||||
<Line
|
||||
key={line.dataKey}
|
||||
type={(options.curve as any) ?? 'monotone'}
|
||||
dataKey={line.dataKey}
|
||||
name={line.name}
|
||||
stroke={line.stroke}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
))}
|
||||
{!!options.zoomable && <Brush dataKey={xKey} height={20} stroke="var(--primary)" />}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||
import {
|
||||
PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import type { PieLabelRenderProps } from 'recharts'
|
||||
import type { WidgetProps } from '../../types'
|
||||
|
||||
const COLORS = [
|
||||
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
|
||||
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)',
|
||||
'var(--chart-7, #06b6d4)', 'var(--chart-8, #f97316)',
|
||||
]
|
||||
|
||||
function renderLabel(props: PieLabelRenderProps): string {
|
||||
const name = props.name ?? ''
|
||||
const pct = typeof props.percent === 'number' ? (props.percent * 100).toFixed(0) : '0'
|
||||
return `${name} ${pct}%`
|
||||
}
|
||||
|
||||
export function PieChartWidget({ widget, data, loading, error }: WidgetProps) {
|
||||
const mapping = widget.mapping ?? {}
|
||||
const nameKey = mapping.name as string ?? mapping.x as string ?? 'name'
|
||||
const valueKey = mapping.value as string ?? mapping.y as string ?? 'value'
|
||||
const options = widget.options ?? {}
|
||||
|
||||
// Ensure numeric values for Recharts Pie
|
||||
const pieData = (data ?? []).map(row => ({
|
||||
...row,
|
||||
[valueKey]: Number(row[valueKey]) || 0,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-1">
|
||||
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||
{widget.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && !data ? (
|
||||
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
|
||||
) : error ? (
|
||||
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={options.height as number ?? 300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
dataKey={valueKey}
|
||||
nameKey={nameKey}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={100}
|
||||
innerRadius={options.donut ? 50 : 0}
|
||||
strokeWidth={0}
|
||||
fontSize={11}
|
||||
fill="var(--muted-foreground)"
|
||||
isAnimationActive={false}
|
||||
label={renderLabel}
|
||||
labelLine={false}
|
||||
>
|
||||
{pieData.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 12 }}
|
||||
/>
|
||||
{options.show_legend !== false && (
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
)}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Card, CardContent } from '@fn_library'
|
||||
import { Sparkline } from '@fn_library'
|
||||
import type { WidgetProps } from '../../types'
|
||||
|
||||
export function SparklineWidget({ widget, data, loading }: WidgetProps) {
|
||||
const mapping = widget.mapping ?? {}
|
||||
const valueKey = mapping.value as string ?? 'value'
|
||||
const options = widget.options ?? {}
|
||||
|
||||
const values = (data ?? []).map(row => Number(row[valueKey]) || 0)
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardContent className="pt-4">
|
||||
<p className="text-xs text-[var(--muted-foreground)] mb-2">{widget.title}</p>
|
||||
{loading && values.length === 0 ? (
|
||||
<div className="h-[40px] flex items-center text-[var(--muted-foreground)] text-xs">Loading...</div>
|
||||
) : (
|
||||
<Sparkline
|
||||
data={values}
|
||||
variant={(options.variant as 'line' | 'area' | 'bar') ?? 'area'}
|
||||
width={options.width as number ?? 200}
|
||||
height={options.height as number ?? 40}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||
import type { WidgetProps } from '../../types'
|
||||
|
||||
interface ColumnDef {
|
||||
key: string
|
||||
label: string
|
||||
format?: string
|
||||
}
|
||||
|
||||
export function TableWidget({ widget, data, loading, error }: WidgetProps) {
|
||||
const mapping = widget.mapping ?? {}
|
||||
const columns = (mapping.columns as ColumnDef[]) ?? []
|
||||
const options = widget.options ?? {}
|
||||
const heatmapKeys = (options.heatmap_columns as string[]) ?? []
|
||||
|
||||
// Auto-detect columns from first row if not specified.
|
||||
const effectiveColumns: ColumnDef[] = columns.length > 0
|
||||
? columns
|
||||
: data && data.length > 0
|
||||
? Object.keys(data[0]).map(k => ({ key: k, label: k }))
|
||||
: []
|
||||
|
||||
// Compute heatmap ranges per column
|
||||
const heatmapRanges: Record<string, { min: number; max: number }> = {}
|
||||
if (heatmapKeys.length > 0 && data && data.length > 0) {
|
||||
for (const key of heatmapKeys) {
|
||||
const values = data.map(r => Number(r[key])).filter(n => !isNaN(n))
|
||||
if (values.length > 0) {
|
||||
heatmapRanges[key] = { min: Math.min(...values), max: Math.max(...values) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function heatmapStyle(key: string, value: unknown): React.CSSProperties | undefined {
|
||||
const range = heatmapRanges[key]
|
||||
if (!range || range.max === range.min) return undefined
|
||||
const num = Number(value)
|
||||
if (isNaN(num)) return undefined
|
||||
const t = (num - range.min) / (range.max - range.min)
|
||||
const alpha = 0.1 + t * 0.55
|
||||
return { backgroundColor: `color-mix(in srgb, var(--chart-1) ${Math.round(alpha * 100)}%, transparent)` }
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-1">
|
||||
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||
{widget.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && !data ? (
|
||||
<div className="h-[200px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
|
||||
) : error ? (
|
||||
<div className="h-[200px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
|
||||
) : (
|
||||
<div className="overflow-auto max-h-[500px]">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-[var(--card)]">
|
||||
<tr className="border-b border-[var(--border)]">
|
||||
{effectiveColumns.map(col => (
|
||||
<th key={col.key} className="text-left py-1.5 px-3 text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data ?? []).map((row, i) => (
|
||||
<tr key={i} className="border-b border-[var(--border)] hover:bg-[var(--accent)]/50 transition-colors">
|
||||
{effectiveColumns.map(col => (
|
||||
<td
|
||||
key={col.key}
|
||||
className="py-1.5 px-3 font-mono text-xs"
|
||||
style={heatmapStyle(col.key, row[col.key])}
|
||||
>
|
||||
{formatCell(row[col.key], col.format)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{(!data || data.length === 0) && (
|
||||
<p className="text-center text-[var(--muted-foreground)] text-sm py-8">No data</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function formatCell(value: unknown, format?: string): string {
|
||||
if (value == null) return '—'
|
||||
if (!format) return String(value)
|
||||
|
||||
const num = Number(value)
|
||||
if (format === 'datetime' && !isNaN(Date.parse(String(value)))) {
|
||||
return new Date(String(value)).toLocaleString()
|
||||
}
|
||||
if (!isNaN(num)) {
|
||||
if (format.includes('f')) {
|
||||
const match = format.match(/\.(\d+)f/)
|
||||
const d = match ? parseInt(match[1]) : 0
|
||||
let str = num.toFixed(d)
|
||||
if (format.includes(',')) str = Number(str).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d })
|
||||
if (format.startsWith('$')) str = '$' + str
|
||||
return str
|
||||
}
|
||||
if (format === ',') return num.toLocaleString()
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Registers all built-in widget types.
|
||||
// To add a new widget: create the component, then add registerWidget() here.
|
||||
|
||||
import { registerWidget } from '../widget-registry'
|
||||
import { KPIWidget } from './KPIWidget'
|
||||
import { LineChartWidget } from './LineChartWidget'
|
||||
import { BarChartWidget } from './BarChartWidget'
|
||||
import { AreaChartWidget } from './AreaChartWidget'
|
||||
import { PieChartWidget } from './PieChartWidget'
|
||||
import { SparklineWidget } from './SparklineWidget'
|
||||
import { TableWidget } from './TableWidget'
|
||||
|
||||
registerWidget('kpi', KPIWidget)
|
||||
registerWidget('line_chart', LineChartWidget)
|
||||
registerWidget('bar_chart', BarChartWidget)
|
||||
registerWidget('area_chart', AreaChartWidget)
|
||||
registerWidget('pie_chart', PieChartWidget)
|
||||
registerWidget('sparkline', SparklineWidget)
|
||||
registerWidget('table', TableWidget)
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// Types for @fn_library — resolved at build time via Vite alias to frontend/functions/ui/
|
||||
declare module '@fn_library' {
|
||||
import type { FC } from 'react'
|
||||
export const Card: FC<any>
|
||||
export const CardContent: FC<any>
|
||||
export const CardHeader: FC<any>
|
||||
export const CardTitle: FC<any>
|
||||
export const KPICard: FC<any>
|
||||
export const Sparkline: FC<any>
|
||||
export const Badge: FC<any>
|
||||
export const Button: FC<any>
|
||||
export const Input: FC<any>
|
||||
export const Skeleton: FC<any>
|
||||
export interface SimpleSelectOption { value: string; label: string; disabled?: boolean }
|
||||
export function SimpleSelect(props: { value: string; onValueChange: (value: string) => void; options: SimpleSelectOption[]; placeholder?: string; disabled?: boolean; size?: 'sm' | 'default'; className?: string }): React.ReactElement
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import type { FilterDef } from '../types'
|
||||
|
||||
export type FilterValues = Record<string, unknown>
|
||||
|
||||
function initializeDefaults(filters: Record<string, FilterDef>): FilterValues {
|
||||
const values: FilterValues = {}
|
||||
for (const [key, def] of Object.entries(filters)) {
|
||||
values[key] = def.default ?? (def.type === 'text' ? '' : null)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
export function useFilterState(filters: Record<string, FilterDef>) {
|
||||
const defaults = useMemo(() => initializeDefaults(filters), [filters])
|
||||
const [values, setValues] = useState<FilterValues>(defaults)
|
||||
|
||||
const setValue = useCallback((key: string, value: unknown) => {
|
||||
setValues(prev => ({ ...prev, [key]: value }))
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setValues(defaults)
|
||||
}, [defaults])
|
||||
|
||||
return { values, setValue, reset }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './app.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
// Types mirroring Go config — sent via GetDashboardConfig() IPC.
|
||||
|
||||
export interface DashboardConfig {
|
||||
settings: Settings
|
||||
theme: string
|
||||
queries: Record<string, QueryDef>
|
||||
filters: Record<string, FilterDef>
|
||||
sections: SectionDef[]
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
title: string
|
||||
refresh: string
|
||||
width: number
|
||||
height: number
|
||||
columns: number
|
||||
}
|
||||
|
||||
export interface QueryDef {
|
||||
connection: string
|
||||
refresh: string
|
||||
staleTime: string
|
||||
params: Record<string, string> | null
|
||||
refreshMs: number
|
||||
staleMs: number
|
||||
}
|
||||
|
||||
export interface FilterDef {
|
||||
type: 'select' | 'date_range' | 'text'
|
||||
label: string
|
||||
default: unknown
|
||||
options?: FilterOption[]
|
||||
presets?: FilterPreset[]
|
||||
placeholder?: string
|
||||
debounce?: number
|
||||
}
|
||||
|
||||
export interface FilterOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface FilterPreset {
|
||||
label: string
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export interface SectionDef {
|
||||
id: string
|
||||
title: string
|
||||
collapsible: boolean
|
||||
columns?: number
|
||||
widgets: WidgetDef[]
|
||||
}
|
||||
|
||||
export interface WidgetDef {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
query: string
|
||||
mapping: Record<string, unknown>
|
||||
options?: Record<string, unknown>
|
||||
span: number
|
||||
rowSpan?: number
|
||||
}
|
||||
|
||||
// Widget component props — all widget components receive this.
|
||||
export interface WidgetProps {
|
||||
widget: WidgetDef
|
||||
data: Record<string, unknown>[] | null
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@wails/*": ["./src/wailsjs/*"],
|
||||
"@fn_library": ["./src/env.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { resolve } from 'path'
|
||||
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
'@wails': resolve(__dirname, './wailsjs'),
|
||||
'@fn_library': resolve(__dirname, '../../../frontend/functions/ui'),
|
||||
},
|
||||
dedupe: ['react', 'react-dom'],
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user