Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Dashboard/Index.cshtml
T
spouliot aa0efe7c6f Add PWA install banner to dashboard
Shows a dismissible banner on mobile only, tailored to three cases:
- iOS + Safari: instructions to tap Share → Add to Home Screen
- iOS + other browser: tells user to open in Safari first (required for standalone)
- Android: instructions to tap menu → Install App / Add to Home Screen

Hidden when already running as standalone PWA, or after user dismisses it
(stored in localStorage so it stays gone). Explains camera permission benefit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:29:21 -04:00

1357 lines
78 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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 -->
@{ var _attnCount = Model.OverdueJobsCount + Model.OverdueInvoicesCount; }
<div class="card border-0 shadow-sm mb-4" style="background:var(--pcl-paper-2);">
<div class="card-body p-0">
<div class="row g-0 align-items-stretch">
<div class="col-lg-7 px-4 py-4">
<div class="mono text-uppercase mb-2" style="font-size:0.7rem;letter-spacing:.12em;color:var(--pcl-steel);">
@DateTime.Now.ToString("dddd").ToUpper() &middot; @DateTime.Now.ToString("MMMM d").ToUpper()
</div>
<p class="mb-3" style="font-family:var(--font-display);font-size:1.35rem;font-weight:500;line-height:1.4;color:var(--pcl-ink);">
@if (_attnCount > 0)
{
<span>Shop is </span><span style="color:var(--pcl-bad);">running hot</span><span> — @_attnCount item@(_attnCount == 1 ? "" : "s") need attention.</span>
}
else
{
<span>Everything's on track. @Model.TodaysJobsCount job@(Model.TodaysJobsCount == 1 ? "" : "s") scheduled for today.</span>
}
</p>
<div class="d-flex gap-2 flex-wrap align-items-center">
<a asp-controller="Jobs" asp-action="Board" class="btn btn-sm btn-primary">Open Jobs Board</a>
@if (!string.IsNullOrEmpty(Model.TipOfTheDay))
{
<span class="text-muted d-none d-xl-inline" style="font-size:0.73rem;"><i class="bi bi-lightbulb me-1"></i>@Model.TipOfTheDay</span>
}
</div>
</div>
<div class="col-lg-5 px-4 py-4" style="border-left:1px solid var(--pcl-rule);">
<div class="row g-3">
<div class="col-6">
@await Html.PartialAsync("_Metric", (Label: "OUTSTANDING A/R", Value: Model.OutstandingAr.ToString("C0"), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="col-6">
@await Html.PartialAsync("_Metric", (Label: "COLLECTED " + DateTime.Now.ToString("MMM").ToUpper(), Value: Model.CollectedThisMonth.ToString("C0"), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="col-6">
@await Html.PartialAsync("_Metric", (Label: "TODAY'S JOBS", Value: Model.TodaysJobsCount.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="col-6">
@await Html.PartialAsync("_Metric", (Label: "ACTIVE CUSTOMERS", Value: Model.ActiveCustomersCount.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
</div>
</div>
</div>
</div>
</div>
@* PWA install banner — rendered by JS only on mobile, hidden once dismissed or already installed *@
<div id="pwa-install-banner" class="row mb-4" style="display:none!important;">
<div class="col-12">
<div class="alert alert-permanent mb-0 d-flex align-items-start gap-3 py-3"
style="background:var(--pcl-paper-2);border:1px solid var(--pcl-rule);border-left:4px solid var(--pcl-ember);">
<div class="flex-shrink-0 mt-1">
<img src="/images/pwa-icon-192.png" alt="" width="36" height="36" style="border-radius:8px;">
</div>
<div class="flex-grow-1">
<div class="fw-semibold mb-1" id="pwa-banner-title">Add to Home Screen</div>
<div class="text-muted small" id="pwa-banner-msg"></div>
</div>
<button type="button" class="btn-close flex-shrink-0" id="pwa-banner-dismiss" aria-label="Dismiss"></button>
</div>
</div>
</div>
@if (guidedActivationBanner?.Show == true)
{
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm @(guidedActivationBanner.IsDismissed ? "" : "border-start border-4 border-primary")">
<div class="card-body py-3">
<div class="d-flex flex-column flex-lg-row gap-3 align-items-lg-center justify-content-between">
<div>
<div class="fw-semibold mb-1">@guidedActivationBanner.Title</div>
<div class="text-muted">@guidedActivationBanner.Message</div>
</div>
<div>
<a asp-controller="GuidedActivation" asp-action="Start" class="btn @(guidedActivationBanner.IsDismissed ? "btn-outline-primary" : "btn-primary")">
@guidedActivationBanner.ActionText
</a>
</div>
</div>
</div>
</div>
</div>
</div>
}
@if (shopProgressWidget != null)
{
@await Html.PartialAsync("_ShopProgressWidget", shopProgressWidget)
}
@* Config health alert — only shown when there are setup gaps *@
@if (configHealth != null && !configHealth.IsHealthy)
{
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm border-start border-4 @(configHealth.CriticalCount > 0 ? "border-danger" : "border-warning")">
<div class="card-body py-3">
<div class="d-flex align-items-start gap-3">
<div class="rounded-circle @(configHealth.CriticalCount > 0 ? "bg-danger" : "bg-warning") bg-opacity-15 p-2 flex-shrink-0">
<i class="bi bi-tools @(configHealth.CriticalCount > 0 ? "text-danger" : "text-warning") fs-5"></i>
</div>
<div class="flex-grow-1">
<div class="fw-semibold mb-1">
@if (configHealth.CriticalCount > 0)
{
<span class="text-danger">Setup issues need attention</span>
}
else
{
<span class="text-warning">Setup incomplete</span>
}
<span class="text-muted fw-normal small ms-2">
@configHealth.Issues.Count item@(configHealth.Issues.Count == 1 ? "" : "s") found
</span>
</div>
<div class="d-flex flex-wrap gap-2">
@foreach (var issue in configHealth.Issues.OrderByDescending(i => i.Severity))
{
var badgeCss = issue.Severity == ConfigIssueSeverity.Critical ? "bg-danger"
: issue.Severity == ConfigIssueSeverity.Warning ? "bg-warning text-dark"
: "bg-secondary";
if (!string.IsNullOrEmpty(issue.FixPath))
{
<a href="@issue.FixPath" class="badge @badgeCss text-decoration-none" title="@issue.Detail">
@issue.Title <i class="bi bi-arrow-right-circle ms-1"></i>
</a>
}
else
{
<span class="badge @badgeCss" title="@issue.Detail">@issue.Title</span>
}
}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
}
<!-- Needs Attention Strip -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-0">
<div class="row g-0 text-center">
<div class="col-4" style="border-right:1px solid var(--pcl-rule);">
<a asp-controller="Invoices" asp-action="Index" asp-route-outstandingOnly="true" class="text-decoration-none d-block px-3 py-3">
<div class="mono mb-1" style="font-size:1.75rem;font-weight:500;line-height:1;color:@(Model.OverdueInvoicesCount > 0 ? "var(--pcl-bad)" : "var(--pcl-ok)");">@Model.OverdueInvoicesCount</div>
<div style="font-size:0.7rem;letter-spacing:.07em;text-transform:uppercase;color:var(--pcl-steel);">Overdue Invoices</div>
@if (Model.OverdueInvoicesAmount > 0)
{
<div class="mono mt-1" style="font-size:0.78rem;color:var(--pcl-bad);">@Model.OverdueInvoicesAmount.ToString("C0")</div>
}
</a>
</div>
<div class="col-4" style="border-right:1px solid var(--pcl-rule);">
<a asp-controller="Jobs" asp-action="Index" asp-route-statusGroup="overdue" class="text-decoration-none d-block px-3 py-3">
<div class="mono mb-1" style="font-size:1.75rem;font-weight:500;line-height:1;color:@(Model.OverdueJobsCount > 0 ? "var(--pcl-warn)" : "var(--pcl-ok)");">@Model.OverdueJobsCount</div>
<div style="font-size:0.7rem;letter-spacing:.07em;text-transform:uppercase;color:var(--pcl-steel);">Overdue Jobs</div>
</a>
</div>
<div class="col-4">
<a asp-controller="Inventory" asp-action="Index" asp-route-lowStockOnly="true" class="text-decoration-none d-block px-3 py-3">
<div class="mono mb-1" style="font-size:1.75rem;font-weight:500;line-height:1;color:@(Model.LowStockCount > 0 ? "var(--pcl-warn)" : "var(--pcl-ok)");">@Model.LowStockCount</div>
<div style="font-size:0.7rem;letter-spacing:.07em;text-transform:uppercase;color:var(--pcl-steel);">Low Stock Items</div>
</a>
</div>
</div>
</div>
</div>
<div class="row g-4">
<!-- TODAY'S SCHEDULE (Jobs + Appointments Combined) -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100 dashboard-card">
<div class="card-header bg-body border-0 d-flex justify-content-between align-items-center pt-4 pb-3">
<h5 class="mb-0 fw-bold">
<i class="bi bi-calendar-day me-2 text-muted"></i>Today's Schedule
<span class="ms-2 text-muted fw-normal small">@(Model.TodaysJobsCount + Model.TodaysAppointmentsCount) Item@(Model.TodaysJobsCount + Model.TodaysAppointmentsCount == 1 ? "" : "s")</span>
</h5>
</div>
<div class="card-body p-0">
@if (Model.TodaysJobs.Any() || Model.TodaysAppointments.Any())
{
<div class="list-group list-group-flush">
@* Combine jobs and appointments, sorted by time *@
@{
var todaysSchedule = Model.TodaysJobs
.Select(j => new { Type = "Job", Time = j.ScheduledDate ?? j.DueDate ?? DateTime.MinValue, JobData = (PowderCoating.Application.DTOs.Dashboard.DashboardJobDto?)j, ApptData = (PowderCoating.Application.DTOs.Dashboard.DashboardAppointmentDto?)null })
.Concat(Model.TodaysAppointments.Select(a => new { Type = "Appointment", Time = a.ScheduledStartTime, JobData = (PowderCoating.Application.DTOs.Dashboard.DashboardJobDto?)null, ApptData = (PowderCoating.Application.DTOs.Dashboard.DashboardAppointmentDto?)a }))
.OrderBy(x => x.Time)
.ToList();
}
@foreach (var item in todaysSchedule)
{
@if (item.Type == "Job" && item.JobData != null)
{
var job = item.JobData;
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@job.Id"
class="list-group-item list-group-item-action px-4 py-3 border-0 hover-lift">
<div class="d-flex justify-content-between align-items-start">
<div class="me-3 flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-wrap mb-1">
<span class="badge bg-primary rounded-pill">Job</span>
<span class="fw-bold">@job.JobNumber</span>
@PriorityBadge(job.PriorityCode, job.PriorityDisplayName, job.PriorityColorClass)
@StatusBadge(job.StatusCode, job.StatusDisplayName, job.StatusColorClass)
</div>
<div class="text-muted small">
<i class="bi bi-building me-1"></i>@job.CustomerName
</div>
</div>
<div class="text-end text-nowrap">
@if (job.ScheduledDate.HasValue)
{
<div class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">
<i class="bi bi-clock me-1"></i>@job.ScheduledDate.Value.ToString("h:mm tt")
</div>
}
</div>
</div>
</a>
}
else if (item.Type == "Appointment" && item.ApptData != null)
{
var appt = item.ApptData;
<a asp-controller="Appointments" asp-action="Details" asp-route-id="@appt.Id"
class="list-group-item list-group-item-action px-4 py-3 border-0 hover-lift">
<div class="d-flex justify-content-between align-items-start">
<div class="me-3 flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-wrap mb-1">
<span class="pcl-chip pcl-chip-@GetAppointmentTypeChipKind(appt.TypeColorClass)"><span class="pcl-chip-dot"></span>@appt.TypeDisplayName</span>
<span class="fw-bold">@appt.Title</span>
</div>
<div class="text-muted small">
<i class="bi bi-person me-1"></i>@appt.CustomerName
</div>
</div>
<div class="text-end text-nowrap">
@if (!appt.IsAllDay)
{
<div class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">
<i class="bi bi-clock me-1"></i>@appt.ScheduledStartTime.ToString("h:mm tt")
</div>
}
else
{
<div class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">All Day</div>
}
</div>
</div>
</a>
}
}
</div>
}
else
{
<div class="text-center py-5 text-muted">
<div class="mb-3">
<i class="bi bi-calendar-check" style="font-size: 3rem; opacity: 0.2;"></i>
</div>
<p class="mb-0">No jobs or appointments scheduled for today</p>
</div>
}
</div>
</div>
</div>
<!-- BILLS DUE -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100 dashboard-card @(Model.BillsDue.Any(b => b.IsOverdue) ? "border-danger border-3 border-top" : "")">
<div class="card-header bg-body border-0 d-flex justify-content-between align-items-center pt-4 pb-3">
<h5 class="mb-0 fw-bold">
<i class="bi bi-file-earmark-text me-2 text-muted"></i>Bills Due
@if (Model.BillsDueCount > 0)
{
<span class="ms-2 text-muted fw-normal small">@Model.BillsDueAmount.ToString("C0") outstanding</span>
}
</h5>
<a asp-controller="Bills" asp-action="Index" class="btn btn-sm btn-outline-secondary">View All</a>
</div>
<div class="card-body p-0">
@if (Model.BillsDue.Any())
{
<div class="list-group list-group-flush">
@foreach (var bill in Model.BillsDue.Take(8))
{
<a asp-controller="Bills" asp-action="Details" asp-route-id="@bill.Id"
class="list-group-item list-group-item-action px-4 py-3 border-0 hover-lift">
<div class="d-flex justify-content-between align-items-center">
<div class="me-3 flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-wrap mb-1">
@if (bill.IsOverdue)
{
<span class="badge bg-danger rounded-pill">Overdue</span>
}
else
{
<span class="badge bg-warning text-dark rounded-pill">Due</span>
}
<span class="fw-bold">@bill.BillNumber</span>
</div>
<div class="text-muted small">
<i class="bi bi-building me-1"></i>@bill.VendorName
</div>
</div>
<div class="text-end text-nowrap">
<div class="fw-bold @(bill.IsOverdue ? "text-danger" : "")">@bill.BalanceDue.ToString("C")</div>
@if (bill.DueDate.HasValue)
{
@if (bill.IsOverdue)
{
<div class="small text-danger">@bill.DaysOverdue day@(bill.DaysOverdue == 1 ? "" : "s") late</div>
}
else
{
<div class="small text-muted">Due @bill.DueDate.Value.ToString("MMM d")</div>
}
}
</div>
</div>
</a>
}
</div>
}
else
{
<div class="text-center py-5 text-muted">
<div class="mb-3">
<i class="bi bi-check-circle" style="font-size: 3rem; opacity: 0.2; color: #38ef7d;"></i>
</div>
<p class="mb-0 fw-semibold text-success">No open bills!</p>
</div>
}
</div>
</div>
</div>
<!-- FINANCIAL SNAPSHOT (Recent Payments + AR Aging) -->
<div class="col-12">
<div class="card border-0 shadow-sm h-100 dashboard-card">
<div class="card-header bg-body border-0 d-flex justify-content-between align-items-center pt-4 pb-3">
<h5 class="mb-0 fw-bold">
<i class="bi bi-graph-up-arrow me-2 text-muted"></i>Financial Snapshot
<span class="ms-2 text-muted fw-normal small">@currentMonth</span>
</h5>
<a asp-controller="Invoices" asp-action="Index" class="btn btn-sm btn-outline-secondary">View All</a>
</div>
<div class="card-body p-0">
@* AR Aging mini-chart *@
@if (Model.OutstandingAr > 0)
{
<div class="px-4 pt-3 pb-2">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="small fw-semibold text-muted text-uppercase" style="letter-spacing:.05em;">A/R Aging</span>
<span class="small fw-bold">@Model.OutstandingAr.ToString("C0") total</span>
</div>
@{
var agingTotal = Model.OutstandingAr > 0 ? Model.OutstandingAr : 1m;
double PctOf(decimal v) => (double)(v / agingTotal * 100);
}
<div class="d-flex gap-1 mb-2" style="height:4px; border-radius:var(--radius-sm); overflow:hidden;">
@if (Model.AgingCurrent > 0)
{
<div style="width:@PctOf(Model.AgingCurrent).ToString("F1")%; background:var(--pcl-ok);" title="Current: @Model.AgingCurrent.ToString("C0")"></div>
}
@if (Model.AgingDays1To30 > 0)
{
<div style="width:@PctOf(Model.AgingDays1To30).ToString("F1")%; background:var(--pcl-warn);" title="1-30 days: @Model.AgingDays1To30.ToString("C0")"></div>
}
@if (Model.AgingDays31To60 > 0)
{
<div style="width:@PctOf(Model.AgingDays31To60).ToString("F1")%; background:var(--pcl-bad);" title="31-60 days: @Model.AgingDays31To60.ToString("C0")"></div>
}
@if (Model.AgingDays61To90 > 0)
{
<div style="width:@PctOf(Model.AgingDays61To90).ToString("F1")%; background:var(--pcl-bad);" title="61-90 days: @Model.AgingDays61To90.ToString("C0")"></div>
}
@if (Model.AgingDaysOver90 > 0)
{
<div style="width:@PctOf(Model.AgingDaysOver90).ToString("F1")%; background:var(--pcl-bad);opacity:0.7;" title="90+ days: @Model.AgingDaysOver90.ToString("C0")"></div>
}
</div>
<div class="d-flex flex-wrap gap-3 small">
@if (Model.AgingCurrent > 0)
{
<span><span class="me-1" style="display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--pcl-ok);"></span>Current @Model.AgingCurrent.ToString("C0")</span>
}
@if (Model.AgingDays1To30 > 0)
{
<span><span class="me-1" style="display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--pcl-warn);"></span>130d @Model.AgingDays1To30.ToString("C0")</span>
}
@if (Model.AgingDays31To60 > 0)
{
<span><span class="me-1" style="display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--pcl-bad);"></span>3160d @Model.AgingDays31To60.ToString("C0")</span>
}
@if (Model.AgingDays61To90 > 0)
{
<span><span class="me-1" style="display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--pcl-bad);"></span>6190d @Model.AgingDays61To90.ToString("C0")</span>
}
@if (Model.AgingDaysOver90 > 0)
{
<span><span class="me-1" style="display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--pcl-bad);opacity:0.7;"></span>90+d @Model.AgingDaysOver90.ToString("C0")</span>
}
</div>
</div>
<hr class="mx-4 my-0">
}
@* Recent Payments *@
@if (Model.RecentPayments.Any())
{
<div class="px-4 pt-3 pb-1">
<div class="small fw-semibold text-muted text-uppercase mb-2" style="letter-spacing:.05em;">Recent Payments</div>
</div>
<div class="list-group list-group-flush">
@foreach (var pmt in Model.RecentPayments)
{
<a asp-controller="Invoices" asp-action="Details" asp-route-id="@pmt.InvoiceId"
class="list-group-item list-group-item-action px-4 py-2 border-0 hover-lift">
<div class="d-flex justify-content-between align-items-center">
<div class="me-3 flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-1">
<span class="badge bg-success rounded-pill">@pmt.PaymentMethodDisplay</span>
<span class="fw-bold small">@pmt.InvoiceNumber</span>
</div>
<div class="text-muted" style="font-size:.8rem;">
<i class="bi bi-building me-1"></i>@pmt.CustomerName
</div>
</div>
<div class="text-end text-nowrap">
<div class="fw-bold text-success small">+@pmt.Amount.ToString("C0")</div>
<div class="text-muted" style="font-size:.75rem;">@pmt.PaymentDate.ToString("MMM d")</div>
</div>
</div>
</a>
}
</div>
}
else if (Model.OutstandingAr == 0)
{
<div class="text-center py-5 text-muted">
<div class="mb-3">
<i class="bi bi-graph-up" style="font-size: 3rem; opacity: 0.2;"></i>
</div>
<p class="mb-0">No invoices yet this month</p>
</div>
}
</div>
</div>
</div>
<!-- POWDER ORDERS NEEDED - full-width row -->
@if (Model.PowderOrdersNeeded.Any())
{
<div class="row g-4 mt-0">
<div class="col-12">
<div class="card border-0 shadow-sm dashboard-card" style="border-left: 4px solid #6f42c1 !important;">
<div class="card-header bg-body border-0 d-flex justify-content-between align-items-center pt-4 pb-3">
<h5 class="mb-0 fw-bold">
<i class="bi bi-bag-plus-fill me-2 text-muted"></i>Powder in Queue to be Ordered
<span class="ms-2 text-muted fw-normal small">@Model.PowderOrdersNeededCount item@(Model.PowderOrdersNeededCount == 1 ? "" : "s")</span>
</h5>
<small class="text-muted">Grouped by vendor · Mark lines as ordered to remove them</small>
</div>
<div class="card-body pt-0 pb-3">
@foreach (var vendorGroup in Model.PowderOrdersNeeded)
{
<div class="border rounded mb-3">
<div class="d-flex align-items-center gap-3 flex-wrap px-3 py-2 bg-body-tertiary rounded-top border-bottom">
<i class="bi bi-building text-muted"></i>
<strong>@vendorGroup.VendorName</strong>
@if (!string.IsNullOrEmpty(vendorGroup.VendorPhone))
{
<span class="text-muted small"><i class="bi bi-telephone me-1"></i>@vendorGroup.VendorPhone</span>
}
@if (!string.IsNullOrEmpty(vendorGroup.VendorEmail))
{
<span class="text-muted small d-none d-md-inline"><i class="bi bi-envelope me-1"></i>@vendorGroup.VendorEmail</span>
}
<span class="ms-auto badge bg-secondary rounded-pill">@vendorGroup.Lines.Count line@(vendorGroup.Lines.Count == 1 ? "" : "s")</span>
<span class="badge bg-primary rounded-pill">@vendorGroup.TotalLbsNeeded.ToString("N1") lbs</span>
@if (vendorGroup.TotalEstCost > 0)
{
<span class="badge bg-success rounded-pill">~@vendorGroup.TotalEstCost.ToString("C0")</span>
}
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" style="font-size:0.8rem;">
<thead class="table-light">
<tr>
<th>Customer</th>
<th>Color</th>
<th class="text-end">Lbs to Order</th>
<th class="text-end">Est. Cost</th>
<th class="text-center" style="width:140px;"></th>
</tr>
</thead>
<tbody>
@foreach (var line in vendorGroup.Lines)
{
<tr id="powder-line-@line.CoatId">
<td>
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@line.JobId"
class="fw-medium text-decoration-none">@line.CustomerName</a>
<span class="text-muted ms-1">(@line.JobNumber)</span>
</td>
<td>
@if (!string.IsNullOrEmpty(line.ColorName))
{<span>@line.ColorName</span>}
@if (!string.IsNullOrEmpty(line.ColorCode))
{<span class="text-muted ms-1">(@line.ColorCode)</span>}
@if (!string.IsNullOrEmpty(line.Finish))
{<span class="badge bg-light text-dark border ms-1">@line.Finish</span>}
</td>
<td class="text-end fw-medium">@line.LbsToOrder.ToString("N2") lbs</td>
<td class="text-end">
@if (line.EstCost.HasValue)
{<span>@line.EstCost.Value.ToString("C")</span>}
else
{<span class="text-muted">—</span>}
</td>
<td class="text-center">
<button class="btn btn-sm btn-outline-danger mark-ordered-btn text-nowrap"
data-coat-id="@line.CoatId"
title="Mark as ordered">
<i class="bi bi-check2-circle me-1"></i>Mark as Ordered
</button>
</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td colspan="2">Vendor Total</td>
<td class="text-end">@vendorGroup.TotalLbsNeeded.ToString("N2") lbs</td>
<td class="text-end">@(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "—")</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
</div>
</div>
</div>
</div>
}
<div class="row g-4 mt-0" id="powder-placed-section" style="@(Model.PowderOrdersPlacedCount > 0 ? "" : "display:none")">
<div class="col-12">
<div class="card border-0 shadow-sm dashboard-card" style="border-left: 4px solid #0d6efd !important;">
<div class="card-header bg-body border-0 d-flex justify-content-between align-items-center pt-4 pb-3">
<h5 class="mb-0 fw-bold">
<i class="bi bi-box-arrow-in-down me-2 text-muted"></i>Powder Ordered — Awaiting Receipt
<span class="ms-2 text-muted fw-normal small" id="placed-count-label">@Model.PowderOrdersPlacedCount item@(Model.PowderOrdersPlacedCount == 1 ? "" : "s")</span>
</h5>
<small class="text-muted">Grouped by vendor · Enter lbs received to update inventory</small>
</div>
<div class="card-body pt-0 pb-3" id="placed-card-body">
@if (Model.PowderOrdersPlaced.Any())
{
@foreach (var vendorGroup in Model.PowderOrdersPlaced)
{
<div class="border rounded mb-3">
<div class="d-flex align-items-center gap-3 flex-wrap px-3 py-2 bg-body-tertiary rounded-top border-bottom">
<i class="bi bi-building text-muted"></i>
<strong>@vendorGroup.VendorName</strong>
@if (!string.IsNullOrEmpty(vendorGroup.VendorPhone))
{
<span class="text-muted small"><i class="bi bi-telephone me-1"></i>@vendorGroup.VendorPhone</span>
}
<span class="ms-auto badge bg-secondary rounded-pill">@vendorGroup.Lines.Count line@(vendorGroup.Lines.Count == 1 ? "" : "s")</span>
<span class="badge bg-primary rounded-pill">@vendorGroup.TotalLbsNeeded.ToString("N1") lbs</span>
@if (vendorGroup.TotalEstCost > 0)
{
<span class="badge bg-success rounded-pill">~@vendorGroup.TotalEstCost.ToString("C0")</span>
}
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" style="font-size:0.8rem;">
<thead class="table-light">
<tr>
<th>Customer</th>
<th>Color</th>
<th class="text-end">Lbs Ordered</th>
<th class="text-end">Est. Cost</th>
<th>Ordered</th>
<th class="text-center" style="width:180px;">Receive</th>
</tr>
</thead>
<tbody>
@foreach (var line in vendorGroup.Lines)
{
<tr id="placed-line-@line.CoatId">
<td>
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@line.JobId"
class="fw-medium text-decoration-none">@line.CustomerName</a>
<span class="text-muted ms-1">(@line.JobNumber)</span>
</td>
<td>
@if (!string.IsNullOrEmpty(line.ColorName))
{<span>@line.ColorName</span>}
@if (!string.IsNullOrEmpty(line.ColorCode))
{<span class="text-muted ms-1">(@line.ColorCode)</span>}
@if (!string.IsNullOrEmpty(line.Finish))
{<span class="badge bg-light text-dark border ms-1">@line.Finish</span>}
</td>
<td class="text-end fw-medium">@line.LbsToOrder.ToString("N2") lbs</td>
<td class="text-end">
@if (line.EstCost.HasValue)
{<span>@line.EstCost.Value.ToString("C")</span>}
else
{<span class="text-muted">—</span>}
</td>
<td class="text-muted small">
@if (line.OrderedAt.HasValue)
{<span title="@line.OrderedAt.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("g")">@line.OrderedAt.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM d")</span>}
else
{<span>—</span>}
</td>
<td class="text-center">
<div class="d-flex align-items-center gap-1 justify-content-center receive-form-@line.CoatId">
<input type="number" step="0.01" min="0.01"
class="form-control form-control-sm receive-qty"
style="width:75px;"
placeholder="lbs"
value="@line.LbsToOrder.ToString("F2")"
data-coat-id="@line.CoatId" />
<button class="btn btn-sm btn-outline-primary receive-btn"
data-coat-id="@line.CoatId"
data-has-inventory="@line.HasInventoryItem.ToString().ToLower()"
data-color-name="@line.ColorName"
data-color-code="@line.ColorCode"
data-finish="@line.Finish"
data-sku="@line.SKU"
data-vendor-id="@line.VendorId"
data-lbs="@line.LbsToOrder.ToString("F2")"
data-cost-per-lb="@line.CostPerLb?.ToString("F2")"
title="@(line.HasInventoryItem ? "Receive & add to inventory" : "Add to inventory")">
<i class="bi bi-box-arrow-in-down"></i>
@(line.HasInventoryItem ? "Receive" : "Got It")
</button>
</div>
</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td colspan="2">Vendor Total</td>
<td class="text-end">@vendorGroup.TotalLbsNeeded.ToString("N2") lbs</td>
<td class="text-end">@(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "—")</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
}
</div>
</div>
</div>
</div>
<!-- Add Custom Powder to Inventory Modal -->
<div class="modal fade" id="addPowderModal" tabindex="-1" aria-labelledby="addPowderModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title fw-bold" id="addPowderModalLabel">
<i class="bi bi-plus-circle me-2 text-primary"></i>Add Received Powder to Inventory
</h5>
<div class="d-flex align-items-center gap-2 ms-auto me-2">
<button type="button" class="btn btn-sm btn-outline-primary" id="apm-ai-btn">
<i class="bi bi-stars me-1"></i>AI Lookup
</button>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="addPowderForm">
<div class="modal-body">
<input type="hidden" id="apm-coatId" name="coatId" />
<div id="apm-ai-status" class="d-none py-2 small mb-3"></div>
<div class="alert alert-info py-2 small mb-3">
<i class="bi bi-info-circle me-1"></i>
Fields pre-filled from the powder order. Fill in any missing details then click Save.
</div>
<div class="row g-3">
<!-- SKU -->
<div class="col-md-4">
<label class="form-label fw-medium">SKU <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="apm-sku" name="sku" required placeholder="e.g. PWD-BLK-001" />
</div>
<!-- Item Name -->
<div class="col-md-8">
<label class="form-label fw-medium">Item Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="apm-itemName" name="itemName" required placeholder="e.g. Gloss Black Powder Coating" />
</div>
<!-- Color Name -->
<div class="col-md-4">
<label class="form-label fw-medium">Color Name</label>
<input type="text" class="form-control" id="apm-colorName" name="colorName" placeholder="e.g. Gloss Black" />
</div>
<!-- Color Code -->
<div class="col-md-4">
<label class="form-label fw-medium">Color Code</label>
<input type="text" class="form-control" id="apm-colorCode" name="colorCode" placeholder="e.g. RAL 9005" />
</div>
<!-- Finish -->
<div class="col-md-4">
<label class="form-label fw-medium">Finish</label>
<input type="text" class="form-control" id="apm-finish" name="finish" placeholder="e.g. Gloss, Matte, Satin" />
</div>
<!-- Category -->
<div class="col-md-6">
<label class="form-label fw-medium">Category</label>
<select class="form-select" id="apm-categoryId" name="inventoryCategoryId">
<option value="">— Select category —</option>
@if (ViewBag.InventoryCategories != null)
{
foreach (var cat in (IEnumerable<dynamic>)ViewBag.InventoryCategories)
{
<option value="@cat.Id">@cat.DisplayName</option>
}
}
</select>
</div>
<!-- Vendor -->
<div class="col-md-6">
<label class="form-label fw-medium">Primary Vendor</label>
<select class="form-select" id="apm-vendorId" name="primaryVendorId">
<option value="">— Select vendor —</option>
@if (ViewBag.VendorList != null)
{
foreach (var v in (IEnumerable<dynamic>)ViewBag.VendorList)
{
<option value="@v.Id">@v.CompanyName</option>
}
}
</select>
</div>
<!-- Manufacturer -->
<div class="col-md-6">
<label class="form-label fw-medium">Manufacturer</label>
<input type="text" class="form-control" id="apm-manufacturer" name="manufacturer" placeholder="e.g. Tiger Drylac" />
</div>
<!-- Vendor Part # -->
<div class="col-md-6">
<label class="form-label fw-medium">Vendor Part #</label>
<input type="text" class="form-control" id="apm-vendorPartNumber" name="vendorPartNumber" />
</div>
<!-- Description -->
<div class="col-12">
<label class="form-label fw-medium">Description</label>
<textarea class="form-control" id="apm-description" name="description" rows="2" placeholder="Optional notes about this powder"></textarea>
</div>
<div class="col-12"><hr class="my-1" /></div>
<!-- Qty Received -->
<div class="col-md-3">
<label class="form-label fw-medium">Qty Received (lbs) <span class="text-danger">*</span></label>
<input type="number" step="0.01" min="0.01" class="form-control" id="apm-lbsReceived" name="lbsReceived" required />
</div>
<!-- Unit Cost -->
<div class="col-md-3">
<label class="form-label fw-medium">Unit Cost ($/lb)</label>
<input type="number" step="0.01" min="0" class="form-control" id="apm-unitCost" name="unitCost" placeholder="0.00" />
</div>
<!-- Reorder Point -->
<div class="col-md-3">
<label class="form-label fw-medium">Reorder Point</label>
<input type="number" step="0.1" min="0" class="form-control" id="apm-reorderPoint" name="reorderPoint" value="0" />
</div>
<!-- Reorder Qty -->
<div class="col-md-3">
<label class="form-label fw-medium">Reorder Qty</label>
<input type="number" step="0.1" min="0" class="form-control" id="apm-reorderQty" name="reorderQuantity" value="0" />
</div>
<!-- Location -->
<div class="col-md-6">
<label class="form-label fw-medium">Storage Location</label>
<input type="text" class="form-control" id="apm-location" name="location" placeholder="e.g. Shelf A-1" />
</div>
<!-- Notes -->
<div class="col-md-6">
<label class="form-label fw-medium">Notes</label>
<input type="text" class="form-control" id="apm-notes" name="notes" />
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary" id="apm-saveBtn">
<i class="bi bi-plus-circle me-1"></i>Add to Inventory
</button>
</div>
</form>
</div>
</div>
</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 => {
btn.addEventListener('click', async function () {
const coatId = this.dataset.coatId;
const row = document.getElementById('powder-line-' + coatId);
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value
?? document.querySelector('meta[name="__RequestVerificationToken"]')?.content;
this.disabled = true;
this.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const resp = await fetch('@Url.Action("MarkPowderOrdered", "Dashboard")', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': token },
body: 'coatId=' + coatId
});
const data = await resp.json();
if (data.success) {
row.style.transition = 'opacity 0.4s';
row.style.opacity = '0';
setTimeout(() => {
row.remove();
// Update the "queue" badge count
const badge = document.querySelector('.badge.bg-danger.rounded-pill');
if (badge) {
const n = parseInt(badge.textContent) - 1;
if (n <= 0) {
document.querySelector('.card[style*="6f42c1"]')?.closest('.row')?.remove();
} else {
badge.textContent = n;
}
}
// Inject into Awaiting Receipt widget
addToAwaitingReceipt(data.coat);
}, 400);
} else {
alert(data.message || 'Could not update. Please try again.');
this.disabled = false;
this.innerHTML = '<i class="bi bi-check2-circle me-1"></i>Mark as Ordered';
}
} catch {
alert('Network error. Please try again.');
this.disabled = false;
this.innerHTML = '<i class="bi bi-check2-circle me-1"></i>Mark as Ordered';
}
});
});
function addToAwaitingReceipt(c) {
const section = document.getElementById('powder-placed-section');
const body = document.getElementById('placed-card-body');
if (!section || !body) return;
const esc = s => s ? s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : '';
const estCost = (c.lbsToOrder && c.costPerLb) ? (c.lbsToOrder * c.costPerLb) : null;
const orderedDate = c.orderedAt ? new Date(c.orderedAt).toLocaleDateString('en-US',{month:'short',day:'numeric'}) : '—';
const lbsFmt = c.lbsToOrder ? parseFloat(c.lbsToOrder).toFixed(2) : '0.00';
// Find or create vendor group
const vendorId = c.vendorId ?? 'none';
let vendorBlock = body.querySelector(`[data-placed-vendor="${vendorId}"]`);
if (!vendorBlock) {
vendorBlock = document.createElement('div');
vendorBlock.className = 'border rounded mb-3';
vendorBlock.dataset.placedVendor = vendorId;
vendorBlock.innerHTML = `
<div class="d-flex align-items-center gap-3 flex-wrap px-3 py-2 bg-body-tertiary rounded-top border-bottom">
<i class="bi bi-building text-muted"></i>
<strong>${esc(c.vendorName)}</strong>
${c.vendorPhone ? `<span class="text-muted small"><i class="bi bi-telephone me-1"></i>${esc(c.vendorPhone)}</span>` : ''}
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" style="font-size:0.8rem;">
<thead class="table-light">
<tr>
<th>Customer</th><th>Color</th>
<th class="text-end">Lbs Ordered</th><th class="text-end">Est. Cost</th>
<th>Ordered</th><th class="text-center" style="width:180px;">Receive</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>`;
body.appendChild(vendorBlock);
}
const tbody = vendorBlock.querySelector('tbody');
const tr = document.createElement('tr');
tr.id = 'placed-line-' + c.coatId;
tr.innerHTML = `
<td>
<a href="/Jobs/Details/${c.jobId}" class="fw-medium text-decoration-none">${esc(c.customerName)}</a>
<span class="text-muted ms-1">(${esc(c.jobNumber)})</span>
</td>
<td>
${c.colorName ? `<span>${esc(c.colorName)}</span>` : ''}
${c.colorCode ? `<span class="text-muted ms-1">(${esc(c.colorCode)})</span>` : ''}
${c.finish ? `<span class="badge bg-light text-dark border ms-1">${esc(c.finish)}</span>` : ''}
</td>
<td class="text-end fw-medium">${lbsFmt} lbs</td>
<td class="text-end">${estCost ? '$' + estCost.toFixed(2) : '<span class="text-muted">—</span>'}</td>
<td class="text-muted small">${orderedDate}</td>
<td class="text-center">
<div class="d-flex align-items-center gap-1 justify-content-center receive-form-${c.coatId}">
<input type="number" step="0.01" min="0.01"
class="form-control form-control-sm receive-qty"
style="width:75px;" placeholder="lbs"
value="${lbsFmt}" data-coat-id="${c.coatId}" />
<button class="btn btn-sm btn-outline-primary receive-btn"
data-coat-id="${c.coatId}"
data-has-inventory="${c.hasInventory}"
data-color-name="${esc(c.colorName)}"
data-color-code="${esc(c.colorCode)}"
data-finish="${esc(c.finish)}"
data-sku="${esc(c.sku)}"
data-vendor-id="${c.vendorId ?? ''}"
data-lbs="${lbsFmt}"
data-cost-per-lb="${c.costPerLb ?? ''}">
<i class="bi bi-box-arrow-in-down"></i> ${c.hasInventory ? 'Receive' : 'Got It'}
</button>
</div>
</td>`;
tbody.appendChild(tr);
// Wire up the new receive button
tr.querySelector('.receive-btn').addEventListener('click', handleReceiveClick);
// Show the section and update count label
section.style.display = '';
const label = document.getElementById('placed-count-label');
if (label) {
const n = body.querySelectorAll('tbody tr').length;
label.textContent = n + ' item' + (n === 1 ? '' : 's');
}
}
// Powder Receipt - Receive button
async function handleReceiveClick() {
const coatId = this.dataset.coatId;
const hasInv = this.dataset.hasInventory === 'true';
const row = document.getElementById('placed-line-' + coatId);
const qtyInput = row.querySelector('.receive-qty');
const lbs = parseFloat(qtyInput.value);
if (!lbs || lbs <= 0) {
qtyInput.classList.add('is-invalid');
qtyInput.focus();
return;
}
qtyInput.classList.remove('is-invalid');
// Custom powder (no inventory item) → open modal to add to inventory
if (!hasInv) {
const modal = document.getElementById('addPowderModal');
// Pre-fill hidden + text fields
modal.querySelector('#apm-coatId').value = coatId;
modal.querySelector('#apm-lbsReceived').value = lbs;
modal.querySelector('#apm-colorName').value = this.dataset.colorName || '';
modal.querySelector('#apm-colorCode').value = this.dataset.colorCode || '';
modal.querySelector('#apm-finish').value = this.dataset.finish || '';
modal.querySelector('#apm-sku').value = this.dataset.sku || '';
modal.querySelector('#apm-unitCost').value = this.dataset.costPerLb || '';
// Pre-select vendor if we have one
const vendorSel = modal.querySelector('#apm-vendorId');
vendorSel.value = this.dataset.vendorId || '';
// Pre-select "Powder" category
const catSel = modal.querySelector('#apm-categoryId');
const powderOpt = Array.from(catSel.options).find(o => o.text.toLowerCase().includes('powder'));
if (powderOpt) catSel.value = powderOpt.value;
// Pre-fill item name from color name
const colorName = this.dataset.colorName || '';
const finish = this.dataset.finish || '';
const colorCode = this.dataset.colorCode || '';
modal.querySelector('#apm-itemName').value = colorName;
// Auto-generate SKU: PWD-{ABBREV}-{SUFFIX}
// Abbreviation: up to 2 words from finish+color, 3 chars each, uppercased
const skuWords = [finish, colorName].filter(Boolean).join(' ')
.replace(/[^a-zA-Z0-9 ]/g, '')
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map(w => w.substring(0, 3).toUpperCase())
.join('');
// Suffix: color code digits if available, otherwise last 4 of timestamp
const codeDigits = colorCode.replace(/[^0-9]/g, '').substring(0, 4);
const suffix = codeDigits || Date.now().toString().slice(-4);
modal.querySelector('#apm-sku').value = 'PWD-' + (skuWords || 'NEW') + '-' + suffix;
bootstrap.Modal.getOrCreateInstance(modal).show();
return;
}
// Inventory item exists → receive directly
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value
?? document.querySelector('meta[name="__RequestVerificationToken"]')?.content;
this.disabled = true;
qtyInput.disabled = true;
this.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const resp = await fetch('@Url.Action("ReceivePowder", "Dashboard")', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': token },
body: `coatId=${coatId}&lbsReceived=${lbs}`
});
const data = await resp.json();
if (data.success) {
fadePlacedRow(row);
showInventoryToast('Inventory updated with received powder.');
} else {
alert(data.message || 'Could not record receipt. Please try again.');
this.disabled = false;
qtyInput.disabled = false;
this.innerHTML = '<i class="bi bi-box-arrow-in-down"></i> Receive';
}
} catch {
alert('Network error. Please try again.');
this.disabled = false;
qtyInput.disabled = false;
this.innerHTML = '<i class="bi bi-box-arrow-in-down"></i> Receive';
}
}
document.querySelectorAll('.receive-btn').forEach(btn => btn.addEventListener('click', handleReceiveClick));
// Custom powder modal - form submit
document.getElementById('addPowderForm').addEventListener('submit', async function (e) {
e.preventDefault();
const saveBtn = document.getElementById('apm-saveBtn');
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value
?? document.querySelector('meta[name="__RequestVerificationToken"]')?.content;
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…';
try {
const resp = await fetch('@Url.Action("AddCustomPowderToInventory", "Dashboard")', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': token },
body: new URLSearchParams(new FormData(this)).toString()
});
const data = await resp.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('addPowderModal')).hide();
const coatId = document.getElementById('apm-coatId').value;
const row = document.getElementById('placed-line-' + coatId);
if (row) fadePlacedRow(row);
const extra = data.linkedCount > 0
? ` Also linked ${data.linkedCount} other job${data.linkedCount > 1 ? 's' : ''} to this powder.`
: '';
showInventoryToast('Powder added to inventory successfully.' + extra);
} else {
alert(data.message || 'Could not save. Please try again.');
}
} catch {
alert('Network error. Please try again.');
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add to Inventory';
}
});
// ── AI Lookup for Add Powder modal ───────────────────────────────────────
(function () {
const apmBtn = document.getElementById('apm-ai-btn');
const apmStatusEl = document.getElementById('apm-ai-status');
if (!apmBtn) return;
let apmFilledFields = [];
function apmShowStatus(type, msg) {
apmStatusEl.className = `alert alert-${type} py-2 small mb-3`;
apmStatusEl.innerHTML = msg;
apmStatusEl.classList.remove('d-none');
}
function apmShowBadMatchBtn() {
if (document.getElementById('apm-bad-match-btn')) return;
const b = document.createElement('button');
b.type = 'button';
b.id = 'apm-bad-match-btn';
b.className = 'btn btn-sm btn-outline-warning ms-2';
b.innerHTML = '<i class="bi bi-arrow-repeat me-1"></i>Wrong match?';
b.addEventListener('click', () => {
apmFilledFields.forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
apmFilledFields = [];
b.remove();
apmShowStatus('info', '<i class="bi bi-check-circle me-1"></i>Fields cleared. Update details and click <em>AI Lookup</em> again.');
});
apmBtn.insertAdjacentElement('afterend', b);
}
// Reset AI state when modal is opened
document.getElementById('addPowderModal').addEventListener('show.bs.modal', () => {
apmFilledFields = [];
document.getElementById('apm-bad-match-btn')?.remove();
apmStatusEl.className = 'd-none';
apmStatusEl.innerHTML = '';
});
apmBtn.addEventListener('click', async () => {
const manufacturer = document.getElementById('apm-manufacturer')?.value?.trim() || '';
const colorName = document.getElementById('apm-colorName')?.value?.trim() || '';
const colorCode = document.getElementById('apm-colorCode')?.value?.trim() || '';
const partNumber = document.getElementById('apm-vendorPartNumber')?.value?.trim() || '';
const itemName = document.getElementById('apm-itemName')?.value?.trim() || '';
const hasInput = manufacturer || colorName || colorCode || partNumber || itemName;
if (!hasInput) {
apmShowStatus('warning', '<i class="bi bi-exclamation-triangle me-1"></i>Fill in at least one field — Manufacturer, Color Name, Color Code, or Item Name — then try again.');
return;
}
const effectiveColorName = colorName || itemName;
document.getElementById('apm-bad-match-btn')?.remove();
apmBtn.disabled = true;
apmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Looking up...';
apmShowStatus('info', '<i class="bi bi-hourglass-split me-1"></i>Searching for product specifications…');
try {
const formData = new FormData();
formData.append('manufacturer', manufacturer);
formData.append('colorName', effectiveColorName);
formData.append('colorCode', colorCode);
formData.append('partNumber', partNumber);
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
if (token) formData.append('__RequestVerificationToken', token);
const resp = await fetch('/Inventory/AiLookup', { method: 'POST', body: formData });
const data = await resp.json();
if (!data.success) {
apmShowStatus('danger', '<i class="bi bi-exclamation-circle me-1"></i>AI lookup failed: ' + (data.errorMessage || 'Unknown error'));
return;
}
const filled = [];
const fillIf = (id, value, label) => {
const el = document.getElementById(id);
if (!el) return;
const v = value !== null && value !== undefined ? String(value).trim() : '';
if (v && !el.value.trim()) {
el.value = v;
filled.push(label);
if (!apmFilledFields.includes(id)) apmFilledFields.push(id);
}
};
fillIf('apm-manufacturer', data.manufacturer, 'Manufacturer');
fillIf('apm-colorName', data.colorName, 'Color Name');
fillIf('apm-colorCode', data.colorCode, 'Color Code');
fillIf('apm-finish', data.finish, 'Finish');
fillIf('apm-vendorPartNumber', data.manufacturerPartNumber, 'Vendor Part #');
fillIf('apm-description', data.description, 'Description');
fillIf('apm-unitCost', data.unitCostPerLb, 'Unit Cost');
// Fill item name from AI color name if blank
const nameEl = document.getElementById('apm-itemName');
if (nameEl && !nameEl.value.trim() && data.colorName) {
nameEl.value = data.colorName;
filled.push('Item Name');
if (!apmFilledFields.includes('apm-itemName')) apmFilledFields.push('apm-itemName');
}
// Vendor: match by name against dropdown
if (data.vendorName) {
const vendorSel = document.getElementById('apm-vendorId');
if (vendorSel && !vendorSel.value) {
const needle = data.vendorName.toLowerCase();
const match = Array.from(vendorSel.options).find(o =>
o.text.toLowerCase().includes(needle) || needle.includes(o.text.toLowerCase().trim())
);
if (match) { vendorSel.value = match.value; filled.push('Vendor'); apmFilledFields.push('apm-vendorId'); }
}
}
apmShowBadMatchBtn();
if (filled.length > 0) {
const reasoning = data.reasoning
? ` <span class="text-muted fst-italic">${data.reasoning}</span>`
: '';
apmShowStatus('success', `<i class="bi bi-check-circle me-1"></i>Auto-filled: ${filled.join(', ')}.${reasoning}`);
} else {
apmShowStatus('warning', '<i class="bi bi-info-circle me-1"></i>No new fields to fill — they may already be populated, or the product wasn\'t found.');
}
} catch (err) {
apmShowStatus('danger', '<i class="bi bi-exclamation-circle me-1"></i>Request failed: ' + err.message);
} finally {
apmBtn.disabled = false;
apmBtn.innerHTML = '<i class="bi bi-stars me-1"></i>AI Lookup';
}
});
})();
// Helpers
function fadePlacedRow(row) {
row.style.transition = 'opacity 0.4s';
row.style.opacity = '0';
setTimeout(() => {
row.remove();
// Remove empty vendor group if no rows remain
const vendorBlock = document.querySelector(`[data-placed-vendor]`);
const section = document.getElementById('powder-placed-section');
// Check remaining rows across all vendor groups
const remaining = document.querySelectorAll('[id^="placed-line-"]').length;
// Update count label
const countLabel = document.getElementById('placed-count-label');
if (countLabel) countLabel.textContent = remaining + ' item' + (remaining === 1 ? '' : 's');
// Hide section if empty
if (remaining === 0 && section) {
section.style.transition = 'opacity 0.4s';
section.style.opacity = '0';
setTimeout(() => { section.style.display = 'none'; section.style.opacity = ''; }, 400);
}
}, 400);
}
function showInventoryToast(msg) {
const toast = document.createElement('div');
toast.className = 'alert alert-success alert-dismissible position-fixed bottom-0 end-0 m-3';
toast.style.zIndex = '9999';
toast.innerHTML = `<i class="bi bi-check-circle me-2"></i>${msg} <button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
</script>
}
<script>
(function () {
var DISMISSED_KEY = 'pcl_pwa_banner_dismissed';
// Already installed as standalone — never show
var isStandalone = window.navigator.standalone === true ||
window.matchMedia('(display-mode: standalone)').matches;
if (isStandalone) return;
// Already dismissed
if (localStorage.getItem(DISMISSED_KEY)) return;
var ua = navigator.userAgent || '';
var isIOS = /iphone|ipad|ipod/i.test(ua);
var isAndroid = /android/i.test(ua);
// Only show on mobile
if (!isIOS && !isAndroid) return;
var banner = document.getElementById('pwa-install-banner');
var msgEl = document.getElementById('pwa-banner-msg');
var titleEl = document.getElementById('pwa-banner-title');
if (isIOS) {
// Detect Safari: has WebKit in UA but NOT Chrome/CriOS/FxiOS
var isSafari = /webkit/i.test(ua) && !/crios|chrome|fxios|opios/i.test(ua);
if (isSafari) {
titleEl.textContent = 'Add to Home Screen';
msgEl.innerHTML = 'For the best experience — and so the camera only asks once — open the ' +
'<strong>Share menu</strong> <span style="font-size:1.1em">&#9650;</span> at the bottom of Safari ' +
'and tap <strong>Add to Home Screen</strong>.';
} else {
titleEl.textContent = 'Open in Safari to Install';
msgEl.innerHTML = 'To add Powder Coating Logix to your home screen, <strong>open this page in Safari</strong> ' +
'(not Chrome or another browser), then tap the <strong>Share menu</strong> ' +
'<span style="font-size:1.1em">&#9650;</span> and choose <strong>Add to Home Screen</strong>. ' +
'This also means the camera only asks for permission once.';
}
} else {
// Android
titleEl.textContent = 'Add to Home Screen';
msgEl.innerHTML = 'Tap the browser <strong>menu&nbsp;(&#8942;)</strong> and choose ' +
'<strong>Add to Home Screen</strong> or <strong>Install App</strong> for the best experience ' +
'and persistent camera access.';
}
banner.style.removeProperty('display');
document.getElementById('pwa-banner-dismiss').addEventListener('click', function () {
localStorage.setItem(DISMISSED_KEY, '1');
banner.style.display = 'none';
});
}());
</script>
@functions {
IHtmlContent PriorityBadge(string priorityCode, string displayName, string colorClass)
{
if (string.IsNullOrEmpty(priorityCode)) return HtmlString.Empty;
var kind = colorClass switch {
"danger" => "bad", "warning" => "warn",
"info" => "cool", "secondary" => "neutral", _ => "ember"
};
return new HtmlString($"<span class=\"pcl-chip pcl-chip-{kind}\"><span class=\"pcl-chip-dot\"></span>{displayName}</span>");
}
IHtmlContent StatusBadge(string statusCode, string displayName, string colorClass)
{
if (string.IsNullOrEmpty(statusCode)) return HtmlString.Empty;
var kind = colorClass switch {
"success" => "ok", "danger" => "bad", "warning" => "warn",
"info" => "cool", "primary" => "cool", _ => "neutral"
};
return new HtmlString($"<span class=\"pcl-chip pcl-chip-{kind}\"><span class=\"pcl-chip-dot\"></span>{displayName}</span>");
}
string GetAppointmentTypeChipKind(string colorClass)
{
return colorClass switch {
"primary" => "cool", "success" => "ok", "info" => "cool",
"warning" => "warn", "danger" => "bad", _ => "neutral"
};
}
}