diff --git a/public/manifest-rep.json b/public/manifest-rep.json new file mode 100644 index 0000000..a61d4c5 --- /dev/null +++ b/public/manifest-rep.json @@ -0,0 +1,25 @@ +{ + "name": "WFDASH — Rep Visits", + "short_name": "Visits", + "description": "Log customer visits on site. Works without signal.", + "start_url": "/rep", + "scope": "/rep", + "display": "standalone", + "orientation": "portrait", + "background_color": "#FDF6F5", + "theme_color": "#E85D4C", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + } + ] +} diff --git a/public/sw-rep.js b/public/sw-rep.js new file mode 100644 index 0000000..20a334f --- /dev/null +++ b/public/sw-rep.js @@ -0,0 +1,102 @@ +// Service worker for the /rep visit-logging PWA. +// +// Unlike the boss-phone SW, this one exists primarily so the app SHELL +// loads with no signal. A rep at a mine shaft has GPS but often no +// data, and the whole check-in flow is built to complete offline: the +// customer list is cached in localStorage, matching runs on the phone, +// and the submission sits in an outbox until there's a connection. +// +// So the shell must open from cache, every time, or none of that is +// reachable. +const CACHE_NAME = "wfdash-rep-v1"; +const OFFLINE_URL = "/offline"; + +const PRECACHE_ASSETS = [ + "/rep", + "/manifest-rep.json", + "/offline", + "/android-chrome-192x192.png", + "/android-chrome-512x512.png", + "/apple-touch-icon.png", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) + ) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + + const url = new URL(request.url); + + // Never cache API traffic. Serving a stale customer snapshot would + // hide newly-added customers from a rep standing in front of one, + // and the app maintains its own snapshot cache in localStorage where + // it can reason about the age of the data. + if (url.pathname.startsWith("/api/")) return; + + // Navigations: cache-first for the shell. The opposite of the boss + // dashboard's network-first, and deliberately so — the boss wants + // fresh numbers, the rep wants the app to open at all. Freshness + // comes from the snapshot refresh once there's signal. + if (request.mode === "navigate") { + event.respondWith( + caches + .match(request) + .then((cached) => { + if (cached) { + // Refresh in the background for the next launch. + event.waitUntil( + fetch(request) + .then((response) => + caches + .open(CACHE_NAME) + .then((cache) => cache.put(request, response.clone())) + ) + .catch(() => {}) + ); + return cached; + } + return fetch(request).then((response) => { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + return response; + }); + }) + .catch(async () => { + const shell = await caches.match("/rep"); + return shell || caches.match(OFFLINE_URL); + }) + ); + return; + } + + // Static assets: stale-while-revalidate. + event.respondWith( + caches.match(request).then((cached) => { + const networkFetch = fetch(request) + .then((response) => { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + return response; + }) + .catch(() => cached); + return cached || networkFetch; + }) + ); +}); diff --git a/src/app/(rep)/rep/check-in-view.tsx b/src/app/(rep)/rep/check-in-view.tsx new file mode 100644 index 0000000..a47c691 --- /dev/null +++ b/src/app/(rep)/rep/check-in-view.tsx @@ -0,0 +1,492 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { MapPin, Search, Check, WifiOff, RefreshCw, Loader2, Navigation } from "lucide-react"; +import { toast } from "sonner"; +import getPocketBase from "@/lib/pocketbase"; +import { rankNearbyCustomers, DEFAULT_RADIUS_KM } from "@/lib/visit-matching"; +import type { CustomerSnapshotRow } from "@/lib/server/visits-service"; +import { + enqueue, + flushOutbox, + readOutbox, + readCachedSnapshot, + writeCachedSnapshot, +} from "./outbox"; + +/** Radii the "widen search" button steps through, in km. */ +const RADIUS_STEPS = [DEFAULT_RADIUS_KM, 5, 15]; + +type Position = { + lat: number; + lon: number; + accuracyM: number | null; + source: "phone" | "tracker"; + label: string; +}; + +export function CheckInView() { + const [customers, setCustomers] = useState([]); + const [snapshotAt, setSnapshotAt] = useState(null); + const [position, setPosition] = useState(null); + const [locating, setLocating] = useState(false); + const [radiusKm, setRadiusKm] = useState(DEFAULT_RADIUS_KM); + const [selected, setSelected] = useState>(new Set()); + const [pinning, setPinning] = useState>(new Set()); + const [search, setSearch] = useState(""); + const [notes, setNotes] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [pending, setPending] = useState(0); + const [online, setOnline] = useState(true); + const repIdRef = useRef(""); + + /* ------------------------------------------------------ boot + sync */ + + const refreshPending = useCallback(() => setPending(readOutbox().length), []); + + const loadSnapshot = useCallback(async () => { + const pb = getPocketBase(); + repIdRef.current = pb.authStore.record?.id ?? ""; + + // Cache first so the screen is usable instantly and offline; the + // network refresh below replaces it when it lands. + const cached = readCachedSnapshot(); + if (cached) { + setCustomers(cached.customers as CustomerSnapshotRow[]); + setSnapshotAt(cached.fetchedAt); + } + + try { + const res = await fetch("/api/v1/visits/customers", { + headers: { Authorization: `Bearer ${pb.authStore.token}` }, + }); + if (!res.ok) throw new Error(String(res.status)); + const body = (await res.json()) as { + customers: CustomerSnapshotRow[]; + fetchedAt: string; + }; + setCustomers(body.customers); + setSnapshotAt(body.fetchedAt); + writeCachedSnapshot(body); + } catch { + // Offline with no cache is the only genuinely broken state, and + // the empty-state copy below covers it. + } + }, []); + + const flush = useCallback(async () => { + const pb = getPocketBase(); + if (!pb.authStore.token || readOutbox().length === 0) return; + const result = await flushOutbox(pb.authStore.token); + refreshPending(); + if (result.sent > 0) { + toast.success(`${result.sent} queued visit${result.sent === 1 ? "" : "s"} synced`); + } + }, [refreshPending]); + + useEffect(() => { + void loadSnapshot(); + refreshPending(); + setOnline(navigator.onLine); + + const goOnline = () => { + setOnline(true); + void flush(); + void loadSnapshot(); + }; + const goOffline = () => setOnline(false); + + window.addEventListener("online", goOnline); + window.addEventListener("offline", goOffline); + if (navigator.onLine) void flush(); + + return () => { + window.removeEventListener("online", goOnline); + window.removeEventListener("offline", goOffline); + }; + }, [loadSnapshot, flush, refreshPending]); + + /* ------------------------------------------------------- positioning */ + + const locate = useCallback(async () => { + setLocating(true); + + const fromPhone = await new Promise((resolve) => { + if (!("geolocation" in navigator)) return resolve(null); + navigator.geolocation.getCurrentPosition( + (pos) => + resolve({ + lat: pos.coords.latitude, + lon: pos.coords.longitude, + accuracyM: Number.isFinite(pos.coords.accuracy) ? pos.coords.accuracy : null, + source: "phone", + label: "Your phone", + }), + () => resolve(null), + // A long timeout beats a wrong answer: a cold GPS fix on a mine + // property can genuinely take 20s, and falling back to the + // tracker too eagerly would log the bakkie's position when the + // rep is 400m away inside the plant. + { enableHighAccuracy: true, timeout: 20_000, maximumAge: 30_000 } + ); + }); + + if (fromPhone) { + setPosition(fromPhone); + setLocating(false); + return; + } + + // Secondary: the vehicle tracker. Needs a network, so it can't help + // underground — but it covers "permission denied" and "GPS off", + // where the rep usually does have data. + try { + const pb = getPocketBase(); + const res = await fetch("/api/v1/visits/position", { + headers: { Authorization: `Bearer ${pb.authStore.token}` }, + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error ?? "Tracker unavailable"); + setPosition({ + lat: body.lat, + lon: body.lon, + accuracyM: null, + source: "tracker", + label: `Your vehicle${body.vehicleNo ? ` (${body.vehicleNo})` : ""}`, + }); + toast.info("Using your vehicle's position — phone location was unavailable."); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Could not get a location. You can still search for a customer by name." + ); + } finally { + setLocating(false); + } + }, []); + + /* ---------------------------------------------------------- matching */ + + const nearby = useMemo(() => { + if (!position) return []; + return rankNearbyCustomers({ + origin: [position.lat, position.lon], + customers: customers.map((c) => ({ + id: c.id, + customer: c.customer, + companyName: c.companyName, + rep: c.rep, + coordinates: c.lat !== undefined && c.lon !== undefined + ? { lat: c.lat, lon: c.lon } + : null, + })), + radiusKm, + repId: repIdRef.current, + }); + }, [position, customers, radiusKm]); + + const searchResults = useMemo(() => { + const term = search.trim().toLowerCase(); + if (term.length < 2) return []; + const nearbyIds = new Set(nearby.map((n) => n.id)); + return customers + .filter( + (c) => + !nearbyIds.has(c.id) && + (c.customer.toLowerCase().includes(term) || + (c.companyName ?? "").toLowerCase().includes(term)) + ) + .slice(0, 20); + }, [search, customers, nearby]); + + const toggle = useCallback( + (id: string, hasCoordinates: boolean) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + // Default the pin on for a customer with no position yet — that's + // how the map builds itself, one visit at a time. + if (!hasCoordinates) { + setPinning((prev) => { + const next = new Set(prev); + if (selected.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + }, + [selected] + ); + + /* ---------------------------------------------------------- submit */ + + const submit = useCallback(async () => { + if (!position || selected.size === 0) return; + setSubmitting(true); + + const entry = enqueue({ + lat: position.lat, + lon: position.lon, + accuracyM: position.accuracyM, + positionSource: position.source, + visitedAt: new Date().toISOString(), + visits: [...selected].map((customerId) => ({ + customerId, + notes: notes.trim() || undefined, + pinCustomer: pinning.has(customerId), + })), + }); + + const count = selected.size; + setSelected(new Set()); + setPinning(new Set()); + setNotes(""); + setSearch(""); + refreshPending(); + + const pb = getPocketBase(); + try { + const result = await flushOutbox(pb.authStore.token); + refreshPending(); + if (result.sent > 0) { + toast.success(`${count} visit${count === 1 ? "" : "s"} logged`); + } else { + toast.success(`${count} visit${count === 1 ? "" : "s"} saved — will sync when you have signal`); + } + } catch { + toast.success(`${count} visit${count === 1 ? "" : "s"} saved offline`); + } finally { + void entry; + setSubmitting(false); + } + }, [position, selected, pinning, notes, refreshPending]); + + /* ------------------------------------------------------------ render */ + + const canWiden = radiusKm !== RADIUS_STEPS[RADIUS_STEPS.length - 1]; + + return ( +
+
+
+

Log a visit

+

+ {snapshotAt + ? `${customers.length} customers · updated ${new Date(snapshotAt).toLocaleDateString()}` + : "Loading customers…"} +

+
+
+ {!online && ( + + Offline + + )} + {pending > 0 && ( + + )} +
+
+ + {!position ? ( + + ) : ( +
+
+ + + {position.label} + {position.accuracyM !== null && ( + + ±{Math.round(position.accuracyM)}m + + )} + + +
+

+ {position.lat.toFixed(5)}, {position.lon.toFixed(5)} · within {radiusKm} km +

+
+ )} + + {position && ( +
+

+ Nearby ({nearby.length}) +

+ + {nearby.length === 0 ? ( +

+ No customers pinned within {radiusKm} km yet. Search below — saving a + visit records this spot for next time. +

+ ) : ( +
    + {nearby.map((row) => ( + toggle(row.id, true)} + /> + ))} +
+ )} + + {canWiden && ( + + )} +
+ )} + +
+ + + {searchResults.length > 0 && ( +
    + {searchResults.map((row) => { + const hasCoordinates = row.lat !== undefined; + return ( + toggle(row.id, hasCoordinates)} + /> + ); + })} +
+ )} +
+ + {selected.size > 0 && ( +