429 lines
19 KiB
PHP
429 lines
19 KiB
PHP
@extends('admin.layouts.app', ['pageTitle' => 'Dumpsites'])
|
||
|
||
@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>
|
||
|
||
<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">Dumpsites</h2>
|
||
<p class="mt-1 text-sm text-neutral-500">Final disposal facilities with geofence boundaries.</p>
|
||
</div>
|
||
<button id="new-btn" class="btn-primary">+ New Dumpsite</button>
|
||
</header>
|
||
|
||
<div class="table-wrap">
|
||
<table class="table">
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>Code</th>
|
||
<th>Capacity</th>
|
||
<th>Permit #</th>
|
||
<th>Boundary</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 id="modal-title" class="text-base font-semibold text-neutral-900">New dumpsite</h3>
|
||
<button data-close aria-label="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" autocomplete="off">
|
||
<div><label class="form-label">Name</label><input name="name" required class="form-input"></div>
|
||
<div><label class="form-label">Code</label><input name="code" id="form-code" required class="form-input" placeholder="DS-..."></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="ds-lat" type="number" step="0.000001" required class="form-input bg-neutral-100 cursor-not-allowed" readonly></div>
|
||
<div><label class="form-label">Longitude</label><input name="lng" id="ds-lng" type="number" step="0.000001" required class="form-input bg-neutral-100 cursor-not-allowed" 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="ds-search" class="form-input flex-1 text-sm" placeholder="Search a place (e.g. Payatas, Quezon City...)">
|
||
<button type="button" id="ds-search-btn" class="btn-primary px-3 py-2 text-xs">Search</button>
|
||
</div>
|
||
<div class="relative">
|
||
<ul id="ds-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>
|
||
<div class="flex items-center justify-between">
|
||
<label class="form-label mb-0">Boundary polygon (geofence)</label>
|
||
<button type="button" id="add-point" class="text-xs font-medium text-verde-700 hover:text-verde-800">+ Add point</button>
|
||
</div>
|
||
<p class="mb-2 mt-1 text-xs text-neutral-500">At least 3 points. Ring auto-closes.</p>
|
||
<div id="boundary-points" class="space-y-2"></div>
|
||
</div>
|
||
<div><label class="form-label">Capacity (tons)</label><input name="capacity_tons" type="number" min="0" class="form-input"></div>
|
||
<div><label class="form-label">Permit number</label><input name="permit_number" class="form-input" placeholder="DENR-..."></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">
|
||
<span class="btn-label">Create</span>
|
||
<svg class="btn-spinner ml-2 hidden h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8v4a4 4 0 0 0-4 4H4z"/>
|
||
</svg>
|
||
</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 Dumpsite</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<script type="module">
|
||
const rows = document.getElementById('rows');
|
||
const modal = document.getElementById('form-modal');
|
||
const pointsEl = document.getElementById('boundary-points');
|
||
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 editingDumpsiteId = 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 editDumpsite(id) {
|
||
editingDumpsiteId = id;
|
||
modalTitle.textContent = 'Edit dumpsite';
|
||
submitBtn.querySelector('.btn-label').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/dumpsites/${id}`);
|
||
if (!res.ok) {
|
||
window.Verde.toast('Failed to load dumpsite details', 'error');
|
||
return;
|
||
}
|
||
|
||
const d = res.body.data;
|
||
createForm.querySelector('[name="name"]').value = d.name;
|
||
createForm.querySelector('[name="code"]').value = d.code;
|
||
createForm.querySelector('[name="address_line"]').value = d.address_line;
|
||
createForm.querySelector('[name="capacity_tons"]').value = d.capacity_tons ?? '';
|
||
createForm.querySelector('[name="permit_number"]').value = d.permit_number ?? '';
|
||
createForm.querySelector('[name="status"]').value = d.status;
|
||
|
||
// Populate boundary geofence points
|
||
pointsEl.innerHTML = '';
|
||
if (d.boundary_polygon && d.boundary_polygon.length > 0) {
|
||
const pts = d.boundary_polygon.slice();
|
||
if (pts.length > 1) {
|
||
const first = pts[0];
|
||
const last = pts[pts.length - 1];
|
||
if (first.lat === last.lat && first.lng === last.lng) {
|
||
pts.pop();
|
||
}
|
||
}
|
||
pts.forEach(p => addPointRow(p.lat, p.lng));
|
||
} else {
|
||
addPointRow(); addPointRow(); addPointRow();
|
||
}
|
||
|
||
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);
|
||
|
||
modal.classList.remove('hidden');
|
||
await initMap(lat, lng);
|
||
}
|
||
|
||
async function load() {
|
||
rows.innerHTML = `<tr><td colspan="7" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>`;
|
||
const res = await window.Verde.apiFetch('/api/v1/admin/dumpsites?per_page=50');
|
||
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 dumpsites configured.</td></tr>`; return; }
|
||
rows.innerHTML = items.map(d => `
|
||
<tr>
|
||
<td class="font-medium text-neutral-900">${window.Verde.escapeHtml(d.name)}</td>
|
||
<td class="font-mono text-xs text-neutral-600">${window.Verde.escapeHtml(d.code)}</td>
|
||
<td>${d.capacity_tons ? `${d.capacity_tons} t` : '—'}</td>
|
||
<td class="font-mono text-xs">${window.Verde.escapeHtml(d.permit_number ?? '—')}</td>
|
||
<td>${d.boundary_polygon && d.boundary_polygon.length > 0
|
||
? `<span class="badge badge-active">${d.boundary_polygon.length - 1} pts</span>`
|
||
: '<span class="badge badge-inactive">None</span>'}</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', () => editDumpsite(btn.dataset.id));
|
||
});
|
||
}
|
||
|
||
function addPointRow(lat = '', lng = '') {
|
||
const div = document.createElement('div');
|
||
div.className = 'flex gap-2';
|
||
div.innerHTML = `
|
||
<input type="number" step="0.000001" placeholder="lat" value="${lat}" class="form-input flex-1 boundary-lat" required>
|
||
<input type="number" step="0.000001" placeholder="lng" value="${lng}" class="form-input flex-1 boundary-lng" required>
|
||
<button type="button" class="btn-ghost px-2 remove-point" aria-label="Remove point">×</button>
|
||
`;
|
||
div.querySelector('.remove-point').addEventListener('click', () => div.remove());
|
||
pointsEl.appendChild(div);
|
||
}
|
||
|
||
function getBoundary() {
|
||
const pts = [];
|
||
pointsEl.querySelectorAll('.flex').forEach(row => {
|
||
const lat = row.querySelector('.boundary-lat').value;
|
||
const lng = row.querySelector('.boundary-lng').value;
|
||
if (lat && lng) pts.push({ lat: parseFloat(lat), lng: parseFloat(lng) });
|
||
});
|
||
return pts;
|
||
}
|
||
|
||
function awaitLeaflet() {
|
||
return new Promise(resolve => {
|
||
const tick = () => window.L ? resolve(window.L) : setTimeout(tick, 50);
|
||
tick();
|
||
});
|
||
}
|
||
|
||
let map = null;
|
||
let marker = null;
|
||
const latInput = document.getElementById('ds-lat');
|
||
const lngInput = document.getElementById('ds-lng');
|
||
|
||
async function initMap(initialLat = 14.6539, initialLng = 121.0685) {
|
||
if (map) {
|
||
if (marker) {
|
||
marker.setLatLng([initialLat, initialLng]);
|
||
}
|
||
map.setView([initialLat, initialLng], 13);
|
||
setTimeout(() => map.invalidateSize(), 50);
|
||
return;
|
||
}
|
||
|
||
const L = await awaitLeaflet();
|
||
map = L.map('map-picker').setView([initialLat, initialLng], 13);
|
||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© OpenStreetMap',
|
||
maxZoom: 19,
|
||
}).addTo(map);
|
||
|
||
marker = L.marker([initialLat, initialLng], { draggable: true }).addTo(map);
|
||
|
||
marker.on('dragend', function () {
|
||
const pos = marker.getLatLng();
|
||
latInput.value = pos.lat.toFixed(6);
|
||
lngInput.value = pos.lng.toFixed(6);
|
||
});
|
||
|
||
map.on('click', function (e) {
|
||
marker.setLatLng(e.latlng);
|
||
latInput.value = e.latlng.lat.toFixed(6);
|
||
lngInput.value = e.latlng.lng.toFixed(6);
|
||
});
|
||
|
||
// Setup geocoding search
|
||
const searchInput = document.getElementById('ds-search');
|
||
const searchBtn = document.getElementById('ds-search-btn');
|
||
const resultsList = document.getElementById('ds-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);
|
||
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', () => {
|
||
editingDumpsiteId = null;
|
||
createForm.reset();
|
||
modalTitle.textContent = 'New dumpsite';
|
||
submitBtn.querySelector('.btn-label').textContent = 'Create';
|
||
deleteBtn.classList.add('hidden');
|
||
|
||
formCode.readOnly = false;
|
||
formCode.classList.remove('bg-neutral-100');
|
||
|
||
pointsEl.innerHTML = '';
|
||
addPointRow(); addPointRow(); addPointRow();
|
||
latInput.value = '14.653900';
|
||
lngInput.value = '121.068500';
|
||
modal.classList.remove('hidden');
|
||
initMap(14.6539, 121.0685);
|
||
});
|
||
|
||
modal.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', () => modal.classList.add('hidden')));
|
||
document.getElementById('add-point').addEventListener('click', () => addPointRow());
|
||
|
||
createForm.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
|
||
const btnLabel = submitBtn.querySelector('.btn-label');
|
||
const btnSpinner = submitBtn.querySelector('.btn-spinner');
|
||
|
||
submitBtn.disabled = true;
|
||
btnLabel.textContent = editingDumpsiteId ? 'Saving…' : 'Creating…';
|
||
btnSpinner.classList.remove('hidden');
|
||
|
||
const fd = new FormData(e.target);
|
||
const payload = Object.fromEntries(fd.entries());
|
||
payload.lat = parseFloat(payload.lat);
|
||
payload.lng = parseFloat(payload.lng);
|
||
if (payload.capacity_tons) payload.capacity_tons = parseInt(payload.capacity_tons, 10);
|
||
else delete payload.capacity_tons;
|
||
|
||
const boundary = getBoundary();
|
||
if (boundary.length >= 3) payload.boundary_polygon = boundary;
|
||
|
||
const url = editingDumpsiteId ? `/api/v1/admin/dumpsites/${editingDumpsiteId}` : '/api/v1/admin/dumpsites';
|
||
const method = editingDumpsiteId ? 'PATCH' : 'POST';
|
||
|
||
const res = await window.Verde.apiFetch(url, {
|
||
method: method,
|
||
body: JSON.stringify(payload),
|
||
});
|
||
|
||
submitBtn.disabled = false;
|
||
btnLabel.textContent = editingDumpsiteId ? 'Save Changes' : 'Create';
|
||
btnSpinner.classList.add('hidden');
|
||
|
||
if (res.ok) {
|
||
window.Verde.toast(editingDumpsiteId ? 'Dumpsite updated' : 'Dumpsite created', 'success');
|
||
modal.classList.add('hidden');
|
||
createForm.reset();
|
||
load();
|
||
} else {
|
||
window.Verde.toast(res.body?.message ?? 'Save failed', 'error');
|
||
}
|
||
});
|
||
|
||
deleteBtn.addEventListener('click', async () => {
|
||
if (!editingDumpsiteId) return;
|
||
if (!confirm('Are you sure you want to delete this dumpsite? This action cannot be undone.')) return;
|
||
|
||
const res = await window.Verde.apiFetch(`/api/v1/admin/dumpsites/${editingDumpsiteId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (res.ok) {
|
||
window.Verde.toast('Dumpsite deleted successfully', 'success');
|
||
modal.classList.add('hidden');
|
||
createForm.reset();
|
||
load();
|
||
} else {
|
||
window.Verde.toast(res.body?.message ?? 'Delete failed', 'error');
|
||
}
|
||
});
|
||
|
||
load();
|
||
</script>
|
||
@endsection
|