Add packing slip PDF to invoice details page

Generates a no-price packing slip (items, color, qty + signature line) via
QuestPDF. New DownloadPackingSlip action reuses existing invoice data pipeline;
Packing Slip button opens inline in a new tab same as Print/PDF.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 16:52:46 -04:00
parent 81dc34bab4
commit b241daf15e
6 changed files with 254 additions and 0 deletions
@@ -2753,4 +2753,187 @@ public class PdfService : IPdfService
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
}
}
// -----------------------------------------------------------------------
// Packing Slip
// -----------------------------------------------------------------------
/// <summary>
/// Generates a no-price packing slip PDF for the given invoice. Lists job items with
/// description, color, and quantity only — no unit prices or totals. Intended for
/// physical pickup/delivery paperwork where pricing should not be visible.
/// </summary>
public async Task<byte[]> GeneratePackingSlipPdfAsync(
InvoiceDto invoiceDto,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accentColor = "#1e40af"; // blue
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Header().Element(c => ComposePackingSlipHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
page.Content().Element(c => ComposePackingSlipContent(c, invoiceDto, accentColor));
page.Footer().AlignCenter().Text(text =>
{
text.Span("PACKING SLIP | ").FontSize(8).FontColor(Colors.Grey.Darken1);
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
text.Span(" | Page ").FontSize(8).FontColor(Colors.Grey.Darken1);
text.CurrentPageNumber().FontSize(8).FontColor(Colors.Grey.Darken1);
text.Span(" of ").FontSize(8).FontColor(Colors.Grey.Darken1);
text.TotalPages().FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
}
/// <summary>
/// Header for the packing slip: company branding left, "PACKING SLIP" title + invoice/date info right.
/// </summary>
private void ComposePackingSlipHeader(IContainer container, byte[]? companyLogo, CompanyInfoDto companyInfo, string accentColor, InvoiceDto invoice)
{
container.Column(col =>
{
col.Item().Row(row =>
{
row.RelativeItem().Column(column =>
{
if (companyLogo != null && companyLogo.Length > 0)
column.Item().MaxHeight(60).Image(companyLogo);
else
column.Item().Text(companyInfo.CompanyName).FontSize(18).Bold().FontColor(accentColor);
if (!string.IsNullOrWhiteSpace(companyInfo.Address))
column.Item().Text(companyInfo.Address).FontSize(8).FontColor(Colors.Grey.Darken1);
var cityLine = $"{companyInfo.City}{(!string.IsNullOrEmpty(companyInfo.City) && !string.IsNullOrEmpty(companyInfo.State) ? ", " : "")}{companyInfo.State} {companyInfo.ZipCode}".Trim();
if (!string.IsNullOrWhiteSpace(cityLine))
column.Item().Text(cityLine).FontSize(8).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
column.Item().Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(8).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.PrimaryContactEmail))
column.Item().Text(companyInfo.PrimaryContactEmail).FontSize(8).FontColor(Colors.Grey.Darken1);
});
row.RelativeItem().AlignRight().Column(column =>
{
column.Item().Text("PACKING SLIP").FontSize(26).Bold().FontColor(accentColor);
column.Item().Text($"Invoice #: {invoice.InvoiceNumber}").FontSize(9).Bold();
column.Item().Text($"Date: {invoice.InvoiceDate:MMMM d, yyyy}").FontSize(9);
if (!string.IsNullOrWhiteSpace(invoice.JobNumber))
column.Item().Text($"Job #: {invoice.JobNumber}").FontSize(9);
});
});
col.Item().PaddingVertical(4).LineHorizontal(1).LineColor(accentColor);
});
}
/// <summary>
/// Body of the packing slip: customer info block, optional PO number, and an items table
/// showing description, color, and quantity — no prices.
/// </summary>
private void ComposePackingSlipContent(IContainer container, InvoiceDto invoice, string accentColor)
{
container.Column(col =>
{
// Customer info
col.Item().PaddingTop(12).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().Text("PREPARED FOR").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(invoice.CustomerName).Bold();
if (!string.IsNullOrWhiteSpace(invoice.CustomerAddress))
c.Item().Text(invoice.CustomerAddress).FontSize(9);
var cityLine = $"{invoice.CustomerCity}{(!string.IsNullOrEmpty(invoice.CustomerCity) && !string.IsNullOrEmpty(invoice.CustomerState) ? ", " : "")}{invoice.CustomerState} {invoice.CustomerZipCode}".Trim();
if (!string.IsNullOrWhiteSpace(cityLine))
c.Item().Text(cityLine).FontSize(9);
if (!string.IsNullOrWhiteSpace(invoice.CustomerPhone))
c.Item().Text(FormatPhoneNumber(invoice.CustomerPhone)).FontSize(9);
});
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
{
row.ConstantItem(160).AlignRight().Column(c =>
{
c.Item().Text("PURCHASE ORDER").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(invoice.CustomerPO).Bold();
});
}
});
// Items table
col.Item().PaddingTop(16).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(5);
cols.RelativeColumn(3);
cols.RelativeColumn(1);
});
table.Header(h =>
{
h.Cell().Background(accentColor).Padding(5).Text("Description").FontColor(Colors.White).Bold().FontSize(9);
h.Cell().Background(accentColor).Padding(5).Text("Color / Finish").FontColor(Colors.White).Bold().FontSize(9);
h.Cell().Background(accentColor).Padding(5).AlignCenter().Text("Qty").FontColor(Colors.White).Bold().FontSize(9);
});
var rowAlt = false;
foreach (var item in invoice.InvoiceItems.OrderBy(i => i.DisplayOrder))
{
var bg = rowAlt ? Colors.Grey.Lighten4 : Colors.White;
table.Cell().Background(bg).Padding(5).Column(c =>
{
c.Item().Text(item.Description).FontSize(9);
if (!string.IsNullOrWhiteSpace(item.Notes))
c.Item().Text(item.Notes).FontSize(8).FontColor(Colors.Grey.Darken1);
});
table.Cell().Background(bg).Padding(5).Text(item.ColorName ?? "—").FontSize(9);
table.Cell().Background(bg).Padding(5).AlignCenter().Text(item.Quantity.ToString("G")).FontSize(9);
rowAlt = !rowAlt;
}
});
// Notes (if any)
if (!string.IsNullOrWhiteSpace(invoice.Notes))
{
col.Item().PaddingTop(16).Column(c =>
{
c.Item().Text("Notes").Bold().FontSize(9);
c.Item().Text(invoice.Notes).FontSize(9).FontColor(Colors.Grey.Darken1);
});
}
// Received by signature line
col.Item().PaddingTop(32).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text(string.Empty);
c.Item().PaddingTop(2).Text("Received by / Date").FontSize(8).FontColor(Colors.Grey.Darken1);
});
row.ConstantItem(24);
row.RelativeItem().Column(c =>
{
c.Item().BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text(string.Empty);
c.Item().PaddingTop(2).Text("Condition noted").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
}
}