Add Formula Library ratings, Job Profitability report, and Quote Revision History improvements

- Formula Library ratings: thumbs up/down per company per formula; toggle on/off; sorts by net score; own formulas not rateable; FormulaLibraryRating entity + migration AddFormulaLibraryRatings
- Job Profitability report: actual labor cost (logged hours x StandardLaborRate) vs powder cost vs billed price per job; gross margin % color-coded; time-tracked-only filter; totals footer
- Quote Revision History: track Total price changes on every save; log Sent/Resent events with recipient email; replace flat table with grouped timeline UI (icons per event type, total-change badge on header)
- Setup Wizard: cap CompletedCount at TotalSteps so old 10-step data no longer shows 10/5
- Formula Library card: fix badge overflow on long titles; add Rate: label to make voting buttons discoverable
- Help docs and AI knowledge base updated for all three features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 09:02:07 -04:00
parent 81119035c7
commit ed35362c7a
24 changed files with 12273 additions and 75 deletions
@@ -84,6 +84,31 @@ public class FormulaLibraryController : Controller
return File(bytes, contentType ?? "image/jpeg");
}
/// <summary>
/// Records or toggles a thumbs-up/down vote for the current company.
/// Returns updated counts so the UI can update without a page reload.
/// Companies cannot rate their own formulas; own-formula cards have no rating buttons.
/// </summary>
// POST: /FormulaLibrary/Rate
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Rate([FromBody] RateFormulaRequest request)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new { success = false, message = "No company context." });
try
{
var (up, down, myVote) = await _libraryService.RateAsync(
request.LibraryItemId, companyId.Value, request.IsPositive);
return Json(new { success = true, thumbsUp = up, thumbsDown = down, myVote });
}
catch (InvalidOperationException ex)
{
return Json(new { success = false, message = ex.Message });
}
}
/// <summary>Imports a library entry as a new local template for the current company.</summary>
// POST: /FormulaLibrary/Import
[HttpPost]
@@ -107,3 +132,11 @@ public class FormulaLibraryController : Controller
}
}
}
/// <summary>Body for the Rate endpoint.</summary>
public class RateFormulaRequest
{
public int LibraryItemId { get; set; }
/// <summary>True = thumbs up, false = thumbs down.</summary>
public bool IsPositive { get; set; }
}
@@ -1320,6 +1320,7 @@ public class QuotesController : Controller
Terms = quote.Terms,
Notes = quote.Notes,
TaxPercent = quote.TaxPercent,
Total = quote.Total,
DiscountType = quote.DiscountType,
DiscountValue = quote.DiscountValue,
DiscountReason = quote.DiscountReason,
@@ -1342,9 +1343,27 @@ public class QuotesController : Controller
// Set calculated pricing — snapshot at save time; never recalculate on load
_quotePricingAssemblyService.ApplyPricingSnapshot(quote, pricingResult);
// Track changes
// All change history records are accumulated here, then saved in bulk below
var changeHistories = new List<QuoteChangeHistory>();
// Log a total-change entry now that the new Total is known
if (Math.Round(oldValues.Total, 2) != Math.Round(quote.Total, 2))
{
changeHistories.Add(new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Total",
OldValue = oldValues.Total.ToString("C"),
NewValue = quote.Total.ToString("C"),
ChangeDescription = $"Total changed from {oldValues.Total:C} to {quote.Total:C}",
CompanyId = currentUser.CompanyId
});
}
// Track changes
_logger.LogInformation("=== CHANGE TRACKING DEBUG ===");
_logger.LogInformation("Old Status: {OldStatus}, New Status: {NewStatus}", oldValues.QuoteStatusId, quote.QuoteStatusId);
_logger.LogInformation("Old Date: {OldDate}, New Date: {NewDate}", oldValues.QuoteDate, quote.QuoteDate);
@@ -3174,6 +3193,22 @@ public class QuotesController : Controller
}
await _unitOfWork.Quotes.UpdateAsync(quote);
// Log send event so the history timeline shows when the quote was emailed
var sentHistoryEntry = new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser!.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Sent",
OldValue = null,
NewValue = recipientEmail,
ChangeDescription = $"Quote sent to {recipientName} ({recipientEmail})",
CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.QuoteChangeHistories.AddAsync(sentHistoryEntry);
await _unitOfWork.CompleteAsync();
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename, trimmedOverride);
@@ -1722,6 +1722,129 @@ public class ReportsController : Controller
return View(new JobCycleTimeViewModel { ReportTitle = "Job Cycle Time", ReportDescription = "Average time spent in each workflow stage", SelectedMonths = months, Items = items, OverallAvgCycleDays = overallAvg });
}
/// <summary>
/// Job Profitability report — compares each job's final price and collected amount against
/// actual labor cost (hours × StandardLaborRate) and estimated powder cost (PowderToOrder ×
/// PowderCostPerLb, or ActualPowderUsedLbs when recorded). Only jobs with at least one
/// JobTimeEntry contribute meaningful labor cost figures; the report flags rows without time
/// tracking so the user knows which margin estimates are incomplete.
/// </summary>
public async Task<IActionResult> JobProfitability(int months = 6, bool timeTrackedOnly = false)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var cutoff = DateTime.UtcNow.AddMonths(-months);
// ── Load base data ────────────────────────────────────────────────
var jobs = (await _unitOfWork.Jobs.FindAsync(
j => j.CompanyId == companyId && j.CreatedAt >= cutoff,
false,
j => j.Customer,
j => j.JobStatus))
.ToList();
var jobIds = jobs.Select(j => j.Id).ToList();
// Time entries grouped by job
var timeEntries = (await _unitOfWork.JobTimeEntries.FindAsync(
te => jobIds.Contains(te.JobId) && !te.IsDeleted))
.GroupBy(te => te.JobId)
.ToDictionary(g => g.Key, g => g.Sum(te => te.HoursWorked));
// Job items, then coats grouped by job
var jobItems = (await _unitOfWork.JobItems.FindAsync(
ji => jobIds.Contains(ji.JobId) && !ji.IsDeleted))
.ToList();
var jobItemIds = jobItems.Select(ji => ji.Id).ToList();
var coats = jobItemIds.Any()
? (await _unitOfWork.JobItemCoats.FindAsync(
c => jobItemIds.Contains(c.JobItemId) && !c.IsDeleted))
.ToList()
: new List<JobItemCoat>();
// Map coat cost totals back to JobId
var jobItemToJob = jobItems.ToDictionary(ji => ji.Id, ji => ji.JobId);
var powderCostByJob = coats
.Where(c => c.PowderCostPerLb.HasValue)
.GroupBy(c => jobItemToJob.TryGetValue(c.JobItemId, out var jid) ? jid : 0)
.Where(g => g.Key > 0)
.ToDictionary(
g => g.Key,
g => g.Sum(c =>
{
var lbs = c.ActualPowderUsedLbs ?? c.PowderToOrder ?? 0m;
return lbs * (c.PowderCostPerLb ?? 0m);
}));
// Invoices grouped by job
var invoices = (await _unitOfWork.Invoices.FindAsync(
inv => inv.CompanyId == companyId && inv.JobId.HasValue
&& jobIds.Contains(inv.JobId!.Value)
&& !inv.IsDeleted))
.GroupBy(inv => inv.JobId!.Value)
.ToDictionary(g => g.Key, g => g.Sum(inv => inv.AmountPaid));
// Labor rate
var opCosts = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(
c => c.CompanyId == companyId && !c.IsDeleted);
var laborRate = opCosts?.StandardLaborRate ?? 0m;
// ── Build report rows ─────────────────────────────────────────────
var items = jobs
.Select(j =>
{
var hours = timeEntries.TryGetValue(j.Id, out var h) ? h : 0m;
var powderCost = powderCostByJob.TryGetValue(j.Id, out var pc) ? pc : 0m;
var collected = invoices.TryGetValue(j.Id, out var paid) ? paid : 0m;
return new JobProfitabilityItem
{
JobId = j.Id,
JobNumber = j.JobNumber,
CustomerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName)
? j.Customer.CompanyName
: $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim()
is { Length: > 0 } n ? n : "Unknown",
StatusName = j.JobStatus?.DisplayName ?? "Unknown",
StatusColorClass = j.JobStatus?.ColorClass ?? "bg-secondary",
JobDate = j.CreatedAt,
FinalPrice = j.FinalPrice,
AmountCollected = collected,
ActualHours = hours,
ActualLaborCost = Math.Round(hours * laborRate, 2),
ActualPowderCost = Math.Round(powderCost, 2),
HasTimeEntries = timeEntries.ContainsKey(j.Id),
};
})
.Where(r => !timeTrackedOnly || r.HasTimeEntries)
.OrderByDescending(r => r.JobDate)
.ToList();
// ── Summary KPIs ──────────────────────────────────────────────────
var itemsWithCost = items.Where(r => r.EstimatedTotalCost > 0).ToList();
return View(new JobProfitabilityViewModel
{
ReportTitle = "Job Profitability",
ReportDescription = "Actual labor and powder cost vs. billed price per job",
SelectedMonths = months,
TimeTrackedOnly = timeTrackedOnly,
TotalJobs = items.Count,
JobsWithTimeEntries = items.Count(r => r.HasTimeEntries),
TotalRevenue = items.Sum(r => r.FinalPrice),
TotalCollected = items.Sum(r => r.AmountCollected),
TotalEstimatedCost = itemsWithCost.Sum(r => r.EstimatedTotalCost),
TotalActualHours = items.Sum(r => r.ActualHours),
AvgMarginPercent = itemsWithCost.Any()
? Math.Round(itemsWithCost.Average(r => r.MarginPercent), 1)
: 0m,
Items = items,
});
}
/// <summary>
/// Job Status Aging report — all active (non-terminal) jobs sorted by days in their current
/// status descending. Uses UpdatedAt as the proxy for "when did this job enter its current status"