torneos/public/assets/app.js

1335 lines
62 KiB
JavaScript

const state = {
token: localStorage.getItem('token'),
user: localStorage.getItem('token') ? JSON.parse(localStorage.getItem('user') || 'null') : null,
tournaments: [],
route: 'public',
ws: null,
scoreMatchId: null,
scoreMode: localStorage.getItem('scoreMode') || 'expert',
sidebarCollapsed: localStorage.getItem('sidebarCollapsed') === '1',
authValidated: false
};
const $ = (selector) => document.querySelector(selector);
const view = $('#view');
function toast(message, kind = 'ok') {
const item = document.createElement('div');
item.className = `rounded px-4 py-3 text-sm font-bold shadow ${kind === 'error' ? 'bg-red-600' : 'bg-zinc-900'} text-white`;
item.textContent = message;
$('#toast').appendChild(item);
setTimeout(() => item.remove(), 3200);
}
async function api(path, options = {}) {
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
if (state.token) headers.Authorization = `Bearer ${state.token}`;
const res = await fetch(path, { ...options, headers });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const error = new Error(data.message || 'Error de API');
error.status = res.status;
throw error;
}
return data;
}
function selectedTournamentId() {
return Number($('#tournamentSelect').value || state.tournaments[0]?.id || 1);
}
function applySidebarState() {
document.body.classList.toggle('sidebar-collapsed', state.sidebarCollapsed);
$('#sidebarToggle')?.setAttribute('title', state.sidebarCollapsed ? 'Expandir menu' : 'Minimizar menu');
}
function openMobileMenu() {
document.body.classList.add('mobile-menu-open');
}
function closeMobileMenu() {
document.body.classList.remove('mobile-menu-open');
}
async function loadBase() {
const tournaments = await api('/api/tournaments');
state.tournaments = tournaments.data || [];
$('#tournamentSelect').innerHTML = state.tournaments.map(t => `<option value="${t.id}">${t.name}</option>`).join('');
updateSession();
}
async function validateStoredSession() {
if (!state.token) {
clearSession();
state.authValidated = true;
return;
}
try {
const res = await api('/api/auth/me');
state.user = res.user;
localStorage.setItem('user', JSON.stringify(res.user));
} catch (err) {
if (err.status === 401 || err.status === 403) {
clearSession();
}
} finally {
state.authValidated = true;
updateSession();
}
}
function clearSession() {
state.token = null;
state.user = null;
localStorage.removeItem('token');
localStorage.removeItem('user');
}
function currentRole() {
return String(state.user?.role || '').trim().toLowerCase();
}
function isAdmin() {
return Boolean(state.token && state.user && currentRole() === 'admin');
}
function updateSession() {
const role = currentRole();
$('#sessionInfo').textContent = state.user ? `${state.user.name} - ${role}` : 'Modo publico';
$('#loginBtn').setAttribute('title', state.user ? `Sesion: ${state.user.name}` : 'Ingresar');
$('#sideLoginBtn').textContent = state.user ? 'Ver sesion' : 'Ingresar';
$('#sessionUser').textContent = state.user ? state.user.name : 'Invitado';
$('#sessionRole').textContent = state.user ? `${state.user.email} - ${role}` : 'Modo publico';
}
function openModal({ eyebrow = '', title, subtitle = '', body, footer = '' }) {
$('#modalEyebrow').textContent = eyebrow;
$('#modalTitle').textContent = title;
$('#modalSubtitle').textContent = subtitle;
$('#modalBody').innerHTML = body;
$('#modalFooter').innerHTML = footer;
$('#modalFooter').classList.toggle('hidden', !footer);
$('#modalRoot').classList.remove('hidden');
$('#modalRoot').setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
}
function closeModal() {
$('#modalRoot').classList.add('hidden');
$('#modalRoot').setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
}
function openLoginModal() {
if (state.user) {
openModal({
eyebrow: 'Sesion activa',
title: state.user.name,
subtitle: `${state.user.email} - ${state.user.role}`,
body: `<div class="space-y-3">
<p class="text-sm text-slate-500 dark:text-zinc-400">Ya estas autenticado en el sistema.</p>
<button id="logoutBtn" class="btn-muted w-full" type="button">Cerrar sesion</button>
</div>`
});
$('#logoutBtn').onclick = () => {
clearSession();
updateSession();
closeModal();
toast('Sesion cerrada');
render();
};
return;
}
openModal({
eyebrow: 'Acceso seguro',
title: 'Ingresar',
subtitle: 'Usa tu usuario administrador o delegado para gestionar torneos.',
body: `<div class="login-panel mb-4">
<span class="login-badge">JWT activo</span>
<p class="mt-3 text-sm text-slate-600 dark:text-zinc-300">Credenciales de prueba: admin@volley.test / password</p>
</div>
<form id="loginForm" class="space-y-4">
<label class="field"><span class="field-label">Email</span>
<input class="input" name="email" value="admin@volley.test" autocomplete="email" placeholder="Email">
</label>
<label class="field"><span class="field-label">Contrasena</span>
<input class="input" name="password" value="password" type="password" autocomplete="current-password" placeholder="Contrasena">
</label>
<button class="btn-primary w-full" type="submit">Ingresar</button>
</form>`
});
const loginForm = $('#modalBody').querySelector('#loginForm');
if (loginForm) {
loginForm.onsubmit = handleLogin;
}
}
async function handleLogin(e) {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
const res = await api('/api/auth/login', { method: 'POST', body: JSON.stringify(data) });
state.token = res.token;
state.user = res.user;
state.authValidated = true;
localStorage.setItem('token', res.token);
localStorage.setItem('user', JSON.stringify(res.user));
updateSession();
closeModal();
toast('Sesion iniciada');
render();
}
function setRoute(route) {
state.route = route;
syncActiveRoute();
closeMobileMenu();
render().catch(err => toast(err.message, 'error'));
}
function syncActiveRoute() {
document.querySelectorAll('.nav-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.route === state.route));
}
function toggleSidebar() {
state.sidebarCollapsed = !state.sidebarCollapsed;
localStorage.setItem('sidebarCollapsed', state.sidebarCollapsed ? '1' : '0');
applySidebarState();
}
async function render() {
try {
syncActiveRoute();
if (state.route === 'admin') return renderAdmin();
if (state.route === 'score') return renderScore();
if (state.route === 'team-link') return renderTeamLink();
return renderPublic();
} catch (err) {
toast(err.message, 'error');
}
}
async function renderPublic() {
const id = selectedTournamentId();
const [standings, matches, stats] = await Promise.all([
api(`/api/tournaments/${id}/standings`),
api(`/api/matches?tournament_id=${id}&per_page=50`),
api(`/api/tournaments/${id}/stats`)
]);
view.innerHTML = `
<div class="grid gap-4 xl:grid-cols-2">
<section class="panel xl:col-span-2">
<h1 class="text-2xl font-black">Resultados, fixture y tabla</h1>
<p class="text-sm text-slate-500">Vista publica responsive con datos actualizados desde la planilla electronica.</p>
</section>
<section class="panel">
<h2 class="section-title">Tabla de posiciones</h2>
${table(['Equipo','PJ','G','P','SF','SC','Dif','Pts'], standings.map(r => [r.name, r.played, r.won, r.lost, r.sets_for, r.sets_against, r.sets_for - r.sets_against, r.points]))}
</section>
<section class="panel">
<h2 class="section-title">Calendario</h2>
${table(['Fecha','Local','Visitante','Sets','Estado'], (matches.data || []).map(m => [fmt(m.scheduled_at), m.home_team, m.away_team, `${m.home_sets}-${m.away_sets}`, m.status]))}
</section>
<section class="panel xl:col-span-2">
<h2 class="section-title">Ranking de jugadores</h2>
${table(['Jugador','Equipo','Puntos','Aces','Bloqueos','MVP'], (stats.players || []).map(p => [p.player_name, p.team_name, p.points || 0, p.aces || 0, p.blocks || 0, p.mvp || 0]))}
</section>
</div>`;
}
async function renderAdmin() {
if (!state.authValidated || state.token) {
await validateStoredSession();
}
if (!isAdmin()) {
view.innerHTML = `<section class="panel max-w-xl">
<h1 class="text-2xl font-black">Acceso administrador</h1>
<p class="mt-2 text-sm text-slate-500">Para crear torneos, equipos y partidos tenes que iniciar sesion con un usuario administrador.</p>
<p class="mt-2 text-xs text-slate-500">Estado actual: ${state.user ? `${state.user.email} - ${currentRole() || 'sin rol'}` : 'sin sesion valida'}</p>
<button class="btn-primary mt-4" id="adminLoginCta">Ingresar</button>
</section>`;
$('#adminLoginCta').onclick = openLoginModal;
setTimeout(openLoginModal, 150);
return;
}
const id = selectedTournamentId();
let teams;
let players;
let matches;
let users;
let templates;
try {
[teams, players, matches, users, templates] = await Promise.all([
api(`/api/teams?tournament_id=${id}&per_page=50`),
api('/api/players?per_page=50'),
api(`/api/matches?tournament_id=${id}&per_page=50`),
api('/api/users?per_page=50'),
api(`/api/sheet-templates?per_page=50&tournament_id=${id}`)
]);
} catch (err) {
if (err.status === 401) {
clearSession();
state.authValidated = true;
updateSession();
toast('La sesion expiro. Inicia sesion nuevamente.', 'error');
return renderAdmin();
}
throw err;
}
view.innerHTML = `
<div class="grid gap-4">
<section class="panel">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-black">Dashboard administrativo</h1>
<p class="text-sm text-slate-500">Gestion operativa del torneo seleccionado.</p>
</div>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
${metricCard('Equipos', teams.data?.length || 0)}
${metricCard('Jugadores', players.data?.length || 0)}
${metricCard('Partidos', matches.data?.length || 0)}
${metricCard('Usuarios', users.data?.length || 0)}
</div>
</div>
</section>
<section class="dashboard-actions">
<button class="action-tile" data-admin-modal="user">
<span class="action-icon">+</span>
<span class="mt-4 block text-base font-black">Crear usuario</span>
<span class="mt-1 block text-sm text-slate-500">Admin o delegado.</span>
</button>
<button class="action-tile" data-admin-modal="tournament">
<span class="action-icon">+</span>
<span class="mt-4 block text-base font-black">Crear torneo</span>
<span class="mt-1 block text-sm text-slate-500">Categoria, formato y fechas.</span>
</button>
<button class="action-tile" data-admin-modal="team">
<span class="action-icon">+</span>
<span class="mt-4 block text-base font-black">Crear equipo</span>
<span class="mt-1 block text-sm text-slate-500">Nombre, DT y link de ficha.</span>
</button>
<button class="action-tile" data-admin-modal="match">
<span class="action-icon">+</span>
<span class="mt-4 block text-base font-black">Crear partido</span>
<span class="mt-1 block text-sm text-slate-500">Equipos, fecha y cancha.</span>
</button>
<button class="action-tile" data-admin-modal="template">
<span class="action-icon">+</span>
<span class="mt-4 block text-base font-black">Crear plantilla</span>
<span class="mt-1 block text-sm text-slate-500">Imagen y coordenadas.</span>
</button>
<button class="action-tile" id="demoScoresheetBtn">
<span class="action-icon">+</span>
<span class="mt-4 block text-base font-black">Demo planilla</span>
<span class="mt-1 block text-sm text-slate-500">Datos para probar export.</span>
</button>
</section>
<section class="dashboard-panels">
<section class="panel"><h2 class="section-title">Equipos</h2>${table(['Equipo','DT','Link ficha'], (teams.data || []).map(t => [t.name, t.coach_name || '-', `/registro/${t.registration_token}`]))}</section>
<section class="panel"><h2 class="section-title">Usuarios</h2>${userTable(users.data || [])}</section>
<section class="panel"><h2 class="section-title">Plantillas de planilla</h2>${templateTable(templates.data || [])}</section>
<section class="panel"><h2 class="section-title">Jugadores</h2>${table(['Jugador','Equipo','DNI','Nro','Posicion'], (players.data || []).map(p => [`${p.first_name} ${p.last_name}`, p.team_name, p.document_id, p.jersey_number || '-', p.position || '-']))}</section>
<section class="panel"><h2 class="section-title">Partidos</h2>${table(['ID','Fecha','Local','Visitante','Estado'], (matches.data || []).map(m => [m.id, fmt(m.scheduled_at), m.home_team, m.away_team, m.status]))}</section>
</section>
</div>`;
bindAdminActions(teams.data || [], users.data || [], templates.data || []);
}
function bindAdminActions(teams, users, templates) {
document.querySelector('[data-admin-modal="user"]').onclick = () => openUserModal();
document.querySelector('[data-admin-modal="tournament"]').onclick = () => openTournamentModal();
document.querySelector('[data-admin-modal="team"]').onclick = () => openTeamModal();
document.querySelector('[data-admin-modal="match"]').onclick = () => openMatchModal(teams);
document.querySelector('[data-admin-modal="template"]').onclick = () => openTemplateModal();
$('#demoScoresheetBtn').onclick = openDemoScoresheetModal;
document.querySelectorAll('[data-edit-user]').forEach(btn => {
btn.onclick = () => openUserModal(users.find(user => Number(user.id) === Number(btn.dataset.editUser)));
});
document.querySelectorAll('[data-toggle-user]').forEach(btn => {
btn.onclick = () => toggleUser(Number(btn.dataset.toggleUser), Number(btn.dataset.active) ? 0 : 1);
});
document.querySelectorAll('[data-assign-template]').forEach(btn => {
btn.onclick = () => openAssignTemplateModal(Number(btn.dataset.assignTemplate));
});
document.querySelectorAll('[data-edit-template]').forEach(btn => {
btn.onclick = () => openTemplateDesigner(Number(btn.dataset.editTemplate));
});
}
function openUserModal(user = null) {
const isEdit = Boolean(user);
openModal({
eyebrow: 'Seguridad',
title: isEdit ? 'Editar usuario' : 'Crear usuario',
subtitle: 'Administra accesos para administradores y delegados.',
body: `<form id="userForm" class="space-y-4">
${field('Nombre', input('name','Nombre', 'text', user?.name || ''))}
${field('Email', input('email','Email', 'email', user?.email || ''))}
${field(isEdit ? 'Nueva contrasena opcional' : 'Contrasena', input('password','Contrasena', 'password'))}
${field('Rol', `<select class="input" name="role">
<option value="admin" ${user?.role === 'admin' ? 'selected' : ''}>admin</option>
<option value="delegate" ${user?.role === 'delegate' ? 'selected' : ''}>delegate</option>
<option value="public" ${user?.role === 'public' ? 'selected' : ''}>public</option>
</select>`)}
${field('Estado', `<select class="input" name="active">
<option value="1" ${!user || Number(user.active) ? 'selected' : ''}>Activo</option>
<option value="0" ${user && !Number(user.active) ? 'selected' : ''}>Inactivo</option>
</select>`)}
<button class="btn-primary w-full">${isEdit ? 'Guardar cambios' : 'Crear usuario'}</button>
</form>`
});
$('#userForm').onsubmit = submitForm(isEdit ? `/api/users/${user.id}` : '/api/users', data => {
if (isEdit && !data.password) delete data.password;
return data;
}, true, isEdit ? 'PUT' : 'POST');
}
async function toggleUser(id, active) {
await api(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify({ active }) });
toast(active ? 'Usuario activado' : 'Usuario desactivado');
render();
}
function openTemplateModal() {
const defaultConfig = JSON.stringify({
fields: {},
result_rows: [],
result_columns: {},
home_players: {},
away_players: {}
}, null, 2);
openModal({
eyebrow: 'Plantillas',
title: 'Crear plantilla',
subtitle: 'Subi la imagen a public/templates y defini coordenadas JSON.',
body: `<form id="templateForm" class="space-y-4">
${field('Codigo', input('code','ej: ltv27'))}
${field('Nombre', input('name','Nombre de plantilla'))}
${field('Ruta imagen', input('image_path','/templates/mi-planilla.png'))}
${field('Ancho px', input('page_width','1418','number'))}
${field('Alto px', input('page_height','970','number'))}
${field('Config JSON', `<textarea class="input min-h-[180px] font-mono text-xs" name="config_json">${escapeHtml(defaultConfig)}</textarea>`)}
<button class="btn-primary w-full">Crear plantilla</button>
</form>`
});
$('#templateForm').onsubmit = submitForm('/api/sheet-templates', data => ({ ...data, config_json: JSON.parse(data.config_json) }), true);
}
function openAssignTemplateModal(templateId) {
openModal({
eyebrow: 'Plantillas',
title: 'Asignar plantilla',
subtitle: 'Selecciona el torneo que usara esta planilla por defecto.',
body: `<form id="assignTemplateForm" class="space-y-4">
${field('Torneo', `<select class="input" name="tournament_id">${state.tournaments.map(tournament => `<option value="${tournament.id}" ${Number(tournament.id) === selectedTournamentId() ? 'selected' : ''}>${tournament.name}</option>`).join('')}</select>`)}
<button class="btn-primary w-full">Asignar plantilla</button>
</form>`
});
$('#assignTemplateForm').onsubmit = async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.target));
await assignTemplateToTournament(templateId, Number(data.tournament_id));
closeModal();
};
}
async function assignTemplateToTournament(templateId, tournamentId) {
try {
await api(`/api/tournaments/${tournamentId}/sheet-template`, { method: 'POST', body: JSON.stringify({ sheet_template_id: templateId }) });
toast('Plantilla asignada al torneo');
await renderAdmin();
} catch (err) {
toast(err.message, 'error');
await renderAdmin();
}
}
function openDemoScoresheetModal() {
openModal({
eyebrow: 'Demo',
title: 'Generar demo de planilla',
subtitle: 'Crea equipos, jugadores, partido, sets, libero, rotaciones y firma demo para probar la plantilla.',
body: `<form id="demoScoresheetForm" class="space-y-4">
${field('Torneo', `<select class="input" name="tournament_id">${state.tournaments.map(tournament => `<option value="${tournament.id}" ${Number(tournament.id) === selectedTournamentId() ? 'selected' : ''}>${tournament.name}</option>`).join('')}</select>`)}
${field('Modo', '<select class="input" name="force"><option value="0">No pisar demo existente</option><option value="1">Forzar regeneracion</option></select>')}
<button class="btn-primary w-full">Generar demo</button>
</form>`
});
$('#demoScoresheetForm').onsubmit = async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.target));
const res = await api(`/api/tournaments/${data.tournament_id}/demo-scoresheet-data`, { method: 'POST', body: JSON.stringify({ force: Number(data.force) === 1 }) });
closeModal();
toast(res.created ? `Demo creado. Partido #${res.match_id}` : res.message);
await renderAdmin();
};
}
async function openTemplateDesigner(templateId) {
const template = await api(`/api/sheet-templates/${templateId}/effective`);
if (!template) return;
const config = safeJson(template.effective_config_json || template.config_json, {});
state.designer = {
template,
config,
selectedKey: null,
selectedKind: 'field',
drag: null
};
renderTemplateDesigner();
}
function renderTemplateDesigner() {
const template = state.designer.template;
const config = state.designer.config;
view.innerHTML = `<div class="grid gap-4">
<section class="panel">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-black">Editor de plantilla</h1>
<p class="text-sm text-slate-500">${template.name} - ${template.page_width}x${template.page_height}</p>
</div>
<div class="flex gap-2">
<button class="btn-muted" id="backAdminBtn">Volver</button>
<button class="btn-primary" id="saveTemplateDesignBtn">Guardar diseno</button>
</div>
</div>
</section>
<section class="template-editor">
<div class="template-canvas-wrap">
<div id="templateCanvas" class="template-canvas" tabindex="0" style="aspect-ratio:${template.page_width}/${template.page_height}">
<img src="${template.image_path}" alt="${template.name}" tabindex="-1">
${designerFields(config)}
${designerPlayerBlock(config.home_players, 'home_players', 'Jugadores A')}
${designerPlayerBlock(config.away_players, 'away_players', 'Jugadores B')}
${designerResultBlock(config)}
</div>
</div>
<aside class="template-side">
<section class="panel">
<h2 class="section-title">Agregar campo</h2>
<div class="template-field-list">
${availableTemplateFields().map(key => `<button class="btn-muted text-xs" data-add-field="${key}">${key}</button>`).join('')}
<button class="btn-muted text-xs" data-select-block="home_players">Bloque jugadores A</button>
<button class="btn-muted text-xs" data-select-block="away_players">Bloque jugadores B</button>
<button class="btn-muted text-xs" data-select-block="result">Bloque resultado</button>
</div>
</section>
<section class="panel">
<h2 class="section-title">Campo seleccionado</h2>
<div id="fieldProps">${fieldPropsPanel()}</div>
</section>
</aside>
</section>
</div>`;
bindTemplateDesigner();
}
function designerFields(config) {
return Object.entries(config.fields || {}).map(([key, box]) => {
const left = percent(box.x, state.designer.template.page_width);
const top = percent(box.y, state.designer.template.page_height);
const width = percent(box.w || 120, state.designer.template.page_width);
const active = state.designer.selectedKey === key ? 'active' : '';
return `<div class="template-field ${active}" data-field-key="${key}" style="left:${left}%;top:${top}%;width:${width}%;font-size:${box.size || 12}px;justify-content:${alignToJustify(box.align)};align-items:${verticalToAlign(box.valign)};transform:${verticalToTransform(box.valign)}">${fieldPreview(key)}</div>`;
}).join('');
}
function designerPlayerBlock(config, key, label) {
if (!config?.x) return '';
const left = percent(config.x, state.designer.template.page_width);
const top = percent(config.y, state.designer.template.page_height);
const width = percent((config.number_w || 30) + (config.name_w || 120), state.designer.template.page_width);
const height = percent((config.row_h || 20) * 14, state.designer.template.page_height);
const active = state.designer.selectedKey === key ? 'active' : '';
return `<div class="template-field ${active}" data-block-key="${key}" style="left:${left}%;top:${top}%;width:${width}%;height:${height}%;font-size:${config.size || 10}px;align-items:${verticalToAlign(config.valign)}">${label}</div>`;
}
function designerResultBlock(config) {
if (!config?.result_rows?.length || !config?.result_columns?.set) return '';
const row = config.result_rows[0];
const col = config.result_columns.home_t || config.result_columns.set;
const lastRow = config.result_rows[config.result_rows.length - 1];
const left = percent(col.x, state.designer.template.page_width);
const top = percent(row.y, state.designer.template.page_height);
const width = percent(300, state.designer.template.page_width);
const height = percent((lastRow.y - row.y) + 24, state.designer.template.page_height);
const active = state.designer.selectedKey === 'result' ? 'active' : '';
return `<div class="template-field ${active}" data-block-key="result" style="left:${left}%;top:${top}%;width:${width}%;height:${height}%;font-size:11px">Resultado</div>`;
}
function fieldPropsPanel() {
const key = state.designer?.selectedKey;
if (!key) return '<p class="text-sm text-slate-500">Selecciona un campo del lienzo.</p>';
if (state.designer.selectedKind === 'block') {
return blockPropsPanel(key);
}
const box = state.designer.config.fields[key];
return `<div class="space-y-3">
${field('Campo', `<input class="input" value="${escapeAttr(key)}" disabled>`)}
${field('X', input('prop_x','x','number', box.x || 0))}
${field('Y', input('prop_y','y','number', box.y || 0))}
${field('Ancho', input('prop_w','w','number', box.w || 120))}
${field('Fuente', input('prop_size','size','number', box.size || 12))}
${field('Alineacion', `<select class="input" name="prop_align"><option value="left" ${box.align === 'left' ? 'selected' : ''}>left</option><option value="center" ${box.align === 'center' ? 'selected' : ''}>center</option><option value="right" ${box.align === 'right' ? 'selected' : ''}>right</option></select>`)}
${field('Alineacion vertical', `<select class="input" name="prop_valign"><option value="top" ${box.valign === 'top' ? 'selected' : ''}>top</option><option value="middle" ${box.valign === 'middle' ? 'selected' : ''}>middle</option><option value="bottom" ${box.valign === 'bottom' ? 'selected' : ''}>bottom</option></select>`)}
<button class="btn-muted w-full" id="deleteFieldBtn">Eliminar campo</button>
</div>`;
}
function blockPropsPanel(key) {
const block = getDesignerBlock(key);
if (!block) return '<p class="text-sm text-slate-500">Bloque no disponible.</p>';
return `<div class="space-y-3">
${field('Bloque', `<input class="input" value="${escapeAttr(key)}" disabled>`)}
${field('X', input('block_x','x','number', block.x || 0))}
${field('Y', input('block_y','y','number', block.y || 0))}
${key === 'result' ? field('Separacion filas', input('block_row_h','row_h','number', resultRowHeight())) : field('Alto fila', input('block_row_h','row_h','number', block.row_h || 22))}
${key === 'result' ? '' : field('Ancho numero', input('block_number_w','number_w','number', block.number_w || 32))}
${key === 'result' ? '' : field('Ancho nombre', input('block_name_w','name_w','number', block.name_w || 124))}
${field('Fuente', input('block_size','size','number', block.size || 10))}
${field('Alineacion vertical', `<select class="input" name="block_valign"><option value="top" ${block.valign === 'top' ? 'selected' : ''}>top</option><option value="middle" ${block.valign === 'middle' ? 'selected' : ''}>middle</option><option value="bottom" ${block.valign === 'bottom' ? 'selected' : ''}>bottom</option></select>`)}
<button class="btn-muted w-full" id="deleteBlockBtn">Eliminar bloque</button>
</div>`;
}
function bindTemplateDesigner() {
$('#backAdminBtn').onclick = () => {
document.onkeydown = null;
renderAdmin();
};
$('#saveTemplateDesignBtn').onclick = saveTemplateDesign;
document.querySelectorAll('[data-add-field]').forEach(btn => btn.onclick = () => addTemplateField(btn.dataset.addField));
document.querySelectorAll('[data-select-block]').forEach(btn => btn.onclick = () => selectTemplateBlock(btn.dataset.selectBlock));
document.querySelectorAll('[data-field-key]').forEach(el => {
el.onclick = () => selectTemplateField(el.dataset.fieldKey);
el.onpointerdown = (event) => startFieldDrag(event, el.dataset.fieldKey);
});
document.querySelectorAll('[data-block-key]').forEach(el => {
el.onclick = () => selectTemplateBlock(el.dataset.blockKey);
el.onpointerdown = (event) => startBlockDrag(event, el.dataset.blockKey);
});
const props = $('#fieldProps');
props.querySelectorAll('input, select').forEach(inputEl => inputEl.oninput = state.designer.selectedKind === 'block' ? updateSelectedBlockFromProps : updateSelectedFieldFromProps);
const deleteBtn = $('#deleteFieldBtn');
if (deleteBtn) deleteBtn.onclick = deleteSelectedField;
const deleteBlockBtn = $('#deleteBlockBtn');
if (deleteBlockBtn) deleteBlockBtn.onclick = deleteSelectedBlock;
document.onkeydown = handleTemplateKeydown;
}
function addTemplateField(key) {
if (['home_players', 'away_players', 'result'].includes(key)) {
addTemplateBlock(key);
return;
}
state.designer.config.fields = state.designer.config.fields || {};
state.designer.config.fields[key] = state.designer.config.fields[key] || { x: 40, y: 40, w: 160, size: 12, align: 'left' };
state.designer.selectedKey = key;
state.designer.selectedKind = 'field';
renderTemplateDesigner();
}
function selectTemplateField(key) {
state.designer.selectedKey = key;
state.designer.selectedKind = 'field';
renderTemplateDesigner();
focusTemplateCanvas();
}
function selectTemplateBlock(key) {
if (!getDesignerBlock(key)) addTemplateBlock(key);
state.designer.selectedKey = key;
state.designer.selectedKind = 'block';
renderTemplateDesigner();
focusTemplateCanvas();
}
function addTemplateBlock(key) {
if (key === 'home_players') state.designer.config.home_players = state.designer.config.home_players || { x: 1081, y: 548, row_h: 22, number_w: 32, name_w: 124, size: 10 };
if (key === 'away_players') state.designer.config.away_players = state.designer.config.away_players || { x: 1238, y: 548, row_h: 22, number_w: 32, name_w: 124, size: 10 };
if (key === 'result') {
state.designer.config.result_rows = state.designer.config.result_rows || [{ y: 790 }, { y: 816 }, { y: 842 }, { y: 867 }, { y: 893 }];
state.designer.config.result_columns = state.designer.config.result_columns || {
home_pt: { x: 886, w: 34 },
set: { x: 921, w: 70 },
away_pt: { x: 992, w: 34 }
};
}
state.designer.selectedKey = key;
state.designer.selectedKind = 'block';
}
function startFieldDrag(event, key) {
event.preventDefault();
const canvas = $('#templateCanvas');
const rect = canvas.getBoundingClientRect();
const box = state.designer.config.fields[key];
state.designer.selectedKey = key;
state.designer.drag = {
key,
startX: event.clientX,
startY: event.clientY,
originX: Number(box.x || 0),
originY: Number(box.y || 0),
scaleX: state.designer.template.page_width / rect.width,
scaleY: state.designer.template.page_height / rect.height
};
window.onpointermove = moveFieldDrag;
window.onpointerup = stopFieldDrag;
}
function startBlockDrag(event, key) {
event.preventDefault();
const canvas = $('#templateCanvas');
const rect = canvas.getBoundingClientRect();
const box = getDesignerBlock(key);
state.designer.selectedKey = key;
state.designer.selectedKind = 'block';
state.designer.drag = {
key,
kind: 'block',
startX: event.clientX,
startY: event.clientY,
originX: Number(box.x || 0),
originY: Number(box.y || 0),
resultOriginColumns: key === 'result' ? clone(state.designer.config.result_columns || {}) : null,
scaleX: state.designer.template.page_width / rect.width,
scaleY: state.designer.template.page_height / rect.height
};
window.onpointermove = moveFieldDrag;
window.onpointerup = stopFieldDrag;
}
function moveFieldDrag(event) {
const drag = state.designer.drag;
if (!drag) return;
const box = drag.kind === 'block' ? getDesignerBlock(drag.key) : state.designer.config.fields[drag.key];
const nextX = Math.max(0, Math.round(drag.originX + ((event.clientX - drag.startX) * drag.scaleX)));
box.x = nextX;
box.y = Math.max(0, Math.round(drag.originY + ((event.clientY - drag.startY) * drag.scaleY)));
if (drag.kind === 'block' && drag.key === 'result') {
moveResultRows(box.y);
moveResultColumns(drag.resultOriginColumns, nextX - drag.originX);
}
const selector = drag.kind === 'block' ? `[data-block-key="${drag.key}"]` : `[data-field-key="${drag.key}"]`;
const el = document.querySelector(selector);
if (el) {
el.style.left = `${percent(box.x, state.designer.template.page_width)}%`;
el.style.top = `${percent(box.y, state.designer.template.page_height)}%`;
}
}
function stopFieldDrag() {
window.onpointermove = null;
window.onpointerup = null;
state.designer.drag = null;
renderTemplateDesigner();
}
function handleTemplateKeydown(event) {
if (!state.designer || !state.designer.selectedKey) return;
const active = document.activeElement;
if (active && ['INPUT', 'SELECT', 'TEXTAREA'].includes(active.tagName)) return;
if (['Delete', 'Backspace'].includes(event.key)) {
event.preventDefault();
if (state.designer.selectedKind === 'block') deleteSelectedBlock();
else deleteSelectedField();
return;
}
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return;
event.preventDefault();
const step = event.shiftKey ? 10 : 1;
const target = state.designer.selectedKind === 'block'
? getDesignerBlock(state.designer.selectedKey)
: state.designer.config.fields[state.designer.selectedKey];
if (!target) return;
if (event.key === 'ArrowLeft') target.x = Math.max(0, Number(target.x || 0) - step);
if (event.key === 'ArrowRight') target.x = Number(target.x || 0) + step;
if (event.key === 'ArrowUp') target.y = Math.max(0, Number(target.y || 0) - step);
if (event.key === 'ArrowDown') target.y = Number(target.y || 0) + step;
if (state.designer.selectedKind === 'block' && state.designer.selectedKey === 'result') {
moveResultRows(target.y);
}
renderTemplateDesigner();
focusTemplateCanvas();
}
function updateSelectedFieldFromProps() {
const key = state.designer.selectedKey;
if (!key) return;
const box = state.designer.config.fields[key];
box.x = Number(document.querySelector('[name="prop_x"]').value || 0);
box.y = Number(document.querySelector('[name="prop_y"]').value || 0);
box.w = Number(document.querySelector('[name="prop_w"]').value || 120);
box.size = Number(document.querySelector('[name="prop_size"]').value || 12);
box.align = document.querySelector('[name="prop_align"]').value || 'left';
box.valign = document.querySelector('[name="prop_valign"]').value || 'top';
renderTemplateDesigner();
}
function updateSelectedBlockFromProps() {
const key = state.designer.selectedKey;
const box = getDesignerBlock(key);
if (!box) return;
box.x = Number(document.querySelector('[name="block_x"]').value || 0);
box.y = Number(document.querySelector('[name="block_y"]').value || 0);
box.size = Number(document.querySelector('[name="block_size"]').value || 10);
box.valign = document.querySelector('[name="block_valign"]').value || 'top';
const rowH = Number(document.querySelector('[name="block_row_h"]').value || 22);
if (key === 'result') {
const cols = state.designer.config.result_columns || {};
const oldX = Number((cols.home_t || cols.set || {}).x || box.x);
const newX = Number(document.querySelector('[name="block_x"]').value || oldX);
moveResultColumns(clone(cols), newX - oldX);
moveResultRows(box.y, rowH);
} else {
box.row_h = rowH;
box.number_w = Number(document.querySelector('[name="block_number_w"]').value || 32);
box.name_w = Number(document.querySelector('[name="block_name_w"]').value || 124);
}
renderTemplateDesigner();
}
function deleteSelectedField() {
const key = state.designer.selectedKey;
if (!key) return;
delete state.designer.config.fields[key];
state.designer.selectedKey = null;
renderTemplateDesigner();
}
function deleteSelectedBlock() {
const key = state.designer.selectedKey;
if (key === 'home_players') delete state.designer.config.home_players;
if (key === 'away_players') delete state.designer.config.away_players;
if (key === 'result') {
delete state.designer.config.result_rows;
delete state.designer.config.result_columns;
}
state.designer.selectedKey = null;
state.designer.selectedKind = 'field';
renderTemplateDesigner();
}
function getDesignerBlock(key) {
if (key === 'home_players') return state.designer.config.home_players;
if (key === 'away_players') return state.designer.config.away_players;
if (key === 'result') return state.designer.config.result_columns?.home_t || state.designer.config.result_columns?.set;
return null;
}
function resultRowHeight() {
const rows = state.designer.config.result_rows || [];
return rows.length > 1 ? Number(rows[1].y) - Number(rows[0].y) : 26;
}
function moveResultRows(startY, rowH = resultRowHeight()) {
(state.designer.config.result_rows || []).forEach((row, index) => {
row.y = Number(startY) + (index * Number(rowH));
});
}
function moveResultColumns(originColumns, deltaX) {
if (!originColumns) return;
Object.entries(originColumns).forEach(([key, col]) => {
if (!state.designer.config.result_columns[key]) return;
state.designer.config.result_columns[key].x = Math.max(0, Math.round(Number(col.x || 0) + Number(deltaX || 0)));
});
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
async function saveTemplateDesign() {
const saved = await api(`/api/sheet-templates/${state.designer.template.id}`, {
method: 'PUT',
body: JSON.stringify({ config_json: state.designer.config })
});
const fresh = await api(`/api/sheet-templates/${state.designer.template.id}`);
state.designer.template = fresh || saved;
state.designer.config = safeJson(state.designer.template.config_json, state.designer.config);
toast('Diseno de plantilla guardado');
renderTemplateDesigner();
}
function focusTemplateCanvas() {
setTimeout(() => $('#templateCanvas')?.focus?.(), 0);
}
function availableTemplateFields() {
return [
'tournament','date','time','court','home_team','away_team','home_sets','away_sets','winner',
'referee_signature','home_captain','away_captain','home_coach','away_coach','observations',
'home_players','away_players','result'
];
}
function fieldPreview(key) {
const values = {
tournament: 'Liga demo',
date: '19/05/2026',
time: '17:47',
court: 'Cancha 1',
home_team: 'Demo A',
away_team: 'Demo B',
home_sets: '3',
away_sets: '1',
winner: 'Demo A',
referee_signature: 'Arbitro Demo',
home_captain: 'Capitan A',
away_captain: 'Capitan B',
home_coach: 'DT A',
away_coach: 'DT B',
observations: 'Observaciones'
};
return values[key] || key;
}
function percent(value, total) {
return (Number(value || 0) / Number(total || 1)) * 100;
}
function alignToJustify(align) {
return align === 'center' ? 'center' : align === 'right' ? 'flex-end' : 'flex-start';
}
function verticalToAlign(align) {
return align === 'middle' ? 'center' : align === 'bottom' ? 'flex-end' : 'flex-start';
}
function verticalToTransform(align) {
return align === 'middle' ? 'translateY(-50%)' : align === 'bottom' ? 'translateY(-100%)' : 'none';
}
function safeJson(value, fallback) {
try {
return typeof value === 'string' ? JSON.parse(value) : value;
} catch (_) {
return fallback;
}
}
function openTournamentModal() {
openModal({
eyebrow: 'Administracion',
title: 'Crear torneo',
subtitle: 'Define la categoria, subcategoria y formato competitivo.',
body: `<form id="tournamentForm" class="space-y-4">
${field('Nombre', input('name','Nombre'))}
${field('Categoria', '<select class="input" name="category"><option>Masculino</option><option>Femenino</option><option>Mixto</option></select>')}
${field('Subcategoria', input('age_subcategory','Subcategoria'))}
${field('Formato', '<select class="input" name="format"><option>Liga</option><option>Eliminacion directa</option><option>Doble eliminacion</option><option>Grupos + playoffs</option></select>')}
<button class="btn-primary w-full">Crear torneo</button>
</form>`
});
$('#tournamentForm').onsubmit = submitForm('/api/tournaments', data => data, true);
}
function openTeamModal() {
openModal({
eyebrow: 'Administracion',
title: 'Crear equipo',
subtitle: 'El equipo se agrega al torneo activo y obtiene su link de ficha online.',
body: `<form id="teamForm" class="space-y-4">
${field('Nombre', input('name','Nombre'))}
${field('DT / responsable', input('coach_name','DT / responsable'))}
<button class="btn-primary w-full">Guardar equipo</button>
</form>`
});
$('#teamForm').onsubmit = submitForm('/api/teams', data => ({ ...data, tournament_id: selectedTournamentId() }), true);
}
function openMatchModal(teams) {
openModal({
eyebrow: 'Fixture',
title: 'Crear partido',
subtitle: 'Programa un encuentro para el torneo seleccionado.',
body: `<form id="matchForm" class="space-y-4">
${field('Local', `<select class="input" name="home_team_id">${teamOptions(teams)}</select>`)}
${field('Visitante', `<select class="input" name="away_team_id">${teamOptions(teams)}</select>`)}
${field('Fecha y hora', '<input class="input" name="scheduled_at" type="datetime-local">')}
<button class="btn-primary w-full">Programar partido</button>
</form>`
});
$('#matchForm').onsubmit = submitForm('/api/matches', data => ({ ...data, tournament_id: selectedTournamentId() }), true);
}
async function renderScore() {
const id = selectedTournamentId();
const matches = await api(`/api/matches?tournament_id=${id}&per_page=50`);
const matchList = matches.data || [];
const first = matchList.find(match => Number(match.id) === Number(state.scoreMatchId)) || matchList[0];
if (!first) {
view.innerHTML = '<section class="panel">No hay partidos programados.</section>';
return;
}
state.scoreMatchId = first.id;
const [score, advanced, homePlayers, awayPlayers, teamResponse] = await Promise.all([
api(`/api/matches/${first.id}/score`),
api(`/api/matches/${first.id}/advanced-score`),
api(`/api/players?team_id=${first.home_team_id}&per_page=50`),
api(`/api/players?team_id=${first.away_team_id}&per_page=50`),
api(`/api/teams?tournament_id=${id}&per_page=100`)
]);
connectWs(first.id);
const currentSet = score.sets.find(s => !s.winner_team_id) || score.sets[score.sets.length - 1] || { set_number: 1, home_points: 0, away_points: 0, side_state: 'normal' };
const players = [...(homePlayers.data || []), ...(awayPlayers.data || [])];
const fullTeams = teamResponse.data || [];
view.innerHTML = `
<div class="grid gap-4">
<section class="panel">
<div class="flex flex-wrap items-center justify-between gap-3">
<div><h1 class="text-2xl font-black">Modo arbitro/anotador</h1><p class="text-sm text-slate-500">${first.home_team} vs ${first.away_team}</p></div>
<div class="flex flex-wrap items-center gap-2">
<div class="segmented">
<button type="button" data-score-mode="guided" class="${state.scoreMode === 'guided' ? 'active' : ''}">Guiado</button>
<button type="button" data-score-mode="expert" class="${state.scoreMode === 'expert' ? 'active' : ''}">Experto</button>
</div>
<button id="exportScoresheetBtn" class="btn-muted" type="button">Planilla PDF</button>
<select id="scoreMatch" class="input max-w-xs">${matchList.map(m => `<option value="${m.id}" ${m.id === first.id ? 'selected' : ''}>#${m.id} ${m.home_team} vs ${m.away_team}</option>`).join('')}</select>
</div>
</div>
</section>
${state.scoreMode === 'guided' ? guidedScorePanel(first, currentSet, score, advanced) : expertScorePanel(first, currentSet, score)}
<section class="grid gap-4 xl:grid-cols-2">
<div class="panel"><h2 class="section-title">Rotaciones oficiales</h2>${table(['Set','Equipo','Pos','Jugador','Libero'], advanced.rotations.map(r => [r.set_number, r.team_name, r.position_number, r.player_name, Number(r.is_libero) ? 'Si' : 'No']))}</div>
<div class="panel"><h2 class="section-title">Sustituciones y tiempos</h2>${table(['Tipo','Set','Equipo','Detalle'], [
...advanced.substitutions.map(s => ['Sustitucion', s.set_number, s.team_name, `${s.out_first} ${s.out_last} -> ${s.in_first} ${s.in_last}`]),
...advanced.timeouts.map(t => ['Tiempo', t.set_number, t.team_name, `${t.points_home}-${t.points_away}`])
])}</div>
<div class="panel"><h2 class="section-title">Historial de rallys</h2>${table(['#','Set','Saque','Ganador','Resultado','Marcador'], advanced.rallies.map(r => [r.rally_number, r.set_number, r.serving_team || '-', r.winning_team || '-', r.result_type, `${r.points_home}-${r.points_away}`]))}</div>
<div class="panel"><h2 class="section-title">Auditoria y firmas</h2>${table(['Tipo','Detalle','Fecha'], [
...advanced.signatures.map(s => ['Firma', `${s.signer_name} (${s.role}) ${String(s.signature_hash).slice(0, 10)}...`, fmt(s.signed_at)]),
...advanced.audit.map(a => ['Audit', `${a.action} - ${a.user_name || '-'}`, fmt(a.created_at)])
])}</div>
</section>
<section class="panel"><h2 class="section-title">Eventos en vivo</h2>${table(['Set','Equipo','Evento','Marcador','Hora'], score.events.map(e => [e.set_number, e.team_name || '-', e.event_type, `${e.points_home}-${e.points_away}`, fmt(e.created_at)]))}</section>
</div>`;
document.querySelectorAll('[data-team]').forEach(btn => btn.onclick = () => addEvent(first.id, btn.dataset.team, 'point'));
document.querySelectorAll('[data-event]').forEach(btn => btn.onclick = () => addEvent(first.id, first.home_team_id, btn.dataset.event));
document.querySelectorAll('[data-advanced]').forEach(btn => btn.onclick = () => openAdvancedScoreModal(btn.dataset.advanced, first, players, fullTeams));
document.querySelectorAll('[data-score-mode]').forEach(btn => btn.onclick = () => {
state.scoreMode = btn.dataset.scoreMode;
localStorage.setItem('scoreMode', state.scoreMode);
renderScore();
});
$('#scoreMatch').onchange = (event) => {
state.scoreMatchId = Number(event.target.value);
renderScore();
};
$('#exportScoresheetBtn').onclick = () => window.open(`/api/matches/${first.id}/scoresheet/ltv26`, '_blank');
}
function expertScorePanel(match, currentSet, score) {
return `<section class="grid gap-3 md:grid-cols-3">
<div class="panel text-center"><div class="section-title">Local</div><div class="text-4xl font-black">${currentSet.home_points}</div><div>${match.home_team}</div><button class="score-btn mt-4 w-full bg-court" data-team="${match.home_team_id}">+ Punto</button></div>
<div class="panel text-center"><div class="section-title">Set ${currentSet.set_number} - Lado ${currentSet.side_state}</div><div class="text-3xl font-black">${score.sets_won.home} - ${score.sets_won.away}</div><div class="mt-3 grid grid-cols-2 gap-2">${eventButtons()}</div></div>
<div class="panel text-center"><div class="section-title">Visitante</div><div class="text-4xl font-black">${currentSet.away_points}</div><div>${match.away_team}</div><button class="score-btn mt-4 w-full bg-zinc-900" data-team="${match.away_team_id}">+ Punto</button></div>
</section>
<section class="panel">
<h2 class="section-title">Planilla reglamentaria</h2>
<div class="grid gap-2 md:grid-cols-3 xl:grid-cols-6">
<button class="btn-muted" data-advanced="rotation">Rotacion 1-6</button>
<button class="btn-muted" data-advanced="libero">Libero</button>
<button class="btn-muted" data-advanced="substitution">Sustitucion</button>
<button class="btn-muted" data-advanced="timeout">Tiempo</button>
<button class="btn-muted" data-advanced="rally">Rally</button>
<button class="btn-muted" data-advanced="sanction">Sancion / tarjeta</button>
<button class="btn-muted" data-advanced="signature">Firma arbitral</button>
</div>
</section>`;
}
function guidedScorePanel(match, currentSet, score, advanced) {
const rotationCount = advanced.rotations.filter(row => Number(row.set_number) === Number(currentSet.set_number)).length;
const hasSignature = advanced.signatures.length > 0;
return `<section class="panel">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-black">Carga guiada de planilla</h2>
<p class="text-sm text-slate-500">Pensado para planilleros nuevos o cierre reglamentario.</p>
</div>
<div class="rounded border border-slate-200 bg-white px-4 py-2 text-sm font-black dark:border-zinc-800 dark:bg-zinc-900">Set ${currentSet.set_number} - ${currentSet.home_points}-${currentSet.away_points}</div>
</div>
<div class="stepper mt-4">
${guidedStep(1, 'Prepartido', `Confirmar equipos, libero y formacion inicial. Rotaciones cargadas: ${rotationCount}/12.`, [
['rotation', 'Cargar rotacion 1-6'],
['libero', 'Registrar libero']
])}
${guidedStep(2, 'Inicio y control de set', `Sets: ${score.sets_won.home}-${score.sets_won.away}. Lado de cancha: ${currentSet.side_state}.`, [
['rally', 'Registrar rally'],
['timeout', 'Tiempo solicitado'],
['substitution', 'Sustitucion']
])}
${guidedStep(3, 'Incidencias', 'Registrar sanciones, errores administrativos o correcciones con auditoria.', [
['sanction', 'Sancion / tarjeta'],
['rally', 'Rally corregido']
])}
${guidedStep(4, 'Cierre', hasSignature ? 'Planilla firmada digitalmente.' : 'Validar resultado y firmar al cierre.', [
['signature', 'Firma arbitral']
])}
</div>
</section>
<section class="grid gap-3 md:grid-cols-2">
<div class="panel text-center"><div class="section-title">Punto local</div><div class="text-3xl font-black">${match.home_team}</div><button class="score-btn mt-4 w-full bg-court" data-team="${match.home_team_id}">+ Punto</button></div>
<div class="panel text-center"><div class="section-title">Punto visitante</div><div class="text-3xl font-black">${match.away_team}</div><button class="score-btn mt-4 w-full bg-zinc-900" data-team="${match.away_team_id}">+ Punto</button></div>
</section>`;
}
function guidedStep(number, title, description, actions) {
return `<div class="step-row">
<span class="step-number">${number}</span>
<div class="min-w-0">
<div class="font-black">${title}</div>
<p class="text-sm text-slate-500">${description}</p>
<div class="step-actions">${actions.map(([type, label]) => `<button class="btn-muted text-sm" data-advanced="${type}">${label}</button>`).join('')}</div>
</div>
</div>`;
}
function openAdvancedScoreModal(type, match, players, fullTeams = []) {
const homeFull = fullTeams.find(team => Number(team.id) === Number(match.home_team_id));
const awayFull = fullTeams.find(team => Number(team.id) === Number(match.away_team_id));
const teams = [
{ id: match.home_team_id, name: match.home_team, coach: homeFull?.coach_name || 'DT Local' },
{ id: match.away_team_id, name: match.away_team, coach: awayFull?.coach_name || 'DT Visitante' }
];
const teamSelect = `<select class="input" name="team_id">${teams.map(team => `<option value="${team.id}">${team.name}</option>`).join('')}</select>`;
const playerSelect = (name) => `<select class="input" name="${name}">${players.map(player => `<option value="${player.id}">${player.first_name} ${player.last_name} - ${player.team_name}</option>`).join('')}</select>`;
const forms = {
rotation: {
title: 'Rotacion oficial',
path: 'rotations',
body: `${field('Equipo', teamSelect)}${field('Posicion 1 a 6', '<input class="input" name="position_number" type="number" min="1" max="6" value="1">')}${field('Jugador', playerSelect('player_id'))}${field('Libero', '<select class="input" name="is_libero"><option value="0">No</option><option value="1">Si</option></select>')}`
},
libero: {
title: 'Gestion de libero',
path: 'liberos',
body: `${field('Equipo', teamSelect)}${field('Jugador libero', playerSelect('player_id'))}${field('Inicial', '<select class="input" name="is_starting"><option value="0">No</option><option value="1">Si</option></select>')}`
},
substitution: {
title: 'Sustitucion reglamentaria',
path: 'substitutions',
body: `${field('Equipo', teamSelect)}${field('Sale', playerSelect('player_out_id'))}${field('Entra', playerSelect('player_in_id'))}${field('Motivo', input('reason','Motivo'))}`
},
timeout: {
title: 'Tiempo solicitado',
path: 'timeouts',
body: `${field('Equipo', teamSelect)}${field('Solicitado por', input('requested_by','DT / capitan'))}`
},
rally: {
title: 'Historial de rally',
path: 'rallies',
body: `${field('Equipo sacador', `<select class="input" name="serving_team_id">${teams.map(team => `<option value="${team.id}">${team.name}</option>`).join('')}</select>`)}${field('Equipo ganador', `<select class="input" name="winning_team_id">${teams.map(team => `<option value="${team.id}">${team.name}</option>`).join('')}</select>`)}${field('Resultado', '<select class="input" name="result_type"><option>point</option><option>error</option><option>ace</option><option>block</option><option>attack</option></select>')}${field('Notas', input('notes','Descripcion breve'))}`
},
signature: {
title: 'Firma digital arbitral',
path: 'signatures',
body: `${field('Nombre del arbitro', input('signer_name','Nombre completo'))}${field('Rol', '<select class="input" name="role"><option value="principal">Principal</option><option value="segundo">Segundo</option><option value="anotador">Anotador</option></select>')}`
},
sanction: {
title: 'Sancion / tarjeta',
path: 'advanced-sanctions',
body: `${field('Equipo', `<select class="input" name="team_id" id="sanctionTeamSelect">${teams.map(team => `<option value="${team.id}">${team.name}</option>`).join('')}</select>`)}${field('Plantel', `<select class="input" name="player_id" id="sanctionRosterSelect"></select>`)}
<div class="field"><span class="field-label">Tarjeta</span><div class="card-choice">
<input id="cardYellow" type="radio" name="card_type" value="yellow" checked><label for="cardYellow"><span class="card-swatch yellow"></span>Tarjeta amarilla</label>
<input id="cardRed" type="radio" name="card_type" value="red"><label for="cardRed"><span class="card-swatch red"></span>Tarjeta roja</label>
</div></div>
${field('Motivo', input('reason','Motivo'))}`
}
};
const config = forms[type];
openModal({
eyebrow: 'Planilla electronica',
title: config.title,
subtitle: 'El registro queda en eventos, historial y auditoria arbitral.',
body: `<form id="advancedScoreForm" class="space-y-4">${config.body}<button class="btn-primary w-full">Registrar</button></form>`
});
if (type === 'sanction') bindSanctionRoster(teams, players);
$('#advancedScoreForm').onsubmit = async (event) => {
event.preventDefault();
const payload = Object.fromEntries(new FormData(event.target));
if (payload.player_id === 'coach' || payload.player_id === '') {
payload.reason = `${payload.player_id === 'coach' ? 'DT - ' : ''}${payload.reason || ''}`.trim();
payload.player_id = null;
}
await api(`/api/matches/${match.id}/${config.path}`, { method: 'POST', body: JSON.stringify(payload) });
closeModal();
toast('Registro guardado');
renderScore();
};
}
function bindSanctionRoster(teams, players) {
const teamSelectEl = $('#sanctionTeamSelect');
const rosterSelect = $('#sanctionRosterSelect');
const refresh = () => {
const teamId = Number(teamSelectEl.value);
const team = teams.find(item => Number(item.id) === teamId);
const roster = players.filter(player => Number(player.team_id) === teamId);
rosterSelect.innerHTML = [
'<option value="">Equipo / banco</option>',
`<option value="coach">DT - ${team?.coach || 'Responsable'}</option>`,
...roster.map(player => `<option value="${player.id}">#${player.jersey_number || '-'} ${player.first_name} ${player.last_name}</option>`)
].join('');
};
teamSelectEl.onchange = refresh;
refresh();
}
function eventButtons() {
return ['serve','ace','block','attack','error','rotation'].map(e => `<button class="btn-muted text-xs" data-event="${e}">${e}</button>`).join('');
}
async function addEvent(matchId, teamId, eventType) {
try {
await api(`/api/matches/${matchId}/events`, { method: 'POST', body: JSON.stringify({ team_id: Number(teamId), event_type: eventType }) });
toast('Evento registrado');
renderScore();
} catch (err) {
toast(err.message, 'error');
}
}
function connectWs(matchId) {
if (state.ws) return;
try {
state.ws = new WebSocket(`ws://${location.hostname}:8081?match_id=${matchId}`);
state.ws.onmessage = () => state.route === 'score' && renderScore();
} catch (_) {}
}
function renderTeamLink() {
view.innerHTML = `<section class="panel max-w-xl">
<h1 class="text-2xl font-black">Ficha online de jugador</h1>
<p class="mb-4 text-sm text-slate-500">Usa el token del equipo. Ejemplo seed: 11111111111111111111111111111111</p>
<form id="linkForm" class="space-y-2">
${input('token','Token del equipo')}${input('first_name','Nombre')}${input('last_name','Apellido')}${input('document_id','DNI')}${input('birth_date','Nacimiento', 'date')}${input('jersey_number','Numero', 'number')}
<select class="input" name="position"><option>Punta</option><option>Armador</option><option>Central</option><option>Opuesto</option><option>Libero</option><option>Universal</option></select>
<button class="btn-primary w-full">Enviar ficha</button>
</form>
</section>`;
$('#linkForm').onsubmit = async (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
const token = data.token;
delete data.token;
await api(`/api/team-links/${token}/players`, { method: 'POST', body: JSON.stringify(data) });
toast('Ficha registrada');
e.target.reset();
};
}
function submitForm(path, mapper, shouldCloseModal = false, method = 'POST') {
return async (e) => {
e.preventDefault();
const payload = mapper(Object.fromEntries(new FormData(e.target)));
await api(path, { method, body: JSON.stringify(payload) });
toast('Guardado correctamente');
if (shouldCloseModal) closeModal();
await loadBase();
render();
};
}
function userTable(users) {
const rows = users.map(user => [
`<div class="font-bold">${user.name}</div><div class="text-xs text-slate-500">${user.email}</div>`,
user.role,
Number(user.active) ? 'Activo' : 'Inactivo',
`<div class="flex gap-2">
<button class="btn-muted text-xs" data-edit-user="${user.id}">Editar</button>
<button class="btn-muted text-xs" data-toggle-user="${user.id}" data-active="${Number(user.active)}">${Number(user.active) ? 'Desactivar' : 'Activar'}</button>
</div>`
]);
return table(['Usuario','Rol','Estado','Acciones'], rows);
}
function templateTable(templates) {
const rows = templates.map(template => [
`<div class="font-bold">${template.name}</div><div class="text-xs text-slate-500">${template.code}</div>`,
`${template.page_width}x${template.page_height}`,
template.image_path,
Number(template.assigned_to_tournament) ? 'Asignada' : (Number(template.active) ? 'Activa' : 'Inactiva'),
`<div class="flex flex-wrap gap-2"><button class="btn-muted text-xs" type="button" data-edit-template="${template.id}">Editar diseno</button><button class="btn-muted text-xs" type="button" data-assign-template="${template.id}">${Number(template.assigned_to_tournament) ? 'Reasignar' : 'Asignar al torneo'}</button></div>`
]);
return table(['Plantilla','Tamano','Imagen','Estado','Acciones'], rows);
}
function table(headers, rows) {
return `<div class="overflow-x-auto"><table class="table"><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>${(rows.length ? rows : [['Sin datos']]).map(r => `<tr>${r.map(c => `<td>${c ?? '-'}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
}
function input(name, placeholder, type = 'text', value = '') {
return `<input class="input" name="${name}" type="${type}" value="${escapeAttr(value)}" placeholder="${placeholder}">`;
}
function field(label, control) {
return `<label class="field"><span class="field-label">${label}</span>${control}</label>`;
}
function escapeAttr(value) {
return String(value ?? '').replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}
function escapeHtml(value) {
return String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}
function teamOptions(teams = []) {
return teams.map(t => `<option value="${t.id}">${t.name}</option>`).join('');
}
function metricCard(label, value) {
return `<div class="min-w-[112px] rounded border border-slate-200 bg-slate-50 px-4 py-3 text-right dark:border-zinc-800 dark:bg-zinc-900">
<div class="text-2xl font-black">${value}</div>
<div class="text-xs font-bold uppercase text-slate-500">${label}</div>
</div>`;
}
function fmt(value) {
if (!value) return '-';
return new Date(value.replace(' ', 'T')).toLocaleString('es-AR', { dateStyle: 'short', timeStyle: 'short' });
}
$('#loginBtn').onclick = openLoginModal;
$('#sideLoginBtn').onclick = openLoginModal;
$('#sidebarToggle').onclick = toggleSidebar;
$('#mobileMenuBtn').onclick = openMobileMenu;
$('#mobileMenuBackdrop').onclick = closeMobileMenu;
$('#themeBtn').onclick = () => document.documentElement.classList.toggle('dark');
$('#refreshBtn').onclick = () => render().catch(err => toast(err.message, 'error'));
$('#fixtureBtn').onclick = async () => {
try {
await api(`/api/tournaments/${selectedTournamentId()}/fixture`, { method: 'POST', body: JSON.stringify({}) });
toast('Fixture generado');
await render();
} catch (err) {
if (err.status === 401) {
clearSession();
updateSession();
openLoginModal();
}
toast(err.message, 'error');
}
};
document.querySelectorAll('[data-modal-close]').forEach(el => el.onclick = closeModal);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeModal();
});
document.querySelectorAll('[data-route]').forEach(btn => btn.onclick = () => setRoute(btn.dataset.route));
$('#tournamentSelect').onchange = () => render().catch(err => toast(err.message, 'error'));
applySidebarState();
validateStoredSession()
.then(loadBase)
.then(render)
.catch(err => toast(err.message, 'error'));