Files
Verde-Web/resources/views/admin/dumpsites.blade.php

429 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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: '&copy; 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