Files
vacaciones/public/js/admin.js
T
juavillo bc4e232a80 Bloqueo de días, override del admin y edición del nombre de ronda
- Admin puede bloquear/desbloquear días (rounds.blocked) desde la vista de
  ocupación; los trabajadores no pueden elegirlos (🔒).
- El admin puede asignar cualquier día aunque supere el límite por día/cargo
  o esté bloqueado (override explícito en requests/set).
- Editar el nombre de la ronda con edición inline en el calendario
  (PUT /api/admin/rounds/:id/name).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 23:21:20 +02:00

555 lines
21 KiB
JavaScript

/* 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 = `
<input class="role-label" type="text" maxlength="40" placeholder="Cargo (p. ej. Camarero/a)" value="${esc(label)}" />
<input class="role-max" type="number" min="1" max="99" placeholder="máx/día" value="${esc(max)}" />
<button type="button" class="role-del" title="Quitar cargo">✕</button>`;
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 = '<p class="empty">Todavía no hay rondas. Crea la primera arriba.</p>';
return;
}
for (const r of rounds) {
const open = r.status === 'open';
const pending = r.pending
? ` · <b class="pending-flag">${r.pending} pendiente${r.pending === 1 ? '' : 's'}</b>`
: '';
const item = document.createElement('div');
item.className = 'round-item';
item.innerHTML = `
<div class="info">
<span class="title">${esc(r.name)} ${r.year}</span>
<span class="badge ${open ? 'open' : 'closed'}">${open ? 'Abierta' : 'Cerrada'}</span>
<div class="meta">${r.workers} persona${r.workers === 1 ? '' : 's'} · ${r.days} día${r.days === 1 ? '' : 's'} pedidos${pending}</div>
<a class="url" href="/r/${r.token}" target="_blank" rel="noopener">${roundUrl(r.token)}</a>
</div>
<div class="actions">
<button class="btn btn--small btn--accent" data-act="calendar">Calendario</button>
<button class="btn btn--ghost btn--small" data-act="copy">Copiar URL</button>
<a class="btn btn--ghost btn--small" href="/api/admin/rounds/${r.id}/excel" download>Excel</a>
<button class="btn btn--small ${open ? '' : 'btn--ghost'}" data-act="status">
${open ? 'Cerrar' : 'Reabrir'}
</button>
<button class="btn btn--ghost btn--small" data-act="delete" title="Eliminar ronda">🗑</button>
</div>`;
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 =
`<span>${esc(label)}</span>` +
`<input type="number" min="1" max="99" placeholder="∞" data-role="${esc(key)}" value="${cal.limits[key] ?? ''}" />`;
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 ? ` <span class="side-lim">máx ${lim}/día</span>` : ''}`;
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 = `
<button type="button" class="side-worker-main">
<span class="name">${esc(w.name)}</span>
<span class="tags">
<span class="t t-ap">${counts.approved}✓</span>
<span class="t t-pe">${counts.pending}⏳</span>
<span class="t t-re">${counts.rejected}✕</span>
</span>
</button>`;
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 = `
<button class="btn btn--small act-approve" ${pend.length ? '' : 'disabled'}>Aprobar pendientes</button>
<button class="btn btn--small act-reject" ${pend.length ? '' : 'disabled'}>Rechazar pendientes</button>`;
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 <b>${esc(worker.name)}</b> · ${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 <b>completos o bloqueados</b>.`
: 'Vista de ocupación de todo el equipo. Toca un día para <b>bloquearlo o desbloquearlo</b> 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 =
`<h3>${MONTHS[m]} ${cal.round.year}</h3>` +
`<div class="dow">${DOW.map((d) => `<span>${d}</span>`).join('')}</div>`;
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 = `<span class="n">${d}</span>`;
if (blockedSet.has(date)) btn.classList.add('is-blocked');
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. Tocar bloquea/desbloquea
// el día para todo el equipo.
btn.className = 'cday' + (blockedSet.has(date) ? ' cday--blocked' : '');
btn.innerHTML = `<span class="n">${d}</span>`;
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);
}
btn.addEventListener('click', () => toggleBlocked(date));
}
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'));
})();