220 lines
8.8 KiB
TypeScript
220 lines
8.8 KiB
TypeScript
import Link from "next/link";
|
|
import { Activity, RefreshCcw, Settings2, Wifi } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import type { DashboardBalanceSummary } from "@/features/dashboard/types/dashboard.types";
|
|
import {
|
|
formatCurrency,
|
|
formatSignedCurrency,
|
|
formatSignedPercent,
|
|
getChangeToneClass,
|
|
} from "@/features/dashboard/utils/dashboard-format";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface StatusHeaderProps {
|
|
summary: DashboardBalanceSummary | null;
|
|
isKisRestConnected: boolean;
|
|
isWebSocketReady: boolean;
|
|
isRealtimePending: boolean;
|
|
isProfileVerified: boolean;
|
|
verifiedAccountNo: string | null;
|
|
isRefreshing: boolean;
|
|
lastUpdatedAt: string | null;
|
|
onRefresh: () => void;
|
|
}
|
|
|
|
/**
|
|
* @description 대시보드 상단 상태 헤더(총자산/손익/연결상태/빠른액션) 컴포넌트입니다.
|
|
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 루트에서 상태 값을 전달받아 렌더링합니다.
|
|
*/
|
|
export function StatusHeader({
|
|
summary,
|
|
isKisRestConnected,
|
|
isWebSocketReady,
|
|
isRealtimePending,
|
|
isProfileVerified,
|
|
verifiedAccountNo,
|
|
isRefreshing,
|
|
lastUpdatedAt,
|
|
onRefresh,
|
|
}: StatusHeaderProps) {
|
|
const toneClass = getChangeToneClass(summary?.totalProfitLoss ?? 0);
|
|
const updatedLabel = lastUpdatedAt
|
|
? new Date(lastUpdatedAt).toLocaleTimeString("ko-KR", {
|
|
hour12: false,
|
|
})
|
|
: "--:--:--";
|
|
const hasApiTotalAmount =
|
|
Boolean(summary) && (summary?.apiReportedTotalAmount ?? 0) > 0;
|
|
const hasApiNetAssetAmount =
|
|
Boolean(summary) && (summary?.apiReportedNetAssetAmount ?? 0) > 0;
|
|
const isApiTotalAmountDifferent =
|
|
Boolean(summary) &&
|
|
Math.abs(
|
|
(summary?.apiReportedTotalAmount ?? 0) - (summary?.totalAmount ?? 0),
|
|
) >= 1;
|
|
const realtimeStatusLabel = isWebSocketReady
|
|
? isRealtimePending
|
|
? "수신 대기중"
|
|
: "연결됨"
|
|
: "미연결";
|
|
|
|
return (
|
|
<Card className="relative overflow-hidden border-brand-200 shadow-sm dark:border-brand-800/50">
|
|
{/* ========== BACKGROUND DECORATION ========== */}
|
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-20 bg-linear-to-r from-brand-100/70 via-brand-50/50 to-transparent dark:from-brand-900/35 dark:via-brand-900/20" />
|
|
|
|
<CardContent className="relative grid gap-3 p-4 md:grid-cols-[1fr_1fr_1fr_auto]">
|
|
{/* ========== TOTAL ASSET ========== */}
|
|
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
|
|
<p className="text-xs font-medium text-muted-foreground">내 자산 (순자산 실시간)</p>
|
|
<p className="mt-1 text-xl font-semibold tracking-tight">
|
|
{summary ? `${formatCurrency(summary.totalAmount)}원` : "-"}
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
현금(예수금) {summary ? `${formatCurrency(summary.cashBalance)}원` : "-"}
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
주식 평가금{" "}
|
|
{summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
총예수금(KIS){" "}
|
|
{summary ? `${formatCurrency(summary.totalDepositAmount)}원` : "-"}
|
|
</p>
|
|
<p className="mt-1 text-[11px] text-muted-foreground/80">
|
|
총예수금은 결제 대기 금액이 포함될 수 있어 체감 현금과 다를 수 있습니다.
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
순자산(대출 반영){" "}
|
|
{summary ? `${formatCurrency(summary.netAssetAmount)}원` : "-"}
|
|
</p>
|
|
{hasApiTotalAmount && isApiTotalAmountDifferent ? (
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
KIS 집계 총자산 {formatCurrency(summary?.apiReportedTotalAmount ?? 0)}원
|
|
</p>
|
|
) : null}
|
|
{hasApiNetAssetAmount ? (
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
KIS 집계 순자산 {formatCurrency(summary?.apiReportedNetAssetAmount ?? 0)}원
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* ========== PROFIT/LOSS ========== */}
|
|
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
|
|
<p className="text-xs font-medium text-muted-foreground">현재 손익</p>
|
|
<p
|
|
className={cn(
|
|
"mt-1 text-xl font-semibold tracking-tight",
|
|
toneClass,
|
|
)}
|
|
>
|
|
{summary ? `${formatSignedCurrency(summary.totalProfitLoss)}원` : "-"}
|
|
</p>
|
|
<p className={cn("mt-1 text-xs font-medium", toneClass)}>
|
|
{summary ? formatSignedPercent(summary.totalProfitRate) : "-"}
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
현재 평가금액{" "}
|
|
{summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
총 매수금액{" "}
|
|
{summary ? `${formatCurrency(summary.purchaseAmount)}원` : "-"}
|
|
</p>
|
|
</div>
|
|
|
|
{/* ========== CONNECTION STATUS ========== */}
|
|
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
|
|
<p className="text-xs font-medium text-muted-foreground">연결 상태</p>
|
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs font-medium">
|
|
<span
|
|
className={cn(
|
|
"inline-flex items-center gap-1 rounded-full px-2 py-1",
|
|
isKisRestConnected
|
|
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
|
: "bg-red-500/10 text-red-600 dark:text-red-400",
|
|
)}
|
|
>
|
|
<Wifi className="h-3.5 w-3.5" />
|
|
서버 {isKisRestConnected ? "연결됨" : "연결 끊김"}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"inline-flex items-center gap-1 rounded-full px-2 py-1",
|
|
isWebSocketReady
|
|
? isRealtimePending
|
|
? "bg-amber-500/10 text-amber-700 dark:text-amber-400"
|
|
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
|
: "bg-muted text-muted-foreground",
|
|
)}
|
|
>
|
|
<Activity className="h-3.5 w-3.5" />
|
|
실시간 시세 {realtimeStatusLabel}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"inline-flex items-center gap-1 rounded-full px-2 py-1",
|
|
isProfileVerified
|
|
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
|
: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
|
)}
|
|
>
|
|
<Activity className="h-3.5 w-3.5" />
|
|
계좌 인증 {isProfileVerified ? "완료" : "미완료"}
|
|
</span>
|
|
</div>
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
마지막 업데이트 {updatedLabel}
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
계좌 {maskAccountNo(verifiedAccountNo)}
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
대출금 {summary ? `${formatCurrency(summary.loanAmount)}원` : "-"}
|
|
</p>
|
|
</div>
|
|
|
|
{/* ========== QUICK ACTIONS ========== */}
|
|
<div className="flex flex-col gap-2 sm:flex-row md:flex-col md:items-stretch md:justify-center">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={onRefresh}
|
|
disabled={isRefreshing}
|
|
className="w-full border-brand-200 text-brand-700 hover:bg-brand-50 dark:border-brand-700 dark:text-brand-300 dark:hover:bg-brand-900/30"
|
|
>
|
|
<RefreshCcw
|
|
className={cn("h-4 w-4 mr-2", isRefreshing ? "animate-spin" : "")}
|
|
/>
|
|
지금 다시 불러오기
|
|
</Button>
|
|
<Button
|
|
asChild
|
|
className="w-full bg-brand-600 text-white hover:bg-brand-700 dark:bg-brand-600 dark:text-white dark:hover:bg-brand-500"
|
|
>
|
|
<Link href="/settings">
|
|
<Settings2 className="h-4 w-4 mr-2" />
|
|
연결 설정
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @description 계좌번호를 마스킹해 표시합니다.
|
|
* @param value 계좌번호(8-2)
|
|
* @returns 마스킹 문자열
|
|
* @see features/dashboard/components/StatusHeader.tsx 시스템 상태 영역 계좌 표시
|
|
*/
|
|
function maskAccountNo(value: string | null) {
|
|
if (!value) return "-";
|
|
const digits = value.replace(/\D/g, "");
|
|
if (digits.length !== 10) return "********";
|
|
return "********-**";
|
|
}
|