Initial commit
This commit is contained in:
@@ -0,0 +1,989 @@
|
||||
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.Infrastructure.Data;
|
||||
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 ApplicationDbContext _context;
|
||||
private readonly ISeedDataService _seedDataService;
|
||||
private readonly ILogger<SetupWizardController> _logger;
|
||||
|
||||
public SetupWizardController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ApplicationDbContext context,
|
||||
ISeedDataService seedDataService,
|
||||
ILogger<SetupWizardController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_userManager = userManager;
|
||||
_context = context;
|
||||
_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 };
|
||||
_context.Set<CompanyPreferences>().Add(company.Preferences);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
if (company.OperatingCosts == null)
|
||||
{
|
||||
company.OperatingCosts = new CompanyOperatingCosts { CompanyId = companyId };
|
||||
_context.Set<CompanyOperatingCosts>().Add(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("Step5", new WizardStep3Dto
|
||||
{
|
||||
QuoteNumberPrefix = prefs.QuoteNumberPrefix,
|
||||
JobNumberPrefix = prefs.JobNumberPrefix,
|
||||
InvoiceNumberPrefix = !string.IsNullOrWhiteSpace(prefs.InvoiceNumberPrefix) ? prefs.InvoiceNumberPrefix : "INV",
|
||||
QtAccentColor = prefs.QtAccentColor,
|
||||
InAccentColor = prefs.InAccentColor,
|
||||
WoAccentColor = prefs.WoAccentColor
|
||||
}),
|
||||
6 => View("Step6", new WizardStep5Dto
|
||||
{
|
||||
DefaultJobPriority = prefs.DefaultJobPriority,
|
||||
RequireCustomerPO = prefs.RequireCustomerPO,
|
||||
AllowCustomerApproval = prefs.AllowCustomerApproval,
|
||||
}),
|
||||
7 => View("Step7", new WizardStep4Dto
|
||||
{
|
||||
DefaultPaymentTerms = prefs.DefaultPaymentTerms,
|
||||
DefaultQuoteValidityDays = prefs.DefaultQuoteValidityDays,
|
||||
DefaultTurnaroundDays = prefs.DefaultTurnaroundDays,
|
||||
QtDefaultTerms = prefs.QtDefaultTerms,
|
||||
QtFooterNote = prefs.QtFooterNote
|
||||
}),
|
||||
8 => await BuildStep8ViewAsync(GetCompanyId()),
|
||||
9 => 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
|
||||
}),
|
||||
10 => await BuildStep10ViewAsync(GetCompanyId()),
|
||||
_ => 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the view model for Step 8 (Pricing Tiers) by loading existing tiers and serializing
|
||||
/// them as camelCase JSON for the client-side tier management table.
|
||||
/// CamelCase serialization is required here because the JavaScript that reads this JSON expects
|
||||
/// camelCase property names (e.g., <c>tierName</c> not <c>TierName</c>), unlike the oven step
|
||||
/// which uses PascalCase — a discrepancy inherited from different JS widget implementations.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> BuildStep8ViewAsync(int companyId)
|
||||
{
|
||||
var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && !t.IsDeleted);
|
||||
var camelCase = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
var dto = new WizardPricingTiersStepDto
|
||||
{
|
||||
TiersJson = existing.Any()
|
||||
? JsonSerializer.Serialize(existing.OrderBy(t => t.Id).Select(t => new WizardPricingTierDto
|
||||
{
|
||||
Id = t.Id,
|
||||
TierName = t.TierName,
|
||||
Description = t.Description,
|
||||
DiscountPercent = t.DiscountPercent
|
||||
}), camelCase)
|
||||
: null
|
||||
};
|
||||
return View("Step8", dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the view model for Step 10 (Team Members) by loading existing non-admin users so they
|
||||
/// can be displayed as read-only in the view.
|
||||
/// Only non-admin company users are shown because the wizard's team-member step is designed for
|
||||
/// adding shop workers and managers; the CompanyAdmin who is running the wizard is already
|
||||
/// implied. Showing existing members prevents the wizard user from accidentally creating
|
||||
/// duplicates of accounts that were added outside the wizard flow.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> BuildStep10ViewAsync(int companyId)
|
||||
{
|
||||
// Load existing non-admin team members so they're shown as read-only in the view
|
||||
var existingUsers = await _userManager.Users
|
||||
.Where(u => u.CompanyId == companyId && u.IsActive
|
||||
&& u.CompanyRole != AppConstants.CompanyRoles.CompanyAdmin)
|
||||
.OrderBy(u => u.LastName).ThenBy(u => u.FirstName)
|
||||
.Select(u => new { u.FirstName, u.LastName, u.Email, u.CompanyRole })
|
||||
.ToListAsync();
|
||||
ViewBag.ExistingTeamMembers = existingUsers;
|
||||
return View("Step10", new WizardStep9Dto());
|
||||
}
|
||||
|
||||
// ─── 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);
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PostStep5(WizardStep3Dto model)
|
||||
{
|
||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 5;
|
||||
|
||||
if (!ModelState.IsValid) return View("Step5", model);
|
||||
|
||||
prefs.QuoteNumberPrefix = model.QuoteNumberPrefix;
|
||||
prefs.JobNumberPrefix = model.JobNumberPrefix;
|
||||
prefs.InvoiceNumberPrefix = model.InvoiceNumberPrefix;
|
||||
prefs.QtAccentColor = model.QtAccentColor;
|
||||
prefs.InAccentColor = model.InAccentColor;
|
||||
prefs.WoAccentColor = model.WoAccentColor;
|
||||
|
||||
MarkDone(prefs, 5);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToStep(6);
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PostStep6(WizardStep5Dto model)
|
||||
{
|
||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 6;
|
||||
|
||||
if (!ModelState.IsValid) return View("Step6", model);
|
||||
|
||||
prefs.DefaultJobPriority = model.DefaultJobPriority;
|
||||
prefs.RequireCustomerPO = model.RequireCustomerPO;
|
||||
prefs.AllowCustomerApproval = model.AllowCustomerApproval;
|
||||
|
||||
MarkDone(prefs, 6);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToStep(7);
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PostStep7(WizardStep4Dto model)
|
||||
{
|
||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 7;
|
||||
|
||||
if (!ModelState.IsValid) return View("Step7", model);
|
||||
|
||||
prefs.DefaultPaymentTerms = model.DefaultPaymentTerms;
|
||||
prefs.DefaultQuoteValidityDays = model.DefaultQuoteValidityDays;
|
||||
prefs.DefaultTurnaroundDays = model.DefaultTurnaroundDays;
|
||||
prefs.QtDefaultTerms = model.QtDefaultTerms;
|
||||
prefs.QtFooterNote = model.QtFooterNote;
|
||||
|
||||
MarkDone(prefs, 7);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToStep(8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists pricing tiers from Step 8, using the same upsert-and-soft-delete pattern as
|
||||
/// <see cref="PostStep4"/>: existing tiers updated in place, new ones inserted, removed ones
|
||||
/// soft-deleted. Tiers with a blank <c>TierName</c> are silently ignored so the client-side
|
||||
/// table's empty placeholder rows do not produce invalid records. JsonException is caught and
|
||||
/// logged rather than thrown so a malformed JSON payload (e.g., from a broken browser extension)
|
||||
/// still advances the wizard rather than stopping the admin from completing setup.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PostStep8(WizardPricingTiersStepDto model)
|
||||
{
|
||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||
var companyId = GetCompanyId();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(model.TiersJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var tiers = JsonSerializer.Deserialize<List<WizardPricingTierDto>>(model.TiersJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
if (tiers != null)
|
||||
{
|
||||
var validTiers = tiers.Where(t => !string.IsNullOrWhiteSpace(t.TierName)).ToList();
|
||||
if (validTiers.Count > 0)
|
||||
{
|
||||
var existing = (await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && !t.IsDeleted))
|
||||
.ToDictionary(t => t.Id);
|
||||
var submittedIds = validTiers.Where(t => t.Id > 0).Select(t => t.Id).ToHashSet();
|
||||
|
||||
// Soft-delete tiers that were removed from the list
|
||||
foreach (var e in existing.Values.Where(e => !submittedIds.Contains(e.Id)))
|
||||
await _unitOfWork.PricingTiers.SoftDeleteAsync(e.Id);
|
||||
|
||||
foreach (var t in validTiers)
|
||||
{
|
||||
if (t.Id > 0 && existing.TryGetValue(t.Id, out var record))
|
||||
{
|
||||
// Update in place
|
||||
record.TierName = t.TierName.Trim();
|
||||
record.Description = t.Description?.Trim();
|
||||
record.DiscountPercent = t.DiscountPercent;
|
||||
await _unitOfWork.PricingTiers.UpdateAsync(record);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _unitOfWork.PricingTiers.AddAsync(new PricingTier
|
||||
{
|
||||
CompanyId = companyId,
|
||||
TierName = t.TierName.Trim(),
|
||||
Description = t.Description?.Trim(),
|
||||
DiscountPercent = t.DiscountPercent,
|
||||
IsActive = true
|
||||
});
|
||||
}
|
||||
}
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize pricing tiers JSON in wizard step 8");
|
||||
}
|
||||
}
|
||||
|
||||
MarkDone(prefs, 8);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToStep(9);
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PostStep9(WizardStep7Dto model)
|
||||
{
|
||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 9;
|
||||
|
||||
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, 9);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToStep(10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates team member accounts from Step 10 (Invite Team), assigns each user a company role,
|
||||
/// and also maps them to the legacy ASP.NET Identity role system for policy-based authorization.
|
||||
/// The dual-role assignment (CompanyRole + Identity role) is required because authorization
|
||||
/// policies in this app evaluate both the legacy role claim and the <c>CompanyRole</c> property.
|
||||
/// Users with emails that already exist are silently skipped so re-submitting the wizard after
|
||||
/// a partial failure does not attempt to create duplicates. Setting <c>SetupWizardCompleted = true</c>
|
||||
/// here hides the wizard prompt from the dashboard going forward.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PostStep10(WizardStep9Dto model)
|
||||
{
|
||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||
var companyId = GetCompanyId();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(model.MembersJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var members = JsonSerializer.Deserialize<List<WizardTeamMemberDto>>(model.MembersJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
if (members != null)
|
||||
{
|
||||
foreach (var m in members.Where(m => !string.IsNullOrWhiteSpace(m.Email)
|
||||
&& !string.IsNullOrWhiteSpace(m.Password)))
|
||||
{
|
||||
var existing = await _userManager.FindByEmailAsync(m.Email);
|
||||
if (existing != null) continue;
|
||||
|
||||
var validRoles = new[] { AppConstants.CompanyRoles.CompanyAdmin, AppConstants.CompanyRoles.Manager, AppConstants.CompanyRoles.Worker, AppConstants.CompanyRoles.Viewer };
|
||||
var companyRole = validRoles.Contains(m.CompanyRole) ? m.CompanyRole : AppConstants.CompanyRoles.Worker;
|
||||
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = m.Email, Email = m.Email, EmailConfirmed = true,
|
||||
FirstName = m.FirstName, LastName = m.LastName,
|
||||
CompanyId = companyId, CompanyRole = companyRole, IsActive = true,
|
||||
CanManageJobs = true, CanManageCustomers = true, CanCreateQuotes = true,
|
||||
CanManageCalendar = true, CanViewCalendar = true, CanViewProducts = true
|
||||
};
|
||||
|
||||
var result = await _userManager.CreateAsync(user, m.Password);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
var legacyRole = companyRole switch
|
||||
{
|
||||
AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator,
|
||||
AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager,
|
||||
AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee,
|
||||
_ => AppConstants.Roles.ReadOnly
|
||||
};
|
||||
await _userManager.AddToRoleAsync(user, legacyRole);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to create wizard user {Email}: {Errors}",
|
||||
m.Email, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize team members JSON in wizard step 10");
|
||||
}
|
||||
}
|
||||
|
||||
MarkDone(prefs, 10);
|
||||
prefs.SetupWizardCompleted = true;
|
||||
|
||||
// Record who completed the wizard and when so SuperAdmins can see completion status per-user.
|
||||
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(nameof(Complete));
|
||||
}
|
||||
|
||||
// ─── 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);
|
||||
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);
|
||||
return View();
|
||||
}
|
||||
}
|
||||
|
||||
public class SaveQbMigrationStateRequest
|
||||
{
|
||||
public string? State { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user