mirror of
https://github.com/tiennm99/exchange-rate-export.git
synced 2026-10-11 03:13:21 +00:00
feat: add multi-currency support with currency selector
API now accepts currency param (USD, EUR, JPY, ALL, etc.). Returns array of rates per date instead of single object. Frontend adds currency dropdown with 12 common currencies plus "All".
This commit is contained in:
1 parent
3418461059
commit
5c683f4c47
2 files changed
+93
-54
No files matched your search
@@ -12,7 +12,7 @@ const axiosInstance = axios.create({
|
||||
const DISPLAY_DATE_FORMAT = "yyyy-MM-dd";
|
||||
const PARALLEL_CHUNK_SIZE = 5;
|
||||
|
||||
async function fetchBIDVRates(dateObj) {
|
||||
async function fetchBIDVRates(dateObj, currency) {
|
||||
const dateStr = format(dateObj, "dd/MM/yyyy");
|
||||
try {
|
||||
const timeUrl = `https://bidv.com.vn/ServicesBIDV/ExchangeDetailSearchTimeServlet?date=${dateStr}`;
|
||||
@@ -30,24 +30,28 @@ async function fetchBIDVRates(dateObj) {
|
||||
const rateData = rateRes.data;
|
||||
if (rateData.status !== 1 || !rateData.data) return null;
|
||||
|
||||
const usd = rateData.data.find((item) => item.currency === "USD");
|
||||
if (!usd) return null;
|
||||
return {
|
||||
date: format(dateObj, DISPLAY_DATE_FORMAT),
|
||||
nameVI: usd.nameVI || "",
|
||||
muaTm: usd.muaTm || "",
|
||||
muaCk: usd.muaCk || "",
|
||||
currency: usd.currency || "USD",
|
||||
nameEN: usd.nameEN || "",
|
||||
ban: usd.ban || "",
|
||||
};
|
||||
const displayDate = format(dateObj, DISPLAY_DATE_FORMAT);
|
||||
const items = currency === "ALL"
|
||||
? rateData.data
|
||||
: rateData.data.filter((item) => item.currency === currency);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return items.map((item) => ({
|
||||
date: displayDate,
|
||||
nameVI: item.nameVI || "",
|
||||
muaTm: item.muaTm || "",
|
||||
muaCk: item.muaCk || "",
|
||||
currency: item.currency || "",
|
||||
nameEN: item.nameEN || "",
|
||||
ban: item.ban || "",
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error fetching BIDV rates:", error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTCBRates(dateObj) {
|
||||
async function fetchTCBRates(dateObj, currency) {
|
||||
const dateStr = format(dateObj, "yyyy-MM-dd");
|
||||
const url = `https://techcombank.com/content/techcombank/web/vn/vi/cong-cu-tien-ich/ty-gia/_jcr_content.exchange-rates.${dateStr}.integration.json`;
|
||||
try {
|
||||
@@ -55,28 +59,29 @@ async function fetchTCBRates(dateObj) {
|
||||
const data = res.data;
|
||||
if (!data.exchangeRate?.data) return null;
|
||||
|
||||
const usd = data.exchangeRate.data.find(
|
||||
(item) => item.label === "USD (50,100)"
|
||||
);
|
||||
if (!usd) return null;
|
||||
return {
|
||||
const items = currency === "ALL"
|
||||
? data.exchangeRate.data
|
||||
: data.exchangeRate.data.filter((item) => item.sourceCurrency === currency);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return items.map((item) => ({
|
||||
date: dateStr,
|
||||
label: usd.label || "",
|
||||
askRate: usd.askRate || "",
|
||||
bidRateCK: usd.bidRateCK || "",
|
||||
bidRateTM: usd.bidRateTM || "",
|
||||
sourceCurrency: usd.sourceCurrency || "",
|
||||
targetCurrency: usd.targetCurrency || "",
|
||||
askRateTM: usd.askRateTM || "",
|
||||
};
|
||||
label: item.label || "",
|
||||
askRate: item.askRate || "",
|
||||
bidRateCK: item.bidRateCK || "",
|
||||
bidRateTM: item.bidRateTM || "",
|
||||
sourceCurrency: item.sourceCurrency || "",
|
||||
targetCurrency: item.targetCurrency || "",
|
||||
askRateTM: item.askRateTM || "",
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error fetching TCB rates:", error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchRateForDate(dateObj, bank) {
|
||||
return bank === "bidv" ? fetchBIDVRates(dateObj) : fetchTCBRates(dateObj);
|
||||
function fetchRateForDate(dateObj, bank, currency) {
|
||||
return bank === "bidv" ? fetchBIDVRates(dateObj, currency) : fetchTCBRates(dateObj, currency);
|
||||
}
|
||||
|
||||
function getDateRange(start, end) {
|
||||
@@ -91,7 +96,7 @@ function getDateRange(start, end) {
|
||||
}
|
||||
|
||||
// Search backwards for the most recent available rate
|
||||
async function getPreviousDayRate(date, bank, rateCache, maxAttempts = 30) {
|
||||
async function getPreviousDayRate(date, bank, currency, rateCache, maxAttempts = 30) {
|
||||
let currentDate = new Date(date);
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
currentDate.setDate(currentDate.getDate() - 1);
|
||||
@@ -101,19 +106,19 @@ async function getPreviousDayRate(date, bank, rateCache, maxAttempts = 30) {
|
||||
return rateCache.get(dateStr);
|
||||
}
|
||||
|
||||
const rate = await fetchRateForDate(currentDate, bank);
|
||||
if (rate) {
|
||||
rateCache.set(dateStr, rate);
|
||||
return rate;
|
||||
const rates = await fetchRateForDate(currentDate, bank, currency);
|
||||
if (rates) {
|
||||
rateCache.set(dateStr, rates);
|
||||
return rates;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch a chunk of dates in parallel, filling gaps with previous day data
|
||||
async function fetchChunk(dates, bank, rateCache) {
|
||||
async function fetchChunk(dates, bank, currency, rateCache) {
|
||||
const settled = await Promise.allSettled(
|
||||
dates.map((date) => fetchRateForDate(date, bank))
|
||||
dates.map((date) => fetchRateForDate(date, bank, currency))
|
||||
);
|
||||
|
||||
const results = [];
|
||||
@@ -121,16 +126,17 @@ async function fetchChunk(dates, bank, rateCache) {
|
||||
const date = dates[i];
|
||||
const dateStr = format(date, "yyyy-MM-dd");
|
||||
const outcome = settled[i];
|
||||
let rate = outcome.status === "fulfilled" ? outcome.value : null;
|
||||
let rates = outcome.status === "fulfilled" ? outcome.value : null;
|
||||
|
||||
if (rate) {
|
||||
rateCache.set(dateStr, rate);
|
||||
results.push(rate);
|
||||
if (rates) {
|
||||
rateCache.set(dateStr, rates);
|
||||
results.push(...rates);
|
||||
} else {
|
||||
// Weekend/holiday/error — fill with most recent available rate
|
||||
const prevRate = await getPreviousDayRate(date, bank, rateCache);
|
||||
if (prevRate) {
|
||||
results.push({ ...prevRate, date: format(date, DISPLAY_DATE_FORMAT) });
|
||||
const prevRates = await getPreviousDayRate(date, bank, currency, rateCache);
|
||||
if (prevRates) {
|
||||
const displayDate = format(date, DISPLAY_DATE_FORMAT);
|
||||
results.push(...prevRates.map((r) => ({ ...r, date: displayDate })));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,7 +146,7 @@ async function fetchChunk(dates, bank, rateCache) {
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { startDate, endDate, bank } = body;
|
||||
const { startDate, endDate, bank, currency = "USD" } = body;
|
||||
if (!startDate || !endDate || !bank) {
|
||||
return Response.json(
|
||||
{ error: "Missing required parameters" },
|
||||
@@ -155,7 +161,7 @@ export async function POST(request) {
|
||||
// Process dates in parallel chunks to avoid overwhelming upstream APIs
|
||||
for (let i = 0; i < dates.length; i += PARALLEL_CHUNK_SIZE) {
|
||||
const chunk = dates.slice(i, i + PARALLEL_CHUNK_SIZE);
|
||||
const chunkResults = await fetchChunk(chunk, bank, rateCache);
|
||||
const chunkResults = await fetchChunk(chunk, bank, currency, rateCache);
|
||||
results.push(...chunkResults);
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ function loadSavedSettings() {
|
||||
const parsed = JSON.parse(saved);
|
||||
return {
|
||||
bank: parsed.bank || "bidv",
|
||||
currency: parsed.currency || "USD",
|
||||
startDate: parsed.startDate ? new Date(parsed.startDate) : null,
|
||||
endDate: parsed.endDate ? new Date(parsed.endDate) : null,
|
||||
};
|
||||
@@ -111,10 +112,11 @@ function loadSavedSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings(bank, startDate, endDate) {
|
||||
function saveSettings(bank, currency, startDate, endDate) {
|
||||
try {
|
||||
localStorage.setItem("exchangeRateSettings", JSON.stringify({
|
||||
bank,
|
||||
currency,
|
||||
startDate: startDate?.toISOString(),
|
||||
endDate: endDate?.toISOString(),
|
||||
}));
|
||||
@@ -134,6 +136,10 @@ export default function ExchangeRateViewer() {
|
||||
const saved = loadSavedSettings();
|
||||
return saved?.bank || "bidv";
|
||||
});
|
||||
const [selectedCurrency, setSelectedCurrency] = useState(() => {
|
||||
const saved = loadSavedSettings();
|
||||
return saved?.currency || "USD";
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [results, setResults] = useState([]);
|
||||
@@ -142,8 +148,8 @@ export default function ExchangeRateViewer() {
|
||||
|
||||
// Persist settings to localStorage
|
||||
useEffect(() => {
|
||||
saveSettings(selectedBank, startDate, endDate);
|
||||
}, [selectedBank, startDate, endDate]);
|
||||
saveSettings(selectedBank, selectedCurrency, startDate, endDate);
|
||||
}, [selectedBank, selectedCurrency, startDate, endDate]);
|
||||
|
||||
const handleFetch = useCallback(async () => {
|
||||
if (!startDate || !endDate) {
|
||||
@@ -167,6 +173,7 @@ export default function ExchangeRateViewer() {
|
||||
startDate: format(startDate, "yyyy-MM-dd"),
|
||||
endDate: format(endDate, "yyyy-MM-dd"),
|
||||
bank: selectedBank,
|
||||
currency: selectedCurrency,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -188,7 +195,7 @@ export default function ExchangeRateViewer() {
|
||||
setIsLoading(false);
|
||||
setProgress(null);
|
||||
}
|
||||
}, [startDate, endDate, selectedBank]);
|
||||
}, [startDate, endDate, selectedBank, selectedCurrency]);
|
||||
|
||||
// Auto-fetch on initial load
|
||||
useEffect(() => {
|
||||
@@ -223,7 +230,7 @@ export default function ExchangeRateViewer() {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `exchange_rates_${selectedBank}_${new Date().toISOString().split("T")[0]}.csv`;
|
||||
link.download = `exchange_rates_${selectedBank}_${selectedCurrency}_${new Date().toISOString().split("T")[0]}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
@@ -242,7 +249,7 @@ export default function ExchangeRateViewer() {
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Exchange Rate Export</h1>
|
||||
<p className="text-sm mt-1" style={{ color: "var(--muted)" }}>
|
||||
Fetch USD exchange rates from Vietnamese banks and export to CSV.
|
||||
Fetch exchange rates from Vietnamese banks and export to CSV.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -254,8 +261,8 @@ export default function ExchangeRateViewer() {
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Inputs row */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex flex-col w-full sm:w-1/3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="bank" className="text-sm font-medium mb-1.5">
|
||||
Bank
|
||||
</label>
|
||||
@@ -270,7 +277,33 @@ export default function ExchangeRateViewer() {
|
||||
<option value="tcb">Techcombank</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col w-full sm:w-1/3">
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="currency" className="text-sm font-medium mb-1.5">
|
||||
Currency
|
||||
</label>
|
||||
<select
|
||||
id="currency"
|
||||
value={selectedCurrency}
|
||||
onChange={(e) => setSelectedCurrency(e.target.value)}
|
||||
className="border rounded-lg px-3 py-2 text-sm w-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="ALL">All Currencies</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="GBP">GBP</option>
|
||||
<option value="JPY">JPY</option>
|
||||
<option value="AUD">AUD</option>
|
||||
<option value="CAD">CAD</option>
|
||||
<option value="CHF">CHF</option>
|
||||
<option value="SGD">SGD</option>
|
||||
<option value="THB">THB</option>
|
||||
<option value="HKD">HKD</option>
|
||||
<option value="CNY">CNY</option>
|
||||
<option value="KRW">KRW</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="start-date" className="text-sm font-medium mb-1.5">
|
||||
Start Date
|
||||
</label>
|
||||
@@ -284,7 +317,7 @@ export default function ExchangeRateViewer() {
|
||||
placeholderText="Select start date"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-full sm:w-1/3">
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="end-date" className="text-sm font-medium mb-1.5">
|
||||
End Date
|
||||
</label>
|
||||
|
||||
Reference in new issue
Block a user