feat: add services module with AI predictions
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)
This commit is contained in:
125
app/services/page.tsx
Normal file
125
app/services/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Servicios y Predicciones</h1>
|
||||||
|
<p className="text-slate-400 text-sm">Gestiona tus consumos de Luz, Agua y Gas.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAddModalOpen(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-cyan-500 hover:bg-cyan-400 text-white rounded-lg transition shadow-lg shadow-cyan-500/20 font-medium self-start sm:self-auto"
|
||||||
|
>
|
||||||
|
<Plus size={18} /> Nuevo Pago
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{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 (
|
||||||
|
<div key={service.id} className="bg-slate-900 border border-slate-800 rounded-xl p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className={cn("p-2 rounded-lg", service.bg)}>
|
||||||
|
<Icon className={cn("w-6 h-6", service.color)} />
|
||||||
|
</div>
|
||||||
|
{trend !== 0 && (
|
||||||
|
<div className={cn("flex items-center gap-1 text-xs font-medium px-2 py-1 rounded-full", trend > 0 ? "bg-red-500/10 text-red-400" : "bg-emerald-500/10 text-emerald-400")}>
|
||||||
|
{trend > 0 ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
|
||||||
|
{Math.abs(trend).toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-400 text-sm font-medium">{service.label}</p>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<h3 className="text-2xl font-bold text-white mt-1">
|
||||||
|
{formatCurrency(prediction || (lastBill?.amount ?? 0))}
|
||||||
|
</h3>
|
||||||
|
{prediction > 0 && <span className="text-xs text-slate-500 font-mono">(est.)</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
{lastBill
|
||||||
|
? `Último: ${formatCurrency(lastBill.amount)}`
|
||||||
|
: 'Sin historial'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History List */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden">
|
||||||
|
<div className="p-5 border-b border-slate-800 flex items-center gap-2">
|
||||||
|
<History size={18} className="text-slate-400" />
|
||||||
|
<h3 className="text-lg font-semibold text-white">Historial de Pagos</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-slate-800">
|
||||||
|
{serviceBills.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-slate-500 text-sm">
|
||||||
|
No hay facturas registradas. Comienza agregando una para ver predicciones.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
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 (
|
||||||
|
<div key={bill.id} className="p-4 flex items-center justify-between hover:bg-slate-800/50 transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={cn("p-2 rounded-lg", service?.bg || 'bg-slate-800')}>
|
||||||
|
<Icon className={cn("w-5 h-5", service?.color || 'text-slate-400')} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium capitalize">{service?.label || bill.type}</p>
|
||||||
|
<p className="text-xs text-slate-500 capitalize">{new Date(bill.date).toLocaleDateString('es-AR', { dateStyle: 'long' })}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-white font-mono font-medium">{formatCurrency(bill.amount)}</p>
|
||||||
|
<p className="text-xs text-slate-500 uppercase">{bill.period}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddServiceModal isOpen={isAddModalOpen} onClose={() => setIsAddModalOpen(false)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
Bell,
|
Bell,
|
||||||
|
Lightbulb,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -23,6 +24,7 @@ const navigationItems = [
|
|||||||
{ name: 'Deudas', href: '/debts', icon: Wallet },
|
{ name: 'Deudas', href: '/debts', icon: Wallet },
|
||||||
{ name: 'Tarjetas', href: '/cards', icon: CreditCard },
|
{ name: 'Tarjetas', href: '/cards', icon: CreditCard },
|
||||||
{ name: 'Presupuesto', href: '/budget', icon: PiggyBank },
|
{ name: 'Presupuesto', href: '/budget', icon: PiggyBank },
|
||||||
|
{ name: 'Servicios', href: '/services', icon: Lightbulb },
|
||||||
{ name: 'Alertas', href: '/alerts', icon: Bell, hasBadge: true },
|
{ name: 'Alertas', href: '/alerts', icon: Bell, hasBadge: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -88,10 +90,9 @@ export function Sidebar({
|
|||||||
className={`
|
className={`
|
||||||
flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||||
transition-colors relative
|
transition-colors relative
|
||||||
${
|
${active
|
||||||
active
|
? 'bg-slate-800 text-emerald-400 border-l-2 border-emerald-500'
|
||||||
? 'bg-slate-800 text-emerald-400 border-l-2 border-emerald-500'
|
: 'text-slate-300 hover:bg-slate-800 hover:text-slate-100'
|
||||||
: 'text-slate-300 hover:bg-slate-800 hover:text-slate-100'
|
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
141
components/modals/AddServiceModal.tsx
Normal file
141
components/modals/AddServiceModal.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||||
|
<div className="w-full max-w-lg rounded-xl bg-slate-900 border border-slate-800 shadow-2xl overflow-hidden scale-100 animate-in zoom-in-95 duration-200">
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-slate-800">
|
||||||
|
<h2 className="text-xl font-semibold text-white">Registrar Factura de Servicio</h2>
|
||||||
|
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||||
|
|
||||||
|
{/* Service Type Selection */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{SERVICES.map((s) => {
|
||||||
|
const Icon = s.icon
|
||||||
|
const isSelected = type === s.id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => 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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn("w-6 h-6", s.color)} />
|
||||||
|
<span className={cn("text-xs font-medium", isSelected ? "text-white" : "text-slate-400")}>{s.label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Monto Factura</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-semibold">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Period */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Periodo</label>
|
||||||
|
<input
|
||||||
|
type="month"
|
||||||
|
value={period}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Fecha Pago</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full py-3 bg-cyan-500 hover:bg-cyan-400 text-white font-semibold rounded-lg shadow-lg shadow-cyan-500/20 transition-all active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
Guardar Factura
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
lib/predictions.ts
Normal file
61
lib/predictions.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -6,9 +6,11 @@ import { createCardsSlice, CardsSlice } from './store/slices/cardsSlice'
|
|||||||
import { createBudgetSlice, BudgetSlice } from './store/slices/budgetSlice'
|
import { createBudgetSlice, BudgetSlice } from './store/slices/budgetSlice'
|
||||||
import { createAlertsSlice, AlertsSlice } from './store/slices/alertsSlice'
|
import { createAlertsSlice, AlertsSlice } from './store/slices/alertsSlice'
|
||||||
|
|
||||||
|
import { createServicesSlice, ServicesSlice } from './store/slices/servicesSlice'
|
||||||
|
|
||||||
// Combined State Interface
|
// Combined State Interface
|
||||||
// Note: We extend the individual slices to create the full store 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<FinanzasState>()(
|
export const useFinanzasStore = create<FinanzasState>()(
|
||||||
persist(
|
persist(
|
||||||
@@ -17,6 +19,7 @@ export const useFinanzasStore = create<FinanzasState>()(
|
|||||||
...createCardsSlice(...a),
|
...createCardsSlice(...a),
|
||||||
...createBudgetSlice(...a),
|
...createBudgetSlice(...a),
|
||||||
...createAlertsSlice(...a),
|
...createAlertsSlice(...a),
|
||||||
|
...createServicesSlice(...a),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'finanzas-storage',
|
name: 'finanzas-storage',
|
||||||
|
|||||||
35
lib/store/slices/servicesSlice.ts
Normal file
35
lib/store/slices/servicesSlice.ts
Normal file
@@ -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<ServiceBill, 'id' | 'isPaid'>) => void
|
||||||
|
deleteServiceBill: (id: string) => void
|
||||||
|
toggleServiceBillPaid: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createServicesSlice: StateCreator<ServicesSlice> = (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
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
})
|
||||||
11
lib/types.ts
11
lib/types.ts
@@ -62,12 +62,23 @@ export interface Alert {
|
|||||||
relatedId?: string
|
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 {
|
export interface AppState {
|
||||||
fixedDebts: FixedDebt[]
|
fixedDebts: FixedDebt[]
|
||||||
variableDebts: VariableDebt[]
|
variableDebts: VariableDebt[]
|
||||||
creditCards: CreditCard[]
|
creditCards: CreditCard[]
|
||||||
cardPayments: CardPayment[]
|
cardPayments: CardPayment[]
|
||||||
monthlyBudgets: MonthlyBudget[]
|
monthlyBudgets: MonthlyBudget[]
|
||||||
|
serviceBills: ServiceBill[]
|
||||||
alerts: Alert[]
|
alerts: Alert[]
|
||||||
currentMonth: number
|
currentMonth: number
|
||||||
currentYear: number
|
currentYear: number
|
||||||
|
|||||||
Reference in New Issue
Block a user