diff --git a/src/app/admin/stops/page.tsx b/src/app/admin/stops/page.tsx index 6b547cd..614d0ed 100644 --- a/src/app/admin/stops/page.tsx +++ b/src/app/admin/stops/page.tsx @@ -1,13 +1,14 @@ import StopTableClient from "@/components/admin/StopTableClient"; import LocationsTab from "@/components/admin/LocationsTab"; -import StopsHeaderActions from "@/components/admin/StopsHeaderActions"; +import StopsLocationsTabs from "@/components/admin/StopsLocationsTabs"; +import StatsStrip from "@/components/admin/StatsStrip"; 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"; +import { TabSwitcher } from "@/components/admin/TabSwitcher"; const StopIcon = () => ( @@ -16,11 +17,7 @@ const StopIcon = () => ( ); -const TABS = [ - { value: "stops", label: "Stops" }, - { value: "locations", label: "Locations" }, -] as const; -type TabValue = (typeof TABS)[number]["value"]; +type TabValue = "stops" | "locations"; function isTab(v: string | undefined): v is TabValue { return v === "stops" || v === "locations"; @@ -83,73 +80,83 @@ export default async function AdminStopsPage({ searchParams }: PageProps) { ); } + // Derive stats for the strip. The stops/locations arrays are already on hand. + const safeStops = stops ?? []; + const cityCount = new Set( + safeStops.map((s) => `${(s.city ?? "").toLowerCase()}|${(s.state ?? "").toLowerCase()}`) + ).size; + const stateCount = new Set(safeStops.map((s) => (s.state ?? "").toUpperCase())).size; + const draftCount = safeStops.filter((s) => s.status === "draft").length; + const activeCount = safeStops.filter((s) => s.active && s.status !== "draft").length; + const inactiveCount = safeStops.filter((s) => !s.active && s.status !== "draft").length; + + const locationCityCount = new Set( + locations.map((l) => (l.city ?? "").toLowerCase()) + ).size; + const locationWithStops = locations.filter((l) => l.stop_count > 0).length; + 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."; + ? "Manage stops, venues, and the routes that connect them." + : "Manage stops, venues, and the routes that connect them."; + + const stopsStats = [ + { value: safeStops.length, label: "stops" }, + { value: cityCount, label: "cities" }, + { value: stateCount, label: "states" }, + { value: activeCount, label: "active" }, + { value: draftCount, label: "draft", emphasis: draftCount > 0 }, + { value: inactiveCount, label: "inactive" }, + ]; + + const locationsStats = [ + { value: locations.length, label: "venues" }, + { value: locationCityCount, label: "cities" }, + { value: locationWithStops, label: "with stops" }, + ]; return (
-
- } - title="Stops & Routes" - subtitle={subtitle} - actions={} - /> -
- - {/* Tabs */} -
+ {/* Header */} +
-
- {TABS.map((t) => { - const active = t.value === tab; - return ( - - {t.label} - {active && ( - - )} - - ); - })} + } + title="Stops & Routes" + subtitle={subtitle} + /> +
+
- {/* Content */} -
+ {/* Card: tabs + content */} +
-
- {tab === "stops" ? ( - - ) : ( - - )} +
+ + + {tab === "stops" ? ( + + ) : ( + + )} +
diff --git a/src/components/admin/StatsStrip.tsx b/src/components/admin/StatsStrip.tsx new file mode 100644 index 0000000..2325e66 --- /dev/null +++ b/src/components/admin/StatsStrip.tsx @@ -0,0 +1,31 @@ +type Stat = { + label: string; + value: number | string; + emphasis?: boolean; +}; + +type Props = { + stats: Stat[]; + /** Right-aligned slot (e.g. an inline "Next stop" pill or refresh action) */ + right?: React.ReactNode; +}; + +export default function StatsStrip({ stats, right }: Props) { + return ( +
+
+ {stats.map((s, i) => ( + + + {s.value} + + {s.label} + + ))} +
+ {right &&
{right}
} +
+ ); +} diff --git a/src/components/admin/StopTableClient.tsx b/src/components/admin/StopTableClient.tsx index 4fe5d08..0c4cac1 100644 --- a/src/components/admin/StopTableClient.tsx +++ b/src/components/admin/StopTableClient.tsx @@ -1,11 +1,20 @@ "use client"; /* eslint-disable react-hooks/set-state-in-effect */ -import React, { useState, useTransition, useEffect } from "react"; +import React, { useState, useTransition, useEffect, useMemo } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { publishStop } from "@/actions/stops"; -import { AdminSearchInput, AdminFilterTabs, AdminButton, AdminIconButton, useToast, Skeleton } from "@/components/admin/design-system"; +import { + AdminSearchInput, + AdminFilterTabs, + AdminButton, + AdminIconButton, + useToast, + Skeleton, +} from "@/components/admin/design-system"; +import ScheduleImportModal from "@/components/admin/ScheduleImportModal"; +import AddStopModal from "@/components/admin/AddStopModal"; type Stop = { id: string; @@ -18,13 +27,21 @@ type Stop = { deleted_at?: string | null; brand_id: string; status?: string; - brands: { name: string } | { name: string }[]; + address?: string | null; + brands: { name: string } | { name: string }[] | null; }; type Props = { stops: Stop[]; }; +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 }: Props) { const router = useRouter(); const { success: showSuccess, error: showError } = useToast(); @@ -36,57 +53,65 @@ export default function StopTableClient({ stops }: Props) { const [isLoading, setIsLoading] = useState(false); const [selectedStops, setSelectedStops] = useState>(new Set()); const [bulkPublishing, setBulkPublishing] = useState(false); + const [showImport, setShowImport] = useState(false); + const [showAdd, setShowAdd] = useState(false); const PAGE_SIZE = 50; - // Simulate loading when filters change useEffect(() => { setIsLoading(true); - const timer = setTimeout(() => setIsLoading(false), 300); + const timer = setTimeout(() => setIsLoading(false), 220); return () => clearTimeout(timer); }, [page, statusFilter, search]); - const filtered = stops.filter((s) => { - const matchesSearch = - !search || - s.city.toLowerCase().includes(search.toLowerCase()) || - s.location.toLowerCase().includes(search.toLowerCase()); - 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; - }); + 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 = { - 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, - }; + 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 = [ - { 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 }, - ]; + 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.ceil(filtered.length / PAGE_SIZE); + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); - const draftStops = stops.filter(s => s.status === "draft" && s.active); - // Bulk selection const toggleSelectAll = () => { if (selectedStops.size === paginatedStops.length) { setSelectedStops(new Set()); } else { - setSelectedStops(new Set(paginatedStops.map(s => s.id))); + setSelectedStops(new Set(paginatedStops.map((s) => s.id))); } }; const toggleStopSelection = (stopId: string) => { - setSelectedStops(prev => { + setSelectedStops((prev) => { const next = new Set(prev); if (next.has(stopId)) { next.delete(stopId); @@ -99,13 +124,11 @@ export default function StopTableClient({ stops }: Props) { 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); + const stop = stops.find((s) => s.id === stopId); if (stop && stop.status === "draft") { const result = await publishStop(stopId, stop.brand_id); if (result.success) { @@ -115,83 +138,123 @@ export default function StopTableClient({ stops }: Props) { } } } - setBulkPublishing(false); setSelectedStops(new Set()); - if (failCount === 0) { - showSuccess(`${successCount} stop${successCount !== 1 ? 's' : ''} published`); + 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(); - }); + startTransition(() => router.refresh()); + } + + function refresh() { + startTransition(() => router.refresh()); } return ( <> {/* Filter bar */} -
+
{ setSearch(e.target.value); setPage(0); }} - onClear={() => { setSearch(""); setPage(0); }} - showClear={true} - className="flex-1 min-w-48 max-w-64" + onChange={(e) => { + setSearch(e.target.value); + setPage(0); + }} + onClear={() => { + setSearch(""); + setPage(0); + }} + showClear + className="flex-1 min-w-[180px] max-w-[280px]" /> { setStatusFilter(value as typeof statusFilter); setPage(0); }} + onTabChange={(value) => { + setStatusFilter(TAB_NUMERIC[value as keyof typeof TAB_NUMERIC] ?? "all"); + setPage(0); + }} tabs={tabs} size="sm" - showCounts={true} + showCounts /> - {filtered.length} stops + + {filtered.length} stop{filtered.length !== 1 ? "s" : ""} + {totalPages > 1 && (
setPage(p => Math.max(0, p - 1))} + onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0 || isLoading} className="!rounded-lg border border-[var(--admin-border)]" > - + + + - {page + 1}/{totalPages} + + {page + 1}/{totalPages} + setPage(p => Math.min(totalPages - 1, p + 1))} + onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1 || isLoading} className="!rounded-lg border border-[var(--admin-border)]" > - + + +
)} + setShowImport(true)} + icon={ + + + + } + > + Upload Schedule + + setShowAdd(true)} + icon={ + + + + } + > + Add Stop +
{/* Bulk actions bar */} {selectedStops.size > 0 && ( -
+
- - {selectedStops.size} stop{selectedStops.size !== 1 ? 's' : ''} selected + + {selectedStops.size} stop{selectedStops.size !== 1 ? "s" : ""} selected @@ -209,7 +272,7 @@ export default function StopTableClient({ stops }: Props) { {/* Delete error */} {deleteError && ( -
+
{deleteError}{" "} )} - 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 - - { setOpenMenu(null); setConfirmDelete(stop.id); }} - className="!justify-start !text-[var(--admin-danger)] hover:!bg-[var(--admin-danger)]/10" + +
)} @@ -446,7 +593,11 @@ function StopRowBase({ <>
{ setConfirmDelete(null); setOpenMenu(null); }} + onClick={(e) => { + e.stopPropagation(); + setConfirmDelete(null); + setOpenMenu(null); + }} />

@@ -455,22 +606,29 @@ function StopRowBase({

This will remove the stop. If it has active orders, you must resolve those first.

-
+
{ setConfirmDelete(null); setOpenMenu(null); }} + onClick={(e) => { + e.stopPropagation(); + setConfirmDelete(null); + setOpenMenu(null); + }} > Cancel handleDelete(stop.id)} + onClick={(e) => { + e.stopPropagation(); + handleDelete(); + }} disabled={deletingId === stop.id} isLoading={deletingId === stop.id} > - {deletingId === stop.id ? "..." : "Delete"} + {deletingId === stop.id ? "Deleting…" : "Delete"}
@@ -482,4 +640,4 @@ function StopRowBase({ ); } -const StopRow = React.memo(StopRowBase); \ No newline at end of file +const StopRow = React.memo(StopRowBase); diff --git a/src/components/admin/StopsHeaderActions.tsx b/src/components/admin/StopsHeaderActions.tsx deleted file mode 100644 index 4d09d5f..0000000 --- a/src/components/admin/StopsHeaderActions.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; - -import { useState } from "react"; -import ScheduleImportModal from "@/components/admin/ScheduleImportModal"; -import AddStopModal from "@/components/admin/AddStopModal"; -import { useRouter } from "next/navigation"; -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, 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(); - } - - function handleAddSuccess(stopId: string) { - router.refresh(); - } - - return ( - <> -
- setShowImport(true)} - icon={ - - - - } - > - Upload Schedule - - setShowAdd(true)} - icon={ - - - - } - > - Add Stop - -
- - {showImport && ( - setShowImport(false)} - onComplete={handleImportComplete} - /> - )} - - setShowAdd(false)} - brandId={brandId} - onSuccess={handleAddSuccess} - /> - - ); -} \ No newline at end of file diff --git a/src/components/admin/StopsLocationsTabs.tsx b/src/components/admin/StopsLocationsTabs.tsx new file mode 100644 index 0000000..80974b1 --- /dev/null +++ b/src/components/admin/StopsLocationsTabs.tsx @@ -0,0 +1,107 @@ +"use client"; + +import Link from "next/link"; + +type Props = { + active: "stops" | "locations"; + stopCount: number; + locationCount: number; + stopsSublabel?: string; // e.g. "269 · 7 cities" + locationsSublabel?: string; // e.g. "41 · 8 cities" +}; + +const StopIcon = () => ( + + + + +); + +const PinIcon = () => ( + + + + +); + +export default function StopsLocationsTabs({ + active, + stopCount, + locationCount, + stopsSublabel, + locationsSublabel, +}: Props) { + const tabs = [ + { + value: "stops" as const, + label: "Stops", + count: stopCount, + sublabel: stopsSublabel, + icon: , + href: "/admin/stops", + }, + { + value: "locations" as const, + label: "Locations", + count: locationCount, + sublabel: locationsSublabel, + icon: , + href: "/admin/stops?tab=locations", + }, + ]; + + return ( +
+ {tabs.map((t) => { + const isActive = t.value === active; + return ( + + + {t.icon} + + {t.label} + + {t.count} + + {t.sublabel && ( + + · {t.sublabel} + + )} + + ); + })} +
+ ); +} diff --git a/src/components/admin/TabSwitcher.tsx b/src/components/admin/TabSwitcher.tsx new file mode 100644 index 0000000..3615429 --- /dev/null +++ b/src/components/admin/TabSwitcher.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { motion, AnimatePresence } from "framer-motion"; + +type Props = { + tabKey: string; + children: React.ReactNode; +}; + +/** + * Fades the content of a tabbed panel when the active tab changes. + * The tab key drives the animation, so switching tabs triggers a quick + * fade-in of the new content. No motion when the tab key is stable + * (i.e. on first render of a given tab). + */ +export function TabSwitcher({ tabKey, children }: Props) { + return ( + + + {children} + + + ); +}