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>
1007 lines
44 KiB
C#
1007 lines
44 KiB
C#
using AutoMapper;
|
||
using PowderCoating.Shared.Constants;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Identity;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using PowderCoating.Application.DTOs.Appointment;
|
||
using PowderCoating.Application.DTOs.Common;
|
||
using PowderCoating.Application.Services;
|
||
using PowderCoating.Core.Entities;
|
||
using PowderCoating.Core.Interfaces;
|
||
|
||
namespace PowderCoating.Web.Controllers;
|
||
|
||
[Authorize(Policy = AppConstants.Policies.CanManageCalendar)]
|
||
public class AppointmentsController : Controller
|
||
{
|
||
private readonly IUnitOfWork _unitOfWork;
|
||
private readonly IMapper _mapper;
|
||
private readonly ILogger<AppointmentsController> _logger;
|
||
private readonly ITenantContext _tenantContext;
|
||
private readonly ILookupCacheService _lookupCache;
|
||
private readonly UserManager<ApplicationUser> _userManager;
|
||
|
||
public AppointmentsController(
|
||
IUnitOfWork unitOfWork,
|
||
IMapper mapper,
|
||
ILogger<AppointmentsController> logger,
|
||
ITenantContext tenantContext,
|
||
ILookupCacheService lookupCache,
|
||
UserManager<ApplicationUser> userManager)
|
||
{
|
||
_unitOfWork = unitOfWork;
|
||
_mapper = mapper;
|
||
_logger = logger;
|
||
_tenantContext = tenantContext;
|
||
_lookupCache = lookupCache;
|
||
_userManager = userManager;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Renders the paginated, searchable appointment list. Filtering is built as a single
|
||
/// SQL-translatable LINQ expression to avoid pulling all rows into memory before applying
|
||
/// predicates. Sorting is driven by a column-name switch so the database handles ordering
|
||
/// rather than doing it client-side. Type and status dropdowns are served from
|
||
/// <see cref="ILookupCacheService"/> to avoid repeated round-trips on every page load.
|
||
/// </summary>
|
||
// GET: Appointments
|
||
public async Task<IActionResult> Index(
|
||
string? searchTerm,
|
||
int? typeFilter,
|
||
int? 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 ?? "ScheduledStartTime",
|
||
SortDirection = sortColumn == null ? "asc" : sortDirection,
|
||
SearchTerm = searchTerm
|
||
};
|
||
gridRequest.Validate();
|
||
|
||
// Build filter expression — each predicate is a pure SQL-translatable expression.
|
||
// Avoid chaining via .Compile() which forces in-memory evaluation.
|
||
System.Linq.Expressions.Expression<Func<Appointment, bool>>? filter = null;
|
||
if (!string.IsNullOrWhiteSpace(searchTerm) || typeFilter.HasValue || statusFilter.HasValue)
|
||
{
|
||
var hasSearch = !string.IsNullOrWhiteSpace(searchTerm);
|
||
var search = searchTerm?.ToLower() ?? string.Empty;
|
||
var typeId = typeFilter ?? 0;
|
||
var statusId = statusFilter ?? 0;
|
||
|
||
filter = a =>
|
||
(!hasSearch || (
|
||
a.AppointmentNumber.ToLower().Contains(search)
|
||
|| a.Title.ToLower().Contains(search)
|
||
|| (a.Description != null && a.Description.ToLower().Contains(search))
|
||
|| a.Customer.CompanyName.ToLower().Contains(search)
|
||
|| (a.Location != null && a.Location.ToLower().Contains(search))))
|
||
&& (!typeFilter.HasValue || a.AppointmentTypeId == typeId)
|
||
&& (!statusFilter.HasValue || a.AppointmentStatusId == statusId);
|
||
}
|
||
|
||
// Build orderBy function
|
||
Func<IQueryable<Appointment>, IOrderedQueryable<Appointment>> orderBy = gridRequest.SortColumn switch
|
||
{
|
||
"AppointmentNumber" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(a => a.AppointmentNumber) : q.OrderByDescending(a => a.AppointmentNumber),
|
||
"Title" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(a => a.Title) : q.OrderByDescending(a => a.Title),
|
||
"Customer" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(a => a.Customer.CompanyName) : q.OrderByDescending(a => a.Customer.CompanyName),
|
||
"Type" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(a => a.AppointmentType.DisplayOrder) : q.OrderByDescending(a => a.AppointmentType.DisplayOrder),
|
||
"Status" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(a => a.AppointmentStatus.DisplayOrder) : q.OrderByDescending(a => a.AppointmentStatus.DisplayOrder),
|
||
"ScheduledStartTime" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(a => a.ScheduledStartTime) : q.OrderByDescending(a => a.ScheduledStartTime),
|
||
"CreatedAt" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(a => a.CreatedAt) : q.OrderByDescending(a => a.CreatedAt),
|
||
_ => q => q.OrderBy(a => a.ScheduledStartTime)
|
||
};
|
||
|
||
// Get paged data with eager loading
|
||
var (items, totalCount) = await _unitOfWork.Appointments.GetPagedAsync(
|
||
gridRequest.PageNumber,
|
||
gridRequest.PageSize,
|
||
filter,
|
||
orderBy,
|
||
a => a.Customer,
|
||
a => a.AppointmentType,
|
||
a => a.AppointmentStatus,
|
||
a => a.AssignedUser);
|
||
|
||
// Map to DTOs
|
||
var appointmentDtos = _mapper.Map<List<AppointmentListDto>>(items);
|
||
|
||
var pagedResult = PagedResult<AppointmentListDto>.From(gridRequest, appointmentDtos, totalCount);
|
||
|
||
// Set ViewBag
|
||
ViewBag.SearchTerm = searchTerm;
|
||
ViewBag.TypeFilter = typeFilter;
|
||
ViewBag.StatusFilter = statusFilter;
|
||
ViewBag.SortColumn = gridRequest.SortColumn;
|
||
ViewBag.SortDirection = gridRequest.SortDirection;
|
||
|
||
// Populate filter dropdowns
|
||
await PopulateFilterDropdowns();
|
||
|
||
return View(pagedResult);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error retrieving appointments");
|
||
TempData["Error"] = "An error occurred while loading appointments.";
|
||
return View(new PagedResult<AppointmentListDto>());
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Shows the full detail view for a single appointment, eagerly loading the customer, linked
|
||
/// job, appointment type, appointment status, and assigned user so the view renders in a
|
||
/// single database round-trip.
|
||
/// </summary>
|
||
// GET: Appointments/Details/5
|
||
public async Task<IActionResult> Details(int? id)
|
||
{
|
||
if (id == null)
|
||
{
|
||
return NotFound();
|
||
}
|
||
|
||
try
|
||
{
|
||
var appointment = await _unitOfWork.Appointments.GetByIdAsync(id.Value, false,
|
||
a => a.Customer,
|
||
a => a.Job,
|
||
a => a.AppointmentType,
|
||
a => a.AppointmentStatus,
|
||
a => a.AssignedUser);
|
||
|
||
if (appointment == null)
|
||
{
|
||
return NotFound();
|
||
}
|
||
|
||
var dto = _mapper.Map<AppointmentDto>(appointment);
|
||
return View(dto);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error retrieving appointment {Id}", id);
|
||
TempData["Error"] = "An error occurred while loading the appointment.";
|
||
return RedirectToAction(nameof(Index));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Renders the appointment creation form with all dropdowns pre-populated via
|
||
/// <see cref="PopulateCreateDropdowns"/>.
|
||
/// </summary>
|
||
// GET: Appointments/Create
|
||
public async Task<IActionResult> Create()
|
||
{
|
||
try
|
||
{
|
||
await PopulateCreateDropdowns();
|
||
return View();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error loading create form");
|
||
TempData["Error"] = "An error occurred while loading the form.";
|
||
return RedirectToAction(nameof(Index));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Persists a new appointment after validating time ordering rules (all-day events require
|
||
/// end date >= start date; timed events require end > start) and the business rule that
|
||
/// appointment types marked <c>RequiresJobLink</c> must reference a specific job. The
|
||
/// appointment number is generated in APT-YYMM-#### format (see
|
||
/// <see cref="GenerateAppointmentNumberAsync"/>) and the initial status is always "SCHEDULED"
|
||
/// looked up by its status code rather than a hard-coded ID.
|
||
/// </summary>
|
||
// POST: Appointments/Create
|
||
[HttpPost]
|
||
[ValidateAntiForgeryToken]
|
||
public async Task<IActionResult> Create(CreateAppointmentDto dto)
|
||
{
|
||
if (!ModelState.IsValid)
|
||
{
|
||
_logger.LogWarning("Model validation failed. Errors: {Errors}",
|
||
string.Join(", ", ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage)));
|
||
await PopulateCreateDropdowns();
|
||
return View(dto);
|
||
}
|
||
|
||
try
|
||
{
|
||
// Validate start time < end time (for all-day events, allow same date)
|
||
if (dto.IsAllDay)
|
||
{
|
||
// For all-day events, end date must be >= start date
|
||
if (dto.ScheduledEndTime.Date < dto.ScheduledStartTime.Date)
|
||
{
|
||
ModelState.AddModelError("ScheduledEndTime", "End date must be on or after start date.");
|
||
await PopulateCreateDropdowns();
|
||
return View(dto);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// For timed events, end time must be after start time
|
||
if (dto.ScheduledStartTime >= dto.ScheduledEndTime)
|
||
{
|
||
ModelState.AddModelError("ScheduledEndTime", "End time must be after start time.");
|
||
await PopulateCreateDropdowns();
|
||
return View(dto);
|
||
}
|
||
}
|
||
|
||
// Validate JobId required for JOB_WORK type
|
||
var appointmentType = await _unitOfWork.AppointmentTypeLookups.GetByIdAsync(dto.AppointmentTypeId);
|
||
if (appointmentType?.RequiresJobLink == true && !dto.JobId.HasValue)
|
||
{
|
||
ModelState.AddModelError("JobId", "Job is required for this appointment type.");
|
||
await PopulateCreateDropdowns();
|
||
return View(dto);
|
||
}
|
||
|
||
// Map to entity
|
||
var appointment = _mapper.Map<Appointment>(dto);
|
||
|
||
// Generate appointment number
|
||
appointment.AppointmentNumber = await GenerateAppointmentNumberAsync();
|
||
|
||
// Set default status to SCHEDULED
|
||
var scheduledStatus = (await _unitOfWork.AppointmentStatusLookups.FindAsync(s => s.StatusCode == "SCHEDULED")).FirstOrDefault();
|
||
if (scheduledStatus != null)
|
||
{
|
||
appointment.AppointmentStatusId = scheduledStatus.Id;
|
||
}
|
||
|
||
// Save
|
||
await _unitOfWork.Appointments.AddAsync(appointment);
|
||
await _unitOfWork.CompleteAsync();
|
||
|
||
TempData["Success"] = "Appointment created successfully.";
|
||
return RedirectToAction(nameof(Details), new { id = appointment.Id });
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error creating appointment");
|
||
ModelState.AddModelError("", "An error occurred while creating the appointment.");
|
||
await PopulateCreateDropdowns();
|
||
return View(dto);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Loads the edit form for an existing appointment, mapping the entity to
|
||
/// <see cref="PowderCoating.Application.DTOs.Appointment.UpdateAppointmentDto"/> and
|
||
/// populating all dropdowns including the status list (which is only available on edits,
|
||
/// not creates — users cannot choose status during initial creation).
|
||
/// </summary>
|
||
// GET: Appointments/Edit/5
|
||
public async Task<IActionResult> Edit(int? id)
|
||
{
|
||
if (id == null)
|
||
{
|
||
return NotFound();
|
||
}
|
||
|
||
try
|
||
{
|
||
var appointment = await _unitOfWork.Appointments.GetByIdAsync(id.Value, false,
|
||
a => a.Customer,
|
||
a => a.Job,
|
||
a => a.AppointmentType,
|
||
a => a.AppointmentStatus);
|
||
|
||
if (appointment == null)
|
||
{
|
||
return NotFound();
|
||
}
|
||
|
||
var dto = _mapper.Map<UpdateAppointmentDto>(appointment);
|
||
await PopulateEditDropdowns();
|
||
return View(dto);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error loading edit form for appointment {Id}", id);
|
||
TempData["Error"] = "An error occurred while loading the form.";
|
||
return RedirectToAction(nameof(Index));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Saves changes to an existing appointment. Applies the same time-ordering and
|
||
/// job-link validation rules as <see cref="Create(CreateAppointmentDto)"/>. Uses
|
||
/// AutoMapper to merge only the DTO fields onto the tracked entity so EF Core generates
|
||
/// a minimal UPDATE statement.
|
||
/// </summary>
|
||
// POST: Appointments/Edit/5
|
||
[HttpPost]
|
||
[ValidateAntiForgeryToken]
|
||
public async Task<IActionResult> Edit(int id, UpdateAppointmentDto dto)
|
||
{
|
||
if (id != dto.Id)
|
||
{
|
||
return NotFound();
|
||
}
|
||
|
||
if (!ModelState.IsValid)
|
||
{
|
||
await PopulateEditDropdowns();
|
||
return View(dto);
|
||
}
|
||
|
||
try
|
||
{
|
||
// Validate start time < end time (for all-day events, allow same date)
|
||
if (dto.IsAllDay)
|
||
{
|
||
// For all-day events, end date must be >= start date
|
||
if (dto.ScheduledEndTime.Date < dto.ScheduledStartTime.Date)
|
||
{
|
||
ModelState.AddModelError("ScheduledEndTime", "End date must be on or after start date.");
|
||
await PopulateEditDropdowns();
|
||
return View(dto);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// For timed events, end time must be after start time
|
||
if (dto.ScheduledStartTime >= dto.ScheduledEndTime)
|
||
{
|
||
ModelState.AddModelError("ScheduledEndTime", "End time must be after start time.");
|
||
await PopulateEditDropdowns();
|
||
return View(dto);
|
||
}
|
||
}
|
||
|
||
// Get existing appointment
|
||
var appointment = await _unitOfWork.Appointments.GetByIdAsync(id);
|
||
if (appointment == null)
|
||
{
|
||
return NotFound();
|
||
}
|
||
|
||
// Validate JobId required for JOB_WORK type
|
||
var appointmentType = await _unitOfWork.AppointmentTypeLookups.GetByIdAsync(dto.AppointmentTypeId);
|
||
if (appointmentType?.RequiresJobLink == true && !dto.JobId.HasValue)
|
||
{
|
||
ModelState.AddModelError("JobId", "Job is required for this appointment type.");
|
||
await PopulateEditDropdowns();
|
||
return View(dto);
|
||
}
|
||
|
||
// Map changes
|
||
_mapper.Map(dto, appointment);
|
||
|
||
// Update
|
||
await _unitOfWork.Appointments.UpdateAsync(appointment);
|
||
await _unitOfWork.CompleteAsync();
|
||
|
||
TempData["Success"] = "Appointment updated successfully.";
|
||
return RedirectToAction(nameof(Details), new { id = appointment.Id });
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error updating appointment {Id}", id);
|
||
ModelState.AddModelError("", "An error occurred while updating the appointment.");
|
||
await PopulateEditDropdowns();
|
||
return View(dto);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Soft-deletes an appointment (sets <c>IsDeleted = true</c>) and redirects to the
|
||
/// calendar rather than the list so the user lands back in the context they most likely
|
||
/// came from. The global query filter automatically hides soft-deleted records from
|
||
/// all subsequent queries.
|
||
/// </summary>
|
||
// POST: Appointments/Delete/5
|
||
[HttpPost]
|
||
[ValidateAntiForgeryToken]
|
||
public async Task<IActionResult> Delete(int id)
|
||
{
|
||
try
|
||
{
|
||
var appointment = await _unitOfWork.Appointments.GetByIdAsync(id);
|
||
if (appointment == null)
|
||
{
|
||
TempData["Error"] = "Appointment not found.";
|
||
return RedirectToAction(nameof(Calendar));
|
||
}
|
||
|
||
await _unitOfWork.Appointments.SoftDeleteAsync(id);
|
||
await _unitOfWork.CompleteAsync();
|
||
|
||
TempData["Success"] = "Appointment deleted successfully.";
|
||
return RedirectToAction(nameof(Calendar));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error deleting appointment {Id}", id);
|
||
TempData["Error"] = "An error occurred while deleting the appointment.";
|
||
return RedirectToAction(nameof(Calendar));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Renders the full-calendar shell view (FullCalendar JS). The server only sets the
|
||
/// initial view mode and date context in ViewBag; actual event data is fetched client-side
|
||
/// by <see cref="GetCalendarEvents"/>. Appointment type legends and quick-create dropdowns
|
||
/// are pre-loaded here so the page is self-contained for the first render.
|
||
/// </summary>
|
||
// GET: Appointments/Calendar
|
||
public async Task<IActionResult> Calendar(string view = "month", DateTime? date = null)
|
||
{
|
||
try
|
||
{
|
||
var currentDate = date ?? DateTime.Today;
|
||
ViewBag.CurrentView = view;
|
||
ViewBag.CurrentDate = currentDate;
|
||
|
||
// Populate dropdowns for quick create modal
|
||
await PopulateCreateDropdowns();
|
||
|
||
// Load appointment types for legend (cached)
|
||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||
var appointmentTypes = await _lookupCache.GetAppointmentTypeLookupsAsync(companyId);
|
||
ViewBag.AppointmentTypesForLegend = appointmentTypes
|
||
.Where(t => t.IsActive)
|
||
.OrderBy(t => t.DisplayOrder)
|
||
.ToList();
|
||
|
||
return View();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error loading calendar view");
|
||
TempData["Error"] = "An error occurred while loading the calendar.";
|
||
return RedirectToAction(nameof(Index));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns a JSON array of calendar events covering appointments, maintenance records, and
|
||
/// active jobs for the requested date window. Called by the FullCalendar JS library on
|
||
/// every view change or navigation. Maintenance records that have no time component (midnight)
|
||
/// are defaulted to 8:00 AM so they appear in the week-view time grid rather than collapsing
|
||
/// to invisible. Jobs are rendered as all-day events using their scheduled date or falling
|
||
/// back to the due date; fall-back events past today are coloured red to signal overdue
|
||
/// scheduling. Terminal statuses (COMPLETED, DELIVERED, CANCELLED) are excluded from jobs.
|
||
/// </summary>
|
||
// GET: Appointments/GetCalendarEvents
|
||
[HttpGet]
|
||
public async Task<IActionResult> GetCalendarEvents(DateTime start, DateTime end)
|
||
{
|
||
try
|
||
{
|
||
var events = new List<CalendarEventDto>();
|
||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||
|
||
// 1. Fetch appointments in date range
|
||
var allAppointments = await _unitOfWork.Appointments.FindAsync(
|
||
a => a.CompanyId == companyId,
|
||
false,
|
||
a => a.Customer,
|
||
a => a.AppointmentType,
|
||
a => a.AppointmentStatus);
|
||
|
||
var appointments = allAppointments
|
||
.Where(a => a.ScheduledStartTime >= start && a.ScheduledStartTime <= end)
|
||
.ToList();
|
||
|
||
var appointmentEvents = _mapper.Map<List<CalendarEventDto>>(appointments);
|
||
events.AddRange(appointmentEvents);
|
||
|
||
// 2. Fetch maintenance records in date range
|
||
var allMaintenanceRecords = await _unitOfWork.MaintenanceRecords.FindAsync(
|
||
m => m.CompanyId == companyId,
|
||
false,
|
||
m => m.Equipment);
|
||
|
||
var maintenanceRecords = allMaintenanceRecords
|
||
.Where(m => m.ScheduledDate.Date >= start.Date && m.ScheduledDate.Date <= end.Date &&
|
||
(m.Status == Core.Enums.MaintenanceStatus.Scheduled ||
|
||
m.Status == Core.Enums.MaintenanceStatus.InProgress))
|
||
.ToList();
|
||
|
||
// Convert maintenance records to calendar events
|
||
foreach (var maintenance in maintenanceRecords)
|
||
{
|
||
// Default to 8:00 AM when ScheduledDate has no time component (midnight),
|
||
// so events are visible in the week view time grid (which shows 6 AM – 8 PM).
|
||
var scheduledStart = maintenance.ScheduledDate.TimeOfDay == TimeSpan.Zero
|
||
? maintenance.ScheduledDate.AddHours(8)
|
||
: maintenance.ScheduledDate;
|
||
|
||
var maintenanceEvent = new CalendarEventDto
|
||
{
|
||
Id = maintenance.Id,
|
||
EventType = "Maintenance",
|
||
Title = $"{maintenance.Equipment?.EquipmentName ?? "Equipment"} - {maintenance.MaintenanceType}",
|
||
Start = scheduledStart.ToString("yyyy-MM-ddTHH:mm:ss"),
|
||
End = scheduledStart.AddHours(2).ToString("yyyy-MM-ddTHH:mm:ss"), // Default 2-hour duration
|
||
AllDay = false,
|
||
BackgroundColor = GetMaintenanceColor(maintenance.Priority),
|
||
BorderColor = GetMaintenanceColor(maintenance.Priority),
|
||
TextColor = "#ffffff",
|
||
CustomerName = maintenance.Equipment?.EquipmentName ?? "Equipment",
|
||
TypeDisplayName = maintenance.MaintenanceType,
|
||
StatusCode = maintenance.Status.ToString(),
|
||
Location = maintenance.Equipment?.Location
|
||
};
|
||
events.Add(maintenanceEvent);
|
||
}
|
||
|
||
// 3. Fetch jobs and add as all-day events
|
||
var allJobs = await _unitOfWork.Jobs.FindAsync(
|
||
j => j.CompanyId == companyId,
|
||
false,
|
||
j => j.Customer,
|
||
j => j.JobStatus);
|
||
|
||
var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled };
|
||
var jobsInRange = allJobs.Where(j =>
|
||
!terminalCodes.Contains(j.JobStatus.StatusCode) &&
|
||
((j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date >= start.Date && j.ScheduledDate.Value.Date <= end.Date) ||
|
||
(!j.ScheduledDate.HasValue && j.DueDate.HasValue && j.DueDate.Value.Date >= start.Date && j.DueDate.Value.Date <= end.Date))
|
||
).ToList();
|
||
|
||
foreach (var job in jobsInRange)
|
||
{
|
||
var displayDate = job.ScheduledDate ?? job.DueDate!.Value;
|
||
var isFallback = !job.ScheduledDate.HasValue;
|
||
var color = GetJobStatusColor(job.JobStatus.StatusCode, isFallback, displayDate);
|
||
var customerName = !string.IsNullOrWhiteSpace(job.Customer.CompanyName)
|
||
? job.Customer.CompanyName
|
||
: $"{job.Customer.ContactFirstName} {job.Customer.ContactLastName}".Trim();
|
||
|
||
events.Add(new CalendarEventDto
|
||
{
|
||
Id = job.Id,
|
||
EventType = "Job",
|
||
Title = $"{job.JobNumber} — {customerName}",
|
||
Start = displayDate.Date.ToString("yyyy-MM-ddT00:00:00"),
|
||
End = displayDate.Date.ToString("yyyy-MM-ddT00:00:00"),
|
||
AllDay = true,
|
||
BackgroundColor = color,
|
||
BorderColor = color,
|
||
TextColor = "#ffffff",
|
||
CustomerName = customerName,
|
||
TypeDisplayName = job.JobStatus.DisplayName ?? job.JobStatus.StatusCode,
|
||
StatusCode = job.JobStatus.StatusCode,
|
||
JobNumber = job.JobNumber,
|
||
IsFallbackDate = isFallback
|
||
});
|
||
}
|
||
|
||
return Json(events);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error retrieving calendar events");
|
||
return Json(new List<CalendarEventDto>());
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns a Bootstrap-compatible hex colour for a maintenance event based on its priority.
|
||
/// Critical is red, High is orange, Normal is yellow, and Low is muted grey — intentionally
|
||
/// matching the severity-colour convention used throughout the rest of the application UI.
|
||
/// </summary>
|
||
private string GetMaintenanceColor(Core.Enums.MaintenancePriority priority)
|
||
{
|
||
return priority switch
|
||
{
|
||
Core.Enums.MaintenancePriority.Critical => "#dc3545", // Red
|
||
Core.Enums.MaintenancePriority.High => "#fd7e14", // Orange
|
||
Core.Enums.MaintenancePriority.Normal => "#ffc107", // Yellow
|
||
Core.Enums.MaintenancePriority.Low => "#6c757d", // Gray
|
||
_ => "#ffc107" // Default yellow
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns a hex colour for a job calendar event given its status code. When the displayed
|
||
/// date is a fallback (due date used because no scheduled date is set) and that date is in
|
||
/// the past, the event is forced to red to draw immediate attention regardless of the job's
|
||
/// current status. Otherwise colours follow the job lifecycle: grey for pre-approval, blue for
|
||
/// active prep, orange for oven, green for completed, etc.
|
||
/// </summary>
|
||
private static string GetJobStatusColor(string statusCode, bool isFallback, DateTime displayDate)
|
||
{
|
||
if (isFallback && displayDate.Date < DateTime.Today)
|
||
return "#dc3545"; // Red — overdue, no scheduled date
|
||
|
||
return statusCode switch
|
||
{
|
||
AppConstants.StatusCodes.Job.Pending or "QUOTED" => "#6c757d", // Gray
|
||
"APPROVED" => "#0dcaf0", // Cyan
|
||
AppConstants.StatusCodes.Job.InPreparation or AppConstants.StatusCodes.Job.Sandblasting or
|
||
AppConstants.StatusCodes.Job.MaskingTaping or AppConstants.StatusCodes.Job.Cleaning => "#0d6efd", // Blue
|
||
AppConstants.StatusCodes.Job.InOven or AppConstants.StatusCodes.Job.Curing => "#fd7e14", // Orange
|
||
AppConstants.StatusCodes.Job.Coating => "#6610f2", // Indigo
|
||
AppConstants.StatusCodes.Job.QualityCheck => "#20c997", // Teal
|
||
AppConstants.StatusCodes.Job.Completed or AppConstants.StatusCodes.Job.Delivered or AppConstants.StatusCodes.Job.ReadyForPickup => "#198754", // Green
|
||
AppConstants.StatusCodes.Job.OnHold => "#ffc107", // Yellow
|
||
AppConstants.StatusCodes.Job.Cancelled => "#adb5bd", // Light gray
|
||
_ => "#0d6efd"
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// AJAX endpoint called from the calendar's "quick create" modal. Applies the same
|
||
/// time-ordering validation as the full <see cref="Create(CreateAppointmentDto)"/> action but
|
||
/// returns JSON rather than redirecting, so the calendar view can add the new event without
|
||
/// a full page reload.
|
||
/// </summary>
|
||
// POST: Appointments/QuickCreate
|
||
[HttpPost]
|
||
[ValidateAntiForgeryToken]
|
||
public async Task<IActionResult> QuickCreate(QuickCreateAppointmentDto dto)
|
||
{
|
||
if (!ModelState.IsValid)
|
||
{
|
||
return Json(new { success = false, message = "Invalid data provided." });
|
||
}
|
||
|
||
try
|
||
{
|
||
// Validate start time < end time (for all-day events, allow same date)
|
||
if (dto.IsAllDay)
|
||
{
|
||
// For all-day events, end date must be >= start date
|
||
if (dto.ScheduledEndTime.Date < dto.ScheduledStartTime.Date)
|
||
{
|
||
return Json(new { success = false, message = "End date must be on or after start date." });
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// For timed events, end time must be after start time
|
||
if (dto.ScheduledStartTime >= dto.ScheduledEndTime)
|
||
{
|
||
return Json(new { success = false, message = "End time must be after start time." });
|
||
}
|
||
}
|
||
|
||
// Map to entity
|
||
var appointment = _mapper.Map<Appointment>(dto);
|
||
|
||
// Generate appointment number
|
||
appointment.AppointmentNumber = await GenerateAppointmentNumberAsync();
|
||
|
||
// Set default status to SCHEDULED
|
||
var scheduledStatus = (await _unitOfWork.AppointmentStatusLookups.FindAsync(s => s.StatusCode == "SCHEDULED")).FirstOrDefault();
|
||
if (scheduledStatus != null)
|
||
{
|
||
appointment.AppointmentStatusId = scheduledStatus.Id;
|
||
}
|
||
|
||
// Save
|
||
await _unitOfWork.Appointments.AddAsync(appointment);
|
||
await _unitOfWork.CompleteAsync();
|
||
|
||
return Json(new { success = true, message = "Appointment created successfully.", appointmentId = appointment.Id });
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error quick creating appointment");
|
||
return Json(new { success = false, message = "An error occurred while creating the appointment." });
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// AJAX endpoint that supports drag-and-drop event rescheduling on the FullCalendar view.
|
||
/// Accepts the new <paramref name="start"/> and <paramref name="end"/> datetimes supplied
|
||
/// by FullCalendar's <c>eventDrop</c> and <c>eventResize</c> callbacks and writes them
|
||
/// directly to the appointment without re-running type/job-link validation (the existing
|
||
/// appointment already passed those checks).
|
||
/// </summary>
|
||
// POST: Appointments/UpdateEventTime
|
||
[HttpPost]
|
||
[ValidateAntiForgeryToken]
|
||
public async Task<IActionResult> UpdateEventTime(int id, DateTime start, DateTime end)
|
||
{
|
||
try
|
||
{
|
||
var appointment = await _unitOfWork.Appointments.GetByIdAsync(id);
|
||
if (appointment == null)
|
||
{
|
||
return Json(new { success = false, message = "Appointment not found." });
|
||
}
|
||
|
||
// Calculate duration and update times
|
||
var duration = appointment.ScheduledEndTime - appointment.ScheduledStartTime;
|
||
appointment.ScheduledStartTime = start;
|
||
appointment.ScheduledEndTime = end;
|
||
|
||
await _unitOfWork.Appointments.UpdateAsync(appointment);
|
||
await _unitOfWork.CompleteAsync();
|
||
|
||
return Json(new { success = true, message = "Appointment time updated successfully." });
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error updating appointment time for {Id}", id);
|
||
return Json(new { success = false, message = "An error occurred while updating the appointment." });
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns a JSON list of active jobs that have no <c>ScheduledDate</c> set, ordered by
|
||
/// due date. Used by the calendar's "Unscheduled Jobs" sidebar so dispatchers can drag a
|
||
/// job onto a date to schedule it. Each job includes up to six items with colour names
|
||
/// (resolved from associated coats) so the dispatcher can see at a glance what powder is
|
||
/// needed. Coats are loaded in a separate filtered query to avoid a cross-join that would
|
||
/// inflate the main job rows.
|
||
/// </summary>
|
||
// GET: Appointments/GetUnscheduledJobs
|
||
[HttpGet]
|
||
public async Task<IActionResult> GetUnscheduledJobs()
|
||
{
|
||
try
|
||
{
|
||
var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled };
|
||
var calCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||
var allJobs = await _unitOfWork.Jobs.FindAsync(
|
||
j => j.CompanyId == calCompanyId,
|
||
false,
|
||
j => j.Customer, j => j.JobStatus, j => j.JobItems);
|
||
|
||
// Load coats separately — filter by JobItemId using already-loaded item IDs
|
||
var jobItemIds = allJobs.SelectMany(j => j.JobItems.Select(i => i.Id)).ToList();
|
||
var allCoats = await _unitOfWork.JobItemCoats.FindAsync(
|
||
c => jobItemIds.Contains(c.JobItemId) && c.CompanyId == calCompanyId);
|
||
|
||
var coatsByItemId = allCoats
|
||
.Where(c => !c.IsDeleted)
|
||
.GroupBy(c => c.JobItemId)
|
||
.ToDictionary(g => g.Key, g => g.OrderBy(c => c.Sequence).ToList());
|
||
|
||
var jobs = allJobs
|
||
.Where(j => !j.ScheduledDate.HasValue && !terminalCodes.Contains(j.JobStatus.StatusCode))
|
||
.OrderBy(j => j.DueDate)
|
||
.Select(j => new
|
||
{
|
||
id = j.Id,
|
||
jobNumber = j.JobNumber,
|
||
customerName = !string.IsNullOrWhiteSpace(j.Customer.CompanyName)
|
||
? j.Customer.CompanyName
|
||
: $"{j.Customer.ContactFirstName} {j.Customer.ContactLastName}".Trim(),
|
||
statusCode = j.JobStatus.StatusCode,
|
||
statusName = j.JobStatus.DisplayName ?? j.JobStatus.StatusCode,
|
||
dueDate = j.DueDate.HasValue ? j.DueDate.Value.ToString("yyyy-MM-dd") : (string?)null,
|
||
isOverdue = j.DueDate.HasValue && j.DueDate.Value.Date < DateTime.Today,
|
||
color = GetJobStatusColor(j.JobStatus.StatusCode, false, j.DueDate ?? DateTime.Today),
|
||
specialInstructions = j.SpecialInstructions,
|
||
quotedPrice = j.QuotedPrice,
|
||
items = j.JobItems
|
||
.Where(i => !i.IsDeleted)
|
||
.OrderBy(i => i.Id)
|
||
.Take(6)
|
||
.Select(i => new
|
||
{
|
||
description = i.Description,
|
||
quantity = (int)i.Quantity,
|
||
colorName = coatsByItemId.TryGetValue(i.Id, out var coats)
|
||
? string.Join(" / ", coats
|
||
.Where(c => !string.IsNullOrWhiteSpace(c.ColorName))
|
||
.Select(c => c.ColorName!)
|
||
.Distinct())
|
||
: null
|
||
})
|
||
.ToList(),
|
||
itemCount = j.JobItems.Count(i => !i.IsDeleted)
|
||
})
|
||
.ToList();
|
||
|
||
return Json(jobs);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error retrieving unscheduled jobs");
|
||
return Json(new List<object>());
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Assigns or clears the <c>ScheduledDate</c> on a job, typically triggered by dragging an
|
||
/// unscheduled job card onto a calendar date. When the date changes and the job was previously
|
||
/// in an oven batch scheduled for the old date, the corresponding <c>OvenBatchItem</c> is
|
||
/// soft-deleted so the batch capacity is freed; the caller receives the removed batch number
|
||
/// so the UI can display a warning without needing a full page reload.
|
||
/// </summary>
|
||
// POST: Appointments/ScheduleJob
|
||
[HttpPost]
|
||
[ValidateAntiForgeryToken]
|
||
public async Task<IActionResult> ScheduleJob(int id, DateTime? date)
|
||
{
|
||
try
|
||
{
|
||
var job = await _unitOfWork.Jobs.GetByIdAsync(id);
|
||
if (job == null)
|
||
return Json(new { success = false, message = "Job not found." });
|
||
|
||
var oldDate = job.ScheduledDate;
|
||
job.ScheduledDate = date?.Date;
|
||
|
||
// If moved to a different day, remove from any oven batch on the old day
|
||
string? removedFromBatch = null;
|
||
if (oldDate.HasValue)
|
||
{
|
||
bool dateChanged = !date.HasValue || date.Value.Date != oldDate.Value.Date;
|
||
if (dateChanged)
|
||
{
|
||
var batchItems = await _unitOfWork.OvenBatchItems.FindAsync(
|
||
bi => bi.JobId == id,
|
||
false,
|
||
bi => bi.Batch);
|
||
|
||
foreach (var item in batchItems.Where(bi => bi.Batch.ScheduledDate.Date == oldDate.Value.Date))
|
||
{
|
||
removedFromBatch = item.Batch.BatchNumber;
|
||
await _unitOfWork.OvenBatchItems.SoftDeleteAsync(item.Id);
|
||
}
|
||
}
|
||
}
|
||
|
||
await _unitOfWork.Jobs.UpdateAsync(job);
|
||
await _unitOfWork.CompleteAsync();
|
||
|
||
return Json(new { success = true, removedFromBatch });
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error scheduling job {Id}", id);
|
||
return Json(new { success = false, message = "An error occurred." });
|
||
}
|
||
}
|
||
|
||
#region Helper Methods
|
||
|
||
/// <summary>
|
||
/// Generates a unique appointment number in the format APT-YYMM-####. Queries all
|
||
/// appointments for the current month (including soft-deleted ones via
|
||
/// <c>ignoreQueryFilters: true</c>) to determine the highest sequence already used, then
|
||
/// increments by one. Checking soft-deleted records prevents number reuse if an appointment
|
||
/// was created and then deleted within the same month.
|
||
/// </summary>
|
||
private async Task<string> GenerateAppointmentNumberAsync()
|
||
{
|
||
var prefix = $"APT-{DateTime.UtcNow:yyMM}-";
|
||
var last = (await _unitOfWork.Appointments.FindAsync(
|
||
a => a.AppointmentNumber.StartsWith(prefix), ignoreQueryFilters: true))
|
||
.OrderByDescending(a => a.AppointmentNumber)
|
||
.Select(a => a.AppointmentNumber)
|
||
.FirstOrDefault();
|
||
|
||
int next = 1;
|
||
if (last != null && int.TryParse(last[prefix.Length..], out int num))
|
||
next = num + 1;
|
||
|
||
return $"{prefix}{next:D4}";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Populates the customer, appointment type, assigned user, and job dropdowns required by
|
||
/// the Create form. Appointment types are served from <see cref="ILookupCacheService"/> to
|
||
/// avoid a database hit on every form render. Commercial customers display their company name;
|
||
/// individual (non-commercial) customers display "FirstName LastName".
|
||
/// </summary>
|
||
private async Task PopulateCreateDropdowns()
|
||
{
|
||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||
|
||
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||
var customerList = customers.Select(c => new
|
||
{
|
||
c.Id,
|
||
DisplayName = !string.IsNullOrWhiteSpace(c.CompanyName)
|
||
? c.CompanyName // Commercial customer
|
||
: $"{c.ContactFirstName} {c.ContactLastName}".Trim() // Individual customer
|
||
})
|
||
.OrderBy(c => c.DisplayName)
|
||
.ToList();
|
||
ViewBag.Customers = new SelectList(customerList, "Id", "DisplayName");
|
||
|
||
var types = await _lookupCache.GetAppointmentTypeLookupsAsync(companyId);
|
||
ViewBag.AppointmentTypes = new SelectList(types.Where(t => t.IsActive).OrderBy(t => t.DisplayOrder), "Id", "DisplayName");
|
||
|
||
var workers = await _userManager.Users
|
||
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
|
||
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
||
.ToListAsync();
|
||
ViewBag.Workers = new SelectList(workers.Select(u => new { u.Id, FullName = u.FullName }), "Id", "FullName");
|
||
|
||
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId);
|
||
ViewBag.Jobs = new SelectList(jobs.OrderBy(j => j.JobNumber), "Id", "JobNumber");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Extends <see cref="PopulateCreateDropdowns"/> by additionally loading the appointment
|
||
/// status list (from cache) so editors can change an appointment's status — an option not
|
||
/// available during initial creation where the status is always set to SCHEDULED.
|
||
/// </summary>
|
||
private async Task PopulateEditDropdowns()
|
||
{
|
||
await PopulateCreateDropdowns();
|
||
|
||
// Use cached appointment statuses
|
||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||
var statuses = await _lookupCache.GetAppointmentStatusLookupsAsync(companyId);
|
||
ViewBag.AppointmentStatuses = new SelectList(statuses.Where(s => s.IsActive).OrderBy(s => s.DisplayOrder), "Id", "DisplayName");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Populates the type and status filter dropdowns shown at the top of the appointment list.
|
||
/// Both are served from <see cref="ILookupCacheService"/> so this method adds no extra
|
||
/// database round-trips beyond the main paged query.
|
||
/// </summary>
|
||
private async Task PopulateFilterDropdowns()
|
||
{
|
||
// Use cached lookups for filters
|
||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||
var types = await _lookupCache.GetAppointmentTypeLookupsAsync(companyId);
|
||
ViewBag.TypeFilterList = new SelectList(types.Where(t => t.IsActive).OrderBy(t => t.DisplayOrder), "Id", "DisplayName");
|
||
|
||
var statuses = await _lookupCache.GetAppointmentStatusLookupsAsync(companyId);
|
||
ViewBag.StatusFilterList = new SelectList(statuses.Where(s => s.IsActive).OrderBy(s => s.DisplayOrder), "Id", "DisplayName");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Calculates the calendar window's start and end datetimes for the given view mode and
|
||
/// focal date. Dispatches to <see cref="GetWeekStartEnd"/> or <see cref="GetMonthStartEnd"/>;
|
||
/// any unrecognised view name defaults to week. This helper exists so the view-range logic
|
||
/// is shared between any server-side code that needs a date window without duplicating the
|
||
/// Monday-anchor arithmetic.
|
||
/// </summary>
|
||
private (DateTime start, DateTime end) GetCalendarStartEnd(string view, DateTime date)
|
||
{
|
||
return view.ToLower() switch
|
||
{
|
||
"week" => GetWeekStartEnd(date),
|
||
"month" => GetMonthStartEnd(date),
|
||
_ => GetWeekStartEnd(date)
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns the Monday–Sunday window that contains <paramref name="date"/>. The week is
|
||
/// anchored to Monday (ISO week convention) rather than Sunday so it aligns with the
|
||
/// FullCalendar locale configured in the views.
|
||
/// </summary>
|
||
private (DateTime start, DateTime end) GetWeekStartEnd(DateTime date)
|
||
{
|
||
// Get start of week (Monday)
|
||
int diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7;
|
||
var weekStart = date.AddDays(-diff).Date;
|
||
var weekEnd = weekStart.AddDays(7).Date;
|
||
return (weekStart, weekEnd);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns the first and last instant of the calendar month that contains
|
||
/// <paramref name="date"/>. The end is the first day of the following month (exclusive)
|
||
/// so range comparisons can use simple < rather than <=.
|
||
/// </summary>
|
||
private (DateTime start, DateTime end) GetMonthStartEnd(DateTime date)
|
||
{
|
||
var monthStart = new DateTime(date.Year, date.Month, 1);
|
||
var monthEnd = monthStart.AddMonths(1);
|
||
return (monthStart, monthEnd);
|
||
}
|
||
|
||
#endregion
|
||
}
|