import React, { useState, useEffect, useMemo } from ‘react’;
import {
Users, Trophy, PlayCircle, Settings, Plus, Trash2, Printer, Edit2, X, Save,
UserPlus, Download, MapPin, ImageIcon, Upload, RefreshCw, Check, RotateCcw,
FileSpreadsheet, FileText, ChevronUp, ChevronDown, ListOrdered
} from ‘lucide-react’;
// — Constantes —
const RESULTS = {
PENDING: ‘P’,
WHITE_WIN: ‘1-0’,
BLACK_WIN: ‘0-1’,
DRAW: ‘½-½’,
BYE: ‘1-0F’
};
const TIEBREAKS = [
{ id: ‘buchholz’, name: ‘Buchholz Total’ },
{ id: ‘buchholzCut1’, name: ‘Buchholz Mediano (Cut 1)’ },
{ id: ‘berger’, name: ‘Sonneborn-Berger’ },
{ id: ‘progressive’, name: ‘Progresivo’ },
{ id: ‘direct’, name: ‘Resultado Directo’ },
{ id: ‘elo’, name: ‘Mayor Elo’ }
];
const CATEGORIES = [‘General’, ‘Sub-18’, ‘Sub-16’, ‘Sub-14’, ‘Sub-12’, ‘Veterano’];
export default function App() {
const [activeTab, setActiveTab] = useState(‘players’);
const [players, setPlayers] = useState([]);
const [rounds, setRounds] = useState([]);
const [isTournamentStarted, setIsTournamentStarted] = useState(false);
const [editingPlayer, setEditingPlayer] = useState(null);
const [tournamentInfo, setTournamentInfo] = useState({
name: ‘Torneo de Ajedrez Pro’,
year: new Date().getFullYear(),
location: ‘Ciudad, País’,
clubLogo: »,
tournamentLogo: »,
tiebreakOrder: [‘buchholz’, ‘buchholzCut1’, ‘berger’]
});
// — Utilidades de Exportación —
const downloadCSV = (data, filename) => {
const csvContent = «data:text/csv;charset=utf-8,» + data.map(e => e.join(«,»)).join(«\n»);
const encodedUri = encodeURI(csvContent);
const link = document.createElement(«a»);
link.setAttribute(«href», encodedUri);
link.setAttribute(«download», `${filename}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const exportPlayersExcel = () => {
const headers = [«Nombre», «Apellidos», «Elo FIDE», «Elo FEDA», «Elo Local», «Categoria», «Ciudad»];
const rows = players.map(p => [p.name, p.lastName, p.eloFide, p.eloFeda, p.eloLocal, p.category, p.city]);
downloadCSV([headers, …rows], «lista_jugadores»);
};
const exportStandingsExcel = () => {
const headers = [«Pos», «Jugador», «Puntos», …tournamentInfo.tiebreakOrder.map(t => TIEBREAKS.find(tb => tb.id === t).name)];
const rows = sortedPlayers.map((p, i) => [
i + 1,
`${p.lastName}, ${p.name}`,
p.score,
…tournamentInfo.tiebreakOrder.map(t => p.tiebreaks[t])
]);
downloadCSV([headers, …rows], «clasificacion_final»);
};
const exportPairingsExcel = () => {
if (rounds.length === 0) return;
const currentRound = rounds[rounds.length – 1];
const headers = [«Mesa», «Blancas», «Resultado», «Negras»];
const rows = currentRound.matches.map((m, idx) => {
const w = players.find(p => p.id === m.white);
const b = m.black === ‘BYE’ ? { name: ‘BYE’, lastName: ‘DESCANSO’ } : players.find(p => p.id === m.black);
return [idx + 1, `${w?.lastName}, ${w?.name}`, m.result, `${b?.lastName}, ${b?.name}`];
});
downloadCSV([headers, …rows], `pareos_ronda_${currentRound.number}`);
};
// — Lógica de Torneo —
const handleAddOrUpdatePlayer = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const playerData = {
name: formData.get(‘name’),
lastName: formData.get(‘lastName’),
eloFide: parseInt(formData.get(‘eloFide’)) || 0,
eloFeda: parseInt(formData.get(‘eloFeda’)) || 0,
eloLocal: parseInt(formData.get(‘eloLocal’)) || 0,
birthYear: parseInt(formData.get(‘birthYear’)) || new Date().getFullYear(),
category: formData.get(‘category’),
city: formData.get(‘city’),
};
if (editingPlayer) {
setPlayers(players.map(p => p.id === editingPlayer.id ? { …p, …playerData } : p));
setEditingPlayer(null);
} else {
setPlayers([…players, {
…playerData,
id: crypto.randomUUID(), score: 0, history: [], colors: [],
tiebreaks: { buchholz: 0, buchholzCut1: 0, berger: 0, progressive: 0, direct: 0, elo: Math.max(playerData.eloFide, playerData.eloFeda, playerData.eloLocal) },
resultsHistory: []
}]);
}
e.target.reset();
};
useEffect(() => {
if (isTournamentStarted) recalculateStandings();
}, [rounds]);
const generateRound = () => {
const nextNum = rounds.length + 1;
const sorted = […players].sort((a, b) => {
if (b.score !== a.score) return b.score – a.score;
return Math.max(b.eloFide, b.eloFeda) – Math.max(a.eloFide, a.eloFeda);
});
const used = new Set();
const matches = [];
for (let i = 0; i < sorted.length; i++) {
if (used.has(sorted[i].id)) continue;
let matched = false;
for (let j = i + 1; j < sorted.length; j++) {
if (!used.has(sorted[j].id) && !sorted[i].history.includes(sorted[j].id)) {
const wCount = sorted[i].colors.filter(c => c === 1).length;
const bCount = sorted[j].colors.filter(c => c === 1).length;
const w = wCount <= bCount ? sorted[i] : sorted[j];
const b = w.id === sorted[i].id ? sorted[j] : sorted[i];
matches.push({ id: `r${nextNum}-${matches.length + 1}`, white: w.id, black: b.id, result: RESULTS.PENDING });
used.add(w.id); used.add(b.id);
matched = true; break;
}
}
if (!matched) {
matches.push({ id: `r${nextNum}-bye`, white: sorted[i].id, black: 'BYE', result: RESULTS.BYE });
used.add(sorted[i].id);
}
}
setRounds([...rounds, { number: nextNum, matches }]);
setIsTournamentStarted(true);
setActiveTab('rounds');
};
const recalculateStandings = () => {
setPlayers(prevPlayers => {
const updated = prevPlayers.map(p => ({
…p, score: 0, history: [], colors: [], resultsHistory: [],
tiebreaks: { buchholz: 0, buchholzCut1: 0, berger: 0, progressive: 0, direct: 0, elo: Math.max(p.eloFide, p.eloFeda, p.eloLocal) }
}));
rounds.forEach(r => {
r.matches.forEach(m => {
const w = updated.find(p => p.id === m.white);
const b = updated.find(p => p.id === m.black);
if (w) {
w.colors.push(1);
if (b && m.black !== ‘BYE’) w.history.push(b.id);
if (m.result === RESULTS.WHITE_WIN || m.result === RESULTS.BYE) w.score += 1;
else if (m.result === RESULTS.DRAW) w.score += 0.5;
w.resultsHistory.push({ opp: m.black, res: m.result, scoreAtMoment: w.score });
}
if (b && m.black !== ‘BYE’) {
b.colors.push(-1);
b.history.push(w.id);
if (m.result === RESULTS.BLACK_WIN) b.score += 1;
else if (m.result === RESULTS.DRAW) b.score += 0.5;
b.resultsHistory.push({ opp: m.white, res: m.result, scoreAtMoment: b.score });
}
});
});
updated.forEach(p => {
const opponentScores = p.history.map(id => updated.find(o => o.id === id)?.score || 0);
p.tiebreaks.buchholz = opponentScores.reduce((a, b) => a + b, 0);
p.tiebreaks.buchholzCut1 = opponentScores.length > 1 ? p.tiebreaks.buchholz – Math.min(…opponentScores) : p.tiebreaks.buchholz;
p.tiebreaks.progressive = p.resultsHistory.reduce((a, b) => a + b.scoreAtMoment, 0);
p.resultsHistory.forEach(rh => {
const opp = updated.find(o => o.id === rh.opp);
if (!opp) return;
if (rh.res === RESULTS.WHITE_WIN || (rh.res === RESULTS.BLACK_WIN && opp.id !== ‘BYE’)) {
p.tiebreaks.berger += opp.score;
} else if (rh.res === RESULTS.DRAW) {
p.tiebreaks.berger += (opp.score / 2);
}
});
});
return updated;
});
};
const sortedPlayers = useMemo(() => {
return […players].sort((a, b) => {
if (b.score !== a.score) return b.score – a.score;
for (let tbId of tournamentInfo.tiebreakOrder) {
if (b.tiebreaks[tbId] !== a.tiebreaks[tbId]) return b.tiebreaks[tbId] – a.tiebreaks[tbId];
}
return 0;
});
}, [players, tournamentInfo.tiebreakOrder]);
const moveTiebreak = (index, direction) => {
const newOrder = […tournamentInfo.tiebreakOrder];
const newPos = index + direction;
if (newPos < 0 || newPos >= newOrder.length) return;
[newOrder[index], newOrder[newPos]] = [newOrder[newPos], newOrder[index]];
setTournamentInfo({…tournamentInfo, tiebreakOrder: newOrder});
};
const updateMatchResult = (rIdx, mId, res) => {
const newRounds = […rounds];
const match = newRounds[rIdx].matches.find(m => m.id === mId);
if (match) {
match.result = res;
setRounds(newRounds);
}
};
// — Sub-Componentes de UI —
const ActionButtons = ({ onExcel, onPrint, label }) => (
);
const tabs = [
{ id: ‘players’, icon: Users, label: ‘JUGADORES’ },
{ id: ‘rounds’, icon: PlayCircle, label: ‘RONDAS Y PAREOS’ },
{ id: ‘standings’, icon: ListOrdered, label: ‘CLASIFICACIÓN’ },
{ id: ‘settings’, icon: Settings, label: ‘SISTEMAS Y CONFIG.’ }
];
return (
);
}
{/* HEADER */}
{/* MENU HORIZONTAL */}
{/* HEADER IMPRESIÓN */}
{/* TAB: PLAYERS */}
{activeTab === ‘players’ && (
window.print()} label=»Jugadores» />
)}
{/* TAB: ROUNDS */}
{activeTab === ‘rounds’ && (
window.print()} label=»Pareos» />
{rounds.length === 0 ? (
)}
{/* TAB: STANDINGS */}
{activeTab === ‘standings’ && (
window.print()} label=»Clasificación» />
)}
{/* TAB: SETTINGS */}
{activeTab === ‘settings’ && (
)}
{tournamentInfo.name}
{tournamentInfo.location} · {tournamentInfo.year}
{tournamentInfo.clubLogo &&
}
{tournamentInfo.tournamentLogo &&
}
{tournamentInfo.name}
{tournamentInfo.location} – {tournamentInfo.year}
{activeTab === ‘players’ && «LISTADO DE INSCRITOS»}
{activeTab === ‘rounds’ && `PAREOS Y RESULTADOS – RONDA ${rounds.length}`}
{activeTab === ‘standings’ && «CLASIFICACIÓN ACTUALIZADA»}
| Jugador | Elo (FIDE/FEDA) | Categoría | Origen | Acciones |
|---|---|---|---|---|
| {p.lastName}, {p.name} | {p.eloFide || ‘-‘} / {p.eloFeda || ‘-‘} | {p.category} | {p.city} |
{rounds.length > 0 && (
)}
Estado
{rounds.length} RONDAS FINALIZADAS
Esperando el inicio del torneo…
) : (
{rounds.slice().reverse().map((r) => (
))}
)}
Índice
Mesa de Juego – Ronda {r.number}
{r.matches.map((m) => {
const w = players.find(p => p.id === m.white);
const b = m.black === ‘BYE’ ? { name: ‘BYE’, lastName: ‘DESCANSO’, score: 0 } : players.find(p => p.id === m.black);
return (
);
})}
Blancas
{w?.lastName}, {w?.name}
[{w?.score || 0} pts]
{m.result}
Negras
{b?.lastName}, {b?.name}
[{b?.score || 0} pts]
{[RESULTS.WHITE_WIN, RESULTS.DRAW, RESULTS.BLACK_WIN].map(res => (
))}
#{(m.id.split(‘-‘)[1])}
| RK | Nombre del Jugador | PTS | {tournamentInfo.tiebreakOrder.map(tId => ({TIEBREAKS.find(tb => tb.id === tId)?.name.split(‘ ‘)[0]} | ))}
|---|---|---|---|
| #{i + 1} |
{p.lastName}, {p.name}
|
{p.score} | {tournamentInfo.tiebreakOrder.map(tId => ({typeof p.tiebreaks[tId] === ‘number’ && p.tiebreaks[tId] % 1 !== 0 ? p.tiebreaks[tId].toFixed(2) : p.tiebreaks[tId]} | ))}
Sistemas de Desempate
Configura la prioridad de los sistemas de desempate en la clasificación.
{tournamentInfo.tiebreakOrder.map((tId, idx) => {
const tb = TIEBREAKS.find(t => t.id === tId);
return (
);
})}
{idx + 1}
{tb?.name}
Configuración Visual
setTournamentInfo({…tournamentInfo, name: e.target.value})} className=»w-full p-4 rounded-2xl border-2 border-slate-100 mt-2 font-black uppercase italic focus:ring-2 ring-blue-500 outline-none» />
setTournamentInfo({…tournamentInfo, year: e.target.value})} className=»w-full p-4 rounded-2xl border-2 border-slate-100 mt-2 font-black italic focus:ring-2 ring-blue-500 outline-none» />
setTournamentInfo({…tournamentInfo, location: e.target.value})} className=»w-full p-4 rounded-2xl border-2 border-slate-100 mt-2 font-black italic focus:ring-2 ring-blue-500 outline-none» />
setTournamentInfo({…tournamentInfo, clubLogo: e.target.value})} placeholder=»https://…» className=»w-full p-4 rounded-2xl border-2 border-slate-100 mt-2 font-bold focus:ring-2 ring-blue-500 outline-none text-xs» />