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:
2026-06-04 15:48:15 +00:00
parent 1feba25ced
commit bc29c70e8b
6 changed files with 550 additions and 290 deletions
+68 -61
View File
@@ -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>