From 8de9cd04b8eeb7d848b5d3f2dbef2022711e29af Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 29 Apr 2026 09:23:20 -0400 Subject: [PATCH] Add server-side dismiss persistence and SuperAdmin onboarding progress page Progress widget dismiss now POSTs to Dashboard/DismissProgressWidget, writing GuidedActivationDismissedAt to the DB so the widget stays hidden across devices and cache clears (localStorage alone wasn't enough). BuildShopProgressWidgetAsync suppresses the widget server-side when AllDone + dismissed. New SuperAdmin page at /OnboardingProgress shows the activation funnel across all tenant companies: wizard status, chosen path, milestone progress bar, key dates (first job/quote, first invoice, workflow completed, widget dismissed), and a status badge (Not Started / In Progress / Complete / Dismissed). Nav link added under Users & Activity in the Platform Management sidebar. Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/DashboardController.cs | 33 +++- .../OnboardingProgressController.cs | 85 ++++++++++ .../Platform/OnboardingProgressViewModels.cs | 35 +++++ .../Views/OnboardingProgress/Index.cshtml | 147 ++++++++++++++++++ .../Views/Shared/_Layout.cshtml | 4 + .../wwwroot/js/shop-progress-widget.js | 8 + 6 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 src/PowderCoating.Web/Controllers/OnboardingProgressController.cs create mode 100644 src/PowderCoating.Web/ViewModels/Platform/OnboardingProgressViewModels.cs create mode 100644 src/PowderCoating.Web/Views/OnboardingProgress/Index.cshtml diff --git a/src/PowderCoating.Web/Controllers/DashboardController.cs b/src/PowderCoating.Web/Controllers/DashboardController.cs index a353c23..5705a49 100644 --- a/src/PowderCoating.Web/Controllers/DashboardController.cs +++ b/src/PowderCoating.Web/Controllers/DashboardController.cs @@ -771,7 +771,38 @@ public class DashboardController : Controller } }; - return new ShopProgressWidgetViewModel { Items = items.Where(i => i != null).Select(i => i!).ToList() }; + var vm = new ShopProgressWidgetViewModel { Items = items.Where(i => i != null).Select(i => i!).ToList() }; + + // Suppress widget if the user already dismissed it after completing all steps + if (vm.AllDone && prefs.GuidedActivationDismissedAt.HasValue) + return null; + + return vm; + } + + /// + /// Persists the company admin's dismissal of the progress widget completion state. + /// Sets GuidedActivationDismissedAt so the widget stays hidden across devices + /// and browser sessions (localStorage alone wouldn't survive a cleared cache). + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task DismissProgressWidget() + { + var companyId = _tenantContext.GetCurrentCompanyId(); + if (companyId == null) + return Json(new { success = false }); + + var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.Preferences!); + if (company?.Preferences == null) + return Json(new { success = false }); + + company.Preferences.GuidedActivationDismissedAt = DateTime.UtcNow; + await _unitOfWork.CompleteAsync(); + + _logger.LogInformation("Progress widget dismissed for company {CompanyId}", companyId.Value); + + return Json(new { success = true }); } /// diff --git a/src/PowderCoating.Web/Controllers/OnboardingProgressController.cs b/src/PowderCoating.Web/Controllers/OnboardingProgressController.cs new file mode 100644 index 0000000..7159edf --- /dev/null +++ b/src/PowderCoating.Web/Controllers/OnboardingProgressController.cs @@ -0,0 +1,85 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Interfaces; +using PowderCoating.Shared.Constants; +using PowderCoating.Web.ViewModels.Platform; + +namespace PowderCoating.Web.Controllers; + +[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] +public class OnboardingProgressController : Controller +{ + private readonly IUnitOfWork _unitOfWork; + private readonly UserManager _userManager; + + public OnboardingProgressController(IUnitOfWork unitOfWork, UserManager userManager) + { + _unitOfWork = unitOfWork; + _userManager = userManager; + } + + /// + /// Shows onboarding and activation status for every tenant company. SuperAdmin-only. + /// Reads CompanyPreferences fields written by the setup wizard, guided activation, + /// and the jobs/quotes/invoices controllers to give a cross-tenant activation funnel view. + /// + public async Task Index() + { + var companies = (await _unitOfWork.Companies.GetAllAsync( + ignoreQueryFilters: true, + c => c.Preferences!)) + .Where(c => !c.IsDeleted) + .OrderBy(c => c.CompanyName) + .ToList(); + + var rows = companies.Select(c => BuildRow(c)).ToList(); + + return View(new OnboardingProgressIndexViewModel { Rows = rows }); + } + + private static OnboardingProgressRowViewModel BuildRow(Company company) + { + var prefs = company.Preferences; + var wizardDone = prefs?.SetupWizardCompleted ?? false; + + // Mirror the same 6-step logic used in DashboardController.BuildShopProgressWidgetAsync + // Steps: first job/quote, status history (unknown here — omit), first invoice, + // team size (unknown here — omit), customized lookups (unknown — omit), payment defaults. + // We track the 3 date-stamped milestones we can derive from prefs alone. + int steps = 0; + if (prefs?.FirstJobCreatedAt.HasValue == true || prefs?.FirstQuoteCreatedAt.HasValue == true) steps++; + if (prefs?.FirstInvoiceCreatedAt.HasValue == true) steps++; + if (prefs?.FirstWorkflowCompletedAt.HasValue == true) steps++; + const int total = 3; + + OnboardingStatus status; + if (!wizardDone) + status = OnboardingStatus.NotStarted; + else if (prefs?.FirstWorkflowCompletedAt.HasValue == true && prefs.GuidedActivationDismissedAt.HasValue) + status = OnboardingStatus.Dismissed; + else if (prefs?.FirstWorkflowCompletedAt.HasValue == true) + status = OnboardingStatus.Complete; + else if (steps > 0) + status = OnboardingStatus.InProgress; + else + status = OnboardingStatus.NotStarted; + + return new OnboardingProgressRowViewModel + { + CompanyId = company.Id, + CompanyName = company.CompanyName ?? "Unknown", + WizardCompleted = wizardDone, + OnboardingPath = prefs?.OnboardingPath, + StepsCompleted = steps, + TotalSteps = total, + FirstJobCreatedAt = prefs?.FirstJobCreatedAt, + FirstQuoteCreatedAt = prefs?.FirstQuoteCreatedAt, + FirstInvoiceCreatedAt = prefs?.FirstInvoiceCreatedAt, + FirstWorkflowCompletedAt = prefs?.FirstWorkflowCompletedAt, + GuidedActivationDismissedAt = prefs?.GuidedActivationDismissedAt, + Status = status + }; + } +} diff --git a/src/PowderCoating.Web/ViewModels/Platform/OnboardingProgressViewModels.cs b/src/PowderCoating.Web/ViewModels/Platform/OnboardingProgressViewModels.cs new file mode 100644 index 0000000..b9c6ebb --- /dev/null +++ b/src/PowderCoating.Web/ViewModels/Platform/OnboardingProgressViewModels.cs @@ -0,0 +1,35 @@ +namespace PowderCoating.Web.ViewModels.Platform; + +public class OnboardingProgressIndexViewModel +{ + public List Rows { get; set; } = new(); + public int TotalCompanies => Rows.Count; + public int WizardCompleted => Rows.Count(r => r.WizardCompleted); + public int FullyActivated => Rows.Count(r => r.Status == OnboardingStatus.Complete); + public int InProgress => Rows.Count(r => r.Status == OnboardingStatus.InProgress); + public int NotStarted => Rows.Count(r => r.Status == OnboardingStatus.NotStarted); +} + +public class OnboardingProgressRowViewModel +{ + public int CompanyId { get; set; } + public string CompanyName { get; set; } = string.Empty; + public bool WizardCompleted { get; set; } + public string? OnboardingPath { get; set; } + public int StepsCompleted { get; set; } + public int TotalSteps { get; set; } + public DateTime? FirstJobCreatedAt { get; set; } + public DateTime? FirstQuoteCreatedAt { get; set; } + public DateTime? FirstInvoiceCreatedAt { get; set; } + public DateTime? FirstWorkflowCompletedAt { get; set; } + public DateTime? GuidedActivationDismissedAt { get; set; } + public OnboardingStatus Status { get; set; } +} + +public enum OnboardingStatus +{ + NotStarted, + InProgress, + Complete, + Dismissed +} diff --git a/src/PowderCoating.Web/Views/OnboardingProgress/Index.cshtml b/src/PowderCoating.Web/Views/OnboardingProgress/Index.cshtml new file mode 100644 index 0000000..8c2ccd5 --- /dev/null +++ b/src/PowderCoating.Web/Views/OnboardingProgress/Index.cshtml @@ -0,0 +1,147 @@ +@model PowderCoating.Web.ViewModels.Platform.OnboardingProgressIndexViewModel +@using PowderCoating.Web.ViewModels.Platform + +@{ + ViewData["Title"] = "Onboarding Progress"; + Layout = "~/Views/Shared/_Layout.cshtml"; +} + +
+
+
+

Onboarding Progress

+

Activation funnel across all tenant companies

+
+
+ + @* Summary KPI strip *@ +
+
+
+
@Model.TotalCompanies
+
Total Companies
+
+
+
+
+
@Model.WizardCompleted
+
Wizard Completed
+
+
+
+
+
@Model.FullyActivated
+
Fully Activated
+
+
+
+
+
@Model.InProgress
+
In Progress
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + @foreach (var row in Model.Rows) + { + + + + + + + + + + + + } + @if (!Model.Rows.Any()) + { + + + + } + +
CompanyWizardPathMilestonesFirst Job / QuoteFirst InvoiceWorkflow DoneWidget DismissedStatus
+ @row.CompanyName + + @if (row.WizardCompleted) + { + + } + else + { + + } + + @if (!string.IsNullOrEmpty(row.OnboardingPath)) + { + @row.OnboardingPath + } + else + { + + } + + @{ + var pct = row.TotalSteps == 0 ? 0 : row.StepsCompleted * 100 / row.TotalSteps; + var barColor = pct == 100 ? "bg-success" : "bg-primary"; + } +
+
+
+
+ @row.StepsCompleted/@row.TotalSteps +
+
+ @{ + var firstActivity = row.FirstJobCreatedAt ?? row.FirstQuoteCreatedAt; + } + @(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "—") + + @(row.FirstInvoiceCreatedAt.HasValue ? row.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "—") + + @(row.FirstWorkflowCompletedAt.HasValue ? row.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "—") + + @(row.GuidedActivationDismissedAt.HasValue ? row.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "—") + + @switch (row.Status) + { + case OnboardingStatus.Complete: + Complete + break; + case OnboardingStatus.InProgress: + In Progress + break; + case OnboardingStatus.Dismissed: + Dismissed + break; + default: + Not Started + break; + } +
No companies found.
+
+
+
+
diff --git a/src/PowderCoating.Web/Views/Shared/_Layout.cshtml b/src/PowderCoating.Web/Views/Shared/_Layout.cshtml index fc9a0e2..c79f351 100644 --- a/src/PowderCoating.Web/Views/Shared/_Layout.cshtml +++ b/src/PowderCoating.Web/Views/Shared/_Layout.cshtml @@ -1215,6 +1215,10 @@ + + + Onboarding Progress + Platform Users diff --git a/src/PowderCoating.Web/wwwroot/js/shop-progress-widget.js b/src/PowderCoating.Web/wwwroot/js/shop-progress-widget.js index 6c83542..2330ec4 100644 --- a/src/PowderCoating.Web/wwwroot/js/shop-progress-widget.js +++ b/src/PowderCoating.Web/wwwroot/js/shop-progress-widget.js @@ -38,6 +38,14 @@ dismiss.addEventListener('click', function () { widget.style.display = 'none'; try { localStorage.setItem(DISMISSED_KEY, '1'); } catch (e) { } + // Persist server-side so dismissal survives cache clears and other devices + var token = document.querySelector('input[name="__RequestVerificationToken"]'); + if (token) { + fetch('/Dashboard/DismissProgressWidget', { + method: 'POST', + headers: { 'RequestVerificationToken': token.value } + }).catch(function () {}); + } }); } }());