import React, { useState, useMemo } from “react”;
const TURNOS = {
mañana: { label: “Mañana”, color: “#F59E0B” },
tarde: { label: “Tarde”, color: “#F97316” },
noche: { label: “Noche”, color: “#7C3AED” },
descanso: { label: “Descanso”, color: “#10B981” },
vacaciones: { label: “Vacaciones”, color: “#3B82F6” }
};
const DIAS_CORTOS = [“Dom”, “Lun”, “Mar”, “Mié”, “Jue”, “Vie”, “Sáb”];
const DIAS_LARGOS = [“Domingo”, “Lunes”, “Martes”, “Miércoles”, “Jueves”, “Viernes”, “Sábado”];
const MESES = [“Enero”, “Febrero”, “Marzo”, “Abril”, “Mayo”, “Junio”, “Julio”, “Agosto”, “Septiembre”, “Octubre”, “Noviembre”, “Diciembre”];
function nuevoId() {
return Math.random().toString(36).slice(2, 9);
}
function fechaToStr(d) {
return d.getFullYear() + “-” + String(d.getMonth() + 1).padStart(2, “0”) + “-” + String(d.getDate()).padStart(2, “0”);
}
function strToFecha(s) {
const p = s.split(”-”).map(Number);
return new Date(p[0], p[1] - 1, p[2]);
}
function diasEntre(a, b) {
return Math.round((strToFecha(b) - strToFecha(a)) / 86400000);
}
function turnoDelCiclo(ciclo, fecha, inicio) {
if (!ciclo || !inicio) return null;
const d = diasEntre(inicio, fecha);
if (d < 0) return null;
return ciclo.dias[d % (ciclo.semanas * 7)] || null;
}
const ESTILO_INPUT = “block w-full border border-gray-300 rounded-lg px-3 py-2 text-base sm:text-sm bg-white”;
function Pildora({ turno }) {
const t = TURNOS[turno];
if (!t) return null;
return (
{t.label}
);
}
function SelectorTurno({ value, onChange }) {
return (
);
}
function Campo({ label, children }) {
return (
);
}
function ModalDialog({ title, onClose, wide, children }) {
return (
);
}
function BotonPrimario({ children, onClick, disabled, full, sm }) {
const tam = sm ? “px-3 py-2 text-sm” : “px-4 py-2.5 text-sm”;
const ancho = full ? “ w-full” : “”;
return (
);
}
function BotonSecundario({ children, onClick, full, sm }) {
const tam = sm ? “px-3 py-2 text-sm” : “px-4 py-2.5 text-sm”;
const ancho = full ? “ w-full” : “”;
return (
);
}
function BotonPeligro({ children, onClick, sm }) {
const tam = sm ? “px-3 py-2 text-sm” : “px-4 py-2.5 text-sm”;
return (
);
}
export default function App() {
const [tab, setTab] = useState(“calendario”);
const [colaboradores, setColaboradores] = useState([]);
const [equipos, setEquipos] = useState([]);
const [ciclos, setCiclos] = useState([]);
const [minimos, setMinimos] = useState([]);
const [asignaciones, setAsignaciones] = useState({});
const [aplicaciones, setAplicaciones] = useState([]);
function obtenerTurno(colId, fecha) {
const clave = colId + “_” + fecha;
if (asignaciones[clave]) return asignaciones[clave];
const apsCol = aplicaciones.filter((a) => a.colId === colId && a.inicio <= fecha);
apsCol.sort((a, b) => b.inicio.localeCompare(a.inicio));
if (apsCol.length === 0) return null;
const ap = apsCol[0];
const ciclo = ciclos.find((c) => c.id === ap.cicloId);
return turnoDelCiclo(ciclo, fecha, ap.inicio);
}
const TABS = [
[“calendario”, “📅”, “Calendario”],
[“colaboradores”, “👥”, “Personas”],
[“equipos”, “🏷️”, “Equipos”],
[“ciclos”, “🔄”, “Ciclos”],
[“minimos”, “⚠️”, “Mínimos”]
];
return (
📅 Gestor de Guardias
{TABS.map(([id, icon, label]) => {
const activo = tab === id;
const cls = “flex items-center gap-1.5 px-3 sm:px-4 py-3 text-sm font-semibold whitespace-nowrap border-b-2 transition-colors “ + (activo ? “border-indigo-600 text-indigo-700” : “border-transparent text-gray-500 hover:text-gray-700”);
return (
);
})}
{tab === “calendario” && (
)}
{tab === “colaboradores” && (
)}
{tab === “equipos” && (
)}
{tab === “ciclos” && }
{tab === “minimos” && }
);
}
function TabCalendario({ colaboradores, equipos, minimos, asignaciones, setAsignaciones, obtenerTurno }) {
const hoy = new Date();
const [año, setAño] = useState(hoy.getFullYear());
const [mes, setMes] = useState(hoy.getMonth());
const [diaAbierto, setDiaAbierto] = useState(null);
function mesAnterior() {
if (mes === 0) { setAño(año - 1); setMes(11); } else setMes(mes - 1);
}
function mesSiguiente() {
if (mes === 11) { setAño(año + 1); setMes(0); } else setMes(mes + 1);
}
const diasDelMes = new Date(año, mes + 1, 0).getDate();
const diaSemanaInicio = new Date(año, mes, 1).getDay();
const hoyStr = fechaToStr(hoy);
const resumen = useMemo(() => {
const r = {};
for (let d = 1; d <= diasDelMes; d++) {
const f = año + “-” + String(mes + 1).padStart(2, “0”) + “-” + String(d).padStart(2, “0”);
const conteo = {};
for (const col of colaboradores) {
const t = obtenerTurno(col.id, f);
if (t) conteo[t] = (conteo[t] || 0) + 1;
}
const dow = new Date(año, mes, d).getDay();
const violaciones = [];
for (const m of minimos) {
if (m.dia !== “todos” && Number(m.dia) !== dow) continue;
let cnt = 0;
for (const col of colaboradores) {
if (m.rol && m.rol !== “todos” && col.rol !== m.rol) continue;
if (obtenerTurno(col.id, f) === m.turno) cnt++;
}
if (cnt < m.cantidad) violaciones.push({ turno: m.turno, cantidad: m.cantidad, actual: cnt, rol: m.rol });
}
r[f] = { conteo, violaciones };
}
return r;
}, [año, mes, diasDelMes, colaboradores, minimos]);
const celdas = [];
for (let i = 0; i < diaSemanaInicio; i++) celdas.push();
for (let d = 1; d <= diasDelMes; d++) {
const f = año + “-” + String(mes + 1).padStart(2, “0”) + “-” + String(d).padStart(2, “0”);
const datos = resumen[f] || { conteo: {}, violaciones: [] };
const esHoy = f === hoyStr;
const tieneAlerta = datos.violaciones.length > 0;
let claseFondo = “border-gray-200 bg-white hover:bg-gray-50 active:bg-gray-100”;
if (esHoy) claseFondo = “border-indigo-400 bg-indigo-50”;
else if (tieneAlerta) claseFondo = “border-red-300 bg-red-50”;
const claseNumero = esHoy ? “text-indigo-700” : “text-gray-500”;
```
celdas.push(
setDiaAbierto(f)} className={"min-h-[60px] sm:min-h-[80px] p-1.5 rounded-lg cursor-pointer border transition hover:shadow-md " + claseFondo}>
{d}
{tieneAlerta && ⚠}
{Object.keys(datos.conteo).map((t) => (
))}
);
```
}
return (
{MESES[mes]} {año}
{DIAS_CORTOS.map((d) =>
{d}
)}
{celdas}
{Object.keys(TURNOS).map((k) => (
{TURNOS[k].label}
))}
⚠ Mínimo incumplido
{diaAbierto && (
setDiaAbierto(null)}
/>
)}
);
}
function ModalDia({ fecha, colaboradores, equipos, asignaciones, setAsignaciones, violaciones, obtenerTurno, onClose }) {
const [busqueda, setBusqueda] = useState(””);
const [filtroEq, setFiltroEq] = useState(””);
const d = strToFecha(fecha);
function cambiarTurno(colId, turno) {
const clave = colId + “_” + fecha;
const nuevo = { …asignaciones };
if (turno) nuevo[clave] = turno;
else delete nuevo[clave];
setAsignaciones(nuevo);
}
const lista = colaboradores.filter((c) => {
if (busqueda && !c.nombre.toLowerCase().includes(busqueda.toLowerCase())) return false;
if (filtroEq && c.eqId !== filtroEq) return false;
return true;
});
const titulo = DIAS_LARGOS[d.getDay()] + “ “ + d.getDate() + “ “ + MESES[d.getMonth()] + “ “ + d.getFullYear();
return (
{violaciones.length > 0 && (
⚠️ {violaciones.map((v) => {
const rolStr = v.rol !== “todos” ? “ (” + v.rol + “)” : “”;
return TURNOS[v.turno].label + “: “ + v.actual + “/” + v.cantidad + rolStr;
}).join(” · “)}
)}
setBusqueda(e.target.value)} placeholder=“Buscar…” className=“border border-gray-300 rounded-lg px-2 py-2 text-sm flex-1” />
{lista.map((col) => {
const turno = obtenerTurno(col.id, fecha);
const tieneOverride = !!asignaciones[col.id + “_” + fecha];
const eq = equipos.find((e) => e.id === col.eqId);
const claseFondo = tieneOverride ? “bg-amber-50 border border-amber-200” : “hover:bg-gray-50 border border-transparent”;
const subtexto = [col.rol, eq ? eq.nombre : null].filter(Boolean).join(” · “);
return (
{col.nombre.slice(0, 2).toUpperCase()}
cambiarTurno(col.id, t)} />
{tieneOverride && (
)}
);
})}
{lista.length === 0 &&
Sin colaboradores
}
);
}
function TabColaboradores({ colaboradores, setColaboradores, equipos, ciclos, aplicaciones, setAplicaciones }) {
const [busqueda, setBusqueda] = useState(””);
const [filtroEq, setFiltroEq] = useState(””);
const [filtroRol, setFiltroRol] = useState(””);
const [formAbierto, setFormAbierto] = useState(false);
const [editando, setEditando] = useState(null);
const [cicloPara, setCicloPara] = useState(null);
const [bulkAbierto, setBulkAbierto] = useState(false);
const roles = […new Set(colaboradores.map((c) => c.rol).filter(Boolean))];
const lista = colaboradores.filter((c) => {
if (busqueda && !c.nombre.toLowerCase().includes(busqueda.toLowerCase())) return false;
if (filtroEq && c.eqId !== filtroEq) return false;
if (filtroRol && c.rol !== filtroRol) return false;
return true;
});
function eliminar(id) {
if (!confirm(”¿Eliminar colaborador?”)) return;
setColaboradores(colaboradores.filter((c) => c.id !== id));
setAplicaciones(aplicaciones.filter((a) => a.colId !== id));
}
return (
👥 Colaboradores ({colaboradores.length})
setBulkAbierto(true)}>📋 Importar
{ setEditando(null); setFormAbierto(true); }}>+ Agregar
setBusqueda(e.target.value)} placeholder=“Buscar…” className=“border border-gray-300 rounded-lg px-3 py-2 text-sm flex-1 min-w-32” />
{lista.map((col) => {
const eq = equipos.find((e) => e.id === col.eqId);
const apsCol = aplicaciones.filter((a) => a.colId === col.id).sort((a, b) => b.inicio.localeCompare(a.inicio));
const ap = apsCol[0];
const ciclo = ap ? ciclos.find((c) => c.id === ap.cicloId) : null;
return (
{col.nombre.slice(0, 2).toUpperCase()}
{col.nombre}
{col.rol && {col.rol}}
{eq && {eq.nombre}}
{ciclo && 🔄 {ciclo.nombre}}
setCicloPara(col.id)}>Ciclo
{ setEditando(col); setFormAbierto(true); }}>✏️
eliminar(col.id)}>🗑
);
})}
{lista.length === 0 &&
Sin resultados
}
{formAbierto && (
setFormAbierto(false)} />
)}
{cicloPara && (
setCicloPara(null)} />
)}
{bulkAbierto && (
setBulkAbierto(false)} />
)}
);
}
function FormColaborador({ item, colaboradores, setColaboradores, equipos, onClose }) {
const [nombre, setNombre] = useState(item ? item.nombre : “”);
const [rol, setRol] = useState(item ? item.rol : “”);
const [eqId, setEqId] = useState(item ? item.eqId : “”);
function guardar() {
if (!nombre.trim()) return;
if (item) {
setColaboradores(colaboradores.map((c) => c.id === item.id ? { …c, nombre, rol, eqId } : c));
} else {
setColaboradores([…colaboradores, { id: nuevoId(), nombre, rol, eqId }]);
}
onClose();
}
return (
);
}
function FormImportar({ colaboradores, setColaboradores, equipos, onClose }) {
const [texto, setTexto] = useState(””);
const [rol, setRol] = useState(””);
const [eqId, setEqId] = useState(””);
const nombres = texto.split(”\n”).map((s) => s.trim()).filter((s) => s.length > 0);
function guardar() {
if (nombres.length === 0) return;
setColaboradores([…colaboradores, …nombres.map((n) => ({ id: nuevoId(), nombre: n, rol, eqId }))]);
onClose();
}
return (
Pega un nombre por línea. El rol y equipo se aplican a todos.
setRol(e.target.value)} className={ESTILO_INPUT} placeholder=“Ej: Enfermero” />
Se agregarán {nombres.length} colaborador(es)
Importar
Cancelar
);
}
function FormAsignarCiclo({ colId, colaboradores, ciclos, aplicaciones, setAplicaciones, onClose }) {
const col = colaboradores.find((c) => c.id === colId);
const [cicloId, setCicloId] = useState(””);
const [inicio, setInicio] = useState(fechaToStr(new Date()));
function aplicar() {
if (!cicloId) return;
setAplicaciones([…aplicaciones, { id: nuevoId(), colId, cicloId, inicio }]);
onClose();
}
return (
setInicio(e.target.value)} className={ESTILO_INPUT} />
{ciclos.length === 0 &&
Primero crea un ciclo en la pestaña 🔄 Ciclos.
}
Aplicar
Cancelar
);
}
function TabEquipos({ colaboradores, setColaboradores, equipos, setEquipos, ciclos, aplicaciones, setAplicaciones }) {
const [formAbierto, setFormAbierto] = useState(false);
const [editando, setEditando] = useState(null);
const [aplicarA, setAplicarA] = useState(null);
function eliminar(id) {
if (!confirm(”¿Eliminar equipo?”)) return;
setEquipos(equipos.filter((e) => e.id !== id));
setColaboradores(colaboradores.map((c) => c.eqId === id ? { …c, eqId: “” } : c));
}
return (
🏷️ Equipos
{ setEditando(null); setFormAbierto(true); }}>+ Crear equipo
{equipos.map((eq) => {
const miembros = colaboradores.filter((c) => c.eqId === eq.id);
const ciclo = ciclos.find((c) => c.id === eq.cicloId);
return (
{eq.nombre}
{miembros.length} miembro(s)
{ciclo && 🔄 {ciclo.nombre}}
setAplicarA(eq.id)}>Aplicar ciclo
{ setEditando(eq); setFormAbierto(true); }}>✏️
eliminar(eq.id)}>🗑
{miembros.length > 0 && (
{miembros.slice(0, 10).map((m) => (
{m.nombre}
))}
{miembros.length > 10 && +{miembros.length - 10} más}
)}
);
})}
{equipos.length === 0 &&
Sin equipos
}
{formAbierto && (
setFormAbierto(false)} />
)}
{aplicarA && (
setAplicarA(null)} />
)}
);
}
function FormEquipo({ item, equipos, setEquipos, ciclos, onClose }) {
const [nombre, setNombre] = useState(item ? item.nombre : “”);
const [cicloId, setCicloId] = useState(item ? item.cicloId : “”);
function guardar() {
if (!nombre.trim()) return;
if (item) {
setEquipos(equipos.map((e) => e.id === item.id ? { …e, nombre, cicloId } : e));
} else {
setEquipos([…equipos, { id: nuevoId(), nombre, cicloId }]);
}
onClose();
}
return (
);
}
function FormAplicarCicloEquipo({ eqId, colaboradores, equipos, ciclos, aplicaciones, setAplicaciones, onClose }) {
const eq = equipos.find((e) => e.id === eqId);
const miembros = colaboradores.filter((c) => c.eqId === eqId);
const [cicloId, setCicloId] = useState(eq && eq.cicloId ? eq.cicloId : “”);
const [inicio, setInicio] = useState(fechaToStr(new Date()));
function aplicar() {
if (!cicloId || miembros.length === 0) return;
setAplicaciones([…aplicaciones, …miembros.map((m) => ({ id: nuevoId(), colId: m.id, cicloId, inicio }))]);
onClose();
}
return (
Se aplicará a {miembros.length} miembro(s). Los cambios individuales del calendario se conservan.
setInicio(e.target.value)} className={ESTILO_INPUT} />
{miembros.length === 0 &&
Este equipo no tiene miembros todavía.
}
Aplicar a {miembros.length} miembro(s)
Cancelar
);
}
function TabCiclos({ ciclos, setCiclos }) {
const [formAbierto, setFormAbierto] = useState(false);
const [editando, setEditando] = useState(null);
function eliminar(id) {
if (!confirm(”¿Eliminar ciclo?”)) return;
setCiclos(ciclos.filter((c) => c.id !== id));
}
return (
🔄 Ciclos
{ setEditando(null); setFormAbierto(true); }}>+ Crear ciclo
{ciclos.map((ciclo) => {
const filas = [];
for (let w = 0; w < ciclo.semanas; w++) {
const celdas = [];
for (let i = 0; i < 7; i++) {
const t = ciclo.dias[w * 7 + i];
const estilo = t ? { background: TURNOS[t].color + “25”, color: TURNOS[t].color } : { background: “#f3f4f6”, color: “#aaa” };
celdas.push(
{t ? TURNOS[t].label.slice(0, 3) : “–”}
);
}
filas.push(
{celdas}
);
}
return (
{ciclo.nombre}
{ciclo.semanas} semana(s) · {ciclo.semanas * 7} días
{ setEditando(ciclo); setFormAbierto(true); }}>✏️
eliminar(ciclo.id)}>🗑
{DIAS_CORTOS.map((d) =>
{d}
)}
{filas}
);
})}
{ciclos.length === 0 &&
Sin ciclos. Crea uno para comenzar.
}
{formAbierto && (
setFormAbierto(false)} />
)}
);
}
function FormCiclo({ item, ciclos, setCiclos, onClose }) {
const [nombre, setNombre] = useState(item ? item.nombre : “”);
const [semanas, setSemanas] = useState(item ? item.semanas : 1);
const [dias, setDias] = useState(() => item ? item.dias.slice() : Array(7).fill(null));
function cambiarSemanas(n) {
setSemanas(n);
setDias((prev) => {
const nuevo = Array(n * 7).fill(null);
const max = Math.min(prev.length, nuevo.length);
for (let i = 0; i < max; i++) nuevo[i] = prev[i];
return nuevo;
});
}
function cambiarDia(idx, turno) {
setDias((prev) => {
const nuevo = prev.slice();
nuevo[idx] = turno || null;
return nuevo;
});
}
function guardar() {
if (!nombre.trim()) return;
const cyc = { id: item ? item.id : nuevoId(), nombre, semanas, dias };
if (item) setCiclos(ciclos.map((c) => c.id === item.id ? cyc : c));
else setCiclos([…ciclos, cyc]);
onClose();
}
const filasEditor = [];
for (let w = 0; w < semanas; w++) {
const selectores = [];
for (let i = 0; i < 7; i++) {
const idx = w * 7 + i;
const t = dias[idx];
const estilo = t ? { background: TURNOS[t].color + “22”, borderColor: TURNOS[t].color, color: TURNOS[t].color } : {};
selectores.push(
);
}
filasEditor.push(
{semanas > 1 &&
Semana {w + 1}
}
{selectores}
);
}
return (
Turno de cada día del patrón:
{DIAS_CORTOS.map((d) =>
{d}
)}
{filasEditor}
{item ? “Guardar cambios” : “Crear ciclo”}
Cancelar
);
}
function TabMinimos({ minimos, setMinimos, colaboradores }) {
const [formAbierto, setFormAbierto] = useState(false);
const [editando, setEditando] = useState(null);
const roles = […new Set(colaboradores.map((c) => c.rol).filter(Boolean))];
return (
⚠️ Mínimos de personal
{ setEditando(null); setFormAbierto(true); }}>+ Agregar regla
Configura el mínimo de trabajadores por turno, día y rol. Los días que no cumplan se mostrarán en rojo en el calendario.
{minimos.map((m) => {
const diaTexto = m.dia === “todos” ? “Todos los días” : DIAS_LARGOS[m.dia];
const rolTexto = m.rol && m.rol !== “todos” ? “Rol: “ + m.rol : “Todos los roles”;
return (
Mínimo: {m.cantidad} persona(s)
{diaTexto} · {rolTexto}
{ setEditando(m); setFormAbierto(true); }}>✏️
setMinimos(minimos.filter((x) => x.id !== m.id))}>🗑
);
})}
{minimos.length === 0 &&
Sin reglas configuradas
}
{formAbierto && (
setFormAbierto(false)} />
)}
);
}
function FormMinimo({ item, minimos, setMinimos, roles, onClose }) {
const [turno, setTurno] = useState(item ? item.turno : “mañana”);
const [cantidad, setCantidad] = useState(item ? item.cantidad : 1);
const [dia, setDia] = useState(item && item.dia !== undefined ? item.dia : “todos”);
const [rol, setRol] = useState(item ? item.rol : “todos”);
function guardar() {
const nuevoMin = { id: item ? item.id : nuevoId(), turno, cantidad: Number(cantidad), dia, rol };
if (item) setMinimos(minimos.map((x) => x.id === item.id ? nuevoMin : x));
else setMinimos([…minimos, nuevoMin]);
onClose();
}
return (
);
}