Add manual Log Material modal to job details page
PC users were blocked to QR scan only for logging material usage. Now a "Log Material" button opens an inline modal with: - Inventory item dropdown (name + unit of measure, current stock shown on select) - Entry method toggle: "Amount Used" or "Amount Remaining" (computes used = onHand - remaining) - Reason: Job Usage or Waste/Spillage - Notes field Submits via AJAX to Jobs/LogMaterial (new POST action) which mirrors the InventoryController.LogUsage flow — updates QuantityOnHand, creates InventoryTransaction, posts GL entries (DR COGS / CR Inventory). QR scan button retained as icon. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -498,6 +498,13 @@ public class JobsController : Controller
|
|||||||
.OrderByDescending(t => t.TransactionDate).ToList();
|
.OrderByDescending(t => t.TransactionDate).ToList();
|
||||||
ViewBag.MaterialsUsed = allJobTransactions;
|
ViewBag.MaterialsUsed = allJobTransactions;
|
||||||
|
|
||||||
|
// Inventory items for the manual log-material modal
|
||||||
|
var inventoryItemsForModal = (await _unitOfWork.InventoryItems.GetAllAsync())
|
||||||
|
.OrderBy(i => i.Name)
|
||||||
|
.Select(i => new { i.Id, i.Name, i.UnitOfMeasure, i.QuantityOnHand })
|
||||||
|
.ToList();
|
||||||
|
ViewBag.InventoryItemsForModal = System.Text.Json.JsonSerializer.Serialize(inventoryItemsForModal);
|
||||||
|
|
||||||
// Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill)
|
// Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill)
|
||||||
ViewBag.PreLoggedPowder = allJobTransactions
|
ViewBag.PreLoggedPowder = allJobTransactions
|
||||||
.GroupBy(t => t.InventoryItemId)
|
.GroupBy(t => t.InventoryItemId)
|
||||||
@@ -4080,9 +4087,87 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Logs manual material usage from the job details page. Mirrors the QR scan LogUsage
|
||||||
|
/// flow in InventoryController but returns JSON so the modal can close and refresh inline.
|
||||||
|
/// Quantity is always the amount USED (caller converts from remaining if needed).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> LogMaterial([FromBody] LogMaterialRequest req)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (req.QuantityUsed <= 0)
|
||||||
|
return Json(new { success = false, message = "Quantity used must be greater than zero." });
|
||||||
|
|
||||||
|
var item = await _unitOfWork.InventoryItems.GetByIdAsync(req.InventoryItemId);
|
||||||
|
if (item == null) return Json(new { success = false, message = "Inventory item not found." });
|
||||||
|
|
||||||
|
var job = await _unitOfWork.Jobs.GetByIdAsync(req.JobId);
|
||||||
|
if (job == null) return Json(new { success = false, message = "Job not found." });
|
||||||
|
|
||||||
|
var txnType = req.TransactionType == "Waste"
|
||||||
|
? InventoryTransactionType.Waste
|
||||||
|
: InventoryTransactionType.JobUsage;
|
||||||
|
|
||||||
|
item.QuantityOnHand -= req.QuantityUsed;
|
||||||
|
item.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
||||||
|
|
||||||
|
var txn = new PowderCoating.Core.Entities.InventoryTransaction
|
||||||
|
{
|
||||||
|
InventoryItemId = item.Id,
|
||||||
|
TransactionType = txnType,
|
||||||
|
Quantity = -req.QuantityUsed,
|
||||||
|
UnitCost = item.UnitCost,
|
||||||
|
TotalCost = req.QuantityUsed * item.UnitCost,
|
||||||
|
TransactionDate = DateTime.UtcNow,
|
||||||
|
BalanceAfter = item.QuantityOnHand,
|
||||||
|
JobId = req.JobId,
|
||||||
|
Reference = $"Job {job.JobNumber}",
|
||||||
|
Notes = req.Notes?.Trim(),
|
||||||
|
CompanyId = item.CompanyId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
await _unitOfWork.InventoryTransactions.AddAsync(txn);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
// GL: DR COGS, CR Inventory Asset
|
||||||
|
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
|
||||||
|
{
|
||||||
|
var cost = req.QuantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
|
||||||
|
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
|
||||||
|
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json(new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
message = $"Logged {req.QuantityUsed:N2} {item.UnitOfMeasure} of {item.Name}.",
|
||||||
|
newBalance = item.QuantityOnHand,
|
||||||
|
unitOfMeasure = item.UnitOfMeasure,
|
||||||
|
itemName = item.Name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error logging material for job {JobId}", req.JobId);
|
||||||
|
return Json(new { success = false, message = "An error occurred. Please try again." });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DeleteTimeEntryRequest { public int Id { get; set; } }
|
public class DeleteTimeEntryRequest { public int Id { get; set; } }
|
||||||
|
public class LogMaterialRequest
|
||||||
|
{
|
||||||
|
public int JobId { get; set; }
|
||||||
|
public int InventoryItemId { get; set; }
|
||||||
|
public decimal QuantityUsed { get; set; }
|
||||||
|
public string TransactionType { get; set; } = "JobUsage";
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
|
public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
|
||||||
|
|
||||||
public class UpdateWorkerAssignmentRequest
|
public class UpdateWorkerAssignmentRequest
|
||||||
|
|||||||
@@ -1016,9 +1016,12 @@
|
|||||||
<span class="badge bg-primary rounded-pill ms-1">@materialsUsed.Count</span>
|
<span class="badge bg-primary rounded-pill ms-1">@materialsUsed.Count</span>
|
||||||
}
|
}
|
||||||
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
|
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
|
||||||
<span class="ms-auto">
|
<span class="ms-auto d-flex gap-2">
|
||||||
<a asp-controller="Inventory" asp-action="Scan" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation();">
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation(); openLogMaterialModal();">
|
||||||
<i class="bi bi-qr-code-scan me-1"></i>Log Material
|
<i class="bi bi-plus-circle me-1"></i>Log Material
|
||||||
|
</button>
|
||||||
|
<a asp-controller="Inventory" asp-action="Scan" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation();" title="Scan QR code">
|
||||||
|
<i class="bi bi-qr-code-scan"></i>
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1028,7 +1031,7 @@
|
|||||||
{
|
{
|
||||||
<div class="card-body text-muted text-center py-3 small">
|
<div class="card-body text-muted text-center py-3 small">
|
||||||
<i class="bi bi-droplet me-1"></i>No materials have been logged for this job yet.
|
<i class="bi bi-droplet me-1"></i>No materials have been logged for this job yet.
|
||||||
Use the QR label on an inventory item to log usage.
|
Click <strong>Log Material</strong> above or scan the QR label on an inventory item.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -1089,6 +1092,65 @@
|
|||||||
</div><!-- /collapseMaterials -->
|
</div><!-- /collapseMaterials -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Log Material Modal -->
|
||||||
|
<div class="modal fade" id="logMaterialModal" tabindex="-1" aria-labelledby="logMaterialModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="logMaterialModalLabel">
|
||||||
|
<i class="bi bi-droplet-half me-2 text-primary"></i>Log Material Usage
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Inventory Item <span class="text-danger">*</span></label>
|
||||||
|
<select id="lmInventoryItem" class="form-select">
|
||||||
|
<option value="">-- Select item --</option>
|
||||||
|
</select>
|
||||||
|
<div id="lmItemBalance" class="form-text text-muted d-none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Entry Method</label>
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodUsed" value="used" checked onchange="lmUpdateQuantityLabel()">
|
||||||
|
<label class="form-check-label" for="lmMethodUsed">Amount Used</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodRemaining" value="remaining" onchange="lmUpdateQuantityLabel()">
|
||||||
|
<label class="form-check-label" for="lmMethodRemaining">Amount Remaining</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label id="lmQtyLabel" class="form-label fw-semibold">Quantity Used <span class="text-danger">*</span></label>
|
||||||
|
<input type="number" id="lmQuantity" class="form-control" min="0" step="0.01" placeholder="0.00">
|
||||||
|
<div id="lmComputedUsed" class="form-text text-muted d-none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Reason</label>
|
||||||
|
<select id="lmTransactionType" class="form-select">
|
||||||
|
<option value="JobUsage">Job Usage</option>
|
||||||
|
<option value="Waste">Waste / Spillage</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Notes</label>
|
||||||
|
<textarea id="lmNotes" class="form-control" rows="2" placeholder="Optional"></textarea>
|
||||||
|
</div>
|
||||||
|
<div id="lmAlert" class="alert alert-permanent d-none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="lmSaveBtn" onclick="lmSave()">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>Log Usage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Part Intake Modal -->
|
<!-- Part Intake Modal -->
|
||||||
@{
|
@{
|
||||||
var intakeExpectedCount = Model.Items?.Sum(i => (int)i.Quantity) ?? 0;
|
var intakeExpectedCount = Model.Items?.Sum(i => (int)i.Quantity) ?? 0;
|
||||||
@@ -3082,6 +3144,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Log Material Modal JS -->
|
||||||
|
<script src="/js/log-material.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]");
|
||||||
|
const jobId = @Model.Id;
|
||||||
|
const logUrl = '@Url.Action("LogMaterial", "Jobs")';
|
||||||
|
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||||
|
window.__logMaterial = { inventoryItems, jobId, logUrl, token };
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Save as Template Modal -->
|
<!-- Save as Template Modal -->
|
||||||
<div class="modal fade" id="saveTemplateModal" tabindex="-1" aria-labelledby="saveTemplateModalLabel" aria-hidden="true">
|
<div class="modal fade" id="saveTemplateModal" tabindex="-1" aria-labelledby="saveTemplateModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Log Material Usage modal — job details page.
|
||||||
|
* Reads config from window.__logMaterial injected inline by the view.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
let _items = [];
|
||||||
|
let _modal = null;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const cfg = window.__logMaterial;
|
||||||
|
if (!cfg) return;
|
||||||
|
|
||||||
|
_items = cfg.inventoryItems || [];
|
||||||
|
_modal = new bootstrap.Modal(document.getElementById('logMaterialModal'));
|
||||||
|
|
||||||
|
const sel = document.getElementById('lmInventoryItem');
|
||||||
|
_items.forEach(function (item) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = item.id;
|
||||||
|
opt.textContent = item.name + (item.unitOfMeasure ? ' (' + item.unitOfMeasure + ')' : '');
|
||||||
|
opt.dataset.qty = item.quantityOnHand;
|
||||||
|
opt.dataset.uom = item.unitOfMeasure || '';
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
sel.addEventListener('change', lmOnItemChange);
|
||||||
|
document.getElementById('lmQuantity').addEventListener('input', lmOnQtyInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lmOnItemChange() {
|
||||||
|
const sel = document.getElementById('lmInventoryItem');
|
||||||
|
const opt = sel.options[sel.selectedIndex];
|
||||||
|
const balDiv = document.getElementById('lmItemBalance');
|
||||||
|
if (sel.value && opt) {
|
||||||
|
const qty = parseFloat(opt.dataset.qty) || 0;
|
||||||
|
const uom = opt.dataset.uom;
|
||||||
|
balDiv.textContent = 'Current stock: ' + qty.toFixed(2) + (uom ? ' ' + uom : '');
|
||||||
|
balDiv.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
balDiv.classList.add('d-none');
|
||||||
|
}
|
||||||
|
lmOnQtyInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
function lmOnQtyInput() {
|
||||||
|
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
|
||||||
|
if (method !== 'remaining') {
|
||||||
|
document.getElementById('lmComputedUsed').classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sel = document.getElementById('lmInventoryItem');
|
||||||
|
const opt = sel.options[sel.selectedIndex];
|
||||||
|
const remaining = parseFloat(document.getElementById('lmQuantity').value) || 0;
|
||||||
|
const onHand = parseFloat(opt?.dataset.qty) || 0;
|
||||||
|
const used = onHand - remaining;
|
||||||
|
const computedDiv = document.getElementById('lmComputedUsed');
|
||||||
|
if (sel.value) {
|
||||||
|
computedDiv.textContent = 'Usage = ' + onHand.toFixed(2) + ' − ' + remaining.toFixed(2) + ' = ' + used.toFixed(2) + ' ' + (opt?.dataset.uom || '');
|
||||||
|
computedDiv.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
computedDiv.classList.add('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.lmUpdateQuantityLabel = function () {
|
||||||
|
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
|
||||||
|
document.getElementById('lmQtyLabel').innerHTML =
|
||||||
|
(method === 'remaining' ? 'Quantity Remaining' : 'Quantity Used') +
|
||||||
|
' <span class="text-danger">*</span>';
|
||||||
|
lmOnQtyInput();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.openLogMaterialModal = function () {
|
||||||
|
// Reset form
|
||||||
|
document.getElementById('lmInventoryItem').value = '';
|
||||||
|
document.getElementById('lmItemBalance').classList.add('d-none');
|
||||||
|
document.getElementById('lmQuantity').value = '';
|
||||||
|
document.getElementById('lmComputedUsed').classList.add('d-none');
|
||||||
|
document.getElementById('lmTransactionType').value = 'JobUsage';
|
||||||
|
document.getElementById('lmNotes').value = '';
|
||||||
|
document.getElementById('lmAlert').classList.add('d-none');
|
||||||
|
document.getElementById('lmSaveBtn').disabled = false;
|
||||||
|
document.getElementById('lmMethodUsed').checked = true;
|
||||||
|
window.lmUpdateQuantityLabel();
|
||||||
|
if (_modal) _modal.show();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.lmSave = async function () {
|
||||||
|
const cfg = window.__logMaterial;
|
||||||
|
const itemId = parseInt(document.getElementById('lmInventoryItem').value) || 0;
|
||||||
|
const qtyInput = parseFloat(document.getElementById('lmQuantity').value) || 0;
|
||||||
|
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
|
||||||
|
const alertEl = document.getElementById('lmAlert');
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
alertEl.className = 'alert alert-danger alert-permanent';
|
||||||
|
alertEl.textContent = msg;
|
||||||
|
alertEl.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemId) { showError('Please select an inventory item.'); return; }
|
||||||
|
if (qtyInput <= 0) { showError('Please enter a quantity greater than zero.'); return; }
|
||||||
|
|
||||||
|
let quantityUsed = qtyInput;
|
||||||
|
if (method === 'remaining') {
|
||||||
|
const sel = document.getElementById('lmInventoryItem');
|
||||||
|
const onHand = parseFloat(sel.options[sel.selectedIndex]?.dataset.qty) || 0;
|
||||||
|
quantityUsed = onHand - qtyInput;
|
||||||
|
if (quantityUsed <= 0) {
|
||||||
|
showError('Remaining quantity cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('lmSaveBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
alertEl.classList.add('d-none');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(cfg.logUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'RequestVerificationToken': cfg.token
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
jobId: cfg.jobId,
|
||||||
|
inventoryItemId: itemId,
|
||||||
|
quantityUsed: quantityUsed,
|
||||||
|
transactionType: document.getElementById('lmTransactionType').value,
|
||||||
|
notes: document.getElementById('lmNotes').value.trim() || null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
if (_modal) _modal.hide();
|
||||||
|
// Reload page so the materials table refreshes
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
showError(data.message || 'An error occurred.');
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showError('Network error. Please try again.');
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user