mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 14:48:48 +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;
|
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 */}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user