645 lines
23 KiB
TypeScript
645 lines
23 KiB
TypeScript
"use client";
|
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
|
|
import React, { useState, useTransition, useEffect, useMemo } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { publishStop, deleteStop } from "@/actions/stops";
|
|
import {
|
|
AdminSearchInput,
|
|
AdminFilterTabs,
|
|
AdminButton,
|
|
AdminIconButton,
|
|
useToast,
|
|
Skeleton,
|
|
} from "@/components/admin/design-system";
|
|
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
|
|
import AddStopModal from "@/components/admin/AddStopModal";
|
|
import StopDetailModal from "@/components/admin/StopDetailModal";
|
|
|
|
type Stop = {
|
|
id: string;
|
|
city: string;
|
|
state: string;
|
|
date: string;
|
|
time: string;
|
|
location: string;
|
|
active: boolean;
|
|
brand_id: string;
|
|
status?: string;
|
|
address?: string | null;
|
|
brands: { name: string } | { name: string }[] | null;
|
|
};
|
|
|
|
type Props = {
|
|
stops: Stop[];
|
|
/**
|
|
* Hide the internal search/filter bar at the top of the table. Use when
|
|
* the parent component already renders shared filters (e.g. StopsViewClient).
|
|
*/
|
|
hideInternalFilterBar?: boolean;
|
|
};
|
|
|
|
const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "active" | "inactive" | "draft"> = {
|
|
all: "all",
|
|
active: "active",
|
|
inactive: "inactive",
|
|
draft: "draft",
|
|
};
|
|
|
|
export default function StopTableClient({ stops, hideInternalFilterBar = false }: Props) {
|
|
const router = useRouter();
|
|
const { success: showSuccess, error: showError } = useToast();
|
|
const [, startTransition] = useTransition();
|
|
const [search, setSearch] = useState("");
|
|
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive" | "draft">("all");
|
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
const [page, setPage] = useState(0);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set());
|
|
const [bulkPublishing, setBulkPublishing] = useState(false);
|
|
const [showImport, setShowImport] = useState(false);
|
|
const [showAdd, setShowAdd] = useState(false);
|
|
const [openStopId, setOpenStopId] = useState<string | null>(null);
|
|
const PAGE_SIZE = 50;
|
|
|
|
useEffect(() => {
|
|
setIsLoading(true);
|
|
const timer = setTimeout(() => setIsLoading(false), 220);
|
|
return () => clearTimeout(timer);
|
|
}, [page, statusFilter, search]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
return stops.filter((s) => {
|
|
const matchesSearch =
|
|
!q ||
|
|
s.city.toLowerCase().includes(q) ||
|
|
s.location.toLowerCase().includes(q);
|
|
const matchesStatus =
|
|
statusFilter === "all" ||
|
|
(statusFilter === "active" && s.active && s.status !== "draft") ||
|
|
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
|
|
(statusFilter === "draft" && s.status === "draft");
|
|
return matchesSearch && matchesStatus;
|
|
});
|
|
}, [stops, search, statusFilter]);
|
|
|
|
const statusCounts = useMemo(
|
|
() => ({
|
|
all: stops.length,
|
|
active: stops.filter((s) => s.active && s.status !== "draft").length,
|
|
inactive: stops.filter((s) => !s.active && s.status !== "draft").length,
|
|
draft: stops.filter((s) => s.status === "draft").length,
|
|
}),
|
|
[stops]
|
|
);
|
|
|
|
const tabs = useMemo(
|
|
() => [
|
|
{ value: "all", label: "All", count: statusCounts.all },
|
|
{ value: "active", label: "Active", count: statusCounts.active },
|
|
{ value: "inactive", label: "Inactive", count: statusCounts.inactive },
|
|
{ value: "draft", label: "Draft", count: statusCounts.draft },
|
|
],
|
|
[statusCounts]
|
|
);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
|
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
|
|
|
const toggleSelectAll = () => {
|
|
if (selectedStops.size === paginatedStops.length) {
|
|
setSelectedStops(new Set());
|
|
} else {
|
|
setSelectedStops(new Set(paginatedStops.map((s) => s.id)));
|
|
}
|
|
};
|
|
|
|
const toggleStopSelection = (stopId: string) => {
|
|
setSelectedStops((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(stopId)) {
|
|
next.delete(stopId);
|
|
} else {
|
|
next.add(stopId);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
async function handleBulkPublish() {
|
|
if (selectedStops.size === 0) return;
|
|
setBulkPublishing(true);
|
|
let successCount = 0;
|
|
let failCount = 0;
|
|
for (const stopId of selectedStops) {
|
|
const stop = stops.find((s) => s.id === stopId);
|
|
if (stop && stop.status === "draft") {
|
|
const result = await publishStop(stopId, stop.brand_id);
|
|
if (result.success) {
|
|
successCount++;
|
|
} else {
|
|
failCount++;
|
|
}
|
|
}
|
|
}
|
|
setBulkPublishing(false);
|
|
setSelectedStops(new Set());
|
|
if (failCount === 0) {
|
|
showSuccess(`${successCount} stop${successCount !== 1 ? "s" : ""} published`);
|
|
} else {
|
|
showError("Some stops failed", `${successCount} succeeded, ${failCount} failed`);
|
|
}
|
|
startTransition(() => router.refresh());
|
|
}
|
|
|
|
function handleDeleted() {
|
|
setDeleteError(null);
|
|
startTransition(() => router.refresh());
|
|
}
|
|
|
|
function refresh() {
|
|
startTransition(() => router.refresh());
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* Filter bar */}
|
|
{!hideInternalFilterBar && (
|
|
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3 border-b border-[var(--admin-border)]">
|
|
<AdminSearchInput
|
|
placeholder="Search by city or venue…"
|
|
value={search}
|
|
onChange={(e) => {
|
|
setSearch(e.target.value);
|
|
setPage(0);
|
|
}}
|
|
onClear={() => {
|
|
setSearch("");
|
|
setPage(0);
|
|
}}
|
|
showClear
|
|
className="flex-1 min-w-[180px] max-w-[280px]"
|
|
/>
|
|
<AdminFilterTabs
|
|
activeTab={statusFilter}
|
|
onTabChange={(value) => {
|
|
setStatusFilter(TAB_NUMERIC[value as keyof typeof TAB_NUMERIC] ?? "all");
|
|
setPage(0);
|
|
}}
|
|
tabs={tabs}
|
|
size="sm"
|
|
showCounts
|
|
/>
|
|
<span className="text-xs text-[var(--admin-text-muted)] ml-auto tabular-nums">
|
|
{filtered.length} stop{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 tabular-nums">
|
|
{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="secondary"
|
|
size="sm"
|
|
onClick={() => setShowImport(true)}
|
|
icon={
|
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
|
</svg>
|
|
}
|
|
>
|
|
Upload Schedule
|
|
</AdminButton>
|
|
<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 Stop
|
|
</AdminButton>
|
|
</div>
|
|
)}
|
|
|
|
{/* Bulk actions bar */}
|
|
{selectedStops.size > 0 && (
|
|
<div className="mx-4 my-3 flex items-center justify-between rounded-xl border border-emerald-200 bg-emerald-50/60 px-4 py-2.5">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm font-semibold text-emerald-800 tabular-nums">
|
|
{selectedStops.size} stop{selectedStops.size !== 1 ? "s" : ""} selected
|
|
</span>
|
|
<button
|
|
onClick={() => setSelectedStops(new Set())}
|
|
className="text-xs text-stone-500 hover:text-stone-700"
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
<AdminButton
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={handleBulkPublish}
|
|
isLoading={bulkPublishing}
|
|
>
|
|
Publish Selected
|
|
</AdminButton>
|
|
</div>
|
|
)}
|
|
|
|
{/* Delete error */}
|
|
{deleteError && (
|
|
<div className="mx-4 my-3 rounded-lg border border-red-500/30 bg-red-50 px-4 py-2.5 text-sm text-red-700">
|
|
{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)]/40 text-[var(--admin-text-muted)] text-xs uppercase tracking-wide">
|
|
<tr>
|
|
<th className="w-10 px-4 py-3">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedStops.size === paginatedStops.length && paginatedStops.length > 0}
|
|
onChange={toggleSelectAll}
|
|
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
|
|
aria-label="Select all"
|
|
/>
|
|
</th>
|
|
<th className="px-4 py-3 font-semibold">When</th>
|
|
<th className="px-4 py-3 font-semibold">Where</th>
|
|
<th className="px-4 py-3 font-semibold">Venue</th>
|
|
<th className="px-4 py-3 font-semibold">Status</th>
|
|
<th className="w-12 px-4 py-3" />
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[var(--admin-border)]">
|
|
{isLoading ? (
|
|
Array.from({ length: 8 }).map((_, i) => (
|
|
<tr key={i}>
|
|
<td className="px-4 py-3.5"><Skeleton variant="rect" className="h-4 w-4" /></td>
|
|
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-24 h-4" /></td>
|
|
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-32 h-4" /></td>
|
|
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-40 h-4" /></td>
|
|
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-16 h-5 rounded-full" /></td>
|
|
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-6 h-6" /></td>
|
|
</tr>
|
|
))
|
|
) : filtered.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-4 py-14 text-center">
|
|
<div className="flex flex-col items-center gap-3 text-[var(--admin-text-muted)]">
|
|
<div className="h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center">
|
|
<svg className="h-6 w-6 text-stone-400" 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>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold text-stone-700">
|
|
{search || statusFilter !== "all"
|
|
? "No stops match your filters"
|
|
: "No stops yet"}
|
|
</p>
|
|
<p className="mt-0.5 text-xs text-stone-500">
|
|
{search || statusFilter !== "all"
|
|
? "Try a different search or clear the filters above."
|
|
: "Use the buttons above to upload a schedule or add your first stop."}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
paginatedStops.map((stop) => (
|
|
<StopRow
|
|
key={stop.id}
|
|
stop={stop}
|
|
onDeleted={handleDeleted}
|
|
onDeleteError={setDeleteError}
|
|
isSelected={selectedStops.has(stop.id)}
|
|
onToggleSelect={(e) => {
|
|
e.stopPropagation();
|
|
toggleStopSelection(stop.id);
|
|
}}
|
|
onOpen={() => setOpenStopId(stop.id)}
|
|
/>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
|
|
{showImport && (
|
|
<ScheduleImportModal
|
|
brandId={stops[0]?.brand_id ?? ""}
|
|
onClose={() => setShowImport(false)}
|
|
onComplete={refresh}
|
|
/>
|
|
)}
|
|
|
|
<AddStopModal
|
|
isOpen={showAdd}
|
|
onClose={() => setShowAdd(false)}
|
|
brandId={stops[0]?.brand_id ?? ""}
|
|
onSuccess={refresh}
|
|
/>
|
|
|
|
{openStopId && (
|
|
<StopDetailModal
|
|
stopId={openStopId}
|
|
onClose={() => setOpenStopId(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function formatDate(iso: string): { date: string; weekday: string } {
|
|
// Stop date is stored as YYYY-MM-DD; parse without TZ shift
|
|
const [y, m, d] = iso.split("-").map((s) => parseInt(s, 10));
|
|
if (!y || !m || !d) return { date: iso, weekday: "" };
|
|
const dt = new Date(y, m - 1, d);
|
|
const weekday = dt.toLocaleDateString("en-US", { weekday: "short" });
|
|
const month = dt.toLocaleDateString("en-US", { month: "short" });
|
|
return { date: `${month} ${d}`, weekday };
|
|
}
|
|
|
|
function formatTime(t: string): string {
|
|
// Stored as HH:MM (24h). Display as 10:00 AM.
|
|
if (!t) return "";
|
|
const [hStr, mStr] = t.split(":");
|
|
const h = parseInt(hStr, 10);
|
|
if (Number.isNaN(h)) return t;
|
|
const period = h >= 12 ? "PM" : "AM";
|
|
const hh = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
|
return `${hh}:${mStr} ${period}`;
|
|
}
|
|
|
|
function StopRowBase({
|
|
stop,
|
|
onDeleted,
|
|
onDeleteError,
|
|
isSelected,
|
|
onToggleSelect,
|
|
onOpen,
|
|
}: {
|
|
stop: Stop;
|
|
onDeleted: () => void;
|
|
onDeleteError: (msg: string) => void;
|
|
isSelected: boolean;
|
|
onToggleSelect: (e: React.MouseEvent) => void;
|
|
onOpen: () => void;
|
|
}) {
|
|
const { success: showSuccess, error: showError } = useToast();
|
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
|
const [publishingId, setPublishingId] = useState<string | null>(null);
|
|
|
|
const { date, weekday } = formatDate(stop.date);
|
|
const time = formatTime(stop.time);
|
|
|
|
async function handlePublish() {
|
|
setPublishingId(stop.id);
|
|
const result = await publishStop(stop.id, stop.brand_id);
|
|
setPublishingId(null);
|
|
if (result.success) {
|
|
showSuccess("Stop published", "The stop is now visible on the storefront");
|
|
onDeleted();
|
|
} else {
|
|
showError("Failed to publish", result.error ?? "Please try again");
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
setDeletingId(stop.id);
|
|
const result = await deleteStop(stop.id, stop.brand_id);
|
|
setDeletingId(null);
|
|
setConfirmDelete(null);
|
|
setOpenMenu(null);
|
|
if (result.success) {
|
|
showSuccess("Stop deleted", "The stop has been removed");
|
|
onDeleted();
|
|
} else {
|
|
onDeleteError(result.error ?? "Delete failed");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<tr
|
|
onClick={onOpen}
|
|
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()}>
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onChange={() => {}}
|
|
onClick={onToggleSelect}
|
|
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
|
|
aria-label={`Select stop in ${stop.city}`}
|
|
/>
|
|
</td>
|
|
|
|
<td className="px-4 py-3.5">
|
|
<div className="flex flex-col leading-tight">
|
|
<span className="font-semibold text-[var(--admin-text-primary)] tabular-nums">{date}</span>
|
|
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">
|
|
{weekday} {time && `· ${time}`}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
|
|
<td className="px-4 py-3.5">
|
|
<div className="flex flex-col leading-tight">
|
|
<span className="font-semibold text-[var(--admin-text-primary)]">{stop.city}</span>
|
|
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">{stop.state}</span>
|
|
</div>
|
|
</td>
|
|
|
|
<td className="px-4 py-3.5">
|
|
<div className="flex flex-col leading-tight">
|
|
<span className="text-[var(--admin-text-secondary)]">{stop.location}</span>
|
|
{stop.address && (
|
|
<span className="text-[11px] text-[var(--admin-text-muted)] truncate max-w-[260px]">
|
|
{stop.address}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
|
|
<td className="px-4 py-3.5">
|
|
<span
|
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold ${
|
|
stop.status === "draft"
|
|
? "bg-amber-50 text-amber-700 border border-amber-200"
|
|
: stop.active
|
|
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
|
|
: "bg-stone-100 text-stone-500 border border-stone-200"
|
|
}`}
|
|
>
|
|
<span
|
|
className={`h-1.5 w-1.5 rounded-full ${
|
|
stop.status === "draft"
|
|
? "bg-amber-500"
|
|
: stop.active
|
|
? "bg-emerald-500"
|
|
: "bg-stone-400"
|
|
}`}
|
|
/>
|
|
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
|
|
</span>
|
|
</td>
|
|
|
|
<td className="px-4 py-3.5 text-right" onClick={(e) => e.stopPropagation()}>
|
|
<div className="relative inline-flex items-center justify-end gap-1">
|
|
<AdminIconButton
|
|
variant="ghost"
|
|
size="sm"
|
|
label="More options"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setOpenMenu(openMenu === stop.id ? null : stop.id);
|
|
}}
|
|
className="opacity-60 group-hover:opacity-100"
|
|
>
|
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<circle cx="10" cy="4" r="1.5" />
|
|
<circle cx="10" cy="10" r="1.5" />
|
|
<circle cx="10" cy="16" r="1.5" />
|
|
</svg>
|
|
</AdminIconButton>
|
|
|
|
{openMenu === stop.id && (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-10"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setOpenMenu(null);
|
|
setConfirmDelete(null);
|
|
}}
|
|
/>
|
|
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl overflow-hidden">
|
|
{stop.status === "draft" && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handlePublish();
|
|
}}
|
|
disabled={publishingId === stop.id}
|
|
className="w-full text-left px-4 py-2.5 text-sm font-medium text-emerald-700 hover:bg-emerald-50 transition-colors disabled:opacity-50"
|
|
>
|
|
{publishingId === stop.id ? "Publishing…" : "Publish"}
|
|
</button>
|
|
)}
|
|
<Link
|
|
href={`/admin/stops/new?duplicate=${stop.id}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="flex items-center px-4 py-2.5 text-sm text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
|
>
|
|
Duplicate
|
|
</Link>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setOpenMenu(null);
|
|
setConfirmDelete(stop.id);
|
|
}}
|
|
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{confirmDelete === stop.id && (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-30"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setConfirmDelete(null);
|
|
setOpenMenu(null);
|
|
}}
|
|
/>
|
|
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
|
|
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
|
Delete "{stop.city}, {stop.state}"?
|
|
</p>
|
|
<p className="mt-1.5 text-xs text-[var(--admin-text-muted)]">
|
|
This will remove the stop. If it has active orders, you must resolve those first.
|
|
</p>
|
|
<div className="mt-4 flex gap-2 justify-end">
|
|
<AdminButton
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setConfirmDelete(null);
|
|
setOpenMenu(null);
|
|
}}
|
|
>
|
|
Cancel
|
|
</AdminButton>
|
|
<AdminButton
|
|
variant="danger"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDelete();
|
|
}}
|
|
disabled={deletingId === stop.id}
|
|
isLoading={deletingId === stop.id}
|
|
>
|
|
{deletingId === stop.id ? "Deleting…" : "Delete"}
|
|
</AdminButton>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
const StopRow = React.memo(StopRowBase);
|