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);
}
);