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:
@@ -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