feat(01-02): create PageShell, StatCard, SummaryStrip, and DashboardSkeleton components
- PageShell: reusable page header with title, description, and action slot - StatCard: KPI card with formatted value, semantic color, and optional variance badge - SummaryStrip: responsive 3-card grid composing StatCards for income/expenses/balance - DashboardSkeleton: pulse-animated loading placeholder mirroring real dashboard layout Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
49
src/components/dashboard/DashboardSkeleton.tsx
Normal file
49
src/components/dashboard/DashboardSkeleton.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||||
|
|
||||||
|
function SkeletonStatCard() {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-32" />
|
||||||
|
<Skeleton className="mt-1 h-3 w-20" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Summary cards skeleton */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<SkeletonStatCard />
|
||||||
|
<SkeletonStatCard />
|
||||||
|
<SkeletonStatCard />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart area skeleton */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-[240px] w-full rounded-md" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-[240px] w-full rounded-md" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
src/components/dashboard/StatCard.tsx
Normal file
58
src/components/dashboard/StatCard.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { TrendingUp, TrendingDown, Minus } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string
|
||||||
|
value: string
|
||||||
|
valueClassName?: string
|
||||||
|
variance?: {
|
||||||
|
amount: string
|
||||||
|
direction: "up" | "down" | "neutral"
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const directionIcon = {
|
||||||
|
up: TrendingUp,
|
||||||
|
down: TrendingDown,
|
||||||
|
neutral: Minus,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
valueClassName,
|
||||||
|
variance,
|
||||||
|
}: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-bold tabular-nums tracking-tight",
|
||||||
|
valueClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{variance && (
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
{(() => {
|
||||||
|
const Icon = directionIcon[variance.direction]
|
||||||
|
return <Icon className="size-3" />
|
||||||
|
})()}
|
||||||
|
<span>{variance.amount}</span>
|
||||||
|
<span>{variance.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
src/components/dashboard/SummaryStrip.tsx
Normal file
45
src/components/dashboard/SummaryStrip.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { StatCard } from "./StatCard"
|
||||||
|
|
||||||
|
interface SummaryStripProps {
|
||||||
|
income: { value: string; budgeted: string }
|
||||||
|
expenses: { value: string; budgeted: string }
|
||||||
|
balance: { value: string; isPositive: boolean }
|
||||||
|
t: (key: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SummaryStrip({
|
||||||
|
income,
|
||||||
|
expenses,
|
||||||
|
balance,
|
||||||
|
t,
|
||||||
|
}: SummaryStripProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
title={t("dashboard.totalIncome")}
|
||||||
|
value={income.value}
|
||||||
|
valueClassName="text-income"
|
||||||
|
variance={{
|
||||||
|
amount: income.budgeted,
|
||||||
|
direction: "neutral",
|
||||||
|
label: t("budgets.budgeted"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t("dashboard.totalExpenses")}
|
||||||
|
value={expenses.value}
|
||||||
|
valueClassName="text-destructive"
|
||||||
|
variance={{
|
||||||
|
amount: expenses.budgeted,
|
||||||
|
direction: "neutral",
|
||||||
|
label: t("budgets.budgeted"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t("dashboard.availableBalance")}
|
||||||
|
value={balance.value}
|
||||||
|
valueClassName={balance.isPositive ? "text-on-budget" : "text-over-budget"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
src/components/shared/PageShell.tsx
Normal file
28
src/components/shared/PageShell.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
interface PageShellProps {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
action?: React.ReactNode
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageShell({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
}: PageShellProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action && <div className="shrink-0">{action}</div>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user