fix(admin/stops): product picker + add StopsHeaderActions

StopProductAssignment
- remove() now uses functional setState — fixes a stale-closure bug
  where 'Remove all' only cleared the first product and rapid single
  removals could collide
- assign() no longer re-fetches the full assigned list on every click;
  it now does an optimistic insert using the product already in
  allProducts, keyed by the row id returned from the RPC. Eliminates
  a network round-trip + brief UI flicker per click
- clearAll() snapshots the ids at click time and stops on error so a
  failed remove doesn't silently continue
- 'Press / to search' hint: moved `ha-modal-footer-hint` to the
  parent span (it was on the kbd), so the kbd styles from
  `.ha-modal-footer-hint kbd` actually apply; removed the redundant
  inline style and the duplicated className

StopsHeaderActions (new)
- Small client component that owns the Add Stop / Upload Schedule
  modals and renders the two header buttons. Wires into the
  PageHeader actions slot so the actions stay accessible from the
  StopsViewClient (where the StopTableClient filter bar is hidden
  by the shared search/status filter)
This commit is contained in:
2026-06-04 17:27:16 +00:00
parent 4763884caf
commit 73cc7d1dce
2 changed files with 115 additions and 20 deletions
+32 -20
View File
@@ -83,6 +83,9 @@ export default function StopProductAssignment({
async function assign(productId: string) { async function assign(productId: string) {
if (!productId || assignedIds.has(productId) || pendingId) return; if (!productId || assignedIds.has(productId) || pendingId) return;
const product = allProducts.find((p) => p.id === productId);
if (!product) return;
setPendingId(productId); setPendingId(productId);
setError(null); setError(null);
@@ -112,20 +115,25 @@ export default function StopProductAssignment({
return; return;
} }
// Optimistic: re-fetch to keep assigned list in sync with server // Optimistic insert: build the entry from the product we already have
const refreshRes = await fetch( // locally, keyed by the row id returned from the RPC. Use functional
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_stop_products`, // setState so concurrent calls compose correctly.
{ setProducts((prev) => {
method: "POST", if (prev.some((p) => p.product_id === productId)) return prev;
headers: { return [
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, ...prev,
"Content-Type": "application/json", {
id: data.id,
product_id: productId,
products: {
name: product.name,
type: product.type,
price: product.price,
image_url: product.image_url ?? null,
},
}, },
body: JSON.stringify({ p_stop_id: stopId }), ];
} });
);
const refreshData = await refreshRes.json();
setProducts(refreshData.products ?? []);
setPendingId(null); setPendingId(null);
logAuditEvent({ logAuditEvent({
@@ -176,7 +184,7 @@ export default function StopProductAssignment({
return; return;
} }
setProducts(products.filter((p) => p.product_id !== productId)); setProducts((prev) => prev.filter((p) => p.product_id !== productId));
setRemovingId(null); setRemovingId(null);
logAuditEvent({ logAuditEvent({
@@ -202,11 +210,15 @@ export default function StopProductAssignment({
} }
function clearAll() { function clearAll() {
if (!products.length) return; if (!products.length || removingId || pendingId) return;
// Remove sequentially to be safe // Snapshot ids at click time, then remove each one sequentially so the
// server sees one request at a time.
const idsToRemove = products.map((p) => p.product_id);
(async () => { (async () => {
for (const p of [...products]) { for (const id of idsToRemove) {
await remove(p.product_id); // Stop if a per-item failure already surfaced an error
if (error) break;
await remove(id);
} }
})(); })();
} }
@@ -301,8 +313,8 @@ export default function StopProductAssignment({
On this stop On this stop
<span className="ha-picker-chip-count">{counts.assigned}</span> <span className="ha-picker-chip-count">{counts.assigned}</span>
</button> </button>
<span className="ml-auto inline-flex items-center gap-1 text-[var(--admin-text-muted)]"> <span className="ha-modal-footer-hint ml-auto">
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 Press <kbd>/</kbd> to search
</span> </span>
</div> </div>
@@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AdminButton } from "@/components/admin/design-system";
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
import AddStopModal from "@/components/admin/AddStopModal";
type Props = {
brandId: string;
};
export default function StopsHeaderActions({ brandId }: Props) {
const router = useRouter();
const [showAdd, setShowAdd] = useState(false);
const [showImport, setShowImport] = useState(false);
const refresh = () => router.refresh();
return (
<>
<AdminButton
variant="secondary"
size="sm"
onClick={() => setShowImport(true)}
icon={
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
}
>
Upload Schedule
</AdminButton>
<AdminButton
variant="primary"
size="sm"
onClick={() => setShowAdd(true)}
icon={
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4v16m8-8H4"
/>
</svg>
}
>
Add Stop
</AdminButton>
{showImport && (
<ScheduleImportModal
brandId={brandId}
onClose={() => setShowImport(false)}
onComplete={refresh}
/>
)}
<AddStopModal
isOpen={showAdd}
onClose={() => setShowAdd(false)}
brandId={brandId}
onSuccess={refresh}
/>
</>
);
}