From 38c65af8e347a33b4351ae45a90e252ee2136b5c Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 20 Sep 2026 20:52:26 +0200 Subject: [PATCH] feat(rep-checkin): unified picker for pinned and unpinned customers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rep check-in screen now treats pinned and unpinned customers through a single interface instead of two stacked sections. The bottom-sheet picker opens from a new 'Add a customer' button below the nearby list, shows every customer with unpinned rows first, and stamps a 'no location yet — saving will pin this spot' badge on each so the rep sees what ticking will do. The success toast now reports the pin count when any selected customer was unpinned (e.g. '5 visits logged · saved this spot for 2 new customers'), giving the rep visible confirmation that coordinates were written. Pure front-end change on top of the existing pinCustomer path — no server or schema work. New helpers: - picker-sort: sortCustomersForPicker + filterPickerCustomers - visit-toast: formatVisitToast (plurals, pin suffix, offline hint) 19 new tests; rep + visits scope stays at 66/66 green. --- src/app/(rep)/rep/check-in-view.tsx | 217 ++++++++++++++++++++++++-- src/app/(rep)/rep/picker-sort.test.ts | 142 +++++++++++++++++ src/app/(rep)/rep/picker-sort.ts | 68 ++++++++ src/app/(rep)/rep/visit-toast.test.ts | 47 ++++++ src/app/(rep)/rep/visit-toast.ts | 42 +++++ 5 files changed, 507 insertions(+), 9 deletions(-) create mode 100644 src/app/(rep)/rep/picker-sort.test.ts create mode 100644 src/app/(rep)/rep/picker-sort.ts create mode 100644 src/app/(rep)/rep/visit-toast.test.ts create mode 100644 src/app/(rep)/rep/visit-toast.ts diff --git a/src/app/(rep)/rep/check-in-view.tsx b/src/app/(rep)/rep/check-in-view.tsx index aabcf6b..fa90063 100644 --- a/src/app/(rep)/rep/check-in-view.tsx +++ b/src/app/(rep)/rep/check-in-view.tsx @@ -1,11 +1,27 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { MapPin, Search, Check, WifiOff, RefreshCw, Loader2, Navigation } from "lucide-react"; +import { + MapPin, + Search, + Check, + WifiOff, + RefreshCw, + Loader2, + Navigation, + UserPlus, +} 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 { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; import { enqueue, flushOutbox, @@ -14,6 +30,12 @@ import { writeCachedSnapshot, } from "./outbox"; import { formatSnapshotAge, isSnapshotStale } from "./snapshot-freshness"; +import { + sortCustomersForPicker, + filterPickerCustomers, + type PickerCustomer, +} from "./picker-sort"; +import { formatVisitToast } from "./visit-toast"; /** Radii the "widen search" button steps through, in km. */ const RADIUS_STEPS = [DEFAULT_RADIUS_KM, 5, 15]; @@ -35,6 +57,8 @@ export function CheckInView() { const [selected, setSelected] = useState>(new Set()); const [pinning, setPinning] = useState>(new Set()); const [search, setSearch] = useState(""); + const [pickerOpen, setPickerOpen] = useState(false); + const [pickerSearch, setPickerSearch] = useState(""); const [notes, setNotes] = useState(""); const [submitting, setSubmitting] = useState(false); const [pending, setPending] = useState(0); @@ -233,6 +257,32 @@ export function CheckInView() { .slice(0, 20); }, [search, customers, nearby]); + /* ------------------------------------------------------------- picker */ + + // Sorted once (unpinned first — that's the primary picker use-case, + // pinning a customer who isn't on the nearby list), then filtered by + // the sheet's own search term. Both steps live in pure helpers so the + // rule is testable without the sheet/React on top. + const pickerRows = useMemo( + () => sortCustomersForPicker(customers), + [customers] + ); + + const pickerResults = useMemo( + () => filterPickerCustomers(pickerRows, pickerSearch), + [pickerRows, pickerSearch] + ); + + const openPicker = useCallback(() => { + setPickerSearch(""); + setPickerOpen(true); + }, []); + + const closePicker = useCallback(() => { + setPickerOpen(false); + setPickerSearch(""); + }, []); + const toggle = useCallback( (id: string, hasCoordinates: boolean) => { setSelected((prev) => { @@ -275,6 +325,7 @@ export function CheckInView() { }); const count = selected.size; + const pinnedCount = pinning.size; setSelected(new Set()); setPinning(new Set()); setNotes(""); @@ -285,13 +336,24 @@ export function CheckInView() { 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`); - } + toast.success( + formatVisitToast({ + count, + pinned: pinnedCount, + delivered: result.sent > 0, + }) + ); } catch { - toast.success(`${count} visit${count === 1 ? "" : "s"} saved offline`); + // Network or unexpected failure — entry is still queued and will + // be retried by the next flush; surface the offline variant so + // the rep knows coordinates will land when signal comes back. + toast.success( + formatVisitToast({ + count, + pinned: pinnedCount, + delivered: false, + }) + ); } finally { void entry; setSubmitting(false); @@ -402,8 +464,9 @@ export function CheckInView() { {nearby.length === 0 ? (

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

) : (
    @@ -438,6 +501,16 @@ export function CheckInView() { )} + {position && ( + + )} +