1a44133a63
Removes the ShopWorker and ShopWorkerRoleCost entities, all related DTOs, mappings, controllers, views, and import/export paths. Worker identity is now handled entirely through ApplicationUser with per-user LaborCostPerHour. ShopWorkerRoleCosts table remains in production pending manual data migration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
667 lines
38 KiB
C#
667 lines
38 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using OfficeOpenXml;
|
|
using OfficeOpenXml.Style;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Infrastructure.Data;
|
|
using PowderCoating.Shared.Constants;
|
|
using System.Drawing;
|
|
using System.IO.Compression;
|
|
using System.Security.Claims;
|
|
using System.Text;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
/// <summary>
|
|
/// Self-service data export for company administrators.
|
|
/// Intentionally bypasses the subscription gate (added to SubscriptionMiddleware.SkipPaths)
|
|
/// so that expired/cancelled accounts can still retrieve their data.
|
|
/// This is the tenant-scoped counterpart to <see cref="DataExportController"/>, which is
|
|
/// SuperAdmin-only and can export any company's data. Here the authenticated user's own
|
|
/// <c>CompanyId</c> claim is used to scope all queries, preventing cross-tenant data leakage.
|
|
/// All sheet queries still use <c>IgnoreQueryFilters()</c> because the global EF filter ties
|
|
/// results to the current <c>ITenantContext</c> (which may be null or mismatched for inactive
|
|
/// accounts), but data is explicitly filtered to the user's own <c>CompanyId</c>.
|
|
/// </summary>
|
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
|
public class AccountDataExportController : Controller
|
|
{
|
|
private readonly ApplicationDbContext _db;
|
|
private readonly ILogger<AccountDataExportController> _logger;
|
|
|
|
/// <summary>
|
|
/// Initializes the controller and sets the EPPlus license context to NonCommercial.
|
|
/// EPPlus 5+ requires an explicit license declaration before any <see cref="ExcelPackage"/>
|
|
/// is constructed; omitting this causes a runtime exception on the first export.
|
|
/// </summary>
|
|
public AccountDataExportController(ApplicationDbContext db, ILogger<AccountDataExportController> logger)
|
|
{
|
|
_db = db;
|
|
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the export selection page where the company admin can choose which data types
|
|
/// to export and in which format (XLSX or CSV/ZIP).
|
|
/// </summary>
|
|
[HttpGet]
|
|
public IActionResult Index()
|
|
{
|
|
return View();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Accepts the selected sheet names and format, resolves the caller's company ID from the
|
|
/// <c>CompanyId</c> JWT/cookie claim, and delegates to the appropriate format builder.
|
|
/// The company name is loaded from the database (not from claims) to guarantee a current,
|
|
/// authoritative value for the file name and metadata sheet.
|
|
/// Logs the export for audit trail purposes so SuperAdmins can see when a tenant exported data.
|
|
/// </summary>
|
|
/// <param name="sheets">Array of entity/sheet names selected on the form (e.g. "Customers", "Jobs").</param>
|
|
/// <param name="format">Output format: <c>"xlsx"</c> (default) or <c>"csv"</c>.</param>
|
|
[HttpPost, ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> Export(string[] sheets, string format = "xlsx")
|
|
{
|
|
var companyId = GetCompanyId();
|
|
if (companyId == 0)
|
|
{
|
|
TempData["Error"] = "Unable to determine your company. Please sign in again.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
if (sheets == null || sheets.Length == 0)
|
|
{
|
|
TempData["Error"] = "Select at least one data type to export.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
var company = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
|
|
.FirstOrDefaultAsync(c => c.Id == companyId && !c.IsDeleted);
|
|
|
|
if (company == null) return NotFound();
|
|
|
|
var safeName = string.Concat(company.CompanyName.Split(Path.GetInvalidFileNameChars()));
|
|
var ordered = OrderSheets(sheets);
|
|
|
|
_logger.LogInformation("Company {CompanyId} ({CompanyName}) self-service export: {Sheets} as {Format}",
|
|
companyId, company.CompanyName, string.Join(", ", ordered), format);
|
|
|
|
if (format == "csv")
|
|
return await ExportAsCsv(companyId, company.CompanyName, safeName, ordered);
|
|
|
|
return await ExportAsXlsx(companyId, company.CompanyName, safeName, ordered);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the <c>CompanyId</c> claim from the authenticated user's identity.
|
|
/// Returns 0 (invalid) when the claim is absent or cannot be parsed, so callers can
|
|
/// guard against unauthenticated or misconfigured sessions without throwing exceptions.
|
|
/// </summary>
|
|
private int GetCompanyId()
|
|
{
|
|
var claim = User.FindFirst("CompanyId")?.Value;
|
|
return int.TryParse(claim, out var id) ? id : 0;
|
|
}
|
|
|
|
// ── XLSX ─────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Builds an XLSX workbook entirely in memory with one worksheet per selected data type,
|
|
/// plus a leading "Export Info" metadata sheet, and returns it as a file download.
|
|
/// In-memory construction avoids creating temp files on disk, removing the need for
|
|
/// cleanup logic and reducing surface area for file-system path traversal attacks.
|
|
/// </summary>
|
|
/// <param name="companyId">Tenant company ID; all sheet queries are filtered to this value.</param>
|
|
/// <param name="companyName">Human-readable company name for the metadata sheet.</param>
|
|
/// <param name="safeName">Filesystem-safe company name for the download file name.</param>
|
|
/// <param name="ordered">Sheet names in canonical order to include.</param>
|
|
private async Task<IActionResult> 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);
|
|
}
|
|
|
|
// ── CSV ──────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Builds a ZIP archive in memory containing one CSV file per selected data type plus an
|
|
/// "Export_Info.csv" metadata file, and returns it as a file download.
|
|
/// <c>leaveOpen: true</c> is passed so the underlying <see cref="MemoryStream"/> remains
|
|
/// usable after <see cref="ZipArchive.Dispose"/> is called, allowing <c>ms.ToArray()</c>
|
|
/// to capture the fully finalised bytes.
|
|
/// </summary>
|
|
/// <param name="companyId">Tenant company ID; all CSV queries are filtered to this value.</param>
|
|
/// <param name="companyName">Human-readable company name for the metadata CSV.</param>
|
|
/// <param name="safeName">Filesystem-safe company name for the download file name.</param>
|
|
/// <param name="ordered">Sheet names in canonical order to include.</param>
|
|
private async Task<IActionResult> ExportAsCsv(int companyId, string companyName, string safeName, string[] ordered)
|
|
{
|
|
using var ms = new MemoryStream();
|
|
using var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true);
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes a CSV string as a named entry inside an open <see cref="ZipArchive"/>.
|
|
/// The UTF-8 BOM (<c>encoderShouldEmitUTF8Identifier: true</c>) ensures Excel opens the
|
|
/// file with correct encoding without requiring an explicit import wizard step.
|
|
/// </summary>
|
|
/// <param name="zip">The open archive to add the entry to.</param>
|
|
/// <param name="entryName">Entry file name inside the ZIP (e.g. "Customers.csv").</param>
|
|
/// <param name="content">Complete CSV text content to write.</param>
|
|
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);
|
|
}
|
|
|
|
// ── Data fetchers (single query per entity, superset of includes for both XLSX + CSV) ─────
|
|
|
|
/// <summary>
|
|
/// Fetches all non-deleted customers for the company, including <c>PricingTier</c> (needed by
|
|
/// the CSV path; harmlessly unused by the XLSX path). Bypasses the global tenant filter via
|
|
/// <c>IgnoreQueryFilters</c> because <c>ITenantContext</c> may be null for expired accounts.
|
|
/// </summary>
|
|
private Task<List<Customer>> FetchCustomersAsync(int companyId) =>
|
|
_db.Customers.AsNoTracking().IgnoreQueryFilters()
|
|
.Include(c => c.PricingTier)
|
|
.Where(c => c.CompanyId == companyId && !c.IsDeleted)
|
|
.OrderBy(c => c.CompanyName).ToListAsync();
|
|
|
|
/// <summary>Fetches all non-deleted jobs with Customer, JobStatus, and JobPriority included.</summary>
|
|
private Task<List<Job>> FetchJobsAsync(int companyId) =>
|
|
_db.Jobs.AsNoTracking().IgnoreQueryFilters()
|
|
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
|
|
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
|
|
.OrderByDescending(j => j.CreatedAt).ToListAsync();
|
|
|
|
/// <summary>
|
|
/// Fetches all non-deleted quotes with Customer and QuoteStatus included.
|
|
/// The XLSX path only needs QuoteStatus; the CSV path also uses Customer — the superset here
|
|
/// avoids a second query when both formats include this sheet in the same request.
|
|
/// </summary>
|
|
private Task<List<Quote>> FetchQuotesAsync(int companyId) =>
|
|
_db.Quotes.AsNoTracking().IgnoreQueryFilters()
|
|
.Include(q => q.Customer).Include(q => q.QuoteStatus)
|
|
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
|
|
.OrderByDescending(q => q.QuoteDate).ToListAsync();
|
|
|
|
/// <summary>Fetches all non-deleted invoices with Customer included.</summary>
|
|
private Task<List<Invoice>> FetchInvoicesAsync(int companyId) =>
|
|
_db.Invoices.AsNoTracking().IgnoreQueryFilters()
|
|
.Include(i => i.Customer)
|
|
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
|
|
.OrderByDescending(i => i.InvoiceDate).ToListAsync();
|
|
|
|
/// <summary>
|
|
/// Fetches all non-deleted inventory items with PrimaryVendor and InventoryCategory included.
|
|
/// Only the CSV path uses these navigations; XLSX reads only scalar fields but the join is cheap.
|
|
/// </summary>
|
|
private Task<List<InventoryItem>> FetchInventoryAsync(int companyId) =>
|
|
_db.InventoryItems.AsNoTracking().IgnoreQueryFilters()
|
|
.Include(i => i.PrimaryVendor).Include(i => i.InventoryCategory)
|
|
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
|
|
.OrderBy(i => i.Name).ToListAsync();
|
|
|
|
/// <summary>Fetches all non-deleted equipment records for the company.</summary>
|
|
private Task<List<Equipment>> FetchEquipmentAsync(int companyId) =>
|
|
_db.Equipment.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(e => e.CompanyId == companyId && !e.IsDeleted)
|
|
.OrderBy(e => e.EquipmentName).ToListAsync();
|
|
|
|
/// <summary>Fetches all non-deleted vendors for the company.</summary>
|
|
private Task<List<Vendor>> FetchVendorsAsync(int companyId) =>
|
|
_db.Vendors.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(s => s.CompanyId == companyId && !s.IsDeleted)
|
|
.OrderBy(s => s.CompanyName).ToListAsync();
|
|
|
|
/// <summary>
|
|
/// Fetches all users for the company. <c>IsDeleted</c> is intentionally omitted because
|
|
/// Identity users use <c>IsActive = false</c> for soft-deletion, not the base-entity flag.
|
|
/// </summary>
|
|
private Task<List<ApplicationUser>> FetchUsersAsync(int companyId) =>
|
|
_db.Users.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(u => u.CompanyId == companyId)
|
|
.OrderBy(u => u.LastName).ToListAsync();
|
|
|
|
// ── Sheet builders ───────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Adds an "Export Info" worksheet as the first sheet of the workbook, recording the company
|
|
/// name, export timestamp, and list of included data sheets.
|
|
/// <c>MoveToStart</c> ensures this orientation sheet always appears at tab position 1,
|
|
/// regardless of when it is added relative to data sheets.
|
|
/// </summary>
|
|
private void AddMetadataSheet(ExcelPackage pkg, string companyName, string[] sheets)
|
|
{
|
|
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;
|
|
}
|
|
|
|
private async Task AddCustomersSheet(ExcelPackage pkg, int companyId, Color hdr)
|
|
{
|
|
var data = await FetchCustomersAsync(companyId);
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a "Jobs" worksheet. Job status and priority are lookup-table entities (not enums);
|
|
/// they are eagerly loaded by <see cref="FetchJobsAsync"/> so their <c>DisplayName</c> is
|
|
/// available without N+1 queries. Falls back to the raw FK integer on data anomalies.
|
|
/// </summary>
|
|
private async Task AddJobsSheet(ExcelPackage pkg, int companyId, Color hdr)
|
|
{
|
|
var data = await FetchJobsAsync(companyId);
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a "Quotes" worksheet. Prospect-only quotes show <c>ProspectCompanyName</c>;
|
|
/// fully linked quotes fall back to <c>Customer #{id}</c> when the navigation is null.
|
|
/// </summary>
|
|
private async Task AddQuotesSheet(ExcelPackage pkg, int companyId, Color hdr)
|
|
{
|
|
var data = await FetchQuotesAsync(companyId);
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds an "Invoices" worksheet. <c>BalanceDue</c> is a computed property on the entity
|
|
/// (<c>Total - AmountPaid</c>) so no extra aggregation query is needed.
|
|
/// </summary>
|
|
private async Task AddInvoicesSheet(ExcelPackage pkg, int companyId, Color hdr)
|
|
{
|
|
var data = await FetchInvoicesAsync(companyId);
|
|
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 cust = 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 = cust; 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);
|
|
}
|
|
|
|
private async Task AddInventorySheet(ExcelPackage pkg, int companyId, Color hdr)
|
|
{
|
|
var data = await FetchInventoryAsync(companyId);
|
|
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);
|
|
}
|
|
|
|
private async Task AddEquipmentSheet(ExcelPackage pkg, int companyId, Color hdr)
|
|
{
|
|
var data = await FetchEquipmentAsync(companyId);
|
|
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);
|
|
}
|
|
|
|
private async Task AddVendorsSheet(ExcelPackage pkg, int companyId, Color hdr)
|
|
{
|
|
var data = await FetchVendorsAsync(companyId);
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a "Users" worksheet. All users (active and inactive) are included because Identity
|
|
/// uses <c>IsActive = false</c> for soft-deletion; <c>IsDeleted</c> is not applicable here.
|
|
/// </summary>
|
|
private async Task AddUsersSheet(ExcelPackage pkg, int companyId, Color hdr)
|
|
{
|
|
var data = await FetchUsersAsync(companyId);
|
|
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 ─────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Column names match <c>CustomerImportDto</c> exactly so the file can be re-imported
|
|
/// via Tools → Bulk Import without any manual header editing.
|
|
/// </summary>
|
|
private async Task<string> BuildCustomersCsv(int companyId)
|
|
{
|
|
var data = await FetchCustomersAsync(companyId);
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Column names match <c>JobImportDto</c> exactly so the file can be re-imported.
|
|
/// CustomerEmail is used (not display name) because the importer resolves the customer FK by email.
|
|
/// </summary>
|
|
private async Task<string> BuildJobsCsv(int companyId)
|
|
{
|
|
var data = await FetchJobsAsync(companyId);
|
|
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();
|
|
}
|
|
|
|
/// <summary>Column names match <c>QuoteImportDto</c> exactly so the file can be re-imported.</summary>
|
|
private async Task<string> BuildQuotesCsv(int companyId)
|
|
{
|
|
var data = await FetchQuotesAsync(companyId);
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Customer name resolution mirrors the XLSX sheet: company name preferred, with
|
|
/// first+last name concatenation as the fallback for non-commercial customers.
|
|
/// </summary>
|
|
private async Task<string> BuildInvoicesCsv(int companyId)
|
|
{
|
|
var data = await FetchInvoicesAsync(companyId);
|
|
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();
|
|
}
|
|
|
|
/// <summary>Column names match <c>InventoryItemImportDto</c> exactly so the file can be re-imported.</summary>
|
|
private async Task<string> BuildInventoryCsv(int companyId)
|
|
{
|
|
var data = await FetchInventoryAsync(companyId);
|
|
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)
|
|
{
|
|
var categoryName = i.InventoryCategory?.DisplayName ?? i.Category;
|
|
sb.AppendLine($"{CsvEscape(i.SKU)},{CsvEscape(i.Name)},{CsvEscape(i.Description)},{CsvEscape(categoryName)},{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},{CsvEscape(i.Location)},{i.IsActive.ToString().ToLower()},{CsvEscape(i.Notes)}");
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>Column names match <c>EquipmentImportDto</c> exactly so the file can be re-imported.</summary>
|
|
private async Task<string> BuildEquipmentCsv(int companyId)
|
|
{
|
|
var data = await FetchEquipmentAsync(companyId);
|
|
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();
|
|
}
|
|
|
|
/// <summary>Column names match <c>VendorImportDto</c> exactly so the file can be re-imported.</summary>
|
|
private async Task<string> BuildVendorsCsv(int companyId)
|
|
{
|
|
var data = await FetchVendorsAsync(companyId);
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// All users (active and inactive) are exported for completeness and compliance — mirrors
|
|
/// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>.
|
|
/// </summary>
|
|
private async Task<string> BuildUsersCsv(int companyId)
|
|
{
|
|
var data = await FetchUsersAsync(companyId);
|
|
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 ────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Writes a bold, white-on-dark-blue header row to the first row of the given worksheet.
|
|
/// The styling is consistent across both the SuperAdmin export and the self-service export
|
|
/// so that exported files look identical regardless of which controller produced them.
|
|
/// </summary>
|
|
/// <param name="ws">Target worksheet; row 1 is always used for headers.</param>
|
|
/// <param name="headers">Ordered header labels for each column.</param>
|
|
/// <param name="bgColor">Header row background fill colour.</param>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Auto-fits all columns to their content width and caps each column at 50 characters.
|
|
/// The 50-character cap prevents free-text fields (job descriptions, notes) from creating
|
|
/// columns so wide they make the spreadsheet awkward to navigate.
|
|
/// </summary>
|
|
/// <param name="ws">The worksheet to auto-fit.</param>
|
|
/// <param name="colCount">Total number of data columns, for the width-cap loop.</param>
|
|
private static void AutoFit(ExcelWorksheet ws, int colCount)
|
|
{
|
|
ws.Cells[ws.Dimension?.Address ?? "A1"].AutoFitColumns();
|
|
for (int c = 1; c <= colCount; c++)
|
|
if (ws.Column(c).Width > 50) ws.Column(c).Width = 50;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the subset of selected sheet names reordered into the canonical export sequence
|
|
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → Users).
|
|
/// Guarantees consistent file layout regardless of the order check-boxes were ticked on the form.
|
|
/// Sheet names not in the canonical list are silently dropped.
|
|
/// </summary>
|
|
private static string[] OrderSheets(string[] sheets)
|
|
{
|
|
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "Users" };
|
|
return order.Where(sheets.Contains).ToArray();
|
|
}
|
|
|
|
/// <summary>
|
|
/// RFC 4180-compliant CSV field escaper. Wraps the value in double-quotes and doubles any
|
|
/// embedded double-quotes when the value contains a comma, double-quote, carriage return, or
|
|
/// line feed. Returns an empty string for null to avoid bare "null" literals in CSV output.
|
|
/// </summary>
|
|
/// <param name="value">Field value to escape; accepts any nullable object.</param>
|
|
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;
|
|
}
|
|
}
|