diff --git a/src/components/admin/EditStopModal.tsx b/src/components/admin/EditStopModal.tsx new file mode 100644 index 0000000..8c25f6c --- /dev/null +++ b/src/components/admin/EditStopModal.tsx @@ -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 = () => ( + +); + +export default function EditStopModal({ isOpen, onClose, brandId, stop, onSuccess }: Props) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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(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 ( + +
+ {error && ( +
+ + + + + {error} +
+ )} + + {/* Row 1 — Where: City + State */} +
+
+ + setCity(e.target.value)} + placeholder="Denver" + className="ha-field-input" + required + autoComplete="address-level2" + /> +
+
+ + 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" + /> +
+
+ + {/* Row 2 — Where at: Location (full) */} +
+ + setLocation(e.target.value)} + placeholder="Whole Foods Market — Highlands" + className="ha-field-input" + required + /> +
+ + {/* Row 3 — When: Date + Time */} +
+
+ + setDate(e.target.value)} + className="ha-field-input ha-field-input-mono" + required + /> +
+
+ + setTime(e.target.value)} + className="ha-field-input ha-field-input-mono" + /> +
+
+ + {/* Row 4 — Address + ZIP + Cutoff */} +
+
+ + setAddress(e.target.value)} + placeholder="123 Main St" + className="ha-field-input" + autoComplete="street-address" + /> +
+
+ + setZip(e.target.value)} + placeholder="80202" + maxLength={10} + className="ha-field-input ha-field-input-mono" + autoComplete="postal-code" + /> +
+
+ + setCutoffTime(e.target.value)} + className="ha-field-input ha-field-input-mono" + /> +
+
+ + {/* Row 5 — Status: segmented control */} +
+
+ + + Draft is hidden from customers + +
+
+ + +
+
+ + {/* Footer */} +
+ + Esc + to close + +
+ + +
+
+
+
+ ); +} + +// 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(() => ( + + ), [isOpen, close, brandId, editingStop]); + + return { open, close, Modal, editingStop }; +} diff --git a/src/components/admin/StopTableClient.tsx b/src/components/admin/StopTableClient.tsx index e69d9fa..6c9deec 100644 --- a/src/components/admin/StopTableClient.tsx +++ b/src/components/admin/StopTableClient.tsx @@ -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>(new Set()); const [bulkPublishing, setBulkPublishing] = useState(false); const [showImport, setShowImport] = useState(false); - const [showAdd, setShowAdd] = useState(false); - const [openStopId, setOpenStopId] = useState(null); - const PAGE_SIZE = 50; + const [editingStop, setEditingStop] = useState(null); + const [showAddModal, setShowAddModal] = useState(false); + const [sortField, setSortField] = useState("date"); + const [sortDirection, setSortDirection] = useState("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 }) => ( + + {sortField === field && sortDirection === "desc" ? ( + + + + ) : ( + + + + )} + + ); + return ( <> + {/* Stats Bar */} +
+
+
+
+ + + +
+
+

{stats.total}

+

Total Stops

+
+
+
+
+
+
+ + + +
+
+

{stats.active}

+

Active

+
+
+
+
+
+
+ + + +
+
+

{stats.upcoming}

+

Upcoming

+
+
+
+
+
+
+ + + +
+
+

{stats.draft}

+

Drafts

+
+
+
+
+ {/* Filter bar */} {!hideInternalFilterBar && ( -
+
{ setSearch(e.target.value); @@ -193,39 +318,8 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false } showCounts /> - {filtered.length} stop{filtered.length !== 1 ? "s" : ""} + {sorted.length} stop{sorted.length !== 1 ? "s" : ""} - {totalPages > 1 && ( -
- setPage((p) => Math.max(0, p - 1))} - disabled={page === 0 || isLoading} - className="!rounded-lg border border-[var(--admin-border)]" - > - - - - - - {page + 1}/{totalPages} - - setPage((p) => Math.min(totalPages - 1, p + 1))} - disabled={page >= totalPages - 1 || isLoading} - className="!rounded-lg border border-[var(--admin-border)]" - > - - - - -
- )} } > - Upload Schedule + Upload setShowAdd(true)} + onClick={() => setShowAddModal(true)} icon={ @@ -289,80 +383,116 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false } )} {/* Table */} - - - - - - - - - - - - {isLoading ? ( - Array.from({ length: 8 }).map((_, i) => ( - - - - - - - - - )) - ) : filtered.length === 0 ? ( +
+
- 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" - /> - WhenWhereVenueStatus -
+ - + + + + + + - ) : ( - paginatedStops.map((stop) => ( - { - e.stopPropagation(); - toggleStopSelection(stop.id); - }} - onOpen={() => setOpenStopId(stop.id)} - /> - )) - )} - -
-
-
- - - - -
-
-

- {search || statusFilter !== "all" - ? "No stops match your filters" - : "No stops yet"} -

-

- {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."} -

-
-
-
+ 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" + /> + handleSort("date")}> + + When + + + handleSort("city")}> + + Where + + + handleSort("location")}> + + Venue + + + handleSort("status")}> + + Status + + +
+ + + {isLoading ? ( + Array.from({ length: 8 }).map((_, i) => ( + + + + + + + + + )) + ) : sorted.length === 0 ? ( + + +
+
+ + + + +
+
+

+ {search || statusFilter !== "all" + ? "No stops match your filters" + : "No stops yet"} +

+

+ {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."} +

+
+
+ + + ) : ( + paginatedStops.map((stop) => ( + { + e.stopPropagation(); + toggleStopSelection(stop.id); + }} + onEdit={() => setEditingStop(stop)} + /> + )) + )} + + +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Showing {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, sorted.length)} of {sorted.length} + + +
+ )} {showImport && ( )} - setShowAdd(false)} + { + setShowAddModal(false); + setEditingStop(null); + }} brandId={stops[0]?.brand_id ?? ""} + stop={editingStop} onSuccess={refresh} /> - - {openStopId && ( - 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(null); @@ -431,7 +567,7 @@ function StopRowBase({ const [confirmDelete, setConfirmDelete] = useState(null); const [publishingId, setPublishingId] = useState(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 ( e.stopPropagation()}>
- {date} - +
+ {isToday && ( + + Today + + )} + + {date} + +
+ {weekday} {time && `· ${time}`}
@@ -487,16 +636,16 @@ function StopRowBase({
- {stop.city} - {stop.state} + {stop.city} + {stop.state}
- {stop.location} + {stop.location} {stop.address && ( - + {stop.address} )} @@ -505,11 +654,11 @@ function StopRowBase({ @@ -556,7 +705,20 @@ function StopRowBase({ setConfirmDelete(null); }} /> -
+
+ {stop.status === "draft" && ( )} 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" > + + + Duplicate
@@ -600,11 +771,11 @@ function StopRowBase({ setOpenMenu(null); }} /> -
-

- Delete "{stop.city}, {stop.state}"? +

+

+ Delete "{stop.city}, {stop.state}"?

-

+

This will remove the stop. If it has active orders, you must resolve those first.