feat(frontend): add dashboard charts

- Sparkline for summary cards
- AreaChart for asset trends
- DonutChart for sector allocation
- BarChart for portfolio comparison

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
zephyrdark 2026-02-05 22:58:00 +09:00
parent eb3ce0e6e7
commit 4f432fb85c
6 changed files with 563 additions and 17 deletions

View File

@ -2,35 +2,110 @@
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Sparkline, AreaChart, DonutChart, BarChart } from '@/components/charts';
import { Wallet, TrendingUp, Briefcase, RefreshCw } from 'lucide-react';
// Sample data for sparklines
const totalAssetSparkline = [
{ value: 9500000 },
{ value: 9800000 },
{ value: 10000000 },
{ value: 9700000 },
{ value: 10200000 },
{ value: 10500000 },
{ value: 10800000 },
];
const returnSparkline = [
{ value: 2.5 },
{ value: 3.1 },
{ value: 2.8 },
{ value: 4.2 },
{ value: 5.1 },
{ value: 4.8 },
{ value: 5.5 },
];
// Sample data for asset trend chart
const assetTrendData = [
{ date: '2024-01', value: 10000000 },
{ date: '2024-02', value: 10500000 },
{ date: '2024-03', value: 10200000 },
{ date: '2024-04', value: 10800000 },
{ date: '2024-05', value: 11200000 },
{ date: '2024-06', value: 11000000 },
{ date: '2024-07', value: 11500000 },
{ date: '2024-08', value: 12000000 },
{ date: '2024-09', value: 11800000 },
{ date: '2024-10', value: 12500000 },
{ date: '2024-11', value: 13000000 },
{ date: '2024-12', value: 13500000 },
];
// Sample data for sector allocation
const sectorData = [
{ name: '기술', value: 40, color: '#3b82f6' },
{ name: '금융', value: 30, color: '#10b981' },
{ name: '헬스케어', value: 20, color: '#f59e0b' },
{ name: '기타', value: 10, color: '#6b7280' },
];
// Sample data for portfolio comparison
const portfolioComparisonData = [
{ name: '포트폴리오 A', value: 12.5 },
{ name: '포트폴리오 B', value: 8.3 },
{ name: '포트폴리오 C', value: -2.1 },
{ name: 'KOSPI', value: 5.7 },
{ name: 'S&P 500', value: 15.2 },
];
const summaryCards = [
{
title: '총 자산',
value: '---',
value: '₩135,000,000',
description: '전체 포트폴리오 가치',
icon: Wallet,
sparklineData: totalAssetSparkline,
sparklineColor: '#3b82f6',
},
{
title: '총 수익률',
value: '---%',
value: '+35.0%',
description: '전체 수익률',
icon: TrendingUp,
sparklineData: returnSparkline,
sparklineColor: '#10b981',
},
{
title: '포트폴리오',
value: '-',
value: '3',
description: '활성 포트폴리오 수',
icon: Briefcase,
sparklineData: null,
sparklineColor: null,
},
{
title: '리밸런싱',
value: '-',
value: '2',
description: '예정된 리밸런싱',
icon: RefreshCw,
sparklineData: null,
sparklineColor: null,
},
];
const formatKRW = (value: number) => {
if (value >= 100000000) {
return `${(value / 100000000).toFixed(1)}`;
}
if (value >= 10000) {
return `${(value / 10000).toFixed(0)}`;
}
return value.toLocaleString();
};
const formatPercent = (value: number) => `${value.toFixed(1)}%`;
export default function DashboardPage() {
return (
<DashboardLayout>
@ -52,22 +127,34 @@ export default function DashboardPage() {
<p className="text-xs text-muted-foreground">
{card.description}
</p>
{card.sparklineData && (
<Sparkline
data={card.sparklineData}
color={card.sparklineColor || '#3b82f6'}
height={32}
className="mt-2"
/>
)}
</CardContent>
</Card>
);
})}
</div>
{/* Chart Placeholders */}
{/* Main Charts */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center h-64 bg-muted/50 rounded-lg">
<p className="text-muted-foreground"> </p>
</div>
<AreaChart
data={assetTrendData}
height={280}
color="#3b82f6"
formatValue={formatKRW}
showLegend={false}
/>
</CardContent>
</Card>
@ -76,22 +163,30 @@ export default function DashboardPage() {
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center h-64 bg-muted/50 rounded-lg">
<p className="text-muted-foreground"> </p>
</div>
<DonutChart
data={sectorData}
height={280}
innerRadius={50}
outerRadius={90}
/>
</CardContent>
</Card>
</div>
{/* Secondary Charts */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center h-48 bg-muted/50 rounded-lg">
<p className="text-muted-foreground"> </p>
</div>
<BarChart
data={portfolioComparisonData}
height={240}
layout="horizontal"
formatValue={formatPercent}
colorByValue={true}
/>
</CardContent>
</Card>
@ -100,8 +195,28 @@ export default function DashboardPage() {
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center h-48 bg-muted/50 rounded-lg">
<p className="text-muted-foreground"> </p>
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<RefreshCw className="h-4 w-4 text-blue-500" />
<div className="flex-1">
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground"> A - 2 </p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<TrendingUp className="h-4 w-4 text-green-500" />
<div className="flex-1">
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground"> B - </p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<Briefcase className="h-4 w-4 text-amber-500" />
<div className="flex-1">
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground"> - 3 </p>
</div>
</div>
</div>
</CardContent>
</Card>

View File

@ -0,0 +1,118 @@
'use client';
import {
Area,
AreaChart as RechartsAreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
interface AreaChartData {
date: string;
value: number;
[key: string]: string | number;
}
interface AreaChartProps {
data: AreaChartData[];
dataKey?: string;
xAxisKey?: string;
color?: string;
height?: number;
showLegend?: boolean;
showGrid?: boolean;
formatValue?: (value: number) => string;
formatXAxis?: (value: string) => string;
className?: string;
}
export function AreaChart({
data,
dataKey = 'value',
xAxisKey = 'date',
color = '#3b82f6',
height = 300,
showLegend = true,
showGrid = true,
formatValue = (value) => value.toLocaleString(),
formatXAxis = (value) => value,
className,
}: AreaChartProps) {
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-64 bg-muted/50 rounded-lg">
<p className="text-muted-foreground"> </p>
</div>
);
}
return (
<div className={className} style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<RechartsAreaChart
data={data}
margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
{showGrid && (
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
vertical={false}
/>
)}
<XAxis
dataKey={xAxisKey}
tickFormatter={formatXAxis}
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
tickFormatter={formatValue}
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
width={80}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px',
color: 'hsl(var(--popover-foreground))',
}}
labelStyle={{ color: 'hsl(var(--popover-foreground))' }}
formatter={(value) => [formatValue(value as number), '자산']}
/>
{showLegend && (
<Legend
wrapperStyle={{
color: 'hsl(var(--foreground))',
}}
/>
)}
<Area
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={2}
fill="url(#areaGradient)"
name="자산 가치"
/>
</RechartsAreaChart>
</ResponsiveContainer>
</div>
);
}

View File

@ -0,0 +1,155 @@
'use client';
import {
Bar,
BarChart as RechartsBarChart,
CartesianGrid,
Cell,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
interface BarChartData {
name: string;
value: number;
[key: string]: string | number;
}
interface BarChartProps {
data: BarChartData[];
dataKey?: string;
nameKey?: string;
height?: number;
layout?: 'horizontal' | 'vertical';
showLegend?: boolean;
showGrid?: boolean;
colorByValue?: boolean;
positiveColor?: string;
negativeColor?: string;
defaultColor?: string;
formatValue?: (value: number) => string;
className?: string;
}
export function BarChart({
data,
dataKey = 'value',
nameKey = 'name',
height = 300,
layout = 'vertical',
showLegend = false,
showGrid = true,
colorByValue = true,
positiveColor = '#10b981',
negativeColor = '#ef4444',
defaultColor = '#3b82f6',
formatValue = (value) => value.toLocaleString(),
className,
}: BarChartProps) {
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-64 bg-muted/50 rounded-lg">
<p className="text-muted-foreground"> </p>
</div>
);
}
const getBarColor = (value: number) => {
if (!colorByValue) return defaultColor;
return value >= 0 ? positiveColor : negativeColor;
};
const isHorizontal = layout === 'horizontal';
return (
<div className={className} style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart
data={data}
layout={isHorizontal ? 'vertical' : 'horizontal'}
margin={{ top: 10, right: 30, left: isHorizontal ? 80 : 0, bottom: 0 }}
>
{showGrid && (
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
horizontal={!isHorizontal}
vertical={isHorizontal}
/>
)}
{isHorizontal ? (
<>
<XAxis
type="number"
tickFormatter={formatValue}
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
type="category"
dataKey={nameKey}
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
width={80}
/>
</>
) : (
<>
<XAxis
dataKey={nameKey}
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
tickFormatter={formatValue}
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
width={60}
/>
</>
)}
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px',
color: 'hsl(var(--popover-foreground))',
}}
formatter={(value) => [formatValue(value as number), '수익률']}
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.3 }}
/>
{showLegend && (
<Legend
wrapperStyle={{
color: 'hsl(var(--foreground))',
}}
/>
)}
<Bar
dataKey={dataKey}
radius={[4, 4, 4, 4]}
name="수익률"
>
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={getBarColor(entry[dataKey] as number)}
/>
))}
</Bar>
</RechartsBarChart>
</ResponsiveContainer>
</div>
);
}

View File

@ -0,0 +1,106 @@
'use client';
import {
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
} from 'recharts';
interface DonutChartData {
name: string;
value: number;
color: string;
}
interface DonutChartProps {
data: DonutChartData[];
height?: number;
innerRadius?: number;
outerRadius?: number;
showLegend?: boolean;
className?: string;
}
export function DonutChart({
data,
height = 300,
innerRadius = 60,
outerRadius = 100,
showLegend = true,
className,
}: DonutChartProps) {
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-64 bg-muted/50 rounded-lg">
<p className="text-muted-foreground"> </p>
</div>
);
}
const total = data.reduce((sum, item) => sum + item.value, 0);
const renderLegend = () => {
return (
<ul className="flex flex-col gap-2 text-sm">
{data.map((entry, index) => (
<li key={`legend-${index}`} className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-foreground">{entry.name}</span>
<span className="text-muted-foreground ml-auto">
{((entry.value / total) * 100).toFixed(1)}%
</span>
</li>
))}
</ul>
);
};
return (
<div className={className} style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={innerRadius}
outerRadius={outerRadius}
paddingAngle={2}
dataKey="value"
stroke="none"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px',
color: 'hsl(var(--popover-foreground))',
}}
formatter={(value, name) => [
`${(((value as number) / total) * 100).toFixed(1)}%`,
name,
]}
/>
{showLegend && (
<Legend
content={renderLegend}
layout="vertical"
align="right"
verticalAlign="middle"
/>
)}
</PieChart>
</ResponsiveContainer>
</div>
);
}

View File

@ -0,0 +1,4 @@
export { Sparkline } from './sparkline';
export { AreaChart } from './area-chart';
export { DonutChart } from './donut-chart';
export { BarChart } from './bar-chart';

View File

@ -0,0 +1,48 @@
'use client';
import { Area, AreaChart, ResponsiveContainer } from 'recharts';
interface SparklineData {
value: number;
}
interface SparklineProps {
data: SparklineData[];
color?: string;
height?: number;
className?: string;
}
export function Sparkline({
data,
color = '#3b82f6',
height = 40,
className,
}: SparklineProps) {
if (!data || data.length === 0) {
return null;
}
return (
<div className={className} style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={`sparkline-gradient-${color.replace('#', '')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={1.5}
fill={`url(#sparkline-gradient-${color.replace('#', '')})`}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}