Fix: colores por estado real en el calendario + leyenda + arrastrar para reprogramar citas

This commit is contained in:
2026-08-24 21:38:33 +00:00
parent 3a3db58a0c
commit af409fc677

View File

@@ -119,6 +119,8 @@
</div> </div>
</div> </div>
<div id="statusLegend" class="flex flex-wrap gap-2 -mt-2"></div>
<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="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 id="calendar" class="h-full"></div> <div id="calendar" class="h-full"></div>
</div> </div>
@@ -180,32 +182,144 @@
let calendarEvents = []; let calendarEvents = [];
let calendarInstance = null; let calendarInstance = null;
// DICCIONARIO DE COLORES E ICONOS // 🎨 ESTADOS REALES: vienen de /statuses (BBDD), no son texto libre.
const statusConfig = { // id (string) -> { name, color, is_final, ... }
'citado': { bg: '#3b82f6', border: '#2563eb', label: 'Citado / Agendado', icon: 'calendar' }, let statusMap = {};
'de_camino': { bg: '#8b5cf6', border: '#7c3aed', label: 'Técnico de Camino', icon: 'truck' }, // COCHE/CAMIÓN
'trabajando': { bg: '#f59e0b', border: '#d97706', label: 'En Reparación', icon: 'hammer' }, // MARTILLO // Paleta de apoyo: traduce el nombre de color (tal cual se guarda en service_statuses.color,
'incidencia': { bg: '#ef4444', border: '#dc2626', label: 'Pausado / Incidencia', icon: 'alert-triangle' }, // p.ej. "indigo", "emerald") al hex real que pintamos en el calendario.
'terminado': { bg: '#10b981', border: '#059669', label: 'Trabajo Terminado', icon: 'check-circle' }, const TAILWIND_HEX = {
'SYSTEM_BLOCK': { bg: '#f43f5e', border: '#e11d48', label: 'BLOQUEO DE AGENDA', icon: 'shield-alert' } gray: { bg: '#6b7280', border: '#4b5563' },
slate: { bg: '#64748b', border: '#475569' },
blue: { bg: '#3b82f6', border: '#2563eb' },
amber: { bg: '#f59e0b', border: '#d97706' },
emerald: { bg: '#10b981', border: '#059669' },
indigo: { bg: '#6366f1', border: '#4f46e5' },
orange: { bg: '#f97316', border: '#ea580c' },
red: { bg: '#ef4444', border: '#dc2626' },
rose: { bg: '#f43f5e', border: '#e11d48' },
purple: { bg: '#a855f7', border: '#9333ea' },
green: { bg: '#22c55e', border: '#16a34a' },
teal: { bg: '#14b8a6', border: '#0d9488' },
cyan: { bg: '#06b6d4', border: '#0891b2' },
violet: { bg: '#8b5cf6', border: '#7c3aed' },
pink: { bg: '#ec4899', border: '#db2777' },
yellow: { bg: '#eab308', border: '#ca8a04' },
lime: { bg: '#84cc16', border: '#65a30d' },
sky: { bg: '#0ea5e9', border: '#0284c7' }
}; };
// Función para detectar la clave correcta aunque venga el ID o un texto variable function iconForStatusName(name) {
function getStatusKey(status) { const s = (name || '').toLowerCase();
if (!status) return 'citado'; if (s.includes('camino')) return 'truck';
const s = String(status).toLowerCase(); if (s.includes('trabajando') || s.includes('reparacion')) return 'hammer';
if (s.includes('camino')) return 'de_camino'; if (s.includes('incidencia') || s.includes('pausado')) return 'alert-triangle';
if (s.includes('trabajando') || s.includes('reparacion')) return 'trabajando'; if (s.includes('anulado')) return 'x-circle';
if (s.includes('incidencia') || s.includes('pausado')) return 'incidencia'; if (s.includes('desasignado')) return 'user-x';
if (s.includes('terminado') || s.includes('finalizado')) return 'terminado'; if (s.includes('terminado') || s.includes('finalizado')) return 'check-circle';
if (s === 'system_block') return 'SYSTEM_BLOCK'; if (s.includes('esperando') || s.includes('pendiente')) return 'clock';
return 'citado'; if (s.includes('compañ') || s.includes('perito')) return 'building-2';
if (s.includes('asignado')) return 'user-check';
return 'calendar';
}
// 🩹 FIX: antes esto comparaba el estado como si fuera texto ("trabajando", "camino"...),
// pero en los datos reales status_operativo es un ID numérico (24, 29, 30...) que apunta
// a la tabla service_statuses. Por eso NUNCA coincidía con ninguna palabra y todas las
// citas salían siempre del mismo color. Ahora resolvemos el ID contra /statuses de verdad.
function getStatusConf(statusOperativo, isBlock) {
if (isBlock) return { bg: '#f43f5e', border: '#e11d48', label: 'BLOQUEO DE AGENDA', icon: 'shield-alert', is_final: false };
const key = (statusOperativo !== null && statusOperativo !== undefined && statusOperativo !== '') ? String(statusOperativo) : null;
const st = key ? statusMap[key] : null;
if (st) {
const hex = TAILWIND_HEX[st.color] || TAILWIND_HEX.blue;
return { bg: hex.bg, border: hex.border, label: st.name, icon: iconForStatusName(st.name), is_final: !!st.is_final };
}
// Estado huérfano/desconocido (dato antiguo, borrado, etc.): lo dejamos visible pero distinguible
return { bg: '#94a3b8', border: '#64748b', label: 'Sin Estado', icon: 'help-circle', is_final: false };
}
async function loadStatuses() {
try {
const res = await fetch(`${API_URL}/statuses`, { headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } });
const data = await res.json();
if (data.ok && Array.isArray(data.statuses)) {
statusMap = {};
data.statuses.forEach(s => { statusMap[String(s.id)] = s; });
renderStatusLegend(data.statuses);
}
} catch (e) { console.error("Error al cargar los estados", e); }
}
function renderStatusLegend(statuses) {
const el = document.getElementById('statusLegend');
if (!el) return;
const chips = statuses.map(s => {
const hex = TAILWIND_HEX[s.color] || TAILWIND_HEX.blue;
return `<span class="inline-flex items-center gap-1.5 bg-slate-50 border border-slate-200 rounded-full px-3 py-1 text-[10px] font-bold text-slate-600 uppercase tracking-wide">
<span class="w-2.5 h-2.5 rounded-full shrink-0" style="background:${hex.bg}"></span>${s.name}
</span>`;
}).join('');
el.innerHTML = chips + `<span class="inline-flex items-center gap-1.5 bg-slate-50 border border-slate-200 rounded-full px-3 py-1 text-[10px] font-bold text-slate-600 uppercase tracking-wide">
<span class="w-2.5 h-2.5 rounded-full shrink-0" style="background:#f43f5e"></span>Bloqueo
</span>`;
}
function toLocalDateStr(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function toLocalTimeStr(d) {
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
// 🖱️ Arrastrar una cita para cambiarla de hora/día
async function handleEventDrop(info) {
const svcId = info.event.id;
const newDate = toLocalDateStr(info.event.start);
const newTime = toLocalTimeStr(info.event.start);
try {
const res = await fetch(`${API_URL}/providers/scraped/${svcId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', "Authorization": `Bearer ${localStorage.getItem("token")}` },
body: JSON.stringify({ scheduled_date: newDate, scheduled_time: newTime })
});
const data = await res.json();
if (!res.ok || data.ok === false) throw new Error('No se pudo mover la cita');
info.event.extendedProps.originalData.raw_data.scheduled_date = newDate;
info.event.extendedProps.originalData.raw_data.scheduled_time = newTime;
} catch (e) {
console.error("Error moviendo la cita:", e);
info.revert();
alert("No se ha podido mover la cita. Inténtalo de nuevo.");
}
}
// ↔️ Alargar/acortar una cita cambia su duración
async function handleEventResize(info) {
const svcId = info.event.id;
const durationMin = Math.round((info.event.end - info.event.start) / 60000);
try {
const res = await fetch(`${API_URL}/providers/scraped/${svcId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', "Authorization": `Bearer ${localStorage.getItem("token")}` },
body: JSON.stringify({ duration_minutes: durationMin })
});
const data = await res.json();
if (!res.ok || data.ok === false) throw new Error('No se pudo cambiar la duración');
info.event.extendedProps.originalData.raw_data.duration_minutes = durationMin;
} catch (e) {
console.error("Error cambiando la duración:", e);
info.revert();
alert("No se ha podido cambiar la duración de la cita.");
}
} }
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", async () => {
if (!localStorage.getItem("token")) window.location.href = "index.html"; if (!localStorage.getItem("token")) window.location.href = "index.html";
lucide.createIcons(); lucide.createIcons();
await loadOperators(); await Promise.all([loadOperators(), loadStatuses()]);
await loadServices(); await loadServices();
}); });
@@ -257,8 +371,7 @@
const endObj = new Date(startObj.getTime() + duration * 60000); const endObj = new Date(startObj.getTime() + duration * 60000);
const endStr = new Date(endObj.getTime() - (endObj.getTimezoneOffset() * 60000)).toISOString().slice(0, 19); const endStr = new Date(endObj.getTime() - (endObj.getTimezoneOffset() * 60000)).toISOString().slice(0, 19);
const statusKey = isBlock ? 'SYSTEM_BLOCK' : getStatusKey(r.status_operativo); const sConf = getStatusConf(r.status_operativo, isBlock);
const sConf = statusConfig[statusKey] || statusConfig['citado'];
return { return {
id: svc.id, id: svc.id,
@@ -272,7 +385,8 @@
companyName: isBlock ? 'SISTEMA' : (r['Compañía'] || r['COMPAÑIA'] || "Particular").toUpperCase(), companyName: isBlock ? 'SISTEMA' : (r['Compañía'] || r['COMPAÑIA'] || "Particular").toUpperCase(),
operatorName: svc.assigned_name || "Sin Asignar", operatorName: svc.assigned_name || "Sin Asignar",
statusConf: sConf, statusConf: sConf,
isBlock: isBlock isBlock: isBlock,
isUrgent: !isBlock && svc.is_urgent === true
} }
}; };
}); });
@@ -307,21 +421,28 @@
}, },
events: calendarEvents, events: calendarEvents,
editable: true,
eventDurationEditable: true,
eventDrop: handleEventDrop,
eventResize: handleEventResize,
eventContent: function(arg) { eventContent: function(arg) {
let timeText = arg.timeText; let timeText = arg.timeText;
if (!timeText && arg.event.start) { if (!timeText && arg.event.start) {
timeText = arg.event.start.toLocaleTimeString('es-ES', {hour: '2-digit', minute:'2-digit'}); timeText = arg.event.start.toLocaleTimeString('es-ES', {hour: '2-digit', minute:'2-digit'});
} }
const title = arg.event.title; const title = arg.event.title;
const op = arg.event.extendedProps.operatorName; const op = arg.event.extendedProps.operatorName;
const isBlock = arg.event.extendedProps.isBlock; const isBlock = arg.event.extendedProps.isBlock;
const isUrgent = arg.event.extendedProps.isUrgent;
const conf = arg.event.extendedProps.statusConf; const conf = arg.event.extendedProps.statusConf;
const bgColor = arg.event.backgroundColor; const bgColor = arg.event.backgroundColor;
return { return {
html: ` html: `
<div class="flex flex-col h-full w-full justify-start p-1.5 text-white overflow-hidden font-sans rounded-lg shadow-sm border-l-4" style="background-color: ${bgColor}; border-color: ${arg.event.borderColor};"> <div class="flex flex-col h-full w-full justify-start p-1.5 text-white overflow-hidden font-sans rounded-lg shadow-sm border-l-4 relative" style="background-color: ${bgColor}; border-color: ${arg.event.borderColor};">
${isUrgent ? `<span class="absolute -top-1 -right-1 bg-white rounded-full p-0.5 shadow-md"><i data-lucide="flame" class="w-3 h-3 text-red-600"></i></span>` : ''}
<div class="flex justify-between items-center mb-1"> <div class="flex justify-between items-center mb-1">
<span class="text-[9px] font-black bg-black/20 px-1.5 py-0.5 rounded-md leading-none whitespace-nowrap">${timeText}</span> <span class="text-[9px] font-black bg-black/20 px-1.5 py-0.5 rounded-md leading-none whitespace-nowrap">${timeText}</span>
<i data-lucide="${conf.icon}" class="w-3 h-3 text-white"></i> <i data-lucide="${conf.icon}" class="w-3 h-3 text-white"></i>
@@ -331,10 +452,10 @@
<i data-lucide="${isBlock ? 'shield-alert' : 'user'}" class="w-2.5 h-2.5"></i> <i data-lucide="${isBlock ? 'shield-alert' : 'user'}" class="w-2.5 h-2.5"></i>
<span class="text-[8px] font-bold uppercase truncate">${op}</span> <span class="text-[8px] font-bold uppercase truncate">${op}</span>
</div> </div>
</div>` </div>`
}; };
}, },
eventDidMount: function() { eventDidMount: function() {
lucide.createIcons(); lucide.createIcons();
}, },