/* 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 = `
${esc(r.name)} ${r.year} ${open ? 'Abierta' : 'Cerrada'}
${r.workers} persona${r.workers === 1 ? '' : 's'} · ${r.days} día${r.days === 1 ? '' : 's'} pedidos${pending}
${roundUrl(r.token)}
Excel
`; 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'); renderLimitsEditor(); rerender(); window.scrollTo(0, 0); } $('cal-back').addEventListener('click', () => loadRounds()); // ----- 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); } }); // ----- 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 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.` : 'Vista de ocupación de todo el equipo. 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 = `

${MONTHS[m]} ${cal.round.year}

` + `
${DOW.map((d) => `${d}`).join('')}
`; const days = document.createElement('div'); days.className = 'days'; const first = new Date(cal.round.year, m, 1); const blanks = (first.getDay() + 6) % 7; for (let i = 0; i < blanks; i++) days.appendChild(document.createElement('span')); const total = new Date(cal.round.year, m + 1, 0).getDate(); for (let d = 1; d <= total; d++) { const date = iso(cal.round.year, m, d); const btn = document.createElement('button'); btn.type = 'button'; btn.dataset.date = date; if (worker) { // Modo empleado: color por su estado + carga del cargo ese día. const status = myReq.get(date); let cls = 'cday'; if (status) cls += ' s-' + status; if (date === focusedDate) cls += ' focused'; btn.className = cls; btn.innerHTML = `${d}`; const others = held(occ[date]?.[worker.role]) - (status && status !== 'rejected' ? 1 : 0); const lim = cal.limits[worker.role]; if (others > 0) { const load = document.createElement('span'); load.className = 'load' + (lim && others >= lim ? ' is-full' : ''); load.textContent = lim ? `${others}/${lim}` : others; btn.appendChild(load); } btn.addEventListener('click', () => focusDay(date)); } else { // Modo ocupación: chips por cargo con su carga. btn.className = 'cday cday--info'; btn.disabled = true; btn.innerHTML = `${d}`; const cell = occ[date]; if (cell) { const chips = document.createElement('span'); chips.className = 'chips'; for (const role of Object.keys(cal.roles)) { const h = held(cell[role]); if (!h) continue; const lim = cal.limits[role]; const chip = document.createElement('span'); chip.className = 'rchip' + (lim && h >= lim ? ' is-full' : ''); chip.textContent = `${initials(cal.roles[role])}${h}`; chip.title = `${cal.roles[role]}: ${h}${lim ? ' / ' + lim : ''}`; chips.appendChild(chip); } if (chips.children.length) btn.appendChild(chips); } } days.appendChild(btn); } month.appendChild(days); months.appendChild(month); } grid.appendChild(months); } function rerender() { const y = window.scrollY; renderSidebar(); renderGrid(); if (!focusedDate) hideActionbar(); window.scrollTo(0, y); } // ----- barra de acción por día ----- function focusDay(date) { focusedDate = focusedDate === date ? null : date; const worker = workerById(selectedWorkerId); if (focusedDate && worker) { const status = worker.requests.find((r) => r.date === date)?.status; const human = new Date(date + 'T00:00:00').toLocaleDateString('es-ES', { weekday: 'long', day: 'numeric', month: 'long' }); const label = status ? `${human} — ${({ approved: 'aprobado', pending: 'pendiente', rejected: 'rechazado' }[status])}` : `${human} — sin solicitar`; $('cal-action-label').textContent = label; $('cal-actionbar').classList.add('show'); } else { hideActionbar(); } // Repinta el foco sin perder scroll. rerender(); } function hideActionbar() { focusedDate = null; $('cal-actionbar').classList.remove('show'); } $('cal-actionbar').querySelectorAll('button[data-status]').forEach((b) => { b.addEventListener('click', () => { if (selectedWorkerId == null || !focusedDate) return; setStatus(selectedWorkerId, [focusedDate], b.dataset.status); }); }); // Aplica un cambio de estado en el servidor y refresca la vista. async function setStatus(workerId, dates, status) { if (!dates.length) return; try { const data = await fetchJSON(`/api/admin/rounds/${cal.round.id}/requests/set`, { method: 'POST', body: { worker_id: workerId, dates, status }, }); const w = workerById(workerId); if (w) w.requests = data.requests; const labels = { approved: 'Aprobado', rejected: 'Rechazado', pending: 'Pendiente', none: 'Quitado' }; toast(`✓ ${labels[status]}${dates.length > 1 ? ` (${dates.length} días)` : ''}`); hideActionbar(); rerender(); } catch (ex) { toast(ex.message, true); } } // ---------- arranque ---------- fetchJSON('/api/admin/me') .then(() => { show('panel'); loadRounds(); }) .catch(() => show('login')); })();