feat(ime): add Iran Mercantile Exchange (بورس فلزات ایران) tab with categories, 1250+ symbols, and historical price charts
This commit is contained in:
parent
2b22b032c1
commit
cf2ab562cc
|
|
@ -0,0 +1,132 @@
|
|||
import urllib.request
|
||||
import json
|
||||
import ssl
|
||||
import time
|
||||
import threading
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
IME_TOKEN = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiMDkzNjMxNDAyNjIiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjE0ODgiLCJleHAiOjE3ODgzMjQ5MzN9.9r-XUKmRUBQ6uEOZGq09l474ZaDDPazNVUDHuXY2_QmBtS2RU78xJLt753GaGU2SZtpndzNYKWDYaoc58SNyAA"
|
||||
BASE_URL = "https://api.yektazob.com"
|
||||
|
||||
_cache_categories = {
|
||||
"data": None,
|
||||
"last_fetched": 0
|
||||
}
|
||||
_cache_symbols = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
HEADERS = {
|
||||
"Authorization": f"Bearer {IME_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
}
|
||||
|
||||
def get_ime_categories(ttl_seconds: int = 3600) -> List[Dict[str, Any]]:
|
||||
now = time.time()
|
||||
with _lock:
|
||||
if _cache_categories["data"] and (now - _cache_categories["last_fetched"]) < ttl_seconds:
|
||||
return _cache_categories["data"]
|
||||
|
||||
url = f"{BASE_URL}/Symbols/IME/Categories/GetAll"
|
||||
payload = json.dumps({"PageNumber": 1, "PageSize": 1000}).encode('utf-8')
|
||||
req = urllib.request.Request(url, data=payload, headers=HEADERS)
|
||||
|
||||
with urllib.request.urlopen(req, context=ctx, timeout=12) as resp:
|
||||
raw_cats = json.loads(resp.read().decode('utf-8'))
|
||||
|
||||
cleaned = []
|
||||
for root in raw_cats:
|
||||
root_title = root.get("title", "")
|
||||
root_id = root.get("id")
|
||||
subs = []
|
||||
for sub in root.get("children", []):
|
||||
sub_id = sub.get("id")
|
||||
sub_title = sub.get("title", "")
|
||||
symbols = []
|
||||
for s in sub.get("symbols", []):
|
||||
s_title = s.get("title", "")
|
||||
parts = [p.strip() for p in s_title.split(" - ") if p.strip()]
|
||||
product_name = s.get("productName") or (parts[0] if len(parts) > 0 else s_title)
|
||||
symbol_code = s.get("symbol") or (parts[1] if len(parts) > 1 else "")
|
||||
manufacturer = s.get("manufacturer") or (parts[2] if len(parts) > 2 else "")
|
||||
|
||||
symbols.append({
|
||||
"id": s.get("id"),
|
||||
"title": s_title,
|
||||
"product_name": product_name,
|
||||
"symbol_code": symbol_code,
|
||||
"manufacturer": manufacturer,
|
||||
"is_active": s.get("isActive", True),
|
||||
"updated_at": s.get("updatedAt", "")
|
||||
})
|
||||
|
||||
subs.append({
|
||||
"id": sub_id,
|
||||
"title": sub_title,
|
||||
"symbols_count": len(symbols),
|
||||
"symbols": symbols
|
||||
})
|
||||
|
||||
total_symbols = sum(s["symbols_count"] for s in subs)
|
||||
cleaned.append({
|
||||
"id": root_id,
|
||||
"title": root_title,
|
||||
"subcategories_count": len(subs),
|
||||
"total_symbols": total_symbols,
|
||||
"subcategories": subs
|
||||
})
|
||||
|
||||
with _lock:
|
||||
_cache_categories["data"] = cleaned
|
||||
_cache_categories["last_fetched"] = now
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
def get_ime_symbol_detail(symbol_id: int, ttl_seconds: int = 1800) -> Dict[str, Any]:
|
||||
now = time.time()
|
||||
with _lock:
|
||||
cached = _cache_symbols.get(symbol_id)
|
||||
if cached and (now - cached["time"]) < ttl_seconds:
|
||||
return cached["data"]
|
||||
|
||||
url = f"{BASE_URL}/Symbols/IME/Symbols/Get/{symbol_id}"
|
||||
req = urllib.request.Request(url, headers=HEADERS)
|
||||
|
||||
with urllib.request.urlopen(req, context=ctx, timeout=12) as resp:
|
||||
raw_detail = json.loads(resp.read().decode('utf-8'))
|
||||
|
||||
raw_prices = raw_detail.get("symbolPrices", []) or []
|
||||
sorted_prices = sorted(raw_prices, key=lambda p: p.get("date", ""))
|
||||
|
||||
clean_prices = []
|
||||
for p in sorted_prices:
|
||||
d_str = p.get("date", "")
|
||||
date_iso = d_str.split("T")[0] if "T" in d_str else d_str
|
||||
clean_prices.append({
|
||||
"id": p.get("id"),
|
||||
"date": date_iso,
|
||||
"high": p.get("high"),
|
||||
"low": p.get("low"),
|
||||
"mid": p.get("mid")
|
||||
})
|
||||
|
||||
result = {
|
||||
"id": raw_detail.get("id"),
|
||||
"cat_id": raw_detail.get("catID"),
|
||||
"title": raw_detail.get("title"),
|
||||
"order_no": raw_detail.get("orderNo"),
|
||||
"small_desc": raw_detail.get("smallDesc"),
|
||||
"total_points": len(clean_prices),
|
||||
"latest_price": clean_prices[-1] if clean_prices else None,
|
||||
"prices": clean_prices
|
||||
}
|
||||
|
||||
with _lock:
|
||||
_cache_symbols[symbol_id] = {"time": now, "data": result}
|
||||
|
||||
return result
|
||||
|
|
@ -251,6 +251,23 @@ def get_crypto(_: object = Depends(require_session)):
|
|||
|
||||
|
||||
|
||||
|
||||
import ime_metals
|
||||
|
||||
@app.get("/api/ime/categories")
|
||||
def get_ime_categories_route(_: object = Depends(require_session)):
|
||||
try:
|
||||
return ime_metals.get_ime_categories()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"IME categories error: {e}")
|
||||
|
||||
@app.get("/api/ime/symbol/{symbol_id}")
|
||||
def get_ime_symbol_route(symbol_id: int, _: object = Depends(require_session)):
|
||||
try:
|
||||
return ime_metals.get_ime_symbol_detail(symbol_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"IME symbol error: {e}")
|
||||
|
||||
@app.get("/api/lme/diagram")
|
||||
def get_lme_diagram(field: str = Query("WM_Cu_low"), _: object = Depends(require_session)):
|
||||
try:
|
||||
|
|
@ -448,6 +465,13 @@ def v1_world_economy(): return get_world_economy(None)
|
|||
|
||||
|
||||
|
||||
|
||||
@data_api.get("/ime/categories")
|
||||
def v1_ime_categories(): return ime_metals.get_ime_categories()
|
||||
|
||||
@data_api.get("/ime/symbol/{symbol_id}")
|
||||
def v1_ime_symbol(symbol_id: int): return ime_metals.get_ime_symbol_detail(symbol_id)
|
||||
|
||||
@data_api.get("/lme/diagram")
|
||||
def v1_lme_diagram(field: str = "WM_Cu_low"): return scrape_lme.get_entire_diagram_data(field)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,616 @@
|
|||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Search,
|
||||
Factory,
|
||||
Layers,
|
||||
ChevronDown,
|
||||
LineChart as LineChartIcon,
|
||||
X,
|
||||
Loader2,
|
||||
Calendar,
|
||||
Sparkles,
|
||||
ArrowUpRight,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Building2,
|
||||
Hash
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
ComposedChart,
|
||||
Area,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid
|
||||
} from "recharts";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
|
||||
const API = API_BASE;
|
||||
|
||||
export interface ImeSymbol {
|
||||
id: number;
|
||||
title: string;
|
||||
product_name: string;
|
||||
symbol_code: string;
|
||||
manufacturer: string;
|
||||
is_active: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ImeSubcategory {
|
||||
id: number;
|
||||
title: string;
|
||||
symbols_count: number;
|
||||
symbols: ImeSymbol[];
|
||||
}
|
||||
|
||||
export interface ImeCategory {
|
||||
id: number;
|
||||
title: string;
|
||||
subcategories_count: number;
|
||||
total_symbols: number;
|
||||
subcategories: ImeSubcategory[];
|
||||
}
|
||||
|
||||
export interface ImePricePoint {
|
||||
id: number;
|
||||
date: string; // YYYY-MM-DD
|
||||
high: number;
|
||||
low: number;
|
||||
mid: number;
|
||||
}
|
||||
|
||||
export interface ImeSymbolDetail {
|
||||
id: number;
|
||||
cat_id: number;
|
||||
title: string;
|
||||
order_no?: number;
|
||||
small_desc?: string;
|
||||
total_points: number;
|
||||
latest_price?: ImePricePoint;
|
||||
prices: ImePricePoint[];
|
||||
}
|
||||
|
||||
type Timeframe = "3M" | "6M" | "1Y" | "ENTIRE";
|
||||
|
||||
export function ImeView() {
|
||||
const [categories, setCategories] = useState<ImeCategory[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [selectedRootId, setSelectedRootId] = useState<number | "ALL">("ALL");
|
||||
const [selectedSubId, setSelectedSubId] = useState<number | "ALL">("ALL");
|
||||
const [search, setSearch] = useState<string>("");
|
||||
|
||||
// Modal Detail
|
||||
const [selectedSymbol, setSelectedSymbol] = useState<ImeSymbol | null>(null);
|
||||
const [symbolDetail, setSymbolDetail] = useState<ImeSymbolDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState<boolean>(false);
|
||||
const [timeframe, setTimeframe] = useState<Timeframe>("ENTIRE");
|
||||
|
||||
// Load all IME categories
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
fetch(`${API}/api/ime/categories`, { credentials: "include" })
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then((data: ImeCategory[]) => {
|
||||
if (alive) {
|
||||
setCategories(data);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (alive) setError(err.message || "خطا در دریافت اطلاعات بورس فلزات ایران");
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch symbol detail & price history when modal opens
|
||||
useEffect(() => {
|
||||
if (!selectedSymbol) {
|
||||
setSymbolDetail(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let alive = true;
|
||||
setDetailLoading(true);
|
||||
setTimeframe("ENTIRE");
|
||||
|
||||
fetch(`${API}/api/ime/symbol/${selectedSymbol.id}`, { credentials: "include" })
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then((data: ImeSymbolDetail) => {
|
||||
if (alive) setSymbolDetail(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setSymbolDetail(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setDetailLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [selectedSymbol]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setSelectedSymbol(null);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Active subcategories based on root category selection
|
||||
const currentSubcategories = useMemo(() => {
|
||||
if (selectedRootId === "ALL") {
|
||||
return [];
|
||||
}
|
||||
const cat = categories.find((c) => c.id === selectedRootId);
|
||||
return cat ? cat.subcategories : [];
|
||||
}, [categories, selectedRootId]);
|
||||
|
||||
// Flattened symbols filtered by root, subcategory, and search query
|
||||
const filteredSymbols = useMemo(() => {
|
||||
let list: Array<ImeSymbol & { rootTitle: string; subTitle: string }> = [];
|
||||
|
||||
for (const root of categories) {
|
||||
if (selectedRootId !== "ALL" && root.id !== selectedRootId) continue;
|
||||
|
||||
for (const sub of root.subcategories) {
|
||||
if (selectedSubId !== "ALL" && sub.id !== selectedSubId) continue;
|
||||
|
||||
for (const sym of sub.symbols) {
|
||||
list.push({
|
||||
...sym,
|
||||
rootTitle: root.title,
|
||||
subTitle: sub.title
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!search.trim()) return list;
|
||||
|
||||
const q = search.toLowerCase().trim();
|
||||
return list.filter(
|
||||
(s) =>
|
||||
s.title.toLowerCase().includes(q) ||
|
||||
s.product_name.toLowerCase().includes(q) ||
|
||||
s.symbol_code.toLowerCase().includes(q) ||
|
||||
s.manufacturer.toLowerCase().includes(q) ||
|
||||
s.rootTitle.toLowerCase().includes(q) ||
|
||||
s.subTitle.toLowerCase().includes(q)
|
||||
);
|
||||
}, [categories, selectedRootId, selectedSubId, search]);
|
||||
|
||||
// Filtered prices for modal chart by timeframe
|
||||
const chartPoints = useMemo(() => {
|
||||
if (!symbolDetail?.prices?.length) return [];
|
||||
const prices = symbolDetail.prices;
|
||||
const total = prices.length;
|
||||
|
||||
let sliceCount = total;
|
||||
if (timeframe === "3M") sliceCount = 15;
|
||||
else if (timeframe === "6M") sliceCount = 30;
|
||||
else if (timeframe === "1Y") sliceCount = 60;
|
||||
else if (timeframe === "ENTIRE") sliceCount = total;
|
||||
|
||||
const start = Math.max(0, total - sliceCount);
|
||||
return prices.slice(start);
|
||||
}, [symbolDetail, timeframe]);
|
||||
|
||||
// Stats calculation for the symbol modal
|
||||
const stats = useMemo(() => {
|
||||
if (!chartPoints.length) return null;
|
||||
const mids = chartPoints.map((p) => p.mid).filter((v): v is number => typeof v === "number" && v > 0);
|
||||
if (!mids.length) return null;
|
||||
|
||||
const max = Math.max(...mids);
|
||||
const min = Math.min(...mids);
|
||||
const avg = mids.reduce((a, b) => a + b, 0) / mids.length;
|
||||
const first = mids[0];
|
||||
const last = mids[mids.length - 1];
|
||||
const diff = last - first;
|
||||
const pct = first > 0 ? (diff / first) * 100 : 0;
|
||||
|
||||
return { max, min, avg, diff, pct, last };
|
||||
}, [chartPoints]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl p-4 sm:p-6 space-y-6" dir="rtl">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 border-b border-border/60 pb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 grid place-items-center shrink-0">
|
||||
<Factory className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold tracking-tight text-foreground">
|
||||
بورس فلزات ایران (IME)
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
تابلوی معاملات فیزیکی بورس کالای ایران، نرخهای کشفشده و آرشیو تاریخی
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Search */}
|
||||
<div className="relative w-full sm:w-72">
|
||||
<Search className="absolute right-3 top-2.5 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="جستجوی نماد، محصول یا تولیدکننده..."
|
||||
className="w-full rounded-xl border border-border/80 bg-background/80 py-1.5 pr-9 pl-3 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Root Category Navigation Tabs */}
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 no-scrollbar">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedRootId("ALL");
|
||||
setSelectedSubId("ALL");
|
||||
}}
|
||||
className={`px-3.5 py-1.5 text-xs font-semibold rounded-xl whitespace-nowrap transition ${
|
||||
selectedRootId === "ALL"
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "border border-border/60 bg-muted/30 text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
همه فلزات
|
||||
</button>
|
||||
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => {
|
||||
setSelectedRootId(cat.id);
|
||||
setSelectedSubId("ALL");
|
||||
}}
|
||||
className={`px-3.5 py-1.5 text-xs font-semibold rounded-xl whitespace-nowrap transition flex items-center gap-1.5 ${
|
||||
selectedRootId === cat.id
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "border border-border/60 bg-muted/30 text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span>{cat.title}</span>
|
||||
<span className="text-[10px] opacity-70">({cat.total_symbols})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Subcategory Pills (when a root is selected) */}
|
||||
{currentSubcategories.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 no-scrollbar border-t border-border/40 pt-3">
|
||||
<button
|
||||
onClick={() => setSelectedSubId("ALL")}
|
||||
className={`px-3 py-1 text-[11px] rounded-lg whitespace-nowrap transition ${
|
||||
selectedSubId === "ALL"
|
||||
? "bg-foreground text-background font-bold shadow-xs"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
همه زیردستهها
|
||||
</button>
|
||||
|
||||
{currentSubcategories.map((sub) => (
|
||||
<button
|
||||
key={sub.id}
|
||||
onClick={() => setSelectedSubId(sub.id)}
|
||||
className={`px-3 py-1 text-[11px] rounded-lg whitespace-nowrap transition flex items-center gap-1 ${
|
||||
selectedSubId === sub.id
|
||||
? "bg-foreground text-background font-bold shadow-xs"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<span>{sub.title}</span>
|
||||
<span className="text-[10px] opacity-60">({sub.symbols_count})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total symbols count indicator */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground px-1">
|
||||
<span>
|
||||
نمایش <strong className="text-foreground">{filteredSymbols.length.toLocaleString()}</strong> نماد معاملاتی
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{loading ? (
|
||||
<div className="py-20 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
<span>در حال دریافت نمادهای بورس فلزات ایران...</span>
|
||||
</div>
|
||||
) : filteredSymbols.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted-foreground text-sm border rounded-2xl border-dashed">
|
||||
نمادی با این مشخصات یافت نشد.
|
||||
</div>
|
||||
) : (
|
||||
/* Symbols Cards Grid */
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredSymbols.slice(0, 120).map((sym) => (
|
||||
<div
|
||||
key={sym.id}
|
||||
onClick={() => setSelectedSymbol(sym)}
|
||||
className="group cursor-pointer relative overflow-hidden rounded-2xl border border-border/70 bg-gradient-to-b from-card to-card/40 p-5 backdrop-blur-md transition-all duration-200 hover:shadow-xl hover:-translate-y-1 hover:border-primary flex flex-col justify-between"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-sm font-bold text-foreground group-hover:text-primary transition-colors line-clamp-2">
|
||||
{sym.product_name || sym.title}
|
||||
</div>
|
||||
{sym.symbol_code && (
|
||||
<span className="text-[10px] font-mono px-2 py-0.5 rounded-md bg-muted/80 text-muted-foreground border border-border/60 shrink-0">
|
||||
{sym.symbol_code}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sym.manufacturer && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Building2 className="w-3.5 h-3.5 text-primary/70 shrink-0" />
|
||||
<span className="truncate">{sym.manufacturer}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-border/40 flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-md bg-primary/10 text-primary font-medium">
|
||||
{sym.subTitle || sym.rootTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-[11px] font-medium text-primary flex items-center gap-1 group-hover:translate-x-[-2px] transition-transform">
|
||||
<span>نمودار سابقه</span>
|
||||
<ArrowUpRight className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FULL HISTORICAL MODAL FOR IME SYMBOL */}
|
||||
{selectedSymbol && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6 bg-black/75 backdrop-blur-md animate-in fade-in duration-200">
|
||||
<div
|
||||
className="relative w-full max-w-4xl max-h-[92vh] overflow-y-auto rounded-3xl border border-border/80 bg-background/95 p-5 sm:p-7 shadow-2xl backdrop-blur-2xl space-y-5"
|
||||
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 bg-primary/10 border-primary/30">
|
||||
<Factory className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg sm:text-xl font-bold text-foreground">
|
||||
{selectedSymbol.product_name || selectedSymbol.title}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
{selectedSymbol.manufacturer && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Building2 className="w-3 h-3 text-primary/70" />
|
||||
{selectedSymbol.manufacturer}
|
||||
</span>
|
||||
)}
|
||||
{selectedSymbol.symbol_code && (
|
||||
<span className="text-xs font-mono bg-muted/60 px-2 py-0.5 rounded text-muted-foreground">
|
||||
{selectedSymbol.symbol_code}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedSymbol(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 Stats Pills */}
|
||||
{stats && (
|
||||
<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">آخرین قیمت (ریال/کیلوگرم)</div>
|
||||
<div className="text-base sm:text-lg font-bold font-mono text-foreground mt-0.5">
|
||||
{stats.last.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{(stats.last / 10).toLocaleString()} تومان
|
||||
</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-base sm:text-lg font-bold font-mono text-foreground mt-0.5">
|
||||
{stats.max.toLocaleString()}
|
||||
</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-base sm:text-lg font-bold font-mono text-foreground mt-0.5">
|
||||
{stats.min.toLocaleString()}
|
||||
</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-base sm:text-lg font-bold font-mono mt-0.5 ${
|
||||
stats.diff >= 0 ? "text-emerald-500" : "text-rose-500"
|
||||
}`}
|
||||
>
|
||||
{stats.diff >= 0 ? "+" : ""}
|
||||
{stats.diff.toLocaleString()} ({stats.pct.toFixed(1)}%)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart Area */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/40 p-4 sm:p-5 space-y-4">
|
||||
{detailLoading ? (
|
||||
<div className="h-72 flex flex-col items-center justify-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
<span>در حال دریافت سابقه قیمتهای معاملاتی بورس کالا...</span>
|
||||
</div>
|
||||
) : chartPoints.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={chartPoints} margin={{ top: 10, right: 15, left: 10, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="imePriceGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#2563eb" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#2563eb" stopOpacity={0.0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="2 2" opacity={0.15} vertical={true} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
|
||||
minTickGap={35}
|
||||
/>
|
||||
<YAxis
|
||||
domain={["auto", "auto"]}
|
||||
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
|
||||
tickFormatter={(v) => `${(v / 1000).toLocaleString()}k`}
|
||||
orientation="right"
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const pt = payload[0].payload as ImePricePoint;
|
||||
return (
|
||||
<div className="rounded-xl border border-border/80 bg-card/95 p-3 text-foreground shadow-2xl backdrop-blur-md space-y-1 min-w-[160px]" dir="rtl">
|
||||
<div className="text-[11px] text-muted-foreground font-mono pb-1 border-b border-border/40">
|
||||
تاریخ: {pt.date}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs pt-1">
|
||||
<span className="text-muted-foreground">میانگین موزون:</span>
|
||||
<span className="font-mono font-bold text-primary">
|
||||
{pt.mid.toLocaleString()} ریال
|
||||
</span>
|
||||
</div>
|
||||
{pt.high !== pt.mid && (
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>بالاترین:</span>
|
||||
<span className="font-mono">{pt.high.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{pt.low !== pt.mid && (
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>پایینترین:</span>
|
||||
<span className="font-mono">{pt.low.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="mid"
|
||||
name="قیمت پایانی"
|
||||
stroke="#2563eb"
|
||||
strokeWidth={2}
|
||||
fill="url(#imePriceGrad)"
|
||||
dot={{ r: 2 }}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeframe selector bar */}
|
||||
<div className="pt-3 border-t border-border/40 flex flex-wrap items-center justify-center gap-2" dir="rtl">
|
||||
<button
|
||||
onClick={() => setTimeframe("3M")}
|
||||
className={`rounded-xl border px-4 py-1.5 text-xs font-semibold transition ${
|
||||
timeframe === "3M"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
۳ ماه
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setTimeframe("6M")}
|
||||
className={`rounded-xl border px-4 py-1.5 text-xs font-semibold transition ${
|
||||
timeframe === "6M"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
۶ ماه
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setTimeframe("1Y")}
|
||||
className={`rounded-xl border px-4 py-1.5 text-xs font-semibold transition ${
|
||||
timeframe === "1Y"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
۱ سال
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setTimeframe("ENTIRE")}
|
||||
className={`rounded-xl border px-5 py-1.5 text-xs font-bold transition ${
|
||||
timeframe === "ENTIRE"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-md ring-2 ring-primary/20"
|
||||
: "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
کل دادهها (تاریخچه کامل)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -76,6 +76,8 @@ import { API_BASE } from "@/lib/api";
|
|||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { TubelightNavbar } from "@/components/TubelightNavbar";
|
||||
import { LmeView } from "@/components/LmeView";
|
||||
import { ImeView } from "@/components/ImeView";
|
||||
import { Building2 } from "lucide-react";
|
||||
|
||||
const API = API_BASE; // dev: "" (same-origin via Vite proxy); prod: VITE_API_BASE
|
||||
|
||||
|
|
@ -89,12 +91,13 @@ export const Route = createFileRoute("/dashboard")({
|
|||
component: Dashboard,
|
||||
});
|
||||
|
||||
type View = "domestic" | "commodity" | "metals" | "lme" | "crypto" | "heatmap" | "steel" | "reports";
|
||||
type View = "domestic" | "commodity" | "metals" | "lme" | "ime" | "crypto" | "heatmap" | "steel" | "reports";
|
||||
const VIEW_NAV: { key: View; name: string; icon: LucideIcon }[] = [
|
||||
{ key: "domestic", name: "بازار داخلی", icon: Banknote },
|
||||
{ key: "commodity", name: "کامودیتی", icon: Flame },
|
||||
{ key: "metals", name: "فلزات و فولاد", icon: Factory },
|
||||
{ key: "lme", name: "LME", icon: Globe },
|
||||
{ key: "ime", name: "بورس فلزات ایران", icon: Building2 },
|
||||
{ key: "crypto", name: "کریپتو", icon: Bitcoin },
|
||||
{ key: "heatmap", name: "اقتصاد جهانی", icon: LayoutGrid },
|
||||
{ key: "steel", name: "سهام فولادی", icon: BarChart3 },
|
||||
|
|
@ -216,6 +219,7 @@ function Dashboard() {
|
|||
{view === "commodity" && <CommodityView />}
|
||||
{view === "metals" && <MetalsView jumpTo={jumpTo} onConsumeJump={() => setJumpTo(null)} />}
|
||||
{view === "lme" && <LmeView />}
|
||||
{view === "ime" && <ImeView />}
|
||||
{view === "crypto" && <CryptoView />}
|
||||
{view === "heatmap" && <WorldEconomyView />}
|
||||
{view === "steel" && <SteelStocksView />}
|
||||
|
|
|
|||
Loading…
Reference in New Issue