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
@@ -91,32 +91,32 @@
<div class="card h-100 border-0 shadow-sm formula-card @(item.IsOwnFormula ? "border-start border-warning border-3" : item.AlreadyImported ? "border-start border-success border-3" : "")">
<div class="card-body d-flex flex-column">
@* Header row *@
<div class="d-flex align-items-start gap-2 mb-2">
<div class="flex-grow-1 min-w-0">
@* Header row — min-w-0 on both sides prevents long titles from pushing badges off-card *@
<div class="d-flex align-items-start gap-2 mb-2" style="min-width:0">
<div class="flex-grow-1" style="min-width:0;overflow:hidden">
<h6 class="fw-semibold mb-0 text-truncate" title="@item.Name">@item.Name</h6>
<small class="text-muted">
<small class="text-muted text-truncate d-block">
<i class="bi bi-building me-1"></i>@item.SourceCompanyName
</small>
</div>
<div class="d-flex flex-column align-items-end gap-1 flex-shrink-0">
<div class="d-flex flex-column align-items-end gap-1" style="flex-shrink:0;max-width:50%">
@if (item.OutputMode == "FixedRate")
{
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">Fixed Rate</span>
<span class="badge bg-primary-subtle text-primary border border-primary-subtle text-nowrap">Fixed Rate</span>
}
else
{
<span class="badge bg-info-subtle text-info border border-info-subtle">Surface Area</span>
<span class="badge bg-info-subtle text-info border border-info-subtle text-nowrap">Surface Area</span>
}
@if (item.IsOwnFormula)
{
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
<span class="badge bg-warning-subtle text-warning border border-warning-subtle text-nowrap">
<i class="bi bi-star-fill me-1"></i>Your Formula
</span>
}
else if (item.AlreadyImported)
{
<span class="badge bg-success-subtle text-success border border-success-subtle">
<span class="badge bg-success-subtle text-success border border-success-subtle text-nowrap">
<i class="bi bi-check-lg me-1"></i>Imported
</span>
}
@@ -156,21 +156,51 @@
}
@* Footer row *@
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
<small class="text-muted">
<i class="bi bi-download me-1"></i>@item.ImportCount import@(item.ImportCount == 1 ? "" : "s")
</small>
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top gap-2">
@* Left: import count + rating buttons *@
<div class="d-flex align-items-center gap-2 flex-wrap">
<small class="text-muted text-nowrap">
<i class="bi bi-download me-1"></i>@item.ImportCount import@(item.ImportCount == 1 ? "" : "s")
</small>
@if (!item.IsOwnFormula)
{
<div class="d-flex align-items-center gap-1"
data-rating-group="@item.Id"
title="Rate this formula">
<span class="text-muted small me-1">Rate:</span>
<button type="button"
class="btn btn-sm btn-vote @(item.MyVote == true ? "btn-success active-vote" : "btn-outline-secondary")"
data-item-id="@item.Id"
data-is-positive="true"
title="Helpful"
aria-label="Thumbs up">
<i class="bi bi-hand-thumbs-up"></i>
<span class="vote-up-count ms-1">@item.ThumbsUp</span>
</button>
<button type="button"
class="btn btn-sm btn-vote @(item.MyVote == false ? "btn-danger active-vote" : "btn-outline-secondary")"
data-item-id="@item.Id"
data-is-positive="false"
title="Not helpful"
aria-label="Thumbs down">
<i class="bi bi-hand-thumbs-down"></i>
<span class="vote-down-count ms-1">@item.ThumbsDown</span>
</button>
</div>
}
</div>
@* Right: action button *@
@if (item.IsOwnFormula)
{
<a asp-controller="CompanySettings" asp-action="Index" asp-fragment="custom-formulas"
class="btn btn-sm btn-outline-warning">
class="btn btn-sm btn-outline-warning flex-shrink-0">
<i class="bi bi-gear me-1"></i><span>Manage</span>
</a>
}
else
{
<button type="button"
class="btn btn-sm @(item.AlreadyImported ? "btn-outline-success" : "btn-outline-primary") btn-import"
class="btn btn-sm @(item.AlreadyImported ? "btn-outline-success" : "btn-outline-primary") btn-import flex-shrink-0"
data-item-id="@item.Id"
data-item-name="@item.Name">
@if (item.AlreadyImported)
@@ -184,6 +184,20 @@
an <em>&ldquo;Inspired by &hellip;&rdquo;</em> line crediting the formula it was derived from.
</p>
<h3 class="h6 mt-3">Rating formulas</h3>
<p>
On any formula card that was shared by another company, you&rsquo;ll see a <strong>Rate:</strong> section
with thumbs&nbsp;up (<i class="bi bi-hand-thumbs-up"></i>) and thumbs&nbsp;down (<i class="bi bi-hand-thumbs-down"></i>) buttons.
Click once to cast your vote; click the same button again to remove it.
Clicking the opposite button switches your vote immediately.
</p>
<ul class="mb-3">
<li class="mb-1">Votes are cast per company &mdash; each company gets one vote per formula.</li>
<li class="mb-1">You cannot rate your own formulas.</li>
<li class="mb-1">The count next to each button updates instantly without a page reload.</li>
<li class="mb-1">The library is sorted by net rating (thumbs up minus thumbs down), so well-rated formulas rise to the top.</li>
</ul>
<h3 class="h6 mt-3">Your own shared formulas</h3>
<p>
Formulas your company has published appear in the library with a gold <strong>Your Formula</strong> badge.
@@ -580,6 +580,43 @@
</div>
</section>
<section id="revision-history" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-clock-history text-primary me-2"></i>Revision History
</h2>
<p>
Every change made to a quote after it is created is recorded in a <strong>Revision History</strong> timeline
at the bottom of the Quote Details page. The timeline groups changes from the same save into a single
entry so you can scan what changed in each revision at a glance.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">What is tracked</h3>
<ul class="mb-3">
<li class="mb-1"><strong>Total price changes</strong> &mdash; whenever a save changes the grand total, the old and new amounts appear as a badge on the revision entry.</li>
<li class="mb-1"><strong>Status changes</strong> &mdash; every status transition (Draft &rarr; Sent, Sent &rarr; Approved, etc.) is recorded with the old and new status names.</li>
<li class="mb-1"><strong>Sent events</strong> &mdash; each time a quote email is sent or resent to a customer, a &ldquo;Sent to customer&rdquo; entry is added showing the recipient email address.</li>
<li class="mb-1"><strong>Approval</strong> &mdash; when a quote is approved (by staff or by the customer via the approval portal), the approver&rsquo;s name is recorded.</li>
<li class="mb-1"><strong>Line item changes</strong> &mdash; items added, removed, or modified (description, quantity, unit price, surface area) are each logged.</li>
<li class="mb-1"><strong>Field edits</strong> &mdash; changes to quote date, expiration date, terms, notes, tax percent, and discount are tracked individually.</li>
</ul>
<h3 class="h6 fw-semibold mt-3 mb-2">Reading the timeline</h3>
<p>
Each timeline entry shows an icon indicating the type of event:
</p>
<ul class="mb-3">
<li class="mb-1"><i class="bi bi-envelope-check-fill text-primary me-1"></i><strong>Blue envelope</strong> &mdash; quote was emailed to the customer.</li>
<li class="mb-1"><i class="bi bi-check-circle-fill text-success me-1"></i><strong>Green check</strong> &mdash; quote was approved.</li>
<li class="mb-1"><i class="bi bi-x-circle-fill text-danger me-1"></i><strong>Red X</strong> &mdash; quote was rejected or declined.</li>
<li class="mb-1"><i class="bi bi-arrow-right-circle-fill text-purple me-1"></i><strong>Purple arrow</strong> &mdash; quote was converted to a job.</li>
<li class="mb-1"><i class="bi bi-pencil-fill text-secondary me-1"></i><strong>Grey pencil</strong> &mdash; general edit (field or line item changes).</li>
</ul>
<p>
If the total price changed in a revision, a badge showing <em>old &rarr; new</em> appears inline in the
revision header so pricing history is visible without expanding the detail lines.
</p>
</section>
</div>
<div class="col-lg-3 d-none d-lg-block">
@@ -600,6 +637,7 @@
<a class="nav-link py-1 px-3 small text-body" href="#ai-quick-quote">AI Quick Quote</a>
<a class="nav-link py-1 px-3 small text-body" href="#changing-customer">Changing the Customer</a>
<a class="nav-link py-1 px-3 small text-body" href="#pricing-breakdown">Pricing Breakdown</a>
<a class="nav-link py-1 px-3 small text-body" href="#revision-history">Revision History</a>
</nav>
</div>
</div>
@@ -168,6 +168,34 @@
stage may need more capacity.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Job Profitability Report</h3>
<p>
Compares each job&rsquo;s billed price against its direct costs so you can see your gross margin per job.
Access it from <strong>Reports &rarr; Job Profitability</strong>.
</p>
<ul class="mb-3">
<li class="mb-1"><strong>Billed</strong> &mdash; the job&rsquo;s final price as quoted.</li>
<li class="mb-1"><strong>Collected</strong> &mdash; how much has actually been paid on the linked invoice.</li>
<li class="mb-1"><strong>Hours</strong> &mdash; actual hours logged via the job&rsquo;s Time Log entries.</li>
<li class="mb-1"><strong>Labor Cost</strong> &mdash; logged hours &times; your Standard Labor Rate (set in Company Settings).</li>
<li class="mb-1"><strong>Powder Cost</strong> &mdash; estimated from pounds-to-order &times; cost-per-lb on each coat. Uses <em>actual pounds used</em> when recorded.</li>
<li class="mb-1"><strong>Gross Margin</strong> &mdash; Billed minus estimated total cost. Color-coded: green &ge;40%, yellow &ge;20%, red below 20%.</li>
</ul>
<p>
Use the <strong>Time-tracked jobs only</strong> toggle to filter out jobs with no time entries &mdash; jobs
without time entries show $0 labor cost, which skews the average margin downward. Log time entries on any
job via <strong>Job Details &rarr; Time Log</strong>.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-3" role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>
This report covers <strong>direct costs only</strong> (labor + powder). Overhead, equipment time,
and prep costs are factored into your quoted price via operating cost rates but are not broken out
separately here. Use it as a relative indicator &mdash; which jobs are thin vs. comfortable &mdash;
rather than an absolute profitability figure.
</div>
</div>
<h3 class="h6 fw-semibold mt-3 mb-2">Powder Usage Report</h3>
<p>
Tracks powder consumption by inventory item and by job. Useful for verifying that actual
+121 -53
View File
@@ -1915,71 +1915,139 @@
</div>
</div>
<!-- Change History Section -->
@if (ViewBag.ChangeHistory != null && ((List<PowderCoating.Application.DTOs.Quote.QuoteChangeHistoryDto>)ViewBag.ChangeHistory).Any())
<!-- Change History Timeline -->
@{
var changeHistory = ViewBag.ChangeHistory as List<PowderCoating.Application.DTOs.Quote.QuoteChangeHistoryDto>;
}
@if (changeHistory != null && changeHistory.Any())
{
<div class="card border-0 shadow-sm mt-4">
<div class="card-header bg-white border-0 py-3">
<div class="card-header border-0 py-3 d-flex align-items-center justify-content-between">
<h5 class="mb-0">
<i class="bi bi-clock-history me-2"></i>Change History
<i class="bi bi-clock-history me-2 text-secondary"></i>Revision History
</h5>
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">
@changeHistory.Count event@(changeHistory.Count != 1 ? "s" : "")
</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 13%">Date & Time</th>
<th style="width: 12%">Changed By</th>
<th style="width: 12%">Field</th>
<th style="width: 13%">Old Value</th>
<th style="width: 13%">New Value</th>
<th>Description</th>
</tr>
</thead>
<tbody>
@foreach (var change in (List<PowderCoating.Application.DTOs.Quote.QuoteChangeHistoryDto>)ViewBag.ChangeHistory)
{
<tr>
<td>
<div>@change.ChangedAt.ToString("MM/dd/yyyy")</div>
<small class="text-muted">@change.ChangedAt.ToString("h:mm tt")</small>
</td>
<td>@change.ChangedByName</td>
<td><strong>@change.FieldName</strong></td>
<td>
@if (!string.IsNullOrEmpty(change.OldValue))
<div class="card-body py-3 px-4">
@{
// Group entries that were saved within 5 seconds of each other into one "revision"
var groups = new List<(DateTime GroupTime, string ByName, List<PowderCoating.Application.DTOs.Quote.QuoteChangeHistoryDto> Entries)>();
foreach (var entry in changeHistory.OrderByDescending(c => c.ChangedAt))
{
var last = groups.LastOrDefault();
if (last != default && (entry.ChangedAt - last.GroupTime).TotalSeconds <= 5 && last.ByName == entry.ChangedByName)
last.Entries.Add(entry);
else
groups.Add((entry.ChangedAt, entry.ChangedByName ?? "", new List<PowderCoating.Application.DTOs.Quote.QuoteChangeHistoryDto> { entry }));
}
}
<div class="quote-timeline">
@foreach (var group in groups)
{
// Determine dominant event type for the group icon
var isSent = group.Entries.Any(e => e.FieldName == "Sent");
var isApproved = group.Entries.Any(e => e.FieldName == "Status" && (e.NewValue?.Contains("Approved") == true));
var isDeclined = group.Entries.Any(e => e.FieldName == "Status" && (e.NewValue?.Contains("Rejected") == true || e.NewValue?.Contains("Declined") == true));
var isConverted = group.Entries.Any(e => e.FieldName == "Status" && e.NewValue?.Contains("Converted") == true);
var hasTotalChange = group.Entries.Any(e => e.FieldName == "Total");
var (iconClass, iconBg, iconColor) = (isSent, isApproved, isDeclined, isConverted) switch
{
(true, _, _, _) => ("bi-envelope-check-fill", "#dbeafe", "#1d4ed8"),
(_, true, _, _) => ("bi-check-circle-fill", "#dcfce7", "#15803d"),
(_, _, true, _) => ("bi-x-circle-fill", "#fee2e2", "#dc2626"),
(_, _, _, true) => ("bi-arrow-right-circle-fill", "#ede9fe", "#6d28d9"),
_ => ("bi-pencil-fill", "#f3f4f6", "#6b7280"),
};
<div class="qt-row d-flex gap-3 mb-4">
@* Icon column *@
<div class="qt-icon flex-shrink-0 d-flex align-items-start pt-1">
<div style="width:32px;height:32px;border-radius:50%;background:@iconBg;color:@iconColor;display:flex;align-items:center;justify-content:center;font-size:.9rem;">
<i class="bi @iconClass"></i>
</div>
</div>
@* Content column *@
<div class="qt-content flex-grow-1">
<div class="d-flex align-items-baseline gap-2 flex-wrap mb-1">
<span class="fw-semibold small">
@(isSent ? "Sent to customer" : isApproved ? "Approved" : isDeclined ? "Rejected" : isConverted ? "Converted to job" : "Edited")
</span>
<span class="text-muted small">
&mdash; @group.GroupTime.ToLocalTime().ToString("MMM d, yyyy h:mm tt")
@if (!string.IsNullOrWhiteSpace(group.ByName))
{
<span class="text-muted">@change.OldValue</span>
<span>by @group.ByName</span>
}
else
</span>
@if (hasTotalChange)
{
var totalEntry = group.Entries.First(e => e.FieldName == "Total");
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
@totalEntry.OldValue &rarr; @totalEntry.NewValue
</span>
}
</div>
@{
var detailEntries = group.Entries
.Where(e => e.FieldName != "Sent")
.ToList();
}
@if (detailEntries.Any())
{
<ul class="list-unstyled mb-0 ms-1">
@foreach (var e in detailEntries)
{
<span class="text-muted fst-italic">None</span>
<li class="text-muted small mb-1">
@if (e.FieldName == "Total")
{
<span><i class="bi bi-currency-dollar me-1"></i><strong>Total</strong> changed from @e.OldValue to <strong>@e.NewValue</strong></span>
}
else if (e.FieldName == "Status")
{
<span><i class="bi bi-arrow-right me-1"></i>Status: <span class="text-muted">@e.OldValue</span> &rarr; <strong>@e.NewValue</strong></span>
}
else if (e.FieldName == "Quote Items")
{
<span><i class="bi bi-list-ul me-1"></i>@e.ChangeDescription</span>
}
else if (!string.IsNullOrWhiteSpace(e.OldValue) && !string.IsNullOrWhiteSpace(e.NewValue))
{
<span><strong>@e.FieldName:</strong> @e.OldValue &rarr; @e.NewValue</span>
}
else
{
<span>@e.ChangeDescription</span>
}
</li>
}
</td>
<td>
@if (!string.IsNullOrEmpty(change.NewValue))
{
<strong>@change.NewValue</strong>
}
else
{
<span class="text-muted fst-italic">None</span>
}
</td>
<td>
@if (!string.IsNullOrEmpty(change.ChangeDescription))
{
<span class="text-muted small">@change.ChangeDescription</span>
}
</td>
</tr>
}
</tbody>
</table>
</ul>
}
@if (isSent)
{
var sentEntry = group.Entries.First(e => e.FieldName == "Sent");
<span class="text-muted small"><i class="bi bi-envelope me-1"></i>@sentEntry.NewValue</span>
}
</div>
</div>
}
</div>
</div>
</div>
<style>
.quote-timeline .qt-row:not(:last-child) .qt-icon::after {
content: '';
display: block;
width: 2px;
background: var(--bs-border-color);
margin: 4px auto 0;
height: calc(100% + 1rem);
}
.qt-icon { position: relative; flex-direction: column; align-items: center !important; }
</style>
}
</div>
@@ -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>