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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user