9c9d2d25f9
- 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>
242 lines
8.2 KiB
JavaScript
242 lines
8.2 KiB
JavaScript
/* Página del trabajador: registro + calendario de la ronda con estados. */
|
|
(() => {
|
|
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 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 ----------
|
|
|
|
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')}`;
|
|
|
|
// 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() {
|
|
$('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 ----------
|
|
|
|
// 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');
|
|
|
|
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);
|
|
$('calendar-legend').classList.remove('hidden');
|
|
|
|
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();
|
|
for (let d = 1; d <= total; d++) {
|
|
const date = iso(round.year, m, d);
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.dataset.date = date;
|
|
btn.textContent = d;
|
|
styleDay(btn);
|
|
if (!btn.disabled) btn.addEventListener('click', () => toggle(btn));
|
|
days.appendChild(btn);
|
|
}
|
|
month.appendChild(days);
|
|
cal.appendChild(month);
|
|
}
|
|
updateBars();
|
|
}
|
|
|
|
function toggle(btn) {
|
|
const date = btn.dataset.date;
|
|
if (pending.has(date)) pending.delete(date);
|
|
else pending.add(date);
|
|
styleDay(btn);
|
|
updateBars();
|
|
}
|
|
|
|
function updateBars() {
|
|
const approved = [...myStatus.values()].filter((s) => s === 'approved').length;
|
|
$('me-count').textContent = approved + pending.size;
|
|
const dirty =
|
|
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 () => {
|
|
const btn = $('save-btn');
|
|
btn.disabled = true;
|
|
try {
|
|
const data = await fetchJSON(api('/requests'), {
|
|
method: 'PUT',
|
|
body: { dates: [...pending] },
|
|
});
|
|
setRequests(data.requests);
|
|
state.counts = data.counts;
|
|
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;
|
|
}
|
|
});
|
|
|
|
// ---------- carga inicial ----------
|
|
|
|
async function load() {
|
|
try {
|
|
state = await fetchJSON(api(''));
|
|
} catch {
|
|
$('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
|
|
? '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) {
|
|
setRequests(state.me.requests || []);
|
|
renderCalendar();
|
|
} else if (state.round.status !== 'open') {
|
|
$('closed-banner').classList.remove('hidden');
|
|
$('round-sub').textContent = 'Esta ronda ya no admite peticiones.';
|
|
} else {
|
|
renderJoin();
|
|
}
|
|
}
|
|
|
|
load();
|
|
})();
|