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

666 lines
26 KiB
Plaintext

@model List<PowderCoating.Application.DTOs.Job.JobDailyPriorityDto>
@using PowderCoating.Core.Entities
@{
Layout = null;
ViewData["Title"] = "Shop Floor Display";
var scheduledDate = ViewBag.ScheduledDate as DateTime? ?? DateTime.Today;
var currentTime = ViewBag.CurrentTime as DateTime? ?? DateTime.Now;
var allStatuses = ViewBag.AllStatuses as List<JobStatusLookup> ?? new List<JobStatusLookup>();
var currentUserId = ViewBag.CurrentUserId as string ?? "";
var filterUserId = ViewBag.FilterUserId as string;
var isMyJobsFilter = filterUserId != null;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shop Floor Display - @scheduledDate.ToString("MMM dd, yyyy")</title>
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="~/lib/bootstrap-icons/font/bootstrap-icons.css">
<style>
body {
background-color: #0d1117;
color: #e0e0e0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
min-height: 100vh;
overflow-x: hidden;
}
.shop-header {
background: linear-gradient(135deg, #161b22 0%, #1c2128 100%);
border-bottom: 3px solid #30363d;
padding: 20px 40px;
position: sticky;
top: 0;
z-index: 100;
}
.shop-title {
font-size: 2rem;
font-weight: 700;
color: #ffffff;
letter-spacing: 3px;
text-transform: uppercase;
}
.shop-subtitle {
font-size: 1rem;
color: #8b949e;
margin-top: 6px;
display: flex;
align-items: center;
gap: 16px;
}
.clock {
font-size: 2.8rem;
font-weight: 300;
color: #58a6ff;
font-family: 'Courier New', monospace;
letter-spacing: 4px;
}
.date-badge {
font-size: 1rem;
color: #8b949e;
margin-top: 4px;
}
.job-count {
background: #21262d;
border: 1px solid #30363d;
border-radius: 20px;
padding: 4px 14px;
font-size: 1rem;
color: #58a6ff;
}
.nav-buttons { display: flex; gap: 10px; }
.nav-btn {
border: 1px solid #30363d;
color: #8b949e;
font-size: 0.95rem;
padding: 8px 16px;
background: #161b22;
}
.nav-btn:hover { background-color: #21262d; border-color: #58a6ff; color: #58a6ff; }
.nav-btn.today { border-color: #58a6ff; color: #58a6ff; }
.nav-btn.my-jobs-active { border-color: #3fb950; color: #3fb950; background: rgba(63,185,80,0.1); }
/* Job card */
.job-card {
background: #161b22;
border: 1px solid #30363d;
border-left: 6px solid #30363d;
border-radius: 12px;
margin-bottom: 14px;
cursor: pointer;
transition: all 0.2s ease;
overflow: hidden;
}
.job-card:hover { transform: translateX(4px); box-shadow: 0 8px 24px rgba(0,0,0,0.4); border-color: #58a6ff44; }
.job-card.no-advance { cursor: default; }
.job-card.no-advance:hover { transform: none; box-shadow: none; border-color: #30363d; }
.job-card.advancing { opacity: 0.6; pointer-events: none; }
.priority-border-danger { border-left-color: #dc3545 !important; }
.priority-border-warning { border-left-color: #ffc107 !important; }
.priority-border-info { border-left-color: #0dcaf0 !important; }
.priority-border-primary { border-left-color: #0d6efd !important; }
.priority-border-secondary { border-left-color: #6c757d !important; }
.priority-border-success { border-left-color: #198754 !important; }
/* Card top row */
.card-top { padding: 18px 28px 12px 28px; display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }
.order-num {
font-size: 2.5rem;
font-weight: 700;
color: rgba(88,166,255,0.18);
min-width: 48px;
line-height: 1;
}
.job-number { font-size: 1.8rem; font-weight: 700; color: #58a6ff; letter-spacing: 2px; min-width: 190px; }
.customer-name { font-size: 1.4rem; font-weight: 600; color: #f0f6fc; flex: 1; }
.badge-xl {
font-size: 1rem;
font-weight: 600;
padding: 8px 16px;
border-radius: 8px;
text-transform: uppercase;
letter-spacing: 1px;
min-width: 130px;
text-align: center;
}
/* bg-info is a light cyan — use dark text so it's readable on TV */
.badge-xl.bg-info { color: #000 !important; }
.worker-badge {
font-size: 1rem;
padding: 8px 16px;
background: rgba(63,185,80,0.15);
color: #3fb950;
border: 1px solid rgba(63,185,80,0.3);
border-radius: 8px;
min-width: 160px;
text-align: center;
}
.worker-badge.unassigned {
background: rgba(139,148,158,0.15);
color: #8b949e;
border-color: rgba(139,148,158,0.3);
}
.due-date { font-size: 1rem; color: #8b949e; min-width: 150px; text-align: right; }
.due-date.overdue { color: #f85149; font-weight: 700; }
/* Card detail row */
.card-detail {
padding: 0 28px 14px 88px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
border-top: 1px solid #21262d;
padding-top: 10px;
}
.detail-pill {
display: inline-flex;
align-items: center;
gap: 6px;
background: #21262d;
border: 1px solid #30363d;
border-radius: 20px;
padding: 4px 12px;
font-size: 0.9rem;
color: #8b949e;
}
.prep-flag { background: rgba(255,193,7,0.1); border-color: rgba(255,193,7,0.3); color: #ffc107; }
.desc-text {
color: #c9d1d9;
font-size: 0.95rem;
flex: 1;
min-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 400px;
}
/* Per-item list in card detail */
.item-list {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
width: 100%;
}
.item-entry {
display: flex;
align-items: baseline;
gap: 6px;
font-size: 0.9rem;
color: #c9d1d9;
flex-wrap: wrap;
}
.item-qty {
font-weight: 700;
color: #58a6ff;
min-width: 30px;
text-align: right;
font-size: 0.85rem;
flex-shrink: 0;
}
.item-desc {
color: #e0e0e0;
}
.item-sep { color: #484f58; flex-shrink: 0; }
.item-color {
color: #a371f7;
font-style: italic;
}
.item-flag {
font-size: 0.75rem;
color: #ffc107;
flex-shrink: 0;
}
.special-instructions {
display: flex;
align-items: flex-start;
width: 100%;
color: #ffc107;
font-size: 0.95rem;
font-weight: 500;
background: rgba(255,193,7,0.08);
border: 1px solid rgba(255,193,7,0.25);
border-radius: 6px;
padding: 6px 12px;
margin-top: 2px;
}
/* Progress strip */
.progress-strip {
padding: 8px 28px 14px 88px;
display: flex;
align-items: center;
gap: 0;
overflow-x: auto;
}
.progress-step {
display: flex;
align-items: center;
gap: 0;
font-size: 0.72rem;
color: #484f58;
white-space: nowrap;
}
.step-label {
padding: 3px 10px;
border-radius: 10px;
background: #21262d;
border: 1px solid #30363d;
transition: all 0.2s;
}
.step-label.current {
background: #1f6feb;
border-color: #58a6ff;
color: #ffffff;
font-weight: 600;
font-size: 0.78rem;
box-shadow: 0 0 10px rgba(88,166,255,0.4);
}
.step-label.done { background: #1a2d1a; border-color: #238636; color: #3fb950; }
.step-arrow { color: #30363d; margin: 0 4px; font-size: 0.65rem; }
.step-arrow.done { color: #238636; }
/* Advance button hint */
.advance-hint { font-size: 0.75rem; color: #3d444d; padding: 0 28px 10px 88px; }
.job-card:hover .advance-hint { color: #58a6ff99; }
/* No-jobs state */
.no-jobs-container {
min-height: 70vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #8b949e;
}
.no-jobs-icon { font-size: 8rem; color: #3fb950; opacity: 0.6; }
/* Modal overrides for dark theme */
.modal-content { background: #161b22; border: 1px solid #30363d; color: #e0e0e0; }
.modal-header { border-bottom: 1px solid #30363d; }
.modal-footer { border-top: 1px solid #30363d; }
.btn-close { filter: invert(1); }
</style>
</head>
<body>
<!-- Header -->
<div class="shop-header">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="shop-title"><i class="bi bi-calendar-week me-3"></i>Job Schedule</div>
<div class="shop-subtitle">
<span class="job-count"><i class="bi bi-briefcase me-2"></i>@Model.Count job@(Model.Count != 1 ? "s" : "")</span>
<span class="text-muted" style="font-size:0.85rem"><i class="bi bi-wifi me-1"></i><span id="shopHubStatus">Connecting...</span></span>
@if (isMyJobsFilter)
{
<span style="color:#3fb950;font-size:0.85rem"><i class="bi bi-person-check me-1"></i>My Jobs</span>
}
</div>
</div>
<div class="text-center">
<div class="clock" id="clock">@currentTime.ToString("HH:mm:ss")</div>
<div class="date-badge">
@scheduledDate.ToString("dddd, MMMM dd, yyyy")
@if (scheduledDate.Date == DateTime.Today)
{
<span class="badge bg-primary ms-2">TODAY</span>
}
else if (scheduledDate.Date == DateTime.Today.AddDays(1))
{
<span class="badge bg-info ms-2">TOMORROW</span>
}
else if (scheduledDate.Date == DateTime.Today.AddDays(-1))
{
<span class="badge bg-secondary ms-2">YESTERDAY</span>
}
</div>
</div>
<div class="nav-buttons">
<a href="@Url.Action("ShopDisplay", new { date = scheduledDate.AddDays(-1), userId = filterUserId })" class="btn nav-btn">
<i class="bi bi-chevron-left"></i>
</a>
<a href="@Url.Action("ShopDisplay", new { userId = filterUserId })" class="btn nav-btn @(scheduledDate.Date == DateTime.Today ? "today" : "")">TODAY</a>
<a href="@Url.Action("ShopDisplay", new { date = scheduledDate.AddDays(1), userId = filterUserId })" class="btn nav-btn">
<i class="bi bi-chevron-right"></i>
</a>
@if (isMyJobsFilter)
{
<a href="@Url.Action("ShopDisplay", new { date = scheduledDate })" class="btn nav-btn my-jobs-active">
<i class="bi bi-people me-1"></i>All Jobs
</a>
}
else
{
<a href="@Url.Action("ShopDisplay", new { date = scheduledDate, userId = currentUserId })" class="btn nav-btn">
<i class="bi bi-person me-1"></i>My Jobs
</a>
}
</div>
</div>
</div>
<!-- Anti-forgery token for AJAX -->
@Html.AntiForgeryToken()
<!-- Main Content -->
<div class="container-fluid p-4">
@if (!Model.Any())
{
<div class="no-jobs-container">
<i class="bi bi-calendar-x no-jobs-icon"></i>
<h2 class="mt-4" style="color:#8b949e;font-size:2.5rem;">
@(isMyJobsFilter ? "No Jobs Assigned to You" : "No Jobs Scheduled")
</h2>
<p class="mt-3" style="font-size:1.3rem;">
@(isMyJobsFilter ? "No jobs assigned to you for " : "No jobs scheduled for ")@scheduledDate.ToString("MMMM dd, yyyy")
</p>
@if (isMyJobsFilter)
{
<a href="@Url.Action("ShopDisplay", new { date = scheduledDate })" class="btn btn-outline-secondary mt-3">
<i class="bi bi-people me-2"></i>View All Jobs
</a>
}
</div>
}
else
{
@for (int i = 0; i < Model.Count; i++)
{
var job = Model[i];
var isOverdue = job.DueDate.HasValue && job.DueDate.Value.Date < DateTime.Today;
var borderClass = $"priority-border-{job.PriorityColorClass}";
var hasNext = job.NextStatusId.HasValue;
<div class="job-card @borderClass @(hasNext ? "" : "no-advance")" id="job-card-@job.JobId"
data-job-id="@job.JobId"
data-job-number="@job.JobNumber"
data-next-status-id="@job.NextStatusId"
data-next-status-name="@job.NextStatusDisplayName"
data-next-status-color="@job.NextStatusColorClass">
<!-- Top Row: order number, job number, customer, status, priority, worker, due date -->
<div class="card-top">
<div class="order-num">@(i + 1)</div>
<div class="job-number">@job.JobNumber</div>
<div class="customer-name"><i class="bi bi-building me-2" style="color:#8b949e"></i>@job.CustomerName</div>
<div>
<span class="badge bg-@job.StatusColorClass badge-xl" id="status-badge-@job.JobId">@job.StatusDisplayName</span>
</div>
<div>
<span class="badge bg-@job.PriorityColorClass badge-xl">@job.PriorityDisplayName</span>
</div>
<div>
<div class="worker-badge @(string.IsNullOrEmpty(job.AssignedWorkerName) ? "unassigned" : "")">
<i class="bi bi-person-badge me-2"></i>@(string.IsNullOrEmpty(job.AssignedWorkerName) ? "Unassigned" : job.AssignedWorkerName)
</div>
</div>
@if (job.DueDate.HasValue)
{
<div class="due-date @(isOverdue ? "overdue" : "")">
<i class="bi bi-calendar-x me-2"></i>Due: @job.DueDate.Value.ToString("MMM dd")
@if (isOverdue) { <i class="bi bi-exclamation-triangle-fill ms-2"></i> }
</div>
}
else
{
<div class="due-date" style="color:#30363d">
<i class="bi bi-calendar me-2"></i>No due date
</div>
}
</div>
<!-- Detail Row: items with colors, prep flags -->
<div class="card-detail">
<div class="item-list">
@foreach (var item in job.Items)
{
<span class="item-entry">
<span class="item-qty">&times;@((int)item.Quantity)</span>
<span class="item-desc">@item.Description</span>
@if (item.Colors.Any())
{
<span class="item-sep">—</span>
<i class="bi bi-circle-fill" style="color:#a371f7;font-size:0.55rem;flex-shrink:0"></i>
<span class="item-color">@string.Join(" / ", item.Colors)</span>
}
@if (item.HasSandblasting)
{
<i class="bi bi-tornado item-flag" title="Sandblast"></i>
}
@if (item.HasMasking)
{
<i class="bi bi-mask item-flag" title="Masking"></i>
}
</span>
}
</div>
@if (!string.IsNullOrWhiteSpace(job.SpecialInstructions))
{
<div class="special-instructions">
<i class="bi bi-exclamation-circle-fill me-2" style="color:#ffc107;flex-shrink:0"></i>@job.SpecialInstructions
</div>
}
</div>
<!-- Progress Strip -->
@if (allStatuses.Any())
{
<div class="progress-strip">
@for (int s = 0; s < allStatuses.Count; s++)
{
var status = allStatuses[s];
var isCurrent = status.StatusCode == job.StatusCode;
var isDone = status.DisplayOrder < job.StatusDisplayOrder;
var stepClass = isCurrent ? "current" : (isDone ? "done" : "");
<div class="progress-step">
<span class="step-label @stepClass">@status.DisplayName</span>
@if (s < allStatuses.Count - 1)
{
<span class="step-arrow @(isDone ? "done" : "")"><i class="bi bi-chevron-right"></i></span>
}
</div>
}
</div>
}
<!-- Advance hint -->
@if (hasNext)
{
<div class="advance-hint">
<i class="bi bi-hand-index me-1"></i>Tap to advance to <strong>@job.NextStatusDisplayName</strong>
</div>
}
</div>
}
}
</div>
<!-- Advance Status Modal -->
<div class="modal fade" id="advanceModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-arrow-right-circle me-2 text-primary"></i>Advance Job Status</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body py-4">
<p class="fs-5 mb-1">Move <strong id="modal-job-number" class="text-primary"></strong> to:</p>
<p class="fs-4 fw-bold mt-2" id="modal-next-status"></p>
<p class="text-muted small mb-0">This will update the job status immediately.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-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>