238 lines
7.3 KiB
TypeScript
238 lines
7.3 KiB
TypeScript
"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>
|
|
);
|
|
} |