feat(admin): add v2 stops list (time-grouped cards with status pill)

This commit is contained in:
Tyler
2026-06-17 14:59:50 -06:00
parent 4c428fd9da
commit f7a02fe127
2 changed files with 391 additions and 0 deletions
+320
View File
@@ -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<DbStopRow>(
`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<DbStopRow>(
`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<DbStopRow>(
`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 (
<main>
<PageHeader title="Stops" actions={<StopsDatePicker value={headerDate} />} />
<div className="px-4 pb-24">
<EmptyState
title="Couldn't load stops"
description={error}
icon={<span aria-hidden="true"></span>}
/>
</div>
</main>
);
}
if (stops.length === 0) {
return (
<main>
<PageHeader
title="Stops"
subtitle={formatDateHeading(headerDate)}
actions={<StopsDatePicker value={headerDate} />}
/>
<PullToRefresh>
<EmptyState
title="No stops scheduled"
description="Add a stop to start routing."
icon={<span aria-hidden="true">🚚</span>}
/>
</PullToRefresh>
</main>
);
}
// Group by time-of-day bucket. Use Map to preserve insertion order.
const grouped: Record<TimeBucket, DbStopRow[]> = {
Morning: [],
Afternoon: [],
Evening: [],
Anytime: [],
};
for (const s of stops) {
grouped[bucketFor(s.time)].push(s);
}
return (
<main>
<PageHeader
title="Stops"
subtitle={formatDateHeading(headerDate)}
actions={<StopsDatePicker value={headerDate} />}
/>
<PullToRefresh>
{BUCKET_ORDER.filter((b) => grouped[b].length > 0).map((bucket) => (
<section key={bucket}>
<h2
className="sticky top-0 px-4 py-2 text-label font-semibold"
style={{
backgroundColor: "var(--color-surface)",
color: "var(--color-text-muted)",
letterSpacing: "0.02em",
}}
>
{bucket.toUpperCase()}
</h2>
<CardList ariaLabel={`${bucket} stops`}>
{grouped[bucket].map((stop) => {
const href = mapsUrlFor(stop);
return (
<CardListItem key={stop.id} href={href ?? "#"}>
<div className="flex items-center gap-3">
<div
className="text-h1 font-display shrink-0"
style={{ fontWeight: 600, minWidth: "72px" }}
>
{formatTimeLabel(stop.time)}
</div>
<div className="flex-1 min-w-0">
<div
className="text-h2"
style={{ fontWeight: 700, lineHeight: 1.3 }}
>
{stop.address ??
stop.location ??
([stop.city, stop.state].filter(Boolean).join(", ") ||
"Stop")}
</div>
<div
className="text-meta mt-1"
style={{ color: "var(--color-text-muted)" }}
>
{stop.customer_count} customer
{stop.customer_count === 1 ? "" : "s"}
{stop.cutoff_date != null
? ` · Cutoff ${stop.cutoff_date}`
: ""}
</div>
</div>
<StatusPill status={stopStatusKind(stop.status)} />
</div>
</CardListItem>
);
})}
</CardList>
</section>
))}
</PullToRefresh>
</main>
);
}
@@ -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 `<input type="date">`.
*
* 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 `<input type="date">` 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 (
<label
className="rounded-xl flex items-center gap-2"
style={{
minHeight: "56px",
padding: "0 16px",
backgroundColor: "var(--color-surface-2)",
fontWeight: 600,
letterSpacing: "0.02em",
}}
>
<span className="text-label" style={{ color: "var(--color-text-muted)" }}>
Date
</span>
<input
type="date"
value={toInputValue(value)}
onChange={(e) => {
const newDate = e.target.value;
if (!newDate) return;
router.push(`/admin/v2/stops?date=${newDate}`);
}}
style={{
background: "transparent",
border: 0,
outline: "none",
fontSize: "15px",
fontWeight: 600,
color: "var(--color-text)",
}}
/>
</label>
);
}
export default StopsDatePicker;