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:
2026-03-16 12:18:55 +01:00
parent b756540339
commit ffc5c5f824
4 changed files with 180 additions and 0 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}