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:
+357
-8
@@ -1,6 +1,8 @@
|
||||
/* Panel de administración: login, rondas, Excel. */
|
||||
/* 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');
|
||||
@@ -22,11 +24,22 @@
|
||||
return data;
|
||||
};
|
||||
|
||||
const esc = (s) =>
|
||||
String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) => {
|
||||
@@ -49,16 +62,42 @@
|
||||
show('login');
|
||||
});
|
||||
|
||||
// ---------- rondas ----------
|
||||
// ---------- 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 = $('round-roles').value.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const roles = readRoles();
|
||||
const { round } = await fetchJSON('/api/admin/rounds', {
|
||||
method: 'POST',
|
||||
body: { name: $('round-name').value, year: Number($('round-year').value), roles },
|
||||
@@ -73,6 +112,8 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- lista de rondas ----------
|
||||
|
||||
const roundUrl = (token) => `${location.origin}/r/${token}`;
|
||||
|
||||
async function copyUrl(token, notify = true) {
|
||||
@@ -93,6 +134,7 @@
|
||||
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>';
|
||||
@@ -100,22 +142,28 @@
|
||||
}
|
||||
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</div>
|
||||
<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--accent' : ''}" data-act="status">
|
||||
${open ? 'Cerrar ronda' : 'Reabrir'}
|
||||
<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;
|
||||
@@ -130,12 +178,313 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const esc = (s) =>
|
||||
s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
// ===================================================================
|
||||
// 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');
|
||||
renderLimitsEditor();
|
||||
rerender();
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
$('cal-back').addEventListener('click', () => loadRounds());
|
||||
|
||||
// ----- 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);
|
||||
}
|
||||
});
|
||||
|
||||
// ----- 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 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.`
|
||||
: 'Vista de ocupación de todo el equipo. 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>`;
|
||||
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.
|
||||
btn.className = 'cday cday--info';
|
||||
btn.disabled = true;
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 ----------
|
||||
|
||||
|
||||
+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