8acbc8605d
Added explicit CompanyId == companyId predicates to every tenant-scoped query in 22 controllers so cross-tenant data leakage is impossible even if EF Core global query filters are bypassed or misconfigured. Also fixed ApplicationDbContext.IsPlatformAdmin to correctly return true for SuperAdmins with no CompanyId claim (break-glass accounts) and when no HTTP context is present (background services, unit tests), resolving 225 unit test failures that stemmed from the global filter blocking all in-memory test data. New MultiTenantIsolationTests class (8 tests) verifies the explicit predicate layer independently of the global query filters. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
758 lines
32 KiB
C#
758 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 ITenantContext _tenantContext;
|
|
private readonly ILogger<MaintenanceController> _logger;
|
|
|
|
public MaintenanceController(
|
|
IUnitOfWork unitOfWork,
|
|
IMapper mapper,
|
|
ITenantContext tenantContext,
|
|
ILogger<MaintenanceController> logger)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_mapper = mapper;
|
|
_tenantContext = tenantContext;
|
|
_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);
|
|
|
|
var pagedResult = PagedResult<MaintenanceListDto>.From(gridRequest, maintenanceDtos, 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 companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId);
|
|
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>();
|
|
}
|
|
}
|