/* Panel de administración: login, rondas, calendario de aprobación, Excel. */ (() => { const $ = (id) => document.getElementById(id); const MONTHS = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre']; const DOW = ['L','M','X','J','V','S','D']; const toast = (msg, isError = false) => { const el = $('toast'); el.textContent = msg; el.classList.toggle('error', isError); el.classList.add('show'); clearTimeout(el._t); el._t = setTimeout(() => el.classList.remove('show'), 2600); }; const fetchJSON = async (url, opts) => { const res = await fetch(url, opts && { method: opts.method, headers: opts.body ? { 'Content-Type': 'application/json' } : undefined, body: opts.body ? JSON.stringify(opts.body) : undefined, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || 'Error de conexión'); return data; }; const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); function show(view) { $('login-card').classList.toggle('hidden', view !== 'login'); $('panel').classList.toggle('hidden', view !== 'panel'); } // Alterna entre la lista de rondas y la vista de calendario dentro del panel. function showPanelView(which) { $('rounds-view').classList.toggle('hidden', which !== 'rounds'); $('calendar-view').classList.toggle('hidden', which !== 'calendar'); document.body.classList.toggle('cal-open', which === 'calendar'); if (which !== 'calendar') hideActionbar(); } // ---------- login ---------- $('login-form').addEventListener('submit', async (e) => { e.preventDefault(); const err = $('login-error'); err.classList.remove('show'); try { await fetchJSON('/api/admin/login', { method: 'POST', body: { password: $('password').value } }); $('password').value = ''; show('panel'); loadRounds(); } catch (ex) { err.textContent = ex.message; err.classList.add('show'); } }); $('logout-btn').addEventListener('click', async () => { await fetchJSON('/api/admin/logout', { method: 'POST' }).catch(() => {}); show('login'); }); // ---------- editor de cargos (nueva ronda) ---------- $('round-year').value = new Date().getFullYear() + 1; function addRoleRow(label = '', max = '') { const row = document.createElement('div'); row.className = 'role-row'; row.innerHTML = ` `; row.querySelector('.role-del').addEventListener('click', () => { if ($('roles-editor').children.length > 1) row.remove(); }); $('roles-editor').appendChild(row); } // Cargos propuestos por defecto al abrir el panel. [['Camarero/a', 2], ['Encargado', ''], ['Cocina', 1]].forEach(([l, m]) => addRoleRow(l, m)); $('add-role').addEventListener('click', () => addRoleRow()); function readRoles() { return [...$('roles-editor').querySelectorAll('.role-row')] .map((row) => ({ label: row.querySelector('.role-label').value.trim(), max: row.querySelector('.role-max').value, })) .filter((r) => r.label) .map((r) => ({ label: r.label, max: r.max === '' ? null : Number(r.max) })); } $('create-form').addEventListener('submit', async (e) => { e.preventDefault(); const err = $('create-error'); err.classList.remove('show'); try { const roles = readRoles(); const { round } = await fetchJSON('/api/admin/rounds', { method: 'POST', body: { name: $('round-name').value, year: Number($('round-year').value), roles }, }); $('round-name').value = ''; await copyUrl(round.token, false); toast('✓ Ronda creada y URL copiada'); loadRounds(); } catch (ex) { err.textContent = ex.message; err.classList.add('show'); } }); // ---------- lista de rondas ---------- const roundUrl = (token) => `${location.origin}/r/${token}`; async function copyUrl(token, notify = true) { try { await navigator.clipboard.writeText(roundUrl(token)); if (notify) toast('✓ URL copiada al portapapeles'); } catch { if (notify) toast('No se pudo copiar; mantén pulsado el enlace', true); } } async function loadRounds() { const list = $('rounds-list'); let rounds; try { ({ rounds } = await fetchJSON('/api/admin/rounds')); } catch { show('login'); return; } showPanelView('rounds'); list.innerHTML = ''; if (!rounds.length) { list.innerHTML = '
Todavía no hay rondas. Crea la primera arriba.
'; return; } for (const r of rounds) { const open = r.status === 'open'; const pending = r.pending ? ` · ${r.pending} pendiente${r.pending === 1 ? '' : 's'}` : ''; const item = document.createElement('div'); item.className = 'round-item'; item.innerHTML = ` `; item.querySelector('[data-act=calendar]').addEventListener('click', () => openCalendar(r.id)); item.querySelector('[data-act=copy]').addEventListener('click', () => copyUrl(r.token)); item.querySelector('[data-act=status]').addEventListener('click', async () => { if (open && !confirm(`¿Cerrar la ronda "${r.name} ${r.year}"? Nadie podrá pedir ni modificar días.`)) return; try { await fetchJSON(`/api/admin/rounds/${r.id}/status`, { method: 'POST', body: { status: open ? 'closed' : 'open' }, }); toast(open ? 'Ronda cerrada' : 'Ronda reabierta'); loadRounds(); } catch (ex) { toast(ex.message, true); } }); item.querySelector('[data-act=delete]').addEventListener('click', async () => { if (!confirm(`¿Eliminar la ronda "${r.name} ${r.year}" y todas sus peticiones? No se puede deshacer.`)) return; try { await fetchJSON(`/api/admin/rounds/${r.id}`, { method: 'DELETE' }); toast('Ronda eliminada'); loadRounds(); } catch (ex) { toast(ex.message, true); } }); list.appendChild(item); } } // =================================================================== // Calendario de aprobación // =================================================================== let cal = null; // datos de la ronda en curso let selectedWorkerId = null;// empleado seleccionado (null = vista de ocupación) let focusedDate = null; // día con la barra de acción abierta const iso = (y, m, d) => `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; const initials = (label) => label.replace(/[^\p{L}\p{N}]/gu, '').slice(0, 2) || '·'; async function openCalendar(id) { try { cal = await fetchJSON(`/api/admin/rounds/${id}/calendar`); } catch (ex) { toast(ex.message, true); return; } selectedWorkerId = null; focusedDate = null; const open = cal.round.status === 'open'; $('cal-title').textContent = `${cal.round.name} ${cal.round.year}`; $('cal-status').textContent = open ? 'Abierta' : 'Cerrada'; $('cal-status').className = `badge ${open ? 'open' : 'closed'}`; showPanelView('calendar'); closeNameEditor(); renderLimitsEditor(); rerender(); window.scrollTo(0, 0); } $('cal-back').addEventListener('click', () => loadRounds()); // ----- editar el nombre de la ronda ----- function closeNameEditor() { $('cal-name-editor').classList.add('hidden'); $('cal-name-edit').classList.remove('hidden'); } $('cal-name-edit').addEventListener('click', () => { $('cal-name-input').value = cal.round.name; $('cal-name-editor').classList.remove('hidden'); $('cal-name-edit').classList.add('hidden'); $('cal-name-input').focus(); $('cal-name-input').select(); }); $('cal-name-cancel').addEventListener('click', closeNameEditor); $('cal-name-save').addEventListener('click', async () => { const name = $('cal-name-input').value.trim(); if (!name) { toast('Escribe un nombre', true); return; } try { const data = await fetchJSON(`/api/admin/rounds/${cal.round.id}/name`, { method: 'PUT', body: { name } }); cal.round.name = data.name; $('cal-title').textContent = `${data.name} ${cal.round.year}`; toast('✓ Nombre actualizado'); closeNameEditor(); } catch (ex) { toast(ex.message, true); } }); $('cal-name-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); $('cal-name-save').click(); } if (e.key === 'Escape') { e.preventDefault(); closeNameEditor(); } }); // ----- editor de límites ----- function renderLimitsEditor() { const box = $('cal-limits-fields'); box.innerHTML = ''; for (const [key, label] of Object.entries(cal.roles)) { const field = document.createElement('label'); field.className = 'limit-field'; field.innerHTML = `${esc(label)}` + ``; box.appendChild(field); } } $('cal-limits-save').addEventListener('click', async () => { const limits = {}; $('cal-limits-fields').querySelectorAll('input').forEach((inp) => { const v = inp.value.trim(); if (v !== '') limits[inp.dataset.role] = Number(v); }); try { const data = await fetchJSON(`/api/admin/rounds/${cal.round.id}/limits`, { method: 'PUT', body: { limits } }); cal.limits = data.limits; toast('✓ Límites guardados'); rerender(); } catch (ex) { toast(ex.message, true); } }); // ----- bloquear / desbloquear días ----- // Alterna el bloqueo de un día para toda la ronda (envía la lista completa). async function toggleBlocked(date) { const current = new Set(cal.blocked || []); const willBlock = !current.has(date); if (willBlock) current.add(date); else current.delete(date); try { const data = await fetchJSON(`/api/admin/rounds/${cal.round.id}/blocked`, { method: 'PUT', body: { dates: [...current] }, }); cal.blocked = data.blocked; toast(willBlock ? '🔒 Día bloqueado para todo el equipo' : 'Día desbloqueado'); rerender(); } catch (ex) { toast(ex.message, true); } } // ----- datos derivados ----- // Ocupación por fecha y cargo: {date: {role: {approved, pending, rejected}}}. function occupancy() { const map = {}; for (const w of cal.workers) { for (const r of w.requests) { const cell = (map[r.date] ??= {}); const c = (cell[w.role] ??= { approved: 0, pending: 0, rejected: 0 }); c[r.status]++; } } return map; } const held = (c) => (c ? c.approved + c.pending : 0); // ocupan hueco (no rechazadas) function workerById(id) { return cal.workers.find((w) => w.id === id) || null; } // ----- barra lateral ----- function renderSidebar() { const box = $('cal-sidebar'); box.innerHTML = ''; const all = document.createElement('button'); all.type = 'button'; all.className = 'side-all' + (selectedWorkerId == null ? ' is-active' : ''); all.textContent = 'Ocupación (todo el equipo)'; all.addEventListener('click', () => { selectedWorkerId = null; rerender(); }); box.appendChild(all); if (!cal.workers.length) { const empty = document.createElement('p'); empty.className = 'empty'; empty.textContent = 'Nadie se ha registrado todavía.'; box.appendChild(empty); return; } let lastRole = null; for (const w of cal.workers) { if (w.role !== lastRole) { lastRole = w.role; const h = document.createElement('p'); h.className = 'side-role'; const lim = cal.limits[w.role]; h.innerHTML = `${esc(cal.roles[w.role] || w.role)}${lim ? ` máx ${lim}/día` : ''}`; box.appendChild(h); } const counts = { approved: 0, pending: 0, rejected: 0 }; for (const r of w.requests) counts[r.status]++; const active = w.id === selectedWorkerId; const card = document.createElement('div'); card.className = 'side-worker' + (active ? ' is-active' : ''); card.innerHTML = ` `; card.querySelector('.side-worker-main').addEventListener('click', () => { selectedWorkerId = active ? null : w.id; rerender(); }); if (active) { const bulk = document.createElement('div'); bulk.className = 'side-bulk'; const pend = w.requests.filter((r) => r.status === 'pending').map((r) => r.date); bulk.innerHTML = ` `; const [bApprove, bReject] = bulk.querySelectorAll('button'); bApprove.addEventListener('click', () => setStatus(w.id, pend, 'approved')); bReject.addEventListener('click', () => setStatus(w.id, pend, 'rejected')); card.appendChild(bulk); } box.appendChild(card); } } // ----- calendario ----- function renderGrid() { const occ = occupancy(); const blockedSet = new Set(cal.blocked || []); const worker = workerById(selectedWorkerId); const myReq = worker ? new Map(worker.requests.map((r) => [r.date, r.status])) : null; const hint = document.createElement('p'); hint.className = 'hint cal-mode-hint'; hint.innerHTML = worker ? `Editando a ${esc(worker.name)} · ${esc(cal.roles[worker.role] || worker.role)}. Toca un día para aprobarlo, rechazarlo, dejarlo pendiente o quitarlo. Puedes asignar días aunque estén completos o bloqueados.` : 'Vista de ocupación de todo el equipo. Toca un día para bloquearlo o desbloquearlo para todo el equipo (🔒 no elegible por nadie). Elige un empleado en la izquierda para aprobar o editar sus días.'; const grid = $('cal-grid'); grid.innerHTML = ''; grid.appendChild(hint); const months = document.createElement('div'); months.className = 'cal-months'; for (let m = 0; m < 12; m++) { const month = document.createElement('section'); month.className = 'month'; month.innerHTML = `