Dashboard
Super Admin

👩‍💼 Operadoras

NombreGabinete / ActividadCiudadDepartamento EstadoNivelAcciones

⚙️ Máquinas

CódigoNombreCategoríaUbicación EstadoÚltimo Mant.Acciones

📅 Reservas

CódigoOperadoraMáquinaTipo Fecha Jornada / InicioFinDepto.EstadoAcciones

🗺 Reglas Logísticas por Departamento

Configurá los días de bloqueo antes y después de cada jornada por territorio.

ℹ️
¿Cómo funciona? Cuando se crea una reserva, el sistema calcula automáticamente los días bloqueados antes y después de la jornada. Esos días impactan en la disponibilidad real de la máquina.

💳 Pagos y Señas

CódigoOperadoraReservaTipo TotalSeñaSaldoEstadoAcciones

💬 Centro de Notificaciones WhatsApp

Gestioná mensajes automáticos a operadoras.

🔌
Modo simulación activo. Las notificaciones se generan y encolan automáticamente. Para envío real, configurá el token en . El payload JSON ya está preparado.

🚚 Envíos de Máquinas

CódigoOperadoraMáquinaReserva DepartamentoF. Envío Est.F. Retiro Est. EstadoAcciones

🔧 Configuración del Sistema

Datos de la empresa e integración WhatsApp Business API.

📊 Reportes Operativos

Métricas generales del sistema.

🔍 Log de Auditoría

Registro de todas las acciones del sistema.

📚 Materiales de Formación

Recursos de capacitación por equipo y categoría.

🎯 Leads / Prospectos

NombreNegocioCiudadWhatsApp FuenteEstadoPróxima AcciónAltaAcciones

🏆 Embudo Comercial

Visión del pipeline por estado.

// ============================================================ // MÓDULO: TRANSPORTISTAS — DepiMóvil CRM // Pegar dentro de la función initApp() o donde están los demás módulos // ============================================================ // ───────────────────────────────────────────── // CONSTANTES Y ESTADO // ───────────────────────────────────────────── const API = 'https://crm.depimovil.live/api'; // ajustar si el base URL es diferente const DEPTOS = [ 'Artigas','Canelones','Cerro Largo','Colonia','Durazno','Flores', 'Florida','Lavalleja','Maldonado','Montevideo','Paysandú','Río Negro', 'Rivera','Rocha','Salto','San José','Soriano','Tacuarembó','Treinta y Tres' ]; let transportistaActual = null; // ───────────────────────────────────────────── // INICIALIZACIÓN DEL MÓDULO // ───────────────────────────────────────────── function initTransportistas() { renderListaTransportistas(); } // ───────────────────────────────────────────── // 1. LISTA DE TRANSPORTISTAS // ───────────────────────────────────────────── async function renderListaTransportistas() { const contenedor = document.getElementById('modulo-transportistas'); if (!contenedor) return; contenedor.innerHTML = `

Transportistas

Cargando…

Cargando transportistas…
`; try { const res = await fetch(`${API}/transportistas`); const data = await res.json(); document.getElementById('trans-count').textContent = `${data.length} activos`; document.getElementById('lista-transportistas').innerHTML = data.length ? data.map(t => cardTransportista(t)).join('') : '
No hay transportistas cargados
'; } catch (e) { document.getElementById('lista-transportistas').innerHTML = '
Error al cargar transportistas
'; } } function cardTransportista(t) { const iniciales = t.nombre.split(' ').slice(0,2).map(w => w[0]).join('').toUpperCase(); const esEmpresa = t.tipo === 'empresa'; const deps = (t.departamentos || []).slice(0,4).map(d => `${d}`).join('') + (t.departamentos?.length > 4 ? `+${t.departamentos.length - 4}` : ''); return `
${iniciales}
${t.nombre} ${esEmpresa ? 'Empresa' : 'Persona física'} Activo
Ciclo ${t.ciclo_pago} · $${t.tarifa_envio_chica} chica · $${t.tarifa_envio_grande} grande
${deps}
`; } // ───────────────────────────────────────────── // 2. FICHA DEL TRANSPORTISTA // ───────────────────────────────────────────── async function abrirFichaTransportista(id) { const contenedor = document.getElementById('modulo-transportistas'); contenedor.innerHTML = `
Cargando ficha…
`; try { const [tRes, enviosRes, incidentesRes, pagosRes] = await Promise.all([ fetch(`${API}/transportistas/${id}`), fetch(`${API}/transportistas/${id}/envios`), fetch(`${API}/transportistas/${id}/incidentes`), fetch(`${API}/transportistas/${id}/pagos`), ]); const t = await tRes.json(); const envios = await enviosRes.json(); const incs = await incidentesRes.json(); const pagos = await pagosRes.json(); transportistaActual = t; contenedor.innerHTML = htmlFicha(t, envios, incs, pagos); } catch (e) { contenedor.innerHTML = `
Error al cargar la ficha. Volver
`; } } function htmlFicha(t, envios, incs, pagos) { const iniciales = t.nombre.split(' ').slice(0,2).map(w => w[0]).join('').toUpperCase(); const esEmpresa = t.tipo === 'empresa'; const totalEnvios = envios.length; const entregados = envios.filter(e => e.fecha_entrega && e.fecha_salida); const promDias = entregados.length ? (entregados.reduce((s,e) => s + e.tiempo_entrega_dias, 0) / entregados.length).toFixed(1) : '—'; return `
${iniciales}
${t.nombre} ${esEmpresa ? 'Empresa' : 'Persona física'} Activo
Ciclo de pago: ${t.ciclo_pago}
Teléfono${t.telefono || '—'}
WhatsApp${t.whatsapp || '—'}
Dirección${t.direccion || '—'}
Departamentos ${(t.departamentos || []).map(d => `${d}`).join('') || '—'}
${t.notas ? `
Notas${t.notas}
` : ''}
Tarifas
Envío chica
$${t.tarifa_envio_chica}
Envío grande
$${t.tarifa_envio_grande}
Limpieza chica
$${t.tarifa_limpieza_chica}
Limpieza grande
$${t.tarifa_limpieza_grande}
Rendimiento
${totalEnvios}
Envíos totales
${promDias}
Días promedio
${incs.length}
Incidentes
Historial de envíos
${envios.length ? envios.slice(0,5).map(e => htmlEnvioRow(e)).join('') : '
Sin envíos registrados
'} ${envios.length > 5 ? `` : ''}
${incs.length ? incs.map(i => `
${formatFecha(i.fecha)} ${i.descripcion} ${i.resuelto ? 'Resuelto' : 'Pendiente'}
`).join('') : '
Sin incidentes registrados
'}
${pagos.length ? pagos.slice(0,3).map(p => htmlPagoRow(p)).join('') : '
Sin liquidaciones registradas
'}
`; } function htmlEnvioRow(e) { const dot = e.estado === 'entregado' ? 'dot-verde' : e.estado === 'en_transito' ? 'dot-azul' : 'dot-rojo'; const rastreo = e.tiene_rastreo && e.numero_rastreo ? `${e.numero_rastreo}` : 'Sin rastreo'; const notif = e.rastreo_notificado ? 'Notificada ✓' : ``; return `
${e.maquina_nombre || 'Máquina'} · ${e.tipo_envio} ${formatFecha(e.fecha_salida)}
${e.departamento_destino || ''} · ${e.tipo_maquina} · $${e.costo_total} ${e.incluye_limpieza ? '+ limpieza' : ''}  ${rastreo} ${notif}
`; } function htmlPagoRow(p) { const badge = p.estado === 'pagado' ? `Pagado ${formatFecha(p.fecha_pago)}` : 'Pendiente'; return `
${formatFecha(p.periodo_desde)} — ${formatFecha(p.periodo_hasta)} ${p.total_envios} envíos · ${p.total_limpiezas} limpiezas
$${p.monto_total} ${badge} ${p.estado === 'pendiente' ? `` : ''}
`; } // ───────────────────────────────────────────── // 3. PANEL DE RASTREO CON CLAUDE VISION // ───────────────────────────────────────────── function abrirPanelRastreo(envioId) { const modal = document.createElement('div'); modal.id = 'modal-rastreo'; modal.className = 'modal-overlay'; modal.innerHTML = ` `; document.body.appendChild(modal); actualizarPreviewRastreo(); } async function procesarRecibo(input, envioId) { if (!input.files[0]) return; const file = input.files[0]; const url = URL.createObjectURL(file); document.getElementById('rastreo-img-preview').src = url; document.getElementById('rastreo-img-preview-2').src = url; document.getElementById('rastreo-phase-scan').style.display = 'none'; document.getElementById('rastreo-phase-loading').style.display = 'block'; const b64 = await fileToBase64(file); const numero = await detectarNumeroRastreo(b64, file.type); document.getElementById('rastreo-phase-loading').style.display = 'none'; document.getElementById('rastreo-phase-result').style.display = 'block'; const resultBox = document.getElementById('rastreo-result-box'); if (numero) { window._rastreoDetectado = numero; resultBox.innerHTML = `
Número detectado
${numero}
`; document.getElementById('rastreo-manual').value = numero; document.getElementById('preview-wpp').style.display = 'block'; actualizarPreviewRastreo(); } else { resultBox.innerHTML = `
No se detectó el número. Ingresalo a mano:
`; } } function manualPostInput(v) { window._rastreoDetectado = v; if (v.length > 3) { document.getElementById('preview-wpp').style.display = 'block'; actualizarPreviewRastreo(); } } async function detectarNumeroRastreo(b64, mimeType) { try { const resp = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'claude-sonnet-4-20250514', max_tokens: 200, system: 'Sos un asistente de logística. Extraé el número de rastreo de un recibo de transporte uruguayo (OCA, Correo, DAC, Turismar, JT, Agencia Central u otro). Respondé SOLO con el número, sin texto extra. Si no encontrás uno claro, respondé: NO_ENCONTRADO', messages: [{ role: 'user', content: [ { type: 'image', source: { type: 'base64', media_type: mimeType, data: b64 } }, { type: 'text', text: 'Extraé el número de rastreo.' } ] }] }) }); const data = await resp.json(); const txt = data.content?.[0]?.text?.trim() || 'NO_ENCONTRADO'; return txt === 'NO_ENCONTRADO' ? null : txt; } catch { return null; } } function actualizarPreviewRastreo() { const num = window._rastreoDetectado || document.getElementById('rastreo-manual')?.value?.trim() || ''; const msg = document.getElementById('rastreo-msg')?.value?.trim() || ''; const operadoraNombre = transportistaActual?.operadora_nombre || 'Hola'; if (!num && !msg) return; document.getElementById('preview-wpp').style.display = 'block'; let texto = `Hola! 👋\nTu máquina ya está en camino.\n\n`; if (num) texto += `📦 Rastreo: *${num}*\n\n`; if (msg) texto += `${msg}\n\n`; texto += `_DepiMóvil_`; document.getElementById('wpp-preview-text').innerHTML = texto.replace(/\n/g,'
').replace(/\*(.*?)\*/g,'$1').replace(/_(.*?)_/g,'$1'); } async function enviarRastreo(envioId) { const num = window._rastreoDetectado || document.getElementById('rastreo-manual')?.value?.trim() || ''; const msg = document.getElementById('rastreo-msg')?.value?.trim() || ''; try { await fetch(`${API}/envios/${envioId}/rastreo`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ numero_rastreo: num, rastreo_notificado: true, fecha_notificacion: new Date().toISOString(), mensaje_operadora: msg }) }); cerrarModalRastreo(); mostrarToast('Rastreo guardado y operadora notificada ✓'); abrirFichaTransportista(transportistaActual?.id); } catch { mostrarToast('Error al guardar. Intentá de nuevo.', 'error'); } } async function guardarRastreoSinEnviar(envioId) { const num = window._rastreoDetectado || document.getElementById('rastreo-manual')?.value?.trim() || ''; await fetch(`${API}/envios/${envioId}/rastreo`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ numero_rastreo: num, rastreo_notificado: false }) }); cerrarModalRastreo(); mostrarToast('Número de rastreo guardado'); } function resetearRastreo() { window._rastreoDetectado = null; document.getElementById('rastreo-phase-result').style.display = 'none'; document.getElementById('rastreo-phase-loading').style.display = 'none'; document.getElementById('rastreo-phase-scan').style.display = 'block'; document.getElementById('scan-file-input').value = ''; document.getElementById('preview-wpp').style.display = 'none'; } function cerrarModalRastreo() { document.getElementById('modal-rastreo')?.remove(); window._rastreoDetectado = null; } // ───────────────────────────────────────────── // 4. FORMULARIOS CRUD // ───────────────────────────────────────────── function abrirFormNuevoTransportista() { abrirFormTransportista(null); } async function abrirFormEditarTransportista(id) { const res = await fetch(`${API}/transportistas/${id}`); const t = await res.json(); abrirFormTransportista(t); } function abrirFormTransportista(t) { const esEdicion = !!t; const deps = t?.departamentos || []; const modal = document.createElement('div'); modal.id = 'modal-form-transportista'; modal.className = 'modal-overlay'; modal.innerHTML = ` `; document.body.appendChild(modal); } async function guardarTransportista(e, id) { e.preventDefault(); const deps = [...document.querySelectorAll('#deps-grid input:checked')].map(el => el.value); const body = { tipo: document.getElementById('f-tipo').value, nombre: document.getElementById('f-nombre').value.trim(), telefono: document.getElementById('f-telefono').value.trim(), whatsapp: document.getElementById('f-whatsapp').value.trim(), direccion: document.getElementById('f-direccion').value.trim(), ciclo_pago: document.getElementById('f-ciclo').value, departamentos: deps, tarifa_envio_chica: parseFloat(document.getElementById('f-env-chica').value)||0, tarifa_envio_grande: parseFloat(document.getElementById('f-env-grande').value)||0, tarifa_limpieza_chica: parseFloat(document.getElementById('f-limp-chica').value)||0, tarifa_limpieza_grande: parseFloat(document.getElementById('f-limp-grande').value)||0, sin_rastreo_siempre: document.getElementById('f-sin-rastreo').checked, notas: document.getElementById('f-notas').value.trim(), }; const url = id ? `${API}/transportistas/${id}` : `${API}/transportistas`; const method = id ? 'PUT' : 'POST'; try { const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!res.ok) throw new Error(); document.getElementById('modal-form-transportista').remove(); mostrarToast(id ? 'Transportista actualizado ✓' : 'Transportista creado ✓'); renderListaTransportistas(); } catch { mostrarToast('Error al guardar', 'error'); } } // ───────────────────────────────────────────── // 5. NUEVO ENVÍO // ───────────────────────────────────────────── async function abrirFormNuevoEnvio(transportistaId) { const resM = await fetch(`${API}/maquinas`); const resR = await fetch(`${API}/reservas?estado=activa`); const maquinas = await resM.json(); const reservas = await resR.json(); const t = transportistaActual; const modal = document.createElement('div'); modal.id = 'modal-form-envio'; modal.className = 'modal-overlay'; modal.innerHTML = ` `; document.body.appendChild(modal); } function actualizarCostosEnvio() { const t = transportistaActual; if (!t) return; const tamano = document.getElementById('env-tamano').value; const limpieza = document.getElementById('env-limpieza').checked; const costoEnv = tamano === 'chica' ? t.tarifa_envio_chica : t.tarifa_envio_grande; const costoLimp = limpieza ? (tamano === 'chica' ? t.tarifa_limpieza_chica : t.tarifa_limpieza_grande) : 0; document.getElementById('env-costo').value = costoEnv; document.getElementById('env-costo-limp').value = costoLimp; document.getElementById('env-costo-limp').disabled = !limpieza; document.getElementById('env-total').textContent = `$${costoEnv + costoLimp}`; } async function guardarEnvio(e, transportistaId) { e.preventDefault(); const body = { transportista_id: transportistaId, reserva_id: document.getElementById('env-reserva').value || null, maquina_id: document.getElementById('env-maquina').value, tipo_envio: document.getElementById('env-tipo').value, tipo_maquina: document.getElementById('env-tamano').value, departamento_destino:document.getElementById('env-depto').value, fecha_salida: document.getElementById('env-salida').value, incluye_limpieza: document.getElementById('env-limpieza').checked, costo_envio: parseFloat(document.getElementById('env-costo').value)||0, costo_limpieza: parseFloat(document.getElementById('env-costo-limp').value)||0, tiene_rastreo: !transportistaActual?.sin_rastreo_siempre, observacion: document.getElementById('env-obs').value.trim(), estado: 'en_transito', }; try { const res = await fetch(`${API}/envios`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!res.ok) throw new Error(); document.getElementById('modal-form-envio').remove(); mostrarToast('Envío registrado ✓'); abrirFichaTransportista(transportistaId); } catch { mostrarToast('Error al crear el envío', 'error'); } } // ───────────────────────────────────────────── // 6. INCIDENTES // ───────────────────────────────────────────── function abrirFormNuevoIncidente(transportistaId) { const modal = document.createElement('div'); modal.id = 'modal-incidente'; modal.className = 'modal-overlay'; modal.innerHTML = ` `; document.body.appendChild(modal); } async function guardarIncidente(e, transportistaId) { e.preventDefault(); const body = { transportista_id: transportistaId, fecha: document.getElementById('inc-fecha').value, descripcion: document.getElementById('inc-desc').value.trim(), }; await fetch(`${API}/transportistas/${transportistaId}/incidentes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); document.getElementById('modal-incidente').remove(); mostrarToast('Incidente registrado'); abrirFichaTransportista(transportistaId); } // ───────────────────────────────────────────── // 7. LIQUIDACIÓN DE HONORARIOS // ───────────────────────────────────────────── function abrirFormLiquidacion(transportistaId) { const hoy = new Date(); const primeroDeMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1).toISOString().split('T')[0]; const ultimoDeMes = new Date(hoy.getFullYear(), hoy.getMonth()+1, 0).toISOString().split('T')[0]; const modal = document.createElement('div'); modal.id = 'modal-liquidacion'; modal.className = 'modal-overlay'; modal.innerHTML = ` `; document.body.appendChild(modal); } async function guardarLiquidacion(e, transportistaId) { e.preventDefault(); const montoEnv = parseFloat(document.getElementById('liq-monto-env').value)||0; const montoLimp = parseFloat(document.getElementById('liq-monto-limp').value)||0; const body = { transportista_id: transportistaId, periodo_desde: document.getElementById('liq-desde').value, periodo_hasta: document.getElementById('liq-hasta').value, total_envios: parseInt(document.getElementById('liq-env').value)||0, total_limpiezas: parseInt(document.getElementById('liq-limp').value)||0, monto_envios: montoEnv, monto_limpiezas: montoLimp, notas: document.getElementById('liq-notas').value.trim(), }; await fetch(`${API}/transportistas/${transportistaId}/pagos`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); document.getElementById('modal-liquidacion').remove(); mostrarToast('Liquidación creada ✓'); abrirFichaTransportista(transportistaId); } async function marcarPagado(pagoId) { await fetch(`${API}/transportistas/pagos/${pagoId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ estado: 'pagado', fecha_pago: new Date().toISOString().split('T')[0] }) }); mostrarToast('Pago registrado ✓'); abrirFichaTransportista(transportistaActual?.id); } // ───────────────────────────────────────────── // 8. UTILIDADES // ───────────────────────────────────────────── function fileToBase64(file) { return new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result.split(',')[1]); r.onerror = rej; r.readAsDataURL(file); }); } function formatFecha(f) { if (!f) return '—'; const d = new Date(f); return d.toLocaleDateString('es-UY', { day:'2-digit', month:'short', year:'numeric' }); } function mostrarToast(msg, tipo='ok') { const t = document.createElement('div'); t.className = `toast toast-${tipo}`; t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), 3000); }