// ============================================================ // Fleet Module // ============================================================ let fleetState = { vehicleId: null, tab: 'overview' }; async function renderFleet(params = {}) { // All roles can use fuel slip; techs see simplified fleet page if (!Auth.can('fleet','view')) { return renderTechFleetPage(); } const content = document.getElementById('page-content'); content.innerHTML = `

Fleet Management

Vehicles, costs, service schedules and documents

${Auth.can('fleet','create') ? `` : ''} ${Auth.can('fleet','create') ? `` : ''}
`; loadFleetList(); } function renderTechFleetPage() { const content = document.getElementById('page-content'); content.innerHTML = `

Fleet

Log fuel and expenses for company vehicles

β›½

Fuel Slip Capture

Capture your fuel slip with odometer reading and a photo of the slip

`; } async function loadFleetList() { const wrap = document.getElementById('fleet-body'); const res = await API.post('fleet/vehicles', { action:'list' }); if (!res.success) { wrap.innerHTML = '

Failed to load vehicles.

'; return; } const vehicles = res.data.vehicles || []; if (!vehicles.length) { wrap.innerHTML = `
πŸš—
No vehicles
Add your first vehicle to get started
${Auth.can('fleet','create') ? `` : ''}
`; return; } // Service warnings const warnings = vehicles.filter(v => v.service_due?.length > 0); const warningHtml = warnings.length ? `
⚠️ ${warnings.length} vehicle(s) due for service: ${warnings.map(v => `${v.make} ${v.model} (${v.registration}) β€” ${v.service_due.map(s=>s.service_type).join(', ')}`).join('; ')}
` : ''; wrap.innerHTML = warningHtml + `
${vehicles.map(v => { const dueCount = v.service_due?.length || 0; return `
${v.registration}
${v.status}
${v.make} ${v.model} ${v.year||''}
${v.vehicle_type} Β· ${v.fuel_type} Β· ${v.color||''}
ODO ${parseInt(v.current_odo||0).toLocaleString()} km
${Auth.can('fleet','reports') && v.cost_per_km ? `
Cost/kmR ${parseFloat(v.cost_per_km).toFixed(2)}
` : ''} ${Auth.can('fleet','reports') && v.quote_rate_per_km ? `
Quote/kmR ${parseFloat(v.quote_rate_per_km).toFixed(2)}
` : ''} ${dueCount ? `
⚠ ${dueCount} service(s) due
` : ''}
`; }).join('')}
${Auth.can('fleet','reports') ? `

Monthly Cost Summary

` : ''}`; if (Auth.can('fleet','reports')) loadFleetMonthlySummary(); } async function loadFleetMonthlySummary() { const wrap = document.getElementById('fleet-monthly-wrap'); if (!wrap) return; const [year, month] = (document.getElementById('fleet-month-sel')?.value || `${new Date().getFullYear()}-${new Date().getMonth()+1}`).split('-'); wrap.innerHTML = '
'; const res = await API.post('fleet/vehicles', { action:'monthly_summary', year, month }); if (!res.success) { wrap.innerHTML = '

Failed.

'; return; } const rows = res.data.summary || []; if (!rows.length) { wrap.innerHTML = '
No costs recorded this month
'; return; } // Group by vehicle const byVehicle = {}; rows.forEach(r => { const key = `${r.registration} β€” ${r.make} ${r.model}`; if (!byVehicle[key]) byVehicle[key] = { total:0, types:{} }; byVehicle[key].types[r.cost_type] = parseFloat(r.total); byVehicle[key].total += parseFloat(r.total); }); const allTypes = [...new Set(rows.map(r=>r.cost_type))]; wrap.innerHTML = `
${allTypes.map(t=>``).join('')}${Object.entries(byVehicle).map(([veh, data]) => ` ${allTypes.map(t => ``).join('')} `).join('')}
Vehicle${t}Total
${veh}R ${(data.types[t]||0).toFixed(2)}R ${data.total.toFixed(2)}
`; } async function openVehicleDetail(id) { fleetState.vehicleId = id; const content = document.getElementById('page-content'); content.innerHTML = '
'; const res = await API.post('fleet/vehicles', { action:'get', id }); if (!res.success) { content.innerHTML = '

Not found.

'; return; } const v = res.data.vehicle; content.innerHTML = `

${v.make} ${v.model} β€” ${v.registration}

${v.vehicle_type} Β· ${v.fuel_type} Β· ODO: ${parseInt(v.current_odo).toLocaleString()} km
${v.status} ${Auth.can('fleet','edit') ? `` : ''}
${Auth.can('fleet','reports') ? `` : ''} ${Auth.can('fleet','reports') ? `` : ''} ${Auth.can('fleet','reports') ? `` : ''} ${Auth.can('fleet','reports') ? `` : ''}

${v.registration}

${v.make} ${v.model}

${v.year||'β€”'}

${v.vin||'β€”'}

${v.engine_no||'β€”'}

${v.color||'β€”'}

${parseInt(v.current_odo).toLocaleString()} km

R ${parseFloat(v.cost_per_km||0).toFixed(2)}

R ${parseFloat(v.quote_rate_per_km||0).toFixed(2)}

${v.notes ? `

${v.notes}

` : ''}
Cost Log
${Auth.can('fleet','create') ? `` : ''}
Service Schedule
${Auth.can('fleet','create') ? `` : ''}
Service History
πŸ—ΊοΈ Travel Log & ODO History
${Auth.can('fleet','create') ? `` : ''}
Vehicle Documents
${Auth.can('fleet','create') ? `` : ''}
`; } async function loadVehicleCosts(vehicleId) { const wrap = document.getElementById('vh-costs-list'); if (!wrap) return; const res = await API.post('fleet/costs', { action:'list', vehicle_id:vehicleId }); if (!res.success) { wrap.innerHTML = '

Failed.

'; return; } const costs = res.data.costs || []; if (!costs.length) { wrap.innerHTML = '
No costs recorded
'; return; } const total = costs.reduce((s,c) => s + parseFloat(c.amount||0), 0); wrap.innerHTML = ` ${costs.map(c => ` `).join('')}
DateTypeDescriptionODOAmountSlipBy
${Fmt.date(c.cost_date)} ${c.cost_type} ${c.description||'β€”'} ${c.litres?`(${c.litres}L)`:''} ${c.odo_reading ? parseInt(c.odo_reading).toLocaleString()+' km' : 'β€”'} R ${parseFloat(c.amount).toFixed(2)} ${c.slip_image_path ? `πŸ–Ό` : 'β€”'} ${c.created_by_name||'β€”'} ${Auth.can('fleet','delete') ? `` : ''}
TotalR ${total.toFixed(2)}
`; } async function loadVehicleService(vehicleId) { const wrap = document.getElementById('vh-service-list'); if (!wrap) return; const res = await API.post('fleet/service', { action:'list', vehicle_id:vehicleId }); if (!res.success) { wrap.innerHTML = '

Failed.

'; return; } const schedules = res.data.schedules || []; if (!schedules.length) { wrap.innerHTML = '
No service schedules
Add service schedules to track maintenance intervals
'; } else { wrap.innerHTML = schedules.map(s => { const kmRemaining = parseInt(s.km_remaining||0); const isDue = kmRemaining <= 0; const isWarn = kmRemaining <= parseInt(s.warn_at_km_before||500); return `
${s.service_type}
Every ${parseInt(s.service_interval_km).toLocaleString()} km Β· Last at ${parseInt(s.last_service_odo).toLocaleString()} km ${s.last_service_date ? `(${Fmt.date(s.last_service_date)})` : ''}
${isDue ? `⚠ SERVICE DUE NOW` : isWarn ? `Due in ${kmRemaining.toLocaleString()} km` : `${kmRemaining.toLocaleString()} km remaining`}
${Auth.can('fleet','delete') ? `` : ''}
`; }).join(''); } loadServiceHistory(vehicleId); } async function loadServiceHistory(vehicleId) { const wrap = document.getElementById('vh-service-history'); if (!wrap) return; const res = await API.post('fleet/service', { action:'history', vehicle_id:vehicleId }); if (!res.success) { wrap.innerHTML = '

Failed to load history.

'; return; } const history = res.data.history || []; if (!history.length) { wrap.innerHTML = '
No service history recorded yet.
'; return; } wrap.innerHTML = `
${history.map(h => ` `).join('')}
DateService TypeODOCostSlipNotesRecorded By
${Fmt.date(h.service_date)} ${h.service_type} ${parseInt(h.odo_reading).toLocaleString()} km ${h.cost_amount ? 'R '+parseFloat(h.cost_amount).toFixed(2) : 'β€”'} ${h.slip_image_path ? `πŸ“„ View` : 'No slip'} ${h.notes||'β€”'} ${h.recorded_by_name}
`; } async function loadVehicleDocs(vehicleId) { const wrap = document.getElementById('vh-docs-list'); if (!wrap) return; const res = await API.post('fleet/documents', { action:'list', vehicle_id:vehicleId }); if (!res.success) { wrap.innerHTML = '

Failed.

'; return; } const docs = res.data.documents || []; if (!docs.length) { wrap.innerHTML = '
No documents
'; return; } const today = new Date(); wrap.innerHTML = `
${docs.map(d => { const expiry = d.expiry_date ? new Date(d.expiry_date) : null; const daysLeft = expiry ? Math.floor((expiry-today)/(1000*60*60*24)) : null; const expired = daysLeft !== null && daysLeft < 0; const expiringSoon = daysLeft !== null && daysLeft >= 0 && daysLeft <= 30; return `
${d.doc_type} ${expired ? 'EXPIRED' : expiringSoon ? `Exp in ${daysLeft}d` : ''}
${d.label}
${d.expiry_date ? `
Expires: ${Fmt.date(d.expiry_date)}
` : ''} ${d.notes ? `
${d.notes}
` : ''}
${d.file_path ? `πŸ“„ View` : ''}
`; }).join('')}
`; } // ── Vehicle Modals ────────────────────────────────────────── function openAddVehicleModal(v = {}) { const isEdit = !!v.id; Modal.open({ id: isEdit ? 'edit-vehicle' : 'add-vehicle', title: isEdit ? `Edit β€” ${v.registration}` : 'Add Vehicle', size: 'modal-lg', body: `
${isEdit ? `` : ''}
`, footer: ` ` }); } async function openEditVehicleModal(id) { const res = await API.post('fleet/vehicles', { action:'get', id }); if (res.success) openAddVehicleModal(res.data.vehicle); } async function submitVehicle() { const btn = document.querySelector('[id^="modal-"] .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('vehicle-form'); const res = await API.post('fleet/vehicles', { action:'save', ...data }); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show(data.id ? 'Vehicle updated' : 'Vehicle added', 'success'); Modal.close(); if (data.id) openVehicleDetail(data.id); else loadFleetList(); } else Toast.show(res.message,'error'); } function openAddCostModal(vehicleId) { Modal.open({ id: 'add-cost', title: 'Add Cost Entry', body: `
`, footer: ` ` }); } async function submitFleetCost(vehicleId) { const btn = document.querySelector('#modal-add-cost .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('cost-form'); const fd = new FormData(); const imgFile = document.getElementById('cost-img')?.files[0]; if (imgFile) fd.append('slip_image', imgFile); fd.append('vehicle_id', vehicleId); Object.entries(data).forEach(([k,v]) => fd.append(k, v)); const res = await API.postForm('fleet/costs', fd); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Cost added','success'); Modal.close(); loadVehicleCosts(vehicleId); } else Toast.show(res.message,'error'); } async function deleteFleetCost(costId, vehicleId) { if (!confirm('Delete this cost entry?')) return; const res = await API.post('fleet/costs', { action:'delete', id:costId, vehicle_id:vehicleId }); if (res.success) { Toast.show('Deleted','success'); loadVehicleCosts(vehicleId); } else Toast.show(res.message,'error'); } function openAddServiceModal(vehicleId) { Modal.open({ id: 'add-service', title: 'Add Service Schedule', body: `
`, footer: ` ` }); } async function submitServiceSchedule(vehicleId) { const btn = document.querySelector('#modal-add-service .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('service-form'); const res = await API.post('fleet/service', { action:'save', vehicle_id:vehicleId, ...data }); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Schedule added','success'); Modal.close(); loadVehicleService(vehicleId); } else Toast.show(res.message,'error'); } function openCompleteServiceModal(scheduleId, vehicleId, serviceType) { Modal.open({ id: 'complete-service', title: 'βœ… Record Service Completed', size: 'modal-lg', body: `
`, footer: ` ` }); } async function submitCompleteService(scheduleId, vehicleId) { const btn = document.querySelector('#modal-complete-service .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('complete-service-form'); if (!data.odo_reading) { Toast.show('ODO reading is required.','error'); if(btn)btn.classList.remove('loading'); return; } const fd = new FormData(); fd.append('action', 'complete'); fd.append('id', scheduleId); fd.append('vehicle_id', vehicleId); Object.entries(data).forEach(([k,v]) => fd.append(k, v)); const slipFile = document.getElementById('svc-slip-file')?.files[0]; if (slipFile) fd.append('slip', slipFile); const res = await API.postForm('fleet/service', fd); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Service recorded! βœ…','success'); Modal.close(); loadVehicleService(vehicleId); } else Toast.show(res.message,'error'); } async function deleteServiceSchedule(scheduleId, vehicleId) { if (!confirm('Delete this service schedule?')) return; const res = await API.post('fleet/service', { action:'delete', id:scheduleId, vehicle_id:vehicleId }); if (res.success) { Toast.show('Deleted','success'); loadVehicleService(vehicleId); } else Toast.show(res.message,'error'); } function openAddDocModal(vehicleId) { Modal.open({ id: 'add-doc', title: 'Upload Vehicle Document', body: `
`, footer: ` ` }); } async function submitFleetDoc(vehicleId) { const btn = document.querySelector('#modal-add-doc .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('doc-form'); const file = document.getElementById('doc-file')?.files[0]; if (!file) { Toast.show('Please select a file.','error'); if(btn)btn.classList.remove('loading'); return; } const fd = new FormData(); fd.append('document', file); fd.append('action', 'save'); fd.append('vehicle_id', vehicleId); Object.entries(data).forEach(([k,v]) => fd.append(k, v)); const res = await API.postForm('fleet/documents', fd); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Document uploaded','success'); Modal.close(); loadVehicleDocs(vehicleId); } else Toast.show(res.message,'error'); } async function deleteFleetDoc(docId, vehicleId) { if (!confirm('Delete this document?')) return; const res = await API.post('fleet/documents', { action:'delete', id:docId, vehicle_id:vehicleId }); if (res.success) { Toast.show('Deleted','success'); loadVehicleDocs(vehicleId); } else Toast.show(res.message,'error'); } // ── Travel Log ──────────────────────────────────────────────── async function loadTravelLog(vehicleId) { const wrap = document.getElementById('vh-travel-wrap'); const metrics = document.getElementById('vh-travel-metrics'); if (!wrap) return; const res = await API.post('fleet/travel_log', { action:'list', vehicle_id:vehicleId }); if (!res.success) { wrap.innerHTML=`

${res.message}

`; return; } const logs = res.data.logs || []; const m = res.data.metrics || {}; const monthly = res.data.monthly || []; _travelLogLatestOdo = res.data.latest_odo || 0; // Source badge helper const sourceBadge = (src) => { const map = { manual: 'badge-muted', job_card: 'badge-info', fuel_slip: 'badge-warning' }; const labels = { manual: 'Manual', job_card: 'Job Card', fuel_slip: 'Fuel Slip' }; return `${labels[src]||src}`; }; // Metrics cards const totalKm = m.max_odo && m.min_odo ? m.max_odo - m.min_odo : 0; const days = m.first_date && m.last_date ? Math.max(1, (new Date(m.last_date)-new Date(m.first_date))/(1000*86400)) : 1; const dailyAvg = totalKm / days; const monthlyAvg = dailyAvg * 30.44; if (metrics) metrics.innerHTML = `
${m.max_odo?.toLocaleString()||'β€”'}
Current ODO (km)
${totalKm.toLocaleString()}
Total Recorded (km)
${Math.round(dailyAvg)}
Avg km/day
${Math.round(monthlyAvg).toLocaleString()}
Avg km/month
${m.reading_count||0}
Total Readings
${monthly.length ? `
Monthly Distance (last 6 months)
${monthly.map(mn=>``).join('')}
MonthEst. kmReadings
${mn.month}${parseInt(mn.km_this_month||0).toLocaleString()}${mn.readings}
` : ''}`; if (!logs.length) { wrap.innerHTML=`
πŸ—ΊοΈ
No ODO readings logged
`; return; } wrap.innerHTML = `
${logs.map((l,i)=>{ const prev = logs[i+1]; const diff = prev && prev.odo_reading ? l.odo_reading - prev.odo_reading : null; const diffStr = diff !== null ? (diff > 0 ? `+${diff.toLocaleString()}` : diff < 0 ? `${diff.toLocaleString()}` : 'β€”') : 'β€”'; return ``; }).join('')}
DateODO (km)+/- kmSourceReferenceRecorded By
${Fmt.date(l.reading_date)} ${parseInt(l.odo_reading).toLocaleString()} km ${diffStr} ${sourceBadge(l.source)} ${l.source_ref ? `${l.source_ref}` : 'β€”'} ${l.recorded_by_name||'System'} ${l.source==='manual'?``:''}
`; } let _travelLogLatestOdo = 0; // track latest odo for validation hints function openAddOdoModal(vehicleId) { const hint = _travelLogLatestOdo > 0 ? `
Latest recorded: ${_travelLogLatestOdo.toLocaleString()} km β€” new reading must be β‰₯ this.
` : ''; Modal.open({ id: 'add-odo', title: 'πŸ“ Log ODO Reading', body: `
${hint}
`, footer: ` ` }); } async function submitOdoReading(vehicleId) { const btn = document.querySelector('#modal-add-odo .btn-primary'); if (btn) btn.classList.add('loading'); const val = parseInt(document.getElementById('odo-val')?.value); const date = document.getElementById('odo-date')?.value; const notes = document.getElementById('odo-notes')?.value; if (!val) { Toast.show('ODO reading required.','error'); if(btn)btn.classList.remove('loading'); return; } if (_travelLogLatestOdo > 0 && val < _travelLogLatestOdo) { Toast.show(`ODO cannot be less than the latest reading (${_travelLogLatestOdo.toLocaleString()} km).`, 'error'); if (btn) btn.classList.remove('loading'); return; } const res = await API.post('fleet/travel_log', { action:'add', vehicle_id:vehicleId, odo_reading:val, reading_date:date, notes }); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Reading logged.','success'); Modal.close(); loadTravelLog(vehicleId); } else Toast.show(res.message,'error'); } async function deleteOdoReading(id, vehicleId) { if (!confirm('Delete this ODO reading?')) return; const res = await API.post('fleet/travel_log', { action:'delete', id, vehicle_id:vehicleId }); if (res.success) { Toast.show('Deleted.','success'); loadTravelLog(vehicleId); } else Toast.show(res.message,'error'); }