Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Jobs/Details.cshtml
T
spouliot 0d980e651a Add pricing breakdown and powder pre-fill to Job Details; surface voided invoice history
- Job Details: collapsible internal pricing breakdown card mirrors quote details breakdown
  (items subtotal, shop supplies, discount, rush fee, tax, total)
- Job Details: voided invoice history section shows previous invoices instead of hiding them
- Complete Job modal: pre-fills powder usage from QR-scanned / manually logged entries so
  staff don't double-log; consumes pre-logged credit per InventoryItemId before deducting net delta
- JobProfile: map ShopSuppliesAmount, ShopSuppliesPercent, IsRushJob, DiscountType/Value/Reason
  so the pricing breakdown has the data it needs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:47:44 -04:00

3245 lines
200 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.Job.JobDto
@{
ViewData["Title"] = $"Job {Model.JobNumber}";
ViewData["PageIcon"] = "bi-briefcase";
var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel;
bool quoteUpdated = ViewBag.QuoteUpdatedAfterConversion == true;
}
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-end gap-2 mb-4">
<div class="d-flex gap-2">
<a asp-controller="NotificationLogs" asp-action="Index" asp-route-jobId="@Model.Id"
class="btn btn-outline-secondary" title="View notifications sent for this job">
<i class="bi bi-bell me-2"></i>Notifications
</a>
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#saveTemplateModal"
title="Save this job as a reusable template">
<i class="bi bi-layout-text-window-reverse me-2"></i>Save as Template
</button>
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
<i class="bi bi-pencil me-2"></i>Edit
</a>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to List
</a>
</div>
</div>
<!-- Status Banner -->
<div class="alert alert-@Model.StatusColorClass alert-permanent d-flex align-items-center mb-4">
<i class="bi bi-info-circle me-2" style="font-size: 1.5rem;"></i>
<div>
<strong>Status:</strong> @Model.StatusDisplayName
@if (Model.RequiresCustomerApproval && !Model.IsCustomerApproved)
{
<span class="ms-3"><i class="bi bi-exclamation-circle"></i> Awaiting Customer Approval</span>
}
</div>
</div>
@if (quoteUpdated)
{
bool canResync = ViewBag.CanResyncFromQuote == true;
<div class="alert alert-warning alert-permanent mb-4">
<div class="d-flex align-items-start gap-3">
<i class="bi bi-exclamation-triangle-fill fs-5 mt-1 flex-shrink-0"></i>
<div class="flex-grow-1">
<div class="fw-semibold mb-1">Source quote was updated on @(((DateTime)ViewBag.QuoteUpdatedAt).ToString("MMM d, yyyy"))</div>
<div class="mb-3">
<a asp-controller="Quotes" asp-action="Details" asp-route-id="@ViewBag.SourceQuoteId" class="alert-link">@ViewBag.SourceQuoteNumber</a>
was edited after this job was created.
@if (canResync)
{
<span>Re-sync to replace job items and pricing with the latest quote, or dismiss if you've already handled it manually.</span>
}
else
{
<span>Shop work has started — review the quote and apply any changes manually.</span>
}
</div>
<div class="d-flex gap-2 flex-wrap">
@if (canResync)
{
<form asp-action="ResyncFromQuote" asp-route-id="@Model.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-warning fw-semibold"
onclick="return confirm('This will replace all job items and pricing with the current quote. Continue?')">
<i class="bi bi-arrow-repeat me-1"></i>Re-sync from Quote
</button>
</form>
}
<form asp-action="DismissQuoteChangedBanner" asp-route-id="@Model.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-check me-1"></i>Dismiss
</button>
</form>
</div>
</div>
</div>
</div>
}
@if (guidedActivationCallout?.Show == true)
{
<div class="alert alert-primary alert-permanent border-0 shadow-sm mb-4">
<div class="d-flex flex-column flex-lg-row gap-3 align-items-lg-center justify-content-between">
<div>
<div class="fw-semibold mb-1">@guidedActivationCallout.Title</div>
<div>@guidedActivationCallout.Message</div>
</div>
<div class="d-flex gap-2 flex-wrap">
@if (!string.IsNullOrWhiteSpace(guidedActivationCallout.ActionText))
{
<a href="@Url.Action(guidedActivationCallout.ActionName, guidedActivationCallout.ActionController, guidedActivationCallout.ActionRouteValues)"
class="btn btn-primary">
@guidedActivationCallout.ActionText
</a>
}
@if (!string.IsNullOrWhiteSpace(guidedActivationCallout.SecondaryActionText))
{
<a href="@Url.Action(guidedActivationCallout.SecondaryActionName, guidedActivationCallout.SecondaryActionController, guidedActivationCallout.SecondaryActionRouteValues)"
class="btn btn-outline-primary">
@guidedActivationCallout.SecondaryActionText
</a>
}
</div>
</div>
</div>
}
<div class="row g-4">
<!-- Left Column -->
<div class="col-lg-8">
<!-- Job Information -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-briefcase me-2 text-primary"></i>Job Information
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="text-muted small mb-1">Job Number</label>
<p class="fw-semibold mb-0">@Model.JobNumber</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Customer</label>
@Html.AntiForgeryToken()
<div data-cc-wrap data-cc-id="@Model.Id"
data-cc-url="@Url.Action("ChangeCustomer", "Jobs")">
<select class="form-select form-select-sm cc-select" style="max-width:300px;">
@foreach (var c in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.CustomerSelectList)
{
<option value="@c.Value" selected="@(c.Value == Model.CustomerId.ToString() ? "selected" : null)">@c.Text</option>
}
</select>
<div class="cc-confirm-banner d-none mt-2 p-2 bg-light border rounded d-flex align-items-center gap-2 flex-wrap">
<span class="cc-confirm-text small fw-semibold"></span>
<button type="button" class="btn btn-success btn-sm" data-cc-save>Save</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-cc-cancel>Cancel</button>
</div>
<div class="cc-error text-danger small mt-1 d-none"></div>
</div>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Priority</label>
<p class="mb-0">
<span class="badge bg-@Model.PriorityColorClass">
@Model.PriorityDisplayName
</span>
</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Customer PO</label>
<p class="mb-0">@(Model.CustomerPO ?? "Not provided")</p>
</div>
<div class="col-12">
<label class="text-muted small mb-1">Description</label>
<p class="mb-0">@Model.Description</p>
</div>
@* Preparation Services *@
@if (Model.PrepServices != null && Model.PrepServices.Any())
{
<div class="col-12">
<hr class="my-3" />
<label class="text-muted small mb-2">
<i class="bi bi-tools me-1"></i>Preparation Services
</label>
<div class="d-flex flex-wrap gap-2">
@foreach (var service in Model.PrepServices)
{
<span class="badge bg-success bg-opacity-10 text-success border border-success px-3 py-2">
<i class="bi bi-check-circle-fill me-1"></i>@service.ServiceName
</span>
}
</div>
</div>
}
</div>
</div>
</div>
<!-- Schedule Information -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-calendar me-2 text-primary"></i>Schedule
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<!-- Scheduled Date -->
<div class="col-md-6">
<label class="text-muted small mb-1">Scheduled Date</label>
<div id="scheduledDate-display" class="d-flex align-items-center gap-2">
<span id="scheduledDate-text" class="@(!Model.ScheduledDate.HasValue ? "text-muted" : "")">
@(Model.ScheduledDate.HasValue ? Model.ScheduledDate.Value.ToString("MMMM dd, yyyy") : "Not scheduled")
</span>
<button type="button" class="btn btn-link btn-sm p-0 text-muted" onclick="startDateEdit('scheduledDate')" title="Edit scheduled date">
<i class="bi bi-pencil-fill" style="font-size:0.75rem"></i>
</button>
</div>
<div id="scheduledDate-editor" class="d-none mt-1">
<div class="input-group input-group-sm" style="max-width:220px">
<input type="date" id="scheduledDate-input" class="form-control"
value="@(Model.ScheduledDate.HasValue ? Model.ScheduledDate.Value.ToString("yyyy-MM-dd") : "")">
<button type="button" class="btn btn-primary" onclick="saveDateField('scheduledDate')" title="Save">
<i class="bi bi-check-lg"></i>
</button>
<button type="button" class="btn btn-outline-secondary" onclick="cancelDateEdit('scheduledDate')" title="Cancel">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div id="scheduledDate-saving" class="d-none mt-1 small text-muted">
<span class="spinner-border spinner-border-sm me-1"></span>Saving…
</div>
</div>
</div>
<!-- Due Date -->
<div class="col-md-6">
<label class="text-muted small mb-1">Due Date</label>
@{
var isOverdue = Model.DueDate.HasValue && Model.DueDate.Value < DateTime.Now
&& Model.StatusCode != "COMPLETED" && Model.StatusCode != "READYFORPICKUP" && Model.StatusCode != "DELIVERED";
}
<div id="dueDate-display" class="d-flex align-items-center gap-2">
<span id="dueDate-text" class="@(!Model.DueDate.HasValue ? "text-muted" : isOverdue ? "text-danger fw-semibold" : "")">
@if (Model.DueDate.HasValue)
{
@Model.DueDate.Value.ToString("MMMM dd, yyyy")
if (isOverdue)
{
<i class="bi bi-exclamation-triangle ms-1"></i>
<small class="d-block">Overdue</small>
}
}
else
{
<text>Not set</text>
}
</span>
<button type="button" class="btn btn-link btn-sm p-0 text-muted" onclick="startDateEdit('dueDate')" title="Edit due date">
<i class="bi bi-pencil-fill" style="font-size:0.75rem"></i>
</button>
</div>
<div id="dueDate-editor" class="d-none mt-1">
<div class="input-group input-group-sm" style="max-width:220px">
<input type="date" id="dueDate-input" class="form-control"
value="@(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("yyyy-MM-dd") : "")">
<button type="button" class="btn btn-primary" onclick="saveDateField('dueDate')" title="Save">
<i class="bi bi-check-lg"></i>
</button>
<button type="button" class="btn btn-outline-secondary" onclick="cancelDateEdit('dueDate')" title="Cancel">
<i class="bi bi-x-lg"></i>
</button>
</div>
<button type="button" class="btn btn-link btn-sm p-0 text-danger mt-1" onclick="clearDateField('dueDate')" title="Clear due date">
<i class="bi bi-x-circle me-1"></i><small>Clear date</small>
</button>
<div id="dueDate-saving" class="d-none mt-1 small text-muted">
<span class="spinner-border spinner-border-sm me-1"></span>Saving…
</div>
</div>
</div>
<!-- Assigned Worker -->
<div class="col-md-6">
<label class="text-muted small mb-1">Assigned Worker</label>
<div class="d-flex align-items-center gap-2">
<select id="workerAssignmentSelect" class="form-select form-select-sm"
onchange="updateWorkerAssignment(this)">
<option value="">— Unassigned —</option>
@foreach (var w in (IEnumerable<SelectListItem>)ViewBag.Workers)
{
if (w.Value == Model.AssignedUserId)
{
<option value="@w.Value" selected>@w.Text</option>
}
else
{
<option value="@w.Value">@w.Text</option>
}
}
</select>
<span id="workerSaveIndicator" class="text-muted small d-none">
<span class="spinner-border spinner-border-sm me-1"></span>Saving…
</span>
<span id="workerSavedTick" class="text-success small d-none">
<i class="bi bi-check-circle-fill"></i>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Job Items -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3 d-flex align-items-center justify-content-between">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-list-check me-2 text-primary"></i>Job Items
@if (Model.Items.Any())
{
<span class="badge bg-secondary rounded-pill ms-1">@Model.Items.Count</span>
}
</h5>
<button type="button" class="btn btn-outline-primary btn-sm" onclick="openWizard()">
<i class="bi bi-plus-circle me-1"></i>Add Item
</button>
</div>
@if (Model.Items.Any())
{
var allItems = Model.Items.ToList();
var catalogItems = Model.Items.Where(i => i.CatalogItemId.HasValue).ToList();
var laborItems = Model.Items.Where(i => !i.CatalogItemId.HasValue && i.IsLaborItem).ToList();
var customItems = Model.Items.Where(i => !i.CatalogItemId.HasValue && !i.IsLaborItem).ToList();
<div class="card-body">
@* ── Catalog Products ── *@
@if (catalogItems.Any())
{
<h6 class="text-primary mb-3"><i class="bi bi-bag-check me-2"></i>Catalog Products</h6>
<div class="table-responsive mb-4">
<table class="table table-hover table-sm">
<thead class="table-primary">
<tr>
<th>Product</th>
<th class="text-center">Qty</th>
<th class="text-end">Unit Price</th>
<th class="text-end">Total</th>
<th class="text-center" style="width:90px;"></th>
</tr>
</thead>
<tbody>
@foreach (var item in catalogItems)
{
var catIdx = allItems.IndexOf(item);
<tr>
<td>
<strong>@item.Description</strong>
@if (item.Coats != null && item.Coats.Any())
{
<br />
<small class="text-muted"><i class="bi bi-paint-bucket me-1"></i><strong>Coating Layers:</strong></small>
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
{
<br />
<small class="ms-3">
• <strong>@coat.CoatName</strong>
@if (!string.IsNullOrEmpty(coat.ColorName))
{
<text> — @coat.ColorName</text>
@if (!string.IsNullOrEmpty(coat.VendorName))
{
<text> (@coat.VendorName)</text>
}
}
@if (!string.IsNullOrEmpty(coat.ColorCode))
{
<text> (@coat.ColorCode)</text>
}
@if (!string.IsNullOrEmpty(coat.Finish))
{
<text> &middot; @coat.Finish</text>
}
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
{
<span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span>
@if (!coat.InventoryItemId.HasValue)
{
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder — must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
}
}
@if (!string.IsNullOrEmpty(coat.Notes))
{
<br /><span class="ms-4 text-muted fst-italic">@coat.Notes</span>
}
</small>
}
}
@if (item.PrepServices != null && item.PrepServices.Any())
{
<br />
<small class="text-muted"><i class="bi bi-tools me-1"></i><strong>Prep Services:</strong></small>
@foreach (var ps in item.PrepServices)
{
<br />
<small class="ms-3">• <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">— @ps.EstimatedMinutes min</span></small>
}
}
@if (!string.IsNullOrEmpty(item.Notes))
{
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
}
</td>
<td class="text-center">@item.Quantity</td>
<td class="text-end">@item.UnitPrice.ToString("C")</td>
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
<td class="text-center">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@catIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
<button type="button" class="btn btn-outline-danger" data-delete-id="@item.Id" data-delete-name="@item.Description" title="Delete"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@* ── Custom Work ── *@
@if (customItems.Any())
{
<h6 class="text-success mb-3"><i class="bi bi-calculator me-2"></i>Custom Work</h6>
<div class="table-responsive mb-4">
<table class="table table-hover table-sm">
<thead class="table-success">
<tr>
<th>Description</th>
<th class="text-center">Qty</th>
<th class="text-center">Surface Area</th>
<th class="text-center">Est. Minutes</th>
<th class="text-center">Coating Needed</th>
<th class="text-end">Unit Price</th>
<th class="text-end">Total</th>
<th class="text-center" style="width:90px;"></th>
</tr>
</thead>
<tbody>
@foreach (var item in customItems)
{
var custIdx = allItems.IndexOf(item);
// Use stored PowderToOrder per coat; fall back to calculating from efficiency data
decimal totalPowderNeeded = 0;
if (item.Coats != null && item.Coats.Any(c => c.PowderToOrder > 0))
{
totalPowderNeeded = item.Coats.Sum(c => c.PowderToOrder ?? 0);
}
else if (item.Coats != null && item.Coats.Any() && item.SurfaceAreaSqFt > 0)
{
foreach (var c in item.Coats)
{
var cov = c.CoverageSqFtPerLb > 0 ? c.CoverageSqFtPerLb : 30m;
var eff = c.TransferEfficiency > 0 ? c.TransferEfficiency / 100m : 0.65m;
totalPowderNeeded += (item.SurfaceAreaSqFt * item.Quantity) / (cov * eff);
}
}
else if (item.SurfaceAreaSqFt > 0)
{
totalPowderNeeded = (item.SurfaceAreaSqFt * item.Quantity) / (30m * 0.65m);
}
<tr>
<td>
<strong>@item.Description</strong>
@if (item.RequiresSandblasting || item.RequiresMasking)
{
<br />
@if (item.RequiresSandblasting)
{
<span class="badge bg-warning text-dark me-1"><i class="bi bi-wind me-1"></i>Sandblast</span>
}
@if (item.RequiresMasking)
{
<span class="badge bg-info text-dark"><i class="bi bi-paint-bucket me-1"></i>Mask</span>
}
}
@if (item.Coats != null && item.Coats.Any())
{
<br />
<small class="text-muted"><i class="bi bi-paint-bucket me-1"></i><strong>Coating Layers:</strong></small>
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
{
<br />
<small class="ms-3">
• <strong>@coat.CoatName</strong>
@if (!string.IsNullOrEmpty(coat.ColorName))
{
<text> — @coat.ColorName</text>
@if (!string.IsNullOrEmpty(coat.VendorName))
{
<text> (@coat.VendorName)</text>
}
}
@if (!string.IsNullOrEmpty(coat.ColorCode))
{
<text> (@coat.ColorCode)</text>
}
@if (!string.IsNullOrEmpty(coat.Finish))
{
<text> &middot; @coat.Finish</text>
}
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
{
<span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span>
@if (!coat.InventoryItemId.HasValue)
{
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder — must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
}
}
@if (!string.IsNullOrEmpty(coat.Notes))
{
<br /><span class="ms-4 text-muted fst-italic">@coat.Notes</span>
}
</small>
}
}
@if (item.PrepServices != null && item.PrepServices.Any())
{
<br />
<small class="text-muted"><i class="bi bi-tools me-1"></i><strong>Prep Services:</strong></small>
@foreach (var ps in item.PrepServices)
{
<br />
<small class="ms-3">• <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">— @ps.EstimatedMinutes min</span></small>
}
}
@if (!string.IsNullOrEmpty(item.Notes))
{
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
}
</td>
<td class="text-center">@item.Quantity</td>
<td class="text-center">
@if (item.SurfaceAreaSqFt > 0)
{
<text>@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit</text>
<br /><small class="text-muted">per item</small>
}
else { <span class="text-muted">—</span> }
</td>
<td class="text-center">
@if (item.EstimatedMinutes > 0)
{
<text>@item.EstimatedMinutes min</text>
<br /><small class="text-muted">per item</small>
}
else { <span class="text-muted">—</span> }
</td>
<td class="text-center">
@if (totalPowderNeeded > 0)
{
<strong class="text-success">@totalPowderNeeded.ToString("F2") lbs</strong>
<br /><small class="text-muted">total batch</small>
}
else { <span class="text-muted">—</span> }
</td>
<td class="text-end">@item.UnitPrice.ToString("C")</td>
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
<td class="text-center">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@custIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
<button type="button" class="btn btn-outline-danger" data-delete-id="@item.Id" data-delete-name="@item.Description" title="Delete"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@* ── Labor ── *@
@if (laborItems.Any())
{
<h6 class="text-warning mb-3"><i class="bi bi-person-gear me-2"></i>Labor</h6>
<div class="table-responsive mb-4">
<table class="table table-hover table-sm">
<thead class="table-warning">
<tr>
<th>Description</th>
<th class="text-center">Qty</th>
<th class="text-center">Est. Time</th>
<th class="text-end">Unit Price</th>
<th class="text-end">Total</th>
<th class="text-center" style="width:90px;"></th>
</tr>
</thead>
<tbody>
@foreach (var item in laborItems)
{
var labIdx = allItems.IndexOf(item);
<tr>
<td>
<strong>@item.Description</strong>
@if (!string.IsNullOrEmpty(item.Notes))
{
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
}
</td>
<td class="text-center">@item.Quantity</td>
<td class="text-center">
@if (item.EstimatedMinutes > 0)
{
<text>@item.EstimatedMinutes min</text>
}
else { <span class="text-muted">—</span> }
</td>
<td class="text-end">@item.UnitPrice.ToString("C")</td>
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
<td class="text-center">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@labIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
<button type="button" class="btn btn-outline-danger" data-delete-id="@item.Id" data-delete-name="@item.Description" title="Delete"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@* ── Mobile cards ── *@
<div class="d-lg-none mt-2">
@foreach (var item in Model.Items)
{
var mobileIdx = allItems.IndexOf(item);
<div class="mobile-data-card mb-2">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<i class="bi bi-@(item.IsLaborItem ? "person-gear" : item.CatalogItemId.HasValue ? "bag-check" : "calculator")"></i>
</div>
<div class="mobile-card-title">
<h6>@item.Description</h6>
<small>Qty: @item.Quantity</small>
</div>
</div>
<div class="mobile-card-body">
@if (item.SurfaceAreaSqFt > 0)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Surface Area</span>
<span class="mobile-card-value">@item.SurfaceAreaSqFt.ToString("F2") per item</span>
</div>
}
@if (item.EstimatedMinutes > 0)
{
<div class="mobile-card-row">
<span class="mobile-card-label"><i class="bi bi-clock"></i> Est. Time</span>
<span class="mobile-card-value">@item.EstimatedMinutes min</span>
</div>
}
@if (item.Coats != null && item.Coats.Any())
{
<div class="mobile-card-row">
<span class="mobile-card-label">Coats</span>
<span class="mobile-card-value">
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
{
<small class="d-block">@coat.CoatName@(!string.IsNullOrEmpty(coat.ColorName) ? $" — {coat.ColorName}" : "")</small>
}
</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Unit Price</span>
<span class="mobile-card-value">@item.UnitPrice.ToString("C")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Total</span>
<span class="mobile-card-value fw-semibold text-primary">@item.TotalPrice.ToString("C")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label"></span>
<span class="mobile-card-value">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@mobileIdx)" title="Edit"><i class="bi bi-pencil me-1"></i>Edit</button>
<button type="button" class="btn btn-outline-danger" data-delete-id="@item.Id" data-delete-name="@item.Description" title="Delete"><i class="bi bi-trash me-1"></i>Delete</button>
</div>
</span>
</div>
</div>
</div>
}
</div>
</div>
}
else
{
<div class="card-body text-center py-4 text-muted">
<i class="bi bi-box-seam display-6 d-block mb-2 opacity-25"></i>
<p class="mb-2">No items on this job yet.</p>
<button type="button" class="btn btn-outline-primary btn-sm" onclick="openWizard()">
<i class="bi bi-plus-circle me-1"></i>Add Item
</button>
</div>
}
</div>
<!-- Time Tracking -->
<div class="card border-0 shadow-sm mb-4" id="timeTrackingCard">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2" style="cursor:pointer;" data-bs-toggle="collapse" data-bs-target="#collapseTimeTracking" aria-expanded="false" aria-controls="collapseTimeTracking">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-clock me-2 text-primary"></i>Time Tracking
</h5>
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
</div>
<div class="d-flex align-items-center gap-3">
<span class="text-muted small">Total: <strong id="totalHoursDisplay">—</strong></span>
@{
var estimatedMins = Model.Items?.Sum(i => i.EstimatedMinutes * i.Quantity) ?? 0;
var estimatedHrs = estimatedMins / 60m;
}
@if (estimatedHrs > 0)
{
<span class="text-muted small">Est: <strong>@estimatedHrs.ToString("0.##") hrs</strong></span>
}
<button class="btn btn-sm btn-outline-primary" onclick="timeTracking.openAdd()">
<i class="bi bi-plus me-1"></i>Log Time
</button>
</div>
</div>
<div class="collapse" id="collapseTimeTracking">
<div class="card-body p-0">
<div id="timeEntriesContainer">
<div class="text-center text-muted py-4 small" id="timeEntriesEmpty">
<i class="bi bi-clock fs-4 d-block mb-2 opacity-25"></i>
No time logged yet.
</div>
<div class="table-responsive d-none" id="timeEntriesTable">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Worker</th>
<th>Date</th>
<th class="text-end">Hours</th>
<th>Stage</th>
<th>Notes</th>
<th class="text-end" style="width:80px"></th>
</tr>
</thead>
<tbody id="timeEntriesTbody"></tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td colspan="3">Total</td>
<td class="text-end" id="timeEntriesTotalHours">—</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div><!-- /collapseTimeTracking -->
</div>
<!-- Part Intake -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2" style="cursor:pointer;" data-bs-toggle="collapse" data-bs-target="#collapsePartIntake" aria-expanded="false" aria-controls="collapsePartIntake">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-box-seam me-2 text-info"></i>Part Intake
</h5>
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
</div>
@if (!Model.StatusIsTerminal)
{
<button type="button" class="btn btn-sm btn-outline-info" data-bs-toggle="modal" data-bs-target="#intakeModal">
<i class="bi bi-box-seam me-1"></i>@(Model.IntakeDate.HasValue ? "Edit Intake" : "Check In")
</button>
}
</div>
<div class="collapse" id="collapsePartIntake">
<div class="card-body">
@if (Model.IntakeDate.HasValue)
{
<div class="row g-3">
<div class="col-sm-4">
<small class="text-muted d-block">Checked In</small>
<strong>@Model.IntakeDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM d, yyyy h:mm tt")</strong>
</div>
@if (!string.IsNullOrEmpty(Model.IntakeCheckedByName))
{
<div class="col-sm-4">
<small class="text-muted d-block">By</small>
<strong>@Model.IntakeCheckedByName</strong>
</div>
}
@if (Model.IntakePartCount.HasValue)
{
<div class="col-sm-4">
<small class="text-muted d-block">Part Count</small>
<strong>@Model.IntakePartCount.Value</strong>
</div>
}
@if (!string.IsNullOrEmpty(Model.IntakeConditionNotes))
{
<div class="col-12">
<small class="text-muted d-block">Condition Notes</small>
<p class="mb-0" style="white-space: pre-wrap;">@Model.IntakeConditionNotes</p>
</div>
}
</div>
}
else
{
<p class="text-muted mb-0">
<i class="bi bi-hourglass me-1"></i>Parts not yet checked in.
@if (!Model.StatusIsTerminal)
{
<a asp-action="Intake" asp-route-id="@Model.Id" class="ms-1">Record intake</a>
}
</p>
}
</div>
</div><!-- /collapsePartIntake -->
</div>
<!-- Special Instructions -->
@if (!string.IsNullOrEmpty(Model.SpecialInstructions))
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-journal-text me-2 text-primary"></i>Special Instructions
</h5>
</div>
<div class="card-body">
<p class="mb-0" style="white-space: pre-wrap;">@Model.SpecialInstructions</p>
</div>
</div>
}
<!-- Internal Notes -->
@if (!string.IsNullOrEmpty(Model.InternalNotes))
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-warning bg-opacity-10 border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-sticky me-2 text-warning"></i>Internal Notes
<span class="badge bg-warning text-dark ms-2">Staff Only</span>
</h5>
</div>
<div class="card-body">
<p class="mb-0" style="white-space: pre-wrap;">@Model.InternalNotes</p>
</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(Model.Tags))
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-tags me-2 text-primary"></i>Tags
</h5>
</div>
<div class="card-body">
@foreach (var tag in Model.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(t => t.Trim()).Where(t => !string.IsNullOrWhiteSpace(t)))
{
<span class="badge rounded-pill bg-info text-dark me-1 mb-1">@tag</span>
}
</div>
</div>
}
<!-- Job Photos -->
@{
bool canUploadJobPhoto = ViewBag.CanUploadJobPhoto is bool jobPhotoOk && jobPhotoOk;
int jobPhotoUsed = ViewBag.JobPhotoUsed is int jpu ? jpu : 0;
int jobPhotoMax = ViewBag.JobPhotoMax is int jpm ? jpm : -1;
}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-body border-0 py-3 d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2" style="cursor:pointer;" data-bs-toggle="collapse" data-bs-target="#collapsePhotos" aria-expanded="false" aria-controls="collapsePhotos">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-camera me-2 text-primary"></i>Job Photos
<span class="badge bg-primary rounded-pill ms-2" id="photoCount">0</span>
@if (jobPhotoMax > 0)
{
<small class="text-secondary fw-normal ms-2" style="font-size:0.75rem;">@jobPhotoUsed / @jobPhotoMax</small>
}
</h5>
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
</div>
@if (canUploadJobPhoto)
{
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#uploadPhotoModal">
<i class="bi bi-cloud-upload me-1"></i>Upload Photo
</button>
}
else
{
<button type="button" class="btn btn-outline-secondary btn-sm" disabled
title="Photo limit reached (@jobPhotoUsed/@jobPhotoMax). Upgrade your plan to add more.">
<i class="bi bi-cloud-upload me-1"></i>Upload Photo
</button>
}
</div>
<div class="collapse" id="collapsePhotos">
<div class="card-body">
<div id="photoGallery" class="row g-3">
<!-- Photos will be loaded here via JavaScript -->
<div class="col-12 text-center py-5" id="noPhotosMessage">
<i class="bi bi-camera" style="font-size: 3rem; opacity: 0.2;"></i>
<p class="text-muted mt-2 mb-0">No photos uploaded yet</p>
<small class="text-muted">Click "Upload Photo" to add photos</small>
</div>
</div>
</div>
</div><!-- /collapsePhotos -->
</div>
<!-- Deposits -->
@{
var jobDeposits = ViewBag.Deposits as List<PowderCoating.Core.Entities.Deposit> ?? new List<PowderCoating.Core.Entities.Deposit>();
var totalDeposited = jobDeposits.Sum(d => d.Amount);
}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-body border-0 py-3 d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2" style="cursor:pointer;" data-bs-toggle="collapse" data-bs-target="#collapseDeposits" aria-expanded="false" aria-controls="collapseDeposits">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-cash-coin me-2 text-success"></i>Deposits
@if (jobDeposits.Any())
{
<span class="badge bg-success rounded-pill ms-2">@jobDeposits.Count</span>
}
</h5>
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
</div>
<div class="d-flex align-items-center gap-2">
@if (totalDeposited > 0)
{
<span class="fw-semibold text-success">Total: @totalDeposited.ToString("C")</span>
}
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#addDepositModal">
<i class="bi bi-plus-circle me-1"></i>Record Deposit
</button>
</div>
</div>
<div class="collapse" id="collapseDeposits">
<div class="card-body p-0">
@if (!jobDeposits.Any())
{
<div class="text-center py-4">
<i class="bi bi-cash-coin" style="font-size: 2.5rem; opacity: 0.2;"></i>
<p class="text-muted mt-2 mb-0">No deposits recorded</p>
<small class="text-muted">Click "Record Deposit" to add a customer deposit</small>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Receipt #</th>
<th>Date</th>
<th>Method</th>
<th>Reference</th>
<th class="text-end">Amount</th>
<th>Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var dep in jobDeposits)
{
<tr>
<td class="fw-semibold">@dep.ReceiptNumber</td>
<td>@dep.ReceivedDate.ToString("MM/dd/yyyy")</td>
<td>@dep.PaymentMethod.ToString().Replace("CreditDebitCard","Credit/Debit Card").Replace("BankTransferACH","Bank Transfer/ACH").Replace("DigitalPayment","Digital")</td>
<td class="text-muted small">@dep.Reference</td>
<td class="text-end fw-semibold text-success">@dep.Amount.ToString("C")</td>
<td>
@if (dep.AppliedToInvoiceId.HasValue)
{
<span class="badge bg-success">Applied</span>
}
else
{
<span class="badge bg-warning text-dark">Pending</span>
}
</td>
<td class="text-end">
<a href="@Url.Action("Receipt", "Deposits", new { id = dep.Id })" class="btn btn-sm btn-outline-secondary" target="_blank" title="Download Receipt">
<i class="bi bi-file-pdf"></i>
</a>
@if (dep.AppliedToInvoiceId == null)
{
<button type="button" class="btn btn-sm btn-outline-danger ms-1"
onclick="deleteDeposit(@dep.Id, '@dep.ReceiptNumber')" title="Delete">
<i class="bi bi-trash"></i>
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div><!-- /collapseDeposits -->
</div>
@{
var materialsUsed = ViewBag.MaterialsUsed as List<PowderCoating.Core.Entities.InventoryTransaction>
?? new List<PowderCoating.Core.Entities.InventoryTransaction>();
}
<!-- Materials Used Card -->
<div class="card mb-3">
<div class="card-header py-2">
<div class="d-flex align-items-center gap-2" style="cursor:pointer;" data-bs-toggle="collapse" data-bs-target="#collapseMaterials" aria-expanded="false" aria-controls="collapseMaterials">
<i class="bi bi-droplet-half text-primary"></i>
<span class="fw-semibold">Materials Used</span>
@if (materialsUsed.Any())
{
<span class="badge bg-primary rounded-pill ms-1">@materialsUsed.Count</span>
}
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
<span class="ms-auto">
<a asp-controller="Inventory" asp-action="Scan" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation();">
<i class="bi bi-qr-code-scan me-1"></i>Log Material
</a>
</span>
</div>
</div>
<div class="collapse" id="collapseMaterials">
@if (!materialsUsed.Any())
{
<div class="card-body text-muted text-center py-3 small">
<i class="bi bi-droplet me-1"></i>No materials have been logged for this job yet.
Use the QR label on an inventory item to log usage.
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Item</th>
<th class="text-end">Qty Used</th>
<th>Reason</th>
<th>Date</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
@foreach (var mat in materialsUsed)
{
<tr>
<td>
<a asp-controller="Inventory" asp-action="Details" asp-route-id="@mat.InventoryItemId" class="text-decoration-none fw-semibold">
@(mat.InventoryItem?.Name ?? "Unknown Item")
</a>
</td>
<td class="text-end fw-semibold text-danger">
@(Math.Abs(mat.Quantity).ToString("N2"))
<small class="text-muted fw-normal">@mat.InventoryItem?.UnitOfMeasure</small>
</td>
<td>
@{
var reasonBadge = mat.TransactionType switch {
PowderCoating.Core.Enums.InventoryTransactionType.JobUsage => ("bg-primary", "Job Usage"),
PowderCoating.Core.Enums.InventoryTransactionType.Waste => ("bg-warning text-dark", "Waste / Spillage"),
PowderCoating.Core.Enums.InventoryTransactionType.Adjustment => ("bg-secondary", "Correction"),
PowderCoating.Core.Enums.InventoryTransactionType.Transfer => ("bg-info text-dark", "Transfer Out"),
_ => ("bg-secondary", mat.TransactionType.ToString())
};
}
<span class="badge @reasonBadge.Item1">@reasonBadge.Item2</span>
</td>
<td class="text-muted small">@mat.TransactionDate.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM d, yyyy h:mm tt")</td>
<td class="text-muted small">@mat.Notes</td>
</tr>
}
</tbody>
<tfoot class="table-light">
<tr>
<td class="fw-semibold">Total</td>
<td class="text-end fw-semibold text-danger" colspan="4">
@materialsUsed.Sum(m => Math.Abs(m.Quantity)).ToString("N2")
<small class="text-muted fw-normal">units</small>
</td>
</tr>
</tfoot>
</table>
</div>
}
</div><!-- /collapseMaterials -->
</div>
<!-- Part Intake Modal -->
@{
var intakeExpectedCount = Model.Items?.Sum(i => (int)i.Quantity) ?? 0;
var showAdvanceToggle = !Model.StatusIsTerminal && Model.StatusCode != "IN_PREPARATION";
}
<div class="modal fade" id="intakeModal" tabindex="-1" aria-labelledby="intakeModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="intakeModalLabel">
<i class="bi bi-box-seam me-2 text-info"></i>Part Intake — Check In
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="intakeModalError" class="alert alert-danger alert-permanent d-none py-2"></div>
<div class="mb-3">
<label class="form-label fw-semibold">
Actual Part Count
@if (intakeExpectedCount > 0)
{
<span class="text-muted fw-normal ms-1">(expected: @intakeExpectedCount)</span>
}
</label>
<input type="number" id="intakePartCount" class="form-control" min="0" max="10000"
value="@(Model.IntakePartCount.HasValue ? Model.IntakePartCount.Value.ToString() : "")"
placeholder="@intakeExpectedCount" />
<div id="intakeMismatchAlert" class="alert alert-warning alert-permanent mt-2 py-2 d-none">
<i class="bi bi-exclamation-triangle me-1"></i>Count doesn't match expected — note the discrepancy below.
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Condition Notes</label>
<textarea id="intakeConditionNotes" class="form-control" rows="4"
placeholder="Describe the condition of parts at drop-off: scratches, rust, pre-existing damage, missing pieces, special handling...">@Model.IntakeConditionNotes</textarea>
</div>
@if (showAdvanceToggle)
{
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" role="switch" id="intakeAdvanceSwitch"
style="width:3em; height:1.5em;" checked />
<label class="form-check-label ms-2" for="intakeAdvanceSwitch">
Advance job to <strong>In Preparation</strong>
</label>
</div>
<small class="text-muted d-block mt-1 ms-5">Uncheck if parts were dropped off but work hasn't started yet.</small>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-info text-white" id="intakeSaveBtn">
<i class="bi bi-check-circle me-1"></i>@(Model.IntakeDate.HasValue ? "Update Intake" : "Complete Check-In")
</button>
</div>
</div>
</div>
</div>
<!-- Add Deposit Modal -->
<div class="modal fade" id="addDepositModal" tabindex="-1" aria-labelledby="addDepositModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addDepositModalLabel">
<i class="bi bi-cash-coin me-2 text-success"></i>Record Customer Deposit
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="addDepositForm">
@Html.AntiForgeryToken()
<input type="hidden" name="jobId" value="@Model.Id" />
<input type="hidden" name="customerId" value="@Model.CustomerId" />
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-semibold">Amount <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="depositAmount" name="amount" min="0.01" step="0.01" required placeholder="0.00" />
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Payment Method <span class="text-danger">*</span></label>
<select class="form-select" id="depositPaymentMethod" name="paymentMethod" required>
<option value="">-- Select --</option>
<option value="Cash">Cash</option>
<option value="Check">Check</option>
<option value="CreditDebitCard">Credit / Debit Card</option>
<option value="BankTransferACH">Bank Transfer / ACH</option>
<option value="DigitalPayment">Digital Payment</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Date Received <span class="text-danger">*</span></label>
<input type="date" class="form-control" id="depositDate" name="receivedDate" required value="@(DateTime.Today.ToString("yyyy-MM-dd"))" />
</div>
<div class="mb-3">
<label class="form-label">Reference (check #, card last 4, etc.)</label>
<input type="text" class="form-control" id="depositReference" name="reference" maxlength="200" />
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea class="form-control" id="depositNotes" name="notes" rows="2" maxlength="500"></textarea>
</div>
<div id="depositFormError" class="alert alert-danger d-none"></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-success" id="saveDepositBtn">
<i class="bi bi-check-circle me-1"></i>Save & Generate Receipt
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Job Completion Details -->
@if (Model.CompletedDate.HasValue)
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-success bg-opacity-10 border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-check-circle me-2 text-success"></i>Job Completion Details
<span class="badge bg-success ms-2">Completed</span>
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="text-muted small mb-1">Completed Date</label>
<p class="mb-0 fw-semibold">@Model.CompletedDate.Value.ToString("MMM dd, yyyy h:mm tt")</p>
</div>
@if (Model.ActualTimeSpentHours.HasValue)
{
<div class="col-md-6">
<label class="text-muted small mb-1">Actual Time Spent</label>
<p class="mb-0 fw-semibold">
<i class="bi bi-clock me-1 text-primary"></i>@Model.ActualTimeSpentHours.Value.ToString("0.##") hours
</p>
</div>
}
</div>
@if (Model.Items != null && Model.Items.Any(i => i.Coats != null && i.Coats.Any(c => c.ActualPowderUsedLbs.HasValue)))
{
<hr class="my-3" />
<h6 class="mb-3 fw-semibold">Actual Powder Usage</h6>
<div class="table-responsive">
<table class="table table-sm table-borderless mb-0">
<thead class="text-muted small">
<tr>
<th>Item</th>
<th>Coat</th>
<th>Color</th>
<th class="text-end">Estimated (lbs)</th>
<th class="text-end">Actual (lbs)</th>
<th class="text-end">Variance</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
@if (item.Coats != null && item.Coats.Any())
{
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
{
@if (coat.ActualPowderUsedLbs.HasValue)
{
var variance = coat.ActualPowderUsedLbs.Value - (coat.PowderToOrder ?? 0);
var varianceClass = variance > 0 ? "text-danger" : variance < 0 ? "text-success" : "text-muted";
<tr>
<td>@item.Description</td>
<td><span class="badge bg-secondary">@coat.CoatName</span></td>
<td>
@if (!string.IsNullOrEmpty(coat.ColorName))
{
<span>@coat.ColorName</span>
@if (!string.IsNullOrEmpty(coat.ColorCode))
{
<small class="text-muted">(@coat.ColorCode)</small>
}
}
</td>
<td class="text-end">@((coat.PowderToOrder ?? 0).ToString("0.##"))</td>
<td class="text-end fw-semibold">@coat.ActualPowderUsedLbs.Value.ToString("0.##")</td>
<td class="text-end @varianceClass">
@(variance > 0 ? "+" : "")@variance.ToString("0.##")
</td>
</tr>
}
}
}
}
</tbody>
</table>
</div>
}
</div>
</div>
}
</div>
<!-- Right Column -->
<div class="col-lg-4">
<!-- Quick Actions -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-lightning me-2 text-primary"></i>Actions
</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-primary">
<i class="bi bi-pencil me-2"></i>Edit Job
</a>
@if (!Model.StatusIsTerminal)
{
<a asp-action="Intake" asp-route-id="@Model.Id"
class="btn @(Model.IntakeDate.HasValue ? "btn-outline-secondary" : "btn-outline-info")"
title="@(Model.IntakeDate.HasValue ? "Update part intake record" : "Check in parts for this job")">
<i class=”bi bi-box-seam me-2”></i>@(Model.IntakeDate.HasValue ? "Intake ✓" : "Intake")
</a>
}
@{
var panelInvoiceId = ViewBag.JobInvoiceId as int?;
var voidedInvoices = ViewBag.JobVoidedInvoices as IEnumerable<dynamic> ?? [];
}
@if (panelInvoiceId.HasValue)
{
<a asp-controller="Invoices" asp-action="Details" asp-route-id="@panelInvoiceId.Value"
class="btn btn-outline-success">
<i class="bi bi-receipt me-2"></i>View Invoice
</a>
}
else
{
<a asp-controller="Invoices" asp-action="Create" asp-route-jobId="@Model.Id"
class="btn btn-outline-success">
<i class="bi bi-receipt me-2"></i>Create Invoice
</a>
}
@foreach (var vi in voidedInvoices)
{
<a asp-controller="Invoices" asp-action="Details" asp-route-id="@vi.Id"
class="btn btn-outline-secondary btn-sm" title="Voided invoice">
<i class="bi bi-x-circle me-1 text-danger"></i>@vi.InvoiceNumber <span class="text-muted">(Voided)</span>
</a>
}
<a asp-action="WorkOrder" asp-route-id="@Model.Id" class="btn btn-outline-secondary" target="_blank">
<i class="bi bi-printer me-2"></i>Print Work Order
</a>
@if (Model.QuoteId.HasValue)
{
<a asp-controller="Quotes" asp-action="Details" asp-route-id="@Model.QuoteId" class="btn btn-outline-info">
<i class="bi bi-file-earmark-text me-2"></i>View Original Quote
</a>
}
@if (Model.StatusCode != "COMPLETED" && Model.StatusCode != "CANCELLED")
{
<button type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#completeJobModal">
<i class="bi bi-check-circle me-2"></i>Complete Job
</button>
}
@if ((bool)(ViewBag.IsAdminOrManager ?? false) && (bool)(ViewBag.SmsEnabled ?? false) && Model.CustomerNotifyBySms && !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone))
{
<button type="button" class="btn btn-outline-info" id="btnSendSms"
data-job-id="@Model.Id"
title="Send a custom SMS to @Model.CustomerName">
<i class="bi bi-chat-dots me-2"></i>Send SMS
</button>
}
<a asp-action="Delete" asp-route-id="@Model.Id" class="btn btn-outline-danger">
<i class="bi bi-trash me-2"></i>Delete Job
</a>
</div>
</div>
</div>
<!-- Pricing Summary (internal — d-print-none) -->
@{
var jobPb = ViewBag.JobPricingBreakdown as PowderCoating.Application.DTOs.Quote.QuotePricingBreakdownDto;
}
<div class="card border-0 shadow-sm mb-4 d-print-none">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-cash-stack me-2 text-primary"></i>Pricing Summary
</h5>
</div>
<div class="card-body">
@if (!string.IsNullOrWhiteSpace(Model.OvenLabel))
{
<div class="d-flex align-items-center mb-3 p-2 bg-body-secondary rounded">
<i class="bi bi-thermometer-half text-warning me-2"></i>
<div>
<small class="text-muted d-block">Oven</small>
<span class="fw-semibold">@Model.OvenLabel</span>
</div>
</div>
}
@if (jobPb != null)
{
<div class="d-flex justify-content-between mb-2">
<span>Items Subtotal:</span>
<strong>@jobPb.ItemsSubtotal.ToString("C")</strong>
</div>
@if (jobPb.OvenBatchCost > 0)
{
<div class="d-flex justify-content-between mb-2">
<span><i class="bi bi-fire me-1"></i>Oven (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $" × {jobPb.OvenCycleMinutes} min" : "")):</span>
<strong>@jobPb.OvenBatchCost.ToString("C")</strong>
</div>
}
@if (jobPb.FacilityOverheadCost > 0)
{
<div class="d-flex justify-content-between mb-2">
<span><i class="bi bi-building me-1"></i>Facility Overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr):</span>
<strong>@jobPb.FacilityOverheadCost.ToString("C")</strong>
</div>
}
@if (jobPb.ShopSuppliesAmount > 0)
{
<div class="d-flex justify-content-between mb-2">
<span>Shop Supplies (@jobPb.ShopSuppliesPercent%):</span>
<strong>@jobPb.ShopSuppliesAmount.ToString("C")</strong>
</div>
}
<div class="d-flex justify-content-between mb-2">
<span>Subtotal:</span>
<strong>@jobPb.SubtotalBeforeDiscount.ToString("C")</strong>
</div>
@if (jobPb.DiscountAmount > 0)
{
<div class="d-flex justify-content-between mb-2 text-success">
<span>
@if (Model.DiscountType == "Percentage")
{
<text>Discount (@Model.DiscountValue% Off):</text>
}
else if (Model.DiscountType == "FixedAmount")
{
<text>Discount (@Model.DiscountValue.ToString("C") Off):</text>
}
else
{
<text>Discount (@jobPb.DiscountPercent.ToString("F1")%):</text>
}
</span>
<strong>-@jobPb.DiscountAmount.ToString("C")</strong>
</div>
@if (!string.IsNullOrWhiteSpace(Model.DiscountReason))
{
<div class="mb-2">
<small class="text-muted fst-italic">
<i class="bi bi-info-circle me-1"></i>Reason: @Model.DiscountReason
</small>
</div>
}
}
@if (Model.IsRushJob && jobPb.RushFee > 0)
{
<div class="d-flex justify-content-between mb-2 text-warning">
<span><i class="bi bi-lightning-fill me-1"></i>Rush Job Fee:</span>
<strong>@jobPb.RushFee.ToString("C")</strong>
</div>
}
@if (jobPb.TaxAmount > 0)
{
<div class="d-flex justify-content-between mb-2">
<span>Tax (@jobPb.TaxPercent.ToString("G29")%):</span>
<strong>@jobPb.TaxAmount.ToString("C")</strong>
</div>
}
<hr />
<div class="d-flex justify-content-between mb-3">
<h5>Total:</h5>
<h5 class="text-primary"><strong>@jobPb.Total.ToString("C")</strong></h5>
</div>
@* Collapsible detail breakdown *@
<button class="btn btn-sm btn-outline-secondary w-100" type="button" data-bs-toggle="collapse" data-bs-target="#jobPricingBreakdown">
<i class="bi bi-calculator me-1"></i>Cost Breakdown
</button>
<div class="collapse mt-3" id="jobPricingBreakdown">
@{
var directCosts = jobPb.MaterialCosts + jobPb.LaborCosts + jobPb.EquipmentCosts;
var hasCostBreakdown = directCosts > 0;
var allCatalog = Model.Items != null && Model.Items.All(i => i.CatalogItemId.HasValue);
}
@* Section 1: Item Costs *@
<div class="mb-3">
<div class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.05em;">
<i class="bi bi-boxes me-1"></i>Item Costs
</div>
@if (hasCostBreakdown)
{
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Material (powder + consumables)</span>
<span>@jobPb.MaterialCosts.ToString("C")</span>
</div>
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Labor</span>
<span>@jobPb.LaborCosts.ToString("C")</span>
</div>
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Equipment (oven + booth)</span>
<span>@jobPb.EquipmentCosts.ToString("C")</span>
</div>
<div class="d-flex justify-content-between small border-top pt-1 mt-1">
<span class="text-muted">Direct costs</span>
<span>@directCosts.ToString("C")</span>
</div>
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Markup (@jobPb.ProfitPercent.ToString("F0")% baked into item prices)</span>
<span>@((jobPb.ItemsSubtotal - directCosts).ToString("C"))</span>
</div>
}
else if (allCatalog)
{
<div class="text-muted small fst-italic">All items use fixed catalog pricing — no per-category cost split available.</div>
}
else
{
<div class="text-muted small fst-italic">Cost breakdown not available.</div>
}
<div class="d-flex justify-content-between small fw-semibold border-top pt-1 mt-1">
<span>Items subtotal</span>
<span>@jobPb.ItemsSubtotal.ToString("C")</span>
</div>
</div>
@* Section 2: Job-Level Additions *@
@if (jobPb.OvenBatchCost > 0 || jobPb.FacilityOverheadCost > 0 || jobPb.ShopSuppliesAmount > 0 || jobPb.OverheadCosts > 0)
{
<div class="mb-3">
<div class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.05em;">
<i class="bi bi-plus-circle me-1"></i>Job-Level Additions
</div>
@if (jobPb.OvenBatchCost > 0)
{
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Oven batch (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $", {jobPb.OvenCycleMinutes} min/cycle" : ""))</span>
<span>@jobPb.OvenBatchCost.ToString("C")</span>
</div>
}
@if (jobPb.FacilityOverheadCost > 0)
{
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Facility overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr × estimated hours)</span>
<span>@jobPb.FacilityOverheadCost.ToString("C")</span>
</div>
}
@if (jobPb.ShopSuppliesAmount > 0)
{
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Shop supplies (@jobPb.ShopSuppliesPercent.ToString("F1")%)</span>
<span>@jobPb.ShopSuppliesAmount.ToString("C")</span>
</div>
}
@if (jobPb.OverheadCosts > 0)
{
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Overhead (@jobPb.OverheadPercent.ToString("F1")%)</span>
<span>@jobPb.OverheadCosts.ToString("C")</span>
</div>
}
</div>
}
@* Section 3: Final Calculation *@
<div class="mb-2">
<div class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.05em;">
<i class="bi bi-receipt me-1"></i>Final Calculation
</div>
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Subtotal</span>
<span>@jobPb.SubtotalBeforeDiscount.ToString("C")</span>
</div>
@if (jobPb.DiscountAmount > 0)
{
<div class="d-flex justify-content-between small mb-1 text-success">
<span>Discount (@jobPb.DiscountPercent.ToString("F1")%)</span>
<span>-@jobPb.DiscountAmount.ToString("C")</span>
</div>
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">After discount</span>
<span>@jobPb.SubtotalAfterDiscount.ToString("C")</span>
</div>
}
@if (jobPb.RushFee > 0)
{
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Rush fee</span>
<span>@jobPb.RushFee.ToString("C")</span>
</div>
}
@if (jobPb.TaxAmount > 0)
{
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Tax (@jobPb.TaxPercent.ToString("G29")%)</span>
<span>@jobPb.TaxAmount.ToString("C")</span>
</div>
}
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1">
<span>Total</span>
<span>@jobPb.Total.ToString("C")</span>
</div>
@{
var jobTotalDirectCost = jobPb.MaterialCosts + jobPb.LaborCosts + jobPb.EquipmentCosts + jobPb.OvenBatchCost + jobPb.FacilityOverheadCost + jobPb.ShopSuppliesAmount;
var jobGrossProfit = jobPb.Total - jobTotalDirectCost;
var jobEffectiveMargin = jobPb.Total > 0 ? (jobGrossProfit / jobPb.Total * 100m) : 0m;
}
@if (jobTotalDirectCost > 0)
{
<div class="d-flex justify-content-between small mt-2 pt-1 border-top @(jobEffectiveMargin < 10 ? "text-danger" : jobEffectiveMargin < 20 ? "text-warning" : "text-success")">
<span>Effective gross margin</span>
<span class="fw-semibold">@jobEffectiveMargin.ToString("F1")%</span>
</div>
}
</div>
</div>
}
else
{
@* Fallback: no items yet *@
@if (Model.QuoteId.HasValue)
{
<div class="mb-3">
<label class="text-muted small mb-1">Quoted Price</label>
<p class="mb-0 fw-semibold">@Model.QuotedPrice.ToString("C")</p>
</div>
}
<div>
<label class="text-muted small mb-1">Final Price</label>
<h3 class="mb-0 text-primary">@Model.FinalPrice.ToString("C")</h3>
</div>
}
</div>
</div>
<!-- Activity -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-clock-history me-2 text-primary"></i>Timeline
</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="text-muted small mb-1">Created</label>
<p class="mb-0">@Model.CreatedAt.ToString("MMMM dd, yyyy")</p>
</div>
@if (Model.RequiresCustomerApproval)
{
<div>
<label class="text-muted small mb-1">Approval Status</label>
<p class="mb-0">
@if (Model.IsCustomerApproved)
{
<span class="badge bg-success">
<i class="bi bi-check-circle me-1"></i>Approved
</span>
}
else
{
<span class="badge bg-warning">
<i class="bi bi-clock me-1"></i>Pending
</span>
}
</p>
</div>
}
</div>
</div>
<!-- Rework / Warranty -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-arrow-repeat me-2 text-warning"></i>Rework / Warranty
<span class="badge bg-danger ms-2 d-none" id="reworkBadge">0</span>
</h5>
<button class="btn btn-sm btn-outline-warning" onclick="rework.openAdd()">
<i class="bi bi-plus-circle me-1"></i>Log Rework
</button>
</div>
<div class="card-body p-0">
<div id="reworkEmpty" class="text-center text-muted py-3 small" style="display:none;">
No rework or warranty claims recorded.
</div>
<div id="reworkList"></div>
</div>
</div>
<!-- Job Costing -->
<div class="card border-0 shadow-sm mb-3" id="costingCard">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-calculator me-2 text-primary"></i>Job Costing
</h5>
<button class="btn btn-sm btn-outline-secondary" onclick="costing.load()" title="Refresh">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div class="card-body p-0">
<div id="costingLoading" class="text-center text-muted py-3">
<div class="spinner-border spinner-border-sm me-2"></div>Loading...
</div>
<div id="costingContent" style="display:none;">
<!-- Summary row -->
<div class="px-3 pt-3 pb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="text-muted small">Revenue <span id="costingRevenueSource" class="badge bg-light text-secondary ms-1"></span></span>
<span class="fw-semibold" id="costingRevenue">—</span>
</div>
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
<span>Powder / Materials <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('powder');return false;"><i class="bi bi-chevron-down" id="powderChevron"></i></a></span>
<span id="costingPowder">—</span>
</div>
<div id="powderDetail" style="display:none;" class="ps-3 pb-1">
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
<tbody id="powderLines"></tbody>
</table>
</div>
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
<span>Labor (<span id="costingLaborHours">0</span> hrs) <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('labor');return false;"><i class="bi bi-chevron-down" id="laborChevron"></i></a></span>
<span id="costingLabor">—</span>
</div>
<div id="laborDetail" style="display:none;" class="ps-3 pb-1">
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
<tbody id="laborLines"></tbody>
</table>
</div>
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
<span>Oven / Equipment <span id="costingOvenLabel" class="text-muted"></span></span>
<span id="costingOven">—</span>
</div>
<div id="costingReworkSection" style="display:none;">
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
<span>Rework Costs <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('rework');return false;"><i class="bi bi-chevron-down" id="reworkChevron"></i></a></span>
<span id="costingRework">—</span>
</div>
<div id="reworkDetail" style="display:none;" class="ps-3 pb-1">
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
<tbody id="reworkCostLines"></tbody>
</table>
</div>
<div class="d-flex justify-content-between small text-success mb-1 ps-2">
<span>Billed to Customer</span>
<span id="costingReworkBilled">—</span>
</div>
</div>
<hr class="my-2" />
<div class="d-flex justify-content-between small mb-1 ps-2">
<span class="text-muted">Total Costs</span>
<span id="costingTotal" class="text-danger">—</span>
</div>
<div class="d-flex justify-content-between fw-bold mb-1">
<span>Gross Profit</span>
<span id="costingProfit">—</span>
</div>
<div class="d-flex justify-content-between small text-muted mb-1">
<span>Gross Margin</span>
<span id="costingMargin">—</span>
</div>
<div class="d-flex justify-content-between small text-muted">
<span>Margin vs Quote</span>
<span id="costingQuotedMargin">—</span>
</div>
</div>
<div id="costingNotes" class="px-3 pb-3" style="font-size:0.75rem;"></div>
</div>
<div id="costingError" style="display:none;" class="text-center text-danger py-3 small"></div>
</div>
</div>
</div>
</div>
<!-- Change History Section -->
@if (ViewBag.ChangeHistory != null && ((List<PowderCoating.Application.DTOs.Job.JobChangeHistoryDto>)ViewBag.ChangeHistory).Any())
{
<div class="card border-0 shadow-sm mt-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0">
<i class="bi bi-clock-history me-2"></i>Change History
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 15%">Date & Time</th>
<th style="width: 15%">Changed By</th>
<th style="width: 15%">Field</th>
<th style="width: 25%">Old Value</th>
<th style="width: 25%">New Value</th>
</tr>
</thead>
<tbody>
@foreach (var change in (List<PowderCoating.Application.DTOs.Job.JobChangeHistoryDto>)ViewBag.ChangeHistory)
{
<tr>
<td>
<div>@change.ChangedAt.ToString("MM/dd/yyyy")</div>
<small class="text-muted">@change.ChangedAt.ToString("h:mm tt")</small>
</td>
<td>@change.ChangedByName</td>
<td><strong>@change.FieldName</strong></td>
<td>
@if (!string.IsNullOrEmpty(change.OldValue))
{
<span class="text-muted">@change.OldValue</span>
}
else
{
<span class="text-muted fst-italic">None</span>
}
</td>
<td>
@if (!string.IsNullOrEmpty(change.NewValue))
{
<strong>@change.NewValue</strong>
}
else
{
<span class="text-muted fst-italic">None</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
</div>
</div>
<!-- Upload Photo Modal -->
<div class="modal fade" id="uploadPhotoModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-cloud-upload me-2"></i>Upload Job Photo
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="uploadPhotoForm" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label">Photo Type</label>
<select class="form-select" id="photoType" name="photoType">
<option value="0">Before</option>
<option value="1" selected>Progress</option>
<option value="2">After</option>
<option value="3">Quality Check</option>
<option value="4">Issue</option>
<option value="5">Completed</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Caption / Note</label>
<textarea class="form-control" id="photoCaption" name="caption" rows="2" placeholder="Add a description or note about this photo..."></textarea>
</div>
<div class="mb-3">
<label class="form-label">Tags
<small class="text-muted fw-normal ms-1">— colors, finish, or other keywords</small>
</label>
<input type="hidden" id="photoTagsHidden" name="tags" />
<div id="photoTagsContainer"></div>
</div>
<div class="mb-3">
<label class="form-label">Choose Photo</label>
<div class="drop-zone" id="dropZone">
<i class="bi bi-cloud-upload" style="font-size: 2.5rem; opacity: 0.3;"></i>
<p class="mb-2">Drag & drop photo here or</p>
<input type="file" class="d-none" id="photoFile" name="photo" accept="image/*">
<button type="button" class="btn btn-outline-primary btn-sm" onclick="document.getElementById('photoFile').click()">
Browse Files
</button>
<small class="d-block mt-2 text-muted">Max 10MB - JPG, PNG, GIF, WebP</small>
</div>
<div id="photoPreview" class="mt-3 d-none">
<img id="previewImage" class="img-fluid rounded" style="max-height: 200px;">
<button type="button" class="btn btn-sm btn-outline-danger mt-2" onclick="jobPhotoModule.clearPhotoSelection()">
<i class="bi bi-x-circle me-1"></i>Remove
</button>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="jobPhotoModule.uploadPhoto()">
<i class="bi bi-upload me-1"></i>Upload
</button>
</div>
</div>
</div>
</div>
<!-- View Photo Modal -->
<div class="modal fade" id="viewPhotoModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="viewPhotoTitle">Photo</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<img id="viewPhotoImage" class="img-fluid rounded mb-3" style="max-height: 60vh;">
<div class="d-flex justify-content-between align-items-center mb-3">
<button type="button" class="btn btn-outline-primary" onclick="jobPhotoModule.navigatePhoto(-1)">
<i class="bi bi-chevron-left"></i> Previous
</button>
<span id="photoPosition" class="text-muted">Photo 1 of 1</span>
<button type="button" class="btn btn-outline-primary" onclick="jobPhotoModule.navigatePhoto(1)">
Next <i class="bi bi-chevron-right"></i>
</button>
</div>
<div id="photoDetails">
<p class="mb-1"><strong>Caption:</strong> <span id="photoDetailCaption"></span></p>
<p class="mb-1"><strong>Type:</strong> <span id="photoDetailType"></span></p>
<p class="mb-1" id="photoDetailTagsRow"><strong>Tags:</strong> <span id="photoDetailTags"></span></p>
<p class="mb-1"><strong>Uploaded:</strong> <span id="photoDetailDate"></span></p>
<p class="mb-0"><strong>By:</strong> <span id="photoDetailUploader"></span></p>
</div>
<!-- Edit form (hidden by default) -->
<div id="photoEditPanel" class="d-none text-start mt-3">
<div class="mb-3">
<label class="form-label fw-semibold">Photo Type</label>
<select class="form-select" id="editPhotoType">
<option value="0">Before</option>
<option value="1">Progress</option>
<option value="2">After</option>
<option value="3">Quality Check</option>
<option value="4">Issue</option>
<option value="5">Completed</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Caption / Note</label>
<textarea class="form-control" id="editPhotoCaption" rows="2" placeholder="Add a description or note..."></textarea>
</div>
<div class="mb-0">
<label class="form-label fw-semibold">Tags <small class="text-muted fw-normal ms-1">— colors, finish, keywords</small></label>
<input type="hidden" id="editPhotoTagsHidden" />
<div id="editPhotoTagsContainer"></div>
</div>
</div>
</div>
<div class="modal-footer">
<div id="viewModeButtons" class="d-flex gap-2 w-100 justify-content-end">
<button type="button" class="btn btn-outline-secondary" onclick="jobPhotoModule.editPhoto()">
<i class="bi bi-pencil me-1"></i>Edit
</button>
<button type="button" class="btn btn-danger" onclick="jobPhotoModule.deletePhoto()">
<i class="bi bi-trash me-1"></i>Delete
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
<div id="editModeButtons" class="d-none d-flex gap-2 w-100 justify-content-end">
<button type="button" class="btn btn-secondary" onclick="jobPhotoModule.cancelPhotoEdit()">Cancel</button>
<button type="button" class="btn btn-primary" onclick="jobPhotoModule.savePhotoEdit()">
<i class="bi bi-check-lg me-1"></i>Save Changes
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Complete Job Modal -->
@await Html.PartialAsync("_CompleteJobModal", Model)
<!-- SMS Compose Modal (Admin/Manager only) -->
@if ((bool)(ViewBag.IsAdminOrManager ?? false) && (bool)(ViewBag.SmsEnabled ?? false))
{
<div class="modal fade" id="smsComposeModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-info bg-opacity-10">
<h5 class="modal-title">
<i class="bi bi-chat-dots me-2 text-info"></i>Send SMS to @Model.CustomerName
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" id="smsModalClose"></button>
</div>
<div class="modal-body">
@if (!string.IsNullOrWhiteSpace(Model.CustomerMobilePhone))
{
<p class="text-muted small mb-3">
<i class="bi bi-phone me-1"></i>Sending to: <strong>@Model.CustomerMobilePhone</strong>
</p>
}
<div class="mb-2">
<label class="form-label fw-semibold" for="smsMessageText">Message</label>
<textarea class="form-control" id="smsMessageText" rows="5"
placeholder="Type your message…" maxlength="160"></textarea>
<div class="d-flex justify-content-between mt-1">
<div id="smsStopWarning" class="text-warning small d-none">
<i class="bi bi-exclamation-triangle me-1"></i>"Reply STOP to opt out." will be appended automatically.
</div>
<div class="ms-auto text-muted small"><span id="smsCharCount">0</span> / 160</div>
</div>
</div>
<div id="smsSendError" class="alert alert-danger d-none mt-2"></div>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="smsDismissBtn">
Skip — don't send
</button>
<button type="button" class="btn btn-info text-white" id="smsSendBtn">
<i class="bi bi-send me-1"></i>Send SMS
</button>
</div>
</div>
</div>
</div>
}
<!-- Hidden form used by item-wizard.js to collect item data and submit to UpdateItems -->
<form asp-action="UpdateItems" asp-controller="Jobs" method="post" id="jobItemsForm" style="display:none">
@Html.AntiForgeryToken()
<input type="hidden" name="JobId" value="@Model.Id" />
<input type="hidden" name="JobNumber" value="@Model.JobNumber" />
<input type="hidden" name="CustomerId" value="@Model.CustomerId" />
<input type="hidden" name="TaxPercent" value="@ViewBag.WizardTaxPercent" />
<div id="hiddenFieldsContainer"></div>
</form>
<!-- Off-screen containers required by item-wizard.js -->
<div id="itemCardsContainer" style="display:none"></div>
<div id="itemsEmptyMessage" style="display:none"></div>
<span id="pricingSpinner" style="display:none"></span>
<span id="pricingPlaceholder" style="display:none"></span>
<span id="itemsSubtotalRow" style="display:none"></span><span id="itemsSubtotalDisplay" style="display:none"></span>
<span id="ovenBatchCostRow" style="display:none"></span><span id="ovenBatchesDisplay" style="display:none"></span><span id="ovenCycleMinDisplay" style="display:none"></span><span id="ovenBatchCostDisplay" style="display:none"></span>
<span id="pricingTierDiscountRow" style="display:none"></span><span id="pricingTierDiscountPercentDisplay" style="display:none"></span><span id="pricingTierDiscountDisplay" style="display:none"></span>
<span id="subtotalRow" style="display:none"></span><span id="subtotalDisplay" style="display:none"></span>
<span id="taxRow" style="display:none"></span><span id="taxPercentDisplay" style="display:none"></span><span id="taxDisplay" style="display:none"></span>
<span id="pricingDivider" style="display:none"></span>
<span id="totalRow" style="display:none"></span><span id="totalDisplay" style="display:none"></span>
<!-- Delete confirmation modal -->
<div class="modal fade" id="deleteConfirmModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h6 class="modal-title mb-0">
<i class="bi bi-exclamation-triangle-fill me-2"></i>Delete Item?
</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="mb-0 small" id="deleteConfirmItemName"></p>
</div>
<div class="modal-footer gap-2">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger btn-sm" id="deleteConfirmBtn">
<i class="bi bi-trash me-1"></i>Delete
</button>
</div>
</div>
</div>
</div>
<!-- Surface Area Calculator Modal -->
<div class="modal fade" id="sqFtCalculatorModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-calculator me-2"></i>Surface Area Calculator <small class="text-muted">(per item)</small></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Shape</label>
<select id="calcShape" class="form-select" onchange="toggleShapeInputs()">
<option value="rectangle">Rectangle / Square</option>
<option value="cylinder">Cylinder (Tube)</option>
<option value="circle">Circle (Flat)</option>
</select>
</div>
<div id="rectangleInputs">
<div class="row g-2">
<div class="col-6"><label class="form-label">Length (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
<input type="number" id="rectLength" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
</div>
<small class="text-muted">Formula: L &times; W &divide; @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
</div>
<div id="cylinderInputs" style="display:none">
<div class="row g-2">
<div class="col-6"><label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
<input type="number" id="cylDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
<div class="col-6"><label class="form-label">Height (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
<input type="number" id="cylHeight" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
</div>
</div>
<div id="circleInputs" style="display:none">
<label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
<input type="number" id="circDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()">
</div>
<hr />
<div class="alert alert-info mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
<i class="bi bi-check-circle me-1"></i>Use This Value
</button>
</div>
</div>
</div>
</div>
<!-- Item Wizard Modal -->
<div class="modal fade" id="itemWizardModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div class="d-flex flex-column">
<h5 class="modal-title mb-0" id="wizardTitle">Add Item</h5>
<div class="text-muted small mb-1" id="wizardStepTitle">Choose Item Type</div>
<div class="d-flex align-items-center gap-2" id="wizardStepIndicator">
<span class="wizard-step-dot active" data-step="1" title="Item Type"></span>
<div class="wizard-step-line"></div>
<span class="wizard-step-dot" data-step="2" title="Item Details"></span>
<div class="wizard-step-line" id="step2Line"></div>
<span class="wizard-step-dot" data-step="3" title="Coating Layers" id="step3Dot"></span>
<div class="wizard-step-line" id="step3Line"></div>
<span class="wizard-step-dot" data-step="4" title="Prep Services" id="step4Dot"></span>
<span class="text-muted small ms-2" id="wizardStepLabel">Step 1 of 4</span>
</div>
</div>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="wizardBody" style="min-height: 300px;">
<!-- Content injected by JS -->
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary d-none" id="btnWizardBack" onclick="wizardBack()">
<i class="bi bi-arrow-left me-1"></i>Back
</button>
<button type="button" class="btn btn-primary" id="btnWizardNext" onclick="wizardNext()">
Next <i class="bi bi-arrow-right ms-1"></i>
</button>
<button type="button" class="btn btn-success d-none" id="btnWizardSave" onclick="wizardSave()">
<i class="bi bi-check-lg me-1"></i>Add Item
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Embedded data for wizard JS -->
@if (ViewBag.InventoryCoatings != null)
{
<script id="inventoryPowdersData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.InventoryCoatings))
</script>
}
@if (ViewBag.CatalogItems != null)
{
<script id="catalogItemsData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CatalogItems))
</script>
}
<script id="vendorsData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.Vendors ?? new List<object>()))
</script>
<script id="prepServicesData" type="application/json">
@Html.Raw(Json.Serialize(((List<PowderCoating.Core.Entities.PrepService>)(ViewBag.PrepServices ?? new List<PowderCoating.Core.Entities.PrepService>())).Select(p => new { id = p.Id, name = p.ServiceName, description = p.Description, requiresBlastSetup = p.RequiresBlastSetup })))
</script>
<script id="blastSetupsData" type="application/json">
@Html.Raw(Json.Serialize(ViewBag.BlastSetups ?? new List<object>()))
</script>
<!-- Rework Modal -->
<div class="modal fade" id="reworkModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="reworkModalTitle">Log Rework / Warranty</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="reworkAddForm">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Type <span class="text-danger">*</span></label>
<select class="form-select" id="rwType">
<option value="0">Internal Defect</option>
<option value="1">Customer Warranty</option>
<option value="2">Customer Damage</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Reason <span class="text-danger">*</span></label>
<select class="form-select" id="rwReason">
<option value="0">Adhesion Failure</option>
<option value="1">Contamination</option>
<option value="2">Color Mismatch</option>
<option value="3">Runs / Sags</option>
<option value="4">Surface Prep Failure</option>
<option value="5">Oven Issue</option>
<option value="6">Insufficient Coverage</option>
<option value="7">Handling Damage</option>
<option value="8">Other</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Defect Description <span class="text-danger">*</span></label>
<textarea class="form-control" id="rwDefect" rows="2" placeholder="Describe the defect or issue..."></textarea>
</div>
<div class="col-md-6">
<label class="form-label">Specific Item (optional)</label>
<select class="form-select" id="rwJobItem">
<option value="">— Whole Job —</option>
@if (Model.Items != null)
{
@foreach (var item in Model.Items)
{
<option value="@item.Id">@item.Description</option>
}
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Discovered By</label>
<select class="form-select" id="rwDiscoveredBy">
<option value="0">Internal (QC)</option>
<option value="1">Customer</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Discovery Date</label>
<input type="date" class="form-control" id="rwDiscoveredDate" value="@DateTime.Today.ToString("yyyy-MM-dd")" />
</div>
<div class="col-md-6">
<label class="form-label">Reported By (if customer)</label>
<input type="text" class="form-control" id="rwReportedBy" placeholder="Customer name / contact" />
</div>
<div class="col-md-4">
<label class="form-label">Estimated Rework Cost</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" min="0" class="form-control" id="rwEstCost" value="0" />
</div>
</div>
<div class="col-md-8 d-flex align-items-end">
<div class="form-check ms-2 mb-2">
<input class="form-check-input" type="checkbox" id="rwBillable" />
<label class="form-check-label" for="rwBillable">
Billable to Customer
<small class="text-muted d-block">Check if customer caused the issue (e.g. tire shop damage)</small>
</label>
</div>
</div>
<div class="col-12">
<label class="form-label">Billing Notes</label>
<input type="text" class="form-control" id="rwBillingNotes" placeholder="e.g. Customer agrees to pay labor, shop absorbs materials" />
</div>
</div>
</div>
<div id="reworkResolveForm" style="display:none;">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Status</label>
<select class="form-select" id="rwStatus">
<option value="0">Open</option>
<option value="1">In Progress</option>
<option value="2">Resolved</option>
<option value="3">Written Off</option>
<option value="4">Disputed</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Resolution</label>
<select class="form-select" id="rwResolution">
<option value="">— Pending —</option>
<option value="0">Recoated — No Charge</option>
<option value="1">Recoated — Billed to Customer</option>
<option value="2">Customer Credited</option>
<option value="3">Written Off</option>
<option value="4">No Action Required</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Actual Rework Cost</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" min="0" class="form-control" id="rwActualCost" value="0" />
</div>
</div>
<div class="col-md-6 d-flex align-items-end">
<div class="form-check ms-2 mb-2">
<input class="form-check-input" type="checkbox" id="rwBillableEdit" />
<label class="form-check-label" for="rwBillableEdit">Billable to Customer</label>
</div>
</div>
<div class="col-12">
<label class="form-label">Billing Notes</label>
<input type="text" class="form-control" id="rwBillingNotesEdit" />
</div>
<div class="col-12">
<label class="form-label">Resolution Notes</label>
<textarea class="form-control" id="rwResolutionNotes" rows="2" placeholder="Describe how this was resolved..."></textarea>
</div>
<div class="col-12" id="rwCreateJobSection">
<div class="border rounded p-3 bg-light">
<div class="fw-semibold mb-1"><i class="bi bi-briefcase me-1"></i>Linked Rework Job</div>
<div id="rwLinkedJob" class="small text-muted"></div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-warning" id="reworkSaveBtn" onclick="rework.save()">
<i class="bi bi-floppy me-1"></i>Save
</button>
</div>
</div>
</div>
</div>
<!-- Time Entry Modal -->
<div class="modal fade" id="timeEntryModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="timeEntryModalTitle">Log Time</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="teEntryId" value="0" />
<div class="mb-3">
<label class="form-label fw-semibold">Worker <span class="text-danger">*</span></label>
<select class="form-select" id="teWorkerId">
<option value="">— Select worker —</option>
@foreach (var w in (ViewBag.ShopWorkers as IEnumerable<dynamic> ?? []))
{
<option value="@w.Id">@w.Name</option>
}
</select>
</div>
<div class="row g-3 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">Date <span class="text-danger">*</span></label>
<input type="date" class="form-control" id="teWorkDate" />
</div>
<div class="col-6">
<label class="form-label fw-semibold">Hours <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="teHoursWorked" min="0.1" max="24" step="0.25" placeholder="e.g. 2.5" />
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Stage / Task</label>
<input type="text" class="form-control" id="teStage" placeholder="e.g. Sandblasting, Coating, Masking…" list="stageOptions" />
<datalist id="stageOptions">
<option value="Sandblasting"></option>
<option value="Masking & Taping"></option>
<option value="Cleaning"></option>
<option value="Coating"></option>
<option value="Curing"></option>
<option value="Quality Check"></option>
<option value="Prep Work"></option>
<option value="Racking"></option>
<option value="Packaging"></option>
</datalist>
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea class="form-control" id="teNotes" rows="2" placeholder="Optional notes…"></textarea>
</div>
<div class="text-danger small d-none" id="teError"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="timeTracking.save()">
<i class="bi bi-check-lg me-1"></i>Save
</button>
</div>
</div>
</div>
</div>
<script id="existingItemsData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.WizardExistingItems ?? new List<object>()))
</script>
<script id="quoteMetaData" type="application/json">
{
"customerId": @Json.Serialize(Model.CustomerId),
"taxPercent": @ViewBag.WizardTaxPercent,
"discountType": "None",
"discountValue": 0,
"isRushJob": false,
"ovenCostId": null,
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
"itemsFieldPrefix": "JobItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
}
</script>
@section Scripts {
<link rel="stylesheet" href="~/css/job-photos.css" />
<script src="~/js/job-photos.js" asp-append-version="true"></script>
<script src="~/js/customer-change.js" asp-append-version="true"></script>
<script>
// ── Inline date editing ──────────────────────────────────────────────
const jobId = @Model.Id;
const antiForgeryToken = () => document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
function startDateEdit(field) {
document.getElementById(field + '-display').classList.add('d-none');
document.getElementById(field + '-editor').classList.remove('d-none');
document.getElementById(field + '-input').focus();
}
function cancelDateEdit(field) {
document.getElementById(field + '-editor').classList.add('d-none');
document.getElementById(field + '-display').classList.remove('d-none');
}
function clearDateField(field) {
document.getElementById(field + '-input').value = '';
saveDateField(field);
}
async function saveDateField(field) {
const input = document.getElementById(field + '-input');
const saving = document.getElementById(field + '-saving');
const value = input.value || null; // null = clear the date
saving.classList.remove('d-none');
try {
const resp = await fetch('@Url.Action("UpdateDates", "Jobs")', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': antiForgeryToken()
},
body: JSON.stringify({
jobId,
scheduledDate: field === 'scheduledDate' ? value : undefined,
dueDate: field === 'dueDate' ? value : undefined
})
});
const result = await resp.json();
if (!result.success) throw new Error(result.message);
// Update display text
const textEl = document.getElementById(field + '-text');
if (value) {
const d = new Date(value + 'T12:00:00');
textEl.textContent = d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: '2-digit' });
textEl.className = '';
} else {
textEl.textContent = field === 'scheduledDate' ? 'Not scheduled' : 'Not set';
textEl.className = 'text-muted';
}
saving.classList.add('d-none');
cancelDateEdit(field);
} catch (err) {
saving.classList.add('d-none');
alert('Could not save date: ' + err.message);
}
}
</script>
<script>
async function updateWorkerAssignment(select) {
const jobId = @Model.Id;
const workerId = select.value || null;
const spinner = document.getElementById('workerSaveIndicator');
const tick = document.getElementById('workerSavedTick');
tick.classList.add('d-none');
spinner.classList.remove('d-none');
try {
const resp = await fetch('@Url.Action("UpdateWorkerAssignment", "Jobs")', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? ''
},
body: JSON.stringify({ jobId, workerId })
});
const result = await resp.json();
if (!result.success) throw new Error(result.message);
spinner.classList.add('d-none');
tick.classList.remove('d-none');
setTimeout(() => tick.classList.add('d-none'), 2000);
} catch (err) {
spinner.classList.add('d-none');
alert('Could not save worker assignment: ' + err.message);
// Revert to previous selection by reloading
location.reload();
}
}
</script>
<style>
.wizard-step-dot {
width: 22px; height: 22px; border-radius: 50%;
background: #dee2e6; display: inline-block; cursor: default;
border: 2px solid #dee2e6; transition: all .2s; flex-shrink: 0;
}
.wizard-step-dot.active { background: #0d6efd; border-color: #0d6efd; }
.wizard-step-dot.done { background: #198754; border-color: #198754; }
.wizard-step-dot.skip { background: #adb5bd; border-color: #adb5bd; }
.wizard-step-line { flex: 1; height: 2px; background: #dee2e6; min-width: 30px; }
.item-type-card {
border: 2px solid #dee2e6; border-radius: .75rem; padding: 1.25rem 1rem;
cursor: pointer; transition: all .15s; text-align: center;
background: #fff; user-select: none;
}
.item-type-card:hover { border-color: #86b7fe; background: #f0f6ff; }
.item-type-card.selected { border-color: #0d6efd; background: #eef3ff; }
.item-type-card .item-type-icon { font-size: 2rem; margin-bottom: .5rem; }
.quote-item-card {
border: 1px solid #dee2e6; border-radius: .5rem;
padding: .75rem 1rem; margin-bottom: .5rem; background: #fafafa;
}
.quote-item-card .item-badge { font-size: .7rem; }
.coat-row { border: 1px solid #dee2e6; border-radius: .5rem; padding: .75rem; margin-bottom: .5rem; }
</style>
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
jobPhotoModule.init(@Model.Id, @Html.Raw(ViewBag.PhotoTagSuggestions ?? "[]"));
// ── Auto-submit after wizard saves an item ────────────────────────
let itemsModified = false;
// Wrap wizardSave to set a flag before the modal hides
const _origWizardSave = window.wizardSave;
window.wizardSave = function () {
_origWizardSave();
itemsModified = true;
};
document.getElementById('itemWizardModal')
.addEventListener('hidden.bs.modal', function () {
if (itemsModified) {
itemsModified = false;
document.getElementById('jobItemsForm').submit();
}
});
// ── Delete confirmation modal ─────────────────────────────────────
let pendingDeleteItemId = -1;
const deleteModal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
const deleteItemToken = document.querySelector('input[name="__RequestVerificationToken"]').value;
// Delegated listener — handles all delete buttons via data attributes
document.addEventListener('click', function (e) {
const btn = e.target.closest('[data-delete-id]');
if (!btn) return;
pendingDeleteItemId = parseInt(btn.dataset.deleteId, 10);
document.getElementById('deleteConfirmItemName').textContent =
'Are you sure you want to delete "' + btn.dataset.deleteName + '"?';
deleteModal.show();
});
document.getElementById('deleteConfirmBtn').addEventListener('click', async function () {
deleteModal.hide();
if (pendingDeleteItemId < 0) return;
const id = pendingDeleteItemId;
pendingDeleteItemId = -1;
try {
const resp = await fetch('@Url.Action("DeleteItem", "Jobs")/' + id, {
method: 'POST',
headers: { 'RequestVerificationToken': deleteItemToken }
});
if (resp.ok) {
window.location.reload();
} else {
alert('Failed to delete item. Please try again.');
}
} catch {
alert('An error occurred. Please try again.');
}
});
});
</script>
<!-- ── Rework / Warranty ────────────────────────────────────────────── -->
<script>
const rework = (() => {
const jid = @Model.Id;
const modal = new bootstrap.Modal(document.getElementById('reworkModal'));
const STATUS_COLORS = { 0:'danger', 1:'warning', 2:'success', 3:'secondary', 4:'info' };
const STATUS_LABELS = { 0:'Open', 1:'In Progress', 2:'Resolved', 3:'Written Off', 4:'Disputed' };
const TYPE_LABELS = { 0:'Internal Defect', 1:'Customer Warranty', 2:'Customer Damage' };
const REASON_LABELS = { 0:'Adhesion Failure', 1:'Contamination', 2:'Color Mismatch', 3:'Runs / Sags', 4:'Surface Prep Failure', 5:'Oven Issue', 6:'Insufficient Coverage', 7:'Handling Damage', 8:'Other' };
let records = [];
let editId = null;
async function load() {
const resp = await fetch(`/Jobs/GetReworkRecords?jobId=${jid}`);
records = await resp.json();
render();
}
function render() {
const badge = document.getElementById('reworkBadge');
const open = records.filter(r => r.status === 0 || r.status === 1).length;
badge.textContent = open;
badge.classList.toggle('d-none', open === 0);
const listEl = document.getElementById('reworkList');
const emptyEl = document.getElementById('reworkEmpty');
if (records.length === 0) { emptyEl.style.display = ''; listEl.innerHTML = ''; return; }
emptyEl.style.display = 'none';
listEl.innerHTML = records.map(r => `
<div class="border-bottom px-3 py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="badge bg-${STATUS_COLORS[r.status]} me-1">${STATUS_LABELS[r.status]}</span>
<span class="badge bg-light text-dark border me-1">${TYPE_LABELS[r.reworkType]}</span>
<span class="badge bg-light text-dark border">${REASON_LABELS[r.reason]}</span>
${r.isBillableToCustomer ? '<span class="badge bg-info ms-1">Billable</span>' : ''}
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" onclick="rework.openEdit(${r.id})" title="Update"><i class="bi bi-pencil"></i></button>
<button class="btn btn-outline-danger" onclick="rework.del(${r.id})" title="Delete"><i class="bi bi-trash"></i></button>
</div>
</div>
<div class="small mt-1 text-muted">${r.defectDescription}</div>
<div class="small text-muted mt-1">
Found: ${r.discoveredByDisplay} — ${new Date(r.discoveredDate).toLocaleDateString()}
${r.reportedByName ? '— ' + r.reportedByName : ''}
${r.jobItemDescription ? ' | Item: ' + r.jobItemDescription : ''}
</div>
${r.reworkJobNumber ? `<div class="small mt-1"><i class="bi bi-briefcase me-1"></i>Rework Job: <a href="/Jobs/Details/${r.reworkJobId}" class="text-decoration-none fw-semibold">${r.reworkJobNumber}</a></div>` : ''}
${r.resolutionDisplay ? `<div class="small text-success mt-1"><i class="bi bi-check-circle me-1"></i>${r.resolutionDisplay}${r.actualReworkCost > 0 ? ' — $' + r.actualReworkCost.toFixed(2) : ''}</div>` : ''}
</div>`).join('');
}
function openAdd() {
editId = null;
document.getElementById('reworkModalTitle').textContent = 'Log Rework / Warranty';
document.getElementById('reworkAddForm').style.display = '';
document.getElementById('reworkResolveForm').style.display = 'none';
document.getElementById('rwDefect').value = '';
document.getElementById('rwEstCost').value = '0';
document.getElementById('rwBillable').checked = false;
document.getElementById('rwBillingNotes').value = '';
document.getElementById('rwReportedBy').value = '';
document.getElementById('rwDiscoveredDate').value = new Date().toISOString().split('T')[0];
modal.show();
}
function openEdit(id) {
editId = id;
const r = records.find(x => x.id === id);
if (!r) return;
document.getElementById('reworkModalTitle').textContent = 'Update Rework Record';
document.getElementById('reworkAddForm').style.display = 'none';
document.getElementById('reworkResolveForm').style.display = '';
document.getElementById('rwStatus').value = r.status;
document.getElementById('rwResolution').value = r.resolution ?? '';
document.getElementById('rwActualCost').value = r.actualReworkCost || 0;
document.getElementById('rwBillableEdit').checked = r.isBillableToCustomer;
document.getElementById('rwBillingNotesEdit').value = r.billingNotes || '';
document.getElementById('rwResolutionNotes').value = r.resolutionNotes || '';
const linkedDiv = document.getElementById('rwLinkedJob');
if (r.reworkJobNumber) {
linkedDiv.innerHTML = `<a href="/Jobs/Details/${r.reworkJobId}" class="fw-semibold">${r.reworkJobNumber}</a>`;
} else {
linkedDiv.textContent = 'No rework job linked.';
}
modal.show();
}
async function save() {
if (editId === null) {
// Create
const defect = document.getElementById('rwDefect').value.trim();
if (!defect) { alert('Defect description is required.'); return; }
const dto = {
jobId: jid,
jobItemId: document.getElementById('rwJobItem').value || null,
reworkType: parseInt(document.getElementById('rwType').value),
reason: parseInt(document.getElementById('rwReason').value),
defectDescription: defect,
discoveredBy: parseInt(document.getElementById('rwDiscoveredBy').value),
discoveredDate: document.getElementById('rwDiscoveredDate').value,
reportedByName: document.getElementById('rwReportedBy').value || null,
estimatedReworkCost: parseFloat(document.getElementById('rwEstCost').value) || 0,
isBillableToCustomer: document.getElementById('rwBillable').checked,
billingNotes: document.getElementById('rwBillingNotes').value || null
};
const resp = await fetch('/Jobs/AddReworkRecord', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': getToken() },
body: JSON.stringify(dto)
});
if (resp.ok) { modal.hide(); await load(); costing.load(); }
} else {
// Update
const dto = {
id: editId,
status: parseInt(document.getElementById('rwStatus').value),
resolution: document.getElementById('rwResolution').value !== '' ? parseInt(document.getElementById('rwResolution').value) : null,
actualReworkCost: parseFloat(document.getElementById('rwActualCost').value) || 0,
isBillableToCustomer: document.getElementById('rwBillableEdit').checked,
billingNotes: document.getElementById('rwBillingNotesEdit').value || null,
resolutionNotes: document.getElementById('rwResolutionNotes').value || null,
reworkJobId: records.find(r => r.id === editId)?.reworkJobId || null
};
const resp = await fetch('/Jobs/UpdateReworkRecord', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': getToken() },
body: JSON.stringify(dto)
});
if (resp.ok) { modal.hide(); await load(); costing.load(); }
}
}
async function del(id) {
if (!await showConfirm('Remove this rework record? The linked rework job will also be deleted.', 'Delete Rework Record', 'Delete', 'btn-danger')) return;
await fetch('/Jobs/DeleteReworkRecord', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': getToken() },
body: JSON.stringify({ id })
});
await load();
costing.load();
}
function getToken() {
return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
}
load();
return { load, openAdd, openEdit, save, del };
})();
</script>
<!-- ── Job Costing ──────────────────────────────────────────────────── -->
<script>
const costing = (() => {
const jid = @Model.Id;
const fmt = v => '$' + parseFloat(v).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
async function load() {
document.getElementById('costingLoading').style.display = '';
document.getElementById('costingContent').style.display = 'none';
document.getElementById('costingError').style.display = 'none';
try {
const resp = await fetch(`/Jobs/GetCostingBreakdown?jobId=${jid}`);
const d = await resp.json();
if (d.error) { showError(d.error); return; }
render(d);
} catch(e) {
showError('Could not load costing data.');
}
}
function render(d) {
document.getElementById('costingRevenueSource').textContent = d.revenueSource;
document.getElementById('costingRevenue').textContent = fmt(d.revenue);
document.getElementById('costingPowder').textContent = fmt(d.powderCost);
document.getElementById('costingLabor').textContent = fmt(d.laborCost);
document.getElementById('costingLaborHours').textContent =
d.laborLines.reduce((s, l) => s + l.hours, 0).toFixed(1);
document.getElementById('costingOven').textContent = fmt(d.ovenCost);
document.getElementById('costingOvenLabel').textContent =
`(${d.ovenLabel}, ${d.ovenCycleMinutes} min est.)`;
// Rework costs
const reworkSection = document.getElementById('costingReworkSection');
if (d.hasRework) {
reworkSection.style.display = '';
document.getElementById('costingRework').textContent =
fmt(d.reworkCostTotal) + (d.reworkCostTotal !== d.netReworkCost ? ` (net: ${fmt(d.netReworkCost)})` : '');
document.getElementById('costingReworkBilled').textContent = fmt(d.reworkBilledToCustomer);
const rBody = document.getElementById('reworkCostLines');
rBody.innerHTML = d.reworkLines.map(l => `<tr>
<td class="text-muted">${l.jobNumber ? `<a href="/Jobs/Details" class="text-decoration-none">${l.jobNumber}</a>` : 'No job'} — ${l.reason}${l.isEstimate ? ' <span class="badge bg-secondary" style="font-size:0.65rem;">est.</span>' : ''}</td>
<td class="text-end text-nowrap">${l.billedToCustomer > 0 ? `<span class="text-success">${fmt(l.billedToCustomer)} billed</span>` : 'absorbed'}</td>
<td class="text-end text-nowrap fw-semibold">${fmt(l.cost)}</td></tr>`).join('');
} else {
reworkSection.style.display = 'none';
}
document.getElementById('costingTotal').textContent = fmt(d.totalCost);
const profit = d.grossProfit;
const profitEl = document.getElementById('costingProfit');
profitEl.textContent = fmt(profit);
profitEl.className = profit >= 0 ? 'text-success' : 'text-danger';
document.getElementById('costingMargin').textContent = `${d.grossMargin}%`;
document.getElementById('costingQuotedMargin').textContent =
d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '—';
// Powder detail lines
const pBody = document.getElementById('powderLines');
pBody.innerHTML = d.hasPowderData
? d.powderLines.map(l => `<tr>
<td class="text-muted" style="max-width:160px;white-space:normal;">${l.description}${l.isActual ? ' <span class="badge bg-success" style="font-size:0.65rem;">actual</span>' : ''}</td>
<td class="text-end text-nowrap">${l.lbs} lbs × ${fmt(l.costPerLb)}/lb</td>
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
: '<tr><td colspan="3" class="text-muted">No powder cost data on coats.</td></tr>';
// Labor detail lines
const lBody = document.getElementById('laborLines');
lBody.innerHTML = d.hasLaborData
? d.laborLines.map(l => `<tr>
<td class="text-muted">${l.worker}${l.stage ? ' — ' + l.stage : ''}<br/><small>${l.workDate}</small></td>
<td class="text-end text-nowrap">${l.hours}h × ${fmt(l.rate)}/hr${l.usingFallback ? ' <span title="Using standard labor rate" class="text-muted">*</span>' : ''}</td>
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
: '<tr><td colspan="3" class="text-muted">No time entries logged yet.</td></tr>';
// Notes
const notes = [];
if (!d.hasPowderData && d.hasPowderRateButNoQty) notes.push('⚠ Surface area not set on one or more items — edit the item and enter a surface area to calculate powder cost.');
else if (!d.hasPowderData) notes.push('⚠ Add powder cost per lb on coat records to include material cost.');
if (!d.hasLaborData) notes.push('⚠ Log time entries to include labor cost.');
if (d.laborLines?.some(l => l.usingFallback)) notes.push('* One or more workers using standard labor rate fallback.');
document.getElementById('costingNotes').innerHTML = notes.map(n => `<div class="text-muted">${n}</div>`).join('');
document.getElementById('costingLoading').style.display = 'none';
document.getElementById('costingContent').style.display = '';
}
function showError(msg) {
document.getElementById('costingLoading').style.display = 'none';
const el = document.getElementById('costingError');
el.textContent = msg;
el.style.display = '';
}
function toggleDetail(section) {
const el = document.getElementById(section + 'Detail');
const chevron = document.getElementById(section + 'Chevron');
const open = el.style.display === 'none';
el.style.display = open ? '' : 'none';
chevron.className = open ? 'bi bi-chevron-up' : 'bi bi-chevron-down';
}
// Auto-load when page opens
load();
return { load, toggleDetail };
})();
</script>
<!-- ── Time Tracking ─────────────────────────────────────────────────── -->
<script>
const timeTracking = (() => {
const jid = @Model.Id;
const currentUserId = '@(ViewBag.CurrentUserId ?? "")';
const modal = new bootstrap.Modal(document.getElementById('timeEntryModal'));
let entries = [];
// ── Load ──────────────────────────────────────────────────────────
async function load() {
const r = await fetch(`/Jobs/GetTimeEntries?jobId=${jid}`);
entries = await r.json();
render();
}
function render() {
const tbody = document.getElementById('timeEntriesTbody');
const empty = document.getElementById('timeEntriesEmpty');
const table = document.getElementById('timeEntriesTable');
tbody.innerHTML = '';
if (!entries.length) {
empty.classList.remove('d-none');
table.classList.add('d-none');
updateTotals(0);
return;
}
empty.classList.add('d-none');
table.classList.remove('d-none');
let total = 0;
entries.forEach(e => {
total += e.hoursWorked;
const d = new Date(e.workDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="fw-semibold">${esc(e.workerName)}</td>
<td class="small">${d}</td>
<td class="text-end fw-semibold">${e.hoursWorked.toFixed(2)}</td>
<td class="small">${e.stage ? `<span class="badge bg-secondary-subtle text-secondary">${esc(e.stage)}</span>` : '<span class="text-muted">—</span>'}</td>
<td class="small text-muted">${esc(e.notes ?? '')}</td>
<td class="text-end">
<button class="btn btn-xs btn-outline-secondary me-1 py-0 px-1" title="Edit" onclick="timeTracking.openEdit(${e.id})"><i class="bi bi-pencil"></i></button>
<button class="btn btn-xs btn-outline-danger py-0 px-1" title="Delete" onclick="timeTracking.del(${e.id})"><i class="bi bi-trash"></i></button>
</td>`;
tbody.appendChild(tr);
});
updateTotals(total);
}
function updateTotals(total) {
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '—';
document.getElementById('totalHoursDisplay').textContent = fmt;
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '—';
}
// ── Modal helpers ─────────────────────────────────────────────────
function openAdd() {
document.getElementById('timeEntryModalTitle').textContent = 'Log Time';
document.getElementById('teEntryId').value = '0';
document.getElementById('teWorkerId').value = currentUserId;
document.getElementById('teWorkDate').value = new Date().toISOString().slice(0, 10);
document.getElementById('teHoursWorked').value = '';
document.getElementById('teStage').value = '';
document.getElementById('teNotes').value = '';
document.getElementById('teError').classList.add('d-none');
modal.show();
}
function openEdit(id) {
const e = entries.find(x => x.id === id);
if (!e) return;
document.getElementById('timeEntryModalTitle').textContent = 'Edit Time Entry';
document.getElementById('teEntryId').value = e.id;
document.getElementById('teWorkerId').value = e.userId ?? '';
document.getElementById('teWorkDate').value = new Date(e.workDate).toISOString().slice(0, 10);
document.getElementById('teHoursWorked').value = e.hoursWorked;
document.getElementById('teStage').value = e.stage ?? '';
document.getElementById('teNotes').value = e.notes ?? '';
document.getElementById('teError').classList.add('d-none');
modal.show();
}
async function save() {
const id = parseInt(document.getElementById('teEntryId').value);
const workerId = document.getElementById('teWorkerId').value;
const workDate = document.getElementById('teWorkDate').value;
const hours = parseFloat(document.getElementById('teHoursWorked').value);
const stage = document.getElementById('teStage').value.trim();
const notes = document.getElementById('teNotes').value.trim();
const errEl = document.getElementById('teError');
if (!workerId || !workDate || !hours || hours <= 0) {
errEl.textContent = 'Worker, date, and hours are required.';
errEl.classList.remove('d-none');
return;
}
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
const url = id > 0 ? '/Jobs/UpdateTimeEntry' : '/Jobs/AddTimeEntry';
const body = id > 0
? { id, userId: workerId, workDate, hoursWorked: hours, stage: stage || null, notes: notes || null }
: { jobId: jid, userId: workerId, workDate, hoursWorked: hours, stage: stage || null, notes: notes || null };
try {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': tok },
body: JSON.stringify(body)
});
if (!r.ok) { const d = await r.json(); errEl.textContent = d.error ?? 'Save failed.'; errEl.classList.remove('d-none'); return; }
modal.hide();
await load();
showToast(id > 0 ? 'Time entry updated.' : 'Time logged.', 'success');
} catch { errEl.textContent = 'Network error.'; errEl.classList.remove('d-none'); }
}
async function del(id) {
if (!await showConfirm('Delete this time entry?', 'Delete Time Entry', 'Delete', 'btn-danger')) return;
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
const r = await fetch('/Jobs/DeleteTimeEntry', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': tok },
body: JSON.stringify({ id })
});
if (r.ok) { await load(); showToast('Time entry deleted.', 'secondary'); }
}
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function showToast(msg, type) {
const t = document.createElement('div');
t.className = `toast align-items-center text-bg-${type} border-0 position-fixed bottom-0 end-0 m-3`;
t.setAttribute('role', 'alert');
t.innerHTML = `<div class="d-flex"><div class="toast-body">${msg}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
document.body.appendChild(t);
new bootstrap.Toast(t, { delay: 3000 }).show();
t.addEventListener('hidden.bs.toast', () => t.remove());
}
// Auto-populate ActualTimeSpentHours in Complete Job modal from logged total
function getTotalHours() {
return entries.reduce((s, e) => s + e.hoursWorked, 0);
}
load();
return { openAdd, openEdit, save, del, getTotalHours };
})();
// Pre-fill actual hours in CompleteJob modal from time entries
document.addEventListener('show.bs.modal', function(e) {
if (e.target.id === 'completeJobModal') {
const total = timeTracking.getTotalHours();
if (total > 0) {
const el = document.getElementById('actualTimeSpent');
if (el && !el.value) el.value = total.toFixed(2);
}
}
});
// ── Deposits ─────────────────────────────────────────────────────────────
// Note: antiForgeryToken() is already defined above in this script block
document.getElementById('addDepositForm')?.addEventListener('submit', async function(e) {
e.preventDefault();
const form = e.currentTarget;
const btn = document.getElementById('saveDepositBtn');
const errEl = document.getElementById('depositFormError');
function showDepositError(msg) {
if (errEl) { errEl.textContent = msg; errEl.classList.remove('d-none'); }
else { alert(msg); }
}
if (errEl) errEl.classList.add('d-none');
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…'; }
const params = new URLSearchParams(new FormData(form));
try {
const resp = await fetch('@Url.Action("Record", "Deposits")', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'RequestVerificationToken': antiForgeryToken()
},
body: params.toString()
});
const data = await resp.json();
if (data.success) {
window.open('@Url.Action("Receipt", "Deposits")/' + data.depositId, '_blank');
location.reload();
} else {
showDepositError(data.message || 'An error occurred.');
}
} catch {
showDepositError('A network error occurred.');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-check-circle me-1"></i>Save & Generate Receipt'; }
}
});
async function deleteDeposit(depositId, receiptNum) {
if (!confirm(`Delete deposit ${receiptNum}? This cannot be undone.`)) return;
try {
const resp = await fetch('@Url.Action("Delete", "Deposits")/' + depositId, {
method: 'POST',
headers: { 'RequestVerificationToken': antiForgeryToken() }
});
const data = await resp.json();
if (data.success) location.reload();
else alert(data.message || 'Could not delete the deposit.');
} catch {
alert('A network error occurred.');
}
}
// ── Collapsible sections ──────────────────────────────────────────────────
(function () {
const storageKey = 'jobDetailCollapse_@Model.Id';
const sections = ['collapseTimeTracking', 'collapsePartIntake', 'collapsePhotos', 'collapseDeposits', 'collapseMaterials'];
function loadState() {
try { return JSON.parse(localStorage.getItem(storageKey) || '{}'); } catch { return {}; }
}
function saveState(id, expanded) {
const s = loadState();
s[id] = expanded;
localStorage.setItem(storageKey, JSON.stringify(s));
}
const state = loadState();
sections.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
// Toggle is now on the inner title div, not the card-header
const toggle = el.closest('.card')?.querySelector('[data-bs-toggle="collapse"]');
const chevron = toggle?.querySelector('.collapse-chevron');
if (state[id] === true) {
el.classList.add('show');
if (toggle) toggle.setAttribute('aria-expanded', 'true');
if (chevron) chevron.style.transform = 'rotate(180deg)';
}
el.addEventListener('show.bs.collapse', () => {
if (chevron) chevron.style.transform = 'rotate(180deg)';
saveState(id, true);
});
el.addEventListener('hide.bs.collapse', () => {
if (chevron) chevron.style.transform = 'rotate(0deg)';
saveState(id, false);
});
});
})();
// ── Part Intake Modal ─────────────────────────────────────────────────────
(function () {
const expectedCount = @intakeExpectedCount;
const partCountInput = document.getElementById('intakePartCount');
const mismatchAlert = document.getElementById('intakeMismatchAlert');
if (partCountInput && mismatchAlert && expectedCount > 0) {
partCountInput.addEventListener('input', function () {
const val = parseInt(this.value);
mismatchAlert.classList.toggle('d-none', isNaN(val) || val === expectedCount);
});
}
document.getElementById('intakeSaveBtn')?.addEventListener('click', async function () {
const btn = this;
const errDiv = document.getElementById('intakeModalError');
errDiv.classList.add('d-none');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving...';
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
const body = new URLSearchParams();
body.append('actualPartCount', partCountInput?.value ?? '');
body.append('conditionNotes', document.getElementById('intakeConditionNotes')?.value ?? '');
const advSwitch = document.getElementById('intakeAdvanceSwitch');
body.append('advanceToInPreparation', advSwitch ? (advSwitch.checked ? 'true' : 'false') : 'false');
try {
const resp = await fetch('/Jobs/IntakeRecord/@Model.Id', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
body: body.toString()
});
const data = await resp.json();
if (data.success) {
window.location.reload();
} else {
errDiv.textContent = data.message ?? 'An error occurred.';
errDiv.classList.remove('d-none');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-check-circle me-1"></i>@(Model.IntakeDate.HasValue ? "Update Intake" : "Complete Check-In")';
}
} catch (e) {
errDiv.textContent = 'Network error: ' + e.message;
errDiv.classList.remove('d-none');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-check-circle me-1"></i>@(Model.IntakeDate.HasValue ? "Update Intake" : "Complete Check-In")';
}
});
})();
</script>
@if ((bool)(ViewBag.IsAdminOrManager ?? false) && (bool)(ViewBag.SmsEnabled ?? false))
{
<script src="~/js/jobs-sms-compose.js" asp-append-version="true"></script>
<script>
(() => {
const pendingPreview = @Html.Raw(ViewBag.PendingSmsPreview != null
? System.Text.Json.JsonSerializer.Serialize((string)ViewBag.PendingSmsPreview)
: "null");
const jobIdForSms = @Model.Id;
const renderUrl = '@Url.Action("RenderJobSms", "Jobs")';
const sendUrl = '@Url.Action("SendJobSms", "Jobs")';
const customerOptedIn = @(Model.CustomerNotifyBySms ? "true" : "false");
window.__smsCompose = { pendingPreview, jobIdForSms, renderUrl, sendUrl, customerOptedIn };
})();
</script>
}
}
<!-- Save as Template Modal -->
<div class="modal fade" id="saveTemplateModal" tabindex="-1" aria-labelledby="saveTemplateModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form asp-controller="JobTemplates" asp-action="SaveJobAsTemplate" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="jobId" value="@Model.Id">
<div class="modal-header">
<h5 class="modal-title" id="saveTemplateModalLabel">
<i class="bi bi-layout-text-window-reverse me-2 text-primary"></i>Save as Template
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-3">
This will save <strong>@Model.JobNumber</strong>'s items, coatings, and prep services
as a reusable template for future jobs.
</p>
<div class="mb-3">
<label class="form-label fw-semibold">Template Name <span class="text-danger">*</span></label>
<input type="text" name="templateName" class="form-control" required maxlength="100"
placeholder="e.g. Wheel Refinish — Standard 4pc">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Description <span class="text-muted fw-normal">(optional)</span></label>
<textarea name="templateDescription" class="form-control" rows="2" maxlength="500"
placeholder="Brief description of when to use this template"></textarea>
</div>
<div class="alert alert-light border mb-0">
<small class="text-muted">
<i class="bi bi-info-circle me-1"></i>
The customer, special instructions, and all items with their coatings will be copied.
Dates, status, and pricing totals will not be copied.
</small>
</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">
<i class="bi bi-layout-text-window-reverse me-2"></i>Save Template
</button>
</div>
</form>
</div>
</div>
</div>