07796b05c8
Edit POST now detects if ScheduledStartTime changed (via previousStart comparison after AutoMapper merge) and nulls ReminderSentAt so the background service will fire the reminder again at the new time. Calendar drag-drop (UpdateEventTime) always clears ReminderSentAt since rescheduling is its only purpose. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1015 lines
44 KiB
C#
1015 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 — capture old start before overwrite so we can detect a reschedule.
|
||
var previousStart = appointment.ScheduledStartTime;
|
||
_mapper.Map(dto, appointment);
|
||
|
||
// If the appointment was rescheduled, clear the reminder stamp so the background
|
||
// service will fire again at the new time.
|
||
if (appointment.ScheduledStartTime != previousStart)
|
||
appointment.ReminderSentAt = null;
|
||
|
||
// 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;
|
||
// Drag-drop always changes the time — reset so the reminder fires at the new time.
|
||
appointment.ReminderSentAt = null;
|
||
|
||
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
|
||
}
|