Cambiar Estado de Reserva
✕
📝 Registrar Seguimiento Comercial
✕
📋 Registrar Capacitación
✕
✅ Otorgar Habilitación Técnica
✕
✓
// ============================================================
// 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 = `
`;
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
Historial de envíos
${envios.length ? envios.slice(0,5).map(e => htmlEnvioRow(e)).join('') : '
Sin envíos registrados
'}
${envios.length > 5 ? `
Ver todos (${envios.length}) → ` : ''}
${incs.length ? incs.map(i => `
${formatFecha(i.fecha)}
${i.descripcion}
${i.resuelto ? 'Resuelto ' : 'Pendiente '}
`).join('') : '
Sin incidentes registrados
'}
Honorarios
+ Nueva liquidación
${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 ✓ '
: `
Notificar `;
return `
${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' ? `Marcar pagado ` : ''}
`;
}
// ─────────────────────────────────────────────
// 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 = `
Claude está leyendo el recibo…
Volver a escanear
Vista previa WhatsApp
Enviar por WhatsApp ↗
Guardar sin enviar
`;
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 = `
Reserva vinculada
— Sin reserva —
${reservas.map(r => `#${r.id} · ${r.operadora_nombre} · ${r.maquina_nombre} `).join('')}
Máquina
Seleccioná
${maquinas.map(m => `${m.nombre} `).join('')}
Tipo de envío
Ida
Vuelta
Tamaño máquina
Chica
Grande
Incluye limpieza
Total: $${t?.tarifa_envio_chica||0}
Observación
Crear envío
`;
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 = `
Fecha
Descripción *
Guardar incidente
`;
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);
}