Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Dashboard/Index.cshtml
T
spouliot 64a9c1531b Fix — HTML entity rendering across 60 views
Razor's @() expression auto-encodes &, turning — into — which
rendered as literal text in the browser. Wrapped all such expressions in
@Html.Raw() so the em-dash entity is passed through unescaped.

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

1402 lines
80 KiB
Plaintext

@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> &mdash; @_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 (ViewBag.KioskActivated == true)
{
<button type="button" class="btn btn-sm btn-outline-info" id="btnStartIntake"
title="Push the intake form to the front-desk tablet">
<i class="bi bi-tablet me-1"></i>Start Intake
</button>
}
<a href="/Kiosk/SendRemoteLink" class="btn btn-sm btn-outline-secondary"
title="Email a customer a link to fill out the intake form remotely">
<i class="bi bi-envelope-at me-1"></i>Send Intake Link
</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 &mdash; 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 &mdash; 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>1&ndash;30d @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>31&ndash;60d @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>61&ndash;90d @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 &middot; 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">&mdash;</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">@Html.Raw(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "&mdash;")</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 &mdash; 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 &middot; 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">&mdash;</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>&mdash;</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">@Html.Raw(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "&mdash;")</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 alert-permanent 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="">&mdash; Select category &mdash;</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="">&mdash; Select vendor &mdash;</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-outline-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>
// Start Intake &mdash; pushes SignalR event to front-desk tablet
document.getElementById('btnStartIntake')?.addEventListener('click', async function () {
const btn = this;
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sending&hellip;';
try {
const res = await fetch('/Kiosk/StartSession', {
method: 'POST',
headers: { 'RequestVerificationToken': token, 'Content-Type': 'application/json' }
});
const data = await res.json();
if (data.success) {
btn.innerHTML = '<i class="bi bi-check-circle me-1"></i>Sent!';
btn.classList.replace('btn-outline-info', 'btn-success');
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-tablet me-1"></i>Start Intake';
btn.classList.replace('btn-success', 'btn-outline-info');
}, 3000);
} else {
throw new Error('Server returned failure');
}
} catch (err) {
btn.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>Failed';
btn.classList.replace('btn-outline-info', 'btn-outline-danger');
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-tablet me-1"></i>Start Intake';
btn.classList.replace('btn-outline-danger', 'btn-outline-info');
}, 3000);
}
});
// 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'}) : '&mdash;';
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">&mdash;</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&hellip;';
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 &mdash; Manufacturer, Color Name, Color Code, or Item Name &mdash; 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&hellip;');
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 &mdash; 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 &mdash; 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 &mdash; and so the camera only asks once &mdash; 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"
};
}
}