feat(rep): visit check-in PWA — phone GPS, offline outbox
The rep app: open it on site, it finds who's around you, tick who you saw, done. Third instance of the per-route-group PWA pattern already running here (/m boss phone, /counter), so it reuses MobileAuthGate and gets its own manifest + service worker. Phone GPS is the primary position source, not the vehicle tracker. The tracker knows where the bakkie is; the phone knows where the rep is, and those diverge by a few hundred metres once the rep walks into the plant. GPS also needs no network, which matters because mine shafts have poor signal. The tracker is the secondary path (GET /api/v1/visits/position) for when location is denied or unavailable. The independently-collected vehicle trail corroborates check-ins afterwards, so honesty doesn't depend on the phone. Matching runs on the phone, not the server. The app caches a compact customer snapshot and runs rankNearbyCustomers locally, so there's one code path that behaves identically online and off — rather than a server path plus an offline fallback that only gets exercised in the places it's hardest to debug. - (rep)/rep/ layout, page, check-in view, outbox - visits-service.ts getCustomerSnapshot, recordVisits - POST /api/v1/visits record a check-in - GET /api/v1/visits/customers snapshot for the offline cache - GET /api/v1/visits/position tracker fallback - public/manifest-rep.json, public/sw-rep.js, pickSwPath() branch These routes authenticate with the browser session, not AGENT_API_KEY — the caller is a logged-in rep. The rep id comes from the validated session rather than the request body, so a client can't log a visit as someone else. The outbox uses localStorage rather than IndexedDB deliberately: the payloads are tiny and localStorage is synchronous, which matters when the rep hits Save and immediately pockets the phone. Entries are only removed once the server acknowledges them, so a failed flush retries instead of dropping a visit — duplicates are preferred over loss, and surfaced rather than merged. Permanently-rejected entries (4xx other than 401/408/429) are dropped so one bad row can't wedge the queue. recordVisits writes each visit independently: a stale customer id shouldn't cost the other four visits in a queued batch. BLOCKED until one PB admin change: rep_visits.position_source does not yet allow "phone" — verified against the live instance, PB returns 400 "Invalid value phone". Add it to the select. Separately, accuracy_m is silently accepted and discarded (PB ignores unknown fields), so GPS accuracy is thrown away until that number field is added; not blocking. 47 new tests. Verified on :4001: /rep serves 200 with the right metadata, manifest-rep.json and sw-rep.js are served. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a551a71277
commit
c8b7e1f51b
13 changed files with 1802 additions and 0 deletions
25
public/manifest-rep.json
Normal file
25
public/manifest-rep.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
102
public/sw-rep.js
Normal file
102
public/sw-rep.js
Normal file
|
|
@ -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;
|
||||
})
|
||||
);
|
||||
});
|
||||
492
src/app/(rep)/rep/check-in-view.tsx
Normal file
492
src/app/(rep)/rep/check-in-view.tsx
Normal file
|
|
@ -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<CustomerSnapshotRow[]>([]);
|
||||
const [snapshotAt, setSnapshotAt] = useState<string | null>(null);
|
||||
const [position, setPosition] = useState<Position | null>(null);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [radiusKm, setRadiusKm] = useState(DEFAULT_RADIUS_KM);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [pinning, setPinning] = useState<Set<string>>(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<string>("");
|
||||
|
||||
/* ------------------------------------------------------ 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<Position | null>((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 (
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-md flex-col gap-4 px-4 py-5">
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight">Log a visit</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{snapshotAt
|
||||
? `${customers.length} customers · updated ${new Date(snapshotAt).toLocaleDateString()}`
|
||||
: "Loading customers…"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{!online && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-1 text-[11px] font-medium text-amber-700 dark:text-amber-400">
|
||||
<WifiOff className="h-3 w-3" /> Offline
|
||||
</span>
|
||||
)}
|
||||
{pending > 0 && (
|
||||
<button
|
||||
onClick={() => void flush()}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" /> {pending} to sync
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!position ? (
|
||||
<button
|
||||
onClick={() => void locate()}
|
||||
disabled={locating}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 py-5 text-base font-semibold text-primary-foreground disabled:opacity-60"
|
||||
>
|
||||
{locating ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" /> Finding you…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Navigation className="h-5 w-5" /> Find customers near me
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="rounded-xl border bg-card px-3 py-2.5 text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 font-medium">
|
||||
<MapPin className="h-3.5 w-3.5 text-primary" />
|
||||
{position.label}
|
||||
{position.accuracyM !== null && (
|
||||
<span className="text-muted-foreground">
|
||||
±{Math.round(position.accuracyM)}m
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => void locate()}
|
||||
className="text-muted-foreground underline underline-offset-2"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[11px] text-muted-foreground">
|
||||
{position.lat.toFixed(5)}, {position.lon.toFixed(5)} · within {radiusKm} km
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{position && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Nearby ({nearby.length})
|
||||
</h2>
|
||||
|
||||
{nearby.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
No customers pinned within {radiusKm} km yet. Search below — saving a
|
||||
visit records this spot for next time.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{nearby.map((row) => (
|
||||
<CustomerRow
|
||||
key={row.id}
|
||||
id={row.id}
|
||||
name={row.name}
|
||||
sub={`${row.companyName ?? "—"} · ${formatDistance(row.distanceKm)}${row.isMine ? " · yours" : ""}`}
|
||||
checked={selected.has(row.id)}
|
||||
onToggle={() => toggle(row.id, true)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{canWiden && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setRadiusKm(
|
||||
(current) =>
|
||||
RADIUS_STEPS[
|
||||
Math.min(RADIUS_STEPS.indexOf(current) + 1, RADIUS_STEPS.length - 1)
|
||||
]
|
||||
)
|
||||
}
|
||||
className="self-start text-xs font-medium text-primary underline underline-offset-2"
|
||||
>
|
||||
Widen search
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<label className="relative block">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search any customer by name"
|
||||
className="w-full rounded-xl border bg-background py-3 pl-9 pr-3 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{searchResults.map((row) => {
|
||||
const hasCoordinates = row.lat !== undefined;
|
||||
return (
|
||||
<CustomerRow
|
||||
key={row.id}
|
||||
id={row.id}
|
||||
name={row.customer}
|
||||
sub={
|
||||
hasCoordinates
|
||||
? (row.companyName ?? "—")
|
||||
: `${row.companyName ?? "—"} · will save this location`
|
||||
}
|
||||
checked={selected.has(row.id)}
|
||||
onToggle={() => toggle(row.id, hasCoordinates)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
placeholder="Note (optional)"
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-xl border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="sticky bottom-0 mt-auto bg-gradient-to-t from-background via-background to-transparent pb-2 pt-3">
|
||||
<button
|
||||
onClick={() => void submit()}
|
||||
disabled={!position || selected.size === 0 || submitting}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 py-4 text-base font-semibold text-primary-foreground disabled:opacity-40"
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-5 w-5" />
|
||||
{selected.size === 0
|
||||
? "Select who you saw"
|
||||
: `Log ${selected.size} visit${selected.size === 1 ? "" : "s"}`}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDistance(km: number): string {
|
||||
return km < 1 ? `${Math.round(km * 1000)} m` : `${km.toFixed(1)} km`;
|
||||
}
|
||||
|
||||
function CustomerRow({
|
||||
name,
|
||||
sub,
|
||||
checked,
|
||||
onToggle,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
sub: string;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
aria-pressed={checked}
|
||||
// Generous hit area: this is tapped with gloves on, outdoors,
|
||||
// in the sun.
|
||||
className={`flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left transition-colors ${
|
||||
checked ? "border-primary bg-primary/8" : "bg-card"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md border ${
|
||||
checked ? "border-primary bg-primary text-primary-foreground" : "bg-background"
|
||||
}`}
|
||||
>
|
||||
{checked && <Check className="h-4 w-4" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{name}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{sub}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
54
src/app/(rep)/rep/layout.tsx
Normal file
54
src/app/(rep)/rep/layout.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { Metadata, Viewport } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { MobileAuthGate } from "@/app/(mobile)/m/auth-gate";
|
||||
|
||||
/**
|
||||
* Rep visit-logging PWA shell.
|
||||
*
|
||||
* Third instance of the per-route-group PWA pattern already running
|
||||
* here (`/m` boss phone, `/counter` over-the-counter), so it reuses
|
||||
* `MobileAuthGate` and gets its own manifest + service worker rather
|
||||
* than inventing anything.
|
||||
*
|
||||
* Deliberately not in the sidebar — matching `/m` and `/counter`. Reps
|
||||
* install this to their home screen and never navigate to it from a
|
||||
* menu; the desktop sidebar is the office's tool.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: "WFDASH — Rep Visits",
|
||||
description: "Log customer visits on site. Works without signal.",
|
||||
manifest: "/manifest-rep.json",
|
||||
icons: { apple: "/apple-touch-icon.png" },
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "Visits",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
// `viewportFit: cover` plus the safe-area padding below keeps the
|
||||
// submit button clear of the home indicator on a notched iPhone —
|
||||
// it's the control the rep taps most and the easiest one to lose
|
||||
// under the system UI.
|
||||
viewportFit: "cover",
|
||||
themeColor: "#E85D4C",
|
||||
};
|
||||
|
||||
export default function RepLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<MobileAuthGate>
|
||||
<main
|
||||
className="min-h-screen bg-background text-foreground antialiased"
|
||||
style={{
|
||||
paddingBottom: "env(safe-area-inset-bottom)",
|
||||
paddingTop: "env(safe-area-inset-top)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</MobileAuthGate>
|
||||
);
|
||||
}
|
||||
201
src/app/(rep)/rep/outbox.test.ts
Normal file
201
src/app/(rep)/rep/outbox.test.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
enqueue,
|
||||
flushOutbox,
|
||||
readOutbox,
|
||||
removeFromOutbox,
|
||||
readCachedSnapshot,
|
||||
writeCachedSnapshot,
|
||||
} from "./outbox";
|
||||
|
||||
const CHECK_IN = {
|
||||
lat: -26.4,
|
||||
lon: 27.4,
|
||||
accuracyM: 12,
|
||||
positionSource: "phone" as const,
|
||||
visitedAt: "2026-09-06T09:15:00.000Z",
|
||||
visits: [{ customerId: "c1" }],
|
||||
};
|
||||
|
||||
function response(status: number): Response {
|
||||
return new Response(status === 204 ? null : "{}", { status });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe("outbox queueing", () => {
|
||||
it("starts empty", () => {
|
||||
expect(readOutbox()).toEqual([]);
|
||||
});
|
||||
|
||||
it("queues a check-in with an id and a zero attempt count", () => {
|
||||
const entry = enqueue(CHECK_IN);
|
||||
|
||||
expect(entry.id).toBeTruthy();
|
||||
expect(entry.attempts).toBe(0);
|
||||
expect(readOutbox()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps entries distinct", () => {
|
||||
enqueue(CHECK_IN);
|
||||
enqueue(CHECK_IN);
|
||||
|
||||
const ids = readOutbox().map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(2);
|
||||
});
|
||||
|
||||
it("removes a specific entry", () => {
|
||||
const first = enqueue(CHECK_IN);
|
||||
enqueue(CHECK_IN);
|
||||
|
||||
removeFromOutbox(first.id);
|
||||
|
||||
expect(readOutbox()).toHaveLength(1);
|
||||
expect(readOutbox()[0].id).not.toBe(first.id);
|
||||
});
|
||||
|
||||
it("survives corrupt storage instead of throwing on the rep's screen", () => {
|
||||
window.localStorage.setItem("wfdash.rep.outbox.v1", "{not json");
|
||||
expect(readOutbox()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flushOutbox", () => {
|
||||
it("posts each entry and clears it once the server acknowledges", async () => {
|
||||
enqueue(CHECK_IN);
|
||||
const fetchImpl = vi.fn(async () => response(200)) as unknown as typeof fetch;
|
||||
|
||||
const result = await flushOutbox("tok", fetchImpl);
|
||||
|
||||
expect(result).toEqual({ sent: 1, failed: 0, remaining: 0 });
|
||||
expect(readOutbox()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sends the check-in time, not the time it was flushed", async () => {
|
||||
// A queued visit can reach the server hours later; recording the
|
||||
// flush time would put the visit at the wrong point in the day and
|
||||
// break any match against the vehicle trail.
|
||||
enqueue(CHECK_IN);
|
||||
const calls: RequestInit[] = [];
|
||||
const fetchImpl = vi.fn(async (_url: unknown, init?: RequestInit) => {
|
||||
calls.push(init!);
|
||||
return response(200);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await flushOutbox("tok", fetchImpl);
|
||||
|
||||
const body = JSON.parse(calls[0].body as string);
|
||||
expect(body.visitedAt).toBe("2026-09-06T09:15:00.000Z");
|
||||
expect(body.accuracyM).toBe(12);
|
||||
});
|
||||
|
||||
it("sends the session token", async () => {
|
||||
enqueue(CHECK_IN);
|
||||
const calls: RequestInit[] = [];
|
||||
const fetchImpl = vi.fn(async (_url: unknown, init?: RequestInit) => {
|
||||
calls.push(init!);
|
||||
return response(200);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await flushOutbox("tok-123", fetchImpl);
|
||||
|
||||
expect((calls[0].headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer tok-123"
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an entry when the phone is offline", async () => {
|
||||
// The single most important behaviour here: a failed flush must not
|
||||
// lose the visit.
|
||||
enqueue(CHECK_IN);
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new TypeError("Failed to fetch");
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await flushOutbox("tok", fetchImpl);
|
||||
|
||||
expect(result).toMatchObject({ sent: 0, failed: 1, remaining: 1 });
|
||||
expect(readOutbox()[0].attempts).toBe(1);
|
||||
expect(readOutbox()[0].lastError).toMatch(/Failed to fetch/);
|
||||
});
|
||||
|
||||
it("keeps an entry on a 5xx and counts the attempt", async () => {
|
||||
enqueue(CHECK_IN);
|
||||
const fetchImpl = vi.fn(async () => response(503)) as unknown as typeof fetch;
|
||||
|
||||
await flushOutbox("tok", fetchImpl);
|
||||
await flushOutbox("tok", fetchImpl);
|
||||
|
||||
expect(readOutbox()).toHaveLength(1);
|
||||
expect(readOutbox()[0].attempts).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps an entry on a 401 — the session will be valid again after re-login", async () => {
|
||||
enqueue(CHECK_IN);
|
||||
const fetchImpl = vi.fn(async () => response(401)) as unknown as typeof fetch;
|
||||
|
||||
await flushOutbox("stale", fetchImpl);
|
||||
|
||||
expect(readOutbox()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drops an entry the server permanently rejects", async () => {
|
||||
// A 400 means the server understood and refused it. Retrying forever
|
||||
// would wedge every later visit behind one bad row.
|
||||
enqueue(CHECK_IN);
|
||||
const fetchImpl = vi.fn(async () => response(400)) as unknown as typeof fetch;
|
||||
|
||||
const result = await flushOutbox("tok", fetchImpl);
|
||||
|
||||
expect(result).toMatchObject({ sent: 0, failed: 1, remaining: 0 });
|
||||
expect(readOutbox()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not let one stuck entry block the ones behind it", async () => {
|
||||
enqueue(CHECK_IN);
|
||||
enqueue({ ...CHECK_IN, visits: [{ customerId: "c2" }] });
|
||||
|
||||
let call = 0;
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
call++;
|
||||
return response(call === 1 ? 503 : 200);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await flushOutbox("tok", fetchImpl);
|
||||
|
||||
expect(result).toMatchObject({ sent: 1, failed: 1, remaining: 1 });
|
||||
expect(readOutbox()[0].visits[0].customerId).toBe("c1");
|
||||
});
|
||||
|
||||
it("is a no-op on an empty queue", async () => {
|
||||
const fetchImpl = vi.fn() as unknown as typeof fetch;
|
||||
expect(await flushOutbox("tok", fetchImpl)).toEqual({
|
||||
sent: 0,
|
||||
failed: 0,
|
||||
remaining: 0,
|
||||
});
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("customer snapshot cache", () => {
|
||||
it("round-trips", () => {
|
||||
writeCachedSnapshot({ customers: [{ id: "c1" }], fetchedAt: "2026-09-06T00:00:00.000Z" });
|
||||
|
||||
expect(readCachedSnapshot()).toEqual({
|
||||
customers: [{ id: "c1" }],
|
||||
fetchedAt: "2026-09-06T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when nothing is cached", () => {
|
||||
expect(readCachedSnapshot()).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a cache whose shape is wrong rather than half-loading it", () => {
|
||||
window.localStorage.setItem("wfdash.rep.customers.v1", JSON.stringify({ customers: "nope" }));
|
||||
expect(readCachedSnapshot()).toBeNull();
|
||||
});
|
||||
});
|
||||
188
src/app/(rep)/rep/outbox.ts
Normal file
188
src/app/(rep)/rep/outbox.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* Offline outbox for rep check-ins.
|
||||
*
|
||||
* A rep at a mine shaft has a GPS fix but frequently no signal. The
|
||||
* whole flow is designed to complete without a network: the customer
|
||||
* list is cached, matching runs on the phone, and the submission lands
|
||||
* here until there's a connection.
|
||||
*
|
||||
* `localStorage` rather than IndexedDB on purpose. The payloads are
|
||||
* tiny (a position plus a handful of customer ids), the volume is a few
|
||||
* entries a day, and localStorage is synchronous — which matters when
|
||||
* the rep hits Save and immediately locks the phone or walks into a
|
||||
* dead zone. An IndexedDB write is async and can be interrupted between
|
||||
* the call and the commit; that's precisely the moment we can't afford
|
||||
* to lose the entry.
|
||||
*
|
||||
* Entries are only removed once the server has acknowledged them, so a
|
||||
* failed flush retries rather than silently dropping a visit. The cost
|
||||
* of that choice is possible duplicates if a response is lost in
|
||||
* flight — deliberately preferred over a lost visit, and surfaced to
|
||||
* the rep rather than merged away.
|
||||
*/
|
||||
|
||||
const OUTBOX_KEY = "wfdash.rep.outbox.v1";
|
||||
const SNAPSHOT_KEY = "wfdash.rep.customers.v1";
|
||||
|
||||
export interface QueuedVisit {
|
||||
customerId: string;
|
||||
notes?: string;
|
||||
pinCustomer?: boolean;
|
||||
}
|
||||
|
||||
export interface QueuedCheckIn {
|
||||
/** Client-side id, so a retry can be matched to its entry. */
|
||||
id: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
accuracyM: number | null;
|
||||
positionSource: "phone" | "tracker" | "manual";
|
||||
/** When the rep actually checked in — not when it reached the server. */
|
||||
visitedAt: string;
|
||||
visits: QueuedVisit[];
|
||||
/** Failed flush attempts, for surfacing a stuck entry to the rep. */
|
||||
attempts: number;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface CachedSnapshot {
|
||||
customers: unknown[];
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
function safeRead<T>(key: string, fallback: T): T {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return fallback;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
// Corrupt or unparseable — treat as empty rather than throwing on a
|
||||
// screen the rep needs to work right now.
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function safeWrite(key: string, value: unknown): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* quota or private mode — nothing useful to do here */
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- outbox */
|
||||
|
||||
export function readOutbox(): QueuedCheckIn[] {
|
||||
const entries = safeRead<QueuedCheckIn[]>(OUTBOX_KEY, []);
|
||||
return Array.isArray(entries) ? entries : [];
|
||||
}
|
||||
|
||||
export function enqueue(entry: Omit<QueuedCheckIn, "id" | "attempts">): QueuedCheckIn {
|
||||
const queued: QueuedCheckIn = {
|
||||
...entry,
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
attempts: 0,
|
||||
};
|
||||
safeWrite(OUTBOX_KEY, [...readOutbox(), queued]);
|
||||
return queued;
|
||||
}
|
||||
|
||||
export function removeFromOutbox(id: string): void {
|
||||
safeWrite(
|
||||
OUTBOX_KEY,
|
||||
readOutbox().filter((entry) => entry.id !== id)
|
||||
);
|
||||
}
|
||||
|
||||
function markAttempt(id: string, error: string): void {
|
||||
safeWrite(
|
||||
OUTBOX_KEY,
|
||||
readOutbox().map((entry) =>
|
||||
entry.id === id
|
||||
? { ...entry, attempts: entry.attempts + 1, lastError: error }
|
||||
: entry
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export interface FlushResult {
|
||||
sent: number;
|
||||
failed: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to post every queued check-in, oldest first.
|
||||
*
|
||||
* A `4xx` other than 401/408/429 means the server understood and
|
||||
* rejected the entry — retrying forever would wedge the queue behind a
|
||||
* permanently-bad row, so those are dropped. Network failures, 401s
|
||||
* (session expired, will be valid again after re-login) and 5xx are
|
||||
* kept for the next flush.
|
||||
*/
|
||||
export async function flushOutbox(
|
||||
token: string,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<FlushResult> {
|
||||
const result: FlushResult = { sent: 0, failed: 0, remaining: 0 };
|
||||
|
||||
for (const entry of readOutbox()) {
|
||||
try {
|
||||
const response = await fetchImpl("/api/v1/visits", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
lat: entry.lat,
|
||||
lon: entry.lon,
|
||||
accuracyM: entry.accuracyM,
|
||||
positionSource: entry.positionSource,
|
||||
visitedAt: entry.visitedAt,
|
||||
visits: entry.visits,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
removeFromOutbox(entry.id);
|
||||
result.sent++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const permanent =
|
||||
response.status >= 400 &&
|
||||
response.status < 500 &&
|
||||
![401, 408, 429].includes(response.status);
|
||||
|
||||
if (permanent) {
|
||||
removeFromOutbox(entry.id);
|
||||
result.failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
markAttempt(entry.id, `HTTP ${response.status}`);
|
||||
result.failed++;
|
||||
} catch (error) {
|
||||
markAttempt(entry.id, error instanceof Error ? error.message : "offline");
|
||||
result.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
result.remaining = readOutbox().length;
|
||||
return result;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- snapshot */
|
||||
|
||||
export function readCachedSnapshot(): CachedSnapshot | null {
|
||||
const cached = safeRead<CachedSnapshot | null>(SNAPSHOT_KEY, null);
|
||||
if (!cached || !Array.isArray(cached.customers)) return null;
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function writeCachedSnapshot(snapshot: CachedSnapshot): void {
|
||||
safeWrite(SNAPSHOT_KEY, snapshot);
|
||||
}
|
||||
16
src/app/(rep)/rep/page.tsx
Normal file
16
src/app/(rep)/rep/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { CheckInView } from "./check-in-view";
|
||||
|
||||
/**
|
||||
* `/rep` — the rep's whole app. One screen: find me, tick who I saw,
|
||||
* log it.
|
||||
*
|
||||
* `"use client"` because the entire flow is client-side by design —
|
||||
* geolocation, the cached customer snapshot, local matching, and the
|
||||
* offline outbox all live in the browser so a check-in completes with
|
||||
* no signal.
|
||||
*/
|
||||
export default function RepPage() {
|
||||
return <CheckInView />;
|
||||
}
|
||||
42
src/app/api/v1/visits/customers/route.ts
Normal file
42
src/app/api/v1/visits/customers/route.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getBrowserSessionUser } from "@/lib/server/browser-session";
|
||||
import { getCustomerSnapshot } from "@/lib/server/visits-service";
|
||||
|
||||
/**
|
||||
* GET /api/v1/visits/customers
|
||||
*
|
||||
* The customer snapshot the rep PWA caches for offline matching:
|
||||
* every customer, with coordinates where they exist.
|
||||
*
|
||||
* The phone does the "who is near me" filtering locally with
|
||||
* `rankNearbyCustomers` rather than asking the server per check-in.
|
||||
* Mine shafts have poor signal, and phone GPS works without a network
|
||||
* — a server-side match would fail in exactly the places reps need it.
|
||||
* So the phone pulls this list when it has signal and works from the
|
||||
* cache thereafter.
|
||||
*
|
||||
* Includes the rep field so the phone can sort its own customers first
|
||||
* without a second lookup, and unpinned customers so the search box has
|
||||
* something to find on day one (when nothing is pinned yet).
|
||||
*
|
||||
* Cached hard on the client, not here: the list changes rarely, but a
|
||||
* newly-added customer should reach a rep on their next refresh rather
|
||||
* than whenever a CDN feels like it.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
await getBrowserSessionUser(req);
|
||||
|
||||
const customers = await getCustomerSnapshot();
|
||||
|
||||
return NextResponse.json(
|
||||
{ customers, fetchedAt: new Date().toISOString() },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to load customers";
|
||||
const status = (error as Error & { status?: number }).status || 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
82
src/app/api/v1/visits/position/route.ts
Normal file
82
src/app/api/v1/visits/position/route.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getBrowserSessionUser } from "@/lib/server/browser-session";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
import { getTrackerAdapter, TrackerError } from "@/lib/server/tracker-service";
|
||||
|
||||
/**
|
||||
* GET /api/v1/visits/position — the tracker fallback.
|
||||
*
|
||||
* Phone GPS is the primary source for a check-in: it works with no
|
||||
* signal and points at the rep rather than the bakkie. This endpoint is
|
||||
* the **secondary** path, for when the rep has denied location
|
||||
* permission or the device can't get a fix.
|
||||
*
|
||||
* It necessarily needs a network, so it can't help underground — but
|
||||
* the case it covers (permission denied, or GPS off) is one where the
|
||||
* rep usually does have data.
|
||||
*
|
||||
* Resolves the vehicle from the signed-in user's
|
||||
* `users.tracker_vehicle_id`. A rep with no vehicle mapped gets a 404
|
||||
* with a message an operator can act on, rather than a silent empty
|
||||
* result that looks like "no customers nearby".
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = await getBrowserSessionUser(req);
|
||||
|
||||
const pb = await getAdminPocketBase();
|
||||
const record = await pb
|
||||
.collection("users")
|
||||
.getOne<{ tracker_vehicle_id?: string }>(user.id, {
|
||||
fields: "id,tracker_vehicle_id",
|
||||
});
|
||||
|
||||
const vehicleNo = String(record.tracker_vehicle_id ?? "").trim();
|
||||
if (!vehicleNo) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"No vehicle is linked to your account. Ask the office to set your registration on your user record, or enable location on this phone.",
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const position = await getTrackerAdapter().getCurrentPosition(vehicleNo);
|
||||
if (!position) {
|
||||
return NextResponse.json(
|
||||
{ error: `The tracker has no current position for ${vehicleNo}.` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
lat: position.lat,
|
||||
lon: position.lon,
|
||||
address: position.address,
|
||||
fixedAt: position.fixedAt,
|
||||
fixAgeSeconds: position.fixAgeSeconds,
|
||||
vehicleNo: position.vehicleNo,
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch (error) {
|
||||
// A rate-limit refusal is worth telling the rep about specifically —
|
||||
// "try again in 10 seconds" is actionable; "failed" is not.
|
||||
if (error instanceof TrackerError && error.retryAfterSeconds) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `The tracker is rate-limited. Try again in ${error.retryAfterSeconds} seconds.`,
|
||||
retryAfterSeconds: error.retryAfterSeconds,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Could not read the vehicle position";
|
||||
const status = (error as Error & { status?: number }).status || 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
115
src/app/api/v1/visits/route.ts
Normal file
115
src/app/api/v1/visits/route.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getBrowserSessionUser } from "@/lib/server/browser-session";
|
||||
import {
|
||||
recordVisits,
|
||||
type PositionSource,
|
||||
type RecordVisitInput,
|
||||
} from "@/lib/server/visits-service";
|
||||
|
||||
const SOURCES: PositionSource[] = ["phone", "tracker", "manual"];
|
||||
|
||||
/**
|
||||
* POST /api/v1/visits — record a rep check-in.
|
||||
*
|
||||
* Authenticated with the **browser session**, not `AGENT_API_KEY`: the
|
||||
* caller is a logged-in rep on their phone. The rep id comes from the
|
||||
* validated session rather than the request body, so a client can't
|
||||
* log a visit as someone else.
|
||||
*
|
||||
* A plain route rather than a Server Action because the rep PWA
|
||||
* replays queued check-ins from an offline outbox — a `fetch` is
|
||||
* trivial to retry from stored JSON, whereas a Server Action is bound
|
||||
* to the React runtime that queued it.
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* lat, lon required, decimal degrees
|
||||
* positionSource "phone" | "tracker" | "manual"
|
||||
* accuracyM? GPS accuracy in metres
|
||||
* fixAgeSeconds? how old the fix was
|
||||
* visitedAt? ISO; set by the phone when the check-in was
|
||||
* *made*, which for a queued submission is
|
||||
* hours before it reaches us
|
||||
* visits: [{ customerId, notes?, pinCustomer? }]
|
||||
* }
|
||||
*
|
||||
* Idempotency is deliberately not attempted here — see the outbox in
|
||||
* the rep PWA, which only removes an entry once the server has
|
||||
* acknowledged it, and surfaces duplicates to the rep rather than
|
||||
* silently merging them.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await getBrowserSessionUser(req);
|
||||
|
||||
const body = (await req.json().catch(() => null)) as {
|
||||
lat?: unknown;
|
||||
lon?: unknown;
|
||||
positionSource?: unknown;
|
||||
accuracyM?: unknown;
|
||||
fixAgeSeconds?: unknown;
|
||||
visitedAt?: unknown;
|
||||
visits?: unknown;
|
||||
} | null;
|
||||
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json({ error: "JSON object body required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { lat, lon } = body;
|
||||
if (typeof lat !== "number" || typeof lon !== "number") {
|
||||
return NextResponse.json(
|
||||
{ error: "lat and lon are required as numbers" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const positionSource = SOURCES.includes(body.positionSource as PositionSource)
|
||||
? (body.positionSource as PositionSource)
|
||||
: "phone";
|
||||
|
||||
if (!Array.isArray(body.visits) || body.visits.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "visits must be a non-empty array" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const visits: RecordVisitInput[] = [];
|
||||
for (const entry of body.visits) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const { customerId, notes, pinCustomer } = entry as Record<string, unknown>;
|
||||
if (typeof customerId !== "string" || !customerId) continue;
|
||||
visits.push({
|
||||
customerId,
|
||||
notes: typeof notes === "string" ? notes : undefined,
|
||||
pinCustomer: pinCustomer === true,
|
||||
});
|
||||
}
|
||||
|
||||
if (visits.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "no valid customer ids in visits" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await recordVisits({
|
||||
repId: user.id,
|
||||
lat,
|
||||
lon,
|
||||
positionSource,
|
||||
accuracyM: typeof body.accuracyM === "number" ? body.accuracyM : null,
|
||||
fixAgeSeconds:
|
||||
typeof body.fixAgeSeconds === "number" ? body.fixAgeSeconds : null,
|
||||
visitedAt: typeof body.visitedAt === "string" ? body.visitedAt : undefined,
|
||||
visits,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to record visits";
|
||||
const status = (error as Error & { status?: number }).status || 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { useEffect } from "react";
|
|||
* - /m → /sw-mobile.js (boss-dashboard PWA, dark theme)
|
||||
* - /counter → /sw-counter.js (over-the-counter order PWA)
|
||||
* - /new-order → /sw-counter.js (same counter PWA shell)
|
||||
* - /rep → /sw-rep.js (rep visit-logging PWA, offline-first)
|
||||
* - everything else → /sw.js (main WFDASH app)
|
||||
*
|
||||
* All SWs are scoped to `/` so co-existence is fine — they each
|
||||
|
|
@ -21,6 +22,9 @@ function pickSwPath(): string {
|
|||
if (path.startsWith("/counter") || path.startsWith("/new-order")) {
|
||||
return "/sw-counter.js";
|
||||
}
|
||||
if (path === "/rep" || path.startsWith("/rep/")) {
|
||||
return "/sw-rep.js";
|
||||
}
|
||||
return "/sw.js";
|
||||
}
|
||||
|
||||
|
|
|
|||
270
src/lib/server/visits-service.test.ts
Normal file
270
src/lib/server/visits-service.test.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const setCustomerCoordinates = vi.fn();
|
||||
vi.mock("./customers-service", () => ({
|
||||
setCustomerCoordinates: (...args: unknown[]) => setCustomerCoordinates(...args),
|
||||
}));
|
||||
|
||||
import { getCustomerSnapshot, recordVisits } from "./visits-service";
|
||||
|
||||
type Row = Record<string, unknown> & { id: string };
|
||||
|
||||
function fakePb(seed: Record<string, Row[]> = {}) {
|
||||
const store: Record<string, Row[]> = { customers: [], rep_visits: [], ...seed };
|
||||
let nextId = 1;
|
||||
const failures: Record<string, Set<string>> = {};
|
||||
|
||||
const api = {
|
||||
store,
|
||||
/** Makes `create`/`update` on a collection reject for a given id. */
|
||||
failOn(collection: string, id: string) {
|
||||
(failures[collection] ??= new Set()).add(id);
|
||||
},
|
||||
collection(name: string) {
|
||||
const rows = () => (store[name] ??= []);
|
||||
return {
|
||||
async getFullList<T>(): Promise<T[]> {
|
||||
return rows() as unknown as T[];
|
||||
},
|
||||
async create(data: Record<string, unknown>) {
|
||||
const customer = String(data.customer ?? "");
|
||||
if (failures[name]?.has(customer)) throw new Error("create failed");
|
||||
const row = { ...data, id: `v-${nextId++}` } as Row;
|
||||
rows().push(row);
|
||||
return row;
|
||||
},
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
if (failures[name]?.has(id)) throw new Error("update failed");
|
||||
const row = rows().find((r) => r.id === id);
|
||||
if (!row) throw new Error("not found");
|
||||
Object.assign(row, data);
|
||||
return row;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return api as unknown as Awaited<
|
||||
ReturnType<typeof import("./pocketbase-admin").getAdminPocketBase>
|
||||
> & { store: Record<string, Row[]>; failOn: (c: string, id: string) => void };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setCustomerCoordinates.mockResolvedValue({ id: "c1" });
|
||||
});
|
||||
|
||||
describe("getCustomerSnapshot", () => {
|
||||
it("flattens the company name and carries real coordinates", async () => {
|
||||
const pb = fakePb({
|
||||
customers: [
|
||||
{
|
||||
id: "c1",
|
||||
customer: "DANIE GREYLING",
|
||||
rep: "u-tiaan",
|
||||
coordinates: { lat: -26.4, lon: 27.4 },
|
||||
expand: { company: { company: "Sibanye - Driefontein" } },
|
||||
},
|
||||
] as Row[],
|
||||
});
|
||||
|
||||
expect(await getCustomerSnapshot(pb)).toEqual([
|
||||
{
|
||||
id: "c1",
|
||||
customer: "DANIE GREYLING",
|
||||
companyName: "Sibanye - Driefontein",
|
||||
rep: "u-tiaan",
|
||||
lat: -26.4,
|
||||
lon: 27.4,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits coordinates for an unpinned customer rather than sending Null Island", async () => {
|
||||
// Every customer starts at PB's `{lon: 0, lat: 0}` default. Shipping
|
||||
// that as a position would put the whole customer base in the Gulf
|
||||
// of Guinea and make the nearby list nonsense.
|
||||
const pb = fakePb({
|
||||
customers: [
|
||||
{ id: "c1", customer: "A", rep: "", coordinates: { lat: 0, lon: 0 } },
|
||||
{ id: "c2", customer: "B", rep: "" },
|
||||
] as Row[],
|
||||
});
|
||||
|
||||
const snapshot = await getCustomerSnapshot(pb);
|
||||
|
||||
expect(snapshot[0]).not.toHaveProperty("lat");
|
||||
expect(snapshot[1]).not.toHaveProperty("lon");
|
||||
});
|
||||
|
||||
it("still includes unpinned customers — the search box needs them", async () => {
|
||||
// On day one *every* customer is unpinned. Filtering them out would
|
||||
// make the app useless until someone had already seeded the map.
|
||||
const pb = fakePb({
|
||||
customers: [{ id: "c1", customer: "A", coordinates: { lat: 0, lon: 0 } }] as Row[],
|
||||
});
|
||||
|
||||
expect(await getCustomerSnapshot(pb)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("tolerates a missing company expand", async () => {
|
||||
const pb = fakePb({ customers: [{ id: "c1", customer: "A" }] as Row[] });
|
||||
const [row] = await getCustomerSnapshot(pb);
|
||||
expect(row.companyName).toBeNull();
|
||||
expect(row.rep).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordVisits", () => {
|
||||
const base = {
|
||||
repId: "u-tiaan",
|
||||
lat: -26.4,
|
||||
lon: 27.4,
|
||||
positionSource: "phone" as const,
|
||||
visitedAt: "2026-09-06T09:15:00.000Z",
|
||||
};
|
||||
|
||||
it("writes one visit row per customer seen", async () => {
|
||||
const pb = fakePb({ customers: [{ id: "c1" }, { id: "c2" }] as Row[] });
|
||||
|
||||
const result = await recordVisits(
|
||||
{ ...base, accuracyM: 12, visits: [{ customerId: "c1" }, { customerId: "c2" }] },
|
||||
pb
|
||||
);
|
||||
|
||||
expect(result.created).toBe(2);
|
||||
expect(pb.store.rep_visits[0]).toMatchObject({
|
||||
rep: "u-tiaan",
|
||||
customer: "c1",
|
||||
visited_at: "2026-09-06T09:15:00.000Z",
|
||||
coordinates: { lat: -26.4, lon: 27.4 },
|
||||
position_source: "phone",
|
||||
accuracy_m: 12,
|
||||
pinned_customer: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("stamps coordinates onto a customer being pinned", async () => {
|
||||
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
|
||||
|
||||
const result = await recordVisits(
|
||||
{ ...base, visits: [{ customerId: "c1", pinCustomer: true }] },
|
||||
pb
|
||||
);
|
||||
|
||||
expect(setCustomerCoordinates).toHaveBeenCalledWith("c1", -26.4, 27.4, pb);
|
||||
expect(result.pinned).toBe(1);
|
||||
expect(pb.store.rep_visits[0].pinned_customer).toBe(true);
|
||||
});
|
||||
|
||||
it("does not claim a pin that failed to write", async () => {
|
||||
// A visit row saying `pinned_customer: true` when nothing was
|
||||
// written would be a lie in the audit trail.
|
||||
setCustomerCoordinates.mockRejectedValue(new Error("PB down"));
|
||||
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
|
||||
|
||||
const result = await recordVisits(
|
||||
{ ...base, visits: [{ customerId: "c1", pinCustomer: true }] },
|
||||
pb
|
||||
);
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
expect(result.pinned).toBe(0);
|
||||
expect(pb.store.rep_visits[0].pinned_customer).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the other visits when one customer fails", async () => {
|
||||
// Reps submit from a replayed offline queue; losing four good visits
|
||||
// because one customer id went stale would be the worst outcome.
|
||||
const pb = fakePb({ customers: [{ id: "c1" }, { id: "c2" }] as Row[] });
|
||||
pb.failOn("rep_visits", "c1");
|
||||
|
||||
const result = await recordVisits(
|
||||
{ ...base, visits: [{ customerId: "c1" }, { customerId: "c2" }] },
|
||||
pb
|
||||
);
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
expect(pb.store.rep_visits[0].customer).toBe("c2");
|
||||
});
|
||||
|
||||
it("updates lastvisiteddate on the customer", async () => {
|
||||
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
|
||||
|
||||
await recordVisits({ ...base, visits: [{ customerId: "c1" }] }, pb);
|
||||
|
||||
expect(pb.store.customers[0].lastvisiteddate).toBe("2026-09-06T09:15:00.000Z");
|
||||
});
|
||||
|
||||
it("still records the visit when lastvisiteddate cannot be updated", async () => {
|
||||
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
|
||||
pb.failOn("customers", "c1");
|
||||
|
||||
const result = await recordVisits({ ...base, visits: [{ customerId: "c1" }] }, pb);
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
});
|
||||
|
||||
it("carries notes through", async () => {
|
||||
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
|
||||
|
||||
await recordVisits(
|
||||
{ ...base, visits: [{ customerId: "c1", notes: "Quoted on hex nuts" }] },
|
||||
pb
|
||||
);
|
||||
|
||||
expect(pb.store.rep_visits[0].notes).toBe("Quoted on hex nuts");
|
||||
});
|
||||
|
||||
it("records which position source was used", async () => {
|
||||
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
|
||||
|
||||
await recordVisits(
|
||||
{ ...base, positionSource: "tracker", visits: [{ customerId: "c1" }] },
|
||||
pb
|
||||
);
|
||||
|
||||
expect(pb.store.rep_visits[0].position_source).toBe("tracker");
|
||||
});
|
||||
|
||||
it("skips blank customer ids without failing the batch", async () => {
|
||||
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
|
||||
|
||||
const result = await recordVisits(
|
||||
{ ...base, visits: [{ customerId: "" }, { customerId: "c1" }] },
|
||||
pb
|
||||
);
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects a submission with no rep, no customers, or a bad position", async () => {
|
||||
const pb = fakePb();
|
||||
|
||||
await expect(
|
||||
recordVisits({ ...base, repId: "", visits: [{ customerId: "c1" }] }, pb)
|
||||
).rejects.toThrow(/repId/);
|
||||
|
||||
await expect(recordVisits({ ...base, visits: [] }, pb)).rejects.toThrow(/at least one/);
|
||||
|
||||
await expect(
|
||||
recordVisits({ ...base, lat: 99, visits: [{ customerId: "c1" }] }, pb)
|
||||
).rejects.toThrow(/lat out of range/);
|
||||
|
||||
await expect(
|
||||
recordVisits({ ...base, lon: NaN, visits: [{ customerId: "c1" }] }, pb)
|
||||
).rejects.toThrow(/lon out of range/);
|
||||
});
|
||||
|
||||
it("defaults visitedAt to now when the caller omits it", async () => {
|
||||
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
|
||||
|
||||
await recordVisits(
|
||||
{ repId: "u", lat: -26.4, lon: 27.4, positionSource: "phone", visits: [{ customerId: "c1" }] },
|
||||
pb
|
||||
);
|
||||
|
||||
const at = new Date(String(pb.store.rep_visits[0].visited_at)).getTime();
|
||||
expect(Math.abs(Date.now() - at)).toBeLessThan(5000);
|
||||
});
|
||||
});
|
||||
211
src/lib/server/visits-service.ts
Normal file
211
src/lib/server/visits-service.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/**
|
||||
* Rep visit check-in.
|
||||
*
|
||||
* A rep opens the PWA at a mine shaft, the phone takes a GPS fix, and
|
||||
* the app shows which customers are around them. They tick who they
|
||||
* saw and submit.
|
||||
*
|
||||
* ## Why the phone matches, not the server
|
||||
*
|
||||
* This service does **not** expose a "customers near this point"
|
||||
* endpoint. It hands the phone a compact snapshot of every customer
|
||||
* and the phone runs `rankNearbyCustomers` locally.
|
||||
*
|
||||
* That's deliberate. Mine shafts have poor signal, and the whole point
|
||||
* of using the phone's GPS rather than the vehicle tracker is that GPS
|
||||
* needs no network. If matching required a round-trip, the check-in
|
||||
* would fail in exactly the places reps use it. Matching locally gives
|
||||
* one code path that behaves identically online and off — and the
|
||||
* snapshot is small (~616 rows) and cacheable.
|
||||
*
|
||||
* ## Why phone GPS and not the tracker
|
||||
*
|
||||
* The tracker knows where the *bakkie* is; the phone knows where the
|
||||
* *rep* is, and they may be several hundred metres apart once the rep
|
||||
* has walked into the plant. `position_source` records which was used,
|
||||
* and the independently-collected vehicle trail
|
||||
* (`vehicle-trail-service.ts`) corroborates the check-in afterwards
|
||||
* without the rep doing anything.
|
||||
*/
|
||||
|
||||
import { getAdminPocketBase } from "./pocketbase-admin";
|
||||
import { setCustomerCoordinates } from "./customers-service";
|
||||
import { isRealGeoPoint } from "@/lib/geo";
|
||||
|
||||
const CUSTOMERS_COLLECTION = "customers";
|
||||
const VISITS_COLLECTION = "rep_visits";
|
||||
|
||||
type AdminPocketBase = Awaited<ReturnType<typeof getAdminPocketBase>>;
|
||||
|
||||
/**
|
||||
* How a check-in's position was obtained.
|
||||
*
|
||||
* NOTE: these must exist as values on the `rep_visits.position_source`
|
||||
* select in the PB admin — PocketBase rejects an unknown select value
|
||||
* with a 500 (see AGENTS.md).
|
||||
*/
|
||||
export type PositionSource = "phone" | "tracker" | "manual";
|
||||
|
||||
/** One customer as the phone needs it for offline matching. */
|
||||
export interface CustomerSnapshotRow {
|
||||
id: string;
|
||||
customer: string;
|
||||
companyName: string | null;
|
||||
rep: string;
|
||||
/** Omitted entirely when the customer has no real position yet. */
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
}
|
||||
|
||||
export interface RecordVisitInput {
|
||||
customerId: string;
|
||||
notes?: string;
|
||||
/**
|
||||
* Stamp the check-in position onto the customer. The phone defaults
|
||||
* this on for customers that have no coordinates yet — that's how the
|
||||
* map builds itself, one visit at a time.
|
||||
*/
|
||||
pinCustomer?: boolean;
|
||||
}
|
||||
|
||||
export interface RecordVisitsArgs {
|
||||
repId: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
positionSource: PositionSource;
|
||||
/** GPS accuracy in metres, when the browser reported one. */
|
||||
accuracyM?: number | null;
|
||||
/** Age of the fix in seconds. */
|
||||
fixAgeSeconds?: number | null;
|
||||
/** ISO timestamp of the visit. Defaults to now. */
|
||||
visitedAt?: string;
|
||||
visits: RecordVisitInput[];
|
||||
}
|
||||
|
||||
export interface RecordVisitsResult {
|
||||
created: number;
|
||||
pinned: number;
|
||||
visitIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every customer, flattened for the phone's local cache.
|
||||
*
|
||||
* Sends customers **without** coordinates too. On day one that's all of
|
||||
* them, and the rep reaches those through the search box — which is
|
||||
* also how they get pinned in the first place. Filtering them out would
|
||||
* make the app useless until someone had already seeded the map.
|
||||
*/
|
||||
export async function getCustomerSnapshot(
|
||||
pbArg?: AdminPocketBase
|
||||
): Promise<CustomerSnapshotRow[]> {
|
||||
const pb = pbArg ?? (await getAdminPocketBase());
|
||||
|
||||
const rows = await pb.collection(CUSTOMERS_COLLECTION).getFullList<{
|
||||
id: string;
|
||||
customer?: string;
|
||||
rep?: string;
|
||||
coordinates?: { lat: number; lon: number };
|
||||
expand?: { company?: { company?: string } };
|
||||
}>({
|
||||
fields: "id,customer,rep,coordinates,expand.company.company",
|
||||
expand: "company",
|
||||
sort: "customer",
|
||||
});
|
||||
|
||||
return rows.map((row) => {
|
||||
const snapshot: CustomerSnapshotRow = {
|
||||
id: row.id,
|
||||
customer: row.customer ?? "",
|
||||
companyName: row.expand?.company?.company ?? null,
|
||||
rep: row.rep ?? "",
|
||||
};
|
||||
// Null Island is PB's default for an unset geoPoint, so an
|
||||
// unpinned customer must not ship as (0, 0) — the phone would
|
||||
// place the entire customer base off the coast of Ghana.
|
||||
if (isRealGeoPoint(row.coordinates)) {
|
||||
snapshot.lat = row.coordinates.lat;
|
||||
snapshot.lon = row.coordinates.lon;
|
||||
}
|
||||
return snapshot;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one check-in: a `rep_visits` row per customer seen, plus the
|
||||
* coordinate stamp for any customer being pinned.
|
||||
*
|
||||
* Partial success is deliberate. Reps submit in poor signal and often
|
||||
* from a replayed offline queue; failing the whole batch because one
|
||||
* customer id went stale would lose the other four visits with it. Each
|
||||
* visit is written independently and the caller gets the count.
|
||||
*/
|
||||
export async function recordVisits(
|
||||
args: RecordVisitsArgs,
|
||||
pbArg?: AdminPocketBase
|
||||
): Promise<RecordVisitsResult> {
|
||||
const pb = pbArg ?? (await getAdminPocketBase());
|
||||
|
||||
if (!args.repId) throw new Error("recordVisits: repId is required");
|
||||
if (!Number.isFinite(args.lat) || args.lat < -90 || args.lat > 90) {
|
||||
throw new Error(`recordVisits: lat out of range (${String(args.lat)})`);
|
||||
}
|
||||
if (!Number.isFinite(args.lon) || args.lon < -180 || args.lon > 180) {
|
||||
throw new Error(`recordVisits: lon out of range (${String(args.lon)})`);
|
||||
}
|
||||
if (args.visits.length === 0) {
|
||||
throw new Error("recordVisits: at least one customer is required");
|
||||
}
|
||||
|
||||
const visitedAt = args.visitedAt ?? new Date().toISOString();
|
||||
const result: RecordVisitsResult = { created: 0, pinned: 0, visitIds: [] };
|
||||
|
||||
for (const visit of args.visits) {
|
||||
if (!visit.customerId) continue;
|
||||
|
||||
// Pin first: if the coordinate write fails the visit is still worth
|
||||
// recording, but a visit row claiming `pinned_customer: true` when
|
||||
// nothing was written would be a lie in the audit trail.
|
||||
let pinned = false;
|
||||
if (visit.pinCustomer) {
|
||||
try {
|
||||
await setCustomerCoordinates(visit.customerId, args.lat, args.lon, pb);
|
||||
pinned = true;
|
||||
result.pinned++;
|
||||
} catch {
|
||||
pinned = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await pb.collection(VISITS_COLLECTION).create({
|
||||
rep: args.repId,
|
||||
customer: visit.customerId,
|
||||
visited_at: visitedAt,
|
||||
coordinates: { lat: args.lat, lon: args.lon },
|
||||
position_source: args.positionSource,
|
||||
fix_age_seconds: args.fixAgeSeconds ?? 0,
|
||||
accuracy_m: args.accuracyM ?? 0,
|
||||
notes: visit.notes ?? "",
|
||||
pinned_customer: pinned,
|
||||
});
|
||||
result.created++;
|
||||
result.visitIds.push((row as { id: string }).id);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// `lastvisiteddate` predates this feature and is shown on the
|
||||
// customer page, so keep it in step. Best-effort: a visit that
|
||||
// recorded fine shouldn't fail because this did.
|
||||
try {
|
||||
await pb
|
||||
.collection(CUSTOMERS_COLLECTION)
|
||||
.update(visit.customerId, { lastvisiteddate: visitedAt });
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Loading…
Reference in a new issue