Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Jobs/ShopMobile.cshtml
T
spouliot a0bdd2b5b4 Sweep all .cshtml files for encoding corruption; add pre-commit guard
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>
2026-05-20 21:37:10 -04:00

630 lines
22 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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">&nbsp;</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 &mdash; 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 &mdash; 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>