Files
route-commerce/src/app/admin/stops/[id]/page.tsx
T
Tyler 685a1269a4 feat(admin): apply design system to Stops pages
- 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.
2026-06-17 00:31:33 -06:00

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>
);
}