Complete personal finance management application with: - Dashboard with financial metrics and alerts - Credit card management and payments - Fixed and variable debt tracking - Monthly budget planning - Intelligent alert system - Responsive design with Tailwind CSS Tech stack: Next.js 14, TypeScript, Zustand, Recharts 🤖 Generated with [Claude Code](https://claude.com/claude-code)
169 lines
5.1 KiB
TypeScript
169 lines
5.1 KiB
TypeScript
'use client'
|
|
|
|
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
|
|
import { FixedDebt, VariableDebt } from '@/lib/types'
|
|
import { formatCurrency } from '@/lib/utils'
|
|
|
|
interface ExpenseChartProps {
|
|
fixedDebts: FixedDebt[]
|
|
variableDebts: VariableDebt[]
|
|
}
|
|
|
|
// Colores por categoría
|
|
const CATEGORY_COLORS: Record<string, string> = {
|
|
// Deudas fijas
|
|
housing: '#10b981', // emerald-500
|
|
services: '#3b82f6', // blue-500
|
|
subscription: '#8b5cf6', // violet-500
|
|
other: '#64748b', // slate-500
|
|
// Deudas variables
|
|
shopping: '#f59e0b', // amber-500
|
|
food: '#ef4444', // red-500
|
|
entertainment: '#ec4899', // pink-500
|
|
health: '#06b6d4', // cyan-500
|
|
transport: '#84cc16', // lime-500
|
|
}
|
|
|
|
// Nombres de categorías en español
|
|
const CATEGORY_NAMES: Record<string, string> = {
|
|
housing: 'Vivienda',
|
|
services: 'Servicios',
|
|
subscription: 'Suscripciones',
|
|
other: 'Otros',
|
|
shopping: 'Compras',
|
|
food: 'Comida',
|
|
entertainment: 'Entretenimiento',
|
|
health: 'Salud',
|
|
transport: 'Transporte',
|
|
}
|
|
|
|
interface ChartData {
|
|
name: string
|
|
value: number
|
|
color: string
|
|
category: string
|
|
}
|
|
|
|
export function ExpenseChart({ fixedDebts, variableDebts }: ExpenseChartProps) {
|
|
// Agrupar gastos por categoría
|
|
const categoryTotals = new Map<string, number>()
|
|
|
|
// Agregar deudas fijas no pagadas
|
|
fixedDebts
|
|
.filter((debt) => !debt.isPaid)
|
|
.forEach((debt) => {
|
|
const current = categoryTotals.get(debt.category) || 0
|
|
categoryTotals.set(debt.category, current + debt.amount)
|
|
})
|
|
|
|
// Agregar deudas variables no pagadas
|
|
variableDebts
|
|
.filter((debt) => !debt.isPaid)
|
|
.forEach((debt) => {
|
|
const current = categoryTotals.get(debt.category) || 0
|
|
categoryTotals.set(debt.category, current + debt.amount)
|
|
})
|
|
|
|
// Convertir a formato de datos para el gráfico
|
|
const data: ChartData[] = Array.from(categoryTotals.entries())
|
|
.map(([category, value]) => ({
|
|
name: CATEGORY_NAMES[category] || category,
|
|
value,
|
|
color: CATEGORY_COLORS[category] || '#64748b',
|
|
category,
|
|
}))
|
|
.filter((item) => item.value > 0)
|
|
.sort((a, b) => b.value - a.value)
|
|
|
|
// Calcular total
|
|
const total = data.reduce((sum, item) => sum + item.value, 0)
|
|
|
|
if (data.length === 0) {
|
|
return (
|
|
<div className="flex h-64 items-center justify-center rounded-xl border border-slate-700 bg-slate-800">
|
|
<p className="text-slate-500">No hay gastos pendientes</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-xl border border-slate-700 bg-slate-800 p-6">
|
|
<h3 className="mb-4 text-lg font-semibold text-white">
|
|
Distribución de Gastos
|
|
</h3>
|
|
|
|
<div className="flex flex-col gap-6 lg:flex-row">
|
|
{/* Gráfico de dona */}
|
|
<div className="h-64 w-full lg:w-1/2">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={data}
|
|
cx="50%"
|
|
cy="50%"
|
|
innerRadius={60}
|
|
outerRadius={90}
|
|
paddingAngle={2}
|
|
dataKey="value"
|
|
>
|
|
{data.map((entry, index) => (
|
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip
|
|
formatter={(value) =>
|
|
typeof value === 'number' ? formatCurrency(value) : value
|
|
}
|
|
contentStyle={{
|
|
backgroundColor: '#1e293b',
|
|
border: '1px solid #334155',
|
|
borderRadius: '8px',
|
|
color: '#fff',
|
|
}}
|
|
/>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
{/* Leyenda */}
|
|
<div className="flex w-full flex-col justify-center gap-3 lg:w-1/2">
|
|
{data.map((item) => {
|
|
const percentage = total > 0 ? (item.value / total) * 100 : 0
|
|
return (
|
|
<div key={item.category} className="flex items-center gap-3">
|
|
<div
|
|
className="h-4 w-4 rounded-full"
|
|
style={{ backgroundColor: item.color }}
|
|
/>
|
|
<div className="flex-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-slate-300">
|
|
{item.name}
|
|
</span>
|
|
<span className="text-sm text-slate-400">
|
|
{percentage.toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-slate-500">
|
|
{formatCurrency(item.value)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* Total */}
|
|
<div className="mt-4 border-t border-slate-700 pt-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-slate-400">Total</span>
|
|
<span className="font-mono text-lg font-bold text-emerald-400">
|
|
{formatCurrency(total)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|