Merge branch 'dev'

This commit is contained in:
2026-04-30 08:23:40 -04:00
18 changed files with 19217 additions and 13 deletions
@@ -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;
+4
View File
@@ -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
@@ -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));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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
@@ -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>
@@ -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);
@@ -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>
@@ -168,7 +168,7 @@
}
.reason-pill.selected { border-color: var(--purple); background: #f3effe; color: var(--purple); font-weight: 600; }
/* ── Submit ──────────────────────────────────── */
/* ── Submit / Cancel ─────────────────────────── */
.btn-submit {
width: 100%;
padding: 16px;
@@ -183,6 +183,22 @@
}
.btn-submit:disabled { opacity: .6; }
.btn-submit:active { background: var(--purple-dark); }
.btn-cancel {
display: block;
width: 100%;
padding: 14px;
background: #fff;
color: var(--muted);
border: 1.5px solid var(--border);
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-top: 10px;
text-align: center;
text-decoration: none;
}
.btn-cancel:active { background: #f0f0f0; }
.error-banner {
margin: 0 16px 16px;
@@ -319,10 +335,11 @@
</div>
</div>
<div style="margin: 0 16px">
<div style="margin: 0 16px 8px">
<button type="submit" class="btn-submit" id="submitBtn">
Save Usage Log
</button>
<a href="/Inventory/Details/@item!.Id" class="btn-cancel">Cancel</a>
</div>
</form>
@@ -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">