using Microsoft.AspNetCore.Authorization; using PowderCoating.Shared.Constants; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using PowderCoating.Core.Entities; using PowderCoating.Core.Interfaces; namespace PowderCoating.Web.Controllers; /// /// 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 . A UsageCount 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. /// [Authorize(Policy = AppConstants.Policies.CanManageJobs)] public class JobTemplatesController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly ITenantContext _tenantContext; public JobTemplatesController( IUnitOfWork unitOfWork, ITenantContext tenantContext) { _unitOfWork = unitOfWork; _tenantContext = tenantContext; } /// /// Displays all non-deleted job templates for the current company, ordered by name, with their /// linked customer and item counts. Multi-tenancy and soft-delete scoping are handled by global /// query filters; the typed repository provides the ThenInclude chain for items. /// public async Task Index() { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var templates = await _unitOfWork.JobTemplates.FindAsync( t => t.CompanyId == companyId, false, t => t.Customer, t => t.Items); templates = templates.OrderBy(t => t.Name).ToList(); return View(templates); } /// /// 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 filtered includes in the typed repository. /// public async Task Details(int id) { var template = await _unitOfWork.JobTemplates.LoadForDetailsAsync(id); if (template == null) return NotFound(); return View(template); } /// /// 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. /// public async Task Edit(int id) { var template = await _unitOfWork.JobTemplates.GetByIdAsync(id); if (template == null) return NotFound(); await PopulateCustomerDropdown(template.CustomerId); return View(template); } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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 }); } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Delete(int id) { await _unitOfWork.JobTemplates.SoftDeleteAsync(id); await _unitOfWork.CompleteAsync(); TempData["Success"] = "Template deleted."; return RedirectToAction(nameof(Index)); } /// /// 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 JobTemplateItemId as a FK. /// This is why CompleteAsync() is called inside the item loop rather than once at the end. /// The source job's CustomerId and SpecialInstructions are copied to the template /// so that the template can pre-fill those fields when used; they can be overridden at job /// creation time. /// [HttpPost] [ValidateAntiForgeryToken] public async Task SaveJobAsTemplate(int jobId, string templateName, string? templateDescription) { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var job = await _unitOfWork.Jobs.LoadForTemplateSnapshotAsync(jobId); 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, IsSalesItem = item.IsSalesItem, Sku = item.Sku, 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 }); } /// /// 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. /// [HttpGet] public async Task GetTemplatesJson() { var templates = await _unitOfWork.JobTemplates.GetAllActiveWithFullIncludesAsync(); 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, isSalesItem = i.IsSalesItem, sku = i.Sku, 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); } /// /// Increments the UsageCount 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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(); } /// /// Populates ViewBag.Customers with a 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. /// 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); } }