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 _logger; public EquipmentController( IUnitOfWork unitOfWork, IMapper mapper, IFileService fileService, IEquipmentManualService manualService, ITenantContext tenantContext, ILogger logger) { _unitOfWork = unitOfWork; _mapper = mapper; _fileService = fileService; _manualService = manualService; _tenantContext = tenantContext; _logger = logger; } /// /// 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. /// public async Task 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>? 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, IOrderedQueryable> 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>(items); // Create paged result var pagedResult = new PagedResult { Items = equipmentDtos, PageNumber = gridRequest.PageNumber, PageSize = gridRequest.PageSize, TotalCount = 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()); } } /// /// 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. /// public async Task 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(equipment); // Load maintenance history var maintenanceRecords = await _unitOfWork.MaintenanceRecords .FindAsync(m => m.EquipmentId == id.Value); var maintenanceList = _mapper.Map>(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)); } } /// /// 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. /// public IActionResult Create() { var dto = new CreateEquipmentDto { Status = EquipmentStatus.Operational.ToString() }; ViewBag.StatusList = Enum.GetValues(); return View(dto); } /// /// Persists a new equipment record and sets IsActive to true. The equipment starts /// with no manual file; manuals are uploaded separately via /// to avoid coupling file I/O to the core create transaction. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Create(CreateEquipmentDto dto) { if (!ModelState.IsValid) { ViewBag.StatusList = Enum.GetValues(); return View(dto); } try { var equipment = _mapper.Map(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(); return View(dto); } } /// /// Renders the equipment edit form, pre-populated via AutoMapper. The status dropdown /// is reloaded from the enum so it always reflects any additions to . /// public async Task 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(equipment); ViewBag.StatusList = Enum.GetValues(); 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)); } } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Edit(int id, UpdateEquipmentDto dto) { if (id != dto.Id) { return NotFound(); } if (!ModelState.IsValid) { ViewBag.StatusList = Enum.GetValues(); 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(); return View(dto); } } /// /// 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. /// public async Task 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(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)); } } /// /// Soft-deletes the equipment record and cleans up any associated manual file from /// both the new filesystem storage (ManualFilePath via ) /// 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. /// [HttpPost, ActionName("Delete")] [ValidateAntiForgeryToken] public async Task 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)); } } /// /// Accepts an equipment manual file upload and saves it to the filesystem via /// . 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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." }); } } /// /// 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. /// public async Task 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 }); } } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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." }); } } }