Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/JobsController.cs
T
spouliot df504674e9 Add oven/batch settings to job create and edit forms
CreateJobDto and UpdateJobDto now carry OvenCostId, OvenBatches, and
OvenCycleMinutes. The Create POST sets these on the new Job entity and
passes them to the pricing engine; the Edit GET populates them from the
existing job so the form reflects saved values, and the Edit POST writes
them back before repricing.

Both Jobs/Create.cshtml and Jobs/Edit.cshtml now include an Oven & Batch
Settings card (matching the quote form) with oven selector, batch count,
and cycle time inputs. The wizard init block now passes the selected
OvenCostId instead of null so live auto-pricing reflects the oven cost.

ViewBag.DefaultOvenCycleMinutes added to PopulateCreateEditWizardViewBagsAsync
so the placeholder in both views shows the company default.

Also fixed: NoExtraLayerCharge was missing from the Edit GET coat DTO
mapping (would have caused the flag to reset to false on next edit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:27:54 -04:00

4274 lines
195 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ApplicationUser> _userManager;
private readonly ILogger<JobsController> _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<NotificationHub> _hub;
private readonly IHubContext<ShopHub> _shopHub;
private readonly IAccountBalanceService _accountBalanceService;
public JobsController(
IUnitOfWork unitOfWork,
IMapper mapper,
IJobPhotoService jobPhotoService,
UserManager<ApplicationUser> userManager,
ILogger<JobsController> logger,
ITenantContext tenantContext,
IMeasurementConversionService measurementService,
ILookupCacheService lookupCache,
INotificationService notificationService,
ISubscriptionService subscriptionService,
IPricingCalculationService pricingService,
IJobItemAssemblyService jobItemAssemblyService,
IHubContext<NotificationHub> hub,
IHubContext<ShopHub> 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;
}
/// <summary>
/// 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.
/// </summary>
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
});
}
/// <summary>
/// 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).
/// </summary>
public async Task<IActionResult> Index(
string? searchTerm,
string? statusGroup,
string? tagFilter,
string? sortColumn,
string sortDirection = "asc",
int pageNumber = 1,
int pageSize = 25)
{
try
{
// Create and validate grid request
var gridRequest = new GridRequest
{
PageNumber = pageNumber,
PageSize = pageSize,
SortColumn = sortColumn ?? "CreatedAt",
SortDirection = sortColumn == null ? "desc" : sortDirection, // Default to desc for CreatedAt
SearchTerm = searchTerm
};
gridRequest.Validate();
// Build search filter
System.Linq.Expressions.Expression<Func<Job, bool>>? 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 (!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<IQueryable<Job>, IOrderedQueryable<Job>> 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<List<JobListDto>>(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<JobListDto>.From(
gridRequest, jobDtos,
string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count);
// Set ViewBag for sorting
ViewBag.SearchTerm = searchTerm;
ViewBag.StatusGroup = statusGroup;
ViewBag.TagFilter = tagFilter;
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection;
// Populate workers for quick assignment modal
await PopulateWorkersDropdown();
var indexUser = await _userManager.GetUserAsync(User);
if (indexUser != null)
await PopulateEmailNotificationDefaultsAsync(indexUser.CompanyId);
return View(pagedResult);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving jobs");
TempData["Error"] = "An error occurred while loading jobs.";
return View(new PagedResult<JobListDto>());
}
}
/// <summary>
/// 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 <paramref name="showTerminal"/> 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.
/// </summary>
public async Task<IActionResult> 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);
}
/// <summary>
/// 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.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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
});
}
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> 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<JobDto>(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<List<JobChangeHistoryDto>>(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<dynamic>();
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<QuotePricingBreakdownDto>(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,
sku = ji.Sku,
requiresSandblasting = ji.RequiresSandblasting,
requiresMasking = ji.RequiresMasking,
notes = ji.Notes,
includePrepCost = ji.IncludePrepCost,
complexity = ji.Complexity,
coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new {
coatName = c.CoatName,
sequence = c.Sequence,
inventoryItemId = c.InventoryItemId,
colorName = c.ColorName,
vendorId = c.VendorId,
colorCode = c.ColorCode,
finish = c.Finish,
coverageSqFtPerLb = c.CoverageSqFtPerLb,
transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder,
notes = c.Notes
}),
prepServices = ji.PrepServices.Select(ps => new {
prepServiceId = ps.PrepServiceId,
estimatedMinutes = ps.EstimatedMinutes
})
}).ToList();
// Load deposits for this job (and any linked quote)
var jobDeposits = (await _unitOfWork.Deposits.FindAsync(
d => d.JobId == id.Value || (job.QuoteId.HasValue && d.QuoteId == job.QuoteId.Value),
false, d => d.RecordedBy))
.OrderByDescending(d => d.ReceivedDate).ToList();
ViewBag.Deposits = jobDeposits;
// Materials used on this job via QR scan or manual log
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<PowderCoating.Application.DTOs.Job.JobItemDto>())
.SelectMany(i => i.Coats ?? new List<PowderCoating.Application.DTOs.Job.JobItemCoatDto>())
.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<string>(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));
}
}
/// <summary>
/// Reassigns a job to a different customer.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 ────────────────────────────────────────────
/// <summary>
/// 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 <c>GetByIdAsync</c>.
/// </summary>
public async Task<IActionResult> 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();
}
/// <summary>
/// Processes a QR-code status bump from the shop floor. Requires authentication.
/// Records the authenticated user's name in status history.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 });
}
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> 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<JobDto>(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<PowderQrCodeInfo>();
var seenInventoryIds = new HashSet<int>();
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 });
}
}
/// <summary>
/// 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).
/// </summary>
[Authorize(Policy = "CanManageJobs")]
public async Task<IActionResult> 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<JobDto>(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));
}
/// <summary>
/// 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.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = "CanManageJobs")]
public async Task<IActionResult> 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<IActionResult> 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 });
}
/// <summary>
/// Renders the job creation wizard, checking the subscription active-job limit before rendering.
/// If <paramref name="templateId"/> is provided, the wizard is pre-populated from a Job Template
/// (pre-configured job types with standard items). If <paramref name="customerId"/> is provided,
/// the customer dropdown is pre-selected. The wizard is the same multi-step UI as the quote wizard.
/// </summary>
public async Task<IActionResult> 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<int, string> 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);
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<PowderCoating.Core.Enums.DiscountType>(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);
}
}
// 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(
dto.JobItems, 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);
}
}
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> 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,
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
/// <summary>
/// Saves edits to a job's core fields (customer, description, dates, priority, etc.).
/// Does NOT replace line items — items are managed separately via <see cref="EditItems"/>
/// and <see cref="UpdateItems"/> to keep the item-editing UX focused and avoid losing
/// in-progress work if the header edit form is submitted independently.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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);
}
}
// 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<PowderCoating.Core.Enums.DiscountType>(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<JobChangeHistory>();
if (oldJobValues.JobStatusId != job.JobStatusId)
{
var oldStatus = await _unitOfWork.JobStatusLookups.GetByIdAsync(oldJobValues.JobStatusId);
var newStatusLookup = await _unitOfWork.JobStatusLookups.GetByIdAsync(job.JobStatusId);
changeHistories.Add(new JobChangeHistory
{
JobId = job.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Status",
OldValue = oldStatus?.DisplayName ?? oldJobValues.JobStatusId.ToString(),
NewValue = newStatusLookup?.DisplayName ?? job.JobStatusId.ToString(),
ChangeDescription = $"Status changed from {oldStatus?.DisplayName} to {newStatusLookup?.DisplayName}",
CompanyId = currentUser.CompanyId
});
}
if (oldJobValues.JobPriorityId != job.JobPriorityId)
{
var oldPriority = await _unitOfWork.JobPriorityLookups.GetByIdAsync(oldJobValues.JobPriorityId);
var newPriority = await _unitOfWork.JobPriorityLookups.GetByIdAsync(job.JobPriorityId);
changeHistories.Add(new JobChangeHistory
{
JobId = job.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Priority",
OldValue = oldPriority?.DisplayName ?? oldJobValues.JobPriorityId.ToString(),
NewValue = newPriority?.DisplayName ?? job.JobPriorityId.ToString(),
ChangeDescription = $"Priority changed from {oldPriority?.DisplayName} to {newPriority?.DisplayName}",
CompanyId = currentUser.CompanyId
});
}
if (oldJobValues.AssignedUserId != job.AssignedUserId)
{
var oldUser = !string.IsNullOrEmpty(oldJobValues.AssignedUserId)
? await _userManager.FindByIdAsync(oldJobValues.AssignedUserId)
: null;
var newUser = !string.IsNullOrEmpty(job.AssignedUserId)
? await _userManager.FindByIdAsync(job.AssignedUserId)
: null;
changeHistories.Add(new JobChangeHistory
{
JobId = job.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Assigned Worker",
OldValue = oldUser?.FullName ?? "Unassigned",
NewValue = newUser?.FullName ?? "Unassigned",
ChangeDescription = $"Worker changed from {oldUser?.FullName ?? "Unassigned"} to {newUser?.FullName ?? "Unassigned"}",
CompanyId = currentUser.CompanyId
});
}
if (oldJobValues.Description != job.Description)
{
changeHistories.Add(new JobChangeHistory
{
JobId = job.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Description",
OldValue = oldJobValues.Description ?? "None",
NewValue = job.Description ?? "None",
ChangeDescription = "Description updated",
CompanyId = currentUser.CompanyId
});
}
if (oldJobValues.ScheduledDate != job.ScheduledDate)
{
changeHistories.Add(new JobChangeHistory
{
JobId = job.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Scheduled Date",
OldValue = oldJobValues.ScheduledDate?.ToString("MM/dd/yyyy") ?? "None",
NewValue = job.ScheduledDate?.ToString("MM/dd/yyyy") ?? "None",
ChangeDescription = $"Scheduled date changed from {oldJobValues.ScheduledDate?.ToString("MM/dd/yyyy") ?? "None"} to {job.ScheduledDate?.ToString("MM/dd/yyyy") ?? "None"}",
CompanyId = currentUser.CompanyId
});
}
if (oldJobValues.DueDate != job.DueDate)
{
changeHistories.Add(new JobChangeHistory
{
JobId = job.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Due Date",
OldValue = oldJobValues.DueDate?.ToString("MM/dd/yyyy") ?? "None",
NewValue = job.DueDate?.ToString("MM/dd/yyyy") ?? "None",
ChangeDescription = $"Due date changed from {oldJobValues.DueDate?.ToString("MM/dd/yyyy") ?? "None"} to {job.DueDate?.ToString("MM/dd/yyyy") ?? "None"}",
CompanyId = currentUser.CompanyId
});
}
if (Math.Round(oldJobValues.QuotedPrice, 2) != Math.Round(job.QuotedPrice, 2))
{
changeHistories.Add(new JobChangeHistory
{
JobId = job.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Quoted Price",
OldValue = oldJobValues.QuotedPrice.ToString("C"),
NewValue = job.QuotedPrice.ToString("C"),
ChangeDescription = $"Quoted price changed from {oldJobValues.QuotedPrice:C} to {job.QuotedPrice:C}",
CompanyId = currentUser.CompanyId
});
}
if (oldJobValues.CustomerPO != job.CustomerPO)
{
changeHistories.Add(new JobChangeHistory
{
JobId = job.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Customer PO",
OldValue = oldJobValues.CustomerPO ?? "None",
NewValue = job.CustomerPO ?? "None",
ChangeDescription = $"Customer PO changed from '{oldJobValues.CustomerPO ?? "None"}' to '{job.CustomerPO ?? "None"}'",
CompanyId = currentUser.CompanyId
});
}
if (oldJobValues.SpecialInstructions != job.SpecialInstructions)
{
changeHistories.Add(new JobChangeHistory
{
JobId = job.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Special Instructions",
OldValue = string.IsNullOrEmpty(oldJobValues.SpecialInstructions) ? "None" : (oldJobValues.SpecialInstructions.Length > 50 ? oldJobValues.SpecialInstructions.Substring(0, 50) + "..." : oldJobValues.SpecialInstructions),
NewValue = string.IsNullOrEmpty(job.SpecialInstructions) ? "None" : (job.SpecialInstructions.Length > 50 ? job.SpecialInstructions.Substring(0, 50) + "..." : job.SpecialInstructions),
ChangeDescription = "Special instructions updated",
CompanyId = currentUser.CompanyId
});
}
if (oldJobValues.RequiresCustomerApproval != job.RequiresCustomerApproval)
{
changeHistories.Add(new JobChangeHistory
{
JobId = job.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Requires Customer Approval",
OldValue = oldJobValues.RequiresCustomerApproval.ToString(),
NewValue = job.RequiresCustomerApproval.ToString(),
ChangeDescription = $"Customer approval requirement changed to {job.RequiresCustomerApproval}",
CompanyId = currentUser.CompanyId
});
}
// Track item changes (simple entry — items are rebuilt via wizard)
if (itemsChanged)
{
changeHistories.Add(new JobChangeHistory
{
JobId = job.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Job Items",
OldValue = "Previous items",
NewValue = $"{dto.JobItems.Count} item(s)",
ChangeDescription = "Job items updated",
CompanyId = currentUser.CompanyId
});
}
// Recalculate FinalPrice from wizard items
if (dto.JobItems.Any())
{
var editCosts = await _pricingService.GetOperatingCostsAsync(companyId);
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(
dto.JobItems, 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);
}
}
/// <summary>
/// Shows the job delete confirmation page. Loads the job summary so the user can
/// verify they're deleting the right job before confirming.
/// </summary>
public async Task<IActionResult> 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<JobDto>(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));
}
}
/// <summary>
/// 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.
/// </summary>
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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));
}
}
/// <summary>
/// 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
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private async Task PopulateCreateEditWizardViewBagsAsync(int companyId)
{
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId);
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);
}
/// <summary>
/// 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.
/// </summary>
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");
}
/// <summary>
/// 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 <see cref="GenerateJobNumberAsync"/> in QuotesController used
/// during quote-to-job conversion; both use the same format and logic.
/// </summary>
private async Task<string> 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";
}
/// <summary>
/// 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 <paramref name="userId"/> 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.
/// </summary>
public async Task<IActionResult> 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));
}
}
/// <summary>
/// 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 <paramref name="workerId"/> when provided so each
/// worker sees only their assigned jobs on their own device.
/// </summary>
public async Task<IActionResult> 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));
}
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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" });
}
}
/// <summary>
/// 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).
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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" });
}
}
/// <summary>
/// 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.
/// </summary>
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");
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// AJAX endpoint for inline worker reassignment on the Job Details page.
/// Loads the job, sets <see cref="Job.AssignedUserId"/>, 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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" });
}
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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" });
}
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateJobStatus([FromBody] UpdateJobStatusRequest request)
{
try
{
var job = await _unitOfWork.Jobs.GetByIdAsync(request.JobId, false, j => j.JobStatus);
if (job == null)
{
return Json(new { success = false, message = "Job not found" });
}
await RecordStatusChangeAsync(job, request.StatusId);
job.JobStatusId = request.StatusId;
job.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Jobs.UpdateAsync(job);
await _unitOfWork.SaveChangesAsync();
// Get the new status for return
var newStatus = await _unitOfWork.JobStatusLookups.GetByIdAsync(request.StatusId);
await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "StatusChanged",
$"Status → {newStatus?.DisplayName ?? "Unknown"}");
if (newStatus != null)
{
var updateStatusCompanyId = _tenantContext.GetCurrentCompanyId()?.ToString();
if (!string.IsNullOrEmpty(updateStatusCompanyId))
await _shopHub.Clients.Group($"shop-{updateStatusCompanyId}").SendAsync("JobStatusChanged", new
{
jobId = job.Id,
jobNumber = job.JobNumber,
statusDisplayName = newStatus.DisplayName,
statusColorClass = newStatus.ColorClass
});
}
// Notify customer on status change (only if user opted in)
if (request.SendEmail && newStatus != null)
{
try
{
var jobForNotify = await _unitOfWork.Jobs.GetByIdAsync(request.JobId, false, j => j.Customer);
if (jobForNotify != null)
await _notificationService.NotifyJobStatusChangedAsync(
jobForNotify, newStatus.StatusCode, newStatus.DisplayName ?? string.Empty);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Notification failed for job {Id}", request.JobId);
}
var statusNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(request.JobId);
this.SetNotificationResultToast(statusNotifLog);
}
return Json(new { success = true, status = newStatus?.DisplayName ?? "Unknown" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating status for job {JobId}", request.JobId);
return Json(new { success = false, message = "An error occurred while updating the status" });
}
}
private static string GetCustomerDisplayName(Core.Entities.Customer? customer)
{
if (customer == null) return "Unknown";
if (!customer.IsCommercial)
{
var contactName = $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
if (contactName.Length > 0) return contactName;
}
if (!string.IsNullOrWhiteSpace(customer.CompanyName)) return customer.CompanyName;
var name = $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
return name.Length > 0 ? name : "Unknown";
}
#region Job Photos
/// <summary>
/// Get job photo - serves the actual image file
/// </summary>
[HttpGet]
public async Task<IActionResult> 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();
}
}
/// <summary>
/// Get job photos for a job - returns JSON list
/// </summary>
[HttpGet]
public async Task<IActionResult> 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<JobPhotoDto>(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" });
}
}
/// <summary>
/// Upload job photo
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<JobPhotoDto>(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." });
}
}
/// <summary>
/// Update photo caption and type
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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." });
}
}
/// <summary>
/// Delete job photo
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<int> 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
/// <summary>
/// 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.
/// </summary>
[HttpGet]
public async Task<IActionResult> 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<JobDto>(job);
return PartialView("_CompleteJobModal", dto);
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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
/// <summary>
/// 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).
/// </summary>
[HttpGet]
public async Task<IActionResult> 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 });
}
/// <summary>
/// Sends a manually-composed SMS for a job. Validates and auto-appends STOP language,
/// sends via Twilio, and writes a NotificationLog entry.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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)
/// <summary>
/// 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
/// <see cref="CreateQuoteItemDto"/> format that item-wizard.js expects — this avoids
/// maintaining a separate DTO just for the edit case.
/// </summary>
public async Task<IActionResult> 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<JobItem>();
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,
RequiresSandblasting = ji.RequiresSandblasting,
RequiresMasking = ji.RequiresMasking,
Notes = ji.Notes,
IncludePrepCost = ji.IncludePrepCost,
Complexity = ji.Complexity,
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
{
CoatName = c.CoatName,
Sequence = c.Sequence,
InventoryItemId = c.InventoryItemId,
ColorName = c.ColorName,
VendorId = c.VendorId,
ColorCode = c.ColorCode,
Finish = c.Finish,
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder,
Notes = c.Notes
}).ToList(),
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
{
PrepServiceId = ps.PrepServiceId,
EstimatedMinutes = ps.EstimatedMinutes
}).ToList()
}).ToList();
var viewModel = new JobEditItemsViewModel
{
JobId = job.Id,
JobNumber = job.JobNumber,
CustomerId = job.CustomerId,
TaxPercent = 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);
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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);
}
}
// 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(
model.JobItems, 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);
}
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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,
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
}).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 });
}
/// <summary>
/// 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 <paramref name="fallbackOvenRate"/> is used when no named oven is selected.
/// </summary>
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<SelectListItem>
{
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);
}
/// <summary>
/// Builds the hierarchical display label for a catalog item in the job item wizard dropdown.
/// Mirrors <see cref="QuotesController.BuildCatalogItemDisplayText"/> — kept as a private
/// static here to avoid a cross-controller dependency.
/// </summary>
private static string BuildCatalogItemText(CatalogItem item)
{
var path = new List<string>();
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}";
}
/// <summary>
/// Converts a <see cref="QuotePricingResult"/> 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.
/// </summary>
/// <summary>
/// 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.
/// </summary>
private async Task<decimal> 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;
}
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)
/// <summary>
/// AJAX endpoint that calculates the full pricing breakdown for the job item wizard.
/// Identical contract to <see cref="QuotesController.CalculatePricing"/> so the same
/// item-wizard.js works for both quote and job creation without branching on the controller.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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
/// <summary>
/// Appends a <see cref="JobStatusHistory"/> 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.
/// </summary>
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 ──────────────────────────────────────────────────────────
/// <summary>
/// Returns all time entries for a job as JSON, ordered newest-first.
/// Used by the time tracking tab on the Job Details page.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetTimeEntries(int jobId)
{
var entries = await _unitOfWork.JobTimeEntries.FindAsync(
e => e.JobId == jobId, false);
var dtos = _mapper.Map<List<JobTimeEntryDto>>(entries.OrderByDescending(e => e.WorkDate).ToList());
return Json(dtos);
}
/// <summary>
/// Adds a time entry for a company user on a specific job.
/// Validates hours are in the reasonable range (0.124) before saving.
/// Returns the new entry as JSON so the UI can append it to the list without a reload.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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<JobTimeEntryDto>(entry));
}
/// <summary>Updates an existing time entry's hours, stage, and notes in place.</summary>
[HttpPost]
public async Task<IActionResult> 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<JobTimeEntryDto>(entry));
}
/// <summary>Soft-deletes a time entry. The hours are removed from the job's running total.</summary>
[HttpPost]
public async Task<IActionResult> 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 ────────────────────────────────────────────
/// <summary>Returns all rework records for a job as JSON, newest first.</summary>
[HttpGet]
public async Task<IActionResult> GetReworkRecords(int jobId)
{
var records = await _unitOfWork.ReworkRecords.FindAsync(
r => r.JobId == jobId, false,
r => r.JobItem,
r => r.ReworkJob);
var dtos = _mapper.Map<List<ReworkRecordDto>>(records.OrderByDescending(r => r.CreatedAt).ToList());
return Json(dtos);
}
/// <summary>
/// Records a rework event against a job item (e.g. defect found during QC).
/// Automatically creates a new linked rework Job so the repair work can be tracked
/// through the same job lifecycle. The rework job inherits the original job's customer,
/// oven, and items so the shop has a complete specification to work from.
/// </summary>
[HttpPost]
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
{
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(dto.JobId);
if (job == null) return NotFound();
var companyId = job.CompanyId;
// Generate rework job number
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
var year = DateTime.Now.ToString("yy");
var month = DateTime.Now.ToString("MM");
var prefix = $"JOB-{year}{month}-";
var maxNum = allJobs
.Where(j => j.JobNumber.StartsWith(prefix))
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
.DefaultIfEmpty(0).Max();
var reworkJob = pendingStatus != null ? new Job
{
JobNumber = $"{prefix}{(maxNum + 1):D4}",
CustomerId = job.CustomerId,
Description = $"REWORK: {job.Description}",
JobStatusId = pendingStatus.Id,
JobPriorityId = normalPriority.Id,
IsReworkJob = true,
OriginalJobId = job.Id,
SpecialInstructions = $"Rework of {job.JobNumber}.",
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
} : null;
if (reworkJob != null)
{
await _unitOfWork.Jobs.AddAsync(reworkJob);
await _unitOfWork.CompleteAsync();
// Copy items: specific item if flagged, otherwise all items
var itemsToCopy = dto.JobItemId.HasValue
? job.JobItems.Where(i => i.Id == dto.JobItemId.Value).ToList()
: job.JobItems.ToList();
foreach (var item in itemsToCopy)
{
var createdAtUtc = DateTime.UtcNow;
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
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 prepService in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
{
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
}
}
await _unitOfWork.CompleteAsync();
}
var record = new ReworkRecord
{
JobId = dto.JobId,
JobItemId = dto.JobItemId,
ReworkType = dto.ReworkType,
Reason = dto.Reason,
DefectDescription = dto.DefectDescription,
DiscoveredBy = dto.DiscoveredBy,
DiscoveredDate = dto.DiscoveredDate,
ReportedByName = dto.ReportedByName,
EstimatedReworkCost = dto.EstimatedReworkCost,
IsBillableToCustomer = dto.IsBillableToCustomer,
BillingNotes = dto.BillingNotes,
ReworkJobId = reworkJob?.Id,
Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.ReworkRecords.AddAsync(record);
await _unitOfWork.CompleteAsync();
// Reload with navigation for response
var saved = await _unitOfWork.ReworkRecords.FindAsync(r => r.Id == record.Id, false, r => r.JobItem, r => r.ReworkJob);
return Json(_mapper.Map<ReworkRecordDto>(saved.First()));
}
/// <summary>
/// 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).
/// </summary>
[HttpPost]
public async Task<IActionResult> 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<ReworkRecordDto>(updated.First()));
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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 });
}
/// <summary>
/// Creates a new rework Job from an existing rework record and links them.
/// The rework job is a lightweight clone of the original job — same customer, description, and
/// oven — but starts fresh with Pending status so it goes through the full workflow again.
/// The ReworkJob FK on the rework record is updated so the Detail view can link to it.
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateReworkJob([FromBody] CreateReworkJobRequest req)
{
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
if (reworkRecord == null) return NotFound();
var originalJob = reworkRecord.Job;
var companyId = originalJob.CompanyId;
// Load status lookups to find Pending status
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
if (pendingStatus == null) return Json(new { success = false, message = "Could not find Pending status." });
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
// Generate job number
var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
var year = DateTime.Now.ToString("yy");
var month = DateTime.Now.ToString("MM");
var prefix = $"JOB-{year}{month}-";
var maxNum = allJobs
.Where(j => j.JobNumber.StartsWith(prefix))
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
.DefaultIfEmpty(0).Max();
var reworkJob = new Job
{
JobNumber = $"{prefix}{(maxNum + 1):D4}",
CustomerId = originalJob.CustomerId,
Description = $"REWORK: {originalJob.Description}",
JobStatusId = pendingStatus.Id,
JobPriorityId = normalPriority.Id,
IsReworkJob = true,
OriginalJobId = originalJob.Id,
SpecialInstructions = $"Rework of {originalJob.JobNumber}. {req.Notes}".Trim().TrimEnd('.') + ".",
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.Jobs.AddAsync(reworkJob);
await _unitOfWork.CompleteAsync();
// Link rework record to new job
reworkRecord.ReworkJobId = reworkJob.Id;
reworkRecord.Status = ReworkStatus.InProgress;
reworkRecord.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
}
// ── Quote-Changed Banner Actions ──────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 });
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<string>(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 ─────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[HttpGet]
public async Task<IActionResult> 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<object>();
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<object>();
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
decimal revenue = job.Invoice != null
? job.Invoice.Total
: (job.FinalPrice > 0 ? job.FinalPrice : job.QuotedPrice);
// 5. Rework costs from linked rework jobs
var reworkRecords = await _unitOfWork.ReworkRecords.FindAsync(
r => r.JobId == jobId, false, r => r.ReworkJob);
decimal reworkCostTotal = 0m;
decimal reworkBilledToCustomer = 0m;
var reworkLines = new List<object>();
foreach (var rr in reworkRecords)
{
var cost = rr.ActualReworkCost > 0 ? rr.ActualReworkCost : rr.EstimatedReworkCost;
var billed = rr.IsBillableToCustomer ? cost : 0m;
reworkCostTotal += cost;
reworkBilledToCustomer += billed;
reworkLines.Add(new {
jobNumber = rr.ReworkJob?.JobNumber,
reason = System.Text.RegularExpressions.Regex.Replace(rr.Reason.ToString(), "([a-z])([A-Z])", "$1 $2"),
cost = Math.Round(cost, 2),
billedToCustomer = Math.Round(billed, 2),
isEstimate = rr.ActualReworkCost == 0,
status = rr.Status.ToString()
});
}
decimal netReworkCost = reworkCostTotal - reworkBilledToCustomer;
decimal totalCost = powderCost + laborCost + ovenCost;
decimal totalCostWithRework = totalCost + netReworkCost;
decimal grossProfit = revenue - totalCostWithRework;
decimal grossMargin = revenue > 0 ? Math.Round(grossProfit / revenue * 100, 1) : 0;
decimal quotedMargin = job.QuotedPrice > 0
? Math.Round((job.QuotedPrice - totalCostWithRework) / job.QuotedPrice * 100, 1)
: 0;
return Json(new {
revenue = Math.Round(revenue, 2),
revenueSource = job.Invoice != null ? "Invoice" : (job.FinalPrice > 0 ? "Final Price" : "Quoted Price"),
powderCost = Math.Round(powderCost, 2),
laborCost = Math.Round(laborCost, 2),
ovenCost = Math.Round(ovenCost, 2),
ovenLabel,
ovenCycleMinutes = 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<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
{
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
}
private async Task<Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel?> 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<bool> 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);
}
/// <summary>
/// Logs manual material usage from the job details page. Mirrors the QR scan LogUsage
/// flow in InventoryController but returns JSON so the modal can close and refresh inline.
/// Quantity is always the amount USED (caller converts from remaining if needed).
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> LogMaterial([FromBody] LogMaterialRequest req)
{
try
{
if (req.QuantityUsed <= 0)
return Json(new { success = false, message = "Quantity used must be greater than zero." });
var item = await _unitOfWork.InventoryItems.GetByIdAsync(req.InventoryItemId);
if (item == null) return Json(new { success = false, message = "Inventory item not found." });
var job = await _unitOfWork.Jobs.GetByIdAsync(req.JobId);
if (job == null) return Json(new { success = false, message = "Job not found." });
var txnType = req.TransactionType == "Waste"
? InventoryTransactionType.Waste
: InventoryTransactionType.JobUsage;
item.QuantityOnHand -= req.QuantityUsed;
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
var txn = new PowderCoating.Core.Entities.InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = txnType,
Quantity = -req.QuantityUsed,
UnitCost = item.UnitCost,
TotalCost = req.QuantityUsed * item.UnitCost,
TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand,
JobId = req.JobId,
Reference = $"Job {job.JobNumber}",
Notes = req.Notes?.Trim(),
CompanyId = item.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.InventoryTransactions.AddAsync(txn);
await _unitOfWork.CompleteAsync();
// GL: DR COGS, CR Inventory Asset
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
{
var cost = req.QuantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
}
return Json(new
{
success = true,
message = $"Logged {req.QuantityUsed:N2} {item.UnitOfMeasure} of {item.Name}.",
newBalance = item.QuantityOnHand,
unitOfMeasure = item.UnitOfMeasure,
itemName = item.Name
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error logging material for job {JobId}", req.JobId);
return Json(new { success = false, message = "An error occurred. Please try again." });
}
}
}
public class DeleteTimeEntryRequest { public int Id { get; set; } }
public class LogMaterialRequest
{
public int JobId { get; set; }
public int InventoryItemId { get; set; }
public decimal QuantityUsed { get; set; }
public string TransactionType { get; set; } = "JobUsage";
public string? Notes { get; set; }
}
public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
public class UpdateWorkerAssignmentRequest
{
public int JobId { get; set; }
public string? WorkerId { get; set; }
}
public class UpdateJobPriorityRequest
{
public int JobId { get; set; }
public int PriorityId { get; set; }
}
public class UpdateJobStatusRequest
{
public int JobId { get; set; }
public int StatusId { get; set; }
public bool SendEmail { get; set; } = false;
}
// ── Kanban Board view models ──────────────────────────────────────────────────
public class JobBoardColumn
{
public int StatusId { get; set; }
public string StatusCode { get; set; } = "";
public string DisplayName { get; set; } = "";
public string ColorClass { get; set; } = "secondary";
public string? IconClass { get; set; }
public bool IsTerminal { get; set; }
public List<JobBoardCard> 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; }
}