Open stop details in a modal from the Stops admin table

Clicking a stop (or the Edit link) in /admin/stops now opens a modal
with Details/Products/Message tabs instead of navigating to
/admin/stops/[id].

- Add getStopDetails server action (returns stop, brand, candidate
  products, assigned products, brand list in one round trip).
- Add StopDetailModal component with tabbed panels that reuse the
  existing StopEditForm, StopProductAssignment, and
  MessageCustomersSection.
- Add an optional onSaved callback to StopEditForm so the modal can
  refetch + router.refresh() after a save.
- StopTableClient now opens the modal on row click; the /admin/stops/[id]
  route is kept in place for direct links/bookmarks.
This commit is contained in:
2026-06-04 16:33:57 +00:00
parent 9b51f5ae29
commit bd623020d5
4 changed files with 443 additions and 8 deletions
+119
View File
@@ -0,0 +1,119 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { createClient } from "@supabase/supabase-js";
export type StopDetail = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
slug: string;
active: boolean;
brand_id: string;
address: string | null;
zip: string | null;
cutoff_time: string | null;
brands: { name: string; slug: string } | { name: string; slug: string }[] | null;
};
export type AssignedProduct = {
id: string;
product_id: string;
products: { name: string; type: string; price: number } | null;
};
export type StopDetailsResult =
| {
success: true;
stop: StopDetail;
allProducts: { id: string; name: string; type: string; price: number }[];
assignedProducts: AssignedProduct[];
brands: { id: string; name: string; slug: string }[];
}
| { success: false; error: string };
/**
* Fetch a single stop with its brand, all candidate products, currently
* assigned products, and the list of brands (for the brand switcher in the
* edit form). Mirrors the data the old `/admin/stops/[id]` page server
* component loaded, so the modal can be a drop-in replacement.
*/
export async function getStopDetails(stopId: string): Promise<StopDetailsResult> {
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.SUPABASE_SERVICE_ROLE_KEY!;
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
// Use a fresh server-side client (cookie-less) so RLS doesn't block reads
// for platform_admin dev sessions. The auth check above has already gated
// access.
const server = useMockData
? null
: createClient(supabaseUrl, supabaseKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
// 1. Stop + brand
let stop: StopDetail | null = null;
let stopErr: string | null = null;
if (server) {
const { data, error } = await server
.from("stops")
.select("*, brands(name, slug)")
.eq("id", stopId)
.single();
if (error) stopErr = error.message;
else stop = (data ?? null) as StopDetail | null;
} else {
// Mock fallback — empty
stopErr = "Stop not found";
}
if (!stop) {
return { success: false, error: stopErr ?? "Stop not found" };
}
// Brand-scope check for brand_admin
if (adminUser.brand_id && stop.brand_id !== adminUser.brand_id) {
return { success: false, error: "Not authorized for this brand" };
}
// 2. Candidate products for this brand
const { data: allProducts } = server
? await server
.from("products")
.select("id, name, type, price")
.eq("brand_id", stop.brand_id)
.eq("active", true)
: { data: [] as { id: string; name: string; type: string; price: number }[] };
// 3. Assigned products (joined with product info)
const { data: productStops } = server
? await server
.from("product_stops")
.select("id, product_id, products(id, name, type, price)")
.eq("stop_id", stopId)
: { data: [] as AssignedProduct[] };
// 4. Brands for the brand switcher
const { data: brands } = server
? await server.from("brands").select("id, name, slug")
: { data: [] as { id: string; name: string; slug: string }[] };
return {
success: true,
stop,
allProducts: allProducts ?? [],
assignedProducts: (productStops ?? []) as AssignedProduct[],
brands: brands ?? [],
};
}
+307
View File
@@ -0,0 +1,307 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import GlassModal from "@/components/admin/GlassModal";
import StopEditForm from "@/components/admin/StopEditForm";
import StopProductAssignment from "@/components/admin/StopProductAssignment";
import MessageCustomersSection from "@/components/admin/MessageCustomersSection";
import { getStopDetails } from "@/actions/stops/get-stop-details";
import { useToast } from "@/components/admin/design-system";
import type { StopDetail, AssignedProduct } from "@/actions/stops/get-stop-details";
type Tab = "details" | "products" | "message";
type Props = {
stopId: string;
/** Optional: when the user clicks Duplicate from inside the modal. */
onDuplicate?: (stopId: string) => void;
onClose: () => void;
};
export default function StopDetailModal({ stopId, onDuplicate, onClose }: Props) {
const router = useRouter();
const { success: showSuccess } = useToast();
const [, startTransition] = useTransition();
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [stop, setStop] = useState<StopDetail | null>(null);
const [allProducts, setAllProducts] = useState<{ id: string; name: string; type: string; price: number }[]>([]);
const [assignedProducts, setAssignedProducts] = useState<AssignedProduct[]>([]);
const [brands, setBrands] = useState<{ id: string; name: string; slug: string }[]>([]);
const [tab, setTab] = useState<Tab>("details");
useEffect(() => {
let cancelled = false;
setLoading(true);
setLoadError(null);
getStopDetails(stopId)
.then((res) => {
if (cancelled) return;
if (!res.success) {
setLoadError(res.error);
setLoading(false);
return;
}
setStop(res.stop);
setAllProducts(res.allProducts);
setAssignedProducts(res.assignedProducts);
setBrands(res.brands);
setLoading(false);
})
.catch((err) => {
if (cancelled) return;
setLoadError(err?.message ?? "Failed to load stop");
setLoading(false);
});
return () => {
cancelled = true;
};
}, [stopId]);
function refresh() {
getStopDetails(stopId).then((res) => {
if (!res.success) return;
setStop(res.stop);
setAllProducts(res.allProducts);
setAssignedProducts(res.assignedProducts);
setBrands(res.brands);
});
}
function handleEditSaved() {
refresh();
showSuccess("Stop updated", "Changes have been saved");
startTransition(() => router.refresh());
}
const subtitle = stop?.brands
? (Array.isArray(stop.brands) ? stop.brands[0]?.name : stop.brands.name) ?? undefined
: undefined;
return (
<GlassModal
title={loading ? "Loading…" : stop ? `${stop.city}, ${stop.state}` : "Stop"}
subtitle={subtitle ?? "Stop details"}
onClose={onClose}
maxWidth="max-w-3xl"
>
{loading ? (
<div className="space-y-3">
<div className="h-4 w-1/3 animate-pulse rounded bg-stone-200" />
<div className="h-4 w-2/3 animate-pulse rounded bg-stone-200" />
<div className="h-4 w-1/2 animate-pulse rounded bg-stone-200" />
</div>
) : loadError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{loadError}
</div>
) : stop ? (
<div className="space-y-5">
{/* Tabs */}
<div
className="flex items-center gap-1 rounded-xl border border-[var(--admin-border)] bg-stone-50 p-1"
role="tablist"
>
<TabButton active={tab === "details"} onClick={() => setTab("details")}>
Details
</TabButton>
<TabButton active={tab === "products"} onClick={() => setTab("products")}>
<span className="inline-flex items-center gap-1.5">
Products
{assignedProducts.length > 0 && (
<span className="rounded-full bg-[var(--admin-accent)]/10 px-2 py-0.5 text-[10px] font-semibold text-[var(--admin-accent)]">
{assignedProducts.length}
</span>
)}
</span>
</TabButton>
<TabButton active={tab === "message"} onClick={() => setTab("message")}>
Message
</TabButton>
</div>
{tab === "details" && (
<DetailsPanel
stop={stop}
brands={brands}
onDuplicate={onDuplicate}
onSaved={handleEditSaved}
/>
)}
{tab === "products" && (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">
Assigned Products
</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Manage which products are available at this stop.
</p>
<div className="mt-4">
<StopProductAssignment
stopId={stop.id}
allProducts={allProducts}
assignedProducts={assignedProducts}
callerUid={stop.id}
/>
</div>
</div>
)}
{tab === "message" && (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">
Message Customers
</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Send updates to customers with pending pickups at this stop.
</p>
<div className="mt-4">
<MessageCustomersSection stopId={stop.id} brandId={stop.brand_id} />
</div>
</div>
)}
</div>
) : null}
</GlassModal>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={`flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all ${
active
? "bg-white text-[var(--admin-accent)] shadow-sm border border-[var(--admin-border)]"
: "text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)]"
}`}
>
{children}
</button>
);
}
function DetailsPanel({
stop,
brands,
onDuplicate,
onSaved,
}: {
stop: StopDetail;
brands: { id: string; name: string; slug: string }[];
onDuplicate?: (stopId: string) => void;
onSaved: () => void;
}) {
return (
<div className="space-y-5">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">
{stop.city}, {stop.state}
</h3>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">{stop.location}</p>
</div>
<span
className={`shrink-0 rounded-full px-3 py-1 text-xs font-medium ${
stop.active
? "bg-emerald-100 text-emerald-700"
: "bg-stone-200 text-stone-500"
}`}
>
{stop.active ? "Active" : "Inactive"}
</span>
</div>
<dl className="mt-4 grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div>
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Date</dt>
<dd className="mt-0.5 font-mono text-[var(--admin-text-primary)]">{stop.date}</dd>
</div>
<div>
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Time</dt>
<dd className="mt-0.5 font-mono text-[var(--admin-text-primary)]">{stop.time}</dd>
</div>
{stop.address && (
<div className="col-span-2">
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Address</dt>
<dd className="mt-0.5 text-[var(--admin-text-primary)]">
{stop.address}
{stop.zip ? `, ${stop.zip}` : ""}
</dd>
</div>
)}
{stop.cutoff_time && (
<div className="col-span-2">
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Cutoff</dt>
<dd className="mt-0.5 text-[var(--admin-text-primary)]">
{new Date(stop.cutoff_time).toLocaleString()}
</dd>
</div>
)}
</dl>
</div>
<div className="flex justify-end">
{onDuplicate ? (
<button
type="button"
onClick={() => onDuplicate(stop.id)}
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50"
>
Duplicate Stop
</button>
) : (
<a
href={`/admin/stops/new?duplicate=${stop.id}`}
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50"
>
Duplicate Stop
</a>
)}
</div>
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Edit Stop</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Update stop details, location, and availability.
</p>
<div className="mt-4">
<StopEditForm
stop={{
id: stop.id,
city: stop.city,
state: stop.state,
date: stop.date,
time: stop.time,
location: stop.location,
slug: stop.slug,
active: stop.active,
brand_id: stop.brand_id,
address: stop.address,
zip: stop.zip,
cutoff_time: stop.cutoff_time,
}}
brands={brands}
onSaved={onSaved}
/>
</div>
</div>
</div>
);
}
+4 -1
View File
@@ -27,9 +27,11 @@ type StopEditFormProps = {
cutoff_time?: string | null; cutoff_time?: string | null;
}; };
brands: Brand[]; brands: Brand[];
/** Optional callback fired after a successful save. */
onSaved?: () => void;
}; };
export default function StopEditForm({ stop, brands }: StopEditFormProps) { export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProps) {
const router = useRouter(); const router = useRouter();
const { success: showSuccess, error: showError } = useToast(); const { success: showSuccess, error: showError } = useToast();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -99,6 +101,7 @@ export default function StopEditForm({ stop, brands }: StopEditFormProps) {
showSuccess("Stop updated", `${city}, ${state} has been saved`); showSuccess("Stop updated", `${city}, ${state} has been saved`);
setSaved(true); setSaved(true);
setSaving(false); setSaving(false);
onSaved?.();
router.refresh(); router.refresh();
} }
+13 -7
View File
@@ -15,6 +15,7 @@ import {
} from "@/components/admin/design-system"; } from "@/components/admin/design-system";
import ScheduleImportModal from "@/components/admin/ScheduleImportModal"; import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
import AddStopModal from "@/components/admin/AddStopModal"; import AddStopModal from "@/components/admin/AddStopModal";
import StopDetailModal from "@/components/admin/StopDetailModal";
type Stop = { type Stop = {
id: string; id: string;
@@ -55,6 +56,7 @@ export default function StopTableClient({ stops }: Props) {
const [bulkPublishing, setBulkPublishing] = useState(false); const [bulkPublishing, setBulkPublishing] = useState(false);
const [showImport, setShowImport] = useState(false); const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false); const [showAdd, setShowAdd] = useState(false);
const [openStopId, setOpenStopId] = useState<string | null>(null);
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
useEffect(() => { useEffect(() => {
@@ -349,6 +351,7 @@ export default function StopTableClient({ stops }: Props) {
e.stopPropagation(); e.stopPropagation();
toggleStopSelection(stop.id); toggleStopSelection(stop.id);
}} }}
onOpen={() => setOpenStopId(stop.id)}
/> />
)) ))
)} )}
@@ -369,6 +372,13 @@ export default function StopTableClient({ stops }: Props) {
brandId={stops[0]?.brand_id ?? ""} brandId={stops[0]?.brand_id ?? ""}
onSuccess={refresh} onSuccess={refresh}
/> />
{openStopId && (
<StopDetailModal
stopId={openStopId}
onClose={() => setOpenStopId(null)}
/>
)}
</> </>
); );
} }
@@ -400,16 +410,16 @@ function StopRowBase({
onDeleteError, onDeleteError,
isSelected, isSelected,
onToggleSelect, onToggleSelect,
onOpen,
}: { }: {
stop: Stop; stop: Stop;
onDeleted: () => void; onDeleted: () => void;
onDeleteError: (msg: string) => void; onDeleteError: (msg: string) => void;
isSelected: boolean; isSelected: boolean;
onToggleSelect: (e: React.MouseEvent) => void; onToggleSelect: (e: React.MouseEvent) => void;
onOpen: () => void;
}) { }) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast(); const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [openMenu, setOpenMenu] = useState<string | null>(null); const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null); const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
@@ -418,10 +428,6 @@ function StopRowBase({
const { date, weekday } = formatDate(stop.date); const { date, weekday } = formatDate(stop.date);
const time = formatTime(stop.time); const time = formatTime(stop.time);
function goToEdit() {
startTransition(() => router.push(`/admin/stops/${stop.id}`));
}
async function handlePublish() { async function handlePublish() {
setPublishingId(stop.id); setPublishingId(stop.id);
const result = await publishStop(stop.id, stop.brand_id); const result = await publishStop(stop.id, stop.brand_id);
@@ -461,7 +467,7 @@ function StopRowBase({
return ( return (
<tr <tr
onClick={goToEdit} onClick={onOpen}
className={`group cursor-pointer transition-colors ${isSelected ? "bg-emerald-50/60" : "hover:bg-[var(--admin-bg-subtle)]/60"}`} className={`group cursor-pointer transition-colors ${isSelected ? "bg-emerald-50/60" : "hover:bg-[var(--admin-bg-subtle)]/60"}`}
> >
<td className="px-4 py-3.5" onClick={(e) => e.stopPropagation()}> <td className="px-4 py-3.5" onClick={(e) => e.stopPropagation()}>