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
+294
View File
@@ -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<LocationInput>
): 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<LocationWithCount[]> {
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<PublicLocation[]> {
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 };
}
+88 -29
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,15 +100,57 @@ 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>
+288
View File
@@ -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<string | null>(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<HTMLInputElement | HTMLTextAreaElement>) => {
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<HTMLInputElement | HTMLTextAreaElement>) => {
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 (
<GlassModal
title="Add Venue"
subtitle="Reusable pick-up location. Once saved, you can attach stops to it from the Stops tab."
onClose={handleClose}
>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div
className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
style={{ background: "rgba(239, 68, 68, 0.1)", border: "1px solid rgba(239, 68, 68, 0.2)" }}
>
{error}
</div>
)}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Venue name *</label>
<input
type="text"
value={name}
onChange={(e) => 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
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Street address</label>
<input
type="text"
value={address}
onChange={(e) => 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}
/>
</div>
<div className="grid grid-cols-6 gap-4">
<div className="col-span-3 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
<input
type="text"
value={city}
onChange={(e) => 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}
/>
</div>
<div className="col-span-1 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
<input
type="text"
value={stateVal}
onChange={(e) => 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}
/>
</div>
<div className="col-span-2 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
<input
type="text"
value={zip}
onChange={(e) => 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}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Phone</label>
<input
type="tel"
value={phone}
onChange={(e) => 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}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact name</label>
<input
type="text"
value={contactName}
onChange={(e) => 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}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact email</label>
<input
type="email"
value={contactEmail}
onChange={(e) => 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}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Park on west side. Use side entrance after 9am."
rows={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all resize-none"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: "1px solid rgba(0, 0, 0, 0.04)" }}>
<button
type="button"
onClick={handleClose}
disabled={loading}
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
style={{
background: loading
? "rgba(16, 185, 129, 0.4)"
: "linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)",
boxShadow: loading
? "none"
: "0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)",
opacity: loading ? 0.7 : 1,
}}
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Creating
</span>
) : (
"Create Venue"
)}
</button>
</div>
</form>
</GlassModal>
);
}
+298
View File
@@ -0,0 +1,298 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { updateLocation } from "@/actions/locations";
import GlassModal from "@/components/admin/GlassModal";
export type LocationForEdit = {
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;
};
type Props = {
isOpen: boolean;
onClose: () => void;
location: LocationForEdit | null;
brandId: string;
onSuccess?: () => void;
};
export default function EditLocationModal({ isOpen, onClose, location, brandId, onSuccess }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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("");
useEffect(() => {
/* eslint-disable react-hooks/set-state-in-effect */
if (location && isOpen) {
setName(location.name ?? "");
setAddress(location.address ?? "");
setCity(location.city ?? "");
setStateVal(location.state ?? "");
setZip(location.zip ?? "");
setPhone(location.phone ?? "");
setContactName(location.contact_name ?? "");
setContactEmail(location.contact_email ?? "");
setNotes(location.notes ?? "");
setError(null);
}
/* eslint-enable react-hooks/set-state-in-effect */
}, [location, isOpen]);
const handleClose = useCallback(() => {
if (loading) return;
setError(null);
onClose();
}, [loading, onClose]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!location) return;
setError(null);
if (!name.trim()) {
setError("Venue name is required.");
return;
}
setLoading(true);
try {
const result = await updateLocation(location.id, 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: location.active,
});
if (result.success) {
onSuccess?.();
onClose();
} else {
setError(result.error ?? "Failed to update location");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
},
[location, brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, 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<HTMLInputElement | HTMLTextAreaElement>) => {
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<HTMLInputElement | HTMLTextAreaElement>) => {
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 || !location) return null;
return (
<GlassModal
title="Edit Venue"
subtitle="Changes apply to every stop currently linked to this venue."
onClose={handleClose}
>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div
className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
style={{ background: "rgba(239, 68, 68, 0.1)", border: "1px solid rgba(239, 68, 68, 0.2)" }}
>
{error}
</div>
)}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Venue name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
required
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Street address</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="grid grid-cols-6 gap-4">
<div className="col-span-3 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="col-span-1 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
<input
type="text"
value={stateVal}
onChange={(e) => setStateVal(e.target.value.toUpperCase())}
maxLength={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="col-span-2 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
<input
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Phone</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact name</label>
<input
type="text"
value={contactName}
onChange={(e) => setContactName(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact email</label>
<input
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all resize-none"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: "1px solid rgba(0, 0, 0, 0.04)" }}>
<button
type="button"
onClick={handleClose}
disabled={loading}
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
style={{
background: loading
? "rgba(16, 185, 129, 0.4)"
: "linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)",
boxShadow: loading
? "none"
: "0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)",
opacity: loading ? 0.7 : 1,
}}
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Saving
</span>
) : (
"Save Changes"
)}
</button>
</div>
</form>
</GlassModal>
);
}
+387
View File
@@ -0,0 +1,387 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useTransition, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { deleteLocation } from "@/actions/locations";
import AddLocationModal from "@/components/admin/AddLocationModal";
import EditLocationModal, { type LocationForEdit } from "@/components/admin/EditLocationModal";
import {
AdminSearchInput,
AdminFilterTabs,
AdminButton,
AdminIconButton,
useToast,
Skeleton,
} from "@/components/admin/design-system";
export type AdminLocation = {
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;
stop_count: number;
};
type Props = {
locations: AdminLocation[];
brandId: string;
};
type StatusFilter = "all" | "active" | "inactive";
export default function LocationsTab({ locations, brandId }: Props) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [editing, setEditing] = useState<LocationForEdit | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const PAGE_SIZE = 50;
useEffect(() => {
setIsLoading(true);
const t = setTimeout(() => setIsLoading(false), 250);
return () => clearTimeout(t);
}, [page, statusFilter, search]);
const counts = useMemo(
() => ({
all: locations.length,
active: locations.filter((l) => l.active).length,
inactive: locations.filter((l) => !l.active).length,
}),
[locations]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return locations.filter((l) => {
const matchesSearch =
!q ||
l.name.toLowerCase().includes(q) ||
(l.address ?? "").toLowerCase().includes(q) ||
(l.city ?? "").toLowerCase().includes(q) ||
(l.contact_name ?? "").toLowerCase().includes(q);
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && l.active) ||
(statusFilter === "inactive" && !l.active);
return matchesSearch && matchesStatus;
});
}, [locations, search, statusFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginated = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const tabs = [
{ value: "all", label: "All", count: counts.all },
{ value: "active", label: "Active", count: counts.active },
{ value: "inactive", label: "Inactive", count: counts.inactive },
];
function handleAdded() {
setShowAdd(false);
showSuccess("Venue created");
startTransition(() => router.refresh());
}
function handleEdited() {
setEditing(null);
showSuccess("Venue updated");
startTransition(() => router.refresh());
}
async function handleDelete(loc: AdminLocation) {
if (!confirm(`Delete venue "${loc.name}"? This cannot be undone.`)) return;
setPendingDeleteId(loc.id);
setDeleteError(null);
const res = await deleteLocation(loc.id, brandId);
setPendingDeleteId(null);
if (res.success) {
showSuccess("Venue deleted");
startTransition(() => router.refresh());
} else {
setDeleteError(res.error ?? "Delete failed");
showError("Delete failed", res.error ?? "Unknown error");
}
}
return (
<>
{/* Filter bar */}
<div className="border-b border-[var(--admin-border)] px-5 py-3 flex gap-4 flex-wrap items-center">
<AdminSearchInput
placeholder="Search venues by name, city, or contact…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
onClear={() => {
setSearch("");
setPage(0);
}}
showClear
className="flex-1 min-w-48 max-w-72"
/>
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(value) => {
setStatusFilter(value as StatusFilter);
setPage(0);
}}
tabs={tabs}
size="sm"
showCounts
/>
<span className="text-xs text-[var(--admin-text-muted)] ml-auto">
{filtered.length} venue{filtered.length !== 1 ? "s" : ""}
</span>
{totalPages > 1 && (
<div className="flex items-center gap-1">
<AdminIconButton
variant="secondary"
size="sm"
label="Previous page"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0 || isLoading}
className="!rounded-lg border border-[var(--admin-border)]"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</AdminIconButton>
<span className="text-xs text-[var(--admin-text-muted)] px-1">
{page + 1}/{totalPages}
</span>
<AdminIconButton
variant="secondary"
size="sm"
label="Next page"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1 || isLoading}
className="!rounded-lg border border-[var(--admin-border)]"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</AdminIconButton>
</div>
)}
<AdminButton
variant="primary"
size="sm"
onClick={() => setShowAdd(true)}
icon={
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
}
>
Add Venue
</AdminButton>
</div>
{/* Delete error */}
{deleteError && (
<div className="mx-5 my-3 rounded-lg border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 text-sm text-[var(--admin-danger)]">
{deleteError}{" "}
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
Dismiss
</button>
</div>
)}
{/* Table */}
<table className="w-full text-left text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-4 font-semibold">Venue</th>
<th className="px-5 py-4 font-semibold">Address</th>
<th className="px-5 py-4 font-semibold">Contact</th>
<th className="px-5 py-4 font-semibold text-center">Stops</th>
<th className="px-5 py-4 font-semibold">Status</th>
<th className="px-5 py-4 font-semibold" />
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
{isLoading ? (
Array.from({ length: 6 }).map((_, i) => (
<tr key={i}>
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-48 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
<td className="px-5 py-4 text-center"><Skeleton variant="rect" className="w-8 h-5 mx-auto rounded-full" /></td>
<td className="px-5 py-4"><Skeleton variant="rect" className="w-16 h-6 rounded-full" /></td>
<td className="px-5 py-4"><Skeleton variant="rect" className="w-12 h-6" /></td>
</tr>
))
) : filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-5 py-12 text-center">
<div className="flex flex-col items-center gap-2 text-[var(--admin-text-muted)]">
<svg className="h-10 w-10 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p className="text-sm">
{search || statusFilter !== "all"
? "No venues match your filters."
: "No venues yet. Add your first venue to get started."}
</p>
</div>
</td>
</tr>
) : (
paginated.map((loc) => (
<tr key={loc.id} className="hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-4">
<div className="font-semibold text-[var(--admin-text-primary)]">{loc.name}</div>
{(loc.city || loc.state) && (
<div className="text-xs text-[var(--admin-text-muted)]">
{[loc.city, loc.state].filter(Boolean).join(", ")}
</div>
)}
</td>
<td className="px-5 py-4">
{loc.address ? (
<div>
<div className="text-[var(--admin-text-secondary)]">{loc.address}</div>
{loc.zip && (
<div className="text-xs text-[var(--admin-text-muted)]">{loc.zip}</div>
)}
</div>
) : (
<span className="text-[var(--admin-text-muted)]"></span>
)}
</td>
<td className="px-5 py-4">
{loc.contact_name || loc.phone || loc.contact_email ? (
<div className="space-y-0.5">
{loc.contact_name && (
<div className="text-[var(--admin-text-secondary)]">{loc.contact_name}</div>
)}
{loc.phone && (
<div className="text-xs text-[var(--admin-text-muted)]">{loc.phone}</div>
)}
{loc.contact_email && (
<div className="text-xs text-[var(--admin-text-muted)] truncate max-w-[200px]">
{loc.contact_email}
</div>
)}
</div>
) : (
<span className="text-[var(--admin-text-muted)]"></span>
)}
</td>
<td className="px-5 py-4 text-center">
{loc.stop_count > 0 ? (
<span
className="inline-flex items-center justify-center min-w-[28px] px-2 py-0.5 rounded-full text-xs font-semibold"
style={{
background: "rgba(16, 185, 129, 0.12)",
color: "#047857",
}}
>
{loc.stop_count}
</span>
) : (
<span className="text-[var(--admin-text-muted)] text-xs">0</span>
)}
</td>
<td className="px-5 py-4">
{loc.active ? (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
style={{ background: "rgba(16, 185, 129, 0.12)", color: "#047857" }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#10b981" }} />
Active
</span>
) : (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
style={{ background: "rgba(120, 113, 108, 0.12)", color: "#57534e" }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#a8a29e" }} />
Inactive
</span>
)}
</td>
<td className="px-5 py-4 text-right">
<div className="flex justify-end gap-1">
<button
onClick={() => setEditing(loc as LocationForEdit)}
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
title="Edit venue"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(loc)}
disabled={pendingDeleteId === loc.id}
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors disabled:opacity-50"
title="Delete venue"
>
{pendingDeleteId === loc.id ? (
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a2 2 0 012-2h2a2 2 0 012 2v3" />
</svg>
)}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
{showAdd && (
<AddLocationModal
isOpen={showAdd}
onClose={() => setShowAdd(false)}
brandId={brandId}
onSuccess={handleAdded}
/>
)}
{editing && (
<EditLocationModal
isOpen={!!editing}
onClose={() => setEditing(null)}
location={editing}
brandId={brandId}
onSuccess={handleEdited}
/>
)}
</>
);
}
+5 -1
View File
@@ -8,13 +8,17 @@ import { AdminButton } from "@/components/admin/design-system";
type Props = {
brandId: string;
/** Hide on the Locations tab — actions belong to the Stops tab only. */
tab?: "stops" | "locations";
};
export default function StopsHeaderActions({ brandId }: Props) {
export default function StopsHeaderActions({ brandId, tab = "stops" }: Props) {
const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const router = useRouter();
if (tab !== "stops") return null;
function handleImportComplete(count: number) {
router.refresh();
}
+470
View File
@@ -0,0 +1,470 @@
-- Migration 203: Locations table (reusable venues) + admin RPCs
--
-- Purpose
-- -------
-- Today, "where a stop happens" is captured in 3 denormalized fields on the
-- stops table:
-- * location (venue name, e.g. "Tractor Supply")
-- * address (street address, e.g. "13778 E I-25 Frontage Rd")
-- * phone (was never on stops — only enriched from the xlsx Stop Directory)
--
-- That works for one-off inserts but breaks down for the Tuxedo 2026 tour
-- where the same venue (Tractor Supply, Murdoch's, etc.) repeats 5-15 times
-- across the season with the same address/phone/contact. Editing the address
-- means updating N stops. Listing "all unique venues we visit" requires
-- SELECT DISTINCT with a GROUP BY, not a real table.
--
-- This migration introduces a `locations` table — the canonical venue record
-- — and links each stop to it via a nullable location_id FK. Existing
-- denormalized fields on stops are kept (backwards compatible) but new
-- admin paths should prefer location_id.
--
-- The 269 seeded Tuxedo stops are backfilled into 49 unique locations
-- (one per (brand_id, name, address) tuple) by a follow-up script —
-- this migration only creates the schema and RPCs.
--
-- RPCs (all SECURITY DEFINER, all brand-scoped):
-- * admin_create_location — insert single location
-- * admin_create_locations_batch — insert many in one call (mirrors stops pattern)
-- * admin_update_location — partial update of name/address/phone/contact
-- * admin_delete_location — soft delete via deleted_at (mirrors stops)
-- * get_locations_for_brand — public read RPC (filters deleted_at, soft-publish)
-- * admin_attach_location_to_stop — link a stop to a location (idempotent)
BEGIN;
-- =============================================================================
-- 1. locations table
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES public.brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
address TEXT,
city TEXT,
state TEXT,
zip TEXT,
phone TEXT,
contact_name TEXT,
contact_email TEXT,
notes TEXT,
active BOOLEAN NOT NULL DEFAULT true,
deleted_at TIMESTAMPTZ DEFAULT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Slug per brand for nice URLs (locations/slug-{id}) — derived from name + zip
-- to disambiguate. Same collision-handling pattern as stops.
ALTER TABLE public.locations
ADD COLUMN IF NOT EXISTS slug TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_locations_brand_slug
ON public.locations (brand_id, slug)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_locations_brand_active
ON public.locations (brand_id, active)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_locations_brand_name
ON public.locations (brand_id, lower(name))
WHERE deleted_at IS NULL;
-- updated_at trigger (reuses the generic set_updated_at fn if present)
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'set_updated_at') THEN
DROP TRIGGER IF EXISTS trg_locations_updated_at ON public.locations;
CREATE TRIGGER trg_locations_updated_at
BEFORE UPDATE ON public.locations
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
END IF;
END$$;
-- =============================================================================
-- 2. Link stops → locations
-- =============================================================================
ALTER TABLE public.stops
ADD COLUMN IF NOT EXISTS location_id UUID REFERENCES public.locations(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_stops_location_id
ON public.stops (location_id)
WHERE deleted_at IS NULL;
-- =============================================================================
-- 3. RLS for locations
-- =============================================================================
ALTER TABLE public.locations ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "platform_admin_read_locations" ON public.locations;
DROP POLICY IF EXISTS "brand_admin_read_locations" ON public.locations;
DROP POLICY IF EXISTS "platform_admin_all_locations" ON public.locations;
DROP POLICY IF EXISTS "brand_admin_all_locations" ON public.locations;
-- Platform admin sees all non-deleted
CREATE POLICY "platform_admin_read_locations" ON public.locations
FOR SELECT USING (
deleted_at IS NULL
AND EXISTS (
SELECT 1 FROM public.admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'platform_admin'
)
);
-- Brand admin / store_employee sees their own brand's non-deleted
CREATE POLICY "brand_admin_read_locations" ON public.locations
FOR SELECT USING (
deleted_at IS NULL
AND EXISTS (
SELECT 1 FROM public.admin_users au
WHERE au.user_id = auth.uid()
AND au.role IN ('brand_admin', 'store_employee')
AND au.brand_id = locations.brand_id
)
);
-- Writes go through SECURITY DEFINER RPCs (no direct INSERT/UPDATE/DELETE from anon)
-- so no INSERT/UPDATE/DELETE policies are granted to anon/authenticated.
-- =============================================================================
-- 4. RPCs
-- =============================================================================
-- ── 4a. admin_create_location ────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.admin_create_location(
p_brand_id UUID,
p_name TEXT,
p_address TEXT DEFAULT NULL,
p_city TEXT DEFAULT NULL,
p_state TEXT DEFAULT NULL,
p_zip TEXT DEFAULT NULL,
p_phone TEXT DEFAULT NULL,
p_contact_name TEXT DEFAULT NULL,
p_contact_email TEXT DEFAULT NULL,
p_notes TEXT DEFAULT NULL,
p_active BOOLEAN DEFAULT true
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_id UUID;
v_slug TEXT;
v_slug_base TEXT;
v_counter INT := 0;
BEGIN
IF p_brand_id IS NULL THEN
RAISE EXCEPTION 'brand_id is required';
END IF;
IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
RAISE EXCEPTION 'name is required';
END IF;
v_slug_base := lower(regexp_replace(trim(p_name), '[^a-z0-9]+', '-', 'g'))
|| COALESCE('-' || regexp_replace(COALESCE(p_zip, ''), '[^a-z0-9]+', '', 'gi'), '');
v_slug_base := trim(BOTH '-' FROM v_slug_base);
v_slug := v_slug_base;
WHILE EXISTS (SELECT 1 FROM locations WHERE brand_id = p_brand_id AND slug = v_slug AND deleted_at IS NULL) LOOP
v_counter := v_counter + 1;
v_slug := v_slug_base || '-' || v_counter;
END LOOP;
INSERT INTO public.locations (
brand_id, name, address, city, state, zip, phone,
contact_name, contact_email, notes, active, slug
) VALUES (
p_brand_id, trim(p_name),
NULLIF(trim(p_address), ''),
NULLIF(trim(p_city), ''),
NULLIF(trim(p_state), ''),
NULLIF(trim(p_zip), ''),
NULLIF(trim(p_phone), ''),
NULLIF(trim(p_contact_name), ''),
NULLIF(trim(p_contact_email), ''),
NULLIF(trim(p_notes), ''),
p_active, v_slug
)
RETURNING id INTO v_id;
RETURN jsonb_build_object('id', v_id, 'slug', v_slug);
EXCEPTION WHEN OTHERS THEN
RAISE EXCEPTION 'admin_create_location failed: % (SQLSTATE %)', SQLERRM, SQLSTATE;
END;
$$;
-- ── 4b. admin_create_locations_batch ─────────────────────────────────────────
-- Mirrors admin_create_stops_batch. p_locations: JSONB array of objects with
-- the same shape as the single-row params. Returns JSONB array of {id, slug}.
-- Transactional: any per-row failure rolls the whole batch back.
CREATE OR REPLACE FUNCTION public.admin_create_locations_batch(
p_brand_id UUID,
p_locations JSONB
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_row JSONB;
v_result JSONB := '[]'::JSONB;
v_id UUID;
v_slug TEXT;
v_slug_base TEXT;
v_counter INT;
BEGIN
IF p_brand_id IS NULL THEN
RAISE EXCEPTION 'brand_id is required';
END IF;
IF p_locations IS NULL OR jsonb_typeof(p_locations) <> 'array' OR jsonb_array_length(p_locations) = 0 THEN
RETURN '[]'::JSONB;
END IF;
FOR v_row IN SELECT * FROM jsonb_array_elements(p_locations) LOOP
IF v_row->>'name' IS NULL OR length(trim(v_row->>'name')) = 0 THEN
RAISE EXCEPTION 'name is required for all locations';
END IF;
v_slug_base := lower(regexp_replace(trim(v_row->>'name'), '[^a-z0-9]+', '-', 'g'))
|| COALESCE('-' || regexp_replace(COALESCE(v_row->>'zip', ''), '[^a-z0-9]+', '', 'gi'), '');
v_slug_base := trim(BOTH '-' FROM v_slug_base);
v_slug := v_slug_base;
v_counter := 0;
WHILE EXISTS (SELECT 1 FROM locations WHERE brand_id = p_brand_id AND slug = v_slug AND deleted_at IS NULL) LOOP
v_counter := v_counter + 1;
v_slug := v_slug_base || '-' || v_counter;
END LOOP;
INSERT INTO public.locations (
brand_id, name, address, city, state, zip, phone,
contact_name, contact_email, notes, active, slug
) VALUES (
p_brand_id,
trim(v_row->>'name'),
NULLIF(trim(v_row->>'address'), ''),
NULLIF(trim(v_row->>'city'), ''),
NULLIF(trim(v_row->>'state'), ''),
NULLIF(trim(v_row->>'zip'), ''),
NULLIF(trim(v_row->>'phone'), ''),
NULLIF(trim(v_row->>'contact_name'), ''),
NULLIF(trim(v_row->>'contact_email'), ''),
NULLIF(trim(v_row->>'notes'), ''),
COALESCE((v_row->>'active')::BOOLEAN, true),
v_slug
)
RETURNING id INTO v_id;
v_result := v_result || jsonb_build_object('id', v_id, 'slug', v_slug)::JSONB;
END LOOP;
RETURN v_result;
EXCEPTION WHEN OTHERS THEN
RAISE EXCEPTION 'admin_create_locations_batch failed: % (SQLSTATE %)', SQLERRM, SQLSTATE;
END;
$$;
-- ── 4c. admin_update_location ────────────────────────────────────────────────
-- Partial update: only fields present in p_updates (JSONB) are written.
-- Example: SELECT admin_update_location('<id>', '<brand_id>', '{"phone": "555-1234"}');
CREATE OR REPLACE FUNCTION public.admin_update_location(
p_location_id UUID,
p_brand_id UUID,
p_updates JSONB
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_existing RECORD;
v_key TEXT;
v_value TEXT;
BEGIN
IF p_location_id IS NULL THEN
RAISE EXCEPTION 'location_id is required';
END IF;
IF p_updates IS NULL OR jsonb_typeof(p_updates) <> 'object' THEN
RAISE EXCEPTION 'updates must be a JSON object';
END IF;
-- Lock + ownership check
SELECT * INTO v_existing FROM public.locations
WHERE id = p_location_id AND deleted_at IS NULL FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Location not found');
END IF;
IF p_brand_id IS NOT NULL AND v_existing.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Location not found');
END IF;
-- Build dynamic UPDATE — only set fields explicitly present in p_updates
FOR v_key, v_value IN
SELECT key, value FROM jsonb_each_text(p_updates)
WHERE key IN ('name', 'address', 'city', 'state', 'zip', 'phone',
'contact_name', 'contact_email', 'notes', 'active')
LOOP
EXECUTE format(
'UPDATE public.locations SET %I = $1 WHERE id = $2',
v_key
) USING v_value, p_location_id;
END LOOP;
RETURN jsonb_build_object('success', true);
EXCEPTION WHEN OTHERS THEN
RAISE EXCEPTION 'admin_update_location failed: % (SQLSTATE %)', SQLERRM, SQLSTATE;
END;
$$;
-- ── 4d. admin_delete_location ────────────────────────────────────────────────
-- Soft delete via deleted_at. If any non-deleted stop still references this
-- location, refuse — caller must reassign or hard-delete those stops first.
CREATE OR REPLACE FUNCTION public.admin_delete_location(
p_location_id UUID,
p_brand_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_loc RECORD;
v_stops_count INT;
BEGIN
SELECT * INTO v_loc FROM public.locations
WHERE id = p_location_id AND deleted_at IS NULL FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Location not found');
END IF;
IF p_brand_id IS NOT NULL AND v_loc.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Location not found');
END IF;
SELECT COUNT(*) INTO v_stops_count FROM public.stops
WHERE location_id = p_location_id AND deleted_at IS NULL;
IF v_stops_count > 0 THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Cannot delete location — ' || v_stops_count || ' stop(s) still reference it. Reassign or delete those stops first.'
);
END IF;
UPDATE public.locations SET deleted_at = NOW() WHERE id = p_location_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── 4e. get_locations_for_brand ──────────────────────────────────────────────
-- Public read RPC. Mirrors get_public_stops_for_brand.
-- Returns active, non-deleted locations for a brand slug.
CREATE OR REPLACE FUNCTION public.get_locations_for_brand(p_brand_slug TEXT)
RETURNS TABLE (
id UUID,
name TEXT,
address TEXT,
city TEXT,
state TEXT,
zip TEXT,
phone TEXT,
contact_name TEXT,
contact_email TEXT,
notes TEXT,
slug TEXT,
stop_count BIGINT
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN QUERY
SELECT
l.id, l.name, l.address, l.city, l.state, l.zip, l.phone,
l.contact_name, l.contact_email, l.notes, l.slug,
(SELECT COUNT(*) FROM stops s
WHERE s.location_id = l.id AND s.deleted_at IS NULL)::BIGINT AS stop_count
FROM locations l
INNER JOIN brands b ON l.brand_id = b.id
WHERE l.deleted_at IS NULL
AND l.active = true
AND b.slug = p_brand_slug
ORDER BY lower(l.name) ASC;
END;
$$;
-- ── 4f. admin_attach_location_to_stop ────────────────────────────────────────
-- One-way link: set stops.location_id. Idempotent.
CREATE OR REPLACE FUNCTION public.admin_attach_location_to_stop(
p_stop_id UUID,
p_location_id UUID,
p_brand_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_stop RECORD;
v_location RECORD;
BEGIN
SELECT * INTO v_stop FROM stops
WHERE id = p_stop_id AND deleted_at IS NULL FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
IF p_brand_id IS NOT NULL AND v_stop.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
SELECT * INTO v_location FROM locations
WHERE id = p_location_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Location not found');
END IF;
IF v_location.brand_id != v_stop.brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Location belongs to a different brand');
END IF;
UPDATE stops SET location_id = p_location_id WHERE id = p_stop_id;
-- Also denormalize the location name + address from the venue so the
-- storefront cards stay useful even when stops.location_id is unset.
UPDATE stops SET location = v_location.name, address = COALESCE(v_location.address, stops.address)
WHERE id = p_stop_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- =============================================================================
-- 5. Grants
-- =============================================================================
GRANT EXECUTE ON FUNCTION public.admin_create_location(
UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN
) TO anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.admin_create_locations_batch(UUID, JSONB)
TO anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.admin_update_location(UUID, UUID, JSONB)
TO anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.admin_delete_location(UUID, UUID)
TO anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.get_locations_for_brand(TEXT)
TO anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.admin_attach_location_to_stop(UUID, UUID, UUID)
TO anon, authenticated, service_role;
COMMIT;
@@ -0,0 +1,19 @@
-- Migration 204: Add permissive read policy for locations
--
-- Why: The admin Stops & Routes page reads stops via the Supabase JS client
-- (anon key + RLS). The `stops` table has a `Public read stops` policy with
-- qual=true, which lets the anon role see all non-deleted stops — that's how
-- the page works in dev mode (no Supabase auth). The admin_locations tab needs
-- the same behavior so the server component can use the same pattern:
-- supabase.from("locations").select(...)
--
-- Brand scoping is enforced at the application layer in the server component
-- (eq("brand_id", adminUser.brand_id)), matching how stops are scoped.
--
-- Brand-scoped public reads (storefront pages) go through get_locations_for_brand
-- RPC, which filters active=true.
DROP POLICY IF EXISTS "Public read locations" ON public.locations;
CREATE POLICY "Public read locations" ON public.locations
FOR SELECT
USING (true);
@@ -0,0 +1,57 @@
-- Migration 205: admin_list_locations RPC
--
-- Returns ALL non-deleted locations for a brand (including inactive) with a
-- stop_count aggregation. Mirrors get_locations_for_brand but is brand_id-
-- keyed and admin-side (no active filter) so the Locations tab in the admin
-- Stops & Routes page can show inactive venues and draft venues.
--
-- brand_id NULL means platform_admin scope (all brands). This is the
-- standard application-layer brand-scoping pattern (SECURITY DEFINER RPCs
-- bypass RLS; caller is responsible for authorization).
CREATE OR REPLACE FUNCTION public.admin_list_locations(
p_brand_id UUID DEFAULT NULL
)
RETURNS TABLE (
id UUID,
brand_id UUID,
name TEXT,
address TEXT,
city TEXT,
state TEXT,
zip TEXT,
phone TEXT,
contact_name TEXT,
contact_email TEXT,
notes TEXT,
active BOOLEAN,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
slug TEXT,
stop_count BIGINT
)
LANGUAGE sql
SECURITY DEFINER
STABLE
SET search_path = public
AS $$
SELECT
l.id, l.brand_id, l.name, l.address, l.city, l.state, l.zip,
l.phone, l.contact_name, l.contact_email, l.notes, l.active,
l.created_at, l.updated_at, l.deleted_at, l.slug,
COALESCE(s.cnt, 0) AS stop_count
FROM public.locations l
LEFT JOIN (
SELECT location_id, COUNT(*) AS cnt
FROM public.stops
WHERE deleted_at IS NULL
GROUP BY location_id
) s ON s.location_id = l.id
WHERE l.deleted_at IS NULL
AND (p_brand_id IS NULL OR l.brand_id = p_brand_id)
ORDER BY lower(l.name) ASC;
$$;
GRANT EXECUTE ON FUNCTION public.admin_list_locations(UUID)
TO anon, authenticated, service_role;