using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.Job; using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; using PowderCoating.Web.Hubs; namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.CanManageJobs)] public class JobsPriorityController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly UserManager _userManager; private readonly ITenantContext _tenantContext; private readonly IHubContext _shopHub; public JobsPriorityController( IUnitOfWork unitOfWork, ILogger logger, UserManager userManager, ITenantContext tenantContext, IHubContext shopHub) { _unitOfWork = unitOfWork; _logger = logger; _userManager = userManager; _tenantContext = tenantContext; _shopHub = shopHub; } /// /// Renders the daily job-scheduling board for the given date (defaults to today). /// /// Loads all jobs whose ScheduledDate matches the requested day, then /// merges them with any existing JobDailyPriority drag-drop order records. /// Jobs that have not yet been given an explicit display order receive /// int.MaxValue so they sort to the bottom, after already-ordered jobs. /// /// /// Maintenance records for the same day (status Scheduled or InProgress) are /// also loaded and passed via ViewBag.MaintenanceItems so the view can /// render a separate maintenance panel on the same board without a second round-trip. /// Priority and worker option lists are serialised to JSON ViewBag properties for /// use by the inline-edit JavaScript modals. /// /// // GET: JobsPriority (Job Schedule) public async Task Index(DateTime? date) { var today = date?.Date ?? DateTime.Today; // Get all jobs scheduled for today with related data var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today); // Get existing priority records for today var existingPriorities = await _unitOfWork.JobDailyPriorities .FindAsync(p => p.ScheduledDate.Date == today); var priorityDict = existingPriorities.ToDictionary(p => p.JobId); // Map to DTOs with display order var jobDtos = jobs.Select(j => new JobDailyPriorityDto { Id = priorityDict.ContainsKey(j.Id) ? priorityDict[j.Id].Id : 0, JobId = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer.CompanyName ?? $"{j.Customer.ContactFirstName} {j.Customer.ContactLastName}".Trim(), StatusDisplayName = j.JobStatus.DisplayName, StatusColorClass = j.JobStatus.ColorClass, JobPriorityId = j.JobPriorityId, PriorityDisplayName = j.JobPriority.DisplayName, PriorityColorClass = j.JobPriority.ColorClass, AssignedUserId = j.AssignedUserId, AssignedWorkerName = j.AssignedUser?.FullName, ScheduledDate = j.ScheduledDate, DueDate = j.DueDate, DisplayOrder = priorityDict.ContainsKey(j.Id) ? priorityDict[j.Id].DisplayOrder : int.MaxValue }) .OrderBy(j => j.DisplayOrder) .ThenBy(j => j.JobNumber) .ToList(); // Get priorities and workers for modal options var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId); var workers = await _userManager.Users .Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null) .OrderBy(u => u.FirstName).ThenBy(u => u.LastName) .ToListAsync(); // Get maintenance records scheduled for today (Scheduled or InProgress) var maintenanceItems = (await _unitOfWork.MaintenanceRecords.FindAsync( m => m.ScheduledDate.Date == today && (m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.InProgress), false, m => m.Equipment, m => m.AssignedUser)) .OrderByDescending(m => (int)m.Priority) .ThenBy(m => m.ScheduledDate) .ToList(); // Load overdue jobs only when viewing today — past-date navigation shows that day as-is if (today == DateTime.Today) { var overdueJobs = await _unitOfWork.Jobs.GetOverdueScheduledJobsAsync(); ViewBag.OverdueJobs = overdueJobs.Select(j => new JobDailyPriorityDto { Id = 0, JobId = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer.CompanyName ?? $"{j.Customer.ContactFirstName} {j.Customer.ContactLastName}".Trim(), StatusDisplayName = j.JobStatus.DisplayName, StatusColorClass = j.JobStatus.ColorClass, JobPriorityId = j.JobPriorityId, PriorityDisplayName = j.JobPriority.DisplayName, PriorityColorClass = j.JobPriority.ColorClass, AssignedUserId = j.AssignedUserId, AssignedWorkerName = j.AssignedUser?.FullName, ScheduledDate = j.ScheduledDate, DueDate = j.DueDate, DisplayOrder = int.MaxValue }).ToList(); } ViewBag.ScheduledDate = today; ViewBag.MaintenanceItems = maintenanceItems; ViewBag.PrioritiesJson = priorities.OrderBy(p => p.DisplayOrder) .Select(p => new { id = p.Id, name = p.DisplayName, colorClass = p.ColorClass }) .ToList(); ViewBag.WorkersJson = workers.OrderBy(w => w.FirstName).ThenBy(w => w.LastName) .Select(w => new { id = w.Id, name = w.FullName }) .ToList(); return View(jobDtos); } /// /// Persists the new drag-drop display order for jobs on the daily scheduling board. /// Called via AJAX after the user reorders cards; returns JSON so the page does not /// reload. /// /// For each job in the payload the action either updates the existing /// JobDailyPriority row for today or creates a new one if the job has not /// been ordered before. Using an upsert-style approach (check dictionary → update or /// insert) avoids a separate EXISTS query per row while keeping the logic clear. /// After saving, a DailyBoardUpdated SignalR event is pushed to the /// company's shop-hub group so any connected shop-floor screen refreshes /// automatically without polling. /// /// // POST: JobsPriority/UpdateOrder [HttpPost] [ValidateAntiForgeryToken] public async Task UpdateOrder([FromBody] List updates) { try { if (updates == null || !updates.Any()) { return Json(new { success = false, message = "No updates provided" }); } var today = DateTime.Today; // Get all existing priority records for today var existingPriorities = await _unitOfWork.JobDailyPriorities .FindAsync(p => p.ScheduledDate.Date == today); var priorityDict = existingPriorities.ToDictionary(p => p.JobId); // Update or create priority records foreach (var update in updates) { if (priorityDict.ContainsKey(update.JobId)) { // Update existing record var priority = priorityDict[update.JobId]; priority.DisplayOrder = update.DisplayOrder; } else { // Create new record var newPriority = new JobDailyPriority { JobId = update.JobId, ScheduledDate = today, DisplayOrder = update.DisplayOrder }; await _unitOfWork.JobDailyPriorities.AddAsync(newPriority); } } await _unitOfWork.CompleteAsync(); var companyId = _tenantContext.GetCurrentCompanyId()?.ToString(); if (!string.IsNullOrEmpty(companyId)) await _shopHub.Clients.Group($"shop-{companyId}").SendAsync("DailyBoardUpdated"); return Json(new { success = true, message = "Job order updated successfully" }); } catch (Exception ex) { _logger.LogError(ex, "Error updating job order"); return Json(new { success = false, message = "Failed to update job order" }); } } /// /// Changes the urgency priority of a job directly from the scheduling board via /// an inline-edit modal, without navigating to the full Job Edit page. /// /// JobPriority is a lookup-table entity (JobPriorityLookup), not an /// enum, so the action validates that maps to a known /// lookup row before writing it to the job. The resolved /// DisplayName and ColorClass are returned in the JSON response so /// the JavaScript can update the board badge in-place without reloading. /// A SignalR DailyBoardUpdated push notifies other connected clients. /// /// // POST: JobsPriority/UpdatePriority [HttpPost] [ValidateAntiForgeryToken] public async Task UpdatePriority(int jobId, int priorityId) { try { var job = await _unitOfWork.Jobs.GetByIdAsync(jobId); if (job == null) { return Json(new { success = false, message = "Job not found" }); } var priority = await _unitOfWork.JobPriorityLookups.GetByIdAsync(priorityId); if (priority == null) { return Json(new { success = false, message = "Priority not found" }); } job.JobPriorityId = priorityId; await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.CompleteAsync(); var companyId = _tenantContext.GetCurrentCompanyId()?.ToString(); if (!string.IsNullOrEmpty(companyId)) await _shopHub.Clients.Group($"shop-{companyId}").SendAsync("DailyBoardUpdated"); return Json(new { success = true, message = "Priority updated successfully", displayName = priority.DisplayName, colorClass = priority.ColorClass }); } catch (Exception ex) { _logger.LogError(ex, "Error updating job priority"); return Json(new { success = false, message = "Failed to update priority" }); } } /// /// Assigns or unassigns a worker (ASP.NET Identity user) to a job from the scheduling board. /// Passing null or an empty clears the assignment. /// /// Worker lookup is done via UserManager (not the repository layer) because /// workers are stored as Identity users, not as ShopWorker entities. /// The resolved FullName ("Unassigned" when cleared) is echoed back in JSON /// so the board card updates immediately without a page reload. /// A SignalR push keeps other open boards in sync. /// /// // POST: JobsPriority/UpdateWorker [HttpPost] [ValidateAntiForgeryToken] public async Task UpdateWorker(int jobId, string? workerId) { try { var job = await _unitOfWork.Jobs.GetByIdAsync(jobId); if (job == null) { return Json(new { success = false, message = "Job not found" }); } string workerName = "Unassigned"; if (!string.IsNullOrEmpty(workerId)) { var user = await _userManager.FindByIdAsync(workerId); if (user == null) { return Json(new { success = false, message = "User not found" }); } workerName = user.FullName; job.AssignedUserId = workerId; } else { job.AssignedUserId = null; } await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.CompleteAsync(); var companyId = _tenantContext.GetCurrentCompanyId()?.ToString(); if (!string.IsNullOrEmpty(companyId)) await _shopHub.Clients.Group($"shop-{companyId}").SendAsync("DailyBoardUpdated"); return Json(new { success = true, message = "Worker assigned successfully", workerName = workerName }); } catch (Exception ex) { _logger.LogError(ex, "Error updating job worker assignment"); return Json(new { success = false, message = "Failed to update worker assignment" }); } } /// /// Updates the ScheduledDate on a job inline from the scheduling board. /// Moving a job to a different date will cause it to disappear from the current /// day's board view on the next render — this is intentional behaviour. /// /// Returns both a human-readable formatted date ("Jan 15, 2026") and an ISO raw /// value ("2026-01-15") in the JSON response so the JavaScript can update the /// display label and any hidden form inputs simultaneously. /// This action does NOT push a SignalR notification because rescheduling a job /// affects a different day's board, not the currently viewed board. /// /// // POST: JobsPriority/UpdateScheduledDate [HttpPost] [ValidateAntiForgeryToken] public async Task UpdateScheduledDate(int jobId, DateTime? scheduledDate) { try { var job = await _unitOfWork.Jobs.GetByIdAsync(jobId); if (job == null) { return Json(new { success = false, message = "Job not found" }); } job.ScheduledDate = scheduledDate; await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.CompleteAsync(); return Json(new { success = true, message = "Scheduled date updated successfully", scheduledDate = scheduledDate?.ToString("MMM dd, yyyy"), scheduledDateRaw = scheduledDate?.ToString("yyyy-MM-dd") }); } catch (Exception ex) { _logger.LogError(ex, "Error updating job scheduled date"); return Json(new { success = false, message = "Failed to update scheduled date" }); } } /// /// Assigns or unassigns a worker on a maintenance record directly from the /// scheduling board's maintenance panel. /// /// Because this action uses DbContext.FindAsync (which bypasses EF global /// query filters), a manual company-ownership check is performed: if the current /// user is not a SuperAdmin, the record's CompanyId must match the tenant /// context to prevent cross-tenant data mutation. This guard is intentional and /// must not be removed. /// The raw ApplicationDbContext is used instead of IUnitOfWork here /// because the maintenance repository does not expose FindAsync(int) with /// the bypass semantics needed for this cross-filter lookup. /// /// // POST: JobsPriority/UpdateMaintenanceWorker [HttpPost] [ValidateAntiForgeryToken] public async Task UpdateMaintenanceWorker(int maintenanceId, string? workerId) { try { var record = await _unitOfWork.MaintenanceRecords.GetByIdAsync(maintenanceId); if (record == null || record.IsDeleted) return Json(new { success = false, message = "Maintenance record not found" }); string workerName = "Unassigned"; if (!string.IsNullOrEmpty(workerId)) { var user = await _userManager.FindByIdAsync(workerId); if (user == null) return Json(new { success = false, message = "User not found" }); workerName = user.FullName; record.AssignedUserId = workerId; } else { record.AssignedUserId = null; } record.UpdatedAt = DateTime.UtcNow; await _unitOfWork.CompleteAsync(); return Json(new { success = true, message = "Worker assigned successfully", workerName }); } catch (Exception ex) { _logger.LogError(ex, "Error updating maintenance worker assignment"); return Json(new { success = false, message = "Failed to update worker assignment" }); } } /// /// Updates the customer-facing DueDate on a job from the scheduling board. /// Unlike ScheduledDate (the internal work date), DueDate is the /// date promised to the customer and is used to calculate overdue status. /// /// Returns an isOverdue flag in JSON (true when the new date is in the past) /// so the JavaScript can immediately toggle the overdue badge colour on the board /// card without waiting for a reload. /// /// // POST: JobsPriority/UpdateDueDate [HttpPost] [ValidateAntiForgeryToken] public async Task UpdateDueDate(int jobId, DateTime? dueDate) { try { var job = await _unitOfWork.Jobs.GetByIdAsync(jobId); if (job == null) { return Json(new { success = false, message = "Job not found" }); } job.DueDate = dueDate; await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.CompleteAsync(); var isOverdue = dueDate.HasValue && dueDate.Value.Date < DateTime.Today; return Json(new { success = true, message = "Due date updated successfully", dueDate = dueDate?.ToString("MMM dd, yyyy"), dueDateRaw = dueDate?.ToString("yyyy-MM-dd"), isOverdue = isOverdue }); } catch (Exception ex) { _logger.LogError(ex, "Error updating job due date"); return Json(new { success = false, message = "Failed to update due date" }); } } }