P2 tail: cuota progress bars, Sync item rows, dialog polish
All checks were successful
Deploy to VPS / test (push) Successful in 1m29s
Deploy to VPS / deploy (push) Successful in 2m19s

Financiamientos shows cuota progress as Progress bars; Sincronización
uses a single Card of Item rows; Items Recurrentes table wrapped in a
Card like every other table; both window.confirm sites replaced with
the app's ConfirmDialog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-07-04 13:22:15 -06:00
parent 41d4306e86
commit 3a258537fc
5 changed files with 106 additions and 50 deletions

View File

@@ -25,6 +25,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import ConfirmDialog from '@/components/ConfirmDialog';
const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [ const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [
{ value: 'INCOME', label: 'Ingreso' }, { value: 'INCOME', label: 'Ingreso' },
@@ -73,11 +74,13 @@ export default function RecurringItemDialog({
const snapshot = () => const snapshot = () =>
JSON.stringify([name, amount, itemType, frequency, dayOfMonth, monthOfYear, overrides, notes]); JSON.stringify([name, amount, itemType, frequency, dayOfMonth, monthOfYear, overrides, notes]);
const [confirmDiscard, setConfirmDiscard] = useState(false);
const handleOpenChange = (next: boolean) => { const handleOpenChange = (next: boolean) => {
if (!next && !saving && snapshot() !== baseline) { if (!next && !saving && snapshot() !== baseline) {
// Closing with unsaved edits — confirm before discarding (UX-06). // Closing with unsaved edits — confirm before discarding (UX-06).
const discard = window.confirm('Hay cambios sin guardar. ¿Descartarlos?'); setConfirmDiscard(true);
if (!discard) return; return;
} }
onOpenChange(next); onOpenChange(next);
}; };
@@ -338,6 +341,19 @@ export default function RecurringItemDialog({
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
{confirmDiscard && (
<ConfirmDialog
title="Cambios sin guardar"
message="Hay cambios sin guardar. ¿Descartarlos?"
confirmLabel="Descartar"
onConfirm={() => {
setConfirmDiscard(false);
onOpenChange(false);
}}
onCancel={() => setConfirmDiscard(false)}
/>
)}
</Dialog> </Dialog>
); );
} }

View File

@@ -8,6 +8,7 @@ import {
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { DataTable } from '@/components/ui/data-table'; import { DataTable } from '@/components/ui/data-table';
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header'; import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
import { Pencil, Plus, Trash2 } from 'lucide-react'; import { Pencil, Plus, Trash2 } from 'lucide-react';
@@ -153,6 +154,8 @@ export default function RecurringItemsManager({
</Button> </Button>
</div> </div>
<Card>
<CardContent className="p-0">
<DataTable <DataTable
columns={columns} columns={columns}
data={items} data={items}
@@ -161,6 +164,8 @@ export default function RecurringItemsManager({
initialSorting={[{ id: 'item_type', desc: false }]} initialSorting={[{ id: 'item_type', desc: false }]}
emptyMessage="No hay items recurrentes." emptyMessage="No hay items recurrentes."
/> />
</CardContent>
</Card>
<RecurringItemDialog <RecurringItemDialog
open={dialogOpen} open={dialogOpen}

View File

@@ -20,6 +20,7 @@ import {
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import ConfirmDialog from '@/components/ConfirmDialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
/** Mirror of the backend cuota split (services/installments.py): truncate to /** Mirror of the backend cuota split (services/installments.py): truncate to
@@ -92,6 +93,7 @@ export default function ConvertToInstallmentsDialog({
: tx?.date ?? new Date().toISOString(); : tx?.date ?? new Date().toISOString();
const [numStr, setNumStr] = useState('3'); const [numStr, setNumStr] = useState('3');
const [confirmUnconvert, setConfirmUnconvert] = useState(false);
const [firstDate, setFirstDate] = useState(''); const [firstDate, setFirstDate] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -142,10 +144,7 @@ export default function ConvertToInstallmentsDialog({
const handleUnconvert = async () => { const handleUnconvert = async () => {
if (!effectivePlan) return; if (!effectivePlan) return;
const confirmed = window.confirm( setConfirmUnconvert(false);
'Se eliminarán las cuotas y la compra volverá a contar como un solo cargo. ¿Continuar?',
);
if (!confirmed) return;
setSaving(true); setSaving(true);
try { try {
await deleteInstallmentPlan(effectivePlan.id); await deleteInstallmentPlan(effectivePlan.id);
@@ -248,7 +247,7 @@ export default function ConvertToInstallmentsDialog({
<Button <Button
variant="outline" variant="outline"
className="text-destructive hover:text-destructive" className="text-destructive hover:text-destructive"
onClick={handleUnconvert} onClick={() => setConfirmUnconvert(true)}
disabled={saving} disabled={saving}
> >
Deshacer Tasa Cero Deshacer Tasa Cero
@@ -273,6 +272,17 @@ export default function ConvertToInstallmentsDialog({
</div> </div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
{confirmUnconvert && (
<ConfirmDialog
title="Deshacer Tasa Cero"
message="Se eliminarán las cuotas y la compra volverá a contar como un solo cargo. Esta acción no se puede deshacer."
confirmLabel="Deshacer plan"
loading={saving}
onConfirm={handleUnconvert}
onCancel={() => setConfirmUnconvert(false)}
/>
)}
</Dialog> </Dialog>
); );
} }

View File

@@ -9,6 +9,7 @@ import {
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { import {
Table, Table,
@@ -30,8 +31,20 @@ function formatDate(iso: string) {
}); });
} }
function progressLabel(plan: InstallmentPlan) { function CuotasProgress({ plan }: { plan: InstallmentPlan }) {
return `${String(plan.cuotas_billed).padStart(3, '0')}/${String(plan.num_installments).padStart(3, '0')}`; const pct = (plan.cuotas_billed / plan.num_installments) * 100;
return (
<div className="flex items-center gap-2">
<Progress
value={pct}
className="w-16"
aria-label={`${plan.cuotas_billed} de ${plan.num_installments} cuotas pagadas`}
/>
<span className="font-mono text-xs text-muted-foreground whitespace-nowrap">
{plan.cuotas_billed}/{plan.num_installments}
</span>
</div>
);
} }
export default function Financiamientos() { export default function Financiamientos() {
@@ -87,7 +100,9 @@ export default function Financiamientos() {
<TableCell className="text-muted-foreground whitespace-nowrap"> <TableCell className="text-muted-foreground whitespace-nowrap">
{formatDate(plan.purchase_date)} {formatDate(plan.purchase_date)}
</TableCell> </TableCell>
<TableCell className="font-mono text-xs">{progressLabel(plan)}</TableCell> <TableCell>
<CuotasProgress plan={plan} />
</TableCell>
<TableCell className="text-right font-mono" data-sensitive> <TableCell className="text-right font-mono" data-sensitive>
{formatAmount(plan.installment_amount, plan.currency)} {formatAmount(plan.installment_amount, plan.currency)}
</TableCell> </TableCell>

View File

@@ -1,3 +1,4 @@
import { Fragment } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
AlertTriangle, AlertTriangle,
@@ -9,8 +10,17 @@ import {
import { getSyncStatus, type SyncSource } from '@/lib/api'; import { getSyncStatus, type SyncSource } from '@/lib/api';
import { formatRelativeAge } from '@/lib/dates'; import { formatRelativeAge } from '@/lib/dates';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
} from '@/components/ui/item';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -72,26 +82,23 @@ export default function SyncStatus() {
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{query.data.sources.map((s) => ( <Card className="py-2">
<Card <ItemGroup>
key={s.key} {query.data.sources.map((s, i) => (
className={ <Fragment key={s.key}>
s.status !== 'ok' ? 'border-amber-500/40' : undefined <Item>
} <ItemMedia>
>
<CardContent className="p-4 flex items-center gap-4">
<StatusIcon status={s.status} /> <StatusIcon status={s.status} />
<div className="flex-1 min-w-0"> </ItemMedia>
<div className="flex items-center gap-2"> <ItemContent>
<span className="font-medium">{s.label}</span> <ItemTitle>
{s.label}
{s.status !== 'ok' && ( {s.status !== 'ok' && (
<Badge variant="destructive">Atención</Badge> <Badge variant="destructive">Atención</Badge>
)} )}
</div> </ItemTitle>
<p className="text-sm text-muted-foreground truncate"> <ItemDescription>{s.description}</ItemDescription>
{s.description} </ItemContent>
</p>
</div>
<div className="text-right shrink-0"> <div className="text-right shrink-0">
<p className="text-sm font-medium">{statusLabel(s)}</p> <p className="text-sm font-medium">{statusLabel(s)}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -100,9 +107,12 @@ export default function SyncStatus() {
: '—'} : '—'}
</p> </p>
</div> </div>
</CardContent> </Item>
</Card> {i < query.data.sources.length - 1 && <ItemSeparator />}
</Fragment>
))} ))}
</ItemGroup>
</Card>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Si una fuente aparece atrasada, revisá el flujo correspondiente en Si una fuente aparece atrasada, revisá el flujo correspondiente en
n8n un cambio de formato en los correos del banco es la causa más n8n un cambio de formato en los correos del banco es la causa más