Files
auto-trade/features/dashboard/components/HoldingsList.tsx

253 lines
9.4 KiB
TypeScript
Raw Normal View History

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-03-12 09:26:27 +09:00
import { useRouter } from "next/navigation";
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";
2026-03-12 09:26:27 +09:00
import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
2026-02-12 14:20:07 +09:00
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) {
2026-03-12 09:26:27 +09:00
const router = useRouter();
const setPendingTarget = useTradeNavigationStore(
(state) => state.setPendingTarget,
);
const handleNavigateToTrade = (holding: DashboardHoldingItem) => {
setPendingTarget({
symbol: holding.symbol,
name: holding.name,
market: holding.market,
});
router.push("/trade");
};
2026-02-12 14:20:07 +09:00
return (
2026-03-12 09:26:27 +09:00
<Card className="h-full border-brand-200/70 bg-background/90 shadow-sm dark:border-brand-800/45">
2026-02-13 12:17:35 +09:00
{/* ========== 카드 헤더: 타이틀 및 설명 ========== */}
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 && (
2026-03-12 09:26:27 +09:00
<ScrollArea className="h-[360px] pr-3">
2026-02-12 14:20:07 +09:00
<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-03-12 09:26:27 +09:00
onNavigateToTrade={handleNavigateToTrade}
2026-02-13 12:17:35 +09:00
/>
))}
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;
2026-03-12 09:26:27 +09:00
/** 거래 페이지 이동 핸들러 */
onNavigateToTrade: (holding: DashboardHoldingItem) => void;
2026-02-13 12:17:35 +09:00
}
/**
* [] ()
* , .
*
* @param props HoldingItemRowProps
* @see HoldingsList.tsx - holdings.map
* @see use-price-flash.ts -
*/
function HoldingItemRow({
holding,
isSelected,
onSelect,
2026-03-12 09:26:27 +09:00
onNavigateToTrade,
2026-02-13 12:17:35 +09:00
}: 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"
2026-03-12 09:26:27 +09:00
// [Step 1] 종목 클릭 시 선택 상태 갱신 후 거래 화면으로 이동
onClick={() => {
onSelect(holding.symbol);
onNavigateToTrade(holding);
}}
2026-02-13 12:17:35 +09:00
className={cn(
2026-03-12 09:26:27 +09:00
"relative w-full overflow-hidden rounded-xl border px-3 py-3 text-left shadow-sm transition-all",
2026-02-13 12:17:35 +09:00
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"
2026-03-12 09:26:27 +09:00
: "border-border/70 bg-background hover:-translate-y-0.5 hover:border-brand-200 hover:bg-brand-50/30 hover:shadow-md dark:hover:border-brand-700 dark:hover:bg-brand-900/15",
2026-02-13 12:17:35 +09:00
)}
>
{/* ========== 행 상단: 종목명, 심볼 및 현재가 정보 ========== */}
<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">
2026-03-12 09:26:27 +09:00
{holding.symbol} · {holding.market} · {holding.quantity} · {" "}
{holding.sellableQuantity}
2026-02-13 12:17:35 +09:00
</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>
);
}