Initial commit
This commit is contained in:
@@ -0,0 +1,760 @@
|
||||
using AutoMapper;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Maintenance;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageMaintenance)]
|
||||
public class MaintenanceController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ILogger<MaintenanceController> _logger;
|
||||
|
||||
public MaintenanceController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IMapper mapper,
|
||||
ILogger<MaintenanceController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays the paginated maintenance record list with optional filters for equipment,
|
||||
/// keyword search, status, pending-only (Scheduled/InProgress/Overdue), and
|
||||
/// upcoming-only (due within 7 days or already overdue). The filter combinations are
|
||||
/// built via explicit lambda expressions rather than dynamic LINQ so the EF query
|
||||
/// can be translated to parameterized SQL without string concatenation risks.
|
||||
/// Equipment is eagerly loaded so the equipment name is available in the list without
|
||||
/// a secondary query per row.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index(
|
||||
int? equipmentId,
|
||||
string? searchTerm,
|
||||
MaintenanceStatus? statusFilter,
|
||||
string? sortColumn,
|
||||
string sortDirection = "asc",
|
||||
bool pendingOnly = false,
|
||||
bool upcomingOnly = false,
|
||||
int pageNumber = 1,
|
||||
int pageSize = 25)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Create and validate grid request
|
||||
var gridRequest = new GridRequest
|
||||
{
|
||||
PageNumber = pageNumber,
|
||||
PageSize = pageSize,
|
||||
SortColumn = sortColumn ?? "ScheduledDate",
|
||||
SortDirection = sortColumn == null ? "asc" : sortDirection,
|
||||
SearchTerm = searchTerm
|
||||
};
|
||||
gridRequest.Validate();
|
||||
|
||||
var today = DateTime.Today;
|
||||
var lookAhead = today.AddDays(7);
|
||||
|
||||
// Build filter expression
|
||||
System.Linq.Expressions.Expression<Func<MaintenanceRecord, bool>>? filter = null;
|
||||
|
||||
if (upcomingOnly)
|
||||
{
|
||||
filter = m => (m.Status == MaintenanceStatus.Scheduled
|
||||
|| m.Status == MaintenanceStatus.InProgress
|
||||
|| m.Status == MaintenanceStatus.Overdue)
|
||||
&& (m.Status == MaintenanceStatus.Overdue || m.ScheduledDate <= lookAhead);
|
||||
}
|
||||
else if (pendingOnly)
|
||||
{
|
||||
filter = m => m.Status == MaintenanceStatus.Scheduled
|
||||
|| m.Status == MaintenanceStatus.InProgress
|
||||
|| m.Status == MaintenanceStatus.Overdue;
|
||||
}
|
||||
else if (equipmentId.HasValue && !string.IsNullOrWhiteSpace(searchTerm) && statusFilter.HasValue)
|
||||
{
|
||||
var search = searchTerm.ToLower();
|
||||
var status = statusFilter.Value;
|
||||
var eqId = equipmentId.Value;
|
||||
filter = m => m.EquipmentId == eqId
|
||||
&& (m.MaintenanceType.ToLower().Contains(search)
|
||||
|| (m.Description != null && m.Description.ToLower().Contains(search))
|
||||
|| (m.PerformedById != null && m.PerformedById.ToLower().Contains(search)))
|
||||
&& m.Status == status;
|
||||
}
|
||||
else if (equipmentId.HasValue && !string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var search = searchTerm.ToLower();
|
||||
var eqId = equipmentId.Value;
|
||||
filter = m => m.EquipmentId == eqId
|
||||
&& (m.MaintenanceType.ToLower().Contains(search)
|
||||
|| (m.Description != null && m.Description.ToLower().Contains(search))
|
||||
|| (m.PerformedById != null && m.PerformedById.ToLower().Contains(search)));
|
||||
}
|
||||
else if (equipmentId.HasValue && statusFilter.HasValue)
|
||||
{
|
||||
var eqId = equipmentId.Value;
|
||||
var status = statusFilter.Value;
|
||||
filter = m => m.EquipmentId == eqId && m.Status == status;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(searchTerm) && statusFilter.HasValue)
|
||||
{
|
||||
var search = searchTerm.ToLower();
|
||||
var status = statusFilter.Value;
|
||||
filter = m => (m.MaintenanceType.ToLower().Contains(search)
|
||||
|| (m.Description != null && m.Description.ToLower().Contains(search))
|
||||
|| (m.PerformedById != null && m.PerformedById.ToLower().Contains(search)))
|
||||
&& m.Status == status;
|
||||
}
|
||||
else if (equipmentId.HasValue)
|
||||
{
|
||||
var eqId = equipmentId.Value;
|
||||
filter = m => m.EquipmentId == eqId;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var search = searchTerm.ToLower();
|
||||
filter = m => m.MaintenanceType.ToLower().Contains(search)
|
||||
|| (m.Description != null && m.Description.ToLower().Contains(search))
|
||||
|| (m.PerformedById != null && m.PerformedById.ToLower().Contains(search));
|
||||
}
|
||||
else if (statusFilter.HasValue)
|
||||
{
|
||||
var status = statusFilter.Value;
|
||||
filter = m => m.Status == status;
|
||||
}
|
||||
|
||||
// Build orderBy function
|
||||
Func<IQueryable<MaintenanceRecord>, IOrderedQueryable<MaintenanceRecord>> orderBy = gridRequest.SortColumn switch
|
||||
{
|
||||
"MaintenanceType" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(m => m.MaintenanceType) : q.OrderByDescending(m => m.MaintenanceType),
|
||||
"Status" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(m => m.Status) : q.OrderByDescending(m => m.Status),
|
||||
"Priority" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(m => m.Priority) : q.OrderByDescending(m => m.Priority),
|
||||
"ScheduledDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(m => m.ScheduledDate) : q.OrderByDescending(m => m.ScheduledDate),
|
||||
"CompletedDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(m => m.CompletedDate) : q.OrderByDescending(m => m.CompletedDate),
|
||||
"Cost" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(m => m.TotalCost) : q.OrderByDescending(m => m.TotalCost),
|
||||
_ => q => q.OrderByDescending(m => m.ScheduledDate)
|
||||
};
|
||||
|
||||
// Get paged data with Equipment eager loading
|
||||
var (items, totalCount) = await _unitOfWork.MaintenanceRecords.GetPagedAsync(
|
||||
gridRequest.PageNumber,
|
||||
gridRequest.PageSize,
|
||||
filter,
|
||||
orderBy,
|
||||
m => m.Equipment);
|
||||
|
||||
// Map to DTOs
|
||||
var maintenanceDtos = _mapper.Map<List<MaintenanceListDto>>(items);
|
||||
|
||||
// Create paged result
|
||||
var pagedResult = new PagedResult<MaintenanceListDto>
|
||||
{
|
||||
Items = maintenanceDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
|
||||
// Get equipment name if filtering by equipment
|
||||
if (equipmentId.HasValue)
|
||||
{
|
||||
var equipment = await _unitOfWork.Equipment.GetByIdAsync(equipmentId.Value);
|
||||
ViewBag.EquipmentName = equipment?.EquipmentName;
|
||||
}
|
||||
|
||||
// Set ViewBag for sorting and filters
|
||||
ViewBag.EquipmentId = equipmentId;
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
ViewBag.StatusFilter = statusFilter;
|
||||
ViewBag.PendingOnly = pendingOnly;
|
||||
ViewBag.UpcomingOnly = upcomingOnly;
|
||||
ViewBag.SortColumn = gridRequest.SortColumn;
|
||||
ViewBag.SortDirection = gridRequest.SortDirection;
|
||||
|
||||
return View(pagedResult);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving maintenance records");
|
||||
TempData["Error"] = "An error occurred while loading maintenance records.";
|
||||
return View(new PagedResult<MaintenanceListDto>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the maintenance record detail page. If the record belongs to a recurrence
|
||||
/// series (identified by RecurrenceGroupId), the total series count is loaded and
|
||||
/// passed to the view so staff know how many scheduled occurrences exist in the group,
|
||||
/// helping them decide whether to edit or delete just this record or the whole series.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int? id)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var maintenance = await _unitOfWork.MaintenanceRecords.GetByIdAsync(id.Value);
|
||||
if (maintenance == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var maintenanceDto = _mapper.Map<MaintenanceRecordDto>(maintenance);
|
||||
|
||||
// Count series siblings so Details view can display "X occurrences in this series"
|
||||
if (!string.IsNullOrEmpty(maintenance.RecurrenceGroupId))
|
||||
{
|
||||
var groupId = maintenance.RecurrenceGroupId;
|
||||
var seriesRecords = await _unitOfWork.MaintenanceRecords.FindAsync(
|
||||
m => m.RecurrenceGroupId == groupId);
|
||||
ViewBag.SeriesCount = seriesRecords.Count();
|
||||
}
|
||||
|
||||
return View(maintenanceDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving maintenance record {MaintenanceId}", id);
|
||||
TempData["Error"] = "An error occurred while loading the maintenance record.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the maintenance record creation form, defaulting status to Scheduled,
|
||||
/// priority to Normal, and the scheduled date to today. If an equipmentId is supplied
|
||||
/// (e.g. linked from the Equipment Details page), it pre-selects that equipment in the
|
||||
/// dropdown so staff do not need to choose it manually.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Create(int? equipmentId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dto = new CreateMaintenanceDto
|
||||
{
|
||||
Status = MaintenanceStatus.Scheduled.ToString(),
|
||||
Priority = MaintenancePriority.Normal.ToString(),
|
||||
ScheduledDate = DateTime.Now.Date
|
||||
};
|
||||
|
||||
if (equipmentId.HasValue)
|
||||
{
|
||||
dto.EquipmentId = equipmentId.Value;
|
||||
}
|
||||
|
||||
await PopulateViewBagAsync(equipmentId);
|
||||
return View(dto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error loading create maintenance form");
|
||||
TempData["Error"] = "An error occurred while loading the form.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists a new maintenance record. If the record is marked as recurring, the parent
|
||||
/// is saved first to obtain an Id, then a RecurrenceGroupId is assigned and
|
||||
/// <see cref="GenerateRecurringOccurrencesAsync"/> creates all child occurrences in a
|
||||
/// second save. This two-phase save is necessary because the child records reference
|
||||
/// the parent's Id as RecurrenceParentId. After creation, redirects back to the
|
||||
/// Equipment Details page if the record was created from that context.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(CreateMaintenanceDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateViewBagAsync(dto.EquipmentId);
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var maintenance = _mapper.Map<MaintenanceRecord>(dto);
|
||||
maintenance.CreatedAt = DateTime.UtcNow;
|
||||
|
||||
await _unitOfWork.MaintenanceRecords.AddAsync(maintenance);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
// Generate recurring occurrences after parent is saved (so we have its Id)
|
||||
if (dto.IsRecurring && dto.RecurrenceFrequency.HasValue)
|
||||
{
|
||||
maintenance.RecurrenceGroupId = Guid.NewGuid().ToString();
|
||||
await _unitOfWork.MaintenanceRecords.UpdateAsync(maintenance);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await GenerateRecurringOccurrencesAsync(maintenance);
|
||||
TempData["Success"] = "Recurring maintenance series created successfully.";
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData["Success"] = "Maintenance record created successfully.";
|
||||
}
|
||||
|
||||
// Redirect back to equipment details if came from there
|
||||
if (dto.EquipmentId > 0)
|
||||
{
|
||||
return RedirectToAction("Details", "Equipment", new { id = dto.EquipmentId });
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating maintenance record");
|
||||
TempData["Error"] = "An error occurred while creating the maintenance record.";
|
||||
await PopulateViewBagAsync(dto.EquipmentId);
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the maintenance record edit form. Sets ViewBag.IsRecurringSeries so the
|
||||
/// view can show a warning when editing a record that is part of a series, letting
|
||||
/// staff decide between editing just this occurrence or the whole series.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Edit(int? id)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var maintenance = await _unitOfWork.MaintenanceRecords.GetByIdAsync(id.Value);
|
||||
if (maintenance == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var dto = _mapper.Map<UpdateMaintenanceDto>(maintenance);
|
||||
ViewBag.IsRecurringSeries = !string.IsNullOrEmpty(maintenance.RecurrenceGroupId);
|
||||
await PopulateViewBagAsync(dto.EquipmentId);
|
||||
return View(dto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving maintenance record {MaintenanceId} for edit", id);
|
||||
TempData["Error"] = "An error occurred while loading the maintenance record.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists edits to a maintenance record. Detects recurrence changes (frequency,
|
||||
/// end date, or the IsRecurring toggle) by comparing old values captured before
|
||||
/// AutoMapper overwrites them. When a change is detected, all future unfinished
|
||||
/// siblings in the old series are soft-deleted via <see cref="DeleteFutureSeriesAsync"/>
|
||||
/// and the current record becomes the new series parent, triggering a fresh call to
|
||||
/// <see cref="GenerateRecurringOccurrencesAsync"/>. Completed and Cancelled records
|
||||
/// in the old series are preserved to maintain historical accuracy.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, UpdateMaintenanceDto dto)
|
||||
{
|
||||
if (id != dto.Id)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateViewBagAsync(dto.EquipmentId);
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var maintenance = await _unitOfWork.MaintenanceRecords.GetByIdAsync(id);
|
||||
if (maintenance == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Capture old recurrence settings before overwriting
|
||||
var oldIsRecurring = maintenance.IsRecurring;
|
||||
var oldFrequency = maintenance.RecurrenceFrequency;
|
||||
var oldEndDate = maintenance.RecurrenceEndDate;
|
||||
var oldGroupId = maintenance.RecurrenceGroupId;
|
||||
|
||||
_mapper.Map(dto, maintenance);
|
||||
maintenance.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Detect recurrence changes
|
||||
bool recurrenceChanged = oldIsRecurring != dto.IsRecurring
|
||||
|| oldFrequency != dto.RecurrenceFrequency
|
||||
|| oldEndDate != dto.RecurrenceEndDate;
|
||||
|
||||
if (recurrenceChanged)
|
||||
{
|
||||
// Delete all future unfinished siblings (not this record)
|
||||
if (!string.IsNullOrEmpty(oldGroupId))
|
||||
{
|
||||
await DeleteFutureSeriesAsync(oldGroupId, excludeId: id);
|
||||
}
|
||||
|
||||
if (dto.IsRecurring && dto.RecurrenceFrequency.HasValue)
|
||||
{
|
||||
// This record becomes the new series parent
|
||||
if (string.IsNullOrEmpty(maintenance.RecurrenceGroupId))
|
||||
maintenance.RecurrenceGroupId = Guid.NewGuid().ToString();
|
||||
|
||||
maintenance.RecurrenceParentId = null;
|
||||
|
||||
await _unitOfWork.MaintenanceRecords.UpdateAsync(maintenance);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await GenerateRecurringOccurrencesAsync(maintenance);
|
||||
TempData["Success"] = "Maintenance record updated and recurrence series regenerated.";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Toggled OFF — clear recurrence fields
|
||||
maintenance.RecurrenceGroupId = null;
|
||||
maintenance.RecurrenceParentId = null;
|
||||
|
||||
await _unitOfWork.MaintenanceRecords.UpdateAsync(maintenance);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
TempData["Success"] = "Maintenance record updated. Recurrence has been removed.";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await _unitOfWork.MaintenanceRecords.UpdateAsync(maintenance);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
TempData["Success"] = "Maintenance record updated successfully.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating maintenance record {MaintenanceId}", id);
|
||||
TempData["Error"] = "An error occurred while updating the maintenance record.";
|
||||
await PopulateViewBagAsync(dto.EquipmentId);
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the delete confirmation page. For recurring series records, counts and
|
||||
/// passes to the view the number of future Scheduled/Overdue siblings so staff can
|
||||
/// make an informed choice between deleting just this occurrence or the entire series.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Delete(int? id)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var maintenance = await _unitOfWork.MaintenanceRecords.GetByIdAsync(id.Value);
|
||||
if (maintenance == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var maintenanceDto = _mapper.Map<MaintenanceRecordDto>(maintenance);
|
||||
|
||||
// Count deletable future series records so the view can show the user
|
||||
if (!string.IsNullOrEmpty(maintenance.RecurrenceGroupId))
|
||||
{
|
||||
var groupId = maintenance.RecurrenceGroupId;
|
||||
var futureScheduled = await _unitOfWork.MaintenanceRecords.FindAsync(
|
||||
m => m.RecurrenceGroupId == groupId
|
||||
&& m.Id != id.Value
|
||||
&& (m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.Overdue));
|
||||
ViewBag.SeriesDeletableCount = futureScheduled.Count();
|
||||
}
|
||||
|
||||
return View(maintenanceDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving maintenance record {MaintenanceId} for delete", id);
|
||||
TempData["Error"] = "An error occurred while loading the maintenance record.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft-deletes a maintenance record or an entire recurring series depending on
|
||||
/// deleteMode ("single" or "series"). When "series" is requested,
|
||||
/// <see cref="DeleteEntireSeriesAsync"/> removes all records sharing the same
|
||||
/// RecurrenceGroupId, including already-completed ones. After deletion, redirects
|
||||
/// to the parent equipment's Details page so staff stay in context.
|
||||
/// </summary>
|
||||
[HttpPost, ActionName("Delete")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> DeleteConfirmed(int id, string deleteMode = "single")
|
||||
{
|
||||
try
|
||||
{
|
||||
var maintenance = await _unitOfWork.MaintenanceRecords.GetByIdAsync(id);
|
||||
if (maintenance == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var equipmentId = maintenance.EquipmentId;
|
||||
|
||||
if (deleteMode == "series" && !string.IsNullOrEmpty(maintenance.RecurrenceGroupId))
|
||||
{
|
||||
// Delete ALL records in the series (including this one)
|
||||
await DeleteEntireSeriesAsync(maintenance.RecurrenceGroupId);
|
||||
TempData["Success"] = "Entire maintenance series deleted successfully.";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single record delete
|
||||
await _unitOfWork.MaintenanceRecords.SoftDeleteAsync(maintenance);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
TempData["Success"] = "Maintenance record deleted successfully.";
|
||||
}
|
||||
|
||||
return RedirectToAction("Details", "Equipment", new { id = equipmentId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting maintenance record {MaintenanceId}", id);
|
||||
TempData["Error"] = "An error occurred while deleting the maintenance record.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a maintenance record as Completed via an AJAX form submission and returns a
|
||||
/// JSON result so the Details page can update in-place without a full reload. Also
|
||||
/// updates the parent equipment's LastMaintenanceDate and calculates the next
|
||||
/// scheduled maintenance by adding RecommendedMaintenanceIntervalDays to the current
|
||||
/// timestamp — keeping the equipment status current without a separate scheduler job.
|
||||
/// Returns JSON { success, message } so the JS caller can show the appropriate toast.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Complete(int id, string workPerformed, string partsReplaced, decimal laborCost, decimal partsCost, decimal downtimeHours)
|
||||
{
|
||||
try
|
||||
{
|
||||
var maintenance = await _unitOfWork.MaintenanceRecords.GetByIdAsync(id);
|
||||
if (maintenance == null)
|
||||
{
|
||||
return Json(new { success = false, message = "Maintenance record not found." });
|
||||
}
|
||||
|
||||
// Update maintenance record
|
||||
maintenance.Status = MaintenanceStatus.Completed;
|
||||
maintenance.CompletedDate = DateTime.UtcNow;
|
||||
maintenance.WorkPerformed = workPerformed;
|
||||
maintenance.PartsReplaced = partsReplaced;
|
||||
maintenance.LaborCost = laborCost;
|
||||
maintenance.PartsCost = partsCost;
|
||||
maintenance.TotalCost = laborCost + partsCost;
|
||||
maintenance.DowntimeHours = downtimeHours;
|
||||
maintenance.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _unitOfWork.MaintenanceRecords.UpdateAsync(maintenance);
|
||||
|
||||
// Update equipment maintenance dates
|
||||
var equipment = await _unitOfWork.Equipment.GetByIdAsync(maintenance.EquipmentId);
|
||||
if (equipment != null)
|
||||
{
|
||||
equipment.LastMaintenanceDate = DateTime.UtcNow;
|
||||
|
||||
// Calculate next scheduled maintenance
|
||||
if (equipment.RecommendedMaintenanceIntervalDays > 0)
|
||||
{
|
||||
equipment.NextScheduledMaintenance = DateTime.UtcNow.AddDays(equipment.RecommendedMaintenanceIntervalDays);
|
||||
}
|
||||
|
||||
equipment.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.Equipment.UpdateAsync(equipment);
|
||||
}
|
||||
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
TempData["Success"] = "Maintenance marked as completed successfully.";
|
||||
return Json(new { success = true, message = "Maintenance completed successfully." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error completing maintenance record {MaintenanceId}", id);
|
||||
return Json(new { success = false, message = "An error occurred while completing the maintenance." });
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Recurrence helpers
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Generates child MaintenanceRecord occurrences for a recurring series, starting one
|
||||
/// interval after the parent's ScheduledDate. The parent must already be persisted so
|
||||
/// its Id can be stored as RecurrenceParentId on each child. If no explicit end date
|
||||
/// is set, a sensible default horizon is applied per frequency (e.g. 90 days for daily,
|
||||
/// 3 years for quarterly) to prevent accidentally creating thousands of records. A hard
|
||||
/// cap of 365 occurrences is enforced as an additional safeguard.
|
||||
/// </summary>
|
||||
private async Task GenerateRecurringOccurrencesAsync(MaintenanceRecord parent)
|
||||
{
|
||||
if (!parent.RecurrenceFrequency.HasValue) return;
|
||||
|
||||
var frequency = parent.RecurrenceFrequency.Value;
|
||||
var startDate = parent.ScheduledDate;
|
||||
|
||||
// Compute the end date: explicit setting or a sensible default horizon
|
||||
DateTime endDate = parent.RecurrenceEndDate ?? frequency switch
|
||||
{
|
||||
MaintenanceRecurrenceFrequency.Daily => startDate.AddDays(90),
|
||||
MaintenanceRecurrenceFrequency.Weekly => startDate.AddDays(364),
|
||||
MaintenanceRecurrenceFrequency.BiWeekly => startDate.AddDays(364),
|
||||
MaintenanceRecurrenceFrequency.Monthly => startDate.AddMonths(24),
|
||||
MaintenanceRecurrenceFrequency.Quarterly => startDate.AddYears(3),
|
||||
MaintenanceRecurrenceFrequency.Annually => startDate.AddYears(3),
|
||||
MaintenanceRecurrenceFrequency.BiAnnually => startDate.AddMonths(18),
|
||||
_ => startDate.AddYears(1)
|
||||
};
|
||||
|
||||
var occurrences = new List<DateTime>();
|
||||
var nextDate = AdvanceDate(startDate, frequency);
|
||||
|
||||
int maxOccurrences = 365;
|
||||
int count = 0;
|
||||
|
||||
while (nextDate <= endDate && count < maxOccurrences)
|
||||
{
|
||||
occurrences.Add(nextDate);
|
||||
nextDate = AdvanceDate(nextDate, frequency);
|
||||
count++;
|
||||
}
|
||||
|
||||
foreach (var date in occurrences)
|
||||
{
|
||||
var child = new MaintenanceRecord
|
||||
{
|
||||
EquipmentId = parent.EquipmentId,
|
||||
MaintenanceType = parent.MaintenanceType,
|
||||
Priority = parent.Priority,
|
||||
Description = parent.Description,
|
||||
AssignedUserId = parent.AssignedUserId,
|
||||
CompanyId = parent.CompanyId,
|
||||
Notes = parent.Notes,
|
||||
LaborCost = parent.LaborCost,
|
||||
PartsCost = parent.PartsCost,
|
||||
TotalCost = parent.TotalCost,
|
||||
Status = MaintenanceStatus.Scheduled,
|
||||
ScheduledDate = date,
|
||||
|
||||
// Recurrence linkage
|
||||
IsRecurring = true,
|
||||
RecurrenceFrequency = parent.RecurrenceFrequency,
|
||||
RecurrenceEndDate = parent.RecurrenceEndDate,
|
||||
RecurrenceGroupId = parent.RecurrenceGroupId,
|
||||
RecurrenceParentId = parent.Id,
|
||||
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.MaintenanceRecords.AddAsync(child);
|
||||
}
|
||||
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft-deletes all future unfinished (Scheduled or Overdue) records that share the
|
||||
/// given RecurrenceGroupId, optionally excluding one record (typically the one being
|
||||
/// edited). Completed, InProgress, and Cancelled records are intentionally preserved
|
||||
/// so that historical maintenance data and cost totals remain intact.
|
||||
/// </summary>
|
||||
private async Task DeleteFutureSeriesAsync(string recurrenceGroupId, int? excludeId = null)
|
||||
{
|
||||
var siblings = await _unitOfWork.MaintenanceRecords.FindAsync(
|
||||
m => m.RecurrenceGroupId == recurrenceGroupId
|
||||
&& (m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.Overdue)
|
||||
&& (excludeId == null || m.Id != excludeId.Value));
|
||||
|
||||
foreach (var record in siblings)
|
||||
{
|
||||
await _unitOfWork.MaintenanceRecords.SoftDeleteAsync(record);
|
||||
}
|
||||
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft-deletes every record sharing the given RecurrenceGroupId, including completed
|
||||
/// and in-progress ones. Used exclusively for the "delete entire series" confirmation
|
||||
/// path. Unlike <see cref="DeleteFutureSeriesAsync"/>, historical records are not
|
||||
/// spared because the operator has explicitly chosen to erase the whole series.
|
||||
/// </summary>
|
||||
private async Task DeleteEntireSeriesAsync(string recurrenceGroupId)
|
||||
{
|
||||
var all = await _unitOfWork.MaintenanceRecords.FindAsync(
|
||||
m => m.RecurrenceGroupId == recurrenceGroupId);
|
||||
|
||||
foreach (var record in all)
|
||||
{
|
||||
await _unitOfWork.MaintenanceRecords.SoftDeleteAsync(record);
|
||||
}
|
||||
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the next occurrence date by advancing <paramref name="from"/> by one
|
||||
/// interval for the given frequency. Used iteratively by
|
||||
/// <see cref="GenerateRecurringOccurrencesAsync"/> to build the full occurrence list.
|
||||
/// BiAnnually means every 6 months (twice a year), not every 2 years.
|
||||
/// </summary>
|
||||
private static DateTime AdvanceDate(DateTime from, MaintenanceRecurrenceFrequency frequency) => frequency switch
|
||||
{
|
||||
MaintenanceRecurrenceFrequency.Daily => from.AddDays(1),
|
||||
MaintenanceRecurrenceFrequency.Weekly => from.AddDays(7),
|
||||
MaintenanceRecurrenceFrequency.BiWeekly => from.AddDays(14),
|
||||
MaintenanceRecurrenceFrequency.Monthly => from.AddMonths(1),
|
||||
MaintenanceRecurrenceFrequency.Quarterly => from.AddMonths(3),
|
||||
MaintenanceRecurrenceFrequency.Annually => from.AddYears(1),
|
||||
MaintenanceRecurrenceFrequency.BiAnnually => from.AddMonths(6),
|
||||
_ => from.AddDays(7)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Loads the equipment dropdown and the status/priority enum arrays into ViewBag for
|
||||
/// use by the Create and Edit forms. Only active equipment is shown in the dropdown
|
||||
/// because scheduling maintenance for retired or out-of-service equipment is not
|
||||
/// meaningful and would clutter the list.
|
||||
/// </summary>
|
||||
private async Task PopulateViewBagAsync(int? selectedEquipmentId = null)
|
||||
{
|
||||
var equipment = await _unitOfWork.Equipment.GetAllAsync();
|
||||
ViewBag.EquipmentList = new SelectList(
|
||||
equipment.Where(e => e.IsActive).OrderBy(e => e.EquipmentName),
|
||||
"Id",
|
||||
"EquipmentName",
|
||||
selectedEquipmentId);
|
||||
|
||||
ViewBag.StatusList = Enum.GetValues<MaintenanceStatus>();
|
||||
ViewBag.PriorityList = Enum.GetValues<MaintenancePriority>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user