Require auth on all work order QR codes and add top view QR

- StatusBump (GET + POST) now requires authentication; routes by job ID
  instead of anonymous ShopAccessCode GUID; records actual user name in
  status history instead of anonymous token string
- WorkOrder action generates a second "View Job" QR in the header linking
  to the authenticated Details page (for verifying specs and seeing catalog
  images on mobile); status bump QR updated to ID-based URL
- WorkOrder view: top QR added to header alongside job number; status bump
  label updated (removed "no login required" copy)
- StatusBump view: updated form routing from asp-route-token to asp-route-id
- HelpKnowledgeBase and Jobs help article updated with two-tier QR docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 13:27:43 -04:00
parent 9361cd4495
commit 4f976b1332
6 changed files with 209 additions and 50 deletions
@@ -573,53 +573,40 @@ public class JobsController : Controller
/// <summary>
/// Shows the status-bump selection page for a shop-floor QR code scan.
/// This endpoint is AllowAnonymous — it is accessed by workers scanning a printed QR code
/// on a work order, not by logged-in users. The <paramref name="token"/> is the job's
/// ShopAccessCode GUID. IgnoreQueryFilters is used so the scan works even if the worker's
/// device has no active session (common on shared shop tablets).
/// Requires authentication — workers must be logged in before scanning. Tenant isolation
/// is enforced by the normal global query filter on <c>GetByIdAsync</c>.
/// </summary>
[AllowAnonymous]
public async Task<IActionResult> StatusBump(Guid token)
public async Task<IActionResult> StatusBump(int id)
{
// Find job by ShopAccessCode — ignore tenant/soft-delete filters so the
// anonymous scan always finds the job regardless of active user session.
var jobs = await _unitOfWork.Jobs.FindAsync(
j => j.ShopAccessCode == token, true,
var job = await _unitOfWork.Jobs.GetByIdAsync(id, false,
j => j.JobStatus,
j => j.JobPriority,
j => j.Customer);
var job = jobs.FirstOrDefault();
if (job == null) return NotFound("Work order token not found.");
if (job == null) return NotFound();
// Load all status lookups to determine next step
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync())
.OrderBy(s => s.DisplayOrder).ToList();
ViewBag.AllStatuses = allStatuses;
ViewBag.Job = job;
ViewBag.Token = token;
ViewBag.JobId = id;
return View();
}
/// <summary>
/// Processes a QR-code status bump from the shop floor — also AllowAnonymous.
/// Validates the token, applies the new status, records the change history, and broadcasts
/// a SignalR update so the office dashboard refreshes in real time.
/// The "bumped by" user is recorded as the ShopAccessCode token string (anonymous actor).
/// Processes a QR-code status bump from the shop floor. Requires authentication.
/// Records the authenticated user's name in status history.
/// </summary>
[AllowAnonymous]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StatusBump(Guid token, int newStatusId)
public async Task<IActionResult> StatusBump(int id, int newStatusId)
{
var jobs = await _unitOfWork.Jobs.FindAsync(
j => j.ShopAccessCode == token, true,
var job = await _unitOfWork.Jobs.GetByIdAsync(id, false,
j => j.JobStatus,
j => j.Customer);
var job = jobs.FirstOrDefault();
if (job == null) return NotFound("Work order token not found.");
if (job == null) return NotFound();
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync()).ToList();
var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId);
@@ -630,28 +617,29 @@ public class JobsController : Controller
job.UpdatedAt = DateTime.UtcNow;
if (newStatus.StatusCode == "COMPLETED") job.CompletedDate = DateTime.UtcNow;
var userName = User.Identity?.Name ?? "Shop Floor";
await _unitOfWork.JobStatusHistory.AddAsync(new JobStatusHistory
{
JobId = job.Id,
FromStatusId = oldStatusId,
ToStatusId = newStatusId,
ChangedDate = DateTime.UtcNow,
Notes = "Updated via shop floor QR scan",
Notes = $"Updated via shop floor QR scan by {userName}",
CompanyId = job.CompanyId,
CreatedAt = DateTime.UtcNow
});
await _unitOfWork.CompleteAsync();
// Reload job status for redirect display
return RedirectToAction(nameof(StatusBump), new { token });
return RedirectToAction(nameof(StatusBump), new { id });
}
/// <summary>
/// Renders the printable work order view for a job.
/// Loads all job items with their coats and prep services so the printed sheet contains
/// the full powder specification, colors, and preparation instructions for shop workers.
/// The work order also includes the QR code for ShopAccessCode-based status bumps.
/// Generates two sets of QR codes: a top "view" code linking to the authenticated job
/// details page, and bottom action codes for status bumping and powder usage logging.
/// </summary>
public async Task<IActionResult> WorkOrder(int? id)
{
@@ -713,13 +701,19 @@ public class JobsController : Controller
ViewBag.Company = company;
}
// Generate QR code for shop floor status bumping
var statusBumpUrl = Url.Action("StatusBump", "Jobs", new { token = job.ShopAccessCode }, Request.Scheme)!;
using var qrGenerator = new QRCoder.QRCodeGenerator();
// Top QR: view/verify the job on mobile (authenticated job details page)
var detailsUrl = Url.Action("Details", "Jobs", new { id = job.Id }, Request.Scheme)!;
using var viewQrData = qrGenerator.CreateQrCode(detailsUrl, QRCoder.QRCodeGenerator.ECCLevel.M);
using var viewQrCode = new QRCoder.PngByteQRCode(viewQrData);
ViewBag.ViewQrCodeBase64 = Convert.ToBase64String(viewQrCode.GetGraphic(4));
// Bottom QR: status bump (authenticated, job ID routed)
var statusBumpUrl = Url.Action("StatusBump", "Jobs", new { id = job.Id }, Request.Scheme)!;
using var qrData = qrGenerator.CreateQrCode(statusBumpUrl, QRCoder.QRCodeGenerator.ECCLevel.M);
using var qrCode = new QRCoder.PngByteQRCode(qrData);
var qrBytes = qrCode.GetGraphic(4);
ViewBag.QrCodeBase64 = Convert.ToBase64String(qrBytes);
ViewBag.QrCodeBase64 = Convert.ToBase64String(qrCode.GetGraphic(4));
ViewBag.StatusBumpUrl = statusBumpUrl;
// Generate QR codes for each unique inventory powder item so workers can
@@ -296,6 +296,16 @@ public static class HelpKnowledgeBase
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice."
**Work Order QR Codes:** Every printed job work order includes two tiers of QR codes one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in.
*Top QR View Job:* Located in the header next to the job number. Scanning it opens the full Job Details page on the worker's phone shows all items, catalog images, powder specs, coatings, prep services, and special instructions. Use this to verify you're working the right job and to see catalog product images on mobile.
*Bottom QR codes Actions:*
- **Update Status** advances the job to its next status stage. Opens a dedicated mobile-friendly status bump page where the worker confirms the new stage. The status change is recorded in history with the logged-in worker's name.
- **Log Powder Usage** one QR per unique powder/inventory item on the job. Scanning opens the inventory usage log page pre-filled with that item and the job, so the worker can record actual lbs used without navigating through the app.
All QR codes require login workers must have an active account. Logging in once on their phone is sufficient for the session.
**Blank Work Order:** Print a pre-formatted paper work order to hand to a walk-in customer before creating a digital job record.
- Access: Jobs list page printer icon button "Blank Work Order" in the top-right toolbar. Or navigate directly to /WorkOrder/Blank.
- The PDF opens in a new tab ready to print. It includes: company logo and address, Drop Off Date field, Client Name / Client Phone / Due Date fields, 12-row parts table (Part Description / Color / Quote), Notes box, customizable Terms & Conditions text, and a Customer Signature line.
@@ -576,6 +576,49 @@
</div>
</section>
<section id="work-order-qr-codes" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-qr-code text-primary me-2"></i>Work Order QR Codes
</h2>
<p>
Every printed job work order includes two tiers of QR codes — one for <strong>viewing</strong>
the job and a separate set for <strong>acting</strong> on it. This gives shop workers everything
they need from a printed sheet without touching the desktop app.
All QR codes require a logged-in account.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-eye me-1"></i>Top QR — View Job</h3>
<p>
Located in the work order header, next to the job number. Scan it with your phone to open the
full <strong>Job Details</strong> page — items, catalog product images, powder specs, coatings,
prep services, and special instructions. Use it to verify you're working the right job or to
see catalog item images on your phone without hunting through the app.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-arrow-right-circle me-1"></i>Bottom QR — Update Status</h3>
<p>
Scan to open a mobile-friendly status bump page for this job. Tap the button to advance to the
next stage (or put the job on hold). The status change is recorded in history with your name —
no anonymous bumps.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-box-seam me-1"></i>Bottom QR — Log Powder Usage</h3>
<p>
One QR per unique powder on the job. Scanning opens the inventory usage log page pre-filled
with that powder and the job number, so you can record actual lbs used in seconds without
navigating through the app.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lock flex-shrink-0 mt-1"></i>
<div>
<strong>Login required:</strong> All three QR codes require workers to be logged in to their
account. Logging in once on their phone is enough for the session. Make sure every shop
floor worker has an account set up before handing out printed work orders.
</div>
</div>
</section>
<section id="blank-work-order" class="mb-5">
<h2 class="h5 fw-semibold mb-3">Blank Work Order</h2>
<p>
@@ -643,6 +686,7 @@
<a class="nav-link py-1 px-3 small text-body" href="#part-intake">Part Intake</a>
<a class="nav-link py-1 px-3 small text-body" href="#shop-mobile">Shop Mobile</a>
<a class="nav-link py-1 px-3 small text-body" href="#changing-customer">Changing the Customer</a>
<a class="nav-link py-1 px-3 small text-body" href="#work-order-qr-codes">Work Order QR Codes</a>
<a class="nav-link py-1 px-3 small text-body" href="#blank-work-order">Blank Work Order</a>
</nav>
</div>
@@ -3,7 +3,7 @@
Layout = null;
var job = ViewBag.Job as PowderCoating.Core.Entities.Job;
var allStatuses = ViewBag.AllStatuses as List<PowderCoating.Core.Entities.JobStatusLookup>;
var token = (Guid)ViewBag.Token;
var jobId = (int)ViewBag.JobId;
// Determine next/previous status options
var currentOrder = job!.JobStatus.DisplayOrder;
@@ -240,7 +240,7 @@
@* On hold — offer resume (next logical status after resume by advancing) *@
@if (nextStatus != null)
{
<form method="post" asp-action="StatusBump" asp-route-token="@token">
<form method="post" asp-action="StatusBump" asp-route-id="@jobId">
@Html.AntiForgeryToken()
<input type="hidden" name="newStatusId" value="@nextStatus.Id" />
<button type="submit" class="btn-resume">
@@ -254,7 +254,7 @@
@* Advance to next step *@
@if (nextStatus != null)
{
<form method="post" asp-action="StatusBump" asp-route-token="@token">
<form method="post" asp-action="StatusBump" asp-route-id="@jobId">
@Html.AntiForgeryToken()
<input type="hidden" name="newStatusId" value="@nextStatus.Id" />
<button type="submit" class="btn-advance">
@@ -270,7 +270,7 @@
@* On Hold option *@
@if (onHoldStatus != null)
{
<form method="post" asp-action="StatusBump" asp-route-token="@token">
<form method="post" asp-action="StatusBump" asp-route-id="@jobId">
@Html.AntiForgeryToken()
<input type="hidden" name="newStatusId" value="@onHoldStatus.Id" />
<button type="submit" class="btn-hold">
@@ -292,22 +292,33 @@
</div>
<div class="col-6">
<div class="work-order-title">WORK ORDER</div>
<div class="text-center" style="font-size: 8pt; line-height: 1.6;">
<div class="mb-2">
<span class="text-muted">Job #:</span>
<span style="font-size: 14pt;" class="fw-bold ms-1">@Model.JobNumber</span>
</div>
<div class="d-flex justify-content-center align-items-center gap-3 mb-1">
<div>
<span class="text-muted">Priority:</span>
<span class="badge bg-@Model.PriorityColorClass ms-1">@Model.PriorityDisplayName</span>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
<div style="font-size: 8pt; line-height: 1.6; flex: 1; text-align: center;">
<div class="mb-2">
<span class="text-muted">Job #:</span>
<span style="font-size: 14pt;" class="fw-bold ms-1">@Model.JobNumber</span>
</div>
<div>
<span class="text-muted">Status:</span>
<span class="badge bg-@Model.StatusColorClass ms-1">@Model.StatusDisplayName</span>
<div class="d-flex justify-content-center align-items-center gap-3 mb-1">
<div>
<span class="text-muted">Priority:</span>
<span class="badge bg-@Model.PriorityColorClass ms-1">@Model.PriorityDisplayName</span>
</div>
<div>
<span class="text-muted">Status:</span>
<span class="badge bg-@Model.StatusColorClass ms-1">@Model.StatusDisplayName</span>
</div>
</div>
<div class="text-center text-muted" style="font-size: 7pt;">Created: @Model.CreatedAt.ToString("MM/dd/yyyy")</div>
</div>
<div class="text-center text-muted" style="font-size: 7pt;">Created: @Model.CreatedAt.ToString("MM/dd/yyyy")</div>
@if (ViewBag.ViewQrCodeBase64 != null)
{
<div style="text-align: center; flex-shrink: 0;">
<img src="data:image/png;base64,@ViewBag.ViewQrCodeBase64"
alt="View Job"
style="width: 64px; height: 64px; image-rendering: pixelated; display: block;" />
<div style="font-size: 6.5pt; color: #6c757d; margin-top: 2px;">View Job</div>
</div>
}
</div>
</div>
</div>
@@ -605,7 +616,7 @@
<i class="bi bi-arrow-right-circle me-1"></i>Update Status
</div>
<div style="font-size: 7.5pt; color: #6c757d; line-height: 1.5;">
Advance job to next<br />status — no login required.
Advance job to<br />next status.
</div>
</div>
</div>