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

1014 lines
43 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
// Create paged result
var pagedResult = new PagedResult<AppointmentListDto>
{
Items = appointmentDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = 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 &gt;= start date; timed events require end &gt; 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>();
// 1. Fetch appointments in date range
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(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.GetAllAsync(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.GetAllAsync(false,
j => j.Customer,
j => j.JobStatus);
var terminalCodes = new[] { "COMPLETED", "DELIVERED", "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
{
"PENDING" or "QUOTED" => "#6c757d", // Gray
"APPROVED" => "#0dcaf0", // Cyan
"IN_PREPARATION" or "SANDBLASTING" or
"MASKING_TAPING" or "CLEANING" => "#0d6efd", // Blue
"IN_OVEN" or "CURING" => "#fd7e14", // Orange
"COATING" => "#6610f2", // Indigo
"QUALITY_CHECK" => "#20c997", // Teal
"COMPLETED" or "DELIVERED" or "READY_FOR_PICKUP" => "#198754", // Green
"ON_HOLD" => "#ffc107", // Yellow
"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[] { "COMPLETED", "DELIVERED", "CANCELLED" };
var allJobs = await _unitOfWork.Jobs.GetAllAsync(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));
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 now = DateTime.UtcNow;
var prefix = $"APT-{now:yyMM}-";
// Get all appointments for current month (including soft-deleted)
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(ignoreQueryFilters: true);
var monthAppointments = allAppointments
.Where(a => a.AppointmentNumber.StartsWith(prefix))
.OrderByDescending(a => a.AppointmentNumber)
.ToList();
var lastNumber = 0;
if (monthAppointments.Any())
{
var lastAppointmentNumber = monthAppointments.First().AppointmentNumber;
var numberPart = lastAppointmentNumber.Split('-').Last();
int.TryParse(numberPart, out lastNumber);
}
var newNumber = lastNumber + 1;
return $"{prefix}{newNumber: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 customers = await _unitOfWork.Customers.GetAllAsync();
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");
// Use cached appointment types
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var types = await _lookupCache.GetAppointmentTypeLookupsAsync(companyId);
ViewBag.AppointmentTypes = new SelectList(types.Where(t => t.IsActive).OrderBy(t => t.DisplayOrder), "Id", "DisplayName");
var companyIdForWorkers = _tenantContext.GetCurrentCompanyId() ?? 0;
var workers = await _userManager.Users
.Where(u => u.CompanyId == companyIdForWorkers && 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.GetAllAsync();
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 MondaySunday 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 &lt; rather than &lt;=.
/// </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
}