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
@@ -75,14 +75,36 @@ public class FormulaLibraryService : IFormulaLibraryService
imp => imp.CompanyId == companyId && !imp.IsDeleted);
var importedIds = imports.Select(imp => imp.FormulaLibraryItemId).ToHashSet();
// Load all ratings in one query for this page of items
var allItemIds = itemList.Select(i => i.Id).ToHashSet();
var allRatings = await _unitOfWork.FormulaLibraryRatings.FindAsync(
r => allItemIds.Contains(r.FormulaLibraryItemId));
// Group counts and find the current company's vote per item
var ratingsByItem = allRatings
.GroupBy(r => r.FormulaLibraryItemId)
.ToDictionary(g => g.Key, g => g.ToList());
var dtos = _mapper.Map<List<FormulaLibraryCardDto>>(itemList);
for (int i = 0; i < dtos.Count; i++)
{
dtos[i].AlreadyImported = importedIds.Contains(dtos[i].Id);
dtos[i].IsOwnFormula = itemList[i].SourceCompanyId == companyId;
if (ratingsByItem.TryGetValue(dtos[i].Id, out var ratings))
{
dtos[i].ThumbsUp = ratings.Count(r => r.IsPositive);
dtos[i].ThumbsDown = ratings.Count(r => !r.IsPositive);
var myRating = ratings.FirstOrDefault(r => r.CompanyId == companyId);
dtos[i].MyVote = myRating?.IsPositive;
}
}
return dtos.OrderByDescending(d => d.ImportCount).ThenBy(d => d.Name);
// Sort: thumbs-up score descending, then import count, then name
return dtos
.OrderByDescending(d => d.ThumbsUp - d.ThumbsDown)
.ThenByDescending(d => d.ImportCount)
.ThenBy(d => d.Name);
}
/// <inheritdoc />
@@ -280,6 +302,58 @@ public class FormulaLibraryService : IFormulaLibraryService
}
}
/// <inheritdoc />
public async Task<(int ThumbsUp, int ThumbsDown, bool? MyVote)> RateAsync(
int libraryItemId, int companyId, bool isPositive)
{
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
if (item == null || !item.IsPublished)
throw new InvalidOperationException("Library entry not found.");
// Companies cannot rate their own formula
if (item.SourceCompanyId == companyId)
throw new InvalidOperationException("You cannot rate your own formula.");
var existing = await _unitOfWork.FormulaLibraryRatings.FirstOrDefaultAsync(
r => r.FormulaLibraryItemId == libraryItemId && r.CompanyId == companyId);
bool? myVote;
if (existing != null && existing.IsPositive == isPositive)
{
// Same vote again — toggle off
await _unitOfWork.FormulaLibraryRatings.DeleteAsync(existing);
myVote = null;
}
else if (existing != null)
{
// Opposite vote — flip it
existing.IsPositive = isPositive;
existing.RatedAt = DateTime.UtcNow;
await _unitOfWork.FormulaLibraryRatings.UpdateAsync(existing);
myVote = isPositive;
}
else
{
// New vote
await _unitOfWork.FormulaLibraryRatings.AddAsync(new FormulaLibraryRating
{
FormulaLibraryItemId = libraryItemId,
CompanyId = companyId,
IsPositive = isPositive,
RatedAt = DateTime.UtcNow,
});
myVote = isPositive;
}
await _unitOfWork.CompleteAsync();
// Return fresh counts
var allRatings = await _unitOfWork.FormulaLibraryRatings.FindAsync(
r => r.FormulaLibraryItemId == libraryItemId);
var list = allRatings.ToList();
return (list.Count(r => r.IsPositive), list.Count(r => !r.IsPositive), myVote);
}
// ── Helpers ───────────────────────────────────────────────────────────
/// <summary>