import { useState, useEffect, useMemo } from "react"; import { Search, Factory, LineChart as LineChartIcon, X, Loader2, Calendar, Building2, TrendingUp, ArrowUpRight } from "lucide-react"; import { ResponsiveContainer, ComposedChart, Area, XAxis, YAxis, Tooltip, CartesianGrid } from "recharts"; import { API_BASE } from "@/lib/api"; const API = API_BASE; export interface ImePricePoint { id: number; date: string; // YYYY-MM-DD high: number; low: number; mid: number; } export interface ImeSymbol { id: number; title: string; product_name: string; manufacturer: string; is_active: boolean; updated_at: string; latest_price?: ImePricePoint; } 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 ImeSymbolDetail { id: number; cat_id: number; title: string; manufacturer?: 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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Filters const [selectedRootId, setSelectedRootId] = useState("ALL"); const [selectedSubId, setSelectedSubId] = useState("ALL"); const [search, setSearch] = useState(""); // Subcategory price cache (populated when subcategory is clicked) const [subPricesMap, setSubPricesMap] = useState>>({}); const [subLoading, setSubLoading] = useState(false); // Modal Detail const [selectedSymbol, setSelectedSymbol] = useState(null); const [symbolDetail, setSymbolDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [timeframe, setTimeframe] = useState("ENTIRE"); // Load categories once on mount (no aggressive interval) 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; }; }, []); // When a specific subcategory is selected, fetch all its symbols with latest prices attached useEffect(() => { if (selectedSubId === "ALL") return; let alive = true; setSubLoading(true); fetch(`${API}/api/ime/subcategory/${selectedSubId}/symbols`, { credentials: "include" }) .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) .then((symbols: Array) => { if (!alive) return; const pMap: Record = {}; for (const s of symbols) { if (s.latest_price) { pMap[s.id] = s.latest_price; } } setSubPricesMap((prev) => ({ ...prev, [selectedSubId as number]: pMap })); }) .catch(() => {}) .finally(() => { if (alive) setSubLoading(false); }); return () => { alive = false; }; }, [selectedSubId]); // Fetch symbol detail & full 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 (100% clean of English codes) const filteredSymbols = useMemo(() => { let list: Array = []; 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) { // Look up latest price from subPricesMap if available const priceObj = subPricesMap[sub.id]?.[sym.id] || sym.latest_price; list.push({ ...sym, latest_price: priceObj, rootTitle: root.title, subTitle: sub.title, subId: sub.id }); } } } 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.manufacturer.toLowerCase().includes(q) || s.rootTitle.toLowerCase().includes(q) || s.subTitle.toLowerCase().includes(q) ); }, [categories, selectedRootId, selectedSubId, search, subPricesMap]); // 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 (
{/* Header */}

بورس فلزات ایران (IME)

تابلوی معاملات فیزیکی بورس کالا • آخرین نرخ‌های کشف‌شده و آرشیو تاریخی

{/* Global Search */}
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" />
{error && (
{error}
)} {/* Root Category Navigation Tabs */}
{categories.map((cat) => ( ))}
{/* Subcategory Pills */} {currentSubcategories.length > 0 && (
{currentSubcategories.map((sub) => ( ))}
)} {/* Indicator */}
نمایش {filteredSymbols.length.toLocaleString()} نماد معاملاتی {subLoading && ( در حال بارگذاری قیمت‌های زیردسته... )}
{/* Loading state */} {loading ? (
در حال دریافت نمادهای بورس فلزات ایران...
) : filteredSymbols.length === 0 ? (
نمادی با این مشخصات یافت نشد.
) : ( /* Symbols Cards Grid */
{filteredSymbols.slice(0, 120).map((sym) => (
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" >
{sym.product_name || sym.title}
{sym.manufacturer && (
{sym.manufacturer}
)}
{/* LATEST PRICE PROMINENTLY DISPLAYED ON CARD FACE */}
آخرین قیمت:
{sym.latest_price?.mid ? (
{sym.latest_price.mid.toLocaleString()} ریال
{(Math.round(sym.latest_price.mid / 10)).toLocaleString()} تومان
) : ( مشاهده سابقه )}
{sym.subTitle || sym.rootTitle} نمودار کامل
))}
)} {/* FULL HISTORICAL MODAL FOR IME SYMBOL */} {selectedSymbol && (
{/* Modal Header */}

{selectedSymbol.product_name || selectedSymbol.title}

{selectedSymbol.manufacturer && (
{selectedSymbol.manufacturer}
)}
{/* Quick Stats Pills */} {stats && (
آخرین قیمت کشف‌شده
{stats.last.toLocaleString()} ریال
{(Math.round(stats.last / 10)).toLocaleString()} تومان
بالاترین قیمت دوره
{stats.max.toLocaleString()} ریال
پایین‌ترین قیمت دوره
{stats.min.toLocaleString()} ریال
تغییر در بازه انتخابی
= 0 ? "text-emerald-500" : "text-rose-500" }`} > {stats.diff >= 0 ? "+" : ""} {stats.diff.toLocaleString()} ({stats.pct.toFixed(1)}%)
)} {/* Chart Area */}
{detailLoading ? (
در حال دریافت سابقه قیمت‌های معاملاتی بورس کالا...
) : chartPoints.length === 0 ? (
اطلاعات قیمتی برای این نماد ثبت نشده است.
) : (
`${(v / 1000).toLocaleString()}k`} orientation="right" /> { if (!active || !payload?.length) return null; const pt = payload[0].payload as ImePricePoint; return (
تاریخ معامله: {pt.date}
قیمت پایانی: {pt.mid.toLocaleString()} ریال
معادل {(Math.round(pt.mid / 10)).toLocaleString()} تومان
); }} />
)} {/* Timeframe selector bar */}
)}
); }