From 49a9900d15cfdb05f592aae312dc1560d539bcbc Mon Sep 17 00:00:00 2001 From: default Date: Thu, 4 Jun 2026 15:24:34 +0000 Subject: [PATCH 01/25] feat: add locations table + Locations tab in Stops & Routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- src/actions/locations.ts | 294 +++++++++++ src/app/admin/stops/page.tsx | 119 +++-- src/components/admin/AddLocationModal.tsx | 288 +++++++++++ src/components/admin/EditLocationModal.tsx | 298 +++++++++++ src/components/admin/LocationsTab.tsx | 387 ++++++++++++++ src/components/admin/StopsHeaderActions.tsx | 6 +- supabase/migrations/203_locations_table.sql | 470 ++++++++++++++++++ .../204_locations_public_read_policy.sql | 19 + .../migrations/205_admin_list_locations.sql | 57 +++ 9 files changed, 1907 insertions(+), 31 deletions(-) create mode 100644 src/actions/locations.ts create mode 100644 src/components/admin/AddLocationModal.tsx create mode 100644 src/components/admin/EditLocationModal.tsx create mode 100644 src/components/admin/LocationsTab.tsx create mode 100644 supabase/migrations/203_locations_table.sql create mode 100644 supabase/migrations/204_locations_public_read_policy.sql create mode 100644 supabase/migrations/205_admin_list_locations.sql diff --git a/src/actions/locations.ts b/src/actions/locations.ts new file mode 100644 index 0000000..ec3dc92 --- /dev/null +++ b/src/actions/locations.ts @@ -0,0 +1,294 @@ +"use server"; + +import { revalidateTag } from "next/cache"; +import { getAdminUser } from "@/lib/admin-permissions"; +import { svcHeaders } from "@/lib/svc-headers"; + +export type LocationInput = { + name: string; + address?: string | null; + city?: string | null; + state?: string | null; + zip?: string | null; + phone?: string | null; + contact_name?: string | null; + contact_email?: string | null; + notes?: string | null; + active?: boolean; +}; + +export type Location = { + id: string; + brand_id: string; + name: string; + address: string | null; + city: string | null; + state: string | null; + zip: string | null; + phone: string | null; + contact_name: string | null; + contact_email: string | null; + notes: string | null; + active: boolean; + deleted_at: string | null; + created_at: string; + updated_at: string; + slug: string | null; +}; + +export type LocationWithCount = Location & { stop_count: number }; + +// ── Create (single) ────────────────────────────────────────────────────────── +export async function createLocation( + brandId: string, + input: LocationInput +): Promise<{ success: true; id: string; slug: string } | { success: false; error: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if (!adminUser.can_manage_stops) { + return { success: false, error: "Not authorized to manage locations" }; + } + const effectiveBrandId = brandId || adminUser.brand_id; + if (!effectiveBrandId) return { success: false, error: "No brand selected" }; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const res = await fetch( + `${supabaseUrl}/rest/v1/rpc/admin_create_location`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ + p_brand_id: effectiveBrandId, + p_name: input.name, + p_address: input.address ?? null, + p_city: input.city ?? null, + p_state: input.state ?? null, + p_zip: input.zip ?? null, + p_phone: input.phone ?? null, + p_contact_name: input.contact_name ?? null, + p_contact_email: input.contact_email ?? null, + p_notes: input.notes ?? null, + p_active: input.active ?? true, + }), + } + ); + + if (!res.ok) { + const err = await res.json().catch(() => ({ message: "Request failed" })); + return { success: false, error: (err as { message?: string }).message ?? "Insert failed" }; + } + + const data = await res.json(); + revalidateTag("locations", "default"); + revalidateTag(`brand:${effectiveBrandId}:locations`, "default"); + return { success: true, id: data.id, slug: data.slug }; +} + +// ── Create (batch) ─────────────────────────────────────────────────────────── +export async function createLocationsBatch( + brandId: string, + locations: LocationInput[] +): Promise<{ success: boolean; created: number; error?: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, created: 0, error: "Not authenticated" }; + if (!adminUser.can_manage_stops) { + return { success: false, created: 0, error: "Not authorized" }; + } + const effectiveBrandId = brandId || adminUser.brand_id; + if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" }; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const res = await fetch( + `${supabaseUrl}/rest/v1/rpc/admin_create_locations_batch`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_brand_id: effectiveBrandId, p_locations: locations }), + } + ); + + if (!res.ok) { + const err = await res.json().catch(() => ({ message: "Request failed" })); + return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" }; + } + + const inserted = await res.json(); + revalidateTag("locations", "default"); + revalidateTag(`brand:${effectiveBrandId}:locations`, "default"); + return { + success: true, + created: Array.isArray(inserted) ? inserted.length : locations.length, + }; +} + +// ── Update (partial) ───────────────────────────────────────────────────────── +export async function updateLocation( + locationId: string, + brandId: string, + updates: Partial +): Promise<{ success: boolean; error?: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" }; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const res = await fetch( + `${supabaseUrl}/rest/v1/rpc/admin_update_location`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId, p_updates: updates }), + } + ); + + if (!res.ok) { + const err = await res.json().catch(() => ({ message: "Request failed" })); + return { success: false, error: (err as { message?: string }).message ?? "Update failed" }; + } + + const data = await res.json(); + if (!data.success) return { success: false, error: data.error ?? "Update failed" }; + + revalidateTag("locations", "default"); + revalidateTag(`brand:${brandId}:locations`, "default"); + return { success: true }; +} + +// ── Delete (soft) ──────────────────────────────────────────────────────────── +export async function deleteLocation( + locationId: string, + brandId: string +): Promise<{ success: boolean; error?: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" }; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const res = await fetch( + `${supabaseUrl}/rest/v1/rpc/admin_delete_location`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId }), + } + ); + + if (!res.ok) { + const err = await res.json().catch(() => ({ message: "Request failed" })); + return { success: false, error: (err as { message?: string }).message ?? "Delete failed" }; + } + + const data = await res.json(); + if (!data.success) return { success: false, error: data.error ?? "Delete failed" }; + + revalidateTag("locations", "default"); + revalidateTag(`brand:${brandId}:locations`, "default"); + return { success: true }; +} + +// ── Read (admin, by brand_id) ──────────────────────────────────────────────── +export async function adminListLocations( + brandId: string +): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return []; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + // Application-layer brand scoping: platform_admin (brand_id: null) gets all + // brands; everyone else gets only their own brand. + const effectiveBrandId = brandId || adminUser.brand_id; + + const res = await fetch( + `${supabaseUrl}/rest/v1/rpc/admin_list_locations`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_brand_id: effectiveBrandId }), + next: { revalidate: 60, tags: ["locations", `brand:${effectiveBrandId ?? "all"}:locations`] }, + } + ); + + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data) ? (data as LocationWithCount[]) : []; +} + +// ── Read (public, by brand slug) ───────────────────────────────────────────── +export type PublicLocation = Pick< + Location, + "id" | "name" | "address" | "city" | "state" | "zip" | "phone" | "slug" +> & { stop_count: number }; + +export async function getPublicLocationsForBrand( + brandSlug: string +): Promise { + if (!brandSlug) return []; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const res = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_locations_for_brand`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_brand_slug: brandSlug }), + next: { revalidate: 300, tags: ["locations", `brand:${brandSlug}:locations`] }, + } + ); + + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data) ? (data as PublicLocation[]) : []; +} + +// ── Attach a stop to a location ────────────────────────────────────────────── +export async function attachStopToLocation( + stopId: string, + locationId: string, + brandId: string +): Promise<{ success: boolean; error?: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" }; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const res = await fetch( + `${supabaseUrl}/rest/v1/rpc/admin_attach_location_to_stop`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ + p_stop_id: stopId, + p_location_id: locationId, + p_brand_id: brandId, + }), + } + ); + + if (!res.ok) { + const err = await res.json().catch(() => ({ message: "Request failed" })); + return { success: false, error: (err as { message?: string }).message ?? "Attach failed" }; + } + + const data = await res.json(); + if (!data.success) return { success: false, error: data.error ?? "Attach failed" }; + + revalidateTag("stops", "default"); + revalidateTag("locations", "default"); + revalidateTag(`brand:${brandId}:stops`, "default"); + revalidateTag(`brand:${brandId}:locations`, "default"); + return { success: true }; +} diff --git a/src/app/admin/stops/page.tsx b/src/app/admin/stops/page.tsx index 396142a..6b547cd 100644 --- a/src/app/admin/stops/page.tsx +++ b/src/app/admin/stops/page.tsx @@ -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 = () => ( @@ -13,45 +16,52 @@ const StopIcon = () => ( ); -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 ; + 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 (
@@ -65,7 +75,7 @@ export default async function AdminStopsPage() { Error loading stops
-              {error.message}
+              {stopsError.message}
             
@@ -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 (
@@ -83,17 +100,59 @@ export default async function AdminStopsPage() { ]} icon={} title="Stops & Routes" - subtitle={adminUser?.brand_id ? "Managing stops for your brand." : "Manage routes, pickup locations, dates, and cutoff times."} - actions={} + subtitle={subtitle} + actions={} />
+ {/* Tabs */} +
+
+
+ {TABS.map((t) => { + const active = t.value === tab; + return ( + + {t.label} + {active && ( + + )} + + ); + })} +
+
+
+ {/* Content */}
-
- +
+
+ {tab === "stops" ? ( + + ) : ( + + )} +
); -} \ No newline at end of file +} diff --git a/src/components/admin/AddLocationModal.tsx b/src/components/admin/AddLocationModal.tsx new file mode 100644 index 0000000..49cb6c6 --- /dev/null +++ b/src/components/admin/AddLocationModal.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { createLocation } from "@/actions/locations"; +import GlassModal from "@/components/admin/GlassModal"; + +type Props = { + isOpen: boolean; + onClose: () => void; + brandId: string; + onSuccess?: (locationId: string) => void; +}; + +export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }: Props) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [name, setName] = useState(""); + const [address, setAddress] = useState(""); + const [city, setCity] = useState(""); + const [stateVal, setStateVal] = useState(""); + const [zip, setZip] = useState(""); + const [phone, setPhone] = useState(""); + const [contactName, setContactName] = useState(""); + const [contactEmail, setContactEmail] = useState(""); + const [notes, setNotes] = useState(""); + + const reset = useCallback(() => { + setName(""); + setAddress(""); + setCity(""); + setStateVal(""); + setZip(""); + setPhone(""); + setContactName(""); + setContactEmail(""); + setNotes(""); + setError(null); + }, []); + + const handleClose = useCallback(() => { + if (loading) return; + reset(); + onClose(); + }, [loading, reset, onClose]); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + if (!name.trim()) { + setError("Venue name is required."); + return; + } + setLoading(true); + try { + const result = await createLocation(brandId, { + name: name.trim(), + address: address.trim() || null, + city: city.trim() || null, + state: stateVal.trim().toUpperCase() || null, + zip: zip.trim() || null, + phone: phone.trim() || null, + contact_name: contactName.trim() || null, + contact_email: contactEmail.trim() || null, + notes: notes.trim() || null, + active: true, + }); + if (result.success) { + onSuccess?.(result.id); + reset(); + onClose(); + } else { + setError(result.error ?? "Failed to create location"); + } + } catch { + setError("Network error. Please try again."); + } finally { + setLoading(false); + } + }, + [brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, reset, onClose] + ); + + const inputStyle = { + background: "rgba(0, 0, 0, 0.02)", + border: "1px solid rgba(0, 0, 0, 0.06)", + outline: "none", + }; + + const handleFocus = (e: React.FocusEvent) => { + e.target.style.background = "rgba(16, 185, 129, 0.04)"; + e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)"; + e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)"; + }; + const handleBlur = (e: React.FocusEvent) => { + e.target.style.background = "rgba(0, 0, 0, 0.02)"; + e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)"; + e.target.style.boxShadow = "none"; + }; + + if (!isOpen) return null; + + return ( + +
+ {error && ( +
+ {error} +
+ )} + +
+ + setName(e.target.value)} + placeholder="Tractor Supply" + className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" + style={inputStyle} + onFocus={handleFocus} + onBlur={handleBlur} + required + /> +
+ +
+ + setAddress(e.target.value)} + placeholder="13778 E I-25 Frontage Rd" + className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" + style={inputStyle} + onFocus={handleFocus} + onBlur={handleBlur} + /> +
+ +
+
+ + setCity(e.target.value)} + placeholder="Wellington" + className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" + style={inputStyle} + onFocus={handleFocus} + onBlur={handleBlur} + /> +
+
+ + setStateVal(e.target.value.toUpperCase())} + placeholder="CO" + maxLength={2} + className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" + style={inputStyle} + onFocus={handleFocus} + onBlur={handleBlur} + /> +
+
+ + setZip(e.target.value)} + placeholder="80549" + className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" + style={inputStyle} + onFocus={handleFocus} + onBlur={handleBlur} + /> +
+
+ +
+
+ + setPhone(e.target.value)} + placeholder="(970) 555-1234" + className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" + style={inputStyle} + onFocus={handleFocus} + onBlur={handleBlur} + /> +
+
+ + setContactName(e.target.value)} + placeholder="Store manager" + className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" + style={inputStyle} + onFocus={handleFocus} + onBlur={handleBlur} + /> +
+
+ +
+ + setContactEmail(e.target.value)} + placeholder="manager@example.com" + className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" + style={inputStyle} + onFocus={handleFocus} + onBlur={handleBlur} + /> +
+ +
+ +