feat(stops): align with products page design
Deploy to route.crispygoat.com / deploy (push) Successful in 4m4s
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user