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:
@@ -83,6 +83,9 @@ export default function StopProductAssignment({
|
||||
async function assign(productId: string) {
|
||||
if (!productId || assignedIds.has(productId) || pendingId) return;
|
||||
|
||||
const product = allProducts.find((p) => p.id === productId);
|
||||
if (!product) return;
|
||||
|
||||
setPendingId(productId);
|
||||
setError(null);
|
||||
|
||||
@@ -112,20 +115,25 @@ export default function StopProductAssignment({
|
||||
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`,
|
||||
// Optimistic insert: build the entry from the product we already have
|
||||
// locally, keyed by the row id returned from the RPC. Use functional
|
||||
// setState so concurrent calls compose correctly.
|
||||
setProducts((prev) => {
|
||||
if (prev.some((p) => p.product_id === productId)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"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);
|
||||
|
||||
logAuditEvent({
|
||||
@@ -176,7 +184,7 @@ export default function StopProductAssignment({
|
||||
return;
|
||||
}
|
||||
|
||||
setProducts(products.filter((p) => p.product_id !== productId));
|
||||
setProducts((prev) => prev.filter((p) => p.product_id !== productId));
|
||||
setRemovingId(null);
|
||||
|
||||
logAuditEvent({
|
||||
@@ -202,11 +210,15 @@ export default function StopProductAssignment({
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (!products.length) return;
|
||||
// Remove sequentially to be safe
|
||||
if (!products.length || removingId || pendingId) return;
|
||||
// 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 () => {
|
||||
for (const p of [...products]) {
|
||||
await remove(p.product_id);
|
||||
for (const id of idsToRemove) {
|
||||
// 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
|
||||
<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 className="ha-modal-footer-hint ml-auto">
|
||||
Press <kbd>/</kbd> to search
|
||||
</span>
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user