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";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import React, { useState, useTransition, useEffect, useMemo } from "react";
|
||||
import React, { useState, useTransition, useEffect, useMemo, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { publishStop, deleteStop } from "@/actions/stops";
|
||||
@@ -12,10 +12,10 @@ import {
|
||||
AdminIconButton,
|
||||
useToast,
|
||||
Skeleton,
|
||||
AdminPagination,
|
||||
} from "@/components/admin/design-system";
|
||||
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
|
||||
import AddStopModal from "@/components/admin/AddStopModal";
|
||||
import StopDetailModal from "@/components/admin/StopDetailModal";
|
||||
import EditStopModal from "@/components/admin/EditStopModal";
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
@@ -28,18 +28,19 @@ type Stop = {
|
||||
brand_id: string;
|
||||
status?: string;
|
||||
address?: string | null;
|
||||
zip?: string | null;
|
||||
cutoff_time?: string | null;
|
||||
brands: { name: string } | { name: string }[] | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
stops: Stop[];
|
||||
/**
|
||||
* Hide the internal search/filter bar at the top of the table. Use when
|
||||
* the parent component already renders shared filters (e.g. StopsViewClient).
|
||||
*/
|
||||
hideInternalFilterBar?: boolean;
|
||||
};
|
||||
|
||||
type SortField = "date" | "city" | "location" | "status";
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "active" | "inactive" | "draft"> = {
|
||||
all: "all",
|
||||
active: "active",
|
||||
@@ -59,15 +60,32 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set());
|
||||
const [bulkPublishing, setBulkPublishing] = useState(false);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [openStopId, setOpenStopId] = useState<string | null>(null);
|
||||
const PAGE_SIZE = 50;
|
||||
const [editingStop, setEditingStop] = useState<Stop | null>(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [sortField, setSortField] = useState<SortField>("date");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
const timer = setTimeout(() => setIsLoading(false), 220);
|
||||
return () => clearTimeout(timer);
|
||||
}, [page, statusFilter, search]);
|
||||
}, [page, statusFilter, search, sortField, sortDirection]);
|
||||
|
||||
// Compute stats
|
||||
const stats = useMemo(() => {
|
||||
const now = new Date();
|
||||
const today = now.toISOString().split("T")[0];
|
||||
const activeStops = stops.filter((s) => s.active && s.status !== "draft");
|
||||
const upcomingStops = stops.filter((s) => s.date >= today && s.status !== "draft");
|
||||
const draftStops = stops.filter((s) => s.status === "draft");
|
||||
return {
|
||||
total: stops.length,
|
||||
active: activeStops.length,
|
||||
upcoming: upcomingStops.length,
|
||||
draft: draftStops.length,
|
||||
};
|
||||
}, [stops]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
@@ -75,7 +93,8 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
const matchesSearch =
|
||||
!q ||
|
||||
s.city.toLowerCase().includes(q) ||
|
||||
s.location.toLowerCase().includes(q);
|
||||
s.location.toLowerCase().includes(q) ||
|
||||
s.state.toLowerCase().includes(q);
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "active" && s.active && s.status !== "draft") ||
|
||||
@@ -85,6 +104,31 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
});
|
||||
}, [stops, search, statusFilter]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...filtered].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortField) {
|
||||
case "date":
|
||||
comparison = a.date.localeCompare(b.date);
|
||||
break;
|
||||
case "city":
|
||||
comparison = a.city.localeCompare(b.city);
|
||||
break;
|
||||
case "location":
|
||||
comparison = a.location.localeCompare(b.location);
|
||||
break;
|
||||
case "status":
|
||||
// Draft first, then inactive, then active
|
||||
const statusOrder = { draft: 0, inactive: 1, active: 2 };
|
||||
const aStatus = a.status === "draft" ? "draft" : a.active ? "active" : "inactive";
|
||||
const bStatus = b.status === "draft" ? "draft" : b.active ? "active" : "inactive";
|
||||
comparison = statusOrder[aStatus] - statusOrder[bStatus];
|
||||
break;
|
||||
}
|
||||
return sortDirection === "asc" ? comparison : -comparison;
|
||||
});
|
||||
}, [filtered, sortField, sortDirection]);
|
||||
|
||||
const statusCounts = useMemo(
|
||||
() => ({
|
||||
all: stops.length,
|
||||
@@ -105,8 +149,18 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
[statusCounts]
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
|
||||
const paginatedStops = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
setPage(0);
|
||||
}, [sortField]);
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedStops.size === paginatedStops.length) {
|
||||
@@ -163,13 +217,84 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
|
||||
// Sort icon component
|
||||
const SortIcon = ({ field }: { field: SortField }) => (
|
||||
<span className={`inline-flex ml-1.5 ${sortField === field ? "opacity-100" : "opacity-30"}`}>
|
||||
{sortField === field && sortDirection === "desc" ? (
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Stats Bar */}
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div className="rounded-xl border border-amber-200/60 bg-gradient-to-br from-amber-50/80 to-orange-50/40 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100/70">
|
||||
<svg className="h-4 w-4 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-stone-800 tabular-nums">{stats.total}</p>
|
||||
<p className="text-xs font-medium text-stone-500">Total Stops</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-emerald-200/60 bg-gradient-to-br from-emerald-50/80 to-teal-50/40 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100/70">
|
||||
<svg className="h-4 w-4 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-stone-800 tabular-nums">{stats.active}</p>
|
||||
<p className="text-xs font-medium text-stone-500">Active</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-sky-200/60 bg-gradient-to-br from-sky-50/80 to-blue-50/40 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-100/70">
|
||||
<svg className="h-4 w-4 text-sky-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-stone-800 tabular-nums">{stats.upcoming}</p>
|
||||
<p className="text-xs font-medium text-stone-500">Upcoming</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stone-200/60 bg-gradient-to-br from-stone-50/80 to-gray-50/40 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-stone-100/70">
|
||||
<svg className="h-4 w-4 text-stone-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-stone-800 tabular-nums">{stats.draft}</p>
|
||||
<p className="text-xs font-medium text-stone-500">Drafts</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
{!hideInternalFilterBar && (
|
||||
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3 border-b border-[var(--admin-border)]">
|
||||
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3 border-b border-[var(--admin-border)] bg-stone-50/50">
|
||||
<AdminSearchInput
|
||||
placeholder="Search by city or venue…"
|
||||
placeholder="Search stops…"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
@@ -193,39 +318,8 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
showCounts
|
||||
/>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] ml-auto tabular-nums">
|
||||
{filtered.length} stop{filtered.length !== 1 ? "s" : ""}
|
||||
{sorted.length} stop{sorted.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<AdminIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Previous page"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0 || isLoading}
|
||||
className="!rounded-lg border border-[var(--admin-border)]"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</AdminIconButton>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] px-1 tabular-nums">
|
||||
{page + 1}/{totalPages}
|
||||
</span>
|
||||
<AdminIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Next page"
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1 || isLoading}
|
||||
className="!rounded-lg border border-[var(--admin-border)]"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</AdminIconButton>
|
||||
</div>
|
||||
)}
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -236,12 +330,12 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Upload Schedule
|
||||
Upload
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setShowAdd(true)}
|
||||
onClick={() => setShowAddModal(true)}
|
||||
icon={
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
@@ -289,80 +383,116 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-[var(--admin-bg-subtle)]/40 text-[var(--admin-text-muted)] text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th className="w-10 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedStops.size === paginatedStops.length && paginatedStops.length > 0}
|
||||
onChange={toggleSelectAll}
|
||||
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold">When</th>
|
||||
<th className="px-4 py-3 font-semibold">Where</th>
|
||||
<th className="px-4 py-3 font-semibold">Venue</th>
|
||||
<th className="px-4 py-3 font-semibold">Status</th>
|
||||
<th className="w-12 px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="rect" className="h-4 w-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-24 h-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-32 h-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-40 h-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-16 h-5 rounded-full" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-6 h-6" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-stone-100/70 text-[var(--admin-text-muted)] text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-14 text-center">
|
||||
<div className="flex flex-col items-center gap-3 text-[var(--admin-text-muted)]">
|
||||
<div className="h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-700">
|
||||
{search || statusFilter !== "all"
|
||||
? "No stops match your filters"
|
||||
: "No stops yet"}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-stone-500">
|
||||
{search || statusFilter !== "all"
|
||||
? "Try a different search or clear the filters above."
|
||||
: "Use the buttons above to upload a schedule or add your first stop."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<th className="w-10 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedStops.size === paginatedStops.length && paginatedStops.length > 0}
|
||||
onChange={toggleSelectAll}
|
||||
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold cursor-pointer hover:text-stone-600 transition-colors" onClick={() => handleSort("date")}>
|
||||
<span className="inline-flex items-center">
|
||||
When
|
||||
<SortIcon field="date" />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold cursor-pointer hover:text-stone-600 transition-colors" onClick={() => handleSort("city")}>
|
||||
<span className="inline-flex items-center">
|
||||
Where
|
||||
<SortIcon field="city" />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold cursor-pointer hover:text-stone-600 transition-colors" onClick={() => handleSort("location")}>
|
||||
<span className="inline-flex items-center">
|
||||
Venue
|
||||
<SortIcon field="location" />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold cursor-pointer hover:text-stone-600 transition-colors" onClick={() => handleSort("status")}>
|
||||
<span className="inline-flex items-center">
|
||||
Status
|
||||
<SortIcon field="status" />
|
||||
</span>
|
||||
</th>
|
||||
<th className="w-20 px-4 py-3" />
|
||||
</tr>
|
||||
) : (
|
||||
paginatedStops.map((stop) => (
|
||||
<StopRow
|
||||
key={stop.id}
|
||||
stop={stop}
|
||||
onDeleted={handleDeleted}
|
||||
onDeleteError={setDeleteError}
|
||||
isSelected={selectedStops.has(stop.id)}
|
||||
onToggleSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleStopSelection(stop.id);
|
||||
}}
|
||||
onOpen={() => setOpenStopId(stop.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="rect" className="h-4 w-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-24 h-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-32 h-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-40 h-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-16 h-5 rounded-full" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-6 h-6" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : sorted.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-14 text-center">
|
||||
<div className="flex flex-col items-center gap-3 text-[var(--admin-text-muted)]">
|
||||
<div className="h-14 w-14 rounded-2xl bg-gradient-to-br from-stone-100 to-stone-50 flex items-center justify-center">
|
||||
<svg className="h-7 w-7 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-700">
|
||||
{search || statusFilter !== "all"
|
||||
? "No stops match your filters"
|
||||
: "No stops yet"}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-stone-500">
|
||||
{search || statusFilter !== "all"
|
||||
? "Try a different search or clear the filters above."
|
||||
: "Use the buttons above to upload a schedule or add your first stop."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedStops.map((stop) => (
|
||||
<StopRow
|
||||
key={stop.id}
|
||||
stop={stop}
|
||||
onDeleted={handleDeleted}
|
||||
onDeleteError={setDeleteError}
|
||||
isSelected={selectedStops.has(stop.id)}
|
||||
onToggleSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleStopSelection(stop.id);
|
||||
}}
|
||||
onEdit={() => setEditingStop(stop)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-stone-100 px-4 py-3">
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
Showing {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, sorted.length)} of {sorted.length}
|
||||
</span>
|
||||
<AdminPagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showImport && (
|
||||
<ScheduleImportModal
|
||||
@@ -372,35 +502,41 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddStopModal
|
||||
isOpen={showAdd}
|
||||
onClose={() => setShowAdd(false)}
|
||||
<EditStopModal
|
||||
isOpen={showAddModal || editingStop !== null}
|
||||
onClose={() => {
|
||||
setShowAddModal(false);
|
||||
setEditingStop(null);
|
||||
}}
|
||||
brandId={stops[0]?.brand_id ?? ""}
|
||||
stop={editingStop}
|
||||
onSuccess={refresh}
|
||||
/>
|
||||
|
||||
{openStopId && (
|
||||
<StopDetailModal
|
||||
stopId={openStopId}
|
||||
onClose={() => setOpenStopId(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): { date: string; weekday: string } {
|
||||
// Stop date is stored as YYYY-MM-DD; parse without TZ shift
|
||||
function formatDate(iso: string): { date: string; weekday: string; isToday: boolean; isPast: boolean } {
|
||||
const [y, m, d] = iso.split("-").map((s) => parseInt(s, 10));
|
||||
if (!y || !m || !d) return { date: iso, weekday: "" };
|
||||
if (!y || !m || !d) return { date: iso, weekday: "", isToday: false, isPast: false };
|
||||
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().split("T")[0];
|
||||
const stopDate = new Date(y, m - 1, d);
|
||||
|
||||
const dt = new Date(y, m - 1, d);
|
||||
const weekday = dt.toLocaleDateString("en-US", { weekday: "short" });
|
||||
const month = dt.toLocaleDateString("en-US", { month: "short" });
|
||||
return { date: `${month} ${d}`, weekday };
|
||||
|
||||
return {
|
||||
date: `${month} ${d}`,
|
||||
weekday,
|
||||
isToday: iso === todayStr,
|
||||
isPast: iso < todayStr
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(t: string): string {
|
||||
// Stored as HH:MM (24h). Display as 10:00 AM.
|
||||
if (!t) return "";
|
||||
const [hStr, mStr] = t.split(":");
|
||||
const h = parseInt(hStr, 10);
|
||||
@@ -416,14 +552,14 @@ function StopRowBase({
|
||||
onDeleteError,
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
onOpen,
|
||||
onEdit,
|
||||
}: {
|
||||
stop: Stop;
|
||||
onDeleted: () => void;
|
||||
onDeleteError: (msg: string) => void;
|
||||
isSelected: boolean;
|
||||
onToggleSelect: (e: React.MouseEvent) => void;
|
||||
onOpen: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
@@ -431,7 +567,7 @@ function StopRowBase({
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||
|
||||
const { date, weekday } = formatDate(stop.date);
|
||||
const { date, weekday, isToday, isPast } = formatDate(stop.date);
|
||||
const time = formatTime(stop.time);
|
||||
|
||||
async function handlePublish() {
|
||||
@@ -462,8 +598,12 @@ function StopRowBase({
|
||||
|
||||
return (
|
||||
<tr
|
||||
onClick={onOpen}
|
||||
className={`group cursor-pointer transition-colors ${isSelected ? "bg-emerald-50/60" : "hover:bg-[var(--admin-bg-subtle)]/60"}`}
|
||||
onClick={onEdit}
|
||||
className={`group cursor-pointer transition-all duration-150 ${
|
||||
isSelected
|
||||
? "bg-emerald-50/60"
|
||||
: "hover:bg-stone-50/80"
|
||||
} ${isPast && stop.status !== "draft" ? "opacity-60" : ""}`}
|
||||
>
|
||||
<td className="px-4 py-3.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
@@ -478,8 +618,17 @@ function StopRowBase({
|
||||
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-semibold text-[var(--admin-text-primary)] tabular-nums">{date}</span>
|
||||
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isToday && (
|
||||
<span className="rounded bg-sky-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-sky-700">
|
||||
Today
|
||||
</span>
|
||||
)}
|
||||
<span className={`font-semibold tabular-nums ${isPast ? "text-stone-400" : "text-stone-800"}`}>
|
||||
{date}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-stone-500 tabular-nums">
|
||||
{weekday} {time && `· ${time}`}
|
||||
</span>
|
||||
</div>
|
||||
@@ -487,16 +636,16 @@ function StopRowBase({
|
||||
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-semibold text-[var(--admin-text-primary)]">{stop.city}</span>
|
||||
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">{stop.state}</span>
|
||||
<span className={`font-semibold ${isPast ? "text-stone-400" : "text-stone-800"}`}>{stop.city}</span>
|
||||
<span className="text-[11px] text-stone-500 tabular-nums">{stop.state}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="text-[var(--admin-text-secondary)]">{stop.location}</span>
|
||||
<span className="text-stone-600">{stop.location}</span>
|
||||
{stop.address && (
|
||||
<span className="text-[11px] text-[var(--admin-text-muted)] truncate max-w-[260px]">
|
||||
<span className="text-[11px] text-stone-400 truncate max-w-[260px]">
|
||||
{stop.address}
|
||||
</span>
|
||||
)}
|
||||
@@ -505,11 +654,11 @@ function StopRowBase({
|
||||
|
||||
<td className="px-4 py-3.5">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold ${
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold ${
|
||||
stop.status === "draft"
|
||||
? "bg-amber-50 text-amber-700 border border-amber-200"
|
||||
? "bg-amber-100 text-amber-700 border border-amber-200"
|
||||
: stop.active
|
||||
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
|
||||
: "bg-stone-100 text-stone-500 border border-stone-200"
|
||||
}`}
|
||||
>
|
||||
@@ -556,7 +705,20 @@ function StopRowBase({
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl overflow-hidden">
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-xl overflow-hidden">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenMenu(null);
|
||||
onEdit();
|
||||
}}
|
||||
className="w-full text-left px-4 py-2.5 text-sm font-medium text-stone-700 hover:bg-stone-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="h-4 w-4 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
{stop.status === "draft" && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -564,16 +726,22 @@ function StopRowBase({
|
||||
handlePublish();
|
||||
}}
|
||||
disabled={publishingId === stop.id}
|
||||
className="w-full text-left px-4 py-2.5 text-sm font-medium text-emerald-700 hover:bg-emerald-50 transition-colors disabled:opacity-50"
|
||||
className="w-full text-left px-4 py-2.5 text-sm font-medium text-emerald-700 hover:bg-emerald-50 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<svg className="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{publishingId === stop.id ? "Publishing…" : "Publish"}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href={`/admin/stops/new?duplicate=${stop.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center px-4 py-2.5 text-sm text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Duplicate
|
||||
</Link>
|
||||
<button
|
||||
@@ -582,8 +750,11 @@ function StopRowBase({
|
||||
setOpenMenu(null);
|
||||
setConfirmDelete(stop.id);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="h-4 w-4 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
@@ -600,11 +771,11 @@ function StopRowBase({
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
|
||||
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
||||
Delete "{stop.city}, {stop.state}"?
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-xl p-4">
|
||||
<p className="text-sm font-semibold text-stone-800">
|
||||
Delete "{stop.city}, {stop.state}"?
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs text-[var(--admin-text-muted)]">
|
||||
<p className="mt-1.5 text-xs text-stone-500">
|
||||
This will remove the stop. If it has active orders, you must resolve those first.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2 justify-end">
|
||||
|
||||
Reference in New Issue
Block a user