Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/MaintenanceController.cs
T
2026-04-23 21:38:24 -04:00

761 lines
32 KiB
C#

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>();
}
}