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 _logger; private readonly IDashboardReadService _dashboardRead; private readonly ITenantContext _tenantContext; private readonly ICompanyConfigHealthService _configHealth; private readonly UserManager _userManager; private readonly ISubscriptionService _subscriptionService; public DashboardController( IUnitOfWork unitOfWork, ILogger logger, IDashboardReadService dashboardRead, ITenantContext tenantContext, ICompanyConfigHealthService configHealth, UserManager userManager, ISubscriptionService subscriptionService) { _unitOfWork = unitOfWork; _logger = logger; _dashboardRead = dashboardRead; _tenantContext = tenantContext; _configHealth = configHealth; _userManager = userManager; _subscriptionService = subscriptionService; } /// /// 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 . SuperAdmins who are not currently impersonating a company /// are redirected to instead. /// public async Task 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 = MapPowderOrderGroups(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 }; // Dropdowns for the "Add Custom Powder to Inventory" modal var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.GetAllAsync()) .Where(c => c.IsActive) .OrderBy(c => c.DisplayOrder) .Select(c => new { c.Id, c.DisplayName }) .ToList(); var vendors = (await _unitOfWork.Vendors.GetAllAsync()) .Where(v => v.IsActive) .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 var currentCompanyId = _tenantContext.GetCurrentCompanyId(); 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); } 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()); } } /// /// Marks a job-item coat as having its powder ordered. Called via AJAX from the Powder Orders /// Needed panel. Verifies company ownership through the parent job (JobItemCoat has no direct /// CompanyId) before updating PowderOrdered, PowderOrderedAt, and /// PowderOrderedByUserId on the coat record. /// [HttpPost] [ValidateAntiForgeryToken] public async Task MarkPowderOrdered(int coatId) { try { var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId); if (coat == null) return Json(new { success = false, message = "Coat not found." }); // JobItemCoat has no CompanyId — verify ownership via parent job var parentCompanyId = coat.JobItem?.Job?.CompanyId; if (!_tenantContext.IsSuperAdmin() && parentCompanyId != _tenantContext.GetCurrentCompanyId()) return Json(new { success = false, message = "Access denied." }); coat.PowderOrdered = true; coat.PowderOrderedAt = DateTime.UtcNow; coat.PowderOrderedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; await _unitOfWork.CompleteAsync(); var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor; var job = coat.JobItem?.Job; return Json(new { success = true, coat = new { coatId = coat.Id, jobId = job?.Id, jobNumber = job?.JobNumber, customerName = job?.Customer?.CompanyName, 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 } }); } catch (Exception ex) { _logger.LogError(ex, "Error marking coat {CoatId} as powder ordered", coatId); 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" }; } /// /// 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. /// private async Task 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 { 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; } /// /// Persists the company admin's dismissal of the progress widget completion state. /// Sets GuidedActivationDismissedAt so the widget stays hidden across devices /// and browser sessions (localStorage alone wouldn't survive a cleared cache). /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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 }); } /// /// Records receipt of a powder shipment against an existing powder order. Sets /// PowderReceived, PowderReceivedLbs, and PowderReceivedAt on the coat, /// and — when the coat is linked to an inventory item — increases QuantityOnHand and /// writes a Purchase so the stock movement is fully /// traceable. Company ownership is verified through the parent job because JobItemCoat /// carries no CompanyId of its own. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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." }); } } /// /// 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 Purchase inventory transaction is written for the opening balance. Only custom /// powders — those with no existing InventoryItemId — should reach this action; the /// standard receive flow is . /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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 if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim())) 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, }; await _unitOfWork.InventoryItems.AddAsync(inventoryItem); await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id // Opening stock transaction var transaction = new InventoryTransaction { CompanyId = companyId, InventoryItemId = inventoryItem.Id, TransactionType = InventoryTransactionType.Purchase, Quantity = lbsReceived, UnitCost = unitCost ?? 0, TotalCost = lbsReceived * (unitCost ?? 0), TransactionDate = DateTime.UtcNow, Notes = $"Initial stock — received from powder order for job {jobItem?.Job?.JobNumber}", BalanceAfter = lbsReceived, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; await _unitOfWork.InventoryTransactions.AddAsync(transaction); // Mark coat as received and link to the new inventory item 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; // Scan for sibling coats with the same custom powder and link them to the new item var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coatId, companyId); int linkedCount = 0; foreach (var other in candidateCoats) { bool 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 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." }); } } /// /// 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 SubscriptionPlanConfig 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 /// when the session contains no impersonation context. /// [Authorize(Roles = "SuperAdmin")] public async Task 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()); } } private static List MapPowderOrderGroups( IEnumerable 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 { CoatId = l.CoatId, JobId = l.JobId, JobNumber = l.JobNumber, CustomerName = l.CustomerName, 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(); /// /// Projects a into a lightweight /// 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. /// 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 }; }