Files
WealthySmart/frontend/src/pages/Salarios.tsx
Carlos Escalante 571428f5ac CSV export for transactions with download buttons (ARCH-18)
GET /api/v1/transactions/export streams a CSV (optional source/type/
date filters), cookie-authenticated so window.open downloads work.
Buttons on the Budget transactions tab (all) and Salarios (SALARY
only). Quoting and category resolution covered by tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:03:55 -06:00

175 lines
6.3 KiB
TypeScript

import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { type ColumnDef } from '@tanstack/react-table';
import { Landmark, RefreshCw, Hash, CalendarDays, Banknote, Download } from 'lucide-react';
import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '@/lib/api';
import { formatAmount, formatDate } from '@/lib/format';
import ErrorState from '@/components/ErrorState';
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 query = useQuery({
queryKey: ['salarios'],
queryFn: async () => {
const [depRes, sumRes] = await Promise.all([
getSalarios({ limit: 500 }),
getSalariosSummary(),
]);
return { deposits: depRes.data, summary: sumRes.data };
},
});
const deposits = query.data?.deposits ?? [];
const summary = query.data?.summary ?? null;
const loading = query.isFetching;
const fetchData = () => query.refetch();
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 data-sensitive 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="outline"
size="sm"
onClick={() => window.open('/api/v1/transactions/export?transaction_type=SALARY', '_blank')}
title="Descargar salarios como CSV"
>
<Download className="w-4 h-4 mr-2" aria-hidden="true" />
CSV
</Button>
<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 data-sensitive 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 */}
{query.isError ? (
<ErrorState
message="No se pudieron cargar los salarios"
onRetry={fetchData}
/>
) : (
<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>
);
}