feat(02-02): create IncomeBarChart and SpendBarChart components

- IncomeBarChart: vertical grouped bars (budgeted muted, actual vivid)
- SpendBarChart: horizontal bars via layout="vertical" with swapped axes
- Both use per-cell conditional fill for over-budget red accent
- Both use ChartContainer + ChartConfig for CSS variable theming
- Both handle empty data with ChartEmptyState placeholder
- ChartLegend and ChartTooltip with formatted currency values
This commit is contained in:
2026-03-16 13:03:23 +01:00
parent 42bf1f9431
commit bb12d01aae
2 changed files with 158 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Cell,
} from "recharts"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
} from "@/components/ui/chart"
import type { ChartConfig } from "@/components/ui/chart"
import { ChartEmptyState } from "./ChartEmptyState"
import { formatCurrency } from "@/lib/format"
interface IncomeBarChartProps {
data: Array<{ label: string; budgeted: number; actual: number }>
currency: string
emptyMessage: string
}
const chartConfig = {
budgeted: { label: "Budgeted", color: "var(--color-budget-bar-bg)" },
actual: { label: "Actual", color: "var(--color-income-fill)" },
} satisfies ChartConfig
export function IncomeBarChart({
data,
currency,
emptyMessage,
}: IncomeBarChartProps) {
if (data.length === 0) {
return <ChartEmptyState message={emptyMessage} />
}
return (
<ChartContainer config={chartConfig} className="min-h-[250px] w-full">
<BarChart data={data}>
<CartesianGrid vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) => formatCurrency(Number(value), currency)}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
<Bar
dataKey="budgeted"
fill="var(--color-budgeted)"
radius={[4, 4, 0, 0]}
/>
<Bar dataKey="actual" radius={[4, 4, 0, 0]}>
{data.map((entry, index) => (
<Cell
key={index}
fill={
entry.actual > entry.budgeted
? "var(--color-over-budget)"
: "var(--color-income-fill)"
}
/>
))}
</Bar>
</BarChart>
</ChartContainer>
)
}

View File

@@ -0,0 +1,84 @@
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Cell,
} from "recharts"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
} from "@/components/ui/chart"
import type { ChartConfig } from "@/components/ui/chart"
import { ChartEmptyState } from "./ChartEmptyState"
import { formatCurrency } from "@/lib/format"
interface SpendBarChartProps {
data: Array<{
type: string
label: string
budgeted: number
actual: number
}>
currency: string
emptyMessage: string
}
const chartConfig = {
budgeted: { label: "Budgeted", color: "var(--color-budget-bar-bg)" },
actual: { label: "Actual", color: "var(--color-muted-foreground)" },
} satisfies ChartConfig
export function SpendBarChart({
data,
currency,
emptyMessage,
}: SpendBarChartProps) {
if (data.length === 0) {
return <ChartEmptyState message={emptyMessage} />
}
return (
<ChartContainer config={chartConfig} className="min-h-[250px] w-full">
<BarChart layout="vertical" data={data}>
<CartesianGrid horizontal={false} />
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="label"
width={120}
tick={{ fontSize: 12 }}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) => formatCurrency(Number(value), currency)}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
<Bar
dataKey="budgeted"
fill="var(--color-budgeted)"
radius={4}
/>
<Bar dataKey="actual" radius={4}>
{data.map((entry, index) => (
<Cell
key={index}
fill={
entry.actual > entry.budgeted
? "var(--color-over-budget)"
: `var(--color-${entry.type}-fill)`
}
/>
))}
</Bar>
</BarChart>
</ChartContainer>
)
}