using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.Wizard; using PowderCoating.Application.Interfaces; using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; using System.Text.Json; namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public class SetupWizardController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly ITenantContext _tenantContext; private readonly UserManager _userManager; private readonly ISeedDataService _seedDataService; private readonly ILogger _logger; public SetupWizardController( IUnitOfWork unitOfWork, ITenantContext tenantContext, UserManager userManager, ISeedDataService seedDataService, ILogger logger) { _unitOfWork = unitOfWork; _tenantContext = tenantContext; _userManager = userManager; _seedDataService = seedDataService; _logger = logger; } // ─── Helpers ───────────────────────────────────────────────────────────── /// /// Returns the current tenant's company ID, throwing if the tenant context is unavailable. /// All wizard actions are scoped to a single company, so failing fast here prevents any wizard /// step from silently operating on the wrong (or no) tenant. /// private int GetCompanyId() { var id = _tenantContext.GetCurrentCompanyId(); if (id == null) throw new InvalidOperationException("No company context."); return id.Value; } /// /// Loads the current company together with its and /// child records, creating them if they do not yet exist. /// The auto-creation logic exists because new companies seeded by the registration flow may not /// have had these child rows created yet; rather than crash or show blank forms, the wizard /// bootstraps them on first access so every step always has a writable record to update. /// private async Task<(Company company, CompanyPreferences prefs, CompanyOperatingCosts costs)> LoadCompanyDataAsync() { var companyId = GetCompanyId(); var company = await _unitOfWork.Companies.GetByIdAsync(companyId, false, c => c.Preferences, c => c.OperatingCosts) ?? throw new InvalidOperationException("Company not found."); if (company.Preferences == null) { company.Preferences = new CompanyPreferences { CompanyId = companyId }; await _unitOfWork.CompanyPreferences.AddAsync(company.Preferences); await _unitOfWork.CompleteAsync(); } if (company.OperatingCosts == null) { company.OperatingCosts = new CompanyOperatingCosts { CompanyId = companyId }; await _unitOfWork.CompanyOperatingCosts.AddAsync(company.OperatingCosts); await _unitOfWork.CompleteAsync(); } return (company, company.Preferences, company.OperatingCosts); } /// /// Builds a from the comma-separated step lists stored on /// , which is then passed to ViewBag.Progress so the /// Razor layout can render the step indicator. Done and skipped steps are stored as CSV strings /// (not a bitmask or JSON array) to remain human-readable and easily editable in the database /// if a support engineer ever needs to reset a specific step. /// private WizardProgressDto BuildProgress(CompanyPreferences prefs) { var progress = new WizardProgressDto { Started = prefs.SetupWizardStarted, Completed = prefs.SetupWizardCompleted, }; if (!string.IsNullOrWhiteSpace(prefs.SetupWizardDoneSteps)) progress.DoneSteps = prefs.SetupWizardDoneSteps.Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(s => int.TryParse(s.Trim(), out var n) ? n : 0).Where(n => n > 0).ToList(); if (!string.IsNullOrWhiteSpace(prefs.SetupWizardSkippedSteps)) progress.SkippedSteps = prefs.SetupWizardSkippedSteps.Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(s => int.TryParse(s.Trim(), out var n) ? n : 0).Where(n => n > 0).ToList(); return progress; } /// /// Marks a wizard step as completed and removes it from the skipped list if it was previously /// skipped. The dual-list update ensures a step cannot be simultaneously "done" and "skipped", /// which would produce inconsistent progress-indicator rendering. Steps are deduplicated and /// sorted before saving so re-submitting a step is safe and idempotent. /// private void MarkDone(CompanyPreferences prefs, int step) { var done = ParseSteps(prefs.SetupWizardDoneSteps); done.Add(step); prefs.SetupWizardDoneSteps = string.Join(",", done.Distinct().OrderBy(n => n)); var skipped = ParseSteps(prefs.SetupWizardSkippedSteps); skipped.Remove(step); prefs.SetupWizardSkippedSteps = string.Join(",", skipped.Distinct().OrderBy(n => n)); } /// /// Marks a wizard step as skipped unless it has already been completed, in which case the skip /// is silently ignored. This guard prevents a "Back + Skip" navigation pattern from downgrading /// a completed step to skipped, which would incorrectly strip the checkmark from the progress bar. /// private void MarkSkipped(CompanyPreferences prefs, int step) { var done = ParseSteps(prefs.SetupWizardDoneSteps); if (done.Contains(step)) return; var skipped = ParseSteps(prefs.SetupWizardSkippedSteps); skipped.Add(step); prefs.SetupWizardSkippedSteps = string.Join(",", skipped.Distinct().OrderBy(n => n)); } private static List ParseSteps(string? csv) => string.IsNullOrWhiteSpace(csv) ? new List() : csv.Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(s => int.TryParse(s.Trim(), out var n) ? n : 0) .Where(n => n > 0).ToList(); /// /// Redirects the user to the next wizard step, or to the page when all /// steps have been processed. Centralizing this logic prevents individual POST handlers from /// each hard-coding a step-number boundary check, making it easy to add or remove steps later. /// private IActionResult RedirectToStep(int step) { if (step > WizardProgressDto.TotalSteps) return RedirectToAction(nameof(Complete)); return RedirectToAction("Step", new { step }); } // ─── Launch ─────────────────────────────────────────────────────────────── /// /// Starts the setup wizard for the current company, seeding lookup tables, default vendors, /// and a default catalog category if this is the first time the wizard is launched. /// Seeding is intentionally non-fatal — if it fails (e.g., transient DB error), the wizard /// still opens so the admin is not blocked from configuring their company. Any existing /// step-progress is cleared on first launch to avoid stale data from a previous wizard version /// confusing the progress indicator. Seed operations are idempotent, so re-launching an /// already-started wizard skips seeding entirely. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Launch() { var companyId = GetCompanyId(); var (_, prefs, _) = await LoadCompanyDataAsync(); if (!prefs.SetupWizardStarted) { try { // Seed all lookup tables + chart of accounts (idempotent) await _seedDataService.SeedCompanyLookupsAsync(companyId); // Seed default vendors await SeedDefaultVendorsAsync(companyId); // Seed default catalog category await SeedDefaultCatalogCategoryAsync(companyId); } catch (Exception ex) { _logger.LogError(ex, "Error seeding defaults during wizard launch for company {CompanyId}", companyId); // Non-fatal — wizard can still proceed } prefs.SetupWizardStarted = true; // Clear any stale step progress from a previous wizard version prefs.SetupWizardDoneSteps = null; prefs.SetupWizardSkippedSteps = null; await _unitOfWork.CompleteAsync(); } return RedirectToAction("Step", new { step = 1 }); } /// /// Seeds well-known powder coat suppliers as vendor records for the new company if they do not /// already exist. These vendors (Prismatic Powders, Columbia Coatings) are pre-populated as a /// convenience for common supplier setups; the admin can delete or rename them later. /// The existence check by CompanyName makes the operation idempotent, so re-launching /// the wizard never creates duplicate vendor rows. /// private async Task SeedDefaultVendorsAsync(int companyId) { var defaults = new[] { new { CompanyName = "Prismatic Powders", Website = "https://www.prismaticpowders.com" }, new { CompanyName = "Columbia Coatings", Website = "https://www.columbiacoatings.com" }, }; foreach (var v in defaults) { var exists = (await _unitOfWork.Vendors.FindAsync( x => x.CompanyId == companyId && x.CompanyName == v.CompanyName)).Any(); if (exists) continue; await _unitOfWork.Vendors.AddAsync(new Vendor { CompanyId = companyId, CompanyName = v.CompanyName, Website = v.Website, IsActive = true, Country = "USA" }); } await _unitOfWork.CompleteAsync(); } /// /// Creates a "General Services" catalog category for the company if one does not yet exist. /// Catalog items require a category, so at least one must exist before an admin can add priced /// services. This default category is intentionally generic so it works for any powder coating /// shop configuration without requiring the admin to create one before they can use the catalog. /// private async Task SeedDefaultCatalogCategoryAsync(int companyId) { var exists = (await _unitOfWork.CatalogCategories.FindAsync( c => c.CompanyId == companyId && c.Name == "General Services")).Any(); if (exists) return; await _unitOfWork.CatalogCategories.AddAsync(new CatalogCategory { CompanyId = companyId, Name = "General Services", Description = "Default category created during setup" }); await _unitOfWork.CompleteAsync(); } // ─── GET Step ───────────────────────────────────────────────────────────── /// /// Serves the view for the requested wizard step, pre-populated with the company's existing data. /// A single action handles all 10 steps via a switch expression so the URL pattern stays uniform /// (/SetupWizard/Step?step=N) and shared progress-bar logic runs in one place. /// Out-of-range step numbers redirect to step 1 rather than returning a 404 so direct URL /// manipulation by the user degrades gracefully. Each branch delegates to a dedicated /// builder (e.g., , ) for /// steps that require additional DB queries beyond the base company data. /// [HttpGet] public async Task Step(int step = 1) { if (step < 1 || step > WizardProgressDto.TotalSteps) return RedirectToAction("Step", new { step = 1 }); try { var (company, prefs, costs) = await LoadCompanyDataAsync(); var progress = BuildProgress(prefs); ViewBag.Progress = progress; ViewBag.Step = step; return step switch { 1 => View("Step1", new WizardStep1Dto { CompanyName = company.CompanyName, PrimaryContactName = company.PrimaryContactName, PrimaryContactEmail = company.PrimaryContactEmail, Phone = company.Phone, Address = company.Address, City = company.City, State = company.State, ZipCode = company.ZipCode, TimeZone = company.TimeZone, DefaultCurrency = prefs.DefaultCurrency, UseMetricSystem = prefs.UseMetricSystem }), 2 => View("Step2", new WizardStep2QbDto { MigratingFromQuickBooks = prefs.MigratingFromQuickBooks }), 3 => View("Step3", new WizardStep2Dto { StandardLaborRate = costs.StandardLaborRate, SandblasterCostPerHour = costs.SandblasterCostPerHour, CoatingBoothCostPerHour = costs.CoatingBoothCostPerHour, OvenOperatingCostPerHour = costs.OvenOperatingCostPerHour, PowderCoatingCostPerSqFt = costs.PowderCoatingCostPerSqFt, GeneralMarkupPercentage = costs.GeneralMarkupPercentage, TaxPercent = costs.TaxPercent, ShopMinimumCharge = costs.ShopMinimumCharge, ShopCapabilityTier = costs.ShopCapabilityTier }), 4 => await BuildStep4ViewAsync(GetCompanyId()), 5 => View("Step9", new WizardStep7Dto { EmailNotificationsEnabled = prefs.EmailNotificationsEnabled, EmailFromAddress = prefs.EmailFromAddress, EmailFromName = prefs.EmailFromName, NotifyOnNewJob = prefs.NotifyOnNewJob, NotifyOnNewQuote = prefs.NotifyOnNewQuote, NotifyOnJobStatusChange = prefs.NotifyOnJobStatusChange, NotifyOnQuoteApproval = prefs.NotifyOnQuoteApproval, NotifyOnPaymentReceived = prefs.NotifyOnPaymentReceived, PaymentRemindersEnabled = prefs.PaymentRemindersEnabled, PaymentReminderDays = prefs.PaymentReminderDays, QuoteExpiryWarningDays = prefs.QuoteExpiryWarningDays, DueDateWarningDays = prefs.DueDateWarningDays, MaintenanceAlertDays = prefs.MaintenanceAlertDays }), _ => RedirectToAction("Step", new { step = 1 }) }; } catch (Exception ex) { _logger.LogError(ex, "Error loading wizard step {Step}", step); TempData["Error"] = "An error occurred loading this step."; return RedirectToAction("Step", new { step = 1 }); } } // ─── GET helpers (preload existing data) ───────────────────────────────── /// /// Builds the view model for Step 4 (Named Ovens) by loading existing active /// records and serializing them to JSON for the client-side /// oven management table. Passing existing ovens as JSON lets the wizard re-use a single /// view template for both first-time setup and subsequent edits without any server-side /// conditional rendering. /// private async Task BuildStep4ViewAsync(int companyId) { var existingOvens = await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == companyId && !o.IsDeleted && o.IsActive); var existingBlasts = await _unitOfWork.BlastSetups.FindAsync(b => b.CompanyId == companyId && !b.IsDeleted && b.IsActive); var dto = new WizardOvensStepDto { OvensJson = existingOvens.Any() ? JsonSerializer.Serialize(existingOvens.OrderBy(o => o.DisplayOrder).Select(o => new WizardOvenDto { Id = o.Id, Label = o.Label, CostPerHour = o.CostPerHour, MaxLoadSqFt = o.MaxLoadSqFt, DefaultCycleMinutes = o.DefaultCycleMinutes })) : null, BlastSetupsJson = existingBlasts.Any() ? JsonSerializer.Serialize(existingBlasts.OrderBy(b => b.DisplayOrder).Select(b => new WizardBlastSetupDto { Id = b.Id, Name = b.Name, SetupType = (int)b.SetupType, CompressorCfm = b.CompressorCfm, BlastNozzleSize = b.BlastNozzleSize, PrimarySubstrate = (int)b.PrimarySubstrate, BlastRateSqFtPerHourOverride = b.BlastRateSqFtPerHourOverride, IsDefault = b.IsDefault })) : null }; return View("Step4", dto); } // ─── POST Steps ─────────────────────────────────────────────────────────── /// /// Saves company identity and locale preferences from Step 1 (Company Info). /// PrimaryContactName and PrimaryContactEmail use null-coalescing to preserve /// existing values when the form fields are left blank — the wizard UI shows them as optional, /// so blanking them out would silently erase data that may have been set during registration. /// [HttpPost, ValidateAntiForgeryToken] public async Task PostStep1(WizardStep1Dto model) { var (company, prefs, _) = await LoadCompanyDataAsync(); ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 1; if (!ModelState.IsValid) return View("Step1", model); company.CompanyName = model.CompanyName; company.PrimaryContactName = model.PrimaryContactName ?? company.PrimaryContactName; company.PrimaryContactEmail = model.PrimaryContactEmail ?? company.PrimaryContactEmail; company.Phone = model.Phone; company.Address = model.Address; company.City = model.City; company.State = model.State; company.ZipCode = model.ZipCode; company.TimeZone = model.TimeZone; prefs.DefaultCurrency = model.DefaultCurrency; prefs.UseMetricSystem = model.UseMetricSystem; MarkDone(prefs, 1); await _unitOfWork.CompleteAsync(); return RedirectToStep(2); } [HttpPost, ValidateAntiForgeryToken] public async Task PostStep2(WizardStep2QbDto model) { var (_, prefs, _) = await LoadCompanyDataAsync(); prefs.MigratingFromQuickBooks = model.MigratingFromQuickBooks; MarkDone(prefs, 2); await _unitOfWork.CompleteAsync(); return RedirectToStep(3); } /// /// Saves base operating cost rates from Step 3 (Pricing Defaults) including labor, sandblasting, /// booth, oven, powder cost per sq ft, markup, tax, and shop minimum. /// Note: OvenOperatingCostPerHour is also updated here as a direct user entry, but Step 4 /// (Named Ovens) will overwrite it with the first named oven's cost per hour once that step is /// saved. Step 3 is kept as a fallback for shops that do not configure named ovens at all. /// [HttpPost, ValidateAntiForgeryToken] public async Task PostStep3(WizardStep2Dto model) { var (_, prefs, costs) = await LoadCompanyDataAsync(); ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 3; if (!ModelState.IsValid) return View("Step3", model); costs.StandardLaborRate = model.StandardLaborRate; costs.SandblasterCostPerHour = model.SandblasterCostPerHour; costs.CoatingBoothCostPerHour = model.CoatingBoothCostPerHour; costs.OvenOperatingCostPerHour = model.OvenOperatingCostPerHour; costs.PowderCoatingCostPerSqFt = model.PowderCoatingCostPerSqFt; costs.GeneralMarkupPercentage = model.GeneralMarkupPercentage; costs.TaxPercent = model.TaxPercent; costs.ShopMinimumCharge = model.ShopMinimumCharge; // Apply capability tier defaults so quoting calibration has a sensible starting point. // The shop can refine these later in Company Settings → Quoting Calibration. costs.ShopCapabilityTier = model.ShopCapabilityTier; var (setupType, cfm, nozzle, substrate) = ShopCapabilityCalculator.TierDefaults(model.ShopCapabilityTier); costs.BlastSetupType = setupType; costs.CompressorCfm = cfm; costs.BlastNozzleSize = nozzle; costs.PrimaryBlastSubstrate = substrate; MarkDone(prefs, 3); await _unitOfWork.CompleteAsync(); return RedirectToStep(4); } /// /// Persists named ovens from Step 4, performing a full upsert: existing ovens are updated in /// place, new ovens are inserted, and any oven omitted from the submitted list is soft-deleted. /// After saving, OvenOperatingCostPerHour on is /// automatically set to the first oven's cost per hour. This auto-derivation is intentional — /// the quote pricing engine uses OvenOperatingCostPerHour as a single flat rate, so it /// must always reflect a real oven cost. The first oven in display order is used as the most /// representative default. Admins can later edit the value directly in Company Settings. /// [HttpPost, ValidateAntiForgeryToken] public async Task PostStep4(WizardOvensStepDto model) { var (_, prefs, costs) = await LoadCompanyDataAsync(); var companyId = GetCompanyId(); if (!string.IsNullOrWhiteSpace(model.OvensJson)) { try { var ovens = JsonSerializer.Deserialize>(model.OvensJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); var validOvens = ovens?.Where(o => !string.IsNullOrWhiteSpace(o.Label)).ToList(); if (validOvens != null && validOvens.Count > 0) { var existing = (await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == companyId && !o.IsDeleted)) .ToDictionary(o => o.Id); var submittedIds = validOvens.Where(o => o.Id > 0).Select(o => o.Id).ToHashSet(); // Soft-delete ovens that were removed from the list foreach (var e in existing.Values.Where(e => !submittedIds.Contains(e.Id))) await _unitOfWork.OvenCosts.SoftDeleteAsync(e.Id); int order = 1; foreach (var o in validOvens) { if (o.Id > 0 && existing.TryGetValue(o.Id, out var record)) { // Update in place record.Label = o.Label.Trim(); record.CostPerHour = o.CostPerHour; record.MaxLoadSqFt = o.MaxLoadSqFt; record.DefaultCycleMinutes = o.DefaultCycleMinutes; record.DisplayOrder = order++; await _unitOfWork.OvenCosts.UpdateAsync(record); } else { await _unitOfWork.OvenCosts.AddAsync(new OvenCost { CompanyId = companyId, Label = o.Label.Trim(), CostPerHour = o.CostPerHour, MaxLoadSqFt = o.MaxLoadSqFt, DefaultCycleMinutes = o.DefaultCycleMinutes, IsActive = true, DisplayOrder = order++ }); } } await _unitOfWork.CompleteAsync(); // Use the first oven's cost as the fallback rate for quotes with no oven selected costs.OvenOperatingCostPerHour = validOvens[0].CostPerHour; await _unitOfWork.CompleteAsync(); } } catch (JsonException ex) { _logger.LogWarning(ex, "Failed to deserialize ovens JSON in wizard step 4"); } } if (!string.IsNullOrWhiteSpace(model.BlastSetupsJson)) { try { var blasts = JsonSerializer.Deserialize>(model.BlastSetupsJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); var validBlasts = blasts?.Where(b => !string.IsNullOrWhiteSpace(b.Name)).ToList(); if (validBlasts != null && validBlasts.Count > 0) { var existing = (await _unitOfWork.BlastSetups.FindAsync(b => b.CompanyId == companyId && !b.IsDeleted)) .ToDictionary(b => b.Id); var submittedIds = validBlasts.Where(b => b.Id > 0).Select(b => b.Id).ToHashSet(); foreach (var e in existing.Values.Where(e => !submittedIds.Contains(e.Id))) await _unitOfWork.BlastSetups.SoftDeleteAsync(e.Id); // Ensure exactly one default — last one flagged wins if multiple submitted var defaultIdx = validBlasts.FindLastIndex(b => b.IsDefault); if (defaultIdx < 0) defaultIdx = 0; int order = 1; for (int i = 0; i < validBlasts.Count; i++) { var b = validBlasts[i]; bool isDefault = i == defaultIdx; if (b.Id > 0 && existing.TryGetValue(b.Id, out var record)) { record.Name = b.Name.Trim(); record.SetupType = (BlastSetupType)b.SetupType; record.CompressorCfm = b.CompressorCfm; record.BlastNozzleSize = b.BlastNozzleSize; record.PrimarySubstrate = (BlastSubstrateType)b.PrimarySubstrate; record.BlastRateSqFtPerHourOverride = b.BlastRateSqFtPerHourOverride; record.IsDefault = isDefault; record.DisplayOrder = order++; await _unitOfWork.BlastSetups.UpdateAsync(record); } else { await _unitOfWork.BlastSetups.AddAsync(new CompanyBlastSetup { CompanyId = companyId, Name = b.Name.Trim(), SetupType = (BlastSetupType)b.SetupType, CompressorCfm = b.CompressorCfm, BlastNozzleSize = b.BlastNozzleSize, PrimarySubstrate = (BlastSubstrateType)b.PrimarySubstrate, BlastRateSqFtPerHourOverride = b.BlastRateSqFtPerHourOverride, IsDefault = isDefault, IsActive = true, DisplayOrder = order++ }); } } await _unitOfWork.CompleteAsync(); } } catch (JsonException ex) { _logger.LogWarning(ex, "Failed to deserialize blast setups JSON in wizard step 4"); } } MarkDone(prefs, 4); await _unitOfWork.CompleteAsync(); return RedirectToStep(5); } /// /// Saves notification preferences from Step 5 (the final step). Marks the wizard complete /// and hands off to the Guided Activation flow. /// [HttpPost, ValidateAntiForgeryToken] public async Task PostStep9(WizardStep7Dto model) { var (_, prefs, _) = await LoadCompanyDataAsync(); ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 5; if (!ModelState.IsValid) return View("Step9", model); prefs.EmailNotificationsEnabled = model.EmailNotificationsEnabled; prefs.EmailFromAddress = model.EmailFromAddress; prefs.EmailFromName = model.EmailFromName; prefs.NotifyOnNewJob = model.NotifyOnNewJob; prefs.NotifyOnNewQuote = model.NotifyOnNewQuote; prefs.NotifyOnJobStatusChange = model.NotifyOnJobStatusChange; prefs.NotifyOnQuoteApproval = model.NotifyOnQuoteApproval; prefs.NotifyOnPaymentReceived = model.NotifyOnPaymentReceived; prefs.PaymentRemindersEnabled = model.PaymentRemindersEnabled; prefs.PaymentReminderDays = model.PaymentReminderDays; prefs.QuoteExpiryWarningDays = model.QuoteExpiryWarningDays; prefs.DueDateWarningDays = model.DueDateWarningDays; prefs.MaintenanceAlertDays = model.MaintenanceAlertDays; MarkDone(prefs, 5); prefs.SetupWizardCompleted = true; var currentUser = await _userManager.GetUserAsync(User); prefs.SetupWizardCompletedAt = DateTime.UtcNow; prefs.SetupWizardCompletedByUserId = currentUser?.Id; prefs.SetupWizardCompletedByName = currentUser != null ? $"{currentUser.FirstName} {currentUser.LastName}".Trim() : User.Identity?.Name; await _unitOfWork.CompleteAsync(); return RedirectToAction("Start", "GuidedActivation"); } // ─── Skip ───────────────────────────────────────────────────────────────── /// /// Marks the given step as skipped and advances to the next step, or to the /// page when skipping the final step. /// Skipped steps are tracked separately from done steps so the progress bar can display them /// with a distinct visual state ("skipped" vs "completed"). A step that was already done /// cannot be skipped — see — so the admin cannot accidentally /// uncheck progress by using the browser's back button then clicking Skip. /// [HttpPost, ValidateAntiForgeryToken] public async Task Skip(int step) { var (_, prefs, _) = await LoadCompanyDataAsync(); MarkSkipped(prefs, step); if (step >= WizardProgressDto.TotalSteps) { prefs.SetupWizardCompleted = true; var currentUser = await _userManager.GetUserAsync(User); prefs.SetupWizardCompletedAt = DateTime.UtcNow; prefs.SetupWizardCompletedByUserId = currentUser?.Id; prefs.SetupWizardCompletedByName = currentUser != null ? $"{currentUser.FirstName} {currentUser.LastName}".Trim() : User.Identity?.Name; } await _unitOfWork.CompleteAsync(); int next = step >= WizardProgressDto.TotalSteps ? 0 : step + 1; return RedirectToStep(next == 0 ? WizardProgressDto.TotalSteps + 1 : next); } // ─── QB Migration Wizard State ──────────────────────────────────────────── /// /// Returns the serialized QuickBooks migration wizard state JSON for the current company, /// allowing the client-side QB migration UI to restore its multi-step state across page /// refreshes. The state blob is opaque to the server — it is stored and returned verbatim. /// [HttpGet] public async Task GetQbMigrationState() { var (_, prefs, _) = await LoadCompanyDataAsync(); return Json(new { state = prefs.QbMigrationStateJson }); } /// /// Persists an arbitrary QuickBooks migration wizard state JSON blob provided by the client. /// The server treats this as an opaque string and does not validate its contents — the QB /// migration UI owns the schema. Storing state server-side (rather than in sessionStorage) /// allows the admin to close the browser and resume the QB migration later without losing /// progress. /// [HttpPost] public async Task SaveQbMigrationState([FromBody] SaveQbMigrationStateRequest request) { var (_, prefs, _) = await LoadCompanyDataAsync(); prefs.QbMigrationStateJson = request.State; await _unitOfWork.CompleteAsync(); return Json(new { ok = true }); } // ─── Complete ───────────────────────────────────────────────────────────── /// /// Renders the wizard completion/summary page after all steps have been submitted or skipped. /// Progress data is passed to the view via ViewBag.Progress so the page can display /// a summary of which steps were completed vs skipped, giving the admin a clear picture of /// any configuration they may want to revisit later. /// [HttpGet] public async Task Complete() { var (_, prefs, _) = await LoadCompanyDataAsync(); ViewBag.Progress = BuildProgress(prefs); ViewBag.ShowGuidedActivationCta = prefs.SetupWizardCompleted && !prefs.FirstWorkflowCompleted; return View(); } } public class SaveQbMigrationStateRequest { public string? State { get; set; } }