// Tormentas — Pantallas administrativas const ADMIN_KEY = 'TormPortal2026'; // ── PAGOS ───────────────────────────────────────────────── function AdminPagos() { const { players } = window.TData; const { payments: init, MONTHLY_FEE, paymentMonths, monthLabels, contacts } = window.TAdmin; const [selMonth, setSelMonth] = React.useState(paymentMonths[paymentMonths.length - 1]); const [payments, setPayments] = React.useState(init); const [methodPick, setMethodPick] = React.useState(null); // {pid, month} // Sync a single payment entry to the server const syncPayment = (pid, pay) => { fetch('/api/pagos.php?action=upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ adminKey: ADMIN_KEY, playerId: parseInt(pid), mes: monthLabels[pay.month] || pay.month, monto: MONTHLY_FEE, status: pay.paid ? 'pagado' : 'pendiente', fecha: pay.date || '', metodo: pay.method || '', }) }).catch(() => {}); }; // On mount: load payments from server and ensure all players have entries React.useEffect(() => { const reverseLabels = Object.fromEntries(Object.entries(monthLabels).map(([k, v]) => [v, k])); const emptyMonths = () => paymentMonths.map(month => ({ month, amount: MONTHLY_FEE, paid: false, date: null, method: null })); fetch('/api/pagos.php') .then(r => r.json()) .then(serverData => { const sd = (serverData && typeof serverData === 'object') ? serverData : {}; setPayments(prev => { const next = { ...prev }; // Init entries for players not in TAdmin defaults players.forEach(p => { if (!next[p.id]) next[p.id] = emptyMonths(); }); // Override with server-stored payments Object.entries(sd).forEach(([pkey, entries]) => { const pid = parseInt(pkey.slice(1)); if (!next[pid]) next[pid] = emptyMonths(); entries.forEach(entry => { const month = reverseLabels[entry.mes] || entry.mes; const idx = next[pid].findIndex(p => p.month === month); if (idx >= 0) { next[pid] = next[pid].map((p, i) => i !== idx ? p : { ...p, paid: entry.status === 'pagado', date: entry.fecha || null, }); } }); }); return next; }); }) .catch(() => { // Even if server fails, ensure all players have entries setPayments(prev => { const next = { ...prev }; players.forEach(p => { if (!next[p.id]) next[p.id] = emptyMonths(); }); return next; }); }); }, []); const confirmPay = (pid, month, method) => { const playerPays = payments[pid] || []; const existing = playerPays.find(p => p.month === month); if (!existing) return; const fecha = new Date().toISOString().split('T')[0]; const newPay = { ...existing, paid: true, date: fecha, method }; const next = { ...payments, [pid]: playerPays.map(p => p.month === month ? newPay : p) }; setPayments(next); setMethodPick(null); syncPayment(pid, newPay); // Send payment confirmation email to parents const player = players.find(p => p.id === pid); if (player) { const staticParents = (contacts && contacts[pid] && Array.isArray(contacts[pid].parents)) ? contacts[pid].parents : []; // Always call API; if no static parents, server will look up from registration fetch('/api/send-mail.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ adminKey: ADMIN_KEY, playerId: pid, playerName: player.name, mes: monthLabels[month] || month, monto: MONTHLY_FEE, metodo: method, fecha, parents: staticParents, }), }).catch(() => {}); } }; const unpay = (pid, month) => { const playerPays = payments[pid] || []; const existing = playerPays.find(p => p.month === month); if (!existing) return; const newPay = { ...existing, paid: false, date: null, method: null }; const next = { ...payments, [pid]: playerPays.map(p => p.month === month ? newPay : p) }; setPayments(next); syncPayment(pid, newPay); }; const curIdx = paymentMonths.indexOf(selMonth); const lastIdx = paymentMonths.length - 1; const rows = players.map(p => ({ player:p, pay: payments[p.id]?.find(x => x.month === selMonth) })); const paid = rows.filter(r => r.pay?.paid).length; const pending = rows.length - paid; const totalPaid = paid * MONTHLY_FEE; const totalPend = pending * MONTHLY_FEE; // Season totals const seasonPaid = players.reduce((s, p) => s + (payments[p.id]?.filter(x => x.paid).length || 0) * MONTHLY_FEE, 0); return (

Pagos mensuales

Cuota: ${MONTHLY_FEE} MXN / jugadora / mes  ·  Vence el día 27 de cada mes

0?T.red:T.green} sub={`${pending} jugadoras`} /> =0.8?T.green:T.yellow} />
{/* Month tabs */}
{paymentMonths.map(m => ( ))}
{ const isPaid = !!pay?.paid; const overdue = !isPaid && curIdx < lastIdx; const statusColor = isPaid ? T.green : overdue ? T.red : T.yellow; const statusBg = isPaid ? T.greenLt : overdue ? T.redLt : T.yellowLt; const statusBdr = isPaid ? "#bbf7d0" : overdue ? "#fecaca" : "#fde68a"; const statusLabel = isPaid ? "Pagado" : overdue ? "Vencido" : "Pendiente"; return { player: (
{p.name}
), pos: , amount: `$${MONTHLY_FEE}`, status: ( {statusLabel} ), date: pay?.date || , method: pay?.method || , action: (() => { const picking = methodPick && methodPick.pid === p.id && methodPick.month === selMonth; if (isPaid) return ( ); if (picking) return (
Método: {["Efectivo","Transferencia"].map(m => ( ))}
); return ( ); })(), }; })} />
); } // ── REGISTRO ────────────────────────────────────────────── // ── DOCUMENTOS ──────────────────────────────────────────── function AdminDocumentos() { const { players } = window.TData; const { documents: init, DOC_TYPES } = window.TAdmin; const [docs, setDocs] = React.useState(init); // On mount: load docs from server React.useEffect(() => { fetch('/api/documentos.php') .then(r => r.json()) .then(serverDocs => { if (serverDocs && typeof serverDocs === 'object' && Object.keys(serverDocs).length > 0) { setDocs(prev => { const next = { ...prev }; Object.entries(serverDocs).forEach(([pid, fields]) => { if (next[+pid]) next[+pid] = { ...next[+pid], ...fields }; }); return next; }); } }) .catch(() => {}); }, []); const CYCLE = ["ok","pendiente","faltante"]; const cycle = (pid, key) => { if (!docs[pid]) return; const cur = docs[pid][key]; const next = { ...docs, [pid]: { ...docs[pid], [key]: CYCLE[(CYCLE.indexOf(cur)+1)%CYCLE.length] } }; setDocs(next); fetch('/api/documentos.php', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ adminKey: ADMIN_KEY, docs: next }), }).catch(() => {}); }; const sc = s => s==="ok" ? T.green : s==="pendiente" ? T.yellow : T.red; const sb = s => s==="ok" ? T.greenLt: s==="pendiente" ? T.yellowLt: T.redLt; const sl = s => s==="ok" ? "OK" : s==="pendiente" ? "Pendiente": "Faltante"; const total = players.length * DOC_TYPES.length; const totalOk = players.reduce((n,p)=>n+DOC_TYPES.filter(d=>docs[p.id]?.[d.key]==="ok").length,0); const totalFalta = players.reduce((n,p)=>n+DOC_TYPES.filter(d=>docs[p.id]?.[d.key]==="faltante").length,0); const totalPend = players.reduce((n,p)=>n+DOC_TYPES.filter(d=>docs[p.id]?.[d.key]==="pendiente").length,0); return (

Documentos

Haz clic en cada celda para cambiar el estado

0?T.red:T.green} sub="sin entregar" /> 0?T.yellow:T.green} sub="en proceso" /> =0.8?T.green:T.yellow} />
{DOC_TYPES.map(dt=>( ))} {players.map((p, pi) => ( {DOC_TYPES.map(dt => { const s = docs[p.id]?.[dt.key] || "faltante"; return ( ); })} ))}
Jugadora {dt.label}
{p.name}
#{p.num}
cycle(p.id, dt.key)} style={{ display:"inline-block", padding:"4px 12px", borderRadius:4, background:sb(s), color:sc(s), fontWeight:700, fontSize:11, border:`1px solid ${sc(s)}30`, cursor:"pointer", transition:"all 0.12s", userSelect:"none" }}>{sl(s)}
OK = Entregado Pendiente = En revisión Faltante = Sin entregar Clic en celda para cambiar estado
); } window.AdminPagos = AdminPagos; window.AdminDocumentos = AdminDocumentos; // ── JUGADORAS (ROSTER + CONTACTOS) ─────────────────────────── const POSITIONS = ["Base","Escolta","Alero","Ala-Pívot","Pívot"]; const AVATAR_COLORS = ["#2bbdf7","#e8508a","#8b6db8","#1a7ecf","#10b981","#f59e0b","#ef4444","#6366f1"]; function AdminJugadoras() { const [activeTab, setActiveTab] = React.useState("roster"); const [pendCount, setPendCount] = React.useState(0); // Refresh pending count from server on mount React.useEffect(() => { fetch('/api/registros.php').then(r => r.json()).then(data => { const n = Array.isArray(data) ? data.filter(r => r.status === 'pendiente').length : 0; setPendCount(n); }).catch(() => {}); }, []); const tabBtn = (id, label, badge) => ( ); return (

Jugadoras

Roster, contactos y registros pendientes

{tabBtn("roster","Roster")} {tabBtn("registros","Registros")} {tabBtn("pendientes","Pendientes", pendCount)}
{activeTab === "roster" && } {activeTab === "registros" && } {activeTab === "pendientes" && { setPendCount(c => Math.max(0, c-1)); }} />}
); } function syncRoster(list) { window.TData.players = list; fetch('/api/roster.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ adminKey: 'TormPortal2026', players: list }), }).catch(() => {}); } function RosterTab() { const [players, setPlayers] = React.useState(() => [...window.TData.players]); const [modal, setModal] = React.useState(null); const [form, setForm] = React.useState({}); const [deleteId, setDeleteId] = React.useState(null); // Load from server on mount, prefer server over data.js defaults React.useEffect(() => { fetch('/api/roster.php').then(r => r.json()).then(data => { if (Array.isArray(data) && data.length > 0) { window.TData.players = data; setPlayers(data); } }).catch(() => {}); }, []); const persist = (list) => { syncRoster(list); setPlayers(list); }; const openAdd = () => { setForm({ name:"", num:"", pos:"Base", hand:"Derecha", color:"#2bbdf7" }); setModal("add"); }; const openEdit = (p) => { setForm({...p, num: String(p.num)}); setModal("edit"); }; const handleSubmit = () => { if (!form.name.trim() || !form.num) return; const words = form.name.trim().split(" ").filter(Boolean); const initials = words.map(w => w[0].toUpperCase()).join("").slice(0, 2); const num = parseInt(form.num) || 0; if (modal === "add") { const id = Math.max(0, ...players.map(p => p.id)) + 1; persist([...players, { ...form, id, num, initials, attendancePct: 90 }]); } else { persist(players.map(p => p.id === form.id ? { ...form, num, initials } : p)); } setModal(null); }; const confirmDelete = () => { persist(players.filter(p => p.id !== deleteId)); setDeleteId(null); }; const inputSt = { width:"100%", padding:"10px 12px", border:"1.5px solid #e2e8f0", borderRadius:8, fontSize:13, fontFamily:"inherit", outline:"none", color:"#0f172a", background:"#f8fafc" }; const btnSt = (color, bg) => ({ padding:"8px 16px", borderRadius:8, border:"none", cursor:"pointer", fontWeight:600, fontSize:13, fontFamily:"inherit", color, background:bg }); const field = (label, key, type="text", opts=null) => (
{opts ? ( ) : ( setForm(f=>({...f,[key]:e.target.value}))} style={inputSt} /> )}
); return ( <>
{players.map((p, i) => (
{p.name}
#{p.num} · {p.pos} · {p.hand}
))}
{modal && (

{modal === "add" ? "Agregar jugadora" : "Editar jugadora"}

{field("Nombre completo", "name")}
{field("Número", "num", "number")}
{field("Posición", "pos", "text", POSITIONS)}
{field("Mano dominante", "hand", "text", ["Derecha","Izquierda"])}
{AVATAR_COLORS.map(c => (
setForm(f=>({...f, color:c}))} style={{ width:28, height:28, borderRadius:"50%", background:c, cursor:"pointer", border: form.color===c ? "3px solid #0f172a" : "3px solid transparent", boxSizing:"border-box" }} /> ))}
)} {deleteId && (
⚠️

¿Eliminar jugadora?

{players.find(p=>p.id===deleteId)?.name} — Esta acción no se puede deshacer.

)} ); } const CATEGORIAS_EDIT = ["2014","2017","Mini","Infantil","Cadete","Juvenil","Mayor"]; const POSICIONES_EDIT = ["Base","Escolta","Alero","Ala-Pivot","Pivot"]; function RegistrosTab() { const [regs, setRegs] = React.useState([]); const [loading, setLoading] = React.useState(true); const [expanded, setExpanded] = React.useState(null); const [editing, setEditing] = React.useState(null); const [form, setForm] = React.useState({}); const [saving, setSaving] = React.useState(false); const [pdfReg, setPdfReg] = React.useState(null); const [search, setSearch] = React.useState(""); function load() { fetch('/api/registros.php').then(r => r.json()).then(data => { setRegs(Array.isArray(data) ? data : []); setLoading(false); }).catch(() => setLoading(false)); } React.useEffect(() => { load(); }, []); const filtered = regs.filter(r => { const q = search.toLowerCase(); return !q || (r.jugadora?.nombre||'').toLowerCase().includes(q) || (r.jugadora?.categoria||'').toLowerCase().includes(q) || (r.contactos?.c1?.nombre||'').toLowerCase().includes(q); }); const statusColor = { aprobada:'#16A34A', pendiente:'#D97706', rechazado:'#DC2626' }; const statusLabel = { aprobada:'Aprobada', pendiente:'Pendiente', rechazado:'Rechazado' }; function openEdit(reg) { const j = reg.jugadora || {}; setForm({ id: reg.id, nombre: j.nombre || '', categoria: j.categoria || '', numero: j.numero || '', posicion: j.posicion || '', escuela: j.escuela || '', fechaNac: j.fechaNac || '', curp: j.curp || '', }); setEditing(reg.id); } function saveEdit() { setSaving(true); fetch('/api/registros.php?action=admin_edit', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ adminKey:'TormPortal2026', ...form }), }) .then(r => r.json()) .then(d => { setSaving(false); if (d.ok) { setEditing(null); // Update local state immediately setRegs(prev => prev.map(r => r.id !== form.id ? r : { ...r, jugadora: { ...r.jugadora, nombre: form.nombre, categoria: form.categoria, numero: form.numero, posicion: form.posicion, escuela: form.escuela, fechaNac: form.fechaNac, curp: form.curp, } })); // Also update TData.players name/num if this reg has a playerId const reg = regs.find(r => r.id === form.id); if (reg?.playerId) { const pid = parseInt(reg.playerId); const updated = window.TData.players.map(p => p.id !== pid ? p : { ...p, name: form.nombre, num: parseInt(form.numero)||p.num, pos: form.posicion||p.pos, initials: form.nombre.trim().split(' ').filter(Boolean).map(w=>w[0].toUpperCase()).join('').slice(0,2), }); syncRoster(updated); } } }) .catch(() => setSaving(false)); } const inputSt = { width:'100%', padding:'9px 11px', border:`1.5px solid ${T.border}`, borderRadius:8, fontSize:13, fontFamily:'inherit', color:T.text, outline:'none' }; const lbl = txt => ; if (loading) return
Cargando registros...
; return ( <>
{regs.length} registro{regs.length!==1?'s':''} en total
setSearch(e.target.value)} placeholder="Buscar jugadora o tutor..." style={{padding:'7px 12px 7px 32px',borderRadius:6,border:`1px solid ${T.border}`,fontSize:13,color:T.text,outline:'none',fontFamily:'inherit',width:220,background:'#fff'}} />
{filtered.length === 0 &&
Sin registros{search?' que coincidan':' aun'}.
} {filtered.map(reg => { const j = reg.jugadora || {}; const c = reg.contactos || {}; const isOpen = expanded === reg.id; const st = reg.status || 'pendiente'; const ini = (j.nombre||'?').trim().split(' ').filter(Boolean).map(w=>w[0].toUpperCase()).join('').slice(0,2); return ( {/* Row */}
{reg.foto ? {j.nombre} : }
setExpanded(isOpen ? null : reg.id)}>
{j.nombre || '—'}
{j.categoria && {j.categoria}} {j.numero && #{j.numero}} {j.posicion && {j.posicion}} {c.c1?.nombre && · {c.c1.nombre}}
{statusLabel[st]||st}
{/* Expanded: quick summary */} {isOpen && (
Datos de salud {[ ['Tipo de sangre', reg.salud?.tipoSangre], ['Alergias', reg.salud?.alergias], ['Medicamentos', reg.salud?.medicamentos], ['Condiciones', reg.salud?.condiciones], ['Medico', reg.salud?.medico], ].filter(([,v])=>v).map(([l,v])=>(
{l} {v}
))}
Contacto familiar {[c.c1, c.c2].filter(Boolean).map((p,i) => (
{p.nombre} ({p.relacion})
{p.tel}
{p.email}
))}
Emergencia
{c.emergencia?.nombre}
{c.emergencia?.tel}
Fecha de registro
{reg.fecha}
)}
); })}
{/* Edit modal */} {editing && (
Editar datos de la jugadora
{lbl('Nombre completo')}setForm(f=>({...f,nombre:e.target.value}))} style={inputSt} />
{lbl('Categoria')}
{lbl('Posicion')}
{lbl('No. de jersey')}setForm(f=>({...f,numero:e.target.value}))} style={inputSt} />
{lbl('Escuela')}setForm(f=>({...f,escuela:e.target.value}))} style={inputSt} />
{lbl('Fecha de nacimiento')}setForm(f=>({...f,fechaNac:e.target.value}))} style={inputSt} />
{lbl('CURP')}setForm(f=>({...f,curp:e.target.value.toUpperCase()}))} maxLength={18} style={{...inputSt,textTransform:'uppercase'}} />
)} {/* PDF / Full registration view */} {pdfReg && setPdfReg(null)} />} ); } function RegistroPdfModal({ reg, onClose }) { const j = reg.jugadora || {}; const s = reg.salud || {}; const c = reg.contactos || {}; const au = reg.autorizaciones || []; const section = (title, rows) => (
{title}
{rows.filter(([,v])=>v!=null&&v!=='').map(([l,v])=>(
{l}: {v}
))}
); const AUTH_LABELS = { auth_foto: 'Autorizo uso de fotografía y video', auth_medica: 'Autorizo atención médica de emergencia', auth_transporte: 'Autorizo transporte a eventos', auth_datos: 'Autorizo uso de datos personales', }; return (
{/* Modal header */}
Hoja de registro
{j.nombre} · Tormentas Basketball 2026
{/* Photo + basic */}
{reg.foto && }
{j.nombre}
{j.categoria && {j.categoria}} {j.posicion && {j.posicion}} {j.numero && #{j.numero}}
Registrada el {reg.fecha} · ID: {reg.id}
{section('Datos de la jugadora', [ ['Fecha de nacimiento', j.fechaNac], ['CURP', j.curp], ['Escuela / Colegio', j.escuela], ])} {section('Salud', [ ['Tipo de sangre', s.tipoSangre], ['Alergias', s.alergias], ['Medicamentos', s.medicamentos], ['Condiciones', s.condiciones], ['Seguro médico', s.seguro], ['Médico', s.medico], ['Tel. médico', s.telMedico], ])} {section('Contacto 1', [ ['Nombre', c.c1?.nombre], ['Relación', c.c1?.relacion], ['Teléfono', c.c1?.tel], ['Correo', c.c1?.email], ])} {c.c2?.nombre && section('Contacto 2', [ ['Nombre', c.c2?.nombre], ['Relación', c.c2?.relacion], ['Teléfono', c.c2?.tel], ['Correo', c.c2?.email], ])} {section('Contacto de emergencia', [ ['Nombre', c.emergencia?.nombre], ['Teléfono', c.emergencia?.tel], ])} {/* Autorizaciones */} {au.length > 0 && (
Autorizaciones otorgadas
{au.map(k => (
{AUTH_LABELS[k] || k}
))}
)} {/* Firma */} {reg.firma && (
Firma del responsable
Firma
{c.c1?.nombre} — {c.c1?.relacion}
)}
); } // ── PENDIENTES TAB ──────────────────────────────────────────── function PendientesTab({ onApprove }) { const [regs, setRegs] = React.useState([]); const [loading, setLoading] = React.useState(true); const [expanded, setExpanded] = React.useState(null); const [confirm, setConfirm] = React.useState(null); React.useEffect(() => { fetch('/api/registros.php') .then(r => r.json()) .then(serverRegs => { setRegs(Array.isArray(serverRegs) ? serverRegs : []); setLoading(false); }) .catch(() => setLoading(false)); }, []); const pending = regs.filter(r => r.status === 'pendiente'); const approveReg = (reg) => { const players = window.TData.players; const newId = Math.max(0, ...players.map(p => p.id)) + 1; const words = reg.jugadora.nombre.trim().split(' ').filter(Boolean); const initials = words.map(w => w[0].toUpperCase()).join('').slice(0, 2); const num = parseInt(reg.jugadora.numero) || 0; const pos = reg.jugadora.posicion || 'Base'; const newPlayer = { id: newId, name: reg.jugadora.nombre, num, pos, initials, hand: 'Derecha', color: '#FF6B9D', attendancePct: 100, }; const updPlayers = [...players, newPlayer]; syncRoster(updPlayers); // Save contact data const parents = [reg.contactos.c1, reg.contactos.c2].filter(Boolean).map(c => ({ name: c.nombre, rel: c.relacion, phone: c.tel, email: c.email || '', })); const contact = { dob: reg.jugadora.fechaNac, school: reg.jugadora.escuela || '', blood: reg.salud.tipoSangre, allergies: reg.salud.alergias || 'Ninguna', parents, emergency: { name: reg.contactos.emergencia.nombre, phone: reg.contactos.emergencia.tel }, seguro: reg.salud.seguro || '', medicamentos: reg.salud.medicamentos || '', condiciones: reg.salud.condiciones || '', medico: reg.salud.medico || '', telMedico: reg.salud.telMedico || '', curp: reg.jugadora.curp || '', categoria: reg.jugadora.categoria || '', firma: reg.firma, autorizaciones: reg.autorizaciones, }; // Transfer photo server-side if present if (reg.foto) { fetch('/api/photos.php', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ adminKey: 'TormPortal2026', playerId: newId, photo: reg.foto }), }).catch(() => {}); } // Update server fetch('/api/registros.php?action=update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: reg.id, status: 'aprobada', playerId: newId }), }).catch(() => {}); setRegs(prev => prev.map(r => r.id === reg.id ? { ...r, status: 'aprobada', playerId: newId } : r)); setConfirm(null); setExpanded(null); onApprove && onApprove(); }; const rejectReg = (regId) => { fetch('/api/registros.php?action=update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: regId, status: 'rechazado' }), }).catch(() => {}); setRegs(prev => prev.map(r => r.id === regId ? { ...r, status: 'rechazado' } : r)); setExpanded(null); onApprove && onApprove(); }; if (loading) return (
Cargando registros...
); if (pending.length === 0) return (
Sin registros pendientes
Los registros del formulario publico apareceran aqui.
URL del formulario: tormentas.muga.com.mx/registro.html
); return (
{pending.length} registro{pending.length !== 1 ? 's' : ''} esperando revision
{pending.map(reg => { const isOpen = expanded === reg.id; const photo = reg.foto || null; return ( {/* Row */}
setExpanded(isOpen ? null : reg.id)} style={{ display:'flex', alignItems:'center', gap:14, padding:'14px 20px', cursor:'pointer', background:isOpen?T.accentLt:'#fff', transition:'background .15s' }}> {photo ? : w[0]).join('').slice(0,2).toUpperCase()} color={T.accent} size={38} /> }
{reg.jugadora.nombre}
{reg.jugadora.categoria}  ·  Registrado {reg.fecha}  ·  {reg.contactos.c1.tel}
Pendiente {isOpen ? '▲' : '▼'}
{/* Detail */} {isOpen && (
{/* Datos jugadora */}
Datos de la jugadora {[ ['Nombre completo', reg.jugadora.nombre], ['Fecha de nac.', reg.jugadora.fechaNac], ['Categoria', reg.jugadora.categoria], ['Escuela', reg.jugadora.escuela || '—'], ['Posicion', reg.jugadora.posicion || '—'], ['No. jersey', reg.jugadora.numero || '—'], ['CURP', reg.jugadora.curp || '—'], ].map(([l,v]) => (
{l} {v}
))} Salud {[ ['Tipo de sangre', reg.salud.tipoSangre], ['Seguro', reg.salud.seguro || '—'], ['Alergias', reg.salud.alergias || '—'], ['Medicamentos', reg.salud.medicamentos || '—'], ['Condiciones', reg.salud.condiciones || '—'], ].map(([l,v]) => (
{l} {v}
))}
{/* Contactos */}
Padres / tutores {[reg.contactos.c1, reg.contactos.c2].filter(Boolean).map((c, i) => (
{c.nombre} ({c.relacion})
{c.tel}
{c.email &&
{c.email}
}
))} Emergencia
{reg.contactos.emergencia.nombre}
{reg.contactos.emergencia.tel}
Autorizaciones {[ ['Datos personales', reg.autorizaciones.datos], ['Uso de imagen', reg.autorizaciones.imagen], ['Atencion medica', reg.autorizaciones.medica], ].map(([l,v]) => (
{l} {v?'Autorizado':'No autorizado'}
))}
{/* Firma + foto */}
Fotografia {photo ? :
Sin fotografia
} Firma digital
Firma
Firmado por: {reg.firma.nombre}
{/* Actions */}
)}
); })} {/* Confirm modal */} {confirm && (

Aprobar registro

Se agregara a {confirm.jugadora.nombre} al roster del equipo y sus datos de contacto quedaran guardados.

)}
); } window.AdminJugadoras = AdminJugadoras; // ── SPRINT CRONOMETRO ───────────────────────────────────────── function SprintCronometro({ history, onSave }) { const DISTS = [10, 20, 30, 40, 60]; const [metros, setMetros] = React.useState(20); const [running, setRunning] = React.useState(false); const [tiempo, setTiempo] = React.useState(0); const [justSaved, setJustSaved] = React.useState(false); const intervalRef = React.useRef(null); const startRef = React.useRef(null); React.useEffect(() => () => clearInterval(intervalRef.current), []); const start = () => { if (running) return; startRef.current = Date.now() - tiempo * 10; intervalRef.current = setInterval(() => { setTiempo(Math.round((Date.now() - startRef.current) / 10)); }, 50); setRunning(true); }; const stop = () => { clearInterval(intervalRef.current); setRunning(false); }; const reset = () => { clearInterval(intervalRef.current); setRunning(false); setTiempo(0); setJustSaved(false); }; const guardar = () => { const seg = (tiempo / 100).toFixed(2); onSave({ metros, seg }); setJustSaved(true); setTimeout(() => { reset(); }, 1800); }; const fmt = (cs) => `${Math.floor(cs/100)}.${String(cs%100).padStart(2,'0')}`; const best = history.length ? history.reduce((b,r) => parseFloat(r.seg) < parseFloat(b.seg) ? r : b) : null; return (
Cronometro Sprint
{best &&
Mejor: {best.seg}s · {best.metros}m
}
{/* Distance */}
{DISTS.map(d => ( ))}
{/* Timer */}
{fmt(tiempo)}
seg · Sprint {metros}m
{/* Controls */}
{!running ? : } {tiempo > 0 && !running && !justSaved && ( )} {justSaved &&
✓ Guardado
}
{/* History */} {history.length > 0 && (
Historial
{history.slice(0,6).map((r,i) => (
{r.seg}s {r.metros}m {r.fecha} {r.hora}
))}
)}
); } // ── SALTO DE CUERDA ─────────────────────────────────────────── function SaltoCuerda({ history, onSave }) { const DURAS = [30, 45, 60, 90, 120]; const [duracion, setDuracion] = React.useState(60); const [phase, setPhase] = React.useState('idle'); const [remaining,setRemaining]= React.useState(60); const [saltos, setSaltos] = React.useState(0); const [tapAnim, setTapAnim] = React.useState(false); const [justSaved,setJustSaved]= React.useState(false); const intervalRef = React.useRef(null); React.useEffect(() => () => clearInterval(intervalRef.current), []); const start = () => { setRemaining(duracion); setSaltos(0); setPhase('running'); setJustSaved(false); intervalRef.current = setInterval(() => { setRemaining(r => { if (r <= 1) { clearInterval(intervalRef.current); setPhase('done'); return 0; } return r - 1; }); }, 1000); }; const stop = () => { clearInterval(intervalRef.current); setPhase('done'); }; const reset = () => { clearInterval(intervalRef.current); setPhase('idle'); setRemaining(duracion); setSaltos(0); setJustSaved(false); }; const tap = () => { if (phase !== 'running') return; setSaltos(s => s + 1); setTapAnim(true); setTimeout(() => setTapAnim(false), 100); }; const guardar = () => { const durReal = phase === 'done' ? duracion - remaining : duracion; onSave({ duracion: durReal, saltos }); setJustSaved(true); setTimeout(() => { reset(); setJustSaved(false); }, 1800); }; const fmtTime = s => `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`; const pct = duracion > 0 ? Math.round((1 - remaining/duracion) * 100) : 0; const best = history.length ? Math.max(...history.map(h => h.saltos)) : null; return (
Salto de Cuerda
{best !== null && (
Mejor: {best} saltos
)}
{/* Duration selector */}
{DURAS.map(d => ( ))}
{/* Timer + tap */}
{fmtTime(remaining)}
tiempo restante
{saltos}
{phase==='running'?'TAP':'saltos'}
{/* Controls */}
{phase==='idle' && } {phase==='running' && } {phase==='done' && ( )}
{/* History */} {history.length > 0 && (
Historial
{history.slice(0,6).map(h => (
{h.fecha} {h.hora} {h.saltos} saltos · {h.duracion}s
))}
)}
); } // ── TIROS DE 3 ──────────────────────────────────────────────── function Tiros3({ history, onSave }) { const OPTS = [5, 10, 15, 20, 25]; const [total, setTotal] = React.useState(10); const [shots, setShots] = React.useState([]); const [anotados, setAnotados] = React.useState(0); const [justSaved, setJustSaved]= React.useState(false); const reset = () => { setShots([]); setAnotados(0); setJustSaved(false); }; const tiro = (made) => { if (shots.length >= total) return; setShots(s => [...s, made]); if (made) setAnotados(a => a + 1); }; const done = shots.length >= total; const pct = shots.length > 0 ? Math.round(anotados/shots.length*100) : 0; const guardar = () => { onSave({ intentos: shots.length, anotados, total }); setJustSaved(true); setTimeout(() => { reset(); }, 1800); }; const bestPct = history.length ? Math.max(...history.map(h => h.intentos > 0 ? Math.round(h.anotados/h.intentos*100) : 0)) : null; return (
Tiros de 3 Puntos
{bestPct !== null && (
Mejor: {bestPct}%
)}
{/* Intentos selector */}
{OPTS.map(n => ( ))}
{/* Shot bubbles */}
{Array.from({length:total}).map((_,i) => { const shot = shots[i]; const isNext = i===shots.length; return (
{shot===true?'✓':shot===false?'✗':i+1}
); })}
{/* Score */}
=50?'#15803D':'#C2410C',lineHeight:1}}> {anotados}/{shots.length||total}
{shots.length>0&&
{pct}% efectividad
}
{/* Buttons */} {!done ? (
) : (
Serie completa: {anotados} de {total} ({pct}%)
)} {/* History */} {history.length > 0 && (
Historial
{history.slice(0,6).map(h => { const p = h.intentos>0?Math.round(h.anotados/h.intentos*100):0; return (
{h.fecha} {h.hora} {h.anotados}/{h.intentos} · {p}%
); })}
)}
); } // ── FISICO (ADMIN) ──────────────────────────────────────────── function AdminFisico() { const { players } = window.TData; const { evalDates } = window.TDataExt; const [selPlayer, setSelPlayer] = React.useState(null); const [form, setForm] = React.useState(null); const [actTab, setActTab] = React.useState('sprint'); const [actHistory, setActHistory] = React.useState({ sprint:[], cuerda:[], tiros3:[] }); const loadHistory = (pid) => { fetch(`/api/fisico.php?playerId=${pid}`) .then(r => r.json()) .then(d => setActHistory({ sprint: d.sprint||[], cuerda: d.cuerda||[], tiros3: d.tiros3||[] })) .catch(() => {}); }; const FIELDS = [ { key:"altura", label:"Altura (cm)", step:1 }, { key:"peso", label:"Peso (kg)", step:0.1 }, { key:"grasa", label:"Grasa corporal (%)", step:0.1 }, { key:"musculo", label:"Masa muscular (%)", step:0.1 }, { key:"sprint", label:"Sprint 20m (seg)", step:0.01}, { key:"salto", label:"Salto vertical (cm)",step:1 }, { key:"agilidad", label:"Agilidad (seg)", step:0.01}, { key:"vo2", label:"VO₂ estimado", step:1 }, { key:"fuerza", label:"Fuerza agarre (kg)", step:0.5 }, ]; const openPlayer = (p) => { setSelPlayer(p); setActHistory({ sprint:[], cuerda:[], tiros3:[] }); loadHistory(p.id); fetch('/api/physdata.php?playerId=' + p.id) .then(r => r.json()) .then(rawArr => { if (Array.isArray(rawArr) && rawArr.length > 0) { setForm(rawArr.map(e => ({ altura:e[0]||"", peso:e[1]||"", grasa:e[2]||"", musculo:e[3]||"", sprint:e[4]||"", salto:e[5]||"", agilidad:e[6]||"", vo2:e[7]||"", fuerza:e[8]||"" }))); } else { const pd = window.TDataExt.physicalData[p.id]; setForm(evalDates.map((_, i) => { const e = pd ? pd[i] : null; return { altura:e?.altura||"", peso:e?.peso||"", grasa:e?.grasa||"", musculo:e?.musculo||"", sprint:e?.sprint||"", salto:e?.salto||"", agilidad:e?.agilidad||"", vo2:e?.vo2||"", fuerza:e?.fuerza||"" }; })); } }) .catch(() => { const pd = window.TDataExt.physicalData[p.id]; setForm(evalDates.map((_, i) => { const e = pd ? pd[i] : null; return { altura:e?.altura||"", peso:e?.peso||"", grasa:e?.grasa||"", musculo:e?.musculo||"", sprint:e?.sprint||"", salto:e?.salto||"", agilidad:e?.agilidad||"", vo2:e?.vo2||"", fuerza:e?.fuerza||"" }; })); }); }; const handleSave = () => { const rawArr = form.map(e => [ +e.altura||0, +e.peso||0, +e.grasa||0, +e.musculo||0, +e.sprint||0, +e.salto||0, +e.agilidad||0, +e.vo2||0, +e.fuerza||0 ]); fetch('/api/physdata.php', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ adminKey: 'TormPortal2026', playerId: selPlayer.id, data: rawArr }), }).catch(() => {}); // Update in memory so ProFisico reflects changes immediately const mapped = rawArr.map((e, i) => ({ date:evalDates[i]||"", altura:e[0], peso:e[1], grasa:e[2], musculo:e[3], sprint:e[4], salto:e[5], agilidad:e[6], vo2:e[7], fuerza:e[8] })); const first = rawArr[0], last = rawArr[rawArr.length-1]; mapped.delta = { peso: +(last[1]-first[1]).toFixed(1), grasa: +(last[2]-first[2]).toFixed(1), musculo: +(last[3]-first[3]).toFixed(1), sprint: +(last[4]-first[4]).toFixed(2), salto: +(last[5]-first[5]).toFixed(0), agilidad:+(last[6]-first[6]).toFixed(2), vo2: +(last[7]-first[7]).toFixed(0), }; mapped.latest = mapped[mapped.length-1]; window.TDataExt.physicalData[selPlayer.id] = mapped; setSelPlayer(null); setForm(null); }; const inputSt = { width:"100%", padding:"8px 10px", border:"1.5px solid #e2e8f0", borderRadius:6, fontSize:13, fontFamily:"inherit", outline:"none", color:"#0f172a", background:"#f8fafc", boxSizing:"border-box" }; const saveActivity = (type, result) => { const now = new Date(); const fecha = now.toLocaleDateString('es-MX', { day:'2-digit', month:'2-digit', year:'numeric' }); const hora = now.toLocaleTimeString('es-MX', { hour:'2-digit', minute:'2-digit' }); const entry = { id: Date.now(), fecha, hora, ...result }; setActHistory(prev => ({ ...prev, [type]: [entry, ...prev[type]].slice(0, 100), })); fetch('/api/fisico.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ adminKey: 'TormPortal2026', playerId: selPlayer.id, type, result: entry }), }).catch(() => {}); }; if (selPlayer && form) { const ACT_TABS = [ { id:'sprint', label:'Sprint', color:'#0369A1', bg:'#F0F9FF' }, { id:'cuerda', label:'Salto de Cuerda',color:'#15803D', bg:'#F0FDF4' }, { id:'tiros3', label:'Tiros de 3', color:'#C2410C', bg:'#FFF7ED' }, { id:'mediciones', label:'Mediciones', color:T.muted, bg:T.stripe }, ]; return (
{ setSelPlayer(null); setForm(null); }} label="Condición Física" />
{selPlayer.name}
#{selPlayer.num} · {selPlayer.pos}
{/* Activity tabs */}
{ACT_TABS.map(t => ( ))}
{/* Sprint tab */} {actTab === 'sprint' && ( saveActivity('sprint', result)} /> )} {/* Salto de Cuerda tab */} {actTab === 'cuerda' && ( saveActivity('cuerda', result)} /> )} {/* Tiros de 3 tab */} {actTab === 'tiros3' && ( saveActivity('tiros3', result)} /> )} {/* Mediciones tab */} {actTab === 'mediciones' && ( <>
{evalDates.map((date, ei) => (
Evaluación · {date}
{FIELDS.map(f => (
setForm(prev => prev.map((row, i) => i===ei ? {...row, [f.key]:e.target.value} : row))} style={inputSt} />
))}
))}
)}
); } return (

Condición Física

Editar mediciones por jugadora · {evalDates.length} evaluaciones registradas

{players.map((p, i) => { const hasData = !!window.TDataExt.physicalData[p.id]; return (
openPlayer(p)} style={{ display:"flex", alignItems:"center", gap:14, padding:"14px 20px", borderBottom: i < players.length-1 ? `1px solid ${T.border}` : "none", cursor:"pointer", transition:"background 0.12s" }} onMouseEnter={e=>e.currentTarget.style.background=T.stripe} onMouseLeave={e=>e.currentTarget.style.background="#fff"} >
{p.name}
#{p.num} · {p.pos}
{hasData ? "Con datos" : "Sin datos"}
); })}
); } window.AdminFisico = AdminFisico; // ── ADMIN PARTIDOS (GESTION) ────────────────────────────────── function AdminPartidos() { const today = new Date().toISOString().split("T")[0]; const [games, setGames] = React.useState(() => window.TData.games || []); const [loading, setLoading] = React.useState(true); const [adding, setAdding] = React.useState(false); const [form, setForm] = React.useState({ rival:'', date:today, home:true, scoreUs:'', scoreThem:'' }); const [delId, setDelId] = React.useState(null); React.useEffect(() => { fetch('/api/partidos.php').then(r => r.json()).then(data => { if (data.games) { const g = data.games; setGames(g); window.TData.games = g; if (data.rawStats) { window.TData.rawStats = data.rawStats; } } setLoading(false); }).catch(() => setLoading(false)); }, []); function syncGames(updGames, updRaw) { const rawStats = updRaw || window.TData.rawStats || {}; setGames(updGames); window.TData.games = updGames; if (updRaw) { window.TData.rawStats = updRaw; } fetch('/api/partidos.php', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ adminKey:'TormPortal2026', games:updGames, rawStats }), }).catch(() => {}); } function addGame() { if (!form.rival.trim() || form.scoreUs === '' || form.scoreThem === '') return; const us = parseInt(form.scoreUs) || 0; const them = parseInt(form.scoreThem) || 0; const result = us > them ? 'W' : us < them ? 'L' : 'E'; const id = Math.max(0, ...games.map(g => g.id), 0) + 1; const newGame = { id, date:form.date, rival:form.rival.trim(), result, scoreUs:us, scoreThem:them, location:form.home?'Local':'Visita' }; syncGames([...games, newGame]); setAdding(false); setForm({ rival:'', date:today, home:true, scoreUs:'', scoreThem:'' }); } function deleteGame(id) { const updGames = games.filter(g => g.id !== id); const raw = {...(window.TData.rawStats || {})}; delete raw[id]; syncGames(updGames, raw); setDelId(null); } const wins = games.filter(g => g.result === 'W').length; const losses = games.filter(g => g.result === 'L').length; const avgPts = games.length ? (games.reduce((s,g) => s+(g.scoreUs||0), 0)/games.length).toFixed(1) : '—'; const avgCon = games.length ? (games.reduce((s,g) => s+(g.scoreThem||0), 0)/games.length).toFixed(1) : '—'; const inputSt = { width:'100%', padding:'10px 12px', border:`1.5px solid ${T.border}`, borderRadius:8, fontSize:13, fontFamily:'inherit', color:T.text, outline:'none', background:'#f8fafc', boxSizing:'border-box', }; const btnSt = (c, bg, bd) => ({ padding:'10px 18px', borderRadius:8, border:`1px solid ${bd||bg}`, cursor:'pointer', fontWeight:700, fontSize:13, fontFamily:'inherit', color:c, background:bg }); return (

Partidos

Historial de partidos de la temporada

{/* KPIs */}
wins?T.red:T.muted} />
{loading ? (
Cargando partidos...
) : games.length === 0 ? (
🏀
Sin partidos registrados
Agrega un partido manualmente o usa el Anotador en vivo.
) : ( {[...games].reverse().map((g, i) => { const isW = g.result === 'W', isL = g.result === 'L'; const rc = isW ? T.green : isL ? T.red : T.muted; const rbg = isW ? T.greenLt: isL ? T.redLt: T.stripe; const rl = isW ? 'Victoria' : isL ? 'Derrota' : 'Empate'; return (
{g.result||'?'}
{g.rival}
{g.date} · {rl} · {g.location||'Local'}
{g.scoreUs} - {g.scoreThem}
Tormentas vs Rival
); })}
)} {/* Add modal */} {adding && (
Agregar partido
setForm(f=>({...f,rival:e.target.value}))} placeholder="Nombre del equipo rival" style={inputSt} />
setForm(f=>({...f,date:e.target.value}))} style={inputSt} />
setForm(f=>({...f,scoreUs:e.target.value}))} placeholder="0" style={inputSt} />
setForm(f=>({...f,scoreThem:e.target.value}))} placeholder="0" style={inputSt} />
{[{l:'Local',v:true},{l:'Visita',v:false}].map(o => ( ))}
)} {/* Delete confirm */} {delId && (
⚠️

Eliminar partido

vs {games.find(g=>g.id===delId)?.rival} — Se eliminara el partido y sus estadisticas individuales.

)}
); } window.AdminPartidos = AdminPartidos; // ── ANOTADOR EN VIVO ────────────────────────────────────────── function AdminPartido() { const { players } = window.TData; const today = new Date().toISOString().split("T")[0]; const initStats = () => { const s = {}; players.forEach(p => { s[p.id] = [0,0,0,0,0,0,0,0,0,0,0]; }); return s; }; const [phase, setPhase] = React.useState("setup"); const [gameInfo, setGameInfo] = React.useState({ rival:"", date:today, home:true }); const [stats, setStats] = React.useState(initStats); const [scoreUs, setScoreUs] = React.useState(0); const [scoreThem, setScoreThem] = React.useState(0); const [sel, setSel] = React.useState(null); const [hist, setHist] = React.useState([]); const [flash, setFlash] = React.useState(null); const addStat = (pid, field, delta, scoreDelta) => { setStats(prev => { const row = [...prev[pid]]; row[field] = Math.max(0, row[field] + delta); setHist(h => [...h.slice(-40), { pid, field, delta, prevRow:[...prev[pid]], scoreDelta:scoreDelta||0 }]); return { ...prev, [pid]: row }; }); if (scoreDelta) setScoreUs(s => Math.max(0, s + scoreDelta)); setFlash(pid); setTimeout(() => setFlash(f => f === pid ? null : f), 500); }; const undo = () => { if (!hist.length) return; const last = hist[hist.length - 1]; setStats(prev => ({ ...prev, [last.pid]: last.prevRow })); if (last.scoreDelta) setScoreUs(s => Math.max(0, s - last.scoreDelta)); setHist(h => h.slice(0, -1)); }; const saveGame = () => { const games = window.TData.games; const newId = Math.max(0, ...games.map(g => g.id)) + 1; const result = scoreUs > scoreThem ? "W" : scoreUs < scoreThem ? "L" : "E"; const newGame = { id:newId, date:gameInfo.date, rival:gameInfo.rival, result, scoreUs, scoreThem, location: gameInfo.home ? "Local" : "Visita" }; const savedGames = [...games, newGame]; const savedRaw = { ...window.TData.rawStats, [newId]: stats }; window.TData.games = savedGames; window.TData.rawStats = savedRaw; // Auto-sync to server so portal de padres updates immediately fetch('/api/partidos.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ adminKey: 'TormPortal2026', games: savedGames, rawStats: savedRaw }), }).catch(() => {}); setPhase("done"); }; const reset = () => { setPhase("setup"); setStats(initStats()); setScoreUs(0); setScoreThem(0); setSel(null); setHist([]); setGameInfo({ rival:"", date:today, home:true }); }; const ACTIONS = [ { label:"+ 2 Pts", color:"#2563eb", bg:"#dbeafe", fn: pid => { addStat(pid,6,1,0); addStat(pid,7,1,2); } }, { label:"+ 3 Pts", color:"#7c3aed", bg:"#ede9fe", fn: pid => { addStat(pid,6,1,0); addStat(pid,7,1,3); } }, { label:"TL ✓", color:"#059669", bg:"#d1fae5", fn: pid => { addStat(pid,4,1,0); addStat(pid,5,1,1); } }, { label:"TL ✗", color:"#b45309", bg:"#fef3c7", fn: pid => { addStat(pid,5,1,0); } }, { label:"Falla TF", color:"#64748b", bg:"#f1f5f9", fn: pid => { addStat(pid,7,1,0); } }, { label:"Rebote", color:"#0891b2", bg:"#cffafe", fn: pid => { addStat(pid,1,1,0); } }, { label:"Asist.", color:"#d97706", bg:"#fef9c3", fn: pid => { addStat(pid,2,1,0); } }, { label:"Robo", color:"#16a34a", bg:"#dcfce7", fn: pid => { addStat(pid,3,1,0); } }, { label:"Pérdida", color:"#dc2626", bg:"#fee2e2", fn: pid => { addStat(pid,10,1,0); } }, { label:"Falta", color:"#ea580c", bg:"#ffedd5", fn: pid => { addStat(pid,8,1,0); } }, ]; const inputSt = { width:"100%", padding:"11px 14px", border:`1.5px solid ${T.border}`, borderRadius:8, fontSize:14, fontFamily:"inherit", outline:"none", color:T.text, background:"#f8fafc", boxSizing:"border-box" }; const btnSt = (color, bg, bd) => ({ padding:"10px 18px", borderRadius:8, border:`1.5px solid ${bd||color}`, background:bg, color, fontWeight:700, fontSize:13, cursor:"pointer", fontFamily:"inherit" }); /* ── SETUP ── */ if (phase === "setup") { return (

Anotador

Registra las estadísticas de un partido en tiempo real

setGameInfo(g=>({...g,rival:e.target.value}))} placeholder="Nombre del equipo rival" style={inputSt} />
setGameInfo(g=>({...g,date:e.target.value}))} style={inputSt} />
{[{l:"Local",v:true},{l:"Visita",v:false}].map(o => ( ))}
); } /* ── DONE ── */ if (phase === "done") { const result = scoreUs > scoreThem ? "V" : scoreUs < scoreThem ? "D" : "E"; const resColor = scoreUs > scoreThem ? T.green : scoreUs < scoreThem ? T.red : T.muted; const topPts = [...players].sort((a,b) => (stats[b.id]?.[0]||0) - (stats[a.id]?.[0]||0)).slice(0,3); return (
{scoreUs > scoreThem ? "🏆" : "💪"}
vs {gameInfo.rival} · {gameInfo.date}
{scoreUs} - {scoreThem}
{result === "V" ? "Victoria" : result === "D" ? "Derrota" : "Empate"}
Mejores anotadoras {topPts.map((p,i) => (
{i+1}
{p.name}
R:{stats[p.id][1]} A:{stats[p.id][2]} F:{stats[p.id][8]}
{stats[p.id][0]}
))}
); } /* ── LIVE ── */ const selPlayer = sel ? players.find(p => p.id === sel) : null; return (
{/* Marcador */}
TORMENTAS
{scoreUs}
VS
{[{l:"+2",d:2},{l:"+3",d:3},{l:"TL",d:1}].map(b => ( ))}
{gameInfo.rival.toUpperCase()}
{scoreThem}
{/* Toolbar */}
{sel ? `Acción para ${selPlayer?.name.split(" ")[0]}` : "Selecciona una jugadora"}
{hist.length > 0 && ( )}
{/* Jugadoras grid */}
{players.map(p => { const s = stats[p.id] || [0,0,0,0,0,0,0,0,0,0,0]; const isSel = sel === p.id; const posColor = posColorsP[p.pos] || T.accent; return (
setSel(p.id)} style={{ borderRadius:12, padding:"12px 8px", textAlign:"center", cursor:"pointer", background: flash === p.id ? "#dcfce7" : isSel ? posColor : "#fff", border: `2px solid ${flash === p.id ? "#16a34a" : isSel ? posColor : T.border}`, boxShadow: isSel ? `0 4px 14px ${posColor}50` : flash === p.id ? "0 0 0 3px #16a34a30" : "none", transition:"background 0.15s, border-color 0.15s, box-shadow 0.15s", userSelect:"none", WebkitTapHighlightColor:"transparent" }}>
{s[0]}
PTS
{p.name.split(" ")[0]}
#{p.num}
{[["R",s[1]],["A",s[2]],["F",s[8]]].map(([l,v])=>( {l}{v} ))}
); })}
{/* Modal overlay de acciones */} {sel && (
setSel(null)} style={{ position:"fixed", inset:0, zIndex:1040, background:"rgba(0,0,0,0.45)", display:"flex", alignItems:"flex-end" }} >
e.stopPropagation()} style={{ background:"#fff", borderRadius:"20px 20px 0 0", padding:"20px 16px 36px", width:"100%", boxShadow:"0 -8px 32px rgba(0,0,0,0.2)" }} > {/* Drag handle */}
{/* Header jugadora */}
{selPlayer?.name} · #{selPlayer?.num}
{stats[sel][0]} pts · {stats[sel][1]} reb · {stats[sel][2]} ast · {stats[sel][8]} f
{/* Acciones */}
{ACTIONS.map(a => ( ))}
)}
); } window.AdminPartido = AdminPartido; // ── PORTAL PADRES ─────────────────────────────────────────── function AdminPortal() { const PORTAL_URL = 'https://tormentas.muga.com.mx/portal.html'; const [status, setStatus] = React.useState(null); // null | 'publishing' | 'ok' | 'err' const [lastPublished, setLastPublished] = React.useState(null); const [errMsg, setErrMsg] = React.useState(''); function publishStats() { setStatus('publishing'); setErrMsg(''); const players = window.TData.players; const games = window.TData.games; const rawStats = window.TData.rawStats; const upcomingEvents = window.TData.upcomingEvents || []; const trainingDates = window.TData.trainingDates || []; fetch('/api/asistencia.php') .then(r => r.json()) .then(attendance => { return fetch('/api/stats.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ adminKey: ADMIN_KEY, players, games, rawStats, attendance, upcomingEvents, trainingDates }), }); }) .then(r => r.json()) .then(d => { if (d.ok) { const ts = new Date().toLocaleString('es-MX', { day:'numeric', month:'long', hour:'2-digit', minute:'2-digit' }); setLastPublished(ts); setStatus('ok'); } else { setErrMsg(d.error || 'Error desconocido'); setStatus('err'); } }) .catch(e => { setErrMsg('Error de conexion: ' + e.message); setStatus('err'); }); } function copyLink() { navigator.clipboard.writeText(PORTAL_URL).then(() => { const btn = document.getElementById('btn-copy-portal'); if (btn) { btn.textContent = 'Copiado'; setTimeout(() => { btn.textContent = 'Copiar enlace'; }, 2000); } }).catch(() => {}); } const { players } = window.TData; const rawStats = window.TData.rawStats || {}; const games = window.TData.games || []; const gamesWithStats = games.filter(g => rawStats[g.id] && Object.keys(rawStats[g.id]).length > 0).length; return (

Portal de Padres

Publica las estadisticas para que los padres y tutores puedan verlas.

{/* Status banner */}
{/* Publish card */}
Publicar estadisticas al portal

Envia las estadisticas actuales (jugadoras, partidos y stats) al portal para que los padres registrados puedan verlas. Los datos se actualizan cada vez que presiones este boton.

{status === 'ok' && (
Estadisticas publicadas correctamente. Los padres ya pueden verlas en el portal.
)} {status === 'err' && (
{errMsg || 'Error al publicar. Intenta de nuevo.'}
)}
{/* Portal link card */}
Enlace del portal

Comparte este enlace con los padres y tutores para que puedan crear su cuenta y ver las estadisticas de su jugadora.

{PORTAL_URL}
Abrir portal
{/* Info card */}
Como funciona
{[ ["1", "Los padres reciben el correo de confirmacion con el enlace al portal tras registrar a su jugadora."], ["2", "Crean su cuenta con el mismo correo que usaron en el formulario de registro."], ["3", "El sistema vincula automaticamente su cuenta con las jugadoras de ese correo."], ["4", "Cuando publicas las estadisticas, los padres pueden ver los promedios de su hija y del equipo."], ].map(([n, txt]) => (
{n}

{txt}

))}
{/* Resend approval emails */}
); } window.AdminPortal = AdminPortal; function ResendApprovalCard() { const [regs, setRegs] = React.useState([]); const [sending, setSending] = React.useState({}); const [sent, setSent] = React.useState({}); React.useEffect(() => { fetch('/api/registros.php') .then(r => r.json()) .then(data => { const approved = (Array.isArray(data) ? data : []).filter(r => r.status === 'aprobada'); setRegs(approved); }) .catch(() => {}); }, []); function resend(reg) { setSending(p => ({ ...p, [reg.id]: true })); fetch('/api/registros.php?action=resend_approval', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: reg.id }), }) .then(r => r.json()) .then(d => { setSending(p => ({ ...p, [reg.id]: false })); if (d.ok) setSent(p => ({ ...p, [reg.id]: true })); }) .catch(() => setSending(p => ({ ...p, [reg.id]: false }))); } if (!regs.length) return null; return (
Reenviar correo de aprobacion

Reenvía el correo de aprobación a los padres de jugadoras ya aprobadas (útil si el correo no llegó).

{regs.map(reg => { const nombre = reg.jugadora?.nombre || '—'; const emails = [reg.contactos?.c1?.email, reg.contactos?.c2?.email].filter(Boolean).join(', '); const isSending = sending[reg.id]; const isSent = sent[reg.id]; return (
{nombre}
{emails || 'Sin email'}
{isSent ? ✓ Enviado : }
); })}
); } window.ResendApprovalCard = ResendApprovalCard; // ── ADMIN USUARIOS ────────────────────────────────────────── const ADMIN_KEY_U = 'TormPortal2026'; function AdminUsuarios() { const [users, setUsers] = React.useState([]); const [loading, setLoading] = React.useState(true); const [editing, setEditing] = React.useState(null); // user object being edited const [deleting, setDeleting] = React.useState(null); // user id pending confirm const [saving, setSaving] = React.useState(false); const [msg, setMsg] = React.useState(null); // {type:'ok'|'err', text} // Edit form state const [editNombre, setEditNombre] = React.useState(''); const [editEmail, setEditEmail] = React.useState(''); const [editPass, setEditPass] = React.useState(''); function load() { setLoading(true); fetch(`/api/usuarios.php?action=admin_list&adminKey=${ADMIN_KEY_U}`) .then(r => r.json()) .then(d => { setUsers(Array.isArray(d) ? d : []); setLoading(false); }) .catch(() => { setUsers([]); setLoading(false); }); } React.useEffect(() => { load(); }, []); function openEdit(user) { setEditing(user); setEditNombre(user.nombre); setEditEmail(user.email); setEditPass(''); setMsg(null); } function saveEdit() { if (!editNombre.trim() || !editEmail.trim()) return; setSaving(true); const body = { adminKey: ADMIN_KEY_U, id: editing.id, nombre: editNombre.trim(), email: editEmail.trim() }; if (editPass.length >= 6) body.password = editPass; fetch('/api/usuarios.php?action=admin_update', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body), }) .then(r => r.json()) .then(d => { setSaving(false); if (d.ok) { setMsg({ type:'ok', text:'Usuario actualizado.' }); setEditing(null); load(); } else { setMsg({ type:'err', text: d.error || 'Error al guardar.' }); } }) .catch(() => { setSaving(false); setMsg({ type:'err', text:'Error de conexion.' }); }); } function confirmDelete(userId) { fetch('/api/usuarios.php?action=admin_delete', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ adminKey: ADMIN_KEY_U, id: userId }), }) .then(r => r.json()) .then(d => { setDeleting(null); if (d.ok) { setMsg({ type:'ok', text:'Cuenta eliminada.' }); load(); } else setMsg({ type:'err', text: d.error || 'Error al eliminar.' }); }) .catch(() => { setDeleting(null); setMsg({ type:'err', text:'Error de conexion.' }); }); } const statusColor = { aprobada:'#16A34A', pendiente:'#D97706', rechazado:'#DC2626' }; const statusLabel = { aprobada:'Aprobada', pendiente:'Pendiente', rechazado:'Rechazado' }; return (

Usuarios del portal

Padres y tutores con cuenta en el portal de estadisticas.

{/* Global message */} {msg && (
{msg.text}
)} {/* KPI */}
s+u.jugadoras.length,0)} color={T.accent} /> u.jugadoras.some(j=>j.status==='aprobada')).length} color={T.green} />
{/* Table */} {loading ? (
Cargando usuarios...
) : users.length === 0 ? (
👤
Sin cuentas registradas
Los padres crean su cuenta desde el portal de padres.
) : (
{/* Table header */}
Usuario
Jugadoras vinculadas
Acciones
{users.map((u, i) => (
{/* User info */}
{u.nombre}
{u.email}
{u.createdAt &&
Cuenta creada: {u.createdAt}
}
{/* Jugadoras */}
{u.jugadoras.length === 0 ? Sin jugadoras vinculadas : u.jugadoras.map(j => (
{j.nombre} {j.numero && #{j.numero}} {statusLabel[j.status]||j.status}
)) }
{/* Actions */}
))}
)} {/* Edit modal */} {editing && (
Editar usuario
{msg &&
{msg.text}
}
setEditNombre(e.target.value)} style={{width:'100%',padding:'9px 11px',border:`1.5px solid ${T.border}`,borderRadius:8,fontSize:13,fontFamily:'inherit',color:T.text}} />
setEditEmail(e.target.value)} style={{width:'100%',padding:'9px 11px',border:`1.5px solid ${T.border}`,borderRadius:8,fontSize:13,fontFamily:'inherit',color:T.text}} />
setEditPass(e.target.value)} placeholder="Min. 6 caracteres" style={{width:'100%',padding:'9px 11px',border:`1.5px solid ${T.border}`,borderRadius:8,fontSize:13,fontFamily:'inherit',color:T.text}} />
)} {/* Delete confirm */} {deleting && (
⚠️
Eliminar cuenta

Esta accion es permanente. El usuario perdera acceso al portal. Los registros de las jugadoras no se eliminan.

)}
); } window.AdminUsuarios = AdminUsuarios;