design(stops): cohesive card layout, count badges, row-click, merged columns
Tier 1 — Layout consistency:
* Move Stops tab action buttons (Upload Schedule, Add Stop) into the
filter bar, matching the Locations tab. One muscle memory for
'where the Add button is'. Removes the page-level StopsHeaderActions
component.
* Make the card a single cohesive unit. Tabs sit INSIDE the card on
top with a border-b, content fills the rest. No more 'rounded-b-2xl
rounded-t-none border-t-0' shape.
* New StopsLocationsTabs: pill-style tabs with icons + count badges +
sublabel ('269 · 7 cities'). Instant orientation. URL-driven via
Link so refresh + deep-linking work.
Tier 2 — Easier to scan:
* New StatsStrip under the page header: '269 stops · 7 cities · 4
states · 4 active · 5 draft · 0 inactive' (different stats for the
Locations tab). Context before the table loads.
* Stops table collapses 8 columns → 6: drop Brand, merge City+State
into 'Where', merge Date+Time into 'When' (date + weekday on one
line, time on the next). 30% more breathing room per row.
* Replace font-mono for dates/times with tabular-nums — same column
alignment, type family stays consistent.
* Click anywhere on a Stops row to edit. Redundant 'Edit' link is
gone. Kebab menu still handles Publish/Duplicate/Delete.
* Match Locations' empty state (icon + title + subtitle) on the Stops
side.
Polish:
* Consistent status pills (Active = emerald, Inactive = stone, Draft
= amber) in both tabs.
* Tab-switch fade via framer-motion AnimatePresence — subtle y/opacity
transition between Stops and Locations content.
This commit is contained in:
@@ -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 = () => (
|
||||
<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">
|
||||
@@ -16,11 +17,7 @@ const StopIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
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 (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<PageHeader
|
||||
breadcrumb={[
|
||||
{ label: "Admin", href: "/admin" },
|
||||
{ label: "Stops & Routes" }
|
||||
]}
|
||||
icon={<StopIcon />}
|
||||
title="Stops & Routes"
|
||||
subtitle={subtitle}
|
||||
actions={<StopsHeaderActions brandId={adminUser?.brand_id ?? ""} tab={tab} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="px-4 sm:px-6 md:px-8">
|
||||
{/* Header */}
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-6 sm:pt-8 pb-4 sm:pb-5">
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
<PageHeader
|
||||
breadcrumb={[
|
||||
{ label: "Admin", href: "/admin" },
|
||||
{ label: "Stops & Routes" }
|
||||
]}
|
||||
icon={<StopIcon />}
|
||||
title="Stops & Routes"
|
||||
subtitle={subtitle}
|
||||
/>
|
||||
<div className="mt-3 ml-[52px]">
|
||||
<StatsStrip
|
||||
stats={tab === "locations" ? locationsStats : stopsStats}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||
{/* Card: tabs + content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 pb-8">
|
||||
<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 className="overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
|
||||
<StopsLocationsTabs
|
||||
active={tab}
|
||||
stopCount={safeStops.length}
|
||||
locationCount={locations.length}
|
||||
stopsSublabel={`${cityCount} cities`}
|
||||
locationsSublabel={`${locationCityCount} cities`}
|
||||
/>
|
||||
<TabSwitcher tabKey={tab}>
|
||||
{tab === "stops" ? (
|
||||
<StopTableClient stops={safeStops} />
|
||||
) : (
|
||||
<LocationsTab locations={locations} brandId={adminUser?.brand_id ?? ""} />
|
||||
)}
|
||||
</TabSwitcher>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
{stats.map((s, i) => (
|
||||
<span key={i} className="flex items-baseline gap-1.5">
|
||||
<span
|
||||
className={`font-bold tabular-nums ${s.emphasis ? "text-emerald-700" : "text-[var(--admin-text-primary)]"}`}
|
||||
>
|
||||
{s.value}
|
||||
</span>
|
||||
<span className="text-[var(--admin-text-muted)]">{s.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{right && <div className="ml-auto">{right}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Set<string>>(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 */}
|
||||
<div className="border-b border-[var(--admin-border)] px-5 py-3 flex gap-4 flex-wrap items-center">
|
||||
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3 border-b border-[var(--admin-border)]">
|
||||
<AdminSearchInput
|
||||
placeholder="Search stops..."
|
||||
placeholder="Search by city or venue…"
|
||||
value={search}
|
||||
onChange={(e) => { 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]"
|
||||
/>
|
||||
<AdminFilterTabs
|
||||
activeTab={statusFilter}
|
||||
onTabChange={(value) => { 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
|
||||
/>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] ml-auto">{filtered.length} stops</span>
|
||||
<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))}
|
||||
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>
|
||||
<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>
|
||||
<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))}
|
||||
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>
|
||||
<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-5 my-3 flex items-center justify-between rounded-xl border border-[var(--admin-accent)] bg-[var(--admin-accent-light)] px-4 py-3">
|
||||
<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-[var(--admin-accent-text)]">
|
||||
{selectedStops.size} stop{selectedStops.size !== 1 ? 's' : ''} selected
|
||||
<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-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]"
|
||||
className="text-xs text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
@@ -209,7 +272,7 @@ export default function StopTableClient({ stops }: Props) {
|
||||
|
||||
{/* 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)]">
|
||||
<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
|
||||
@@ -219,45 +282,59 @@ export default function StopTableClient({ stops }: Props) {
|
||||
|
||||
{/* Table */}
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
|
||||
<thead className="bg-[var(--admin-bg-subtle)]/40 text-[var(--admin-text-muted)] text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th className="w-10 px-5 py-4">
|
||||
<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-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
|
||||
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-5 py-4 font-semibold">City</th>
|
||||
<th className="px-5 py-4 font-semibold">Location</th>
|
||||
<th className="px-5 py-4 font-semibold">Date</th>
|
||||
<th className="px-5 py-4 font-semibold">Time</th>
|
||||
<th className="px-5 py-4 font-semibold">Brand</th>
|
||||
<th className="px-5 py-4 font-semibold">Status</th>
|
||||
<th className="px-5 py-4 font-semibold" />
|
||||
<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} className="hover:bg-[var(--admin-bg-subtle)] transition-colors">
|
||||
<td className="px-5 py-4"><Skeleton variant="rect" className="h-5 w-5" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-24 h-4" /></td>
|
||||
<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-16 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-16 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-20 h-4" /></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 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={7} className="px-5 py-10 text-center text-sm text-[var(--admin-text-muted)]">
|
||||
{search || statusFilter !== "all"
|
||||
? "No stops match your search."
|
||||
: "No stops found. Create one to get started."}
|
||||
<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>
|
||||
) : (
|
||||
@@ -268,16 +345,55 @@ export default function StopTableClient({ stops }: Props) {
|
||||
onDeleted={handleDeleted}
|
||||
onDeleteError={setDeleteError}
|
||||
isSelected={selectedStops.has(stop.id)}
|
||||
onToggleSelect={() => toggleStopSelection(stop.id)}
|
||||
onToggleSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleStopSelection(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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -289,7 +405,7 @@ function StopRowBase({
|
||||
onDeleted: () => void;
|
||||
onDeleteError: (msg: string) => void;
|
||||
isSelected: boolean;
|
||||
onToggleSelect: () => void;
|
||||
onToggleSelect: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
@@ -299,21 +415,27 @@ function StopRowBase({
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||
|
||||
async function handlePublish(stopId: string) {
|
||||
setPublishingId(stopId);
|
||||
setOpenMenu(null);
|
||||
const result = await publishStop(stopId, stop.brand_id);
|
||||
const { date, weekday } = formatDate(stop.date);
|
||||
const time = formatTime(stop.time);
|
||||
|
||||
function goToEdit() {
|
||||
startTransition(() => router.push(`/admin/stops/${stop.id}`));
|
||||
}
|
||||
|
||||
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");
|
||||
startTransition(() => router.refresh());
|
||||
onDeleted();
|
||||
} else {
|
||||
showError("Failed to publish", result.error ?? "Please try again");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(stopId: string) {
|
||||
setDeletingId(stopId);
|
||||
async function handleDelete() {
|
||||
setDeletingId(stop.id);
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/delete_stop`,
|
||||
{
|
||||
@@ -322,7 +444,7 @@ function StopRowBase({
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_stop_id: stopId, p_brand_id: stop.brand_id }),
|
||||
body: JSON.stringify({ p_stop_id: stop.id, p_brand_id: stop.brand_id }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
@@ -338,69 +460,88 @@ function StopRowBase({
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className={`hover:bg-[var(--admin-bg-subtle)] transition-colors relative ${isSelected ? 'bg-[var(--admin-accent-light)]/30' : ''}`}>
|
||||
<td className="px-5 py-4">
|
||||
<tr
|
||||
onClick={goToEdit}
|
||||
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={onToggleSelect}
|
||||
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
|
||||
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-5 py-4">
|
||||
<Link
|
||||
href={`/admin/stops/${stop.id}`}
|
||||
className="font-medium text-[var(--admin-text-primary)] hover:text-[var(--admin-accent)] transition-colors"
|
||||
>
|
||||
{stop.city}, {stop.state}
|
||||
</Link>
|
||||
|
||||
<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-5 py-4 text-[var(--admin-text-secondary)]">{stop.location}</td>
|
||||
|
||||
<td className="px-5 py-4 font-mono text-[var(--admin-text-muted)] text-xs">{stop.date}</td>
|
||||
|
||||
<td className="px-5 py-4 font-mono text-[var(--admin-text-muted)] text-xs">{stop.time}</td>
|
||||
|
||||
<td className="px-5 py-4 text-[var(--admin-text-secondary)]">
|
||||
{Array.isArray(stop.brands)
|
||||
? stop.brands[0]?.name
|
||||
: stop.brands?.name}
|
||||
<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-5 py-4">
|
||||
<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={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold ${
|
||||
stop.status === "draft"
|
||||
? "bg-amber-100 text-amber-700 border border-amber-200"
|
||||
? "bg-amber-50 text-amber-700 border border-amber-200"
|
||||
: stop.active
|
||||
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)]"
|
||||
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)] border border-[var(--admin-border)]"
|
||||
? "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-5 py-4 text-right">
|
||||
<div className="relative inline-flex items-center justify-end gap-2">
|
||||
<Link
|
||||
href={`/admin/stops/${stop.id}`}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<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"/>
|
||||
<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>
|
||||
|
||||
@@ -408,36 +549,42 @@ function StopRowBase({
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
|
||||
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" && (
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onClick={() => handlePublish(stop.id)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePublish();
|
||||
}}
|
||||
disabled={publishingId === stop.id}
|
||||
className="!justify-start !text-[var(--admin-accent)] hover:!bg-[var(--admin-accent-light)]"
|
||||
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"}
|
||||
</AdminButton>
|
||||
{publishingId === stop.id ? "Publishing…" : "Publish"}
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
<Link
|
||||
href={`/admin/stops/new?duplicate=${stop.id}`}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
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
|
||||
</a>
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onClick={() => { setOpenMenu(null); setConfirmDelete(stop.id); }}
|
||||
className="!justify-start !text-[var(--admin-danger)] hover:!bg-[var(--admin-danger)]/10"
|
||||
</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
|
||||
</AdminButton>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -446,7 +593,11 @@ function StopRowBase({
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-30"
|
||||
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
||||
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)]">
|
||||
@@ -455,22 +606,29 @@ function StopRowBase({
|
||||
<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">
|
||||
<div className="mt-4 flex gap-2 justify-end">
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(null);
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => 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"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="flex gap-3">
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
onClick={() => setShowImport(true)}
|
||||
icon={
|
||||
<svg className="h-4 w-4" 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"
|
||||
onClick={() => setShowAdd(true)}
|
||||
icon={
|
||||
<svg className="h-4 w-4" 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>
|
||||
|
||||
{showImport && (
|
||||
<ScheduleImportModal
|
||||
brandId={brandId}
|
||||
onClose={() => setShowImport(false)}
|
||||
onComplete={handleImportComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddStopModal
|
||||
isOpen={showAdd}
|
||||
onClose={() => setShowAdd(false)}
|
||||
brandId={brandId}
|
||||
onSuccess={handleAddSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PinIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path 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 d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function StopsLocationsTabs({
|
||||
active,
|
||||
stopCount,
|
||||
locationCount,
|
||||
stopsSublabel,
|
||||
locationsSublabel,
|
||||
}: Props) {
|
||||
const tabs = [
|
||||
{
|
||||
value: "stops" as const,
|
||||
label: "Stops",
|
||||
count: stopCount,
|
||||
sublabel: stopsSublabel,
|
||||
icon: <StopIcon />,
|
||||
href: "/admin/stops",
|
||||
},
|
||||
{
|
||||
value: "locations" as const,
|
||||
label: "Locations",
|
||||
count: locationCount,
|
||||
sublabel: locationsSublabel,
|
||||
icon: <PinIcon />,
|
||||
href: "/admin/stops?tab=locations",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-stretch gap-1.5 px-3 py-2.5 border-b border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]"
|
||||
role="tablist"
|
||||
aria-label="Stops and Locations tabs"
|
||||
>
|
||||
{tabs.map((t) => {
|
||||
const isActive = t.value === active;
|
||||
return (
|
||||
<Link
|
||||
key={t.value}
|
||||
href={t.href}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
className={`
|
||||
group relative flex items-center gap-2.5 rounded-xl px-3.5 py-2
|
||||
text-sm font-semibold transition-all duration-200
|
||||
${isActive
|
||||
? "bg-white text-emerald-700 border border-emerald-200 shadow-sm"
|
||||
: "text-stone-600 border border-transparent hover:text-emerald-700 hover:bg-emerald-50/40"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`flex-shrink-0 transition-colors ${isActive ? "text-emerald-600" : "text-stone-400 group-hover:text-emerald-500"}`}
|
||||
aria-hidden
|
||||
>
|
||||
{t.icon}
|
||||
</span>
|
||||
<span>{t.label}</span>
|
||||
<span
|
||||
className={`
|
||||
inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5
|
||||
text-[11px] font-bold tabular-nums
|
||||
${isActive
|
||||
? "bg-emerald-600 text-white"
|
||||
: "bg-stone-200/70 text-stone-600 group-hover:bg-emerald-100 group-hover:text-emerald-700"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{t.count}
|
||||
</span>
|
||||
{t.sublabel && (
|
||||
<span
|
||||
className={`hidden sm:inline text-[11px] font-medium tabular-nums ${isActive ? "text-stone-500" : "text-stone-400"}`}
|
||||
>
|
||||
· {t.sublabel}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={tabKey}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user