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 => ``).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: `

Ya estas autenticado en el sistema.

` }); $('#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: `
JWT activo

Credenciales de prueba: admin@volley.test / password

` }); 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 = `

Resultados, fixture y tabla

Vista publica responsive con datos actualizados desde la planilla electronica.

Tabla de posiciones

${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]))}

Calendario

${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]))}

Ranking de jugadores

${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]))}
`; } async function renderAdmin() { if (!state.authValidated || state.token) { await validateStoredSession(); } if (!isAdmin()) { view.innerHTML = `

Acceso administrador

Para crear torneos, equipos y partidos tenes que iniciar sesion con un usuario administrador.

Estado actual: ${state.user ? `${state.user.email} - ${currentRole() || 'sin rol'}` : 'sin sesion valida'}

`; $('#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 = `

Dashboard administrativo

Gestion operativa del torneo seleccionado.

${metricCard('Equipos', teams.data?.length || 0)} ${metricCard('Jugadores', players.data?.length || 0)} ${metricCard('Partidos', matches.data?.length || 0)} ${metricCard('Usuarios', users.data?.length || 0)}

Equipos

${table(['Equipo','DT','Link ficha'], (teams.data || []).map(t => [t.name, t.coach_name || '-', `/registro/${t.registration_token}`]))}

Usuarios

${userTable(users.data || [])}

Plantillas de planilla

${templateTable(templates.data || [])}

Jugadores

${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 || '-']))}

Partidos

${table(['ID','Fecha','Local','Visitante','Estado'], (matches.data || []).map(m => [m.id, fmt(m.scheduled_at), m.home_team, m.away_team, m.status]))}
`; 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: `
${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', ``)} ${field('Estado', ``)}
` }); $('#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: `
${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', ``)}
` }); $('#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: `
${field('Torneo', ``)}
` }); $('#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: `
${field('Torneo', ``)} ${field('Modo', '')}
` }); $('#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 = `

Editor de plantilla

${template.name} - ${template.page_width}x${template.page_height}

${template.name} ${designerFields(config)} ${designerPlayerBlock(config.home_players, 'home_players', 'Jugadores A')} ${designerPlayerBlock(config.away_players, 'away_players', 'Jugadores B')} ${designerResultBlock(config)}
`; 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 `
${fieldPreview(key)}
`; }).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 `
${label}
`; } 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 `
Resultado
`; } function fieldPropsPanel() { const key = state.designer?.selectedKey; if (!key) return '

Selecciona un campo del lienzo.

'; if (state.designer.selectedKind === 'block') { return blockPropsPanel(key); } const box = state.designer.config.fields[key]; return `
${field('Campo', ``)} ${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', ``)} ${field('Alineacion vertical', ``)}
`; } function blockPropsPanel(key) { const block = getDesignerBlock(key); if (!block) return '

Bloque no disponible.

'; return `
${field('Bloque', ``)} ${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', ``)}
`; } 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: `
${field('Nombre', input('name','Nombre'))} ${field('Categoria', '')} ${field('Subcategoria', input('age_subcategory','Subcategoria'))} ${field('Formato', '')}
` }); $('#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: `
${field('Nombre', input('name','Nombre'))} ${field('DT / responsable', input('coach_name','DT / responsable'))}
` }); $('#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: `
${field('Local', ``)} ${field('Visitante', ``)} ${field('Fecha y hora', '')}
` }); $('#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 = '
No hay partidos programados.
'; 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 = `

Modo arbitro/anotador

${first.home_team} vs ${first.away_team}

${state.scoreMode === 'guided' ? guidedScorePanel(first, currentSet, score, advanced) : expertScorePanel(first, currentSet, score)}

Rotaciones oficiales

${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']))}

Sustituciones y tiempos

${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}`]) ])}

Historial de rallys

${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}`]))}

Auditoria y firmas

${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)]) ])}

Eventos en vivo

${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)]))}
`; 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 `
Local
${currentSet.home_points}
${match.home_team}
Set ${currentSet.set_number} - Lado ${currentSet.side_state}
${score.sets_won.home} - ${score.sets_won.away}
${eventButtons()}
Visitante
${currentSet.away_points}
${match.away_team}

Planilla reglamentaria

`; } 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 `

Carga guiada de planilla

Pensado para planilleros nuevos o cierre reglamentario.

Set ${currentSet.set_number} - ${currentSet.home_points}-${currentSet.away_points}
${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'] ])}
Punto local
${match.home_team}
Punto visitante
${match.away_team}
`; } function guidedStep(number, title, description, actions) { return `
${number}
${title}

${description}

${actions.map(([type, label]) => ``).join('')}
`; } 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 = ``; const playerSelect = (name) => ``; const forms = { rotation: { title: 'Rotacion oficial', path: 'rotations', body: `${field('Equipo', teamSelect)}${field('Posicion 1 a 6', '')}${field('Jugador', playerSelect('player_id'))}${field('Libero', '')}` }, libero: { title: 'Gestion de libero', path: 'liberos', body: `${field('Equipo', teamSelect)}${field('Jugador libero', playerSelect('player_id'))}${field('Inicial', '')}` }, 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', ``)}${field('Equipo ganador', ``)}${field('Resultado', '')}${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', '')}` }, sanction: { title: 'Sancion / tarjeta', path: 'advanced-sanctions', body: `${field('Equipo', ``)}${field('Plantel', ``)}
Tarjeta
${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: `
${config.body}
` }); 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 = [ '', ``, ...roster.map(player => ``) ].join(''); }; teamSelectEl.onchange = refresh; refresh(); } function eventButtons() { return ['serve','ace','block','attack','error','rotation'].map(e => ``).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 = `

Ficha online de jugador

Usa el token del equipo. Ejemplo seed: 11111111111111111111111111111111

${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')}
`; $('#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 => [ `
${user.name}
${user.email}
`, user.role, Number(user.active) ? 'Activo' : 'Inactivo', `
` ]); return table(['Usuario','Rol','Estado','Acciones'], rows); } function templateTable(templates) { const rows = templates.map(template => [ `
${template.name}
${template.code}
`, `${template.page_width}x${template.page_height}`, template.image_path, Number(template.assigned_to_tournament) ? 'Asignada' : (Number(template.active) ? 'Activa' : 'Inactiva'), `
` ]); return table(['Plantilla','Tamano','Imagen','Estado','Acciones'], rows); } function table(headers, rows) { return `
${headers.map(h => ``).join('')}${(rows.length ? rows : [['Sin datos']]).map(r => `${r.map(c => ``).join('')}`).join('')}
${h}
${c ?? '-'}
`; } function input(name, placeholder, type = 'text', value = '') { return ``; } function field(label, control) { return ``; } function escapeAttr(value) { return String(value ?? '').replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>'); } function escapeHtml(value) { return String(value ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); } function teamOptions(teams = []) { return teams.map(t => ``).join(''); } function metricCard(label, value) { return `
${value}
${label}
`; } 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'));