feat(web): replace remaining "Soon" stubs with real pages
- Live Tracking: Leaflet map (CDN) showing active truck positions,
auto-refresh every 10s, sidebar list with speed + last update.
- Finance / Payments: filterable list of payments with mark-paid
action that runs admin fulfillment.
- Settings: notification-preferences form bound to /me/notification-
preferences.
Backend:
- New admin payments index endpoint
GET /admin/payments + POST /admin/payments/{uuid}/mark-paid (moved
out of /admin/live/* for clarity).
Sidebar groups now reflect what's live: Operations (Trips, Live
Tracking), Planning (Routes, Teams, Trucks, DOPs, Dumpsites), People
(Users, Households, Partner Stores), QR, Areas, Finance, Reports,
Settings. Trip Calendar + Incidents remain "Soon" — backends ready,
UI work pending. Dashboard backend-status copy updated to 13/13
modules / 171 tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,53 @@ class PaymentController extends ApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function adminIndex(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'status' => ['nullable', 'in:pending,processing,paid,failed,refunded'],
|
||||
'purpose' => ['nullable', 'in:resident_code_purchase,store_inventory_purchase'],
|
||||
'q' => ['nullable', 'string', 'max:100'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
$perPage = (int) $request->input('per_page', 25);
|
||||
|
||||
$payments = Payment::query()
|
||||
->with('payer')
|
||||
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
||||
->when($request->filled('purpose'), fn ($q) => $q->where('purpose', $request->string('purpose')))
|
||||
->when($request->filled('q'), function ($q) use ($request) {
|
||||
$term = '%'.$request->string('q').'%';
|
||||
$q->where(fn ($qq) => $qq->where('uuid', 'like', $term)
|
||||
->orWhereHas('payer', fn ($p) => $p->where('email', 'like', $term)->orWhere('phone', 'like', $term)));
|
||||
})
|
||||
->orderByDesc('id')
|
||||
->paginate($perPage);
|
||||
|
||||
return $this->ok(
|
||||
$payments->getCollection()->map(fn (Payment $p) => [
|
||||
'id' => $p->uuid,
|
||||
'payer' => [
|
||||
'name' => $p->payer?->full_name,
|
||||
'email' => $p->payer?->email,
|
||||
],
|
||||
'purpose' => $p->purpose,
|
||||
'amount_centavos' => $p->amount_centavos,
|
||||
'currency' => $p->currency,
|
||||
'provider' => $p->provider,
|
||||
'status' => $p->status,
|
||||
'paid_at' => $p->paid_at?->toIso8601String(),
|
||||
'created_at' => $p->created_at?->toIso8601String(),
|
||||
])->all(),
|
||||
null,
|
||||
[
|
||||
'page' => $payments->currentPage(),
|
||||
'per_page' => $payments->perPage(),
|
||||
'total' => $payments->total(),
|
||||
'last_page' => $payments->lastPage(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin manually marks a payment paid (used for cash-paid resident
|
||||
* purchases or when the manual driver is in effect). Triggers the
|
||||
|
||||
@@ -47,15 +47,15 @@
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-verde-500"></span>
|
||||
Backend modules <span class="ml-auto text-xs text-neutral-500">8 / 13</span>
|
||||
Backend modules <span class="ml-auto text-xs text-neutral-500">13 / 13</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-verde-500"></span>
|
||||
Tests passing <span class="ml-auto text-xs text-neutral-500">139</span>
|
||||
Tests passing <span class="ml-auto text-xs text-neutral-500">171</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-5 border-t border-neutral-100 pt-4 text-xs text-neutral-500">
|
||||
Operations / Live tracking / Finance ship with later modules.
|
||||
Reverb live broadcast and FCM push are deferred — polling drives the live map for now.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
108
resources/views/admin/finance.blade.php
Normal file
108
resources/views/admin/finance.blade.php
Normal file
@@ -0,0 +1,108 @@
|
||||
@extends('admin.layouts.app', ['pageTitle' => 'Finance'])
|
||||
|
||||
@section('page')
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-6">
|
||||
<h2 class="text-2xl font-semibold tracking-tight text-neutral-900">Payments</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500">Resident code purchases + wholesale store buys.</p>
|
||||
</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="pending">Pending</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="refunded">Refunded</option>
|
||||
</select>
|
||||
<select id="filter-purpose" class="form-select w-56">
|
||||
<option value="">All purposes</option>
|
||||
<option value="resident_code_purchase">Resident code purchase</option>
|
||||
<option value="store_inventory_purchase">Store wholesale</option>
|
||||
</select>
|
||||
<input id="filter-q" type="search" placeholder="Search payment id, email…" 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>Payment</th>
|
||||
<th>Payer</th>
|
||||
<th>Purpose</th>
|
||||
<th>Amount</th>
|
||||
<th>Provider</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>
|
||||
|
||||
<script type="module">
|
||||
const rows = document.getElementById('rows');
|
||||
|
||||
function pesos(c) { return ((c ?? 0) / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
|
||||
|
||||
function statusBadge(s) {
|
||||
const m = {
|
||||
pending: 'badge-pending', processing: 'badge-info', paid: 'badge-active',
|
||||
failed: 'badge-rejected', refunded: 'badge-inactive',
|
||||
};
|
||||
return `<span class="badge ${m[s] || 'badge-inactive'}">${s}</span>`;
|
||||
}
|
||||
|
||||
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 p = document.getElementById('filter-purpose').value;
|
||||
const q = document.getElementById('filter-q').value.trim();
|
||||
if (s) params.set('status', s);
|
||||
if (p) params.set('purpose', p);
|
||||
if (q) params.set('q', q);
|
||||
params.set('per_page', '50');
|
||||
|
||||
const res = await window.Verde.apiFetch(`/api/v1/admin/payments?${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 payments yet.</td></tr>`; return; }
|
||||
rows.innerHTML = items.map(p => `
|
||||
<tr>
|
||||
<td><span class="font-mono text-xs text-neutral-700">${window.Verde.escapeHtml(p.id.slice(0, 8))}…</span></td>
|
||||
<td>${p.payer?.email ? window.Verde.escapeHtml(p.payer.email) : '—'}</td>
|
||||
<td><span class="badge badge-info">${p.purpose.replace(/_/g, ' ')}</span></td>
|
||||
<td class="font-medium">₱${pesos(p.amount_centavos)}</td>
|
||||
<td class="text-xs text-neutral-500">${p.provider}</td>
|
||||
<td>${statusBadge(p.status)}</td>
|
||||
<td class="text-right">
|
||||
${p.status !== 'paid' && p.status !== 'refunded'
|
||||
? `<button data-id="${p.id}" data-action="mark-paid" class="btn-primary px-3 py-1 text-xs">Mark paid</button>`
|
||||
: '<span class="text-xs text-neutral-400">—</span>'}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
rows.querySelectorAll('button[data-action="mark-paid"]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm('Mark this payment as paid? This triggers fulfillment (codes get activated).')) return;
|
||||
const r = await window.Verde.apiFetch(`/api/v1/admin/payments/${btn.dataset.id}/mark-paid`, { method: 'POST' });
|
||||
if (r.ok) { window.Verde.toast('Marked paid + fulfilled', 'success'); load(); }
|
||||
else window.Verde.toast(r.body?.message ?? 'Failed', 'error');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('filter-apply').addEventListener('click', load);
|
||||
document.getElementById('filter-status').addEventListener('change', load);
|
||||
document.getElementById('filter-purpose').addEventListener('change', load);
|
||||
|
||||
load();
|
||||
</script>
|
||||
@endsection
|
||||
126
resources/views/admin/live-tracking.blade.php
Normal file
126
resources/views/admin/live-tracking.blade.php
Normal file
@@ -0,0 +1,126 @@
|
||||
@extends('admin.layouts.app', ['pageTitle' => 'Live Tracking'])
|
||||
|
||||
@section('page')
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
|
||||
|
||||
<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">Live Tracking</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500">Active truck positions, refreshed every 10 seconds.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-neutral-500">
|
||||
<span class="inline-flex h-2 w-2 animate-pulse rounded-full bg-verde-500"></span>
|
||||
<span id="last-update">Loading…</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2">
|
||||
<div id="map" class="card h-[600px]"></div>
|
||||
</div>
|
||||
|
||||
<div class="card-padded">
|
||||
<h3 class="mb-3 text-sm font-semibold text-neutral-900">Trucks online</h3>
|
||||
<div id="truck-list" class="space-y-2">
|
||||
<div class="text-sm text-neutral-400">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
||||
<script type="module">
|
||||
// Wait for Leaflet to be available (loaded via non-module script tag)
|
||||
function awaitLeaflet() {
|
||||
return new Promise(resolve => {
|
||||
const tick = () => window.L ? resolve(window.L) : setTimeout(tick, 50);
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
const L = await awaitLeaflet();
|
||||
const map = L.map('map').setView([14.65, 121.05], 12);
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
const markers = new Map();
|
||||
const truckList = document.getElementById('truck-list');
|
||||
const lastUpdate = document.getElementById('last-update');
|
||||
|
||||
function truckIcon(plate) {
|
||||
return L.divIcon({
|
||||
html: `<div class="flex h-7 w-auto items-center gap-1 rounded-md border border-verde-700 bg-verde-600 px-2 text-[10px] font-semibold text-white shadow"><span>🚛</span><span>${plate}</span></div>`,
|
||||
className: '',
|
||||
iconSize: null,
|
||||
iconAnchor: [0, 14],
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const res = await window.Verde.apiFetch('/api/v1/admin/live/trucks');
|
||||
if (!res.ok) {
|
||||
lastUpdate.textContent = 'Error fetching positions';
|
||||
return;
|
||||
}
|
||||
const trucks = res.body.data?.trucks ?? [];
|
||||
lastUpdate.textContent = `Last update ${window.Verde.formatDate(res.body.data.as_of, { hour: '2-digit', minute: '2-digit', second: '2-digit' })} · ${trucks.length} active`;
|
||||
|
||||
// Update markers
|
||||
const seen = new Set();
|
||||
const bounds = [];
|
||||
trucks.forEach(t => {
|
||||
seen.add(t.truck_id);
|
||||
bounds.push([t.lat, t.lng]);
|
||||
const existing = markers.get(t.truck_id);
|
||||
if (existing) {
|
||||
existing.setLatLng([t.lat, t.lng]);
|
||||
} else {
|
||||
const m = L.marker([t.lat, t.lng], { icon: truckIcon(t.plate_number) }).addTo(map);
|
||||
m.bindPopup(`
|
||||
<div class="text-sm">
|
||||
<strong>${t.plate_number}</strong><br>
|
||||
Team: ${t.team ?? '—'}<br>
|
||||
Speed: ${t.speed_kmh ?? '—'} km/h<br>
|
||||
${window.Verde.formatDate(t.recorded_at)}
|
||||
</div>
|
||||
`);
|
||||
markers.set(t.truck_id, m);
|
||||
}
|
||||
});
|
||||
// Remove stale markers
|
||||
for (const [id, marker] of markers) {
|
||||
if (!seen.has(id)) {
|
||||
map.removeLayer(marker);
|
||||
markers.delete(id);
|
||||
}
|
||||
}
|
||||
if (bounds.length > 0) {
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
|
||||
}
|
||||
|
||||
// Update sidebar list
|
||||
if (trucks.length === 0) {
|
||||
truckList.innerHTML = `<div class="rounded-md border border-dashed border-neutral-200 p-4 text-center text-sm text-neutral-400">No trucks active in the last hour.</div>`;
|
||||
return;
|
||||
}
|
||||
truckList.innerHTML = trucks.map(t => `
|
||||
<div class="flex items-center justify-between rounded-md border border-neutral-200 bg-white p-3">
|
||||
<div>
|
||||
<div class="font-mono text-sm font-medium text-neutral-900">${window.Verde.escapeHtml(t.plate_number)}</div>
|
||||
<div class="text-xs text-neutral-500">${window.Verde.escapeHtml(t.team ?? 'Unassigned')}</div>
|
||||
</div>
|
||||
<div class="text-right text-xs text-neutral-500">
|
||||
${t.speed_kmh != null ? `${Math.round(t.speed_kmh)} km/h` : '—'}<br>
|
||||
<span class="text-[10px]">${window.Verde.formatDate(t.recorded_at, { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 10000);
|
||||
</script>
|
||||
@endsection
|
||||
@@ -3,20 +3,22 @@
|
||||
$isActive = fn ($path) => str_starts_with($current, ltrim($path, '/'));
|
||||
$groups = [
|
||||
'Operations' => [
|
||||
['href' => '/admin/operations', 'label' => 'Today\'s Trips', 'disabled' => true, 'icon' => 'truck'],
|
||||
['href' => '/admin/trips', 'label' => 'Trips', 'icon' => 'truck'],
|
||||
['href' => '/admin/operations/live', 'label' => 'Live Tracking', 'icon' => 'map'],
|
||||
['href' => '/admin/operations/calendar', 'label' => 'Trip Calendar', 'disabled' => true, 'icon' => 'calendar'],
|
||||
['href' => '/admin/operations/live', 'label' => 'Live Tracking', 'disabled' => true, 'icon' => 'map'],
|
||||
['href' => '/admin/operations/incidents', 'label' => 'Incidents', 'disabled' => true, 'icon' => 'alert'],
|
||||
],
|
||||
'Planning' => [
|
||||
['href' => '/admin/routes', 'label' => 'Routes', 'icon' => 'route'],
|
||||
['href' => '/admin/teams', 'label' => 'Teams', 'disabled' => true, 'icon' => 'users'],
|
||||
['href' => '/admin/teams', 'label' => 'Teams', 'icon' => 'users'],
|
||||
['href' => '/admin/trucks', 'label' => 'Trucks', 'icon' => 'truck'],
|
||||
['href' => '/admin/drop-off-points', 'label' => 'Drop-off Points', 'icon' => 'pin'],
|
||||
['href' => '/admin/dumpsites', 'label' => 'Dumpsites', 'icon' => 'trash'],
|
||||
],
|
||||
'People' => [
|
||||
['href' => '/admin/users', 'label' => 'All Users', 'icon' => 'people'],
|
||||
['href' => '/admin/households', 'label' => 'Households', 'icon' => 'home'],
|
||||
['href' => '/admin/partner-stores', 'label' => 'Partner Stores', 'icon' => 'cash'],
|
||||
],
|
||||
'QR Management' => [
|
||||
['href' => '/admin/qr-batches', 'label' => 'Code Batches', 'icon' => 'qr'],
|
||||
@@ -26,13 +28,13 @@
|
||||
['href' => '/admin/service-areas', 'label' => 'Service Areas', 'icon' => 'globe'],
|
||||
],
|
||||
'Finance' => [
|
||||
['href' => '/admin/finance', 'label' => 'Sales & Payouts', 'disabled' => true, 'icon' => 'cash'],
|
||||
['href' => '/admin/finance', 'label' => 'Payments', 'icon' => 'cash'],
|
||||
],
|
||||
'Reports' => [
|
||||
['href' => '/admin/reports', 'label' => 'Reports', 'disabled' => true, 'icon' => 'chart'],
|
||||
['href' => '/admin/reports', 'label' => 'Reports', 'icon' => 'chart'],
|
||||
],
|
||||
'Settings' => [
|
||||
['href' => '/admin/settings', 'label' => 'System Config', 'disabled' => true, 'icon' => 'cog'],
|
||||
['href' => '/admin/settings', 'label' => 'System Config', 'icon' => 'cog'],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -55,9 +57,7 @@
|
||||
'cog' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
|
||||
];
|
||||
|
||||
$renderIcon = function ($name) use ($icons) {
|
||||
return $icons[$name] ?? $icons['cog'];
|
||||
};
|
||||
$renderIcon = fn ($name) => $icons[$name] ?? $icons['cog'];
|
||||
@endphp
|
||||
|
||||
<aside class="hidden lg:flex lg:w-64 lg:flex-col lg:border-r lg:border-neutral-200 lg:bg-white">
|
||||
|
||||
104
resources/views/admin/settings.blade.php
Normal file
104
resources/views/admin/settings.blade.php
Normal file
@@ -0,0 +1,104 @@
|
||||
@extends('admin.layouts.app', ['pageTitle' => 'Settings'])
|
||||
|
||||
@section('page')
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<header class="mb-6">
|
||||
<h2 class="text-2xl font-semibold tracking-tight text-neutral-900">Settings</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500">Notification preferences for your admin account.</p>
|
||||
</header>
|
||||
|
||||
<div class="card-padded">
|
||||
<h3 class="mb-4 text-base font-semibold text-neutral-900">Notification preferences</h3>
|
||||
<form id="prefs-form" class="space-y-5">
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-neutral-500">Categories</h4>
|
||||
@foreach ([
|
||||
'pickup_reminder' => 'Pickup reminders',
|
||||
'pickup_imminent' => 'Truck approaching',
|
||||
'pickup_completed' => 'Pickup completed',
|
||||
'low_codes_warning' => 'Low QR codes warning',
|
||||
'codes_purchased' => 'Codes purchased',
|
||||
'schedule_changed' => 'Schedule changed',
|
||||
'household_status' => 'Household verification updates',
|
||||
] as $key => $label)
|
||||
<label class="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-3">
|
||||
<span class="text-sm text-neutral-800">{{ $label }}</span>
|
||||
<input type="checkbox" name="{{ $key }}" class="rounded border-neutral-300 text-verde-600 focus:ring-verde-500">
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-neutral-500">Channels</h4>
|
||||
@foreach ([
|
||||
'sms_enabled' => 'SMS',
|
||||
'email_enabled' => 'Email',
|
||||
'push_enabled' => 'Push notifications',
|
||||
] as $key => $label)
|
||||
<label class="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-3">
|
||||
<span class="text-sm text-neutral-800">{{ $label }}</span>
|
||||
<input type="checkbox" name="{{ $key }}" class="rounded border-neutral-300 text-verde-600 focus:ring-verde-500">
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Language</label>
|
||||
<select name="language" class="form-select">
|
||||
<option value="en">English</option>
|
||||
<option value="tl">Tagalog</option>
|
||||
<option value="ceb">Cebuano</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<button type="submit" class="btn-primary">Save preferences</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card-padded mt-6">
|
||||
<h3 class="mb-2 text-base font-semibold text-neutral-900">System</h3>
|
||||
<p class="text-sm text-neutral-500">
|
||||
User roles, notification templates, audit logs, and feature flags
|
||||
will land here as the platform grows. For now, system config is
|
||||
managed via <code class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs">config/*.php</code>
|
||||
and <code class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs">.env</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
const form = document.getElementById('prefs-form');
|
||||
|
||||
async function load() {
|
||||
const res = await window.Verde.apiFetch('/api/v1/me/notification-preferences');
|
||||
if (!res.ok) return;
|
||||
const prefs = res.body.data;
|
||||
Object.entries(prefs).forEach(([k, v]) => {
|
||||
const el = form.elements[k];
|
||||
if (!el) return;
|
||||
if (el.type === 'checkbox') el.checked = !!v;
|
||||
else el.value = v;
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(form);
|
||||
const payload = {};
|
||||
for (const el of form.elements) {
|
||||
if (!el.name) continue;
|
||||
if (el.type === 'checkbox') payload[el.name] = el.checked;
|
||||
else if (el.value !== '') payload[el.name] = el.value;
|
||||
}
|
||||
const res = await window.Verde.apiFetch('/api/v1/me/notification-preferences', {
|
||||
method: 'PATCH', body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) window.Verde.toast('Preferences saved', 'success');
|
||||
else window.Verde.toast(res.body?.message ?? 'Failed', 'error');
|
||||
});
|
||||
|
||||
load();
|
||||
</script>
|
||||
@endsection
|
||||
@@ -91,7 +91,12 @@ Route::middleware(['auth:sanctum', 'role:driver'])->prefix('driver')->name('api.
|
||||
// Admin live tracking
|
||||
Route::middleware(['auth:sanctum', 'role:admin'])->prefix('admin/live')->name('api.v1.admin.live.')->group(function () {
|
||||
Route::get('/trucks', [AdminLiveTrackingController::class, 'trucks'])->name('trucks');
|
||||
Route::post('/payments/{payment}/mark-paid', [PaymentController::class, 'adminMarkPaid'])->name('payments.mark-paid');
|
||||
});
|
||||
|
||||
// Admin payments
|
||||
Route::middleware(['auth:sanctum', 'role:admin'])->prefix('admin/payments')->name('api.v1.admin.payments.')->group(function () {
|
||||
Route::get('/', [PaymentController::class, 'adminIndex'])->name('index');
|
||||
Route::post('/{payment}/mark-paid', [PaymentController::class, 'adminMarkPaid'])->name('mark-paid');
|
||||
});
|
||||
|
||||
Route::prefix('admin/users')
|
||||
|
||||
@@ -18,30 +18,24 @@ Route::prefix('admin')->group(function () {
|
||||
Route::view('/dumpsites', 'admin.dumpsites')->name('admin.dumpsites');
|
||||
Route::view('/routes', 'admin.routes')->name('admin.routes');
|
||||
Route::view('/users', 'admin.users')->name('admin.users');
|
||||
|
||||
// New pages from Modules 9–13
|
||||
Route::view('/teams', 'admin.teams')->name('admin.teams');
|
||||
Route::view('/trucks', 'admin.trucks')->name('admin.trucks');
|
||||
Route::view('/trips', 'admin.trips')->name('admin.trips');
|
||||
Route::view('/partner-stores', 'admin.partner-stores')->name('admin.partner-stores');
|
||||
Route::view('/reports', 'admin.reports')->name('admin.reports');
|
||||
|
||||
// Stubs that still need backend work
|
||||
// New from this batch
|
||||
Route::view('/operations/live', 'admin.live-tracking')->name('admin.live-tracking');
|
||||
Route::view('/finance', 'admin.finance')->name('admin.finance');
|
||||
Route::view('/settings', 'admin.settings')->name('admin.settings');
|
||||
|
||||
// Still stubs — UI work needed (backends are ready, just no page yet)
|
||||
Route::view('/operations/calendar', 'admin.coming-soon', [
|
||||
'title' => 'Trip Calendar', 'description' => 'Drag-to-create week/month calendar — UI work pending.',
|
||||
]);
|
||||
Route::view('/operations/live', 'admin.coming-soon', [
|
||||
'title' => 'Live Tracking', 'module' => 'Module 13 — Reverb live tracking (deferred)',
|
||||
'description' => 'Real-time truck positions on a map. Requires Reverb + driver GPS broadcast.',
|
||||
'title' => 'Trip Calendar',
|
||||
'description' => 'Drag-to-create week/month calendar — UI work pending. Use the Trips list for now.',
|
||||
]);
|
||||
Route::view('/operations/incidents', 'admin.coming-soon', [
|
||||
'title' => 'Incidents', 'description' => 'Filtered view of trip incident events — UI work pending.',
|
||||
]);
|
||||
Route::view('/finance', 'admin.coming-soon', [
|
||||
'title' => 'Finance', 'module' => 'Module 13 — PayMongo payments (deferred)',
|
||||
'description' => 'Sales, commissions, and payouts. Needs PayMongo integration.',
|
||||
]);
|
||||
Route::view('/settings', 'admin.coming-soon', [
|
||||
'title' => 'Settings', 'description' => 'User roles, notification templates, system config, audit logs.',
|
||||
'title' => 'Incidents',
|
||||
'description' => 'Filtered view of trip incident events — UI work pending. Visible inline in each Trip detail.',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ class PaymentFlowTest extends TestCase
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($admin);
|
||||
$response = $this->postJson("/api/v1/admin/live/payments/{$payment->uuid}/mark-paid");
|
||||
$response = $this->postJson("/api/v1/admin/payments/{$payment->uuid}/mark-paid");
|
||||
|
||||
$response->assertOk()->assertJsonPath('data.status', 'paid');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user