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:
+85
-57
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user