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 { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system"; import StopTableClient from "@/components/admin/StopTableClient";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
const StopIcon = () => ( const StopIcon = () => (
@@ -25,7 +25,6 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
let stops: unknown[] = []; let stops: unknown[] = [];
let error: string | null = null; let error: string | null = null;
let totalCount = 0;
// Resolve active brand from cookie/URL (respects platform_admin "All brands" = null) // Resolve active brand from cookie/URL (respects platform_admin "All brands" = null)
let activeBrandId: string | null = null; let activeBrandId: string | null = null;
@@ -40,11 +39,6 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
? "1=1" ? "1=1"
: `brand_id IN ('${(adminUser.brand_ids ?? []).join("','")}')`; : `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( const { rows } = await pool.query(
`SELECT s.id, s.city, s.state, s.date, s."time", s.location, s.status, `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, s.brand_id, s.address, s.zip, s.cutoff_date,
@@ -56,7 +50,7 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
LIMIT 200` LIMIT 200`
); );
stops = rows; stops = rows;
console.log("[admin/stops] Fetched", rows.length, "stops, total:", totalCount); console.log("[admin/stops] Fetched", rows.length, "stops");
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : String(e); error = e instanceof Error ? e.message : String(e);
console.error("[admin/stops] Query error:", error); 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 ( 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"> <div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<PageHeader <StopTableClient stops={stopsForClient} />
breadcrumb={[
{ label: "Admin", href: "/admin" },
{ label: "Stops & Routes" }
]}
icon={<StopIcon />}
title="Stops & Routes"
subtitle={`${totalCount} stops total`}
/>
</div> </div>
<div className="px-4 sm:px-6 md:px-8"> </div>
{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>
); );
} }
+506 -225
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
/* eslint-disable react-hooks/set-state-in-effect */ /* 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 Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { publishStop, deleteStop } from "@/actions/stops"; import { publishStop, deleteStop } from "@/actions/stops";
@@ -10,6 +10,7 @@ import {
AdminFilterTabs, AdminFilterTabs,
AdminButton, AdminButton,
AdminIconButton, AdminIconButton,
AdminViewModeTabs,
useToast, useToast,
Skeleton, Skeleton,
AdminPagination, AdminPagination,
@@ -38,6 +39,7 @@ type Props = {
hideInternalFilterBar?: boolean; hideInternalFilterBar?: boolean;
}; };
type ViewMode = "table" | "cards";
type SortField = "date" | "city" | "location" | "status"; type SortField = "date" | "city" | "location" | "status";
type SortDirection = "asc" | "desc"; type SortDirection = "asc" | "desc";
@@ -48,6 +50,42 @@ const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "acti
draft: "draft", 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) { export default function StopTableClient({ stops, hideInternalFilterBar = false }: Props) {
const router = useRouter(); const router = useRouter();
const { success: showSuccess, error: showError } = useToast(); const { success: showSuccess, error: showError } = useToast();
@@ -64,6 +102,7 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
const [showAddModal, setShowAddModal] = useState(false); const [showAddModal, setShowAddModal] = useState(false);
const [sortField, setSortField] = useState<SortField>("date"); const [sortField, setSortField] = useState<SortField>("date");
const [sortDirection, setSortDirection] = useState<SortDirection>("asc"); const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
const [viewMode, setViewMode] = useState<ViewMode>("table");
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
useEffect(() => { useEffect(() => {
@@ -118,7 +157,6 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
comparison = a.location.localeCompare(b.location); comparison = a.location.localeCompare(b.location);
break; break;
case "status": case "status":
// Draft first, then inactive, then active
const statusOrder = { draft: 0, inactive: 1, active: 2 }; const statusOrder = { draft: 0, inactive: 1, active: 2 };
const aStatus = a.status === "draft" ? "draft" : a.active ? "active" : "inactive"; const aStatus = a.status === "draft" ? "draft" : a.active ? "active" : "inactive";
const bStatus = b.status === "draft" ? "draft" : b.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 ( return (
<> <>
{/* Stats Bar */} {/* Page Header */}
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4"> <div className="flex items-center justify-between mb-6">
<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-3">
<div className="flex items-center gap-2"> <div className="flex h-11 w-11 items-center justify-center rounded-xl bg-[var(--admin-accent)]/10">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100/70"> <StopIconHeader />
<svg className="h-4 w-4 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> </div>
<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" /> <div>
</svg> <h1 className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] tracking-tight">
</div> Stops & Routes
<div> </h1>
<p className="text-2xl font-bold text-stone-800 tabular-nums">{stats.total}</p> <p className="text-xs text-[var(--admin-text-muted)]">
<p className="text-xs font-medium text-stone-500">Total Stops</p> {sorted.length} stop{sorted.length !== 1 ? "s" : ""}
</div> {search || statusFilter !== "all" ? " matching filters" : ""}
</p>
</div> </div>
</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 items-center gap-2"> <AdminButton
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100/70"> variant="secondary"
<svg className="h-4 w-4 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> size="sm"
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> onClick={() => setShowImport(true)}
</svg> icon={Icons.upload("h-4 w-4")}
</div> >
<div> Upload
<p className="text-2xl font-bold text-stone-800 tabular-nums">{stats.active}</p> </AdminButton>
<p className="text-xs font-medium text-stone-500">Active</p> <AdminButton
</div> variant="primary"
</div> size="sm"
</div> onClick={() => setShowAddModal(true)}
<div className="rounded-xl border border-sky-200/60 bg-gradient-to-br from-sky-50/80 to-blue-50/40 p-4"> icon={Icons.plus("h-4 w-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"> Add Stop
<svg className="h-4 w-4 text-sky-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> </AdminButton>
<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> </div>
</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 && ( {!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 <AdminSearchInput
placeholder="Search stops…"
value={search} value={search}
onChange={(e) => { onChange={(e) => {
setSearch(e.target.value); setSearch(e.target.value);
@@ -304,52 +358,19 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
setSearch(""); setSearch("");
setPage(0); setPage(0);
}} }}
showClear placeholder="Search stops..."
className="flex-1 min-w-[180px] max-w-[280px]" containerClassName="flex-1"
/> />
<AdminFilterTabs <AdminViewModeTabs
activeTab={statusFilter} activeTab={viewMode}
onTabChange={(value) => { onTabChange={(value) => setViewMode(value as ViewMode)}
setStatusFilter(TAB_NUMERIC[value as keyof typeof TAB_NUMERIC] ?? "all");
setPage(0);
}}
tabs={tabs}
size="sm"
showCounts
/> />
<span className="text-xs text-[var(--admin-text-muted)] ml-auto tabular-nums">
{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> </div>
)} )}
{/* Bulk actions bar */} {/* Bulk actions bar */}
{selectedStops.size > 0 && ( {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"> <div className="flex items-center gap-3">
<span className="text-sm font-semibold text-emerald-800 tabular-nums"> <span className="text-sm font-semibold text-emerald-800 tabular-nums">
{selectedStops.size} stop{selectedStops.size !== 1 ? "s" : ""} selected {selectedStops.size} stop{selectedStops.size !== 1 ? "s" : ""} selected
@@ -374,7 +395,7 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
{/* Delete error */} {/* Delete error */}
{deleteError && ( {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}{" "} {deleteError}{" "}
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline"> <button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
Dismiss Dismiss
@@ -382,107 +403,44 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
</div> </div>
)} )}
{/* Table */} {/* Content */}
<div className="overflow-x-auto"> {viewMode === "table" ? (
<table className="w-full text-left text-sm"> <TableView
<thead className="bg-stone-100/70 text-[var(--admin-text-muted)] text-xs uppercase tracking-wide"> stops={paginatedStops}
<tr> sortField={sortField}
<th className="w-10 px-4 py-3"> sortDirection={sortDirection}
<input onSort={handleSort}
type="checkbox" onEdit={setEditingStop}
checked={selectedStops.size === paginatedStops.length && paginatedStops.length > 0} onDelete={(id) => {
onChange={toggleSelectAll} const stop = stops.find((s) => s.id === id);
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer" if (stop) {
aria-label="Select all" setEditingStop(stop);
/> }
</th> }}
<th className="px-4 py-3 font-semibold cursor-pointer hover:text-stone-600 transition-colors" onClick={() => handleSort("date")}> onDeleted={handleDeleted}
<span className="inline-flex items-center"> onDeleteError={setDeleteError}
When selectedStops={selectedStops}
<SortIcon field="date" /> onToggleSelectAll={toggleSelectAll}
</span> onToggleSelect={toggleStopSelection}
</th> isLoading={isLoading}
<th className="px-4 py-3 font-semibold cursor-pointer hover:text-stone-600 transition-colors" onClick={() => handleSort("city")}> search={search}
<span className="inline-flex items-center"> statusFilter={statusFilter}
Where />
<SortIcon field="city" /> ) : (
</span> <CardView
</th> stops={paginatedStops}
<th className="px-4 py-3 font-semibold cursor-pointer hover:text-stone-600 transition-colors" onClick={() => handleSort("location")}> onEdit={setEditingStop}
<span className="inline-flex items-center"> onDeleted={handleDeleted}
Venue onDeleteError={setDeleteError}
<SortIcon field="location" /> isLoading={isLoading}
</span> search={search}
</th> statusFilter={statusFilter}
<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>
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {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)]"> <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} Showing {page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, sorted.length)} of {sorted.length}
</span> </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 } { function formatDate(iso: string): { date: string; weekday: string; isToday: boolean; isPast: boolean } {
const [y, m, d] = iso.split("-").map((s) => parseInt(s, 10)); const [y, m, d] = iso.split("-").map((s) => parseInt(s, 10));
if (!y || !m || !d) return { date: iso, weekday: "", isToday: false, isPast: false }; if (!y || !m || !d) return { date: iso, weekday: "", isToday: false, isPast: false };
const today = new Date(); const today = new Date();
const todayStr = today.toISOString().split("T")[0]; const todayStr = today.toISOString().split("T")[0];
const stopDate = new Date(y, m - 1, d);
const dt = new Date(y, m - 1, d); const dt = new Date(y, m - 1, d);
const weekday = dt.toLocaleDateString("en-US", { weekday: "short" }); const weekday = dt.toLocaleDateString("en-US", { weekday: "short" });
@@ -546,20 +697,24 @@ function formatTime(t: string): string {
return `${hh}:${mStr} ${period}`; return `${hh}:${mStr} ${period}`;
} }
function StopRowBase({ // ── Row Component ───────────────────────────────────────────────────────────────
function StopRow({
stop, stop,
onEdit,
onDelete,
onDeleted, onDeleted,
onDeleteError, onDeleteError,
isSelected, isSelected,
onToggleSelect, onToggleSelect,
onEdit,
}: { }: {
stop: Stop; stop: Stop;
onEdit: () => void;
onDelete: () => void;
onDeleted: () => void; onDeleted: () => void;
onDeleteError: (msg: string) => void; onDeleteError: (msg: string) => void;
isSelected: boolean; isSelected: boolean;
onToggleSelect: (e: React.MouseEvent) => void; onToggleSelect: () => void;
onEdit: () => void;
}) { }) {
const { success: showSuccess, error: showError } = useToast(); const { success: showSuccess, error: showError } = useToast();
const [openMenu, setOpenMenu] = useState<string | null>(null); const [openMenu, setOpenMenu] = useState<string | null>(null);
@@ -610,7 +765,10 @@ function StopRowBase({
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected}
onChange={() => {}} 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" 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}`} aria-label={`Select stop in ${stop.city}`}
/> />
@@ -654,7 +812,7 @@ function StopRowBase({
<td className="px-4 py-3.5"> <td className="px-4 py-3.5">
<span <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" stop.status === "draft"
? "bg-amber-100 text-amber-700 border border-amber-200" ? "bg-amber-100 text-amber-700 border border-amber-200"
: stop.active : stop.active
@@ -677,23 +835,26 @@ function StopRowBase({
<td className="px-4 py-3.5 text-right" onClick={(e) => e.stopPropagation()}> <td className="px-4 py-3.5 text-right" onClick={(e) => e.stopPropagation()}>
<div className="relative inline-flex items-center justify-end gap-1"> <div className="relative inline-flex items-center justify-end gap-1">
<AdminIconButton <AdminButton
variant="ghost" variant="ghost"
size="sm" size="sm"
label="More options" onClick={onEdit}
>
Edit
</AdminButton>
<button
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setOpenMenu(openMenu === stop.id ? null : stop.id); 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"> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="10" cy="4" r="1.5" /> <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" />
<circle cx="10" cy="10" r="1.5" />
<circle cx="10" cy="16" r="1.5" />
</svg> </svg>
</AdminIconButton> </button>
{openMenu === stop.id && ( {openMenu === stop.id && (
<> <>
@@ -705,25 +866,13 @@ function StopRowBase({
setConfirmDelete(null); 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"> <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">
<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>
{stop.status === "draft" && ( {stop.status === "draft" && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handlePublish(); handlePublish();
setOpenMenu(null);
}} }}
disabled={publishingId === stop.id} 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" 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); 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"> <p className="text-sm font-semibold text-stone-800">
Delete "{stop.city}, {stop.state}"? Delete "{stop.city}, {stop.state}"?
</p> </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>
);
}