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';
|
} 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +154,18 @@ export default function RecurringItemsManager({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<Card>
|
||||||
columns={columns}
|
<CardContent className="p-0">
|
||||||
data={items}
|
<DataTable
|
||||||
pagination
|
columns={columns}
|
||||||
pageSize={20}
|
data={items}
|
||||||
initialSorting={[{ id: 'item_type', desc: false }]}
|
pagination
|
||||||
emptyMessage="No hay items recurrentes."
|
pageSize={20}
|
||||||
/>
|
initialSorting={[{ id: 'item_type', desc: false }]}
|
||||||
|
emptyMessage="No hay items recurrentes."
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<RecurringItemDialog
|
<RecurringItemDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,37 +82,37 @@ 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>
|
||||||
>
|
<StatusIcon status={s.status} />
|
||||||
<CardContent className="p-4 flex items-center gap-4">
|
</ItemMedia>
|
||||||
<StatusIcon status={s.status} />
|
<ItemContent>
|
||||||
<div className="flex-1 min-w-0">
|
<ItemTitle>
|
||||||
<div className="flex items-center gap-2">
|
{s.label}
|
||||||
<span className="font-medium">{s.label}</span>
|
{s.status !== 'ok' && (
|
||||||
{s.status !== 'ok' && (
|
<Badge variant="destructive">Atención</Badge>
|
||||||
<Badge variant="destructive">Atención</Badge>
|
)}
|
||||||
)}
|
</ItemTitle>
|
||||||
</div>
|
<ItemDescription>{s.description}</ItemDescription>
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
</ItemContent>
|
||||||
{s.description}
|
<div className="text-right shrink-0">
|
||||||
</p>
|
<p className="text-sm font-medium">{statusLabel(s)}</p>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">
|
||||||
<div className="text-right shrink-0">
|
{s.last_received
|
||||||
<p className="text-sm font-medium">{statusLabel(s)}</p>
|
? `Último: ${formatRelativeAge(s.last_received)}`
|
||||||
<p className="text-xs text-muted-foreground">
|
: '—'}
|
||||||
{s.last_received
|
</p>
|
||||||
? `Último: ${formatRelativeAge(s.last_received)}`
|
</div>
|
||||||
: '—'}
|
</Item>
|
||||||
</p>
|
{i < query.data.sources.length - 1 && <ItemSeparator />}
|
||||||
</div>
|
</Fragment>
|
||||||
</CardContent>
|
))}
|
||||||
</Card>
|
</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
|
||||||
|
|||||||
Reference in New Issue
Block a user