mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 12:08:47 +02:00
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:
@@ -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,45 +220,58 @@ 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>
|
||||
<Button onClick={() => { setEditing(null); setModalOpen(true); }}>
|
||||
<Plus className="w-4 h-4" />
|
||||
{addLabel}
|
||||
</Button>
|
||||
<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 */}
|
||||
{totalsByCurrency.length > 0 && (
|
||||
<div
|
||||
className="flex items-center justify-end gap-1.5 text-sm 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:
|
||||
{/* 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.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)}
|
||||
{totalsByCurrency.length > 0 && (
|
||||
<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)."
|
||||
>
|
||||
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>
|
||||
))}
|
||||
)}
|
||||
</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,7 +471,9 @@ export default function TransactionList({
|
||||
initialSorting={[{ id: 'date', desc: true }]}
|
||||
emptyMessage={emptyMessage}
|
||||
/>
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Modals */}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user