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
@@ -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);