560 lines
23 KiB
Plaintext
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)"> · @_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>
|
|
}
|