83ad6536a3
Deploy to route.crispygoat.com / deploy (push) Successful in 5m46s
- Migrate login page to atelier design system (editorial modal style) - Polish root error.tsx, not-found.tsx, loading.tsx with atelier design - Add JSON-LD structured data: SoftwareApplication + LocalBusiness - Fix next.config.ts outputFileTracingRoot absolute path warning - Add force-dynamic to protected-example (uses cookies) - Add proper type for stops page (replace : any) - Fix unescaped quotes in StopTableClient - Fix no-explicit-any in db-schema route - Clean up console.logs in admin-permissions - Fix inline any in admin/stops page - Update OG image references to existing og-default.svg
1097 lines
40 KiB
TypeScript
1097 lines
40 KiB
TypeScript
"use client";
|
||
/* eslint-disable react-hooks/set-state-in-effect */
|
||
|
||
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";
|
||
import {
|
||
AdminSearchInput,
|
||
AdminFilterTabs,
|
||
AdminButton,
|
||
AdminIconButton,
|
||
AdminViewModeTabs,
|
||
useToast,
|
||
Skeleton,
|
||
AdminPagination,
|
||
} from "@/components/admin/design-system";
|
||
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
|
||
import EditStopModal from "@/components/admin/EditStopModal";
|
||
|
||
type Stop = {
|
||
id: string;
|
||
city: string;
|
||
state: string;
|
||
date: string;
|
||
time: string;
|
||
location: string;
|
||
active: boolean;
|
||
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[];
|
||
hideInternalFilterBar?: boolean;
|
||
};
|
||
|
||
type ViewMode = "table" | "cards";
|
||
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",
|
||
inactive: "inactive",
|
||
draft: "draft",
|
||
};
|
||
|
||
// Icons
|
||
const Icons = {
|
||
search: (className: string) => (
|
||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="11" cy="11" r="8"/>
|
||
<path d="m21 21-4.3-4.3"/>
|
||
</svg>
|
||
),
|
||
plus: (className: string) => (
|
||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M12 5v14M5 12h14"/>
|
||
</svg>
|
||
),
|
||
pin: (className: string) => (
|
||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<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>
|
||
),
|
||
upload: (className: string) => (
|
||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||
<polyline points="17 8 12 3 7 8"/>
|
||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||
</svg>
|
||
),
|
||
};
|
||
|
||
// Page header icon
|
||
const StopIconHeader = () => (
|
||
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<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 StopTableClient({ stops, hideInternalFilterBar = false }: Props) {
|
||
const router = useRouter();
|
||
const { success: showSuccess, error: showError } = useToast();
|
||
const [, startTransition] = useTransition();
|
||
const [search, setSearch] = useState("");
|
||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive" | "draft">("all");
|
||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||
const [page, setPage] = useState(0);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set());
|
||
const [bulkPublishing, setBulkPublishing] = useState(false);
|
||
const [showImport, setShowImport] = useState(false);
|
||
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 [viewMode, setViewMode] = useState<ViewMode>("table");
|
||
const PAGE_SIZE = 25;
|
||
|
||
useEffect(() => {
|
||
setIsLoading(true);
|
||
const timer = setTimeout(() => setIsLoading(false), 220);
|
||
return () => clearTimeout(timer);
|
||
}, [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();
|
||
return stops.filter((s) => {
|
||
const matchesSearch =
|
||
!q ||
|
||
s.city.toLowerCase().includes(q) ||
|
||
s.location.toLowerCase().includes(q) ||
|
||
s.state.toLowerCase().includes(q);
|
||
const matchesStatus =
|
||
statusFilter === "all" ||
|
||
(statusFilter === "active" && s.active && s.status !== "draft") ||
|
||
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
|
||
(statusFilter === "draft" && s.status === "draft");
|
||
return matchesSearch && matchesStatus;
|
||
});
|
||
}, [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":
|
||
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,
|
||
active: stops.filter((s) => s.active && s.status !== "draft").length,
|
||
inactive: stops.filter((s) => !s.active && s.status !== "draft").length,
|
||
draft: stops.filter((s) => s.status === "draft").length,
|
||
}),
|
||
[stops]
|
||
);
|
||
|
||
const tabs = useMemo(
|
||
() => [
|
||
{ value: "all", label: "All", count: statusCounts.all },
|
||
{ value: "active", label: "Active", count: statusCounts.active },
|
||
{ value: "inactive", label: "Inactive", count: statusCounts.inactive },
|
||
{ value: "draft", label: "Draft", count: statusCounts.draft },
|
||
],
|
||
[statusCounts]
|
||
);
|
||
|
||
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) {
|
||
setSelectedStops(new Set());
|
||
} else {
|
||
setSelectedStops(new Set(paginatedStops.map((s) => s.id)));
|
||
}
|
||
};
|
||
|
||
const toggleStopSelection = (stopId: string) => {
|
||
setSelectedStops((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(stopId)) {
|
||
next.delete(stopId);
|
||
} else {
|
||
next.add(stopId);
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
async function handleBulkPublish() {
|
||
if (selectedStops.size === 0) return;
|
||
setBulkPublishing(true);
|
||
let successCount = 0;
|
||
let failCount = 0;
|
||
for (const stopId of selectedStops) {
|
||
const stop = stops.find((s) => s.id === stopId);
|
||
if (stop && stop.status === "draft") {
|
||
const result = await publishStop(stopId, stop.brand_id);
|
||
if (result.success) {
|
||
successCount++;
|
||
} else {
|
||
failCount++;
|
||
}
|
||
}
|
||
}
|
||
setBulkPublishing(false);
|
||
setSelectedStops(new Set());
|
||
if (failCount === 0) {
|
||
showSuccess(`${successCount} stop${successCount !== 1 ? "s" : ""} published`);
|
||
} else {
|
||
showError("Some stops failed", `${successCount} succeeded, ${failCount} failed`);
|
||
}
|
||
startTransition(() => router.refresh());
|
||
}
|
||
|
||
function handleDeleted() {
|
||
setDeleteError(null);
|
||
startTransition(() => router.refresh());
|
||
}
|
||
|
||
function 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 (
|
||
<>
|
||
{/* Page Header */}
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-[var(--admin-accent)]/10">
|
||
<StopIconHeader />
|
||
</div>
|
||
<div>
|
||
<h1 className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] tracking-tight">
|
||
Stops & Routes
|
||
</h1>
|
||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||
{sorted.length} stop{sorted.length !== 1 ? "s" : ""}
|
||
{search || statusFilter !== "all" ? " matching filters" : ""}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<AdminButton
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => setShowImport(true)}
|
||
icon={Icons.upload("h-4 w-4")}
|
||
>
|
||
Upload
|
||
</AdminButton>
|
||
<AdminButton
|
||
variant="primary"
|
||
size="sm"
|
||
onClick={() => setShowAddModal(true)}
|
||
icon={Icons.plus("h-4 w-4")}
|
||
>
|
||
Add Stop
|
||
</AdminButton>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stats Cards */}
|
||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 mb-6">
|
||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
|
||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1 tabular-nums">
|
||
{isLoading ? <Skeleton variant="text" className="w-12 h-6" /> : stats.total}
|
||
</p>
|
||
</div>
|
||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Active</p>
|
||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-accent)] mt-1 tabular-nums">
|
||
{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : stats.active}
|
||
</p>
|
||
</div>
|
||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Upcoming</p>
|
||
<p className="text-xl sm:text-2xl font-bold text-sky-600 mt-1 tabular-nums">
|
||
{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : stats.upcoming}
|
||
</p>
|
||
</div>
|
||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Drafts</p>
|
||
<p className="text-xl sm:text-2xl font-bold text-stone-400 mt-1 tabular-nums">
|
||
{isLoading ? <Skeleton variant="text" className="w-8 h-6" /> : stats.draft}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filters */}
|
||
{!hideInternalFilterBar && (
|
||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||
<AdminFilterTabs
|
||
activeTab={statusFilter}
|
||
onTabChange={(value) => {
|
||
setStatusFilter(TAB_NUMERIC[value as keyof typeof TAB_NUMERIC] ?? "all");
|
||
setPage(0);
|
||
}}
|
||
tabs={tabs}
|
||
size="md"
|
||
/>
|
||
<AdminSearchInput
|
||
value={search}
|
||
onChange={(e) => {
|
||
setSearch(e.target.value);
|
||
setPage(0);
|
||
}}
|
||
onClear={() => {
|
||
setSearch("");
|
||
setPage(0);
|
||
}}
|
||
placeholder="Search stops..."
|
||
containerClassName="flex-1"
|
||
/>
|
||
<AdminViewModeTabs
|
||
activeTab={viewMode}
|
||
onTabChange={(value) => setViewMode(value as ViewMode)}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Bulk actions bar */}
|
||
{selectedStops.size > 0 && (
|
||
<div className="mb-4 flex items-center justify-between rounded-xl border border-emerald-200 bg-emerald-50/60 px-4 py-2.5">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-sm font-semibold text-emerald-800 tabular-nums">
|
||
{selectedStops.size} stop{selectedStops.size !== 1 ? "s" : ""} selected
|
||
</span>
|
||
<button
|
||
onClick={() => setSelectedStops(new Set())}
|
||
className="text-xs text-stone-500 hover:text-stone-700"
|
||
>
|
||
Clear
|
||
</button>
|
||
</div>
|
||
<AdminButton
|
||
variant="primary"
|
||
size="sm"
|
||
onClick={handleBulkPublish}
|
||
isLoading={bulkPublishing}
|
||
>
|
||
Publish Selected
|
||
</AdminButton>
|
||
</div>
|
||
)}
|
||
|
||
{/* Delete error */}
|
||
{deleteError && (
|
||
<div className="mb-4 rounded-lg border border-red-500/30 bg-red-50 px-4 py-2.5 text-sm text-red-700">
|
||
{deleteError}{" "}
|
||
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
|
||
Dismiss
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Content */}
|
||
{viewMode === "table" ? (
|
||
<TableView
|
||
stops={paginatedStops}
|
||
sortField={sortField}
|
||
sortDirection={sortDirection}
|
||
onSort={handleSort}
|
||
onEdit={setEditingStop}
|
||
onDelete={(id) => {
|
||
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}
|
||
/>
|
||
) : (
|
||
<CardView
|
||
stops={paginatedStops}
|
||
onEdit={setEditingStop}
|
||
onDeleted={handleDeleted}
|
||
onDeleteError={setDeleteError}
|
||
isLoading={isLoading}
|
||
search={search}
|
||
statusFilter={statusFilter}
|
||
/>
|
||
)}
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-between border-t border-[var(--admin-border)] mt-4 pt-4">
|
||
<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
|
||
brandId={stops[0]?.brand_id ?? ""}
|
||
onClose={() => setShowImport(false)}
|
||
onComplete={refresh}
|
||
/>
|
||
)}
|
||
|
||
<EditStopModal
|
||
isOpen={showAddModal || editingStop !== null}
|
||
onClose={() => {
|
||
setShowAddModal(false);
|
||
setEditingStop(null);
|
||
}}
|
||
brandId={stops[0]?.brand_id ?? ""}
|
||
stop={editingStop}
|
||
onSuccess={refresh}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ── 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<string>;
|
||
onToggleSelectAll: () => void;
|
||
onToggleSelect: (id: string) => void;
|
||
isLoading: boolean;
|
||
search: string;
|
||
statusFilter: string;
|
||
}) {
|
||
return (
|
||
<div className="overflow-visible rounded-xl border border-[var(--admin-border)] bg-white">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-stone-50">
|
||
<tr>
|
||
<th className="w-10 px-4 py-3">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedStops.size === stops.length && stops.length > 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"
|
||
/>
|
||
</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs cursor-pointer hover:text-stone-600 transition-colors" onClick={() => onSort("date")}>
|
||
<span className="inline-flex items-center">
|
||
When
|
||
<SortIcon field="date" sortField={sortField} sortDirection={sortDirection} />
|
||
</span>
|
||
</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs cursor-pointer hover:text-stone-600 transition-colors" onClick={() => onSort("city")}>
|
||
<span className="inline-flex items-center">
|
||
Where
|
||
<SortIcon field="city" sortField={sortField} sortDirection={sortDirection} />
|
||
</span>
|
||
</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs cursor-pointer hover:text-stone-600 transition-colors" onClick={() => onSort("location")}>
|
||
<span className="inline-flex items-center">
|
||
Venue
|
||
<SortIcon field="location" sortField={sortField} sortDirection={sortDirection} />
|
||
</span>
|
||
</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs cursor-pointer hover:text-stone-600 transition-colors" onClick={() => onSort("status")}>
|
||
<span className="inline-flex items-center">
|
||
Status
|
||
<SortIcon field="status" sortField={sortField} sortDirection={sortDirection} />
|
||
</span>
|
||
</th>
|
||
<th className="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>
|
||
))
|
||
) : stops.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={6} className="px-4 py-16 text-center">
|
||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
||
{Icons.pin("h-8 w-8 text-stone-400")}
|
||
</div>
|
||
<p className="text-sm font-medium text-stone-600">
|
||
{search || statusFilter !== "all"
|
||
? "No stops match your filters"
|
||
: "No stops yet"}
|
||
</p>
|
||
<p className="text-xs text-stone-400 mt-1">
|
||
{search || statusFilter !== "all"
|
||
? "Try a different search or clear the filters"
|
||
: "Add your first stop to get started"}
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
stops.map((stop) => (
|
||
<StopRow
|
||
key={stop.id}
|
||
stop={stop}
|
||
onEdit={() => onEdit(stop)}
|
||
onDelete={() => onDelete(stop.id)}
|
||
onDeleted={onDeleted}
|
||
onDeleteError={onDeleteError}
|
||
isSelected={selectedStops.has(stop.id)}
|
||
onToggleSelect={() => onToggleSelect(stop.id)}
|
||
/>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||
{stops.length === 0 ? (
|
||
<div className="col-span-full text-center py-16 rounded-xl border border-[var(--admin-border)] bg-white">
|
||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
||
{Icons.pin("h-8 w-8 text-stone-400")}
|
||
</div>
|
||
<p className="text-sm font-medium text-stone-600">
|
||
{search || statusFilter !== "all"
|
||
? "No stops match your filters"
|
||
: "No stops yet"}
|
||
</p>
|
||
<p className="text-xs text-stone-400 mt-1">
|
||
{search || statusFilter !== "all"
|
||
? "Try a different search or clear the filters"
|
||
: "Add your first stop to get started"}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
stops.map((stop) => (
|
||
<StopCard
|
||
key={stop.id}
|
||
stop={stop}
|
||
onEdit={() => onEdit(stop)}
|
||
onDeleted={onDeleted}
|
||
onDeleteError={onDeleteError}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||
|
||
function SortIcon({ field, sortField, sortDirection }: { field: SortField; sortField: SortField; sortDirection: SortDirection }) {
|
||
return (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
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 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,
|
||
isToday: iso === todayStr,
|
||
isPast: iso < todayStr
|
||
};
|
||
}
|
||
|
||
function formatTime(t: string): string {
|
||
if (!t) return "";
|
||
const [hStr, mStr] = t.split(":");
|
||
const h = parseInt(hStr, 10);
|
||
if (Number.isNaN(h)) return t;
|
||
const period = h >= 12 ? "PM" : "AM";
|
||
const hh = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||
return `${hh}:${mStr} ${period}`;
|
||
}
|
||
|
||
// ── Row Component ───────────────────────────────────────────────────────────────
|
||
|
||
function StopRow({
|
||
stop,
|
||
onEdit,
|
||
onDelete,
|
||
onDeleted,
|
||
onDeleteError,
|
||
isSelected,
|
||
onToggleSelect,
|
||
}: {
|
||
stop: Stop;
|
||
onEdit: () => void;
|
||
onDelete: () => void;
|
||
onDeleted: () => void;
|
||
onDeleteError: (msg: string) => void;
|
||
isSelected: boolean;
|
||
onToggleSelect: () => void;
|
||
}) {
|
||
const { success: showSuccess, error: showError } = useToast();
|
||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||
|
||
const { date, weekday, isToday, isPast } = formatDate(stop.date);
|
||
const time = formatTime(stop.time);
|
||
|
||
async function handlePublish() {
|
||
setPublishingId(stop.id);
|
||
const result = await publishStop(stop.id, stop.brand_id);
|
||
setPublishingId(null);
|
||
if (result.success) {
|
||
showSuccess("Stop published", "The stop is now visible on the storefront");
|
||
onDeleted();
|
||
} else {
|
||
showError("Failed to publish", result.error ?? "Please try again");
|
||
}
|
||
}
|
||
|
||
async function handleDelete() {
|
||
setDeletingId(stop.id);
|
||
const result = await deleteStop(stop.id, stop.brand_id);
|
||
setDeletingId(null);
|
||
setConfirmDelete(null);
|
||
setOpenMenu(null);
|
||
if (result.success) {
|
||
showSuccess("Stop deleted", "The stop has been removed");
|
||
onDeleted();
|
||
} else {
|
||
onDeleteError(result.error ?? "Delete failed");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<tr
|
||
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
|
||
type="checkbox"
|
||
checked={isSelected}
|
||
onChange={() => {}}
|
||
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}`}
|
||
/>
|
||
</td>
|
||
|
||
<td className="px-4 py-3.5">
|
||
<div className="flex flex-col leading-tight">
|
||
<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>
|
||
</td>
|
||
|
||
<td className="px-4 py-3.5">
|
||
<div className="flex flex-col leading-tight">
|
||
<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-stone-600">{stop.location}</span>
|
||
{stop.address && (
|
||
<span className="text-[11px] text-stone-400 truncate max-w-[260px]">
|
||
{stop.address}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
|
||
<td className="px-4 py-3.5">
|
||
<span
|
||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${
|
||
stop.status === "draft"
|
||
? "bg-amber-100 text-amber-700 border border-amber-200"
|
||
: stop.active
|
||
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
|
||
: "bg-stone-100 text-stone-500 border border-stone-200"
|
||
}`}
|
||
>
|
||
<span
|
||
className={`h-1.5 w-1.5 rounded-full ${
|
||
stop.status === "draft"
|
||
? "bg-amber-500"
|
||
: stop.active
|
||
? "bg-emerald-500"
|
||
: "bg-stone-400"
|
||
}`}
|
||
/>
|
||
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
|
||
</span>
|
||
</td>
|
||
|
||
<td className="px-4 py-3.5 text-right" onClick={(e) => e.stopPropagation()}>
|
||
<div className="relative inline-flex items-center justify-end gap-1">
|
||
<AdminButton
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={onEdit}
|
||
>
|
||
Edit
|
||
</AdminButton>
|
||
<button
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setOpenMenu(openMenu === stop.id ? null : stop.id);
|
||
}}
|
||
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors"
|
||
aria-label="Stop actions"
|
||
>
|
||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||
</svg>
|
||
</button>
|
||
|
||
{openMenu === stop.id && (
|
||
<>
|
||
<div
|
||
className="fixed inset-0 z-10"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setOpenMenu(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">
|
||
{stop.status === "draft" && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handlePublish();
|
||
setOpenMenu(null);
|
||
}}
|
||
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 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 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
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
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 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>
|
||
</>
|
||
)}
|
||
|
||
{confirmDelete === stop.id && (
|
||
<>
|
||
<div
|
||
className="fixed inset-0 z-30"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setConfirmDelete(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">
|
||
<p className="text-sm font-semibold text-stone-800">
|
||
Delete “{stop.city}, {stop.state}”?
|
||
</p>
|
||
<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">
|
||
<AdminButton
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setConfirmDelete(null);
|
||
setOpenMenu(null);
|
||
}}
|
||
>
|
||
Cancel
|
||
</AdminButton>
|
||
<AdminButton
|
||
variant="danger"
|
||
size="sm"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDelete();
|
||
}}
|
||
disabled={deletingId === stop.id}
|
||
isLoading={deletingId === stop.id}
|
||
>
|
||
{deletingId === stop.id ? "Deleting…" : "Delete"}
|
||
</AdminButton>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
// ── Card Component ─────────────────────────────────────────────────────────────
|
||
|
||
function StopCard({
|
||
stop,
|
||
onEdit,
|
||
onDeleted,
|
||
onDeleteError,
|
||
}: {
|
||
stop: Stop;
|
||
onEdit: () => void;
|
||
onDeleted: () => void;
|
||
onDeleteError: (msg: string) => void;
|
||
}) {
|
||
const { success: showSuccess, error: showError } = useToast();
|
||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||
const [deleting, setDeleting] = useState(false);
|
||
|
||
const { date, weekday, isToday, isPast } = formatDate(stop.date);
|
||
const time = formatTime(stop.time);
|
||
|
||
async function handleDelete() {
|
||
setDeleting(true);
|
||
const result = await deleteStop(stop.id, stop.brand_id);
|
||
setDeleting(false);
|
||
if (result.success) {
|
||
showSuccess("Stop deleted", "The stop has been removed");
|
||
onDeleted();
|
||
} else {
|
||
onDeleteError(result.error ?? "Delete failed");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={`group relative rounded-xl border border-[var(--admin-border)] bg-white hover:shadow-md hover:border-[var(--admin-accent)]/30 transition-all overflow-hidden ${
|
||
isPast && stop.status !== "draft" ? "opacity-70" : ""
|
||
}`}
|
||
>
|
||
{/* Header with date */}
|
||
<div className="bg-gradient-to-br from-stone-50 to-stone-100/50 px-4 pt-4 pb-3 border-b border-stone-100">
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<div className="flex items-center gap-1.5 mb-1">
|
||
{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>
|
||
)}
|
||
</div>
|
||
<p className="text-lg font-bold text-stone-800 tabular-nums">{date}</p>
|
||
<p className="text-xs text-stone-500 tabular-nums">{weekday} {time && `· ${time}`}</p>
|
||
</div>
|
||
<span
|
||
className={`shrink-0 rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${
|
||
stop.status === "draft"
|
||
? "bg-amber-100 text-amber-700 border border-amber-200"
|
||
: stop.active
|
||
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
|
||
: "bg-stone-100 text-stone-500 border border-stone-200"
|
||
}`}
|
||
>
|
||
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="p-4">
|
||
<button onClick={onEdit} className="block w-full text-left">
|
||
<h3 className="font-semibold text-[var(--admin-text-primary)] group-hover:text-[var(--admin-accent)] transition-colors">
|
||
{stop.city}, {stop.state}
|
||
</h3>
|
||
<p className="text-xs text-stone-500 mt-1 line-clamp-2">
|
||
{stop.location}
|
||
</p>
|
||
{stop.address && (
|
||
<p className="text-xs text-stone-400 mt-1 line-clamp-1">
|
||
{stop.address}
|
||
</p>
|
||
)}
|
||
</button>
|
||
|
||
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-stone-100">
|
||
<AdminButton
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={onEdit}
|
||
className="flex-1"
|
||
>
|
||
Edit
|
||
</AdminButton>
|
||
<button
|
||
onClick={() => setConfirmDelete(true)}
|
||
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-muted)] hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors"
|
||
>
|
||
<svg className="h-4 w-4" 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>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Delete confirm */}
|
||
{confirmDelete && (
|
||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/95 backdrop-blur-sm">
|
||
<div className="bg-white rounded-xl shadow-xl p-4 max-w-[280px] mx-4 border border-[var(--admin-border)]">
|
||
<p className="text-sm font-semibold text-stone-900">
|
||
Delete “{stop.city}, {stop.state}”?
|
||
</p>
|
||
<p className="mt-1 text-xs text-stone-500">
|
||
This will remove the stop. If it has active orders, you must resolve those first.
|
||
</p>
|
||
<div className="flex gap-2 mt-3">
|
||
<button
|
||
onClick={() => setConfirmDelete(false)}
|
||
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={handleDelete}
|
||
disabled={deleting}
|
||
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-red-500"
|
||
>
|
||
{deleting ? "..." : "Delete"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|