diff --git a/src/app/admin/stops/page.tsx b/src/app/admin/stops/page.tsx index 1534468..ec52c20 100644 --- a/src/app/admin/stops/page.tsx +++ b/src/app/admin/stops/page.tsx @@ -2,7 +2,7 @@ import { pool } from "@/lib/db"; import { getAdminUser } from "@/lib/admin-permissions"; import { getActiveBrandId } from "@/lib/brand-scope"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; -import { PageHeader } from "@/components/admin/design-system"; +import StopTableClient from "@/components/admin/StopTableClient"; import { redirect } from "next/navigation"; const StopIcon = () => ( @@ -25,7 +25,6 @@ export default async function AdminStopsPage({ searchParams }: PageProps) { let stops: unknown[] = []; let error: string | null = null; - let totalCount = 0; // Resolve active brand from cookie/URL (respects platform_admin "All brands" = null) let activeBrandId: string | null = null; @@ -40,11 +39,6 @@ export default async function AdminStopsPage({ searchParams }: PageProps) { ? "1=1" : `brand_id IN ('${(adminUser.brand_ids ?? []).join("','")}')`; - const countResult = await pool.query<{ count: string }>( - `SELECT count(*) as count FROM stops WHERE ${brandCondition}` - ); - totalCount = parseInt(countResult.rows[0]?.count ?? "0", 10); - const { rows } = await pool.query( `SELECT s.id, s.city, s.state, s.date, s."time", s.location, s.status, s.brand_id, s.address, s.zip, s.cutoff_date, @@ -56,7 +50,7 @@ export default async function AdminStopsPage({ searchParams }: PageProps) { LIMIT 200` ); stops = rows; - console.log("[admin/stops] Fetched", rows.length, "stops, total:", totalCount); + console.log("[admin/stops] Fetched", rows.length, "stops"); } catch (e) { error = e instanceof Error ? e.message : String(e); console.error("[admin/stops] Query error:", error); @@ -88,76 +82,28 @@ export default async function AdminStopsPage({ searchParams }: PageProps) { ); } + // Transform stops for the client component + const stopsForClient = stops.map((s: any) => ({ + id: s.id, + city: s.city, + state: s.state, + date: s.date instanceof Date ? s.date.toISOString().split("T")[0] : String(s.date).split("T")[0], + time: s.time || "", + location: s.location, + active: s.status === "active", + brand_id: s.brand_id, + status: s.status, + address: s.address, + zip: s.zip, + cutoff_time: s.cutoff_date, + brands: s.brand_name ? { name: s.brand_name } : null, + })); + return ( -
+
- } - title="Stops & Routes" - subtitle={`${totalCount} stops total`} - /> +
-
- {stops.length === 0 ? ( -
-
- - - - -
-

No stops found

-

- {activeBrandId - ? "No stops for the selected brand. Upload a schedule or add stops manually." - : "No stops in the database."} -

-
- - Add Stop - -
-
- ) : ( -
- - - - - - - - - - - - {stops.map((s: any) => ( - - - - - - - - ))} - -
CityLocationDateStatusBrand
{s.city}, {s.state}{s.location}{String(s.date)} - {s.status} - {s.brand_name ?? "—"}
-
- )} -
-
+ ); -} \ No newline at end of file +} diff --git a/src/components/admin/StopTableClient.tsx b/src/components/admin/StopTableClient.tsx index 6c9deec..5c8a2d0 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, useCallback } from "react"; +import React, { useState, useTransition, useEffect, useMemo, useCallback, useRef } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { publishStop, deleteStop } from "@/actions/stops"; @@ -10,6 +10,7 @@ import { AdminFilterTabs, AdminButton, AdminIconButton, + AdminViewModeTabs, useToast, Skeleton, AdminPagination, @@ -38,6 +39,7 @@ type Props = { hideInternalFilterBar?: boolean; }; +type ViewMode = "table" | "cards"; type SortField = "date" | "city" | "location" | "status"; type SortDirection = "asc" | "desc"; @@ -48,6 +50,42 @@ const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "acti draft: "draft", }; +// Icons +const Icons = { + search: (className: string) => ( + + + + + ), + plus: (className: string) => ( + + + + ), + pin: (className: string) => ( + + + + + ), + upload: (className: string) => ( + + + + + + ), +}; + +// Page header icon +const StopIconHeader = () => ( + + + + +); + export default function StopTableClient({ stops, hideInternalFilterBar = false }: Props) { const router = useRouter(); const { success: showSuccess, error: showError } = useToast(); @@ -64,6 +102,7 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false } const [showAddModal, setShowAddModal] = useState(false); const [sortField, setSortField] = useState("date"); const [sortDirection, setSortDirection] = useState("asc"); + const [viewMode, setViewMode] = useState("table"); const PAGE_SIZE = 25; useEffect(() => { @@ -118,7 +157,6 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false } 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"; @@ -234,67 +272,83 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false } return ( <> - {/* Stats Bar */} -
-
-
-
- - - -
-
-

{stats.total}

-

Total Stops

-
+ {/* Page Header */} +
+
+
+ +
+
+

+ Stops & Routes +

+

+ {sorted.length} stop{sorted.length !== 1 ? "s" : ""} + {search || statusFilter !== "all" ? " matching filters" : ""} +

-
-
-
- - - -
-
-

{stats.active}

-

Active

-
-
-
-
-
-
- - - -
-
-

{stats.upcoming}

-

Upcoming

-
-
-
-
-
-
- - - -
-
-

{stats.draft}

-

Drafts

-
-
+
+ setShowImport(true)} + icon={Icons.upload("h-4 w-4")} + > + Upload + + setShowAddModal(true)} + icon={Icons.plus("h-4 w-4")} + > + Add Stop +
- {/* Filter bar */} + {/* Stats Cards */} +
+
+

Total

+

+ {isLoading ? : stats.total} +

+
+
+

Active

+

+ {isLoading ? : stats.active} +

+
+
+

Upcoming

+

+ {isLoading ? : stats.upcoming} +

+
+
+

Drafts

+

+ {isLoading ? : stats.draft} +

+
+
+ + {/* Filters */} {!hideInternalFilterBar && ( -
+
+ { + setStatusFilter(TAB_NUMERIC[value as keyof typeof TAB_NUMERIC] ?? "all"); + setPage(0); + }} + tabs={tabs} + size="md" + /> { setSearch(e.target.value); @@ -304,52 +358,19 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false } setSearch(""); setPage(0); }} - showClear - className="flex-1 min-w-[180px] max-w-[280px]" + placeholder="Search stops..." + containerClassName="flex-1" /> - { - setStatusFilter(TAB_NUMERIC[value as keyof typeof TAB_NUMERIC] ?? "all"); - setPage(0); - }} - tabs={tabs} - size="sm" - showCounts + setViewMode(value as ViewMode)} /> - - {sorted.length} stop{sorted.length !== 1 ? "s" : ""} - - setShowImport(true)} - icon={ - - - - } - > - Upload - - setShowAddModal(true)} - icon={ - - - - } - > - Add Stop -
)} {/* Bulk actions bar */} {selectedStops.size > 0 && ( -
+
{selectedStops.size} stop{selectedStops.size !== 1 ? "s" : ""} selected @@ -374,7 +395,7 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false } {/* Delete error */} {deleteError && ( -
+
{deleteError}{" "}
)} - {/* Table */} -
- - - - - - - - - - - - {isLoading ? ( - Array.from({ length: 8 }).map((_, i) => ( - - - - - - - - - )) - ) : sorted.length === 0 ? ( - - - - ) : ( - paginatedStops.map((stop) => ( - { - e.stopPropagation(); - toggleStopSelection(stop.id); - }} - onEdit={() => setEditingStop(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 - - - -
-
-
- - - - -
-
-

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

-
-
-
-
+ {/* Content */} + {viewMode === "table" ? ( + { + const stop = stops.find((s) => s.id === id); + if (stop) { + setEditingStop(stop); + } + }} + onDeleted={handleDeleted} + onDeleteError={setDeleteError} + selectedStops={selectedStops} + onToggleSelectAll={toggleSelectAll} + onToggleSelect={toggleStopSelection} + isLoading={isLoading} + search={search} + statusFilter={statusFilter} + /> + ) : ( + + )} {/* Pagination */} {totalPages > 1 && ( -
+
Showing {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, sorted.length)} of {sorted.length} @@ -516,13 +474,206 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false } ); } +// ── Table View ───────────────────────────────────────────────────────────────── + +function TableView({ + stops, + sortField, + sortDirection, + onSort, + onEdit, + onDelete, + onDeleted, + onDeleteError, + selectedStops, + onToggleSelectAll, + onToggleSelect, + isLoading, + search, + statusFilter, +}: { + stops: Stop[]; + sortField: SortField; + sortDirection: SortDirection; + onSort: (field: SortField) => void; + onEdit: (stop: Stop) => void; + onDelete: (id: string) => void; + onDeleted: () => void; + onDeleteError: (msg: string) => void; + selectedStops: Set; + onToggleSelectAll: () => void; + onToggleSelect: (id: string) => void; + isLoading: boolean; + search: string; + statusFilter: string; +}) { + return ( +
+ + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 8 }).map((_, i) => ( + + + + + + + + + )) + ) : stops.length === 0 ? ( + + + + ) : ( + stops.map((stop) => ( + onEdit(stop)} + onDelete={() => onDelete(stop.id)} + onDeleted={onDeleted} + onDeleteError={onDeleteError} + isSelected={selectedStops.has(stop.id)} + onToggleSelect={() => onToggleSelect(stop.id)} + /> + )) + )} + +
+ 0} + onChange={onToggleSelectAll} + className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer" + aria-label="Select all" + /> + onSort("date")}> + + When + + + onSort("city")}> + + Where + + + onSort("location")}> + + Venue + + + onSort("status")}> + + Status + + + +
+
+ {Icons.pin("h-8 w-8 text-stone-400")} +
+

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

+

+ {search || statusFilter !== "all" + ? "Try a different search or clear the filters" + : "Add your first stop to get started"} +

+
+
+ ); +} + +// ── Card View ────────────────────────────────────────────────────────────────── + +function CardView({ + stops, + onEdit, + onDeleted, + onDeleteError, + isLoading, + search, + statusFilter, +}: { + stops: Stop[]; + onEdit: (stop: Stop) => void; + onDeleted: () => void; + onDeleteError: (msg: string) => void; + isLoading: boolean; + search: string; + statusFilter: string; +}) { + return ( +
+ {stops.length === 0 ? ( +
+
+ {Icons.pin("h-8 w-8 text-stone-400")} +
+

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

+

+ {search || statusFilter !== "all" + ? "Try a different search or clear the filters" + : "Add your first stop to get started"} +

+
+ ) : ( + stops.map((stop) => ( + onEdit(stop)} + onDeleted={onDeleted} + onDeleteError={onDeleteError} + /> + )) + )} +
+ ); +} + +// ── Shared helpers ───────────────────────────────────────────────────────────── + +function SortIcon({ field, sortField, sortDirection }: { field: SortField; sortField: SortField; sortDirection: SortDirection }) { + return ( + + {sortField === field && sortDirection === "desc" ? ( + + + + ) : ( + + + + )} + + ); +} + 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: "", 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" }); @@ -546,20 +697,24 @@ function formatTime(t: string): string { return `${hh}:${mStr} ${period}`; } -function StopRowBase({ +// ── Row Component ─────────────────────────────────────────────────────────────── + +function StopRow({ stop, + onEdit, + onDelete, onDeleted, onDeleteError, isSelected, onToggleSelect, - onEdit, }: { stop: Stop; + onEdit: () => void; + onDelete: () => void; onDeleted: () => void; onDeleteError: (msg: string) => void; isSelected: boolean; - onToggleSelect: (e: React.MouseEvent) => void; - onEdit: () => void; + onToggleSelect: () => void; }) { const { success: showSuccess, error: showError } = useToast(); const [openMenu, setOpenMenu] = useState(null); @@ -610,7 +765,10 @@ function StopRowBase({ type="checkbox" checked={isSelected} onChange={() => {}} - onClick={onToggleSelect} + onClick={(e) => { + e.stopPropagation(); + onToggleSelect(); + }} className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer" aria-label={`Select stop in ${stop.city}`} /> @@ -654,7 +812,7 @@ function StopRowBase({ e.stopPropagation()}>
- + Edit + + {openMenu === stop.id && ( <> @@ -705,25 +866,13 @@ function StopRowBase({ setConfirmDelete(null); }} /> -
- +
{stop.status === "draft" && ( + +
+ + Edit + + +
+
+ + {/* Delete confirm */} + {confirmDelete && ( +
+
+

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

+

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

+
+ + +
+
+
+ )} +
+ ); +}