refactor(frontend): migracion a Mantine y limpieza de widgets
- Migra el frontend a Mantine v9 siguiendo la regla de theming del registry (@fn_library, sin Tailwind/cn/CVA). - Reescribe DashboardShell, FilterBar, Section, WidgetRenderer y todos los widgets (Area/Bar/Line/Pie/KPI/Sparkline/Table) con componentes y props de Mantine. - Ajusta vite.config, main.tsx, App.tsx, app.css y env.d.ts. - Añade postcss.config.cjs requerido por Mantine. - Actualiza package.json y pnpm-lock. - Ajusta config.go, main.go y los ejemplos (fn_registry_apps/overview) para el nuevo esquema de tipos en frontend/src/types.ts.
This commit is contained in:
@@ -25,6 +25,7 @@ type Settings struct {
|
|||||||
Width int `yaml:"width" json:"width"`
|
Width int `yaml:"width" json:"width"`
|
||||||
Height int `yaml:"height" json:"height"`
|
Height int `yaml:"height" json:"height"`
|
||||||
Columns int `yaml:"columns" json:"columns"`
|
Columns int `yaml:"columns" json:"columns"`
|
||||||
|
Layout string `yaml:"layout" json:"layout"` // "scrollable" (default) or "single_view"
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConnConfig struct {
|
type ConnConfig struct {
|
||||||
@@ -127,6 +128,12 @@ func (c *DashboardConfig) validate() error {
|
|||||||
if c.Theme == "" {
|
if c.Theme == "" {
|
||||||
c.Theme = "dark"
|
c.Theme = "dark"
|
||||||
}
|
}
|
||||||
|
if c.Settings.Layout == "" {
|
||||||
|
c.Settings.Layout = "scrollable"
|
||||||
|
}
|
||||||
|
if c.Settings.Layout != "scrollable" && c.Settings.Layout != "single_view" {
|
||||||
|
return fmt.Errorf("settings.layout must be 'scrollable' or 'single_view', got %q", c.Settings.Layout)
|
||||||
|
}
|
||||||
|
|
||||||
if len(c.Connections) == 0 {
|
if len(c.Connections) == 0 {
|
||||||
return fmt.Errorf("at least one connection is required")
|
return fmt.Errorf("at least one connection is required")
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ settings:
|
|||||||
height: 900
|
height: 900
|
||||||
columns: 12
|
columns: 12
|
||||||
|
|
||||||
theme: "emerald"
|
theme: "amber"
|
||||||
|
|
||||||
connections:
|
connections:
|
||||||
registry:
|
registry:
|
||||||
driver: sqlite
|
driver: sqlite
|
||||||
path: ../../registry.db
|
path: ../../../registry.db
|
||||||
|
|
||||||
queries:
|
queries:
|
||||||
# --- KPIs ---
|
# --- KPIs ---
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ theme: "dark"
|
|||||||
connections:
|
connections:
|
||||||
registry:
|
registry:
|
||||||
driver: sqlite
|
driver: sqlite
|
||||||
path: ../../registry.db
|
path: ../../../registry.db
|
||||||
|
|
||||||
queries:
|
queries:
|
||||||
# --- KPIs ---
|
# --- KPIs ---
|
||||||
|
|||||||
+9
-11
@@ -9,27 +9,25 @@
|
|||||||
"preview": "vite preview --host"
|
"preview": "vite preview --host"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.3.0",
|
"@mantine/charts": "^9.0.0",
|
||||||
|
"@mantine/core": "^9.0.0",
|
||||||
|
"@mantine/hooks": "^9.0.0",
|
||||||
|
"@mantine/notifications": "^9.0.0",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@tabler/icons-react": "^3.41.1",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.577.0",
|
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-day-picker": "^9.14.0",
|
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0"
|
||||||
"tailwind-merge": "^3.5.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.0",
|
"@vitejs/plugin-react": "^6.0.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"postcss": "^8.5.8",
|
||||||
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
052e44bcd719cd7db765d6c7fb248074
|
1677d31df4e76e35008620851dba3211
|
||||||
Generated
+381
-609
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
'postcss-preset-mantine': {},
|
||||||
|
'postcss-simple-vars': {
|
||||||
|
variables: {
|
||||||
|
'mantine-breakpoint-xs': '36em',
|
||||||
|
'mantine-breakpoint-sm': '48em',
|
||||||
|
'mantine-breakpoint-md': '62em',
|
||||||
|
'mantine-breakpoint-lg': '75em',
|
||||||
|
'mantine-breakpoint-xl': '88em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
+17
-16
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Center, Box, Stack, Text, Code, Alert, Loader } from '@mantine/core'
|
||||||
import { DashboardShell } from './components/DashboardShell'
|
import { DashboardShell } from './components/DashboardShell'
|
||||||
import { useFilterState } from './hooks/useFilterState'
|
import { useFilterState } from './hooks/useFilterState'
|
||||||
import type { DashboardConfig } from './types'
|
import type { DashboardConfig } from './types'
|
||||||
@@ -21,7 +22,7 @@ interface DashboardInfo {
|
|||||||
current: boolean
|
current: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const dummyConfig: DashboardConfig = { settings: { title: 'Dev Mode', refresh: '30s', width: 1280, height: 800, columns: 12 }, theme: 'dark', queries: {}, filters: {}, sections: [] }
|
const dummyConfig: DashboardConfig = { settings: { title: 'Dev Mode', refresh: '30s', width: 1280, height: 800, columns: 12, layout: 'scrollable' }, theme: 'dark', queries: {}, filters: {}, sections: [] }
|
||||||
|
|
||||||
// Import bindings — they call window.go which only exists inside Wails WebView
|
// Import bindings — they call window.go which only exists inside Wails WebView
|
||||||
const bindings = await import('./wailsjs/go/main/App').catch(() => null)
|
const bindings = await import('./wailsjs/go/main/App').catch(() => null)
|
||||||
@@ -64,22 +65,26 @@ export default function App() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)] flex items-center justify-center p-8">
|
<Center mih="100vh" p="xl">
|
||||||
<div className="max-w-lg space-y-4">
|
<Stack maw={480} gap="md">
|
||||||
<h1 className="text-xl font-semibold text-[var(--destructive)]">Dashboard Error</h1>
|
<Alert color="red" title="Dashboard Error" variant="light">
|
||||||
<pre className="text-sm bg-[var(--card)] rounded-lg p-4 overflow-auto whitespace-pre-wrap border border-[var(--border)]">
|
<Code block style={{ whiteSpace: 'pre-wrap' }}>
|
||||||
{error}
|
{error}
|
||||||
</pre>
|
</Code>
|
||||||
</div>
|
</Alert>
|
||||||
</div>
|
</Stack>
|
||||||
|
</Center>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config || switching) {
|
if (!config || switching) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)] flex items-center justify-center">
|
<Center mih="100vh">
|
||||||
<p className="text-[var(--muted-foreground)]">{switching ? 'Switching dashboard...' : 'Loading dashboard...'}</p>
|
<Stack align="center" gap="sm">
|
||||||
</div>
|
<Loader size="sm" />
|
||||||
|
<Text c="dimmed">{switching ? 'Switching dashboard...' : 'Loading dashboard...'}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,10 +98,6 @@ function DashboardInner({ config, dashboards, onSwitch }: {
|
|||||||
}) {
|
}) {
|
||||||
const { values, setValue, reset } = useFilterState(config.filters ?? {})
|
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) => {
|
const getData = useCallback(async (widgetID: string, filters: FilterValues) => {
|
||||||
return GetWidgetData(widgetID, filters)
|
return GetWidgetData(widgetID, filters)
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -1,176 +1,3 @@
|
|||||||
@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 {
|
body {
|
||||||
background-color: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: 'Geist Variable', system-ui, -apple-system, sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { Box, Group, Text, Stack } from '@mantine/core'
|
||||||
import { FilterBar } from './FilterBar'
|
import { FilterBar } from './FilterBar'
|
||||||
import { Section } from './Section'
|
import { Section } from './Section'
|
||||||
import { SimpleSelect } from '@fn_library'
|
import { Select } from '@fn_library'
|
||||||
import type { DashboardConfig } from '../types'
|
import type { DashboardConfig } from '../types'
|
||||||
import type { FilterValues } from '../hooks/useFilterState'
|
import type { FilterValues } from '../hooks/useFilterState'
|
||||||
|
|
||||||
@@ -23,40 +24,57 @@ interface DashboardShellProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardShell({ config, filters, onFilterChange, onFilterReset, getData, dashboards, onSwitchDashboard }: DashboardShellProps) {
|
export function DashboardShell({ config, filters, onFilterChange, onFilterReset, getData, dashboards, onSwitchDashboard }: DashboardShellProps) {
|
||||||
return (
|
const isSingleView = config.settings.layout === 'single_view'
|
||||||
<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 */}
|
return (
|
||||||
{(config.sections ?? []).map(section => (
|
<Box
|
||||||
<Section
|
px="md"
|
||||||
key={section.id}
|
py="sm"
|
||||||
section={section}
|
mih={isSingleView ? undefined : '100vh'}
|
||||||
queries={config.queries}
|
style={isSingleView ? {
|
||||||
filters={filters}
|
height: '100vh',
|
||||||
globalColumns={config.settings.columns}
|
overflow: 'hidden',
|
||||||
getData={getData}
|
display: 'flex',
|
||||||
/>
|
flexDirection: 'column',
|
||||||
))}
|
} : undefined}
|
||||||
</div>
|
>
|
||||||
|
<Stack gap="sm" style={isSingleView ? { height: '100%', display: 'flex', flexDirection: 'column' } : undefined}>
|
||||||
|
{/* Header */}
|
||||||
|
<Group justify="space-between" style={{ flexShrink: 0 }}>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Text size="xl" fw={600} style={{ letterSpacing: '-0.025em' }}>{config.settings.title}</Text>
|
||||||
|
{dashboards && dashboards.length > 1 && onSwitchDashboard && (
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
value={dashboards.find(d => d.current)?.file ?? null}
|
||||||
|
onChange={(v) => v && onSwitchDashboard(v)}
|
||||||
|
data={dashboards.map(d => ({ value: d.file, label: `${d.title} (${d.theme})` }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<FilterBar
|
||||||
|
filters={config.filters ?? {}}
|
||||||
|
values={filters}
|
||||||
|
onChange={onFilterChange}
|
||||||
|
onReset={onFilterReset}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Sections */}
|
||||||
|
<Box style={isSingleView ? { flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', gap: 'var(--mantine-spacing-sm)' } : undefined}>
|
||||||
|
{(config.sections ?? []).map(section => (
|
||||||
|
<Section
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
queries={config.queries}
|
||||||
|
filters={filters}
|
||||||
|
globalColumns={config.settings.columns}
|
||||||
|
getData={getData}
|
||||||
|
fillHeight={isSingleView}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useRef, useEffect, useState } from 'react'
|
import { useCallback, useRef, useEffect, useState } from 'react'
|
||||||
import { SimpleSelect } from '@fn_library'
|
import { Button, TextInput, Group, Text } from '@mantine/core'
|
||||||
|
import { Select } from '@fn_library'
|
||||||
import type { FilterDef, FilterPreset } from '../types'
|
import type { FilterDef, FilterPreset } from '../types'
|
||||||
import type { FilterValues } from '../hooks/useFilterState'
|
import type { FilterValues } from '../hooks/useFilterState'
|
||||||
|
|
||||||
@@ -15,17 +16,14 @@ export function FilterBar({ filters, values, onChange, onReset }: FilterBarProps
|
|||||||
if (entries.length === 0) return null
|
if (entries.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<Group gap="sm" wrap="wrap">
|
||||||
{entries.map(([key, def]) => (
|
{entries.map(([key, def]) => (
|
||||||
<FilterControl key={key} id={key} def={def} value={values[key]} onChange={onChange} />
|
<FilterControl key={key} id={key} def={def} value={values[key]} onChange={onChange} />
|
||||||
))}
|
))}
|
||||||
<button
|
<Button variant="subtle" size="xs" onClick={onReset}>
|
||||||
onClick={onReset}
|
|
||||||
className="text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors px-2 py-1 rounded"
|
|
||||||
>
|
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,14 +49,15 @@ function FilterControl({ id, def, value, onChange }: FilterControlProps) {
|
|||||||
|
|
||||||
function SelectFilter({ id, def, value, onChange }: { id: string; def: FilterDef; value: string; onChange: (k: string, v: unknown) => void }) {
|
function SelectFilter({ id, def, value, onChange }: { id: string; def: FilterDef; value: string; onChange: (k: string, v: unknown) => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<Group gap="xs" align="center">
|
||||||
<label className="text-xs text-[var(--muted-foreground)]">{def.label}</label>
|
<Text size="xs" c="dimmed">{def.label}</Text>
|
||||||
<SimpleSelect
|
<Select
|
||||||
value={value ?? ''}
|
value={value ?? null}
|
||||||
onValueChange={(v) => onChange(id, v)}
|
onChange={(v) => onChange(id, v ?? '')}
|
||||||
options={def.options?.map(opt => ({ value: opt.value, label: opt.label })) ?? []}
|
data={def.options?.map(opt => ({ value: opt.value, label: opt.label })) ?? []}
|
||||||
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,24 +66,21 @@ function DateRangeFilter({ id, def, value, onChange }: { id: string; def: Filter
|
|||||||
const currentFrom = value?.from ?? ''
|
const currentFrom = value?.from ?? ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<Group gap="xs" align="center">
|
||||||
<label className="text-xs text-[var(--muted-foreground)]">{def.label}</label>
|
<Text size="xs" c="dimmed">{def.label}</Text>
|
||||||
<div className="flex gap-1">
|
<Group gap={4}>
|
||||||
{presets.map((preset: FilterPreset) => (
|
{presets.map((preset: FilterPreset) => (
|
||||||
<button
|
<Button
|
||||||
key={preset.label}
|
key={preset.label}
|
||||||
|
size="xs"
|
||||||
|
variant={currentFrom === preset.from ? 'filled' : 'light'}
|
||||||
onClick={() => onChange(id, { from: preset.from, to: preset.to })}
|
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}
|
{preset.label}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Group>
|
||||||
</div>
|
</Group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,15 +101,15 @@ function TextFilter({ id, def, value, onChange }: { id: string; def: FilterDef;
|
|||||||
}, [id, onChange, debounce])
|
}, [id, onChange, debounce])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<Group gap="xs" align="center">
|
||||||
<label className="text-xs text-[var(--muted-foreground)]">{def.label}</label>
|
<Text size="xs" c="dimmed">{def.label}</Text>
|
||||||
<input
|
<TextInput
|
||||||
type="text"
|
size="sm"
|
||||||
value={localValue}
|
value={localValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder={def.placeholder}
|
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"
|
style={{ width: 192 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
import { Box, UnstyledButton, Group, Text } from '@mantine/core'
|
||||||
|
import { IconChevronDown, IconChevronRight } from '@tabler/icons-react'
|
||||||
import { WidgetRenderer } from './WidgetRenderer'
|
import { WidgetRenderer } from './WidgetRenderer'
|
||||||
import type { SectionDef, QueryDef } from '../types'
|
import type { SectionDef, QueryDef } from '../types'
|
||||||
import type { FilterValues } from '../hooks/useFilterState'
|
import type { FilterValues } from '../hooks/useFilterState'
|
||||||
@@ -10,42 +11,47 @@ interface SectionProps {
|
|||||||
filters: FilterValues
|
filters: FilterValues
|
||||||
globalColumns: number
|
globalColumns: number
|
||||||
getData: (widgetID: string, filters: FilterValues) => Promise<Record<string, unknown>[]>
|
getData: (widgetID: string, filters: FilterValues) => Promise<Record<string, unknown>[]>
|
||||||
|
fillHeight?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Section({ section, queries, filters, globalColumns, getData }: SectionProps) {
|
export function Section({ section, queries, filters, globalColumns, getData, fillHeight }: SectionProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const columns = section.columns || globalColumns
|
const columns = section.columns || globalColumns
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<Box style={fillHeight ? { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', gap: 'var(--mantine-spacing-xs)' } : { display: 'flex', flexDirection: 'column', gap: 'var(--mantine-spacing-xs)' }}>
|
||||||
<button
|
<UnstyledButton
|
||||||
className="flex items-center gap-2 cursor-pointer select-none bg-transparent border-none p-0"
|
|
||||||
onClick={() => section.collapsible && setCollapsed(c => !c)}
|
onClick={() => section.collapsible && setCollapsed(c => !c)}
|
||||||
type="button"
|
style={{ flexShrink: 0, cursor: section.collapsible ? 'pointer' : 'default', userSelect: 'none' }}
|
||||||
>
|
>
|
||||||
{section.collapsible && (
|
<Group gap="xs">
|
||||||
collapsed
|
{section.collapsible && (
|
||||||
? <ChevronRight className="w-4 h-4 text-[var(--muted-foreground)]" />
|
collapsed
|
||||||
: <ChevronDown className="w-4 h-4 text-[var(--muted-foreground)]" />
|
? <IconChevronRight size={16} style={{ color: 'var(--mantine-color-dimmed)' }} />
|
||||||
)}
|
: <IconChevronDown size={16} style={{ color: 'var(--mantine-color-dimmed)' }} />
|
||||||
<h2 className="text-sm font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
)}
|
||||||
{section.title}
|
<Text size="xs" fw={500} c="dimmed" tt="uppercase" style={{ letterSpacing: '0.05em' }}>
|
||||||
</h2>
|
{section.title}
|
||||||
</button>
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div
|
<Box
|
||||||
className="grid gap-2"
|
|
||||||
style={{
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gap: 'var(--mantine-spacing-xs)',
|
||||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||||
|
...(fillHeight ? { flex: 1, minHeight: 0 } : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{section.widgets.map(widget => (
|
{section.widgets.map(widget => (
|
||||||
<div
|
<Box
|
||||||
key={widget.id}
|
key={widget.id}
|
||||||
style={{
|
style={{
|
||||||
gridColumn: `span ${Math.min(widget.span || 1, columns)}`,
|
gridColumn: `span ${Math.min(widget.span || 1, columns)}`,
|
||||||
gridRow: widget.rowSpan ? `span ${widget.rowSpan}` : undefined,
|
gridRow: widget.rowSpan ? `span ${widget.rowSpan}` : undefined,
|
||||||
|
...(fillHeight ? { minHeight: 0, overflow: 'hidden' } : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<WidgetRenderer
|
<WidgetRenderer
|
||||||
@@ -54,10 +60,10 @@ export function Section({ section, queries, filters, globalColumns, getData }: S
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
getData={getData}
|
getData={getData}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
|
import { Box, Text } from '@mantine/core'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||||
import { getWidget } from './widget-registry'
|
import { getWidget } from './widget-registry'
|
||||||
import type { WidgetDef, QueryDef } from '../types'
|
import type { WidgetDef, QueryDef } from '../types'
|
||||||
@@ -71,23 +72,34 @@ export function WidgetRenderer({ widget, queryDef, filters, getData }: WidgetRen
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm">{widget.title}</CardTitle>
|
<CardTitle>{widget.title}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
<Text size="sm" c="dimmed">
|
||||||
Unknown widget type: <code>{widget.type}</code>
|
Unknown widget type: <code>{widget.type}</code>
|
||||||
</p>
|
</Text>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full">
|
<Box style={{ position: 'relative', height: '100%' }}>
|
||||||
<Component widget={widget} data={data} loading={loading} error={error} />
|
<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">
|
<Text
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
fontSize: 10,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{countdown}s
|
{countdown}s
|
||||||
</span>
|
</Text>
|
||||||
</div>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
|
import { Center, Text, Loader } from '@mantine/core'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||||
import {
|
import {
|
||||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
Legend, ResponsiveContainer,
|
Legend,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import type { WidgetProps } from '../../types'
|
import type { WidgetProps } from '../../types'
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
|
'#3b82f6', '#10b981', '#f59e0b',
|
||||||
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)',
|
'#ef4444', '#8b5cf6', '#ec4899',
|
||||||
]
|
]
|
||||||
|
|
||||||
export function AreaChartWidget({ widget, data, loading, error }: WidgetProps) {
|
export function AreaChartWidget({ widget, data, loading, error }: WidgetProps) {
|
||||||
@@ -25,47 +26,46 @@ export function AreaChartWidget({ widget, data, loading, error }: WidgetProps) {
|
|||||||
: yKey ? [{ dataKey: yKey, name: yKey, color: COLORS[0] }] : []
|
: yKey ? [{ dataKey: yKey, name: yKey, color: COLORS[0] }] : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full">
|
<Card style={{ height: '100%' }}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
<CardTitle>
|
||||||
{widget.title}
|
<Text size="sm" fw={500} c="dimmed">{widget.title}</Text>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading && !data ? (
|
{loading && !data ? (
|
||||||
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
|
<Center h={300}><Loader size="sm" /></Center>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
|
<Center h={300}><Text size="sm" c="red">{error.message}</Text></Center>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={options.height as number ?? 300}>
|
<AreaChart width={options.width as number ?? undefined} height={options.height as number ?? 300} style={{ width: '100%' }} data={data ?? []} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
||||||
<AreaChart data={data ?? []} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-default-border)" />}
|
||||||
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />}
|
<XAxis dataKey={xKey} tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 12 }} />
|
||||||
<XAxis dataKey={xKey} tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
|
<YAxis tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 12 }} />
|
||||||
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
|
<Tooltip isAnimationActive={false} contentStyle={{ backgroundColor: 'var(--mantine-color-body)', border: '1px solid var(--mantine-color-default-border)', borderRadius: 8 }} />
|
||||||
<Tooltip contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8 }} />
|
{!!options.show_legend && <Legend />}
|
||||||
{!!options.show_legend && <Legend />}
|
<defs>
|
||||||
<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 => (
|
{areas.map(area => (
|
||||||
<Area
|
<linearGradient key={`grad-${area.dataKey}`} id={`gradient-${area.dataKey}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
key={area.dataKey}
|
<stop offset="0%" stopColor={area.color} stopOpacity={0.3} />
|
||||||
type="monotone"
|
<stop offset="100%" stopColor={area.color} stopOpacity={0.05} />
|
||||||
dataKey={area.dataKey}
|
</linearGradient>
|
||||||
name={area.name}
|
|
||||||
stroke={area.color}
|
|
||||||
strokeWidth={2}
|
|
||||||
fill={`url(#gradient-${area.dataKey})`}
|
|
||||||
stackId={options.stacked ? 'stack' : undefined}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</AreaChart>
|
</defs>
|
||||||
</ResponsiveContainer>
|
{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}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
|
import { Center, Text, Loader } from '@mantine/core'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
Legend, ResponsiveContainer,
|
Legend,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import type { WidgetProps } from '../../types'
|
import type { WidgetProps } from '../../types'
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
|
'#3b82f6', '#10b981', '#f59e0b',
|
||||||
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)',
|
'#ef4444', '#8b5cf6', '#ec4899',
|
||||||
]
|
]
|
||||||
|
|
||||||
export function BarChartWidget({ widget, data, loading, error }: WidgetProps) {
|
export function BarChartWidget({ widget, data, loading, error }: WidgetProps) {
|
||||||
@@ -27,51 +28,52 @@ export function BarChartWidget({ widget, data, loading, error }: WidgetProps) {
|
|||||||
const isHorizontal = !!options.horizontal
|
const isHorizontal = !!options.horizontal
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full">
|
<Card style={{ height: '100%' }}>
|
||||||
<CardHeader className="pb-1">
|
<CardHeader style={{ paddingBottom: 4 }}>
|
||||||
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
<CardTitle>
|
||||||
{widget.title}
|
<Text size="sm" fw={500} c="dimmed">{widget.title}</Text>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading && !data ? (
|
{loading && !data ? (
|
||||||
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
|
<Center h={300}><Loader size="sm" /></Center>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
|
<Center h={300}><Text size="sm" c="red">{error.message}</Text></Center>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={options.height as number ?? 300}>
|
<BarChart
|
||||||
<BarChart
|
width={options.width as number ?? undefined}
|
||||||
data={data ?? []}
|
height={options.height as number ?? 300}
|
||||||
layout={isHorizontal ? 'vertical' : 'horizontal'}
|
style={{ width: '100%' }}
|
||||||
margin={{ top: 5, right: 10, left: isHorizontal ? 10 : 0, bottom: 5 }}
|
data={data ?? []}
|
||||||
>
|
layout={isHorizontal ? 'vertical' : 'horizontal'}
|
||||||
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />}
|
margin={{ top: 5, right: 10, left: isHorizontal ? 10 : 0, bottom: 5 }}
|
||||||
{isHorizontal ? (
|
>
|
||||||
<>
|
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-default-border)" />}
|
||||||
<YAxis
|
{isHorizontal ? (
|
||||||
dataKey={xKey}
|
<>
|
||||||
type="category"
|
<YAxis
|
||||||
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
|
dataKey={xKey}
|
||||||
width={140}
|
type="category"
|
||||||
/>
|
tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 10 }}
|
||||||
<XAxis
|
width={140}
|
||||||
type="number"
|
/>
|
||||||
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
|
<XAxis
|
||||||
/>
|
type="number"
|
||||||
</>
|
tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 10 }}
|
||||||
) : (
|
/>
|
||||||
<>
|
</>
|
||||||
<XAxis dataKey={xKey} tick={{ fill: 'var(--muted-foreground)', fontSize: 11 }} />
|
) : (
|
||||||
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 11 }} />
|
<>
|
||||||
</>
|
<XAxis dataKey={xKey} tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 11 }} />
|
||||||
)}
|
<YAxis tick={{ fill: 'var(--mantine-color-dimmed)', 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 => (
|
<Tooltip isAnimationActive={false} contentStyle={{ backgroundColor: 'var(--mantine-color-body)', border: '1px solid var(--mantine-color-default-border)', borderRadius: 8, fontSize: 12 }} />
|
||||||
<Bar key={bar.dataKey} dataKey={bar.dataKey} name={bar.name} fill={bar.fill} radius={isHorizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]} />
|
{!!options.show_legend && <Legend wrapperStyle={{ fontSize: 11 }} />}
|
||||||
))}
|
{bars.map(bar => (
|
||||||
</BarChart>
|
<Bar key={bar.dataKey} dataKey={bar.dataKey} name={bar.name} fill={bar.fill} radius={isHorizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]} isAnimationActive={false} />
|
||||||
</ResponsiveContainer>
|
))}
|
||||||
|
</BarChart>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function KPIWidget({ widget, data, loading }: WidgetProps) {
|
|||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
size="default"
|
size="default"
|
||||||
className="border-0 shadow-none"
|
style={{ border: 'none', boxShadow: 'none' }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
|
import { Center, Text, Loader } from '@mantine/core'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||||
import {
|
import {
|
||||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
Legend, Brush, ResponsiveContainer,
|
Legend, Brush,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import type { WidgetProps } from '../../types'
|
import type { WidgetProps } from '../../types'
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
|
'#3b82f6', '#10b981', '#f59e0b',
|
||||||
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)',
|
'#ef4444', '#8b5cf6', '#ec4899',
|
||||||
]
|
]
|
||||||
|
|
||||||
export function LineChartWidget({ widget, data, loading, error }: WidgetProps) {
|
export function LineChartWidget({ widget, data, loading, error }: WidgetProps) {
|
||||||
@@ -25,40 +26,39 @@ export function LineChartWidget({ widget, data, loading, error }: WidgetProps) {
|
|||||||
: yKey ? [{ dataKey: yKey, name: yKey, stroke: COLORS[0] }] : []
|
: yKey ? [{ dataKey: yKey, name: yKey, stroke: COLORS[0] }] : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full">
|
<Card style={{ height: '100%' }}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
<CardTitle>
|
||||||
{widget.title}
|
<Text size="sm" fw={500} c="dimmed">{widget.title}</Text>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading && !data ? (
|
{loading && !data ? (
|
||||||
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
|
<Center h={300}><Loader size="sm" /></Center>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
|
<Center h={300}><Text size="sm" c="red">{error.message}</Text></Center>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={options.height as number ?? 300}>
|
<LineChart width={options.width as number ?? undefined} height={options.height as number ?? 300} style={{ width: '100%' }} data={data ?? []} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
||||||
<LineChart data={data ?? []} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-default-border)" />}
|
||||||
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />}
|
<XAxis dataKey={xKey} tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 12 }} />
|
||||||
<XAxis dataKey={xKey} tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
|
<YAxis tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 12 }} />
|
||||||
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
|
<Tooltip isAnimationActive={false} contentStyle={{ backgroundColor: 'var(--mantine-color-body)', border: '1px solid var(--mantine-color-default-border)', borderRadius: 8 }} />
|
||||||
<Tooltip contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8 }} />
|
{!!options.show_legend && <Legend />}
|
||||||
{!!options.show_legend && <Legend />}
|
{lines.map(line => (
|
||||||
{lines.map(line => (
|
<Line
|
||||||
<Line
|
key={line.dataKey}
|
||||||
key={line.dataKey}
|
type={(options.curve as any) ?? 'monotone'}
|
||||||
type={(options.curve as any) ?? 'monotone'}
|
dataKey={line.dataKey}
|
||||||
dataKey={line.dataKey}
|
name={line.name}
|
||||||
name={line.name}
|
stroke={line.stroke}
|
||||||
stroke={line.stroke}
|
strokeWidth={2}
|
||||||
strokeWidth={2}
|
dot={false}
|
||||||
dot={false}
|
activeDot={{ r: 4 }}
|
||||||
activeDot={{ r: 4 }}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{!!options.zoomable && <Brush dataKey={xKey} height={20} stroke="var(--primary)" />}
|
{!!options.zoomable && <Brush dataKey={xKey} height={20} stroke="var(--mantine-primary-color-filled)" />}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
|
import { Center, Text, Loader } from '@mantine/core'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||||
import {
|
import {
|
||||||
PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer,
|
PieChart, Pie, Cell, Tooltip, Legend,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import type { PieLabelRenderProps } from 'recharts'
|
import type { PieLabelRenderProps } from 'recharts'
|
||||||
import type { WidgetProps } from '../../types'
|
import type { WidgetProps } from '../../types'
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
|
'#3b82f6', '#10b981', '#f59e0b',
|
||||||
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)',
|
'#ef4444', '#8b5cf6', '#ec4899',
|
||||||
'var(--chart-7, #06b6d4)', 'var(--chart-8, #f97316)',
|
'#06b6d4', '#f97316',
|
||||||
]
|
]
|
||||||
|
|
||||||
function renderLabel(props: PieLabelRenderProps): string {
|
function renderLabel(props: PieLabelRenderProps): string {
|
||||||
@@ -30,47 +31,46 @@ export function PieChartWidget({ widget, data, loading, error }: WidgetProps) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full">
|
<Card style={{ height: '100%' }}>
|
||||||
<CardHeader className="pb-1">
|
<CardHeader style={{ paddingBottom: 4 }}>
|
||||||
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
<CardTitle>
|
||||||
{widget.title}
|
<Text size="sm" fw={500} c="dimmed">{widget.title}</Text>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading && !data ? (
|
{loading && !data ? (
|
||||||
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
|
<Center h={300}><Loader size="sm" /></Center>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
|
<Center h={300}><Text size="sm" c="red">{error.message}</Text></Center>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={options.height as number ?? 300}>
|
<PieChart width={options.width as number ?? undefined} height={options.height as number ?? 300} style={{ width: '100%' }}>
|
||||||
<PieChart>
|
<Pie
|
||||||
<Pie
|
data={pieData}
|
||||||
data={pieData}
|
dataKey={valueKey}
|
||||||
dataKey={valueKey}
|
nameKey={nameKey}
|
||||||
nameKey={nameKey}
|
cx="50%"
|
||||||
cx="50%"
|
cy="50%"
|
||||||
cy="50%"
|
outerRadius={100}
|
||||||
outerRadius={100}
|
innerRadius={options.donut ? 50 : 0}
|
||||||
innerRadius={options.donut ? 50 : 0}
|
strokeWidth={0}
|
||||||
strokeWidth={0}
|
fontSize={11}
|
||||||
fontSize={11}
|
fill="var(--mantine-color-dimmed)"
|
||||||
fill="var(--muted-foreground)"
|
isAnimationActive={false}
|
||||||
isAnimationActive={false}
|
label={renderLabel}
|
||||||
label={renderLabel}
|
labelLine={false}
|
||||||
labelLine={false}
|
>
|
||||||
>
|
{pieData.map((_, i) => (
|
||||||
{pieData.map((_, i) => (
|
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
))}
|
||||||
))}
|
</Pie>
|
||||||
</Pie>
|
<Tooltip
|
||||||
<Tooltip
|
isAnimationActive={false}
|
||||||
contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 12 }}
|
contentStyle={{ backgroundColor: 'var(--mantine-color-body)', border: '1px solid var(--mantine-color-default-border)', borderRadius: 8, fontSize: 12 }}
|
||||||
/>
|
/>
|
||||||
{options.show_legend !== false && (
|
{options.show_legend !== false && (
|
||||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
)}
|
)}
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Center, Text, Loader } from '@mantine/core'
|
||||||
import { Card, CardContent } from '@fn_library'
|
import { Card, CardContent } from '@fn_library'
|
||||||
import { Sparkline } from '@fn_library'
|
import { Sparkline } from '@fn_library'
|
||||||
import type { WidgetProps } from '../../types'
|
import type { WidgetProps } from '../../types'
|
||||||
@@ -10,11 +11,11 @@ export function SparklineWidget({ widget, data, loading }: WidgetProps) {
|
|||||||
const values = (data ?? []).map(row => Number(row[valueKey]) || 0)
|
const values = (data ?? []).map(row => Number(row[valueKey]) || 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full">
|
<Card style={{ height: '100%' }}>
|
||||||
<CardContent className="pt-4">
|
<CardContent style={{ paddingTop: 'var(--mantine-spacing-sm)' }}>
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mb-2">{widget.title}</p>
|
<Text size="xs" c="dimmed" mb="xs">{widget.title}</Text>
|
||||||
{loading && values.length === 0 ? (
|
{loading && values.length === 0 ? (
|
||||||
<div className="h-[40px] flex items-center text-[var(--muted-foreground)] text-xs">Loading...</div>
|
<Center h={40}><Loader size="xs" /></Center>
|
||||||
) : (
|
) : (
|
||||||
<Sparkline
|
<Sparkline
|
||||||
data={values}
|
data={values}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Center, Text, Loader, Table, Box } from '@mantine/core'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||||
import type { WidgetProps } from '../../types'
|
import type { WidgetProps } from '../../types'
|
||||||
|
|
||||||
@@ -38,53 +39,67 @@ export function TableWidget({ widget, data, loading, error }: WidgetProps) {
|
|||||||
if (isNaN(num)) return undefined
|
if (isNaN(num)) return undefined
|
||||||
const t = (num - range.min) / (range.max - range.min)
|
const t = (num - range.min) / (range.max - range.min)
|
||||||
const alpha = 0.1 + t * 0.55
|
const alpha = 0.1 + t * 0.55
|
||||||
return { backgroundColor: `color-mix(in srgb, var(--chart-1) ${Math.round(alpha * 100)}%, transparent)` }
|
return { backgroundColor: `rgba(59, 130, 246, ${alpha})` }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full">
|
<Card style={{ height: '100%' }}>
|
||||||
<CardHeader className="pb-1">
|
<CardHeader style={{ paddingBottom: 4 }}>
|
||||||
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
<CardTitle>
|
||||||
{widget.title}
|
<Text size="sm" fw={500} c="dimmed">{widget.title}</Text>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading && !data ? (
|
{loading && !data ? (
|
||||||
<div className="h-[200px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
|
<Center h={200}><Loader size="sm" /></Center>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="h-[200px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
|
<Center h={200}><Text size="sm" c="red">{error.message}</Text></Center>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-auto max-h-[500px]">
|
<Table.ScrollContainer minWidth={0} style={{ maxHeight: 500 }}>
|
||||||
<table className="w-full text-sm">
|
<Table striped={false} highlightOnHover withTableBorder={false} withColumnBorders={false} fz="sm">
|
||||||
<thead className="sticky top-0 bg-[var(--card)]">
|
<Table.Thead style={{ position: 'sticky', top: 0, zIndex: 1, backgroundColor: 'var(--mantine-color-body)' }}>
|
||||||
<tr className="border-b border-[var(--border)]">
|
<Table.Tr>
|
||||||
{effectiveColumns.map(col => (
|
{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">
|
<Table.Th
|
||||||
|
key={col.key}
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--mantine-font-size-xs)',
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
color: 'var(--mantine-color-dimmed)',
|
||||||
|
padding: '6px 12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{col.label}
|
{col.label}
|
||||||
</th>
|
</Table.Th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</Table.Tr>
|
||||||
</thead>
|
</Table.Thead>
|
||||||
<tbody>
|
<Table.Tbody>
|
||||||
{(data ?? []).map((row, i) => (
|
{(data ?? []).map((row, i) => (
|
||||||
<tr key={i} className="border-b border-[var(--border)] hover:bg-[var(--accent)]/50 transition-colors">
|
<Table.Tr key={i}>
|
||||||
{effectiveColumns.map(col => (
|
{effectiveColumns.map(col => (
|
||||||
<td
|
<Table.Td
|
||||||
key={col.key}
|
key={col.key}
|
||||||
className="py-1.5 px-3 font-mono text-xs"
|
style={{
|
||||||
style={heatmapStyle(col.key, row[col.key])}
|
padding: '6px 12px',
|
||||||
|
fontFamily: 'var(--mantine-font-family-monospace)',
|
||||||
|
fontSize: 'var(--mantine-font-size-xs)',
|
||||||
|
...heatmapStyle(col.key, row[col.key]),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{formatCell(row[col.key], col.format)}
|
{formatCell(row[col.key], col.format)}
|
||||||
</td>
|
</Table.Td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</Table.Tbody>
|
||||||
</table>
|
</Table>
|
||||||
{(!data || data.length === 0) && (
|
{(!data || data.length === 0) && (
|
||||||
<p className="text-center text-[var(--muted-foreground)] text-sm py-8">No data</p>
|
<Text ta="center" c="dimmed" size="sm" py="xl">No data</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Table.ScrollContainer>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -92,7 +107,7 @@ export function TableWidget({ widget, data, loading, error }: WidgetProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatCell(value: unknown, format?: string): string {
|
function formatCell(value: unknown, format?: string): string {
|
||||||
if (value == null) return '—'
|
if (value == null) return '\u2014'
|
||||||
if (!format) return String(value)
|
if (!format) return String(value)
|
||||||
|
|
||||||
const num = Number(value)
|
const num = Number(value)
|
||||||
|
|||||||
Vendored
+3
-2
@@ -3,6 +3,7 @@
|
|||||||
// Types for @fn_library — resolved at build time via Vite alias to frontend/functions/ui/
|
// Types for @fn_library — resolved at build time via Vite alias to frontend/functions/ui/
|
||||||
declare module '@fn_library' {
|
declare module '@fn_library' {
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
import type { SelectProps as MantineSelectProps } from '@mantine/core'
|
||||||
export const Card: FC<any>
|
export const Card: FC<any>
|
||||||
export const CardContent: FC<any>
|
export const CardContent: FC<any>
|
||||||
export const CardHeader: FC<any>
|
export const CardHeader: FC<any>
|
||||||
@@ -13,6 +14,6 @@ declare module '@fn_library' {
|
|||||||
export const Button: FC<any>
|
export const Button: FC<any>
|
||||||
export const Input: FC<any>
|
export const Input: FC<any>
|
||||||
export const Skeleton: FC<any>
|
export const Skeleton: FC<any>
|
||||||
export interface SimpleSelectOption { value: string; label: string; disabled?: boolean }
|
export interface SelectProps extends MantineSelectProps {}
|
||||||
export function SimpleSelect(props: { value: string; onValueChange: (value: string) => void; options: SimpleSelectOption[]; placeholder?: string; disabled?: boolean; size?: 'sm' | 'default'; className?: string }): React.ReactElement
|
export function Select(props: SelectProps): React.ReactElement
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-1
@@ -1,10 +1,39 @@
|
|||||||
|
import '@mantine/core/styles.css'
|
||||||
|
import '@mantine/charts/styles.css'
|
||||||
|
import '@mantine/notifications/styles.css'
|
||||||
|
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { MantineProvider, createTheme, type MantineColorsTuple } from '@mantine/core'
|
||||||
|
import { Notifications } from '@mantine/notifications'
|
||||||
import './app.css'
|
import './app.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
|
const brand: MantineColorsTuple = [
|
||||||
|
'#e5f0ff',
|
||||||
|
'#cddeff',
|
||||||
|
'#9abbff',
|
||||||
|
'#6495ff',
|
||||||
|
'#3874fe',
|
||||||
|
'#1d60fe',
|
||||||
|
'#0953ff',
|
||||||
|
'#0046e4',
|
||||||
|
'#003dcd',
|
||||||
|
'#0034b5',
|
||||||
|
]
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
colors: { brand },
|
||||||
|
primaryColor: 'brand',
|
||||||
|
defaultRadius: 'md',
|
||||||
|
fontFamily: "'Geist Variable', system-ui, -apple-system, sans-serif",
|
||||||
|
})
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<MantineProvider theme={theme} defaultColorScheme="dark">
|
||||||
|
<Notifications position="top-right" />
|
||||||
|
<App />
|
||||||
|
</MantineProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface Settings {
|
|||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
columns: number
|
columns: number
|
||||||
|
layout: 'scrollable' | 'single_view'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueryDef {
|
export interface QueryDef {
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, './src'),
|
'@': resolve(__dirname, './src'),
|
||||||
@@ -14,6 +12,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
dedupe: ['react', 'react-dom'],
|
dedupe: ['react', 'react-dom'],
|
||||||
},
|
},
|
||||||
|
css: {
|
||||||
|
postcss: resolve(__dirname, './postcss.config.cjs'),
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -122,9 +122,9 @@ func main() {
|
|||||||
engine := NewQueryEngine(cfg, pool)
|
engine := NewQueryEngine(cfg, pool)
|
||||||
app := NewApp(cfg, engine, pool, dashDir, *dashboardPath)
|
app := NewApp(cfg, engine, pool, dashDir, *dashboardPath)
|
||||||
|
|
||||||
log.Printf("starting wails — title=%q width=%d height=%d dashDir=%q", cfg.Settings.Title, cfg.Settings.Width, cfg.Settings.Height, dashDir)
|
log.Printf("starting wails — title=%q width=%d height=%d layout=%q dashDir=%q", cfg.Settings.Title, cfg.Settings.Width, cfg.Settings.Height, cfg.Settings.Layout, dashDir)
|
||||||
|
|
||||||
runErr := wails.Run(&options.App{
|
wailsOpts := &options.App{
|
||||||
Title: cfg.Settings.Title,
|
Title: cfg.Settings.Title,
|
||||||
Width: cfg.Settings.Width,
|
Width: cfg.Settings.Width,
|
||||||
Height: cfg.Settings.Height,
|
Height: cfg.Settings.Height,
|
||||||
@@ -136,7 +136,17 @@ func main() {
|
|||||||
Bind: []interface{}{
|
Bind: []interface{}{
|
||||||
app,
|
app,
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if cfg.Settings.Layout == "single_view" {
|
||||||
|
wailsOpts.MaxWidth = cfg.Settings.Width
|
||||||
|
wailsOpts.MaxHeight = cfg.Settings.Height
|
||||||
|
wailsOpts.MinWidth = cfg.Settings.Width
|
||||||
|
wailsOpts.MinHeight = cfg.Settings.Height
|
||||||
|
wailsOpts.DisableResize = true
|
||||||
|
}
|
||||||
|
|
||||||
|
runErr := wails.Run(wailsOpts)
|
||||||
|
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
log.Printf("ERROR wails.Run: %v", runErr)
|
log.Printf("ERROR wails.Run: %v", runErr)
|
||||||
|
|||||||
Reference in New Issue
Block a user