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,
|
||||
|
||||
Reference in New Issue
Block a user