대시보드 실시간 기능 추가

This commit is contained in:
2026-02-13 12:17:35 +09:00
parent 12feeb2775
commit 1ac907cd27
35 changed files with 2790 additions and 1032 deletions

View File

@@ -3,6 +3,7 @@ import { useShallow } from "zustand/react/shallow";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import {
revokeKisCredentials,
@@ -10,15 +11,19 @@ import {
} from "@/features/settings/apis/kis-auth.api";
import {
KeyRound,
Shield,
ShieldCheck,
CheckCircle2,
XCircle,
Lock,
Sparkles,
Zap,
Link2,
Unlink2,
Activity,
Zap,
KeySquare,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { InlineSpinner } from "@/components/ui/loading-spinner";
import { SettingsCard } from "./SettingsCard";
/**
* @description 한국투자증권 앱키/앱시크릿키 인증 폼입니다.
@@ -62,9 +67,6 @@ export function KisAuthForm() {
const [isValidating, startValidateTransition] = useTransition();
const [isRevoking, startRevokeTransition] = useTransition();
// 입력 필드 Focus 상태 관리를 위한 State
const [focusedField, setFocusedField] = useState<"appKey" | "appSecret" | null>(null);
function handleValidate() {
startValidateTransition(async () => {
try {
@@ -123,134 +125,21 @@ export function KisAuthForm() {
}
return (
<div className="group relative w-full overflow-hidden rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm transition-all hover:border-brand-200 hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900/50 dark:hover:border-brand-800 dark:hover:shadow-brand-900/10">
{/* Inner Content Container - Compact spacing */}
<div className="flex flex-col gap-4">
{/* Header Section */}
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-brand-50 text-brand-600 ring-1 ring-brand-100 dark:bg-brand-900/20 dark:text-brand-400 dark:ring-brand-800/50">
<KeyRound className="h-4.5 w-4.5" />
</div>
<div>
<h2 className="flex items-center gap-2 text-base font-bold tracking-tight text-zinc-800 dark:text-zinc-100">
{isKisVerified && (
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-1.5 py-0.5 text-[10px] font-medium text-green-600 ring-1 ring-green-600/10 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/30">
<CheckCircle2 className="h-3 w-3" />
</span>
)}
</h2>
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400">
Open API에서 /릿 .
</p>
</div>
</div>
{/* Trading Mode Switch (Segmented Control - Compact) */}
<div className="flex shrink-0 items-center rounded-md bg-zinc-100 p-0.5 ring-1 ring-zinc-200 dark:bg-zinc-800 dark:ring-zinc-700">
<button
onClick={() => setKisTradingEnvInput("real")}
className={cn(
"relative flex items-center gap-1.5 rounded-[5px] px-2.5 py-1 text-[11px] font-semibold transition-all duration-200",
kisTradingEnvInput === "real"
? "bg-white text-brand-600 shadow-sm ring-1 ring-black/5 dark:bg-brand-500 dark:text-white dark:ring-brand-400"
: "text-zinc-500 hover:text-zinc-700 hover:bg-black/5 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-white/5",
)}
>
<Zap
className={cn(
"h-3 w-3",
kisTradingEnvInput === "real"
? "text-brand-500 dark:text-white"
: "text-zinc-400",
)}
/>
</button>
<button
onClick={() => setKisTradingEnvInput("mock")}
className={cn(
"relative flex items-center gap-1.5 rounded-[5px] px-2.5 py-1 text-[11px] font-semibold transition-all duration-200",
kisTradingEnvInput === "mock"
? "bg-white text-zinc-800 shadow-sm ring-1 ring-black/5 dark:bg-zinc-600 dark:text-white dark:ring-zinc-500"
: "text-zinc-500 hover:text-zinc-700 hover:bg-black/5 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-white/5",
)}
>
<Activity
className={cn(
"h-3 w-3",
kisTradingEnvInput === "mock"
? "text-zinc-800 dark:text-zinc-200"
: "text-zinc-400",
)}
/>
</button>
</div>
</div>
{/* Input Fields Section (Compact Stacked Layout) */}
<div className="flex flex-col gap-2">
{/* ========== APP KEY INPUT ========== */}
<div
className={cn(
"group/input relative flex items-center overflow-hidden rounded-lg border bg-white transition-all duration-200 dark:bg-zinc-900/30",
focusedField === "appKey"
? "border-brand-500 ring-1 ring-brand-500"
: "border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600",
)}
>
<div className="hidden h-9 w-9 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 transition-colors group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-500 dark:group-focus-within/input:text-brand-400 sm:flex">
<Shield className="h-3.5 w-3.5" />
</div>
<div className="flex h-9 min-w-[70px] items-center justify-center bg-zinc-50 px-2 text-[11px] font-semibold text-zinc-500 border-r border-zinc-100 sm:hidden dark:bg-zinc-800/50 dark:text-zinc-500 dark:border-zinc-800">
</div>
<Input
type="password"
value={kisAppKeyInput}
onChange={(e) => setKisAppKeyInput(e.target.value)}
onFocus={() => setFocusedField("appKey")}
onBlur={() => setFocusedField(null)}
placeholder="한국투자증권 앱키 입력"
className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600"
autoComplete="off"
/>
</div>
{/* ========== APP SECRET INPUT ========== */}
<div
className={cn(
"group/input relative flex items-center overflow-hidden rounded-lg border bg-white transition-all duration-200 dark:bg-zinc-900/30",
focusedField === "appSecret"
? "border-brand-500 ring-1 ring-brand-500"
: "border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600",
)}
>
<div className="hidden h-9 w-9 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 transition-colors group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-500 dark:group-focus-within/input:text-brand-400 sm:flex">
<Lock className="h-3.5 w-3.5" />
</div>
<div className="flex h-9 min-w-[70px] items-center justify-center bg-zinc-50 px-2 text-[11px] font-semibold text-zinc-500 border-r border-zinc-100 sm:hidden dark:bg-zinc-800/50 dark:text-zinc-500 dark:border-zinc-800">
릿
</div>
<Input
type="password"
value={kisAppSecretInput}
onChange={(e) => setKisAppSecretInput(e.target.value)}
onFocus={() => setFocusedField("appSecret")}
onBlur={() => setFocusedField(null)}
placeholder="한국투자증권 앱시크릿키 입력"
className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600"
autoComplete="off"
/>
</div>
</div>
{/* Action & Status Section */}
<div className="flex items-center justify-between border-t border-zinc-100 pt-4 dark:border-zinc-800/50">
<div className="flex gap-2">
<SettingsCard
icon={KeyRound}
title="한국투자증권 앱키 연결"
description="Open API에서 발급받은 앱키와 앱시크릿키를 입력해 연결을 완료하세요."
badge={
isKisVerified ? (
<span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-full bg-green-50 px-2 py-0.5 text-[11px] font-medium text-green-700 ring-1 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/30">
<CheckCircle2 className="h-3 w-3" />
</span>
) : undefined
}
footer={{
actions: (
<div className="flex flex-wrap gap-2">
<Button
onClick={handleValidate}
disabled={
@@ -262,52 +151,160 @@ export function KisAuthForm() {
>
{isValidating ? (
<span className="flex items-center gap-1.5">
<InlineSpinner className="text-white h-3 w-3" />
<InlineSpinner className="h-3 w-3 text-white" />
</span>
) : (
<span className="flex items-center gap-1.5">
<Sparkles className="h-3 w-3 fill-brand-200 text-brand-200" />
</span>
)}
</Button>
) : (
<span className="flex items-center gap-1.5">
<Link2 className="h-3.5 w-3.5 text-brand-100" />
</span>
)}
</Button>
{isKisVerified && (
<Button
variant="outline"
onClick={handleRevoke}
disabled={isRevoking}
className="h-9 rounded-lg border-zinc-200 bg-white text-xs text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
className="h-9 rounded-lg border-zinc-200 bg-white px-4 text-xs text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
>
{isRevoking ? "해제 중" : "연결 해제"}
{isRevoking ? (
"해제 중"
) : (
<span className="flex items-center gap-1.5">
<Unlink2 className="h-3.5 w-3.5" />
</span>
)}
</Button>
)}
</div>
{/* Status Messages - Compact */}
<div className="flex-1 text-right">
),
status: (
<div className="flex min-h-5 items-center justify-start sm:justify-end">
{errorMessage && (
<p className="animate-in fade-in slide-in-from-right-4 flex justify-end gap-1.5 text-[11px] font-semibold text-red-500">
<p className="animate-in fade-in slide-in-from-right-4 flex items-center gap-1.5 text-xs font-semibold text-red-500">
<XCircle className="h-3.5 w-3.5" />
{errorMessage}
</p>
)}
{statusMessage && (
<p className="animate-in fade-in slide-in-from-right-4 flex justify-end gap-1.5 text-[11px] font-semibold text-brand-600 dark:text-brand-400">
<p className="animate-in fade-in slide-in-from-right-4 flex items-center gap-1.5 text-xs font-semibold text-brand-600 dark:text-brand-400">
<CheckCircle2 className="h-3.5 w-3.5" />
{statusMessage}
</p>
)}
{!errorMessage && !statusMessage && !isKisVerified && (
<p className="flex justify-end gap-1.5 text-[11px] text-zinc-400 dark:text-zinc-600">
<span className="h-1.5 w-1.5 translate-y-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
<p className="flex items-center gap-1.5 text-xs text-zinc-400 dark:text-zinc-600">
<span className="h-1.5 w-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
</p>
)}
</div>
),
}}
className="h-full"
>
<div className="space-y-4">
{/* ========== TRADING MODE ========== */}
<section className="rounded-xl border border-zinc-200 bg-zinc-50/70 p-3 dark:border-zinc-800 dark:bg-zinc-900/30">
<div className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-zinc-700 dark:text-zinc-200">
<ShieldCheck className="h-3.5 w-3.5 text-brand-500" />
</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setKisTradingEnvInput("real")}
className={cn(
"flex h-9 items-center justify-center gap-1.5 rounded-lg border text-xs font-semibold transition",
kisTradingEnvInput === "real"
? "border-brand-500 bg-brand-600 text-white shadow-sm"
: "border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300",
)}
>
<Zap className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setKisTradingEnvInput("mock")}
className={cn(
"flex h-9 items-center justify-center gap-1.5 rounded-lg border text-xs font-semibold transition",
kisTradingEnvInput === "mock"
? "border-zinc-700 bg-zinc-800 text-white shadow-sm dark:border-zinc-500 dark:bg-zinc-700"
: "border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300",
)}
>
<Activity className="h-3.5 w-3.5" />
</button>
</div>
</section>
{/* ========== APP KEY INPUTS ========== */}
<div className="space-y-3">
<CredentialInput
id="kis-app-key"
label="앱키"
placeholder="한국투자증권 앱키 입력"
value={kisAppKeyInput}
onChange={setKisAppKeyInput}
icon={KeySquare}
/>
<CredentialInput
id="kis-app-secret"
label="앱시크릿키"
placeholder="한국투자증권 앱시크릿키 입력"
value={kisAppSecretInput}
onChange={setKisAppSecretInput}
icon={Lock}
/>
</div>
</div>
</SettingsCard>
);
}
/**
* @description 앱키/시크릿키 입력 전용 필드 블록입니다.
* @see features/settings/components/KisAuthForm.tsx 입력 UI 렌더링
*/
function CredentialInput({
id,
label,
value,
placeholder,
onChange,
icon: Icon,
}: {
id: string;
label: string;
value: string;
placeholder: string;
onChange: (value: string) => void;
icon: LucideIcon;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id} className="text-xs font-semibold text-zinc-600">
{label}
</Label>
<div className="group/input flex items-center overflow-hidden rounded-xl border border-zinc-200 bg-white transition-colors focus-within:border-brand-500 focus-within:ring-1 focus-within:ring-brand-500 dark:border-zinc-700 dark:bg-zinc-900/20">
<div className="flex h-10 w-10 shrink-0 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/40">
<Icon className="h-4 w-4" />
</div>
<Input
id={id}
type="password"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="h-10 border-none bg-transparent px-3 text-sm shadow-none focus-visible:ring-0"
autoComplete="off"
/>
</div>
</div>
);
}

View File

@@ -2,12 +2,21 @@
import { useState, useTransition } from "react";
import { useShallow } from "zustand/react/shallow";
import { CreditCard, CheckCircle2, SearchCheck, ShieldOff, XCircle } from "lucide-react";
import {
CreditCard,
CheckCircle2,
SearchCheck,
ShieldOff,
XCircle,
FileLock2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { InlineSpinner } from "@/components/ui/loading-spinner";
import { validateKisProfile } from "@/features/settings/apis/kis-auth.api";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import { SettingsCard } from "./SettingsCard";
/**
* @description 한국투자증권 계좌번호 검증 폼입니다.
@@ -98,98 +107,122 @@ export function KisProfileForm() {
}
return (
<div className="rounded-2xl border border-brand-200 bg-background p-4 shadow-sm dark:border-brand-800/45 dark:bg-brand-900/14">
{/* ========== HEADER ========== */}
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold tracking-tight text-foreground">
</h2>
<p className="mt-1 text-xs text-muted-foreground">
.
<SettingsCard
icon={CreditCard}
title="한국투자증권 계좌 인증"
description="앱키 연결 후 계좌번호를 검증하면 잔고/대시보드 기능을 사용할 수 있습니다."
badge={
isKisProfileVerified ? (
<span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-full bg-green-50 px-2 py-0.5 text-[11px] font-medium text-green-700 ring-1 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/30">
<CheckCircle2 className="h-3 w-3" />
</span>
) : undefined
}
className={
!isKisVerified ? "opacity-60 grayscale pointer-events-none" : undefined
}
footer={{
actions: (
<div className="flex flex-wrap gap-2">
<Button
type="button"
onClick={handleValidateProfile}
disabled={
!isKisVerified || isValidating || !kisAccountNoInput.trim()
}
className="h-9 rounded-lg bg-brand-600 px-4 text-xs font-semibold text-white shadow-sm transition-all hover:bg-brand-700 hover:shadow disabled:opacity-50 disabled:shadow-none dark:bg-brand-600 dark:hover:bg-brand-500"
>
{isValidating ? (
<span className="flex items-center gap-1.5">
<InlineSpinner className="h-3 w-3 text-white" />
</span>
) : (
<span className="flex items-center gap-1.5">
<SearchCheck className="h-3.5 w-3.5" />
</span>
)}
</Button>
<Button
type="button"
variant="outline"
onClick={handleDisconnectAccount}
disabled={!isKisProfileVerified && !kisAccountNoInput.trim()}
className="h-9 rounded-lg border-zinc-200 bg-white px-4 text-xs text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
>
<ShieldOff className="h-3.5 w-3.5" />
</Button>
</div>
),
status: (
<div className="flex min-h-5 items-center justify-start sm:justify-end">
{errorMessage && (
<p className="animate-in fade-in slide-in-from-right-4 flex items-center gap-1.5 text-xs font-semibold text-red-500">
<XCircle className="h-3.5 w-3.5" />
{errorMessage}
</p>
)}
{statusMessage && (
<p className="animate-in fade-in slide-in-from-right-4 flex items-center gap-1.5 text-xs font-semibold text-brand-600 dark:text-brand-400">
<CheckCircle2 className="h-3.5 w-3.5" />
{statusMessage}
</p>
)}
{!statusMessage && !errorMessage && !isKisVerified && (
<p className="flex items-center gap-1.5 text-xs text-zinc-400 dark:text-zinc-600">
<span className="h-1.5 w-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
</p>
)}
{!statusMessage && !errorMessage && isKisProfileVerified && (
<p className="animate-in fade-in slide-in-from-right-4 flex items-center gap-1.5 text-xs font-medium text-zinc-500 dark:text-zinc-400">
: {maskAccountNo(verifiedAccountNo)}
</p>
)}
</div>
),
}}
>
<div className="space-y-4">
{/* ========== ACCOUNT GUIDE ========== */}
<section className="rounded-xl border border-zinc-200 bg-zinc-50/70 p-3 dark:border-zinc-800 dark:bg-zinc-900/30">
<p className="flex items-center gap-1.5 text-xs font-semibold text-zinc-700 dark:text-zinc-200">
<FileLock2 className="h-3.5 w-3.5 text-brand-500" />
</p>
</div>
<span className="inline-flex items-center rounded-full bg-muted px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
{isKisProfileVerified ? "인증 완료" : "미인증"}
</span>
</div>
<p className="mt-1 text-xs leading-relaxed text-zinc-600 dark:text-zinc-300">
8-2 . : <span className="font-medium">12345678-01</span>
</p>
</section>
{/* ========== INPUTS ========== */}
<div className="mt-4">
<div className="relative">
<CreditCard className="pointer-events-none absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
type="password"
value={kisAccountNoInput}
onChange={(event) => setKisAccountNoInput(event.target.value)}
placeholder="계좌번호 (예: 12345678-01)"
className="h-9 pl-8 text-xs"
autoComplete="off"
/>
{/* ========== ACCOUNT NO INPUT ========== */}
<div className="space-y-1.5">
<Label
htmlFor="kis-account-no"
className="text-xs font-semibold text-zinc-600"
>
</Label>
<div className="group/input flex items-center overflow-hidden rounded-xl border border-zinc-200 bg-white transition-colors focus-within:border-brand-500 focus-within:ring-1 focus-within:ring-brand-500 dark:border-zinc-700 dark:bg-zinc-900/20">
<div className="flex h-10 w-10 shrink-0 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/40">
<CreditCard className="h-4 w-4" />
</div>
<Input
id="kis-account-no"
type="password"
value={kisAccountNoInput}
onChange={(e) => setKisAccountNoInput(e.target.value)}
placeholder="계좌번호 (예: 12345678-01)"
className="h-10 border-none bg-transparent px-3 text-sm shadow-none focus-visible:ring-0"
autoComplete="off"
/>
</div>
</div>
</div>
{/* ========== ACTION ========== */}
<div className="mt-4 flex items-center justify-between gap-3 border-t border-border/70 pt-4">
<Button
type="button"
onClick={handleValidateProfile}
disabled={
!isKisVerified ||
isValidating ||
!kisAccountNoInput.trim()
}
className="h-9 rounded-lg bg-brand-600 px-4 text-xs font-semibold text-white hover:bg-brand-700"
>
{isValidating ? (
<span className="flex items-center gap-1.5">
<InlineSpinner className="h-3 w-3 text-white" />
</span>
) : (
<span className="flex items-center gap-1.5">
<SearchCheck className="h-3.5 w-3.5" />
</span>
)}
</Button>
<Button
type="button"
variant="outline"
onClick={handleDisconnectAccount}
disabled={!isKisProfileVerified && !kisAccountNoInput.trim()}
className="h-9 rounded-lg text-xs"
>
<ShieldOff className="h-3.5 w-3.5" />
</Button>
<div className="flex-1 text-right">
{errorMessage && (
<p className="flex justify-end gap-1.5 text-[11px] font-medium text-red-500">
<XCircle className="h-3.5 w-3.5" />
{errorMessage}
</p>
)}
{statusMessage && (
<p className="flex justify-end gap-1.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5" />
{statusMessage}
</p>
)}
{!statusMessage && !errorMessage && !isKisVerified && (
<p className="text-[11px] text-muted-foreground">
.
</p>
)}
{!statusMessage && !errorMessage && isKisProfileVerified && (
<p className="text-[11px] text-muted-foreground">
: {maskAccountNo(verifiedAccountNo)}
</p>
)}
</div>
</div>
</div>
</SettingsCard>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { ReactNode } from "react";
import { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface SettingsCardProps {
/** 카드 상단에 표시될 아이콘 컴포넌트 */
icon: LucideIcon;
/** 카드 제목 */
title: ReactNode;
/** 제목 옆에 표시될 배지 (선택 사항) */
badge?: ReactNode;
/** 헤더 우측에 표시될 액션 요소 (스위치, 버튼 등) */
headerAction?: ReactNode;
/** 카드 설명 텍스트 */
description?: string;
/** 카드 본문 컨텐츠 */
children: ReactNode;
/** 카드 하단 영역 (액션 버튼 및 상태 메시지 포함) */
footer?: {
/** 좌측 액션 버튼들 */
actions?: ReactNode;
/** 우측 상태 메시지 */
status?: ReactNode;
};
/** 추가 클래스 */
className?: string;
}
/**
* @description 설정 페이지에서 사용되는 통일된 카드 UI 컴포넌트입니다.
* @remarks 모든 설정 폼(인증, 프로필 등)은 이 컴포넌트를 사용하여 일관된 디자인을 유지해야 합니다.
*/
export function SettingsCard({
icon: Icon,
title,
badge,
headerAction,
description,
children,
footer,
className,
}: SettingsCardProps) {
return (
<div
className={cn(
"group relative flex h-full flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900/50 dark:hover:border-brand-800 dark:hover:shadow-brand-900/10",
className,
)}
>
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-brand-300/70 to-transparent dark:via-brand-600/60" />
<div className="flex flex-1 flex-col p-5 sm:p-6">
{/* ========== CARD HEADER ========== */}
<div className="mb-5 flex flex-col gap-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-600 ring-1 ring-brand-100 dark:bg-brand-900/20 dark:text-brand-400 dark:ring-brand-800/50">
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate text-base font-bold tracking-tight text-zinc-900 dark:text-zinc-100">
{title}
</h2>
{badge && <div className="shrink-0">{badge}</div>}
</div>
{description && (
<p className="mt-1 text-[13px] font-medium leading-normal text-zinc-500 dark:text-zinc-400">
{description}
</p>
)}
</div>
</div>
{headerAction && (
<div className="sm:shrink-0 sm:pl-2">{headerAction}</div>
)}
</div>
</div>
{/* ========== CARD BODY ========== */}
<div className="flex-1">{children}</div>
</div>
{/* ========== CARD FOOTER ========== */}
{footer && (
<div className="border-t border-zinc-100 bg-zinc-50/50 px-5 py-3 dark:border-zinc-800/50 dark:bg-zinc-900/30 sm:px-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-2">{footer.actions}</div>
<div className="text-left sm:text-right">{footer.status}</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,10 +1,11 @@
"use client";
import { Info } from "lucide-react";
import { type LucideIcon, Info, Link2, Wallet } from "lucide-react";
import { useShallow } from "zustand/react/shallow";
import { KisAuthForm } from "@/features/settings/components/KisAuthForm";
import { KisProfileForm } from "@/features/settings/components/KisProfileForm";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import { cn } from "@/lib/utils";
/**
* @description 설정 페이지 컨테이너입니다. KIS 연결 상태와 인증 폼을 카드 UI로 제공합니다.
@@ -28,54 +29,59 @@ export function SettingsContainer() {
);
return (
<section className="mx-auto flex w-full max-w-5xl flex-col gap-5 p-4 md:p-6">
{/* ========== STATUS CARD ========== */}
<article className="rounded-2xl border border-brand-200 bg-muted/35 p-4 dark:border-brand-800/45 dark:bg-brand-900/20">
<h1 className="text-xl font-semibold tracking-tight text-foreground">
</h1>
<div className="mt-3 flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium text-foreground"> :</span>
{isKisVerified ? (
<span className="inline-flex items-center rounded-full bg-brand-100 px-2.5 py-1 text-xs font-semibold text-brand-700 dark:bg-brand-900/45 dark:text-brand-200">
<span className="mr-1.5 h-2 w-2 rounded-full bg-brand-500" />
({verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})
</span>
) : (
<span className="inline-flex items-center rounded-full bg-brand-50 px-2.5 py-1 text-xs font-semibold text-brand-700 dark:bg-brand-900/30 dark:text-brand-200">
<span className="mr-1.5 h-2 w-2 rounded-full bg-brand-300 dark:bg-brand-500/70" />
</span>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium text-foreground"> :</span>
{isKisProfileVerified ? (
<span className="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-900/35 dark:text-emerald-200">
({maskAccountNo(verifiedAccountNo)})
</span>
) : (
<span className="inline-flex items-center rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-semibold text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
</span>
)}
</div>
</article>
<section className="mx-auto flex w-full max-w-[1400px] flex-col gap-6 px-4 py-4 md:px-8 md:py-8">
{/* ========== SETTINGS OVERVIEW ========== */}
<article className="rounded-2xl border border-brand-200 bg-gradient-to-br from-brand-50/80 via-background to-background p-5 dark:border-brand-800/45 dark:from-brand-900/30 dark:via-brand-950/10 dark:to-background md:p-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]">
<div className="space-y-3">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
</h1>
<p className="text-sm leading-relaxed text-muted-foreground">
.
/ .
</p>
<div className="rounded-xl border border-brand-200/70 bg-brand-50/70 p-3 dark:border-brand-800/60 dark:bg-brand-900/20">
<p className="text-xs font-semibold text-brand-700 dark:text-brand-200">
순서: 1) {"->"} 2) {"->"} 3)
</p>
</div>
</div>
{/* ========== PRIVACY NOTICE ========== */}
<article className="rounded-2xl border border-amber-300 bg-amber-50/70 p-4 text-sm text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/30 dark:text-amber-200">
<p className="flex items-start gap-2 font-medium">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
</p>
<p className="mt-2 text-xs leading-relaxed text-amber-800/90 dark:text-amber-200/90">
, 릿, DB에 .
() , .
</p>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<StatusTile
icon={Link2}
title="앱키 연결"
value={
isKisVerified
? `연결됨 (${verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})`
: "미연결"
}
tone={isKisVerified ? "success" : "idle"}
/>
<StatusTile
icon={Wallet}
title="계좌 인증"
value={
isKisProfileVerified
? `확인 완료 (${maskAccountNo(verifiedAccountNo)})`
: "미확인"
}
tone={isKisProfileVerified ? "success" : "idle"}
/>
<StatusTile
icon={Info}
title="입력 정보 보관"
value="서버 DB 저장 없음 · 현재 브라우저에서만 관리"
tone="notice"
/>
</div>
</div>
</article>
{/* ========== FORM GRID ========== */}
<div className="grid gap-4 lg:grid-cols-2">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)]">
<KisAuthForm />
<KisProfileForm />
</div>
@@ -96,3 +102,40 @@ function maskAccountNo(value: string | null) {
return "********-**";
}
type StatusTileTone = "success" | "idle" | "notice";
/**
* @description 설정 페이지 상단 요약 상태 타일입니다.
* @see features/settings/components/SettingsContainer.tsx 상태 요약 렌더링
*/
function StatusTile({
icon: Icon,
title,
value,
tone,
}: {
icon: LucideIcon;
title: string;
value: string;
tone: StatusTileTone;
}) {
return (
<div
className={cn(
"rounded-xl border px-3 py-2.5",
tone === "success" &&
"border-emerald-200 bg-emerald-50/70 dark:border-emerald-800/45 dark:bg-emerald-900/15",
tone === "idle" &&
"border-zinc-200 bg-zinc-50/70 dark:border-zinc-800 dark:bg-zinc-900/30",
tone === "notice" &&
"border-amber-300 bg-amber-50/70 dark:border-amber-800/45 dark:bg-amber-900/20",
)}
>
<p className="flex items-center gap-1.5 text-[12px] font-semibold text-zinc-700 dark:text-zinc-200">
<Icon className="h-3.5 w-3.5" />
{title}
</p>
<p className="mt-1 text-xs text-zinc-600 dark:text-zinc-300">{value}</p>
</div>
);
}