feat(stops): add edit modal, sorting, pagination, and stats bar
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:
Tyler
2026-06-10 14:45:41 -06:00
parent 001840ab05
commit b1d4174721
2 changed files with 796 additions and 159 deletions
+466
View File
@@ -0,0 +1,466 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { createStop } from "@/actions/stops/create-stop";
import { updateStop } from "@/actions/stops/update-stop";
import GlassModal from "@/components/admin/GlassModal";
type Stop = {
id: string;
city: string;
state: string;
location: string;
date: string;
time: string;
address?: string | null;
zip?: string | null;
cutoff_time?: string | null;
active: boolean;
brand_id: string;
};
type Props = {
isOpen: boolean;
onClose: () => void;
brandId: string;
stop?: Stop | null;
onSuccess?: (stopId: string) => void;
};
/* Pin icon for the modal header */
const PinIcon = () => (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<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 EditStopModal({ isOpen, onClose, brandId, stop, onSuccess }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [city, setCity] = useState("");
const [stateField, setStateField] = useState("");
const [location, setLocation] = useState("");
const [date, setDate] = useState("");
const [time, setTime] = useState("");
const [address, setAddress] = useState("");
const [zip, setZip] = useState("");
const [cutoffTime, setCutoffTime] = useState("");
const [status, setStatus] = useState<"draft" | "active">("draft");
const cityRef = useRef<HTMLInputElement>(null);
const isEditing = Boolean(stop);
useEffect(() => {
if (isOpen) {
if (stop) {
// Edit mode - populate from existing stop
setCity(stop.city);
setStateField(stop.state);
setLocation(stop.location);
setDate(stop.date);
setTime(stop.time || "");
setAddress(stop.address ?? "");
setZip(stop.zip ?? "");
setCutoffTime(stop.cutoff_time ? stop.cutoff_time.slice(0, 16) : "");
setStatus(stop.active ? "active" : "draft");
} else {
// Add mode - reset form
setCity("");
setStateField("");
setLocation("");
setDate("");
setTime("");
setAddress("");
setZip("");
setCutoffTime("");
setStatus("draft");
}
setError(null);
requestAnimationFrame(() => cityRef.current?.focus());
}
}, [isOpen, stop]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!city.trim() || !stateField.trim() || !location.trim() || !date) {
setError("City, state, location, and date are required.");
return;
}
setLoading(true);
try {
let result;
if (isEditing && stop) {
result = await updateStop(stop.id, brandId, {
city: city.trim(),
state: stateField.trim(),
location: location.trim(),
date,
time: time || "08:00",
address: address || null,
zip: zip || null,
cutoff_time: cutoffTime || null,
active: status === "active",
});
} else {
result = await createStop(brandId, {
city: city.trim(),
state: stateField.trim(),
location: location.trim(),
date,
time: time || "08:00",
address: address || undefined,
zip: zip || undefined,
cutoff_time: cutoffTime || undefined,
active: status === "active",
});
}
if (result.success) {
const newStopId = isEditing ? stop!.id : (result as { success: true; id: string }).id;
onSuccess?.(newStopId);
onClose();
} else {
setError(result.error ?? `Failed to ${isEditing ? "update" : "create"} stop`);
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
},
[
brandId,
city,
stateField,
location,
date,
time,
address,
zip,
cutoffTime,
status,
isEditing,
stop,
onSuccess,
onClose,
]
);
const title = isEditing ? "Edit Stop" : "Add Stop";
const eyebrow = isEditing
? `${stop?.city}, ${stop?.state}`
: "New stop on the route";
const submitLabel = loading
? isEditing ? "Saving…" : "Creating…"
: isEditing
? "Save Changes"
: status === "active"
? "Create & Publish"
: "Save as Draft";
if (!isOpen) return null;
return (
<GlassModal
title={title}
eyebrow={eyebrow}
onClose={onClose}
maxWidth="max-w-xl"
compact
>
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
{error && (
<div
role="alert"
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-red-700"
style={{ background: "rgba(220, 38, 38, 0.06)", border: "1px solid rgba(220, 38, 38, 0.15)" }}
>
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
</svg>
<span>{error}</span>
</div>
)}
{/* Row 1 — Where: City + State */}
<div className="grid grid-cols-[1fr_5.5rem] gap-3">
<div className="ha-field">
<label htmlFor="stop-city" className="ha-field-label">
<PinIcon />
<span>City</span>
<span className="ha-field-label-required">*</span>
</label>
<input
ref={cityRef}
id="stop-city"
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Denver"
className="ha-field-input"
required
autoComplete="address-level2"
/>
</div>
<div className="ha-field">
<label htmlFor="stop-state" className="ha-field-label">
<span>State</span>
<span className="ha-field-label-required">*</span>
</label>
<input
id="stop-state"
type="text"
value={stateField}
onChange={(e) => setStateField(e.target.value.toUpperCase())}
placeholder="CO"
maxLength={2}
className="ha-field-input ha-field-input-mono text-center"
style={{ textTransform: "uppercase" }}
required
autoComplete="address-level1"
/>
</div>
</div>
{/* Row 2 — Where at: Location (full) */}
<div className="ha-field">
<label htmlFor="stop-location" className="ha-field-label">
<span>Location / Venue</span>
<span className="ha-field-label-required">*</span>
</label>
<input
id="stop-location"
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Whole Foods Market — Highlands"
className="ha-field-input"
required
/>
</div>
{/* Row 3 — When: Date + Time */}
<div className="grid grid-cols-[1fr_1fr] gap-3">
<div className="ha-field">
<label htmlFor="stop-date" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
</svg>
<span>Pickup Date</span>
<span className="ha-field-label-required">*</span>
</label>
<input
id="stop-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="ha-field-input ha-field-input-mono"
required
/>
</div>
<div className="ha-field">
<label htmlFor="stop-time" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" strokeLinecap="round" />
</svg>
<span>Pickup Time</span>
</label>
<input
id="stop-time"
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="ha-field-input ha-field-input-mono"
/>
</div>
</div>
{/* Row 4 — Address + ZIP + Cutoff */}
<div className="grid grid-cols-[1fr_5.5rem_6.5rem] gap-3">
<div className="ha-field">
<label htmlFor="stop-address" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" strokeLinejoin="round" />
<path d="M9 22V12h6v10" strokeLinejoin="round" />
</svg>
<span>Street Address</span>
</label>
<input
id="stop-address"
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="123 Main St"
className="ha-field-input"
autoComplete="street-address"
/>
</div>
<div className="ha-field">
<label htmlFor="stop-zip" className="ha-field-label">
<span>ZIP</span>
</label>
<input
id="stop-zip"
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
placeholder="80202"
maxLength={10}
className="ha-field-input ha-field-input-mono"
autoComplete="postal-code"
/>
</div>
<div className="ha-field">
<label htmlFor="stop-cutoff" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" strokeLinecap="round" />
</svg>
<span>Cutoff</span>
</label>
<input
id="stop-cutoff"
type="time"
value={cutoffTime}
onChange={(e) => setCutoffTime(e.target.value)}
className="ha-field-input ha-field-input-mono"
/>
</div>
</div>
{/* Row 5 — Status: segmented control */}
<div className="ha-field pt-0.5">
<div className="flex items-center justify-between">
<label className="ha-field-label">
<span>Visibility</span>
</label>
<span className="text-[10px] text-[var(--admin-text-muted)] leading-none">
Draft is hidden from customers
</span>
</div>
<div className="ha-segment mt-1" role="radiogroup" aria-label="Stop status">
<button
type="button"
role="radio"
aria-checked={status === "draft"}
onClick={() => setStatus("draft")}
className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
>
<span className="ha-segment-dot" />
<span>Save as Draft</span>
</button>
<button
type="button"
role="radio"
aria-checked={status === "active"}
onClick={() => setStatus("active")}
className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
>
<span className="ha-segment-dot" />
<span>Publish Now</span>
</button>
</div>
</div>
{/* Footer */}
<div className="ha-modal-footer">
<span className="ha-modal-footer-hint">
<kbd>Esc</kbd>
to close
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="ha-btn-ghost"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="ha-btn-primary"
>
{loading ? (
<>
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
<span>{submitLabel}</span>
</>
) : (
<>
{isEditing ? (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : status === "active" ? (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
<span>{submitLabel}</span>
</>
)}
</button>
</div>
</div>
</form>
</GlassModal>
);
}
// Hook for easy integration
export function useEditStopModal(brandId: string) {
const [isOpen, setIsOpen] = useState(false);
const [editingStop, setEditingStop] = useState<{
id: string;
city: string;
state: string;
location: string;
date: string;
time: string;
address?: string | null;
zip?: string | null;
cutoff_time?: string | null;
active: boolean;
brand_id: string;
} | null>(null);
const open = useCallback((stop?: typeof editingStop) => {
setEditingStop(stop ?? null);
setIsOpen(true);
}, []);
const close = useCallback(() => {
setIsOpen(false);
setEditingStop(null);
}, []);
const Modal = useCallback(() => (
<EditStopModal
isOpen={isOpen}
onClose={close}
brandId={brandId}
stop={editingStop}
/>
), [isOpen, close, brandId, editingStop]);
return { open, close, Modal, editingStop };
}
+330 -159
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 } from "react"; import React, { useState, useTransition, useEffect, useMemo, useCallback } 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";
@@ -12,10 +12,10 @@ import {
AdminIconButton, AdminIconButton,
useToast, useToast,
Skeleton, Skeleton,
AdminPagination,
} from "@/components/admin/design-system"; } from "@/components/admin/design-system";
import ScheduleImportModal from "@/components/admin/ScheduleImportModal"; import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
import AddStopModal from "@/components/admin/AddStopModal"; import EditStopModal from "@/components/admin/EditStopModal";
import StopDetailModal from "@/components/admin/StopDetailModal";
type Stop = { type Stop = {
id: string; id: string;
@@ -28,18 +28,19 @@ type Stop = {
brand_id: string; brand_id: string;
status?: string; status?: string;
address?: string | null; address?: string | null;
zip?: string | null;
cutoff_time?: string | null;
brands: { name: string } | { name: string }[] | null; brands: { name: string } | { name: string }[] | null;
}; };
type Props = { type Props = {
stops: Stop[]; 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; hideInternalFilterBar?: boolean;
}; };
type SortField = "date" | "city" | "location" | "status";
type SortDirection = "asc" | "desc";
const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "active" | "inactive" | "draft"> = { const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "active" | "inactive" | "draft"> = {
all: "all", all: "all",
active: "active", active: "active",
@@ -59,15 +60,32 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set()); const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set());
const [bulkPublishing, setBulkPublishing] = useState(false); const [bulkPublishing, setBulkPublishing] = useState(false);
const [showImport, setShowImport] = useState(false); const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false); const [editingStop, setEditingStop] = useState<Stop | null>(null);
const [openStopId, setOpenStopId] = useState<string | null>(null); const [showAddModal, setShowAddModal] = useState(false);
const PAGE_SIZE = 50; const [sortField, setSortField] = useState<SortField>("date");
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
const PAGE_SIZE = 25;
useEffect(() => { useEffect(() => {
setIsLoading(true); setIsLoading(true);
const timer = setTimeout(() => setIsLoading(false), 220); const timer = setTimeout(() => setIsLoading(false), 220);
return () => clearTimeout(timer); 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 filtered = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
@@ -75,7 +93,8 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
const matchesSearch = const matchesSearch =
!q || !q ||
s.city.toLowerCase().includes(q) || s.city.toLowerCase().includes(q) ||
s.location.toLowerCase().includes(q); s.location.toLowerCase().includes(q) ||
s.state.toLowerCase().includes(q);
const matchesStatus = const matchesStatus =
statusFilter === "all" || statusFilter === "all" ||
(statusFilter === "active" && s.active && s.status !== "draft") || (statusFilter === "active" && s.active && s.status !== "draft") ||
@@ -85,6 +104,31 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
}); });
}, [stops, search, statusFilter]); }, [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( const statusCounts = useMemo(
() => ({ () => ({
all: stops.length, all: stops.length,
@@ -105,8 +149,18 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
[statusCounts] [statusCounts]
); );
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * 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 = () => { const toggleSelectAll = () => {
if (selectedStops.size === paginatedStops.length) { if (selectedStops.size === paginatedStops.length) {
@@ -163,13 +217,84 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
startTransition(() => router.refresh()); 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 ( 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 */} {/* Filter bar */}
{!hideInternalFilterBar && ( {!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 <AdminSearchInput
placeholder="Search by city or venue…" placeholder="Search stops…"
value={search} value={search}
onChange={(e) => { onChange={(e) => {
setSearch(e.target.value); setSearch(e.target.value);
@@ -193,39 +318,8 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
showCounts showCounts
/> />
<span className="text-xs text-[var(--admin-text-muted)] ml-auto tabular-nums"> <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> </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 <AdminButton
variant="secondary" variant="secondary"
size="sm" size="sm"
@@ -236,12 +330,12 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
</svg> </svg>
} }
> >
Upload Schedule Upload
</AdminButton> </AdminButton>
<AdminButton <AdminButton
variant="primary" variant="primary"
size="sm" size="sm"
onClick={() => setShowAdd(true)} onClick={() => setShowAddModal(true)}
icon={ icon={
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
@@ -289,80 +383,116 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
)} )}
{/* Table */} {/* Table */}
<table className="w-full text-left text-sm"> <div className="overflow-x-auto">
<thead className="bg-[var(--admin-bg-subtle)]/40 text-[var(--admin-text-muted)] text-xs uppercase tracking-wide"> <table className="w-full text-left text-sm">
<tr> <thead className="bg-stone-100/70 text-[var(--admin-text-muted)] text-xs uppercase tracking-wide">
<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 ? (
<tr> <tr>
<td colSpan={6} className="px-4 py-14 text-center"> <th className="w-10 px-4 py-3">
<div className="flex flex-col items-center gap-3 text-[var(--admin-text-muted)]"> <input
<div className="h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center"> type="checkbox"
<svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> checked={selectedStops.size === paginatedStops.length && paginatedStops.length > 0}
<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" /> onChange={toggleSelectAll}
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /> className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
</svg> aria-label="Select all"
</div> />
<div> </th>
<p className="text-sm font-semibold text-stone-700"> <th className="px-4 py-3 font-semibold cursor-pointer hover:text-stone-600 transition-colors" onClick={() => handleSort("date")}>
{search || statusFilter !== "all" <span className="inline-flex items-center">
? "No stops match your filters" When
: "No stops yet"} <SortIcon field="date" />
</p> </span>
<p className="mt-0.5 text-xs text-stone-500"> </th>
{search || statusFilter !== "all" <th className="px-4 py-3 font-semibold cursor-pointer hover:text-stone-600 transition-colors" onClick={() => handleSort("city")}>
? "Try a different search or clear the filters above." <span className="inline-flex items-center">
: "Use the buttons above to upload a schedule or add your first stop."} Where
</p> <SortIcon field="city" />
</div> </span>
</div> </th>
</td> <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> </tr>
) : ( </thead>
paginatedStops.map((stop) => ( <tbody className="divide-y divide-stone-100">
<StopRow {isLoading ? (
key={stop.id} Array.from({ length: 8 }).map((_, i) => (
stop={stop} <tr key={i}>
onDeleted={handleDeleted} <td className="px-4 py-3.5"><Skeleton variant="rect" className="h-4 w-4" /></td>
onDeleteError={setDeleteError} <td className="px-4 py-3.5"><Skeleton variant="text" className="w-24 h-4" /></td>
isSelected={selectedStops.has(stop.id)} <td className="px-4 py-3.5"><Skeleton variant="text" className="w-32 h-4" /></td>
onToggleSelect={(e) => { <td className="px-4 py-3.5"><Skeleton variant="text" className="w-40 h-4" /></td>
e.stopPropagation(); <td className="px-4 py-3.5"><Skeleton variant="rect" className="w-16 h-5 rounded-full" /></td>
toggleStopSelection(stop.id); <td className="px-4 py-3.5"><Skeleton variant="rect" className="w-6 h-6" /></td>
}} </tr>
onOpen={() => setOpenStopId(stop.id)} ))
/> ) : sorted.length === 0 ? (
)) <tr>
)} <td colSpan={6} className="px-4 py-14 text-center">
</tbody> <div className="flex flex-col items-center gap-3 text-[var(--admin-text-muted)]">
</table> <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 && ( {showImport && (
<ScheduleImportModal <ScheduleImportModal
@@ -372,35 +502,41 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
/> />
)} )}
<AddStopModal <EditStopModal
isOpen={showAdd} isOpen={showAddModal || editingStop !== null}
onClose={() => setShowAdd(false)} onClose={() => {
setShowAddModal(false);
setEditingStop(null);
}}
brandId={stops[0]?.brand_id ?? ""} brandId={stops[0]?.brand_id ?? ""}
stop={editingStop}
onSuccess={refresh} onSuccess={refresh}
/> />
{openStopId && (
<StopDetailModal
stopId={openStopId}
onClose={() => setOpenStopId(null)}
/>
)}
</> </>
); );
} }
function formatDate(iso: string): { date: string; weekday: string } { function formatDate(iso: string): { date: string; weekday: string; isToday: boolean; isPast: boolean } {
// Stop date is stored as YYYY-MM-DD; parse without TZ shift
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: "" }; 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 dt = new Date(y, m - 1, d);
const weekday = dt.toLocaleDateString("en-US", { weekday: "short" }); const weekday = dt.toLocaleDateString("en-US", { weekday: "short" });
const month = dt.toLocaleDateString("en-US", { month: "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 { function formatTime(t: string): string {
// Stored as HH:MM (24h). Display as 10:00 AM.
if (!t) return ""; if (!t) return "";
const [hStr, mStr] = t.split(":"); const [hStr, mStr] = t.split(":");
const h = parseInt(hStr, 10); const h = parseInt(hStr, 10);
@@ -416,14 +552,14 @@ function StopRowBase({
onDeleteError, onDeleteError,
isSelected, isSelected,
onToggleSelect, onToggleSelect,
onOpen, onEdit,
}: { }: {
stop: Stop; stop: Stop;
onDeleted: () => void; onDeleted: () => void;
onDeleteError: (msg: string) => void; onDeleteError: (msg: string) => void;
isSelected: boolean; isSelected: boolean;
onToggleSelect: (e: React.MouseEvent) => void; onToggleSelect: (e: React.MouseEvent) => void;
onOpen: () => 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);
@@ -431,7 +567,7 @@ function StopRowBase({
const [confirmDelete, setConfirmDelete] = useState<string | null>(null); const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [publishingId, setPublishingId] = 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); const time = formatTime(stop.time);
async function handlePublish() { async function handlePublish() {
@@ -462,8 +598,12 @@ function StopRowBase({
return ( return (
<tr <tr
onClick={onOpen} onClick={onEdit}
className={`group cursor-pointer transition-colors ${isSelected ? "bg-emerald-50/60" : "hover:bg-[var(--admin-bg-subtle)]/60"}`} 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()}> <td className="px-4 py-3.5" onClick={(e) => e.stopPropagation()}>
<input <input
@@ -478,8 +618,17 @@ function StopRowBase({
<td className="px-4 py-3.5"> <td className="px-4 py-3.5">
<div className="flex flex-col leading-tight"> <div className="flex flex-col leading-tight">
<span className="font-semibold text-[var(--admin-text-primary)] tabular-nums">{date}</span> <div className="flex items-center gap-1.5">
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums"> {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}`} {weekday} {time && `· ${time}`}
</span> </span>
</div> </div>
@@ -487,16 +636,16 @@ function StopRowBase({
<td className="px-4 py-3.5"> <td className="px-4 py-3.5">
<div className="flex flex-col leading-tight"> <div className="flex flex-col leading-tight">
<span className="font-semibold text-[var(--admin-text-primary)]">{stop.city}</span> <span className={`font-semibold ${isPast ? "text-stone-400" : "text-stone-800"}`}>{stop.city}</span>
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">{stop.state}</span> <span className="text-[11px] text-stone-500 tabular-nums">{stop.state}</span>
</div> </div>
</td> </td>
<td className="px-4 py-3.5"> <td className="px-4 py-3.5">
<div className="flex flex-col leading-tight"> <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 && ( {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} {stop.address}
</span> </span>
)} )}
@@ -505,11 +654,11 @@ 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 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" 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 : 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" : "bg-stone-100 text-stone-500 border border-stone-200"
}`} }`}
> >
@@ -556,7 +705,20 @@ 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-[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" && ( {stop.status === "draft" && (
<button <button
onClick={(e) => { onClick={(e) => {
@@ -564,16 +726,22 @@ function StopRowBase({
handlePublish(); handlePublish();
}} }}
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" 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"} {publishingId === stop.id ? "Publishing…" : "Publish"}
</button> </button>
)} )}
<Link <Link
href={`/admin/stops/new?duplicate=${stop.id}`} href={`/admin/stops/new?duplicate=${stop.id}`}
onClick={(e) => e.stopPropagation()} 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 Duplicate
</Link> </Link>
<button <button
@@ -582,8 +750,11 @@ function StopRowBase({
setOpenMenu(null); setOpenMenu(null);
setConfirmDelete(stop.id); 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 Delete
</button> </button>
</div> </div>
@@ -600,11 +771,11 @@ 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-[var(--admin-border)] shadow-xl p-4"> <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-[var(--admin-text-primary)]"> <p className="text-sm font-semibold text-stone-800">
Delete &quot;{stop.city}, {stop.state}&quot;? Delete "{stop.city}, {stop.state}"?
</p> </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. This will remove the stop. If it has active orders, you must resolve those first.
</p> </p>
<div className="mt-4 flex gap-2 justify-end"> <div className="mt-4 flex gap-2 justify-end">