bdcaf0f1da
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
432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo, useEffect } from "react";
|
|
import Image from "next/image";
|
|
import { logAuditEvent } from "@/actions/audit";
|
|
|
|
type Product = {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
price: number;
|
|
image_url?: string | null;
|
|
};
|
|
|
|
type AssignedProduct = {
|
|
id: string;
|
|
product_id: string;
|
|
products: { name: string; type: string; price: number; image_url?: string | null } | null;
|
|
};
|
|
|
|
type StopProductAssignmentProps = {
|
|
stopId: string;
|
|
allProducts: Product[];
|
|
assignedProducts: AssignedProduct[];
|
|
callerUid: string;
|
|
};
|
|
|
|
type Filter = "all" | "available" | "assigned";
|
|
|
|
/* Helpers — monospace price + initial-letter avatar */
|
|
const formatPrice = (n: number) =>
|
|
`$${Number(n ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
|
|
const initialOf = (name: string) => (name?.trim()?.charAt(0) ?? "·").toUpperCase();
|
|
|
|
const TYPE_LABEL: Record<string, string> = {
|
|
pickup: "Pickup",
|
|
wholesale: "Wholesale",
|
|
subscription: "Sub",
|
|
};
|
|
|
|
export default function StopProductAssignment({
|
|
stopId,
|
|
allProducts,
|
|
assignedProducts,
|
|
callerUid,
|
|
}: StopProductAssignmentProps) {
|
|
const [products, setProducts] = useState(assignedProducts);
|
|
const [search, setSearch] = useState("");
|
|
const [filter, setFilter] = useState<Filter>("all");
|
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
|
const [removingId, setRemovingId] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const assignedIds = useMemo(
|
|
() => new Set(products.map((p) => p.product_id)),
|
|
[products]
|
|
);
|
|
|
|
// Counters for the filter chips
|
|
const counts = useMemo(() => {
|
|
const assignedCount = products.length;
|
|
const availableCount = allProducts.filter((p) => !assignedIds.has(p.id)).length;
|
|
return {
|
|
all: allProducts.length,
|
|
available: availableCount,
|
|
assigned: assignedCount,
|
|
};
|
|
}, [allProducts, products, assignedIds]);
|
|
|
|
// 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);
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
p_stop_id: stopId,
|
|
p_product_id: 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 assign: ${errMsg}${errDetail ? " — " + errDetail : ""}`);
|
|
setPendingId(null);
|
|
return;
|
|
}
|
|
|
|
// Optimistic: re-fetch to keep assigned list in sync with server
|
|
const refreshRes = await fetch(
|
|
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_stop_products`,
|
|
{
|
|
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 ?? []);
|
|
setPendingId(null);
|
|
|
|
logAuditEvent({
|
|
table_name: "product_stops",
|
|
record_id: data.id,
|
|
action: "INSERT",
|
|
old_data: null,
|
|
new_data: { stop_id: stopId, product_id: productId },
|
|
brand_id: null,
|
|
});
|
|
} catch {
|
|
setError("Network error while assigning product.");
|
|
setPendingId(null);
|
|
}
|
|
}
|
|
|
|
async function remove(productId: string) {
|
|
if (removingId) return;
|
|
const entry = products.find((p) => p.product_id === productId);
|
|
if (!entry) return;
|
|
|
|
setRemovingId(productId);
|
|
setError(null);
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
p_stop_id: stopId,
|
|
p_product_id: 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;
|
|
}
|
|
|
|
setProducts(products.filter((p) => p.product_id !== productId));
|
|
setRemovingId(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,
|
|
});
|
|
} catch {
|
|
setError("Network error while removing product.");
|
|
setRemovingId(null);
|
|
}
|
|
}
|
|
|
|
function toggle(productId: string) {
|
|
if (assignedIds.has(productId)) {
|
|
remove(productId);
|
|
} else {
|
|
assign(productId);
|
|
}
|
|
}
|
|
|
|
function clearAll() {
|
|
if (!products.length) return;
|
|
// Remove sequentially to be safe
|
|
(async () => {
|
|
for (const p of [...products]) {
|
|
await remove(p.product_id);
|
|
}
|
|
})();
|
|
}
|
|
|
|
// Keyboard: pressing / focuses search
|
|
useEffect(() => {
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === "/" && document.activeElement?.tagName !== "INPUT") {
|
|
e.preventDefault();
|
|
const el = document.getElementById("stop-product-search") as HTMLInputElement | null;
|
|
el?.focus();
|
|
}
|
|
}
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, []);
|
|
|
|
const hasAssigned = products.length > 0;
|
|
const hasProducts = allProducts.length > 0;
|
|
|
|
return (
|
|
<div className="ha-picker">
|
|
{error && (
|
|
<div
|
|
role="alert"
|
|
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
|
|
style={{
|
|
background: "rgba(220, 38, 38, 0.06)",
|
|
border: "1px solid rgba(220, 38, 38, 0.15)",
|
|
}}
|
|
>
|
|
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
|
|
</svg>
|
|
<span className="font-mono text-[11px]">{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Header: editorial title + search */}
|
|
<div className="ha-picker-header">
|
|
<div className="ha-picker-title">
|
|
<span className="ha-picker-title-num font-display">
|
|
{String(products.length).padStart(2, "0")}
|
|
</span>
|
|
<span className="ha-picker-title-label">
|
|
{products.length === 1 ? "Product on this stop" : "Products on this stop"}
|
|
</span>
|
|
</div>
|
|
<div className="ha-picker-search">
|
|
<span className="ha-picker-search-icon" aria-hidden="true">
|
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
|
<circle cx="11" cy="11" r="7" />
|
|
<path d="m21 21-4.3-4.3" strokeLinecap="round" />
|
|
</svg>
|
|
</span>
|
|
<input
|
|
id="stop-product-search"
|
|
type="search"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Search products…"
|
|
className="ha-picker-search-input"
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter chips */}
|
|
<div className="ha-picker-filterbar">
|
|
<button
|
|
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>
|
|
</div>
|
|
) : (
|
|
<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>
|
|
)}
|
|
|
|
{/* Summary footer when assigned items exist */}
|
|
{hasAssigned && (
|
|
<div className="ha-picker-summary">
|
|
<span className="ha-picker-summary-left">
|
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
|
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
<span>
|
|
<span className="ha-picker-summary-count">{products.length}</span>{" "}
|
|
{products.length === 1 ? "product is" : "products are"} live at this stop
|
|
</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={clearAll}
|
|
disabled={!!removingId}
|
|
className="ha-picker-summary-clear"
|
|
>
|
|
Remove all
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|