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