Compare commits
5 Commits
73df72ab97
...
49e3d73c67
| Author | SHA1 | Date | |
|---|---|---|---|
| 49e3d73c67 | |||
| 90a06c6acd | |||
| 9221fcc783 | |||
| ac3e4452b2 | |||
| 8de9cd04b8 |
@@ -23,6 +23,7 @@ public class InventoryItemDto
|
||||
public string? ColorFamilies { get; set; }
|
||||
public bool RequiresClearCoat { get; set; }
|
||||
public string? SpecPageUrl { get; set; }
|
||||
public string? ImageUrl { get; set; }
|
||||
public decimal QuantityOnHand { get; set; }
|
||||
public string UnitOfMeasure { get; set; } = "lbs";
|
||||
public decimal ReorderPoint { get; set; }
|
||||
@@ -144,6 +145,10 @@ public class CreateInventoryItemDto
|
||||
[Display(Name = "Product URL")]
|
||||
public string? SpecPageUrl { get; set; }
|
||||
|
||||
[StringLength(1000, ErrorMessage = "Image URL cannot exceed 1000 characters")]
|
||||
[Display(Name = "Product Image URL")]
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
[Range(0, 999999999, ErrorMessage = "Quantity on hand must be 0 or greater")]
|
||||
[Display(Name = "Quantity on Hand")]
|
||||
public decimal QuantityOnHand { get; set; }
|
||||
|
||||
@@ -25,6 +25,7 @@ public class InventoryAiLookupResult
|
||||
public decimal? UnitCostPerLb { get; set; } // price per lb/unit if found in search results
|
||||
public string? VendorName { get; set; } // manufacturer/vendor name for dropdown matching
|
||||
public string? SpecPageUrl { get; set; } // URL of the product page that was fetched
|
||||
public string? ImageUrl { get; set; } // og:image or first product image found on the page
|
||||
|
||||
public string? Reasoning { get; set; } // brief explanation of what was found
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ public class InventoryItem : BaseEntity
|
||||
public string? ColorFamilies { get; set; } // Comma-separated primary color families e.g. "Green,Blue"
|
||||
public bool RequiresClearCoat { get; set; } // True if this powder requires a clear coat topcoat
|
||||
public string? SpecPageUrl { get; set; } // Link to manufacturer's product/spec page
|
||||
public string? ImageUrl { get; set; } // Product image URL (sourced from og:image on AI lookup)
|
||||
|
||||
// Sample Panel Tracking (coating category items only)
|
||||
public bool HasSamplePanel { get; set; } = false;
|
||||
|
||||
@@ -55,6 +55,10 @@ public class Job : BaseEntity
|
||||
public int? IntakePartCount { get; set; }
|
||||
public string? IntakeCheckedByUserId { get; set; }
|
||||
|
||||
// Quote snapshot — UpdatedAt of the source quote at the moment this job was created from it.
|
||||
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
|
||||
public DateTime? QuoteSnapshotUpdatedAt { get; set; }
|
||||
|
||||
// Rework tracking
|
||||
public bool IsReworkJob { get; set; }
|
||||
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
|
||||
|
||||
Generated
+9328
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJobQuoteSnapshotUpdatedAt : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "QuoteSnapshotUpdatedAt",
|
||||
table: "Jobs",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4877));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4884));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4886));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "QuoteSnapshotUpdatedAt",
|
||||
table: "Jobs");
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9331
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInventoryItemImageUrl : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ImageUrl",
|
||||
table: "InventoryItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9171));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9177));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9179));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ImageUrl",
|
||||
table: "InventoryItems");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4877));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4884));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4886));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3181,6 +3181,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("HasSamplePanel")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("ImageUrl")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("InventoryAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -3732,6 +3735,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int?>("QuoteId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("QuoteSnapshotUpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal>("QuotedPrice")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@@ -5860,7 +5866,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5055),
|
||||
CreatedAt = new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9171),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -5871,7 +5877,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5063),
|
||||
CreatedAt = new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9177),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -5882,7 +5888,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5065),
|
||||
CreatedAt = new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9179),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
|
||||
@@ -171,14 +171,19 @@ Rules:
|
||||
_logger.LogInformation("Using direct manufacturer URL: {Url}", directUrl);
|
||||
|
||||
// Fetch product page
|
||||
var pageContent = fetchUrl != null ? await FetchPageTextAsync(fetchUrl) : null;
|
||||
string? pageContent = null;
|
||||
string? pageImageUrl = null;
|
||||
if (fetchUrl != null)
|
||||
{
|
||||
(pageContent, pageImageUrl) = await FetchPageAsync(fetchUrl);
|
||||
}
|
||||
|
||||
// If direct URL fetch failed, fall back to the search fetch URL
|
||||
if (pageContent == null && directUrl != null && searchFetchUrl != null && searchFetchUrl != directUrl)
|
||||
{
|
||||
_logger.LogInformation("Direct URL fetch failed; falling back to search URL: {Url}", searchFetchUrl);
|
||||
fetchUrl = searchFetchUrl;
|
||||
pageContent = await FetchPageTextAsync(searchFetchUrl);
|
||||
(pageContent, pageImageUrl) = await FetchPageAsync(searchFetchUrl);
|
||||
}
|
||||
|
||||
var userPrompt = BuildUserPrompt(manufacturer, colorName, colorCode, partNumber, snippets, fetchUrl, pageContent);
|
||||
@@ -246,6 +251,7 @@ Rules:
|
||||
result.UnitCostPerLb = GetDecimal(parsed, "unitCostPerLb");
|
||||
result.VendorName = GetString(parsed, "vendorName");
|
||||
result.SpecPageUrl = specPageUrl;
|
||||
result.ImageUrl = pageImageUrl;
|
||||
result.Reasoning = GetString(parsed, "reasoning");
|
||||
|
||||
return result;
|
||||
@@ -488,7 +494,13 @@ Rules:
|
||||
/// A browser-like User-Agent header is sent because some manufacturer sites return 403
|
||||
/// or empty responses to bare HttpClient default agents.
|
||||
/// </summary>
|
||||
private async Task<string?> FetchPageTextAsync(string url)
|
||||
/// <summary>
|
||||
/// Fetches a product page and returns both stripped plain text (for Claude) and the
|
||||
/// best product image URL found on the page. Extracts og:image (Open Graph) first,
|
||||
/// then falls back to twitter:image. The raw HTML is processed before tag-stripping
|
||||
/// so the image URL is captured while it still exists in the markup.
|
||||
/// </summary>
|
||||
private async Task<(string? text, string? imageUrl)> FetchPageAsync(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -499,6 +511,9 @@ Rules:
|
||||
|
||||
var html = await client.GetStringAsync(url);
|
||||
|
||||
// Extract product image from Open Graph / Twitter Card meta tags
|
||||
var imageUrl = ExtractOgImageUrl(html);
|
||||
|
||||
// Extract structured data (JSON-LD) BEFORE stripping scripts — it contains
|
||||
// machine-readable price, SKU, and product info that would otherwise be lost.
|
||||
var structuredData = ExtractJsonLdData(html);
|
||||
@@ -524,17 +539,46 @@ Rules:
|
||||
if (!string.IsNullOrWhiteSpace(structuredData))
|
||||
text = structuredData + "\n" + text;
|
||||
|
||||
_logger.LogInformation("Fetched {Chars} chars from {Url} (structured data: {HasData})",
|
||||
text.Length, url, structuredData != null ? "yes" : "no");
|
||||
return text;
|
||||
_logger.LogInformation("Fetched {Chars} chars from {Url} (structured data: {HasData}, image: {HasImage})",
|
||||
text.Length, url, structuredData != null ? "yes" : "no", imageUrl != null ? "yes" : "no");
|
||||
return (text, imageUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch page content from {Url}", url);
|
||||
return null;
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the best product image URL from raw HTML. Checks og:image first (most
|
||||
/// reliable for e-commerce product pages), then twitter:image as fallback.
|
||||
/// </summary>
|
||||
private static string? ExtractOgImageUrl(string html)
|
||||
{
|
||||
var patterns = new[]
|
||||
{
|
||||
@"<meta[^>]+property=[""']og:image[""'][^>]+content=[""']([^""']+)[""']",
|
||||
@"<meta[^>]+content=[""']([^""']+)[""'][^>]+property=[""']og:image[""']",
|
||||
@"<meta[^>]+name=[""']twitter:image[""'][^>]+content=[""']([^""']+)[""']",
|
||||
@"<meta[^>]+content=[""']([^""']+)[""'][^>]+name=[""']twitter:image[""']",
|
||||
};
|
||||
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
var m = System.Text.RegularExpressions.Regex.Match(
|
||||
html, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
if (m.Success)
|
||||
{
|
||||
var url = m.Groups[1].Value.Trim();
|
||||
if (url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts product name, SKU, and price from JSON-LD structured data blocks.
|
||||
/// Many e-commerce sites (Shopify, WooCommerce, etc.) embed this in the page HTML
|
||||
|
||||
@@ -771,7 +771,38 @@ public class DashboardController : Controller
|
||||
}
|
||||
};
|
||||
|
||||
return new ShopProgressWidgetViewModel { Items = items.Where(i => i != null).Select(i => i!).ToList() };
|
||||
var vm = new ShopProgressWidgetViewModel { Items = items.Where(i => i != null).Select(i => i!).ToList() };
|
||||
|
||||
// Suppress widget if the user already dismissed it after completing all steps
|
||||
if (vm.AllDone && prefs.GuidedActivationDismissedAt.HasValue)
|
||||
return null;
|
||||
|
||||
return vm;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the company admin's dismissal of the progress widget completion state.
|
||||
/// Sets <c>GuidedActivationDismissedAt</c> so the widget stays hidden across devices
|
||||
/// and browser sessions (localStorage alone wouldn't survive a cleared cache).
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> DismissProgressWidget()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null)
|
||||
return Json(new { success = false });
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.Preferences!);
|
||||
if (company?.Preferences == null)
|
||||
return Json(new { success = false });
|
||||
|
||||
company.Preferences.GuidedActivationDismissedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Progress widget dismissed for company {CompanyId}", companyId.Value);
|
||||
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -488,6 +488,19 @@ public class JobsController : Controller
|
||||
.OrderBy(c => c.Text)
|
||||
.ToList();
|
||||
|
||||
// Banner: warn if the source quote was edited after this job was created from it.
|
||||
if (job.Quote != null && job.QuoteSnapshotUpdatedAt.HasValue &&
|
||||
job.Quote.UpdatedAt.HasValue && job.Quote.UpdatedAt > job.QuoteSnapshotUpdatedAt)
|
||||
{
|
||||
ViewBag.QuoteUpdatedAfterConversion = true;
|
||||
ViewBag.QuoteUpdatedAt = job.Quote.UpdatedAt.Value;
|
||||
ViewBag.SourceQuoteId = job.QuoteId;
|
||||
ViewBag.SourceQuoteNumber = job.Quote.QuoteNumber;
|
||||
var preProductionCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{ "PENDING", "QUOTED", "APPROVED" };
|
||||
ViewBag.CanResyncFromQuote = preProductionCodes.Contains(job.JobStatus?.StatusCode ?? "");
|
||||
}
|
||||
|
||||
var jobPrefs = await GetCompanyPreferencesAsync(job.CompanyId);
|
||||
if (guidedActivation == AppConstants.GuidedActivation.JobCreatedStep
|
||||
&& jobPrefs?.FirstWorkflowCompleted == false)
|
||||
@@ -3618,6 +3631,192 @@ public class JobsController : Controller
|
||||
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
|
||||
}
|
||||
|
||||
// ── Quote-Changed Banner Actions ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Dismisses the "source quote was updated" banner by advancing QuoteSnapshotUpdatedAt
|
||||
/// to match the quote's current UpdatedAt. No job data changes — the user is acknowledging
|
||||
/// they have reviewed the quote manually.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> DismissQuoteChangedBanner(int id)
|
||||
{
|
||||
var job = await _unitOfWork.Jobs.GetByIdAsync(id, false, j => j.Quote!);
|
||||
if (job == null) return NotFound();
|
||||
|
||||
job.QuoteSnapshotUpdatedAt = job.Quote?.UpdatedAt ?? job.Quote?.CreatedAt;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-syncs a job's items and pricing from its source quote, but only while the job is
|
||||
/// still in a pre-production status (PENDING, QUOTED, APPROVED). Once shop work has
|
||||
/// started (IN_PREPARATION or beyond) the button is hidden and this endpoint returns 400
|
||||
/// as a safety guard. Soft-deletes all current job items, then re-copies items, coats,
|
||||
/// and prep services from the quote — identical logic to the initial quote→job conversion.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ResyncFromQuote(int id)
|
||||
{
|
||||
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
|
||||
if (job == null) return NotFound();
|
||||
|
||||
// Guard: only allow re-sync while job is pre-production
|
||||
var preProductionCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{ "PENDING", "QUOTED", "APPROVED" };
|
||||
if (!preProductionCodes.Contains(job.JobStatus?.StatusCode ?? ""))
|
||||
{
|
||||
TempData["Error"] = "Re-sync is only available before shop work has started.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
if (!job.QuoteId.HasValue)
|
||||
{
|
||||
TempData["Error"] = "This job has no linked quote to sync from.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
// Soft-delete all current job items and their coats
|
||||
var existingItems = job.JobItems.Where(ji => !ji.IsDeleted).ToList();
|
||||
foreach (var item in existingItems)
|
||||
{
|
||||
var coats = await _unitOfWork.JobItemCoats.FindAsync(c => c.JobItemId == item.Id && !c.IsDeleted);
|
||||
foreach (var coat in coats)
|
||||
await _unitOfWork.JobItemCoats.SoftDeleteAsync(coat.Id);
|
||||
|
||||
await _unitOfWork.JobItems.SoftDeleteAsync(item.Id);
|
||||
}
|
||||
|
||||
// Soft-delete all job-level prep services
|
||||
var existingPrep = await _unitOfWork.JobPrepServices.FindAsync(p => p.JobId == id && !p.IsDeleted);
|
||||
foreach (var ps in existingPrep)
|
||||
await _unitOfWork.JobPrepServices.SoftDeleteAsync(ps.Id);
|
||||
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
// Load quote items with full coat + prep-service data
|
||||
var quote = job.Quote!;
|
||||
var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(job.QuoteId.Value);
|
||||
|
||||
foreach (var quoteItem in fullItems.Where(qi => !qi.IsDeleted))
|
||||
{
|
||||
var firstCoat = quoteItem.Coats?.OrderBy(c => c.Sequence).FirstOrDefault();
|
||||
|
||||
var jobItem = new JobItem
|
||||
{
|
||||
JobId = id,
|
||||
Description = quoteItem.Description,
|
||||
Quantity = quoteItem.Quantity,
|
||||
ColorName = firstCoat?.ColorName,
|
||||
ColorCode = firstCoat?.ColorCode,
|
||||
Finish = firstCoat?.Finish,
|
||||
SurfaceArea = quoteItem.SurfaceAreaSqFt,
|
||||
SurfaceAreaSqFt = quoteItem.SurfaceAreaSqFt,
|
||||
CatalogItemId = quoteItem.CatalogItemId,
|
||||
IsGenericItem = quoteItem.IsGenericItem,
|
||||
IsLaborItem = quoteItem.IsLaborItem,
|
||||
IsSalesItem = quoteItem.IsSalesItem,
|
||||
Sku = quoteItem.Sku,
|
||||
ManualUnitPrice = quoteItem.ManualUnitPrice,
|
||||
PowderCostOverride = quoteItem.PowderCostOverride,
|
||||
UnitPrice = quoteItem.UnitPrice,
|
||||
TotalPrice = quoteItem.TotalPrice,
|
||||
LaborCost = quoteItem.TotalPrice * 0.4m,
|
||||
RequiresSandblasting = quoteItem.RequiresSandblasting,
|
||||
RequiresMasking = quoteItem.RequiresMasking,
|
||||
EstimatedMinutes = quoteItem.EstimatedMinutes,
|
||||
Notes = quoteItem.Notes,
|
||||
Complexity = quoteItem.Complexity,
|
||||
AiTags = quoteItem.AiTags,
|
||||
AiPredictionId = quoteItem.AiPredictionId,
|
||||
IncludePrepCost = !quoteItem.CatalogItemId.HasValue,
|
||||
CompanyId = job.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.JobItems.AddAsync(jobItem);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
if (quoteItem.Coats != null)
|
||||
{
|
||||
foreach (var quoteCoat in quoteItem.Coats.OrderBy(c => c.Sequence))
|
||||
{
|
||||
string colorName = quoteCoat.ColorName;
|
||||
string colorCode = quoteCoat.ColorCode;
|
||||
string finish = quoteCoat.Finish;
|
||||
|
||||
if (quoteCoat.InventoryItemId.HasValue && quoteCoat.InventoryItem != null)
|
||||
{
|
||||
colorName = quoteCoat.InventoryItem.Name;
|
||||
colorCode = quoteCoat.InventoryItem.ColorCode;
|
||||
finish = quoteCoat.InventoryItem.Finish;
|
||||
}
|
||||
|
||||
var cov = quoteCoat.CoverageSqFtPerLb > 0 ? quoteCoat.CoverageSqFtPerLb : 30m;
|
||||
var eff = quoteCoat.TransferEfficiency > 0 ? quoteCoat.TransferEfficiency / 100m : 0.65m;
|
||||
var powderToOrder = (quoteCoat.PowderToOrder > 0)
|
||||
? quoteCoat.PowderToOrder
|
||||
: (quoteItem.SurfaceAreaSqFt > 0
|
||||
? Math.Round((quoteItem.SurfaceAreaSqFt * quoteItem.Quantity) / (cov * eff), 2)
|
||||
: (decimal?)null);
|
||||
|
||||
await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat
|
||||
{
|
||||
JobItemId = jobItem.Id,
|
||||
CoatName = quoteCoat.CoatName,
|
||||
Sequence = quoteCoat.Sequence,
|
||||
InventoryItemId = quoteCoat.InventoryItemId,
|
||||
ColorName = colorName,
|
||||
VendorId = quoteCoat.VendorId,
|
||||
ColorCode = colorCode,
|
||||
Finish = finish,
|
||||
CoverageSqFtPerLb = quoteCoat.CoverageSqFtPerLb,
|
||||
TransferEfficiency = quoteCoat.TransferEfficiency,
|
||||
PowderCostPerLb = quoteCoat.PowderCostPerLb,
|
||||
PowderToOrder = powderToOrder,
|
||||
Notes = quoteCoat.Notes,
|
||||
CompanyId = job.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
// Aggregate prep services from all quote items and copy to job
|
||||
var quoteItemIds = fullItems.Select(qi => qi.Id).ToList();
|
||||
var itemPrepServices = await _unitOfWork.QuoteItemPrepServices.FindAsync(
|
||||
ps => quoteItemIds.Contains(ps.QuoteItemId));
|
||||
foreach (var prepServiceId in itemPrepServices.Select(ps => ps.PrepServiceId).Distinct())
|
||||
{
|
||||
await _unitOfWork.JobPrepServices.AddAsync(new JobPrepService
|
||||
{
|
||||
JobId = id,
|
||||
PrepServiceId = prepServiceId,
|
||||
CompanyId = job.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
// Update pricing from quote and advance the snapshot so banner clears
|
||||
job.QuotedPrice = quote.Total;
|
||||
job.FinalPrice = quote.Total;
|
||||
job.QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
});
|
||||
|
||||
_logger.LogInformation("Job {JobId} re-synced from quote {QuoteId}", id, job.QuoteId);
|
||||
TempData["Success"] = "Job items and pricing re-synced from the source quote.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
// ── Job Costing Breakdown ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.ViewModels.Platform;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class OnboardingProgressController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
public OnboardingProgressController(IUnitOfWork unitOfWork, UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows onboarding and activation status for every tenant company. SuperAdmin-only.
|
||||
/// Reads <c>CompanyPreferences</c> fields written by the setup wizard, guided activation,
|
||||
/// and the jobs/quotes/invoices controllers to give a cross-tenant activation funnel view.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var companies = (await _unitOfWork.Companies.GetAllAsync(
|
||||
ignoreQueryFilters: true,
|
||||
c => c.Preferences!))
|
||||
.Where(c => !c.IsDeleted)
|
||||
.OrderBy(c => c.CompanyName)
|
||||
.ToList();
|
||||
|
||||
var rows = companies.Select(c => BuildRow(c)).ToList();
|
||||
|
||||
return View(new OnboardingProgressIndexViewModel { Rows = rows });
|
||||
}
|
||||
|
||||
private static OnboardingProgressRowViewModel BuildRow(Company company)
|
||||
{
|
||||
var prefs = company.Preferences;
|
||||
var wizardDone = prefs?.SetupWizardCompleted ?? false;
|
||||
|
||||
// Mirror the same 6-step logic used in DashboardController.BuildShopProgressWidgetAsync
|
||||
// Steps: first job/quote, status history (unknown here — omit), first invoice,
|
||||
// team size (unknown here — omit), customized lookups (unknown — omit), payment defaults.
|
||||
// We track the 3 date-stamped milestones we can derive from prefs alone.
|
||||
int steps = 0;
|
||||
if (prefs?.FirstJobCreatedAt.HasValue == true || prefs?.FirstQuoteCreatedAt.HasValue == true) steps++;
|
||||
if (prefs?.FirstInvoiceCreatedAt.HasValue == true) steps++;
|
||||
if (prefs?.FirstWorkflowCompletedAt.HasValue == true) steps++;
|
||||
const int total = 3;
|
||||
|
||||
OnboardingStatus status;
|
||||
if (!wizardDone)
|
||||
status = OnboardingStatus.NotStarted;
|
||||
else if (prefs?.FirstWorkflowCompletedAt.HasValue == true && prefs.GuidedActivationDismissedAt.HasValue)
|
||||
status = OnboardingStatus.Dismissed;
|
||||
else if (prefs?.FirstWorkflowCompletedAt.HasValue == true)
|
||||
status = OnboardingStatus.Complete;
|
||||
else if (steps > 0)
|
||||
status = OnboardingStatus.InProgress;
|
||||
else
|
||||
status = OnboardingStatus.NotStarted;
|
||||
|
||||
return new OnboardingProgressRowViewModel
|
||||
{
|
||||
CompanyId = company.Id,
|
||||
CompanyName = company.CompanyName ?? "Unknown",
|
||||
WizardCompleted = wizardDone,
|
||||
OnboardingPath = prefs?.OnboardingPath,
|
||||
StepsCompleted = steps,
|
||||
TotalSteps = total,
|
||||
FirstJobCreatedAt = prefs?.FirstJobCreatedAt,
|
||||
FirstQuoteCreatedAt = prefs?.FirstQuoteCreatedAt,
|
||||
FirstInvoiceCreatedAt = prefs?.FirstInvoiceCreatedAt,
|
||||
FirstWorkflowCompletedAt = prefs?.FirstWorkflowCompletedAt,
|
||||
GuidedActivationDismissedAt = prefs?.GuidedActivationDismissedAt,
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2991,7 +2991,8 @@ public class QuotesController : Controller
|
||||
DiscountValue = quote.DiscountValue,
|
||||
DiscountReason = quote.DiscountReason,
|
||||
CompanyId = quote.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt
|
||||
};
|
||||
|
||||
await _unitOfWork.Jobs.AddAsync(job);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace PowderCoating.Web.ViewModels.Platform;
|
||||
|
||||
public class OnboardingProgressIndexViewModel
|
||||
{
|
||||
public List<OnboardingProgressRowViewModel> Rows { get; set; } = new();
|
||||
public int TotalCompanies => Rows.Count;
|
||||
public int WizardCompleted => Rows.Count(r => r.WizardCompleted);
|
||||
public int FullyActivated => Rows.Count(r => r.Status == OnboardingStatus.Complete);
|
||||
public int InProgress => Rows.Count(r => r.Status == OnboardingStatus.InProgress);
|
||||
public int NotStarted => Rows.Count(r => r.Status == OnboardingStatus.NotStarted);
|
||||
}
|
||||
|
||||
public class OnboardingProgressRowViewModel
|
||||
{
|
||||
public int CompanyId { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public bool WizardCompleted { get; set; }
|
||||
public string? OnboardingPath { get; set; }
|
||||
public int StepsCompleted { get; set; }
|
||||
public int TotalSteps { get; set; }
|
||||
public DateTime? FirstJobCreatedAt { get; set; }
|
||||
public DateTime? FirstQuoteCreatedAt { get; set; }
|
||||
public DateTime? FirstInvoiceCreatedAt { get; set; }
|
||||
public DateTime? FirstWorkflowCompletedAt { get; set; }
|
||||
public DateTime? GuidedActivationDismissedAt { get; set; }
|
||||
public OnboardingStatus Status { get; set; }
|
||||
}
|
||||
|
||||
public enum OnboardingStatus
|
||||
{
|
||||
NotStarted,
|
||||
InProgress,
|
||||
Complete,
|
||||
Dismissed
|
||||
}
|
||||
@@ -123,6 +123,17 @@
|
||||
</div>
|
||||
<span asp-validation-for="SpecPageUrl" class="text-danger"></span>
|
||||
</div>
|
||||
<input asp-for="ImageUrl" type="hidden" id="field-imageurl" />
|
||||
<div class="col-12" id="wrap-imagepreview" style="display:@(Model.ImageUrl != null ? "" : "none");">
|
||||
<label class="form-label text-muted small">Product Image (from AI Lookup)</label>
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<img id="field-imagepreview-img" src="@Model.ImageUrl" alt="Product image"
|
||||
style="max-height:120px;max-width:160px;object-fit:contain;border:1px solid #dee2e6;border-radius:6px;background:#f8f9fa;padding:4px;" />
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" id="btn-clear-image" title="Remove image">
|
||||
<i class="bi bi-x me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6" id="wrap-coverage">
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<label asp-for="CoverageSqFtPerLb" class="form-label mb-0">Coverage (@ViewBag.CoverageUnit)</label>
|
||||
|
||||
@@ -308,6 +308,32 @@
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="col-lg-4">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ImageUrl))
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-3 text-center">
|
||||
<a href="#" data-bs-toggle="modal" data-bs-target="#imageModal" title="Click to enlarge" style="cursor:zoom-in;">
|
||||
<img src="@Model.ImageUrl" alt="@Model.Name"
|
||||
style="max-width:100%;max-height:200px;object-fit:contain;" />
|
||||
</a>
|
||||
<div class="text-muted small mt-1"><i class="bi bi-zoom-in me-1"></i>Click to enlarge</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Lightbox Modal -->
|
||||
<div class="modal fade" id="imageModal" tabindex="-1" aria-label="Product image">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content border-0 bg-transparent shadow-none">
|
||||
<div class="modal-body p-0 text-center position-relative">
|
||||
<button type="button" class="btn-close btn-close-white position-absolute top-0 end-0 m-2" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
<img src="@Model.ImageUrl" alt="@Model.Name"
|
||||
style="max-width:100%;max-height:85vh;object-fit:contain;border-radius:6px;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Stock, Pricing & Status -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-0 py-2 d-flex align-items-center gap-2">
|
||||
|
||||
@@ -125,6 +125,17 @@
|
||||
</div>
|
||||
<span asp-validation-for="SpecPageUrl" class="text-danger"></span>
|
||||
</div>
|
||||
<input asp-for="ImageUrl" type="hidden" id="field-imageurl" />
|
||||
<div class="col-12" id="wrap-imagepreview" style="display:@(Model.ImageUrl != null ? "" : "none");">
|
||||
<label class="form-label text-muted small">Product Image (from AI Lookup)</label>
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<img id="field-imagepreview-img" src="@Model.ImageUrl" alt="Product image"
|
||||
style="max-height:120px;max-width:160px;object-fit:contain;border:1px solid #dee2e6;border-radius:6px;background:#f8f9fa;padding:4px;" />
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" id="btn-clear-image" title="Remove image">
|
||||
<i class="bi bi-x me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6" id="wrap-coverage">
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<label asp-for="CoverageSqFtPerLb" class="form-label mb-0">Coverage (@ViewBag.CoverageUnit)</label>
|
||||
|
||||
@@ -153,8 +153,33 @@
|
||||
let aiFilledColorFamilies = false;
|
||||
let aiFilledVendor = false;
|
||||
let aiFilledClearCoat = false;
|
||||
let aiFilledImage = false;
|
||||
let forceRefill = false; // set true for bad-match retry
|
||||
|
||||
function setImagePreview(url) {
|
||||
const wrap = document.getElementById('wrap-imagepreview');
|
||||
const img = document.getElementById('field-imagepreview-img');
|
||||
const inp = document.getElementById('field-imageurl');
|
||||
if (!wrap || !img || !inp) return;
|
||||
if (url) {
|
||||
inp.value = url;
|
||||
img.src = url;
|
||||
wrap.style.display = '';
|
||||
} else {
|
||||
inp.value = '';
|
||||
img.src = '';
|
||||
wrap.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const clearImageBtn = document.getElementById('btn-clear-image');
|
||||
if (clearImageBtn) {
|
||||
clearImageBtn.addEventListener('click', () => {
|
||||
setImagePreview('');
|
||||
aiFilledImage = false;
|
||||
});
|
||||
}
|
||||
|
||||
function autoComposeName() {
|
||||
if (!isCoatingCategory(categorySelect?.value)) return;
|
||||
const color = colorNameEl?.value?.trim() ?? '';
|
||||
@@ -257,6 +282,7 @@
|
||||
if (cc) cc.checked = false;
|
||||
aiFilledClearCoat = false;
|
||||
}
|
||||
if (aiFilledImage) { setImagePreview(''); aiFilledImage = false; }
|
||||
aiFilledFields = [];
|
||||
lastAutoName = '';
|
||||
forceRefill = false;
|
||||
@@ -421,6 +447,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Product image — show preview if returned and not already set
|
||||
if (data.imageUrl && (forceRefill || !document.getElementById('field-imageurl')?.value?.trim())) {
|
||||
setImagePreview(data.imageUrl);
|
||||
filled.push('Product Image');
|
||||
aiFilledImage = true;
|
||||
}
|
||||
|
||||
// Build a persistent "needs more info" tip if key identity fields are still unknown
|
||||
const missingHints = [];
|
||||
if (!data.manufacturer && !document.getElementById('field-manufacturer')?.value?.trim())
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
ViewData["Title"] = $"Job {Model.JobNumber}";
|
||||
ViewData["PageIcon"] = "bi-briefcase";
|
||||
var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel;
|
||||
bool quoteUpdated = ViewBag.QuoteUpdatedAfterConversion == true;
|
||||
}
|
||||
|
||||
<div class="row justify-content-center">
|
||||
@@ -39,6 +40,49 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (quoteUpdated)
|
||||
{
|
||||
bool canResync = ViewBag.CanResyncFromQuote == true;
|
||||
<div class="alert alert-warning alert-permanent mb-4">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-5 mt-1 flex-shrink-0"></i>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-semibold mb-1">Source quote was updated on @(((DateTime)ViewBag.QuoteUpdatedAt).ToString("MMM d, yyyy"))</div>
|
||||
<div class="mb-3">
|
||||
<a asp-controller="Quotes" asp-action="Details" asp-route-id="@ViewBag.SourceQuoteId" class="alert-link">@ViewBag.SourceQuoteNumber</a>
|
||||
was edited after this job was created.
|
||||
@if (canResync)
|
||||
{
|
||||
<span>Re-sync to replace job items and pricing with the latest quote, or dismiss if you've already handled it manually.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Shop work has started — review the quote and apply any changes manually.</span>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
@if (canResync)
|
||||
{
|
||||
<form asp-action="ResyncFromQuote" asp-route-id="@Model.Id" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-warning fw-semibold"
|
||||
onclick="return confirm('This will replace all job items and pricing with the current quote. Continue?')">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Re-sync from Quote
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
<form asp-action="DismissQuoteChangedBanner" asp-route-id="@Model.Id" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-check me-1"></i>Dismiss
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (guidedActivationCallout?.Show == true)
|
||||
{
|
||||
<div class="alert alert-primary alert-permanent border-0 shadow-sm mb-4">
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
@model PowderCoating.Web.ViewModels.Platform.OnboardingProgressIndexViewModel
|
||||
@using PowderCoating.Web.ViewModels.Platform
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Onboarding Progress";
|
||||
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||
}
|
||||
|
||||
<div class="container-fluid px-4 py-3">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0 fw-semibold">Onboarding Progress</h4>
|
||||
<p class="text-muted mb-0" style="font-size:.85rem;">Activation funnel across all tenant companies</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Summary KPI strip *@
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 shadow-sm text-center py-3">
|
||||
<div class="fs-3 fw-bold">@Model.TotalCompanies</div>
|
||||
<div class="text-muted small">Total Companies</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 shadow-sm text-center py-3">
|
||||
<div class="fs-3 fw-bold text-primary">@Model.WizardCompleted</div>
|
||||
<div class="text-muted small">Wizard Completed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 shadow-sm text-center py-3">
|
||||
<div class="fs-3 fw-bold text-success">@Model.FullyActivated</div>
|
||||
<div class="text-muted small">Fully Activated</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 shadow-sm text-center py-3">
|
||||
<div class="fs-3 fw-bold text-warning">@Model.InProgress</div>
|
||||
<div class="text-muted small">In Progress</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0" id="onboardingTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Company</th>
|
||||
<th class="text-center">Wizard</th>
|
||||
<th>Path</th>
|
||||
<th class="text-center">Milestones</th>
|
||||
<th>First Job / Quote</th>
|
||||
<th>First Invoice</th>
|
||||
<th>Workflow Done</th>
|
||||
<th>Widget Dismissed</th>
|
||||
<th class="text-center">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in Model.Rows)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<a asp-controller="Companies" asp-action="Details" asp-route-id="@row.CompanyId"
|
||||
class="fw-medium text-decoration-none">@row.CompanyName</a>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (row.WizardCompleted)
|
||||
{
|
||||
<i class="bi bi-check-circle-fill text-success"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-circle text-muted"></i>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(row.OnboardingPath))
|
||||
{
|
||||
<span class="badge bg-secondary bg-opacity-10 text-secondary">@row.OnboardingPath</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@{
|
||||
var pct = row.TotalSteps == 0 ? 0 : row.StepsCompleted * 100 / row.TotalSteps;
|
||||
var barColor = pct == 100 ? "bg-success" : "bg-primary";
|
||||
}
|
||||
<div class="d-flex align-items-center gap-2" style="min-width:80px;">
|
||||
<div class="progress flex-grow-1" style="height:5px;">
|
||||
<div class="progress-bar @barColor" style="width:@pct%"></div>
|
||||
</div>
|
||||
<span class="text-muted small" style="white-space:nowrap;">@row.StepsCompleted/@row.TotalSteps</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-muted small">
|
||||
@{
|
||||
var firstActivity = row.FirstJobCreatedAt ?? row.FirstQuoteCreatedAt;
|
||||
}
|
||||
@(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "—")
|
||||
</td>
|
||||
<td class="text-muted small">
|
||||
@(row.FirstInvoiceCreatedAt.HasValue ? row.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "—")
|
||||
</td>
|
||||
<td class="text-muted small">
|
||||
@(row.FirstWorkflowCompletedAt.HasValue ? row.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "—")
|
||||
</td>
|
||||
<td class="text-muted small">
|
||||
@(row.GuidedActivationDismissedAt.HasValue ? row.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "—")
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@switch (row.Status)
|
||||
{
|
||||
case OnboardingStatus.Complete:
|
||||
<span class="badge bg-success">Complete</span>
|
||||
break;
|
||||
case OnboardingStatus.InProgress:
|
||||
<span class="badge bg-warning text-dark">In Progress</span>
|
||||
break;
|
||||
case OnboardingStatus.Dismissed:
|
||||
<span class="badge bg-secondary">Dismissed</span>
|
||||
break;
|
||||
default:
|
||||
<span class="badge bg-light text-muted border">Not Started</span>
|
||||
break;
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (!Model.Rows.Any())
|
||||
{
|
||||
<tr>
|
||||
<td colspan="9" class="text-center text-muted py-4">No companies found.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1215,6 +1215,10 @@
|
||||
</a>
|
||||
|
||||
<div class="nav-section-title">Users & Activity</div>
|
||||
<a asp-controller="OnboardingProgress" asp-action="Index" class="nav-link">
|
||||
<i class="bi bi-rocket-takeoff"></i>
|
||||
<span>Onboarding Progress</span>
|
||||
</a>
|
||||
<a asp-controller="PlatformUsers" asp-action="Index" class="nav-link">
|
||||
<i class="bi bi-people-fill"></i>
|
||||
<span>Platform Users</span>
|
||||
|
||||
@@ -394,10 +394,21 @@ function renderProductFields() {
|
||||
}
|
||||
|
||||
function filterCatalog() {
|
||||
const q = document.getElementById('catalogSearch').value.toLowerCase();
|
||||
document.querySelectorAll('#catalogListbox .catalog-list-item').forEach(el => {
|
||||
el.style.display = (q && !el.textContent.toLowerCase().includes(q)) ? 'none' : '';
|
||||
const q = document.getElementById('catalogSearch').value.toLowerCase();
|
||||
const words = q.split(/\s+/).filter(Boolean);
|
||||
const listbox = document.getElementById('catalogListbox');
|
||||
if (!listbox) return;
|
||||
listbox.querySelectorAll('.catalog-list-item').forEach(el => {
|
||||
const text = el.textContent.toLowerCase();
|
||||
const hide = words.length > 0 && !words.every(w => text.includes(w));
|
||||
el.style.display = hide ? 'none' : '';
|
||||
});
|
||||
// Reset scroll so filtered-in items are always visible at the top.
|
||||
listbox.scrollTop = 0;
|
||||
// Force a layout recalculation — required on iOS Safari so elements that
|
||||
// transition from display:none back to visible register as interactive.
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
listbox.offsetHeight;
|
||||
}
|
||||
|
||||
function pickCatalogItem(el) {
|
||||
|
||||
@@ -38,6 +38,14 @@
|
||||
dismiss.addEventListener('click', function () {
|
||||
widget.style.display = 'none';
|
||||
try { localStorage.setItem(DISMISSED_KEY, '1'); } catch (e) { }
|
||||
// Persist server-side so dismissal survives cache clears and other devices
|
||||
var token = document.querySelector('input[name="__RequestVerificationToken"]');
|
||||
if (token) {
|
||||
fetch('/Dashboard/DismissProgressWidget', {
|
||||
method: 'POST',
|
||||
headers: { 'RequestVerificationToken': token.value }
|
||||
}).catch(function () {});
|
||||
}
|
||||
});
|
||||
}
|
||||
}());
|
||||
|
||||
Reference in New Issue
Block a user