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,270 @@
@model PowderCoating.Web.ViewModels.Reports.JobProfitabilityViewModel
@{
ViewData["Title"] = "Job Profitability";
var hasItems = Model.Items.Any();
}
<div class="container-fluid px-4">
<div class="d-flex align-items-start align-items-sm-center justify-content-between flex-column flex-sm-row gap-3 mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-graph-up-arrow me-2 text-success"></i>Job Profitability
</h1>
<p class="text-muted mb-0">@Model.ReportDescription</p>
</div>
<a asp-action="Landing" class="btn btn-outline-secondary flex-shrink-0">
<i class="bi bi-arrow-left me-1"></i>All Reports
</a>
</div>
@* ── Filter bar ──────────────────────────────────────────────────────── *@
<div class="card mb-4 border-0 shadow-sm">
<div class="card-body py-3">
<form method="get" class="row g-2 align-items-end">
<div class="col-sm-auto">
<label class="form-label small fw-semibold mb-1">Period</label>
<select name="months" class="form-select form-select-sm" onchange="this.form.submit()">
@foreach (var opt in new[] { (1,"Last month"), (3,"Last 3 months"), (6,"Last 6 months"), (12,"Last 12 months"), (24,"Last 24 months") })
{
<option value="@opt.Item1" selected="@(Model.SelectedMonths == opt.Item1)">@opt.Item2</option>
}
</select>
</div>
<div class="col-sm-auto">
<div class="form-check mt-4 ms-1">
<input class="form-check-input" type="checkbox" id="timeTrackedOnly"
name="timeTrackedOnly" value="true"
@(Model.TimeTrackedOnly ? "checked" : "")
onchange="this.form.submit()" />
<label class="form-check-label small" for="timeTrackedOnly">
Time-tracked jobs only
</label>
</div>
</div>
</form>
</div>
</div>
@* ── Summary KPI cards ───────────────────────────────────────────────── *@
<div class="row g-3 mb-4">
<div class="col-6 col-lg-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body">
<div class="text-muted small mb-1">Total Jobs</div>
<div class="fs-4 fw-bold">@Model.TotalJobs</div>
<div class="text-muted small">@Model.JobsWithTimeEntries time-tracked</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body">
<div class="text-muted small mb-1">Total Billed</div>
<div class="fs-4 fw-bold text-primary">@Model.TotalRevenue.ToString("C")</div>
<div class="text-muted small">Collected: @Model.TotalCollected.ToString("C")</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body">
<div class="text-muted small mb-1">Est. Total Cost</div>
<div class="fs-4 fw-bold text-danger">@Model.TotalEstimatedCost.ToString("C")</div>
<div class="text-muted small">@Model.TotalActualHours.ToString("N1") hrs logged</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
@{
var marginColor = Model.AvgMarginPercent >= 40 ? "text-success"
: Model.AvgMarginPercent >= 20 ? "text-warning"
: "text-danger";
}
<div class="card h-100 border-0 shadow-sm">
<div class="card-body">
<div class="text-muted small mb-1">Avg Gross Margin</div>
<div class="fs-4 fw-bold @marginColor">@Model.AvgMarginPercent.ToString("N1")%</div>
<div class="text-muted small">Jobs with cost data</div>
</div>
</div>
</div>
</div>
@* ── Data note ───────────────────────────────────────────────────────── *@
@if (Model.JobsWithTimeEntries < Model.TotalJobs && !Model.TimeTrackedOnly)
{
<div class="alert alert-info alert-permanent d-flex gap-2 mb-4">
<i class="bi bi-info-circle-fill flex-shrink-0 mt-1"></i>
<div>
<strong>@(Model.TotalJobs - Model.JobsWithTimeEntries) jobs have no time entries.</strong>
Their labor cost shows &mdash;$0&mdash; which understates actual cost. Use the
<strong>Time-tracked jobs only</strong> filter for accurate margin averages, or log time
entries on jobs via <strong>Job Details &rarr; Time Log</strong>.
</div>
</div>
}
@* ── Detail table ────────────────────────────────────────────────────── *@
<div class="card border-0 shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">Job Detail</span>
<span class="text-muted small">@Model.Items.Count job@(Model.Items.Count != 1 ? "s" : "")</span>
</div>
@if (!hasItems)
{
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-graph-up display-4 mb-3 d-block"></i>
<p>No jobs found for the selected period.</p>
</div>
}
else
{
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Job #</th>
<th>Customer</th>
<th>Status</th>
<th class="text-end">Billed</th>
<th class="text-end">Collected</th>
<th class="text-end">Hours</th>
<th class="text-end">Labor Cost</th>
<th class="text-end">Powder Cost</th>
<th class="text-end">Est. Cost</th>
<th class="text-end">Gross Margin</th>
<th class="text-end">Margin %</th>
</tr>
</thead>
<tbody>
@foreach (var r in Model.Items)
{
var rowClass = !r.HasTimeEntries ? "text-muted" : "";
var marginPct = r.MarginPercent;
var mpClass = r.EstimatedTotalCost == 0 ? "text-muted"
: marginPct >= 40 ? "text-success fw-semibold"
: marginPct >= 20 ? "text-warning fw-semibold"
: "text-danger fw-semibold";
<tr class="@rowClass">
<td>
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@r.JobId"
class="text-decoration-none fw-semibold">
@r.JobNumber
</a>
</td>
<td>@r.CustomerName</td>
<td>
<span class="badge @r.StatusColorClass">@r.StatusName</span>
</td>
<td class="text-end">@r.FinalPrice.ToString("C")</td>
<td class="text-end">
@if (r.AmountCollected > 0)
{
@r.AmountCollected.ToString("C")
}
else
{
<span class="text-muted">&mdash;</span>
}
</td>
<td class="text-end">
@if (r.HasTimeEntries)
{
@r.ActualHours.ToString("N2")
}
else
{
<span title="No time entries logged">
<i class="bi bi-exclamation-circle text-warning me-1" title="No time entries"></i>0
</span>
}
</td>
<td class="text-end">
@if (r.HasTimeEntries)
{
@r.ActualLaborCost.ToString("C")
}
else
{
<span class="text-muted">&mdash;</span>
}
</td>
<td class="text-end">
@if (r.ActualPowderCost > 0)
{
@r.ActualPowderCost.ToString("C")
}
else
{
<span class="text-muted">&mdash;</span>
}
</td>
<td class="text-end">
@if (r.EstimatedTotalCost > 0)
{
@r.EstimatedTotalCost.ToString("C")
}
else
{
<span class="text-muted">&mdash;</span>
}
</td>
<td class="text-end">
@if (r.EstimatedTotalCost > 0)
{
<span class="@(r.GrossMargin >= 0 ? "text-success" : "text-danger")">
@r.GrossMargin.ToString("C")
</span>
}
else
{
<span class="text-muted">&mdash;</span>
}
</td>
<td class="text-end @mpClass">
@if (r.EstimatedTotalCost > 0)
{
@marginPct.ToString("N1")@:%
}
else
{
<span class="text-muted">&mdash;</span>
}
</td>
</tr>
}
</tbody>
@if (hasItems)
{
<tfoot class="table-secondary fw-semibold">
<tr>
<td colspan="3">Totals</td>
<td class="text-end">@Model.TotalRevenue.ToString("C")</td>
<td class="text-end">@Model.TotalCollected.ToString("C")</td>
<td class="text-end">@Model.TotalActualHours.ToString("N2")</td>
<td class="text-end">@Model.Items.Sum(r => r.ActualLaborCost).ToString("C")</td>
<td class="text-end">@Model.Items.Sum(r => r.ActualPowderCost).ToString("C")</td>
<td class="text-end">@Model.TotalEstimatedCost.ToString("C")</td>
<td class="text-end">@(Model.TotalRevenue - Model.TotalEstimatedCost).ToString("C")</td>
<td class="text-end">@Model.AvgMarginPercent.ToString("N1")%</td>
</tr>
</tfoot>
}
</table>
</div>
</div>
}
</div>
<p class="text-muted small mt-3">
<i class="bi bi-info-circle me-1"></i>
Labor cost = logged hours &times; your Standard Labor Rate (set in
<a asp-controller="CompanySettings" asp-action="Index" class="text-decoration-none">Company Settings</a>).
Powder cost uses <em>actual lbs used</em> when recorded, otherwise <em>estimated lbs to order</em>.
Overhead, equipment, and prep costs are not included &mdash; this is a direct cost margin only.
</p>
</div>
@@ -323,6 +323,14 @@
<p>Average, min, and max days spent in each workflow stage for completed jobs.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="JobProfitability" class="report-card">
<div class="report-card-icon" style="background:#f0fdf4;color:#15803d;">
<i class="bi bi-graph-up-arrow"></i>
</div>
<h5>Job Profitability</h5>
<p>Actual labor and powder cost vs. billed price per job. Spot low-margin jobs and see your direct cost gross margin.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="RevenueTrends" class="report-card">
<div class="report-card-icon" style="background:#ede9fe;color:#7c3aed;">
<i class="bi bi-trending-up"></i>