From 4f432fb85c5adf344dbb3ccdc4cc9a10b1544493 Mon Sep 17 00:00:00 2001 From: zephyrdark Date: Thu, 5 Feb 2026 22:58:00 +0900 Subject: [PATCH] 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 --- frontend/src/app/page.tsx | 149 +++++++++++++++-- frontend/src/components/charts/area-chart.tsx | 118 +++++++++++++ frontend/src/components/charts/bar-chart.tsx | 155 ++++++++++++++++++ .../src/components/charts/donut-chart.tsx | 106 ++++++++++++ frontend/src/components/charts/index.ts | 4 + frontend/src/components/charts/sparkline.tsx | 48 ++++++ 6 files changed, 563 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/charts/area-chart.tsx create mode 100644 frontend/src/components/charts/bar-chart.tsx create mode 100644 frontend/src/components/charts/donut-chart.tsx create mode 100644 frontend/src/components/charts/index.ts create mode 100644 frontend/src/components/charts/sparkline.tsx diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index d8c4ac8..040b9f8 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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 ( @@ -52,22 +127,34 @@ export default function DashboardPage() {

{card.description}

+ {card.sparklineData && ( + + )} ); })} - {/* Chart Placeholders */} + {/* Main Charts */}
포트폴리오 성과 -
-

차트 영역

-
+
@@ -76,22 +163,30 @@ export default function DashboardPage() { 자산 배분 -
-

차트 영역

-
+
+ {/* Secondary Charts */}
- 최근 거래 + 포트폴리오 비교 -
-

거래 내역 영역

-
+
@@ -100,8 +195,28 @@ export default function DashboardPage() { 알림 -
-

알림 영역

+
+
+ +
+

리밸런싱 예정

+

포트폴리오 A - 2일 후

+
+
+
+ +
+

목표 수익률 달성

+

포트폴리오 B - 오늘

+
+
+
+ +
+

배당금 입금

+

삼성전자 - 3일 후

+
+
diff --git a/frontend/src/components/charts/area-chart.tsx b/frontend/src/components/charts/area-chart.tsx new file mode 100644 index 0000000..886b319 --- /dev/null +++ b/frontend/src/components/charts/area-chart.tsx @@ -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 ( +
+

데이터가 없습니다

+
+ ); + } + + return ( +
+ + + + + + + + + {showGrid && ( + + )} + + + [formatValue(value as number), '자산']} + /> + {showLegend && ( + + )} + + + +
+ ); +} diff --git a/frontend/src/components/charts/bar-chart.tsx b/frontend/src/components/charts/bar-chart.tsx new file mode 100644 index 0000000..3e529ef --- /dev/null +++ b/frontend/src/components/charts/bar-chart.tsx @@ -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 ( +
+

데이터가 없습니다

+
+ ); + } + + const getBarColor = (value: number) => { + if (!colorByValue) return defaultColor; + return value >= 0 ? positiveColor : negativeColor; + }; + + const isHorizontal = layout === 'horizontal'; + + return ( +
+ + + {showGrid && ( + + )} + {isHorizontal ? ( + <> + + + + ) : ( + <> + + + + )} + [formatValue(value as number), '수익률']} + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.3 }} + /> + {showLegend && ( + + )} + + {data.map((entry, index) => ( + + ))} + + + +
+ ); +} diff --git a/frontend/src/components/charts/donut-chart.tsx b/frontend/src/components/charts/donut-chart.tsx new file mode 100644 index 0000000..ef7c803 --- /dev/null +++ b/frontend/src/components/charts/donut-chart.tsx @@ -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 ( +
+

데이터가 없습니다

+
+ ); + } + + const total = data.reduce((sum, item) => sum + item.value, 0); + + const renderLegend = () => { + return ( +
    + {data.map((entry, index) => ( +
  • + + {entry.name} + + {((entry.value / total) * 100).toFixed(1)}% + +
  • + ))} +
+ ); + }; + + return ( +
+ + + + {data.map((entry, index) => ( + + ))} + + [ + `${(((value as number) / total) * 100).toFixed(1)}%`, + name, + ]} + /> + {showLegend && ( + + )} + + +
+ ); +} diff --git a/frontend/src/components/charts/index.ts b/frontend/src/components/charts/index.ts new file mode 100644 index 0000000..621b944 --- /dev/null +++ b/frontend/src/components/charts/index.ts @@ -0,0 +1,4 @@ +export { Sparkline } from './sparkline'; +export { AreaChart } from './area-chart'; +export { DonutChart } from './donut-chart'; +export { BarChart } from './bar-chart'; diff --git a/frontend/src/components/charts/sparkline.tsx b/frontend/src/components/charts/sparkline.tsx new file mode 100644 index 0000000..7e16009 --- /dev/null +++ b/frontend/src/components/charts/sparkline.tsx @@ -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 ( +
+ + + + + + + + + + + +
+ ); +}