From 82fb48f7a5abc52a1b97a64fefa1e5ef8200fd6f Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 10 Jun 2026 15:14:27 -0400 Subject: [PATCH] Patch export/import for missing fields; add CustomerContacts export - DataExportController + AccountDataExportController: add ProjectName to Jobs, Quotes, Invoices (XLSX + CSV); add LeadSource + ShipTo fields to Customers (XLSX + CSV); add CustomerContacts sheet/CSV (new) - Both export views: add Customer Contacts checkbox (checked by default) - CustomerImportDto: add LeadSource + ShipTo* fields - JobImportDto: add ProjectName - QuoteImportDto: add ProjectName - InvoiceImportDto: add Project Name (dual-name alias for round-trip) - CsvImportService: wire all new import fields to entity creation; also patch invoice update path for ProjectName - Add scripts/purge_imported_data.sql (dry-run T-SQL for data cleanup) Co-Authored-By: Claude Sonnet 4.6 --- scripts/purge_imported_data.sql | 403 ++++++++++++++++++ .../DTOs/Import/CustomerImportDto.cs | 18 + .../DTOs/Import/InvoiceImportDto.cs | 3 + .../DTOs/Import/JobImportDto.cs | 3 + .../DTOs/Import/QuoteImportDto.cs | 3 + .../Services/CsvImportService.cs | 22 +- .../AccountDataExportController.cs | 97 +++-- .../Controllers/DataExportController.cs | 152 +++++-- .../Views/AccountDataExport/Index.cshtml | 5 +- .../Views/DataExport/Index.cshtml | 7 + 10 files changed, 637 insertions(+), 76 deletions(-) create mode 100644 scripts/purge_imported_data.sql diff --git a/scripts/purge_imported_data.sql b/scripts/purge_imported_data.sql new file mode 100644 index 0000000..3ff20df --- /dev/null +++ b/scripts/purge_imported_data.sql @@ -0,0 +1,403 @@ +-- ============================================================================= +-- Company Data Purge Script +-- Removes financial, job, and quote data created before a cutoff date. +-- Customers and Vendors are always preserved. +-- +-- WHAT THIS DELETES (for records where CreatedAt < @CutoffDate): +-- • Journal entries & bank reconciliations +-- • Bills, bill payments, purchase orders, vendor credits, expenses +-- • Invoices, invoice payments, credit memos, refunds, deposits +-- • Jobs and all child records (items, coats, prep, notes, photos, etc.) +-- • Quotes and all child records +-- +-- WHAT THIS KEEPS (always): +-- • Customers, customer notes, customer contacts, preferred powders +-- • Vendors +-- • Inventory items and inventory transactions +-- • Equipment, catalog items, pricing tiers +-- • Company settings and configuration +-- • Any record with CreatedAt >= @CutoffDate +-- +-- INSTRUCTIONS: +-- 1. Set @CompanyId — find it with: SELECT Id, Name FROM Companies +-- 2. Set @CutoffDate — records created BEFORE this date are deleted +-- 3. Run with @DryRun = 1 first and review the row counts printed +-- 4. Back up the database before setting @DryRun = 0 +-- 5. Set @DryRun = 0 and run again to apply +-- ============================================================================= + +DECLARE @CutoffDate DATE = '2026-01-01'; -- Delete records created BEFORE this date +DECLARE @CompanyId INT = 0; -- !! Set to your company ID before running +DECLARE @DryRun BIT = 1; -- 1 = preview counts only | 0 = apply deletes + +-- ============================================================================= +SET NOCOUNT ON; + +IF @CompanyId = 0 +BEGIN + RAISERROR('ERROR: Set @CompanyId before running this script. Run: SELECT Id, Name FROM Companies', 16, 1); + RETURN; +END + +PRINT '============================================================'; +PRINT 'Purge run at: ' + CONVERT(NVARCHAR, GETDATE(), 120); +PRINT 'Company ID : ' + CAST(@CompanyId AS NVARCHAR); +PRINT 'Cutoff date : ' + CAST(@CutoffDate AS NVARCHAR) + ' (records BEFORE this date)'; +PRINT 'Dry run : ' + CASE @DryRun WHEN 1 THEN 'YES — no changes will be made' ELSE 'NO — deletes will be applied' END; +PRINT '============================================================'; + +BEGIN TRANSACTION; + +-- =========================================================================== +-- SECTION 1 — JOURNAL ENTRIES & GL +-- =========================================================================== +PRINT ''; +PRINT '--- Section 1: Journal Entries & GL ---'; + +-- Null the self-referential ReversalOfId FK before deleting +UPDATE JournalEntries +SET ReversalOfId = NULL +WHERE CompanyId = @CompanyId + AND ReversalOfId IN (SELECT Id FROM JournalEntries WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); + +DELETE FROM JournalEntryLines +WHERE JournalEntryId IN ( + SELECT Id FROM JournalEntries WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'JournalEntryLines deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM JournalEntries +WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate; +PRINT 'JournalEntries deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM BankReconciliations +WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate; +PRINT 'BankReconciliations deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- =========================================================================== +-- SECTION 2 — BILLS, PURCHASE ORDERS & EXPENSES +-- =========================================================================== +PRINT ''; +PRINT '--- Section 2: Bills, Purchase Orders & Expenses ---'; + +-- Vendor credits (must come before Bills because VendorCreditApplications references both) +DELETE FROM VendorCreditApplications +WHERE VendorCreditId IN ( + SELECT Id FROM VendorCredits WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'VendorCreditApplications deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM VendorCreditLineItems +WHERE VendorCreditId IN ( + SELECT Id FROM VendorCredits WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'VendorCreditLineItems deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM VendorCredits +WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate; +PRINT 'VendorCredits deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- Bills +DELETE FROM BillPayments +WHERE BillId IN ( + SELECT Id FROM Bills WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'BillPayments deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM BillLineItems +WHERE BillId IN ( + SELECT Id FROM Bills WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'BillLineItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM Bills +WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate; +PRINT 'Bills deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- Purchase orders +DELETE FROM PurchaseOrderItems +WHERE PurchaseOrderId IN ( + SELECT Id FROM PurchaseOrders WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'PurchaseOrderItems deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM PurchaseOrders +WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate; +PRINT 'PurchaseOrders deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- Expenses +DELETE FROM Expenses +WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate; +PRINT 'Expenses deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- =========================================================================== +-- SECTION 3 — INVOICES, PAYMENTS & DEPOSITS +-- =========================================================================== +PRINT ''; +PRINT '--- Section 3: Invoices, Payments & Deposits ---'; + +-- CreditMemos: NULL the OriginalInvoiceId FK before deleting the invoice it points to +UPDATE CreditMemos +SET OriginalInvoiceId = NULL +WHERE CompanyId = @CompanyId + AND OriginalInvoiceId IN ( + SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); + +DELETE FROM CreditMemoApplications +WHERE InvoiceId IN ( + SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate) + OR CreditMemoId IN ( + SELECT Id FROM CreditMemos WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'CreditMemoApplications deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM CreditMemos +WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate; +PRINT 'CreditMemos deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- Refunds and gift-cert redemptions tied to deleted invoices +DELETE FROM Refunds +WHERE InvoiceId IN ( + SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'Refunds deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM GiftCertificateRedemptions +WHERE InvoiceId IN ( + SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'GiftCertificateRedemptions deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- Payments +DELETE FROM Payments +WHERE InvoiceId IN ( + SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'Payments deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- InvoiceItems (NULL SourceJobItemId on any invoice items that survive but point at deleted jobs) +UPDATE InvoiceItems +SET SourceJobItemId = NULL +WHERE SourceJobItemId IN ( + SELECT ji.Id FROM JobItems ji + INNER JOIN Jobs j ON ji.JobId = j.Id + WHERE j.CompanyId = @CompanyId AND j.CreatedAt < @CutoffDate); + +DELETE FROM InvoiceItems +WHERE InvoiceId IN ( + SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'InvoiceItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- Deposits: clear the AppliedToInvoiceId FK before deleting the invoices +UPDATE Deposits +SET AppliedToInvoiceId = NULL, + AppliedDate = NULL +WHERE CompanyId = @CompanyId + AND AppliedToInvoiceId IN ( + SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); + +-- Now delete deposits that were created before the cutoff +DELETE FROM Deposits +WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate; +PRINT 'Deposits deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- Notification logs referencing deleted invoices +UPDATE NotificationLogs +SET InvoiceId = NULL +WHERE CompanyId = @CompanyId + AND InvoiceId IN ( + SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); + +DELETE FROM Invoices +WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate; +PRINT 'Invoices deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- =========================================================================== +-- SECTION 4 — JOBS +-- =========================================================================== +PRINT ''; +PRINT '--- Section 4: Jobs ---'; + +-- NULL FKs in other tables that point to jobs/job-items being deleted -- + +-- BillLineItems.JobId (bill survived but referenced a deleted job) +UPDATE BillLineItems +SET JobId = NULL +WHERE JobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); + +-- Expenses.JobId +UPDATE Expenses +SET JobId = NULL +WHERE CompanyId = @CompanyId + AND JobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); + +-- Appointments.JobId +UPDATE Appointments +SET JobId = NULL +WHERE CompanyId = @CompanyId + AND JobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); + +-- Deposits.JobId (NoAction FK — must NULL before deleting job) +UPDATE Deposits +SET JobId = NULL +WHERE CompanyId = @CompanyId + AND JobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); + +-- NotificationLogs.JobId +UPDATE NotificationLogs +SET JobId = NULL +WHERE CompanyId = @CompanyId + AND JobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); + +-- OvenBatchItems: delete before OvenBatches and before JobItems +DELETE FROM OvenBatchItems +WHERE JobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'OvenBatchItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- Clean up now-empty OvenBatches (batches belonging to this company with no remaining items) +DELETE FROM OvenBatches +WHERE CompanyId = @CompanyId + AND CreatedAt < @CutoffDate + AND Id NOT IN (SELECT DISTINCT OvenBatchId FROM OvenBatchItems); +PRINT 'OvenBatches deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- ReworkRecords (JobId required FK; ReworkJobId optional) +DELETE FROM ReworkRecords +WHERE JobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate) + OR ReworkJobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'ReworkRecords deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- JobItem children (coats and prep services) +DELETE FROM JobItemCoats +WHERE JobItemId IN ( + SELECT Id FROM JobItems WHERE JobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate)); +PRINT 'JobItemCoats deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM JobItemPrepServices +WHERE JobItemId IN ( + SELECT Id FROM JobItems WHERE JobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate)); +PRINT 'JobItemPrepServices deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM JobItems +WHERE JobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'JobItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- Job metadata tables +DELETE FROM JobChangeHistories +WHERE JobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'JobChangeHistories deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM JobTimeEntries +WHERE JobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'JobTimeEntries deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM JobPhotos +WHERE JobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'JobPhotos deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM JobNotes +WHERE JobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'JobNotes deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM JobStatusHistory +WHERE JobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'JobStatusHistory deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM JobDailyPriorities +WHERE JobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'JobDailyPriorities deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM PowderUsageLogs +WHERE JobId IN ( + SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'PowderUsageLogs deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM AiItemPredictions +WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate; +PRINT 'AiItemPredictions deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM Jobs +WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate; +PRINT 'Jobs deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- =========================================================================== +-- SECTION 5 — QUOTES +-- =========================================================================== +PRINT ''; +PRINT '--- Section 5: Quotes ---'; + +-- NULL FKs that point to quotes being deleted +UPDATE Deposits +SET QuoteId = NULL +WHERE CompanyId = @CompanyId + AND QuoteId IN ( + SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); + +UPDATE NotificationLogs +SET QuoteId = NULL +WHERE CompanyId = @CompanyId + AND QuoteId IN ( + SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); + +-- QuoteItem children +DELETE FROM QuoteItemCoats +WHERE QuoteItemId IN ( + SELECT Id FROM QuoteItems WHERE QuoteId IN ( + SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate)); +PRINT 'QuoteItemCoats deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM QuoteItemPrepServices +WHERE QuoteItemId IN ( + SELECT Id FROM QuoteItems WHERE QuoteId IN ( + SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate)); +PRINT 'QuoteItemPrepServices deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM QuoteItems +WHERE QuoteId IN ( + SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'QuoteItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM QuoteChangeHistories +WHERE QuoteId IN ( + SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'QuoteChangeHistories deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM QuotePhotos +WHERE QuoteId IN ( + SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'QuotePhotos deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- QuotePrepServices are scoped to the quote's company via the quote FK, not directly +DELETE FROM QuotePrepServices +WHERE QuoteId IN ( + SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate); +PRINT 'QuotePrepServices deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +DELETE FROM Quotes +WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate; +PRINT 'Quotes deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR); + +-- =========================================================================== +-- SUMMARY & COMMIT / ROLLBACK +-- =========================================================================== +PRINT ''; +PRINT '============================================================'; + +IF @DryRun = 1 +BEGIN + PRINT 'DRY RUN complete — rolling back. No data was changed.'; + PRINT 'Set @DryRun = 0 and run again to apply the deletes.'; + ROLLBACK TRANSACTION; +END +ELSE +BEGIN + PRINT 'Purge complete — committing.'; + COMMIT TRANSACTION; +END diff --git a/src/PowderCoating.Application/DTOs/Import/CustomerImportDto.cs b/src/PowderCoating.Application/DTOs/Import/CustomerImportDto.cs index 746d82e..0ebbbf9 100644 --- a/src/PowderCoating.Application/DTOs/Import/CustomerImportDto.cs +++ b/src/PowderCoating.Application/DTOs/Import/CustomerImportDto.cs @@ -63,4 +63,22 @@ public class CustomerImportDto [Name("Notes")] public string? Notes { get; set; } + + [Name("LeadSource")] + public string? LeadSource { get; set; } + + [Name("ShipToAddress")] + public string? ShipToAddress { get; set; } + + [Name("ShipToCity")] + public string? ShipToCity { get; set; } + + [Name("ShipToState")] + public string? ShipToState { get; set; } + + [Name("ShipToZipCode")] + public string? ShipToZipCode { get; set; } + + [Name("ShipToCountry")] + public string? ShipToCountry { get; set; } } diff --git a/src/PowderCoating.Application/DTOs/Import/InvoiceImportDto.cs b/src/PowderCoating.Application/DTOs/Import/InvoiceImportDto.cs index a32fa63..4ca64c1 100644 --- a/src/PowderCoating.Application/DTOs/Import/InvoiceImportDto.cs +++ b/src/PowderCoating.Application/DTOs/Import/InvoiceImportDto.cs @@ -33,6 +33,9 @@ public class InvoiceImportDto [Name("DueDate")] public DateTime? DueDate { get; set; } + [Name("Project Name", "ProjectName")] + public string? ProjectName { get; set; } + [Name("SubTotal")] public decimal SubTotal { get; set; } diff --git a/src/PowderCoating.Application/DTOs/Import/JobImportDto.cs b/src/PowderCoating.Application/DTOs/Import/JobImportDto.cs index d134dad..2a8b194 100644 --- a/src/PowderCoating.Application/DTOs/Import/JobImportDto.cs +++ b/src/PowderCoating.Application/DTOs/Import/JobImportDto.cs @@ -49,6 +49,9 @@ public class JobImportDto [Name("SpecialInstructions")] public string? SpecialInstructions { get; set; } + [Name("ProjectName")] + public string? ProjectName { get; set; } + [Name("Notes")] public string? Notes { get; set; } } diff --git a/src/PowderCoating.Application/DTOs/Import/QuoteImportDto.cs b/src/PowderCoating.Application/DTOs/Import/QuoteImportDto.cs index 60db950..67f8a92 100644 --- a/src/PowderCoating.Application/DTOs/Import/QuoteImportDto.cs +++ b/src/PowderCoating.Application/DTOs/Import/QuoteImportDto.cs @@ -38,6 +38,9 @@ public class QuoteImportDto [Name("ExpirationDate")] public DateTime? ExpirationDate { get; set; } + [Name("ProjectName")] + public string? ProjectName { get; set; } + [Name("Subtotal")] public decimal Subtotal { get; set; } diff --git a/src/PowderCoating.Infrastructure/Services/CsvImportService.cs b/src/PowderCoating.Infrastructure/Services/CsvImportService.cs index b9a3ffe..c6208fd 100644 --- a/src/PowderCoating.Infrastructure/Services/CsvImportService.cs +++ b/src/PowderCoating.Infrastructure/Services/CsvImportService.cs @@ -605,6 +605,12 @@ public class CsvImportService : ICsvImportService PaymentTerms = record.PaymentTerms?.Trim() ?? "Net 30", IsTaxExempt = record.TaxExempt ?? false, GeneralNotes = record.Notes?.Trim(), + LeadSource = record.LeadSource?.Trim(), + ShipToAddress = record.ShipToAddress?.Trim(), + ShipToCity = record.ShipToCity?.Trim(), + ShipToState = record.ShipToState?.Trim(), + ShipToZipCode = record.ShipToZipCode?.Trim(), + ShipToCountry = record.ShipToCountry?.Trim(), IsActive = record.IsActive ?? true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow @@ -1284,6 +1290,7 @@ public class CsvImportService : ICsvImportService Total = record.Total, Notes = record.Notes?.Trim(), Terms = record.TermsAndConditions?.Trim(), + ProjectName = record.ProjectName?.Trim(), IsCommercial = customerId.HasValue, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow @@ -1557,6 +1564,7 @@ public class CsvImportService : ICsvImportService CustomerPO = record.CustomerPO?.Trim(), SpecialInstructions = record.SpecialInstructions?.Trim(), InternalNotes = record.Notes?.Trim(), + ProjectName = record.ProjectName?.Trim(), Description = record.Description?.Trim() ?? record.SpecialInstructions?.Trim() ?? "Imported job", @@ -3146,9 +3154,10 @@ public class CsvImportService : ICsvImportService existing.DiscountAmount = record.DiscountAmount; existing.Total = record.Total; existing.AmountPaid = record.AmountPaid; - existing.CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(); - existing.Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(); - existing.Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim(); + existing.CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(); + existing.Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(); + existing.Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim(); + existing.ProjectName = string.IsNullOrWhiteSpace(record.ProjectName) ? null : record.ProjectName.Trim(); await _unitOfWork.CompleteAsync(); result.SuccessCount++; } @@ -3170,9 +3179,10 @@ public class CsvImportService : ICsvImportService DiscountAmount = record.DiscountAmount, Total = record.Total, AmountPaid = record.AmountPaid, - CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(), - Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(), - Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim() + CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(), + Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(), + Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim(), + ProjectName = string.IsNullOrWhiteSpace(record.ProjectName) ? null : record.ProjectName.Trim() }; await _unitOfWork.Invoices.AddAsync(invoice); await _unitOfWork.CompleteAsync(); diff --git a/src/PowderCoating.Web/Controllers/AccountDataExportController.cs b/src/PowderCoating.Web/Controllers/AccountDataExportController.cs index 6e16294..eda8ec9 100644 --- a/src/PowderCoating.Web/Controllers/AccountDataExportController.cs +++ b/src/PowderCoating.Web/Controllers/AccountDataExportController.cs @@ -126,8 +126,9 @@ public class AccountDataExportController : Controller { switch (sheet) { - case "Customers": await AddCustomersSheet(package, companyId, headerColor); break; - case "Jobs": await AddJobsSheet(package, companyId, headerColor); break; + case "Customers": await AddCustomersSheet(package, companyId, headerColor); break; + case "CustomerContacts": await AddCustomerContactsSheet(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; @@ -174,8 +175,9 @@ public class AccountDataExportController : Controller { switch (sheet) { - case "Customers": WriteCsvEntry(zip, "Customers.csv", await BuildCustomersCsv(companyId)); break; - case "Jobs": WriteCsvEntry(zip, "Jobs.csv", await BuildJobsCsv(companyId)); break; + case "Customers": WriteCsvEntry(zip, "Customers.csv", await BuildCustomersCsv(companyId)); break; + case "CustomerContacts": WriteCsvEntry(zip, "CustomerContacts.csv", await BuildCustomerContactsCsv(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; @@ -299,7 +301,9 @@ public class AccountDataExportController : Controller 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" }; + "Commercial", "City", "State", "Active", "Credit Limit", "Current Balance", + "Lead Source", "Ship-To Address", "Ship-To City", "Ship-To State", "Ship-To Zip", "Ship-To Country", + "Created At" }; WriteHeader(ws, headers, hdr); for (int i = 0; i < data.Count; i++) { @@ -311,7 +315,34 @@ public class AccountDataExportController : Controller 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"); + ws.Cells[r, 13].Value = c.LeadSource; + ws.Cells[r, 14].Value = c.ShipToAddress; ws.Cells[r, 15].Value = c.ShipToCity; + ws.Cells[r, 16].Value = c.ShipToState; ws.Cells[r, 17].Value = c.ShipToZipCode; + ws.Cells[r, 18].Value = c.ShipToCountry; + ws.Cells[r, 19].Value = c.CreatedAt.ToString("yyyy-MM-dd"); + } + AutoFit(ws, headers.Length); + } + + private async Task AddCustomerContactsSheet(ExcelPackage pkg, int companyId, Color hdr) + { + var data = await _db.CustomerContacts.AsNoTracking() + .Include(cc => cc.Customer) + .Where(cc => cc.CompanyId == companyId && !cc.IsDeleted) + .OrderBy(cc => cc.Customer!.CompanyName).ThenBy(cc => cc.LastName).ThenBy(cc => cc.FirstName) + .ToListAsync(); + + var ws = pkg.Workbook.Worksheets.Add("CustomerContacts"); + var headers = new[] { "CustomerEmail", "FirstName", "LastName", "Title", "ContactRole", "Email", "Phone", "MobilePhone", "Notes" }; + WriteHeader(ws, headers, hdr); + for (int i = 0; i < data.Count; i++) + { + var r = i + 2; var cc = data[i]; + ws.Cells[r, 1].Value = cc.Customer?.Email; + ws.Cells[r, 2].Value = cc.FirstName; ws.Cells[r, 3].Value = cc.LastName; + ws.Cells[r, 4].Value = cc.Title; ws.Cells[r, 5].Value = cc.ContactRole; + ws.Cells[r, 6].Value = cc.Email; ws.Cells[r, 7].Value = cc.Phone; + ws.Cells[r, 8].Value = cc.MobilePhone; ws.Cells[r, 9].Value = cc.Notes; } AutoFit(ws, headers.Length); } @@ -326,7 +357,7 @@ public class AccountDataExportController : Controller 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" }; + "Description", "Project Name", "Due Date", "Final Price", "Created At" }; WriteHeader(ws, headers, hdr); for (int i = 0; i < data.Count; i++) { @@ -337,9 +368,10 @@ public class AccountDataExportController : Controller 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"); + ws.Cells[r, 7].Value = j.ProjectName; + ws.Cells[r, 8].Value = j.DueDate?.ToString("yyyy-MM-dd"); + ws.Cells[r, 9].Value = j.FinalPrice; + ws.Cells[r, 10].Value = j.CreatedAt.ToString("yyyy-MM-dd"); } AutoFit(ws, headers.Length); } @@ -353,7 +385,7 @@ public class AccountDataExportController : Controller 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" }; + "Quote Date", "Expiration Date", "Project Name", "Subtotal", "Tax", "Total" }; WriteHeader(ws, headers, hdr); for (int i = 0; i < data.Count; i++) { @@ -363,7 +395,8 @@ public class AccountDataExportController : Controller 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; + ws.Cells[r, 7].Value = q.ProjectName; + ws.Cells[r, 8].Value = q.SubTotal; ws.Cells[r, 9].Value = q.TaxAmount; ws.Cells[r, 10].Value = q.Total; } AutoFit(ws, headers.Length); } @@ -377,7 +410,7 @@ public class AccountDataExportController : Controller 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" }; + "Due Date", "Project Name", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" }; WriteHeader(ws, headers, hdr); for (int i = 0; i < data.Count; i++) { @@ -389,9 +422,10 @@ public class AccountDataExportController : Controller 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; + ws.Cells[r, 7].Value = inv.ProjectName; + ws.Cells[r, 8].Value = inv.SubTotal; ws.Cells[r, 9].Value = inv.TaxAmount; + ws.Cells[r, 10].Value = inv.Total; ws.Cells[r, 11].Value = inv.AmountPaid; + ws.Cells[r, 12].Value = inv.BalanceDue; } AutoFit(ws, headers.Length); } @@ -487,15 +521,30 @@ public class AccountDataExportController : Controller { 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"); + sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes,LeadSource,ShipToAddress,ShipToCity,ShipToState,ShipToZipCode,ShipToCountry"); 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)}"); + 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)},{CsvEscape(c.LeadSource)},{CsvEscape(c.ShipToAddress)},{CsvEscape(c.ShipToCity)},{CsvEscape(c.ShipToState)},{CsvEscape(c.ShipToZipCode)},{CsvEscape(c.ShipToCountry)}"); } return sb.ToString(); } + /// Builds the customer contacts CSV. CustomerEmail is the join key for re-import. + private async Task BuildCustomerContactsCsv(int companyId) + { + var data = await _db.CustomerContacts.AsNoTracking() + .Include(cc => cc.Customer) + .Where(cc => cc.CompanyId == companyId && !cc.IsDeleted) + .OrderBy(cc => cc.Customer!.CompanyName).ThenBy(cc => cc.LastName).ThenBy(cc => cc.FirstName) + .ToListAsync(); + var sb = new StringBuilder(); + sb.AppendLine("CustomerEmail,FirstName,LastName,Title,ContactRole,Email,Phone,MobilePhone,Notes"); + foreach (var cc in data) + sb.AppendLine($"{CsvEscape(cc.Customer?.Email)},{CsvEscape(cc.FirstName)},{CsvEscape(cc.LastName)},{CsvEscape(cc.Title)},{CsvEscape(cc.ContactRole)},{CsvEscape(cc.Email)},{CsvEscape(cc.Phone)},{CsvEscape(cc.MobilePhone)},{CsvEscape(cc.Notes)}"); + return sb.ToString(); + } + /// /// Column names match JobImportDto exactly so the file can be re-imported. /// CustomerEmail is used (not display name) because the importer resolves the customer FK by email. @@ -504,13 +553,13 @@ public class AccountDataExportController : Controller { var data = await FetchJobsAsync(companyId); var sb = new StringBuilder(); - sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,FinalPrice,CustomerPO,SpecialInstructions,Notes"); + sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,ProjectName,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)}"); + 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")},{CsvEscape(j.ProjectName)},{j.FinalPrice},{CsvEscape(j.CustomerPO)},{CsvEscape(j.SpecialInstructions)},{CsvEscape(j.InternalNotes)}"); } return sb.ToString(); } @@ -520,13 +569,13 @@ public class AccountDataExportController : Controller { 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"); + sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,ProjectName,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)}"); + 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")},{CsvEscape(q.ProjectName)},{q.SubTotal},{q.TaxAmount},{q.Total},{CsvEscape(q.Notes)},{CsvEscape(q.Terms)}"); } return sb.ToString(); } @@ -539,13 +588,13 @@ public class AccountDataExportController : Controller { 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"); + sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Project Name,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}"); + sb.AppendLine($"{inv.Id},{CsvEscape(inv.InvoiceNumber)},{CsvEscape(cust)},{inv.Status},{inv.InvoiceDate:yyyy-MM-dd},{inv.DueDate?.ToString("yyyy-MM-dd")},{CsvEscape(inv.ProjectName)},{inv.SubTotal},{inv.TaxAmount},{inv.Total},{inv.AmountPaid},{inv.BalanceDue}"); } return sb.ToString(); } diff --git a/src/PowderCoating.Web/Controllers/DataExportController.cs b/src/PowderCoating.Web/Controllers/DataExportController.cs index fc0f8da..a97cf38 100644 --- a/src/PowderCoating.Web/Controllers/DataExportController.cs +++ b/src/PowderCoating.Web/Controllers/DataExportController.cs @@ -115,10 +115,11 @@ public class DataExportController : Controller { 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 "Customers": await AddCustomersSheet(package, companyId, headerColor); break; + case "CustomerContacts": await AddCustomerContactsSheet(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; @@ -164,10 +165,11 @@ public class DataExportController : Controller { 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 "Customers": WriteCsvEntry(zip, "Customers.csv", await BuildCustomersCsv(companyId)); break; + case "CustomerContacts": WriteCsvEntry(zip, "CustomerContacts.csv", await BuildCustomerContactsCsv(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; @@ -240,7 +242,9 @@ public class DataExportController : Controller 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" }; + "Current Balance", "Lead Source", + "Ship-To Address", "Ship-To City", "Ship-To State", "Ship-To Zip", "Ship-To Country", + "Created At" }; WriteHeader(ws, headers, hdr); for (int i = 0; i < data.Count; i++) @@ -259,7 +263,13 @@ public class DataExportController : Controller 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"); + ws.Cells[r, 13].Value = c.LeadSource; + ws.Cells[r, 14].Value = c.ShipToAddress; + ws.Cells[r, 15].Value = c.ShipToCity; + ws.Cells[r, 16].Value = c.ShipToState; + ws.Cells[r, 17].Value = c.ShipToZipCode; + ws.Cells[r, 18].Value = c.ShipToCountry; + ws.Cells[r, 19].Value = c.CreatedAt.ToString("yyyy-MM-dd"); } AutoFit(ws, headers.Length); } @@ -282,22 +292,23 @@ public class DataExportController : Controller var ws = pkg.Workbook.Worksheets.Add("Jobs"); var headers = new[] { "ID", "Job Number", "Customer", "Status", "Priority", - "Description", "Due Date", "Final Price", "Created At" }; + "Description", "Project Name", "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"); + 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.ProjectName; + ws.Cells[r, 8].Value = j.DueDate?.ToString("yyyy-MM-dd"); + ws.Cells[r, 9].Value = j.FinalPrice; + ws.Cells[r, 10].Value = j.CreatedAt.ToString("yyyy-MM-dd"); } AutoFit(ws, headers.Length); } @@ -318,22 +329,23 @@ public class DataExportController : Controller var ws = pkg.Workbook.Worksheets.Add("Quotes"); var headers = new[] { "ID", "Quote Number", "Customer / Prospect", "Status", - "Quote Date", "Expiration Date", "Subtotal", "Tax", "Total" }; + "Quote Date", "Expiration Date", "Project Name", "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; + 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.ProjectName; + ws.Cells[r, 8].Value = q.SubTotal; + ws.Cells[r, 9].Value = q.TaxAmount; + ws.Cells[r, 10].Value = q.Total; } AutoFit(ws, headers.Length); } @@ -456,7 +468,7 @@ public class DataExportController : Controller 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" }; + "Due Date", "Project Name", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" }; WriteHeader(ws, headers, hdr); for (int i = 0; i < data.Count; i++) @@ -472,11 +484,12 @@ public class DataExportController : Controller 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; + ws.Cells[r, 7].Value = inv.ProjectName; + ws.Cells[r, 8].Value = inv.SubTotal; + ws.Cells[r, 9].Value = inv.TaxAmount; + ws.Cells[r, 10].Value = inv.Total; + ws.Cells[r, 11].Value = inv.AmountPaid; + ws.Cells[r, 12].Value = inv.BalanceDue; } AutoFit(ws, headers.Length); } @@ -530,11 +543,11 @@ public class DataExportController : Controller .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"); + sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes,LeadSource,ShipToAddress,ShipToCity,ShipToState,ShipToZipCode,ShipToCountry"); 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)}"); + 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)},{CsvEscape(c.LeadSource)},{CsvEscape(c.ShipToAddress)},{CsvEscape(c.ShipToCity)},{CsvEscape(c.ShipToState)},{CsvEscape(c.ShipToZipCode)},{CsvEscape(c.ShipToCountry)}"); } return sb.ToString(); } @@ -552,13 +565,13 @@ public class DataExportController : Controller .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"); + sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,ProjectName,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)}"); + 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")},{CsvEscape(j.ProjectName)},{j.FinalPrice},{CsvEscape(j.CustomerPO)},{CsvEscape(j.SpecialInstructions)},{CsvEscape(j.InternalNotes)}"); } return sb.ToString(); } @@ -574,13 +587,13 @@ public class DataExportController : Controller .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"); + sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,ProjectName,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)}"); + 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")},{CsvEscape(q.ProjectName)},{q.SubTotal},{q.TaxAmount},{q.Total},{CsvEscape(q.Notes)},{CsvEscape(q.Terms)}"); } return sb.ToString(); } @@ -596,17 +609,68 @@ public class DataExportController : Controller .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"); + sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Project Name,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}"); + sb.AppendLine($"{inv.Id},{CsvEscape(inv.InvoiceNumber)},{CsvEscape(cust)},{inv.Status},{inv.InvoiceDate:yyyy-MM-dd},{inv.DueDate?.ToString("yyyy-MM-dd")},{CsvEscape(inv.ProjectName)},{inv.SubTotal},{inv.TaxAmount},{inv.Total},{inv.AmountPaid},{inv.BalanceDue}"); } return sb.ToString(); } + /// + /// Adds a CustomerContacts worksheet: one row per additional contact linked to the company's customers. + /// CustomerEmail is the join key used by the importer to re-link contacts to their parent customer. + /// + private async Task AddCustomerContactsSheet(ExcelPackage pkg, int companyId, Color hdr) + { + var data = await _db.CustomerContacts.AsNoTracking().IgnoreQueryFilters() + .Include(cc => cc.Customer) + .Where(cc => cc.CompanyId == companyId && !cc.IsDeleted) + .OrderBy(cc => cc.Customer!.CompanyName).ThenBy(cc => cc.LastName).ThenBy(cc => cc.FirstName) + .ToListAsync(); + + var ws = pkg.Workbook.Worksheets.Add("CustomerContacts"); + var headers = new[] { "CustomerEmail", "FirstName", "LastName", "Title", "ContactRole", "Email", "Phone", "MobilePhone", "Notes" }; + WriteHeader(ws, headers, hdr); + + for (int i = 0; i < data.Count; i++) + { + var r = i + 2; + var cc = data[i]; + ws.Cells[r, 1].Value = cc.Customer?.Email; + ws.Cells[r, 2].Value = cc.FirstName; + ws.Cells[r, 3].Value = cc.LastName; + ws.Cells[r, 4].Value = cc.Title; + ws.Cells[r, 5].Value = cc.ContactRole; + ws.Cells[r, 6].Value = cc.Email; + ws.Cells[r, 7].Value = cc.Phone; + ws.Cells[r, 8].Value = cc.MobilePhone; + ws.Cells[r, 9].Value = cc.Notes; + } + AutoFit(ws, headers.Length); + } + + /// + /// Builds the customer contacts CSV string for the specified company. + /// CustomerEmail is the join key used by the importer to re-link contacts to their parent customer. + /// + private async Task BuildCustomerContactsCsv(int companyId) + { + var data = await _db.CustomerContacts.AsNoTracking().IgnoreQueryFilters() + .Include(cc => cc.Customer) + .Where(cc => cc.CompanyId == companyId && !cc.IsDeleted) + .OrderBy(cc => cc.Customer!.CompanyName).ThenBy(cc => cc.LastName).ThenBy(cc => cc.FirstName) + .ToListAsync(); + var sb = new StringBuilder(); + sb.AppendLine("CustomerEmail,FirstName,LastName,Title,ContactRole,Email,Phone,MobilePhone,Notes"); + foreach (var cc in data) + sb.AppendLine($"{CsvEscape(cc.Customer?.Email)},{CsvEscape(cc.FirstName)},{CsvEscape(cc.LastName)},{CsvEscape(cc.Title)},{CsvEscape(cc.ContactRole)},{CsvEscape(cc.Email)},{CsvEscape(cc.Phone)},{CsvEscape(cc.MobilePhone)},{CsvEscape(cc.Notes)}"); + 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. diff --git a/src/PowderCoating.Web/Views/AccountDataExport/Index.cshtml b/src/PowderCoating.Web/Views/AccountDataExport/Index.cshtml index 1640c28..2d583fd 100644 --- a/src/PowderCoating.Web/Views/AccountDataExport/Index.cshtml +++ b/src/PowderCoating.Web/Views/AccountDataExport/Index.cshtml @@ -31,8 +31,9 @@
@foreach (var item in new[] { - ("Customers", "people", "Customers"), - ("Jobs", "tools", "Jobs"), + ("Customers", "people", "Customers"), + ("CustomerContacts", "person-lines-fill", "Customer Contacts"), + ("Jobs", "tools", "Jobs"), ("Quotes", "file-earmark-text", "Quotes"), ("Invoices", "receipt", "Invoices"), ("Inventory", "boxes", "Inventory Items"), diff --git a/src/PowderCoating.Web/Views/DataExport/Index.cshtml b/src/PowderCoating.Web/Views/DataExport/Index.cshtml index 855df0b..8efe23f 100644 --- a/src/PowderCoating.Web/Views/DataExport/Index.cshtml +++ b/src/PowderCoating.Web/Views/DataExport/Index.cshtml @@ -180,6 +180,13 @@ Customers
+
+ + +