feat(admin/stops): editorial redesign + calendar/route view
Reimagine the admin stops surface under a 'Harvest Almanac' editorial direction. Establishes the type system (Fraunces serif display, Geist sans, JetBrains Mono numerics) loaded via next/font, and a compact modal pattern used across the new components. Stop modal (AddStopModal) - Compact 2-col grid: City|State, Date|Time, Address|ZIP|Cutoff - Status replaced with slim segmented control (Draft / Publish Now) - Tighter inputs (36px), single-line footer with Esc hint - Auto-focus on City field, reactive submit label GlassModal - New 'compact' prop: tighter padding, eyebrow text, no accent bar, capped max-height for dense forms Stop product assignment (StopProductAssignment) - Reimagine the <select> dropdown as an editorial card-grid picker - Big Fraunces numeral + small-caps eyebrow showing live count - Click-to-toggle assign/remove; green check for assigned, red X on hover-to-remove intent - Search + filter chips (All / Available / On this stop) with mono counters - '/' keyboard shortcut focuses search - Summary footer with 'Remove all' action Stops page - New StopsViewClient wrapper with shared search/status filter and Calendar/Table view toggle (default = Calendar) - New StopsCalendarClient: month grid almanac with editorial header, color-coded event chips, today highlight, prev/next/Today nav - Event popover with auto-edge-detection positioning, full stop details, Publish/View Route/Edit actions - Day-route drawer: slides in from right with route spine (numbered markers + connecting hairline), 3-card summary (stops/live/brands), per-stop Publish/Edit/Duplicate actions - StopsTableClient refactored to accept hideInternalFilterBar prop so the wrapper can own shared filtering Design tokens - New utility classes in admin-design-system.css: ha-display, ha-eyebrow, ha-field, ha-segment, ha-picker, ha-product-card, ha-calendar, ha-event-popover, ha-drawer, ha-route-stop, etc. - All uses CSS variables (--font-fraunces, --font-geist, --font-jetbrains-mono) so the type system cascades
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import StopTableClient from "@/components/admin/StopTableClient";
|
||||
import StopsViewClient from "@/components/admin/StopsViewClient";
|
||||
import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
|
||||
import LocationsTab from "@/components/admin/LocationsTab";
|
||||
import StopsLocationsTabs from "@/components/admin/StopsLocationsTabs";
|
||||
import StatsStrip from "@/components/admin/StatsStrip";
|
||||
@@ -133,6 +134,11 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
|
||||
icon={<StopIcon />}
|
||||
title="Stops & Routes"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
<StopsHeaderActions
|
||||
brandId={activeBrandId ?? adminUser?.brand_id ?? ""}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className="mt-3 ml-[52px]">
|
||||
<StatsStrip
|
||||
@@ -155,7 +161,7 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
|
||||
/>
|
||||
<TabSwitcher tabKey={tab}>
|
||||
{tab === "stops" ? (
|
||||
<StopTableClient stops={safeStops} />
|
||||
<StopsViewClient stops={safeStops} />
|
||||
) : (
|
||||
<LocationsTab locations={locations} brandId={adminUser?.brand_id ?? ""} />
|
||||
)}
|
||||
|
||||
+28
-1
@@ -1,9 +1,32 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Fraunces, Geist, JetBrains_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/Providers";
|
||||
import ToastNotificationContainer from "@/components/notifications/ToastNotification";
|
||||
import CookieConsentBanner from "@/components/legal/CookieConsentBanner";
|
||||
|
||||
const fraunces = Fraunces({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700", "800"],
|
||||
style: ["normal", "italic"],
|
||||
variable: "--font-fraunces",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const geist = Geist({
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500", "600", "700"],
|
||||
variable: "--font-geist",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
variable: "--font-jetbrains-mono",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
@@ -50,7 +73,11 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={`${fraunces.variable} ${geist.variable} ${jetbrainsMono.variable}`}
|
||||
>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
<ToastNotificationContainer />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { createStop } from "@/actions/stops/create-stop";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
@@ -21,12 +21,20 @@ type Props = {
|
||||
onSuccess?: (stopId: string) => void;
|
||||
};
|
||||
|
||||
/* Pin icon for the modal header */
|
||||
const PinIcon = () => (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom, onSuccess }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [city, setCity] = useState("");
|
||||
const [state, setState] = useState("");
|
||||
const [stateField, setStateField] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [date, setDate] = useState("");
|
||||
const [time, setTime] = useState("");
|
||||
@@ -36,11 +44,13 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
||||
const [cutoffTime, setCutoffTime] = useState("");
|
||||
const [status, setStatus] = useState<"draft" | "active">("draft");
|
||||
|
||||
const cityRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Reset form when modal opens with optional duplicate source
|
||||
setCity(duplicateFrom?.city ?? "");
|
||||
setState(duplicateFrom?.state ?? "");
|
||||
setStateField(duplicateFrom?.state ?? "");
|
||||
setLocation(duplicateFrom?.location ?? "");
|
||||
setDate(duplicateFrom?.date ?? "");
|
||||
setTime(duplicateFrom?.time ?? "");
|
||||
@@ -49,15 +59,18 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
||||
setCutoffTime(duplicateFrom?.cutoff_time ?? "");
|
||||
setStatus("draft");
|
||||
setError(null);
|
||||
// Auto-focus city field for fast data entry
|
||||
requestAnimationFrame(() => cityRef.current?.focus());
|
||||
}
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
}, [isOpen, duplicateFrom]);
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!city.trim() || !state.trim() || !location.trim() || !date) {
|
||||
if (!city.trim() || !stateField.trim() || !location.trim() || !date) {
|
||||
setError("City, state, location, and date are required.");
|
||||
return;
|
||||
}
|
||||
@@ -66,7 +79,7 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
||||
try {
|
||||
const result = await createStop(brandId, {
|
||||
city: city.trim(),
|
||||
state: state.trim(),
|
||||
state: stateField.trim(),
|
||||
location: location.trim(),
|
||||
date,
|
||||
time: time || "08:00",
|
||||
@@ -87,235 +100,288 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [brandId, city, state, location, date, time, address, zip, cutoffTime, status, onSuccess, onClose]);
|
||||
},
|
||||
[
|
||||
brandId,
|
||||
city,
|
||||
stateField,
|
||||
location,
|
||||
date,
|
||||
time,
|
||||
address,
|
||||
zip,
|
||||
cutoffTime,
|
||||
status,
|
||||
onSuccess,
|
||||
onClose,
|
||||
]
|
||||
);
|
||||
|
||||
const isDuplicate = Boolean(duplicateFrom);
|
||||
const title = isDuplicate ? "Duplicate Stop" : "Add New Stop";
|
||||
const subtitle = isDuplicate && duplicateFrom
|
||||
const title = isDuplicate ? "Duplicate Stop" : "Add Stop";
|
||||
const eyebrow = isDuplicate && duplicateFrom
|
||||
? `From ${duplicateFrom.city}, ${duplicateFrom.state}`
|
||||
: "Create a new tour stop for your route.";
|
||||
|
||||
const inputStyle = {
|
||||
background: 'rgba(0, 0, 0, 0.02)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.06)',
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
e.target.style.background = 'rgba(16, 185, 129, 0.04)';
|
||||
e.target.style.border = '1px solid rgba(16, 185, 129, 0.5)';
|
||||
e.target.style.boxShadow = '0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)';
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
e.target.style.background = 'rgba(0, 0, 0, 0.02)';
|
||||
e.target.style.border = '1px solid rgba(0, 0, 0, 0.06)';
|
||||
e.target.style.boxShadow = 'none';
|
||||
};
|
||||
: "New stop on the route";
|
||||
const submitLabel = loading
|
||||
? isDuplicate ? "Duplicating…" : "Creating…"
|
||||
: isDuplicate
|
||||
? "Duplicate Stop"
|
||||
: status === "active"
|
||||
? "Create & Publish"
|
||||
: "Save as Draft";
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<GlassModal title={title} subtitle={subtitle} onClose={onClose}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<GlassModal
|
||||
title={title}
|
||||
eyebrow={eyebrow}
|
||||
onClose={onClose}
|
||||
maxWidth="max-w-xl"
|
||||
compact
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
|
||||
{error && (
|
||||
<div className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
|
||||
style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.2)' }}>
|
||||
{error}
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
|
||||
style={{ background: "rgba(220, 38, 38, 0.06)", border: "1px solid rgba(220, 38, 38, 0.15)" }}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
|
||||
{/* Row 1 — Where: City + State */}
|
||||
<div className="grid grid-cols-[1fr_5.5rem] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-city" className="ha-field-label">
|
||||
<PinIcon />
|
||||
<span>City</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
ref={cityRef}
|
||||
id="stop-city"
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="Denver"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className="ha-field-input"
|
||||
required
|
||||
autoComplete="address-level2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-state" className="ha-field-label">
|
||||
<span>State</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-state"
|
||||
type="text"
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value.toUpperCase())}
|
||||
value={stateField}
|
||||
onChange={(e) => setStateField(e.target.value.toUpperCase())}
|
||||
placeholder="CO"
|
||||
maxLength={2}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className="ha-field-input ha-field-input-mono text-center"
|
||||
style={{ textTransform: "uppercase" }}
|
||||
required
|
||||
autoComplete="address-level1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Location</label>
|
||||
{/* Row 2 — Where at: Location (full) */}
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-location" className="ha-field-label">
|
||||
<span>Location / Venue</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-location"
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="Whole Foods Market"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder="Whole Foods Market — Highlands"
|
||||
className="ha-field-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Date</label>
|
||||
{/* Row 3 — When: Date + Time (with small clock icon) */}
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-date" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Pickup Date</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Pickup Time</label>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-time" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Pickup Time</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-time"
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Address</label>
|
||||
{/* Row 4 — Address + ZIP + Cutoff in 3 columns */}
|
||||
<div className="grid grid-cols-[1fr_5.5rem_6.5rem] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-address" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" strokeLinejoin="round" />
|
||||
<path d="M9 22V12h6v10" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>Street Address</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-address"
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className="ha-field-input"
|
||||
autoComplete="street-address"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-zip" className="ha-field-label">
|
||||
<span>ZIP</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-zip"
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
placeholder="80202"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
maxLength={10}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
autoComplete="postal-code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Cutoff Time</label>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-cutoff" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Cutoff</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-cutoff"
|
||||
type="time"
|
||||
value={cutoffTime}
|
||||
onChange={(e) => setCutoffTime(e.target.value)}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-stone-400 ml-1">Orders close this time the day before pickup</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status toggle */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Status</label>
|
||||
<div className="flex gap-3">
|
||||
{/* Row 5 — Status: segmented control (compact, replaces two giant buttons) */}
|
||||
<div className="ha-field pt-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="ha-field-label">
|
||||
<span>Visibility</span>
|
||||
</label>
|
||||
<span className="text-[10px] text-[var(--admin-text-muted)] leading-none">
|
||||
Draft is hidden from customers
|
||||
</span>
|
||||
</div>
|
||||
<div className="ha-segment mt-1" role="radiogroup" aria-label="Stop status">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={status === "draft"}
|
||||
onClick={() => setStatus("draft")}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
|
||||
style={{
|
||||
border: status === "draft" ? '1px solid rgba(245, 158, 11, 0.4)' : '1px solid rgba(0, 0, 0, 0.08)',
|
||||
background: status === "draft" ? 'rgba(245, 158, 11, 0.08)' : 'rgba(0, 0, 0, 0.02)',
|
||||
}}
|
||||
className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
|
||||
>
|
||||
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
|
||||
background: status === "draft" ? 'rgba(245, 158, 11, 0.2)' : 'rgba(0, 0, 0, 0.05)',
|
||||
color: status === "draft" ? '#b45309' : 'rgba(0, 0, 0, 0.4)',
|
||||
}}>Draft</span>
|
||||
<span className="text-sm font-medium" style={{ color: status === "draft" ? '#92400e' : 'rgba(0, 0, 0, 0.4)' }}>Save as draft</span>
|
||||
<span className="ha-segment-dot" />
|
||||
<span>Save as Draft</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={status === "active"}
|
||||
onClick={() => setStatus("active")}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
|
||||
style={{
|
||||
border: status === "active" ? '1px solid rgba(16, 185, 129, 0.4)' : '1px solid rgba(0, 0, 0, 0.08)',
|
||||
background: status === "active" ? 'rgba(16, 185, 129, 0.08)' : 'rgba(0, 0, 0, 0.02)',
|
||||
}}
|
||||
className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
|
||||
>
|
||||
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
|
||||
background: status === "active" ? 'rgba(16, 185, 129, 0.2)' : 'rgba(0, 0, 0, 0.05)',
|
||||
color: status === "active" ? '#047857' : 'rgba(0, 0, 0, 0.4)',
|
||||
}}>Active</span>
|
||||
<span className="text-sm font-medium" style={{ color: status === "active" ? '#065f46' : 'rgba(0, 0, 0, 0.4)' }}>Publish now</span>
|
||||
<span className="ha-segment-dot" />
|
||||
<span>Publish Now</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: '1px solid rgba(0, 0, 0, 0.04)' }}>
|
||||
<div className="ha-modal-footer">
|
||||
<span className="ha-modal-footer-hint">
|
||||
<kbd>Esc</kbd>
|
||||
to close
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100"
|
||||
className="ha-btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
|
||||
style={{
|
||||
background: loading
|
||||
? 'rgba(16, 185, 129, 0.4)'
|
||||
: 'linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)',
|
||||
boxShadow: loading
|
||||
? 'none'
|
||||
: '0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)',
|
||||
opacity: loading ? 0.7 : 1,
|
||||
}}
|
||||
className="ha-btn-primary"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
<>
|
||||
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
Creating...
|
||||
</span>
|
||||
<span>{submitLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
isDuplicate ? "Duplicate Stop" : "Create Stop"
|
||||
<>
|
||||
{status === "active" ? (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
|
||||
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{submitLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</GlassModal>
|
||||
);
|
||||
|
||||
@@ -9,9 +9,22 @@ type Props = {
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: string;
|
||||
/** Compact mode: tighter padding, smaller title, no accent bar — for dense forms. */
|
||||
compact?: boolean;
|
||||
/** Optional eyebrow text rendered above the title in compact mode. */
|
||||
eyebrow?: string;
|
||||
};
|
||||
|
||||
export default function GlassModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg" }: Props) {
|
||||
export default function GlassModal({
|
||||
title,
|
||||
titleIcon,
|
||||
subtitle,
|
||||
onClose,
|
||||
children,
|
||||
maxWidth = "max-w-lg",
|
||||
compact = false,
|
||||
eyebrow,
|
||||
}: Props) {
|
||||
// Lock body scroll
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
@@ -33,24 +46,35 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 md:p-8"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 md:p-6"
|
||||
onClick={handleBackdropClick}
|
||||
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
|
||||
style={{
|
||||
backgroundColor: "rgba(60, 56, 37, 0.45)",
|
||||
backdropFilter: "blur(2px)",
|
||||
WebkitBackdropFilter: "blur(2px)",
|
||||
}}
|
||||
>
|
||||
{/* Modal card - solid white with shadow for high contrast */}
|
||||
<div
|
||||
className={`relative w-full ${maxWidth} max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)] rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.15)] flex flex-col`}
|
||||
className={`relative w-full ${maxWidth} ${
|
||||
compact
|
||||
? "max-h-[min(640px,calc(100vh-2rem))]"
|
||||
: "max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)]"
|
||||
} rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.18),0_8px_16px_-4px_rgba(0,0,0,0.05)] flex flex-col overflow-hidden`}
|
||||
>
|
||||
{/* Subtle top border accent */}
|
||||
{/* Accent bar — only for non-compact (compact forms have their own internal accent) */}
|
||||
{!compact && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[var(--admin-accent)] to-[var(--admin-accent-hover)] rounded-t-2xl" />
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 sm:px-6 md:px-8 py-4 sm:py-6 shrink-0"
|
||||
className={`flex items-center justify-between shrink-0 ${
|
||||
compact ? "px-5 pt-4 pb-3" : "px-4 sm:px-6 md:px-8 py-4 sm:py-6"
|
||||
}`}
|
||||
style={{ borderBottom: "1px solid var(--admin-border-light)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
||||
{titleIcon && (
|
||||
{titleIcon && !compact && (
|
||||
<div
|
||||
className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-xl shrink-0"
|
||||
style={{ backgroundColor: "var(--admin-accent-light)" }}
|
||||
@@ -59,21 +83,39 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{compact && eyebrow && (
|
||||
<p className="ha-eyebrow mb-0.5 truncate">{eyebrow}</p>
|
||||
)}
|
||||
<h2
|
||||
className="text-lg sm:text-xl font-semibold text-[var(--admin-text-primary)] truncate"
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
className={`${
|
||||
compact
|
||||
? "ha-display text-lg truncate"
|
||||
: "text-lg sm:text-xl font-semibold truncate"
|
||||
} text-[var(--admin-text-primary)]`}
|
||||
style={compact ? undefined : { letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">{subtitle}</p>
|
||||
{subtitle && !compact && (
|
||||
<p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-full transition-all duration-150 shrink-0 ml-2"
|
||||
style={{ backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }}
|
||||
className={`flex shrink-0 items-center justify-center rounded-full transition-all duration-150 ml-2 ${
|
||||
compact
|
||||
? "h-7 w-7 text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)]"
|
||||
: "h-8 w-8 sm:h-9 sm:w-9"
|
||||
}`}
|
||||
style={
|
||||
compact
|
||||
? undefined
|
||||
: { backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }
|
||||
}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -81,8 +123,12 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - scrollable */}
|
||||
<div className="relative px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8 overflow-y-auto flex-1">
|
||||
{/* Content */}
|
||||
<div
|
||||
className={`relative overflow-y-auto flex-1 ${
|
||||
compact ? "px-5 pb-5 pt-3" : "px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { logAuditEvent } from "@/actions/audit";
|
||||
|
||||
type Product = {
|
||||
@@ -8,12 +9,13 @@ type Product = {
|
||||
name: string;
|
||||
type: string;
|
||||
price: number;
|
||||
image_url?: string | null;
|
||||
};
|
||||
|
||||
type AssignedProduct = {
|
||||
id: string;
|
||||
product_id: string;
|
||||
products: { name: string; type: string; price: number } | null;
|
||||
products: { name: string; type: string; price: number; image_url?: string | null } | null;
|
||||
};
|
||||
|
||||
type StopProductAssignmentProps = {
|
||||
@@ -23,6 +25,20 @@ type StopProductAssignmentProps = {
|
||||
callerUid: string;
|
||||
};
|
||||
|
||||
type Filter = "all" | "available" | "assigned";
|
||||
|
||||
/* Helpers — monospace price + initial-letter avatar */
|
||||
const formatPrice = (n: number) =>
|
||||
`$${Number(n ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
|
||||
const initialOf = (name: string) => (name?.trim()?.charAt(0) ?? "·").toUpperCase();
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
pickup: "Pickup",
|
||||
wholesale: "Wholesale",
|
||||
subscription: "Sub",
|
||||
};
|
||||
|
||||
export default function StopProductAssignment({
|
||||
stopId,
|
||||
allProducts,
|
||||
@@ -30,40 +46,47 @@ export default function StopProductAssignment({
|
||||
callerUid,
|
||||
}: StopProductAssignmentProps) {
|
||||
const [products, setProducts] = useState(assignedProducts);
|
||||
const [selected, setSelected] = useState("");
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const assignedIds = new Set(products.map((p) => p.product_id));
|
||||
const availableProducts = allProducts.filter((p) => !assignedIds.has(p.id));
|
||||
const assignedIds = useMemo(
|
||||
() => new Set(products.map((p) => p.product_id)),
|
||||
[products]
|
||||
);
|
||||
|
||||
async function handleAssign(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!selected) return;
|
||||
// Counters for the filter chips
|
||||
const counts = useMemo(() => {
|
||||
const assignedCount = products.length;
|
||||
const availableCount = allProducts.filter((p) => !assignedIds.has(p.id)).length;
|
||||
return {
|
||||
all: allProducts.length,
|
||||
available: availableCount,
|
||||
assigned: assignedCount,
|
||||
};
|
||||
}, [allProducts, products, assignedIds]);
|
||||
|
||||
setAssigning(true);
|
||||
// Apply search + filter
|
||||
const visibleProducts = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return allProducts.filter((p) => {
|
||||
const isAssigned = assignedIds.has(p.id);
|
||||
if (filter === "available" && isAssigned) return false;
|
||||
if (filter === "assigned" && !isAssigned) return false;
|
||||
if (!q) return true;
|
||||
return p.name.toLowerCase().includes(q) || p.type.toLowerCase().includes(q);
|
||||
});
|
||||
}, [allProducts, search, filter, assignedIds]);
|
||||
|
||||
async function assign(productId: string) {
|
||||
if (!productId || assignedIds.has(productId) || pendingId) return;
|
||||
|
||||
setPendingId(productId);
|
||||
setError(null);
|
||||
|
||||
// First run diagnostic to understand the actual state
|
||||
const diagRes = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/debug_stop_product_assignment`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_stop_id: stopId,
|
||||
p_product_id: selected,
|
||||
p_caller_uid: callerUid,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const diag = await diagRes.json();
|
||||
|
||||
// Now attempt the actual assignment
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
|
||||
{
|
||||
@@ -74,29 +97,22 @@ export default function StopProductAssignment({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_stop_id: stopId,
|
||||
p_product_id: selected,
|
||||
p_product_id: productId,
|
||||
p_caller_uid: callerUid,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || data.success === false) {
|
||||
const diagInfo = diag && typeof diag === 'object' && 'reason' in diag
|
||||
? `[${(diag as any).reason}] (admin_found=${(diag as any).admin_found}, role=${(diag as any).admin_role}, admin_brand=${(diag as any).admin_brand_id}, stop_brand=${(diag as any).stop_brand_id}, product_brand=${(diag as any).product_brand_id})`
|
||||
: '';
|
||||
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
|
||||
const errDetail = data?.details ?? data?.hint ?? data?.code ?? '';
|
||||
const fullError = diagInfo
|
||||
? `Failed to assign: ${errMsg} ${diagInfo}${errDetail ? ' | ' + errDetail : ''}`
|
||||
: `Failed to assign: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`;
|
||||
setError(fullError);
|
||||
setAssigning(false);
|
||||
const errDetail = data?.details ?? data?.hint ?? data?.code ?? "";
|
||||
setError(`Failed to assign: ${errMsg}${errDetail ? " — " + errDetail : ""}`);
|
||||
setPendingId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh product list from get_stop_products RPC
|
||||
// Optimistic: re-fetch to keep assigned list in sync with server
|
||||
const refreshRes = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_stop_products`,
|
||||
{
|
||||
@@ -110,26 +126,31 @@ export default function StopProductAssignment({
|
||||
);
|
||||
const refreshData = await refreshRes.json();
|
||||
setProducts(refreshData.products ?? []);
|
||||
setSelected("");
|
||||
setAssigning(false);
|
||||
setPendingId(null);
|
||||
|
||||
logAuditEvent({
|
||||
table_name: "product_stops",
|
||||
record_id: data.id,
|
||||
action: "INSERT",
|
||||
old_data: null,
|
||||
new_data: { stop_id: stopId, product_id: selected },
|
||||
new_data: { stop_id: stopId, product_id: productId },
|
||||
brand_id: null,
|
||||
});
|
||||
} catch {
|
||||
setError("Network error while assigning product.");
|
||||
setPendingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(productId: string) {
|
||||
async function remove(productId: string) {
|
||||
if (removingId) return;
|
||||
const entry = products.find((p) => p.product_id === productId);
|
||||
if (!entry) return;
|
||||
|
||||
setRemoving(productId);
|
||||
setRemovingId(productId);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
|
||||
{
|
||||
@@ -145,19 +166,18 @@ export default function StopProductAssignment({
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || data.success === false) {
|
||||
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
|
||||
const errDetail = data?.details ?? data?.hint ?? data?.code ?? '';
|
||||
setError(`Failed to remove: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`);
|
||||
setRemoving(null);
|
||||
const errDetail = data?.details ?? data?.hint ?? data?.code ?? "";
|
||||
setError(`Failed to remove: ${errMsg}${errDetail ? " — " + errDetail : ""}`);
|
||||
setRemovingId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setProducts(products.filter((p) => p.product_id !== productId));
|
||||
setRemoving(null);
|
||||
setRemovingId(null);
|
||||
|
||||
logAuditEvent({
|
||||
table_name: "product_stops",
|
||||
@@ -167,71 +187,244 @@ export default function StopProductAssignment({
|
||||
new_data: null,
|
||||
brand_id: null,
|
||||
});
|
||||
} catch {
|
||||
setError("Network error while removing product.");
|
||||
setRemovingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(productId: string) {
|
||||
if (assignedIds.has(productId)) {
|
||||
remove(productId);
|
||||
} else {
|
||||
assign(productId);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (!products.length) return;
|
||||
// Remove sequentially to be safe
|
||||
(async () => {
|
||||
for (const p of [...products]) {
|
||||
await remove(p.product_id);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Keyboard: pressing / focuses search
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "/" && document.activeElement?.tagName !== "INPUT") {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById("stop-product-search") as HTMLInputElement | null;
|
||||
el?.focus();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const hasAssigned = products.length > 0;
|
||||
const hasProducts = allProducts.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="ha-picker">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
|
||||
{error}
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
|
||||
style={{
|
||||
background: "rgba(220, 38, 38, 0.06)",
|
||||
border: "1px solid rgba(220, 38, 38, 0.15)",
|
||||
}}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="font-mono text-[11px]">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{products.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-300">
|
||||
Assigned Products ({products.length})
|
||||
</p>
|
||||
{products.map((ap) => (
|
||||
<div
|
||||
key={ap.id}
|
||||
className="flex items-center justify-between rounded-xl border border-zinc-800 bg-slate-50 px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-zinc-100">
|
||||
{ap.products?.name ?? "Unknown"}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{ap.products?.type} ·{" "}
|
||||
${Number(ap.products?.price ?? 0).toFixed(2)}
|
||||
</p>
|
||||
{/* Header: editorial title + search */}
|
||||
<div className="ha-picker-header">
|
||||
<div className="ha-picker-title">
|
||||
<span className="ha-picker-title-num font-display">
|
||||
{String(products.length).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="ha-picker-title-label">
|
||||
{products.length === 1 ? "Product on this stop" : "Products on this stop"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ha-picker-search">
|
||||
<span className="ha-picker-search-icon" aria-hidden="true">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m21 21-4.3-4.3" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
id="stop-product-search"
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search products…"
|
||||
className="ha-picker-search-input"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter chips */}
|
||||
<div className="ha-picker-filterbar">
|
||||
<button
|
||||
onClick={() => handleRemove(ap.product_id)}
|
||||
disabled={removing === ap.product_id}
|
||||
className="shrink-0 rounded-lg px-3 py-1 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
|
||||
type="button"
|
||||
onClick={() => setFilter("all")}
|
||||
className={`ha-picker-chip ${filter === "all" ? "ha-picker-chip--active" : ""}`}
|
||||
>
|
||||
{removing === ap.product_id ? "..." : "Remove"}
|
||||
All
|
||||
<span className="ha-picker-chip-count">{counts.all}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilter("available")}
|
||||
className={`ha-picker-chip ${filter === "available" ? "ha-picker-chip--active" : ""}`}
|
||||
>
|
||||
Available
|
||||
<span className="ha-picker-chip-count">{counts.available}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilter("assigned")}
|
||||
className={`ha-picker-chip ${filter === "assigned" ? "ha-picker-chip--active" : ""}`}
|
||||
>
|
||||
On this stop
|
||||
<span className="ha-picker-chip-count">{counts.assigned}</span>
|
||||
</button>
|
||||
<span className="ml-auto inline-flex items-center gap-1 text-[var(--admin-text-muted)]">
|
||||
Press <kbd className="ha-modal-footer-hint kbd inline-flex items-center justify-center min-w-[1.125rem] h-[1.125rem] px-1 font-mono text-[10px] font-semibold text-[var(--admin-text-secondary)] bg-[rgba(0,0,0,0.04)] border border-[var(--admin-border)] rounded" style={{ display: "inline-flex" }}>/</kbd> to search
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Product grid */}
|
||||
{!hasProducts ? (
|
||||
<div className="ha-picker-empty">
|
||||
<p className="ha-picker-empty-mark">No products yet</p>
|
||||
<p className="ha-picker-empty-text">
|
||||
Create products in the catalog first, then come back here to assign them to this stop.
|
||||
</p>
|
||||
</div>
|
||||
) : visibleProducts.length === 0 ? (
|
||||
<div className="ha-picker-empty">
|
||||
<p className="ha-picker-empty-mark">
|
||||
{filter === "assigned" ? "Nothing assigned" : "No matches"}
|
||||
</p>
|
||||
<p className="ha-picker-empty-text">
|
||||
{search.trim()
|
||||
? `No products match "${search.trim()}". Try a different term.`
|
||||
: filter === "assigned"
|
||||
? "This stop has no products assigned yet. Click a card in the All tab to add one."
|
||||
: "All products are already assigned to this stop."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">No products assigned yet.</p>
|
||||
<div className="ha-picker-grid">
|
||||
{visibleProducts.map((product) => {
|
||||
const isAssigned = assignedIds.has(product.id);
|
||||
const isBusy = pendingId === product.id || removingId === product.id;
|
||||
return (
|
||||
<button
|
||||
key={product.id}
|
||||
type="button"
|
||||
onClick={() => !isBusy && toggle(product.id)}
|
||||
disabled={isBusy}
|
||||
aria-pressed={isAssigned}
|
||||
className={`ha-product-card ${isAssigned ? "ha-product-card--assigned" : ""} ${
|
||||
isBusy ? "ha-product-card--disabled" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Image / Initial */}
|
||||
<span className="ha-product-card-image">
|
||||
{product.image_url ? (
|
||||
<Image
|
||||
src={product.image_url}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="48px"
|
||||
style={{ objectFit: "cover" }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
initialOf(product.name)
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Body */}
|
||||
<span className="ha-product-card-body">
|
||||
<span className="ha-product-card-name">{product.name}</span>
|
||||
<span className="ha-product-card-meta">
|
||||
<span className="ha-product-card-type">{TYPE_LABEL[product.type] ?? product.type}</span>
|
||||
<span className="ha-product-card-price">{formatPrice(product.price)}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Status icon */}
|
||||
{isBusy ? (
|
||||
<span className="ha-product-card-plus" aria-hidden="true">
|
||||
<svg className="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
) : isAssigned ? (
|
||||
<>
|
||||
<span className="ha-product-card-check" aria-hidden="true">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="ha-product-card-remove" aria-hidden="true">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M6 6l12 12M6 18 18 6" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="ha-product-card-plus" aria-hidden="true">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{availableProducts.length > 0 && (
|
||||
<form onSubmit={handleAssign} className="flex gap-3">
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="">Select a product...</option>
|
||||
{availableProducts.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Summary footer when assigned items exist */}
|
||||
{hasAssigned && (
|
||||
<div className="ha-picker-summary">
|
||||
<span className="ha-picker-summary-left">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>
|
||||
<span className="ha-picker-summary-count">{products.length}</span>{" "}
|
||||
{products.length === 1 ? "product is" : "products are"} live at this stop
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!selected || assigning}
|
||||
className="rounded-xl bg-slate-900 px-5 py-3 font-medium text-white disabled:opacity-50"
|
||||
type="button"
|
||||
onClick={clearAll}
|
||||
disabled={!!removingId}
|
||||
className="ha-picker-summary-clear"
|
||||
>
|
||||
{assigning ? "..." : "Assign"}
|
||||
Remove all
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,6 +34,11 @@ type Stop = {
|
||||
|
||||
type Props = {
|
||||
stops: Stop[];
|
||||
/**
|
||||
* Hide the internal search/filter bar at the top of the table. Use when
|
||||
* the parent component already renders shared filters (e.g. StopsViewClient).
|
||||
*/
|
||||
hideInternalFilterBar?: boolean;
|
||||
};
|
||||
|
||||
const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "active" | "inactive" | "draft"> = {
|
||||
@@ -43,7 +48,7 @@ const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "acti
|
||||
draft: "draft",
|
||||
};
|
||||
|
||||
export default function StopTableClient({ stops }: Props) {
|
||||
export default function StopTableClient({ stops, hideInternalFilterBar = false }: Props) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
@@ -162,6 +167,7 @@ export default function StopTableClient({ stops }: Props) {
|
||||
return (
|
||||
<>
|
||||
{/* Filter bar */}
|
||||
{!hideInternalFilterBar && (
|
||||
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3 border-b border-[var(--admin-border)]">
|
||||
<AdminSearchInput
|
||||
placeholder="Search by city or venue…"
|
||||
@@ -246,6 +252,7 @@ export default function StopTableClient({ stops }: Props) {
|
||||
Add Stop
|
||||
</AdminButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedStops.size > 0 && (
|
||||
|
||||
@@ -0,0 +1,754 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect, useRef, useCallback, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { publishStop } from "@/actions/stops";
|
||||
import { useToast } from "@/components/admin/design-system";
|
||||
import type { StopForView } from "./StopsViewClient";
|
||||
|
||||
type Props = {
|
||||
stops: StopForView[];
|
||||
};
|
||||
|
||||
/* ================================================================== */
|
||||
/* Date helpers — work with YYYY-MM-DD strings to avoid TZ drift */
|
||||
/* ================================================================== */
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
function parseYmd(s: string): Date | null {
|
||||
// Treat YYYY-MM-DD as a local date (no UTC shift)
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
||||
if (!m) return null;
|
||||
const [, y, mo, d] = m;
|
||||
return new Date(Number(y), Number(mo) - 1, Number(d));
|
||||
}
|
||||
|
||||
function todayYmd(): string {
|
||||
return ymd(new Date());
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
|
||||
const MONTH_NAMES_ITALIC = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
|
||||
const DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
function buildMonthGrid(month: Date): Date[] {
|
||||
// Start at the Sunday on or before the 1st of the month
|
||||
const first = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const startDayOfWeek = first.getDay(); // 0 = Sun
|
||||
const start = new Date(first);
|
||||
start.setDate(1 - startDayOfWeek);
|
||||
|
||||
// 6 weeks = 42 cells
|
||||
const cells: Date[] = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(start);
|
||||
d.setDate(start.getDate() + i);
|
||||
cells.push(d);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function statusOf(stop: StopForView): "active" | "draft" | "inactive" {
|
||||
if (stop.status === "draft") return "draft";
|
||||
if (stop.active) return "active";
|
||||
return "inactive";
|
||||
}
|
||||
|
||||
function statusLabel(s: "active" | "draft" | "inactive"): string {
|
||||
return s === "draft" ? "Draft" : s === "active" ? "Active" : "Inactive";
|
||||
}
|
||||
|
||||
function brandName(s: StopForView): string {
|
||||
return Array.isArray(s.brands) ? s.brands[0]?.name ?? "—" : s.brands?.name ?? "—";
|
||||
}
|
||||
|
||||
function formatTime12(t: string | null | undefined): string {
|
||||
if (!t) return "—";
|
||||
// t is "HH:MM" or "HH:MM:SS"
|
||||
const [hStr = "0", mStr = "00"] = t.split(":");
|
||||
const h = Number(hStr);
|
||||
const ampm = h >= 12 ? "pm" : "am";
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
return `${h12}:${mStr} ${ampm}`;
|
||||
}
|
||||
|
||||
function formatTimeCompact(t: string | null | undefined): string {
|
||||
if (!t) return "—";
|
||||
const [hStr = "0", mStr = "00"] = t.split(":");
|
||||
const h = Number(hStr);
|
||||
const ampm = h >= 12 ? "p" : "a";
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
return `${h12}:${mStr}${ampm}`;
|
||||
}
|
||||
|
||||
function formatDateLong(d: Date): string {
|
||||
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
|
||||
const month = MONTH_NAMES[d.getMonth()];
|
||||
return `${weekday}, ${month} ${d.getDate()}`;
|
||||
}
|
||||
|
||||
function formatDateLongSpelled(d: Date): string {
|
||||
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
|
||||
const month = MONTH_NAMES[d.getMonth()];
|
||||
return `${weekday}, ${month} ${d.getDate()}, ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Component */
|
||||
/* ================================================================== */
|
||||
|
||||
const MAX_EVENTS_PER_CELL = 3;
|
||||
|
||||
export default function StopsCalendarClient({ stops }: Props) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
const [viewMonth, setViewMonth] = useState(() => {
|
||||
const today = new Date();
|
||||
return new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
});
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
const [activePopover, setActivePopover] = useState<{
|
||||
stopId: string;
|
||||
cellRect: DOMRect;
|
||||
} | null>(null);
|
||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||
|
||||
// Bucket stops by YYYY-MM-DD
|
||||
const stopsByDate = useMemo(() => {
|
||||
const map = new Map<string, StopForView[]>();
|
||||
for (const s of stops) {
|
||||
if (!s.date) continue;
|
||||
const list = map.get(s.date) ?? [];
|
||||
list.push(s);
|
||||
map.set(s.date, list);
|
||||
}
|
||||
// Sort each day by time
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
|
||||
}
|
||||
return map;
|
||||
}, [stops]);
|
||||
|
||||
const todayStr = useMemo(() => todayYmd(), []);
|
||||
|
||||
const monthCells = useMemo(() => buildMonthGrid(viewMonth), [viewMonth]);
|
||||
|
||||
// Earliest/latest stop dates — used to enable/disable nav
|
||||
const dateRange = useMemo(() => {
|
||||
if (stops.length === 0) return null;
|
||||
const dates = stops.map((s) => s.date).filter(Boolean).sort();
|
||||
return { min: dates[0], max: dates[dates.length - 1] };
|
||||
}, [stops]);
|
||||
|
||||
const isFirstMonth = useMemo(() => {
|
||||
if (!dateRange?.min) return false;
|
||||
const minDate = parseYmd(dateRange.min);
|
||||
if (!minDate) return false;
|
||||
return viewMonth.getFullYear() === minDate.getFullYear() && viewMonth.getMonth() === minDate.getMonth();
|
||||
}, [dateRange, viewMonth]);
|
||||
|
||||
const isLastMonth = useMemo(() => {
|
||||
if (!dateRange?.max) return false;
|
||||
const maxDate = parseYmd(dateRange.max);
|
||||
if (!maxDate) return false;
|
||||
return viewMonth.getFullYear() === maxDate.getFullYear() && viewMonth.getMonth() === maxDate.getMonth();
|
||||
}, [dateRange, viewMonth]);
|
||||
|
||||
function goPrevMonth() {
|
||||
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1));
|
||||
}
|
||||
function goNextMonth() {
|
||||
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1));
|
||||
}
|
||||
function goToday() {
|
||||
const today = new Date();
|
||||
setViewMonth(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||
setSelectedDate(todayYmd());
|
||||
}
|
||||
|
||||
// Close popover on outside click / Escape
|
||||
useEffect(() => {
|
||||
if (!activePopover) return;
|
||||
function onDown(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("[data-event-popover]") || target.closest("[data-event-chip]")) return;
|
||||
setActivePopover(null);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setActivePopover(null);
|
||||
}
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [activePopover]);
|
||||
|
||||
// Close drawer on Escape
|
||||
useEffect(() => {
|
||||
if (!selectedDate) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setSelectedDate(null);
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [selectedDate]);
|
||||
|
||||
// Lock body scroll when drawer is open
|
||||
useEffect(() => {
|
||||
if (selectedDate) {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
const openStop = useCallback((stop: StopForView, anchorRect: DOMRect) => {
|
||||
setActivePopover({ stopId: stop.id, cellRect: anchorRect });
|
||||
}, []);
|
||||
|
||||
const handlePublish = useCallback(
|
||||
async (stop: StopForView) => {
|
||||
setActivePopover(null);
|
||||
setPublishingId(stop.id);
|
||||
const result = await publishStop(stop.id, stop.brand_id);
|
||||
setPublishingId(null);
|
||||
if (result.success) {
|
||||
showSuccess("Stop published", `${stop.city}, ${stop.state} is now visible to customers`);
|
||||
startTransition(() => router.refresh());
|
||||
} else {
|
||||
showError("Failed to publish", result.error ?? "Please try again");
|
||||
}
|
||||
},
|
||||
[router, showSuccess, showError]
|
||||
);
|
||||
|
||||
const activeStop = useMemo(
|
||||
() => (activePopover ? stops.find((s) => s.id === activePopover.stopId) ?? null : null),
|
||||
[activePopover, stops]
|
||||
);
|
||||
|
||||
const selectedDayStops = useMemo(() => {
|
||||
if (!selectedDate) return [];
|
||||
return stopsByDate.get(selectedDate) ?? [];
|
||||
}, [selectedDate, stopsByDate]);
|
||||
|
||||
const selectedDayDate = useMemo(() => (selectedDate ? parseYmd(selectedDate) : null), [selectedDate]);
|
||||
|
||||
// Compute popover position with viewport edge detection
|
||||
const popoverStyle = useMemo<React.CSSProperties | null>(() => {
|
||||
if (!activePopover) return null;
|
||||
const POPOVER_W = 288; // 18rem
|
||||
const MARGIN = 8;
|
||||
const rect = activePopover.cellRect;
|
||||
const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
|
||||
// Default: place below the chip, left-aligned
|
||||
let left = rect.left + rect.width / 2 - POPOVER_W / 2;
|
||||
const top = rect.bottom + MARGIN;
|
||||
if (left + POPOVER_W > vw - 8) left = vw - POPOVER_W - 8;
|
||||
if (left < 8) left = 8;
|
||||
return { left, top };
|
||||
}, [activePopover]);
|
||||
|
||||
const popoverArrowStyle = useMemo<React.CSSProperties | null>(() => {
|
||||
if (!activePopover || !popoverStyle) return null;
|
||||
const rect = activePopover.cellRect;
|
||||
const arrowLeft = rect.left + rect.width / 2 - (popoverStyle.left as number) - 5;
|
||||
return { left: Math.max(8, Math.min(arrowLeft, 270)) };
|
||||
}, [activePopover, popoverStyle]);
|
||||
|
||||
// === Render ===
|
||||
return (
|
||||
<>
|
||||
<div className="ha-calendar">
|
||||
{/* Header */}
|
||||
<div className="ha-calendar-header">
|
||||
<div className="ha-calendar-title-block">
|
||||
<span className="ha-calendar-eyebrow">
|
||||
Tour Almanac · {stops.length} {stops.length === 1 ? "stop" : "stops"} on file
|
||||
</span>
|
||||
<h2 className="ha-calendar-title">
|
||||
<span className="ha-calendar-title-italic">{MONTH_NAMES_ITALIC[viewMonth.getMonth()]}</span>
|
||||
<span className="ha-calendar-title-year">{viewMonth.getFullYear()}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="ha-calendar-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="ha-calendar-nav-btn"
|
||||
onClick={goPrevMonth}
|
||||
disabled={isFirstMonth}
|
||||
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 type="button" className="ha-calendar-today" onClick={goToday}>
|
||||
<span className="ha-calendar-today-dot" style={{ background: "currentColor" }} />
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ha-calendar-nav-btn"
|
||||
onClick={goNextMonth}
|
||||
disabled={isLastMonth}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* DOW header */}
|
||||
<div className="ha-calendar-dow" role="row">
|
||||
{DOW_LABELS.map((label) => (
|
||||
<div key={label} className="ha-calendar-dow-cell" role="columnheader">
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="ha-calendar-grid" role="grid">
|
||||
{monthCells.map((d) => {
|
||||
const key = ymd(d);
|
||||
const dayStops = stopsByDate.get(key) ?? [];
|
||||
const isOut = d.getMonth() !== viewMonth.getMonth();
|
||||
const isToday = key === todayStr;
|
||||
const hasStops = dayStops.length > 0;
|
||||
const dow = d.getDay();
|
||||
const isWeekend = dow === 0 || dow === 6;
|
||||
const isSelected = selectedDate === key;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="gridcell"
|
||||
tabIndex={hasStops ? 0 : -1}
|
||||
onClick={() => hasStops && setSelectedDate(key)}
|
||||
onKeyDown={(e) => {
|
||||
if (hasStops && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
setSelectedDate(key);
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
"ha-calendar-cell",
|
||||
isOut ? "ha-calendar-cell--out" : "",
|
||||
isWeekend ? "ha-calendar-cell--weekend" : "",
|
||||
hasStops ? "ha-calendar-cell--has-stops" : "",
|
||||
isToday ? "ha-calendar-cell--today" : "",
|
||||
isSelected ? "ha-calendar-cell--selected" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
aria-label={hasStops ? `${formatDateLong(d)} — ${dayStops.length} stop${dayStops.length !== 1 ? "s" : ""}` : formatDateLong(d)}
|
||||
>
|
||||
<div className="ha-calendar-daynum">
|
||||
<span>{d.getDate()}</span>
|
||||
{isToday && <span className="ha-calendar-today-dot" />}
|
||||
</div>
|
||||
<div className="ha-calendar-events">
|
||||
{dayStops.slice(0, MAX_EVENTS_PER_CELL).map((stop) => (
|
||||
<EventChip
|
||||
key={stop.id}
|
||||
stop={stop}
|
||||
isPublishing={publishingId === stop.id}
|
||||
onOpen={(rect) => openStop(stop, rect)}
|
||||
isActive={activePopover?.stopId === stop.id}
|
||||
/>
|
||||
))}
|
||||
{dayStops.length > MAX_EVENTS_PER_CELL && (
|
||||
<button
|
||||
type="button"
|
||||
className="ha-calendar-event-more"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedDate(key);
|
||||
}}
|
||||
>
|
||||
+{dayStops.length - MAX_EVENTS_PER_CELL} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="ha-calendar-legend">
|
||||
<span className="ha-calendar-legend-item">
|
||||
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-accent)" }} />
|
||||
Active
|
||||
</span>
|
||||
<span className="ha-calendar-legend-item">
|
||||
<span className="ha-calendar-legend-swatch" style={{ background: "#f59e0b" }} />
|
||||
Draft
|
||||
</span>
|
||||
<span className="ha-calendar-legend-item">
|
||||
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-text-muted)" }} />
|
||||
Inactive
|
||||
</span>
|
||||
<span style={{ marginLeft: "auto" }} className="hidden sm:inline">
|
||||
Tip — click any day with stops to see the full route · click an event for details
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event popover */}
|
||||
{activeStop && activePopover && popoverStyle && (
|
||||
<EventPopover
|
||||
stop={activeStop}
|
||||
isPublishing={publishingId === activeStop.id}
|
||||
onPublish={() => handlePublish(activeStop)}
|
||||
onOpenRoute={() => {
|
||||
setActivePopover(null);
|
||||
setSelectedDate(activeStop.date);
|
||||
}}
|
||||
style={popoverStyle}
|
||||
arrowStyle={popoverArrowStyle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Day-route drawer */}
|
||||
{selectedDate && selectedDayDate && (
|
||||
<DayRouteDrawer
|
||||
date={selectedDayDate}
|
||||
dateStr={selectedDate}
|
||||
stops={selectedDayStops}
|
||||
onClose={() => setSelectedDate(null)}
|
||||
onPublish={handlePublish}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* EventChip — single stop on a day cell */
|
||||
/* ================================================================== */
|
||||
|
||||
function EventChip({
|
||||
stop,
|
||||
onOpen,
|
||||
isActive,
|
||||
isPublishing,
|
||||
}: {
|
||||
stop: StopForView;
|
||||
onOpen: (rect: DOMRect) => void;
|
||||
isActive: boolean;
|
||||
isPublishing: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const status = statusOf(stop);
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
data-event-chip
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isPublishing && ref.current) onOpen(ref.current.getBoundingClientRect());
|
||||
}}
|
||||
className={`ha-calendar-event ha-calendar-event--${status} ${isActive ? "!bg-[var(--admin-accent)]/20" : ""}`}
|
||||
title={`${stop.city}, ${stop.state} — ${stop.location}`}
|
||||
>
|
||||
<span className="ha-calendar-event-time">{formatTimeCompact(stop.time)}</span>
|
||||
<span className="ha-calendar-event-text">{stop.city}, {stop.state}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* EventPopover — floating detail card */
|
||||
/* ================================================================== */
|
||||
|
||||
function EventPopover({
|
||||
stop,
|
||||
isPublishing,
|
||||
onPublish,
|
||||
onOpenRoute,
|
||||
style,
|
||||
arrowStyle,
|
||||
}: {
|
||||
stop: StopForView;
|
||||
isPublishing: boolean;
|
||||
onPublish: () => void;
|
||||
onOpenRoute: () => void;
|
||||
style: React.CSSProperties;
|
||||
arrowStyle: React.CSSProperties | null;
|
||||
}) {
|
||||
const status = statusOf(stop);
|
||||
return (
|
||||
<div
|
||||
data-event-popover
|
||||
className="ha-event-popover"
|
||||
style={style}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{arrowStyle && <div className="ha-event-popover-arrow" style={{ ...arrowStyle, top: -5 }} />}
|
||||
<div className="ha-event-popover-eyebrow">
|
||||
<span
|
||||
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
status === "active" ? "var(--admin-accent)" : status === "draft" ? "#f59e0b" : "var(--admin-text-muted)",
|
||||
}}
|
||||
/>
|
||||
{statusLabel(status)} · {brandName(stop)}
|
||||
</div>
|
||||
<h3 className="ha-event-popover-title">
|
||||
{stop.city}, {stop.state}
|
||||
</h3>
|
||||
<p className="ha-event-popover-location">{stop.location}</p>
|
||||
|
||||
<div className="ha-event-popover-grid">
|
||||
<div className="ha-event-popover-field">
|
||||
<span className="ha-event-popover-field-label">Pickup</span>
|
||||
<span className="ha-event-popover-field-value">{formatTime12(stop.time)}</span>
|
||||
</div>
|
||||
<div className="ha-event-popover-field">
|
||||
<span className="ha-event-popover-field-label">Cutoff</span>
|
||||
<span className="ha-event-popover-field-value">{stop.cutoff_time ? formatTime12(stop.cutoff_time) : "—"}</span>
|
||||
</div>
|
||||
{stop.address && (
|
||||
<div className="ha-event-popover-field" style={{ gridColumn: "1 / -1" }}>
|
||||
<span className="ha-event-popover-field-label">Address</span>
|
||||
<span className="ha-event-popover-field-value" style={{ fontFamily: "var(--font-geist), sans-serif", fontSize: "0.75rem" }}>
|
||||
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ha-event-popover-actions">
|
||||
{status === "draft" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPublish}
|
||||
disabled={isPublishing}
|
||||
className="ha-event-popover-btn ha-event-popover-btn--primary"
|
||||
>
|
||||
{isPublishing ? "Publishing…" : "Publish"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenRoute}
|
||||
className="ha-event-popover-btn"
|
||||
>
|
||||
View route
|
||||
</button>
|
||||
<Link
|
||||
href={`/admin/stops/${stop.id}`}
|
||||
className="ha-event-popover-btn ha-event-popover-btn--primary"
|
||||
>
|
||||
Edit
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* DayRouteDrawer — full day's route on the side */
|
||||
/* ================================================================== */
|
||||
|
||||
function DayRouteDrawer({
|
||||
date,
|
||||
dateStr,
|
||||
stops,
|
||||
onClose,
|
||||
onPublish,
|
||||
}: {
|
||||
date: Date;
|
||||
dateStr: string;
|
||||
stops: StopForView[];
|
||||
onClose: () => void;
|
||||
onPublish: (stop: StopForView) => void;
|
||||
}) {
|
||||
const sorted = useMemo(() => [...stops].sort((a, b) => (a.time || "").localeCompare(b.time || "")), [stops]);
|
||||
const counts = useMemo(() => {
|
||||
const active = sorted.filter((s) => s.status !== "draft" && s.active).length;
|
||||
const draft = sorted.filter((s) => s.status === "draft").length;
|
||||
const total = sorted.length;
|
||||
return { active, draft, total };
|
||||
}, [sorted]);
|
||||
const brands = useMemo(() => Array.from(new Set(sorted.map(brandName))), [sorted]);
|
||||
const routeNumber = useMemo(() => {
|
||||
// Editorial "Route 03" — count of stops with status badge
|
||||
return String(sorted.length).padStart(2, "0");
|
||||
}, [sorted.length]);
|
||||
|
||||
const earliest = sorted[0]?.time;
|
||||
const latest = sorted[sorted.length - 1]?.time;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ha-drawer-backdrop" onClick={onClose} aria-hidden="true" />
|
||||
<aside className="ha-drawer" role="dialog" aria-label={`Route for ${formatDateLongSpelled(date)}`}>
|
||||
<header className="ha-drawer-header">
|
||||
<div>
|
||||
<div className="ha-drawer-eyebrow">Route {routeNumber} · {dateStr}</div>
|
||||
<h2 className="ha-drawer-title">{formatDateLongSpelled(date)}</h2>
|
||||
<p className="ha-drawer-subtitle">
|
||||
{sorted.length} {sorted.length === 1 ? "stop" : "stops"} on the route
|
||||
{earliest && latest && sorted.length > 1 && (
|
||||
<> · {formatTime12(earliest)} – {formatTime12(latest)}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="ha-drawer-close" aria-label="Close">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="ha-drawer-body">
|
||||
{sorted.length === 0 ? (
|
||||
<div className="ha-drawer-empty">
|
||||
<p className="ha-drawer-empty-mark">No route on this day</p>
|
||||
<p className="ha-drawer-empty-text">
|
||||
No stops are scheduled for {formatDateLongSpelled(date)}.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary cards */}
|
||||
<div className="ha-route-summary">
|
||||
<div className="ha-route-summary-cell">
|
||||
<span className="ha-route-summary-num">{counts.total}</span>
|
||||
<span className="ha-route-summary-label">Stops</span>
|
||||
</div>
|
||||
<div className="ha-route-summary-cell">
|
||||
<span className="ha-route-summary-num">{counts.active}</span>
|
||||
<span className="ha-route-summary-label">Live</span>
|
||||
</div>
|
||||
<div className="ha-route-summary-cell">
|
||||
<span className="ha-route-summary-num">{brands.length}</span>
|
||||
<span className="ha-route-summary-label">Brands</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route spine */}
|
||||
<div className="ha-route-list">
|
||||
{sorted.map((stop, idx) => {
|
||||
const status = statusOf(stop);
|
||||
return (
|
||||
<article key={stop.id} className="ha-route-stop">
|
||||
<div className="ha-route-stop-spine">
|
||||
<div className={`ha-route-stop-marker ha-route-stop-marker--${status}`}>
|
||||
{String(idx + 1).padStart(2, "0")}
|
||||
</div>
|
||||
<div className="ha-route-stop-line" />
|
||||
</div>
|
||||
<div className="ha-route-stop-content">
|
||||
<div className="ha-route-stop-time">{formatTime12(stop.time)}</div>
|
||||
<h3 className="ha-route-stop-name">
|
||||
{stop.city}, {stop.state}
|
||||
</h3>
|
||||
<p className="ha-route-stop-location">{stop.location}</p>
|
||||
<div className="ha-route-stop-meta">
|
||||
<span className="ha-route-stop-meta-pill">{brandName(stop)}</span>
|
||||
<span
|
||||
className="ha-route-stop-meta-pill"
|
||||
style={{
|
||||
background:
|
||||
status === "active"
|
||||
? "var(--admin-accent-light)"
|
||||
: status === "draft"
|
||||
? "rgba(245, 158, 11, 0.1)"
|
||||
: "var(--admin-bg-subtle)",
|
||||
color:
|
||||
status === "active"
|
||||
? "var(--admin-accent-text)"
|
||||
: status === "draft"
|
||||
? "#92400e"
|
||||
: "var(--admin-text-secondary)",
|
||||
borderColor:
|
||||
status === "active"
|
||||
? "var(--admin-accent)"
|
||||
: status === "draft"
|
||||
? "rgba(245, 158, 11, 0.3)"
|
||||
: "var(--admin-border-light)",
|
||||
}}
|
||||
>
|
||||
{statusLabel(status)}
|
||||
</span>
|
||||
{stop.cutoff_time && (
|
||||
<span className="ha-route-stop-meta-pill">
|
||||
Cutoff {formatTime12(stop.cutoff_time)}
|
||||
</span>
|
||||
)}
|
||||
{stop.address && (
|
||||
<span className="ha-route-stop-meta-pill">
|
||||
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ha-route-stop-actions">
|
||||
{status === "draft" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPublish(stop)}
|
||||
className="ha-route-stop-action ha-route-stop-action--primary"
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
Publish
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href={`/admin/stops/${stop.id}`}
|
||||
className="ha-route-stop-action ha-route-stop-action--primary"
|
||||
>
|
||||
Edit
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/stops/new?duplicate=${stop.id}`}
|
||||
className="ha-route-stop-action"
|
||||
>
|
||||
Duplicate
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import StopTableClient from "@/components/admin/StopTableClient";
|
||||
import StopsCalendarClient from "@/components/admin/StopsCalendarClient";
|
||||
import { AdminSearchInput, AdminFilterTabs } from "@/components/admin/design-system";
|
||||
|
||||
export type StopStatusFilter = "all" | "active" | "inactive" | "draft";
|
||||
export type StopsViewMode = "calendar" | "table";
|
||||
|
||||
export type StopForView = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
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 }[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
stops: StopForView[];
|
||||
};
|
||||
|
||||
export default function StopsViewClient({ stops }: Props) {
|
||||
const [view, setView] = useState<StopsViewMode>("calendar");
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StopStatusFilter>("all");
|
||||
|
||||
// Apply shared filter so both views agree on what's visible
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return stops.filter((s) => {
|
||||
const matchesSearch =
|
||||
!q ||
|
||||
s.city.toLowerCase().includes(q) ||
|
||||
s.state.toLowerCase().includes(q) ||
|
||||
s.location.toLowerCase().includes(q);
|
||||
const isDraft = s.status === "draft";
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "active" && s.active && !isDraft) ||
|
||||
(statusFilter === "inactive" && !s.active && !isDraft) ||
|
||||
(statusFilter === "draft" && isDraft);
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [stops, search, statusFilter]);
|
||||
|
||||
const counts = 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 = [
|
||||
{ value: "all", label: "All", count: counts.all },
|
||||
{ value: "active", label: "Active", count: counts.active },
|
||||
{ value: "inactive", label: "Inactive", count: counts.inactive },
|
||||
{ value: "draft", label: "Draft", count: counts.draft },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Filter / search / view-toggle bar — shared across both views */}
|
||||
<div className="flex flex-col gap-3 mb-4 sm:flex-row sm:items-center">
|
||||
<AdminFilterTabs
|
||||
activeTab={statusFilter}
|
||||
onTabChange={(v) => setStatusFilter(v as StopStatusFilter)}
|
||||
tabs={tabs}
|
||||
size="sm"
|
||||
showCounts
|
||||
/>
|
||||
<AdminSearchInput
|
||||
placeholder="Search city, state, or venue…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onClear={() => setSearch("")}
|
||||
showClear
|
||||
className="flex-1 min-w-48 max-w-72"
|
||||
/>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{filtered.length} {filtered.length === 1 ? "stop" : "stops"}
|
||||
</span>
|
||||
<div className="sm:ml-auto">
|
||||
<div className="ha-viewtoggle" role="tablist" aria-label="Stops view mode">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === "calendar"}
|
||||
onClick={() => setView("calendar")}
|
||||
className={`ha-viewtoggle-btn ${view === "calendar" ? "ha-viewtoggle-btn--active" : ""}`}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
|
||||
</svg>
|
||||
Calendar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === "table"}
|
||||
onClick={() => setView("table")}
|
||||
className={`ha-viewtoggle-btn ${view === "table" ? "ha-viewtoggle-btn--active" : ""}`}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M3 6h18M3 12h18M3 18h18" strokeLinecap="round" />
|
||||
</svg>
|
||||
Table
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === "calendar" ? (
|
||||
<StopsCalendarClient stops={filtered} />
|
||||
) : (
|
||||
<StopTableClient stops={filtered} hideInternalFilterBar />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user