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 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:14:27 -04:00
parent 427c52a499
commit 82fb48f7a5
10 changed files with 637 additions and 76 deletions
+403
View File
@@ -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
@@ -63,4 +63,22 @@ public class CustomerImportDto
[Name("Notes")] [Name("Notes")]
public string? Notes { get; set; } 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; }
} }
@@ -33,6 +33,9 @@ public class InvoiceImportDto
[Name("DueDate")] [Name("DueDate")]
public DateTime? DueDate { get; set; } public DateTime? DueDate { get; set; }
[Name("Project Name", "ProjectName")]
public string? ProjectName { get; set; }
[Name("SubTotal")] [Name("SubTotal")]
public decimal SubTotal { get; set; } public decimal SubTotal { get; set; }
@@ -49,6 +49,9 @@ public class JobImportDto
[Name("SpecialInstructions")] [Name("SpecialInstructions")]
public string? SpecialInstructions { get; set; } public string? SpecialInstructions { get; set; }
[Name("ProjectName")]
public string? ProjectName { get; set; }
[Name("Notes")] [Name("Notes")]
public string? Notes { get; set; } public string? Notes { get; set; }
} }
@@ -38,6 +38,9 @@ public class QuoteImportDto
[Name("ExpirationDate")] [Name("ExpirationDate")]
public DateTime? ExpirationDate { get; set; } public DateTime? ExpirationDate { get; set; }
[Name("ProjectName")]
public string? ProjectName { get; set; }
[Name("Subtotal")] [Name("Subtotal")]
public decimal Subtotal { get; set; } public decimal Subtotal { get; set; }
@@ -605,6 +605,12 @@ public class CsvImportService : ICsvImportService
PaymentTerms = record.PaymentTerms?.Trim() ?? "Net 30", PaymentTerms = record.PaymentTerms?.Trim() ?? "Net 30",
IsTaxExempt = record.TaxExempt ?? false, IsTaxExempt = record.TaxExempt ?? false,
GeneralNotes = record.Notes?.Trim(), 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, IsActive = record.IsActive ?? true,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow UpdatedAt = DateTime.UtcNow
@@ -1284,6 +1290,7 @@ public class CsvImportService : ICsvImportService
Total = record.Total, Total = record.Total,
Notes = record.Notes?.Trim(), Notes = record.Notes?.Trim(),
Terms = record.TermsAndConditions?.Trim(), Terms = record.TermsAndConditions?.Trim(),
ProjectName = record.ProjectName?.Trim(),
IsCommercial = customerId.HasValue, IsCommercial = customerId.HasValue,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow UpdatedAt = DateTime.UtcNow
@@ -1557,6 +1564,7 @@ public class CsvImportService : ICsvImportService
CustomerPO = record.CustomerPO?.Trim(), CustomerPO = record.CustomerPO?.Trim(),
SpecialInstructions = record.SpecialInstructions?.Trim(), SpecialInstructions = record.SpecialInstructions?.Trim(),
InternalNotes = record.Notes?.Trim(), InternalNotes = record.Notes?.Trim(),
ProjectName = record.ProjectName?.Trim(),
Description = record.Description?.Trim() Description = record.Description?.Trim()
?? record.SpecialInstructions?.Trim() ?? record.SpecialInstructions?.Trim()
?? "Imported job", ?? "Imported job",
@@ -3149,6 +3157,7 @@ public class CsvImportService : ICsvImportService
existing.CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(); existing.CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim();
existing.Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(); existing.Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim();
existing.Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim(); existing.Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim();
existing.ProjectName = string.IsNullOrWhiteSpace(record.ProjectName) ? null : record.ProjectName.Trim();
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
result.SuccessCount++; result.SuccessCount++;
} }
@@ -3172,7 +3181,8 @@ public class CsvImportService : ICsvImportService
AmountPaid = record.AmountPaid, AmountPaid = record.AmountPaid,
CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(), CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(),
Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(), Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(),
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.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.Invoices.AddAsync(invoice);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -127,6 +127,7 @@ public class AccountDataExportController : Controller
switch (sheet) switch (sheet)
{ {
case "Customers": await AddCustomersSheet(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 "Jobs": await AddJobsSheet(package, companyId, headerColor); break;
case "Quotes": await AddQuotesSheet(package, companyId, headerColor); break; case "Quotes": await AddQuotesSheet(package, companyId, headerColor); break;
case "Invoices": await AddInvoicesSheet(package, companyId, headerColor); break; case "Invoices": await AddInvoicesSheet(package, companyId, headerColor); break;
@@ -175,6 +176,7 @@ public class AccountDataExportController : Controller
switch (sheet) switch (sheet)
{ {
case "Customers": WriteCsvEntry(zip, "Customers.csv", await BuildCustomersCsv(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 "Jobs": WriteCsvEntry(zip, "Jobs.csv", await BuildJobsCsv(companyId)); break;
case "Quotes": WriteCsvEntry(zip, "Quotes.csv", await BuildQuotesCsv(companyId)); break; case "Quotes": WriteCsvEntry(zip, "Quotes.csv", await BuildQuotesCsv(companyId)); break;
case "Invoices": WriteCsvEntry(zip, "Invoices.csv", await BuildInvoicesCsv(companyId)); break; case "Invoices": WriteCsvEntry(zip, "Invoices.csv", await BuildInvoicesCsv(companyId)); break;
@@ -299,7 +301,9 @@ public class AccountDataExportController : Controller
var data = await FetchCustomersAsync(companyId); var data = await FetchCustomersAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Customers"); var ws = pkg.Workbook.Worksheets.Add("Customers");
var headers = new[] { "ID", "Company Name", "First Name", "Last Name", "Email", "Phone", 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); WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++) 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, 8].Value = c.City; ws.Cells[r, 9].Value = c.State;
ws.Cells[r, 10].Value = c.IsActive ? "Yes" : "No"; 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, 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); AutoFit(ws, headers.Length);
} }
@@ -326,7 +357,7 @@ public class AccountDataExportController : Controller
var data = await FetchJobsAsync(companyId); var data = await FetchJobsAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Jobs"); var ws = pkg.Workbook.Worksheets.Add("Jobs");
var headers = new[] { "ID", "Job Number", "Customer", "Status", "Priority", 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); WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++) 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, 4].Value = j.JobStatus?.DisplayName ?? j.JobStatusId.ToString();
ws.Cells[r, 5].Value = j.JobPriority?.DisplayName ?? j.JobPriorityId.ToString(); ws.Cells[r, 5].Value = j.JobPriority?.DisplayName ?? j.JobPriorityId.ToString();
ws.Cells[r, 6].Value = j.Description; ws.Cells[r, 6].Value = j.Description;
ws.Cells[r, 7].Value = j.DueDate?.ToString("yyyy-MM-dd"); ws.Cells[r, 7].Value = j.ProjectName;
ws.Cells[r, 8].Value = j.FinalPrice; ws.Cells[r, 8].Value = j.DueDate?.ToString("yyyy-MM-dd");
ws.Cells[r, 9].Value = j.CreatedAt.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); AutoFit(ws, headers.Length);
} }
@@ -353,7 +385,7 @@ public class AccountDataExportController : Controller
var data = await FetchQuotesAsync(companyId); var data = await FetchQuotesAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Quotes"); var ws = pkg.Workbook.Worksheets.Add("Quotes");
var headers = new[] { "ID", "Quote Number", "Customer / Prospect", "Status", 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); WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++) 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, 4].Value = q.QuoteStatus?.DisplayName ?? q.QuoteStatusId.ToString();
ws.Cells[r, 5].Value = q.QuoteDate.ToString("yyyy-MM-dd"); 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, 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); AutoFit(ws, headers.Length);
} }
@@ -377,7 +410,7 @@ public class AccountDataExportController : Controller
var data = await FetchInvoicesAsync(companyId); var data = await FetchInvoicesAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Invoices"); var ws = pkg.Workbook.Worksheets.Add("Invoices");
var headers = new[] { "ID", "Invoice #", "Customer", "Status", "Invoice Date", 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); WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++) 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, 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, 5].Value = inv.InvoiceDate.ToString("yyyy-MM-dd");
ws.Cells[r, 6].Value = inv.DueDate?.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, 7].Value = inv.ProjectName;
ws.Cells[r, 9].Value = inv.Total; ws.Cells[r, 10].Value = inv.AmountPaid; ws.Cells[r, 8].Value = inv.SubTotal; ws.Cells[r, 9].Value = inv.TaxAmount;
ws.Cells[r, 11].Value = inv.BalanceDue; 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); AutoFit(ws, headers.Length);
} }
@@ -487,15 +521,30 @@ public class AccountDataExportController : Controller
{ {
var data = await FetchCustomersAsync(companyId); var data = await FetchCustomersAsync(companyId);
var sb = new StringBuilder(); 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) foreach (var c in data)
{ {
var customerType = c.IsCommercial ? "Commercial" : "Non-Commercial"; 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(); return sb.ToString();
} }
/// <summary>Builds the customer contacts CSV. CustomerEmail is the join key for re-import.</summary>
private async Task<string> 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();
}
/// <summary> /// <summary>
/// Column names match <c>JobImportDto</c> exactly so the file can be re-imported. /// 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. /// 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 data = await FetchJobsAsync(companyId);
var sb = new StringBuilder(); 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) foreach (var j in data)
{ {
var customerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName) var customerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName)
? j.Customer.CompanyName ? j.Customer.CompanyName
: $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(); : $"{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(); return sb.ToString();
} }
@@ -520,13 +569,13 @@ public class AccountDataExportController : Controller
{ {
var data = await FetchQuotesAsync(companyId); var data = await FetchQuotesAsync(companyId);
var sb = new StringBuilder(); 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) foreach (var q in data)
{ {
var customerName = !string.IsNullOrWhiteSpace(q.Customer?.CompanyName) var customerName = !string.IsNullOrWhiteSpace(q.Customer?.CompanyName)
? q.Customer.CompanyName ? q.Customer.CompanyName
: $"{q.Customer?.ContactFirstName} {q.Customer?.ContactLastName}".Trim(); : $"{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(); return sb.ToString();
} }
@@ -539,13 +588,13 @@ public class AccountDataExportController : Controller
{ {
var data = await FetchInvoicesAsync(companyId); var data = await FetchInvoicesAsync(companyId);
var sb = new StringBuilder(); 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) foreach (var inv in data)
{ {
var cust = inv.Customer != null var cust = inv.Customer != null
? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim()) ? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim())
: $"Customer #{inv.CustomerId}"; : $"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(); return sb.ToString();
} }
@@ -116,6 +116,7 @@ public class DataExportController : Controller
switch (sheet) switch (sheet)
{ {
case "Customers": await AddCustomersSheet(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 "Jobs": await AddJobsSheet(package, companyId, headerColor); break;
case "Quotes": await AddQuotesSheet(package, companyId, headerColor); break; case "Quotes": await AddQuotesSheet(package, companyId, headerColor); break;
case "Invoices": await AddInvoicesSheet(package, companyId, headerColor); break; case "Invoices": await AddInvoicesSheet(package, companyId, headerColor); break;
@@ -165,6 +166,7 @@ public class DataExportController : Controller
switch (sheet) switch (sheet)
{ {
case "Customers": WriteCsvEntry(zip, "Customers.csv", await BuildCustomersCsv(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 "Jobs": WriteCsvEntry(zip, "Jobs.csv", await BuildJobsCsv(companyId)); break;
case "Quotes": WriteCsvEntry(zip, "Quotes.csv", await BuildQuotesCsv(companyId)); break; case "Quotes": WriteCsvEntry(zip, "Quotes.csv", await BuildQuotesCsv(companyId)); break;
case "Invoices": WriteCsvEntry(zip, "Invoices.csv", await BuildInvoicesCsv(companyId)); break; case "Invoices": WriteCsvEntry(zip, "Invoices.csv", await BuildInvoicesCsv(companyId)); break;
@@ -240,7 +242,9 @@ public class DataExportController : Controller
var ws = pkg.Workbook.Worksheets.Add("Customers"); var ws = pkg.Workbook.Worksheets.Add("Customers");
var headers = new[] { "ID", "Company Name", "First Name", "Last Name", "Email", "Phone", var headers = new[] { "ID", "Company Name", "First Name", "Last Name", "Email", "Phone",
"Commercial", "City", "State", "Active", "Credit Limit", "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); WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++) 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, 10].Value = c.IsActive ? "Yes" : "No";
ws.Cells[r, 11].Value = c.CreditLimit; ws.Cells[r, 11].Value = c.CreditLimit;
ws.Cells[r, 12].Value = c.CurrentBalance; 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); AutoFit(ws, headers.Length);
} }
@@ -282,7 +292,7 @@ public class DataExportController : Controller
var ws = pkg.Workbook.Worksheets.Add("Jobs"); var ws = pkg.Workbook.Worksheets.Add("Jobs");
var headers = new[] { "ID", "Job Number", "Customer", "Status", "Priority", 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); WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++) for (int i = 0; i < data.Count; i++)
@@ -295,9 +305,10 @@ public class DataExportController : Controller
ws.Cells[r, 4].Value = j.JobStatus?.DisplayName ?? j.JobStatusId.ToString(); 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, 5].Value = j.JobPriority?.DisplayName ?? j.JobPriorityId.ToString();
ws.Cells[r, 6].Value = j.Description; ws.Cells[r, 6].Value = j.Description;
ws.Cells[r, 7].Value = j.DueDate?.ToString("yyyy-MM-dd"); ws.Cells[r, 7].Value = j.ProjectName;
ws.Cells[r, 8].Value = j.FinalPrice; ws.Cells[r, 8].Value = j.DueDate?.ToString("yyyy-MM-dd");
ws.Cells[r, 9].Value = j.CreatedAt.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); AutoFit(ws, headers.Length);
} }
@@ -318,7 +329,7 @@ public class DataExportController : Controller
var ws = pkg.Workbook.Worksheets.Add("Quotes"); var ws = pkg.Workbook.Worksheets.Add("Quotes");
var headers = new[] { "ID", "Quote Number", "Customer / Prospect", "Status", 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); WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++) for (int i = 0; i < data.Count; i++)
@@ -331,9 +342,10 @@ public class DataExportController : Controller
ws.Cells[r, 4].Value = q.QuoteStatus?.DisplayName ?? q.QuoteStatusId.ToString(); 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, 5].Value = q.QuoteDate.ToString("yyyy-MM-dd");
ws.Cells[r, 6].Value = q.ExpirationDate?.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, 7].Value = q.ProjectName;
ws.Cells[r, 8].Value = q.TaxAmount; ws.Cells[r, 8].Value = q.SubTotal;
ws.Cells[r, 9].Value = q.Total; ws.Cells[r, 9].Value = q.TaxAmount;
ws.Cells[r, 10].Value = q.Total;
} }
AutoFit(ws, headers.Length); AutoFit(ws, headers.Length);
} }
@@ -456,7 +468,7 @@ public class DataExportController : Controller
var ws = pkg.Workbook.Worksheets.Add("Invoices"); var ws = pkg.Workbook.Worksheets.Add("Invoices");
var headers = new[] { "ID", "Invoice #", "Customer", "Status", "Invoice Date", 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); WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++) 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, 4].Value = inv.Status.ToString();
ws.Cells[r, 5].Value = inv.InvoiceDate.ToString("yyyy-MM-dd"); 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, 6].Value = inv.DueDate?.ToString("yyyy-MM-dd");
ws.Cells[r, 7].Value = inv.SubTotal; ws.Cells[r, 7].Value = inv.ProjectName;
ws.Cells[r, 8].Value = inv.TaxAmount; ws.Cells[r, 8].Value = inv.SubTotal;
ws.Cells[r, 9].Value = inv.Total; ws.Cells[r, 9].Value = inv.TaxAmount;
ws.Cells[r, 10].Value = inv.AmountPaid; ws.Cells[r, 10].Value = inv.Total;
ws.Cells[r, 11].Value = inv.BalanceDue; ws.Cells[r, 11].Value = inv.AmountPaid;
ws.Cells[r, 12].Value = inv.BalanceDue;
} }
AutoFit(ws, headers.Length); AutoFit(ws, headers.Length);
} }
@@ -530,11 +543,11 @@ public class DataExportController : Controller
.Include(c => c.PricingTier) .Include(c => c.PricingTier)
.Where(c => c.CompanyId == companyId && !c.IsDeleted).OrderBy(c => c.CompanyName).ToListAsync(); .Where(c => c.CompanyId == companyId && !c.IsDeleted).OrderBy(c => c.CompanyName).ToListAsync();
var sb = new StringBuilder(); 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) foreach (var c in data)
{ {
var customerType = c.IsCommercial ? "Commercial" : "Non-Commercial"; 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(); return sb.ToString();
} }
@@ -552,13 +565,13 @@ public class DataExportController : Controller
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority) .Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
.OrderByDescending(j => j.CreatedAt).ToListAsync(); .OrderByDescending(j => j.CreatedAt).ToListAsync();
var sb = new StringBuilder(); 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) foreach (var j in data)
{ {
var customerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName) var customerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName)
? j.Customer.CompanyName ? j.Customer.CompanyName
: $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(); : $"{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(); return sb.ToString();
} }
@@ -574,13 +587,13 @@ public class DataExportController : Controller
.Where(q => q.CompanyId == companyId && !q.IsDeleted) .Where(q => q.CompanyId == companyId && !q.IsDeleted)
.Include(q => q.Customer).Include(q => q.QuoteStatus).OrderByDescending(q => q.QuoteDate).ToListAsync(); .Include(q => q.Customer).Include(q => q.QuoteStatus).OrderByDescending(q => q.QuoteDate).ToListAsync();
var sb = new StringBuilder(); 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) foreach (var q in data)
{ {
var customerName = !string.IsNullOrWhiteSpace(q.Customer?.CompanyName) var customerName = !string.IsNullOrWhiteSpace(q.Customer?.CompanyName)
? q.Customer.CompanyName ? q.Customer.CompanyName
: $"{q.Customer?.ContactFirstName} {q.Customer?.ContactLastName}".Trim(); : $"{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(); return sb.ToString();
} }
@@ -596,17 +609,68 @@ public class DataExportController : Controller
.Where(i => i.CompanyId == companyId && !i.IsDeleted) .Where(i => i.CompanyId == companyId && !i.IsDeleted)
.Include(i => i.Customer).OrderByDescending(i => i.InvoiceDate).ToListAsync(); .Include(i => i.Customer).OrderByDescending(i => i.InvoiceDate).ToListAsync();
var sb = new StringBuilder(); 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) foreach (var inv in data)
{ {
var cust = inv.Customer != null var cust = inv.Customer != null
? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim()) ? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim())
: $"Customer #{inv.CustomerId}"; : $"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(); return sb.ToString();
} }
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
private async Task<string> 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();
}
/// <summary> /// <summary>
/// Builds the inventory CSV string for the specified company, ordered alphabetically by name. /// Builds the inventory CSV string for the specified company, ordered alphabetically by name.
/// Column names match <see cref="InventoryItemImportDto"/> exactly so the file can be re-imported. /// Column names match <see cref="InventoryItemImportDto"/> exactly so the file can be re-imported.
@@ -32,6 +32,7 @@
@foreach (var item in new[] @foreach (var item in new[]
{ {
("Customers", "people", "Customers"), ("Customers", "people", "Customers"),
("CustomerContacts", "person-lines-fill", "Customer Contacts"),
("Jobs", "tools", "Jobs"), ("Jobs", "tools", "Jobs"),
("Quotes", "file-earmark-text", "Quotes"), ("Quotes", "file-earmark-text", "Quotes"),
("Invoices", "receipt", "Invoices"), ("Invoices", "receipt", "Invoices"),
@@ -180,6 +180,13 @@
<i class="bi bi-people me-1 text-muted"></i>Customers <i class="bi bi-people me-1 text-muted"></i>Customers
</label> </label>
</div> </div>
<div class="form-check mb-1">
<input class="form-check-input sheet-check" type="checkbox" name="sheets"
value="CustomerContacts" id="chkCustomerContacts" checked />
<label class="form-check-label" for="chkCustomerContacts">
<i class="bi bi-person-lines-fill me-1 text-muted"></i>Customer Contacts
</label>
</div>
<div class="form-check mb-1"> <div class="form-check mb-1">
<input class="form-check-input sheet-check" type="checkbox" name="sheets" <input class="form-check-input sheet-check" type="checkbox" name="sheets"
value="Jobs" id="chkJobs" checked /> value="Jobs" id="chkJobs" checked />