Vacaciones: app de peticiones de vacaciones para restaurante
Cargos configurables por ronda (por defecto Camarero/a, Encargado, Cocina), calendario con días recuadrados, recuento de compañeros del mismo cargo por día y minileyenda por mes. Panel de administración con login, rondas y exportación a Excel. Node + Express + SQLite, listo para Docker/Coolify. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
/* Panel de administración: login, rondas, Excel. */
|
||||
(() => {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
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 && {
|
||||
method: opts.method,
|
||||
headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Error de conexión');
|
||||
return data;
|
||||
};
|
||||
|
||||
function show(view) {
|
||||
$('login-card').classList.toggle('hidden', view !== 'login');
|
||||
$('panel').classList.toggle('hidden', view !== 'panel');
|
||||
}
|
||||
|
||||
// ---------- login ----------
|
||||
|
||||
$('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const err = $('login-error');
|
||||
err.classList.remove('show');
|
||||
try {
|
||||
await fetchJSON('/api/admin/login', { method: 'POST', body: { password: $('password').value } });
|
||||
$('password').value = '';
|
||||
show('panel');
|
||||
loadRounds();
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.classList.add('show');
|
||||
}
|
||||
});
|
||||
|
||||
$('logout-btn').addEventListener('click', async () => {
|
||||
await fetchJSON('/api/admin/logout', { method: 'POST' }).catch(() => {});
|
||||
show('login');
|
||||
});
|
||||
|
||||
// ---------- rondas ----------
|
||||
|
||||
$('round-year').value = new Date().getFullYear() + 1;
|
||||
|
||||
$('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 { round } = await fetchJSON('/api/admin/rounds', {
|
||||
method: 'POST',
|
||||
body: { name: $('round-name').value, year: Number($('round-year').value), roles },
|
||||
});
|
||||
$('round-name').value = '';
|
||||
await copyUrl(round.token, false);
|
||||
toast('✓ Ronda creada y URL copiada');
|
||||
loadRounds();
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.classList.add('show');
|
||||
}
|
||||
});
|
||||
|
||||
const roundUrl = (token) => `${location.origin}/r/${token}`;
|
||||
|
||||
async function copyUrl(token, notify = true) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(roundUrl(token));
|
||||
if (notify) toast('✓ URL copiada al portapapeles');
|
||||
} catch {
|
||||
if (notify) toast('No se pudo copiar; mantén pulsado el enlace', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRounds() {
|
||||
const list = $('rounds-list');
|
||||
let rounds;
|
||||
try {
|
||||
({ rounds } = await fetchJSON('/api/admin/rounds'));
|
||||
} catch {
|
||||
show('login');
|
||||
return;
|
||||
}
|
||||
list.innerHTML = '';
|
||||
if (!rounds.length) {
|
||||
list.innerHTML = '<p class="empty">Todavía no hay rondas. Crea la primera arriba.</p>';
|
||||
return;
|
||||
}
|
||||
for (const r of rounds) {
|
||||
const open = r.status === 'open';
|
||||
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>
|
||||
<a class="url" href="/r/${r.token}" target="_blank" rel="noopener">${roundUrl(r.token)}</a>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<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>
|
||||
</div>`;
|
||||
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;
|
||||
try {
|
||||
await fetchJSON(`/api/admin/rounds/${r.id}/status`, {
|
||||
method: 'POST',
|
||||
body: { status: open ? 'closed' : 'open' },
|
||||
});
|
||||
toast(open ? 'Ronda cerrada' : 'Ronda reabierta');
|
||||
loadRounds();
|
||||
} catch (ex) {
|
||||
toast(ex.message, true);
|
||||
}
|
||||
});
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
const esc = (s) =>
|
||||
s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
// ---------- arranque ----------
|
||||
|
||||
fetchJSON('/api/admin/me')
|
||||
.then(() => { show('panel'); loadRounds(); })
|
||||
.catch(() => show('login'));
|
||||
})();
|
||||
@@ -0,0 +1,213 @@
|
||||
/* Página del trabajador: registro + calendario de la ronda. */
|
||||
(() => {
|
||||
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 selected = new Set(); // fechas elegidas (con cambios sin guardar)
|
||||
let savedDates = new Set();
|
||||
|
||||
// ---------- 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')}`;
|
||||
|
||||
// ---------- 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 ----------
|
||||
|
||||
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);
|
||||
|
||||
const today = new Date();
|
||||
const todayIso = iso(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
|
||||
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();
|
||||
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));
|
||||
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);
|
||||
}
|
||||
});
|
||||
updateBars();
|
||||
}
|
||||
|
||||
function updateBars() {
|
||||
$('me-count').textContent = selected.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`;
|
||||
}
|
||||
|
||||
$('save-btn').addEventListener('click', async () => {
|
||||
const btn = $('save-btn');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const data = await fetchJSON(api('/requests'), {
|
||||
method: 'PUT',
|
||||
body: { dates: [...selected] },
|
||||
});
|
||||
savedDates = new Set(data.dates);
|
||||
selected = new Set(data.dates);
|
||||
state.counts = data.counts;
|
||||
paint();
|
||||
toast('✓ Petición guardada');
|
||||
} catch (ex) {
|
||||
toast(ex.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- carga inicial ----------
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
state = await fetchJSON(api(''));
|
||||
} catch {
|
||||
$('round-title').textContent = 'Ronda no encontrada';
|
||||
return;
|
||||
}
|
||||
$('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) {
|
||||
savedDates = new Set(state.me.dates);
|
||||
selected = new Set(state.me.dates);
|
||||
renderCalendar();
|
||||
} else if (state.round.status !== 'open') {
|
||||
$('closed-banner').classList.remove('hidden');
|
||||
$('round-sub').textContent = 'Esta ronda ya no admite peticiones.';
|
||||
} else {
|
||||
renderJoin();
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
})();
|
||||
Reference in New Issue
Block a user