b241daf15e
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>
2940 lines
157 KiB
C#
2940 lines
157 KiB
C#
using PowderCoating.Application.DTOs.Accounting;
|
||
using PowderCoating.Application.DTOs.Company;
|
||
using PowderCoating.Application.DTOs.GiftCertificate;
|
||
using PowderCoating.Application.DTOs.Invoice;
|
||
using PowderCoating.Application.DTOs.PurchaseOrder;
|
||
using PowderCoating.Application.DTOs.Quote;
|
||
using PowderCoating.Application.Interfaces;
|
||
using PowderCoating.Core.Entities;
|
||
using PowderCoating.Core.Enums;
|
||
using QuestPDF.Fluent;
|
||
using QuestPDF.Helpers;
|
||
using QuestPDF.Infrastructure;
|
||
|
||
namespace PowderCoating.Application.Services;
|
||
|
||
public class PdfService : IPdfService
|
||
{
|
||
/// <summary>
|
||
/// Generates a customer-facing quote PDF and returns the raw bytes for download or email attachment.
|
||
/// Runs document composition on a background thread via <c>Task.Run</c> because QuestPDF's
|
||
/// <c>GeneratePdf()</c> is CPU-bound and would otherwise block an ASP.NET request thread.
|
||
/// The <paramref name="template"/> parameter drives accent colour, footer note, and default terms;
|
||
/// when null a sensible default (<see cref="QuoteTemplateSettingsDto"/>) is used so callers do not
|
||
/// need to guard against missing company preferences.
|
||
/// </summary>
|
||
public async Task<byte[]> GenerateQuotePdfAsync(
|
||
QuoteDto quoteDto,
|
||
byte[]? companyLogo,
|
||
string? companyLogoContentType,
|
||
CompanyInfoDto companyInfo,
|
||
QuoteTemplateSettingsDto? template = null,
|
||
byte[]? preparedByPhoto = null)
|
||
{
|
||
// Configure QuestPDF license (community/commercial)
|
||
QuestPDF.Settings.License = LicenseType.Community;
|
||
|
||
template ??= new QuoteTemplateSettingsDto();
|
||
var accentColor = ResolveColor(template.AccentColor, Colors.Grey.Darken2);
|
||
|
||
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 => ComposeHeader(c, companyLogo, companyLogoContentType, companyInfo, accentColor));
|
||
page.Content().Element(c => ComposeContent(c, quoteDto, preparedByPhoto, template, accentColor));
|
||
page.Footer().AlignCenter().Text(text =>
|
||
{
|
||
text.CurrentPageNumber();
|
||
text.Span(" / ");
|
||
text.TotalPages();
|
||
text.Span($" - {companyInfo.CompanyName}");
|
||
if (!string.IsNullOrWhiteSpace(template.FooterNote))
|
||
{
|
||
text.Span($" | {template.FooterNote}");
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
return document.GeneratePdf();
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Generates an invoice PDF and returns the raw bytes.
|
||
/// Mirrors the structure of <see cref="GenerateQuotePdfAsync"/> but delegates content layout to
|
||
/// <see cref="ComposeInvoiceHeader"/> and <see cref="ComposeInvoiceContent"/>, which render the
|
||
/// payment history, balance-due totals, and overdue status colouring that are unique to invoices.
|
||
/// The accent colour defaults to blue (versus grey for quotes) to give invoices a distinct visual identity.
|
||
/// </summary>
|
||
public async Task<byte[]> GenerateInvoicePdfAsync(
|
||
InvoiceDto invoiceDto,
|
||
byte[]? companyLogo,
|
||
string? companyLogoContentType,
|
||
CompanyInfoDto companyInfo,
|
||
QuoteTemplateSettingsDto? template = null)
|
||
{
|
||
QuestPDF.Settings.License = LicenseType.Community;
|
||
template ??= new QuoteTemplateSettingsDto();
|
||
var accentColor = ResolveColor(template.AccentColor, Colors.Blue.Darken2);
|
||
|
||
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 => ComposeInvoiceHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
|
||
page.Content().Layers(layers =>
|
||
{
|
||
layers.PrimaryLayer().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
|
||
if (invoiceDto.Status == InvoiceStatus.Paid)
|
||
layers.Layer().Element(c => ComposePaidStamp(c));
|
||
});
|
||
page.Footer().AlignCenter().Text(text =>
|
||
{
|
||
text.CurrentPageNumber();
|
||
text.Span(" / ");
|
||
text.TotalPages();
|
||
text.Span($" - {companyInfo.CompanyName}");
|
||
if (!string.IsNullOrWhiteSpace(template.FooterNote))
|
||
{
|
||
text.Span($" | {template.FooterNote}");
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
return document.GeneratePdf();
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Composes the invoice page header: a two-column row with company branding on the left and
|
||
/// invoice metadata (number, date, due date, status) on the right, separated from the body by
|
||
/// a full-width accent-coloured rule. Due dates are rendered in red when the invoice is overdue,
|
||
/// giving accountants an immediate visual cue without opening the web application.
|
||
/// </summary>
|
||
private void ComposeInvoiceHeader(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(9).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(9).FontColor(Colors.Grey.Darken1);
|
||
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||
column.Item().Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
|
||
row.RelativeItem().AlignRight().Column(column =>
|
||
{
|
||
column.Item().Text("INVOICE").FontSize(28).Bold().FontColor(accentColor);
|
||
column.Item().Text($"# {invoice.InvoiceNumber}").FontSize(12).Bold();
|
||
column.Item().Text($"Date: {invoice.InvoiceDate:MMMM d, yyyy}").FontSize(9);
|
||
if (invoice.DueDate.HasValue)
|
||
column.Item().Text($"Due: {invoice.DueDate.Value:MMMM d, yyyy}").FontSize(9).FontColor(
|
||
invoice.Status == Core.Enums.InvoiceStatus.Overdue ? Colors.Red.Medium : Colors.Grey.Darken2);
|
||
});
|
||
});
|
||
|
||
col.Item().PaddingVertical(4).LineHorizontal(1).LineColor(accentColor);
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Renders a semi-transparent angled PAID stamp centred over the invoice content layer.
|
||
/// Uses QuestPDF layout primitives (AlignCenter, AlignMiddle, Rotate, Opacity) so no
|
||
/// external Skia/SkiaSharp dependency is needed.
|
||
/// </summary>
|
||
private static void ComposePaidStamp(IContainer container)
|
||
{
|
||
container
|
||
.AlignCenter()
|
||
.AlignMiddle()
|
||
.Rotate(-45f)
|
||
.Border(5)
|
||
.BorderColor(Colors.Green.Darken2)
|
||
.PaddingVertical(14)
|
||
.PaddingHorizontal(28)
|
||
.Text("PAID")
|
||
.FontSize(80)
|
||
.Bold()
|
||
.FontColor(Colors.Green.Darken2);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Composes the body of the invoice PDF: bill-to address block, job reference, alternating-row
|
||
/// line-item table, and a right-aligned totals block that conditionally shows discount, tax,
|
||
/// amount-paid, and balance-due rows. Terms fall back to the company template's
|
||
/// <see cref="QuoteTemplateSettingsDto.DefaultTerms"/> when the invoice itself has none, keeping
|
||
/// every PDF self-contained for the customer even if terms were added to the template after the
|
||
/// invoice was created.
|
||
/// </summary>
|
||
private void ComposeInvoiceContent(IContainer container, InvoiceDto invoice, string accentColor, QuoteTemplateSettingsDto template)
|
||
{
|
||
container.Column(col =>
|
||
{
|
||
// Bill To
|
||
col.Item().PaddingTop(12).Row(row =>
|
||
{
|
||
row.RelativeItem().Column(c =>
|
||
{
|
||
c.Item().Text("BILL TO").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
|
||
c.Item().Text(invoice.CustomerName).Bold();
|
||
if (!string.IsNullOrWhiteSpace(invoice.CustomerEmail))
|
||
c.Item().Text(invoice.CustomerEmail).FontSize(9);
|
||
if (!string.IsNullOrWhiteSpace(invoice.CustomerPhone))
|
||
c.Item().Text(invoice.CustomerPhone).FontSize(9);
|
||
});
|
||
|
||
row.RelativeItem().Column(c =>
|
||
{
|
||
c.Item().Text("JOB REFERENCE").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
|
||
c.Item().Text($"Job #: {invoice.JobNumber}");
|
||
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
|
||
c.Item().Text($"PO #: {invoice.CustomerPO}");
|
||
});
|
||
});
|
||
|
||
// Line items header
|
||
col.Item().PaddingTop(16).Table(table =>
|
||
{
|
||
table.ColumnsDefinition(cols =>
|
||
{
|
||
cols.RelativeColumn(5);
|
||
cols.RelativeColumn(1);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
});
|
||
|
||
table.Header(h =>
|
||
{
|
||
h.Cell().Background(accentColor).Padding(4).Text("Description").FontColor(Colors.White).Bold().FontSize(9);
|
||
h.Cell().Background(accentColor).Padding(4).AlignCenter().Text("Qty").FontColor(Colors.White).Bold().FontSize(9);
|
||
h.Cell().Background(accentColor).Padding(4).AlignRight().Text("Unit Price").FontColor(Colors.White).Bold().FontSize(9);
|
||
h.Cell().Background(accentColor).Padding(4).AlignRight().Text("Total").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(4).Column(c =>
|
||
{
|
||
c.Item().Text(item.Description).FontSize(9);
|
||
if (!string.IsNullOrWhiteSpace(item.ColorName))
|
||
c.Item().Text(item.ColorName).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
table.Cell().Background(bg).Padding(4).AlignCenter().Text(item.Quantity.ToString("G")).FontSize(9);
|
||
table.Cell().Background(bg).Padding(4).AlignRight().Text(item.UnitPrice.ToString("C")).FontSize(9);
|
||
table.Cell().Background(bg).Padding(4).AlignRight().Text(item.TotalPrice.ToString("C")).FontSize(9);
|
||
rowAlt = !rowAlt;
|
||
}
|
||
});
|
||
|
||
// Totals
|
||
col.Item().PaddingTop(8).AlignRight().Column(c =>
|
||
{
|
||
c.Item().Row(r =>
|
||
{
|
||
r.RelativeItem();
|
||
r.ConstantItem(150).Row(rr =>
|
||
{
|
||
rr.RelativeItem().Text("Subtotal:").FontSize(9);
|
||
rr.ConstantItem(80).AlignRight().Text(invoice.SubTotal.ToString("C")).FontSize(9);
|
||
});
|
||
});
|
||
if (invoice.DiscountAmount > 0)
|
||
{
|
||
c.Item().Row(r =>
|
||
{
|
||
r.RelativeItem();
|
||
r.ConstantItem(150).Row(rr =>
|
||
{
|
||
rr.RelativeItem().Text("Discount:").FontSize(9).FontColor(Colors.Green.Darken1);
|
||
rr.ConstantItem(80).AlignRight().Text($"({invoice.DiscountAmount:C})").FontSize(9).FontColor(Colors.Green.Darken1);
|
||
});
|
||
});
|
||
}
|
||
if (invoice.TaxPercent > 0)
|
||
{
|
||
c.Item().Row(r =>
|
||
{
|
||
r.RelativeItem();
|
||
r.ConstantItem(150).Row(rr =>
|
||
{
|
||
rr.RelativeItem().Text($"Tax ({invoice.TaxPercent:G}%):").FontSize(9);
|
||
rr.ConstantItem(80).AlignRight().Text(invoice.TaxAmount.ToString("C")).FontSize(9);
|
||
});
|
||
});
|
||
}
|
||
c.Item().Row(r =>
|
||
{
|
||
r.RelativeItem();
|
||
r.ConstantItem(150).Background(accentColor).Padding(4).Row(rr =>
|
||
{
|
||
rr.RelativeItem().Text("TOTAL:").FontSize(10).Bold().FontColor(Colors.White);
|
||
rr.ConstantItem(80).AlignRight().Text(invoice.Total.ToString("C")).FontSize(10).Bold().FontColor(Colors.White);
|
||
});
|
||
});
|
||
if (invoice.AmountPaid > 0)
|
||
{
|
||
c.Item().Row(r =>
|
||
{
|
||
r.RelativeItem();
|
||
r.ConstantItem(150).Row(rr =>
|
||
{
|
||
rr.RelativeItem().Text("Amount Paid:").FontSize(9).FontColor(Colors.Green.Darken1);
|
||
rr.ConstantItem(80).AlignRight().Text(invoice.AmountPaid.ToString("C")).FontSize(9).FontColor(Colors.Green.Darken1);
|
||
});
|
||
});
|
||
c.Item().Row(r =>
|
||
{
|
||
r.RelativeItem();
|
||
r.ConstantItem(150).Row(rr =>
|
||
{
|
||
rr.RelativeItem().Text("Balance Due:").FontSize(10).Bold();
|
||
rr.ConstantItem(80).AlignRight().Text(invoice.BalanceDue.ToString("C")).FontSize(10).Bold();
|
||
});
|
||
});
|
||
}
|
||
});
|
||
|
||
// Notes
|
||
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);
|
||
});
|
||
}
|
||
var termsToShow = !string.IsNullOrWhiteSpace(invoice.Terms) ? invoice.Terms : template.DefaultTerms;
|
||
if (!string.IsNullOrWhiteSpace(termsToShow))
|
||
{
|
||
col.Item().PaddingTop(8).Column(c =>
|
||
{
|
||
c.Item().Text("Payment Terms").Bold().FontSize(9);
|
||
c.Item().Text(termsToShow).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates and normalises a hex colour string for use with QuestPDF's string-based colour API.
|
||
/// QuestPDF accepts CSS-style hex strings ("#RRGGBB") but does not validate them at call-time;
|
||
/// passing a malformed value silently produces black. This method strips a leading '#', confirms
|
||
/// exactly six valid hexadecimal characters, and returns the normalised string — or the
|
||
/// <paramref name="fallback"/> constant when the input is absent or malformed.
|
||
/// </summary>
|
||
private static string ResolveColor(string? hex, string fallback)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(hex)) return fallback;
|
||
// Validate: must be a 6-digit hex color
|
||
var h = hex.TrimStart('#');
|
||
if (h.Length == 6 && h.All(c => (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')))
|
||
return "#" + h;
|
||
return fallback;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Formats a raw phone string as <c>(XXX) XXX-XXXX</c> for US ten-digit numbers, stripping
|
||
/// all non-numeric characters first. Inputs that are not exactly ten digits are returned as-is
|
||
/// to preserve international or extension-bearing numbers stored in the database without
|
||
/// corrupting them on the printed document.
|
||
/// </summary>
|
||
private string FormatPhoneNumber(string? phone)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(phone)) return string.Empty;
|
||
|
||
// Remove all non-numeric characters
|
||
var digits = new string(phone.Where(char.IsDigit).ToArray());
|
||
|
||
// Format as (XXX) XXX-XXXX for 10-digit numbers
|
||
if (digits.Length == 10)
|
||
{
|
||
return $"({digits.Substring(0, 3)}) {digits.Substring(3, 3)}-{digits.Substring(6, 4)}";
|
||
}
|
||
|
||
// Return original if not 10 digits
|
||
return phone;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Composes the shared quote-page header used by <see cref="GenerateQuotePdfAsync"/>.
|
||
/// Renders the company logo (passed as raw bytes so this service has no file-system dependency)
|
||
/// at a capped height of 60pt on the left, and formatted contact details on the right.
|
||
/// When no logo is provided the company name is rendered in the accent colour instead, so the
|
||
/// header always looks intentional whether or not a logo has been uploaded.
|
||
/// </summary>
|
||
private void ComposeHeader(IContainer container, byte[]? companyLogo, string? companyLogoContentType, CompanyInfoDto companyInfo, string accentColor)
|
||
{
|
||
container.Row(row =>
|
||
{
|
||
// Left: Company logo or name
|
||
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);
|
||
}
|
||
});
|
||
|
||
// Right: Company contact info
|
||
row.RelativeItem().AlignRight().Column(column =>
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||
column.Item().Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(9);
|
||
|
||
if (!string.IsNullOrWhiteSpace(companyInfo.Address))
|
||
column.Item().Text(companyInfo.Address).FontSize(9);
|
||
|
||
var cityStateZip = new List<string>();
|
||
if (!string.IsNullOrWhiteSpace(companyInfo.City)) cityStateZip.Add(companyInfo.City);
|
||
if (!string.IsNullOrWhiteSpace(companyInfo.State)) cityStateZip.Add(companyInfo.State);
|
||
if (!string.IsNullOrWhiteSpace(companyInfo.ZipCode)) cityStateZip.Add(companyInfo.ZipCode);
|
||
if (cityStateZip.Any())
|
||
column.Item().Text(string.Join(", ", cityStateZip)).FontSize(9);
|
||
|
||
if (!string.IsNullOrWhiteSpace(companyInfo.PrimaryContactEmail))
|
||
column.Item().Text(companyInfo.PrimaryContactEmail).FontSize(9);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Composes the full body of the quote PDF below the header.
|
||
/// Orchestrates the sequence of sub-composers: customer/prospect info and quote metadata in a
|
||
/// two-column row, line-items table, right-aligned pricing summary, and optional terms and notes
|
||
/// sections. Terms fall back to <see cref="QuoteTemplateSettingsDto.DefaultTerms"/> and notes fall
|
||
/// back to <see cref="QuoteTemplateSettingsDto.FooterNote"/> so the document remains useful even
|
||
/// when those fields are blank on the individual quote.
|
||
/// </summary>
|
||
private void ComposeContent(IContainer container, QuoteDto quote, byte[]? preparedByPhoto, QuoteTemplateSettingsDto template, string accentColor)
|
||
{
|
||
container.Column(column =>
|
||
{
|
||
// Quote number
|
||
column.Item().PaddingTop(15).Text($"Quote #: {quote.QuoteNumber}")
|
||
.FontSize(12)
|
||
.Bold();
|
||
|
||
column.Item().PaddingTop(10);
|
||
|
||
// Two-column info section
|
||
column.Item().Row(row =>
|
||
{
|
||
// Left: Customer/Prospect info
|
||
row.RelativeItem().Column(leftCol =>
|
||
{
|
||
leftCol.Item().Text(quote.CustomerId.HasValue ? "CUSTOMER INFORMATION" : "PROSPECT INFORMATION")
|
||
.FontSize(11)
|
||
.Bold()
|
||
.FontColor(accentColor);
|
||
|
||
leftCol.Item().PaddingTop(5).Element(c => ComposeCustomerInfo(c, quote));
|
||
});
|
||
|
||
row.ConstantItem(20);
|
||
|
||
// Right: Quote details
|
||
row.RelativeItem().Column(rightCol =>
|
||
{
|
||
rightCol.Item().Text("QUOTE DETAILS")
|
||
.FontSize(11)
|
||
.Bold()
|
||
.FontColor(accentColor);
|
||
|
||
rightCol.Item().PaddingTop(5).Element(c => ComposeQuoteDetails(c, quote));
|
||
});
|
||
});
|
||
|
||
column.Item().PaddingTop(15);
|
||
|
||
// Line items table
|
||
column.Item().Element(c => ComposeLineItems(c, quote, accentColor));
|
||
|
||
column.Item().PaddingTop(10);
|
||
|
||
// Pricing summary
|
||
column.Item().AlignRight().Width(250).Element(c => ComposePricingSummary(c, quote));
|
||
|
||
// Terms section: use quote's own terms, fall back to company default terms
|
||
var termsToShow = !string.IsNullOrWhiteSpace(quote.Terms) ? quote.Terms : template.DefaultTerms;
|
||
if (!string.IsNullOrWhiteSpace(termsToShow))
|
||
{
|
||
column.Item().PaddingTop(15).Element(c => ComposeTerms(c, termsToShow, accentColor));
|
||
}
|
||
|
||
// Description/Notes: use quote's own description, fall back to footer note
|
||
var notesToShow = !string.IsNullOrWhiteSpace(quote.Description) ? quote.Description : template.FooterNote;
|
||
if (!string.IsNullOrWhiteSpace(notesToShow))
|
||
{
|
||
column.Item().PaddingTop(10).Element(c => ComposeNotes(c, notesToShow, accentColor));
|
||
}
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Renders the customer or prospect address block in the quote PDF.
|
||
/// Branches on <see cref="QuoteDto.CustomerId"/>: existing customers use the joined
|
||
/// <c>CustomerCompanyName</c> / contact name / email / phone fields from the Customer entity,
|
||
/// while prospects use the denormalised <c>Prospect*</c> fields stored directly on the quote
|
||
/// (because prospects are not yet in the Customers table). This branching ensures the correct
|
||
/// data source regardless of how the quote was created.
|
||
/// </summary>
|
||
private void ComposeCustomerInfo(IContainer container, QuoteDto quote)
|
||
{
|
||
container.Column(column =>
|
||
{
|
||
if (quote.CustomerId.HasValue)
|
||
{
|
||
// Existing customer
|
||
var contactName = new List<string>();
|
||
if (!string.IsNullOrWhiteSpace(quote.CustomerContactFirstName)) contactName.Add(quote.CustomerContactFirstName);
|
||
if (!string.IsNullOrWhiteSpace(quote.CustomerContactLastName)) contactName.Add(quote.CustomerContactLastName);
|
||
var contactFullName = string.Join(" ", contactName);
|
||
|
||
// Only show company name when it's distinct from the contact's full name.
|
||
// Non-commercial customers store the person's name in CompanyName, which would
|
||
// otherwise render twice (e.g. "Bryndon Lee" bold + "Bryndon Lee" regular).
|
||
if (!string.IsNullOrWhiteSpace(quote.CustomerCompanyName) &&
|
||
!string.Equals(quote.CustomerCompanyName.Trim(), contactFullName.Trim(), StringComparison.OrdinalIgnoreCase))
|
||
column.Item().Text(quote.CustomerCompanyName).FontSize(10).Bold();
|
||
|
||
if (contactName.Any())
|
||
column.Item().Text(contactFullName).FontSize(9);
|
||
|
||
if (!string.IsNullOrWhiteSpace(quote.CustomerEmail))
|
||
column.Item().Text(quote.CustomerEmail).FontSize(9);
|
||
|
||
if (!string.IsNullOrWhiteSpace(quote.CustomerPhone))
|
||
column.Item().Text(FormatPhoneNumber(quote.CustomerPhone)).FontSize(9);
|
||
}
|
||
else
|
||
{
|
||
// Prospect
|
||
if (!string.IsNullOrWhiteSpace(quote.ProspectCompanyName))
|
||
column.Item().Text(quote.ProspectCompanyName).FontSize(10).Bold();
|
||
|
||
if (!string.IsNullOrWhiteSpace(quote.ProspectContactName))
|
||
column.Item().Text(quote.ProspectContactName).FontSize(9);
|
||
|
||
if (!string.IsNullOrWhiteSpace(quote.ProspectEmail))
|
||
column.Item().Text(quote.ProspectEmail).FontSize(9);
|
||
|
||
if (!string.IsNullOrWhiteSpace(quote.ProspectPhone))
|
||
column.Item().Text(FormatPhoneNumber(quote.ProspectPhone)).FontSize(9);
|
||
}
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Renders the right-hand "Quote Details" metadata panel: quote date, expiration date, sent date,
|
||
/// approved date, and customer PO number. Each field is only printed when it has a value, keeping
|
||
/// the panel compact for simple quotes and fully informative for quotes that have progressed
|
||
/// through the approval workflow.
|
||
/// </summary>
|
||
private void ComposeQuoteDetails(IContainer container, QuoteDto quote)
|
||
{
|
||
container.Column(column =>
|
||
{
|
||
column.Item().Row(row =>
|
||
{
|
||
row.ConstantItem(80).Text("Quote Date:").FontSize(9);
|
||
row.RelativeItem().Text(quote.QuoteDate.ToString("MMM dd, yyyy")).FontSize(9);
|
||
});
|
||
|
||
column.Item().Row(row =>
|
||
{
|
||
row.ConstantItem(80).Text("Valid Until:").FontSize(9);
|
||
row.RelativeItem().Text(quote.ExpirationDate?.ToString("MMM dd, yyyy") ?? "N/A").FontSize(9);
|
||
});
|
||
|
||
if (quote.SentDate.HasValue)
|
||
{
|
||
column.Item().Row(row =>
|
||
{
|
||
row.ConstantItem(80).Text("Sent Date:").FontSize(9);
|
||
row.RelativeItem().Text(quote.SentDate.Value.ToString("MMM dd, yyyy")).FontSize(9);
|
||
});
|
||
}
|
||
|
||
if (quote.ApprovedDate.HasValue)
|
||
{
|
||
column.Item().Row(row =>
|
||
{
|
||
row.ConstantItem(80).Text("Approved:").FontSize(9);
|
||
row.RelativeItem().Text(quote.ApprovedDate.Value.ToString("MMM dd, yyyy")).FontSize(9);
|
||
});
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(quote.CustomerPO))
|
||
{
|
||
column.Item().Row(row =>
|
||
{
|
||
row.ConstantItem(80).Text("PO Number:").FontSize(9);
|
||
row.RelativeItem().Text(quote.CustomerPO).FontSize(9);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Renders the "Prepared By" card with the sales rep's avatar, name, and email.
|
||
/// The avatar is embedded as raw bytes (loaded by the controller from disk before the PDF call)
|
||
/// and displayed at 40×40pt; if no photo is available the card still renders without blank space
|
||
/// by simply omitting the image column. This component is currently wired into
|
||
/// <see cref="ComposeContent"/> but the call site guards on <c>preparedByPhoto != null</c>.
|
||
/// </summary>
|
||
private void ComposePreparedBy(IContainer container, QuoteDto quote, byte[]? preparedByPhoto)
|
||
{
|
||
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(8).Row(row =>
|
||
{
|
||
if (preparedByPhoto != null && preparedByPhoto.Length > 0)
|
||
{
|
||
row.ConstantItem(40, Unit.Point).Height(40).Image(preparedByPhoto);
|
||
row.ConstantItem(10, Unit.Point);
|
||
}
|
||
|
||
row.RelativeItem().Column(column =>
|
||
{
|
||
column.Item().Text("Prepared By").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
column.Item().Text(quote.PreparedByName ?? "N/A").FontSize(10).Bold();
|
||
|
||
if (!string.IsNullOrWhiteSpace(quote.PreparedByEmail))
|
||
column.Item().Text(quote.PreparedByEmail).FontSize(9);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Renders the quote line-items table with alternating row shading.
|
||
/// Each item cell is a sub-column that prints the item description (preferring the explicit
|
||
/// description over a default "Product Item" label for catalog items), then iterates the ordered
|
||
/// coat list to show colour name or inventory powder name, colour code, and finish on separate
|
||
/// sub-rows. The "Color" summary column shows the single colour name for single-coat items or
|
||
/// "<n> Coats" for multi-coat items, since repeating all coat names in that narrow column
|
||
/// would make the row illegible. Inventory item names are preferred over free-text colour names
|
||
/// to match the language used in the job and inventory modules.
|
||
/// </summary>
|
||
private void ComposeLineItems(IContainer container, QuoteDto quote, string accentColor)
|
||
{
|
||
var allItems = quote.QuoteItems?.OrderBy(i => i.Id).ToList() ?? new List<QuoteItemDto>();
|
||
|
||
container.Column(column =>
|
||
{
|
||
column.Item().Text("LINE ITEMS")
|
||
.FontSize(11)
|
||
.Bold()
|
||
.FontColor(accentColor);
|
||
|
||
if (allItems.Any())
|
||
{
|
||
column.Item().PaddingTop(10).Table(table =>
|
||
{
|
||
// Define columns: Description, Color, Qty, Unit Price, Total
|
||
table.ColumnsDefinition(columns =>
|
||
{
|
||
columns.RelativeColumn(4); // Description
|
||
columns.RelativeColumn(2); // Color
|
||
columns.ConstantColumn(40); // Qty
|
||
columns.ConstantColumn(80); // Unit Price
|
||
columns.ConstantColumn(80); // Total
|
||
});
|
||
|
||
// Header
|
||
table.Header(header =>
|
||
{
|
||
header.Cell().Background(accentColor).Padding(5).Text("Description").FontSize(9).Bold().FontColor(Colors.White);
|
||
header.Cell().Background(accentColor).Padding(5).Text("Color").FontSize(9).Bold().FontColor(Colors.White);
|
||
header.Cell().Background(accentColor).Padding(5).Text("Qty").FontSize(9).Bold().FontColor(Colors.White);
|
||
header.Cell().Background(accentColor).Padding(5).AlignRight().Text("Unit Price").FontSize(9).Bold().FontColor(Colors.White);
|
||
header.Cell().Background(accentColor).Padding(5).AlignRight().Text("Total").FontSize(9).Bold().FontColor(Colors.White);
|
||
});
|
||
|
||
// All items (unified)
|
||
var index = 0;
|
||
foreach (var item in allItems)
|
||
{
|
||
var bgColor = index % 2 == 0 ? Colors.White : Colors.Grey.Lighten4;
|
||
|
||
// Description - show product name for catalog items, or custom description
|
||
table.Cell().Background(bgColor).Padding(5).Column(descCol =>
|
||
{
|
||
string displayDescription;
|
||
if (item.CatalogItemId.HasValue)
|
||
{
|
||
// For catalog items, show product name
|
||
displayDescription = item.CatalogItemName ?? "Catalog Item";
|
||
// If there's a custom description (not "Product Item"), use that instead
|
||
if (!string.IsNullOrWhiteSpace(item.Description) && item.Description != "Product Item")
|
||
{
|
||
displayDescription = item.Description;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// For calculated items, use the description
|
||
displayDescription = item.Description ?? "Custom Item";
|
||
}
|
||
|
||
descCol.Item().Text(displayDescription).FontSize(9).Bold();
|
||
|
||
// Show coating layers if present
|
||
if (item.Coats != null && item.Coats.Any())
|
||
{
|
||
descCol.Item().PaddingTop(2).Text("Coating Layers:")
|
||
.FontSize(7)
|
||
.FontColor(Colors.Grey.Darken2)
|
||
.Italic();
|
||
|
||
foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
||
{
|
||
var coatText = $" • {coat.CoatName}";
|
||
|
||
// Show inventory powder name (preferred) or custom color name
|
||
if (!string.IsNullOrEmpty(coat.InventoryItemName))
|
||
{
|
||
coatText += $" - {coat.InventoryItemName}";
|
||
}
|
||
else if (!string.IsNullOrEmpty(coat.ColorName))
|
||
{
|
||
coatText += $" - {coat.ColorName}";
|
||
}
|
||
|
||
// Add color code if present
|
||
if (!string.IsNullOrEmpty(coat.ColorCode))
|
||
{
|
||
coatText += $" ({coat.ColorCode})";
|
||
}
|
||
|
||
// Add finish type if present
|
||
if (!string.IsNullOrEmpty(coat.Finish))
|
||
{
|
||
coatText += $" - {coat.Finish}";
|
||
}
|
||
|
||
descCol.Item().Text(coatText)
|
||
.FontSize(7)
|
||
.FontColor(Colors.Grey.Darken1);
|
||
}
|
||
}
|
||
|
||
// Show notes if present
|
||
if (!string.IsNullOrWhiteSpace(item.Notes))
|
||
{
|
||
descCol.Item().PaddingTop(2).Text(item.Notes)
|
||
.FontSize(7)
|
||
.FontColor(Colors.Grey.Darken1)
|
||
.Italic();
|
||
}
|
||
});
|
||
|
||
// Color - show count if multiple coats, or first coat color
|
||
string colorText;
|
||
if (item.Coats == null || !item.Coats.Any())
|
||
{
|
||
colorText = "-";
|
||
}
|
||
else if (item.Coats.Count > 1)
|
||
{
|
||
colorText = $"{item.Coats.Count} Coats";
|
||
}
|
||
else
|
||
{
|
||
var firstCoat = item.Coats.First();
|
||
// Prefer inventory item name over color name
|
||
colorText = firstCoat.InventoryItemName ?? firstCoat.ColorName ?? "-";
|
||
}
|
||
table.Cell().Background(bgColor).Padding(5).Text(colorText).FontSize(9);
|
||
|
||
// Quantity (centered)
|
||
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.Quantity.ToString()).FontSize(9);
|
||
|
||
// Unit Price (right-aligned)
|
||
table.Cell().Background(bgColor).Padding(5).AlignRight().Text($"${item.UnitPrice:N2}").FontSize(9);
|
||
|
||
// Total (right-aligned, bold)
|
||
table.Cell().Background(bgColor).Padding(5).AlignRight().Text($"${item.TotalPrice:N2}").FontSize(9).Bold();
|
||
|
||
index++;
|
||
}
|
||
});
|
||
}
|
||
else
|
||
{
|
||
column.Item().PaddingTop(10).Text("No items in this quote.")
|
||
.FontSize(9)
|
||
.FontColor(Colors.Grey.Darken1)
|
||
.Italic();
|
||
}
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Renders the right-aligned pricing summary box at the bottom of the quote content area.
|
||
/// Shows only the rows that are non-zero (shop supplies surcharge, discount, rush fee, tax) to
|
||
/// keep the summary clean for simple jobs. The grand total line is styled bold and green to draw
|
||
/// the customer's eye to the final price. Uses <see cref="QuoteDto.PricingBreakdown"/> for the
|
||
/// detailed sub-items that are not directly on the top-level DTO.
|
||
/// </summary>
|
||
private void ComposePricingSummary(IContainer container, QuoteDto quote)
|
||
{
|
||
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
|
||
{
|
||
// Customer-facing summary (simplified)
|
||
if (quote.PricingBreakdown?.ShopSuppliesAmount > 0)
|
||
{
|
||
column.Item().Row(row =>
|
||
{
|
||
row.RelativeItem().Text($"Shop Supplies ({quote.PricingBreakdown.ShopSuppliesPercent:F1}%):").FontSize(9);
|
||
row.RelativeItem().AlignRight().Text($"${quote.PricingBreakdown.ShopSuppliesAmount:N2}").FontSize(9);
|
||
});
|
||
}
|
||
|
||
column.Item().Row(row =>
|
||
{
|
||
row.RelativeItem().Text("Subtotal:").FontSize(9);
|
||
row.RelativeItem().AlignRight().Text($"${quote.SubTotal:N2}").FontSize(9);
|
||
});
|
||
|
||
if (quote.DiscountPercent > 0 && !quote.HideDiscountFromCustomer)
|
||
{
|
||
column.Item().Row(row =>
|
||
{
|
||
row.RelativeItem().Text($"Discount ({quote.DiscountPercent:F1}%):").FontSize(9);
|
||
row.RelativeItem().AlignRight().Text($"-${quote.DiscountAmount:N2}").FontSize(9).FontColor(Colors.Red.Darken2);
|
||
});
|
||
}
|
||
|
||
if (quote.PricingBreakdown?.RushFee > 0)
|
||
{
|
||
column.Item().Row(row =>
|
||
{
|
||
row.RelativeItem().Text("Rush Charge:").FontSize(9).FontColor(Colors.Orange.Darken2);
|
||
row.RelativeItem().AlignRight().Text($"${quote.PricingBreakdown.RushFee:N2}").FontSize(9).FontColor(Colors.Orange.Darken2);
|
||
});
|
||
}
|
||
|
||
if (quote.TaxPercent > 0)
|
||
{
|
||
column.Item().Row(row =>
|
||
{
|
||
row.RelativeItem().Text($"Tax ({quote.TaxPercent:F1}%):").FontSize(9);
|
||
row.RelativeItem().AlignRight().Text($"${quote.TaxAmount:N2}").FontSize(9);
|
||
});
|
||
}
|
||
|
||
column.Item().PaddingVertical(5).LineHorizontal(1).LineColor(Colors.Grey.Darken1);
|
||
|
||
column.Item().Row(row =>
|
||
{
|
||
row.RelativeItem().Text("TOTAL:").FontSize(11).Bold();
|
||
row.RelativeItem().AlignRight().Text($"${quote.Total:N2}").FontSize(11).Bold().FontColor(Colors.Green.Darken3);
|
||
});
|
||
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Renders the "Terms & Conditions" section as a bordered box with the accent-coloured
|
||
/// heading and the terms text at 8pt with 1.3× line height for readability of multi-line legal text.
|
||
/// The section is only called when <paramref name="terms"/> is non-empty; the caller
|
||
/// (<see cref="ComposeContent"/>) is responsible for the conditional guard.
|
||
/// </summary>
|
||
private void ComposeTerms(IContainer container, string terms, string accentColor)
|
||
{
|
||
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(8).Column(column =>
|
||
{
|
||
column.Item().Text("TERMS & CONDITIONS").FontSize(9).Bold().FontColor(accentColor);
|
||
column.Item().PaddingTop(3).Text(terms).FontSize(8).LineHeight(1.3f);
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Renders the "Additional Notes" section, visually identical to <see cref="ComposeTerms"/> but
|
||
/// labelled differently. Notes come from the quote's own <c>Description</c> field, falling back
|
||
/// to <see cref="QuoteTemplateSettingsDto.FooterNote"/> when the quote description is blank.
|
||
/// Kept as a separate method (rather than reusing ComposeTerms with a label parameter) to make
|
||
/// future per-section styling changes independent.
|
||
/// </summary>
|
||
private void ComposeNotes(IContainer container, string notes, string accentColor)
|
||
{
|
||
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(8).Column(column =>
|
||
{
|
||
column.Item().Text("ADDITIONAL NOTES").FontSize(9).Bold().FontColor(accentColor);
|
||
column.Item().PaddingTop(3).Text(notes).FontSize(8).LineHeight(1.3f);
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Generates a product catalog PDF grouped by <see cref="CatalogCategory"/>, returning the raw bytes.
|
||
/// Items are pre-grouped by the caller so this service stays free of database access.
|
||
/// Each category is wrapped in QuestPDF's <c>ShowEntire()</c> container which attempts to keep
|
||
/// the category block on a single page; if it overflows QuestPDF moves the entire block to the
|
||
/// next page rather than splitting it mid-category. A summary row at the end shows total category
|
||
/// and item counts for quick reference.
|
||
/// </summary>
|
||
public async Task<byte[]> GenerateCatalogPdfAsync(
|
||
IEnumerable<IGrouping<CatalogCategory, CatalogItem>> itemsByCategory,
|
||
string companyName,
|
||
byte[]? companyLogo,
|
||
string? companyLogoContentType)
|
||
{
|
||
// Configure QuestPDF license
|
||
QuestPDF.Settings.License = LicenseType.Community;
|
||
|
||
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 => ComposeCatalogHeader(c, companyName, companyLogo, companyLogoContentType));
|
||
page.Content().Element(c => ComposeCatalogContent(c, itemsByCategory));
|
||
page.Footer().AlignCenter().Text(text =>
|
||
{
|
||
text.CurrentPageNumber();
|
||
text.Span(" / ");
|
||
text.TotalPages();
|
||
text.Span($" - {companyName} Product Catalog");
|
||
});
|
||
});
|
||
});
|
||
|
||
return document.GeneratePdf();
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Composes the catalog page header with company branding on the left and "PRODUCT CATALOG"
|
||
/// plus generation date on the right. The generation timestamp is embedded so recipients can
|
||
/// identify stale printed copies; the date uses <c>DateTime.Now</c> (server time) which is
|
||
/// acceptable because catalog PDFs are generated on demand and not stored.
|
||
/// </summary>
|
||
private void ComposeCatalogHeader(IContainer container, string companyName, byte[]? companyLogo, string? companyLogoContentType)
|
||
{
|
||
container.Row(row =>
|
||
{
|
||
// Left: Company logo or name
|
||
row.RelativeItem().Column(column =>
|
||
{
|
||
if (companyLogo != null && companyLogo.Length > 0)
|
||
{
|
||
column.Item().MaxHeight(60).Image(companyLogo);
|
||
}
|
||
else
|
||
{
|
||
column.Item().Text(companyName)
|
||
.FontSize(18)
|
||
.Bold()
|
||
.FontColor(Colors.Blue.Darken3);
|
||
}
|
||
});
|
||
|
||
// Right: Title
|
||
row.RelativeItem().AlignRight().Column(column =>
|
||
{
|
||
column.Item().Text("PRODUCT CATALOG")
|
||
.FontSize(20)
|
||
.Bold()
|
||
.FontColor(Colors.Grey.Darken3);
|
||
|
||
column.Item().Text($"Generated: {DateTime.Now:MMMM dd, yyyy}")
|
||
.FontSize(9)
|
||
.FontColor(Colors.Grey.Darken1);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Composes the catalog body: iterates each category group, renders a QuestPDF table whose header
|
||
/// row carries both the category name band and the column labels so both repeat on overflow pages.
|
||
/// Items within a category are sorted by name for alphabetical scanning. The two-row header
|
||
/// technique (category name + column labels both in <c>table.Header</c>) is the idiomatic
|
||
/// QuestPDF pattern for repeating group headings across page breaks.
|
||
/// </summary>
|
||
private void ComposeCatalogContent(IContainer container, IEnumerable<IGrouping<CatalogCategory, CatalogItem>> itemsByCategory)
|
||
{
|
||
container.Column(column =>
|
||
{
|
||
column.Item().PaddingTop(20);
|
||
|
||
foreach (var categoryGroup in itemsByCategory)
|
||
{
|
||
// Wrap category in a container that tries to keep it together on one page
|
||
// QuestPDF will automatically move it to next page if it doesn't fit
|
||
column.Item().ShowEntire().Column(categoryColumn =>
|
||
{
|
||
// Items table with category header integrated
|
||
categoryColumn.Item().Table(table =>
|
||
{
|
||
// Define columns
|
||
table.ColumnsDefinition(columns =>
|
||
{
|
||
columns.RelativeColumn(4); // Item Name
|
||
columns.RelativeColumn(6); // Description
|
||
columns.ConstantColumn(100); // Price
|
||
});
|
||
|
||
// Header - will repeat on each page if category spans multiple pages
|
||
table.Header(header =>
|
||
{
|
||
// Category name row (spans all columns, repeats on each page)
|
||
header.Cell().ColumnSpan(3).Background(Colors.Blue.Darken2).Padding(8).Row(row =>
|
||
{
|
||
row.RelativeItem().Text(categoryGroup.Key.Name)
|
||
.FontSize(14)
|
||
.Bold()
|
||
.FontColor(Colors.White);
|
||
|
||
row.ConstantItem(80).AlignRight().Text($"{categoryGroup.Count()} items")
|
||
.FontSize(10)
|
||
.FontColor(Colors.White);
|
||
});
|
||
|
||
// Column headers row (repeats on each page)
|
||
header.Cell().Background(Colors.Grey.Lighten2).Padding(5).Text("Item Name").FontSize(9).Bold();
|
||
header.Cell().Background(Colors.Grey.Lighten2).Padding(5).Text("Description").FontSize(9).Bold();
|
||
header.Cell().Background(Colors.Grey.Lighten2).Padding(5).AlignRight().Text("Price").FontSize(9).Bold();
|
||
});
|
||
|
||
// Items
|
||
var index = 0;
|
||
foreach (var item in categoryGroup.OrderBy(i => i.Name))
|
||
{
|
||
var bgColor = index % 2 == 0 ? Colors.White : Colors.Grey.Lighten4;
|
||
|
||
table.Cell().Background(bgColor).Padding(5).Text(item.Name ?? "").FontSize(9).Bold();
|
||
|
||
table.Cell().Background(bgColor).Padding(5).Text(item.Description ?? "").FontSize(9);
|
||
|
||
table.Cell().Background(bgColor).Padding(5).AlignRight().Text($"${item.DefaultPrice:N2}").FontSize(9).Bold();
|
||
|
||
index++;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Summary at the end
|
||
column.Item().PaddingTop(20).Border(1).BorderColor(Colors.Blue.Darken2).Padding(10).Row(row =>
|
||
{
|
||
var totalItems = itemsByCategory.Sum(g => g.Count());
|
||
var totalCategories = itemsByCategory.Count();
|
||
|
||
row.RelativeItem().Column(col =>
|
||
{
|
||
col.Item().Text("Catalog Summary").FontSize(12).Bold().FontColor(Colors.Blue.Darken3);
|
||
col.Item().PaddingTop(5).Text($"Total Categories: {totalCategories}").FontSize(9);
|
||
col.Item().Text($"Total Items: {totalItems}").FontSize(9);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Financial Reports ─────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Generates a Profit & Loss statement PDF for the date range stored in <paramref name="dto"/>.
|
||
/// Renders a KPI summary band (revenue, COGS, expenses, net income with colour-coded values),
|
||
/// then three report sections via <see cref="PlReportSection"/>: Revenue, Cost of Goods Sold,
|
||
/// and Operating Expenses. Gross Profit and Net Income summary rows are injected between sections
|
||
/// and colour-coded green/red to reflect profitability at a glance. All financial data is
|
||
/// pre-computed by the Reports controller — this method only handles layout.
|
||
/// </summary>
|
||
public async Task<byte[]> GenerateProfitAndLossPdfAsync(PowderCoating.Application.DTOs.Accounting.ProfitAndLossDto dto)
|
||
{
|
||
QuestPDF.Settings.License = LicenseType.Community;
|
||
const string accent = "#1a56db";
|
||
|
||
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 => ComposeReportHeader(c, dto.CompanyName, "Profit & Loss",
|
||
$"{dto.From:MMMM d, yyyy} – {dto.To:MMMM d, yyyy}", accent));
|
||
|
||
page.Content().PaddingTop(12).Column(col =>
|
||
{
|
||
// KPI summary band
|
||
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
|
||
{
|
||
KpiCell(row, "Total Revenue", dto.TotalRevenue.ToString("C"), "#16a34a");
|
||
KpiCell(row, "Cost of Goods", dto.TotalCogs.ToString("C"), "#ca8a04");
|
||
KpiCell(row, "Operating Expenses", dto.TotalExpenses.ToString("C"), "#dc2626");
|
||
KpiCell(row, "Net Income", dto.NetIncome.ToString("C"), dto.NetIncome >= 0 ? "#16a34a" : "#dc2626");
|
||
});
|
||
|
||
col.Item().PaddingTop(14).Element(c => PlReportSection(c, "REVENUE", dto.RevenueLines, dto.TotalRevenue, dto.TotalRevenue, "Total Revenue", "#16a34a", accent));
|
||
|
||
if (dto.CogsLines.Any())
|
||
{
|
||
col.Item().PaddingTop(10).Element(c => PlReportSection(c, "COST OF GOODS SOLD", dto.CogsLines, dto.TotalCogs, dto.TotalRevenue, "Total COGS", "#ca8a04", accent));
|
||
|
||
col.Item().PaddingTop(4).Row(row =>
|
||
{
|
||
row.RelativeItem(3).AlignRight().Text("Gross Profit").Bold().FontSize(10);
|
||
row.ConstantItem(110).AlignRight().Text(dto.GrossProfit.ToString("C")).Bold().FontSize(10)
|
||
.FontColor(dto.GrossProfit >= 0 ? "#16a34a" : "#dc2626");
|
||
row.ConstantItem(70).AlignRight().Text(dto.TotalRevenue == 0 ? "—" : $"{dto.GrossMarginPercent:F1}%")
|
||
.FontSize(9).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
}
|
||
|
||
col.Item().PaddingTop(10).Element(c => PlReportSection(c, "OPERATING EXPENSES", dto.ExpenseLines, dto.TotalExpenses, dto.TotalRevenue, "Total Expenses", "#dc2626", accent));
|
||
|
||
// Net Income row
|
||
col.Item().PaddingTop(6).Background("#e8f5e9").Padding(8).Row(row =>
|
||
{
|
||
row.RelativeItem(3).Text("NET INCOME").Bold().FontSize(11);
|
||
row.ConstantItem(110).AlignRight().Text(dto.NetIncome.ToString("C")).Bold().FontSize(11)
|
||
.FontColor(dto.NetIncome >= 0 ? "#16a34a" : "#dc2626");
|
||
row.ConstantItem(70).AlignRight()
|
||
.Text(dto.TotalRevenue == 0 ? "—" : $"{dto.NetIncome / dto.TotalRevenue * 100:F1}%")
|
||
.FontSize(9).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
});
|
||
|
||
page.Footer().AlignCenter().Text(text =>
|
||
{
|
||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
});
|
||
});
|
||
return document.GeneratePdf();
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Renders a single section (Revenue, COGS, or Expenses) of the P&L report as an accent-headed
|
||
/// block of alternating account rows followed by a bold subtotal row.
|
||
/// The right-most column shows each line's percentage of total revenue (<paramref name="revenueTotal"/>),
|
||
/// displaying "—" when revenue is zero to avoid a division-by-zero NaN in the PDF.
|
||
/// <paramref name="subtotalColor"/> is passed by the caller so each section's total row can be
|
||
/// colour-coded independently (green for revenue, amber for COGS, red for expenses).
|
||
/// </summary>
|
||
private void PlReportSection(IContainer container, string title, List<PowderCoating.Application.DTOs.Accounting.FinancialReportLine> lines,
|
||
decimal sectionTotal, decimal revenueTotal, string subtotalLabel, string subtotalColor, string accent)
|
||
{
|
||
container.Column(col =>
|
||
{
|
||
col.Item().Background(accent).Padding(4).Text(title).FontColor(Colors.White).Bold().FontSize(9);
|
||
|
||
if (!lines.Any())
|
||
{
|
||
col.Item().PaddingLeft(8).PaddingVertical(4).Text("No activity for this period.").FontSize(9).FontColor(Colors.Grey.Medium);
|
||
return;
|
||
}
|
||
|
||
var alt = false;
|
||
foreach (var line in lines)
|
||
{
|
||
col.Item().Background(alt ? "#f8fafc" : Colors.White).Row(row =>
|
||
{
|
||
row.RelativeItem(3).PaddingLeft(12).PaddingVertical(3).Text($"{line.AccountNumber} {line.AccountName}").FontSize(9);
|
||
row.ConstantItem(110).AlignRight().PaddingVertical(3).Text(line.Amount.ToString("C")).FontSize(9);
|
||
row.ConstantItem(70).AlignRight().PaddingVertical(3)
|
||
.Text(revenueTotal == 0 ? "—" : $"{line.Amount / revenueTotal * 100:F1}%").FontSize(8).FontColor(Colors.Grey.Medium);
|
||
});
|
||
alt = !alt;
|
||
}
|
||
|
||
col.Item().BorderTop(1).BorderColor("#dee2e6").Row(row =>
|
||
{
|
||
row.RelativeItem(3).PaddingLeft(12).PaddingVertical(4).Text(subtotalLabel).Bold().FontSize(9);
|
||
row.ConstantItem(110).AlignRight().PaddingVertical(4).Text(sectionTotal.ToString("C")).Bold().FontSize(9).FontColor(subtotalColor);
|
||
row.ConstantItem(70).AlignRight().PaddingVertical(4)
|
||
.Text(revenueTotal == 0 ? "—" : $"{sectionTotal / revenueTotal * 100:F1}%").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Generates a Balance Sheet PDF as of the date in <paramref name="dto"/>.
|
||
/// Uses a two-column layout (Assets left, Liabilities + Equity right) which matches the
|
||
/// traditional accounting presentation and makes the Assets = Liabilities + Equity identity
|
||
/// visually obvious on the page. A balance-check row at the bottom is coloured red with a
|
||
/// warning message when <see cref="PowderCoating.Application.DTOs.Accounting.BalanceSheetDto.IsBalanced"/> is false,
|
||
/// alerting accountants to a chart-of-accounts configuration issue without hiding the imbalance.
|
||
/// Sub-sections within each column are rendered by <see cref="BsSection"/>.
|
||
/// </summary>
|
||
public async Task<byte[]> GenerateBalanceSheetPdfAsync(PowderCoating.Application.DTOs.Accounting.BalanceSheetDto dto)
|
||
{
|
||
QuestPDF.Settings.License = LicenseType.Community;
|
||
const string accent = "#1a56db";
|
||
|
||
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 => ComposeReportHeader(c, dto.CompanyName, "Balance Sheet",
|
||
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
|
||
|
||
page.Content().PaddingTop(12).Column(col =>
|
||
{
|
||
// KPI band
|
||
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
|
||
{
|
||
KpiCell(row, "Total Assets", dto.TotalAssets.ToString("C"), "#1a56db");
|
||
KpiCell(row, "Total Liabilities", dto.TotalLiabilities.ToString("C"), "#dc2626");
|
||
KpiCell(row, "Total Equity", dto.TotalEquity.ToString("C"), "#16a34a");
|
||
});
|
||
|
||
// Two-column: Assets | Liabilities+Equity
|
||
col.Item().PaddingTop(14).Row(row =>
|
||
{
|
||
// Assets
|
||
row.RelativeItem().Column(assetCol =>
|
||
{
|
||
assetCol.Item().Background(accent).Padding(5).Text("ASSETS").FontColor(Colors.White).Bold().FontSize(10);
|
||
BsSection(assetCol, "Current Assets", dto.CurrentAssets);
|
||
BsSection(assetCol, "Fixed Assets", dto.FixedAssets);
|
||
BsSection(assetCol, "Other Assets", dto.OtherAssets);
|
||
assetCol.Item().Background("#dbeafe").Padding(5).Row(r =>
|
||
{
|
||
r.RelativeItem().Text("Total Assets").Bold().FontSize(10).FontColor("#1e40af");
|
||
r.ConstantItem(100).AlignRight().Text(dto.TotalAssets.ToString("C")).Bold().FontSize(10).FontColor("#1e40af");
|
||
});
|
||
});
|
||
|
||
row.ConstantItem(16); // gutter
|
||
|
||
// Liabilities + Equity
|
||
row.RelativeItem().Column(liabCol =>
|
||
{
|
||
liabCol.Item().Background("#dc2626").Padding(5).Text("LIABILITIES").FontColor(Colors.White).Bold().FontSize(10);
|
||
BsSection(liabCol, "Current Liabilities", dto.CurrentLiabilities);
|
||
BsSection(liabCol, "Long-Term Liabilities", dto.LongTermLiabilities);
|
||
liabCol.Item().Background("#fee2e2").Padding(5).Row(r =>
|
||
{
|
||
r.RelativeItem().Text("Total Liabilities").Bold().FontSize(9).FontColor("#991b1b");
|
||
r.ConstantItem(100).AlignRight().Text(dto.TotalLiabilities.ToString("C")).Bold().FontSize(9).FontColor("#991b1b");
|
||
});
|
||
|
||
liabCol.Item().PaddingTop(10).Background("#16a34a").Padding(5).Text("EQUITY").FontColor(Colors.White).Bold().FontSize(10);
|
||
foreach (var line in dto.EquityLines)
|
||
{
|
||
liabCol.Item().PaddingLeft(8).PaddingVertical(3).Row(r =>
|
||
{
|
||
r.RelativeItem().Text($"{line.AccountNumber} {line.AccountName}").FontSize(9);
|
||
r.ConstantItem(100).AlignRight().Text(line.Amount.ToString("C")).FontSize(9);
|
||
});
|
||
}
|
||
liabCol.Item().PaddingLeft(8).PaddingVertical(3).Row(r =>
|
||
{
|
||
r.RelativeItem().Text("Retained Earnings (Net Income)").FontSize(9).FontColor(Colors.Grey.Darken1);
|
||
r.ConstantItem(100).AlignRight().Text(dto.RetainedEarnings.ToString("C")).FontSize(9)
|
||
.FontColor(dto.RetainedEarnings < 0 ? "#dc2626" : Colors.Black);
|
||
});
|
||
liabCol.Item().Background("#dcfce7").Padding(5).Row(r =>
|
||
{
|
||
r.RelativeItem().Text("Total Equity").Bold().FontSize(9).FontColor("#166534");
|
||
r.ConstantItem(100).AlignRight().Text(dto.TotalEquity.ToString("C")).Bold().FontSize(9).FontColor("#166534");
|
||
});
|
||
|
||
liabCol.Item().PaddingTop(4).Background(dto.IsBalanced ? "#dcfce7" : "#fee2e2").Padding(5).Row(r =>
|
||
{
|
||
r.RelativeItem().Text("Total Liabilities & Equity").Bold().FontSize(10);
|
||
r.ConstantItem(100).AlignRight().Text(dto.TotalLiabilitiesAndEquity.ToString("C")).Bold().FontSize(10)
|
||
.FontColor(dto.IsBalanced ? "#166534" : "#dc2626");
|
||
});
|
||
|
||
if (!dto.IsBalanced)
|
||
liabCol.Item().PaddingTop(4).Text("⚠ Sheet does not balance — check account setup.").FontSize(8).FontColor("#dc2626");
|
||
});
|
||
});
|
||
});
|
||
|
||
page.Footer().AlignCenter().Text(text =>
|
||
{
|
||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
});
|
||
});
|
||
return document.GeneratePdf();
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Renders a named sub-section of the Balance Sheet (e.g. "Current Assets", "Fixed Assets")
|
||
/// as a light-grey header, alternating account rows, a subtotal border-top row, and a small
|
||
/// vertical spacer. Returns immediately when <paramref name="lines"/> is empty so the containing
|
||
/// column does not show an orphaned heading for accounts with no activity.
|
||
/// Called from <see cref="GenerateBalanceSheetPdfAsync"/> for both the Assets and Liabilities columns.
|
||
/// </summary>
|
||
private static void BsSection(ColumnDescriptor col, string title, List<PowderCoating.Application.DTOs.Accounting.FinancialReportLine> lines)
|
||
{
|
||
if (!lines.Any()) return;
|
||
|
||
col.Item().Background("#f1f5f9").PaddingLeft(4).PaddingVertical(3)
|
||
.Text(title.ToUpperInvariant()).FontSize(8).Bold().FontColor(Colors.Grey.Darken2);
|
||
|
||
var alt = false;
|
||
foreach (var line in lines)
|
||
{
|
||
col.Item().Background(alt ? "#f8fafc" : Colors.White).PaddingLeft(8).PaddingVertical(2).Row(r =>
|
||
{
|
||
r.RelativeItem().Text($"{line.AccountNumber} {line.AccountName}").FontSize(9);
|
||
r.ConstantItem(100).AlignRight().Text(line.Amount.ToString("C")).FontSize(9);
|
||
});
|
||
alt = !alt;
|
||
}
|
||
|
||
col.Item().BorderTop(1).BorderColor("#dee2e6").PaddingLeft(8).PaddingVertical(3).Row(r =>
|
||
{
|
||
r.RelativeItem().Text($"Total {title}").Bold().FontSize(9);
|
||
r.ConstantItem(100).AlignRight().Text(lines.Sum(l => l.Amount).ToString("C")).Bold().FontSize(9);
|
||
});
|
||
|
||
col.Item().PaddingVertical(2); // spacer
|
||
}
|
||
|
||
/// <summary>
|
||
/// Generates an Accounts Receivable Aging report PDF as of <see cref="PowderCoating.Application.DTOs.Accounting.ArAgingReportDto.AsOf"/>.
|
||
/// Renders a summary KPI band with five aging buckets (Current, 1-30, 31-60, 61-90, Over 90),
|
||
/// a customer-level summary table, and a per-customer invoice detail section that colour-codes
|
||
/// each invoice age from green (current) to dark red (over 90 days). The detail section uses
|
||
/// QuestPDF's <c>ShowEntire()</c> per customer so a customer block is never split across pages.
|
||
/// When there are no outstanding invoices a centred "all paid" message is shown instead of an
|
||
/// empty table — a common state for companies that bill promptly.
|
||
/// </summary>
|
||
public async Task<byte[]> GenerateArAgingPdfAsync(PowderCoating.Application.DTOs.Accounting.ArAgingReportDto dto)
|
||
{
|
||
QuestPDF.Settings.License = LicenseType.Community;
|
||
const string accent = "#1a56db";
|
||
|
||
return await Task.Run(() =>
|
||
{
|
||
var document = Document.Create(container =>
|
||
{
|
||
container.Page(page =>
|
||
{
|
||
page.Size(PageSizes.Letter);
|
||
page.Margin(0.6f, Unit.Inch);
|
||
page.PageColor(Colors.White);
|
||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||
|
||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Accounts Receivable Aging",
|
||
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
|
||
|
||
page.Content().PaddingTop(12).Column(col =>
|
||
{
|
||
// Summary band
|
||
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
|
||
{
|
||
KpiCell(row, "Current", dto.TotalCurrent.ToString("C0"), "#16a34a");
|
||
KpiCell(row, "1–30 Days", dto.Total1to30.ToString("C0"), "#ca8a04");
|
||
KpiCell(row, "31–60 Days", dto.Total31to60.ToString("C0"), "#ea580c");
|
||
KpiCell(row, "61–90 Days", dto.Total61to90.ToString("C0"), "#dc2626");
|
||
KpiCell(row, "Over 90", dto.TotalOver90.ToString("C0"), "#7f1d1d");
|
||
KpiCell(row, "Total Outstanding", dto.TotalOutstanding.ToString("C0"), "#1a56db");
|
||
});
|
||
|
||
if (!dto.Customers.Any())
|
||
{
|
||
col.Item().PaddingTop(20).AlignCenter().Text("All invoices are paid — no outstanding balances.").FontSize(11).FontColor("#16a34a");
|
||
return;
|
||
}
|
||
|
||
// Summary table
|
||
col.Item().PaddingTop(14).Table(table =>
|
||
{
|
||
table.ColumnsDefinition(cols =>
|
||
{
|
||
cols.RelativeColumn(3);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
});
|
||
|
||
void Hdr(string t) => table.Header(h =>
|
||
{
|
||
foreach (var lbl in new[] { "Customer", "Current", "1–30", "31–60", "61–90", "Over 90", "Total" })
|
||
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
|
||
});
|
||
Hdr("");
|
||
|
||
var alt = false;
|
||
foreach (var cust in dto.Customers)
|
||
{
|
||
var bg = alt ? "#f8fafc" : "#ffffff";
|
||
table.Cell().Background(bg).Padding(4).Text(cust.CustomerName).FontSize(9).Bold();
|
||
table.Cell().Background(bg).AlignRight().Padding(4).Text(cust.TotalCurrent > 0 ? cust.TotalCurrent.ToString("C") : "—").FontSize(9).FontColor("#16a34a");
|
||
table.Cell().Background(bg).AlignRight().Padding(4).Text(cust.Total1to30 > 0 ? cust.Total1to30.ToString("C") : "—").FontSize(9).FontColor("#ca8a04");
|
||
table.Cell().Background(bg).AlignRight().Padding(4).Text(cust.Total31to60 > 0 ? cust.Total31to60.ToString("C") : "—").FontSize(9).FontColor("#ea580c");
|
||
table.Cell().Background(bg).AlignRight().Padding(4).Text(cust.Total61to90 > 0 ? cust.Total61to90.ToString("C") : "—").FontSize(9).FontColor("#dc2626");
|
||
table.Cell().Background(bg).AlignRight().Padding(4).Text(cust.TotalOver90 > 0 ? cust.TotalOver90.ToString("C") : "—").FontSize(9).FontColor("#7f1d1d");
|
||
table.Cell().Background(bg).AlignRight().Padding(4).Text(cust.TotalBalance.ToString("C")).FontSize(9).Bold();
|
||
alt = !alt;
|
||
}
|
||
|
||
// Totals row
|
||
table.Cell().Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCurrent.ToString("C")).FontSize(9).Bold().FontColor("#16a34a");
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total1to30.ToString("C")).FontSize(9).Bold().FontColor("#ca8a04");
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total31to60.ToString("C")).FontSize(9).Bold().FontColor("#ea580c");
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total61to90.ToString("C")).FontSize(9).Bold().FontColor("#dc2626");
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOver90.ToString("C")).FontSize(9).Bold().FontColor("#7f1d1d");
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOutstanding.ToString("C")).FontSize(9).Bold();
|
||
});
|
||
|
||
// Invoice detail per customer
|
||
col.Item().PaddingTop(16).Text("Invoice Detail").FontSize(11).Bold();
|
||
|
||
foreach (var cust in dto.Customers)
|
||
{
|
||
col.Item().PaddingTop(8).ShowEntire().Column(custCol =>
|
||
{
|
||
custCol.Item().Background("#f1f5f9").Padding(4).Text(cust.CustomerName).Bold().FontSize(10);
|
||
|
||
custCol.Item().Table(table =>
|
||
{
|
||
table.ColumnsDefinition(cols =>
|
||
{
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
});
|
||
|
||
table.Header(h =>
|
||
{
|
||
foreach (var lbl in new[] { "Invoice", "Date", "Due Date", "Balance", "Age" })
|
||
h.Cell().Background("#e2e8f0").Padding(3).Text(lbl).Bold().FontSize(8);
|
||
});
|
||
|
||
foreach (var inv in cust.Invoices.OrderBy(i => i.DaysOverdue))
|
||
{
|
||
var ageColor = inv.DaysOverdue <= 0 ? "#16a34a"
|
||
: inv.DaysOverdue <= 30 ? "#ca8a04"
|
||
: inv.DaysOverdue <= 60 ? "#ea580c"
|
||
: inv.DaysOverdue <= 90 ? "#dc2626"
|
||
: "#7f1d1d";
|
||
var ageLabel = inv.DaysOverdue <= 0 ? "Current" : $"{inv.DaysOverdue}d overdue";
|
||
|
||
table.Cell().Padding(3).Text(inv.InvoiceNumber).FontSize(8);
|
||
table.Cell().Padding(3).Text(inv.InvoiceDate.ToString("MM/dd/yyyy")).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
table.Cell().Padding(3).Text(inv.DueDate?.ToString("MM/dd/yyyy") ?? "—").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
table.Cell().AlignRight().Padding(3).Text(inv.BalanceDue.ToString("C")).Bold().FontSize(8)
|
||
.FontColor(inv.DaysOverdue > 30 ? "#dc2626" : "#000000");
|
||
table.Cell().Padding(3).Text(ageLabel).FontSize(8).FontColor(ageColor);
|
||
}
|
||
|
||
table.Cell().ColumnSpan(3).Background("#f1f5f9").AlignRight().Padding(3).Text($"{cust.CustomerName} subtotal").Bold().FontSize(8).FontColor(Colors.Grey.Darken2);
|
||
table.Cell().Background("#f1f5f9").AlignRight().Padding(3).Text(cust.TotalBalance.ToString("C")).Bold().FontSize(8);
|
||
table.Cell().Background("#f1f5f9");
|
||
});
|
||
});
|
||
}
|
||
});
|
||
|
||
page.Footer().AlignCenter().Text(text =>
|
||
{
|
||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
});
|
||
});
|
||
return document.GeneratePdf();
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Generates a Sales & Income report PDF covering the date range in <paramref name="dto"/>.
|
||
/// Contains three sections: Sales by Month (invoice vs. collected per month), Sales by Customer
|
||
/// (invoice count, total invoiced, paid, and balance per customer), and a full Invoice Detail
|
||
/// table. Invoice status strings are colour-coded (Paid=green, PartiallyPaid=amber, Overdue=red)
|
||
/// using a switch expression so adding a new status only requires updating that one switch.
|
||
/// The "no invoices" guard short-circuits after the KPI band to avoid rendering empty tables.
|
||
/// </summary>
|
||
public async Task<byte[]> GenerateSalesAndIncomePdfAsync(PowderCoating.Application.DTOs.Accounting.SalesIncomeReportDto dto)
|
||
{
|
||
QuestPDF.Settings.License = LicenseType.Community;
|
||
const string accent = "#1a56db";
|
||
|
||
return await Task.Run(() =>
|
||
{
|
||
var document = Document.Create(container =>
|
||
{
|
||
container.Page(page =>
|
||
{
|
||
page.Size(PageSizes.Letter);
|
||
page.Margin(0.65f, Unit.Inch);
|
||
page.PageColor(Colors.White);
|
||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||
|
||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Sales & Income",
|
||
$"{dto.From:MMMM d, yyyy} – {dto.To:MMMM d, yyyy}", accent));
|
||
|
||
page.Content().PaddingTop(12).Column(col =>
|
||
{
|
||
// KPI band
|
||
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
|
||
{
|
||
KpiCell(row, "Total Invoiced", dto.TotalInvoiced.ToString("C"), "#1a56db");
|
||
KpiCell(row, "Collected (period)", dto.TotalCollected.ToString("C"), "#16a34a");
|
||
KpiCell(row, "Avg Invoice Value", dto.AverageInvoiceValue.ToString("C"), Colors.Grey.Darken2);
|
||
KpiCell(row, "Active Customers", dto.CustomerCount.ToString(), Colors.Grey.Darken2);
|
||
});
|
||
|
||
if (!dto.Invoices.Any())
|
||
{
|
||
col.Item().PaddingTop(20).AlignCenter().Text("No invoices found for this period.").FontSize(11).FontColor(Colors.Grey.Medium);
|
||
return;
|
||
}
|
||
|
||
// By Month
|
||
col.Item().PaddingTop(14).Text("Sales by Month").FontSize(11).Bold();
|
||
col.Item().PaddingTop(4).Table(table =>
|
||
{
|
||
table.ColumnsDefinition(cols =>
|
||
{
|
||
cols.RelativeColumn(3);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(1);
|
||
});
|
||
|
||
table.Header(h =>
|
||
{
|
||
foreach (var lbl in new[] { "Month", "Invoiced", "Collected", "#" })
|
||
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
|
||
});
|
||
|
||
var alt = false;
|
||
foreach (var m in dto.ByMonth)
|
||
{
|
||
var bg = alt ? "#f8fafc" : "#ffffff";
|
||
table.Cell().Background(bg).Padding(3).Text(m.Label).FontSize(9);
|
||
table.Cell().Background(bg).AlignRight().Padding(3).Text(m.TotalInvoiced.ToString("C")).FontSize(9);
|
||
table.Cell().Background(bg).AlignRight().Padding(3).Text(m.TotalCollected.ToString("C")).FontSize(9).FontColor("#16a34a");
|
||
table.Cell().Background(bg).AlignCenter().Padding(3).Text(m.InvoiceCount.ToString()).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||
alt = !alt;
|
||
}
|
||
|
||
table.Cell().Background("#e2e8f0").Padding(3).Text("Total").Bold().FontSize(9);
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(3).Text(dto.TotalInvoiced.ToString("C")).Bold().FontSize(9);
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(3).Text(dto.TotalCollected.ToString("C")).Bold().FontSize(9).FontColor("#16a34a");
|
||
table.Cell().Background("#e2e8f0").AlignCenter().Padding(3).Text(dto.InvoiceCount.ToString()).Bold().FontSize(9);
|
||
});
|
||
|
||
// By Customer
|
||
col.Item().PaddingTop(14).Text("Sales by Customer").FontSize(11).Bold();
|
||
col.Item().PaddingTop(4).Table(table =>
|
||
{
|
||
table.ColumnsDefinition(cols =>
|
||
{
|
||
cols.RelativeColumn(4);
|
||
cols.RelativeColumn(1);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
});
|
||
|
||
table.Header(h =>
|
||
{
|
||
foreach (var lbl in new[] { "Customer", "Inv", "Total Invoiced", "Paid", "Balance Due" })
|
||
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
|
||
});
|
||
|
||
var alt = false;
|
||
foreach (var c in dto.ByCustomer)
|
||
{
|
||
var bg = alt ? "#f8fafc" : "#ffffff";
|
||
table.Cell().Background(bg).Padding(3).Text(c.CustomerName).FontSize(9).Bold();
|
||
table.Cell().Background(bg).AlignCenter().Padding(3).Text(c.InvoiceCount.ToString()).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||
table.Cell().Background(bg).AlignRight().Padding(3).Text(c.TotalInvoiced.ToString("C")).FontSize(9).Bold();
|
||
table.Cell().Background(bg).AlignRight().Padding(3).Text(c.TotalPaid.ToString("C")).FontSize(9).FontColor("#16a34a");
|
||
table.Cell().Background(bg).AlignRight().Padding(3).Text(c.BalanceDue.ToString("C")).FontSize(9)
|
||
.FontColor(c.BalanceDue > 0 ? "#ca8a04" : Colors.Grey.Darken1);
|
||
alt = !alt;
|
||
}
|
||
|
||
table.Cell().Background("#e2e8f0").Padding(3).Text($"Total ({dto.CustomerCount} customers)").Bold().FontSize(9);
|
||
table.Cell().Background("#e2e8f0").AlignCenter().Padding(3).Text(dto.InvoiceCount.ToString()).Bold().FontSize(9);
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(3).Text(dto.TotalInvoiced.ToString("C")).Bold().FontSize(9);
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(3).Text(dto.TotalCollected.ToString("C")).Bold().FontSize(9).FontColor("#16a34a");
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(3).Text(dto.Invoices.Sum(i => i.BalanceDue).ToString("C")).Bold().FontSize(9);
|
||
});
|
||
|
||
// Invoice Detail
|
||
col.Item().PaddingTop(14).Text("Invoice Detail").FontSize(11).Bold();
|
||
col.Item().PaddingTop(4).Table(table =>
|
||
{
|
||
table.ColumnsDefinition(cols =>
|
||
{
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(3);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
});
|
||
|
||
table.Header(h =>
|
||
{
|
||
foreach (var lbl in new[] { "Invoice", "Customer", "Date", "Total", "Paid", "Status" })
|
||
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
|
||
});
|
||
|
||
var alt = false;
|
||
foreach (var inv in dto.Invoices)
|
||
{
|
||
var bg = alt ? "#f8fafc" : "#ffffff";
|
||
var statusColor = inv.Status switch
|
||
{
|
||
"Paid" => "#16a34a",
|
||
"PartiallyPaid" => "#ca8a04",
|
||
"Overdue" => "#dc2626",
|
||
_ => "#6c757d"
|
||
};
|
||
table.Cell().Background(bg).Padding(3).Text(inv.InvoiceNumber).FontSize(8).Bold();
|
||
table.Cell().Background(bg).Padding(3).Text(inv.CustomerName).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
table.Cell().Background(bg).Padding(3).Text(inv.InvoiceDate.ToString("MM/dd/yy")).FontSize(8);
|
||
table.Cell().Background(bg).AlignRight().Padding(3).Text(inv.Total.ToString("C")).FontSize(8).Bold();
|
||
table.Cell().Background(bg).AlignRight().Padding(3).Text(inv.AmountPaid.ToString("C")).FontSize(8).FontColor("#16a34a");
|
||
table.Cell().Background(bg).Padding(3).Text(inv.Status).FontSize(8).FontColor(statusColor);
|
||
alt = !alt;
|
||
}
|
||
|
||
table.Cell().ColumnSpan(3).Background("#e2e8f0").Padding(3).Text("Totals").Bold().FontSize(8);
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(3).Text(dto.TotalInvoiced.ToString("C")).Bold().FontSize(8);
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(3).Text(dto.TotalCollected.ToString("C")).Bold().FontSize(8).FontColor("#16a34a");
|
||
table.Cell().Background("#e2e8f0");
|
||
});
|
||
});
|
||
|
||
page.Footer().AlignCenter().Text(text =>
|
||
{
|
||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
});
|
||
});
|
||
return document.GeneratePdf();
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Composes the shared header used by all four financial report PDFs (P&L, Balance Sheet,
|
||
/// AR Aging, Sales & Income). Places the company name and date range/subtitle on the left,
|
||
/// and the report title in large bold accent text on the right, separated from the content area
|
||
/// by a 1.5pt accent-coloured horizontal rule. Kept static because it references no instance state.
|
||
/// </summary>
|
||
private static void ComposeReportHeader(IContainer container, string companyName, string reportTitle, string subtitle, string accent)
|
||
{
|
||
container.Column(col =>
|
||
{
|
||
col.Item().Row(row =>
|
||
{
|
||
row.RelativeItem().Column(c =>
|
||
{
|
||
c.Item().Text(companyName).FontSize(14).Bold().FontColor(accent);
|
||
c.Item().Text(subtitle).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
row.RelativeItem().AlignRight().Column(c =>
|
||
{
|
||
c.Item().Text(reportTitle).FontSize(20).Bold().FontColor(accent);
|
||
});
|
||
});
|
||
col.Item().PaddingVertical(4).LineHorizontal(1.5f).LineColor(accent);
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Appends a single KPI cell to a <see cref="RowDescriptor"/> for use in the summary bands of
|
||
/// all financial report PDFs. Each cell is a centred column with the value on top (bold, 11pt,
|
||
/// colour-coded) and the label below (8pt grey). Implemented as a static helper to avoid
|
||
/// repeating the same four-line QuestPDF fluent chain at every call site in the four report methods.
|
||
/// </summary>
|
||
private static void KpiCell(RowDescriptor row, string label, string value, string valueColor)
|
||
{
|
||
row.RelativeItem().AlignCenter().Column(c =>
|
||
{
|
||
c.Item().AlignCenter().Text(value).Bold().FontSize(11).FontColor(valueColor);
|
||
c.Item().AlignCenter().Text(label).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
}
|
||
|
||
// =========================================================================
|
||
// Purchase Order PDF
|
||
// =========================================================================
|
||
|
||
/// <summary>
|
||
/// Generates a Purchase Order PDF and returns the raw bytes for download or email to the vendor.
|
||
/// Uses a green accent colour to visually distinguish PO documents from quote (grey) and invoice
|
||
/// (blue) PDFs. Layout delegates to <see cref="ComposePOHeader"/> for the branding/PO-metadata
|
||
/// block and <see cref="ComposePOContent"/> for the vendor address, line-item table, totals, notes,
|
||
/// and authorisation signature line.
|
||
/// </summary>
|
||
public async Task<byte[]> GeneratePurchaseOrderPdfAsync(
|
||
PurchaseOrderDto po,
|
||
byte[]? companyLogo,
|
||
string? companyLogoContentType,
|
||
CompanyInfoDto companyInfo)
|
||
{
|
||
QuestPDF.Settings.License = LicenseType.Community;
|
||
var accent = Colors.Green.Darken2;
|
||
|
||
return await Task.Run(() =>
|
||
{
|
||
var doc = 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 => ComposePOHeader(c, companyLogo, companyInfo, accent, po));
|
||
page.Content().Element(c => ComposePOContent(c, po, accent));
|
||
page.Footer().AlignCenter().Text(text =>
|
||
{
|
||
text.CurrentPageNumber();
|
||
text.Span(" / ");
|
||
text.TotalPages();
|
||
text.Span($" — {companyInfo.CompanyName}");
|
||
});
|
||
});
|
||
});
|
||
|
||
return doc.GeneratePdf();
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Composes the PO page header: company branding (logo or name + address) on the left, and a
|
||
/// "PURCHASE ORDER" title block with PO number, order date, expected delivery date, and status
|
||
/// on the right. Mirrors the structure of <see cref="ComposeInvoiceHeader"/> but uses
|
||
/// "PURCHASE ORDER" as the document type label. The header is separated from the body by a
|
||
/// 1pt accent-coloured rule.
|
||
/// </summary>
|
||
private void ComposePOHeader(IContainer container, byte[]? logo, CompanyInfoDto company, string accent, PurchaseOrderDto po)
|
||
{
|
||
container.Column(col =>
|
||
{
|
||
col.Item().Row(row =>
|
||
{
|
||
// Left: company branding
|
||
row.RelativeItem().Column(c =>
|
||
{
|
||
if (logo != null && logo.Length > 0)
|
||
c.Item().MaxHeight(60).Image(logo);
|
||
else
|
||
c.Item().Text(company.CompanyName).FontSize(18).Bold().FontColor(accent);
|
||
|
||
if (!string.IsNullOrWhiteSpace(company.Address))
|
||
c.Item().Text(company.Address).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||
|
||
var cityLine = $"{company.City}{(!string.IsNullOrEmpty(company.City) && !string.IsNullOrEmpty(company.State) ? ", " : "")}{company.State} {company.ZipCode}".Trim();
|
||
if (!string.IsNullOrWhiteSpace(cityLine))
|
||
c.Item().Text(cityLine).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||
|
||
if (!string.IsNullOrWhiteSpace(company.Phone))
|
||
c.Item().Text(FormatPhoneNumber(company.Phone)).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
|
||
// Right: PO title block
|
||
row.RelativeItem().AlignRight().Column(c =>
|
||
{
|
||
c.Item().Text("PURCHASE ORDER").FontSize(24).Bold().FontColor(accent);
|
||
c.Item().Text($"# {po.PoNumber}").FontSize(12).Bold();
|
||
c.Item().Text($"Date: {po.OrderDate:MMMM d, yyyy}").FontSize(9);
|
||
if (po.ExpectedDeliveryDate.HasValue)
|
||
c.Item().Text($"Expected: {po.ExpectedDeliveryDate.Value:MMMM d, yyyy}").FontSize(9);
|
||
c.Item().Text($"Status: {po.Status}").FontSize(9).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
});
|
||
|
||
col.Item().PaddingVertical(4).LineHorizontal(1).LineColor(accent);
|
||
});
|
||
}
|
||
|
||
// =========================================================================
|
||
// Gift Certificate PDF
|
||
// =========================================================================
|
||
|
||
/// <summary>
|
||
/// Generates a decorative gift certificate PDF and returns the raw bytes.
|
||
/// Uses purple (#7c3aed) and gold (#b45309) colour constants rather than a configurable accent
|
||
/// because the celebratory aesthetic should be consistent regardless of the company's brand colour.
|
||
/// Layout is handled by <see cref="ComposeGiftCertificateContent"/> which renders the double-border
|
||
/// frame, big-value amount, certificate code, redemption metadata, and optional remaining-balance
|
||
/// callout for partially redeemed certificates.
|
||
/// </summary>
|
||
public async Task<byte[]> GenerateGiftCertificatePdfAsync(
|
||
GiftCertificateDto cert,
|
||
byte[]? companyLogo,
|
||
string? companyLogoContentType,
|
||
CompanyInfoDto companyInfo)
|
||
{
|
||
QuestPDF.Settings.License = LicenseType.Community;
|
||
const string accent = "#7c3aed"; // purple — gift/celebration feel
|
||
const string gold = "#b45309";
|
||
|
||
return await Task.Run(() =>
|
||
{
|
||
var doc = 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.Content().Element(c => ComposeGiftCertificateContent(c, cert, companyInfo, companyLogo, accent, gold));
|
||
|
||
page.Footer().AlignCenter().Text(text =>
|
||
{
|
||
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||
text.Span($" · {FormatPhoneNumber(companyInfo.Phone)}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
});
|
||
});
|
||
|
||
return doc.GeneratePdf();
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Generates a multi-page PDF containing one gift certificate per page, all using the same
|
||
/// branded layout as the single-certificate download. Used for bulk print runs (car shows,
|
||
/// promotions) so staff can hand-cut and distribute a full batch from one print job.
|
||
/// </summary>
|
||
public async Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
|
||
IList<GiftCertificateDto> certs,
|
||
byte[]? companyLogo,
|
||
string? companyLogoContentType,
|
||
CompanyInfoDto companyInfo)
|
||
{
|
||
QuestPDF.Settings.License = LicenseType.Community;
|
||
const string accent = "#7c3aed";
|
||
const string gold = "#b45309";
|
||
|
||
return await Task.Run(() =>
|
||
{
|
||
var doc = Document.Create(container =>
|
||
{
|
||
foreach (var cert in certs)
|
||
{
|
||
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.Content().Element(c => ComposeGiftCertificateContent(c, cert, companyInfo, companyLogo, accent, gold));
|
||
|
||
page.Footer().AlignCenter().Text(text =>
|
||
{
|
||
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||
text.Span($" · {FormatPhoneNumber(companyInfo.Phone)}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
});
|
||
}
|
||
});
|
||
|
||
return doc.GeneratePdf();
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
|
||
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
|
||
/// frame: company branding, a 48pt bold amount (the visual centrepiece), a two-column row with
|
||
/// the recipient name and certificate code in a highlighted box, and a four-column metadata row
|
||
/// (issued, valid-through, issued-as, status). A conditional remaining-balance callout is shown
|
||
/// in a gold-bordered amber box when the certificate has been partially redeemed, so the customer
|
||
/// knows exactly how much they still have. The certificate code is displayed prominently because
|
||
/// it is the redemption key — staff look it up in the system at time of service.
|
||
/// </summary>
|
||
private void ComposeGiftCertificateContent(IContainer container, GiftCertificateDto cert, CompanyInfoDto companyInfo,
|
||
byte[]? logo, string accent, string gold)
|
||
{
|
||
container.Column(col =>
|
||
{
|
||
// ── Outer decorative border ────────────────────────────────────────
|
||
col.Item().Border(3).BorderColor(accent).Padding(16).Column(inner =>
|
||
{
|
||
inner.Item().Border(1).BorderColor(gold).Padding(16).Column(body =>
|
||
{
|
||
// ── Company header ─────────────────────────────────────────
|
||
body.Item().Row(row =>
|
||
{
|
||
row.RelativeItem().Column(c =>
|
||
{
|
||
if (logo != null && logo.Length > 0)
|
||
c.Item().MaxHeight(55).Image(logo);
|
||
else
|
||
c.Item().Text(companyInfo.CompanyName).FontSize(16).Bold().FontColor(accent);
|
||
|
||
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||
c.Item().PaddingTop(2).Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
if (!string.IsNullOrWhiteSpace(companyInfo.Address))
|
||
c.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))
|
||
c.Item().Text(cityLine).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
|
||
row.RelativeItem().AlignRight().Column(c =>
|
||
{
|
||
c.Item().Text("GIFT CERTIFICATE").FontSize(26).Bold().FontColor(accent);
|
||
});
|
||
});
|
||
|
||
body.Item().PaddingVertical(10).LineHorizontal(1.5f).LineColor(gold);
|
||
|
||
// ── Big amount ─────────────────────────────────────────────
|
||
body.Item().PaddingVertical(10).AlignCenter().Column(c =>
|
||
{
|
||
c.Item().AlignCenter().Text("This certificate is worth").FontSize(11).FontColor(Colors.Grey.Darken2);
|
||
c.Item().AlignCenter().Text(cert.OriginalAmount.ToString("C")).FontSize(48).Bold().FontColor(accent);
|
||
});
|
||
|
||
body.Item().PaddingVertical(6).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
|
||
|
||
// ── Recipient + Code ───────────────────────────────────────
|
||
body.Item().PaddingTop(10).Row(row =>
|
||
{
|
||
// Left: recipient
|
||
row.RelativeItem().Column(c =>
|
||
{
|
||
c.Item().Text("PRESENTED TO").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
|
||
c.Item().PaddingTop(3).Text(
|
||
!string.IsNullOrWhiteSpace(cert.RecipientName) ? cert.RecipientName : "Bearer"
|
||
).FontSize(16).Bold();
|
||
if (!string.IsNullOrWhiteSpace(cert.RecipientEmail))
|
||
c.Item().PaddingTop(2).Text(cert.RecipientEmail).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
|
||
// Right: certificate code
|
||
row.RelativeItem().AlignRight().Column(c =>
|
||
{
|
||
c.Item().AlignRight().Text("CERTIFICATE CODE").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
|
||
c.Item().PaddingTop(3).AlignRight().Background("#f5f3ff").Padding(6)
|
||
.Text(cert.CertificateCode).FontSize(16).Bold().FontColor(accent);
|
||
});
|
||
});
|
||
|
||
body.Item().PaddingTop(14).Row(row =>
|
||
{
|
||
// Issued
|
||
row.RelativeItem().Column(c =>
|
||
{
|
||
c.Item().Text("ISSUED").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
|
||
c.Item().PaddingTop(2).Text(cert.IssueDate.ToLocalTime().ToString("MMMM d, yyyy")).FontSize(10);
|
||
});
|
||
|
||
// Expiry
|
||
row.RelativeItem().Column(c =>
|
||
{
|
||
c.Item().Text("VALID THROUGH").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
|
||
c.Item().PaddingTop(2).Text(
|
||
cert.ExpiryDate.HasValue
|
||
? cert.ExpiryDate.Value.ToLocalTime().ToString("MMMM d, yyyy")
|
||
: "No expiration"
|
||
).FontSize(10);
|
||
});
|
||
|
||
// Issued reason
|
||
row.RelativeItem().Column(c =>
|
||
{
|
||
c.Item().Text("ISSUED AS").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
|
||
c.Item().PaddingTop(2).Text(cert.IssuedReason switch
|
||
{
|
||
GiftCertificateIssuedReason.Sold => "Purchased Gift",
|
||
GiftCertificateIssuedReason.Prize => "Prize / Award",
|
||
GiftCertificateIssuedReason.Promotional => "Promotional",
|
||
GiftCertificateIssuedReason.Goodwill => "Goodwill",
|
||
_ => "Gift Certificate"
|
||
}).FontSize(10);
|
||
});
|
||
|
||
// Status
|
||
row.RelativeItem().AlignRight().Column(c =>
|
||
{
|
||
c.Item().AlignRight().Text("STATUS").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
|
||
string statusText, statusColor;
|
||
(statusText, statusColor) = cert.Status switch
|
||
{
|
||
GiftCertificateStatus.Active => ("Active", "#16a34a"),
|
||
GiftCertificateStatus.PartiallyRedeemed => ("Partially Redeemed", "#ca8a04"),
|
||
GiftCertificateStatus.FullyRedeemed => ("Fully Redeemed", "#6b7280"),
|
||
GiftCertificateStatus.Expired => ("Expired", "#dc2626"),
|
||
GiftCertificateStatus.Voided => ("VOIDED", "#dc2626"),
|
||
_ => (cert.Status.ToString(), "#6b7280")
|
||
};
|
||
c.Item().PaddingTop(2).AlignRight().Text(statusText).FontSize(10).Bold().FontColor(statusColor);
|
||
});
|
||
});
|
||
|
||
// Show remaining balance if partially redeemed
|
||
if (cert.RedeemedAmount > 0 && cert.RemainingBalance > 0)
|
||
{
|
||
body.Item().PaddingTop(10).Background("#fefce8").Border(1).BorderColor(gold).Padding(8).Row(row =>
|
||
{
|
||
row.RelativeItem().Column(c =>
|
||
{
|
||
c.Item().Text($"Original Value: {cert.OriginalAmount:C}").FontSize(9).FontColor(Colors.Grey.Darken1);
|
||
c.Item().Text($"Amount Used: ({cert.RedeemedAmount:C})").FontSize(9).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
row.ConstantItem(160).AlignRight().Column(c =>
|
||
{
|
||
c.Item().AlignRight().Text("REMAINING BALANCE").FontSize(8).Bold().FontColor(gold);
|
||
c.Item().AlignRight().Text(cert.RemainingBalance.ToString("C")).FontSize(18).Bold().FontColor(gold);
|
||
});
|
||
});
|
||
}
|
||
|
||
body.Item().PaddingTop(14).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
|
||
|
||
// ── Redemption instructions ────────────────────────────────
|
||
body.Item().PaddingTop(10).AlignCenter().Text(
|
||
"Present this certificate at time of service. Quote the certificate code above to redeem."
|
||
).FontSize(9).FontColor(Colors.Grey.Darken1).Italic();
|
||
|
||
if (!string.IsNullOrWhiteSpace(cert.Notes))
|
||
{
|
||
body.Item().PaddingTop(8).AlignCenter().Text(cert.Notes).FontSize(9).FontColor(Colors.Grey.Darken2);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Composes the body of the Purchase Order PDF: vendor contact block and order-reference panel,
|
||
/// an alternating-row line-item table with item name, notes, quantity (using "G29" format to
|
||
/// suppress trailing zeros for decimal quantities), unit cost, and line total, followed by a
|
||
/// right-aligned totals block that conditionally includes shipping cost. A signature line is
|
||
/// rendered at the bottom for physical authorisation; QuestPDF's <c>PaddingTop(32)</c> provides
|
||
/// enough blank space above the line for a handwritten signature.
|
||
/// </summary>
|
||
private void ComposePOContent(IContainer container, PurchaseOrderDto po, string accent)
|
||
{
|
||
container.Column(col =>
|
||
{
|
||
// Vendor info
|
||
col.Item().PaddingTop(12).Row(row =>
|
||
{
|
||
row.RelativeItem().Column(c =>
|
||
{
|
||
c.Item().Text("VENDOR").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
|
||
c.Item().Text(po.VendorName).Bold();
|
||
if (!string.IsNullOrWhiteSpace(po.VendorEmail))
|
||
c.Item().Text(po.VendorEmail).FontSize(9);
|
||
if (!string.IsNullOrWhiteSpace(po.VendorPhone))
|
||
c.Item().Text(po.VendorPhone).FontSize(9);
|
||
});
|
||
|
||
row.ConstantItem(200).Column(c =>
|
||
{
|
||
c.Item().Text("ORDER DETAILS").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
|
||
c.Item().Text($"PO Number: {po.PoNumber}").FontSize(9);
|
||
c.Item().Text($"Order Date: {po.OrderDate:MM/dd/yyyy}").FontSize(9);
|
||
if (po.ExpectedDeliveryDate.HasValue)
|
||
c.Item().Text($"Expected Delivery: {po.ExpectedDeliveryDate.Value:MM/dd/yyyy}").FontSize(9);
|
||
});
|
||
});
|
||
|
||
// Line items table
|
||
col.Item().PaddingTop(16).Table(table =>
|
||
{
|
||
table.ColumnsDefinition(cols =>
|
||
{
|
||
cols.RelativeColumn(5); // Description
|
||
cols.RelativeColumn(1.5f); // Qty
|
||
cols.RelativeColumn(2); // Unit Cost
|
||
cols.RelativeColumn(2); // Line Total
|
||
});
|
||
|
||
table.Header(h =>
|
||
{
|
||
h.Cell().Background(accent).Padding(5).Text("Item / Description").FontColor(Colors.White).Bold().FontSize(9);
|
||
h.Cell().Background(accent).Padding(5).AlignCenter().Text("Qty").FontColor(Colors.White).Bold().FontSize(9);
|
||
h.Cell().Background(accent).Padding(5).AlignRight().Text("Unit Cost").FontColor(Colors.White).Bold().FontSize(9);
|
||
h.Cell().Background(accent).Padding(5).AlignRight().Text("Line Total").FontColor(Colors.White).Bold().FontSize(9);
|
||
});
|
||
|
||
var alt = false;
|
||
foreach (var item in po.Items)
|
||
{
|
||
var bg = alt ? Colors.Grey.Lighten4 : Colors.White;
|
||
table.Cell().Background(bg).Padding(5).Column(c =>
|
||
{
|
||
c.Item().Text(item.ItemName).FontSize(9);
|
||
if (!string.IsNullOrWhiteSpace(item.Notes))
|
||
c.Item().Text(item.Notes).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
table.Cell().Background(bg).Padding(5).AlignCenter().Text(item.QuantityOrdered.ToString("G29")).FontSize(9);
|
||
table.Cell().Background(bg).Padding(5).AlignRight().Text(item.UnitCost.ToString("C")).FontSize(9);
|
||
table.Cell().Background(bg).Padding(5).AlignRight().Text(item.LineTotal.ToString("C")).FontSize(9);
|
||
alt = !alt;
|
||
}
|
||
});
|
||
|
||
// Totals block
|
||
col.Item().PaddingTop(8).AlignRight().Column(c =>
|
||
{
|
||
c.Item().Row(r =>
|
||
{
|
||
r.RelativeItem();
|
||
r.ConstantItem(180).Row(rr =>
|
||
{
|
||
rr.RelativeItem().Text("Subtotal:").FontSize(9);
|
||
rr.ConstantItem(90).AlignRight().Text(po.SubTotal.ToString("C")).FontSize(9);
|
||
});
|
||
});
|
||
|
||
if (po.ShippingCost > 0)
|
||
{
|
||
c.Item().Row(r =>
|
||
{
|
||
r.RelativeItem();
|
||
r.ConstantItem(180).Row(rr =>
|
||
{
|
||
rr.RelativeItem().Text("Shipping:").FontSize(9);
|
||
rr.ConstantItem(90).AlignRight().Text(po.ShippingCost.ToString("C")).FontSize(9);
|
||
});
|
||
});
|
||
}
|
||
|
||
c.Item().Row(r =>
|
||
{
|
||
r.RelativeItem();
|
||
r.ConstantItem(180).Background(accent).Padding(5).Row(rr =>
|
||
{
|
||
rr.RelativeItem().Text("TOTAL:").FontSize(10).Bold().FontColor(Colors.White);
|
||
rr.ConstantItem(90).AlignRight().Text(po.TotalAmount.ToString("C")).FontSize(10).Bold().FontColor(Colors.White);
|
||
});
|
||
});
|
||
});
|
||
|
||
// Notes
|
||
if (!string.IsNullOrWhiteSpace(po.Notes))
|
||
{
|
||
col.Item().PaddingTop(16).Column(c =>
|
||
{
|
||
c.Item().Text("NOTES").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
|
||
c.Item().PaddingTop(2).Border(0.5f).BorderColor(Colors.Grey.Lighten2)
|
||
.Padding(6).Text(po.Notes).FontSize(9);
|
||
});
|
||
}
|
||
|
||
// Signature line
|
||
col.Item().PaddingTop(32).Row(row =>
|
||
{
|
||
row.RelativeItem().Column(c =>
|
||
{
|
||
c.Item().BorderBottom(0.5f).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text("").FontSize(9);
|
||
c.Item().PaddingTop(2).Text("Authorized Signature").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
row.ConstantItem(20);
|
||
row.RelativeItem().Column(c =>
|
||
{
|
||
c.Item().BorderBottom(0.5f).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text("").FontSize(9);
|
||
c.Item().PaddingTop(2).Text("Date").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// ─── Sales Tax Report ─────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Generates a letter-sized PDF for the Sales Tax Liability report. Sections: summary KPI
|
||
/// row, breakdown by tax account, breakdown by month, then a full invoice detail table.
|
||
/// Intended for handing to an accountant or attaching to a tax filing.
|
||
/// </summary>
|
||
public Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto)
|
||
{
|
||
QuestPDF.Settings.License = LicenseType.Community;
|
||
const string accent = "#1e3a5f";
|
||
|
||
return Task.Run(() =>
|
||
{
|
||
var document = Document.Create(container =>
|
||
{
|
||
container.Page(page =>
|
||
{
|
||
page.Size(PageSizes.Letter);
|
||
page.Margin(0.65f, Unit.Inch);
|
||
page.PageColor(Colors.White);
|
||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||
|
||
page.Header().Column(col =>
|
||
{
|
||
col.Item().Row(row =>
|
||
{
|
||
row.RelativeItem().Column(c =>
|
||
{
|
||
c.Item().Text(dto.CompanyName).FontSize(14).Bold().FontColor(accent);
|
||
c.Item().Text("Sales Tax Liability Report").FontSize(10).FontColor(Colors.Grey.Darken1);
|
||
c.Item().Text($"{dto.From:MMMM d, yyyy} – {dto.To:MMMM d, yyyy}").FontSize(9).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
row.RelativeItem().AlignRight().Column(c =>
|
||
{
|
||
c.Item().Text("Invoice-Basis Report").FontSize(8).Italic().FontColor(Colors.Grey.Medium);
|
||
c.Item().Text($"Generated {DateTime.Today:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Medium);
|
||
});
|
||
});
|
||
col.Item().PaddingTop(4).BorderBottom(1.5f).BorderColor(accent);
|
||
});
|
||
|
||
page.Content().PaddingTop(12).Column(col =>
|
||
{
|
||
// KPI summary row
|
||
col.Item().PaddingBottom(12).Row(row =>
|
||
{
|
||
void KpiBox(RowDescriptor r, string label, string value, string bg)
|
||
{
|
||
r.RelativeItem().Background(bg).Padding(8).Column(c =>
|
||
{
|
||
c.Item().Text(label).FontSize(7.5f).FontColor(Colors.Grey.Darken2);
|
||
c.Item().Text(value).FontSize(13).Bold().FontColor(accent);
|
||
});
|
||
}
|
||
KpiBox(row, "Total Tax Billed", $"{dto.TotalTaxBilled:C}", "#e8f4fd");
|
||
row.ConstantItem(6);
|
||
KpiBox(row, "Taxable Sales", $"{dto.TotalTaxableSales:C}", "#f0fdf4");
|
||
row.ConstantItem(6);
|
||
KpiBox(row, "Non-Taxable Sales", $"{dto.TotalNonTaxableSales:C}", "#fafafa");
|
||
row.ConstantItem(6);
|
||
KpiBox(row, "Effective Tax Rate", $"{dto.EffectiveTaxRate:F2}%", "#fff7ed");
|
||
});
|
||
|
||
// By account
|
||
if (dto.ByAccount.Any())
|
||
{
|
||
col.Item().PaddingBottom(4).Text("Tax by Liability Account").FontSize(10).Bold().FontColor(accent);
|
||
col.Item().PaddingBottom(10).Table(table =>
|
||
{
|
||
table.ColumnsDefinition(c =>
|
||
{
|
||
c.RelativeColumn(3);
|
||
c.RelativeColumn(2);
|
||
c.RelativeColumn(2);
|
||
c.ConstantColumn(40);
|
||
});
|
||
void AHdr(string t) => table.Header(h => h.Cell().Background("#e8f4fd").Padding(4).Text(t).Bold().FontSize(8));
|
||
table.Header(h =>
|
||
{
|
||
h.Cell().Background("#e8f4fd").Padding(4).Text("Account").Bold().FontSize(8);
|
||
h.Cell().Background("#e8f4fd").Padding(4).AlignRight().Text("Taxable Sales").Bold().FontSize(8);
|
||
h.Cell().Background("#e8f4fd").Padding(4).AlignRight().Text("Tax Billed").Bold().FontSize(8);
|
||
h.Cell().Background("#e8f4fd").Padding(4).AlignCenter().Text("Invoices").Bold().FontSize(8);
|
||
});
|
||
foreach (var a in dto.ByAccount)
|
||
{
|
||
var label = string.IsNullOrEmpty(a.AccountNumber) ? a.AccountName : $"{a.AccountNumber} {a.AccountName}";
|
||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).Text(label).FontSize(8);
|
||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignRight().Text($"{a.TaxableSales:C}").FontSize(8);
|
||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignRight().Text($"{a.TaxBilled:C}").FontSize(8).Bold();
|
||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignCenter().Text(a.InvoiceCount.ToString()).FontSize(8);
|
||
}
|
||
// Totals row
|
||
table.Cell().Background("#f8fafc").Padding(4).Text("Total").Bold().FontSize(8);
|
||
table.Cell().Background("#f8fafc").Padding(4).AlignRight().Text($"{dto.ByAccount.Sum(a => a.TaxableSales):C}").Bold().FontSize(8);
|
||
table.Cell().Background("#f8fafc").Padding(4).AlignRight().Text($"{dto.ByAccount.Sum(a => a.TaxBilled):C}").Bold().FontSize(8);
|
||
table.Cell().Background("#f8fafc").Padding(4).AlignCenter().Text(dto.ByAccount.Sum(a => a.InvoiceCount).ToString()).Bold().FontSize(8);
|
||
});
|
||
}
|
||
|
||
// By month
|
||
if (dto.ByMonth.Any())
|
||
{
|
||
col.Item().PaddingBottom(4).Text("Tax by Month").FontSize(10).Bold().FontColor(accent);
|
||
col.Item().PaddingBottom(10).Table(table =>
|
||
{
|
||
table.ColumnsDefinition(c =>
|
||
{
|
||
c.RelativeColumn(2);
|
||
c.RelativeColumn(2);
|
||
c.RelativeColumn(2);
|
||
c.ConstantColumn(40);
|
||
});
|
||
table.Header(h =>
|
||
{
|
||
h.Cell().Background("#e8f4fd").Padding(4).Text("Month").Bold().FontSize(8);
|
||
h.Cell().Background("#e8f4fd").Padding(4).AlignRight().Text("Taxable Sales").Bold().FontSize(8);
|
||
h.Cell().Background("#e8f4fd").Padding(4).AlignRight().Text("Tax Billed").Bold().FontSize(8);
|
||
h.Cell().Background("#e8f4fd").Padding(4).AlignCenter().Text("Invoices").Bold().FontSize(8);
|
||
});
|
||
foreach (var m in dto.ByMonth)
|
||
{
|
||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).Text(m.Label).FontSize(8);
|
||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignRight().Text($"{m.TaxableSales:C}").FontSize(8);
|
||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignRight().Text($"{m.TaxBilled:C}").FontSize(8).Bold();
|
||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignCenter().Text(m.InvoiceCount.ToString()).FontSize(8);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Invoice detail
|
||
col.Item().PaddingBottom(4).Text("Invoice Detail").FontSize(10).Bold().FontColor(accent);
|
||
col.Item().Table(table =>
|
||
{
|
||
table.ColumnsDefinition(c =>
|
||
{
|
||
c.ConstantColumn(70); // Invoice #
|
||
c.RelativeColumn(2.5f); // Customer
|
||
c.ConstantColumn(58); // Date
|
||
c.ConstantColumn(48); // Status
|
||
c.ConstantColumn(52); // SubTotal
|
||
c.ConstantColumn(34); // Tax %
|
||
c.ConstantColumn(52); // Tax $
|
||
c.ConstantColumn(52); // Total
|
||
c.RelativeColumn(2); // Tax Account
|
||
});
|
||
table.Header(h =>
|
||
{
|
||
string bg = "#e8f4fd";
|
||
h.Cell().Background(bg).Padding(3).Text("Invoice #").Bold().FontSize(7.5f);
|
||
h.Cell().Background(bg).Padding(3).Text("Customer").Bold().FontSize(7.5f);
|
||
h.Cell().Background(bg).Padding(3).Text("Date").Bold().FontSize(7.5f);
|
||
h.Cell().Background(bg).Padding(3).Text("Status").Bold().FontSize(7.5f);
|
||
h.Cell().Background(bg).Padding(3).AlignRight().Text("Subtotal").Bold().FontSize(7.5f);
|
||
h.Cell().Background(bg).Padding(3).AlignRight().Text("Tax %").Bold().FontSize(7.5f);
|
||
h.Cell().Background(bg).Padding(3).AlignRight().Text("Tax $").Bold().FontSize(7.5f);
|
||
h.Cell().Background(bg).Padding(3).AlignRight().Text("Total").Bold().FontSize(7.5f);
|
||
h.Cell().Background(bg).Padding(3).Text("Tax Account").Bold().FontSize(7.5f);
|
||
});
|
||
foreach (var inv in dto.Invoices)
|
||
{
|
||
var rowBg = inv.TaxAmount == 0 ? Colors.Grey.Lighten4 : Colors.White;
|
||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.InvoiceNumber).FontSize(7.5f);
|
||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.CustomerName).FontSize(7.5f);
|
||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.InvoiceDate.ToString("MM/dd/yyyy")).FontSize(7.5f);
|
||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.Status).FontSize(7.5f);
|
||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).AlignRight().Text($"{inv.SubTotal:C}").FontSize(7.5f);
|
||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).AlignRight().Text(inv.TaxAmount > 0 ? $"{inv.TaxPercent:F2}%" : "—").FontSize(7.5f);
|
||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).AlignRight().Text(inv.TaxAmount > 0 ? $"{inv.TaxAmount:C}" : "—").FontSize(7.5f).Bold();
|
||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).AlignRight().Text($"{inv.Total:C}").FontSize(7.5f);
|
||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.TaxAccountName).FontSize(7).FontColor(Colors.Grey.Darken1);
|
||
}
|
||
// Totals row
|
||
table.Cell().ColumnSpan(4).Background("#f0fdf4").Padding(3).AlignRight().Text("Totals").Bold().FontSize(7.5f);
|
||
table.Cell().Background("#f0fdf4").Padding(3).AlignRight().Text($"{dto.Invoices.Sum(i => i.SubTotal):C}").Bold().FontSize(7.5f);
|
||
table.Cell().Background("#f0fdf4").Padding(3);
|
||
table.Cell().Background("#f0fdf4").Padding(3).AlignRight().Text($"{dto.TotalTaxBilled:C}").Bold().FontSize(7.5f);
|
||
table.Cell().Background("#f0fdf4").Padding(3).AlignRight().Text($"{dto.Invoices.Sum(i => i.Total):C}").Bold().FontSize(7.5f);
|
||
table.Cell().Background("#f0fdf4").Padding(3);
|
||
});
|
||
});
|
||
|
||
page.Footer().AlignCenter().Text(text =>
|
||
{
|
||
text.DefaultTextStyle(s => s.FontSize(7.5f).FontColor(Colors.Grey.Medium));
|
||
text.Span("Sales Tax Liability Report | Invoice Basis | ");
|
||
text.CurrentPageNumber();
|
||
text.Span(" of ");
|
||
text.TotalPages();
|
||
});
|
||
});
|
||
});
|
||
|
||
return document.GeneratePdf();
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Generates an Accounts Payable Aging PDF. Layout mirrors GenerateArAgingPdfAsync:
|
||
/// a KPI summary band, a per-vendor summary table with aging columns, then a bill-detail
|
||
/// section grouped by vendor. Uses a red accent palette to visually distinguish AP from AR.
|
||
/// </summary>
|
||
public async Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto)
|
||
{
|
||
QuestPDF.Settings.License = LicenseType.Community;
|
||
const string accent = "#b91c1c";
|
||
|
||
return await Task.Run(() =>
|
||
{
|
||
var document = Document.Create(container =>
|
||
{
|
||
container.Page(page =>
|
||
{
|
||
page.Size(PageSizes.Letter);
|
||
page.Margin(0.6f, Unit.Inch);
|
||
page.PageColor(Colors.White);
|
||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||
|
||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Accounts Payable Aging",
|
||
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
|
||
|
||
page.Content().PaddingTop(12).Column(col =>
|
||
{
|
||
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
|
||
{
|
||
KpiCell(row, "Current", dto.TotalCurrent.ToString("C0"), "#16a34a");
|
||
KpiCell(row, "1–30 Days", dto.Total1to30.ToString("C0"), "#ca8a04");
|
||
KpiCell(row, "31–60 Days", dto.Total31to60.ToString("C0"), "#ea580c");
|
||
KpiCell(row, "61–90 Days", dto.Total61to90.ToString("C0"), "#dc2626");
|
||
KpiCell(row, "Over 90", dto.TotalOver90.ToString("C0"), "#7f1d1d");
|
||
KpiCell(row, "Total Owed", dto.TotalOutstanding.ToString("C0"), accent);
|
||
});
|
||
|
||
if (!dto.Vendors.Any())
|
||
{
|
||
col.Item().PaddingTop(20).AlignCenter()
|
||
.Text("All bills are paid — no outstanding balances.")
|
||
.FontSize(11).FontColor("#16a34a");
|
||
return;
|
||
}
|
||
|
||
col.Item().PaddingTop(14).Table(table =>
|
||
{
|
||
table.ColumnsDefinition(cols =>
|
||
{
|
||
cols.RelativeColumn(3);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
});
|
||
|
||
table.Header(h =>
|
||
{
|
||
foreach (var lbl in new[] { "Vendor", "Current", "1–30", "31–60", "61–90", "Over 90", "Total" })
|
||
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
|
||
});
|
||
|
||
var alt = false;
|
||
foreach (var vend in dto.Vendors)
|
||
{
|
||
var bg = alt ? "#f8fafc" : "#ffffff";
|
||
table.Cell().Background(bg).Padding(4).Text(vend.VendorName).FontSize(9).Bold();
|
||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "—").FontSize(9).FontColor("#16a34a");
|
||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "—").FontSize(9).FontColor("#ca8a04");
|
||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "—").FontSize(9).FontColor("#ea580c");
|
||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "—").FontSize(9).FontColor("#dc2626");
|
||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "—").FontSize(9).FontColor("#7f1d1d");
|
||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalBalance.ToString("C")).FontSize(9).Bold();
|
||
alt = !alt;
|
||
}
|
||
|
||
table.Cell().Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCurrent.ToString("C")).FontSize(9).Bold().FontColor("#16a34a");
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total1to30.ToString("C")).FontSize(9).Bold().FontColor("#ca8a04");
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total31to60.ToString("C")).FontSize(9).Bold().FontColor("#ea580c");
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total61to90.ToString("C")).FontSize(9).Bold().FontColor("#dc2626");
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOver90.ToString("C")).FontSize(9).Bold().FontColor("#7f1d1d");
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOutstanding.ToString("C")).FontSize(9).Bold();
|
||
});
|
||
|
||
col.Item().PaddingTop(16).Text("Bill Detail").FontSize(11).Bold();
|
||
|
||
foreach (var vend in dto.Vendors)
|
||
{
|
||
col.Item().PaddingTop(8).ShowEntire().Column(vendCol =>
|
||
{
|
||
vendCol.Item().Background("#f1f5f9").Padding(4).Text(vend.VendorName).Bold().FontSize(10);
|
||
|
||
vendCol.Item().Table(table =>
|
||
{
|
||
table.ColumnsDefinition(cols =>
|
||
{
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
});
|
||
|
||
table.Header(h =>
|
||
{
|
||
foreach (var lbl in new[] { "Bill #", "Bill Date", "Due Date", "Balance", "Age" })
|
||
h.Cell().Background("#e2e8f0").Padding(3).Text(lbl).Bold().FontSize(8);
|
||
});
|
||
|
||
foreach (var bill in vend.Bills.OrderBy(b => b.DaysOverdue))
|
||
{
|
||
var ageColor = bill.DaysOverdue <= 0 ? "#16a34a"
|
||
: bill.DaysOverdue <= 30 ? "#ca8a04"
|
||
: bill.DaysOverdue <= 60 ? "#ea580c"
|
||
: bill.DaysOverdue <= 90 ? "#dc2626"
|
||
: "#7f1d1d";
|
||
var ageLabel = bill.DaysOverdue <= 0 ? "Current" : $"{bill.DaysOverdue}d overdue";
|
||
|
||
table.Cell().Padding(3).Text(bill.BillNumber).FontSize(8);
|
||
table.Cell().Padding(3).Text(bill.BillDate.ToString("MM/dd/yyyy")).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
table.Cell().Padding(3).Text(bill.DueDate?.ToString("MM/dd/yyyy") ?? "—").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
table.Cell().AlignRight().Padding(3).Text(bill.BalanceDue.ToString("C")).Bold().FontSize(8)
|
||
.FontColor(bill.DaysOverdue > 30 ? "#dc2626" : "#000000");
|
||
table.Cell().Padding(3).Text(ageLabel).FontSize(8).FontColor(ageColor);
|
||
}
|
||
|
||
table.Cell().ColumnSpan(3).Background("#f1f5f9").AlignRight().Padding(3)
|
||
.Text($"{vend.VendorName} subtotal").Bold().FontSize(8).FontColor(Colors.Grey.Darken2);
|
||
table.Cell().Background("#f1f5f9").AlignRight().Padding(3).Text(vend.TotalBalance.ToString("C")).Bold().FontSize(8);
|
||
table.Cell().Background("#f1f5f9");
|
||
});
|
||
});
|
||
}
|
||
});
|
||
|
||
page.Footer().AlignCenter().Text(text =>
|
||
{
|
||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
});
|
||
});
|
||
return document.GeneratePdf();
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Generates a Trial Balance PDF. Each active account appears once with its balance in either
|
||
/// the Debit or Credit column based on AccountingRules sign conventions. A footer row shows
|
||
/// totals and a balanced/unbalanced indicator.
|
||
/// </summary>
|
||
public async Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto)
|
||
{
|
||
QuestPDF.Settings.License = LicenseType.Community;
|
||
const string accent = "#1a56db";
|
||
|
||
return await Task.Run(() =>
|
||
{
|
||
var document = Document.Create(container =>
|
||
{
|
||
container.Page(page =>
|
||
{
|
||
page.Size(PageSizes.Letter);
|
||
page.Margin(0.6f, Unit.Inch);
|
||
page.PageColor(Colors.White);
|
||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||
|
||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Trial Balance",
|
||
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
|
||
|
||
page.Content().PaddingTop(12).Column(col =>
|
||
{
|
||
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
|
||
{
|
||
KpiCell(row, "Total Debits", dto.TotalDebits.ToString("C0"), "#1a56db");
|
||
KpiCell(row, "Total Credits", dto.TotalCredits.ToString("C0"), "#1a56db");
|
||
KpiCell(row, "Status", dto.IsBalanced ? "Balanced ✓" : "Out of Balance ✗",
|
||
dto.IsBalanced ? "#16a34a" : "#dc2626");
|
||
});
|
||
|
||
if (!dto.Lines.Any())
|
||
{
|
||
col.Item().PaddingTop(20).AlignCenter()
|
||
.Text("No active accounts with balances found.")
|
||
.FontSize(11).FontColor(Colors.Grey.Darken1);
|
||
return;
|
||
}
|
||
|
||
col.Item().PaddingTop(14).Table(table =>
|
||
{
|
||
table.ColumnsDefinition(cols =>
|
||
{
|
||
cols.ConstantColumn(70);
|
||
cols.RelativeColumn(4);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
cols.RelativeColumn(2);
|
||
});
|
||
|
||
table.Header(h =>
|
||
{
|
||
foreach (var lbl in new[] { "Acct #", "Account Name", "Type", "Debit", "Credit" })
|
||
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
|
||
});
|
||
|
||
var alt = false;
|
||
foreach (var line in dto.Lines)
|
||
{
|
||
var bg = alt ? "#f8fafc" : "#ffffff";
|
||
table.Cell().Background(bg).Padding(4).Text(line.AccountNumber).FontSize(8).FontColor(Colors.Grey.Darken2);
|
||
table.Cell().Background(bg).Padding(4).Text(line.AccountName).FontSize(9);
|
||
table.Cell().Background(bg).Padding(4).Text(line.AccountType.ToString()).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.DebitBalance > 0 ? line.DebitBalance.ToString("C") : "").FontSize(9);
|
||
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.CreditBalance > 0 ? line.CreditBalance.ToString("C") : "").FontSize(9);
|
||
alt = !alt;
|
||
}
|
||
|
||
table.Cell().ColumnSpan(3).Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalDebits.ToString("C")).FontSize(9).Bold();
|
||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCredits.ToString("C")).FontSize(9).Bold();
|
||
});
|
||
});
|
||
|
||
page.Footer().AlignCenter().Text(text =>
|
||
{
|
||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
});
|
||
});
|
||
return document.GeneratePdf();
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Generates a Cash Flow Statement PDF with three sections (Operating, Investing, Financing)
|
||
/// plus a summary reconciling beginning → ending cash. Uses a teal accent palette to
|
||
/// visually distinguish it from the other financial statements.
|
||
/// </summary>
|
||
public async Task<byte[]> GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto)
|
||
{
|
||
QuestPDF.Settings.License = LicenseType.Community;
|
||
const string accent = "#0891b2";
|
||
|
||
return await Task.Run(() =>
|
||
{
|
||
var document = Document.Create(container =>
|
||
{
|
||
container.Page(page =>
|
||
{
|
||
page.Size(PageSizes.Letter);
|
||
page.Margin(0.6f, Unit.Inch);
|
||
page.PageColor(Colors.White);
|
||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||
|
||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Cash Flow Statement",
|
||
$"{dto.From:MMMM d, yyyy} – {dto.To:MMMM d, yyyy}", accent));
|
||
|
||
page.Content().PaddingTop(12).Column(col =>
|
||
{
|
||
col.Spacing(4);
|
||
|
||
// ── Operating Activities ──────────────────────────────────────
|
||
col.Item().Text("Operating Activities").Bold().FontSize(11).FontColor(accent);
|
||
col.Item().Table(t =>
|
||
{
|
||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||
CfRow(t, "Cash Received from Customers", dto.CashFromCustomers, false);
|
||
CfRow(t, "Cash Paid to Vendors (Bills)", -dto.CashToVendors, false);
|
||
CfRow(t, "Cash Paid for Expenses", -dto.CashForExpenses, false);
|
||
CfTotalRow(t, "Net Cash from Operating Activities", dto.NetOperating);
|
||
});
|
||
|
||
col.Item().PaddingTop(10).Text("Investing Activities").Bold().FontSize(11).FontColor(accent);
|
||
col.Item().Table(t =>
|
||
{
|
||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||
if (dto.InvestingLines.Count == 0)
|
||
CfRow(t, "No investing activities recorded", 0, true);
|
||
else
|
||
foreach (var line in dto.InvestingLines)
|
||
CfRow(t, line.Label, line.Amount, false);
|
||
CfTotalRow(t, "Net Cash from Investing Activities", dto.NetInvesting);
|
||
});
|
||
|
||
col.Item().PaddingTop(10).Text("Financing Activities").Bold().FontSize(11).FontColor(accent);
|
||
col.Item().Table(t =>
|
||
{
|
||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||
if (dto.FinancingLines.Count == 0)
|
||
CfRow(t, "No financing activities recorded", 0, true);
|
||
else
|
||
foreach (var line in dto.FinancingLines)
|
||
CfRow(t, line.Label, line.Amount, false);
|
||
CfTotalRow(t, "Net Cash from Financing Activities", dto.NetFinancing);
|
||
});
|
||
|
||
// ── Summary ───────────────────────────────────────────────────
|
||
col.Item().PaddingTop(12).Table(t =>
|
||
{
|
||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||
|
||
void SumRow(string label, decimal amount, bool bold = false)
|
||
{
|
||
var bg = bold ? "#e0f2fe" : "#ffffff";
|
||
var lText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).Text(label).FontSize(9);
|
||
if (bold) lText.Bold();
|
||
var vText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).AlignRight()
|
||
.Text(amount.ToString("C")).FontSize(9)
|
||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||
if (bold) vText.Bold();
|
||
}
|
||
|
||
SumRow("Beginning Cash Balance", dto.BeginningCash);
|
||
SumRow("Net Change in Cash", dto.NetChangeInCash);
|
||
SumRow("Ending Cash Balance", dto.EndingCash, bold: true);
|
||
});
|
||
});
|
||
|
||
page.Footer().AlignCenter().Text(text =>
|
||
{
|
||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||
});
|
||
});
|
||
});
|
||
return document.GeneratePdf();
|
||
});
|
||
|
||
static void CfRow(TableDescriptor t, string label, decimal amount, bool muted)
|
||
{
|
||
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
|
||
.PaddingVertical(3).PaddingHorizontal(6)
|
||
.Text(label).FontSize(9).FontColor(muted ? Colors.Grey.Medium : Colors.Black);
|
||
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
|
||
.PaddingVertical(3).PaddingHorizontal(6).AlignRight()
|
||
.Text(muted ? "" : amount.ToString("C")).FontSize(9)
|
||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||
}
|
||
|
||
static void CfTotalRow(TableDescriptor t, string label, decimal amount)
|
||
{
|
||
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6)
|
||
.Text(label).Bold().FontSize(9);
|
||
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6).AlignRight()
|
||
.Text(amount.ToString("C")).Bold().FontSize(9)
|
||
.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);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
}
|