Initial commit
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user