feat: add locations table + Locations tab in Stops & Routes

* New 'locations' table for reusable venues (Tractor Supply, Boot Barn,
  etc.) — each stop now links via stops.location_id while keeping the
  denormalized location text for backwards compat.
* 6 SECURITY DEFINER RPCs: admin_create_location, admin_create_locations_batch,
  admin_update_location, admin_delete_location, admin_attach_location_to_stop,
  get_locations_for_brand. Plus admin_list_locations for the admin tab.
* Backfill: 26 unique venues extracted from the 269 Tuxedo stops, all linked.
* Stops & Routes page now has tabs (Stops | Locations) via ?tab= query param.
  Locations tab has full CRUD UI: search, status filter, pagination, edit,
  delete, add-venue modal.
* StopsHeaderActions now tab-aware — Upload Schedule / Add Stop only show on
  Stops tab; Locations tab has its own Add Venue button inline.
This commit is contained in:
2026-06-04 15:24:34 +00:00
parent 9374e63ae6
commit 49a9900d15
9 changed files with 1907 additions and 31 deletions
+89 -30
View File
@@ -1,10 +1,13 @@
import StopTableClient from "@/components/admin/StopTableClient";
import LocationsTab from "@/components/admin/LocationsTab";
import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
import { adminListLocations } from "@/actions/locations";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation";
import Link from "next/link";
const StopIcon = () => (
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-current" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -13,45 +16,52 @@ const StopIcon = () => (
</svg>
);
export default async function AdminStopsPage() {
const TABS = [
{ value: "stops", label: "Stops" },
{ value: "locations", label: "Locations" },
] as const;
type TabValue = (typeof TABS)[number]["value"];
function isTab(v: string | undefined): v is TabValue {
return v === "stops" || v === "locations";
}
type PageProps = {
searchParams: Promise<{ tab?: string }>;
};
export default async function AdminStopsPage({ searchParams }: PageProps) {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
if (!adminUser.can_manage_stops) redirect("/admin/pickup");
if (!adminUser.can_manage_stops) {
redirect("/admin/pickup");
}
const params = await searchParams;
const tab: TabValue = isTab(params.tab) ? params.tab : "stops";
let query = supabase
// Always fetch stops + locations; the page is fast and a server component can
// hand both to the client. The Locations tab only needs the array — it does
// its own filtering in JS. Stops tab uses the existing client table.
const stopsQuery = supabase
.from("stops")
.select(`
id,
city,
state,
date,
time,
location,
active,
deleted_at,
brand_id,
address,
zip,
cutoff_time,
status,
brands (
name
)
id, city, state, date, time, location, active, deleted_at, brand_id,
address, zip, cutoff_time, status,
brands ( name )
`)
.is("deleted_at", null)
.order("date", { ascending: true });
if (adminUser?.brand_id) {
query = query.eq("brand_id", adminUser.brand_id);
stopsQuery.eq("brand_id", adminUser.brand_id);
}
const { data: stops, error } = await query;
const [{ data: stops, error: stopsError }, locations] = await Promise.all([
stopsQuery,
adminListLocations(adminUser.brand_id ?? ""),
]);
if (error) {
if (stopsError) {
return (
<main className="min-h-screen bg-[var(--admin-bg)]">
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
@@ -65,7 +75,7 @@ export default async function AdminStopsPage() {
Error loading stops
</h1>
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-secondary)]">
{error.message}
{stopsError.message}
</pre>
</div>
</div>
@@ -73,6 +83,13 @@ export default async function AdminStopsPage() {
);
}
const subtitle =
tab === "locations"
? "Reusable venues. Each stop links to one venue, so editing here updates every stop using it."
: adminUser?.brand_id
? "Managing stops for your brand."
: "Manage routes, pickup locations, dates, and cutoff times.";
return (
<main className="min-h-screen bg-[var(--admin-bg)]">
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
@@ -83,17 +100,59 @@ export default async function AdminStopsPage() {
]}
icon={<StopIcon />}
title="Stops & Routes"
subtitle={adminUser?.brand_id ? "Managing stops for your brand." : "Manage routes, pickup locations, dates, and cutoff times."}
actions={<StopsHeaderActions brandId={adminUser?.brand_id ?? ""} />}
subtitle={subtitle}
actions={<StopsHeaderActions brandId={adminUser?.brand_id ?? ""} tab={tab} />}
/>
</div>
{/* Tabs */}
<div className="px-4 sm:px-6 md:px-8">
<div className="max-w-6xl mx-auto">
<div
className="flex gap-1 border-b border-[var(--admin-border)]"
role="tablist"
aria-label="Stops and Locations tabs"
>
{TABS.map((t) => {
const active = t.value === tab;
return (
<Link
key={t.value}
href={t.value === "stops" ? "/admin/stops" : "/admin/stops?tab=locations"}
role="tab"
aria-selected={active}
className="relative px-4 py-2.5 text-sm font-semibold transition-colors"
style={{
color: active ? "var(--admin-text-primary)" : "var(--admin-text-muted)",
}}
>
{t.label}
{active && (
<span
className="absolute left-0 right-0 -bottom-px h-0.5"
style={{ background: "var(--admin-accent)" }}
aria-hidden
/>
)}
</Link>
);
})}
</div>
</div>
</div>
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
<div className="overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
<StopTableClient stops={stops ?? []} />
<div className="max-w-6xl mx-auto">
<div className="overflow-hidden rounded-b-2xl rounded-t-none border border-t-0 border-[var(--admin-border)] bg-white shadow-sm">
{tab === "stops" ? (
<StopTableClient stops={stops ?? []} />
) : (
<LocationsTab locations={locations} brandId={adminUser?.brand_id ?? ""} />
)}
</div>
</div>
</div>
</main>
);
}
}