feat: initial commit - finanzas app
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)
This commit is contained in:
359
lib/alerts.ts
Normal file
359
lib/alerts.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import {
|
||||
FixedDebt,
|
||||
VariableDebt,
|
||||
CreditCard,
|
||||
MonthlyBudget,
|
||||
Alert,
|
||||
} from './types'
|
||||
import {
|
||||
getDaysUntil,
|
||||
getNextDateByDay,
|
||||
formatCurrency,
|
||||
calculateTotalFixedDebts,
|
||||
calculateTotalVariableDebts,
|
||||
} from './utils'
|
||||
|
||||
export interface GenerateAlertsParams {
|
||||
fixedDebts: FixedDebt[]
|
||||
variableDebts: VariableDebt[]
|
||||
creditCards: CreditCard[]
|
||||
monthlyBudgets: MonthlyBudget[]
|
||||
currentMonth: number
|
||||
currentYear: number
|
||||
}
|
||||
|
||||
interface AlertDraft {
|
||||
type: Alert['type']
|
||||
title: string
|
||||
message: string
|
||||
severity: Alert['severity']
|
||||
relatedId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene las deudas fijas no pagadas que vencen en los próximos N días
|
||||
*/
|
||||
export function getUpcomingFixedDebts(
|
||||
fixedDebts: FixedDebt[],
|
||||
days: number
|
||||
): Array<{ debt: FixedDebt; daysUntil: number; dueDate: Date }> {
|
||||
const today = new Date()
|
||||
const currentDay = today.getDate()
|
||||
const currentMonth = today.getMonth()
|
||||
const currentYear = today.getFullYear()
|
||||
|
||||
return fixedDebts
|
||||
.filter((debt) => !debt.isPaid)
|
||||
.map((debt) => {
|
||||
// Calcular la fecha de vencimiento para este mes
|
||||
let dueDate = new Date(currentYear, currentMonth, debt.dueDay)
|
||||
|
||||
// Si ya pasó, calcular para el mes siguiente
|
||||
if (currentDay > debt.dueDay) {
|
||||
dueDate = new Date(currentYear, currentMonth + 1, debt.dueDay)
|
||||
}
|
||||
|
||||
const daysUntil = getDaysUntil(dueDate)
|
||||
|
||||
return { debt, daysUntil, dueDate }
|
||||
})
|
||||
.filter(({ daysUntil }) => daysUntil >= 0 && daysUntil <= days)
|
||||
.sort((a, b) => a.daysUntil - b.daysUntil)
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el presupuesto del mes actual
|
||||
*/
|
||||
export function getCurrentMonthBudget(
|
||||
monthlyBudgets: MonthlyBudget[],
|
||||
month: number,
|
||||
year: number
|
||||
): MonthlyBudget | null {
|
||||
return (
|
||||
monthlyBudgets.find(
|
||||
(budget) => budget.month === month && budget.year === year
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el gasto actual del mes (deudas fijas + variables no pagadas)
|
||||
*/
|
||||
export function calculateCurrentSpending(
|
||||
fixedDebts: FixedDebt[],
|
||||
variableDebts: VariableDebt[]
|
||||
): number {
|
||||
const fixedSpending = calculateTotalFixedDebts(fixedDebts)
|
||||
const variableSpending = calculateTotalVariableDebts(variableDebts)
|
||||
|
||||
return fixedSpending + variableSpending
|
||||
}
|
||||
|
||||
interface CardEvent {
|
||||
card: CreditCard
|
||||
type: 'closing' | 'due'
|
||||
daysUntil: number
|
||||
date: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene los eventos próximos de tarjetas (cierre o vencimiento)
|
||||
*/
|
||||
export function getUpcomingCardEvents(
|
||||
creditCards: CreditCard[],
|
||||
days: number
|
||||
): CardEvent[] {
|
||||
const events: CardEvent[] = []
|
||||
|
||||
for (const card of creditCards) {
|
||||
// Calcular próximo cierre
|
||||
const closingDate = getNextDateByDay(card.closingDay)
|
||||
const daysUntilClosing = getDaysUntil(closingDate)
|
||||
|
||||
if (daysUntilClosing >= 0 && daysUntilClosing <= days) {
|
||||
events.push({
|
||||
card,
|
||||
type: 'closing',
|
||||
daysUntil: daysUntilClosing,
|
||||
date: closingDate,
|
||||
})
|
||||
}
|
||||
|
||||
// Calcular próximo vencimiento
|
||||
const dueDate = getNextDateByDay(card.dueDay)
|
||||
const daysUntilDue = getDaysUntil(dueDate)
|
||||
|
||||
if (daysUntilDue >= 0 && daysUntilDue <= days) {
|
||||
events.push({
|
||||
card,
|
||||
type: 'due',
|
||||
daysUntil: daysUntilDue,
|
||||
date: dueDate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ordenar por días hasta el evento
|
||||
return events.sort((a, b) => a.daysUntil - b.daysUntil)
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera alertas de pagos próximos (deudas fijas)
|
||||
*/
|
||||
function generatePaymentDueAlerts(fixedDebts: FixedDebt[]): AlertDraft[] {
|
||||
const upcomingDebts = getUpcomingFixedDebts(fixedDebts, 3)
|
||||
const alerts: AlertDraft[] = []
|
||||
|
||||
for (const { debt, daysUntil } of upcomingDebts) {
|
||||
const severity: Alert['severity'] =
|
||||
daysUntil <= 1 ? 'danger' : 'warning'
|
||||
|
||||
const daysText = daysUntil === 0 ? 'hoy' : daysUntil === 1 ? 'mañana' : `en ${daysUntil} días`
|
||||
|
||||
alerts.push({
|
||||
type: 'PAYMENT_DUE',
|
||||
title: 'Pago próximo',
|
||||
message: `'${debt.name}' vence ${daysText}: ${formatCurrency(debt.amount)}`,
|
||||
severity,
|
||||
relatedId: debt.id,
|
||||
})
|
||||
}
|
||||
|
||||
return alerts
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera alertas de presupuesto
|
||||
*/
|
||||
function generateBudgetAlerts(
|
||||
fixedDebts: FixedDebt[],
|
||||
variableDebts: VariableDebt[],
|
||||
monthlyBudgets: MonthlyBudget[],
|
||||
currentMonth: number,
|
||||
currentYear: number
|
||||
): AlertDraft[] {
|
||||
const currentBudget = getCurrentMonthBudget(
|
||||
monthlyBudgets,
|
||||
currentMonth,
|
||||
currentYear
|
||||
)
|
||||
|
||||
if (!currentBudget) {
|
||||
return []
|
||||
}
|
||||
|
||||
const totalBudget =
|
||||
currentBudget.fixedExpenses + currentBudget.variableExpenses
|
||||
|
||||
if (totalBudget <= 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const currentSpending = calculateCurrentSpending(fixedDebts, variableDebts)
|
||||
const percentageUsed = (currentSpending / totalBudget) * 100
|
||||
|
||||
if (percentageUsed < 80) {
|
||||
return []
|
||||
}
|
||||
|
||||
const severity: Alert['severity'] =
|
||||
percentageUsed > 95 ? 'danger' : 'warning'
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'BUDGET_WARNING',
|
||||
title: 'Presupuesto al límite',
|
||||
message: `Has usado el ${percentageUsed.toFixed(1)}% de tu presupuesto mensual`,
|
||||
severity,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera alertas de eventos de tarjetas (cierre y vencimiento)
|
||||
*/
|
||||
function generateCardAlerts(creditCards: CreditCard[]): AlertDraft[] {
|
||||
const events = getUpcomingCardEvents(creditCards, 3)
|
||||
const closingAlerts: AlertDraft[] = []
|
||||
const dueAlerts: AlertDraft[] = []
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type === 'closing') {
|
||||
const daysText =
|
||||
event.daysUntil === 0
|
||||
? 'hoy'
|
||||
: event.daysUntil === 1
|
||||
? 'mañana'
|
||||
: `en ${event.daysUntil} días`
|
||||
|
||||
closingAlerts.push({
|
||||
type: 'CARD_CLOSING',
|
||||
title: 'Cierre de tarjeta próximo',
|
||||
message: `Tu tarjeta ${event.card.name} cierra ${daysText}. Balance: ${formatCurrency(event.card.currentBalance)}`,
|
||||
severity: 'info',
|
||||
relatedId: event.card.id,
|
||||
})
|
||||
} else {
|
||||
const severity: Alert['severity'] =
|
||||
event.daysUntil <= 2 ? 'warning' : 'info'
|
||||
|
||||
const daysText =
|
||||
event.daysUntil === 0
|
||||
? 'hoy'
|
||||
: event.daysUntil === 1
|
||||
? 'mañana'
|
||||
: `en ${event.daysUntil} días`
|
||||
|
||||
dueAlerts.push({
|
||||
type: 'CARD_DUE',
|
||||
title: 'Vencimiento de tarjeta',
|
||||
message: `Vencimiento de ${event.card.name} ${daysText}. Balance: ${formatCurrency(event.card.currentBalance)}`,
|
||||
severity,
|
||||
relatedId: event.card.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return [...closingAlerts, ...dueAlerts]
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera alertas de meta de ahorro
|
||||
*/
|
||||
function generateSavingsAlerts(
|
||||
fixedDebts: FixedDebt[],
|
||||
variableDebts: VariableDebt[],
|
||||
monthlyBudgets: MonthlyBudget[],
|
||||
currentMonth: number,
|
||||
currentYear: number
|
||||
): AlertDraft[] {
|
||||
const currentBudget = getCurrentMonthBudget(
|
||||
monthlyBudgets,
|
||||
currentMonth,
|
||||
currentYear
|
||||
)
|
||||
|
||||
if (!currentBudget || currentBudget.savingsGoal <= 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const currentSpending = calculateCurrentSpending(fixedDebts, variableDebts)
|
||||
const projectedSavings = currentBudget.totalIncome - currentSpending
|
||||
|
||||
if (projectedSavings >= currentBudget.savingsGoal) {
|
||||
return []
|
||||
}
|
||||
|
||||
const percentageBelow =
|
||||
((currentBudget.savingsGoal - projectedSavings) / currentBudget.savingsGoal) *
|
||||
100
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'SAVINGS_GOAL',
|
||||
title: 'Meta de ahorro',
|
||||
message: `Vas ${percentageBelow.toFixed(0)}% por debajo de tu meta de ahorro mensual`,
|
||||
severity: 'info',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina alertas duplicadas basándose en tipo y relatedId
|
||||
*/
|
||||
function deduplicateAlerts(alerts: AlertDraft[]): AlertDraft[] {
|
||||
const seen = new Set<string>()
|
||||
|
||||
return alerts.filter((alert) => {
|
||||
const key = `${alert.type}-${alert.relatedId || 'global'}`
|
||||
|
||||
if (seen.has(key)) {
|
||||
return false
|
||||
}
|
||||
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera todas las alertas inteligentes basadas en el estado actual
|
||||
*/
|
||||
export function generateAlerts(params: GenerateAlertsParams): AlertDraft[] {
|
||||
const {
|
||||
fixedDebts,
|
||||
variableDebts,
|
||||
creditCards,
|
||||
monthlyBudgets,
|
||||
currentMonth,
|
||||
currentYear,
|
||||
} = params
|
||||
|
||||
const allAlerts: AlertDraft[] = [
|
||||
...generatePaymentDueAlerts(fixedDebts),
|
||||
...generateBudgetAlerts(
|
||||
fixedDebts,
|
||||
variableDebts,
|
||||
monthlyBudgets,
|
||||
currentMonth,
|
||||
currentYear
|
||||
),
|
||||
...generateCardAlerts(creditCards),
|
||||
...generateSavingsAlerts(
|
||||
fixedDebts,
|
||||
variableDebts,
|
||||
monthlyBudgets,
|
||||
currentMonth,
|
||||
currentYear
|
||||
),
|
||||
]
|
||||
|
||||
// Eliminar duplicados y ordenar por severidad (danger > warning > info)
|
||||
const uniqueAlerts = deduplicateAlerts(allAlerts)
|
||||
|
||||
const severityOrder = { danger: 0, warning: 1, info: 2 }
|
||||
|
||||
return uniqueAlerts.sort(
|
||||
(a, b) => severityOrder[a.severity] - severityOrder[b.severity]
|
||||
)
|
||||
}
|
||||
27
lib/store.ts
Normal file
27
lib/store.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { AppState } from '@/lib/types'
|
||||
import { createDebtsSlice, DebtsSlice } from './store/slices/debtsSlice'
|
||||
import { createCardsSlice, CardsSlice } from './store/slices/cardsSlice'
|
||||
import { createBudgetSlice, BudgetSlice } from './store/slices/budgetSlice'
|
||||
import { createAlertsSlice, AlertsSlice } from './store/slices/alertsSlice'
|
||||
|
||||
// Combined State Interface
|
||||
// Note: We extend the individual slices to create the full store interface
|
||||
export interface FinanzasState extends DebtsSlice, CardsSlice, BudgetSlice, AlertsSlice { }
|
||||
|
||||
export const useFinanzasStore = create<FinanzasState>()(
|
||||
persist(
|
||||
(...a) => ({
|
||||
...createDebtsSlice(...a),
|
||||
...createCardsSlice(...a),
|
||||
...createBudgetSlice(...a),
|
||||
...createAlertsSlice(...a),
|
||||
}),
|
||||
{
|
||||
name: 'finanzas-storage',
|
||||
// Optional: Filter what gets persisted if needed in the future
|
||||
// partialize: (state) => ({ ... })
|
||||
}
|
||||
)
|
||||
)
|
||||
45
lib/store/slices/alertsSlice.ts
Normal file
45
lib/store/slices/alertsSlice.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { StateCreator } from 'zustand'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { Alert } from '@/lib/types'
|
||||
|
||||
export interface AlertsSlice {
|
||||
alerts: Alert[]
|
||||
|
||||
addAlert: (alert: Omit<Alert, 'id' | 'date'>) => void
|
||||
markAlertAsRead: (id: string) => void
|
||||
deleteAlert: (id: string) => void
|
||||
clearAllAlerts: () => void
|
||||
}
|
||||
|
||||
export const createAlertsSlice: StateCreator<AlertsSlice> = (set) => ({
|
||||
alerts: [],
|
||||
|
||||
addAlert: (alert) =>
|
||||
set((state) => ({
|
||||
alerts: [
|
||||
...state.alerts,
|
||||
{
|
||||
...alert,
|
||||
id: uuidv4(),
|
||||
date: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
})),
|
||||
|
||||
markAlertAsRead: (id) =>
|
||||
set((state) => ({
|
||||
alerts: state.alerts.map((a) =>
|
||||
a.id === id ? { ...a, isRead: true } : a
|
||||
),
|
||||
})),
|
||||
|
||||
deleteAlert: (id) =>
|
||||
set((state) => ({
|
||||
alerts: state.alerts.filter((a) => a.id !== id),
|
||||
})),
|
||||
|
||||
clearAllAlerts: () =>
|
||||
set(() => ({
|
||||
alerts: [],
|
||||
})),
|
||||
})
|
||||
39
lib/store/slices/budgetSlice.ts
Normal file
39
lib/store/slices/budgetSlice.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { StateCreator } from 'zustand'
|
||||
import { MonthlyBudget } from '@/lib/types'
|
||||
|
||||
const now = new Date()
|
||||
|
||||
export interface BudgetSlice {
|
||||
monthlyBudgets: MonthlyBudget[]
|
||||
currentMonth: number
|
||||
currentYear: number
|
||||
|
||||
setMonthlyBudget: (budget: MonthlyBudget) => void
|
||||
updateMonthlyBudget: (month: number, year: number, updates: Partial<MonthlyBudget>) => void
|
||||
}
|
||||
|
||||
export const createBudgetSlice: StateCreator<BudgetSlice> = (set) => ({
|
||||
monthlyBudgets: [],
|
||||
currentMonth: now.getMonth() + 1,
|
||||
currentYear: now.getFullYear(),
|
||||
|
||||
setMonthlyBudget: (budget) =>
|
||||
set((state) => {
|
||||
const existingIndex = state.monthlyBudgets.findIndex(
|
||||
(b) => b.month === budget.month && b.year === budget.year
|
||||
)
|
||||
if (existingIndex >= 0) {
|
||||
const newBudgets = [...state.monthlyBudgets]
|
||||
newBudgets[existingIndex] = budget
|
||||
return { monthlyBudgets: newBudgets }
|
||||
}
|
||||
return { monthlyBudgets: [...state.monthlyBudgets, budget] }
|
||||
}),
|
||||
|
||||
updateMonthlyBudget: (month, year, updates) =>
|
||||
set((state) => ({
|
||||
monthlyBudgets: state.monthlyBudgets.map((b) =>
|
||||
b.month === month && b.year === year ? { ...b, ...updates } : b
|
||||
),
|
||||
})),
|
||||
})
|
||||
47
lib/store/slices/cardsSlice.ts
Normal file
47
lib/store/slices/cardsSlice.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { StateCreator } from 'zustand'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { CreditCard, CardPayment } from '@/lib/types'
|
||||
|
||||
export interface CardsSlice {
|
||||
creditCards: CreditCard[]
|
||||
cardPayments: CardPayment[]
|
||||
|
||||
addCreditCard: (card: Omit<CreditCard, 'id'>) => void
|
||||
updateCreditCard: (id: string, card: Partial<CreditCard>) => void
|
||||
deleteCreditCard: (id: string) => void
|
||||
|
||||
addCardPayment: (payment: Omit<CardPayment, 'id'>) => void
|
||||
deleteCardPayment: (id: string) => void
|
||||
}
|
||||
|
||||
export const createCardsSlice: StateCreator<CardsSlice> = (set) => ({
|
||||
creditCards: [],
|
||||
cardPayments: [],
|
||||
|
||||
addCreditCard: (card) =>
|
||||
set((state) => ({
|
||||
creditCards: [...state.creditCards, { ...card, id: uuidv4() }],
|
||||
})),
|
||||
|
||||
updateCreditCard: (id, card) =>
|
||||
set((state) => ({
|
||||
creditCards: state.creditCards.map((c) =>
|
||||
c.id === id ? { ...c, ...card } : c
|
||||
),
|
||||
})),
|
||||
|
||||
deleteCreditCard: (id) =>
|
||||
set((state) => ({
|
||||
creditCards: state.creditCards.filter((c) => c.id !== id),
|
||||
})),
|
||||
|
||||
addCardPayment: (payment) =>
|
||||
set((state) => ({
|
||||
cardPayments: [...state.cardPayments, { ...payment, id: uuidv4() }],
|
||||
})),
|
||||
|
||||
deleteCardPayment: (id) =>
|
||||
set((state) => ({
|
||||
cardPayments: state.cardPayments.filter((p) => p.id !== id),
|
||||
})),
|
||||
})
|
||||
73
lib/store/slices/debtsSlice.ts
Normal file
73
lib/store/slices/debtsSlice.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { StateCreator } from 'zustand'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { FixedDebt, VariableDebt } from '@/lib/types'
|
||||
|
||||
export interface DebtsSlice {
|
||||
fixedDebts: FixedDebt[]
|
||||
variableDebts: VariableDebt[]
|
||||
|
||||
// Actions Fixed
|
||||
addFixedDebt: (debt: Omit<FixedDebt, 'id'>) => void
|
||||
updateFixedDebt: (id: string, debt: Partial<FixedDebt>) => void
|
||||
deleteFixedDebt: (id: string) => void
|
||||
toggleFixedDebtPaid: (id: string) => void
|
||||
|
||||
// Actions Variable
|
||||
addVariableDebt: (debt: Omit<VariableDebt, 'id'>) => void
|
||||
updateVariableDebt: (id: string, debt: Partial<VariableDebt>) => void
|
||||
deleteVariableDebt: (id: string) => void
|
||||
toggleVariableDebtPaid: (id: string) => void
|
||||
}
|
||||
|
||||
export const createDebtsSlice: StateCreator<DebtsSlice> = (set) => ({
|
||||
fixedDebts: [],
|
||||
variableDebts: [],
|
||||
|
||||
addFixedDebt: (debt) =>
|
||||
set((state) => ({
|
||||
fixedDebts: [...state.fixedDebts, { ...debt, id: uuidv4() }],
|
||||
})),
|
||||
|
||||
updateFixedDebt: (id, debt) =>
|
||||
set((state) => ({
|
||||
fixedDebts: state.fixedDebts.map((d) =>
|
||||
d.id === id ? { ...d, ...debt } : d
|
||||
),
|
||||
})),
|
||||
|
||||
deleteFixedDebt: (id) =>
|
||||
set((state) => ({
|
||||
fixedDebts: state.fixedDebts.filter((d) => d.id !== id),
|
||||
})),
|
||||
|
||||
toggleFixedDebtPaid: (id) =>
|
||||
set((state) => ({
|
||||
fixedDebts: state.fixedDebts.map((d) =>
|
||||
d.id === id ? { ...d, isPaid: !d.isPaid } : d
|
||||
),
|
||||
})),
|
||||
|
||||
addVariableDebt: (debt) =>
|
||||
set((state) => ({
|
||||
variableDebts: [...state.variableDebts, { ...debt, id: uuidv4() }],
|
||||
})),
|
||||
|
||||
updateVariableDebt: (id, debt) =>
|
||||
set((state) => ({
|
||||
variableDebts: state.variableDebts.map((d) =>
|
||||
d.id === id ? { ...d, ...debt } : d
|
||||
),
|
||||
})),
|
||||
|
||||
deleteVariableDebt: (id) =>
|
||||
set((state) => ({
|
||||
variableDebts: state.variableDebts.filter((d) => d.id !== id),
|
||||
})),
|
||||
|
||||
toggleVariableDebtPaid: (id) =>
|
||||
set((state) => ({
|
||||
variableDebts: state.variableDebts.map((d) =>
|
||||
d.id === id ? { ...d, isPaid: !d.isPaid } : d
|
||||
),
|
||||
})),
|
||||
})
|
||||
74
lib/types.ts
Normal file
74
lib/types.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export interface FixedDebt {
|
||||
id: string
|
||||
name: string
|
||||
amount: number
|
||||
dueDay: number
|
||||
category: 'housing' | 'services' | 'subscription' | 'other'
|
||||
isAutoDebit: boolean
|
||||
isPaid: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface VariableDebt {
|
||||
id: string
|
||||
name: string
|
||||
amount: number
|
||||
date: string
|
||||
category: 'shopping' | 'food' | 'entertainment' | 'health' | 'transport' | 'other'
|
||||
isPaid: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface CreditCard {
|
||||
id: string
|
||||
name: string
|
||||
lastFourDigits: string
|
||||
closingDay: number
|
||||
dueDay: number
|
||||
currentBalance: number
|
||||
creditLimit: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface CardPayment {
|
||||
id: string
|
||||
cardId: string
|
||||
amount: number
|
||||
date: string
|
||||
description: string
|
||||
installments?: {
|
||||
current: number
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MonthlyBudget {
|
||||
month: number
|
||||
year: number
|
||||
totalIncome: number
|
||||
fixedExpenses: number
|
||||
variableExpenses: number
|
||||
savingsGoal: number
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
id: string
|
||||
type: 'PAYMENT_DUE' | 'BUDGET_WARNING' | 'CARD_CLOSING' | 'CARD_DUE' | 'SAVINGS_GOAL' | 'UNUSUAL_SPENDING'
|
||||
title: string
|
||||
message: string
|
||||
severity: 'info' | 'warning' | 'danger'
|
||||
date: string
|
||||
isRead: boolean
|
||||
relatedId?: string
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
fixedDebts: FixedDebt[]
|
||||
variableDebts: VariableDebt[]
|
||||
creditCards: CreditCard[]
|
||||
cardPayments: CardPayment[]
|
||||
monthlyBudgets: MonthlyBudget[]
|
||||
alerts: Alert[]
|
||||
currentMonth: number
|
||||
currentYear: number
|
||||
}
|
||||
189
lib/utils.ts
Normal file
189
lib/utils.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { FixedDebt, VariableDebt, CardPayment } from './types'
|
||||
|
||||
/**
|
||||
* Combina clases de Tailwind CSS usando clsx y tailwind-merge
|
||||
* Permite combinar múltiples clases condicionalmente
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea un número como moneda (pesos argentinos/USD)
|
||||
* Ejemplo: 1500.50 -> "$ 1.500,50"
|
||||
*/
|
||||
export function formatCurrency(amount: number): string {
|
||||
const formatter = new Intl.NumberFormat('es-AR', {
|
||||
style: 'currency',
|
||||
currency: 'ARS',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
return formatter.format(amount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea una fecha en formato legible en español
|
||||
* Ejemplo: "28 de enero de 2026"
|
||||
*/
|
||||
export function formatDate(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
const formatter = new Intl.DateTimeFormat('es-AR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
return formatter.format(d)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea una fecha en formato corto
|
||||
* Ejemplo: "28/01/2026"
|
||||
*/
|
||||
export function formatShortDate(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
const formatter = new Intl.DateTimeFormat('es-AR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
return formatter.format(d)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula los días hasta una fecha específica
|
||||
* Retorna un número negativo si la fecha ya pasó
|
||||
*/
|
||||
export function getDaysUntil(date: string | Date): number {
|
||||
const targetDate = typeof date === 'string' ? new Date(date) : date
|
||||
const today = new Date()
|
||||
|
||||
// Reset hours to compare only dates
|
||||
const target = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate())
|
||||
const current = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
|
||||
const diffTime = target.getTime() - current.getTime()
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
return diffDays
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la próxima fecha para un día específico del mes
|
||||
* Si el día ya pasó este mes, devuelve el del mes siguiente
|
||||
*/
|
||||
export function getNextDateByDay(dayOfMonth: number): Date {
|
||||
const today = new Date()
|
||||
const currentYear = today.getFullYear()
|
||||
const currentMonth = today.getMonth()
|
||||
const currentDay = today.getDate()
|
||||
|
||||
let targetYear = currentYear
|
||||
let targetMonth = currentMonth
|
||||
|
||||
// Si el día ya pasó este mes, ir al siguiente mes
|
||||
if (currentDay > dayOfMonth) {
|
||||
targetMonth += 1
|
||||
if (targetMonth > 11) {
|
||||
targetMonth = 0
|
||||
targetYear += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Ajustar si el día no existe en el mes objetivo (ej: 31 de febrero)
|
||||
const lastDayOfMonth = new Date(targetYear, targetMonth + 1, 0).getDate()
|
||||
const targetDay = Math.min(dayOfMonth, lastDayOfMonth)
|
||||
|
||||
return new Date(targetYear, targetMonth, targetDay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el nombre del mes en español
|
||||
* El mes debe ser 1-12 (enero = 1)
|
||||
*/
|
||||
export function getMonthName(month: number): string {
|
||||
const monthNames = [
|
||||
'enero',
|
||||
'febrero',
|
||||
'marzo',
|
||||
'abril',
|
||||
'mayo',
|
||||
'junio',
|
||||
'julio',
|
||||
'agosto',
|
||||
'septiembre',
|
||||
'octubre',
|
||||
'noviembre',
|
||||
'diciembre',
|
||||
]
|
||||
|
||||
if (month < 1 || month > 12) {
|
||||
throw new Error('El mes debe estar entre 1 y 12')
|
||||
}
|
||||
|
||||
return monthNames[month - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el total de deudas fijas no pagadas
|
||||
*/
|
||||
export function calculateTotalFixedDebts(debts: FixedDebt[]): number {
|
||||
return debts
|
||||
.filter((debt) => !debt.isPaid)
|
||||
.reduce((total, debt) => total + debt.amount, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el total de deudas variables no pagadas
|
||||
*/
|
||||
export function calculateTotalVariableDebts(debts: VariableDebt[]): number {
|
||||
return debts
|
||||
.filter((debt) => !debt.isPaid)
|
||||
.reduce((total, debt) => total + debt.amount, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el total de pagos de tarjeta
|
||||
* Opcionalmente filtrados por cardId
|
||||
*/
|
||||
export function calculateCardPayments(
|
||||
payments: CardPayment[],
|
||||
cardId?: string
|
||||
): number {
|
||||
const filteredPayments = cardId
|
||||
? payments.filter((payment) => payment.cardId === cardId)
|
||||
: payments
|
||||
|
||||
return filteredPayments.reduce((total, payment) => total + payment.amount, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula la próxima fecha de cierre de tarjeta
|
||||
* Si el día de cierre ya pasó este mes, devuelve el del mes siguiente
|
||||
*/
|
||||
export function calculateNextClosingDate(closingDay: number): Date {
|
||||
return getNextDateByDay(closingDay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula la próxima fecha de vencimiento de tarjeta
|
||||
* Si el día de vencimiento ya pasó este mes, devuelve el del mes siguiente
|
||||
*/
|
||||
export function calculateNextDueDate(dueDay: number): Date {
|
||||
return getNextDateByDay(dueDay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el porcentaje de utilización de una tarjeta de crédito
|
||||
* Retorna un valor entre 0 y 100
|
||||
*/
|
||||
export function getCardUtilization(balance: number, limit: number): number {
|
||||
if (limit <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const utilization = (balance / limit) * 100
|
||||
return Math.min(Math.max(utilization, 0), 100)
|
||||
}
|
||||
Reference in New Issue
Block a user