feat(stops): add edit modal, sorting, pagination, and stats bar
Deploy to route.crispygoat.com / deploy (push) Successful in 4m3s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m3s
- Add EditStopModal for unified add/edit stop workflow - Enhance StopTableClient with sortable columns (date, city, location, status) - Add stats bar showing total, active, upcoming, and draft counts - Improve pagination with 'Showing X-Y of Z' indicator - Add 'Today' badge for current-day stops - Dim past stops visually - Add Edit action to row menu with icons - Reduce page size to 25 for better UX
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 } from "react";
|
||||
import React, { useState, useTransition, useEffect, useMemo, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { publishStop, deleteStop } from "@/actions/stops";
|
||||
@@ -12,10 +12,10 @@ import {
|
||||
AdminIconButton,
|
||||
useToast,
|
||||
Skeleton,
|
||||
AdminPagination,
|
||||
} from "@/components/admin/design-system";
|
||||
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
|
||||
import AddStopModal from "@/components/admin/AddStopModal";
|
||||
import StopDetailModal from "@/components/admin/StopDetailModal";
|
||||
import EditStopModal from "@/components/admin/EditStopModal";
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
@@ -28,18 +28,19 @@ type Stop = {
|
||||
brand_id: string;
|
||||
status?: string;
|
||||
address?: string | null;
|
||||
zip?: string | null;
|
||||
cutoff_time?: string | null;
|
||||
brands: { name: string } | { name: string }[] | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
stops: Stop[];
|
||||
/**
|
||||
* Hide the internal search/filter bar at the top of the table. Use when
|
||||
* the parent component already renders shared filters (e.g. StopsViewClient).
|
||||
*/
|
||||
hideInternalFilterBar?: boolean;
|
||||
};
|
||||
|
||||
type SortField = "date" | "city" | "location" | "status";
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "active" | "inactive" | "draft"> = {
|
||||
all: "all",
|
||||
active: "active",
|
||||
@@ -59,15 +60,32 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set());
|
||||
const [bulkPublishing, setBulkPublishing] = useState(false);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [openStopId, setOpenStopId] = useState<string | null>(null);
|
||||
const PAGE_SIZE = 50;
|
||||
const [editingStop, setEditingStop] = useState<Stop | null>(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [sortField, setSortField] = useState<SortField>("date");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
const timer = setTimeout(() => setIsLoading(false), 220);
|
||||
return () => clearTimeout(timer);
|
||||
}, [page, statusFilter, search]);
|
||||
}, [page, statusFilter, search, sortField, sortDirection]);
|
||||
|
||||
// Compute stats
|
||||
const stats = useMemo(() => {
|
||||
const now = new Date();
|
||||
const today = now.toISOString().split("T")[0];
|
||||
const activeStops = stops.filter((s) => s.active && s.status !== "draft");
|
||||
const upcomingStops = stops.filter((s) => s.date >= today && s.status !== "draft");
|
||||
const draftStops = stops.filter((s) => s.status === "draft");
|
||||
return {
|
||||
total: stops.length,
|
||||
active: activeStops.length,
|
||||
upcoming: upcomingStops.length,
|
||||
draft: draftStops.length,
|
||||
};
|
||||
}, [stops]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
@@ -75,7 +93,8 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
const matchesSearch =
|
||||
!q ||
|
||||
s.city.toLowerCase().includes(q) ||
|
||||
s.location.toLowerCase().includes(q);
|
||||
s.location.toLowerCase().includes(q) ||
|
||||
s.state.toLowerCase().includes(q);
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "active" && s.active && s.status !== "draft") ||
|
||||
@@ -85,6 +104,31 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
});
|
||||
}, [stops, search, statusFilter]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...filtered].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortField) {
|
||||
case "date":
|
||||
comparison = a.date.localeCompare(b.date);
|
||||
break;
|
||||
case "city":
|
||||
comparison = a.city.localeCompare(b.city);
|
||||
break;
|
||||
case "location":
|
||||
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";
|
||||
comparison = statusOrder[aStatus] - statusOrder[bStatus];
|
||||
break;
|
||||
}
|
||||
return sortDirection === "asc" ? comparison : -comparison;
|
||||
});
|
||||
}, [filtered, sortField, sortDirection]);
|
||||
|
||||
const statusCounts = useMemo(
|
||||
() => ({
|
||||
all: stops.length,
|
||||
@@ -105,8 +149,18 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
[statusCounts]
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
|
||||
const paginatedStops = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
setPage(0);
|
||||
}, [sortField]);
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedStops.size === paginatedStops.length) {
|
||||
@@ -163,13 +217,84 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
|
||||
// Sort icon component
|
||||
const SortIcon = ({ field }: { field: SortField }) => (
|
||||
<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>
|
||||
);
|
||||
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
{!hideInternalFilterBar && (
|
||||
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3 border-b border-[var(--admin-border)]">
|
||||
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3 border-b border-[var(--admin-border)] bg-stone-50/50">
|
||||
<AdminSearchInput
|
||||
placeholder="Search by city or venue…"
|
||||
placeholder="Search stops…"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
@@ -193,39 +318,8 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
showCounts
|
||||
/>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] ml-auto tabular-nums">
|
||||
{filtered.length} stop{filtered.length !== 1 ? "s" : ""}
|
||||
{sorted.length} stop{sorted.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<AdminIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Previous page"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0 || isLoading}
|
||||
className="!rounded-lg border border-[var(--admin-border)]"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</AdminIconButton>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] px-1 tabular-nums">
|
||||
{page + 1}/{totalPages}
|
||||
</span>
|
||||
<AdminIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Next page"
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1 || isLoading}
|
||||
className="!rounded-lg border border-[var(--admin-border)]"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</AdminIconButton>
|
||||
</div>
|
||||
)}
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -236,12 +330,12 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Upload Schedule
|
||||
Upload
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setShowAdd(true)}
|
||||
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" />
|
||||
@@ -289,80 +383,116 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-[var(--admin-bg-subtle)]/40 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">When</th>
|
||||
<th className="px-4 py-3 font-semibold">Where</th>
|
||||
<th className="px-4 py-3 font-semibold">Venue</th>
|
||||
<th className="px-4 py-3 font-semibold">Status</th>
|
||||
<th className="w-12 px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="rect" className="h-4 w-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-24 h-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-32 h-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-40 h-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-16 h-5 rounded-full" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-6 h-6" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : filtered.length === 0 ? (
|
||||
<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>
|
||||
<td colSpan={6} className="px-4 py-14 text-center">
|
||||
<div className="flex flex-col items-center gap-3 text-[var(--admin-text-muted)]">
|
||||
<div className="h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-700">
|
||||
{search || statusFilter !== "all"
|
||||
? "No stops match your filters"
|
||||
: "No stops yet"}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-stone-500">
|
||||
{search || statusFilter !== "all"
|
||||
? "Try a different search or clear the filters above."
|
||||
: "Use the buttons above to upload a schedule or add your first stop."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<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>
|
||||
) : (
|
||||
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);
|
||||
}}
|
||||
onOpen={() => setOpenStopId(stop.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</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 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-stone-100 px-4 py-3">
|
||||
<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>
|
||||
<AdminPagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showImport && (
|
||||
<ScheduleImportModal
|
||||
@@ -372,35 +502,41 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddStopModal
|
||||
isOpen={showAdd}
|
||||
onClose={() => setShowAdd(false)}
|
||||
<EditStopModal
|
||||
isOpen={showAddModal || editingStop !== null}
|
||||
onClose={() => {
|
||||
setShowAddModal(false);
|
||||
setEditingStop(null);
|
||||
}}
|
||||
brandId={stops[0]?.brand_id ?? ""}
|
||||
stop={editingStop}
|
||||
onSuccess={refresh}
|
||||
/>
|
||||
|
||||
{openStopId && (
|
||||
<StopDetailModal
|
||||
stopId={openStopId}
|
||||
onClose={() => setOpenStopId(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): { date: string; weekday: string } {
|
||||
// Stop date is stored as YYYY-MM-DD; parse without TZ shift
|
||||
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: "" };
|
||||
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" });
|
||||
const month = dt.toLocaleDateString("en-US", { month: "short" });
|
||||
return { date: `${month} ${d}`, weekday };
|
||||
|
||||
return {
|
||||
date: `${month} ${d}`,
|
||||
weekday,
|
||||
isToday: iso === todayStr,
|
||||
isPast: iso < todayStr
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(t: string): string {
|
||||
// Stored as HH:MM (24h). Display as 10:00 AM.
|
||||
if (!t) return "";
|
||||
const [hStr, mStr] = t.split(":");
|
||||
const h = parseInt(hStr, 10);
|
||||
@@ -416,14 +552,14 @@ function StopRowBase({
|
||||
onDeleteError,
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
onOpen,
|
||||
onEdit,
|
||||
}: {
|
||||
stop: Stop;
|
||||
onDeleted: () => void;
|
||||
onDeleteError: (msg: string) => void;
|
||||
isSelected: boolean;
|
||||
onToggleSelect: (e: React.MouseEvent) => void;
|
||||
onOpen: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
@@ -431,7 +567,7 @@ function StopRowBase({
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||
|
||||
const { date, weekday } = formatDate(stop.date);
|
||||
const { date, weekday, isToday, isPast } = formatDate(stop.date);
|
||||
const time = formatTime(stop.time);
|
||||
|
||||
async function handlePublish() {
|
||||
@@ -462,8 +598,12 @@ function StopRowBase({
|
||||
|
||||
return (
|
||||
<tr
|
||||
onClick={onOpen}
|
||||
className={`group cursor-pointer transition-colors ${isSelected ? "bg-emerald-50/60" : "hover:bg-[var(--admin-bg-subtle)]/60"}`}
|
||||
onClick={onEdit}
|
||||
className={`group cursor-pointer transition-all duration-150 ${
|
||||
isSelected
|
||||
? "bg-emerald-50/60"
|
||||
: "hover:bg-stone-50/80"
|
||||
} ${isPast && stop.status !== "draft" ? "opacity-60" : ""}`}
|
||||
>
|
||||
<td className="px-4 py-3.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
@@ -478,8 +618,17 @@ function StopRowBase({
|
||||
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-semibold text-[var(--admin-text-primary)] tabular-nums">{date}</span>
|
||||
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{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>
|
||||
)}
|
||||
<span className={`font-semibold tabular-nums ${isPast ? "text-stone-400" : "text-stone-800"}`}>
|
||||
{date}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-stone-500 tabular-nums">
|
||||
{weekday} {time && `· ${time}`}
|
||||
</span>
|
||||
</div>
|
||||
@@ -487,16 +636,16 @@ function StopRowBase({
|
||||
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-semibold text-[var(--admin-text-primary)]">{stop.city}</span>
|
||||
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">{stop.state}</span>
|
||||
<span className={`font-semibold ${isPast ? "text-stone-400" : "text-stone-800"}`}>{stop.city}</span>
|
||||
<span className="text-[11px] text-stone-500 tabular-nums">{stop.state}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="text-[var(--admin-text-secondary)]">{stop.location}</span>
|
||||
<span className="text-stone-600">{stop.location}</span>
|
||||
{stop.address && (
|
||||
<span className="text-[11px] text-[var(--admin-text-muted)] truncate max-w-[260px]">
|
||||
<span className="text-[11px] text-stone-400 truncate max-w-[260px]">
|
||||
{stop.address}
|
||||
</span>
|
||||
)}
|
||||
@@ -505,11 +654,11 @@ function StopRowBase({
|
||||
|
||||
<td className="px-4 py-3.5">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold ${
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold ${
|
||||
stop.status === "draft"
|
||||
? "bg-amber-50 text-amber-700 border border-amber-200"
|
||||
? "bg-amber-100 text-amber-700 border border-amber-200"
|
||||
: stop.active
|
||||
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
|
||||
: "bg-stone-100 text-stone-500 border border-stone-200"
|
||||
}`}
|
||||
>
|
||||
@@ -556,7 +705,20 @@ function StopRowBase({
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl overflow-hidden">
|
||||
<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>
|
||||
{stop.status === "draft" && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -564,16 +726,22 @@ function StopRowBase({
|
||||
handlePublish();
|
||||
}}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<svg className="h-4 w-4 text-emerald-500" 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>
|
||||
{publishingId === stop.id ? "Publishing…" : "Publish"}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href={`/admin/stops/new?duplicate=${stop.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center px-4 py-2.5 text-sm text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Duplicate
|
||||
</Link>
|
||||
<button
|
||||
@@ -582,8 +750,11 @@ function StopRowBase({
|
||||
setOpenMenu(null);
|
||||
setConfirmDelete(stop.id);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="h-4 w-4 text-red-400" 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>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
@@ -600,11 +771,11 @@ function StopRowBase({
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
|
||||
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
||||
Delete "{stop.city}, {stop.state}"?
|
||||
<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">
|
||||
<p className="text-sm font-semibold text-stone-800">
|
||||
Delete "{stop.city}, {stop.state}"?
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs text-[var(--admin-text-muted)]">
|
||||
<p className="mt-1.5 text-xs text-stone-500">
|
||||
This will remove the stop. If it has active orders, you must resolve those first.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2 justify-end">
|
||||
|
||||
Reference in New Issue
Block a user