mirror of
https://github.com/tiennm99/exchange-rate-export.git
synced 2026-10-11 03:13:21 +00:00
Merge pull request #22 from tiennm99/dependabot/npm_and_yarn/next-16.2.2
build(deps): bump next from 16.1.5 to 16.2.2
This commit is contained in:
1 parent
ca423741ad
commit
f93bc95ed9
7 files changed
+408
-142
No files matched your search
Generated
+9
-9
@@ -3808,9 +3808,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.3.3",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -5523,9 +5523,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -6442,9 +6442,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -11,12 +11,27 @@ const axiosInstance = axios.create({
|
|||||||
|
|
||||||
const DISPLAY_DATE_FORMAT = "yyyy-MM-dd";
|
const DISPLAY_DATE_FORMAT = "yyyy-MM-dd";
|
||||||
const PARALLEL_CHUNK_SIZE = 5;
|
const PARALLEL_CHUNK_SIZE = 5;
|
||||||
|
const MAX_RETRIES = 2;
|
||||||
|
const INITIAL_BACKOFF_MS = 500;
|
||||||
|
|
||||||
|
// Retry a function with exponential backoff
|
||||||
|
async function withRetry(fn, retries = MAX_RETRIES) {
|
||||||
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === retries) throw error;
|
||||||
|
const delay = INITIAL_BACKOFF_MS * Math.pow(2, attempt);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchBIDVRates(dateObj, currency) {
|
async function fetchBIDVRates(dateObj, currency) {
|
||||||
const dateStr = format(dateObj, "dd/MM/yyyy");
|
const dateStr = format(dateObj, "dd/MM/yyyy");
|
||||||
try {
|
try {
|
||||||
const timeUrl = `https://bidv.com.vn/ServicesBIDV/ExchangeDetailSearchTimeServlet?date=${dateStr}`;
|
const timeUrl = `https://bidv.com.vn/ServicesBIDV/ExchangeDetailSearchTimeServlet?date=${dateStr}`;
|
||||||
const timeRes = await axiosInstance.get(timeUrl);
|
const timeRes = await withRetry(() => axiosInstance.get(timeUrl));
|
||||||
const timeData = timeRes.data;
|
const timeData = timeRes.data;
|
||||||
if (timeData.status !== 1 || !timeData.data?.length) return null;
|
if (timeData.status !== 1 || !timeData.data?.length) return null;
|
||||||
|
|
||||||
@@ -26,7 +41,7 @@ async function fetchBIDVRates(dateObj, currency) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const rateUrl = `https://bidv.com.vn/ServicesBIDV/ExchangeDetailServlet?date=${dateStr}&time=${latest.namerecord}`;
|
const rateUrl = `https://bidv.com.vn/ServicesBIDV/ExchangeDetailServlet?date=${dateStr}&time=${latest.namerecord}`;
|
||||||
const rateRes = await axiosInstance.get(rateUrl);
|
const rateRes = await withRetry(() => axiosInstance.get(rateUrl));
|
||||||
const rateData = rateRes.data;
|
const rateData = rateRes.data;
|
||||||
if (rateData.status !== 1 || !rateData.data) return null;
|
if (rateData.status !== 1 || !rateData.data) return null;
|
||||||
|
|
||||||
@@ -55,7 +70,7 @@ async function fetchTCBRates(dateObj, currency) {
|
|||||||
const dateStr = format(dateObj, "yyyy-MM-dd");
|
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`;
|
const url = `https://techcombank.com/content/techcombank/web/vn/vi/cong-cu-tien-ich/ty-gia/_jcr_content.exchange-rates.${dateStr}.integration.json`;
|
||||||
try {
|
try {
|
||||||
const res = await axiosInstance.get(url);
|
const res = await withRetry(() => axiosInstance.get(url));
|
||||||
const data = res.data;
|
const data = res.data;
|
||||||
if (!data.exchangeRate?.data) return null;
|
if (!data.exchangeRate?.data) return null;
|
||||||
|
|
||||||
@@ -165,7 +180,7 @@ export async function POST(request) {
|
|||||||
results.push(...chunkResults);
|
results.push(...chunkResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({ data: results, total: dates.length });
|
return Response.json({ data: results, total: dates.length, chunks: Math.ceil(dates.length / PARALLEL_CHUNK_SIZE) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Server error:", error);
|
console.error("Server error:", error);
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|||||||
@@ -3,12 +3,29 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import DatePicker from "react-datepicker";
|
import DatePicker from "react-datepicker";
|
||||||
import "react-datepicker/dist/react-datepicker.css";
|
import "react-datepicker/dist/react-datepicker.css";
|
||||||
import { format, subDays, startOfMonth } from "date-fns";
|
import { format, subDays, startOfMonth, isSameDay, differenceInCalendarDays } from "date-fns";
|
||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
import { LoadingSpinner, ErrorMessage, EmptyState } from "./exchange-rate-status";
|
import { LoadingSpinner, ErrorMessage, EmptyState } from "./exchange-rate-status";
|
||||||
import { RateTable, ComparisonTable } from "./exchange-rate-table";
|
import { RateTable, ComparisonTable } from "./exchange-rate-table";
|
||||||
import { RateTrendChart } from "./exchange-rate-chart";
|
import { RateTrendChart } from "./exchange-rate-chart";
|
||||||
|
|
||||||
|
// Read state from URL search params (takes priority over localStorage)
|
||||||
|
function loadFromUrl() {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const bank = params.get("bank");
|
||||||
|
const currency = params.get("currency");
|
||||||
|
const start = params.get("start");
|
||||||
|
const end = params.get("end");
|
||||||
|
if (!bank && !currency && !start && !end) return null;
|
||||||
|
return {
|
||||||
|
bank: bank || null,
|
||||||
|
currency: currency || null,
|
||||||
|
startDate: start ? new Date(start) : null,
|
||||||
|
endDate: end ? new Date(end) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function loadSavedSettings() {
|
function loadSavedSettings() {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
try {
|
try {
|
||||||
@@ -49,11 +66,82 @@ const DATE_PRESETS = [
|
|||||||
{ label: "This month", start: () => startOfMonth(new Date()), end: () => new Date() },
|
{ label: "This month", start: () => startOfMonth(new Date()), end: () => new Date() },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function getActivePreset(startDate, endDate) {
|
||||||
|
if (!startDate || !endDate) return null;
|
||||||
|
for (const preset of DATE_PRESETS) {
|
||||||
|
if (isSameDay(startDate, preset.start()) && isSameDay(endDate, preset.end())) {
|
||||||
|
return preset.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(value) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (value === "system") {
|
||||||
|
root.removeAttribute("data-theme");
|
||||||
|
} else {
|
||||||
|
root.setAttribute("data-theme", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEME_CYCLE = ["system", "light", "dark"];
|
||||||
|
const THEME_LABELS = { system: "System", light: "Light", dark: "Dark" };
|
||||||
|
|
||||||
|
function SunIcon() {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoonIcon() {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemIcon() {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25A2.25 2.25 0 0 1 5.25 3h13.5A2.25 2.25 0 0 1 21 5.25Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline SVG icons (16x16) to avoid adding an icon library dependency
|
||||||
|
function FetchIcon() {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 16.5V9.75m0 0 3 3m-3-3-3 3M6.75 19.5a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExcelIcon() {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M10.875 12c-.621 0-1.125.504-1.125 1.125M12 12c.621 0 1.125.504 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 0v1.5c0 .621-.504 1.125-1.125 1.125m0 0c-.621 0-1.125.504-1.125 1.125v1.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CsvIcon() {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ExchangeRateViewer() {
|
export default function ExchangeRateViewer() {
|
||||||
const [startDate, setStartDate] = useState(() => loadSavedSettings()?.startDate || subDays(new Date(), 7));
|
const [startDate, setStartDate] = useState(() => subDays(new Date(), 7));
|
||||||
const [endDate, setEndDate] = useState(() => loadSavedSettings()?.endDate || new Date());
|
const [endDate, setEndDate] = useState(() => new Date());
|
||||||
const [selectedBank, setSelectedBank] = useState(() => loadSavedSettings()?.bank || "bidv");
|
const [selectedBank, setSelectedBank] = useState("bidv");
|
||||||
const [selectedCurrency, setSelectedCurrency] = useState(() => loadSavedSettings()?.currency || "USD");
|
const [selectedCurrency, setSelectedCurrency] = useState("USD");
|
||||||
const [compareMode, setCompareMode] = useState(false);
|
const [compareMode, setCompareMode] = useState(false);
|
||||||
const [showChart, setShowChart] = useState(false);
|
const [showChart, setShowChart] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -61,10 +149,36 @@ export default function ExchangeRateViewer() {
|
|||||||
const [results, setResults] = useState([]);
|
const [results, setResults] = useState([]);
|
||||||
const [hasFetched, setHasFetched] = useState(false);
|
const [hasFetched, setHasFetched] = useState(false);
|
||||||
const [progress, setProgress] = useState(null);
|
const [progress, setProgress] = useState(null);
|
||||||
|
const [theme, setTheme] = useState("system");
|
||||||
|
|
||||||
|
// Restore saved settings on client mount — URL params take priority over localStorage
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const urlSettings = loadFromUrl();
|
||||||
|
const saved = urlSettings || loadSavedSettings();
|
||||||
|
if (saved) {
|
||||||
|
if (saved.bank) setSelectedBank(saved.bank);
|
||||||
|
if (saved.currency) setSelectedCurrency(saved.currency);
|
||||||
|
if (saved.startDate) setStartDate(saved.startDate);
|
||||||
|
if (saved.endDate) setEndDate(saved.endDate);
|
||||||
|
}
|
||||||
|
const savedTheme = localStorage.getItem("theme") || "system";
|
||||||
|
setTheme(savedTheme);
|
||||||
|
applyTheme(savedTheme);
|
||||||
|
setHydrated(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync state to both localStorage and URL
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrated) return;
|
||||||
saveSettings(selectedBank, selectedCurrency, startDate, endDate);
|
saveSettings(selectedBank, selectedCurrency, startDate, endDate);
|
||||||
}, [selectedBank, selectedCurrency, startDate, endDate]);
|
const params = new URLSearchParams();
|
||||||
|
params.set("bank", selectedBank);
|
||||||
|
params.set("currency", selectedCurrency);
|
||||||
|
if (startDate) params.set("start", format(startDate, "yyyy-MM-dd"));
|
||||||
|
if (endDate) params.set("end", format(endDate, "yyyy-MM-dd"));
|
||||||
|
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||||
|
}, [selectedBank, selectedCurrency, startDate, endDate, hydrated]);
|
||||||
|
|
||||||
const fetchBank = async (bank) => {
|
const fetchBank = async (bank) => {
|
||||||
const response = await fetch("/api/exchange-rates", {
|
const response = await fetch("/api/exchange-rates", {
|
||||||
@@ -97,7 +211,8 @@ export default function ExchangeRateViewer() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setHasFetched(false);
|
setHasFetched(false);
|
||||||
setProgress(null);
|
const totalDays = differenceInCalendarDays(endDate, startDate) + 1;
|
||||||
|
setProgress({ current: 0, total: totalDays });
|
||||||
try {
|
try {
|
||||||
if (compareMode) {
|
if (compareMode) {
|
||||||
const [bidvData, tcbData] = await Promise.all([fetchBank("bidv"), fetchBank("tcb")]);
|
const [bidvData, tcbData] = await Promise.all([fetchBank("bidv"), fetchBank("tcb")]);
|
||||||
@@ -129,8 +244,8 @@ export default function ExchangeRateViewer() {
|
|||||||
}, [startDate, endDate, selectedBank, selectedCurrency, compareMode]);
|
}, [startDate, endDate, selectedBank, selectedCurrency, compareMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleFetch();
|
if (hydrated) handleFetch();
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [hydrated]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const getExportRows = () => {
|
const getExportRows = () => {
|
||||||
if (compareMode) {
|
if (compareMode) {
|
||||||
@@ -170,27 +285,38 @@ export default function ExchangeRateViewer() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputStyle = {
|
const activePreset = getActivePreset(startDate, endDate);
|
||||||
background: "var(--input-bg)",
|
|
||||||
color: "var(--foreground)",
|
|
||||||
borderColor: "var(--input-border)",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 max-w-5xl w-full">
|
<div className="container mx-auto px-4 py-8 max-w-5xl w-full">
|
||||||
<div className="mb-6">
|
<div className="mb-6 flex items-start justify-between">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Exchange Rate Export</h1>
|
<div>
|
||||||
<p className="text-sm mt-1" style={{ color: "var(--muted)" }}>
|
<h1 className="text-2xl font-bold tracking-tight">Exchange Rate Export</h1>
|
||||||
Fetch exchange rates from Vietnamese banks and export to CSV.
|
<p className="text-sm mt-1 text-[var(--muted)]">
|
||||||
</p>
|
Fetch exchange rates from Vietnamese banks and export to CSV.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={`Theme: ${THEME_LABELS[theme]}`}
|
||||||
|
onClick={() => {
|
||||||
|
const next = THEME_CYCLE[(THEME_CYCLE.indexOf(theme) + 1) % THEME_CYCLE.length];
|
||||||
|
setTheme(next);
|
||||||
|
applyTheme(next);
|
||||||
|
localStorage.setItem("theme", next);
|
||||||
|
}}
|
||||||
|
className="p-2 rounded-lg border border-[var(--input-border)] text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
|
||||||
|
>
|
||||||
|
{theme === "dark" ? <MoonIcon /> : theme === "light" ? <SunIcon /> : <SystemIcon />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="rounded-xl p-5 mb-6"
|
className="rounded-xl p-5 mb-6 bg-[var(--card-bg)] border border-[var(--card-border)]"
|
||||||
style={{ background: "var(--card-bg)", border: "1px solid var(--card-border)" }}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Enter" && !isLoading) handleFetch(); }}
|
onKeyDown={(e) => { if (e.key === "Enter" && !isLoading) handleFetch(); }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Row 1: Selectors and date pickers */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<label htmlFor="bank" className="text-sm font-medium mb-1.5">Bank</label>
|
<label htmlFor="bank" className="text-sm font-medium mb-1.5">Bank</label>
|
||||||
@@ -199,8 +325,7 @@ export default function ExchangeRateViewer() {
|
|||||||
value={selectedBank}
|
value={selectedBank}
|
||||||
onChange={(e) => setSelectedBank(e.target.value)}
|
onChange={(e) => setSelectedBank(e.target.value)}
|
||||||
disabled={compareMode}
|
disabled={compareMode}
|
||||||
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 disabled:opacity-50"
|
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 disabled:opacity-50 bg-[var(--input-bg)] text-[var(--foreground)] border-[var(--input-border)]"
|
||||||
style={inputStyle}
|
|
||||||
>
|
>
|
||||||
<option value="bidv">BIDV</option>
|
<option value="bidv">BIDV</option>
|
||||||
<option value="tcb">Techcombank</option>
|
<option value="tcb">Techcombank</option>
|
||||||
@@ -212,8 +337,7 @@ export default function ExchangeRateViewer() {
|
|||||||
id="currency"
|
id="currency"
|
||||||
value={selectedCurrency}
|
value={selectedCurrency}
|
||||||
onChange={(e) => setSelectedCurrency(e.target.value)}
|
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"
|
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 bg-[var(--input-bg)] text-[var(--foreground)] border-[var(--input-border)]"
|
||||||
style={inputStyle}
|
|
||||||
>
|
>
|
||||||
{CURRENCY_OPTIONS.map((c) => (
|
{CURRENCY_OPTIONS.map((c) => (
|
||||||
<option key={c} value={c}>{c === "ALL" ? "All Currencies" : c}</option>
|
<option key={c} value={c}>{c === "ALL" ? "All Currencies" : c}</option>
|
||||||
@@ -247,22 +371,26 @@ export default function ExchangeRateViewer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
{/* Row 2: Toggles and date presets */}
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer" style={{ color: "var(--muted)" }}>
|
<div className="flex flex-wrap items-center gap-3 pt-1 border-t border-[var(--card-border)]">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer text-[var(--muted)]">
|
||||||
<input type="checkbox" checked={compareMode} onChange={(e) => setCompareMode(e.target.checked)} className="rounded" />
|
<input type="checkbox" checked={compareMode} onChange={(e) => setCompareMode(e.target.checked)} className="rounded" />
|
||||||
Compare BIDV vs TCB
|
Compare BIDV vs TCB
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer" style={{ color: "var(--muted)" }}>
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer text-[var(--muted)]">
|
||||||
<input type="checkbox" checked={showChart} onChange={(e) => setShowChart(e.target.checked)} className="rounded" />
|
<input type="checkbox" checked={showChart} onChange={(e) => setShowChart(e.target.checked)} className="rounded" />
|
||||||
Show Chart
|
Show Chart
|
||||||
</label>
|
</label>
|
||||||
<span style={{ color: "var(--card-border)" }}>|</span>
|
<span className="text-[var(--card-border)]">|</span>
|
||||||
{DATE_PRESETS.map((preset) => (
|
{DATE_PRESETS.map((preset) => (
|
||||||
<button
|
<button
|
||||||
key={preset.label}
|
key={preset.label}
|
||||||
type="button"
|
type="button"
|
||||||
className="text-xs px-3 py-1 rounded-md border transition-colors hover:bg-blue-50 dark:hover:bg-blue-900/20"
|
className={`text-xs px-3 py-1 rounded-md border transition-colors ${
|
||||||
style={{ borderColor: "var(--input-border)", color: "var(--muted)" }}
|
activePreset === preset.label
|
||||||
|
? "bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border-blue-300 dark:border-blue-700 font-medium"
|
||||||
|
: "border-[var(--input-border)] text-[var(--muted)] hover:bg-blue-50 dark:hover:bg-blue-900/20"
|
||||||
|
}`}
|
||||||
onClick={() => { setStartDate(preset.start()); setEndDate(preset.end()); }}
|
onClick={() => { setStartDate(preset.start()); setEndDate(preset.end()); }}
|
||||||
>
|
>
|
||||||
{preset.label}
|
{preset.label}
|
||||||
@@ -270,27 +398,30 @@ export default function ExchangeRateViewer() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-end pt-1">
|
{/* Row 3: Action buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 sm:justify-end pt-1">
|
||||||
<button
|
<button
|
||||||
className="bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-medium px-5 py-2 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500/40"
|
className="bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-medium px-5 py-2 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500/40 inline-flex items-center justify-center gap-2"
|
||||||
onClick={handleFetch}
|
onClick={handleFetch}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
|
<FetchIcon />
|
||||||
{isLoading ? "Fetching..." : "Fetch Rates"}
|
{isLoading ? "Fetching..." : "Fetch Rates"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-medium px-5 py-2 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-emerald-500/40"
|
className="bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-medium px-5 py-2 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-emerald-500/40 inline-flex items-center justify-center gap-2"
|
||||||
onClick={handleExportExcel}
|
onClick={handleExportExcel}
|
||||||
disabled={results.length === 0 || isLoading}
|
disabled={results.length === 0 || isLoading}
|
||||||
>
|
>
|
||||||
|
<ExcelIcon />
|
||||||
Export Excel
|
Export Excel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="text-sm font-medium px-5 py-2 rounded-lg border transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500/30"
|
className="text-sm font-medium px-5 py-2 rounded-lg border transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500/30 inline-flex items-center justify-center gap-2 border-[var(--input-border)] text-[var(--foreground)]"
|
||||||
style={{ borderColor: "var(--input-border)", color: "var(--foreground)" }}
|
|
||||||
onClick={handleExportCsv}
|
onClick={handleExportCsv}
|
||||||
disabled={results.length === 0 || isLoading}
|
disabled={results.length === 0 || isLoading}
|
||||||
>
|
>
|
||||||
|
<CsvIcon />
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -304,7 +435,7 @@ export default function ExchangeRateViewer() {
|
|||||||
{!isLoading && hasFetched && results.length > 0 && (
|
{!isLoading && hasFetched && results.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<p className="text-sm" style={{ color: "var(--muted)" }}>
|
<p className="text-sm text-[var(--muted)]">
|
||||||
{results.length} {results.length === 1 ? "record" : "records"} found
|
{results.length} {results.length === 1 ? "record" : "records"} found
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,11 +11,9 @@ function parseRate(val) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RateTrendChart({ results, selectedBank, compareMode }) {
|
export function RateTrendChart({ results, selectedBank, compareMode }) {
|
||||||
// Build chart data from results
|
|
||||||
const chartData = [];
|
const chartData = [];
|
||||||
|
|
||||||
if (compareMode) {
|
if (compareMode) {
|
||||||
// Group by date, show BIDV sell vs TCB ask
|
|
||||||
const byDate = new Map();
|
const byDate = new Map();
|
||||||
for (const row of results) {
|
for (const row of results) {
|
||||||
if (!byDate.has(row.date)) byDate.set(row.date, {});
|
if (!byDate.has(row.date)) byDate.set(row.date, {});
|
||||||
@@ -49,7 +47,6 @@ export function RateTrendChart({ results, selectedBank, compareMode }) {
|
|||||||
|
|
||||||
if (chartData.length === 0) return null;
|
if (chartData.length === 0) return null;
|
||||||
|
|
||||||
// Collect all numeric keys for lines
|
|
||||||
const lineKeys = new Set();
|
const lineKeys = new Set();
|
||||||
for (const row of chartData) {
|
for (const row of chartData) {
|
||||||
for (const key of Object.keys(row)) {
|
for (const key of Object.keys(row)) {
|
||||||
@@ -60,10 +57,7 @@ export function RateTrendChart({ results, selectedBank, compareMode }) {
|
|||||||
const colors = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"];
|
const colors = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="rounded-lg p-4 mb-4 bg-[var(--card-bg)] border border-[var(--card-border)]">
|
||||||
className="rounded-lg p-4 mb-4"
|
|
||||||
style={{ background: "var(--card-bg)", border: "1px solid var(--card-border)" }}
|
|
||||||
>
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
|
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--card-border)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--card-border)" />
|
||||||
@@ -76,6 +70,7 @@ export function RateTrendChart({ results, selectedBank, compareMode }) {
|
|||||||
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
domain={["auto", "auto"]}
|
domain={["auto", "auto"]}
|
||||||
|
label={{ value: "VND", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "var(--muted)" } }}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
|
|||||||
@@ -5,11 +5,16 @@ import React from "react";
|
|||||||
export function LoadingSpinner({ progress }) {
|
export function LoadingSpinner({ progress }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3 w-64">
|
||||||
<div className="w-8 h-8 border-3 border-blue-200 border-t-blue-600 rounded-full animate-spin" />
|
<div className="w-8 h-8 border-3 border-blue-200 border-t-blue-600 rounded-full animate-spin" />
|
||||||
<p style={{ color: "var(--muted)" }}>
|
<p className="text-[var(--muted)] text-sm">
|
||||||
{progress ? `Fetching ${progress.current}/${progress.total} days...` : "Fetching exchange rates..."}
|
{progress?.total ? `Fetching rates for ${progress.total} day${progress.total > 1 ? "s" : ""}...` : "Fetching exchange rates..."}
|
||||||
</p>
|
</p>
|
||||||
|
{progress?.total > 1 && (
|
||||||
|
<div className="w-full h-1.5 rounded-full bg-[var(--card-border)] overflow-hidden">
|
||||||
|
<div className="h-full bg-blue-500 rounded-full animate-pulse" style={{ width: "100%" }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -17,15 +22,10 @@ export function LoadingSpinner({ progress }) {
|
|||||||
|
|
||||||
export function ErrorMessage({ message }) {
|
export function ErrorMessage({ message }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex items-start gap-2 rounded-lg px-4 py-3 text-sm mb-4 bg-red-50 dark:bg-red-950 border border-red-500/20 text-red-500">
|
||||||
className="flex items-start gap-2 rounded-lg px-4 py-3 text-sm mb-4"
|
<svg aria-hidden="true" className="shrink-0 mt-0.5 w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
style={{
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||||
background: "rgba(239, 68, 68, 0.08)",
|
</svg>
|
||||||
border: "1px solid rgba(239, 68, 68, 0.2)",
|
|
||||||
color: "#ef4444",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="shrink-0 mt-0.5">⚠</span>
|
|
||||||
<span>{message}</span>
|
<span>{message}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -33,9 +33,12 @@ export function ErrorMessage({ message }) {
|
|||||||
|
|
||||||
export function EmptyState() {
|
export function EmptyState() {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12" style={{ color: "var(--muted)" }}>
|
<div className="text-center py-16 text-[var(--muted)]">
|
||||||
<p className="text-lg mb-1">No results yet</p>
|
<svg aria-hidden="true" className="mx-auto mb-4 w-12 h-12 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<p className="text-sm">Select a bank and date range, then click Fetch Rates.</p>
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 0 0 6 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0 1 18 16.5h-2.25m-7.5 0h7.5m-7.5 0-1 3m8.5-3 1 3m0 0 .5 1.5m-.5-1.5h-9.5m0 0-.5 1.5M9 11.25v-5.5m3 5.5V8.25" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-lg font-medium mb-1">No exchange rate data</p>
|
||||||
|
<p className="text-sm">Select a bank and date range, then click Fetch Rates to get started.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,95 +3,202 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export function ComparisonTable({ results }) {
|
export function ComparisonTable({ results }) {
|
||||||
const headers = ["Date", "Currency", "BIDV Buy TM", "BIDV Sell", "TCB Bid TM", "TCB Ask"];
|
const headers = [
|
||||||
|
{ label: "Date", numeric: false },
|
||||||
|
{ label: "Currency", numeric: false },
|
||||||
|
{ label: "BIDV Buy TM", numeric: true },
|
||||||
|
{ label: "BIDV Sell", numeric: true },
|
||||||
|
{ label: "TCB Bid TM", numeric: true },
|
||||||
|
{ label: "TCB Ask", numeric: true },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto rounded-lg" style={{ border: "1px solid var(--card-border)" }}>
|
<>
|
||||||
<table className="min-w-full text-sm">
|
{/* Desktop table */}
|
||||||
<thead>
|
<div className="hidden sm:block overflow-x-auto rounded-lg border border-[var(--card-border)]">
|
||||||
<tr style={{ background: "var(--table-header-bg)" }}>
|
<table className="min-w-full text-sm">
|
||||||
{headers.map((h) => (
|
<thead className="sticky top-0 z-10">
|
||||||
<th
|
<tr className="bg-[var(--table-header-bg)]">
|
||||||
key={h}
|
{headers.map((h) => (
|
||||||
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
|
<th
|
||||||
style={{ borderBottom: "1px solid var(--card-border)" }}
|
key={h.label}
|
||||||
>
|
className={`px-3 py-2.5 font-semibold whitespace-nowrap border-b border-[var(--card-border)] ${h.numeric ? "text-right" : "text-left"}`}
|
||||||
{h}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{results.map((row, idx) => (
|
|
||||||
<tr
|
|
||||||
key={idx}
|
|
||||||
className={`transition-colors hover:!bg-[var(--table-row-hover)] ${idx % 2 !== 0 ? "bg-[var(--table-row-stripe)]" : ""}`}
|
|
||||||
>
|
|
||||||
{[row.date, row.currency, row.bidvBuyTm, row.bidvSell, row.tcbBidTm, row.tcbAsk].map((cell, i) => (
|
|
||||||
<td
|
|
||||||
key={i}
|
|
||||||
className="px-3 py-2 whitespace-nowrap"
|
|
||||||
style={{ borderBottom: "1px solid var(--card-border)" }}
|
|
||||||
>
|
>
|
||||||
{cell || "-"}
|
{h.label}
|
||||||
</td>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{results.map((row, idx) => {
|
||||||
</div>
|
const cells = [
|
||||||
|
{ value: row.date, numeric: false },
|
||||||
|
{ value: row.currency, numeric: false },
|
||||||
|
{ value: row.bidvBuyTm, numeric: true },
|
||||||
|
{ value: row.bidvSell, numeric: true },
|
||||||
|
{ value: row.tcbBidTm, numeric: true },
|
||||||
|
{ value: row.tcbAsk, numeric: true },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={idx}
|
||||||
|
className={`transition-colors hover:!bg-[var(--table-row-hover)] ${idx % 2 !== 0 ? "bg-[var(--table-row-stripe)]" : ""}`}
|
||||||
|
>
|
||||||
|
{cells.map((cell, i) => (
|
||||||
|
<td
|
||||||
|
key={i}
|
||||||
|
className={`px-3 py-2 whitespace-nowrap border-b border-[var(--card-border)] ${cell.numeric ? "text-right tabular-nums" : ""}`}
|
||||||
|
>
|
||||||
|
{cell.value || "-"}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/* Mobile cards */}
|
||||||
|
<div className="sm:hidden flex flex-col gap-2">
|
||||||
|
{results.map((row, idx) => (
|
||||||
|
<div key={idx} className="rounded-lg border border-[var(--card-border)] p-3 bg-[var(--card-bg)] text-sm">
|
||||||
|
<div className="flex justify-between mb-1.5">
|
||||||
|
<span className="font-medium">{row.date}</span>
|
||||||
|
<span className="text-[var(--muted)]">{row.currency}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||||
|
<div className="flex justify-between"><span className="text-[var(--muted)]">BIDV Buy</span><span className="tabular-nums">{row.bidvBuyTm || "-"}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-[var(--muted)]">TCB Bid</span><span className="tabular-nums">{row.tcbBidTm || "-"}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-[var(--muted)]">BIDV Sell</span><span className="tabular-nums">{row.bidvSell || "-"}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-[var(--muted)]">TCB Ask</span><span className="tabular-nums">{row.tcbAsk || "-"}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RateTable({ results, selectedBank }) {
|
export function RateTable({ results, selectedBank }) {
|
||||||
const bidvHeaders = ["Date", "Name (VI)", "Buy TM", "Buy CK", "Currency", "Name (EN)", "Sell"];
|
const bidvHeaders = [
|
||||||
const tcbHeaders = ["Date", "Label", "Ask Rate", "Bid CK", "Bid TM", "Source", "Target", "Ask TM"];
|
{ label: "Date", numeric: false },
|
||||||
|
{ label: "Name (VI)", numeric: false },
|
||||||
|
{ label: "Buy TM", numeric: true },
|
||||||
|
{ label: "Buy CK", numeric: true },
|
||||||
|
{ label: "Currency", numeric: false },
|
||||||
|
{ label: "Name (EN)", numeric: false },
|
||||||
|
{ label: "Sell", numeric: true },
|
||||||
|
];
|
||||||
|
const tcbHeaders = [
|
||||||
|
{ label: "Date", numeric: false },
|
||||||
|
{ label: "Label", numeric: false },
|
||||||
|
{ label: "Ask Rate", numeric: true },
|
||||||
|
{ label: "Bid CK", numeric: true },
|
||||||
|
{ label: "Bid TM", numeric: true },
|
||||||
|
{ label: "Source", numeric: false },
|
||||||
|
{ label: "Target", numeric: false },
|
||||||
|
{ label: "Ask TM", numeric: true },
|
||||||
|
];
|
||||||
|
|
||||||
const headers = selectedBank === "bidv" ? bidvHeaders : tcbHeaders;
|
const headers = selectedBank === "bidv" ? bidvHeaders : tcbHeaders;
|
||||||
|
|
||||||
const getRowCells = (row) => {
|
const getRowCells = (row) => {
|
||||||
if (selectedBank === "bidv") {
|
if (selectedBank === "bidv") {
|
||||||
return [row.date, row.nameVI, row.muaTm, row.muaCk, row.currency, row.nameEN, row.ban];
|
return [
|
||||||
|
{ value: row.date, numeric: false },
|
||||||
|
{ value: row.nameVI, numeric: false },
|
||||||
|
{ value: row.muaTm, numeric: true },
|
||||||
|
{ value: row.muaCk, numeric: true },
|
||||||
|
{ value: row.currency, numeric: false },
|
||||||
|
{ value: row.nameEN, numeric: false },
|
||||||
|
{ value: row.ban, numeric: true },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
return [row.date, row.label, row.askRate, row.bidRateCK, row.bidRateTM, row.sourceCurrency, row.targetCurrency, row.askRateTM];
|
return [
|
||||||
|
{ value: row.date, numeric: false },
|
||||||
|
{ value: row.label, numeric: false },
|
||||||
|
{ value: row.askRate, numeric: true },
|
||||||
|
{ value: row.bidRateCK, numeric: true },
|
||||||
|
{ value: row.bidRateTM, numeric: true },
|
||||||
|
{ value: row.sourceCurrency, numeric: false },
|
||||||
|
{ value: row.targetCurrency, numeric: false },
|
||||||
|
{ value: row.askRateTM, numeric: true },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMobileCard = (row) => {
|
||||||
|
if (selectedBank === "bidv") {
|
||||||
|
return { title: row.nameVI || row.nameEN, subtitle: `${row.currency} - ${row.date}`, fields: [
|
||||||
|
{ label: "Buy TM", value: row.muaTm }, { label: "Buy CK", value: row.muaCk }, { label: "Sell", value: row.ban },
|
||||||
|
]};
|
||||||
|
}
|
||||||
|
return { title: row.label, subtitle: `${row.sourceCurrency} - ${row.date}`, fields: [
|
||||||
|
{ label: "Bid TM", value: row.bidRateTM }, { label: "Bid CK", value: row.bidRateCK },
|
||||||
|
{ label: "Ask", value: row.askRate }, { label: "Ask TM", value: row.askRateTM },
|
||||||
|
]};
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto rounded-lg" style={{ border: "1px solid var(--card-border)" }}>
|
<>
|
||||||
<table className="min-w-full text-sm">
|
{/* Desktop table */}
|
||||||
<thead>
|
<div className="hidden sm:block overflow-x-auto rounded-lg border border-[var(--card-border)]">
|
||||||
<tr style={{ background: "var(--table-header-bg)" }}>
|
<table className="min-w-full text-sm">
|
||||||
{headers.map((h) => (
|
<thead className="sticky top-0 z-10">
|
||||||
<th
|
<tr className="bg-[var(--table-header-bg)]">
|
||||||
key={h}
|
{headers.map((h) => (
|
||||||
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
|
<th
|
||||||
style={{ borderBottom: "1px solid var(--card-border)" }}
|
key={h.label}
|
||||||
>
|
className={`px-3 py-2.5 font-semibold whitespace-nowrap border-b border-[var(--card-border)] ${h.numeric ? "text-right" : "text-left"}`}
|
||||||
{h}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{results.map((row, idx) => (
|
|
||||||
<tr
|
|
||||||
key={idx}
|
|
||||||
className={`transition-colors hover:!bg-[var(--table-row-hover)] ${idx % 2 !== 0 ? "bg-[var(--table-row-stripe)]" : ""}`}
|
|
||||||
>
|
|
||||||
{getRowCells(row).map((cell, i) => (
|
|
||||||
<td
|
|
||||||
key={i}
|
|
||||||
className="px-3 py-2 whitespace-nowrap"
|
|
||||||
style={{ borderBottom: "1px solid var(--card-border)" }}
|
|
||||||
>
|
>
|
||||||
{cell}
|
{h.label}
|
||||||
</td>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{results.map((row, idx) => {
|
||||||
</div>
|
const cells = getRowCells(row);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={idx}
|
||||||
|
className={`transition-colors hover:!bg-[var(--table-row-hover)] ${idx % 2 !== 0 ? "bg-[var(--table-row-stripe)]" : ""}`}
|
||||||
|
>
|
||||||
|
{cells.map((cell, i) => (
|
||||||
|
<td
|
||||||
|
key={i}
|
||||||
|
className={`px-3 py-2 whitespace-nowrap border-b border-[var(--card-border)] ${cell.numeric ? "text-right tabular-nums" : ""}`}
|
||||||
|
>
|
||||||
|
{cell.value}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/* Mobile cards */}
|
||||||
|
<div className="sm:hidden flex flex-col gap-2">
|
||||||
|
{results.map((row, idx) => {
|
||||||
|
const card = getMobileCard(row);
|
||||||
|
return (
|
||||||
|
<div key={idx} className="rounded-lg border border-[var(--card-border)] p-3 bg-[var(--card-bg)] text-sm">
|
||||||
|
<div className="flex justify-between mb-1.5">
|
||||||
|
<span className="font-medium">{card.title}</span>
|
||||||
|
<span className="text-[var(--muted)] text-xs">{card.subtitle}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||||
|
{card.fields.map((f, i) => (
|
||||||
|
<div key={i} className="flex justify-between">
|
||||||
|
<span className="text-[var(--muted)]">{f.label}</span>
|
||||||
|
<span className="tabular-nums">{f.value || "-"}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+16
-1
@@ -20,8 +20,23 @@
|
|||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode: explicit toggle OR system preference (unless overridden to light) */
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
--card-bg: #18181b;
|
||||||
|
--card-border: #27272a;
|
||||||
|
--input-bg: #1e1e22;
|
||||||
|
--input-border: #3f3f46;
|
||||||
|
--input-focus: #60a5fa;
|
||||||
|
--table-header-bg: #1e1e22;
|
||||||
|
--table-row-hover: #27272a;
|
||||||
|
--table-row-stripe: #141416;
|
||||||
|
--muted: #a1a1aa;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root:not([data-theme="light"]) {
|
||||||
--background: #0a0a0a;
|
--background: #0a0a0a;
|
||||||
--foreground: #ededed;
|
--foreground: #ededed;
|
||||||
--card-bg: #18181b;
|
--card-bg: #18181b;
|
||||||
|
|||||||
Reference in new issue
Block a user