Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/JobTemplatesController.cs
T
2026-04-23 21:38:24 -04:00

359 lines
15 KiB
C#

using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Manages reusable job templates that pre-populate job items, coats, and prep services when a
/// new job is created. Templates are created either from scratch (Edit form) or by snapshotting an
/// existing job via <see cref="SaveJobAsTemplate"/>. A <c>UsageCount</c> field tracks how often
/// each template is applied, allowing admins to identify the most-used service configurations.
/// Template items store the same coating and prep-service structure as live job items, so the
/// full item wizard state can be replayed client-side on job creation without additional queries.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public class JobTemplatesController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly ApplicationDbContext _context;
public JobTemplatesController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
ApplicationDbContext context)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_context = context;
}
/// <summary>
/// Displays all non-deleted job templates for the current company, ordered by name, with their
/// linked customer and item counts. Uses the direct <c>_context</c> query (bypassing
/// <c>IUnitOfWork</c>) to leverage EF Core's filtered includes (<c>.Include(t => t.Items)</c>)
/// which are not exposed through the generic repository pattern.
/// </summary>
public async Task<IActionResult> Index()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var templates = await _context.JobTemplates
.Include(t => t.Customer)
.Include(t => t.Items)
.Where(t => !t.IsDeleted && t.CompanyId == companyId)
.OrderBy(t => t.Name)
.ToListAsync();
return View(templates);
}
/// <summary>
/// Shows the full template detail including all non-deleted items, each with their coats
/// (including the linked inventory item for color/powder info) and prep services (including the
/// prep service entity for the service name). Soft-deleted items, coats, and prep services are
/// excluded via EF filtered includes so the view reflects the current active configuration.
/// </summary>
public async Task<IActionResult> Details(int id)
{
var template = await _context.JobTemplates
.Include(t => t.Customer)
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.ThenInclude(c => c.InventoryItem)
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
.ThenInclude(p => p.PrepService)
.FirstOrDefaultAsync(t => t.Id == id && !t.IsDeleted);
if (template == null) return NotFound();
return View(template);
}
/// <summary>
/// Renders the Edit form for a template's header fields (name, description, linked customer,
/// special instructions, active flag). Template items are managed separately via the item wizard
/// on the job creation page; only the header metadata is editable here to keep the form simple.
/// </summary>
public async Task<IActionResult> Edit(int id)
{
var template = await _context.JobTemplates
.FirstOrDefaultAsync(t => t.Id == id && !t.IsDeleted);
if (template == null) return NotFound();
await PopulateCustomerDropdown(template.CustomerId);
return View(template);
}
/// <summary>
/// Saves changes to a template's header fields. Uses individual scalar parameters instead of a
/// DTO because only a handful of simple fields are editable here; a dedicated DTO would add
/// boilerplate for minimal benefit given the small scope of this form.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, string name, string? description, int? customerId, string? specialInstructions, bool isActive)
{
var template = await _unitOfWork.JobTemplates.GetByIdAsync(id);
if (template == null) return NotFound();
template.Name = name;
template.Description = description;
template.CustomerId = customerId;
template.SpecialInstructions = specialInstructions;
template.IsActive = isActive;
template.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.JobTemplates.UpdateAsync(template);
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Template updated successfully.";
return RedirectToAction(nameof(Details), new { id });
}
/// <summary>
/// Soft-deletes a job template. Existing jobs created from this template are unaffected because
/// job items are copied at creation time (not linked by FK), so removing the template does not
/// alter any live jobs.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
await _unitOfWork.JobTemplates.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Template deleted.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Snapshots an existing job into a new reusable template by deep-copying its items, coats, and
/// prep services. Each template item is saved and immediately given an ID before its coats and
/// prep services are inserted, because the child records need <c>JobTemplateItemId</c> as a FK.
/// This is why <c>CompleteAsync()</c> is called inside the item loop rather than once at the end.
/// The source job's <c>CustomerId</c> and <c>SpecialInstructions</c> are copied to the template
/// so that the template can pre-fill those fields when used; they can be overridden at job
/// creation time.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveJobAsTemplate(int jobId, string templateName, string? templateDescription)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var job = await _context.Jobs
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
.FirstOrDefaultAsync(j => j.Id == jobId && !j.IsDeleted);
if (job == null) return NotFound();
var template = new JobTemplate
{
Name = templateName.Trim(),
Description = templateDescription?.Trim(),
CustomerId = job.CustomerId,
SpecialInstructions = job.SpecialInstructions,
IsActive = true,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.JobTemplates.AddAsync(template);
await _unitOfWork.CompleteAsync();
// Copy items
int displayOrder = 1;
foreach (var item in job.JobItems.OrderBy(i => i.Id))
{
var templateItem = new JobTemplateItem
{
JobTemplateId = template.Id,
Description = item.Description,
Quantity = item.Quantity,
SurfaceAreaSqFt = item.SurfaceAreaSqFt,
CatalogItemId = item.CatalogItemId,
IsGenericItem = item.IsGenericItem,
IsLaborItem = item.IsLaborItem,
ManualUnitPrice = item.ManualUnitPrice,
RequiresSandblasting = item.RequiresSandblasting,
RequiresMasking = item.RequiresMasking,
IncludePrepCost = item.IncludePrepCost,
EstimatedMinutes = item.EstimatedMinutes,
Complexity = item.Complexity,
Notes = item.Notes,
DisplayOrder = displayOrder++,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.JobTemplateItems.AddAsync(templateItem);
await _unitOfWork.CompleteAsync();
// Copy coats
foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
{
await _unitOfWork.JobTemplateItemCoats.AddAsync(new JobTemplateItemCoat
{
JobTemplateItemId = templateItem.Id,
CoatName = coat.CoatName,
Sequence = coat.Sequence,
InventoryItemId = coat.InventoryItemId,
ColorName = coat.ColorName,
VendorId = coat.VendorId,
ColorCode = coat.ColorCode,
Finish = coat.Finish,
CoverageSqFtPerLb = coat.CoverageSqFtPerLb,
TransferEfficiency = coat.TransferEfficiency,
PowderCostPerLb = coat.PowderCostPerLb,
Notes = coat.Notes,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
});
}
// Copy prep services
foreach (var prep in item.PrepServices)
{
await _unitOfWork.JobTemplateItemPrepServices.AddAsync(new JobTemplateItemPrepService
{
JobTemplateItemId = templateItem.Id,
PrepServiceId = prep.PrepServiceId,
EstimatedMinutes = prep.EstimatedMinutes,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
});
}
}
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Template \"{template.Name}\" saved successfully.";
return RedirectToAction(nameof(Details), new { id = template.Id });
}
/// <summary>
/// AJAX endpoint that returns all active templates for the current company as JSON, fully
/// expanded with items, coats, and prep services. This payload is consumed by the job creation
/// page's JavaScript to hydrate the item wizard when the user selects a template, so it must
/// contain every field the wizard needs to reconstruct the full item state client-side without
/// additional round-trips. Only active templates are returned so deactivated templates are no
/// longer selectable on new jobs.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetTemplatesJson()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var templates = await _context.JobTemplates
.Include(t => t.Customer)
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
.ThenInclude(p => p.PrepService)
.Where(t => !t.IsDeleted && t.CompanyId == companyId && t.IsActive)
.OrderBy(t => t.Name)
.ToListAsync();
var result = templates.Select(t => new
{
id = t.Id,
name = t.Name,
description = t.Description,
customerId = t.CustomerId,
customerName = t.Customer != null
? (t.Customer.CompanyName ?? $"{t.Customer.ContactFirstName} {t.Customer.ContactLastName}".Trim())
: null,
specialInstructions = t.SpecialInstructions,
usageCount = t.UsageCount,
items = t.Items.OrderBy(i => i.DisplayOrder).Select(i => new
{
description = i.Description,
quantity = i.Quantity,
surfaceAreaSqFt = i.SurfaceAreaSqFt,
catalogItemId = i.CatalogItemId,
isGenericItem = i.IsGenericItem,
isLaborItem = i.IsLaborItem,
manualUnitPrice = i.ManualUnitPrice,
requiresSandblasting = i.RequiresSandblasting,
requiresMasking = i.RequiresMasking,
includePrepCost = i.IncludePrepCost,
estimatedMinutes = i.EstimatedMinutes,
complexity = i.Complexity,
coats = i.Coats.OrderBy(c => c.Sequence).Select(c => new
{
coatName = c.CoatName,
sequence = c.Sequence,
inventoryItemId = c.InventoryItemId,
colorName = c.ColorName,
colorCode = c.ColorCode,
finish = c.Finish,
coverageSqFtPerLb = c.CoverageSqFtPerLb,
transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb
}),
prepServices = i.PrepServices.Select(p => new
{
prepServiceId = p.PrepServiceId,
prepServiceName = p.PrepService?.ServiceName,
estimatedMinutes = p.EstimatedMinutes
})
})
});
return Json(result);
}
/// <summary>
/// Increments the <c>UsageCount</c> on a template when a job is created from it. Called via
/// AJAX from the job creation page immediately after the job is saved. Silently succeeds even
/// if the template no longer exists (soft-deleted between page load and job save) to avoid
/// blocking the job creation flow over a non-critical analytics update.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> IncrementUsage(int id)
{
var template = await _unitOfWork.JobTemplates.GetByIdAsync(id);
if (template != null)
{
template.UsageCount++;
template.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.JobTemplates.UpdateAsync(template);
await _unitOfWork.CompleteAsync();
}
return Ok();
}
/// <summary>
/// Populates <c>ViewBag.Customers</c> with a <see cref="SelectList"/> of active customers in the
/// current company, used by the Edit form's optional customer association. The customer link on a
/// template pre-fills the customer field on new jobs created from that template, but it is
/// optional — templates representing generic services (e.g. "Standard Wheel Coat") are not tied
/// to any specific customer.
/// </summary>
private async Task PopulateCustomerDropdown(int? selectedId = null)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = await _unitOfWork.Customers.FindAsync(
c => c.CompanyId == companyId && !c.IsDeleted);
ViewBag.Customers = new SelectList(
customers.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
.Select(c => new { c.Id, Name = c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim() }),
"Id", "Name", selectedId);
}
}