feat(lme): interactive modal chart with full history on card click
This commit is contained in:
parent
9b1daf6f76
commit
a857102b98
|
|
@ -249,6 +249,14 @@ def get_crypto(_: object = Depends(require_session)):
|
|||
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")
|
||||
def get_lme(_: object = Depends(require_session)):
|
||||
try:
|
||||
|
|
@ -430,6 +438,10 @@ def v1_crypto(): return get_crypto(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")
|
||||
def v1_lme(): return scrape_lme.get_lme_data()
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,40 @@ _cache = {
|
|||
"data": None,
|
||||
"last_fetched": 0
|
||||
}
|
||||
_history_cache = {}
|
||||
_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:
|
||||
if not val:
|
||||
return 0.0
|
||||
|
|
@ -25,16 +57,18 @@ def clean_num(val: str) -> float:
|
|||
except:
|
||||
return 0.0
|
||||
|
||||
FA_NAMES = {
|
||||
"Copper": "مس (Copper)",
|
||||
"Tin": "قلع (Tin)",
|
||||
"Lead": "سرب (Lead)",
|
||||
"Zinc": "روی (Zinc)",
|
||||
"Aluminium": "آلومینیوم (Aluminium)",
|
||||
"Nickel": "نیکل (Nickel)",
|
||||
"Aluminium Alloy": "آلیاژ آلومینیوم",
|
||||
"NASAAC": "نازاک (NASAAC)"
|
||||
}
|
||||
def parse_date_iso(raw: str) -> str:
|
||||
# Example: "25. August 2026"
|
||||
try:
|
||||
parts = raw.replace('.', '').split()
|
||||
if len(parts) >= 3:
|
||||
day = parts[0].zfill(2)
|
||||
month = MONTHS_MAP.get(parts[1].lower(), "01")
|
||||
year = parts[2]
|
||||
return f"{year}-{month}-{day}"
|
||||
except:
|
||||
pass
|
||||
return raw
|
||||
|
||||
def fetch_raw():
|
||||
url = 'https://www.westmetall.com/en/markdaten.php'
|
||||
|
|
@ -156,3 +190,66 @@ def get_lme_data(ttl_seconds: int = 180):
|
|||
return _cache["data"]
|
||||
raise e
|
||||
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(' ', ' ').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
|
||||
|
|
|
|||
|
|
@ -6,8 +6,26 @@ import {
|
|||
DollarSign,
|
||||
Layers,
|
||||
ArrowUpDown,
|
||||
Globe
|
||||
Globe,
|
||||
X,
|
||||
LineChart as LineChartIcon,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Calendar,
|
||||
Loader2,
|
||||
Maximize2
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
ComposedChart,
|
||||
Line,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Legend
|
||||
} from "recharts";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
|
||||
const API = API_BASE;
|
||||
|
|
@ -28,6 +46,18 @@ export interface LmeMetal {
|
|||
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 {
|
||||
pair: string;
|
||||
rate: number;
|
||||
|
|
@ -48,13 +78,13 @@ export interface LmeData {
|
|||
precious_metals: LmePrecious[];
|
||||
}
|
||||
|
||||
const METAL_STYLES: Record<string, { border: string; bg: string; text: string }> = {
|
||||
Copper: { border: "border-amber-500/30", bg: "from-amber-500/10 to-transparent", text: "text-amber-500" },
|
||||
Aluminium: { border: "border-sky-500/30", bg: "from-sky-500/10 to-transparent", text: "text-sky-500" },
|
||||
Zinc: { border: "border-emerald-500/30", bg: "from-emerald-500/10 to-transparent", text: "text-emerald-500" },
|
||||
Nickel: { border: "border-purple-500/30", bg: "from-purple-500/10 to-transparent", text: "text-purple-500" },
|
||||
Lead: { border: "border-slate-500/30", bg: "from-slate-500/10 to-transparent", text: "text-slate-400" },
|
||||
Tin: { border: "border-cyan-500/30", bg: "from-cyan-500/10 to-transparent", text: "text-cyan-500" },
|
||||
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", primaryColor: "#f59e0b", secondaryColor: "#10b981" },
|
||||
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", primaryColor: "#10b981", secondaryColor: "#06b6d4" },
|
||||
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", primaryColor: "#94a3b8", secondaryColor: "#38bdf8" },
|
||||
Tin: { border: "border-cyan-500/30", bg: "from-cyan-500/10 to-transparent", text: "text-cyan-500", primaryColor: "#06b6d4", secondaryColor: "#8b5cf6" },
|
||||
};
|
||||
|
||||
export function LmeView() {
|
||||
|
|
@ -62,6 +92,13 @@ export function LmeView() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
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 () => {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/lme`, { credentials: "include" });
|
||||
|
|
@ -80,6 +117,46 @@ export function LmeView() {
|
|||
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(() => {
|
||||
if (!data?.metals) return [];
|
||||
if (!search.trim()) return data.metals;
|
||||
|
|
@ -91,6 +168,49 @@ export function LmeView() {
|
|||
);
|
||||
}, [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 (
|
||||
<main className="mx-auto max-w-6xl p-4 sm:p-6 space-y-6" dir="rtl">
|
||||
{/* Clean Header */}
|
||||
|
|
@ -104,7 +224,7 @@ export function LmeView() {
|
|||
بورس فلزات لندن (LME)
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
قیمتهای رسمی نقدی، قراردادهای ۳ ماهه و موجودی انبارها
|
||||
قیمتهای رسمی نقدی، قراردادهای ۳ ماهه و موجودی انبارها • برای مشاهده نمودار روی هر کارت کلیک کنید
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -122,7 +242,7 @@ export function LmeView() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI Cards Grid */}
|
||||
{/* KPI Cards Grid - Clickable */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data?.metals.map((m) => {
|
||||
const style = METAL_STYLES[m.metal] || {
|
||||
|
|
@ -134,20 +254,29 @@ export function LmeView() {
|
|||
return (
|
||||
<div
|
||||
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>
|
||||
<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>
|
||||
<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" />
|
||||
<span className={m.spread < 0 ? "text-amber-500" : "text-emerald-500"}>
|
||||
{m.spread_str} $
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prices Section */}
|
||||
<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>
|
||||
|
||||
{/* 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="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">
|
||||
|
|
@ -218,11 +347,16 @@ export function LmeView() {
|
|||
<th className="py-3 px-4">اسپرد (3M - Cash)</th>
|
||||
<th className="py-3 px-4">موجودی انبارها (Stocks)</th>
|
||||
<th className="py-3 px-4">تغییرات موجودی</th>
|
||||
<th className="py-3 px-4 text-center">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{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">
|
||||
<div className="font-semibold text-foreground">{m.name_fa}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono">{m.metal}</div>
|
||||
|
|
@ -253,6 +387,12 @@ export function LmeView() {
|
|||
{m.stocks_change_str} تن
|
||||
</span>
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -262,7 +402,6 @@ export function LmeView() {
|
|||
|
||||
{/* Side Panels: FX Rates and Precious Metals */}
|
||||
<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="flex items-center justify-between border-b border-border/50 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -285,7 +424,6 @@ export function LmeView() {
|
|||
</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="flex items-center justify-between border-b border-border/50 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -308,6 +446,295 @@ export function LmeView() {
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue