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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user