mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:48:48 +02:00
Add DEPOSITO transaction type and Salarios page
All checks were successful
Deploy to VPS / deploy (push) Successful in 21s
All checks were successful
Deploy to VPS / deploy (push) Successful in 21s
New TransactionType.DEPOSITO for salary deposits from n8n/Gmail flow. New /salarios endpoint with summary. New top-level Salarios page with DataTable and summary cards. Push notifications link to /salarios for deposits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
54
backend/app/api/v1/endpoints/salarios.py
Normal file
54
backend/app/api/v1/endpoints/salarios.py
Normal file
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/budget" element={<Budget />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/salarios" element={<Salarios />} />
|
||||
{/* Redirect old routes */}
|
||||
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
|
||||
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
|
||||
|
||||
@@ -225,3 +225,16 @@ export const getYearlyProjection = (year: number) =>
|
||||
api.get<YearlyProjection>(`/budget/projection/${year}`);
|
||||
export const getMonthlyDetail = (year: number, month: number) =>
|
||||
api.get<MonthlyDetail>(`/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<Transaction[]>('/salarios/', { params });
|
||||
export const getSalariosSummary = () =>
|
||||
api.get<SalariosSummary>('/salarios/summary');
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
|
||||
tx.transaction_type === 'DEVOLUCION' ? 'bg-primary/10 text-primary' : 'bg-destructive/10 text-destructive'
|
||||
tx.transaction_type === 'COMPRA' ? 'bg-destructive/10 text-destructive' : 'bg-primary/10 text-primary'
|
||||
)}>
|
||||
{tx.transaction_type === 'DEVOLUCION' ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
|
||||
{tx.transaction_type === 'DEPOSITO' ? <Landmark className="w-4 h-4" /> : tx.transaction_type === 'DEVOLUCION' ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{tx.merchant}</p>
|
||||
@@ -307,9 +308,9 @@ export default function Dashboard() {
|
||||
</div>
|
||||
<span className={cn(
|
||||
'font-mono text-sm font-medium shrink-0 ml-4',
|
||||
tx.transaction_type === 'DEVOLUCION' && 'text-primary'
|
||||
tx.transaction_type !== 'COMPRA' && 'text-primary'
|
||||
)}>
|
||||
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}{formatAmount(tx.amount, tx.currency)}
|
||||
{tx.transaction_type === 'COMPRA' ? '-' : '+'}{formatAmount(tx.amount, tx.currency)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
163
frontend/src/pages/Salarios.tsx
Normal file
163
frontend/src/pages/Salarios.tsx
Normal file
@@ -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<Transaction[]>([]);
|
||||
const [summary, setSummary] = useState<SalariosSummary | null>(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<ColumnDef<Transaction, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'date',
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Fecha" />,
|
||||
cell: ({ row }) => {
|
||||
const d = new Date(row.original.date);
|
||||
return (
|
||||
<div>
|
||||
<span className="font-medium">{formatDate(row.original.date)}</span>
|
||||
<span className="text-muted-foreground ml-1 text-xs">{d.getFullYear()}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'merchant',
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Detalle" />,
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<span className="font-medium">{row.original.merchant}</span>
|
||||
{row.original.notes && (
|
||||
<p className="text-xs text-muted-foreground truncate max-w-xs">{row.original.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'amount',
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Monto" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono font-bold text-primary">
|
||||
+{formatAmount(row.original.amount, row.original.currency)}
|
||||
</span>
|
||||
),
|
||||
meta: { className: 'text-right' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'bank',
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Banco" />,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline">{row.original.bank}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'reference',
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Comprobante" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{row.original.reference || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Landmark className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold font-heading">Salarios</h1>
|
||||
<p className="text-sm text-muted-foreground">Historial de depósitos salariales</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh">
|
||||
<RefreshCw className={loading ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<Hash className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider">Depósitos</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold font-mono">{summary.count}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<Banknote className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider">Total acumulado</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold font-mono text-primary">
|
||||
{formatAmount(summary.total_amount, 'CRC')}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<CalendarDays className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider">Último depósito</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold font-mono">
|
||||
{summary.latest_date ? formatDate(summary.latest_date) : '—'}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data table */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={deposits}
|
||||
pagination
|
||||
pageSize={25}
|
||||
initialSorting={[{ id: 'date', desc: true }]}
|
||||
emptyMessage="No hay depósitos registrados aún."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user