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:
@@ -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 —$0— 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 → 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">—</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">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (r.ActualPowderCost > 0)
|
||||
{
|
||||
@r.ActualPowderCost.ToString("C")
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (r.EstimatedTotalCost > 0)
|
||||
{
|
||||
@r.EstimatedTotalCost.ToString("C")
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</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">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end @mpClass">
|
||||
@if (r.EstimatedTotalCost > 0)
|
||||
{
|
||||
@marginPct.ToString("N1")@:%
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</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 × 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 — 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>
|
||||
|
||||
Reference in New Issue
Block a user