using System.Text.Json; 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.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.Job; using PowderCoating.Application.DTOs.Quote; using PowderCoating.Application.Interfaces; using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Web.Helpers; using PowderCoating.Web.Hubs; namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.CanManageJobs)] public class JobsController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; private readonly IJobPhotoService _jobPhotoService; private readonly UserManager _userManager; private readonly ILogger _logger; private readonly ITenantContext _tenantContext; private readonly IMeasurementConversionService _measurementService; private readonly ILookupCacheService _lookupCache; private readonly INotificationService _notificationService; private readonly ISubscriptionService _subscriptionService; private readonly IPricingCalculationService _pricingService; private readonly IHubContext _hub; private readonly IHubContext _shopHub; public JobsController( IUnitOfWork unitOfWork, IMapper mapper, IJobPhotoService jobPhotoService, UserManager userManager, ILogger logger, ITenantContext tenantContext, IMeasurementConversionService measurementService, ILookupCacheService lookupCache, INotificationService notificationService, ISubscriptionService subscriptionService, IPricingCalculationService pricingService, IHubContext hub, IHubContext shopHub) { _unitOfWork = unitOfWork; _mapper = mapper; _jobPhotoService = jobPhotoService; _userManager = userManager; _logger = logger; _tenantContext = tenantContext; _measurementService = measurementService; _lookupCache = lookupCache; _notificationService = notificationService; _subscriptionService = subscriptionService; _pricingService = pricingService; _hub = hub; _shopHub = shopHub; } /// /// Sends a real-time "JobBoardUpdated" SignalR event to all connected clients in the company's /// hub group so the Kanban board and dashboard refresh without polling. /// The event carries enough context (jobId, jobNumber, eventType, detail, changedBy) for the /// client to decide whether to refresh a card or show a toast notification. /// private Task BroadcastJobUpdate(int companyId, string jobNumber, int jobId, string eventType, string detail) { var user = User.Identity?.Name ?? "Someone"; return _hub.Clients.Group($"company-{companyId}").SendAsync("JobBoardUpdated", new { jobId, jobNumber, eventType, detail, changedBy = user }); } /// /// Displays the paginated, sortable, filterable jobs list. /// Status filtering uses a "group" concept (Active, Completed, All) that maps to a set of /// status codes rather than a single status, so the user can filter by workflow stage without /// having to select every individual status. Tag filtering is done post-query (same reason as /// in QuotesController — Tags is a comma-separated string column). /// public async Task Index( string? searchTerm, string? statusGroup, string? tagFilter, 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 ?? "CreatedAt", SortDirection = sortColumn == null ? "desc" : sortDirection, // Default to desc for CreatedAt SearchTerm = searchTerm }; gridRequest.Validate(); // Build search filter System.Linq.Expressions.Expression>? filter = null; if (!string.IsNullOrWhiteSpace(statusGroup)) { var todayDate = DateTime.Today; if (statusGroup == "active") { filter = j => j.JobStatus.StatusCode != "COMPLETED" && j.JobStatus.StatusCode != "READY_FOR_PICKUP" && j.JobStatus.StatusCode != "DELIVERED" && j.JobStatus.StatusCode != "CANCELLED"; } else if (statusGroup == "overdue") { filter = j => j.DueDate < todayDate && j.JobStatus.StatusCode != "COMPLETED" && j.JobStatus.StatusCode != "READY_FOR_PICKUP" && j.JobStatus.StatusCode != "DELIVERED" && j.JobStatus.StatusCode != "CANCELLED"; } } else if (!string.IsNullOrWhiteSpace(searchTerm)) { var search = searchTerm.ToLower(); filter = j => j.JobNumber.ToLower().Contains(search) || j.Description.ToLower().Contains(search) || (j.CustomerPO != null && j.CustomerPO.ToLower().Contains(search)) || (j.SpecialInstructions != null && j.SpecialInstructions.ToLower().Contains(search)) || j.JobStatus.DisplayName.ToLower().Contains(search) || j.JobPriority.DisplayName.ToLower().Contains(search) || j.Customer.CompanyName.ToLower().Contains(search); } // Build orderBy function Func, IOrderedQueryable> orderBy = gridRequest.SortColumn switch { "JobNumber" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.JobNumber) : q.OrderByDescending(j => j.JobNumber), "Status" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.JobStatus.DisplayOrder) : q.OrderByDescending(j => j.JobStatus.DisplayOrder), "Priority" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.JobPriority.DisplayOrder) : q.OrderByDescending(j => j.JobPriority.DisplayOrder), "ScheduledDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.ScheduledDate) : q.OrderByDescending(j => j.ScheduledDate), "DueDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.DueDate) : q.OrderByDescending(j => j.DueDate), "FinalPrice" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.FinalPrice) : q.OrderByDescending(j => j.FinalPrice), "CreatedAt" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.CreatedAt) : q.OrderByDescending(j => j.CreatedAt), _ => q => q.OrderByDescending(j => j.CreatedAt) }; // Get paged data with Customer, AssignedWorker, JobStatus, and JobPriority eager loading var (items, totalCount) = await _unitOfWork.Jobs.GetPagedAsync( gridRequest.PageNumber, gridRequest.PageSize, filter, orderBy, j => j.Customer, j => j.AssignedUser, j => j.JobStatus, j => j.JobPriority); // Map to DTOs using AutoMapper var jobDtos = _mapper.Map>(items); // Apply tag filter (post-query, since Tags is a comma-separated string) if (!string.IsNullOrWhiteSpace(tagFilter)) { var tagLower = tagFilter.Trim().ToLower(); jobDtos = jobDtos.Where(j => j.Tags != null && j.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(t => t.Trim().ToLower()) .Contains(tagLower)).ToList(); } // Create paged result var pagedResult = new PagedResult { Items = jobDtos, PageNumber = gridRequest.PageNumber, PageSize = gridRequest.PageSize, TotalCount = string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count }; // Set ViewBag for sorting ViewBag.SearchTerm = searchTerm; ViewBag.StatusGroup = statusGroup; ViewBag.TagFilter = tagFilter; ViewBag.SortColumn = gridRequest.SortColumn; ViewBag.SortDirection = gridRequest.SortDirection; // Populate workers for quick assignment modal await PopulateWorkersDropdown(); var indexUser = await _userManager.GetUserAsync(User); if (indexUser != null) await PopulateEmailNotificationDefaultsAsync(indexUser.CompanyId); return View(pagedResult); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving jobs"); TempData["Error"] = "An error occurred while loading jobs."; return View(new PagedResult()); } } /// /// Renders the Kanban-style job board grouped by status. /// By default only non-terminal statuses are shown (IsTerminalStatus = false) to keep the board /// uncluttered. When is true, Completed/Delivered/Cancelled /// columns are also shown for historical context. /// Uses the lookup cache so column headers stay consistent with the configurable status list. /// public async Task Board( bool showTerminal = false, string? guidedActivation = null, int? highlightJobId = null) { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var statuses = (await _lookupCache.GetJobStatusLookupsAsync(companyId)) .Where(s => s.IsActive) .OrderBy(s => s.DisplayOrder) .ToList(); // Load all active jobs with related data var jobs = await _unitOfWork.Jobs.GetBoardJobsAsync(); var highlightedJob = highlightJobId.HasValue ? jobs.FirstOrDefault(j => j.Id == highlightJobId.Value) : null; var now = DateTime.UtcNow.Date; var columns = statuses .Where(s => showTerminal || !s.IsTerminalStatus) .Select(s => new JobBoardColumn { StatusId = s.Id, StatusCode = s.StatusCode, DisplayName = s.DisplayName, ColorClass = s.ColorClass, IconClass = s.IconClass, IsTerminal = s.IsTerminalStatus, Jobs = jobs .Where(j => j.JobStatusId == s.Id) .Select(j => new JobBoardCard { Id = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer?.CompanyName ?? $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(), Description = j.Description, PriorityCode = j.JobPriority?.PriorityCode ?? "", PriorityDisplayName = j.JobPriority?.DisplayName ?? "", PriorityColorClass = j.JobPriority?.ColorClass ?? "secondary", DueDate = j.DueDate, IsOverdue = j.DueDate.HasValue && j.DueDate.Value.Date < now, AssignedWorkerName = j.AssignedUser?.FullName, FinalPrice = j.FinalPrice, }).ToList() }).ToList(); ViewBag.ShowTerminal = showTerminal; ViewBag.TotalTerminal = statuses.Where(s => s.IsTerminalStatus) .Sum(s => jobs.Count(j => j.JobStatusId == s.Id)); ViewBag.GuidedActivation = guidedActivation; ViewBag.GuidedActivationHighlightJobId = highlightJobId; ViewBag.GuidedActivationCallout = await BuildBoardGuidedActivationCalloutAsync( companyId, guidedActivation, highlightJobId, highlightedJob); return View(columns); } /// /// AJAX endpoint that handles drag-and-drop column changes on the Kanban board. /// Records the status change in JobStatusHistory, updates the job, and broadcasts /// a SignalR event so other connected users see the card move in real time. /// [HttpPost, ValidateAntiForgeryToken] public async Task MoveCard([FromBody] MoveCardRequest req) { var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(req.JobId); if (job == null) return Json(new { success = false, message = "Job not found." }); var newStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync( s => s.Id == req.NewStatusId && s.IsActive); if (newStatus == null) return Json(new { success = false, message = "Status not found." }); await RecordStatusChangeAsync(job, newStatus.Id); job.JobStatusId = newStatus.Id; job.UpdatedAt = DateTime.UtcNow; job.UpdatedBy = User.Identity?.Name; var workflowJustCompleted = req.JobId == req.HighlightJobId && await MaybeMarkFirstWorkflowCompletedFromBoardAsync(job.CompanyId, req.GuidedActivation); await _unitOfWork.CompleteAsync(); await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "StatusChanged", $"Status → {newStatus.DisplayName}"); var moveCardCompanyId = _tenantContext.GetCurrentCompanyId()?.ToString(); if (!string.IsNullOrEmpty(moveCardCompanyId)) await _shopHub.Clients.Group($"shop-{moveCardCompanyId}").SendAsync("JobStatusChanged", new { jobId = job.Id, jobNumber = job.JobNumber, statusDisplayName = newStatus.DisplayName, statusColorClass = newStatus.ColorClass }); return Json(new { success = true, newStatusId = newStatus.Id, newStatusDisplay = newStatus.DisplayName, newStatusColor = newStatus.ColorClass, guidedActivationNext = workflowJustCompleted ? AppConstants.GuidedActivation.BoardReadyForInvoiceStep : null }); } /// /// Shows the full job detail view: all line items with coats and prep services, status history, /// photo gallery, time entries, rework records, deposits, and the linked invoice/quote. /// Photo counts and subscription limits are checked here so the upload button shows/hides /// correctly without a separate AJAX call. Measurement units (sq ft vs m²) are resolved from /// the tenant's metric preference and passed via ViewBag. /// public async Task Details(int? id, string? guidedActivation = null) { if (id == null) { return NotFound(); } try { var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value); if (job == null) { return NotFound(); } // Build photo tag suggestions from coat colors on this job var coatColorSuggestions = job.JobItems .SelectMany(ji => ji.Coats) .Where(c => !string.IsNullOrWhiteSpace(c.ColorName)) .Select(c => c.ColorName!.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(c => c) .ToList(); ViewBag.PhotoTagSuggestions = System.Text.Json.JsonSerializer.Serialize(coatColorSuggestions); // Use AutoMapper to map the job entity to JobDto var jobDto = _mapper.Map(job); // Load change history var changeHistories = await _unitOfWork.Jobs.GetChangeHistoryAsync(id.Value); _logger.LogInformation("Loaded {Count} change history records for Job {JobId}", changeHistories.Count, id.Value); var changeHistoryDtos = _mapper.Map>(changeHistories); ViewBag.ChangeHistory = changeHistoryDtos; _logger.LogInformation("ViewBag.ChangeHistory has {Count} items", changeHistoryDtos?.Count ?? 0); var currentUserDetails = await _userManager.GetUserAsync(User); if (currentUserDetails != null) await PopulateEmailNotificationDefaultsAsync(currentUserDetails.CompanyId); // Set measurement unit labels var useMetric = await _tenantContext.UseMetricSystemAsync(); ViewBag.UseMetric = useMetric; ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); // Check if an invoice exists for this job var jobInvoice = await _unitOfWork.Invoices.GetForJobAsync(id.Value); ViewBag.JobInvoiceId = jobInvoice?.Id; ViewBag.JobInvoiceNumber = jobInvoice?.InvoiceNumber; ViewBag.JobInvoiceStatus = jobInvoice?.Status; // Workers dropdown for inline assignment await PopulateWorkersDropdown(); // Company users for time entry worker dropdown var companyUsers = await _userManager.Users .Where(u => u.CompanyId == job.CompanyId && u.IsActive) .OrderBy(u => u.FirstName).ThenBy(u => u.LastName) .ToListAsync(); ViewBag.ShopWorkers = companyUsers.Select(u => new { Id = u.Id, Name = u.FullName }).ToList(); // Populate Edit Items wizard data (inline modal on Details page) var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId); await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m); ViewBag.WizardTaxPercent = wizardCosts?.TaxPercent ?? 0m; ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m; ViewBag.ComplexityModeratePercent = wizardCosts?.ComplexityModeratePercent ?? 5m; ViewBag.ComplexityComplexPercent = wizardCosts?.ComplexityComplexPercent ?? 15m; ViewBag.ComplexityExtremePercent = wizardCosts?.ComplexityExtremePercent ?? 25m; ViewBag.WizardExistingItems = job.JobItems.Where(ji => !ji.IsDeleted) .Select(ji => new { description = ji.Description, quantity = ji.Quantity, surfaceAreaSqFt = ji.SurfaceAreaSqFt, estimatedMinutes = ji.EstimatedMinutes, catalogItemId = ji.CatalogItemId, manualUnitPrice = ji.ManualUnitPrice ?? (ji.IsGenericItem || ji.IsSalesItem ? ji.UnitPrice : (decimal?)null), powderCostOverride = ji.PowderCostOverride, isGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem), isLaborItem = ji.IsLaborItem, isSalesItem = ji.IsSalesItem, sku = ji.Sku, requiresSandblasting = ji.RequiresSandblasting, requiresMasking = ji.RequiresMasking, notes = ji.Notes, includePrepCost = ji.IncludePrepCost, complexity = ji.Complexity, coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new { coatName = c.CoatName, sequence = c.Sequence, inventoryItemId = c.InventoryItemId, colorName = c.ColorName, vendorId = c.VendorId, colorCode = c.ColorCode, finish = c.Finish, coverageSqFtPerLb = c.CoverageSqFtPerLb, transferEfficiency = c.TransferEfficiency, powderCostPerLb = c.PowderCostPerLb, powderToOrder = c.PowderToOrder, notes = c.Notes }), prepServices = ji.PrepServices.Select(ps => new { prepServiceId = ps.PrepServiceId, estimatedMinutes = ps.EstimatedMinutes }) }).ToList(); // Load deposits for this job (and any linked quote) var jobDeposits = (await _unitOfWork.Deposits.FindAsync( d => d.JobId == id.Value || (job.QuoteId.HasValue && d.QuoteId == job.QuoteId.Value), false, d => d.RecordedBy)) .OrderByDescending(d => d.ReceivedDate).ToList(); ViewBag.Deposits = jobDeposits; // Materials used on this job via QR scan or manual log ViewBag.MaterialsUsed = (await _unitOfWork.InventoryTransactions.FindAsync( t => t.JobId == id.Value, false, t => t.InventoryItem)) .OrderByDescending(t => t.TransactionDate).ToList(); // Job photo subscription limits — used to disable the upload button in the view var photoCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; ViewBag.CanUploadJobPhoto = await _subscriptionService.CanAddJobPhotoAsync(photoCompanyId, id.Value); var (photoUsed, photoMax) = await _subscriptionService.GetJobPhotoCountAsync(photoCompanyId, id.Value); ViewBag.JobPhotoUsed = photoUsed; ViewBag.JobPhotoMax = photoMax; // Customer list for inline customer-change dropdown var allCustomers = await _unitOfWork.Customers.GetAllAsync(); ViewBag.CustomerSelectList = allCustomers .Where(c => c.IsActive) .Select(c => new SelectListItem { Value = c.Id.ToString(), Text = !string.IsNullOrWhiteSpace(c.CompanyName) ? c.CompanyName : $"{c.ContactFirstName} {c.ContactLastName}".Trim() }) .OrderBy(c => c.Text) .ToList(); // Banner: warn if the source quote was edited after this job was created from it. if (job.Quote != null && job.QuoteSnapshotUpdatedAt.HasValue && job.Quote.UpdatedAt.HasValue && job.Quote.UpdatedAt > job.QuoteSnapshotUpdatedAt) { ViewBag.QuoteUpdatedAfterConversion = true; ViewBag.QuoteUpdatedAt = job.Quote.UpdatedAt.Value; ViewBag.SourceQuoteId = job.QuoteId; ViewBag.SourceQuoteNumber = job.Quote.QuoteNumber; var preProductionCodes = new HashSet(StringComparer.OrdinalIgnoreCase) { "PENDING", "QUOTED", "APPROVED" }; ViewBag.CanResyncFromQuote = preProductionCodes.Contains(job.JobStatus?.StatusCode ?? ""); } // SMS compose modal: pass pending preview (set by CompleteJob for Admin/Manager) and role flags if (TempData["PendingSmsPreview"] is string smsPreview) ViewBag.PendingSmsPreview = smsPreview; var detailsCompanyRole = User.FindFirst("CompanyRole")?.Value ?? string.Empty; ViewBag.IsAdminOrManager = detailsCompanyRole is "CompanyAdmin" or "Administrator" or "Manager"; ViewBag.SmsEnabled = HttpContext.Items["AllowSms"] is true; var jobPrefs = await GetCompanyPreferencesAsync(job.CompanyId); if (guidedActivation == AppConstants.GuidedActivation.JobCreatedStep && jobPrefs?.FirstWorkflowCompleted == false) { ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel { Show = true, Title = jobPrefs.OnboardingPath == AppConstants.GuidedActivation.QuoteFirstPath ? "Now your approved quote is a job. This is where you track it through your shop." : "This job is now live in your shop workflow.", Message = "Next, open the Daily Board and move it to the next stage so you can see how work flows across the shop.", ActionText = "Open Daily Board", ActionController = "Jobs", ActionName = "Board", ActionRouteValues = new { guidedActivation = AppConstants.GuidedActivation.BoardIntroStep, highlightJobId = job.Id } }; } return View(jobDto); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving job {JobId}", id); TempData["Error"] = "An error occurred while loading the job."; return RedirectToAction(nameof(Index)); } } /// /// Reassigns a job to a different customer. /// [HttpPost] [ValidateAntiForgeryToken] public async Task ChangeCustomer(int id, int customerId) { var job = await _unitOfWork.Jobs.GetByIdAsync(id); if (job == null) return NotFound(); var customer = await _unitOfWork.Customers.GetByIdAsync(customerId); if (customer == null) return Json(new { success = false, error = "Customer not found." }); job.CustomerId = customerId; await _unitOfWork.CompleteAsync(); var customerName = !string.IsNullOrWhiteSpace(customer.CompanyName) ? customer.CompanyName : $"{customer.ContactFirstName} {customer.ContactLastName}".Trim(); return Json(new { success = true, customerName, customerId = customer.Id }); } // ── Shop Floor QR Status Bump ──────────────────────────────────────────── /// /// Shows the status-bump selection page for a shop-floor QR code scan. /// Requires authentication — workers must be logged in before scanning. Tenant isolation /// is enforced by the normal global query filter on GetByIdAsync. /// public async Task StatusBump(int id) { var job = await _unitOfWork.Jobs.GetByIdAsync(id, false, j => j.JobStatus, j => j.JobPriority, j => j.Customer); if (job == null) return NotFound(); var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync()) .OrderBy(s => s.DisplayOrder).ToList(); ViewBag.AllStatuses = allStatuses; ViewBag.Job = job; ViewBag.JobId = id; return View(); } /// /// Processes a QR-code status bump from the shop floor. Requires authentication. /// Records the authenticated user's name in status history. /// [HttpPost] [ValidateAntiForgeryToken] public async Task StatusBump(int id, int newStatusId) { var job = await _unitOfWork.Jobs.GetByIdAsync(id, false, j => j.JobStatus, j => j.Customer); if (job == null) return NotFound(); var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync()).ToList(); var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId); if (newStatus == null) return BadRequest("Invalid status."); var oldStatusId = job.JobStatusId; job.JobStatusId = newStatusId; job.UpdatedAt = DateTime.UtcNow; if (newStatus.StatusCode == "COMPLETED") job.CompletedDate = DateTime.UtcNow; var userName = User.Identity?.Name ?? "Shop Floor"; await _unitOfWork.JobStatusHistory.AddAsync(new JobStatusHistory { JobId = job.Id, FromStatusId = oldStatusId, ToStatusId = newStatusId, ChangedDate = DateTime.UtcNow, Notes = $"Updated via shop floor QR scan by {userName}", CompanyId = job.CompanyId, CreatedAt = DateTime.UtcNow }); await _unitOfWork.CompleteAsync(); return RedirectToAction(nameof(StatusBump), new { id }); } /// /// Renders the printable work order view for a job. /// Loads all job items with their coats and prep services so the printed sheet contains /// the full powder specification, colors, and preparation instructions for shop workers. /// Generates two sets of QR codes: a top "view" code linking to the authenticated job /// details page, and bottom action codes for status bumping and powder usage logging. /// public async Task WorkOrder(int? id) { if (id == null) { return NotFound(); } try { var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value); if (job == null) { return NotFound(); } // Use AutoMapper to map the job entity to JobDto var jobDto = _mapper.Map(job); // Get company info for the header var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId.HasValue) { var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value); ViewBag.Company = company; } using var qrGenerator = new QRCoder.QRCodeGenerator(); // Top QR: view/verify the job on mobile (authenticated job details page) var detailsUrl = Url.Action("Details", "Jobs", new { id = job.Id }, Request.Scheme)!; using var viewQrData = qrGenerator.CreateQrCode(detailsUrl, QRCoder.QRCodeGenerator.ECCLevel.M); using var viewQrCode = new QRCoder.PngByteQRCode(viewQrData); ViewBag.ViewQrCodeBase64 = Convert.ToBase64String(viewQrCode.GetGraphic(4)); // Bottom QR: status bump (authenticated, job ID routed) var statusBumpUrl = Url.Action("StatusBump", "Jobs", new { id = job.Id }, Request.Scheme)!; using var qrData = qrGenerator.CreateQrCode(statusBumpUrl, QRCoder.QRCodeGenerator.ECCLevel.M); using var qrCode = new QRCoder.PngByteQRCode(qrData); ViewBag.QrCodeBase64 = Convert.ToBase64String(qrCode.GetGraphic(4)); ViewBag.StatusBumpUrl = statusBumpUrl; // Generate QR codes for each unique inventory powder item so workers can // scan and record actual usage directly from the work order. var powderQrCodes = new List(); var seenInventoryIds = new HashSet(); var allCoats = job.JobItems.SelectMany(ji => ji.Coats).ToList(); foreach (var coat in allCoats.Where(c => c.InventoryItemId.HasValue)) { if (!seenInventoryIds.Add(coat.InventoryItemId!.Value)) continue; var scanUrl = Url.Action("Scan", "Inventory", new { id = coat.InventoryItemId.Value, jobId = job.Id }, Request.Scheme)!; using var pqData = qrGenerator.CreateQrCode(scanUrl, QRCoder.QRCodeGenerator.ECCLevel.M); using var pqCode = new QRCoder.PngByteQRCode(pqData); var pqBytes = pqCode.GetGraphic(4); var totalLbs = allCoats .Where(c => c.InventoryItemId == coat.InventoryItemId && c.PowderToOrder.HasValue) .Sum(c => c.PowderToOrder!.Value); powderQrCodes.Add(new PowderQrCodeInfo { Base64 = Convert.ToBase64String(pqBytes), Name = coat.InventoryItem?.ColorName ?? coat.InventoryItem?.Name ?? "Unknown Powder", ColorCode = coat.InventoryItem?.ColorCode, Manufacturer = coat.InventoryItem?.Manufacturer, TotalLbs = totalLbs }); } ViewBag.PowderQrCodes = powderQrCodes; return View(jobDto); } catch (Exception ex) { _logger.LogError(ex, "Error generating work order for job {JobId}", id); TempData["Error"] = "An error occurred while generating the work order."; return RedirectToAction(nameof(Details), new { id }); } } /// /// Renders the job intake form where staff record the physical receipt of customer parts: /// actual part count (may differ from quoted), condition notes, and an optional photo. /// The AdvanceToInPreparation flag is checked by default on first intake but unchecked /// on re-intake (so re-checking parts doesn't unintentionally bump the status back). /// [Authorize(Policy = "CanManageJobs")] public async Task Intake(int? id) { if (id == null) return NotFound(); var job = await _unitOfWork.Jobs.GetByIdAsync(id.Value, false, j => j.Customer, j => j.JobStatus, j => j.JobItems, j => j.IntakeCheckedBy); if (job == null) return NotFound(); var jobDto = _mapper.Map(job); var form = new IntakeJobDto { JobId = job.Id, ActualPartCount = job.IntakePartCount, ConditionNotes = job.IntakeConditionNotes, AdvanceToInPreparation = !job.IntakeDate.HasValue // default checked only on first intake }; // Expected part count from job items ViewBag.ExpectedPartCount = job.JobItems.Sum(i => (int)i.Quantity); return View((jobDto, form)); } /// /// Saves the intake record: records the actual part count, condition notes, intake date, /// and which user performed the check. Reads form values directly (not model binding) because /// the checkbox's hidden-field pattern requires special handling to distinguish checked from unchecked. /// If AdvanceToInPreparation is checked the job status is bumped to InPreparation and a SignalR /// broadcast fires so the Kanban board updates live. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = "CanManageJobs")] public async Task Intake(int id) { int? actualPartCount = null; if (int.TryParse(Request.Form["actualPartCount"], out int parsedCount)) actualPartCount = parsedCount; var conditionNotes = Request.Form["conditionNotes"].FirstOrDefault()?.Trim(); if (string.IsNullOrEmpty(conditionNotes)) conditionNotes = null; // Hidden field posts "false"; checked checkbox also posts "true" — take any "true" value. var advanceToInPreparation = Request.Form["advanceToInPreparation"] .Any(v => string.Equals(v, "true", StringComparison.OrdinalIgnoreCase)); var jobToUpdate = await _unitOfWork.Jobs.GetByIdAsync(id, false, j => j.JobStatus, j => j.JobItems); if (jobToUpdate == null) return NotFound(); var userId = _userManager.GetUserId(User); var now = DateTime.UtcNow; jobToUpdate.IntakeDate = now; jobToUpdate.IntakePartCount = actualPartCount; jobToUpdate.IntakeConditionNotes = conditionNotes; jobToUpdate.IntakeCheckedByUserId = userId; jobToUpdate.UpdatedAt = now; // Optionally advance status to In Preparation if (advanceToInPreparation && jobToUpdate.JobStatus.StatusCode != "IN_PREPARATION") { var allStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync(); var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == "IN_PREPARATION"); if (inPrepStatus != null) { var oldStatusId = jobToUpdate.JobStatusId; jobToUpdate.JobStatusId = inPrepStatus.Id; if (jobToUpdate.StartedDate == null) jobToUpdate.StartedDate = now; await _unitOfWork.JobStatusHistory.AddAsync(new JobStatusHistory { JobId = jobToUpdate.Id, FromStatusId = oldStatusId, ToStatusId = inPrepStatus.Id, ChangedDate = now, Notes = "Advanced via part intake check-in", CompanyId = jobToUpdate.CompanyId, CreatedAt = now }); } } await _unitOfWork.CompleteAsync(); _logger.LogInformation("Intake recorded for job {JobId} by user {UserId}", id, userId); TempData["Success"] = "Parts checked in successfully."; return RedirectToAction(nameof(Details), new { id }); } // POST: Jobs/IntakeRecord/5 (AJAX — returns JSON for modal) [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = "CanManageJobs")] public async Task IntakeRecord(int id) { int? actualPartCount = null; if (int.TryParse(Request.Form["actualPartCount"], out int parsedCount)) actualPartCount = parsedCount; var conditionNotes = Request.Form["conditionNotes"].FirstOrDefault()?.Trim(); if (string.IsNullOrEmpty(conditionNotes)) conditionNotes = null; var advanceToInPreparation = Request.Form["advanceToInPreparation"] .Any(v => string.Equals(v, "true", StringComparison.OrdinalIgnoreCase)); var job = await _unitOfWork.Jobs.GetByIdAsync(id, false, j => j.JobStatus, j => j.JobItems); if (job == null) return Json(new { success = false, message = "Job not found." }); var userId = _userManager.GetUserId(User); var now = DateTime.UtcNow; job.IntakeDate = now; job.IntakePartCount = actualPartCount; job.IntakeConditionNotes = conditionNotes; job.IntakeCheckedByUserId = userId; job.UpdatedAt = now; if (advanceToInPreparation && job.JobStatus.StatusCode != "IN_PREPARATION" && !job.JobStatus.IsTerminalStatus) { var allStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync(); var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == "IN_PREPARATION"); if (inPrepStatus != null) { var oldStatusId = job.JobStatusId; job.JobStatusId = inPrepStatus.Id; if (job.StartedDate == null) job.StartedDate = now; await _unitOfWork.JobStatusHistory.AddAsync(new JobStatusHistory { JobId = job.Id, FromStatusId = oldStatusId, ToStatusId = inPrepStatus.Id, ChangedDate = now, Notes = "Advanced via part intake check-in", CompanyId = job.CompanyId }); } } await _unitOfWork.CompleteAsync(); _logger.LogInformation("Intake recorded (modal) for job {JobId} by user {UserId}", id, userId); return Json(new { success = true }); } /// /// Renders the job creation wizard, checking the subscription active-job limit before rendering. /// If is provided, the wizard is pre-populated from a Job Template /// (pre-configured job types with standard items). If is provided, /// the customer dropdown is pre-selected. The wizard is the same multi-step UI as the quote wizard. /// public async Task Create(int? customerId, int? templateId, string? guidedActivation = null) { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; // Subscription limit check — fail fast before rendering the form if (!await _subscriptionService.CanAddJobAsync(companyId)) { var (used, max) = await _subscriptionService.GetJobCountAsync(companyId); TempData["Error"] = $"You have reached your plan limit of {max} active jobs. " + "Please upgrade your plan or complete/cancel existing jobs to add more."; return RedirectToAction(nameof(Index)); } await PopulateCreateEditWizardViewBagsAsync(companyId); var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId); var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL"); var dto = new CreateJobDto { JobPriorityId = normalPriority?.Id ?? 1 }; if (customerId.HasValue) dto.CustomerId = customerId.Value; if (guidedActivation == AppConstants.GuidedActivation.JobFirstPath && customerId.HasValue) { dto = GuidedActivationDefaults.BuildJobDraft(customerId.Value, normalPriority?.Id ?? 1); } // Pre-populate from template if provided if (templateId.HasValue) { var template = await _unitOfWork.JobTemplates.GetByIdAsync(templateId.Value); if (template != null) { var templateItemsEnum = await _unitOfWork.JobTemplateItems.FindAsync( i => i.JobTemplateId == templateId.Value, false, i => i.Coats, i => i.PrepServices); var templateItems = templateItemsEnum.ToList(); var tplPrepIds = templateItems .SelectMany(i => i.PrepServices.Select(p => p.PrepServiceId)) .Distinct().ToList(); Dictionary tplPrepNameMap = new(); if (tplPrepIds.Any()) { var tplPreps = await _unitOfWork.PrepServices.FindAsync(p => tplPrepIds.Contains(p.Id)); tplPrepNameMap = tplPreps.ToDictionary(p => p.Id, p => p.ServiceName); } if (!customerId.HasValue && template.CustomerId.HasValue) dto.CustomerId = template.CustomerId.Value; dto.SpecialInstructions = template.SpecialInstructions; ViewBag.TemplateId = template.Id; ViewBag.TemplateName = template.Name; ViewBag.TemplateJson = System.Text.Json.JsonSerializer.Serialize(new { id = template.Id, name = template.Name, items = templateItems.OrderBy(i => i.DisplayOrder).Select(i => new { description = i.Description, quantity = i.Quantity, surfaceAreaSqFt = i.SurfaceAreaSqFt, catalogItemId = i.CatalogItemId, isGenericItem = i.IsGenericItem, isLaborItem = i.IsLaborItem, isSalesItem = false, // JobTemplateItem doesn't have IsSalesItem — default false sku = (string?)null, manualUnitPrice = i.ManualUnitPrice, requiresSandblasting = i.RequiresSandblasting, requiresMasking = i.RequiresMasking, includePrepCost = i.IncludePrepCost, estimatedMinutes = i.EstimatedMinutes, complexity = i.Complexity, coats = i.Coats.OrderBy(c => c.Sequence).Select(c => new { coatName = c.CoatName, sequence = c.Sequence, inventoryItemId = c.InventoryItemId, colorName = c.ColorName, colorCode = c.ColorCode, finish = c.Finish, coverageSqFtPerLb = c.CoverageSqFtPerLb, transferEfficiency = c.TransferEfficiency, powderCostPerLb = c.PowderCostPerLb }), prepServices = i.PrepServices.Select(p => new { prepServiceId = p.PrepServiceId, prepServiceName = tplPrepNameMap.TryGetValue(p.PrepServiceId, out var psName) ? psName : null, estimatedMinutes = p.EstimatedMinutes }) }) }); } } ViewBag.GuidedActivation = guidedActivation; return View(dto); } /// /// Saves a new job with all its line items, coats, and prep services. /// Generates a sequential job number, assigns a ShopAccessCode GUID (for QR-code-based /// status bumps), calculates and stores PowderToOrder per coat from the surface area and /// coverage values, and promotes any AI photo temp files to the permanent job-photos path. /// After save, broadcasts a SignalR JobBoardUpdated event so the Kanban board refreshes. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Create(CreateJobDto dto, string? guidedActivation = null) { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; if (!ModelState.IsValid) { await PopulateCreateEditWizardViewBagsAsync(companyId); ViewBag.GuidedActivation = guidedActivation; return View(dto); } // Subscription limit check if (!await _subscriptionService.CanAddJobAsync(companyId)) { var (used, max) = await _subscriptionService.GetJobCountAsync(companyId); ModelState.AddModelError(string.Empty, $"You have reached your plan limit of {max} active jobs. " + "Please upgrade your plan or complete/cancel existing jobs to add more."); await PopulateCreateEditWizardViewBagsAsync(companyId); ViewBag.GuidedActivation = guidedActivation; return View(dto); } try { // Get default "Pending" status (cached) var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId); var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == "PENDING"); var job = new Job { JobNumber = await GenerateJobNumber(), CustomerId = dto.CustomerId, QuoteId = dto.QuoteId, AssignedUserId = dto.AssignedUserId, Description = dto.Description, JobPriorityId = dto.JobPriorityId, JobStatusId = pendingStatus?.Id ?? 1, ScheduledDate = dto.ScheduledDate, DueDate = dto.DueDate, QuotedPrice = dto.QuotedPrice, FinalPrice = dto.QuotedPrice, CustomerPO = dto.CustomerPO, SpecialInstructions = dto.SpecialInstructions, RequiresCustomerApproval = dto.RequiresCustomerApproval, IsRushJob = dto.IsRushJob, DiscountType = Enum.TryParse(dto.DiscountType, out var createDt) ? createDt : PowderCoating.Core.Enums.DiscountType.None, DiscountValue = dto.DiscountValue, DiscountReason = dto.DiscountReason, CreatedAt = DateTime.UtcNow }; await _unitOfWork.Jobs.AddAsync(job); await _unitOfWork.SaveChangesAsync(); // Save prep services if (dto.PrepServiceIds != null && dto.PrepServiceIds.Any()) { foreach (var prepServiceId in dto.PrepServiceIds) { await _unitOfWork.JobPrepServices.AddAsync(new JobPrepService { JobId = job.Id, PrepServiceId = prepServiceId, CompanyId = companyId }); } await _unitOfWork.CompleteAsync(); } // Save job items from wizard if (dto.JobItems.Any()) { foreach (var itemDto in dto.JobItems) { var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync( itemDto, companyId, null); var jobItem = new JobItem { JobId = job.Id, Description = itemDto.Description, Quantity = itemDto.Quantity, SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt, EstimatedMinutes = itemDto.EstimatedMinutes, CatalogItemId = itemDto.CatalogItemId, IsGenericItem = itemDto.IsGenericItem, IsLaborItem = itemDto.IsLaborItem, IsSalesItem = itemDto.IsSalesItem, Sku = itemDto.Sku, ManualUnitPrice = itemDto.ManualUnitPrice, PowderCostOverride = itemDto.PowderCostOverride, RequiresSandblasting = itemDto.RequiresSandblasting, RequiresMasking = itemDto.RequiresMasking, Notes = itemDto.Notes, IncludePrepCost = itemDto.IncludePrepCost, Complexity = itemDto.Complexity, UnitPrice = itemPricing.UnitPrice, TotalPrice = itemPricing.TotalPrice, LaborCost = itemPricing.TotalPrice * 0.4m, CompanyId = companyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.JobItems.AddAsync(jobItem); await _unitOfWork.SaveChangesAsync(); if (itemDto.Coats?.Any() == true) { foreach (var coatDto in itemDto.Coats.OrderBy(c => c.Sequence)) { decimal? powderToOrder = coatDto.PowderToOrder; if ((powderToOrder == null || powderToOrder == 0) && itemDto.SurfaceAreaSqFt > 0) { var cov = coatDto.CoverageSqFtPerLb > 0 ? coatDto.CoverageSqFtPerLb : 30m; var eff = coatDto.TransferEfficiency > 0 ? coatDto.TransferEfficiency / 100m : 0.65m; powderToOrder = Math.Round((itemDto.SurfaceAreaSqFt * itemDto.Quantity) / (cov * eff), 2); } await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat { JobItemId = jobItem.Id, CoatName = coatDto.CoatName, Sequence = coatDto.Sequence, InventoryItemId = coatDto.InventoryItemId, ColorName = coatDto.ColorName, VendorId = coatDto.VendorId, ColorCode = coatDto.ColorCode, Finish = coatDto.Finish, CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb, TransferEfficiency = coatDto.TransferEfficiency, PowderCostPerLb = coatDto.PowderCostPerLb, PowderToOrder = powderToOrder, Notes = coatDto.Notes, CompanyId = companyId, CreatedAt = DateTime.UtcNow }); } } if (itemDto.PrepServices?.Any() == true) { foreach (var psDto in itemDto.PrepServices) { await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService { JobItemId = jobItem.Id, PrepServiceId = psDto.PrepServiceId, EstimatedMinutes = psDto.EstimatedMinutes, BlastSetupId = psDto.BlastSetupId, CompanyId = companyId, CreatedAt = DateTime.UtcNow }); } } } // Recalculate total from wizard items var createCosts = await _pricingService.GetOperatingCostsAsync(companyId); var totals = await _pricingService.CalculateQuoteTotalsAsync( dto.JobItems, companyId, dto.CustomerId, createCosts?.TaxPercent ?? 0m, dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null); job.FinalPrice = totals.Total; job.UpdatedAt = DateTime.UtcNow; await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.SaveChangesAsync(); } await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "Created", $"New job created"); var createCompanyId = _tenantContext.GetCurrentCompanyId()?.ToString(); if (!string.IsNullOrEmpty(createCompanyId)) await _shopHub.Clients.Group($"shop-{createCompanyId}").SendAsync("DailyBoardUpdated"); await StampJobCreatedAsync(companyId); this.ToastSuccess($"Job {job.JobNumber} created successfully!"); if (guidedActivation == AppConstants.GuidedActivation.JobFirstPath) { return RedirectToAction(nameof(Details), new { id = job.Id, guidedActivation = AppConstants.GuidedActivation.JobCreatedStep }); } return RedirectToAction(nameof(Details), new { id = job.Id }); } catch (Exception ex) { _logger.LogError(ex, "Error creating job"); this.ToastError("An error occurred while creating the job. Please try again."); await PopulateCreateEditWizardViewBagsAsync(companyId); ViewBag.GuidedActivation = guidedActivation; return View(dto); } } /// /// Loads the job edit wizard pre-populated with current data including all items, /// coats, and prep services. Items are loaded via the wizard ViewBag structure so /// the same item-wizard.js works for both Create and Edit flows. /// public async Task Edit(int? id) { if (id == null) { return NotFound(); } try { var job = await _unitOfWork.Jobs.LoadForEditAsync(id.Value); if (job == null) { return NotFound(); } var dto = new UpdateJobDto { Id = job.Id, CustomerId = job.CustomerId, QuoteId = job.QuoteId, AssignedUserId = job.AssignedUserId, Description = job.Description, JobStatusId = job.JobStatusId, JobPriorityId = job.JobPriorityId, ScheduledDate = job.ScheduledDate, DueDate = job.DueDate, QuotedPrice = job.QuotedPrice, CustomerPO = job.CustomerPO, SpecialInstructions = job.SpecialInstructions, RequiresCustomerApproval = job.RequiresCustomerApproval, IsRushJob = job.IsRushJob, Tags = job.Tags, DiscountType = job.DiscountType.ToString(), DiscountValue = job.DiscountValue, DiscountReason = job.DiscountReason, JobItems = job.JobItems.Where(ji => !ji.IsDeleted).Select(ji => new CreateQuoteItemDto { Description = ji.Description, Quantity = ji.Quantity, SurfaceAreaSqFt = ji.SurfaceAreaSqFt, EstimatedMinutes = ji.EstimatedMinutes, CatalogItemId = ji.CatalogItemId, ManualUnitPrice = ji.ManualUnitPrice ?? (ji.IsGenericItem ? ji.UnitPrice : null), PowderCostOverride = ji.PowderCostOverride, IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0), IsLaborItem = ji.IsLaborItem, RequiresSandblasting = ji.RequiresSandblasting, RequiresMasking = ji.RequiresMasking, Notes = ji.Notes, IncludePrepCost = ji.IncludePrepCost, Complexity = ji.Complexity, Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto { CoatName = c.CoatName, Sequence = c.Sequence, InventoryItemId = c.InventoryItemId, ColorName = c.ColorName, VendorId = c.VendorId, ColorCode = c.ColorCode, Finish = c.Finish, CoverageSqFtPerLb = c.CoverageSqFtPerLb, TransferEfficiency = c.TransferEfficiency, PowderCostPerLb = c.PowderCostPerLb, PowderToOrder = c.PowderToOrder, Notes = c.Notes }).ToList(), PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto { PrepServiceId = ps.PrepServiceId, EstimatedMinutes = ps.EstimatedMinutes }).ToList() }).ToList(), PrepServiceIds = job.JobPrepServices.Select(jps => jps.PrepServiceId).ToList() }; var editCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; await PopulateCreateEditWizardViewBagsAsync(editCompanyId); var currentUserEdit = await _userManager.GetUserAsync(User); if (currentUserEdit != null) { await PopulateEmailNotificationDefaultsAsync(currentUserEdit.CompanyId); dto.SendEmailOnStatusChange = (bool)(ViewBag.EmailDefaultOnStatusChange ?? false); } // Used by view to hide the email checkbox when the customer has no email address on file ViewBag.CustomerEmail = job.Customer?.Email; return View(dto); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving job {JobId} for edit", id); TempData["Error"] = "An error occurred while loading the job."; return RedirectToAction(nameof(Index)); } } // POST: Jobs/Edit/5 /// /// Saves edits to a job's core fields (customer, description, dates, priority, etc.). /// Does NOT replace line items — items are managed separately via /// and to keep the item-editing UX focused and avoid losing /// in-progress work if the header edit form is submitted independently. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Edit(int id, UpdateJobDto dto) { if (id != dto.Id) { return NotFound(); } var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; if (!ModelState.IsValid) { await PopulateCreateEditWizardViewBagsAsync(companyId); return View(dto); } try { // Replace job items: soft-delete old, recreate from wizard var oldItemsEnum = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == id); var oldItems = oldItemsEnum.ToList(); var itemsChanged = dto.JobItems.Any() || oldItems.Any(); foreach (var oldItem in oldItems) { oldItem.IsDeleted = true; oldItem.DeletedAt = DateTime.UtcNow; } if (oldItems.Any()) await _unitOfWork.CompleteAsync(); foreach (var itemDto in dto.JobItems) { var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync( itemDto, companyId, null); var jobItem = new JobItem { JobId = id, Description = itemDto.Description, Quantity = itemDto.Quantity, SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt, EstimatedMinutes = itemDto.EstimatedMinutes, CatalogItemId = itemDto.CatalogItemId, IsGenericItem = itemDto.IsGenericItem, IsLaborItem = itemDto.IsLaborItem, ManualUnitPrice = itemDto.ManualUnitPrice, PowderCostOverride = itemDto.PowderCostOverride, RequiresSandblasting = itemDto.RequiresSandblasting, RequiresMasking = itemDto.RequiresMasking, Notes = itemDto.Notes, IncludePrepCost = itemDto.IncludePrepCost, Complexity = itemDto.Complexity, UnitPrice = itemPricing.UnitPrice, TotalPrice = itemPricing.TotalPrice, LaborCost = itemPricing.TotalPrice * 0.4m, CompanyId = companyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.JobItems.AddAsync(jobItem); await _unitOfWork.SaveChangesAsync(); if (itemDto.Coats?.Any() == true) { foreach (var coatDto in itemDto.Coats.OrderBy(c => c.Sequence)) { decimal? powderToOrder = coatDto.PowderToOrder; if ((powderToOrder == null || powderToOrder == 0) && itemDto.SurfaceAreaSqFt > 0) { var cov = coatDto.CoverageSqFtPerLb > 0 ? coatDto.CoverageSqFtPerLb : 30m; var eff = coatDto.TransferEfficiency > 0 ? coatDto.TransferEfficiency / 100m : 0.65m; powderToOrder = Math.Round((itemDto.SurfaceAreaSqFt * itemDto.Quantity) / (cov * eff), 2); } await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat { JobItemId = jobItem.Id, CoatName = coatDto.CoatName, Sequence = coatDto.Sequence, InventoryItemId = coatDto.InventoryItemId, ColorName = coatDto.ColorName, VendorId = coatDto.VendorId, ColorCode = coatDto.ColorCode, Finish = coatDto.Finish, CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb, TransferEfficiency = coatDto.TransferEfficiency, PowderCostPerLb = coatDto.PowderCostPerLb, PowderToOrder = powderToOrder, Notes = coatDto.Notes, CompanyId = companyId, CreatedAt = DateTime.UtcNow }); } } if (itemDto.PrepServices?.Any() == true) { foreach (var psDto in itemDto.PrepServices) { await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService { JobItemId = jobItem.Id, PrepServiceId = psDto.PrepServiceId, EstimatedMinutes = psDto.EstimatedMinutes, CompanyId = companyId, CreatedAt = DateTime.UtcNow }); } } } // Now load and update the job itself var job = await _unitOfWork.Jobs.GetByIdAsync(id); if (job == null) { return NotFound(); } // Get current user for change tracking var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) { return Unauthorized(); } // Capture old job values for change tracking var oldJobValues = new { CustomerId = job.CustomerId, JobStatusId = job.JobStatusId, JobPriorityId = job.JobPriorityId, AssignedUserId = job.AssignedUserId, Description = job.Description, ScheduledDate = job.ScheduledDate, DueDate = job.DueDate, QuotedPrice = job.QuotedPrice, CustomerPO = job.CustomerPO, SpecialInstructions = job.SpecialInstructions, RequiresCustomerApproval = job.RequiresCustomerApproval }; // Get status info to check for transitions var newStatus = await _unitOfWork.JobStatusLookups.GetByIdAsync(dto.JobStatusId); var oldStatusId = job.JobStatusId; job.CustomerId = dto.CustomerId; job.QuoteId = dto.QuoteId; job.Description = dto.Description; await RecordStatusChangeAsync(job, dto.JobStatusId); job.JobStatusId = dto.JobStatusId; job.JobPriorityId = dto.JobPriorityId; job.AssignedUserId = dto.AssignedUserId; job.ScheduledDate = dto.ScheduledDate; job.DueDate = dto.DueDate; job.QuotedPrice = dto.QuotedPrice; job.FinalPrice = dto.QuotedPrice; // Update final price with quoted price job.CustomerPO = dto.CustomerPO; job.SpecialInstructions = dto.SpecialInstructions; job.RequiresCustomerApproval = dto.RequiresCustomerApproval; job.IsRushJob = dto.IsRushJob; job.DiscountType = Enum.TryParse(dto.DiscountType, out var editDt) ? editDt : PowderCoating.Core.Enums.DiscountType.None; job.DiscountValue = dto.DiscountValue; job.DiscountReason = dto.DiscountReason; job.Tags = string.IsNullOrWhiteSpace(dto.Tags) ? null : dto.Tags.Trim(); job.UpdatedAt = DateTime.UtcNow; // Update status-related dates if (oldStatusId != dto.JobStatusId && newStatus != null) { if (newStatus.StatusCode == "IN_PREPARATION" && job.StartedDate == null) { job.StartedDate = DateTime.UtcNow; } else if (newStatus.StatusCode == "COMPLETED" && job.CompletedDate == null) { job.CompletedDate = DateTime.UtcNow; } } await _unitOfWork.Jobs.UpdateAsync(job); // Track job field changes var changeHistories = new List(); if (oldJobValues.JobStatusId != job.JobStatusId) { var oldStatus = await _unitOfWork.JobStatusLookups.GetByIdAsync(oldJobValues.JobStatusId); var newStatusLookup = await _unitOfWork.JobStatusLookups.GetByIdAsync(job.JobStatusId); changeHistories.Add(new JobChangeHistory { JobId = job.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Status", OldValue = oldStatus?.DisplayName ?? oldJobValues.JobStatusId.ToString(), NewValue = newStatusLookup?.DisplayName ?? job.JobStatusId.ToString(), ChangeDescription = $"Status changed from {oldStatus?.DisplayName} to {newStatusLookup?.DisplayName}", CompanyId = currentUser.CompanyId }); } if (oldJobValues.JobPriorityId != job.JobPriorityId) { var oldPriority = await _unitOfWork.JobPriorityLookups.GetByIdAsync(oldJobValues.JobPriorityId); var newPriority = await _unitOfWork.JobPriorityLookups.GetByIdAsync(job.JobPriorityId); changeHistories.Add(new JobChangeHistory { JobId = job.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Priority", OldValue = oldPriority?.DisplayName ?? oldJobValues.JobPriorityId.ToString(), NewValue = newPriority?.DisplayName ?? job.JobPriorityId.ToString(), ChangeDescription = $"Priority changed from {oldPriority?.DisplayName} to {newPriority?.DisplayName}", CompanyId = currentUser.CompanyId }); } if (oldJobValues.AssignedUserId != job.AssignedUserId) { var oldUser = !string.IsNullOrEmpty(oldJobValues.AssignedUserId) ? await _userManager.FindByIdAsync(oldJobValues.AssignedUserId) : null; var newUser = !string.IsNullOrEmpty(job.AssignedUserId) ? await _userManager.FindByIdAsync(job.AssignedUserId) : null; changeHistories.Add(new JobChangeHistory { JobId = job.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Assigned Worker", OldValue = oldUser?.FullName ?? "Unassigned", NewValue = newUser?.FullName ?? "Unassigned", ChangeDescription = $"Worker changed from {oldUser?.FullName ?? "Unassigned"} to {newUser?.FullName ?? "Unassigned"}", CompanyId = currentUser.CompanyId }); } if (oldJobValues.Description != job.Description) { changeHistories.Add(new JobChangeHistory { JobId = job.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Description", OldValue = oldJobValues.Description ?? "None", NewValue = job.Description ?? "None", ChangeDescription = "Description updated", CompanyId = currentUser.CompanyId }); } if (oldJobValues.ScheduledDate != job.ScheduledDate) { changeHistories.Add(new JobChangeHistory { JobId = job.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Scheduled Date", OldValue = oldJobValues.ScheduledDate?.ToString("MM/dd/yyyy") ?? "None", NewValue = job.ScheduledDate?.ToString("MM/dd/yyyy") ?? "None", ChangeDescription = $"Scheduled date changed from {oldJobValues.ScheduledDate?.ToString("MM/dd/yyyy") ?? "None"} to {job.ScheduledDate?.ToString("MM/dd/yyyy") ?? "None"}", CompanyId = currentUser.CompanyId }); } if (oldJobValues.DueDate != job.DueDate) { changeHistories.Add(new JobChangeHistory { JobId = job.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Due Date", OldValue = oldJobValues.DueDate?.ToString("MM/dd/yyyy") ?? "None", NewValue = job.DueDate?.ToString("MM/dd/yyyy") ?? "None", ChangeDescription = $"Due date changed from {oldJobValues.DueDate?.ToString("MM/dd/yyyy") ?? "None"} to {job.DueDate?.ToString("MM/dd/yyyy") ?? "None"}", CompanyId = currentUser.CompanyId }); } if (Math.Round(oldJobValues.QuotedPrice, 2) != Math.Round(job.QuotedPrice, 2)) { changeHistories.Add(new JobChangeHistory { JobId = job.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Quoted Price", OldValue = oldJobValues.QuotedPrice.ToString("C"), NewValue = job.QuotedPrice.ToString("C"), ChangeDescription = $"Quoted price changed from {oldJobValues.QuotedPrice:C} to {job.QuotedPrice:C}", CompanyId = currentUser.CompanyId }); } if (oldJobValues.CustomerPO != job.CustomerPO) { changeHistories.Add(new JobChangeHistory { JobId = job.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Customer PO", OldValue = oldJobValues.CustomerPO ?? "None", NewValue = job.CustomerPO ?? "None", ChangeDescription = $"Customer PO changed from '{oldJobValues.CustomerPO ?? "None"}' to '{job.CustomerPO ?? "None"}'", CompanyId = currentUser.CompanyId }); } if (oldJobValues.SpecialInstructions != job.SpecialInstructions) { changeHistories.Add(new JobChangeHistory { JobId = job.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Special Instructions", OldValue = string.IsNullOrEmpty(oldJobValues.SpecialInstructions) ? "None" : (oldJobValues.SpecialInstructions.Length > 50 ? oldJobValues.SpecialInstructions.Substring(0, 50) + "..." : oldJobValues.SpecialInstructions), NewValue = string.IsNullOrEmpty(job.SpecialInstructions) ? "None" : (job.SpecialInstructions.Length > 50 ? job.SpecialInstructions.Substring(0, 50) + "..." : job.SpecialInstructions), ChangeDescription = "Special instructions updated", CompanyId = currentUser.CompanyId }); } if (oldJobValues.RequiresCustomerApproval != job.RequiresCustomerApproval) { changeHistories.Add(new JobChangeHistory { JobId = job.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Requires Customer Approval", OldValue = oldJobValues.RequiresCustomerApproval.ToString(), NewValue = job.RequiresCustomerApproval.ToString(), ChangeDescription = $"Customer approval requirement changed to {job.RequiresCustomerApproval}", CompanyId = currentUser.CompanyId }); } // Track item changes (simple entry — items are rebuilt via wizard) if (itemsChanged) { changeHistories.Add(new JobChangeHistory { JobId = job.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Job Items", OldValue = "Previous items", NewValue = $"{dto.JobItems.Count} item(s)", ChangeDescription = "Job items updated", CompanyId = currentUser.CompanyId }); } // Recalculate FinalPrice from wizard items if (dto.JobItems.Any()) { var editCosts = await _pricingService.GetOperatingCostsAsync(companyId); var totals = await _pricingService.CalculateQuoteTotalsAsync( dto.JobItems, companyId, dto.CustomerId, editCosts?.TaxPercent ?? 0m, dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null); job.FinalPrice = totals.Total; } // Save change history records foreach (var history in changeHistories) { await _unitOfWork.JobChangeHistories.AddAsync(history); } // Update prep services // Soft-delete existing prep services var existingPrepServicesEnum = await _unitOfWork.JobPrepServices.FindAsync(jps => jps.JobId == job.Id); foreach (var eps in existingPrepServicesEnum) { eps.IsDeleted = true; eps.DeletedAt = DateTime.UtcNow; } // Add new prep services if (dto.PrepServiceIds != null && dto.PrepServiceIds.Any()) { foreach (var prepServiceId in dto.PrepServiceIds) { var jobPrepService = new JobPrepService { JobId = job.Id, PrepServiceId = prepServiceId, CompanyId = currentUser.CompanyId }; await _unitOfWork.JobPrepServices.AddAsync(jobPrepService); } _logger.LogInformation("Updated prep services for job {JobNumber}: {Count} services", job.JobNumber, dto.PrepServiceIds.Count); } await _unitOfWork.SaveChangesAsync(); // Notify shop display of status or priority changes var editCompanyId = _tenantContext.GetCurrentCompanyId()?.ToString(); if (!string.IsNullOrEmpty(editCompanyId)) { if (oldJobValues.JobStatusId != job.JobStatusId) await _shopHub.Clients.Group($"shop-{editCompanyId}").SendAsync("JobStatusChanged", new { jobId = job.Id, jobNumber = job.JobNumber, statusDisplayName = newStatus?.DisplayName, statusColorClass = newStatus?.ColorClass }); else if (oldJobValues.JobPriorityId != job.JobPriorityId) await _shopHub.Clients.Group($"shop-{editCompanyId}").SendAsync("DailyBoardUpdated"); } // Notify customer on status change (only if status actually changed and user opted in) if (dto.SendEmailOnStatusChange && oldJobValues.JobStatusId != job.JobStatusId && newStatus != null) { try { var jobForNotify = await _unitOfWork.Jobs.GetByIdAsync(job.Id, false, j => j.Customer); if (jobForNotify != null) await _notificationService.NotifyJobStatusChangedAsync( jobForNotify, newStatus.StatusCode, newStatus.DisplayName ?? string.Empty); } catch (Exception ex) { _logger.LogWarning(ex, "Notification failed for job {Id}", job.Id); } var editNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(job.Id); this.SetNotificationResultToast(editNotifLog); } this.ToastSuccess("Job updated successfully!"); return RedirectToAction(nameof(Details), new { id = job.Id }); } catch (Exception ex) { _logger.LogError(ex, "Error updating job {JobId}", id); TempData["Error"] = "An error occurred while updating the job."; await PopulateCreateEditWizardViewBagsAsync(companyId); return View(dto); } } /// /// Shows the job delete confirmation page. Loads the job summary so the user can /// verify they're deleting the right job before confirming. /// public async Task Delete(int? id) { if (id == null) { return NotFound(); } try { var job = await _unitOfWork.Jobs.GetByIdAsync(id.Value, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority); if (job == null) { return NotFound(); } // Use AutoMapper to map the job entity to JobDto var jobDto = _mapper.Map(job); return View(jobDto); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving job {JobId} for delete", id); TempData["Error"] = "An error occurred while loading the job."; return RedirectToAction(nameof(Index)); } } /// /// Soft-deletes a job (sets IsDeleted = true). The subscription active-job counter decreases /// after deletion, which may allow the tenant to create new jobs if they were at their plan limit. /// [HttpPost, ActionName("Delete")] [ValidateAntiForgeryToken] public async Task DeleteConfirmed(int id) { try { var job = await _unitOfWork.Jobs.GetByIdAsync(id); if (job == null) { return NotFound(); } // If this job was created from a quote, reset the quote so it can generate a new job if (job.QuoteId.HasValue) { var quote = await _unitOfWork.Quotes.GetByIdAsync(job.QuoteId.Value); if (quote != null) { quote.ConvertedToJobId = null; quote.ConvertedDate = null; await _unitOfWork.Quotes.UpdateAsync(quote); _logger.LogInformation("Reset quote {QuoteId} after deleting linked job {JobId}", job.QuoteId.Value, id); } } await _unitOfWork.Jobs.SoftDeleteAsync(job); await _unitOfWork.SaveChangesAsync(); await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "Deleted", "Job deleted"); this.ToastSuccess($"Job {job.JobNumber} deleted successfully!"); return RedirectToAction(nameof(Index)); } catch (Exception ex) { _logger.LogError(ex, "Error deleting job {JobId}", id); TempData["Error"] = "An error occurred while deleting the job."; return RedirectToAction(nameof(Index)); } } /// /// Loads company email notification preferences and stores them in ViewBag for use by views. /// ViewBag.EmailDefaultOnStatusChange — default checked state for job status change emails /// ViewBag.EmailDefaultOnComplete — default checked state for job completion emails /// private async Task PopulateEmailNotificationDefaultsAsync(int companyId) { var prefs = (await _unitOfWork.CompanyPreferences.FindAsync(p => p.CompanyId == companyId)).FirstOrDefault(); var emailOn = prefs?.EmailNotificationsEnabled ?? true; ViewBag.EmailDefaultOnStatusChange = emailOn && (prefs?.NotifyOnJobStatusChange ?? true); ViewBag.EmailDefaultOnComplete = emailOn; } /// /// Populates all ViewBag data needed to render the Create/Edit job wizard. /// Combines dropdowns (customers, workers, prep services, catalog items, oven costs) and /// pricing configuration values (tax percent, complexity surcharges, measurement units) /// in a single call so each action only needs one await. /// private async Task PopulateCreateEditWizardViewBagsAsync(int companyId) { ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId); await PopulateDropdowns(); await PopulatePrepServicesAsync(); var costs = await _pricingService.GetOperatingCostsAsync(companyId); await PopulateJobItemDropDownsAsync(companyId, costs?.OvenOperatingCostPerHour ?? 45m); ViewBag.TaxPercent = costs?.TaxPercent ?? 0m; ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m; ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m; ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m; ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m; var useMetric = await _tenantContext.UseMetricSystemAsync(); ViewBag.UseMetric = useMetric; ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); } /// /// Populates the core header dropdowns: customers, workers, statuses, and priorities. /// Only active customers are included. Workers are sourced from Identity users (not ShopWorker /// entity) because job assignment tracks who is responsible, not just shop-floor roles. /// private async Task PopulateDropdowns() { var customers = await _unitOfWork.Customers.GetAllAsync(); ViewBag.Customers = new SelectList( customers.Where(c => c.IsActive).Select(c => new { c.Id, DisplayName = !string.IsNullOrWhiteSpace(c.CompanyName) ? c.CompanyName : $"{c.ContactFirstName} {c.ContactLastName}".Trim() }).OrderBy(c => c.DisplayName), "Id", "DisplayName"); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var users = await _userManager.Users .Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null) .OrderBy(u => u.FirstName).ThenBy(u => u.LastName) .ToListAsync(); ViewBag.Workers = new SelectList(users.Select(u => new { u.Id, FullName = u.FullName }), "Id", "FullName"); // Use cached lookups for better performance var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId); ViewBag.Statuses = new SelectList( statuses.OrderBy(s => s.DisplayOrder), "Id", "DisplayName"); var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId); ViewBag.Priorities = new SelectList( priorities.OrderBy(p => p.DisplayOrder), "Id", "DisplayName"); } /// /// Generates the next sequential job number in the format PREFIX-YYMM-#### (e.g. JOB-2404-0001). /// Uses IgnoreQueryFilters so soft-deleted jobs are included in the sequence counter — /// this prevents number reuse if a job is deleted after being created this month. /// Also scopes to the current company so multi-tenant sequences don't collide. /// Note: there is also a in QuotesController used /// during quote-to-job conversion; both use the same format and logic. /// private async Task GenerateJobNumber() { var year = DateTime.Now.Year.ToString().Substring(2); var month = DateTime.Now.Month.ToString("D2"); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true); var jobPrefix = !string.IsNullOrWhiteSpace(prefs?.JobNumberPrefix) ? prefs.JobNumberPrefix : "JOB"; var prefix = $"{jobPrefix}-{year}{month}"; var lastJobNumber = await _unitOfWork.Jobs.GetLastJobNumberByPrefixAsync(companyId, prefix); if (lastJobNumber != null) { var lastNumberStr = lastJobNumber.Substring(prefix.Length + 1); if (int.TryParse(lastNumberStr, out int lastNumber)) return $"{prefix}-{(lastNumber + 1):D4}"; } return $"{prefix}-0001"; } /// /// Renders the shop floor TV display view, showing today's scheduled jobs in priority order. /// Designed to auto-refresh on a mounted monitor so the shop team always sees the current queue. /// Optionally filtered by for individual worker views. /// Non-terminal statuses are loaded for the progress strip — terminal and navigation statuses /// (Pending, Approved, OnHold, etc.) are excluded because they don't represent active work stages. /// public async Task ShopDisplay(DateTime? date, string? userId) { try { var today = date?.Date ?? DateTime.Today; // Load all non-terminal statuses for the progress strip (excluding nav/hold/cancel) var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s => s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED" && s.StatusCode != "DELIVERED" && s.StatusCode != "QUOTED" && s.StatusCode != "PENDING" && s.StatusCode != "APPROVED"); var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList(); // Get all jobs scheduled for today with related data including items and coats var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today, userId); // 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 => { var nextStatus = allStatuses .Where(s => s.DisplayOrder > j.JobStatus.DisplayOrder && s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED" && s.StatusCode != "DELIVERED") .OrderBy(s => s.DisplayOrder) .FirstOrDefault(); return 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, DueDate = j.DueDate, DisplayOrder = priorityDict.ContainsKey(j.Id) ? priorityDict[j.Id].DisplayOrder : int.MaxValue, // New enriched fields Description = j.Description, SpecialInstructions = j.SpecialInstructions, ItemCount = j.JobItems.Count, TotalPieces = (int)j.JobItems.Sum(i => i.Quantity), HasSandblasting = j.JobItems.Any(i => i.RequiresSandblasting), HasMasking = j.JobItems.Any(i => i.RequiresMasking), Colors = j.JobItems .SelectMany(i => i.Coats) .Where(c => !string.IsNullOrEmpty(c.ColorName)) .Select(c => c.ColorName!) .Distinct() .ToList(), Items = j.JobItems.Select(i => new JobItemSummaryDto { Description = i.Description ?? string.Empty, Quantity = i.Quantity, Colors = i.Coats .Where(c => !string.IsNullOrEmpty(c.ColorName)) .OrderBy(c => c.Sequence) .Select(c => c.ColorName!) .Distinct() .ToList(), HasSandblasting = i.RequiresSandblasting, HasMasking = i.RequiresMasking }).ToList(), StatusDisplayOrder = j.JobStatus.DisplayOrder, StatusCode = j.JobStatus.StatusCode, StatusIsTerminal = j.JobStatus.IsTerminalStatus, NextStatusId = nextStatus?.Id, NextStatusDisplayName = nextStatus?.DisplayName, NextStatusColorClass = nextStatus?.ColorClass, IntakeCompleted = j.IntakeDate.HasValue }; }) .OrderBy(j => j.DisplayOrder) .ThenBy(j => j.JobNumber) .ToList(); ViewBag.ScheduledDate = today; ViewBag.CurrentTime = DateTime.Now; ViewBag.AllStatuses = allStatuses; ViewBag.CurrentUserId = _userManager.GetUserId(User); ViewBag.FilterUserId = userId; return View(jobDtos); } catch (Exception ex) { _logger.LogError(ex, "Error loading shop floor display"); return RedirectToAction(nameof(Index)); } } /// /// Renders the mobile-optimized shop floor view — same data as ShopDisplay but laid out /// for phone/tablet screens without the sidebar. Workers can swipe through their jobs and /// tap to advance status. Filtered by when provided so each /// worker sees only their assigned jobs on their own device. /// public async Task ShopMobile(string? workerId) { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (!companyId.HasValue) return RedirectToAction(nameof(Index)); var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s => !s.IsTerminalStatus && s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED" && s.StatusCode != "DELIVERED"); var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList(); var jobs = await _unitOfWork.Jobs.GetActiveJobsForMobileAsync(companyId.Value, workerId); var jobDtos = jobs.Select(j => { var nextStatus = allStatuses .Where(s => s.DisplayOrder > j.JobStatus.DisplayOrder) .OrderBy(s => s.DisplayOrder) .FirstOrDefault(); return new JobDailyPriorityDto { Id = 0, JobId = j.Id, JobNumber = j.JobNumber, CustomerName = !string.IsNullOrWhiteSpace(j.Customer.CompanyName) ? j.Customer.CompanyName : $"{j.Customer.ContactFirstName} {j.Customer.ContactLastName}".Trim(), StatusDisplayName = j.JobStatus.DisplayName ?? string.Empty, StatusColorClass = j.JobStatus.ColorClass ?? "secondary", StatusCode = j.JobStatus.StatusCode, StatusDisplayOrder = j.JobStatus.DisplayOrder, StatusIsTerminal = j.JobStatus.IsTerminalStatus, JobPriorityId = j.JobPriorityId, PriorityDisplayName = j.JobPriority.DisplayName ?? string.Empty, PriorityColorClass = j.JobPriority.ColorClass ?? "secondary", AssignedUserId = j.AssignedUserId, AssignedWorkerName = j.AssignedUser?.FullName, DueDate = j.DueDate, ScheduledDate = j.ScheduledDate, Description = j.Description, SpecialInstructions = j.SpecialInstructions, ItemCount = j.JobItems.Count, TotalPieces = (int)j.JobItems.Sum(i => i.Quantity), HasSandblasting = j.JobItems.Any(i => i.RequiresSandblasting), HasMasking = j.JobItems.Any(i => i.RequiresMasking), Colors = j.JobItems .SelectMany(i => i.Coats) .Where(c => !string.IsNullOrEmpty(c.ColorName)) .Select(c => c.ColorName!) .Distinct() .ToList(), Items = j.JobItems.Select(i => new JobItemSummaryDto { Description = i.Description ?? string.Empty, Quantity = i.Quantity, Colors = i.Coats .Where(c => !string.IsNullOrEmpty(c.ColorName)) .OrderBy(c => c.Sequence) .Select(c => c.ColorName!) .Distinct() .ToList(), HasSandblasting = i.RequiresSandblasting, HasMasking = i.RequiresMasking }).ToList(), NextStatusId = nextStatus?.Id, NextStatusDisplayName = nextStatus?.DisplayName, NextStatusColorClass = nextStatus?.ColorClass, IntakeCompleted = j.IntakeDate.HasValue }; }) .OrderBy(j => j.StatusDisplayOrder) .ThenBy(j => j.DueDate) .ThenBy(j => j.JobNumber) .ToList(); // Company users for worker filter chips var mobileWorkers = await _userManager.Users .Where(u => u.CompanyId == companyId.Value && u.IsActive) .OrderBy(u => u.FirstName).ThenBy(u => u.LastName) .ToListAsync(); ViewBag.Workers = mobileWorkers.Select(u => new { Id = u.Id, Name = u.FullName }).ToList(); ViewBag.CurrentWorkerId = workerId; ViewBag.AllStatuses = allStatuses; return View(jobDtos); } catch (Exception ex) { _logger.LogError(ex, "Error loading shop mobile view"); return RedirectToAction(nameof(Index)); } } /// /// AJAX endpoint for inline scheduled/due date editing on the Job Details page. /// Null request fields mean "don't change this field"; empty string means "clear this field". /// This distinction lets the caller update only one date without touching the other. /// [HttpPost] [ValidateAntiForgeryToken] public async Task UpdateDates([FromBody] UpdateDatesRequest request) { try { var job = await _unitOfWork.Jobs.GetByIdAsync(request.JobId); if (job == null) return Json(new { success = false, message = "Job not found" }); if (request.ScheduledDate != null) job.ScheduledDate = string.IsNullOrEmpty(request.ScheduledDate) ? null : DateTime.Parse(request.ScheduledDate); if (request.DueDate != null) job.DueDate = string.IsNullOrEmpty(request.DueDate) ? null : DateTime.Parse(request.DueDate); job.UpdatedAt = DateTime.UtcNow; await _unitOfWork.CompleteAsync(); return Json(new { success = true }); } catch (Exception ex) { _logger.LogError(ex, "Error updating dates for job {JobId}", request.JobId); return Json(new { success = false, message = "An error occurred while saving" }); } } /// /// AJAX endpoint that advances a job to a specific status from the shop floor UI. /// Records the change in JobStatusHistory and broadcasts via SignalR so the TV display and /// Kanban board update live. If the new status is a terminal status and the job has an /// associated invoice, no action is taken on the invoice here (invoicing is separate). /// [HttpPost] [ValidateAntiForgeryToken] public async Task AdvanceJobStatus([FromBody] AdvanceJobStatusRequest request) { try { var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(request.JobId); if (job == null) return Json(new { success = false, message = "Job not found" }); var newStatus = await _unitOfWork.JobStatusLookups.GetByIdAsync(request.NewStatusId); if (newStatus == null) return Json(new { success = false, message = "Status not found" }); var oldStatusId = job.JobStatusId; job.JobStatusId = request.NewStatusId; job.UpdatedAt = DateTime.UtcNow; if (newStatus.StatusCode == "COMPLETED") job.CompletedDate = DateTime.UtcNow; // Log status history await _unitOfWork.JobStatusHistory.AddAsync(new JobStatusHistory { JobId = job.Id, FromStatusId = oldStatusId, ToStatusId = request.NewStatusId, ChangedDate = DateTime.UtcNow, Notes = "Advanced from shop floor display", CompanyId = job.CompanyId, CreatedAt = DateTime.UtcNow }); var workflowJustCompleted = request.JobId == request.HighlightJobId && await MaybeMarkFirstWorkflowCompletedFromBoardAsync(job.CompanyId, request.GuidedActivation); await _unitOfWork.CompleteAsync(); var companyId = _tenantContext.GetCurrentCompanyId()?.ToString(); if (!string.IsNullOrEmpty(companyId)) await _shopHub.Clients.Group($"shop-{companyId}").SendAsync("JobStatusChanged", new { jobId = job.Id, jobNumber = job.JobNumber, statusDisplayName = newStatus.DisplayName, statusColorClass = newStatus.ColorClass }); return Json(new { success = true, newStatusDisplayName = newStatus.DisplayName, newStatusColorClass = newStatus.ColorClass, guidedActivationNext = workflowJustCompleted ? AppConstants.GuidedActivation.BoardReadyForInvoiceStep : null }); } catch (Exception ex) { _logger.LogError(ex, "Error advancing job status for job {JobId}", request.JobId); return Json(new { success = false, message = "An error occurred" }); } } /// /// Populates ViewBag.Workers with active Identity users for the worker assignment dropdown /// on the job details/edit pages. Uses Identity users (not ShopWorker entity) because the /// AssignedUserId FK links to the user who is accountable for the job, not a shop role. /// private async Task PopulateWorkersDropdown() { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var users = await _userManager.Users .Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null) .OrderBy(u => u.FirstName).ThenBy(u => u.LastName) .ToListAsync(); ViewBag.Workers = new SelectList(users.Select(u => new { u.Id, FullName = u.FullName }), "Id", "FullName"); } /// /// Loads all active prep services into ViewBag for the item wizard's prep services step. /// Prep services are ordered by DisplayOrder so they appear in the intended workflow sequence. /// private async Task PopulatePrepServicesAsync() { var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive); ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList(); _logger.LogInformation("Populated {Count} active prep services", prepServices.Count()); var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive); ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder) .Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault }) .ToList(); } /// /// AJAX endpoint for inline worker reassignment on the Job Details page. /// Loads the job, sets , and saves. /// The response includes the worker's display name so the UI can update the badge without a page reload. /// Passing WorkerId = null unassigns the current worker. /// [HttpPost] [ValidateAntiForgeryToken] public async Task UpdateWorkerAssignment([FromBody] UpdateWorkerAssignmentRequest request) { try { var workerJob = await _unitOfWork.Jobs.GetByIdAsync(request.JobId); if (workerJob == null) { return Json(new { success = false, message = "Job not found" }); } workerJob.AssignedUserId = request.WorkerId; workerJob.UpdatedAt = DateTime.UtcNow; await _unitOfWork.Jobs.UpdateAsync(workerJob); await _unitOfWork.CompleteAsync(); string? workerName = null; if (!string.IsNullOrEmpty(request.WorkerId)) { var user = await _userManager.FindByIdAsync(request.WorkerId); workerName = user?.FullName; } var assignDetail = workerName != null ? $"Assigned to {workerName}" : "Worker unassigned"; await BroadcastJobUpdate(workerJob.CompanyId, workerJob.JobNumber!, workerJob.Id, "WorkerChanged", assignDetail); return Json(new { success = true, workerName = workerName }); } catch (Exception ex) { _logger.LogError(ex, "Error updating worker assignment for job {JobId}", request.JobId); return Json(new { success = false, message = "An error occurred while updating the assignment" }); } } /// /// AJAX endpoint for inline priority change on the Job Details and Kanban board pages. /// Updates the priority badge color and label without a page reload. Broadcasts a SignalR /// event so the Kanban column card updates in real time on other connected clients. /// [HttpPost] [ValidateAntiForgeryToken] public async Task UpdateJobPriority([FromBody] UpdateJobPriorityRequest request) { try { var job = await _unitOfWork.Jobs.GetByIdAsync(request.JobId, false, j => j.JobPriority); if (job == null) { return Json(new { success = false, message = "Job not found" }); } job.JobPriorityId = request.PriorityId; job.UpdatedAt = DateTime.UtcNow; await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.SaveChangesAsync(); // Get the new priority for return var newPriority = await _unitOfWork.JobPriorityLookups.GetByIdAsync(request.PriorityId); await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "PriorityChanged", $"Priority → {newPriority?.DisplayName ?? "Unknown"}"); var priorityCompanyId = _tenantContext.GetCurrentCompanyId()?.ToString(); if (!string.IsNullOrEmpty(priorityCompanyId)) await _shopHub.Clients.Group($"shop-{priorityCompanyId}").SendAsync("DailyBoardUpdated"); return Json(new { success = true, priority = newPriority?.DisplayName ?? "Unknown" }); } catch (Exception ex) { _logger.LogError(ex, "Error updating priority for job {JobId}", request.JobId); return Json(new { success = false, message = "An error occurred while updating the priority" }); } } /// /// AJAX endpoint for inline status change on the Job Details page (the status dropdown). /// Records a JobStatusHistory entry and optionally sends a customer notification email /// when request.SendEmail is true. Broadcasts SignalR so the Kanban board refreshes. /// [HttpPost] [ValidateAntiForgeryToken] public async Task UpdateJobStatus([FromBody] UpdateJobStatusRequest request) { try { var job = await _unitOfWork.Jobs.GetByIdAsync(request.JobId, false, j => j.JobStatus); if (job == null) { return Json(new { success = false, message = "Job not found" }); } await RecordStatusChangeAsync(job, request.StatusId); job.JobStatusId = request.StatusId; job.UpdatedAt = DateTime.UtcNow; await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.SaveChangesAsync(); // Get the new status for return var newStatus = await _unitOfWork.JobStatusLookups.GetByIdAsync(request.StatusId); await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "StatusChanged", $"Status → {newStatus?.DisplayName ?? "Unknown"}"); if (newStatus != null) { var updateStatusCompanyId = _tenantContext.GetCurrentCompanyId()?.ToString(); if (!string.IsNullOrEmpty(updateStatusCompanyId)) await _shopHub.Clients.Group($"shop-{updateStatusCompanyId}").SendAsync("JobStatusChanged", new { jobId = job.Id, jobNumber = job.JobNumber, statusDisplayName = newStatus.DisplayName, statusColorClass = newStatus.ColorClass }); } // Notify customer on status change (only if user opted in) if (request.SendEmail && newStatus != null) { try { var jobForNotify = await _unitOfWork.Jobs.GetByIdAsync(request.JobId, false, j => j.Customer); if (jobForNotify != null) await _notificationService.NotifyJobStatusChangedAsync( jobForNotify, newStatus.StatusCode, newStatus.DisplayName ?? string.Empty); } catch (Exception ex) { _logger.LogWarning(ex, "Notification failed for job {Id}", request.JobId); } var statusNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(request.JobId); this.SetNotificationResultToast(statusNotifLog); } return Json(new { success = true, status = newStatus?.DisplayName ?? "Unknown" }); } catch (Exception ex) { _logger.LogError(ex, "Error updating status for job {JobId}", request.JobId); return Json(new { success = false, message = "An error occurred while updating the status" }); } } private static string GetCustomerDisplayName(Core.Entities.Customer? customer) { if (customer == null) return "Unknown"; if (!customer.IsCommercial) { var contactName = $"{customer.ContactFirstName} {customer.ContactLastName}".Trim(); if (contactName.Length > 0) return contactName; } if (!string.IsNullOrWhiteSpace(customer.CompanyName)) return customer.CompanyName; var name = $"{customer.ContactFirstName} {customer.ContactLastName}".Trim(); return name.Length > 0 ? name : "Unknown"; } #region Job Photos /// /// Get job photo - serves the actual image file /// [HttpGet] public async Task GetPhoto(int id) { try { var photo = await _unitOfWork.JobPhotos.GetByIdAsync(id); if (photo == null) return NotFound(); var (success, fileContent, contentType, errorMessage) = await _jobPhotoService.GetJobPhotoAsync(photo.FilePath); if (!success) return NotFound(); return File(fileContent, contentType); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving job photo {PhotoId}", id); return NotFound(); } } /// /// Get job photos for a job - returns JSON list /// [HttpGet] public async Task GetJobPhotos(int jobId) { try { var photosEnum = await _unitOfWork.JobPhotos.FindAsync(p => p.JobId == jobId, false, p => p.UploadedBy); var photos = photosEnum.OrderBy(p => p.DisplayOrder).ThenBy(p => p.UploadedDate).ToList(); var photoDtos = photos .Select(p => _mapper.Map(p)) .ToList(); return Json(new { success = true, photos = photoDtos }); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving photos for job {JobId}", jobId); return Json(new { success = false, message = "Error loading photos" }); } } /// /// Upload job photo /// [HttpPost] [ValidateAntiForgeryToken] public async Task UploadPhoto(int jobId, IFormFile photo, string? caption, string? tags, JobPhotoType photoType = JobPhotoType.Progress) { try { if (photo == null || photo.Length == 0) return Json(new { success = false, message = "Please select a photo to upload." }); var job = await _unitOfWork.Jobs.GetByIdAsync(jobId); if (job == null) return Json(new { success = false, message = "Job not found." }); var user = await _userManager.GetUserAsync(User); if (user == null) return Json(new { success = false, message = "User not found." }); // Check subscription photo limit for this job if (!await _subscriptionService.CanAddJobPhotoAsync(user.CompanyId, jobId)) { var (_, max) = await _subscriptionService.GetJobPhotoCountAsync(user.CompanyId, jobId); var limitMsg = max == 0 ? "Your plan does not include job photos. Upgrade your subscription to upload photos." : $"Photo limit reached. Your plan allows {max} photo{(max == 1 ? "" : "s")} per job."; return Json(new { success = false, message = limitMsg }); } // Save photo to filesystem var (success, filePath, errorMessage) = await _jobPhotoService.SaveJobPhotoAsync( photo, jobId, user.CompanyId, caption, photoType); if (!success) return Json(new { success = false, message = errorMessage }); // Create JobPhoto entity var jobPhoto = new JobPhoto { JobId = jobId, FilePath = filePath, FileName = photo.FileName, FileSize = photo.Length, ContentType = photo.ContentType, Caption = caption, Tags = string.IsNullOrWhiteSpace(tags) ? null : tags.Trim(), PhotoType = photoType, UploadedById = user.Id, UploadedDate = DateTime.UtcNow, DisplayOrder = await GetNextDisplayOrder(jobId), CompanyId = user.CompanyId }; await _unitOfWork.JobPhotos.AddAsync(jobPhoto); await _unitOfWork.SaveChangesAsync(); var photoDto = _mapper.Map(jobPhoto); photoDto.UploadedByName = user.FullName; return Json(new { success = true, message = "Photo uploaded successfully.", photo = photoDto }); } catch (Exception ex) { _logger.LogError(ex, "Error uploading photo for job {JobId}", jobId); return Json(new { success = false, message = "An error occurred while uploading the photo." }); } } /// /// Update photo caption and type /// [HttpPost] [ValidateAntiForgeryToken] public async Task UpdatePhoto([FromBody] UpdateJobPhotoDto dto) { try { var photo = await _unitOfWork.JobPhotos.GetByIdAsync(dto.Id); if (photo == null) return Json(new { success = false, message = "Photo not found." }); photo.Caption = dto.Caption; photo.Tags = string.IsNullOrWhiteSpace(dto.Tags) ? null : dto.Tags.Trim(); photo.PhotoType = dto.PhotoType; photo.DisplayOrder = dto.DisplayOrder; photo.UpdatedAt = DateTime.UtcNow; await _unitOfWork.JobPhotos.UpdateAsync(photo); await _unitOfWork.SaveChangesAsync(); return Json(new { success = true, message = "Photo updated successfully." }); } catch (Exception ex) { _logger.LogError(ex, "Error updating photo {PhotoId}", dto.Id); return Json(new { success = false, message = "An error occurred while updating the photo." }); } } /// /// Delete job photo /// [HttpPost] [ValidateAntiForgeryToken] public async Task DeletePhoto(int id) { try { var photo = await _unitOfWork.JobPhotos.GetByIdAsync(id); if (photo == null) return Json(new { success = false, message = "Photo not found." }); // Delete from filesystem await _jobPhotoService.DeleteJobPhotoAsync(photo.FilePath); // Soft delete from database await _unitOfWork.JobPhotos.SoftDeleteAsync(id); await _unitOfWork.SaveChangesAsync(); return Json(new { success = true, message = "Photo deleted successfully." }); } catch (Exception ex) { _logger.LogError(ex, "Error deleting photo {PhotoId}", id); return Json(new { success = false, message = "An error occurred while deleting the photo." }); } } private async Task GetNextDisplayOrder(int jobId) { var photos = await _unitOfWork.JobPhotos.FindAsync(p => p.JobId == jobId); return photos.Any() ? photos.Max(p => p.DisplayOrder) + 1 : 1; } #endregion #region Job Completion /// /// Marks a job as completed, recording actual time spent and final price adjustments. /// Updates the CompletedDate, ActualTimeSpentHours, and FinalPrice fields, transitions to /// the "Completed" status, and optionally sends a customer completion notification. /// This is a form POST (not AJAX) because it may redirect to invoice creation if the job /// doesn't already have an invoice. /// [HttpPost] [ValidateAntiForgeryToken] public async Task CompleteJob([FromForm] CompleteJobDto dto) { try { _logger.LogInformation("Completing job {JobId} with {Hours} hours", dto.JobId, dto.ActualTimeSpentHours); // Load job with all necessary data var job = await _unitOfWork.Jobs.GetByIdAsync(dto.JobId, false, j => j.JobItems, j => j.JobStatus); if (job == null) { TempData["Error"] = "Job not found."; return RedirectToAction(nameof(Index)); } // Store old status for change history var oldStatusName = job.JobStatus.DisplayName; // Get current user var currentUser = await _userManager.GetUserAsync(User); // Update job with actual time spent job.ActualTimeSpentHours = dto.ActualTimeSpentHours; job.CompletedDate = DateTime.UtcNow; // Find the "Completed" status var completedStatus = await _unitOfWork.JobStatusLookups .FirstOrDefaultAsync(s => s.StatusCode == "COMPLETED" && s.CompanyId == job.CompanyId); if (completedStatus != null) { await RecordStatusChangeAsync(job, completedStatus.Id); job.JobStatusId = completedStatus.Id; } // Update actual powder usage for each coat foreach (var coatUsage in dto.CoatUsages) { var jobItemCoat = await _unitOfWork.JobItemCoats.GetByIdAsync( coatUsage.JobItemCoatId, false, jic => jic.InventoryItem); if (jobItemCoat != null) { jobItemCoat.ActualPowderUsedLbs = coatUsage.ActualPowderUsedLbs; await _unitOfWork.JobItemCoats.UpdateAsync(jobItemCoat); _logger.LogInformation("Updated JobItemCoat {CoatId} with {Lbs} lbs actual powder used", coatUsage.JobItemCoatId, coatUsage.ActualPowderUsedLbs); // Deduct powder from inventory if using stock powder if (jobItemCoat.InventoryItemId.HasValue && coatUsage.ActualPowderUsedLbs.HasValue && coatUsage.ActualPowderUsedLbs.Value > 0) { var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(jobItemCoat.InventoryItemId.Value); if (inventoryItem != null) { // Create inventory transaction to track the usage var transaction = new InventoryTransaction { InventoryItemId = inventoryItem.Id, TransactionType = InventoryTransactionType.JobUsage, Quantity = -coatUsage.ActualPowderUsedLbs.Value, // Negative for deduction UnitCost = inventoryItem.UnitCost, TotalCost = inventoryItem.UnitCost * coatUsage.ActualPowderUsedLbs.Value, TransactionDate = DateTime.UtcNow, JobId = job.Id, Reference = job.JobNumber, Notes = $"Powder used for Job {job.JobNumber} - {jobItemCoat.CoatName} ({jobItemCoat.ColorName ?? "N/A"}) by {currentUser!.FirstName} {currentUser.LastName}", BalanceAfter = inventoryItem.QuantityOnHand - coatUsage.ActualPowderUsedLbs.Value, CompanyId = job.CompanyId }; await _unitOfWork.InventoryTransactions.AddAsync(transaction); // Update inventory item quantity inventoryItem.QuantityOnHand -= coatUsage.ActualPowderUsedLbs.Value; await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem); _logger.LogInformation( "Deducted {Lbs} lbs of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}", coatUsage.ActualPowderUsedLbs.Value, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand); } } } } await _unitOfWork.Jobs.UpdateAsync(job); // Add change history entry if (completedStatus != null) { var changeHistory = new JobChangeHistory { JobId = job.Id, ChangedByUserId = currentUser!.Id, ChangedAt = DateTime.UtcNow, FieldName = "Status", OldValue = oldStatusName, NewValue = completedStatus.DisplayName, ChangeDescription = $"Job completed by {currentUser.FirstName} {currentUser.LastName}" + (dto.ActualTimeSpentHours.HasValue ? $" - Actual time: {dto.ActualTimeSpentHours.Value:0.##} hours" : ""), CompanyId = job.CompanyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.JobChangeHistories.AddAsync(changeHistory); } await _unitOfWork.CompleteAsync(); // Admin/Manager gets an SMS compose modal; ShopFloor workers trigger auto-send. var companyRole = User.FindFirst("CompanyRole")?.Value ?? string.Empty; var isAdminOrManager = companyRole is "CompanyAdmin" or "Administrator" or "Manager"; // Load job with customer for notification + SMS render var jobForNotify = await _unitOfWork.Jobs.GetByIdAsync(dto.JobId, false, j => j.Customer); // Notify customer that job is completed (only if user opted in) if (dto.SendEmailToCustomer && jobForNotify != null) { try { // Admin/Manager path: suppress auto-SMS so they can review via compose modal await _notificationService.NotifyJobCompletedAsync(jobForNotify, suppressSms: isAdminOrManager); } catch (Exception ex) { _logger.LogWarning(ex, "Notification failed for job {Id}", dto.JobId); } var completeNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(dto.JobId); this.SetNotificationResultToast(completeNotifLog); } // For Admin/Manager: render the SMS template and store it for the compose modal if (isAdminOrManager && jobForNotify != null) { try { var smsPreview = await _notificationService.RenderJobCompletedSmsAsync(jobForNotify); if (smsPreview != null) TempData["PendingSmsPreview"] = smsPreview; } catch (Exception ex) { _logger.LogWarning(ex, "SMS render failed for job {Id}", dto.JobId); } } await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "Completed", "Job completed"); if (completedStatus != null) { var completeJobCompanyId = _tenantContext.GetCurrentCompanyId()?.ToString(); if (!string.IsNullOrEmpty(completeJobCompanyId)) await _shopHub.Clients.Group($"shop-{completeJobCompanyId}").SendAsync("JobStatusChanged", new { jobId = job.Id, jobNumber = job.JobNumber, statusDisplayName = completedStatus.DisplayName, statusColorClass = completedStatus.ColorClass }); } this.ToastSuccess($"Job {job.JobNumber} has been marked as completed!"); return RedirectToAction(nameof(Details), new { id = dto.JobId }); } catch (Exception ex) { _logger.LogError(ex, "Error completing job {JobId}", dto.JobId); TempData["Error"] = "An error occurred while completing the job."; return RedirectToAction(nameof(Details), new { id = dto.JobId }); } } #endregion #region SMS Compose /// /// Returns the pre-rendered job-completed SMS text so the Admin/Manager compose modal /// can pre-fill the textarea when the "Send SMS" button is clicked from the job details page /// (as opposed to the auto-populated TempData path from CompleteJob). /// [HttpGet] public async Task RenderJobSms(int jobId) { var job = await _unitOfWork.Jobs.GetByIdAsync(jobId, false, j => j.Customer); if (job == null) return NotFound(); var text = await _notificationService.RenderJobCompletedSmsAsync(job); if (text == null) return Json(new { eligible = false, reason = "SMS is not enabled or customer has not opted in." }); return Json(new { eligible = true, message = text }); } /// /// Sends a manually-composed SMS for a job. Validates and auto-appends STOP language, /// sends via Twilio, and writes a NotificationLog entry. /// [HttpPost] [ValidateAntiForgeryToken] public async Task SendJobSms([FromBody] SendJobSmsRequest request) { if (request.JobId <= 0 || string.IsNullOrWhiteSpace(request.Message)) return Json(new { success = false, error = "Job ID and message are required." }); var job = await _unitOfWork.Jobs.GetByIdAsync(request.JobId, false, j => j.Customer); if (job == null) return Json(new { success = false, error = "Job not found." }); var (success, error) = await _notificationService.SendJobSmsAsync(job, request.Message.Trim()); return Json(new { success, error }); } #endregion #region Edit Job Items (Wizard) /// /// Renders the dedicated item-editing wizard for a job (separate from the job header edit). /// Loads all existing items with their coats and prep services and maps them into the /// format that item-wizard.js expects — this avoids /// maintaining a separate DTO just for the edit case. /// public async Task EditItems(int id) { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Challenge(); var job = await _unitOfWork.Jobs.GetByIdAsync(id, false, j => j.JobItems); if (job == null || job.CompanyId != currentUser.CompanyId) return NotFound(); // Load coats and prep services for each item var itemsWithRelations = new List(); if (job.JobItems != null) { foreach (var item in job.JobItems.Where(i => !i.IsDeleted)) { var full = await _unitOfWork.JobItems.GetByIdAsync(item.Id, false, ji => ji.Coats, ji => ji.PrepServices); if (full != null) itemsWithRelations.Add(full); } } // Map existing items to CreateQuoteItemDto format for the wizard var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId); var existingItems = itemsWithRelations.Select(ji => new CreateQuoteItemDto { Description = ji.Description, Quantity = ji.Quantity, SurfaceAreaSqFt = ji.SurfaceAreaSqFt, EstimatedMinutes = ji.EstimatedMinutes, CatalogItemId = ji.CatalogItemId, ManualUnitPrice = ji.ManualUnitPrice ?? (ji.IsGenericItem ? ji.UnitPrice : null), PowderCostOverride = ji.PowderCostOverride, IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0), IsLaborItem = ji.IsLaborItem, RequiresSandblasting = ji.RequiresSandblasting, RequiresMasking = ji.RequiresMasking, Notes = ji.Notes, IncludePrepCost = ji.IncludePrepCost, Complexity = ji.Complexity, Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto { CoatName = c.CoatName, Sequence = c.Sequence, InventoryItemId = c.InventoryItemId, ColorName = c.ColorName, VendorId = c.VendorId, ColorCode = c.ColorCode, Finish = c.Finish, CoverageSqFtPerLb = c.CoverageSqFtPerLb, TransferEfficiency = c.TransferEfficiency, PowderCostPerLb = c.PowderCostPerLb, PowderToOrder = c.PowderToOrder, Notes = c.Notes }).ToList(), PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto { PrepServiceId = ps.PrepServiceId, EstimatedMinutes = ps.EstimatedMinutes }).ToList() }).ToList(); var viewModel = new JobEditItemsViewModel { JobId = job.Id, JobNumber = job.JobNumber, CustomerId = job.CustomerId, TaxPercent = costs?.TaxPercent ?? 0m, JobItems = existingItems }; await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m); ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m; ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m; ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m; ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m; return View(viewModel); } /// /// Replaces all line items on a job: soft-deletes existing items and their child coats/prep /// services, then inserts the new set from the wizard submission. Also recalculates and stores /// PowderToOrder per coat (surface area × coats × lbs/sqft coverage efficiency) so the /// inventory system knows how much powder to pull for each color. /// Runs all inserts inside a transaction so partial failures don't leave items in an /// inconsistent state. /// [HttpPost] [ValidateAntiForgeryToken] public async Task UpdateItems(JobEditItemsViewModel model) { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Challenge(); var job = await _unitOfWork.Jobs.GetByIdAsync(model.JobId); if (job == null || job.CompanyId != currentUser.CompanyId) return NotFound(); if (!model.JobItems.Any()) { ModelState.AddModelError("", "Please add at least one job item."); var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId); model.TaxPercent = costs?.TaxPercent ?? 0m; await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m); ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m; ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m; ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m; ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m; return View("EditItems", model); } try { // Soft-delete existing job items (and their children) var oldItemsEnum = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == job.Id); var oldItems = oldItemsEnum.ToList(); foreach (var oldItem in oldItems) { oldItem.IsDeleted = true; oldItem.DeletedAt = DateTime.UtcNow; oldItem.DeletedBy = currentUser.UserName; } await _unitOfWork.CompleteAsync(); // Create new items foreach (var itemDto in model.JobItems) { var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync( itemDto, currentUser.CompanyId, null); var jobItem = new JobItem { JobId = job.Id, Description = itemDto.Description, Quantity = itemDto.Quantity, SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt, EstimatedMinutes = itemDto.EstimatedMinutes, CatalogItemId = itemDto.CatalogItemId, IsGenericItem = itemDto.IsGenericItem, IsLaborItem = itemDto.IsLaborItem, ManualUnitPrice = itemDto.ManualUnitPrice, PowderCostOverride = itemDto.PowderCostOverride, RequiresSandblasting = itemDto.RequiresSandblasting, RequiresMasking = itemDto.RequiresMasking, Notes = itemDto.Notes, IncludePrepCost = itemDto.IncludePrepCost, Complexity = itemDto.Complexity, UnitPrice = itemPricing.UnitPrice, TotalPrice = itemPricing.TotalPrice, LaborCost = itemPricing.TotalPrice * 0.4m, CompanyId = currentUser.CompanyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.JobItems.AddAsync(jobItem); await _unitOfWork.SaveChangesAsync(); // Coats if (itemDto.Coats?.Any() == true) { foreach (var coatDto in itemDto.Coats.OrderBy(c => c.Sequence)) { // Calculate PowderToOrder if not supplied by the client decimal? powderToOrder = coatDto.PowderToOrder; if ((powderToOrder == null || powderToOrder == 0) && itemDto.SurfaceAreaSqFt > 0) { var cov = coatDto.CoverageSqFtPerLb > 0 ? coatDto.CoverageSqFtPerLb : 30m; var eff = coatDto.TransferEfficiency > 0 ? coatDto.TransferEfficiency / 100m : 0.65m; powderToOrder = Math.Round((itemDto.SurfaceAreaSqFt * itemDto.Quantity) / (cov * eff), 2); } var coat = new JobItemCoat { JobItemId = jobItem.Id, CoatName = coatDto.CoatName, Sequence = coatDto.Sequence, InventoryItemId = coatDto.InventoryItemId, ColorName = coatDto.ColorName, VendorId = coatDto.VendorId, ColorCode = coatDto.ColorCode, Finish = coatDto.Finish, CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb, TransferEfficiency = coatDto.TransferEfficiency, PowderCostPerLb = coatDto.PowderCostPerLb, PowderToOrder = powderToOrder, Notes = coatDto.Notes, CompanyId = currentUser.CompanyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.JobItemCoats.AddAsync(coat); } } // Prep services if (itemDto.PrepServices?.Any() == true) { foreach (var psDto in itemDto.PrepServices) { var ps = new JobItemPrepService { JobItemId = jobItem.Id, PrepServiceId = psDto.PrepServiceId, EstimatedMinutes = psDto.EstimatedMinutes, BlastSetupId = psDto.BlastSetupId, CompanyId = currentUser.CompanyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.JobItemPrepServices.AddAsync(ps); } } } // Calculate full total (overhead, margins, tax) to match what the wizard displays var totals = await _pricingService.CalculateQuoteTotalsAsync( model.JobItems, currentUser.CompanyId, job.CustomerId, model.TaxPercent, "None", 0, false, null, 1, null); job.FinalPrice = totals.Total; job.UpdatedAt = DateTime.UtcNow; job.UpdatedBy = currentUser.UserName; await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.SaveChangesAsync(); this.ToastSuccess("Job items updated successfully."); return RedirectToAction(nameof(Details), new { id = job.Id }); } catch (Exception ex) { _logger.LogError(ex, "Error updating items for job {JobId}", job.Id); TempData["Error"] = "An error occurred while saving job items."; var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId); model.TaxPercent = costs?.TaxPercent ?? 0m; await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m); return View("EditItems", model); } } /// /// Soft-deletes a single job item and redirects back to the EditItems wizard populated /// with the remaining items. Verifies the item belongs to the current user's company /// before deleting to prevent cross-tenant item deletion. /// [HttpPost] [ValidateAntiForgeryToken] public async Task DeleteItem(int id) { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); var item = await _unitOfWork.JobItems.GetByIdAsync(id); if (item == null) return NotFound(); var job = await _unitOfWork.Jobs.GetByIdAsync(item.JobId); if (job == null || job.CompanyId != currentUser.CompanyId) return NotFound(); item.IsDeleted = true; item.DeletedAt = DateTime.UtcNow; item.DeletedBy = currentUser.UserName; await _unitOfWork.CompleteAsync(); // Recalculate job total from remaining items var remainingItemsEnum = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == job.Id, false, ji => ji.Coats); var remainingItems = remainingItemsEnum.ToList(); var remainingDtos = remainingItems.Select(ji => new CreateQuoteItemDto { Description = ji.Description, Quantity = ji.Quantity, SurfaceAreaSqFt = ji.SurfaceAreaSqFt, EstimatedMinutes = ji.EstimatedMinutes, CatalogItemId = ji.CatalogItemId, IsGenericItem = ji.IsGenericItem, IsLaborItem = ji.IsLaborItem, ManualUnitPrice = ji.ManualUnitPrice, Coats = ji.Coats.Select(c => new CreateQuoteItemCoatDto { CoverageSqFtPerLb = c.CoverageSqFtPerLb, TransferEfficiency = c.TransferEfficiency, PowderCostPerLb = c.PowderCostPerLb }).ToList() }).ToList(); if (remainingDtos.Any()) { var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId); var totals = await _pricingService.CalculateQuoteTotalsAsync( remainingDtos, currentUser.CompanyId, job.CustomerId, costs?.TaxPercent ?? 0m, "None", 0, false, null, 1, null); job.FinalPrice = totals.Total; } else { job.FinalPrice = 0; } job.UpdatedAt = DateTime.UtcNow; job.UpdatedBy = currentUser.UserName; await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.SaveChangesAsync(); return Json(new { success = true, jobId = job.Id }); } /// /// Populates all item-wizard dropdowns: powder inventory coatings (coating-category items only), /// vendors (for custom powder sourcing), catalog items, and named oven costs. /// Coverage and efficiency values are included in the powder dropdown so the wizard's JS can /// calculate PowderNeeded live as the user enters surface area and quantity. /// The is used when no named oven is selected. /// private async Task PopulateJobItemDropDownsAsync(int companyId, decimal fallbackOvenRate) { var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory); ViewBag.InventoryCoatings = inventory .Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating) .OrderBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name) .Select(i => new { value = i.Id.ToString(), text = $"{i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)", coverage = i.CoverageSqFtPerLb ?? 30m, efficiency = i.TransferEfficiency ?? 65m, unitOfMeasure = i.UnitOfMeasure ?? "lbs", categoryName = i.InventoryCategory.DisplayName, costPerLb = i.UnitCost, colorName = i.ColorName ?? i.Name, colorCode = i.ColorCode ?? "" }).ToList(); var vendors = await _unitOfWork.Vendors.GetAllAsync(false); ViewBag.Vendors = vendors .Where(s => s.IsActive).OrderBy(s => s.CompanyName) .Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList(); var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category, i => i.Category.ParentCategory); ViewBag.CatalogItems = catalogItems .Where(i => i.IsActive) .OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder) .Select(i => new { value = i.Id.ToString(), text = BuildCatalogItemText(i), categoryName = i.Category.Name, price = i.DefaultPrice, approxArea = i.ApproximateArea ?? 0m, defaultMinutes = i.DefaultEstimatedMinutes ?? 0, thumbnailPath = i.ThumbnailPath }).ToList(); // Merchandise items (IsMerchandise = true) — for the sales wizard step ViewBag.MerchandiseItems = catalogItems .Where(i => i.IsActive && i.IsMerchandise) .OrderBy(i => i.Category.Name).ThenBy(i => i.DisplayOrder).ThenBy(i => i.Name) .Select(i => new { id = i.Id, name = i.Name, sku = i.SKU, category = i.Category.Name, price = i.DefaultPrice, description = i.Description }).ToList(); var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive); ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList(); var blastSetupsForEditItems = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive); ViewBag.BlastSetups = blastSetupsForEditItems.OrderBy(b => b.DisplayOrder) .Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault }) .ToList(); var ovens = await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive && o.CompanyId == companyId); var ovenItems = new List { new SelectListItem { Value = "", Text = $"Default rate ({fallbackOvenRate:C2}/hr)" } }; ovenItems.AddRange(ovens.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label) .Select(o => new SelectListItem { Value = o.Id.ToString(), Text = $"{o.Label} ({o.CostPerHour:C2}/hr)" })); ViewBag.OvenCosts = ovenItems; var useMetric = await _tenantContext.UseMetricSystemAsync(); ViewBag.UseMetric = useMetric; ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); } /// /// Builds the hierarchical display label for a catalog item in the job item wizard dropdown. /// Mirrors — kept as a private /// static here to avoid a cross-controller dependency. /// private static string BuildCatalogItemText(CatalogItem item) { var path = new List(); var cat = item.Category; while (cat != null) { path.Insert(0, cat.Name); cat = cat.ParentCategory; } var sku = !string.IsNullOrWhiteSpace(item.SKU) ? $" [{item.SKU}]" : ""; return $"{string.Join(" > ", path)} > {item.Name}{sku} - {item.DefaultPrice:C}"; } #endregion #region Item Pricing (AJAX) /// /// AJAX endpoint that calculates the full pricing breakdown for the job item wizard. /// Identical contract to so the same /// item-wizard.js works for both quote and job creation without branching on the controller. /// [HttpPost] public async Task CalculatePricing([FromBody] PricingCalculationRequest request) { try { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); decimal? ovenRateOverride = null; if (request.OvenCostId.HasValue) { var oven = await _unitOfWork.OvenCosts.GetByIdAsync(request.OvenCostId.Value); if (oven != null && oven.IsActive && oven.CompanyId == currentUser.CompanyId) ovenRateOverride = oven.CostPerHour; } var result = await _pricingService.CalculateQuoteTotalsAsync( request.Items, currentUser.CompanyId, request.CustomerId, request.TaxPercent, request.DiscountType, request.DiscountValue, request.IsRushJob, ovenRateOverride, request.OvenBatches, request.OvenCycleMinutes); return Json(result); } catch (Exception ex) { _logger.LogError(ex, "Error calculating job item pricing"); return StatusCode(500, new { error = "Pricing calculation failed." }); } } #endregion #region Status History /// /// Appends a row for a status transition. /// Skips the insert if the status hasn't actually changed (idempotent) to avoid /// duplicate history entries when multiple code paths call this before saving. /// The caller is responsible for also updating job.JobStatusId and calling SaveChangesAsync. /// private async Task RecordStatusChangeAsync(Job job, int newStatusId, string? notes = null) { // Don't record if status hasn't changed if (job.JobStatusId == newStatusId) return; var history = new JobStatusHistory { JobId = job.Id, CompanyId = job.CompanyId, FromStatusId = job.JobStatusId, ToStatusId = newStatusId, ChangedDate = DateTime.UtcNow, Notes = notes, CreatedAt = DateTime.UtcNow }; await _unitOfWork.JobStatusHistory.AddAsync(history); } #endregion // ── Time Tracking ────────────────────────────────────────────────────────── /// /// Returns all time entries for a job as JSON, ordered newest-first. /// Used by the time tracking tab on the Job Details page. /// [HttpGet] public async Task GetTimeEntries(int jobId) { var entries = await _unitOfWork.JobTimeEntries.FindAsync( e => e.JobId == jobId, false, e => e.Worker); // Worker nav loaded for display of legacy entries that pre-date user migration var dtos = _mapper.Map>(entries.OrderByDescending(e => e.WorkDate).ToList()); return Json(dtos); } /// /// Adds a time entry for a company user on a specific job. /// Validates hours are in the reasonable range (0.1–24) before saving. /// Returns the new entry as JSON so the UI can append it to the list without a reload. /// [HttpPost] public async Task AddTimeEntry([FromBody] CreateJobTimeEntryDto dto) { if (dto.HoursWorked <= 0 || dto.HoursWorked > 24) return BadRequest(new { error = "Hours must be between 0.1 and 24." }); var job = await _unitOfWork.Jobs.GetByIdAsync(dto.JobId); if (job == null) return NotFound(); var user = await _userManager.FindByIdAsync(dto.UserId); if (user == null) return BadRequest(new { error = "Worker not found." }); var entry = new JobTimeEntry { JobId = dto.JobId, UserId = dto.UserId, UserDisplayName = user.FullName, WorkDate = dto.WorkDate.Date, HoursWorked = Math.Round(dto.HoursWorked, 2), Stage = string.IsNullOrWhiteSpace(dto.Stage) ? null : dto.Stage.Trim(), Notes = string.IsNullOrWhiteSpace(dto.Notes) ? null : dto.Notes.Trim(), CompanyId = job.CompanyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.JobTimeEntries.AddAsync(entry); await _unitOfWork.CompleteAsync(); return Json(_mapper.Map(entry)); } /// Updates an existing time entry's hours, stage, and notes in place. [HttpPost] public async Task UpdateTimeEntry([FromBody] UpdateJobTimeEntryDto dto) { if (dto.HoursWorked <= 0 || dto.HoursWorked > 24) return BadRequest(new { error = "Hours must be between 0.1 and 24." }); var entry = await _unitOfWork.JobTimeEntries.GetByIdAsync(dto.Id); if (entry == null) return NotFound(); var user = await _userManager.FindByIdAsync(dto.UserId); if (user == null) return BadRequest(new { error = "Worker not found." }); entry.UserId = dto.UserId; entry.UserDisplayName = user.FullName; entry.WorkDate = dto.WorkDate.Date; entry.HoursWorked = Math.Round(dto.HoursWorked, 2); entry.Stage = string.IsNullOrWhiteSpace(dto.Stage) ? null : dto.Stage.Trim(); entry.Notes = string.IsNullOrWhiteSpace(dto.Notes) ? null : dto.Notes.Trim(); entry.UpdatedAt = DateTime.UtcNow; await _unitOfWork.JobTimeEntries.UpdateAsync(entry); await _unitOfWork.CompleteAsync(); return Json(_mapper.Map(entry)); } /// Soft-deletes a time entry. The hours are removed from the job's running total. [HttpPost] public async Task DeleteTimeEntry([FromBody] DeleteTimeEntryRequest req) { var entry = await _unitOfWork.JobTimeEntries.GetByIdAsync(req.Id); if (entry == null) return NotFound(); await _unitOfWork.JobTimeEntries.SoftDeleteAsync(req.Id); await _unitOfWork.CompleteAsync(); return Json(new { success = true }); } // ── Rework / Warranty Tracking ──────────────────────────────────────────── /// Returns all rework records for a job as JSON, newest first. [HttpGet] public async Task GetReworkRecords(int jobId) { var records = await _unitOfWork.ReworkRecords.FindAsync( r => r.JobId == jobId, false, r => r.JobItem, r => r.ReworkJob); var dtos = _mapper.Map>(records.OrderByDescending(r => r.CreatedAt).ToList()); return Json(dtos); } /// /// Records a rework event against a job item (e.g. defect found during QC). /// Automatically creates a new linked rework Job so the repair work can be tracked /// through the same job lifecycle. The rework job inherits the original job's customer, /// oven, and items so the shop has a complete specification to work from. /// [HttpPost] public async Task AddReworkRecord([FromBody] CreateReworkRecordDto dto) { var job = await _unitOfWork.Jobs.LoadForDetailsAsync(dto.JobId); if (job == null) return NotFound(); var companyId = job.CompanyId; // Generate rework job number var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId); var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == "PENDING"); var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId); var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First(); var allJobs = await _unitOfWork.Jobs.GetAllAsync(true); var year = DateTime.Now.ToString("yy"); var month = DateTime.Now.ToString("MM"); var prefix = $"JOB-{year}{month}-"; var maxNum = allJobs .Where(j => j.JobNumber.StartsWith(prefix)) .Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; }) .DefaultIfEmpty(0).Max(); var reworkJob = pendingStatus != null ? new Job { JobNumber = $"{prefix}{(maxNum + 1):D4}", CustomerId = job.CustomerId, Description = $"REWORK: {job.Description}", JobStatusId = pendingStatus.Id, JobPriorityId = normalPriority.Id, IsReworkJob = true, OriginalJobId = job.Id, SpecialInstructions = $"Rework of {job.JobNumber}.", CompanyId = companyId, CreatedAt = DateTime.UtcNow } : null; if (reworkJob != null) { await _unitOfWork.Jobs.AddAsync(reworkJob); await _unitOfWork.CompleteAsync(); // Copy items: specific item if flagged, otherwise all items var itemsToCopy = dto.JobItemId.HasValue ? job.JobItems.Where(i => i.Id == dto.JobItemId.Value).ToList() : job.JobItems.ToList(); foreach (var item in itemsToCopy) { var newItem = new JobItem { JobId = reworkJob.Id, Description = item.Description, Quantity = item.Quantity, SurfaceAreaSqFt = item.SurfaceAreaSqFt, CatalogItemId = item.CatalogItemId, IsGenericItem = item.IsGenericItem, IsLaborItem = item.IsLaborItem, ManualUnitPrice = item.ManualUnitPrice, RequiresSandblasting = item.RequiresSandblasting, RequiresMasking = item.RequiresMasking, IncludePrepCost = item.IncludePrepCost, EstimatedMinutes = item.EstimatedMinutes, Complexity = item.Complexity, Notes = item.Notes, CompanyId = companyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.JobItems.AddAsync(newItem); await _unitOfWork.CompleteAsync(); foreach (var coat in item.Coats.OrderBy(c => c.Sequence)) { await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat { JobItemId = newItem.Id, CoatName = coat.CoatName, Sequence = coat.Sequence, InventoryItemId = coat.InventoryItemId, ColorName = coat.ColorName, VendorId = coat.VendorId, ColorCode = coat.ColorCode, Finish = coat.Finish, CoverageSqFtPerLb = coat.CoverageSqFtPerLb, TransferEfficiency = coat.TransferEfficiency, PowderCostPerLb = coat.PowderCostPerLb, Notes = coat.Notes, CompanyId = companyId, CreatedAt = DateTime.UtcNow }); } foreach (var prep in item.PrepServices) { await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService { JobItemId = newItem.Id, PrepServiceId = prep.PrepServiceId, EstimatedMinutes = prep.EstimatedMinutes, CompanyId = companyId, CreatedAt = DateTime.UtcNow }); } } await _unitOfWork.CompleteAsync(); } var record = new ReworkRecord { JobId = dto.JobId, JobItemId = dto.JobItemId, ReworkType = dto.ReworkType, Reason = dto.Reason, DefectDescription = dto.DefectDescription, DiscoveredBy = dto.DiscoveredBy, DiscoveredDate = dto.DiscoveredDate, ReportedByName = dto.ReportedByName, EstimatedReworkCost = dto.EstimatedReworkCost, IsBillableToCustomer = dto.IsBillableToCustomer, BillingNotes = dto.BillingNotes, ReworkJobId = reworkJob?.Id, Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open, CompanyId = companyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.ReworkRecords.AddAsync(record); await _unitOfWork.CompleteAsync(); // Reload with navigation for response var saved = await _unitOfWork.ReworkRecords.FindAsync(r => r.Id == record.Id, false, r => r.JobItem, r => r.ReworkJob); return Json(_mapper.Map(saved.First())); } /// /// Updates a rework record's status, resolution notes, cost, and billability. /// Auto-sets ResolvedDate when status transitions to Resolved or WrittenOff (if not already set). /// [HttpPost] public async Task UpdateReworkRecord([FromBody] UpdateReworkRecordDto dto) { var record = await _unitOfWork.ReworkRecords.GetByIdAsync(dto.Id, false, r => r.JobItem, r => r.ReworkJob); if (record == null) return NotFound(); record.Status = dto.Status; record.Resolution = dto.Resolution; record.ActualReworkCost = dto.ActualReworkCost; record.IsBillableToCustomer = dto.IsBillableToCustomer; record.BillingNotes = dto.BillingNotes; record.ResolutionNotes = dto.ResolutionNotes; record.ReworkJobId = dto.ReworkJobId > 0 ? dto.ReworkJobId : null; if (dto.ResolvedDate.HasValue) record.ResolvedDate = dto.ResolvedDate; if (dto.Status == ReworkStatus.Resolved || dto.Status == ReworkStatus.WrittenOff) record.ResolvedDate ??= DateTime.UtcNow; record.UpdatedAt = DateTime.UtcNow; await _unitOfWork.ReworkRecords.UpdateAsync(record); await _unitOfWork.CompleteAsync(); var updated = await _unitOfWork.ReworkRecords.FindAsync(r => r.Id == record.Id, false, r => r.JobItem, r => r.ReworkJob); return Json(_mapper.Map(updated.First())); } /// /// Soft-deletes a rework record and its associated rework job (if one was created). /// Deleting the rework job is intentional — the job only exists to track the repair work /// for this specific rework event, so it has no independent lifecycle. /// [HttpPost] public async Task DeleteReworkRecord([FromBody] DeleteTimeEntryRequest req) { var record = await _unitOfWork.ReworkRecords.GetByIdAsync(req.Id); if (record == null) return NotFound(); var reworkJobId = record.ReworkJobId; await _unitOfWork.ReworkRecords.SoftDeleteAsync(req.Id); if (reworkJobId.HasValue) await _unitOfWork.Jobs.SoftDeleteAsync(reworkJobId.Value); await _unitOfWork.CompleteAsync(); return Json(new { success = true }); } /// /// Creates a new rework Job from an existing rework record and links them. /// The rework job is a lightweight clone of the original job — same customer, description, and /// oven — but starts fresh with Pending status so it goes through the full workflow again. /// The ReworkJob FK on the rework record is updated so the Detail view can link to it. /// [HttpPost] public async Task CreateReworkJob([FromBody] CreateReworkJobRequest req) { var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job); if (reworkRecord == null) return NotFound(); var originalJob = reworkRecord.Job; var companyId = originalJob.CompanyId; // Load status lookups to find Pending status var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId); var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == "PENDING"); if (pendingStatus == null) return Json(new { success = false, message = "Could not find Pending status." }); var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId); var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First(); // Generate job number var allJobs = await _unitOfWork.Jobs.GetAllAsync(true); var year = DateTime.Now.ToString("yy"); var month = DateTime.Now.ToString("MM"); var prefix = $"JOB-{year}{month}-"; var maxNum = allJobs .Where(j => j.JobNumber.StartsWith(prefix)) .Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; }) .DefaultIfEmpty(0).Max(); var reworkJob = new Job { JobNumber = $"{prefix}{(maxNum + 1):D4}", CustomerId = originalJob.CustomerId, Description = $"REWORK: {originalJob.Description}", JobStatusId = pendingStatus.Id, JobPriorityId = normalPriority.Id, IsReworkJob = true, OriginalJobId = originalJob.Id, SpecialInstructions = $"Rework of {originalJob.JobNumber}. {req.Notes}".Trim().TrimEnd('.') + ".", CompanyId = companyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.Jobs.AddAsync(reworkJob); await _unitOfWork.CompleteAsync(); // Link rework record to new job reworkRecord.ReworkJobId = reworkJob.Id; reworkRecord.Status = ReworkStatus.InProgress; reworkRecord.UpdatedAt = DateTime.UtcNow; await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord); await _unitOfWork.CompleteAsync(); return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber }); } // ── Quote-Changed Banner Actions ────────────────────────────────────────── /// /// Dismisses the "source quote was updated" banner by advancing QuoteSnapshotUpdatedAt /// to match the quote's current UpdatedAt. No job data changes — the user is acknowledging /// they have reviewed the quote manually. /// [HttpPost] [ValidateAntiForgeryToken] public async Task DismissQuoteChangedBanner(int id) { var job = await _unitOfWork.Jobs.GetByIdAsync(id, false, j => j.Quote!); if (job == null) return NotFound(); job.QuoteSnapshotUpdatedAt = job.Quote?.UpdatedAt ?? job.Quote?.CreatedAt; await _unitOfWork.CompleteAsync(); return RedirectToAction(nameof(Details), new { id }); } /// /// Re-syncs a job's items and pricing from its source quote, but only while the job is /// still in a pre-production status (PENDING, QUOTED, APPROVED). Once shop work has /// started (IN_PREPARATION or beyond) the button is hidden and this endpoint returns 400 /// as a safety guard. Soft-deletes all current job items, then re-copies items, coats, /// and prep services from the quote — identical logic to the initial quote→job conversion. /// [HttpPost] [ValidateAntiForgeryToken] public async Task ResyncFromQuote(int id) { var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id); if (job == null) return NotFound(); // Guard: only allow re-sync while job is pre-production var preProductionCodes = new HashSet(StringComparer.OrdinalIgnoreCase) { "PENDING", "QUOTED", "APPROVED" }; if (!preProductionCodes.Contains(job.JobStatus?.StatusCode ?? "")) { TempData["Error"] = "Re-sync is only available before shop work has started."; return RedirectToAction(nameof(Details), new { id }); } if (!job.QuoteId.HasValue) { TempData["Error"] = "This job has no linked quote to sync from."; return RedirectToAction(nameof(Details), new { id }); } await _unitOfWork.ExecuteInTransactionAsync(async () => { // Soft-delete all current job items and their coats var existingItems = job.JobItems.Where(ji => !ji.IsDeleted).ToList(); foreach (var item in existingItems) { var coats = await _unitOfWork.JobItemCoats.FindAsync(c => c.JobItemId == item.Id && !c.IsDeleted); foreach (var coat in coats) await _unitOfWork.JobItemCoats.SoftDeleteAsync(coat.Id); await _unitOfWork.JobItems.SoftDeleteAsync(item.Id); } // Soft-delete all job-level prep services var existingPrep = await _unitOfWork.JobPrepServices.FindAsync(p => p.JobId == id && !p.IsDeleted); foreach (var ps in existingPrep) await _unitOfWork.JobPrepServices.SoftDeleteAsync(ps.Id); await _unitOfWork.SaveChangesAsync(); // Load quote items with full coat + prep-service data var quote = job.Quote!; var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(job.QuoteId.Value); foreach (var quoteItem in fullItems.Where(qi => !qi.IsDeleted)) { var firstCoat = quoteItem.Coats?.OrderBy(c => c.Sequence).FirstOrDefault(); var jobItem = new JobItem { JobId = id, Description = quoteItem.Description, Quantity = quoteItem.Quantity, ColorName = firstCoat?.ColorName, ColorCode = firstCoat?.ColorCode, Finish = firstCoat?.Finish, SurfaceArea = quoteItem.SurfaceAreaSqFt, SurfaceAreaSqFt = quoteItem.SurfaceAreaSqFt, CatalogItemId = quoteItem.CatalogItemId, IsGenericItem = quoteItem.IsGenericItem, IsLaborItem = quoteItem.IsLaborItem, IsSalesItem = quoteItem.IsSalesItem, Sku = quoteItem.Sku, ManualUnitPrice = quoteItem.ManualUnitPrice, PowderCostOverride = quoteItem.PowderCostOverride, UnitPrice = quoteItem.UnitPrice, TotalPrice = quoteItem.TotalPrice, LaborCost = quoteItem.TotalPrice * 0.4m, RequiresSandblasting = quoteItem.RequiresSandblasting, RequiresMasking = quoteItem.RequiresMasking, EstimatedMinutes = quoteItem.EstimatedMinutes, Notes = quoteItem.Notes, Complexity = quoteItem.Complexity, AiTags = quoteItem.AiTags, AiPredictionId = quoteItem.AiPredictionId, IncludePrepCost = !quoteItem.CatalogItemId.HasValue, CompanyId = job.CompanyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.JobItems.AddAsync(jobItem); await _unitOfWork.SaveChangesAsync(); if (quoteItem.Coats != null) { foreach (var quoteCoat in quoteItem.Coats.OrderBy(c => c.Sequence)) { string colorName = quoteCoat.ColorName; string colorCode = quoteCoat.ColorCode; string finish = quoteCoat.Finish; if (quoteCoat.InventoryItemId.HasValue && quoteCoat.InventoryItem != null) { colorName = quoteCoat.InventoryItem.Name; colorCode = quoteCoat.InventoryItem.ColorCode; finish = quoteCoat.InventoryItem.Finish; } var cov = quoteCoat.CoverageSqFtPerLb > 0 ? quoteCoat.CoverageSqFtPerLb : 30m; var eff = quoteCoat.TransferEfficiency > 0 ? quoteCoat.TransferEfficiency / 100m : 0.65m; var powderToOrder = (quoteCoat.PowderToOrder > 0) ? quoteCoat.PowderToOrder : (quoteItem.SurfaceAreaSqFt > 0 ? Math.Round((quoteItem.SurfaceAreaSqFt * quoteItem.Quantity) / (cov * eff), 2) : (decimal?)null); await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat { JobItemId = jobItem.Id, CoatName = quoteCoat.CoatName, Sequence = quoteCoat.Sequence, InventoryItemId = quoteCoat.InventoryItemId, ColorName = colorName, VendorId = quoteCoat.VendorId, ColorCode = colorCode, Finish = finish, CoverageSqFtPerLb = quoteCoat.CoverageSqFtPerLb, TransferEfficiency = quoteCoat.TransferEfficiency, PowderCostPerLb = quoteCoat.PowderCostPerLb, PowderToOrder = powderToOrder, Notes = quoteCoat.Notes, CompanyId = job.CompanyId, CreatedAt = DateTime.UtcNow }); } } } await _unitOfWork.SaveChangesAsync(); // Aggregate prep services from all quote items and copy to job var quoteItemIds = fullItems.Select(qi => qi.Id).ToList(); var itemPrepServices = await _unitOfWork.QuoteItemPrepServices.FindAsync( ps => quoteItemIds.Contains(ps.QuoteItemId)); foreach (var prepServiceId in itemPrepServices.Select(ps => ps.PrepServiceId).Distinct()) { await _unitOfWork.JobPrepServices.AddAsync(new JobPrepService { JobId = id, PrepServiceId = prepServiceId, CompanyId = job.CompanyId, CreatedAt = DateTime.UtcNow }); } // Update pricing from quote and advance the snapshot so banner clears job.QuotedPrice = quote.Total; job.FinalPrice = quote.Total; job.QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt; await _unitOfWork.CompleteAsync(); }); _logger.LogInformation("Job {JobId} re-synced from quote {QuoteId}", id, job.QuoteId); TempData["Success"] = "Job items and pricing re-synced from the source quote."; return RedirectToAction(nameof(Details), new { id }); } // ── Job Costing Breakdown ───────────────────────────────────────────────── /// /// Returns a detailed cost breakdown for a job as JSON, used by the "Costing" tab on /// the Job Details page. Calculates material cost (powder × coverage × unit cost), /// labor cost (time entries × role-specific hourly rates or fallback rate), oven cost /// (cycle time × hourly rate), and overhead. Also computes margin vs quoted price so the /// shop can see profitability at a glance. ShopWorkerRoleCosts provide per-role hourly /// rates; if a worker's role has no cost configured, the company's standard labor rate is used. /// [HttpGet] public async Task GetCostingBreakdown(int jobId) { try { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; // Load job with items, coats, and time entries var job = await _unitOfWork.Jobs.LoadForCostingAsync(jobId, companyId); if (job == null) return NotFound(); // Operating costs for fallback labor rate and oven rate var opCosts = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault(); var fallbackLaborRate = opCosts?.StandardLaborRate ?? 0m; var defaultOvenCycleHours = (opCosts?.DefaultOvenCycleMinutes ?? 45) / 60.0m; // Role cost rates map: role → hourly rate var roleCosts = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId); var roleCostMap = roleCosts.ToDictionary(r => r.Role, r => r.HourlyRate); // 1. Powder / Material cost decimal powderCost = 0m; var powderLines = new List(); foreach (var item in job.JobItems) { foreach (var coat in item.Coats) { var lbs = coat.ActualPowderUsedLbs ?? coat.PowderToOrder ?? 0m; var costPerLb = coat.PowderCostPerLb ?? 0m; var lineCost = lbs * costPerLb; powderCost += lineCost; if (lbs > 0 && costPerLb > 0) { powderLines.Add(new { description = $"{item.Description} — {coat.CoatName}" + (!string.IsNullOrWhiteSpace(coat.ColorName) ? $" ({coat.ColorName})" : ""), lbs = Math.Round(lbs, 3), costPerLb = Math.Round(costPerLb, 4), total = Math.Round(lineCost, 2), isActual = coat.ActualPowderUsedLbs.HasValue }); } } } // 2. Labor cost decimal laborCost = 0m; var laborLines = new List(); foreach (var entry in job.TimeEntries) { var rate = entry.Worker != null && roleCostMap.TryGetValue(entry.Worker.Role, out var r) ? r : fallbackLaborRate; var lineCost = entry.HoursWorked * rate; laborCost += lineCost; laborLines.Add(new { worker = entry.Worker?.Name ?? "Unknown", role = entry.Worker != null ? System.Text.RegularExpressions.Regex.Replace(entry.Worker.Role.ToString(), "([a-z])([A-Z])", "$1 $2") : "", hours = entry.HoursWorked, rate = Math.Round(rate, 2), total = Math.Round(lineCost, 2), usingFallback = entry.Worker == null || !roleCostMap.ContainsKey(entry.Worker.Role), stage = entry.Stage, workDate = entry.WorkDate.ToString("MM/dd/yyyy") }); } // 3. Oven / equipment cost (estimated from oven selection + default cycle) decimal ovenCost = 0m; string ovenLabel = "Default"; if (job.OvenCost != null) { ovenCost = job.OvenCost.CostPerHour * defaultOvenCycleHours; ovenLabel = job.OvenCost.Label; } else if (opCosts != null) { ovenCost = opCosts.OvenOperatingCostPerHour * defaultOvenCycleHours; } // 4. Revenue decimal revenue = job.Invoice != null ? job.Invoice.Total : (job.FinalPrice > 0 ? job.FinalPrice : job.QuotedPrice); // 5. Rework costs from linked rework jobs var reworkRecords = await _unitOfWork.ReworkRecords.FindAsync( r => r.JobId == jobId, false, r => r.ReworkJob); decimal reworkCostTotal = 0m; decimal reworkBilledToCustomer = 0m; var reworkLines = new List(); foreach (var rr in reworkRecords) { var cost = rr.ActualReworkCost > 0 ? rr.ActualReworkCost : rr.EstimatedReworkCost; var billed = rr.IsBillableToCustomer ? cost : 0m; reworkCostTotal += cost; reworkBilledToCustomer += billed; reworkLines.Add(new { jobNumber = rr.ReworkJob?.JobNumber, reason = System.Text.RegularExpressions.Regex.Replace(rr.Reason.ToString(), "([a-z])([A-Z])", "$1 $2"), cost = Math.Round(cost, 2), billedToCustomer = Math.Round(billed, 2), isEstimate = rr.ActualReworkCost == 0, status = rr.Status.ToString() }); } decimal netReworkCost = reworkCostTotal - reworkBilledToCustomer; decimal totalCost = powderCost + laborCost + ovenCost; decimal totalCostWithRework = totalCost + netReworkCost; decimal grossProfit = revenue - totalCostWithRework; decimal grossMargin = revenue > 0 ? Math.Round(grossProfit / revenue * 100, 1) : 0; decimal quotedMargin = job.QuotedPrice > 0 ? Math.Round((job.QuotedPrice - totalCostWithRework) / job.QuotedPrice * 100, 1) : 0; return Json(new { revenue = Math.Round(revenue, 2), revenueSource = job.Invoice != null ? "Invoice" : (job.FinalPrice > 0 ? "Final Price" : "Quoted Price"), powderCost = Math.Round(powderCost, 2), laborCost = Math.Round(laborCost, 2), ovenCost = Math.Round(ovenCost, 2), ovenLabel, ovenCycleMinutes = opCosts?.DefaultOvenCycleMinutes ?? 45, reworkCostTotal = Math.Round(reworkCostTotal, 2), reworkBilledToCustomer = Math.Round(reworkBilledToCustomer, 2), netReworkCost = Math.Round(netReworkCost, 2), reworkLines, hasRework = reworkLines.Count > 0, totalCost = Math.Round(totalCostWithRework, 2), grossProfit = Math.Round(grossProfit, 2), grossMargin, quotedMargin, quotedPrice = Math.Round(job.QuotedPrice, 2), fallbackLaborRate, powderLines, laborLines, hasPowderData = powderLines.Count > 0, hasLaborData = laborLines.Count > 0 }); } catch (Exception ex) { _logger.LogError(ex, "Error computing costing breakdown for job {JobId}", jobId); return Json(new { error = "Unable to compute costing breakdown." }); } } private async Task GetCompanyPreferencesAsync(int companyId) { return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted); } private async Task BuildBoardGuidedActivationCalloutAsync( int companyId, string? guidedActivation, int? highlightJobId, Job? highlightedJob) { if (!highlightJobId.HasValue || highlightedJob == null) return null; var prefs = await GetCompanyPreferencesAsync(companyId); if (prefs == null || !prefs.SetupWizardCompleted || string.IsNullOrWhiteSpace(prefs.OnboardingPath)) return null; if (guidedActivation == AppConstants.GuidedActivation.BoardIntroStep && !prefs.FirstWorkflowCompleted) { return new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel { Show = true, Title = "This is your shop in real time", Message = "Every active job shows up here so you can see what's in production, what's waiting, and what's ready to go.", InstructionText = "Move this job to the next stage to see how your workflow updates.", SecondaryActionText = "View job", SecondaryActionController = "Jobs", SecondaryActionName = "Details", SecondaryActionRouteValues = new { id = highlightJobId.Value } }; } if (guidedActivation == AppConstants.GuidedActivation.BoardReadyForInvoiceStep) { var jobInvoice = await _unitOfWork.Invoices.GetForJobAsync(highlightJobId.Value); var hasInvoice = jobInvoice != null; return new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel { Show = true, Title = "Nice — your workflow just updated. This is how you track work through your shop.", Message = hasInvoice ? "You've already tied billing to this job. Open the invoice or keep exploring the board." : "When the work is done, you can create the invoice.", ActionText = hasInvoice ? "View Invoice" : "Create Invoice", ActionController = "Invoices", ActionName = hasInvoice ? "Details" : "Create", ActionRouteValues = hasInvoice ? new { id = jobInvoice!.Id } : new { jobId = highlightJobId.Value, guidedActivation = AppConstants.GuidedActivation.BoardReadyForInvoiceStep }, SecondaryActionText = "View job", SecondaryActionController = "Jobs", SecondaryActionName = "Details", SecondaryActionRouteValues = new { id = highlightJobId.Value } }; } return null; } private async Task MaybeMarkFirstWorkflowCompletedFromBoardAsync(int companyId, string? guidedActivation) { if (guidedActivation != AppConstants.GuidedActivation.BoardIntroStep) return false; var prefs = await GetCompanyPreferencesAsync(companyId); if (prefs == null || prefs.FirstWorkflowCompleted || !prefs.SetupWizardCompleted) return false; prefs.FirstWorkflowCompleted = true; prefs.FirstWorkflowCompletedAt = DateTime.UtcNow; prefs.GuidedActivationDismissedAt = null; _logger.LogInformation("Marked first workflow complete from Daily Board tracking for company {CompanyId}", companyId); return true; } private async Task StampJobCreatedAsync(int companyId) { var prefs = await GetCompanyPreferencesAsync(companyId); if (prefs == null || prefs.FirstJobCreatedAt.HasValue) return; prefs.FirstJobCreatedAt = DateTime.UtcNow; await _unitOfWork.CompleteAsync(); _logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId); } } public class DeleteTimeEntryRequest { public int Id { get; set; } } public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } } public class UpdateWorkerAssignmentRequest { public int JobId { get; set; } public string? WorkerId { get; set; } } public class UpdateJobPriorityRequest { public int JobId { get; set; } public int PriorityId { get; set; } } public class UpdateJobStatusRequest { public int JobId { get; set; } public int StatusId { get; set; } public bool SendEmail { get; set; } = false; } // ── Kanban Board view models ────────────────────────────────────────────────── public class JobBoardColumn { public int StatusId { get; set; } public string StatusCode { get; set; } = ""; public string DisplayName { get; set; } = ""; public string ColorClass { get; set; } = "secondary"; public string? IconClass { get; set; } public bool IsTerminal { get; set; } public List Jobs { get; set; } = new(); } public class JobBoardCard { public int Id { get; set; } public string JobNumber { get; set; } = ""; public string CustomerName { get; set; } = ""; public string Description { get; set; } = ""; public string PriorityCode { get; set; } = ""; public string PriorityDisplayName { get; set; } = ""; public string PriorityColorClass { get; set; } = "secondary"; public DateTime? DueDate { get; set; } public bool IsOverdue { get; set; } public string? AssignedWorkerName { get; set; } public decimal FinalPrice { get; set; } } public class MoveCardRequest { public int JobId { get; set; } public int NewStatusId { get; set; } public string? GuidedActivation { get; set; } public int? HighlightJobId { get; set; } } public class AdvanceJobStatusRequest { public int JobId { get; set; } public int NewStatusId { get; set; } public string? GuidedActivation { get; set; } public int? HighlightJobId { get; set; } } public class UpdateDatesRequest { public int JobId { get; set; } public string? ScheduledDate { get; set; } // "yyyy-MM-dd" or "" to clear; null = not changing public string? DueDate { get; set; } // "yyyy-MM-dd" or "" to clear; null = not changing } public class PowderQrCodeInfo { public string Base64 { get; set; } = ""; public string Name { get; set; } = ""; public string? ColorCode { get; set; } public string? Manufacturer { get; set; } public decimal TotalLbs { get; set; } }