ERP-node/frontend/components/dashboard/widgets/ExchangeWidget.tsx

279 lines
9.8 KiB
TypeScript

'use client';
/**
* 환율 위젯 컴포넌트
* - 실시간 환율 정보를 표시
* - 한국은행(BOK) API 연동
*/
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';
interface ExchangeWidgetProps {
element?: DashboardElement;
baseCurrency?: string;
targetCurrency?: string;
refreshInterval?: number; // 새로고침 간격 (ms), 기본값: 600000 (10분)
}
export default function ExchangeWidget({
element,
baseCurrency = 'KRW',
targetCurrency = 'USD',
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);
const [calculatorAmount, setCalculatorAmount] = useState<string>('');
const [displayAmount, setDisplayAmount] = useState<string>('');
// 지원 통화 목록
const currencies = [
{ 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: '£' },
];
// 환율 조회
const fetchExchangeRate = async () => {
try {
setError(null);
setLoading(true);
const data = await getExchangeRate(base, target);
setExchangeRate(data);
setLastUpdated(new Date());
} catch (err: any) {
console.error('환율 조회 실패:', err);
let errorMessage = '환율 정보를 가져오는 중 오류가 발생했습니다.';
if (err.response?.status === 503) {
errorMessage = 'API 키가 설정되지 않았습니다. 관리자에게 문의하세요.';
} else if (err.response?.status === 401) {
errorMessage = 'API 키가 유효하지 않습니다.';
} else if (err.response?.data?.message) {
errorMessage = err.response.data.message;
}
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;
};
// 계산기 금액 입력 처리
const handleCalculatorInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// 쉼표 제거 후 숫자만 추출
const cleanValue = value.replace(/,/g, '').replace(/[^\d]/g, '');
// 계산용 원본 값 저장
setCalculatorAmount(cleanValue);
// 표시용 포맷팅된 값 저장
if (cleanValue === '') {
setDisplayAmount('');
} else {
const num = parseInt(cleanValue);
setDisplayAmount(num.toLocaleString('ko-KR'));
}
};
// 계산 결과
const calculateResult = () => {
const amount = parseFloat(calculatorAmount || '0');
if (!exchangeRate || isNaN(amount)) return 0;
return amount * (base === 'KRW' ? exchangeRate.rate : 1 / exchangeRate.rate);
};
// 로딩 상태
if (loading && !exchangeRate) {
return (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-6">
<div className="flex flex-col items-center gap-2">
<RefreshCw className="h-8 w-8 animate-spin text-green-500" />
<p className="text-sm text-gray-600"> ...</p>
</div>
</div>
);
}
// 에러 상태 - 하지만 계산기는 표시
const hasError = error || !exchangeRate;
return (
<div className="h-full bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-4 @container">
{/* 헤더 */}
<div className="flex items-center justify-between mb-3">
<div className="flex-1">
<h3 className="text-base font-semibold text-gray-900 mb-1">{element?.customTitle || "환율"}</h3>
<p className="text-xs text-gray-500">
{lastUpdated
? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
})}`
: ''}
</p>
</div>
<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' : ''}`} />
</Button>
</div>
{/* 통화 선택 - 반응형 (좁을 때 세로 배치) */}
<div className="flex @[300px]:flex-row flex-col items-center gap-2 mb-3">
<Select value={base} onValueChange={setBase}>
<SelectTrigger className="w-full @[300px]:flex-1 bg-white h-8 text-xs">
<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}
className="h-8 w-8 p-0 rounded-full hover:bg-white @[300px]:rotate-0 rotate-90"
>
<ArrowRightLeft className="h-3 w-3" />
</Button>
<Select value={target} onValueChange={setTarget}>
<SelectTrigger className="w-full @[300px]:flex-1 bg-white h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{currencies.map((currency) => (
<SelectItem key={currency.value} value={currency.value}>
{currency.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 에러 메시지 */}
{hasError && (
<div className="mb-3 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-xs text-red-600 text-center">{error || '환율 정보를 불러올 수 없습니다.'}</p>
<button
onClick={fetchExchangeRate}
className="mt-2 w-full text-xs text-red-600 hover:text-red-700 underline"
>
</button>
</div>
)}
{/* 환율 표시 */}
{!hasError && (
<div className="mb-2 bg-white rounded-lg border p-2">
<div className="text-center">
<div className="text-xs text-gray-400 mb-0.5">
{exchangeRate.base === 'KRW' ? '1,000' : '1'} {getCurrencySymbol(exchangeRate.base)} =
</div>
<div className="text-lg font-bold text-gray-900">
{exchangeRate.base === 'KRW'
? (exchangeRate.rate * 1000).toLocaleString('ko-KR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: exchangeRate.rate.toLocaleString('ko-KR', {
minimumFractionDigits: 2,
maximumFractionDigits: 4,
})}
</div>
<div className="text-xs text-gray-400 mt-0.5">{getCurrencySymbol(exchangeRate.target)}</div>
</div>
</div>
)}
{/* 계산기 입력 */}
<div className="bg-white rounded-lg border p-2">
<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-xs text-gray-400 w-12">{base}</span>
</div>
<div className="flex items-center justify-center gap-2">
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-gray-300 to-transparent" />
<span className="text-xs text-gray-400"></span>
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-gray-300 to-transparent" />
</div>
<div className="flex items-center gap-2">
<div className="flex-1 text-center text-lg font-bold text-green-600 bg-green-50 border border-green-200 rounded px-2 py-1.5">
{calculateResult().toLocaleString('ko-KR', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})}
</div>
<span className="text-xs text-gray-400 w-12">{target}</span>
</div>
</div>
</div>
{/* 데이터 출처 */}
<div className="mt-3 pt-2 border-t text-center">
<p className="text-xs text-gray-400">: {exchangeRate.source}</p>
</div>
</div>
);
}