Compare commits
7 Commits
v2.15.0
...
v2025.05.16
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c390a2e05 | |||
| 0df2353d4f | |||
| be0a5b26e2 | |||
| 36680eced9 | |||
| 27aa4e0ea6 | |||
| b2d6fae400 | |||
| 3a1928f9bf |
@@ -389,7 +389,7 @@ public class CompleteJobDto
|
|||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
public int JobId { get; set; }
|
||||||
public decimal? ActualTimeSpentHours { get; set; }
|
public decimal? ActualTimeSpentHours { get; set; }
|
||||||
public List<JobItemCoatUsageDto> CoatUsages { get; set; } = new();
|
public List<JobPowderUsageDto> PowderUsages { get; set; } = new();
|
||||||
public bool SendEmailToCustomer { get; set; } = false;
|
public bool SendEmailToCustomer { get; set; } = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,10 +400,10 @@ public class SendJobSmsRequest
|
|||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DTO for tracking actual powder usage per coat
|
// DTO for tracking actual powder usage per inventory item (color) for the whole job
|
||||||
public class JobItemCoatUsageDto
|
public class JobPowderUsageDto
|
||||||
{
|
{
|
||||||
public int JobItemCoatId { get; set; }
|
public int InventoryItemId { get; set; }
|
||||||
public decimal? ActualPowderUsedLbs { get; set; }
|
public decimal? ActualPowderUsedLbs { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -340,13 +340,14 @@ public class InvoicesController : Controller
|
|||||||
var costs = await _unitOfWork.CompanyOperatingCosts
|
var costs = await _unitOfWork.CompanyOperatingCosts
|
||||||
.FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted);
|
.FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted);
|
||||||
|
|
||||||
|
var defaultTerms = prefs?.DefaultPaymentTerms ?? "Net 30";
|
||||||
var dto = new CreateInvoiceDto
|
var dto = new CreateInvoiceDto
|
||||||
{
|
{
|
||||||
PreparedById = currentUser.Id,
|
PreparedById = currentUser.Id,
|
||||||
InvoiceDate = DateTime.Today,
|
InvoiceDate = DateTime.Today,
|
||||||
DueDate = DateTime.Today.AddDays(prefs?.DefaultTurnaroundDays ?? 30),
|
DueDate = PaymentTermsParser.CalculateDueDate(defaultTerms, DateTime.Today),
|
||||||
TaxPercent = costs?.TaxPercent ?? 0,
|
TaxPercent = costs?.TaxPercent ?? 0,
|
||||||
Terms = prefs?.DefaultPaymentTerms ?? "Net 30"
|
Terms = defaultTerms
|
||||||
};
|
};
|
||||||
|
|
||||||
if (jobId.HasValue)
|
if (jobId.HasValue)
|
||||||
@@ -378,6 +379,13 @@ public class InvoicesController : Controller
|
|||||||
var defaultRevenueAccount = await _unitOfWork.Accounts
|
var defaultRevenueAccount = await _unitOfWork.Accounts
|
||||||
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
|
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
|
||||||
|
|
||||||
|
// Deserialize the job's pricing snapshot up front — it is authoritative for discount,
|
||||||
|
// tax, and fees for both quote-based and direct jobs, because it is recalculated on
|
||||||
|
// every save and reflects any edits made after quote conversion.
|
||||||
|
QuotePricingBreakdownDto? jobBreakdown = null;
|
||||||
|
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||||
|
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||||
|
|
||||||
// If the job came from a quote, load it so we can use the agreed pricing.
|
// If the job came from a quote, load it so we can use the agreed pricing.
|
||||||
// The quote stores the approved total including oven batch cost and shop supplies —
|
// The quote stores the approved total including oven batch cost and shop supplies —
|
||||||
// these are quote-level charges that are NOT stored on individual job items.
|
// these are quote-level charges that are NOT stored on individual job items.
|
||||||
@@ -461,17 +469,15 @@ public class InvoicesController : Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the quote's agreed tax rate and discount — not current company defaults
|
// Use the quote's agreed tax rate and discount — these represent the customer-approved
|
||||||
dto.TaxPercent = sourceQuote.TaxPercent;
|
// price and must not be recomputed from the job's current state.
|
||||||
|
dto.TaxPercent = sourceQuote.TaxPercent;
|
||||||
dto.DiscountAmount = sourceQuote.DiscountAmount;
|
dto.DiscountAmount = sourceQuote.DiscountAmount;
|
||||||
}
|
}
|
||||||
else if (hadJobItems)
|
else if (hadJobItems)
|
||||||
{
|
{
|
||||||
// Direct job — no source quote. Read all charges from the pricing snapshot so the
|
// Direct job — no source quote. Read all charges from the pricing snapshot so the
|
||||||
// invoice always matches the total shown on the job's Pricing Summary card.
|
// invoice always matches the total shown on the job's Pricing Summary card.
|
||||||
QuotePricingBreakdownDto? jobBreakdown = null;
|
|
||||||
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
|
||||||
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
|
||||||
|
|
||||||
if (job.OvenBatchCost > 0.01m)
|
if (job.OvenBatchCost > 0.01m)
|
||||||
{
|
{
|
||||||
@@ -529,6 +535,22 @@ public class InvoicesController : Controller
|
|||||||
RevenueAccountId = defaultRevenueAccount?.Id
|
RevenueAccountId = defaultRevenueAccount?.Id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dto.DiscountAmount = jobBreakdown?.DiscountAmount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inherit payment terms from the source quote or the customer — more specific than
|
||||||
|
// the company-wide default set in the outer DTO. Quote terms take priority because
|
||||||
|
// they represent the agreed price; customer terms are next best for direct jobs.
|
||||||
|
var inheritedTerms = sourceQuote?.Terms ?? job.Customer?.PaymentTerms;
|
||||||
|
if (!string.IsNullOrWhiteSpace(inheritedTerms))
|
||||||
|
{
|
||||||
|
dto.Terms = inheritedTerms;
|
||||||
|
dto.DueDate = PaymentTermsParser.CalculateDueDate(inheritedTerms, DateTime.Today)
|
||||||
|
?? dto.DueDate;
|
||||||
|
var (discPct, discDays) = PaymentTermsParser.ParseEarlyPaymentDiscount(inheritedTerms);
|
||||||
|
dto.EarlyPaymentDiscountPercent = discPct;
|
||||||
|
dto.EarlyPaymentDiscountDays = discDays;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override tax to 0 for tax-exempt customers, regardless of company default or quote rate
|
// Override tax to 0 for tax-exempt customers, regardless of company default or quote rate
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -2648,78 +2655,80 @@ public class JobsController : Controller
|
|||||||
.GroupBy(t => t.InventoryItemId)
|
.GroupBy(t => t.InventoryItemId)
|
||||||
.ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity)));
|
.ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity)));
|
||||||
|
|
||||||
// Update actual powder usage for each coat
|
// Process powder usage submitted per inventory item (color) for the whole job.
|
||||||
foreach (var coatUsage in dto.CoatUsages)
|
// Distribute entered lbs across coats sharing that InventoryItemId proportionally
|
||||||
|
// by estimated PowderToOrder so per-coat reporting stays meaningful.
|
||||||
|
// One inventory deduction per powder (net of pre-logged credit).
|
||||||
|
if (dto.PowderUsages.Any())
|
||||||
{
|
{
|
||||||
var jobItemCoat = await _unitOfWork.JobItemCoats.GetByIdAsync(
|
// Load all coats for the job with their inventory items
|
||||||
coatUsage.JobItemCoatId,
|
var allCoats = (await _unitOfWork.JobItemCoats.FindAsync(
|
||||||
false,
|
jic => jic.JobItem != null && jic.JobItem.JobId == dto.JobId,
|
||||||
jic => jic.InventoryItem);
|
false, jic => jic.InventoryItem, jic => jic.JobItem))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
if (jobItemCoat != null)
|
foreach (var powderUsage in dto.PowderUsages)
|
||||||
{
|
{
|
||||||
jobItemCoat.ActualPowderUsedLbs = coatUsage.ActualPowderUsedLbs;
|
if (!powderUsage.ActualPowderUsedLbs.HasValue || powderUsage.ActualPowderUsedLbs.Value <= 0)
|
||||||
await _unitOfWork.JobItemCoats.UpdateAsync(jobItemCoat);
|
continue;
|
||||||
|
|
||||||
_logger.LogInformation("Updated JobItemCoat {CoatId} with {Lbs} lbs actual powder used",
|
var invItemId = powderUsage.InventoryItemId;
|
||||||
coatUsage.JobItemCoatId, coatUsage.ActualPowderUsedLbs);
|
var totalActualLbs = powderUsage.ActualPowderUsedLbs.Value;
|
||||||
|
|
||||||
// Deduct powder from inventory if using stock powder
|
// Distribute across coats using this powder proportionally by estimated lbs
|
||||||
if (jobItemCoat.InventoryItemId.HasValue &&
|
var coatsForPowder = allCoats.Where(c => c.InventoryItemId == invItemId).ToList();
|
||||||
coatUsage.ActualPowderUsedLbs.HasValue &&
|
if (coatsForPowder.Any())
|
||||||
coatUsage.ActualPowderUsedLbs.Value > 0)
|
|
||||||
{
|
{
|
||||||
var invItemId = jobItemCoat.InventoryItemId.Value;
|
var totalEstimated = coatsForPowder.Sum(c => c.PowderToOrder ?? 0m);
|
||||||
var actualLbs = coatUsage.ActualPowderUsedLbs.Value;
|
foreach (var coat in coatsForPowder)
|
||||||
|
|
||||||
// Apply available pre-logged credit so we don't double-deduct
|
|
||||||
var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m);
|
|
||||||
var deductNow = Math.Max(0m, actualLbs - credit);
|
|
||||||
// Consume credit (other coats sharing the same powder get whatever remains)
|
|
||||||
preLoggedCredit[invItemId] = Math.Max(0m, credit - actualLbs);
|
|
||||||
|
|
||||||
if (deductNow > 0)
|
|
||||||
{
|
{
|
||||||
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
|
var share = totalEstimated > 0
|
||||||
if (inventoryItem != null)
|
? totalActualLbs * ((coat.PowderToOrder ?? 0m) / totalEstimated)
|
||||||
{
|
: totalActualLbs / coatsForPowder.Count;
|
||||||
var transaction = new InventoryTransaction
|
coat.ActualPowderUsedLbs = Math.Round(share, 4);
|
||||||
{
|
await _unitOfWork.JobItemCoats.UpdateAsync(coat);
|
||||||
InventoryItemId = inventoryItem.Id,
|
|
||||||
TransactionType = InventoryTransactionType.JobUsage,
|
|
||||||
Quantity = -deductNow,
|
|
||||||
UnitCost = inventoryItem.UnitCost,
|
|
||||||
TotalCost = inventoryItem.UnitCost * deductNow,
|
|
||||||
TransactionDate = DateTime.UtcNow,
|
|
||||||
JobId = job.Id,
|
|
||||||
Reference = job.JobNumber,
|
|
||||||
Notes = $"Powder used for Job {job.JobNumber} - {jobItemCoat.CoatName} ({jobItemCoat.ColorName ?? "N/A"}) by {currentUser!.FirstName} {currentUser.LastName}",
|
|
||||||
BalanceAfter = inventoryItem.QuantityOnHand - deductNow,
|
|
||||||
CompanyId = job.CompanyId
|
|
||||||
};
|
|
||||||
|
|
||||||
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
|
||||||
inventoryItem.QuantityOnHand -= deductNow;
|
|
||||||
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
|
|
||||||
|
|
||||||
// GL: DR COGS, CR Inventory Asset (accrual) — no-op if accounts not configured
|
|
||||||
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
|
|
||||||
{
|
|
||||||
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
|
|
||||||
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
|
|
||||||
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
|
|
||||||
deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
}
|
||||||
|
|
||||||
|
// Single inventory deduction for the whole powder, net of pre-logged credit
|
||||||
|
var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m);
|
||||||
|
var deductNow = Math.Max(0m, totalActualLbs - credit);
|
||||||
|
preLoggedCredit[invItemId] = 0m;
|
||||||
|
|
||||||
|
if (deductNow > 0)
|
||||||
|
{
|
||||||
|
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
|
||||||
|
if (inventoryItem != null)
|
||||||
{
|
{
|
||||||
|
inventoryItem.QuantityOnHand -= deductNow;
|
||||||
|
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
|
||||||
|
|
||||||
|
var transaction = new InventoryTransaction
|
||||||
|
{
|
||||||
|
InventoryItemId = inventoryItem.Id,
|
||||||
|
TransactionType = InventoryTransactionType.JobUsage,
|
||||||
|
Quantity = -deductNow,
|
||||||
|
UnitCost = inventoryItem.UnitCost,
|
||||||
|
TotalCost = inventoryItem.UnitCost * deductNow,
|
||||||
|
TransactionDate = DateTime.UtcNow,
|
||||||
|
JobId = job.Id,
|
||||||
|
Reference = job.JobNumber,
|
||||||
|
Notes = $"Powder used for Job {job.JobNumber} by {currentUser!.FirstName} {currentUser.LastName}",
|
||||||
|
BalanceAfter = inventoryItem.QuantityOnHand,
|
||||||
|
CompanyId = job.CompanyId
|
||||||
|
};
|
||||||
|
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
||||||
|
|
||||||
|
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
|
||||||
|
{
|
||||||
|
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
|
||||||
|
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
|
||||||
|
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Skipped inventory deduction for JobItemCoat {CoatId} — {Lbs} lbs already pre-logged for inventory item {InvItemId}",
|
"Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
|
||||||
coatUsage.JobItemCoatId, actualLbs, invItemId);
|
deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4080,9 +4089,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
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ public static class HelpKnowledgeBase
|
|||||||
|
|
||||||
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer — a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
|
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer — a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
|
||||||
|
|
||||||
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice."
|
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Review the Totals panel on the right — if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
@@ -314,6 +314,8 @@ public static class HelpKnowledgeBase
|
|||||||
|
|
||||||
All QR codes require login — workers must have an active account. Logging in once on their phone is sufficient for the session.
|
All QR codes require login — workers must have an active account. Logging in once on their phone is sufficient for the session.
|
||||||
|
|
||||||
|
**Logging material usage from a PC (without QR scan):** On the Job Details page, expand the Materials Used section and click **Log Material**. A modal opens where you can: select any inventory item from a dropdown (current stock level shown), choose whether to enter the amount used or the amount remaining (the system calculates usage automatically), pick a reason (Job Usage or Waste/Spillage), and add optional notes. Saves immediately and updates inventory on hand.
|
||||||
|
|
||||||
**Blank Work Order:** Print a pre-formatted paper work order to hand to a walk-in customer before creating a digital job record.
|
**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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -48,8 +48,9 @@
|
|||||||
<ol class="mb-3">
|
<ol class="mb-3">
|
||||||
<li class="mb-2">Open the job from <strong>Operations › Jobs</strong> and go to its Details page.</li>
|
<li class="mb-2">Open the job from <strong>Operations › Jobs</strong> and go to its Details page.</li>
|
||||||
<li class="mb-2">Scroll to the <strong>Invoice</strong> section near the bottom of the page.</li>
|
<li class="mb-2">Scroll to the <strong>Invoice</strong> section near the bottom of the page.</li>
|
||||||
<li class="mb-2">Click <strong>Create Invoice</strong>. The system generates an invoice pre-filled with all the job's line items and the final pricing.</li>
|
<li class="mb-2">Click <strong>Create Invoice</strong>. The system pre-fills all line items, the discount, tax rate, payment terms, and due date from the job and customer automatically.</li>
|
||||||
<li class="mb-2">Review the invoice — check line items, totals, and the due date — then click <strong>Save Invoice</strong>.</li>
|
<li class="mb-2">Review the <strong>Totals</strong> panel on the right — if a discount was applied to the job it shows as a red <em>Discount Applied</em> line below the subtotal. Negative line items are allowed if you need to apply a manual credit or price adjustment.</li>
|
||||||
|
<li class="mb-2">Adjust anything you need, then click <strong>Save Invoice</strong>.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<h3 class="h6 fw-semibold mt-3 mb-2">From the Invoices list (manual)</h3>
|
<h3 class="h6 fw-semibold mt-3 mb-2">From the Invoices list (manual)</h3>
|
||||||
@@ -139,7 +140,7 @@
|
|||||||
<li class="mb-2">Open the invoice from <strong>Operations › Invoices</strong> or from the job's Details page.</li>
|
<li class="mb-2">Open the invoice from <strong>Operations › Invoices</strong> or from the job's Details page.</li>
|
||||||
<li class="mb-2">Click <strong>Send Invoice</strong>. The status changes from Draft to Sent.</li>
|
<li class="mb-2">Click <strong>Send Invoice</strong>. The status changes from Draft to Sent.</li>
|
||||||
<li class="mb-2">If email notifications are configured, the customer receives an email with the invoice details and total due.</li>
|
<li class="mb-2">If email notifications are configured, the customer receives an email with the invoice details and total due.</li>
|
||||||
<li class="mb-2">A due date is set automatically based on the customer's payment terms (e.g., Net 30 means the due date is 30 days from today).</li>
|
<li class="mb-2">The due date and payment terms are pre-filled from the source quote (if the job came from a quote) or the customer’s payment terms — you can always override them before saving.</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p>
|
<p>
|
||||||
You can also click <strong>Download PDF</strong> on any invoice to generate a print-ready PDF
|
You can also click <strong>Download PDF</strong> on any invoice to generate a print-ready PDF
|
||||||
|
|||||||
@@ -607,13 +607,28 @@
|
|||||||
no anonymous bumps.
|
no anonymous bumps.
|
||||||
</p>
|
</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>
|
<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>
|
<p>
|
||||||
One QR per unique powder on the job. Scanning opens the inventory usage log page pre-filled
|
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
|
with that powder and the job number, so you can record actual lbs used in seconds without
|
||||||
navigating through the app.
|
navigating through the app.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-droplet-half me-1"></i>Logging Material Usage from a PC</h3>
|
||||||
|
<p>
|
||||||
|
You don’t need a phone or QR code to log material usage. On the Job Details page, expand the
|
||||||
|
<strong>Materials Used</strong> section and click <strong>Log Material</strong>. A modal opens where you can:
|
||||||
|
</p>
|
||||||
|
<ul class="mb-2">
|
||||||
|
<li>Select any inventory item from a searchable dropdown — the item’s current stock level is shown when you pick it.</li>
|
||||||
|
<li>Choose <strong>Amount Used</strong> (enter how much was consumed) or <strong>Amount Remaining</strong> (enter what’s left in the bag — the system calculates the usage automatically).</li>
|
||||||
|
<li>Pick a reason: <em>Job Usage</em> or <em>Waste / Spillage</em>.</li>
|
||||||
|
<li>Add optional notes.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Saving immediately reduces the item’s stock on hand and creates an entry in the Inventory Activity ledger, exactly like a QR scan would. The QR scan icon is still available next to the button for mobile workers.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
<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>
|
<i class="bi bi-lock flex-shrink-0 mt-1"></i>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -283,13 +283,13 @@
|
|||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<input type="number" name="InvoiceItems[@i].UnitPrice"
|
<input type="number" name="InvoiceItems[@i].UnitPrice"
|
||||||
class="form-control form-control-sm text-end unit-price-input"
|
class="form-control form-control-sm text-end unit-price-input"
|
||||||
value="@item.UnitPrice.ToString("F2")" min="0" step="0.01"
|
value="@item.UnitPrice.ToString("F2")" step="0.01"
|
||||||
onchange="recalcRow(this)" oninput="recalcRow(this)" />
|
onchange="recalcRow(this)" oninput="recalcRow(this)" />
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<input type="number" name="InvoiceItems[@i].TotalPrice"
|
<input type="number" name="InvoiceItems[@i].TotalPrice"
|
||||||
class="form-control form-control-sm text-end total-price-input"
|
class="form-control form-control-sm text-end total-price-input"
|
||||||
value="@item.TotalPrice.ToString("F2")" min="0" step="0.01"
|
value="@item.TotalPrice.ToString("F2")" step="0.01"
|
||||||
oninput="recalcTotals()" />
|
oninput="recalcTotals()" />
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
@@ -371,6 +371,10 @@
|
|||||||
<input asp-for="DiscountAmount" type="number" class="form-control form-control-sm text-end"
|
<input asp-for="DiscountAmount" type="number" class="form-control form-control-sm text-end"
|
||||||
min="0" step="0.01" oninput="recalcTotals()" />
|
min="0" step="0.01" oninput="recalcTotals()" />
|
||||||
</div>
|
</div>
|
||||||
|
<div id="discountRow" class="d-flex justify-content-between mb-1 d-none">
|
||||||
|
<span class="text-muted small">Discount Applied</span>
|
||||||
|
<span id="displayDiscount" class="small text-danger">−$0.00</span>
|
||||||
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<label class="form-label mb-0 text-muted">Tax (%)</label>
|
<label class="form-label mb-0 text-muted">Tax (%)</label>
|
||||||
@@ -725,13 +729,13 @@
|
|||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<input type="number" name="InvoiceItems[${idx}].UnitPrice"
|
<input type="number" name="InvoiceItems[${idx}].UnitPrice"
|
||||||
class="form-control form-control-sm text-end unit-price-input"
|
class="form-control form-control-sm text-end unit-price-input"
|
||||||
value="${unitPrice.toFixed(2)}" min="0" step="0.01"
|
value="${unitPrice.toFixed(2)}" step="0.01"
|
||||||
onchange="recalcRow(this)" oninput="recalcRow(this)" />
|
onchange="recalcRow(this)" oninput="recalcRow(this)" />
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<input type="number" name="InvoiceItems[${idx}].TotalPrice"
|
<input type="number" name="InvoiceItems[${idx}].TotalPrice"
|
||||||
class="form-control form-control-sm text-end total-price-input"
|
class="form-control form-control-sm text-end total-price-input"
|
||||||
value="${total}" min="0" step="0.01"
|
value="${total}" step="0.01"
|
||||||
oninput="recalcTotals()" />
|
oninput="recalcTotals()" />
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
@@ -797,6 +801,15 @@
|
|||||||
const total = taxableAmount + tax;
|
const total = taxableAmount + tax;
|
||||||
|
|
||||||
document.getElementById('displaySubtotal').textContent = formatCurrency(subtotal);
|
document.getElementById('displaySubtotal').textContent = formatCurrency(subtotal);
|
||||||
|
const discountRow = document.getElementById('discountRow');
|
||||||
|
if (discountRow) {
|
||||||
|
if (discount > 0) {
|
||||||
|
document.getElementById('displayDiscount').textContent = '−' + formatCurrency(discount);
|
||||||
|
discountRow.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
discountRow.classList.add('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
document.getElementById('displayTax').textContent = formatCurrency(tax);
|
document.getElementById('displayTax').textContent = formatCurrency(tax);
|
||||||
document.getElementById('displayTotal').textContent = formatCurrency(total);
|
document.getElementById('displayTotal').textContent = formatCurrency(total);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -2,8 +2,21 @@
|
|||||||
@{
|
@{
|
||||||
var emailDefault = ViewBag.EmailDefaultOnComplete == true;
|
var emailDefault = ViewBag.EmailDefaultOnComplete == true;
|
||||||
var preLoggedPowder = ViewBag.PreLoggedPowder as Dictionary<int, decimal> ?? new Dictionary<int, decimal>();
|
var preLoggedPowder = ViewBag.PreLoggedPowder as Dictionary<int, decimal> ?? new Dictionary<int, decimal>();
|
||||||
// Track remaining credit per InventoryItemId as we allocate it across coat rows
|
|
||||||
var remainingCredit = preLoggedPowder.ToDictionary(kv => kv.Key, kv => kv.Value);
|
// Group all coats by inventory item so we ask once per powder color, not once per item/coat
|
||||||
|
var powderGroups = (Model.Items ?? new List<PowderCoating.Application.DTOs.Job.JobItemDto>())
|
||||||
|
.SelectMany(i => i.Coats ?? new List<PowderCoating.Application.DTOs.Job.JobItemCoatDto>())
|
||||||
|
.Where(c => c.InventoryItemId.HasValue)
|
||||||
|
.GroupBy(c => c.InventoryItemId!.Value)
|
||||||
|
.Select(g => new {
|
||||||
|
InventoryItemId = g.Key,
|
||||||
|
ColorName = g.First().ColorName,
|
||||||
|
ColorCode = g.First().ColorCode,
|
||||||
|
TotalEstimatedLbs = g.Sum(c => c.PowderToOrder ?? 0m),
|
||||||
|
PreLogged = preLoggedPowder.GetValueOrDefault(g.Key, 0m)
|
||||||
|
})
|
||||||
|
.OrderBy(g => g.ColorName)
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
<div class="modal fade" id="completeJobModal" tabindex="-1">
|
<div class="modal fade" id="completeJobModal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||||
@@ -27,102 +40,59 @@
|
|||||||
<div class="form-text">Enter the total time in hours (e.g., 2.5 for 2 hours 30 minutes)</div>
|
<div class="form-text">Enter the total time in hours (e.g., 2.5 for 2 hours 30 minutes)</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (Model.Items != null && Model.Items.Any())
|
@if (powderGroups.Any())
|
||||||
{
|
{
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<h6 class="fw-semibold mb-3">
|
<h6 class="fw-semibold mb-1">
|
||||||
<i class="bi bi-palette me-1 text-primary"></i>Actual Powder Usage
|
<i class="bi bi-palette me-1 text-primary"></i>Actual Powder Usage
|
||||||
</h6>
|
</h6>
|
||||||
|
<p class="text-muted small mb-3">Enter total lbs used per powder color for the entire job.</p>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm table-hover">
|
<table class="table table-sm table-hover">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Item</th>
|
<th>Color / Powder</th>
|
||||||
<th>Coat</th>
|
|
||||||
<th>Color</th>
|
|
||||||
<th class="text-end">Estimated (lbs)</th>
|
<th class="text-end">Estimated (lbs)</th>
|
||||||
<th>Actual (lbs)</th>
|
<th style="width:150px">Actual Used (lbs)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@{
|
@for (int i = 0; i < powderGroups.Count; i++)
|
||||||
var coatIndex = 0;
|
|
||||||
}
|
|
||||||
@foreach (var item in Model.Items)
|
|
||||||
{
|
{
|
||||||
if (item.Coats != null && item.Coats.Any())
|
var pg = powderGroups[i];
|
||||||
{
|
<tr>
|
||||||
foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
<td>
|
||||||
{
|
<span class="fw-semibold">@pg.ColorName</span>
|
||||||
<tr>
|
@if (!string.IsNullOrEmpty(pg.ColorCode))
|
||||||
<td>
|
{
|
||||||
<small>@item.Description</small>
|
<small class="text-muted ms-1">(@pg.ColorCode)</small>
|
||||||
@if (item.Quantity > 1)
|
}
|
||||||
{
|
</td>
|
||||||
<span class="badge bg-secondary ms-1">×@item.Quantity</span>
|
<td class="text-end text-muted small align-middle">
|
||||||
}
|
@pg.TotalEstimatedLbs.ToString("0.##")
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge bg-secondary">@coat.CoatName</span></td>
|
<td>
|
||||||
<td>
|
<input type="hidden" name="PowderUsages[@i].InventoryItemId" value="@pg.InventoryItemId" />
|
||||||
@if (!string.IsNullOrEmpty(coat.ColorName))
|
<input type="number"
|
||||||
{
|
class="form-control form-control-sm"
|
||||||
<small>
|
name="PowderUsages[@i].ActualPowderUsedLbs"
|
||||||
@coat.ColorName
|
step="0.01" min="0" placeholder="0.00"
|
||||||
@if (!string.IsNullOrEmpty(coat.ColorCode))
|
value="@(pg.PreLogged > 0 ? pg.PreLogged.ToString("0.##") : "")">
|
||||||
{
|
@if (pg.PreLogged > 0)
|
||||||
<span class="text-muted">(@coat.ColorCode)</span>
|
{
|
||||||
}
|
<small class="text-success d-block mt-1">
|
||||||
</small>
|
<i class="bi bi-check-circle me-1"></i>@pg.PreLogged.ToString("0.##") lbs already logged
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<small class="text-muted">@((coat.PowderToOrder ?? 0).ToString("0.##"))</small>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
@{
|
|
||||||
decimal preFilledLbs = 0m;
|
|
||||||
if (coat.InventoryItemId.HasValue && remainingCredit.TryGetValue(coat.InventoryItemId.Value, out var availCredit) && availCredit > 0)
|
|
||||||
{
|
|
||||||
preFilledLbs = availCredit;
|
|
||||||
remainingCredit[coat.InventoryItemId.Value] = 0m;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
<input type="hidden" name="CoatUsages[@coatIndex].JobItemCoatId" value="@coat.Id" />
|
|
||||||
<input type="number"
|
|
||||||
class="form-control form-control-sm"
|
|
||||||
name="CoatUsages[@coatIndex].ActualPowderUsedLbs"
|
|
||||||
step="0.01" min="0" placeholder="0.00"
|
|
||||||
value="@(preFilledLbs > 0 ? preFilledLbs.ToString("0.##") : "")"
|
|
||||||
style="max-width: 120px;">
|
|
||||||
@if (preFilledLbs > 0)
|
|
||||||
{
|
|
||||||
<small class="text-success d-block mt-1">
|
|
||||||
<i class="bi bi-check-circle me-1"></i>Already logged — inventory adjusted
|
|
||||||
</small>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
coatIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<tr class="table-secondary">
|
|
||||||
<td colspan="5">
|
|
||||||
<small class="text-muted fst-italic">
|
|
||||||
<i class="bi bi-info-circle me-1"></i>
|
|
||||||
@item.Description — No coat information available (legacy job item)
|
|
||||||
</small>
|
</small>
|
||||||
</td>
|
}
|
||||||
</tr>
|
</td>
|
||||||
}
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="alert alert-info alert-permanent mb-0">
|
<div class="alert alert-info alert-permanent mb-0">
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
<small>Pre-filled values were already logged via scan — inventory is already adjusted for those. You can edit the amount; only the difference will be applied to inventory.</small>
|
<small>Pre-filled values were already logged via scan — inventory is already adjusted for those. You can edit the amount; only the difference will be applied.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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