Restore all zeroed views + add bulk gift certificate creation

The HTML entity sweep script had a bug where it wrote empty files for any
view that contained no target Unicode characters, zeroing out 215 view files.
All views restored from the pre-sweep commit (cefdf3e).

Bulk gift certificate feature:
- BulkCreateGiftCertificateDto with Quantity (1-500), Amount, Reason, Expiry, Notes
- GenerateBulkGiftCertificatePdfAsync on IPdfService / PdfService: one Letter page
  per cert, reusing the same purple/gold branded ComposeGiftCertificateContent helper
- GiftCertificatesController: BulkCreate GET/POST, BulkResult GET, BulkDownloadPdf POST
- Views: BulkCreate.cshtml (form with live total preview), BulkResult.cshtml (table +
  Download All PDF button that POSTs cert IDs to avoid URL length limits)
- gift-certificate-bulk.js: live preview + spinner/disable on submit
- Index.cshtml: Bulk Create button added alongside New Certificate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 20:09:22 -04:00
parent 3eda91f170
commit 4ec55e7290
240 changed files with 73116 additions and 0 deletions
@@ -0,0 +1,512 @@
@using PowderCoating.Application.DTOs.Inventory
@using PowderCoating.Web.Controllers
@{
var item = ViewBag.ItemDto as InventoryItemDto;
var myJobs = ViewBag.MyJobs as List<ScanJobOption> ?? new();
var otherJobs = ViewBag.OtherJobs as List<ScanJobOption> ?? new();
var preselectedJobId = ViewBag.PreselectedJobId as int?;
var scanError = ViewBag.ScanError as string;
ViewData["Title"] = $"Log Usage — {item?.Name}";
Layout = null; // mobile-first standalone page
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>Log Usage — @item?.Name</title>
<style>
:root {
--purple: #6f42c1;
--purple-dark: #5a32a3;
--danger: #dc3545;
--success: #198754;
--muted: #6c757d;
--border: #dee2e6;
--bg: #f8f9fa;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
background: var(--bg);
min-height: 100vh;
padding-bottom: 32px;
}
/* ── Header ──────────────────────────────────── */
.page-header {
background: var(--purple);
color: #fff;
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
}
.page-header .logo { font-weight: 700; font-size: 13px; letter-spacing: .05em; opacity: .85; }
.page-header h1 { font-size: 18px; font-weight: 700; line-height: 1.2; }
.page-header .sub { font-size: 13px; opacity: .85; }
/* ── Item card ───────────────────────────────── */
.item-card {
background: #fff;
margin: 16px;
border-radius: 12px;
padding: 16px;
box-shadow: 0 1px 4px rgba(0,0,0,.1);
display: flex;
align-items: center;
gap: 14px;
}
.item-qr img { width: 70px; height: 70px; border-radius: 6px; border: 1px solid var(--border); }
.item-info .item-name { font-size: 16px; font-weight: 700; }
.item-info .item-sku { font-size: 13px; color: var(--muted); margin-top: 2px; }
.item-info .item-stock {
margin-top: 6px;
font-size: 13px;
font-weight: 600;
}
.stock-ok { color: var(--success); }
.stock-low { color: var(--danger); }
.stock-zero { color: #343a40; }
/* ── Form card ───────────────────────────────── */
.form-card {
background: #fff;
margin: 0 16px 16px;
border-radius: 12px;
padding: 20px 16px;
box-shadow: 0 1px 4px rgba(0,0,0,.1);
}
.form-card h2 { font-size: 15px; font-weight: 700; margin-bottom: 16px; color: #333; }
.field { margin-bottom: 18px; }
.field label {
display: block;
font-size: 13px;
font-weight: 600;
color: #444;
margin-bottom: 6px;
}
.field label .req { color: var(--danger); }
.field input[type=number],
.field select,
.field textarea {
width: 100%;
padding: 12px 14px;
border: 1.5px solid var(--border);
border-radius: 8px;
font-size: 16px; /* prevents iOS zoom */
background: #fff;
appearance: none;
-webkit-appearance: none;
}
.field input[type=number]:focus,
.field select:focus,
.field textarea:focus {
outline: none;
border-color: var(--purple);
box-shadow: 0 0 0 3px rgba(111,66,193,.15);
}
/* ── Job picker ──────────────────────────────── */
.job-tabs { display: flex; gap: 8px; margin-bottom: 12px; }
.job-tab {
flex: 1;
padding: 8px;
border: 1.5px solid var(--border);
border-radius: 8px;
background: #fff;
font-size: 13px;
font-weight: 600;
color: var(--muted);
cursor: pointer;
text-align: center;
}
.job-tab.active { border-color: var(--purple); color: var(--purple); background: #f3effe; }
.job-list { display: flex; flex-direction: column; gap: 8px; max-height: 220px; overflow-y: auto; }
.job-option {
padding: 10px 12px;
border: 1.5px solid var(--border);
border-radius: 8px;
cursor: pointer;
background: #fff;
display: flex;
flex-direction: column;
gap: 2px;
}
.job-option:hover, .job-option.selected { border-color: var(--purple); background: #f3effe; }
.job-option .jn { font-size: 14px; font-weight: 700; }
.job-option .cn { font-size: 12px; color: var(--muted); }
.no-job-opt {
padding: 10px 12px;
border: 1.5px dashed var(--border);
border-radius: 8px;
cursor: pointer;
background: #fff;
font-size: 13px;
color: var(--muted);
text-align: center;
}
.no-job-opt.selected { border-color: var(--muted); background: #f8f9fa; color: #333; }
#jobIdInput { display: none; }
/* ── Reason pills ────────────────────────────── */
.reason-pills { display: flex; flex-wrap: wrap; gap: 8px; }
.reason-pill {
padding: 8px 14px;
border: 1.5px solid var(--border);
border-radius: 20px;
font-size: 13px;
cursor: pointer;
background: #fff;
color: #444;
white-space: nowrap;
}
.reason-pill.selected { border-color: var(--purple); background: #f3effe; color: var(--purple); font-weight: 600; }
/* ── Input mode toggle ───────────────────────── */
.mode-toggle { display: flex; border: 1.5px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 18px; }
.mode-btn {
flex: 1;
padding: 10px 8px;
background: #fff;
border: none;
font-size: 13px;
font-weight: 600;
color: var(--muted);
cursor: pointer;
text-align: center;
transition: background .15s, color .15s;
}
.mode-btn.active { background: var(--purple); color: #fff; }
.mode-btn:first-child { border-right: 1.5px solid var(--border); }
/* ── Submit / Cancel ─────────────────────────── */
.btn-submit {
width: 100%;
padding: 16px;
background: var(--purple);
color: #fff;
border: none;
border-radius: 10px;
font-size: 17px;
font-weight: 700;
cursor: pointer;
margin-top: 4px;
}
.btn-submit:disabled { opacity: .6; }
.btn-submit:active { background: var(--purple-dark); }
.btn-cancel {
display: block;
width: 100%;
padding: 14px;
background: #fff;
color: var(--muted);
border: 1.5px solid var(--border);
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-top: 10px;
text-align: center;
text-decoration: none;
}
.btn-cancel:active { background: #f0f0f0; }
.error-banner {
margin: 0 16px 16px;
background: #f8d7da;
color: #842029;
border: 1px solid #f5c2c7;
border-radius: 8px;
padding: 12px 14px;
font-size: 14px;
}
.hint { font-size: 12px; color: var(--muted); margin-top: 4px; }
</style>
</head>
<body>
<div class="page-header">
<div>
<div class="logo">Powder Coating Logix</div>
<h1>Log Usage</h1>
<div class="sub">Record powder used from inventory</div>
</div>
</div>
@if (!string.IsNullOrEmpty(scanError))
{
<div class="error-banner">⚠ @scanError</div>
}
<!-- Item Info -->
<div class="item-card">
<div class="item-qr">
<img src="/Inventory/QrCode/@item!.Id?size=4" alt="QR" />
</div>
<div class="item-info">
<div class="item-name">@item.Name</div>
<div class="item-sku">@item.SKU</div>
@if (!string.IsNullOrEmpty(item.ColorName))
{
<div class="item-sku">@item.ColorName@(item.Finish != null ? " · " + item.Finish : "")</div>
}
<div class="item-stock @(item.IsOutOfStock ? "stock-zero" : item.IsLowStock ? "stock-low" : "stock-ok")">
@item.QuantityOnHand.ToString("N2") @item.UnitOfMeasure on hand
@if (item.IsOutOfStock) { <span>· Out of Stock</span> }
else if (item.IsLowStock) { <span>· Low Stock</span> }
</div>
</div>
</div>
<!-- Usage Form -->
<form method="post" action="/Inventory/LogUsage" id="usageForm">
@Html.AntiForgeryToken()
<input type="hidden" name="inventoryItemId" value="@item.Id" />
<input type="hidden" name="jobId" id="jobIdInput" />
<input type="hidden" name="transactionType" id="transactionTypeInput" value="Adjustment" />
<div class="form-card">
<h2>1. Select Job (optional)</h2>
@if (myJobs.Any() || otherJobs.Any())
{
<div class="job-tabs">
@if (myJobs.Any())
{
<div class="job-tab active" id="tabMine" onclick="showTab('mine')">My Jobs (@myJobs.Count)</div>
}
@if (otherJobs.Any())
{
<div class="job-tab @(!myJobs.Any() ? "active" : "")" id="tabOther" onclick="showTab('other')">Other Jobs</div>
}
<div class="job-tab" id="tabNone" onclick="showTab('none')">No Job</div>
</div>
<div id="listMine" class="job-list" style="@(!myJobs.Any() ? "display:none" : "")">
@foreach (var j in myJobs)
{
<div class="job-option @(preselectedJobId == j.Id ? "selected" : "")"
data-jobid="@j.Id" onclick="selectJob(this)">
<span class="jn">@j.JobNumber</span>
<span class="cn">@j.CustomerName</span>
</div>
}
</div>
<div id="listOther" class="job-list" style="display:none">
@foreach (var j in otherJobs)
{
<div class="job-option" data-jobid="@j.Id" onclick="selectJob(this)">
<span class="jn">@j.JobNumber</span>
<span class="cn">@j.CustomerName</span>
</div>
}
</div>
<div id="listNone" style="display:none">
<div class="no-job-opt selected" onclick="selectNoJob(this)">
No job — log as general usage
</div>
</div>
}
else
{
<p style="font-size:13px;color:var(--muted)">No active jobs found. Usage will be logged without a job reference.</p>
}
<div class="hint" id="jobHint" style="margin-top:8px"></div>
</div>
<div class="form-card">
<h2>2. Enter Quantity</h2>
<div class="mode-toggle">
<button type="button" class="mode-btn active" id="modeUsed" onclick="setMode('used')">Amount Used</button>
<button type="button" class="mode-btn" id="modeRemaining" onclick="setMode('remaining')">Remaining Weight</button>
</div>
<!-- amount-used mode -->
<div id="usedField" class="field">
<label for="quantityInput">Amount Used (@item.UnitOfMeasure) <span class="req">*</span></label>
<input type="number" id="quantityInput" name="quantity"
min="0" step="any" placeholder="0" inputmode="decimal"
oninvalid="this.setCustomValidity('')" />
<div class="hint" id="balanceHint"></div>
</div>
<!-- remaining-weight mode -->
<div id="remainingField" class="field" style="display:none">
<label for="remainingInput">Weight Remaining (@item.UnitOfMeasure) <span class="req">*</span></label>
<input type="number" id="remainingInput" min="0" step="any"
placeholder="0" inputmode="decimal" />
<div class="hint" id="remainingHint"></div>
</div>
</div>
<div class="form-card">
<h2>3. Reason</h2>
<div class="field">
<div class="reason-pills">
<div class="reason-pill selected" data-val="JobUsage" onclick="selectReason(this)">Job Usage</div>
<div class="reason-pill" data-val="Waste" onclick="selectReason(this)">Waste / Spillage</div>
<div class="reason-pill" data-val="Adjustment" onclick="selectReason(this)">Correction</div>
<div class="reason-pill" data-val="Transfer" onclick="selectReason(this)">Transfer Out</div>
</div>
</div>
<div class="field">
<label for="notesInput">Notes <span style="font-weight:400;color:var(--muted)">(optional)</span></label>
<textarea id="notesInput" name="notes" rows="2"
placeholder="Any additional details…" maxlength="500"
style="font-size:16px;resize:none"></textarea>
</div>
</div>
<div style="margin: 0 16px 8px">
<button type="submit" class="btn-submit" id="submitBtn">
Save Usage Log
</button>
<a href="/Inventory/Details/@item!.Id" class="btn-cancel">Cancel</a>
</div>
</form>
<script>
var currentQty = @item.QuantityOnHand;
var uom = '@item.UnitOfMeasure';
var inputMode = 'used'; // 'used' | 'remaining'
// ── Input mode toggle ────────────────────────────
function setMode(mode) {
inputMode = mode;
document.getElementById('modeUsed').classList.toggle('active', mode === 'used');
document.getElementById('modeRemaining').classList.toggle('active', mode === 'remaining');
document.getElementById('usedField').style.display = mode === 'used' ? '' : 'none';
document.getElementById('remainingField').style.display = mode === 'remaining' ? '' : 'none';
document.getElementById('balanceHint').textContent = '';
document.getElementById('remainingHint').textContent = '';
// clear both inputs when switching
document.getElementById('quantityInput').value = '';
document.getElementById('remainingInput').value = '';
}
// ── Job selection ────────────────────────────────
function showTab(tab) {
['mine','other','none'].forEach(function(t) {
var list = document.getElementById('list' + t.charAt(0).toUpperCase() + t.slice(1));
var tabEl = document.getElementById('tab' + t.charAt(0).toUpperCase() + t.slice(1));
if (list) list.style.display = t === tab ? '' : 'none';
if (tabEl) tabEl.classList.toggle('active', t === tab);
});
if (tab === 'none') {
document.getElementById('jobIdInput').value = '';
document.getElementById('jobHint').textContent = 'Usage will be logged without a job reference.';
} else {
document.getElementById('jobIdInput').value = '';
document.getElementById('jobHint').textContent = '';
}
}
function selectJob(el) {
document.querySelectorAll('.job-option').forEach(function(e) { e.classList.remove('selected'); });
el.classList.add('selected');
document.getElementById('jobIdInput').value = el.dataset.jobid;
document.getElementById('jobHint').textContent = 'Job selected: ' + el.querySelector('.jn').textContent;
}
function selectNoJob(el) {
document.querySelectorAll('.job-option').forEach(function(e) { e.classList.remove('selected'); });
document.getElementById('jobIdInput').value = '';
document.getElementById('jobHint').textContent = 'Usage will be logged without a job reference.';
}
// ── Reason selection ─────────────────────────────
function selectReason(el) {
document.querySelectorAll('.reason-pill').forEach(function(e) { e.classList.remove('selected'); });
el.classList.add('selected');
document.getElementById('transactionTypeInput').value = el.dataset.val;
}
// ── Balance hint (amount-used mode) ─────────────
document.getElementById('quantityInput').addEventListener('input', function() {
var qty = parseFloat(this.value) || 0;
if (!this.value) { document.getElementById('balanceHint').textContent = ''; return; }
var newBal = currentQty - qty;
var col = newBal < 0 ? 'var(--danger)' : newBal === 0 ? '#343a40' : 'var(--success)';
document.getElementById('balanceHint').innerHTML =
'New balance: <strong style="color:' + col + '">' + newBal.toFixed(2) + ' ' + uom + '</strong>';
});
// ── Remaining-weight hint ────────────────────────
document.getElementById('remainingInput').addEventListener('input', function() {
var hint = document.getElementById('remainingHint');
if (!this.value) { hint.textContent = ''; return; }
var remaining = parseFloat(this.value);
if (isNaN(remaining) || remaining < 0) { hint.innerHTML = '<span style="color:var(--danger)">Enter a valid weight.</span>'; return; }
if (remaining > currentQty) {
hint.innerHTML = '<span style="color:var(--danger)">Remaining cannot exceed current stock (' + currentQty.toFixed(2) + ' ' + uom + ').</span>';
return;
}
var used = currentQty - remaining;
if (used <= 0) {
hint.innerHTML = '<span style="color:var(--danger)">No usage to log &mdash; remaining equals current stock.</span>';
return;
}
hint.innerHTML = 'Will log <strong>' + used.toFixed(2) + ' ' + uom + '</strong> as used &mdash; new balance: <strong style="color:' + (remaining === 0 ? '#343a40' : 'var(--success)') + '">' + remaining.toFixed(2) + ' ' + uom + '</strong>';
});
// ── Preselect job if coming from success page ────
@if (preselectedJobId.HasValue)
{
<text>
(function() {
var preId = '@preselectedJobId';
var el = document.querySelector('[data-jobid="' + preId + '"]');
if (el) { selectJob(el); el.scrollIntoView({block:'nearest'}); }
}());
</text>
}
// ── Submit: resolve quantity from whichever mode is active ──
document.getElementById('usageForm').addEventListener('submit', function(e) {
if (inputMode === 'remaining') {
var remaining = parseFloat(document.getElementById('remainingInput').value);
if (isNaN(remaining) || remaining < 0 || remaining > currentQty) {
e.preventDefault();
document.getElementById('remainingHint').innerHTML =
'<span style="color:var(--danger)">Please enter a valid remaining weight.</span>';
return;
}
var used = currentQty - remaining;
if (used <= 0) {
e.preventDefault();
document.getElementById('remainingHint').innerHTML =
'<span style="color:var(--danger)">No usage to log &mdash; remaining equals current stock.</span>';
return;
}
document.getElementById('quantityInput').value = used.toFixed(4);
}
// validate amount-used mode
if (inputMode === 'used') {
var qty = parseFloat(document.getElementById('quantityInput').value);
if (isNaN(qty) || qty <= 0) {
e.preventDefault();
document.getElementById('balanceHint').innerHTML =
'<span style="color:var(--danger)">Please enter a quantity greater than zero.</span>';
return;
}
}
var btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = 'Saving…';
});
</script>
</body>
</html>