AI Analysis and Content Strategy fixes. Enhanced Strategy Routes refactoring.
This commit is contained in:
50
frontend/src/components/shared/AnimatedNumber.tsx
Normal file
50
frontend/src/components/shared/AnimatedNumber.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion';
|
||||
|
||||
interface AnimatedNumberProps {
|
||||
value: number;
|
||||
format?: (n: number) => string;
|
||||
duration?: number;
|
||||
decimals?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AnimatedNumber - Smoothly animates number changes
|
||||
*
|
||||
* Usage:
|
||||
* <AnimatedNumber value={1234.56} format={(n) => `$${n.toFixed(2)}`} />
|
||||
* <AnimatedNumber value={1000} prefix="$" decimals={2} />
|
||||
*/
|
||||
export const AnimatedNumber: React.FC<AnimatedNumberProps> = ({
|
||||
value,
|
||||
format,
|
||||
duration = 1,
|
||||
decimals = 0,
|
||||
prefix = '',
|
||||
suffix = ''
|
||||
}) => {
|
||||
const motionValue = useMotionValue(0);
|
||||
const spring = useSpring(motionValue, {
|
||||
stiffness: 50,
|
||||
damping: 30,
|
||||
duration: duration * 1000
|
||||
});
|
||||
|
||||
const display = useTransform(spring, (latest) => {
|
||||
const rounded = decimals > 0 ? Number(latest.toFixed(decimals)) : Math.round(latest);
|
||||
if (format) {
|
||||
return format(rounded);
|
||||
}
|
||||
return `${prefix}${rounded.toLocaleString()}${suffix}`;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
motionValue.set(value);
|
||||
}, [value, motionValue]);
|
||||
|
||||
return <motion.span>{display}</motion.span>;
|
||||
};
|
||||
|
||||
export default AnimatedNumber;
|
||||
106
frontend/src/components/shared/AnimatedProgressBar.tsx
Normal file
106
frontend/src/components/shared/AnimatedProgressBar.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { Box, LinearProgress, Typography } from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface AnimatedProgressBarProps {
|
||||
value: number; // 0-100
|
||||
maxValue?: number; // Optional max value for display
|
||||
label?: string;
|
||||
color?: string;
|
||||
height?: number;
|
||||
showLabel?: boolean;
|
||||
showPercentage?: boolean;
|
||||
variant?: 'determinate' | 'indeterminate' | 'buffer' | 'query';
|
||||
}
|
||||
|
||||
/**
|
||||
* AnimatedProgressBar - Progress bar with smooth fill animation
|
||||
*
|
||||
* Usage:
|
||||
* <AnimatedProgressBar value={75} label="Usage" showPercentage />
|
||||
* <AnimatedProgressBar value={50} color="#4ade80" height={8} />
|
||||
*/
|
||||
export const AnimatedProgressBar: React.FC<AnimatedProgressBarProps> = ({
|
||||
value,
|
||||
maxValue,
|
||||
label,
|
||||
color,
|
||||
height = 8,
|
||||
showLabel = false,
|
||||
showPercentage = false,
|
||||
variant = 'determinate'
|
||||
}) => {
|
||||
// Clamp value between 0 and 100
|
||||
const clampedValue = Math.min(Math.max(value, 0), 100);
|
||||
|
||||
// Get color based on value if not provided
|
||||
const getColor = () => {
|
||||
if (color) return color;
|
||||
if (clampedValue >= 90) return '#ef4444'; // Red
|
||||
if (clampedValue >= 75) return '#f59e0b'; // Orange
|
||||
if (clampedValue >= 50) return '#eab308'; // Yellow
|
||||
return '#22c55e'; // Green
|
||||
};
|
||||
|
||||
const displayValue = maxValue
|
||||
? `${Math.round(clampedValue)} / ${maxValue}`
|
||||
: `${Math.round(clampedValue)}%`;
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
{(showLabel || showPercentage) && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
{showLabel && label && (
|
||||
<Typography variant="caption" sx={{ fontSize: '0.75rem', opacity: 0.8 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
)}
|
||||
{showPercentage && (
|
||||
<Typography variant="caption" sx={{ fontSize: '0.75rem', fontWeight: 'bold' }}>
|
||||
{displayValue}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ position: 'relative', width: '100%', height }}>
|
||||
<LinearProgress
|
||||
variant={variant}
|
||||
value={clampedValue}
|
||||
sx={{
|
||||
height,
|
||||
borderRadius: height / 2,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: height / 2,
|
||||
backgroundColor: getColor(),
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Animated overlay for smoother animation */}
|
||||
<motion.div
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: clampedValue / 100 }}
|
||||
transition={{
|
||||
duration: 1,
|
||||
ease: "easeOut",
|
||||
delay: 0.2
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
transformOrigin: 'left',
|
||||
backgroundColor: getColor(),
|
||||
borderRadius: height / 2,
|
||||
opacity: 0.3,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedProgressBar;
|
||||
148
frontend/src/components/shared/MiniSparkline.tsx
Normal file
148
frontend/src/components/shared/MiniSparkline.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import {
|
||||
LazyLineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
ChartLoadingFallback
|
||||
} from '../../utils/lazyRecharts';
|
||||
|
||||
interface MiniSparklineProps {
|
||||
data: Array<{ date: string; value: number }>;
|
||||
color: string;
|
||||
height?: number;
|
||||
showArea?: boolean;
|
||||
formatValue?: (value: number) => string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MiniSparkline - Enhanced trend line chart for metric cards with axes and tooltips
|
||||
*
|
||||
* Usage:
|
||||
* <MiniSparkline
|
||||
* data={last7DaysData}
|
||||
* color="#4ade80"
|
||||
* height={60}
|
||||
* formatValue={(v) => `$${v.toFixed(2)}`}
|
||||
* label="Cost"
|
||||
* />
|
||||
*/
|
||||
export const MiniSparkline: React.FC<MiniSparklineProps> = ({
|
||||
data,
|
||||
color,
|
||||
height = 60,
|
||||
showArea = false,
|
||||
formatValue = (v) => v.toLocaleString(),
|
||||
label = 'Value'
|
||||
}) => {
|
||||
// Ensure we have data
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<Box sx={{ height, width: '100%', mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.7rem' }}>
|
||||
No data available
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// If only one data point, duplicate it for visual consistency
|
||||
const chartData = data.length === 1
|
||||
? [data[0], { ...data[0], value: data[0].value }]
|
||||
: data;
|
||||
|
||||
// Calculate min/max for Y-axis domain
|
||||
const values = chartData.map(d => d.value);
|
||||
const minValue = Math.min(...values);
|
||||
const maxValue = Math.max(...values);
|
||||
const padding = (maxValue - minValue) * 0.1 || 0.1;
|
||||
|
||||
// Format date for X-axis
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return dateStr;
|
||||
// Show day of month for daily data
|
||||
return date.getDate().toString();
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
// Custom tooltip
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
color: 'white',
|
||||
padding: 1,
|
||||
borderRadius: 1,
|
||||
border: `1px solid ${color}`,
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ display: 'block', fontWeight: 'bold', mb: 0.5 }}>
|
||||
{new Date(data.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: color }}>
|
||||
{label}: {formatValue(data.value)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ height, width: '100%', mt: 1 }}>
|
||||
<Suspense fallback={<ChartLoadingFallback />}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LazyLineChart
|
||||
data={chartData}
|
||||
margin={{ top: 5, right: 5, bottom: 20, left: 5 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDate}
|
||||
stroke="rgba(255,255,255,0.5)"
|
||||
tick={{ fill: 'rgba(255,255,255,0.5)', fontSize: 10 }}
|
||||
height={20}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[minValue - padding, maxValue + padding]}
|
||||
tickFormatter={(value) => {
|
||||
if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
|
||||
if (value >= 1) return value.toFixed(0);
|
||||
return value.toFixed(2);
|
||||
}}
|
||||
stroke="rgba(255,255,255,0.5)"
|
||||
tick={{ fill: 'rgba(255,255,255,0.5)', fontSize: 10 }}
|
||||
width={40}
|
||||
/>
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={{ fill: color, r: 3 }}
|
||||
activeDot={{ r: 5, fill: color }}
|
||||
isAnimationActive={true}
|
||||
animationDuration={1000}
|
||||
animationBegin={0}
|
||||
/>
|
||||
</LazyLineChart>
|
||||
</ResponsiveContainer>
|
||||
</Suspense>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniSparkline;
|
||||
166
frontend/src/components/shared/Priority2AlertBanner.tsx
Normal file
166
frontend/src/components/shared/Priority2AlertBanner.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Priority 2 Alert Banner Component
|
||||
*
|
||||
* Displays Priority 2 alerts (cost trends, pricing changes, OSS recommendations)
|
||||
* in a prominent banner format for the main dashboard.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
Collapse,
|
||||
Stack,
|
||||
Chip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Info,
|
||||
XCircle,
|
||||
TrendingUp,
|
||||
DollarSign,
|
||||
X,
|
||||
Lightbulb,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Priority2Alert } from '../../hooks/usePriority2Alerts';
|
||||
|
||||
interface Priority2AlertBannerProps {
|
||||
alerts: Priority2Alert[];
|
||||
onDismiss: (alertId: string) => void;
|
||||
maxAlerts?: number;
|
||||
}
|
||||
|
||||
const Priority2AlertBanner: React.FC<Priority2AlertBannerProps> = ({
|
||||
alerts,
|
||||
onDismiss,
|
||||
maxAlerts = 3
|
||||
}) => {
|
||||
const [expanded, setExpanded] = React.useState(true);
|
||||
const [dismissedIds, setDismissedIds] = React.useState<Set<string>>(new Set());
|
||||
|
||||
// Filter out dismissed alerts
|
||||
const visibleAlerts = alerts.filter(alert => !dismissedIds.has(alert.id));
|
||||
const displayAlerts = visibleAlerts.slice(0, maxAlerts);
|
||||
const remainingCount = visibleAlerts.length - maxAlerts;
|
||||
|
||||
const getSeverityIcon = (severity: string, type: string) => {
|
||||
if (type === 'cost_trend') return <TrendingUp size={20} />;
|
||||
if (type === 'oss_recommendation') return <Lightbulb size={20} />;
|
||||
if (type === 'pricing_change') return <DollarSign size={20} />;
|
||||
|
||||
switch (severity) {
|
||||
case 'error':
|
||||
return <XCircle size={20} />;
|
||||
case 'warning':
|
||||
return <AlertTriangle size={20} />;
|
||||
default:
|
||||
return <Info size={20} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'error':
|
||||
return 'error';
|
||||
case 'warning':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = (alertId: string) => {
|
||||
setDismissedIds(prev => new Set([...prev, alertId]));
|
||||
onDismiss(alertId);
|
||||
};
|
||||
|
||||
if (displayAlerts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<AnimatePresence>
|
||||
{displayAlerts.map((alert, index) => (
|
||||
<motion.div
|
||||
key={alert.id}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.1 }}
|
||||
>
|
||||
<Alert
|
||||
severity={getSeverityColor(alert.severity)}
|
||||
icon={getSeverityIcon(alert.severity, alert.type)}
|
||||
action={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{alert.action && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={alert.action.onClick}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
{alert.action.label}
|
||||
</Button>
|
||||
)}
|
||||
{alert.dismissible && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDismiss(alert.id)}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiAlert-icon': {
|
||||
alignItems: 'center'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertTitle sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
{alert.title}
|
||||
</AlertTitle>
|
||||
{alert.message}
|
||||
{alert.type && (
|
||||
<Chip
|
||||
label={alert.type.replace('_', ' ')}
|
||||
size="small"
|
||||
sx={{ mt: 1, ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Alert>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{remainingCount > 0 && (
|
||||
<Collapse in={expanded}>
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
<AlertTitle>
|
||||
{remainingCount} more alert{remainingCount > 1 ? 's' : ''} available
|
||||
</AlertTitle>
|
||||
View all alerts in the{' '}
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => window.location.href = '/billing'}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Billing Dashboard
|
||||
</Button>
|
||||
</Alert>
|
||||
</Collapse>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Priority2AlertBanner;
|
||||
@@ -111,7 +111,6 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
}
|
||||
|
||||
// All checks passed - render protected component
|
||||
console.log('ProtectedRoute: Access granted (context/local), rendering component');
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
} from '@mui/icons-material';
|
||||
import { apiClient } from '../../api/client';
|
||||
import { useSubscription } from '../../contexts/SubscriptionContext';
|
||||
import { usePriority2Alerts } from '../../hooks/usePriority2Alerts';
|
||||
import Priority2AlertBanner from './Priority2AlertBanner';
|
||||
|
||||
interface UsageStats {
|
||||
total_calls: number;
|
||||
@@ -84,6 +86,13 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
|
||||
|
||||
const userId = localStorage.getItem('user_id');
|
||||
|
||||
// Priority 2 Alerts - automatically appears in all tool headers
|
||||
const { alerts: priority2Alerts, dismissAlert: dismissPriority2Alert } = usePriority2Alerts({
|
||||
userId: userId || undefined,
|
||||
enabled: !!userId && subscription?.active,
|
||||
checkInterval: 120000, // Check every 2 minutes
|
||||
});
|
||||
|
||||
const fetchUsageData = async () => {
|
||||
if (!userId) return;
|
||||
|
||||
@@ -175,19 +184,34 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return <Box />; // Return empty box instead of null
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
// Compact view - show key metrics as chips
|
||||
const totalCalls = dashboardData.summary.total_api_calls_this_month;
|
||||
const totalCost = dashboardData.summary.total_cost_this_month;
|
||||
// Use current_usage for accurate cost (properly coerced from provider breakdown)
|
||||
// Fallback to summary if current_usage is not available
|
||||
const totalCalls = dashboardData.current_usage?.total_calls ?? dashboardData.summary.total_api_calls_this_month;
|
||||
const totalCost = dashboardData.current_usage?.total_cost ?? dashboardData.summary.total_cost_this_month ?? 0;
|
||||
const monthlyLimit = dashboardData.limits.limits.monthly_cost;
|
||||
const usagePercentage = (totalCost / monthlyLimit) * 100;
|
||||
const usagePercentage = monthlyLimit > 0 ? (totalCost / monthlyLimit) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{/* Total API Calls */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{/* Priority 2 Alerts - Shows cost trends, OSS recommendations, spending velocity */}
|
||||
{priority2Alerts.length > 0 && (
|
||||
<Box sx={{ mb: 0.5 }}>
|
||||
<Priority2AlertBanner
|
||||
alerts={priority2Alerts}
|
||||
onDismiss={dismissPriority2Alert}
|
||||
maxAlerts={1} // Show only 1 alert in compact view
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Usage Statistics */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{/* Total API Calls */}
|
||||
<Tooltip title={`${totalCalls.toLocaleString()} API calls this month`}>
|
||||
<Chip
|
||||
icon={getUsageStatusIcon(dashboardData.summary.usage_status)}
|
||||
@@ -298,6 +322,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
|
||||
</Box>
|
||||
)}
|
||||
</Menu>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -316,7 +341,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
|
||||
Total API Calls
|
||||
</Typography>
|
||||
<Typography variant="h4" color="primary">
|
||||
{dashboardData.summary.total_api_calls_this_month.toLocaleString()}
|
||||
{(dashboardData.current_usage?.total_calls ?? dashboardData.summary.total_api_calls_this_month).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -326,7 +351,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
|
||||
Monthly Cost
|
||||
</Typography>
|
||||
<Typography variant="h4" color="secondary">
|
||||
${dashboardData.summary.total_cost_this_month.toFixed(2)}
|
||||
${(dashboardData.current_usage?.total_cost ?? dashboardData.summary.total_cost_this_month ?? 0).toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
of ${dashboardData.limits.limits.monthly_cost} limit
|
||||
|
||||
131
frontend/src/components/shared/UsageLimitRing.tsx
Normal file
131
frontend/src/components/shared/UsageLimitRing.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { LazyPieChart, Pie, Cell, ResponsiveContainer, ChartLoadingFallback } from '../../utils/lazyRecharts';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface UsageLimitRingProps {
|
||||
used: number;
|
||||
limit: number;
|
||||
label: string;
|
||||
color: string;
|
||||
size?: number;
|
||||
showValue?: boolean;
|
||||
terminalTheme?: boolean;
|
||||
terminalColors?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* UsageLimitRing - Circular progress ring showing usage vs limit
|
||||
*
|
||||
* Usage:
|
||||
* <UsageLimitRing
|
||||
* used={75}
|
||||
* limit={100}
|
||||
* label="Calls"
|
||||
* color="#4ade80"
|
||||
* />
|
||||
*/
|
||||
export const UsageLimitRing: React.FC<UsageLimitRingProps> = ({
|
||||
used,
|
||||
limit,
|
||||
label,
|
||||
color,
|
||||
size = 120,
|
||||
showValue = true,
|
||||
terminalTheme = false,
|
||||
terminalColors
|
||||
}) => {
|
||||
const percentage = limit > 0 ? Math.min((used / limit) * 100, 100) : 0;
|
||||
const remaining = Math.max(0, limit - used);
|
||||
|
||||
const data = [
|
||||
{ name: 'Used', value: used },
|
||||
{ name: 'Remaining', value: remaining }
|
||||
];
|
||||
|
||||
// Determine color based on percentage
|
||||
const getRingColor = () => {
|
||||
if (percentage >= 90) return '#ef4444'; // Red
|
||||
if (percentage >= 75) return '#f59e0b'; // Orange
|
||||
if (percentage >= 50) return '#eab308'; // Yellow
|
||||
return color || '#22c55e'; // Green or provided color
|
||||
};
|
||||
|
||||
const ringColor = getRingColor();
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', width: size, height: size }}>
|
||||
<Suspense fallback={<ChartLoadingFallback />}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LazyPieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={size * 0.35}
|
||||
outerRadius={size * 0.45}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
dataKey="value"
|
||||
animationBegin={0}
|
||||
animationDuration={1000}
|
||||
>
|
||||
<Cell fill={ringColor} />
|
||||
<Cell fill={terminalTheme
|
||||
? (terminalColors?.backgroundLight || 'rgba(255,255,255,0.1)')
|
||||
: 'rgba(255,255,255,0.1)'}
|
||||
/>
|
||||
</Pie>
|
||||
</LazyPieChart>
|
||||
</ResponsiveContainer>
|
||||
</Suspense>
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
{showValue && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.3, duration: 0.4 }}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: terminalTheme
|
||||
? (terminalColors?.text || '#ffffff')
|
||||
: '#ffffff',
|
||||
fontSize: size * 0.15,
|
||||
lineHeight: 1.2
|
||||
}}
|
||||
>
|
||||
{Math.round(percentage)}%
|
||||
</Typography>
|
||||
</motion.div>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: size * 0.08,
|
||||
color: terminalTheme
|
||||
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
|
||||
: 'rgba(255,255,255,0.7)',
|
||||
display: 'block',
|
||||
mt: 0.5
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageLimitRing;
|
||||
Reference in New Issue
Block a user