Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,631 @@
@model List<PowderCoating.Application.DTOs.Job.JobDailyPriorityDto>
@using PowderCoating.Core.Entities
@{
Layout = null;
var workers = ViewBag.Workers as List<ShopWorker> ?? new();
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 — 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>