Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/SetupWizardController.cs
T
spouliot 8aae30765f Onboarding overhaul: slim wizard, progress widget, guided activation UX
Setup Wizard: reduced from 10 steps to 5 (Company Info → QB Migration →
Pricing Defaults → Named Ovens → Notifications). Removed Doc Numbering,
Job Settings, Payment Terms, Pricing Tiers, and Team Members steps — these
all have sensible defaults and are accessible any time in Company Settings.
Wizard now completes in ~5 minutes instead of 15–20.

Dashboard progress widget (new): "Get the most out of your shop" checklist
appears for Company Admins after wizard completion. Tracks six post-setup
activation tasks with dynamic progress badge, motivating subtitle copy,
collapsed-state persistence via localStorage, and a full completion state
("Your shop is fully set up 🎉") that replaces the checklist at 100%.
The next recommended step is highlighted with a solid CTA button and a
subtle blue row tint. Completed steps show encouraging green subtext instead
of just "Done". Widget disappears from controller when AllDone would have
caused a silent vanish — now renders the completion state instead.

Guided activation (Daily Board): rewrote the BoardIntroStep callout to lead
with "This is your shop in real time" and a plain-English description of the
board's purpose. Added a separate InstructionText field to
GuidedActivationCalloutViewModel so the "Move this job to the next stage"
action prompt renders as a distinct bold line with an arrow icon rather than
being buried in the body copy. After the stage change, the confirmation
callout now reads "Nice — your workflow just updated" to reinforce what just
happened before prompting the invoice step.

All copy passes the "shop owner, not SaaS" test: no technical jargon,
benefit-driven descriptions, natural language throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:10:47 -04:00

730 lines
36 KiB
C#

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<ApplicationUser> _userManager;
private readonly ISeedDataService _seedDataService;
private readonly ILogger<SetupWizardController> _logger;
public SetupWizardController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager,
ISeedDataService seedDataService,
ILogger<SetupWizardController> logger)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_userManager = userManager;
_seedDataService = seedDataService;
_logger = logger;
}
// ─── Helpers ─────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
private int GetCompanyId()
{
var id = _tenantContext.GetCurrentCompanyId();
if (id == null) throw new InvalidOperationException("No company context.");
return id.Value;
}
/// <summary>
/// Loads the current company together with its <see cref="CompanyPreferences"/> and
/// <see cref="CompanyOperatingCosts"/> 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.
/// </summary>
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);
}
/// <summary>
/// Builds a <see cref="WizardProgressDto"/> from the comma-separated step lists stored on
/// <see cref="CompanyPreferences"/>, which is then passed to <c>ViewBag.Progress</c> 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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));
}
/// <summary>
/// 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.
/// </summary>
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<int> ParseSteps(string? csv) =>
string.IsNullOrWhiteSpace(csv)
? new List<int>()
: csv.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => int.TryParse(s.Trim(), out var n) ? n : 0)
.Where(n => n > 0).ToList();
/// <summary>
/// Redirects the user to the next wizard step, or to the <see cref="Complete"/> 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.
/// </summary>
private IActionResult RedirectToStep(int step)
{
if (step > WizardProgressDto.TotalSteps) return RedirectToAction(nameof(Complete));
return RedirectToAction("Step", new { step });
}
// ─── Launch ───────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 });
}
/// <summary>
/// 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 <c>CompanyName</c> makes the operation idempotent, so re-launching
/// the wizard never creates duplicate vendor rows.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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 ─────────────────────────────────────────────────────────────
/// <summary>
/// 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
/// (<c>/SetupWizard/Step?step=N</c>) 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., <see cref="BuildStep4ViewAsync"/>, <see cref="BuildStep8ViewAsync"/>) for
/// steps that require additional DB queries beyond the base company data.
/// </summary>
[HttpGet]
public async Task<IActionResult> 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) ─────────────────────────────────
/// <summary>
/// Builds the view model for Step 4 (Named Ovens) by loading existing active
/// <see cref="OvenCost"/> 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.
/// </summary>
private async Task<IActionResult> 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 ───────────────────────────────────────────────────────────
/// <summary>
/// Saves company identity and locale preferences from Step 1 (Company Info).
/// <c>PrimaryContactName</c> and <c>PrimaryContactEmail</c> 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.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> PostStep2(WizardStep2QbDto model)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
prefs.MigratingFromQuickBooks = model.MigratingFromQuickBooks;
MarkDone(prefs, 2);
await _unitOfWork.CompleteAsync();
return RedirectToStep(3);
}
/// <summary>
/// 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: <c>OvenOperatingCostPerHour</c> 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.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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);
}
/// <summary>
/// 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, <c>OvenOperatingCostPerHour</c> on <see cref="CompanyOperatingCosts"/> is
/// automatically set to the first oven's cost per hour. This auto-derivation is intentional —
/// the quote pricing engine uses <c>OvenOperatingCostPerHour</c> 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.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep4(WizardOvensStepDto model)
{
var (_, prefs, costs) = await LoadCompanyDataAsync();
var companyId = GetCompanyId();
if (!string.IsNullOrWhiteSpace(model.OvensJson))
{
try
{
var ovens = JsonSerializer.Deserialize<List<WizardOvenDto>>(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<List<WizardBlastSetupDto>>(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);
}
/// <summary>
/// Saves notification preferences from Step 5 (the final step). Marks the wizard complete
/// and hands off to the Guided Activation flow.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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 ─────────────────────────────────────────────────────────────────
/// <summary>
/// Marks the given step as skipped and advances to the next step, or to the
/// <see cref="Complete"/> 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 <see cref="MarkSkipped"/> — so the admin cannot accidentally
/// uncheck progress by using the browser's back button then clicking Skip.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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 ────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetQbMigrationState()
{
var (_, prefs, _) = await LoadCompanyDataAsync();
return Json(new { state = prefs.QbMigrationStateJson });
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
public async Task<IActionResult> SaveQbMigrationState([FromBody] SaveQbMigrationStateRequest request)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
prefs.QbMigrationStateJson = request.State;
await _unitOfWork.CompleteAsync();
return Json(new { ok = true });
}
// ─── Complete ─────────────────────────────────────────────────────────────
/// <summary>
/// Renders the wizard completion/summary page after all steps have been submitted or skipped.
/// Progress data is passed to the view via <c>ViewBag.Progress</c> 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.
/// </summary>
[HttpGet]
public async Task<IActionResult> 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; }
}