Replace literal Unicode special chars with HTML entities across all 233 views

Sweeps em dashes, en dashes, multiplication signs, ellipses, and curly quotes
to their HTML entity equivalents (— – × … ‘ ’)
in all .cshtml files, skipping <script> blocks. Prevents encoding corruption
from AI tools and Windows encoding mismatches that caused recurring symbol bugs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 19:16:17 -04:00
parent cefdf3e35c
commit 3eda91f170
233 changed files with 0 additions and 72627 deletions
@@ -1,695 +0,0 @@
@using PowderCoating.Shared.Constants
@using PowderCoating.Web.Controllers
@using PowderCoating.Web.ViewModels.GuidedActivation
@model List<JobBoardColumn>
@{
ViewData["Title"] = "Jobs Board";
bool showTerminal = ViewBag.ShowTerminal == true;
int totalTerminal = (int)(ViewBag.TotalTerminal ?? 0);
var guidedActivationCallout = ViewBag.GuidedActivationCallout as GuidedActivationCalloutViewModel;
string? guidedActivation = ViewBag.GuidedActivation as string;
int? highlightJobId = ViewBag.GuidedActivationHighlightJobId is int highlightedId ? highlightedId : null;
var highlightedCard = highlightJobId.HasValue
? Model.SelectMany(c => c.Jobs).FirstOrDefault(j => j.Id == highlightJobId.Value)
: null;
}
@section Styles {
<style>
.board-outer {
display: flex;
flex-direction: column;
height: calc(100vh - 130px);
min-height: 500px;
}
.board-toolbar { flex-shrink: 0; padding-bottom: .75rem; }
.board-scroll {
flex: 1;
overflow-x: auto;
overflow-y: hidden;
display: flex;
gap: .75rem;
padding-bottom: .5rem;
align-items: flex-start;
}
/* Column */
.board-column {
flex-shrink: 0;
width: 240px;
display: flex;
flex-direction: column;
max-height: 100%;
background: var(--pcl-paper-2);
border-radius: var(--radius-lg);
border: 1px solid var(--pcl-rule);
overflow: hidden;
}
.board-column-header {
padding: .55rem .75rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: .5rem;
flex-shrink: 0;
border-bottom: 1px solid var(--pcl-rule);
background: var(--pcl-card);
}
.board-column-header-label {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--pcl-steel);
flex-grow: 1;
}
.col-count {
font-family: var(--font-mono);
font-size: 10px;
background: var(--pcl-rule);
color: var(--pcl-steel);
border-radius: 99px;
padding: 1px 7px;
}
.board-column-body {
flex: 1;
overflow-y: auto;
padding: .5rem .5rem .75rem;
min-height: 80px;
}
/* Oven meter strip (Curing column only) */
.board-oven-meter {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 12px;
background: var(--pcl-ember-tint);
border-bottom: 1px solid var(--pcl-rule);
font-family: var(--font-mono);
font-size: 10px;
color: var(--pcl-ember-ink);
}
.board-oven-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--pcl-ember);
flex-shrink: 0;
animation: pcl-pulse 1.5s infinite;
}
/* Cards */
.board-card {
background: var(--pcl-card);
border-radius: var(--radius);
border: 1px solid var(--pcl-rule);
margin-bottom: .5rem;
padding: .6rem .7rem;
cursor: grab;
transition: background 0.1s;
text-decoration: none;
color: var(--pcl-ink);
display: block;
position: relative;
}
.board-card:hover { background: var(--pcl-paper-2); color: var(--pcl-ink); }
.board-card.board-card-hot { box-shadow: inset 2px 0 0 var(--pcl-bad); }
.board-card.board-card-guided {
border-color: var(--pcl-cool);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--pcl-cool) 24%, transparent);
background: color-mix(in srgb, var(--pcl-cool) 8%, var(--pcl-card));
}
.board-card.dragging { opacity: .5; cursor: grabbing; }
.board-guided-badge {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--pcl-cool);
}
/* Card content */
.card-job-number { font-family: var(--font-mono); font-weight: 500; font-size: .8rem; color: var(--pcl-ink); }
.card-customer { font-size: .75rem; color: var(--pcl-steel); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-desc { font-size: .72rem; color: var(--pcl-steel); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: .15rem; }
.card-footer-row {
display: flex; align-items: center; gap: .4rem; margin-top: .4rem;
flex-wrap: wrap; padding-top: .35rem;
border-top: 1px solid var(--pcl-rule-soft);
}
/* Priority dot in card footer */
.board-priority-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.board-priority-danger { background: var(--pcl-bad); }
.board-priority-warning { background: var(--pcl-warn); }
.board-priority-success { background: var(--pcl-ok); }
.board-priority-info { background: var(--pcl-cool); }
.board-priority-dark, .board-priority-secondary { background: var(--pcl-mute); }
/* Drag / ghost */
.board-column-body.drag-over { background: var(--pcl-ember-tint); border-radius: var(--radius); }
.sortable-ghost {
opacity: .3;
background: var(--pcl-ember-tint);
border: 2px dashed var(--pcl-ember);
}
.card-saving { pointer-events: none; }
.card-saving::after {
content: ''; position: absolute; inset: 0;
background: var(--pcl-card); opacity: .6; border-radius: var(--radius);
}
/* Hidden / dropdown */
.board-column.col-hidden { display: none; }
.col-visibility-menu { min-width: 200px; padding: .5rem 0; }
.col-visibility-menu .form-check { padding: .3rem 1rem; margin: 0; cursor: pointer; }
.col-visibility-menu .form-check:hover { background: var(--pcl-paper-2); }
.col-visibility-menu .form-check-label { cursor: pointer; width: 100%; }
.col-color-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: .4rem; flex-shrink: 0; }
/* Column drag handle */
.col-drag-handle { cursor: grab; opacity: 0; transition: opacity .15s; flex-shrink: 0; line-height: 1; }
.board-column-header:hover .col-drag-handle { opacity: .5; }
.col-drag-handle:hover { opacity: 1 !important; }
.col-sortable-ghost { opacity: .35; outline: 2px dashed var(--pcl-ember); border-radius: var(--radius-lg); }
/* Segmented view switch */
.board-view-switch { display: flex; border: 1px solid var(--pcl-rule); border-radius: var(--radius); overflow: hidden; }
.board-view-switch a {
padding: 4px 12px; font-size: 13px; font-weight: 500;
text-decoration: none; color: var(--pcl-steel);
border-right: 1px solid var(--pcl-rule);
background: var(--pcl-card);
transition: background 0.1s, color 0.1s;
}
.board-view-switch a:last-child { border-right: none; }
.board-view-switch a:hover { background: var(--pcl-paper-2); color: var(--pcl-ink); }
.board-view-switch a.active { background: var(--pcl-ink); color: var(--pcl-paper); }
</style>
}
<div class="board-outer">
@if (guidedActivationCallout?.Show == true)
{
<div class="alert alert-info alert-permanent border-0 shadow-sm mb-3">
<div class="d-flex flex-column flex-xl-row gap-3 align-items-xl-center justify-content-between">
<div>
<div class="fw-semibold mb-1">@guidedActivationCallout.Title</div>
<div>@guidedActivationCallout.Message</div>
@if (!string.IsNullOrWhiteSpace(guidedActivationCallout.InstructionText))
{
<div class="fw-semibold mt-2 small" style="color:var(--pcl-ink);">
<i class="bi bi-arrow-right-circle me-1"></i>@guidedActivationCallout.InstructionText
</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>
}
@* Toolbar *@
@{
var _totalOnFloor = Model.Sum(c => c.Jobs.Count);
var _hotCount = Model.Sum(c => c.Jobs.Count(j => j.IsOverdue));
}
<div class="board-toolbar">
<div class="d-flex align-items-center justify-content-between gap-2 flex-wrap">
@* Left: view switch + live stats *@
<div class="d-flex align-items-center gap-3">
<div class="board-view-switch">
<a asp-action="Board"
asp-route-showTerminal="@showTerminal"
asp-route-guidedActivation="@guidedActivation"
asp-route-highlightJobId="@highlightJobId"
class="active">Board</a>
<a asp-action="Index">List</a>
</div>
<span class="mono" style="font-size:.75rem;color:var(--pcl-steel)">
@_totalOnFloor job@(_totalOnFloor == 1 ? "" : "s") on floor
@if (_hotCount > 0)
{
<span style="color:var(--pcl-bad)"> &middot; @_hotCount overdue</span>
}
</span>
</div>
@* Right: actions *@
<div class="d-flex align-items-center gap-2">
<a href="@Url.Action("Board", new { showTerminal = !showTerminal, guidedActivation, highlightJobId })"
class="btn btn-sm btn-outline-secondary"
id="toggleTerminalBtn">
<i class="bi bi-archive me-1"></i>
@if (showTerminal) { <text>Hide Completed</text> }
else if (totalTerminal > 0) { <text>Completed (@totalTerminal)</text> }
else { <text>Completed</text> }
</a>
@* Column visibility dropdown *@
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button"
id="colVisBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside"
aria-expanded="false">
<i class="bi bi-layout-three-columns me-1"></i>Columns
<span class="badge bg-secondary ms-1" id="hiddenColCount" style="display:none"></span>
</button>
<ul class="dropdown-menu col-visibility-menu shadow" aria-labelledby="colVisBtn">
<li class="px-3 pb-1 pt-2">
<small class="text-muted fw-semibold text-uppercase" style="font-size:.7rem;letter-spacing:.04em">Show / Hide Columns</small>
</li>
<li><hr class="dropdown-divider my-1"></li>
@foreach (var col in Model)
{
<li>
<div class="form-check col-vis-item">
<input class="form-check-input col-vis-check" type="checkbox"
id="vis-@col.StatusId"
data-status-id="@col.StatusId"
checked />
<label class="form-check-label d-flex align-items-center" for="vis-@col.StatusId">
<span class="col-color-dot" style="background:var(--pcl-steel)"></span>
@col.DisplayName
<span class="ms-auto text-muted ps-2 mono" style="font-size:.72rem">@col.Jobs.Count</span>
</label>
</div>
</li>
}
<li><hr class="dropdown-divider my-1"></li>
<li class="px-3 pb-1">
<button type="button" class="btn btn-xs btn-link p-0 text-muted small" id="showAllCols">
Show all
</button>
</li>
</ul>
</div>
<a asp-action="Create" class="btn btn-sm btn-primary">
<i class="bi bi-plus-circle me-1"></i>New Job
</a>
</div>
</div>
</div>
@* Board columns *@
<div class="board-scroll" id="boardScroll">
@foreach (var col in Model)
{
var isOvenPhase = col.DisplayName is "In Oven" or "Coating" or "Curing";
<div class="board-column"
data-status-id="@col.StatusId"
data-status-code="@col.StatusCode"
data-status-name="@col.DisplayName">
<div class="board-column-header">
<span class="col-drag-handle" title="Drag to reorder column">
<i class="bi bi-grip-vertical"></i>
</span>
<span class="board-column-header-label">@col.DisplayName</span>
<span class="col-count">@col.Jobs.Count</span>
</div>
@if (isOvenPhase && col.Jobs.Count > 0)
{
<div class="board-oven-meter">
<span class="board-oven-dot"></span>
<span>@col.Jobs.Count ACTIVE</span>
</div>
}
<div class="board-column-body" id="col-@col.StatusId">
@foreach (var card in col.Jobs)
{
var priorityDotClass = card.PriorityColorClass switch {
"danger" => "board-priority-danger",
"warning" => "board-priority-warning",
"success" => "board-priority-success",
"info" => "board-priority-info",
_ => "board-priority-secondary"
};
<a href="@Url.Action("Details", new { id = card.Id })"
class="board-card@(card.IsOverdue ? " board-card-hot" : "")@(highlightJobId == card.Id ? " board-card-guided" : "")"
data-job-id="@card.Id"
onclick="return false">
<div class="d-flex align-items-start justify-content-between gap-1">
<span class="card-job-number">@card.JobNumber</span>
@if (highlightJobId == card.Id)
{
<span class="board-guided-badge">Guided Job</span>
}
</div>
<div class="card-customer">@card.CustomerName</div>
@if (!string.IsNullOrEmpty(card.Description))
{
<div class="card-desc">@(card.Description.Length > 60 ? card.Description[..60] + "…" : card.Description)</div>
}
<div class="card-footer-row">
<span class="board-priority-dot @priorityDotClass" title="@card.PriorityDisplayName"></span>
@if (card.IsOverdue)
{
<span class="mono" style="font-size:.7rem;color:var(--pcl-bad);font-weight:600">@card.DueDate!.Value.ToString("MMM d")</span>
}
else if (card.DueDate.HasValue)
{
<span class="mono" style="font-size:.7rem;color:var(--pcl-steel)">@card.DueDate.Value.ToString("MMM d")</span>
}
@if (!string.IsNullOrEmpty(card.AssignedWorkerName))
{
<span class="ms-auto mono" style="font-size:.68rem;color:var(--pcl-steel)" title="@card.AssignedWorkerName">
@card.AssignedWorkerName.Split(' ')[0]
</span>
}
else if (card.FinalPrice > 0)
{
<span class="ms-auto mono" style="font-size:.68rem;color:var(--pcl-steel)">@card.FinalPrice.ToString("C0")</span>
}
</div>
</a>
}
</div>
</div>
}
</div>
</div>
@* Toast for move feedback *@
<div class="position-fixed bottom-0 end-0 p-3" style="z-index:1100">
<div id="moveToast" class="toast align-items-center border-0" role="alert" aria-live="assertive">
<div class="d-flex">
<div class="toast-body" id="moveToastMsg"></div>
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>
<!-- Container for the dynamically loaded Mark Complete modal -->
<div id="completeJobModalContainer"></div>
@section Scripts {
<script src="~/lib/sortablejs/Sortable.min.js"></script>
<script>
(function () {
const COMPANY_ID = '@(User.FindFirst("CompanyId")?.Value ?? "0")';
const guidedActivation = '@(guidedActivation ?? string.Empty)';
const highlightJobId = @(highlightJobId?.ToString() ?? "null");
// ── Show Completed persistence ───────────────────────────────────────────
const TERMINAL_KEY = `jobBoard_showTerminal_${COMPANY_ID}`;
const currentTerminal = @(showTerminal ? "true" : "false");
const storedTerminal = localStorage.getItem(TERMINAL_KEY) === 'true';
// On first load, if stored preference differs from current URL state, redirect
if (storedTerminal !== currentTerminal && !sessionStorage.getItem('boardTerminalRedirected')) {
sessionStorage.setItem('boardTerminalRedirected', '1');
window.location.href = '@Url.Action("Board")' + (storedTerminal ? '?showTerminal=true' : '');
} else {
sessionStorage.removeItem('boardTerminalRedirected');
}
// Save preference when user clicks the toggle
document.getElementById('toggleTerminalBtn')?.addEventListener('click', function () {
localStorage.setItem(TERMINAL_KEY, String(!currentTerminal));
});
// ── Column visibility ────────────────────────────────────────────────────
const STORAGE_KEY = `jobBoard_hiddenCols_${COMPANY_ID}`;
const hiddenColCountBadge = document.getElementById('hiddenColCount');
function loadHiddenCols() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); }
catch { return []; }
}
function saveHiddenCols(ids) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
}
function applyVisibility() {
const hidden = loadHiddenCols();
let hiddenCount = 0;
document.querySelectorAll('.board-column').forEach(col => {
const id = parseInt(col.dataset.statusId);
const isHidden = hidden.includes(id);
col.classList.toggle('col-hidden', isHidden);
if (isHidden) hiddenCount++;
});
document.querySelectorAll('.col-vis-check').forEach(cb => {
cb.checked = !hidden.includes(parseInt(cb.dataset.statusId));
});
if (hiddenCount > 0) {
hiddenColCountBadge.textContent = hiddenCount;
hiddenColCountBadge.style.display = '';
} else {
hiddenColCountBadge.style.display = 'none';
}
}
function ensureGuidedCardVisible() {
if (!highlightJobId) return;
const card = document.querySelector(`.board-card[data-job-id="${highlightJobId}"]`);
if (!card) return;
const column = card.closest('.board-column');
if (column?.classList.contains('col-hidden')) {
const statusId = parseInt(column.dataset.statusId);
const hidden = loadHiddenCols();
const idx = hidden.indexOf(statusId);
if (idx > -1) {
hidden.splice(idx, 1);
saveHiddenCols(hidden);
applyVisibility();
}
}
card.classList.add('board-card-guided');
card.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}
document.querySelectorAll('.col-vis-check').forEach(cb => {
cb.addEventListener('change', () => {
const id = parseInt(cb.dataset.statusId);
const hidden = loadHiddenCols();
if (cb.checked) {
const idx = hidden.indexOf(id);
if (idx > -1) hidden.splice(idx, 1);
} else {
if (!hidden.includes(id)) hidden.push(id);
}
saveHiddenCols(hidden);
applyVisibility();
});
});
document.getElementById('showAllCols').addEventListener('click', () => {
saveHiddenCols([]);
applyVisibility();
});
// Apply on load
applyVisibility();
// ── Column reordering ────────────────────────────────────────────────────
const COL_ORDER_KEY = `jobBoard_colOrder_${COMPANY_ID}`;
function loadColOrder() {
try { return JSON.parse(localStorage.getItem(COL_ORDER_KEY) || '[]'); }
catch { return []; }
}
function saveColOrder() {
const order = [...document.querySelectorAll('#boardScroll .board-column')]
.map(c => parseInt(c.dataset.statusId));
localStorage.setItem(COL_ORDER_KEY, JSON.stringify(order));
}
function applyColOrder() {
const order = loadColOrder();
if (!order.length) return;
const boardScroll = document.getElementById('boardScroll');
order.forEach(id => {
const col = boardScroll.querySelector(`.board-column[data-status-id="${id}"]`);
if (col) boardScroll.appendChild(col); // moves to end in saved sequence
});
}
Sortable.create(document.getElementById('boardScroll'), {
animation: 150,
handle: '.col-drag-handle',
draggable: '.board-column',
ghostClass: 'col-sortable-ghost',
onEnd() { saveColOrder(); }
});
applyColOrder();
ensureGuidedCardVisible();
// ── Drag & drop + card navigation ────────────────────────────────────────
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value
?? document.cookie.split('; ').find(c => c.startsWith('XSRF-TOKEN'))?.split('=')[1] ?? '';
const toast = new bootstrap.Toast(document.getElementById('moveToast'), { delay: 2500 });
const toastMsg = document.getElementById('moveToastMsg');
function showToast(msg, success) {
const el = document.getElementById('moveToast');
el.classList.remove('bg-success', 'bg-danger', 'text-white');
el.classList.add(success ? 'bg-success' : 'bg-danger', 'text-white');
toastMsg.textContent = msg;
toast.show();
}
// Initialise SortableJS on every column body
document.querySelectorAll('.board-column-body').forEach(colEl => {
Sortable.create(colEl, {
group: 'board', // shared group = drag between columns
animation: 150,
ghostClass: 'sortable-ghost',
dragClass: 'dragging',
onEnd(evt) {
const card = evt.item;
const jobId = parseInt(card.dataset.jobId);
const newColEl = evt.to;
const newColWrapper = newColEl.closest('.board-column');
const newStatus = parseInt(newColWrapper.dataset.statusId);
const newStatusCode = newColWrapper.dataset.statusCode;
const oldColEl = evt.from;
// No-op if dropped in same column
if (newColEl === oldColEl && evt.newIndex === evt.oldIndex) return;
// Completing a job requires the full completion flow (time, powder, email/SMS).
// Fetch the Mark Complete modal partial and show it inline so the user
// never leaves the board.
if (newStatusCode === 'COMPLETED') {
oldColEl.insertBefore(card, oldColEl.children[evt.oldIndex] ?? null);
updateCount(oldColEl);
updateCount(newColEl);
openCompleteModal(jobId);
return;
}
// Update column counts immediately
updateCount(oldColEl);
updateCount(newColEl);
// AJAX move
card.style.opacity = '.5';
card.style.pointerEvents = 'none';
fetch('@Url.Action("MoveCard")', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': token
},
body: JSON.stringify({
jobId,
newStatusId: newStatus,
guidedActivation,
highlightJobId
})
})
.then(r => r.json())
.then(data => {
if (data.success) {
// Update card's priority border stays — status shown by column
showToast(`Moved to ${data.newStatusDisplay}`, true);
if (data.guidedActivationNext && highlightJobId && jobId === highlightJobId) {
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.set('guidedActivation', data.guidedActivationNext);
nextUrl.searchParams.set('highlightJobId', String(highlightJobId));
setTimeout(() => { window.location.href = nextUrl.toString(); }, 700);
return;
}
} else {
// Revert
oldColEl.insertBefore(card, oldColEl.children[evt.oldIndex] ?? null);
updateCount(oldColEl);
updateCount(newColEl);
showToast(data.message || 'Move failed', false);
}
})
.catch(() => {
oldColEl.insertBefore(card, oldColEl.children[evt.oldIndex] ?? null);
updateCount(oldColEl);
updateCount(newColEl);
showToast('Network error — move reverted', false);
})
.finally(() => {
card.style.opacity = '';
card.style.pointerEvents = '';
});
},
onMove(evt) {
evt.related.classList.remove('drag-over');
}
});
});
// Make cards navigate to Details on click (not drag)
document.querySelectorAll('.board-card').forEach(card => {
let moved = false;
card.addEventListener('mousedown', () => { moved = false; });
card.addEventListener('mousemove', () => { moved = true; });
card.addEventListener('click', () => {
if (!moved) window.location = card.href;
});
});
function updateCount(colEl) {
const col = colEl.closest('.board-column');
const badge = col.querySelector('.col-count');
if (badge) badge.textContent = colEl.querySelectorAll('.board-card').length;
}
function openCompleteModal(jobId) {
const container = document.getElementById('completeJobModalContainer');
fetch('@Url.Action("CompleteJobModal", "Jobs")' + '?id=' + jobId, {
headers: { 'RequestVerificationToken': token }
})
.then(r => {
if (!r.ok) throw new Error('Failed to load modal');
return r.text();
})
.then(html => {
container.innerHTML = html;
const modalEl = container.querySelector('.modal');
if (!modalEl) return;
const modal = new bootstrap.Modal(modalEl);
modalEl.addEventListener('hidden.bs.modal', () => { container.innerHTML = ''; }, { once: true });
modal.show();
})
.catch(() => showToast('Could not load completion form. Open the job to complete it.', false));
}
})();
</script>
}
@@ -1,447 +0,0 @@
@model PowderCoating.Application.DTOs.Job.CreateJobDto
@using PowderCoating.Core.Entities
@{
ViewData["Title"] = "Create Job";
ViewData["PageIcon"] = "bi-plus-circle";
}
<div class="container-fluid mt-4">
<div class="d-flex justify-content-end mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to List
</a>
</div>
@if (ViewBag.TemplateName != null)
{
<div class="alert alert-primary alert-dismissible d-flex align-items-center gap-2 mb-4" role="alert">
<i class="bi bi-layout-text-window-reverse fs-5"></i>
<div>
Pre-filled from template <strong>@ViewBag.TemplateName</strong>.
Items and coatings have been loaded — review and adjust before saving.
</div>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert"></button>
</div>
}
<form asp-action="Create" method="post" id="jobCreateForm">
@Html.AntiForgeryToken()
<input type="hidden" name="guidedActivation" value="@ViewBag.GuidedActivation" />
@if (ViewBag.TemplateId != null)
{
<input type="hidden" name="SourceTemplateId" value="@ViewBag.TemplateId">
}
<partial name="_ValidationSummary" />
@if ((ViewBag.GuidedActivation as string) == PowderCoating.Shared.Constants.AppConstants.GuidedActivation.JobFirstPath)
{
<div class="alert alert-primary alert-permanent border-0 shadow-sm mb-4">
<div class="fw-semibold mb-1">Step 1: Create your first sample job</div>
<div>We've prefilled a quick example. You can edit anything before saving.</div>
</div>
}
<!-- Job Details Card -->
<div class="card mb-4">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Job Details</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Job Details"
data-bs-content="Core job information. Priority and due date are visible on the shop floor board and affect how work is sorted. Customer PO is the customer's own reference number for their purchase order — include it so it appears on invoices. Special Instructions go directly to the shop floor worker.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label asp-for="CustomerId" class="form-label">Customer <span class="text-danger">*</span></label>
<select asp-for="CustomerId" class="form-select" asp-items="@ViewBag.Customers" id="customerSelect">
<option value="">-- Select Customer --</option>
</select>
<span asp-validation-for="CustomerId" class="text-danger"></span>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center gap-1">
<label asp-for="JobPriorityId" class="form-label mb-0">Priority</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Job Priority"
data-bs-content="Controls sort order on the shop floor board and job list. Rush and Urgent jobs are highlighted in red/orange. Normal is the default. Raise priority only when the customer has an actual deadline constraint — overuse of Rush dilutes its meaning for the shop floor team.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<select asp-for="JobPriorityId" class="form-select" asp-items="@ViewBag.Priorities"></select>
<span asp-validation-for="JobPriorityId" class="text-danger"></span>
</div>
<div class="col-12">
<label asp-for="Description" class="form-label">Description <span class="text-danger">*</span></label>
<textarea asp-for="Description" class="form-control" rows="3" placeholder="Enter job description"></textarea>
<span asp-validation-for="Description" class="text-danger"></span>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center gap-1">
<label asp-for="ScheduledDate" class="form-label mb-0">Scheduled Date</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Scheduled Date"
data-bs-content="The date the shop plans to start work on this job. Used for planning and the shop floor board. Does not affect customer-facing documents.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="ScheduledDate" type="date" class="form-control" />
</div>
<div class="col-md-6">
<div class="d-flex align-items-center gap-1">
<label asp-for="DueDate" class="form-label mb-0">Due Date</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Due Date"
data-bs-content="The customer's deadline — when the work must be ready for pickup or delivery. Overdue jobs (past due date and not yet completed) are highlighted in red on the job list.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="DueDate" type="date" class="form-control" />
</div>
<div class="col-md-6">
<label asp-for="AssignedUserId" class="form-label">Assigned Worker</label>
<select asp-for="AssignedUserId" class="form-select" asp-items="@ViewBag.Workers">
<option value="">Not assigned</option>
</select>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center gap-1">
<label asp-for="CustomerPO" class="form-label mb-0">Customer PO</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Customer PO"
data-bs-content="The customer's Purchase Order number from their own system. This prints on invoices and quotes so the customer can match it to their records. Required by many commercial customers for payment processing.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
</div>
<div class="col-md-7">
<div class="d-flex align-items-center gap-1">
<label asp-for="SpecialInstructions" class="form-label mb-0">Special Instructions</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Special Instructions"
data-bs-content="Free-text notes visible to the shop floor worker on the work order. Use this for masking requirements, handling notes, customer preferences, or anything that doesn't fit in the item-level notes — e.g., 'Keep brackets separated, customer allergic to zinc primer'.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<textarea asp-for="SpecialInstructions" class="form-control" rows="3" placeholder="Any special instructions"></textarea>
</div>
<div class="col-md-5">
<label class="form-label fw-semibold">Tags <span class="badge rounded-pill bg-warning text-dark ms-1" style="font-size:0.65rem;">Recommended</span></label>
<p class="text-muted small mb-1">Tags are used to improve smart predictions and assistance over time. The more consistently you tag, the smarter the system gets.</p>
<input type="hidden" asp-for="Tags" id="jobTags" />
<div id="jobTagsContainer"></div>
<small class="text-muted">Press <kbd>Enter</kbd> or <kbd>,</kbd> to add a tag. Click &times; to remove.</small>
</div>
<div class="col-md-6">
<div class="form-check mt-2">
<input asp-for="RequiresCustomerApproval" class="form-check-input" type="checkbox" />
<label class="form-check-label" asp-for="RequiresCustomerApproval">
Requires Customer Approval Before Starting
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Preparation Services -->
@if (ViewBag.PrepServices != null && ((List<PrepService>)ViewBag.PrepServices).Any())
{
<div class="card mb-4">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-tools me-2"></i>Preparation Services</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Preparation Services"
data-bs-content="Job-level prep services that apply to all items (e.g., full-job sandblasting, chemical strip). For prep that applies to only one item, use the per-item prep settings in the item wizard instead.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<div class="row g-3">
@foreach (var service in (List<PrepService>)ViewBag.PrepServices)
{
var isChecked = Model.PrepServiceIds != null && Model.PrepServiceIds.Contains(service.Id);
<div class="col-sm-6 col-lg-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="PrepServiceIds" value="@service.Id" id="prepService_@service.Id" @(isChecked ? "checked" : "")>
<label class="form-check-label" for="prepService_@service.Id">
<strong>@service.ServiceName</strong>
@if (!string.IsNullOrWhiteSpace(service.Description))
{
<small class="text-muted d-block">@service.Description</small>
}
</label>
</div>
</div>
}
</div>
</div>
</div>
}
<!-- Items Card -->
<div class="card mb-4">
<div class="card-header d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Job Items</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Job Items"
data-bs-content="Each item represents a physical piece being coated. Use the wizard to pick from the catalog, enter custom dimensions, or upload a photo for AI analysis. Each item gets its own coating specification — color, powder, finish, and cure details. You can add multiple coating passes per item for multi-color or primer+topcoat work.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<button type="button" class="btn btn-primary" onclick="openWizard()">
<i class="bi bi-plus-circle me-1"></i>Add Item
</button>
</div>
<div class="card-body">
<div id="itemsEmptyMessage" class="text-center py-5 text-muted">
<i class="bi bi-box-seam display-4 d-block mb-2 opacity-25"></i>
<p class="mb-0">No items added yet.</p>
<p class="small">Click <strong>Add Item</strong> to get started.</p>
</div>
<div id="itemCardsContainer"></div>
<div id="hiddenFieldsContainer"></div>
<div id="aiPhotoTempIdsContainer"></div>
</div>
</div>
<!-- Pricing Options (Rush / Discount) -->
<div class="card mb-4">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-tag me-2"></i>Pricing Options <small class="text-muted fw-normal">(optional)</small></h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Pricing Options"
data-bs-content="Apply a rush surcharge or a one-off discount to this job. Tier discounts for the customer are applied automatically. These settings are preserved if the job is edited later.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check">
<input asp-for="IsRushJob" class="form-check-input" type="checkbox" id="IsRushJob" onchange="scheduleAutoPricing()">
<label class="form-check-label" for="IsRushJob">
<strong>Rush Job</strong> <small class="text-muted">(additional fee applies)</small>
</label>
</div>
</div>
<div class="row g-3">
<div class="col-md-4">
<label asp-for="DiscountType" class="form-label fw-semibold">Discount Type</label>
<select asp-for="DiscountType" class="form-select" id="discountTypeSelect" onchange="onDiscountTypeChange(); scheduleAutoPricing()">
<option value="None">No Discount</option>
<option value="Percentage">Percentage (%)</option>
<option value="FixedAmount">Fixed Amount ($)</option>
</select>
</div>
<div class="col-md-4" id="discountValueSection" style="display: @(Model.DiscountType == "None" ? "none" : "block")">
<label asp-for="DiscountValue" class="form-label fw-semibold">Discount Value</label>
<input asp-for="DiscountValue" type="number" class="form-control" id="discountValueInput"
min="0" step="0.01" onchange="scheduleAutoPricing()" />
</div>
<div class="col-md-4" id="discountReasonSection" style="display: @(Model.DiscountType == "None" ? "none" : "block")">
<label asp-for="DiscountReason" class="form-label fw-semibold">Reason</label>
<input asp-for="DiscountReason" class="form-control" />
</div>
</div>
</div>
</div>
<!-- Pricing Summary -->
<div class="card mb-4">
<div class="card-header bg-primary text-white d-flex align-items-center justify-content-between">
<h5 class="mb-0"><i class="bi bi-calculator me-2"></i>Pricing Summary</h5>
<span id="pricingSpinner" class="spinner-border spinner-border-sm text-white d-none" role="status"></span>
</div>
<div class="card-body">
<div class="text-end" id="pricingSummary">
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="ovenBatchCostRow">
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
<strong id="ovenBatchCostDisplay">$0.00</strong>
</p>
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
Tier Discount (<span id="pricingTierDiscountPercentDisplay">0</span>%):
<strong id="pricingTierDiscountDisplay">-$0.00</strong>
</p>
<p class="mb-1 text-success d-none" id="quoteDiscountRow">
Discount (<span id="quoteDiscountPercentDisplay">0</span>%):
<strong id="quoteDiscountDisplay">-$0.00</strong>
</p>
<p class="mb-1 text-warning d-none" id="rushFeeRow">
<i class="bi bi-lightning-fill me-1"></i>Rush Fee: <strong id="rushFeeDisplay">$0.00</strong>
</p>
<p class="mb-1 d-none" id="shopSuppliesRow">Shop Supplies (<span id="shopSuppliesPercentDisplay">0</span>%): <strong id="shopSuppliesDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="subtotalRow">Subtotal: <strong id="subtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="taxRow">Tax (<span id="taxPercentDisplay">0</span>%): <strong id="taxDisplay">$0.00</strong></p>
<hr class="my-2 d-none" id="pricingDivider" />
<h5 class="mb-0 d-none" id="totalRow">Total: <strong id="totalDisplay" class="text-primary">$0.00</strong></h5>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="card mb-4">
<div class="card-body d-flex align-items-center justify-content-end gap-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-lg">
<i class="bi bi-x-circle me-1"></i>Cancel
</a>
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn">
<i class="bi bi-check-circle me-1"></i>Create Job
</button>
</div>
</div>
</form>
</div>
@await Html.PartialAsync("_SqFtCalculatorModal")
@await Html.PartialAsync("_ItemWizardModal")
<!-- Embedded data for 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="merchandiseItemsData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.MerchandiseItems ?? new List<object>()))
</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<PrepService>)(ViewBag.PrepServices ?? new List<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>
<script id="existingItemsData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize((Model.JobItems ?? new List<PowderCoating.Application.DTOs.Quote.CreateQuoteItemDto>()).Select(item => new {
description = item.Description,
quantity = item.Quantity,
surfaceAreaSqFt = item.SurfaceAreaSqFt,
estimatedMinutes = item.EstimatedMinutes,
catalogItemId = item.CatalogItemId,
manualUnitPrice = item.ManualUnitPrice,
powderCostOverride = item.PowderCostOverride,
complexity = item.Complexity,
isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem,
requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking,
notes = item.Notes,
includePrepCost = item.IncludePrepCost,
coats = item.Coats.Select(c => new {
coatName = c.CoatName,
sequence = c.Sequence,
inventoryItemId = c.InventoryItemId,
colorName = c.ColorName,
vendorId = c.VendorId,
colorCode = c.ColorCode,
finish = c.Finish,
coverageSqFtPerLb = c.CoverageSqFtPerLb,
transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder,
notes = c.Notes
}),
prepServices = item.PrepServices.Select(ps => new {
prepServiceId = ps.PrepServiceId,
estimatedMinutes = ps.EstimatedMinutes,
blastSetupId = ps.BlastSetupId
})
})))
</script>
<script id="quoteMetaData" type="application/json">
{
"customerId": @Json.Serialize(Model.CustomerId == 0 ? (int?)null : (int?)Model.CustomerId),
"taxPercent": @(ViewBag.TaxPercent ?? 0),
"discountType": @Json.Serialize(Model.DiscountType),
"discountValue": @Model.DiscountValue,
"isRushJob": @Json.Serialize(Model.IsRushJob),
"ovenCostId": null,
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
"aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")",
"aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")",
"aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)),
"itemsFieldPrefix": "JobItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
}
</script>
@section Styles {
<link rel="stylesheet" href="~/lib/tom-select/css/tom-select.bootstrap5.min.css">
<link rel="stylesheet" href="~/css/item-wizard.css">
}
@section Scripts {
<script src="~/lib/tom-select/js/tom-select.complete.min.js"></script>
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
initTagInput('jobTags', 'jobTagsContainer');
new TomSelect('#customerSelect', {
placeholder: '-- Select Customer --',
openOnFocus: true,
maxOptions: false
});
});
// Update customerId in pageMeta when customer dropdown changes
document.getElementById('customerSelect')?.addEventListener('change', function () {
if (typeof pageMeta !== 'undefined') {
pageMeta.customerId = this.value ? parseInt(this.value) : null;
}
});
function onDiscountTypeChange() {
const type = document.getElementById('discountTypeSelect').value;
const show = type !== 'None';
document.getElementById('discountValueSection').style.display = show ? 'block' : 'none';
document.getElementById('discountReasonSection').style.display = show ? 'block' : 'none';
}
@if (ViewBag.TemplateJson != null)
{
<text>
// Pre-populate items from template
document.addEventListener('DOMContentLoaded', function () {
const template = @Html.Raw(ViewBag.TemplateJson);
if (template && template.items && typeof loadItemsFromTemplate === 'function') {
loadItemsFromTemplate(template.items);
}
});
</text>
}
</script>
}
@@ -1,414 +0,0 @@
@model PowderCoating.Application.DTOs.Job.UpdateJobDto
@using PowderCoating.Core.Entities
@{
ViewData["Title"] = "Edit Job";
ViewData["PageIcon"] = "bi-pencil-square";
}
<div class="container-fluid mt-4">
<div class="d-flex justify-content-end mb-4">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Job
</a>
</div>
<form asp-action="Edit" method="post" id="jobEditForm">
@Html.AntiForgeryToken()
<input type="hidden" asp-for="Id" />
<partial name="_ValidationSummary" />
<!-- Job Details Card -->
<div class="card mb-4">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Job Details</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Job Details"
data-bs-content="Core job information. Priority and due date are visible on the shop floor board and job list. Customer PO is the customer's own reference number — it appears on invoices. Special Instructions go directly to the shop floor worker on the work order.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label asp-for="CustomerId" class="form-label">Customer <span class="text-danger">*</span></label>
<select asp-for="CustomerId" asp-items="@ViewBag.Customers" id="customerSelect">
<option value="">-- Select Customer --</option>
</select>
<span asp-validation-for="CustomerId" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="JobStatusId" class="form-label">Status
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Job Status"
data-bs-content="Tracks where the job is in the workflow: Pending → Approved → Sandblasting → Cleaning → Coating → Curing → QualityCheck → Completed → ReadyForPickup → Delivered. Status changes trigger customer email notifications (if enabled). Use OnHold to pause work without losing progress.">
<i class="bi bi-question-circle"></i>
</a>
</label>
<select asp-for="JobStatusId" class="form-select" asp-items="@ViewBag.Statuses"></select>
<span asp-validation-for="JobStatusId" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="JobPriorityId" class="form-label">Priority
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Job Priority"
data-bs-content="Controls sort order on the shop floor board and job list. Rush and Urgent jobs are highlighted in red/orange. Normal is the default. Raise priority only when the customer has an actual deadline constraint — overuse of Rush dilutes its meaning for the shop floor team.">
<i class="bi bi-question-circle"></i>
</a>
</label>
<select asp-for="JobPriorityId" class="form-select" asp-items="@ViewBag.Priorities"></select>
<span asp-validation-for="JobPriorityId" class="text-danger"></span>
</div>
<div class="col-12">
<label asp-for="Description" class="form-label">Description <span class="text-danger">*</span></label>
<textarea asp-for="Description" class="form-control" rows="3" placeholder="Enter job description"></textarea>
<span asp-validation-for="Description" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="ScheduledDate" class="form-label">Scheduled Date
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Scheduled Date"
data-bs-content="The date the shop plans to work on this job. Used for planning and the shop floor board. Does not affect customer-facing documents.">
<i class="bi bi-question-circle"></i>
</a>
</label>
<input asp-for="ScheduledDate" type="date" class="form-control" />
</div>
<div class="col-md-6">
<label asp-for="DueDate" class="form-label">Due Date
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Due Date"
data-bs-content="The customer's deadline — when the work must be ready for pickup or delivery. Overdue jobs (past due date and not yet completed) are highlighted in red on the job list.">
<i class="bi bi-question-circle"></i>
</a>
</label>
<input asp-for="DueDate" type="date" class="form-control" />
</div>
<div class="col-md-6">
<label asp-for="AssignedUserId" class="form-label">Assigned Worker</label>
<select asp-for="AssignedUserId" class="form-select" asp-items="@ViewBag.Workers">
<option value="">Not assigned</option>
</select>
</div>
<div class="col-md-6">
<label asp-for="CustomerPO" class="form-label">Customer PO</label>
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
</div>
<div class="col-md-7">
<label asp-for="SpecialInstructions" class="form-label">Special Instructions</label>
<textarea asp-for="SpecialInstructions" class="form-control" rows="3" placeholder="Any special instructions"></textarea>
</div>
<div class="col-md-5">
<label class="form-label fw-semibold">Tags <span class="badge rounded-pill bg-warning text-dark ms-1" style="font-size:0.65rem;">Recommended</span></label>
<p class="text-muted small mb-1">Tags are used to improve smart predictions and assistance over time. The more consistently you tag, the smarter the system gets.</p>
<input type="hidden" asp-for="Tags" id="jobTags" />
<div id="jobTagsContainer"></div>
<small class="text-muted">Press <kbd>Enter</kbd> or <kbd>,</kbd> to add a tag. Click &times; to remove.</small>
</div>
<div class="col-md-6">
<div class="form-check mt-2">
<input asp-for="RequiresCustomerApproval" class="form-check-input" type="checkbox" />
<label class="form-check-label" asp-for="RequiresCustomerApproval">
Requires Customer Approval Before Starting
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Preparation Services -->
@if (ViewBag.PrepServices != null && ((List<PrepService>)ViewBag.PrepServices).Any())
{
<div class="card mb-4">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-tools me-2"></i>Preparation Services</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Preparation Services"
data-bs-content="Job-level prep services that apply to all items (e.g., full-job sandblasting, chemical strip). For prep that applies to only one item, use the per-item prep settings in the item wizard instead.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<div class="row g-3">
@foreach (var service in (List<PrepService>)ViewBag.PrepServices)
{
var isChecked = Model.PrepServiceIds != null && Model.PrepServiceIds.Contains(service.Id);
<div class="col-sm-6 col-lg-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="PrepServiceIds" value="@service.Id" id="prepService_@service.Id" @(isChecked ? "checked" : "")>
<label class="form-check-label" for="prepService_@service.Id">
<strong>@service.ServiceName</strong>
@if (!string.IsNullOrWhiteSpace(service.Description))
{
<small class="text-muted d-block">@service.Description</small>
}
</label>
</div>
</div>
}
</div>
</div>
</div>
}
<!-- Items Card -->
<div class="card mb-4">
<div class="card-header d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Job Items</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Job Items"
data-bs-content="Each item represents a physical piece being coated. Use the wizard to pick from the catalog, enter custom dimensions, or upload a photo for AI analysis. Each item gets its own coating specification — color, powder, finish, and cure details. You can add multiple coating passes per item for multi-color or primer+topcoat work.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<button type="button" class="btn btn-primary" onclick="openWizard()">
<i class="bi bi-plus-circle me-1"></i>Add Item
</button>
</div>
<div class="card-body">
<div id="itemsEmptyMessage" class="text-center py-5 text-muted" style="display: none;">
<i class="bi bi-box-seam display-4 d-block mb-2 opacity-25"></i>
<p class="mb-0">No items added yet.</p>
<p class="small">Click <strong>Add Item</strong> to get started.</p>
</div>
<div id="itemCardsContainer"></div>
<div id="hiddenFieldsContainer"></div>
<div id="aiPhotoTempIdsContainer"></div>
</div>
</div>
<!-- Pricing Options (Rush / Discount) -->
<div class="card mb-4">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-tag me-2"></i>Pricing Options <small class="text-muted fw-normal">(optional)</small></h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Pricing Options"
data-bs-content="Apply a rush surcharge or a one-off discount to this job. Tier discounts for the customer are applied automatically. These settings are preserved through future edits.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check">
<input asp-for="IsRushJob" class="form-check-input" type="checkbox" id="IsRushJob" onchange="scheduleAutoPricing()">
<label class="form-check-label" for="IsRushJob">
<strong>Rush Job</strong> <small class="text-muted">(additional fee applies)</small>
</label>
</div>
</div>
<div class="row g-3">
<div class="col-md-4">
<label asp-for="DiscountType" class="form-label fw-semibold">Discount Type</label>
<select asp-for="DiscountType" class="form-select" id="discountTypeSelect" onchange="onDiscountTypeChange(); scheduleAutoPricing()">
<option value="None">No Discount</option>
<option value="Percentage">Percentage (%)</option>
<option value="FixedAmount">Fixed Amount ($)</option>
</select>
</div>
<div class="col-md-4" id="discountValueSection" style="display: @(Model.DiscountType == "None" ? "none" : "block")">
<label asp-for="DiscountValue" class="form-label fw-semibold">Discount Value</label>
<input asp-for="DiscountValue" type="number" class="form-control" id="discountValueInput"
min="0" step="0.01" onchange="scheduleAutoPricing()" />
</div>
<div class="col-md-4" id="discountReasonSection" style="display: @(Model.DiscountType == "None" ? "none" : "block")">
<label asp-for="DiscountReason" class="form-label fw-semibold">Reason</label>
<input asp-for="DiscountReason" class="form-control" />
</div>
</div>
</div>
</div>
<!-- Pricing Summary -->
<div class="card mb-4">
<div class="card-header bg-primary text-white d-flex align-items-center justify-content-between">
<h5 class="mb-0"><i class="bi bi-calculator me-2"></i>Pricing Summary</h5>
<span id="pricingSpinner" class="spinner-border spinner-border-sm text-white d-none" role="status"></span>
</div>
<div class="card-body">
<div class="text-end" id="pricingSummary">
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="ovenBatchCostRow">
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
<strong id="ovenBatchCostDisplay">$0.00</strong>
</p>
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
Tier Discount (<span id="pricingTierDiscountPercentDisplay">0</span>%):
<strong id="pricingTierDiscountDisplay">-$0.00</strong>
</p>
<p class="mb-1 text-success d-none" id="quoteDiscountRow">
Discount (<span id="quoteDiscountPercentDisplay">0</span>%):
<strong id="quoteDiscountDisplay">-$0.00</strong>
</p>
<p class="mb-1 text-warning d-none" id="rushFeeRow">
<i class="bi bi-lightning-fill me-1"></i>Rush Fee: <strong id="rushFeeDisplay">$0.00</strong>
</p>
<p class="mb-1 d-none" id="shopSuppliesRow">Shop Supplies (<span id="shopSuppliesPercentDisplay">0</span>%): <strong id="shopSuppliesDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="subtotalRow">Subtotal: <strong id="subtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="taxRow">Tax (<span id="taxPercentDisplay">0</span>%): <strong id="taxDisplay">$0.00</strong></p>
<hr class="my-2 d-none" id="pricingDivider" />
<h5 class="mb-0 d-none" id="totalRow">Total: <strong id="totalDisplay" class="text-primary">$0.00</strong></h5>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="card mb-4">
<div class="card-body d-flex flex-wrap align-items-center justify-content-between gap-3">
@{
var sendEmailDefault = (bool)(ViewBag.EmailDefaultOnStatusChange ?? Model.SendEmailOnStatusChange);
}
@if (!string.IsNullOrWhiteSpace(ViewBag.CustomerEmail as string))
{
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch"
id="SendEmailOnStatusChange" name="SendEmailOnStatusChange" value="true"
checked="@(sendEmailDefault ? "checked" : null)" />
<label class="form-check-label small" for="SendEmailOnStatusChange">
<i class="bi bi-envelope me-1"></i>Notify customer if status changes
</label>
</div>
}
<div class="d-flex gap-2 ms-auto">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary btn-lg">
<i class="bi bi-x-circle me-1"></i>Cancel
</a>
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn">
<i class="bi bi-check-circle me-1"></i>Save Changes
</button>
</div>
</div>
</div>
</form>
</div>
@await Html.PartialAsync("_SqFtCalculatorModal")
@await Html.PartialAsync("_ItemWizardModal")
<!-- Embedded data for 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="merchandiseItemsData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.MerchandiseItems ?? new List<object>()))
</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<PrepService>)(ViewBag.PrepServices ?? new List<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>
<!-- Existing items pre-fill -->
<script id="existingItemsData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize((Model.JobItems ?? new List<PowderCoating.Application.DTOs.Quote.CreateQuoteItemDto>()).Select(item => new {
description = item.Description,
quantity = item.Quantity,
surfaceAreaSqFt = item.SurfaceAreaSqFt,
estimatedMinutes = item.EstimatedMinutes,
catalogItemId = item.CatalogItemId,
manualUnitPrice = item.ManualUnitPrice,
powderCostOverride = item.PowderCostOverride,
complexity = item.Complexity,
isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem,
isAiItem = item.IsAiItem,
requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking,
notes = item.Notes,
includePrepCost = item.IncludePrepCost,
coats = item.Coats.Select(c => new {
coatName = c.CoatName,
sequence = c.Sequence,
inventoryItemId = c.InventoryItemId,
colorName = c.ColorName,
vendorId = c.VendorId,
colorCode = c.ColorCode,
finish = c.Finish,
coverageSqFtPerLb = c.CoverageSqFtPerLb,
transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder,
notes = c.Notes
}),
prepServices = item.PrepServices.Select(ps => new {
prepServiceId = ps.PrepServiceId,
estimatedMinutes = ps.EstimatedMinutes,
blastSetupId = ps.BlastSetupId
})
})))
</script>
<script id="quoteMetaData" type="application/json">
{
"customerId": @Json.Serialize(Model.CustomerId),
"taxPercent": @(ViewBag.TaxPercent ?? 0),
"discountType": @Json.Serialize(Model.DiscountType),
"discountValue": @Model.DiscountValue,
"isRushJob": @Json.Serialize(Model.IsRushJob),
"ovenCostId": null,
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
"aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")",
"aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")",
"aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)),
"itemsFieldPrefix": "JobItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
}
</script>
@section Styles {
<link rel="stylesheet" href="~/lib/tom-select/css/tom-select.bootstrap5.min.css">
<link rel="stylesheet" href="~/css/item-wizard.css">
}
@section Scripts {
<script src="~/lib/tom-select/js/tom-select.complete.min.js"></script>
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
initTagInput('jobTags', 'jobTagsContainer');
new TomSelect('#customerSelect', {
placeholder: '-- Select Customer --',
openOnFocus: true,
maxOptions: false
});
});
function onDiscountTypeChange() {
const type = document.getElementById('discountTypeSelect').value;
const show = type !== 'None';
document.getElementById('discountValueSection').style.display = show ? 'block' : 'none';
document.getElementById('discountReasonSection').style.display = show ? 'block' : 'none';
}
</script>
}
@@ -1,188 +0,0 @@
@model PowderCoating.Application.DTOs.Job.JobEditItemsViewModel
@using PowderCoating.Core.Entities
@{
ViewData["Title"] = $"Edit Items — {Model.JobNumber}";
ViewData["PageIcon"] = "bi-list-check";
}
<div class="container-fluid mt-4">
<div class="d-flex justify-content-end mb-4">
<a asp-action="Details" asp-route-id="@Model.JobId" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Job
</a>
</div>
<form asp-action="UpdateItems" asp-controller="Jobs" method="post" id="jobItemsForm">
@Html.AntiForgeryToken()
<input type="hidden" name="JobId" value="@Model.JobId" />
<input type="hidden" name="JobNumber" value="@Model.JobNumber" />
<input type="hidden" name="CustomerId" value="@Model.CustomerId" />
<input type="hidden" name="TaxPercent" value="@Model.TaxPercent" />
<input type="hidden" name="OvenCostId" value="@Model.OvenCostId" />
<input type="hidden" name="OvenBatches" value="@Model.OvenBatches" />
<input type="hidden" name="OvenCycleMinutes" value="@Model.OvenCycleMinutes" />
@if (!ViewData.ModelState.IsValid)
{
<div class="alert alert-danger alert-permanent mb-4" role="alert">
<ul class="mb-0">
@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
{
<li>@error.ErrorMessage</li>
}
</ul>
</div>
}
<!-- Items Card -->
<div class="card mb-4">
<div class="card-header d-flex align-items-center justify-content-between">
<h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Job Items</h5>
<button type="button" class="btn btn-primary" onclick="openWizard()">
<i class="bi bi-plus-circle me-1"></i>Add Item
</button>
</div>
<div class="card-body">
<div id="itemsEmptyMessage" class="text-center py-5 text-muted" style="display: none;">
<i class="bi bi-box-seam display-4 d-block mb-2 opacity-25"></i>
<p class="mb-0">No items added yet.</p>
<p class="small">Click <strong>Add Item</strong> to get started.</p>
</div>
<div id="itemCardsContainer"></div>
<!-- Hidden fields written by wizard JS -->
<div id="hiddenFieldsContainer"></div>
</div>
</div>
<!-- Pricing Summary -->
<div class="card mb-4">
<div class="card-header bg-primary text-white d-flex align-items-center justify-content-between">
<h5 class="mb-0"><i class="bi bi-calculator me-2"></i>Pricing Summary</h5>
<span id="pricingSpinner" class="spinner-border spinner-border-sm text-white d-none" role="status"></span>
</div>
<div class="card-body">
<div class="text-end" id="pricingSummary">
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="ovenBatchCostRow">
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
<strong id="ovenBatchCostDisplay">$0.00</strong>
</p>
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
Tier Discount (<span id="pricingTierDiscountPercentDisplay">0</span>%):
<strong id="pricingTierDiscountDisplay">-$0.00</strong>
</p>
<p class="mb-1 d-none" id="shopSuppliesRow">Shop Supplies (<span id="shopSuppliesPercentDisplay">0</span>%): <strong id="shopSuppliesDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="subtotalRow">Subtotal: <strong id="subtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="taxRow">Tax (<span id="taxPercentDisplay">0</span>%): <strong id="taxDisplay">$0.00</strong></p>
<hr class="my-2 d-none" id="pricingDivider" />
<h5 class="mb-0 d-none" id="totalRow">Total: <strong id="totalDisplay" class="text-primary">$0.00</strong></h5>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="card mb-4">
<div class="card-body d-flex align-items-center justify-content-end gap-3">
<a asp-action="Details" asp-route-id="@Model.JobId" class="btn btn-outline-secondary btn-lg">
<i class="bi bi-x-circle me-1"></i>Cancel
</a>
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn">
<i class="bi bi-check-circle me-1"></i>Save Items
</button>
</div>
</div>
</form>
</div>
@await Html.PartialAsync("_SqFtCalculatorModal")
@await Html.PartialAsync("_ItemWizardModal")
<!-- Embedded data for 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<PrepService>)(ViewBag.PrepServices ?? new List<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>
<!-- Existing items -->
<script id="existingItemsData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize((Model.JobItems ?? new List<PowderCoating.Application.DTOs.Quote.CreateQuoteItemDto>()).Select(item => new {
description = item.Description,
quantity = item.Quantity,
surfaceAreaSqFt = item.SurfaceAreaSqFt,
estimatedMinutes = item.EstimatedMinutes,
catalogItemId = item.CatalogItemId,
manualUnitPrice = item.ManualUnitPrice,
powderCostOverride = item.PowderCostOverride,
complexity = item.Complexity,
isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem,
isAiItem = item.IsAiItem,
requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking,
notes = item.Notes,
includePrepCost = item.IncludePrepCost,
coats = item.Coats.Select(c => new {
coatName = c.CoatName,
sequence = c.Sequence,
inventoryItemId = c.InventoryItemId,
colorName = c.ColorName,
vendorId = c.VendorId,
colorCode = c.ColorCode,
finish = c.Finish,
coverageSqFtPerLb = c.CoverageSqFtPerLb,
transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder,
notes = c.Notes
}),
prepServices = item.PrepServices.Select(ps => new {
prepServiceId = ps.PrepServiceId,
estimatedMinutes = ps.EstimatedMinutes,
blastSetupId = ps.BlastSetupId
})
})))
</script>
<script id="quoteMetaData" type="application/json">
{
"customerId": @Json.Serialize(Model.CustomerId),
"taxPercent": @Model.TaxPercent,
"discountType": "None",
"discountValue": 0,
"isRushJob": false,
"ovenCostId": @Json.Serialize(Model.OvenCostId),
"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 {
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
}
File diff suppressed because it is too large Load Diff
@@ -1,288 +0,0 @@
@model (PowderCoating.Application.DTOs.Job.JobDto Job, PowderCoating.Application.DTOs.Job.IntakeJobDto Form)
@{
ViewData["Title"] = $"Part Intake — {Model.Job.JobNumber}";
ViewData["PageIcon"] = "bi-box-seam";
var job = Model.Job;
var form = Model.Form;
var expectedCount = (int)(ViewBag.ExpectedPartCount ?? 0);
var isReintake = job.IntakeDate.HasValue;
}
<div class="row justify-content-center">
<div class="col-lg-7 col-xl-6">
<!-- Header -->
<div class="d-flex justify-content-end mb-4">
<a asp-action="Details" asp-route-id="@job.Id" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
</div>
@if (isReintake)
{
<div class="alert alert-warning alert-permanent mb-4">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Parts were previously checked in</strong> on @job.IntakeDate!.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM d, yyyy h:mm tt")
@if (!string.IsNullOrEmpty(job.IntakeCheckedByName))
{
<span> by @job.IntakeCheckedByName</span>
}
. Submitting this form will update the intake record.
</div>
}
<!-- Job summary card -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-info-circle me-2"></i>Job Summary
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-6">
<small class="text-muted d-block">Status</small>
<span class="badge bg-@job.StatusColorClass fs-6">@job.StatusDisplayName</span>
</div>
<div class="col-6">
<small class="text-muted d-block">Expected Parts</small>
<span class="fs-4 fw-bold text-primary">@expectedCount</span>
</div>
@if (!string.IsNullOrEmpty(job.CustomerPO))
{
<div class="col-6">
<small class="text-muted d-block">Customer PO</small>
<span>@job.CustomerPO</span>
</div>
}
@if (job.DueDate.HasValue)
{
<div class="col-6">
<small class="text-muted d-block">Due Date</small>
<span class="@(job.DueDate < DateTime.Now ? "text-danger fw-semibold" : "")">
@job.DueDate.Value.ToString("MMM d, yyyy")
</span>
</div>
}
</div>
@if (job.Items.Any())
{
<hr class="my-3" />
<small class="text-muted d-block mb-2">Items</small>
<ul class="list-unstyled mb-0">
@foreach (var item in job.Items)
{
<li class="d-flex align-items-start gap-2 mb-1">
<span class="badge bg-secondary">&times;@item.Quantity</span>
<span class="small">@item.Description</span>
</li>
}
</ul>
}
@if (!string.IsNullOrEmpty(job.SpecialInstructions))
{
<hr class="my-3" />
<small class="text-muted d-block">Special Instructions</small>
<p class="mb-0 small text-warning-emphasis">
<i class="bi bi-exclamation-circle me-1"></i>@job.SpecialInstructions
</p>
}
</div>
</div>
<!-- Intake form -->
<form asp-action="Intake" asp-route-id="@job.Id" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="jobId" value="@form.JobId" />
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-clipboard-check me-2"></i>Check-In Details
</div>
<div class="card-body">
<!-- Part count -->
<div class="mb-4">
<label asp-for="Form.ActualPartCount" class="form-label fw-semibold">
Actual Part Count
@if (expectedCount > 0)
{
<span class="text-muted fw-normal ms-1">(expected: @expectedCount)</span>
}
</label>
<div class="input-group input-group-lg">
<span class="input-group-text"><i class="bi bi-hash"></i></span>
<input name="actualPartCount" class="form-control form-control-lg"
type="number" min="0" max="10000"
value="@(form.ActualPartCount.HasValue ? form.ActualPartCount.Value.ToString() : "")"
placeholder="@expectedCount" />
</div>
@if (expectedCount > 0)
{
<div id="countMismatchAlert" class="alert alert-warning mt-2 py-2 d-none">
<i class="bi bi-exclamation-triangle me-1"></i>
Count doesn't match expected — note the discrepancy in condition notes.
</div>
}
</div>
<!-- Condition notes -->
<div class="mb-4">
<label asp-for="Form.ConditionNotes" class="form-label fw-semibold">
Condition Notes
</label>
<textarea name="conditionNotes" class="form-control" rows="4"
placeholder="Describe the condition of parts at drop-off: scratches, rust, pre-existing damage, any missing pieces, special handling notes...">@form.ConditionNotes</textarea>
</div>
<!-- Advance status checkbox -->
@if (job.StatusCode != "IN_PREPARATION" && !job.StatusIsTerminal)
{
<div class="form-check form-switch">
<input type="hidden" name="advanceToInPreparation" value="false" />
<input type="checkbox" name="advanceToInPreparation" value="true" class="form-check-input" role="switch"
id="advanceSwitch" style="width:3em; height:1.5em;"
@(form.AdvanceToInPreparation ? "checked" : "") />
<label class="form-check-label ms-2" for="advanceSwitch">
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>
<!-- Before photos -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-camera me-2"></i>Before Photos
<span class="badge bg-secondary ms-1">Optional</span>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Upload photos documenting the condition of parts at drop-off. These are saved as "Before" photos on the job.
</p>
<div id="photoDropZone"
class="border border-2 border-dashed rounded-3 p-4 text-center text-muted"
style="cursor:pointer; border-color: #dee2e6 !important;"
onclick="document.getElementById('photoInput').click()">
<i class="bi bi-cloud-upload fs-2 d-block mb-2"></i>
<span>Tap to add photos</span>
<small class="d-block mt-1">JPG, PNG up to 10 MB each</small>
</div>
<input type="file" id="photoInput" accept="image/jpeg,image/png,image/gif"
multiple class="d-none" />
<div id="photoPreviewArea" class="row g-2 mt-2"></div>
<div id="photoUploadStatus" class="mt-2"></div>
</div>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-check-circle me-2"></i>
@(isReintake ? "Update Intake Record" : "Complete Intake")
</button>
<a asp-action="Details" asp-route-id="@job.Id" class="btn btn-outline-secondary">
Cancel
</a>
</div>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
// Part count mismatch warning
const expectedCount = @expectedCount;
const countInput = document.querySelector('input[name="actualPartCount"]');
const mismatchAlert = document.getElementById('countMismatchAlert');
if (countInput && mismatchAlert && expectedCount > 0) {
countInput.addEventListener('input', function () {
const val = parseInt(this.value);
if (!isNaN(val) && val !== expectedCount) {
mismatchAlert.classList.remove('d-none');
} else {
mismatchAlert.classList.add('d-none');
}
});
}
// Before photo upload (reuses the existing /Jobs/UploadPhoto endpoint)
const photoInput = document.getElementById('photoInput');
const previewArea = document.getElementById('photoPreviewArea');
const statusDiv = document.getElementById('photoUploadStatus');
const jobId = @job.Id;
const dropZone = document.getElementById('photoDropZone');
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('border-primary'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('border-primary'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('border-primary');
handleFiles(e.dataTransfer.files);
});
photoInput.addEventListener('change', function () {
handleFiles(this.files);
this.value = '';
});
async function handleFiles(files) {
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
await uploadPhoto(file);
}
}
async function uploadPhoto(file) {
const formData = new FormData();
formData.append('jobId', jobId);
formData.append('photo', file);
formData.append('caption', 'Intake — before');
formData.append('photoType', '0'); // JobPhotoType.Before = 0
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
try {
const resp = await fetch('/Jobs/UploadPhoto', {
method: 'POST',
headers: { 'RequestVerificationToken': token },
body: formData
});
const data = await resp.json();
if (data.success) {
addPreview(file, data.photoId);
} else {
showStatus('Upload failed: ' + (data.message ?? 'Unknown error'), 'danger');
}
} catch (e) {
showStatus('Upload error: ' + e.message, 'danger');
}
}
function addPreview(file, photoId) {
const reader = new FileReader();
reader.onload = e => {
const col = document.createElement('div');
col.className = 'col-4 col-sm-3 position-relative';
col.innerHTML = `
<img src="${e.target.result}" class="img-fluid rounded" style="aspect-ratio:1;object-fit:cover;" />
<span class="position-absolute top-0 end-0 badge bg-success m-1"><i class="bi bi-check"></i></span>`;
previewArea.appendChild(col);
};
reader.readAsDataURL(file);
}
function showStatus(msg, type) {
statusDiv.innerHTML = `<div class="alert alert-${type} alert-permanent py-2">${msg}</div>`;
}
</script>
}
@@ -1,665 +0,0 @@
@model List<PowderCoating.Application.DTOs.Job.JobDailyPriorityDto>
@using PowderCoating.Core.Entities
@{
Layout = null;
ViewData["Title"] = "Shop Floor Display";
var scheduledDate = ViewBag.ScheduledDate as DateTime? ?? DateTime.Today;
var currentTime = ViewBag.CurrentTime as DateTime? ?? DateTime.Now;
var allStatuses = ViewBag.AllStatuses as List<JobStatusLookup> ?? new List<JobStatusLookup>();
var currentUserId = ViewBag.CurrentUserId as string ?? "";
var filterUserId = ViewBag.FilterUserId as string;
var isMyJobsFilter = filterUserId != null;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shop Floor Display - @scheduledDate.ToString("MMM dd, yyyy")</title>
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="~/lib/bootstrap-icons/font/bootstrap-icons.css">
<style>
body {
background-color: #0d1117;
color: #e0e0e0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
min-height: 100vh;
overflow-x: hidden;
}
.shop-header {
background: linear-gradient(135deg, #161b22 0%, #1c2128 100%);
border-bottom: 3px solid #30363d;
padding: 20px 40px;
position: sticky;
top: 0;
z-index: 100;
}
.shop-title {
font-size: 2rem;
font-weight: 700;
color: #ffffff;
letter-spacing: 3px;
text-transform: uppercase;
}
.shop-subtitle {
font-size: 1rem;
color: #8b949e;
margin-top: 6px;
display: flex;
align-items: center;
gap: 16px;
}
.clock {
font-size: 2.8rem;
font-weight: 300;
color: #58a6ff;
font-family: 'Courier New', monospace;
letter-spacing: 4px;
}
.date-badge {
font-size: 1rem;
color: #8b949e;
margin-top: 4px;
}
.job-count {
background: #21262d;
border: 1px solid #30363d;
border-radius: 20px;
padding: 4px 14px;
font-size: 1rem;
color: #58a6ff;
}
.nav-buttons { display: flex; gap: 10px; }
.nav-btn {
border: 1px solid #30363d;
color: #8b949e;
font-size: 0.95rem;
padding: 8px 16px;
background: #161b22;
}
.nav-btn:hover { background-color: #21262d; border-color: #58a6ff; color: #58a6ff; }
.nav-btn.today { border-color: #58a6ff; color: #58a6ff; }
.nav-btn.my-jobs-active { border-color: #3fb950; color: #3fb950; background: rgba(63,185,80,0.1); }
/* Job card */
.job-card {
background: #161b22;
border: 1px solid #30363d;
border-left: 6px solid #30363d;
border-radius: 12px;
margin-bottom: 14px;
cursor: pointer;
transition: all 0.2s ease;
overflow: hidden;
}
.job-card:hover { transform: translateX(4px); box-shadow: 0 8px 24px rgba(0,0,0,0.4); border-color: #58a6ff44; }
.job-card.no-advance { cursor: default; }
.job-card.no-advance:hover { transform: none; box-shadow: none; border-color: #30363d; }
.job-card.advancing { opacity: 0.6; pointer-events: none; }
.priority-border-danger { border-left-color: #dc3545 !important; }
.priority-border-warning { border-left-color: #ffc107 !important; }
.priority-border-info { border-left-color: #0dcaf0 !important; }
.priority-border-primary { border-left-color: #0d6efd !important; }
.priority-border-secondary { border-left-color: #6c757d !important; }
.priority-border-success { border-left-color: #198754 !important; }
/* Card top row */
.card-top { padding: 18px 28px 12px 28px; display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }
.order-num {
font-size: 2.5rem;
font-weight: 700;
color: rgba(88,166,255,0.18);
min-width: 48px;
line-height: 1;
}
.job-number { font-size: 1.8rem; font-weight: 700; color: #58a6ff; letter-spacing: 2px; min-width: 190px; }
.customer-name { font-size: 1.4rem; font-weight: 600; color: #f0f6fc; flex: 1; }
.badge-xl {
font-size: 1rem;
font-weight: 600;
padding: 8px 16px;
border-radius: 8px;
text-transform: uppercase;
letter-spacing: 1px;
min-width: 130px;
text-align: center;
}
/* bg-info is a light cyan — use dark text so it's readable on TV */
.badge-xl.bg-info { color: #000 !important; }
.worker-badge {
font-size: 1rem;
padding: 8px 16px;
background: rgba(63,185,80,0.15);
color: #3fb950;
border: 1px solid rgba(63,185,80,0.3);
border-radius: 8px;
min-width: 160px;
text-align: center;
}
.worker-badge.unassigned {
background: rgba(139,148,158,0.15);
color: #8b949e;
border-color: rgba(139,148,158,0.3);
}
.due-date { font-size: 1rem; color: #8b949e; min-width: 150px; text-align: right; }
.due-date.overdue { color: #f85149; font-weight: 700; }
/* Card detail row */
.card-detail {
padding: 0 28px 14px 88px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
border-top: 1px solid #21262d;
padding-top: 10px;
}
.detail-pill {
display: inline-flex;
align-items: center;
gap: 6px;
background: #21262d;
border: 1px solid #30363d;
border-radius: 20px;
padding: 4px 12px;
font-size: 0.9rem;
color: #8b949e;
}
.prep-flag { background: rgba(255,193,7,0.1); border-color: rgba(255,193,7,0.3); color: #ffc107; }
.desc-text {
color: #c9d1d9;
font-size: 0.95rem;
flex: 1;
min-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 400px;
}
/* Per-item list in card detail */
.item-list {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
width: 100%;
}
.item-entry {
display: flex;
align-items: baseline;
gap: 6px;
font-size: 0.9rem;
color: #c9d1d9;
flex-wrap: wrap;
}
.item-qty {
font-weight: 700;
color: #58a6ff;
min-width: 30px;
text-align: right;
font-size: 0.85rem;
flex-shrink: 0;
}
.item-desc {
color: #e0e0e0;
}
.item-sep { color: #484f58; flex-shrink: 0; }
.item-color {
color: #a371f7;
font-style: italic;
}
.item-flag {
font-size: 0.75rem;
color: #ffc107;
flex-shrink: 0;
}
.special-instructions {
display: flex;
align-items: flex-start;
width: 100%;
color: #ffc107;
font-size: 0.95rem;
font-weight: 500;
background: rgba(255,193,7,0.08);
border: 1px solid rgba(255,193,7,0.25);
border-radius: 6px;
padding: 6px 12px;
margin-top: 2px;
}
/* Progress strip */
.progress-strip {
padding: 8px 28px 14px 88px;
display: flex;
align-items: center;
gap: 0;
overflow-x: auto;
}
.progress-step {
display: flex;
align-items: center;
gap: 0;
font-size: 0.72rem;
color: #484f58;
white-space: nowrap;
}
.step-label {
padding: 3px 10px;
border-radius: 10px;
background: #21262d;
border: 1px solid #30363d;
transition: all 0.2s;
}
.step-label.current {
background: #1f6feb;
border-color: #58a6ff;
color: #ffffff;
font-weight: 600;
font-size: 0.78rem;
box-shadow: 0 0 10px rgba(88,166,255,0.4);
}
.step-label.done { background: #1a2d1a; border-color: #238636; color: #3fb950; }
.step-arrow { color: #30363d; margin: 0 4px; font-size: 0.65rem; }
.step-arrow.done { color: #238636; }
/* Advance button hint */
.advance-hint { font-size: 0.75rem; color: #3d444d; padding: 0 28px 10px 88px; }
.job-card:hover .advance-hint { color: #58a6ff99; }
/* No-jobs state */
.no-jobs-container {
min-height: 70vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #8b949e;
}
.no-jobs-icon { font-size: 8rem; color: #3fb950; opacity: 0.6; }
/* Modal overrides for dark theme */
.modal-content { background: #161b22; border: 1px solid #30363d; color: #e0e0e0; }
.modal-header { border-bottom: 1px solid #30363d; }
.modal-footer { border-top: 1px solid #30363d; }
.btn-close { filter: invert(1); }
</style>
</head>
<body>
<!-- Header -->
<div class="shop-header">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="shop-title"><i class="bi bi-calendar-week me-3"></i>Job Schedule</div>
<div class="shop-subtitle">
<span class="job-count"><i class="bi bi-briefcase me-2"></i>@Model.Count job@(Model.Count != 1 ? "s" : "")</span>
<span class="text-muted" style="font-size:0.85rem"><i class="bi bi-wifi me-1"></i><span id="shopHubStatus">Connecting...</span></span>
@if (isMyJobsFilter)
{
<span style="color:#3fb950;font-size:0.85rem"><i class="bi bi-person-check me-1"></i>My Jobs</span>
}
</div>
</div>
<div class="text-center">
<div class="clock" id="clock">@currentTime.ToString("HH:mm:ss")</div>
<div class="date-badge">
@scheduledDate.ToString("dddd, MMMM dd, yyyy")
@if (scheduledDate.Date == DateTime.Today)
{
<span class="badge bg-primary ms-2">TODAY</span>
}
else if (scheduledDate.Date == DateTime.Today.AddDays(1))
{
<span class="badge bg-info ms-2">TOMORROW</span>
}
else if (scheduledDate.Date == DateTime.Today.AddDays(-1))
{
<span class="badge bg-secondary ms-2">YESTERDAY</span>
}
</div>
</div>
<div class="nav-buttons">
<a href="@Url.Action("ShopDisplay", new { date = scheduledDate.AddDays(-1), userId = filterUserId })" class="btn nav-btn">
<i class="bi bi-chevron-left"></i>
</a>
<a href="@Url.Action("ShopDisplay", new { userId = filterUserId })" class="btn nav-btn @(scheduledDate.Date == DateTime.Today ? "today" : "")">TODAY</a>
<a href="@Url.Action("ShopDisplay", new { date = scheduledDate.AddDays(1), userId = filterUserId })" class="btn nav-btn">
<i class="bi bi-chevron-right"></i>
</a>
@if (isMyJobsFilter)
{
<a href="@Url.Action("ShopDisplay", new { date = scheduledDate })" class="btn nav-btn my-jobs-active">
<i class="bi bi-people me-1"></i>All Jobs
</a>
}
else
{
<a href="@Url.Action("ShopDisplay", new { date = scheduledDate, userId = currentUserId })" class="btn nav-btn">
<i class="bi bi-person me-1"></i>My Jobs
</a>
}
</div>
</div>
</div>
<!-- Anti-forgery token for AJAX -->
@Html.AntiForgeryToken()
<!-- Main Content -->
<div class="container-fluid p-4">
@if (!Model.Any())
{
<div class="no-jobs-container">
<i class="bi bi-calendar-x no-jobs-icon"></i>
<h2 class="mt-4" style="color:#8b949e;font-size:2.5rem;">
@(isMyJobsFilter ? "No Jobs Assigned to You" : "No Jobs Scheduled")
</h2>
<p class="mt-3" style="font-size:1.3rem;">
@(isMyJobsFilter ? "No jobs assigned to you for " : "No jobs scheduled for ")@scheduledDate.ToString("MMMM dd, yyyy")
</p>
@if (isMyJobsFilter)
{
<a href="@Url.Action("ShopDisplay", new { date = scheduledDate })" class="btn btn-outline-secondary mt-3">
<i class="bi bi-people me-2"></i>View All Jobs
</a>
}
</div>
}
else
{
@for (int i = 0; i < Model.Count; i++)
{
var job = Model[i];
var isOverdue = job.DueDate.HasValue && job.DueDate.Value.Date < DateTime.Today;
var borderClass = $"priority-border-{job.PriorityColorClass}";
var hasNext = job.NextStatusId.HasValue;
<div class="job-card @borderClass @(hasNext ? "" : "no-advance")" id="job-card-@job.JobId"
data-job-id="@job.JobId"
data-job-number="@job.JobNumber"
data-next-status-id="@job.NextStatusId"
data-next-status-name="@job.NextStatusDisplayName"
data-next-status-color="@job.NextStatusColorClass">
<!-- Top Row: order number, job number, customer, status, priority, worker, due date -->
<div class="card-top">
<div class="order-num">@(i + 1)</div>
<div class="job-number">@job.JobNumber</div>
<div class="customer-name"><i class="bi bi-building me-2" style="color:#8b949e"></i>@job.CustomerName</div>
<div>
<span class="badge bg-@job.StatusColorClass badge-xl" id="status-badge-@job.JobId">@job.StatusDisplayName</span>
</div>
<div>
<span class="badge bg-@job.PriorityColorClass badge-xl">@job.PriorityDisplayName</span>
</div>
<div>
<div class="worker-badge @(string.IsNullOrEmpty(job.AssignedWorkerName) ? "unassigned" : "")">
<i class="bi bi-person-badge me-2"></i>@(string.IsNullOrEmpty(job.AssignedWorkerName) ? "Unassigned" : job.AssignedWorkerName)
</div>
</div>
@if (job.DueDate.HasValue)
{
<div class="due-date @(isOverdue ? "overdue" : "")">
<i class="bi bi-calendar-x me-2"></i>Due: @job.DueDate.Value.ToString("MMM dd")
@if (isOverdue) { <i class="bi bi-exclamation-triangle-fill ms-2"></i> }
</div>
}
else
{
<div class="due-date" style="color:#30363d">
<i class="bi bi-calendar me-2"></i>No due date
</div>
}
</div>
<!-- Detail Row: items with colors, prep flags -->
<div class="card-detail">
<div class="item-list">
@foreach (var item in job.Items)
{
<span class="item-entry">
<span class="item-qty">&times;@((int)item.Quantity)</span>
<span class="item-desc">@item.Description</span>
@if (item.Colors.Any())
{
<span class="item-sep">—</span>
<i class="bi bi-circle-fill" style="color:#a371f7;font-size:0.55rem;flex-shrink:0"></i>
<span class="item-color">@string.Join(" / ", item.Colors)</span>
}
@if (item.HasSandblasting)
{
<i class="bi bi-tornado item-flag" title="Sandblast"></i>
}
@if (item.HasMasking)
{
<i class="bi bi-mask item-flag" title="Masking"></i>
}
</span>
}
</div>
@if (!string.IsNullOrWhiteSpace(job.SpecialInstructions))
{
<div class="special-instructions">
<i class="bi bi-exclamation-circle-fill me-2" style="color:#ffc107;flex-shrink:0"></i>@job.SpecialInstructions
</div>
}
</div>
<!-- Progress Strip -->
@if (allStatuses.Any())
{
<div class="progress-strip">
@for (int s = 0; s < allStatuses.Count; s++)
{
var status = allStatuses[s];
var isCurrent = status.StatusCode == job.StatusCode;
var isDone = status.DisplayOrder < job.StatusDisplayOrder;
var stepClass = isCurrent ? "current" : (isDone ? "done" : "");
<div class="progress-step">
<span class="step-label @stepClass">@status.DisplayName</span>
@if (s < allStatuses.Count - 1)
{
<span class="step-arrow @(isDone ? "done" : "")"><i class="bi bi-chevron-right"></i></span>
}
</div>
}
</div>
}
<!-- Advance hint -->
@if (hasNext)
{
<div class="advance-hint">
<i class="bi bi-hand-index me-1"></i>Tap to advance to <strong>@job.NextStatusDisplayName</strong>
</div>
}
</div>
}
}
</div>
<!-- Advance Status Modal -->
<div class="modal fade" id="advanceModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-arrow-right-circle me-2 text-primary"></i>Advance Job Status</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body py-4">
<p class="fs-5 mb-1">Move <strong id="modal-job-number" class="text-primary"></strong> to:</p>
<p class="fs-4 fw-bold mt-2" id="modal-next-status"></p>
<p class="text-muted small mb-0">This will update the job status immediately.</p>
</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" id="btn-confirm-advance">
<i class="bi bi-check-lg me-2"></i>Yes, Advance
</button>
</div>
</div>
</div>
</div>
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="~/lib/microsoft/signalr/dist/browser/signalr.min.js"></script>
<script>
// Live clock
function updateClock() {
const now = new Date();
document.getElementById('clock').textContent =
String(now.getHours()).padStart(2, '0') + ':' +
String(now.getMinutes()).padStart(2, '0') + ':' +
String(now.getSeconds()).padStart(2, '0');
}
updateClock();
setInterval(updateClock, 1000);
// Advance status modal
let pendingJobId = null;
let pendingStatusId = null;
const advanceModal = new bootstrap.Modal(document.getElementById('advanceModal'));
function openAdvanceModal(jobId, jobNumber, nextStatusId, nextStatusName, nextStatusColor) {
pendingJobId = jobId;
pendingStatusId = nextStatusId;
document.getElementById('modal-job-number').textContent = jobNumber;
document.getElementById('modal-next-status').innerHTML =
`<span class="badge bg-${nextStatusColor} fs-5 px-3 py-2">${nextStatusName}</span>`;
advanceModal.show();
}
// Use event delegation so Razor encoding doesn't break onclick attributes
document.addEventListener('click', function (e) {
const card = e.target.closest('.job-card:not(.no-advance)');
if (!card) return;
const nextStatusId = card.dataset.nextStatusId;
if (!nextStatusId) return;
openAdvanceModal(
parseInt(card.dataset.jobId),
card.dataset.jobNumber,
parseInt(nextStatusId),
card.dataset.nextStatusName,
card.dataset.nextStatusColor
);
});
document.getElementById('btn-confirm-advance').addEventListener('click', async function () {
if (!pendingJobId || !pendingStatusId) return;
const btn = this;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving...';
const card = document.getElementById('job-card-' + pendingJobId);
if (card) card.classList.add('advancing');
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const resp = await fetch('@Url.Action("AdvanceJobStatus", "Jobs")', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': token
},
body: JSON.stringify({ jobId: pendingJobId, newStatusId: pendingStatusId })
});
const result = await resp.json();
if (result.success) {
advanceModal.hide();
// Update badge in place
const badge = document.getElementById('status-badge-' + pendingJobId);
if (badge) {
badge.className = `badge bg-${result.newStatusColorClass} badge-xl`;
badge.textContent = result.newStatusDisplayName;
}
// Reload page after short delay so progress strip refreshes
setTimeout(() => location.reload(), 1200);
} else {
alert('Error: ' + (result.message || 'Could not advance status'));
if (card) card.classList.remove('advancing');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-check-lg me-2"></i>Yes, Advance';
}
} catch (err) {
alert('Network error. Please try again.');
if (card) card.classList.remove('advancing');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-check-lg me-2"></i>Yes, Advance';
}
});
</script>
<script>
// SignalR — real-time updates
(function () {
const connection = new signalR.HubConnectionBuilder()
.withUrl('/hubs/shop')
.withAutomaticReconnect()
.build();
const statusEl = document.getElementById('shopHubStatus');
function setStatus(text, color) {
if (statusEl) { statusEl.textContent = text; statusEl.style.color = color || ''; }
}
connection.onreconnecting(() => setStatus('Reconnecting...', '#fd7e14'));
connection.onreconnected(() => setStatus('Live', '#198754'));
connection.onclose(() => setStatus('Disconnected', '#dc3545'));
// Daily Board changed order/priority/worker — reload job list
connection.on('DailyBoardUpdated', function () {
const url = new URL(window.location.href);
fetch(url.toString(), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(() => location.reload())
.catch(() => {});
});
// A job status was advanced — reload so progress strip updates
connection.on('JobStatusChanged', function () {
location.reload();
});
connection.start()
.then(() => setStatus('Live', '#198754'))
.catch(() => setStatus('Offline — refresh manually', '#dc3545'));
})();
</script>
</body>
</html>
@@ -1,629 +0,0 @@
@model List<PowderCoating.Application.DTOs.Job.JobDailyPriorityDto>
@{
Layout = null;
var workers = (ViewBag.Workers as IEnumerable<dynamic>) ?? Array.Empty<dynamic>();
var currentWorkerId = ViewBag.CurrentWorkerId as string;
var allStatuses = ViewBag.AllStatuses as List<JobStatusLookup> ?? new();
var activeCount = Model.Count;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Shop Floor</title>
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/lib/bootstrap-icons/font/bootstrap-icons.css" />
<style>
:root { --priority-strip-width: 6px; }
* { -webkit-tap-highlight-color: transparent; }
body {
background: #f0f2f5;
font-family: 'Segoe UI', system-ui, sans-serif;
padding-bottom: env(safe-area-inset-bottom, 16px);
}
/* ── Header ── */
.mobile-header {
background: #1a1a2e;
color: #fff;
padding: 12px 16px;
padding-top: max(12px, env(safe-area-inset-top));
position: sticky;
top: 0;
z-index: 100;
}
.mobile-header h1 {
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
margin: 0;
}
.mobile-header .badge-count {
background: rgba(255,255,255,0.15);
border-radius: 20px;
padding: 2px 10px;
font-size: 0.8rem;
}
/* ── Filter bar ── */
.filter-bar {
background: #fff;
border-bottom: 1px solid #e0e0e0;
padding: 10px 16px;
display: flex;
gap: 8px;
align-items: center;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.filter-bar::-webkit-scrollbar { display: none; }
.filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 20px;
border: 1.5px solid #dee2e6;
background: #fff;
font-size: 0.8rem;
white-space: nowrap;
cursor: pointer;
text-decoration: none;
color: #495057;
transition: all 0.15s;
}
.filter-chip.active {
background: #1a1a2e;
border-color: #1a1a2e;
color: #fff;
}
/* ── Status group header ── */
.status-group-header {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
color: #6c757d;
padding: 14px 16px 6px;
}
/* ── Job card ── */
.job-card {
background: #fff;
border-radius: 12px;
margin: 0 12px 10px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
overflow: hidden;
display: flex;
}
.priority-strip {
width: var(--priority-strip-width);
flex-shrink: 0;
}
.card-inner {
flex: 1;
min-width: 0;
padding: 12px 14px;
}
/* Card header row */
.card-job-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 4px;
}
.job-number {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.5px;
color: #6c757d;
}
.customer-name {
font-size: 1rem;
font-weight: 600;
color: #1a1a2e;
line-height: 1.2;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Meta row */
.card-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.meta-badge {
font-size: 0.72rem;
padding: 2px 8px;
border-radius: 10px;
border: 1px solid currentColor;
display: inline-flex;
align-items: center;
gap: 3px;
}
/* Items list */
.items-list {
font-size: 0.8rem;
color: #495057;
margin-bottom: 10px;
}
.item-row {
display: flex;
align-items: baseline;
gap: 6px;
padding: 2px 0;
}
.item-qty { font-weight: 700; color: #1a1a2e; flex-shrink: 0; }
/* Colors row */
.colors-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 10px;
}
.color-chip {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 10px;
background: #f8f9fa;
border: 1px solid #dee2e6;
color: #495057;
}
/* Special instructions */
.special-instructions {
font-size: 0.78rem;
color: #856404;
background: #fff3cd;
border-radius: 6px;
padding: 6px 10px;
margin-bottom: 10px;
display: flex;
gap: 6px;
align-items: flex-start;
}
/* Action row */
.card-actions {
display: flex;
gap: 8px;
align-items: stretch;
}
.btn-advance {
flex: 1;
padding: 12px 16px;
border-radius: 10px;
font-size: 0.88rem;
font-weight: 600;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: opacity 0.15s, transform 0.1s;
min-height: 50px;
}
.btn-advance:active { transform: scale(0.97); opacity: 0.85; }
.btn-advance:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-details {
width: 50px;
height: 50px;
border-radius: 10px;
border: 1.5px solid #dee2e6;
background: #fff;
color: #6c757d;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
text-decoration: none;
flex-shrink: 0;
}
/* Feedback toast */
#statusToast {
position: fixed;
bottom: max(20px, env(safe-area-inset-bottom, 20px));
left: 50%;
transform: translateX(-50%);
z-index: 9999;
border-radius: 20px;
padding: 10px 20px;
font-size: 0.9rem;
font-weight: 600;
color: #fff;
opacity: 0;
transition: opacity 0.25s;
pointer-events: none;
}
#statusToast.show { opacity: 1; }
#statusToast.success { background: #198754; }
#statusToast.danger { background: #dc3545; }
/* Empty state */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #6c757d;
}
.empty-state i { font-size: 3rem; opacity: 0.4; }
/* Auto-refresh indicator */
.refresh-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: #28a745;
animation: pulse 2s infinite;
display: inline-block;
}
@@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ── Dark mode toggle button ── */
.btn-theme-toggle {
background: none;
border: none;
color: rgba(255,255,255,0.65);
font-size: 1.2rem;
cursor: pointer;
padding: 0;
line-height: 1;
transition: color 0.15s;
}
.btn-theme-toggle:hover { color: #fff; }
/* ══════════════════════════════════════════
Shop Dark Mode (body.shop-dark)
══════════════════════════════════════════ */
body.shop-dark {
background: #0f1117;
color: #d0d6e0;
}
body.shop-dark .filter-bar {
background: #1a1b26;
border-bottom-color: #2a2b3a;
}
body.shop-dark .filter-chip {
background: #252636;
border-color: #3a3b50;
color: #a0aabb;
}
body.shop-dark .filter-chip.active {
background: #4a5080;
border-color: #4a5080;
color: #fff;
}
body.shop-dark .status-group-header {
color: #6b7a92;
}
body.shop-dark .job-card {
background: #1e1f2e;
box-shadow: 0 1px 6px rgba(0,0,0,0.4);
}
body.shop-dark .job-number {
color: #6b7a92;
}
body.shop-dark .customer-name {
color: #e2e8f0;
}
body.shop-dark .items-list {
color: #8892a4;
}
body.shop-dark .item-qty {
color: #c8d0de;
}
body.shop-dark .color-chip {
background: #252636;
border-color: #3a3b50;
color: #8892a4;
}
body.shop-dark .special-instructions {
background: #2a1e00;
color: #ffc107;
}
body.shop-dark .btn-details {
background: #252636;
border-color: #3a3b50;
color: #8892a4;
}
body.shop-dark .empty-state {
color: #5a6478;
}
</style>
</head>
<body>
<!-- Header -->
<div class="mobile-header">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<h1><i class="bi bi-tools me-1"></i>Shop Floor</h1>
<span class="badge-count">@activeCount active</span>
</div>
<div class="d-flex align-items-center gap-3">
<span class="refresh-dot" title="Auto-refreshes every 60s"></span>
<button class="btn-theme-toggle" id="themeToggle" onclick="toggleShopTheme()" title="Toggle dark mode">
<i class="bi bi-moon-fill" id="themeIcon"></i>
</button>
<a href="/Jobs" class="text-white-50" style="font-size:1.3rem;">
<i class="bi bi-grid-3x3-gap"></i>
</a>
</div>
</div>
</div>
<!-- Worker filter chips -->
@if (workers.Any())
{
<div class="filter-bar">
<a href="@Url.Action("ShopMobile")"
class="filter-chip @(currentWorkerId == null ? "active" : "")">
<i class="bi bi-people"></i> All
</a>
@foreach (var w in workers)
{
<a href="@Url.Action("ShopMobile", new { workerId = w.Id })"
class="filter-chip @(currentWorkerId == w.Id.ToString() ? "active" : "")">
@w.Name
</a>
}
</div>
}
<!-- Job cards grouped by status -->
@{
var grouped = Model.GroupBy(j => new { j.StatusDisplayOrder, j.StatusDisplayName, j.StatusColorClass })
.OrderBy(g => g.Key.StatusDisplayOrder);
}
@if (!Model.Any())
{
<div class="empty-state">
<i class="bi bi-check2-all d-block mb-3"></i>
<strong>All clear!</strong>
<p class="mt-1 mb-0">No active jobs@(currentWorkerId != null ? " assigned to this worker" : "").</p>
</div>
}
else
{
@foreach (var group in grouped)
{
<div class="status-group-header">
<span class="badge bg-@group.Key.StatusColorClass me-1">&nbsp;</span>
@group.Key.StatusDisplayName
<span class="text-muted fw-normal">(@group.Count())</span>
</div>
@foreach (var job in group.OrderBy(j => j.DueDate).ThenBy(j => j.JobNumber))
{
var priorityColors = new Dictionary<string, string>
{
["success"] = "#198754",
["info"] = "#0dcaf0",
["warning"] = "#ffc107",
["danger"] = "#dc3545",
["dark"] = "#212529",
["secondary"] = "#6c757d",
["primary"] = "#0d6efd",
};
var stripColor = priorityColors.GetValueOrDefault(job.PriorityColorClass, "#6c757d");
var isOverdue = job.DueDate.HasValue && job.DueDate.Value < DateTime.Now;
<div class="job-card" id="job-@job.JobId">
<div class="priority-strip" style="background:@stripColor;"></div>
<div class="card-inner">
<!-- Job number + intake indicator -->
<div class="card-job-header">
<span class="job-number">@job.JobNumber</span>
<div class="d-flex gap-1 align-items-center">
@if (job.IntakeCompleted)
{
<span class="meta-badge text-success" title="Parts checked in">
<i class="bi bi-box-seam-fill"></i>
</span>
}
else
{
<a href="/Jobs/Intake/@job.JobId" class="meta-badge text-warning" title="Parts not yet checked in — tap to intake">
<i class="bi bi-box-seam"></i>
</a>
}
@if (isOverdue)
{
<span class="meta-badge text-danger">
<i class="bi bi-alarm"></i> Overdue
</span>
}
</div>
</div>
<!-- Customer name -->
<div class="customer-name">@job.CustomerName</div>
<!-- Meta badges -->
<div class="card-meta">
<span class="meta-badge text-secondary">
<i class="bi bi-box"></i> @job.TotalPieces pc@(job.TotalPieces != 1 ? "s" : "")
</span>
@if (!string.IsNullOrEmpty(job.AssignedWorkerName))
{
<span class="meta-badge text-primary">
<i class="bi bi-person"></i> @job.AssignedWorkerName
</span>
}
@if (job.HasSandblasting)
{
<span class="meta-badge text-warning">
<i class="bi bi-wind"></i> Blast
</span>
}
@if (job.HasMasking)
{
<span class="meta-badge text-info">
<i class="bi bi-pencil-square"></i> Mask
</span>
}
@if (job.DueDate.HasValue)
{
<span class="meta-badge @(isOverdue ? "text-danger" : "text-secondary")">
<i class="bi bi-calendar"></i> @job.DueDate.Value.ToString("MMM d")
</span>
}
</div>
<!-- Items -->
@if (job.Items.Any())
{
<div class="items-list">
@foreach (var item in job.Items.Take(3))
{
<div class="item-row">
<span class="item-qty">×@((int)item.Quantity)</span>
<span>@item.Description</span>
</div>
}
@if (job.Items.Count > 3)
{
<div class="item-row text-muted">
<span>+ @(job.Items.Count - 3) more item@(job.Items.Count - 3 != 1 ? "s" : "")</span>
</div>
}
</div>
}
<!-- Colors -->
@if (job.Colors.Any())
{
<div class="colors-row">
@foreach (var color in job.Colors)
{
<span class="color-chip"><i class="bi bi-palette2 me-1"></i>@color</span>
}
</div>
}
<!-- Special instructions -->
@if (!string.IsNullOrEmpty(job.SpecialInstructions))
{
<div class="special-instructions">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0"></i>
<span>@job.SpecialInstructions</span>
</div>
}
<!-- Actions -->
<div class="card-actions">
@if (job.NextStatusId.HasValue)
{
<button class="btn-advance bg-@(job.NextStatusColorClass ?? "primary") text-white"
onclick="advanceStatus(@job.JobId, @job.NextStatusId.Value, this)"
data-job-id="@job.JobId">
<i class="bi bi-arrow-right-circle"></i>
@job.NextStatusDisplayName
</button>
}
else
{
<div class="btn-advance bg-success text-white" style="cursor:default;">
<i class="bi bi-check-circle"></i> Final Stage
</div>
}
<a href="/Jobs/Details/@job.JobId" class="btn-details" title="Job details">
<i class="bi bi-three-dots-vertical"></i>
</a>
</div>
</div>
</div>
}
}
}
<!-- Feedback toast -->
<div id="statusToast"></div>
<script>
// ── Shop dark mode (localStorage, independent of main app theme) ──
const SHOP_DARK_KEY = 'shopFloorDark';
function applyShopTheme(dark) {
document.body.classList.toggle('shop-dark', dark);
document.getElementById('themeIcon').className = dark ? 'bi bi-sun-fill' : 'bi bi-moon-fill';
}
function toggleShopTheme() {
const nowDark = !document.body.classList.contains('shop-dark');
localStorage.setItem(SHOP_DARK_KEY, nowDark ? '1' : '0');
applyShopTheme(nowDark);
}
// Apply saved preference immediately (before first paint if possible)
applyShopTheme(localStorage.getItem(SHOP_DARK_KEY) === '1');
const antiForgeryToken = '@Html.AntiForgeryToken()'.match(/value="([^"]+)"/)?.[1] ?? '';
async function advanceStatus(jobId, newStatusId, btn) {
btn.disabled = true;
const originalHtml = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const resp = await fetch('/Jobs/AdvanceJobStatus', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': antiForgeryToken
},
body: JSON.stringify({ jobId, newStatusId })
});
const data = await resp.json();
if (data.success) {
showToast(data.message ?? 'Status updated', 'success');
// Reload after short delay so user sees the toast
setTimeout(() => location.reload(), 1200);
} else {
showToast(data.message ?? 'Update failed', 'danger');
btn.innerHTML = originalHtml;
btn.disabled = false;
}
} catch (e) {
showToast('Network error — try again', 'danger');
btn.innerHTML = originalHtml;
btn.disabled = false;
}
}
function showToast(msg, type) {
const t = document.getElementById('statusToast');
t.textContent = msg;
t.className = `show ${type}`;
setTimeout(() => t.className = type, 2500);
}
// Auto-refresh every 60 seconds
setTimeout(() => location.reload(), 60000);
</script>
</body>
</html>
@@ -1,292 +0,0 @@
@{
ViewData["Title"] = "Update Job Status";
Layout = null;
var job = ViewBag.Job as PowderCoating.Core.Entities.Job;
var allStatuses = ViewBag.AllStatuses as List<PowderCoating.Core.Entities.JobStatusLookup>;
var jobId = (int)ViewBag.JobId;
// Determine next/previous status options
var currentOrder = job!.JobStatus.DisplayOrder;
var nextStatus = allStatuses!
.Where(s => s.DisplayOrder > currentOrder && s.StatusCode != "ONHOLD" && s.StatusCode != "CANCELLED")
.OrderBy(s => s.DisplayOrder)
.FirstOrDefault();
var onHoldStatus = allStatuses!.FirstOrDefault(s => s.StatusCode == "ONHOLD");
var isOnHold = job.JobStatus.StatusCode == "ONHOLD";
var isTerminal = job.JobStatus.IsTerminalStatus;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>@ViewData["Title"]</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f0f2f5;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 16px;
}
.card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.10);
width: 100%;
max-width: 420px;
overflow: hidden;
}
.card-header {
background: #1a56db;
color: white;
padding: 20px;
text-align: center;
}
.card-header .job-number {
font-size: 28px;
font-weight: 800;
letter-spacing: -0.5px;
}
.card-header .customer-name {
font-size: 14px;
opacity: 0.85;
margin-top: 4px;
}
.card-body {
padding: 24px 20px;
}
.status-section {
text-align: center;
margin-bottom: 28px;
}
.status-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: #6b7280;
margin-bottom: 10px;
}
.current-status-badge {
display: inline-block;
font-size: 20px;
font-weight: 700;
padding: 10px 24px;
border-radius: 50px;
color: white;
}
.action-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.btn-advance {
display: block;
width: 100%;
padding: 18px 20px;
background: #059669;
color: white;
border: none;
border-radius: 12px;
font-size: 18px;
font-weight: 700;
cursor: pointer;
text-align: center;
line-height: 1.3;
}
.btn-advance:active { opacity: 0.85; transform: scale(0.98); }
.btn-hold {
display: block;
width: 100%;
padding: 14px 20px;
background: white;
color: #d97706;
border: 2px solid #d97706;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
text-align: center;
}
.btn-hold:active { background: #fef3c7; }
.btn-resume {
display: block;
width: 100%;
padding: 18px 20px;
background: #2563eb;
color: white;
border: none;
border-radius: 12px;
font-size: 18px;
font-weight: 700;
cursor: pointer;
text-align: center;
}
.btn-resume:active { opacity: 0.85; }
.terminal-msg {
text-align: center;
padding: 16px;
color: #6b7280;
font-size: 15px;
line-height: 1.5;
}
.success-banner {
background: #d1fae5;
border: 2px solid #059669;
border-radius: 12px;
padding: 16px;
text-align: center;
color: #065f46;
font-weight: 600;
font-size: 15px;
margin-bottom: 16px;
}
.divider {
height: 1px;
background: #e5e7eb;
margin: 20px 0;
}
.meta-row {
display: flex;
justify-content: space-between;
font-size: 13px;
color: #6b7280;
margin-top: 20px;
}
/* Bootstrap color helpers inline for the badge */
.bg-primary { background-color: #0d6efd !important; }
.bg-secondary { background-color: #6c757d !important; }
.bg-success { background-color: #198754 !important; }
.bg-danger { background-color: #dc3545 !important; }
.bg-warning { background-color: #ffc107 !important; color: #212529 !important; }
.bg-info { background-color: #0dcaf0 !important; color: #212529 !important; }
.bg-dark { background-color: #212529 !important; }
.bg-purple { background-color: #6f42c1 !important; }
.bg-orange { background-color: #fd7e14 !important; }
</style>
</head>
<body>
<div class="card">
<div class="card-header">
<div class="job-number">@job.JobNumber</div>
<div class="customer-name">
@if (!string.IsNullOrWhiteSpace(job.Customer?.CompanyName))
{ @job.Customer.CompanyName }
else
{ @($"{job.Customer?.ContactFirstName} {job.Customer?.ContactLastName}".Trim()) }
</div>
</div>
<div class="card-body">
@if (TempData["StatusUpdated"] != null)
{
<div class="success-banner">
✓ Status updated successfully!
</div>
}
<div class="status-section">
<div class="status-label">Current Status</div>
<span class="current-status-badge bg-@job.JobStatus.ColorClass">
@job.JobStatus.DisplayName
</span>
@if (job.DueDate.HasValue)
{
<div style="margin-top: 10px; font-size: 13px; color: @(job.DueDate < DateTime.Today ? "#dc2626" : "#6b7280");">
Due: @job.DueDate.Value.ToString("MMM d, yyyy")
@if (job.DueDate < DateTime.Today && !isTerminal)
{
<strong style="color: #dc2626;"> — OVERDUE</strong>
}
</div>
}
</div>
<div class="action-section">
@if (isTerminal)
{
<div class="terminal-msg">
This job is <strong>@job.JobStatus.DisplayName</strong> and requires no further updates.
</div>
}
else if (isOnHold)
{
@* On hold — offer resume (next logical status after resume by advancing) *@
@if (nextStatus != null)
{
<form method="post" asp-action="StatusBump" asp-route-id="@jobId">
@Html.AntiForgeryToken()
<input type="hidden" name="newStatusId" value="@nextStatus.Id" />
<button type="submit" class="btn-resume">
▶ Resume — Move to @nextStatus.DisplayName
</button>
</form>
}
}
else
{
@* Advance to next step *@
@if (nextStatus != null)
{
<form method="post" asp-action="StatusBump" asp-route-id="@jobId">
@Html.AntiForgeryToken()
<input type="hidden" name="newStatusId" value="@nextStatus.Id" />
<button type="submit" class="btn-advance">
✓ Mark as @nextStatus.DisplayName
</button>
</form>
}
else
{
<div class="terminal-msg">No further stages available.</div>
}
@* On Hold option *@
@if (onHoldStatus != null)
{
<form method="post" asp-action="StatusBump" asp-route-id="@jobId">
@Html.AntiForgeryToken()
<input type="hidden" name="newStatusId" value="@onHoldStatus.Id" />
<button type="submit" class="btn-hold">
⏸ Put on Hold
</button>
</form>
}
}
</div>
<div class="divider"></div>
<div class="meta-row">
<span>Job created @job.CreatedAt.ToString("MMM d, yyyy")</span>
<span>@job.JobPriority?.DisplayName Priority</span>
</div>
</div>
</div>
</body>
</html>
@@ -1,666 +0,0 @@
@model PowderCoating.Application.DTOs.Job.JobDto
@using PowderCoating.Web.Controllers
@{
ViewData["Title"] = $"Work Order - {Model.JobNumber}";
Layout = null; // No layout for print
var company = ViewBag.Company as PowderCoating.Core.Entities.Company;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"]</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/lib/bootstrap-icons/font/bootstrap-icons.css">
<script>
// Auto-trigger print dialog when page loads
window.onload = function() {
// Small delay to ensure page is fully rendered
setTimeout(function() {
window.print();
}, 500);
};
</script>
<style>
@@media print {
.no-print {
display: none !important;
}
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.page-break {
page-break-after: always;
}
@@page {
margin: 0.4in 0.5in;
size: letter;
}
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 9pt;
line-height: 1.3;
}
.work-order-header {
border-bottom: 2px solid #0d6efd;
padding-bottom: 10px;
margin-bottom: 15px;
}
.company-logo {
max-height: 60px;
max-width: 150px;
margin-bottom: 5px;
}
.company-name {
font-size: 16pt;
font-weight: bold;
color: #0d6efd;
margin-bottom: 3px;
}
.company-info {
font-size: 8pt;
line-height: 1.4;
}
.work-order-title {
font-size: 20pt;
font-weight: bold;
text-align: center;
color: #333;
}
.section-title {
background-color: #f8f9fa;
border-left: 3px solid #0d6efd;
padding: 4px 8px;
margin-top: 12px;
margin-bottom: 8px;
font-weight: bold;
font-size: 11pt;
}
.info-row {
padding: 3px 0;
border-bottom: 1px solid #e9ecef;
}
.info-label {
font-weight: 600;
color: #6c757d;
margin-bottom: 2px;
font-size: 8pt;
}
.info-value {
color: #212529;
font-size: 9pt;
}
.items-table {
width: 100%;
margin-top: 8px;
border-collapse: collapse;
font-size: 8pt;
}
.items-table th {
background-color: #0d6efd;
color: white;
padding: 4px 6px;
text-align: left;
font-weight: 600;
font-size: 8pt;
}
.items-table td {
padding: 4px 6px;
border-bottom: 1px solid #dee2e6;
}
.items-table tbody tr:nth-child(even) {
background-color: #f8f9fa;
}
.badge {
padding: 2px 8px;
border-radius: 3px;
font-weight: 600;
font-size: 8pt;
}
.prep-service-badge {
display: inline-block;
background-color: #d1e7dd;
color: #0f5132;
border: 1px solid #0f5132;
padding: 3px 8px;
margin: 2px;
border-radius: 3px;
font-weight: 600;
font-size: 8pt;
}
.signature-section {
margin-top: 15px;
page-break-inside: avoid;
}
.signature-line {
border-top: 1px solid #333;
margin-top: 25px;
padding-top: 3px;
font-size: 8pt;
}
.special-instructions {
background-color: #fff3cd;
border: 1px solid #ffc107;
padding: 8px;
border-radius: 3px;
margin-top: 8px;
font-size: 8pt;
}
.footer-note {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid #dee2e6;
font-size: 7pt;
color: #6c757d;
text-align: center;
}
.compact-section {
margin-bottom: 8px;
}
.item-card {
border: 1px solid #dee2e6;
border-radius: 4px;
margin-bottom: 10px;
page-break-inside: avoid;
}
.item-card-header {
background-color: #0d6efd;
color: white;
padding: 5px 10px;
border-radius: 3px 3px 0 0;
display: flex;
align-items: center;
gap: 10px;
}
.item-card-body {
padding: 8px 10px;
}
.coat-table {
width: 100%;
border-collapse: collapse;
font-size: 8pt;
margin-top: 4px;
}
.coat-table th {
background-color: #e9ecef;
padding: 3px 6px;
font-weight: 600;
font-size: 7.5pt;
color: #495057;
border: 1px solid #dee2e6;
}
.coat-table td {
padding: 3px 6px;
border: 1px solid #dee2e6;
vertical-align: middle;
}
.order-badge {
display: inline-block;
background-color: #ffc107;
color: #212529;
border: 1px solid #e0a800;
padding: 1px 5px;
border-radius: 3px;
font-weight: 700;
font-size: 7pt;
}
.sub-label {
font-size: 7.5pt;
font-weight: 600;
color: #6c757d;
margin-bottom: 2px;
}
</style>
</head>
<body>
<div class="container-fluid p-3">
<!-- Print Button (hidden when printing) -->
<div class="no-print mb-2">
<button onclick="window.print()" class="btn btn-primary btn-sm">
<i class="bi bi-printer me-2"></i>Print Work Order
</button>
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary btn-sm ms-2">
<i class="bi bi-arrow-left me-2"></i>Back to Job Details
</a>
</div>
<!-- Header -->
<div class="work-order-header">
<div class="row align-items-center">
<div class="col-6">
@if (company != null)
{
@* Company Logo *@
@if (!string.IsNullOrWhiteSpace(company.LogoFilePath) || company.LogoData != null)
{
<img src="@Url.Action("Logo", "CompanySettings")" alt="@company.CompanyName" class="company-logo" />
}
<div class="company-name">@company.CompanyName</div>
<div class="company-info">
@if (!string.IsNullOrWhiteSpace(company.Address))
{
<div>@company.Address</div>
}
@if (!string.IsNullOrWhiteSpace(company.Phone))
{
<span>Phone: @company.Phone</span>
}
@if (!string.IsNullOrWhiteSpace(company.PrimaryContactEmail))
{
<span class="ms-2">Email: @company.PrimaryContactEmail</span>
}
</div>
}
else
{
<div class="company-name">Powder Coating Shop</div>
}
</div>
<div class="col-6">
<div class="work-order-title">WORK ORDER</div>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
<div style="font-size: 8pt; line-height: 1.6; flex: 1; text-align: center;">
<div class="mb-2">
<span class="text-muted">Job #:</span>
<span style="font-size: 14pt;" class="fw-bold ms-1">@Model.JobNumber</span>
</div>
<div class="d-flex justify-content-center align-items-center gap-3 mb-1">
<div>
<span class="text-muted">Priority:</span>
<span class="badge bg-@Model.PriorityColorClass ms-1">@Model.PriorityDisplayName</span>
</div>
<div>
<span class="text-muted">Status:</span>
<span class="badge bg-@Model.StatusColorClass ms-1">@Model.StatusDisplayName</span>
</div>
</div>
<div class="text-center text-muted" style="font-size: 7pt;">Created: @Model.CreatedAt.ToString("MM/dd/yyyy")</div>
</div>
@if (ViewBag.ViewQrCodeBase64 != null)
{
<div style="text-align: center; flex-shrink: 0;">
<img src="data:image/png;base64,@ViewBag.ViewQrCodeBase64"
alt="View Job"
style="width: 64px; height: 64px; image-rendering: pixelated; display: block;" />
<div style="font-size: 6.5pt; color: #6c757d; margin-top: 2px;">View Job</div>
</div>
}
</div>
</div>
</div>
</div>
<!-- Customer & Job Information Combined -->
<div class="row compact-section">
<div class="col-6">
<div class="section-title">
<i class="bi bi-person-circle me-1"></i>Customer Information
</div>
<div class="info-row">
<div class="info-label">Company/Name</div>
<div class="info-value">
@if (!string.IsNullOrWhiteSpace(Model.CustomerCompanyName))
{
<strong>@Model.CustomerCompanyName</strong>
}
else
{
@Model.CustomerName
}
</div>
</div>
@if (!string.IsNullOrWhiteSpace(Model.CustomerContactName))
{
<div class="info-row">
<div class="info-label">Contact Person</div>
<div class="info-value">@Model.CustomerContactName</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(Model.CustomerPO))
{
<div class="info-row">
<div class="info-label">Customer PO</div>
<div class="info-value">@Model.CustomerPO</div>
</div>
}
</div>
<div class="col-6">
<div class="section-title">
<i class="bi bi-briefcase me-1"></i>Job Information
</div>
<div class="row">
@if (!string.IsNullOrWhiteSpace(Model.AssignedWorkerName))
{
<div class="col-6">
<div class="info-row">
<div class="info-label">Assigned Worker</div>
<div class="info-value">@Model.AssignedWorkerName</div>
</div>
</div>
}
@if (Model.DueDate.HasValue)
{
<div class="col-6">
<div class="info-row">
<div class="info-label">Due Date</div>
<div class="info-value">
<strong>@Model.DueDate.Value.ToString("MM/dd/yyyy")</strong>
</div>
</div>
</div>
}
@if (Model.ScheduledDate.HasValue)
{
<div class="col-6">
<div class="info-row">
<div class="info-label">Scheduled Date</div>
<div class="info-value">@Model.ScheduledDate.Value.ToString("MM/dd/yyyy")</div>
</div>
</div>
}
</div>
</div>
</div>
<!-- Description -->
@if (!string.IsNullOrWhiteSpace(Model.Description))
{
<div class="compact-section">
<div class="info-row">
<div class="info-label">Job Description</div>
<div class="info-value">@Model.Description</div>
</div>
</div>
}
<!-- Preparation Services -->
@if (Model.PrepServices != null && Model.PrepServices.Any())
{
<div class="compact-section">
<div class="info-label">
<i class="bi bi-tools me-1"></i>Preparation Services Required
</div>
<div class="mt-1">
@foreach (var service in Model.PrepServices)
{
<span class="prep-service-badge">
<i class="bi bi-check-circle-fill me-1"></i>@service.ServiceName
</span>
}
</div>
</div>
}
<!-- Job Items -->
@if (Model.Items != null && Model.Items.Any())
{
<div class="section-title">
<i class="bi bi-list-ul me-1"></i>Items to be Coated
</div>
@for (int i = 0; i < Model.Items.Count; i++)
{
var item = Model.Items[i];
<div class="item-card">
<!-- Item Header -->
<div class="item-card-header">
<span style="font-size: 10pt; font-weight: 700; min-width: 24px;">#@(i + 1)</span>
<span style="font-size: 10pt; font-weight: 700; flex: 1;">@item.Description</span>
<span style="font-size: 8pt; white-space: nowrap;">
Qty: <strong>@item.Quantity</strong>
</span>
@if (item.SurfaceAreaSqFt > 0)
{
<span style="font-size: 8pt; white-space: nowrap;">
Area: <strong>@item.SurfaceAreaSqFt.ToString("N2") sq ft</strong>
</span>
}
else if (item.SurfaceArea.HasValue)
{
<span style="font-size: 8pt; white-space: nowrap;">
Area: <strong>@item.SurfaceArea.Value.ToString("N2") sq ft</strong>
</span>
}
</div>
<div class="item-card-body">
<div class="row">
<!-- Coating Layers -->
<div class="@((item.PrepServices != null && item.PrepServices.Any()) ? "col-8" : "col-12")">
@if (item.Coats != null && item.Coats.Any())
{
<div class="sub-label"><i class="bi bi-layers me-1"></i>Coating Layers</div>
<table class="coat-table">
<thead>
<tr>
<th style="width: 12%;">Layer</th>
<th style="width: 20%;">Coat Name</th>
<th style="width: 18%;">Color</th>
<th style="width: 10%;">Code</th>
<th style="width: 15%;">Finish</th>
<th style="width: 15%;">Vendor</th>
<th style="width: 10%;">Powder</th>
</tr>
</thead>
<tbody>
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
{
var isCustom = !coat.InventoryItemId.HasValue && (coat.PowderCostPerLb.HasValue || coat.PowderToOrder.HasValue);
<tr>
<td style="text-align: center;">@coat.Sequence</td>
<td><strong>@coat.CoatName</strong></td>
<td>@(coat.ColorName ?? "—")</td>
<td>@(coat.ColorCode ?? "—")</td>
<td>@(coat.Finish ?? "—")</td>
<td>@(coat.VendorName ?? "—")</td>
<td>
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
{
if (isCustom)
{
<span class="order-badge">ORDER @coat.PowderToOrder.Value.ToString("N2") lbs</span>
}
else
{
<span>@coat.PowderToOrder.Value.ToString("N2") lbs</span>
}
}
else
{
<span class="text-muted">—</span>
}
</td>
</tr>
@if (!string.IsNullOrWhiteSpace(coat.Notes))
{
<tr>
<td></td>
<td colspan="6" style="font-size: 7.5pt; color: #6c757d; font-style: italic;">
<i class="bi bi-chat-left-text me-1"></i>@coat.Notes
</td>
</tr>
}
}
</tbody>
</table>
}
else
{
<span class="text-muted" style="font-size: 8pt;">No coat details specified.</span>
}
</div>
<!-- Prep Services -->
@if (item.PrepServices != null && item.PrepServices.Any())
{
<div class="col-4">
<div class="sub-label"><i class="bi bi-tools me-1"></i>Prep Services</div>
@foreach (var svc in item.PrepServices)
{
<div style="margin-bottom: 3px;">
<span class="prep-service-badge">
<i class="bi bi-check-circle-fill me-1"></i>@svc.PrepServiceName
</span>
@if (svc.EstimatedMinutes > 0)
{
<span style="font-size: 7.5pt; color: #6c757d; margin-left: 4px;">@svc.EstimatedMinutes min</span>
}
</div>
}
</div>
}
</div>
<!-- Item Notes -->
@if (!string.IsNullOrWhiteSpace(item.Notes))
{
<div style="margin-top: 6px; padding: 4px 8px; background: #fff3cd; border-left: 3px solid #ffc107; border-radius: 2px; font-size: 8pt;">
<strong>Notes:</strong> @item.Notes
</div>
}
</div>
</div>
}
}
<!-- Special Instructions -->
@if (!string.IsNullOrWhiteSpace(Model.SpecialInstructions))
{
<div class="compact-section">
<div class="section-title">
<i class="bi bi-exclamation-triangle me-1"></i>Special Instructions
</div>
<div class="special-instructions">
<pre style="white-space: pre-wrap; font-family: inherit; margin: 0;">@Model.SpecialInstructions</pre>
</div>
</div>
}
<!-- Signature Section -->
<div class="signature-section">
<div class="row">
<div class="col-4">
<div style="font-size: 8pt;">Worker Signature:</div>
<div class="signature-line"></div>
<div class="text-muted" style="font-size: 7pt;">Print Name: ___________________ Date: _______</div>
</div>
<div class="col-4">
<div style="font-size: 8pt;">Quality Control:</div>
<div class="signature-line"></div>
<div class="text-muted" style="font-size: 7pt;">Inspector: ___________________ Date: _______</div>
</div>
<div class="col-4">
<div style="font-size: 8pt;">Customer Approval:</div>
<div class="signature-line"></div>
<div class="text-muted" style="font-size: 7pt;">Signature: ___________________ Date: _______</div>
</div>
</div>
</div>
<!-- QR Codes Row: Status Bump + Powder Usage -->
@{
var powderQrCodes = ViewBag.PowderQrCodes as List<PowderQrCodeInfo>;
bool hasPowderQrs = powderQrCodes != null && powderQrCodes.Count > 0;
}
@if (ViewBag.QrCodeBase64 != null || hasPowderQrs)
{
<div style="margin-top: 14px; padding: 10px; border: 1px dashed #dee2e6; border-radius: 4px;">
<div style="font-size: 8pt; font-weight: 700; color: #6c757d; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 10px;">
<i class="bi bi-qr-code me-1"></i>Scan Codes
</div>
<div style="display: flex; flex-wrap: wrap; gap: 24px; align-items: flex-start;">
@* Status bump QR *@
@if (ViewBag.QrCodeBase64 != null)
{
<div style="display: flex; align-items: center; gap: 10px; min-width: 200px;">
<img src="data:image/png;base64,@ViewBag.QrCodeBase64"
alt="Status Update QR"
style="width: 80px; height: 80px; image-rendering: pixelated; flex-shrink: 0;" />
<div>
<div style="font-size: 8.5pt; font-weight: 700; margin-bottom: 3px;">
<i class="bi bi-arrow-right-circle me-1"></i>Update Status
</div>
<div style="font-size: 7.5pt; color: #6c757d; line-height: 1.5;">
Advance job to<br />next status.
</div>
</div>
</div>
}
@* Powder usage QRs — one per unique inventory item *@
@if (hasPowderQrs)
{
@foreach (var pqr in powderQrCodes!)
{
<div style="display: flex; align-items: center; gap: 10px; min-width: 200px;">
<img src="data:image/png;base64,@pqr.Base64"
alt="Powder Usage QR - @pqr.Name"
style="width: 80px; height: 80px; image-rendering: pixelated; flex-shrink: 0;" />
<div>
<div style="font-size: 8.5pt; font-weight: 700; margin-bottom: 2px;">
<i class="bi bi-box-seam me-1"></i>Log Usage
</div>
<div style="font-size: 8pt; font-weight: 600; margin-bottom: 1px;">@pqr.Name</div>
@if (!string.IsNullOrWhiteSpace(pqr.ColorCode))
{
<div style="font-size: 7.5pt; color: #6c757d;">Code: @pqr.ColorCode</div>
}
@if (!string.IsNullOrWhiteSpace(pqr.Manufacturer))
{
<div style="font-size: 7.5pt; color: #6c757d;">@pqr.Manufacturer</div>
}
@if (pqr.TotalLbs > 0)
{
<div style="font-size: 7.5pt; color: #0d6efd; font-weight: 600;">Est. @pqr.TotalLbs.ToString("N2") lbs</div>
}
</div>
</div>
}
}
</div>
</div>
}
<!-- Footer -->
<div class="footer-note">
Generated: @DateTime.Now.ToString("MM/dd/yyyy hh:mm tt")
</div>
</div>
</body>
</html>
@@ -1,164 +0,0 @@
@model PowderCoating.Application.DTOs.Job.JobDto
@{
var emailDefault = ViewBag.EmailDefaultOnComplete == true;
var preLoggedPowder = ViewBag.PreLoggedPowder as Dictionary<int, decimal> ?? new Dictionary<int, decimal>();
// Track remaining credit per InventoryItemId as we allocate it across coat rows
var remainingCredit = preLoggedPowder.ToDictionary(kv => kv.Key, kv => kv.Value);
}
<div class="modal fade" id="completeJobModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<form asp-action="CompleteJob" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="JobId" value="@Model.Id" />
<div class="modal-header bg-success bg-opacity-10">
<h5 class="modal-title">
<i class="bi bi-check-circle me-2 text-success"></i>Complete Job: @Model.JobNumber
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-4">
<label for="actualTimeSpent" class="form-label fw-semibold">
<i class="bi bi-clock me-1 text-primary"></i>Actual Time Spent (hours)
</label>
<input type="number" class="form-control" id="actualTimeSpent" name="ActualTimeSpentHours"
step="0.25" min="0" placeholder="Enter total hours spent on this job">
<div class="form-text">Enter the total time in hours (e.g., 2.5 for 2 hours 30 minutes)</div>
</div>
@if (Model.Items != null && Model.Items.Any())
{
<div class="mb-3">
<h6 class="fw-semibold mb-3">
<i class="bi bi-palette me-1 text-primary"></i>Actual Powder Usage
</h6>
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead class="table-light">
<tr>
<th>Item</th>
<th>Coat</th>
<th>Color</th>
<th class="text-end">Estimated (lbs)</th>
<th>Actual (lbs)</th>
</tr>
</thead>
<tbody>
@{
var coatIndex = 0;
}
@foreach (var item in Model.Items)
{
if (item.Coats != null && item.Coats.Any())
{
foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
{
<tr>
<td>
<small>@item.Description</small>
@if (item.Quantity > 1)
{
<span class="badge bg-secondary ms-1">&times;@item.Quantity</span>
}
</td>
<td><span class="badge bg-secondary">@coat.CoatName</span></td>
<td>
@if (!string.IsNullOrEmpty(coat.ColorName))
{
<small>
@coat.ColorName
@if (!string.IsNullOrEmpty(coat.ColorCode))
{
<span class="text-muted">(@coat.ColorCode)</span>
}
</small>
}
</td>
<td class="text-end">
<small class="text-muted">@((coat.PowderToOrder ?? 0).ToString("0.##"))</small>
</td>
<td>
@{
decimal preFilledLbs = 0m;
if (coat.InventoryItemId.HasValue && remainingCredit.TryGetValue(coat.InventoryItemId.Value, out var availCredit) && availCredit > 0)
{
preFilledLbs = availCredit;
remainingCredit[coat.InventoryItemId.Value] = 0m;
}
}
<input type="hidden" name="CoatUsages[@coatIndex].JobItemCoatId" value="@coat.Id" />
<input type="number"
class="form-control form-control-sm"
name="CoatUsages[@coatIndex].ActualPowderUsedLbs"
step="0.01" min="0" placeholder="0.00"
value="@(preFilledLbs > 0 ? preFilledLbs.ToString("0.##") : "")"
style="max-width: 120px;">
@if (preFilledLbs > 0)
{
<small class="text-success d-block mt-1">
<i class="bi bi-check-circle me-1"></i>Already logged — inventory adjusted
</small>
}
</td>
</tr>
coatIndex++;
}
}
else
{
<tr class="table-secondary">
<td colspan="5">
<small class="text-muted fst-italic">
<i class="bi bi-info-circle me-1"></i>
@item.Description — No coat information available (legacy job item)
</small>
</td>
</tr>
}
}
</tbody>
</table>
</div>
<div class="alert alert-info alert-permanent mb-0">
<i class="bi bi-info-circle me-2"></i>
<small>Pre-filled values were already logged via scan — inventory is already adjusted for those. You can edit the amount; only the difference will be applied to inventory.</small>
</div>
</div>
}
</div>
<div class="modal-footer justify-content-between">
<div class="d-flex align-items-center gap-2 flex-wrap">
@if (!string.IsNullOrWhiteSpace(Model.CustomerEmail))
{
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch"
id="completeJobSendEmail" name="SendEmailToCustomer" value="true"
@(emailDefault ? "checked" : "") />
<label class="form-check-label small" for="completeJobSendEmail">
<i class="bi bi-envelope me-1"></i>Notify customer via email
</label>
</div>
}
@if (Model.CustomerNotifyBySms && !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone))
{
<span class="badge bg-info text-white">
<i class="bi bi-phone me-1"></i>SMS notification will be sent
</span>
}
else if (!string.IsNullOrWhiteSpace(Model.CustomerMobilePhone) && !Model.CustomerNotifyBySms)
{
<span class="text-muted small"><i class="bi bi-phone-slash me-1"></i>SMS consent required</span>
}
</div>
<div>
<button type="button" class="btn btn-outline-secondary me-2" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">
<i class="bi bi-check-circle me-1"></i>Mark as Completed
</button>
</div>
</div>
</form>
</div>
</div>
</div>