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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user