8aae30765f
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>
730 lines
36 KiB
C#
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; }
|
|
}
|