| 'use client' |
|
|
| import { useState, useEffect, useRef, useCallback } from 'react' |
| import { |
| AreaChart, |
| Area, |
| XAxis, |
| YAxis, |
| CartesianGrid, |
| Tooltip, |
| ReferenceLine, |
| ReferenceDot, |
| ResponsiveContainer, |
| } from 'recharts' |
| import { TrendingUp, TrendingDown, RefreshCw, Eye } from 'lucide-react' |
|
|
| |
| interface PricePoint { |
| date: string |
| open: number |
| close: number |
| high: number |
| low: number |
| volume: number |
| } |
|
|
| interface TradeMarker { |
| type: 'BUY' | 'SELL' |
| date: string |
| price: number |
| } |
|
|
| interface PositionChartProps { |
| symbol: string |
| entryPrice: number |
| entryDate: string |
| quantity: number |
| |
| exitPrice?: number |
| exitDate?: string |
| exitReason?: string |
| |
| priceRefreshMs?: number |
| chartRefreshMs?: number |
| } |
|
|
| |
| function fmtMoney(n: number) { |
| return new Intl.NumberFormat('tr-TR', { |
| style: 'currency', |
| currency: 'TRY', |
| minimumFractionDigits: 2, |
| }).format(n) |
| } |
|
|
| |
| function ChartTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ value: number }>; label?: string }) { |
| if (!active || !payload?.length) return null |
| return ( |
| <div className="bg-gray-900 text-white text-xs px-3 py-2 rounded-lg shadow-lg border border-gray-700"> |
| <p className="text-gray-400 mb-1">{label}</p> |
| <p className="font-bold">{fmtMoney(payload[0].value)}</p> |
| </div> |
| ) |
| } |
|
|
| |
| function BuyMarker(props: { cx?: number; cy?: number }) { |
| const { cx = 0, cy = 0 } = props |
| return ( |
| <g> |
| {/* Green upward triangle */} |
| <polygon |
| points={`${cx},${cy - 12} ${cx - 8},${cy + 4} ${cx + 8},${cy + 4}`} |
| fill="#22c55e" |
| stroke="#fff" |
| strokeWidth={1.5} |
| /> |
| <text x={cx} y={cy - 16} textAnchor="middle" fill="#22c55e" fontSize={9} fontWeight="bold"> |
| AL |
| </text> |
| </g> |
| ) |
| } |
|
|
| function SellMarker(props: { cx?: number; cy?: number }) { |
| const { cx = 0, cy = 0 } = props |
| return ( |
| <g> |
| {/* Red downward triangle */} |
| <polygon |
| points={`${cx},${cy + 12} ${cx - 8},${cy - 4} ${cx + 8},${cy - 4}`} |
| fill="#ef4444" |
| stroke="#fff" |
| strokeWidth={1.5} |
| /> |
| <text x={cx} y={cy + 24} textAnchor="middle" fill="#ef4444" fontSize={9} fontWeight="bold"> |
| SAT |
| </text> |
| </g> |
| ) |
| } |
|
|
| |
| export default function PositionChart({ |
| symbol, |
| entryPrice, |
| entryDate, |
| quantity, |
| exitPrice, |
| exitDate, |
| exitReason, |
| priceRefreshMs = 10_000, |
| chartRefreshMs = 60_000, |
| }: PositionChartProps) { |
| const [priceData, setPriceData] = useState<PricePoint[]>([]) |
| const [currentPrice, setCurrentPrice] = useState<number | null>(null) |
| const [prevPrice, setPrevPrice] = useState<number | null>(null) |
| const [loading, setLoading] = useState(true) |
| const [lastUpdate, setLastUpdate] = useState<string>('') |
| const [expanded, setExpanded] = useState(true) |
| const mountedRef = useRef(true) |
|
|
| const isClosed = !!exitDate |
|
|
| |
| const fetchChart = useCallback(async () => { |
| try { |
| const res = await fetch(`/api/stocks/${symbol}/prices?days=30&interval=1d`, { |
| credentials: 'include', |
| }) |
| const json = await res.json() |
| if (!mountedRef.current) return |
| if (json.ok && json.data?.length > 0) { |
| setPriceData(json.data) |
| const last = json.data[json.data.length - 1] |
| if (last) { |
| setPrevPrice(currentPrice) |
| setCurrentPrice(last.close) |
| } |
| } |
| } catch { |
| |
| } finally { |
| if (mountedRef.current) setLoading(false) |
| } |
| }, [symbol]) |
|
|
| |
| const fetchPrice = useCallback(async () => { |
| try { |
| const res = await fetch(`/api/stocks/${symbol}/prices?days=1&interval=1d`, { |
| credentials: 'include', |
| }) |
| const json = await res.json() |
| if (!mountedRef.current) return |
| if (json.ok && json.data?.length > 0) { |
| const last = json.data[json.data.length - 1] |
| setPrevPrice(currentPrice) |
| setCurrentPrice(last.close) |
| setLastUpdate(new Date().toLocaleTimeString('tr-TR')) |
| } |
| } catch { |
| |
| } |
| }, [symbol]) |
|
|
| |
| useEffect(() => { |
| mountedRef.current = true |
| fetchChart() |
| if (chartRefreshMs > 0) { |
| const interval = setInterval(fetchChart, chartRefreshMs) |
| return () => { |
| mountedRef.current = false |
| clearInterval(interval) |
| } |
| } |
| return () => { mountedRef.current = false } |
| }, [fetchChart, chartRefreshMs]) |
|
|
| |
| useEffect(() => { |
| if (isClosed || priceRefreshMs <= 0) return |
| const interval = setInterval(fetchPrice, priceRefreshMs) |
| return () => clearInterval(interval) |
| }, [fetchPrice, priceRefreshMs, isClosed]) |
|
|
| |
| const displayPrice = isClosed ? (exitPrice ?? entryPrice) : (currentPrice ?? entryPrice) |
| const pnl = (displayPrice - entryPrice) * quantity |
| const pnlPct = ((displayPrice - entryPrice) / entryPrice) * 100 |
| const totalValue = displayPrice * quantity |
| const priceDirection = prevPrice !== null && currentPrice !== null |
| ? currentPrice > prevPrice ? 'up' : currentPrice < prevPrice ? 'down' : 'same' |
| : 'same' |
|
|
| |
| const entryDateShort = entryDate.slice(0, 10) |
| const exitDateShort = exitDate?.slice(0, 10) |
| const entryPointIdx = priceData.findIndex(d => d.date >= entryDateShort) |
| const exitPointIdx = exitDateShort ? priceData.findIndex(d => d.date >= exitDateShort) : -1 |
|
|
| |
| const stopLoss = entryPrice * 0.95 |
| const takeProfit = entryPrice * 1.10 |
|
|
| if (loading) { |
| return ( |
| <div className="bg-white border border-gray-200 rounded-xl p-4 animate-pulse"> |
| <div className="flex items-center gap-3"> |
| <div className="h-6 w-20 bg-gray-200 rounded" /> |
| <div className="h-4 w-32 bg-gray-100 rounded" /> |
| </div> |
| <div className="h-48 bg-gray-50 rounded mt-3" /> |
| </div> |
| ) |
| } |
|
|
| return ( |
| <div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm"> |
| {/* βββ Header βββββββββββββββββββββββ */} |
| <div |
| className="px-4 py-3 flex items-center justify-between cursor-pointer hover:bg-gray-50 transition-colors" |
| onClick={() => setExpanded(!expanded)} |
| > |
| <div className="flex items-center gap-3"> |
| <h4 className="font-bold text-lg text-gray-900">{symbol}</h4> |
| <span className={`text-xs px-2 py-0.5 rounded-full font-medium ${ |
| isClosed |
| ? 'bg-gray-100 text-gray-600' |
| : 'bg-green-50 text-green-700 border border-green-200' |
| }`}> |
| {isClosed ? `KapalΔ± β ${exitReason || ''}` : 'AΓ§Δ±k Pozisyon'} |
| </span> |
| {!isClosed && lastUpdate && ( |
| <span className="text-xs text-gray-400 flex items-center gap-1"> |
| <Eye className="w-3 h-3" /> |
| {lastUpdate} |
| </span> |
| )} |
| </div> |
| |
| <div className="flex items-center gap-4"> |
| {/* Current Price with animation */} |
| <div className="text-right"> |
| <div className={`text-xl font-bold tabular-nums transition-colors duration-300 ${ |
| priceDirection === 'up' ? 'text-green-600' : |
| priceDirection === 'down' ? 'text-red-600' : |
| 'text-gray-900' |
| }`}> |
| {displayPrice.toFixed(2)} βΊ |
| {priceDirection === 'up' && <TrendingUp className="w-4 h-4 inline ml-1" />} |
| {priceDirection === 'down' && <TrendingDown className="w-4 h-4 inline ml-1" />} |
| </div> |
| <div className={`text-sm font-medium ${pnl >= 0 ? 'text-green-600' : 'text-red-600'}`}> |
| {pnl >= 0 ? '+' : ''}{fmtMoney(pnl)} ({pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}%) |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| {/* βββ Expanded Content βββββββββββββ */} |
| {expanded && ( |
| <div className="border-t border-gray-100"> |
| {/* Stats Row */} |
| <div className="grid grid-cols-2 md:grid-cols-5 gap-3 px-4 py-3 bg-gray-50/50 text-sm"> |
| <div> |
| <span className="text-gray-500">Adet</span> |
| <p className="font-semibold text-gray-900">{quantity.toLocaleString('tr-TR')} lot</p> |
| </div> |
| <div> |
| <span className="text-gray-500">GiriΕ FiyatΔ±</span> |
| <p className="font-semibold text-gray-900">{entryPrice.toFixed(2)} βΊ</p> |
| </div> |
| <div> |
| <span className="text-gray-500">Toplam DeΔer</span> |
| <p className="font-semibold text-gray-900">{fmtMoney(totalValue)}</p> |
| </div> |
| <div> |
| <span className="text-gray-500">Stop Loss</span> |
| <p className="font-semibold text-red-600">{stopLoss.toFixed(2)} βΊ</p> |
| </div> |
| <div> |
| <span className="text-gray-500">Take Profit</span> |
| <p className="font-semibold text-green-600">{takeProfit.toFixed(2)} βΊ</p> |
| </div> |
| </div> |
| |
| {/* Chart */} |
| {priceData.length > 0 ? ( |
| <div className="px-4 py-3"> |
| <ResponsiveContainer width="100%" height={240}> |
| <AreaChart data={priceData} margin={{ top: 20, right: 10, left: 0, bottom: 5 }}> |
| <defs> |
| <linearGradient id={`gradient-${symbol}`} x1="0" y1="0" x2="0" y2="1"> |
| <stop offset="0%" stopColor={pnl >= 0 ? '#22c55e' : '#ef4444'} stopOpacity={0.3} /> |
| <stop offset="100%" stopColor={pnl >= 0 ? '#22c55e' : '#ef4444'} stopOpacity={0.02} /> |
| </linearGradient> |
| </defs> |
| <CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" /> |
| <XAxis |
| dataKey="date" |
| tick={{ fontSize: 10, fill: '#9ca3af' }} |
| tickFormatter={(v: string) => v.slice(5)} |
| interval="preserveStartEnd" |
| /> |
| <YAxis |
| domain={['auto', 'auto']} |
| tick={{ fontSize: 10, fill: '#9ca3af' }} |
| tickFormatter={(v: number) => v.toFixed(1)} |
| width={55} |
| /> |
| <Tooltip content={<ChartTooltip />} /> |
| |
| {/* Price area */} |
| <Area |
| type="monotone" |
| dataKey="close" |
| stroke={pnl >= 0 ? '#22c55e' : '#ef4444'} |
| strokeWidth={2} |
| fill={`url(#gradient-${symbol})`} |
| dot={false} |
| animationDuration={500} |
| /> |
| |
| {/* Entry price reference line */} |
| <ReferenceLine |
| y={entryPrice} |
| stroke="#f59e0b" |
| strokeDasharray="6 4" |
| strokeWidth={1.5} |
| label={{ |
| value: `GiriΕ: ${entryPrice.toFixed(2)}`, |
| position: 'right', |
| fill: '#f59e0b', |
| fontSize: 10, |
| }} |
| /> |
| |
| {/* Stop loss line */} |
| {!isClosed && ( |
| <ReferenceLine |
| y={stopLoss} |
| stroke="#ef4444" |
| strokeDasharray="4 4" |
| strokeWidth={1} |
| strokeOpacity={0.5} |
| /> |
| )} |
| |
| {/* Take profit line */} |
| {!isClosed && ( |
| <ReferenceLine |
| y={takeProfit} |
| stroke="#22c55e" |
| strokeDasharray="4 4" |
| strokeWidth={1} |
| strokeOpacity={0.5} |
| /> |
| )} |
| |
| {/* BUY marker */} |
| {entryPointIdx >= 0 && ( |
| <ReferenceDot |
| x={priceData[entryPointIdx].date} |
| y={priceData[entryPointIdx].close} |
| r={0} |
| shape={<BuyMarker />} |
| /> |
| )} |
| |
| {/* SELL marker */} |
| {exitPointIdx >= 0 && exitPrice && ( |
| <ReferenceDot |
| x={priceData[exitPointIdx].date} |
| y={priceData[exitPointIdx].close} |
| r={0} |
| shape={<SellMarker />} |
| /> |
| )} |
| </AreaChart> |
| </ResponsiveContainer> |
| </div> |
| ) : ( |
| <div className="px-4 py-8 text-center text-gray-400 text-sm"> |
| Fiyat verisi bulunamadΔ± |
| </div> |
| )} |
| </div> |
| )} |
| </div> |
| ) |
| } |
|
|