Files
web/calendario.html
2026-02-11 08:36:09 +00:00

412 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendario - IntegraRepara</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js'></script>
<style>
.fade-in { animation: fadeIn 0.3s ease-in-out; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* Personalización de FullCalendar para que parezca Tailwind */
:root { --fc-border-color: #e5e7eb; --fc-button-text-color: #374151; --fc-button-bg-color: #ffffff; --fc-button-border-color: #d1d5db; --fc-button-hover-bg-color: #f3f4f6; --fc-button-hover-border-color: #d1d5db; --fc-button-active-bg-color: #e5e7eb; --fc-button-active-border-color: #d1d5db; --fc-event-bg-color: #3b82f6; --fc-event-border-color: #3b82f6; --fc-today-bg-color: #eff6ff; }
.fc .fc-toolbar-title { font-size: 1.25rem; font-weight: 700; color: #1f2937; }
.fc .fc-col-header-cell-cushion { padding: 8px; font-size: 0.875rem; font-weight: 600; color: #4b5563; }
.fc-event { cursor: pointer; border-radius: 4px; border: none; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); transition: transform 0.1s; }
.fc-event:hover { transform: scale(1.02); }
.fc-daygrid-event { font-size: 0.75rem; font-weight: 500; padding: 2px 4px; }
.fc .fc-button { padding: 0.4rem 0.8rem; font-weight: 600; text-transform: capitalize; border-radius: 0.5rem; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); }
.fc .fc-button-primary:not(:disabled).fc-button-active { background-color: #1e40af; color: white; border-color: #1e40af; }
</style>
</head>
<body class="bg-gray-50 text-gray-800 font-sans h-screen overflow-hidden flex">
<div id="sidebar-container" class="h-full shrink-0"></div>
<div class="flex-1 flex flex-col h-full relative min-w-0">
<div id="header-container"></div>
<main class="flex-1 overflow-hidden flex flex-col relative">
<div class="bg-white border-b border-gray-200 p-4 shrink-0 z-10 shadow-sm">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-4">
<div>
<h2 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
<i data-lucide="calendar-days" class="text-blue-600"></i> Calendario de Servicios
</h2>
<p class="text-xs text-gray-500 mt-1" id="calendarSubtitle">Gestiona tu agenda visualmente.</p>
</div>
<div class="flex gap-4 text-sm">
<div class="bg-blue-50 text-blue-700 px-3 py-1 rounded-lg border border-blue-100 flex items-center gap-2">
<i data-lucide="layers" class="w-4 h-4"></i> <span id="countTotal" class="font-bold">0</span> Servicios
</div>
<div class="bg-red-50 text-red-700 px-3 py-1 rounded-lg border border-red-100 flex items-center gap-2">
<i data-lucide="alert-circle" class="w-4 h-4"></i> <span id="countUrgent" class="font-bold">0</span> Urgentes
</div>
</div>
</div>
<div class="flex flex-wrap gap-3 items-center">
<div class="relative group">
<i data-lucide="filter" class="w-4 h-4 absolute left-3 top-2.5 text-gray-400"></i>
<select id="filterStatus" onchange="applyFilters()" class="pl-9 pr-8 py-2 border rounded-lg text-sm bg-gray-50 outline-none focus:ring-2 focus:ring-blue-500 appearance-none cursor-pointer hover:bg-white transition-colors">
<option value="all">Todos los Estados</option>
</select>
</div>
<div class="relative group">
<i data-lucide="user" class="w-4 h-4 absolute left-3 top-2.5 text-gray-400"></i>
<select id="filterOperator" onchange="applyFilters()" class="pl-9 pr-8 py-2 border rounded-lg text-sm bg-gray-50 outline-none focus:ring-2 focus:ring-blue-500 appearance-none cursor-pointer hover:bg-white transition-colors">
<option value="all">Todos los Operarios</option>
</select>
</div>
<button onclick="calendar.refetchEvents()" class="ml-auto text-gray-400 hover:text-blue-600 transition-colors p-2 rounded-full hover:bg-blue-50" title="Actualizar Datos">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 bg-white relative">
<div id='calendar' class="h-full min-h-[600px]"></div>
</div>
</main>
</div>
<div id="eventModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center backdrop-blur-sm">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md p-6 animate-slide-in flex flex-col max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4 border-b pb-2">
<h3 class="text-lg font-bold text-gray-800" id="modalTitle">Nuevo Servicio</h3>
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600"><i data-lucide="x" class="w-5 h-5"></i></button>
</div>
<form id="quickForm" onsubmit="handleQuickSave(event)" class="space-y-4">
<input type="hidden" id="qId">
<div>
<label class="block text-xs font-bold text-gray-500 mb-1">Cliente</label>
<input type="text" id="qName" required class="w-full border p-2 rounded-lg text-sm focus:border-blue-500 outline-none" placeholder="Nombre del cliente">
</div>
<div>
<label class="block text-xs font-bold text-gray-500 mb-1">Teléfono</label>
<input type="tel" id="qPhone" required class="w-full border p-2 rounded-lg text-sm focus:border-blue-500 outline-none" placeholder="600...">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-bold text-gray-500 mb-1">Fecha</label>
<input type="date" id="qDate" required class="w-full border p-2 rounded-lg text-sm">
</div>
<div>
<label class="block text-xs font-bold text-gray-500 mb-1">Hora</label>
<input type="time" id="qTime" class="w-full border p-2 rounded-lg text-sm">
</div>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 mb-1">Dirección</label>
<input type="text" id="qAddress" class="w-full border p-2 rounded-lg text-sm" placeholder="Dirección completa">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-bold text-gray-500 mb-1">Estado</label>
<select id="qStatus" class="w-full border p-2 rounded-lg text-sm bg-white"></select>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 mb-1">Operario</label>
<select id="qOperator" class="w-full border p-2 rounded-lg text-sm bg-white"></select>
</div>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 mb-1">Descripción</label>
<textarea id="qDesc" rows="2" class="w-full border p-2 rounded-lg text-sm" placeholder="Detalles..."></textarea>
</div>
<div class="pt-4 flex gap-2">
<button type="button" id="btnDelete" onclick="deleteEvent()" class="hidden bg-red-50 text-red-600 px-4 py-2 rounded-lg text-sm font-bold border border-red-100 hover:bg-red-100">Borrar</button>
<button type="submit" class="flex-1 bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-bold hover:bg-blue-700 shadow-lg">Guardar</button>
</div>
</form>
</div>
</div>
<div id="toast" class="fixed bottom-5 right-5 bg-slate-800 text-white px-6 py-3 rounded-lg shadow-2xl hidden"><span id="toastMsg"></span></div>
<script src="js/layout.js"></script>
<script>
let calendar;
let allServices = []; // Copia local para filtrar rápido
let statuses = [];
let operators = [];
// Mapeo de colores Tailwind a Hex (FullCalendar necesita Hex para renderizar bien)
const colorMap = {
'gray': '#6b7280', 'red': '#ef4444', 'orange': '#f97316', 'yellow': '#eab308',
'green': '#22c55e', 'teal': '#14b8a6', 'blue': '#3b82f6', 'indigo': '#6366f1',
'purple': '#a855f7', 'pink': '#ec4899'
};
document.addEventListener('DOMContentLoaded', async function() {
if (!localStorage.getItem("token")) window.location.href = "index.html";
await loadMetadata(); // Cargar estados y operarios
initCalendar();
});
async function loadMetadata() {
try {
// Cargar Estados
const resSt = await fetch(`${API_URL}/statuses`, { headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } });
const dataSt = await resSt.json();
statuses = dataSt.statuses;
const selSt = document.getElementById('filterStatus');
const formSt = document.getElementById('qStatus');
statuses.forEach(s => {
selSt.innerHTML += `<option value="${s.id}">${s.name}</option>`;
formSt.innerHTML += `<option value="${s.id}">${s.name}</option>`;
});
// Cargar Operarios
const resOp = await fetch(`${API_URL}/operators`, { headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } });
const dataOp = await resOp.json();
operators = dataOp.operators;
const selOp = document.getElementById('filterOperator');
const formOp = document.getElementById('qOperator');
formOp.innerHTML = '<option value="">-- Sin Asignar --</option>';
operators.forEach(op => {
selOp.innerHTML += `<option value="${op.id}">${op.full_name}</option>`;
formOp.innerHTML += `<option value="${op.id}">${op.full_name}</option>`;
});
} catch(e) { console.error("Error cargando metadatos", e); }
}
function initCalendar() {
var calendarEl = document.getElementById('calendar');
calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
locale: 'es',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
},
buttonText: {
today: 'Hoy', month: 'Mes', week: 'Semana', day: 'Día', list: 'Lista'
},
navLinks: true, // Click en día te lleva a la vista día
editable: true, // Permite arrastrar
dayMaxEvents: true, // "ver más" cuando hay muchos
// --- CARGA DE EVENTOS ---
events: async function(info, successCallback, failureCallback) {
try {
const res = await fetch(`${API_URL}/services`, { headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } });
const data = await res.json();
allServices = data.services; // Guardamos copia
updateDashboard(allServices);
// Aplicar filtros en memoria
const fStatus = document.getElementById('filterStatus').value;
const fOp = document.getElementById('filterOperator').value;
const filtered = allServices.filter(s => {
if(fStatus !== 'all' && s.status_id != fStatus) return false;
if(fOp !== 'all' && s.assigned_to != fOp) return false;
return true;
});
// ... dentro de events: async function ...
const events = filtered.map(s => {
// CORRECCIÓN: Aseguramos que la fecha sea solo YYYY-MM-DD
// Si viene como "2026-02-11T00:00:00.000Z", nos quedamos solo con la primera parte.
const cleanDate = s.scheduled_date ? s.scheduled_date.split('T')[0] : null;
// Si no hay fecha válida, no mostramos el evento para evitar errores
if (!cleanDate) return null;
return {
id: s.id,
title: `${s.contact_name} ${s.assigned_name ? '('+s.assigned_name.split(' ')[0]+')' : ''}`,
// Ahora concatenamos limpio: "2026-02-11" + "T" + "09:00:00"
start: cleanDate + (s.scheduled_time ? 'T' + s.scheduled_time : ''),
backgroundColor: colorMap[s.status_color] || '#6b7280',
borderColor: colorMap[s.status_color] || '#6b7280',
extendedProps: { ...s }
};
}).filter(e => e !== null); // Filtramos los nulos por seguridad
successCallback(events);
} catch(e) { failureCallback(e); }
},
// --- CLICK EN EVENTO (EDITAR) ---
eventClick: function(info) {
openModal(info.event.extendedProps);
},
// --- CLICK EN DÍA VACÍO (CREAR) ---
dateClick: function(info) {
openModal(null, info.dateStr);
},
// --- ARRASTRAR Y SOLTAR (CAMBIAR FECHA) ---
eventDrop: async function(info) {
if(!confirm(`¿Mover servicio de ${info.oldEvent.start.toLocaleDateString()} a ${info.event.start.toLocaleDateString()}?`)) {
info.revert();
return;
}
const s = info.event.extendedProps;
// Preparamos datos para update (solo fecha/hora)
const newDate = info.event.start.toISOString().split('T')[0];
const newTime = info.event.start.toTimeString().split(' ')[0];
try {
await fetch(`${API_URL}/services/${s.id}`, {
method: 'PUT',
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${localStorage.getItem("token")}` },
body: JSON.stringify({
name: s.contact_name, // Backend requiere todos los campos, enviamos los existentes
address: s.address,
email: s.email,
description: s.description,
scheduled_date: newDate,
scheduled_time: newTime,
duration: s.duration_minutes,
is_urgent: s.is_urgent,
is_company: s.is_company,
company_id: s.company_id,
company_ref: s.company_ref,
internal_notes: s.internal_notes,
client_notes: s.client_notes,
guild_id: s.guild_id,
assigned_to: s.assigned_to
})
});
showToast("Fecha actualizada");
} catch(e) {
showToast("Error al mover", true);
info.revert();
}
}
});
calendar.render();
}
function applyFilters() {
calendar.refetchEvents();
}
function updateDashboard(services) {
document.getElementById('countTotal').innerText = services.length;
const urgents = services.filter(s => s.is_urgent).length;
document.getElementById('countUrgent').innerText = urgents;
}
// --- LÓGICA DEL MODAL ---
function openModal(service, dateStr = null) {
const modal = document.getElementById('eventModal');
const form = document.getElementById('quickForm');
const btnDel = document.getElementById('btnDelete');
form.reset();
if (service) {
// MODO EDICIÓN
document.getElementById('modalTitle').innerText = `Editar Servicio #${service.id}`;
document.getElementById('qId').value = service.id;
document.getElementById('qName').value = service.contact_name;
document.getElementById('qPhone').value = service.contact_phone;
document.getElementById('qDate').value = service.scheduled_date.split('T')[0];
document.getElementById('qTime').value = service.scheduled_time;
document.getElementById('qAddress').value = service.address;
document.getElementById('qStatus').value = service.status_id;
document.getElementById('qOperator').value = service.assigned_to || "";
document.getElementById('qDesc').value = service.description || "";
btnDel.classList.remove('hidden');
} else {
// MODO CREACIÓN
document.getElementById('modalTitle').innerText = "Nuevo Servicio Rápido";
document.getElementById('qId').value = "";
if(dateStr) document.getElementById('qDate').value = dateStr;
document.getElementById('qTime').value = "09:00";
btnDel.classList.add('hidden');
}
modal.classList.remove('hidden');
}
function closeModal() {
document.getElementById('eventModal').classList.add('hidden');
}
async function handleQuickSave(e) {
e.preventDefault();
const id = document.getElementById('qId').value;
const isEdit = !!id;
const data = {
name: document.getElementById('qName').value,
phone: document.getElementById('qPhone').value,
address: document.getElementById('qAddress').value,
scheduled_date: document.getElementById('qDate').value,
scheduled_time: document.getElementById('qTime').value,
status_id: document.getElementById('qStatus').value,
assigned_to: document.getElementById('qOperator').value || null,
description: document.getElementById('qDesc').value,
// Defaults para campos no presentes en modal rápido
email: "", duration: 60, is_urgent: false, is_company: false
};
const url = isEdit ? `${API_URL}/services/${id}` : `${API_URL}/services`;
const method = isEdit ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method: method,
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${localStorage.getItem("token")}` },
body: JSON.stringify(data)
});
if(res.ok) {
showToast(isEdit ? "Servicio actualizado" : "Servicio creado");
closeModal();
calendar.refetchEvents();
} else {
showToast("Error al guardar", true);
}
} catch(e) { showToast("Error de conexión", true); }
}
async function deleteEvent() {
const id = document.getElementById('qId').value;
if(!id || !confirm("¿Borrar este servicio?")) return;
try {
await fetch(`${API_URL}/services/${id}`, { method: 'DELETE', headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } });
showToast("Servicio eliminado");
closeModal();
calendar.refetchEvents();
} catch(e) { showToast("Error al borrar", true); }
}
function showToast(msg, isError = false) {
const t = document.getElementById('toast'), m = document.getElementById('toastMsg');
t.className = `fixed bottom-5 right-5 px-6 py-3 rounded-lg shadow-xl transition-all duration-300 z-50 flex items-center gap-3 ${isError ? 'bg-red-600' : 'bg-slate-800'} text-white font-medium`;
m.innerText = msg; t.classList.remove('translate-y-20', 'opacity-0');
setTimeout(() => t.classList.add('translate-y-20', 'opacity-0'), 3000);
}
</script>
</body>
</html>