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 LocationsTab from "@/components/admin/LocationsTab";
|
||||||
import StopsLocationsTabs from "@/components/admin/StopsLocationsTabs";
|
import StopsLocationsTabs from "@/components/admin/StopsLocationsTabs";
|
||||||
import StatsStrip from "@/components/admin/StatsStrip";
|
import StatsStrip from "@/components/admin/StatsStrip";
|
||||||
@@ -133,6 +134,11 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
|
|||||||
icon={<StopIcon />}
|
icon={<StopIcon />}
|
||||||
title="Stops & Routes"
|
title="Stops & Routes"
|
||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
|
actions={
|
||||||
|
<StopsHeaderActions
|
||||||
|
brandId={activeBrandId ?? adminUser?.brand_id ?? ""}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div className="mt-3 ml-[52px]">
|
<div className="mt-3 ml-[52px]">
|
||||||
<StatsStrip
|
<StatsStrip
|
||||||
@@ -155,7 +161,7 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
|
|||||||
/>
|
/>
|
||||||
<TabSwitcher tabKey={tab}>
|
<TabSwitcher tabKey={tab}>
|
||||||
{tab === "stops" ? (
|
{tab === "stops" ? (
|
||||||
<StopTableClient stops={safeStops} />
|
<StopsViewClient stops={safeStops} />
|
||||||
) : (
|
) : (
|
||||||
<LocationsTab locations={locations} brandId={adminUser?.brand_id ?? ""} />
|
<LocationsTab locations={locations} brandId={adminUser?.brand_id ?? ""} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+28
-1
@@ -1,9 +1,32 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { Fraunces, Geist, JetBrains_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";
|
||||||
import CookieConsentBanner from "@/components/legal/CookieConsentBanner";
|
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";
|
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
@@ -50,7 +73,11 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html
|
||||||
|
lang="en"
|
||||||
|
suppressHydrationWarning
|
||||||
|
className={`${fraunces.variable} ${geist.variable} ${jetbrainsMono.variable}`}
|
||||||
|
>
|
||||||
<body>
|
<body>
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
<ToastNotificationContainer />
|
<ToastNotificationContainer />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { createStop } from "@/actions/stops/create-stop";
|
import { createStop } from "@/actions/stops/create-stop";
|
||||||
import GlassModal from "@/components/admin/GlassModal";
|
import GlassModal from "@/components/admin/GlassModal";
|
||||||
|
|
||||||
@@ -21,12 +21,20 @@ type Props = {
|
|||||||
onSuccess?: (stopId: string) => void;
|
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) {
|
export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom, onSuccess }: Props) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [city, setCity] = useState("");
|
const [city, setCity] = useState("");
|
||||||
const [state, setState] = useState("");
|
const [stateField, setStateField] = useState("");
|
||||||
const [location, setLocation] = useState("");
|
const [location, setLocation] = useState("");
|
||||||
const [date, setDate] = useState("");
|
const [date, setDate] = useState("");
|
||||||
const [time, setTime] = useState("");
|
const [time, setTime] = useState("");
|
||||||
@@ -36,11 +44,13 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
|||||||
const [cutoffTime, setCutoffTime] = useState("");
|
const [cutoffTime, setCutoffTime] = useState("");
|
||||||
const [status, setStatus] = useState<"draft" | "active">("draft");
|
const [status, setStatus] = useState<"draft" | "active">("draft");
|
||||||
|
|
||||||
|
const cityRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
// Reset form when modal opens with optional duplicate source
|
// Reset form when modal opens with optional duplicate source
|
||||||
setCity(duplicateFrom?.city ?? "");
|
setCity(duplicateFrom?.city ?? "");
|
||||||
setState(duplicateFrom?.state ?? "");
|
setStateField(duplicateFrom?.state ?? "");
|
||||||
setLocation(duplicateFrom?.location ?? "");
|
setLocation(duplicateFrom?.location ?? "");
|
||||||
setDate(duplicateFrom?.date ?? "");
|
setDate(duplicateFrom?.date ?? "");
|
||||||
setTime(duplicateFrom?.time ?? "");
|
setTime(duplicateFrom?.time ?? "");
|
||||||
@@ -49,272 +59,328 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
|||||||
setCutoffTime(duplicateFrom?.cutoff_time ?? "");
|
setCutoffTime(duplicateFrom?.cutoff_time ?? "");
|
||||||
setStatus("draft");
|
setStatus("draft");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
// Auto-focus city field for fast data entry
|
||||||
|
requestAnimationFrame(() => cityRef.current?.focus());
|
||||||
}
|
}
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
/* eslint-enable react-hooks/set-state-in-effect */
|
||||||
}, [isOpen, duplicateFrom]);
|
}, [isOpen, duplicateFrom]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
const handleSubmit = useCallback(
|
||||||
e.preventDefault();
|
async (e: React.FormEvent) => {
|
||||||
setError(null);
|
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.");
|
setError("City, state, location, and date are required.");
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await createStop(brandId, {
|
|
||||||
city: city.trim(),
|
|
||||||
state: state.trim(),
|
|
||||||
location: location.trim(),
|
|
||||||
date,
|
|
||||||
time: time || "08:00",
|
|
||||||
address: address || undefined,
|
|
||||||
zip: zip || undefined,
|
|
||||||
cutoff_time: cutoffTime || undefined,
|
|
||||||
active: status === "active",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
onSuccess?.(result.id);
|
|
||||||
onClose();
|
|
||||||
} else {
|
|
||||||
setError(result.error ?? "Failed to create stop");
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
setError("Network error. Please try again.");
|
setLoading(true);
|
||||||
} finally {
|
try {
|
||||||
setLoading(false);
|
const result = await createStop(brandId, {
|
||||||
}
|
city: city.trim(),
|
||||||
}, [brandId, city, state, location, date, time, address, zip, cutoffTime, status, onSuccess, onClose]);
|
state: stateField.trim(),
|
||||||
|
location: location.trim(),
|
||||||
|
date,
|
||||||
|
time: time || "08:00",
|
||||||
|
address: address || undefined,
|
||||||
|
zip: zip || undefined,
|
||||||
|
cutoff_time: cutoffTime || undefined,
|
||||||
|
active: status === "active",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
onSuccess?.(result.id);
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setError(result.error ?? "Failed to create stop");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Network error. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
brandId,
|
||||||
|
city,
|
||||||
|
stateField,
|
||||||
|
location,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
address,
|
||||||
|
zip,
|
||||||
|
cutoffTime,
|
||||||
|
status,
|
||||||
|
onSuccess,
|
||||||
|
onClose,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const isDuplicate = Boolean(duplicateFrom);
|
const isDuplicate = Boolean(duplicateFrom);
|
||||||
const title = isDuplicate ? "Duplicate Stop" : "Add New Stop";
|
const title = isDuplicate ? "Duplicate Stop" : "Add Stop";
|
||||||
const subtitle = isDuplicate && duplicateFrom
|
const eyebrow = isDuplicate && duplicateFrom
|
||||||
? `From ${duplicateFrom.city}, ${duplicateFrom.state}`
|
? `From ${duplicateFrom.city}, ${duplicateFrom.state}`
|
||||||
: "Create a new tour stop for your route.";
|
: "New stop on the route";
|
||||||
|
const submitLabel = loading
|
||||||
const inputStyle = {
|
? isDuplicate ? "Duplicating…" : "Creating…"
|
||||||
background: 'rgba(0, 0, 0, 0.02)',
|
: isDuplicate
|
||||||
border: '1px solid rgba(0, 0, 0, 0.06)',
|
? "Duplicate Stop"
|
||||||
outline: 'none',
|
: status === "active"
|
||||||
};
|
? "Create & Publish"
|
||||||
|
: "Save as Draft";
|
||||||
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';
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassModal title={title} subtitle={subtitle} onClose={onClose}>
|
<GlassModal
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
title={title}
|
||||||
|
eyebrow={eyebrow}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="max-w-xl"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
|
<div
|
||||||
style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.2)' }}>
|
role="alert"
|
||||||
{error}
|
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
{/* Row 1 — Where: City + State */}
|
||||||
<div className="col-span-2 space-y-1.5">
|
<div className="grid grid-cols-[1fr_5.5rem] gap-3">
|
||||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
|
<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
|
<input
|
||||||
|
ref={cityRef}
|
||||||
|
id="stop-city"
|
||||||
type="text"
|
type="text"
|
||||||
value={city}
|
value={city}
|
||||||
onChange={(e) => setCity(e.target.value)}
|
onChange={(e) => setCity(e.target.value)}
|
||||||
placeholder="Denver"
|
placeholder="Denver"
|
||||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
className="ha-field-input"
|
||||||
style={inputStyle}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
required
|
required
|
||||||
|
autoComplete="address-level2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="ha-field">
|
||||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
|
<label htmlFor="stop-state" className="ha-field-label">
|
||||||
|
<span>State</span>
|
||||||
|
<span className="ha-field-label-required">*</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="stop-state"
|
||||||
type="text"
|
type="text"
|
||||||
value={state}
|
value={stateField}
|
||||||
onChange={(e) => setState(e.target.value.toUpperCase())}
|
onChange={(e) => setStateField(e.target.value.toUpperCase())}
|
||||||
placeholder="CO"
|
placeholder="CO"
|
||||||
maxLength={2}
|
maxLength={2}
|
||||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
className="ha-field-input ha-field-input-mono text-center"
|
||||||
style={inputStyle}
|
style={{ textTransform: "uppercase" }}
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
required
|
required
|
||||||
|
autoComplete="address-level1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
{/* Row 2 — Where at: Location (full) */}
|
||||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Location</label>
|
<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
|
<input
|
||||||
|
id="stop-location"
|
||||||
type="text"
|
type="text"
|
||||||
value={location}
|
value={location}
|
||||||
onChange={(e) => setLocation(e.target.value)}
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
placeholder="Whole Foods Market"
|
placeholder="Whole Foods Market — Highlands"
|
||||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
className="ha-field-input"
|
||||||
style={inputStyle}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{/* Row 3 — When: Date + Time (with small clock icon) */}
|
||||||
<div className="space-y-1.5">
|
<div className="grid grid-cols-[1fr_1fr] gap-3">
|
||||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Date</label>
|
<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
|
<input
|
||||||
|
id="stop-date"
|
||||||
type="date"
|
type="date"
|
||||||
value={date}
|
value={date}
|
||||||
onChange={(e) => setDate(e.target.value)}
|
onChange={(e) => setDate(e.target.value)}
|
||||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
className="ha-field-input ha-field-input-mono"
|
||||||
style={inputStyle}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="ha-field">
|
||||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Pickup Time</label>
|
<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
|
<input
|
||||||
|
id="stop-time"
|
||||||
type="time"
|
type="time"
|
||||||
value={time}
|
value={time}
|
||||||
onChange={(e) => setTime(e.target.value)}
|
onChange={(e) => setTime(e.target.value)}
|
||||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
className="ha-field-input ha-field-input-mono"
|
||||||
style={inputStyle}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
{/* Row 4 — Address + ZIP + Cutoff in 3 columns */}
|
||||||
<div className="col-span-2 space-y-1.5">
|
<div className="grid grid-cols-[1fr_5.5rem_6.5rem] gap-3">
|
||||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Address</label>
|
<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
|
<input
|
||||||
|
id="stop-address"
|
||||||
type="text"
|
type="text"
|
||||||
value={address}
|
value={address}
|
||||||
onChange={(e) => setAddress(e.target.value)}
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
placeholder="123 Main St"
|
placeholder="123 Main St"
|
||||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
className="ha-field-input"
|
||||||
style={inputStyle}
|
autoComplete="street-address"
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="ha-field">
|
||||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
|
<label htmlFor="stop-zip" className="ha-field-label">
|
||||||
|
<span>ZIP</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="stop-zip"
|
||||||
type="text"
|
type="text"
|
||||||
value={zip}
|
value={zip}
|
||||||
onChange={(e) => setZip(e.target.value)}
|
onChange={(e) => setZip(e.target.value)}
|
||||||
placeholder="80202"
|
placeholder="80202"
|
||||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
maxLength={10}
|
||||||
style={inputStyle}
|
className="ha-field-input ha-field-input-mono"
|
||||||
onFocus={handleFocus}
|
autoComplete="postal-code"
|
||||||
onBlur={handleBlur}
|
/>
|
||||||
|
</div>
|
||||||
|
<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="ha-field-input ha-field-input-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
{/* Row 5 — Status: segmented control (compact, replaces two giant buttons) */}
|
||||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Cutoff Time</label>
|
<div className="ha-field pt-0.5">
|
||||||
<input
|
<div className="flex items-center justify-between">
|
||||||
type="time"
|
<label className="ha-field-label">
|
||||||
value={cutoffTime}
|
<span>Visibility</span>
|
||||||
onChange={(e) => setCutoffTime(e.target.value)}
|
</label>
|
||||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
<span className="text-[10px] text-[var(--admin-text-muted)] leading-none">
|
||||||
style={inputStyle}
|
Draft is hidden from customers
|
||||||
onFocus={handleFocus}
|
</span>
|
||||||
onBlur={handleBlur}
|
</div>
|
||||||
/>
|
<div className="ha-segment mt-1" role="radiogroup" aria-label="Stop status">
|
||||||
<p className="mt-1 text-xs text-stone-400 ml-1">Orders close this time the day before pickup</p>
|
|
||||||
</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">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={status === "draft"}
|
||||||
onClick={() => setStatus("draft")}
|
onClick={() => setStatus("draft")}
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
|
className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
|
||||||
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)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
|
<span className="ha-segment-dot" />
|
||||||
background: status === "draft" ? 'rgba(245, 158, 11, 0.2)' : 'rgba(0, 0, 0, 0.05)',
|
<span>Save as Draft</span>
|
||||||
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>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={status === "active"}
|
||||||
onClick={() => setStatus("active")}
|
onClick={() => setStatus("active")}
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
|
className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
|
||||||
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)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
|
<span className="ha-segment-dot" />
|
||||||
background: status === "active" ? 'rgba(16, 185, 129, 0.2)' : 'rgba(0, 0, 0, 0.05)',
|
<span>Publish Now</span>
|
||||||
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>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* 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">
|
||||||
<button
|
<span className="ha-modal-footer-hint">
|
||||||
type="button"
|
<kbd>Esc</kbd>
|
||||||
onClick={onClose}
|
to close
|
||||||
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"
|
</span>
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
Cancel
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
onClick={onClose}
|
||||||
type="submit"
|
className="ha-btn-ghost"
|
||||||
disabled={loading}
|
>
|
||||||
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
|
Cancel
|
||||||
style={{
|
</button>
|
||||||
background: loading
|
<button
|
||||||
? 'rgba(16, 185, 129, 0.4)'
|
type="submit"
|
||||||
: 'linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)',
|
disabled={loading}
|
||||||
boxShadow: loading
|
className="ha-btn-primary"
|
||||||
? 'none'
|
>
|
||||||
: '0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)',
|
{loading ? (
|
||||||
opacity: loading ? 0.7 : 1,
|
<>
|
||||||
}}
|
<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" />
|
||||||
{loading ? (
|
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||||
<span className="flex items-center gap-2">
|
</svg>
|
||||||
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
<span>{submitLabel}</span>
|
||||||
<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>
|
<>
|
||||||
Creating...
|
{status === "active" ? (
|
||||||
</span>
|
<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" />
|
||||||
isDuplicate ? "Duplicate Stop" : "Create Stop"
|
</svg>
|
||||||
)}
|
) : (
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</GlassModal>
|
</GlassModal>
|
||||||
@@ -338,4 +404,4 @@ export function useAddStopModal(brandId: string, duplicateFrom?: Props["duplicat
|
|||||||
), [isOpen, close, brandId, duplicateFrom]);
|
), [isOpen, close, brandId, duplicateFrom]);
|
||||||
|
|
||||||
return { open, close, Modal };
|
return { open, close, Modal };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,22 @@ type Props = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
maxWidth?: string;
|
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
|
// Lock body scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
@@ -33,25 +46,36 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
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
|
<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) */}
|
||||||
<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" />
|
{!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 */}
|
{/* Header */}
|
||||||
<div
|
<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)" }}
|
style={{ borderBottom: "1px solid var(--admin-border-light)" }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
||||||
{titleIcon && (
|
{titleIcon && !compact && (
|
||||||
<div
|
<div
|
||||||
className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-xl shrink-0"
|
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)" }}
|
style={{ backgroundColor: "var(--admin-accent-light)" }}
|
||||||
>
|
>
|
||||||
@@ -59,21 +83,39 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2
|
{compact && eyebrow && (
|
||||||
className="text-lg sm:text-xl font-semibold text-[var(--admin-text-primary)] truncate"
|
<p className="ha-eyebrow mb-0.5 truncate">{eyebrow}</p>
|
||||||
style={{ letterSpacing: "-0.02em" }}
|
)}
|
||||||
|
<h2
|
||||||
|
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}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
{subtitle && (
|
{subtitle && !compact && (
|
||||||
<p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">{subtitle}</p>
|
<p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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"
|
className={`flex shrink-0 items-center justify-center rounded-full transition-all duration-150 ml-2 ${
|
||||||
style={{ backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }}
|
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}>
|
<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" />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content - scrollable */}
|
{/* Content */}
|
||||||
<div className="relative px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8 overflow-y-auto flex-1">
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import { logAuditEvent } from "@/actions/audit";
|
import { logAuditEvent } from "@/actions/audit";
|
||||||
|
|
||||||
type Product = {
|
type Product = {
|
||||||
@@ -8,12 +9,13 @@ type Product = {
|
|||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
price: number;
|
price: number;
|
||||||
|
image_url?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AssignedProduct = {
|
type AssignedProduct = {
|
||||||
id: string;
|
id: string;
|
||||||
product_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 = {
|
type StopProductAssignmentProps = {
|
||||||
@@ -23,6 +25,20 @@ type StopProductAssignmentProps = {
|
|||||||
callerUid: string;
|
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({
|
export default function StopProductAssignment({
|
||||||
stopId,
|
stopId,
|
||||||
allProducts,
|
allProducts,
|
||||||
@@ -30,209 +46,386 @@ export default function StopProductAssignment({
|
|||||||
callerUid,
|
callerUid,
|
||||||
}: StopProductAssignmentProps) {
|
}: StopProductAssignmentProps) {
|
||||||
const [products, setProducts] = useState(assignedProducts);
|
const [products, setProducts] = useState(assignedProducts);
|
||||||
const [selected, setSelected] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [assigning, setAssigning] = useState(false);
|
const [filter, setFilter] = useState<Filter>("all");
|
||||||
const [removing, setRemoving] = useState<string | null>(null);
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||||
|
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const assignedIds = new Set(products.map((p) => p.product_id));
|
const assignedIds = useMemo(
|
||||||
const availableProducts = allProducts.filter((p) => !assignedIds.has(p.id));
|
() => new Set(products.map((p) => p.product_id)),
|
||||||
|
[products]
|
||||||
|
);
|
||||||
|
|
||||||
async function handleAssign(e: React.FormEvent) {
|
// Counters for the filter chips
|
||||||
e.preventDefault();
|
const counts = useMemo(() => {
|
||||||
if (!selected) return;
|
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);
|
setError(null);
|
||||||
|
|
||||||
// First run diagnostic to understand the actual state
|
try {
|
||||||
const diagRes = await fetch(
|
const res = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/debug_stop_product_assignment`,
|
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_stop_id: stopId,
|
p_stop_id: stopId,
|
||||||
p_product_id: selected,
|
p_product_id: productId,
|
||||||
p_caller_uid: callerUid,
|
p_caller_uid: callerUid,
|
||||||
}),
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
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 assign: ${errMsg}${errDetail ? " — " + errDetail : ""}`);
|
||||||
|
setPendingId(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
const diag = await diagRes.json();
|
|
||||||
|
|
||||||
// Now attempt the actual assignment
|
// Optimistic: re-fetch to keep assigned list in sync with server
|
||||||
const res = await fetch(
|
const refreshRes = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
|
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_stop_products`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ p_stop_id: stopId }),
|
||||||
p_stop_id: stopId,
|
}
|
||||||
p_product_id: selected,
|
);
|
||||||
p_caller_uid: callerUid,
|
const refreshData = await refreshRes.json();
|
||||||
}),
|
setProducts(refreshData.products ?? []);
|
||||||
}
|
setPendingId(null);
|
||||||
);
|
|
||||||
|
|
||||||
const data = await res.json();
|
logAuditEvent({
|
||||||
|
table_name: "product_stops",
|
||||||
if (!res.ok || data.success === false) {
|
record_id: data.id,
|
||||||
const diagInfo = diag && typeof diag === 'object' && 'reason' in diag
|
action: "INSERT",
|
||||||
? `[${(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})`
|
old_data: null,
|
||||||
: '';
|
new_data: { stop_id: stopId, product_id: productId },
|
||||||
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
|
brand_id: null,
|
||||||
const errDetail = data?.details ?? data?.hint ?? data?.code ?? '';
|
});
|
||||||
const fullError = diagInfo
|
} catch {
|
||||||
? `Failed to assign: ${errMsg} ${diagInfo}${errDetail ? ' | ' + errDetail : ''}`
|
setError("Network error while assigning product.");
|
||||||
: `Failed to assign: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`;
|
setPendingId(null);
|
||||||
setError(fullError);
|
|
||||||
setAssigning(false);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh product list from get_stop_products RPC
|
|
||||||
const refreshRes = await fetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_stop_products`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ p_stop_id: stopId }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const refreshData = await refreshRes.json();
|
|
||||||
setProducts(refreshData.products ?? []);
|
|
||||||
setSelected("");
|
|
||||||
setAssigning(false);
|
|
||||||
|
|
||||||
logAuditEvent({
|
|
||||||
table_name: "product_stops",
|
|
||||||
record_id: data.id,
|
|
||||||
action: "INSERT",
|
|
||||||
old_data: null,
|
|
||||||
new_data: { stop_id: stopId, product_id: selected },
|
|
||||||
brand_id: null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemove(productId: string) {
|
async function remove(productId: string) {
|
||||||
|
if (removingId) return;
|
||||||
const entry = products.find((p) => p.product_id === productId);
|
const entry = products.find((p) => p.product_id === productId);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
|
||||||
setRemoving(productId);
|
setRemovingId(productId);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await fetch(
|
try {
|
||||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
|
const res = await fetch(
|
||||||
{
|
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
|
||||||
method: "POST",
|
{
|
||||||
headers: {
|
method: "POST",
|
||||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
headers: {
|
||||||
"Content-Type": "application/json",
|
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
},
|
"Content-Type": "application/json",
|
||||||
body: JSON.stringify({
|
},
|
||||||
p_stop_id: stopId,
|
body: JSON.stringify({
|
||||||
p_product_id: productId,
|
p_stop_id: stopId,
|
||||||
p_caller_uid: callerUid,
|
p_product_id: productId,
|
||||||
}),
|
p_caller_uid: callerUid,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
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 : ""}`);
|
||||||
|
setRemovingId(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
const data = await res.json();
|
setProducts(products.filter((p) => p.product_id !== productId));
|
||||||
|
setRemovingId(null);
|
||||||
|
|
||||||
if (!res.ok || data.success === false) {
|
logAuditEvent({
|
||||||
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
|
table_name: "product_stops",
|
||||||
const errDetail = data?.details ?? data?.hint ?? data?.code ?? '';
|
record_id: entry.id,
|
||||||
setError(`Failed to remove: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`);
|
action: "DELETE",
|
||||||
setRemoving(null);
|
old_data: { stop_id: stopId, product_id: productId },
|
||||||
return;
|
new_data: null,
|
||||||
|
brand_id: null,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setError("Network error while removing product.");
|
||||||
|
setRemovingId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
setProducts(products.filter((p) => p.product_id !== productId));
|
|
||||||
setRemoving(null);
|
|
||||||
|
|
||||||
logAuditEvent({
|
|
||||||
table_name: "product_stops",
|
|
||||||
record_id: entry.id,
|
|
||||||
action: "DELETE",
|
|
||||||
old_data: { stop_id: stopId, product_id: productId },
|
|
||||||
new_data: null,
|
|
||||||
brand_id: 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="ha-picker">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
|
<div
|
||||||
{error}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{products.length > 0 ? (
|
{/* Header: editorial title + search */}
|
||||||
<div className="space-y-2">
|
<div className="ha-picker-header">
|
||||||
<p className="text-sm font-medium text-zinc-300">
|
<div className="ha-picker-title">
|
||||||
Assigned Products ({products.length})
|
<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
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter("all")}
|
||||||
|
className={`ha-picker-chip ${filter === "all" ? "ha-picker-chip--active" : ""}`}
|
||||||
|
>
|
||||||
|
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>
|
</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>
|
|
||||||
</div>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{removing === ap.product_id ? "..." : "Remove"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</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 && (
|
{/* Summary footer when assigned items exist */}
|
||||||
<form onSubmit={handleAssign} className="flex gap-3">
|
{hasAssigned && (
|
||||||
<select
|
<div className="ha-picker-summary">
|
||||||
value={selected}
|
<span className="ha-picker-summary-left">
|
||||||
onChange={(e) => setSelected(e.target.value)}
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
>
|
</svg>
|
||||||
<option value="">Select a product...</option>
|
<span>
|
||||||
{availableProducts.map((p) => (
|
<span className="ha-picker-summary-count">{products.length}</span>{" "}
|
||||||
<option key={p.id} value={p.id}>
|
{products.length === 1 ? "product is" : "products are"} live at this stop
|
||||||
{p.name} ({p.type})
|
</span>
|
||||||
</option>
|
</span>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={!selected || assigning}
|
onClick={clearAll}
|
||||||
className="rounded-xl bg-slate-900 px-5 py-3 font-medium text-white disabled:opacity-50"
|
disabled={!!removingId}
|
||||||
|
className="ha-picker-summary-clear"
|
||||||
>
|
>
|
||||||
{assigning ? "..." : "Assign"}
|
Remove all
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ type Stop = {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
stops: Stop[];
|
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"> = {
|
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",
|
draft: "draft",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function StopTableClient({ stops }: Props) {
|
export default function StopTableClient({ stops, hideInternalFilterBar = false }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { success: showSuccess, error: showError } = useToast();
|
const { success: showSuccess, error: showError } = useToast();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
@@ -162,6 +167,7 @@ export default function StopTableClient({ stops }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Filter bar */}
|
{/* Filter bar */}
|
||||||
|
{!hideInternalFilterBar && (
|
||||||
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3 border-b border-[var(--admin-border)]">
|
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3 border-b border-[var(--admin-border)]">
|
||||||
<AdminSearchInput
|
<AdminSearchInput
|
||||||
placeholder="Search by city or venue…"
|
placeholder="Search by city or venue…"
|
||||||
@@ -246,6 +252,7 @@ export default function StopTableClient({ stops }: Props) {
|
|||||||
Add Stop
|
Add Stop
|
||||||
</AdminButton>
|
</AdminButton>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bulk actions bar */}
|
{/* Bulk actions bar */}
|
||||||
{selectedStops.size > 0 && (
|
{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