252 lines
9.9 KiB
TypeScript
252 lines
9.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useTransition } from "react";
|
|
import { useShallow } from "zustand/react/shallow";
|
|
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 한국투자증권 계좌번호 검증 폼입니다.
|
|
* @remarks UI 흐름: /settings -> 계좌번호 입력 -> 계좌 확인 버튼 -> validate-profile API -> store 반영 -> 대시보드 반영
|
|
* @see app/api/kis/validate-profile/route.ts 계좌번호 검증 서버 라우트
|
|
* @see features/settings/store/use-kis-runtime-store.ts 검증 성공값을 전역 상태에 저장합니다.
|
|
*/
|
|
export function KisProfileForm() {
|
|
const {
|
|
kisAccountNoInput,
|
|
verifiedCredentials,
|
|
isKisVerified,
|
|
isKisProfileVerified,
|
|
verifiedAccountNo,
|
|
setKisAccountNoInput,
|
|
setVerifiedKisProfile,
|
|
invalidateKisProfileVerification,
|
|
} = useKisRuntimeStore(
|
|
useShallow((state) => ({
|
|
kisAccountNoInput: state.kisAccountNoInput,
|
|
verifiedCredentials: state.verifiedCredentials,
|
|
isKisVerified: state.isKisVerified,
|
|
isKisProfileVerified: state.isKisProfileVerified,
|
|
verifiedAccountNo: state.verifiedAccountNo,
|
|
setKisAccountNoInput: state.setKisAccountNoInput,
|
|
setVerifiedKisProfile: state.setVerifiedKisProfile,
|
|
invalidateKisProfileVerification: state.invalidateKisProfileVerification,
|
|
})),
|
|
);
|
|
|
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const [isValidating, startValidateTransition] = useTransition();
|
|
|
|
/**
|
|
* @description 계좌번호 인증을 해제하고 입력값을 비웁니다.
|
|
* @see features/settings/store/use-kis-runtime-store.ts setKisAccountNoInput
|
|
* @see features/settings/store/use-kis-runtime-store.ts invalidateKisProfileVerification
|
|
*/
|
|
function handleDisconnectAccount() {
|
|
setStatusMessage("계좌 인증을 해제했습니다.");
|
|
setErrorMessage(null);
|
|
setKisAccountNoInput("");
|
|
invalidateKisProfileVerification();
|
|
}
|
|
|
|
function handleValidateProfile() {
|
|
startValidateTransition(async () => {
|
|
try {
|
|
setStatusMessage(null);
|
|
setErrorMessage(null);
|
|
|
|
if (!verifiedCredentials || !isKisVerified) {
|
|
throw new Error("먼저 앱키 연결을 완료해 주세요.");
|
|
}
|
|
|
|
const accountNo = kisAccountNoInput.trim();
|
|
|
|
if (!accountNo) {
|
|
throw new Error("계좌번호를 입력해 주세요.");
|
|
}
|
|
|
|
if (!isValidAccountNo(accountNo)) {
|
|
throw new Error(
|
|
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
|
|
);
|
|
}
|
|
|
|
const result = await validateKisProfile({
|
|
...verifiedCredentials,
|
|
accountNo,
|
|
});
|
|
|
|
setVerifiedKisProfile({
|
|
accountNo: result.account.normalizedAccountNo,
|
|
});
|
|
|
|
setStatusMessage(result.message);
|
|
} catch (error) {
|
|
invalidateKisProfileVerification();
|
|
setErrorMessage(
|
|
error instanceof Error
|
|
? error.message
|
|
: "계좌 확인 중 오류가 발생했습니다.",
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
return (
|
|
<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>
|
|
<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>
|
|
|
|
{/* ========== 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>
|
|
</SettingsCard>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @description KIS 계좌번호(8-2) 입력 포맷을 검증합니다.
|
|
* @param value 사용자 입력 계좌번호
|
|
* @returns 형식 유효 여부
|
|
* @see features/settings/components/KisProfileForm.tsx handleValidateProfile
|
|
*/
|
|
function isValidAccountNo(value: string) {
|
|
const digits = value.replace(/\D/g, "");
|
|
return digits.length === 10;
|
|
}
|
|
|
|
/**
|
|
* @description 표시용 계좌번호를 마스킹 처리합니다.
|
|
* @param value 계좌번호(8-2)
|
|
* @returns 마스킹 계좌번호
|
|
* @see features/settings/components/KisProfileForm.tsx 확인된 값 표시
|
|
*/
|
|
function maskAccountNo(value: string | null) {
|
|
if (!value) return "-";
|
|
const digits = value.replace(/\D/g, "");
|
|
if (digits.length !== 10) return "********";
|
|
return "********-**";
|
|
}
|