feat(pos): fleet management module — vehicles, maintenance schedules, alerts

Add full fleet management (Feature 13): database migration for fleet_vehicles,
fleet_maintenance_schedules, and fleet_maintenance_logs tables; REST API blueprint
with CRUD, scheduling, logging, alerts, and stats endpoints; frontend with tabbed
UI (vehicles grid, maintenance schedules, history, overdue alerts); sidebar nav entry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 02:43:26 +00:00
parent c645bc03f3
commit db5bbf6718
4 changed files with 2171 additions and 0 deletions

609
pos/static/js/fleet.js Normal file
View File

@@ -0,0 +1,609 @@
/**
* fleet.js — Fleet Management module for Nexus POS
*
* Handles vehicle CRUD, maintenance schedules, logs, and alerts.
*/
var Fleet = (function() {
'use strict';
var API = '/pos/api/fleet';
var token = localStorage.getItem('pos_token');
var vehicles = [];
var currentPage = 1;
var searchTimeout = null;
// ─── Helpers ───
function headers() {
return {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
};
}
function fmt(n) {
if (n == null) return '0';
return parseFloat(n).toLocaleString('es-MX');
}
function fmtMoney(n) {
if (n == null) return '$0.00';
return '$' + parseFloat(n).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
function fmtDate(d) {
if (!d) return '—';
var dt = new Date(d);
return dt.toLocaleDateString('es-MX', {day: '2-digit', month: 'short', year: 'numeric'});
}
function esc(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ─── Init ───
function init() {
// Tab switching
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var tab = this.getAttribute('data-tab');
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('is-active'); });
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('is-active'); });
this.classList.add('is-active');
document.getElementById('tab-' + tab).classList.add('is-active');
if (tab === 'maintenance') loadMaintenance();
if (tab === 'history') loadHistory();
if (tab === 'alerts') loadAlerts();
});
});
// Search
var searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(function() {
currentPage = 1;
loadVehicles();
}, 300);
});
}
// New vehicle button
document.getElementById('btnNewVehicle').addEventListener('click', function() {
openVehicleModal();
});
// Load initial data
loadStats();
loadVehicles();
}
// ─── Stats ───
function loadStats() {
fetch(API + '/stats', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
document.getElementById('statTotal').textContent = fmt(d.total_vehicles);
document.getElementById('statOverdue').textContent = fmt(d.overdue_count);
document.getElementById('statUpcoming').textContent = fmt(d.upcoming_this_month);
document.getElementById('statCost').textContent = fmtMoney(d.total_cost_month);
})
.catch(function() {});
}
// ─── Vehicles Tab ───
function loadVehicles() {
var q = (document.getElementById('searchInput') || {}).value || '';
var url = API + '/vehicles?page=' + currentPage + '&per_page=50';
if (q) url += '&q=' + encodeURIComponent(q);
fetch(url, {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
vehicles = d.data || [];
renderVehicleGrid(vehicles);
renderPagination(d.pagination);
populateVehicleSelects(vehicles);
})
.catch(function() {
document.getElementById('vehicleGrid').innerHTML =
'<div class="empty-state"><div class="empty-state__text">Error al cargar vehiculos</div></div>';
});
}
function renderVehicleGrid(list) {
var grid = document.getElementById('vehicleGrid');
if (!list.length) {
grid.innerHTML = '<div class="empty-state">' +
'<div class="empty-state__icon">&#x1F69A;</div>' +
'<div class="empty-state__text">No hay vehiculos registrados</div>' +
'<button class="btn btn--primary" onclick="Fleet.openVehicleModal()">+ Agregar Vehiculo</button>' +
'</div>';
return;
}
var html = '';
list.forEach(function(v) {
var label = (v.make || '') + ' ' + (v.model || '');
if (v.year) label += ' ' + v.year;
html += '<div class="vehicle-card" onclick="Fleet.viewVehicle(' + v.id + ')">' +
'<div class="vehicle-card__header">' +
'<span class="vehicle-card__plate">' + esc(v.plate || 'SIN PLACA') + '</span>' +
'<span class="badge ' + (v.is_active ? 'badge--active' : 'badge--inactive') + '">' +
(v.is_active ? 'Activo' : 'Inactivo') + '</span>' +
'</div>' +
'<div class="vehicle-card__info">' +
'<div><strong>' + esc(label.trim()) + '</strong></div>' +
(v.owner_name ? '<div>' + esc(v.owner_name) + '</div>' : '') +
(v.color ? '<div>Color: ' + esc(v.color) + '</div>' : '') +
'</div>' +
'<div class="vehicle-card__footer">' +
'<span>' + fmt(v.current_mileage) + ' km</span>' +
'<span>' + esc(v.fuel_type || '') + '</span>' +
'</div>' +
'</div>';
});
grid.innerHTML = html;
}
function renderPagination(p) {
var el = document.getElementById('vehiclePagination');
if (!p || p.total_pages <= 1) { el.innerHTML = ''; return; }
el.innerHTML = '<button class="pagination__btn" ' + (p.page <= 1 ? 'disabled' : '') +
' onclick="Fleet.goPage(' + (p.page - 1) + ')">&laquo; Anterior</button>' +
'<span class="pagination__info">Pagina ' + p.page + ' de ' + p.total_pages +
' (' + p.total + ' vehiculos)</span>' +
'<button class="pagination__btn" ' + (p.page >= p.total_pages ? 'disabled' : '') +
' onclick="Fleet.goPage(' + (p.page + 1) + ')">Siguiente &raquo;</button>';
}
function goPage(n) {
currentPage = n;
loadVehicles();
}
function populateVehicleSelects(list) {
var opts = '<option value="">-- Seleccionar vehiculo --</option>';
list.forEach(function(v) {
var label = (v.plate || 'S/P') + ' - ' + (v.make || '') + ' ' + (v.model || '');
opts += '<option value="' + v.id + '">' + esc(label.trim()) + '</option>';
});
var selects = ['schedVehicleSelect', 'logVehicleSelect'];
selects.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.innerHTML = opts;
});
}
// ─── Vehicle Detail (view in edit modal) ───
function viewVehicle(id) {
fetch(API + '/vehicles/' + id, {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(v) {
openVehicleModal(v);
});
}
// ─── Vehicle Modal ───
function openVehicleModal(data) {
var modal = document.getElementById('vehicleModal');
var title = document.getElementById('vehicleModalTitle');
if (data && data.id) {
title.textContent = 'Editar Vehiculo';
document.getElementById('vehEditId').value = data.id;
document.getElementById('vehPlate').value = data.plate || '';
document.getElementById('vehVin').value = data.vin || '';
document.getElementById('vehMake').value = data.make || '';
document.getElementById('vehModel').value = data.model || '';
document.getElementById('vehYear').value = data.year || '';
document.getElementById('vehMileage').value = data.current_mileage || 0;
document.getElementById('vehFuel').value = data.fuel_type || 'gasolina';
document.getElementById('vehColor').value = data.color || '';
document.getElementById('vehOwner').value = data.owner_name || '';
document.getElementById('vehNotes').value = data.notes || '';
} else {
title.textContent = 'Nuevo Vehiculo';
document.getElementById('vehEditId').value = '';
['vehPlate','vehVin','vehMake','vehModel','vehYear','vehColor','vehOwner','vehNotes']
.forEach(function(id) { document.getElementById(id).value = ''; });
document.getElementById('vehMileage').value = '0';
document.getElementById('vehFuel').value = 'gasolina';
}
modal.classList.add('is-open');
}
function closeVehicleModal() {
document.getElementById('vehicleModal').classList.remove('is-open');
}
function saveVehicle() {
var editId = document.getElementById('vehEditId').value;
var payload = {
plate: document.getElementById('vehPlate').value.trim(),
vin: document.getElementById('vehVin').value.trim(),
make: document.getElementById('vehMake').value.trim(),
model: document.getElementById('vehModel').value.trim(),
year: parseInt(document.getElementById('vehYear').value) || null,
current_mileage: parseInt(document.getElementById('vehMileage').value) || 0,
fuel_type: document.getElementById('vehFuel').value,
color: document.getElementById('vehColor').value.trim(),
owner_name: document.getElementById('vehOwner').value.trim(),
notes: document.getElementById('vehNotes').value.trim(),
};
if (!payload.plate && !payload.vin) {
alert('Se requiere placa o VIN');
return;
}
var url = API + '/vehicles';
var method = 'POST';
if (editId) {
url += '/' + editId;
method = 'PUT';
}
fetch(url, {
method: method,
headers: headers(),
body: JSON.stringify(payload)
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) { alert(d.error); return; }
closeVehicleModal();
loadVehicles();
loadStats();
})
.catch(function(e) { alert('Error: ' + e.message); });
}
// ─── Maintenance Tab ───
function loadMaintenance() {
// Load all vehicles with their schedules
fetch(API + '/vehicles?per_page=200', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
var allVehicles = d.data || [];
var promises = allVehicles.map(function(v) {
return fetch(API + '/vehicles/' + v.id + '/schedules', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(s) {
return {vehicle: v, schedules: s.data || []};
});
});
return Promise.all(promises);
})
.then(function(results) {
renderMaintenance(results);
})
.catch(function() {
document.getElementById('maintBody').innerHTML =
'<tr><td colspan="7" style="text-align:center;color:var(--color-text-muted);">Error al cargar</td></tr>';
});
}
function renderMaintenance(results) {
var body = document.getElementById('maintBody');
var rows = [];
results.forEach(function(r) {
var v = r.vehicle;
r.schedules.forEach(function(s) {
var now = new Date();
var isOverdue = false;
if (s.next_due_at && new Date(s.next_due_at) < now) isOverdue = true;
if (s.next_due_km && s.next_due_km <= v.current_mileage) isOverdue = true;
var interval = '';
if (s.interval_km) interval += fmt(s.interval_km) + ' km';
if (s.interval_km && s.interval_months) interval += ' / ';
if (s.interval_months) interval += s.interval_months + ' meses';
var next = '';
if (s.next_due_at) next += fmtDate(s.next_due_at);
if (s.next_due_at && s.next_due_km) next += ' / ';
if (s.next_due_km) next += fmt(s.next_due_km) + ' km';
rows.push(
'<tr>' +
'<td><strong>' + esc(v.plate || 'S/P') + '</strong><br>' +
'<span style="font-size:var(--text-caption);color:var(--color-text-muted);">' +
esc((v.make || '') + ' ' + (v.model || '')) + '</span></td>' +
'<td>' + esc(s.maintenance_type) + '</td>' +
'<td class="mono">' + (interval || '—') + '</td>' +
'<td>' + fmtDate(s.last_done_at) + (s.last_done_km ? '<br><span class="mono" style="font-size:var(--text-caption);">' + fmt(s.last_done_km) + ' km</span>' : '') + '</td>' +
'<td>' + (next || '—') + '</td>' +
'<td><span class="badge ' + (isOverdue ? 'badge--overdue' : 'badge--active') + '">' +
(isOverdue ? 'Vencido' : 'Al dia') + '</span></td>' +
'<td><button class="btn btn--sm btn--ghost" onclick="Fleet.openLogModalFor(' + v.id + ',' + s.id + ',\'' + esc(s.maintenance_type) + '\')">Registrar</button></td>' +
'</tr>'
);
});
});
if (!rows.length) {
body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">' +
'No hay programas de mantenimiento.<br><button class="btn btn--primary btn--sm" style="margin-top:var(--space-3);" onclick="Fleet.openScheduleModal()">+ Crear Programa</button></td></tr>';
return;
}
body.innerHTML = rows.join('');
}
// ─── History Tab ───
function loadHistory() {
fetch(API + '/vehicles?per_page=200', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
var allVehicles = d.data || [];
var promises = allVehicles.map(function(v) {
return fetch(API + '/vehicles/' + v.id, {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(detail) {
return {vehicle: v, logs: detail.recent_logs || []};
});
});
return Promise.all(promises);
})
.then(function(results) {
renderHistory(results);
})
.catch(function() {
document.getElementById('historyBody').innerHTML =
'<tr><td colspan="7" style="text-align:center;color:var(--color-text-muted);">Error al cargar</td></tr>';
});
}
function renderHistory(results) {
var body = document.getElementById('historyBody');
var allLogs = [];
results.forEach(function(r) {
r.logs.forEach(function(l) {
l._plate = r.vehicle.plate || 'S/P';
l._make = (r.vehicle.make || '') + ' ' + (r.vehicle.model || '');
allLogs.push(l);
});
});
// Sort by date desc
allLogs.sort(function(a, b) {
return new Date(b.created_at) - new Date(a.created_at);
});
if (!allLogs.length) {
body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay registros de mantenimiento</td></tr>';
return;
}
var html = '';
allLogs.forEach(function(l) {
html += '<tr>' +
'<td>' + fmtDate(l.created_at) + '</td>' +
'<td><strong>' + esc(l._plate) + '</strong><br>' +
'<span style="font-size:var(--text-caption);color:var(--color-text-muted);">' + esc(l._make) + '</span></td>' +
'<td>' + esc(l.maintenance_type) + '</td>' +
'<td class="mono">' + fmt(l.mileage_at) + '</td>' +
'<td class="mono">' + fmtMoney(l.cost) + '</td>' +
'<td>' + esc(l.employee_name || '—') + '</td>' +
'<td style="max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + esc(l.notes || '—') + '</td>' +
'</tr>';
});
body.innerHTML = html;
}
// ─── Alerts Tab ───
function loadAlerts() {
fetch(API + '/alerts', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
renderAlerts(d.data || []);
})
.catch(function() {
document.getElementById('alertsList').innerHTML =
'<div class="empty-state"><div class="empty-state__text">Error al cargar alertas</div></div>';
});
}
function renderAlerts(list) {
var el = document.getElementById('alertsList');
if (!list.length) {
el.innerHTML = '<div class="empty-state">' +
'<div class="empty-state__icon" style="color:#3FB950;">&#10003;</div>' +
'<div class="empty-state__text">No hay mantenimientos vencidos</div>' +
'</div>';
return;
}
var html = '';
list.forEach(function(a) {
var detail = '';
if (a.next_due_at) detail += 'Vencio: ' + fmtDate(a.next_due_at);
if (a.next_due_km) detail += (detail ? ' | ' : '') + 'Limite: ' + fmt(a.next_due_km) + ' km (actual: ' + fmt(a.current_mileage) + ' km)';
html += '<div class="alert-card">' +
'<div class="alert-card__info">' +
'<div class="alert-card__vehicle">' +
esc(a.plate || 'S/P') + ' — ' + esc((a.make || '') + ' ' + (a.model || '')) +
(a.year ? ' ' + a.year : '') +
'</div>' +
'<div class="alert-card__detail">' +
'<span class="badge badge--overdue">' + esc(a.maintenance_type) + '</span>' +
' &nbsp; ' + detail +
'</div>' +
'</div>' +
'<button class="btn btn--sm btn--primary" onclick="Fleet.openLogModalFor(' + a.vehicle_id + ',' + a.schedule_id + ',\'' + esc(a.maintenance_type) + '\')">Registrar Mant.</button>' +
'</div>';
});
el.innerHTML = html;
}
// ─── Schedule Modal ───
function openScheduleModal(vehicleId) {
var modal = document.getElementById('scheduleModal');
document.getElementById('schedType').value = '';
document.getElementById('schedIntervalKm').value = '';
document.getElementById('schedIntervalMonths').value = '';
document.getElementById('schedNextDate').value = '';
document.getElementById('schedNextKm').value = '';
document.getElementById('schedNotes').value = '';
if (vehicleId) {
document.getElementById('schedVehicleSelect').value = vehicleId;
}
modal.classList.add('is-open');
}
function closeScheduleModal() {
document.getElementById('scheduleModal').classList.remove('is-open');
}
function saveSchedule() {
var vehicleId = document.getElementById('schedVehicleSelect').value;
if (!vehicleId) { alert('Seleccione un vehiculo'); return; }
var mType = document.getElementById('schedType').value.trim();
if (!mType) { alert('Ingrese el tipo de mantenimiento'); return; }
var payload = {
maintenance_type: mType,
interval_km: parseInt(document.getElementById('schedIntervalKm').value) || null,
interval_months: parseInt(document.getElementById('schedIntervalMonths').value) || null,
next_due_at: document.getElementById('schedNextDate').value || null,
next_due_km: parseInt(document.getElementById('schedNextKm').value) || null,
notes: document.getElementById('schedNotes').value.trim(),
};
fetch(API + '/vehicles/' + vehicleId + '/schedules', {
method: 'POST',
headers: headers(),
body: JSON.stringify(payload)
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) { alert(d.error); return; }
closeScheduleModal();
loadMaintenance();
loadStats();
})
.catch(function(e) { alert('Error: ' + e.message); });
}
// ─── Log Modal ───
function openLogModal() {
var modal = document.getElementById('logModal');
document.getElementById('logScheduleSelect').innerHTML = '<option value="">-- Sin programa --</option>';
document.getElementById('logType').value = '';
document.getElementById('logMileage').value = '';
document.getElementById('logCost').value = '';
document.getElementById('logParts').value = '';
document.getElementById('logNotes').value = '';
modal.classList.add('is-open');
}
function openLogModalFor(vehicleId, scheduleId, maintType) {
openLogModal();
document.getElementById('logVehicleSelect').value = vehicleId;
if (maintType) document.getElementById('logType').value = maintType;
// Load schedules for this vehicle
fetch(API + '/vehicles/' + vehicleId + '/schedules', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
var opts = '<option value="">-- Sin programa --</option>';
(d.data || []).forEach(function(s) {
var sel = (s.id == scheduleId) ? ' selected' : '';
opts += '<option value="' + s.id + '"' + sel + '>' + esc(s.maintenance_type) + '</option>';
});
document.getElementById('logScheduleSelect').innerHTML = opts;
});
// Pre-fill mileage
var veh = vehicles.find(function(v) { return v.id == vehicleId; });
if (veh) document.getElementById('logMileage').value = veh.current_mileage || '';
}
function closeLogModal() {
document.getElementById('logModal').classList.remove('is-open');
}
function saveLog() {
var vehicleId = document.getElementById('logVehicleSelect').value;
if (!vehicleId) { alert('Seleccione un vehiculo'); return; }
var mType = document.getElementById('logType').value.trim();
if (!mType) { alert('Ingrese el tipo de mantenimiento'); return; }
var payload = {
schedule_id: parseInt(document.getElementById('logScheduleSelect').value) || null,
maintenance_type: mType,
mileage_at: parseInt(document.getElementById('logMileage').value) || null,
cost: parseFloat(document.getElementById('logCost').value) || 0,
parts_used: document.getElementById('logParts').value.trim(),
notes: document.getElementById('logNotes').value.trim(),
};
fetch(API + '/vehicles/' + vehicleId + '/log', {
method: 'POST',
headers: headers(),
body: JSON.stringify(payload)
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) { alert(d.error); return; }
closeLogModal();
loadStats();
// Refresh current tab
var activeTab = document.querySelector('.tab-btn.is-active');
if (activeTab) {
var tab = activeTab.getAttribute('data-tab');
if (tab === 'maintenance') loadMaintenance();
if (tab === 'history') loadHistory();
if (tab === 'alerts') loadAlerts();
if (tab === 'vehicles') loadVehicles();
}
})
.catch(function(e) { alert('Error: ' + e.message); });
}
// ─── Public API ───
document.addEventListener('DOMContentLoaded', init);
return {
openVehicleModal: openVehicleModal,
closeVehicleModal: closeVehicleModal,
saveVehicle: saveVehicle,
viewVehicle: viewVehicle,
goPage: goPage,
openScheduleModal: openScheduleModal,
closeScheduleModal: closeScheduleModal,
saveSchedule: saveSchedule,
openLogModal: openLogModal,
openLogModalFor: openLogModalFor,
closeLogModal: closeLogModal,
saveLog: saveLog,
};
})();