Files
auto-trade/features/settings/components/KisProfileForm.tsx

252 lines
9.9 KiB
TypeScript
Raw Normal View History

"use client";
import { useState, useTransition } from "react";
import { useShallow } from "zustand/react/shallow";
2026-02-13 12:17:35 +09:00
import {
CreditCard,
CheckCircle2,
SearchCheck,
ShieldOff,
XCircle,
FileLock2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
2026-02-13 12:17:35 +09:00
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";
2026-02-13 12:17:35 +09:00
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 (
2026-02-13 12:17:35 +09:00
<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>
2026-02-13 12:17:35 +09:00
<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>
2026-02-13 12:17:35 +09:00
</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 "********-**";
}