feat: improve UI/UX with consistent styling and better usability

Replace all inline styles with Tailwind classes, add SVG icons to
action buttons, right-align numeric table cells with tabular-nums,
add sticky table headers, highlight active date preset, improve
empty state with illustration, fix SSR hydration mismatch for
localStorage settings, and make buttons mobile-responsive.
This commit is contained in:
tiennm99 committed 2026-04-04 00:11:32 +07:00
1 parent 75eed4a75a
commit ebe1bd3ba7
4 files changed
+196 -103

No files matched your search

+82 -32
View File
@@ -3,7 +3,7 @@
import React, { useState, useEffect, useCallback } from "react";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import { format, subDays, startOfMonth } from "date-fns";
import { format, subDays, startOfMonth, isSameDay } from "date-fns";
import * as XLSX from "xlsx";
import { LoadingSpinner, ErrorMessage, EmptyState } from "./exchange-rate-status";
import { RateTable, ComparisonTable } from "./exchange-rate-table";
@@ -49,11 +49,46 @@ const DATE_PRESETS = [
{ 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;
}
// 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() {
const [startDate, setStartDate] = useState(() => loadSavedSettings()?.startDate || subDays(new Date(), 7));
const [endDate, setEndDate] = useState(() => loadSavedSettings()?.endDate || new Date());
const [selectedBank, setSelectedBank] = useState(() => loadSavedSettings()?.bank || "bidv");
const [selectedCurrency, setSelectedCurrency] = useState(() => loadSavedSettings()?.currency || "USD");
const [startDate, setStartDate] = useState(() => subDays(new Date(), 7));
const [endDate, setEndDate] = useState(() => new Date());
const [selectedBank, setSelectedBank] = useState("bidv");
const [selectedCurrency, setSelectedCurrency] = useState("USD");
const [compareMode, setCompareMode] = useState(false);
const [showChart, setShowChart] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -62,9 +97,23 @@ export default function ExchangeRateViewer() {
const [hasFetched, setHasFetched] = useState(false);
const [progress, setProgress] = useState(null);
// Restore saved settings on client mount only (avoids SSR hydration mismatch)
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
const saved = 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);
}
setHydrated(true);
}, []);
useEffect(() => {
if (!hydrated) return;
saveSettings(selectedBank, selectedCurrency, startDate, endDate);
}, [selectedBank, selectedCurrency, startDate, endDate]);
}, [selectedBank, selectedCurrency, startDate, endDate, hydrated]);
const fetchBank = async (bank) => {
const response = await fetch("/api/exchange-rates", {
@@ -129,8 +178,8 @@ export default function ExchangeRateViewer() {
}, [startDate, endDate, selectedBank, selectedCurrency, compareMode]);
useEffect(() => {
handleFetch();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (hydrated) handleFetch();
}, [hydrated]); // eslint-disable-line react-hooks/exhaustive-deps
const getExportRows = () => {
if (compareMode) {
@@ -170,27 +219,23 @@ export default function ExchangeRateViewer() {
URL.revokeObjectURL(url);
};
const inputStyle = {
background: "var(--input-bg)",
color: "var(--foreground)",
borderColor: "var(--input-border)",
};
const activePreset = getActivePreset(startDate, endDate);
return (
<div className="container mx-auto px-4 py-8 max-w-5xl w-full">
<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)" }}>
<p className="text-sm mt-1 text-[var(--muted)]">
Fetch exchange rates from Vietnamese banks and export to CSV.
</p>
</div>
<div
className="rounded-xl p-5 mb-6"
style={{ background: "var(--card-bg)", border: "1px solid var(--card-border)" }}
className="rounded-xl p-5 mb-6 bg-[var(--card-bg)] border border-[var(--card-border)]"
onKeyDown={(e) => { if (e.key === "Enter" && !isLoading) handleFetch(); }}
>
<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="flex flex-col">
<label htmlFor="bank" className="text-sm font-medium mb-1.5">Bank</label>
@@ -199,8 +244,7 @@ export default function ExchangeRateViewer() {
value={selectedBank}
onChange={(e) => setSelectedBank(e.target.value)}
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"
style={inputStyle}
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)]"
>
<option value="bidv">BIDV</option>
<option value="tcb">Techcombank</option>
@@ -212,8 +256,7 @@ export default function ExchangeRateViewer() {
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}
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)]"
>
{CURRENCY_OPTIONS.map((c) => (
<option key={c} value={c}>{c === "ALL" ? "All Currencies" : c}</option>
@@ -247,22 +290,26 @@ export default function ExchangeRateViewer() {
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-1.5 text-xs cursor-pointer" style={{ color: "var(--muted)" }}>
{/* Row 2: Toggles and date presets */}
<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" />
Compare BIDV vs TCB
</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" />
Show Chart
</label>
<span style={{ color: "var(--card-border)" }}>|</span>
<span className="text-[var(--card-border)]">|</span>
{DATE_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
className="text-xs px-3 py-1 rounded-md border transition-colors hover:bg-blue-50 dark:hover:bg-blue-900/20"
style={{ borderColor: "var(--input-border)", color: "var(--muted)" }}
className={`text-xs px-3 py-1 rounded-md border transition-colors ${
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()); }}
>
{preset.label}
@@ -270,27 +317,30 @@ export default function ExchangeRateViewer() {
))}
</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
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}
disabled={isLoading}
>
<FetchIcon />
{isLoading ? "Fetching..." : "Fetch Rates"}
</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}
disabled={results.length === 0 || isLoading}
>
<ExcelIcon />
Export Excel
</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"
style={{ borderColor: "var(--input-border)", color: "var(--foreground)" }}
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)]"
onClick={handleExportCsv}
disabled={results.length === 0 || isLoading}
>
<CsvIcon />
Export CSV
</button>
</div>
@@ -304,7 +354,7 @@ export default function ExchangeRateViewer() {
{!isLoading && hasFetched && results.length > 0 && (
<div>
<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
</p>
</div>
+2 -7
View File
@@ -11,11 +11,9 @@ function parseRate(val) {
}
export function RateTrendChart({ results, selectedBank, compareMode }) {
// Build chart data from results
const chartData = [];
if (compareMode) {
// Group by date, show BIDV sell vs TCB ask
const byDate = new Map();
for (const row of results) {
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;
// Collect all numeric keys for lines
const lineKeys = new Set();
for (const row of chartData) {
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"];
return (
<div
className="rounded-lg p-4 mb-4"
style={{ background: "var(--card-bg)", border: "1px solid var(--card-border)" }}
>
<div className="rounded-lg p-4 mb-4 bg-[var(--card-bg)] border border-[var(--card-border)]">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--card-border)" />
@@ -76,6 +70,7 @@ export function RateTrendChart({ results, selectedBank, compareMode }) {
tick={{ fontSize: 11, fill: "var(--muted)" }}
tickLine={false}
domain={["auto", "auto"]}
label={{ value: "VND", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "var(--muted)" } }}
/>
<Tooltip
contentStyle={{
+11 -13
View File
@@ -7,7 +7,7 @@ export function LoadingSpinner({ progress }) {
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center gap-3">
<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)]">
{progress ? `Fetching ${progress.current}/${progress.total} days...` : "Fetching exchange rates..."}
</p>
</div>
@@ -17,15 +17,10 @@ export function LoadingSpinner({ progress }) {
export function ErrorMessage({ message }) {
return (
<div
className="flex items-start gap-2 rounded-lg px-4 py-3 text-sm mb-4"
style={{
background: "rgba(239, 68, 68, 0.08)",
border: "1px solid rgba(239, 68, 68, 0.2)",
color: "#ef4444",
}}
>
<span className="shrink-0 mt-0.5">&#9888;</span>
<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">
<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}>
<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" />
</svg>
<span>{message}</span>
</div>
);
@@ -33,9 +28,12 @@ export function ErrorMessage({ message }) {
export function EmptyState() {
return (
<div className="text-center py-12" style={{ color: "var(--muted)" }}>
<p className="text-lg mb-1">No results yet</p>
<p className="text-sm">Select a bank and date range, then click Fetch Rates.</p>
<div className="text-center py-16 text-[var(--muted)]">
<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}>
<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>
);
}
+101 -51
View File
@@ -3,41 +3,56 @@
import React from "react";
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 (
<div className="overflow-x-auto rounded-lg" style={{ border: "1px solid var(--card-border)" }}>
<div className="overflow-x-auto rounded-lg border border-[var(--card-border)]">
<table className="min-w-full text-sm">
<thead>
<tr style={{ background: "var(--table-header-bg)" }}>
<thead className="sticky top-0 z-10">
<tr className="bg-[var(--table-header-bg)]">
{headers.map((h) => (
<th
key={h}
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
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}
{h.label}
</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 || "-"}
</td>
))}
</tr>
))}
{results.map((row, idx) => {
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>
@@ -45,51 +60,86 @@ export function ComparisonTable({ results }) {
}
export function RateTable({ results, selectedBank }) {
const bidvHeaders = ["Date", "Name (VI)", "Buy TM", "Buy CK", "Currency", "Name (EN)", "Sell"];
const tcbHeaders = ["Date", "Label", "Ask Rate", "Bid CK", "Bid TM", "Source", "Target", "Ask TM"];
const bidvHeaders = [
{ 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 getRowCells = (row) => {
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 },
];
};
return (
<div className="overflow-x-auto rounded-lg" style={{ border: "1px solid var(--card-border)" }}>
<div className="overflow-x-auto rounded-lg border border-[var(--card-border)]">
<table className="min-w-full text-sm">
<thead>
<tr style={{ background: "var(--table-header-bg)" }}>
<thead className="sticky top-0 z-10">
<tr className="bg-[var(--table-header-bg)]">
{headers.map((h) => (
<th
key={h}
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
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}
{h.label}
</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}
</td>
))}
</tr>
))}
{results.map((row, idx) => {
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>