feat(purchase-orders): add edit functionality and improve form validation
- Implement EditPurchaseOrderDialog for modifying existing orders - Enhance form validation with better number handling and error messages - Add subtotal calculation to purchase order forms - Improve PDF generation with logo support and better layout - Update column definitions to include edit action - Add className prop to StockItemSearch for better styling control
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 23 KiB |
BIN
public/icons/icon-192x192.pngsss
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 23 KiB |
BIN
public/icons/icon-512x512.pngssss
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/jeilogo.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
178
src/components/purchase-orders/EditPurchaseOrderDialog.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm, useFieldArray } from "react-hook-form"
|
||||
import * as z from "zod"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import pb from "@/lib/pocketbase"
|
||||
import { PurchaseOrder } from "./columns"
|
||||
import { showError, showSuccess } from "@/utils/toast"
|
||||
|
||||
const rowSchema = z.object({
|
||||
stockItemId: z.string(),
|
||||
itemName: z.string(),
|
||||
quantity: z.coerce.number().int().min(1),
|
||||
unitPriceGBP: z.coerce.number().min(0),
|
||||
})
|
||||
|
||||
const formSchema = z.object({
|
||||
rows: z.array(rowSchema),
|
||||
})
|
||||
|
||||
interface EditPurchaseOrderDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
order: PurchaseOrder
|
||||
refreshData: () => void
|
||||
}
|
||||
|
||||
export function EditPurchaseOrderDialog({ open, onOpenChange, order, refreshData }: EditPurchaseOrderDialogProps) {
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
rows: order.items.map((item, idx) => ({
|
||||
stockItemId: item.stockItemId,
|
||||
itemName: item.itemName,
|
||||
quantity: order.quantities[idx] ?? 1,
|
||||
unitPriceGBP: Number(item.costPriceGBP ?? 0),
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
const { fields } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "rows",
|
||||
})
|
||||
|
||||
const rowsValues = form.watch("rows")
|
||||
const subtotalGBP = React.useMemo(
|
||||
() =>
|
||||
(rowsValues || []).reduce(
|
||||
(sum, r) => sum + (Number(r.quantity || 0) * Number(r.unitPriceGBP || 0)),
|
||||
0
|
||||
),
|
||||
[rowsValues]
|
||||
)
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
try {
|
||||
await pb.collection("purchase_orders").update(order.id, {
|
||||
quantities: values.rows.map(r => r.quantity),
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all(values.rows.map(async (r, idx) => {
|
||||
const existingGBP = Number(order.items[idx].costPriceGBP ?? 0)
|
||||
const newGBP = Number(r.unitPriceGBP || 0)
|
||||
if (Number.isFinite(newGBP) && newGBP >= 0 && newGBP !== existingGBP) {
|
||||
await pb.collection("stock_items").update(r.stockItemId, { cost_price_gbp: newGBP })
|
||||
}
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error("Stock price update error:", e)
|
||||
}
|
||||
|
||||
showSuccess("Purchase order updated")
|
||||
refreshData()
|
||||
onOpenChange(false)
|
||||
} catch (e: any) {
|
||||
console.error("Update order error:", e)
|
||||
showError("Failed to update purchase order")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="mx-4 sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Purchase Order</DialogTitle>
|
||||
<DialogDescription>Order ID: {order.id}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="border p-4 rounded-md">
|
||||
<h3 className="text-lg font-semibold mb-4">Items</h3>
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="grid grid-cols-1 md:grid-cols-[minmax(220px,1fr),110px,140px] gap-4 mb-4 items-start"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`rows.${index}.itemName`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Item</FormLabel>
|
||||
<FormControl>
|
||||
<Input readOnly {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`rows.${index}.quantity`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Qty</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
className="text-right md:w-28"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => field.onChange(e.target.value.replace(/[^0-9]/g, ""))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`rows.${index}.unitPriceGBP`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Unit Price (£)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="^[0-9]*[.,]?[0-9]*$"
|
||||
className="text-right md:w-36"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => field.onChange(e.target.value.replace(",", "."))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-4 border-t pt-4">
|
||||
<div className="flex items-center justify-end gap-6">
|
||||
<div className="text-sm text-gray-600">Items total (GBP)</div>
|
||||
<div className="text-lg font-semibold">£{subtotalGBP.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">Save Changes</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import { jsPDF } from "jspdf"
|
|||
import { ReceiveStockDialog } from "./ReceiveStockDialog"
|
||||
import pb from "@/lib/pocketbase"
|
||||
import { createColumns, PurchaseOrder } from "./columns"
|
||||
import { EditPurchaseOrderDialog } from "./EditPurchaseOrderDialog"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -65,6 +66,7 @@ export function PurchaseOrderDataTable<TData, TValue>({
|
|||
const [receiveDialogOpen, setReceiveDialogOpen] = React.useState(false)
|
||||
const [selectedOrder, setSelectedOrder] = React.useState<PurchaseOrder | null>(null)
|
||||
const [viewDialogOpen, setViewDialogOpen] = React.useState(false)
|
||||
const [editDialogOpen, setEditDialogOpen] = React.useState(false)
|
||||
|
||||
const openReceiveDialog = (order: PurchaseOrder) => {
|
||||
setSelectedOrder(order)
|
||||
|
|
@ -81,12 +83,53 @@ export function PurchaseOrderDataTable<TData, TValue>({
|
|||
setViewDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditDialog = (order: PurchaseOrder) => {
|
||||
setSelectedOrder(order)
|
||||
setEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const closeViewDialog = () => {
|
||||
setSelectedOrder(null)
|
||||
setViewDialogOpen(false)
|
||||
}
|
||||
|
||||
const generatePDF = (order: PurchaseOrder) => {
|
||||
let logoDataUrlCache: string | null = null
|
||||
const getLogoDataUrl = async (): Promise<string | null> => {
|
||||
if (logoDataUrlCache) return logoDataUrlCache
|
||||
try {
|
||||
const tryPaths = ["/brand.png", "/jei-logo.svg", "/icons/icon-512x512.png"]
|
||||
const srcPath = tryPaths[0]
|
||||
const img = new Image()
|
||||
img.crossOrigin = "anonymous"
|
||||
img.src = srcPath
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve
|
||||
img.onerror = reject
|
||||
})
|
||||
const canvas = document.createElement("canvas")
|
||||
const targetW = 80
|
||||
const targetH = 80
|
||||
canvas.width = targetW
|
||||
canvas.height = targetH
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return null
|
||||
ctx.clearRect(0, 0, targetW, targetH)
|
||||
// preserve aspect ratio inside target box
|
||||
const scale = Math.min(targetW / img.width, targetH / img.height)
|
||||
const drawW = img.width * scale
|
||||
const drawH = img.height * scale
|
||||
const dx = (targetW - drawW) / 2
|
||||
const dy = (targetH - drawH) / 2
|
||||
ctx.drawImage(img, dx, dy, drawW, drawH)
|
||||
const dataUrl = canvas.toDataURL("image/png")
|
||||
logoDataUrlCache = dataUrl
|
||||
return dataUrl
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const generatePDF = async (order: PurchaseOrder) => {
|
||||
try {
|
||||
console.log("Generating PDF for order:", order);
|
||||
const doc = new jsPDF();
|
||||
|
|
@ -97,16 +140,34 @@ export function PurchaseOrderDataTable<TData, TValue>({
|
|||
const darkText = "#1a1a1a";
|
||||
const lightGray = "#f5f5f5";
|
||||
const mediumGray = "#666666";
|
||||
const pageHeight = (doc.internal as any).pageSize.getHeight ? (doc.internal as any).pageSize.getHeight() : doc.internal.pageSize.height;
|
||||
const bottomMargin = 20;
|
||||
const itemsTopY = 20;
|
||||
const renderItemsHeader = (startY: number) => {
|
||||
doc.setFillColor(brandBlack);
|
||||
doc.roundedRect(14, startY, 182, 10, 1, 1, 'F');
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text("#", 17, startY + 6);
|
||||
doc.text("Description", 28, startY + 6);
|
||||
doc.text("SKU", 100, startY + 6);
|
||||
doc.text("Qty", 130, startY + 6, { align: "center" });
|
||||
doc.text("Unit Price", 155, startY + 6, { align: "right" });
|
||||
doc.text("Total", 188, startY + 6, { align: "right" });
|
||||
};
|
||||
|
||||
// Create branded logo box
|
||||
doc.setFillColor(brandBlack);
|
||||
doc.roundedRect(14, 10, 20, 20, 3, 3, 'F');
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(16);
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text("JEI", 24, 23, { align: "center" }); // --- Modern Header with black background ---
|
||||
// --- Modern Header with black background ---
|
||||
doc.setFillColor(brandBlack);
|
||||
doc.rect(0, 0, 210, 45, 'F');
|
||||
const logoDataUrl = await getLogoDataUrl()
|
||||
if (logoDataUrl) {
|
||||
doc.addImage(logoDataUrl, "PNG", 14, 9, 26, 26)
|
||||
} else {
|
||||
// Fallback simple box if logo couldn't load
|
||||
doc.setFillColor(brandBlack);
|
||||
doc.roundedRect(14, 9, 26, 26, 3, 3, 'F');
|
||||
}
|
||||
|
||||
doc.setFillColor(accentYellow);
|
||||
doc.rect(0, 45, 210, 2, 'F'); // Accent line
|
||||
|
|
@ -221,20 +282,7 @@ export function PurchaseOrderDataTable<TData, TValue>({
|
|||
|
||||
y += 8;
|
||||
|
||||
// Modern table header
|
||||
doc.setFillColor(brandBlack);
|
||||
doc.roundedRect(14, y, 182, 10, 1, 1, 'F');
|
||||
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text("#", 17, y + 6);
|
||||
doc.text("Description", 28, y + 6);
|
||||
doc.text("SKU", 100, y + 6);
|
||||
doc.text("Qty", 130, y + 6, { align: "center" });
|
||||
doc.text("Unit Price", 155, y + 6, { align: "right" });
|
||||
doc.text("Total", 188, y + 6, { align: "right" });
|
||||
|
||||
renderItemsHeader(y);
|
||||
y += 10;
|
||||
|
||||
// Table rows with alternating colors and proper text wrapping
|
||||
|
|
@ -253,6 +301,13 @@ export function PurchaseOrderDataTable<TData, TValue>({
|
|||
const maxDescWidth = 65;
|
||||
const wrappedDesc = doc.splitTextToSize(item.itemName, maxDescWidth);
|
||||
const rowHeight = Math.max(10, wrappedDesc.length * 5 + 5); // Dynamic height
|
||||
const bottomLimit = pageHeight - bottomMargin;
|
||||
if (y + rowHeight > bottomLimit) {
|
||||
doc.addPage();
|
||||
y = itemsTopY;
|
||||
renderItemsHeader(y);
|
||||
y += 10;
|
||||
}
|
||||
|
||||
// Alternating row background
|
||||
if (rowColor) {
|
||||
|
|
@ -282,6 +337,11 @@ export function PurchaseOrderDataTable<TData, TValue>({
|
|||
|
||||
// --- Modern Totals Section ---
|
||||
y += 5;
|
||||
const bottomLimit = pageHeight - bottomMargin;
|
||||
if (y + 48 > bottomLimit) {
|
||||
doc.addPage();
|
||||
y = itemsTopY;
|
||||
}
|
||||
|
||||
const carriageCharge = order.carriageCharge || 0;
|
||||
const grandTotal = subtotal + carriageCharge;
|
||||
|
|
@ -289,7 +349,7 @@ export function PurchaseOrderDataTable<TData, TValue>({
|
|||
// Totals box - increased height to accommodate carriage charge
|
||||
doc.setFillColor(lightGray);
|
||||
doc.roundedRect(130, y, 66, 38, 2, 2, 'F');
|
||||
|
||||
|
||||
y += 7;
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(9);
|
||||
|
|
@ -324,6 +384,10 @@ export function PurchaseOrderDataTable<TData, TValue>({
|
|||
|
||||
// --- Footer (with more spacing from grand total) ---
|
||||
y += 25; // Add more space between grand total and footer
|
||||
if (y + 10 > bottomLimit) {
|
||||
doc.addPage();
|
||||
y = itemsTopY;
|
||||
}
|
||||
doc.setFont("helvetica", "italic");
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(mediumGray);
|
||||
|
|
@ -331,7 +395,9 @@ export function PurchaseOrderDataTable<TData, TValue>({
|
|||
doc.text("This is a computer-generated document and does not require a signature.", 105, y + 5, { align: "center" });
|
||||
|
||||
console.log("Saving PDF...");
|
||||
doc.save(`PO_${order.id}_${new Date().toISOString().split('T')[0]}.pdf`);
|
||||
const rawName = order.orderNumber || order.id || `PO_${Date.now()}`
|
||||
const safeName = String(rawName).replace(/[^A-Za-z0-9_-]/g, "_")
|
||||
doc.save(`PO_${safeName}.pdf`);
|
||||
console.log("PDF saved successfully!");
|
||||
} catch (error: any) {
|
||||
console.error("Error generating PDF:", error);
|
||||
|
|
@ -360,7 +426,11 @@ export function PurchaseOrderDataTable<TData, TValue>({
|
|||
}
|
||||
}
|
||||
|
||||
const columnsWithActions = React.useMemo(() => createColumns({ onDownloadPdf: generatePDF, onViewOrder: handleViewOrder }), [generatePDF, handleViewOrder]);
|
||||
const handleDownloadPdf = React.useCallback((order: PurchaseOrder) => { void generatePDF(order) }, [])
|
||||
const columnsWithActions = React.useMemo(
|
||||
() => createColumns({ onDownloadPdf: handleDownloadPdf, onViewOrder: handleViewOrder, onEditOrder: openEditDialog }),
|
||||
[handleDownloadPdf, handleViewOrder]
|
||||
);
|
||||
|
||||
const table = useReactTable<PurchaseOrder>({
|
||||
data: data as PurchaseOrder[],
|
||||
|
|
@ -449,16 +519,18 @@ export function PurchaseOrderDataTable<TData, TValue>({
|
|||
</td>
|
||||
))}
|
||||
<td className="px-4 py-2 space-x-2">
|
||||
{order.status === "Pending Approval" && (
|
||||
{(order.status === "Pending" || order.status === "Pending Approval") && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => approveOrder(order)}>Approve</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => rejectOrder(order)}>Reject</Button>
|
||||
<Button size="sm" onClick={() => openEditDialog(order)}>Edit</Button>
|
||||
</>
|
||||
)}
|
||||
{order.status === "Approved" && (
|
||||
{(order.status === "Approved" || order.status === "Ordered") && (
|
||||
<>
|
||||
<Button size="sm" onClick={() => generatePDF(order)}>Download PDF</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => openReceiveDialog(order)}>Receive Stock</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => openEditDialog(order)}>Edit</Button>
|
||||
</>
|
||||
)}
|
||||
{order.status === "Received" && (
|
||||
|
|
@ -520,6 +592,21 @@ export function PurchaseOrderDataTable<TData, TValue>({
|
|||
refreshData={refreshData}
|
||||
/>
|
||||
)}
|
||||
{selectedOrder && (
|
||||
<EditPurchaseOrderDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditDialogOpen(false)
|
||||
setSelectedOrder(null)
|
||||
} else {
|
||||
setEditDialogOpen(true)
|
||||
}
|
||||
}}
|
||||
order={selectedOrder}
|
||||
refreshData={refreshData}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,9 +31,9 @@ import { StockItemSearch } from "./StockItemSearch"
|
|||
const itemSchema = z.object({
|
||||
stockItemId: z.string().min(1, { message: "Stock item ID is required." }),
|
||||
itemName: z.string().min(1, { message: "Item name is required." }),
|
||||
quantity: z.number().int().positive(),
|
||||
quantity: z.coerce.number().int().positive(),
|
||||
sku: z.string(),
|
||||
costPrice: z.number(),
|
||||
costPrice: z.coerce.number(),
|
||||
})
|
||||
|
||||
// Define the type for the items array
|
||||
|
|
@ -45,7 +45,7 @@ const purchaseOrderFormSchema = z.object({
|
|||
supplierName: z.string().min(1, { message: "Supplier name is required." }),
|
||||
accountRef: z.string().optional(),
|
||||
salesOrderNo: z.string().optional(),
|
||||
carriageCharge: z.number().min(0).optional(),
|
||||
carriageCharge: z.coerce.number().min(0).optional(),
|
||||
orderDate: z.date({ required_error: "Order date is required." }),
|
||||
items: z.array(itemSchema).min(1, { message: "At least one item is required." }),
|
||||
})
|
||||
|
|
@ -180,6 +180,17 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
|
|||
name: "items",
|
||||
});
|
||||
|
||||
const itemsValues = form.watch("items") as ItemSchemaType[];
|
||||
const subtotalGBP = React.useMemo(
|
||||
() =>
|
||||
(itemsValues || []).reduce(
|
||||
(sum, item) =>
|
||||
sum + (Number(item?.quantity || 0) * Number(item?.costPrice || 0)),
|
||||
0
|
||||
),
|
||||
[itemsValues]
|
||||
);
|
||||
|
||||
|
||||
// Function to add a new item row
|
||||
const addItem = () => {
|
||||
|
|
@ -218,14 +229,15 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
|
|||
const selectedStockItem = stockItems.find(item => item.id === selectedItemId);
|
||||
if (selectedStockItem) {
|
||||
// Update the form fields with the selected stock item's details
|
||||
handleItemChange(index, "stockItemId", selectedStockItem.id);
|
||||
handleItemChange(index, "itemName", selectedStockItem.name);
|
||||
handleItemChange(index, "sku", selectedStockItem.sku);
|
||||
form.setValue(`items.${index}.stockItemId`, selectedStockItem.id, { shouldDirty: true });
|
||||
form.setValue(`items.${index}.itemName`, selectedStockItem.name, { shouldDirty: true });
|
||||
form.setValue(`items.${index}.sku`, selectedStockItem.sku, { shouldDirty: true });
|
||||
// Use GBP price for purchase orders
|
||||
handleItemChange(index, "costPrice", selectedStockItem.costPriceGBP);
|
||||
// Quantity remains as is or defaults to 1 if not set
|
||||
if (form.getValues("items")[index].quantity === 0) {
|
||||
handleItemChange(index, "quantity", 1);
|
||||
form.setValue(`items.${index}.costPrice`, selectedStockItem.costPriceGBP, { shouldDirty: true, shouldValidate: true });
|
||||
// Ensure quantity has a sensible default
|
||||
const currentQty = form.getValues(`items.${index}.quantity`);
|
||||
if (!currentQty || Number.isNaN(currentQty)) {
|
||||
form.setValue(`items.${index}.quantity`, 1, { shouldDirty: true, shouldValidate: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -257,6 +269,24 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
|
|||
console.log("Form values:", values);
|
||||
|
||||
await pb.collection('purchase_orders').create(purchaseOrderData);
|
||||
|
||||
// Update stock item GBP prices if missing or changed
|
||||
try {
|
||||
await Promise.all(
|
||||
values.items.map(async (item) => {
|
||||
const current = stockItems.find(si => si.id === item.stockItemId);
|
||||
const newPrice = Number(item.costPrice || 0);
|
||||
if (!current) return;
|
||||
const existing = Number(current.costPriceGBP ?? 0);
|
||||
if (Number.isFinite(newPrice) && newPrice > 0 && newPrice !== existing) {
|
||||
await pb.collection('stock_items').update(item.stockItemId, { cost_price_gbp: newPrice });
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch (updateErr) {
|
||||
console.error("Error updating stock item GBP prices:", updateErr);
|
||||
}
|
||||
|
||||
showSuccess(`Purchase order ${values.orderNumber} created successfully!`);
|
||||
form.reset();
|
||||
// Generate new order number for next use
|
||||
|
|
@ -383,7 +413,10 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
|
|||
<div className="border p-4 rounded-md">
|
||||
<h3 className="text-lg font-semibold mb-4">Order Items</h3>
|
||||
{fields.map((item, index) => (
|
||||
<div key={item.id} className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-[2fr,1fr,1fr,1fr,auto] gap-4 mb-4 items-center">
|
||||
<div
|
||||
key={item.id}
|
||||
className="grid grid-cols-1 md:grid-cols-[minmax(220px,1fr),160px,110px,140px,auto] gap-4 mb-4 items-start"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`items.${index}.itemName`}
|
||||
|
|
@ -395,6 +428,7 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
|
|||
selectedItemName={field.value}
|
||||
onSelectItem={(selectedItemId) => handleStockItemSelect(index, selectedItemId)}
|
||||
placeholder="Search by SKU or name..."
|
||||
className="min-w-0"
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -408,7 +442,7 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
|
|||
<FormLabel>SKU</FormLabel>
|
||||
<FormControl>
|
||||
{/* Display SKU, but it's managed by handleStockItemSelect */}
|
||||
<Input {...field} readOnly placeholder="SKU" />
|
||||
<Input {...field} readOnly placeholder="SKU" className="md:w-40" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -421,7 +455,14 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
|
|||
<FormItem>
|
||||
<FormLabel>Quantity</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} onChange={(e) => handleItemChange(index, "quantity", e.target.value)} />
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
className="text-right md:w-28"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => field.onChange(e.target.value.replace(/[^0-9]/g, ""))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -434,7 +475,15 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
|
|||
<FormItem>
|
||||
<FormLabel>Unit Price (£ GBP)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" placeholder="£0.00" {...field} onChange={(e) => handleItemChange(index, "costPrice", e.target.value)} />
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="^[0-9]*[.,]?[0-9]*$"
|
||||
placeholder="£0.00"
|
||||
className="text-right md:w-36"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => field.onChange(e.target.value.replace(",", "."))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -452,10 +501,16 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
|
|||
<Button type="button" variant="outline" onClick={addItem} className="mt-2">
|
||||
Add Item
|
||||
</Button>
|
||||
<div className="mt-4 border-t pt-4">
|
||||
<div className="flex items-center justify-end gap-6">
|
||||
<div className="text-sm text-gray-600">Items total (GBP)</div>
|
||||
<div className="text-lg font-semibold">£{subtotalGBP.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full">Create Purchase Order</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,12 +35,13 @@ interface StockItemSearchProps {
|
|||
selectedItemName: string;
|
||||
onSelectItem: (itemId: string) => void;
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export function StockItemSearch({ stockItems, selectedItemName, onSelectItem, placeholder }: StockItemSearchProps) {
|
||||
export function StockItemSearch({ stockItems, selectedItemName, onSelectItem, placeholder, className }: StockItemSearchProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [search, setSearch] = React.useState("")
|
||||
|
||||
|
|
@ -51,7 +52,7 @@ export function StockItemSearch({ stockItems, selectedItemName, onSelectItem, pl
|
|||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between text-left font-normal"
|
||||
className={cn("w-full justify-between text-left font-normal", className)}
|
||||
>
|
||||
{selectedItemName ? (
|
||||
<div className="flex flex-col">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { MoreHorizontal, Download, Eye } from "lucide-react" // Import Eye icon
|
||||
import { MoreHorizontal, Download, Eye, Pencil } from "lucide-react" // Import Eye icon
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -45,10 +45,11 @@ export type PurchaseOrder = {
|
|||
interface ActionsColumnProps {
|
||||
onDownloadPdf: (order: PurchaseOrder) => void;
|
||||
onViewOrder: (order: PurchaseOrder) => void; // Add onViewOrder prop
|
||||
onEditOrder?: (order: PurchaseOrder) => void;
|
||||
}
|
||||
|
||||
// Export createColumns function
|
||||
export const createColumns = ({ onDownloadPdf, onViewOrder }: ActionsColumnProps): ColumnDef<PurchaseOrder>[] => [
|
||||
export const createColumns = ({ onDownloadPdf, onViewOrder, onEditOrder }: ActionsColumnProps): ColumnDef<PurchaseOrder>[] => [
|
||||
{
|
||||
accessorKey: "supplierName",
|
||||
header: "Supplier Name",
|
||||
|
|
@ -101,6 +102,12 @@ export const createColumns = ({ onDownloadPdf, onViewOrder }: ActionsColumnProps
|
|||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Order
|
||||
</DropdownMenuItem>
|
||||
{!!onEditOrder && (
|
||||
<DropdownMenuItem onClick={() => onEditOrder(order)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit Order
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onDownloadPdf(order)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download PDF
|
||||
|
|
@ -110,4 +117,4 @@ export const createColumns = ({ onDownloadPdf, onViewOrder }: ActionsColumnProps
|
|||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ const PurchaseOrders = () => {
|
|||
New Purchase Order
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="mx-4 sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="mx-4 sm:max-w-[800px] lg:max-w-[900px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Purchase Order</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
@ -150,4 +150,4 @@ const PurchaseOrders = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default PurchaseOrders;
|
||||
export default PurchaseOrders;
|
||||
|
|
|
|||