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
+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();
}