Añadir calendario.html
This commit is contained in:
401
calendario.html
Normal file
401
calendario.html
Normal file
@@ -0,0 +1,401 @@
|
||||
<!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;
|
||||
});
|
||||
|
||||
const events = filtered.map(s => ({
|
||||
id: s.id,
|
||||
title: `${s.contact_name} ${s.assigned_name ? '('+s.assigned_name.split(' ')[0]+')' : ''}`,
|
||||
start: s.scheduled_date + (s.scheduled_time ? 'T' + s.scheduled_time : ''),
|
||||
backgroundColor: colorMap[s.status_color] || '#6b7280',
|
||||
borderColor: colorMap[s.status_color] || '#6b7280',
|
||||
extendedProps: { ...s } // Guardamos todo el objeto servicio
|
||||
}));
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user