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:
@@ -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>“Inspired by …”</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’ll see a <strong>Rate:</strong> section
|
||||
with thumbs up (<i class="bi bi-hand-thumbs-up"></i>) and thumbs 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 — 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> — 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> — every status transition (Draft → Sent, Sent → Approved, etc.) is recorded with the old and new status names.</li>
|
||||
<li class="mb-1"><strong>Sent events</strong> — each time a quote email is sent or resent to a customer, a “Sent to customer” entry is added showing the recipient email address.</li>
|
||||
<li class="mb-1"><strong>Approval</strong> — when a quote is approved (by staff or by the customer via the approval portal), the approver’s name is recorded.</li>
|
||||
<li class="mb-1"><strong>Line item changes</strong> — items added, removed, or modified (description, quantity, unit price, surface area) are each logged.</li>
|
||||
<li class="mb-1"><strong>Field edits</strong> — 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> — 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> — quote was approved.</li>
|
||||
<li class="mb-1"><i class="bi bi-x-circle-fill text-danger me-1"></i><strong>Red X</strong> — 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> — 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> — general edit (field or line item changes).</li>
|
||||
</ul>
|
||||
<p>
|
||||
If the total price changed in a revision, a badge showing <em>old → 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’s billed price against its direct costs so you can see your gross margin per job.
|
||||
Access it from <strong>Reports → Job Profitability</strong>.
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-1"><strong>Billed</strong> — the job’s final price as quoted.</li>
|
||||
<li class="mb-1"><strong>Collected</strong> — how much has actually been paid on the linked invoice.</li>
|
||||
<li class="mb-1"><strong>Hours</strong> — actual hours logged via the job’s Time Log entries.</li>
|
||||
<li class="mb-1"><strong>Labor Cost</strong> — logged hours × your Standard Labor Rate (set in Company Settings).</li>
|
||||
<li class="mb-1"><strong>Powder Cost</strong> — estimated from pounds-to-order × cost-per-lb on each coat. Uses <em>actual pounds used</em> when recorded.</li>
|
||||
<li class="mb-1"><strong>Gross Margin</strong> — Billed minus estimated total cost. Color-coded: green ≥40%, yellow ≥20%, red below 20%.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Use the <strong>Time-tracked jobs only</strong> toggle to filter out jobs with no time entries — jobs
|
||||
without time entries show $0 labor cost, which skews the average margin downward. Log time entries on any
|
||||
job via <strong>Job Details → 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 — which jobs are thin vs. comfortable —
|
||||
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
|
||||
|
||||
@@ -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">
|
||||
— @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 → @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> → <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 → @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 —$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