pdfs
This commit is contained in:
@@ -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) => `
|
||||
<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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user