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 _logger; private readonly ITenantContext _tenantContext; private readonly ILookupCacheService _lookupCache; private readonly UserManager _userManager; public AppointmentsController( IUnitOfWork unitOfWork, IMapper mapper, ILogger logger, ITenantContext tenantContext, ILookupCacheService lookupCache, UserManager userManager) { _unitOfWork = unitOfWork; _mapper = mapper; _logger = logger; _tenantContext = tenantContext; _lookupCache = lookupCache; _userManager = userManager; } /// /// 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 /// to avoid repeated round-trips on every page load. /// // GET: Appointments public async Task 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>? 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, IOrderedQueryable> 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>(items); // Create paged result var pagedResult = new PagedResult { 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()); } } /// /// 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. /// // GET: Appointments/Details/5 public async Task 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(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)); } } /// /// Renders the appointment creation form with all dropdowns pre-populated via /// . /// // GET: Appointments/Create public async Task 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)); } } /// /// 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 RequiresJobLink must reference a specific job. The /// appointment number is generated in APT-YYMM-#### format (see /// ) and the initial status is always "SCHEDULED" /// looked up by its status code rather than a hard-coded ID. /// // POST: Appointments/Create [HttpPost] [ValidateAntiForgeryToken] public async Task 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(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); } } /// /// Loads the edit form for an existing appointment, mapping the entity to /// and /// populating all dropdowns including the status list (which is only available on edits, /// not creates — users cannot choose status during initial creation). /// // GET: Appointments/Edit/5 public async Task 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(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)); } } /// /// Saves changes to an existing appointment. Applies the same time-ordering and /// job-link validation rules as . Uses /// AutoMapper to merge only the DTO fields onto the tracked entity so EF Core generates /// a minimal UPDATE statement. /// // POST: Appointments/Edit/5 [HttpPost] [ValidateAntiForgeryToken] public async Task 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); } } /// /// Soft-deletes an appointment (sets IsDeleted = true) 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. /// // POST: Appointments/Delete/5 [HttpPost] [ValidateAntiForgeryToken] public async Task 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)); } } /// /// 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 . Appointment type legends and quick-create dropdowns /// are pre-loaded here so the page is self-contained for the first render. /// // GET: Appointments/Calendar public async Task 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)); } } /// /// 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. /// // GET: Appointments/GetCalendarEvents [HttpGet] public async Task GetCalendarEvents(DateTime start, DateTime end) { try { var events = new List(); // 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>(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()); } } /// /// 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. /// 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 }; } /// /// 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. /// 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" }; } /// /// AJAX endpoint called from the calendar's "quick create" modal. Applies the same /// time-ordering validation as the full action but /// returns JSON rather than redirecting, so the calendar view can add the new event without /// a full page reload. /// // POST: Appointments/QuickCreate [HttpPost] [ValidateAntiForgeryToken] public async Task 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(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." }); } } /// /// AJAX endpoint that supports drag-and-drop event rescheduling on the FullCalendar view. /// Accepts the new and datetimes supplied /// by FullCalendar's eventDrop and eventResize callbacks and writes them /// directly to the appointment without re-running type/job-link validation (the existing /// appointment already passed those checks). /// // POST: Appointments/UpdateEventTime [HttpPost] [ValidateAntiForgeryToken] public async Task 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." }); } } /// /// Returns a JSON list of active jobs that have no ScheduledDate 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. /// // GET: Appointments/GetUnscheduledJobs [HttpGet] public async Task 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()); } } /// /// Assigns or clears the ScheduledDate 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 OvenBatchItem 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. /// // POST: Appointments/ScheduleJob [HttpPost] [ValidateAntiForgeryToken] public async Task 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 /// /// Generates a unique appointment number in the format APT-YYMM-####. Queries all /// appointments for the current month (including soft-deleted ones via /// ignoreQueryFilters: true) 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. /// private async Task 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}"; } /// /// Populates the customer, appointment type, assigned user, and job dropdowns required by /// the Create form. Appointment types are served from to /// avoid a database hit on every form render. Commercial customers display their company name; /// individual (non-commercial) customers display "FirstName LastName". /// 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"); } /// /// Extends 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. /// 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"); } /// /// Populates the type and status filter dropdowns shown at the top of the appointment list. /// Both are served from so this method adds no extra /// database round-trips beyond the main paged query. /// 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"); } /// /// Calculates the calendar window's start and end datetimes for the given view mode and /// focal date. Dispatches to or ; /// 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. /// private (DateTime start, DateTime end) GetCalendarStartEnd(string view, DateTime date) { return view.ToLower() switch { "week" => GetWeekStartEnd(date), "month" => GetMonthStartEnd(date), _ => GetWeekStartEnd(date) }; } /// /// Returns the Monday–Sunday window that contains . The week is /// anchored to Monday (ISO week convention) rather than Sunday so it aligns with the /// FullCalendar locale configured in the views. /// 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); } /// /// Returns the first and last instant of the calendar month that contains /// . The end is the first day of the following month (exclusive) /// so range comparisons can use simple < rather than <=. /// 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 }