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:
@@ -12,7 +12,7 @@ public class WizardProgressDto
|
|||||||
public bool Completed { get; set; }
|
public bool Completed { get; set; }
|
||||||
public List<int> DoneSteps { get; set; } = new();
|
public List<int> DoneSteps { get; set; } = new();
|
||||||
public List<int> SkippedSteps { get; set; } = new();
|
public List<int> SkippedSteps { get; set; } = new();
|
||||||
public const int TotalSteps = 10;
|
public const int TotalSteps = 5;
|
||||||
|
|
||||||
public bool IsStepDone(int step) => DoneSteps.Contains(step);
|
public bool IsStepDone(int step) => DoneSteps.Contains(step);
|
||||||
public bool IsStepSkipped(int step) => SkippedSteps.Contains(step);
|
public bool IsStepSkipped(int step) => SkippedSteps.Contains(step);
|
||||||
|
|||||||
@@ -86,6 +86,22 @@ public class CompanyPreferences : BaseEntity
|
|||||||
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
|
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
|
||||||
public string? QbMigrationStateJson { get; set; }
|
public string? QbMigrationStateJson { get; set; }
|
||||||
|
|
||||||
|
// Guided activation / first-workflow onboarding
|
||||||
|
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
|
||||||
|
public string? OnboardingPath { get; set; }
|
||||||
|
/// <summary>True once the company completes its first guided real workflow.</summary>
|
||||||
|
public bool FirstWorkflowCompleted { get; set; } = false;
|
||||||
|
/// <summary>UTC timestamp of when the first guided workflow was completed.</summary>
|
||||||
|
public DateTime? FirstWorkflowCompletedAt { get; set; }
|
||||||
|
/// <summary>UTC timestamp of the company's first quote creation.</summary>
|
||||||
|
public DateTime? FirstQuoteCreatedAt { get; set; }
|
||||||
|
/// <summary>UTC timestamp of the company's first job creation.</summary>
|
||||||
|
public DateTime? FirstJobCreatedAt { get; set; }
|
||||||
|
/// <summary>UTC timestamp of the company's first invoice creation.</summary>
|
||||||
|
public DateTime? FirstInvoiceCreatedAt { get; set; }
|
||||||
|
/// <summary>UTC timestamp of when the company dismissed guided activation without completing it.</summary>
|
||||||
|
public DateTime? GuidedActivationDismissedAt { get; set; }
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public virtual Company Company { get; set; } = null!;
|
public virtual Company Company { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+9325
File diff suppressed because it is too large
Load Diff
+132
@@ -0,0 +1,132 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGuidedActivationFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "FirstInvoiceCreatedAt",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
type: "datetime2",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "FirstJobCreatedAt",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
type: "datetime2",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "FirstQuoteCreatedAt",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
type: "datetime2",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "FirstWorkflowCompleted",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "FirstWorkflowCompletedAt",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
type: "datetime2",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "GuidedActivationDismissedAt",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
type: "datetime2",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "OnboardingPath",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5055));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5063));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5065));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FirstInvoiceCreatedAt",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FirstJobCreatedAt",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FirstQuoteCreatedAt",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FirstWorkflowCompleted",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FirstWorkflowCompletedAt",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GuidedActivationDismissedAt",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OnboardingPath",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5921));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5931));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5932));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1969,6 +1969,24 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("EmailNotificationsEnabled")
|
b.Property<bool>("EmailNotificationsEnabled")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("FirstInvoiceCreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("FirstJobCreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("FirstQuoteCreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<bool>("FirstWorkflowCompleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("FirstWorkflowCompletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("GuidedActivationDismissedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<string>("InAccentColor")
|
b.Property<string>("InAccentColor")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
@@ -2017,6 +2035,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("NotifyOnQuoteApproval")
|
b.Property<bool>("NotifyOnQuoteApproval")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("OnboardingPath")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("PaymentReminderDays")
|
b.Property<string>("PaymentReminderDays")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
@@ -5839,7 +5860,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5921),
|
CreatedAt = new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5055),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -5850,7 +5871,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5931),
|
CreatedAt = new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5063),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -5861,7 +5882,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5932),
|
CreatedAt = new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5065),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
|
|||||||
@@ -109,10 +109,23 @@ public static class AppConstants
|
|||||||
public const string CurrentTosVersion = "2026-04-09";
|
public const string CurrentTosVersion = "2026-04-09";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class GuidedActivation
|
||||||
|
{
|
||||||
|
public const string QuoteFirstPath = "quote_first";
|
||||||
|
public const string JobFirstPath = "job_first";
|
||||||
|
|
||||||
|
public const string QueryKey = "guidedActivation";
|
||||||
|
public const string HighlightJobIdKey = "highlightJobId";
|
||||||
|
public const string QuoteCreatedStep = "quote_created";
|
||||||
|
public const string JobCreatedStep = "job_created";
|
||||||
|
public const string BoardIntroStep = "board_intro";
|
||||||
|
public const string BoardReadyForInvoiceStep = "board_ready_for_invoice";
|
||||||
|
public const string InvoiceCreatedStep = "invoice_created";
|
||||||
|
}
|
||||||
|
|
||||||
public static class PowderInsights
|
public static class PowderInsights
|
||||||
{
|
{
|
||||||
public const int Layer3MinJobs = 150; // Minimum jobs with actual powder data before Layer 3 predictive features unlock
|
public const int Layer3MinJobs = 150; // Minimum jobs with actual powder data before Layer 3 predictive features unlock
|
||||||
public const int Layer2MinJobs = 10; // Minimum for efficiency trending to be meaningful
|
public const int Layer2MinJobs = 10; // Minimum for efficiency trending to be meaningful
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PowderCoating.Application.DTOs.Dashboard;
|
using PowderCoating.Application.DTOs.Dashboard;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Core.Entities;
|
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;
|
||||||
using PowderCoating.Core.Interfaces.Services;
|
using PowderCoating.Core.Interfaces.Services;
|
||||||
using PowderCoating.Shared.Constants;
|
using PowderCoating.Shared.Constants;
|
||||||
|
using PowderCoating.Web.ViewModels.Dashboard;
|
||||||
|
using PowderCoating.Web.ViewModels.GuidedActivation;
|
||||||
using InvoiceStatus = PowderCoating.Core.Enums.InvoiceStatus;
|
using InvoiceStatus = PowderCoating.Core.Enums.InvoiceStatus;
|
||||||
|
|
||||||
namespace PowderCoating.Web.Controllers;
|
namespace PowderCoating.Web.Controllers;
|
||||||
@@ -19,6 +23,7 @@ public class DashboardController : Controller
|
|||||||
private readonly IDashboardReadService _dashboardRead;
|
private readonly IDashboardReadService _dashboardRead;
|
||||||
private readonly ITenantContext _tenantContext;
|
private readonly ITenantContext _tenantContext;
|
||||||
private readonly ICompanyConfigHealthService _configHealth;
|
private readonly ICompanyConfigHealthService _configHealth;
|
||||||
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
|
||||||
private static readonly string[] CompletedStatusCodes =
|
private static readonly string[] CompletedStatusCodes =
|
||||||
[
|
[
|
||||||
@@ -45,13 +50,15 @@ public class DashboardController : Controller
|
|||||||
ILogger<DashboardController> logger,
|
ILogger<DashboardController> logger,
|
||||||
IDashboardReadService dashboardRead,
|
IDashboardReadService dashboardRead,
|
||||||
ITenantContext tenantContext,
|
ITenantContext tenantContext,
|
||||||
ICompanyConfigHealthService configHealth)
|
ICompanyConfigHealthService configHealth,
|
||||||
|
UserManager<ApplicationUser> userManager)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_dashboardRead = dashboardRead;
|
_dashboardRead = dashboardRead;
|
||||||
_tenantContext = tenantContext;
|
_tenantContext = tenantContext;
|
||||||
_configHealth = configHealth;
|
_configHealth = configHealth;
|
||||||
|
_userManager = userManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -564,8 +571,17 @@ public class DashboardController : Controller
|
|||||||
// Config health check — surface setup gaps to company admins
|
// Config health check — surface setup gaps to company admins
|
||||||
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
|
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
|
||||||
if (currentCompanyId.HasValue)
|
if (currentCompanyId.HasValue)
|
||||||
|
{
|
||||||
ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value);
|
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);
|
return View(vm);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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>
|
/// <summary>
|
||||||
/// Records receipt of a powder shipment against an existing powder order. Sets
|
/// 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,
|
/// <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
|
/// — 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.
|
/// Stripe Connect account. Both conditions must be true; per-plan override wins if set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Details(int? id)
|
public async Task<IActionResult> Details(int? id, string? guidedActivation = null)
|
||||||
{
|
{
|
||||||
if (id == null) return NotFound();
|
if (id == null) return NotFound();
|
||||||
|
|
||||||
@@ -257,6 +257,19 @@ public class InvoicesController : Controller
|
|||||||
ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed
|
ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed
|
||||||
&& company?.StripeConnectStatus == StripeConnectStatus.Active;
|
&& 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);
|
return View(dto);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -285,7 +298,7 @@ public class InvoicesController : Controller
|
|||||||
/// — Revenue accounts are pulled from the catalog item's RevenueAccountId, falling back to
|
/// — Revenue accounts are pulled from the catalog item's RevenueAccountId, falling back to
|
||||||
/// account 4000 (default revenue) if no catalog item is linked.
|
/// account 4000 (default revenue) if no catalog item is linked.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Create(int? jobId)
|
public async Task<IActionResult> Create(int? jobId, string? guidedActivation = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -429,6 +442,7 @@ public class InvoicesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||||
|
ViewBag.GuidedActivation = guidedActivation;
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -459,7 +473,7 @@ public class InvoicesController : Controller
|
|||||||
/// declared outside the lambda and assigned inside — EF requires this pattern with closures.
|
/// declared outside the lambda and assigned inside — EF requires this pattern with closures.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Create(CreateInvoiceDto dto)
|
public async Task<IActionResult> Create(CreateInvoiceDto dto, string? guidedActivation = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -469,6 +483,7 @@ public class InvoicesController : Controller
|
|||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||||
|
ViewBag.GuidedActivation = guidedActivation;
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,6 +491,7 @@ public class InvoicesController : Controller
|
|||||||
{
|
{
|
||||||
ModelState.AddModelError("", "Please add at least one line item before saving.");
|
ModelState.AddModelError("", "Please add at least one line item before saving.");
|
||||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||||
|
ViewBag.GuidedActivation = guidedActivation;
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,6 +503,7 @@ public class InvoicesController : Controller
|
|||||||
{
|
{
|
||||||
ModelState.AddModelError("", "An invoice already exists for this job.");
|
ModelState.AddModelError("", "An invoice already exists for this job.");
|
||||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||||
|
ViewBag.GuidedActivation = guidedActivation;
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -643,8 +660,21 @@ public class InvoicesController : Controller
|
|||||||
var depositMsg = pendingDeposits.Any()
|
var depositMsg = pendingDeposits.Any()
|
||||||
? $" {pendingDeposits.Count} deposit(s) totaling {pendingDeposits.Sum(d => d.Amount):C} auto-applied."
|
? $" {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}";
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -652,6 +682,7 @@ public class InvoicesController : Controller
|
|||||||
TempData["Error"] = "An error occurred while creating the invoice.";
|
TempData["Error"] = "An error occurred while creating the invoice.";
|
||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
if (currentUser != null) await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
if (currentUser != null) await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||||
|
ViewBag.GuidedActivation = guidedActivation;
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2387,4 +2418,30 @@ public class InvoicesController : Controller
|
|||||||
|
|
||||||
return View(vm);
|
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.
|
/// columns are also shown for historical context.
|
||||||
/// Uses the lookup cache so column headers stay consistent with the configurable status list.
|
/// Uses the lookup cache so column headers stay consistent with the configurable status list.
|
||||||
/// </summary>
|
/// </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;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
@@ -236,6 +239,9 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
// Load all active jobs with related data
|
// Load all active jobs with related data
|
||||||
var jobs = await _unitOfWork.Jobs.GetBoardJobsAsync();
|
var jobs = await _unitOfWork.Jobs.GetBoardJobsAsync();
|
||||||
|
var highlightedJob = highlightJobId.HasValue
|
||||||
|
? jobs.FirstOrDefault(j => j.Id == highlightJobId.Value)
|
||||||
|
: null;
|
||||||
|
|
||||||
var now = DateTime.UtcNow.Date;
|
var now = DateTime.UtcNow.Date;
|
||||||
|
|
||||||
@@ -271,6 +277,13 @@ public class JobsController : Controller
|
|||||||
ViewBag.ShowTerminal = showTerminal;
|
ViewBag.ShowTerminal = showTerminal;
|
||||||
ViewBag.TotalTerminal = statuses.Where(s => s.IsTerminalStatus)
|
ViewBag.TotalTerminal = statuses.Where(s => s.IsTerminalStatus)
|
||||||
.Sum(s => jobs.Count(j => j.JobStatusId == s.Id));
|
.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);
|
return View(columns);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +311,10 @@ public class JobsController : Controller
|
|||||||
job.UpdatedAt = DateTime.UtcNow;
|
job.UpdatedAt = DateTime.UtcNow;
|
||||||
job.UpdatedBy = User.Identity?.Name;
|
job.UpdatedBy = User.Identity?.Name;
|
||||||
|
|
||||||
|
var workflowJustCompleted =
|
||||||
|
req.JobId == req.HighlightJobId
|
||||||
|
&& await MaybeMarkFirstWorkflowCompletedFromBoardAsync(job.CompanyId, req.GuidedActivation);
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "StatusChanged",
|
await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "StatusChanged",
|
||||||
@@ -318,7 +335,10 @@ public class JobsController : Controller
|
|||||||
success = true,
|
success = true,
|
||||||
newStatusId = newStatus.Id,
|
newStatusId = newStatus.Id,
|
||||||
newStatusDisplay = newStatus.DisplayName,
|
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
|
/// correctly without a separate AJAX call. Measurement units (sq ft vs m²) are resolved from
|
||||||
/// the tenant's metric preference and passed via ViewBag.
|
/// the tenant's metric preference and passed via ViewBag.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Details(int? id)
|
public async Task<IActionResult> Details(int? id, string? guidedActivation = null)
|
||||||
{
|
{
|
||||||
if (id == null)
|
if (id == null)
|
||||||
{
|
{
|
||||||
@@ -468,6 +488,28 @@ public class JobsController : Controller
|
|||||||
.OrderBy(c => c.Text)
|
.OrderBy(c => c.Text)
|
||||||
.ToList();
|
.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);
|
return View(jobDto);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -817,7 +859,7 @@ public class JobsController : Controller
|
|||||||
/// (pre-configured job types with standard items). If <paramref name="customerId"/> is provided,
|
/// (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.
|
/// the customer dropdown is pre-selected. The wizard is the same multi-step UI as the quote wizard.
|
||||||
/// </summary>
|
/// </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;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
@@ -839,6 +881,11 @@ public class JobsController : Controller
|
|||||||
if (customerId.HasValue)
|
if (customerId.HasValue)
|
||||||
dto.CustomerId = customerId.Value;
|
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
|
// Pre-populate from template if provided
|
||||||
if (templateId.HasValue)
|
if (templateId.HasValue)
|
||||||
{
|
{
|
||||||
@@ -907,6 +954,7 @@ public class JobsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ViewBag.GuidedActivation = guidedActivation;
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -919,13 +967,14 @@ public class JobsController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Create(CreateJobDto dto)
|
public async Task<IActionResult> Create(CreateJobDto dto, string? guidedActivation = null)
|
||||||
{
|
{
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
await PopulateCreateEditWizardViewBagsAsync(companyId);
|
await PopulateCreateEditWizardViewBagsAsync(companyId);
|
||||||
|
ViewBag.GuidedActivation = guidedActivation;
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -937,6 +986,7 @@ public class JobsController : Controller
|
|||||||
$"You have reached your plan limit of {max} active jobs. " +
|
$"You have reached your plan limit of {max} active jobs. " +
|
||||||
"Please upgrade your plan or complete/cancel existing jobs to add more.");
|
"Please upgrade your plan or complete/cancel existing jobs to add more.");
|
||||||
await PopulateCreateEditWizardViewBagsAsync(companyId);
|
await PopulateCreateEditWizardViewBagsAsync(companyId);
|
||||||
|
ViewBag.GuidedActivation = guidedActivation;
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1092,7 +1142,18 @@ public class JobsController : Controller
|
|||||||
if (!string.IsNullOrEmpty(createCompanyId))
|
if (!string.IsNullOrEmpty(createCompanyId))
|
||||||
await _shopHub.Clients.Group($"shop-{createCompanyId}").SendAsync("DailyBoardUpdated");
|
await _shopHub.Clients.Group($"shop-{createCompanyId}").SendAsync("DailyBoardUpdated");
|
||||||
|
|
||||||
|
await StampJobCreatedAsync(companyId);
|
||||||
|
|
||||||
this.ToastSuccess($"Job {job.JobNumber} created successfully!");
|
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 });
|
return RedirectToAction(nameof(Details), new { id = job.Id });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -1100,6 +1161,7 @@ public class JobsController : Controller
|
|||||||
_logger.LogError(ex, "Error creating job");
|
_logger.LogError(ex, "Error creating job");
|
||||||
this.ToastError("An error occurred while creating the job. Please try again.");
|
this.ToastError("An error occurred while creating the job. Please try again.");
|
||||||
await PopulateCreateEditWizardViewBagsAsync(companyId);
|
await PopulateCreateEditWizardViewBagsAsync(companyId);
|
||||||
|
ViewBag.GuidedActivation = guidedActivation;
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2126,6 +2188,10 @@ public class JobsController : Controller
|
|||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var workflowJustCompleted =
|
||||||
|
request.JobId == request.HighlightJobId
|
||||||
|
&& await MaybeMarkFirstWorkflowCompletedFromBoardAsync(job.CompanyId, request.GuidedActivation);
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId()?.ToString();
|
var companyId = _tenantContext.GetCurrentCompanyId()?.ToString();
|
||||||
@@ -2138,7 +2204,15 @@ public class JobsController : Controller
|
|||||||
statusColorClass = newStatus.ColorClass
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -3700,6 +3774,100 @@ public class JobsController : Controller
|
|||||||
return Json(new { error = "Unable to compute costing breakdown." });
|
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; } }
|
public class DeleteTimeEntryRequest { public int Id { get; set; } }
|
||||||
@@ -3756,12 +3924,16 @@ public class MoveCardRequest
|
|||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
public int JobId { get; set; }
|
||||||
public int NewStatusId { get; set; }
|
public int NewStatusId { get; set; }
|
||||||
|
public string? GuidedActivation { get; set; }
|
||||||
|
public int? HighlightJobId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AdvanceJobStatusRequest
|
public class AdvanceJobStatusRequest
|
||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
public int JobId { get; set; }
|
||||||
public int NewStatusId { get; set; }
|
public int NewStatusId { get; set; }
|
||||||
|
public string? GuidedActivation { get; set; }
|
||||||
|
public int? HighlightJobId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateDatesRequest
|
public class UpdateDatesRequest
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ public class QuotesController : Controller
|
|||||||
/// the customer even if operating costs have changed since the quote was created.
|
/// 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).
|
/// Also verifies that ConvertedToJobId still points to a live job (clears stale references).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Details(int? id)
|
public async Task<IActionResult> Details(int? id, string? guidedActivation = null)
|
||||||
{
|
{
|
||||||
if (id == null)
|
if (id == null)
|
||||||
{
|
{
|
||||||
@@ -435,6 +435,21 @@ public class QuotesController : Controller
|
|||||||
.OrderBy(c => c.Text)
|
.OrderBy(c => c.Text)
|
||||||
.ToList();
|
.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);
|
return View(quoteDto);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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
|
/// 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).
|
/// navigating from the Customer Details page using the "New Quote" shortcut).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Create(int? customerId)
|
public async Task<IActionResult> Create(int? customerId, string? guidedActivation = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -682,6 +697,17 @@ public class QuotesController : Controller
|
|||||||
CustomerId = customerId
|
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);
|
return View(dto);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -703,7 +729,7 @@ public class QuotesController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[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("=== CREATE QUOTE POST ACTION CALLED ===");
|
||||||
_logger.LogInformation("IsForProspect: {IsForProspect}", dto.IsForProspect);
|
_logger.LogInformation("IsForProspect: {IsForProspect}", dto.IsForProspect);
|
||||||
@@ -832,6 +858,7 @@ public class QuotesController : Controller
|
|||||||
|
|
||||||
await PopulateDropDownsAsync(currentUser!.CompanyId, operatingCosts?.OvenOperatingCostPerHour ?? 0);
|
await PopulateDropDownsAsync(currentUser!.CompanyId, operatingCosts?.OvenOperatingCostPerHour ?? 0);
|
||||||
await SetMeasurementViewBagAsync();
|
await SetMeasurementViewBagAsync();
|
||||||
|
ViewBag.GuidedActivation = guidedActivation;
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1134,7 +1161,18 @@ public class QuotesController : Controller
|
|||||||
this.SetNotificationResultToast(quoteCreateNotifLog);
|
this.SetNotificationResultToast(quoteCreateNotifLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await StampQuoteCreatedAsync(currentUser.CompanyId);
|
||||||
|
|
||||||
this.ToastSuccess($"Quote {quote.QuoteNumber} created successfully!");
|
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 });
|
return RedirectToAction(nameof(Details), new { id = quote.Id });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -1145,6 +1183,7 @@ public class QuotesController : Controller
|
|||||||
var catchCosts = await _pricingService.GetOperatingCostsAsync(catchUser!.CompanyId);
|
var catchCosts = await _pricingService.GetOperatingCostsAsync(catchUser!.CompanyId);
|
||||||
await PopulateDropDownsAsync(catchUser.CompanyId, catchCosts?.OvenOperatingCostPerHour ?? 0);
|
await PopulateDropDownsAsync(catchUser.CompanyId, catchCosts?.OvenOperatingCostPerHour ?? 0);
|
||||||
await SetMeasurementViewBagAsync();
|
await SetMeasurementViewBagAsync();
|
||||||
|
ViewBag.GuidedActivation = guidedActivation;
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2100,7 +2139,7 @@ public class QuotesController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> ConvertToJob(int id)
|
public async Task<IActionResult> ConvertToJob(int id, string? guidedActivation = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -2231,9 +2270,20 @@ public class QuotesController : Controller
|
|||||||
|
|
||||||
this.ToastSuccess($"Job has been successfully created from quote {quote.QuoteNumber}!");
|
this.ToastSuccess($"Job has been successfully created from quote {quote.QuoteNumber}!");
|
||||||
|
|
||||||
|
await StampJobCreatedAsync(currentUser!.CompanyId);
|
||||||
|
|
||||||
// Redirect to the newly created job's details page
|
// Redirect to the newly created job's details page
|
||||||
if (quote.ConvertedToJobId.HasValue)
|
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 });
|
return RedirectToAction("Details", "Jobs", new { id = quote.ConvertedToJobId.Value });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3791,6 +3841,35 @@ public class QuotesController : Controller
|
|||||||
|
|
||||||
return Json(new { success = true });
|
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
|
// Request model for AJAX pricing calculation
|
||||||
|
|||||||
@@ -311,31 +311,7 @@ public class SetupWizardController : Controller
|
|||||||
ShopCapabilityTier = costs.ShopCapabilityTier
|
ShopCapabilityTier = costs.ShopCapabilityTier
|
||||||
}),
|
}),
|
||||||
4 => await BuildStep4ViewAsync(GetCompanyId()),
|
4 => await BuildStep4ViewAsync(GetCompanyId()),
|
||||||
5 => View("Step5", new WizardStep3Dto
|
5 => View("Step9", new WizardStep7Dto
|
||||||
{
|
|
||||||
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
|
|
||||||
{
|
{
|
||||||
EmailNotificationsEnabled = prefs.EmailNotificationsEnabled,
|
EmailNotificationsEnabled = prefs.EmailNotificationsEnabled,
|
||||||
EmailFromAddress = prefs.EmailFromAddress,
|
EmailFromAddress = prefs.EmailFromAddress,
|
||||||
@@ -351,7 +327,6 @@ public class SetupWizardController : Controller
|
|||||||
DueDateWarningDays = prefs.DueDateWarningDays,
|
DueDateWarningDays = prefs.DueDateWarningDays,
|
||||||
MaintenanceAlertDays = prefs.MaintenanceAlertDays
|
MaintenanceAlertDays = prefs.MaintenanceAlertDays
|
||||||
}),
|
}),
|
||||||
10 => await BuildStep10ViewAsync(GetCompanyId()),
|
|
||||||
_ => RedirectToAction("Step", new { step = 1 })
|
_ => RedirectToAction("Step", new { step = 1 })
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -405,53 +380,6 @@ public class SetupWizardController : Controller
|
|||||||
return View("Step4", dto);
|
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 ───────────────────────────────────────────────────────────
|
// ─── POST Steps ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -675,138 +603,15 @@ public class SetupWizardController : Controller
|
|||||||
return RedirectToStep(5);
|
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>
|
/// <summary>
|
||||||
/// Persists pricing tiers from Step 8, using the same upsert-and-soft-delete pattern as
|
/// Saves notification preferences from Step 5 (the final step). Marks the wizard complete
|
||||||
/// <see cref="PostStep4"/>: existing tiers updated in place, new ones inserted, removed ones
|
/// and hands off to the Guided Activation flow.
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
/// </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]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> PostStep9(WizardStep7Dto model)
|
public async Task<IActionResult> PostStep9(WizardStep7Dto model)
|
||||||
{
|
{
|
||||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
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);
|
if (!ModelState.IsValid) return View("Step9", model);
|
||||||
|
|
||||||
@@ -824,83 +629,9 @@ public class SetupWizardController : Controller
|
|||||||
prefs.DueDateWarningDays = model.DueDateWarningDays;
|
prefs.DueDateWarningDays = model.DueDateWarningDays;
|
||||||
prefs.MaintenanceAlertDays = model.MaintenanceAlertDays;
|
prefs.MaintenanceAlertDays = model.MaintenanceAlertDays;
|
||||||
|
|
||||||
MarkDone(prefs, 9);
|
MarkDone(prefs, 5);
|
||||||
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);
|
|
||||||
prefs.SetupWizardCompleted = true;
|
prefs.SetupWizardCompleted = true;
|
||||||
|
|
||||||
// Record who completed the wizard and when so SuperAdmins can see completion status per-user.
|
|
||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
prefs.SetupWizardCompletedAt = DateTime.UtcNow;
|
prefs.SetupWizardCompletedAt = DateTime.UtcNow;
|
||||||
prefs.SetupWizardCompletedByUserId = currentUser?.Id;
|
prefs.SetupWizardCompletedByUserId = currentUser?.Id;
|
||||||
@@ -909,7 +640,7 @@ public class SetupWizardController : Controller
|
|||||||
: User.Identity?.Name;
|
: User.Identity?.Name;
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
return RedirectToAction(nameof(Complete));
|
return RedirectToAction("Start", "GuidedActivation");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Skip ─────────────────────────────────────────────────────────────────
|
// ─── Skip ─────────────────────────────────────────────────────────────────
|
||||||
@@ -927,6 +658,18 @@ public class SetupWizardController : Controller
|
|||||||
{
|
{
|
||||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||||
MarkSkipped(prefs, step);
|
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();
|
await _unitOfWork.CompleteAsync();
|
||||||
int next = step >= WizardProgressDto.TotalSteps ? 0 : step + 1;
|
int next = step >= WizardProgressDto.TotalSteps ? 0 : step + 1;
|
||||||
return RedirectToStep(next == 0 ? WizardProgressDto.TotalSteps + 1 : next);
|
return RedirectToStep(next == 0 ? WizardProgressDto.TotalSteps + 1 : next);
|
||||||
@@ -975,6 +718,7 @@ public class SetupWizardController : Controller
|
|||||||
{
|
{
|
||||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||||
ViewBag.Progress = BuildProgress(prefs);
|
ViewBag.Progress = BuildProgress(prefs);
|
||||||
|
ViewBag.ShowGuidedActivationCta = prefs.SetupWizardCompleted && !prefs.FirstWorkflowCompleted;
|
||||||
return View();
|
return View();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using PowderCoating.Application.DTOs.Job;
|
||||||
|
using PowderCoating.Application.DTOs.Quote;
|
||||||
|
|
||||||
|
namespace PowderCoating.Web.Helpers;
|
||||||
|
|
||||||
|
internal static class GuidedActivationDefaults
|
||||||
|
{
|
||||||
|
public static CreateQuoteDto BuildQuoteDraft(int? customerId)
|
||||||
|
{
|
||||||
|
return new CreateQuoteDto
|
||||||
|
{
|
||||||
|
CustomerId = customerId,
|
||||||
|
QuoteDate = DateTime.Today,
|
||||||
|
ExpirationDate = DateTime.Today.AddDays(30),
|
||||||
|
Description = "Sample onboarding quote",
|
||||||
|
Notes = "Sample onboarding quote",
|
||||||
|
QuoteItems =
|
||||||
|
[
|
||||||
|
BuildSampleItem("Sample onboarding quote")
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CreateJobDto BuildJobDraft(int customerId, int jobPriorityId)
|
||||||
|
{
|
||||||
|
return new CreateJobDto
|
||||||
|
{
|
||||||
|
CustomerId = customerId,
|
||||||
|
JobPriorityId = jobPriorityId,
|
||||||
|
Description = "Wheel Set",
|
||||||
|
SpecialInstructions = "Sample onboarding job",
|
||||||
|
JobItems =
|
||||||
|
[
|
||||||
|
BuildSampleItem("Sample onboarding job")
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateQuoteItemDto BuildSampleItem(string notes)
|
||||||
|
{
|
||||||
|
return new CreateQuoteItemDto
|
||||||
|
{
|
||||||
|
Description = "Wheel Set",
|
||||||
|
Quantity = 4,
|
||||||
|
SurfaceAreaSqFt = 0,
|
||||||
|
EstimatedMinutes = 0,
|
||||||
|
IsGenericItem = true,
|
||||||
|
ManualUnitPrice = 125m,
|
||||||
|
Notes = notes,
|
||||||
|
IncludePrepCost = false,
|
||||||
|
Complexity = "Moderate"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
namespace PowderCoating.Web.ViewModels.Dashboard;
|
||||||
|
|
||||||
|
public class ShopProgressWidgetViewModel
|
||||||
|
{
|
||||||
|
public List<ShopProgressItem> Items { get; set; } = new();
|
||||||
|
public int TotalItems => Items.Count;
|
||||||
|
public int CompletedCount => Items.Count(i => i.Done);
|
||||||
|
public bool AllDone => CompletedCount == TotalItems;
|
||||||
|
public int ProgressPercent => TotalItems == 0 ? 0 : CompletedCount * 100 / TotalItems;
|
||||||
|
|
||||||
|
public string SubtitleText => CompletedCount switch
|
||||||
|
{
|
||||||
|
0 => "You're set up and ready to go — these steps will help you run a tighter shop.",
|
||||||
|
1 => "You're off to a great start — here's how to run your shop even smoother.",
|
||||||
|
2 => "Good momentum — keep going.",
|
||||||
|
3 => "Halfway there — great progress so far.",
|
||||||
|
4 => "Almost there — just a couple more steps.",
|
||||||
|
5 => "One step left — you're this close.",
|
||||||
|
_ => "You're all set."
|
||||||
|
};
|
||||||
|
|
||||||
|
public string BadgeText => CompletedCount switch
|
||||||
|
{
|
||||||
|
0 => $"0 of {TotalItems} complete",
|
||||||
|
1 => $"1 of {TotalItems} — you're on your way",
|
||||||
|
2 => $"2 of {TotalItems} — good momentum",
|
||||||
|
3 => $"3 of {TotalItems} — halfway there",
|
||||||
|
4 => $"4 of {TotalItems} — almost there",
|
||||||
|
5 => $"5 of {TotalItems} — one step left",
|
||||||
|
_ => $"{CompletedCount} of {TotalItems} complete"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ShopProgressItem
|
||||||
|
{
|
||||||
|
public bool Done { get; set; }
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public string SubLabel { get; set; } = string.Empty;
|
||||||
|
public string DoneSubLabel { get; set; } = string.Empty;
|
||||||
|
public string Icon { get; set; } = string.Empty;
|
||||||
|
public string CtaText { get; set; } = string.Empty;
|
||||||
|
public string CtaUrl { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace PowderCoating.Web.ViewModels.GuidedActivation;
|
||||||
|
|
||||||
|
public class GuidedActivationSelectionViewModel
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string? OnboardingPath { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GuidedActivationBannerViewModel
|
||||||
|
{
|
||||||
|
public bool Show { get; set; }
|
||||||
|
public bool IsDismissed { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public string ActionText { get; set; } = "Start first workflow";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GuidedActivationCalloutViewModel
|
||||||
|
{
|
||||||
|
public bool Show { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
/// <summary>Optional action prompt rendered below the message (e.g. "Move this job to the next stage…").</summary>
|
||||||
|
public string? InstructionText { get; set; }
|
||||||
|
public string? ActionText { get; set; }
|
||||||
|
public string? ActionController { get; set; }
|
||||||
|
public string? ActionName { get; set; }
|
||||||
|
public object? ActionRouteValues { get; set; }
|
||||||
|
public string? SecondaryActionText { get; set; }
|
||||||
|
public string? SecondaryActionController { get; set; }
|
||||||
|
public string? SecondaryActionName { get; set; }
|
||||||
|
public object? SecondaryActionRouteValues { get; set; }
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
@model PowderCoating.Application.DTOs.Dashboard.DashboardViewModel
|
@model PowderCoating.Application.DTOs.Dashboard.DashboardViewModel
|
||||||
@using Microsoft.AspNetCore.Html
|
@using Microsoft.AspNetCore.Html
|
||||||
@using PowderCoating.Application.DTOs.Health
|
@using PowderCoating.Application.DTOs.Health
|
||||||
|
@using PowderCoating.Web.ViewModels.Dashboard
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Dashboard";
|
ViewData["Title"] = "Dashboard";
|
||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
var currentMonth = DateTime.Now.ToString("MMMM yyyy");
|
var currentMonth = DateTime.Now.ToString("MMMM yyyy");
|
||||||
var configHealth = ViewBag.ConfigHealth as CompanyConfigHealth;
|
var configHealth = ViewBag.ConfigHealth as CompanyConfigHealth;
|
||||||
|
var guidedActivationBanner = ViewBag.GuidedActivationBanner as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationBannerViewModel;
|
||||||
|
var shopProgressWidget = ViewBag.ShopProgressWidget as ShopProgressWidgetViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Hero Brief -->
|
<!-- Hero Brief -->
|
||||||
@@ -56,6 +59,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (guidedActivationBanner?.Show == true)
|
||||||
|
{
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm @(guidedActivationBanner.IsDismissed ? "" : "border-start border-4 border-primary")">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<div class="d-flex flex-column flex-lg-row gap-3 align-items-lg-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold mb-1">@guidedActivationBanner.Title</div>
|
||||||
|
<div class="text-muted">@guidedActivationBanner.Message</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a asp-controller="GuidedActivation" asp-action="Start" class="btn @(guidedActivationBanner.IsDismissed ? "btn-outline-primary" : "btn-primary")">
|
||||||
|
@guidedActivationBanner.ActionText
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (shopProgressWidget != null)
|
||||||
|
{
|
||||||
|
@await Html.PartialAsync("_ShopProgressWidget", shopProgressWidget)
|
||||||
|
}
|
||||||
|
|
||||||
@* Config health alert — only shown when there are setup gaps *@
|
@* Config health alert — only shown when there are setup gaps *@
|
||||||
@if (configHealth != null && !configHealth.IsHealthy)
|
@if (configHealth != null && !configHealth.IsHealthy)
|
||||||
{
|
{
|
||||||
@@ -777,6 +808,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
|
<script src="~/js/shop-progress-widget.js" asp-append-version="true"></script>
|
||||||
<script>
|
<script>
|
||||||
// Powder Orders - Mark as Ordered
|
// Powder Orders - Mark as Ordered
|
||||||
document.querySelectorAll('.mark-ordered-btn').forEach(btn => {
|
document.querySelectorAll('.mark-ordered-btn').forEach(btn => {
|
||||||
@@ -1250,4 +1282,3 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
@using PowderCoating.Web.ViewModels.Dashboard
|
||||||
|
@model ShopProgressWidgetViewModel
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm mb-4" id="shopProgressWidget">
|
||||||
|
<div class="card-header d-flex align-items-center gap-2 py-2 px-4"
|
||||||
|
style="background:var(--pcl-paper-2);border-bottom:1px solid var(--pcl-rule);">
|
||||||
|
<i class="bi bi-rocket-takeoff" style="color:var(--pcl-blue);"></i>
|
||||||
|
<span class="fw-semibold" style="color:var(--pcl-ink);">Get the most out of your shop</span>
|
||||||
|
@if (!Model.AllDone)
|
||||||
|
{
|
||||||
|
<span class="ms-auto badge rounded-pill bg-secondary">@Model.BadgeText</span>
|
||||||
|
}
|
||||||
|
<button class="btn btn-link btn-sm p-0 @(Model.AllDone ? "ms-auto" : "ms-2") text-secondary"
|
||||||
|
id="shopProgressToggle" title="Collapse" style="line-height:1;">
|
||||||
|
<i class="bi bi-chevron-up" id="shopProgressChevron"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="shopProgressBody">
|
||||||
|
@if (Model.AllDone)
|
||||||
|
{
|
||||||
|
<div class="px-4 py-4 text-center">
|
||||||
|
<div class="fw-semibold mb-1" style="font-size:1rem;color:var(--pcl-ink);">Your shop is fully set up 🎉</div>
|
||||||
|
<div class="text-muted mb-3" style="font-size:0.85rem;">You're ready to run everything from here.</div>
|
||||||
|
<a href="@Url.Action("Create", "Jobs")" class="btn btn-primary btn-sm">
|
||||||
|
Create job <i class="bi bi-arrow-right ms-1"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="px-4 pt-3 pb-1">
|
||||||
|
<p class="text-muted mb-2" style="font-size:0.85rem;">@Model.SubtitleText</p>
|
||||||
|
<div class="progress mb-1" style="height:5px;border-radius:3px;">
|
||||||
|
<div class="progress-bar @(Model.ProgressPercent >= 60 ? "bg-success" : "bg-primary")"
|
||||||
|
role="progressbar"
|
||||||
|
style="width:@Model.ProgressPercent%;transition:width 0.4s ease;"
|
||||||
|
aria-valuenow="@Model.ProgressPercent" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="list-group list-group-flush mb-1">
|
||||||
|
@{
|
||||||
|
var nextFound = false;
|
||||||
|
bool? prevDone = null;
|
||||||
|
}
|
||||||
|
@foreach (var item in Model.Items)
|
||||||
|
{
|
||||||
|
var isNext = !item.Done && !nextFound;
|
||||||
|
if (isNext) { nextFound = true; }
|
||||||
|
|
||||||
|
@if (prevDone.HasValue && prevDone.Value && !item.Done)
|
||||||
|
{
|
||||||
|
<li class="list-group-item border-0 px-4 py-0" style="background:transparent;">
|
||||||
|
<hr class="my-0" style="border-color:var(--pcl-rule);">
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
prevDone = item.Done;
|
||||||
|
|
||||||
|
<li class="list-group-item border-0 d-flex align-items-center gap-3 px-4 py-2"
|
||||||
|
style="background:@(isNext ? "rgba(13,110,253,0.04)" : "transparent");">
|
||||||
|
@if (item.Done)
|
||||||
|
{
|
||||||
|
<i class="bi bi-check-circle-fill flex-shrink-0"
|
||||||
|
style="color:var(--pcl-good);font-size:1.1rem;"></i>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="bi @item.Icon flex-shrink-0 text-muted" style="font-size:1.1rem;"></i>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex-grow-1 min-width-0">
|
||||||
|
<div class="fw-medium @(item.Done ? "text-muted" : "")" style="font-size:0.875rem;">
|
||||||
|
@item.Label
|
||||||
|
@if (isNext)
|
||||||
|
{
|
||||||
|
<span class="badge bg-primary bg-opacity-10 text-primary rounded-pill ms-1" style="font-size:0.65rem;">Next</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (item.Done)
|
||||||
|
{
|
||||||
|
@if (!string.IsNullOrEmpty(item.DoneSubLabel))
|
||||||
|
{
|
||||||
|
<div style="font-size:0.78rem;color:var(--pcl-good);">@item.DoneSubLabel</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="text-muted" style="font-size:0.78rem;">@item.SubLabel</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (item.Done)
|
||||||
|
{
|
||||||
|
<span class="flex-shrink-0 fw-medium"
|
||||||
|
style="font-size:0.78rem;color:var(--pcl-good);">Done</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a href="@item.CtaUrl"
|
||||||
|
class="btn btn-sm @(isNext ? "btn-primary" : "btn-outline-primary") flex-shrink-0">
|
||||||
|
@item.CtaText <i class="bi bi-arrow-right ms-1"></i>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
@model PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationSelectionViewModel
|
||||||
|
@using PowderCoating.Shared.Constants
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Start Your First Workflow";
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<style>
|
||||||
|
.ga-shell {
|
||||||
|
min-height: calc(100vh - 12rem);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ga-card {
|
||||||
|
width: min(920px, 100%);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ga-hero {
|
||||||
|
background: linear-gradient(145deg, #0f172a 0%, #1d4ed8 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ga-option {
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ga-option:hover {
|
||||||
|
border-color: #2563eb;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 12px 24px rgba(37, 99, 235, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ga-option input {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="ga-shell">
|
||||||
|
<div class="card ga-card">
|
||||||
|
<div class="ga-hero">
|
||||||
|
<div class="text-uppercase small fw-semibold mb-2" style="letter-spacing:0.12em;opacity:0.8;">Guided Activation</div>
|
||||||
|
<h1 class="h2 fw-bold mb-2">Your shop is set up. Let's run your first workflow.</h1>
|
||||||
|
<p class="mb-0" style="max-width:42rem;color:rgba(255,255,255,0.82);">
|
||||||
|
Choose how jobs usually start for your shop and we'll guide you through it with real quotes, jobs, and invoices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-4 p-lg-5">
|
||||||
|
<form asp-action="Select" method="post">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
|
||||||
|
|
||||||
|
<p class="text-muted fw-semibold mb-3">How do jobs usually start for your shop?</p>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="ga-option d-flex gap-3" for="pathQuoteFirst">
|
||||||
|
<input asp-for="OnboardingPath" id="pathQuoteFirst" type="radio"
|
||||||
|
value="@AppConstants.GuidedActivation.QuoteFirstPath" class="form-check-input" />
|
||||||
|
<span>
|
||||||
|
<span class="d-block fw-bold fs-5 text-dark">I send a quote first</span>
|
||||||
|
<span class="d-block text-muted mt-2">
|
||||||
|
Create a quote, convert it to a job, then invoice when work is complete.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="ga-option d-flex gap-3" for="pathJobFirst">
|
||||||
|
<input asp-for="OnboardingPath" id="pathJobFirst" type="radio"
|
||||||
|
value="@AppConstants.GuidedActivation.JobFirstPath" class="form-check-input" />
|
||||||
|
<span>
|
||||||
|
<span class="d-block fw-bold fs-5 text-dark">I start with a job</span>
|
||||||
|
<span class="d-block text-muted mt-2">
|
||||||
|
For walk-ins or approved work where you start immediately.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg px-4">
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form asp-action="Skip" method="post" class="mt-3">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<button type="submit" class="btn btn-link text-muted px-0">Skip for now</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
|
|
||||||
<form asp-action="Create" method="post" id="invoiceForm">
|
<form asp-action="Create" method="post" id="invoiceForm">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
|
<input type="hidden" name="guidedActivation" value="@ViewBag.GuidedActivation" />
|
||||||
|
|
||||||
@if (!ViewData.ModelState.IsValid)
|
@if (!ViewData.ModelState.IsValid)
|
||||||
{
|
{
|
||||||
@@ -47,6 +48,14 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (ViewBag.GuidedActivation != null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-primary alert-permanent border-0 shadow-sm mb-4">
|
||||||
|
<div class="fw-semibold mb-1">Optional next step: Create the invoice</div>
|
||||||
|
<div>This uses the real invoice flow. Review the line items, then save when you want to close the loop with billing.</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<input type="hidden" asp-for="PreparedById" />
|
<input type="hidden" asp-for="PreparedById" />
|
||||||
<input type="hidden" asp-for="JobId" />
|
<input type="hidden" asp-for="JobId" />
|
||||||
<input type="hidden" asp-for="CustomerId" id="hiddenCustomerId" />
|
<input type="hidden" asp-for="CustomerId" id="hiddenCustomerId" />
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
&& (Model.PaymentLinkExpiresAt == null || Model.PaymentLinkExpiresAt <= DateTime.UtcNow);
|
&& (Model.PaymentLinkExpiresAt == null || Model.PaymentLinkExpiresAt <= DateTime.UtcNow);
|
||||||
var onlinePaymentsEnabled = ViewBag.OnlinePaymentsEnabled == true;
|
var onlinePaymentsEnabled = ViewBag.OnlinePaymentsEnabled == true;
|
||||||
var showOnlinePaymentCard = !isDraft && !isVoided && Model.BalanceDue > 0 && onlinePaymentsEnabled;
|
var showOnlinePaymentCard = !isDraft && !isVoided && Model.BalanceDue > 0 && onlinePaymentsEnabled;
|
||||||
|
var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
@@ -69,6 +70,23 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (guidedActivationCallout?.Show == true)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success alert-permanent border-0 shadow-sm mb-4">
|
||||||
|
<div class="d-flex flex-column flex-lg-row gap-3 align-items-lg-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold mb-1">@guidedActivationCallout.Title</div>
|
||||||
|
<div>@guidedActivationCallout.Message</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a asp-controller="Dashboard" asp-action="Index" class="btn btn-success">
|
||||||
|
@guidedActivationCallout.ActionText
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Status Banner -->
|
<!-- Status Banner -->
|
||||||
<div class="alert alert-@statusColor alert-permanent d-flex align-items-center mb-4">
|
<div class="alert alert-@statusColor alert-permanent d-flex align-items-center mb-4">
|
||||||
<i class="bi bi-info-circle me-2" style="font-size:1.4rem;"></i>
|
<i class="bi bi-info-circle me-2" style="font-size:1.4rem;"></i>
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
@using PowderCoating.Shared.Constants
|
||||||
@using PowderCoating.Web.Controllers
|
@using PowderCoating.Web.Controllers
|
||||||
|
@using PowderCoating.Web.ViewModels.GuidedActivation
|
||||||
@model List<JobBoardColumn>
|
@model List<JobBoardColumn>
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Jobs Board";
|
ViewData["Title"] = "Jobs Board";
|
||||||
bool showTerminal = ViewBag.ShowTerminal == true;
|
bool showTerminal = ViewBag.ShowTerminal == true;
|
||||||
int totalTerminal = (int)(ViewBag.TotalTerminal ?? 0);
|
int totalTerminal = (int)(ViewBag.TotalTerminal ?? 0);
|
||||||
|
var guidedActivationCallout = ViewBag.GuidedActivationCallout as GuidedActivationCalloutViewModel;
|
||||||
|
string? guidedActivation = ViewBag.GuidedActivation as string;
|
||||||
|
int? highlightJobId = ViewBag.GuidedActivationHighlightJobId is int highlightedId ? highlightedId : null;
|
||||||
|
var highlightedCard = highlightJobId.HasValue
|
||||||
|
? Model.SelectMany(c => c.Jobs).FirstOrDefault(j => j.Id == highlightJobId.Value)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Styles {
|
@section Styles {
|
||||||
@@ -108,7 +115,19 @@
|
|||||||
}
|
}
|
||||||
.board-card:hover { background: var(--pcl-paper-2); color: var(--pcl-ink); }
|
.board-card:hover { background: var(--pcl-paper-2); color: var(--pcl-ink); }
|
||||||
.board-card.board-card-hot { box-shadow: inset 2px 0 0 var(--pcl-bad); }
|
.board-card.board-card-hot { box-shadow: inset 2px 0 0 var(--pcl-bad); }
|
||||||
|
.board-card.board-card-guided {
|
||||||
|
border-color: var(--pcl-cool);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--pcl-cool) 24%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--pcl-cool) 8%, var(--pcl-card));
|
||||||
|
}
|
||||||
.board-card.dragging { opacity: .5; cursor: grabbing; }
|
.board-card.dragging { opacity: .5; cursor: grabbing; }
|
||||||
|
.board-guided-badge {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--pcl-cool);
|
||||||
|
}
|
||||||
|
|
||||||
/* Card content */
|
/* Card content */
|
||||||
.card-job-number { font-family: var(--font-mono); font-weight: 500; font-size: .8rem; color: var(--pcl-ink); }
|
.card-job-number { font-family: var(--font-mono); font-weight: 500; font-size: .8rem; color: var(--pcl-ink); }
|
||||||
@@ -171,6 +190,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="board-outer">
|
<div class="board-outer">
|
||||||
|
@if (guidedActivationCallout?.Show == true)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info alert-permanent border-0 shadow-sm mb-3">
|
||||||
|
<div class="d-flex flex-column flex-xl-row gap-3 align-items-xl-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold mb-1">@guidedActivationCallout.Title</div>
|
||||||
|
<div>@guidedActivationCallout.Message</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(guidedActivationCallout.InstructionText))
|
||||||
|
{
|
||||||
|
<div class="fw-semibold mt-2 small" style="color:var(--pcl-ink);">
|
||||||
|
<i class="bi bi-arrow-right-circle me-1"></i>@guidedActivationCallout.InstructionText
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(guidedActivationCallout.ActionText))
|
||||||
|
{
|
||||||
|
<a href="@Url.Action(guidedActivationCallout.ActionName, guidedActivationCallout.ActionController, guidedActivationCallout.ActionRouteValues)"
|
||||||
|
class="btn btn-primary">
|
||||||
|
@guidedActivationCallout.ActionText
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(guidedActivationCallout.SecondaryActionText))
|
||||||
|
{
|
||||||
|
<a href="@Url.Action(guidedActivationCallout.SecondaryActionName, guidedActivationCallout.SecondaryActionController, guidedActivationCallout.SecondaryActionRouteValues)"
|
||||||
|
class="btn btn-outline-primary">
|
||||||
|
@guidedActivationCallout.SecondaryActionText
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@* Toolbar *@
|
@* Toolbar *@
|
||||||
@{
|
@{
|
||||||
var _totalOnFloor = Model.Sum(c => c.Jobs.Count);
|
var _totalOnFloor = Model.Sum(c => c.Jobs.Count);
|
||||||
@@ -181,7 +234,11 @@
|
|||||||
@* Left: view switch + live stats *@
|
@* Left: view switch + live stats *@
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="board-view-switch">
|
<div class="board-view-switch">
|
||||||
<a asp-action="Board" class="active">Board</a>
|
<a asp-action="Board"
|
||||||
|
asp-route-showTerminal="@showTerminal"
|
||||||
|
asp-route-guidedActivation="@guidedActivation"
|
||||||
|
asp-route-highlightJobId="@highlightJobId"
|
||||||
|
class="active">Board</a>
|
||||||
<a asp-action="Index">List</a>
|
<a asp-action="Index">List</a>
|
||||||
</div>
|
</div>
|
||||||
<span class="mono" style="font-size:.75rem;color:var(--pcl-steel)">
|
<span class="mono" style="font-size:.75rem;color:var(--pcl-steel)">
|
||||||
@@ -195,7 +252,7 @@
|
|||||||
|
|
||||||
@* Right: actions *@
|
@* Right: actions *@
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<a href="@Url.Action("Board", new { showTerminal = !showTerminal })"
|
<a href="@Url.Action("Board", new { showTerminal = !showTerminal, guidedActivation, highlightJobId })"
|
||||||
class="btn btn-sm btn-outline-secondary"
|
class="btn btn-sm btn-outline-secondary"
|
||||||
id="toggleTerminalBtn">
|
id="toggleTerminalBtn">
|
||||||
<i class="bi bi-archive me-1"></i>
|
<i class="bi bi-archive me-1"></i>
|
||||||
@@ -286,12 +343,16 @@
|
|||||||
_ => "board-priority-secondary"
|
_ => "board-priority-secondary"
|
||||||
};
|
};
|
||||||
<a href="@Url.Action("Details", new { id = card.Id })"
|
<a href="@Url.Action("Details", new { id = card.Id })"
|
||||||
class="board-card@(card.IsOverdue ? " board-card-hot" : "")"
|
class="board-card@(card.IsOverdue ? " board-card-hot" : "")@(highlightJobId == card.Id ? " board-card-guided" : "")"
|
||||||
data-job-id="@card.Id"
|
data-job-id="@card.Id"
|
||||||
onclick="return false">
|
onclick="return false">
|
||||||
|
|
||||||
<div class="d-flex align-items-start justify-content-between gap-1">
|
<div class="d-flex align-items-start justify-content-between gap-1">
|
||||||
<span class="card-job-number">@card.JobNumber</span>
|
<span class="card-job-number">@card.JobNumber</span>
|
||||||
|
@if (highlightJobId == card.Id)
|
||||||
|
{
|
||||||
|
<span class="board-guided-badge">Guided Job</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-customer">@card.CustomerName</div>
|
<div class="card-customer">@card.CustomerName</div>
|
||||||
@@ -347,6 +408,8 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const COMPANY_ID = '@(User.FindFirst("CompanyId")?.Value ?? "0")';
|
const COMPANY_ID = '@(User.FindFirst("CompanyId")?.Value ?? "0")';
|
||||||
|
const guidedActivation = '@(guidedActivation ?? string.Empty)';
|
||||||
|
const highlightJobId = @(highlightJobId?.ToString() ?? "null");
|
||||||
|
|
||||||
// ── Show Completed persistence ───────────────────────────────────────────
|
// ── Show Completed persistence ───────────────────────────────────────────
|
||||||
const TERMINAL_KEY = `jobBoard_showTerminal_${COMPANY_ID}`;
|
const TERMINAL_KEY = `jobBoard_showTerminal_${COMPANY_ID}`;
|
||||||
@@ -402,6 +465,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureGuidedCardVisible() {
|
||||||
|
if (!highlightJobId) return;
|
||||||
|
|
||||||
|
const card = document.querySelector(`.board-card[data-job-id="${highlightJobId}"]`);
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
const column = card.closest('.board-column');
|
||||||
|
if (column?.classList.contains('col-hidden')) {
|
||||||
|
const statusId = parseInt(column.dataset.statusId);
|
||||||
|
const hidden = loadHiddenCols();
|
||||||
|
const idx = hidden.indexOf(statusId);
|
||||||
|
if (idx > -1) {
|
||||||
|
hidden.splice(idx, 1);
|
||||||
|
saveHiddenCols(hidden);
|
||||||
|
applyVisibility();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
card.classList.add('board-card-guided');
|
||||||
|
card.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.col-vis-check').forEach(cb => {
|
document.querySelectorAll('.col-vis-check').forEach(cb => {
|
||||||
cb.addEventListener('change', () => {
|
cb.addEventListener('change', () => {
|
||||||
const id = parseInt(cb.dataset.statusId);
|
const id = parseInt(cb.dataset.statusId);
|
||||||
@@ -458,6 +543,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
applyColOrder();
|
applyColOrder();
|
||||||
|
ensureGuidedCardVisible();
|
||||||
|
|
||||||
// ── Drag & drop + card navigation ────────────────────────────────────────
|
// ── Drag & drop + card navigation ────────────────────────────────────────
|
||||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value
|
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value
|
||||||
@@ -506,13 +592,26 @@
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'RequestVerificationToken': token
|
'RequestVerificationToken': token
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ jobId, newStatusId: newStatus })
|
body: JSON.stringify({
|
||||||
|
jobId,
|
||||||
|
newStatusId: newStatus,
|
||||||
|
guidedActivation,
|
||||||
|
highlightJobId
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
// Update card's priority border stays — status shown by column
|
// Update card's priority border stays — status shown by column
|
||||||
showToast(`Moved to ${data.newStatusDisplay}`, true);
|
showToast(`Moved to ${data.newStatusDisplay}`, true);
|
||||||
|
|
||||||
|
if (data.guidedActivationNext && highlightJobId && jobId === highlightJobId) {
|
||||||
|
const nextUrl = new URL(window.location.href);
|
||||||
|
nextUrl.searchParams.set('guidedActivation', data.guidedActivationNext);
|
||||||
|
nextUrl.searchParams.set('highlightJobId', String(highlightJobId));
|
||||||
|
setTimeout(() => { window.location.href = nextUrl.toString(); }, 700);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Revert
|
// Revert
|
||||||
oldColEl.insertBefore(card, oldColEl.children[evt.oldIndex] ?? null);
|
oldColEl.insertBefore(card, oldColEl.children[evt.oldIndex] ?? null);
|
||||||
|
|||||||
@@ -27,12 +27,21 @@
|
|||||||
|
|
||||||
<form asp-action="Create" method="post" id="jobCreateForm">
|
<form asp-action="Create" method="post" id="jobCreateForm">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
|
<input type="hidden" name="guidedActivation" value="@ViewBag.GuidedActivation" />
|
||||||
@if (ViewBag.TemplateId != null)
|
@if (ViewBag.TemplateId != null)
|
||||||
{
|
{
|
||||||
<input type="hidden" name="SourceTemplateId" value="@ViewBag.TemplateId">
|
<input type="hidden" name="SourceTemplateId" value="@ViewBag.TemplateId">
|
||||||
}
|
}
|
||||||
<partial name="_ValidationSummary" />
|
<partial name="_ValidationSummary" />
|
||||||
|
|
||||||
|
@if ((ViewBag.GuidedActivation as string) == PowderCoating.Shared.Constants.AppConstants.GuidedActivation.JobFirstPath)
|
||||||
|
{
|
||||||
|
<div class="alert alert-primary alert-permanent border-0 shadow-sm mb-4">
|
||||||
|
<div class="fw-semibold mb-1">Step 1: Create your first sample job</div>
|
||||||
|
<div>We've prefilled a quick example. You can edit anything before saving.</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Job Details Card -->
|
<!-- Job Details Card -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@{
|
@{
|
||||||
ViewData["Title"] = $"Job {Model.JobNumber}";
|
ViewData["Title"] = $"Job {Model.JobNumber}";
|
||||||
ViewData["PageIcon"] = "bi-briefcase";
|
ViewData["PageIcon"] = "bi-briefcase";
|
||||||
|
var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
@@ -38,6 +39,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (guidedActivationCallout?.Show == true)
|
||||||
|
{
|
||||||
|
<div class="alert alert-primary alert-permanent border-0 shadow-sm mb-4">
|
||||||
|
<div class="d-flex flex-column flex-lg-row gap-3 align-items-lg-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold mb-1">@guidedActivationCallout.Title</div>
|
||||||
|
<div>@guidedActivationCallout.Message</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(guidedActivationCallout.ActionText))
|
||||||
|
{
|
||||||
|
<a href="@Url.Action(guidedActivationCallout.ActionName, guidedActivationCallout.ActionController, guidedActivationCallout.ActionRouteValues)"
|
||||||
|
class="btn btn-primary">
|
||||||
|
@guidedActivationCallout.ActionText
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(guidedActivationCallout.SecondaryActionText))
|
||||||
|
{
|
||||||
|
<a href="@Url.Action(guidedActivationCallout.SecondaryActionName, guidedActivationCallout.SecondaryActionController, guidedActivationCallout.SecondaryActionRouteValues)"
|
||||||
|
class="btn btn-outline-primary">
|
||||||
|
@guidedActivationCallout.SecondaryActionText
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<!-- Left Column -->
|
<!-- Left Column -->
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
|
|||||||
@@ -18,8 +18,17 @@
|
|||||||
|
|
||||||
<form asp-action="Create" asp-controller="Quotes" method="post" id="quoteForm">
|
<form asp-action="Create" asp-controller="Quotes" method="post" id="quoteForm">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
|
<input type="hidden" name="guidedActivation" value="@ViewBag.GuidedActivation" />
|
||||||
<input type="hidden" asp-for="TaxPercent" />
|
<input type="hidden" asp-for="TaxPercent" />
|
||||||
|
|
||||||
|
@if ((ViewBag.GuidedActivation as string) == PowderCoating.Shared.Constants.AppConstants.GuidedActivation.QuoteFirstPath)
|
||||||
|
{
|
||||||
|
<div class="alert alert-primary alert-permanent border-0 shadow-sm mb-4">
|
||||||
|
<div class="fw-semibold mb-1">Step 1: Create your first sample quote</div>
|
||||||
|
<div>We've prefilled a quick example. You can edit anything before saving.</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Mode Toggle -->
|
<!-- Mode Toggle -->
|
||||||
<div class="d-flex align-items-center gap-2 mb-3">
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
<div class="quote-mode-toggle" role="group" aria-label="Quote mode">
|
<div class="quote-mode-toggle" role="group" aria-label="Quote mode">
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
@{
|
@{
|
||||||
ViewData["Title"] = $"Quote {Model.QuoteNumber}";
|
ViewData["Title"] = $"Quote {Model.QuoteNumber}";
|
||||||
ViewData["PageIcon"] = "bi-file-text";
|
ViewData["PageIcon"] = "bi-file-text";
|
||||||
|
var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel;
|
||||||
|
var guidedActivationMode = ViewBag.GuidedActivationMode as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="container-fluid mt-4">
|
<div class="container-fluid mt-4">
|
||||||
@@ -43,7 +45,28 @@
|
|||||||
<i class="bi bi-arrow-left me-1"></i>Back
|
<i class="bi bi-arrow-left me-1"></i>Back
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div> <div class="row">
|
</div>
|
||||||
|
|
||||||
|
@if (guidedActivationCallout?.Show == true)
|
||||||
|
{
|
||||||
|
<div class="alert alert-primary alert-permanent border-0 shadow-sm d-flex flex-column flex-lg-row gap-3 align-items-lg-center justify-content-between mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold mb-1">@guidedActivationCallout.Title</div>
|
||||||
|
<div>@guidedActivationCallout.Message</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<form asp-action="ConvertToJob" asp-route-id="@Model.Id" method="post" class="d-inline">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<input type="hidden" name="guidedActivation" value="@guidedActivationMode" />
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
@guidedActivationCallout.ActionText
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
<!-- Left Column: Quote Information -->
|
<!-- Left Column: Quote Information -->
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
<!-- Customer/Prospect Info -->
|
<!-- Customer/Prospect Info -->
|
||||||
@@ -1461,6 +1484,7 @@
|
|||||||
{
|
{
|
||||||
<form asp-action="ConvertToJob" asp-route-id="@Model.Id" method="post" class="d-inline" id="createJobForm">
|
<form asp-action="ConvertToJob" asp-route-id="@Model.Id" method="post" class="d-inline" id="createJobForm">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
|
<input type="hidden" name="guidedActivation" value="@guidedActivationMode" />
|
||||||
<button type="button" class="btn btn-success w-100" data-bs-toggle="modal" data-bs-target="#createJobModal">
|
<button type="button" class="btn btn-success w-100" data-bs-toggle="modal" data-bs-target="#createJobModal">
|
||||||
<i class="bi bi-clipboard-check me-1"></i>Create Job from Quote
|
<i class="bi bi-clipboard-check me-1"></i>Create Job from Quote
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Setup Complete!";
|
ViewData["Title"] = "Setup Complete!";
|
||||||
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
|
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
|
||||||
|
var showGuidedActivationCta = (bool?)ViewBag.ShowGuidedActivationCta ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Styles {
|
@section Styles {
|
||||||
@@ -79,9 +80,18 @@
|
|||||||
<p style="color:rgba(255,255,255,0.8);font-size:1.05rem;max-width:500px;margin:0 auto 1.5rem;">
|
<p style="color:rgba(255,255,255,0.8);font-size:1.05rem;max-width:500px;margin:0 auto 1.5rem;">
|
||||||
Your setup is complete. @progress.DoneSteps.Count of @WizardProgressDto.TotalSteps steps were configured — your shop is ready to roll.
|
Your setup is complete. @progress.DoneSteps.Count of @WizardProgressDto.TotalSteps steps were configured — your shop is ready to roll.
|
||||||
</p>
|
</p>
|
||||||
|
@if (showGuidedActivationCta)
|
||||||
|
{
|
||||||
|
<a asp-controller="GuidedActivation" asp-action="Start" class="btn btn-light btn-lg px-5 fw-semibold">
|
||||||
|
<i class="bi bi-play-circle me-2"></i>Start First Workflow
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
<a asp-controller="Dashboard" asp-action="Index" class="btn btn-light btn-lg px-5 fw-semibold">
|
<a asp-controller="Dashboard" asp-action="Index" class="btn btn-light btn-lg px-5 fw-semibold">
|
||||||
<i class="bi bi-house me-2"></i>Go to Dashboard
|
<i class="bi bi-house me-2"></i>Go to Dashboard
|
||||||
</a>
|
</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@{
|
@{
|
||||||
@@ -91,12 +101,7 @@
|
|||||||
{ 2, ("QB Migration", "bi-arrow-left-right") },
|
{ 2, ("QB Migration", "bi-arrow-left-right") },
|
||||||
{ 3, ("Operating Costs", "bi-currency-dollar") },
|
{ 3, ("Operating Costs", "bi-currency-dollar") },
|
||||||
{ 4, ("Shop Ovens", "bi-fire") },
|
{ 4, ("Shop Ovens", "bi-fire") },
|
||||||
{ 5, ("Doc Numbering", "bi-palette") },
|
{ 5, ("Notifications", "bi-bell") },
|
||||||
{ 6, ("Job Settings", "bi-diagram-3") },
|
|
||||||
{ 7, ("Payment Terms", "bi-file-earmark-text") },
|
|
||||||
{ 8, ("Pricing Tiers", "bi-percent") },
|
|
||||||
{ 9, ("Notifications", "bi-bell") },
|
|
||||||
{ 10, ("Team Members", "bi-people") },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +151,16 @@
|
|||||||
<a asp-controller="CompanySettings" asp-action="Index" class="btn btn-outline-primary">
|
<a asp-controller="CompanySettings" asp-action="Index" class="btn btn-outline-primary">
|
||||||
<i class="bi bi-gear me-1"></i>Open Company Settings
|
<i class="bi bi-gear me-1"></i>Open Company Settings
|
||||||
</a>
|
</a>
|
||||||
|
@if (showGuidedActivationCta)
|
||||||
|
{
|
||||||
|
<a asp-controller="GuidedActivation" asp-action="Start" class="btn btn-primary">
|
||||||
|
<i class="bi bi-play-circle me-1"></i>Start First Workflow
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
<a asp-controller="Dashboard" asp-action="Index" class="btn btn-primary">
|
<a asp-controller="Dashboard" asp-action="Index" class="btn btn-primary">
|
||||||
<i class="bi bi-house me-1"></i>Go to Dashboard
|
<i class="bi bi-house me-1"></i>Go to Dashboard
|
||||||
</a>
|
</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,8 +19,10 @@
|
|||||||
|
|
||||||
<form asp-action="PostStep4" method="post" onsubmit="return validateStep4()">
|
<form asp-action="PostStep4" method="post" onsubmit="return validateStep4()">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<input type="hidden" name="OvensJson" id="ovensJson" value="@Html.Raw(Model.OvensJson ?? "[]")" />
|
<script type="application/json" id="ovensSeedJson">@Html.Raw(Model.OvensJson ?? "[]")</script>
|
||||||
<input type="hidden" name="BlastSetupsJson" id="blastSetupsJson" value="@Html.Raw(Model.BlastSetupsJson ?? "[]")" />
|
<script type="application/json" id="blastSetupsSeedJson">@Html.Raw(Model.BlastSetupsJson ?? "[]")</script>
|
||||||
|
<input type="hidden" name="OvensJson" id="ovensJson" value="[]" />
|
||||||
|
<input type="hidden" name="BlastSetupsJson" id="blastSetupsJson" value="[]" />
|
||||||
|
|
||||||
<!-- ── Ovens ─────────────────────────────────────────────────────── -->
|
<!-- ── Ovens ─────────────────────────────────────────────────────── -->
|
||||||
<div class="wizard-card">
|
<div class="wizard-card">
|
||||||
@@ -75,7 +77,7 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// OVENS
|
// OVENS
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
var ovens = JSON.parse(document.getElementById('ovensJson').value || '[]');
|
var ovens = JSON.parse(document.getElementById('ovensSeedJson').textContent || '[]');
|
||||||
|
|
||||||
function serializeOvens() {
|
function serializeOvens() {
|
||||||
document.getElementById('ovensJson').value = JSON.stringify(
|
document.getElementById('ovensJson').value = JSON.stringify(
|
||||||
@@ -212,7 +214,7 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// BLAST SETUPS
|
// BLAST SETUPS
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
var blasts = JSON.parse(document.getElementById('blastSetupsJson').value || '[]');
|
var blasts = JSON.parse(document.getElementById('blastSetupsSeedJson').textContent || '[]');
|
||||||
|
|
||||||
function serializeBlasts() {
|
function serializeBlasts() {
|
||||||
document.getElementById('blastSetupsJson').value = JSON.stringify(
|
document.getElementById('blastSetupsJson').value = JSON.stringify(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Setup Wizard — Notifications";
|
ViewData["Title"] = "Setup Wizard — Notifications";
|
||||||
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
|
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
|
||||||
int step = ViewBag.Step as int? ?? 9;
|
int step = ViewBag.Step as int? ?? 5;
|
||||||
}
|
}
|
||||||
@section Styles { @await Html.PartialAsync("_WizardStyles") }
|
@section Styles { @await Html.PartialAsync("_WizardStyles") }
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,7 @@
|
|||||||
(2, "QB Migration", "bi-arrow-left-right"),
|
(2, "QB Migration", "bi-arrow-left-right"),
|
||||||
(3, "Operating Costs", "bi-currency-dollar"),
|
(3, "Operating Costs", "bi-currency-dollar"),
|
||||||
(4, "Shop Ovens", "bi-fire"),
|
(4, "Shop Ovens", "bi-fire"),
|
||||||
(5, "Doc Numbering", "bi-palette"),
|
(5, "Notifications", "bi-bell"),
|
||||||
(6, "Job Settings", "bi-diagram-3"),
|
|
||||||
(7, "Payment Terms", "bi-file-earmark-text"),
|
|
||||||
(8, "Pricing Tiers", "bi-percent"),
|
|
||||||
(9, "Notifications", "bi-bell"),
|
|
||||||
(10, "Team Members", "bi-people"),
|
|
||||||
};
|
};
|
||||||
int currentStep = ViewBag.Step as int? ?? 1;
|
int currentStep = ViewBag.Step as int? ?? 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
(function () {
|
||||||
|
var STORAGE_KEY = 'shopProgressCollapsed';
|
||||||
|
var widget = document.getElementById('shopProgressWidget');
|
||||||
|
if (!widget) return;
|
||||||
|
|
||||||
|
var body = document.getElementById('shopProgressBody');
|
||||||
|
var toggle = document.getElementById('shopProgressToggle');
|
||||||
|
var chevron = document.getElementById('shopProgressChevron');
|
||||||
|
|
||||||
|
function setCollapsed(collapsed) {
|
||||||
|
body.style.display = collapsed ? 'none' : '';
|
||||||
|
chevron.className = collapsed ? 'bi bi-chevron-down' : 'bi bi-chevron-up';
|
||||||
|
toggle.title = collapsed ? 'Expand' : 'Collapse';
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0'); } catch (e) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore on load
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem(STORAGE_KEY) === '1') setCollapsed(true);
|
||||||
|
} catch (e) { }
|
||||||
|
|
||||||
|
toggle.addEventListener('click', function () {
|
||||||
|
setCollapsed(body.style.display === 'none' ? false : true);
|
||||||
|
});
|
||||||
|
}());
|
||||||
Reference in New Issue
Block a user