Files
Verde-Web/resources/views/admin/drop-off-points.blade.php

566 lines
24 KiB
PHP

@extends('admin.layouts.app', ['pageTitle' => 'Drop-off Points'])
@section('page')
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
<link rel="stylesheet" href="https://unpkg.com/@geoman-io/leaflet-geoman-free@2.14.2/dist/leaflet-geoman.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="https://unpkg.com/@geoman-io/leaflet-geoman-free@2.14.2/dist/leaflet-geoman.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@turf/turf@6/turf.min.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">Drop-off Points</h2>
<p class="mt-1 text-sm text-neutral-500">Where residents bring trash for collection.</p>
</div>
<button id="new-btn" class="btn-primary">+ New DOP</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="active">Active</option>
<option value="maintenance">Maintenance</option>
<option value="closed">Closed</option>
</select>
<input id="filter-q" type="search" placeholder="Search name, code, address…" class="form-input flex-1 min-w-[200px]">
<button id="filter-apply" class="btn-primary">Apply</button>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>LGU</th>
<th>Code</th>
<th>Address</th>
<th>Capacity</th>
<th>Status</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody id="rows">
<tr><td colspan="6" 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 id="modal-title" class="text-base font-semibold text-neutral-900">New drop-off point</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>
<form id="create-form" class="space-y-4">
<input type="hidden" name="geofence_wkt" id="geofence_wkt">
<div>
<label class="form-label">Name</label>
<input name="name" required class="form-input">
</div>
<div>
<label class="form-label">Barangay (LGU)</label>
<select name="barangay_id" id="form-barangay" class="form-select">
<option value=""> Select Barangay </option>
</select>
</div>
<div>
<label class="form-label">Code</label>
<input name="code" id="form-code" required class="form-input" placeholder="DOP-...">
</div>
<div>
<label class="form-label">Address</label>
<input name="address_line" required class="form-input">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="form-label">Latitude</label>
<input name="lat" id="dop-lat" type="number" step="0.000001" required class="form-input bg-neutral-100 cursor-not-allowed" placeholder="14.6539" readonly>
</div>
<div>
<label class="form-label">Longitude</label>
<input name="lng" id="dop-lng" type="number" step="0.000001" required class="form-input bg-neutral-100 cursor-not-allowed" placeholder="121.0685" readonly>
</div>
</div>
<div class="space-y-1">
<label class="form-label text-neutral-700">Search Address / Place</label>
<div class="flex gap-2">
<input type="text" id="dop-search" class="form-input flex-1 text-sm" placeholder="Search a place (e.g. Payatas, Quezon City...)">
<button type="button" id="dop-search-btn" class="btn-primary px-3 py-2 text-xs">Search</button>
</div>
<div class="relative">
<ul id="dop-search-results" class="absolute z-50 w-full rounded border border-neutral-200 bg-white shadow-md max-h-40 overflow-y-auto hidden"></ul>
</div>
</div>
<div>
<label class="form-label text-neutral-700">Pin Location on Map</label>
<div id="map-picker" class="h-48 w-full rounded border border-neutral-200" style="min-height: 200px; z-index: 0;"></div>
<p class="mt-1 text-xs text-neutral-500">Click anywhere on the map or drag the marker to auto-fill coordinates.</p>
</div>
<div>
<label class="form-label">Capacity (kg)</label>
<input name="capacity_kg" type="number" min="0" class="form-input" placeholder="1000">
</div>
<div>
<label class="form-label">Contact Person</label>
<input name="contact_person" class="form-input" placeholder="Juan Dela Cruz">
</div>
<div>
<label class="form-label">Contact Phone</label>
<input name="contact_phone" class="form-input" placeholder="+639...">
</div>
<div>
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="active">Active</option>
<option value="maintenance">Maintenance</option>
<option value="closed">Closed</option>
</select>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" data-close class="btn-ghost">Cancel</button>
<button type="submit" id="submit-btn" class="btn-primary">Create</button>
</div>
<div class="pt-2 border-t border-neutral-100 mt-4" id="delete-container">
<button type="button" id="delete-btn" class="btn-danger w-full hidden">Delete Drop-off Point</button>
</div>
</form>
</div>
</div>
<script type="module">
const rows = document.getElementById('rows');
const modal = document.getElementById('form-modal');
const createForm = document.getElementById('create-form');
const deleteBtn = document.getElementById('delete-btn');
const submitBtn = document.getElementById('submit-btn');
const modalTitle = document.getElementById('modal-title');
const formCode = document.getElementById('form-code');
let editingDopId = null;
function badge(s) {
const m = { active: 'badge-active', maintenance: 'badge-pending', closed: 'badge-rejected' };
return `<span class="badge ${m[s] || 'badge-inactive'}">${s}</span>`;
}
async function editDop(id) {
editingDopId = id;
modalTitle.textContent = 'Edit drop-off point';
submitBtn.textContent = 'Save Changes';
deleteBtn.classList.remove('hidden');
formCode.readOnly = true;
formCode.classList.add('bg-neutral-100');
const res = await window.Verde.apiFetch(`/api/v1/admin/drop-off-points/${id}`);
if (!res.ok) {
window.Verde.toast('Failed to load drop-off point details', 'error');
return;
}
const d = res.body.data.drop_off_point || res.body.data;
createForm.querySelector('[name="name"]').value = d.name;
createForm.querySelector('[name="code"]').value = d.code;
createForm.querySelector('[name="barangay_id"]').value = d.barangay_id || '';
barangaySelect.dispatchEvent(new Event('change'));
createForm.querySelector('[name="address_line"]').value = d.address_line;
createForm.querySelector('[name="capacity_kg"]').value = d.capacity_kg ?? '';
createForm.querySelector('[name="contact_person"]').value = d.contact_person ?? '';
createForm.querySelector('[name="contact_phone"]').value = d.contact_phone ?? '';
createForm.querySelector('[name="status"]').value = d.status;
const lat = d.coordinates ? parseFloat(d.coordinates.lat) : 14.6539;
const lng = d.coordinates ? parseFloat(d.coordinates.lng) : 121.0685;
latInput.value = lat.toFixed(6);
lngInput.value = lng.toFixed(6);
if (d.geofence) {
geofenceInput.value = JSON.stringify(d.geofence);
} else {
geofenceInput.value = '';
}
modal.classList.remove('hidden');
await initMap(lat, lng, d.geofence);
}
async function load() {
rows.innerHTML = `<tr><td colspan="6" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>`;
const params = new URLSearchParams();
const status = document.getElementById('filter-status').value;
const q = document.getElementById('filter-q').value.trim();
if (status) params.set('status', status);
if (q) params.set('q', q);
params.set('per_page', '50');
const res = await window.Verde.apiFetch(`/api/v1/admin/drop-off-points?${params}`);
if (!res.ok) {
rows.innerHTML = `<tr><td colspan="6" 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="6" class="py-10 text-center text-sm text-neutral-400">No drop-off points yet.</td></tr>`;
return;
}
rows.innerHTML = items.map(d => `
<tr>
<td class="font-medium text-neutral-900">${window.Verde.escapeHtml(d.name)}</td>
<td class="text-xs text-neutral-500">${window.Verde.escapeHtml(d.tenant?.name || d.barangay?.city_municipality?.name || 'N/A')}</td>
<td class="font-mono text-xs text-neutral-600">${window.Verde.escapeHtml(d.code)}</td>
<td class="max-w-xs truncate text-neutral-600">${window.Verde.escapeHtml(d.address_line)}</td>
<td>${d.capacity_kg ?? '—'} kg</td>
<td>${badge(d.status)}</td>
<td class="text-right">
<button data-id="${d.id}" data-action="edit" class="btn-ghost px-3 py-1 text-xs">Edit</button>
</td>
</tr>
`).join('');
rows.querySelectorAll('button[data-action="edit"]').forEach(btn => {
btn.addEventListener('click', () => editDop(btn.dataset.id));
});
}
function awaitLeaflet() {
return new Promise(resolve => {
const tick = () => window.L ? resolve(window.L) : setTimeout(tick, 50);
tick();
});
}
let map = null;
let marker = null;
let geofenceLayer = null;
let currentLguBoundaryLayer = null;
let currentLguPolygon = null;
let allBarangays = [];
const latInput = document.getElementById('dop-lat');
const lngInput = document.getElementById('dop-lng');
const geofenceInput = document.getElementById('geofence_wkt');
const barangaySelect = document.getElementById('form-barangay');
async function loadBarangays() {
const res = await window.Verde.apiFetch('/api/v1/geo/barangays');
if (res.ok) {
allBarangays = res.body.data || [];
barangaySelect.innerHTML = '<option value="">— Select Barangay —</option>' +
allBarangays.map(b => {
const lgu = b.city_municipality ? b.city_municipality.name : '';
const label = lgu ? `${b.name} (${lgu})` : b.name;
return `<option value="${b.id}">${window.Verde.escapeHtml(label)}</option>`;
}).join('');
}
}
loadBarangays();
barangaySelect.addEventListener('change', () => {
const id = barangaySelect.value;
const b = allBarangays.find(x => x.id == id);
if (currentLguBoundaryLayer && map) {
map.removeLayer(currentLguBoundaryLayer);
currentLguBoundaryLayer = null;
}
currentLguPolygon = null;
if (b && b.boundary && b.boundary.length > 2 && map) {
const coords = b.boundary.map(p => [p.lat, p.lng]);
currentLguBoundaryLayer = L.polygon(coords, { color: '#3b82f6', fillOpacity: 0.1, weight: 2 }).addTo(map);
map.fitBounds(currentLguBoundaryLayer.getBounds());
// Close the loop for turf
const turfCoords = b.boundary.map(p => [p.lng, p.lat]);
turfCoords.push([b.boundary[0].lng, b.boundary[0].lat]);
currentLguPolygon = turf.polygon([turfCoords]);
}
});
function validatePoint(latlng) {
if (!currentLguPolygon) return true;
const pt = turf.point([latlng.lng, latlng.lat]);
return turf.booleanPointInPolygon(pt, currentLguPolygon);
}
function updateGeofenceInput(layer) {
if (!layer) {
geofenceInput.value = '';
return;
}
geofenceInput.value = JSON.stringify(layer.toGeoJSON().geometry);
}
function bindGeofenceEvents(layer) {
layer.on('pm:edit', (e) => updateGeofenceInput(e.layer));
layer.on('pm:remove', () => updateGeofenceInput(null));
}
async function initMap(initialLat = 14.6539, initialLng = 121.0685, geofenceObj = null) {
const L = window.L || await awaitLeaflet();
if (map) {
if (marker) {
marker.setLatLng([initialLat, initialLng]);
}
map.setView([initialLat, initialLng], 13);
if (geofenceLayer) {
map.removeLayer(geofenceLayer);
geofenceLayer = null;
}
if (geofenceObj) {
const group = L.geoJSON(geofenceObj);
if (group.getLayers().length > 0) {
geofenceLayer = group.getLayers()[0];
geofenceLayer.addTo(map);
bindGeofenceEvents(geofenceLayer);
}
}
setTimeout(() => map.invalidateSize(), 50);
return;
}
map = L.map('map-picker').setView([initialLat, initialLng], 13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap',
maxZoom: 19,
}).addTo(map);
marker = L.marker([initialLat, initialLng], { draggable: true }).addTo(map);
marker.on('dragstart', function () {
marker._lastPos = marker.getLatLng();
});
marker.on('dragend', function () {
const pos = marker.getLatLng();
if (!validatePoint(pos)) {
marker.setLatLng(marker._lastPos);
window.Verde.toast('Drop-off point must be within the selected LGU boundary.', 'error');
return;
}
latInput.value = pos.lat.toFixed(6);
lngInput.value = pos.lng.toFixed(6);
});
map.on('click', function (e) {
if (!validatePoint(e.latlng)) {
window.Verde.toast('Drop-off point must be within the selected LGU boundary.', 'error');
return;
}
marker.setLatLng(e.latlng);
latInput.value = e.latlng.lat.toFixed(6);
lngInput.value = e.latlng.lng.toFixed(6);
});
map.pm.addControls({
position: 'topleft',
drawCircle: false,
drawMarker: false,
drawCircleMarker: false,
drawPolyline: false,
drawRectangle: true,
drawText: false,
cutPolygon: false,
editMode: true,
dragMode: true,
removalMode: true
});
map.on('pm:create', (e) => {
if (geofenceLayer) {
map.removeLayer(geofenceLayer);
}
geofenceLayer = e.layer;
bindGeofenceEvents(geofenceLayer);
updateGeofenceInput(geofenceLayer);
});
map.on('pm:remove', (e) => {
if (e.layer === geofenceLayer) {
geofenceLayer = null;
updateGeofenceInput(null);
}
});
if (geofenceObj) {
const group = L.geoJSON(geofenceObj);
if (group.getLayers().length > 0) {
geofenceLayer = group.getLayers()[0];
geofenceLayer.addTo(map);
bindGeofenceEvents(geofenceLayer);
}
}
// Setup geocoding search
const searchInput = document.getElementById('dop-search');
const searchBtn = document.getElementById('dop-search-btn');
const resultsList = document.getElementById('dop-search-results');
function debounce(func, wait) {
let timeout;
return function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
const searchPlace = async () => {
const query = searchInput.value.trim();
if (!query) {
resultsList.innerHTML = '';
resultsList.classList.add('hidden');
return;
}
searchBtn.disabled = true;
searchBtn.textContent = '...';
resultsList.innerHTML = '';
resultsList.classList.add('hidden');
try {
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5`);
if (!res.ok) throw new Error('Search failed');
const data = await res.json();
if (data.length === 0) {
resultsList.innerHTML = `<li class="px-3 py-2 text-xs text-neutral-400">No places found.</li>`;
resultsList.classList.remove('hidden');
return;
}
resultsList.innerHTML = data.map(item => `
<li data-lat="${item.lat}" data-lng="${item.lon}" class="px-3 py-2 text-xs border-b border-neutral-100 cursor-pointer hover:bg-neutral-50 text-neutral-800 last:border-b-0">
${window.Verde.escapeHtml(item.display_name)}
</li>
`).join('');
resultsList.classList.remove('hidden');
resultsList.querySelectorAll('li[data-lat]').forEach(li => {
li.addEventListener('click', () => {
const lat = parseFloat(li.dataset.lat);
const lng = parseFloat(li.dataset.lng);
if (!validatePoint({lat, lng})) {
window.Verde.toast('Searched location is outside the selected LGU boundary.', 'error');
return;
}
latInput.value = lat.toFixed(6);
lngInput.value = lng.toFixed(6);
marker.setLatLng([lat, lng]);
map.setView([lat, lng], 15);
resultsList.classList.add('hidden');
searchInput.value = '';
});
});
} catch (err) {
resultsList.innerHTML = `<li class="px-3 py-2 text-xs text-red-500">Error searching.</li>`;
resultsList.classList.remove('hidden');
} finally {
searchBtn.disabled = false;
searchBtn.textContent = 'Search';
}
};
searchBtn.onclick = (e) => { e.preventDefault(); searchPlace(); };
searchInput.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
searchPlace();
}
};
searchInput.addEventListener('input', debounce(searchPlace, 300));
const dismissSearch = (e) => {
if (!searchInput.contains(e.target) && !searchBtn.contains(e.target) && !resultsList.contains(e.target)) {
resultsList.classList.add('hidden');
}
};
document.removeEventListener('click', dismissSearch);
document.addEventListener('click', dismissSearch);
setTimeout(() => map.invalidateSize(), 100);
}
document.getElementById('new-btn').addEventListener('click', () => {
editingDopId = null;
createForm.reset();
modalTitle.textContent = 'New drop-off point';
submitBtn.textContent = 'Create';
deleteBtn.classList.add('hidden');
formCode.readOnly = false;
formCode.classList.remove('bg-neutral-100');
latInput.value = '14.653900';
lngInput.value = '121.068500';
geofenceInput.value = '';
createForm.querySelector('[name="barangay_id"]').value = '';
barangaySelect.dispatchEvent(new Event('change'));
modal.classList.remove('hidden');
initMap(14.6539, 121.0685, null);
});
modal.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', () => modal.classList.add('hidden')));
document.getElementById('filter-apply').addEventListener('click', load);
document.getElementById('filter-status').addEventListener('change', load);
createForm.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = Object.fromEntries(fd.entries());
payload.lat = parseFloat(payload.lat);
payload.lng = parseFloat(payload.lng);
if (payload.barangay_id) payload.barangay_id = parseInt(payload.barangay_id, 10);
else delete payload.barangay_id;
if (payload.capacity_kg) payload.capacity_kg = parseInt(payload.capacity_kg, 10);
else delete payload.capacity_kg;
if (!payload.contact_person) payload.contact_person = null;
if (!payload.contact_phone) payload.contact_phone = null;
const url = editingDopId ? `/api/v1/admin/drop-off-points/${editingDopId}` : '/api/v1/admin/drop-off-points';
const method = editingDopId ? 'PATCH' : 'POST';
const res = await window.Verde.apiFetch(url, {
method: method,
body: JSON.stringify(payload),
});
if (res.ok) {
window.Verde.toast(editingDopId ? 'Drop-off point updated' : 'Drop-off point created', 'success');
modal.classList.add('hidden');
createForm.reset();
load();
} else {
window.Verde.toast(res.body?.message ?? 'Save failed', 'error');
}
});
deleteBtn.addEventListener('click', async () => {
if (!editingDopId) return;
if (!confirm('Are you sure you want to delete this drop-off point? This action cannot be undone.')) return;
const res = await window.Verde.apiFetch(`/api/v1/admin/drop-off-points/${editingDopId}`, {
method: 'DELETE'
});
if (res.ok) {
window.Verde.toast('Drop-off point deleted successfully', 'success');
modal.classList.add('hidden');
createForm.reset();
load();
} else {
window.Verde.toast(res.body?.message ?? 'Delete failed', 'error');
}
});
load();
</script>
@endsection