Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/FormulaLibraryController.cs
T
spouliot ed35362c7a 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>
2026-06-01 09:02:07 -04:00

143 lines
5.2 KiB
C#

using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Community formula library — browse published formulas from all companies and import
/// them into the current company's local template list.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanViewData)]
public class FormulaLibraryController : Controller
{
private readonly IFormulaLibraryService _libraryService;
private readonly ITenantContext _tenantContext;
private readonly IMapper _mapper;
private readonly IAzureBlobStorageService _blobStorage;
public FormulaLibraryController(
IFormulaLibraryService libraryService,
ITenantContext tenantContext,
IMapper mapper,
IAzureBlobStorageService blobStorage)
{
_libraryService = libraryService;
_tenantContext = tenantContext;
_mapper = mapper;
_blobStorage = blobStorage;
}
/// <summary>Browse the community library with optional search and filter params.</summary>
// GET: /FormulaLibrary
public async Task<IActionResult> Index(
string? search = null,
string? outputMode = null,
string? industryHint = null)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return RedirectToAction("Index", "Home");
var items = await _libraryService.BrowseAsync(companyId.Value, search, outputMode, industryHint);
ViewBag.Search = search;
ViewBag.OutputMode = outputMode;
ViewBag.IndustryHint = industryHint;
ViewBag.TotalCount = items.Count();
return View(items);
}
/// <summary>Returns full detail JSON for the import preview modal.</summary>
// GET: /FormulaLibrary/Detail/5
[HttpGet]
public async Task<IActionResult> Detail(int id)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new { error = "No company context." });
var detail = await _libraryService.GetDetailAsync(id, companyId.Value);
if (detail == null) return NotFound();
return Json(detail);
}
/// <summary>
/// Serves a formula diagram image by blob storage path. Used for library cards where the
/// diagram belongs to another company's template blob container.
/// </summary>
// GET: /FormulaLibrary/Diagram?path=...
[HttpGet]
public async Task<IActionResult> Diagram(string path)
{
if (string.IsNullOrWhiteSpace(path)) return NotFound();
// Sanitize: path must not escape the blob container
if (path.Contains("..") || path.StartsWith("/") || path.StartsWith("\\"))
return BadRequest();
var (ok, bytes, contentType, _) = await _blobStorage.DownloadAsync("formulatemplate-diagrams", path);
if (!ok || bytes == null || bytes.Length == 0) return NotFound();
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]
[ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> Import(int libraryItemId)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "No company context." });
try
{
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
var templateId = await _libraryService.ImportAsync(libraryItemId, companyId.Value, userId);
return Json(new { success = true, templateId });
}
catch (Exception ex)
{
return Json(new { success = false, message = ex.Message });
}
}
}
/// <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; }
}