Files
vacaciones/public/js/round.js
T
juavillo 15fc6dac3b Vacaciones: app de peticiones de vacaciones para restaurante
Cargos configurables por ronda (por defecto Camarero/a, Encargado, Cocina),
calendario con días recuadrados, recuento de compañeros del mismo cargo por
día y minileyenda por mes. Panel de administración con login, rondas y
exportación a Excel. Node + Express + SQLite, listo para Docker/Coolify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:45:36 +02:00

214 lines
7.0 KiB
JavaScript

/* Página del trabajador: registro + calendario de la ronda. */
(() => {
const token = location.pathname.split('/').pop();
const api = (p) => `/api/round/${token}${p}`;
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'];
let state = null; // respuesta del servidor
let selected = new Set(); // fechas elegidas (con cambios sin guardar)
let savedDates = new Set();
// ---------- utilidades ----------
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 && {
...opts,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(opts.body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Error de conexión');
return data;
};
const iso = (y, m, d) =>
`${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
// ---------- registro ----------
function renderJoin() {
$('join-card').classList.remove('hidden');
const grid = $('role-grid');
grid.innerHTML = '';
for (const [value, label] of Object.entries(state.roles)) {
const opt = document.createElement('label');
opt.className = 'role-option';
opt.innerHTML = `<input type="radio" name="role" value="${value}" required /><span class="dot"></span>${label}`;
grid.appendChild(opt);
}
$('join-form').addEventListener('submit', async (e) => {
e.preventDefault();
const err = $('join-error');
err.classList.remove('show');
try {
const role = document.querySelector('input[name=role]:checked')?.value;
await fetchJSON(api('/join'), { method: 'POST', body: { name: $('name').value, role } });
await load();
} catch (ex) {
err.textContent = ex.message;
err.classList.add('show');
}
});
}
// ---------- calendario ----------
function renderCalendar() {
$('join-card').classList.add('hidden');
$('calendar-section').classList.remove('hidden');
const { me, round } = state;
const open = round.status === 'open';
$('me-name').textContent = me.name;
$('me-role').textContent = state.roles[me.role] || me.role;
$('closed-banner').classList.toggle('hidden', open);
$('calendar-hint').classList.toggle('hidden', !open);
const today = new Date();
const todayIso = iso(today.getFullYear(), today.getMonth(), today.getDate());
const cal = $('calendar');
cal.innerHTML = '';
for (let m = 0; m < 12; m++) {
const month = document.createElement('section');
month.className = 'month';
month.style.animationDelay = `${m * 40}ms`;
month.innerHTML =
`<h3>${MONTHS[m]} ${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(round.year, m, 1);
const blanks = (first.getDay() + 6) % 7; // semana empieza en lunes
for (let i = 0; i < blanks; i++) days.appendChild(document.createElement('span'));
const total = new Date(round.year, m + 1, 0).getDate();
let monthHasCounts = false;
for (let d = 1; d <= total; d++) {
const date = iso(round.year, m, d);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'day';
btn.dataset.date = date;
btn.textContent = d;
if (date < todayIso) btn.classList.add('past');
if (state.counts[date]?.[me.role]) monthHasCounts = true;
if (!open) btn.disabled = true;
else btn.addEventListener('click', () => toggle(date, btn));
days.appendChild(btn);
}
month.appendChild(days);
// Minileyenda: solo si este mes tiene días ya pedidos por compañeros de tu cargo.
if (monthHasCounts) {
const legend = document.createElement('p');
legend.className = 'month-legend';
legend.innerHTML =
'<span class="count-sample">N</span> compañeros de tu mismo cargo que ya han pedido ese día';
month.appendChild(legend);
}
cal.appendChild(month);
}
paint();
}
function toggle(date, btn) {
if (selected.has(date)) selected.delete(date);
else selected.add(date);
btn.classList.toggle('selected', selected.has(date));
updateBars();
}
// Pinta selección y contadores de compañeros del mismo cargo.
function paint() {
const role = state.me.role;
document.querySelectorAll('.day').forEach((btn) => {
const date = btn.dataset.date;
btn.classList.toggle('selected', selected.has(date));
btn.querySelector('.count')?.remove();
const n = state.counts[date]?.[role];
if (n) {
const b = document.createElement('span');
b.className = 'count';
b.textContent = n;
btn.appendChild(b);
}
});
updateBars();
}
function updateBars() {
$('me-count').textContent = selected.size;
const dirty =
selected.size !== savedDates.size || [...selected].some((d) => !savedDates.has(d));
const bar = $('savebar');
bar.classList.toggle('show', dirty && state.round.status === 'open');
$('save-label').textContent =
selected.size === 1 ? '1 día elegido' : `${selected.size} días elegidos`;
}
$('save-btn').addEventListener('click', async () => {
const btn = $('save-btn');
btn.disabled = true;
try {
const data = await fetchJSON(api('/requests'), {
method: 'PUT',
body: { dates: [...selected] },
});
savedDates = new Set(data.dates);
selected = new Set(data.dates);
state.counts = data.counts;
paint();
toast('✓ Petición guardada');
} catch (ex) {
toast(ex.message, true);
} finally {
btn.disabled = false;
}
});
// ---------- carga inicial ----------
async function load() {
try {
state = await fetchJSON(api(''));
} catch {
$('round-title').textContent = 'Ronda no encontrada';
return;
}
$('round-title').innerHTML =
`${state.round.name} <em>${state.round.year}</em>`;
$('round-sub').textContent = state.me
? 'Marca en el calendario los días que quieres pedir'
: 'Pide tus días de vacaciones en un minuto';
document.title = `Vacaciones · ${state.round.name} ${state.round.year}`;
if (state.me) {
savedDates = new Set(state.me.dates);
selected = new Set(state.me.dates);
renderCalendar();
} else if (state.round.status !== 'open') {
$('closed-banner').classList.remove('hidden');
$('round-sub').textContent = 'Esta ronda ya no admite peticiones.';
} else {
renderJoin();
}
}
load();
})();