a0bdd2b5b4
Replace all corruption variants with HTML entities across 226 view files: - 3-char UTF-8-as-Win1252 sequences (ae-corruption) - Standalone smart/curly quotes that break C# Razor expressions - Partially re-corrupted variants where the 3rd byte was normalised to ASCII tools/Fix-Encoding.ps1: re-runnable sweep; uses [char] code points so the script itself never contains a literal non-ASCII character; supports -DryRun .githooks/pre-commit: blocks commits containing the ae-corruption byte signature (xc3xa2xe2x82xac); git core.hooksPath = .githooks so the hook is repo-committed and active for all future work on this machine. Build clean; 225 unit tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
630 lines
22 KiB
Plaintext
630 lines
22 KiB
Plaintext
@model List<PowderCoating.Application.DTOs.Job.JobDailyPriorityDto>
|
||
@{
|
||
Layout = null;
|
||
var workers = (ViewBag.Workers as IEnumerable<dynamic>) ?? Array.Empty<dynamic>();
|
||
var currentWorkerId = ViewBag.CurrentWorkerId as string;
|
||
var allStatuses = ViewBag.AllStatuses as List<JobStatusLookup> ?? new();
|
||
var activeCount = Model.Count;
|
||
}
|
||
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||
<title>Shop Floor</title>
|
||
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
|
||
<link rel="stylesheet" href="~/lib/bootstrap-icons/font/bootstrap-icons.css" />
|
||
<style>
|
||
:root { --priority-strip-width: 6px; }
|
||
|
||
* { -webkit-tap-highlight-color: transparent; }
|
||
|
||
body {
|
||
background: #f0f2f5;
|
||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||
padding-bottom: env(safe-area-inset-bottom, 16px);
|
||
}
|
||
|
||
/* ── Header ── */
|
||
.mobile-header {
|
||
background: #1a1a2e;
|
||
color: #fff;
|
||
padding: 12px 16px;
|
||
padding-top: max(12px, env(safe-area-inset-top));
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
}
|
||
.mobile-header h1 {
|
||
font-size: 1.15rem;
|
||
font-weight: 700;
|
||
letter-spacing: 1px;
|
||
text-transform: uppercase;
|
||
margin: 0;
|
||
}
|
||
.mobile-header .badge-count {
|
||
background: rgba(255,255,255,0.15);
|
||
border-radius: 20px;
|
||
padding: 2px 10px;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
/* ── Filter bar ── */
|
||
.filter-bar {
|
||
background: #fff;
|
||
border-bottom: 1px solid #e0e0e0;
|
||
padding: 10px 16px;
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
overflow-x: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
.filter-bar::-webkit-scrollbar { display: none; }
|
||
|
||
.filter-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 14px;
|
||
border-radius: 20px;
|
||
border: 1.5px solid #dee2e6;
|
||
background: #fff;
|
||
font-size: 0.8rem;
|
||
white-space: nowrap;
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
color: #495057;
|
||
transition: all 0.15s;
|
||
}
|
||
.filter-chip.active {
|
||
background: #1a1a2e;
|
||
border-color: #1a1a2e;
|
||
color: #fff;
|
||
}
|
||
|
||
/* ── Status group header ── */
|
||
.status-group-header {
|
||
font-size: 0.7rem;
|
||
font-weight: 700;
|
||
letter-spacing: 1.5px;
|
||
text-transform: uppercase;
|
||
color: #6c757d;
|
||
padding: 14px 16px 6px;
|
||
}
|
||
|
||
/* ── Job card ── */
|
||
.job-card {
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
margin: 0 12px 10px;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||
overflow: hidden;
|
||
display: flex;
|
||
}
|
||
.priority-strip {
|
||
width: var(--priority-strip-width);
|
||
flex-shrink: 0;
|
||
}
|
||
.card-inner {
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: 12px 14px;
|
||
}
|
||
|
||
/* Card header row */
|
||
.card-job-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 4px;
|
||
}
|
||
.job-number {
|
||
font-size: 0.75rem;
|
||
font-weight: 700;
|
||
letter-spacing: 0.5px;
|
||
color: #6c757d;
|
||
}
|
||
.customer-name {
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
color: #1a1a2e;
|
||
line-height: 1.2;
|
||
margin-bottom: 6px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
/* Meta row */
|
||
.card-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.meta-badge {
|
||
font-size: 0.72rem;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
border: 1px solid currentColor;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
}
|
||
|
||
/* Items list */
|
||
.items-list {
|
||
font-size: 0.8rem;
|
||
color: #495057;
|
||
margin-bottom: 10px;
|
||
}
|
||
.item-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 6px;
|
||
padding: 2px 0;
|
||
}
|
||
.item-qty { font-weight: 700; color: #1a1a2e; flex-shrink: 0; }
|
||
|
||
/* Colors row */
|
||
.colors-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.color-chip {
|
||
font-size: 0.7rem;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
background: #f8f9fa;
|
||
border: 1px solid #dee2e6;
|
||
color: #495057;
|
||
}
|
||
|
||
/* Special instructions */
|
||
.special-instructions {
|
||
font-size: 0.78rem;
|
||
color: #856404;
|
||
background: #fff3cd;
|
||
border-radius: 6px;
|
||
padding: 6px 10px;
|
||
margin-bottom: 10px;
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
/* Action row */
|
||
.card-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: stretch;
|
||
}
|
||
.btn-advance {
|
||
flex: 1;
|
||
padding: 12px 16px;
|
||
border-radius: 10px;
|
||
font-size: 0.88rem;
|
||
font-weight: 600;
|
||
border: none;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
transition: opacity 0.15s, transform 0.1s;
|
||
min-height: 50px;
|
||
}
|
||
.btn-advance:active { transform: scale(0.97); opacity: 0.85; }
|
||
.btn-advance:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
|
||
.btn-details {
|
||
width: 50px;
|
||
height: 50px;
|
||
border-radius: 10px;
|
||
border: 1.5px solid #dee2e6;
|
||
background: #fff;
|
||
color: #6c757d;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 1.1rem;
|
||
text-decoration: none;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Feedback toast */
|
||
#statusToast {
|
||
position: fixed;
|
||
bottom: max(20px, env(safe-area-inset-bottom, 20px));
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 9999;
|
||
border-radius: 20px;
|
||
padding: 10px 20px;
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
opacity: 0;
|
||
transition: opacity 0.25s;
|
||
pointer-events: none;
|
||
}
|
||
#statusToast.show { opacity: 1; }
|
||
#statusToast.success { background: #198754; }
|
||
#statusToast.danger { background: #dc3545; }
|
||
|
||
/* Empty state */
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
color: #6c757d;
|
||
}
|
||
.empty-state i { font-size: 3rem; opacity: 0.4; }
|
||
|
||
/* Auto-refresh indicator */
|
||
.refresh-dot {
|
||
width: 8px; height: 8px;
|
||
border-radius: 50%;
|
||
background: #28a745;
|
||
animation: pulse 2s infinite;
|
||
display: inline-block;
|
||
}
|
||
@@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.3; }
|
||
}
|
||
|
||
/* ── Dark mode toggle button ── */
|
||
.btn-theme-toggle {
|
||
background: none;
|
||
border: none;
|
||
color: rgba(255,255,255,0.65);
|
||
font-size: 1.2rem;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
line-height: 1;
|
||
transition: color 0.15s;
|
||
}
|
||
.btn-theme-toggle:hover { color: #fff; }
|
||
|
||
/* ══════════════════════════════════════════
|
||
Shop Dark Mode (body.shop-dark)
|
||
══════════════════════════════════════════ */
|
||
body.shop-dark {
|
||
background: #0f1117;
|
||
color: #d0d6e0;
|
||
}
|
||
body.shop-dark .filter-bar {
|
||
background: #1a1b26;
|
||
border-bottom-color: #2a2b3a;
|
||
}
|
||
body.shop-dark .filter-chip {
|
||
background: #252636;
|
||
border-color: #3a3b50;
|
||
color: #a0aabb;
|
||
}
|
||
body.shop-dark .filter-chip.active {
|
||
background: #4a5080;
|
||
border-color: #4a5080;
|
||
color: #fff;
|
||
}
|
||
body.shop-dark .status-group-header {
|
||
color: #6b7a92;
|
||
}
|
||
body.shop-dark .job-card {
|
||
background: #1e1f2e;
|
||
box-shadow: 0 1px 6px rgba(0,0,0,0.4);
|
||
}
|
||
body.shop-dark .job-number {
|
||
color: #6b7a92;
|
||
}
|
||
body.shop-dark .customer-name {
|
||
color: #e2e8f0;
|
||
}
|
||
body.shop-dark .items-list {
|
||
color: #8892a4;
|
||
}
|
||
body.shop-dark .item-qty {
|
||
color: #c8d0de;
|
||
}
|
||
body.shop-dark .color-chip {
|
||
background: #252636;
|
||
border-color: #3a3b50;
|
||
color: #8892a4;
|
||
}
|
||
body.shop-dark .special-instructions {
|
||
background: #2a1e00;
|
||
color: #ffc107;
|
||
}
|
||
body.shop-dark .btn-details {
|
||
background: #252636;
|
||
border-color: #3a3b50;
|
||
color: #8892a4;
|
||
}
|
||
body.shop-dark .empty-state {
|
||
color: #5a6478;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Header -->
|
||
<div class="mobile-header">
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<div class="d-flex align-items-center gap-2">
|
||
<h1><i class="bi bi-tools me-1"></i>Shop Floor</h1>
|
||
<span class="badge-count">@activeCount active</span>
|
||
</div>
|
||
<div class="d-flex align-items-center gap-3">
|
||
<span class="refresh-dot" title="Auto-refreshes every 60s"></span>
|
||
<button class="btn-theme-toggle" id="themeToggle" onclick="toggleShopTheme()" title="Toggle dark mode">
|
||
<i class="bi bi-moon-fill" id="themeIcon"></i>
|
||
</button>
|
||
<a href="/Jobs" class="text-white-50" style="font-size:1.3rem;">
|
||
<i class="bi bi-grid-3x3-gap"></i>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Worker filter chips -->
|
||
@if (workers.Any())
|
||
{
|
||
<div class="filter-bar">
|
||
<a href="@Url.Action("ShopMobile")"
|
||
class="filter-chip @(currentWorkerId == null ? "active" : "")">
|
||
<i class="bi bi-people"></i> All
|
||
</a>
|
||
@foreach (var w in workers)
|
||
{
|
||
<a href="@Url.Action("ShopMobile", new { workerId = w.Id })"
|
||
class="filter-chip @(currentWorkerId == w.Id.ToString() ? "active" : "")">
|
||
@w.Name
|
||
</a>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
<!-- Job cards grouped by status -->
|
||
@{
|
||
var grouped = Model.GroupBy(j => new { j.StatusDisplayOrder, j.StatusDisplayName, j.StatusColorClass })
|
||
.OrderBy(g => g.Key.StatusDisplayOrder);
|
||
}
|
||
|
||
@if (!Model.Any())
|
||
{
|
||
<div class="empty-state">
|
||
<i class="bi bi-check2-all d-block mb-3"></i>
|
||
<strong>All clear!</strong>
|
||
<p class="mt-1 mb-0">No active jobs@(currentWorkerId != null ? " assigned to this worker" : "").</p>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
@foreach (var group in grouped)
|
||
{
|
||
<div class="status-group-header">
|
||
<span class="badge bg-@group.Key.StatusColorClass me-1"> </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>
|