Fix: colores por estado real en el calendario + leyenda + arrastrar para reprogramar citas
This commit is contained in:
177
calendario.html
177
calendario.html
@@ -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();
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user