2026-02-12 14:20:07 +09:00
|
|
|
import Link from "next/link";
|
2026-03-12 09:26:27 +09:00
|
|
|
import type { ReactNode } from "react";
|
2026-02-12 14:20:07 +09:00
|
|
|
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,
|
2026-02-13 12:17:35 +09:00
|
|
|
formatSignedCurrency,
|
|
|
|
|
formatSignedPercent,
|
2026-02-12 14:20:07 +09:00
|
|
|
getChangeToneClass,
|
|
|
|
|
} from "@/features/dashboard/utils/dashboard-format";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
|
|
|
|
interface StatusHeaderProps {
|
|
|
|
|
summary: DashboardBalanceSummary | null;
|
|
|
|
|
isKisRestConnected: boolean;
|
|
|
|
|
isWebSocketReady: boolean;
|
2026-02-13 12:17:35 +09:00
|
|
|
isRealtimePending: boolean;
|
2026-02-12 17:16:41 +09:00
|
|
|
isProfileVerified: boolean;
|
|
|
|
|
verifiedAccountNo: string | null;
|
2026-02-12 14:20:07 +09:00
|
|
|
isRefreshing: boolean;
|
|
|
|
|
lastUpdatedAt: string | null;
|
|
|
|
|
onRefresh: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description 대시보드 상단 상태 헤더(총자산/손익/연결상태/빠른액션) 컴포넌트입니다.
|
|
|
|
|
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 루트에서 상태 값을 전달받아 렌더링합니다.
|
|
|
|
|
*/
|
|
|
|
|
export function StatusHeader({
|
|
|
|
|
summary,
|
|
|
|
|
isKisRestConnected,
|
|
|
|
|
isWebSocketReady,
|
2026-02-13 12:17:35 +09:00
|
|
|
isRealtimePending,
|
2026-02-12 17:16:41 +09:00
|
|
|
isProfileVerified,
|
|
|
|
|
verifiedAccountNo,
|
2026-02-12 14:20:07 +09:00
|
|
|
isRefreshing,
|
|
|
|
|
lastUpdatedAt,
|
|
|
|
|
onRefresh,
|
|
|
|
|
}: StatusHeaderProps) {
|
|
|
|
|
const toneClass = getChangeToneClass(summary?.totalProfitLoss ?? 0);
|
|
|
|
|
const updatedLabel = lastUpdatedAt
|
|
|
|
|
? new Date(lastUpdatedAt).toLocaleTimeString("ko-KR", {
|
|
|
|
|
hour12: false,
|
|
|
|
|
})
|
|
|
|
|
: "--:--:--";
|
2026-02-13 12:17:35 +09:00
|
|
|
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
|
|
|
|
|
? "수신 대기중"
|
|
|
|
|
: "연결됨"
|
|
|
|
|
: "미연결";
|
2026-03-12 09:26:27 +09:00
|
|
|
const displayGrossTotalAmount = hasApiTotalAmount
|
|
|
|
|
? summary?.apiReportedTotalAmount ?? 0
|
|
|
|
|
: summary?.totalAmount ?? 0;
|
2026-02-12 14:20:07 +09:00
|
|
|
|
|
|
|
|
return (
|
2026-03-12 09:26:27 +09:00
|
|
|
<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" />
|
2026-02-12 14:20:07 +09:00
|
|
|
|
2026-03-12 09:26:27 +09:00
|
|
|
<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>
|
2026-02-13 12:17:35 +09:00
|
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
2026-03-12 09:26:27 +09:00
|
|
|
순자산 {summary ? `${formatCurrency(summary.netAssetAmount)}원` : "-"}
|
2026-02-13 12:17:35 +09:00
|
|
|
</p>
|
|
|
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
2026-03-12 09:26:27 +09:00
|
|
|
현금 {summary ? `${formatCurrency(summary.cashBalance)}원` : "-"} · 평가금{" "}
|
|
|
|
|
{summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}
|
2026-02-13 12:17:35 +09:00
|
|
|
</p>
|
2026-03-12 09:26:27 +09:00
|
|
|
</div>
|
2026-02-12 14:20:07 +09:00
|
|
|
|
2026-03-12 09:26:27 +09:00
|
|
|
<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>
|
2026-02-12 14:20:07 +09:00
|
|
|
|
2026-03-12 09:26:27 +09:00
|
|
|
<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"
|
2026-02-12 14:20:07 +09:00
|
|
|
>
|
2026-03-12 09:26:27 +09:00
|
|
|
<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"
|
2026-02-12 14:20:07 +09:00
|
|
|
>
|
2026-03-12 09:26:27 +09:00
|
|
|
<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>
|
2026-02-12 14:20:07 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-12 09:26:27 +09:00
|
|
|
<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"
|
|
|
|
|
/>
|
2026-02-12 14:20:07 +09:00
|
|
|
</div>
|
2026-03-12 09:26:27 +09:00
|
|
|
|
|
|
|
|
{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}
|
2026-02-12 14:20:07 +09:00
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-12 17:16:41 +09:00
|
|
|
|
2026-03-12 09:26:27 +09:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 17:16:41 +09:00
|
|
|
/**
|
|
|
|
|
* @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 "********-**";
|
|
|
|
|
}
|