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,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { logAuditEvent } from "@/actions/audit";
|
||||
|
||||
type Product = {
|
||||
@@ -8,12 +9,13 @@ type Product = {
|
||||
name: string;
|
||||
type: string;
|
||||
price: number;
|
||||
image_url?: string | null;
|
||||
};
|
||||
|
||||
type AssignedProduct = {
|
||||
id: string;
|
||||
product_id: string;
|
||||
products: { name: string; type: string; price: number } | null;
|
||||
products: { name: string; type: string; price: number; image_url?: string | null } | null;
|
||||
};
|
||||
|
||||
type StopProductAssignmentProps = {
|
||||
@@ -23,6 +25,20 @@ type StopProductAssignmentProps = {
|
||||
callerUid: string;
|
||||
};
|
||||
|
||||
type Filter = "all" | "available" | "assigned";
|
||||
|
||||
/* Helpers — monospace price + initial-letter avatar */
|
||||
const formatPrice = (n: number) =>
|
||||
`$${Number(n ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
|
||||
const initialOf = (name: string) => (name?.trim()?.charAt(0) ?? "·").toUpperCase();
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
pickup: "Pickup",
|
||||
wholesale: "Wholesale",
|
||||
subscription: "Sub",
|
||||
};
|
||||
|
||||
export default function StopProductAssignment({
|
||||
stopId,
|
||||
allProducts,
|
||||
@@ -30,209 +46,386 @@ export default function StopProductAssignment({
|
||||
callerUid,
|
||||
}: StopProductAssignmentProps) {
|
||||
const [products, setProducts] = useState(assignedProducts);
|
||||
const [selected, setSelected] = useState("");
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const assignedIds = new Set(products.map((p) => p.product_id));
|
||||
const availableProducts = allProducts.filter((p) => !assignedIds.has(p.id));
|
||||
const assignedIds = useMemo(
|
||||
() => new Set(products.map((p) => p.product_id)),
|
||||
[products]
|
||||
);
|
||||
|
||||
async function handleAssign(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!selected) return;
|
||||
// Counters for the filter chips
|
||||
const counts = useMemo(() => {
|
||||
const assignedCount = products.length;
|
||||
const availableCount = allProducts.filter((p) => !assignedIds.has(p.id)).length;
|
||||
return {
|
||||
all: allProducts.length,
|
||||
available: availableCount,
|
||||
assigned: assignedCount,
|
||||
};
|
||||
}, [allProducts, products, assignedIds]);
|
||||
|
||||
setAssigning(true);
|
||||
// Apply search + filter
|
||||
const visibleProducts = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return allProducts.filter((p) => {
|
||||
const isAssigned = assignedIds.has(p.id);
|
||||
if (filter === "available" && isAssigned) return false;
|
||||
if (filter === "assigned" && !isAssigned) return false;
|
||||
if (!q) return true;
|
||||
return p.name.toLowerCase().includes(q) || p.type.toLowerCase().includes(q);
|
||||
});
|
||||
}, [allProducts, search, filter, assignedIds]);
|
||||
|
||||
async function assign(productId: string) {
|
||||
if (!productId || assignedIds.has(productId) || pendingId) return;
|
||||
|
||||
setPendingId(productId);
|
||||
setError(null);
|
||||
|
||||
// First run diagnostic to understand the actual state
|
||||
const diagRes = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/debug_stop_product_assignment`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_stop_id: stopId,
|
||||
p_product_id: selected,
|
||||
p_caller_uid: callerUid,
|
||||
}),
|
||||
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;
|
||||
}
|
||||
);
|
||||
const diag = await diagRes.json();
|
||||
|
||||
// Now attempt the actual assignment
|
||||
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: selected,
|
||||
p_caller_uid: callerUid,
|
||||
}),
|
||||
}
|
||||
);
|
||||
// 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);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || data.success === false) {
|
||||
const diagInfo = diag && typeof diag === 'object' && 'reason' in diag
|
||||
? `[${(diag as any).reason}] (admin_found=${(diag as any).admin_found}, role=${(diag as any).admin_role}, admin_brand=${(diag as any).admin_brand_id}, stop_brand=${(diag as any).stop_brand_id}, product_brand=${(diag as any).product_brand_id})`
|
||||
: '';
|
||||
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
|
||||
const errDetail = data?.details ?? data?.hint ?? data?.code ?? '';
|
||||
const fullError = diagInfo
|
||||
? `Failed to assign: ${errMsg} ${diagInfo}${errDetail ? ' | ' + errDetail : ''}`
|
||||
: `Failed to assign: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`;
|
||||
setError(fullError);
|
||||
setAssigning(false);
|
||||
return;
|
||||
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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (!entry) return;
|
||||
|
||||
setRemoving(productId);
|
||||
setRemovingId(productId);
|
||||
setError(null);
|
||||
|
||||
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,
|
||||
}),
|
||||
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;
|
||||
}
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
setProducts(products.filter((p) => p.product_id !== productId));
|
||||
setRemovingId(null);
|
||||
|
||||
if (!res.ok || data.success === false) {
|
||||
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
|
||||
const errDetail = data?.details ?? data?.hint ?? data?.code ?? '';
|
||||
setError(`Failed to remove: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`);
|
||||
setRemoving(null);
|
||||
return;
|
||||
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);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="ha-picker">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
|
||||
{error}
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
|
||||
style={{
|
||||
background: "rgba(220, 38, 38, 0.06)",
|
||||
border: "1px solid rgba(220, 38, 38, 0.15)",
|
||||
}}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="font-mono text-[11px]">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{products.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-300">
|
||||
Assigned Products ({products.length})
|
||||
{/* 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>
|
||||
{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>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">No products assigned yet.</p>
|
||||
<div className="ha-picker-grid">
|
||||
{visibleProducts.map((product) => {
|
||||
const isAssigned = assignedIds.has(product.id);
|
||||
const isBusy = pendingId === product.id || removingId === product.id;
|
||||
return (
|
||||
<button
|
||||
key={product.id}
|
||||
type="button"
|
||||
onClick={() => !isBusy && toggle(product.id)}
|
||||
disabled={isBusy}
|
||||
aria-pressed={isAssigned}
|
||||
className={`ha-product-card ${isAssigned ? "ha-product-card--assigned" : ""} ${
|
||||
isBusy ? "ha-product-card--disabled" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Image / Initial */}
|
||||
<span className="ha-product-card-image">
|
||||
{product.image_url ? (
|
||||
<Image
|
||||
src={product.image_url}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="48px"
|
||||
style={{ objectFit: "cover" }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
initialOf(product.name)
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Body */}
|
||||
<span className="ha-product-card-body">
|
||||
<span className="ha-product-card-name">{product.name}</span>
|
||||
<span className="ha-product-card-meta">
|
||||
<span className="ha-product-card-type">{TYPE_LABEL[product.type] ?? product.type}</span>
|
||||
<span className="ha-product-card-price">{formatPrice(product.price)}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Status icon */}
|
||||
{isBusy ? (
|
||||
<span className="ha-product-card-plus" aria-hidden="true">
|
||||
<svg className="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
) : isAssigned ? (
|
||||
<>
|
||||
<span className="ha-product-card-check" aria-hidden="true">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="ha-product-card-remove" aria-hidden="true">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M6 6l12 12M6 18 18 6" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="ha-product-card-plus" aria-hidden="true">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{availableProducts.length > 0 && (
|
||||
<form onSubmit={handleAssign} className="flex gap-3">
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="">Select a product...</option>
|
||||
{availableProducts.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Summary footer when assigned items exist */}
|
||||
{hasAssigned && (
|
||||
<div className="ha-picker-summary">
|
||||
<span className="ha-picker-summary-left">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>
|
||||
<span className="ha-picker-summary-count">{products.length}</span>{" "}
|
||||
{products.length === 1 ? "product is" : "products are"} live at this stop
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!selected || assigning}
|
||||
className="rounded-xl bg-slate-900 px-5 py-3 font-medium text-white disabled:opacity-50"
|
||||
type="button"
|
||||
onClick={clearAll}
|
||||
disabled={!!removingId}
|
||||
className="ha-picker-summary-clear"
|
||||
>
|
||||
{assigning ? "..." : "Assign"}
|
||||
Remove all
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user