15fc6dac3b
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>
146 lines
5.0 KiB
JavaScript
146 lines
5.0 KiB
JavaScript
/* 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'));
|
|
})();
|