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:
@@ -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 };
|
||||
}
|
||||
@@ -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">
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user