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:
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 "{stop.city}, {stop.state}"?
|
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">
|
||||||
|
|||||||
Reference in New Issue
Block a user