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:
2026-04-28 21:10:47 -04:00
parent 4d27a378ac
commit 8aae30765f
30 changed files with 10870 additions and 333 deletions
@@ -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,