feat(admin): add v2 stops list (time-grouped cards with status pill)
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user