diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 4f768ee..599aa21 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -464,6 +464,19 @@ export const api = { token ); }, + async getShipmentPackingSlipPdf(token: string, shipmentId: string) { + const response = await fetch(`/api/v1/documents/shipping/shipments/${shipmentId}/packing-slip.pdf`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new ApiError("Unable to render packing slip PDF.", "PACKING_SLIP_FAILED"); + } + + return response.blob(); + }, async getCompanyProfilePreviewPdf(token: string) { const response = await fetch("/api/v1/documents/company-profile-preview.pdf", { headers: { diff --git a/client/src/modules/shipping/ShipmentDetailPage.tsx b/client/src/modules/shipping/ShipmentDetailPage.tsx index 51fb07e..c006ccc 100644 --- a/client/src/modules/shipping/ShipmentDetailPage.tsx +++ b/client/src/modules/shipping/ShipmentDetailPage.tsx @@ -15,6 +15,7 @@ export function ShipmentDetailPage() { const [relatedShipments, setRelatedShipments] = useState([]); const [status, setStatus] = useState("Loading shipment..."); const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); + const [isRenderingPdf, setIsRenderingPdf] = useState(false); const canManage = user?.permissions.includes(permissions.shippingWrite) ?? false; @@ -55,6 +56,27 @@ export function ShipmentDetailPage() { } } + async function handleOpenPackingSlip() { + if (!token || !shipment) { + return; + } + + setIsRenderingPdf(true); + setStatus("Rendering packing slip PDF..."); + try { + const blob = await api.getShipmentPackingSlipPdf(token, shipment.id); + const objectUrl = URL.createObjectURL(blob); + window.open(objectUrl, "_blank", "noopener,noreferrer"); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); + setStatus("Packing slip PDF rendered."); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : "Unable to render packing slip PDF."; + setStatus(message); + } finally { + setIsRenderingPdf(false); + } + } + if (!shipment) { return
{status}
; } @@ -72,6 +94,9 @@ export function ShipmentDetailPage() {
Back to shipments Open sales order + {canManage ? ( Edit shipment ) : null} diff --git a/server/src/modules/documents/router.ts b/server/src/modules/documents/router.ts index ed0bb47..4b674f4 100644 --- a/server/src/modules/documents/router.ts +++ b/server/src/modules/documents/router.ts @@ -3,10 +3,20 @@ import { Router } from "express"; import { renderPdf } from "../../lib/pdf.js"; import { requirePermissions } from "../../lib/rbac.js"; +import { getShipmentPackingSlipData } from "../shipping/service.js"; import { getActiveCompanyProfile } from "../settings/service.js"; export const documentsRouter = Router(); +function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + documentsRouter.get("/company-profile-preview.pdf", requirePermissions([permissions.companyRead]), async (_request, response) => { const profile = await getActiveCompanyProfile(); const pdf = await renderPdf(` @@ -48,3 +58,123 @@ documentsRouter.get("/company-profile-preview.pdf", requirePermissions([permissi return response.send(pdf); }); +documentsRouter.get( + "/shipping/shipments/:shipmentId/packing-slip.pdf", + requirePermissions([permissions.shippingRead]), + async (request, response) => { + const shipmentId = typeof request.params.shipmentId === "string" ? request.params.shipmentId : null; + if (!shipmentId) { + response.status(400); + return response.send("Invalid shipment id."); + } + + const [profile, shipment] = await Promise.all([getActiveCompanyProfile(), getShipmentPackingSlipData(shipmentId)]); + if (!shipment) { + response.status(404); + return response.send("Shipment was not found."); + } + + const shipToLines = [ + shipment.customer.name, + shipment.customer.addressLine1, + shipment.customer.addressLine2, + `${shipment.customer.city}, ${shipment.customer.state} ${shipment.customer.postalCode}`.trim(), + shipment.customer.country, + ].filter((line) => line.trim().length > 0); + + const rows = shipment.lines + .map( + (line) => ` + + ${escapeHtml(line.itemSku)} + +
${escapeHtml(line.itemName)}
+
${escapeHtml(line.description || "")}
+ + ${line.quantity} + ${escapeHtml(line.unitOfMeasure)} + + ` + ) + .join(""); + + const pdf = await renderPdf(` + + + + + +
+
+
+

${escapeHtml(profile.companyName)}

+

${escapeHtml(profile.addressLine1)}${profile.addressLine2 ? `
${escapeHtml(profile.addressLine2)}` : ""}
${escapeHtml(profile.city)}, ${escapeHtml(profile.state)} ${escapeHtml(profile.postalCode)}
${escapeHtml(profile.country)}

+
+
+
Document
Packing Slip
+
Shipment
${escapeHtml(shipment.shipmentNumber)}
+
Sales Order
${escapeHtml(shipment.salesOrderNumber)}
+
Ship Date
${shipment.shipDate ? escapeHtml(new Date(shipment.shipDate).toLocaleDateString()) : "Pending"}
+
Carrier
${escapeHtml(shipment.carrier || "Not set")}
+
Tracking
${escapeHtml(shipment.trackingNumber || "Not set")}
+
+
+
+
+
Ship To
+
${shipToLines.map((line) => `
${escapeHtml(line)}
`).join("")}
+
+
+
Shipment Info
+
+
Status: ${escapeHtml(shipment.status)}
+
Service: ${escapeHtml(shipment.serviceLevel || "Not set")}
+
Packages: ${shipment.packageCount}
+
+
+
+ + + + + + + + + + + ${rows} + +
SKUDescriptionQtyUOM
+ +
+ + + `); + + response.setHeader("Content-Type", "application/pdf"); + response.setHeader("Content-Disposition", `inline; filename=${shipment.shipmentNumber.toLowerCase()}-packing-slip.pdf`); + return response.send(pdf); + } +); diff --git a/server/src/modules/shipping/service.ts b/server/src/modules/shipping/service.ts index 37b9b41..931342b 100644 --- a/server/src/modules/shipping/service.ts +++ b/server/src/modules/shipping/service.ts @@ -8,6 +8,34 @@ import type { import { prisma } from "../../lib/prisma.js"; +export interface ShipmentPackingSlipData { + shipmentNumber: string; + status: ShipmentStatus; + shipDate: string | null; + carrier: string; + serviceLevel: string; + trackingNumber: string; + packageCount: number; + notes: string; + salesOrderNumber: string; + customer: { + name: string; + addressLine1: string; + addressLine2: string; + city: string; + state: string; + postalCode: string; + country: string; + }; + lines: Array<{ + itemSku: string; + itemName: string; + description: string; + quantity: number; + unitOfMeasure: string; + }>; +} + type ShipmentRecord = { id: string; shipmentNumber: string; @@ -221,3 +249,61 @@ export async function updateShipmentStatus(shipmentId: string, status: ShipmentS const detail = await getShipmentById(shipmentId); return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load updated shipment." }; } + +export async function getShipmentPackingSlipData(shipmentId: string): Promise { + const shipment = await prisma.shipment.findUnique({ + where: { id: shipmentId }, + include: { + salesOrder: { + include: { + customer: { + select: { + name: true, + addressLine1: true, + addressLine2: true, + city: true, + state: true, + postalCode: true, + country: true, + }, + }, + lines: { + include: { + item: { + select: { + sku: true, + name: true, + }, + }, + }, + orderBy: [{ position: "asc" }, { createdAt: "asc" }], + }, + }, + }, + }, + }); + + if (!shipment) { + return null; + } + + return { + shipmentNumber: shipment.shipmentNumber, + status: shipment.status as ShipmentStatus, + shipDate: shipment.shipDate ? shipment.shipDate.toISOString() : null, + carrier: shipment.carrier, + serviceLevel: shipment.serviceLevel, + trackingNumber: shipment.trackingNumber, + packageCount: shipment.packageCount, + notes: shipment.notes, + salesOrderNumber: shipment.salesOrder.documentNumber, + customer: shipment.salesOrder.customer, + lines: shipment.salesOrder.lines.map((line) => ({ + itemSku: line.item.sku, + itemName: line.item.name, + description: line.description, + quantity: line.quantity, + unitOfMeasure: line.unitOfMeasure, + })), + }; +}