Compare commits

..

7 Commits

Author SHA1 Message Date
spouliot 03b425a12f Update blast rate tests to match nozzle-primary formula
Remove the CFM=0→zero test (CFM no longer in the formula path).
Update expected values to match the new nozzle-primary tables and
corrected TierDefaults CFM/nozzle pairings. Add WetBlasting and
RustAndScale substrate coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:21:39 -04:00
spouliot 8453449833 Recalibrate blast rate formula from industry reference tables
Replace the inaccurate CFM-based formula with nozzle-primary tables
sourced from industry standard abrasive blast cleaning references:
- Pressure pot: midpoints averaged from two reference tables
  (#4 nozzle: 115 sqft/hr, #5: 175 sqft/hr, etc.)
- Siphon cabinet: dedicated siphon cabinet reference table
  (#4 nozzle: 125 sqft/hr, #3: 75 sqft/hr, etc.)
- SiphonPot: 80% of pressure pot rate (open gravity feed, no enclosure)
- WetBlasting: 60% of pressure pot rate (water-media reduces velocity)

CFM is removed from the rate formula entirely — nozzle size determines
throughput and CFM draw, so CFM is a consequence of nozzle choice, not
an independent variable. Override field still bypasses formula for shops
that have measured their own throughput.

Also corrects TierDefaults nozzle/CFM pairings which were mismatched
(e.g. Small tier had 40 CFM assigned to a #5 nozzle that needs 150 CFM).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:12:45 -04:00
spouliot ad986561c9 Fix AI quote blast rate: single formula path, correct client preview
Root cause: company-settings-lookups.js had its own baseByCfm/multiplier
tables that were completely different from ShopCapabilityCalculator.cs,
so the UI showed an inflated rate (e.g. 82 sqft/hr) while the AI prompt
received the server-computed rate (e.g. 9 sqft/hr).

- Add CompanySettingsController.DeriveBlastRate endpoint — thin GET that
  calls ShopCapabilityCalculator directly; now the single formula path
- Delete all client-side formula code (baseByCfm, multiplier tables,
  deriveBlastRate) — ~30 lines removed
- Modal live preview calls /CompanySettings/DeriveBlastRate with 250ms
  debounce instead of computing locally
- Blast setup table uses setup.derivedRate from GetBlastSetups (already
  server-computed) instead of recalculating client-side
- QuotesController.AiAnalyzeItem: when no blast setup is explicitly
  selected, fall back to the company's default blast setup so the
  configured rate is always used

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 15:57:46 -04:00
spouliot 0d5553f3b2 Fix dark mode hover colors in coat/powder dropdown menus
Replace all hardcoded light-mode colors (#f0f4ff, #e8eeff, #fff8e1, #fff)
with Bootstrap CSS variables (--bs-secondary-bg, --bs-primary-bg-subtle,
--bs-warning-bg-subtle, --bs-body-bg) so dropdown containers and hover
states render correctly in both light and dark mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 15:12:24 -04:00
spouliot 87bbf158a4 Fix material usage logging: remaining weight mode, edit modal, and consolidate duplicate logic
- InventoryController: extract RecordInventoryUsageAsync helper; both LogUsage
  (scan page) and LogMaterial (jobs modal, moved from JobsController) call it —
  no more duplicate save/GL logic across two controllers
- Log Material modal: replace radio buttons with prominent toggle buttons so the
  active mode (Amount Used vs Amount Remaining) is always visually obvious; add
  always-visible preview line showing exactly what will be logged before saving
- Edit Usage modal: add quantity field (pre-populated from existing transaction)
  with delta adjustment to InventoryItem.QuantityOnHand on save; include
  completed/terminal jobs in the dropdown so entries can be corrected after a
  job is marked done
- Scan page job picker: include jobs completed within the last 7 days (marked
  with '(completed)') so usage can be logged after a job is finished

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:31:02 -04:00
spouliot f453a95f28 Add hover tooltips on job list rows showing description and PO number
Adds CustomerPO to JobListDto (maps by convention), then builds a
Bootstrap tooltip per row with description · PO: xxx, skipping blank
fields. Rows with neither get no tooltip. Helps identify jobs at a
glance without opening the details page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 18:53:52 -04:00
spouliot d9e98a55d2 Fix customer email inputs to allow comma-separated addresses
type="email" triggers jQuery Validate's email rule which rejects commas,
blocking multi-address input despite the multiple attribute. Switching to
type="text" defers validation to the server-side SplitEmails/MailAddress
logic in the DTO which already handles comma-separated lists correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:16:30 -04:00
16 changed files with 495 additions and 336 deletions
@@ -113,6 +113,7 @@ public class JobListDto
public string? CustomerEmail { get; set; }
public bool CustomerNotifyByEmail { get; set; } = true;
public string? CustomerPO { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateTime? DueDate { get; set; }
public decimal FinalPrice { get; set; }
@@ -5,145 +5,165 @@ namespace PowderCoating.Application.Services;
/// <summary>
/// Derives sqft/hr throughput rates from a shop's equipment configuration.
/// Used in two places: the AI photo quote prompt (so Claude reasons from real shop
/// speeds) and the calculated-item wizard (to show a suggested blast time hint).
/// Used by the AI photo quote prompt (so Claude reasons from real shop speeds)
/// and the Company Settings live preview (so the UI always shows the same rate
/// the AI will use — single formula path, no client-side duplication).
///
/// Formula:
/// BlastRate = BaseByCfm(cfm) × NozzleMultiplier × SetupMultiplier × SubstrateMultiplier
/// Both pressure pots and siphon cabinets are nozzle-primary: nozzle size
/// determines throughput and CFM draw. CFM is not used in the rate formula.
///
/// Base rates by CFM represent a pressure pot at #5 nozzle removing paint.
/// All multipliers are relative to that baseline.
/// Sources:
/// Pressure pot rates — averaged from two industry standard abrasive blast
/// cleaning reference tables.
/// Siphon cabinet rates — industry reference table for siphon-fed cabinets.
/// Substrate multipliers — relative removal difficulty vs. paint baseline.
/// </summary>
public static class ShopCapabilityCalculator
{
// ── Blast rate derivation ─────────────────────────────────────────────────
// ── Public entry points ────────────────────────────────────────────────────
/// <summary>
/// Returns the effective blast rate in sqft/hr.
/// If <see cref="CompanyOperatingCosts.BlastRateSqFtPerHourOverride"/> is set, returns it directly.
/// Otherwise derives from CFM, nozzle, setup type, and substrate.
/// Returns 0 when CFM is not configured (shop hasn't calibrated yet).
/// Returns the effective blast rate in sqft/hr for company-level operating costs.
/// BlastRateSqFtPerHourOverride bypasses the formula when set.
/// </summary>
public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs)
{
if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0)
return costs.BlastRateSqFtPerHourOverride.Value;
if (costs.CompressorCfm <= 0)
return 0m;
var baseRate = BaseByCfm(costs.CompressorCfm);
var nozzle = NozzleMultiplier(costs.BlastNozzleSize);
var setup = SetupMultiplier(costs.BlastSetupType);
var substrate = SubstrateMultiplier(costs.PrimaryBlastSubstrate);
return Math.Round(baseRate * nozzle * setup * substrate, 1);
return CalculateBlastRate(costs.BlastNozzleSize, costs.BlastSetupType, costs.PrimaryBlastSubstrate);
}
/// <summary>
/// Returns the effective blast rate in sqft/hr for a named <see cref="CompanyBlastSetup"/>.
/// Identical logic to the <see cref="CompanyOperatingCosts"/> overload — uses override if set,
/// otherwise derives from the setup's equipment specs.
/// Returns the effective blast rate in sqft/hr for a named blast setup.
/// BlastRateSqFtPerHourOverride bypasses the formula when set.
/// </summary>
public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup)
{
if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0)
return setup.BlastRateSqFtPerHourOverride.Value;
if (setup.CompressorCfm <= 0)
return 0m;
var baseRate = BaseByCfm(setup.CompressorCfm);
var nozzle = NozzleMultiplier(setup.BlastNozzleSize);
var setupMult = SetupMultiplier(setup.SetupType);
var substrate = SubstrateMultiplier(setup.PrimarySubstrate);
return Math.Round(baseRate * nozzle * setupMult * substrate, 1);
return CalculateBlastRate(setup.BlastNozzleSize, setup.SetupType, setup.PrimarySubstrate);
}
/// <summary>
/// Returns the effective coating application rate in sqft/hr.
/// If override is set, returns it directly.
/// Otherwise derives a sensible default from gun type.
/// Override bypasses the formula when set.
/// </summary>
public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs)
{
if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0)
return costs.CoatingRateSqFtPerHourOverride.Value;
// Corona and tribo guns are roughly similar on flat parts; tribo edges out on complex geometry.
// Without more equipment data (voltage, gun model) we use a single reasonable default.
return costs.CoatingGunType switch
{
CoatingGunType.Corona => 40m,
CoatingGunType.Tribo => 35m, // slower on flat but better on complex; conservative default
CoatingGunType.Tribo => 35m,
CoatingGunType.Both => 40m,
_ => 40m
};
}
/// <summary>
/// Returns default equipment field values for a given capability tier.
/// Applied during Setup Wizard tier selection so the shop gets reasonable
/// starting values even if they never visit the Quoting Calibration tab.
/// Returns default equipment field values for a given capability tier, applied
/// during Setup Wizard tier selection so new shops get reasonable starting values.
/// CFM defaults reflect typical compressor sizes for each tier; they appear in the
/// UI for reference but are not used in the rate formula.
/// </summary>
public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate)
TierDefaults(ShopCapabilityTier tier) => tier switch
{
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed),
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 40m, 5, BlastSubstrateType.Mixed),
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 80m, 5, BlastSubstrateType.Mixed),
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 6, BlastSubstrateType.Mixed),
_ => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed)
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed),
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 49m, 3, BlastSubstrateType.Mixed),
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 90m, 4, BlastSubstrateType.Mixed),
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 5, BlastSubstrateType.Mixed),
_ => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed)
};
// ── Private helpers ───────────────────────────────────────────────────────
// ── Core formula (single path for all callers) ─────────────────────────────
/// <summary>
/// Base sqft/hr at a pressure pot, #5 nozzle, removing paint.
/// Calibrated so that real-world examples produce expected results:
/// - 7 CFM siphon cabinet → ~2 sqft/hr (garage coater, 3+ hrs/wheel)
/// - 40 CFM pressure pot → ~15 sqft/hr (small shop, ~30 min/wheel)
/// - 80 CFM pressure pot → ~25 sqft/hr (medium shop)
/// - 150 CFM pressure pot → ~40 sqft/hr (large shop, ~10 min/wheel)
/// Nozzle-primary blast rate calculation. Nozzle size determines throughput;
/// setup type routes to the appropriate reference table; substrate adjusts for
/// removal difficulty. CFM is not used — it is a consequence of nozzle choice,
/// not an independent variable in throughput.
/// </summary>
private static decimal BaseByCfm(decimal cfm) => cfm switch
private static decimal CalculateBlastRate(int nozzle, BlastSetupType setupType, BlastSubstrateType substrate)
{
< 10 => 5m,
< 20 => 9m,
< 40 => 15m,
< 80 => 25m,
< 120 => 35m,
_ => 45m
var baseRate = setupType switch
{
BlastSetupType.PressurePot => PressurePotRateByNozzle(nozzle),
BlastSetupType.SiphonCabinet => SiphonCabinetRateByNozzle(nozzle),
// Siphon pot: open gravity feed, no enclosure penalty, ~80% of pressure pot
BlastSetupType.SiphonPot => Math.Round(PressurePotRateByNozzle(nozzle) * 0.80m, 1),
// Wet blasting: water-media mix reduces impact velocity, ~60% of dry pressure pot
BlastSetupType.WetBlasting => Math.Round(PressurePotRateByNozzle(nozzle) * 0.60m, 1),
_ => 0m
};
return Math.Round(baseRate * SubstrateMultiplier(substrate), 1);
}
/// <summary>
/// Midpoint cleaning rates for a pressure pot at adequate air supply, by nozzle size.
/// Averaged from two industry-standard abrasive blast cleaning reference tables.
/// #1 (1/16"): 20-35 sqft/hr avg → 20
/// #2 (1/8"): 40-60 sqft/hr avg → 40
/// #3 (3/16"): 60-85 sqft/hr avg → 75
/// #4 (1/4"): 90-110 sqft/hr avg → 115
/// #5 (5/16"): 130-160 sqft/hr avg → 175
/// #6 (3/8"): 180-230 sqft/hr avg → 245
/// #7 (7/16"): 240-300 sqft/hr avg → 325
/// #8 (1/2"): 320-400 sqft/hr avg → 430
/// </summary>
private static decimal PressurePotRateByNozzle(int nozzle) => nozzle switch
{
1 => 20m,
2 => 40m,
3 => 75m,
4 => 115m,
5 => 175m,
6 => 245m,
7 => 325m,
8 => 430m,
_ => 100m
};
private static decimal NozzleMultiplier(int nozzle) => nozzle switch
/// <summary>
/// Midpoint cleaning rates for siphon-fed blast cabinets, by nozzle size.
/// Source: industry reference table for siphon cabinet production rates.
/// #1 (1/16"): 10-25 sqft/hr → 18
/// #2 (1/8"): 25-50 sqft/hr → 38
/// #3 (3/16"): 50-100 sqft/hr → 75
/// #4 (1/4"): 100-150 sqft/hr → 125
/// #5 (5/16"): 150-225 sqft/hr → 188
/// #6 (3/8"): 225-300 sqft/hr → 263
/// #7 (7/16"): 300-375 sqft/hr → 338
/// #8 (1/2"): 375-450 sqft/hr → 413
/// </summary>
private static decimal SiphonCabinetRateByNozzle(int nozzle) => nozzle switch
{
2 => 0.35m,
3 => 0.55m,
4 => 0.75m,
5 => 1.00m,
6 => 1.30m,
7 => 1.65m,
8 => 2.00m,
_ => 1.00m
};
private static decimal SetupMultiplier(BlastSetupType setup) => setup switch
{
BlastSetupType.SiphonCabinet => 0.50m, // enclosed, low pressure, repositioning time
BlastSetupType.SiphonPot => 0.70m,
BlastSetupType.PressurePot => 1.00m, // baseline
BlastSetupType.WetBlasting => 0.60m,
_ => 1.00m
1 => 18m,
2 => 38m,
3 => 75m,
4 => 125m,
5 => 188m,
6 => 263m,
7 => 338m,
8 => 413m,
_ => 80m
};
/// <summary>
/// Adjustment for substrate removal difficulty relative to paint (baseline = 1.0).
/// Powder coat strips faster than paint; rust and scale requires multiple passes.
/// </summary>
private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch
{
BlastSubstrateType.PowderCoat => 1.25m, // faster to remove than paint
BlastSubstrateType.Paint => 1.00m, // baseline
BlastSubstrateType.PowderCoat => 1.25m,
BlastSubstrateType.Paint => 1.00m,
BlastSubstrateType.Mixed => 0.90m,
BlastSubstrateType.RustAndScale => 0.70m, // requires more passes
BlastSubstrateType.RustAndScale => 0.70m,
_ => 0.90m
};
}
@@ -1726,6 +1726,26 @@ public class CompanySettingsController : Controller
#region Blast Setups
/// <summary>
/// Single authoritative blast-rate calculation endpoint. Takes equipment parameters and
/// returns the sqft/hr rate using the same ShopCapabilityCalculator formula the AI uses.
/// The modal live preview calls this instead of duplicating the formula in JavaScript.
/// </summary>
[HttpGet]
public IActionResult DeriveBlastRate(decimal cfm, int nozzle, int setupType, int substrate, decimal? rateOverride)
{
var setup = new CompanyBlastSetup
{
CompressorCfm = cfm,
BlastNozzleSize = nozzle,
SetupType = (BlastSetupType)setupType,
PrimarySubstrate = (BlastSubstrateType)substrate,
BlastRateSqFtPerHourOverride = rateOverride > 0 ? rateOverride : null
};
var rate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup);
return Json(new { rate });
}
/// <summary>Returns all active blast setups for the current company with their derived rates.</summary>
[HttpGet]
public async Task<IActionResult> GetBlastSetups()
@@ -1642,8 +1642,10 @@ public class InventoryController : Controller
var userId = _userManager.GetUserId(User);
var recentCutoff = DateTime.UtcNow.AddDays(-7);
var myJobs = (await _unitOfWork.Jobs.FindAsync(
j => !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId,
j => (!j.JobStatus.IsTerminalStatus || j.UpdatedAt >= recentCutoff) && j.AssignedUserId == userId,
false,
j => j.Customer,
j => j.JobStatus))
@@ -1651,7 +1653,7 @@ public class InventoryController : Controller
.Select(j => new ScanJobOption
{
Id = j.Id,
JobNumber = j.JobNumber,
JobNumber = j.JobNumber + (j.JobStatus.IsTerminalStatus ? " (completed)" : ""),
CustomerName = j.Customer != null
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
: "No Customer"
@@ -1660,7 +1662,7 @@ public class InventoryController : Controller
var myJobIds = myJobs.Select(j => j.Id).ToHashSet();
var otherJobs = (await _unitOfWork.Jobs.FindAsync(
j => !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id),
j => (!j.JobStatus.IsTerminalStatus || j.UpdatedAt >= recentCutoff) && !myJobIds.Contains(j.Id),
false,
j => j.Customer,
j => j.JobStatus))
@@ -1669,7 +1671,7 @@ public class InventoryController : Controller
.Select(j => new ScanJobOption
{
Id = j.Id,
JobNumber = j.JobNumber,
JobNumber = j.JobNumber + (j.JobStatus.IsTerminalStatus ? " (completed)" : ""),
CustomerName = j.Customer != null
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
: "No Customer"
@@ -1686,9 +1688,64 @@ public class InventoryController : Controller
}
/// <summary>
/// Records powder usage logged via the mobile scan page. Creates a JobUsage
/// InventoryTransaction (and PowderUsageLog) when a job is selected, or an
/// Adjustment transaction when logging without a job. Updates QuantityOnHand.
/// Core inventory usage recording logic shared by LogUsage (scan page) and LogMaterial (modal).
/// Deducts quantityUsed from QuantityOnHand, writes an InventoryTransaction, and posts GL entries.
/// </summary>
private async Task<InventoryUsageResult> RecordInventoryUsageAsync(
int inventoryItemId, int? jobId, decimal quantityUsed,
InventoryTransactionType transactionType, string? notes)
{
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
if (item == null)
return new InventoryUsageResult(false, "Inventory item not found.", 0, "", "");
string? reference = null;
if (jobId.HasValue)
{
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value);
reference = job != null ? $"Job {job.JobNumber}" : null;
}
item.QuantityOnHand -= quantityUsed;
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
var txn = new InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = transactionType,
Quantity = -quantityUsed,
UnitCost = item.UnitCost,
TotalCost = quantityUsed * item.UnitCost,
TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand,
JobId = jobId,
Reference = reference,
Notes = notes?.Trim(),
CompanyId = item.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.InventoryTransactions.AddAsync(txn);
await _unitOfWork.CompleteAsync();
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
{
var cost = quantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
}
return new InventoryUsageResult(
true,
$"Logged {quantityUsed:N2} {item.UnitOfMeasure} of {item.Name}. New balance: {item.QuantityOnHand:N2} {item.UnitOfMeasure}.",
item.QuantityOnHand,
item.UnitOfMeasure,
item.Name);
}
/// <summary>
/// Records powder usage from the mobile scan page. Resolves the used quantity
/// (caller already converts "remaining weight" to delta before posting) and redirects to ScanSuccess.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
@@ -1697,55 +1754,26 @@ public class InventoryController : Controller
{
try
{
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
if (item == null) return NotFound();
if (quantity <= 0)
{
TempData["ScanError"] = "Quantity must be greater than zero.";
return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
}
var userId = _userManager.GetUserId(User) ?? string.Empty;
// Scan-based logging always records as JobUsage; Adjustment is for manual stock corrections only
var txnType = InventoryTransactionType.JobUsage;
var result = await RecordInventoryUsageAsync(
inventoryItemId, jobId, quantity,
InventoryTransactionType.JobUsage, notes);
item.QuantityOnHand -= quantity;
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
var txn = new InventoryTransaction
if (!result.Success)
{
InventoryItemId = item.Id,
TransactionType = txnType,
Quantity = -quantity,
UnitCost = item.UnitCost,
TotalCost = quantity * item.UnitCost,
TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand,
JobId = jobId,
Reference = jobId.HasValue ? $"Job #{jobId}" : null,
Notes = notes?.Trim()
};
await _unitOfWork.InventoryTransactions.AddAsync(txn);
await _unitOfWork.SaveChangesAsync();
// GL: DR COGS, CR Inventory Asset — no-op if accounts not configured on the item
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
{
var cost = quantity * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
TempData["ScanError"] = result.Message;
return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
}
// PowderUsageLog requires a specific JobItem + Coat FK — scan-based logging
// doesn't have that context, so we rely on the InventoryTransaction alone
// for the audit trail. Coat-level PowderUsageLogs are created by the job workflow.
TempData["ScanSuccess"] = $"Logged {quantity:N2} {item.UnitOfMeasure} of {item.Name}. New balance: {item.QuantityOnHand:N2} {item.UnitOfMeasure}.";
TempData["ScanSuccess"] = result.Message;
TempData["ScanItemId"] = inventoryItemId.ToString();
TempData["ScanJobId"] = jobId?.ToString();
TempData["ScanItemName"] = item.Name;
TempData["ScanItemName"] = result.ItemName;
return RedirectToAction(nameof(ScanSuccess));
}
catch (Exception ex)
@@ -1756,6 +1784,43 @@ public class InventoryController : Controller
}
}
/// <summary>
/// Records manual material usage from the job details modal. Accepts JSON, resolves
/// the amount used (caller sends the already-computed used quantity), and returns JSON
/// so the modal can close and refresh inline.
/// </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 txnType = req.TransactionType == "Waste"
? InventoryTransactionType.Waste
: InventoryTransactionType.JobUsage;
var result = await RecordInventoryUsageAsync(
req.InventoryItemId, req.JobId, req.QuantityUsed, txnType, req.Notes);
return Json(new
{
success = result.Success,
message = result.Message,
newBalance = result.NewBalance,
unitOfMeasure = result.UnitOfMeasure,
itemName = result.ItemName
});
}
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." });
}
}
/// <summary>
/// Success screen shown after a usage log is saved. Offers "Log Another Item for
/// This Job" and "Done" options.
@@ -2003,7 +2068,7 @@ public class InventoryController : Controller
/// <summary>
/// Returns the current values of a JobUsage InventoryTransaction plus a list of active
/// jobs so the edit modal can be pre-populated without a full page reload.
/// jobs (plus the currently assigned job even if terminal) for the edit modal.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetUsageForEdit(int id)
@@ -2034,10 +2099,27 @@ public class InventoryController : Controller
})
.ToList();
// If the assigned job has terminal status it won't appear in the active list; insert it at the top
// so the dropdown pre-selects correctly and the user can see the existing job assignment.
if (txn.JobId.HasValue && jobs.All(j => j.Id != txn.JobId.Value))
{
var assignedJob = await _unitOfWork.Jobs.GetByIdAsync(txn.JobId.Value, false, j => j.Customer);
if (assignedJob != null)
jobs.Insert(0, new ScanJobOption
{
Id = assignedJob.Id,
JobNumber = assignedJob.JobNumber,
CustomerName = assignedJob.Customer != null
? (assignedJob.Customer.CompanyName ?? $"{assignedJob.Customer.ContactFirstName} {assignedJob.Customer.ContactLastName}".Trim())
: "No Customer"
});
}
return Json(new
{
transactionId = txn.Id,
jobId = txn.JobId,
quantity = Math.Abs(txn.Quantity),
notes = txn.Notes,
transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"),
itemName = txn.InventoryItem?.Name,
@@ -2046,14 +2128,15 @@ public class InventoryController : Controller
}
/// <summary>
/// Saves edits to a JobUsage InventoryTransaction's job assignment, notes, and date.
/// Quantity and balance are not changed.
/// Saves edits to a JobUsage InventoryTransaction: job assignment, quantity, notes, and date.
/// When quantity changes the InventoryItem.QuantityOnHand is adjusted by the delta so the
/// ledger balance remains consistent.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate)
public async Task<IActionResult> EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate, decimal? quantity)
{
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id);
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id, false, t => t.InventoryItem);
if (txn == null) return NotFound();
if (txn.TransactionType != InventoryTransactionType.JobUsage
&& txn.TransactionType != InventoryTransactionType.Adjustment)
@@ -2075,6 +2158,28 @@ public class InventoryController : Controller
if (jobId.HasValue && txn.TransactionType == InventoryTransactionType.Adjustment)
txn.TransactionType = InventoryTransactionType.JobUsage;
// Adjust inventory when the logged quantity is changed.
// txn.Quantity is stored as a negative number for usage (e.g. -3.5 for 3.5 lbs used).
if (quantity.HasValue && quantity.Value > 0)
{
var oldUsed = Math.Abs(txn.Quantity);
var newUsed = quantity.Value;
if (oldUsed != newUsed)
{
var item = txn.InventoryItem ?? await _unitOfWork.InventoryItems.GetByIdAsync(txn.InventoryItemId);
if (item != null)
{
// Positive delta means less was actually used → restore the difference to inventory.
item.QuantityOnHand += oldUsed - newUsed;
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
txn.BalanceAfter = item.QuantityOnHand;
}
txn.Quantity = -newUsed;
txn.TotalCost = newUsed * txn.UnitCost;
}
}
txn.Notes = notes?.Trim();
txn.TransactionDate = transactionDate.Kind == DateTimeKind.Utc
? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc);
@@ -2094,3 +2199,21 @@ public class ScanJobOption
public string JobNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
}
/// <summary>Result returned by RecordInventoryUsageAsync.</summary>
public record InventoryUsageResult(
bool Success,
string Message,
decimal NewBalance,
string UnitOfMeasure,
string ItemName);
/// <summary>JSON body for the LogMaterial endpoint (job details modal).</summary>
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; }
}
@@ -4399,75 +4399,7 @@ public class JobsController : Controller
_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." });
}
}
// LogMaterial has been consolidated into InventoryController.LogMaterial.
/// <summary>
/// Inline-edits description, quantity, and unit price on a single job line item.
@@ -4554,14 +4486,6 @@ public class PatchJobItemRequest
public decimal Quantity { get; set; }
public decimal UnitPrice { 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; }
@@ -3435,13 +3435,21 @@ public class QuotesController : Controller
// Build company AI context: profile text + recent accepted predictions as few-shot examples
var aiContext = await BuildCompanyAiContextAsync(companyId, costs);
// Load the specific blast setup when the user picked one before analyzing
// Load the specific blast setup when the user picked one before analyzing.
// If none was explicitly chosen, fall back to the company's default blast setup so
// named-setup rates (e.g. a blast cabinet configured at 82 sqft/hr) are always
// used instead of the coarser company-level operating cost fallback.
CompanyBlastSetup? selectedBlastSetup = null;
if (request.BlastSetupId.HasValue)
{
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId);
selectedBlastSetup = setups.FirstOrDefault();
}
else
{
var defaultSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsDefault && b.IsActive && b.CompanyId == companyId);
selectedBlastSetup = defaultSetups.FirstOrDefault();
}
var result = await _aiService.AnalyzeItemAsync(request, photos, costs, avgPowderCost, aiContext, selectedBlastSetup);
await _usageLogger.LogAsync(companyId, user?.Id ?? "", AppConstants.AiFeatures.PhotoQuote, result.Success, photos.Sum(p => p.Data.Length));
@@ -74,7 +74,7 @@
</div>
<div class="col-md-6">
<label asp-for="Email" class="form-label">Email <span class="text-danger">*</span> <span class="text-muted fw-normal">(required if no phone number)</span></label>
<input asp-for="Email" type="email" multiple class="form-control" placeholder="name@example.com (comma-separate multiple)" />
<input asp-for="Email" type="text" class="form-control" placeholder="name@example.com (comma-separate multiple)" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="col-md-3">
@@ -91,7 +91,7 @@
<label asp-for="BillingEmail" class="form-label">Billing / Accounting Email
<span class="text-muted fw-normal">(invoices sent here)</span>
</label>
<input asp-for="BillingEmail" type="email" multiple class="form-control" placeholder="accounting@company.com (comma-separate multiple)" />
<input asp-for="BillingEmail" type="text" class="form-control" placeholder="accounting@company.com (comma-separate multiple)" />
<span asp-validation-for="BillingEmail" class="text-danger"></span>
<div class="form-text">When set, invoices are emailed here instead of the contact email.</div>
</div>
@@ -78,7 +78,7 @@
</div>
<div class="col-md-6">
<label asp-for="Email" class="form-label">Email</label>
<input asp-for="Email" type="email" multiple class="form-control" placeholder="name@example.com (comma-separate multiple)" />
<input asp-for="Email" type="text" class="form-control" placeholder="name@example.com (comma-separate multiple)" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="col-md-3">
@@ -95,7 +95,7 @@
<label asp-for="BillingEmail" class="form-label">Billing / Accounting Email
<span class="text-muted fw-normal">(invoices sent here)</span>
</label>
<input asp-for="BillingEmail" type="email" multiple class="form-control" placeholder="accounting@company.com (comma-separate multiple)" />
<input asp-for="BillingEmail" type="text" class="form-control" placeholder="accounting@company.com (comma-separate multiple)" />
<span asp-validation-for="BillingEmail" class="text-danger"></span>
<div class="form-text">When set, invoices are emailed here instead of the contact email.</div>
</div>
@@ -353,6 +353,11 @@
<label class="form-label fw-semibold">Powder Item</label>
<p id="euItemName" class="form-control-plaintext text-muted"></p>
</div>
<div class="mb-3">
<label for="euQuantity" class="form-label fw-semibold">Amount Used <small class="text-muted fw-normal" id="euQuantityUom"></small></label>
<input type="number" id="euQuantity" name="quantity" class="form-control" min="0.001" step="any" required />
<div class="form-text">Adjusts the inventory balance by the difference from the original entry.</div>
</div>
<div class="mb-3">
<label for="euJobId" class="form-label fw-semibold">Job <span class="text-muted fw-normal">(optional)</span></label>
<select id="euJobId" name="jobId" class="form-select">
+14 -11
View File
@@ -1158,21 +1158,24 @@
</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 class="btn-group w-100" role="group">
<button type="button" id="lmBtnUsed" class="btn btn-primary"
onclick="lmSetMethod('used')">
<i class="bi bi-droplet me-1"></i>Amount Used
</button>
<button type="button" id="lmBtnRemaining" class="btn btn-outline-primary"
onclick="lmSetMethod('remaining')">
<i class="bi bi-droplet-half me-1"></i>Amount Remaining
</button>
</div>
<div class="form-text">
<span id="lmMethodHint">Enter how much powder you took out of the bag.</span>
</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 id="lmComputedUsed" class="form-text fw-semibold d-none"></div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Reason</label>
@@ -3311,7 +3314,7 @@
const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]");
const jobPowderIds = @Html.Raw(ViewBag.JobPowderIds ?? "[]");
const jobId = @Model.Id;
const logUrl = '@Url.Action("LogMaterial", "Jobs")';
const logUrl = '@Url.Action("LogMaterial", "Inventory")';
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
window.__logMaterial = { inventoryItems, jobPowderIds, jobId, logUrl, token };
})();
+12 -1
View File
@@ -191,7 +191,14 @@
var isHot = job.DueDate.HasValue && job.DueDate.Value < DateTime.Now
&& job.StatusCode != "COMPLETED" && job.StatusCode != "READYFORPICKUP"
&& job.StatusCode != "DELIVERED" && job.StatusCode != "CANCELLED";
<tr class="job-row" data-job-id="@job.Id" style="cursor: pointer;">
var tipParts = new List<string>();
if (!string.IsNullOrWhiteSpace(job.Description)) tipParts.Add(job.Description);
if (!string.IsNullOrWhiteSpace(job.CustomerPO)) tipParts.Add("PO: " + job.CustomerPO);
var tipText = string.Join(" · ", tipParts);
<tr class="job-row" data-job-id="@job.Id" style="cursor: pointer;"
@if (!string.IsNullOrEmpty(tipText)) {
<text>data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="@Html.Encode(tipText)"</text>
}>
<td class="ps-4 @(isHot ? "job-hot-cell" : "")">
<div>
<div class="mono fw-500">
@@ -629,6 +636,10 @@
loadJobStatuses();
loadJobPriorities();
// Row tooltips (description + PO)
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el =>
new bootstrap.Tooltip(el, { trigger: 'hover' }));
// / key focuses search input
document.addEventListener('keydown', function(e) {
if (e.key === '/' && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') {
@@ -1094,30 +1094,31 @@
3: 'Powder Coat'
};
// Nozzle multipliers matching ShopCapabilityCalculator
const blastNozzleMultipliers = [0, 0, 0.35, 0.55, 0.75, 1.00, 1.30, 1.65, 2.00];
const blastSetupModalTypeMultipliers = { 0: 0.55, 1: 0.70, 2: 1.00, 3: 0.45 };
const blastSubstrateMultipliers = { 0: 1.00, 1: 0.80, 2: 1.40, 3: 0.90 };
// No client-side blast-rate formula here — ShopCapabilityCalculator.cs is the single
// source of truth. The table uses derivedRate from the server response; the modal
// live-preview calls /CompanySettings/DeriveBlastRate instead.
function baseByCfm(cfm) {
if (cfm <= 0) return 0;
if (cfm <= 5) return 15;
if (cfm <= 10) return 30;
if (cfm <= 15) return 50;
if (cfm <= 25) return 80;
if (cfm <= 40) return 130;
if (cfm <= 60) return 200;
return 300;
}
let _deriveRateTimer = null;
function deriveBlastRate(cfm, nozzle, setupType, substrate, override) {
if (override && parseFloat(override) > 0) return parseFloat(override);
const base = baseByCfm(parseFloat(cfm) || 0);
if (base === 0) return 0;
const nm = blastNozzleMultipliers[parseInt(nozzle)] || 1.00;
const sm = blastSetupModalTypeMultipliers[parseInt(setupType)] || 1.00;
const bm = blastSubstrateMultipliers[parseInt(substrate)] || 1.00;
return Math.round(base * nm * sm * bm * 10) / 10;
function updateBlastSetupDerivedRate() {
clearTimeout(_deriveRateTimer);
_deriveRateTimer = setTimeout(function () {
const cfm = document.getElementById('blastSetupCfm').value;
const nozzle = document.getElementById('blastSetupNozzleSize').value;
const setupType = document.getElementById('blastSetupModalType').value;
const substrate = document.getElementById('blastSetupSubstrate').value;
const override = document.getElementById('blastSetupOverride').value;
const el = document.getElementById('blastSetupDerivedRate');
if (!el) return;
const params = new URLSearchParams({ cfm, nozzle, setupType, substrate });
if (override && parseFloat(override) > 0) params.set('rateOverride', override);
fetch('/CompanySettings/DeriveBlastRate?' + params)
.then(r => r.json())
.then(data => { el.textContent = data.rate > 0 ? data.rate + ' sqft/hr' : '—'; })
.catch(() => { el.textContent = '—'; });
}, 250);
}
window.loadBlastSetups = function () {
@@ -1150,7 +1151,7 @@
window.blastSetups.forEach(function (setup) {
const rate = setup.blastRateSqFtPerHourOverride > 0
? setup.blastRateSqFtPerHourOverride + ' sqft/hr <span class="badge bg-secondary">Override</span>'
: deriveBlastRate(setup.compressorCfm, setup.blastNozzleSize, setup.setupType, setup.primarySubstrate, 0) + ' sqft/hr';
: (setup.derivedRate > 0 ? setup.derivedRate + ' sqft/hr' : '<span class="text-muted">—</span>');
const defaultBadge = setup.isDefault
? ' <span class="badge bg-primary ms-1">Default</span>'
@@ -1179,20 +1180,6 @@
});
}
function updateBlastSetupDerivedRate() {
const cfm = document.getElementById('blastSetupCfm').value;
const nozzle = document.getElementById('blastSetupNozzleSize').value;
const setupType = document.getElementById('blastSetupModalType').value;
const substrate = document.getElementById('blastSetupSubstrate').value;
const override = document.getElementById('blastSetupOverride').value;
const rate = deriveBlastRate(cfm, nozzle, setupType, substrate, override);
const el = document.getElementById('blastSetupDerivedRate');
if (el) {
el.textContent = rate > 0 ? rate + ' sqft/hr' : '—';
}
}
window.showBlastSetupModal = function (setupId = null) {
const modal = new bootstrap.Modal(document.getElementById('blastSetupModal'));
const form = document.getElementById('blastSetupForm');
@@ -18,6 +18,7 @@ async function openUsageEdit(transactionId) {
document.getElementById('euTxnId').value = data.transactionId;
document.getElementById('euItemName').textContent = data.itemName || '—';
document.getElementById('euQuantity').value = data.quantity != null ? parseFloat(data.quantity).toFixed(4) : '';
document.getElementById('euDate').value = data.transactionDate;
document.getElementById('euNotes').value = data.notes || '';
@@ -54,6 +55,7 @@ document.getElementById('euSaveBtn').addEventListener('click', async () => {
const token = form.querySelector('input[name="__RequestVerificationToken"]')?.value;
const params = new URLSearchParams({
id: document.getElementById('euTxnId').value,
quantity: document.getElementById('euQuantity').value,
jobId: document.getElementById('euJobId').value,
notes: document.getElementById('euNotes').value,
transactionDate: document.getElementById('euDate').value,
+19 -13
View File
@@ -691,7 +691,7 @@ function renderSalesFields() {
</button>
</div>
<div id="wzMerchDropdown"
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;background:#fff;border:1px solid #dee2e6;border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);position:fixed;">
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;background:var(--bs-body-bg);color:var(--bs-body-color);border:1px solid var(--bs-border-color);border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);position:fixed;">
</div>
</div>
<div class="text-danger d-none mt-1" id="err_salesCatalogItemId">Please select an item.</div>
@@ -773,7 +773,7 @@ function wzMerchComboRender(query) {
`<div class="wz-merch-opt" style="padding:.35rem .75rem .35rem 1.25rem;font-size:.875rem;cursor:pointer;"
data-id="${m.id}" data-name="${escHtml(m.name)}" data-price="${m.price}" data-sku="${escHtml(m.sku || '')}"
onmousedown="event.preventDefault();wzMerchComboSelect(this)"
onmouseenter="this.style.background='#f0f4ff'"
onmouseenter="this.style.background='var(--bs-secondary-bg)'"
onmouseleave="this.style.background=''">
${escHtml(m.name)}${m.sku ? ` <span class="text-muted">[${escHtml(m.sku)}]</span>` : ''} <span class="text-muted"> $${parseFloat(m.price).toFixed(2)}</span>
</div>`
@@ -1510,8 +1510,11 @@ async function aiAnalyze() {
document.getElementById('ai_resultsSection')?.classList.add('d-none');
document.getElementById('ai_errorAlert')?.classList.add('d-none');
const blastSetupIdEl = document.getElementById('ai_blastSetupId');
const blastSetupId = blastSetupIdEl ? (parseInt(blastSetupIdEl.value) || null) : null;
const blastSetupIdEl = document.getElementById('ai_blastSetupId');
const _defaultSetup = blastSetupData.find(s => s.isDefault) || blastSetupData[0];
const blastSetupId = blastSetupIdEl
? (parseInt(blastSetupIdEl.value) || null)
: (_defaultSetup ? _defaultSetup.id : null);
const payload = {
photoTempIds: wz.ai.tempIds,
@@ -1590,8 +1593,11 @@ async function aiSendFollowup() {
const weightLbs = isNaN(weightLbsRaw) || weightLbsRaw <= 0 ? null : weightLbsRaw;
wz.data.quantity = qty; // persist before renderStep re-renders
const blastSetupIdEl2 = document.getElementById('ai_blastSetupId');
const blastSetupId2 = blastSetupIdEl2 ? (parseInt(blastSetupIdEl2.value) || null) : null;
const blastSetupIdEl2 = document.getElementById('ai_blastSetupId');
const _defaultSetup2 = blastSetupData.find(s => s.isDefault) || blastSetupData[0];
const blastSetupId2 = blastSetupIdEl2
? (parseInt(blastSetupIdEl2.value) || null)
: (_defaultSetup2 ? _defaultSetup2.id : null);
const payload = {
photoTempIds: wz.ai.tempIds,
@@ -1909,7 +1915,7 @@ function buildCoatRowHtml(i, coat) {
<input type="hidden" id="coat_inventoryItemId_${i}">
<div id="coat_powder_dropdown_${i}"
class="powder-combo-dropdown"
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;background:var(--bs-body-bg);color:var(--bs-body-color);border:1px solid var(--bs-border-color);border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
</div>
</div>
</div>
@@ -1968,7 +1974,7 @@ function buildCoatRowHtml(i, coat) {
</div>
<div id="coat_catalog_results_${i}"
class="powder-combo-dropdown"
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;background:var(--bs-body-bg);color:var(--bs-body-color);border:1px solid var(--bs-border-color);border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
</div>
</div>
<div class="col-sm-6">
@@ -2166,7 +2172,7 @@ function powderComboRender(i, query) {
data-val="${escHtml(String(p.value))}"
data-txt="${escHtml(p.text)}"
onmousedown="event.preventDefault(); powderComboSelect(${i}, this.dataset.val, this.dataset.txt)"
onmouseenter="this.style.background=document.documentElement.getAttribute('data-bs-theme')==='dark'?'#2c3a5a':'#f0f4ff'"
onmouseenter="this.style.background='var(--bs-secondary-bg)'"
onmouseleave="this.classList.contains('pw-active')?null:this.style.background=''">
${escHtml(displayText)}${badge}
</div>`;
@@ -2214,12 +2220,12 @@ function powderComboKey(event, i) {
event.preventDefault();
idx = Math.min(idx + 1, items.length - 1);
items.forEach(it => { it.classList.remove('pw-active'); it.style.background = ''; });
if (items[idx]) { items[idx].classList.add('pw-active'); items[idx].style.background = '#e8eeff'; items[idx].scrollIntoView({ block: 'nearest' }); }
if (items[idx]) { items[idx].classList.add('pw-active'); items[idx].style.background = 'var(--bs-primary-bg-subtle)'; items[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'ArrowUp') {
event.preventDefault();
idx = Math.max(idx - 1, 0);
items.forEach(it => { it.classList.remove('pw-active'); it.style.background = ''; });
if (items[idx]) { items[idx].classList.add('pw-active'); items[idx].style.background = '#e8eeff'; items[idx].scrollIntoView({ block: 'nearest' }); }
if (items[idx]) { items[idx].classList.add('pw-active'); items[idx].style.background = 'var(--bs-primary-bg-subtle)'; items[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'Enter') {
event.preventDefault();
const active = dd.querySelector('.pw-active') || items[0];
@@ -2272,7 +2278,7 @@ function customPowderCatalogSearch(i, query) {
const price = r.unitPrice ? `<span class="text-muted small ms-1">$${parseFloat(r.unitPrice).toFixed(2)}/lb</span>` : '';
return `<div class="powder-opt" style="padding:.4rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;"
onmousedown="event.preventDefault(); applyCustomCatalogResult(${i}, ${JSON.stringify(r).replace(/"/g, '&quot;')})"
onmouseenter="this.style.background='#f0f4ff'"
onmouseenter="this.style.background='var(--bs-secondary-bg)'"
onmouseleave="this.style.background=''">
<strong>${escHtml(r.colorName)}</strong> ${escHtml(r.vendorName)}
<span class="text-muted small ms-1">${escHtml(r.sku || '')}</span>
@@ -2363,7 +2369,7 @@ function powderCatalogSearch(i, query) {
: '';
return `<div class="powder-opt" style="padding:.35rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;"
onmousedown="event.preventDefault(); createIncomingFromCatalog(${i}, ${r.id})"
onmouseenter="this.style.background='#fff8e1'"
onmouseenter="this.style.background='var(--bs-warning-bg-subtle)'"
onmouseleave="this.style.background=''">
<i class="bi bi-truck text-warning me-1"></i>
<strong>${escHtml(r.colorName)}</strong> ${escHtml(r.vendorName)} ${escHtml(r.sku || '')}
@@ -6,9 +6,64 @@
let _items = [];
let _jobPowderIds = new Set();
let _modal = null;
let _selectedItemId = 0;
let _entryMethod = 'used'; // 'used' | 'remaining'
// ── Mode toggle ───────────────────────────────────────────────────────────
window.lmSetMethod = function (method) {
_entryMethod = method;
const btnUsed = document.getElementById('lmBtnUsed');
const btnRemaining = document.getElementById('lmBtnRemaining');
const hintEl = document.getElementById('lmMethodHint');
const qtyLabel = document.getElementById('lmQtyLabel');
if (method === 'remaining') {
btnUsed.className = 'btn btn-outline-primary';
btnRemaining.className = 'btn btn-primary';
hintEl.textContent = 'Enter how much is LEFT in the bag — the system calculates what was used.';
qtyLabel.innerHTML = 'Weight Remaining in Bag <span class="text-danger">*</span>';
} else {
btnUsed.className = 'btn btn-primary';
btnRemaining.className = 'btn btn-outline-primary';
hintEl.textContent = 'Enter how much powder you took out of the bag.';
qtyLabel.innerHTML = 'Quantity Used <span class="text-danger">*</span>';
}
lmUpdatePreview();
};
// ── Live preview (always visible once qty + item are set) ─────────────────
function lmUpdatePreview() {
const computedDiv = document.getElementById('lmComputedUsed');
if (!_selectedItemId || !computedDiv) { computedDiv?.classList.add('d-none'); return; }
const item = _items.find(it => it.id === _selectedItemId);
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
const qty = parseFloat(document.getElementById('lmQuantity').value) || 0;
if (qty <= 0) { computedDiv.classList.add('d-none'); return; }
const uom = item?.unitOfMeasure || '';
if (_entryMethod === 'remaining') {
const used = onHand - qty;
if (used <= 0) {
computedDiv.className = 'form-text fw-semibold text-danger';
computedDiv.textContent = 'Remaining cannot be ≥ current stock (' + onHand.toFixed(2) + ' ' + uom + ').';
} else {
computedDiv.className = 'form-text fw-semibold text-success';
computedDiv.textContent =
'Will log ' + used.toFixed(2) + ' ' + uom + ' used — new balance: ' + qty.toFixed(2) + ' ' + uom;
}
} else {
const newBal = onHand - qty;
const col = newBal < 0 ? 'text-danger' : 'text-success';
computedDiv.className = 'form-text fw-semibold ' + col;
computedDiv.textContent =
'Will log ' + qty.toFixed(2) + ' ' + uom + ' used — new balance: ' + newBal.toFixed(2) + ' ' + uom;
}
computedDiv.classList.remove('d-none');
}
// ── Combobox state ────────────────────────────────────────────────────────
let _selectedItemId = 0;
function lmComboInput() {
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
@@ -16,7 +71,7 @@
lmComboShow();
_selectedItemId = 0;
document.getElementById('lmItemBalance').classList.add('d-none');
lmOnQtyInput();
lmUpdatePreview();
}
function lmComboOpen() {
@@ -111,7 +166,7 @@
const balDiv = document.getElementById('lmItemBalance');
balDiv.textContent = 'Current stock: ' + qty.toFixed(2) + (uom ? ' ' + uom : '');
balDiv.classList.remove('d-none');
lmOnQtyInput();
lmUpdatePreview();
};
window.lmComboInput = lmComboInput;
@@ -152,39 +207,14 @@
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Quantity / label logic ───────────────────────────────────────────────
function lmOnQtyInput() {
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
if (method !== 'remaining') {
document.getElementById('lmComputedUsed').classList.add('d-none');
return;
}
if (!_selectedItemId) {
document.getElementById('lmComputedUsed').classList.add('d-none');
return;
}
const item = _items.find(it => it.id === _selectedItemId);
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
const remaining = parseFloat(document.getElementById('lmQuantity').value) || 0;
const used = onHand - remaining;
const computedDiv = document.getElementById('lmComputedUsed');
computedDiv.textContent = 'Usage = ' + onHand.toFixed(2) + ' ' + remaining.toFixed(2) + ' = ' + used.toFixed(2) + (item?.unitOfMeasure ? ' ' + item.unitOfMeasure : '');
computedDiv.classList.remove('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();
};
// ── Kept for backward-compat with any inline onchange handlers that may exist
window.lmUpdateQuantityLabel = function () { lmUpdatePreview(); };
// ── Modal open / save ─────────────────────────────────────────────────────
window.openLogMaterialModal = function () {
_selectedItemId = 0;
_entryMethod = 'used';
document.getElementById('lmItemSearch').value = '';
document.getElementById('lmItemBalance').classList.add('d-none');
document.getElementById('lmQuantity').value = '';
@@ -193,8 +223,7 @@
document.getElementById('lmNotes').value = '';
document.getElementById('lmAlert').classList.add('d-none');
document.getElementById('lmSaveBtn').disabled = false;
document.getElementById('lmMethodUsed').checked = true;
window.lmUpdateQuantityLabel();
lmSetMethod('used');
lmComboClose();
if (_modal) _modal.show();
};
@@ -214,14 +243,14 @@
const qtyInput = parseFloat(document.getElementById('lmQuantity').value) || 0;
if (qtyInput <= 0) { showError('Please enter a quantity greater than zero.'); return; }
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
const item = _items.find(it => it.id === _selectedItemId);
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
let quantityUsed = qtyInput;
if (method === 'remaining') {
const item = _items.find(it => it.id === _selectedItemId);
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
if (_entryMethod === 'remaining') {
quantityUsed = onHand - qtyInput;
if (quantityUsed <= 0) {
showError('Remaining quantity cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').');
showError('Remaining cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').');
return;
}
}
@@ -269,9 +298,8 @@
_jobPowderIds = new Set(cfg.jobPowderIds || []);
_modal = new bootstrap.Modal(document.getElementById('logMaterialModal'));
document.getElementById('lmQuantity').addEventListener('input', lmOnQtyInput);
document.getElementById('lmQuantity').addEventListener('input', lmUpdatePreview);
// Close dropdown when clicking outside
document.addEventListener('click', function (e) {
if (!e.target.closest('#lmItemSearch') &&
!e.target.closest('#lmItemDropdown') &&
@@ -24,24 +24,12 @@ public class ShopCapabilityCalculatorTests
}
[Fact]
public void GetBlastRateSqFtPerHour_WithNoCompressorCfm_ReturnsZero()
public void GetBlastRateSqFtPerHour_PressurePot_Nozzle6_Paint()
{
// PressurePotRateByNozzle(6) = 245 * SubstrateMultiplier(Paint) 1.0 = 245
var costs = new CompanyOperatingCosts
{
CompressorCfm = 0m
};
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
Assert.Equal(0m, result);
}
[Fact]
public void GetBlastRateSqFtPerHour_DerivesRateFromEquipmentInputs()
{
var costs = new CompanyOperatingCosts
{
CompressorCfm = 150m,
CompressorCfm = 200m,
BlastNozzleSize = 6,
BlastSetupType = BlastSetupType.PressurePot,
PrimaryBlastSubstrate = BlastSubstrateType.Paint
@@ -49,16 +37,17 @@ public class ShopCapabilityCalculatorTests
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
Assert.Equal(58.5m, result);
Assert.Equal(245m, result);
}
[Fact]
public void GetBlastRateSqFtPerHour_ForNamedSetup_UsesSetupOverload()
public void GetBlastRateSqFtPerHour_SiphonCabinet_Nozzle4_Mixed()
{
// SiphonCabinetRateByNozzle(4) = 125 * SubstrateMultiplier(Mixed) 0.9 = 112.5
var setup = new CompanyBlastSetup
{
Name = "Main Cabinet",
CompressorCfm = 7m,
CompressorCfm = 42m,
BlastNozzleSize = 4,
SetupType = BlastSetupType.SiphonCabinet,
PrimarySubstrate = BlastSubstrateType.Mixed
@@ -66,7 +55,39 @@ public class ShopCapabilityCalculatorTests
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup);
Assert.Equal(1.7m, result);
Assert.Equal(112.5m, result);
}
[Fact]
public void GetBlastRateSqFtPerHour_PressurePot_Nozzle4_RustAndScale()
{
// PressurePotRateByNozzle(4) = 115 * SubstrateMultiplier(RustAndScale) 0.7 = 80.5
var costs = new CompanyOperatingCosts
{
BlastNozzleSize = 4,
BlastSetupType = BlastSetupType.PressurePot,
PrimaryBlastSubstrate = BlastSubstrateType.RustAndScale
};
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
Assert.Equal(80.5m, result);
}
[Fact]
public void GetBlastRateSqFtPerHour_WetBlasting_Is60PctOfPressurePot()
{
// WetBlasting = PressurePotRateByNozzle(5) * 0.6 * substrate(Paint 1.0) = 175 * 0.6 = 105
var costs = new CompanyOperatingCosts
{
BlastNozzleSize = 5,
BlastSetupType = BlastSetupType.WetBlasting,
PrimaryBlastSubstrate = BlastSubstrateType.Paint
};
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
Assert.Equal(105m, result);
}
[Theory]
@@ -86,10 +107,10 @@ public class ShopCapabilityCalculatorTests
}
[Theory]
[InlineData(ShopCapabilityTier.Garage, BlastSetupType.SiphonCabinet, 7, 4, BlastSubstrateType.Mixed)]
[InlineData(ShopCapabilityTier.Small, BlastSetupType.PressurePot, 40, 5, BlastSubstrateType.Mixed)]
[InlineData(ShopCapabilityTier.Medium, BlastSetupType.PressurePot, 80, 5, BlastSubstrateType.Mixed)]
[InlineData(ShopCapabilityTier.Large, BlastSetupType.PressurePot, 150, 6, BlastSubstrateType.Mixed)]
[InlineData(ShopCapabilityTier.Garage, BlastSetupType.SiphonCabinet, 7, 3, BlastSubstrateType.Mixed)]
[InlineData(ShopCapabilityTier.Small, BlastSetupType.PressurePot, 49, 3, BlastSubstrateType.Mixed)]
[InlineData(ShopCapabilityTier.Medium, BlastSetupType.PressurePot, 90, 4, BlastSubstrateType.Mixed)]
[InlineData(ShopCapabilityTier.Large, BlastSetupType.PressurePot, 150, 5, BlastSubstrateType.Mixed)]
public void TierDefaults_ReturnExpectedPresetValues(
ShopCapabilityTier tier,
BlastSetupType expectedSetup,