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;
onToggleDeferred?: (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({
@@ -61,9 +66,12 @@ export default function TransactionList({
emptyMessage = 'No transactions found',
showCategory = true,
showSourceIcon = false,
addLabel = 'Add Transaction',
addLabel = 'Agregar transacción',
onToggleDeferred,
onConvertTasaCero,
toolbarLeft,
toolbarRight,
summaryLabel,
}: TransactionListProps) {
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null);
@@ -212,32 +220,43 @@ export default function TransactionList({
return (
<>
{/* Search + Add */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Card className="overflow-hidden">
<CardContent className="p-0">
{/* 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" />
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
placeholder="Search merchants..."
placeholder="Buscar comercio…"
/>
</div>
<div className="ml-auto flex items-center gap-2">
{toolbarRight}
<Button onClick={() => { setEditing(null); setModalOpen(true); }}>
<Plus className="w-4 h-4" />
{addLabel}
</Button>
</div>
</div>
{/* Total of the listed transactions */}
{/* Summary strip: period · count · total */}
{visibleTransactions.length > 0 && (
<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">
<span className="text-muted-foreground">
{summaryLabel ? `${summaryLabel} · ` : ''}
{visibleTransactions.length} transaccion{visibleTransactions.length === 1 ? '' : 'es'}
</span>
{totalsByCurrency.length > 0 && (
<div
className="flex items-center justify-end gap-1.5 text-sm text-muted-foreground"
<span
className="flex items-center gap-1.5 text-muted-foreground"
title="Compras menos devoluciones de las transacciones listadas. Excluye compras convertidas a Tasa Cero (cuentan sus cuotas)."
>
<span>
{visibleTransactions.length} transaccion{visibleTransactions.length === 1 ? '' : 'es'} · Total:
</span>
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>}
@@ -245,12 +264,14 @@ export default function TransactionList({
{formatAmount(net, currency)}
</span>
))}
</span>
)}
</div>
)}
{/* Bulk action bar */}
{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">
{selected.size} seleccionada{selected.size === 1 ? '' : 's'}
</span>
@@ -327,8 +348,7 @@ export default function TransactionList({
)}
{/* Mobile list */}
<Card className="md:hidden">
<CardContent className="p-0 divide-y divide-border">
<div className="md:hidden divide-y divide-border">
{empty ? (
<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" />}
@@ -422,13 +442,13 @@ export default function TransactionList({
<ArrowRightFromLine className="w-4 h-4" />
</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" />
</Button>
<Button
variant="ghost"
size="icon"
title="Delete transaction"
title="Eliminar transacción"
aria-label="Delete transaction"
onClick={() => setDeleteId(tx.id)}
className="hover:text-destructive"
@@ -439,12 +459,10 @@ export default function TransactionList({
</div>
))
)}
</CardContent>
</Card>
</div>
{/* Desktop table */}
<Card className="hidden md:block">
<CardContent className="p-0">
<div className="hidden md:block">
<DataTable
columns={columns}
data={visibleTransactions}
@@ -453,6 +471,8 @@ export default function TransactionList({
initialSorting={[{ id: 'date', desc: true }]}
emptyMessage={emptyMessage}
/>
</div>
</CardContent>
</Card>

View File

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

View File

@@ -5,7 +5,10 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
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 { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -86,6 +89,14 @@ export default function Budget() {
});
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(() => {
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['installment-plans'] });
@@ -123,6 +134,7 @@ export default function Budget() {
<Button
variant="ghost"
size="sm"
className="mr-4 text-muted-foreground"
onClick={() => navigate('/proyecciones')}
aria-label="Volver a Proyecciones"
>
@@ -181,27 +193,7 @@ export default function Budget() {
)}
</TabsContent>
<TabsContent value="transactions" className="space-y-3 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>
<TabsContent value="transactions" className="mt-4">
{txQuery.isError ? (
<ErrorState
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]}`}
onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : 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>