Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Jobs/Details.cshtml
T
spouliot 1eba50cf0f Add Custom Formula Item Templates with AI generation and wizard integration
Introduces per-company reusable NCalc2 pricing formula templates for complex
fabricated items (roof curbs, enclosures, welded frames). Templates support
two output modes — FixedRate (formula yields a dollar amount) and SurfaceAreaSqFt
(formula yields sq ft fed into the standard coating engine). Includes:

- CustomItemTemplate entity, migration (AddCustomItemTemplates), IUnitOfWork repo
- IsCustomFormulaItem / CustomItemTemplateId / FormulaFieldValuesJson flags on
  QuoteItem, JobItem, CreateQuoteItemDto; mapped in all 3 JobItemAssemblyService
  overloads and all existingItemsData JSON projections + pageMeta blocks
- ICustomFormulaAiService / CustomFormulaAiService: Claude-powered formula
  generator (natural language + optional diagram image) and NCalc2 evaluator
- CompanySettings CRUD endpoints: GetCustomItemTemplates, Create/Update/Delete,
  UploadTemplateDiagram, TemplateDiagram (blob serve), EvaluateFormula, GenerateFormulaFromAi
- Company Settings "Custom Formulas" tab + cfModal + company-settings-custom-formulas.js
- item-wizard.js: formula item type card, renderFormulaFields, wzFormulaRecalc
  (live evaluate via POST), collectStep2 formula branch, buildCardHtml / emitHiddenFields
- Formula badge in Quotes/Details and Jobs/Details; AI badge gap fixed in Jobs/Details
- Help article (CustomFormulaTemplates.cshtml), Help Index card, HelpController action,
  HelpKnowledgeBase entry; 225/225 unit tests passing

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

3362 lines
206 KiB
Plaintext

@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>
@if (Model.IsReworkJob && Model.OriginalJobId.HasValue)
{
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-3 mb-4">
<i class="bi bi-arrow-repeat fs-5 flex-shrink-0"></i>
<div>
<strong>Rework Job</strong> &mdash; This job was created to redo work from
<a asp-action="Details" asp-route-id="@Model.OriginalJobId" class="alert-link fw-semibold">
@(Model.OriginalJobNumber ?? $"Job #{Model.OriginalJobId}")
</a>.
All costs for this redo are tracked here separately from the original job.
</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 &mdash; 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&hellip;
</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&hellip;
</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="">&ndash; Unassigned &ndash;</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&hellip;
</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())
{
<div class="d-none d-lg-block">
<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 data-item-id="@item.Id">
<td>
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
@if (item.IsAiItem) { <span class="badge bg-purple ms-1">AI</span> }
@if (item.IsCustomFormulaItem) { <span class="badge bg-secondary ms-1"><i class="bi bi-calculator me-1"></i>Formula</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">
&bull; <strong>@coat.CoatName</strong>
@if (!string.IsNullOrEmpty(coat.ColorName))
{
<text> &ndash; @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 &mdash; 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">&bull; <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">&ndash; @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"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
<td class="text-end fw-semibold" data-line-total>@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>
</div>
}
@* -- Custom Work -- *@
@if (customItems.Any())
{
<div class="d-none d-lg-block">
<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
// Note: row has data-item-id for inline editing
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 data-item-id="@item.Id">
<td>
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
@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">
&bull; <strong>@coat.CoatName</strong>
@if (!string.IsNullOrEmpty(coat.ColorName))
{
<text> &ndash; @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 &mdash; 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">&bull; <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">&ndash; @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"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></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">&mdash;</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">&mdash;</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">&mdash;</span> }
</td>
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
<td class="text-end fw-semibold" data-line-total>@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>
</div>
}
@* -- Labor -- *@
@if (laborItems.Any())
{
<div class="d-none d-lg-block">
<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 data-item-id="@item.Id">
<td>
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
@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"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
<td class="text-center">
@if (item.EstimatedMinutes > 0)
{
<text>@item.EstimatedMinutes min</text>
}
else { <span class="text-muted">&mdash;</span> }
</td>
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
<td class="text-end fw-semibold" data-line-total>@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>
</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@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> &ndash; @coat.ColorName</text> }
@if (!string.IsNullOrEmpty(coat.Notes)) { <text><br /><span class="fst-italic text-muted ms-2">@coat.Notes</span></text> }
</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">&mdash;</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">&mdash;</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 d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation(); openLogMaterialModal();">
<i class="bi bi-plus-circle me-1"></i>Log Material
</button>
<a asp-controller="Inventory" asp-action="Scan" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation();" title="Scan QR code">
<i class="bi bi-qr-code-scan"></i>
</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.
Click <strong>Log Material</strong> above or scan the QR label on an inventory item.
</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>
<!-- Log Material Modal -->
<div class="modal fade" id="logMaterialModal" tabindex="-1" aria-labelledby="logMaterialModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="logMaterialModalLabel">
<i class="bi bi-droplet-half me-2 text-primary"></i>Log Material Usage
</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 fw-semibold">Inventory Item <span class="text-danger">*</span></label>
<div class="position-relative">
<div class="input-group">
<input type="text" class="form-control" id="lmItemSearch"
placeholder="Search by name or manufacturer&hellip;" autocomplete="off"
oninput="lmComboInput()"
onfocus="lmComboOpen()"
onkeydown="lmComboKey(event)">
<button class="btn btn-outline-secondary" type="button" tabindex="-1"
id="lmItemDropdownToggle" onclick="lmComboToggle()">
<i class="bi bi-chevron-down" style="font-size:.75rem;"></i>
</button>
</div>
<div id="lmItemDropdown"
style="display:none;max-height:220px;overflow-y:auto;z-index:1070;background:var(--bs-body-bg);color:var(--bs-body-color);border:1px solid var(--bs-border-color);border-radius:.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
</div>
</div>
<div id="lmItemBalance" class="form-text text-muted d-none"></div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Entry Method</label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodUsed" value="used" checked onchange="lmUpdateQuantityLabel()">
<label class="form-check-label" for="lmMethodUsed">Amount Used</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodRemaining" value="remaining" onchange="lmUpdateQuantityLabel()">
<label class="form-check-label" for="lmMethodRemaining">Amount Remaining</label>
</div>
</div>
</div>
<div class="mb-3">
<label id="lmQtyLabel" class="form-label fw-semibold">Quantity Used <span class="text-danger">*</span></label>
<input type="number" id="lmQuantity" class="form-control" min="0" step="0.01" placeholder="0.00">
<div id="lmComputedUsed" class="form-text text-muted d-none"></div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Reason</label>
<select id="lmTransactionType" class="form-select">
<option value="JobUsage">Job Usage</option>
<option value="Waste">Waste / Spillage</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Notes</label>
<textarea id="lmNotes" class="form-control" rows="2" placeholder="Optional"></textarea>
</div>
<div id="lmAlert" class="alert alert-permanent d-none"></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" id="lmSaveBtn" onclick="lmSave()">
<i class="bi bi-check-circle me-1"></i>Log Usage
</button>
</div>
</div>
</div>
</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 &ndash; 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 &mdash; 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-outline-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-outline-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>@Html.Raw(Model.IntakeDate.HasValue ? "Intake &#10003;" : "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 data-pb="itemsSubtotal">@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 ? $" &times; {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 data-pb="subtotalBeforeDiscount">@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 data-pb="taxAmount">@jobPb.TaxAmount.ToString("C")</strong>
</div>
}
<hr />
<div class="d-flex justify-content-between mb-3">
<h5>Total:</h5>
<h5 class="text-primary"><strong class="job-final-price-display">@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 &mdash; 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 data-pb="itemsSubtotal">@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 &times; 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 data-pb="subtotalBeforeDiscount">@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 data-pb="subtotalAfterDiscount">@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 data-pb="taxAmount">@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 class="job-final-price-display">@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 job-final-price-display">@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">&mdash;</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">&mdash;</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">&mdash;</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">&mdash;</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">&mdash;</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">&mdash;</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">&mdash;</span>
</div>
<div class="d-flex justify-content-between fw-bold mb-1">
<span>Gross Profit</span>
<span id="costingProfit">&mdash;</span>
</div>
<div class="d-flex justify-content-between small text-muted mb-1">
<span>Gross Margin</span>
<span id="costingMargin">&mdash;</span>
</div>
<div class="d-flex justify-content-between small text-muted">
<span>Margin vs Quote</span>
<span id="costingQuotedMargin">&mdash;</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">&ndash; 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-outline-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">&ndash; 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-outline-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-outline-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&hellip;" 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 &mdash; 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-outline-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>
@await Html.PartialAsync("_SqFtCalculatorModal")
@await Html.PartialAsync("_ItemWizardModal")
<!-- 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">
<!-- Step 1: Are parts back in the shop? -->
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="rwCreateJobToggle" onchange="rework.toggleCreateJob(this.checked)" />
<label class="form-check-label fw-semibold" for="rwCreateJobToggle">
<i class="bi bi-briefcase me-1"></i>Parts are back &mdash; create a Rework Job in the shop
</label>
<div class="text-muted small">Turn this on if the parts are physically in the shop and need to go back through the workflow.</div>
</div>
</div>
<!-- Item selection: checkboxes when creating a job, single dropdown otherwise -->
<div id="rwCreateJobOptions" style="display:none;" class="col-12">
<div class="border rounded p-3 bg-light">
<div class="mb-3">
<label class="form-label fw-semibold mb-1">Which items need to be redone? <span class="text-danger">*</span></label>
<div class="text-muted small mb-2">Only checked items will be copied to the rework job.</div>
<div id="rwItemCheckboxes">
@if (Model.Items != null)
{
@foreach (var item in Model.Items)
{
<div class="form-check">
<input class="form-check-input rw-item-cb" type="checkbox" value="@item.Id" id="rwItem_@item.Id" />
<label class="form-check-label" for="rwItem_@item.Id">@item.Description</label>
</div>
}
}
</div>
</div>
<div>
<label class="form-label fw-semibold mb-1">Who is responsible? <span class="text-danger">*</span></label>
<div class="row g-2">
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingShopFault" value="0" />
<label class="form-check-label" for="rwPricingShopFault">
<strong>Shop Fault</strong>
<span class="text-muted small d-block">Our mistake &mdash; rework job priced at $0.</span>
</label>
</div>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingCustomerReduced" value="1" />
<label class="form-check-label" for="rwPricingCustomerReduced">
<strong>Customer &mdash; Reduced Rate</strong>
<span class="text-muted small d-block">Customer caused it but we&rsquo;re helping out &mdash; prices copied, edit after creation.</span>
</label>
</div>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingCustomerFull" value="2" />
<label class="form-check-label" for="rwPricingCustomerFull">
<strong>Customer &mdash; Full Price</strong>
<span class="text-muted small d-block">Customer caused it &mdash; original pricing applies.</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="rwSpecificItemRow" class="col-md-6">
<label class="form-label">Specific Item (optional)</label>
<select class="form-select" id="rwJobItem">
<option value="">&ndash; Whole Job &ndash;</option>
@if (Model.Items != null)
{
@foreach (var item in Model.Items)
{
<option value="@item.Id">@item.Description</option>
}
}
</select>
</div>
<hr class="my-0" />
<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">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="">&ndash; Pending &ndash;</option>
<option value="0">Recoated &mdash; No Charge</option>
<option value="1">Recoated &mdash; 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-outline-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="">&ndash; Select worker &ndash;</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&hellip;" 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&hellip;"></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 Styles {
<link rel="stylesheet" href="~/css/item-wizard.css">
}
@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 src="~/js/inline-item-edit.js" asp-append-version="true"></script>
<script>
window.inlineItemEdit = {
patchUrl: '@Url.Action("PatchItem", "Jobs")',
canEdit: true,
totals: {
itemsSubtotal: '[data-pb="itemsSubtotal"]',
subtotalBeforeDiscount: '[data-pb="subtotalBeforeDiscount"]',
subtotalAfterDiscount: '[data-pb="subtotalAfterDiscount"]',
taxAmount: '[data-pb="taxAmount"]',
finalPrice: '.job-final-price-display'
},
afterSave: () => { if (typeof costing !== 'undefined') costing.load(); }
};
</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>
<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} &mdash; ${new Date(r.discoveredDate).toLocaleDateString()}
${r.reportedByName ? '&ndash; ' + 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 ? ' &mdash; $' + 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];
// Reset rework job creation section
document.getElementById('rwCreateJobToggle').checked = false;
document.getElementById('rwCreateJobOptions').style.display = 'none';
document.querySelectorAll('.rw-item-cb').forEach(cb => cb.checked = false);
document.querySelectorAll('input[name="rwPricingType"]').forEach(r => r.checked = false);
modal.show();
}
function toggleCreateJob(on) {
document.getElementById('rwCreateJobOptions').style.display = on ? '' : 'none';
document.getElementById('rwSpecificItemRow').style.display = on ? 'none' : '';
if (!on) document.getElementById('rwJobItem').value = '';
}
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 createJob = document.getElementById('rwCreateJobToggle').checked;
const selectedItemIds = createJob
? Array.from(document.querySelectorAll('.rw-item-cb:checked')).map(cb => parseInt(cb.value))
: null;
const pricingRadio = document.querySelector('input[name="rwPricingType"]:checked');
if (createJob && (!selectedItemIds || selectedItemIds.length === 0)) {
alert('Select at least one item to include in the rework job.'); return;
}
if (createJob && !pricingRadio) {
alert('Select who is responsible for this rework.'); return;
}
const dto = {
jobId: jid,
jobItemId: createJob ? null : (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,
createReworkJob: createJob,
reworkJobItemIds: selectedItemIds,
reworkPricingType: pricingRadio ? parseInt(pricingRadio.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, toggleCreateJob };
})();
</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'} &ndash; ${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)})` : '&mdash;';
// 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 &times; ${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 ? ' &ndash; ' + l.stage : ''}<br/><small>${l.workDate}</small></td>
<td class="text-end text-nowrap">${l.hours}h &times; ${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 &mdash; 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">&mdash;</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' : '&mdash;';
document.getElementById('totalHoursDisplay').innerHTML = fmt;
document.getElementById('timeEntriesTotalHours').innerHTML = total > 0 ? total.toFixed(2) : '&mdash;';
}
// -- 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();
costing.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(); costing.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&hellip;'; }
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>
}
}
<!-- Log Material Modal JS -->
<script src="/js/log-material.js"></script>
<script>
(function () {
const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]");
const jobPowderIds = @Html.Raw(ViewBag.JobPowderIds ?? "[]");
const jobId = @Model.Id;
const logUrl = '@Url.Action("LogMaterial", "Jobs")';
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
window.__logMaterial = { inventoryItems, jobPowderIds, jobId, logUrl, token };
})();
</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 &mdash; 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 alert-permanent 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>