Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/DashboardController.cs
T
spouliot 059d94d4fe Lazily enrich catalog specs from TDS on first use
Specific gravity, coverage, and ~55% of cure specs aren't in the Columbia feed.
Rather than read 2,400 TDS PDFs up front, enrich a catalog item the first time
it's actually used:

- FetchTdsCureSpecsAsync now also extracts specific gravity from the TDS.
- New EnsureCatalogTdsSpecsAsync fills a catalog item's specific gravity (and
  any missing cure temp/time) from its TDS, then derives theoretical coverage
  (192.3 / (SG x mils)). No-op once specific gravity is known or when there's no
  TDS; persists to the catalog so the work is done once and benefits everyone.
- Hooked into the catalog->inventory paths (CreateIncomingFromCatalog, the
  custom-powder receive enrichment, and ReceivePowderFromCatalog) so a powder's
  full specs land on both the catalog and the new inventory record. DashboardController
  gains the AI lookup service for this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:07:23 -04:00

1244 lines
59 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Dashboard;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums; // Still needed for MaintenanceStatus, EquipmentStatus, PaymentMethod
using PowderCoating.Core.Interfaces;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.ViewModels.Dashboard;
using PowderCoating.Web.ViewModels.GuidedActivation;
using InvoiceStatus = PowderCoating.Core.Enums.InvoiceStatus;
namespace PowderCoating.Web.Controllers;
[Authorize]
public class DashboardController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<DashboardController> _logger;
private readonly IDashboardReadService _dashboardRead;
private readonly ITenantContext _tenantContext;
private readonly ICompanyConfigHealthService _configHealth;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ISubscriptionService _subscriptionService;
private readonly IInventoryAiLookupService _aiLookupService;
public DashboardController(
IUnitOfWork unitOfWork,
ILogger<DashboardController> logger,
IDashboardReadService dashboardRead,
ITenantContext tenantContext,
ICompanyConfigHealthService configHealth,
UserManager<ApplicationUser> userManager,
ISubscriptionService subscriptionService,
IInventoryAiLookupService aiLookupService)
{
_unitOfWork = unitOfWork;
_logger = logger;
_dashboardRead = dashboardRead;
_tenantContext = tenantContext;
_configHealth = configHealth;
_userManager = userManager;
_subscriptionService = subscriptionService;
_aiLookupService = aiLookupService;
}
/// <summary>
/// Renders the operator dashboard for the current tenant company. Aggregates KPI cards,
/// job panels (today / overdue / in-progress), financial summary (AR aging, invoices, payments),
/// inventory alerts, maintenance schedule, powder-order pipeline, and the rotating tip-of-the-day
/// from <see cref="DashboardTip"/>. SuperAdmins who are not currently impersonating a company
/// are redirected to <see cref="SuperAdminDashboard"/> instead.
/// </summary>
public async Task<IActionResult> Index()
{
if (User.IsInRole("SuperAdmin") && HttpContext.Session.GetString("ImpersonatingCompanyName") == null)
return RedirectToAction(nameof(SuperAdminDashboard));
try
{
var today = DateTime.Today;
var data = await _dashboardRead.GetIndexDataAsync(today);
// ---------------------------------------------------------------
// Job panels
// ---------------------------------------------------------------
var todaysJobs = data.TodaysJobs
.Select(MapJobDto)
.ToList();
var overdueJobs = data.OverdueJobs
.Select(MapJobDto)
.ToList();
var inProgressJobs = data.InProgressJobs
.Select(MapJobDto)
.ToList();
// ---------------------------------------------------------------
// Appointments
// ---------------------------------------------------------------
var todaysAppointments = data.TodaysAppointments
.Select(a => new DashboardAppointmentDto
{
Id = a.Id,
AppointmentNumber = a.AppointmentNumber,
Title = a.Title,
CustomerName = a.Customer?.CompanyName ?? "Unknown",
ScheduledStartTime = a.ScheduledStartTime,
ScheduledEndTime = a.ScheduledEndTime,
IsAllDay = a.IsAllDay,
TypeDisplayName = a.AppointmentType?.DisplayName ?? "Unknown",
TypeColorClass = a.AppointmentType?.ColorClass ?? "secondary",
StatusDisplayName = a.AppointmentStatus?.DisplayName ?? "Unknown",
StatusColorClass = a.AppointmentStatus?.ColorClass ?? "secondary",
AssignedWorkerName = a.AssignedUser?.FullName
}).ToList();
// ---------------------------------------------------------------
// Low stock items
// ---------------------------------------------------------------
var lowStockItems = data.LowStockItems
.Select(i => new DashboardLowStockDto
{
Id = i.Id,
Name = i.Name,
ColorName = i.ColorName,
Manufacturer = i.Manufacturer,
QuantityOnHand = i.QuantityOnHand,
ReorderPoint = i.ReorderPoint,
UnitOfMeasure = i.UnitOfMeasure
}).ToList();
// ---------------------------------------------------------------
// Maintenance
// ---------------------------------------------------------------
var upcomingMaintenanceDtos = data.UpcomingMaintenance
.Select(m => new DashboardMaintenanceDto
{
Id = m.Id,
EquipmentName = m.Equipment?.EquipmentName ?? "Unknown",
MaintenanceType = m.MaintenanceType,
Status = m.Status,
Priority = m.Priority,
ScheduledDate = m.ScheduledDate,
Description = m.Description,
AssignedWorkerName = m.AssignedUser?.FullName
}).ToList();
// ---------------------------------------------------------------
// Quotes
// ---------------------------------------------------------------
var pendingQuotes = data.PendingQuotes
.Select(q => new DashboardQuoteDto
{
Id = q.Id,
QuoteNumber = q.QuoteNumber,
CustomerName = q.Customer?.CompanyName
?? (q.Customer != null ? $"{q.Customer.ContactFirstName} {q.Customer.ContactLastName}".Trim() : null)
?? q.ProspectCompanyName
?? q.ProspectContactName
?? "Unknown",
QuoteDate = q.QuoteDate,
ExpirationDate = q.ExpirationDate,
Total = q.Total,
StatusCode = q.QuoteStatus.StatusCode,
StatusDisplayName = q.QuoteStatus.DisplayName
}).ToList();
var expiringQuotes = data.ExpiringQuotes
.Select(q => new DashboardQuoteDto
{
Id = q.Id,
QuoteNumber = q.QuoteNumber,
CustomerName = q.Customer?.CompanyName
?? (q.Customer != null ? $"{q.Customer.ContactFirstName} {q.Customer.ContactLastName}".Trim() : null)
?? q.ProspectCompanyName
?? q.ProspectContactName
?? "Unknown",
QuoteDate = q.QuoteDate,
ExpirationDate = q.ExpirationDate,
Total = q.Total,
StatusCode = q.QuoteStatus.StatusCode,
StatusDisplayName = q.QuoteStatus.DisplayName
}).ToList();
// ---------------------------------------------------------------
// Invoices & AR aging
// ---------------------------------------------------------------
var overdueInvoices = data.OverdueInvoices
.Select(i => new DashboardInvoiceDto
{
Id = i.Id,
InvoiceNumber = i.InvoiceNumber,
CustomerName = i.Customer?.CompanyName
?? $"{i.Customer?.ContactFirstName} {i.Customer?.ContactLastName}".Trim(),
Total = i.Total,
BalanceDue = i.BalanceDue,
DueDate = i.DueDate,
DaysOverdue = i.DueDate.HasValue ? (int)(today - i.DueDate.Value.Date).TotalDays : 0
})
.ToList();
// ---------------------------------------------------------------
// Payments
// ---------------------------------------------------------------
var recentPayments = data.RecentPayments
.Select(p => new DashboardPaymentDto
{
Id = p.Id,
InvoiceId = p.InvoiceId,
InvoiceNumber = p.Invoice?.InvoiceNumber ?? "-",
CustomerName = p.Invoice?.Customer?.CompanyName
?? $"{p.Invoice?.Customer?.ContactFirstName} {p.Invoice?.Customer?.ContactLastName}".Trim(),
Amount = p.Amount,
PaymentDate = p.PaymentDate,
PaymentMethodDisplay = p.PaymentMethod switch
{
PaymentMethod.Cash => "Cash",
PaymentMethod.Check => "Check",
PaymentMethod.CreditDebitCard => "Card",
PaymentMethod.BankTransferACH => "ACH",
PaymentMethod.DigitalPayment => "Digital",
_ => "Other"
}
})
.ToList();
// ---------------------------------------------------------------
// Equipment alerts
// ---------------------------------------------------------------
var equipmentAlerts = data.EquipmentAlerts
.Select(e => new DashboardEquipmentAlertDto
{
Id = e.Id,
EquipmentName = e.EquipmentName,
EquipmentType = e.EquipmentType,
Issue = e.Status == EquipmentStatus.OutOfService ? "Out of Service" : "Needs Maintenance",
Severity = e.Status == EquipmentStatus.OutOfService ? "Critical" : "Warning",
LastMaintenanceDate = e.LastMaintenanceDate,
NextMaintenanceDue = null
}).ToList();
// ---------------------------------------------------------------
// Recent activity
// ---------------------------------------------------------------
var recentQuoteDtos = data.RecentQuotes
.Select(q => new DashboardRecentActivityDto
{
Id = q.Id,
Type = "Quote",
Title = q.QuoteNumber,
Description = "Quote created",
CustomerName = q.Customer?.CompanyName
?? (q.Customer != null ? $"{q.Customer.ContactFirstName} {q.Customer.ContactLastName}".Trim() : null)
?? q.ProspectCompanyName
?? q.ProspectContactName
?? "Unknown",
ActivityDate = q.CreatedAt,
StatusDisplayName = q.QuoteStatus.DisplayName,
StatusColorClass = q.QuoteStatus.ColorClass,
Amount = q.Total
});
var recentJobDtos = data.RecentJobs
.Select(j => new DashboardRecentActivityDto
{
Id = j.Id,
Type = "Job",
Title = j.JobNumber,
Description = j.Description ?? "Job created",
CustomerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName)
? j.Customer.CompanyName
: $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(),
ActivityDate = j.CreatedAt,
StatusDisplayName = j.JobStatus.DisplayName,
StatusColorClass = j.JobStatus.ColorClass,
Amount = j.FinalPrice
});
var recentActivity = recentQuoteDtos.Concat(recentJobDtos)
.OrderByDescending(a => a.ActivityDate)
.Take(10)
.ToList();
// ---------------------------------------------------------------
// Powder orders needed
// ---------------------------------------------------------------
var powderOrderGroups = MapPowderOrderGroupsMerged(data.PowderOrdersNeeded);
// ---------------------------------------------------------------
// Powder orders placed
// ---------------------------------------------------------------
var powderPlacedGroups = MapPowderOrderGroups(data.PowderOrdersPlaced);
// ---------------------------------------------------------------
// Bills due
// ---------------------------------------------------------------
var billsDue = data.BillsDue.Select(b => new DashboardBillDto
{
Id = b.Id,
BillNumber = b.BillNumber,
VendorName = b.Vendor?.CompanyName ?? "Unknown",
BalanceDue = b.BalanceDue,
DueDate = b.DueDate,
IsOverdue = b.DueDate.HasValue && b.DueDate.Value.Date < today,
DaysOverdue = b.DueDate.HasValue && b.DueDate.Value.Date < today
? (int)(today - b.DueDate.Value.Date).TotalDays : 0
}).ToList();
var vm = new DashboardViewModel
{
// Counts
ActiveJobsCount = data.ActiveJobsCount,
TodaysJobsCount = data.TodaysJobsCount,
OverdueJobsCount = data.OverdueJobsCount,
TodaysAppointmentsCount = data.TodaysAppointmentsCount,
LowStockCount = data.LowStockCount,
PendingMaintenanceCount = data.PendingMaintenanceCount,
PendingQuotesCount = data.PendingQuotesCount,
PendingQuoteValue = data.PendingQuoteValue,
MonthlyRevenue = data.MonthlyRevenue,
ActiveCustomersCount = data.ActiveCustomersCount,
// Financial KPIs
OutstandingAr = data.OutstandingAr,
CollectedThisMonth = data.CollectedThisMonth,
InvoicedThisMonth = data.InvoicedThisMonth,
OverdueInvoicesCount = data.OverdueInvoicesCount,
OverdueInvoicesAmount = data.OverdueInvoicesAmount,
AgingCurrent = data.ArAging.Current,
AgingDays1To30 = data.ArAging.Days1To30,
AgingDays31To60 = data.ArAging.Days31To60,
AgingDays61To90 = data.ArAging.Days61To90,
AgingDaysOver90 = data.ArAging.DaysOver90,
// Sections
TodaysJobs = todaysJobs,
TodaysAppointments = todaysAppointments,
OverdueJobs = overdueJobs,
ExpiringQuotes = expiringQuotes,
ActiveJobs = inProgressJobs,
LowStockItems = lowStockItems,
UpcomingMaintenance = upcomingMaintenanceDtos,
EquipmentAlerts = equipmentAlerts,
PendingQuotes = pendingQuotes,
RecentActivity = recentActivity,
OverdueInvoices = overdueInvoices,
RecentPayments = recentPayments,
// Bills Due
BillsDue = billsDue,
BillsDueCount = data.BillsDueCount,
BillsDueAmount = data.BillsDueAmount,
// Powder orders
PowderOrdersNeeded = powderOrderGroups,
PowderOrdersNeededCount = data.PowderOrdersNeeded.Count,
PowderOrdersPlaced = powderPlacedGroups,
PowderOrdersPlacedCount = data.PowderOrdersPlaced.Count,
TipOfTheDay = data.TipOfTheDay
};
// Resolve company once so all remaining queries are explicitly scoped
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
var companyId = currentCompanyId ?? 0;
// Dropdowns for the "Add Custom Powder to Inventory" modal
var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsActive && c.CompanyId == companyId))
.OrderBy(c => c.DisplayOrder)
.Select(c => new { c.Id, c.DisplayName })
.ToList();
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive && v.CompanyId == companyId))
.OrderBy(v => v.CompanyName)
.Select(v => new { v.Id, v.CompanyName })
.ToList();
ViewBag.InventoryCategories = inventoryCategories;
ViewBag.VendorList = vendors;
// Config health check — surface setup gaps to company admins
if (currentCompanyId.HasValue)
{
ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value);
// Load prefs once and share between both banner and progress widget builders
var companyPrefs = await _unitOfWork.CompanyPreferences
.FirstOrDefaultAsync(p => p.CompanyId == currentCompanyId.Value && !p.IsDeleted);
ViewBag.GuidedActivationBanner = BuildGuidedActivationBanner(companyPrefs);
ViewBag.ShopProgressWidget = await BuildShopProgressWidgetAsync(currentCompanyId.Value, companyPrefs);
var companyForKiosk = await _unitOfWork.Companies.GetByIdAsync(currentCompanyId.Value);
ViewBag.KioskActivated = !string.IsNullOrEmpty(companyForKiosk?.KioskActivationToken);
}
return View(vm);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading dashboard");
TempData["Error"] = "An error occurred while loading the dashboard.";
return View(new DashboardViewModel());
}
}
/// <summary>
/// Marks one or more job-item coats as having their powder ordered. Called via AJAX from
/// the "Powder in Queue to be Ordered" panel. Accepts a comma-separated list of coat IDs
/// so that a merged line (multiple jobs needing the same powder) can be marked in one click.
/// Verifies company ownership for each coat via its parent job before updating
/// <c>PowderOrdered</c>, <c>PowderOrderedAt</c>, and <c>PowderOrderedByUserId</c>.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> MarkPowderOrdered(string coatIds)
{
try
{
var ids = coatIds?
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => int.TryParse(s.Trim(), out var id) ? id : 0)
.Where(id => id > 0)
.ToList() ?? new List<int>();
if (ids.Count == 0)
return Json(new { success = false, message = "No valid coat IDs provided." });
var currentUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
var results = new List<object>();
foreach (var coatId in ids)
{
var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId);
if (coat == null) continue;
// JobItemCoat has no CompanyId — verify ownership via parent job
var parentCompanyId = coat.JobItem?.Job?.CompanyId;
if (!_tenantContext.IsSuperAdmin() && parentCompanyId != _tenantContext.GetCurrentCompanyId())
continue;
coat.PowderOrdered = true;
coat.PowderOrderedAt = DateTime.UtcNow;
coat.PowderOrderedByUserId = currentUserId;
var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor;
var job = coat.JobItem?.Job;
results.Add(new
{
coatId = coat.Id,
jobId = job?.Id,
jobNumber = job?.JobNumber,
customerName = job?.Customer?.CompanyName ?? job?.Customer?.ContactFirstName ?? "Unknown",
colorName = coat.ColorName,
colorCode = coat.ColorCode,
finish = coat.Finish,
sku = coat.InventoryItem?.SKU,
lbsToOrder = coat.PowderToOrder,
costPerLb = coat.PowderCostPerLb,
orderedAt = coat.PowderOrderedAt,
hasInventory = coat.InventoryItemId.HasValue,
vendorId = vendor?.Id,
vendorName = vendor?.CompanyName ?? "No Vendor Assigned",
vendorPhone = vendor?.Phone
});
}
await _unitOfWork.CompleteAsync();
return Json(new { success = true, coats = results });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error marking coats {CoatIds} as powder ordered", coatIds);
return Json(new { success = false, message = "An error occurred." });
}
}
private GuidedActivationBannerViewModel? BuildGuidedActivationBanner(CompanyPreferences? prefs)
{
var companyRole = User.FindFirst("CompanyRole")?.Value;
if (companyRole != AppConstants.CompanyRoles.CompanyAdmin)
return null;
if (prefs == null || !prefs.SetupWizardCompleted || prefs.FirstWorkflowCompleted)
return null;
return new GuidedActivationBannerViewModel
{
Show = true,
IsDismissed = prefs.GuidedActivationDismissedAt.HasValue,
Title = prefs.GuidedActivationDismissedAt.HasValue
? "Start your first workflow when you're ready"
: "Create your first job or quote",
Message = prefs.GuidedActivationDismissedAt.HasValue
? "You can come back anytime to run a short walkthrough using real quotes, jobs, and invoices."
: "Run a quick 2-minute workflow to see how the system works.",
ActionText = "Start first workflow"
};
}
/// <summary>
/// Builds the "Get the most out of your shop" activation checklist for CompanyAdmins.
/// Returns null when the wizard is not yet complete, the viewer is not a CompanyAdmin,
/// or all six tasks are already done (so the widget disappears naturally at 100%).
/// Three DB checks are fired in parallel to keep the overhead to a minimum.
/// </summary>
private async Task<ShopProgressWidgetViewModel?> BuildShopProgressWidgetAsync(int companyId, CompanyPreferences? prefs)
{
var companyRole = User.FindFirst("CompanyRole")?.Value;
if (companyRole != AppConstants.CompanyRoles.CompanyAdmin)
return null;
if (prefs == null || !prefs.SetupWizardCompleted)
return null;
// These share the same scoped DbContext so must run sequentially
var hasStatusHistory = await _unitOfWork.JobStatusHistory.AnyAsync(_ => true);
// ignoreQueryFilters so soft-deleted lookups (UpdatedAt set on delete) are also visible
var hasCustomizedLookups = await _unitOfWork.JobStatusLookups.AnyAsync(
j => j.CompanyId == companyId && j.UpdatedAt != null,
ignoreQueryFilters: true);
var teamCount = await _userManager.Users
.CountAsync(u => u.CompanyId == companyId && u.IsActive && !u.IsBanned);
var (_, maxUsers) = await _subscriptionService.GetUserCountAsync(companyId);
var planAllowsMultipleUsers = maxUsers != 1;
var items = new List<ShopProgressItem?>
{
new()
{
Done = prefs.FirstJobCreatedAt.HasValue || prefs.FirstQuoteCreatedAt.HasValue,
Label = "Create your first job or quote",
SubLabel = "Get customer sign-off before you start — takes about 2 minutes.",
DoneSubLabel = "Your first job is now being tracked.",
Icon = "bi-file-earmark-plus",
CtaText = "Create a quote",
CtaUrl = Url.Action("Start", "GuidedActivation")!
},
new()
{
Done = hasStatusHistory,
Label = "Move a job through your workflow",
SubLabel = "Move a job through your board so your crew always knows what's next.",
DoneSubLabel = "You've started tracking work through your shop.",
Icon = "bi-arrow-right-circle",
CtaText = "Go to jobs board",
CtaUrl = Url.Action("Board", "Jobs")!
},
new()
{
Done = prefs.FirstInvoiceCreatedAt.HasValue,
Label = "Send your first invoice",
SubLabel = "When the work is done, turn it into an invoice and send it in seconds.",
DoneSubLabel = "You're ready to get paid.",
Icon = "bi-receipt",
CtaText = "Create invoice",
CtaUrl = Url.Action("Create", "Invoices")!
},
planAllowsMultipleUsers ? new()
{
Done = teamCount > 1,
Label = "Bring your crew in",
SubLabel = "Add your crew so everyone stays on the same page in real time.",
DoneSubLabel = "Your team is in the system.",
Icon = "bi-people",
CtaText = "Invite team",
CtaUrl = Url.Action("Index", "CompanyUsers")!
} : null!,
new()
{
Done = hasCustomizedLookups,
Label = "Customize your workflow",
SubLabel = "Adjust stages and services to match how your shop runs.",
DoneSubLabel = "Your workflow speaks your shop's language.",
Icon = "bi-list-ul",
CtaText = "Customize workflow",
CtaUrl = Url.Action("Index", "CompanySettings") + "#data-lookups"
},
new()
{
Done = prefs.DefaultPaymentTerms != "Net 30"
|| prefs.DefaultQuoteValidityDays != 30
|| prefs.DefaultTurnaroundDays != 7
|| prefs.QtDefaultTerms != null,
Label = "Set how you get paid",
SubLabel = "Set your payment terms and timing so every job goes out right.",
DoneSubLabel = "Your payment defaults are locked in.",
Icon = "bi-file-earmark-text",
CtaText = "Set payment terms",
CtaUrl = Url.Action("Index", "CompanySettings") + "#app-defaults"
}
};
var vm = new ShopProgressWidgetViewModel { Items = items.Where(i => i != null).Select(i => i!).ToList() };
// Suppress widget if the user already dismissed it after completing all steps
if (vm.AllDone && prefs.GuidedActivationDismissedAt.HasValue)
return null;
return vm;
}
/// <summary>
/// Persists the company admin's dismissal of the progress widget completion state.
/// Sets <c>GuidedActivationDismissedAt</c> so the widget stays hidden across devices
/// and browser sessions (localStorage alone wouldn't survive a cleared cache).
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DismissProgressWidget()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.Preferences!);
if (company?.Preferences == null)
return Json(new { success = false });
company.Preferences.GuidedActivationDismissedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Progress widget dismissed for company {CompanyId}", companyId.Value);
return Json(new { success = true });
}
/// <summary>
/// Records receipt of a powder shipment against an existing powder order. Sets
/// <c>PowderReceived</c>, <c>PowderReceivedLbs</c>, and <c>PowderReceivedAt</c> on the coat,
/// and — when the coat is linked to an inventory item — increases <c>QuantityOnHand</c> and
/// writes a <c>Purchase</c> <see cref="InventoryTransaction"/> so the stock movement is fully
/// traceable. Company ownership is verified through the parent job because <c>JobItemCoat</c>
/// carries no <c>CompanyId</c> of its own.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ReceivePowder(int coatId, decimal lbsReceived)
{
try
{
// Load coat with inventory item for the stock update
var coat = await _unitOfWork.JobItemCoats.LoadWithInventoryAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat record not found." });
if (lbsReceived <= 0)
return Json(new { success = false, message = "Quantity received must be greater than zero." });
// Verify ownership — JobItemCoat has no CompanyId, check via parent job.
// If JobItem/Job wasn't populated by the initial load, bring in the chain via a second
// query; EF Core identity-map fixup will propagate the navigation to the tracked coat.
var coatJobCompanyId = coat.JobItem?.Job?.CompanyId;
if (coatJobCompanyId == null)
{
await _unitOfWork.JobItemCoats.LoadWithJobChainAsync(coatId);
coatJobCompanyId = coat.JobItem?.Job?.CompanyId;
}
if (!_tenantContext.IsSuperAdmin() && coatJobCompanyId != _tenantContext.GetCurrentCompanyId())
return Json(new { success = false, message = "Access denied." });
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
coat.PowderReceived = true;
coat.PowderReceivedAt = DateTime.UtcNow;
coat.PowderReceivedByUserId = userId;
coat.PowderReceivedLbs = lbsReceived;
if (coat.InventoryItemId.HasValue && coat.InventoryItem != null)
{
var item = coat.InventoryItem;
var previousBalance = item.QuantityOnHand;
item.QuantityOnHand += lbsReceived;
item.LastPurchaseDate = DateTime.UtcNow;
if (coat.PowderCostPerLb.HasValue)
item.LastPurchasePrice = coat.PowderCostPerLb.Value;
var transaction = new InventoryTransaction
{
CompanyId = item.CompanyId,
InventoryItemId = item.Id,
TransactionType = InventoryTransactionType.Purchase,
Quantity = lbsReceived,
UnitCost = coat.PowderCostPerLb ?? item.UnitCost,
TotalCost = lbsReceived * (coat.PowderCostPerLb ?? item.UnitCost),
TransactionDate = DateTime.UtcNow,
Notes = $"Received {lbsReceived:N2} lbs for job order",
BalanceAfter = previousBalance + lbsReceived,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
}
await _unitOfWork.CompleteAsync();
return Json(new { success = true, updatedInventory = coat.InventoryItemId.HasValue });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error receiving powder for coat {CoatId}", coatId);
return Json(new { success = false, message = "An error occurred while recording the receipt." });
}
}
/// <summary>
/// Creates a new inventory item from a custom (non-catalogued) powder order and immediately
/// marks the originating coat as received. After creating the item, the action scans other
/// open job coats in the same company for matching color (by color code first, then color name)
/// and links them to the new inventory item so future powder receipt correctly updates stock.
/// A <c>Purchase</c> inventory transaction is written for the opening balance. Only custom
/// powders — those with no existing <c>InventoryItemId</c> — should reach this action; the
/// standard receive flow is <see cref="ReceivePowder"/>.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AddCustomPowderToInventory(
int coatId, string sku, string itemName, decimal lbsReceived,
int? inventoryCategoryId, string? manufacturer, string? description,
string? colorName, string? colorCode, string? finish,
string? vendorPartNumber, int? primaryVendorId,
decimal? unitCost, decimal reorderPoint, decimal reorderQuantity,
string? location, string? notes)
{
try
{
var coat = await _unitOfWork.JobItemCoats.GetByIdAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat record not found." });
if (string.IsNullOrWhiteSpace(sku))
return Json(new { success = false, message = "SKU is required." });
if (string.IsNullOrWhiteSpace(itemName))
return Json(new { success = false, message = "Item name is required." });
if (lbsReceived <= 0)
return Json(new { success = false, message = "Quantity received must be greater than zero." });
// Resolve company id from the job chain; fall back to tenant context
var jobItem = await _unitOfWork.JobItems.FirstOrDefaultAsync(
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
// Check SKU uniqueness within this company
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim() && i.CompanyId == companyId))
return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." });
// Determine category display name for legacy field
string categoryDisplay = string.Empty;
if (inventoryCategoryId.HasValue)
{
var cat = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(inventoryCategoryId.Value);
categoryDisplay = cat?.DisplayName ?? string.Empty;
}
var inventoryItem = new InventoryItem
{
CompanyId = companyId,
SKU = sku.Trim(),
Name = itemName.Trim(),
Description = description?.Trim(),
InventoryCategoryId = inventoryCategoryId,
Category = categoryDisplay,
Manufacturer = manufacturer?.Trim(),
ColorName = colorName?.Trim(),
ColorCode = colorCode?.Trim(),
Finish = finish?.Trim(),
VendorPartNumber = vendorPartNumber?.Trim(),
PrimaryVendorId = primaryVendorId,
QuantityOnHand = lbsReceived,
UnitOfMeasure = "lbs",
UnitCost = unitCost ?? 0,
LastPurchasePrice = unitCost ?? 0,
LastPurchaseDate = DateTime.UtcNow,
ReorderPoint = reorderPoint,
ReorderQuantity = reorderQuantity,
Location = location?.Trim(),
Notes = notes?.Trim(),
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
// Enrich from the platform powder catalog so the new inventory record carries the full
// spec/doc set (cure schedule, SDS/TDS, sample image, color families) rather than just
// the color code/name carried on the quote. Match by the catalog SKU (stored as the
// coat's colorCode), preferring the same manufacturer; fall back to color name.
await EnrichInventoryFromCatalogAsync(inventoryItem, colorCode, colorName, manufacturer);
var linkedCount = await FinalizeReceivedPowderAsync(
coat, inventoryItem, lbsReceived, companyId, colorCode, colorName, primaryVendorId,
jobItem?.Job?.JobNumber);
return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding custom powder to inventory for coat {CoatId}", coatId);
return Json(new { success = false, message = "An error occurred while saving." });
}
}
/// <summary>
/// Finds the platform powder catalog row for an inventory/coat identity: by catalog SKU
/// (stored as the coat's color code), preferring the same manufacturer, then by color name.
/// Returns null when no match is found.
/// </summary>
private async Task<PowderCatalogItem?> FindCatalogByIdentityAsync(
string? colorCode, string? colorName, string? manufacturer)
{
var code = colorCode?.Trim();
if (!string.IsNullOrWhiteSpace(code))
{
var codeLower = code.ToLower();
var hits = (await _unitOfWork.PowderCatalog.FindAsync(p => p.Sku.ToLower() == codeLower)).ToList();
var mfr = manufacturer?.Trim().ToLower();
var match = (!string.IsNullOrWhiteSpace(mfr)
? hits.FirstOrDefault(p => p.VendorName.ToLower().Contains(mfr))
: null)
?? hits.FirstOrDefault();
if (match != null)
return match;
}
if (!string.IsNullOrWhiteSpace(colorName))
{
var nameLower = colorName.Trim().ToLower();
return (await _unitOfWork.PowderCatalog.FindAsync(p => p.ColorName.ToLower() == nameLower))
.FirstOrDefault();
}
return null;
}
/// <summary>
/// Copies catalog spec/document fields onto an inventory item — cure schedule, coverage,
/// specific gravity, transfer efficiency, SDS/TDS links, sample image, color families, product
/// page — and links <see cref="InventoryItem.PowderCatalogItemId"/>. Only fills gaps, so any
/// value already set (e.g. entered on the receive form) is preserved.
/// </summary>
private static void ApplyCatalogToInventory(InventoryItem item, PowderCatalogItem catalog)
{
item.PowderCatalogItemId = catalog.Id;
if (string.IsNullOrWhiteSpace(item.ManufacturerPartNumber)) item.ManufacturerPartNumber = catalog.Sku;
if (string.IsNullOrWhiteSpace(item.Manufacturer)) item.Manufacturer = catalog.VendorName;
if (string.IsNullOrWhiteSpace(item.ColorName)) item.ColorName = catalog.ColorName;
if (string.IsNullOrWhiteSpace(item.Finish)) item.Finish = catalog.Finish;
if (string.IsNullOrWhiteSpace(item.ColorFamilies)) item.ColorFamilies = catalog.ColorFamilies;
if (string.IsNullOrWhiteSpace(item.ImageUrl)) item.ImageUrl = catalog.ImageUrl;
if (string.IsNullOrWhiteSpace(item.SdsUrl)) item.SdsUrl = catalog.SdsUrl;
if (string.IsNullOrWhiteSpace(item.TdsUrl)) item.TdsUrl = catalog.TdsUrl;
if (string.IsNullOrWhiteSpace(item.SpecPageUrl)) item.SpecPageUrl = catalog.ProductUrl;
item.CureTemperatureF ??= catalog.CureTemperatureF;
item.CureTimeMinutes ??= catalog.CureTimeMinutes;
item.SpecificGravity ??= catalog.SpecificGravity;
item.CoverageSqFtPerLb ??= catalog.CoverageSqFtPerLb ?? 30m;
item.TransferEfficiency ??= catalog.TransferEfficiency ?? 65m;
if (!item.RequiresClearCoat && catalog.RequiresClearCoat == true)
item.RequiresClearCoat = true;
if (item.UnitCost <= 0 && catalog.UnitPrice > 0)
{
item.UnitCost = catalog.UnitPrice;
item.LastPurchasePrice = catalog.UnitPrice;
}
// Quoting reference price (current catalog list price) — separate from cost basis above.
if (catalog.UnitPrice > 0)
{
item.CatalogReferencePrice = catalog.UnitPrice;
item.CatalogPriceUpdatedAt = DateTime.UtcNow;
}
}
/// <summary>
/// Fills blank spec/document fields on a received custom-powder inventory item from the matching
/// platform powder catalog row, so the tenant gets a complete record instead of just the color
/// code/name carried on the quote. No-op when the powder isn't in the catalog.
/// </summary>
private async Task EnrichInventoryFromCatalogAsync(
InventoryItem item, string? colorCode, string? colorName, string? manufacturer)
{
var catalog = await FindCatalogByIdentityAsync(colorCode, colorName, manufacturer);
if (catalog == null)
return;
// First use — lazily fill specific gravity / cure from the TDS before copying onto the item.
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalog);
ApplyCatalogToInventory(item, catalog);
}
/// <summary>
/// Shared finalize for a received powder: saves the inventory item, writes the opening Purchase
/// transaction, marks the coat received and links it, then links any sibling coats ordering the
/// same color. Returns the number of additional coats linked. Used by both the manual modal
/// (<see cref="AddCustomPowderToInventory"/>) and the catalog auto-receive
/// (<see cref="ReceivePowderFromCatalog"/>).
/// </summary>
private async Task<int> FinalizeReceivedPowderAsync(
JobItemCoat coat, InventoryItem inventoryItem, decimal lbsReceived, int companyId,
string? colorCode, string? colorName, int? primaryVendorId, string? jobNumber)
{
await _unitOfWork.InventoryItems.AddAsync(inventoryItem);
await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id
var transaction = new InventoryTransaction
{
CompanyId = companyId,
InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.Purchase,
Quantity = lbsReceived,
UnitCost = inventoryItem.UnitCost,
TotalCost = lbsReceived * inventoryItem.UnitCost,
TransactionDate = DateTime.UtcNow,
Notes = $"Initial stock — received from powder order for job {jobNumber}",
BalanceAfter = lbsReceived,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
coat.PowderReceived = true;
coat.PowderReceivedAt = DateTime.UtcNow;
coat.PowderReceivedByUserId = userId;
coat.PowderReceivedLbs = lbsReceived;
coat.InventoryItemId = inventoryItem.Id;
var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coat.Id, companyId);
var linkedCount = 0;
foreach (var other in candidateCoats)
{
var colorMatch = !string.IsNullOrWhiteSpace(colorCode)
? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase)
: !string.IsNullOrWhiteSpace(colorName) &&
string.Equals(other.ColorName?.Trim(), colorName.Trim(), StringComparison.OrdinalIgnoreCase);
if (!colorMatch) continue;
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId) continue;
other.InventoryItemId = inventoryItem.Id;
linkedCount++;
}
if (linkedCount > 0)
_logger.LogInformation("Linked {Count} additional coat(s) to new inventory item {ItemId} ({SKU})",
linkedCount, inventoryItem.Id, inventoryItem.SKU);
await _unitOfWork.CompleteAsync();
return linkedCount;
}
/// <summary>
/// Generates a unique powder SKU for a company in the form <c>{CODE}-{YYMM}-{####}</c>, where
/// CODE is the (padded) inventory category code. Mirrors the inventory SKU pattern used when
/// adding catalog-sourced powders.
/// </summary>
private async Task<string> GeneratePowderSkuAsync(InventoryCategoryLookup category)
{
var code = category.CategoryCode.Length >= 4
? category.CategoryCode[..4].ToUpperInvariant()
: category.CategoryCode.ToUpperInvariant().PadRight(4, 'X');
var yearMonth = DateTime.Now.ToString("yyMM");
var prefix = $"{code}-{yearMonth}-";
var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
var maxSeq = allItems
.Where(i => i.SKU.StartsWith(prefix))
.Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
.DefaultIfEmpty(0)
.Max();
return $"{prefix}{(maxSeq + 1):D4}";
}
/// <summary>
/// Receives an ordered custom powder straight into inventory WITHOUT the manual modal when the
/// powder is already in the master catalog — the new record is fully populated from the catalog
/// (specs, SDS/TDS, image, pricing). Returns <c>needsDetails = true</c> (without saving) when
/// the powder isn't in the catalog or no coating category is configured, signaling the caller to
/// fall back to the manual entry modal.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ReceivePowderFromCatalog(int coatId, decimal lbsReceived)
{
try
{
if (lbsReceived <= 0)
return Json(new { success = false, message = "Quantity received must be greater than zero." });
var coat = await _unitOfWork.JobItemCoats.GetByIdAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat record not found." });
var jobItem = await _unitOfWork.JobItems.FirstOrDefaultAsync(
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
// Only auto-receive when the powder resolves in the master catalog; otherwise the caller
// opens the manual modal.
var catalog = await FindCatalogByIdentityAsync(coat.ColorCode, coat.ColorName, null);
if (catalog == null)
return Json(new { success = false, needsDetails = true });
// First use — lazily fill specific gravity / cure from the TDS so the new record is complete.
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalog);
// Resolve the company's POWDER (coating) inventory category.
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
?? categories.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder).FirstOrDefault();
if (coatingCategory == null)
return Json(new { success = false, needsDetails = true });
var sku = await GeneratePowderSkuAsync(coatingCategory);
var inventoryItem = new InventoryItem
{
CompanyId = companyId,
SKU = sku,
Name = catalog.ColorName,
ColorName = catalog.ColorName,
ColorCode = coat.ColorCode,
InventoryCategoryId = coatingCategory.Id,
Category = coatingCategory.DisplayName,
QuantityOnHand = lbsReceived,
UnitOfMeasure = "lbs",
UnitCost = catalog.UnitPrice,
LastPurchasePrice = catalog.UnitPrice,
LastPurchaseDate = DateTime.UtcNow,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
ApplyCatalogToInventory(inventoryItem, catalog);
var linkedCount = await FinalizeReceivedPowderAsync(
coat, inventoryItem, lbsReceived, companyId, coat.ColorCode, coat.ColorName, null,
jobItem?.Job?.JobNumber);
return Json(new
{
success = true,
fromCatalog = true,
itemName = inventoryItem.Name,
sku = inventoryItem.SKU,
linkedCount
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error auto-receiving powder from catalog for coat {CoatId}", coatId);
return Json(new { success = false, message = "An error occurred while saving." });
}
}
/// <summary>
/// Platform-level dashboard visible only to SuperAdmins who are not impersonating a tenant.
/// Displays a cross-company overview: total/active/inactive company counts, user count,
/// subscription plan distribution (sourced from DB-backed <c>SubscriptionPlanConfig</c> so
/// display names stay in sync), companies currently in their grace period or expired, and
/// the 10 most recently created companies. Regular dashboard traffic is routed here from
/// <see cref="Index"/> when the session contains no impersonation context.
/// </summary>
[Authorize(Roles = "SuperAdmin")]
public async Task<IActionResult> SuperAdminDashboard()
{
try
{
var today = DateTime.Today;
var data = await _dashboardRead.GetSuperAdminDashboardDataAsync(today);
var vm = new SuperAdminDashboardViewModel
{
TotalCompanies = data.TotalCompanies,
ActiveCompanies = data.ActiveCompanies,
InactiveCompanies = data.InactiveCompanies,
TotalUsers = data.TotalUsers,
PlanDistribution = data.PlanDistribution.ToDictionary(
kvp => kvp.Key,
kvp => (kvp.Value.DisplayName, kvp.Value.Count)),
ActiveSubscriptions = data.ActiveSubscriptions,
GracePeriodCount = data.GracePeriodCount,
ExpiredCount = data.ExpiredCount,
CompanyAlerts = data.CompanyAlerts.Select(c => new PlatformCompanyAlertDto
{
Id = c.Id,
CompanyName = c.CompanyName,
Plan = c.Plan,
PlanDisplayName = c.PlanDisplayName,
Status = c.Status,
SubscriptionEndDate = c.SubscriptionEndDate,
DaysOverdue = c.DaysOverdue,
IsActive = c.IsActive
}).ToList(),
RecentCompanies = data.RecentCompanies.Select(c => new PlatformRecentCompanyDto
{
Id = c.Id,
CompanyName = c.CompanyName,
Plan = c.Plan,
PlanDisplayName = c.PlanDisplayName,
Status = c.Status,
IsActive = c.IsActive,
CreatedAt = c.CreatedAt
}).ToList()
};
return View(vm);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading SuperAdmin dashboard");
TempData["Error"] = "An error occurred while loading the platform dashboard.";
return View(new SuperAdminDashboardViewModel());
}
}
/// <summary>
/// Projects per-coat rows into vendor-grouped DTOs one-line-per-coat.
/// Used for the "Awaiting Receipt" panel where each coat is received individually.
/// </summary>
private static List<PowderOrderVendorGroupDto> MapPowderOrderGroups(
IEnumerable<DashboardPowderOrderLineData> lines) =>
lines.GroupBy(l => l.VendorId)
.Select(g =>
{
var first = g.First();
return new PowderOrderVendorGroupDto
{
VendorId = g.Key,
VendorName = first.VendorName ?? "No Vendor Assigned",
VendorPhone = first.VendorPhone,
VendorEmail = first.VendorEmail,
TotalLbsNeeded = g.Sum(l => l.LbsToOrder),
TotalEstCost = g.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0m),
Lines = g.Select(l => new PowderOrderLineDto
{
CoatIds = new List<int> { l.CoatId },
Jobs = new List<PowderOrderJobRefDto>
{
new() { JobId = l.JobId, JobNumber = l.JobNumber, CustomerName = l.CustomerName, LbsToOrder = l.LbsToOrder }
},
CoatName = l.CoatName,
ColorName = l.ColorName,
ColorCode = l.ColorCode,
Finish = l.Finish,
SKU = l.SKU,
LbsToOrder = l.LbsToOrder,
CostPerLb = l.CostPerLb,
OrderedAt = l.OrderedAt,
HasInventoryItem = l.HasInventoryItem,
VendorId = l.VendorId
})
.OrderBy(l => l.OrderedAt ?? DateTime.MinValue)
.ThenBy(l => l.JobNumber)
.ThenBy(l => l.CoatName)
.ToList()
};
})
.OrderBy(g => g.VendorName)
.ToList();
/// <summary>
/// Like <see cref="MapPowderOrderGroups"/> but collapses coats for the same powder within
/// a vendor group into one line, summing lbs and accumulating coat IDs and job refs.
/// Used for the "Powder in Queue to be Ordered" panel so you order one batch per color.
/// Two coats are considered the same powder when ColorName, ColorCode, Finish, and SKU
/// all match (case- and whitespace-insensitive).
/// </summary>
private static List<PowderOrderVendorGroupDto> MapPowderOrderGroupsMerged(
IEnumerable<DashboardPowderOrderLineData> lines) =>
lines.GroupBy(l => l.VendorId)
.Select(vendorGrp =>
{
var first = vendorGrp.First();
var mergedLines = vendorGrp
.GroupBy(l => (
ColorName: l.ColorName?.Trim().ToLowerInvariant() ?? "",
ColorCode: l.ColorCode?.Trim().ToLowerInvariant() ?? "",
Finish: l.Finish?.Trim().ToLowerInvariant() ?? "",
SKU: l.SKU?.Trim().ToLowerInvariant() ?? ""
))
.Select(powderGrp =>
{
var p = powderGrp.First();
return new PowderOrderLineDto
{
CoatIds = powderGrp.Select(l => l.CoatId).ToList(),
Jobs = powderGrp.Select(l => new PowderOrderJobRefDto
{
JobId = l.JobId,
JobNumber = l.JobNumber,
CustomerName = l.CustomerName,
LbsToOrder = l.LbsToOrder
}).ToList(),
CoatName = p.CoatName,
ColorName = p.ColorName,
ColorCode = p.ColorCode,
Finish = p.Finish,
SKU = p.SKU,
LbsToOrder = powderGrp.Sum(l => l.LbsToOrder),
CostPerLb = p.CostPerLb,
HasInventoryItem = p.HasInventoryItem,
VendorId = p.VendorId
};
})
.OrderBy(l => l.ColorName)
.ThenBy(l => l.CoatName)
.ToList();
return new PowderOrderVendorGroupDto
{
VendorId = vendorGrp.Key,
VendorName = first.VendorName ?? "No Vendor Assigned",
VendorPhone = first.VendorPhone,
VendorEmail = first.VendorEmail,
TotalLbsNeeded = vendorGrp.Sum(l => l.LbsToOrder),
TotalEstCost = vendorGrp.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0m),
Lines = mergedLines
};
})
.OrderBy(g => g.VendorName)
.ToList();
/// <summary>
/// Projects a <see cref="Core.Entities.Job"/> into a lightweight <see cref="DashboardJobDto"/>
/// for use in dashboard job lists. Centralising the mapping in one static helper ensures that
/// all three job panels (today, overdue, in-progress) render identical column data without
/// duplicating the projection expression.
/// </summary>
private static DashboardJobDto MapJobDto(Core.Entities.Job j) => new()
{
Id = j.Id,
JobNumber = j.JobNumber,
CustomerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName)
? j.Customer.CompanyName
: $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(),
Description = j.Description,
StatusCode = j.JobStatus.StatusCode,
StatusDisplayName = j.JobStatus.DisplayName,
StatusColorClass = j.JobStatus.ColorClass,
PriorityCode = j.JobPriority.PriorityCode,
PriorityDisplayName = j.JobPriority.DisplayName,
PriorityColorClass = j.JobPriority.ColorClass,
ScheduledDate = j.ScheduledDate,
DueDate = j.DueDate,
AssignedWorkerName = j.AssignedUser?.FullName
};
}