Spaces:
Paused
Paused
| import { useEffect, useState } from 'react'; | |
| import { Maximize2 } from 'lucide-react'; | |
| import SourceLink, { DataSource } from './SourceLink'; | |
| import { DrillDownModal, DrillDownData } from './DrillDownModal'; | |
| interface DataPanelProps { | |
| title: string; | |
| value: string | number; | |
| subtitle?: string; | |
| trend?: 'up' | 'down' | 'stable'; | |
| animate?: boolean; | |
| source?: DataSource; | |
| /** Enable drill-down capability */ | |
| drillDown?: DrillDownData; | |
| /** Backend endpoint for fetching detailed data */ | |
| drillDownEndpoint?: string; | |
| } | |
| const DataPanel = ({ title, value, subtitle, trend, animate = true, source, drillDown, drillDownEndpoint }: DataPanelProps) => { | |
| const [displayValue, setDisplayValue] = useState(animate ? '---' : value); | |
| const [showDrillDown, setShowDrillDown] = useState(false); | |
| // Build drill-down data from props | |
| const drillDownData: DrillDownData = drillDown || { | |
| id: title.toLowerCase().replace(/\s+/g, '-'), | |
| title: title, | |
| description: subtitle, | |
| endpoint: drillDownEndpoint, | |
| metrics: [ | |
| { label: 'Nuværende værdi', value: String(value), trend } | |
| ], | |
| details: { | |
| title, | |
| value, | |
| subtitle, | |
| trend, | |
| source: source?.name | |
| } | |
| }; | |
| const hasDrillDown = drillDown || drillDownEndpoint; | |
| useEffect(() => { | |
| if (!animate) return; | |
| const chars = '0123456789ABCDEF'; | |
| let iterations = 0; | |
| const maxIterations = 10; | |
| const interval = setInterval(() => { | |
| if (iterations >= maxIterations) { | |
| setDisplayValue(value); | |
| clearInterval(interval); | |
| return; | |
| } | |
| setDisplayValue( | |
| String(value) | |
| .split('') | |
| .map((char, index) => { | |
| if (index < iterations) return char; | |
| return chars[Math.floor(Math.random() * chars.length)]; | |
| }) | |
| .join('') | |
| ); | |
| iterations++; | |
| }, 50); | |
| return () => clearInterval(interval); | |
| }, [value, animate]); | |
| return ( | |
| <> | |
| <div | |
| className={`group relative p-4 bg-secondary/30 border border-border/30 hover:border-primary/50 transition-all duration-300 ${hasDrillDown ? 'cursor-pointer' : ''}`} | |
| onClick={hasDrillDown ? () => setShowDrillDown(true) : undefined} | |
| > | |
| <div className="flex items-start justify-between mb-2"> | |
| <span className="font-mono text-xs uppercase tracking-wider text-muted-foreground"> | |
| {title} | |
| </span> | |
| <div className="flex items-center gap-2"> | |
| {hasDrillDown && ( | |
| <Maximize2 className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" /> | |
| )} | |
| {source && <SourceLink source={source} variant="icon" />} | |
| {trend && ( | |
| <span className={`text-xs ${ | |
| trend === 'up' ? 'text-primary' : | |
| trend === 'down' ? 'text-destructive' : | |
| 'text-muted-foreground' | |
| }`}> | |
| {trend === 'up' ? '▲' : trend === 'down' ? '▼' : '●'} | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| <div className="font-display text-2xl sm:text-3xl font-bold text-foreground group-hover:text-primary transition-colors"> | |
| {displayValue} | |
| </div> | |
| {subtitle && ( | |
| <div className="mt-1 flex items-center justify-between gap-2"> | |
| <p className="font-mono text-xs text-muted-foreground"> | |
| {subtitle} | |
| </p> | |
| {source && <SourceLink source={source} variant="text" />} | |
| </div> | |
| )} | |
| {/* Scan line on hover */} | |
| <div className="absolute inset-0 opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity"> | |
| <div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-primary to-transparent animate-scan" /> | |
| </div> | |
| </div> | |
| {/* Drill-Down Modal */} | |
| {hasDrillDown && ( | |
| <DrillDownModal | |
| isOpen={showDrillDown} | |
| onClose={() => setShowDrillDown(false)} | |
| data={drillDownData} | |
| /> | |
| )} | |
| </> | |
| ); | |
| }; | |
| export default DataPanel; | |