From f7a02fe1277ab6be20aa4fa911d44b524ce93615 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 17 Jun 2026 14:59:50 -0600 Subject: [PATCH] feat(admin): add v2 stops list (time-grouped cards with status pill) --- src/app/admin/v2/stops/page.tsx | 320 ++++++++++++++++++ .../admin/stops/StopsDatePicker.tsx | 71 ++++ 2 files changed, 391 insertions(+) create mode 100644 src/app/admin/v2/stops/page.tsx create mode 100644 src/components/admin/stops/StopsDatePicker.tsx diff --git a/src/app/admin/v2/stops/page.tsx b/src/app/admin/v2/stops/page.tsx new file mode 100644 index 0000000..8608711 --- /dev/null +++ b/src/app/admin/v2/stops/page.tsx @@ -0,0 +1,320 @@ +import { getAdminUser } from "@/lib/admin-permissions"; +import { getActiveBrandId } from "@/lib/brand-scope"; +import { pool } from "@/lib/db"; +import PageHeader from "@/components/admin/PageHeader"; +import { CardList, CardListItem } from "@/components/admin/CardList"; +import StatusPill, { type StatusKind } from "@/components/admin/StatusPill"; +import EmptyState from "@/components/admin/EmptyState"; +import PullToRefresh from "@/components/admin/PullToRefresh"; +import StopsDatePicker from "@/components/admin/stops/StopsDatePicker"; + +export const dynamic = "force-dynamic"; + +/** + * Row shape returned from the stops query. The legacy `stops` table uses + * separate `date` (DATE) and `time` (TEXT) columns, not a combined + * `scheduled_at` ISO string — the plan's example was speculative about + * the schema, so we mirror the v1 stops page shape and adapt. + */ +interface DbStopRow { + id: string; + city: string | null; + state: string | null; + date: string; + time: string | null; + location: string; + status: string; + brand_id: string; + address: string | null; + zip: string | null; + cutoff_date: string | null; + customer_count: number; +} + +/** + * Map the legacy `stops.status` ('active' | 'paused' | 'closed') onto the + * StatusPill's `StatusKind` vocabulary. Mirrors the v1 page's + * `s.status === "active"` check but extends it to cover the full enum. + * + * - 'active' → 'scheduled' (default — accepting orders) + * - 'paused' → 'in-transit' (temporarily closed, like a truck en route) + * - 'closed' → 'completed' (final state) + */ +function stopStatusKind(s: string): StatusKind { + if (s === "paused") return "in-transit"; + if (s === "closed") return "completed"; + return "scheduled"; +} + +/** + * Parse the hour (0-23) from a free-form time string. The `stops.time` + * column is TEXT and accepts both 24h ("14:30") and 12h ("9:00 AM", + * "9 AM", "2:30pm") formats. Returns null for unparseable input so + * those stops fall into the "Anytime" bucket. + */ +function parseHour(time: string | null): number | null { + if (!time) return null; + const t = time.trim(); + // 12h format with optional minutes: "9:00 AM" / "9 AM" / "9:00am" / "2:30 PM" + const m12 = t.match(/^(\d{1,2})(?::\d{2})?\s*(am|pm)$/i); + if (m12) { + let h = parseInt(m12[1], 10); + const isPm = m12[2].toLowerCase() === "pm"; + if (h < 1 || h > 12) return null; + if (h === 12) h = isPm ? 12 : 0; + else if (isPm) h += 12; + return h; + } + // 24h format: "14:30" or "14" + const m24 = t.match(/^(\d{1,2})(?::\d{2})?$/); + if (m24) { + const h = parseInt(m24[1], 10); + return h >= 0 && h < 24 ? h : null; + } + return null; +} + +type TimeBucket = "Morning" | "Afternoon" | "Evening" | "Anytime"; + +function bucketFor(time: string | null): TimeBucket { + const h = parseHour(time); + if (h === null) return "Anytime"; + if (h < 12) return "Morning"; + if (h < 17) return "Afternoon"; + return "Evening"; +} + +const BUCKET_ORDER: TimeBucket[] = ["Morning", "Afternoon", "Evening", "Anytime"]; + +/** Build a Google Maps directions URL for a stop's address. */ +function mapsUrlFor(stop: DbStopRow): string | null { + const parts = [stop.address, stop.city, stop.state, stop.zip].filter(Boolean); + if (parts.length === 0) return null; + return `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent( + parts.join(", "), + )}`; +} + +function formatDateHeading(d: Date): string { + return d.toLocaleDateString("en-US", { + weekday: "long", + month: "short", + day: "numeric", + }); +} + +function formatTimeLabel(time: string | null): string { + if (!time) return "—"; + const t = time.trim(); + // If it's already a "9:00 AM" or "9:00AM" style, return as-is + if (/[ap]m$/i.test(t)) return t.toUpperCase(); + // 24h "14:30" → "2:30 PM" + const m = t.match(/^(\d{1,2}):(\d{2})$/); + if (m) { + let h = parseInt(m[1], 10); + const min = m[2]; + const ampm = h >= 12 ? "PM" : "AM"; + if (h === 0) h = 12; + else if (h > 12) h -= 12; + return `${h}:${min} ${ampm}`; + } + return t; +} + +export default async function StopsV2Page({ + searchParams, +}: { + searchParams: Promise<{ date?: string }>; +}) { + const adminUser = await getAdminUser(); + if (!adminUser) return null; + + const activeBrandId = await getActiveBrandId(adminUser); + const params = await searchParams; + + // Resolve the target date. `searchParams.date` is a YYYY-MM-DD string. + // Fall back to "today" in the user's local timezone (not UTC, so the + // displayed heading matches what they expect). + let dateObj: Date; + if (params.date) { + const parsed = new Date(params.date); + if (!isNaN(parsed.getTime())) { + dateObj = parsed; + } else { + const now = new Date(); + dateObj = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + } + } else { + const now = new Date(); + dateObj = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + } + const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, "0")}-${String(dateObj.getDate()).padStart(2, "0")}`; + + // Build the brand filter. Mirrors the v1 stops page's brand-scoping + // pattern: explicit active brand > platform_admin "all brands" > + // multi-brand membership list. + let stops: DbStopRow[] = []; + let error: string | null = null; + try { + const selectFields = ` + s.id, s.city, s.state, s.date::text AS date, s."time", s.location, + s.status, s.brand_id, s.address, s.zip, s.cutoff_date, + (SELECT COUNT(*)::int FROM orders o WHERE o.stop_id = s.id) AS customer_count + `; + const orderBy = `s."time" ASC NULLS LAST, s.location ASC`; + const baseWhere = `s.date = $1`; + + if (activeBrandId) { + const result = await pool.query( + `SELECT ${selectFields} + FROM stops s + WHERE ${baseWhere} AND s.brand_id = $2 + ORDER BY ${orderBy} + LIMIT 200`, + [dateStr, activeBrandId], + ); + stops = result.rows; + } else if (adminUser.role === "platform_admin") { + const result = await pool.query( + `SELECT ${selectFields} + FROM stops s + WHERE ${baseWhere} + ORDER BY ${orderBy} + LIMIT 200`, + [dateStr], + ); + stops = result.rows; + } else { + const brandIds = adminUser.brand_ids ?? []; + if (brandIds.length === 0) { + // No accessible brands → empty list, no need to query. + stops = []; + } else { + const result = await pool.query( + `SELECT ${selectFields} + FROM stops s + WHERE ${baseWhere} AND s.brand_id = ANY($2::uuid[]) + ORDER BY ${orderBy} + LIMIT 200`, + [dateStr, brandIds], + ); + stops = result.rows; + } + } + } catch (e) { + error = e instanceof Error ? e.message : String(e); + console.error("[admin/v2/stops] query error:", error); + } + + const headerDate = dateObj; + + if (error) { + return ( +
+ } /> +
+
+
+ ); + } + + if (stops.length === 0) { + return ( +
+ } + /> + + +
+ ); + } + + // Group by time-of-day bucket. Use Map to preserve insertion order. + const grouped: Record = { + Morning: [], + Afternoon: [], + Evening: [], + Anytime: [], + }; + for (const s of stops) { + grouped[bucketFor(s.time)].push(s); + } + + return ( +
+ } + /> + + {BUCKET_ORDER.filter((b) => grouped[b].length > 0).map((bucket) => ( +
+

+ {bucket.toUpperCase()} +

+ + {grouped[bucket].map((stop) => { + const href = mapsUrlFor(stop); + return ( + +
+
+ {formatTimeLabel(stop.time)} +
+
+
+ {stop.address ?? + stop.location ?? + ([stop.city, stop.state].filter(Boolean).join(", ") || + "Stop")} +
+
+ {stop.customer_count} customer + {stop.customer_count === 1 ? "" : "s"} + {stop.cutoff_date != null + ? ` · Cutoff ${stop.cutoff_date}` + : ""} +
+
+ +
+
+ ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/admin/stops/StopsDatePicker.tsx b/src/components/admin/stops/StopsDatePicker.tsx new file mode 100644 index 0000000..c9bd21d --- /dev/null +++ b/src/components/admin/stops/StopsDatePicker.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +interface StopsDatePickerProps { + value: Date; +} + +/** + * Format a Date as the `YYYY-MM-DD` value used by ``. + * + * Uses local-time components (not `toISOString()`) so the picker doesn't + * snap to the day before/after for users in a non-UTC timezone — a date + * picker where selecting "today" in the user's browser unexpectedly + * changes the URL by a day is a common footgun. + */ +function toInputValue(d: Date): string { + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; +} + +/** + * StopsDatePicker — native `` for the v2 stops list. + * + * Pushing a new URL on change triggers a server re-render of the page + * with the new date. The page's `searchParams.date` is the source of + * truth; this component is purely an input control. + * + * The pill is sized at 56px to match the rest of the v2 action bar + * (matching OrdersFilterButton). + */ +export function StopsDatePicker({ value }: StopsDatePickerProps) { + const router = useRouter(); + return ( + + ); +} + +export default StopsDatePicker;