diff --git a/backend/app/api/v1/endpoints/salarios.py b/backend/app/api/v1/endpoints/salarios.py new file mode 100644 index 0000000..9503e21 --- /dev/null +++ b/backend/app/api/v1/endpoints/salarios.py @@ -0,0 +1,54 @@ +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +from sqlmodel import Session, col, func, select + +from app.auth import get_current_user +from app.db import get_session +from app.models.models import Transaction, TransactionRead, TransactionType + +router = APIRouter(prefix="/salarios", tags=["salarios"]) + + +class SalariosSummary(BaseModel): + count: int + total_amount: float + latest_date: Optional[datetime] = None + + +@router.get("/", response_model=list[TransactionRead]) +def list_salarios( + limit: int = Query(default=50, le=500), + offset: int = 0, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + query = ( + select(Transaction) + .where(Transaction.transaction_type == TransactionType.DEPOSITO) + .order_by(col(Transaction.date).desc()) + .offset(offset) + .limit(limit) + ) + return session.exec(query).all() + + +@router.get("/summary", response_model=SalariosSummary) +def salarios_summary( + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + result = session.exec( + select( + func.count(), + func.coalesce(func.sum(Transaction.amount), 0), + func.max(Transaction.date), + ).where(Transaction.transaction_type == TransactionType.DEPOSITO) + ).first() + return SalariosSummary( + count=result[0] if result else 0, + total_amount=float(result[1]) if result else 0.0, + latest_date=result[2] if result else None, + ) diff --git a/backend/app/api/v1/endpoints/transactions.py b/backend/app/api/v1/endpoints/transactions.py index 4367de4..e35d76f 100644 --- a/backend/app/api/v1/endpoints/transactions.py +++ b/backend/app/api/v1/endpoints/transactions.py @@ -15,6 +15,7 @@ from app.models.models import ( TransactionCreate, TransactionRead, TransactionSource, + TransactionType, TransactionUpdate, ) @@ -183,11 +184,12 @@ def create_transaction( # Send push notification symbol = "₡" if tx.currency == Currency.CRC else tx.currency.value amount_str = f"{symbol}{tx.amount:,.0f}" if tx.currency == Currency.CRC else f"{symbol}{tx.amount:,.2f}" + is_deposit = tx.transaction_type == TransactionType.DEPOSITO send_push_to_all( session, - title=f"💳 {tx.merchant}", - body=f"{amount_str} — {tx.bank.value} {tx.transaction_type.value.lower()}", - url=f"/budget", + title=f"{'🏦' if is_deposit else '💳'} {tx.merchant}", + body=f"{amount_str} — {tx.bank.value} {'depósito' if is_deposit else tx.transaction_type.value.lower()}", + url="/salarios" if is_deposit else "/budget", ) return tx diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 7478e63..987bcb4 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -9,6 +9,7 @@ from app.api.v1.endpoints import ( exchange_rate, import_transactions, notifications, + salarios, settings, tokens, transactions, @@ -26,3 +27,4 @@ api_router.include_router(analytics.router) api_router.include_router(settings.router) api_router.include_router(budget.router) api_router.include_router(notifications.router) +api_router.include_router(salarios.router) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 62d5c79..d2355a7 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -23,6 +23,7 @@ class RecurringFrequency(str, enum.Enum): class TransactionType(str, enum.Enum): COMPRA = "COMPRA" DEVOLUCION = "DEVOLUCION" + DEPOSITO = "DEPOSITO" class TransactionSource(str, enum.Enum): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ffc5135..c55c38f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import Login from './pages/Login'; import Dashboard from './pages/Dashboard'; import Budget from './pages/Budget'; import Analytics from './pages/Analytics'; +import Salarios from './pages/Salarios'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated } = useAuth(); @@ -31,6 +32,7 @@ function AppRoutes() { } /> } /> } /> + } /> {/* Redirect old routes */} } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 75665d3..2cc5e39 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -225,3 +225,16 @@ export const getYearlyProjection = (year: number) => api.get(`/budget/projection/${year}`); export const getMonthlyDetail = (year: number, month: number) => api.get(`/budget/month/${year}/${month}`); + +// --- Salarios --- + +export interface SalariosSummary { + count: number; + total_amount: number; + latest_date: string | null; +} + +export const getSalarios = (params?: { limit?: number; offset?: number }) => + api.get('/salarios/', { params }); +export const getSalariosSummary = () => + api.get('/salarios/summary'); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index d395afe..f8f94a9 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -3,6 +3,7 @@ import { LayoutDashboard, Calculator, BarChart3, + Landmark, LogOut, Wallet, Menu, @@ -27,6 +28,7 @@ import { cn } from '@/lib/utils'; const navItems = [ { to: '/', icon: LayoutDashboard, label: 'Dashboard' }, { to: '/budget', icon: Calculator, label: 'Presupuesto' }, + { to: '/salarios', icon: Landmark, label: 'Salarios' }, { to: '/analytics', icon: BarChart3, label: 'Analytics' }, ]; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index d0732c5..13ae407 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -10,6 +10,7 @@ import { Check, X, BellRing, + Landmark, } from 'lucide-react'; import api, { type Account, type Transaction } from '../api'; @@ -293,9 +294,9 @@ export default function Dashboard() {
- {tx.transaction_type === 'DEVOLUCION' ? : } + {tx.transaction_type === 'DEPOSITO' ? : tx.transaction_type === 'DEVOLUCION' ? : }

{tx.merchant}

@@ -307,9 +308,9 @@ export default function Dashboard() {
- {tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}{formatAmount(tx.amount, tx.currency)} + {tx.transaction_type === 'COMPRA' ? '-' : '+'}{formatAmount(tx.amount, tx.currency)}
))} diff --git a/frontend/src/pages/Salarios.tsx b/frontend/src/pages/Salarios.tsx new file mode 100644 index 0000000..d9be189 --- /dev/null +++ b/frontend/src/pages/Salarios.tsx @@ -0,0 +1,163 @@ +import { useEffect, useMemo, useState } from 'react'; +import { type ColumnDef } from '@tanstack/react-table'; +import { Landmark, RefreshCw, Hash, CalendarDays, Banknote } from 'lucide-react'; + +import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '../api'; +import { formatAmount, formatDate } from '@/lib/format'; +import { DataTable } from '@/components/ui/data-table'; +import { DataTableColumnHeader } from '@/components/ui/data-table-column-header'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; + +export default function Salarios() { + const [deposits, setDeposits] = useState([]); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchData = async () => { + setLoading(true); + try { + const [depRes, sumRes] = await Promise.all([ + getSalarios({ limit: 500 }), + getSalariosSummary(), + ]); + setDeposits(depRes.data); + setSummary(sumRes.data); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchData(); }, []); + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'date', + header: ({ column }) => , + cell: ({ row }) => { + const d = new Date(row.original.date); + return ( +
+ {formatDate(row.original.date)} + {d.getFullYear()} +
+ ); + }, + }, + { + accessorKey: 'merchant', + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.merchant} + {row.original.notes && ( +

{row.original.notes}

+ )} +
+ ), + }, + { + accessorKey: 'amount', + header: ({ column }) => , + cell: ({ row }) => ( + + +{formatAmount(row.original.amount, row.original.currency)} + + ), + meta: { className: 'text-right' }, + }, + { + accessorKey: 'bank', + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.bank} + ), + }, + { + accessorKey: 'reference', + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.reference || '—'} + + ), + }, + ], + [], + ); + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Salarios

+

Historial de depósitos salariales

+
+
+ +
+ + {/* Summary cards */} + {summary && ( +
+ + +
+ + Depósitos +
+ {summary.count} +
+
+ + +
+ + Total acumulado +
+ + {formatAmount(summary.total_amount, 'CRC')} + +
+
+ + +
+ + Último depósito +
+ + {summary.latest_date ? formatDate(summary.latest_date) : '—'} + +
+
+
+ )} + + {/* Data table */} + + + + + +
+ ); +}