Compare commits
2 Commits
4d27a378ac
...
b1337d3b61
| Author | SHA1 | Date | |
|---|---|---|---|
| b1337d3b61 | |||
| 8aae30765f |
@@ -12,7 +12,7 @@ public class WizardProgressDto
|
||||
public bool Completed { get; set; }
|
||||
public List<int> DoneSteps { 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 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>
|
||||
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
|
||||
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")
|
||||
.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")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
@@ -2017,6 +2035,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("NotifyOnQuoteApproval")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("OnboardingPath")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PaymentReminderDays")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
@@ -5839,7 +5860,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
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",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -5850,7 +5871,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
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",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -5861,7 +5882,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
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",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
|
||||
@@ -109,10 +109,23 @@ public static class AppConstants
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Dashboard;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
@@ -7,6 +9,8 @@ using PowderCoating.Core.Enums; // Still needed for MaintenanceStatus, Equipment
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Core.Interfaces.Services;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.ViewModels.Dashboard;
|
||||
using PowderCoating.Web.ViewModels.GuidedActivation;
|
||||
using InvoiceStatus = PowderCoating.Core.Enums.InvoiceStatus;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -19,6 +23,7 @@ public class DashboardController : Controller
|
||||
private readonly IDashboardReadService _dashboardRead;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ICompanyConfigHealthService _configHealth;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
private static readonly string[] CompletedStatusCodes =
|
||||
[
|
||||
@@ -45,13 +50,15 @@ public class DashboardController : Controller
|
||||
ILogger<DashboardController> logger,
|
||||
IDashboardReadService dashboardRead,
|
||||
ITenantContext tenantContext,
|
||||
ICompanyConfigHealthService configHealth)
|
||||
ICompanyConfigHealthService configHealth,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
_dashboardRead = dashboardRead;
|
||||
_tenantContext = tenantContext;
|
||||
_configHealth = configHealth;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -564,8 +571,17 @@ public class DashboardController : Controller
|
||||
// Config health check — surface setup gaps to company admins
|
||||
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (currentCompanyId.HasValue)
|
||||
{
|
||||
ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value);
|
||||
|
||||
// Load prefs once and share between both banner and progress widget builders
|
||||
var companyPrefs = await _unitOfWork.CompanyPreferences
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == currentCompanyId.Value && !p.IsDeleted);
|
||||
|
||||
ViewBag.GuidedActivationBanner = BuildGuidedActivationBanner(companyPrefs);
|
||||
ViewBag.ShopProgressWidget = await BuildShopProgressWidgetAsync(currentCompanyId.Value, companyPrefs);
|
||||
}
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -636,6 +652,120 @@ public class DashboardController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
private GuidedActivationBannerViewModel? BuildGuidedActivationBanner(CompanyPreferences? prefs)
|
||||
{
|
||||
var companyRole = User.FindFirst("CompanyRole")?.Value;
|
||||
if (companyRole != AppConstants.CompanyRoles.CompanyAdmin)
|
||||
return null;
|
||||
|
||||
if (prefs == null || !prefs.SetupWizardCompleted || prefs.FirstWorkflowCompleted)
|
||||
return null;
|
||||
|
||||
return new GuidedActivationBannerViewModel
|
||||
{
|
||||
Show = true,
|
||||
IsDismissed = prefs.GuidedActivationDismissedAt.HasValue,
|
||||
Title = prefs.GuidedActivationDismissedAt.HasValue
|
||||
? "Start your first workflow when you're ready"
|
||||
: "Create your first job or quote",
|
||||
Message = prefs.GuidedActivationDismissedAt.HasValue
|
||||
? "You can come back anytime to run a short walkthrough using real quotes, jobs, and invoices."
|
||||
: "Run a quick 2-minute workflow to see how the system works.",
|
||||
ActionText = "Start first workflow"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the "Get the most out of your shop" activation checklist for CompanyAdmins.
|
||||
/// Returns null when the wizard is not yet complete, the viewer is not a CompanyAdmin,
|
||||
/// or all six tasks are already done (so the widget disappears naturally at 100%).
|
||||
/// Three DB checks are fired in parallel to keep the overhead to a minimum.
|
||||
/// </summary>
|
||||
private async Task<ShopProgressWidgetViewModel?> BuildShopProgressWidgetAsync(int companyId, CompanyPreferences? prefs)
|
||||
{
|
||||
var companyRole = User.FindFirst("CompanyRole")?.Value;
|
||||
if (companyRole != AppConstants.CompanyRoles.CompanyAdmin)
|
||||
return null;
|
||||
|
||||
if (prefs == null || !prefs.SetupWizardCompleted)
|
||||
return null;
|
||||
|
||||
// These share the same scoped DbContext so must run sequentially
|
||||
var hasStatusHistory = await _unitOfWork.JobStatusHistory.AnyAsync(_ => true);
|
||||
var hasCustomizedLookups = await _unitOfWork.JobStatusLookups.AnyAsync(j => j.UpdatedAt != null);
|
||||
var teamCount = await _userManager.Users
|
||||
.CountAsync(u => u.CompanyId == companyId && u.IsActive && !u.IsBanned);
|
||||
|
||||
var items = new List<ShopProgressItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Done = prefs.FirstJobCreatedAt.HasValue || prefs.FirstQuoteCreatedAt.HasValue,
|
||||
Label = "Create your first job or quote",
|
||||
SubLabel = "Get customer sign-off before you start — takes about 2 minutes.",
|
||||
DoneSubLabel = "Your first job is now being tracked.",
|
||||
Icon = "bi-file-earmark-plus",
|
||||
CtaText = "Create a quote",
|
||||
CtaUrl = Url.Action("Start", "GuidedActivation")!
|
||||
},
|
||||
new()
|
||||
{
|
||||
Done = hasStatusHistory,
|
||||
Label = "Move a job through your workflow",
|
||||
SubLabel = "Move a job through your board so your crew always knows what's next.",
|
||||
DoneSubLabel = "You've started tracking work through your shop.",
|
||||
Icon = "bi-arrow-right-circle",
|
||||
CtaText = "Go to jobs board",
|
||||
CtaUrl = Url.Action("Board", "Jobs")!
|
||||
},
|
||||
new()
|
||||
{
|
||||
Done = prefs.FirstInvoiceCreatedAt.HasValue,
|
||||
Label = "Send your first invoice",
|
||||
SubLabel = "When the work is done, turn it into an invoice and send it in seconds.",
|
||||
DoneSubLabel = "You're ready to get paid.",
|
||||
Icon = "bi-receipt",
|
||||
CtaText = "Create invoice",
|
||||
CtaUrl = Url.Action("Create", "Invoices")!
|
||||
},
|
||||
new()
|
||||
{
|
||||
Done = teamCount > 1,
|
||||
Label = "Bring your crew in",
|
||||
SubLabel = "Add your crew so everyone stays on the same page in real time.",
|
||||
DoneSubLabel = "Your team is in the system.",
|
||||
Icon = "bi-people",
|
||||
CtaText = "Invite team",
|
||||
CtaUrl = Url.Action("Index", "CompanyUsers")!
|
||||
},
|
||||
new()
|
||||
{
|
||||
Done = hasCustomizedLookups,
|
||||
Label = "Customize your workflow",
|
||||
SubLabel = "Adjust stages and services to match how your shop runs.",
|
||||
DoneSubLabel = "Your workflow speaks your shop's language.",
|
||||
Icon = "bi-list-ul",
|
||||
CtaText = "Customize workflow",
|
||||
CtaUrl = Url.Action("Index", "CompanySettings") + "#data-lookups"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Done = prefs.DefaultPaymentTerms != "Net 30"
|
||||
|| prefs.DefaultQuoteValidityDays != 30
|
||||
|| prefs.DefaultTurnaroundDays != 7
|
||||
|| prefs.QtDefaultTerms != null,
|
||||
Label = "Set how you get paid",
|
||||
SubLabel = "Set your payment terms and timing so every job goes out right.",
|
||||
DoneSubLabel = "Your payment defaults are locked in.",
|
||||
Icon = "bi-file-earmark-text",
|
||||
CtaText = "Set payment terms",
|
||||
CtaUrl = Url.Action("Index", "CompanySettings") + "#general"
|
||||
}
|
||||
};
|
||||
|
||||
return new ShopProgressWidgetViewModel { Items = items };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records receipt of a powder shipment against an existing powder order. Sets
|
||||
/// <c>PowderReceived</c>, <c>PowderReceivedLbs</c>, and <c>PowderReceivedAt</c> on the coat,
|
||||
|
||||
@@ -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
|
||||
/// Stripe Connect account. Both conditions must be true; per-plan override wins if set.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int? id)
|
||||
public async Task<IActionResult> Details(int? id, string? guidedActivation = null)
|
||||
{
|
||||
if (id == null) return NotFound();
|
||||
|
||||
@@ -257,6 +257,19 @@ public class InvoicesController : Controller
|
||||
ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed
|
||||
&& 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);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -285,7 +298,7 @@ public class InvoicesController : Controller
|
||||
/// — Revenue accounts are pulled from the catalog item's RevenueAccountId, falling back to
|
||||
/// account 4000 (default revenue) if no catalog item is linked.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Create(int? jobId)
|
||||
public async Task<IActionResult> Create(int? jobId, string? guidedActivation = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -429,6 +442,7 @@ public class InvoicesController : Controller
|
||||
}
|
||||
|
||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -459,7 +473,7 @@ public class InvoicesController : Controller
|
||||
/// declared outside the lambda and assigned inside — EF requires this pattern with closures.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(CreateInvoiceDto dto)
|
||||
public async Task<IActionResult> Create(CreateInvoiceDto dto, string? guidedActivation = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -469,6 +483,7 @@ public class InvoicesController : Controller
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -476,6 +491,7 @@ public class InvoicesController : Controller
|
||||
{
|
||||
ModelState.AddModelError("", "Please add at least one line item before saving.");
|
||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -487,6 +503,7 @@ public class InvoicesController : Controller
|
||||
{
|
||||
ModelState.AddModelError("", "An invoice already exists for this job.");
|
||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
@@ -643,8 +660,21 @@ public class InvoicesController : Controller
|
||||
var depositMsg = pendingDeposits.Any()
|
||||
? $" {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}";
|
||||
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)
|
||||
{
|
||||
@@ -652,6 +682,7 @@ public class InvoicesController : Controller
|
||||
TempData["Error"] = "An error occurred while creating the invoice.";
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser != null) await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
@@ -2387,4 +2418,30 @@ public class InvoicesController : Controller
|
||||
|
||||
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.
|
||||
/// Uses the lookup cache so column headers stay consistent with the configurable status list.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Board(bool showTerminal = false)
|
||||
public async Task<IActionResult> Board(
|
||||
bool showTerminal = false,
|
||||
string? guidedActivation = null,
|
||||
int? highlightJobId = null)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
@@ -236,6 +239,9 @@ public class JobsController : Controller
|
||||
|
||||
// Load all active jobs with related data
|
||||
var jobs = await _unitOfWork.Jobs.GetBoardJobsAsync();
|
||||
var highlightedJob = highlightJobId.HasValue
|
||||
? jobs.FirstOrDefault(j => j.Id == highlightJobId.Value)
|
||||
: null;
|
||||
|
||||
var now = DateTime.UtcNow.Date;
|
||||
|
||||
@@ -271,6 +277,13 @@ public class JobsController : Controller
|
||||
ViewBag.ShowTerminal = showTerminal;
|
||||
ViewBag.TotalTerminal = statuses.Where(s => s.IsTerminalStatus)
|
||||
.Sum(s => jobs.Count(j => j.JobStatusId == s.Id));
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
ViewBag.GuidedActivationHighlightJobId = highlightJobId;
|
||||
ViewBag.GuidedActivationCallout = await BuildBoardGuidedActivationCalloutAsync(
|
||||
companyId,
|
||||
guidedActivation,
|
||||
highlightJobId,
|
||||
highlightedJob);
|
||||
return View(columns);
|
||||
}
|
||||
|
||||
@@ -298,6 +311,10 @@ public class JobsController : Controller
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
job.UpdatedBy = User.Identity?.Name;
|
||||
|
||||
var workflowJustCompleted =
|
||||
req.JobId == req.HighlightJobId
|
||||
&& await MaybeMarkFirstWorkflowCompletedFromBoardAsync(job.CompanyId, req.GuidedActivation);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "StatusChanged",
|
||||
@@ -318,7 +335,10 @@ public class JobsController : Controller
|
||||
success = true,
|
||||
newStatusId = newStatus.Id,
|
||||
newStatusDisplay = newStatus.DisplayName,
|
||||
newStatusColor = newStatus.ColorClass
|
||||
newStatusColor = newStatus.ColorClass,
|
||||
guidedActivationNext = workflowJustCompleted
|
||||
? AppConstants.GuidedActivation.BoardReadyForInvoiceStep
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -329,7 +349,7 @@ public class JobsController : Controller
|
||||
/// correctly without a separate AJAX call. Measurement units (sq ft vs m²) are resolved from
|
||||
/// the tenant's metric preference and passed via ViewBag.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int? id)
|
||||
public async Task<IActionResult> Details(int? id, string? guidedActivation = null)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
@@ -468,6 +488,28 @@ public class JobsController : Controller
|
||||
.OrderBy(c => c.Text)
|
||||
.ToList();
|
||||
|
||||
var jobPrefs = await GetCompanyPreferencesAsync(job.CompanyId);
|
||||
if (guidedActivation == AppConstants.GuidedActivation.JobCreatedStep
|
||||
&& jobPrefs?.FirstWorkflowCompleted == false)
|
||||
{
|
||||
ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
|
||||
{
|
||||
Show = true,
|
||||
Title = jobPrefs.OnboardingPath == AppConstants.GuidedActivation.QuoteFirstPath
|
||||
? "Now your approved quote is a job. This is where you track it through your shop."
|
||||
: "This job is now live in your shop workflow.",
|
||||
Message = "Next, open the Daily Board and move it to the next stage so you can see how work flows across the shop.",
|
||||
ActionText = "Open Daily Board",
|
||||
ActionController = "Jobs",
|
||||
ActionName = "Board",
|
||||
ActionRouteValues = new
|
||||
{
|
||||
guidedActivation = AppConstants.GuidedActivation.BoardIntroStep,
|
||||
highlightJobId = job.Id
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return View(jobDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -817,7 +859,7 @@ public class JobsController : Controller
|
||||
/// (pre-configured job types with standard items). If <paramref name="customerId"/> is provided,
|
||||
/// the customer dropdown is pre-selected. The wizard is the same multi-step UI as the quote wizard.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Create(int? customerId, int? templateId)
|
||||
public async Task<IActionResult> Create(int? customerId, int? templateId, string? guidedActivation = null)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
@@ -839,6 +881,11 @@ public class JobsController : Controller
|
||||
if (customerId.HasValue)
|
||||
dto.CustomerId = customerId.Value;
|
||||
|
||||
if (guidedActivation == AppConstants.GuidedActivation.JobFirstPath && customerId.HasValue)
|
||||
{
|
||||
dto = GuidedActivationDefaults.BuildJobDraft(customerId.Value, normalPriority?.Id ?? 1);
|
||||
}
|
||||
|
||||
// Pre-populate from template if provided
|
||||
if (templateId.HasValue)
|
||||
{
|
||||
@@ -907,6 +954,7 @@ public class JobsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -919,13 +967,14 @@ public class JobsController : Controller
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(CreateJobDto dto)
|
||||
public async Task<IActionResult> Create(CreateJobDto dto, string? guidedActivation = null)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateCreateEditWizardViewBagsAsync(companyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -937,6 +986,7 @@ public class JobsController : Controller
|
||||
$"You have reached your plan limit of {max} active jobs. " +
|
||||
"Please upgrade your plan or complete/cancel existing jobs to add more.");
|
||||
await PopulateCreateEditWizardViewBagsAsync(companyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -1092,7 +1142,18 @@ public class JobsController : Controller
|
||||
if (!string.IsNullOrEmpty(createCompanyId))
|
||||
await _shopHub.Clients.Group($"shop-{createCompanyId}").SendAsync("DailyBoardUpdated");
|
||||
|
||||
await StampJobCreatedAsync(companyId);
|
||||
|
||||
this.ToastSuccess($"Job {job.JobNumber} created successfully!");
|
||||
if (guidedActivation == AppConstants.GuidedActivation.JobFirstPath)
|
||||
{
|
||||
return RedirectToAction(nameof(Details), new
|
||||
{
|
||||
id = job.Id,
|
||||
guidedActivation = AppConstants.GuidedActivation.JobCreatedStep
|
||||
});
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id = job.Id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1100,6 +1161,7 @@ public class JobsController : Controller
|
||||
_logger.LogError(ex, "Error creating job");
|
||||
this.ToastError("An error occurred while creating the job. Please try again.");
|
||||
await PopulateCreateEditWizardViewBagsAsync(companyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
@@ -2126,6 +2188,10 @@ public class JobsController : Controller
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
var workflowJustCompleted =
|
||||
request.JobId == request.HighlightJobId
|
||||
&& await MaybeMarkFirstWorkflowCompletedFromBoardAsync(job.CompanyId, request.GuidedActivation);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()?.ToString();
|
||||
@@ -2138,7 +2204,15 @@ public class JobsController : Controller
|
||||
statusColorClass = newStatus.ColorClass
|
||||
});
|
||||
|
||||
return Json(new { success = true, newStatusDisplayName = newStatus.DisplayName, newStatusColorClass = newStatus.ColorClass });
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
newStatusDisplayName = newStatus.DisplayName,
|
||||
newStatusColorClass = newStatus.ColorClass,
|
||||
guidedActivationNext = workflowJustCompleted
|
||||
? AppConstants.GuidedActivation.BoardReadyForInvoiceStep
|
||||
: null
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -3700,6 +3774,100 @@ public class JobsController : Controller
|
||||
return Json(new { error = "Unable to compute costing breakdown." });
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
|
||||
{
|
||||
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
}
|
||||
|
||||
private async Task<Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel?> BuildBoardGuidedActivationCalloutAsync(
|
||||
int companyId,
|
||||
string? guidedActivation,
|
||||
int? highlightJobId,
|
||||
Job? highlightedJob)
|
||||
{
|
||||
if (!highlightJobId.HasValue || highlightedJob == null)
|
||||
return null;
|
||||
|
||||
var prefs = await GetCompanyPreferencesAsync(companyId);
|
||||
if (prefs == null || !prefs.SetupWizardCompleted || string.IsNullOrWhiteSpace(prefs.OnboardingPath))
|
||||
return null;
|
||||
|
||||
if (guidedActivation == AppConstants.GuidedActivation.BoardIntroStep && !prefs.FirstWorkflowCompleted)
|
||||
{
|
||||
return new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
|
||||
{
|
||||
Show = true,
|
||||
Title = "This is your shop in real time",
|
||||
Message = "Every active job shows up here so you can see what's in production, what's waiting, and what's ready to go.",
|
||||
InstructionText = "Move this job to the next stage to see how your workflow updates.",
|
||||
SecondaryActionText = "View job",
|
||||
SecondaryActionController = "Jobs",
|
||||
SecondaryActionName = "Details",
|
||||
SecondaryActionRouteValues = new { id = highlightJobId.Value }
|
||||
};
|
||||
}
|
||||
|
||||
if (guidedActivation == AppConstants.GuidedActivation.BoardReadyForInvoiceStep)
|
||||
{
|
||||
var jobInvoice = await _unitOfWork.Invoices.GetForJobAsync(highlightJobId.Value);
|
||||
var hasInvoice = jobInvoice != null;
|
||||
|
||||
return new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
|
||||
{
|
||||
Show = true,
|
||||
Title = "Nice — your workflow just updated. This is how you track work through your shop.",
|
||||
Message = hasInvoice
|
||||
? "You've already tied billing to this job. Open the invoice or keep exploring the board."
|
||||
: "When the work is done, you can create the invoice.",
|
||||
ActionText = hasInvoice ? "View Invoice" : "Create Invoice",
|
||||
ActionController = "Invoices",
|
||||
ActionName = hasInvoice ? "Details" : "Create",
|
||||
ActionRouteValues = hasInvoice
|
||||
? new { id = jobInvoice!.Id }
|
||||
: new
|
||||
{
|
||||
jobId = highlightJobId.Value,
|
||||
guidedActivation = AppConstants.GuidedActivation.BoardReadyForInvoiceStep
|
||||
},
|
||||
SecondaryActionText = "View job",
|
||||
SecondaryActionController = "Jobs",
|
||||
SecondaryActionName = "Details",
|
||||
SecondaryActionRouteValues = new { id = highlightJobId.Value }
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<bool> MaybeMarkFirstWorkflowCompletedFromBoardAsync(int companyId, string? guidedActivation)
|
||||
{
|
||||
if (guidedActivation != AppConstants.GuidedActivation.BoardIntroStep)
|
||||
return false;
|
||||
|
||||
var prefs = await GetCompanyPreferencesAsync(companyId);
|
||||
if (prefs == null || prefs.FirstWorkflowCompleted || !prefs.SetupWizardCompleted)
|
||||
return false;
|
||||
|
||||
prefs.FirstWorkflowCompleted = true;
|
||||
prefs.FirstWorkflowCompletedAt = DateTime.UtcNow;
|
||||
prefs.GuidedActivationDismissedAt = null;
|
||||
|
||||
_logger.LogInformation("Marked first workflow complete from Daily Board tracking for company {CompanyId}", companyId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task StampJobCreatedAsync(int companyId)
|
||||
{
|
||||
var prefs = await GetCompanyPreferencesAsync(companyId);
|
||||
if (prefs == null || prefs.FirstJobCreatedAt.HasValue)
|
||||
return;
|
||||
|
||||
prefs.FirstJobCreatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
||||
}
|
||||
}
|
||||
|
||||
public class DeleteTimeEntryRequest { public int Id { get; set; } }
|
||||
@@ -3756,12 +3924,16 @@ public class MoveCardRequest
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
public int NewStatusId { get; set; }
|
||||
public string? GuidedActivation { get; set; }
|
||||
public int? HighlightJobId { get; set; }
|
||||
}
|
||||
|
||||
public class AdvanceJobStatusRequest
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
public int NewStatusId { get; set; }
|
||||
public string? GuidedActivation { get; set; }
|
||||
public int? HighlightJobId { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateDatesRequest
|
||||
|
||||
@@ -260,7 +260,7 @@ public class QuotesController : Controller
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int? id)
|
||||
public async Task<IActionResult> Details(int? id, string? guidedActivation = null)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
@@ -435,6 +435,21 @@ public class QuotesController : Controller
|
||||
.OrderBy(c => c.Text)
|
||||
.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);
|
||||
}
|
||||
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
|
||||
/// navigating from the Customer Details page using the "New Quote" shortcut).
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Create(int? customerId)
|
||||
public async Task<IActionResult> Create(int? customerId, string? guidedActivation = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -682,6 +697,17 @@ public class QuotesController : Controller
|
||||
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);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -703,7 +729,7 @@ public class QuotesController : Controller
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[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("IsForProspect: {IsForProspect}", dto.IsForProspect);
|
||||
@@ -832,6 +858,7 @@ public class QuotesController : Controller
|
||||
|
||||
await PopulateDropDownsAsync(currentUser!.CompanyId, operatingCosts?.OvenOperatingCostPerHour ?? 0);
|
||||
await SetMeasurementViewBagAsync();
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -1134,7 +1161,18 @@ public class QuotesController : Controller
|
||||
this.SetNotificationResultToast(quoteCreateNotifLog);
|
||||
}
|
||||
|
||||
await StampQuoteCreatedAsync(currentUser.CompanyId);
|
||||
|
||||
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 });
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1145,6 +1183,7 @@ public class QuotesController : Controller
|
||||
var catchCosts = await _pricingService.GetOperatingCostsAsync(catchUser!.CompanyId);
|
||||
await PopulateDropDownsAsync(catchUser.CompanyId, catchCosts?.OvenOperatingCostPerHour ?? 0);
|
||||
await SetMeasurementViewBagAsync();
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
@@ -2100,7 +2139,7 @@ public class QuotesController : Controller
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ConvertToJob(int id)
|
||||
public async Task<IActionResult> ConvertToJob(int id, string? guidedActivation = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -2231,9 +2270,20 @@ public class QuotesController : Controller
|
||||
|
||||
this.ToastSuccess($"Job has been successfully created from quote {quote.QuoteNumber}!");
|
||||
|
||||
await StampJobCreatedAsync(currentUser!.CompanyId);
|
||||
|
||||
// Redirect to the newly created job's details page
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -3791,6 +3841,35 @@ public class QuotesController : Controller
|
||||
|
||||
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
|
||||
|
||||
@@ -311,31 +311,7 @@ public class SetupWizardController : Controller
|
||||
ShopCapabilityTier = costs.ShopCapabilityTier
|
||||
}),
|
||||
4 => await BuildStep4ViewAsync(GetCompanyId()),
|
||||
5 => View("Step5", new WizardStep3Dto
|
||||
{
|
||||
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
|
||||
5 => View("Step9", new WizardStep7Dto
|
||||
{
|
||||
EmailNotificationsEnabled = prefs.EmailNotificationsEnabled,
|
||||
EmailFromAddress = prefs.EmailFromAddress,
|
||||
@@ -351,7 +327,6 @@ public class SetupWizardController : Controller
|
||||
DueDateWarningDays = prefs.DueDateWarningDays,
|
||||
MaintenanceAlertDays = prefs.MaintenanceAlertDays
|
||||
}),
|
||||
10 => await BuildStep10ViewAsync(GetCompanyId()),
|
||||
_ => RedirectToAction("Step", new { step = 1 })
|
||||
};
|
||||
}
|
||||
@@ -405,53 +380,6 @@ public class SetupWizardController : Controller
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -675,138 +603,15 @@ public class SetupWizardController : Controller
|
||||
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>
|
||||
/// Persists pricing tiers from Step 8, using the same upsert-and-soft-delete pattern as
|
||||
/// <see cref="PostStep4"/>: existing tiers updated in place, new ones inserted, removed ones
|
||||
/// 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.
|
||||
/// Saves notification preferences from Step 5 (the final step). Marks the wizard complete
|
||||
/// and hands off to the Guided Activation flow.
|
||||
/// </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]
|
||||
public async Task<IActionResult> PostStep9(WizardStep7Dto model)
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -824,83 +629,9 @@ public class SetupWizardController : Controller
|
||||
prefs.DueDateWarningDays = model.DueDateWarningDays;
|
||||
prefs.MaintenanceAlertDays = model.MaintenanceAlertDays;
|
||||
|
||||
MarkDone(prefs, 9);
|
||||
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);
|
||||
MarkDone(prefs, 5);
|
||||
prefs.SetupWizardCompleted = true;
|
||||
|
||||
// Record who completed the wizard and when so SuperAdmins can see completion status per-user.
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
prefs.SetupWizardCompletedAt = DateTime.UtcNow;
|
||||
prefs.SetupWizardCompletedByUserId = currentUser?.Id;
|
||||
@@ -909,7 +640,7 @@ public class SetupWizardController : Controller
|
||||
: User.Identity?.Name;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Complete));
|
||||
return RedirectToAction("Start", "GuidedActivation");
|
||||
}
|
||||
|
||||
// ─── Skip ─────────────────────────────────────────────────────────────────
|
||||
@@ -927,6 +658,18 @@ public class SetupWizardController : Controller
|
||||
{
|
||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||
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();
|
||||
int next = step >= WizardProgressDto.TotalSteps ? 0 : step + 1;
|
||||
return RedirectToStep(next == 0 ? WizardProgressDto.TotalSteps + 1 : next);
|
||||
@@ -975,6 +718,7 @@ public class SetupWizardController : Controller
|
||||
{
|
||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||
ViewBag.Progress = BuildProgress(prefs);
|
||||
ViewBag.ShowGuidedActivationCta = prefs.SetupWizardCompleted && !prefs.FirstWorkflowCompleted;
|
||||
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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@ public static class HelpKnowledgeBase
|
||||
**Dashboard** → /Dashboard
|
||||
The main landing page after login. Shows KPIs (open jobs, pending quotes, outstanding invoices), low-stock powder alerts, equipment needing maintenance, recent activity, and rotating tips.
|
||||
If your company has setup gaps — missing chart of accounts, unconfigured operating costs, incomplete setup wizard, no inventory items, etc. — a color-coded alert card appears at the top of the Dashboard. Each issue badge is a clickable link that takes you directly to the page where you can fix it. Critical issues (red) affect core features like pricing and invoicing; warnings (amber) affect specific workflows; informational items are optional but recommended. The card disappears once all issues are resolved.
|
||||
After the Setup Wizard is complete, a "Get the most out of your shop" progress widget appears on the Dashboard for Company Admins. It tracks six post-setup activation steps: creating a first job or quote, moving a job through the workflow, sending a first invoice, inviting the team, customizing the workflow labels, and setting payment terms. Each incomplete step shows a description and a button to the relevant page. The next recommended step is highlighted. The widget disappears automatically once all six steps are done. It can be collapsed using the chevron button in the top-right corner — the collapsed state is remembered per browser.
|
||||
|
||||
**Operations section:**
|
||||
- Customers → /Customers
|
||||
@@ -579,7 +580,7 @@ public static class HelpKnowledgeBase
|
||||
If you are setting up Powder Coating Logix for the first time, follow this order. Each step builds on the previous one — skipping steps will cause quotes and pricing calculations to produce $0 or incorrect results.
|
||||
|
||||
**Step 1 — Run the Setup Wizard first**
|
||||
The Setup Wizard at [/SetupWizard/Step?step=1](/SetupWizard/Step?step=1) (or click the "Start Setup Wizard" button on the Dashboard) walks you through the 10 most important configuration steps in the right order. It takes about 15–20 minutes. Start here.
|
||||
The Setup Wizard at [/SetupWizard/Step?step=1](/SetupWizard/Step?step=1) (or click the "Start Setup Wizard" button on the Dashboard) walks you through the 5 essential configuration steps in the right order. It takes about 5–10 minutes. Start here.
|
||||
|
||||
**Step 2 — Add your inventory items (powders)**
|
||||
After the wizard, go to [Inventory](/Inventory) and add your powder coating colors as inventory items. Each item needs a cost per unit, coverage rate (sq ft/lb, default 30), and efficiency % (default 65). Items must belong to a category that has "Is Coating" checked (the wizard seeds this category for you) for them to appear in quote/job powder dropdowns.
|
||||
@@ -602,7 +603,7 @@ public static class HelpKnowledgeBase
|
||||
|
||||
**Where:** [/SetupWizard](/SetupWizard/Step?step=1) — launched from the Dashboard "Start Setup Wizard" button, or directly from the URL. Only Company Admins can run it.
|
||||
|
||||
The Setup Wizard is a 10-step guided setup that configures every setting needed for quotes, jobs, and pricing to work correctly. It auto-seeds your chart of accounts, inventory categories, and default vendors (Prismatic Powders, Columbia Coatings) on first launch. You can skip any step and return later — progress is saved automatically.
|
||||
The Setup Wizard is a 5-step guided setup that configures the essentials needed for quotes, jobs, and pricing to work correctly. It auto-seeds your chart of accounts, inventory categories, and default vendors (Prismatic Powders, Columbia Coatings) on first launch. You can skip any step and return later — progress is saved automatically. Settings not covered by the wizard (numbering prefixes, payment terms, pricing tiers, team members) can be configured any time in Company Settings.
|
||||
|
||||
**Step 1 — Company Info**
|
||||
Sets your company name, address, phone, time zone, currency (USD default), and whether to use metric units. This information appears on all PDFs (quotes, invoices, work orders). Make sure the address is correct — it prints on every customer-facing document.
|
||||
@@ -623,31 +624,22 @@ public static class HelpKnowledgeBase
|
||||
**Step 4 — Named Ovens**
|
||||
Configure each physical oven in your shop: give it a name (e.g., "Main Oven", "Small Batch Oven"), set its cost per hour, max load capacity in sq ft, and default cure cycle in minutes. The first oven's cost per hour automatically becomes the default oven rate used in pricing calculations. You can have multiple ovens — they appear as options in the Oven Scheduler.
|
||||
|
||||
**Step 5 — Numbering & Branding**
|
||||
Set your quote and job number prefixes (default: QT and JOB). Also set the accent color for your quote PDFs — this becomes the color of table headers and section bars on customer-facing quote documents.
|
||||
|
||||
**Step 6 — Job Defaults**
|
||||
- *Default Job Priority* — what priority new jobs get (Normal recommended)
|
||||
- *Require Customer PO* — whether a purchase order number is required before creating a job (useful for commercial customers)
|
||||
- *Allow Customer Online Approval* — whether customers can approve/reject quotes via an online link (recommended: on)
|
||||
|
||||
**Step 7 — Quote Settings**
|
||||
- *Default Payment Terms* — e.g., "Due on Receipt", "Net 30" — appears on quotes and invoices
|
||||
- *Default Quote Validity Days* — how long quotes stay valid before expiring (30 days is typical)
|
||||
- *Default Turnaround Days* — your standard lead time shown on quotes
|
||||
- *Quote Terms & Conditions* — legal/policy text printed at the bottom of every quote PDF
|
||||
- *Quote Footer Note* — a short footer line (e.g., "Thank you for your business!")
|
||||
|
||||
**Step 8 — Pricing Tiers**
|
||||
Create named discount tiers for volume or preferred customers (e.g., "Gold — 10% off", "Silver — 5% off"). These can be assigned to individual customers and are automatically applied when building quotes. You can add more tiers later at [Pricing Tiers](/PricingTiers).
|
||||
|
||||
**Step 9 — Notifications**
|
||||
**Step 5 — Notifications**
|
||||
Configure email alerts: which events trigger emails (new job, job status changes, quote approvals, payments received), how many days before due dates to send warnings, and whether to enable automatic payment reminders. Set the "From" email address and display name for outgoing emails.
|
||||
|
||||
**Step 10 — Team Members**
|
||||
Invite your staff. Enter their name, email, password, and role (CompanyAdmin, Manager, Worker, or Viewer). They receive an invitation and can log in immediately. You can add more users later at [Company Users](/CompanyUsers).
|
||||
After completing all steps, the wizard marks your setup as complete. The Dashboard will then show the "Get the most out of your shop" progress widget to guide you through your first live workflow steps.
|
||||
|
||||
After completing all steps, the wizard marks your setup as complete and removes the setup banner from the Dashboard.
|
||||
## GUIDED ACTIVATION — FIRST WORKFLOW
|
||||
|
||||
After the Setup Wizard completes, Company Admins are guided through their first real workflow via a banner and the Daily Board. The guided activation flow:
|
||||
|
||||
1. Choose a starting path: Quote First (create a quote → approve it → convert to job) or Job First (create a job directly).
|
||||
2. The system creates a sample customer ("Sample Customer") to use during the walkthrough. You can use this record or swap it for a real customer.
|
||||
3. After the job is created, you are taken to the **Daily Board** ("This is your shop in real time") showing every active job by stage. The new job is highlighted with a glow border.
|
||||
4. Drag the highlighted job to its next stage. The board updates in real time and shows a confirmation: "Nice — your workflow just updated."
|
||||
5. The next prompt is to create an invoice when the work is done.
|
||||
|
||||
The guided activation banner appears only for Company Admins and only until the first workflow is complete. It can be dismissed at any time.
|
||||
|
||||
---
|
||||
|
||||
@@ -1197,7 +1189,7 @@ public static class HelpKnowledgeBase
|
||||
## COMMON WORKFLOWS
|
||||
|
||||
**New company first-time setup:**
|
||||
Run Setup Wizard (Dashboard → Start Setup Wizard) → Add powder inventory items → Mark inventory category as "Is Coating" → Add customers → Build first quote
|
||||
Run Setup Wizard (5 steps: Company Info → QB Migration → Pricing → Named Ovens → Notifications) → Complete guided activation first workflow on the Daily Board → Use the Dashboard progress widget to finish remaining setup steps (invite team, customize workflow labels, set payment terms) → Add powder inventory items → Add customers → Build first real quote
|
||||
|
||||
**Standard job flow (most common):**
|
||||
Quote (Draft → Sent → Approved) → Convert to Job → Job progresses through statuses → Create Invoice → Record Payment → Mark Delivered
|
||||
|
||||
@@ -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
|
||||
@using Microsoft.AspNetCore.Html
|
||||
@using PowderCoating.Application.DTOs.Health
|
||||
@using PowderCoating.Web.ViewModels.Dashboard
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Dashboard";
|
||||
var today = DateTime.Today;
|
||||
var currentMonth = DateTime.Now.ToString("MMMM yyyy");
|
||||
var configHealth = ViewBag.ConfigHealth as CompanyConfigHealth;
|
||||
var guidedActivationBanner = ViewBag.GuidedActivationBanner as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationBannerViewModel;
|
||||
var shopProgressWidget = ViewBag.ShopProgressWidget as ShopProgressWidgetViewModel;
|
||||
}
|
||||
|
||||
<!-- Hero Brief -->
|
||||
@@ -56,6 +59,34 @@
|
||||
</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 *@
|
||||
@if (configHealth != null && !configHealth.IsHealthy)
|
||||
{
|
||||
@@ -777,6 +808,7 @@
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/shop-progress-widget.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
// Powder Orders - Mark as Ordered
|
||||
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");}
|
||||
}
|
||||
@@ -247,8 +247,9 @@
|
||||
<i class="bi bi-magic flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
<strong>New to the system?</strong> Use the <a href="/SetupWizard">Setup Wizard</a> to
|
||||
configure your company, operating costs, named ovens, and initial inventory in a guided
|
||||
10-step walkthrough. The wizard is the fastest way to get your shop configured and ready.
|
||||
configure your company profile, operating costs, named ovens, and notifications in a guided
|
||||
5-step walkthrough — takes about 5–10 minutes. After the wizard, the Dashboard will show
|
||||
a progress checklist to guide you through your first live workflow.
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
@@ -327,6 +328,57 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="after-the-wizard" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-rocket-takeoff text-primary me-2"></i>After the Wizard — Your First Workflow
|
||||
</h2>
|
||||
<p>
|
||||
Once the Setup Wizard is complete, two things appear on your Dashboard to guide you through your
|
||||
first live workflow:
|
||||
</p>
|
||||
|
||||
<h5 class="fw-semibold">Guided Activation</h5>
|
||||
<p>
|
||||
A banner prompts you to run a short first-workflow walkthrough. Choose a starting path — either
|
||||
<strong>Quote First</strong> (create a quote, get it approved, convert it to a job) or
|
||||
<strong>Job First</strong> (create a job directly). The system creates a sample customer
|
||||
record for you to use during the walkthrough.
|
||||
</p>
|
||||
<p>
|
||||
After the job is created you are taken to the <strong>Daily Board</strong> — your shop in real
|
||||
time. Every active job appears on the board by stage. The new job is highlighted so you can
|
||||
find it easily. Drag it to its next stage to see how your workflow updates live. Once you have
|
||||
moved the job, the board prompts you to create the invoice when the work is done.
|
||||
</p>
|
||||
|
||||
<h5 class="fw-semibold">Progress Widget</h5>
|
||||
<p>
|
||||
Below the guided activation banner you will see a <strong>"Get the most out of your shop"</strong>
|
||||
widget. It tracks six steps that unlock the full day-to-day workflow:
|
||||
</p>
|
||||
<ol>
|
||||
<li>Create your first job or quote</li>
|
||||
<li>Move a job through your workflow</li>
|
||||
<li>Send your first invoice</li>
|
||||
<li>Bring your crew in (invite team members)</li>
|
||||
<li>Customize your workflow labels (job stages, priorities, prep services)</li>
|
||||
<li>Set how you get paid (payment terms and quote defaults)</li>
|
||||
</ol>
|
||||
<p>
|
||||
Each incomplete step shows a description and a button that takes you directly to the right place.
|
||||
The next recommended step is highlighted. The widget disappears once all six steps are done.
|
||||
You can collapse it using the chevron button — the collapsed state is saved in your browser.
|
||||
</p>
|
||||
|
||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-info-circle-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
The progress widget and guided activation banner are visible to <strong>Company Admins only</strong>.
|
||||
Other roles land directly on the standard Dashboard.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 d-none d-lg-block">
|
||||
@@ -340,6 +392,7 @@
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#navigating">Navigating the System</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#roles-and-permissions">Roles and Permissions</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#your-first-steps">Your First Steps</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#after-the-wizard">After the Wizard</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
|
||||
<form asp-action="Create" method="post" id="invoiceForm">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="guidedActivation" value="@ViewBag.GuidedActivation" />
|
||||
|
||||
@if (!ViewData.ModelState.IsValid)
|
||||
{
|
||||
@@ -47,6 +48,14 @@
|
||||
</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="JobId" />
|
||||
<input type="hidden" asp-for="CustomerId" id="hiddenCustomerId" />
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
&& (Model.PaymentLinkExpiresAt == null || Model.PaymentLinkExpiresAt <= DateTime.UtcNow);
|
||||
var onlinePaymentsEnabled = ViewBag.OnlinePaymentsEnabled == true;
|
||||
var showOnlinePaymentCard = !isDraft && !isVoided && Model.BalanceDue > 0 && onlinePaymentsEnabled;
|
||||
var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel;
|
||||
}
|
||||
|
||||
<div class="row justify-content-center">
|
||||
@@ -69,6 +70,23 @@
|
||||
</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 -->
|
||||
<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>
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
@using PowderCoating.Shared.Constants
|
||||
@using PowderCoating.Web.Controllers
|
||||
@using PowderCoating.Web.ViewModels.GuidedActivation
|
||||
@model List<JobBoardColumn>
|
||||
@{
|
||||
ViewData["Title"] = "Jobs Board";
|
||||
bool showTerminal = ViewBag.ShowTerminal == true;
|
||||
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 {
|
||||
@@ -108,7 +115,19 @@
|
||||
}
|
||||
.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-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-guided-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pcl-cool);
|
||||
}
|
||||
|
||||
/* Card content */
|
||||
.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">
|
||||
@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 *@
|
||||
@{
|
||||
var _totalOnFloor = Model.Sum(c => c.Jobs.Count);
|
||||
@@ -181,7 +234,11 @@
|
||||
@* Left: view switch + live stats *@
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<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>
|
||||
</div>
|
||||
<span class="mono" style="font-size:.75rem;color:var(--pcl-steel)">
|
||||
@@ -195,7 +252,7 @@
|
||||
|
||||
@* Right: actions *@
|
||||
<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"
|
||||
id="toggleTerminalBtn">
|
||||
<i class="bi bi-archive me-1"></i>
|
||||
@@ -286,12 +343,16 @@
|
||||
_ => "board-priority-secondary"
|
||||
};
|
||||
<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"
|
||||
onclick="return false">
|
||||
|
||||
<div class="d-flex align-items-start justify-content-between gap-1">
|
||||
<span class="card-job-number">@card.JobNumber</span>
|
||||
@if (highlightJobId == card.Id)
|
||||
{
|
||||
<span class="board-guided-badge">Guided Job</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card-customer">@card.CustomerName</div>
|
||||
@@ -347,6 +408,8 @@
|
||||
<script>
|
||||
(function () {
|
||||
const COMPANY_ID = '@(User.FindFirst("CompanyId")?.Value ?? "0")';
|
||||
const guidedActivation = '@(guidedActivation ?? string.Empty)';
|
||||
const highlightJobId = @(highlightJobId?.ToString() ?? "null");
|
||||
|
||||
// ── Show Completed persistence ───────────────────────────────────────────
|
||||
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 => {
|
||||
cb.addEventListener('change', () => {
|
||||
const id = parseInt(cb.dataset.statusId);
|
||||
@@ -458,6 +543,7 @@
|
||||
});
|
||||
|
||||
applyColOrder();
|
||||
ensureGuidedCardVisible();
|
||||
|
||||
// ── Drag & drop + card navigation ────────────────────────────────────────
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value
|
||||
@@ -506,13 +592,26 @@
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': token
|
||||
},
|
||||
body: JSON.stringify({ jobId, newStatusId: newStatus })
|
||||
body: JSON.stringify({
|
||||
jobId,
|
||||
newStatusId: newStatus,
|
||||
guidedActivation,
|
||||
highlightJobId
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Update card's priority border stays — status shown by column
|
||||
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 {
|
||||
// Revert
|
||||
oldColEl.insertBefore(card, oldColEl.children[evt.oldIndex] ?? null);
|
||||
|
||||
@@ -27,12 +27,21 @@
|
||||
|
||||
<form asp-action="Create" method="post" id="jobCreateForm">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="guidedActivation" value="@ViewBag.GuidedActivation" />
|
||||
@if (ViewBag.TemplateId != null)
|
||||
{
|
||||
<input type="hidden" name="SourceTemplateId" value="@ViewBag.TemplateId">
|
||||
}
|
||||
<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 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@{
|
||||
ViewData["Title"] = $"Job {Model.JobNumber}";
|
||||
ViewData["PageIcon"] = "bi-briefcase";
|
||||
var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel;
|
||||
}
|
||||
|
||||
<div class="row justify-content-center">
|
||||
@@ -38,6 +39,34 @@
|
||||
</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">
|
||||
<!-- Left Column -->
|
||||
<div class="col-lg-8">
|
||||
|
||||
@@ -18,8 +18,17 @@
|
||||
|
||||
<form asp-action="Create" asp-controller="Quotes" method="post" id="quoteForm">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="guidedActivation" value="@ViewBag.GuidedActivation" />
|
||||
<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 -->
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<div class="quote-mode-toggle" role="group" aria-label="Quote mode">
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
@{
|
||||
ViewData["Title"] = $"Quote {Model.QuoteNumber}";
|
||||
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">
|
||||
@@ -43,7 +45,28 @@
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
</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 -->
|
||||
<div class="col-lg-8">
|
||||
<!-- Customer/Prospect Info -->
|
||||
@@ -1461,6 +1484,7 @@
|
||||
{
|
||||
<form asp-action="ConvertToJob" asp-route-id="@Model.Id" method="post" class="d-inline" id="createJobForm">
|
||||
@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">
|
||||
<i class="bi bi-clipboard-check me-1"></i>Create Job from Quote
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@{
|
||||
ViewData["Title"] = "Setup Complete!";
|
||||
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
|
||||
var showGuidedActivationCta = (bool?)ViewBag.ShowGuidedActivationCta ?? false;
|
||||
}
|
||||
|
||||
@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;">
|
||||
Your setup is complete. @progress.DoneSteps.Count of @WizardProgressDto.TotalSteps steps were configured — your shop is ready to roll.
|
||||
</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">
|
||||
<i class="bi bi-house me-2"></i>Go to Dashboard
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
@{
|
||||
@@ -91,12 +101,7 @@
|
||||
{ 2, ("QB Migration", "bi-arrow-left-right") },
|
||||
{ 3, ("Operating Costs", "bi-currency-dollar") },
|
||||
{ 4, ("Shop Ovens", "bi-fire") },
|
||||
{ 5, ("Doc Numbering", "bi-palette") },
|
||||
{ 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") },
|
||||
{ 5, ("Notifications", "bi-bell") },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,7 +151,16 @@
|
||||
<a asp-controller="CompanySettings" asp-action="Index" class="btn btn-outline-primary">
|
||||
<i class="bi bi-gear me-1"></i>Open Company Settings
|
||||
</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">
|
||||
<i class="bi bi-house me-1"></i>Go to Dashboard
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -19,8 +19,10 @@
|
||||
|
||||
<form asp-action="PostStep4" method="post" onsubmit="return validateStep4()">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="OvensJson" id="ovensJson" value="@Html.Raw(Model.OvensJson ?? "[]")" />
|
||||
<input type="hidden" name="BlastSetupsJson" id="blastSetupsJson" value="@Html.Raw(Model.BlastSetupsJson ?? "[]")" />
|
||||
<script type="application/json" id="ovensSeedJson">@Html.Raw(Model.OvensJson ?? "[]")</script>
|
||||
<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 ─────────────────────────────────────────────────────── -->
|
||||
<div class="wizard-card">
|
||||
@@ -75,7 +77,7 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// OVENS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
var ovens = JSON.parse(document.getElementById('ovensJson').value || '[]');
|
||||
var ovens = JSON.parse(document.getElementById('ovensSeedJson').textContent || '[]');
|
||||
|
||||
function serializeOvens() {
|
||||
document.getElementById('ovensJson').value = JSON.stringify(
|
||||
@@ -212,7 +214,7 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// BLAST SETUPS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
var blasts = JSON.parse(document.getElementById('blastSetupsJson').value || '[]');
|
||||
var blasts = JSON.parse(document.getElementById('blastSetupsSeedJson').textContent || '[]');
|
||||
|
||||
function serializeBlasts() {
|
||||
document.getElementById('blastSetupsJson').value = JSON.stringify(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@{
|
||||
ViewData["Title"] = "Setup Wizard — Notifications";
|
||||
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") }
|
||||
|
||||
|
||||
@@ -8,12 +8,7 @@
|
||||
(2, "QB Migration", "bi-arrow-left-right"),
|
||||
(3, "Operating Costs", "bi-currency-dollar"),
|
||||
(4, "Shop Ovens", "bi-fire"),
|
||||
(5, "Doc Numbering", "bi-palette"),
|
||||
(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"),
|
||||
(5, "Notifications", "bi-bell"),
|
||||
};
|
||||
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