Límites por día/cargo, aprobación de días y calendario de admin

- Límite de solicitudes por día y cargo (modelo reserva: pendientes + aprobadas
  ocupan hueco, rechazar lo libera). Días completos, pasados o el de hoy no se
  pueden elegir; validado también en el servidor.
- Estados de aprobación en las peticiones (pendiente/aprobado/rechazado) visibles
  para el trabajador con color; solo puede editar pendientes o añadir nuevas.
- Calendario de admin con barra lateral por empleado: aprobar/rechazar por día y
  en bloque, añadir/quitar días, y editar los límites por cargo.
- Excel con días por estado y ocupación por día/cargo.
- Migraciones automáticas no destructivas (columnas rounds.limits y requests.status;
  las peticiones existentes pasan a "pendiente").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 12:15:31 +02:00
parent 15fc6dac3b
commit 9c9d2d25f9
7 changed files with 1076 additions and 130 deletions
+85 -57
View File
@@ -1,4 +1,4 @@
/* Página del trabajador: registro + calendario de la ronda. */
/* Página del trabajador: registro + calendario de la ronda con estados. */
(() => {
const token = location.pathname.split('/').pop();
const api = (p) => `/api/round/${token}${p}`;
@@ -7,9 +7,11 @@
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();
let state = null; // respuesta del servidor
let todayIso = ''; // hoy según el servidor (zona de España)
let myStatus = new Map(); // fecha → 'approved' | 'rejected' | 'pending' (guardado)
let savedPending = new Set();// pendientes ya guardadas en el servidor
let pending = new Set(); // pendientes en edición (con cambios sin guardar)
// ---------- utilidades ----------
@@ -36,6 +38,13 @@
const iso = (y, m, d) =>
`${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
// Reconstruye el estado local a partir de las solicitudes del servidor.
function setRequests(requests) {
myStatus = new Map(requests.map((r) => [r.date, r.status]));
savedPending = new Set(requests.filter((r) => r.status === 'pending').map((r) => r.date));
pending = new Set(savedPending);
}
// ---------- registro ----------
function renderJoin() {
@@ -65,6 +74,57 @@
// ---------- calendario ----------
// Calcula cómo se ve y se comporta un día concreto para este trabajador.
function dayMeta(date) {
const role = state.me.role;
const status = myStatus.get(date);
const inPending = pending.has(date);
const others = state.counts[date]?.[role] || 0;
const limit = state.limits[role];
const full = limit ? others >= limit : false;
const past = date <= todayIso;
const open = state.round.status === 'open';
let cls = 'day';
let disabled = !open;
let clickable = false;
if (status === 'approved') {
cls += ' approved';
disabled = true;
} else if (status === 'rejected') {
cls += ' rejected';
disabled = true;
} else if (inPending && savedPending.has(date)) {
cls += ' pending';
if (past) disabled = true; else clickable = !disabled;
} else if (inPending) {
cls += ' choosing';
clickable = !disabled;
} else {
// Día libre para este trabajador.
if (past) { cls += ' past'; disabled = true; }
else if (full) { cls += ' full'; disabled = true; }
else clickable = !disabled;
}
return { cls, disabled, clickable, others, full };
}
// Aplica el aspecto y el badge de compañeros a un botón de día.
function styleDay(btn) {
const date = btn.dataset.date;
const m = dayMeta(date);
btn.className = m.cls;
btn.disabled = m.disabled;
btn.querySelector('.count')?.remove();
if (m.others) {
const b = document.createElement('span');
b.className = 'count' + (m.full ? ' is-full' : '');
b.textContent = m.others;
btn.appendChild(b);
}
}
function renderCalendar() {
$('join-card').classList.add('hidden');
$('calendar-section').classList.remove('hidden');
@@ -75,9 +135,7 @@
$('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());
$('calendar-legend').classList.remove('hidden');
const cal = $('calendar');
cal.innerHTML = '';
@@ -96,69 +154,38 @@
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));
styleDay(btn);
if (!btn.disabled) btn.addEventListener('click', () => toggle(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);
}
});
function toggle(btn) {
const date = btn.dataset.date;
if (pending.has(date)) pending.delete(date);
else pending.add(date);
styleDay(btn);
updateBars();
}
function updateBars() {
$('me-count').textContent = selected.size;
const approved = [...myStatus.values()].filter((s) => s === 'approved').length;
$('me-count').textContent = approved + pending.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`;
pending.size !== savedPending.size || [...pending].some((d) => !savedPending.has(d));
$('savebar').classList.toggle('show', dirty && state.round.status === 'open');
const n = pending.size;
$('save-label').textContent = n === 1 ? '1 día pendiente' : `${n} días pendientes`;
}
$('save-btn').addEventListener('click', async () => {
@@ -167,15 +194,16 @@
try {
const data = await fetchJSON(api('/requests'), {
method: 'PUT',
body: { dates: [...selected] },
body: { dates: [...pending] },
});
savedDates = new Set(data.dates);
selected = new Set(data.dates);
setRequests(data.requests);
state.counts = data.counts;
paint();
renderCalendar();
toast('✓ Petición guardada');
} catch (ex) {
toast(ex.message, true);
// Si el servidor rechazó por días completos, recargamos para ver la realidad.
if (/complet/i.test(ex.message)) load();
} finally {
btn.disabled = false;
}
@@ -190,6 +218,7 @@
$('round-title').textContent = 'Ronda no encontrada';
return;
}
todayIso = state.today || new Date().toISOString().slice(0, 10);
$('round-title').innerHTML =
`${state.round.name} <em>${state.round.year}</em>`;
$('round-sub').textContent = state.me
@@ -198,8 +227,7 @@
document.title = `Vacaciones · ${state.round.name} ${state.round.year}`;
if (state.me) {
savedDates = new Set(state.me.dates);
selected = new Set(state.me.dates);
setRequests(state.me.requests || []);
renderCalendar();
} else if (state.round.status !== 'open') {
$('closed-banner').classList.remove('hidden');