601 lines
30 KiB
PHP
601 lines
30 KiB
PHP
@extends('admin.layouts.app', ['pageTitle' => 'Trips'])
|
|
|
|
@section('page')
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.css" />
|
|
<script src="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.js"></script>
|
|
<div class="mx-auto max-w-7xl">
|
|
<header class="mb-6 flex items-end justify-between">
|
|
<div>
|
|
<h2 class="text-2xl font-semibold tracking-tight text-neutral-900">Trips</h2>
|
|
<p class="mt-1 text-sm text-neutral-500">Schedule and monitor collection runs.</p>
|
|
</div>
|
|
<button id="new-btn" class="btn-primary">+ Schedule Trip</button>
|
|
</header>
|
|
|
|
<div class="card mb-4 flex flex-wrap items-center gap-3 p-4">
|
|
<select id="filter-status" class="form-select w-44">
|
|
<option value="">All statuses</option>
|
|
<option value="scheduled">Scheduled</option>
|
|
<option value="in_progress">In progress</option>
|
|
<option value="at_dumpsite">At dumpsite</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="cancelled">Cancelled</option>
|
|
</select>
|
|
<input id="filter-date" type="date" class="form-input w-44">
|
|
<button id="filter-apply" class="btn-primary">Apply</button>
|
|
</div>
|
|
|
|
<div class="table-wrap">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Trip #</th>
|
|
<th>Date</th>
|
|
<th>LGU</th>
|
|
<th>Route</th>
|
|
<th>Team</th>
|
|
<th>Load</th>
|
|
<th>Status</th>
|
|
<th class="text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="rows">
|
|
<tr><td colspan="7" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="form-modal" class="hidden">
|
|
<div class="slide-over-mask" data-close></div>
|
|
<div class="slide-over-panel translate-x-0 p-6 overflow-y-auto">
|
|
<div class="mb-4 flex items-center justify-between">
|
|
<h3 class="text-base font-semibold text-neutral-900">Schedule trip</h3>
|
|
<button data-close class="text-neutral-400 hover:text-neutral-600">
|
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div id="conflict-banner" class="mb-4 hidden rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
|
<div id="conflict-message" class="mb-2"></div>
|
|
<button type="button" id="override-btn" class="btn-primary py-1 px-3 text-xs" style="background-color: #d97706; border-color: #d97706;">Override & Schedule</button>
|
|
</div>
|
|
|
|
<form id="create-form" class="space-y-4">
|
|
<div><label class="form-label">Route</label>
|
|
<select name="route_id" id="route-select" required class="form-select"><option value="">— Select —</option></select>
|
|
</div>
|
|
<div id="preview-map-container" class="hidden">
|
|
<label class="form-label">Map Preview</label>
|
|
<div id="preview-map" class="h-48 w-full rounded border" style="z-index: 0;"></div>
|
|
</div>
|
|
<div><label class="form-label">Team</label>
|
|
<select name="team_id" id="team-select" required class="form-select"><option value="">— Select —</option></select>
|
|
</div>
|
|
<div><label class="form-label">Date</label><input name="scheduled_date" type="date" required class="form-input"></div>
|
|
<div><label class="form-label">Start time</label><input name="scheduled_start_time" type="time" class="form-input" placeholder="06:00"></div>
|
|
<div><label class="form-label">Notes</label><textarea name="notes" rows="2" class="form-textarea"></textarea></div>
|
|
<div class="flex justify-end gap-2 pt-2">
|
|
<button type="button" data-close class="btn-ghost">Cancel</button>
|
|
<button type="submit" class="btn-primary">Schedule</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Detail panel --}}
|
|
<div id="detail-modal" class="hidden">
|
|
<div class="slide-over-mask" data-close></div>
|
|
<div class="slide-over-panel translate-x-0 p-6 overflow-y-auto" style="max-width: 600px;">
|
|
<div class="mb-4 flex items-center justify-between">
|
|
<h3 id="detail-title" class="text-base font-semibold text-neutral-900">Trip detail</h3>
|
|
<button data-close class="text-neutral-400 hover:text-neutral-600">
|
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
<div id="detail-body" class="text-sm"></div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Hand Off / Continuation Modal --}}
|
|
<div id="handoff-modal" class="hidden">
|
|
<div class="slide-over-mask" onclick="document.getElementById('handoff-modal').classList.add('hidden')"></div>
|
|
<div class="slide-over-panel translate-x-0 p-6 overflow-y-auto">
|
|
<div class="mb-4 flex items-center justify-between">
|
|
<h3 class="text-base font-semibold text-neutral-900">⚠ Hand Off Trip to Another Team</h3>
|
|
<button onclick="document.getElementById('handoff-modal').classList.add('hidden')" class="text-neutral-400 hover:text-neutral-600">
|
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
<p class="mb-4 text-xs text-neutral-500">This will close the current trip and create a new continuation trip with the remaining pending stops assigned to the selected team.</p>
|
|
<form id="handoff-form" class="space-y-4">
|
|
<div>
|
|
<label class="form-label">Reason for Handoff</label>
|
|
<select name="end_reason" required class="form-select">
|
|
<option value="">— Select reason —</option>
|
|
<option value="truck_full">Truck Full (going to dumpsite)</option>
|
|
<option value="breakdown">Truck Breakdown</option>
|
|
<option value="incident">Incident</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">New Team</label>
|
|
<select name="new_team_id" required class="form-select" id="handoff-team-select">
|
|
<option value="">— Select team —</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">New Truck <span class="text-neutral-400">(optional)</span></label>
|
|
<select name="new_truck_id" class="form-select" id="handoff-truck-select">
|
|
<option value="">— No change —</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex justify-end gap-2 pt-2">
|
|
<button type="button" onclick="document.getElementById('handoff-modal').classList.add('hidden')" class="btn-ghost">Cancel</button>
|
|
<button type="submit" class="btn-primary" style="background:#d97706">Create Continuation Trip</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="module">
|
|
const rows = document.getElementById('rows');
|
|
const modal = document.getElementById('form-modal');
|
|
const detail = document.getElementById('detail-modal');
|
|
|
|
function badge(s) {
|
|
const m = {
|
|
scheduled: 'badge-info', in_progress: 'badge-pending',
|
|
at_dumpsite: 'badge-pending', completed: 'badge-active',
|
|
handed_off: 'badge-pending', cancelled: 'badge-rejected',
|
|
};
|
|
return `<span class="badge ${m[s] || 'badge-inactive'}">${s.replace('_', ' ')}</span>`;
|
|
}
|
|
|
|
async function loadRoutes() {
|
|
const res = await window.Verde.apiFetch('/api/v1/admin/routes?status=active&per_page=200');
|
|
if (!res.ok) return;
|
|
const sel = document.getElementById('route-select');
|
|
(res.body.data ?? []).forEach(r => {
|
|
const opt = document.createElement('option');
|
|
opt.value = r.db_id;
|
|
opt.dataset.uuid = r.id;
|
|
opt.textContent = `${r.code} — ${r.name}`;
|
|
sel.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
async function loadTeams() {
|
|
const res = await window.Verde.apiFetch('/api/v1/admin/teams?status=active&per_page=200');
|
|
if (!res.ok) return;
|
|
const sel = document.getElementById('team-select');
|
|
(res.body.data ?? []).forEach(t => {
|
|
const opt = document.createElement('option');
|
|
opt.value = t.db_id;
|
|
opt.textContent = t.name;
|
|
sel.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
async function load() {
|
|
rows.innerHTML = `<tr><td colspan="7" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>`;
|
|
const params = new URLSearchParams();
|
|
const s = document.getElementById('filter-status').value;
|
|
const d = document.getElementById('filter-date').value;
|
|
if (s) params.set('status', s);
|
|
if (d) params.set('date', d);
|
|
params.set('per_page', '50');
|
|
|
|
const res = await window.Verde.apiFetch(`/api/v1/admin/trips?${params}`);
|
|
if (!res.ok) { rows.innerHTML = `<tr><td colspan="7" class="py-10 text-center text-sm text-red-500">Failed to load.</td></tr>`; return; }
|
|
const items = res.body.data ?? [];
|
|
if (items.length === 0) { rows.innerHTML = `<tr><td colspan="7" class="py-10 text-center text-sm text-neutral-400">No trips match. Schedule one to get started.</td></tr>`; return; }
|
|
rows.innerHTML = items.map(t => `
|
|
<tr>
|
|
<td class="font-mono text-xs font-medium text-neutral-900">${window.Verde.escapeHtml(t.trip_number)}</td>
|
|
<td>${window.Verde.escapeHtml(t.scheduled_date)}</td>
|
|
<td class="text-xs text-neutral-500">${window.Verde.escapeHtml(t.tenant?.name || 'N/A')}</td>
|
|
<td>${t.route ? window.Verde.escapeHtml(t.route.name) : '—'}</td>
|
|
<td>${t.team ? window.Verde.escapeHtml(t.team.name) : '—'}</td>
|
|
<td>${t.total_load_kg ? `${t.total_load_kg} kg` : '—'}</td>
|
|
<td>${badge(t.status)}</td>
|
|
<td class="text-right space-x-1">
|
|
<button data-id="${t.id}" data-action="view" class="btn-ghost px-3 py-1 text-xs">View</button>
|
|
${!['completed','cancelled'].includes(t.status)
|
|
? `<button data-id="${t.id}" data-action="cancel" class="btn-ghost px-3 py-1 text-xs">Cancel</button>`
|
|
: ''}
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
|
|
rows.querySelectorAll('button[data-action]').forEach(btn => {
|
|
btn.addEventListener('click', () => onAction(btn.dataset.action, btn.dataset.id));
|
|
});
|
|
}
|
|
|
|
async function onAction(action, uuid) {
|
|
if (action === 'cancel') {
|
|
if (!confirm('Cancel this trip?')) return;
|
|
const r = await window.Verde.apiFetch(`/api/v1/admin/trips/${uuid}/cancel`, { method: 'POST' });
|
|
if (r.ok) { window.Verde.toast('Cancelled', 'success'); load(); }
|
|
else window.Verde.toast(r.body?.message ?? 'Cancel failed', 'error');
|
|
}
|
|
if (action === 'view') {
|
|
const r = await window.Verde.apiFetch(`/api/v1/admin/trips/${uuid}`);
|
|
if (!r.ok) { window.Verde.toast('Failed to load', 'error'); return; }
|
|
renderDetail(r.body.data);
|
|
detail.classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
let editingTripId = null;
|
|
let tripMapInstance = null;
|
|
let tripRoutingControl = null;
|
|
|
|
function renderDetail(t) {
|
|
window.currentTrip = t;
|
|
document.getElementById('detail-title').textContent = t.trip_number;
|
|
const stops = (t.stops ?? []).map(s => `
|
|
<li class="rounded border border-neutral-200 p-2 text-xs">
|
|
<div class="flex items-center justify-between">
|
|
<span class="font-mono text-neutral-400">#${s.sequence}</span>
|
|
${badge(s.status)}
|
|
</div>
|
|
<div class="font-medium text-neutral-900">${window.Verde.escapeHtml(s.drop_off_point?.name ?? '—')}</div>
|
|
${s.actual_arrival ? `<div class="mt-1 text-neutral-500">Arrived ${window.Verde.formatDate(s.actual_arrival)}</div>` : ''}
|
|
${s.skip_reason ? `<div class="mt-1 italic text-red-600">Skipped: ${window.Verde.escapeHtml(s.skip_reason)}</div>` : ''}
|
|
</li>
|
|
`).join('');
|
|
const timeline = (t.timeline ?? []).map(e => `
|
|
<li class="border-l-2 border-verde-200 pl-3 text-xs">
|
|
<div class="flex items-baseline gap-2">
|
|
<span class="font-mono text-neutral-400">${window.Verde.formatDate(e.event_at, { hour: '2-digit', minute: '2-digit' })}</span>
|
|
<span class="font-medium text-neutral-900">${e.event_type.replace(/_/g, ' ')}</span>
|
|
</div>
|
|
${e.notes ? `<div class="mt-0.5 text-neutral-600">${window.Verde.escapeHtml(e.notes)}</div>` : ''}
|
|
</li>
|
|
`).join('');
|
|
|
|
let actionsHtml = '';
|
|
if (t.status === 'scheduled') {
|
|
actionsHtml = `
|
|
<div class="mb-4 flex gap-2">
|
|
<button type="button" class="btn-ghost text-xs py-1 px-2" onclick="window.editTrip(${t.db_id})">Edit</button>
|
|
<button type="button" class="btn-rejected text-xs py-1 px-2" onclick="window.deleteTrip(${t.db_id})">Delete</button>
|
|
</div>
|
|
`;
|
|
} else if (['in_progress', 'at_dumpsite'].includes(t.status)) {
|
|
actionsHtml = `
|
|
<div class="mb-4 flex gap-2">
|
|
<button type="button" class="text-xs py-1 px-3 rounded bg-amber-50 border border-amber-300 text-amber-800 hover:bg-amber-100 font-medium" onclick="window.openHandOff('${t.id}')">⚠ Hand Off to Another Team</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
document.getElementById('detail-body').innerHTML = `
|
|
${actionsHtml}
|
|
${t.parent_trip ? `<div class="mb-3 rounded border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">📦 Continuation of Trip <strong>${window.Verde.escapeHtml(t.parent_trip.trip_number)}</strong>${t.end_reason ? ` — <em>${t.end_reason.replace('_', ' ')}</em>` : ''}</div>` : ''}
|
|
${(t.continuation_trips ?? []).length ? `<div class="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">🔄 Continued by: ${t.continuation_trips.map(c => `<strong>${window.Verde.escapeHtml(c.trip_number)}</strong> (${window.Verde.escapeHtml(c.team_name ?? '—')} · ${c.status})`).join(', ')}</div>` : ''}
|
|
<div id="trip-map" class="mb-4 rounded border" style="height: 250px; z-index: 0;"></div>
|
|
<div class="mb-4 grid grid-cols-2 gap-y-2 text-sm">
|
|
<dt class="text-neutral-500">Status</dt><dd>${badge(t.status)}</dd>
|
|
<dt class="text-neutral-500">Date</dt><dd>${window.Verde.escapeHtml(t.scheduled_date)}</dd>
|
|
<dt class="text-neutral-500">Route</dt><dd>${window.Verde.escapeHtml(t.route?.name ?? '—')}</dd>
|
|
<dt class="text-neutral-500">Team</dt><dd>${window.Verde.escapeHtml(t.team?.name ?? '—')}</dd>
|
|
<dt class="text-neutral-500">Load</dt><dd>${t.total_load_kg
|
|
? `${t.total_load_kg} kg${t.releases?.length ? ` (${t.releases.length} release${t.releases.length > 1 ? 's' : ''})` : ''}`
|
|
: '—'}</dd>
|
|
</div>
|
|
<h4 class="mb-2 text-xs font-semibold uppercase tracking-wider text-neutral-500">Stops</h4>
|
|
<ul class="mb-4 space-y-1.5">${stops || '<li class="text-xs text-neutral-400">No stops</li>'}</ul>
|
|
<h4 class="mb-2 text-xs font-semibold uppercase tracking-wider text-neutral-500">Timeline</h4>
|
|
<ul class="space-y-2">${timeline || '<li class="text-xs text-neutral-400">No events yet</li>'}</ul>
|
|
<h4 class="mt-4 mb-2 text-xs font-semibold uppercase tracking-wider text-neutral-500">Dumpsite Releases</h4>
|
|
${t.releases?.length
|
|
? `<ul class="space-y-2">${t.releases.map(r => `
|
|
<li class="rounded border border-neutral-200 bg-neutral-50 p-2.5 text-xs">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="font-semibold text-neutral-800">${window.Verde.escapeHtml(r.dumpsite_name || '—')}</span>
|
|
<span class="font-mono text-neutral-400">${window.Verde.formatDate(r.released_at, { hour: '2-digit', minute: '2-digit' })}</span>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<span class="font-bold text-verde-700">${r.weight_kg} kg</span>
|
|
${r.gate_pass_number ? `<span class="text-neutral-500">Gate pass: ${window.Verde.escapeHtml(r.gate_pass_number)}</span>` : ''}
|
|
</div>
|
|
<div class="mt-1 text-neutral-500">Driver: ${window.Verde.escapeHtml(r.driver_name || '—')}</div>
|
|
${r.dumpsite_attendant ? `<div class="text-neutral-500">Attendant: ${window.Verde.escapeHtml(r.dumpsite_attendant)}</div>` : ''}
|
|
${r.notes ? `<div class="mt-1 italic text-neutral-400">${window.Verde.escapeHtml(r.notes)}</div>` : ''}
|
|
</li>
|
|
`).join('')}</ul>`
|
|
: '<p class="text-xs text-neutral-400">No releases recorded yet</p>'
|
|
}
|
|
`;
|
|
|
|
if (tripMapInstance) {
|
|
tripMapInstance.remove();
|
|
tripMapInstance = null;
|
|
}
|
|
|
|
setTimeout(() => {
|
|
tripMapInstance = L.map('trip-map').setView([14.5995, 120.9842], 12);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© OpenStreetMap'
|
|
}).addTo(tripMapInstance);
|
|
|
|
const wps = [];
|
|
(t.stops ?? []).forEach(s => {
|
|
if (s.drop_off_point?.coordinates) {
|
|
wps.push(L.latLng(s.drop_off_point.coordinates.lat, s.drop_off_point.coordinates.lng));
|
|
}
|
|
});
|
|
if (t.dumpsite?.coordinates) {
|
|
wps.push(L.latLng(t.dumpsite.coordinates.lat, t.dumpsite.coordinates.lng));
|
|
}
|
|
|
|
if (wps.length >= 2) {
|
|
tripRoutingControl = L.Routing.control({
|
|
router: L.Routing.osrmv1({ serviceUrl: 'https://routing.openstreetmap.de/routed-car/route/v1', profile: 'driving' }),
|
|
waypoints: wps,
|
|
routeWhileDragging: false,
|
|
addWaypoints: false,
|
|
show: false,
|
|
createMarker: function(i, wp, n) {
|
|
return L.marker(wp.latLng, {
|
|
icon: L.icon({
|
|
iconUrl: i === n - 1
|
|
? 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png'
|
|
: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png',
|
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
|
|
iconSize: [25, 41],
|
|
iconAnchor: [12, 41],
|
|
popupAnchor: [1, -34]
|
|
})
|
|
});
|
|
}
|
|
}).addTo(tripMapInstance);
|
|
} else if (wps.length === 1) {
|
|
L.marker(wps[0]).addTo(tripMapInstance);
|
|
tripMapInstance.setView(wps[0], 15);
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
window.deleteTrip = async (id) => {
|
|
if (!confirm('Are you sure you want to delete this trip?')) return;
|
|
const res = await window.Verde.apiFetch('/api/v1/admin/trips/' + id, { method: 'DELETE' });
|
|
if (res.ok) {
|
|
window.Verde.toast('Trip deleted', 'success');
|
|
document.getElementById('detail-modal').classList.add('hidden');
|
|
load();
|
|
}
|
|
};
|
|
|
|
window.editTrip = (id) => {
|
|
const t = window.currentTrip;
|
|
if (!t) return;
|
|
editingTripId = t.db_id;
|
|
document.querySelector('#form-modal h3').textContent = 'Edit trip';
|
|
const form = document.getElementById('create-form');
|
|
form.route_id.value = t.route?.db_id || '';
|
|
form.team_id.value = t.team?.db_id || '';
|
|
form.scheduled_date.value = t.scheduled_date || '';
|
|
form.scheduled_start_time.value = t.scheduled_start_time ? t.scheduled_start_time.substring(0, 5) : '';
|
|
form.notes.value = t.notes || '';
|
|
previewRoute(t.route?.id || '');
|
|
document.getElementById('detail-modal').classList.add('hidden');
|
|
document.getElementById('form-modal').classList.remove('hidden');
|
|
};
|
|
|
|
document.getElementById('new-btn').addEventListener('click', () => {
|
|
editingTripId = null;
|
|
document.getElementById('create-form').reset();
|
|
document.getElementById('conflict-banner').classList.add('hidden');
|
|
previewRoute('');
|
|
document.querySelector('#form-modal h3').textContent = 'Schedule trip';
|
|
modal.classList.remove('hidden');
|
|
});
|
|
[modal, detail].forEach(m => m.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', () => m.classList.add('hidden'))));
|
|
document.getElementById('filter-apply').addEventListener('click', load);
|
|
document.getElementById('filter-status').addEventListener('change', load);
|
|
document.getElementById('route-select').addEventListener('change', (e) => {
|
|
const opt = e.target.options[e.target.selectedIndex];
|
|
previewRoute(opt && opt.dataset.uuid ? opt.dataset.uuid : '');
|
|
});
|
|
|
|
let previewMapInstance = null;
|
|
let previewRoutingControl = null;
|
|
|
|
async function previewRoute(routeId) {
|
|
const container = document.getElementById('preview-map-container');
|
|
if (!routeId) {
|
|
container.classList.add('hidden');
|
|
if (previewMapInstance) {
|
|
previewMapInstance.remove();
|
|
previewMapInstance = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
container.classList.remove('hidden');
|
|
|
|
if (previewMapInstance) {
|
|
previewMapInstance.remove();
|
|
previewMapInstance = null;
|
|
}
|
|
|
|
previewMapInstance = L.map('preview-map').setView([14.5995, 120.9842], 12);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© OpenStreetMap'
|
|
}).addTo(previewMapInstance);
|
|
|
|
const res = await window.Verde.apiFetch(`/api/v1/admin/routes/${routeId}`);
|
|
if (!res.ok) return;
|
|
|
|
const route = res.body.data;
|
|
const wps = [];
|
|
(route.stops ?? []).forEach(s => {
|
|
if (s.drop_off_point?.coordinates) {
|
|
wps.push(L.latLng(s.drop_off_point.coordinates.lat, s.drop_off_point.coordinates.lng));
|
|
}
|
|
});
|
|
if (route.default_dumpsite?.coordinates) {
|
|
wps.push(L.latLng(route.default_dumpsite.coordinates.lat, route.default_dumpsite.coordinates.lng));
|
|
}
|
|
|
|
if (wps.length >= 2) {
|
|
previewRoutingControl = L.Routing.control({
|
|
router: L.Routing.osrmv1({ serviceUrl: 'https://routing.openstreetmap.de/routed-car/route/v1', profile: 'driving' }),
|
|
waypoints: wps,
|
|
routeWhileDragging: false,
|
|
addWaypoints: false,
|
|
show: false,
|
|
createMarker: function(i, wp, n) {
|
|
return L.marker(wp.latLng, {
|
|
icon: L.icon({
|
|
iconUrl: i === n - 1
|
|
? 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png'
|
|
: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png',
|
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
|
|
iconSize: [25, 41],
|
|
iconAnchor: [12, 41],
|
|
popupAnchor: [1, -34]
|
|
})
|
|
});
|
|
}
|
|
}).addTo(previewMapInstance);
|
|
} else if (wps.length === 1) {
|
|
L.marker(wps[0]).addTo(previewMapInstance);
|
|
previewMapInstance.setView(wps[0], 15);
|
|
}
|
|
}
|
|
|
|
document.getElementById('create-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
async function submitTrip(override = false) {
|
|
const fd = new FormData(e.target);
|
|
const payload = Object.fromEntries(fd.entries());
|
|
payload.route_id = parseInt(payload.route_id, 10);
|
|
payload.team_id = parseInt(payload.team_id, 10);
|
|
|
|
if (payload.scheduled_start_time) {
|
|
payload.scheduled_start_time = payload.scheduled_start_time.substring(0, 5);
|
|
} else {
|
|
delete payload.scheduled_start_time;
|
|
}
|
|
if (!payload.notes) delete payload.notes;
|
|
if (override) payload.override_conflicts = true;
|
|
|
|
const res = await window.Verde.apiFetch(editingTripId ? '/api/v1/admin/trips/' + editingTripId : '/api/v1/admin/trips', {
|
|
method: editingTripId ? 'PUT' : 'POST', body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (res.ok) {
|
|
window.Verde.toast(editingTripId ? 'Trip updated' : 'Trip scheduled', 'success');
|
|
modal.classList.add('hidden');
|
|
document.getElementById('conflict-banner').classList.add('hidden');
|
|
e.target.reset();
|
|
load();
|
|
} else {
|
|
if (res.status === 422 && res.body?.data?.conflicts) {
|
|
const banner = document.getElementById('conflict-banner');
|
|
document.getElementById('conflict-message').innerText = res.body.data.conflicts.join("\n");
|
|
banner.classList.remove('hidden');
|
|
} else if (res.status === 422 && res.body?.errors) {
|
|
const msgs = Object.values(res.body.errors).flat().join('\n');
|
|
window.Verde.toast(msgs, 'error');
|
|
} else {
|
|
window.Verde.toast(res.body?.message ?? 'Schedule failed', 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
submitTrip();
|
|
});
|
|
|
|
document.getElementById('override-btn').addEventListener('click', () => {
|
|
document.getElementById('conflict-banner').classList.add('hidden');
|
|
document.getElementById('create-form').dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
|
// The event listener doesn't pass 'override', so we need a cleaner way.
|
|
// Let's set a hidden input.
|
|
let input = document.getElementById('override_conflicts_input');
|
|
if (!input) {
|
|
input = document.createElement('input');
|
|
input.type = 'hidden';
|
|
input.id = 'override_conflicts_input';
|
|
input.name = 'override_conflicts';
|
|
document.getElementById('create-form').appendChild(input);
|
|
}
|
|
input.value = 'true';
|
|
document.getElementById('create-form').querySelector('button[type="submit"]').click();
|
|
setTimeout(() => { if(input) input.remove(); }, 100);
|
|
});
|
|
|
|
Promise.all([loadRoutes(), loadTeams()]);
|
|
load();
|
|
|
|
// ── Hand Off / Continuation ────────────────────────────────────────────
|
|
let handoffTripId = null;
|
|
|
|
async function populateHandoffSelects() {
|
|
const [teamsRes, trucksRes] = await Promise.all([
|
|
window.Verde.apiFetch('/api/v1/admin/teams?status=active&per_page=200'),
|
|
window.Verde.apiFetch('/api/v1/admin/trucks?per_page=200'),
|
|
]);
|
|
const teamSel = document.getElementById('handoff-team-select');
|
|
const truckSel = document.getElementById('handoff-truck-select');
|
|
if (teamsRes.ok) {
|
|
(teamsRes.body.data ?? []).forEach(t => {
|
|
const o = document.createElement('option');
|
|
o.value = t.db_id;
|
|
o.textContent = t.name;
|
|
teamSel.appendChild(o);
|
|
});
|
|
}
|
|
if (trucksRes.ok) {
|
|
(trucksRes.body.data ?? []).forEach(t => {
|
|
const o = document.createElement('option');
|
|
o.value = t.db_id;
|
|
o.textContent = `${t.plate_number}${t.capacity_kg ? ' (' + t.capacity_kg + ' kg)' : ''}`;
|
|
truckSel.appendChild(o);
|
|
});
|
|
}
|
|
}
|
|
populateHandoffSelects();
|
|
|
|
window.openHandOff = (tripUuid) => {
|
|
handoffTripId = tripUuid;
|
|
document.getElementById('handoff-form').reset();
|
|
document.getElementById('handoff-modal').classList.remove('hidden');
|
|
};
|
|
|
|
document.getElementById('handoff-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(e.target);
|
|
const payload = {
|
|
end_reason: fd.get('end_reason'),
|
|
new_team_id: parseInt(fd.get('new_team_id'), 10),
|
|
};
|
|
const newTruck = fd.get('new_truck_id');
|
|
if (newTruck) payload.new_truck_id = parseInt(newTruck, 10);
|
|
|
|
const res = await window.Verde.apiFetch(`/api/v1/admin/trips/${handoffTripId}/continue`, {
|
|
method: 'POST', body: JSON.stringify(payload),
|
|
});
|
|
if (res.ok) {
|
|
const newNum = res.body?.data?.trip_number ?? '';
|
|
window.Verde.toast(`Continuation trip ${newNum} created`, 'success');
|
|
document.getElementById('handoff-modal').classList.add('hidden');
|
|
document.getElementById('detail-modal').classList.add('hidden');
|
|
load();
|
|
} else {
|
|
window.Verde.toast(res.body?.message ?? 'Hand off failed', 'error');
|
|
}
|
|
});
|
|
</script>
|
|
@endsection
|