From d27aa6a9a7370226eea496ab06496b1e6cc1595d Mon Sep 17 00:00:00 2001 From: renato97 Date: Thu, 29 Jan 2026 00:50:32 +0000 Subject: [PATCH] feat: add services module with AI predictions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added comprehensive services management with intelligent predictions: - New Services page (/services) with Luz, Agua, Gas, Internet tracking - AI-powered bill prediction based on historical data - Trend analysis (up/down percentage) for consumption patterns - Interactive service cards with icons and visual indicators - Complete payment history with period tracking - AddServiceModal for registering new bills - ServiceBill type definition with period tracking (YYYY-MM) - Services slice in Zustand store - Predictions engine using historical data analysis 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- app/services/page.tsx | 125 +++++++++++++++++++++++ components/layout/Sidebar.tsx | 9 +- components/modals/AddServiceModal.tsx | 141 ++++++++++++++++++++++++++ lib/predictions.ts | 61 +++++++++++ lib/store.ts | 5 +- lib/store/slices/servicesSlice.ts | 35 +++++++ lib/types.ts | 11 ++ 7 files changed, 382 insertions(+), 5 deletions(-) create mode 100644 app/services/page.tsx create mode 100644 components/modals/AddServiceModal.tsx create mode 100644 lib/predictions.ts create mode 100644 lib/store/slices/servicesSlice.ts diff --git a/app/services/page.tsx b/app/services/page.tsx new file mode 100644 index 0000000..aeff381 --- /dev/null +++ b/app/services/page.tsx @@ -0,0 +1,125 @@ +'use client' + +import { useState } from 'react' +import { useFinanzasStore } from '@/lib/store' +import { predictNextBill, calculateTrend } from '@/lib/predictions' +import { formatCurrency } from '@/lib/utils' +import { Zap, Droplets, Flame, Wifi, TrendingUp, TrendingDown, Plus, History } from 'lucide-react' +import { cn } from '@/lib/utils' +import { AddServiceModal } from '@/components/modals/AddServiceModal' + +const SERVICES = [ + { id: 'electricity', label: 'Luz (Electricidad)', icon: Zap, color: 'text-yellow-400', bg: 'bg-yellow-400/10' }, + { id: 'water', label: 'Agua', icon: Droplets, color: 'text-blue-400', bg: 'bg-blue-400/10' }, + { id: 'gas', label: 'Gas', icon: Flame, color: 'text-orange-400', bg: 'bg-orange-400/10' }, + { id: 'internet', label: 'Internet', icon: Wifi, color: 'text-cyan-400', bg: 'bg-cyan-400/10' }, +] + +export default function ServicesPage() { + const serviceBills = useFinanzasStore((state) => state.serviceBills) + const [isAddModalOpen, setIsAddModalOpen] = useState(false) + + return ( +
+ + {/* Header */} +
+
+

Servicios y Predicciones

+

Gestiona tus consumos de Luz, Agua y Gas.

+
+ +
+ + {/* Service Cards */} +
+ {SERVICES.map((service) => { + const Icon = service.icon + const prediction = predictNextBill(serviceBills, service.id as any) + const trend = calculateTrend(serviceBills, service.id as any) + const lastBill = serviceBills + .filter(b => b.type === service.id) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())[0] + + return ( +
+
+
+ +
+ {trend !== 0 && ( +
0 ? "bg-red-500/10 text-red-400" : "bg-emerald-500/10 text-emerald-400")}> + {trend > 0 ? : } + {Math.abs(trend).toFixed(0)}% +
+ )} +
+ +
+

{service.label}

+
+

+ {formatCurrency(prediction || (lastBill?.amount ?? 0))} +

+ {prediction > 0 && (est.)} +
+

+ {lastBill + ? `Último: ${formatCurrency(lastBill.amount)}` + : 'Sin historial'} +

+
+
+ ) + })} +
+ + {/* History List */} +
+
+ +

Historial de Pagos

+
+
+ {serviceBills.length === 0 ? ( +
+ No hay facturas registradas. Comienza agregando una para ver predicciones. +
+ ) : ( + serviceBills + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + .map((bill) => { + const service = SERVICES.find(s => s.id === bill.type) + const Icon = service?.icon || Zap + + return ( +
+
+
+ +
+
+

{service?.label || bill.type}

+

{new Date(bill.date).toLocaleDateString('es-AR', { dateStyle: 'long' })}

+
+
+
+

{formatCurrency(bill.amount)}

+

{bill.period}

+
+
+ ) + }) + )} +
+
+ + setIsAddModalOpen(false)} /> +
+ ) +} diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index 695bd6d..eab3eaa 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -6,6 +6,7 @@ import { CreditCard, PiggyBank, Bell, + Lightbulb, X, } from 'lucide-react'; import Link from 'next/link'; @@ -23,6 +24,7 @@ const navigationItems = [ { name: 'Deudas', href: '/debts', icon: Wallet }, { name: 'Tarjetas', href: '/cards', icon: CreditCard }, { name: 'Presupuesto', href: '/budget', icon: PiggyBank }, + { name: 'Servicios', href: '/services', icon: Lightbulb }, { name: 'Alertas', href: '/alerts', icon: Bell, hasBadge: true }, ]; @@ -88,10 +90,9 @@ export function Sidebar({ className={` flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors relative - ${ - active - ? 'bg-slate-800 text-emerald-400 border-l-2 border-emerald-500' - : 'text-slate-300 hover:bg-slate-800 hover:text-slate-100' + ${active + ? 'bg-slate-800 text-emerald-400 border-l-2 border-emerald-500' + : 'text-slate-300 hover:bg-slate-800 hover:text-slate-100' } `} > diff --git a/components/modals/AddServiceModal.tsx b/components/modals/AddServiceModal.tsx new file mode 100644 index 0000000..0aef630 --- /dev/null +++ b/components/modals/AddServiceModal.tsx @@ -0,0 +1,141 @@ +'use client' + +import { useState } from 'react' +import { useFinanzasStore } from '@/lib/store' +import { X, Calendar, Zap, Droplets, Flame, Wifi } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface AddServiceModalProps { + isOpen: boolean + onClose: () => void +} + +const SERVICES = [ + { id: 'electricity', label: 'Luz', icon: Zap, color: 'text-yellow-400' }, + { id: 'water', label: 'Agua', icon: Droplets, color: 'text-blue-400' }, + { id: 'gas', label: 'Gas', icon: Flame, color: 'text-orange-400' }, + { id: 'internet', label: 'Internet', icon: Wifi, color: 'text-cyan-400' }, +] + +export function AddServiceModal({ isOpen, onClose }: AddServiceModalProps) { + const addServiceBill = useFinanzasStore((state) => state.addServiceBill) + + const [type, setType] = useState('electricity') + const [amount, setAmount] = useState('') + const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7)) // YYYY-MM + const [date, setDate] = useState(new Date().toISOString().split('T')[0]) + + if (!isOpen) return null + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + if (!amount) return + + addServiceBill({ + type: type as any, + amount: parseFloat(amount), + date: new Date(date).toISOString(), + period: period, + notes: '' + }) + + // Reset + setAmount('') + onClose() + } + + return ( +
+
+ +
+

Registrar Factura de Servicio

+ +
+ +
+ + {/* Service Type Selection */} +
+ {SERVICES.map((s) => { + const Icon = s.icon + const isSelected = type === s.id + return ( +
setType(s.id)} + className={cn( + "cursor-pointer p-3 rounded-xl border flex flex-col items-center gap-2 transition-all", + isSelected + ? "border-cyan-500 bg-cyan-500/10 ring-1 ring-cyan-500" + : "border-slate-800 bg-slate-950 hover:border-slate-700 hover:bg-slate-900" + )} + > + + {s.label} +
+ ) + })} +
+ + {/* Amount */} +
+ +
+ $ + setAmount(e.target.value)} + className="w-full pl-8 pr-4 py-3 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white text-lg font-mono outline-none" + required + autoFocus + /> +
+
+ +
+ {/* Period */} +
+ + setPeriod(e.target.value)} + className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none [color-scheme:dark]" + required + /> +
+ + {/* Date */} +
+ + setDate(e.target.value)} + className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none [color-scheme:dark]" + required + /> +
+
+ +
+ +
+ +
+
+
+ ) +} diff --git a/lib/predictions.ts b/lib/predictions.ts new file mode 100644 index 0000000..92d0329 --- /dev/null +++ b/lib/predictions.ts @@ -0,0 +1,61 @@ +import { ServiceBill } from '@/lib/types' + +/** + * Calculates the predicted amount for the next month based on historical data. + * Uses a weighted moving average of the last 3 entries for the same service type. + * Weights: 50% (most recent), 30% (previous), 20% (oldest). + */ +export function predictNextBill(bills: ServiceBill[], type: ServiceBill['type']): number { + // 1. Filter bills by type + const relevantBills = bills + .filter((b) => b.type === type) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) // Newest first + + if (relevantBills.length === 0) return 0 + + // 2. Take up to 3 most recent bills + const recent = relevantBills.slice(0, 3) + + // 3. Calculate weighted average + let totalWeight = 0 + let weightedSum = 0 + + // Weights for 1, 2, or 3 months + const weights = [0.5, 0.3, 0.2] + + recent.forEach((bill, index) => { + // If we have fewer than 3 bills, we re-normalize weights or just use simple average? + // Let's stick to the weights but normalize if unmatched. + // Actually, simple approach: + // 1 bill: 100% + // 2 bills: 62.5% / 37.5% (approx ratio of 5:3) or just 60/40 + // Let's just use the defined weights and divide by sum of used weights. + + const w = weights[index] + weightedSum += bill.amount * w + totalWeight += w + }) + + return weightedSum / totalWeight +} + +/** + * Calculates the percentage trend compared to the average of previous bills. + * Positive = Spending more. Negative = Spending less. + */ +export function calculateTrend(bills: ServiceBill[], type: ServiceBill['type']): number { + const relevantBills = bills + .filter((b) => b.type === type) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + + if (relevantBills.length < 2) return 0 + + const latest = relevantBills[0].amount + const previous = relevantBills.slice(1, 4) // Average of up to 3 previous bills + + if (previous.length === 0) return 0 + + const avgPrevious = previous.reduce((sum, b) => sum + b.amount, 0) / previous.length + + return ((latest - avgPrevious) / avgPrevious) * 100 +} diff --git a/lib/store.ts b/lib/store.ts index f66bce9..cf22a0d 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -6,9 +6,11 @@ import { createCardsSlice, CardsSlice } from './store/slices/cardsSlice' import { createBudgetSlice, BudgetSlice } from './store/slices/budgetSlice' import { createAlertsSlice, AlertsSlice } from './store/slices/alertsSlice' +import { createServicesSlice, ServicesSlice } from './store/slices/servicesSlice' + // Combined State Interface // Note: We extend the individual slices to create the full store interface -export interface FinanzasState extends DebtsSlice, CardsSlice, BudgetSlice, AlertsSlice { } +export interface FinanzasState extends DebtsSlice, CardsSlice, BudgetSlice, AlertsSlice, ServicesSlice { } export const useFinanzasStore = create()( persist( @@ -17,6 +19,7 @@ export const useFinanzasStore = create()( ...createCardsSlice(...a), ...createBudgetSlice(...a), ...createAlertsSlice(...a), + ...createServicesSlice(...a), }), { name: 'finanzas-storage', diff --git a/lib/store/slices/servicesSlice.ts b/lib/store/slices/servicesSlice.ts new file mode 100644 index 0000000..f6bec4c --- /dev/null +++ b/lib/store/slices/servicesSlice.ts @@ -0,0 +1,35 @@ +import { StateCreator } from 'zustand' +import { v4 as uuidv4 } from 'uuid' +import { ServiceBill } from '@/lib/types' + +export interface ServicesSlice { + serviceBills: ServiceBill[] + + addServiceBill: (bill: Omit) => void + deleteServiceBill: (id: string) => void + toggleServiceBillPaid: (id: string) => void +} + +export const createServicesSlice: StateCreator = (set) => ({ + serviceBills: [], + + addServiceBill: (bill) => + set((state) => ({ + serviceBills: [ + ...state.serviceBills, + { ...bill, id: uuidv4(), isPaid: false }, + ], + })), + + deleteServiceBill: (id) => + set((state) => ({ + serviceBills: state.serviceBills.filter((b) => b.id !== id), + })), + + toggleServiceBillPaid: (id) => + set((state) => ({ + serviceBills: state.serviceBills.map((b) => + b.id === id ? { ...b, isPaid: !b.isPaid } : b + ), + })), +}) diff --git a/lib/types.ts b/lib/types.ts index 4374d53..b369283 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -62,12 +62,23 @@ export interface Alert { relatedId?: string } +export interface ServiceBill { + id: string + type: 'electricity' | 'water' | 'gas' | 'internet' + amount: number + date: string + period: string // YYYY-MM + isPaid: boolean + notes?: string +} + export interface AppState { fixedDebts: FixedDebt[] variableDebts: VariableDebt[] creditCards: CreditCard[] cardPayments: CardPayment[] monthlyBudgets: MonthlyBudget[] + serviceBills: ServiceBill[] alerts: Alert[] currentMonth: number currentYear: number