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:
@@ -8,13 +8,16 @@ Aplicación web sencilla y pensada para el móvil con la que el equipo de un res
|
|||||||
1. Reciben una URL del tipo `https://tudominio.com/r/AbC123xyz`.
|
1. Reciben una URL del tipo `https://tudominio.com/r/AbC123xyz`.
|
||||||
2. Escriben su nombre y eligen su cargo (los que el administrador haya definido al crear la ronda; por defecto Camarero/a, Encargado o Cocina).
|
2. Escriben su nombre y eligen su cargo (los que el administrador haya definido al crear la ronda; por defecto Camarero/a, Encargado o Cocina).
|
||||||
3. Marcan en el calendario los días que quieren pedir y guardan.
|
3. Marcan en el calendario los días que quieren pedir y guardan.
|
||||||
4. En cada día ven cuántas personas **de su mismo cargo** ya lo han pedido (nunca quiénes). Cada mes muestra una pequeña leyenda cuando tiene días ya pedidos por compañeros.
|
4. En cada día ven cuántas personas **de su mismo cargo** ya lo han pedido (nunca quiénes). Si un día ya ha alcanzado el límite de su cargo, o es pasado o es hoy, no se puede elegir.
|
||||||
5. Una cookie les identifica: si vuelven a entrar con el mismo navegador, ven y pueden editar su petición mientras la ronda siga abierta.
|
5. Cada solicitud tiene un estado, visible con un color: **gris** = eligiendo (sin guardar), **amarillo** = pendiente de aprobar, **verde** = aprobada, **rojo** = rechazada. Solo se pueden cambiar las pendientes o añadir nuevas; las aprobadas y rechazadas quedan bloqueadas.
|
||||||
|
6. Una cookie les identifica: si vuelven a entrar con el mismo navegador, ven el estado de todos sus días (aprobados, rechazados y pendientes) y pueden seguir editando los pendientes mientras la ronda siga abierta.
|
||||||
|
|
||||||
**Administrador** (`/admin`, protegido por contraseña)
|
**Administrador** (`/admin`, protegido por contraseña)
|
||||||
- Crea rondas de peticiones por local y año; en cada ronda define los cargos disponibles (separados por comas) y se genera una URL nueva para compartir.
|
- Crea rondas de peticiones por local y año; en cada ronda define los cargos y, opcionalmente, un **límite de solicitudes por día y cargo** (p. ej. Camarero/a: 2, Cocina: 1). Se genera una URL nueva para compartir.
|
||||||
|
- **Calendario de aprobación** por ronda: una barra lateral lista a cada empleado con su situación (aprobados / pendientes / rechazados) y el calendario muestra la ocupación del equipo. Desde ahí aprueba o rechaza cada día (uno a uno o en bloque por empleado) y puede añadir o quitar días directamente.
|
||||||
|
- **Edita los límites** por cargo en cualquier momento para ampliarlos o reducirlos (afecta a las nuevas solicitudes; las reservas ocupan hueco mientras estén pendientes o aprobadas, y rechazar libera el hueco).
|
||||||
- Cierra una ronda (nadie puede pedir ni modificar) y puede reabrirla.
|
- Cierra una ronda (nadie puede pedir ni modificar) y puede reabrirla.
|
||||||
- Descarga un Excel con dos hojas: peticiones por persona y recuento por día y cargo.
|
- Descarga un Excel con dos hojas: días por persona separados por estado, y ocupación por día y cargo.
|
||||||
|
|
||||||
## Ejecutar en local
|
## Ejecutar en local
|
||||||
|
|
||||||
|
|||||||
+69
-23
@@ -31,37 +31,83 @@
|
|||||||
|
|
||||||
<!-- Panel -->
|
<!-- Panel -->
|
||||||
<div id="panel" class="hidden">
|
<div id="panel" class="hidden">
|
||||||
<section class="card">
|
|
||||||
<h2>Nueva ronda</h2>
|
<!-- Vista: lista de rondas -->
|
||||||
<form id="create-form">
|
<div id="rounds-view">
|
||||||
<div class="form-row">
|
<section class="card">
|
||||||
<div>
|
<h2>Nueva ronda</h2>
|
||||||
<label for="round-name">Local</label>
|
<form id="create-form">
|
||||||
<input id="round-name" type="text" maxlength="80" placeholder="P. ej. Restaurante Centro" required />
|
<div class="form-row">
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<label for="round-name">Local</label>
|
||||||
<label for="round-year">Año</label>
|
<input id="round-name" type="text" maxlength="80" placeholder="P. ej. Restaurante Centro" required />
|
||||||
<input id="round-year" type="number" min="2020" max="2100" required />
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="round-year">Año</label>
|
||||||
|
<input id="round-year" type="number" min="2020" max="2100" required />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label>Cargos y límite por día</label>
|
||||||
|
<div id="roles-editor"></div>
|
||||||
|
<button type="button" class="btn btn--ghost btn--small" id="add-role">+ Añadir cargo</button>
|
||||||
|
<p class="field-hint">El equipo elegirá su cargo al registrarse. El <b>máx/día</b> es cuántas personas de ese cargo pueden tener vacaciones el mismo día (vacío = sin límite).</p>
|
||||||
|
<div class="error-msg" id="create-error"></div>
|
||||||
|
<button class="btn btn--accent" type="submit">Crear ronda y obtener URL</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Rondas</h2>
|
||||||
|
<div id="rounds-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button class="btn btn--ghost" id="logout-btn">Cerrar sesión</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vista: calendario / aprobación de una ronda -->
|
||||||
|
<div id="calendar-view" class="hidden">
|
||||||
|
<div class="cal-topbar">
|
||||||
|
<button class="btn btn--ghost btn--small" id="cal-back">← Volver</button>
|
||||||
|
<div class="cal-heading">
|
||||||
|
<h2 id="cal-title">Calendario</h2>
|
||||||
|
<span class="badge" id="cal-status"></span>
|
||||||
</div>
|
</div>
|
||||||
<label for="round-roles">Cargos</label>
|
</div>
|
||||||
<input id="round-roles" type="text" maxlength="200" value="Camarero/a, Encargado, Cocina" />
|
|
||||||
<p class="field-hint">Sepáralos con comas. El equipo elegirá uno al registrarse y verá el recuento de su mismo cargo.</p>
|
|
||||||
<div class="error-msg" id="create-error"></div>
|
|
||||||
<button class="btn btn--accent" type="submit">Crear ronda y obtener URL</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
<section class="card cal-limits">
|
||||||
<h2>Rondas</h2>
|
<h3>Límite de solicitudes por día y cargo</h3>
|
||||||
<div id="rounds-list"></div>
|
<div id="cal-limits-fields" class="cal-limits-grid"></div>
|
||||||
</section>
|
<div class="cal-limits-foot">
|
||||||
|
<span class="field-hint">Vacío = sin límite. Ampliar o reducir afecta a las nuevas solicitudes.</span>
|
||||||
|
<button class="btn btn--small" id="cal-limits-save">Guardar límites</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="cal-layout">
|
||||||
|
<aside class="cal-sidebar card" id="cal-sidebar"></aside>
|
||||||
|
<div class="cal-main">
|
||||||
|
<div id="cal-grid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="btn btn--ghost" id="logout-btn">Cerrar sesión</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="colophon">Panel del administrador</footer>
|
<footer class="colophon">Panel del administrador</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Barra de acción para aprobar/rechazar/quitar un día concreto -->
|
||||||
|
<div class="actionbar" id="cal-actionbar">
|
||||||
|
<div class="inner">
|
||||||
|
<span class="label" id="cal-action-label"></span>
|
||||||
|
<div class="acts">
|
||||||
|
<button class="btn btn--small act-approve" data-status="approved">Aprobar</button>
|
||||||
|
<button class="btn btn--small act-reject" data-status="rejected">Rechazar</button>
|
||||||
|
<button class="btn btn--small btn--ghost" data-status="pending">Pendiente</button>
|
||||||
|
<button class="btn btn--small btn--ghost" data-status="none">Quitar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script src="/js/admin.js"></script>
|
<script src="/js/admin.js"></script>
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
--accent-deep: #9c3f1b;
|
--accent-deep: #9c3f1b;
|
||||||
--accent-wash: #f3e0d2;
|
--accent-wash: #f3e0d2;
|
||||||
--ok: #2f6b46;
|
--ok: #2f6b46;
|
||||||
|
/* estados de las solicitudes */
|
||||||
|
--choosing: #8d8779;
|
||||||
|
--pending: #e0a52a;
|
||||||
|
--pending-ink: #5a4200;
|
||||||
|
--approved: #2f6b46;
|
||||||
|
--rejected: #b23b27;
|
||||||
--radius: 14px;
|
--radius: 14px;
|
||||||
--shadow: 0 2px 0 rgba(30, 53, 40, 0.08), 0 14px 34px -18px rgba(30, 53, 40, 0.35);
|
--shadow: 0 2px 0 rgba(30, 53, 40, 0.08), 0 14px 34px -18px rgba(30, 53, 40, 0.35);
|
||||||
}
|
}
|
||||||
@@ -281,6 +287,29 @@ input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(194, 86, 4
|
|||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* leyenda de colores del calendario */
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px 16px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.legend-item { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.legend .sw {
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1.5px solid var(--line);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.legend .sw--choosing { background: var(--choosing); border-color: var(--choosing); }
|
||||||
|
.legend .sw--pending { background: var(--pending); border-color: #c98e1d; }
|
||||||
|
.legend .sw--approved { background: var(--approved); border-color: var(--approved); }
|
||||||
|
.legend .sw--rejected { background: #f6e3df; border-color: #e2b7af; }
|
||||||
|
|
||||||
/* ---------- calendario ---------- */
|
/* ---------- calendario ---------- */
|
||||||
|
|
||||||
.month {
|
.month {
|
||||||
@@ -350,6 +379,41 @@ input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(194, 86, 4
|
|||||||
color: var(--paper);
|
color: var(--paper);
|
||||||
border-color: var(--ink);
|
border-color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* estados del día para el trabajador */
|
||||||
|
.day.choosing {
|
||||||
|
background: var(--choosing);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--choosing);
|
||||||
|
}
|
||||||
|
.day.pending {
|
||||||
|
background: var(--pending);
|
||||||
|
color: var(--pending-ink);
|
||||||
|
border-color: #c98e1d;
|
||||||
|
}
|
||||||
|
.day.approved {
|
||||||
|
background: var(--approved);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--approved);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.day.rejected {
|
||||||
|
background: #f6e3df;
|
||||||
|
color: var(--rejected);
|
||||||
|
border-color: #e2b7af;
|
||||||
|
text-decoration: line-through;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
/* día completo para el cargo: no se puede elegir */
|
||||||
|
.day.full {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
-45deg, #efe6d2, #efe6d2 4px, #e6dabf 4px, #e6dabf 8px
|
||||||
|
);
|
||||||
|
color: #a99b7e;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.day .count.is-full { background: var(--rejected); }
|
||||||
|
|
||||||
.day .count {
|
.day .count {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
@@ -497,6 +561,263 @@ input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(194, 86, 4
|
|||||||
padding: 22px 0;
|
padding: 22px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pending-flag { color: var(--accent-deep); }
|
||||||
|
|
||||||
|
/* ---------- editor de cargos (nueva ronda) ---------- */
|
||||||
|
|
||||||
|
#roles-editor { display: grid; gap: 8px; margin-top: 4px; }
|
||||||
|
.role-row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.role-row .role-label { flex: 1; }
|
||||||
|
.role-row .role-max { width: 92px; text-align: center; }
|
||||||
|
.role-row .role-del {
|
||||||
|
flex: none;
|
||||||
|
width: 40px; height: 44px;
|
||||||
|
border: 1.5px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--accent-deep);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.role-row .role-del:hover { background: var(--accent-wash); border-color: var(--accent); }
|
||||||
|
#add-role { margin-top: 10px; }
|
||||||
|
|
||||||
|
/* ---------- vista de calendario del admin ---------- */
|
||||||
|
|
||||||
|
body.cal-open .page--wide { max-width: 1080px; }
|
||||||
|
|
||||||
|
.cal-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.cal-topbar .btn { margin-top: 0; }
|
||||||
|
.cal-heading { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.cal-heading h2 {
|
||||||
|
font-family: "Fraunces", Georgia, serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.cal-heading .badge { margin-left: 0; vertical-align: middle; }
|
||||||
|
|
||||||
|
.cal-limits h3 {
|
||||||
|
font-family: "Fraunces", Georgia, serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.cal-limits-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.limit-field { margin: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.limit-field span { font-weight: 700; font-size: 13px; }
|
||||||
|
.limit-field input { width: 100%; text-align: center; }
|
||||||
|
.cal-limits-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.cal-limits-foot .field-hint { margin: 0; flex: 1 1 200px; }
|
||||||
|
.cal-limits-foot .btn { margin-top: 0; }
|
||||||
|
|
||||||
|
.cal-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 250px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.cal-layout { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 12px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 14px;
|
||||||
|
max-height: calc(100dvh - 24px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.cal-sidebar { position: static; max-height: none; }
|
||||||
|
}
|
||||||
|
.side-all {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border: 1.5px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.side-all.is-active { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
||||||
|
.side-role {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-deep);
|
||||||
|
margin: 14px 2px 6px;
|
||||||
|
}
|
||||||
|
.side-role .side-lim {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.side-worker {
|
||||||
|
border: 1.5px solid var(--line);
|
||||||
|
border-radius: 11px;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fffdf7;
|
||||||
|
}
|
||||||
|
.side-worker.is-active { border-color: var(--ink); box-shadow: var(--shadow); }
|
||||||
|
.side-worker-main {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font: inherit;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.side-worker-main .name { font-weight: 800; font-size: 14px; }
|
||||||
|
.side-worker .tags { display: flex; gap: 5px; flex: none; }
|
||||||
|
.side-worker .t {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.side-worker .t-ap { background: #ddebdd; color: var(--approved); }
|
||||||
|
.side-worker .t-pe { background: #f7e6c2; color: var(--pending-ink); }
|
||||||
|
.side-worker .t-re { background: #f1ddd7; color: var(--rejected); }
|
||||||
|
.side-bulk {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 10px 10px;
|
||||||
|
}
|
||||||
|
.side-bulk .btn { margin-top: 0; flex: 1; padding: 8px 6px; font-size: 12.5px; }
|
||||||
|
|
||||||
|
.cal-main { min-width: 0; }
|
||||||
|
.cal-mode-hint { text-align: left; margin: 0 2px 12px; }
|
||||||
|
.cal-months {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(232px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.cal-months .month { margin-bottom: 0; animation: none; }
|
||||||
|
|
||||||
|
.cday {
|
||||||
|
position: relative;
|
||||||
|
min-height: 42px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 3px 2px;
|
||||||
|
border: 1.5px solid var(--line);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.cday:not(.cday--info):active { transform: scale(0.94); }
|
||||||
|
.cday .n { font-size: 12px; line-height: 1; }
|
||||||
|
.cday--info { cursor: default; }
|
||||||
|
.cday.s-approved { background: var(--approved); color: #fff; border-color: var(--approved); }
|
||||||
|
.cday.s-pending { background: var(--pending); color: var(--pending-ink); border-color: #c98e1d; }
|
||||||
|
.cday.s-rejected {
|
||||||
|
background: #f6e3df;
|
||||||
|
color: var(--rejected);
|
||||||
|
border-color: #e2b7af;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.cday.focused { outline: 3px solid var(--ink); outline-offset: 1px; z-index: 1; }
|
||||||
|
.cday .load {
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
background: rgba(30, 53, 40, 0.14);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
||||||
|
.cday.s-approved .load { background: rgba(255, 255, 255, 0.28); }
|
||||||
|
.cday .load.is-full { background: var(--rejected); color: #fff; }
|
||||||
|
.cday .chips { display: flex; flex-wrap: wrap; gap: 2px; justify-content: center; }
|
||||||
|
.cday .rchip {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 800;
|
||||||
|
background: #e7dcc2;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 3px;
|
||||||
|
}
|
||||||
|
.cday .rchip.is-full { background: var(--rejected); color: #fff; }
|
||||||
|
|
||||||
|
/* barra de acción del admin (aprobar / rechazar / pendiente / quitar) */
|
||||||
|
.actionbar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0; right: 0; bottom: 0;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||||
|
background: linear-gradient(transparent, rgba(246, 240, 227, 0.92) 35%);
|
||||||
|
transform: translateY(115%);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.actionbar.show { transform: translateY(0); pointer-events: auto; }
|
||||||
|
.actionbar .inner {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--paper);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
box-shadow: 0 16px 34px -12px rgba(30, 53, 40, 0.55);
|
||||||
|
}
|
||||||
|
.actionbar .label { flex: 1 1 160px; font-weight: 700; font-size: 14px; text-transform: capitalize; }
|
||||||
|
.actionbar .acts { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.actionbar .btn { margin-top: 0; width: auto; }
|
||||||
|
.actionbar .act-approve { background: var(--approved); }
|
||||||
|
.actionbar .act-reject { background: var(--rejected); }
|
||||||
|
.actionbar .btn--ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--paper);
|
||||||
|
border-color: rgba(246, 240, 227, 0.4);
|
||||||
|
}
|
||||||
|
.actionbar .btn--ghost:hover { background: rgba(246, 240, 227, 0.12); }
|
||||||
|
|
||||||
|
/* en la barra lateral los botones de aprobar/rechazar conservan su color */
|
||||||
|
.side-bulk .act-approve { background: var(--approved); }
|
||||||
|
.side-bulk .act-reject { background: var(--rejected); }
|
||||||
|
|
||||||
footer.colophon {
|
footer.colophon {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
+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 $ = (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 toast = (msg, isError = false) => {
|
||||||
const el = $('toast');
|
const el = $('toast');
|
||||||
@@ -22,11 +24,22 @@
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const esc = (s) =>
|
||||||
|
String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
|
||||||
function show(view) {
|
function show(view) {
|
||||||
$('login-card').classList.toggle('hidden', view !== 'login');
|
$('login-card').classList.toggle('hidden', view !== 'login');
|
||||||
$('panel').classList.toggle('hidden', view !== 'panel');
|
$('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 ----------
|
||||||
|
|
||||||
$('login-form').addEventListener('submit', async (e) => {
|
$('login-form').addEventListener('submit', async (e) => {
|
||||||
@@ -49,16 +62,42 @@
|
|||||||
show('login');
|
show('login');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- rondas ----------
|
// ---------- editor de cargos (nueva ronda) ----------
|
||||||
|
|
||||||
$('round-year').value = new Date().getFullYear() + 1;
|
$('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) => {
|
$('create-form').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const err = $('create-error');
|
const err = $('create-error');
|
||||||
err.classList.remove('show');
|
err.classList.remove('show');
|
||||||
try {
|
try {
|
||||||
const roles = $('round-roles').value.split(',').map((s) => s.trim()).filter(Boolean);
|
const roles = readRoles();
|
||||||
const { round } = await fetchJSON('/api/admin/rounds', {
|
const { round } = await fetchJSON('/api/admin/rounds', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { name: $('round-name').value, year: Number($('round-year').value), roles },
|
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}`;
|
const roundUrl = (token) => `${location.origin}/r/${token}`;
|
||||||
|
|
||||||
async function copyUrl(token, notify = true) {
|
async function copyUrl(token, notify = true) {
|
||||||
@@ -93,6 +134,7 @@
|
|||||||
show('login');
|
show('login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
showPanelView('rounds');
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
if (!rounds.length) {
|
if (!rounds.length) {
|
||||||
list.innerHTML = '<p class="empty">Todavía no hay rondas. Crea la primera arriba.</p>';
|
list.innerHTML = '<p class="empty">Todavía no hay rondas. Crea la primera arriba.</p>';
|
||||||
@@ -100,22 +142,28 @@
|
|||||||
}
|
}
|
||||||
for (const r of rounds) {
|
for (const r of rounds) {
|
||||||
const open = r.status === 'open';
|
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');
|
const item = document.createElement('div');
|
||||||
item.className = 'round-item';
|
item.className = 'round-item';
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="title">${esc(r.name)} ${r.year}</span>
|
<span class="title">${esc(r.name)} ${r.year}</span>
|
||||||
<span class="badge ${open ? 'open' : 'closed'}">${open ? 'Abierta' : 'Cerrada'}</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>
|
<a class="url" href="/r/${r.token}" target="_blank" rel="noopener">${roundUrl(r.token)}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<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>
|
<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>
|
<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">
|
<button class="btn btn--small ${open ? '' : 'btn--ghost'}" data-act="status">
|
||||||
${open ? 'Cerrar ronda' : 'Reabrir'}
|
${open ? 'Cerrar' : 'Reabrir'}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn--ghost btn--small" data-act="delete" title="Eliminar ronda">🗑</button>
|
||||||
</div>`;
|
</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=copy]').addEventListener('click', () => copyUrl(r.token));
|
||||||
item.querySelector('[data-act=status]').addEventListener('click', async () => {
|
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;
|
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);
|
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);
|
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 ----------
|
// ---------- 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 token = location.pathname.split('/').pop();
|
||||||
const api = (p) => `/api/round/${token}${p}`;
|
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 MONTHS = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
|
||||||
const DOW = ['L','M','X','J','V','S','D'];
|
const DOW = ['L','M','X','J','V','S','D'];
|
||||||
|
|
||||||
let state = null; // respuesta del servidor
|
let state = null; // respuesta del servidor
|
||||||
let selected = new Set(); // fechas elegidas (con cambios sin guardar)
|
let todayIso = ''; // hoy según el servidor (zona de España)
|
||||||
let savedDates = new Set();
|
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 ----------
|
// ---------- utilidades ----------
|
||||||
|
|
||||||
@@ -36,6 +38,13 @@
|
|||||||
const iso = (y, m, d) =>
|
const iso = (y, m, d) =>
|
||||||
`${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
`${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 ----------
|
// ---------- registro ----------
|
||||||
|
|
||||||
function renderJoin() {
|
function renderJoin() {
|
||||||
@@ -65,6 +74,57 @@
|
|||||||
|
|
||||||
// ---------- calendario ----------
|
// ---------- 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() {
|
function renderCalendar() {
|
||||||
$('join-card').classList.add('hidden');
|
$('join-card').classList.add('hidden');
|
||||||
$('calendar-section').classList.remove('hidden');
|
$('calendar-section').classList.remove('hidden');
|
||||||
@@ -75,9 +135,7 @@
|
|||||||
$('me-role').textContent = state.roles[me.role] || me.role;
|
$('me-role').textContent = state.roles[me.role] || me.role;
|
||||||
$('closed-banner').classList.toggle('hidden', open);
|
$('closed-banner').classList.toggle('hidden', open);
|
||||||
$('calendar-hint').classList.toggle('hidden', !open);
|
$('calendar-hint').classList.toggle('hidden', !open);
|
||||||
|
$('calendar-legend').classList.remove('hidden');
|
||||||
const today = new Date();
|
|
||||||
const todayIso = iso(today.getFullYear(), today.getMonth(), today.getDate());
|
|
||||||
|
|
||||||
const cal = $('calendar');
|
const cal = $('calendar');
|
||||||
cal.innerHTML = '';
|
cal.innerHTML = '';
|
||||||
@@ -96,69 +154,38 @@
|
|||||||
for (let i = 0; i < blanks; i++) days.appendChild(document.createElement('span'));
|
for (let i = 0; i < blanks; i++) days.appendChild(document.createElement('span'));
|
||||||
|
|
||||||
const total = new Date(round.year, m + 1, 0).getDate();
|
const total = new Date(round.year, m + 1, 0).getDate();
|
||||||
let monthHasCounts = false;
|
|
||||||
for (let d = 1; d <= total; d++) {
|
for (let d = 1; d <= total; d++) {
|
||||||
const date = iso(round.year, m, d);
|
const date = iso(round.year, m, d);
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.className = 'day';
|
|
||||||
btn.dataset.date = date;
|
btn.dataset.date = date;
|
||||||
btn.textContent = d;
|
btn.textContent = d;
|
||||||
if (date < todayIso) btn.classList.add('past');
|
styleDay(btn);
|
||||||
if (state.counts[date]?.[me.role]) monthHasCounts = true;
|
if (!btn.disabled) btn.addEventListener('click', () => toggle(btn));
|
||||||
if (!open) btn.disabled = true;
|
|
||||||
else btn.addEventListener('click', () => toggle(date, btn));
|
|
||||||
days.appendChild(btn);
|
days.appendChild(btn);
|
||||||
}
|
}
|
||||||
month.appendChild(days);
|
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);
|
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();
|
updateBars();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pinta selección y contadores de compañeros del mismo cargo.
|
function toggle(btn) {
|
||||||
function paint() {
|
const date = btn.dataset.date;
|
||||||
const role = state.me.role;
|
if (pending.has(date)) pending.delete(date);
|
||||||
document.querySelectorAll('.day').forEach((btn) => {
|
else pending.add(date);
|
||||||
const date = btn.dataset.date;
|
styleDay(btn);
|
||||||
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();
|
updateBars();
|
||||||
}
|
}
|
||||||
|
|
||||||
function 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 =
|
const dirty =
|
||||||
selected.size !== savedDates.size || [...selected].some((d) => !savedDates.has(d));
|
pending.size !== savedPending.size || [...pending].some((d) => !savedPending.has(d));
|
||||||
const bar = $('savebar');
|
$('savebar').classList.toggle('show', dirty && state.round.status === 'open');
|
||||||
bar.classList.toggle('show', dirty && state.round.status === 'open');
|
const n = pending.size;
|
||||||
$('save-label').textContent =
|
$('save-label').textContent = n === 1 ? '1 día pendiente' : `${n} días pendientes`;
|
||||||
selected.size === 1 ? '1 día elegido' : `${selected.size} días elegidos`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$('save-btn').addEventListener('click', async () => {
|
$('save-btn').addEventListener('click', async () => {
|
||||||
@@ -167,15 +194,16 @@
|
|||||||
try {
|
try {
|
||||||
const data = await fetchJSON(api('/requests'), {
|
const data = await fetchJSON(api('/requests'), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: { dates: [...selected] },
|
body: { dates: [...pending] },
|
||||||
});
|
});
|
||||||
savedDates = new Set(data.dates);
|
setRequests(data.requests);
|
||||||
selected = new Set(data.dates);
|
|
||||||
state.counts = data.counts;
|
state.counts = data.counts;
|
||||||
paint();
|
renderCalendar();
|
||||||
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.
|
||||||
|
if (/complet/i.test(ex.message)) load();
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -190,6 +218,7 @@
|
|||||||
$('round-title').textContent = 'Ronda no encontrada';
|
$('round-title').textContent = 'Ronda no encontrada';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
todayIso = state.today || new Date().toISOString().slice(0, 10);
|
||||||
$('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
|
||||||
@@ -198,8 +227,7 @@
|
|||||||
document.title = `Vacaciones · ${state.round.name} ${state.round.year}`;
|
document.title = `Vacaciones · ${state.round.name} ${state.round.year}`;
|
||||||
|
|
||||||
if (state.me) {
|
if (state.me) {
|
||||||
savedDates = new Set(state.me.dates);
|
setRequests(state.me.requests || []);
|
||||||
selected = new Set(state.me.dates);
|
|
||||||
renderCalendar();
|
renderCalendar();
|
||||||
} else if (state.round.status !== 'open') {
|
} else if (state.round.status !== 'open') {
|
||||||
$('closed-banner').classList.remove('hidden');
|
$('closed-banner').classList.remove('hidden');
|
||||||
|
|||||||
+8
-1
@@ -48,8 +48,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<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.
|
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.
|
||||||
</p>
|
</p>
|
||||||
|
<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--pending"></i>Pendiente</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>
|
||||||
|
</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>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS rounds (
|
|||||||
year INTEGER NOT NULL,
|
year INTEGER NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'open',
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
roles TEXT,
|
roles TEXT,
|
||||||
|
limits TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS workers (
|
CREATE TABLE IF NOT EXISTS workers (
|
||||||
@@ -40,15 +41,24 @@ CREATE TABLE IF NOT EXISTS workers (
|
|||||||
CREATE TABLE IF NOT EXISTS requests (
|
CREATE TABLE IF NOT EXISTS requests (
|
||||||
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
|
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
|
||||||
date TEXT NOT NULL,
|
date TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
PRIMARY KEY (worker_id, date)
|
PRIMARY KEY (worker_id, date)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_workers_round ON workers(round_id);
|
CREATE INDEX IF NOT EXISTS idx_workers_round ON workers(round_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_requests_date ON requests(date);
|
CREATE INDEX IF NOT EXISTS idx_requests_date ON requests(date);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Migración para bases de datos creadas antes de los cargos configurables.
|
// Migraciones para bases de datos creadas antes de algunas columnas.
|
||||||
if (!db.prepare("PRAGMA table_info('rounds')").all().some((c) => c.name === 'roles')) {
|
const hasColumn = (table, col) =>
|
||||||
db.exec('ALTER TABLE rounds ADD COLUMN roles TEXT');
|
db.prepare(`PRAGMA table_info('${table}')`).all().some((c) => c.name === col);
|
||||||
|
|
||||||
|
// Cargos configurables por ronda.
|
||||||
|
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}).
|
||||||
|
if (!hasColumn('rounds', 'limits')) db.exec('ALTER TABLE rounds ADD COLUMN limits TEXT');
|
||||||
|
// Estado de aprobación de cada solicitud: pending | approved | rejected.
|
||||||
|
if (!hasColumn('requests', 'status')) {
|
||||||
|
db.exec("ALTER TABLE requests ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cargos por defecto que se proponen al crear una ronda nueva.
|
// Cargos por defecto que se proponen al crear una ronda nueva.
|
||||||
@@ -78,6 +88,25 @@ function roundRoles(round) {
|
|||||||
return LEGACY_ROLES;
|
return LEGACY_ROLES;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Límite de solicitudes por día y cargo (mapa clave → entero). Una clave
|
||||||
|
// ausente significa "sin límite". Solo se devuelven límites de cargos vigentes.
|
||||||
|
function roundLimits(round) {
|
||||||
|
const roles = roundRoles(round);
|
||||||
|
const out = {};
|
||||||
|
if (round.limits) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(round.limits);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(parsed)) {
|
||||||
|
const n = Number(v);
|
||||||
|
if (roles[k] && Number.isInteger(n) && n > 0) out[k] = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* sin límites */ }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -100,6 +129,31 @@ function buildRoles(labels) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Interpreta los cargos que llegan al crear una ronda. Acepta una lista de
|
||||||
|
// etiquetas ("Camarero, Cocina") o de objetos {label, max} con el límite por
|
||||||
|
// día. Devuelve {roles: {clave: etiqueta}, limits: {clave: máximo}}.
|
||||||
|
function parseRolesInput(input) {
|
||||||
|
const arr = Array.isArray(input) ? input : String(input).split(',');
|
||||||
|
const items = arr
|
||||||
|
.map((x) =>
|
||||||
|
x && typeof x === 'object'
|
||||||
|
? { label: String(x.label ?? '').trim(), max: x.max }
|
||||||
|
: { label: String(x).trim(), max: null }
|
||||||
|
)
|
||||||
|
.filter((it) => it.label);
|
||||||
|
if (items.length < 1 || items.length > 8) throw new Error('Indica entre 1 y 8 cargos');
|
||||||
|
if (items.some((it) => it.label.length > 40)) throw new Error('Cada cargo puede tener como máximo 40 caracteres');
|
||||||
|
|
||||||
|
const roles = buildRoles(items.map((it) => it.label));
|
||||||
|
const keys = Object.keys(roles);
|
||||||
|
const limits = {};
|
||||||
|
items.forEach((it, i) => {
|
||||||
|
const n = Number(it.max);
|
||||||
|
if (Number.isInteger(n) && n > 0 && n <= 99) limits[keys[i]] = n;
|
||||||
|
});
|
||||||
|
return { roles, limits };
|
||||||
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -121,12 +175,13 @@ function getWorkerFromCookie(req, round) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function roundCounts(roundId, excludeWorkerId) {
|
function roundCounts(roundId, excludeWorkerId) {
|
||||||
// Recuento por fecha y cargo, excluyendo al propio trabajador.
|
// Solicitudes que ocupan hueco por fecha y cargo (pendientes + aprobadas,
|
||||||
|
// nunca rechazadas), excluyendo al propio trabajador.
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT r.date, w.role, COUNT(*) AS n
|
`SELECT r.date, w.role, COUNT(*) AS n
|
||||||
FROM requests r JOIN workers w ON w.id = r.worker_id
|
FROM requests r JOIN workers w ON w.id = r.worker_id
|
||||||
WHERE w.round_id = ? AND w.id != ?
|
WHERE w.round_id = ? AND w.id != ? AND r.status != 'rejected'
|
||||||
GROUP BY r.date, w.role`
|
GROUP BY r.date, w.role`
|
||||||
)
|
)
|
||||||
.all(roundId, excludeWorkerId ?? -1);
|
.all(roundId, excludeWorkerId ?? -1);
|
||||||
@@ -137,11 +192,11 @@ function roundCounts(roundId, excludeWorkerId) {
|
|||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
function workerDates(workerId) {
|
// Solicitudes de un trabajador con su estado: [{date, status}].
|
||||||
|
function workerRequests(workerId) {
|
||||||
return db
|
return db
|
||||||
.prepare('SELECT date FROM requests WHERE worker_id = ? ORDER BY date')
|
.prepare('SELECT date, status FROM requests WHERE worker_id = ? ORDER BY date')
|
||||||
.all(workerId)
|
.all(workerId);
|
||||||
.map((r) => r.date);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidDate = (s, year) => {
|
const isValidDate = (s, year) => {
|
||||||
@@ -150,6 +205,11 @@ const isValidDate = (s, year) => {
|
|||||||
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s && d.getUTCFullYear() === year;
|
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s && d.getUTCFullYear() === year;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fecha de hoy (YYYY-MM-DD) en la zona del restaurante, sea cual sea la zona
|
||||||
|
// horaria del servidor. Así "pasado / hoy" se calcula igual aquí y en el móvil.
|
||||||
|
const todayISO = () =>
|
||||||
|
new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Madrid' }).format(new Date());
|
||||||
|
|
||||||
// ---------- sesiones de administración (en memoria) ----------
|
// ---------- sesiones de administración (en memoria) ----------
|
||||||
|
|
||||||
const adminSessions = new Set();
|
const adminSessions = new Set();
|
||||||
@@ -187,8 +247,10 @@ app.get('/api/round/:token', (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
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),
|
||||||
|
today: todayISO(),
|
||||||
me: worker
|
me: worker
|
||||||
? { name: worker.name, role: worker.role, dates: workerDates(worker.id) }
|
? { name: worker.name, role: worker.role, requests: workerRequests(worker.id) }
|
||||||
: null,
|
: null,
|
||||||
counts: roundCounts(round.id, worker?.id),
|
counts: roundCounts(round.id, worker?.id),
|
||||||
});
|
});
|
||||||
@@ -215,7 +277,7 @@ app.post('/api/round/:token/join', (req, res) => {
|
|||||||
maxAge: 400 * 24 * 3600 * 1000,
|
maxAge: 400 * 24 * 3600 * 1000,
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
res.json({ ok: true, me: { name, role, dates: [] } });
|
res.json({ ok: true, me: { name, role, requests: [] } });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/round/:token/requests', (req, res) => {
|
app.put('/api/round/:token/requests', (req, res) => {
|
||||||
@@ -227,18 +289,52 @@ app.put('/api/round/:token/requests', (req, res) => {
|
|||||||
|
|
||||||
const dates = req.body.dates;
|
const dates = req.body.dates;
|
||||||
if (!Array.isArray(dates) || dates.length > 366) return res.status(400).json({ error: 'Petición no válida' });
|
if (!Array.isArray(dates) || dates.length > 366) return res.status(400).json({ error: 'Petición no válida' });
|
||||||
const unique = [...new Set(dates.map(String))];
|
const wanted = [...new Set(dates.map(String))];
|
||||||
if (!unique.every((d) => isValidDate(d, round.year))) {
|
if (!wanted.every((d) => isValidDate(d, round.year))) {
|
||||||
return res.status(400).json({ error: `Las fechas deben ser días válidos de ${round.year}` });
|
return res.status(400).json({ error: `Las fechas deben ser días válidos de ${round.year}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const replace = db.transaction((ds) => {
|
const existing = workerRequests(worker.id);
|
||||||
db.prepare('DELETE FROM requests WHERE worker_id = ?').run(worker.id);
|
const locked = new Set(existing.filter((r) => r.status !== 'pending').map((r) => r.date));
|
||||||
const ins = db.prepare('INSERT INTO requests (worker_id, date) VALUES (?, ?)');
|
const existingPending = new Set(existing.filter((r) => r.status === 'pending').map((r) => r.date));
|
||||||
for (const d of ds) ins.run(worker.id, d);
|
|
||||||
|
// El trabajador solo controla sus pendientes: los días aprobados o rechazados
|
||||||
|
// se conservan tal cual aunque vengan (o no) en la selección.
|
||||||
|
const desiredPending = wanted.filter((d) => !locked.has(d));
|
||||||
|
const added = desiredPending.filter((d) => !existingPending.has(d));
|
||||||
|
|
||||||
|
// Los días nuevos no pueden ser pasados ni el día de hoy.
|
||||||
|
const today = todayISO();
|
||||||
|
if (added.some((d) => d <= today)) {
|
||||||
|
return res.status(400).json({ error: 'No puedes elegir días pasados ni el día de hoy.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
const limit = roundLimits(round)[worker.role];
|
||||||
|
if (limit) {
|
||||||
|
const others = roundCounts(round.id, worker.id);
|
||||||
|
if (added.some((d) => (others[d]?.[worker.role] ?? 0) >= limit)) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'Algún día que elegiste ya se ha completado para tu cargo. Recarga la página y vuelve a intentarlo.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const apply = db.transaction((keep) => {
|
||||||
|
const keepSet = new Set(keep);
|
||||||
|
const del = db.prepare("DELETE FROM requests WHERE worker_id = ? AND status = 'pending' AND date = ?");
|
||||||
|
for (const d of existingPending) if (!keepSet.has(d)) del.run(worker.id, d);
|
||||||
|
const ins = db.prepare("INSERT OR IGNORE INTO requests (worker_id, date, status) VALUES (?, ?, 'pending')");
|
||||||
|
for (const d of keep) if (!existingPending.has(d)) ins.run(worker.id, d);
|
||||||
|
});
|
||||||
|
apply(desiredPending);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
requests: workerRequests(worker.id),
|
||||||
|
counts: roundCounts(round.id, worker.id),
|
||||||
});
|
});
|
||||||
replace(unique);
|
|
||||||
res.json({ ok: true, dates: unique.sort(), counts: roundCounts(round.id, worker.id) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- API administración ----------
|
// ---------- API administración ----------
|
||||||
@@ -266,7 +362,8 @@ app.get('/api/admin/rounds', requireAdmin, (_req, res) => {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT r.*,
|
`SELECT r.*,
|
||||||
(SELECT COUNT(*) FROM workers w WHERE w.round_id = r.id) AS workers,
|
(SELECT COUNT(*) FROM workers w WHERE w.round_id = r.id) AS workers,
|
||||||
(SELECT COUNT(*) FROM requests q JOIN workers w ON w.id = q.worker_id WHERE w.round_id = r.id) AS days
|
(SELECT COUNT(*) FROM requests q JOIN workers w ON w.id = q.worker_id WHERE w.round_id = r.id) AS days,
|
||||||
|
(SELECT COUNT(*) FROM requests q JOIN workers w ON w.id = q.worker_id WHERE w.round_id = r.id AND q.status = 'pending') AS pending
|
||||||
FROM rounds r ORDER BY r.created_at DESC`
|
FROM rounds r ORDER BY r.created_at DESC`
|
||||||
)
|
)
|
||||||
.all();
|
.all();
|
||||||
@@ -280,19 +377,19 @@ app.post('/api/admin/rounds', requireAdmin, (req, res) => {
|
|||||||
if (!Number.isInteger(year) || year < 2020 || year > 2100) return res.status(400).json({ error: 'Año no válido' });
|
if (!Number.isInteger(year) || year < 2020 || year > 2100) return res.status(400).json({ error: 'Año no válido' });
|
||||||
|
|
||||||
let roles = DEFAULT_ROLES;
|
let roles = DEFAULT_ROLES;
|
||||||
|
let limits = {};
|
||||||
if (req.body.roles != null) {
|
if (req.body.roles != null) {
|
||||||
const labels = (Array.isArray(req.body.roles) ? req.body.roles : String(req.body.roles).split(','))
|
try {
|
||||||
.map((s) => String(s).trim())
|
({ roles, limits } = parseRolesInput(req.body.roles));
|
||||||
.filter(Boolean);
|
} catch (ex) {
|
||||||
if (labels.length < 1 || labels.length > 8) return res.status(400).json({ error: 'Indica entre 1 y 8 cargos' });
|
return res.status(400).json({ error: ex.message });
|
||||||
if (labels.some((l) => l.length > 40)) return res.status(400).json({ error: 'Cada cargo puede tener como máximo 40 caracteres' });
|
}
|
||||||
roles = buildRoles(labels);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = newToken();
|
const token = newToken();
|
||||||
const info = db
|
const info = db
|
||||||
.prepare('INSERT INTO rounds (token, name, year, roles) VALUES (?, ?, ?, ?)')
|
.prepare('INSERT INTO rounds (token, name, year, roles, limits) VALUES (?, ?, ?, ?, ?)')
|
||||||
.run(token, name, year, JSON.stringify(roles));
|
.run(token, name, year, JSON.stringify(roles), JSON.stringify(limits));
|
||||||
res.json({ ok: true, round: { id: info.lastInsertRowid, token, name, year, status: 'open' } });
|
res.json({ ok: true, round: { id: info.lastInsertRowid, token, name, year, status: 'open' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,11 +406,85 @@ app.delete('/api/admin/rounds/:id', requireAdmin, (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Editar los límites por día y cargo de una ronda (ampliar o reducir).
|
||||||
|
app.put('/api/admin/rounds/:id/limits', 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' });
|
||||||
|
if (req.body.limits == null || typeof req.body.limits !== 'object') {
|
||||||
|
return res.status(400).json({ error: 'Límites no válidos' });
|
||||||
|
}
|
||||||
|
const limits = {};
|
||||||
|
for (const key of Object.keys(roundRoles(round))) {
|
||||||
|
const n = Number(req.body.limits[key]);
|
||||||
|
if (Number.isInteger(n) && n > 0 && n <= 99) limits[key] = n;
|
||||||
|
}
|
||||||
|
db.prepare('UPDATE rounds SET limits = ? WHERE id = ?').run(JSON.stringify(limits), round.id);
|
||||||
|
res.json({ ok: true, limits });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vista de calendario del administrador: empleados con sus solicitudes (y estado),
|
||||||
|
// más los cargos y límites de la ronda.
|
||||||
|
app.get('/api/admin/rounds/:id/calendar', 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 workers = db
|
||||||
|
.prepare('SELECT id, name, role FROM workers WHERE round_id = ? ORDER BY role, name COLLATE NOCASE')
|
||||||
|
.all(round.id)
|
||||||
|
.map((w) => ({ ...w, requests: workerRequests(w.id) }));
|
||||||
|
res.json({
|
||||||
|
round: { id: round.id, name: round.name, year: round.year, status: round.status },
|
||||||
|
roles: roundRoles(round),
|
||||||
|
limits: roundLimits(round),
|
||||||
|
today: todayISO(),
|
||||||
|
workers,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aprobar / rechazar / volver a pendiente / añadir / quitar días de un empleado.
|
||||||
|
app.post('/api/admin/rounds/:id/requests/set', 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 worker = db
|
||||||
|
.prepare('SELECT * FROM workers WHERE id = ? AND round_id = ?')
|
||||||
|
.get(req.body.worker_id, round.id);
|
||||||
|
if (!worker) return res.status(404).json({ error: 'Empleado no encontrado' });
|
||||||
|
|
||||||
|
const status = req.body.status;
|
||||||
|
if (!['approved', 'rejected', 'pending', 'none'].includes(status)) {
|
||||||
|
return res.status(400).json({ error: 'Estado no válido' });
|
||||||
|
}
|
||||||
|
const dates = req.body.dates;
|
||||||
|
if (!Array.isArray(dates) || !dates.length || dates.length > 366) {
|
||||||
|
return res.status(400).json({ error: 'Fechas no válidas' });
|
||||||
|
}
|
||||||
|
const unique = [...new Set(dates.map(String))];
|
||||||
|
if (!unique.every((d) => isValidDate(d, round.year))) {
|
||||||
|
return res.status(400).json({ error: `Las fechas deben ser días válidos de ${round.year}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const apply = db.transaction((ds) => {
|
||||||
|
if (status === 'none') {
|
||||||
|
const del = db.prepare('DELETE FROM requests WHERE worker_id = ? AND date = ?');
|
||||||
|
for (const d of ds) del.run(worker.id, d);
|
||||||
|
} else {
|
||||||
|
const up = db.prepare(
|
||||||
|
`INSERT INTO requests (worker_id, date, status) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(worker_id, date) DO UPDATE SET status = excluded.status`
|
||||||
|
);
|
||||||
|
for (const d of ds) up.run(worker.id, d, status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
apply(unique);
|
||||||
|
|
||||||
|
res.json({ ok: true, requests: workerRequests(worker.id) });
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/admin/rounds/:id/excel', requireAdmin, async (req, res) => {
|
app.get('/api/admin/rounds/:id/excel', requireAdmin, async (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' });
|
||||||
|
|
||||||
const roles = roundRoles(round);
|
const roles = roundRoles(round);
|
||||||
|
const limits = roundLimits(round);
|
||||||
|
|
||||||
const workers = db
|
const workers = db
|
||||||
.prepare('SELECT * FROM workers WHERE round_id = ? ORDER BY role, name COLLATE NOCASE')
|
.prepare('SELECT * FROM workers WHERE round_id = ? ORDER BY role, name COLLATE NOCASE')
|
||||||
@@ -322,26 +493,43 @@ app.get('/api/admin/rounds/:id/excel', requireAdmin, async (req, res) => {
|
|||||||
const wb = new ExcelJS.Workbook();
|
const wb = new ExcelJS.Workbook();
|
||||||
wb.creator = 'Vacaciones';
|
wb.creator = 'Vacaciones';
|
||||||
|
|
||||||
// Hoja 1: una fila por persona con sus fechas
|
// Hoja 1: una fila por persona, con sus días separados por estado.
|
||||||
const ws = wb.addWorksheet('Peticiones');
|
const ws = wb.addWorksheet('Por persona');
|
||||||
ws.columns = [
|
ws.columns = [
|
||||||
{ header: 'Nombre', key: 'name', width: 28 },
|
{ header: 'Nombre', key: 'name', width: 28 },
|
||||||
{ header: 'Cargo', key: 'role', width: 16 },
|
{ header: 'Cargo', key: 'role', width: 16 },
|
||||||
{ header: 'Nº días', key: 'n', width: 9 },
|
{ header: 'Aprobados', key: 'na', width: 11 },
|
||||||
{ header: 'Fechas pedidas', key: 'dates', width: 90 },
|
{ header: 'Pendientes', key: 'np', width: 11 },
|
||||||
|
{ header: 'Rechazados', key: 'nr', width: 11 },
|
||||||
|
{ header: 'Días aprobados', key: 'da', width: 60 },
|
||||||
|
{ header: 'Días pendientes', key: 'dp', width: 60 },
|
||||||
|
{ header: 'Días rechazados', key: 'dr', width: 40 },
|
||||||
];
|
];
|
||||||
ws.getRow(1).font = { bold: true };
|
ws.getRow(1).font = { bold: true };
|
||||||
for (const w of workers) {
|
for (const w of workers) {
|
||||||
const dates = workerDates(w.id);
|
const reqs = workerRequests(w.id);
|
||||||
ws.addRow({ name: w.name, role: roles[w.role] ?? w.role, n: dates.length, dates: dates.join(', ') });
|
const by = { approved: [], pending: [], rejected: [] };
|
||||||
|
for (const r of reqs) (by[r.status] ??= []).push(r.date);
|
||||||
|
ws.addRow({
|
||||||
|
name: w.name,
|
||||||
|
role: roles[w.role] ?? w.role,
|
||||||
|
na: by.approved.length,
|
||||||
|
np: by.pending.length,
|
||||||
|
nr: by.rejected.length,
|
||||||
|
da: by.approved.join(', '),
|
||||||
|
dp: by.pending.join(', '),
|
||||||
|
dr: by.rejected.join(', '),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hoja 2: recuento por día y cargo
|
// Hoja 2: ocupación por día y cargo (solicitudes no rechazadas, las que
|
||||||
|
// cuentan para el límite).
|
||||||
const byDay = db
|
const byDay = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT q.date, w.role, COUNT(*) AS n
|
`SELECT q.date, w.role, COUNT(*) AS n
|
||||||
FROM requests q JOIN workers w ON w.id = q.worker_id
|
FROM requests q JOIN workers w ON w.id = q.worker_id
|
||||||
WHERE w.round_id = ? GROUP BY q.date, w.role ORDER BY q.date`
|
WHERE w.round_id = ? AND q.status != 'rejected'
|
||||||
|
GROUP BY q.date, w.role ORDER BY q.date`
|
||||||
)
|
)
|
||||||
.all(round.id);
|
.all(round.id);
|
||||||
const dayMap = new Map();
|
const dayMap = new Map();
|
||||||
@@ -353,7 +541,11 @@ app.get('/api/admin/rounds/:id/excel', requireAdmin, async (req, res) => {
|
|||||||
const ws2 = wb.addWorksheet('Por día');
|
const ws2 = wb.addWorksheet('Por día');
|
||||||
ws2.columns = [
|
ws2.columns = [
|
||||||
{ header: 'Fecha', key: 'date', width: 14 },
|
{ header: 'Fecha', key: 'date', width: 14 },
|
||||||
...roleKeys.map((k) => ({ header: roles[k], key: k, width: 16 })),
|
...roleKeys.map((k) => ({
|
||||||
|
header: limits[k] ? `${roles[k]} (máx ${limits[k]})` : roles[k],
|
||||||
|
key: k,
|
||||||
|
width: 16,
|
||||||
|
})),
|
||||||
{ header: 'Total', key: 'total', width: 9 },
|
{ header: 'Total', key: 'total', width: 9 },
|
||||||
];
|
];
|
||||||
ws2.getRow(1).font = { bold: true };
|
ws2.getRow(1).font = { bold: true };
|
||||||
|
|||||||
Reference in New Issue
Block a user