mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 11:08:47 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
14
frontend/src/components/ui/skeleton.tsx
Normal file
14
frontend/src/components/ui/skeleton.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user