Actualizar calendario.html
This commit is contained in:
618
calendario.html
618
calendario.html
@@ -3,410 +3,342 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Calendario - IntegraRepara</title>
|
<title>Calendario Inteligente - IntegraReparaPro</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://unpkg.com/lucide@latest"></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>
|
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js'></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.fade-in { animation: fadeIn 0.3s ease-in-out; }
|
.fade-in { animation: fadeIn 0.3s ease-in-out; }
|
||||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
/* Personalización de FullCalendar para que parezca Tailwind */
|
/* CUSTOMIZACIÓN PREMIUM DEL CALENDARIO PARA QUE NO PAREZCA DE LOS AÑOS 90 */
|
||||||
: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 { font-family: inherit; }
|
||||||
.fc .fc-toolbar-title { font-size: 1.25rem; font-weight: 700; color: #1f2937; }
|
.fc-toolbar-title { font-weight: 900 !important; font-size: 1.25rem !important; text-transform: uppercase; color: #1e293b; letter-spacing: -0.025em; }
|
||||||
.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-button-primary {
|
||||||
.fc-event:hover { transform: scale(1.02); }
|
background-color: #f1f5f9 !important;
|
||||||
.fc-daygrid-event { font-size: 0.75rem; font-weight: 500; padding: 2px 4px; }
|
color: #475569 !important;
|
||||||
.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); }
|
border: none !important;
|
||||||
.fc .fc-button-primary:not(:disabled).fc-button-active { background-color: #1e40af; color: white; border-color: #1e40af; }
|
border-radius: 1rem !important;
|
||||||
|
font-weight: 800 !important;
|
||||||
|
text-transform: uppercase !important;
|
||||||
|
font-size: 0.7rem !important;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.6rem 1.2rem !important;
|
||||||
|
transition: all 0.2s !important;
|
||||||
|
margin-left: 0.25rem !important;
|
||||||
|
}
|
||||||
|
.fc-button-primary:hover { background-color: #e2e8f0 !important; color: #0f172a !important; }
|
||||||
|
.fc-button-active { background-color: #2563eb !important; color: #ffffff !important; box-shadow: 0 10px 15px -3px rgba(37,99,235,0.3) !important; }
|
||||||
|
.fc-button-primary:focus { box-shadow: none !important; }
|
||||||
|
|
||||||
|
.fc-theme-standard td, .fc-theme-standard th { border-color: #f1f5f9 !important; }
|
||||||
|
.fc-col-header-cell-cushion { padding: 12px 0 !important; font-size: 0.75rem; text-transform: uppercase; font-weight: 900; color: #64748b; }
|
||||||
|
.fc-timegrid-slot-label-cushion { font-size: 0.7rem; font-weight: 800; color: #94a3b8; }
|
||||||
|
|
||||||
|
.fc-event {
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 0.75rem !important;
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fc-event:hover { transform: scale(1.02); z-index: 50 !important; }
|
||||||
|
.fc-v-event .fc-event-main-frame { padding: 4px; }
|
||||||
|
|
||||||
|
.fc-today-button { background-color: #0f172a !important; color: white !important; }
|
||||||
|
.fc-timegrid-col.fc-day-today { background-color: #f8fafc !important; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 text-gray-800 font-sans h-screen overflow-hidden flex">
|
<body class="bg-gray-50 text-gray-800 font-sans antialiased overflow-hidden text-left">
|
||||||
|
|
||||||
<div id="sidebar-container" class="h-full shrink-0"></div>
|
<div class="flex h-screen overflow-hidden text-left">
|
||||||
|
<div id="sidebar-container" class="h-full shrink-0"></div>
|
||||||
|
|
||||||
<div class="flex-1 flex flex-col h-full relative min-w-0">
|
<div class="flex-1 flex flex-col overflow-hidden relative">
|
||||||
<div id="header-container"></div>
|
<div id="header-container"></div>
|
||||||
|
|
||||||
<main class="flex-1 overflow-hidden flex flex-col relative">
|
<main class="flex-1 overflow-x-hidden overflow-y-auto bg-gray-50 p-6 relative">
|
||||||
|
<div class="fade-in max-w-7xl mx-auto flex flex-col h-full gap-6">
|
||||||
|
|
||||||
<div class="bg-white border-b border-gray-200 p-4 shrink-0 z-10 shadow-sm">
|
<div class="bg-white p-6 rounded-[2rem] shadow-sm border border-slate-100 flex flex-col xl:flex-row justify-between items-center gap-6">
|
||||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-4">
|
<div>
|
||||||
<div>
|
<h2 class="text-2xl font-black text-slate-800 flex items-center gap-3 tracking-tight">
|
||||||
<h2 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
|
<span class="bg-blue-600 p-2.5 rounded-xl text-white shadow-lg shadow-blue-200"><i data-lucide="calendar-days"></i></span>
|
||||||
<i data-lucide="calendar-days" class="text-blue-600"></i> Calendario de Servicios
|
Calendario Inteligente
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-xs text-gray-500 mt-1" id="calendarSubtitle">Gestiona tu agenda visualmente.</p>
|
<p class="text-sm text-slate-500 mt-1 font-medium">Control de agendas, rutas y operarios citados.</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>
|
||||||
<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 class="flex flex-wrap gap-3 items-center w-full xl:w-auto">
|
||||||
|
<div class="relative flex-1 xl:w-64">
|
||||||
|
<i data-lucide="search" class="w-4 h-4 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400"></i>
|
||||||
|
<input type="text" id="searchFilter" oninput="applyFilters()" placeholder="Buscar cliente, REF..." class="w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-500 outline-none transition-all">
|
||||||
|
</div>
|
||||||
|
<select id="opFilter" onchange="applyFilters()" class="bg-slate-50 border border-slate-200 text-xs font-black px-4 py-3 rounded-2xl outline-none focus:ring-2 focus:ring-blue-500 uppercase tracking-widest text-slate-600">
|
||||||
|
<option value="ALL">TODOS LOS OPERARIOS</option>
|
||||||
|
</select>
|
||||||
|
<select id="compFilter" onchange="applyFilters()" class="bg-slate-50 border border-slate-200 text-xs font-black px-4 py-3 rounded-2xl outline-none focus:ring-2 focus:ring-blue-500 uppercase tracking-widest text-slate-600">
|
||||||
|
<option value="ALL">TODAS LAS COMPAÑÍAS</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3 items-center">
|
<div class="bg-white p-6 rounded-[2.5rem] shadow-xl shadow-slate-200/40 border border-slate-100 flex-1 min-h-[700px] overflow-hidden">
|
||||||
<div class="relative group">
|
<div id="calendar" class="h-full"></div>
|
||||||
<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>
|
||||||
|
|
||||||
<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>
|
</main>
|
||||||
|
|
||||||
<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>
|
</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>
|
<div id="eventModal" class="fixed inset-0 bg-slate-900/80 hidden z-[150] flex items-center justify-center backdrop-blur-sm p-4 fade-in text-left">
|
||||||
|
<div class="bg-white rounded-[2.5rem] shadow-2xl w-full max-w-md overflow-hidden flex flex-col border border-slate-100">
|
||||||
|
<div id="emHeader" class="p-6 border-b flex justify-between items-center text-white">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="bg-white/20 p-2 rounded-xl backdrop-blur-md"><i data-lucide="wrench" class="w-5 h-5 text-white"></i></div>
|
||||||
|
<div>
|
||||||
|
<p class="text-[9px] font-black uppercase tracking-widest opacity-80" id="emStatusLabel">ESTADO</p>
|
||||||
|
<h3 class="font-black text-lg leading-tight uppercase tracking-tight" id="emRef">#REF</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeEventModal()" class="text-white/70 hover:text-white bg-white/10 p-2 rounded-full transition-all"><i data-lucide="x" class="w-5 h-5"></i></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-8 space-y-6 bg-slate-50 overflow-y-auto max-h-[70vh] no-scrollbar">
|
||||||
|
<div>
|
||||||
|
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1">Asegurado / Cliente</p>
|
||||||
|
<p class="font-black text-slate-800 text-xl uppercase leading-none" id="emName"></p>
|
||||||
|
<a href="#" id="emPhoneLink" class="inline-flex items-center gap-1.5 mt-2 bg-slate-200/50 hover:bg-emerald-100 text-emerald-700 px-3 py-1.5 rounded-lg text-xs font-black transition-all">
|
||||||
|
<i data-lucide="phone" class="w-3.5 h-3.5"></i> <span id="emPhone"></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="bg-white p-4 rounded-2xl border border-slate-100 shadow-sm">
|
||||||
|
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1"><i data-lucide="hard-hat" class="w-3 h-3 inline mr-1"></i>Operario</p>
|
||||||
|
<p class="font-bold text-slate-700 text-xs uppercase" id="emOperator"></p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white p-4 rounded-2xl border border-slate-100 shadow-sm">
|
||||||
|
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1"><i data-lucide="building-2" class="w-3 h-3 inline mr-1"></i>Compañía</p>
|
||||||
|
<p class="font-bold text-slate-700 text-xs uppercase" id="emCompany"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white p-4 rounded-2xl border border-slate-100 shadow-sm">
|
||||||
|
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1"><i data-lucide="map-pin" class="w-3 h-3 inline mr-1"></i>Dirección Exacta</p>
|
||||||
|
<p class="font-bold text-slate-600 text-sm uppercase leading-snug" id="emAddress"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-2 ml-2">Descripción de la Avería</p>
|
||||||
|
<div class="bg-amber-50 border border-amber-100 p-4 rounded-2xl text-xs font-medium text-slate-700 leading-relaxed max-h-32 overflow-y-auto no-scrollbar" id="emDesc"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="js/layout.js"></script>
|
<script src="js/layout.js"></script>
|
||||||
<script>
|
<script>
|
||||||
let calendar;
|
let rawServices = [];
|
||||||
let allServices = []; // Copia local para filtrar rápido
|
let calendarEvents = [];
|
||||||
let statuses = [];
|
let calendarInstance = null;
|
||||||
let operators = [];
|
|
||||||
|
|
||||||
// Mapeo de colores Tailwind a Hex (FullCalendar necesita Hex para renderizar bien)
|
// Diccionario de colores según estado_operativo
|
||||||
const colorMap = {
|
const statusConfig = {
|
||||||
'gray': '#6b7280', 'red': '#ef4444', 'orange': '#f97316', 'yellow': '#eab308',
|
'citado': { bg: '#3b82f6', border: '#2563eb', label: 'Citado / Agendado' }, // Azul
|
||||||
'green': '#22c55e', 'teal': '#14b8a6', 'blue': '#3b82f6', 'indigo': '#6366f1',
|
'de_camino': { bg: '#8b5cf6', border: '#7c3aed', label: 'De Camino' }, // Morado
|
||||||
'purple': '#a855f7', 'pink': '#ec4899'
|
'trabajando': { bg: '#f59e0b', border: '#d97706', label: 'Trabajando en lugar' }, // Naranja
|
||||||
|
'incidencia': { bg: '#ef4444', border: '#dc2626', label: 'Pausado / Incidencia' }, // Rojo
|
||||||
|
'terminado': { bg: '#10b981', border: '#059669', label: 'Trabajo Terminado' } // Verde
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async function() {
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
if (!localStorage.getItem("token")) window.location.href = "index.html";
|
if (!localStorage.getItem("token")) window.location.href = "index.html";
|
||||||
|
lucide.createIcons();
|
||||||
await loadMetadata(); // Cargar estados y operarios
|
await loadOperators();
|
||||||
initCalendar();
|
await loadServices();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadMetadata() {
|
async function loadOperators() {
|
||||||
try {
|
try {
|
||||||
// Cargar Estados
|
const res = await fetch(`${API_URL}/operators`, { headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } });
|
||||||
const resSt = await fetch(`${API_URL}/statuses`, { headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } });
|
const data = await res.json();
|
||||||
const dataSt = await resSt.json();
|
if(data.ok) {
|
||||||
statuses = dataSt.statuses;
|
const sel = document.getElementById('opFilter');
|
||||||
const selSt = document.getElementById('filterStatus');
|
data.operators.forEach(op => sel.innerHTML += `<option value="${op.full_name}">${op.full_name}</option>`);
|
||||||
const formSt = document.getElementById('qStatus');
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
statuses.forEach(s => {
|
async function loadServices() {
|
||||||
selSt.innerHTML += `<option value="${s.id}">${s.name}</option>`;
|
try {
|
||||||
formSt.innerHTML += `<option value="${s.id}">${s.name}</option>`;
|
// Usamos la ruta /services/active que nos trae la info operativa (con assigned_name y estado_operativo)
|
||||||
});
|
const res = await fetch(`${API_URL}/services/active`, { headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
// Cargar Operarios
|
if (data.ok) {
|
||||||
const resOp = await fetch(`${API_URL}/operators`, { headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } });
|
// 1. Filtrar solo los que están "CITADOS" (tienen fecha de visita guardada en raw_data)
|
||||||
const dataOp = await resOp.json();
|
rawServices = data.services.filter(s => s.raw_data && s.raw_data.scheduled_date && s.raw_data.scheduled_date !== "");
|
||||||
operators = dataOp.operators;
|
|
||||||
const selOp = document.getElementById('filterOperator');
|
|
||||||
const formOp = document.getElementById('qOperator');
|
|
||||||
|
|
||||||
formOp.innerHTML = '<option value="">-- Sin Asignar --</option>';
|
// 2. Extraer compañías únicas para el filtro
|
||||||
operators.forEach(op => {
|
const compSelect = document.getElementById('compFilter');
|
||||||
selOp.innerHTML += `<option value="${op.id}">${op.full_name}</option>`;
|
const uniqueComps = [...new Set(rawServices.map(s => (s.raw_data['Compañía'] || s.raw_data['COMPAÑIA'] || "Particular").toString().toUpperCase()))].sort();
|
||||||
formOp.innerHTML += `<option value="${op.id}">${op.full_name}</option>`;
|
uniqueComps.forEach(c => compSelect.innerHTML += `<option value="${c}">${c}</option>`);
|
||||||
});
|
|
||||||
|
|
||||||
} catch(e) { console.error("Error cargando metadatos", e); }
|
// 3. Formatear para FullCalendar y renderizar
|
||||||
|
processEventsAndRender();
|
||||||
|
}
|
||||||
|
} catch (e) { console.error("Error al cargar calendario", e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function processEventsAndRender() {
|
||||||
|
calendarEvents = rawServices.map(svc => {
|
||||||
|
const r = svc.raw_data;
|
||||||
|
const name = r['Nombre Cliente'] || r['CLIENTE'] || "Sin Nombre";
|
||||||
|
const pop = r['Población'] || r['POBLACION-PROVINCIA'] || "";
|
||||||
|
const title = `${name} - ${pop}`;
|
||||||
|
|
||||||
|
// Construir formato de fecha ISO 8601 (YYYY-MM-DDTHH:mm:00)
|
||||||
|
const dateIso = r.scheduled_date; // Suele venir en YYYY-MM-DD del input type="date"
|
||||||
|
const timeIso = r.scheduled_time ? r.scheduled_time + ':00' : '09:00:00';
|
||||||
|
const startStr = `${dateIso}T${timeIso}`;
|
||||||
|
|
||||||
|
// Configurar color según estado
|
||||||
|
const status = r.status_operativo || 'citado';
|
||||||
|
const sConf = statusConfig[status] || statusConfig['citado'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: svc.id,
|
||||||
|
title: title,
|
||||||
|
start: startStr,
|
||||||
|
backgroundColor: sConf.bg,
|
||||||
|
borderColor: sConf.border,
|
||||||
|
extendedProps: {
|
||||||
|
originalData: svc,
|
||||||
|
companyName: (r['Compañía'] || r['COMPAÑIA'] || "Particular").toUpperCase(),
|
||||||
|
operatorName: svc.assigned_name || "Sin Asignar",
|
||||||
|
statusConf: sConf
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
initCalendar();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initCalendar() {
|
function initCalendar() {
|
||||||
var calendarEl = document.getElementById('calendar');
|
const calendarEl = document.getElementById('calendar');
|
||||||
calendar = new FullCalendar.Calendar(calendarEl, {
|
|
||||||
initialView: 'dayGridMonth',
|
calendarInstance = new FullCalendar.Calendar(calendarEl, {
|
||||||
|
initialView: 'timeGridWeek', // Vista por defecto semanal con horas
|
||||||
locale: 'es',
|
locale: 'es',
|
||||||
|
firstDay: 1, // La semana empieza en lunes
|
||||||
|
slotMinTime: '08:00:00', // El calendario empieza a las 8 AM
|
||||||
|
slotMaxTime: '22:00:00', // Termina a las 10 PM
|
||||||
|
expandRows: true,
|
||||||
|
allDaySlot: false,
|
||||||
|
nowIndicator: true,
|
||||||
|
dayMaxEvents: true, // "Ver más" si hay demasiados en vista mensual
|
||||||
|
buttonText: {
|
||||||
|
today: 'Hoy',
|
||||||
|
month: 'Mes',
|
||||||
|
week: 'Semana',
|
||||||
|
day: 'Día',
|
||||||
|
list: 'Agenda'
|
||||||
|
},
|
||||||
headerToolbar: {
|
headerToolbar: {
|
||||||
left: 'prev,next today',
|
left: 'prev,next today',
|
||||||
center: 'title',
|
center: 'title',
|
||||||
right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
|
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
|
||||||
},
|
},
|
||||||
buttonText: {
|
events: calendarEvents,
|
||||||
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 ---
|
// Diseño HTML interno de cada cajita del calendario
|
||||||
events: async function(info, successCallback, failureCallback) {
|
eventContent: function(arg) {
|
||||||
try {
|
const timeText = arg.timeText;
|
||||||
const res = await fetch(`${API_URL}/services`, { headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } });
|
const title = arg.event.title;
|
||||||
const data = await res.json();
|
const op = arg.event.extendedProps.operatorName;
|
||||||
allServices = data.services; // Guardamos copia
|
|
||||||
updateDashboard(allServices);
|
|
||||||
|
|
||||||
// Aplicar filtros en memoria
|
return {
|
||||||
const fStatus = document.getElementById('filterStatus').value;
|
html: `
|
||||||
const fOp = document.getElementById('filterOperator').value;
|
<div class="flex flex-col h-full justify-start p-1.5 text-white overflow-hidden">
|
||||||
|
<div class="flex justify-between items-start mb-1">
|
||||||
const filtered = allServices.filter(s => {
|
<span class="text-[10px] font-black bg-white/20 px-1.5 rounded-md leading-tight">${timeText}</span>
|
||||||
if(fStatus !== 'all' && s.status_id != fStatus) return false;
|
</div>
|
||||||
if(fOp !== 'all' && s.assigned_to != fOp) return false;
|
<p class="text-xs font-black leading-tight tracking-tight mb-1 truncate">${title}</p>
|
||||||
return true;
|
<p class="text-[9px] font-bold opacity-90 truncate"><i data-lucide="user" class="w-2.5 h-2.5 inline mr-0.5"></i>${op}</p>
|
||||||
});
|
</div>`
|
||||||
|
};
|
||||||
// ... 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) ---
|
// Al hacer clic en una cita
|
||||||
eventClick: function(info) {
|
eventClick: function(info) {
|
||||||
openModal(info.event.extendedProps);
|
openEventDetails(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();
|
|
||||||
|
calendarInstance.render();
|
||||||
|
// Recargar iconos Lucide después de renderizar
|
||||||
|
setTimeout(() => lucide.createIcons(), 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
calendar.refetchEvents();
|
const textFilter = document.getElementById('searchFilter').value.toUpperCase();
|
||||||
|
const opFilter = document.getElementById('opFilter').value;
|
||||||
|
const compFilter = document.getElementById('compFilter').value;
|
||||||
|
|
||||||
|
const filteredEvents = calendarEvents.filter(ev => {
|
||||||
|
const props = ev.extendedProps;
|
||||||
|
const matchText = ev.title.toUpperCase().includes(textFilter) || props.originalData.service_ref.toUpperCase().includes(textFilter);
|
||||||
|
const matchOp = opFilter === 'ALL' || props.operatorName === opFilter;
|
||||||
|
const matchComp = compFilter === 'ALL' || props.companyName === compFilter;
|
||||||
|
|
||||||
|
return matchText && matchOp && matchComp;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reemplazar eventos en el calendario de forma dinámica
|
||||||
|
calendarInstance.removeAllEventSources();
|
||||||
|
calendarInstance.addEventSource(filteredEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDashboard(services) {
|
function openEventDetails(props) {
|
||||||
document.getElementById('countTotal').innerText = services.length;
|
const raw = props.originalData.raw_data;
|
||||||
const urgents = services.filter(s => s.is_urgent).length;
|
const ref = props.originalData.service_ref;
|
||||||
document.getElementById('countUrgent').innerText = urgents;
|
const conf = props.statusConf;
|
||||||
|
|
||||||
|
// Header dinámico de color
|
||||||
|
const header = document.getElementById('emHeader');
|
||||||
|
header.style.backgroundColor = conf.bg;
|
||||||
|
document.getElementById('emStatusLabel').innerText = conf.label;
|
||||||
|
document.getElementById('emRef').innerText = `EXP. #${ref}`;
|
||||||
|
|
||||||
|
// Datos
|
||||||
|
document.getElementById('emName').innerText = raw['Nombre Cliente'] || raw['CLIENTE'] || "Desconocido";
|
||||||
|
const phone = (raw['Teléfono'] || raw['TELEFONOS'] || raw['TELEFONO'] || "").match(/[6789]\d{8}/)?.[0] || "Sin Teléfono";
|
||||||
|
document.getElementById('emPhone').innerText = phone;
|
||||||
|
document.getElementById('emPhoneLink').href = phone !== "Sin Teléfono" ? `tel:+34${phone}` : '#';
|
||||||
|
|
||||||
|
document.getElementById('emOperator').innerText = props.operatorName;
|
||||||
|
document.getElementById('emCompany').innerText = props.companyName;
|
||||||
|
|
||||||
|
const addr = raw['Dirección'] || raw['DOMICILIO'] || "";
|
||||||
|
const pop = raw['Población'] || raw['POBLACION-PROVINCIA'] || "";
|
||||||
|
document.getElementById('emAddress').innerText = `${addr}, ${pop} (CP: ${raw['Código Postal'] || '---'})`;
|
||||||
|
|
||||||
|
document.getElementById('emDesc').innerHTML = (raw['Descripción'] || raw['DESCRIPCION'] || "No hay notas de la avería.").replace(/\n/g, '<br>');
|
||||||
|
|
||||||
|
document.getElementById('eventModal').classList.remove('hidden');
|
||||||
|
lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- LÓGICA DEL MODAL ---
|
function closeEventModal() {
|
||||||
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');
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user