718 lines
No EOL
27 KiB
TypeScript
718 lines
No EOL
27 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import {
|
|
Building2,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
History,
|
|
Info,
|
|
Loader2,
|
|
Lock,
|
|
ShoppingCart,
|
|
Truck,
|
|
X,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { SupplierSearch } from "@/components/ui/supplier-search";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { toast } from "sonner";
|
|
import getPocketBase from "@/lib/pocketbase";
|
|
import { cn } from "@/lib/utils";
|
|
import { supplierColor } from "@/lib/supplier-color";
|
|
import type { Supplier } from "@/lib/types";
|
|
import { MarginApprovalDialog } from "@/components/margin-approval-dialog";
|
|
import { OrderItemContextPanel, type ApplyPayload } from "@/components/order-item-context-panel";
|
|
|
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
|
const MARKUP_APPROVAL_THRESHOLD = 22;
|
|
|
|
interface Props {
|
|
item: OrderSprintItem | null;
|
|
suppliers: Supplier[];
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onProcessed: () => void;
|
|
}
|
|
|
|
type OrderSprintItem = {
|
|
id: string;
|
|
description?: string;
|
|
quantity?: number | null;
|
|
order_qty?: number | null;
|
|
supplier?: string;
|
|
cost_price?: number;
|
|
selling_price?: number;
|
|
markup?: number;
|
|
sku?: string;
|
|
notes?: string;
|
|
category?: string;
|
|
po_description?: string;
|
|
item_status?: string;
|
|
ai_item_insights?: Record<string, unknown> | null;
|
|
};
|
|
|
|
export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProcessed }: Props) {
|
|
// Selling comes from the order and is fixed — never edited here. We
|
|
// capture it on mount via `item.selling_price` so the operator sees
|
|
// the price the customer was quoted.
|
|
const [supplierId, setSupplierId] = useState(item?.supplier || "");
|
|
const [cost, setCost] = useState(item?.cost_price || 0);
|
|
// qty that goes onto the PO. Defaults to order_qty (the customer's
|
|
// requested qty from the original quote); the operator can adjust
|
|
// for partial fills.
|
|
const [qty, setQty] = useState(item?.order_qty ?? item?.quantity ?? 1);
|
|
const [poDescription, setPoDescription] = useState(item?.po_description || item?.description || "");
|
|
const [notes, setNotes] = useState(item?.notes || "");
|
|
const [processing, setProcessing] = useState(false);
|
|
const [approvalPromptOpen, setApprovalPromptOpen] = useState(false);
|
|
const [approvalError, setApprovalError] = useState<string | null>(null);
|
|
const [suppliersExpanded, setSuppliersExpanded] = useState(true);
|
|
const [orderHistory, setOrderHistory] = useState<OrderHistoryItem[]>([]);
|
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
|
const [availableSuppliers, setAvailableSuppliers] = useState<AvailableSupplier[]>([]);
|
|
const [loadingSuppliers, setLoadingSuppliers] = useState(false);
|
|
|
|
const selling = item?.selling_price ?? 0;
|
|
const liveMarkup =
|
|
cost > 0 && selling > 0
|
|
? round2(((selling - cost) / cost) * 100)
|
|
: 0;
|
|
|
|
// Load order history and available suppliers when modal opens
|
|
useEffect(() => {
|
|
if (!open || !item) return;
|
|
let cancelled = false;
|
|
|
|
(async () => {
|
|
setLoadingHistory(true);
|
|
setLoadingSuppliers(true);
|
|
|
|
try {
|
|
const pb = getPocketBase();
|
|
const desc = item.description?.toLowerCase() || "";
|
|
const sku = item.sku || "";
|
|
const filter = sku ? `sku ~ "${sku}" || description ~ "${desc}"` : `description ~ "${desc}"`;
|
|
|
|
const historyRes = await pb.collection("order_items").getList(1, 10, {
|
|
filter: `${filter} && item_status != "pending" && selling_price > 0`,
|
|
sort: "-updated",
|
|
fields: "id,description,sku,supplier,cost_price,selling_price,markup,order,updated",
|
|
expand: "order",
|
|
});
|
|
|
|
if (!cancelled) {
|
|
setOrderHistory(
|
|
historyRes.items.map((i) => {
|
|
// Resolve the supplier id to a friendly name from the
|
|
// suppliers list we already have on hand — avoids an
|
|
// extra PB expand round-trip per history row.
|
|
const supplierMatch = suppliers.find((s) => s.id === i.supplier);
|
|
return {
|
|
id: i.id,
|
|
description: i.description,
|
|
sku: i.sku,
|
|
supplier: i.supplier,
|
|
supplierName: supplierMatch?.suppliername || undefined,
|
|
costPrice: i.cost_price,
|
|
sellingPrice: i.selling_price,
|
|
markup: i.markup,
|
|
orderNumber: (i.expand as { order?: { order_number?: string } })?.order?.order_number || "—",
|
|
date: i.updated,
|
|
};
|
|
})
|
|
);
|
|
}
|
|
} catch {
|
|
if (!cancelled) setOrderHistory([]);
|
|
} finally {
|
|
if (!cancelled) setLoadingHistory(false);
|
|
}
|
|
|
|
// Load available suppliers from price list
|
|
try {
|
|
const pb = getPocketBase();
|
|
const desc = item.description?.toLowerCase() || "";
|
|
|
|
const priceRes = await pb.collection("price_list").getList(1, 50, {
|
|
filter: `description ~ "${desc}" || brand ~ "${desc}"`,
|
|
fields: "id,description,supplier,last_cost,brand",
|
|
});
|
|
|
|
if (!cancelled) {
|
|
const supplierMap = new Map<string, AvailableSupplier>();
|
|
for (const p of priceRes.items) {
|
|
const sId = p.supplier as string;
|
|
if (!sId || supplierMap.has(sId)) continue;
|
|
const supplier = suppliers.find((s) => s.id === sId);
|
|
if (supplier) {
|
|
supplierMap.set(sId, {
|
|
id: sId,
|
|
name: supplier.suppliername || "—",
|
|
lastCost: p.last_cost as number,
|
|
brand: p.brand as string,
|
|
timesBought: 1,
|
|
});
|
|
}
|
|
}
|
|
setAvailableSuppliers(Array.from(supplierMap.values()));
|
|
}
|
|
} catch {
|
|
if (!cancelled) setAvailableSuppliers([]);
|
|
} finally {
|
|
if (!cancelled) setLoadingSuppliers(false);
|
|
}
|
|
})();
|
|
|
|
return () => { cancelled = true; };
|
|
}, [open, item, suppliers]);
|
|
|
|
// Apply supplier from history
|
|
const applySupplier = (s: AvailableSupplier) => {
|
|
setSupplierId(s.id);
|
|
if (s.lastCost) {
|
|
setCost(s.lastCost);
|
|
}
|
|
toast.success(`Applied supplier: ${s.name}`);
|
|
};
|
|
|
|
// Apply from order history — copy cost + selling as-is (selling is
|
|
// already fixed on the order, so we keep the historical selling;
|
|
// we only refresh cost if the item doesn't have one yet).
|
|
const applyFromHistory = (h: OrderHistoryItem) => {
|
|
const supplier = suppliers.find((s) => s.id === h.supplier);
|
|
if (supplier) setSupplierId(supplier.id);
|
|
if (h.costPrice) setCost(h.costPrice);
|
|
toast.success(`Applied from order ${h.orderNumber}`);
|
|
};
|
|
|
|
// Apply a row from the context panel. Sells only the cost (+ supplier
|
|
// when the row carries one). Selling stays locked to the order.
|
|
const applyContext = (p: ApplyPayload) => {
|
|
if (p.cost_price > 0) setCost(p.cost_price);
|
|
if (p.supplier_id) setSupplierId(p.supplier_id);
|
|
toast.success(`Applied cost from ${p.source_label}: R${p.cost_price.toFixed(2)}`);
|
|
};
|
|
|
|
// Process
|
|
const handleProcess = async () => {
|
|
if (!item) return;
|
|
if (!supplierId) {
|
|
toast.error("Pick a supplier before processing");
|
|
return;
|
|
}
|
|
|
|
const trimmedPoDescription = poDescription.trim();
|
|
const computedMarkup =
|
|
cost > 0 && selling > 0
|
|
? round2(((selling - cost) / cost) * 100)
|
|
: 0;
|
|
const storedMarkup = Math.max(0, computedMarkup);
|
|
// A below-threshold pending/rejected item must first be saved under
|
|
// the blocking status and sent to the manager. A processed item at
|
|
// the same price is the signal that n8n already approved it.
|
|
if (
|
|
cost > 0 &&
|
|
selling > 0 &&
|
|
computedMarkup < MARKUP_APPROVAL_THRESHOLD &&
|
|
item.item_status !== "processed"
|
|
) {
|
|
setApprovalError(null);
|
|
setApprovalPromptOpen(true);
|
|
return;
|
|
}
|
|
|
|
setProcessing(true);
|
|
try {
|
|
const pb = getPocketBase();
|
|
|
|
// Same readiness gate as /orders/[id]/process saveItem:
|
|
// pending → processed only when supplier + cost > 0 +
|
|
// po_description are all set. Items already past `processed`
|
|
// (stock_booked, po_generated, ready_delivery, etc.) are not
|
|
// downgraded. This is what makes the item appear on the
|
|
// buyer-tasks Needs PO tab.
|
|
const isReadyForBuyer =
|
|
supplierId.trim() !== "" &&
|
|
cost > 0 &&
|
|
trimmedPoDescription !== "";
|
|
const currentStatus = item.item_status ?? "pending";
|
|
const nextStatus =
|
|
(currentStatus === "pending" || currentStatus === "approval_rejected") &&
|
|
isReadyForBuyer
|
|
? "processed"
|
|
: currentStatus;
|
|
|
|
await pb.collection("order_items").update(item.id, {
|
|
supplier: supplierId,
|
|
cost_price: round2(cost),
|
|
selling_price: round2(selling),
|
|
markup: storedMarkup,
|
|
markup_percent: storedMarkup,
|
|
order_qty: qty,
|
|
po_description: trimmedPoDescription,
|
|
notes,
|
|
item_status: nextStatus,
|
|
});
|
|
toast.success("Item processed!");
|
|
onProcessed();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Processing failed");
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
const submitMarginApproval = async () => {
|
|
if (!item) return;
|
|
setProcessing(true);
|
|
setApprovalError(null);
|
|
try {
|
|
const response = await fetch(
|
|
`/api/v1/order-items/${item.id}/request-margin-approval`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
source: "order_sprint",
|
|
draft: {
|
|
description: item.description || "",
|
|
supplier: supplierId,
|
|
cost_price: round2(cost),
|
|
selling_price: round2(selling),
|
|
order_qty: qty,
|
|
po_description: poDescription.trim(),
|
|
notes,
|
|
},
|
|
}),
|
|
},
|
|
);
|
|
const result = (await response.json().catch(() => ({}))) as {
|
|
error?: string;
|
|
webhookFired?: boolean;
|
|
webhookError?: string | null;
|
|
};
|
|
if (!response.ok) {
|
|
throw new Error(result.error || `Approval request failed (${response.status})`);
|
|
}
|
|
|
|
if (result.webhookFired === false) {
|
|
toast.warning("Draft saved and blocked, but the approval webhook failed", {
|
|
description: result.webhookError || "Check the n8n workflow and retry.",
|
|
duration: 12000,
|
|
});
|
|
} else {
|
|
toast.success("Approval requested", {
|
|
description: "The item is blocked until a manager approves it.",
|
|
});
|
|
}
|
|
setApprovalPromptOpen(false);
|
|
onProcessed();
|
|
} catch (err) {
|
|
setApprovalError(
|
|
err instanceof Error ? err.message : "Could not request approval",
|
|
);
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
// Esc closes
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [open, onClose]);
|
|
|
|
if (!open || !item) return null;
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 p-0 sm:p-4 backdrop-blur-sm"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
>
|
|
<Card className="w-full max-w-4xl h-[100dvh] sm:h-auto sm:max-h-[90vh] flex flex-col rounded-none sm:rounded-lg shadow-2xl border-0 sm:border-2 border-warn/40 border-t-warn/40 shadow-warn/15">
|
|
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0 border-b bg-gradient-to-b from-muted/40 to-transparent">
|
|
<div className="min-w-0 flex-1">
|
|
<CardTitle className="text-sm flex items-center gap-2">
|
|
<ShoppingCart className="h-4 w-4 text-primary" />
|
|
Process Order Item
|
|
</CardTitle>
|
|
<p className="mt-1 text-sm font-medium">{item.description || "(no description)"}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</CardHeader>
|
|
|
|
<CardContent className="flex-1 overflow-y-auto space-y-5 p-4 sm:p-5">
|
|
{/* Context — deterministic history surfaced from PB. Replaces the
|
|
AI Insights section. Selling stays locked; Apply copies cost. */}
|
|
<OrderItemContextPanel
|
|
itemId={item.id}
|
|
onApply={applyContext}
|
|
/>
|
|
|
|
{/* Order History */}
|
|
<section className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<History className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
Recent Orders ({orderHistory.length})
|
|
</span>
|
|
</div>
|
|
{loadingHistory && (
|
|
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
{orderHistory.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{orderHistory.slice(0, 5).map((h) => (
|
|
<div
|
|
key={h.id}
|
|
className="flex items-center gap-3 p-2.5 rounded-lg border border-border/60 bg-muted/20 hover:bg-muted/40 transition-colors"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs font-medium truncate">{h.description}</div>
|
|
<div className="text-[10px] text-muted-foreground flex items-center gap-2 flex-wrap">
|
|
{h.supplierName && (
|
|
<>
|
|
<span
|
|
className="font-medium"
|
|
style={{ color: supplierColor(h.supplier) }}
|
|
>
|
|
{h.supplierName}
|
|
</span>
|
|
<span aria-hidden="true">·</span>
|
|
</>
|
|
)}
|
|
<span className="font-mono">{h.orderNumber}</span>
|
|
<span aria-hidden="true">·</span>
|
|
<span>R{h.costPrice?.toFixed(2) || "—"}</span>
|
|
<span aria-hidden="true">·</span>
|
|
<span>R{h.sellingPrice?.toFixed(2) || "—"}</span>
|
|
{h.markup != null && (
|
|
<>
|
|
<span aria-hidden="true">·</span>
|
|
<span>{h.markup}%</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="xs"
|
|
variant="outline"
|
|
onClick={() => applyFromHistory(h)}
|
|
className="border-border/60"
|
|
>
|
|
Apply
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
!loadingHistory && (
|
|
<p className="text-xs text-muted-foreground italic">
|
|
No order history found.
|
|
</p>
|
|
)
|
|
)}
|
|
</section>
|
|
|
|
{/* Available Suppliers */}
|
|
<section className="space-y-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setSuppliersExpanded(v => !v)}
|
|
aria-expanded={suppliersExpanded}
|
|
className="flex items-center gap-2 w-full text-left"
|
|
>
|
|
{suppliersExpanded ? (
|
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
<Truck className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
Available Suppliers ({availableSuppliers.length})
|
|
</span>
|
|
</button>
|
|
{suppliersExpanded && (
|
|
<div className="pl-6">
|
|
{loadingSuppliers ? (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
Searching…
|
|
</div>
|
|
) : availableSuppliers.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{availableSuppliers.map((s) => (
|
|
<div
|
|
key={s.id}
|
|
className="flex items-center gap-3 p-2.5 rounded-lg border border-border/60 hover:bg-muted/30 hover:border-primary/30 transition-colors"
|
|
>
|
|
<Building2 className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs font-medium">{s.name}</div>
|
|
{s.lastCost && (
|
|
<div className="text-[10px] text-muted-foreground">
|
|
Last cost: R{s.lastCost.toFixed(2)}
|
|
{s.brand && ` · ${s.brand}`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Button
|
|
size="xs"
|
|
variant="outline"
|
|
onClick={() => applySupplier(s)}
|
|
className="border-border/60"
|
|
>
|
|
Use
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground italic">
|
|
No suppliers found in price list.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Pricing — qty + cost + selling (read-only) + markup
|
|
(derived). Selling is locked because it comes from the
|
|
order; markup is derived from selling/cost so the
|
|
operator sees the margin they're pricing at. */}
|
|
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="order-sprint-qty" className="text-xs">
|
|
PO Qty
|
|
</Label>
|
|
<Input
|
|
id="order-sprint-qty"
|
|
type="number"
|
|
min={1}
|
|
value={qty || ""}
|
|
onChange={(e) => setQty(Math.max(1, Number(e.target.value) || 1))}
|
|
className="h-9 tabular-nums"
|
|
placeholder="1"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
From order: {item.order_qty ?? item.quantity ?? 1}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="order-sprint-cost" className="text-xs">
|
|
Cost (ZAR)
|
|
</Label>
|
|
<Input
|
|
id="order-sprint-cost"
|
|
type="number"
|
|
value={cost || ""}
|
|
onChange={(e) => setCost(Number(e.target.value) || 0)}
|
|
className="h-9 tabular-nums"
|
|
placeholder="0.00"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs flex items-center gap-1">
|
|
<Lock className="h-3 w-3 text-muted-foreground" />
|
|
Selling (ZAR)
|
|
</Label>
|
|
<div
|
|
className="flex h-9 items-center rounded-md border border-dashed border-border/60 bg-muted/30 px-3 text-sm tabular-nums text-foreground"
|
|
aria-label="Selling price (from order, read-only)"
|
|
title="Selling comes from the order and is not editable here"
|
|
>
|
|
{selling > 0 ? `R${selling.toFixed(2)}` : <span className="text-muted-foreground italic">not set</span>}
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
|
|
<Info className="h-2.5 w-2.5" />
|
|
From order · not editable
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs flex items-center gap-1">
|
|
<Lock className="h-3 w-3 text-muted-foreground" />
|
|
Markup
|
|
</Label>
|
|
<div
|
|
className="flex h-9 items-center rounded-md border border-dashed border-border/60 bg-muted/30 px-3 text-sm tabular-nums"
|
|
aria-label="Markup (derived from cost and selling, read-only)"
|
|
title="Derived from selling vs cost"
|
|
>
|
|
{cost > 0 && selling > 0 ? (
|
|
<span
|
|
className={cn(
|
|
"font-medium",
|
|
selling >= cost ? "text-emerald-700 dark:text-emerald-400" : "text-destructive",
|
|
)}
|
|
>
|
|
{liveMarkup.toFixed(1)}%
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground italic">—</span>
|
|
)}
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
|
|
<Info className="h-2.5 w-2.5" />
|
|
Derived · not editable
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Supplier */}
|
|
<section className="space-y-1.5">
|
|
<Label className="text-xs">Supplier</Label>
|
|
<SupplierSearch
|
|
value={supplierId}
|
|
onValueChange={setSupplierId}
|
|
suppliers={suppliers}
|
|
placeholder="Pick the supplier…"
|
|
ariaLabel="Supplier"
|
|
/>
|
|
</section>
|
|
|
|
{/* PO description — what the supplier sees on the PO. Defaults
|
|
to the order description so the buyer-tasks pending →
|
|
processed transition fires for the common no-op case.
|
|
Mirrors the canonical /orders/[id]/process gate. The
|
|
order's original item description is shown read-only
|
|
above the textarea as a reference, so the operator can
|
|
see what the customer asked for vs what they're sending
|
|
to the supplier. */}
|
|
<section className="space-y-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">PO description</Label>
|
|
{poDescription.trim() !== (item.description || "").trim() && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setPoDescription(item.description || "")}
|
|
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
↺ reset to order description
|
|
</button>
|
|
)}
|
|
</div>
|
|
{item.description && (
|
|
<div
|
|
className="rounded-md border border-dashed border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground"
|
|
aria-label="Order description (read-only)"
|
|
>
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
|
Order description
|
|
</span>
|
|
<div className="mt-0.5 text-foreground/80 italic">
|
|
{item.description}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<Textarea
|
|
value={poDescription}
|
|
onChange={(e) => setPoDescription(e.target.value)}
|
|
rows={2}
|
|
className="text-sm"
|
|
placeholder="Description the supplier sees on the PO"
|
|
/>
|
|
</section>
|
|
|
|
{/* Notes */}
|
|
<section className="space-y-1.5">
|
|
<Label className="text-xs">Notes</Label>
|
|
<Textarea
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
rows={2}
|
|
className="text-sm"
|
|
placeholder="Internal notes…"
|
|
/>
|
|
</section>
|
|
</CardContent>
|
|
|
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between border-t bg-muted/20 px-4 py-3 sm:px-5">
|
|
<p className="text-[11px] text-muted-foreground">
|
|
{item.item_status === "pending"
|
|
? "Save marks this item processed and surfaces it on the buyer-tasks Needs PO tab."
|
|
: `Current status: ${item.item_status ?? "pending"}. Save keeps it there.`}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onClose}
|
|
disabled={processing}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleProcess}
|
|
disabled={processing}
|
|
className="bg-success text-success-foreground hover:bg-success/90 shadow-sm"
|
|
>
|
|
{processing && <Loader2 className="h-3 w-3 mr-1 animate-spin" />}
|
|
<ShoppingCart className="h-3 w-3 mr-1" />
|
|
Process Item
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<MarginApprovalDialog
|
|
open={approvalPromptOpen}
|
|
description={item.description || "Order item"}
|
|
costPrice={round2(cost)}
|
|
sellingPrice={round2(selling)}
|
|
markup={liveMarkup}
|
|
threshold={MARKUP_APPROVAL_THRESHOLD}
|
|
busy={processing}
|
|
error={approvalError}
|
|
onOpenChange={(nextOpen) => {
|
|
setApprovalPromptOpen(nextOpen);
|
|
if (!nextOpen) setApprovalError(null);
|
|
}}
|
|
onConfirm={() => void submitMarginApproval()}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface OrderHistoryItem {
|
|
id: string;
|
|
description: string;
|
|
sku?: string;
|
|
supplier?: string;
|
|
/** Resolved supplier name (from the modal's `suppliers` prop) so
|
|
* the recent-orders list can show "who supplied this last time"
|
|
* without expanding the PB relation. */
|
|
supplierName?: string;
|
|
costPrice?: number;
|
|
sellingPrice?: number;
|
|
markup?: number;
|
|
orderNumber: string;
|
|
date: string;
|
|
}
|
|
|
|
interface AvailableSupplier {
|
|
id: string;
|
|
name: string;
|
|
lastCost?: number;
|
|
brand?: string;
|
|
timesBought?: number;
|
|
} |