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