Phase 3 - credts, usage, plans app pages #Migrations
This commit is contained in:
450
frontend/src/components/billing/CreditInsightsCharts.tsx
Normal file
450
frontend/src/components/billing/CreditInsightsCharts.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* Credit Insights Charts Component
|
||||
* Displays credit usage analytics with visual charts
|
||||
* - Donut chart: Credits by operation type
|
||||
* - Line chart: Daily credit usage timeline
|
||||
* - Bar chart: Top credit-consuming operations
|
||||
*/
|
||||
|
||||
import Chart from 'react-apexcharts';
|
||||
import { ApexOptions } from 'apexcharts';
|
||||
import { Card } from '../ui/card';
|
||||
import { ActivityIcon, TrendingUpIcon, PieChartIcon, BarChart3Icon } from '../../icons';
|
||||
import type { UsageAnalytics } from '../../services/billing.api';
|
||||
|
||||
interface CreditInsightsChartsProps {
|
||||
analytics: UsageAnalytics | null;
|
||||
loading?: boolean;
|
||||
period: number;
|
||||
}
|
||||
|
||||
// Friendly names for operation types
|
||||
const OPERATION_LABELS: Record<string, string> = {
|
||||
content_generation: 'Content Generation',
|
||||
image_generation: 'Image Generation',
|
||||
keyword_clustering: 'Keyword Clustering',
|
||||
content_analysis: 'Content Analysis',
|
||||
subscription: 'Subscription',
|
||||
purchase: 'Credit Purchase',
|
||||
refund: 'Refund',
|
||||
adjustment: 'Adjustment',
|
||||
grant: 'Credit Grant',
|
||||
deduction: 'Deduction',
|
||||
};
|
||||
|
||||
const CHART_COLORS = [
|
||||
'var(--color-brand-500)',
|
||||
'var(--color-purple-500)',
|
||||
'var(--color-success-500)',
|
||||
'var(--color-warning-500)',
|
||||
'var(--color-error-500)',
|
||||
'var(--color-info-500)',
|
||||
'#6366f1', // indigo
|
||||
'#ec4899', // pink
|
||||
'#14b8a6', // teal
|
||||
'#f97316', // orange
|
||||
];
|
||||
|
||||
export default function CreditInsightsCharts({ analytics, loading, period }: CreditInsightsChartsProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i} className="p-6 animate-pulse">
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-4"></div>
|
||||
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!analytics) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<PieChartIcon className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p>No analytics data available</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare data for donut chart (credits by operation type)
|
||||
const usageByType = analytics.usage_by_type.filter(item => Math.abs(item.total) > 0);
|
||||
const donutLabels = usageByType.map(item => OPERATION_LABELS[item.transaction_type] || item.transaction_type.replace(/_/g, ' '));
|
||||
const donutSeries = usageByType.map(item => Math.abs(item.total));
|
||||
|
||||
const donutOptions: ApexOptions = {
|
||||
chart: {
|
||||
type: 'donut',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
},
|
||||
labels: donutLabels,
|
||||
colors: CHART_COLORS.slice(0, donutLabels.length),
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
fontFamily: 'Outfit',
|
||||
labels: {
|
||||
colors: 'var(--color-gray-600)',
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
pie: {
|
||||
donut: {
|
||||
size: '65%',
|
||||
labels: {
|
||||
show: true,
|
||||
name: {
|
||||
show: true,
|
||||
fontSize: '14px',
|
||||
fontFamily: 'Outfit',
|
||||
color: 'var(--color-gray-600)',
|
||||
},
|
||||
value: {
|
||||
show: true,
|
||||
fontSize: '24px',
|
||||
fontFamily: 'Outfit',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-gray-900)',
|
||||
formatter: (val: string) => parseInt(val).toLocaleString(),
|
||||
},
|
||||
total: {
|
||||
show: true,
|
||||
label: 'Total Credits',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'Outfit',
|
||||
color: 'var(--color-gray-600)',
|
||||
formatter: () => analytics.total_usage.toLocaleString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
tooltip: {
|
||||
y: {
|
||||
formatter: (val: number) => `${val.toLocaleString()} credits`,
|
||||
},
|
||||
},
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 480,
|
||||
options: {
|
||||
chart: {
|
||||
width: 300,
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Prepare data for timeline chart (daily usage)
|
||||
const dailyData = analytics.daily_usage || [];
|
||||
const timelineCategories = dailyData.map(d => {
|
||||
const date = new Date(d.date);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
});
|
||||
const usageSeries = dailyData.map(d => Math.abs(d.usage));
|
||||
const purchasesSeries = dailyData.map(d => d.purchases);
|
||||
|
||||
const timelineOptions: ApexOptions = {
|
||||
chart: {
|
||||
type: 'area',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
height: 300,
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
zoom: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
colors: ['var(--color-brand-500)', 'var(--color-success-500)'],
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
stroke: {
|
||||
curve: 'smooth',
|
||||
width: 2,
|
||||
},
|
||||
fill: {
|
||||
type: 'gradient',
|
||||
gradient: {
|
||||
shadeIntensity: 1,
|
||||
opacityFrom: 0.4,
|
||||
opacityTo: 0.1,
|
||||
stops: [0, 90, 100],
|
||||
},
|
||||
},
|
||||
xaxis: {
|
||||
categories: timelineCategories,
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
labels: {
|
||||
style: {
|
||||
colors: 'var(--color-gray-500)',
|
||||
fontFamily: 'Outfit',
|
||||
},
|
||||
rotate: -45,
|
||||
rotateAlways: dailyData.length > 14,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
colors: 'var(--color-gray-500)',
|
||||
fontFamily: 'Outfit',
|
||||
},
|
||||
formatter: (val: number) => val.toLocaleString(),
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
borderColor: 'var(--color-gray-200)',
|
||||
strokeDashArray: 4,
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
horizontalAlign: 'right',
|
||||
fontFamily: 'Outfit',
|
||||
labels: {
|
||||
colors: 'var(--color-gray-600)',
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
y: {
|
||||
formatter: (val: number) => `${val.toLocaleString()} credits`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const timelineSeries = [
|
||||
{ name: 'Credits Used', data: usageSeries },
|
||||
{ name: 'Credits Added', data: purchasesSeries },
|
||||
];
|
||||
|
||||
// Prepare data for bar chart (top operations by count)
|
||||
const operationsByCount = [...analytics.usage_by_type]
|
||||
.filter(item => item.count > 0)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 8);
|
||||
|
||||
const barCategories = operationsByCount.map(item =>
|
||||
OPERATION_LABELS[item.transaction_type] || item.transaction_type.replace(/_/g, ' ')
|
||||
);
|
||||
const barSeries = operationsByCount.map(item => item.count);
|
||||
|
||||
const barOptions: ApexOptions = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
height: 300,
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
colors: ['var(--color-purple-500)'],
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: true,
|
||||
borderRadius: 4,
|
||||
barHeight: '60%',
|
||||
},
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
xaxis: {
|
||||
categories: barCategories,
|
||||
labels: {
|
||||
style: {
|
||||
colors: 'var(--color-gray-500)',
|
||||
fontFamily: 'Outfit',
|
||||
},
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
colors: 'var(--color-gray-600)',
|
||||
fontFamily: 'Outfit',
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
borderColor: 'var(--color-gray-200)',
|
||||
strokeDashArray: 4,
|
||||
},
|
||||
tooltip: {
|
||||
y: {
|
||||
formatter: (val: number) => `${val.toLocaleString()} operations`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Summary stats
|
||||
const avgDailyUsage = dailyData.length > 0
|
||||
? Math.round(dailyData.reduce((sum, d) => sum + Math.abs(d.usage), 0) / dailyData.length)
|
||||
: 0;
|
||||
|
||||
const peakUsage = dailyData.length > 0
|
||||
? Math.max(...dailyData.map(d => Math.abs(d.usage)))
|
||||
: 0;
|
||||
|
||||
const topOperation = usageByType.length > 0
|
||||
? usageByType.reduce((max, item) => Math.abs(item.total) > Math.abs(max.total) ? item : max, usageByType[0])
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="p-4 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||
<TrendingUpIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">Avg Daily Usage</div>
|
||||
<div className="text-xl font-bold text-brand-600 dark:text-brand-400">
|
||||
{avgDailyUsage.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">credits/day</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<BarChart3Icon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">Peak Usage</div>
|
||||
<div className="text-xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{peakUsage.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">credits in one day</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||
<ActivityIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">Top Operation</div>
|
||||
<div className="text-base font-bold text-success-600 dark:text-success-400 truncate max-w-[150px]">
|
||||
{topOperation
|
||||
? (OPERATION_LABELS[topOperation.transaction_type] || topOperation.transaction_type.replace(/_/g, ' '))
|
||||
: 'N/A'
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{topOperation ? `${Math.abs(topOperation.total).toLocaleString()} credits` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Usage by Type - Donut Chart */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||
<PieChartIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Credits by Type
|
||||
</h3>
|
||||
</div>
|
||||
{donutSeries.length > 0 ? (
|
||||
<Chart
|
||||
options={donutOptions}
|
||||
series={donutSeries}
|
||||
type="donut"
|
||||
height={320}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||
<div className="text-center">
|
||||
<PieChartIcon className="w-12 h-12 mx-auto mb-2 opacity-30" />
|
||||
<p>No usage data for this period</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Operations by Count - Bar Chart */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<BarChart3Icon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Operations Count
|
||||
</h3>
|
||||
</div>
|
||||
{barSeries.length > 0 ? (
|
||||
<Chart
|
||||
options={barOptions}
|
||||
series={[{ name: 'Operations', data: barSeries }]}
|
||||
type="bar"
|
||||
height={320}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||
<div className="text-center">
|
||||
<BarChart3Icon className="w-12 h-12 mx-auto mb-2 opacity-30" />
|
||||
<p>No operations in this period</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Daily Timeline - Full Width */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||
<TrendingUpIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Credit Activity Timeline
|
||||
</h3>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Last {period} days
|
||||
</span>
|
||||
</div>
|
||||
{dailyData.length > 0 ? (
|
||||
<Chart
|
||||
options={timelineOptions}
|
||||
series={timelineSeries}
|
||||
type="area"
|
||||
height={300}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||
<div className="text-center">
|
||||
<TrendingUpIcon className="w-12 h-12 mx-auto mb-2 opacity-30" />
|
||||
<p>No daily activity data available</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user