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 IJobItemAssemblyService _jobItemAssemblyService; private readonly IHubContext _hub; private readonly IHubContext _shopHub; private readonly IAccountBalanceService _accountBalanceService; public JobsController( IUnitOfWork unitOfWork, IMapper mapper, IJobPhotoService jobPhotoService, UserManager userManager, ILogger logger, ITenantContext tenantContext, IMeasurementConversionService measurementService, ILookupCacheService lookupCache, INotificationService notificationService, ISubscriptionService subscriptionService, IPricingCalculationService pricingService, IJobItemAssemblyService jobItemAssemblyService, IHubContext hub, IHubContext shopHub, IAccountBalanceService accountBalanceService) { _unitOfWork = unitOfWork; _mapper = mapper; _jobPhotoService = jobPhotoService; _userManager = userManager; _logger = logger; _tenantContext = tenantContext; _measurementService = measurementService; _lookupCache = lookupCache; _notificationService = notificationService; _subscriptionService = subscriptionService; _pricingService = pricingService; _jobItemAssemblyService = jobItemAssemblyService; _hub = hub; _shopHub = shopHub; _accountBalanceService = accountBalanceService; } /// /// 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 { // Default landing view: On Floor — redirect bare /Jobs to ?statusGroup=active // so completed/cancelled jobs don't clutter the first screen. if (string.IsNullOrEmpty(statusGroup) && string.IsNullOrEmpty(searchTerm) && string.IsNullOrEmpty(tagFilter)) return RedirectToAction("Index", new { statusGroup = "active" }); // 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 != AppConstants.StatusCodes.Job.Completed && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled; } else if (statusGroup == "overdue") { filter = j => j.DueDate < todayDate && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled; } else if (statusGroup == "completed") { filter = j => j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed || j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup || j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered; } else if (statusGroup == "ready") { filter = j => j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup; } // "all" or unknown group: no filter applied (show every status) } 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(); } var pagedResult = PagedResult.From( gridRequest, jobDtos, string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count); // Pill badge counts — always global (not scoped to current filter/page) var today = DateTime.Today; ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync(); ViewBag.ActiveCount = await _unitOfWork.Jobs.CountAsync(j => j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled); ViewBag.OverdueCount = await _unitOfWork.Jobs.CountAsync(j => j.DueDate < today && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled); ViewBag.CompletedCount = await _unitOfWork.Jobs.CountAsync(j => j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed || j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup || j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered); ViewBag.ReadyCount = await _unitOfWork.Jobs.CountAsync(j => j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup); // 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); // Separate active invoice from voided history for this job var allJobInvoices = await _unitOfWork.Invoices.FindAsync(i => i.JobId == id.Value); var jobInvoice = allJobInvoices.FirstOrDefault(i => i.Status != Core.Enums.InvoiceStatus.Voided); var voidedInvoices = allJobInvoices .Where(i => i.Status == Core.Enums.InvoiceStatus.Voided) .Select(i => new { i.Id, i.InvoiceNumber }) .ToList(); ViewBag.JobInvoiceId = jobInvoice?.Id; ViewBag.JobInvoiceNumber = jobInvoice?.InvoiceNumber; ViewBag.JobInvoiceStatus = jobInvoice?.Status; ViewBag.JobVoidedInvoices = voidedInvoices; // 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(); ViewBag.CurrentUserId = _userManager.GetUserId(User); // 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 = await GetEffectiveTaxPercentAsync(job.CustomerId, wizardCosts?.TaxPercent ?? 0m); // Display the pricing snapshot stored when items were last saved. // Never recalculate on load — operating cost changes must not retroactively alter existing jobs. if (!string.IsNullOrEmpty(job.PricingBreakdownJson)) { ViewBag.JobPricingBreakdown = JsonSerializer.Deserialize(job.PricingBreakdownJson); } else if (job.FinalPrice > 0) { // Legacy job created before snapshot was introduced — show what we have stored ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto { OvenBatchCost = job.OvenBatchCost, OvenBatches = job.OvenBatches, ShopSuppliesAmount = job.ShopSuppliesAmount, ShopSuppliesPercent = job.ShopSuppliesPercent, Total = job.FinalPrice }; } 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, isAiItem = ji.IsAiItem, isCustomFormulaItem = ji.IsCustomFormulaItem, customItemTemplateId = ji.CustomItemTemplateId, formulaFieldValuesJson = ji.FormulaFieldValuesJson, 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, noExtraLayerCharge = c.NoExtraLayerCharge, 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 var allJobTransactions = (await _unitOfWork.InventoryTransactions.FindAsync( t => t.JobId == id.Value, false, t => t.InventoryItem)) .OrderByDescending(t => t.TransactionDate).ToList(); ViewBag.MaterialsUsed = allJobTransactions; // Inventory items for the manual log-material modal var inventoryItemsForModal = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == job.CompanyId)) .OrderBy(i => i.Name) .Select(i => new { i.Id, i.Name, i.Manufacturer, i.UnitOfMeasure, i.QuantityOnHand }) .ToList(); var jsonOpts = new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }; ViewBag.InventoryItemsForModal = System.Text.Json.JsonSerializer.Serialize(inventoryItemsForModal, jsonOpts); // IDs of powders already assigned to this job's coats — shown at top of log-material dropdown var jobPowderIds = (jobDto.Items ?? new List()) .SelectMany(i => i.Coats ?? new List()) .Where(c => c.InventoryItemId.HasValue) .Select(c => c.InventoryItemId!.Value) .Distinct() .ToList(); ViewBag.JobPowderIds = System.Text.Json.JsonSerializer.Serialize(jobPowderIds, jsonOpts); // Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill) ViewBag.PreLoggedPowder = allJobTransactions .GroupBy(t => t.InventoryItemId) .ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity))); // 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.FindAsync(c => c.CompanyId == job.CompanyId); 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) { AppConstants.StatusCodes.Job.Pending, AppConstants.StatusCodes.Job.Quoted, AppConstants.StatusCodes.Job.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 companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var allStatuses = (await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId)) .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.FindAsync(s => s.CompanyId == job.CompanyId)).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 == AppConstants.StatusCodes.Job.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 != AppConstants.StatusCodes.Job.InPreparation) { var allStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == jobToUpdate.CompanyId); var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation); 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 != AppConstants.StatusCodes.Job.InPreparation && !job.JobStatus.IsTerminalStatus) { var allStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == job.CompanyId); var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation); 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 = i.IsSalesItem, sku = i.Sku, 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 == AppConstants.StatusCodes.Job.Pending); var job = new Job { JobNumber = await GenerateJobNumber(), CustomerId = dto.CustomerId, QuoteId = dto.QuoteId, AssignedUserId = dto.AssignedUserId, OvenCostId = dto.OvenCostId, OvenBatches = dto.OvenBatches > 0 ? dto.OvenBatches : 1, OvenCycleMinutes = dto.OvenCycleMinutes, 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 createdAtUtc = DateTime.UtcNow; var jobItem = _jobItemAssemblyService.CreateJobItem(itemDto, job.Id, companyId, itemPricing, createdAtUtc); await _unitOfWork.JobItems.AddAsync(jobItem); await _unitOfWork.SaveChangesAsync(); foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(itemDto, jobItem.Id, companyId, createdAtUtc)) { await _unitOfWork.JobItemCoats.AddAsync(coat); } foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(itemDto, jobItem.Id, companyId, createdAtUtc)) { await _unitOfWork.JobItemPrepServices.AddAsync(prepService); } } // Option B: auto-add Custom Powder Order item on first save if not already present var allCreateItems = dto.JobItems.ToList(); if (!allCreateItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true)) { var powderDto = await BuildCustomPowderOrderDto(allCreateItems); if (powderDto != null) { var pp = new QuoteItemPricingResult { UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value, ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value, LaborCost = 0, EquipmentCost = 0 }; var pi = _jobItemAssemblyService.CreateJobItem(powderDto, job.Id, companyId, pp, DateTime.UtcNow); await _unitOfWork.JobItems.AddAsync(pi); await _unitOfWork.SaveChangesAsync(); allCreateItems.Add(powderDto); } } // Recalculate total from wizard items var createCosts = await _pricingService.GetOperatingCostsAsync(companyId); decimal? createOvenRate = null; if (dto.OvenCostId.HasValue) { var createOven = await _unitOfWork.OvenCosts.GetByIdAsync(dto.OvenCostId.Value); if (createOven != null && createOven.CompanyId == companyId) createOvenRate = createOven.CostPerHour; } var totals = await _pricingService.CalculateQuoteTotalsAsync( allCreateItems, companyId, dto.CustomerId, await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m), dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes); job.FinalPrice = totals.Total; job.OvenBatchCost = totals.OvenBatchCost; job.ShopSuppliesAmount = totals.ShopSuppliesAmount; job.ShopSuppliesPercent = totals.ShopSuppliesPercent; job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals)); 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, OvenCostId = job.OvenCostId, OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1, OvenCycleMinutes = job.OvenCycleMinutes, 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, IsAiItem = ji.IsAiItem, IsCustomFormulaItem = ji.IsCustomFormulaItem, CustomItemTemplateId = ji.CustomItemTemplateId, FormulaFieldValuesJson = ji.FormulaFieldValuesJson, 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, NoExtraLayerCharge = c.NoExtraLayerCharge, 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 createdAtUtc = DateTime.UtcNow; var jobItem = _jobItemAssemblyService.CreateJobItem(itemDto, id, companyId, itemPricing, createdAtUtc); await _unitOfWork.JobItems.AddAsync(jobItem); await _unitOfWork.SaveChangesAsync(); foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(itemDto, jobItem.Id, companyId, createdAtUtc)) { await _unitOfWork.JobItemCoats.AddAsync(coat); } foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(itemDto, jobItem.Id, companyId, createdAtUtc)) { await _unitOfWork.JobItemPrepServices.AddAsync(prepService); } } // Option B: auto-add Custom Powder Order item on first save if not already present var allEditItems = dto.JobItems.ToList(); if (!allEditItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true)) { var powderDto = await BuildCustomPowderOrderDto(allEditItems); if (powderDto != null) { var pp = new QuoteItemPricingResult { UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value, ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value, LaborCost = 0, EquipmentCost = 0 }; var pi = _jobItemAssemblyService.CreateJobItem(powderDto, id, companyId, pp, DateTime.UtcNow); await _unitOfWork.JobItems.AddAsync(pi); await _unitOfWork.SaveChangesAsync(); allEditItems.Add(powderDto); } } // 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; job.OvenCostId = dto.OvenCostId; job.OvenBatches = dto.OvenBatches > 0 ? dto.OvenBatches : 1; job.OvenCycleMinutes = dto.OvenCycleMinutes; 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 == AppConstants.StatusCodes.Job.InPreparation && job.StartedDate == null) { job.StartedDate = DateTime.UtcNow; } else if (newStatus.StatusCode == AppConstants.StatusCodes.Job.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 (allEditItems.Any()) { var editCosts = await _pricingService.GetOperatingCostsAsync(companyId); decimal? editOvenRate = null; if (job.OvenCostId.HasValue) { var editOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value); if (editOven != null && editOven.CompanyId == companyId) editOvenRate = editOven.CostPerHour; } var totals = await _pricingService.CalculateQuoteTotalsAsync( allEditItems, companyId, dto.CustomerId, await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m), dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes); job.FinalPrice = totals.Total; job.OvenBatchCost = totals.OvenBatchCost; job.ShopSuppliesAmount = totals.ShopSuppliesAmount; job.ShopSuppliesPercent = totals.ShopSuppliesPercent; job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals)); } // 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); var allowFormulas = HttpContext.Items["AllowCustomFormulas"] as bool? ?? false; if (allowFormulas) { var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync( t => t.CompanyId == companyId && t.IsActive); ViewBag.CustomFormulaTemplates = formulaTemplates .OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name) .Select(t => new { id = t.Id, name = t.Name, description = t.Description, outputMode = t.OutputMode, fieldsJson = t.FieldsJson, formula = t.Formula, defaultRate = t.DefaultRate, rateLabel = t.RateLabel, diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath) ? (string?)null : Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id }) }).ToList(); } else { ViewBag.CustomFormulaTemplates = new List(); } await PopulateDropdowns(); await PopulatePrepServicesAsync(companyId); 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; ViewBag.DefaultOvenCycleMinutes = costs?.DefaultOvenCycleMinutes ?? 45; 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 companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId); 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 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"); } /// /// /// Creates a new job that is a copy of an existing job. All items, coats, and prep services /// are deep-copied. Pricing-routing flags (IsAiItem, IsGenericItem, IsLaborItem, IsSalesItem) /// are preserved so pricing behaves identically. Dates, worker assignment, and invoice links /// are cleared; status resets to Pending so the job enters the normal workflow from the start. /// [Authorize(Policy = AppConstants.Policies.CanManageJobs)] public async Task CloneJob(int id) { try { var source = await _unitOfWork.Jobs.LoadForDetailsAsync(id); if (source == null) return NotFound(); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var pendingStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync( s => s.StatusCode == AppConstants.StatusCodes.Job.Pending && s.CompanyId == companyId); if (pendingStatus == null) { this.ToastError("Could not find Pending status for this company."); return RedirectToAction(nameof(Details), new { id }); } var newJob = new Job { JobNumber = await GenerateJobNumber(), CustomerId = source.CustomerId, CompanyId = companyId, JobStatusId = pendingStatus.Id, JobPriorityId = source.JobPriorityId, Description = source.Description, CustomerPO = source.CustomerPO, ProjectName = source.ProjectName, SpecialInstructions = source.SpecialInstructions, InternalNotes = source.InternalNotes, Tags = source.Tags, IsRushJob = source.IsRushJob, RequiresCustomerApproval = source.RequiresCustomerApproval, DiscountType = source.DiscountType, DiscountValue = source.DiscountValue, DiscountReason = source.DiscountReason, OvenCostId = source.OvenCostId, OvenBatches = source.OvenBatches, OvenCycleMinutes = source.OvenCycleMinutes, ShopSuppliesPercent = source.ShopSuppliesPercent, ShopAccessCode = Guid.NewGuid() }; await _unitOfWork.Jobs.AddAsync(newJob); await _unitOfWork.CompleteAsync(); foreach (var srcItem in source.JobItems.Where(i => !i.IsDeleted)) { var newItem = new JobItem { JobId = newJob.Id, CompanyId = companyId, Description = srcItem.Description, Quantity = srcItem.Quantity, ColorName = srcItem.ColorName, ColorCode = srcItem.ColorCode, Finish = srcItem.Finish, SurfaceArea = srcItem.SurfaceArea, SurfaceAreaSqFt = srcItem.SurfaceAreaSqFt, CatalogItemId = srcItem.CatalogItemId, UnitPrice = srcItem.UnitPrice, TotalPrice = srcItem.TotalPrice, LaborCost = srcItem.LaborCost, IsGenericItem = srcItem.IsGenericItem, ManualUnitPrice = srcItem.ManualUnitPrice, PowderCostOverride = srcItem.PowderCostOverride, IsLaborItem = srcItem.IsLaborItem, IsSalesItem = srcItem.IsSalesItem, IsAiItem = srcItem.IsAiItem, AiTags = srcItem.AiTags, IsCustomFormulaItem = srcItem.IsCustomFormulaItem, CustomItemTemplateId = srcItem.CustomItemTemplateId, FormulaFieldValuesJson = srcItem.FormulaFieldValuesJson, Sku = srcItem.Sku, IncludePrepCost = srcItem.IncludePrepCost, RequiresSandblasting = srcItem.RequiresSandblasting, RequiresMasking = srcItem.RequiresMasking, EstimatedMinutes = srcItem.EstimatedMinutes, Complexity = srcItem.Complexity, Notes = srcItem.Notes // AiPredictionId intentionally not copied — prediction belongs to original quote }; await _unitOfWork.JobItems.AddAsync(newItem); await _unitOfWork.CompleteAsync(); foreach (var srcCoat in srcItem.Coats.Where(c => !c.IsDeleted)) { await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat { JobItemId = newItem.Id, CompanyId = companyId, CoatName = srcCoat.CoatName, Sequence = srcCoat.Sequence, InventoryItemId = srcCoat.InventoryItemId, ColorName = srcCoat.ColorName, VendorId = srcCoat.VendorId, ColorCode = srcCoat.ColorCode, Finish = srcCoat.Finish, CoverageSqFtPerLb = srcCoat.CoverageSqFtPerLb, TransferEfficiency = srcCoat.TransferEfficiency, PowderCostPerLb = srcCoat.PowderCostPerLb, PowderToOrder = srcCoat.PowderToOrder, NoExtraLayerCharge = srcCoat.NoExtraLayerCharge, Notes = srcCoat.Notes // Powder ordering / receiving tracking fields intentionally not copied }); } foreach (var srcPrep in srcItem.PrepServices.Where(p => !p.IsDeleted)) { await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService { JobItemId = newItem.Id, CompanyId = companyId, PrepServiceId = srcPrep.PrepServiceId, EstimatedMinutes = srcPrep.EstimatedMinutes, BlastSetupId = srcPrep.BlastSetupId }); } } await _unitOfWork.CompleteAsync(); this.ToastSuccess($"Job cloned as {newJob.JobNumber} — review and update dates before scheduling."); return RedirectToAction(nameof(Details), new { id = newJob.Id }); } catch (Exception ex) { _logger.LogError(ex, "Error cloning job {JobId}", id); this.ToastError("An error occurred while cloning the job."); return RedirectToAction(nameof(Details), new { id }); } } /// 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 != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled && s.StatusCode != AppConstants.StatusCodes.Job.Delivered && s.StatusCode != AppConstants.StatusCodes.Job.Quoted && s.StatusCode != AppConstants.StatusCodes.Job.Pending && s.StatusCode != AppConstants.StatusCodes.Job.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 != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled && s.StatusCode != AppConstants.StatusCodes.Job.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 != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled && s.StatusCode != AppConstants.StatusCodes.Job.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 == AppConstants.StatusCodes.Job.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(int companyId) { var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId); 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 && b.CompanyId == companyId); 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 }); } // When a rework job reaches a terminal status, close out the linked ReworkRecord // on the original job so the shop doesn't have to do it manually. // Cancelled → WrittenOff; any other terminal → Resolved. if (newStatus?.IsTerminalStatus == true && job.IsReworkJob) { var linkedRecords = await _unitOfWork.ReworkRecords.FindAsync( r => r.ReworkJobId == job.Id && r.CompanyId == job.CompanyId, false); foreach (var rr in linkedRecords) { if (rr.Status == ReworkStatus.Resolved || rr.Status == ReworkStatus.WrittenOff) continue; rr.Status = newStatus.StatusCode == AppConstants.StatusCodes.Job.Cancelled ? ReworkStatus.WrittenOff : ReworkStatus.Resolved; rr.ResolvedDate ??= DateTime.UtcNow; rr.UpdatedAt = DateTime.UtcNow; await _unitOfWork.ReworkRecords.UpdateAsync(rr); } if (linkedRecords.Any()) await _unitOfWork.SaveChangesAsync(); } // 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 /// /// Returns the Mark Complete modal partial view populated with full job data. /// Called via AJAX from the Job Board when a card is dragged to the Completed column. /// [HttpGet] public async Task CompleteJobModal(int id) { var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id); if (job == null) return NotFound(); var currentUser = await _userManager.GetUserAsync(User); if (currentUser != null) await PopulateEmailNotificationDefaultsAsync(currentUser.CompanyId); var dto = _mapper.Map(job); return PartialView("_CompleteJobModal", dto); } /// /// 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 == AppConstants.StatusCodes.Job.Completed && s.CompanyId == job.CompanyId); if (completedStatus != null) { await RecordStatusChangeAsync(job, completedStatus.Id); job.JobStatusId = completedStatus.Id; } // Build a mutable credit map: lbs already deducted from inventory for this job // (via QR scan / LogUsage before completion). We consume this credit per InventoryItemId // so we only deduct the net delta and never double-subtract. var preLoggedTransactions = await _unitOfWork.InventoryTransactions.FindAsync( t => t.JobId == dto.JobId); var preLoggedCredit = preLoggedTransactions .GroupBy(t => t.InventoryItemId) .ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity))); // Process powder usage submitted per inventory item (color) for the whole job. // Distribute entered lbs across coats sharing that InventoryItemId proportionally // by estimated PowderToOrder so per-coat reporting stays meaningful. // One inventory deduction per powder (net of pre-logged credit). if (dto.PowderUsages.Any()) { // Load all coats for the job with their inventory items var allCoats = (await _unitOfWork.JobItemCoats.FindAsync( jic => jic.JobItem != null && jic.JobItem.JobId == dto.JobId, false, jic => jic.InventoryItem, jic => jic.JobItem)) .ToList(); foreach (var powderUsage in dto.PowderUsages) { if (!powderUsage.ActualPowderUsedLbs.HasValue || powderUsage.ActualPowderUsedLbs.Value <= 0) continue; var invItemId = powderUsage.InventoryItemId; var totalActualLbs = powderUsage.ActualPowderUsedLbs.Value; // Distribute across coats using this powder proportionally by estimated lbs var coatsForPowder = allCoats.Where(c => c.InventoryItemId == invItemId).ToList(); if (coatsForPowder.Any()) { var totalEstimated = coatsForPowder.Sum(c => c.PowderToOrder ?? 0m); foreach (var coat in coatsForPowder) { var share = totalEstimated > 0 ? totalActualLbs * ((coat.PowderToOrder ?? 0m) / totalEstimated) : totalActualLbs / coatsForPowder.Count; coat.ActualPowderUsedLbs = Math.Round(share, 4); await _unitOfWork.JobItemCoats.UpdateAsync(coat); } } // Single inventory deduction for the whole powder, net of pre-logged credit var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m); var deductNow = Math.Max(0m, totalActualLbs - credit); preLoggedCredit[invItemId] = 0m; if (deductNow > 0) { var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId); if (inventoryItem != null) { inventoryItem.QuantityOnHand -= deductNow; await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem); var transaction = new InventoryTransaction { InventoryItemId = inventoryItem.Id, TransactionType = InventoryTransactionType.JobUsage, Quantity = -deductNow, UnitCost = inventoryItem.UnitCost, TotalCost = inventoryItem.UnitCost * deductNow, TransactionDate = DateTime.UtcNow, JobId = job.Id, Reference = job.JobNumber, Notes = $"Powder used for Job {job.JobNumber} by {currentUser!.FirstName} {currentUser.LastName}", BalanceAfter = inventoryItem.QuantityOnHand, CompanyId = job.CompanyId }; await _unitOfWork.InventoryTransactions.AddAsync(transaction); if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue) { var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost); await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost); await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost); } _logger.LogInformation( "Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}", deductNow, 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, IsAiItem = ji.IsAiItem, IsCustomFormulaItem = ji.IsCustomFormulaItem, CustomItemTemplateId = ji.CustomItemTemplateId, FormulaFieldValuesJson = ji.FormulaFieldValuesJson, 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, NoExtraLayerCharge = c.NoExtraLayerCharge, 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 = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m), OvenCostId = job.OvenCostId, OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1, OvenCycleMinutes = job.OvenCycleMinutes, 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 = await GetEffectiveTaxPercentAsync(job.CustomerId, 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 createdAtUtc = DateTime.UtcNow; var jobItem = _jobItemAssemblyService.CreateJobItem(itemDto, job.Id, currentUser.CompanyId, itemPricing, createdAtUtc); await _unitOfWork.JobItems.AddAsync(jobItem); await _unitOfWork.SaveChangesAsync(); foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(itemDto, jobItem.Id, currentUser.CompanyId, createdAtUtc)) { await _unitOfWork.JobItemCoats.AddAsync(coat); } foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(itemDto, jobItem.Id, currentUser.CompanyId, createdAtUtc)) { await _unitOfWork.JobItemPrepServices.AddAsync(prepService); } } // Option B: auto-add Custom Powder Order item on first save if not already present var allUpdateItems = model.JobItems.ToList(); if (!allUpdateItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true)) { var powderDto = await BuildCustomPowderOrderDto(allUpdateItems); if (powderDto != null) { var pp = new QuoteItemPricingResult { UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value, ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value, LaborCost = 0, EquipmentCost = 0 }; var pi = _jobItemAssemblyService.CreateJobItem(powderDto, job.Id, currentUser.CompanyId, pp, DateTime.UtcNow); await _unitOfWork.JobItems.AddAsync(pi); await _unitOfWork.SaveChangesAsync(); allUpdateItems.Add(powderDto); } } // Calculate full total (overhead, margins, tax) matching what Details shows decimal? ovenRateOverride = null; if (job.OvenCostId.HasValue) { var oven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value); if (oven != null && oven.CompanyId == currentUser.CompanyId) ovenRateOverride = oven.CostPerHour; } var updateCosts = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId); var totals = await _pricingService.CalculateQuoteTotalsAsync( allUpdateItems, currentUser.CompanyId, job.CustomerId, await GetEffectiveTaxPercentAsync(job.CustomerId, updateCosts?.TaxPercent ?? 0m), job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob, ovenRateOverride, job.OvenBatches, job.OvenCycleMinutes); job.FinalPrice = totals.Total; job.OvenBatchCost = totals.OvenBatchCost; job.ShopSuppliesAmount = totals.ShopSuppliesAmount; job.ShopSuppliesPercent = totals.ShopSuppliesPercent; job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals)); 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 = await GetEffectiveTaxPercentAsync(job.CustomerId, 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, IsSalesItem = ji.IsSalesItem, IsAiItem = ji.IsAiItem, IsCustomFormulaItem = ji.IsCustomFormulaItem, CustomItemTemplateId = ji.CustomItemTemplateId, FormulaFieldValuesJson = ji.FormulaFieldValuesJson, ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null), IncludePrepCost = ji.IncludePrepCost, Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto { InventoryItemId = c.InventoryItemId, CoverageSqFtPerLb = c.CoverageSqFtPerLb, TransferEfficiency = c.TransferEfficiency, PowderCostPerLb = c.PowderCostPerLb, NoExtraLayerCharge = c.NoExtraLayerCharge }).ToList() }).ToList(); var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId); if (remainingDtos.Any()) { decimal? deleteOvenRate = null; if (job.OvenCostId.HasValue) { var deleteOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value); if (deleteOven != null && deleteOven.CompanyId == currentUser.CompanyId) deleteOvenRate = deleteOven.CostPerHour; } var totals = await _pricingService.CalculateQuoteTotalsAsync( remainingDtos, currentUser.CompanyId, job.CustomerId, await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m), job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob, deleteOvenRate, job.OvenBatches, job.OvenCycleMinutes); job.FinalPrice = totals.Total; job.OvenBatchCost = totals.OvenBatchCost; job.ShopSuppliesAmount = totals.ShopSuppliesAmount; job.ShopSuppliesPercent = totals.ShopSuppliesPercent; job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals)); } else { job.FinalPrice = 0; job.OvenBatchCost = 0; job.ShopSuppliesAmount = 0; job.ShopSuppliesPercent = 0; job.PricingBreakdownJson = null; } 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.FindAsync(i => i.CompanyId == companyId, false, i => i.InventoryCategory); ViewBag.InventoryCoatings = inventory .Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating) .OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name) .Select(i => new { value = i.Id.ToString(), text = i.IsIncoming ? $"[INCOMING] {i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)" : $"{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 ?? "", isIncoming = i.IsIncoming }).ToList(); var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, 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.FindAsync(i => i.CompanyId == companyId, 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 && ps.CompanyId == companyId); ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList(); var blastSetupsForEditItems = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId); 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); var allowFormulas2 = HttpContext.Items["AllowCustomFormulas"] as bool? ?? false; if (allowFormulas2) { var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId && t.IsActive); ViewBag.CustomFormulaTemplates = formulaTemplates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name) .Select(t => new { id = t.Id, name = t.Name, description = t.Description, outputMode = t.OutputMode, fieldsJson = t.FieldsJson, formula = t.Formula, defaultRate = t.DefaultRate, rateLabel = t.RateLabel, diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath) ? null : Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id }) }).ToList(); } else { ViewBag.CustomFormulaTemplates = new List(); } } /// /// 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}"; } /// /// Converts a into the DTO used for both display and JSON snapshot storage. /// All save paths (Create, Edit, UpdateItems, DeleteJobItem) call this so the snapshot is always consistent. /// /// /// Returns the effective tax rate for a job, respecting customer tax-exempt status. /// Always call this instead of using costs.TaxPercent directly so tax-exempt customers /// are never charged tax when a job is saved or recalculated. /// private async Task GetEffectiveTaxPercentAsync(int? customerId, decimal companyDefaultRate) { if (customerId is > 0) { var customer = await _unitOfWork.Customers.GetByIdAsync(customerId.Value); if (customer?.IsTaxExempt == true) return 0m; } return companyDefaultRate; } /// /// Builds a "Custom Powder Order" DTO by aggregating all powder-to-order costs across the /// submitted items. Returns null when no qualifying coats are present. /// /// Two coat types qualify: /// - Custom powder: no InventoryItemId, PowderToOrder > 0, PowderCostPerLb > 0 /// - Incoming powder: InventoryItemId set, inventoryItem.IsIncoming == true, PowderToOrder > 0 /// (PowderCostPerLb is null for incoming powder — cost comes from inventoryItem.UnitCost) /// private async Task BuildCustomPowderOrderDto(IEnumerable itemDtos) { var colorNames = new List(); decimal totalCost = 0m; foreach (var dto in itemDtos) { if (dto.Coats == null) continue; foreach (var coat in dto.Coats) { if (!coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0) { // Custom powder: no inventory link, user entered cost per lb manually totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value; if (!string.IsNullOrWhiteSpace(coat.ColorName)) colorNames.Add(coat.ColorName); } else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0) { // Incoming powder: catalog-selected; PowderCostPerLb was cleared after incoming // inventory item was created, so cost comes from inventoryItem.UnitCost var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value); if (invItem?.IsIncoming == true) { totalCost += coat.PowderToOrder.Value * invItem.UnitCost; var colorName = !string.IsNullOrWhiteSpace(coat.ColorName) ? coat.ColorName : invItem.Name; if (!string.IsNullOrWhiteSpace(colorName)) colorNames.Add(colorName); } } } } if (totalCost <= 0) return null; var uniqueColors = colorNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var description = uniqueColors.Any() ? $"Custom Powder Order ({string.Join(", ", uniqueColors)})" : "Custom Powder Order"; return new CreateQuoteItemDto { Description = description, Quantity = 1, IsGenericItem = true, ManualUnitPrice = totalCost }; } private static QuotePricingBreakdownDto BuildPricingSnapshotDto(QuotePricingResult pr) => new QuotePricingBreakdownDto { MaterialCosts = pr.MaterialCosts, LaborCosts = pr.LaborCosts, EquipmentCosts = pr.EquipmentCosts, ItemsSubtotal = pr.ItemsSubtotal, OvenBatchCost = pr.OvenBatchCost, OvenBatches = pr.OvenBatches, OvenCycleMinutes = pr.OvenCycleMinutes, FacilityOverheadCost = pr.FacilityOverheadCost, FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour, ShopSuppliesAmount = pr.ShopSuppliesAmount, ShopSuppliesPercent = pr.ShopSuppliesPercent, OverheadCosts = pr.OverheadCosts, OverheadPercent = pr.OverheadPercent, ProfitMargin = pr.ProfitMargin, ProfitPercent = pr.ProfitPercent, SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount, PricingTierDiscountAmount = pr.PricingTierDiscountAmount, PricingTierDiscountPercent = pr.PricingTierDiscountPercent, QuoteDiscountAmount = pr.QuoteDiscountAmount, QuoteDiscountPercent = pr.QuoteDiscountPercent, DiscountAmount = pr.DiscountAmount, DiscountPercent = pr.DiscountPercent, SubtotalAfterDiscount = pr.SubtotalAfterDiscount, RushFee = pr.RushFee, TaxAmount = pr.TaxAmount, TaxPercent = pr.TaxPercent, Total = pr.Total }; #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); 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. Optionally creates a linked rework job so the /// repair can flow through the full shop lifecycle. When creating a rework job: /// - Job number uses sub-number format: {parentNumber}-R{n} (e.g. JOB-2605-0007-R1) /// - Only items selected by the user are copied (partial rework support) /// - Pricing obeys the ReworkPricingType: ShopFault zeros all item prices; /// CustomerReduced/CustomerFull copy prices as-is (user edits after if needed) /// - Job starts at the first non-Pending status in the company's workflow /// [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; Job? reworkJob = null; if (dto.CreateReworkJob && dto.ReworkJobItemIds != null && dto.ReworkJobItemIds.Count > 0 && dto.ReworkPricingType.HasValue) { var typeLabel = dto.ReworkType switch { ReworkType.InternalDefect => "Internal Defect", ReworkType.CustomerWarranty => "Customer Warranty", ReworkType.CustomerDamage => "Customer Damage", _ => dto.ReworkType.ToString() }; var reasonLabel = dto.Reason switch { ReworkReason.AdhesionFailure => "Adhesion Failure", ReworkReason.Contamination => "Contamination", ReworkReason.ColorMismatch => "Color Mismatch", ReworkReason.RunsSags => "Runs / Sags", ReworkReason.SurfacePrepFailure => "Surface Prep Failure", ReworkReason.OvenIssue => "Oven Issue", ReworkReason.InsufficientCoverage => "Insufficient Coverage", ReworkReason.HandlingDamage => "Handling Damage", _ => "Other" }; var pricingLabel = dto.ReworkPricingType.Value switch { ReworkPricingType.ShopFault => "Shop Fault — no charge", ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate", ReworkPricingType.CustomerFull => "Customer responsible — full price", _ => "" }; var defect = string.IsNullOrWhiteSpace(dto.DefectDescription) ? "" : $": {dto.DefectDescription}"; var reworkDescription = $"REWORK ({typeLabel} / {reasonLabel}){defect}. Pricing: {pricingLabel}."; var currentUserId = _userManager.GetUserId(User); reworkJob = await BuildReworkJobAsync(job, dto.ReworkJobItemIds, dto.ReworkPricingType.Value, companyId, reworkDescription, currentUserId); } 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, ReworkPricingType = dto.ReworkPricingType, ReworkJobId = reworkJob?.Id, Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open, CompanyId = companyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.ReworkRecords.AddAsync(record); await _unitOfWork.CompleteAsync(); var saved = await _unitOfWork.ReworkRecords.FindAsync(r => r.Id == record.Id, false, r => r.JobItem, r => r.ReworkJob); return Json(_mapper.Map(saved.First())); } /// /// Creates a linked rework Job from an existing rework record that was saved without one. /// Uses sub-number format and applies the specified pricing attribution. /// [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 = await _unitOfWork.Jobs.LoadForDetailsAsync(reworkRecord.JobId); if (originalJob == null) return NotFound(); var companyId = originalJob.CompanyId; var itemIds = req.ItemIds ?? originalJob.JobItems.Select(i => i.Id).ToList(); var pricingType = req.ReworkPricingType ?? ReworkPricingType.ShopFault; var pricingLabel = pricingType switch { ReworkPricingType.ShopFault => "Shop Fault — no charge", ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate", ReworkPricingType.CustomerFull => "Customer responsible — full price", _ => "" }; var notes = string.IsNullOrWhiteSpace(req.Notes) ? "" : $" Notes: {req.Notes}"; var reworkDescription = $"REWORK: {pricingLabel}.{notes}"; var currentUserId = _userManager.GetUserId(User); var reworkJob = await BuildReworkJobAsync(originalJob, itemIds, pricingType, companyId, reworkDescription, currentUserId); reworkRecord.ReworkJobId = reworkJob.Id; reworkRecord.ReworkPricingType = pricingType; 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 }); } /// /// Shared helper that creates and persists a rework Job with sub-numbered job number, /// copies the specified items (with coats and prep services), applies pricing attribution, /// sets descriptive job description from the rework record data, and auto-records intake /// (parts are already on hand when rework is logged). /// Called by both AddReworkRecord and CreateReworkJob. /// private async Task BuildReworkJobAsync( Job originalJob, List itemIds, ReworkPricingType pricingType, int companyId, string reworkDescription, string? checkedByUserId) { var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId); var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId); // First non-Pending status by workflow order var firstActiveStatus = statuses .Where(s => s.StatusCode != AppConstants.StatusCodes.Job.Pending) .OrderBy(s => s.DisplayOrder) .First(); var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First(); // Sub-number: {parentJobNumber}-R{n+1} var reworkCount = await _unitOfWork.Jobs.GetReworkJobCountAsync(originalJob.Id); var reworkNumber = $"{originalJob.JobNumber}-R{reworkCount + 1}"; var reworkJob = new Job { JobNumber = reworkNumber, CustomerId = originalJob.CustomerId, Description = reworkDescription, JobStatusId = firstActiveStatus.Id, JobPriorityId = normalPriority.Id, IsReworkJob = true, OriginalJobId = originalJob.Id, SpecialInstructions = $"Rework of {originalJob.JobNumber}.", // Auto-intake: parts are already on hand when rework is logged IntakeDate = DateTime.UtcNow, IntakeConditionNotes = $"Parts auto-checked in as rework from {originalJob.JobNumber}.", IntakeCheckedByUserId = checkedByUserId, CompanyId = companyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.Jobs.AddAsync(reworkJob); await _unitOfWork.CompleteAsync(); var itemsToCopy = originalJob.JobItems.Where(i => itemIds.Contains(i.Id)).ToList(); var createdAtUtc = DateTime.UtcNow; foreach (var item in itemsToCopy) { var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc); // Shop-fault rework jobs are done at no charge if (pricingType == ReworkPricingType.ShopFault) { newItem.UnitPrice = 0; newItem.ManualUnitPrice = 0; newItem.TotalPrice = 0; } await _unitOfWork.JobItems.AddAsync(newItem); await _unitOfWork.CompleteAsync(); foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc)) await _unitOfWork.JobItemCoats.AddAsync(coat); foreach (var prep in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc)) await _unitOfWork.JobItemPrepServices.AddAsync(prep); } // Set intake part count now that items are known reworkJob.IntakePartCount = (int)Math.Ceiling(itemsToCopy.Sum(i => i.Quantity)); // Write a pricing snapshot so the Details page and inline edit both work correctly var itemsSubtotal = pricingType == ReworkPricingType.ShopFault ? 0m : itemsToCopy.Sum(i => i.TotalPrice); reworkJob.FinalPrice = itemsSubtotal; reworkJob.PricingBreakdownJson = System.Text.Json.JsonSerializer.Serialize(new QuotePricingBreakdownDto { ItemsSubtotal = itemsSubtotal, SubtotalBeforeDiscount = itemsSubtotal, SubtotalAfterDiscount = itemsSubtotal, Total = itemsSubtotal }); await _unitOfWork.Jobs.UpdateAsync(reworkJob); await _unitOfWork.CompleteAsync(); return reworkJob; } /// /// 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 }); } // ── 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) { AppConstants.StatusCodes.Job.Pending, AppConstants.StatusCodes.Job.Quoted, AppConstants.StatusCodes.Job.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 createdAtUtc = DateTime.UtcNow; var jobItem = _jobItemAssemblyService.CreateJobItem(quoteItem, id, job.CompanyId, createdAtUtc); await _unitOfWork.JobItems.AddAsync(jobItem); await _unitOfWork.SaveChangesAsync(); foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(quoteItem, jobItem.Id, job.CompanyId, createdAtUtc)) { await _unitOfWork.JobItemCoats.AddAsync(coat); } foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(quoteItem, jobItem.Id, job.CompanyId, createdAtUtc)) { await _unitOfWork.JobItemPrepServices.AddAsync(prepService); } } await _unitOfWork.SaveChangesAsync(); // Aggregate prep services from the fully-loaded quote items and copy to job foreach (var prepServiceId in fullItems .SelectMany(qi => qi.PrepServices) .Where(ps => !ps.IsDeleted) .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.ShopSuppliesAmount = quote.ShopSuppliesAmount; job.ShopSuppliesPercent = quote.ShopSuppliesPercent; 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 effectiveOvenMinutes = (opCosts?.DefaultOvenCycleMinutes > 0 ? (int?)opCosts!.DefaultOvenCycleMinutes : null) ?? 45; var defaultOvenCycleHours = effectiveOvenMinutes / 60.0m; // Labor cost rate priority: per-user LaborCostPerHour → company LaborCostPerHour → 20% of StandardLaborRate var companyLaborCostRate = opCosts?.LaborCostPerHour ?? ((opCosts?.StandardLaborRate ?? 0m) * 0.20m); var companyUsers = await _userManager.Users .Where(u => u.CompanyId == companyId && u.LaborCostPerHour != null) .Select(u => new { u.Id, u.LaborCostPerHour }) .ToListAsync(); var userLaborCostMap = companyUsers.ToDictionary(u => u.Id, u => u.LaborCostPerHour!.Value); // 1. Powder / Material cost // Priority: PowderUsageLog actuals (sum per coat) > coat.ActualPowderUsedLbs > coat.PowderToOrder (estimated) var usageLogs = await _unitOfWork.PowderUsageLogs.FindAsync(u => u.JobId == jobId); var actualByCoat = usageLogs .GroupBy(u => u.JobItemCoatId) .ToDictionary(g => g.Key, g => g.Sum(u => u.ActualLbsUsed)); decimal powderCost = 0m; var powderLines = new List(); bool hasCoatsWithRateButNoQty = false; foreach (var item in job.JobItems) { foreach (var coat in item.Coats) { bool isActual; decimal lbs; if (actualByCoat.TryGetValue(coat.Id, out var loggedLbs) && loggedLbs > 0) { lbs = loggedLbs; isActual = true; } else { lbs = coat.ActualPowderUsedLbs ?? coat.PowderToOrder ?? 0m; isActual = coat.ActualPowderUsedLbs.HasValue; } 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 }); } else if (costPerLb > 0 && lbs == 0) { // Coat has a price/lb but no quantity — surface area missing on the item hasCoatsWithRateButNoQty = true; } } } // 2. Labor cost // Priority: per-user LaborCostPerHour → company LaborCostPerHour → 20% of StandardLaborRate decimal laborCost = 0m; var laborLines = new List(); foreach (var entry in job.TimeEntries) { bool usingPerUser = entry.UserId != null && userLaborCostMap.TryGetValue(entry.UserId, out _); var rate = usingPerUser ? userLaborCostMap[entry.UserId!] : companyLaborCostRate; var lineCost = entry.HoursWorked * rate; laborCost += lineCost; laborLines.Add(new { worker = entry.UserDisplayName ?? "Unknown", hours = entry.HoursWorked, rate = Math.Round(rate, 2), total = Math.Round(lineCost, 2), usingFallback = !usingPerUser, 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 — prefer FinalPrice (reflects inline edits and job-level changes); // fall back to Invoice.Total only when FinalPrice is zero (voided/zeroed job). decimal revenue = job.FinalPrice > 0 ? job.FinalPrice : (job.Invoice?.Total ?? 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.FinalPrice > 0 ? "Final Price" : (job.Invoice != null ? "Invoice" : "Quoted Price"), powderCost = Math.Round(powderCost, 2), laborCost = Math.Round(laborCost, 2), ovenCost = Math.Round(ovenCost, 2), ovenLabel, ovenCycleMinutes = effectiveOvenMinutes, 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), companyLaborCostRate, powderLines, laborLines, hasPowderData = powderLines.Count > 0, hasPowderRateButNoQty = hasCoatsWithRateButNoQty && 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); } // LogMaterial has been consolidated into InventoryController.LogMaterial. /// /// Inline-edits description, quantity, and unit price on a single job line item. /// Adjusts FinalPrice and the stored PricingBreakdownJson snapshot by the price delta. /// Returns updated totals so the page can reflect the change without a reload. /// [HttpPost] [ValidateAntiForgeryToken] public async Task PatchItem([FromBody] PatchJobItemRequest request) { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); var item = await _unitOfWork.JobItems.GetByIdAsync(request.ItemId); if (item == null) return NotFound(); var job = await _unitOfWork.Jobs.GetByIdAsync(item.JobId); if (job == null || job.CompanyId != currentUser.CompanyId) return NotFound(); var oldTotal = item.TotalPrice; item.Description = request.Description.Trim(); item.Quantity = request.Quantity; item.UnitPrice = request.UnitPrice; item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2); await _unitOfWork.JobItems.UpdateAsync(item); var delta = item.TotalPrice - oldTotal; job.FinalPrice = Math.Round(job.FinalPrice + delta, 2); // Keep the stored pricing snapshot in sync so the breakdown panel stays consistent. // Case-insensitive options handle JSON stored before PascalCase serialization was enforced. QuotePricingBreakdownDto? pbFinal = null; var jsonOpts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; if (!string.IsNullOrEmpty(job.PricingBreakdownJson)) { var pb = JsonSerializer.Deserialize(job.PricingBreakdownJson, jsonOpts); if (pb != null) { pb.ItemsSubtotal += delta; pb.SubtotalBeforeDiscount += delta; pb.SubtotalAfterDiscount = pb.SubtotalBeforeDiscount - pb.DiscountAmount; pb.TaxAmount = Math.Round(pb.SubtotalAfterDiscount * pb.TaxPercent / 100m, 2); pb.Total = Math.Round(pb.SubtotalAfterDiscount + pb.RushFee + pb.TaxAmount, 2); job.FinalPrice = pb.Total; job.PricingBreakdownJson = JsonSerializer.Serialize(pb); pbFinal = pb; } } await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.CompleteAsync(); // For legacy jobs without a stored snapshot, derive breakdown from live item totals. if (pbFinal == null) { var allItems = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == job.Id && !ji.IsDeleted); var itemsSubtotal = allItems.Sum(ji => ji.TotalPrice); var subtotal = itemsSubtotal + job.OvenBatchCost + job.ShopSuppliesAmount; pbFinal = new QuotePricingBreakdownDto { ItemsSubtotal = itemsSubtotal, SubtotalBeforeDiscount = subtotal, SubtotalAfterDiscount = subtotal, Total = job.FinalPrice }; } return Json(new { lineTotal = item.TotalPrice, finalPrice = job.FinalPrice, itemsSubtotal = pbFinal.ItemsSubtotal, subtotalBeforeDiscount = pbFinal.SubtotalBeforeDiscount, subtotalAfterDiscount = pbFinal.SubtotalAfterDiscount, taxAmount = pbFinal.TaxAmount }); } } public class DeleteTimeEntryRequest { public int Id { get; set; } } public class PatchJobItemRequest { public int ItemId { get; set; } public string Description { get; set; } = string.Empty; public decimal Quantity { get; set; } public decimal UnitPrice { get; set; } } public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public List? ItemIds { get; set; } public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { 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; } }