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