Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/EquipmentController.cs
T
spouliot edd7389d7d Refactor: extract shared helpers, fix field drift, add assembly services
- IJobItemAssemblyService / IQuotePricingAssemblyService: centralize job item
  and quote pricing construction that was duplicated across create, rework copy,
  and quote-to-job conversion paths
- BlobFileHelper: single ValidateUpload/GetContentType/SanitizeFileName used by
  6 blob services (JobPhoto, QuotePhoto, ProfilePhoto, CompanyLogo, Equipment,
  Catalog) and BillsController + ExpensesController, removing 8 private copies
- PagedResult<T>.From(): static factory eliminates 6-line boilerplate in 11
  controllers (Appointments, Customers, Equipment, Inventory, Invoices, Jobs,
  Maintenance, CompanyUsers, PlatformUsers, Quotes, Vendors)
- AccountingDropdownHelper: single LoadAsync() call replaces duplicate
  vendor/account/job queries in BillsController and ExpensesController
- JobTemplateItem: add IsSalesItem + Sku fields with migration; propagate
  through JobTemplatesController snapshot copy and GetTemplatesJson projection,
  and JobsController template-application path
- Test assertions updated for standardized BlobFileHelper error messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:12:33 -04:00

605 lines
24 KiB
C#

using AutoMapper;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Equipment;
using PowderCoating.Application.DTOs.Maintenance;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanManageEquipment)]
public class EquipmentController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly IFileService _fileService; // Legacy - kept for backward compatibility
private readonly IEquipmentManualService _manualService;
private readonly ITenantContext _tenantContext;
private readonly ILogger<EquipmentController> _logger;
public EquipmentController(
IUnitOfWork unitOfWork,
IMapper mapper,
IFileService fileService,
IEquipmentManualService manualService,
ITenantContext tenantContext,
ILogger<EquipmentController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_fileService = fileService;
_manualService = manualService;
_tenantContext = tenantContext;
_logger = logger;
}
/// <summary>
/// Displays the paginated equipment list with optional keyword search and status filter.
/// Search covers name, equipment number, serial number, manufacturer, and model so
/// shop staff can find a piece of equipment using any identifier they have on hand.
/// Sorting is resolved server-side via a switch expression so column names never reach
/// raw SQL.
/// </summary>
public async Task<IActionResult> Index(
string? searchTerm,
EquipmentStatus? statusFilter,
string? sortColumn,
string sortDirection = "asc",
int pageNumber = 1,
int pageSize = 25)
{
try
{
// Create and validate grid request
var gridRequest = new GridRequest
{
PageNumber = pageNumber,
PageSize = pageSize,
SortColumn = sortColumn ?? "Name",
SortDirection = sortDirection,
SearchTerm = searchTerm
};
gridRequest.Validate();
// Build search and status filter
System.Linq.Expressions.Expression<Func<Equipment, bool>>? filter = null;
if (!string.IsNullOrWhiteSpace(searchTerm) && statusFilter.HasValue)
{
// Both search and status filter
var search = searchTerm.ToLower();
var status = statusFilter.Value;
filter = e => (e.EquipmentName.ToLower().Contains(search)
|| (e.EquipmentNumber != null && e.EquipmentNumber.ToLower().Contains(search))
|| (e.SerialNumber != null && e.SerialNumber.ToLower().Contains(search))
|| (e.Manufacturer != null && e.Manufacturer.ToLower().Contains(search))
|| (e.Model != null && e.Model.ToLower().Contains(search)))
&& e.Status == status;
}
else if (!string.IsNullOrWhiteSpace(searchTerm))
{
// Search only
var search = searchTerm.ToLower();
filter = e => e.EquipmentName.ToLower().Contains(search)
|| (e.EquipmentNumber != null && e.EquipmentNumber.ToLower().Contains(search))
|| (e.SerialNumber != null && e.SerialNumber.ToLower().Contains(search))
|| (e.Manufacturer != null && e.Manufacturer.ToLower().Contains(search))
|| (e.Model != null && e.Model.ToLower().Contains(search));
}
else if (statusFilter.HasValue)
{
// Status filter only
var status = statusFilter.Value;
filter = e => e.Status == status;
}
// Build orderBy function
Func<IQueryable<Equipment>, IOrderedQueryable<Equipment>> orderBy = gridRequest.SortColumn switch
{
"Name" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(e => e.EquipmentName) : q.OrderByDescending(e => e.EquipmentName),
"EquipmentCode" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(e => e.EquipmentNumber) : q.OrderByDescending(e => e.EquipmentNumber),
"Status" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(e => e.Status) : q.OrderByDescending(e => e.Status),
"PurchaseDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(e => e.PurchaseDate) : q.OrderByDescending(e => e.PurchaseDate),
"NextMaintenanceDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(e => e.NextScheduledMaintenance) : q.OrderByDescending(e => e.NextScheduledMaintenance),
_ => q => q.OrderBy(e => e.EquipmentName)
};
// Get paged data
var (items, totalCount) = await _unitOfWork.Equipment.GetPagedAsync(
gridRequest.PageNumber,
gridRequest.PageSize,
filter,
orderBy);
// Map to DTOs
var equipmentDtos = _mapper.Map<List<EquipmentListDto>>(items);
var pagedResult = PagedResult<EquipmentListDto>.From(gridRequest, equipmentDtos, totalCount);
// Set ViewBag for sorting and filters
ViewBag.SearchTerm = searchTerm;
ViewBag.StatusFilter = statusFilter;
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection;
return View(pagedResult);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving equipment");
TempData["Error"] = "An error occurred while loading equipment.";
return View(new PagedResult<EquipmentListDto>());
}
}
/// <summary>
/// Renders the equipment detail page. Maintenance history is loaded via a separate
/// FindAsync call (not eager loading on the Equipment entity) and sorted descending
/// by ScheduledDate so the most recent maintenance tasks appear first. This gives
/// technicians an at-a-glance view of the equipment's service history.
/// </summary>
public async Task<IActionResult> Details(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var equipment = await _unitOfWork.Equipment.GetByIdAsync(id.Value);
if (equipment == null)
{
return NotFound();
}
var equipmentDto = _mapper.Map<EquipmentDto>(equipment);
// Load maintenance history
var maintenanceRecords = await _unitOfWork.MaintenanceRecords
.FindAsync(m => m.EquipmentId == id.Value);
var maintenanceList = _mapper.Map<List<MaintenanceListDto>>(maintenanceRecords.OrderByDescending(m => m.ScheduledDate));
ViewBag.MaintenanceHistory = maintenanceList;
return View(equipmentDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving equipment {EquipmentId}", id);
TempData["Error"] = "An error occurred while loading the equipment.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Renders the equipment creation form, defaulting status to Operational because new
/// equipment entering the shop is presumed ready to use until a maintenance issue
/// is logged. The full EquipmentStatus enum is passed to the view for the status
/// dropdown so adding new statuses to the enum automatically appears in the form.
/// </summary>
public IActionResult Create()
{
var dto = new CreateEquipmentDto
{
Status = EquipmentStatus.Operational.ToString()
};
ViewBag.StatusList = Enum.GetValues<EquipmentStatus>();
return View(dto);
}
/// <summary>
/// Persists a new equipment record and sets IsActive to true. The equipment starts
/// with no manual file; manuals are uploaded separately via <see cref="UploadManual"/>
/// to avoid coupling file I/O to the core create transaction.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateEquipmentDto dto)
{
if (!ModelState.IsValid)
{
ViewBag.StatusList = Enum.GetValues<EquipmentStatus>();
return View(dto);
}
try
{
var equipment = _mapper.Map<Equipment>(dto);
equipment.CreatedAt = DateTime.UtcNow;
equipment.IsActive = true;
await _unitOfWork.Equipment.AddAsync(equipment);
await _unitOfWork.SaveChangesAsync();
TempData["Success"] = "Equipment created successfully.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating equipment");
TempData["Error"] = "An error occurred while creating the equipment.";
ViewBag.StatusList = Enum.GetValues<EquipmentStatus>();
return View(dto);
}
}
/// <summary>
/// Renders the equipment edit form, pre-populated via AutoMapper. The status dropdown
/// is reloaded from the enum so it always reflects any additions to <see cref="EquipmentStatus"/>.
/// </summary>
public async Task<IActionResult> Edit(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var equipment = await _unitOfWork.Equipment.GetByIdAsync(id.Value);
if (equipment == null)
{
return NotFound();
}
var dto = _mapper.Map<UpdateEquipmentDto>(equipment);
ViewBag.StatusList = Enum.GetValues<EquipmentStatus>();
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving equipment {EquipmentId} for edit", id);
TempData["Error"] = "An error occurred while loading the equipment.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Persists edits to an existing equipment record. The Notes field is captured before
/// AutoMapper runs and restored if the submitted dto.Notes is blank, because the Notes
/// field doubles as legacy file metadata storage (the original upload path was stored
/// there). This prevents an Edit save from silently erasing a manual reference that
/// was uploaded before the dedicated ManualFilePath column existed.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdateEquipmentDto dto)
{
if (id != dto.Id)
{
return NotFound();
}
if (!ModelState.IsValid)
{
ViewBag.StatusList = Enum.GetValues<EquipmentStatus>();
return View(dto);
}
try
{
var equipment = await _unitOfWork.Equipment.GetByIdAsync(id);
if (equipment == null)
{
return NotFound();
}
// Preserve Notes field (contains file metadata)
var preservedNotes = equipment.Notes;
_mapper.Map(dto, equipment);
// If dto.Notes is empty, restore the preserved notes (file metadata)
if (string.IsNullOrWhiteSpace(dto.Notes))
{
equipment.Notes = preservedNotes;
}
equipment.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Equipment.UpdateAsync(equipment);
await _unitOfWork.SaveChangesAsync();
TempData["Success"] = "Equipment updated successfully.";
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating equipment {EquipmentId}", id);
TempData["Error"] = "An error occurred while updating the equipment.";
ViewBag.StatusList = Enum.GetValues<EquipmentStatus>();
return View(dto);
}
}
/// <summary>
/// Renders the equipment delete confirmation page, showing a full summary so the user
/// can verify they are removing the correct piece of equipment before confirming.
/// </summary>
public async Task<IActionResult> Delete(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var equipment = await _unitOfWork.Equipment.GetByIdAsync(id.Value);
if (equipment == null)
{
return NotFound();
}
var equipmentDto = _mapper.Map<EquipmentDto>(equipment);
return View(equipmentDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving equipment {EquipmentId} for delete", id);
TempData["Error"] = "An error occurred while loading the equipment.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Soft-deletes the equipment record and cleans up any associated manual file from
/// both the new filesystem storage (ManualFilePath via <see cref="IEquipmentManualService"/>)
/// and the legacy uploads folder (Notes field starting with "uploads/"). File cleanup
/// runs before the soft delete so that orphaned files on disk are not left behind if
/// the delete succeeds.
/// </summary>
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
try
{
var equipment = await _unitOfWork.Equipment.GetByIdAsync(id);
if (equipment == null)
{
return NotFound();
}
// Delete associated manual file if exists (new filesystem storage)
if (!string.IsNullOrEmpty(equipment.ManualFilePath))
{
await _manualService.DeleteEquipmentManualAsync(equipment.ManualFilePath);
}
// Delete associated manual file if exists (legacy uploads folder)
else if (!string.IsNullOrWhiteSpace(equipment.Notes) && equipment.Notes.StartsWith("uploads/"))
{
await _fileService.DeleteFileAsync(equipment.Notes);
}
await _unitOfWork.Equipment.SoftDeleteAsync(equipment);
await _unitOfWork.SaveChangesAsync();
TempData["Success"] = "Equipment deleted successfully.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting equipment {EquipmentId}", id);
TempData["Error"] = "An error occurred while deleting the equipment.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Accepts an equipment manual file upload and saves it to the filesystem via
/// <see cref="IEquipmentManualService"/>. If a manual already exists (either in the
/// new ManualFilePath column or in the legacy Notes/uploads path), it is deleted first
/// to prevent orphaned files accumulating on disk. Returns JSON { success, message,
/// fileName } so the Details page can update the manual section without a full reload.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadManual(int equipmentId, IFormFile manualFile)
{
try
{
if (manualFile == null || manualFile.Length == 0)
{
return Json(new { success = false, message = "No file was uploaded." });
}
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
return Json(new { success = false, message = "User does not have a company ID." });
}
var equipment = await _unitOfWork.Equipment.GetByIdAsync(equipmentId);
if (equipment == null)
{
return Json(new { success = false, message = "Equipment not found." });
}
// Delete old manual if exists (new filesystem storage)
if (!string.IsNullOrEmpty(equipment.ManualFilePath))
{
await _manualService.DeleteEquipmentManualAsync(equipment.ManualFilePath);
}
// Delete old manual if exists (legacy uploads folder)
else if (!string.IsNullOrWhiteSpace(equipment.Notes) && equipment.Notes.StartsWith("uploads/"))
{
await _fileService.DeleteFileAsync(equipment.Notes);
equipment.Notes = null; // Clear legacy field
}
// Save new file to filesystem
var (success, filePath, errorMessage) = await _manualService.SaveEquipmentManualAsync(
manualFile,
companyId.Value,
equipmentId);
if (!success)
{
return Json(new { success = false, message = errorMessage });
}
// Update equipment record
equipment.ManualFilePath = filePath;
equipment.ManualFileName = manualFile.FileName;
equipment.ManualFileSize = manualFile.Length;
equipment.ManualContentType = manualFile.ContentType;
equipment.ManualUploadedDate = DateTime.UtcNow;
equipment.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Equipment.UpdateAsync(equipment);
await _unitOfWork.SaveChangesAsync();
_logger.LogInformation("Equipment {EquipmentId} manual uploaded to filesystem", equipmentId);
return Json(new
{
success = true,
message = "Manual uploaded successfully.",
fileName = manualFile.FileName
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading manual for equipment {EquipmentId}", equipmentId);
return Json(new { success = false, message = "An error occurred while uploading the file." });
}
}
/// <summary>
/// Streams the equipment manual file back to the browser as a file download. Checks
/// the new ManualFilePath column first, then falls back to the legacy Notes/uploads
/// path for equipment whose manuals were uploaded before the ManualFilePath column
/// was added. For legacy files, the GUID prefix (added by the old upload service) is
/// stripped from the download filename so the user sees the original filename.
/// </summary>
public async Task<IActionResult> DownloadManual(int id)
{
try
{
var equipment = await _unitOfWork.Equipment.GetByIdAsync(id);
if (equipment == null)
{
return NotFound();
}
// Try new filesystem storage first
if (!string.IsNullOrEmpty(equipment.ManualFilePath))
{
var (success, fileContent, contentType, errorMessage) = await _manualService.GetEquipmentManualAsync(equipment.ManualFilePath);
if (success)
{
var fileName = equipment.ManualFileName ?? Path.GetFileName(equipment.ManualFilePath);
return File(fileContent, contentType, fileName);
}
TempData["Error"] = errorMessage;
return RedirectToAction(nameof(Details), new { id });
}
// Fallback to legacy storage (uploads folder)
if (!string.IsNullOrWhiteSpace(equipment.Notes) && equipment.Notes.StartsWith("uploads/"))
{
var result = await _fileService.GetFileAsync(equipment.Notes);
if (!result.Success)
{
TempData["Error"] = result.ErrorMessage;
return RedirectToAction(nameof(Details), new { id });
}
var fileName = Path.GetFileName(equipment.Notes);
// Remove GUID prefix from filename for download
if (fileName.Length > 37 && fileName[36] == '-')
{
fileName = fileName.Substring(37);
}
return File(result.FileContent, result.ContentType, fileName);
}
return NotFound();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error downloading manual for equipment {EquipmentId}", id);
TempData["Error"] = "An error occurred while downloading the file.";
return RedirectToAction(nameof(Details), new { id });
}
}
/// <summary>
/// Deletes the equipment manual from disk and clears the manual metadata fields on the
/// equipment entity. Handles both the current filesystem storage path (ManualFilePath)
/// and the legacy Notes/uploads path so that manuals uploaded under either storage
/// scheme can be removed without leaving orphaned files. Returns JSON so the Details
/// page can remove the download link without a full reload.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteManual(int equipmentId)
{
try
{
var equipment = await _unitOfWork.Equipment.GetByIdAsync(equipmentId);
if (equipment == null)
{
return Json(new { success = false, message = "Equipment not found." });
}
// Check if manual exists
if (string.IsNullOrEmpty(equipment.ManualFilePath) && string.IsNullOrWhiteSpace(equipment.Notes))
{
return Json(new { success = false, message = "No manual found." });
}
// Delete from new filesystem storage if exists
if (!string.IsNullOrEmpty(equipment.ManualFilePath))
{
var (success, errorMessage) = await _manualService.DeleteEquipmentManualAsync(equipment.ManualFilePath);
if (!success)
{
return Json(new { success = false, message = errorMessage });
}
equipment.ManualFilePath = null;
equipment.ManualFileName = null;
equipment.ManualFileSize = null;
equipment.ManualContentType = null;
equipment.ManualUploadedDate = null;
}
// Delete from legacy storage if exists
else if (!string.IsNullOrWhiteSpace(equipment.Notes) && equipment.Notes.StartsWith("uploads/"))
{
var result = await _fileService.DeleteFileAsync(equipment.Notes);
if (!result.Success)
{
return Json(new { success = false, message = result.ErrorMessage });
}
equipment.Notes = null;
}
equipment.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Equipment.UpdateAsync(equipment);
await _unitOfWork.SaveChangesAsync();
_logger.LogInformation("Equipment {EquipmentId} manual deleted", equipmentId);
return Json(new { success = true, message = "Manual deleted successfully." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting manual for equipment {EquipmentId}", equipmentId);
return Json(new { success = false, message = "An error occurred while deleting the file." });
}
}
}