diff --git a/public/icons/icon-192x192.png b/public/icons/icon-192x192.png index dda2e0a..c736987 100644 Binary files a/public/icons/icon-192x192.png and b/public/icons/icon-192x192.png differ diff --git a/public/icons/icon-192x192.pngsss b/public/icons/icon-192x192.pngsss new file mode 100644 index 0000000..dda2e0a Binary files /dev/null and b/public/icons/icon-192x192.pngsss differ diff --git a/public/icons/icon-512x512.png b/public/icons/icon-512x512.png index 3405907..c736987 100644 Binary files a/public/icons/icon-512x512.png and b/public/icons/icon-512x512.png differ diff --git a/public/icons/icon-512x512.pngssss b/public/icons/icon-512x512.pngssss new file mode 100644 index 0000000..3405907 Binary files /dev/null and b/public/icons/icon-512x512.pngssss differ diff --git a/public/jeilogo.png b/public/jeilogo.png new file mode 100644 index 0000000..c736987 Binary files /dev/null and b/public/jeilogo.png differ diff --git a/src/components/purchase-orders/EditPurchaseOrderDialog.tsx b/src/components/purchase-orders/EditPurchaseOrderDialog.tsx new file mode 100644 index 0000000..9c97501 --- /dev/null +++ b/src/components/purchase-orders/EditPurchaseOrderDialog.tsx @@ -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>({ + 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) => { + 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 ( + + + + Edit Purchase Order + Order ID: {order.id} + +
+ +
+

Items

+ {fields.map((field, index) => ( +
+ ( + + Item + + + + + + )} + /> + ( + + Qty + + field.onChange(e.target.value.replace(/[^0-9]/g, ""))} + /> + + + + )} + /> + ( + + Unit Price (£) + + field.onChange(e.target.value.replace(",", "."))} + /> + + + + )} + /> +
+ ))} +
+
+
Items total (GBP)
+
£{subtotalGBP.toFixed(2)}
+
+
+
+ +
+ +
+
+ ) +} diff --git a/src/components/purchase-orders/PurchaseOrderDataTable.tsx b/src/components/purchase-orders/PurchaseOrderDataTable.tsx index e3dfe92..36cb233 100644 --- a/src/components/purchase-orders/PurchaseOrderDataTable.tsx +++ b/src/components/purchase-orders/PurchaseOrderDataTable.tsx @@ -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({ const [receiveDialogOpen, setReceiveDialogOpen] = React.useState(false) const [selectedOrder, setSelectedOrder] = React.useState(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({ 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 => { + 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({ 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({ 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({ 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({ // --- 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({ // 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({ // --- 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({ 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({ } } - 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({ data: data as PurchaseOrder[], @@ -449,16 +519,18 @@ export function PurchaseOrderDataTable({ ))} - {order.status === "Pending Approval" && ( + {(order.status === "Pending" || order.status === "Pending Approval") && ( <> + )} - {order.status === "Approved" && ( + {(order.status === "Approved" || order.status === "Ordered") && ( <> + )} {order.status === "Received" && ( @@ -520,6 +592,21 @@ export function PurchaseOrderDataTable({ refreshData={refreshData} /> )} + {selectedOrder && ( + { + if (!open) { + setEditDialogOpen(false) + setSelectedOrder(null) + } else { + setEditDialogOpen(true) + } + }} + order={selectedOrder} + refreshData={refreshData} + /> + )} ) -} \ No newline at end of file +} diff --git a/src/components/purchase-orders/PurchaseOrderForm.tsx b/src/components/purchase-orders/PurchaseOrderForm.tsx index 290988c..0a996a0 100644 --- a/src/components/purchase-orders/PurchaseOrderForm.tsx +++ b/src/components/purchase-orders/PurchaseOrderForm.tsx @@ -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

Order Items

{fields.map((item, index) => ( -
+
handleStockItemSelect(index, selectedItemId)} placeholder="Search by SKU or name..." + className="min-w-0" /> @@ -408,7 +442,7 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro SKU {/* Display SKU, but it's managed by handleStockItemSelect */} - + @@ -421,7 +455,14 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro Quantity - handleItemChange(index, "quantity", e.target.value)} /> + field.onChange(e.target.value.replace(/[^0-9]/g, ""))} + /> @@ -434,7 +475,15 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro Unit Price (£ GBP) - handleItemChange(index, "costPrice", e.target.value)} /> + field.onChange(e.target.value.replace(",", "."))} + /> @@ -452,10 +501,16 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro +
+
+
Items total (GBP)
+
£{subtotalGBP.toFixed(2)}
+
+
) -} \ No newline at end of file +} diff --git a/src/components/purchase-orders/StockItemSearch.tsx b/src/components/purchase-orders/StockItemSearch.tsx index a887d0a..05c454c 100644 --- a/src/components/purchase-orders/StockItemSearch.tsx +++ b/src/components/purchase-orders/StockItemSearch.tsx @@ -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 ? (
diff --git a/src/components/purchase-orders/columns.tsx b/src/components/purchase-orders/columns.tsx index dca9a78..4da0523 100644 --- a/src/components/purchase-orders/columns.tsx +++ b/src/components/purchase-orders/columns.tsx @@ -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[] => [ +export const createColumns = ({ onDownloadPdf, onViewOrder, onEditOrder }: ActionsColumnProps): ColumnDef[] => [ { accessorKey: "supplierName", header: "Supplier Name", @@ -101,6 +102,12 @@ export const createColumns = ({ onDownloadPdf, onViewOrder }: ActionsColumnProps View Order + {!!onEditOrder && ( + onEditOrder(order)}> + + Edit Order + + )} onDownloadPdf(order)}> Download PDF @@ -110,4 +117,4 @@ export const createColumns = ({ onDownloadPdf, onViewOrder }: ActionsColumnProps ) }, }, -] \ No newline at end of file +] diff --git a/src/pages/PurchaseOrders.tsx b/src/pages/PurchaseOrders.tsx index 72d477d..7711d06 100644 --- a/src/pages/PurchaseOrders.tsx +++ b/src/pages/PurchaseOrders.tsx @@ -100,7 +100,7 @@ const PurchaseOrders = () => { New Purchase Order - + New Purchase Order @@ -150,4 +150,4 @@ const PurchaseOrders = () => { ); }; -export default PurchaseOrders; \ No newline at end of file +export default PurchaseOrders;