Initial commit
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
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.Infrastructure.Data;
|
||||
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 ApplicationDbContext _context;
|
||||
private readonly ILogger<JobsPriorityController> _logger;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly IHubContext<ShopHub> _shopHub;
|
||||
|
||||
public JobsPriorityController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ApplicationDbContext context,
|
||||
ILogger<JobsPriorityController> logger,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ITenantContext tenantContext,
|
||||
IHubContext<ShopHub> shopHub)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
_userManager = userManager;
|
||||
_tenantContext = tenantContext;
|
||||
_shopHub = shopHub;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the daily job-scheduling board for the given date (defaults to today).
|
||||
/// <para>
|
||||
/// Loads all jobs whose <c>ScheduledDate</c> matches the requested day, then
|
||||
/// merges them with any existing <c>JobDailyPriority</c> drag-drop order records.
|
||||
/// Jobs that have not yet been given an explicit display order receive
|
||||
/// <c>int.MaxValue</c> so they sort to the bottom, after already-ordered jobs.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Maintenance records for the same day (status Scheduled or InProgress) are
|
||||
/// also loaded and passed via <c>ViewBag.MaintenanceItems</c> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
// GET: JobsPriority (Job Schedule)
|
||||
public async Task<IActionResult> Index(DateTime? date)
|
||||
{
|
||||
var today = date?.Date ?? DateTime.Today;
|
||||
|
||||
// Get all jobs scheduled for today with related data
|
||||
var jobs = await _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobPriority)
|
||||
.Include(j => j.AssignedUser)
|
||||
.Where(j => j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today && !j.IsDeleted)
|
||||
.ToListAsync();
|
||||
|
||||
// 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 priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
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 _context.MaintenanceRecords
|
||||
.Include(m => m.Equipment)
|
||||
.Include(m => m.AssignedUser)
|
||||
.Where(m => m.ScheduledDate.Date == today && !m.IsDeleted &&
|
||||
(m.Status == MaintenanceStatus.Scheduled ||
|
||||
m.Status == MaintenanceStatus.InProgress))
|
||||
.OrderByDescending(m => (int)m.Priority)
|
||||
.ThenBy(m => m.ScheduledDate)
|
||||
.ToListAsync();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// For each job in the payload the action either updates the existing
|
||||
/// <c>JobDailyPriority</c> 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 <c>DailyBoardUpdated</c> SignalR event is pushed to the
|
||||
/// company's shop-hub group so any connected shop-floor screen refreshes
|
||||
/// automatically without polling.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
// POST: JobsPriority/UpdateOrder
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> UpdateOrder([FromBody] List<UpdateJobOrderDto> 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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// <c>JobPriority</c> is a lookup-table entity (<c>JobPriorityLookup</c>), not an
|
||||
/// enum, so the action validates that <paramref name="priorityId"/> maps to a known
|
||||
/// lookup row before writing it to the job. The resolved
|
||||
/// <c>DisplayName</c> and <c>ColorClass</c> are returned in the JSON response so
|
||||
/// the JavaScript can update the board badge in-place without reloading.
|
||||
/// A SignalR <c>DailyBoardUpdated</c> push notifies other connected clients.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
// POST: JobsPriority/UpdatePriority
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> 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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assigns or unassigns a worker (ASP.NET Identity user) to a job from the scheduling board.
|
||||
/// Passing <c>null</c> or an empty <paramref name="workerId"/> clears the assignment.
|
||||
/// <para>
|
||||
/// Worker lookup is done via <c>UserManager</c> (not the repository layer) because
|
||||
/// workers are stored as Identity users, not as <c>ShopWorker</c> entities.
|
||||
/// The resolved <c>FullName</c> ("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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
// POST: JobsPriority/UpdateWorker
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> 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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the <c>ScheduledDate</c> 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.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
// POST: JobsPriority/UpdateScheduledDate
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> 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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assigns or unassigns a worker on a maintenance record directly from the
|
||||
/// scheduling board's maintenance panel.
|
||||
/// <para>
|
||||
/// Because this action uses <c>DbContext.FindAsync</c> (which bypasses EF global
|
||||
/// query filters), a manual company-ownership check is performed: if the current
|
||||
/// user is not a SuperAdmin, the record's <c>CompanyId</c> must match the tenant
|
||||
/// context to prevent cross-tenant data mutation. This guard is intentional and
|
||||
/// must not be removed.
|
||||
/// The raw <c>ApplicationDbContext</c> is used instead of <c>IUnitOfWork</c> here
|
||||
/// because the maintenance repository does not expose <c>FindAsync(int)</c> with
|
||||
/// the bypass semantics needed for this cross-filter lookup.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
// POST: JobsPriority/UpdateMaintenanceWorker
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> UpdateMaintenanceWorker(int maintenanceId, string? workerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var record = await _context.MaintenanceRecords.FindAsync(maintenanceId);
|
||||
if (record == null || record.IsDeleted)
|
||||
return Json(new { success = false, message = "Maintenance record not found" });
|
||||
|
||||
// FindAsync bypasses global query filters — verify company ownership explicitly
|
||||
if (!_tenantContext.IsSuperAdmin() && record.CompanyId != _tenantContext.GetCurrentCompanyId())
|
||||
return Json(new { success = false, message = "Access denied." });
|
||||
|
||||
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 _context.SaveChangesAsync();
|
||||
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the customer-facing <c>DueDate</c> on a job from the scheduling board.
|
||||
/// Unlike <c>ScheduledDate</c> (the internal work date), <c>DueDate</c> is the
|
||||
/// date promised to the customer and is used to calculate overdue status.
|
||||
/// <para>
|
||||
/// Returns an <c>isOverdue</c> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
// POST: JobsPriority/UpdateDueDate
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> 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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user