This commit is contained in:
2026-03-14 23:50:41 -05:00
parent 7b85d14ff6
commit 5f93adab8b
4 changed files with 254 additions and 0 deletions

View File

@@ -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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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) => `
<tr>
<td>${escapeHtml(line.itemSku)}</td>
<td>
<div class="item-name">${escapeHtml(line.itemName)}</div>
<div class="item-desc">${escapeHtml(line.description || "")}</div>
</td>
<td class="qty">${line.quantity}</td>
<td class="qty">${escapeHtml(line.unitOfMeasure)}</td>
</tr>
`
)
.join("");
const pdf = await renderPdf(`
<html>
<head>
<style>
@page { margin: 18mm; }
body { font-family: ${profile.theme.fontFamily}, Arial, sans-serif; color: #1b1f29; font-size: 12px; }
.page { display: flex; flex-direction: column; gap: 18px; }
.header { display: flex; justify-content: space-between; gap: 24px; border-bottom: 2px solid ${profile.theme.primaryColor}; padding-bottom: 16px; }
.brand h1 { margin: 0; font-size: 24px; color: ${profile.theme.primaryColor}; }
.brand p { margin: 6px 0 0; color: #5a6a85; }
.meta { min-width: 280px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px 18px; }
.label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; }
.value { margin-top: 4px; font-size: 13px; font-weight: 600; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.card { border: 1px solid #d7deeb; border-radius: 14px; padding: 14px 16px; }
.card-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; margin-bottom: 8px; }
.stack { display: flex; flex-direction: column; gap: 4px; }
table { width: 100%; border-collapse: collapse; }
thead th { text-align: left; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; background: #f4f7fb; padding: 10px 12px; border-bottom: 1px solid #d7deeb; }
tbody td { padding: 12px; border-bottom: 1px solid #e6ebf3; vertical-align: top; }
.qty { text-align: right; white-space: nowrap; }
.item-name { font-weight: 600; }
.item-desc { margin-top: 4px; color: #5a6a85; font-size: 11px; }
.footer-note { border: 1px solid #d7deeb; border-radius: 14px; padding: 14px 16px; min-height: 72px; white-space: pre-line; }
</style>
</head>
<body>
<div class="page">
<div class="header">
<div class="brand">
<h1>${escapeHtml(profile.companyName)}</h1>
<p>${escapeHtml(profile.addressLine1)}${profile.addressLine2 ? `<br/>${escapeHtml(profile.addressLine2)}` : ""}<br/>${escapeHtml(profile.city)}, ${escapeHtml(profile.state)} ${escapeHtml(profile.postalCode)}<br/>${escapeHtml(profile.country)}</p>
</div>
<div class="meta">
<div><div class="label">Document</div><div class="value">Packing Slip</div></div>
<div><div class="label">Shipment</div><div class="value">${escapeHtml(shipment.shipmentNumber)}</div></div>
<div><div class="label">Sales Order</div><div class="value">${escapeHtml(shipment.salesOrderNumber)}</div></div>
<div><div class="label">Ship Date</div><div class="value">${shipment.shipDate ? escapeHtml(new Date(shipment.shipDate).toLocaleDateString()) : "Pending"}</div></div>
<div><div class="label">Carrier</div><div class="value">${escapeHtml(shipment.carrier || "Not set")}</div></div>
<div><div class="label">Tracking</div><div class="value">${escapeHtml(shipment.trackingNumber || "Not set")}</div></div>
</div>
</div>
<div class="grid">
<div class="card">
<div class="card-title">Ship To</div>
<div class="stack">${shipToLines.map((line) => `<div>${escapeHtml(line)}</div>`).join("")}</div>
</div>
<div class="card">
<div class="card-title">Shipment Info</div>
<div class="stack">
<div><strong>Status:</strong> ${escapeHtml(shipment.status)}</div>
<div><strong>Service:</strong> ${escapeHtml(shipment.serviceLevel || "Not set")}</div>
<div><strong>Packages:</strong> ${shipment.packageCount}</div>
</div>
</div>
</div>
<table>
<thead>
<tr>
<th style="width: 20%;">SKU</th>
<th>Description</th>
<th style="width: 12%;" class="qty">Qty</th>
<th style="width: 10%;" class="qty">UOM</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
<div class="footer-note"><div class="card-title">Notes</div>${escapeHtml(shipment.notes || "No shipment notes recorded.")}</div>
</div>
</body>
</html>
`);
response.setHeader("Content-Type", "application/pdf");
response.setHeader("Content-Disposition", `inline; filename=${shipment.shipmentNumber.toLowerCase()}-packing-slip.pdf`);
return response.send(pdf);
}
);

View File

@@ -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<ShipmentPackingSlipData | null> {
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,
})),
};
}