using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using OfficeOpenXml; using OfficeOpenXml.Style; using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; using System.Drawing; using System.IO.Compression; using System.Text; namespace PowderCoating.Web.Controllers; /// /// SuperAdmin-only controller for exporting any tenant company's operational data. /// Supports XLSX (single workbook, multiple sheets) and CSV (per-sheet files inside a ZIP archive). /// All queries use IgnoreQueryFilters() to bypass the global soft-delete and multi-tenancy /// filters that normally restrict non-SuperAdmin users to their own company's non-deleted records. /// [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class DataExportController : Controller { private readonly ApplicationDbContext _db; /// /// Initializes the controller and sets the EPPlus license context to NonCommercial. /// EPPlus 5+ requires an explicit license declaration at startup or an exception is thrown; /// this must be done before any is constructed. /// public DataExportController(ApplicationDbContext db) { _db = db; ExcelPackage.LicenseContext = LicenseContext.NonCommercial; } // ── GET: Index ─────────────────────────────────────────────────────────── /// /// Displays the export selection page, listing every non-deleted tenant company so the /// SuperAdmin can choose which company to export and which data sets to include. /// Uses IgnoreQueryFilters() because the global filter would otherwise scope results /// to the SuperAdmin's own company context, which would return nothing meaningful here. /// public async Task Index() { var companies = await _db.Companies .AsNoTracking().IgnoreQueryFilters() .Where(c => !c.IsDeleted) .OrderBy(c => c.CompanyName) .Select(c => new CompanyExportSummary { Id = c.Id, CompanyName = c.CompanyName, Plan = c.SubscriptionPlan.ToString(), IsActive = c.IsActive, CreatedAt = c.CreatedAt }) .ToListAsync(); return View(companies); } // ── POST: Export ───────────────────────────────────────────────────────── /// /// Accepts the company ID, selected sheet names, and desired format (xlsx or csv) /// and delegates to the appropriate format builder. /// Sheet names are reordered into a canonical order by so that the /// resulting file has a consistent layout regardless of the order the user checked the boxes. /// Illegal filename characters are stripped from the company name to produce a safe file name. /// /// ID of the tenant company whose data should be exported. /// Array of sheet/entity names selected on the form (e.g. "Customers", "Jobs"). /// Output format: "xlsx" (default) or "csv". [HttpPost, ValidateAntiForgeryToken] public async Task Export(int companyId, string[] sheets, string format = "xlsx") { var company = await _db.Companies .AsNoTracking().IgnoreQueryFilters() .FirstOrDefaultAsync(c => c.Id == companyId && !c.IsDeleted); if (company == null) return NotFound(); if (sheets == null || sheets.Length == 0) { TempData["Error"] = "Select at least one data type to export."; return RedirectToAction(nameof(Index)); } var safeName = string.Concat(company.CompanyName.Split(Path.GetInvalidFileNameChars())); var ordered = OrderSheets(sheets); if (format == "csv") return await ExportAsCsv(companyId, company.CompanyName, safeName, ordered); return await ExportAsXlsx(companyId, company.CompanyName, safeName, ordered); } /// /// Builds the XLSX export: one EPPlus workbook with one worksheet per selected data type, /// plus a leading "Export Info" metadata sheet. /// The workbook is assembled entirely in memory (no temp files) and returned as a file download, /// which avoids disk I/O and temp-file cleanup concerns on the server. /// /// Tenant company ID used to filter all sheet queries. /// Human-readable company name written into the metadata sheet. /// Filesystem-safe company name used in the download file name. /// Sheet names in canonical order; only listed names are added. private async Task ExportAsXlsx(int companyId, string companyName, string safeName, string[] ordered) { using var package = new ExcelPackage(); var headerColor = Color.FromArgb(31, 78, 121); foreach (var sheet in ordered) { switch (sheet) { case "Customers": await AddCustomersSheet(package, companyId, headerColor); break; case "Jobs": await AddJobsSheet(package, companyId, headerColor); break; case "Quotes": await AddQuotesSheet(package, companyId, headerColor); break; case "Invoices": await AddInvoicesSheet(package, companyId, headerColor); break; case "Inventory": await AddInventorySheet(package, companyId, headerColor); break; case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break; case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break; case "Users": await AddUsersSheet(package, companyId, headerColor); break; } } AddMetadataSheet(package, companyName, ordered); var fileName = $"{safeName}_Export_{DateTime.UtcNow:yyyyMMdd}.xlsx"; return File(package.GetAsByteArray(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName); } /// /// Builds the CSV export: one CSV file per selected data type, bundled into a single in-memory /// ZIP archive that is returned as a file download. /// A ZIP is used rather than multiple individual file downloads because browsers do not support /// multi-file responses; the archive also keeps all related CSVs together for the recipient. /// leaveOpen: true is passed to so the underlying /// remains valid after the archive is disposed, allowing /// ms.ToArray() to capture the final bytes before the stream goes out of scope. /// /// Tenant company ID used to filter all CSV queries. /// Human-readable company name written into the metadata CSV. /// Filesystem-safe company name used in the download file name. /// Sheet names in canonical order; only listed names are included. private async Task ExportAsCsv(int companyId, string companyName, string safeName, string[] ordered) { using var ms = new MemoryStream(); using var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true); // Metadata entry var meta = new StringBuilder(); meta.AppendLine("Field,Value"); meta.AppendLine($"Company,{CsvEscape(companyName)}"); meta.AppendLine($"Exported At,{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"); meta.AppendLine($"Sheets,{CsvEscape(string.Join("; ", ordered))}"); WriteCsvEntry(zip, "Export_Info.csv", meta.ToString()); foreach (var sheet in ordered) { switch (sheet) { case "Customers": WriteCsvEntry(zip, "Customers.csv", await BuildCustomersCsv(companyId)); break; case "Jobs": WriteCsvEntry(zip, "Jobs.csv", await BuildJobsCsv(companyId)); break; case "Quotes": WriteCsvEntry(zip, "Quotes.csv", await BuildQuotesCsv(companyId)); break; case "Invoices": WriteCsvEntry(zip, "Invoices.csv", await BuildInvoicesCsv(companyId)); break; case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break; case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break; case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break; case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break; } } zip.Dispose(); ms.Position = 0; var fileName = $"{safeName}_Export_{DateTime.UtcNow:yyyyMMdd}.zip"; return File(ms.ToArray(), "application/zip", fileName); } /// /// Writes a single CSV string as a named entry inside an existing . /// The BOM (encoderShouldEmitUTF8Identifier: true) is included so that Excel opens /// the CSV with correct encoding without requiring an import wizard. /// /// The open archive to write the entry into. /// File name for the entry inside the ZIP (e.g. "Customers.csv"). /// The complete CSV text to write. private static void WriteCsvEntry(ZipArchive zip, string entryName, string content) { var entry = zip.CreateEntry(entryName, System.IO.Compression.CompressionLevel.Optimal); using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); writer.Write(content); } // ── Sheet builders ─────────────────────────────────────────────────────── /// /// Adds an "Export Info" worksheet at position 1 of the workbook containing company name, /// export timestamp, and list of included sheets. /// Inserting this sheet first (via MoveToStart) gives recipients orientation context /// before they open the data sheets. /// /// The EPPlus workbook to add the sheet to. /// Company name to record in the sheet. /// Names of the data sheets included in this export. private void AddMetadataSheet(ExcelPackage pkg, string companyName, string[] sheets) { // Insert at position 1 var ws = pkg.Workbook.Worksheets.Add("Export Info"); pkg.Workbook.Worksheets.MoveToStart("Export Info"); ws.Cells[1, 1].Value = "Company"; ws.Cells[1, 2].Value = companyName; ws.Cells[2, 1].Value = "Exported At"; ws.Cells[2, 2].Value = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss UTC"); ws.Cells[3, 1].Value = "Sheets"; ws.Cells[3, 2].Value = string.Join(", ", sheets); ws.Column(1).Width = 20; ws.Column(2).Width = 40; ws.Cells[1, 1, 3, 1].Style.Font.Bold = true; } /// /// Adds a "Customers" worksheet with one row per non-deleted customer for the specified company. /// IgnoreQueryFilters() is required because the global EF filter would otherwise /// restrict results to the SuperAdmin's own tenant context. /// private async Task AddCustomersSheet(ExcelPackage pkg, int companyId, Color hdr) { var data = await _db.Customers.AsNoTracking().IgnoreQueryFilters() .Where(c => c.CompanyId == companyId && !c.IsDeleted) .OrderBy(c => c.CompanyName) .ToListAsync(); var ws = pkg.Workbook.Worksheets.Add("Customers"); var headers = new[] { "ID", "Company Name", "First Name", "Last Name", "Email", "Phone", "Commercial", "City", "State", "Active", "Credit Limit", "Current Balance", "Created At" }; WriteHeader(ws, headers, hdr); for (int i = 0; i < data.Count; i++) { var r = i + 2; var c = data[i]; ws.Cells[r, 1].Value = c.Id; ws.Cells[r, 2].Value = c.CompanyName; ws.Cells[r, 3].Value = c.ContactFirstName; ws.Cells[r, 4].Value = c.ContactLastName; ws.Cells[r, 5].Value = c.Email; ws.Cells[r, 6].Value = c.Phone; ws.Cells[r, 7].Value = c.IsCommercial ? "Yes" : "No"; ws.Cells[r, 8].Value = c.City; ws.Cells[r, 9].Value = c.State; ws.Cells[r, 10].Value = c.IsActive ? "Yes" : "No"; ws.Cells[r, 11].Value = c.CreditLimit; ws.Cells[r, 12].Value = c.CurrentBalance; ws.Cells[r, 13].Value = c.CreatedAt.ToString("yyyy-MM-dd"); } AutoFit(ws, headers.Length); } /// /// Adds a "Jobs" worksheet with one row per non-deleted job for the specified company. /// Job status and priority are lookup-table entities (not enums), so they must be eagerly /// loaded via Include; the DisplayName navigation property is used for the /// human-readable label, falling back to the raw FK integer if the navigation is null. /// private async Task AddJobsSheet(ExcelPackage pkg, int companyId, Color hdr) { var data = await _db.Jobs.AsNoTracking().IgnoreQueryFilters() .Where(j => j.CompanyId == companyId && !j.IsDeleted) .Include(j => j.Customer) .Include(j => j.JobStatus) .Include(j => j.JobPriority) .OrderByDescending(j => j.CreatedAt) .ToListAsync(); var ws = pkg.Workbook.Worksheets.Add("Jobs"); var headers = new[] { "ID", "Job Number", "Customer", "Status", "Priority", "Description", "Due Date", "Final Price", "Created At" }; WriteHeader(ws, headers, hdr); for (int i = 0; i < data.Count; i++) { var r = i + 2; var j = data[i]; ws.Cells[r, 1].Value = j.Id; ws.Cells[r, 2].Value = j.JobNumber; ws.Cells[r, 3].Value = j.Customer?.CompanyName ?? $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(); ws.Cells[r, 4].Value = j.JobStatus?.DisplayName ?? j.JobStatusId.ToString(); ws.Cells[r, 5].Value = j.JobPriority?.DisplayName ?? j.JobPriorityId.ToString(); ws.Cells[r, 6].Value = j.Description; ws.Cells[r, 7].Value = j.DueDate?.ToString("yyyy-MM-dd"); ws.Cells[r, 8].Value = j.FinalPrice; ws.Cells[r, 9].Value = j.CreatedAt.ToString("yyyy-MM-dd"); } AutoFit(ws, headers.Length); } /// /// Adds a "Quotes" worksheet with one row per non-deleted quote for the specified company. /// Quotes can belong to prospects (no CustomerId) or linked customers; the customer/prospect /// column shows ProspectCompanyName when set, otherwise falls back to the customer FK. /// Quote status is a lookup-table entity and is eagerly loaded so DisplayName is available. /// private async Task AddQuotesSheet(ExcelPackage pkg, int companyId, Color hdr) { var data = await _db.Quotes.AsNoTracking().IgnoreQueryFilters() .Where(q => q.CompanyId == companyId && !q.IsDeleted) .Include(q => q.QuoteStatus) .OrderByDescending(q => q.QuoteDate) .ToListAsync(); var ws = pkg.Workbook.Worksheets.Add("Quotes"); var headers = new[] { "ID", "Quote Number", "Customer / Prospect", "Status", "Quote Date", "Expiration Date", "Subtotal", "Tax", "Total" }; WriteHeader(ws, headers, hdr); for (int i = 0; i < data.Count; i++) { var r = i + 2; var q = data[i]; ws.Cells[r, 1].Value = q.Id; ws.Cells[r, 2].Value = q.QuoteNumber; ws.Cells[r, 3].Value = string.IsNullOrEmpty(q.ProspectCompanyName) ? $"Customer #{q.CustomerId}" : q.ProspectCompanyName; ws.Cells[r, 4].Value = q.QuoteStatus?.DisplayName ?? q.QuoteStatusId.ToString(); ws.Cells[r, 5].Value = q.QuoteDate.ToString("yyyy-MM-dd"); ws.Cells[r, 6].Value = q.ExpirationDate?.ToString("yyyy-MM-dd"); ws.Cells[r, 7].Value = q.SubTotal; ws.Cells[r, 8].Value = q.TaxAmount; ws.Cells[r, 9].Value = q.Total; } AutoFit(ws, headers.Length); } /// /// Adds an "Inventory" worksheet with one row per non-deleted inventory item for the /// specified company, ordered alphabetically by name for easy scanning. /// private async Task AddInventorySheet(ExcelPackage pkg, int companyId, Color hdr) { var data = await _db.InventoryItems.AsNoTracking().IgnoreQueryFilters() .Where(i => i.CompanyId == companyId && !i.IsDeleted) .OrderBy(i => i.Name) .ToListAsync(); var ws = pkg.Workbook.Worksheets.Add("Inventory"); var headers = new[] { "ID", "Name", "SKU", "Category", "Qty on Hand", "Unit", "Unit Cost", "Reorder Point", "Manufacturer", "Color" }; WriteHeader(ws, headers, hdr); for (int i = 0; i < data.Count; i++) { var r = i + 2; var inv = data[i]; ws.Cells[r, 1].Value = inv.Id; ws.Cells[r, 2].Value = inv.Name; ws.Cells[r, 3].Value = inv.SKU; ws.Cells[r, 4].Value = inv.Category; ws.Cells[r, 5].Value = inv.QuantityOnHand; ws.Cells[r, 6].Value = inv.UnitOfMeasure; ws.Cells[r, 7].Value = inv.UnitCost; ws.Cells[r, 8].Value = inv.ReorderPoint; ws.Cells[r, 9].Value = inv.Manufacturer; ws.Cells[r, 10].Value = inv.ColorName; } AutoFit(ws, headers.Length); } /// /// Adds an "Equipment" worksheet with one row per non-deleted equipment record for the /// specified company. Equipment status is stored as an enum on the entity and is /// serialized via ToString() for a human-readable string in the export. /// private async Task AddEquipmentSheet(ExcelPackage pkg, int companyId, Color hdr) { var data = await _db.Equipment.AsNoTracking().IgnoreQueryFilters() .Where(e => e.CompanyId == companyId && !e.IsDeleted) .OrderBy(e => e.EquipmentName) .ToListAsync(); var ws = pkg.Workbook.Worksheets.Add("Equipment"); var headers = new[] { "ID", "Name", "Type", "Serial Number", "Model", "Status", "Purchase Date", "Purchase Price", "Next Maintenance" }; WriteHeader(ws, headers, hdr); for (int i = 0; i < data.Count; i++) { var r = i + 2; var e = data[i]; ws.Cells[r, 1].Value = e.Id; ws.Cells[r, 2].Value = e.EquipmentName; ws.Cells[r, 3].Value = e.EquipmentType; ws.Cells[r, 4].Value = e.SerialNumber; ws.Cells[r, 5].Value = e.Model; ws.Cells[r, 6].Value = e.Status.ToString(); ws.Cells[r, 7].Value = e.PurchaseDate?.ToString("yyyy-MM-dd"); ws.Cells[r, 8].Value = e.PurchasePrice; ws.Cells[r, 9].Value = e.NextScheduledMaintenance?.ToString("yyyy-MM-dd"); } AutoFit(ws, headers.Length); } /// /// Adds a "Vendors" worksheet with one row per non-deleted vendor (supplier) for the /// specified company, ordered alphabetically by company name. /// private async Task AddVendorsSheet(ExcelPackage pkg, int companyId, Color hdr) { var data = await _db.Vendors.AsNoTracking().IgnoreQueryFilters() .Where(s => s.CompanyId == companyId && !s.IsDeleted) .OrderBy(s => s.CompanyName) .ToListAsync(); var ws = pkg.Workbook.Worksheets.Add("Vendors"); var headers = new[] { "ID", "Company Name", "Contact", "Email", "Phone", "City", "State", "Preferred", "Active" }; WriteHeader(ws, headers, hdr); for (int i = 0; i < data.Count; i++) { var r = i + 2; var s = data[i]; ws.Cells[r, 1].Value = s.Id; ws.Cells[r, 2].Value = s.CompanyName; ws.Cells[r, 3].Value = s.ContactName; ws.Cells[r, 4].Value = s.Email; ws.Cells[r, 5].Value = s.Phone; ws.Cells[r, 6].Value = s.City; ws.Cells[r, 7].Value = s.State; ws.Cells[r, 8].Value = s.IsPreferred ? "Yes" : "No"; ws.Cells[r, 9].Value = s.IsActive ? "Yes" : "No"; } AutoFit(ws, headers.Length); } /// /// Adds an "Invoices" worksheet with one row per non-deleted invoice for the specified company. /// The customer navigation is eagerly loaded so the customer name can be rendered; when a /// customer record is missing (data anomaly), the FK integer is shown as a fallback. /// BalanceDue is a computed property (Total - AmountPaid) that reflects partial /// payment status without requiring an additional query. /// private async Task AddInvoicesSheet(ExcelPackage pkg, int companyId, Color hdr) { var data = await _db.Invoices.AsNoTracking().IgnoreQueryFilters() .Where(i => i.CompanyId == companyId && !i.IsDeleted) .Include(i => i.Customer) .OrderByDescending(i => i.InvoiceDate) .ToListAsync(); var ws = pkg.Workbook.Worksheets.Add("Invoices"); var headers = new[] { "ID", "Invoice #", "Customer", "Status", "Invoice Date", "Due Date", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" }; WriteHeader(ws, headers, hdr); for (int i = 0; i < data.Count; i++) { var r = i + 2; var inv = data[i]; var customerName = inv.Customer != null ? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim()) : $"Customer #{inv.CustomerId}"; ws.Cells[r, 1].Value = inv.Id; ws.Cells[r, 2].Value = inv.InvoiceNumber; ws.Cells[r, 3].Value = customerName; ws.Cells[r, 4].Value = inv.Status.ToString(); ws.Cells[r, 5].Value = inv.InvoiceDate.ToString("yyyy-MM-dd"); ws.Cells[r, 6].Value = inv.DueDate?.ToString("yyyy-MM-dd"); ws.Cells[r, 7].Value = inv.SubTotal; ws.Cells[r, 8].Value = inv.TaxAmount; ws.Cells[r, 9].Value = inv.Total; ws.Cells[r, 10].Value = inv.AmountPaid; ws.Cells[r, 11].Value = inv.BalanceDue; } AutoFit(ws, headers.Length); } /// /// Adds a "Users" worksheet with one row per user belonging to the specified company. /// Unlike most other sheet builders this query does NOT add && !c.IsDeleted /// because ASP.NET Identity users are soft-deleted by setting IsActive = false /// rather than the standard IsDeleted flag; all users, active or inactive, are included /// so the tenant has a full record for compliance/audit purposes. /// private async Task AddUsersSheet(ExcelPackage pkg, int companyId, Color hdr) { var data = await _db.Users.AsNoTracking().IgnoreQueryFilters() .Where(u => u.CompanyId == companyId) .OrderBy(u => u.LastName) .ToListAsync(); var ws = pkg.Workbook.Worksheets.Add("Users"); var headers = new[] { "ID", "First Name", "Last Name", "Email", "Role", "Active", "Hire Date", "Last Login", "Created At" }; WriteHeader(ws, headers, hdr); for (int i = 0; i < data.Count; i++) { var r = i + 2; var u = data[i]; ws.Cells[r, 1].Value = u.Id; ws.Cells[r, 2].Value = u.FirstName; ws.Cells[r, 3].Value = u.LastName; ws.Cells[r, 4].Value = u.Email; ws.Cells[r, 5].Value = u.CompanyRole; ws.Cells[r, 6].Value = u.IsActive ? "Yes" : "No"; ws.Cells[r, 7].Value = u.HireDate.ToString("yyyy-MM-dd"); ws.Cells[r, 8].Value = u.LastLoginDate?.ToString("yyyy-MM-dd") ?? "Never"; ws.Cells[r, 9].Value = u.CreatedAt.ToString("yyyy-MM-dd"); } AutoFit(ws, headers.Length); } // ── CSV builders ───────────────────────────────────────────────────────── /// /// Builds the customers CSV string for the specified company. /// Column names match exactly so the file can be /// re-imported via Tools → Bulk Import without any manual header editing. /// private async Task BuildCustomersCsv(int companyId) { var data = await _db.Customers.AsNoTracking().IgnoreQueryFilters() .Include(c => c.PricingTier) .Where(c => c.CompanyId == companyId && !c.IsDeleted).OrderBy(c => c.CompanyName).ToListAsync(); var sb = new StringBuilder(); sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes"); foreach (var c in data) { var customerType = c.IsCommercial ? "Commercial" : "Non-Commercial"; sb.AppendLine($"{CsvEscape(c.CompanyName)},{CsvEscape(c.ContactFirstName)},{CsvEscape(c.ContactLastName)},{CsvEscape(c.Email)},{CsvEscape(c.Phone)},{CsvEscape(c.MobilePhone)},{CsvEscape(c.Address)},{CsvEscape(c.City)},{CsvEscape(c.State)},{CsvEscape(c.ZipCode)},{CsvEscape(c.Country)},{customerType},{CsvEscape(c.PricingTier?.TierName)},{c.CreditLimit},{CsvEscape(c.PaymentTerms)},{c.IsTaxExempt.ToString().ToLower()},{CsvEscape(c.TaxId)},{c.IsActive.ToString().ToLower()},{CsvEscape(c.GeneralNotes)}"); } return sb.ToString(); } /// /// Builds the jobs CSV string for the specified company. /// Column names match exactly so the file can be re-imported. /// CustomerEmail is included (not the display name) because the importer resolves /// the customer FK by email lookup. /// private async Task BuildJobsCsv(int companyId) { var data = await _db.Jobs.AsNoTracking().IgnoreQueryFilters() .Where(j => j.CompanyId == companyId && !j.IsDeleted) .Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority) .OrderByDescending(j => j.CreatedAt).ToListAsync(); var sb = new StringBuilder(); sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,FinalPrice,CustomerPO,SpecialInstructions,Notes"); foreach (var j in data) { var customerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName) ? j.Customer.CompanyName : $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(); sb.AppendLine($"{CsvEscape(j.JobNumber)},{CsvEscape(j.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(j.JobStatus?.DisplayName)},{CsvEscape(j.JobPriority?.DisplayName)},{j.ScheduledDate?.ToString("yyyy-MM-dd")},{j.DueDate?.ToString("yyyy-MM-dd")},{j.FinalPrice},{CsvEscape(j.CustomerPO)},{CsvEscape(j.SpecialInstructions)},{CsvEscape(j.InternalNotes)}"); } return sb.ToString(); } /// /// Builds the quotes CSV string for the specified company. /// Column names match exactly so the file can be re-imported. /// Customer-linked quotes use CustomerEmail; prospect quotes use the ProspectCompany/Contact/Email/Phone fields. /// private async Task BuildQuotesCsv(int companyId) { var data = await _db.Quotes.AsNoTracking().IgnoreQueryFilters() .Where(q => q.CompanyId == companyId && !q.IsDeleted) .Include(q => q.Customer).Include(q => q.QuoteStatus).OrderByDescending(q => q.QuoteDate).ToListAsync(); var sb = new StringBuilder(); sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,Subtotal,TaxAmount,Total,Notes,TermsAndConditions"); foreach (var q in data) { var customerName = !string.IsNullOrWhiteSpace(q.Customer?.CompanyName) ? q.Customer.CompanyName : $"{q.Customer?.ContactFirstName} {q.Customer?.ContactLastName}".Trim(); sb.AppendLine($"{CsvEscape(q.QuoteNumber)},{CsvEscape(q.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(q.ProspectCompanyName)},{CsvEscape(q.ProspectContactName)},{CsvEscape(q.ProspectEmail)},{CsvEscape(q.ProspectPhone)},{CsvEscape(q.QuoteStatus?.DisplayName)},{q.QuoteDate:yyyy-MM-dd},{q.ExpirationDate?.ToString("yyyy-MM-dd")},{q.SubTotal},{q.TaxAmount},{q.Total},{CsvEscape(q.Notes)},{CsvEscape(q.Terms)}"); } return sb.ToString(); } /// /// Builds the invoices CSV string for the specified company. /// Eagerly loads Customer so the customer name is available without a second query. /// When a customer record cannot be found the FK integer is used as a fallback. /// private async Task BuildInvoicesCsv(int companyId) { var data = await _db.Invoices.AsNoTracking().IgnoreQueryFilters() .Where(i => i.CompanyId == companyId && !i.IsDeleted) .Include(i => i.Customer).OrderByDescending(i => i.InvoiceDate).ToListAsync(); var sb = new StringBuilder(); sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Subtotal,Tax,Total,Amount Paid,Balance Due"); foreach (var inv in data) { var cust = inv.Customer != null ? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim()) : $"Customer #{inv.CustomerId}"; sb.AppendLine($"{inv.Id},{CsvEscape(inv.InvoiceNumber)},{CsvEscape(cust)},{inv.Status},{inv.InvoiceDate:yyyy-MM-dd},{inv.DueDate?.ToString("yyyy-MM-dd")},{inv.SubTotal},{inv.TaxAmount},{inv.Total},{inv.AmountPaid},{inv.BalanceDue}"); } return sb.ToString(); } /// /// Builds the inventory CSV string for the specified company, ordered alphabetically by name. /// Column names match exactly so the file can be re-imported. /// private async Task BuildInventoryCsv(int companyId) { var data = await _db.InventoryItems.AsNoTracking().IgnoreQueryFilters() .Include(i => i.PrimaryVendor) .Where(i => i.CompanyId == companyId && !i.IsDeleted).OrderBy(i => i.Name).ToListAsync(); var sb = new StringBuilder(); sb.AppendLine("SKU,ItemName,Description,CategoryName,Manufacturer,ManufacturerPartNumber,ColorName,ColorCode,Finish,VendorName,VendorPartNumber,QuantityInStock,UnitOfMeasure,UnitCost,LastPurchasePrice,ReorderPoint,ReorderQuantity,MinimumStock,MaximumStock,CoverageSqFtPerLb,TransferEfficiencyPct,Location,IsActive,Notes"); foreach (var i in data) sb.AppendLine($"{CsvEscape(i.SKU)},{CsvEscape(i.Name)},{CsvEscape(i.Description)},{CsvEscape(i.Category)},{CsvEscape(i.Manufacturer)},{CsvEscape(i.ManufacturerPartNumber)},{CsvEscape(i.ColorName)},{CsvEscape(i.ColorCode)},{CsvEscape(i.Finish)},{CsvEscape(i.PrimaryVendor?.CompanyName)},{CsvEscape(i.VendorPartNumber)},{i.QuantityOnHand},{CsvEscape(i.UnitOfMeasure)},{i.UnitCost},{i.LastPurchasePrice},{i.ReorderPoint},{i.ReorderQuantity},{i.MinimumStock},{i.MaximumStock},{i.CoverageSqFtPerLb},{i.TransferEfficiency},{i.Location},{i.IsActive.ToString().ToLower()},{CsvEscape(i.Notes)}"); return sb.ToString(); } /// /// Builds the equipment CSV string for the specified company, ordered alphabetically by name. /// Column names match exactly so the file can be re-imported. /// private async Task BuildEquipmentCsv(int companyId) { var data = await _db.Equipment.AsNoTracking().IgnoreQueryFilters() .Where(e => e.CompanyId == companyId && !e.IsDeleted).OrderBy(e => e.EquipmentName).ToListAsync(); var sb = new StringBuilder(); sb.AppendLine("EquipmentName,EquipmentNumber,EquipmentType,Manufacturer,Model,SerialNumber,PurchaseDate,PurchasePrice,WarrantyExpiration,Location,RecommendedMaintenanceIntervalDays,Status,IsActive,Notes"); foreach (var e in data) sb.AppendLine($"{CsvEscape(e.EquipmentName)},{CsvEscape(e.EquipmentNumber)},{CsvEscape(e.EquipmentType)},{CsvEscape(e.Manufacturer)},{CsvEscape(e.Model)},{CsvEscape(e.SerialNumber)},{e.PurchaseDate?.ToString("yyyy-MM-dd")},{e.PurchasePrice},{e.WarrantyExpiration?.ToString("yyyy-MM-dd")},{CsvEscape(e.Location)},{e.RecommendedMaintenanceIntervalDays},{e.Status},{e.IsActive.ToString().ToLower()},{CsvEscape(e.Notes)}"); return sb.ToString(); } /// /// Builds the vendors CSV string for the specified company, ordered alphabetically by company name. /// Column names match exactly so the file can be re-imported. /// private async Task BuildVendorsCsv(int companyId) { var data = await _db.Vendors.AsNoTracking().IgnoreQueryFilters() .Where(s => s.CompanyId == companyId && !s.IsDeleted).OrderBy(s => s.CompanyName).ToListAsync(); var sb = new StringBuilder(); sb.AppendLine("CompanyName,ContactName,Email,Phone,Address,City,State,ZipCode,Country,Website,AccountNumber,TaxId,PaymentTerms,CreditLimit,IsPreferred,IsActive,Notes"); foreach (var s in data) sb.AppendLine($"{CsvEscape(s.CompanyName)},{CsvEscape(s.ContactName)},{CsvEscape(s.Email)},{CsvEscape(s.Phone)},{CsvEscape(s.Address)},{CsvEscape(s.City)},{CsvEscape(s.State)},{CsvEscape(s.ZipCode)},{CsvEscape(s.Country)},{CsvEscape(s.Website)},{CsvEscape(s.AccountNumber)},{CsvEscape(s.TaxId)},{CsvEscape(s.PaymentTerms)},{s.CreditLimit},{s.IsPreferred.ToString().ToLower()},{s.IsActive.ToString().ToLower()},{CsvEscape(s.Notes)}"); return sb.ToString(); } /// /// Builds the users CSV string for the specified company, ordered by last name. /// Like , the IsDeleted filter is intentionally omitted /// because Identity users use IsActive for soft-deletion, not the IsDeleted flag. /// private async Task BuildUsersCsv(int companyId) { var data = await _db.Users.AsNoTracking().IgnoreQueryFilters() .Where(u => u.CompanyId == companyId).OrderBy(u => u.LastName).ToListAsync(); var sb = new StringBuilder(); sb.AppendLine("ID,First Name,Last Name,Email,Role,Active,Hire Date,Last Login,Created At"); foreach (var u in data) sb.AppendLine($"{CsvEscape(u.Id)},{CsvEscape(u.FirstName)},{CsvEscape(u.LastName)},{CsvEscape(u.Email)},{CsvEscape(u.CompanyRole)},{(u.IsActive ? "Yes" : "No")},{u.HireDate:yyyy-MM-dd},{u.LastLoginDate?.ToString("yyyy-MM-dd") ?? "Never"},{u.CreatedAt:yyyy-MM-dd}"); return sb.ToString(); } // ── Utilities ──────────────────────────────────────────────────────────── /// /// Writes a styled header row to the first row of an EPPlus worksheet using the specified /// column labels and background color. White bold font on a dark background provides high /// contrast and visually separates headers from data in exported workbooks. /// /// The worksheet to write headers into (always row 1). /// Ordered column header labels. /// Background fill color for the header row. private static void WriteHeader(ExcelWorksheet ws, string[] headers, Color bgColor) { for (int c = 0; c < headers.Length; c++) { var cell = ws.Cells[1, c + 1]; cell.Value = headers[c]; cell.Style.Font.Bold = true; cell.Style.Font.Color.SetColor(Color.White); cell.Style.Fill.PatternType = ExcelFillStyle.Solid; cell.Style.Fill.BackgroundColor.SetColor(bgColor); } } /// /// Auto-fits all columns in the worksheet to their content width, then caps each column at /// 50 characters to prevent excessively wide columns caused by long free-text fields /// (e.g., job descriptions or notes). /// /// The worksheet to auto-fit. /// Total number of data columns, used for the width-cap loop. private static void AutoFit(ExcelWorksheet ws, int colCount) { ws.Cells[ws.Dimension?.Address ?? "A1"].AutoFitColumns(); // Cap column width for (int c = 1; c <= colCount; c++) { if (ws.Column(c).Width > 50) ws.Column(c).Width = 50; } } /// /// Returns the requested sheet names sorted into the canonical export order /// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → Users). /// This ensures that the workbook and ZIP archive always have a predictable, logical layout /// regardless of the order the administrator checked the boxes on the form. /// Any sheet name not in the canonical list is silently ignored. /// /// Raw sheet names from the form POST. private static string[] OrderSheets(string[] sheets) { var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "Users" }; return order.Where(sheets.Contains).ToArray(); } /// /// RFC 4180-compliant CSV field escaper. Wraps the value in double-quotes and escapes /// embedded double-quotes (by doubling them) whenever the value contains a comma, /// double-quote, carriage return, or line feed. Returns an empty string for null values /// so CSV rows never contain bare null literals. /// /// The field value to escape; may be null or any object type. private static string CsvEscape(object? value) { if (value == null) return ""; var s = value.ToString() ?? ""; if (s.Contains(',') || s.Contains('"') || s.Contains('\n') || s.Contains('\r')) return $"\"{s.Replace("\"", "\"\"")}\""; return s; } } // ── DTOs ───────────────────────────────────────────────────────────────────── /// /// Lightweight projection used on the export index page to list available tenant companies /// without loading every column from the Companies table. /// public class CompanyExportSummary { public int Id { get; set; } public string CompanyName { get; set; } = ""; public string Plan { get; set; } = ""; public bool IsActive { get; set; } public DateTime CreatedAt { get; set; } }