diff --git a/src/PowderCoating.Application/DTOs/Invoice/InvoiceDtos.cs b/src/PowderCoating.Application/DTOs/Invoice/InvoiceDtos.cs index e3699db..c3d8a13 100644 --- a/src/PowderCoating.Application/DTOs/Invoice/InvoiceDtos.cs +++ b/src/PowderCoating.Application/DTOs/Invoice/InvoiceDtos.cs @@ -33,6 +33,10 @@ public class InvoiceDto public string? CustomerEmail { get; set; } public string? CustomerPhone { get; set; } public string? CustomerMobilePhone { get; set; } + public string? CustomerAddress { get; set; } + public string? CustomerCity { get; set; } + public string? CustomerState { get; set; } + public string? CustomerZipCode { get; set; } public bool CustomerNotifyByEmail { get; set; } public bool CustomerNotifyBySms { get; set; } public string? PreparedById { get; set; } diff --git a/src/PowderCoating.Application/Interfaces/IPdfService.cs b/src/PowderCoating.Application/Interfaces/IPdfService.cs index f680c68..b377897 100644 --- a/src/PowderCoating.Application/Interfaces/IPdfService.cs +++ b/src/PowderCoating.Application/Interfaces/IPdfService.cs @@ -25,6 +25,12 @@ public interface IPdfService CompanyInfoDto companyInfo, QuoteTemplateSettingsDto? template = null); + Task GeneratePackingSlipPdfAsync( + InvoiceDto invoiceDto, + byte[]? companyLogo, + string? companyLogoContentType, + CompanyInfoDto companyInfo); + Task GeneratePurchaseOrderPdfAsync( PurchaseOrderDto po, byte[]? companyLogo, diff --git a/src/PowderCoating.Application/Mappings/InvoiceProfile.cs b/src/PowderCoating.Application/Mappings/InvoiceProfile.cs index 419b780..378bce7 100644 --- a/src/PowderCoating.Application/Mappings/InvoiceProfile.cs +++ b/src/PowderCoating.Application/Mappings/InvoiceProfile.cs @@ -29,6 +29,10 @@ public class InvoiceProfile : Profile : null)) .ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null)) .ForMember(d => d.CustomerMobilePhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.MobilePhone : null)) + .ForMember(d => d.CustomerAddress, o => o.MapFrom(s => s.Customer != null ? s.Customer.Address : null)) + .ForMember(d => d.CustomerCity, o => o.MapFrom(s => s.Customer != null ? s.Customer.City : null)) + .ForMember(d => d.CustomerState, o => o.MapFrom(s => s.Customer != null ? s.Customer.State : null)) + .ForMember(d => d.CustomerZipCode, o => o.MapFrom(s => s.Customer != null ? s.Customer.ZipCode : null)) .ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail)) .ForMember(d => d.CustomerNotifyBySms, o => o.MapFrom(s => s.Customer != null && s.Customer.NotifyBySms)) .ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null diff --git a/src/PowderCoating.Application/Services/PdfService.cs b/src/PowderCoating.Application/Services/PdfService.cs index c4db5b0..1e3cae3 100644 --- a/src/PowderCoating.Application/Services/PdfService.cs +++ b/src/PowderCoating.Application/Services/PdfService.cs @@ -2753,4 +2753,187 @@ public class PdfService : IPdfService .FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black); } } + + // ----------------------------------------------------------------------- + // Packing Slip + // ----------------------------------------------------------------------- + + /// + /// 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. + /// + public async Task 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(); + }); + } + + /// + /// Header for the packing slip: company branding left, "PACKING SLIP" title + invoice/date info right. + /// + 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); + }); + } + + /// + /// Body of the packing slip: customer info block, optional PO number, and an items table + /// showing description, color, and quantity — no prices. + /// + 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); + }); + }); + }); + } } diff --git a/src/PowderCoating.Web/Controllers/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index 3abf803..9c9a7f6 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -1782,6 +1782,59 @@ public class InvoicesController : Controller } } + // ----------------------------------------------------------------------- + // GET: /Invoices/DownloadPackingSlip/5 + // ----------------------------------------------------------------------- + /// + /// Generates a no-price packing slip PDF for physical pickup/delivery paperwork. + /// Reuses the same company branding and invoice data pipeline as DownloadPdf but + /// delegates to GeneratePackingSlipPdfAsync which omits all pricing columns. + /// + public async Task DownloadPackingSlip(int? id, bool inline = false) + { + if (id == null) return NotFound(); + + try + { + var invoice = await LoadInvoiceForViewAsync(id.Value); + if (invoice == null) return NotFound(); + + var currentUser = await _userManager.GetUserAsync(User); + if (currentUser == null) return Unauthorized(); + + var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId); + var companyInfo = new Application.DTOs.Company.CompanyInfoDto + { + CompanyName = company?.CompanyName ?? string.Empty, + Phone = company?.Phone, + Address = company?.Address, + City = company?.City, + State = company?.State, + ZipCode = company?.ZipCode, + PrimaryContactEmail = company?.PrimaryContactEmail + }; + + var (logoData, logoContentType) = await LoadCompanyLogoAsync(company); + var dto = await BuildInvoiceDtoAsync(invoice); + var pdfBytes = await _pdfService.GeneratePackingSlipPdfAsync(dto, logoData, logoContentType, companyInfo); + var fileName = $"PackingSlip-{invoice.InvoiceNumber}.pdf"; + + if (inline) + { + Response.Headers["Content-Disposition"] = $"inline; filename=\"{fileName}\""; + return File(pdfBytes, "application/pdf"); + } + + return File(pdfBytes, "application/pdf", fileName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating packing slip for invoice {Id}", id); + TempData["ErrorPermanent"] = $"Packing slip generation failed: {ex.Message}"; + return RedirectToAction(nameof(Details), new { id }); + } + } + // ----------------------------------------------------------------------- // GET: /Invoices/ForJob/5 — redirect to existing or Create // ----------------------------------------------------------------------- diff --git a/src/PowderCoating.Web/Views/Invoices/Details.cshtml b/src/PowderCoating.Web/Views/Invoices/Details.cshtml index 884d1e1..30bd6e2 100644 --- a/src/PowderCoating.Web/Views/Invoices/Details.cshtml +++ b/src/PowderCoating.Web/Views/Invoices/Details.cshtml @@ -51,6 +51,10 @@ PDF + + Packing Slip + Back