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:
@@ -26,6 +26,15 @@ public class FormulaLibraryCardDto
|
||||
|
||||
/// <summary>True when this formula was shared by the current browsing company.</summary>
|
||||
public bool IsOwnFormula { get; set; }
|
||||
|
||||
/// <summary>Total thumbs-up votes across all companies.</summary>
|
||||
public int ThumbsUp { get; set; }
|
||||
|
||||
/// <summary>Total thumbs-down votes across all companies.</summary>
|
||||
public int ThumbsDown { get; set; }
|
||||
|
||||
/// <summary>The current browsing company's vote: true = up, false = down, null = no vote.</summary>
|
||||
public bool? MyVote { get; set; }
|
||||
}
|
||||
|
||||
// ── Full detail (import preview modal) ────────────────────────────────────
|
||||
|
||||
@@ -18,7 +18,8 @@ public class WizardProgressDto
|
||||
public bool IsStepSkipped(int step) => SkippedSteps.Contains(step);
|
||||
public bool IsStepTouched(int step) => IsStepDone(step) || IsStepSkipped(step);
|
||||
|
||||
public int CompletedCount => DoneSteps.Count + SkippedSteps.Count;
|
||||
// Capped at TotalSteps so old step data from a larger wizard doesn't overflow the display.
|
||||
public int CompletedCount => Math.Min(DoneSteps.Count + SkippedSteps.Count, TotalSteps);
|
||||
public int ProgressPercent => TotalSteps == 0 ? 0 : (int)Math.Round((double)CompletedCount / TotalSteps * 100);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,4 +48,14 @@ public interface IFormulaLibraryService
|
||||
/// when a diagram is deleted or replaced.
|
||||
/// </summary>
|
||||
Task CascadeRemoveDiagramAsync(int sourceCustomItemTemplateId);
|
||||
|
||||
/// <summary>
|
||||
/// Records or toggles a thumbs-up/down vote from the given company.
|
||||
/// If the same vote already exists it is removed (toggle off).
|
||||
/// If the opposite vote exists it is replaced.
|
||||
/// Companies cannot rate their own formulas.
|
||||
/// Returns the updated counts for the library entry.
|
||||
/// </summary>
|
||||
Task<(int ThumbsUp, int ThumbsDown, bool? MyVote)> RateAsync(
|
||||
int libraryItemId, int companyId, bool isPositive);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One thumbs-up or thumbs-down vote per company per library formula.
|
||||
/// Platform-level — no BaseEntity, no soft delete, no CompanyId tenant filter.
|
||||
/// Unique constraint enforced at the DB level: (FormulaLibraryItemId, CompanyId).
|
||||
/// </summary>
|
||||
public class FormulaLibraryRating
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int FormulaLibraryItemId { get; set; }
|
||||
|
||||
/// <summary>The company casting the vote.</summary>
|
||||
public int CompanyId { get; set; }
|
||||
|
||||
/// <summary>True = thumbs up, false = thumbs down.</summary>
|
||||
public bool IsPositive { get; set; }
|
||||
|
||||
public DateTime RatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public virtual FormulaLibraryItem FormulaLibraryItem { get; set; } = null!;
|
||||
}
|
||||
@@ -161,6 +161,7 @@ IRepository<ReworkRecord> ReworkRecords { get; }
|
||||
// Formula Community Library
|
||||
IPlainRepository<FormulaLibraryItem> FormulaLibrary { get; }
|
||||
IRepository<FormulaLibraryImport> FormulaLibraryImports { get; }
|
||||
IPlainRepository<FormulaLibraryRating> FormulaLibraryRatings { get; }
|
||||
|
||||
// Employee Timeclock
|
||||
IRepository<EmployeeClockEntry> EmployeeClockEntries { get; }
|
||||
|
||||
@@ -295,6 +295,9 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// <summary>Per-company record of which community library formulas a company has imported.</summary>
|
||||
public DbSet<FormulaLibraryImport> FormulaLibraryImports { get; set; }
|
||||
|
||||
/// <summary>Per-company thumbs-up / thumbs-down vote on community library formulas.</summary>
|
||||
public DbSet<FormulaLibraryRating> FormulaLibraryRatings { get; set; }
|
||||
|
||||
/// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<BugReport> BugReports { get; set; }
|
||||
/// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary>
|
||||
@@ -2116,6 +2119,18 @@ modelBuilder.Entity<Job>()
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
|
||||
|
||||
// FormulaLibraryRating — platform-level; one vote per company per formula
|
||||
modelBuilder.Entity<FormulaLibraryRating>()
|
||||
.HasOne(r => r.FormulaLibraryItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.FormulaLibraryItemId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<FormulaLibraryRating>()
|
||||
.HasIndex(r => new { r.FormulaLibraryItemId, r.CompanyId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_FormulaLibraryRatings_Item_Company");
|
||||
|
||||
// CustomItemTemplate → FormulaLibraryItem (nullable; only set on imported templates)
|
||||
modelBuilder.Entity<CustomItemTemplate>()
|
||||
.HasOne(t => t.SourceFormulaLibraryItem)
|
||||
|
||||
Generated
+11159
File diff suppressed because it is too large
Load Diff
+92
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFormulaLibraryRatings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FormulaLibraryRatings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
FormulaLibraryItemId = table.Column<int>(type: "int", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
IsPositive = table.Column<bool>(type: "bit", nullable: false),
|
||||
RatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FormulaLibraryRatings", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FormulaLibraryRatings_FormulaLibraryItems_FormulaLibraryItemId",
|
||||
column: x => x.FormulaLibraryItemId,
|
||||
principalTable: "FormulaLibraryItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FormulaLibraryRatings_Item_Company",
|
||||
table: "FormulaLibraryRatings",
|
||||
columns: new[] { "FormulaLibraryItemId", "CompanyId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "FormulaLibraryRatings");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3849));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3855));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3856));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3593,6 +3593,35 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("FormulaLibraryItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryRating", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("FormulaLibraryItemId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsPositive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime>("RatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FormulaLibraryItemId", "CompanyId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_FormulaLibraryRatings_Item_Company");
|
||||
|
||||
b.ToTable("FormulaLibraryRatings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -7024,7 +7053,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3849),
|
||||
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -7035,7 +7064,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3855),
|
||||
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -7046,7 +7075,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3856),
|
||||
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -9614,6 +9643,17 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("InspiredBy");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryRating", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "FormulaLibraryItem")
|
||||
.WithMany()
|
||||
.HasForeignKey("FormulaLibraryItemId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("FormulaLibraryItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
|
||||
|
||||
@@ -133,6 +133,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
// Formula Community Library
|
||||
private IPlainRepository<FormulaLibraryItem>? _formulaLibrary;
|
||||
private IRepository<FormulaLibraryImport>? _formulaLibraryImports;
|
||||
private IPlainRepository<FormulaLibraryRating>? _formulaLibraryRatings;
|
||||
|
||||
// Purchase Orders
|
||||
private IPurchaseOrderRepository? _purchaseOrders;
|
||||
@@ -488,6 +489,10 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IRepository<FormulaLibraryImport> FormulaLibraryImports =>
|
||||
_formulaLibraryImports ??= new Repository<FormulaLibraryImport>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="FormulaLibraryRating"/> per-company thumbs votes; platform-level, no tenant filter.</summary>
|
||||
public IPlainRepository<FormulaLibraryRating> FormulaLibraryRatings =>
|
||||
_formulaLibraryRatings ??= new PlainRepository<FormulaLibraryRating>(_context);
|
||||
|
||||
// Job Templates
|
||||
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
||||
public IJobTemplateRepository JobTemplates =>
|
||||
|
||||
@@ -75,14 +75,36 @@ public class FormulaLibraryService : IFormulaLibraryService
|
||||
imp => imp.CompanyId == companyId && !imp.IsDeleted);
|
||||
var importedIds = imports.Select(imp => imp.FormulaLibraryItemId).ToHashSet();
|
||||
|
||||
// Load all ratings in one query for this page of items
|
||||
var allItemIds = itemList.Select(i => i.Id).ToHashSet();
|
||||
var allRatings = await _unitOfWork.FormulaLibraryRatings.FindAsync(
|
||||
r => allItemIds.Contains(r.FormulaLibraryItemId));
|
||||
|
||||
// Group counts and find the current company's vote per item
|
||||
var ratingsByItem = allRatings
|
||||
.GroupBy(r => r.FormulaLibraryItemId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var dtos = _mapper.Map<List<FormulaLibraryCardDto>>(itemList);
|
||||
for (int i = 0; i < dtos.Count; i++)
|
||||
{
|
||||
dtos[i].AlreadyImported = importedIds.Contains(dtos[i].Id);
|
||||
dtos[i].IsOwnFormula = itemList[i].SourceCompanyId == companyId;
|
||||
|
||||
if (ratingsByItem.TryGetValue(dtos[i].Id, out var ratings))
|
||||
{
|
||||
dtos[i].ThumbsUp = ratings.Count(r => r.IsPositive);
|
||||
dtos[i].ThumbsDown = ratings.Count(r => !r.IsPositive);
|
||||
var myRating = ratings.FirstOrDefault(r => r.CompanyId == companyId);
|
||||
dtos[i].MyVote = myRating?.IsPositive;
|
||||
}
|
||||
}
|
||||
|
||||
return dtos.OrderByDescending(d => d.ImportCount).ThenBy(d => d.Name);
|
||||
// Sort: thumbs-up score descending, then import count, then name
|
||||
return dtos
|
||||
.OrderByDescending(d => d.ThumbsUp - d.ThumbsDown)
|
||||
.ThenByDescending(d => d.ImportCount)
|
||||
.ThenBy(d => d.Name);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -280,6 +302,58 @@ public class FormulaLibraryService : IFormulaLibraryService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(int ThumbsUp, int ThumbsDown, bool? MyVote)> RateAsync(
|
||||
int libraryItemId, int companyId, bool isPositive)
|
||||
{
|
||||
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
|
||||
if (item == null || !item.IsPublished)
|
||||
throw new InvalidOperationException("Library entry not found.");
|
||||
|
||||
// Companies cannot rate their own formula
|
||||
if (item.SourceCompanyId == companyId)
|
||||
throw new InvalidOperationException("You cannot rate your own formula.");
|
||||
|
||||
var existing = await _unitOfWork.FormulaLibraryRatings.FirstOrDefaultAsync(
|
||||
r => r.FormulaLibraryItemId == libraryItemId && r.CompanyId == companyId);
|
||||
|
||||
bool? myVote;
|
||||
if (existing != null && existing.IsPositive == isPositive)
|
||||
{
|
||||
// Same vote again — toggle off
|
||||
await _unitOfWork.FormulaLibraryRatings.DeleteAsync(existing);
|
||||
myVote = null;
|
||||
}
|
||||
else if (existing != null)
|
||||
{
|
||||
// Opposite vote — flip it
|
||||
existing.IsPositive = isPositive;
|
||||
existing.RatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.FormulaLibraryRatings.UpdateAsync(existing);
|
||||
myVote = isPositive;
|
||||
}
|
||||
else
|
||||
{
|
||||
// New vote
|
||||
await _unitOfWork.FormulaLibraryRatings.AddAsync(new FormulaLibraryRating
|
||||
{
|
||||
FormulaLibraryItemId = libraryItemId,
|
||||
CompanyId = companyId,
|
||||
IsPositive = isPositive,
|
||||
RatedAt = DateTime.UtcNow,
|
||||
});
|
||||
myVote = isPositive;
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Return fresh counts
|
||||
var allRatings = await _unitOfWork.FormulaLibraryRatings.FindAsync(
|
||||
r => r.FormulaLibraryItemId == libraryItemId);
|
||||
var list = allRatings.ToList();
|
||||
return (list.Count(r => r.IsPositive), list.Count(r => !r.IsPositive), myVote);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -84,6 +84,31 @@ public class FormulaLibraryController : Controller
|
||||
return File(bytes, contentType ?? "image/jpeg");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records or toggles a thumbs-up/down vote for the current company.
|
||||
/// Returns updated counts so the UI can update without a page reload.
|
||||
/// Companies cannot rate their own formulas; own-formula cards have no rating buttons.
|
||||
/// </summary>
|
||||
// POST: /FormulaLibrary/Rate
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Rate([FromBody] RateFormulaRequest request)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) return Json(new { success = false, message = "No company context." });
|
||||
|
||||
try
|
||||
{
|
||||
var (up, down, myVote) = await _libraryService.RateAsync(
|
||||
request.LibraryItemId, companyId.Value, request.IsPositive);
|
||||
return Json(new { success = true, thumbsUp = up, thumbsDown = down, myVote });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Json(new { success = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Imports a library entry as a new local template for the current company.</summary>
|
||||
// POST: /FormulaLibrary/Import
|
||||
[HttpPost]
|
||||
@@ -107,3 +132,11 @@ public class FormulaLibraryController : Controller
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Body for the Rate endpoint.</summary>
|
||||
public class RateFormulaRequest
|
||||
{
|
||||
public int LibraryItemId { get; set; }
|
||||
/// <summary>True = thumbs up, false = thumbs down.</summary>
|
||||
public bool IsPositive { get; set; }
|
||||
}
|
||||
|
||||
@@ -1320,6 +1320,7 @@ public class QuotesController : Controller
|
||||
Terms = quote.Terms,
|
||||
Notes = quote.Notes,
|
||||
TaxPercent = quote.TaxPercent,
|
||||
Total = quote.Total,
|
||||
DiscountType = quote.DiscountType,
|
||||
DiscountValue = quote.DiscountValue,
|
||||
DiscountReason = quote.DiscountReason,
|
||||
@@ -1342,9 +1343,27 @@ public class QuotesController : Controller
|
||||
// Set calculated pricing — snapshot at save time; never recalculate on load
|
||||
_quotePricingAssemblyService.ApplyPricingSnapshot(quote, pricingResult);
|
||||
|
||||
// Track changes
|
||||
// All change history records are accumulated here, then saved in bulk below
|
||||
var changeHistories = new List<QuoteChangeHistory>();
|
||||
|
||||
// Log a total-change entry now that the new Total is known
|
||||
if (Math.Round(oldValues.Total, 2) != Math.Round(quote.Total, 2))
|
||||
{
|
||||
changeHistories.Add(new QuoteChangeHistory
|
||||
{
|
||||
QuoteId = quote.Id,
|
||||
ChangedByUserId = currentUser.Id,
|
||||
ChangedAt = DateTime.UtcNow,
|
||||
FieldName = "Total",
|
||||
OldValue = oldValues.Total.ToString("C"),
|
||||
NewValue = quote.Total.ToString("C"),
|
||||
ChangeDescription = $"Total changed from {oldValues.Total:C} to {quote.Total:C}",
|
||||
CompanyId = currentUser.CompanyId
|
||||
});
|
||||
}
|
||||
|
||||
// Track changes
|
||||
|
||||
_logger.LogInformation("=== CHANGE TRACKING DEBUG ===");
|
||||
_logger.LogInformation("Old Status: {OldStatus}, New Status: {NewStatus}", oldValues.QuoteStatusId, quote.QuoteStatusId);
|
||||
_logger.LogInformation("Old Date: {OldDate}, New Date: {NewDate}", oldValues.QuoteDate, quote.QuoteDate);
|
||||
@@ -3174,6 +3193,22 @@ public class QuotesController : Controller
|
||||
}
|
||||
|
||||
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||
|
||||
// Log send event so the history timeline shows when the quote was emailed
|
||||
var sentHistoryEntry = new QuoteChangeHistory
|
||||
{
|
||||
QuoteId = quote.Id,
|
||||
ChangedByUserId = currentUser!.Id,
|
||||
ChangedAt = DateTime.UtcNow,
|
||||
FieldName = "Sent",
|
||||
OldValue = null,
|
||||
NewValue = recipientEmail,
|
||||
ChangeDescription = $"Quote sent to {recipientName} ({recipientEmail})",
|
||||
CompanyId = quote.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _unitOfWork.QuoteChangeHistories.AddAsync(sentHistoryEntry);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename, trimmedOverride);
|
||||
|
||||
@@ -1722,6 +1722,129 @@ public class ReportsController : Controller
|
||||
return View(new JobCycleTimeViewModel { ReportTitle = "Job Cycle Time", ReportDescription = "Average time spent in each workflow stage", SelectedMonths = months, Items = items, OverallAvgCycleDays = overallAvg });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Job Profitability report — compares each job's final price and collected amount against
|
||||
/// actual labor cost (hours × StandardLaborRate) and estimated powder cost (PowderToOrder ×
|
||||
/// PowderCostPerLb, or ActualPowderUsedLbs when recorded). Only jobs with at least one
|
||||
/// JobTimeEntry contribute meaningful labor cost figures; the report flags rows without time
|
||||
/// tracking so the user knows which margin estimates are incomplete.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> JobProfitability(int months = 6, bool timeTrackedOnly = false)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var cutoff = DateTime.UtcNow.AddMonths(-months);
|
||||
|
||||
// ── Load base data ────────────────────────────────────────────────
|
||||
|
||||
var jobs = (await _unitOfWork.Jobs.FindAsync(
|
||||
j => j.CompanyId == companyId && j.CreatedAt >= cutoff,
|
||||
false,
|
||||
j => j.Customer,
|
||||
j => j.JobStatus))
|
||||
.ToList();
|
||||
|
||||
var jobIds = jobs.Select(j => j.Id).ToList();
|
||||
|
||||
// Time entries grouped by job
|
||||
var timeEntries = (await _unitOfWork.JobTimeEntries.FindAsync(
|
||||
te => jobIds.Contains(te.JobId) && !te.IsDeleted))
|
||||
.GroupBy(te => te.JobId)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(te => te.HoursWorked));
|
||||
|
||||
// Job items, then coats grouped by job
|
||||
var jobItems = (await _unitOfWork.JobItems.FindAsync(
|
||||
ji => jobIds.Contains(ji.JobId) && !ji.IsDeleted))
|
||||
.ToList();
|
||||
var jobItemIds = jobItems.Select(ji => ji.Id).ToList();
|
||||
|
||||
var coats = jobItemIds.Any()
|
||||
? (await _unitOfWork.JobItemCoats.FindAsync(
|
||||
c => jobItemIds.Contains(c.JobItemId) && !c.IsDeleted))
|
||||
.ToList()
|
||||
: new List<JobItemCoat>();
|
||||
|
||||
// Map coat cost totals back to JobId
|
||||
var jobItemToJob = jobItems.ToDictionary(ji => ji.Id, ji => ji.JobId);
|
||||
var powderCostByJob = coats
|
||||
.Where(c => c.PowderCostPerLb.HasValue)
|
||||
.GroupBy(c => jobItemToJob.TryGetValue(c.JobItemId, out var jid) ? jid : 0)
|
||||
.Where(g => g.Key > 0)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Sum(c =>
|
||||
{
|
||||
var lbs = c.ActualPowderUsedLbs ?? c.PowderToOrder ?? 0m;
|
||||
return lbs * (c.PowderCostPerLb ?? 0m);
|
||||
}));
|
||||
|
||||
// Invoices grouped by job
|
||||
var invoices = (await _unitOfWork.Invoices.FindAsync(
|
||||
inv => inv.CompanyId == companyId && inv.JobId.HasValue
|
||||
&& jobIds.Contains(inv.JobId!.Value)
|
||||
&& !inv.IsDeleted))
|
||||
.GroupBy(inv => inv.JobId!.Value)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(inv => inv.AmountPaid));
|
||||
|
||||
// Labor rate
|
||||
var opCosts = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(
|
||||
c => c.CompanyId == companyId && !c.IsDeleted);
|
||||
var laborRate = opCosts?.StandardLaborRate ?? 0m;
|
||||
|
||||
// ── Build report rows ─────────────────────────────────────────────
|
||||
|
||||
var items = jobs
|
||||
.Select(j =>
|
||||
{
|
||||
var hours = timeEntries.TryGetValue(j.Id, out var h) ? h : 0m;
|
||||
var powderCost = powderCostByJob.TryGetValue(j.Id, out var pc) ? pc : 0m;
|
||||
var collected = invoices.TryGetValue(j.Id, out var paid) ? paid : 0m;
|
||||
|
||||
return new JobProfitabilityItem
|
||||
{
|
||||
JobId = j.Id,
|
||||
JobNumber = j.JobNumber,
|
||||
CustomerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName)
|
||||
? j.Customer.CompanyName
|
||||
: $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim()
|
||||
is { Length: > 0 } n ? n : "Unknown",
|
||||
StatusName = j.JobStatus?.DisplayName ?? "Unknown",
|
||||
StatusColorClass = j.JobStatus?.ColorClass ?? "bg-secondary",
|
||||
JobDate = j.CreatedAt,
|
||||
FinalPrice = j.FinalPrice,
|
||||
AmountCollected = collected,
|
||||
ActualHours = hours,
|
||||
ActualLaborCost = Math.Round(hours * laborRate, 2),
|
||||
ActualPowderCost = Math.Round(powderCost, 2),
|
||||
HasTimeEntries = timeEntries.ContainsKey(j.Id),
|
||||
};
|
||||
})
|
||||
.Where(r => !timeTrackedOnly || r.HasTimeEntries)
|
||||
.OrderByDescending(r => r.JobDate)
|
||||
.ToList();
|
||||
|
||||
// ── Summary KPIs ──────────────────────────────────────────────────
|
||||
|
||||
var itemsWithCost = items.Where(r => r.EstimatedTotalCost > 0).ToList();
|
||||
|
||||
return View(new JobProfitabilityViewModel
|
||||
{
|
||||
ReportTitle = "Job Profitability",
|
||||
ReportDescription = "Actual labor and powder cost vs. billed price per job",
|
||||
SelectedMonths = months,
|
||||
TimeTrackedOnly = timeTrackedOnly,
|
||||
TotalJobs = items.Count,
|
||||
JobsWithTimeEntries = items.Count(r => r.HasTimeEntries),
|
||||
TotalRevenue = items.Sum(r => r.FinalPrice),
|
||||
TotalCollected = items.Sum(r => r.AmountCollected),
|
||||
TotalEstimatedCost = itemsWithCost.Sum(r => r.EstimatedTotalCost),
|
||||
TotalActualHours = items.Sum(r => r.ActualHours),
|
||||
AvgMarginPercent = itemsWithCost.Any()
|
||||
? Math.Round(itemsWithCost.Average(r => r.MarginPercent), 1)
|
||||
: 0m,
|
||||
Items = items,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Job Status Aging report — all active (non-terminal) jobs sorted by days in their current
|
||||
/// status descending. Uses UpdatedAt as the proxy for "when did this job enter its current status"
|
||||
|
||||
@@ -242,6 +242,8 @@ public static class HelpKnowledgeBase
|
||||
|
||||
**Changing the customer on a quote:** On the Quote Details page, the Customer field is an always-visible dropdown. Select a different customer — a confirmation banner appears asking you to confirm the change. Click **Save** to apply or **Cancel** to revert to the original. This is especially useful when a quote was created under the "Walk-In / Phone" placeholder and the real customer record is added later.
|
||||
|
||||
**Quote Revision History:** Every change to a quote is recorded in a timeline at the bottom of the Quote Details page. Changes from the same save are grouped into one revision entry. Tracked events include: total price changes (shown as old→new badge on the revision header), status transitions, sent/resent events (including recipient email), approvals, line item adds/removes/edits, and field changes (dates, terms, notes, tax, discount). Timeline icons: blue envelope = sent to customer, green check = approved, red X = rejected, purple arrow = converted to job, grey pencil = general edit.
|
||||
|
||||
---
|
||||
|
||||
## JOBS
|
||||
@@ -627,6 +629,7 @@ public static class HelpKnowledgeBase
|
||||
- *Ask Your Financials* — [/Reports/FinancialQuery](/Reports/FinancialQuery) — natural language query interface. Type any financial question ("What were my top expenses last quarter?", "Which customers owe the most?") and the AI answers using your real data. Includes suggestion chips, follow-up prompts, supporting facts, and session history. The right panel shows a YTD financial snapshot (revenue, expenses, net income, open AR, open AP).
|
||||
- *Powder Usage Report* — powder consumption by item/job
|
||||
- *Job Cycle Time Report* — how long jobs spend in each status
|
||||
- *Job Profitability Report* — [/Reports/JobProfitability](/Reports/JobProfitability) — compares each job's billed price against actual labor cost (logged hours × Standard Labor Rate) and estimated powder cost (lbs × cost/lb). Shows gross margin and margin % per job. Jobs without time entries show $0 labor cost; use the "Time-tracked jobs only" toggle to filter for accurate averages. Color-coded: green ≥40%, yellow ≥20%, red below 20%. This is a direct-cost margin only — overhead and equipment are not broken out separately.
|
||||
|
||||
Most financial reports support PDF export. The Sales Tax Report also supports CSV export.
|
||||
|
||||
@@ -1443,7 +1446,8 @@ public static class HelpKnowledgeBase
|
||||
- Importing: click Import to My Formulas in the preview modal → a fully independent copy is added to your local library; edits to the copy do not affect the original. If the original creator deletes their diagram image, the image is automatically cleared from all imported copies.
|
||||
- Attribution: every card shows the source company name. If a company imports a formula, modifies it, and re-shares it, the card displays "Inspired by [original name] from [original company]".
|
||||
- Your own shared formulas: appear in the library with a gold "Your Formula" badge; Manage button links back to Company Settings. To remove from the library, click Unshare in the Library column — existing imports are unaffected.
|
||||
- Import counts are shown on each card and the library is sorted by popularity (most imported first).
|
||||
- Import counts are shown on each card.
|
||||
- Rating: each card from another company has thumbs-up / thumbs-down buttons labelled "Rate:". One vote per company per formula; clicking the same button again removes the vote; clicking the opposite button switches it. Cannot rate your own formulas. Library sorts by net rating (thumbs up minus thumbs down) then by import count.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace PowderCoating.Web.ViewModels.Reports;
|
||||
|
||||
public class JobProfitabilityViewModel : ReportViewModelBase
|
||||
{
|
||||
public bool TimeTrackedOnly { get; set; }
|
||||
|
||||
// ── Summary KPIs ──────────────────────────────────────────────────────
|
||||
|
||||
public int TotalJobs { get; set; }
|
||||
public int JobsWithTimeEntries { get; set; }
|
||||
public decimal TotalRevenue { get; set; }
|
||||
public decimal TotalCollected { get; set; }
|
||||
public decimal TotalEstimatedCost { get; set; }
|
||||
public decimal TotalActualHours { get; set; }
|
||||
|
||||
/// <summary>Average margin % across jobs that have at least some cost data.</summary>
|
||||
public decimal AvgMarginPercent { get; set; }
|
||||
|
||||
// ── Detail rows ───────────────────────────────────────────────────────
|
||||
|
||||
public List<JobProfitabilityItem> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class JobProfitabilityItem
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
public string JobNumber { get; set; } = string.Empty;
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string StatusName { get; set; } = string.Empty;
|
||||
public string StatusColorClass { get; set; } = "bg-secondary";
|
||||
public DateTime JobDate { get; set; }
|
||||
|
||||
/// <summary>The invoiced / final price of the job.</summary>
|
||||
public decimal FinalPrice { get; set; }
|
||||
|
||||
/// <summary>How much has actually been collected on the linked invoice.</summary>
|
||||
public decimal AmountCollected { get; set; }
|
||||
|
||||
/// <summary>Total hours logged via time entries.</summary>
|
||||
public decimal ActualHours { get; set; }
|
||||
|
||||
/// <summary>ActualHours × StandardLaborRate.</summary>
|
||||
public decimal ActualLaborCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sum of (ActualPowderUsedLbs ?? PowderToOrder) × PowderCostPerLb across all coats.
|
||||
/// Zero when no coat has pricing data.
|
||||
/// </summary>
|
||||
public decimal ActualPowderCost { get; set; }
|
||||
|
||||
public decimal EstimatedTotalCost => ActualLaborCost + ActualPowderCost;
|
||||
|
||||
public decimal GrossMargin => FinalPrice - EstimatedTotalCost;
|
||||
|
||||
public decimal MarginPercent => FinalPrice > 0
|
||||
? Math.Round(GrossMargin / FinalPrice * 100, 1)
|
||||
: 0;
|
||||
|
||||
/// <summary>True when at least one JobTimeEntry exists for this job.</summary>
|
||||
public bool HasTimeEntries { get; set; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,62 @@
|
||||
const importModal = new bootstrap.Modal(document.getElementById('importModal'));
|
||||
let currentLibraryItemId = null;
|
||||
|
||||
// ── Rating (thumbs up / down) ─────────────────────────────────────────
|
||||
document.getElementById('libraryGrid')?.addEventListener('click', function (e) {
|
||||
const voteBtn = e.target.closest('.btn-vote');
|
||||
if (voteBtn) {
|
||||
handleVote(voteBtn);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
function handleVote(btn) {
|
||||
const itemId = parseInt(btn.dataset.itemId, 10);
|
||||
const positive = btn.dataset.isPositive === 'true';
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
|
||||
btn.disabled = true;
|
||||
|
||||
fetch('/FormulaLibrary/Rate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': token,
|
||||
},
|
||||
body: JSON.stringify({ libraryItemId: itemId, isPositive: positive }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (!res.success) { showToast(res.message || 'Rating failed.', 'danger'); return; }
|
||||
|
||||
// Update counts and active-vote state on both buttons in the group
|
||||
const group = document.querySelector(`[data-rating-group="${itemId}"]`);
|
||||
if (!group) return;
|
||||
|
||||
const upBtn = group.querySelector('[data-is-positive="true"]');
|
||||
const downBtn = group.querySelector('[data-is-positive="false"]');
|
||||
|
||||
if (upBtn) upBtn.querySelector('.vote-up-count').textContent = res.thumbsUp;
|
||||
if (downBtn) downBtn.querySelector('.vote-down-count').textContent = res.thumbsDown;
|
||||
|
||||
// Apply active styles
|
||||
const myVote = res.myVote; // true / false / null
|
||||
if (upBtn) {
|
||||
upBtn.classList.toggle('btn-success', myVote === true);
|
||||
upBtn.classList.toggle('active-vote', myVote === true);
|
||||
upBtn.classList.toggle('btn-outline-secondary', myVote !== true);
|
||||
}
|
||||
if (downBtn) {
|
||||
downBtn.classList.toggle('btn-danger', myVote === false);
|
||||
downBtn.classList.toggle('active-vote', myVote === false);
|
||||
downBtn.classList.toggle('btn-outline-secondary', myVote !== false);
|
||||
}
|
||||
})
|
||||
.catch(() => showToast('Rating failed. Please try again.', 'danger'))
|
||||
.finally(() => { btn.disabled = false; });
|
||||
}
|
||||
|
||||
// ── Import modal ──────────────────────────────────────────────────────
|
||||
// Open preview modal when any "Preview & Import" button is clicked
|
||||
document.getElementById('libraryGrid')?.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('.btn-import');
|
||||
|
||||
Reference in New Issue
Block a user