feat(stops): align with products page design
Deploy to route.crispygoat.com / deploy (push) Successful in 4m4s

- Use StopTableClient in server page component
- Add view mode toggle (table/card) matching products page
- Match stats cards styling to products page
- Add consistent filter bar layout with AdminViewModeTabs
- Add card view for stops with inline delete confirm
- Improve row actions with Edit button + dropdown menu
- Add 'Today' badge and past stop dimming in card view
This commit is contained in:
Tyler
2026-06-10 14:59:42 -06:00
parent b1d4174721
commit 244551ce70
2 changed files with 529 additions and 302 deletions
+23 -77
View File
@@ -2,7 +2,7 @@ import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system";
import StopTableClient from "@/components/admin/StopTableClient";
import { redirect } from "next/navigation";
const StopIcon = () => (
@@ -25,7 +25,6 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
let stops: unknown[] = [];
let error: string | null = null;
let totalCount = 0;
// Resolve active brand from cookie/URL (respects platform_admin "All brands" = null)
let activeBrandId: string | null = null;
@@ -40,11 +39,6 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
? "1=1"
: `brand_id IN ('${(adminUser.brand_ids ?? []).join("','")}')`;
const countResult = await pool.query<{ count: string }>(
`SELECT count(*) as count FROM stops WHERE ${brandCondition}`
);
totalCount = parseInt(countResult.rows[0]?.count ?? "0", 10);
const { rows } = await pool.query(
`SELECT s.id, s.city, s.state, s.date, s."time", s.location, s.status,
s.brand_id, s.address, s.zip, s.cutoff_date,
@@ -56,7 +50,7 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
LIMIT 200`
);
stops = rows;
console.log("[admin/stops] Fetched", rows.length, "stops, total:", totalCount);
console.log("[admin/stops] Fetched", rows.length, "stops");
} catch (e) {
error = e instanceof Error ? e.message : String(e);
console.error("[admin/stops] Query error:", error);
@@ -88,76 +82,28 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
);
}
// Transform stops for the client component
const stopsForClient = stops.map((s: any) => ({
id: s.id,
city: s.city,
state: s.state,
date: s.date instanceof Date ? s.date.toISOString().split("T")[0] : String(s.date).split("T")[0],
time: s.time || "",
location: s.location,
active: s.status === "active",
brand_id: s.brand_id,
status: s.status,
address: s.address,
zip: s.zip,
cutoff_time: s.cutoff_date,
brands: s.brand_name ? { name: s.brand_name } : null,
}));
return (
<main className="min-h-screen bg-[var(--admin-bg)]">
<div 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={`${totalCount} stops total`}
/>
<StopTableClient stops={stopsForClient} />
</div>
<div className="px-4 sm:px-6 md:px-8">
{stops.length === 0 ? (
<div className="rounded-2xl border border-dashed border-stone-300 bg-white p-16 text-center">
<div className="mx-auto mb-4 h-14 w-14 rounded-full bg-stone-100 flex items-center justify-center">
<svg className="h-7 w-7 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<h3 className="font-display text-2xl font-medium text-stone-950">No stops found</h3>
<p className="mt-2 text-stone-500">
{activeBrandId
? "No stops for the selected brand. Upload a schedule or add stops manually."
: "No stops in the database."}
</p>
<div className="mt-6 flex justify-center gap-3">
<a
href="/admin/stops/new"
className="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-500"
>
Add Stop
</a>
</div>
</div>
) : (
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-stone-50 border-b border-stone-200">
<tr>
<th className="px-4 py-3 font-semibold text-stone-600">City</th>
<th className="px-4 py-3 font-semibold text-stone-600">Location</th>
<th className="px-4 py-3 font-semibold text-stone-600">Date</th>
<th className="px-4 py-3 font-semibold text-stone-600">Status</th>
<th className="px-4 py-3 font-semibold text-stone-600">Brand</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{stops.map((s: any) => (
<tr key={s.id} className="hover:bg-stone-50">
<td className="px-4 py-3 font-medium text-stone-900">{s.city}, {s.state}</td>
<td className="px-4 py-3 text-stone-600">{s.location}</td>
<td className="px-4 py-3 font-mono text-stone-500">{String(s.date)}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
s.status === "active" ? "bg-emerald-100 text-emerald-700" :
s.status === "draft" ? "bg-amber-100 text-amber-700" :
"bg-stone-100 text-stone-500"
}`}>{s.status}</span>
</td>
<td className="px-4 py-3 text-stone-500 text-xs">{s.brand_name ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</main>
</div>
);
}
}
+506 -225
View File
@@ -1,7 +1,7 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import React, { useState, useTransition, useEffect, useMemo, useCallback } from "react";
import React, { useState, useTransition, useEffect, useMemo, useCallback, useRef } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { publishStop, deleteStop } from "@/actions/stops";
@@ -10,6 +10,7 @@ import {
AdminFilterTabs,
AdminButton,
AdminIconButton,
AdminViewModeTabs,
useToast,
Skeleton,
AdminPagination,
@@ -38,6 +39,7 @@ type Props = {
hideInternalFilterBar?: boolean;
};
type ViewMode = "table" | "cards";
type SortField = "date" | "city" | "location" | "status";
type SortDirection = "asc" | "desc";
@@ -48,6 +50,42 @@ const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "acti
draft: "draft",
};
// Icons
const Icons = {
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
plus: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14"/>
</svg>
),
pin: (className: string) => (
<svg className={className} 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>
),
upload: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
),
};
// Page header icon
const StopIconHeader = () => (
<svg className="h-6 w-6" 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>
);
export default function StopTableClient({ stops, hideInternalFilterBar = false }: Props) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
@@ -64,6 +102,7 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
const [showAddModal, setShowAddModal] = useState(false);
const [sortField, setSortField] = useState<SortField>("date");
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
const [viewMode, setViewMode] = useState<ViewMode>("table");
const PAGE_SIZE = 25;
useEffect(() => {
@@ -118,7 +157,6 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
comparison = a.location.localeCompare(b.location);
break;
case "status":
// Draft first, then inactive, then active
const statusOrder = { draft: 0, inactive: 1, active: 2 };
const aStatus = a.status === "draft" ? "draft" : a.active ? "active" : "inactive";
const bStatus = b.status === "draft" ? "draft" : b.active ? "active" : "inactive";
@@ -234,67 +272,83 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
return (
<>
{/* Stats Bar */}
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="rounded-xl border border-amber-200/60 bg-gradient-to-br from-amber-50/80 to-orange-50/40 p-4">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100/70">
<svg className="h-4 w-4 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<div>
<p className="text-2xl font-bold text-stone-800 tabular-nums">{stats.total}</p>
<p className="text-xs font-medium text-stone-500">Total Stops</p>
</div>
{/* Page Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-[var(--admin-accent)]/10">
<StopIconHeader />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] tracking-tight">
Stops & Routes
</h1>
<p className="text-xs text-[var(--admin-text-muted)]">
{sorted.length} stop{sorted.length !== 1 ? "s" : ""}
{search || statusFilter !== "all" ? " matching filters" : ""}
</p>
</div>
</div>
<div className="rounded-xl border border-emerald-200/60 bg-gradient-to-br from-emerald-50/80 to-teal-50/40 p-4">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100/70">
<svg className="h-4 w-4 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p className="text-2xl font-bold text-stone-800 tabular-nums">{stats.active}</p>
<p className="text-xs font-medium text-stone-500">Active</p>
</div>
</div>
</div>
<div className="rounded-xl border border-sky-200/60 bg-gradient-to-br from-sky-50/80 to-blue-50/40 p-4">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-100/70">
<svg className="h-4 w-4 text-sky-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div>
<p className="text-2xl font-bold text-stone-800 tabular-nums">{stats.upcoming}</p>
<p className="text-xs font-medium text-stone-500">Upcoming</p>
</div>
</div>
</div>
<div className="rounded-xl border border-stone-200/60 bg-gradient-to-br from-stone-50/80 to-gray-50/40 p-4">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-stone-100/70">
<svg className="h-4 w-4 text-stone-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
<div>
<p className="text-2xl font-bold text-stone-800 tabular-nums">{stats.draft}</p>
<p className="text-xs font-medium text-stone-500">Drafts</p>
</div>
</div>
<div className="flex items-center gap-2">
<AdminButton
variant="secondary"
size="sm"
onClick={() => setShowImport(true)}
icon={Icons.upload("h-4 w-4")}
>
Upload
</AdminButton>
<AdminButton
variant="primary"
size="sm"
onClick={() => setShowAddModal(true)}
icon={Icons.plus("h-4 w-4")}
>
Add Stop
</AdminButton>
</div>
</div>
{/* Filter bar */}
{/* Stats Cards */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 mb-6">
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1 tabular-nums">
{isLoading ? <Skeleton variant="text" className="w-12 h-6" /> : stats.total}
</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Active</p>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-accent)] mt-1 tabular-nums">
{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : stats.active}
</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Upcoming</p>
<p className="text-xl sm:text-2xl font-bold text-sky-600 mt-1 tabular-nums">
{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : stats.upcoming}
</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Drafts</p>
<p className="text-xl sm:text-2xl font-bold text-stone-400 mt-1 tabular-nums">
{isLoading ? <Skeleton variant="text" className="w-8 h-6" /> : stats.draft}
</p>
</div>
</div>
{/* Filters */}
{!hideInternalFilterBar && (
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(value) => {
setStatusFilter(TAB_NUMERIC[value as keyof typeof TAB_NUMERIC] ?? "all");
setPage(0);
}}
tabs={tabs}
size="md"
/>
<AdminSearchInput
placeholder="Search stops…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
@@ -304,52 +358,19 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
setSearch("");
setPage(0);
}}
showClear
className="flex-1 min-w-[180px] max-w-[280px]"
placeholder="Search stops..."
containerClassName="flex-1"
/>
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(value) => {
setStatusFilter(TAB_NUMERIC[value as keyof typeof TAB_NUMERIC] ?? "all");
setPage(0);
}}
tabs={tabs}
size="sm"
showCounts
<AdminViewModeTabs
activeTab={viewMode}
onTabChange={(value) => setViewMode(value as ViewMode)}
/>
<span className="text-xs text-[var(--admin-text-muted)] ml-auto tabular-nums">
{sorted.length} stop{sorted.length !== 1 ? "s" : ""}
</span>
<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
</AdminButton>
<AdminButton
variant="primary"
size="sm"
onClick={() => setShowAddModal(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="mb-4 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
@@ -374,7 +395,7 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
{/* 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">
<div className="mb-4 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
@@ -382,107 +403,44 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
</div>
)}
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-stone-100/70 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 cursor-pointer hover:text-stone-600 transition-colors" onClick={() => handleSort("date")}>
<span className="inline-flex items-center">
When
<SortIcon field="date" />
</span>
</th>
<th className="px-4 py-3 font-semibold cursor-pointer hover:text-stone-600 transition-colors" onClick={() => handleSort("city")}>
<span className="inline-flex items-center">
Where
<SortIcon field="city" />
</span>
</th>
<th className="px-4 py-3 font-semibold cursor-pointer hover:text-stone-600 transition-colors" onClick={() => handleSort("location")}>
<span className="inline-flex items-center">
Venue
<SortIcon field="location" />
</span>
</th>
<th className="px-4 py-3 font-semibold cursor-pointer hover:text-stone-600 transition-colors" onClick={() => handleSort("status")}>
<span className="inline-flex items-center">
Status
<SortIcon field="status" />
</span>
</th>
<th className="w-20 px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{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>
))
) : sorted.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-14 w-14 rounded-2xl bg-gradient-to-br from-stone-100 to-stone-50 flex items-center justify-center">
<svg className="h-7 w-7 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);
}}
onEdit={() => setEditingStop(stop)}
/>
))
)}
</tbody>
</table>
</div>
{/* Content */}
{viewMode === "table" ? (
<TableView
stops={paginatedStops}
sortField={sortField}
sortDirection={sortDirection}
onSort={handleSort}
onEdit={setEditingStop}
onDelete={(id) => {
const stop = stops.find((s) => s.id === id);
if (stop) {
setEditingStop(stop);
}
}}
onDeleted={handleDeleted}
onDeleteError={setDeleteError}
selectedStops={selectedStops}
onToggleSelectAll={toggleSelectAll}
onToggleSelect={toggleStopSelection}
isLoading={isLoading}
search={search}
statusFilter={statusFilter}
/>
) : (
<CardView
stops={paginatedStops}
onEdit={setEditingStop}
onDeleted={handleDeleted}
onDeleteError={setDeleteError}
isLoading={isLoading}
search={search}
statusFilter={statusFilter}
/>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-stone-100 px-4 py-3">
<div className="flex items-center justify-between border-t border-[var(--admin-border)] mt-4 pt-4">
<span className="text-xs text-[var(--admin-text-muted)]">
Showing {page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, sorted.length)} of {sorted.length}
</span>
@@ -516,13 +474,206 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
);
}
// ── Table View ─────────────────────────────────────────────────────────────────
function TableView({
stops,
sortField,
sortDirection,
onSort,
onEdit,
onDelete,
onDeleted,
onDeleteError,
selectedStops,
onToggleSelectAll,
onToggleSelect,
isLoading,
search,
statusFilter,
}: {
stops: Stop[];
sortField: SortField;
sortDirection: SortDirection;
onSort: (field: SortField) => void;
onEdit: (stop: Stop) => void;
onDelete: (id: string) => void;
onDeleted: () => void;
onDeleteError: (msg: string) => void;
selectedStops: Set<string>;
onToggleSelectAll: () => void;
onToggleSelect: (id: string) => void;
isLoading: boolean;
search: string;
statusFilter: string;
}) {
return (
<div className="overflow-visible rounded-xl border border-[var(--admin-border)] bg-white">
<table className="w-full text-sm">
<thead className="bg-stone-50">
<tr>
<th className="w-10 px-4 py-3">
<input
type="checkbox"
checked={selectedStops.size === stops.length && stops.length > 0}
onChange={onToggleSelectAll}
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="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs cursor-pointer hover:text-stone-600 transition-colors" onClick={() => onSort("date")}>
<span className="inline-flex items-center">
When
<SortIcon field="date" sortField={sortField} sortDirection={sortDirection} />
</span>
</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs cursor-pointer hover:text-stone-600 transition-colors" onClick={() => onSort("city")}>
<span className="inline-flex items-center">
Where
<SortIcon field="city" sortField={sortField} sortDirection={sortDirection} />
</span>
</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs cursor-pointer hover:text-stone-600 transition-colors" onClick={() => onSort("location")}>
<span className="inline-flex items-center">
Venue
<SortIcon field="location" sortField={sortField} sortDirection={sortDirection} />
</span>
</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs cursor-pointer hover:text-stone-600 transition-colors" onClick={() => onSort("status")}>
<span className="inline-flex items-center">
Status
<SortIcon field="status" sortField={sortField} sortDirection={sortDirection} />
</span>
</th>
<th className="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>
))
) : stops.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-16 text-center">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
{Icons.pin("h-8 w-8 text-stone-400")}
</div>
<p className="text-sm font-medium text-stone-600">
{search || statusFilter !== "all"
? "No stops match your filters"
: "No stops yet"}
</p>
<p className="text-xs text-stone-400 mt-1">
{search || statusFilter !== "all"
? "Try a different search or clear the filters"
: "Add your first stop to get started"}
</p>
</td>
</tr>
) : (
stops.map((stop) => (
<StopRow
key={stop.id}
stop={stop}
onEdit={() => onEdit(stop)}
onDelete={() => onDelete(stop.id)}
onDeleted={onDeleted}
onDeleteError={onDeleteError}
isSelected={selectedStops.has(stop.id)}
onToggleSelect={() => onToggleSelect(stop.id)}
/>
))
)}
</tbody>
</table>
</div>
);
}
// ── Card View ──────────────────────────────────────────────────────────────────
function CardView({
stops,
onEdit,
onDeleted,
onDeleteError,
isLoading,
search,
statusFilter,
}: {
stops: Stop[];
onEdit: (stop: Stop) => void;
onDeleted: () => void;
onDeleteError: (msg: string) => void;
isLoading: boolean;
search: string;
statusFilter: string;
}) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{stops.length === 0 ? (
<div className="col-span-full text-center py-16 rounded-xl border border-[var(--admin-border)] bg-white">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
{Icons.pin("h-8 w-8 text-stone-400")}
</div>
<p className="text-sm font-medium text-stone-600">
{search || statusFilter !== "all"
? "No stops match your filters"
: "No stops yet"}
</p>
<p className="text-xs text-stone-400 mt-1">
{search || statusFilter !== "all"
? "Try a different search or clear the filters"
: "Add your first stop to get started"}
</p>
</div>
) : (
stops.map((stop) => (
<StopCard
key={stop.id}
stop={stop}
onEdit={() => onEdit(stop)}
onDeleted={onDeleted}
onDeleteError={onDeleteError}
/>
))
)}
</div>
);
}
// ── Shared helpers ─────────────────────────────────────────────────────────────
function SortIcon({ field, sortField, sortDirection }: { field: SortField; sortField: SortField; sortDirection: SortDirection }) {
return (
<span className={`inline-flex ml-1.5 ${sortField === field ? "opacity-100" : "opacity-30"}`}>
{sortField === field && sortDirection === "desc" ? (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
) : (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
)}
</span>
);
}
function formatDate(iso: string): { date: string; weekday: string; isToday: boolean; isPast: boolean } {
const [y, m, d] = iso.split("-").map((s) => parseInt(s, 10));
if (!y || !m || !d) return { date: iso, weekday: "", isToday: false, isPast: false };
const today = new Date();
const todayStr = today.toISOString().split("T")[0];
const stopDate = new Date(y, m - 1, d);
const dt = new Date(y, m - 1, d);
const weekday = dt.toLocaleDateString("en-US", { weekday: "short" });
@@ -546,20 +697,24 @@ function formatTime(t: string): string {
return `${hh}:${mStr} ${period}`;
}
function StopRowBase({
// ── Row Component ───────────────────────────────────────────────────────────────
function StopRow({
stop,
onEdit,
onDelete,
onDeleted,
onDeleteError,
isSelected,
onToggleSelect,
onEdit,
}: {
stop: Stop;
onEdit: () => void;
onDelete: () => void;
onDeleted: () => void;
onDeleteError: (msg: string) => void;
isSelected: boolean;
onToggleSelect: (e: React.MouseEvent) => void;
onEdit: () => void;
onToggleSelect: () => void;
}) {
const { success: showSuccess, error: showError } = useToast();
const [openMenu, setOpenMenu] = useState<string | null>(null);
@@ -610,7 +765,10 @@ function StopRowBase({
type="checkbox"
checked={isSelected}
onChange={() => {}}
onClick={onToggleSelect}
onClick={(e) => {
e.stopPropagation();
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}`}
/>
@@ -654,7 +812,7 @@ function StopRowBase({
<td className="px-4 py-3.5">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold ${
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${
stop.status === "draft"
? "bg-amber-100 text-amber-700 border border-amber-200"
: stop.active
@@ -677,23 +835,26 @@ function StopRowBase({
<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
<AdminButton
variant="ghost"
size="sm"
label="More options"
onClick={onEdit}
>
Edit
</AdminButton>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpenMenu(openMenu === stop.id ? null : stop.id);
}}
className="opacity-60 group-hover:opacity-100"
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors"
aria-label="Stop actions"
>
<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 className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</AdminIconButton>
</button>
{openMenu === stop.id && (
<>
@@ -705,25 +866,13 @@ function StopRowBase({
setConfirmDelete(null);
}}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-xl overflow-hidden">
<button
onClick={(e) => {
e.stopPropagation();
setOpenMenu(null);
onEdit();
}}
className="w-full text-left px-4 py-2.5 text-sm font-medium text-stone-700 hover:bg-stone-50 transition-colors flex items-center gap-2"
>
<svg className="h-4 w-4 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</button>
<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();
setOpenMenu(null);
}}
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 flex items-center gap-2"
@@ -771,7 +920,7 @@ function StopRowBase({
setOpenMenu(null);
}}
/>
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-xl p-4">
<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-stone-800">
Delete "{stop.city}, {stop.state}"?
</p>
@@ -812,4 +961,136 @@ function StopRowBase({
);
}
const StopRow = React.memo(StopRowBase);
// ── Card Component ─────────────────────────────────────────────────────────────
function StopCard({
stop,
onEdit,
onDeleted,
onDeleteError,
}: {
stop: Stop;
onEdit: () => void;
onDeleted: () => void;
onDeleteError: (msg: string) => void;
}) {
const { success: showSuccess, error: showError } = useToast();
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
const { date, weekday, isToday, isPast } = formatDate(stop.date);
const time = formatTime(stop.time);
async function handleDelete() {
setDeleting(true);
const result = await deleteStop(stop.id, stop.brand_id);
setDeleting(false);
if (result.success) {
showSuccess("Stop deleted", "The stop has been removed");
onDeleted();
} else {
onDeleteError(result.error ?? "Delete failed");
}
}
return (
<div
className={`group relative rounded-xl border border-[var(--admin-border)] bg-white hover:shadow-md hover:border-[var(--admin-accent)]/30 transition-all overflow-hidden ${
isPast && stop.status !== "draft" ? "opacity-70" : ""
}`}
>
{/* Header with date */}
<div className="bg-gradient-to-br from-stone-50 to-stone-100/50 px-4 pt-4 pb-3 border-b border-stone-100">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-1.5 mb-1">
{isToday && (
<span className="rounded bg-sky-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-sky-700">
Today
</span>
)}
</div>
<p className="text-lg font-bold text-stone-800 tabular-nums">{date}</p>
<p className="text-xs text-stone-500 tabular-nums">{weekday} {time && `· ${time}`}</p>
</div>
<span
className={`shrink-0 rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${
stop.status === "draft"
? "bg-amber-100 text-amber-700 border border-amber-200"
: stop.active
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "bg-stone-100 text-stone-500 border border-stone-200"
}`}
>
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
</span>
</div>
</div>
{/* Content */}
<div className="p-4">
<button onClick={onEdit} className="block w-full text-left">
<h3 className="font-semibold text-[var(--admin-text-primary)] group-hover:text-[var(--admin-accent)] transition-colors">
{stop.city}, {stop.state}
</h3>
<p className="text-xs text-stone-500 mt-1 line-clamp-2">
{stop.location}
</p>
{stop.address && (
<p className="text-xs text-stone-400 mt-1 line-clamp-1">
{stop.address}
</p>
)}
</button>
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-stone-100">
<AdminButton
variant="secondary"
size="sm"
onClick={onEdit}
className="flex-1"
>
Edit
</AdminButton>
<button
onClick={() => setConfirmDelete(true)}
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-muted)] hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
{/* Delete confirm */}
{confirmDelete && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/95 backdrop-blur-sm">
<div className="bg-white rounded-xl shadow-xl p-4 max-w-[280px] mx-4 border border-[var(--admin-border)]">
<p className="text-sm font-semibold text-stone-900">
Delete "{stop.city}, {stop.state}"?
</p>
<p className="mt-1 text-xs text-stone-500">
This will remove the stop. If it has active orders, you must resolve those first.
</p>
<div className="flex gap-2 mt-3">
<button
onClick={() => setConfirmDelete(false)}
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-red-500"
>
{deleting ? "..." : "Delete"}
</button>
</div>
</div>
</div>
)}
</div>
);
}