feat: implement drop-off points & dumpsites edit/delete and status transition logic
This commit is contained in:
@@ -10,12 +10,15 @@ use App\Http\Resources\DropOffCapacityLogResource;
|
||||
use App\Http\Resources\DropOffPointResource;
|
||||
use App\Models\DropOffCapacityLog;
|
||||
use App\Models\DropOffPoint;
|
||||
use App\Services\DropOff\DropOffPointFinder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Point;
|
||||
|
||||
class AdminDropOffPointController extends ApiController
|
||||
{
|
||||
public function __construct(private readonly DropOffPointFinder $dopFinder) {}
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
@@ -89,12 +92,28 @@ class AdminDropOffPointController extends ApiController
|
||||
public function update(UpdateDropOffPointRequest $request, DropOffPoint $dropOffPoint): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$oldStatus = $dropOffPoint->status;
|
||||
$newStatus = $data['status'] ?? $oldStatus;
|
||||
|
||||
if (isset($data['lat'], $data['lng'])) {
|
||||
$data['coordinates'] = $this->makePoint($data);
|
||||
}
|
||||
unset($data['lat'], $data['lng']);
|
||||
|
||||
$dropOffPoint->update($data);
|
||||
DB::transaction(function () use ($dropOffPoint, $data, $oldStatus, $newStatus) {
|
||||
$dropOffPoint->update($data);
|
||||
|
||||
if ($newStatus !== DropOffPoint::STATUS_ACTIVE && $oldStatus === DropOffPoint::STATUS_ACTIVE) {
|
||||
$households = $dropOffPoint->households;
|
||||
foreach ($households as $household) {
|
||||
$nearest = $this->dopFinder->nearest(
|
||||
$household->coordinates->latitude,
|
||||
$household->coordinates->longitude
|
||||
);
|
||||
$household->update(['assigned_drop_off_point_id' => $nearest?->id]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return $this->ok(
|
||||
new DropOffPointResource($dropOffPoint->fresh()->load('barangay')),
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Http\Requests\Dumpsite\StoreDumpsiteRequest;
|
||||
use App\Http\Requests\Dumpsite\UpdateDumpsiteRequest;
|
||||
use App\Http\Resources\DumpsiteResource;
|
||||
use App\Models\Dumpsite;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use MatanYadaev\EloquentSpatial\Objects\LineString;
|
||||
@@ -102,7 +103,25 @@ class AdminDumpsiteController extends ApiController
|
||||
}
|
||||
}
|
||||
|
||||
$dumpsite->update($payload);
|
||||
$oldStatus = $dumpsite->status;
|
||||
$newStatus = $payload['status'] ?? $oldStatus;
|
||||
|
||||
DB::transaction(function () use ($dumpsite, $payload, $oldStatus, $newStatus) {
|
||||
$dumpsite->update($payload);
|
||||
|
||||
if ($newStatus !== Dumpsite::STATUS_ACTIVE && $oldStatus === Dumpsite::STATUS_ACTIVE) {
|
||||
$trips = \App\Models\Trip::where('dumpsite_id', $dumpsite->id)
|
||||
->whereIn('status', [\App\Models\Trip::STATUS_SCHEDULED, \App\Models\Trip::STATUS_IN_PROGRESS, \App\Models\Trip::STATUS_AT_DUMPSITE])
|
||||
->get();
|
||||
|
||||
foreach ($trips as $trip) {
|
||||
$trip->update([
|
||||
'status' => \App\Models\Trip::STATUS_CANCELLED,
|
||||
'notes' => trim(($trip->notes ?? '') . "\nSystem: Trip cancelled because the dumpsite was moved to " . $newStatus . " status."),
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return $this->ok(
|
||||
new DumpsiteResource($dumpsite->fresh()->load('cityMunicipality')),
|
||||
|
||||
@@ -33,10 +33,11 @@
|
||||
<th>Address</th>
|
||||
<th>Capacity</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rows">
|
||||
<tr><td colspan="5" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>
|
||||
<tr><td colspan="6" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -46,7 +47,7 @@
|
||||
<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">New drop-off point</h3>
|
||||
<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>
|
||||
@@ -58,7 +59,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Code</label>
|
||||
<input name="code" required class="form-input" placeholder="DOP-...">
|
||||
<input name="code" id="form-code" required class="form-input" placeholder="DOP-...">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Address</label>
|
||||
@@ -93,6 +94,14 @@
|
||||
<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">
|
||||
@@ -103,7 +112,10 @@
|
||||
</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">Create</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>
|
||||
@@ -112,14 +124,54 @@
|
||||
<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="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);
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
await initMap(lat, lng);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
rows.innerHTML = `<tr><td colspan="5" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>`;
|
||||
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();
|
||||
@@ -129,12 +181,12 @@
|
||||
|
||||
const res = await window.Verde.apiFetch(`/api/v1/admin/drop-off-points?${params}`);
|
||||
if (!res.ok) {
|
||||
rows.innerHTML = `<tr><td colspan="5" class="py-10 text-center text-sm text-red-500">Failed to load.</td></tr>`;
|
||||
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="5" class="py-10 text-center text-sm text-neutral-400">No drop-off points yet.</td></tr>`;
|
||||
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 => `
|
||||
@@ -144,8 +196,15 @@
|
||||
<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() {
|
||||
@@ -191,8 +250,6 @@
|
||||
lngInput.value = e.latlng.lng.toFixed(6);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Setup geocoding search
|
||||
const searchInput = document.getElementById('dop-search');
|
||||
const searchBtn = document.getElementById('dop-search-btn');
|
||||
@@ -280,6 +337,15 @@
|
||||
}
|
||||
|
||||
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';
|
||||
modal.classList.remove('hidden');
|
||||
@@ -290,7 +356,7 @@
|
||||
document.getElementById('filter-apply').addEventListener('click', load);
|
||||
document.getElementById('filter-status').addEventListener('change', load);
|
||||
|
||||
document.getElementById('create-form').addEventListener('submit', async (e) => {
|
||||
createForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const payload = Object.fromEntries(fd.entries());
|
||||
@@ -298,18 +364,41 @@
|
||||
payload.lng = parseFloat(payload.lng);
|
||||
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 res = await window.Verde.apiFetch('/api/v1/admin/drop-off-points', {
|
||||
method: 'POST',
|
||||
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('Drop-off point created', 'success');
|
||||
window.Verde.toast(editingDopId ? 'Drop-off point updated' : 'Drop-off point created', 'success');
|
||||
modal.classList.add('hidden');
|
||||
e.target.reset();
|
||||
createForm.reset();
|
||||
load();
|
||||
} else {
|
||||
window.Verde.toast(res.body?.message ?? 'Create failed', 'error');
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -23,10 +23,11 @@
|
||||
<th>Permit #</th>
|
||||
<th>Boundary</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>
|
||||
<tr><td colspan="7" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -36,14 +37,14 @@
|
||||
<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">New dumpsite</h3>
|
||||
<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" required class="form-input" placeholder="DS-..."></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>
|
||||
@@ -91,6 +92,9 @@
|
||||
</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>
|
||||
@@ -99,18 +103,73 @@
|
||||
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="6" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>`;
|
||||
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="6" class="py-10 text-center text-sm text-red-500">Failed to load.</td></tr>`; return; }
|
||||
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="6" class="py-10 text-center text-sm text-neutral-400">No dumpsites configured.</td></tr>`; return; }
|
||||
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>
|
||||
@@ -121,8 +180,15 @@
|
||||
? `<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 = '') {
|
||||
@@ -190,8 +256,6 @@
|
||||
lngInput.value = e.latlng.lng.toFixed(6);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Setup geocoding search
|
||||
const searchInput = document.getElementById('ds-search');
|
||||
const searchBtn = document.getElementById('ds-search-btn');
|
||||
@@ -279,6 +343,15 @@
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -286,18 +359,18 @@
|
||||
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());
|
||||
|
||||
document.getElementById('create-form').addEventListener('submit', async (e) => {
|
||||
createForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const btnLabel = submitBtn.querySelector('.btn-label');
|
||||
const btnSpinner = submitBtn.querySelector('.btn-spinner');
|
||||
|
||||
submitBtn.disabled = true;
|
||||
btnLabel.textContent = 'Creating…';
|
||||
btnLabel.textContent = editingDumpsiteId ? 'Saving…' : 'Creating…';
|
||||
btnSpinner.classList.remove('hidden');
|
||||
|
||||
const fd = new FormData(e.target);
|
||||
@@ -310,17 +383,44 @@
|
||||
const boundary = getBoundary();
|
||||
if (boundary.length >= 3) payload.boundary_polygon = boundary;
|
||||
|
||||
const res = await window.Verde.apiFetch('/api/v1/admin/dumpsites', {
|
||||
method: 'POST',
|
||||
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 = 'Create';
|
||||
btnLabel.textContent = editingDumpsiteId ? 'Save Changes' : 'Create';
|
||||
btnSpinner.classList.add('hidden');
|
||||
|
||||
if (res.ok) { window.Verde.toast('Dumpsite created', 'success'); modal.classList.add('hidden'); e.target.reset(); load(); }
|
||||
else window.Verde.toast(res.body?.message ?? 'Create failed', 'error');
|
||||
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();
|
||||
|
||||
@@ -152,4 +152,32 @@ class DropOffPointTest extends TestCase
|
||||
$this->deleteJson("/api/v1/admin/drop-off-points/{$dop->uuid}")->assertOk();
|
||||
$this->assertSoftDeleted('drop_off_points', ['id' => $dop->id]);
|
||||
}
|
||||
|
||||
public function test_dop_status_transition_reassigns_households(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => User::ROLE_ADMIN, 'status' => User::STATUS_ACTIVE]);
|
||||
Sanctum::actingAs($admin);
|
||||
|
||||
$dop1 = DropOffPoint::where('status', DropOffPoint::STATUS_ACTIVE)->first();
|
||||
|
||||
$household = \App\Models\Household::create([
|
||||
'tenant_id' => $dop1->tenant_id,
|
||||
'head_user_id' => User::factory()->create()->id,
|
||||
'barangay_id' => $dop1->barangay_id,
|
||||
'address_line' => '123 Test St',
|
||||
'coordinates' => new \MatanYadaev\EloquentSpatial\Objects\Point(14.6539, 121.0685, 4326),
|
||||
'household_size' => 3,
|
||||
'assigned_drop_off_point_id' => $dop1->id,
|
||||
'verification_status' => 'approved',
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/api/v1/admin/drop-off-points/{$dop1->uuid}", [
|
||||
'status' => DropOffPoint::STATUS_MAINTENANCE,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$household->refresh();
|
||||
$this->assertNotEquals($dop1->id, $household->assigned_drop_off_point_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,4 +162,71 @@ class DumpsiteCrudTest extends TestCase
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['code']);
|
||||
}
|
||||
|
||||
public function test_dumpsite_status_transition_cancels_trips(): void
|
||||
{
|
||||
Sanctum::actingAs($this->admin);
|
||||
|
||||
$tenantId = $this->admin->tenant_id ?? \App\Models\Tenant::value('id');
|
||||
|
||||
$dumpsite = Dumpsite::factory()->create([
|
||||
'status' => Dumpsite::STATUS_ACTIVE,
|
||||
]);
|
||||
|
||||
$route = \App\Models\Route::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => 'Route A',
|
||||
'code' => 'R-A',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$team = \App\Models\CollectionTeam::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => 'Team A',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$truck = \App\Models\Truck::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'plate_number' => 'XYZ-123',
|
||||
'brand' => 'Isz',
|
||||
'model' => 'FTR',
|
||||
'capacity_tons' => 10,
|
||||
'default_dumpsite_id' => $dumpsite->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$trip1 = \App\Models\Trip::create([
|
||||
'trip_number' => 'TRIP-DS-1',
|
||||
'route_id' => $route->id,
|
||||
'team_id' => $team->id,
|
||||
'truck_id' => $truck->id,
|
||||
'dumpsite_id' => $dumpsite->id,
|
||||
'scheduled_date' => now()->toDateString(),
|
||||
'status' => \App\Models\Trip::STATUS_IN_PROGRESS,
|
||||
]);
|
||||
|
||||
$trip2 = \App\Models\Trip::create([
|
||||
'trip_number' => 'TRIP-DS-2',
|
||||
'route_id' => $route->id,
|
||||
'team_id' => $team->id,
|
||||
'truck_id' => $truck->id,
|
||||
'dumpsite_id' => $dumpsite->id,
|
||||
'scheduled_date' => now()->toDateString(),
|
||||
'status' => \App\Models\Trip::STATUS_COMPLETED,
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/api/v1/admin/dumpsites/{$dumpsite->uuid}", [
|
||||
'status' => Dumpsite::STATUS_MAINTENANCE,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$trip1->refresh();
|
||||
$trip2->refresh();
|
||||
|
||||
$this->assertEquals(\App\Models\Trip::STATUS_CANCELLED, $trip1->status);
|
||||
$this->assertStringContainsString('System: Trip cancelled because the dumpsite was moved to maintenance status', $trip1->notes);
|
||||
$this->assertEquals(\App\Models\Trip::STATUS_COMPLETED, $trip2->status);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user