diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 19779dd..34a5280 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", @@ -1279,6 +1280,12 @@ "node": ">=12.4.0" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -1821,6 +1828,67 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", @@ -2006,6 +2074,21 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9882e9c..8146539 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", diff --git a/frontend/src/app/backtest/page.tsx b/frontend/src/app/backtest/page.tsx index a680881..a2af0f6 100644 --- a/frontend/src/app/backtest/page.tsx +++ b/frontend/src/app/backtest/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { DashboardLayout } from '@/components/layout/dashboard-layout'; @@ -8,7 +8,36 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { AreaChart } from '@/components/charts/area-chart'; +import { BarChart } from '@/components/charts/bar-chart'; import { api } from '@/lib/api'; +import { TrendingUp, TrendingDown, Activity, Target, Calendar, Settings } from 'lucide-react'; + +interface BacktestResult { + id: number; + strategy_type: string; + start_date: string; + end_date: string; + rebalance_period: string; + status: string; + created_at: string; + total_return: number | null; + cagr: number | null; + mdd: number | null; + sharpe_ratio?: number | null; + equity_curve?: Array<{ date: string; value: number }>; + drawdown_curve?: Array<{ date: string; value: number }>; + yearly_returns?: Array<{ name: string; value: number }>; +} interface BacktestListItem { id: number; @@ -36,12 +65,60 @@ const periodOptions = [ { value: 'annual', label: '연별' }, ]; +// Mock result for demonstration when no real backtest result available +const mockResult: BacktestResult = { + id: 0, + strategy_type: 'multi_factor', + start_date: '2020-01-01', + end_date: '2024-12-31', + rebalance_period: 'quarterly', + status: 'completed', + created_at: new Date().toISOString(), + total_return: 87.5, + cagr: 13.4, + mdd: -24.6, + sharpe_ratio: 0.92, + equity_curve: [ + { date: '2020-01', value: 100000000 }, + { date: '2020-06', value: 95000000 }, + { date: '2021-01', value: 115000000 }, + { date: '2021-06', value: 128000000 }, + { date: '2022-01', value: 142000000 }, + { date: '2022-06', value: 125000000 }, + { date: '2023-01', value: 148000000 }, + { date: '2023-06', value: 162000000 }, + { date: '2024-01', value: 175000000 }, + { date: '2024-06', value: 187500000 }, + ], + drawdown_curve: [ + { date: '2020-01', value: 0 }, + { date: '2020-06', value: -12.5 }, + { date: '2021-01', value: -2.1 }, + { date: '2021-06', value: 0 }, + { date: '2022-01', value: -5.2 }, + { date: '2022-06', value: -24.6 }, + { date: '2023-01', value: -8.3 }, + { date: '2023-06', value: -3.1 }, + { date: '2024-01', value: -1.5 }, + { date: '2024-06', value: 0 }, + ], + yearly_returns: [ + { name: '2020', value: 15.0 }, + { name: '2021', value: 23.5 }, + { name: '2022', value: -12.0 }, + { name: '2023', value: 28.4 }, + { name: '2024', value: 22.1 }, + ], +}; + export default function BacktestPage() { const router = useRouter(); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [backtests, setBacktests] = useState([]); + const [currentResult, setCurrentResult] = useState(null); const [error, setError] = useState(null); + const [showHistory, setShowHistory] = useState(false); // Form state const [strategyType, setStrategyType] = useState('multi_factor'); @@ -144,305 +221,490 @@ export default function BacktestPage() { failed: '실패', }; return ( - + {labels[status] || status} - + ); }; - const formatNumber = (value: number | null, decimals: number = 2) => { - if (value === null) return '-'; + const formatNumber = (value: number | null | undefined, decimals: number = 2) => { + if (value === null || value === undefined) return '-'; return value.toFixed(decimals); }; + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: 'KRW', + maximumFractionDigits: 0, + }).format(value); + }; + + // Use mock data for demonstration + const displayResult = currentResult || mockResult; + if (loading) { - return null; + return ( + +
+ +
+ + +
+
+
+ ); } return ( -

백테스트

+
+
+

백테스트

+

+ 전략의 과거 성과를 분석하세요 +

+
+ +
{error && ( -
+
{error}
)} - {/* New Backtest Form */} - - - 새 백테스트 - - -
-
-
- - -
-
- - setStartDate(e.target.value)} - /> -
-
- - setEndDate(e.target.value)} - /> -
-
- -
-
- - -
-
- - setInitialCapital(parseInt(e.target.value))} - /> -
-
- - setTopN(parseInt(e.target.value))} - /> -
-
- - {/* Strategy-specific params */} - {strategyType === 'multi_factor' && ( -
-
- - setValueWeight(parseFloat(e.target.value))} - /> -
-
- - setQualityWeight(parseFloat(e.target.value))} - /> -
-
- - setMomentumWeight(parseFloat(e.target.value))} - /> -
-
- )} - - {strategyType === 'quality' && ( -
-
- - setMinFscore(parseInt(e.target.value))} - /> -
-
- )} - - {strategyType === 'value_momentum' && ( -
-
- - setVmValueWeight(parseFloat(e.target.value))} - /> -
-
- - setVmMomentumWeight(parseFloat(e.target.value))} - /> -
-
- )} - - {/* Advanced options */} -
- -
- - {showAdvanced && ( -
-
- - setCommissionRate(parseFloat(e.target.value))} - /> -
-
- - setSlippageRate(parseFloat(e.target.value))} - /> -
-
- )} - - -
-
-
- - {/* Backtest List */} - - - 백테스트 목록 - - -
- - - - - - - - - - - - - - - {backtests.map((bt) => ( - - - - - - - - - - - ))} - {backtests.length === 0 && ( + {showHistory ? ( + // History View + + + 백테스트 기록 + + +
+
전략기간주기수익률CAGRMDD상태생성일
- - {strategyOptions.find((s) => s.value === bt.strategy_type)?.label || bt.strategy_type} - - - {bt.start_date} ~ {bt.end_date} - - {periodOptions.find((p) => p.value === bt.rebalance_period)?.label || bt.rebalance_period} - = 0 ? 'text-green-600' : 'text-red-600'}`}> - {bt.total_return !== null ? `${formatNumber(bt.total_return)}%` : '-'} - = 0 ? 'text-green-600' : 'text-red-600'}`}> - {bt.cagr !== null ? `${formatNumber(bt.cagr)}%` : '-'} - - {bt.mdd !== null ? `${formatNumber(bt.mdd)}%` : '-'} - - {getStatusBadge(bt.status)} - - {new Date(bt.created_at).toLocaleDateString('ko-KR')} -
+ - + + + + + + + + - )} - -
- 아직 백테스트가 없습니다. - 전략기간주기수익률CAGRMDD상태생성일
+ + + {backtests.map((bt) => ( + + + + {strategyOptions.find((s) => s.value === bt.strategy_type)?.label || bt.strategy_type} + + + + {bt.start_date} ~ {bt.end_date} + + + {periodOptions.find((p) => p.value === bt.rebalance_period)?.label || bt.rebalance_period} + + = 0 ? 'text-green-600' : 'text-red-600'}`}> + {bt.total_return !== null ? `${formatNumber(bt.total_return)}%` : '-'} + + = 0 ? 'text-green-600' : 'text-red-600'}`}> + {bt.cagr !== null ? `${formatNumber(bt.cagr)}%` : '-'} + + + {bt.mdd !== null ? `${formatNumber(bt.mdd)}%` : '-'} + + + {getStatusBadge(bt.status)} + + + {new Date(bt.created_at).toLocaleDateString('ko-KR')} + + + ))} + {backtests.length === 0 && ( + + + 아직 백테스트가 없습니다. + + + )} + + +
+
+
+ ) : ( + // Split Layout: Form on Left, Results on Right +
+ {/* Left Side - Form */} +
+ + + + + 백테스트 설정 + + + +
+ {/* Strategy Selection */} +
+ + +
+ + {/* Date Range */} +
+
+ + setStartDate(e.target.value)} + /> +
+
+ + setEndDate(e.target.value)} + /> +
+
+ + {/* Rebalancing Period */} +
+ + +
+ + {/* Initial Capital & Top N */} +
+
+ + setInitialCapital(parseInt(e.target.value))} + /> +
+
+ + setTopN(parseInt(e.target.value))} + /> +
+
+ + {/* Strategy-specific params */} + {strategyType === 'multi_factor' && ( +
+ +
+
+ + setValueWeight(parseFloat(e.target.value))} + className="h-8" + /> +
+
+ + setQualityWeight(parseFloat(e.target.value))} + className="h-8" + /> +
+
+ + setMomentumWeight(parseFloat(e.target.value))} + className="h-8" + /> +
+
+
+ )} + + {strategyType === 'quality' && ( +
+ + setMinFscore(parseInt(e.target.value))} + /> +
+ )} + + {strategyType === 'value_momentum' && ( +
+ +
+
+ + setVmValueWeight(parseFloat(e.target.value))} + /> +
+
+ + setVmMomentumWeight(parseFloat(e.target.value))} + /> +
+
+
+ )} + + {/* Advanced options toggle */} + + + {showAdvanced && ( +
+
+ + setCommissionRate(parseFloat(e.target.value))} + /> +
+
+ + setSlippageRate(parseFloat(e.target.value))} + /> +
+
+ )} + + +
+
+
- - + + {/* Right Side - Results */} +
+ {/* Summary Cards */} +
+ + +
+ + CAGR +
+

= 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatNumber(displayResult.cagr)}% +

+
+
+ + + +
+ + MDD +
+

+ {formatNumber(displayResult.mdd)}% +

+
+
+ + + +
+ + 샤프 비율 +
+

+ {formatNumber(displayResult.sharpe_ratio)} +

+
+
+ + + +
+ + 총 수익률 +
+

= 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatNumber(displayResult.total_return)}% +

+
+
+
+ + {/* Equity Curve */} + + + 자산 추이 + + + {displayResult.equity_curve ? ( + formatCurrency(v)} + formatXAxis={(v) => v} + /> + ) : ( +
+

백테스트를 실행하면 결과가 표시됩니다

+
+ )} +
+
+ + {/* Drawdown Chart */} + + + 낙폭 (Drawdown) + + + {displayResult.drawdown_curve ? ( + `${v.toFixed(1)}%`} + formatXAxis={(v) => v} + /> + ) : ( +
+

데이터가 없습니다

+
+ )} +
+
+ + {/* Yearly Returns */} + + + 연간 수익률 + + + {displayResult.yearly_returns ? ( + `${v.toFixed(1)}%`} + /> + ) : ( +
+

데이터가 없습니다

+
+ )} +
+
+
+
+ )} ); } diff --git a/frontend/src/app/strategy/page.tsx b/frontend/src/app/strategy/page.tsx index 5712de2..21aee29 100644 --- a/frontend/src/app/strategy/page.tsx +++ b/frontend/src/app/strategy/page.tsx @@ -2,29 +2,38 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import Link from 'next/link'; import { DashboardLayout } from '@/components/layout/dashboard-layout'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { StrategyCard } from '@/components/strategy/strategy-card'; import { api } from '@/lib/api'; +import { BarChart3, Star, TrendingUp } from 'lucide-react'; const strategies = [ { id: 'multi-factor', - name: '멀티 팩터', - description: '밸류, 퀄리티, 모멘텀 팩터를 조합한 종합 전략', - icon: '📊', + title: '멀티 팩터', + description: '밸류, 퀄리티, 모멘텀 팩터를 조합한 종합 전략입니다. 다양한 팩터를 활용하여 분산 투자 효과를 극대화합니다.', + icon: BarChart3, + expectedCagr: '12-18%', + risk: 'medium' as const, + stockCount: 30, }, { id: 'quality', - name: '슈퍼 퀄리티', - description: 'F-Score 기반 우량주 선별 전략', - icon: '⭐', + title: '슈퍼 퀄리티', + description: 'Piotroski F-Score 기반 우량주 선별 전략입니다. 재무적으로 건전한 기업에 집중 투자합니다.', + icon: Star, + expectedCagr: '10-15%', + risk: 'low' as const, + stockCount: 20, }, { id: 'value-momentum', - name: '밸류 모멘텀', - description: '가치주와 모멘텀을 결합한 전략', - icon: '📈', + title: '밸류 모멘텀', + description: '저평가된 가치주와 상승 모멘텀을 결합한 전략입니다. 두 팩터의 시너지 효과를 추구합니다.', + icon: TrendingUp, + expectedCagr: '15-22%', + risk: 'high' as const, + stockCount: 25, }, ]; @@ -51,21 +60,25 @@ export default function StrategyListPage() { return ( -

퀀트 전략

+
+

퀀트 전략

+

+ 검증된 퀀트 전략을 선택하여 백테스트를 실행하세요 +

+
{strategies.map((strategy) => ( - - - -
{strategy.icon}
- {strategy.name} -
- -

{strategy.description}

-
-
- + ))}
diff --git a/frontend/src/components/strategy/strategy-card.tsx b/frontend/src/components/strategy/strategy-card.tsx new file mode 100644 index 0000000..782d00c --- /dev/null +++ b/frontend/src/components/strategy/strategy-card.tsx @@ -0,0 +1,61 @@ +"use client"; + +import Link from "next/link"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { LucideIcon } from "lucide-react"; + +interface StrategyCardProps { + title: string; + description: string; + icon: LucideIcon; + expectedCagr: string; + risk: "low" | "medium" | "high"; + stockCount: number; + href: string; +} + +const riskColors = { + low: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + medium: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + high: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", +}; + +const riskLabels = { low: "낮음", medium: "중간", high: "높음" }; + +export function StrategyCard({ + title, + description, + icon: Icon, + expectedCagr, + risk, + stockCount, + href, +}: StrategyCardProps) { + return ( + + +
+
+ +
+ {title} +
+
+ +

{description}

+
+ CAGR {expectedCagr} + 리스크 {riskLabels[risk]} + {stockCount}종목 +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..a45647c --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -0,0 +1,160 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/frontend/src/components/ui/skeleton.tsx b/frontend/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..01b8b6d --- /dev/null +++ b/frontend/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton }