539c6c2559
- Carry OvenBatches/OvenCycleMinutes from Quote → Job entity (was missing fields; all job pricing recalcs hardcoded 1/null) - Fix invoice creation from job always showing Quantity=1 (was using TotalPrice as UnitPrice with qty 1) - Add IsAiItem to JobItem + migration; map in all 3 JobItemAssemblyService.CreateJobItem overloads so AI photo jobs no longer double-price on first edit after quote→job conversion - Propagate IsAiItem through all existingItemsData JSON blocks in Jobs views (Edit, EditItems, Create) so the wizard preserves AI routing on re-edit - Add PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem structural test + 3 behavioral IsAiItem tests to JobItemAssemblyServiceTests - Consolidate item wizard partials (_ItemWizardModal, _SqFtCalculatorModal) and item-wizard.css into shared locations - Document pricing flag propagation checklist in CLAUDE.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4097 lines
186 KiB
C#
4097 lines
186 KiB
C#
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 = wizardCosts?.TaxPercent ?? 0m;
|
||
|
||
// Internal pricing breakdown (not printed — mirrors quote details breakdown)
|
||
var breakdownItems = 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,
|
||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
|
||
IsLaborItem = ji.IsLaborItem,
|
||
IsSalesItem = ji.IsSalesItem,
|
||
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
||
PowderCostOverride = ji.PowderCostOverride,
|
||
IncludePrepCost = ji.IncludePrepCost,
|
||
Complexity = ji.Complexity,
|
||
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
||
{
|
||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||
TransferEfficiency = c.TransferEfficiency,
|
||
PowderCostPerLb = c.PowderCostPerLb,
|
||
PowderToOrder = c.PowderToOrder
|
||
}).ToList(),
|
||
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
||
{
|
||
PrepServiceId = ps.PrepServiceId,
|
||
EstimatedMinutes = ps.EstimatedMinutes
|
||
}).ToList()
|
||
}).ToList();
|
||
|
||
if (breakdownItems.Any())
|
||
{
|
||
var pr = await _pricingService.CalculateQuoteTotalsAsync(
|
||
breakdownItems, job.CompanyId, job.CustomerId,
|
||
wizardCosts?.TaxPercent ?? 0m,
|
||
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||
job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
|
||
|
||
ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto
|
||
{
|
||
MaterialCosts = pr.MaterialCosts,
|
||
LaborCosts = pr.LaborCosts,
|
||
EquipmentCosts = pr.EquipmentCosts,
|
||
ItemsSubtotal = pr.ItemsSubtotal,
|
||
OvenBatchCost = pr.OvenBatchCost,
|
||
OvenBatches = pr.OvenBatches,
|
||
OvenCycleMinutes = pr.OvenCycleMinutes > 0 ? pr.OvenCycleMinutes : (wizardCosts?.DefaultOvenCycleMinutes ?? 0),
|
||
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,
|
||
DiscountAmount = pr.DiscountAmount,
|
||
DiscountPercent = pr.DiscountPercent,
|
||
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
|
||
RushFee = pr.RushFee,
|
||
TaxAmount = pr.TaxAmount,
|
||
TaxPercent = pr.TaxPercent,
|
||
Total = pr.Total
|
||
};
|
||
}
|
||
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;
|
||
|
||
// 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.GetAllAsync();
|
||
ViewBag.CustomerSelectList = allCustomers
|
||
.Where(c => c.IsActive)
|
||
.Select(c => new SelectListItem
|
||
{
|
||
Value = c.Id.ToString(),
|
||
Text = !string.IsNullOrWhiteSpace(c.CompanyName)
|
||
? c.CompanyName
|
||
: $"{c.ContactFirstName} {c.ContactLastName}".Trim()
|
||
})
|
||
.OrderBy(c => c.Text)
|
||
.ToList();
|
||
|
||
// Banner: warn if the source quote was edited after this job was created from it.
|
||
if (job.Quote != null && job.QuoteSnapshotUpdatedAt.HasValue &&
|
||
job.Quote.UpdatedAt.HasValue && job.Quote.UpdatedAt > job.QuoteSnapshotUpdatedAt)
|
||
{
|
||
ViewBag.QuoteUpdatedAfterConversion = true;
|
||
ViewBag.QuoteUpdatedAt = job.Quote.UpdatedAt.Value;
|
||
ViewBag.SourceQuoteId = job.QuoteId;
|
||
ViewBag.SourceQuoteNumber = job.Quote.QuoteNumber;
|
||
var preProductionCodes = new HashSet<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 allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync())
|
||
.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.GetAllAsync()).ToList();
|
||
var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId);
|
||
if (newStatus == null) return BadRequest("Invalid status.");
|
||
|
||
var oldStatusId = job.JobStatusId;
|
||
job.JobStatusId = newStatusId;
|
||
job.UpdatedAt = DateTime.UtcNow;
|
||
if (newStatus.StatusCode == 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.GetAllAsync();
|
||
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.GetAllAsync();
|
||
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,
|
||
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);
|
||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||
dto.JobItems, companyId, dto.CustomerId,
|
||
createCosts?.TaxPercent ?? 0m,
|
||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
|
||
|
||
job.FinalPrice = totals.Total;
|
||
job.OvenBatchCost = totals.OvenBatchCost;
|
||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||
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,
|
||
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,
|
||
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;
|
||
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);
|
||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||
dto.JobItems, companyId, dto.CustomerId,
|
||
editCosts?.TaxPercent ?? 0m,
|
||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
|
||
job.FinalPrice = totals.Total;
|
||
job.OvenBatchCost = totals.OvenBatchCost;
|
||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||
}
|
||
|
||
// 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();
|
||
var costs = await _pricingService.GetOperatingCostsAsync(companyId);
|
||
await PopulateJobItemDropDownsAsync(companyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||
ViewBag.TaxPercent = costs?.TaxPercent ?? 0m;
|
||
ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m;
|
||
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
|
||
ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m;
|
||
ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m;
|
||
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
||
ViewBag.UseMetric = useMetric;
|
||
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
||
}
|
||
|
||
/// <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 customers = await _unitOfWork.Customers.GetAllAsync();
|
||
ViewBag.Customers = new SelectList(
|
||
customers.Where(c => c.IsActive).Select(c => new
|
||
{
|
||
c.Id,
|
||
DisplayName = !string.IsNullOrWhiteSpace(c.CompanyName)
|
||
? c.CompanyName
|
||
: $"{c.ContactFirstName} {c.ContactLastName}".Trim()
|
||
}).OrderBy(c => c.DisplayName),
|
||
"Id",
|
||
"DisplayName");
|
||
|
||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||
var users = await _userManager.Users
|
||
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
|
||
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
||
.ToListAsync();
|
||
ViewBag.Workers = new SelectList(users.Select(u => new { u.Id, FullName = u.FullName }), "Id", "FullName");
|
||
|
||
// Use cached lookups for better performance
|
||
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
||
ViewBag.Statuses = new SelectList(
|
||
statuses.OrderBy(s => s.DisplayOrder),
|
||
"Id",
|
||
"DisplayName");
|
||
|
||
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
||
ViewBag.Priorities = new SelectList(
|
||
priorities.OrderBy(p => p.DisplayOrder),
|
||
"Id",
|
||
"DisplayName");
|
||
}
|
||
|
||
/// <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()
|
||
{
|
||
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
|
||
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
|
||
_logger.LogInformation("Populated {Count} active prep services", prepServices.Count());
|
||
|
||
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
|
||
ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
|
||
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
|
||
.ToList();
|
||
}
|
||
|
||
/// <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)));
|
||
|
||
// Update actual powder usage for each coat
|
||
foreach (var coatUsage in dto.CoatUsages)
|
||
{
|
||
var jobItemCoat = await _unitOfWork.JobItemCoats.GetByIdAsync(
|
||
coatUsage.JobItemCoatId,
|
||
false,
|
||
jic => jic.InventoryItem);
|
||
|
||
if (jobItemCoat != null)
|
||
{
|
||
jobItemCoat.ActualPowderUsedLbs = coatUsage.ActualPowderUsedLbs;
|
||
await _unitOfWork.JobItemCoats.UpdateAsync(jobItemCoat);
|
||
|
||
_logger.LogInformation("Updated JobItemCoat {CoatId} with {Lbs} lbs actual powder used",
|
||
coatUsage.JobItemCoatId, coatUsage.ActualPowderUsedLbs);
|
||
|
||
// Deduct powder from inventory if using stock powder
|
||
if (jobItemCoat.InventoryItemId.HasValue &&
|
||
coatUsage.ActualPowderUsedLbs.HasValue &&
|
||
coatUsage.ActualPowderUsedLbs.Value > 0)
|
||
{
|
||
var invItemId = jobItemCoat.InventoryItemId.Value;
|
||
var actualLbs = coatUsage.ActualPowderUsedLbs.Value;
|
||
|
||
// Apply available pre-logged credit so we don't double-deduct
|
||
var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m);
|
||
var deductNow = Math.Max(0m, actualLbs - credit);
|
||
// Consume credit (other coats sharing the same powder get whatever remains)
|
||
preLoggedCredit[invItemId] = Math.Max(0m, credit - actualLbs);
|
||
|
||
if (deductNow > 0)
|
||
{
|
||
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
|
||
if (inventoryItem != null)
|
||
{
|
||
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} - {jobItemCoat.CoatName} ({jobItemCoat.ColorName ?? "N/A"}) by {currentUser!.FirstName} {currentUser.LastName}",
|
||
BalanceAfter = inventoryItem.QuantityOnHand - deductNow,
|
||
CompanyId = job.CompanyId
|
||
};
|
||
|
||
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
||
inventoryItem.QuantityOnHand -= deductNow;
|
||
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
|
||
|
||
// GL: DR COGS, CR Inventory Asset (accrual) — no-op if accounts not configured
|
||
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);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
_logger.LogInformation(
|
||
"Skipped inventory deduction for JobItemCoat {CoatId} — {Lbs} lbs already pre-logged for inventory item {InvItemId}",
|
||
coatUsage.JobItemCoatId, actualLbs, invItemId);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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 = 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 = 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) to match what the wizard displays
|
||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||
model.JobItems, currentUser.CompanyId, job.CustomerId,
|
||
model.TaxPercent, "None", 0, false, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
|
||
|
||
job.FinalPrice = totals.Total;
|
||
job.OvenBatchCost = totals.OvenBatchCost;
|
||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||
job.UpdatedAt = DateTime.UtcNow;
|
||
job.UpdatedBy = currentUser.UserName;
|
||
await _unitOfWork.Jobs.UpdateAsync(job);
|
||
await _unitOfWork.SaveChangesAsync();
|
||
|
||
this.ToastSuccess("Job items updated successfully.");
|
||
return RedirectToAction(nameof(Details), new { id = job.Id });
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error updating items for job {JobId}", job.Id);
|
||
TempData["Error"] = "An error occurred while saving job items.";
|
||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||
model.TaxPercent = costs?.TaxPercent ?? 0m;
|
||
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||
return View("EditItems", model);
|
||
}
|
||
}
|
||
|
||
/// <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,
|
||
IsAiItem = ji.IsAiItem,
|
||
ManualUnitPrice = ji.ManualUnitPrice,
|
||
Coats = ji.Coats.Select(c => new CreateQuoteItemCoatDto
|
||
{
|
||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||
TransferEfficiency = c.TransferEfficiency,
|
||
PowderCostPerLb = c.PowderCostPerLb
|
||
}).ToList()
|
||
}).ToList();
|
||
|
||
if (remainingDtos.Any())
|
||
{
|
||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||
remainingDtos, currentUser.CompanyId, job.CustomerId,
|
||
costs?.TaxPercent ?? 0m, "None", 0, false, null, 1, null);
|
||
job.FinalPrice = totals.Total;
|
||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||
}
|
||
else
|
||
{
|
||
job.FinalPrice = 0;
|
||
job.ShopSuppliesAmount = 0;
|
||
job.ShopSuppliesPercent = 0;
|
||
}
|
||
|
||
job.UpdatedAt = DateTime.UtcNow;
|
||
job.UpdatedBy = currentUser.UserName;
|
||
await _unitOfWork.Jobs.UpdateAsync(job);
|
||
await _unitOfWork.SaveChangesAsync();
|
||
|
||
return Json(new { success = true, jobId = job.Id });
|
||
}
|
||
|
||
/// <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.GetAllAsync(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.GetAllAsync(false);
|
||
ViewBag.Vendors = vendors
|
||
.Where(s => s.IsActive).OrderBy(s => s.CompanyName)
|
||
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
|
||
|
||
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category, i => i.Category.ParentCategory);
|
||
ViewBag.CatalogItems = catalogItems
|
||
.Where(i => i.IsActive)
|
||
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
|
||
.Select(i => new
|
||
{
|
||
value = i.Id.ToString(),
|
||
text = BuildCatalogItemText(i),
|
||
categoryName = i.Category.Name,
|
||
price = i.DefaultPrice,
|
||
approxArea = i.ApproximateArea ?? 0m,
|
||
defaultMinutes = i.DefaultEstimatedMinutes ?? 0,
|
||
thumbnailPath = i.ThumbnailPath
|
||
}).ToList();
|
||
|
||
// Merchandise items (IsMerchandise = true) — for the sales wizard step
|
||
ViewBag.MerchandiseItems = catalogItems
|
||
.Where(i => i.IsActive && i.IsMerchandise)
|
||
.OrderBy(i => i.Category.Name).ThenBy(i => i.DisplayOrder).ThenBy(i => i.Name)
|
||
.Select(i => new
|
||
{
|
||
id = i.Id,
|
||
name = i.Name,
|
||
sku = i.SKU,
|
||
category = i.Category.Name,
|
||
price = i.DefaultPrice,
|
||
description = i.Description
|
||
}).ToList();
|
||
|
||
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
|
||
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
|
||
|
||
var blastSetupsForEditItems = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
|
||
ViewBag.BlastSetups = blastSetupsForEditItems.OrderBy(b => b.DisplayOrder)
|
||
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
|
||
.ToList();
|
||
|
||
var ovens = await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive && o.CompanyId == companyId);
|
||
var ovenItems = new List<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}";
|
||
}
|
||
|
||
#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,
|
||
e => e.Worker); // Worker nav loaded for display of legacy entries that pre-date user migration
|
||
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.1–24) 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 fallbackLaborRate = opCosts?.StandardLaborRate ?? 0m;
|
||
var effectiveOvenMinutes = (opCosts?.DefaultOvenCycleMinutes > 0 ? (int?)opCosts!.DefaultOvenCycleMinutes : null) ?? 45;
|
||
var defaultOvenCycleHours = effectiveOvenMinutes / 60.0m;
|
||
|
||
// Role cost rates map: role → hourly rate
|
||
var roleCosts = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId);
|
||
var roleCostMap = roleCosts.ToDictionary(r => r.Role, r => r.HourlyRate);
|
||
|
||
// 1. Powder / Material cost
|
||
decimal powderCost = 0m;
|
||
var powderLines = new List<object>();
|
||
bool hasCoatsWithRateButNoQty = false;
|
||
foreach (var item in job.JobItems)
|
||
{
|
||
foreach (var coat in item.Coats)
|
||
{
|
||
var lbs = coat.ActualPowderUsedLbs ?? coat.PowderToOrder ?? 0m;
|
||
var costPerLb = coat.PowderCostPerLb ?? 0m;
|
||
var lineCost = lbs * costPerLb;
|
||
powderCost += lineCost;
|
||
if (lbs > 0 && costPerLb > 0)
|
||
{
|
||
powderLines.Add(new {
|
||
description = $"{item.Description} — {coat.CoatName}" + (!string.IsNullOrWhiteSpace(coat.ColorName) ? $" ({coat.ColorName})" : ""),
|
||
lbs = Math.Round(lbs, 3),
|
||
costPerLb = Math.Round(costPerLb, 4),
|
||
total = Math.Round(lineCost, 2),
|
||
isActual = coat.ActualPowderUsedLbs.HasValue
|
||
});
|
||
}
|
||
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
|
||
decimal laborCost = 0m;
|
||
var laborLines = new List<object>();
|
||
foreach (var entry in job.TimeEntries)
|
||
{
|
||
var rate = entry.Worker != null && roleCostMap.TryGetValue(entry.Worker.Role, out var r) ? r : fallbackLaborRate;
|
||
var lineCost = entry.HoursWorked * rate;
|
||
laborCost += lineCost;
|
||
laborLines.Add(new {
|
||
worker = entry.Worker?.Name ?? "Unknown",
|
||
role = entry.Worker != null ? System.Text.RegularExpressions.Regex.Replace(entry.Worker.Role.ToString(), "([a-z])([A-Z])", "$1 $2") : "",
|
||
hours = entry.HoursWorked,
|
||
rate = Math.Round(rate, 2),
|
||
total = Math.Round(lineCost, 2),
|
||
usingFallback = entry.Worker == null || !roleCostMap.ContainsKey(entry.Worker.Role),
|
||
stage = entry.Stage,
|
||
workDate = entry.WorkDate.ToString("MM/dd/yyyy")
|
||
});
|
||
}
|
||
|
||
// 3. Oven / equipment cost (estimated from oven selection + default cycle)
|
||
decimal ovenCost = 0m;
|
||
string ovenLabel = "Default";
|
||
if (job.OvenCost != null)
|
||
{
|
||
ovenCost = job.OvenCost.CostPerHour * defaultOvenCycleHours;
|
||
ovenLabel = job.OvenCost.Label;
|
||
}
|
||
else if (opCosts != null)
|
||
{
|
||
ovenCost = opCosts.OvenOperatingCostPerHour * defaultOvenCycleHours;
|
||
}
|
||
|
||
// 4. Revenue
|
||
decimal revenue = job.Invoice != null
|
||
? job.Invoice.Total
|
||
: (job.FinalPrice > 0 ? job.FinalPrice : job.QuotedPrice);
|
||
|
||
// 5. Rework costs from linked rework jobs
|
||
var reworkRecords = await _unitOfWork.ReworkRecords.FindAsync(
|
||
r => r.JobId == jobId, false, r => r.ReworkJob);
|
||
|
||
decimal reworkCostTotal = 0m;
|
||
decimal reworkBilledToCustomer = 0m;
|
||
var reworkLines = new List<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),
|
||
fallbackLaborRate,
|
||
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);
|
||
}
|
||
}
|
||
|
||
public class DeleteTimeEntryRequest { public int Id { get; set; } }
|
||
public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
|
||
|
||
public class UpdateWorkerAssignmentRequest
|
||
{
|
||
public int JobId { get; set; }
|
||
public string? WorkerId { get; set; }
|
||
}
|
||
|
||
public class UpdateJobPriorityRequest
|
||
{
|
||
public int JobId { get; set; }
|
||
public int PriorityId { get; set; }
|
||
}
|
||
|
||
public class UpdateJobStatusRequest
|
||
{
|
||
public int JobId { get; set; }
|
||
public int StatusId { get; set; }
|
||
public bool SendEmail { get; set; } = false;
|
||
}
|
||
|
||
// ── Kanban Board view models ──────────────────────────────────────────────────
|
||
|
||
public class JobBoardColumn
|
||
{
|
||
public int StatusId { get; set; }
|
||
public string StatusCode { get; set; } = "";
|
||
public string DisplayName { get; set; } = "";
|
||
public string ColorClass { get; set; } = "secondary";
|
||
public string? IconClass { get; set; }
|
||
public bool IsTerminal { get; set; }
|
||
public List<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; }
|
||
}
|