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,
|
TransactionCreate,
|
||||||
TransactionRead,
|
TransactionRead,
|
||||||
TransactionSource,
|
TransactionSource,
|
||||||
|
TransactionType,
|
||||||
TransactionUpdate,
|
TransactionUpdate,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -183,11 +184,12 @@ def create_transaction(
|
|||||||
# Send push notification
|
# Send push notification
|
||||||
symbol = "₡" if tx.currency == Currency.CRC else tx.currency.value
|
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}"
|
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(
|
send_push_to_all(
|
||||||
session,
|
session,
|
||||||
title=f"💳 {tx.merchant}",
|
title=f"{'🏦' if is_deposit else '💳'} {tx.merchant}",
|
||||||
body=f"{amount_str} — {tx.bank.value} {tx.transaction_type.value.lower()}",
|
body=f"{amount_str} — {tx.bank.value} {'depósito' if is_deposit else tx.transaction_type.value.lower()}",
|
||||||
url=f"/budget",
|
url="/salarios" if is_deposit else "/budget",
|
||||||
)
|
)
|
||||||
|
|
||||||
return tx
|
return tx
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.api.v1.endpoints import (
|
|||||||
exchange_rate,
|
exchange_rate,
|
||||||
import_transactions,
|
import_transactions,
|
||||||
notifications,
|
notifications,
|
||||||
|
salarios,
|
||||||
settings,
|
settings,
|
||||||
tokens,
|
tokens,
|
||||||
transactions,
|
transactions,
|
||||||
@@ -26,3 +27,4 @@ api_router.include_router(analytics.router)
|
|||||||
api_router.include_router(settings.router)
|
api_router.include_router(settings.router)
|
||||||
api_router.include_router(budget.router)
|
api_router.include_router(budget.router)
|
||||||
api_router.include_router(notifications.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):
|
class TransactionType(str, enum.Enum):
|
||||||
COMPRA = "COMPRA"
|
COMPRA = "COMPRA"
|
||||||
DEVOLUCION = "DEVOLUCION"
|
DEVOLUCION = "DEVOLUCION"
|
||||||
|
DEPOSITO = "DEPOSITO"
|
||||||
|
|
||||||
|
|
||||||
class TransactionSource(str, enum.Enum):
|
class TransactionSource(str, enum.Enum):
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Login from './pages/Login';
|
|||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Budget from './pages/Budget';
|
import Budget from './pages/Budget';
|
||||||
import Analytics from './pages/Analytics';
|
import Analytics from './pages/Analytics';
|
||||||
|
import Salarios from './pages/Salarios';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
@@ -31,6 +32,7 @@ function AppRoutes() {
|
|||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/budget" element={<Budget />} />
|
<Route path="/budget" element={<Budget />} />
|
||||||
<Route path="/analytics" element={<Analytics />} />
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
|
<Route path="/salarios" element={<Salarios />} />
|
||||||
{/* Redirect old routes */}
|
{/* Redirect old routes */}
|
||||||
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
|
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
|
||||||
<Route path="/transfers" 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}`);
|
api.get<YearlyProjection>(`/budget/projection/${year}`);
|
||||||
export const getMonthlyDetail = (year: number, month: number) =>
|
export const getMonthlyDetail = (year: number, month: number) =>
|
||||||
api.get<MonthlyDetail>(`/budget/month/${year}/${month}`);
|
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,
|
LayoutDashboard,
|
||||||
Calculator,
|
Calculator,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Landmark,
|
||||||
LogOut,
|
LogOut,
|
||||||
Wallet,
|
Wallet,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -27,6 +28,7 @@ import { cn } from '@/lib/utils';
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||||
{ to: '/budget', icon: Calculator, label: 'Presupuesto' },
|
{ to: '/budget', icon: Calculator, label: 'Presupuesto' },
|
||||||
|
{ to: '/salarios', icon: Landmark, label: 'Salarios' },
|
||||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
BellRing,
|
BellRing,
|
||||||
|
Landmark,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import api, { type Account, type Transaction } from '../api';
|
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="flex items-center gap-3 min-w-0">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
|
'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>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium truncate">{tx.merchant}</p>
|
<p className="text-sm font-medium truncate">{tx.merchant}</p>
|
||||||
@@ -307,9 +308,9 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'font-mono text-sm font-medium shrink-0 ml-4',
|
'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>
|
</span>
|
||||||
</div>
|
</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