feat(lme): interactive modal chart with full history on card click

This commit is contained in:
alireza 2026-08-26 10:50:37 +03:30
parent 9b1daf6f76
commit a857102b98
3 changed files with 566 additions and 30 deletions

View File

@ -249,6 +249,14 @@ def get_crypto(_: object = Depends(require_session)):
return crypto_market.get_latest() return crypto_market.get_latest()
@app.get("/api/lme/history")
def get_lme_history_route(metal: str = Query("Copper"), _: object = Depends(require_session)):
try:
return scrape_lme.get_metal_history(metal)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Failed to fetch LME history: {e}")
@app.get("/api/lme") @app.get("/api/lme")
def get_lme(_: object = Depends(require_session)): def get_lme(_: object = Depends(require_session)):
try: try:
@ -430,6 +438,10 @@ def v1_crypto(): return get_crypto(None)
def v1_world_economy(): return get_world_economy(None) def v1_world_economy(): return get_world_economy(None)
@data_api.get("/lme/history")
def v1_lme_history(metal: str = "Copper"): return scrape_lme.get_metal_history(metal)
@data_api.get("/lme") @data_api.get("/lme")
def v1_lme(): return scrape_lme.get_lme_data() def v1_lme(): return scrape_lme.get_lme_data()

View File

@ -14,8 +14,40 @@ _cache = {
"data": None, "data": None,
"last_fetched": 0 "last_fetched": 0
} }
_history_cache = {}
_lock = threading.Lock() _lock = threading.Lock()
FIELD_MAP = {
"copper": "LME_Cu_cash",
"cu": "LME_Cu_cash",
"aluminium": "LME_Al_cash",
"al": "LME_Al_cash",
"aluminum": "LME_Al_cash",
"zinc": "LME_Zn_cash",
"zn": "LME_Zn_cash",
"nickel": "LME_Ni_cash",
"ni": "LME_Ni_cash",
"lead": "LME_Pb_cash",
"pb": "LME_Pb_cash",
"tin": "LME_Sn_cash",
"sn": "LME_Sn_cash",
}
FA_NAMES = {
"Copper": "مس (Copper)",
"Tin": "قلع (Tin)",
"Lead": "سرب (Lead)",
"Zinc": "روی (Zinc)",
"Aluminium": "آلومینیوم (Aluminium)",
"Nickel": "نیکل (Nickel)",
}
MONTHS_MAP = {
"january": "01", "february": "02", "march": "03", "april": "04",
"may": "05", "june": "06", "july": "07", "august": "08",
"september": "09", "october": "10", "november": "11", "december": "12"
}
def clean_num(val: str) -> float: def clean_num(val: str) -> float:
if not val: if not val:
return 0.0 return 0.0
@ -25,16 +57,18 @@ def clean_num(val: str) -> float:
except: except:
return 0.0 return 0.0
FA_NAMES = { def parse_date_iso(raw: str) -> str:
"Copper": "مس (Copper)", # Example: "25. August 2026"
"Tin": "قلع (Tin)", try:
"Lead": "سرب (Lead)", parts = raw.replace('.', '').split()
"Zinc": "روی (Zinc)", if len(parts) >= 3:
"Aluminium": "آلومینیوم (Aluminium)", day = parts[0].zfill(2)
"Nickel": "نیکل (Nickel)", month = MONTHS_MAP.get(parts[1].lower(), "01")
"Aluminium Alloy": "آلیاژ آلومینیوم", year = parts[2]
"NASAAC": "نازاک (NASAAC)" return f"{year}-{month}-{day}"
} except:
pass
return raw
def fetch_raw(): def fetch_raw():
url = 'https://www.westmetall.com/en/markdaten.php' url = 'https://www.westmetall.com/en/markdaten.php'
@ -156,3 +190,66 @@ def get_lme_data(ttl_seconds: int = 180):
return _cache["data"] return _cache["data"]
raise e raise e
return _cache["data"] return _cache["data"]
def get_metal_history(metal_key: str, ttl_seconds: int = 300):
norm_key = metal_key.lower().strip()
field = FIELD_MAP.get(norm_key)
if not field:
# Fallback: check if the key already is a field
if norm_key.startswith("lme_"):
field = metal_key
else:
field = f"LME_{norm_key[:2].capitalize()}_cash"
now = time.time()
with _lock:
cached = _history_cache.get(field)
if cached and (now - cached["time"]) < ttl_seconds:
return cached["data"]
url = f'https://www.westmetall.com/en/markdaten.php?action=table&field={field}'
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=12) as resp:
html = resp.read().decode('utf-8', errors='ignore')
tables = re.findall(r'<table[^>]*>([\s\S]*?)</table>', html)
if not tables:
return {"metal": metal_key, "field": field, "points": []}
rows = re.findall(r'<tr[^>]*>([\s\S]*?)</tr>', tables[0])
points = []
# Skip header
for r in rows[1:]:
cells = [re.sub(r'<.*?>', '', c).replace('&nbsp;', ' ').strip() for c in re.findall(r'<t[dh][^>]*>([\s\S]*?)</t[dh]>', r)]
if len(cells) >= 4 and cells[0]:
c_val = clean_num(cells[1])
tm_val = clean_num(cells[2])
s_val = clean_num(cells[3])
date_iso = parse_date_iso(cells[0])
points.append({
"date": date_iso,
"date_display": cells[0],
"cash": c_val,
"cash_str": cells[1],
"three_months": tm_val,
"three_months_str": cells[2],
"spread": round(tm_val - c_val, 2),
"stocks": s_val,
"stocks_str": cells[3],
})
# Reverse points to chronological order (oldest to newest) for charting
points.reverse()
result = {
"metal": metal_key,
"name_fa": FA_NAMES.get(metal_key.capitalize(), metal_key),
"field": field,
"total_points": len(points),
"points": points
}
with _lock:
_history_cache[field] = {"time": now, "data": result}
return result

View File

@ -6,8 +6,26 @@ import {
DollarSign, DollarSign,
Layers, Layers,
ArrowUpDown, ArrowUpDown,
Globe Globe,
X,
LineChart as LineChartIcon,
TrendingUp,
TrendingDown,
Calendar,
Loader2,
Maximize2
} from "lucide-react"; } from "lucide-react";
import {
ResponsiveContainer,
ComposedChart,
Line,
Area,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Legend
} from "recharts";
import { API_BASE } from "@/lib/api"; import { API_BASE } from "@/lib/api";
const API = API_BASE; const API = API_BASE;
@ -28,6 +46,18 @@ export interface LmeMetal {
unit: string; unit: string;
} }
export interface LmeHistoryPoint {
date: string;
date_display: string;
cash: number;
cash_str: string;
three_months: number;
three_months_str: string;
spread: number;
stocks: number;
stocks_str: string;
}
export interface LmeFx { export interface LmeFx {
pair: string; pair: string;
rate: number; rate: number;
@ -48,13 +78,13 @@ export interface LmeData {
precious_metals: LmePrecious[]; precious_metals: LmePrecious[];
} }
const METAL_STYLES: Record<string, { border: string; bg: string; text: string }> = { const METAL_STYLES: Record<string, { border: string; bg: string; text: string; primaryColor: string; secondaryColor: string }> = {
Copper: { border: "border-amber-500/30", bg: "from-amber-500/10 to-transparent", text: "text-amber-500" }, Copper: { border: "border-amber-500/30", bg: "from-amber-500/10 to-transparent", text: "text-amber-500", primaryColor: "#f59e0b", secondaryColor: "#10b981" },
Aluminium: { border: "border-sky-500/30", bg: "from-sky-500/10 to-transparent", text: "text-sky-500" }, Aluminium: { border: "border-sky-500/30", bg: "from-sky-500/10 to-transparent", text: "text-sky-500", primaryColor: "#0ea5e9", secondaryColor: "#6366f1" },
Zinc: { border: "border-emerald-500/30", bg: "from-emerald-500/10 to-transparent", text: "text-emerald-500" }, Zinc: { border: "border-emerald-500/30", bg: "from-emerald-500/10 to-transparent", text: "text-emerald-500", primaryColor: "#10b981", secondaryColor: "#06b6d4" },
Nickel: { border: "border-purple-500/30", bg: "from-purple-500/10 to-transparent", text: "text-purple-500" }, Nickel: { border: "border-purple-500/30", bg: "from-purple-500/10 to-transparent", text: "text-purple-500", primaryColor: "#a855f7", secondaryColor: "#ec4899" },
Lead: { border: "border-slate-500/30", bg: "from-slate-500/10 to-transparent", text: "text-slate-400" }, Lead: { border: "border-slate-500/30", bg: "from-slate-500/10 to-transparent", text: "text-slate-400", primaryColor: "#94a3b8", secondaryColor: "#38bdf8" },
Tin: { border: "border-cyan-500/30", bg: "from-cyan-500/10 to-transparent", text: "text-cyan-500" }, Tin: { border: "border-cyan-500/30", bg: "from-cyan-500/10 to-transparent", text: "text-cyan-500", primaryColor: "#06b6d4", secondaryColor: "#8b5cf6" },
}; };
export function LmeView() { export function LmeView() {
@ -62,6 +92,13 @@ export function LmeView() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
// Modal State
const [selectedMetal, setSelectedMetal] = useState<LmeMetal | null>(null);
const [historyLoading, setHistoryLoading] = useState<boolean>(false);
const [historyPoints, setHistoryPoints] = useState<LmeHistoryPoint[]>([]);
const [timeRange, setTimeRange] = useState<"1M" | "3M" | "6M" | "ALL">("ALL");
const [chartMode, setChartMode] = useState<"price" | "stocks">("price");
const loadData = async () => { const loadData = async () => {
try { try {
const res = await fetch(`${API}/api/lme`, { credentials: "include" }); const res = await fetch(`${API}/api/lme`, { credentials: "include" });
@ -80,6 +117,46 @@ export function LmeView() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
// Fetch full history when a card is selected
useEffect(() => {
if (!selectedMetal) return;
let alive = true;
setHistoryLoading(true);
fetch(`${API}/api/lme/history?metal=${encodeURIComponent(selectedMetal.metal)}`, {
credentials: "include"
})
.then((r) => r.json())
.then((json) => {
if (!alive) return;
if (json.points && Array.isArray(json.points)) {
setHistoryPoints(json.points);
} else {
setHistoryPoints([]);
}
})
.catch(() => {
if (alive) setHistoryPoints([]);
})
.finally(() => {
if (alive) setHistoryLoading(false);
});
return () => {
alive = false;
};
}, [selectedMetal]);
// Close modal on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setSelectedMetal(null);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
const filteredMetals = useMemo(() => { const filteredMetals = useMemo(() => {
if (!data?.metals) return []; if (!data?.metals) return [];
if (!search.trim()) return data.metals; if (!search.trim()) return data.metals;
@ -91,6 +168,49 @@ export function LmeView() {
); );
}, [data, search]); }, [data, search]);
// Filter history points based on selected timeRange
const displayPoints = useMemo(() => {
if (!historyPoints.length) return [];
if (timeRange === "ALL") return historyPoints;
const days = timeRange === "1M" ? 30 : timeRange === "3M" ? 90 : 180;
// Each trading day is ~1 point
const pointsCount = timeRange === "1M" ? 22 : timeRange === "3M" ? 65 : 130;
return historyPoints.slice(-pointsCount);
}, [historyPoints, timeRange]);
// Statistics calculation for the chart
const stats = useMemo(() => {
if (!displayPoints.length) return null;
const cashVals = displayPoints.map((p) => p.cash).filter((v) => v > 0);
const stockVals = displayPoints.map((p) => p.stocks).filter((v) => v > 0);
if (!cashVals.length) return null;
const maxCash = Math.max(...cashVals);
const minCash = Math.min(...cashVals);
const avgCash = cashVals.reduce((a, b) => a + b, 0) / cashVals.length;
const firstCash = cashVals[0];
const lastCash = cashVals[cashVals.length - 1];
const changeCash = lastCash - firstCash;
const changePct = firstCash > 0 ? (changeCash / firstCash) * 100 : 0;
const maxStock = stockVals.length ? Math.max(...stockVals) : 0;
const minStock = stockVals.length ? Math.min(...stockVals) : 0;
return {
maxCash,
minCash,
avgCash,
changeCash,
changePct,
maxStock,
minStock,
};
}, [displayPoints]);
const activeStyle = selectedMetal ? (METAL_STYLES[selectedMetal.metal] || METAL_STYLES.Copper) : METAL_STYLES.Copper;
return ( return (
<main className="mx-auto max-w-6xl p-4 sm:p-6 space-y-6" dir="rtl"> <main className="mx-auto max-w-6xl p-4 sm:p-6 space-y-6" dir="rtl">
{/* Clean Header */} {/* Clean Header */}
@ -104,7 +224,7 @@ export function LmeView() {
بورس فلزات لندن (LME) بورس فلزات لندن (LME)
</h1> </h1>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
قیمتهای رسمی نقدی، قراردادهای ۳ ماهه و موجودی انبارها قیمتهای رسمی نقدی، قراردادهای ۳ ماهه و موجودی انبارها برای مشاهده نمودار روی هر کارت کلیک کنید
</p> </p>
</div> </div>
</div> </div>
@ -122,7 +242,7 @@ export function LmeView() {
</div> </div>
)} )}
{/* KPI Cards Grid */} {/* KPI Cards Grid - Clickable */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{data?.metals.map((m) => { {data?.metals.map((m) => {
const style = METAL_STYLES[m.metal] || { const style = METAL_STYLES[m.metal] || {
@ -134,20 +254,29 @@ export function LmeView() {
return ( return (
<div <div
key={m.metal} key={m.metal}
className={`relative overflow-hidden rounded-2xl border ${style.border} bg-gradient-to-b ${style.bg} bg-card/40 p-5 backdrop-blur-md transition-all duration-200 hover:shadow-md hover:-translate-y-0.5`} onClick={() => setSelectedMetal(m)}
className={`group cursor-pointer relative overflow-hidden rounded-2xl border ${style.border} bg-gradient-to-b ${style.bg} bg-card/40 p-5 backdrop-blur-md transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-primary/60`}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<div className="text-base font-bold text-foreground">{m.name_fa}</div> <div className="text-base font-bold text-foreground group-hover:text-primary transition-colors">
{m.name_fa}
</div>
<div className="text-xs text-muted-foreground font-mono mt-0.5">{m.metal} USD/mt</div> <div className="text-xs text-muted-foreground font-mono mt-0.5">{m.metal} USD/mt</div>
</div> </div>
<div className="flex items-center gap-1 text-xs font-mono font-medium text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="text-[11px] opacity-0 group-hover:opacity-100 transition-opacity text-primary font-medium flex items-center gap-0.5">
نمودار
<Maximize2 className="w-3 h-3" />
</span>
<div className="flex items-center gap-1 font-mono font-medium">
<ArrowUpDown className="w-3.5 h-3.5" /> <ArrowUpDown className="w-3.5 h-3.5" />
<span className={m.spread < 0 ? "text-amber-500" : "text-emerald-500"}> <span className={m.spread < 0 ? "text-amber-500" : "text-emerald-500"}>
{m.spread_str} $ {m.spread_str} $
</span> </span>
</div> </div>
</div> </div>
</div>
{/* Prices Section */} {/* Prices Section */}
<div className="mt-4 grid grid-cols-2 gap-3 pt-3 border-t border-border/40"> <div className="mt-4 grid grid-cols-2 gap-3 pt-3 border-t border-border/40">
@ -189,7 +318,7 @@ export function LmeView() {
})} })}
</div> </div>
{/* Main Quotations Table */} {/* Main Quotations Table - Clickable Rows */}
<div className="rounded-2xl border border-border/60 bg-card/60 backdrop-blur-xl shadow-sm overflow-hidden"> <div className="rounded-2xl border border-border/60 bg-card/60 backdrop-blur-xl shadow-sm overflow-hidden">
<div className="p-4 sm:p-5 border-b border-border/60 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div className="p-4 sm:p-5 border-b border-border/60 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -218,11 +347,16 @@ export function LmeView() {
<th className="py-3 px-4">اسپرد (3M - Cash)</th> <th className="py-3 px-4">اسپرد (3M - Cash)</th>
<th className="py-3 px-4">موجودی انبارها (Stocks)</th> <th className="py-3 px-4">موجودی انبارها (Stocks)</th>
<th className="py-3 px-4">تغییرات موجودی</th> <th className="py-3 px-4">تغییرات موجودی</th>
<th className="py-3 px-4 text-center">عملیات</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border/40"> <tbody className="divide-y divide-border/40">
{filteredMetals.map((m) => ( {filteredMetals.map((m) => (
<tr key={m.metal} className="transition-colors hover:bg-muted/30"> <tr
key={m.metal}
onClick={() => setSelectedMetal(m)}
className="cursor-pointer transition-colors hover:bg-muted/50"
>
<td className="py-3 px-4"> <td className="py-3 px-4">
<div className="font-semibold text-foreground">{m.name_fa}</div> <div className="font-semibold text-foreground">{m.name_fa}</div>
<div className="text-[10px] text-muted-foreground font-mono">{m.metal}</div> <div className="text-[10px] text-muted-foreground font-mono">{m.metal}</div>
@ -253,6 +387,12 @@ export function LmeView() {
{m.stocks_change_str} تن {m.stocks_change_str} تن
</span> </span>
</td> </td>
<td className="py-3 px-4 text-center">
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-primary/10 text-primary text-[11px] font-medium transition hover:bg-primary/20">
<LineChartIcon className="w-3 h-3" />
<span>نمودار</span>
</span>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -262,7 +402,6 @@ export function LmeView() {
{/* Side Panels: FX Rates and Precious Metals */} {/* Side Panels: FX Rates and Precious Metals */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Exchange Rates Panel */}
<div className="rounded-2xl border border-border/60 bg-card/60 p-5 backdrop-blur-xl shadow-sm space-y-3"> <div className="rounded-2xl border border-border/60 bg-card/60 p-5 backdrop-blur-xl shadow-sm space-y-3">
<div className="flex items-center justify-between border-b border-border/50 pb-3"> <div className="flex items-center justify-between border-b border-border/50 pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -285,7 +424,6 @@ export function LmeView() {
</div> </div>
</div> </div>
{/* Precious Metals Panel */}
<div className="rounded-2xl border border-border/60 bg-card/60 p-5 backdrop-blur-xl shadow-sm space-y-3"> <div className="rounded-2xl border border-border/60 bg-card/60 p-5 backdrop-blur-xl shadow-sm space-y-3">
<div className="flex items-center justify-between border-b border-border/50 pb-3"> <div className="flex items-center justify-between border-b border-border/50 pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -308,6 +446,295 @@ export function LmeView() {
</div> </div>
</div> </div>
</div> </div>
{/* FULL HISTORY CHART MODAL */}
{selectedMetal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div
className="relative w-full max-w-5xl max-h-[90vh] overflow-y-auto rounded-3xl border border-border/80 bg-background/95 p-6 shadow-2xl backdrop-blur-2xl space-y-6"
dir="rtl"
>
{/* Modal Header */}
<div className="flex items-start justify-between border-b border-border/60 pb-4">
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-2xl grid place-items-center border shrink-0"
style={{ borderColor: activeStyle.primaryColor, backgroundColor: `${activeStyle.primaryColor}15` }}
>
<Globe className="w-6 h-6" style={{ color: activeStyle.primaryColor }} />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl sm:text-2xl font-bold text-foreground">
{selectedMetal.name_fa}
</h2>
<span className="text-xs font-mono text-muted-foreground bg-muted/60 px-2 py-0.5 rounded-md">
{selectedMetal.metal}
</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
نمودار کامل روند تاریخی قیمت نقدی، قراردادهای ۳ ماهه و موجودی انبارها
</p>
</div>
</div>
<button
onClick={() => setSelectedMetal(null)}
className="rounded-xl border border-border/60 bg-muted/40 p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground"
aria-label="بستن"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Quick KPI Row in Modal */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
<div className="text-[11px] text-muted-foreground">قیمت نقدی (Cash)</div>
<div className="text-lg font-bold font-mono text-foreground mt-1">
${selectedMetal.cash_str}
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
<div className="text-[11px] text-muted-foreground">قرارداد ۳ ماهه (3M)</div>
<div className="text-lg font-bold font-mono text-foreground mt-1">
${selectedMetal.three_months_str}
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
<div className="text-[11px] text-muted-foreground">اسپرد نقدی با ۳ ماهه</div>
<div
className={`text-lg font-bold font-mono mt-1 ${
selectedMetal.spread < 0 ? "text-amber-500" : "text-emerald-500"
}`}
>
{selectedMetal.spread_str} $
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
<div className="text-[11px] text-muted-foreground">موجودی انبار LME</div>
<div className="text-lg font-bold font-mono text-foreground mt-1">
{selectedMetal.stocks_str} تن
</div>
</div>
</div>
{/* Controls Bar: Timeframe & Metric Toggle */}
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
{/* Metric Switcher */}
<div className="flex items-center gap-1 rounded-xl border border-border/60 bg-muted/40 p-1">
<button
onClick={() => setChartMode("price")}
className={`px-3 py-1 text-xs font-semibold rounded-lg transition ${
chartMode === "price"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
قیمت نقدی و ۳ ماهه
</button>
<button
onClick={() => setChartMode("stocks")}
className={`px-3 py-1 text-xs font-semibold rounded-lg transition ${
chartMode === "stocks"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
موجودی انبارها (Stocks)
</button>
</div>
{/* Timeframe Buttons */}
<div className="flex items-center gap-1 rounded-xl border border-border/60 bg-muted/40 p-1">
{(["1M", "3M", "6M", "ALL"] as const).map((r) => (
<button
key={r}
onClick={() => setTimeRange(r)}
className={`px-3 py-1 text-xs font-mono font-semibold rounded-lg transition ${
timeRange === r
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{r === "ALL" ? "همه (YTD)" : r}
</button>
))}
</div>
</div>
{/* Chart Area */}
<div className="rounded-2xl border border-border/60 bg-card/40 p-4 sm:p-5">
{historyLoading ? (
<div className="h-72 flex flex-col items-center justify-center gap-2 text-muted-foreground text-sm">
<Loader2 className="w-7 h-7 animate-spin text-primary" />
<span>در حال دریافت تاریخچه معاملات...</span>
</div>
) : displayPoints.length === 0 ? (
<div className="h-72 flex items-center justify-center text-muted-foreground text-sm">
دادهای برای این بازه یافت نشد.
</div>
) : (
<div className="h-72 sm:h-80 w-full" dir="ltr">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={displayPoints} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<defs>
<linearGradient id="colorCash" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={activeStyle.primaryColor} stopOpacity={0.3} />
<stop offset="95%" stopColor={activeStyle.primaryColor} stopOpacity={0.0} />
</linearGradient>
<linearGradient id="colorStocks" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" opacity={0.15} />
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
tickFormatter={(val) => val?.slice(5) || ""}
/>
<YAxis
domain={["auto", "auto"]}
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
tickFormatter={(v) => v.toLocaleString()}
orientation="right"
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
borderColor: "var(--border)",
borderRadius: "0.75rem",
direction: "rtl",
fontSize: "12px",
}}
formatter={(val: any, name: any) => {
const num = typeof val === "number" ? val.toLocaleString() : val;
if (name === "cash") return [`$${num}`, "تسویه نقدی"];
if (name === "three_months") return [`$${num}`, "۳ ماهه"];
if (name === "stocks") return [`${num} تن`, "موجودی انبار"];
return [num, name];
}}
labelFormatter={(l) => `تاریخ: ${l}`}
/>
<Legend
formatter={(value) => {
if (value === "cash") return "تسویه نقدی (Cash)";
if (value === "three_months") return "قرارداد ۳ ماهه (3-Month)";
if (value === "stocks") return "موجودی انبارها (Stocks mt)";
return value;
}}
/>
{chartMode === "price" ? (
<>
<Area
type="monotone"
dataKey="cash"
stroke={activeStyle.primaryColor}
strokeWidth={2}
fillOpacity={1}
fill="url(#colorCash)"
/>
<Line
type="monotone"
dataKey="three_months"
stroke={activeStyle.secondaryColor}
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
/>
</>
) : (
<Area
type="monotone"
dataKey="stocks"
stroke="#3b82f6"
strokeWidth={2}
fillOpacity={1}
fill="url(#colorStocks)"
/>
)}
</ComposedChart>
</ResponsiveContainer>
</div>
)}
{/* Stats Footer */}
{stats && (
<div className="mt-4 pt-3 border-t border-border/40 grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<div>
<span className="text-muted-foreground">بالاترین قیمت دوره: </span>
<span className="font-mono font-bold text-foreground">${stats.maxCash.toLocaleString()}</span>
</div>
<div>
<span className="text-muted-foreground">پایینترین قیمت دوره: </span>
<span className="font-mono font-bold text-foreground">${stats.minCash.toLocaleString()}</span>
</div>
<div>
<span className="text-muted-foreground">میانگین دوره: </span>
<span className="font-mono font-bold text-foreground">
${Math.round(stats.avgCash).toLocaleString()}
</span>
</div>
<div>
<span className="text-muted-foreground">تغییر در دوره: </span>
<span
className={`font-mono font-bold ${
stats.changeCash >= 0 ? "text-emerald-500" : "text-rose-500"
}`}
>
{stats.changeCash >= 0 ? "+" : ""}${stats.changeCash.toLocaleString()} ({stats.changePct.toFixed(1)}%)
</span>
</div>
</div>
)}
</div>
{/* Historical Data Table Inside Modal */}
<div className="rounded-2xl border border-border/60 bg-card/40 overflow-hidden">
<div className="p-4 border-b border-border/60 flex items-center justify-between">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">
جدول روزشمار قیمتهای تاریخی ({displayPoints.length} روز کاری)
</h3>
</div>
</div>
<div className="max-h-60 overflow-y-auto">
<table className="w-full text-right text-xs">
<thead className="sticky top-0 bg-muted/80 backdrop-blur-md text-muted-foreground font-medium border-b border-border/60">
<tr>
<th className="py-2.5 px-4">تاریخ</th>
<th className="py-2.5 px-4">تسویه نقدی (Cash)</th>
<th className="py-2.5 px-4">۳ ماهه (3-Month)</th>
<th className="py-2.5 px-4">اسپرد (3M - Cash)</th>
<th className="py-2.5 px-4">موجودی انبارها</th>
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{[...displayPoints].reverse().map((p, idx) => (
<tr key={idx} className="hover:bg-muted/30">
<td className="py-2 px-4 font-mono text-muted-foreground">{p.date_display}</td>
<td className="py-2 px-4 font-mono font-bold text-foreground">${p.cash_str}</td>
<td className="py-2 px-4 font-mono font-medium text-foreground">${p.three_months_str}</td>
<td
className={`py-2 px-4 font-mono font-semibold ${
p.spread < 0 ? "text-amber-500" : "text-emerald-500"
}`}
>
{p.spread > 0 ? "+" : ""}{p.spread} $
</td>
<td className="py-2 px-4 font-mono text-muted-foreground">{p.stocks_str} تن</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
</main> </main>
); );
} }