Sidebar with all 8 nav groups (Operations / Planning / People / QR / Areas / Finance / Reports / Settings) per the admin-panel-flow spec. Disabled items show "Soon" and toast on click. Working pages (real API): - Dashboard with live stat cards + recent verification queue - Households (filter, approve, reject with reason) - Service Areas (CRUD via slide-over) - QR Batches (generate, mark printed, link to print PDF) - QR Code Search (lifecycle lookup by serial) - Drop-off Points (filter + create) - Dumpsites, Routes, All Users (filterable lists) Shared helpers on window.Verde — apiFetch, requireAuth, logout, toast, escapeHtml, formatDate. Tailwind brand tokens, table/card/badge classes, slide-over panels. GenerateBatchRequest now accepts target_area_id by uuid for consistency with the rest of the public API. 139 tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
130 lines
3.5 KiB
JavaScript
130 lines
3.5 KiB
JavaScript
/**
|
|
* Verde admin web — shared client helpers, exposed on window.Verde so
|
|
* inline page scripts can use them without ESM imports.
|
|
*/
|
|
|
|
const TOKEN_KEY = 'verde:token';
|
|
const USER_KEY = 'verde:user';
|
|
|
|
function csrfToken() {
|
|
return document.querySelector('meta[name="csrf-token"]')?.content ?? '';
|
|
}
|
|
|
|
function getToken() {
|
|
return localStorage.getItem(TOKEN_KEY);
|
|
}
|
|
|
|
function getUser() {
|
|
try { return JSON.parse(localStorage.getItem(USER_KEY) || 'null'); } catch { return null; }
|
|
}
|
|
|
|
function setSession(token, user) {
|
|
localStorage.setItem(TOKEN_KEY, token);
|
|
if (user) localStorage.setItem(USER_KEY, JSON.stringify(user));
|
|
}
|
|
|
|
function clearSession() {
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
localStorage.removeItem(USER_KEY);
|
|
}
|
|
|
|
async function apiFetch(path, opts = {}) {
|
|
const token = getToken();
|
|
const method = (opts.method || 'GET').toUpperCase();
|
|
const isFormData = opts.body instanceof FormData;
|
|
|
|
const headers = {
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken(),
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
...(opts.headers || {}),
|
|
};
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
if (!isFormData && method !== 'GET' && method !== 'HEAD' && opts.body !== undefined) {
|
|
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
|
}
|
|
|
|
const res = await fetch(path, {
|
|
...opts,
|
|
method,
|
|
credentials: 'same-origin',
|
|
headers,
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
clearSession();
|
|
window.location.href = '/login';
|
|
throw new Error('unauthenticated');
|
|
}
|
|
|
|
let body = null;
|
|
try { body = await res.json(); } catch { /* ignore */ }
|
|
|
|
return { ok: res.ok, status: res.status, body };
|
|
}
|
|
|
|
function requireAuth() {
|
|
if (!getToken()) {
|
|
window.location.href = '/login';
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function logout() {
|
|
try {
|
|
await apiFetch('/api/v1/auth/logout', { method: 'POST' });
|
|
} catch { /* ignore */ }
|
|
clearSession();
|
|
window.location.href = '/login';
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
if (value === null || value === undefined) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = String(value);
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatDate(iso, options = { dateStyle: 'medium', timeStyle: 'short' }) {
|
|
if (!iso) return '—';
|
|
try {
|
|
return new Intl.DateTimeFormat(undefined, options).format(new Date(iso));
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function toast(message, type = 'info') {
|
|
let host = document.getElementById('verde-toasts');
|
|
if (!host) {
|
|
host = document.createElement('div');
|
|
host.id = 'verde-toasts';
|
|
host.className = 'pointer-events-none fixed top-4 right-4 z-50 flex flex-col gap-2';
|
|
document.body.appendChild(host);
|
|
}
|
|
|
|
const colors = {
|
|
success: 'border-verde-200 bg-verde-50 text-verde-800',
|
|
error: 'border-red-200 bg-red-50 text-red-700',
|
|
info: 'border-neutral-200 bg-white text-neutral-800',
|
|
};
|
|
|
|
const el = document.createElement('div');
|
|
el.className = `pointer-events-auto rounded-md border ${colors[type] || colors.info} px-4 py-3 text-sm shadow-md transition-opacity`;
|
|
el.textContent = message;
|
|
host.appendChild(el);
|
|
setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, 3500);
|
|
}
|
|
|
|
window.Verde = {
|
|
apiFetch,
|
|
requireAuth,
|
|
logout,
|
|
setSession,
|
|
getUser,
|
|
toast,
|
|
escapeHtml,
|
|
formatDate,
|
|
};
|