// 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 => (
setSelMonth(m)} style={{
padding:"7px 20px", border:`1px solid ${selMonth===m?T.accent:T.border}`,
borderRadius:6, background:selMonth===m?T.accentLt:"#fff",
color:selMonth===m?T.accent:T.muted,
fontWeight:selMonth===m?600:400, fontSize:13,
cursor:"pointer", fontFamily:"inherit", transition:"all 0.12s"
}}>{monthLabels[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: (
),
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 (
{e.stopPropagation();unpay(p.id,selMonth);}} style={{
padding:"5px 12px", borderRadius:5, fontWeight:600, fontSize:11,
cursor:"pointer", fontFamily:"inherit",
border:"1px solid #fecaca", background:T.redLt, color:T.red,
}}>Marcar pendiente
);
if (picking) return (
Método:
{["Efectivo","Transferencia"].map(m => (
{e.stopPropagation();confirmPay(p.id,selMonth,m);}} style={{
padding:"5px 10px", borderRadius:5, fontWeight:600, fontSize:11,
cursor:"pointer", fontFamily:"inherit",
border:`1px solid ${T.accentMd}`, background:T.accentLt, color:T.accent,
}}>{m}
))}
{e.stopPropagation();setMethodPick(null);}} style={{
padding:"5px 8px", borderRadius:5, fontWeight:700, fontSize:12,
cursor:"pointer", fontFamily:"inherit",
border:`1px solid ${T.border}`, background:"#fff", color:T.muted,
}}>✕
);
return (
{e.stopPropagation();setMethodPick({pid:p.id,month:selMonth});}} style={{
padding:"5px 12px", borderRadius:5, fontWeight:600, fontSize:11,
cursor:"pointer", fontFamily:"inherit",
border:`1px solid ${T.accentMd}`, background:T.accentLt, color:T.accent,
}}>Marcar pagado
);
})(),
};
})}
/>
);
}
// ── 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} />
Jugadora
{DOC_TYPES.map(dt=>(
{dt.label}
))}
{players.map((p, pi) => (
{DOC_TYPES.map(dt => {
const s = docs[p.id]?.[dt.key] || "faltante";
return (
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) => (
setActiveTab(id)} style={{
padding:"8px 16px", border:"none", cursor:"pointer", fontFamily:"inherit",
background: activeTab === id ? T.accent : "transparent",
color: activeTab === id ? "#fff" : T.muted,
fontWeight: activeTab === id ? 700 : 500,
fontSize:13, borderRadius:6, transition:"all 0.12s",
display:"flex", alignItems:"center", gap:6
}}>
{label}
{badge > 0 && (
{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) => (
{label}
{opts ? (
setForm(f=>({...f,[key]:e.target.value}))} style={inputSt}>
{opts.map(o => {o} )}
) : (
setForm(f=>({...f,[key]:e.target.value}))} style={inputSt} />
)}
);
return (
<>
+ Agregar jugadora
{players.map((p, i) => (
{p.name}
#{p.num} · {p.pos} · {p.hand}
openEdit(p)} style={{ ...btnSt("#2563eb","#eff6ff"), padding:"7px 14px" }}>Editar
setDeleteId(p.id)} style={{ ...btnSt("#dc2626","#fef2f2"), padding:"7px 14px" }}>Eliminar
))}
{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"])}
Color del avatar
{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"
}} />
))}
Guardar
setModal(null)} style={{ ...btnSt("#64748b","#f1f5f9"), flex:1, padding:"12px" }}>Cancelar
)}
{deleteId && (
⚠️
¿Eliminar jugadora?
{players.find(p=>p.id===deleteId)?.name} — Esta acción no se puede deshacer.
Eliminar
setDeleteId(null)} style={{ ...btnSt("#64748b","#f1f5f9"), flex:1, padding:"12px" }}>Cancelar
)}
>
);
}
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 =>
{txt} ;
if (loading) return
Cargando registros...
;
return (
<>
{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
?
:
}
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}
openEdit(reg)} style={{padding:'6px 12px',background:'#EFF6FF',border:`1px solid #BFDBFE`,borderRadius:7,fontWeight:600,fontSize:11,cursor:'pointer',fontFamily:'inherit',color:'#2563EB'}}>Editar
setPdfReg(reg)} style={{padding:'6px 12px',background:T.stripe,border:`1px solid ${T.border}`,borderRadius:7,fontWeight:600,fontSize:11,cursor:'pointer',fontFamily:'inherit',color:T.text}}>Ver registro
setExpanded(isOpen?null:reg.id)} style={{background:'none',border:'none',cursor:'pointer',color:T.muted,fontSize:12,padding:'4px 6px'}}>{isOpen?'▲':'▼'}
{/* 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')}
setForm(f=>({...f,categoria:e.target.value}))} style={inputSt}>
— Sin asignar —
{CATEGORIAS_EDIT.map(c=>{c} )}
{lbl('Posicion')}
setForm(f=>({...f,posicion:e.target.value}))} style={inputSt}>
— Sin asignar —
{POSICIONES_EDIT.map(p=>{p} )}
setEditing(null)} style={{flex:1,padding:'11px',background:'#F8FAFC',border:`1px solid ${T.border}`,borderRadius:8,fontWeight:600,fontSize:13,cursor:'pointer',fontFamily:'inherit',color:T.text}}>Cancelar
{saving ? 'Guardando...' : 'Guardar cambios'}
)}
{/* 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
window.print()} style={{padding:'8px 16px',background:'#FF6B9D',color:'#fff',border:'none',borderRadius:7,fontWeight:700,fontSize:12,cursor:'pointer'}}>Imprimir
✕
{/* 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
{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 (
);
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
Firmado por: {reg.firma.nombre}
{/* Actions */}
setConfirm(reg)} style={{
padding:'10px 24px', borderRadius:8, border:'none', cursor:'pointer',
background:'#2563eb', color:'#fff', fontWeight:700, fontSize:13, fontFamily:'inherit'
}}>Aprobar y agregar al roster
rejectReg(reg.id)} style={{
padding:'10px 18px', borderRadius:8, border:`1px solid ${T.border}`,
cursor:'pointer', background:'#fff', color:T.muted, fontWeight:600, fontSize:13, fontFamily:'inherit'
}}>Rechazar
)}
);
})}
{/* Confirm modal */}
{confirm && (
✓
Aprobar registro
Se agregara a {confirm.jugadora.nombre} al roster del equipo y sus datos de contacto quedaran guardados.
approveReg(confirm)} style={{
flex:1, padding:'12px', borderRadius:8, border:'none', cursor:'pointer',
background:'#2563eb', color:'#fff', fontWeight:700, fontSize:13, fontFamily:'inherit'
}}>Confirmar
setConfirm(null)} style={{
flex:1, padding:'12px', borderRadius:8, border:`1px solid ${T.border}`,
cursor:'pointer', background:'#fff', color:T.muted, fontWeight:600, fontSize:13, fontFamily:'inherit'
}}>Cancelar
)}
);
}
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 => (
{if(!running){setMetros(d);setTiempo(0);setJustSaved(false);}}} style={{
flex:1,padding:'7px 0',borderRadius:7,border:'none',
cursor:running?'default':'pointer',fontWeight:700,fontSize:12,fontFamily:'inherit',
background:metros===d?'#0369A1':'#E0F2FE',color:metros===d?'#fff':'#0369A1',
opacity:running&&metros!==d?0.4:1,transition:'all .12s',
}}>{d}m
))}
{/* Timer */}
{fmt(tiempo)}
seg · Sprint {metros}m
{/* Controls */}
{!running
?
▶ Iniciar
:
■ Detener
}
{tiempo > 0 && !running && !justSaved && (
✓ Guardar {(tiempo/100).toFixed(2)}s
)}
{justSaved &&
✓ Guardado
}
Reset
{/* 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 => (
{if(phase==='idle'){setDuracion(d);setRemaining(d);}}} style={{
flex:1,padding:'7px 0',borderRadius:7,border:'none',
cursor:phase!=='idle'?'default':'pointer',fontWeight:700,fontSize:11,fontFamily:'inherit',
background:duracion===d?'#15803D':'#DCFCE7',color:duracion===d?'#fff':'#15803D',
opacity:phase!=='idle'&&duracion!==d?0.4:1,
}}>{d}s
))}
{/* Timer + tap */}
{fmtTime(remaining)}
tiempo restante
{saltos}
{phase==='running'?'TAP':'saltos'}
{/* Controls */}
{phase==='idle' && ▶ Iniciar }
{phase==='running' && ■ Detener }
{phase==='done' && (
{justSaved ? `✓ Guardado` : `Guardar ${saltos} saltos`}
)}
Reset
{/* 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 => (
{if(!shots.length)setTotal(n);}} style={{
flex:1,padding:'7px 0',borderRadius:7,border:'none',
cursor:shots.length?'default':'pointer',fontWeight:700,fontSize:12,fontFamily:'inherit',
background:total===n?'#C2410C':'#FED7AA',color:total===n?'#fff':'#C2410C',
opacity:shots.length&&total!==n?0.4:1,
}}>{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 ? (
tiro(true)} style={{flex:2,padding:'14px',background:'#15803D',color:'#fff',border:'none',borderRadius:10,fontWeight:800,fontSize:15,cursor:'pointer',fontFamily:'inherit'}}>✓ Anoto
tiro(false)} style={{flex:2,padding:'14px',background:'#DC2626',color:'#fff',border:'none',borderRadius:10,fontWeight:800,fontSize:15,cursor:'pointer',fontFamily:'inherit'}}>✗ Fallo
↩
) : (
Serie completa: {anotados} de {total} ({pct}%)
Nueva serie
{justSaved ? '✓ Guardado' : `Guardar ${anotados}/${total}`}
)}
{/* 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 => (
setActTab(t.id)} style={{
flex:1, padding:"9px 6px", border:"none", cursor:"pointer", fontFamily:"inherit",
background: actTab===t.id ? t.bg : "transparent",
color: actTab===t.id ? t.color : T.muted,
fontWeight: actTab===t.id ? 700 : 500,
fontSize:12, borderRadius:7, transition:"all .12s",
outline: actTab===t.id ? `1.5px solid ${t.color}30` : "none",
}}>{t.label}
))}
{/* 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 => (
{f.label}
setForm(prev => prev.map((row, i) => i===ei ? {...row, [f.key]:e.target.value} : row))}
style={inputSt}
/>
))}
))}
Guardar mediciones
{ setSelPlayer(null); setForm(null); }} style={{ padding:"10px 22px", borderRadius:8, border:"none", cursor:"pointer", fontWeight:700, fontSize:13, fontFamily:"inherit", color:"#64748b", background:"#f1f5f9" }}>
Cancelar
>
)}
);
}
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
setAdding(true)} style={{...btnSt('#fff',T.accent),padding:'9px 18px'}}>+ Agregar partido
{/* KPIs */}
{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
setDelId(g.id)} style={{padding:'6px 10px',background:'#FEF2F2',border:'1px solid #FECACA',borderRadius:7,fontWeight:600,fontSize:11,cursor:'pointer',fontFamily:'inherit',color:'#DC2626',flexShrink:0}}>×
);
})}
)}
{/* Add modal */}
{adding && (
Agregar partido
Rival
setForm(f=>({...f,rival:e.target.value}))} placeholder="Nombre del equipo rival" style={inputSt} />
Fecha
setForm(f=>({...f,date:e.target.value}))} style={inputSt} />
Sede
{[{l:'Local',v:true},{l:'Visita',v:false}].map(o => (
setForm(f=>({...f,home:o.v}))} style={{
flex:1, padding:'9px', borderRadius:8,
border:`1.5px solid ${form.home===o.v?T.accent:T.border}`,
background:form.home===o.v?T.accentLt:'#fff',
color:form.home===o.v?T.accent:T.muted,
fontWeight:700, fontSize:13, cursor:'pointer', fontFamily:'inherit'
}}>{o.l}
))}
setAdding(false)} style={{...btnSt(T.muted,'#f8fafc',T.border),flex:1,padding:'11px'}}>Cancelar
Guardar partido
)}
{/* Delete confirm */}
{delId && (
⚠️
Eliminar partido
vs {games.find(g=>g.id===delId)?.rival} — Se eliminara el partido y sus estadisticas individuales.
setDelId(null)} style={{...btnSt(T.muted,'#f8fafc',T.border),flex:1,padding:'11px'}}>Cancelar
deleteGame(delId)} style={{...btnSt('#fff','#DC2626'),flex:1,padding:'11px',border:'none'}}>Eliminar
)}
);
}
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
Rival
setGameInfo(g=>({...g,rival:e.target.value}))}
placeholder="Nombre del equipo rival" style={inputSt} />
Fecha
setGameInfo(g=>({...g,date:e.target.value}))} style={inputSt} />
Sede
{[{l:"Local",v:true},{l:"Visita",v:false}].map(o => (
setGameInfo(g=>({...g,home:o.v}))} style={{
flex:1, padding:"11px", borderRadius:8,
border:`1.5px solid ${gameInfo.home===o.v?T.accent:T.border}`,
background:gameInfo.home===o.v?T.accentLt:"#fff",
color:gameInfo.home===o.v?T.accent:T.muted,
fontWeight:700, fontSize:14, cursor:"pointer", fontFamily:"inherit", transition:"all 0.12s"
}}>{o.l}
))}
{ if(gameInfo.rival.trim()) setPhase("live"); }}
style={{
width:"100%", padding:"15px", borderRadius:10, border:"none",
background: gameInfo.rival.trim() ? "#2563eb" : "#cbd5e1",
color:"#fff", fontWeight:700, fontSize:16, cursor: gameInfo.rival.trim() ? "pointer" : "default",
fontFamily:"inherit", letterSpacing:"-0.01em"
}}>
🏀 Iniciar partido
);
}
/* ── 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]}
))}
+ Nuevo partido
);
}
/* ── LIVE ── */
const selPlayer = sel ? players.find(p => p.id === sel) : null;
return (
{/* Marcador */}
VS
{[{l:"+2",d:2},{l:"+3",d:3},{l:"TL",d:1}].map(b => (
setScoreThem(s=>s+b.d)} style={{
padding:"5px 7px", borderRadius:6, background:"rgba(255,255,255,0.12)",
border:"none", color:"#fff", fontSize:10, fontWeight:700, cursor:"pointer", fontFamily:"inherit"
}}>{b.l}
))}
setScoreThem(s=>Math.max(0,s-1))} style={{
padding:"5px 7px", borderRadius:6, background:"rgba(239,68,68,0.2)",
border:"none", color:"#fca5a5", fontSize:10, fontWeight:700, cursor:"pointer", fontFamily:"inherit"
}}>-1
{gameInfo.rival.toUpperCase()}
{scoreThem}
{/* Toolbar */}
{sel ? `Acción para ${selPlayer?.name.split(" ")[0]}` : "Selecciona una jugadora"}
{hist.length > 0 && (
↩ Deshacer
)}
{ if(window.confirm("¿Guardar y cerrar el partido?")) saveGame(); }}
style={btnSt("#166534","#dcfce7","#4ade80")}>✓ Terminar
{/* 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
setSel(null)} style={{
width:32, height:32, borderRadius:"50%", border:`1px solid ${T.border}`,
background:"#f8f8f8", color:T.muted, fontSize:18, lineHeight:1,
cursor:"pointer", display:"flex", alignItems:"center", justifyContent:"center",
fontFamily:"inherit", flexShrink:0
}}>×
{/* Acciones */}
{ACTIONS.map(a => (
{ a.fn(sel); }}
style={{
padding:"14px 4px", borderRadius:12, minHeight:52,
border:`1.5px solid ${a.color}30`,
background:a.bg, color:a.color,
fontWeight:700, fontSize:12, cursor:"pointer", fontFamily:"inherit",
lineHeight:1.3, touchAction:"manipulation",
WebkitTapHighlightColor:"transparent", transition:"transform 0.08s"
}}
onMouseDown={e=>e.currentTarget.style.transform="scale(0.91)"}
onMouseUp={e=>e.currentTarget.style.transform="scale(1)"}
onTouchStart={e=>e.currentTarget.style.transform="scale(0.91)"}
onTouchEnd={e=>e.currentTarget.style.transform="scale(1)"}
>{a.label}
))}
)}
);
}
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.'}
)}
{status === 'publishing' ? 'Publicando...' : 'Publicar estadisticas ahora'}
{/* 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.
{/* 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]) => (
))}
{/* 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
:
resend(reg)}
disabled={isSending}
style={{padding:"7px 14px",background:isSending?"#F1F5F9":T.accent+"18",color:isSending?T.muted:T.accent,border:`1px solid ${T.accent}44`,borderRadius:7,fontWeight:600,fontSize:12,cursor:isSending?"not-allowed":"pointer",fontFamily:"inherit",flexShrink:0}}
>
{isSending ? 'Enviando...' : 'Reenviar'}
}
);
})}
);
}
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}
setMsg(null)} style={{background:'none',border:'none',cursor:'pointer',fontSize:16,color:'inherit',lineHeight:1,padding:'0 2px'}}>×
)}
{/* 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 */}
openEdit(u)}
style={{padding:'6px 12px',background:'#F8FAFC',border:`1px solid ${T.border}`,borderRadius:7,fontWeight:600,fontSize:11,cursor:'pointer',fontFamily:'inherit',color:T.text}}
>
Editar
setDeleting(u.id)}
style={{padding:'6px 10px',background:'#FEF2F2',border:'1px solid #FECACA',borderRadius:7,fontWeight:600,fontSize:11,cursor:'pointer',fontFamily:'inherit',color:'#DC2626'}}
>
×
))}
)}
{/* Edit modal */}
{editing && (
Editar usuario
{msg &&
{msg.text}
}
Nombre
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}} />
Correo electronico
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}} />
Nueva contrasena (dejar vacio para no cambiar)
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}} />
{setEditing(null);setMsg(null);}} style={{flex:1,padding:'10px',background:'#F8FAFC',border:`1px solid ${T.border}`,borderRadius:8,fontWeight:600,fontSize:13,cursor:'pointer',fontFamily:'inherit',color:T.text}}>
Cancelar
{saving ? 'Guardando...' : 'Guardar cambios'}
)}
{/* Delete confirm */}
{deleting && (
⚠️
Eliminar cuenta
Esta accion es permanente. El usuario perdera acceso al portal. Los registros de las jugadoras no se eliminan.
setDeleting(null)} style={{flex:1,padding:'10px',background:'#F8FAFC',border:`1px solid ${T.border}`,borderRadius:8,fontWeight:600,fontSize:13,cursor:'pointer',fontFamily:'inherit',color:T.text}}>
Cancelar
confirmDelete(deleting)} style={{flex:1,padding:'10px',background:'#DC2626',color:'#fff',border:'none',borderRadius:8,fontWeight:700,fontSize:13,cursor:'pointer',fontFamily:'inherit'}}>
Eliminar
)}
);
}
window.AdminUsuarios = AdminUsuarios;