2026-02-13 12:17:35 +09:00
|
|
|
/**
|
|
|
|
|
* @file HoldingsList.tsx
|
|
|
|
|
* @description 대시보드 좌측 영역의 보유 종목 리스트 컴포넌트
|
|
|
|
|
* @remarks
|
|
|
|
|
* - [레이어] Components / UI
|
|
|
|
|
* - [사용자 행동] 종목 리스트 스크롤 -> 특정 종목 클릭(선택) -> 우측 상세 프레뷰 갱신
|
|
|
|
|
* - [데이터 흐름] DashboardContainer(mergedHoldings) -> HoldingsList -> HoldingItemRow -> onSelect(Callback)
|
|
|
|
|
* - [연관 파일] DashboardContainer.tsx, dashboard.types.ts, use-price-flash.ts
|
|
|
|
|
* @author jihoon87.lee
|
|
|
|
|
*/
|
2026-02-12 14:20:07 +09:00
|
|
|
import { AlertCircle, Wallet2 } from "lucide-react";
|
2026-02-13 12:17:35 +09:00
|
|
|
import { RefreshCcw } from "lucide-react";
|
2026-02-12 14:20:07 +09:00
|
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
2026-02-13 12:17:35 +09:00
|
|
|
import { Button } from "@/components/ui/button";
|
2026-02-12 14:20:07 +09:00
|
|
|
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";
|
2026-02-13 12:17:35 +09:00
|
|
|
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
|
2026-02-12 14:20:07 +09:00
|
|
|
|
|
|
|
|
interface HoldingsListProps {
|
2026-02-13 12:17:35 +09:00
|
|
|
/** 보유 종목 데이터 리스트 (실시간 시세 병합됨) */
|
2026-02-12 14:20:07 +09:00
|
|
|
holdings: DashboardHoldingItem[];
|
2026-02-13 12:17:35 +09:00
|
|
|
/** 현재 선택된 종목의 심볼 (없으면 null) */
|
2026-02-12 14:20:07 +09:00
|
|
|
selectedSymbol: string | null;
|
2026-02-13 12:17:35 +09:00
|
|
|
/** 데이터 로딩 상태 */
|
2026-02-12 14:20:07 +09:00
|
|
|
isLoading: boolean;
|
2026-02-13 12:17:35 +09:00
|
|
|
/** 에러 메시지 (없으면 null) */
|
2026-02-12 14:20:07 +09:00
|
|
|
error: string | null;
|
2026-02-13 12:17:35 +09:00
|
|
|
/** 섹션 재조회 핸들러 */
|
|
|
|
|
onRetry?: () => void;
|
|
|
|
|
/** 종목 선택 시 호출되는 핸들러 */
|
2026-02-12 14:20:07 +09:00
|
|
|
onSelect: (symbol: string) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-13 12:17:35 +09:00
|
|
|
* [컴포넌트] 보유 종목 리스트
|
|
|
|
|
* 사용자의 잔고 정보를 바탕으로 실시간 시세가 반영된 종목 카드 목록을 렌더링합니다.
|
|
|
|
|
*
|
|
|
|
|
* @param props HoldingsListProps
|
|
|
|
|
* @see DashboardContainer.tsx - 좌측 메인 영역에서 실시간 병합 데이터를 전달받아 호출
|
|
|
|
|
* @see DashboardContainer.tsx - setSelectedSymbol 핸들러를 onSelect로 전달
|
2026-02-12 14:20:07 +09:00
|
|
|
*/
|
|
|
|
|
export function HoldingsList({
|
|
|
|
|
holdings,
|
|
|
|
|
selectedSymbol,
|
|
|
|
|
isLoading,
|
|
|
|
|
error,
|
2026-02-13 12:17:35 +09:00
|
|
|
onRetry,
|
2026-02-12 14:20:07 +09:00
|
|
|
onSelect,
|
|
|
|
|
}: HoldingsListProps) {
|
|
|
|
|
return (
|
2026-02-13 12:17:35 +09:00
|
|
|
<Card className="h-full">
|
|
|
|
|
{/* ========== 카드 헤더: 타이틀 및 설명 ========== */}
|
2026-02-12 14:20:07 +09:00
|
|
|
<CardHeader className="pb-3">
|
|
|
|
|
<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>
|
|
|
|
|
|
2026-02-13 12:17:35 +09:00
|
|
|
{/* ========== 카드 본문: 상태별 메시지 및 리스트 ========== */}
|
2026-02-12 14:20:07 +09:00
|
|
|
<CardContent>
|
2026-02-13 12:17:35 +09:00
|
|
|
{/* 로딩 중 상태 (데이터가 아직 없는 경우) */}
|
2026-02-12 14:20:07 +09:00
|
|
|
{isLoading && holdings.length === 0 && (
|
2026-02-13 12:17:35 +09:00
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
보유 종목을 불러오는 중입니다.
|
|
|
|
|
</p>
|
2026-02-12 14:20:07 +09:00
|
|
|
)}
|
|
|
|
|
|
2026-02-13 12:17:35 +09:00
|
|
|
{/* 에러 발생 상태 */}
|
2026-02-12 14:20:07 +09:00
|
|
|
{error && (
|
2026-02-13 12:17:35 +09:00
|
|
|
<div className="mb-2 rounded-md border border-red-200 bg-red-50/60 p-2.5 dark:border-red-900/40 dark:bg-red-950/20">
|
|
|
|
|
<p className="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>
|
|
|
|
|
<p className="mt-1 text-xs text-red-700/80 dark:text-red-300/80">
|
|
|
|
|
한국투자증권 API가 일시적으로 불안정할 수 있습니다. 잠시 후 다시 시도해 주세요.
|
|
|
|
|
</p>
|
|
|
|
|
{onRetry ? (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={onRetry}
|
|
|
|
|
className="mt-2 border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/40"
|
|
|
|
|
>
|
|
|
|
|
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
|
|
|
|
|
보유종목 다시 불러오기
|
|
|
|
|
</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
2026-02-12 14:20:07 +09:00
|
|
|
)}
|
|
|
|
|
|
2026-02-13 12:17:35 +09:00
|
|
|
{/* 데이터 없음 상태 */}
|
2026-02-12 14:20:07 +09:00
|
|
|
{!isLoading && holdings.length === 0 && !error && (
|
|
|
|
|
<p className="text-sm text-muted-foreground">보유 종목이 없습니다.</p>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-13 12:17:35 +09:00
|
|
|
{/* 종목 리스트 렌더링 영역 */}
|
2026-02-12 14:20:07 +09:00
|
|
|
{holdings.length > 0 && (
|
|
|
|
|
<ScrollArea className="h-[420px] pr-3">
|
|
|
|
|
<div className="space-y-2">
|
2026-02-13 12:17:35 +09:00
|
|
|
{holdings.map((holding) => (
|
|
|
|
|
<HoldingItemRow
|
|
|
|
|
key={holding.symbol}
|
|
|
|
|
holding={holding}
|
|
|
|
|
isSelected={selectedSymbol === holding.symbol}
|
|
|
|
|
onSelect={onSelect}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
2026-02-12 14:20:07 +09:00
|
|
|
</div>
|
|
|
|
|
</ScrollArea>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-13 12:17:35 +09:00
|
|
|
|
|
|
|
|
interface HoldingItemRowProps {
|
|
|
|
|
/** 개별 종목 정보 */
|
|
|
|
|
holding: DashboardHoldingItem;
|
|
|
|
|
/** 선택 여부 */
|
|
|
|
|
isSelected: boolean;
|
|
|
|
|
/** 클릭 핸들러 */
|
|
|
|
|
onSelect: (symbol: string) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* [컴포넌트] 보유 종목 개별 행 (아이템)
|
|
|
|
|
* 종목의 기본 정보와 실시간 시세, 현재 손익 상태를 표시합니다.
|
|
|
|
|
*
|
|
|
|
|
* @param props HoldingItemRowProps
|
|
|
|
|
* @see HoldingsList.tsx - holdings.map 내에서 호출
|
|
|
|
|
* @see use-price-flash.ts - 현재가 변경 감지 및 애니메이션 효과 트리거
|
|
|
|
|
*/
|
|
|
|
|
function HoldingItemRow({
|
|
|
|
|
holding,
|
|
|
|
|
isSelected,
|
|
|
|
|
onSelect,
|
|
|
|
|
}: HoldingItemRowProps) {
|
|
|
|
|
// [State/Hook] 현재가 기반 가격 반짝임 효과 상태 관리
|
|
|
|
|
// @see use-price-flash.ts - 현재가 변경 감지 시 symbol을 기준으로 이펙트 발생 여부 결정
|
|
|
|
|
const flash = usePriceFlash(holding.currentPrice, holding.symbol);
|
|
|
|
|
|
|
|
|
|
// [UI] 손익 상태에 따른 텍스트 색상 클래스 결정 (상승: red, 하락: blue)
|
|
|
|
|
const toneClass = getChangeToneClass(holding.profitLoss);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
// [Step 1] 종목 클릭 시 부모의 선택 핸들러 호출
|
|
|
|
|
onClick={() => onSelect(holding.symbol)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"w-full rounded-xl border px-3 py-3 text-left transition-all relative overflow-hidden",
|
|
|
|
|
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",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{/* ========== 행 상단: 종목명, 심볼 및 현재가 정보 ========== */}
|
|
|
|
|
<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">
|
|
|
|
|
<div className="relative inline-flex items-center justify-end gap-1">
|
|
|
|
|
{/* 시세 변동 애니메이션 (Flash) 표시 영역 */}
|
|
|
|
|
{flash && (
|
|
|
|
|
<span
|
|
|
|
|
key={flash.id}
|
|
|
|
|
className={cn(
|
|
|
|
|
"pointer-events-none absolute -left-12 top-0 whitespace-nowrap text-xs font-bold animate-in fade-in slide-in-from-bottom-1 fill-mode-forwards duration-300",
|
|
|
|
|
flash.type === "up" ? "text-red-500" : "text-blue-500",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{flash.type === "up" ? "+" : ""}
|
|
|
|
|
{flash.val.toLocaleString()}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{/* 실시간 현재가 */}
|
|
|
|
|
<p className="text-sm font-semibold text-foreground transition-colors duration-300">
|
|
|
|
|
{formatCurrency(holding.currentPrice)}원
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
{/* 실시간 수익률 */}
|
|
|
|
|
<p className={cn("text-xs font-medium", toneClass)}>
|
|
|
|
|
{formatPercent(holding.profitRate)}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ========== 행 하단: 평단가, 평가액 및 실시간 손익 ========== */}
|
|
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|