Initial commit
This commit is contained in:
@@ -0,0 +1,559 @@
|
||||
@using PowderCoating.Web.Controllers
|
||||
@model List<JobBoardColumn>
|
||||
@{
|
||||
ViewData["Title"] = "Jobs Board";
|
||||
bool showTerminal = ViewBag.ShowTerminal == true;
|
||||
int totalTerminal = (int)(ViewBag.TotalTerminal ?? 0);
|
||||
|
||||
}
|
||||
|
||||
@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.dragging { opacity: .5; cursor: grabbing; }
|
||||
|
||||
/* 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">
|
||||
@* 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" 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)"> · @_hotCount overdue</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@* Right: actions *@
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<a href="@Url.Action("Board", new { showTerminal = !showTerminal })"
|
||||
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-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" : "")"
|
||||
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>
|
||||
</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>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/lib/sortablejs/Sortable.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const COMPANY_ID = '@(User.FindFirst("CompanyId")?.Value ?? "0")';
|
||||
|
||||
// ── 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';
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// ── 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 newStatus = parseInt(newColEl.closest('.board-column').dataset.statusId);
|
||||
const oldColEl = evt.from;
|
||||
|
||||
// No-op if dropped in same column
|
||||
if (newColEl === oldColEl && evt.newIndex === evt.oldIndex) 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 })
|
||||
})
|
||||
.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);
|
||||
} 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;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
@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()
|
||||
@if (ViewBag.TemplateId != null)
|
||||
{
|
||||
<input type="hidden" name="SourceTemplateId" value="@ViewBag.TemplateId">
|
||||
}
|
||||
<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 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 × 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>
|
||||
|
||||
<!-- Surface Area Calculator Modal -->
|
||||
<div class="modal fade" id="sqFtCalculatorModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-calculator me-2"></i>Surface Area Calculator <small class="text-muted">(per item)</small></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Shape</label>
|
||||
<select id="calcShape" class="form-select" onchange="toggleShapeInputs()">
|
||||
<option value="rectangle">Rectangle / Square</option>
|
||||
<option value="cylinder">Cylinder (Tube)</option>
|
||||
<option value="circle">Circle (Flat)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="rectangleInputs">
|
||||
<div class="row g-2">
|
||||
<div class="col-6"><label class="form-label">Length (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="rectLength" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
</div>
|
||||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
||||
</div>
|
||||
<div id="cylinderInputs" style="display:none">
|
||||
<div class="row g-2">
|
||||
<div class="col-6"><label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="cylDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
<div class="col-6"><label class="form-label">Height (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="cylHeight" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="circleInputs" style="display:none">
|
||||
<label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="circDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()">
|
||||
</div>
|
||||
<hr />
|
||||
<div class="alert alert-info mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
|
||||
<i class="bi bi-check-circle me-1"></i>Use This Value
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Wizard Modal -->
|
||||
<div class="modal fade" id="itemWizardModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="d-flex flex-column">
|
||||
<h5 class="modal-title mb-0" id="wizardTitle">Add Item</h5>
|
||||
<div class="text-muted small mb-1" id="wizardStepTitle">Choose Item Type</div>
|
||||
<div class="d-flex align-items-center gap-2" id="wizardStepIndicator">
|
||||
<span class="wizard-step-dot active" data-step="1" title="Item Type"></span>
|
||||
<div class="wizard-step-line"></div>
|
||||
<span class="wizard-step-dot" data-step="2" title="Item Details"></span>
|
||||
<div class="wizard-step-line" id="step2Line"></div>
|
||||
<span class="wizard-step-dot" data-step="3" title="Coating Layers" id="step3Dot"></span>
|
||||
<div class="wizard-step-line" id="step3Line"></div>
|
||||
<span class="wizard-step-dot" data-step="4" title="Prep Services" id="step4Dot"></span>
|
||||
<span class="text-muted small ms-2" id="wizardStepLabel">Step 1 of 4</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="wizardBody" style="min-height: 300px;"></div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary d-none" id="btnWizardBack" onclick="wizardBack()">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="btnWizardNext" onclick="wizardNext()">
|
||||
Next <i class="bi bi-arrow-right ms-1"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-success d-none" id="btnWizardSave" onclick="wizardSave()">
|
||||
<i class="bi bi-check-lg me-1"></i>Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embedded data for 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">
|
||||
<style>
|
||||
.wizard-step-dot {
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
background: #dee2e6; display: inline-block; cursor: default;
|
||||
border: 2px solid #dee2e6; transition: all .2s; flex-shrink: 0;
|
||||
}
|
||||
.wizard-step-dot.active { background: #0d6efd; border-color: #0d6efd; }
|
||||
.wizard-step-dot.done { background: #198754; border-color: #198754; }
|
||||
.wizard-step-dot.skip { background: #adb5bd; border-color: #adb5bd; }
|
||||
.wizard-step-line { flex: 1; height: 2px; background: #dee2e6; min-width: 30px; }
|
||||
.item-type-card {
|
||||
border: 2px solid #dee2e6; border-radius: .75rem; padding: 1.25rem 1rem;
|
||||
cursor: pointer; transition: all .15s; text-align: center;
|
||||
background: #fff; user-select: none;
|
||||
}
|
||||
.item-type-card:hover { border-color: #86b7fe; background: #f0f6ff; }
|
||||
.item-type-card.selected { border-color: #0d6efd; background: #eef3ff; }
|
||||
.item-type-card .item-type-icon { font-size: 2rem; margin-bottom: .5rem; }
|
||||
[data-bs-theme="dark"] .item-type-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
||||
[data-bs-theme="dark"] .item-type-card:hover { border-color: #86b7fe; background: var(--bs-secondary-bg); }
|
||||
[data-bs-theme="dark"] .item-type-card.selected { border-color: #0d6efd; background: #1a2a4a; }
|
||||
.catalog-list-item { cursor: pointer; border-bottom: 1px solid var(--bs-border-color); font-size: .9rem; transition: background .1s; }
|
||||
.catalog-list-item:last-child { border-bottom: none; }
|
||||
.catalog-list-item:hover { background: var(--bs-tertiary-bg); }
|
||||
.catalog-list-item.selected { background: #eef3ff; color: #0d6efd; font-weight: 600; }
|
||||
[data-bs-theme="dark"] .catalog-list-item.selected { background: #1a2a4a; color: #86b7fe; }
|
||||
.quote-item-card {
|
||||
border: 1px solid #dee2e6; border-radius: .5rem;
|
||||
padding: .75rem 1rem; margin-bottom: .5rem; background: #fafafa;
|
||||
}
|
||||
.quote-item-card .item-badge { font-size: .7rem; }
|
||||
.coat-row { border: 1px solid #dee2e6; border-radius: .5rem; padding: .75rem; margin-bottom: .5rem; }
|
||||
[data-bs-theme="dark"] .quote-item-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
||||
[data-bs-theme="dark"] .coat-row { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); }
|
||||
</style>
|
||||
}
|
||||
|
||||
@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>
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
@model PowderCoating.Application.DTOs.Job.JobDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Delete Job";
|
||||
ViewData["PageIcon"] = "bi-briefcase";
|
||||
}
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="d-flex justify-content-end align-items-center mb-4">
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-2"></i>Back to List
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Warning Banner -->
|
||||
<div class="alert alert-danger d-flex align-items-start mb-4">
|
||||
<i class="bi bi-exclamation-triangle-fill me-3" style="font-size: 1.5rem;"></i>
|
||||
<div>
|
||||
<h5 class="alert-heading mb-2">Are you sure you want to delete this job?</h5>
|
||||
<p class="mb-0">This action will mark the job as deleted. All related records (items, photos, notes) will be preserved but the job will no longer appear in active listings.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-danger shadow-sm">
|
||||
<div class="card-header bg-danger bg-opacity-10 border-danger">
|
||||
<h5 class="mb-0 text-danger">
|
||||
<i class="bi bi-briefcase-fill me-2"></i>Job to be Deleted
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Job Information -->
|
||||
<div class="mb-4">
|
||||
<h6 class="text-muted small text-uppercase mb-2">Job Information</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Job Number</label>
|
||||
<p class="fw-semibold mb-0">@Model.JobNumber</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Status</label>
|
||||
<p class="mb-0">
|
||||
<span class="badge bg-@Model.StatusColorClass">
|
||||
@Model.StatusDisplayName
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Customer</label>
|
||||
<p class="mb-0">@Model.CustomerName</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Priority</label>
|
||||
<p class="mb-0">
|
||||
<span class="badge bg-@Model.PriorityColorClass">
|
||||
@Model.PriorityDisplayName
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="text-muted small mb-1">Description</label>
|
||||
<p class="mb-0">@Model.Description</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<!-- Schedule Information -->
|
||||
<div class="mb-4">
|
||||
<h6 class="text-muted small text-uppercase mb-2">Schedule</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Scheduled Date</label>
|
||||
<p class="mb-0">
|
||||
@if (Model.ScheduledDate.HasValue)
|
||||
{
|
||||
<span>@Model.ScheduledDate.Value.ToString("MMMM dd, yyyy")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">Not scheduled</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Due Date</label>
|
||||
<p class="mb-0">
|
||||
@if (Model.DueDate.HasValue)
|
||||
{
|
||||
<span>@Model.DueDate.Value.ToString("MMMM dd, yyyy")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">Not set</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<!-- Pricing Information -->
|
||||
<div class="mb-4">
|
||||
<h6 class="text-muted small text-uppercase mb-2">Pricing Information</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Quoted Price</label>
|
||||
<p class="mb-0 fw-semibold">@Model.QuotedPrice.ToString("C")</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Final Price</label>
|
||||
<p class="mb-0 fw-semibold text-primary">@Model.FinalPrice.ToString("C")</p>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(Model.CustomerPO))
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Customer PO</label>
|
||||
<p class="mb-0">@Model.CustomerPO</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.StatusIsWIP)
|
||||
{
|
||||
<div class="alert alert-warning d-flex align-items-center">
|
||||
<i class="bi bi-exclamation-circle me-2"></i>
|
||||
<div>
|
||||
<strong>Warning:</strong> This job is currently in progress. Please ensure all work is properly documented before deletion.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Delete Form -->
|
||||
<div class="d-flex gap-2 justify-content-end pt-3 border-top mt-4">
|
||||
<a asp-action="Index" class="btn btn-outline-secondary px-4">
|
||||
<i class="bi bi-x-circle me-2"></i>Cancel
|
||||
</a>
|
||||
<form asp-action="Delete" method="post" class="d-inline">
|
||||
<input type="hidden" asp-for="Id" />
|
||||
<button type="submit" class="btn btn-danger px-4">
|
||||
<i class="bi bi-trash me-2"></i>Delete Job
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Information -->
|
||||
<div class="card border-0 shadow-sm mt-4">
|
||||
<div class="card-body">
|
||||
<h6 class="mb-3">
|
||||
<i class="bi bi-info-circle me-2 text-info"></i>What happens when you delete a job?
|
||||
</h6>
|
||||
<ul class="mb-0">
|
||||
<li>The job will be marked as deleted (soft delete)</li>
|
||||
<li>It will no longer appear in active job listings</li>
|
||||
<li>All related items, photos, and notes will be preserved</li>
|
||||
<li>Historical records and reports will still include this job</li>
|
||||
<li>Administrators can restore deleted jobs if needed</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,532 @@
|
||||
@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 × 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);
|
||||
}
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<!-- Surface Area Calculator Modal -->
|
||||
<div class="modal fade" id="sqFtCalculatorModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-calculator me-2"></i>Surface Area Calculator <small class="text-muted">(per item)</small></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Shape</label>
|
||||
<select id="calcShape" class="form-select" onchange="toggleShapeInputs()">
|
||||
<option value="rectangle">Rectangle / Square</option>
|
||||
<option value="cylinder">Cylinder (Tube)</option>
|
||||
<option value="circle">Circle (Flat)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="rectangleInputs">
|
||||
<div class="row g-2">
|
||||
<div class="col-6"><label class="form-label">Length (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="rectLength" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
</div>
|
||||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
||||
</div>
|
||||
<div id="cylinderInputs" style="display:none">
|
||||
<div class="row g-2">
|
||||
<div class="col-6"><label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="cylDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
<div class="col-6"><label class="form-label">Height (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="cylHeight" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="circleInputs" style="display:none">
|
||||
<label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="circDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()">
|
||||
</div>
|
||||
<hr />
|
||||
<div class="alert alert-info mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
|
||||
<i class="bi bi-check-circle me-1"></i>Use This Value
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Wizard Modal -->
|
||||
<div class="modal fade" id="itemWizardModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="d-flex flex-column">
|
||||
<h5 class="modal-title mb-0" id="wizardTitle">Add Item</h5>
|
||||
<div class="text-muted small mb-1" id="wizardStepTitle">Choose Item Type</div>
|
||||
<div class="d-flex align-items-center gap-2" id="wizardStepIndicator">
|
||||
<span class="wizard-step-dot active" data-step="1" title="Item Type"></span>
|
||||
<div class="wizard-step-line"></div>
|
||||
<span class="wizard-step-dot" data-step="2" title="Item Details"></span>
|
||||
<div class="wizard-step-line" id="step2Line"></div>
|
||||
<span class="wizard-step-dot" data-step="3" title="Coating Layers" id="step3Dot"></span>
|
||||
<div class="wizard-step-line" id="step3Line"></div>
|
||||
<span class="wizard-step-dot" data-step="4" title="Prep Services" id="step4Dot"></span>
|
||||
<span class="text-muted small ms-2" id="wizardStepLabel">Step 1 of 4</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="wizardBody" style="min-height: 300px;"></div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary d-none" id="btnWizardBack" onclick="wizardBack()">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="btnWizardNext" onclick="wizardNext()">
|
||||
Next <i class="bi bi-arrow-right ms-1"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-success d-none" id="btnWizardSave" onclick="wizardSave()">
|
||||
<i class="bi bi-check-lg me-1"></i>Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embedded data for 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,
|
||||
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">
|
||||
<style>
|
||||
.wizard-step-dot {
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
background: #dee2e6; display: inline-block; cursor: default;
|
||||
border: 2px solid #dee2e6; transition: all .2s; flex-shrink: 0;
|
||||
}
|
||||
.wizard-step-dot.active { background: #0d6efd; border-color: #0d6efd; }
|
||||
.wizard-step-dot.done { background: #198754; border-color: #198754; }
|
||||
.wizard-step-dot.skip { background: #adb5bd; border-color: #adb5bd; }
|
||||
.wizard-step-line { flex: 1; height: 2px; background: #dee2e6; min-width: 30px; }
|
||||
.item-type-card {
|
||||
border: 2px solid #dee2e6; border-radius: .75rem; padding: 1.25rem 1rem;
|
||||
cursor: pointer; transition: all .15s; text-align: center;
|
||||
background: #fff; user-select: none;
|
||||
}
|
||||
.item-type-card:hover { border-color: #86b7fe; background: #f0f6ff; }
|
||||
.item-type-card.selected { border-color: #0d6efd; background: #eef3ff; }
|
||||
.item-type-card .item-type-icon { font-size: 2rem; margin-bottom: .5rem; }
|
||||
[data-bs-theme="dark"] .item-type-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
||||
[data-bs-theme="dark"] .item-type-card:hover { border-color: #86b7fe; background: var(--bs-secondary-bg); }
|
||||
[data-bs-theme="dark"] .item-type-card.selected { border-color: #0d6efd; background: #1a2a4a; }
|
||||
.catalog-list-item { cursor: pointer; border-bottom: 1px solid var(--bs-border-color); font-size: .9rem; transition: background .1s; }
|
||||
.catalog-list-item:last-child { border-bottom: none; }
|
||||
.catalog-list-item:hover { background: var(--bs-tertiary-bg); }
|
||||
.catalog-list-item.selected { background: #eef3ff; color: #0d6efd; font-weight: 600; }
|
||||
[data-bs-theme="dark"] .catalog-list-item.selected { background: #1a2a4a; color: #86b7fe; }
|
||||
.quote-item-card {
|
||||
border: 1px solid #dee2e6; border-radius: .5rem;
|
||||
padding: .75rem 1rem; margin-bottom: .5rem; background: #fafafa;
|
||||
}
|
||||
.quote-item-card .item-badge { font-size: .7rem; }
|
||||
.coat-row { border: 1px solid #dee2e6; border-radius: .5rem; padding: .75rem; margin-bottom: .5rem; }
|
||||
[data-bs-theme="dark"] .quote-item-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
||||
[data-bs-theme="dark"] .coat-row { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); }
|
||||
</style>
|
||||
}
|
||||
|
||||
@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>
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
@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" />
|
||||
|
||||
@if (!ViewData.ModelState.IsValid)
|
||||
{
|
||||
<div class="alert alert-danger 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>
|
||||
|
||||
<!-- Surface Area Calculator Modal -->
|
||||
<div class="modal fade" id="sqFtCalculatorModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-calculator me-2"></i>Surface Area Calculator <small class="text-muted">(per item)</small></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Shape</label>
|
||||
<select id="calcShape" class="form-select" onchange="toggleShapeInputs()">
|
||||
<option value="rectangle">Rectangle / Square</option>
|
||||
<option value="cylinder">Cylinder (Tube)</option>
|
||||
<option value="circle">Circle (Flat)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="rectangleInputs">
|
||||
<div class="row g-2">
|
||||
<div class="col-6"><label class="form-label">Length (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="rectLength" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
</div>
|
||||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
||||
</div>
|
||||
<div id="cylinderInputs" style="display:none">
|
||||
<div class="row g-2">
|
||||
<div class="col-6"><label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="cylDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
<div class="col-6"><label class="form-label">Height (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="cylHeight" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="circleInputs" style="display:none">
|
||||
<label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="circDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()">
|
||||
</div>
|
||||
<hr />
|
||||
<div class="alert alert-info mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
|
||||
<i class="bi bi-check-circle me-1"></i>Use This Value
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================= ITEM WIZARD MODAL ========================= -->
|
||||
<div class="modal fade" id="itemWizardModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="d-flex flex-column">
|
||||
<h5 class="modal-title mb-0" id="wizardTitle">Add Item</h5>
|
||||
<div class="text-muted small mb-1" id="wizardStepTitle">Choose Item Type</div>
|
||||
<div class="d-flex align-items-center gap-2" id="wizardStepIndicator">
|
||||
<span class="wizard-step-dot active" data-step="1" title="Item Type"></span>
|
||||
<div class="wizard-step-line"></div>
|
||||
<span class="wizard-step-dot" data-step="2" title="Item Details"></span>
|
||||
<div class="wizard-step-line" id="step2Line"></div>
|
||||
<span class="wizard-step-dot" data-step="3" title="Coating Layers" id="step3Dot"></span>
|
||||
<div class="wizard-step-line" id="step3Line"></div>
|
||||
<span class="wizard-step-dot" data-step="4" title="Prep Services" id="step4Dot"></span>
|
||||
<span class="text-muted small ms-2" id="wizardStepLabel">Step 1 of 4</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="wizardBody" style="min-height: 300px;">
|
||||
<!-- Content injected by JS -->
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary d-none" id="btnWizardBack" onclick="wizardBack()">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="btnWizardNext" onclick="wizardNext()">
|
||||
Next <i class="bi bi-arrow-right ms-1"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-success d-none" id="btnWizardSave" onclick="wizardSave()">
|
||||
<i class="bi bi-check-lg me-1"></i>Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embedded data for 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,
|
||||
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": null,
|
||||
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
|
||||
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
|
||||
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
|
||||
"itemsFieldPrefix": "JobItems",
|
||||
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
|
||||
}
|
||||
</script>
|
||||
|
||||
@section Styles {
|
||||
<style>
|
||||
/* Wizard step indicator */
|
||||
.wizard-step-dot {
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
background: #dee2e6; display: inline-block; cursor: default;
|
||||
border: 2px solid #dee2e6; transition: all .2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wizard-step-dot.active { background: #0d6efd; border-color: #0d6efd; }
|
||||
.wizard-step-dot.done { background: #198754; border-color: #198754; }
|
||||
.wizard-step-dot.skip { background: #adb5bd; border-color: #adb5bd; }
|
||||
.wizard-step-line { flex: 1; height: 2px; background: #dee2e6; min-width: 30px; }
|
||||
/* Item type picker cards */
|
||||
.item-type-card {
|
||||
border: 2px solid #dee2e6; border-radius: .75rem; padding: 1.25rem 1rem;
|
||||
cursor: pointer; transition: all .15s; text-align: center;
|
||||
background: #fff; user-select: none;
|
||||
}
|
||||
.item-type-card:hover { border-color: #86b7fe; background: #f0f6ff; }
|
||||
.item-type-card.selected { border-color: #0d6efd; background: #eef3ff; }
|
||||
.item-type-card .item-type-icon { font-size: 2rem; margin-bottom: .5rem; }
|
||||
.catalog-list-item { cursor: pointer; border-bottom: 1px solid var(--bs-border-color); font-size: .9rem; transition: background .1s; }
|
||||
.catalog-list-item:last-child { border-bottom: none; }
|
||||
.catalog-list-item:hover { background: var(--bs-tertiary-bg); }
|
||||
.catalog-list-item.selected { background: #eef3ff; color: #0d6efd; font-weight: 600; }
|
||||
[data-bs-theme="dark"] .catalog-list-item.selected { background: #1a2a4a; color: #86b7fe; }
|
||||
/* Summary cards */
|
||||
.quote-item-card {
|
||||
border: 1px solid #dee2e6; border-radius: .5rem;
|
||||
padding: .75rem 1rem; margin-bottom: .5rem;
|
||||
background: #fafafa;
|
||||
}
|
||||
.quote-item-card .item-badge { font-size: .7rem; }
|
||||
/* Coat rows in wizard */
|
||||
.coat-row { border: 1px solid #dee2e6; border-radius: .5rem; padding: .75rem; margin-bottom: .5rem; }
|
||||
</style>
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
|
||||
}
|
||||
@@ -0,0 +1,960 @@
|
||||
@model PagedResult<PowderCoating.Application.DTOs.Job.JobListDto>
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Jobs";
|
||||
ViewData["PageIcon"] = "bi-briefcase";
|
||||
ViewData["PageHelpTitle"] = "Jobs";
|
||||
ViewData["PageHelpContent"] = "A Job is the active work order once a customer's quote is approved. Jobs track the full lifecycle from intake through coating, curing, quality check, and delivery. Each job can contain multiple items with individual coating specifications. Priority and due date help the shop floor triage workload.";
|
||||
}
|
||||
|
||||
@{
|
||||
var _wip = Model.Items.Count(j => j.StatusIsWIP);
|
||||
var _done = Model.Items.Count(j => j.StatusCode == "COMPLETED" || j.StatusCode == "READYFORPICKUP" || j.StatusCode == "DELIVERED");
|
||||
var _overdue = Model.Items.Count(j => j.DueDate.HasValue && j.DueDate.Value < DateTime.Now && j.StatusCode != "COMPLETED" && j.StatusCode != "READYFORPICKUP" && j.StatusCode != "DELIVERED" && j.StatusCode != "CANCELLED");
|
||||
var _value = Model.Items.Sum(j => j.FinalPrice);
|
||||
}
|
||||
<div class="pcl-metric-strip">
|
||||
<div class="pcl-metric-strip-cell">
|
||||
@await Html.PartialAsync("_Metric", (Label: "TOTAL", Value: Model.TotalCount.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
|
||||
</div>
|
||||
<div class="pcl-metric-strip-cell">
|
||||
@await Html.PartialAsync("_Metric", (Label: "IN PROGRESS", Value: _wip.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
|
||||
</div>
|
||||
<div class="pcl-metric-strip-cell">
|
||||
@await Html.PartialAsync("_Metric", (Label: "COMPLETED", Value: _done.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
|
||||
</div>
|
||||
<div class="pcl-metric-strip-cell">
|
||||
@await Html.PartialAsync("_Metric", (Label: "OVERDUE", Value: _overdue.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
|
||||
</div>
|
||||
<div class="pcl-metric-strip-cell">
|
||||
@await Html.PartialAsync("_Metric", (Label: "VALUE", Value: _value.ToString("C0"), Delta: (string?)null, DeltaDir: (string?)null))
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm as string))
|
||||
{
|
||||
<div class="alert alert-info alert-permanent d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi bi-funnel me-2"></i>
|
||||
Showing <strong>@Model.TotalCount</strong> job(s) matching "<strong>@ViewBag.SearchTerm</strong>"
|
||||
<small class="text-muted ms-2">(searches job number, description, customer, PO, instructions, status, priority)</small>
|
||||
</div>
|
||||
<a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-x me-1"></i>Clear Filter
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(ViewBag.StatusGroup as string))
|
||||
{
|
||||
var groupLabel = ViewBag.StatusGroup == "active" ? "Active Jobs (excluding completed & cancelled)"
|
||||
: ViewBag.StatusGroup == "overdue" ? "Overdue Jobs (past due date)"
|
||||
: ViewBag.StatusGroup;
|
||||
<div class="alert alert-warning alert-permanent d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi bi-funnel-fill me-2"></i>
|
||||
Showing: <strong>@groupLabel</strong> — @Model.TotalCount result@(Model.TotalCount == 1 ? "" : "s")
|
||||
</div>
|
||||
<a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-x me-1"></i>Show All
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@{
|
||||
var _activeGroup = ViewBag.StatusGroup as string;
|
||||
var _activeSearch = ViewBag.SearchTerm as string;
|
||||
var _noFilter = string.IsNullOrEmpty(_activeGroup) && string.IsNullOrEmpty(_activeSearch) && string.IsNullOrEmpty(ViewBag.TagFilter as string);
|
||||
}
|
||||
<!-- Jobs Table Card -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header border-0 py-3">
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<!-- Row 1: search + actions -->
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||
<form asp-action="Index" method="get" class="d-flex gap-2 align-items-center flex-grow-1" style="max-width:480px;">
|
||||
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
|
||||
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
|
||||
<div class="input-group">
|
||||
<span class="input-group-text border-end-0"><i class="bi bi-search text-muted"></i></span>
|
||||
<input type="text" name="searchTerm" class="form-control border-start-0"
|
||||
id="jobSearchInput"
|
||||
placeholder="Search jobs… /"
|
||||
value="@ViewBag.SearchTerm"
|
||||
aria-label="Search jobs">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(_activeSearch) || !string.IsNullOrEmpty(ViewBag.TagFilter as string))
|
||||
{
|
||||
<a href="@Url.Action("Index")" class="btn btn-outline-secondary text-nowrap"><i class="bi bi-x-lg"></i></a>
|
||||
}
|
||||
</form>
|
||||
<div class="d-flex gap-2">
|
||||
<div class="btn-group">
|
||||
<a asp-action="Create" class="btn btn-primary text-nowrap">
|
||||
<i class="bi bi-plus-circle me-1"></i>New Job
|
||||
</a>
|
||||
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item" type="button" id="fromTemplateBtn">
|
||||
<i class="bi bi-layout-text-window-reverse me-2 text-primary"></i>From Template
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<a asp-action="Board" class="btn btn-outline-secondary text-nowrap">
|
||||
<i class="bi bi-kanban me-1"></i>Board
|
||||
</a>
|
||||
<a href="@Url.Action("Blank", "WorkOrder")" target="_blank" class="btn btn-outline-secondary text-nowrap" title="Print a blank work order form">
|
||||
<i class="bi bi-printer me-1"></i><span class="d-none d-md-inline">Blank Work Order</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 2: quick-view pills -->
|
||||
<div class="pcl-pill-group">
|
||||
<a href="@Url.Action("Index")" class="pcl-pill @(_noFilter ? "active" : "")">
|
||||
All <span class="pcl-pill-count">@Model.TotalCount</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="pcl-pill @(_activeGroup == "active" ? "active" : "")">
|
||||
On floor <span class="pcl-pill-count">@_wip</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Index", new { statusGroup = "overdue" })" class="pcl-pill @(_activeGroup == "overdue" ? "active" : "")">
|
||||
Overdue <span class="pcl-pill-count">@_overdue</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Index", new { searchTerm = "ReadyForPickup" })" class="pcl-pill @(_activeSearch == "ReadyForPickup" ? "active" : "")">
|
||||
Ready <span class="pcl-pill-count">@_done</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (!Model.Items.Any())
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||
<h5 class="mt-3 text-muted">No jobs found</h5>
|
||||
<p class="text-muted mb-4">Get started by creating your first job</p>
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>Create Your First Job
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th sortable="JobNumber" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="ps-4 th-kicker">Job</th>
|
||||
<th class="th-kicker">Customer</th>
|
||||
<th sortable="Status" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="th-kicker">Status</th>
|
||||
<th sortable="Priority" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="th-kicker">Priority</th>
|
||||
<th class="th-kicker">Worker</th>
|
||||
<th sortable="ScheduledDate" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="th-kicker">Scheduled</th>
|
||||
<th sortable="DueDate" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="th-kicker">Due</th>
|
||||
<th sortable="FinalPrice" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="th-kicker text-end">Price</th>
|
||||
<th class="th-kicker text-end pe-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="jobsTable">
|
||||
@foreach (var job in Model.Items)
|
||||
{
|
||||
var isHot = job.DueDate.HasValue && job.DueDate.Value < DateTime.Now
|
||||
&& job.StatusCode != "COMPLETED" && job.StatusCode != "READYFORPICKUP"
|
||||
&& job.StatusCode != "DELIVERED" && job.StatusCode != "CANCELLED";
|
||||
<tr class="job-row" data-job-id="@job.Id" style="cursor: pointer;">
|
||||
<td class="ps-4 @(isHot ? "job-hot-cell" : "")">
|
||||
<div>
|
||||
<div class="mono fw-500">
|
||||
@job.JobNumber
|
||||
@if (job.IsReworkJob)
|
||||
{
|
||||
@await Html.PartialAsync("_StatusChip", (Kind: "warn", Text: "Rework"))
|
||||
}
|
||||
</div>
|
||||
<small class="text-muted">@job.CreatedAt.ToString("MMM dd, yyyy")</small>
|
||||
@if (!string.IsNullOrWhiteSpace(job.Tags))
|
||||
{
|
||||
<div class="mt-1">
|
||||
@foreach (var tag in job.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(t => t.Trim()).Where(t => !string.IsNullOrWhiteSpace(t)))
|
||||
{
|
||||
<a href="@Url.Action("Index", new { tagFilter = tag })" class="pcl-chip pcl-chip-cool me-1 text-decoration-none" onclick="event.stopPropagation();">@tag</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td>@job.CustomerName</td>
|
||||
<td>
|
||||
<span class="pcl-chip pcl-chip-@StatusChipHelper.JobStatus(job.StatusCode) status-badge"
|
||||
style="cursor:pointer;"
|
||||
data-job-id="@job.Id"
|
||||
data-job-number="@job.JobNumber"
|
||||
data-status-id="@job.JobStatusId"
|
||||
data-status-name="@job.StatusDisplayName"
|
||||
title="Click to change status">
|
||||
<span class="pcl-chip-dot"></span>@job.StatusDisplayName
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="pcl-chip pcl-chip-@StatusChipHelper.JobPriority(job.PriorityCode) priority-badge"
|
||||
style="cursor:pointer;"
|
||||
data-job-id="@job.Id"
|
||||
data-job-number="@job.JobNumber"
|
||||
data-priority-id="@job.JobPriorityId"
|
||||
data-priority-name="@job.PriorityDisplayName"
|
||||
title="Click to change priority">
|
||||
<span class="pcl-chip-dot"></span>@job.PriorityDisplayName
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(job.AssignedWorkerName))
|
||||
{
|
||||
<span class="badge bg-success-subtle text-success worker-assignment-badge"
|
||||
style="cursor: pointer;"
|
||||
data-job-id="@job.Id"
|
||||
data-job-number="@job.JobNumber"
|
||||
data-worker-id="@job.AssignedUserId"
|
||||
data-worker-name="@job.AssignedWorkerName"
|
||||
title="Click to change worker">
|
||||
<i class="bi bi-person-badge me-1"></i>@job.AssignedWorkerName
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted worker-assignment-badge"
|
||||
style="cursor: pointer;"
|
||||
data-job-id="@job.Id"
|
||||
data-job-number="@job.JobNumber"
|
||||
data-worker-id=""
|
||||
data-worker-name=""
|
||||
title="Click to assign worker">
|
||||
Unassigned
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="date-cell" onclick="openDatePopover(event, @job.Id, 'scheduledDate', '@(job.ScheduledDate.HasValue ? job.ScheduledDate.Value.ToString("yyyy-MM-dd") : "")')" title="Click to change scheduled date" style="cursor:pointer">
|
||||
<span class="date-display" id="sched-@job.Id">
|
||||
@if (job.ScheduledDate.HasValue)
|
||||
{
|
||||
<span>@job.ScheduledDate.Value.ToString("MMM dd, yyyy")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">Not scheduled</span>
|
||||
}
|
||||
</span>
|
||||
<i class="bi bi-pencil-fill ms-1 text-muted date-edit-icon" style="font-size:0.65rem;opacity:0"></i>
|
||||
</td>
|
||||
<td class="date-cell" onclick="openDatePopover(event, @job.Id, 'dueDate', '@(job.DueDate.HasValue ? job.DueDate.Value.ToString("yyyy-MM-dd") : "")')" title="Click to change due date" style="cursor:pointer">
|
||||
<span class="date-display" id="due-@job.Id">
|
||||
@if (job.DueDate.HasValue)
|
||||
{
|
||||
var isOverdue = job.DueDate.Value < DateTime.Now && job.StatusCode != "COMPLETED" && job.StatusCode != "READYFORPICKUP" && job.StatusCode != "DELIVERED";
|
||||
<span class="@(isOverdue ? "text-danger fw-semibold" : "")">
|
||||
@job.DueDate.Value.ToString("MMM dd, yyyy")
|
||||
@if (isOverdue) { <i class="bi bi-exclamation-triangle ms-1"></i> }
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">Not set</span>
|
||||
}
|
||||
</span>
|
||||
<i class="bi bi-pencil-fill ms-1 text-muted date-edit-icon" style="font-size:0.65rem;opacity:0"></i>
|
||||
</td>
|
||||
<td class="text-end mono">@job.FinalPrice.ToString("C")</td>
|
||||
<td class="text-end pe-4">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a asp-action="Details" asp-route-id="@job.Id" class="btn btn-outline-primary" title="View Details">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a asp-action="Edit" asp-route-id="@job.Id" class="btn btn-outline-warning" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<a asp-action="Delete" asp-route-id="@job.Id" class="btn btn-outline-danger" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Card View -->
|
||||
<div class="mobile-card-view">
|
||||
@if (!Model.Items.Any())
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||
<h5 class="mt-3 text-muted">No jobs found</h5>
|
||||
<p class="text-muted mb-4">Get started by creating your first job</p>
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>Create Your First Job
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var job in Model.Items)
|
||||
{
|
||||
<div class="mobile-data-card"
|
||||
data-id="@job.Id"
|
||||
onclick="window.location.href='@Url.Action("Details", new { id = job.Id })'">
|
||||
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
|
||||
<i class="bi bi-briefcase"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@job.JobNumber</h6>
|
||||
<small>@job.CustomerName</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
@await Html.PartialAsync("_StatusChip", (Kind: StatusChipHelper.JobStatus(job.StatusCode), Text: job.StatusDisplayName))
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Priority</span>
|
||||
<span class="mobile-card-value">
|
||||
@await Html.PartialAsync("_StatusChip", (Kind: StatusChipHelper.JobPriority(job.PriorityCode), Text: job.PriorityDisplayName))
|
||||
</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(job.AssignedWorkerName))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Worker</span>
|
||||
<span class="mobile-card-value">
|
||||
<i class="bi bi-person-badge me-1 text-muted"></i>@job.AssignedWorkerName
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (job.ScheduledDate.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Scheduled</span>
|
||||
<span class="mobile-card-value">@job.ScheduledDate.Value.ToString("MMM dd, yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
@if (job.DueDate.HasValue)
|
||||
{
|
||||
var isOverdue = job.DueDate.Value < DateTime.Now && job.StatusCode != "COMPLETED" && job.StatusCode != "READYFORPICKUP" && job.StatusCode != "DELIVERED";
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Due Date</span>
|
||||
<span class="mobile-card-value @(isOverdue ? "text-danger fw-semibold" : "")">
|
||||
@job.DueDate.Value.ToString("MMM dd, yyyy")
|
||||
@if (isOverdue)
|
||||
{
|
||||
<i class="bi bi-exclamation-triangle ms-1"></i>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Price</span>
|
||||
<span class="mobile-card-value fw-semibold">@job.FinalPrice.ToString("C")</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-card-footer">
|
||||
<a href="@Url.Action("Details", new { id = job.Id })"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
onclick="event.stopPropagation();">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
<a href="@Url.Action("Edit", new { id = job.Id })"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
onclick="event.stopPropagation();">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (Model.TotalCount > 0)
|
||||
{
|
||||
@await Html.PartialAsync("_Pagination", Model)
|
||||
}
|
||||
@if (Model.Items.Any())
|
||||
{
|
||||
<div class="pcl-kbd-footer">↑↓ to move · ↵ to open · / to filter</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Antiforgery Token for AJAX -->
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<!-- Worker Assignment Modal -->
|
||||
<div class="modal fade" id="workerAssignmentModal" tabindex="-1" aria-labelledby="workerAssignmentModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="workerAssignmentModalLabel">
|
||||
<i class="bi bi-person-badge me-2"></i>Assign Worker
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small mb-1">Job Number</label>
|
||||
<p class="fw-semibold mb-0" id="modalJobNumber"></p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small mb-1">Current Assignment</label>
|
||||
<p class="mb-0" id="modalCurrentWorker"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="workerSelect" class="form-label">Select Worker</label>
|
||||
<select id="workerSelect" class="form-select">
|
||||
<option value="">Not assigned</option>
|
||||
@if (ViewBag.Workers != null)
|
||||
{
|
||||
foreach (var worker in (SelectList)ViewBag.Workers)
|
||||
{
|
||||
<option value="@worker.Value">@worker.Text</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="saveWorkerAssignment">
|
||||
<i class="bi bi-save me-2"></i>Save Assignment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Change Modal -->
|
||||
<div class="modal fade" id="priorityModal" tabindex="-1" aria-labelledby="priorityModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="priorityModalLabel">
|
||||
<i class="bi bi-flag me-2"></i>Change Priority
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small mb-1">Job Number</label>
|
||||
<p class="fw-semibold mb-0" id="modalPriorityJobNumber"></p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small mb-1">Current Priority</label>
|
||||
<p class="mb-0" id="modalCurrentPriority"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="prioritySelect" class="form-label">Select Priority</label>
|
||||
<select id="prioritySelect" class="form-select">
|
||||
<option value="">Loading priorities...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="savePriority">
|
||||
<i class="bi bi-save me-2"></i>Save Priority
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Change Modal -->
|
||||
<div class="modal fade" id="statusModal" tabindex="-1" aria-labelledby="statusModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="statusModalLabel">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>Change Status
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small mb-1">Job Number</label>
|
||||
<p class="fw-semibold mb-0" id="modalStatusJobNumber"></p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small mb-1">Current Status</label>
|
||||
<p class="mb-0" id="modalCurrentStatus"></p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="statusSelect" class="form-label">Select Status</label>
|
||||
<select id="statusSelect" class="form-select">
|
||||
<option value="">Loading statuses...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="statusModalSendEmail"
|
||||
@(ViewBag.EmailDefaultOnStatusChange == true ? "checked" : "") />
|
||||
<label class="form-check-label small" for="statusModalSendEmail">
|
||||
<i class="bi bi-envelope me-1"></i>Notify customer via email
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="saveStatus">
|
||||
<i class="bi bi-save me-2"></i>Save Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
let currentJobId = null;
|
||||
let currentJobStatusId = null;
|
||||
let currentJobPriorityId = null;
|
||||
let jobStatuses = [];
|
||||
let jobPriorities = [];
|
||||
|
||||
// Load job statuses from lookup table
|
||||
async function loadJobStatuses() {
|
||||
try {
|
||||
const response = await fetch('/CompanySettings/GetJobStatuses');
|
||||
const data = await response.json();
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
jobStatuses = data.sort((a, b) => a.displayOrder - b.displayOrder);
|
||||
populateStatusDropdown();
|
||||
} else {
|
||||
console.error('Failed to load job statuses:', data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading job statuses:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function populateStatusDropdown() {
|
||||
const select = document.getElementById('statusSelect');
|
||||
select.innerHTML = '';
|
||||
|
||||
jobStatuses.forEach(status => {
|
||||
if (status.isActive) {
|
||||
const option = document.createElement('option');
|
||||
option.value = status.id;
|
||||
option.textContent = status.displayName;
|
||||
select.appendChild(option);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load job priorities from lookup table
|
||||
async function loadJobPriorities() {
|
||||
try {
|
||||
const response = await fetch('/CompanySettings/GetJobPriorities');
|
||||
const data = await response.json();
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
jobPriorities = data.sort((a, b) => a.displayOrder - b.displayOrder);
|
||||
populatePriorityDropdown();
|
||||
} else {
|
||||
console.error('Failed to load job priorities:', data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading job priorities:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function populatePriorityDropdown() {
|
||||
const select = document.getElementById('prioritySelect');
|
||||
select.innerHTML = '';
|
||||
|
||||
jobPriorities.forEach(priority => {
|
||||
if (priority.isActive) {
|
||||
const option = document.createElement('option');
|
||||
option.value = priority.id;
|
||||
option.textContent = priority.displayName;
|
||||
select.appendChild(option);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load statuses and priorities on page load
|
||||
loadJobStatuses();
|
||||
loadJobPriorities();
|
||||
|
||||
// / key focuses search input
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === '/' && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') {
|
||||
e.preventDefault();
|
||||
document.getElementById('jobSearchInput')?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Make table rows clickable
|
||||
document.querySelectorAll('.job-row').forEach(row => {
|
||||
row.addEventListener('click', function(e) {
|
||||
// Don't navigate if clicking on action buttons, links, worker badges, priority badges, or status badges
|
||||
if (e.target.closest('.btn-group') || e.target.closest('a') || e.target.closest('button') ||
|
||||
e.target.closest('.worker-assignment-badge') || e.target.closest('.priority-badge') ||
|
||||
e.target.closest('.status-badge') || e.target.closest('.date-cell')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jobId = this.getAttribute('data-job-id');
|
||||
window.location.href = '@Url.Action("Details", "Jobs")/' + jobId;
|
||||
});
|
||||
|
||||
// Hover handled by CSS .table tbody tr:hover
|
||||
});
|
||||
|
||||
// Worker assignment modal
|
||||
document.querySelectorAll('.worker-assignment-badge').forEach(badge => {
|
||||
badge.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
currentJobId = this.getAttribute('data-job-id');
|
||||
const jobNumber = this.getAttribute('data-job-number');
|
||||
const workerId = this.getAttribute('data-worker-id');
|
||||
const workerName = this.getAttribute('data-worker-name');
|
||||
|
||||
// Update modal content
|
||||
document.getElementById('modalJobNumber').textContent = jobNumber;
|
||||
document.getElementById('modalCurrentWorker').textContent = workerName || 'Not assigned';
|
||||
document.getElementById('workerSelect').value = workerId || '';
|
||||
|
||||
// Show modal
|
||||
const modal = new bootstrap.Modal(document.getElementById('workerAssignmentModal'));
|
||||
modal.show();
|
||||
});
|
||||
});
|
||||
|
||||
// Save worker assignment
|
||||
document.getElementById('saveWorkerAssignment').addEventListener('click', function() {
|
||||
const workerId = document.getElementById('workerSelect').value;
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||
|
||||
// Send AJAX request
|
||||
fetch('@Url.Action("UpdateWorkerAssignment", "Jobs")', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jobId: parseInt(currentJobId),
|
||||
workerId: workerId || null
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Close modal
|
||||
bootstrap.Modal.getInstance(document.getElementById('workerAssignmentModal')).hide();
|
||||
|
||||
// Reload page to show updated assignment
|
||||
location.reload();
|
||||
} else {
|
||||
showError('Error updating worker assignment: ' + (data.message || 'Unknown error'), 'Update Failed');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showError('An error occurred while updating worker assignment', 'Update Failed');
|
||||
});
|
||||
});
|
||||
|
||||
// Priority change modal
|
||||
document.querySelectorAll('.priority-badge').forEach(badge => {
|
||||
badge.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
currentJobId = this.getAttribute('data-job-id');
|
||||
currentJobPriorityId = this.getAttribute('data-priority-id');
|
||||
const jobNumber = this.getAttribute('data-job-number');
|
||||
const priorityName = this.getAttribute('data-priority-name');
|
||||
|
||||
// Update modal content
|
||||
document.getElementById('modalPriorityJobNumber').textContent = jobNumber;
|
||||
document.getElementById('modalCurrentPriority').textContent = priorityName;
|
||||
document.getElementById('prioritySelect').value = currentJobPriorityId;
|
||||
|
||||
// Show modal
|
||||
const modal = new bootstrap.Modal(document.getElementById('priorityModal'));
|
||||
modal.show();
|
||||
});
|
||||
});
|
||||
|
||||
// Save priority
|
||||
document.getElementById('savePriority').addEventListener('click', function() {
|
||||
const priorityId = document.getElementById('prioritySelect').value;
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||
|
||||
// Send AJAX request
|
||||
fetch('@Url.Action("UpdateJobPriority", "Jobs")', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jobId: parseInt(currentJobId),
|
||||
priorityId: parseInt(priorityId)
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Close modal
|
||||
bootstrap.Modal.getInstance(document.getElementById('priorityModal')).hide();
|
||||
|
||||
// Reload page to show updated priority
|
||||
location.reload();
|
||||
} else {
|
||||
showError('Error updating job priority: ' + (data.message || 'Unknown error'), 'Update Failed');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showError('An error occurred while updating job priority', 'Update Failed');
|
||||
});
|
||||
});
|
||||
|
||||
// Status change modal
|
||||
document.querySelectorAll('.status-badge').forEach(badge => {
|
||||
badge.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
currentJobId = this.getAttribute('data-job-id');
|
||||
currentJobStatusId = this.getAttribute('data-status-id');
|
||||
const jobNumber = this.getAttribute('data-job-number');
|
||||
const statusName = this.getAttribute('data-status-name');
|
||||
|
||||
// Update modal content
|
||||
document.getElementById('modalStatusJobNumber').textContent = jobNumber;
|
||||
document.getElementById('modalCurrentStatus').textContent = statusName;
|
||||
document.getElementById('statusSelect').value = currentJobStatusId;
|
||||
|
||||
// Show modal
|
||||
const modal = new bootstrap.Modal(document.getElementById('statusModal'));
|
||||
modal.show();
|
||||
});
|
||||
});
|
||||
|
||||
// Save status
|
||||
document.getElementById('saveStatus').addEventListener('click', function() {
|
||||
const statusId = document.getElementById('statusSelect').value;
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||
const sendEmail = document.getElementById('statusModalSendEmail')?.checked ?? false;
|
||||
|
||||
// Send AJAX request
|
||||
fetch('@Url.Action("UpdateJobStatus", "Jobs")', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jobId: parseInt(currentJobId),
|
||||
statusId: parseInt(statusId),
|
||||
sendEmail: sendEmail
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Close modal
|
||||
bootstrap.Modal.getInstance(document.getElementById('statusModal')).hide();
|
||||
|
||||
// Reload page to show updated status
|
||||
location.reload();
|
||||
} else {
|
||||
showError('Error updating job status: ' + (data.message || 'Unknown error'), 'Update Failed');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showError('An error occurred while updating job status', 'Update Failed');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.date-cell:hover .date-edit-icon { opacity: 1 !important; }
|
||||
.date-cell:hover { background-color: var(--bs-table-hover-bg); }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// ── Inline date editing (job list) ────────────────────────────────────
|
||||
let activeDatePopover = null;
|
||||
|
||||
function openDatePopover(event, jobId, field, currentValue) {
|
||||
event.stopPropagation();
|
||||
|
||||
// Close any already-open popover
|
||||
closeDatePopover();
|
||||
|
||||
const cell = event.currentTarget;
|
||||
const label = field === 'scheduledDate' ? 'Scheduled Date' : 'Due Date';
|
||||
const allowClear = field === 'dueDate';
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'date-popover shadow rounded border bg-white p-2';
|
||||
wrapper.style.cssText = 'position:absolute;z-index:1055;min-width:220px;';
|
||||
wrapper.innerHTML = `
|
||||
<div class="fw-semibold small text-muted mb-1">${label}</div>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="date" class="form-control" id="dp-input" value="${currentValue}">
|
||||
<button class="btn btn-primary" onclick="saveDateFromPopover(${jobId},'${field}')" title="Save"><i class="bi bi-check-lg"></i></button>
|
||||
<button class="btn btn-outline-secondary" onclick="closeDatePopover()" title="Cancel"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
${allowClear ? `<button class="btn btn-link btn-sm p-0 mt-1 text-danger" onclick="saveDateFromPopover(${jobId},'${field}',true)"><i class="bi bi-x-circle me-1"></i><small>Clear date</small></button>` : ''}
|
||||
`;
|
||||
|
||||
// Position relative to viewport using absolute on body
|
||||
document.body.appendChild(wrapper);
|
||||
const rect = cell.getBoundingClientRect();
|
||||
wrapper.style.top = (rect.bottom + window.scrollY + 4) + 'px';
|
||||
wrapper.style.left = (rect.left + window.scrollX) + 'px';
|
||||
|
||||
activeDatePopover = wrapper;
|
||||
wrapper.querySelector('#dp-input').focus();
|
||||
|
||||
// Close on outside click
|
||||
setTimeout(() => document.addEventListener('click', outsideClickHandler), 0);
|
||||
}
|
||||
|
||||
function outsideClickHandler(e) {
|
||||
if (activeDatePopover && !activeDatePopover.contains(e.target)) {
|
||||
closeDatePopover();
|
||||
}
|
||||
}
|
||||
|
||||
function closeDatePopover() {
|
||||
if (activeDatePopover) {
|
||||
activeDatePopover.remove();
|
||||
activeDatePopover = null;
|
||||
document.removeEventListener('click', outsideClickHandler);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDateFromPopover(jobId, field, clear = false) {
|
||||
const input = document.getElementById('dp-input');
|
||||
const value = clear ? '' : (input?.value || '');
|
||||
|
||||
const saveBtn = activeDatePopover?.querySelector('.btn-primary');
|
||||
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>'; }
|
||||
|
||||
try {
|
||||
const resp = await fetch('@Url.Action("UpdateDates", "Jobs")', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jobId,
|
||||
scheduledDate: field === 'scheduledDate' ? value : undefined,
|
||||
dueDate: field === 'dueDate' ? value : undefined
|
||||
})
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!result.success) throw new Error(result.message);
|
||||
|
||||
// Update display cell in place
|
||||
const prefix = field === 'scheduledDate' ? 'sched' : 'due';
|
||||
const displayEl = document.getElementById(`${prefix}-${jobId}`);
|
||||
if (displayEl) {
|
||||
if (value) {
|
||||
const d = new Date(value + 'T12:00:00');
|
||||
const formatted = d.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' });
|
||||
displayEl.innerHTML = `<span>${formatted}</span>`;
|
||||
} else {
|
||||
const emptyLabel = field === 'scheduledDate' ? 'Not scheduled' : 'Not set';
|
||||
displayEl.innerHTML = `<span class="text-muted">${emptyLabel}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
closeDatePopover();
|
||||
} catch (err) {
|
||||
alert('Could not save date: ' + err.message);
|
||||
if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = '<i class="bi bi-check-lg"></i>'; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── From Template picker ──────────────────────────────────────────────
|
||||
document.getElementById('fromTemplateBtn')?.addEventListener('click', async function () {
|
||||
const modal = new bootstrap.Modal(document.getElementById('fromTemplateModal'));
|
||||
const list = document.getElementById('templatePickerList');
|
||||
list.innerHTML = '<div class="text-center p-4"><div class="spinner-border spinner-border-sm"></div></div>';
|
||||
modal.show();
|
||||
|
||||
try {
|
||||
const resp = await fetch('@Url.Action("GetTemplatesJson", "JobTemplates")');
|
||||
const templates = await resp.json();
|
||||
|
||||
if (!templates.length) {
|
||||
list.innerHTML = '<p class="text-muted p-3 mb-0">No active templates found. Save a job as a template first.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = templates.map(t => `
|
||||
<a href="@Url.Action("Create", "Jobs")?templateId=${t.id}"
|
||||
class="list-group-item list-group-item-action p-3">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="fw-semibold">${t.name}</div>
|
||||
${t.description ? `<small class="text-muted">${t.description}</small>` : ''}
|
||||
${t.customerName ? `<div class="small text-muted mt-1"><i class="bi bi-building me-1"></i>${t.customerName}</div>` : ''}
|
||||
</div>
|
||||
<div class="text-end flex-shrink-0 ms-3">
|
||||
<span class="badge bg-secondary">${t.items.length} item${t.items.length !== 1 ? 's' : ''}</span>
|
||||
${t.usageCount > 0 ? `<div class="small text-muted mt-1">Used ${t.usageCount}×</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</a>`).join('');
|
||||
} catch {
|
||||
list.innerHTML = '<p class="text-danger p-3 mb-0">Failed to load templates.</p>';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
<!-- From Template Modal -->
|
||||
<div class="modal fade" id="fromTemplateModal" tabindex="-1" aria-labelledby="fromTemplateModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="fromTemplateModalLabel">
|
||||
<i class="bi bi-layout-text-window-reverse me-2 text-primary"></i>Create Job from Template
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<div class="list-group list-group-flush" id="templatePickerList"></div>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<a asp-controller="JobTemplates" asp-action="Index" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-gear me-1"></i>Manage Templates
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,288 @@
|
||||
@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">×@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>
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
@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">×@((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-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>
|
||||
@@ -0,0 +1,631 @@
|
||||
@model List<PowderCoating.Application.DTOs.Job.JobDailyPriorityDto>
|
||||
@using PowderCoating.Core.Entities
|
||||
|
||||
@{
|
||||
Layout = null;
|
||||
var workers = ViewBag.Workers as List<ShopWorker> ?? new();
|
||||
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"> </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>
|
||||
@@ -0,0 +1,292 @@
|
||||
@{
|
||||
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 token = (Guid)ViewBag.Token;
|
||||
|
||||
// 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-token="@token">
|
||||
@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-token="@token">
|
||||
@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-token="@token">
|
||||
@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>
|
||||
@@ -0,0 +1,655 @@
|
||||
@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 class="text-center" style="font-size: 8pt; line-height: 1.6;">
|
||||
<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>
|
||||
</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 next<br />status — no login required.
|
||||
</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>
|
||||
Reference in New Issue
Block a user