Initial commit - Route Commerce platform
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { logAuditEvent } from "@/actions/audit";
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
price: number;
|
||||
};
|
||||
|
||||
type AssignedProduct = {
|
||||
id: string;
|
||||
product_id: string;
|
||||
products: { name: string; type: string; price: number } | null;
|
||||
};
|
||||
|
||||
type StopProductAssignmentProps = {
|
||||
stopId: string;
|
||||
allProducts: Product[];
|
||||
assignedProducts: AssignedProduct[];
|
||||
callerUid: string;
|
||||
};
|
||||
|
||||
export default function StopProductAssignment({
|
||||
stopId,
|
||||
allProducts,
|
||||
assignedProducts,
|
||||
callerUid,
|
||||
}: StopProductAssignmentProps) {
|
||||
const [products, setProducts] = useState(assignedProducts);
|
||||
const [selected, setSelected] = useState("");
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [removing, setRemoving] = 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));
|
||||
|
||||
async function handleAssign(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!selected) return;
|
||||
|
||||
setAssigning(true);
|
||||
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,
|
||||
}),
|
||||
}
|
||||
);
|
||||
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,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const entry = products.find((p) => p.product_id === productId);
|
||||
if (!entry) return;
|
||||
|
||||
setRemoving(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,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
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 : ''}`);
|
||||
setRemoving(null);
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{products.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-300">
|
||||
Assigned Products ({products.length})
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!selected || assigning}
|
||||
className="rounded-xl bg-slate-900 px-5 py-3 font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{assigning ? "..." : "Assign"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user