Add Skeleton primitive and budget detail loading state

Replaces the blank flash on the budget detail tab with skeleton
placeholders that mirror the final card layout, so the page no
longer shifts when the API returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-04-29 22:02:22 -06:00
parent 5d5727ec4e
commit 8b3a19b552
3 changed files with 129 additions and 16 deletions

View File

@@ -1,12 +1,13 @@
import { useState } from 'react';
import { PieChart, Pie, Cell } from 'recharts';
import { type MonthlyDetail as MonthlyDetailType } from '@/api';
import { type MonthlyDetail as MonthlyDetailType } from '@/lib/api';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import {
ChartContainer,
ChartTooltip,
@@ -51,22 +52,122 @@ const SOURCE_LABELS: Record<string, { label: string; icon: typeof Banknote }> =
};
interface MonthlyDetailProps {
detail: MonthlyDetailType;
detail: MonthlyDetailType | null;
loading?: boolean;
onNavigateToTransactions?: () => void;
}
function PieCardSkeleton({ titleIcon: TitleIcon, title }: { titleIcon: typeof TrendingUp; title: string }) {
return (
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2 text-muted-foreground">
<TitleIcon className="w-4 h-4" />
{title}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center">
<div className="h-[200px] w-full flex items-center justify-center">
<Skeleton className="h-[160px] w-[160px] rounded-full" />
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-1 w-full">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-1.5">
<Skeleton className="w-2 h-2 rounded-full" />
<Skeleton className="h-3 flex-1" />
</div>
))}
</div>
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-24" />
</div>
</div>
</CardContent>
</Card>
);
}
export default function MonthlyDetail({ detail, loading, onNavigateToTransactions }: MonthlyDetailProps) {
const [paletteMode, setPaletteMode] = useState<PaletteMode>('chatgpt');
if (loading) {
if (loading || !detail) {
return (
<div className="grid gap-4 md:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="h-48" />
<div className="space-y-4">
<div className="flex items-center justify-end gap-1">
<span className="text-xs text-muted-foreground mr-1">Paleta:</span>
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-16" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<PieCardSkeleton titleIcon={TrendingUp} title="Ingresos" />
<PieCardSkeleton titleIcon={TrendingDown} title="Egresos Fijos" />
</div>
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2 text-muted-foreground">
<CreditCard className="w-4 h-4" />
Tarjeta de Crédito
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col md:flex-row items-center gap-4">
<div className="h-[200px] w-full md:w-1/2 flex items-center justify-center">
<Skeleton className="h-[160px] w-[160px] rounded-full" />
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 w-full md:w-1/2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-1.5">
<Skeleton className="w-2 h-2 rounded-full" />
<Skeleton className="h-3 flex-1" />
</div>
))}
</div>
</div>
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-28" />
</div>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2 text-muted-foreground">
<Banknote className="w-4 h-4" />
Efectivo o Transferencias
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-20" />
</div>
))}
</CardContent>
</Card>
))}
<Card className="border-2 border-muted/40">
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
</div>
<Separator />
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-6 w-32" />
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { cn } from "@/lib/utils";
export function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted/60", className)}
{...props}
/>
);
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Calculator } from 'lucide-react';
import api, { type Transaction } from '@/api';
import api, { type Transaction } from '@/lib/api';
import { useBudget } from '@/hooks/useBudget';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -154,13 +154,11 @@ export default function Budget() {
</div>
<TabsContent value="detail" className="space-y-6 mt-4">
{monthDetail && (
<MonthlyDetail
detail={monthDetail}
loading={monthLoading}
onNavigateToTransactions={handleNavigateToTransactions}
/>
)}
<MonthlyDetail
detail={monthDetail}
loading={monthLoading || !monthDetail}
onNavigateToTransactions={handleNavigateToTransactions}
/>
</TabsContent>
<TabsContent value="transactions" className="space-y-3 mt-4">