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>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Dashboard;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
@@ -7,6 +9,8 @@ using PowderCoating.Core.Enums; // Still needed for MaintenanceStatus, Equipment
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Core.Interfaces.Services;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.ViewModels.Dashboard;
|
||||
using PowderCoating.Web.ViewModels.GuidedActivation;
|
||||
using InvoiceStatus = PowderCoating.Core.Enums.InvoiceStatus;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -19,6 +23,7 @@ public class DashboardController : Controller
|
||||
private readonly IDashboardReadService _dashboardRead;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ICompanyConfigHealthService _configHealth;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
private static readonly string[] CompletedStatusCodes =
|
||||
[
|
||||
@@ -45,13 +50,15 @@ public class DashboardController : Controller
|
||||
ILogger<DashboardController> logger,
|
||||
IDashboardReadService dashboardRead,
|
||||
ITenantContext tenantContext,
|
||||
ICompanyConfigHealthService configHealth)
|
||||
ICompanyConfigHealthService configHealth,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
_dashboardRead = dashboardRead;
|
||||
_tenantContext = tenantContext;
|
||||
_configHealth = configHealth;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -564,8 +571,17 @@ public class DashboardController : Controller
|
||||
// Config health check — surface setup gaps to company admins
|
||||
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (currentCompanyId.HasValue)
|
||||
{
|
||||
ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value);
|
||||
|
||||
// Load prefs once and share between both banner and progress widget builders
|
||||
var companyPrefs = await _unitOfWork.CompanyPreferences
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == currentCompanyId.Value && !p.IsDeleted);
|
||||
|
||||
ViewBag.GuidedActivationBanner = BuildGuidedActivationBanner(companyPrefs);
|
||||
ViewBag.ShopProgressWidget = await BuildShopProgressWidgetAsync(currentCompanyId.Value, companyPrefs);
|
||||
}
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -636,6 +652,120 @@ public class DashboardController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
private GuidedActivationBannerViewModel? BuildGuidedActivationBanner(CompanyPreferences? prefs)
|
||||
{
|
||||
var companyRole = User.FindFirst("CompanyRole")?.Value;
|
||||
if (companyRole != AppConstants.CompanyRoles.CompanyAdmin)
|
||||
return null;
|
||||
|
||||
if (prefs == null || !prefs.SetupWizardCompleted || prefs.FirstWorkflowCompleted)
|
||||
return null;
|
||||
|
||||
return new GuidedActivationBannerViewModel
|
||||
{
|
||||
Show = true,
|
||||
IsDismissed = prefs.GuidedActivationDismissedAt.HasValue,
|
||||
Title = prefs.GuidedActivationDismissedAt.HasValue
|
||||
? "Start your first workflow when you're ready"
|
||||
: "Create your first job or quote",
|
||||
Message = prefs.GuidedActivationDismissedAt.HasValue
|
||||
? "You can come back anytime to run a short walkthrough using real quotes, jobs, and invoices."
|
||||
: "Run a quick 2-minute workflow to see how the system works.",
|
||||
ActionText = "Start first workflow"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the "Get the most out of your shop" activation checklist for CompanyAdmins.
|
||||
/// Returns null when the wizard is not yet complete, the viewer is not a CompanyAdmin,
|
||||
/// or all six tasks are already done (so the widget disappears naturally at 100%).
|
||||
/// Three DB checks are fired in parallel to keep the overhead to a minimum.
|
||||
/// </summary>
|
||||
private async Task<ShopProgressWidgetViewModel?> BuildShopProgressWidgetAsync(int companyId, CompanyPreferences? prefs)
|
||||
{
|
||||
var companyRole = User.FindFirst("CompanyRole")?.Value;
|
||||
if (companyRole != AppConstants.CompanyRoles.CompanyAdmin)
|
||||
return null;
|
||||
|
||||
if (prefs == null || !prefs.SetupWizardCompleted)
|
||||
return null;
|
||||
|
||||
// These share the same scoped DbContext so must run sequentially
|
||||
var hasStatusHistory = await _unitOfWork.JobStatusHistory.AnyAsync(_ => true);
|
||||
var hasCustomizedLookups = await _unitOfWork.JobStatusLookups.AnyAsync(j => j.UpdatedAt != null);
|
||||
var teamCount = await _userManager.Users
|
||||
.CountAsync(u => u.CompanyId == companyId && u.IsActive && !u.IsBanned);
|
||||
|
||||
var items = new List<ShopProgressItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Done = prefs.FirstJobCreatedAt.HasValue || prefs.FirstQuoteCreatedAt.HasValue,
|
||||
Label = "Create your first job or quote",
|
||||
SubLabel = "Get customer sign-off before you start — takes about 2 minutes.",
|
||||
DoneSubLabel = "Your first job is now being tracked.",
|
||||
Icon = "bi-file-earmark-plus",
|
||||
CtaText = "Create a quote",
|
||||
CtaUrl = Url.Action("Start", "GuidedActivation")!
|
||||
},
|
||||
new()
|
||||
{
|
||||
Done = hasStatusHistory,
|
||||
Label = "Move a job through your workflow",
|
||||
SubLabel = "Move a job through your board so your crew always knows what's next.",
|
||||
DoneSubLabel = "You've started tracking work through your shop.",
|
||||
Icon = "bi-arrow-right-circle",
|
||||
CtaText = "Go to jobs board",
|
||||
CtaUrl = Url.Action("Board", "Jobs")!
|
||||
},
|
||||
new()
|
||||
{
|
||||
Done = prefs.FirstInvoiceCreatedAt.HasValue,
|
||||
Label = "Send your first invoice",
|
||||
SubLabel = "When the work is done, turn it into an invoice and send it in seconds.",
|
||||
DoneSubLabel = "You're ready to get paid.",
|
||||
Icon = "bi-receipt",
|
||||
CtaText = "Create invoice",
|
||||
CtaUrl = Url.Action("Create", "Invoices")!
|
||||
},
|
||||
new()
|
||||
{
|
||||
Done = teamCount > 1,
|
||||
Label = "Bring your crew in",
|
||||
SubLabel = "Add your crew so everyone stays on the same page in real time.",
|
||||
DoneSubLabel = "Your team is in the system.",
|
||||
Icon = "bi-people",
|
||||
CtaText = "Invite team",
|
||||
CtaUrl = Url.Action("Index", "CompanyUsers")!
|
||||
},
|
||||
new()
|
||||
{
|
||||
Done = hasCustomizedLookups,
|
||||
Label = "Customize your workflow",
|
||||
SubLabel = "Adjust stages and services to match how your shop runs.",
|
||||
DoneSubLabel = "Your workflow speaks your shop's language.",
|
||||
Icon = "bi-list-ul",
|
||||
CtaText = "Customize workflow",
|
||||
CtaUrl = Url.Action("Index", "CompanySettings") + "#data-lookups"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Done = prefs.DefaultPaymentTerms != "Net 30"
|
||||
|| prefs.DefaultQuoteValidityDays != 30
|
||||
|| prefs.DefaultTurnaroundDays != 7
|
||||
|| prefs.QtDefaultTerms != null,
|
||||
Label = "Set how you get paid",
|
||||
SubLabel = "Set your payment terms and timing so every job goes out right.",
|
||||
DoneSubLabel = "Your payment defaults are locked in.",
|
||||
Icon = "bi-file-earmark-text",
|
||||
CtaText = "Set payment terms",
|
||||
CtaUrl = Url.Action("Index", "CompanySettings") + "#general"
|
||||
}
|
||||
};
|
||||
|
||||
return new ShopProgressWidgetViewModel { Items = items };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records receipt of a powder shipment against an existing powder order. Sets
|
||||
/// <c>PowderReceived</c>, <c>PowderReceivedLbs</c>, and <c>PowderReceivedAt</c> on the coat,
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.ViewModels.GuidedActivation;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public class GuidedActivationController : Controller
|
||||
{
|
||||
private const string SampleCustomerName = "Sample Customer";
|
||||
private const string SampleCustomerMarker = "Guided activation sample customer";
|
||||
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ILogger<GuidedActivationController> _logger;
|
||||
|
||||
public GuidedActivationController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<GuidedActivationController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Start()
|
||||
{
|
||||
var prefs = await LoadPreferencesAsync();
|
||||
if (!prefs.SetupWizardCompleted)
|
||||
return RedirectToAction("Step", "SetupWizard", new { step = 1 });
|
||||
|
||||
if (prefs.FirstWorkflowCompleted)
|
||||
return RedirectToAction("Index", "Dashboard");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(prefs.OnboardingPath))
|
||||
return RedirectToAction(nameof(Select));
|
||||
|
||||
return prefs.OnboardingPath switch
|
||||
{
|
||||
AppConstants.GuidedActivation.QuoteFirstPath => RedirectToAction(nameof(StartQuoteFlow)),
|
||||
AppConstants.GuidedActivation.JobFirstPath => RedirectToAction(nameof(StartJobFlow)),
|
||||
_ => RedirectToAction(nameof(Select))
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Select()
|
||||
{
|
||||
var prefs = await LoadPreferencesAsync();
|
||||
if (!prefs.SetupWizardCompleted)
|
||||
return RedirectToAction("Step", "SetupWizard", new { step = 1 });
|
||||
|
||||
if (prefs.FirstWorkflowCompleted)
|
||||
return RedirectToAction("Index", "Dashboard");
|
||||
|
||||
return View(new GuidedActivationSelectionViewModel
|
||||
{
|
||||
OnboardingPath = prefs.OnboardingPath
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Select(GuidedActivationSelectionViewModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return View(model);
|
||||
|
||||
if (model.OnboardingPath != AppConstants.GuidedActivation.QuoteFirstPath
|
||||
&& model.OnboardingPath != AppConstants.GuidedActivation.JobFirstPath)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.OnboardingPath), "Please choose a workflow path.");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var prefs = await LoadPreferencesAsync();
|
||||
prefs.OnboardingPath = model.OnboardingPath;
|
||||
prefs.GuidedActivationDismissedAt = null;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Guided activation path selected for company {CompanyId}: {Path}",
|
||||
prefs.CompanyId, prefs.OnboardingPath);
|
||||
|
||||
return RedirectToAction(nameof(Start));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Skip()
|
||||
{
|
||||
var prefs = await LoadPreferencesAsync();
|
||||
prefs.GuidedActivationDismissedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Guided activation dismissed for company {CompanyId}", prefs.CompanyId);
|
||||
return RedirectToAction("Index", "Dashboard");
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CompleteFromJob(int id)
|
||||
{
|
||||
var prefs = await LoadPreferencesAsync();
|
||||
if (!prefs.FirstWorkflowCompleted)
|
||||
{
|
||||
prefs.FirstWorkflowCompleted = true;
|
||||
prefs.FirstWorkflowCompletedAt = DateTime.UtcNow;
|
||||
prefs.GuidedActivationDismissedAt = null;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Guided activation completed from job {JobId} for company {CompanyId}",
|
||||
id, prefs.CompanyId);
|
||||
}
|
||||
|
||||
TempData["Success"] = "Your first workflow is complete. You're ready to keep going.";
|
||||
return RedirectToAction("Details", "Jobs", new { id, guidedActivation = AppConstants.GuidedActivation.JobCreatedStep });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CompleteFromInvoice(int id)
|
||||
{
|
||||
var prefs = await LoadPreferencesAsync();
|
||||
if (!prefs.FirstWorkflowCompleted)
|
||||
{
|
||||
prefs.FirstWorkflowCompleted = true;
|
||||
prefs.FirstWorkflowCompletedAt = DateTime.UtcNow;
|
||||
prefs.GuidedActivationDismissedAt = null;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Guided activation completed from invoice {InvoiceId} for company {CompanyId}",
|
||||
id, prefs.CompanyId);
|
||||
}
|
||||
|
||||
TempData["Success"] = "Your first workflow is complete. You're ready to keep going.";
|
||||
return RedirectToAction("Details", "Invoices", new { id, guidedActivation = AppConstants.GuidedActivation.InvoiceCreatedStep });
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> StartQuoteFlow()
|
||||
{
|
||||
var prefs = await LoadPreferencesAsync();
|
||||
prefs.OnboardingPath = AppConstants.GuidedActivation.QuoteFirstPath;
|
||||
prefs.GuidedActivationDismissedAt = null;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var customer = await GetOrCreateOnboardingCustomerAsync(prefs.CompanyId);
|
||||
|
||||
return RedirectToAction("Create", "Quotes", new
|
||||
{
|
||||
customerId = customer.Id,
|
||||
guidedActivation = AppConstants.GuidedActivation.QuoteFirstPath
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> StartJobFlow()
|
||||
{
|
||||
var prefs = await LoadPreferencesAsync();
|
||||
prefs.OnboardingPath = AppConstants.GuidedActivation.JobFirstPath;
|
||||
prefs.GuidedActivationDismissedAt = null;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var customer = await GetOrCreateOnboardingCustomerAsync(prefs.CompanyId);
|
||||
|
||||
return RedirectToAction("Create", "Jobs", new
|
||||
{
|
||||
customerId = customer.Id,
|
||||
guidedActivation = AppConstants.GuidedActivation.JobFirstPath
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<CompanyPreferences> LoadPreferencesAsync()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null)
|
||||
throw new InvalidOperationException("No company context available for guided activation.");
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.Preferences!)
|
||||
?? throw new InvalidOperationException("Company not found.");
|
||||
|
||||
if (company.Preferences != null)
|
||||
return company.Preferences;
|
||||
|
||||
var prefs = new CompanyPreferences { CompanyId = companyId.Value };
|
||||
await _unitOfWork.CompanyPreferences.AddAsync(prefs);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return prefs;
|
||||
}
|
||||
|
||||
private async Task<Customer> GetOrCreateOnboardingCustomerAsync(int companyId)
|
||||
{
|
||||
var existing = (await _unitOfWork.Customers.FindAsync(c =>
|
||||
c.CompanyId == companyId
|
||||
&& !c.IsDeleted
|
||||
&& (c.GeneralNotes == SampleCustomerMarker || c.CompanyName == SampleCustomerName)))
|
||||
.OrderBy(c => c.Id)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
CompanyId = companyId,
|
||||
CompanyName = SampleCustomerName,
|
||||
ContactFirstName = "Sample",
|
||||
ContactLastName = "Customer",
|
||||
Phone = "(555) 010-0001",
|
||||
IsCommercial = false,
|
||||
GeneralNotes = SampleCustomerMarker,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await _unitOfWork.Customers.AddAsync(customer);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Created guided activation sample customer {CustomerId} for company {CompanyId}",
|
||||
customer.Id, companyId);
|
||||
|
||||
return customer;
|
||||
}
|
||||
}
|
||||
@@ -222,7 +222,7 @@ public class InvoicesController : Controller
|
||||
/// — Whether online payments are allowed: requires plan-level permission AND an active
|
||||
/// Stripe Connect account. Both conditions must be true; per-plan override wins if set.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int? id)
|
||||
public async Task<IActionResult> Details(int? id, string? guidedActivation = null)
|
||||
{
|
||||
if (id == null) return NotFound();
|
||||
|
||||
@@ -257,6 +257,19 @@ public class InvoicesController : Controller
|
||||
ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed
|
||||
&& company?.StripeConnectStatus == StripeConnectStatus.Active;
|
||||
|
||||
if (guidedActivation == AppConstants.GuidedActivation.InvoiceCreatedStep)
|
||||
{
|
||||
ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
|
||||
{
|
||||
Show = true,
|
||||
Title = "This is how billing connects back to the job.",
|
||||
Message = "You’ve already seen the shop workflow. From here you can send the invoice, collect payment, or head back to the dashboard.",
|
||||
ActionText = "Go to Dashboard",
|
||||
ActionController = "Dashboard",
|
||||
ActionName = "Index"
|
||||
};
|
||||
}
|
||||
|
||||
return View(dto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -285,7 +298,7 @@ public class InvoicesController : Controller
|
||||
/// — Revenue accounts are pulled from the catalog item's RevenueAccountId, falling back to
|
||||
/// account 4000 (default revenue) if no catalog item is linked.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Create(int? jobId)
|
||||
public async Task<IActionResult> Create(int? jobId, string? guidedActivation = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -429,6 +442,7 @@ public class InvoicesController : Controller
|
||||
}
|
||||
|
||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -459,7 +473,7 @@ public class InvoicesController : Controller
|
||||
/// declared outside the lambda and assigned inside — EF requires this pattern with closures.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(CreateInvoiceDto dto)
|
||||
public async Task<IActionResult> Create(CreateInvoiceDto dto, string? guidedActivation = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -469,6 +483,7 @@ public class InvoicesController : Controller
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -476,6 +491,7 @@ public class InvoicesController : Controller
|
||||
{
|
||||
ModelState.AddModelError("", "Please add at least one line item before saving.");
|
||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -487,6 +503,7 @@ public class InvoicesController : Controller
|
||||
{
|
||||
ModelState.AddModelError("", "An invoice already exists for this job.");
|
||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
@@ -643,8 +660,21 @@ public class InvoicesController : Controller
|
||||
var depositMsg = pendingDeposits.Any()
|
||||
? $" {pendingDeposits.Count} deposit(s) totaling {pendingDeposits.Sum(d => d.Amount):C} auto-applied."
|
||||
: "";
|
||||
var workflowJustCompleted = await StampInvoiceCreatedAsync(currentUser.CompanyId);
|
||||
TempData["Success"] = $"Invoice {invoiceNumber} created successfully.{depositMsg}{gcMsg}";
|
||||
return RedirectToAction(nameof(Details), new { id = invoice.Id });
|
||||
if (!string.IsNullOrWhiteSpace(guidedActivation) || workflowJustCompleted)
|
||||
{
|
||||
return RedirectToAction(nameof(Details), new
|
||||
{
|
||||
id = invoice!.Id,
|
||||
guidedActivation = AppConstants.GuidedActivation.InvoiceCreatedStep
|
||||
});
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new
|
||||
{
|
||||
id = invoice!.Id
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -652,6 +682,7 @@ public class InvoicesController : Controller
|
||||
TempData["Error"] = "An error occurred while creating the invoice.";
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser != null) await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
@@ -2387,4 +2418,30 @@ public class InvoicesController : Controller
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
private async Task<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
|
||||
{
|
||||
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
}
|
||||
|
||||
private async Task<bool> StampInvoiceCreatedAsync(int companyId)
|
||||
{
|
||||
var prefs = await GetCompanyPreferencesAsync(companyId);
|
||||
if (prefs == null)
|
||||
return false;
|
||||
|
||||
var changed = false;
|
||||
|
||||
if (!prefs.FirstInvoiceCreatedAt.HasValue)
|
||||
{
|
||||
prefs.FirstInvoiceCreatedAt = DateTime.UtcNow;
|
||||
changed = true;
|
||||
_logger.LogInformation("Recorded first invoice creation for company {CompanyId}", companyId);
|
||||
}
|
||||
|
||||
if (changed)
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +225,10 @@ public class JobsController : Controller
|
||||
/// columns are also shown for historical context.
|
||||
/// Uses the lookup cache so column headers stay consistent with the configurable status list.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Board(bool showTerminal = false)
|
||||
public async Task<IActionResult> Board(
|
||||
bool showTerminal = false,
|
||||
string? guidedActivation = null,
|
||||
int? highlightJobId = null)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
@@ -236,6 +239,9 @@ public class JobsController : Controller
|
||||
|
||||
// Load all active jobs with related data
|
||||
var jobs = await _unitOfWork.Jobs.GetBoardJobsAsync();
|
||||
var highlightedJob = highlightJobId.HasValue
|
||||
? jobs.FirstOrDefault(j => j.Id == highlightJobId.Value)
|
||||
: null;
|
||||
|
||||
var now = DateTime.UtcNow.Date;
|
||||
|
||||
@@ -271,6 +277,13 @@ public class JobsController : Controller
|
||||
ViewBag.ShowTerminal = showTerminal;
|
||||
ViewBag.TotalTerminal = statuses.Where(s => s.IsTerminalStatus)
|
||||
.Sum(s => jobs.Count(j => j.JobStatusId == s.Id));
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
ViewBag.GuidedActivationHighlightJobId = highlightJobId;
|
||||
ViewBag.GuidedActivationCallout = await BuildBoardGuidedActivationCalloutAsync(
|
||||
companyId,
|
||||
guidedActivation,
|
||||
highlightJobId,
|
||||
highlightedJob);
|
||||
return View(columns);
|
||||
}
|
||||
|
||||
@@ -298,6 +311,10 @@ public class JobsController : Controller
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
job.UpdatedBy = User.Identity?.Name;
|
||||
|
||||
var workflowJustCompleted =
|
||||
req.JobId == req.HighlightJobId
|
||||
&& await MaybeMarkFirstWorkflowCompletedFromBoardAsync(job.CompanyId, req.GuidedActivation);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "StatusChanged",
|
||||
@@ -318,7 +335,10 @@ public class JobsController : Controller
|
||||
success = true,
|
||||
newStatusId = newStatus.Id,
|
||||
newStatusDisplay = newStatus.DisplayName,
|
||||
newStatusColor = newStatus.ColorClass
|
||||
newStatusColor = newStatus.ColorClass,
|
||||
guidedActivationNext = workflowJustCompleted
|
||||
? AppConstants.GuidedActivation.BoardReadyForInvoiceStep
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -329,7 +349,7 @@ public class JobsController : Controller
|
||||
/// correctly without a separate AJAX call. Measurement units (sq ft vs m²) are resolved from
|
||||
/// the tenant's metric preference and passed via ViewBag.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int? id)
|
||||
public async Task<IActionResult> Details(int? id, string? guidedActivation = null)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
@@ -468,6 +488,28 @@ public class JobsController : Controller
|
||||
.OrderBy(c => c.Text)
|
||||
.ToList();
|
||||
|
||||
var jobPrefs = await GetCompanyPreferencesAsync(job.CompanyId);
|
||||
if (guidedActivation == AppConstants.GuidedActivation.JobCreatedStep
|
||||
&& jobPrefs?.FirstWorkflowCompleted == false)
|
||||
{
|
||||
ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
|
||||
{
|
||||
Show = true,
|
||||
Title = jobPrefs.OnboardingPath == AppConstants.GuidedActivation.QuoteFirstPath
|
||||
? "Now your approved quote is a job. This is where you track it through your shop."
|
||||
: "This job is now live in your shop workflow.",
|
||||
Message = "Next, open the Daily Board and move it to the next stage so you can see how work flows across the shop.",
|
||||
ActionText = "Open Daily Board",
|
||||
ActionController = "Jobs",
|
||||
ActionName = "Board",
|
||||
ActionRouteValues = new
|
||||
{
|
||||
guidedActivation = AppConstants.GuidedActivation.BoardIntroStep,
|
||||
highlightJobId = job.Id
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return View(jobDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -817,7 +859,7 @@ public class JobsController : Controller
|
||||
/// (pre-configured job types with standard items). If <paramref name="customerId"/> is provided,
|
||||
/// the customer dropdown is pre-selected. The wizard is the same multi-step UI as the quote wizard.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Create(int? customerId, int? templateId)
|
||||
public async Task<IActionResult> Create(int? customerId, int? templateId, string? guidedActivation = null)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
@@ -839,6 +881,11 @@ public class JobsController : Controller
|
||||
if (customerId.HasValue)
|
||||
dto.CustomerId = customerId.Value;
|
||||
|
||||
if (guidedActivation == AppConstants.GuidedActivation.JobFirstPath && customerId.HasValue)
|
||||
{
|
||||
dto = GuidedActivationDefaults.BuildJobDraft(customerId.Value, normalPriority?.Id ?? 1);
|
||||
}
|
||||
|
||||
// Pre-populate from template if provided
|
||||
if (templateId.HasValue)
|
||||
{
|
||||
@@ -907,6 +954,7 @@ public class JobsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -919,13 +967,14 @@ public class JobsController : Controller
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(CreateJobDto dto)
|
||||
public async Task<IActionResult> Create(CreateJobDto dto, string? guidedActivation = null)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateCreateEditWizardViewBagsAsync(companyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -937,6 +986,7 @@ public class JobsController : Controller
|
||||
$"You have reached your plan limit of {max} active jobs. " +
|
||||
"Please upgrade your plan or complete/cancel existing jobs to add more.");
|
||||
await PopulateCreateEditWizardViewBagsAsync(companyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -1092,7 +1142,18 @@ public class JobsController : Controller
|
||||
if (!string.IsNullOrEmpty(createCompanyId))
|
||||
await _shopHub.Clients.Group($"shop-{createCompanyId}").SendAsync("DailyBoardUpdated");
|
||||
|
||||
await StampJobCreatedAsync(companyId);
|
||||
|
||||
this.ToastSuccess($"Job {job.JobNumber} created successfully!");
|
||||
if (guidedActivation == AppConstants.GuidedActivation.JobFirstPath)
|
||||
{
|
||||
return RedirectToAction(nameof(Details), new
|
||||
{
|
||||
id = job.Id,
|
||||
guidedActivation = AppConstants.GuidedActivation.JobCreatedStep
|
||||
});
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id = job.Id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1100,6 +1161,7 @@ public class JobsController : Controller
|
||||
_logger.LogError(ex, "Error creating job");
|
||||
this.ToastError("An error occurred while creating the job. Please try again.");
|
||||
await PopulateCreateEditWizardViewBagsAsync(companyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
@@ -2126,6 +2188,10 @@ public class JobsController : Controller
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
var workflowJustCompleted =
|
||||
request.JobId == request.HighlightJobId
|
||||
&& await MaybeMarkFirstWorkflowCompletedFromBoardAsync(job.CompanyId, request.GuidedActivation);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()?.ToString();
|
||||
@@ -2138,7 +2204,15 @@ public class JobsController : Controller
|
||||
statusColorClass = newStatus.ColorClass
|
||||
});
|
||||
|
||||
return Json(new { success = true, newStatusDisplayName = newStatus.DisplayName, newStatusColorClass = newStatus.ColorClass });
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
newStatusDisplayName = newStatus.DisplayName,
|
||||
newStatusColorClass = newStatus.ColorClass,
|
||||
guidedActivationNext = workflowJustCompleted
|
||||
? AppConstants.GuidedActivation.BoardReadyForInvoiceStep
|
||||
: null
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -3700,6 +3774,100 @@ public class JobsController : Controller
|
||||
return Json(new { error = "Unable to compute costing breakdown." });
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
|
||||
{
|
||||
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
}
|
||||
|
||||
private async Task<Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel?> BuildBoardGuidedActivationCalloutAsync(
|
||||
int companyId,
|
||||
string? guidedActivation,
|
||||
int? highlightJobId,
|
||||
Job? highlightedJob)
|
||||
{
|
||||
if (!highlightJobId.HasValue || highlightedJob == null)
|
||||
return null;
|
||||
|
||||
var prefs = await GetCompanyPreferencesAsync(companyId);
|
||||
if (prefs == null || !prefs.SetupWizardCompleted || string.IsNullOrWhiteSpace(prefs.OnboardingPath))
|
||||
return null;
|
||||
|
||||
if (guidedActivation == AppConstants.GuidedActivation.BoardIntroStep && !prefs.FirstWorkflowCompleted)
|
||||
{
|
||||
return new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
|
||||
{
|
||||
Show = true,
|
||||
Title = "This is your shop in real time",
|
||||
Message = "Every active job shows up here so you can see what's in production, what's waiting, and what's ready to go.",
|
||||
InstructionText = "Move this job to the next stage to see how your workflow updates.",
|
||||
SecondaryActionText = "View job",
|
||||
SecondaryActionController = "Jobs",
|
||||
SecondaryActionName = "Details",
|
||||
SecondaryActionRouteValues = new { id = highlightJobId.Value }
|
||||
};
|
||||
}
|
||||
|
||||
if (guidedActivation == AppConstants.GuidedActivation.BoardReadyForInvoiceStep)
|
||||
{
|
||||
var jobInvoice = await _unitOfWork.Invoices.GetForJobAsync(highlightJobId.Value);
|
||||
var hasInvoice = jobInvoice != null;
|
||||
|
||||
return new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
|
||||
{
|
||||
Show = true,
|
||||
Title = "Nice — your workflow just updated. This is how you track work through your shop.",
|
||||
Message = hasInvoice
|
||||
? "You've already tied billing to this job. Open the invoice or keep exploring the board."
|
||||
: "When the work is done, you can create the invoice.",
|
||||
ActionText = hasInvoice ? "View Invoice" : "Create Invoice",
|
||||
ActionController = "Invoices",
|
||||
ActionName = hasInvoice ? "Details" : "Create",
|
||||
ActionRouteValues = hasInvoice
|
||||
? new { id = jobInvoice!.Id }
|
||||
: new
|
||||
{
|
||||
jobId = highlightJobId.Value,
|
||||
guidedActivation = AppConstants.GuidedActivation.BoardReadyForInvoiceStep
|
||||
},
|
||||
SecondaryActionText = "View job",
|
||||
SecondaryActionController = "Jobs",
|
||||
SecondaryActionName = "Details",
|
||||
SecondaryActionRouteValues = new { id = highlightJobId.Value }
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<bool> MaybeMarkFirstWorkflowCompletedFromBoardAsync(int companyId, string? guidedActivation)
|
||||
{
|
||||
if (guidedActivation != AppConstants.GuidedActivation.BoardIntroStep)
|
||||
return false;
|
||||
|
||||
var prefs = await GetCompanyPreferencesAsync(companyId);
|
||||
if (prefs == null || prefs.FirstWorkflowCompleted || !prefs.SetupWizardCompleted)
|
||||
return false;
|
||||
|
||||
prefs.FirstWorkflowCompleted = true;
|
||||
prefs.FirstWorkflowCompletedAt = DateTime.UtcNow;
|
||||
prefs.GuidedActivationDismissedAt = null;
|
||||
|
||||
_logger.LogInformation("Marked first workflow complete from Daily Board tracking for company {CompanyId}", companyId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task StampJobCreatedAsync(int companyId)
|
||||
{
|
||||
var prefs = await GetCompanyPreferencesAsync(companyId);
|
||||
if (prefs == null || prefs.FirstJobCreatedAt.HasValue)
|
||||
return;
|
||||
|
||||
prefs.FirstJobCreatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
||||
}
|
||||
}
|
||||
|
||||
public class DeleteTimeEntryRequest { public int Id { get; set; } }
|
||||
@@ -3756,12 +3924,16 @@ public class MoveCardRequest
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
public int NewStatusId { get; set; }
|
||||
public string? GuidedActivation { get; set; }
|
||||
public int? HighlightJobId { get; set; }
|
||||
}
|
||||
|
||||
public class AdvanceJobStatusRequest
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
public int NewStatusId { get; set; }
|
||||
public string? GuidedActivation { get; set; }
|
||||
public int? HighlightJobId { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateDatesRequest
|
||||
|
||||
@@ -260,7 +260,7 @@ public class QuotesController : Controller
|
||||
/// the customer even if operating costs have changed since the quote was created.
|
||||
/// Also verifies that ConvertedToJobId still points to a live job (clears stale references).
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int? id)
|
||||
public async Task<IActionResult> Details(int? id, string? guidedActivation = null)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
@@ -435,6 +435,21 @@ public class QuotesController : Controller
|
||||
.OrderBy(c => c.Text)
|
||||
.ToList();
|
||||
|
||||
var quotePrefs = await GetCompanyPreferencesAsync(currentUser!.CompanyId);
|
||||
if (guidedActivation == AppConstants.GuidedActivation.QuoteCreatedStep
|
||||
&& quotePrefs?.OnboardingPath == AppConstants.GuidedActivation.QuoteFirstPath
|
||||
&& quotePrefs.FirstWorkflowCompleted == false)
|
||||
{
|
||||
ViewBag.GuidedActivationMode = AppConstants.GuidedActivation.QuoteFirstPath;
|
||||
ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
|
||||
{
|
||||
Show = true,
|
||||
Title = "This is the quote you would send to your customer.",
|
||||
Message = "Next, convert it into a job so it moves into your real production workflow.",
|
||||
ActionText = "Convert to Job"
|
||||
};
|
||||
}
|
||||
|
||||
return View(quoteDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -627,7 +642,7 @@ public class QuotesController : Controller
|
||||
/// Optionally pre-selects a customer when <paramref name="customerId"/> is provided (e.g. when
|
||||
/// navigating from the Customer Details page using the "New Quote" shortcut).
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Create(int? customerId)
|
||||
public async Task<IActionResult> Create(int? customerId, string? guidedActivation = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -682,6 +697,17 @@ public class QuotesController : Controller
|
||||
CustomerId = customerId
|
||||
};
|
||||
|
||||
if (guidedActivation == AppConstants.GuidedActivation.QuoteFirstPath)
|
||||
{
|
||||
var draft = GuidedActivationDefaults.BuildQuoteDraft(customerId);
|
||||
dto.CustomerId = draft.CustomerId;
|
||||
dto.Description = draft.Description;
|
||||
dto.Notes = draft.Notes;
|
||||
dto.QuoteItems = draft.QuoteItems;
|
||||
}
|
||||
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
|
||||
return View(dto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -703,7 +729,7 @@ public class QuotesController : Controller
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(CreateQuoteDto dto)
|
||||
public async Task<IActionResult> Create(CreateQuoteDto dto, string? guidedActivation = null)
|
||||
{
|
||||
_logger.LogInformation("=== CREATE QUOTE POST ACTION CALLED ===");
|
||||
_logger.LogInformation("IsForProspect: {IsForProspect}", dto.IsForProspect);
|
||||
@@ -832,6 +858,7 @@ public class QuotesController : Controller
|
||||
|
||||
await PopulateDropDownsAsync(currentUser!.CompanyId, operatingCosts?.OvenOperatingCostPerHour ?? 0);
|
||||
await SetMeasurementViewBagAsync();
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -1134,7 +1161,18 @@ public class QuotesController : Controller
|
||||
this.SetNotificationResultToast(quoteCreateNotifLog);
|
||||
}
|
||||
|
||||
await StampQuoteCreatedAsync(currentUser.CompanyId);
|
||||
|
||||
this.ToastSuccess($"Quote {quote.QuoteNumber} created successfully!");
|
||||
if (guidedActivation == AppConstants.GuidedActivation.QuoteFirstPath)
|
||||
{
|
||||
return RedirectToAction(nameof(Details), new
|
||||
{
|
||||
id = quote.Id,
|
||||
guidedActivation = AppConstants.GuidedActivation.QuoteCreatedStep
|
||||
});
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id = quote.Id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1145,6 +1183,7 @@ public class QuotesController : Controller
|
||||
var catchCosts = await _pricingService.GetOperatingCostsAsync(catchUser!.CompanyId);
|
||||
await PopulateDropDownsAsync(catchUser.CompanyId, catchCosts?.OvenOperatingCostPerHour ?? 0);
|
||||
await SetMeasurementViewBagAsync();
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
@@ -2100,7 +2139,7 @@ public class QuotesController : Controller
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ConvertToJob(int id)
|
||||
public async Task<IActionResult> ConvertToJob(int id, string? guidedActivation = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -2231,9 +2270,20 @@ public class QuotesController : Controller
|
||||
|
||||
this.ToastSuccess($"Job has been successfully created from quote {quote.QuoteNumber}!");
|
||||
|
||||
await StampJobCreatedAsync(currentUser!.CompanyId);
|
||||
|
||||
// Redirect to the newly created job's details page
|
||||
if (quote.ConvertedToJobId.HasValue)
|
||||
{
|
||||
if (guidedActivation == AppConstants.GuidedActivation.QuoteFirstPath)
|
||||
{
|
||||
return RedirectToAction("Details", "Jobs", new
|
||||
{
|
||||
id = quote.ConvertedToJobId.Value,
|
||||
guidedActivation = AppConstants.GuidedActivation.JobCreatedStep
|
||||
});
|
||||
}
|
||||
|
||||
return RedirectToAction("Details", "Jobs", new { id = quote.ConvertedToJobId.Value });
|
||||
}
|
||||
|
||||
@@ -3791,6 +3841,35 @@ public class QuotesController : Controller
|
||||
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
private async Task<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
|
||||
{
|
||||
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
}
|
||||
|
||||
private async Task StampQuoteCreatedAsync(int companyId)
|
||||
{
|
||||
var prefs = await GetCompanyPreferencesAsync(companyId);
|
||||
if (prefs == null || prefs.FirstQuoteCreatedAt.HasValue)
|
||||
return;
|
||||
|
||||
prefs.FirstQuoteCreatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Recorded first quote creation for company {CompanyId}", companyId);
|
||||
}
|
||||
|
||||
private async Task StampJobCreatedAsync(int companyId)
|
||||
{
|
||||
var prefs = await GetCompanyPreferencesAsync(companyId);
|
||||
if (prefs == null || prefs.FirstJobCreatedAt.HasValue)
|
||||
return;
|
||||
|
||||
prefs.FirstJobCreatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
||||
}
|
||||
}
|
||||
|
||||
// Request model for AJAX pricing calculation
|
||||
|
||||
@@ -311,31 +311,7 @@ public class SetupWizardController : Controller
|
||||
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
|
||||
5 => View("Step9", new WizardStep7Dto
|
||||
{
|
||||
EmailNotificationsEnabled = prefs.EmailNotificationsEnabled,
|
||||
EmailFromAddress = prefs.EmailFromAddress,
|
||||
@@ -351,7 +327,6 @@ public class SetupWizardController : Controller
|
||||
DueDateWarningDays = prefs.DueDateWarningDays,
|
||||
MaintenanceAlertDays = prefs.MaintenanceAlertDays
|
||||
}),
|
||||
10 => await BuildStep10ViewAsync(GetCompanyId()),
|
||||
_ => RedirectToAction("Step", new { step = 1 })
|
||||
};
|
||||
}
|
||||
@@ -405,53 +380,6 @@ public class SetupWizardController : Controller
|
||||
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>
|
||||
@@ -675,138 +603,15 @@ public class SetupWizardController : Controller
|
||||
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.
|
||||
/// 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> 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;
|
||||
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 5;
|
||||
|
||||
if (!ModelState.IsValid) return View("Step9", model);
|
||||
|
||||
@@ -824,83 +629,9 @@ public class SetupWizardController : Controller
|
||||
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);
|
||||
MarkDone(prefs, 5);
|
||||
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;
|
||||
@@ -909,7 +640,7 @@ public class SetupWizardController : Controller
|
||||
: User.Identity?.Name;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Complete));
|
||||
return RedirectToAction("Start", "GuidedActivation");
|
||||
}
|
||||
|
||||
// ─── Skip ─────────────────────────────────────────────────────────────────
|
||||
@@ -927,6 +658,18 @@ public class SetupWizardController : Controller
|
||||
{
|
||||
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);
|
||||
@@ -975,6 +718,7 @@ public class SetupWizardController : Controller
|
||||
{
|
||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||
ViewBag.Progress = BuildProgress(prefs);
|
||||
ViewBag.ShowGuidedActivationCta = prefs.SetupWizardCompleted && !prefs.FirstWorkflowCompleted;
|
||||
return View();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user