Bloqueo de días, override del admin y edición del nombre de ronda

- Admin puede bloquear/desbloquear días (rounds.blocked) desde la vista de
  ocupación; los trabajadores no pueden elegirlos (🔒).
- El admin puede asignar cualquier día aunque supere el límite por día/cargo
  o esté bloqueado (override explícito en requests/set).
- Editar el nombre de la ronda con edición inline en el calendario
  (PUT /api/admin/rounds/:id/name).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 23:21:20 +02:00
parent 9c9d2d25f9
commit bc4e232a80
6 changed files with 177 additions and 8 deletions
+6
View File
@@ -71,8 +71,14 @@
<div class="cal-heading"> <div class="cal-heading">
<h2 id="cal-title">Calendario</h2> <h2 id="cal-title">Calendario</h2>
<span class="badge" id="cal-status"></span> <span class="badge" id="cal-status"></span>
<button class="btn btn--ghost btn--small" id="cal-name-edit" title="Editar nombre de la ronda">✎ Nombre</button>
</div> </div>
</div> </div>
<div id="cal-name-editor" class="cal-name-editor hidden">
<input id="cal-name-input" type="text" maxlength="80" placeholder="Nombre del local" />
<button class="btn btn--small" id="cal-name-save">Guardar</button>
<button class="btn btn--ghost btn--small" id="cal-name-cancel">Cancelar</button>
</div>
<section class="card cal-limits"> <section class="card cal-limits">
<h3>Límite de solicitudes por día y cargo</h3> <h3>Límite de solicitudes por día y cargo</h3>
+47
View File
@@ -309,6 +309,7 @@ input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(194, 86, 4
.legend .sw--pending { background: var(--pending); border-color: #c98e1d; } .legend .sw--pending { background: var(--pending); border-color: #c98e1d; }
.legend .sw--approved { background: var(--approved); border-color: var(--approved); } .legend .sw--approved { background: var(--approved); border-color: var(--approved); }
.legend .sw--rejected { background: #f6e3df; border-color: #e2b7af; } .legend .sw--rejected { background: #f6e3df; border-color: #e2b7af; }
.legend .sw--blocked { background: #d3c6a8; border-color: #bdae8c; }
/* ---------- calendario ---------- */ /* ---------- calendario ---------- */
@@ -412,6 +413,22 @@ input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(194, 86, 4
color: #a99b7e; color: #a99b7e;
cursor: default; cursor: default;
} }
/* día bloqueado por el admin: no elegible por nadie */
.day.blocked {
background: repeating-linear-gradient(
-45deg, #e6dccb, #e6dccb 4px, #d3c6a8 4px, #d3c6a8 8px
);
color: #8f836a;
cursor: default;
}
.day.blocked::after {
content: '🔒';
position: absolute;
top: 2px;
left: 3px;
font-size: 9px;
line-height: 1;
}
.day .count.is-full { background: var(--rejected); } .day .count.is-full { background: var(--rejected); }
.day .count { .day .count {
@@ -777,6 +794,36 @@ body.cal-open .page--wide { max-width: 1080px; }
} }
.cday .rchip.is-full { background: var(--rejected); color: #fff; } .cday .rchip.is-full { background: var(--rejected); color: #fff; }
/* días bloqueados por el admin (no elegibles por nadie) */
.cday--blocked {
background: repeating-linear-gradient(
-45deg, #efe6d2, #efe6d2 4px, #e6dabf 4px, #e6dabf 8px
);
color: #a99b7e;
border-color: #cdbf9d;
}
.cday--blocked::after,
.cday.is-blocked::after {
content: '🔒';
position: absolute;
top: 1px;
left: 3px;
font-size: 9px;
line-height: 1;
}
/* editor del nombre de la ronda */
.cal-name-editor {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin: 6px 0 4px;
}
.cal-name-editor input { width: min(320px, 100%); margin: 0; }
.cal-name-editor .btn { margin-top: 0; }
#cal-name-edit { margin-top: 0; }
/* barra de acción del admin (aprobar / rechazar / pendiente / quitar) */ /* barra de acción del admin (aprobar / rechazar / pendiente / quitar) */
.actionbar { .actionbar {
position: fixed; position: fixed;
+65 -5
View File
@@ -218,6 +218,7 @@
$('cal-status').textContent = open ? 'Abierta' : 'Cerrada'; $('cal-status').textContent = open ? 'Abierta' : 'Cerrada';
$('cal-status').className = `badge ${open ? 'open' : 'closed'}`; $('cal-status').className = `badge ${open ? 'open' : 'closed'}`;
showPanelView('calendar'); showPanelView('calendar');
closeNameEditor();
renderLimitsEditor(); renderLimitsEditor();
rerender(); rerender();
window.scrollTo(0, 0); window.scrollTo(0, 0);
@@ -225,6 +226,42 @@
$('cal-back').addEventListener('click', () => loadRounds()); $('cal-back').addEventListener('click', () => loadRounds());
// ----- editar el nombre de la ronda -----
function closeNameEditor() {
$('cal-name-editor').classList.add('hidden');
$('cal-name-edit').classList.remove('hidden');
}
$('cal-name-edit').addEventListener('click', () => {
$('cal-name-input').value = cal.round.name;
$('cal-name-editor').classList.remove('hidden');
$('cal-name-edit').classList.add('hidden');
$('cal-name-input').focus();
$('cal-name-input').select();
});
$('cal-name-cancel').addEventListener('click', closeNameEditor);
$('cal-name-save').addEventListener('click', async () => {
const name = $('cal-name-input').value.trim();
if (!name) { toast('Escribe un nombre', true); return; }
try {
const data = await fetchJSON(`/api/admin/rounds/${cal.round.id}/name`, { method: 'PUT', body: { name } });
cal.round.name = data.name;
$('cal-title').textContent = `${data.name} ${cal.round.year}`;
toast('✓ Nombre actualizado');
closeNameEditor();
} catch (ex) {
toast(ex.message, true);
}
});
$('cal-name-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); $('cal-name-save').click(); }
if (e.key === 'Escape') { e.preventDefault(); closeNameEditor(); }
});
// ----- editor de límites ----- // ----- editor de límites -----
function renderLimitsEditor() { function renderLimitsEditor() {
@@ -256,6 +293,26 @@
} }
}); });
// ----- bloquear / desbloquear días -----
// Alterna el bloqueo de un día para toda la ronda (envía la lista completa).
async function toggleBlocked(date) {
const current = new Set(cal.blocked || []);
const willBlock = !current.has(date);
if (willBlock) current.add(date); else current.delete(date);
try {
const data = await fetchJSON(`/api/admin/rounds/${cal.round.id}/blocked`, {
method: 'PUT',
body: { dates: [...current] },
});
cal.blocked = data.blocked;
toast(willBlock ? '🔒 Día bloqueado para todo el equipo' : 'Día desbloqueado');
rerender();
} catch (ex) {
toast(ex.message, true);
}
}
// ----- datos derivados ----- // ----- datos derivados -----
// Ocupación por fecha y cargo: {date: {role: {approved, pending, rejected}}}. // Ocupación por fecha y cargo: {date: {role: {approved, pending, rejected}}}.
@@ -345,14 +402,15 @@
function renderGrid() { function renderGrid() {
const occ = occupancy(); const occ = occupancy();
const blockedSet = new Set(cal.blocked || []);
const worker = workerById(selectedWorkerId); const worker = workerById(selectedWorkerId);
const myReq = worker ? new Map(worker.requests.map((r) => [r.date, r.status])) : null; const myReq = worker ? new Map(worker.requests.map((r) => [r.date, r.status])) : null;
const hint = document.createElement('p'); const hint = document.createElement('p');
hint.className = 'hint cal-mode-hint'; hint.className = 'hint cal-mode-hint';
hint.innerHTML = worker 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.` ? `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. Puedes asignar días aunque estén <b>completos o bloqueados</b>.`
: 'Vista de ocupación de todo el equipo. Elige un empleado en la izquierda para aprobar o editar sus días.'; : 'Vista de ocupación de todo el equipo. Toca un día para <b>bloquearlo o desbloquearlo</b> para todo el equipo (🔒 no elegible por nadie). Elige un empleado en la izquierda para aprobar o editar sus días.';
const grid = $('cal-grid'); const grid = $('cal-grid');
grid.innerHTML = ''; grid.innerHTML = '';
@@ -388,6 +446,7 @@
if (date === focusedDate) cls += ' focused'; if (date === focusedDate) cls += ' focused';
btn.className = cls; btn.className = cls;
btn.innerHTML = `<span class="n">${d}</span>`; btn.innerHTML = `<span class="n">${d}</span>`;
if (blockedSet.has(date)) btn.classList.add('is-blocked');
const others = held(occ[date]?.[worker.role]) - (status && status !== 'rejected' ? 1 : 0); const others = held(occ[date]?.[worker.role]) - (status && status !== 'rejected' ? 1 : 0);
const lim = cal.limits[worker.role]; const lim = cal.limits[worker.role];
if (others > 0) { if (others > 0) {
@@ -398,9 +457,9 @@
} }
btn.addEventListener('click', () => focusDay(date)); btn.addEventListener('click', () => focusDay(date));
} else { } else {
// Modo ocupación: chips por cargo con su carga. // Modo ocupación: chips por cargo con su carga. Tocar bloquea/desbloquea
btn.className = 'cday cday--info'; // el día para todo el equipo.
btn.disabled = true; btn.className = 'cday' + (blockedSet.has(date) ? ' cday--blocked' : '');
btn.innerHTML = `<span class="n">${d}</span>`; btn.innerHTML = `<span class="n">${d}</span>`;
const cell = occ[date]; const cell = occ[date];
if (cell) { if (cell) {
@@ -418,6 +477,7 @@
} }
if (chips.children.length) btn.appendChild(chips); if (chips.children.length) btn.appendChild(chips);
} }
btn.addEventListener('click', () => toggleBlocked(date));
} }
days.appendChild(btn); days.appendChild(btn);
} }
+6 -2
View File
@@ -9,6 +9,7 @@
let state = null; // respuesta del servidor let state = null; // respuesta del servidor
let todayIso = ''; // hoy según el servidor (zona de España) let todayIso = ''; // hoy según el servidor (zona de España)
let blockedSet = new Set(); // días bloqueados por el admin (no elegibles)
let myStatus = new Map(); // fecha → 'approved' | 'rejected' | 'pending' (guardado) let myStatus = new Map(); // fecha → 'approved' | 'rejected' | 'pending' (guardado)
let savedPending = new Set();// pendientes ya guardadas en el servidor let savedPending = new Set();// pendientes ya guardadas en el servidor
let pending = new Set(); // pendientes en edición (con cambios sin guardar) let pending = new Set(); // pendientes en edición (con cambios sin guardar)
@@ -82,6 +83,7 @@
const others = state.counts[date]?.[role] || 0; const others = state.counts[date]?.[role] || 0;
const limit = state.limits[role]; const limit = state.limits[role];
const full = limit ? others >= limit : false; const full = limit ? others >= limit : false;
const blocked = blockedSet.has(date);
const past = date <= todayIso; const past = date <= todayIso;
const open = state.round.status === 'open'; const open = state.round.status === 'open';
@@ -104,6 +106,7 @@
} else { } else {
// Día libre para este trabajador. // Día libre para este trabajador.
if (past) { cls += ' past'; disabled = true; } if (past) { cls += ' past'; disabled = true; }
else if (blocked) { cls += ' blocked'; disabled = true; }
else if (full) { cls += ' full'; disabled = true; } else if (full) { cls += ' full'; disabled = true; }
else clickable = !disabled; else clickable = !disabled;
} }
@@ -202,8 +205,8 @@
toast('✓ Petición guardada'); toast('✓ Petición guardada');
} catch (ex) { } catch (ex) {
toast(ex.message, true); toast(ex.message, true);
// Si el servidor rechazó por días completos, recargamos para ver la realidad. // Si el servidor rechazó por días completos o bloqueados, recargamos para ver la realidad.
if (/complet/i.test(ex.message)) load(); if (/complet|bloquead/i.test(ex.message)) load();
} finally { } finally {
btn.disabled = false; btn.disabled = false;
} }
@@ -219,6 +222,7 @@
return; return;
} }
todayIso = state.today || new Date().toISOString().slice(0, 10); todayIso = state.today || new Date().toISOString().slice(0, 10);
blockedSet = new Set(state.blocked || []);
$('round-title').innerHTML = $('round-title').innerHTML =
`${state.round.name} <em>${state.round.year}</em>`; `${state.round.name} <em>${state.round.year}</em>`;
$('round-sub').textContent = state.me $('round-sub').textContent = state.me
+2 -1
View File
@@ -49,13 +49,14 @@
<p class="hint" id="calendar-hint"> <p class="hint" id="calendar-hint">
Toca los días que quieres pedir. El número <span class="chip">2</span> indica Toca los días que quieres pedir. El número <span class="chip">2</span> indica
cuántas personas de tu mismo cargo ya han pedido ese día. Los días completos cuántas personas de tu mismo cargo ya han pedido ese día. Los días completos
para tu cargo, pasados o de hoy no se pueden elegir. para tu cargo, bloqueados (🔒), pasados o de hoy no se pueden elegir.
</p> </p>
<div class="legend hidden" id="calendar-legend"> <div class="legend hidden" id="calendar-legend">
<span class="legend-item"><i class="sw sw--choosing"></i>Eligiendo</span> <span class="legend-item"><i class="sw sw--choosing"></i>Eligiendo</span>
<span class="legend-item"><i class="sw sw--pending"></i>Pendiente</span> <span class="legend-item"><i class="sw sw--pending"></i>Pendiente</span>
<span class="legend-item"><i class="sw sw--approved"></i>Aprobado</span> <span class="legend-item"><i class="sw sw--approved"></i>Aprobado</span>
<span class="legend-item"><i class="sw sw--rejected"></i>Rechazado</span> <span class="legend-item"><i class="sw sw--rejected"></i>Rechazado</span>
<span class="legend-item"><i class="sw sw--blocked"></i>Bloqueado</span>
</div> </div>
<div id="calendar"></div> <div id="calendar"></div>
<footer class="colophon">Buen servicio · Buen descanso</footer> <footer class="colophon">Buen servicio · Buen descanso</footer>
+51
View File
@@ -56,6 +56,8 @@ const hasColumn = (table, col) =>
if (!hasColumn('rounds', 'roles')) db.exec('ALTER TABLE rounds ADD COLUMN roles TEXT'); if (!hasColumn('rounds', 'roles')) db.exec('ALTER TABLE rounds ADD COLUMN roles TEXT');
// Límite de solicitudes por día y cargo (JSON {clave: máximo}). // Límite de solicitudes por día y cargo (JSON {clave: máximo}).
if (!hasColumn('rounds', 'limits')) db.exec('ALTER TABLE rounds ADD COLUMN limits TEXT'); if (!hasColumn('rounds', 'limits')) db.exec('ALTER TABLE rounds ADD COLUMN limits TEXT');
// Días bloqueados por el admin para toda la ronda (JSON array de fechas).
if (!hasColumn('rounds', 'blocked')) db.exec('ALTER TABLE rounds ADD COLUMN blocked TEXT');
// Estado de aprobación de cada solicitud: pending | approved | rejected. // Estado de aprobación de cada solicitud: pending | approved | rejected.
if (!hasColumn('requests', 'status')) { if (!hasColumn('requests', 'status')) {
db.exec("ALTER TABLE requests ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'"); db.exec("ALTER TABLE requests ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'");
@@ -107,6 +109,19 @@ function roundLimits(round) {
return out; return out;
} }
// Días bloqueados por el admin (no elegibles por ningún trabajador). Devuelve
// una lista de fechas válidas del año de la ronda, sin duplicados y ordenadas.
function roundBlocked(round) {
if (!round.blocked) return [];
try {
const parsed = JSON.parse(round.blocked);
if (Array.isArray(parsed)) {
return [...new Set(parsed.map(String).filter((d) => isValidDate(d, round.year)))].sort();
}
} catch { /* sin bloqueos */ }
return [];
}
// Convierte una etiqueta ("Camarero/a") en una clave estable ("camarero_a"). // Convierte una etiqueta ("Camarero/a") en una clave estable ("camarero_a").
const slugify = (s) => const slugify = (s) =>
String(s) String(s)
@@ -248,6 +263,7 @@ app.get('/api/round/:token', (req, res) => {
round: { name: round.name, year: round.year, status: round.status }, round: { name: round.name, year: round.year, status: round.status },
roles: roundRoles(round), roles: roundRoles(round),
limits: roundLimits(round), limits: roundLimits(round),
blocked: roundBlocked(round),
today: todayISO(), today: todayISO(),
me: worker me: worker
? { name: worker.name, role: worker.role, requests: workerRequests(worker.id) } ? { name: worker.name, role: worker.role, requests: workerRequests(worker.id) }
@@ -309,6 +325,14 @@ app.put('/api/round/:token/requests', (req, res) => {
return res.status(400).json({ error: 'No puedes elegir días pasados ni el día de hoy.' }); return res.status(400).json({ error: 'No puedes elegir días pasados ni el día de hoy.' });
} }
// Los días nuevos no pueden estar bloqueados por la administración.
const blocked = new Set(roundBlocked(round));
if (added.some((d) => blocked.has(d))) {
return res.status(409).json({
error: 'Algún día que elegiste está bloqueado por la administración. Recarga la página.',
});
}
// Los días nuevos no pueden superar el límite de tu cargo (cuentan las // Los días nuevos no pueden superar el límite de tu cargo (cuentan las
// solicitudes de los demás que no estén rechazadas). // solicitudes de los demás que no estén rechazadas).
const limit = roundLimits(round)[worker.role]; const limit = roundLimits(round)[worker.role];
@@ -422,6 +446,30 @@ app.put('/api/admin/rounds/:id/limits', requireAdmin, (req, res) => {
res.json({ ok: true, limits }); res.json({ ok: true, limits });
}); });
// Editar el nombre de una ronda (igual que se editan los límites).
app.put('/api/admin/rounds/:id/name', requireAdmin, (req, res) => {
const name = String(req.body.name ?? '').trim();
if (!name || name.length > 80) return res.status(400).json({ error: 'Indica el nombre del local' });
const info = db.prepare('UPDATE rounds SET name = ? WHERE id = ?').run(name, req.params.id);
if (!info.changes) return res.status(404).json({ error: 'Ronda no encontrada' });
res.json({ ok: true, name });
});
// Bloquear / desbloquear días de una ronda. Los días bloqueados no son elegibles
// por ningún trabajador, pero el admin sí puede asignarlos manualmente. Recibe la
// lista completa de días bloqueados y la guarda (reemplazo total).
app.put('/api/admin/rounds/:id/blocked', requireAdmin, (req, res) => {
const round = db.prepare('SELECT * FROM rounds WHERE id = ?').get(req.params.id);
if (!round) return res.status(404).json({ error: 'Ronda no encontrada' });
const dates = req.body.dates;
if (!Array.isArray(dates) || dates.length > 366) {
return res.status(400).json({ error: 'Días no válidos' });
}
const blocked = [...new Set(dates.map(String))].filter((d) => isValidDate(d, round.year)).sort();
db.prepare('UPDATE rounds SET blocked = ? WHERE id = ?').run(JSON.stringify(blocked), round.id);
res.json({ ok: true, blocked });
});
// Vista de calendario del administrador: empleados con sus solicitudes (y estado), // Vista de calendario del administrador: empleados con sus solicitudes (y estado),
// más los cargos y límites de la ronda. // más los cargos y límites de la ronda.
app.get('/api/admin/rounds/:id/calendar', requireAdmin, (req, res) => { app.get('/api/admin/rounds/:id/calendar', requireAdmin, (req, res) => {
@@ -435,12 +483,15 @@ app.get('/api/admin/rounds/:id/calendar', requireAdmin, (req, res) => {
round: { id: round.id, name: round.name, year: round.year, status: round.status }, round: { id: round.id, name: round.name, year: round.year, status: round.status },
roles: roundRoles(round), roles: roundRoles(round),
limits: roundLimits(round), limits: roundLimits(round),
blocked: roundBlocked(round),
today: todayISO(), today: todayISO(),
workers, workers,
}); });
}); });
// Aprobar / rechazar / volver a pendiente / añadir / quitar días de un empleado. // Aprobar / rechazar / volver a pendiente / añadir / quitar días de un empleado.
// El admin manda: no se aplica el límite por día/cargo ni el bloqueo de días,
// así que puede asignar cualquier día aunque esté completo o bloqueado.
app.post('/api/admin/rounds/:id/requests/set', requireAdmin, (req, res) => { app.post('/api/admin/rounds/:id/requests/set', requireAdmin, (req, res) => {
const round = db.prepare('SELECT * FROM rounds WHERE id = ?').get(req.params.id); const round = db.prepare('SELECT * FROM rounds WHERE id = ?').get(req.params.id);
if (!round) return res.status(404).json({ error: 'Ronda no encontrada' }); if (!round) return res.status(404).json({ error: 'Ronda no encontrada' });