685a1269a4
- Stops list, new, and detail pages use new PageHeader with Operations eyebrow, Stops & Routes title, and editorial subtitle. - StopsCalendarClient: replace hardcoded amber (#f59e0b) and brown (#92400e) draft markers with --admin-warning / --admin-warning-soft tokens. Calendar structure unchanged (drag/click/edit preserved). - Add/Edit/StopDetail modals: replace hardcoded red rgba with --admin-danger-soft and color-mix derived borders. - StopsLocationsTabs: swap hardcoded emerald-* for --admin-primary / --admin-primary-soft tokens. - StopMessagingForm: align with design system (ha-field, ha-eyebrow, tokens). - StopProductAssignment error surface: tokens instead of red rgba. - StopsHeaderActions already tokenized via AdminButton; no change. TS clean. Calendar functionality unchanged.
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
import { pool } from "@/lib/db";
|
|
import StopEditForm from "@/components/admin/StopEditForm";
|
|
import StopProductAssignment from "@/components/admin/StopProductAssignment";
|
|
import MessageCustomersSection from "@/components/admin/MessageCustomersSection";
|
|
import { getAdminUser } from "@/lib/admin-permissions";
|
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
|
import { PageHeader } from "@/components/admin/design-system";
|
|
import { redirect } from "next/navigation";
|
|
import Link from "next/link";
|
|
|
|
const StopIcon = () => (
|
|
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-current" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
|
|
<circle cx="12" cy="10" r="3"/>
|
|
</svg>
|
|
);
|
|
|
|
type StopDetailPageProps = {
|
|
params: Promise<{
|
|
id: string;
|
|
}>;
|
|
};
|
|
|
|
interface Stop {
|
|
id: string;
|
|
brand_id: string;
|
|
city: string;
|
|
state: string;
|
|
address: string | null;
|
|
zip: string | null;
|
|
date: string;
|
|
time: string;
|
|
cutoff_date: string | null;
|
|
cutoff_time: string | null;
|
|
status: string;
|
|
location: string;
|
|
active: boolean;
|
|
brands?: { name: string; slug: string };
|
|
}
|
|
|
|
interface Product {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
price: number;
|
|
image_url?: string | null;
|
|
}
|
|
|
|
interface ProductStop {
|
|
id: string;
|
|
product_id: string;
|
|
products?: Product;
|
|
}
|
|
|
|
export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
|
const { id } = await params;
|
|
|
|
const adminUser = await getAdminUser();
|
|
|
|
if (!adminUser) {
|
|
return <AdminAccessDenied />;
|
|
}
|
|
|
|
if (!adminUser.can_manage_stops) redirect("/admin/pickup");
|
|
|
|
// Fetch stop
|
|
const { rows: stopsRows } = await pool.query<Stop & { brand_name: string; brand_slug: string }>(
|
|
`SELECT s.*, b.name as brand_name, b.slug as brand_slug
|
|
FROM stops s
|
|
LEFT JOIN brands b ON b.id = s.brand_id
|
|
WHERE s.id = $1
|
|
LIMIT 1`,
|
|
[id]
|
|
);
|
|
const stopRow = stopsRows[0];
|
|
|
|
if (!stopRow) {
|
|
return (
|
|
<main className="min-h-screen bg-[var(--admin-bg)]">
|
|
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
|
<div className="mx-auto max-w-4xl">
|
|
<p className="ha-eyebrow mb-2">Operations</p>
|
|
<PageHeader
|
|
title="Stop not found"
|
|
subtitle="This stop may have been removed or you don't have access."
|
|
icon={<StopIcon />}
|
|
/>
|
|
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-5">
|
|
<pre className="rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border-light)] p-4 text-sm text-[var(--admin-text-secondary)] overflow-auto">
|
|
{id}
|
|
</pre>
|
|
<Link
|
|
href="/admin/stops"
|
|
className="ha-btn-ghost mt-4 inline-flex"
|
|
>
|
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
Back to Stops
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
if (adminUser.brand_id && stopRow.brand_id !== adminUser.brand_id) {
|
|
return <AdminAccessDenied />;
|
|
}
|
|
|
|
const stop: Stop = {
|
|
id: stopRow.id,
|
|
brand_id: stopRow.brand_id,
|
|
city: stopRow.city ?? "",
|
|
state: stopRow.state ?? "",
|
|
address: stopRow.address ?? null,
|
|
zip: stopRow.zip ?? null,
|
|
date: stopRow.date ? String(stopRow.date) : "",
|
|
time: stopRow.time ?? "",
|
|
cutoff_date: stopRow.cutoff_date ? String(stopRow.cutoff_date) : null,
|
|
cutoff_time: stopRow.cutoff_date ? String(stopRow.cutoff_date) : null,
|
|
status: stopRow.status ?? "active",
|
|
location: stopRow.location ?? "",
|
|
active: stopRow.status === "active",
|
|
brands: { name: stopRow.brand_name ?? "", slug: stopRow.brand_slug ?? "" },
|
|
};
|
|
|
|
// Fetch all products for this brand
|
|
const { rows: productRows } = await pool.query<{ id: string; name: string; type: string; price: number; image_url: string | null }>(
|
|
`SELECT id, name, type, price, image_url
|
|
FROM products
|
|
WHERE brand_id = $1 AND active = true
|
|
ORDER BY name`,
|
|
[stop.brand_id]
|
|
);
|
|
|
|
// Fetch product-stop assignments
|
|
const { rows: productStopRows } = await pool.query<{ id: string; product_id: string; name: string; type: string; price: number; image_url: string | null }>(
|
|
`SELECT ps.id, ps.product_id, p.name, p.type, p.price, p.image_url
|
|
FROM product_stops ps
|
|
JOIN products p ON p.id = ps.product_id
|
|
WHERE ps.stop_id = $1`,
|
|
[id]
|
|
);
|
|
|
|
// Fetch brands list
|
|
const { rows: brandRows } = await pool.query<{ id: string; name: string; slug: string }>(
|
|
`SELECT id, name, slug FROM brands ORDER BY name`
|
|
);
|
|
|
|
const allProducts = productRows.map((p) => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
type: p.type,
|
|
price: Number(p.price),
|
|
image_url: p.image_url,
|
|
}));
|
|
|
|
const assignedProducts = productStopRows.map((ps) => ({
|
|
id: ps.id,
|
|
product_id: ps.product_id,
|
|
products: {
|
|
name: ps.name,
|
|
type: ps.type,
|
|
price: Number(ps.price),
|
|
image_url: ps.image_url,
|
|
},
|
|
}));
|
|
|
|
const detailSubtitle = stop.brands?.name
|
|
? `${stop.brands.name} · ${stop.location}`
|
|
: stop.location;
|
|
|
|
return (
|
|
<main className="min-h-screen bg-[var(--admin-bg)]">
|
|
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
|
<p className="ha-eyebrow mb-2">Operations</p>
|
|
<PageHeader
|
|
title={`${stop.city}, ${stop.state}`}
|
|
subtitle={detailSubtitle}
|
|
icon={<StopIcon />}
|
|
/>
|
|
</div>
|
|
|
|
<div className="px-4 sm:px-6 md:px-8 pb-8 sm:pb-10">
|
|
<div className="mx-auto max-w-4xl">
|
|
<Link
|
|
href="/admin/stops"
|
|
className="ha-btn-ghost mb-4 inline-flex"
|
|
>
|
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
Back to Stops
|
|
</Link>
|
|
|
|
<div className="rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--admin-text-muted)]">
|
|
{stop.brands?.name}
|
|
</p>
|
|
<h1 className="mt-2 text-4xl font-bold text-[var(--admin-text-primary)]">
|
|
{stop.city}, {stop.state}
|
|
</h1>
|
|
<p className="mt-2 text-lg text-[var(--admin-text-secondary)]">{stop.location}</p>
|
|
</div>
|
|
|
|
<span
|
|
className={`shrink-0 rounded-full px-4 py-2 text-sm font-medium ${
|
|
stop.active
|
|
? "bg-[var(--admin-success-soft)] text-[var(--admin-success)] border border-[var(--admin-success)]/30"
|
|
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)] border border-[var(--admin-border)]"
|
|
}`}
|
|
>
|
|
{stop.active ? "Active" : "Inactive"}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-8 grid grid-cols-2 gap-6">
|
|
<div>
|
|
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Date</p>
|
|
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
|
|
{stop.date}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Time</p>
|
|
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
|
|
{stop.time}
|
|
</p>
|
|
</div>
|
|
{stop.address && (
|
|
<div>
|
|
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Address</p>
|
|
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
|
|
{stop.address}
|
|
{stop.zip ? `, ${stop.zip}` : ""}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{stop.cutoff_date && (
|
|
<div>
|
|
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Cutoff</p>
|
|
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
|
|
{new Date(stop.cutoff_date + "T00:00:00").toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 flex justify-end">
|
|
<a
|
|
href={`/admin/stops/new?duplicate=${stop.id}`}
|
|
className="ha-btn-ghost"
|
|
>
|
|
Duplicate Stop
|
|
</a>
|
|
</div>
|
|
|
|
<div className="mt-6 rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
|
|
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">
|
|
Assigned Products
|
|
</h2>
|
|
<p className="mt-1 text-[var(--admin-text-secondary)]">
|
|
Manage which products are available at this stop.
|
|
</p>
|
|
|
|
<div className="mt-6">
|
|
<StopProductAssignment
|
|
stopId={stop.id}
|
|
allProducts={allProducts}
|
|
assignedProducts={assignedProducts}
|
|
callerUid={adminUser.user_id}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
|
|
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Edit Stop</h2>
|
|
<p className="mt-1 text-[var(--admin-text-secondary)]">
|
|
Update stop details, location, and availability.
|
|
</p>
|
|
|
|
<div className="mt-6">
|
|
<StopEditForm
|
|
stop={{
|
|
id: stop.id,
|
|
city: stop.city,
|
|
state: stop.state,
|
|
date: stop.date,
|
|
time: stop.time,
|
|
location: stop.location,
|
|
slug: stop.brands?.slug ?? "",
|
|
active: stop.active,
|
|
brand_id: stop.brand_id,
|
|
address: stop.address,
|
|
zip: stop.zip,
|
|
cutoff_time: stop.cutoff_time,
|
|
}}
|
|
brands={brandRows ?? []}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Message Customers */}
|
|
<div className="mt-6 rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
|
|
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Message Customers</h2>
|
|
<p className="mt-1 text-[var(--admin-text-secondary)]">
|
|
Send updates to customers with pending pickups at this stop.
|
|
</p>
|
|
|
|
<div className="mt-6">
|
|
<MessageCustomersSection stopId={stop.id} brandId={stop.brand_id} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|