Budget: unify transactions section into one card

Toolbar (source tabs, search, CSV, add) and a summary strip (billing
cycle range, count, per-currency total) now live inside the table card
instead of five loose control rows. Cycle range 18-to-18 is finally
visible. Stray English strings translated; back-button spacing fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-07-04 09:38:36 -06:00
parent 72a5840752
commit 3d71234a61
3 changed files with 96 additions and 61 deletions

View File

@@ -48,6 +48,11 @@ export interface TransactionListProps {
addLabel?: string; addLabel?: string;
onToggleDeferred?: (tx: Transaction) => void; onToggleDeferred?: (tx: Transaction) => void;
onConvertTasaCero?: (tx: Transaction) => void; onConvertTasaCero?: (tx: Transaction) => void;
/** Extra toolbar content, e.g. source tabs (left) and the CSV button (right). */
toolbarLeft?: React.ReactNode;
toolbarRight?: React.ReactNode;
/** Period hint shown in the summary strip, e.g. "Ciclo 18 jun 18 jul". */
summaryLabel?: string;
} }
export default function TransactionList({ export default function TransactionList({
@@ -61,9 +66,12 @@ export default function TransactionList({
emptyMessage = 'No transactions found', emptyMessage = 'No transactions found',
showCategory = true, showCategory = true,
showSourceIcon = false, showSourceIcon = false,
addLabel = 'Add Transaction', addLabel = 'Agregar transacción',
onToggleDeferred, onToggleDeferred,
onConvertTasaCero, onConvertTasaCero,
toolbarLeft,
toolbarRight,
summaryLabel,
}: TransactionListProps) { }: TransactionListProps) {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null); const [editing, setEditing] = useState<Transaction | null>(null);
@@ -212,45 +220,58 @@ export default function TransactionList({
return ( return (
<> <>
{/* Search + Add */} <Card className="overflow-hidden">
<div className="flex items-center gap-3"> <CardContent className="p-0">
<div className="relative flex-1 max-w-sm">
{/* Toolbar: source tabs · search · CSV · add */}
<div className="flex flex-wrap items-center gap-2 p-3 border-b border-border">
{toolbarLeft}
<div className="relative flex-1 min-w-44 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
value={search} value={search}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
className="pl-10" className="pl-10"
placeholder="Search merchants..." placeholder="Buscar comercio…"
/> />
</div> </div>
<Button onClick={() => { setEditing(null); setModalOpen(true); }}> <div className="ml-auto flex items-center gap-2">
<Plus className="w-4 h-4" /> {toolbarRight}
{addLabel} <Button onClick={() => { setEditing(null); setModalOpen(true); }}>
</Button> <Plus className="w-4 h-4" />
{addLabel}
</Button>
</div>
</div> </div>
{/* Total of the listed transactions */} {/* Summary strip: period · count · total */}
{totalsByCurrency.length > 0 && ( {visibleTransactions.length > 0 && (
<div <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 px-4 py-2 border-b border-border bg-muted/30 text-sm">
className="flex items-center justify-end gap-1.5 text-sm text-muted-foreground" <span className="text-muted-foreground">
title="Compras menos devoluciones de las transacciones listadas. Excluye compras convertidas a Tasa Cero (cuentan sus cuotas)." {summaryLabel ? `${summaryLabel} · ` : ''}
> {visibleTransactions.length} transaccion{visibleTransactions.length === 1 ? '' : 'es'}
<span>
{visibleTransactions.length} transaccion{visibleTransactions.length === 1 ? '' : 'es'} · Total:
</span> </span>
{totalsByCurrency.map(([currency, net], i) => ( {totalsByCurrency.length > 0 && (
<span key={currency} data-sensitive className="font-mono font-semibold text-foreground"> <span
{i > 0 && <span className="text-muted-foreground font-normal mr-1.5">+</span>} className="flex items-center gap-1.5 text-muted-foreground"
{net < 0 ? '+' : ''} title="Compras menos devoluciones de las transacciones listadas. Excluye compras convertidas a Tasa Cero (cuentan sus cuotas)."
{formatAmount(net, currency)} >
Total:
{totalsByCurrency.map(([currency, net], i) => (
<span key={currency} data-sensitive className="font-mono font-semibold text-foreground">
{i > 0 && <span className="text-muted-foreground font-normal mr-1.5">+</span>}
{net < 0 ? '+' : ''}
{formatAmount(net, currency)}
</span>
))}
</span> </span>
))} )}
</div> </div>
)} )}
{/* Bulk action bar */} {/* Bulk action bar */}
{selected.size > 0 && ( {selected.size > 0 && (
<div className="hidden md:flex items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2"> <div className="hidden md:flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-2">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{selected.size} seleccionada{selected.size === 1 ? '' : 's'} {selected.size} seleccionada{selected.size === 1 ? '' : 's'}
</span> </span>
@@ -327,8 +348,7 @@ export default function TransactionList({
)} )}
{/* Mobile list */} {/* Mobile list */}
<Card className="md:hidden"> <div className="md:hidden divide-y divide-border">
<CardContent className="p-0 divide-y divide-border">
{empty ? ( {empty ? (
<div className="px-5 py-16 text-center text-muted-foreground text-sm"> <div className="px-5 py-16 text-center text-muted-foreground text-sm">
{emptyIcon || <ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-muted-foreground/50" />} {emptyIcon || <ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-muted-foreground/50" />}
@@ -422,13 +442,13 @@ export default function TransactionList({
<ArrowRightFromLine className="w-4 h-4" /> <ArrowRightFromLine className="w-4 h-4" />
</Button> </Button>
)} )}
<Button variant="ghost" size="icon" title="Edit transaction" aria-label="Edit transaction" onClick={() => handleEdit(tx)}> <Button variant="ghost" size="icon" title="Editar transacción" aria-label="Edit transaction" onClick={() => handleEdit(tx)}>
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
title="Delete transaction" title="Eliminar transacción"
aria-label="Delete transaction" aria-label="Delete transaction"
onClick={() => setDeleteId(tx.id)} onClick={() => setDeleteId(tx.id)}
className="hover:text-destructive" className="hover:text-destructive"
@@ -439,12 +459,10 @@ export default function TransactionList({
</div> </div>
)) ))
)} )}
</CardContent> </div>
</Card>
{/* Desktop table */} {/* Desktop table */}
<Card className="hidden md:block"> <div className="hidden md:block">
<CardContent className="p-0">
<DataTable <DataTable
columns={columns} columns={columns}
data={visibleTransactions} data={visibleTransactions}
@@ -453,7 +471,9 @@ export default function TransactionList({
initialSorting={[{ id: 'date', desc: true }]} initialSorting={[{ id: 'date', desc: true }]}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
/> />
</CardContent> </div>
</CardContent>
</Card> </Card>
{/* Modals */} {/* Modals */}

View File

@@ -65,7 +65,7 @@ export function getTransactionColumns({
columns.push( columns.push(
{ {
accessorKey: 'date', accessorKey: 'date',
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />, header: ({ column }) => <DataTableColumnHeader column={column} title="Fecha" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-mono text-muted-foreground text-xs whitespace-nowrap"> <span className="font-mono text-muted-foreground text-xs whitespace-nowrap">
{new Date(row.original.date).toLocaleDateString('es-CR', { {new Date(row.original.date).toLocaleDateString('es-CR', {
@@ -78,7 +78,7 @@ export function getTransactionColumns({
}, },
{ {
accessorKey: 'merchant', accessorKey: 'merchant',
header: ({ column }) => <DataTableColumnHeader column={column} title="Merchant" />, header: ({ column }) => <DataTableColumnHeader column={column} title="Comercio" />,
cell: ({ row }) => { cell: ({ row }) => {
const tx = row.original; const tx = row.original;
return ( return (
@@ -129,7 +129,7 @@ export function getTransactionColumns({
columns.push({ columns.push({
accessorFn: (row) => row.category?.name ?? '', accessorFn: (row) => row.category?.name ?? '',
id: 'category', id: 'category',
header: ({ column }) => <DataTableColumnHeader column={column} title="Category" />, header: ({ column }) => <DataTableColumnHeader column={column} title="Categoría" />,
cell: ({ row }) => { cell: ({ row }) => {
const category = row.original.category; const category = row.original.category;
return category ? ( return category ? (
@@ -146,7 +146,7 @@ export function getTransactionColumns({
accessorKey: 'amount', accessorKey: 'amount',
meta: { className: 'text-right' }, meta: { className: 'text-right' },
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader column={column} title="Amount" className="justify-end" /> <DataTableColumnHeader column={column} title="Monto" className="justify-end" />
), ),
cell: ({ row }) => { cell: ({ row }) => {
const tx = row.original; const tx = row.original;
@@ -204,7 +204,7 @@ export function getTransactionColumns({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
title="Edit transaction" title="Editar transacción"
aria-label="Edit transaction" aria-label="Edit transaction"
onClick={() => onEdit(tx)} onClick={() => onEdit(tx)}
> >
@@ -213,7 +213,7 @@ export function getTransactionColumns({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
title="Delete transaction" title="Eliminar transacción"
aria-label="Delete transaction" aria-label="Delete transaction"
onClick={() => onDelete(tx.id)} onClick={() => onDelete(tx.id)}
className="hover:text-destructive" className="hover:text-destructive"

View File

@@ -5,7 +5,10 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import api, { type Transaction } from '@/lib/api'; import api, { type Transaction } from '@/lib/api';
import { MONTH_NAMES_ES as MONTH_NAMES } from '@/lib/dates'; import {
MONTH_NAMES_ES as MONTH_NAMES,
MONTH_NAMES_ES_SHORT as MONTH_NAMES_SHORT,
} from '@/lib/dates';
import { useBudget } from '@/hooks/useBudget'; import { useBudget } from '@/hooks/useBudget';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -86,6 +89,14 @@ export default function Budget() {
}); });
const transactions = txQuery.data ?? []; const transactions = txQuery.data ?? [];
// Make the period explicit: budget month M on the card tab = the billing
// cycle 18/(M-1) → 18/M; cash/transfers use the calendar month.
const cyclePrevMonth = selectedMonth === 1 ? 12 : selectedMonth - 1;
const cycleLabel =
txSource === 'CREDIT_CARD'
? `Ciclo 18 ${MONTH_NAMES_SHORT[cyclePrevMonth].toLowerCase()} 18 ${MONTH_NAMES_SHORT[selectedMonth].toLowerCase()}`
: `Mes de ${MONTH_NAMES[selectedMonth].toLowerCase()}`;
const invalidateTransactionsAndBudget = useCallback(() => { const invalidateTransactionsAndBudget = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['transactions'] }); queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['installment-plans'] }); queryClient.invalidateQueries({ queryKey: ['installment-plans'] });
@@ -123,6 +134,7 @@ export default function Budget() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="mr-4 text-muted-foreground"
onClick={() => navigate('/proyecciones')} onClick={() => navigate('/proyecciones')}
aria-label="Volver a Proyecciones" aria-label="Volver a Proyecciones"
> >
@@ -181,27 +193,7 @@ export default function Budget() {
)} )}
</TabsContent> </TabsContent>
<TabsContent value="transactions" className="space-y-3 mt-4"> <TabsContent value="transactions" className="mt-4">
<div className="flex items-center justify-between gap-2">
<Tabs
value={txSource}
onValueChange={(v) => setTxSource(v as typeof txSource)}
>
<TabsList>
<TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger>
<TabsTrigger value="CASH_AND_TRANSFER">Efectivo y Transferencias</TabsTrigger>
</TabsList>
</Tabs>
<Button
variant="outline"
size="sm"
onClick={() => window.open('/api/v1/transactions/export', '_blank')}
title="Descargar todas las transacciones como CSV"
>
<Download className="w-4 h-4 mr-2" aria-hidden="true" />
CSV
</Button>
</div>
{txQuery.isError ? ( {txQuery.isError ? (
<ErrorState <ErrorState
message="No se pudieron cargar las transacciones" message="No se pudieron cargar las transacciones"
@@ -220,6 +212,29 @@ export default function Budget() {
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`} emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined} onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined}
onConvertTasaCero={txSource === 'CREDIT_CARD' ? setConvertTx : undefined} onConvertTasaCero={txSource === 'CREDIT_CARD' ? setConvertTx : undefined}
summaryLabel={cycleLabel}
toolbarLeft={
<Tabs
value={txSource}
onValueChange={(v) => setTxSource(v as typeof txSource)}
>
<TabsList>
<TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger>
<TabsTrigger value="CASH_AND_TRANSFER">Efectivo y Transferencias</TabsTrigger>
</TabsList>
</Tabs>
}
toolbarRight={
<Button
variant="outline"
size="sm"
onClick={() => window.open('/api/v1/transactions/export', '_blank')}
title="Descargar todas las transacciones como CSV"
>
<Download className="w-4 h-4 mr-2" aria-hidden="true" />
CSV
</Button>
}
/> />
)} )}
</TabsContent> </TabsContent>