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
@@ -0,0 +1,61 @@
namespace PowderCoating.Web.ViewModels.Reports;
public class JobProfitabilityViewModel : ReportViewModelBase
{
public bool TimeTrackedOnly { get; set; }
// ── Summary KPIs ──────────────────────────────────────────────────────
public int TotalJobs { get; set; }
public int JobsWithTimeEntries { get; set; }
public decimal TotalRevenue { get; set; }
public decimal TotalCollected { get; set; }
public decimal TotalEstimatedCost { get; set; }
public decimal TotalActualHours { get; set; }
/// <summary>Average margin % across jobs that have at least some cost data.</summary>
public decimal AvgMarginPercent { get; set; }
// ── Detail rows ───────────────────────────────────────────────────────
public List<JobProfitabilityItem> Items { get; set; } = new();
}
public class JobProfitabilityItem
{
public int JobId { get; set; }
public string JobNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string StatusName { get; set; } = string.Empty;
public string StatusColorClass { get; set; } = "bg-secondary";
public DateTime JobDate { get; set; }
/// <summary>The invoiced / final price of the job.</summary>
public decimal FinalPrice { get; set; }
/// <summary>How much has actually been collected on the linked invoice.</summary>
public decimal AmountCollected { get; set; }
/// <summary>Total hours logged via time entries.</summary>
public decimal ActualHours { get; set; }
/// <summary>ActualHours × StandardLaborRate.</summary>
public decimal ActualLaborCost { get; set; }
/// <summary>
/// Sum of (ActualPowderUsedLbs ?? PowderToOrder) × PowderCostPerLb across all coats.
/// Zero when no coat has pricing data.
/// </summary>
public decimal ActualPowderCost { get; set; }
public decimal EstimatedTotalCost => ActualLaborCost + ActualPowderCost;
public decimal GrossMargin => FinalPrice - EstimatedTotalCost;
public decimal MarginPercent => FinalPrice > 0
? Math.Round(GrossMargin / FinalPrice * 100, 1)
: 0;
/// <summary>True when at least one JobTimeEntry exists for this job.</summary>
public bool HasTimeEntries { get; set; }
}