StatistaAmeri/frontend/src/components/ImeView.tsx

655 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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>("");
// Subcategory price cache (populated when subcategory is clicked)
const [subPricesMap, setSubPricesMap] = useState<Record<number, Record<number, ImePricePoint>>>({});
const [subLoading, setSubLoading] = useState<boolean>(false);
// 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 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<ImeSymbol & { latest_price?: ImePricePoint }>) => {
if (!alive) return;
const pMap: Record<number, ImePricePoint> = {};
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<ImeSymbol & { rootTitle: string; subTitle: string; subId: number }> = [];
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 (
<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 */}
{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>
)}
{/* 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>
{subLoading && (
<span className="flex items-center gap-1 text-[11px] text-primary">
<Loader2 className="w-3 h-3 animate-spin" />
<span>در حال بارگذاری قیمتهای زیردسته...</span>
</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="text-sm font-bold text-foreground group-hover:text-primary transition-colors line-clamp-2">
{sym.product_name || sym.title}
</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>
{/* LATEST PRICE PROMINENTLY DISPLAYED ON CARD FACE */}
<div className="mt-4 pt-3 border-t border-border/40 space-y-2">
<div className="flex items-baseline justify-between">
<span className="text-[11px] text-muted-foreground">آخرین قیمت:</span>
<div className="text-left font-mono">
{sym.latest_price?.mid ? (
<div>
<div className="text-base font-bold text-primary">
{sym.latest_price.mid.toLocaleString()} <span className="text-[11px] font-normal text-muted-foreground">ریال</span>
</div>
<div className="text-[10px] text-muted-foreground">
{(Math.round(sym.latest_price.mid / 10)).toLocaleString()} تومان
</div>
</div>
) : (
<span className="text-xs font-semibold text-primary">مشاهده سابقه</span>
)}
</div>
</div>
<div className="flex items-center justify-between text-xs pt-1 border-t border-border/30">
<span className="text-[10px] px-2 py-0.5 rounded-md bg-muted/60 text-muted-foreground font-medium">
{sym.subTitle || sym.rootTitle}
</span>
<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>
))}
</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>
{selectedSymbol.manufacturer && (
<div className="text-xs text-muted-foreground flex items-center gap-1 mt-1">
<Building2 className="w-3.5 h-3.5 text-primary/70" />
<span>{selectedSymbol.manufacturer}</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">
{(Math.round(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>
<div className="text-[10px] text-muted-foreground">
معادل {(Math.round(pt.mid / 10)).toLocaleString()} تومان
</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>
);
}