fpl-solver / frontend /src /components /ProjectionsTable.jsx
AnayShukla's picture
updates
052f3f2
import React, { useState, useEffect, useMemo, useRef, useContext } from 'react';
import { Search, ChevronLeft, ChevronRight, Shield, Download, RotateCcw, Loader2 } from 'lucide-react';
import { getShortName } from '../utils/teams';
import { PlayerContext } from '../PlayerContext';
// --- BASELINE INPUT WITH LIVE AUTO-SAVE ---
// --- BASELINE INPUT WITH LIVE AUTO-SAVE & SNAP-PROOF MEMORY ---
const BaselineInput = ({ player, handleUpdate }) => {
const [val, setVal] = useState(player.baseline_xMins != null ? Math.round(player.baseline_xMins) : '');
useEffect(() => {
setVal(player.baseline_xMins != null ? Math.round(player.baseline_xMins) : '');
}, [player.baseline_xMins]);
return (
<input
type="number"
value={val}
onChange={(e) => setVal(e.target.value)}
onBlur={() => {
let num = val === '' ? 0 : parseInt(val, 10);
num = Math.max(0, Math.min(90, num)); // CAP FIX: Locks between 0 and 90
setVal(num);
handleUpdate(player.ID, 'baseline', null, num);
}}
onKeyDown={(e) => e.key === 'Enter' && e.target.blur()}
className="w-12 bg-transparent text-center font-mono text-sm font-bold text-emerald-400 focus:outline-none focus:bg-slate-950/80 focus:ring-1 ring-emerald-500 rounded py-1 hover:bg-slate-800/50 transition-colors cursor-text [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
);
};
// --- DGW CHILD INPUT WITH LIVE AUTO-SAVE ---
const SafeChildInput = ({ initialValue, onSave }) => {
const [val, setVal] = useState(initialValue);
useEffect(() => setVal(initialValue), [initialValue]);
return (
<input
type="number"
value={val}
onChange={(e) => setVal(e.target.value)}
onBlur={() => {
let num = val === '' ? 0 : parseFloat(val);
num = Math.max(0, Math.min(90, num)); // CAP FIX
setVal(num);
onSave(num);
}}
onKeyDown={(e) => e.key === 'Enter' && e.target.blur()}
className="w-12 bg-slate-950 text-center font-mono text-xs font-bold text-indigo-400 rounded py-1 outline-none focus:ring-1 ring-indigo-500 border border-slate-800 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
);
};
// --- GW INPUT WITH LIVE AUTO-SAVE & DGW POPOVER ---
// --- GW INPUT WITH SAFE FRONTEND SAVING ---
// --- GW INPUT WITH SAFE FRONTEND SAVING ---
const GwMinsInput = ({ player, gw, handleUpdate }) => {
const { effectiveFixtures, sessionEdits, globalXmins } = useContext(PlayerContext);
const [showPopover, setShowPopover] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const popoverRef = useRef(null);
const [val, setVal] = useState(player[`${gw}_xMins`] != null ? Math.round(player[`${gw}_xMins`]) : '');
useEffect(() => {
setVal(player[`${gw}_xMins`] != null ? Math.round(player[`${gw}_xMins`]) : '');
}, [player[`${gw}_xMins`]]);
useEffect(() => {
const handleClickOutside = (event) => {
if (popoverRef.current && !popoverRef.current.contains(event.target)) setShowPopover(false);
};
if (showPopover) document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showPopover]);
const matches = [];
if (player.match_projections) {
Object.entries(player.match_projections).forEach(([mId, mData]) => {
const override = effectiveFixtures?.[mId];
// THE FIX: Force Number() conversion so strings like "1" don't break the math!
if (override && override[gw] > 0) matches.push({ ...mData, id: mId, prob: Number(override[gw]) });
else if (!override && String(mData.default_gw) === String(gw)) matches.push({ ...mData, id: mId, prob: 1.0 });
});
}
const hasMultiple = matches.length > 1 || (matches.length === 1 && Math.abs(matches[0].prob - 1.0) > 0.001);
const isBlank = matches.length === 0;
const TEAM_SHORTS = {
1: "ARS", 2: "AVL", 3: "BUR", 4: "BOU", 5: "BRE",
6: "BHA", 7: "CHE", 8: "CRY", 9: "EVE", 10: "FUL",
11: "LEE", 12: "LIV", 13: "MCI", 14: "MUN", 15: "NEW",
16: "NFO", 17: "SUN", 18: "TOT", 19: "WHU", 20: "WOL"
};
if (isBlank) return <span className="text-[10px] font-bold text-slate-600">-</span>;
const hoverFixtureText = matches.map(m => `${TEAM_SHORTS[m.opponent_team_id] || m.opponent_team_id} ${m.is_home ? '(H)' : '(A)'}`).join(" & ");
const handleParentSave = (newVal) => {
let numVal = newVal === '' ? 0 : parseFloat(newVal);
numVal = Math.max(0, Math.min(90, numVal)); // CAP FIX
const currentAvg = Math.round(player[`${gw}_xMins`] || 0);
if (numVal === currentAvg) {
setVal(numVal);
return;
}
setVal(numVal); // Snaps the UI instantly
if (hasMultiple) {
const edits = {};
matches.forEach(m => { edits[m.id] = numVal; });
handleUpdate(player.ID, 'batch', edits, null);
} else {
handleUpdate(player.ID, 'single', gw, numVal);
}
};
return (
<div className="relative flex justify-center w-full pl-2" ref={popoverRef} title={hoverFixtureText}>
{isFocused && !hasMultiple && !isBlank && (
<div className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-slate-800 text-indigo-300 text-[10px] font-bold px-2 py-0.5 rounded shadow-xl border border-indigo-500/30 whitespace-nowrap z-tooltip pointer-events-none animate-in fade-in zoom-in-95">
{hoverFixtureText}
</div>
)}
<input
type="number"
title={hoverFixtureText}
value={val}
onClick={() => hasMultiple && setShowPopover(true)}
onFocus={() => !hasMultiple && setIsFocused(true)}
onChange={(e) => !hasMultiple && setVal(e.target.value)}
onBlur={(e) => {
if (!hasMultiple) {
setIsFocused(false); // Hides tooltip when you click away
handleParentSave(e.target.value);
}
}}
onKeyDown={(e) => e.key === 'Enter' && e.target.blur()}
className={`w-12 bg-transparent text-center font-mono text-sm font-bold rounded py-1 outline-none transition-colors ${hasMultiple ? 'text-indigo-300 hover:bg-slate-800/50 cursor-pointer focus:ring-1 ring-indigo-500' : 'text-emerald-400 focus:bg-slate-950/80 focus:ring-1 ring-emerald-500 hover:bg-slate-800/50'} [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none`}
/>
{showPopover && hasMultiple && (
<div className="absolute top-0 right-full mr-2 w-48 bg-slate-900 border border-indigo-500/50 rounded-lg shadow-2xl z-popover flex flex-col overflow-hidden animate-in fade-in zoom-in-95">
<div className="bg-indigo-950/50 text-[9px] font-bold text-indigo-300 uppercase tracking-widest p-2 border-b border-indigo-500/30 flex justify-between items-center">
<span>Edit Match Splits</span>
<button onClick={(e) => { e.stopPropagation(); setShowPopover(false); }} className="text-indigo-400 hover:text-white text-xs">✕</button>
</div>
<div className="p-2 flex flex-col gap-2">
{matches.map(m => {
const oppName = TEAM_SHORTS[m.opponent_team_id] || m.opponent_team_id || "OPP";
const fixLabel = m.is_home ? `${oppName} (H)` : `${oppName} (A)`;
const globalMatchMins = globalXmins?.[player.ID]?.[m.id];
const sessionVal = sessionEdits?.[player.ID]?.[`${m.id}_xMins`];
const currentMins = Math.round(sessionVal !== undefined ? Number(sessionVal) : (globalMatchMins !== undefined ? Number(globalMatchMins) : m.xMins));
return (
<div key={m.id} className="flex items-center justify-between gap-2">
<span className="text-[10px] font-bold text-slate-300 truncate flex-1">{fixLabel} <span className="opacity-50">({Math.round(m.prob * 100)}%)</span></span>
<SafeChildInput
initialValue={currentMins}
onSave={(newVal) => handleUpdate(player.ID, 'single', m.id, newVal)}
/>
</div>
);
})}
</div>
</div>
)}
</div>
);
};
export default function ProjectionsTable() {
const {
globalPlayers: players, setGlobalPlayers, isLoadingDB,
projSearchTerm: searchTerm, setProjSearchTerm: setSearchTerm,
sessionEdits, setSessionEdits, manualOverrides, effectiveFixtures, setOriginalPlayers, globalXmins
} = useContext(PlayerContext);
const [sortConfig, setSortConfig] = useState({ key: 'Total Points', direction: 'desc' });
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = typeof window !== 'undefined' && window.innerWidth < 768 ? 15 : 50;
const tableContainerRef = useRef(null);
useEffect(() => {
if (tableContainerRef.current) {
tableContainerRef.current.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [currentPage]);
const [isAdmin, setIsAdmin] = useState(false);
const [adminPassword, setAdminPassword] = useState('');
const [showAdminLogin, setShowAdminLogin] = useState(false);
const [clickCount, setClickCount] = useState(0);
const clickTimeoutRef = useRef(null);
const handleSecretClick = () => {
setClickCount((prev) => {
const newCount = prev + 1;
if (newCount === 5) { setShowAdminLogin(!showAdminLogin); return 0; }
return newCount;
});
if (clickTimeoutRef.current) clearTimeout(clickTimeoutRef.current);
clickTimeoutRef.current = setTimeout(() => setClickCount(0), 1000);
};
const gameweeks = useMemo(() => {
if (!players || players.length === 0) return [];
const gwSet = new Set();
Object.keys(players[0]).forEach(k => {
if (/^\d+_Pts$/.test(k)) {
const num = parseInt(k.split('_')[0], 10);
if (num >= 1 && num <= 38) gwSet.add(num);
}
});
return Array.from(gwSet).sort((a, b) => a - b);
}, [players]);
const getDynamicTotal = (p) => gameweeks.reduce((sum, gw) => sum + (Number(p[`${gw}_Pts`]) || 0), 0);
const getDynamicAvg = (p) => gameweeks.length > 0 ? getDynamicTotal(p) / gameweeks.length : 0;
const handleUpdate = async (playerId, type, gw, valueStr) => {
const value = type === 'baseline' ? parseInt(valueStr, 10) || 0 : parseFloat(valueStr) || 0;
// 1. Grab the active baseline from memory so Python doesn't forget it!
const activeBaseline = sessionEdits[playerId]?.baseline_xMins;
// 2. Prevent Python Crash: Separate Match IDs (13_vs_1) from real Gameweeks (34)
const realGwEdits = {};
if (type === 'batch') {
Object.keys(gw).forEach(k => { realGwEdits[k] = gw[k]; });
} else if (type === 'single') {
realGwEdits[gw] = value;
}
// 3. Update local React memory instantly (handles DGW splits perfectly)
setSessionEdits(prev => {
const next = { ...prev };
if (!next[playerId]) next[playerId] = {};
if (type === 'baseline') {
next[playerId]['baseline_xMins'] = value;
} else if (type === 'batch') {
Object.keys(gw).forEach(k => { next[playerId][`${k}_xMins`] = gw[k]; });
} else {
next[playerId][`${gw}_xMins`] = value;
}
const token = localStorage.getItem('fpl_token');
if (token) {
fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ saved_edits: { ...next, _solver_overrides: manualOverrides } })
});
}
return next;
});
// 4. If this is a DGW match split ('13_vs_1'), STOP HERE. Don't crash Python!
try {
const payload = { player_id: playerId, is_admin: isAdmin, admin_password: adminPassword, gw_edits: realGwEdits };
// 5. Prevent Reset Bug: ALWAYS send the baseline to Python!
if (type === 'baseline') {
payload.baseline_edit = value;
} else if (activeBaseline !== undefined) {
payload.baseline_edit = activeBaseline;
}
const res = await fetch('https://anayshukla-fpl-solver.hf.space/api/player/update', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
});
if (!res.ok) { if (res.status === 401) { alert("Invalid Admin Password!"); setIsAdmin(false); } throw new Error('Backend recalculation failed'); }
const updatedRow = await res.json();
// 6. Merge Python's exact decayed math into the table
if (setGlobalPlayers) {
setGlobalPlayers(prev => prev.map(p => {
const newBaseline = type === 'baseline' ? (valueStr === '' ? null : value) : (activeBaseline !== undefined ? activeBaseline : p.baseline_xMins);
if (p.ID === playerId) {
// THE FIX: Protect the original match dictionary so Python doesn't wipe out the DGW tags!
const pristineMatches = p.match_projections;
return { ...p, ...updatedRow, match_projections: pristineMatches, baseline_xMins: newBaseline };
}
return p;
}));
}
// 7. Lock Python's curve into memory (from your old working file)
if (type === 'baseline') {
setSessionEdits(prev => {
const next = { ...prev };
gameweeks.forEach(g => {
next[playerId][`${g}_xMins`] = updatedRow[`${g}_xMins`];
next[playerId][`${g}_Pts`] = updatedRow[`${g}_Pts`];
});
return next;
});
}
} catch (err) { console.error("Recalculation error:", err); }
};
const resetPlayer = async (playerId) => {
try {
const res = await fetch('https://anayshukla-fpl-solver.hf.space/api/projections');
const freshData = await res.json();
const cleanPlayer = freshData.find(p => p.ID === playerId);
if (cleanPlayer && setGlobalPlayers) {
// THE FLICKER FIX: Apply the UI math interceptor instantly before merging the reset player!
if (cleanPlayer.match_projections) {
gameweeks.forEach(g => {
cleanPlayer[`${g}_Pts`] = 0;
cleanPlayer[`${g}_xMins`] = 0;
cleanPlayer[`${g}_probSum`] = 0;
});
Object.entries(cleanPlayer.match_projections).forEach(([mId, mData]) => {
const pts = mData.Pts !== undefined ? mData.Pts : (mData.points || 0);
const mins = mData.xMins !== undefined ? mData.xMins : (mData.mins || 0);
const override = effectiveFixtures?.[mId];
if (override) {
Object.entries(override).forEach(([gwStr, prob]) => {
if (prob > 0) {
cleanPlayer[`${gwStr}_Pts`] = (cleanPlayer[`${gwStr}_Pts`] || 0) + (pts * prob);
cleanPlayer[`${gwStr}_xMins`] = (cleanPlayer[`${gwStr}_xMins`] || 0) + (mins * prob);
cleanPlayer[`${gwStr}_probSum`] = (cleanPlayer[`${gwStr}_probSum`] || 0) + prob;
}
});
} else {
const defGw = mData.default_gw;
if (defGw) {
cleanPlayer[`${defGw}_Pts`] = (cleanPlayer[`${defGw}_Pts`] || 0) + pts;
cleanPlayer[`${defGw}_xMins`] = (cleanPlayer[`${defGw}_xMins`] || 0) + mins;
cleanPlayer[`${defGw}_probSum`] = (cleanPlayer[`${defGw}_probSum`] || 0) + 1.0;
}
}
});
gameweeks.forEach(g => {
if (cleanPlayer[`${g}_probSum`] > 0) {
cleanPlayer[`${g}_xMins`] = cleanPlayer[`${g}_xMins`] / cleanPlayer[`${g}_probSum`];
}
});
}
setGlobalPlayers(prev => prev.map(p => p.ID === playerId ? cleanPlayer : p));
if (setOriginalPlayers) {
setOriginalPlayers(prev => prev.map(p => p.ID === playerId ? cleanPlayer : p));
}
setSessionEdits(prev => {
const newEdits = { ...prev };
delete newEdits[playerId];
const token = localStorage.getItem('fpl_token');
if (token) {
fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ saved_edits: { ...newEdits, _solver_overrides: manualOverrides } })
});
}
return newEdits;
});
}
} catch (e) { console.error("Failed to reset player", e); }
};
const resetAll = async () => {
try {
const res = await fetch('https://anayshukla-fpl-solver.hf.space/api/projections');
const freshData = await res.json();
if (setGlobalPlayers) setGlobalPlayers(freshData);
setSessionEdits(prev => {
const token = localStorage.getItem('fpl_token');
if (token) {
fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ saved_edits: { _solver_overrides: manualOverrides } })
});
}
return {};
});
} catch (e) { console.error("Failed to reset all", e); }
};
const downloadCSV = () => {
if (!players || players.length === 0) return;
const headers = ["Pos", "ID", "Name", "BV", "SV", "Team"];
gameweeks.forEach(gw => {
headers.push(`${gw}_xMins`, `${gw}_Pts`);
});
headers.push("Total Points", "Average Points");
let csvContent = "data:text/csv;charset=utf-8,";
csvContent += headers.join(",") + "\n";
const escapeCsv = (str) => {
if (str == null) return "";
const s = String(str);
return s.includes(",") ? `"${s}"` : s;
};
sortedAndFilteredData.forEach(p => {
const row = [
p.Pos,
p.ID,
escapeCsv(p.Name),
p.BV,
p.SV !== undefined ? p.SV : p.BV,
escapeCsv(p.Team)
];
gameweeks.forEach(gw => {
const mins = Number(p[`${gw}_xMins`]) || 0;
const pts = Number(p[`${gw}_Pts`]) || 0;
row.push(Math.round(mins));
row.push(pts.toFixed(2));
});
row.push(getDynamicTotal(p).toFixed(2));
row.push(getDynamicAvg(p).toFixed(2));
csvContent += row.join(",") + "\n";
});
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", `luigis_mansion.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleSort = (key) => {
let direction = 'desc';
if (sortConfig.key === key && sortConfig.direction === 'desc') direction = 'asc';
setSortConfig({ key, direction });
};
const sortedAndFilteredData = useMemo(() => {
if (!players) return [];
// Add the special character normalizer
const cleanString = (str) => str ? str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase() : "";
const cleanSearch = cleanString(searchTerm);
let filtered = searchTerm ? players.filter(p => cleanString(p.Name).includes(cleanSearch)) : [...players];
return filtered.sort((a, b) => {
let valA = sortConfig.key === 'Total Points' ? getDynamicTotal(a) : (sortConfig.key === 'Average Points' ? getDynamicAvg(a) : a[sortConfig.key]);
let valB = sortConfig.key === 'Total Points' ? getDynamicTotal(b) : (sortConfig.key === 'Average Points' ? getDynamicAvg(b) : b[sortConfig.key]);
if (sortConfig.key === 'Team') { valA = getShortName(valA); valB = getShortName(valB); }
if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1;
if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
}, [players, sortConfig, searchTerm, gameweeks]);
useEffect(() => setCurrentPage(1), [searchTerm, sortConfig]);
const totalPages = Math.ceil(sortedAndFilteredData.length / itemsPerPage);
const paginatedData = sortedAndFilteredData.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
const getMinsColor = (mins) => `rgba(52, 211, 153, ${Math.min(mins / 90, 1) * 0.4})`;
const getPtsColor = (pts) => pts <= 0 ? 'transparent' : `rgba(16, 185, 129, ${Math.min(pts / 10, 1) * 0.6})`;
if (isLoadingDB) return <div className="flex items-center justify-center h-64"><Loader2 size={32} className="animate-spin text-emerald-500" /></div>;
const displayedPlayers = useMemo(() => {
return paginatedData.map(p => {
if (!p.match_projections) return p;
const cloned = { ...p };
gameweeks.forEach(g => {
cloned[`${g}_Pts`] = 0;
cloned[`${g}_xMins`] = 0;
cloned[`${g}_probSum`] = 0;
});
const manualBaseline = sessionEdits[p.ID]?.baseline_xMins;
Object.entries(p.match_projections).forEach(([mId, mData]) => {
const override = effectiveFixtures?.[mId];
let manualMins = sessionEdits[p.ID]?.[`${mId}_xMins`];
const globalMatchMins = globalXmins?.[p.ID]?.[mId];
if (manualMins === undefined) {
if (globalMatchMins !== undefined) {
manualMins = globalMatchMins;
} else {
let activeGw = override ? Object.keys(override).find(g => override[g] > 0) : mData.default_gw;
if (activeGw) manualMins = sessionEdits[p.ID]?.[`${activeGw}_xMins`] ?? globalXmins?.[p.ID]?.[activeGw];
}
}
// Safely get the unedited minutes from the backend
const origMins = mData.xMins !== undefined ? mData.xMins : (mData.mins || 0);
let activeMins = origMins;
// THE DECAY FIX: Use a ratio to preserve the backend curve instead of flattening it
if (manualMins !== undefined) {
activeMins = Number(manualMins);
} else if (manualBaseline !== undefined) {
const origBase = p.baseline_xMins || 90;
const ratio = origBase > 0 ? (Number(manualBaseline) / origBase) : 1.0;
activeMins = Math.min((origMins * ratio), 90);
}
const scaling = (activeMins > 0 && origMins > 0) ? (activeMins / origMins) : (activeMins === 0 ? 0 : 1);
const basePts = mData.Pts !== undefined ? mData.Pts : (mData.points || 0);
const aPts = basePts * scaling;
if (override) {
Object.entries(override).forEach(([gwStr, prob]) => {
if (prob > 0) {
cloned[`${gwStr}_Pts`] = (cloned[`${gwStr}_Pts`] || 0) + (aPts * prob);
cloned[`${gwStr}_xMins`] = (cloned[`${gwStr}_xMins`] || 0) + (activeMins * prob);
cloned[`${gwStr}_probSum`] = (cloned[`${gwStr}_probSum`] || 0) + prob;
}
});
} else {
const defGw = mData.default_gw;
if (defGw) {
cloned[`${defGw}_Pts`] = (cloned[`${defGw}_Pts`] || 0) + aPts;
cloned[`${defGw}_xMins`] = (cloned[`${defGw}_xMins`] || 0) + activeMins;
cloned[`${defGw}_probSum`] = (cloned[`${defGw}_probSum`] || 0) + 1.0;
}
}
});
gameweeks.forEach(g => {
if (cloned[`${g}_probSum`] > 0) {
cloned[`${g}_xMins`] = cloned[`${g}_xMins`] / cloned[`${g}_probSum`];
}
});
return cloned;
});
}, [paginatedData, sessionEdits, effectiveFixtures, gameweeks]);
return (
<div className="space-y-4 w-full">
<div className="flex flex-col md:flex-row justify-between items-center gap-4 bg-slate-900/40 p-4 rounded-xl border border-slate-800 backdrop-blur-sm shadow-sm">
<div className="flex gap-4 items-center">
<div className="relative w-72 flex items-center">
<div className="absolute left-0 w-10 h-full flex items-center justify-center cursor-pointer z-dropdown" onClick={handleSecretClick}>
<Search className="text-slate-500 pointer-events-none" size={18} />
</div>
<input type="text" placeholder="Search players..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full bg-slate-950/80 border border-slate-700 rounded-lg py-2 pl-10 pr-10 text-sm text-slate-200 focus:outline-none focus:border-luigi-400" />
{isAdmin && <Shield size={14} className="absolute right-3 text-luigi-500" title="Admin Mode Active" />}
</div>
{showAdminLogin && !isAdmin && (
<div className="flex gap-2 animate-in fade-in slide-in-from-left-4 duration-300">
<input type="password" placeholder="Admin Pass" value={adminPassword} onChange={(e) => setAdminPassword(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && setIsAdmin(true)} className="bg-slate-950 border border-slate-700 rounded py-1.5 px-3 text-sm w-32 focus:outline-none focus:border-luigi-400 text-slate-200" />
<button onClick={() => setIsAdmin(true)} className="bg-slate-700 hover:bg-slate-600 px-3 rounded text-sm text-white transition-colors">Login</button>
</div>
)}
</div>
<div className="flex gap-3">
{Object.keys(sessionEdits).length > 0 && (
<button onClick={resetAll} className="flex items-center gap-2 px-3 py-2 text-sm bg-red-900/30 text-red-400 border border-red-900/50 rounded-lg hover:bg-red-900/50 transition-colors"><RotateCcw size={16} /> Reset to Default</button>
)}
<button onClick={downloadCSV} className="flex items-center gap-2 px-4 py-2 text-sm bg-luigi-500 text-slate-950 font-bold rounded-lg hover:bg-luigi-400 transition-colors shadow-lg shadow-luigi-500/20"><Download size={16} /> Export CSV</button>
</div>
</div>
<div ref={tableContainerRef} className="rounded-xl border border-slate-800 bg-slate-900/40 backdrop-blur-sm shadow-xl max-h-[70vh] overflow-y-auto overflow-x-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-slate-700/50 [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-slate-600/80">
<table className="w-full text-sm text-left text-slate-300 relative">
<thead className="text-[11px] text-slate-400 uppercase bg-slate-950 border-b border-slate-800 sticky top-0 z-sticky shadow-sm">
<tr>
<th onClick={() => handleSort('Pos')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 text-center whitespace-nowrap">Pos</th>
<th onClick={() => handleSort('Name')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 whitespace-nowrap">Name</th>
<th onClick={() => handleSort('Team')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 whitespace-nowrap">Team</th>
<th onClick={() => handleSort('BV')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 text-center whitespace-nowrap">Cost</th>
<th onClick={() => handleSort('baseline_xMins')} className="px-2 py-3 text-center border-l border-slate-800/50 bg-slate-900/50 cursor-pointer">
<div className="flex flex-col items-center gap-1"><span className="text-emerald-400 font-bold tracking-wider">Baseline</span><div className="flex w-full text-[10px] text-slate-500 justify-center px-1">xMins</div></div>
</th>
{gameweeks.map(gw => (
<th key={gw} className="px-2 py-3 text-center border-l border-slate-800/50 bg-slate-950">
<div className="flex flex-col items-center gap-1">
<span className="text-luigi-400 font-bold tracking-wider">GW{gw}</span>
<div className="flex w-full text-[10px] text-slate-500 justify-around px-1 gap-2">
<span className="cursor-pointer hover:text-slate-300" onClick={() => handleSort(`${gw}_xMins`)}>xMins</span>
<span className="cursor-pointer hover:text-slate-300" onClick={() => handleSort(`${gw}_Pts`)}>xPts</span>
</div>
</div>
</th>
))}
<th onClick={() => handleSort('Total Points')} className="px-3 py-4 text-right cursor-pointer hover:text-slate-200 text-luigi-400 border-l border-slate-800/50 bg-slate-950 whitespace-nowrap">Total</th>
<th className="px-3 py-4 text-center bg-slate-950 border-l border-slate-800/50 whitespace-nowrap">Reset</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{displayedPlayers.map((player) => (
<tr key={player.ID} className={`transition-colors group ${sessionEdits[player.ID] ? 'bg-luigi-900/10' : 'hover:bg-slate-800/30'}`}>
<td className="px-3 py-2 font-medium text-slate-500 text-center whitespace-nowrap">{player.Pos}</td>
<td className="px-3 py-2 font-bold text-slate-100 truncate max-w-[160px]">{player.Name}</td>
<td className="px-3 py-2 text-slate-400 font-bold text-center">{getShortName(player.Team)}</td>
<td className="px-3 py-2 text-center whitespace-nowrap">{player.BV}</td>
<td className="p-0 border-l border-slate-800/30 bg-slate-900/30">
<div className="w-full h-full p-1.5 flex items-center justify-center">
<BaselineInput
player={player}
handleUpdate={handleUpdate}
/>
</div>
</td>
{gameweeks.map(gw => (
<td key={gw} className="p-0 border-l border-slate-800/30">
<div className="flex h-full items-stretch">
<div className="relative w-1/2 p-2 border-r border-slate-800/20 flex items-center justify-center" style={{ backgroundColor: getMinsColor(player[`${gw}_xMins`]) }}>
<GwMinsInput
player={player}
gw={gw}
handleUpdate={handleUpdate}
/>
{/* TAG FIX: Tiny, padded, and pointer-events-none so it never blocks clicks */}
{player[`${gw}_probSum`] > 1.01 && (
<span className="absolute top-0 right-0 text-[7px] leading-none py-[2px] px-1 bg-indigo-500/90 text-white rounded-bl font-black tracking-tighter pointer-events-none" title={`DGW`}>
DGW
</span>
)}
{player[`${gw}_probSum`] < 0.99 && player[`${gw}_probSum`] > 0.01 && (
<span className="absolute top-0 right-0 text-[7px] leading-none py-[2px] px-1 bg-orange-500/90 text-white rounded-bl font-black pointer-events-none" title="Odds of the fixture happening">
%
</span>
)}
</div>
<div className="w-1/2 p-2 text-center font-mono text-sm font-bold flex items-center justify-center" style={{ backgroundColor: getPtsColor(player[`${gw}_Pts`]) }}>
<span className="drop-shadow-md">{Number(player[`${gw}_Pts`]).toFixed(2)}</span>
</div>
</div>
</td>
))}
<td className="px-3 py-2 text-right font-bold text-luigi-400 font-mono border-l border-slate-800/30 bg-slate-900/20 group-hover:bg-transparent">{getDynamicTotal(player).toFixed(2)}</td>
<td className="px-2 py-2 text-center border-l border-slate-800/30">
{sessionEdits[player.ID] && (
<button onClick={() => resetPlayer(player.ID)} className="p-1 text-slate-500 hover:text-red-400 transition-colors" title="Reset Player">
<RotateCcw size={16} />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 bg-slate-900/40 border border-slate-800 rounded-xl mt-2">
<span className="text-sm text-slate-400">
Showing <span className="font-bold text-slate-200">{(currentPage - 1) * itemsPerPage + 1}</span> to <span className="font-bold text-slate-200">{Math.min(currentPage * itemsPerPage, sortedAndFilteredData.length)}</span> of <span className="font-bold text-slate-200">{sortedAndFilteredData.length}</span> players
</span>
<div className="flex gap-2">
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-1.5 rounded-lg bg-slate-800 text-slate-300 hover:bg-slate-700 disabled:opacity-50 transition-colors">
<ChevronLeft size={20} />
</button>
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-1.5 rounded-lg bg-slate-800 text-slate-300 hover:bg-slate-700 disabled:opacity-50 transition-colors">
<ChevronRight size={20} />
</button>
</div>
</div>
)}
</div>
);
}