feat(admin/stops): add tabbed dashboard with calendar and locations views
Replace the table-only stops view with a three-tab 'Harvest Dispatch' almanac dashboard. - Calendar tab: month grid with stops as status-colored pins, day-detail side panel, prev/next/today nav, weekend tint, sunrise gradient on the current day. - Locations tab: cards grouped by city+state with per-location stats (stops/active/upcoming), date range, next-stop ribbon, and inline expansion to list every stop at that location. Top of the tab shows a six-card almanac stats strip (Locations/Cities/Stops/Active/Upcoming/ Drafts). - List tab: condensed read-only list of all stops in date order. Typography: load Fraunces (display), Manrope (body), and Fragment Mono (mono) via next/font/google, wired into the Tailwind theme as --font-display, --font-sans, --font-mono. Add typescript to devDeps for tsc --noEmit in this environment. Visual signature: cream parchment cards, forest green + clay accents, Roman-numeral stat cells, paper-grain noise, binder-tab nav, route number watermarks on location cards, harvest-pin SVG markers. Verified with tsc --noEmit, eslint on the new files, and a 200 from GET /admin/stops in the dev server.
This commit is contained in:
+1
-1
@@ -58,7 +58,7 @@
|
|||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"playwright": "^1.59.1",
|
"playwright": "^1.59.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"overrides": "{}"
|
"overrides": "{}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import StopTableClient from "@/components/admin/StopTableClient";
|
import StopsDashboardClient from "@/components/admin/stops/StopsDashboardClient";
|
||||||
import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
|
import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
@@ -90,9 +90,7 @@ export default async function AdminStopsPage() {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||||
<div className="overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
|
<StopsDashboardClient stops={stops ?? []} />
|
||||||
<StopTableClient stops={stops ?? []} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
+6
-1
@@ -1,6 +1,11 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
|
/* Custom typefaces — Field Almanac */
|
||||||
|
--font-display: var(--font-fraunces, "Georgia"), Georgia, serif;
|
||||||
|
--font-sans: var(--font-manrope, system-ui), system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
--font-mono: var(--font-fragment-mono, "JetBrains Mono"), ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
|
||||||
/* Apple-inspired dark palette — sophisticated depth */
|
/* Apple-inspired dark palette — sophisticated depth */
|
||||||
--color-surface-50: #f5f5f7;
|
--color-surface-50: #f5f5f7;
|
||||||
--color-surface-100: #e8e8ed;
|
--color-surface-100: #e8e8ed;
|
||||||
@@ -85,7 +90,7 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
font-family: var(--font-sans);
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
|
|||||||
+23
-2
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { Fraunces, Manrope, Fragment_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Providers } from "@/components/Providers";
|
import { Providers } from "@/components/Providers";
|
||||||
import ToastNotificationContainer from "@/components/notifications/ToastNotification";
|
import ToastNotificationContainer from "@/components/notifications/ToastNotification";
|
||||||
@@ -6,6 +7,26 @@ import CookieConsentBanner from "@/components/legal/CookieConsentBanner";
|
|||||||
|
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||||
|
|
||||||
|
const fraunces = Fraunces({
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-fraunces",
|
||||||
|
axes: ["SOFT", "WONK", "opsz"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const manrope = Manrope({
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-manrope",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fragmentMono = Fragment_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-fragment-mono",
|
||||||
|
weight: "400",
|
||||||
|
});
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
@@ -50,8 +71,8 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning className={`${fraunces.variable} ${manrope.variable} ${fragmentMono.variable}`}>
|
||||||
<body>
|
<body className="font-sans">
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
<ToastNotificationContainer />
|
<ToastNotificationContainer />
|
||||||
<CookieConsentBanner />
|
<CookieConsentBanner />
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
type Stop,
|
||||||
|
type StopStatus,
|
||||||
|
formatMonthYear,
|
||||||
|
formatTime12,
|
||||||
|
getStopDate,
|
||||||
|
getStopStatus,
|
||||||
|
isSameDay,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
stops: Stop[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<StopStatus, { bg: string; text: string; dot: string; border: string }> = {
|
||||||
|
active: {
|
||||||
|
bg: "bg-[var(--admin-accent-light)]",
|
||||||
|
text: "text-[var(--admin-accent-text)]",
|
||||||
|
dot: "bg-[var(--admin-accent-dot)]",
|
||||||
|
border: "border-[var(--admin-accent)]/40",
|
||||||
|
},
|
||||||
|
draft: {
|
||||||
|
bg: "bg-amber-50",
|
||||||
|
text: "text-amber-800",
|
||||||
|
dot: "bg-amber-500",
|
||||||
|
border: "border-amber-300",
|
||||||
|
},
|
||||||
|
inactive: {
|
||||||
|
bg: "bg-stone-100",
|
||||||
|
text: "text-stone-500",
|
||||||
|
dot: "bg-stone-400",
|
||||||
|
border: "border-stone-300",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildMonthGrid(viewYear: number, viewMonth: number) {
|
||||||
|
// Sunday-first 6-row grid
|
||||||
|
const first = new Date(viewYear, viewMonth, 1);
|
||||||
|
const startWeekday = first.getDay();
|
||||||
|
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||||
|
|
||||||
|
const cells: { date: Date; inMonth: boolean }[] = [];
|
||||||
|
// Leading days from previous month
|
||||||
|
for (let i = startWeekday - 1; i >= 0; i--) {
|
||||||
|
const d = new Date(viewYear, viewMonth, -i);
|
||||||
|
cells.push({ date: d, inMonth: false });
|
||||||
|
}
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
|
cells.push({ date: new Date(viewYear, viewMonth, d), inMonth: true });
|
||||||
|
}
|
||||||
|
// Trailing days to fill 6 rows = 42 cells
|
||||||
|
while (cells.length < 42) {
|
||||||
|
const last = cells[cells.length - 1].date;
|
||||||
|
const next = new Date(last);
|
||||||
|
next.setDate(last.getDate() + 1);
|
||||||
|
cells.push({ date: next, inMonth: false });
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ymd(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StopsCalendar({ stops }: Props) {
|
||||||
|
const today = useMemo(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [view, setView] = useState({ year: today.getFullYear(), month: today.getMonth() });
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string | null>(ymd(today));
|
||||||
|
|
||||||
|
const cells = useMemo(() => buildMonthGrid(view.year, view.month), [view]);
|
||||||
|
|
||||||
|
const stopsByDate = useMemo(() => {
|
||||||
|
const map = new Map<string, Stop[]>();
|
||||||
|
for (const s of stops) {
|
||||||
|
const d = getStopDate(s);
|
||||||
|
if (!d) continue;
|
||||||
|
const k = ymd(d);
|
||||||
|
const arr = map.get(k) ?? [];
|
||||||
|
arr.push(s);
|
||||||
|
map.set(k, arr);
|
||||||
|
}
|
||||||
|
// sort each bucket by time
|
||||||
|
for (const arr of map.values()) {
|
||||||
|
arr.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [stops]);
|
||||||
|
|
||||||
|
const visibleStopsByDate = useMemo(() => {
|
||||||
|
const map = new Map<string, Stop[]>();
|
||||||
|
for (const [k, arr] of stopsByDate.entries()) {
|
||||||
|
map.set(
|
||||||
|
k,
|
||||||
|
arr.filter((s) => getStopStatus(s) !== "inactive" || arr.length <= 6)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [stopsByDate]);
|
||||||
|
|
||||||
|
const monthStopsCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
for (const cell of cells) {
|
||||||
|
if (!cell.inMonth) continue;
|
||||||
|
count += stopsByDate.get(ymd(cell.date))?.length ?? 0;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}, [cells, stopsByDate]);
|
||||||
|
|
||||||
|
const upcomingWeek = useMemo(() => {
|
||||||
|
const end = new Date(today);
|
||||||
|
end.setDate(end.getDate() + 7);
|
||||||
|
let count = 0;
|
||||||
|
for (const s of stops) {
|
||||||
|
const d = getStopDate(s);
|
||||||
|
if (!d) continue;
|
||||||
|
if (d >= today && d < end && getStopStatus(s) !== "inactive") count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}, [stops, today]);
|
||||||
|
|
||||||
|
const goPrev = () => {
|
||||||
|
setView((v) => {
|
||||||
|
const m = v.month - 1;
|
||||||
|
if (m < 0) return { year: v.year - 1, month: 11 };
|
||||||
|
return { ...v, month: m };
|
||||||
|
});
|
||||||
|
setSelectedDate(null);
|
||||||
|
};
|
||||||
|
const goNext = () => {
|
||||||
|
setView((v) => {
|
||||||
|
const m = v.month + 1;
|
||||||
|
if (m > 11) return { year: v.year + 1, month: 0 };
|
||||||
|
return { ...v, month: m };
|
||||||
|
});
|
||||||
|
setSelectedDate(null);
|
||||||
|
};
|
||||||
|
const goToday = () => {
|
||||||
|
setView({ year: today.getFullYear(), month: today.getMonth() });
|
||||||
|
setSelectedDate(ymd(today));
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthLabel = formatMonthYear(new Date(view.year, view.month, 1));
|
||||||
|
const isCurrentMonth = view.year === today.getFullYear() && view.month === today.getMonth();
|
||||||
|
|
||||||
|
const selectedDayStops = selectedDate ? stopsByDate.get(selectedDate) ?? [] : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-6">
|
||||||
|
{/* Calendar card */}
|
||||||
|
<section
|
||||||
|
className="relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm"
|
||||||
|
aria-label="Stops calendar"
|
||||||
|
>
|
||||||
|
{/* Decorative paper texture */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-[0.4]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"radial-gradient(circle at 20% 10%, rgba(34,197,94,0.04) 0%, transparent 40%), radial-gradient(circle at 80% 90%, rgba(217,119,6,0.04) 0%, transparent 40%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-[0.035]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0.5 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<header className="relative flex items-center justify-between border-b border-[var(--admin-border)] px-6 py-5">
|
||||||
|
<div className="flex items-baseline gap-4">
|
||||||
|
<h2 className="font-display text-3xl font-medium text-[var(--admin-text-primary)] tracking-tight">
|
||||||
|
{monthLabel.split(" ")[0]}
|
||||||
|
<span className="text-[var(--admin-accent)] italic font-light">
|
||||||
|
{" "}
|
||||||
|
{monthLabel.split(" ")[1]}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<span className="hidden sm:inline-block font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--admin-text-muted)]">
|
||||||
|
{monthStopsCount} stop{monthStopsCount === 1 ? "" : "s"} scheduled
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={goPrev}
|
||||||
|
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||||
|
aria-label="Previous month"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={goToday}
|
||||||
|
disabled={isCurrentMonth}
|
||||||
|
className="h-8 px-3 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={goNext}
|
||||||
|
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||||
|
aria-label="Next month"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Weekday header */}
|
||||||
|
<div className="relative grid grid-cols-7 border-b border-[var(--admin-border)]">
|
||||||
|
{WEEKDAY_LABELS.map((d, i) => (
|
||||||
|
<div
|
||||||
|
key={d}
|
||||||
|
className={`px-2 py-2.5 text-center font-mono text-[10px] uppercase tracking-[0.2em] ${
|
||||||
|
i === 0 || i === 6 ? "text-[var(--admin-accent)]" : "text-[var(--admin-text-muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="relative grid grid-cols-7 grid-rows-6">
|
||||||
|
{cells.map((cell, idx) => {
|
||||||
|
const k = ymd(cell.date);
|
||||||
|
const dayStops = stopsByDate.get(k) ?? [];
|
||||||
|
const visibleStops = visibleStopsByDate.get(k) ?? [];
|
||||||
|
const isToday = isSameDay(cell.date, today);
|
||||||
|
const isSelected = k === selectedDate;
|
||||||
|
const isWeekend = cell.date.getDay() === 0 || cell.date.getDay() === 6;
|
||||||
|
const overflow = dayStops.length - visibleStops.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedDate(k)}
|
||||||
|
className={`
|
||||||
|
group relative flex min-h-[110px] flex-col items-stretch gap-1.5 border-b border-r border-[var(--admin-border-light)] p-2 text-left transition-colors
|
||||||
|
${!cell.inMonth ? "bg-[var(--admin-bg-subtle)]/40" : "bg-white"}
|
||||||
|
${isWeekend && cell.inMonth ? "bg-[var(--admin-bg-subtle)]/30" : ""}
|
||||||
|
${isSelected ? "!bg-[var(--admin-accent-light)] ring-2 ring-inset ring-[var(--admin-accent)]" : "hover:bg-[var(--admin-accent-light)]/40"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Date number row */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
inline-flex h-6 min-w-[1.5rem] items-center justify-center rounded-md px-1.5 font-mono text-[11px] font-semibold
|
||||||
|
${isToday
|
||||||
|
? "bg-gradient-to-br from-amber-300 to-orange-400 text-white shadow-sm"
|
||||||
|
: !cell.inMonth
|
||||||
|
? "text-[var(--admin-text-muted)]/50"
|
||||||
|
: isSelected
|
||||||
|
? "text-[var(--admin-accent-text)]"
|
||||||
|
: "text-[var(--admin-text-primary)]"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{cell.date.getDate()}
|
||||||
|
</span>
|
||||||
|
{dayStops.length > 0 && cell.inMonth && (
|
||||||
|
<span className="font-mono text-[9px] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||||
|
{dayStops.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stop chips */}
|
||||||
|
<div className="flex flex-col gap-0.5 overflow-hidden">
|
||||||
|
{visibleStops.slice(0, 3).map((s) => {
|
||||||
|
const status = getStopStatus(s);
|
||||||
|
const st = STATUS_STYLES[status];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={`flex items-center gap-1 rounded ${st.bg} ${st.border} border px-1.5 py-0.5 text-[10px] font-medium leading-tight ${st.text}`}
|
||||||
|
>
|
||||||
|
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${st.dot}`} aria-hidden />
|
||||||
|
<span className="truncate">
|
||||||
|
{s.time && (
|
||||||
|
<span className="font-mono opacity-70 mr-0.5">{formatTime12(s.time).replace(" ", "").toLowerCase()}</span>
|
||||||
|
)}
|
||||||
|
{s.city}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{overflow > 0 && (
|
||||||
|
<div className="px-1.5 text-[10px] font-mono font-semibold text-[var(--admin-accent-text)]">
|
||||||
|
+{overflow} more
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<footer className="relative flex flex-wrap items-center gap-4 border-t border-[var(--admin-border)] px-6 py-3">
|
||||||
|
{(["active", "draft", "inactive"] as StopStatus[]).map((s) => {
|
||||||
|
const st = STATUS_STYLES[s];
|
||||||
|
return (
|
||||||
|
<div key={s} className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||||
|
<span className={`h-2 w-2 rounded-full ${st.dot}`} aria-hidden />
|
||||||
|
<span>{s}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="ml-auto flex items-center gap-3 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||||
|
<span>
|
||||||
|
<span className="font-display text-base font-semibold text-[var(--admin-accent)]">
|
||||||
|
{upcomingWeek}
|
||||||
|
</span>{" "}
|
||||||
|
upcoming in 7d
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Day detail panel */}
|
||||||
|
<aside className="rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
|
||||||
|
<div className="border-b border-[var(--admin-border)] px-5 py-4">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<h3 className="font-display text-2xl font-medium text-[var(--admin-text-primary)]">
|
||||||
|
{selectedDate
|
||||||
|
? new Date(selectedDate + "T00:00:00").toLocaleDateString("en-US", { weekday: "long" })
|
||||||
|
: "Pick a day"}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
|
||||||
|
{selectedDate
|
||||||
|
? new Date(selectedDate + "T00:00:00").toLocaleDateString("en-US", {
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
: "Select a date on the calendar"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[640px] overflow-y-auto px-2 py-2">
|
||||||
|
{!selectedDate ? (
|
||||||
|
<div className="px-4 py-10 text-center font-mono text-[11px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||||
|
No day selected
|
||||||
|
</div>
|
||||||
|
) : selectedDayStops.length === 0 ? (
|
||||||
|
<div className="px-4 py-10 text-center">
|
||||||
|
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
|
||||||
|
<svg className="h-5 w-5 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="font-display text-lg text-[var(--admin-text-primary)]">No stops</p>
|
||||||
|
<p className="mt-1 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||||
|
Free day on the route
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{selectedDayStops.map((s) => {
|
||||||
|
const status = getStopStatus(s);
|
||||||
|
const st = STATUS_STYLES[status];
|
||||||
|
return (
|
||||||
|
<li key={s.id}>
|
||||||
|
<Link
|
||||||
|
href={`/admin/stops/${s.id}`}
|
||||||
|
className="group block rounded-xl border border-transparent px-3 py-3 transition-all hover:border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${st.bg} ${st.border} border`}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[10px] font-bold text-[var(--admin-text-primary)]">
|
||||||
|
{formatTime12(s.time).split(" ")[0]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<p className="truncate font-display text-base font-medium text-[var(--admin-text-primary)] group-hover:text-[var(--admin-accent)] transition-colors">
|
||||||
|
{s.city}, {s.state}
|
||||||
|
</p>
|
||||||
|
<span className="font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||||
|
{formatTime12(s.time).split(" ")[1]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-xs text-[var(--admin-text-secondary)]">{s.location}</p>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full ${st.bg} ${st.text} px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider ${st.border} border`}
|
||||||
|
>
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} aria-hidden />
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
{Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name ? (
|
||||||
|
<span className="truncate font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||||
|
{Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { type Stop, type StopView, getStopStatus } from "./types";
|
||||||
|
import StopsCalendar from "./StopsCalendar";
|
||||||
|
import StopsLocations from "./StopsLocations";
|
||||||
|
import StopsList from "./StopsList";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
stops: Stop[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABS: { value: StopView; label: string; hint: string }[] = [
|
||||||
|
{ value: "calendar", label: "Calendar", hint: "Month at a glance" },
|
||||||
|
{ value: "locations", label: "Locations", hint: "Stops grouped by city" },
|
||||||
|
{ value: "list", label: "List", hint: "All stops in order" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function StopsDashboardClient({ stops }: Props) {
|
||||||
|
const [view, setView] = useState<StopView>("calendar");
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = stops.length;
|
||||||
|
const active = stops.filter((s) => getStopStatus(s) === "active").length;
|
||||||
|
const draft = stops.filter((s) => getStopStatus(s) === "draft").length;
|
||||||
|
const cities = new Set(stops.map((s) => s.city.trim().toLowerCase())).size;
|
||||||
|
const venues = new Set(
|
||||||
|
stops.map((s) => `${s.city}|${s.state}|${s.location}`.toLowerCase())
|
||||||
|
).size;
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const in7 = new Date(today);
|
||||||
|
in7.setDate(today.getDate() + 7);
|
||||||
|
const upcoming = stops.filter((s) => {
|
||||||
|
if (!s.date) return false;
|
||||||
|
if (getStopStatus(s) === "inactive") return false;
|
||||||
|
const d = new Date(s.date + "T00:00:00");
|
||||||
|
return d >= today && d < in7;
|
||||||
|
}).length;
|
||||||
|
return { total, active, draft, cities, venues, upcoming };
|
||||||
|
}, [stops]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Top stats strip — almanac style */}
|
||||||
|
<section
|
||||||
|
aria-label="Stops overview"
|
||||||
|
className="relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white p-5 shadow-sm"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-[0.04]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0.6 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--admin-text-muted)]">
|
||||||
|
Harvest Dispatch · Almanac
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 font-display text-3xl sm:text-4xl font-medium tracking-tight text-[var(--admin-text-primary)]">
|
||||||
|
{stats.total === 0
|
||||||
|
? "No stops scheduled"
|
||||||
|
: `${stats.total} stop${stats.total === 1 ? "" : "s"} on the route`}
|
||||||
|
<span className="ml-2 text-[var(--admin-accent)] italic font-light">
|
||||||
|
{stats.cities} cit{stats.cities === 1 ? "y" : "ies"}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
|
||||||
|
{stats.active} active · {stats.draft} drafts · {stats.venues} distinct venues
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab nav — binder-style with notched corners */}
|
||||||
|
<div className="flex items-end gap-1">
|
||||||
|
{TABS.map((t) => {
|
||||||
|
const isActive = t.value === view;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView(t.value)}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
className={`
|
||||||
|
group relative -mb-px inline-flex flex-col items-start gap-0.5 rounded-t-xl border border-b-0 px-4 py-2.5 transition-all
|
||||||
|
${isActive
|
||||||
|
? "z-10 border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] shadow-[0_-4px_8px_-4px_rgba(60,56,37,0.08)]"
|
||||||
|
: "border-transparent bg-transparent text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-white/40"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span className={`font-display text-base font-medium ${isActive ? "text-[var(--admin-accent-text)]" : ""}`}>
|
||||||
|
{t.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[9px] uppercase tracking-[0.18em] opacity-80">
|
||||||
|
{t.hint}
|
||||||
|
</span>
|
||||||
|
{isActive && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute left-3 right-3 -bottom-px h-0.5 bg-[var(--admin-accent)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat cells */}
|
||||||
|
<div className="relative mt-5 grid grid-cols-2 gap-3 border-t border-[var(--admin-border-light)] pt-5 sm:grid-cols-4">
|
||||||
|
<AlmanacStat
|
||||||
|
numeral="I"
|
||||||
|
label="Active"
|
||||||
|
value={stats.active}
|
||||||
|
accent
|
||||||
|
/>
|
||||||
|
<AlmanacStat
|
||||||
|
numeral="II"
|
||||||
|
label="Upcoming 7d"
|
||||||
|
value={stats.upcoming}
|
||||||
|
accent={stats.upcoming > 0}
|
||||||
|
/>
|
||||||
|
<AlmanacStat
|
||||||
|
numeral="III"
|
||||||
|
label="Cities"
|
||||||
|
value={stats.cities}
|
||||||
|
/>
|
||||||
|
<AlmanacStat
|
||||||
|
numeral="IV"
|
||||||
|
label="Venues"
|
||||||
|
value={stats.venues}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Active view */}
|
||||||
|
<section>
|
||||||
|
{view === "calendar" && <StopsCalendar stops={stops} />}
|
||||||
|
{view === "locations" && <StopsLocations stops={stops} />}
|
||||||
|
{view === "list" && <StopsList stops={stops} />}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlmanacStat({
|
||||||
|
numeral,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
numeral: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
accent?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md font-display text-base font-medium ${
|
||||||
|
accent ? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]" : "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]"
|
||||||
|
}`}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{numeral}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`font-display text-2xl font-medium leading-none tabular-nums ${
|
||||||
|
accent ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { type Stop, getStopStatus } from "./types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
stops: Stop[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Minimal list view used inside the StopsDashboard tab nav.
|
||||||
|
// It is a calmer, read-only list; for bulk publish/edit use the
|
||||||
|
// dedicated /admin/stops page.
|
||||||
|
export default function StopsList({ stops }: Props) {
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
return [...stops].sort((a, b) => (a.date || "").localeCompare(b.date || ""));
|
||||||
|
}, [stops]);
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed border-[var(--admin-border)] bg-white p-10 text-center">
|
||||||
|
<p className="font-display text-lg text-[var(--admin-text-primary)]">No stops yet</p>
|
||||||
|
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
|
||||||
|
Switch to a different tab to add stops.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="divide-y divide-[var(--admin-border-light)] overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
|
||||||
|
{sorted.map((s) => {
|
||||||
|
const status = getStopStatus(s);
|
||||||
|
const dot =
|
||||||
|
status === "active"
|
||||||
|
? "bg-[var(--admin-accent-dot)]"
|
||||||
|
: status === "draft"
|
||||||
|
? "bg-amber-500"
|
||||||
|
: "bg-stone-400";
|
||||||
|
const brand = Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name;
|
||||||
|
return (
|
||||||
|
<li key={s.id} className="group flex items-center gap-4 px-5 py-3 transition-colors hover:bg-[var(--admin-bg-subtle)]">
|
||||||
|
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} aria-hidden />
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)] w-20">
|
||||||
|
{s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "2-digit" }) : "—"}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-14">
|
||||||
|
{s.time ? s.time.slice(0, 5) : "—"}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="truncate font-display text-sm font-medium text-[var(--admin-text-primary)]">
|
||||||
|
{s.city}, {s.state}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 truncate text-xs text-[var(--admin-text-secondary)]">{s.location}</span>
|
||||||
|
</span>
|
||||||
|
{brand && (
|
||||||
|
<span className="hidden md:inline-block font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||||
|
{brand}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`font-mono text-[9px] uppercase tracking-wider ${
|
||||||
|
status === "active" ? "text-[var(--admin-accent-text)]" : status === "draft" ? "text-amber-700" : "text-[var(--admin-text-muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
type LocationGroup,
|
||||||
|
type Stop,
|
||||||
|
formatTime12,
|
||||||
|
getStopStatus,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
stops: Stop[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function groupStopsByLocation(stops: Stop[]): LocationGroup[] {
|
||||||
|
const map = new Map<string, LocationGroup>();
|
||||||
|
for (const s of stops) {
|
||||||
|
const key = `${(s.city || "Unknown").trim().toUpperCase()}|${(s.state || "").trim().toUpperCase()}`;
|
||||||
|
let group = map.get(key);
|
||||||
|
if (!group) {
|
||||||
|
group = {
|
||||||
|
key,
|
||||||
|
city: s.city || "Unknown",
|
||||||
|
state: s.state || "",
|
||||||
|
venueCount: 0,
|
||||||
|
total: 0,
|
||||||
|
active: 0,
|
||||||
|
draft: 0,
|
||||||
|
inactive: 0,
|
||||||
|
upcoming: 0,
|
||||||
|
nextDate: null,
|
||||||
|
firstDate: null,
|
||||||
|
lastDate: null,
|
||||||
|
sampleVenue: s.location,
|
||||||
|
stops: [],
|
||||||
|
};
|
||||||
|
map.set(key, group);
|
||||||
|
}
|
||||||
|
group.stops.push(s);
|
||||||
|
group.total += 1;
|
||||||
|
const status = getStopStatus(s);
|
||||||
|
if (status === "active") group.active += 1;
|
||||||
|
else if (status === "draft") group.draft += 1;
|
||||||
|
else group.inactive += 1;
|
||||||
|
|
||||||
|
if (s.date) {
|
||||||
|
if (!group.firstDate || s.date < group.firstDate) group.firstDate = s.date;
|
||||||
|
if (!group.lastDate || s.date > group.lastDate) group.lastDate = s.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// post-process: count distinct venues, upcoming stops
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
for (const g of map.values()) {
|
||||||
|
const venues = new Set(g.stops.map((s) => (s.location || "").trim().toLowerCase()).filter(Boolean));
|
||||||
|
g.venueCount = Math.max(1, venues.size);
|
||||||
|
g.upcoming = g.stops.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive").length;
|
||||||
|
const future = g.stops
|
||||||
|
.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive")
|
||||||
|
.map((s) => s.date)
|
||||||
|
.sort();
|
||||||
|
g.nextDate = future[0] ?? null;
|
||||||
|
g.stops.sort((a, b) => (a.date || "").localeCompare(b.date || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.values()).sort((a, b) => {
|
||||||
|
// active locations with upcoming stops first, sorted by next date; then by city
|
||||||
|
if (a.nextDate && !b.nextDate) return -1;
|
||||||
|
if (!a.nextDate && b.nextDate) return 1;
|
||||||
|
if (a.nextDate && b.nextDate) return a.nextDate.localeCompare(b.nextDate);
|
||||||
|
return a.city.localeCompare(b.city);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLabel(ymd: string | null): string {
|
||||||
|
if (!ymd) return "—";
|
||||||
|
const d = new Date(ymd + "T00:00:00");
|
||||||
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRange(a: string | null, b: string | null): string {
|
||||||
|
if (!a) return "—";
|
||||||
|
if (!b || a === b) return formatDateLabel(a);
|
||||||
|
return `${formatDateLabel(a)} → ${formatDateLabel(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StopsLocations({ stops }: Props) {
|
||||||
|
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [showOnlyActive, setShowOnlyActive] = useState(false);
|
||||||
|
|
||||||
|
const groups = useMemo(() => groupStopsByLocation(stops), [stops]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return groups.filter((g) => {
|
||||||
|
if (q) {
|
||||||
|
const hay = `${g.city} ${g.state} ${g.sampleVenue}`.toLowerCase();
|
||||||
|
if (!hay.includes(q)) return false;
|
||||||
|
}
|
||||||
|
if (showOnlyActive && g.active === 0) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [groups, query, showOnlyActive]);
|
||||||
|
|
||||||
|
// Aggregated stats
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const totalLocations = groups.length;
|
||||||
|
const totalStops = stops.length;
|
||||||
|
const totalActive = groups.reduce((sum, g) => sum + g.active, 0);
|
||||||
|
const totalUpcoming = groups.reduce((sum, g) => sum + g.upcoming, 0);
|
||||||
|
const totalDrafts = groups.reduce((sum, g) => sum + g.draft, 0);
|
||||||
|
const totalCities = new Set(groups.map((g) => g.city.toLowerCase())).size;
|
||||||
|
const totalVenues = groups.reduce((sum, g) => sum + g.venueCount, 0);
|
||||||
|
return { totalLocations, totalStops, totalActive, totalUpcoming, totalDrafts, totalCities, totalVenues };
|
||||||
|
}, [groups, stops]);
|
||||||
|
|
||||||
|
if (stops.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-12 text-center shadow-sm">
|
||||||
|
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
|
||||||
|
<svg className="h-7 w-7 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-display text-2xl font-medium text-[var(--admin-text-primary)]">
|
||||||
|
No locations yet
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
|
||||||
|
Create your first stop to see pickup locations here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Stats strip — almanac style */}
|
||||||
|
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
{[
|
||||||
|
{ numeral: "I", label: "Locations", value: stats.totalLocations, hint: `${stats.totalVenues} venues` },
|
||||||
|
{ numeral: "II", label: "Cities", value: stats.totalCities, hint: "covered" },
|
||||||
|
{ numeral: "III", label: "Stops", value: stats.totalStops, hint: "all-time" },
|
||||||
|
{ numeral: "IV", label: "Active", value: stats.totalActive, hint: "live" },
|
||||||
|
{ numeral: "V", label: "Upcoming", value: stats.totalUpcoming, hint: "in queue" },
|
||||||
|
{ numeral: "VI", label: "Drafts", value: stats.totalDrafts, hint: "unpublished" },
|
||||||
|
].map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.numeral}
|
||||||
|
className="group relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white p-4 shadow-sm transition-all hover:shadow-md hover:border-[var(--admin-accent)]/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
|
||||||
|
{s.numeral} · {s.label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-display text-3xl font-medium leading-none text-[var(--admin-text-primary)] tabular-nums">
|
||||||
|
{s.value}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1.5 font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||||
|
{s.hint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="h-8 w-8 -rotate-3 font-display text-2xl text-[var(--admin-accent)]/15 group-hover:text-[var(--admin-accent)]/30 transition-colors"
|
||||||
|
>
|
||||||
|
{s.numeral}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-px w-full bg-gradient-to-r from-[var(--admin-accent)]/20 via-[var(--admin-accent)]/40 to-[var(--admin-accent)]/20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Filter bar */}
|
||||||
|
<section className="flex flex-wrap items-center gap-3 rounded-2xl border border-[var(--admin-border)] bg-white px-4 py-3 shadow-sm">
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
|
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search city, state, venue..."
|
||||||
|
className="w-full rounded-lg border border-[var(--admin-border)] bg-white pl-9 pr-3 py-2 text-sm text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:border-[var(--admin-accent)] focus:outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/15"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={showOnlyActive}
|
||||||
|
onClick={() => setShowOnlyActive((v) => !v)}
|
||||||
|
className={`relative h-5 w-9 rounded-full transition-colors ${showOnlyActive ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${showOnlyActive ? "translate-x-4" : "translate-x-0.5"}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-secondary)]">
|
||||||
|
Active only
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<span className="ml-auto font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||||
|
{filtered.length} of {groups.length} locations
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Location grid */}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-[var(--admin-border)] bg-white p-10 text-center">
|
||||||
|
<p className="font-display text-lg text-[var(--admin-text-primary)]">No locations match.</p>
|
||||||
|
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">Try a different search.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{filtered.map((g, idx) => {
|
||||||
|
const isOpen = expandedKey === g.key;
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={g.key}
|
||||||
|
className={`
|
||||||
|
group relative overflow-hidden rounded-2xl border bg-white shadow-sm transition-all
|
||||||
|
${isOpen ? "border-[var(--admin-accent)] shadow-md ring-1 ring-[var(--admin-accent)]/20" : "border-[var(--admin-border)] hover:border-[var(--admin-accent)]/40 hover:shadow-md"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Decorative route number */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -right-2 -top-3 select-none font-display text-[80px] leading-none font-medium text-[var(--admin-accent)]/[0.06] group-hover:text-[var(--admin-accent)]/[0.1] transition-colors"
|
||||||
|
>
|
||||||
|
{String(idx + 1).padStart(2, "0")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpandedKey(isOpen ? null : g.key)}
|
||||||
|
className="block w-full text-left p-5"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Pin marker */}
|
||||||
|
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[var(--admin-accent-light)] border border-[var(--admin-accent)]/30">
|
||||||
|
<svg className="h-3.5 w-3.5 text-[var(--admin-accent-text)]" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="truncate font-display text-xl font-medium text-[var(--admin-text-primary)]">
|
||||||
|
{g.city}
|
||||||
|
{g.state && (
|
||||||
|
<span className="ml-1.5 font-mono text-xs font-normal text-[var(--admin-text-muted)]">
|
||||||
|
{g.state}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p className="truncate text-xs text-[var(--admin-text-secondary)]">
|
||||||
|
{g.sampleVenue}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`mt-1 h-7 w-7 shrink-0 inline-flex items-center justify-center rounded-full border transition-transform ${
|
||||||
|
isOpen ? "border-[var(--admin-accent)] bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] rotate-45" : "border-[var(--admin-border)] bg-white text-[var(--admin-text-muted)]"
|
||||||
|
}`}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat grid */}
|
||||||
|
<div className="mt-5 grid grid-cols-3 gap-2">
|
||||||
|
<Stat label="Stops" value={g.total} />
|
||||||
|
<Stat label="Active" value={g.active} accent={g.active > 0} />
|
||||||
|
<Stat label="Upcoming" value={g.upcoming} accent={g.upcoming > 0} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date range + drafts */}
|
||||||
|
<div className="mt-4 flex items-center justify-between border-t border-[var(--admin-border-light)] pt-3 font-mono text-[10px] uppercase tracking-wider">
|
||||||
|
<div className="text-[var(--admin-text-muted)]">
|
||||||
|
{g.firstDate ? (
|
||||||
|
<>
|
||||||
|
<span className="text-[var(--admin-text-secondary)]">{formatRange(g.firstDate, g.lastDate)}</span>
|
||||||
|
{" · "}
|
||||||
|
{g.venueCount > 1 ? `${g.venueCount} venues` : `${g.total} stop${g.total === 1 ? "" : "s"}`}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"No dates"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{g.draft > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 text-amber-800">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden />
|
||||||
|
{g.draft} draft
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next up ribbon */}
|
||||||
|
{g.nextDate && (
|
||||||
|
<div className="mt-3 flex items-center gap-2 rounded-lg border border-[var(--admin-accent)]/20 bg-gradient-to-r from-[var(--admin-accent-light)] to-transparent px-3 py-2">
|
||||||
|
<span className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-accent-text)]">
|
||||||
|
Next →
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-sm font-medium text-[var(--admin-text-primary)]">
|
||||||
|
{formatDateLabel(g.nextDate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded stop list */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="border-t border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/40 px-5 py-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h4 className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
|
||||||
|
All stops · {g.stops.length}
|
||||||
|
</h4>
|
||||||
|
<Link
|
||||||
|
href={`/admin/stops/new?city=${encodeURIComponent(g.city)}&state=${encodeURIComponent(g.state)}`}
|
||||||
|
className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-accent-text)] hover:underline"
|
||||||
|
>
|
||||||
|
+ Add at this city
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{g.stops.map((s) => {
|
||||||
|
const status = getStopStatus(s);
|
||||||
|
const dot =
|
||||||
|
status === "active"
|
||||||
|
? "bg-[var(--admin-accent-dot)]"
|
||||||
|
: status === "draft"
|
||||||
|
? "bg-amber-500"
|
||||||
|
: "bg-stone-400";
|
||||||
|
return (
|
||||||
|
<li key={s.id}>
|
||||||
|
<Link
|
||||||
|
href={`/admin/stops/${s.id}`}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-transparent bg-white px-3 py-2 transition-colors hover:border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)]"
|
||||||
|
>
|
||||||
|
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} aria-hidden />
|
||||||
|
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-16">
|
||||||
|
{s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" }) : "—"}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-14">
|
||||||
|
{formatTime12(s.time)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate text-xs text-[var(--admin-text-primary)]">
|
||||||
|
{s.location}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`font-mono text-[9px] uppercase tracking-wider ${
|
||||||
|
status === "active" ? "text-[var(--admin-accent-text)]" : status === "draft" ? "text-amber-700" : "text-[var(--admin-text-muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border px-2.5 py-2 ${
|
||||||
|
accent ? "border-[var(--admin-accent)]/30 bg-[var(--admin-accent-light)]" : "border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">{label}</p>
|
||||||
|
<p
|
||||||
|
className={`mt-0.5 font-display text-xl font-medium tabular-nums leading-none ${
|
||||||
|
accent ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
export type StopStatus = "draft" | "active" | "inactive";
|
||||||
|
|
||||||
|
export type Stop = {
|
||||||
|
id: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
time: string; // HH:MM
|
||||||
|
location: string;
|
||||||
|
active: boolean;
|
||||||
|
deleted_at?: string | null;
|
||||||
|
brand_id: string;
|
||||||
|
status?: string;
|
||||||
|
address?: string | null;
|
||||||
|
zip?: string | null;
|
||||||
|
cutoff_time?: string | null;
|
||||||
|
brands: { name: string } | { name: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StopView = "calendar" | "locations" | "list";
|
||||||
|
|
||||||
|
export type LocationGroup = {
|
||||||
|
key: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
venueCount: number; // distinct location strings at this city
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
draft: number;
|
||||||
|
inactive: number;
|
||||||
|
upcoming: number;
|
||||||
|
nextDate: string | null; // YYYY-MM-DD
|
||||||
|
firstDate: string | null;
|
||||||
|
lastDate: string | null;
|
||||||
|
sampleVenue: string;
|
||||||
|
stops: Stop[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getStopStatus(s: Stop): StopStatus {
|
||||||
|
if (s.status === "draft") return "draft";
|
||||||
|
return s.active ? "active" : "inactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStopDate(s: Stop): Date | null {
|
||||||
|
if (!s.date) return null;
|
||||||
|
const d = new Date(s.date + "T00:00:00");
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime12(time: string): string {
|
||||||
|
if (!time) return "—";
|
||||||
|
const [h, m] = time.split(":");
|
||||||
|
const hour = parseInt(h, 10);
|
||||||
|
if (isNaN(hour)) return time;
|
||||||
|
const ampm = hour >= 12 ? "PM" : "AM";
|
||||||
|
const hour12 = hour % 12 || 12;
|
||||||
|
return `${hour12}:${m} ${ampm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMonthYear(date: Date): string {
|
||||||
|
return date.toLocaleDateString("en-US", { month: "long", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSameDay(a: Date, b: Date): boolean {
|
||||||
|
return (
|
||||||
|
a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth() &&
|
||||||
|
a.getDate() === b.getDate()
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user