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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import {
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
@@ -30,8 +31,20 @@ function formatDate(iso: string) {
});
}
function progressLabel(plan: InstallmentPlan) {
return `${String(plan.cuotas_billed).padStart(3, '0')}/${String(plan.num_installments).padStart(3, '0')}`;
function CuotasProgress({ plan }: { plan: InstallmentPlan }) {
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() {
@@ -87,7 +100,9 @@ export default function Financiamientos() {
<TableCell className="text-muted-foreground whitespace-nowrap">
{formatDate(plan.purchase_date)}
</TableCell>
<TableCell className="font-mono text-xs">{progressLabel(plan)}</TableCell>
<TableCell>
<CuotasProgress plan={plan} />
</TableCell>
<TableCell className="text-right font-mono" data-sensitive>
{formatAmount(plan.installment_amount, plan.currency)}
</TableCell>

View File

@@ -1,3 +1,4 @@
import { Fragment } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
AlertTriangle,
@@ -9,8 +10,17 @@ import {
import { getSyncStatus, type SyncSource } from '@/lib/api';
import { formatRelativeAge } from '@/lib/dates';
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 {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
} from '@/components/ui/item';
import { PageHeader } from '@/components/page-header';
import ErrorState from '@/components/ErrorState';
import { Skeleton } from '@/components/ui/skeleton';
@@ -72,37 +82,37 @@ export default function SyncStatus() {
</div>
) : (
<div className="space-y-3">
{query.data.sources.map((s) => (
<Card
key={s.key}
className={
s.status !== 'ok' ? 'border-amber-500/40' : undefined
}
>
<CardContent className="p-4 flex items-center gap-4">
<StatusIcon status={s.status} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{s.label}</span>
{s.status !== 'ok' && (
<Badge variant="destructive">Atención</Badge>
)}
</div>
<p className="text-sm text-muted-foreground truncate">
{s.description}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-medium">{statusLabel(s)}</p>
<p className="text-xs text-muted-foreground">
{s.last_received
? `Último: ${formatRelativeAge(s.last_received)}`
: '—'}
</p>
</div>
</CardContent>
</Card>
))}
<Card className="py-2">
<ItemGroup>
{query.data.sources.map((s, i) => (
<Fragment key={s.key}>
<Item>
<ItemMedia>
<StatusIcon status={s.status} />
</ItemMedia>
<ItemContent>
<ItemTitle>
{s.label}
{s.status !== 'ok' && (
<Badge variant="destructive">Atención</Badge>
)}
</ItemTitle>
<ItemDescription>{s.description}</ItemDescription>
</ItemContent>
<div className="text-right shrink-0">
<p className="text-sm font-medium">{statusLabel(s)}</p>
<p className="text-xs text-muted-foreground">
{s.last_received
? `Último: ${formatRelativeAge(s.last_received)}`
: '—'}
</p>
</div>
</Item>
{i < query.data.sources.length - 1 && <ItemSeparator />}
</Fragment>
))}
</ItemGroup>
</Card>
<p className="text-xs text-muted-foreground">
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