2026-03-10 18:30:18 +09:00
|
|
|
"use client";
|
2025-10-13 19:04:28 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 환율 위젯 컴포넌트
|
|
|
|
|
* - 실시간 환율 정보를 표시
|
|
|
|
|
* - 한국은행(BOK) API 연동
|
|
|
|
|
*/
|
|
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
import React, { useEffect, useState } from "react";
|
|
|
|
|
import { getExchangeRate, ExchangeRateData } from "@/lib/api/openApi";
|
|
|
|
|
import { TrendingUp, TrendingDown, RefreshCw, ArrowRightLeft } from "lucide-react";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { DashboardElement } from "@/components/admin/dashboard/types";
|
2025-10-13 19:04:28 +09:00
|
|
|
|
|
|
|
|
interface ExchangeWidgetProps {
|
2025-10-15 18:25:16 +09:00
|
|
|
element?: DashboardElement;
|
2025-10-13 19:04:28 +09:00
|
|
|
baseCurrency?: string;
|
|
|
|
|
targetCurrency?: string;
|
|
|
|
|
refreshInterval?: number; // 새로고침 간격 (ms), 기본값: 600000 (10분)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function ExchangeWidget({
|
2025-10-15 18:25:16 +09:00
|
|
|
element,
|
2026-03-10 18:30:18 +09:00
|
|
|
baseCurrency = "KRW",
|
|
|
|
|
targetCurrency = "USD",
|
2025-10-13 19:04:28 +09:00
|
|
|
refreshInterval = 600000,
|
|
|
|
|
}: ExchangeWidgetProps) {
|
|
|
|
|
const [base, setBase] = useState(baseCurrency);
|
|
|
|
|
const [target, setTarget] = useState(targetCurrency);
|
|
|
|
|
const [exchangeRate, setExchangeRate] = useState<ExchangeRateData | null>(null);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
2026-03-10 18:30:18 +09:00
|
|
|
const [calculatorAmount, setCalculatorAmount] = useState<string>("");
|
|
|
|
|
const [displayAmount, setDisplayAmount] = useState<string>("");
|
2025-10-13 19:04:28 +09:00
|
|
|
|
|
|
|
|
// 지원 통화 목록
|
|
|
|
|
const currencies = [
|
2026-03-10 18:30:18 +09:00
|
|
|
{ value: "KRW", label: "🇰🇷 KRW (원)", symbol: "₩" },
|
|
|
|
|
{ value: "USD", label: "🇺🇸 USD (달러)", symbol: "$" },
|
|
|
|
|
{ value: "EUR", label: "🇪🇺 EUR (유로)", symbol: "€" },
|
|
|
|
|
{ value: "JPY", label: "🇯🇵 JPY (엔)", symbol: "¥" },
|
|
|
|
|
{ value: "CNY", label: "🇨🇳 CNY (위안)", symbol: "¥" },
|
|
|
|
|
{ value: "GBP", label: "🇬🇧 GBP (파운드)", symbol: "£" },
|
2025-10-13 19:04:28 +09:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// 환율 조회
|
|
|
|
|
const fetchExchangeRate = async () => {
|
|
|
|
|
try {
|
|
|
|
|
setError(null);
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
|
|
|
|
const data = await getExchangeRate(base, target);
|
|
|
|
|
setExchangeRate(data);
|
|
|
|
|
setLastUpdated(new Date());
|
|
|
|
|
} catch (err: any) {
|
2026-03-10 18:30:18 +09:00
|
|
|
console.error("환율 조회 실패:", err);
|
|
|
|
|
|
|
|
|
|
let errorMessage = "환율 정보를 가져오는 중 오류가 발생했습니다.";
|
|
|
|
|
|
2025-10-13 19:04:28 +09:00
|
|
|
if (err.response?.status === 503) {
|
2026-03-10 18:30:18 +09:00
|
|
|
errorMessage = "API 키가 설정되지 않았습니다. 관리자에게 문의하세요.";
|
2025-10-13 19:04:28 +09:00
|
|
|
} else if (err.response?.status === 401) {
|
2026-03-10 18:30:18 +09:00
|
|
|
errorMessage = "API 키가 유효하지 않습니다.";
|
2025-10-13 19:04:28 +09:00
|
|
|
} else if (err.response?.data?.message) {
|
|
|
|
|
errorMessage = err.response.data.message;
|
|
|
|
|
}
|
2026-03-10 18:30:18 +09:00
|
|
|
|
2025-10-13 19:04:28 +09:00
|
|
|
setError(errorMessage);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 초기 로딩 및 자동 새로고침
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchExchangeRate();
|
|
|
|
|
const interval = setInterval(fetchExchangeRate, refreshInterval);
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
}, [base, target, refreshInterval]);
|
|
|
|
|
|
|
|
|
|
// 통화 스왑
|
|
|
|
|
const handleSwap = () => {
|
|
|
|
|
setBase(target);
|
|
|
|
|
setTarget(base);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 통화 기호 가져오기
|
|
|
|
|
const getCurrencySymbol = (currency: string) => {
|
|
|
|
|
return currencies.find((c) => c.value === currency)?.symbol || currency;
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-14 10:05:40 +09:00
|
|
|
// 계산기 금액 입력 처리
|
|
|
|
|
const handleCalculatorInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
const value = e.target.value;
|
2026-03-10 18:30:18 +09:00
|
|
|
|
2025-10-14 10:05:40 +09:00
|
|
|
// 쉼표 제거 후 숫자만 추출
|
2026-03-10 18:30:18 +09:00
|
|
|
const cleanValue = value.replace(/,/g, "").replace(/[^\d]/g, "");
|
|
|
|
|
|
2025-10-14 10:05:40 +09:00
|
|
|
// 계산용 원본 값 저장
|
|
|
|
|
setCalculatorAmount(cleanValue);
|
2026-03-10 18:30:18 +09:00
|
|
|
|
2025-10-14 10:05:40 +09:00
|
|
|
// 표시용 포맷팅된 값 저장
|
2026-03-10 18:30:18 +09:00
|
|
|
if (cleanValue === "") {
|
|
|
|
|
setDisplayAmount("");
|
2025-10-14 10:05:40 +09:00
|
|
|
} else {
|
|
|
|
|
const num = parseInt(cleanValue);
|
2026-03-10 18:30:18 +09:00
|
|
|
setDisplayAmount(num.toLocaleString("ko-KR"));
|
2025-10-14 10:05:40 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 계산 결과
|
|
|
|
|
const calculateResult = () => {
|
2026-03-10 18:30:18 +09:00
|
|
|
const amount = parseFloat(calculatorAmount || "0");
|
2025-10-14 10:05:40 +09:00
|
|
|
if (!exchangeRate || isNaN(amount)) return 0;
|
2026-03-10 18:30:18 +09:00
|
|
|
|
|
|
|
|
return amount * (base === "KRW" ? exchangeRate.rate : 1 / exchangeRate.rate);
|
2025-10-14 10:05:40 +09:00
|
|
|
};
|
|
|
|
|
|
2025-10-13 19:04:28 +09:00
|
|
|
// 로딩 상태
|
|
|
|
|
if (loading && !exchangeRate) {
|
|
|
|
|
return (
|
2026-03-10 18:30:18 +09:00
|
|
|
<div className="bg-background flex h-full items-center justify-center rounded-lg border p-6">
|
2025-10-13 19:04:28 +09:00
|
|
|
<div className="flex flex-col items-center gap-2">
|
2026-03-10 18:30:18 +09:00
|
|
|
<RefreshCw className="text-success h-8 w-8 animate-spin" />
|
|
|
|
|
<p className="text-foreground text-sm">환율 정보 불러오는 중...</p>
|
2025-10-13 19:04:28 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-14 10:05:40 +09:00
|
|
|
// 에러 상태 - 하지만 계산기는 표시
|
|
|
|
|
const hasError = error || !exchangeRate;
|
2025-10-13 19:04:28 +09:00
|
|
|
|
|
|
|
|
return (
|
2026-03-10 18:30:18 +09:00
|
|
|
<div className="bg-background @container h-full rounded-lg border p-4">
|
2025-10-13 19:04:28 +09:00
|
|
|
{/* 헤더 */}
|
2026-03-10 18:30:18 +09:00
|
|
|
<div className="mb-3 flex items-center justify-between">
|
2025-10-13 19:04:28 +09:00
|
|
|
<div className="flex-1">
|
2026-03-10 18:30:18 +09:00
|
|
|
<h3 className="text-foreground mb-1 text-base font-semibold">{element?.customTitle || "환율"}</h3>
|
|
|
|
|
<p className="text-muted-foreground text-xs">
|
2025-10-13 19:04:28 +09:00
|
|
|
{lastUpdated
|
2026-03-10 18:30:18 +09:00
|
|
|
? `업데이트: ${lastUpdated.toLocaleTimeString("ko-KR", {
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
minute: "2-digit",
|
2025-10-13 19:04:28 +09:00
|
|
|
})}`
|
2026-03-10 18:30:18 +09:00
|
|
|
: ""}
|
2025-10-13 19:04:28 +09:00
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-03-10 18:30:18 +09:00
|
|
|
<Button variant="ghost" size="sm" onClick={fetchExchangeRate} disabled={loading} className="h-8 w-8 p-0">
|
|
|
|
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
2025-10-13 19:04:28 +09:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-10-16 16:34:59 +09:00
|
|
|
{/* 통화 선택 - 반응형 (좁을 때 세로 배치) */}
|
2026-03-10 18:30:18 +09:00
|
|
|
<div className="mb-3 flex flex-col items-center gap-2 @[300px]:flex-row">
|
2025-10-13 19:04:28 +09:00
|
|
|
<Select value={base} onValueChange={setBase}>
|
2026-03-10 18:30:18 +09:00
|
|
|
<SelectTrigger className="bg-background h-8 w-full text-xs @[300px]:flex-1">
|
2025-10-13 19:04:28 +09:00
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{currencies.map((currency) => (
|
|
|
|
|
<SelectItem key={currency.value} value={currency.value}>
|
|
|
|
|
{currency.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleSwap}
|
2026-03-10 18:30:18 +09:00
|
|
|
className="hover:bg-background h-8 w-8 rotate-90 rounded-full p-0 @[300px]:rotate-0"
|
2025-10-13 19:04:28 +09:00
|
|
|
>
|
2025-10-14 10:05:40 +09:00
|
|
|
<ArrowRightLeft className="h-3 w-3" />
|
2025-10-13 19:04:28 +09:00
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<Select value={target} onValueChange={setTarget}>
|
2026-03-10 18:30:18 +09:00
|
|
|
<SelectTrigger className="bg-background h-8 w-full text-xs @[300px]:flex-1">
|
2025-10-13 19:04:28 +09:00
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{currencies.map((currency) => (
|
|
|
|
|
<SelectItem key={currency.value} value={currency.value}>
|
|
|
|
|
{currency.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-10-14 10:05:40 +09:00
|
|
|
{/* 에러 메시지 */}
|
|
|
|
|
{hasError && (
|
2026-03-10 18:30:18 +09:00
|
|
|
<div className="bg-destructive/10 border-destructive mb-3 rounded-lg border p-3">
|
|
|
|
|
<p className="text-destructive text-center text-xs">{error || "환율 정보를 불러올 수 없습니다."}</p>
|
2025-10-14 10:05:40 +09:00
|
|
|
<button
|
|
|
|
|
onClick={fetchExchangeRate}
|
2026-03-10 18:30:18 +09:00
|
|
|
className="text-destructive hover:text-destructive mt-2 w-full text-xs underline"
|
2025-10-14 10:05:40 +09:00
|
|
|
>
|
|
|
|
|
다시 시도
|
|
|
|
|
</button>
|
2025-10-13 19:04:28 +09:00
|
|
|
</div>
|
2025-10-14 10:05:40 +09:00
|
|
|
)}
|
2025-10-13 19:04:28 +09:00
|
|
|
|
2025-10-14 10:05:40 +09:00
|
|
|
{/* 환율 표시 */}
|
|
|
|
|
{!hasError && (
|
2026-03-10 18:30:18 +09:00
|
|
|
<div className="bg-background mb-2 rounded-lg border p-2">
|
2025-10-14 10:05:40 +09:00
|
|
|
<div className="text-center">
|
2026-03-10 18:30:18 +09:00
|
|
|
<div className="text-muted-foreground mb-0.5 text-xs">
|
|
|
|
|
{exchangeRate.base === "KRW" ? "1,000" : "1"} {getCurrencySymbol(exchangeRate.base)} =
|
2025-10-14 10:05:40 +09:00
|
|
|
</div>
|
2026-03-10 18:30:18 +09:00
|
|
|
<div className="text-foreground text-lg font-bold">
|
|
|
|
|
{exchangeRate.base === "KRW"
|
|
|
|
|
? (exchangeRate.rate * 1000).toLocaleString("ko-KR", {
|
2025-10-14 10:05:40 +09:00
|
|
|
minimumFractionDigits: 2,
|
|
|
|
|
maximumFractionDigits: 2,
|
|
|
|
|
})
|
2026-03-10 18:30:18 +09:00
|
|
|
: exchangeRate.rate.toLocaleString("ko-KR", {
|
2025-10-14 10:05:40 +09:00
|
|
|
minimumFractionDigits: 2,
|
|
|
|
|
maximumFractionDigits: 4,
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
2026-03-10 18:30:18 +09:00
|
|
|
<div className="text-muted-foreground mt-0.5 text-xs">{getCurrencySymbol(exchangeRate.target)}</div>
|
2025-10-13 19:04:28 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-10-14 10:05:40 +09:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 계산기 입력 */}
|
2025-10-29 17:53:03 +09:00
|
|
|
<div className="bg-background rounded-lg border p-2">
|
2026-03-10 18:30:18 +09:00
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Input
|
|
|
|
|
type="text"
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
value={displayAmount || ""}
|
|
|
|
|
onChange={handleCalculatorInput}
|
|
|
|
|
placeholder="금액 직접 입력"
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
className="flex-1 text-center text-sm font-semibold"
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-muted-foreground w-12 text-xs">{base}</span>
|
|
|
|
|
</div>
|
2025-10-13 19:04:28 +09:00
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
<div className="flex items-center justify-center gap-2">
|
|
|
|
|
<div className="via-border h-px flex-1 bg-gradient-to-r from-transparent to-transparent" />
|
|
|
|
|
<span className="text-muted-foreground text-xs">▼</span>
|
|
|
|
|
<div className="via-border h-px flex-1 bg-gradient-to-r from-transparent to-transparent" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="text-success bg-success/10 border-success flex-1 rounded border px-2 py-1.5 text-center text-lg font-bold">
|
|
|
|
|
{calculateResult().toLocaleString("ko-KR", {
|
|
|
|
|
minimumFractionDigits: 0,
|
|
|
|
|
maximumFractionDigits: 2,
|
|
|
|
|
})}
|
2025-10-14 10:05:40 +09:00
|
|
|
</div>
|
2026-03-10 18:30:18 +09:00
|
|
|
<span className="text-muted-foreground w-12 text-xs">{target}</span>
|
2025-10-13 19:04:28 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-10 18:30:18 +09:00
|
|
|
</div>
|
2025-10-13 19:04:28 +09:00
|
|
|
|
|
|
|
|
{/* 데이터 출처 */}
|
2026-03-10 18:30:18 +09:00
|
|
|
<div className="mt-3 border-t pt-2 text-center">
|
|
|
|
|
<p className="text-muted-foreground text-xs">출처: {exchangeRate.source}</p>
|
2025-10-13 19:04:28 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|