mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 09:08:46 +02:00
P2 tail: cuota progress bars, Sync item rows, dialog polish
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user