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:
@@ -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 — remaining equals current stock.</span>';
|
||||
return;
|
||||
}
|
||||
hint.innerHTML = 'Will log <strong>' + used.toFixed(2) + ' ' + uom + '</strong> as used — 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 — 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>
|
||||
|
||||
Reference in New Issue
Block a user