127 lines
4.7 KiB
TypeScript
127 lines
4.7 KiB
TypeScript
import { AlertCircle, Wallet2 } from "lucide-react";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
|
import {
|
|
formatCurrency,
|
|
formatPercent,
|
|
getChangeToneClass,
|
|
} from "@/features/dashboard/utils/dashboard-format";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface HoldingsListProps {
|
|
holdings: DashboardHoldingItem[];
|
|
selectedSymbol: string | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
onSelect: (symbol: string) => void;
|
|
}
|
|
|
|
/**
|
|
* @description 보유 종목 리스트 카드입니다.
|
|
* @see features/dashboard/components/DashboardContainer.tsx 좌측 메인 영역에서 호출합니다.
|
|
*/
|
|
export function HoldingsList({
|
|
holdings,
|
|
selectedSymbol,
|
|
isLoading,
|
|
error,
|
|
onSelect,
|
|
}: HoldingsListProps) {
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
{/* ========== TITLE ========== */}
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Wallet2 className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
|
보유 종목
|
|
</CardTitle>
|
|
<CardDescription>
|
|
현재 보유 중인 종목을 선택하면 우측 상세가 갱신됩니다.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
|
|
<CardContent>
|
|
{isLoading && holdings.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">보유 종목을 불러오는 중입니다.</p>
|
|
)}
|
|
|
|
{error && (
|
|
<p className="mb-2 flex items-start gap-1.5 text-sm text-red-600 dark:text-red-400">
|
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
|
<span>{error}</span>
|
|
</p>
|
|
)}
|
|
|
|
{!isLoading && holdings.length === 0 && !error && (
|
|
<p className="text-sm text-muted-foreground">보유 종목이 없습니다.</p>
|
|
)}
|
|
|
|
{holdings.length > 0 && (
|
|
<ScrollArea className="h-[420px] pr-3">
|
|
<div className="space-y-2">
|
|
{holdings.map((holding) => {
|
|
const isSelected = selectedSymbol === holding.symbol;
|
|
const toneClass = getChangeToneClass(holding.profitLoss);
|
|
|
|
return (
|
|
<button
|
|
key={holding.symbol}
|
|
type="button"
|
|
onClick={() => onSelect(holding.symbol)}
|
|
className={cn(
|
|
"w-full rounded-xl border px-3 py-3 text-left transition-all",
|
|
isSelected
|
|
? "border-brand-400 bg-brand-50/60 ring-1 ring-brand-300 dark:border-brand-600 dark:bg-brand-900/20 dark:ring-brand-700"
|
|
: "border-border/70 bg-background hover:border-brand-200 hover:bg-brand-50/30 dark:hover:border-brand-700 dark:hover:bg-brand-900/15",
|
|
)}
|
|
>
|
|
{/* ========== ROW TOP ========== */}
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div>
|
|
<p className="text-sm font-semibold text-foreground">
|
|
{holding.name}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{holding.symbol} · {holding.market} · {holding.quantity}주
|
|
</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-sm font-semibold text-foreground">
|
|
{formatCurrency(holding.currentPrice)}원
|
|
</p>
|
|
<p className={cn("text-xs font-medium", toneClass)}>
|
|
{formatPercent(holding.profitRate)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ========== ROW BOTTOM ========== */}
|
|
<div className="mt-2 grid grid-cols-3 gap-1 text-xs">
|
|
<span className="text-muted-foreground">
|
|
평균 매수가 {formatCurrency(holding.averagePrice)}원
|
|
</span>
|
|
<span className="text-muted-foreground">
|
|
현재 평가금액 {formatCurrency(holding.evaluationAmount)}원
|
|
</span>
|
|
<span className={cn("text-right font-medium", toneClass)}>
|
|
현재 손익 {formatCurrency(holding.profitLoss)}원
|
|
</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</ScrollArea>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|