Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Jobs/Board.cshtml
T
2026-04-23 21:38:24 -04:00

560 lines
23 KiB
Plaintext

@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)"> &middot; @_hotCount overdue</span>
}
</span>
</div>
@* Right: actions *@
<div class="d-flex align-items-center gap-2">
<a href="@Url.Action("Board", new { showTerminal = !showTerminal })"
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>
}