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