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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user