using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.EntityFrameworkCore; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Infrastructure.Data; using System.Security.Claims; namespace PowderCoating.Web.Controllers; [Authorize] public class ToolsController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly IQuickBooksIifService _quickBooksService; private readonly ICsvImportService _csvImportService; private readonly ITenantContext _tenantContext; private readonly ILogger _logger; private readonly UserManager _userManager; private readonly IAccountBalanceService _accountBalanceService; private readonly PowderCoating.Application.Interfaces.IAuditService _auditService; public ToolsController( IUnitOfWork unitOfWork, IQuickBooksIifService quickBooksService, ICsvImportService csvImportService, ITenantContext tenantContext, ILogger logger, UserManager userManager, IAccountBalanceService accountBalanceService, PowderCoating.Application.Interfaces.IAuditService auditService) { _unitOfWork = unitOfWork; _quickBooksService = quickBooksService; _csvImportService = csvImportService; _tenantContext = tenantContext; _logger = logger; _userManager = userManager; _accountBalanceService = accountBalanceService; _auditService = auditService; } private Task LogImportAsync(string entityType, string fileName, PowderCoating.Application.DTOs.QuickBooks.ImportResultDto r) => _auditService.LogAsync("Imported", entityType, $"{fileName}: {r.Summary}", new { fileName, r.TotalRecords, r.ImportedCount, r.UpdatedCount, r.SkippedCount, Errors = r.Errors.Count }); private Task LogCsvImportAsync(string entityType, string fileName, PowderCoating.Application.DTOs.Import.CsvImportResultDto r) => _auditService.LogAsync("Imported", entityType, $"{fileName}: {r.Summary}", new { fileName, r.TotalRows, r.SuccessCount, r.ErrorCount, r.SkippedCount }); private Task LogExportAsync(string entityType, string description) => _auditService.LogAsync("Exported", entityType, description); /// /// Renders the Tools hub page, which exposes QuickBooks IIF/CSV import/export, /// bulk CSV import/export, the worker randomizer wheel, and QuickBooks Online import tabs. /// Redirects to Home if the user is not bound to a company (i.e., a SuperAdmin with no /// active tenant context), because all tools are company-scoped. /// // GET: Tools public async Task Index() { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company. Please contact support."; return RedirectToAction("Index", "Home"); } await PopulateImportAccountDropdownsAsync(); return View(); } /// /// Returns the current GL account lists as JSON so the import wizard can refresh dropdowns /// without a full page reload. Called via AJAX when the catalog or inventory import card opens. /// // GET: Tools/GetImportAccounts [HttpGet] public async Task GetImportAccounts() { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId); var revenue = allAccounts .Where(a => a.AccountType == AccountType.Revenue && a.IsActive) .OrderBy(a => a.AccountNumber) .Select(a => new { value = a.Id.ToString(), text = $"{a.AccountNumber} – {a.Name}" }) .Prepend(new { value = "", text = "— None —" }); var cogs = allAccounts .Where(a => a.AccountType == AccountType.CostOfGoods && a.IsActive) .OrderBy(a => a.AccountNumber) .Select(a => new { value = a.Id.ToString(), text = $"{a.AccountNumber} – {a.Name}" }) .Prepend(new { value = "", text = "— None —" }); var inventory = allAccounts .Where(a => a.AccountType == AccountType.Asset && a.AccountSubType == AccountSubType.Inventory && a.IsActive) .OrderBy(a => a.AccountNumber) .Select(a => new { value = a.Id.ToString(), text = $"{a.AccountNumber} – {a.Name}" }) .Prepend(new { value = "", text = "— None —" }); return Json(new { revenueAccounts = revenue, cogsAccounts = cogs, inventoryAccounts = inventory }); } /// /// Populates ViewBag dropdowns used by CSV import forms that need an optional GL account /// override (e.g., Revenue, COGS, Inventory Asset accounts). The "— None —" sentinel at /// index 0 means "use whatever the import service defaults to" so callers can pass null. /// Queries are company-scoped via global query filters; no ignoreQueryFilters needed here. /// private async Task PopulateImportAccountDropdownsAsync() { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId); var revenueAccounts = allAccounts .Where(a => a.AccountType == AccountType.Revenue && a.IsActive) .OrderBy(a => a.AccountNumber) .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())) .ToList(); revenueAccounts.Insert(0, new SelectListItem("— None —", "")); ViewBag.RevenueAccounts = revenueAccounts; var cogsAccounts = allAccounts .Where(a => a.AccountType == AccountType.CostOfGoods && a.IsActive) .OrderBy(a => a.AccountNumber) .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())) .ToList(); cogsAccounts.Insert(0, new SelectListItem("— None —", "")); ViewBag.CogsAccounts = cogsAccounts; var inventoryAccounts = allAccounts .Where(a => a.AccountType == AccountType.Asset && a.AccountSubType == AccountSubType.Inventory && a.IsActive) .OrderBy(a => a.AccountNumber) .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())) .ToList(); inventoryAccounts.Insert(0, new SelectListItem("— None —", "")); ViewBag.InventoryAccounts = inventoryAccounts; } /// /// Exports all customers for the current company in QuickBooks IIF format (desktop) or /// CSV format (online), delegating format-specific logic to . /// The query-string parameter defaults to "desktop" (IIF / text/plain); /// pass "online" to get a CSV suitable for QuickBooks Online's import wizard. /// Returns the file directly on success; sets TempData["ErrorMessage"] and redirects on failure. /// // GET: Tools/ExportCustomers [HttpGet] public async Task ExportCustomers(string format = "desktop") { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } _logger.LogInformation("User {UserName} exporting customers for company {CompanyId} in {Format} format", User.Identity?.Name, companyId, format); var result = format.ToLower() == "online" ? await _quickBooksService.ExportCustomersOnlineAsync(companyId.Value) : await _quickBooksService.ExportCustomersAsync(companyId.Value); if (!result.Success) { _logger.LogWarning("Export customers failed: {ErrorMessage}", result.ErrorMessage); TempData["ErrorMessage"] = result.ErrorMessage; return RedirectToAction(nameof(Index)); } var contentType = format.ToLower() == "online" ? "text/csv" : "text/plain"; await LogExportAsync("Customers", $"QB {format} export"); return File(result.FileContent, contentType, result.FileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting customers"); TempData["ErrorMessage"] = "An error occurred while exporting customers."; return RedirectToAction(nameof(Index)); } } /// /// Exports the company's service catalog in QuickBooks IIF (desktop) or CSV (online) format. /// Mirrors in structure; "online" produces a CSV that QuickBooks /// Online can import as Products & Services, while "desktop" produces an IIF file for /// QuickBooks Desktop item import. /// // GET: Tools/ExportCatalogItems [HttpGet] public async Task ExportCatalogItems(string format = "desktop") { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } _logger.LogInformation("User {UserName} exporting catalog items for company {CompanyId} in {Format} format", User.Identity?.Name, companyId, format); var result = format.ToLower() == "online" ? await _quickBooksService.ExportCatalogItemsOnlineAsync(companyId.Value) : await _quickBooksService.ExportCatalogItemsAsync(companyId.Value); if (!result.Success) { _logger.LogWarning("Export catalog items failed: {ErrorMessage}", result.ErrorMessage); TempData["ErrorMessage"] = result.ErrorMessage; return RedirectToAction(nameof(Index)); } var contentType = format.ToLower() == "online" ? "text/csv" : "text/plain"; await LogExportAsync("CatalogItems", $"QB {format} export"); return File(result.FileContent, contentType, result.FileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting catalog items"); TempData["ErrorMessage"] = "An error occurred while exporting catalog items."; return RedirectToAction(nameof(Index)); } } /// /// Accepts a QuickBooks-exported IIF or CSV customer file and upserts customers for the current /// company via . Returns JSON so the page can display a /// per-row error/warning accordion without a full page reload. The response always includes /// row-level error details (severity, lineNumber, recordName, fieldName, errorMessage, /// displayMessage) to let the user drill into any skipped or failed records. /// // POST: Tools/ImportCustomers [HttpPost] [ValidateAntiForgeryToken] public async Task ImportCustomers(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { return Json(new { success = false, message = "Your account is not associated with a company." }); } if (file == null || file.Length == 0) { return Json(new { success = false, message = "No file provided or file is empty." }); } var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; _logger.LogInformation("User {UserName} importing customers from {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); var result = await _quickBooksService.ImportCustomersAsync(file, companyId.Value, userId); await LogImportAsync("Customers", file.FileName, result); return Json(new { success = result.Success, message = result.Success ? "Import completed successfully!" : "Import completed with errors.", totalRecords = result.TotalRecords, importedCount = result.ImportedCount, updatedCount = result.UpdatedCount, skippedCount = result.SkippedCount, errors = result.Errors.Select(e => new { severity = e.Severity, lineNumber = e.LineNumber, recordName = e.RecordName, fieldName = e.FieldName, errorMessage = e.ErrorMessage, displayMessage = e.DisplayMessage }).ToList() }); } catch (Exception ex) { _logger.LogError(ex, "Error importing customers"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Exports the company's vendor list as a QuickBooks Desktop IIF file (text/plain). /// Vendor export is desktop-only; there is no "online" variant because the QB Online vendor /// CSV format is handled by the separate QBO import/export endpoints. /// // GET: Tools/ExportVendors [HttpGet] public async Task ExportVendors() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } _logger.LogInformation("User {UserName} exporting vendors for company {CompanyId}", User.Identity?.Name, companyId); var result = await _quickBooksService.ExportVendorsAsync(companyId.Value); if (!result.Success) { TempData["ErrorMessage"] = result.ErrorMessage; return RedirectToAction(nameof(Index)); } await LogExportAsync("Vendors", "QB IIF export"); return File(result.FileContent, "text/plain", result.FileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting vendors"); TempData["ErrorMessage"] = "An error occurred while exporting vendors."; return RedirectToAction(nameof(Index)); } } /// /// Imports vendors from a QuickBooks-exported IIF or CSV file, upserting records for the /// current company. Returns JSON with the same row-level detail structure as /// so the UI can render a consistent error accordion. /// // POST: Tools/ImportVendors [HttpPost] [ValidateAntiForgeryToken] public async Task ImportVendors(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return Json(new { success = false, message = "Your account is not associated with a company." }); if (file == null || file.Length == 0) return Json(new { success = false, message = "No file provided or file is empty." }); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; _logger.LogInformation("User {UserName} importing vendors from {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); var result = await _quickBooksService.ImportVendorsAsync(file, companyId.Value, userId); await LogImportAsync("Vendors", file.FileName, result); return Json(new { success = result.Success, message = result.Success ? "Import completed successfully!" : "Import completed with errors.", totalRecords = result.TotalRecords, importedCount = result.ImportedCount, updatedCount = result.UpdatedCount, skippedCount = result.SkippedCount, errors = result.Errors.Select(e => new { severity = e.Severity, lineNumber = e.LineNumber, recordName = e.RecordName, fieldName = e.FieldName, errorMessage = e.ErrorMessage, displayMessage = e.DisplayMessage }).ToList() }); } catch (Exception ex) { _logger.LogError(ex, "Error importing vendors"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Imports historical invoices from a QuickBooks CSV export (e.g., Transaction List by Date). /// After importing, calls to bring /// every customer's CurrentBalance and every GL account balance into sync, because /// bulk invoice imports can shift outstanding AR totals significantly. /// // POST: Tools/ImportQbInvoices [HttpPost] [ValidateAntiForgeryToken] public async Task ImportQbInvoices(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return Json(new { success = false, message = "Your account is not associated with a company." }); if (file == null || file.Length == 0) return Json(new { success = false, message = "No file provided or file is empty." }); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; _logger.LogInformation("User {UserName} importing QB invoices from {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); var result = await _quickBooksService.ImportQbInvoicesFromCsvAsync(file, companyId.Value, userId); await LogImportAsync("Invoices", file.FileName, result); await _accountBalanceService.RecalculateAllAsync(companyId.Value); return Json(new { success = result.Success, message = result.Success ? "Invoice import completed!" : "Import completed with errors.", totalRecords = result.TotalRecords, importedCount = result.ImportedCount, updatedCount = result.UpdatedCount, skippedCount = result.SkippedCount, errors = result.Errors.Select(e => new { severity = e.Severity, lineNumber = e.LineNumber, recordName = e.RecordName, fieldName = e.FieldName, errorMessage = e.ErrorMessage, displayMessage = e.DisplayMessage }).ToList() }); } catch (Exception ex) { _logger.LogError(ex, "Error importing QB invoices"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Imports general-ledger transactions from a QuickBooks CSV export. Like /// , triggers a full account-balance recalculation after the /// import because journal-entry style transactions directly affect GL account running totals. /// // POST: Tools/ImportQbTransactions [HttpPost] [ValidateAntiForgeryToken] public async Task ImportQbTransactions(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return Json(new { success = false, message = "Your account is not associated with a company." }); if (file == null || file.Length == 0) return Json(new { success = false, message = "No file provided or file is empty." }); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; _logger.LogInformation("User {UserName} importing QB transactions from {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); var result = await _quickBooksService.ImportQbTransactionsFromCsvAsync(file, companyId.Value, userId); await LogImportAsync("Transactions", file.FileName, result); await _accountBalanceService.RecalculateAllAsync(companyId.Value); return Json(new { success = result.Success, message = result.Success ? "Transaction import completed!" : "Import completed with errors.", totalRecords = result.TotalRecords, importedCount = result.ImportedCount, updatedCount = result.UpdatedCount, skippedCount = result.SkippedCount, errors = result.Errors.Select(e => new { severity = e.Severity, lineNumber = e.LineNumber, recordName = e.RecordName, fieldName = e.FieldName, errorMessage = e.ErrorMessage, displayMessage = e.DisplayMessage }).ToList() }); } catch (Exception ex) { _logger.LogError(ex, "Error importing QB transactions"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Imports service catalog items from a QuickBooks IIF or CSV file, creating or updating /// catalog items for the current company. No account-balance recalculation is needed because /// catalog items carry pricing metadata but are not themselves financial transactions. /// // POST: Tools/ImportCatalogItems [HttpPost] [ValidateAntiForgeryToken] public async Task ImportCatalogItems(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { return Json(new { success = false, message = "Your account is not associated with a company." }); } if (file == null || file.Length == 0) { return Json(new { success = false, message = "No file provided or file is empty." }); } var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; _logger.LogInformation("User {UserName} importing catalog items from {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); var result = await _quickBooksService.ImportCatalogItemsAsync(file, companyId.Value, userId); await LogImportAsync("CatalogItems", file.FileName, result); return Json(new { success = result.Success, message = result.Success ? "Import completed successfully!" : "Import completed with errors.", totalRecords = result.TotalRecords, importedCount = result.ImportedCount, updatedCount = result.UpdatedCount, skippedCount = result.SkippedCount, errors = result.Errors.Select(e => new { severity = e.Severity, lineNumber = e.LineNumber, recordName = e.RecordName, fieldName = e.FieldName, errorMessage = e.ErrorMessage, displayMessage = e.DisplayMessage }).ToList() }); } catch (Exception ex) { _logger.LogError(ex, "Error importing catalog items"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Imports the Chart of Accounts from a QuickBooks CSV export, creating or updating GL accounts /// for the current company. Triggers a full balance recalculation because account metadata /// (type, subtype) directly affects how balances are aggregated in financial reports — a /// changed account type can shift an amount from Assets to Expenses, for example. /// // POST: Tools/ImportChartOfAccounts [HttpPost] [ValidateAntiForgeryToken] public async Task ImportChartOfAccounts(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return Json(new { success = false, message = "Your account is not associated with a company." }); if (file == null || file.Length == 0) return Json(new { success = false, message = "No file provided or file is empty." }); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; _logger.LogInformation("User {UserName} importing Chart of Accounts from {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); var result = await _quickBooksService.ImportChartOfAccountsAsync(file, companyId.Value, userId); await LogImportAsync("ChartOfAccounts", file.FileName, result); await _accountBalanceService.RecalculateAllAsync(companyId.Value); return Json(new { success = result.Success, message = result.Success ? "Chart of Accounts imported successfully!" : "Import completed with errors.", totalRecords = result.TotalRecords, importedCount = result.ImportedCount, updatedCount = result.UpdatedCount, skippedCount = result.SkippedCount, errors = result.Errors.Select(e => new { severity = e.Severity, lineNumber = e.LineNumber, recordName = e.RecordName, errorMessage = e.ErrorMessage, displayMessage = e.DisplayMessage }).ToList() }); } catch (Exception ex) { _logger.LogError(ex, "Error importing Chart of Accounts"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Imports vendor payments (bill payment checks/EFTs) from a QuickBooks CSV export and /// reconciles them against existing vendor bills. Triggers account-balance recalculation /// because payments reduce AP balances and affect cash/bank accounts. Use /// instead when the source file contains both bills /// and their associated payments (e.g., a Vendor Balance Detail report), which avoids the /// need to upload two separate files. /// // POST: Tools/ImportQbVendorPayments [HttpPost] [ValidateAntiForgeryToken] public async Task ImportQbVendorPayments(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return Json(new { success = false, message = "Your account is not associated with a company." }); if (file == null || file.Length == 0) return Json(new { success = false, message = "No file provided or file is empty." }); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; _logger.LogInformation("User {UserName} importing QB vendor payments from {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); var result = await _quickBooksService.ImportQbVendorPaymentsAsync(file, companyId.Value, userId); await LogImportAsync("VendorPayments", file.FileName, result); await _accountBalanceService.RecalculateAllAsync(companyId.Value); return Json(new { success = result.Success, message = result.Success ? "Vendor payments imported successfully!" : "Import completed with errors.", totalRecords = result.TotalRecords, importedCount = result.ImportedCount, updatedCount = result.UpdatedCount, skippedCount = result.SkippedCount, errors = result.Errors.Select(e => new { severity = e.Severity, lineNumber = e.LineNumber, recordName = e.RecordName, fieldName = e.FieldName, errorMessage = e.ErrorMessage, displayMessage = e.DisplayMessage }).ToList() }); } catch (Exception ex) { _logger.LogError(ex, "Error importing QB vendor payments"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Combined action that processes a single QuickBooks Vendor Balance Detail CSV in two passes: /// first importing all bills, then applying payments to those bills via FIFO matching. /// The file is buffered into a once so two independent /// wrappers can be created — one per service call — without re-reading /// the original upload stream (which would be exhausted after the first pass). "Bill skipped" /// messages from the payments pass are suppressed in the combined error list because every /// bill-type row in the file will naturally be skipped by the payment importer; surfacing those /// would create noise that masks real payment-matching failures. /// // POST: Tools/ImportQbBillsAndPayments // Combined action: runs Bills pass then Vendor Payments pass on the same file (Vendor Balance Detail). [HttpPost] [ValidateAntiForgeryToken] public async Task ImportQbBillsAndPayments(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return Json(new { success = false, message = "Your account is not associated with a company." }); if (file == null || file.Length == 0) return Json(new { success = false, message = "No file provided or file is empty." }); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; _logger.LogInformation("User {UserName} importing QB bills+payments from {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); // Read the file bytes once so we can pass two independent streams to the service using var ms = new System.IO.MemoryStream(); await file.OpenReadStream().CopyToAsync(ms); var fileBytes = ms.ToArray(); IFormFile MakeFormFile() => new FormFile( new System.IO.MemoryStream(fileBytes), 0, fileBytes.Length, file.Name, file.FileName) { Headers = file.Headers, ContentType = file.ContentType }; // Pass 1: Bills var billsResult = await _quickBooksService.ImportQbBillsAsync(MakeFormFile(), companyId.Value, userId); // Pass 2: Vendor Payments (FIFO against just-imported bills) var pmtResult = await _quickBooksService.ImportQbVendorPaymentsAsync(MakeFormFile(), companyId.Value, userId); await _accountBalanceService.RecalculateAllAsync(companyId.Value); await LogImportAsync("Bills", file.FileName, billsResult); await LogImportAsync("VendorPayments", file.FileName, pmtResult); // Combine results — only surface non-"Bill" skipped messages from payments pass // (the payments pass will generate "Bill skipped" messages for every Bill row, suppress those) var filteredPmtErrors = pmtResult.Errors .Where(e => !(e.Severity == "Skipped" && e.ErrorMessage != null && e.ErrorMessage.Contains("not a bill payment", StringComparison.OrdinalIgnoreCase))) .ToList(); var allErrors = billsResult.Errors.Concat(filteredPmtErrors).ToList(); bool overallSuccess = billsResult.Success && pmtResult.Success; return Json(new { success = overallSuccess, message = overallSuccess ? $"Bills & payments imported successfully!" : "Import completed with some errors — see details below.", billsImported = billsResult.ImportedCount, paymentsImported = pmtResult.ImportedCount, totalRecords = billsResult.TotalRecords + pmtResult.TotalRecords, importedCount = billsResult.ImportedCount + pmtResult.ImportedCount, updatedCount = billsResult.UpdatedCount + pmtResult.UpdatedCount, skippedCount = billsResult.SkippedCount + pmtResult.SkippedCount, alreadyRecordedCount = billsResult.AlreadyRecordedCount + pmtResult.AlreadyRecordedCount, errors = allErrors.Select(e => new { severity = e.Severity, lineNumber = e.LineNumber, recordName = e.RecordName, fieldName = e.FieldName, errorMessage = e.ErrorMessage, displayMessage = e.DisplayMessage }).ToList() }); } catch (Exception ex) { _logger.LogError(ex, "Error importing QB bills and payments"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Imports vendor bills (AP invoices) from a QuickBooks CSV export. Use this when you have a /// bills-only export file; use when the same file also /// contains payment rows. Triggers a full account-balance recalculation because each new bill /// increases AP and may affect expense account balances. /// // POST: Tools/ImportQbBills [HttpPost] [ValidateAntiForgeryToken] public async Task ImportQbBills(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return Json(new { success = false, message = "Your account is not associated with a company." }); if (file == null || file.Length == 0) return Json(new { success = false, message = "No file provided or file is empty." }); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; _logger.LogInformation("User {UserName} importing QB bills from {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); var result = await _quickBooksService.ImportQbBillsAsync(file, companyId.Value, userId); await LogImportAsync("Bills", file.FileName, result); await _accountBalanceService.RecalculateAllAsync(companyId.Value); return Json(new { success = result.Success, message = result.Success ? "Bills imported successfully!" : "Import completed with errors.", totalRecords = result.TotalRecords, importedCount = result.ImportedCount, updatedCount = result.UpdatedCount, skippedCount = result.SkippedCount, errors = result.Errors.Select(e => new { severity = e.Severity, lineNumber = e.LineNumber, recordName = e.RecordName, fieldName = e.FieldName, errorMessage = e.ErrorMessage, displayMessage = e.DisplayMessage }).ToList() }); } catch (Exception ex) { _logger.LogError(ex, "Error importing QB bills"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Imports inventory quantities and valuations from a QuickBooks Inventory Valuation Summary CSV. /// Triggers a balance recalculation because imported on-hand quantities feed into the Inventory /// Asset account balance used on the Balance Sheet report. /// // POST: Tools/ImportQbInventoryValuation [HttpPost] [ValidateAntiForgeryToken] public async Task ImportQbInventoryValuation(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return Json(new { success = false, message = "Your account is not associated with a company." }); if (file == null || file.Length == 0) return Json(new { success = false, message = "No file provided or file is empty." }); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; _logger.LogInformation("User {UserName} importing QB inventory valuation from {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); var result = await _quickBooksService.ImportQbInventoryValuationAsync(file, companyId.Value, userId); await LogImportAsync("InventoryValuation", file.FileName, result); await _accountBalanceService.RecalculateAllAsync(companyId.Value); return Json(new { success = result.Success, message = result.Success ? "Inventory import completed successfully!" : "Import completed with errors.", totalRecords = result.TotalRecords, importedCount = result.ImportedCount, updatedCount = result.UpdatedCount, skippedCount = result.SkippedCount, errors = result.Errors.Select(e => new { severity = e.Severity, lineNumber = e.LineNumber, recordName = e.RecordName, errorMessage = e.ErrorMessage, displayMessage = e.DisplayMessage }).ToList() }); } catch (Exception ex) { _logger.LogError(ex, "Error importing QB inventory valuation"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Returns the full names of all active users in the current company as a JSON array, used /// by the shop-floor randomizer (spinning wheel) feature on the Tools page. Queries /// directly rather than going through /// because Identity users are not exposed as a first-class repository; the filter by /// CompanyId provides the multi-tenant isolation that global query filters would /// normally enforce for other entity types. /// // GET: Tools/GetShopWorkers - Returns active company users for randomizer wheel [HttpGet] public async Task GetShopWorkers() { try { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var users = await _userManager.Users .Where(u => u.CompanyId == companyId && u.IsActive) .OrderBy(u => u.FirstName).ThenBy(u => u.LastName) .ToListAsync(); var workerNames = users .Select(u => u.FullName) .ToList(); return Json(new { success = true, workers = workerNames }); } catch (Exception ex) { _logger.LogError(ex, "Error fetching shop workers for randomizer"); return Json(new { success = false, message = "Error loading shop workers" }); } } #region CSV Bulk Import /// /// Downloads a blank CSV file pre-populated with the correct column headers for the native /// (non-QuickBooks) customer bulk import. Timestamps the filename to prevent browser caching /// when users download a fresh copy after a column layout change. /// // GET: Tools/DownloadCustomerTemplate [HttpGet] public IActionResult DownloadCustomerTemplate() { try { var csvContent = _csvImportService.GenerateCustomerTemplate(); var fileName = $"customer_import_template_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; return File(csvContent, "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error generating customer template"); TempData["ErrorMessage"] = "An error occurred while generating the template."; return RedirectToAction(nameof(Index)); } } /// /// Downloads a blank CSV template for the native catalog-item bulk import, with headers /// matching the columns expected by . /// // GET: Tools/DownloadCatalogTemplate [HttpGet] public IActionResult DownloadCatalogTemplate() { try { var csvContent = _csvImportService.GenerateCatalogItemTemplate(); var fileName = $"catalog_import_template_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; return File(csvContent, "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error generating catalog template"); TempData["ErrorMessage"] = "An error occurred while generating the template."; return RedirectToAction(nameof(Index)); } } /// /// Downloads a blank CSV template for the native inventory-item bulk import, with headers /// matching the columns expected by . /// // GET: Tools/DownloadInventoryTemplate [HttpGet] public IActionResult DownloadInventoryTemplate() { try { var csvContent = _csvImportService.GenerateInventoryItemTemplate(); var fileName = $"inventory_import_template_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; return File(csvContent, "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error generating inventory template"); TempData["ErrorMessage"] = "An error occurred while generating the template."; return RedirectToAction(nameof(Index)); } } /// /// Accepts a native CSV file (not QuickBooks-formatted) and bulk-imports customers for the /// current company via . The service owns all upsert logic; /// this action only validates the file type (must be .csv), resolves the company, and /// returns a JSON result with per-row success/error/warning detail for the UI accordion. /// // POST: Tools/CsvImportCustomers [HttpPost] [ValidateAntiForgeryToken] public async Task CsvImportCustomers(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { return Json(new { success = false, message = "Your account is not associated with a company." }); } if (file == null || file.Length == 0) { return Json(new { success = false, message = "No file provided or file is empty." }); } if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) { return Json(new { success = false, message = "Only CSV files are allowed." }); } _logger.LogInformation("User {UserName} importing customers from CSV {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); using var stream = file.OpenReadStream(); var result = await _csvImportService.ImportCustomersAsync(stream, companyId.Value); await LogCsvImportAsync("Customers", file.FileName, result); return Json(new { success = result.Success, message = result.Summary, successCount = result.SuccessCount, skippedCount = result.SkippedCount, errorCount = result.ErrorCount, totalRows = result.TotalRows, errors = result.Errors, warnings = result.Warnings }); } catch (Exception ex) { _logger.LogError(ex, "Error importing customers from CSV"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Bulk-imports service catalog items from a native CSV file. The optional /// and parameters let /// the user map all imported items to specific GL accounts in one pass, avoiding the need to /// edit each item individually afterward. Passing null for either account defers to the /// service's default mapping logic. /// // POST: Tools/CsvImportCatalogItems [HttpPost] [ValidateAntiForgeryToken] public async Task CsvImportCatalogItems(IFormFile file, int? revenueAccountId, int? cogsAccountId) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { return Json(new { success = false, message = "Your account is not associated with a company." }); } if (file == null || file.Length == 0) { return Json(new { success = false, message = "No file provided or file is empty." }); } if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) { return Json(new { success = false, message = "Only CSV files are allowed." }); } _logger.LogInformation("User {UserName} importing catalog items from CSV {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); // Validate account IDs belong to this company — stale page load can produce IDs // that were valid before a data reset but no longer exist. var validAccountIds = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value)) .Select(a => a.Id).ToHashSet(); if (revenueAccountId.HasValue && !validAccountIds.Contains(revenueAccountId.Value)) revenueAccountId = null; if (cogsAccountId.HasValue && !validAccountIds.Contains(cogsAccountId.Value)) cogsAccountId = null; using var stream = file.OpenReadStream(); var result = await _csvImportService.ImportCatalogItemsAsync(stream, companyId.Value, revenueAccountId, cogsAccountId); await LogCsvImportAsync("CatalogItems", file.FileName, result); return Json(new { success = result.Success, message = result.Summary, successCount = result.SuccessCount, skippedCount = result.SkippedCount, errorCount = result.ErrorCount, totalRows = result.TotalRows, errors = result.Errors, warnings = result.Warnings }); } catch (Exception ex) { _logger.LogError(ex, "Error importing catalog items from CSV"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Bulk-imports inventory items (powder coatings, consumables, etc.) from a native CSV file. /// Like , the optional GL account overrides /// ( for the balance-sheet Inventory Asset account and /// for the income-statement COGS account) let the user /// pre-assign accounts during the import rather than editing each item afterward. /// // POST: Tools/CsvImportInventoryItems [HttpPost] [ValidateAntiForgeryToken] public async Task CsvImportInventoryItems(IFormFile file, int? inventoryAccountId, int? cogsAccountId) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { return Json(new { success = false, message = "Your account is not associated with a company." }); } if (file == null || file.Length == 0) { return Json(new { success = false, message = "No file provided or file is empty." }); } if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) { return Json(new { success = false, message = "Only CSV files are allowed." }); } _logger.LogInformation("User {UserName} importing inventory items from CSV {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); // Validate account IDs belong to this company — stale page load can produce IDs // that were valid before a data reset but no longer exist. var validAccountIds = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value)) .Select(a => a.Id).ToHashSet(); if (inventoryAccountId.HasValue && !validAccountIds.Contains(inventoryAccountId.Value)) inventoryAccountId = null; if (cogsAccountId.HasValue && !validAccountIds.Contains(cogsAccountId.Value)) cogsAccountId = null; using var stream = file.OpenReadStream(); var result = await _csvImportService.ImportInventoryItemsAsync(stream, companyId.Value, inventoryAccountId, cogsAccountId); await LogCsvImportAsync("InventoryItems", file.FileName, result); return Json(new { success = result.Success, message = result.Summary, successCount = result.SuccessCount, skippedCount = result.SkippedCount, errorCount = result.ErrorCount, totalRows = result.TotalRows, errors = result.Errors, warnings = result.Warnings }); } catch (Exception ex) { _logger.LogError(ex, "Error importing inventory items from CSV"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Downloads a blank CSV template for the native quote bulk import, with headers matching /// the columns expected by . /// // GET: Tools/DownloadQuoteTemplate [HttpGet] public IActionResult DownloadQuoteTemplate() { var csvBytes = _csvImportService.GenerateQuoteTemplate(); return File(csvBytes, "text/csv", "quote_import_template.csv"); } /// /// Bulk-imports quotes from a native CSV file. Quotes can reference existing customers by /// name or ID; the import service handles customer resolution. Returns JSON with per-row /// status so the UI can display a success/error breakdown without a page reload. /// // POST: Tools/CsvImportQuotes [HttpPost] [ValidateAntiForgeryToken] public async Task CsvImportQuotes(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { return Json(new { success = false, message = "Your account is not associated with a company." }); } if (file == null || file.Length == 0) { return Json(new { success = false, message = "No file provided or file is empty." }); } if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) { return Json(new { success = false, message = "Only CSV files are allowed." }); } _logger.LogInformation("User {UserName} importing quotes from CSV {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); using var stream = file.OpenReadStream(); var result = await _csvImportService.ImportQuotesAsync(stream, companyId.Value); await LogCsvImportAsync("Quotes", file.FileName, result); return Json(new { success = result.Success, message = result.Summary, successCount = result.SuccessCount, skippedCount = result.SkippedCount, errorCount = result.ErrorCount, totalRows = result.TotalRows, errors = result.Errors, warnings = result.Warnings }); } catch (Exception ex) { _logger.LogError(ex, "Error importing quotes from CSV"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Downloads a blank CSV template for the native job bulk import, with headers matching /// the columns expected by . /// // GET: Tools/DownloadJobTemplate [HttpGet] public IActionResult DownloadJobTemplate() { var csvBytes = _csvImportService.GenerateJobTemplate(); return File(csvBytes, "text/csv", "job_import_template.csv"); } /// /// Bulk-imports jobs from a native CSV file. Job status and priority are lookup-table /// entities (not enums), so the import service must resolve StatusCode and /// PriorityCode strings against the company's current lookup tables. Returns JSON /// with per-row status detail for the UI accordion. /// // POST: Tools/CsvImportJobs [HttpPost] [ValidateAntiForgeryToken] public async Task CsvImportJobs(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { return Json(new { success = false, message = "Your account is not associated with a company." }); } if (file == null || file.Length == 0) { return Json(new { success = false, message = "No file provided or file is empty." }); } if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) { return Json(new { success = false, message = "Only CSV files are allowed." }); } _logger.LogInformation("User {UserName} importing jobs from CSV {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); using var stream = file.OpenReadStream(); var result = await _csvImportService.ImportJobsAsync(stream, companyId.Value); await LogCsvImportAsync("Jobs", file.FileName, result); return Json(new { success = result.Success, message = result.Summary, successCount = result.SuccessCount, skippedCount = result.SkippedCount, errorCount = result.ErrorCount, totalRows = result.TotalRows, errors = result.Errors, warnings = result.Warnings }); } catch (Exception ex) { _logger.LogError(ex, "Error importing jobs from CSV"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Downloads a blank CSV template for the native invoice bulk import, with headers matching /// the columns expected by and produced by /// for round-trip compatibility. /// // GET: Tools/DownloadInvoiceTemplate [HttpGet] public IActionResult DownloadInvoiceTemplate() { var csvBytes = _csvImportService.GenerateInvoiceTemplate(); return File(csvBytes, "text/csv", "invoice_import_template.csv"); } /// /// Bulk-imports invoice headers from a native CSV file. Customers are resolved by /// CustomerEmail then CustomerName; rows without a match are skipped. Existing invoices /// matched by InvoiceNumber are updated; new ones are created. Returns JSON with per-row /// status so the UI can display a success/error breakdown without a page reload. /// // POST: Tools/CsvImportInvoices [HttpPost] [ValidateAntiForgeryToken] public async Task CsvImportInvoices(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return Json(new { success = false, message = "Your account is not associated with a company." }); if (file == null || file.Length == 0) return Json(new { success = false, message = "No file provided or file is empty." }); if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) return Json(new { success = false, message = "Only CSV files are allowed." }); _logger.LogInformation("User {UserName} importing invoices from CSV {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); using var stream = file.OpenReadStream(); var result = await _csvImportService.ImportInvoicesAsync(stream, companyId.Value); await LogCsvImportAsync("Invoices", file.FileName, result); return Json(new { success = result.Success, message = result.Summary, successCount = result.SuccessCount, skippedCount = result.SkippedCount, errorCount = result.ErrorCount, totalRows = result.TotalRows, errors = result.Errors, warnings = result.Warnings }); } catch (Exception ex) { _logger.LogError(ex, "Error importing invoices from CSV"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Downloads a blank CSV template for the native payment bulk import. /// Columns match the native ExportPaymentsCsv output for round-trip compatibility. /// // GET: Tools/DownloadPaymentTemplate [HttpGet] public IActionResult DownloadPaymentTemplate() { var csvBytes = _csvImportService.GeneratePaymentTemplate(); return File(csvBytes, "text/csv", "payment_import_template.csv"); } /// /// Bulk-imports payment records from a native CSV file. Invoices are resolved by InvoiceNumber. /// Duplicate payments (same invoice + date + amount) are skipped. Updates the invoice AmountPaid /// and status after each successful row. /// // POST: Tools/CsvImportPayments [HttpPost] [ValidateAntiForgeryToken] public async Task CsvImportPayments(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return Json(new { success = false, message = "Your account is not associated with a company." }); if (file == null || file.Length == 0) return Json(new { success = false, message = "No file provided or file is empty." }); if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) return Json(new { success = false, message = "Only CSV files are allowed." }); _logger.LogInformation("User {UserName} importing payments from CSV {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); using var stream = file.OpenReadStream(); var result = await _csvImportService.ImportPaymentsAsync(stream, companyId.Value); await LogCsvImportAsync("Payments", file.FileName, result); return Json(new { success = result.Success, message = result.Summary, successCount = result.SuccessCount, skippedCount = result.SkippedCount, errorCount = result.ErrorCount, totalRows = result.TotalRows, errors = result.Errors, warnings = result.Warnings }); } catch (Exception ex) { _logger.LogError(ex, "Error importing payments from CSV"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Downloads a blank CSV template for the native purchase order bulk import. /// Columns match the native ExportPurchaseOrdersCsv output for round-trip compatibility. /// // GET: Tools/DownloadPurchaseOrderTemplate [HttpGet] public IActionResult DownloadPurchaseOrderTemplate() { var csvBytes = _csvImportService.GeneratePurchaseOrderTemplate(); return File(csvBytes, "text/csv", "purchase_order_import_template.csv"); } /// /// Bulk-imports purchase order headers from a native CSV file. Vendors are resolved by /// company name. Existing POs matched by PoNumber are updated; new ones are created. /// // POST: Tools/CsvImportPurchaseOrders [HttpPost] [ValidateAntiForgeryToken] public async Task CsvImportPurchaseOrders(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return Json(new { success = false, message = "Your account is not associated with a company." }); if (file == null || file.Length == 0) return Json(new { success = false, message = "No file provided or file is empty." }); if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) return Json(new { success = false, message = "Only CSV files are allowed." }); _logger.LogInformation("User {UserName} importing purchase orders from CSV {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); using var stream = file.OpenReadStream(); var result = await _csvImportService.ImportPurchaseOrdersAsync(stream, companyId.Value); await LogCsvImportAsync("PurchaseOrders", file.FileName, result); return Json(new { success = result.Success, message = result.Summary, successCount = result.SuccessCount, skippedCount = result.SkippedCount, errorCount = result.ErrorCount, totalRows = result.TotalRows, errors = result.Errors, warnings = result.Warnings }); } catch (Exception ex) { _logger.LogError(ex, "Error importing purchase orders from CSV"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Downloads a blank CSV template for the native appointment bulk import, with headers /// matching the columns expected by . /// // GET: Tools/DownloadAppointmentTemplate [HttpGet] public IActionResult DownloadAppointmentTemplate() { var csvBytes = _csvImportService.GenerateAppointmentTemplate(); return File(csvBytes, "text/csv", "appointment_import_template.csv"); } /// /// Bulk-imports customer appointments from a native CSV file. Appointment status and type /// are lookup-table entities resolved by the import service against the company's current /// lookup data. Returns JSON with per-row status detail. /// // POST: Tools/CsvImportAppointments [HttpPost] [ValidateAntiForgeryToken] public async Task CsvImportAppointments(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { return Json(new { success = false, message = "Your account is not associated with a company." }); } if (file == null || file.Length == 0) { return Json(new { success = false, message = "No file provided or file is empty." }); } if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) { return Json(new { success = false, message = "Only CSV files are allowed." }); } _logger.LogInformation("User {UserName} importing appointments from CSV {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); using var stream = file.OpenReadStream(); var result = await _csvImportService.ImportAppointmentsAsync(stream, companyId.Value); await LogCsvImportAsync("Appointments", file.FileName, result); return Json(new { success = result.Success, message = result.Summary, successCount = result.SuccessCount, skippedCount = result.SkippedCount, errorCount = result.ErrorCount, totalRows = result.TotalRows, errors = result.Errors, warnings = result.Warnings }); } catch (Exception ex) { _logger.LogError(ex, "Error importing appointments from CSV"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Downloads a blank CSV template for the native equipment bulk import, with headers /// matching the columns expected by . /// // GET: Tools/DownloadEquipmentTemplate [HttpGet] public IActionResult DownloadEquipmentTemplate() { var csvBytes = _csvImportService.GenerateEquipmentTemplate(); return File(csvBytes, "text/csv", "equipment_import_template.csv"); } /// /// Bulk-imports shop equipment records from a native CSV file. Equipment status is an enum /// (EquipmentStatus) rather than a lookup-table entity, so the import service parses /// the string value to the enum directly. Returns JSON with per-row status detail. /// // POST: Tools/CsvImportEquipment [HttpPost] [ValidateAntiForgeryToken] public async Task CsvImportEquipment(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { return Json(new { success = false, message = "Your account is not associated with a company." }); } if (file == null || file.Length == 0) { return Json(new { success = false, message = "No file provided or file is empty." }); } if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) { return Json(new { success = false, message = "Only CSV files are allowed." }); } _logger.LogInformation("User {UserName} importing equipment from CSV {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); using var stream = file.OpenReadStream(); var result = await _csvImportService.ImportEquipmentAsync(stream, companyId.Value); await LogCsvImportAsync("Equipment", file.FileName, result); return Json(new { success = result.Success, message = result.Summary, successCount = result.SuccessCount, skippedCount = result.SkippedCount, errorCount = result.ErrorCount, totalRows = result.TotalRows, errors = result.Errors, warnings = result.Warnings }); } catch (Exception ex) { _logger.LogError(ex, "Error importing equipment from CSV"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Downloads a blank CSV template for the native maintenance-record bulk import, with headers /// matching the columns expected by . /// // GET: Tools/DownloadMaintenanceTemplate [HttpGet] public IActionResult DownloadMaintenanceTemplate() { var csvBytes = _csvImportService.GenerateMaintenanceTemplate(); return File(csvBytes, "text/csv", "maintenance_import_template.csv"); } /// /// Bulk-imports equipment maintenance records from a native CSV file. Each record is linked /// to an equipment item resolved by name or number; unmatched equipment names are reported /// as row-level errors in the returned JSON. Returns per-row status detail for the UI accordion. /// // POST: Tools/CsvImportMaintenance [HttpPost] [ValidateAntiForgeryToken] public async Task CsvImportMaintenance(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { return Json(new { success = false, message = "Your account is not associated with a company." }); } if (file == null || file.Length == 0) { return Json(new { success = false, message = "No file provided or file is empty." }); } if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) { return Json(new { success = false, message = "Only CSV files are allowed." }); } _logger.LogInformation("User {UserName} importing maintenance records from CSV {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); using var stream = file.OpenReadStream(); var result = await _csvImportService.ImportMaintenanceAsync(stream, companyId.Value); await LogCsvImportAsync("MaintenanceRecords", file.FileName, result); return Json(new { success = result.Success, message = result.Summary, successCount = result.SuccessCount, skippedCount = result.SkippedCount, errorCount = result.ErrorCount, totalRows = result.TotalRows, errors = result.Errors, warnings = result.Warnings }); } catch (Exception ex) { _logger.LogError(ex, "Error importing maintenance records from CSV"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Downloads a blank CSV template for the native vendor bulk import, with headers /// matching the columns expected by . /// // GET: Tools/DownloadVendorTemplate [HttpGet] public IActionResult DownloadVendorTemplate() { var csvBytes = _csvImportService.GenerateVendorTemplate(); return File(csvBytes, "text/csv", "vendor_import_template.csv"); } /// /// Bulk-imports vendor records from a native CSV file, creating or updating vendors for the /// current company. Returns JSON with per-row status detail for the UI accordion. /// // POST: Tools/CsvImportVendors [HttpPost] [ValidateAntiForgeryToken] public async Task CsvImportVendors(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return Json(new { success = false, message = "Your account is not associated with a company." }); if (file == null || file.Length == 0) return Json(new { success = false, message = "No file provided or file is empty." }); if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) return Json(new { success = false, message = "Only CSV files are allowed." }); _logger.LogInformation("User {UserName} importing vendors from CSV {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); using var stream = file.OpenReadStream(); var result = await _csvImportService.ImportVendorsAsync(stream, companyId.Value); await LogCsvImportAsync("Vendors", file.FileName, result); return Json(new { success = result.Success, message = result.Summary, successCount = result.SuccessCount, skippedCount = result.SkippedCount, errorCount = result.ErrorCount, totalRows = result.TotalRows, errors = result.Errors, warnings = result.Warnings }); } catch (Exception ex) { _logger.LogError(ex, "Error importing vendors from CSV"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Downloads a blank CSV template for the native prep-service bulk import, with headers /// matching the columns expected by . /// // GET: Tools/DownloadPrepServiceTemplate [HttpGet] public IActionResult DownloadPrepServiceTemplate() { var csvBytes = _csvImportService.GeneratePrepServiceTemplate(); return File(csvBytes, "text/csv", "prep_service_import_template.csv"); } /// /// Bulk-imports prep-service definitions (sandblasting, masking, etc.) from a native CSV file. /// Prep services are used as line items on quotes and jobs; importing them saves manual entry /// when onboarding a company with an established service list. Returns JSON with per-row /// status detail. /// // POST: Tools/CsvImportPrepServices [HttpPost] [ValidateAntiForgeryToken] public async Task CsvImportPrepServices(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return Json(new { success = false, message = "Your account is not associated with a company." }); if (file == null || file.Length == 0) return Json(new { success = false, message = "No file provided or file is empty." }); if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) return Json(new { success = false, message = "Only CSV files are allowed." }); _logger.LogInformation("User {UserName} importing prep services from CSV {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); using var stream = file.OpenReadStream(); var result = await _csvImportService.ImportPrepServicesAsync(stream, companyId.Value); await LogCsvImportAsync("PrepServices", file.FileName, result); return Json(new { success = result.Success, message = result.Summary, successCount = result.SuccessCount, skippedCount = result.SkippedCount, errorCount = result.ErrorCount, totalRows = result.TotalRows, errors = result.Errors, warnings = result.Warnings }); } catch (Exception ex) { _logger.LogError(ex, "Error importing prep services from CSV"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } /// /// Downloads a structured CSV template for company settings import. The template uses a /// multi-section format: sections marked with square brackets (e.g., "[Company Info]") switch /// the parser between key-value mode (single settings) and table mode (lookup tables such as /// Pricing Tiers, Job Statuses). This dual-mode format is why the file is generated inline /// rather than served as a static asset — the structure documents the exact expected schema. /// // GET: Tools/DownloadCompanySettingsTemplate [HttpGet] public IActionResult DownloadCompanySettingsTemplate() { try { var csvContent = GenerateCompanySettingsTemplate(); var fileName = $"company_settings_template_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; return File(System.Text.Encoding.UTF8.GetBytes(csvContent), "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error generating company settings template"); TempData["ErrorMessage"] = "An error occurred while generating the template."; return RedirectToAction(nameof(Index)); } } /// /// Imports company settings from a multi-section CSV file (see /// for format). Parsing and upsert logic are handled by ; /// this action is responsible only for file validation, stream setup, and converting the result /// to a JSON response. Returns a list of non-fatal warnings alongside the success flag so users /// can see which settings were skipped or defaulted without treating the entire import as failed. /// // POST: Tools/CsvImportCompanySettings [HttpPost] [ValidateAntiForgeryToken] public async Task CsvImportCompanySettings(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { return Json(new { success = false, message = "Your account is not associated with a company." }); } if (file == null || file.Length == 0) { return Json(new { success = false, message = "No file provided or file is empty." }); } if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) { return Json(new { success = false, message = "Only CSV files are allowed." }); } _logger.LogInformation("User {UserName} importing company settings from CSV {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); using var stream = file.OpenReadStream(); using var reader = new System.IO.StreamReader(stream); var result = await ImportCompanySettingsFromCsv(reader, companyId.Value); await _auditService.LogAsync("Imported", "CompanySettings", file.FileName, new { file.FileName, result.Success, Errors = result.Errors.Count }); return Json(new { success = result.Success, message = result.Message, errors = result.Errors }); } catch (Exception ex) { _logger.LogError(ex, "Error importing company settings from CSV"); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } #endregion #region CSV Export /// /// Exports all exportable entity types (customers, quotes, jobs, appointments, catalog items, /// inventory, equipment, maintenance records) for the current company as a single ZIP archive. /// Each entity type becomes a timestamped CSV file inside the archive, making it easy to /// restore data to a fresh tenant or hand off data to an external system. The ZIP is built /// entirely in memory using so no temporary /// files are written to disk. /// // GET: Tools/ExportAllCsv [HttpGet] public async Task ExportAllCsv() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["Error"] = "Unable to determine company context."; return RedirectToAction(nameof(Index)); } var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); using var memoryStream = new System.IO.MemoryStream(); using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true)) { // 1. Customers var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId.Value); var customersCsv = GenerateCustomersCsv(customers); var customersEntry = archive.CreateEntry($"customers_{timestamp}.csv"); using (var entryStream = customersEntry.Open()) using (var writer = new System.IO.StreamWriter(entryStream)) { await writer.WriteAsync(customersCsv); } // 2. Quotes var quotes = await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId.Value, false, q => q.Customer, q => q.QuoteStatus); var quotesCsv = GenerateQuotesCsv(quotes); var quotesEntry = archive.CreateEntry($"quotes_{timestamp}.csv"); using (var entryStream = quotesEntry.Open()) using (var writer = new System.IO.StreamWriter(entryStream)) { await writer.WriteAsync(quotesCsv); } // 3. Jobs var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId.Value, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority); var jobsCsv = GenerateJobsCsv(jobs); var jobsEntry = archive.CreateEntry($"jobs_{timestamp}.csv"); using (var entryStream = jobsEntry.Open()) using (var writer = new System.IO.StreamWriter(entryStream)) { await writer.WriteAsync(jobsCsv); } // 4. Appointments var appointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId.Value, false, a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus); var appointmentsCsv = GenerateAppointmentsCsv(appointments); var appointmentsEntry = archive.CreateEntry($"appointments_{timestamp}.csv"); using (var entryStream = appointmentsEntry.Open()) using (var writer = new System.IO.StreamWriter(entryStream)) { await writer.WriteAsync(appointmentsCsv); } // 5. Catalog var catalogCategories = await _unitOfWork.CatalogCategories.FindAsync(cc => cc.CompanyId == companyId.Value); var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories); var catalog = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId.Value); var catalogCsv = GenerateCatalogCsv(catalog, catalogCategoryPaths); var catalogEntry = archive.CreateEntry($"catalog_{timestamp}.csv"); using (var entryStream = catalogEntry.Open()) using (var writer = new System.IO.StreamWriter(entryStream)) { await writer.WriteAsync(catalogCsv); } // 6. Inventory var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId.Value); var inventoryCsv = GenerateInventoryCsv(inventory); var inventoryEntry = archive.CreateEntry($"inventory_{timestamp}.csv"); using (var entryStream = inventoryEntry.Open()) using (var writer = new System.IO.StreamWriter(entryStream)) { await writer.WriteAsync(inventoryCsv); } // 7. Equipment var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId.Value); var equipmentCsv = GenerateEquipmentCsv(equipment); var equipmentEntry = archive.CreateEntry($"equipment_{timestamp}.csv"); using (var entryStream = equipmentEntry.Open()) using (var writer = new System.IO.StreamWriter(entryStream)) { await writer.WriteAsync(equipmentCsv); } // 8. Maintenance var maintenance = await _unitOfWork.MaintenanceRecords.FindAsync(m => m.CompanyId == companyId.Value, false, m => m.Equipment); var maintenanceCsv = GenerateMaintenanceCsv(maintenance); var maintenanceEntry = archive.CreateEntry($"maintenance_{timestamp}.csv"); using (var entryStream = maintenanceEntry.Open()) using (var writer = new System.IO.StreamWriter(entryStream)) { await writer.WriteAsync(maintenanceCsv); } // 9. Vendors var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId.Value); var vendorsCsv = GenerateVendorsCsv(vendors); var vendorsEntry = archive.CreateEntry($"vendors_{timestamp}.csv"); using (var entryStream = vendorsEntry.Open()) using (var writer = new System.IO.StreamWriter(entryStream)) { await writer.WriteAsync(vendorsCsv); } // 10. Prep Services var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.CompanyId == companyId.Value); var prepServicesCsv = GeneratePrepServicesCsv(prepServices); var prepServicesEntry = archive.CreateEntry($"prep_services_{timestamp}.csv"); using (var entryStream = prepServicesEntry.Open()) using (var writer = new System.IO.StreamWriter(entryStream)) { await writer.WriteAsync(prepServicesCsv); } // 11. Invoices var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job); var invoicesCsv = GenerateInvoicesCsv(invoices); var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv"); using (var entryStream = invoicesEntry.Open()) using (var writer = new System.IO.StreamWriter(entryStream)) { await writer.WriteAsync(invoicesCsv); } // 12. Chart of Accounts var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value); var accountsCsv = GenerateChartOfAccountsCsv(accounts); var accountsEntry = archive.CreateEntry($"chart_of_accounts_{timestamp}.csv"); using (var entryStream = accountsEntry.Open()) using (var writer = new System.IO.StreamWriter(entryStream)) { await writer.WriteAsync(accountsCsv); } // 13. Expenses var expenses = await _unitOfWork.Expenses.FindAsync(e => e.CompanyId == companyId.Value, false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job); var expensesCsv = GenerateExpensesCsv(expenses); var expensesEntry = archive.CreateEntry($"expenses_{timestamp}.csv"); using (var entryStream = expensesEntry.Open()) using (var writer = new System.IO.StreamWriter(entryStream)) { await writer.WriteAsync(expensesCsv); } // 14. Payments var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice); var paymentsCsv = GeneratePaymentsCsv(payments); var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv"); using (var entryStream = paymentsEntry.Open()) using (var writer = new System.IO.StreamWriter(entryStream)) { await writer.WriteAsync(paymentsCsv); } // 15. Purchase Orders var purchaseOrders = await _unitOfWork.PurchaseOrders.GetAllAsync(false, po => po.Vendor); var purchaseOrdersCsv = GeneratePurchaseOrdersCsv(purchaseOrders); var purchaseOrdersEntry = archive.CreateEntry($"purchase_orders_{timestamp}.csv"); using (var entryStream = purchaseOrdersEntry.Open()) using (var writer = new System.IO.StreamWriter(entryStream)) { await writer.WriteAsync(purchaseOrdersCsv); } } memoryStream.Position = 0; var zipFileName = $"all_data_export_{timestamp}.zip"; await LogExportAsync("AllData", "Full CSV ZIP export"); return File(memoryStream.ToArray(), "application/zip", zipFileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting all CSV data"); TempData["Error"] = "An error occurred while exporting data."; return RedirectToAction(nameof(Index)); } } /// /// Exports all customers for the current company as a standalone CSV file using the native /// (non-QuickBooks) column layout defined in . Use /// for QuickBooks IIF/CSV export. /// // GET: Tools/ExportCustomersCsv [HttpGet] public async Task ExportCustomersCsv() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } var customers = await _unitOfWork.Customers.GetAllAsync(false, c => c.PricingTier); var csv = GenerateCustomersCsv(customers); var fileName = $"customers_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; await LogExportAsync("Customers", $"CSV export ({customers.Count()} records)"); return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting customers to CSV"); TempData["ErrorMessage"] = "An error occurred while exporting customers."; return RedirectToAction(nameof(Index)); } } /// /// Exports all quotes for the current company as a CSV file. Eagerly loads Customer and /// QuoteStatus navigation properties so can output human-readable /// names without additional round-trips. Prospect quotes (no linked customer) are handled by /// falling back to ProspectCompanyName or ProspectContactName. /// // GET: Tools/ExportQuotesCsv [HttpGet] public async Task ExportQuotesCsv() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } var quotes = await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus); var csv = GenerateQuotesCsv(quotes); var fileName = $"quotes_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; await LogExportAsync("Quotes", $"CSV export ({quotes.Count()} records)"); return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting quotes to CSV"); TempData["ErrorMessage"] = "An error occurred while exporting quotes."; return RedirectToAction(nameof(Index)); } } /// /// Exports all jobs for the current company as a CSV file. Eagerly loads Customer, /// JobStatus, and JobPriority navigation properties because job status and priority are /// lookup-table entities (not enums) and must be resolved to display names for the export. /// // GET: Tools/ExportJobsCsv [HttpGet] public async Task ExportJobsCsv() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } var jobs = await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority); var csv = GenerateJobsCsv(jobs); var fileName = $"jobs_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; await LogExportAsync("Jobs", $"CSV export ({jobs.Count()} records)"); return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting jobs to CSV"); TempData["ErrorMessage"] = "An error occurred while exporting jobs."; return RedirectToAction(nameof(Index)); } } /// /// Exports all appointments for the current company as a CSV file, including appointment type /// and status display names resolved from their respective lookup-table navigation properties. /// // GET: Tools/ExportAppointmentsCsv [HttpGet] public async Task ExportAppointmentsCsv() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } var appointments = await _unitOfWork.Appointments.GetAllAsync(false, a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus); var csv = GenerateAppointmentsCsv(appointments); var fileName = $"appointments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; await LogExportAsync("Appointments", $"CSV export ({appointments.Count()} records)"); return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting appointments to CSV"); TempData["ErrorMessage"] = "An error occurred while exporting appointments."; return RedirectToAction(nameof(Index)); } } /// /// Exports all catalog items for the current company as a CSV file. Catalog categories form a /// hierarchy (parent/child), so is called first to resolve /// each category's full slash-separated path (e.g., "Metal/Automotive/Wheels") before building /// the CSV rows, making the export reimportable without needing separate category IDs. /// // GET: Tools/ExportCatalogCsv [HttpGet] public async Task ExportCatalogCsv() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } var catalogCategories = await _unitOfWork.CatalogCategories.FindAsync(cc => cc.CompanyId == companyId.Value); var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories); var catalogItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId.Value); var csv = GenerateCatalogCsv(catalogItems, catalogCategoryPaths); var fileName = $"catalog_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; await LogExportAsync("CatalogItems", $"CSV export ({catalogItems.Count()} records)"); return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting catalog to CSV"); TempData["ErrorMessage"] = "An error occurred while exporting catalog."; return RedirectToAction(nameof(Index)); } } /// /// Exports all inventory items for the current company as a CSV file including current /// on-hand quantities, unit costs, and reorder points, enabling offline stock analysis or /// re-import to a fresh environment. /// // GET: Tools/ExportInventoryCsv [HttpGet] public async Task ExportInventoryCsv() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } var inventoryItems = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.PrimaryVendor); var csv = GenerateInventoryCsv(inventoryItems); var fileName = $"inventory_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; await LogExportAsync("InventoryItems", $"CSV export ({inventoryItems.Count()} records)"); return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting inventory to CSV"); TempData["ErrorMessage"] = "An error occurred while exporting inventory."; return RedirectToAction(nameof(Index)); } } /// /// Exports all equipment records for the current company as a CSV file. Equipment status is /// written as its enum name (e.g., "Operational") so the CSV is human-readable and can be /// re-imported by the native CSV import which parses it back to the enum. /// // GET: Tools/ExportEquipmentCsv [HttpGet] public async Task ExportEquipmentCsv() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId.Value); var csv = GenerateEquipmentCsv(equipment); var fileName = $"equipment_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; await LogExportAsync("Equipment", $"CSV export ({equipment.Count()} records)"); return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting equipment to CSV"); TempData["ErrorMessage"] = "An error occurred while exporting equipment."; return RedirectToAction(nameof(Index)); } } /// /// Exports all maintenance records for the current company as a CSV file. Note that the /// repository call does not eagerly load the Equipment navigation property, so /// relies on EF lazy loading (or an already-tracked entity) /// to resolve equipment names. If lazy loading is disabled this column will be empty. /// // GET: Tools/ExportMaintenanceCsv [HttpGet] public async Task ExportMaintenanceCsv() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } var maintenance = await _unitOfWork.MaintenanceRecords.GetAllAsync(false, m => m.Equipment); var csv = GenerateMaintenanceCsv(maintenance); var fileName = $"maintenance_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; await LogExportAsync("MaintenanceRecords", $"CSV export ({maintenance.Count()} records)"); return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting maintenance to CSV"); TempData["ErrorMessage"] = "An error occurred while exporting maintenance records."; return RedirectToAction(nameof(Index)); } } /// /// Exports the full company configuration — operating costs, preferences, pricing tiers, and /// all status/priority/category lookup tables — as a structured CSV file that can be re-imported /// via . Lookup tables (job statuses, job priorities, etc.) /// are loaded separately from the company entity because they are not navigation properties on /// Company; the multi-tenancy global query filter scopes them to the current company /// automatically. /// // GET: Tools/ExportCompanySettingsCsv [HttpGet] public async Task ExportCompanySettingsCsv() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.OperatingCosts, c => c.Preferences, c => c.PricingTiers); if (company == null) { TempData["ErrorMessage"] = "Company not found."; return RedirectToAction(nameof(Index)); } // Load all lookup tables — scoped to this company var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId.Value); var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId.Value); var quoteStatuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId.Value); var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId.Value); var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.FindAsync(s => s.CompanyId == companyId.Value); var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId.Value); var csv = GenerateCompanySettingsCsv(company, jobStatuses, jobPriorities, quoteStatuses, inventoryCategories, appointmentStatuses, appointmentTypes); var fileName = $"company_settings_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; await LogExportAsync("CompanySettings", "CSV export"); return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting company settings to CSV"); TempData["ErrorMessage"] = "An error occurred while exporting company settings."; return RedirectToAction(nameof(Index)); } } /// /// Exports all invoices for the current company as a CSV file, including customer name and /// linked job number resolved via eager loading. Invoice status is written as its enum name /// (e.g., "PartiallyPaid") so the data can be consumed by external accounting or reporting tools. /// // GET: Tools/ExportInvoicesCsv [HttpGet] public async Task ExportInvoicesCsv() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Job); var csv = GenerateInvoicesCsv(invoices); var fileName = $"invoices_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; await LogExportAsync("Invoices", $"CSV export ({invoices.Count()} records)"); return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting invoices to CSV"); TempData["ErrorMessage"] = "An error occurred while exporting invoices."; return RedirectToAction(nameof(Index)); } } /// /// Exports all invoice payment records for the current company as a CSV file, including the /// parent invoice number resolved via eager loading. Payment method is written as its enum name. /// This export covers only customer payments (AR); vendor bill payments are not included here. /// // GET: Tools/ExportPaymentsCsv [HttpGet] public async Task ExportPaymentsCsv() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } var payments = await _unitOfWork.Payments.GetAllAsync(false, p => p.Invoice); var csv = GeneratePaymentsCsv(payments); var fileName = $"payments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; await LogExportAsync("Payments", $"CSV export ({payments.Count()} records)"); return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting payments to CSV"); TempData["ErrorMessage"] = "An error occurred while exporting payments."; return RedirectToAction(nameof(Index)); } } /// /// Exports all purchase orders for the current company as a CSV file, including the vendor /// company name resolved via eager loading. PO status is written as its enum name. /// // GET: Tools/ExportPurchaseOrdersCsv [HttpGet] public async Task ExportPurchaseOrdersCsv() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } var purchaseOrders = await _unitOfWork.PurchaseOrders.GetAllAsync(false, po => po.Vendor); var csv = GeneratePurchaseOrdersCsv(purchaseOrders); var fileName = $"purchase_orders_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; await LogExportAsync("PurchaseOrders", $"CSV export ({purchaseOrders.Count()} records)"); return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting purchase orders to CSV"); TempData["ErrorMessage"] = "An error occurred while exporting purchase orders."; return RedirectToAction(nameof(Index)); } } #endregion #region CSV Generation Helpers /// /// Builds a CSV string for the given customer collection using the native column layout that /// matches . Each field is passed through /// to handle commas, quotes, and newlines embedded in data values. /// private string GenerateCustomersCsv(IEnumerable customers) { var sb = new System.Text.StringBuilder(); // Column names match CustomerImportDto [Name] attributes exactly for round-trip compatibility. sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes"); foreach (var customer in customers) { var customerType = customer.IsCommercial ? "Commercial" : "NonCommercial"; sb.AppendLine($"{EscapeCsv(customer.CompanyName)},{EscapeCsv(customer.ContactFirstName)},{EscapeCsv(customer.ContactLastName)},{EscapeCsv(customer.Email)},{EscapeCsv(customer.Phone)},{EscapeCsv(customer.MobilePhone)},{EscapeCsv(customer.Address)},{EscapeCsv(customer.City)},{EscapeCsv(customer.State)},{EscapeCsv(customer.ZipCode)},{EscapeCsv(customer.Country)},{customerType},{EscapeCsv(customer.PricingTier?.TierName)},{customer.CreditLimit},{EscapeCsv(customer.PaymentTerms)},{customer.IsTaxExempt.ToString().ToLower()},{EscapeCsv(customer.TaxId)},{customer.IsActive.ToString().ToLower()},{EscapeCsv(customer.GeneralNotes)}"); } return sb.ToString(); } /// /// Builds a CSV string for the given quote collection. Customer name resolution falls back to /// ProspectCompanyName then ProspectContactName then "Unknown" to handle quotes that were /// created for prospects and never converted to a linked customer record. /// QuoteStatus.StatusCode is exported (not DisplayName) so the importer can resolve it /// by the same code the import dict is keyed on. /// private string GenerateQuotesCsv(IEnumerable quotes) { var sb = new System.Text.StringBuilder(); // Column names match QuoteImportDto [Name] attributes for round-trip compatibility. sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,Subtotal,TaxAmount,Total,Notes,TermsAndConditions"); foreach (var quote in quotes) { var customerName = !string.IsNullOrWhiteSpace(quote.Customer?.CompanyName) ? quote.Customer.CompanyName : $"{quote.Customer?.ContactFirstName} {quote.Customer?.ContactLastName}".Trim(); sb.AppendLine($"{EscapeCsv(quote.QuoteNumber)},{EscapeCsv(quote.Customer?.Email)},{EscapeCsv(customerName)},{EscapeCsv(quote.ProspectCompanyName)},{EscapeCsv(quote.ProspectContactName)},{EscapeCsv(quote.ProspectEmail)},{EscapeCsv(quote.ProspectPhone)},{EscapeCsv(quote.QuoteStatus?.StatusCode)},{quote.QuoteDate:yyyy-MM-dd},{quote.ExpirationDate?.ToString("yyyy-MM-dd")},{quote.SubTotal},{quote.TaxAmount},{quote.Total},{EscapeCsv(quote.Notes)},{EscapeCsv(quote.Terms)}"); } return sb.ToString(); } /// /// Builds a CSV string for the given job collection. JobStatus and JobPriority are lookup-table /// navigation properties (not enums) — their StatusCode/PriorityCode values are /// exported so the importer can resolve them by code (which is what the import dict is keyed on). /// Callers must have eagerly loaded both navigation properties before calling this helper. /// private string GenerateJobsCsv(IEnumerable jobs) { var sb = new System.Text.StringBuilder(); // Import-compatible columns first (match JobImportDto [Name] attributes), then read-only extras. sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,FinalPrice,CustomerPO,SpecialInstructions,Notes,CompletedDate,QuotedPrice,ActualTimeHours"); foreach (var job in jobs) { var customerName = !string.IsNullOrWhiteSpace(job.Customer?.CompanyName) ? job.Customer.CompanyName : $"{job.Customer?.ContactFirstName} {job.Customer?.ContactLastName}".Trim(); sb.AppendLine($"{EscapeCsv(job.JobNumber)},{EscapeCsv(job.Customer?.Email)},{EscapeCsv(customerName)},{EscapeCsv(job.JobStatus?.StatusCode)},{EscapeCsv(job.JobPriority?.PriorityCode)},{job.ScheduledDate?.ToString("yyyy-MM-dd")},{job.DueDate?.ToString("yyyy-MM-dd")},{job.FinalPrice},{EscapeCsv(job.CustomerPO)},{EscapeCsv(job.SpecialInstructions)},{EscapeCsv(job.InternalNotes)},{job.CompletedDate?.ToString("yyyy-MM-dd")},{job.QuotedPrice},{job.ActualTimeSpentHours}"); } return sb.ToString(); } /// /// Builds a CSV string for the given appointment collection. Appointment type and status are /// lookup-table navigation properties whose DisplayName is used in the export. /// Scheduled times are exported in ISO-8601 combined date-time format (yyyy-MM-dd HH:mm) to /// avoid ambiguity in locale-specific date parsing during re-import. /// private string GenerateAppointmentsCsv(IEnumerable appointments) { var sb = new System.Text.StringBuilder(); // Column names match AppointmentImportDto [Name] attributes for round-trip compatibility. sb.AppendLine("AppointmentNumber,CustomerEmail,AppointmentType,Status,ScheduledStart,ScheduledEnd,Title,Description,Location,Notes"); foreach (var appointment in appointments) { sb.AppendLine($"{EscapeCsv(appointment.AppointmentNumber)},{EscapeCsv(appointment.Customer?.Email)},{EscapeCsv(appointment.AppointmentType?.TypeCode)},{EscapeCsv(appointment.AppointmentStatus?.StatusCode)},{appointment.ScheduledStartTime:yyyy-MM-dd HH:mm},{appointment.ScheduledEndTime:yyyy-MM-dd HH:mm},{EscapeCsv(appointment.Title)},{EscapeCsv(appointment.Description)},{EscapeCsv(appointment.Location)},{EscapeCsv(appointment.Notes)}"); } return sb.ToString(); } /// /// Builds a CSV string for the given catalog items, using the pre-computed /// dictionary to write the full hierarchical category path /// (e.g., "Metal/Automotive") rather than an opaque category ID. This makes the export /// directly re-importable without the user needing to re-create the category hierarchy first. /// private string GenerateCatalogCsv(IEnumerable catalogItems, Dictionary categoryPaths) { var sb = new System.Text.StringBuilder(); sb.AppendLine("CategoryPath,ItemName,SKU,Description,BasePrice,ApproximateArea,EstimatedMinutes,RequiresSandblasting,RequiresMasking,IsActive"); foreach (var item in catalogItems) { var categoryPath = categoryPaths.TryGetValue(item.CategoryId, out var path) ? path : string.Empty; sb.AppendLine($"{EscapeCsv(categoryPath)},{EscapeCsv(item.Name)},{EscapeCsv(item.SKU)}," + $"{EscapeCsv(item.Description)},{item.DefaultPrice},{item.ApproximateArea}," + $"{item.DefaultEstimatedMinutes},{item.DefaultRequiresSandblasting}," + $"{item.DefaultRequiresMasking},{item.IsActive}"); } return sb.ToString(); } /// /// Converts the flat list of catalog categories into a dictionary mapping each category ID to /// its full slash-separated ancestor path (e.g., "Metal/Automotive/Wheels"). The algorithm /// walks up the ParentCategoryId chain for each category, prepending ancestor names, /// until it reaches a root category (null parent). This is O(n*d) where d is the tree depth, /// which is acceptable for the small category trees expected in this domain. The result is /// passed into to avoid recomputing paths per row. /// private static Dictionary BuildCategoryPathMap(IEnumerable categories) { var all = categories.ToDictionary(c => c.Id); var result = new Dictionary(); foreach (var category in all.Values) { var parts = new System.Collections.Generic.List(); var current = category; while (current != null) { parts.Insert(0, current.Name); current = current.ParentCategoryId.HasValue && all.TryGetValue(current.ParentCategoryId.Value, out var parent) ? parent : null; } result[category.Id] = string.Join("/", parts); } return result; } /// /// Builds a CSV string for the given inventory items using the native column layout that /// matches the inventory import template. Numeric fields (UnitCost, QuantityOnHand, ReorderPoint) /// are written without currency symbols so they can be parsed as decimals on re-import. /// private string GenerateInventoryCsv(IEnumerable inventoryItems) { var sb = new System.Text.StringBuilder(); // Column names match InventoryItemImportDto [Name] attributes for round-trip compatibility. sb.AppendLine("SKU,ItemName,Description,CategoryName,Manufacturer,ManufacturerPartNumber,ColorName,ColorCode,Finish,VendorName,VendorPartNumber,QuantityInStock,UnitOfMeasure,UnitCost,LastPurchasePrice,ReorderPoint,ReorderQuantity,MinimumStock,MaximumStock,CoverageSqFtPerLb,TransferEfficiencyPct,Location,IsActive,Notes"); foreach (var item in inventoryItems) { sb.AppendLine($"{EscapeCsv(item.SKU)},{EscapeCsv(item.Name)},{EscapeCsv(item.Description)},{EscapeCsv(item.Category)},{EscapeCsv(item.Manufacturer)},{EscapeCsv(item.ManufacturerPartNumber)},{EscapeCsv(item.ColorName)},{EscapeCsv(item.ColorCode)},{EscapeCsv(item.Finish)},{EscapeCsv(item.PrimaryVendor?.CompanyName)},{EscapeCsv(item.VendorPartNumber)},{item.QuantityOnHand},{EscapeCsv(item.UnitOfMeasure)},{item.UnitCost},{item.LastPurchasePrice},{item.ReorderPoint},{item.ReorderQuantity},{item.MinimumStock},{item.MaximumStock},{item.CoverageSqFtPerLb},{item.TransferEfficiency},{EscapeCsv(item.Location)},{item.IsActive.ToString().ToLower()},{EscapeCsv(item.Notes)}"); } return sb.ToString(); } /// /// Builds a CSV string for the given equipment records. Status is written as its enum /// name (e.g., "Operational", "UnderMaintenance") without special handling because the import /// service parses it back using Enum.Parse case-insensitively. /// private string GenerateEquipmentCsv(IEnumerable equipment) { var sb = new System.Text.StringBuilder(); // Column names match EquipmentImportDto [Name] attributes for round-trip compatibility. sb.AppendLine("EquipmentName,EquipmentNumber,EquipmentType,Manufacturer,Model,SerialNumber,PurchaseDate,PurchasePrice,WarrantyExpiration,Location,RecommendedMaintenanceIntervalDays,Status,IsActive,Notes"); foreach (var item in equipment) { sb.AppendLine($"{EscapeCsv(item.EquipmentName)},{EscapeCsv(item.EquipmentNumber)},{EscapeCsv(item.EquipmentType)},{EscapeCsv(item.Manufacturer)},{EscapeCsv(item.Model)},{EscapeCsv(item.SerialNumber)},{item.PurchaseDate?.ToString("yyyy-MM-dd")},{item.PurchasePrice},{item.WarrantyExpiration?.ToString("yyyy-MM-dd")},{EscapeCsv(item.Location)},{item.RecommendedMaintenanceIntervalDays},{item.Status},{item.IsActive.ToString().ToLower()},{EscapeCsv(item.Notes)}"); } return sb.ToString(); } /// /// Builds a CSV string for the given maintenance records. The Equipment navigation /// property is accessed directly on each record; callers must ensure it is loaded (either via /// eager loading or lazy loading) before invoking this helper, or the Equipment column will /// be null/empty in the output. /// private string GenerateMaintenanceCsv(IEnumerable maintenance) { var sb = new System.Text.StringBuilder(); sb.AppendLine("EquipmentName,MaintenanceType,ScheduledDate,CompletedDate,Status,Priority,LaborCost,PartsCost,TotalCost,Description"); foreach (var record in maintenance) { sb.AppendLine($"{EscapeCsv(record.Equipment?.EquipmentName)},{record.MaintenanceType}," + $"{record.ScheduledDate:yyyy-MM-dd},{record.CompletedDate?.ToString("yyyy-MM-dd")}," + $"{record.Status},{record.Priority},{record.LaborCost},{record.PartsCost},{record.TotalCost},{EscapeCsv(record.Description)}"); } return sb.ToString(); } /// /// Builds a re-importable CSV for all prep services. Columns match PrepServiceImportDto /// so the file can be fed directly back into . /// private string GeneratePrepServicesCsv(IEnumerable prepServices) { var sb = new System.Text.StringBuilder(); sb.AppendLine("ServiceName,Description,DisplayOrder,IsActive"); foreach (var ps in prepServices.OrderBy(p => p.DisplayOrder)) { sb.AppendLine($"{EscapeCsv(ps.ServiceName)},{EscapeCsv(ps.Description)},{ps.DisplayOrder},{ps.IsActive.ToString().ToLower()}"); } return sb.ToString(); } /// /// Generates a human-readable, instructional CSV template that demonstrates the multi-section /// format expected by . Comment lines (prefixed with /// "#") explain the rules; section headers in square brackets switch the parser between /// key-value mode and table mode. Sample data rows are included so users understand the /// expected value format for each field without consulting separate documentation. /// private string GenerateCompanySettingsTemplate() { var sb = new System.Text.StringBuilder(); // Header sb.AppendLine("# Company Settings Import Template"); sb.AppendLine("# Instructions:"); sb.AppendLine("# - For single-value sections (Company Info, Operating Costs, Preferences), use Key,Value format"); sb.AppendLine("# - For table sections (Pricing Tiers, Lookups), use CSV table format with headers"); sb.AppendLine("# - Leave single-value fields blank to keep existing values unchanged"); sb.AppendLine("# - Table sections REPLACE all existing records - include all records you want to keep!"); sb.AppendLine("# - All monetary values: numbers only, no currency symbols"); sb.AppendLine("# - All percentages: decimal numbers (e.g., 15 for 15%)"); sb.AppendLine("# - Boolean values: true or false (lowercase)"); sb.AppendLine(); // Company Info sb.AppendLine("[Company Info]"); sb.AppendLine("CompanyName,"); sb.AppendLine("CompanyCode,"); sb.AppendLine("PrimaryContactName,"); sb.AppendLine("PrimaryContactEmail,"); sb.AppendLine("Phone,"); sb.AppendLine("Address,"); sb.AppendLine("City,"); sb.AppendLine("State,"); sb.AppendLine("ZipCode,"); sb.AppendLine("TimeZone,America/New_York"); sb.AppendLine(); // Operating Costs sb.AppendLine("[Operating Costs]"); sb.AppendLine("StandardLaborRate,65.00"); sb.AppendLine("AdditionalCoatLaborPercent,30"); sb.AppendLine("OvenOperatingCostPerHour,25.00"); sb.AppendLine("SandblasterCostPerHour,35.00"); sb.AppendLine("CoatingBoothCostPerHour,30.00"); sb.AppendLine("PowderCoatingCostPerSqFt,0.50"); sb.AppendLine("GeneralMarkupPercentage,35"); sb.AppendLine("TaxPercent,8.5"); sb.AppendLine("ShopSuppliesRate,5"); sb.AppendLine("RushChargeType,Percentage"); sb.AppendLine("RushChargePercentage,25"); sb.AppendLine("RushChargeFixedAmount,0"); sb.AppendLine("ShopMinimumCharge,50.00"); sb.AppendLine(); // Preferences sb.AppendLine("[Preferences]"); sb.AppendLine("DefaultCurrency,USD"); sb.AppendLine("DefaultDateFormat,MM/dd/yyyy"); sb.AppendLine("DefaultTimeFormat,12h"); sb.AppendLine("DefaultPaymentTerms,Net 30"); sb.AppendLine("DefaultQuoteValidityDays,30"); sb.AppendLine("QuoteNumberPrefix,QT"); sb.AppendLine("JobNumberPrefix,JOB"); sb.AppendLine("UseMetricSystem,false"); sb.AppendLine("DefaultJobPriority,Normal"); sb.AppendLine("RequireCustomerPO,false"); sb.AppendLine("AllowCustomerApproval,true"); sb.AppendLine("DefaultTurnaroundDays,7"); sb.AppendLine("EmailNotificationsEnabled,true"); sb.AppendLine("NotifyOnNewJob,true"); sb.AppendLine("NotifyOnJobStatusChange,true"); sb.AppendLine("NotifyOnQuoteApproval,true"); sb.AppendLine("NotifyOnPaymentReceived,true"); sb.AppendLine("QuoteExpiryWarningDays,3"); sb.AppendLine("DueDateWarningDays,2"); sb.AppendLine("MaintenanceAlertDays,7"); sb.AppendLine("QuoteRetentionYears,7"); sb.AppendLine("JobRetentionYears,7"); sb.AppendLine("LogRetentionDays,90"); sb.AppendLine("AutoArchiveJobsDays,365"); sb.AppendLine("DeletedRecordRetentionDays,30"); sb.AppendLine(); // Pricing Tiers sb.AppendLine("[Pricing Tiers]"); sb.AppendLine("TierName,Description,DiscountPercent,IsActive"); sb.AppendLine("Standard,Standard pricing tier,0,true"); sb.AppendLine("Silver,5% discount for regular customers,5,true"); sb.AppendLine("Gold,10% discount for preferred customers,10,true"); sb.AppendLine("Platinum,15% discount for VIP customers,15,true"); sb.AppendLine(); // Job Status Lookups sb.AppendLine("[Job Statuses]"); sb.AppendLine("StatusCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive,IsSystemDefined,IsTerminalStatus,IsWorkInProgressStatus,WorkflowCategory,Description"); sb.AppendLine("PENDING,Pending,1,secondary,bi-clock,true,true,false,false,Pre-Production,Job created but not yet started"); sb.AppendLine("INPREPARATION,In Preparation,2,info,bi-wrench,true,false,false,true,Pre-Production,Preparing parts for coating"); sb.AppendLine("COATING,Coating,3,primary,bi-paint-bucket,true,false,false,true,Production,Applying powder coating"); sb.AppendLine("COMPLETED,Completed,4,success,bi-check-circle,true,true,true,false,Post-Production,Job finished"); sb.AppendLine(); // Job Priority Lookups sb.AppendLine("[Job Priorities]"); sb.AppendLine("PriorityCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive"); sb.AppendLine("LOW,Low,1,secondary,bi-arrow-down,true"); sb.AppendLine("NORMAL,Normal,2,primary,bi-dash,true"); sb.AppendLine("HIGH,High,3,warning,bi-arrow-up,true"); sb.AppendLine("URGENT,Urgent,4,danger,bi-exclamation-triangle,true"); sb.AppendLine(); // Quote Status Lookups sb.AppendLine("[Quote Statuses]"); sb.AppendLine("StatusCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive,IsApprovedStatus,IsConvertedStatus,IsDraftStatus"); sb.AppendLine("DRAFT,Draft,1,secondary,bi-pencil,true,false,false,true"); sb.AppendLine("SENT,Sent,2,info,bi-send,true,false,false,false"); sb.AppendLine("APPROVED,Approved,3,success,bi-check-circle,true,true,false,false"); sb.AppendLine("REJECTED,Rejected,4,danger,bi-x-circle,true,false,false,false"); sb.AppendLine(); // Inventory Category Lookups sb.AppendLine("[Inventory Categories]"); sb.AppendLine("CategoryCode,DisplayName,DisplayOrder,IsActive,IsSystemDefined,IsCoating,Description"); sb.AppendLine("POWDER,Powder Coating,1,true,true,true,Powder coating materials"); sb.AppendLine("CONSUMABLES,Consumables,2,true,false,false,Shop consumables and supplies"); sb.AppendLine(); // Appointment Status Lookups sb.AppendLine("[Appointment Statuses]"); sb.AppendLine("StatusCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive"); sb.AppendLine("SCHEDULED,Scheduled,1,primary,bi-calendar-check,true"); sb.AppendLine("CONFIRMED,Confirmed,2,success,bi-check2-circle,true"); sb.AppendLine("COMPLETED,Completed,3,success,bi-check-circle,true"); sb.AppendLine("CANCELLED,Cancelled,4,danger,bi-x-circle,true"); sb.AppendLine(); // Appointment Type Lookups sb.AppendLine("[Appointment Types]"); sb.AppendLine("TypeCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive,Description"); sb.AppendLine("ESTIMATE,Estimate,1,info,bi-calculator,true,On-site estimate appointment"); sb.AppendLine("DROPOFF,Drop-off,2,primary,bi-box-arrow-in-down,true,Customer dropping off parts"); sb.AppendLine("PICKUP,Pick-up,3,success,bi-box-arrow-up,true,Customer picking up completed job"); sb.AppendLine(); return sb.ToString(); } /// /// Serialises the current company's live settings into the multi-section CSV format, producing /// a file that can be round-tripped back through . /// Null OperatingCosts or Preferences sections are silently omitted so a partially configured /// company still produces a valid (if incomplete) export. Pricing tiers filter out soft-deleted /// records (!t.IsDeleted) because the export is intended to capture the active configuration. /// private string GenerateCompanySettingsCsv( Core.Entities.Company company, IEnumerable jobStatuses, IEnumerable jobPriorities, IEnumerable quoteStatuses, IEnumerable inventoryCategories, IEnumerable appointmentStatuses, IEnumerable appointmentTypes) { var sb = new System.Text.StringBuilder(); // Company Info sb.AppendLine("[Company Info]"); sb.AppendLine($"CompanyName,{EscapeCsv(company.CompanyName)}"); sb.AppendLine($"CompanyCode,{EscapeCsv(company.CompanyCode)}"); sb.AppendLine($"PrimaryContactName,{EscapeCsv(company.PrimaryContactName)}"); sb.AppendLine($"PrimaryContactEmail,{EscapeCsv(company.PrimaryContactEmail)}"); sb.AppendLine($"Phone,{EscapeCsv(company.Phone)}"); sb.AppendLine($"Address,{EscapeCsv(company.Address)}"); sb.AppendLine($"City,{EscapeCsv(company.City)}"); sb.AppendLine($"State,{EscapeCsv(company.State)}"); sb.AppendLine($"ZipCode,{EscapeCsv(company.ZipCode)}"); sb.AppendLine($"TimeZone,{EscapeCsv(company.TimeZone)}"); sb.AppendLine(); // Operating Costs if (company.OperatingCosts != null) { var costs = company.OperatingCosts; sb.AppendLine("[Operating Costs]"); sb.AppendLine($"StandardLaborRate,{costs.StandardLaborRate}"); sb.AppendLine($"AdditionalCoatLaborPercent,{costs.AdditionalCoatLaborPercent}"); sb.AppendLine($"OvenOperatingCostPerHour,{costs.OvenOperatingCostPerHour}"); sb.AppendLine($"SandblasterCostPerHour,{costs.SandblasterCostPerHour}"); sb.AppendLine($"CoatingBoothCostPerHour,{costs.CoatingBoothCostPerHour}"); sb.AppendLine($"PowderCoatingCostPerSqFt,{costs.PowderCoatingCostPerSqFt}"); sb.AppendLine($"GeneralMarkupPercentage,{costs.GeneralMarkupPercentage}"); sb.AppendLine($"TaxPercent,{costs.TaxPercent}"); sb.AppendLine($"ShopSuppliesRate,{costs.ShopSuppliesRate}"); sb.AppendLine($"RushChargeType,{EscapeCsv(costs.RushChargeType)}"); sb.AppendLine($"RushChargePercentage,{costs.RushChargePercentage}"); sb.AppendLine($"RushChargeFixedAmount,{costs.RushChargeFixedAmount}"); sb.AppendLine($"ShopMinimumCharge,{costs.ShopMinimumCharge}"); sb.AppendLine(); } // Preferences if (company.Preferences != null) { var prefs = company.Preferences; sb.AppendLine("[Preferences]"); sb.AppendLine($"DefaultCurrency,{EscapeCsv(prefs.DefaultCurrency)}"); sb.AppendLine($"DefaultDateFormat,{EscapeCsv(prefs.DefaultDateFormat)}"); sb.AppendLine($"DefaultTimeFormat,{EscapeCsv(prefs.DefaultTimeFormat)}"); sb.AppendLine($"DefaultPaymentTerms,{EscapeCsv(prefs.DefaultPaymentTerms)}"); sb.AppendLine($"DefaultQuoteValidityDays,{prefs.DefaultQuoteValidityDays}"); sb.AppendLine($"QuoteNumberPrefix,{EscapeCsv(prefs.QuoteNumberPrefix)}"); sb.AppendLine($"JobNumberPrefix,{EscapeCsv(prefs.JobNumberPrefix)}"); sb.AppendLine($"UseMetricSystem,{prefs.UseMetricSystem.ToString().ToLower()}"); sb.AppendLine($"DefaultJobPriority,{EscapeCsv(prefs.DefaultJobPriority)}"); sb.AppendLine($"RequireCustomerPO,{prefs.RequireCustomerPO.ToString().ToLower()}"); sb.AppendLine($"AllowCustomerApproval,{prefs.AllowCustomerApproval.ToString().ToLower()}"); sb.AppendLine($"DefaultTurnaroundDays,{prefs.DefaultTurnaroundDays}"); sb.AppendLine($"EmailNotificationsEnabled,{prefs.EmailNotificationsEnabled.ToString().ToLower()}"); sb.AppendLine($"NotifyOnNewJob,{prefs.NotifyOnNewJob.ToString().ToLower()}"); sb.AppendLine($"NotifyOnJobStatusChange,{prefs.NotifyOnJobStatusChange.ToString().ToLower()}"); sb.AppendLine($"NotifyOnQuoteApproval,{prefs.NotifyOnQuoteApproval.ToString().ToLower()}"); sb.AppendLine($"NotifyOnPaymentReceived,{prefs.NotifyOnPaymentReceived.ToString().ToLower()}"); sb.AppendLine($"QuoteExpiryWarningDays,{prefs.QuoteExpiryWarningDays}"); sb.AppendLine($"DueDateWarningDays,{prefs.DueDateWarningDays}"); sb.AppendLine($"MaintenanceAlertDays,{prefs.MaintenanceAlertDays}"); sb.AppendLine($"QuoteRetentionYears,{prefs.QuoteRetentionYears}"); sb.AppendLine($"JobRetentionYears,{prefs.JobRetentionYears}"); sb.AppendLine($"LogRetentionDays,{prefs.LogRetentionDays}"); sb.AppendLine($"AutoArchiveJobsDays,{prefs.AutoArchiveJobsDays}"); sb.AppendLine($"DeletedRecordRetentionDays,{prefs.DeletedRecordRetentionDays}"); sb.AppendLine(); } // Pricing Tiers sb.AppendLine("[Pricing Tiers]"); sb.AppendLine("TierName,Description,DiscountPercent,IsActive"); foreach (var tier in company.PricingTiers.Where(t => !t.IsDeleted).OrderBy(t => t.TierName)) { sb.AppendLine($"{EscapeCsv(tier.TierName)},{EscapeCsv(tier.Description)},{tier.DiscountPercent},{tier.IsActive.ToString().ToLower()}"); } sb.AppendLine(); // Job Status Lookups sb.AppendLine("[Job Statuses]"); sb.AppendLine("StatusCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive,IsSystemDefined,IsTerminalStatus,IsWorkInProgressStatus,WorkflowCategory,Description"); foreach (var status in jobStatuses.OrderBy(s => s.DisplayOrder)) { sb.AppendLine($"{EscapeCsv(status.StatusCode)},{EscapeCsv(status.DisplayName)},{status.DisplayOrder}," + $"{EscapeCsv(status.ColorClass)},{EscapeCsv(status.IconClass)},{status.IsActive.ToString().ToLower()}," + $"{status.IsSystemDefined.ToString().ToLower()},{status.IsTerminalStatus.ToString().ToLower()}," + $"{status.IsWorkInProgressStatus.ToString().ToLower()},{EscapeCsv(status.WorkflowCategory)},{EscapeCsv(status.Description)}"); } sb.AppendLine(); // Job Priority Lookups sb.AppendLine("[Job Priorities]"); sb.AppendLine("PriorityCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive"); foreach (var priority in jobPriorities.OrderBy(p => p.DisplayOrder)) { sb.AppendLine($"{EscapeCsv(priority.PriorityCode)},{EscapeCsv(priority.DisplayName)},{priority.DisplayOrder}," + $"{EscapeCsv(priority.ColorClass)},{EscapeCsv(priority.IconClass)},{priority.IsActive.ToString().ToLower()}"); } sb.AppendLine(); // Quote Status Lookups sb.AppendLine("[Quote Statuses]"); sb.AppendLine("StatusCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive,IsApprovedStatus,IsConvertedStatus,IsDraftStatus"); foreach (var status in quoteStatuses.OrderBy(s => s.DisplayOrder)) { sb.AppendLine($"{EscapeCsv(status.StatusCode)},{EscapeCsv(status.DisplayName)},{status.DisplayOrder}," + $"{EscapeCsv(status.ColorClass)},{EscapeCsv(status.IconClass)},{status.IsActive.ToString().ToLower()}," + $"{status.IsApprovedStatus.ToString().ToLower()},{status.IsConvertedStatus.ToString().ToLower()}," + $"{status.IsDraftStatus.ToString().ToLower()}"); } sb.AppendLine(); // Inventory Category Lookups sb.AppendLine("[Inventory Categories]"); sb.AppendLine("CategoryCode,DisplayName,DisplayOrder,IsActive,IsSystemDefined,IsCoating,Description"); foreach (var category in inventoryCategories.OrderBy(c => c.DisplayOrder)) { sb.AppendLine($"{EscapeCsv(category.CategoryCode)},{EscapeCsv(category.DisplayName)},{category.DisplayOrder}," + $"{category.IsActive.ToString().ToLower()},{category.IsSystemDefined.ToString().ToLower()}," + $"{category.IsCoating.ToString().ToLower()},{EscapeCsv(category.Description)}"); } sb.AppendLine(); // Appointment Status Lookups sb.AppendLine("[Appointment Statuses]"); sb.AppendLine("StatusCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive"); foreach (var status in appointmentStatuses.OrderBy(s => s.DisplayOrder)) { sb.AppendLine($"{EscapeCsv(status.StatusCode)},{EscapeCsv(status.DisplayName)},{status.DisplayOrder}," + $"{EscapeCsv(status.ColorClass)},{EscapeCsv(status.IconClass)},{status.IsActive.ToString().ToLower()}"); } sb.AppendLine(); // Appointment Type Lookups sb.AppendLine("[Appointment Types]"); sb.AppendLine("TypeCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive,Description"); foreach (var type in appointmentTypes.OrderBy(t => t.DisplayOrder)) { sb.AppendLine($"{EscapeCsv(type.TypeCode)},{EscapeCsv(type.DisplayName)},{type.DisplayOrder}," + $"{EscapeCsv(type.ColorClass)},{EscapeCsv(type.IconClass)},{type.IsActive.ToString().ToLower()},{EscapeCsv(type.Description)}"); } return sb.ToString(); } /// /// Core parser and upsert engine for company-settings CSV import. Reads the file line by line, /// switching between key-value mode (for Company Info, Operating Costs, Preferences) and table /// mode (for Pricing Tiers and all lookup tables) based on section headers. Key-value fields /// that parse successfully are applied to the in-memory entity; parse errors are collected as /// non-fatal warnings (the row is skipped, but processing continues). Table sections are /// accumulated in memory, then processed after the full file is read, so the final table state /// reflects the entire section rather than partial rows applied mid-stream. A single /// CompleteAsync call at the end commits all changes atomically. /// /// Design caveat: Empty CSV values are silently skipped for key-value rows (so a blank value /// means "keep existing"), but table sections REPLACE all existing records — the caller must /// include every row they want to retain in a table section export. /// /// private async Task<(bool Success, string Message, List Errors)> ImportCompanySettingsFromCsv(System.IO.StreamReader reader, int companyId) { var errors = new List(); var currentSection = ""; var lineNumber = 0; var tableSections = new Dictionary>(); var tableHeaders = new Dictionary(); try { var company = await _unitOfWork.Companies.GetByIdAsync(companyId, false, c => c.OperatingCosts, c => c.Preferences, c => c.PricingTiers); if (company == null) { return (false, "Company not found.", errors); } // Create operating costs if null if (company.OperatingCosts == null) { company.OperatingCosts = new Core.Entities.CompanyOperatingCosts { CompanyId = companyId }; } // Create preferences if null if (company.Preferences == null) { company.Preferences = new Core.Entities.CompanyPreferences { CompanyId = companyId }; } // Parse the CSV string? line; bool isTableSection = false; bool isFirstLineOfTable = false; while ((line = await reader.ReadLineAsync()) != null) { lineNumber++; // Skip comments and empty lines if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("#")) continue; // Check for section headers if (line.StartsWith("[") && line.EndsWith("]")) { currentSection = line.Trim('[', ']'); isTableSection = IsTableSection(currentSection); isFirstLineOfTable = isTableSection; if (isTableSection) { tableSections[currentSection] = new List(); } continue; } if (isTableSection) { // Parse CSV row var cells = ParseCsvLine(line); if (isFirstLineOfTable) { // This is the header row tableHeaders[currentSection] = cells; isFirstLineOfTable = false; } else { // This is a data row tableSections[currentSection].Add(cells); } } else { // Parse key-value pairs var parts = line.Split(',', 2); if (parts.Length != 2) { errors.Add($"Line {lineNumber}: Invalid format (expected Key,Value)"); continue; } var key = parts[0].Trim(); var value = parts[1].Trim(); // Skip empty values if (string.IsNullOrWhiteSpace(value)) continue; try { switch (currentSection) { case "Company Info": UpdateCompanyInfo(company, key, value); break; case "Operating Costs": UpdateOperatingCosts(company.OperatingCosts, key, value); break; case "Preferences": UpdatePreferences(company.Preferences, key, value); break; } } catch (Exception ex) { errors.Add($"Line {lineNumber} ({key}): {ex.Message}"); } } } // Process table sections foreach (var section in tableSections) { try { await ProcessTableSection(section.Key, tableHeaders[section.Key], section.Value, companyId, errors); } catch (Exception ex) { errors.Add($"Section [{section.Key}]: {ex.Message}"); } } await _unitOfWork.CompleteAsync(); var message = errors.Any() ? $"Import completed with {errors.Count} warning(s). Settings have been updated." : "All settings imported successfully!"; return (true, message, errors); } catch (Exception ex) { _logger.LogError(ex, "Error importing company settings"); return (false, $"Import failed: {ex.Message}", errors); } } /// /// Returns true if the given CSV section name corresponds to a multi-row table (Pricing Tiers, /// Job Statuses, etc.) rather than a flat key-value section (Company Info, Operating Costs, /// Preferences). The distinction controls whether /// reads lines as "Key,Value" pairs or as CSV rows with a header row followed by data rows. /// private bool IsTableSection(string sectionName) { return sectionName switch { "Pricing Tiers" => true, "Job Statuses" => true, "Job Priorities" => true, "Quote Statuses" => true, "Inventory Categories" => true, "Appointment Statuses" => true, "Appointment Types" => true, _ => false }; } /// /// Parses a single CSV line into a string array, correctly handling RFC 4180 quoting rules: /// fields enclosed in double quotes may contain commas, newlines, or literal double-quotes /// (escaped as two consecutive double-quotes ""). Each resulting cell is trimmed of leading /// and trailing whitespace. Used exclusively for table-section rows where a standard /// string.Split(',') would break on embedded commas in field values (e.g., addresses). /// private string[] ParseCsvLine(string line) { var cells = new List(); var inQuotes = false; var currentCell = new System.Text.StringBuilder(); for (int i = 0; i < line.Length; i++) { var c = line[i]; if (c == '"') { if (inQuotes && i + 1 < line.Length && line[i + 1] == '"') { // Escaped quote currentCell.Append('"'); i++; } else { inQuotes = !inQuotes; } } else if (c == ',' && !inQuotes) { cells.Add(currentCell.ToString().Trim()); currentCell.Clear(); } else { currentCell.Append(c); } } cells.Add(currentCell.ToString().Trim()); return cells.ToArray(); } /// /// Dispatches table-section data to the appropriate typed import helper based on the section /// name. Each helper receives the parsed header row (for column-index resolution) and all data /// rows, and appends any row-level errors to the shared list. /// Adding a new importable lookup table requires adding a case here and implementing the /// corresponding Import* private method. /// private async Task ProcessTableSection(string sectionName, string[] headers, List rows, int companyId, List errors) { switch (sectionName) { case "Pricing Tiers": await ImportPricingTiers(headers, rows, companyId, errors); break; case "Job Statuses": await ImportJobStatuses(headers, rows, companyId, errors); break; case "Job Priorities": await ImportJobPriorities(headers, rows, companyId, errors); break; case "Quote Statuses": await ImportQuoteStatuses(headers, rows, companyId, errors); break; case "Inventory Categories": await ImportInventoryCategories(headers, rows, companyId, errors); break; case "Appointment Statuses": await ImportAppointmentStatuses(headers, rows, companyId, errors); break; case "Appointment Types": await ImportAppointmentTypes(headers, rows, companyId, errors); break; } } /// /// Applies a single key-value pair from the "[Company Info]" CSV section to the given company /// entity. Unrecognised keys are silently ignored so the file format can evolve without breaking /// older imports. The caller is responsible for saving changes via CompleteAsync. /// private void UpdateCompanyInfo(Core.Entities.Company company, string key, string value) { switch (key) { case "CompanyName": company.CompanyName = value; break; case "CompanyCode": company.CompanyCode = value; break; case "PrimaryContactName": company.PrimaryContactName = value; break; case "PrimaryContactEmail": company.PrimaryContactEmail = value; break; case "Phone": company.Phone = value; break; case "Address": company.Address = value; break; case "City": company.City = value; break; case "State": company.State = value; break; case "ZipCode": company.ZipCode = value; break; case "TimeZone": company.TimeZone = value; break; } } /// /// Applies a single key-value pair from the "[Operating Costs]" CSV section to the given /// operating costs entity using decimal.Parse. Throws on /// invalid numeric values; callers should catch and log this as a row-level warning without /// aborting the entire import. Unrecognised keys are silently ignored. /// private void UpdateOperatingCosts(Core.Entities.CompanyOperatingCosts costs, string key, string value) { switch (key) { case "StandardLaborRate": costs.StandardLaborRate = decimal.Parse(value); break; case "AdditionalCoatLaborPercent": costs.AdditionalCoatLaborPercent = decimal.Parse(value); break; case "OvenOperatingCostPerHour": costs.OvenOperatingCostPerHour = decimal.Parse(value); break; case "SandblasterCostPerHour": costs.SandblasterCostPerHour = decimal.Parse(value); break; case "CoatingBoothCostPerHour": costs.CoatingBoothCostPerHour = decimal.Parse(value); break; case "PowderCoatingCostPerSqFt": costs.PowderCoatingCostPerSqFt = decimal.Parse(value); break; case "GeneralMarkupPercentage": costs.GeneralMarkupPercentage = decimal.Parse(value); break; case "TaxPercent": costs.TaxPercent = decimal.Parse(value); break; case "ShopSuppliesRate": costs.ShopSuppliesRate = decimal.Parse(value); break; case "RushChargeType": costs.RushChargeType = value; break; case "RushChargePercentage": costs.RushChargePercentage = decimal.Parse(value); break; case "RushChargeFixedAmount": costs.RushChargeFixedAmount = decimal.Parse(value); break; case "ShopMinimumCharge": costs.ShopMinimumCharge = decimal.Parse(value); break; } } /// /// Applies a single key-value pair from the "[Preferences]" CSV section to the given /// preferences entity. Boolean fields use bool.Parse (case-insensitive, expects "true" /// or "false"); integer fields use int.Parse. Like , /// parse failures throw and are caught by the caller as row-level warnings. /// private void UpdatePreferences(Core.Entities.CompanyPreferences prefs, string key, string value) { switch (key) { case "DefaultCurrency": prefs.DefaultCurrency = value; break; case "DefaultDateFormat": prefs.DefaultDateFormat = value; break; case "DefaultTimeFormat": prefs.DefaultTimeFormat = value; break; case "DefaultPaymentTerms": prefs.DefaultPaymentTerms = value; break; case "DefaultQuoteValidityDays": prefs.DefaultQuoteValidityDays = int.Parse(value); break; case "QuoteNumberPrefix": prefs.QuoteNumberPrefix = value; break; case "JobNumberPrefix": prefs.JobNumberPrefix = value; break; case "UseMetricSystem": prefs.UseMetricSystem = bool.Parse(value); break; case "DefaultJobPriority": prefs.DefaultJobPriority = value; break; case "RequireCustomerPO": prefs.RequireCustomerPO = bool.Parse(value); break; case "AllowCustomerApproval": prefs.AllowCustomerApproval = bool.Parse(value); break; case "DefaultTurnaroundDays": prefs.DefaultTurnaroundDays = int.Parse(value); break; case "EmailNotificationsEnabled": prefs.EmailNotificationsEnabled = bool.Parse(value); break; case "NotifyOnNewJob": prefs.NotifyOnNewJob = bool.Parse(value); break; case "NotifyOnJobStatusChange": prefs.NotifyOnJobStatusChange = bool.Parse(value); break; case "NotifyOnQuoteApproval": prefs.NotifyOnQuoteApproval = bool.Parse(value); break; case "NotifyOnPaymentReceived": prefs.NotifyOnPaymentReceived = bool.Parse(value); break; case "QuoteExpiryWarningDays": prefs.QuoteExpiryWarningDays = int.Parse(value); break; case "DueDateWarningDays": prefs.DueDateWarningDays = int.Parse(value); break; case "MaintenanceAlertDays": prefs.MaintenanceAlertDays = int.Parse(value); break; case "QuoteRetentionYears": prefs.QuoteRetentionYears = int.Parse(value); break; case "JobRetentionYears": prefs.JobRetentionYears = int.Parse(value); break; case "LogRetentionDays": prefs.LogRetentionDays = int.Parse(value); break; case "AutoArchiveJobsDays": prefs.AutoArchiveJobsDays = int.Parse(value); break; case "DeletedRecordRetentionDays": prefs.DeletedRecordRetentionDays = int.Parse(value); break; } } /// /// Replaces all existing pricing tiers for the company with the tiers defined in the CSV /// table section. Existing records are physically deleted (not soft-deleted) because pricing /// tiers are configuration data, not transactional records; any customers referencing deleted /// tiers will retain their FK but the tier row will be gone. Callers should warn users that /// table sections replace all existing records — the export/import round-trip is the safe path. /// Column positions are resolved by name from so the import is /// resilient to column reordering. /// private async Task ImportPricingTiers(string[] headers, List rows, int companyId, List errors) { // Delete existing tiers for this company var existing = await _unitOfWork.PricingTiers.FindAsync(pt => pt.CompanyId == companyId); foreach (var tier in existing) { await _unitOfWork.PricingTiers.DeleteAsync(tier); } // Import new tiers var nameIdx = Array.IndexOf(headers, "TierName"); var descIdx = Array.IndexOf(headers, "Description"); var discountIdx = Array.IndexOf(headers, "DiscountPercent"); var activeIdx = Array.IndexOf(headers, "IsActive"); foreach (var row in rows) { try { var tier = new Core.Entities.PricingTier { CompanyId = companyId, TierName = row[nameIdx], Description = descIdx >= 0 && descIdx < row.Length ? row[descIdx] : null, DiscountPercent = discountIdx >= 0 && discountIdx < row.Length ? decimal.Parse(row[discountIdx]) : 0, IsActive = activeIdx >= 0 && activeIdx < row.Length ? bool.Parse(row[activeIdx]) : true }; await _unitOfWork.PricingTiers.AddAsync(tier); } catch (Exception ex) { errors.Add($"Pricing Tier '{row[nameIdx]}': {ex.Message}"); } } } /// /// Upserts job status lookup records from the CSV table section. Unlike other lookup importers, /// this one does NOT delete all existing records first: only non-system-defined statuses are /// deleted, and system-defined ones are updated in-place via a find-by-StatusCode lookup. /// This protects built-in statuses (e.g., "PENDING", "COMPLETED") that jobs may already /// reference, while still allowing custom statuses to be replaced. System-defined rows from /// the CSV are recognised by their IsSystemDefined column value. /// private async Task ImportJobStatuses(string[] headers, List rows, int companyId, List errors) { // Delete existing non-system-defined statuses var existing = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId && !s.IsSystemDefined); foreach (var status in existing) { await _unitOfWork.JobStatusLookups.DeleteAsync(status); } // Update or add statuses var codeIdx = Array.IndexOf(headers, "StatusCode"); var nameIdx = Array.IndexOf(headers, "DisplayName"); var orderIdx = Array.IndexOf(headers, "DisplayOrder"); var colorIdx = Array.IndexOf(headers, "ColorClass"); var iconIdx = Array.IndexOf(headers, "IconClass"); var activeIdx = Array.IndexOf(headers, "IsActive"); var systemIdx = Array.IndexOf(headers, "IsSystemDefined"); var terminalIdx = Array.IndexOf(headers, "IsTerminalStatus"); var wipIdx = Array.IndexOf(headers, "IsWorkInProgressStatus"); var categoryIdx = Array.IndexOf(headers, "WorkflowCategory"); var descIdx = Array.IndexOf(headers, "Description"); foreach (var row in rows) { try { var statusCode = row[codeIdx]; var existingStatus = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId && s.StatusCode == statusCode); var status = existingStatus.FirstOrDefault(); if (status == null) { status = new Core.Entities.JobStatusLookup { CompanyId = companyId, StatusCode = statusCode }; await _unitOfWork.JobStatusLookups.AddAsync(status); } status.DisplayName = row[nameIdx]; status.DisplayOrder = int.Parse(row[orderIdx]); status.ColorClass = row[colorIdx]; status.IconClass = iconIdx >= 0 && iconIdx < row.Length ? row[iconIdx] : null; status.IsActive = bool.Parse(row[activeIdx]); status.IsSystemDefined = bool.Parse(row[systemIdx]); status.IsTerminalStatus = bool.Parse(row[terminalIdx]); status.IsWorkInProgressStatus = bool.Parse(row[wipIdx]); status.WorkflowCategory = categoryIdx >= 0 && categoryIdx < row.Length ? row[categoryIdx] : null; status.Description = descIdx >= 0 && descIdx < row.Length ? row[descIdx] : null; } catch (Exception ex) { errors.Add($"Job Status '{row[codeIdx]}': {ex.Message}"); } } } /// /// Replaces all job priority lookup records for the company with the priorities defined in /// the CSV table section. All existing priorities are physically deleted before inserting the /// new set, so callers must include every priority they want to keep (including the system /// defaults) when submitting the import file. /// private async Task ImportJobPriorities(string[] headers, List rows, int companyId, List errors) { // Delete existing priorities var existing = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId); foreach (var priority in existing) { await _unitOfWork.JobPriorityLookups.DeleteAsync(priority); } var codeIdx = Array.IndexOf(headers, "PriorityCode"); var nameIdx = Array.IndexOf(headers, "DisplayName"); var orderIdx = Array.IndexOf(headers, "DisplayOrder"); var colorIdx = Array.IndexOf(headers, "ColorClass"); var iconIdx = Array.IndexOf(headers, "IconClass"); var activeIdx = Array.IndexOf(headers, "IsActive"); foreach (var row in rows) { try { var priority = new Core.Entities.JobPriorityLookup { CompanyId = companyId, PriorityCode = row[codeIdx], DisplayName = row[nameIdx], DisplayOrder = int.Parse(row[orderIdx]), ColorClass = row[colorIdx], IconClass = iconIdx >= 0 && iconIdx < row.Length ? row[iconIdx] : null, IsActive = bool.Parse(row[activeIdx]) }; await _unitOfWork.JobPriorityLookups.AddAsync(priority); } catch (Exception ex) { errors.Add($"Job Priority '{row[codeIdx]}': {ex.Message}"); } } } /// /// Replaces all quote status lookup records for the company with the statuses defined in the /// CSV table section. The three semantic flags (IsApprovedStatus, IsConvertedStatus, /// IsDraftStatus) are critical for workflow logic (e.g., only an approved quote can be /// converted to a job), so importers must ensure exactly one row sets each flag to true. /// private async Task ImportQuoteStatuses(string[] headers, List rows, int companyId, List errors) { // Delete existing statuses var existing = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId); foreach (var status in existing) { await _unitOfWork.QuoteStatusLookups.DeleteAsync(status); } var codeIdx = Array.IndexOf(headers, "StatusCode"); var nameIdx = Array.IndexOf(headers, "DisplayName"); var orderIdx = Array.IndexOf(headers, "DisplayOrder"); var colorIdx = Array.IndexOf(headers, "ColorClass"); var iconIdx = Array.IndexOf(headers, "IconClass"); var activeIdx = Array.IndexOf(headers, "IsActive"); var approvedIdx = Array.IndexOf(headers, "IsApprovedStatus"); var convertedIdx = Array.IndexOf(headers, "IsConvertedStatus"); var draftIdx = Array.IndexOf(headers, "IsDraftStatus"); foreach (var row in rows) { try { var status = new Core.Entities.QuoteStatusLookup { CompanyId = companyId, StatusCode = row[codeIdx], DisplayName = row[nameIdx], DisplayOrder = int.Parse(row[orderIdx]), ColorClass = row[colorIdx], IconClass = iconIdx >= 0 && iconIdx < row.Length ? row[iconIdx] : null, IsActive = bool.Parse(row[activeIdx]), IsApprovedStatus = bool.Parse(row[approvedIdx]), IsConvertedStatus = bool.Parse(row[convertedIdx]), IsDraftStatus = bool.Parse(row[draftIdx]) }; await _unitOfWork.QuoteStatusLookups.AddAsync(status); } catch (Exception ex) { errors.Add($"Quote Status '{row[codeIdx]}': {ex.Message}"); } } } /// /// Replaces all inventory category lookup records for the company with the categories defined /// in the CSV table section. The IsCoating flag is used by the pricing engine to /// identify powder-coating material categories for coverage calculations; at least one category /// with IsCoating=true should be present for quoting to work correctly. /// private async Task ImportInventoryCategories(string[] headers, List rows, int companyId, List errors) { // Delete existing categories var existing = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId); foreach (var category in existing) { await _unitOfWork.InventoryCategoryLookups.DeleteAsync(category); } var codeIdx = Array.IndexOf(headers, "CategoryCode"); var nameIdx = Array.IndexOf(headers, "DisplayName"); var orderIdx = Array.IndexOf(headers, "DisplayOrder"); var activeIdx = Array.IndexOf(headers, "IsActive"); var systemIdx = Array.IndexOf(headers, "IsSystemDefined"); var coatingIdx = Array.IndexOf(headers, "IsCoating"); var descIdx = Array.IndexOf(headers, "Description"); foreach (var row in rows) { try { var category = new Core.Entities.InventoryCategoryLookup { CompanyId = companyId, CategoryCode = row[codeIdx], DisplayName = row[nameIdx], DisplayOrder = int.Parse(row[orderIdx]), IsActive = bool.Parse(row[activeIdx]), IsSystemDefined = systemIdx >= 0 && systemIdx < row.Length ? bool.Parse(row[systemIdx]) : false, IsCoating = coatingIdx >= 0 && coatingIdx < row.Length ? bool.Parse(row[coatingIdx]) : false, Description = descIdx >= 0 && descIdx < row.Length ? row[descIdx] : null }; await _unitOfWork.InventoryCategoryLookups.AddAsync(category); } catch (Exception ex) { errors.Add($"Inventory Category '{row[codeIdx]}': {ex.Message}"); } } } /// /// Replaces all appointment status lookup records for the company with the statuses defined /// in the CSV table section. All existing statuses are physically deleted before inserting /// the new set; callers must include every status they wish to retain. /// private async Task ImportAppointmentStatuses(string[] headers, List rows, int companyId, List errors) { // Delete existing statuses var existing = await _unitOfWork.AppointmentStatusLookups.FindAsync(s => s.CompanyId == companyId); foreach (var status in existing) { await _unitOfWork.AppointmentStatusLookups.DeleteAsync(status); } var codeIdx = Array.IndexOf(headers, "StatusCode"); var nameIdx = Array.IndexOf(headers, "DisplayName"); var orderIdx = Array.IndexOf(headers, "DisplayOrder"); var colorIdx = Array.IndexOf(headers, "ColorClass"); var iconIdx = Array.IndexOf(headers, "IconClass"); var activeIdx = Array.IndexOf(headers, "IsActive"); foreach (var row in rows) { try { var status = new Core.Entities.AppointmentStatusLookup { CompanyId = companyId, StatusCode = row[codeIdx], DisplayName = row[nameIdx], DisplayOrder = int.Parse(row[orderIdx]), ColorClass = row[colorIdx], IconClass = iconIdx >= 0 && iconIdx < row.Length ? row[iconIdx] : null, IsActive = bool.Parse(row[activeIdx]) }; await _unitOfWork.AppointmentStatusLookups.AddAsync(status); } catch (Exception ex) { errors.Add($"Appointment Status '{row[codeIdx]}': {ex.Message}"); } } } /// /// Replaces all appointment type lookup records for the company with the types defined in the /// CSV table section. All existing types are physically deleted before inserting the new set; /// callers must include every type they wish to retain. The optional Description column /// defaults to null if the column is absent or the cell is empty. /// private async Task ImportAppointmentTypes(string[] headers, List rows, int companyId, List errors) { // Delete existing types var existing = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId); foreach (var type in existing) { await _unitOfWork.AppointmentTypeLookups.DeleteAsync(type); } var codeIdx = Array.IndexOf(headers, "TypeCode"); var nameIdx = Array.IndexOf(headers, "DisplayName"); var orderIdx = Array.IndexOf(headers, "DisplayOrder"); var colorIdx = Array.IndexOf(headers, "ColorClass"); var iconIdx = Array.IndexOf(headers, "IconClass"); var activeIdx = Array.IndexOf(headers, "IsActive"); var descIdx = Array.IndexOf(headers, "Description"); foreach (var row in rows) { try { var type = new Core.Entities.AppointmentTypeLookup { CompanyId = companyId, TypeCode = row[codeIdx], DisplayName = row[nameIdx], DisplayOrder = int.Parse(row[orderIdx]), ColorClass = row[colorIdx], IconClass = iconIdx >= 0 && iconIdx < row.Length ? row[iconIdx] : null, IsActive = bool.Parse(row[activeIdx]), Description = descIdx >= 0 && descIdx < row.Length ? row[descIdx] : null }; await _unitOfWork.AppointmentTypeLookups.AddAsync(type); } catch (Exception ex) { errors.Add($"Appointment Type '{row[codeIdx]}': {ex.Message}"); } } } /// /// Builds a CSV string for the given invoice collection. Invoice status is written as its enum /// name. Monetary fields (SubTotal, TaxAmount, DiscountAmount, Total, AmountPaid, BalanceDue) /// are written without formatting so they can be parsed as decimals by downstream tools. /// private string GenerateInvoicesCsv(IEnumerable invoices) { var sb = new System.Text.StringBuilder(); sb.AppendLine("InvoiceNumber,JobNumber,Customer,Status,InvoiceDate,DueDate,SubTotal,TaxPercent,TaxAmount,DiscountAmount,Total,AmountPaid,BalanceDue,CustomerPO,Terms,Notes"); foreach (var invoice in invoices) { var customerName = invoice.Customer != null ? (!string.IsNullOrWhiteSpace(invoice.Customer.CompanyName) ? invoice.Customer.CompanyName : $"{invoice.Customer.ContactFirstName} {invoice.Customer.ContactLastName}".Trim()) : "Unknown"; sb.AppendLine($"{EscapeCsv(invoice.InvoiceNumber)},{EscapeCsv(invoice.Job?.JobNumber)}," + $"{EscapeCsv(customerName)},{invoice.Status}," + $"{invoice.InvoiceDate:yyyy-MM-dd},{invoice.DueDate?.ToString("yyyy-MM-dd")}," + $"{invoice.SubTotal},{invoice.TaxPercent},{invoice.TaxAmount},{invoice.DiscountAmount}," + $"{invoice.Total},{invoice.AmountPaid},{invoice.BalanceDue}," + $"{EscapeCsv(invoice.CustomerPO)},{EscapeCsv(invoice.Terms)},{EscapeCsv(invoice.Notes)}"); } return sb.ToString(); } /// /// Builds a CSV string for the given invoice payment records. The parent invoice number is /// resolved from the eagerly loaded Invoice navigation property. PaymentMethod is /// written as its enum name (e.g., "Cash", "BankTransferACH"). /// private string GeneratePaymentsCsv(IEnumerable payments) { var sb = new System.Text.StringBuilder(); sb.AppendLine("InvoiceNumber,Amount,PaymentDate,PaymentMethod,Reference,Notes"); foreach (var payment in payments) { sb.AppendLine($"{EscapeCsv(payment.Invoice?.InvoiceNumber)}," + $"{payment.Amount},{payment.PaymentDate:yyyy-MM-dd}," + $"{payment.PaymentMethod},{EscapeCsv(payment.Reference)},{EscapeCsv(payment.Notes)}"); } return sb.ToString(); } /// /// Builds a CSV string for the given purchase order collection. PO status is written as its /// enum name. The vendor company name is resolved from the eagerly loaded Vendor /// navigation property; null-safe access means POs with a missing vendor (orphaned records) /// will output an empty vendor column rather than throwing. /// private string GeneratePurchaseOrdersCsv(IEnumerable purchaseOrders) { var sb = new System.Text.StringBuilder(); sb.AppendLine("PoNumber,Vendor,Status,OrderDate,ExpectedDeliveryDate,ReceivedDate,SubTotal,ShippingCost,TotalAmount,Notes"); foreach (var po in purchaseOrders) { sb.AppendLine($"{EscapeCsv(po.PoNumber)},{EscapeCsv(po.Vendor?.CompanyName)}," + $"{po.Status},{po.OrderDate:yyyy-MM-dd}," + $"{po.ExpectedDeliveryDate?.ToString("yyyy-MM-dd")},{po.ReceivedDate?.ToString("yyyy-MM-dd")}," + $"{po.SubTotal},{po.ShippingCost},{po.TotalAmount},{EscapeCsv(po.Notes)}"); } return sb.ToString(); } // GET: Tools/DownloadExpenseTemplate [HttpGet] public IActionResult DownloadExpenseTemplate() { var csvBytes = _csvImportService.GenerateExpenseTemplate(); return File(csvBytes, "text/csv", "expense_import_template.csv"); } /// /// Bulk-imports expense records from a native CSV file. Resolves ExpenseAccountNumber and /// PaymentAccountNumber against the Chart of Accounts; VendorName and JobNumber are optional /// lookups. ExpenseNumber is auto-generated when blank. /// // POST: Tools/CsvImportExpenses [HttpPost] [ValidateAntiForgeryToken] public async Task CsvImportExpenses(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return Json(new { success = false, message = "Your account is not associated with a company." }); if (file == null || file.Length == 0) return Json(new { success = false, message = "No file provided or file is empty." }); if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) return Json(new { success = false, message = "Only CSV files are allowed." }); _logger.LogInformation("User {UserName} importing expenses from CSV {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); using var stream = file.OpenReadStream(); var result = await _csvImportService.ImportExpensesAsync(stream, companyId.Value); await LogCsvImportAsync("Expenses", file.FileName, result); return Json(new { success = result.Success, message = result.Summary, successCount = result.SuccessCount, totalRows = result.TotalRows, skippedCount = result.SkippedCount, errors = result.Errors, warnings = result.Warnings }); } catch (Exception ex) { _logger.LogError(ex, "Error importing expenses from CSV"); return Json(new { success = false, message = "An unexpected error occurred during import." }); } } // GET: Tools/DownloadChartOfAccountsTemplate [HttpGet] public IActionResult DownloadChartOfAccountsTemplate() { var csvBytes = _csvImportService.GenerateChartOfAccountsTemplate(); return File(csvBytes, "text/csv", "chart_of_accounts_import_template.csv"); } /// /// Bulk-imports Chart of Accounts entries from a native CSV file. Existing accounts matched by /// AccountNumber are updated; new ones are created. System accounts are never modified. /// Returns JSON with per-row status detail. /// // POST: Tools/CsvImportChartOfAccounts [HttpPost] [ValidateAntiForgeryToken] public async Task CsvImportChartOfAccounts(IFormFile file) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return Json(new { success = false, message = "Your account is not associated with a company." }); if (file == null || file.Length == 0) return Json(new { success = false, message = "No file provided or file is empty." }); if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) return Json(new { success = false, message = "Only CSV files are allowed." }); _logger.LogInformation("User {UserName} importing chart of accounts from CSV {FileName} for company {CompanyId}", User.Identity?.Name, file.FileName, companyId); using var stream = file.OpenReadStream(); var result = await _csvImportService.ImportChartOfAccountsAsync(stream, companyId.Value); await LogCsvImportAsync("ChartOfAccounts", file.FileName, result); return Json(new { success = result.Success, message = result.Summary, successCount = result.SuccessCount, skippedCount = result.SkippedCount, errorCount = result.ErrorCount, totalRows = result.TotalRows, errors = result.Errors, warnings = result.Warnings }); } catch (Exception ex) { _logger.LogError(ex, "Error importing chart of accounts from CSV"); return Json(new { success = false, message = "An unexpected error occurred during import." }); } } /// /// Exports all expense records for the current company as a CSV file. Account numbers are /// written as the GL account number (e.g. "6200") so the file can be re-imported directly. /// // GET: Tools/ExportExpensesCsv [HttpGet] public async Task ExportExpensesCsv() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } var expenses = await _unitOfWork.Expenses.GetAllAsync(false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job); var csv = GenerateExpensesCsv(expenses); var fileName = $"expenses_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; await LogExportAsync("Expenses", "CSV export"); return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting expenses to CSV"); TempData["ErrorMessage"] = "An error occurred while exporting expenses."; return RedirectToAction(nameof(Index)); } } private string GenerateExpensesCsv(IEnumerable expenses) { var sb = new System.Text.StringBuilder(); sb.AppendLine("ExpenseNumber,Date,VendorName,ExpenseAccountNumber,PaymentAccountNumber,JobNumber,PaymentMethod,Amount,Memo"); foreach (var e in expenses.OrderBy(x => x.Date).ThenBy(x => x.ExpenseNumber)) { sb.AppendLine($"{EscapeCsv(e.ExpenseNumber)},{e.Date:yyyy-MM-dd},{EscapeCsv(e.Vendor?.CompanyName)},{EscapeCsv(e.ExpenseAccount?.AccountNumber)},{EscapeCsv(e.PaymentAccount?.AccountNumber)},{EscapeCsv(e.Job?.JobNumber)},{e.PaymentMethod},{e.Amount},{EscapeCsv(e.Memo)}"); } return sb.ToString(); } /// /// Exports all prep services for the current company as a CSV file. /// Column names match PrepServiceImportDto exactly so the file can be re-imported. /// // GET: Tools/ExportPrepServicesCsv [HttpGet] public async Task ExportPrepServicesCsv() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.CompanyId == companyId.Value); var csv = GeneratePrepServicesCsv(prepServices); var fileName = $"prep_services_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; await LogExportAsync("PrepServices", $"CSV export ({prepServices.Count()} records)"); return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting prep services to CSV"); TempData["ErrorMessage"] = "An error occurred while exporting prep services."; return RedirectToAction(nameof(Index)); } } /// /// Exports all vendor records for the current company as a CSV file. /// Column names match exactly so the file can be re-imported. /// // GET: Tools/ExportVendorsCsv [HttpGet] public async Task ExportVendorsCsv() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId.Value); var csv = GenerateVendorsCsv(vendors); var fileName = $"vendors_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; await LogExportAsync("Vendors", $"CSV export ({vendors.Count()} records)"); return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting vendors to CSV"); TempData["ErrorMessage"] = "An error occurred while exporting vendors."; return RedirectToAction(nameof(Index)); } } /// /// Exports the Chart of Accounts for the current company as a CSV file. /// Useful for reviewing all GL accounts and migrating the account list between environments. /// // GET: Tools/ExportChartOfAccountsCsv [HttpGet] public async Task ExportChartOfAccountsCsv() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); } var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value); var csv = GenerateChartOfAccountsCsv(accounts); var fileName = $"chart_of_accounts_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; await LogExportAsync("ChartOfAccounts", $"CSV export ({accounts.Count()} records)"); return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error exporting chart of accounts to CSV"); TempData["ErrorMessage"] = "An error occurred while exporting chart of accounts."; return RedirectToAction(nameof(Index)); } } private string GenerateVendorsCsv(IEnumerable vendors) { var sb = new System.Text.StringBuilder(); sb.AppendLine("CompanyName,ContactName,Email,Phone,Address,City,State,ZipCode,Country,Website,AccountNumber,TaxId,PaymentTerms,CreditLimit,IsPreferred,IsActive,Notes"); foreach (var v in vendors) { sb.AppendLine($"{EscapeCsv(v.CompanyName)},{EscapeCsv(v.ContactName)},{EscapeCsv(v.Email)},{EscapeCsv(v.Phone)},{EscapeCsv(v.Address)},{EscapeCsv(v.City)},{EscapeCsv(v.State)},{EscapeCsv(v.ZipCode)},{EscapeCsv(v.Country)},{EscapeCsv(v.Website)},{EscapeCsv(v.AccountNumber)},{EscapeCsv(v.TaxId)},{EscapeCsv(v.PaymentTerms)},{v.CreditLimit},{v.IsPreferred.ToString().ToLower()},{v.IsActive.ToString().ToLower()},{EscapeCsv(v.Notes)}"); } return sb.ToString(); } private string GenerateChartOfAccountsCsv(IEnumerable accounts) { var sb = new System.Text.StringBuilder(); sb.AppendLine("AccountNumber,Name,AccountType,AccountSubType,Description,IsActive,IsSystem,OpeningBalance,OpeningBalanceDate,CurrentBalance"); foreach (var a in accounts.OrderBy(x => x.AccountNumber)) { sb.AppendLine($"{EscapeCsv(a.AccountNumber)},{EscapeCsv(a.Name)},{a.AccountType},{a.AccountSubType},{EscapeCsv(a.Description)},{a.IsActive.ToString().ToLower()},{a.IsSystem.ToString().ToLower()},{a.OpeningBalance},{a.OpeningBalanceDate?.ToString("yyyy-MM-dd")},{a.CurrentBalance}"); } return sb.ToString(); } private string EscapeCsv(string? value) { if (string.IsNullOrEmpty(value)) return ""; if (value.Contains(",") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r")) { return $"\"{value.Replace("\"", "\"\"")}\""; } return value; } #endregion // ── QuickBooks Online Import Endpoints ──────────────────────────────────── /// /// Imports customers from a QuickBooks Online CSV export file. Delegates all parsing and /// upsert logic to via the /// shared helper. The QBO service is injected per-action via /// [FromServices] to avoid adding it to the controller constructor, keeping the /// constructor lean given the number of other injected services. /// [HttpPost, ValidateAntiForgeryToken] public async Task ImportQboCustomers(IFormFile file, [FromServices] PowderCoating.Web.Services.QuickBooksOnlineService qboService) => await RunQboImport(file, qboService.ImportCustomersAsync, "customers"); /// /// Imports vendors from a QuickBooks Online CSV export file via . /// [HttpPost, ValidateAntiForgeryToken] public async Task ImportQboVendors(IFormFile file, [FromServices] PowderCoating.Web.Services.QuickBooksOnlineService qboService) => await RunQboImport(file, qboService.ImportVendorsAsync, "vendors"); /// /// Imports Products & Services (catalog items) from a QuickBooks Online CSV export file /// via . /// [HttpPost, ValidateAntiForgeryToken] public async Task ImportQboCatalogItems(IFormFile file, [FromServices] PowderCoating.Web.Services.QuickBooksOnlineService qboService) => await RunQboImport(file, qboService.ImportCatalogItemsAsync, "products & services"); /// /// Imports the Chart of Accounts from a QuickBooks Online CSV export file via /// . /// [HttpPost, ValidateAntiForgeryToken] public async Task ImportQboChartOfAccounts(IFormFile file, [FromServices] PowderCoating.Web.Services.QuickBooksOnlineService qboService) => await RunQboImport(file, qboService.ImportChartOfAccountsAsync, "chart of accounts"); /// /// Imports historical invoices from a QuickBooks Online CSV export file via /// . /// [HttpPost, ValidateAntiForgeryToken] public async Task ImportQboInvoices(IFormFile file, [FromServices] PowderCoating.Web.Services.QuickBooksOnlineService qboService) => await RunQboImport(file, qboService.ImportInvoicesAsync, "invoices"); /// /// Imports general-ledger transactions from a QuickBooks Online CSV export file via /// . /// [HttpPost, ValidateAntiForgeryToken] public async Task ImportQboTransactions(IFormFile file, [FromServices] PowderCoating.Web.Services.QuickBooksOnlineService qboService) => await RunQboImport(file, qboService.ImportTransactionsAsync, "transactions"); /// /// Shared execution kernel for all QuickBooks Online CSV import actions. Validates the /// company context and file presence, invokes the provided delegate /// (which is the specific QBO service method for the entity type being imported), and returns /// a consistent JSON response with row-level error detail. The string /// is used only for structured logging so log queries can filter by entity type without /// parsing the file name. All QBO import actions are thin wrappers that delegate here to avoid /// repeating the same validation and JSON shaping code. /// private async Task RunQboImport( IFormFile file, Func> handler, string label) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return Json(new { success = false, message = "Your account is not associated with a company." }); if (file == null || file.Length == 0) return Json(new { success = false, message = "No file provided or file is empty." }); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; _logger.LogInformation("User {UserName} importing QBO {Label} from {FileName} for company {CompanyId}", User.Identity?.Name, label, file.FileName, companyId); var result = await handler(file, companyId.Value, userId); await LogImportAsync(label, file.FileName, result); return Json(new { success = result.Success, message = result.Success ? $"Import completed successfully!" : "Import completed with errors.", totalRecords = result.TotalRecords, importedCount = result.ImportedCount, updatedCount = result.UpdatedCount, skippedCount = result.SkippedCount, errors = result.Errors.Select(e => new { severity = e.Severity, lineNumber = e.LineNumber, recordName = e.RecordName, fieldName = e.FieldName, errorMessage = e.ErrorMessage, displayMessage = e.DisplayMessage }).ToList() }); } catch (Exception ex) { _logger.LogError(ex, "Error importing QBO {Label}", label); return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" }); } } }