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

223 lines
9.2 KiB
TypeScript

import Link from "next/link";
import type { ReactNode } from "react";
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
? "수신 대기중"
: "연결됨"
: "미연결";
const displayGrossTotalAmount = hasApiTotalAmount
? summary?.apiReportedTotalAmount ?? 0
: summary?.totalAmount ?? 0;
return (
<Card className="relative overflow-hidden border-brand-200/80 bg-linear-to-br from-brand-50/80 via-background to-brand-50/20 shadow-sm dark:border-brand-800/45 dark:from-brand-900/25 dark:via-background dark:to-background">
<div className="pointer-events-none absolute -right-14 -top-14 h-52 w-52 rounded-full bg-brand-300/30 blur-3xl dark:bg-brand-700/20" />
<div className="pointer-events-none absolute -left-16 bottom-0 h-44 w-44 rounded-full bg-brand-200/25 blur-3xl dark:bg-brand-800/20" />
<CardContent className="relative space-y-3 p-4 md:p-5">
<div className="grid gap-3 xl:grid-cols-[1fr_1fr_auto]">
<div className="rounded-2xl border border-brand-200/70 bg-background/85 p-4 shadow-sm dark:border-brand-800/60 dark:bg-brand-950/20">
<p className="text-xs font-semibold tracking-wide text-muted-foreground">
TOTAL ASSET
</p>
<p className="mt-2 text-2xl font-bold tracking-tight text-foreground md:text-3xl">
{summary ? `${formatCurrency(displayGrossTotalAmount)}` : "-"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{summary ? `${formatCurrency(summary.netAssetAmount)}` : "-"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{summary ? `${formatCurrency(summary.cashBalance)}` : "-"} · {" "}
{summary ? `${formatCurrency(summary.evaluationAmount)}` : "-"}
</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/85 p-4 shadow-sm">
<p className="text-xs font-semibold tracking-wide text-muted-foreground">
TODAY P/L
</p>
<p className={cn("mt-2 text-2xl font-bold tracking-tight md:text-3xl", toneClass)}>
{summary ? `${formatSignedCurrency(summary.totalProfitLoss)}` : "-"}
</p>
<p className={cn("mt-1 text-sm font-semibold", toneClass)}>
{summary ? formatSignedPercent(summary.totalProfitRate) : "-"}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{summary ? `${formatCurrency(summary.purchaseAmount)}` : "-"} · {" "}
{summary ? `${formatCurrency(summary.loanAmount)}` : "-"}
</p>
</div>
<div className="flex flex-col gap-2 rounded-2xl border border-border/70 bg-background/85 p-3">
<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("mr-2 h-4 w-4", 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="mr-2 h-4 w-4" />
</Link>
</Button>
<div className="mt-1 rounded-xl border border-border/70 bg-muted/30 px-2.5 py-2 text-[11px] text-muted-foreground">
<p> {updatedLabel}</p>
<p> {maskAccountNo(verifiedAccountNo)}</p>
</div>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<OverviewMetric
icon={<Wifi className="h-3.5 w-3.5" />}
label="REST 연결"
value={isKisRestConnected ? "정상" : "끊김"}
toneClass={
isKisRestConnected
? "border-emerald-300/70 bg-emerald-50/60 text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-950/30 dark:text-emerald-300"
: "border-red-300/70 bg-red-50/60 text-red-700 dark:border-red-700/50 dark:bg-red-950/30 dark:text-red-300"
}
/>
<OverviewMetric
icon={<Activity className="h-3.5 w-3.5" />}
label="실시간 시세"
value={realtimeStatusLabel}
toneClass={
isWebSocketReady
? isRealtimePending
? "border-amber-300/70 bg-amber-50/60 text-amber-700 dark:border-amber-700/50 dark:bg-amber-950/30 dark:text-amber-300"
: "border-emerald-300/70 bg-emerald-50/60 text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-950/30 dark:text-emerald-300"
: "border-slate-300/70 bg-slate-50/60 text-slate-700 dark:border-slate-700/50 dark:bg-slate-900/30 dark:text-slate-300"
}
/>
<OverviewMetric
icon={<Activity className="h-3.5 w-3.5" />}
label="계좌 인증"
value={isProfileVerified ? "완료" : "미완료"}
toneClass={
isProfileVerified
? "border-emerald-300/70 bg-emerald-50/60 text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-950/30 dark:text-emerald-300"
: "border-amber-300/70 bg-amber-50/60 text-amber-700 dark:border-amber-700/50 dark:bg-amber-950/30 dark:text-amber-300"
}
/>
<OverviewMetric
icon={<Activity className="h-3.5 w-3.5" />}
label="총예수금(KIS)"
value={summary ? `${formatCurrency(summary.totalDepositAmount)}` : "-"}
toneClass="border-brand-200/80 bg-brand-50/70 text-brand-700 dark:border-brand-700/60 dark:bg-brand-900/35 dark:text-brand-300"
/>
</div>
{hasApiTotalAmount && isApiTotalAmountDifferent ? (
<p className="text-xs text-muted-foreground">
{formatCurrency(summary?.totalAmount ?? 0)} · KIS {" "}
{formatCurrency(summary?.apiReportedTotalAmount ?? 0)}
</p>
) : null}
{hasApiNetAssetAmount ? (
<p className="text-xs text-muted-foreground">
KIS {formatCurrency(summary?.apiReportedNetAssetAmount ?? 0)}
</p>
) : null}
</CardContent>
</Card>
);
}
function OverviewMetric({
icon,
label,
value,
toneClass,
}: {
icon: ReactNode;
label: string;
value: string;
toneClass: string;
}) {
return (
<div className={cn("rounded-xl border px-3 py-2", toneClass)}>
<p className="flex items-center gap-1 text-[11px] font-medium opacity-85">
{icon}
{label}
</p>
<p className="mt-0.5 text-xs font-semibold">{value}</p>
</div>
);
}
/**
* @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 "********-**";
}