Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/DashboardController.cs
T
spouliot 1cb7a8ca4a Phases 3 & 4: Complete data access architecture migration
Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers,
routing all data access through IUnitOfWork. Added IPlainRepository<T> for
the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote)
that intentionally don't extend BaseEntity and therefore can't use the
constrained IRepository<T>. Added permanent-exception comments to the 18
controllers that legitimately retain direct DbContext access (Identity infra,
cross-tenant platform ops, bulk streaming exports).

Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup
gate that reflects over every Controller subclass and throws at boot if any
non-exempt controller injects ApplicationDbContext. The app cannot start with
a violation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:17:29 -04:00

981 lines
46 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
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 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 static readonly string[] CompletedStatusCodes =
[
"COMPLETED",
"READY_FOR_PICKUP",
"DELIVERED",
"CANCELLED"
];
private static readonly string[] InProgressStatusCodes =
[
"IN_PREPARATION",
"SANDBLASTING",
"MASKING_TAPING",
"CLEANING",
"IN_OVEN",
"COATING",
"CURING",
"QUALITY_CHECK"
];
public DashboardController(
IUnitOfWork unitOfWork,
ILogger<DashboardController> logger,
IDashboardReadService dashboardRead,
ITenantContext tenantContext,
ICompanyConfigHealthService configHealth)
{
_unitOfWork = unitOfWork;
_logger = logger;
_dashboardRead = dashboardRead;
_tenantContext = tenantContext;
_configHealth = configHealth;
}
/// <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 lookAheadDate = today.AddDays(7);
var data = await _dashboardRead.GetIndexDataAsync(today);
// ---------------------------------------------------------------
// Job panels — in-memory split of the pre-fetched activeJobs list
// ---------------------------------------------------------------
var todaysJobsFiltered = data.ActiveJobs
.Where(j => (j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today) ||
(j.DueDate.HasValue && j.DueDate.Value.Date == today));
var todaysJobsCount = todaysJobsFiltered.Count();
var todaysJobs = todaysJobsFiltered
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.ScheduledDate ?? j.DueDate)
.Take(10)
.Select(MapJobDto)
.ToList();
var overdueJobsFiltered = data.ActiveJobs
.Where(j => j.DueDate.HasValue && j.DueDate.Value.Date < today);
var overdueJobsCount = overdueJobsFiltered.Count();
var overdueJobs = overdueJobsFiltered
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.DueDate)
.Take(10)
.Select(MapJobDto)
.ToList();
var inProgressJobs = data.ActiveJobs
.Where(j => InProgressStatusCodes.Contains(j.JobStatus.StatusCode))
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.ScheduledDate)
.Take(10)
.Select(MapJobDto)
.ToList();
// ---------------------------------------------------------------
// Appointments
// ---------------------------------------------------------------
var todaysAppointmentsCount = data.TodaysAppointments.Count;
var todaysAppointments = data.TodaysAppointments
.Take(10)
.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 lowStockAll = await _unitOfWork.InventoryItems.FindAsync(
i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
var lowStockCount = lowStockAll.Count();
var lowStockItems = lowStockAll
.OrderBy(i => i.QuantityOnHand)
.Take(10)
.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
.OrderBy(q => q.ExpirationDate)
.Take(10)
.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 pendingQuoteValue = data.PendingQuotes.Sum(q => q.Total);
var expiringQuotes = data.PendingQuotes
.Where(q => q.ExpirationDate.HasValue
&& q.ExpirationDate.Value.Date >= today
&& q.ExpirationDate.Value.Date <= lookAheadDate)
.OrderBy(q => q.ExpirationDate)
.Take(10)
.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();
// ---------------------------------------------------------------
// Active customers
// ---------------------------------------------------------------
var activeCustomersCount = await _unitOfWork.Customers.CountAsync(c => c.IsActive);
// ---------------------------------------------------------------
// Invoices & AR aging
// ---------------------------------------------------------------
var outstandingAr = data.OpenInvoices.Sum(i => i.BalanceDue);
var overdueInvoicesList = data.OpenInvoices
.Where(i => i.DueDate.HasValue && i.DueDate.Value.Date < today)
.OrderBy(i => i.DueDate)
.ToList();
var overdueInvoicesCount = overdueInvoicesList.Count;
var overdueInvoicesAmount = overdueInvoicesList.Sum(i => i.BalanceDue);
var overdueInvoices = overdueInvoicesList
.Take(6)
.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();
// AR Aging buckets
decimal agingCurrent = 0, aging1To30 = 0, aging31To60 = 0, aging61To90 = 0, agingOver90 = 0;
foreach (var inv in data.OpenInvoices)
{
if (!inv.DueDate.HasValue || inv.DueDate.Value.Date >= today)
{
agingCurrent += inv.BalanceDue;
}
else
{
var daysLate = (int)(today - inv.DueDate.Value.Date).TotalDays;
if (daysLate <= 30) aging1To30 += inv.BalanceDue;
else if (daysLate <= 60) aging31To60 += inv.BalanceDue;
else if (daysLate <= 90) aging61To90 += inv.BalanceDue;
else agingOver90 += inv.BalanceDue;
}
}
// ---------------------------------------------------------------
// 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 = (await _unitOfWork.Equipment.FindAsync(
e => e.Status == EquipmentStatus.NeedsMaintenance ||
e.Status == EquipmentStatus.OutOfService))
.OrderByDescending(e => e.Status == EquipmentStatus.OutOfService ? 1 : 0)
.Take(5)
.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 powderFlat = data.JobsNeedingPowder
.SelectMany(j => j.JobItems
.SelectMany(i => i.Coats
.Where(c => !c.IsDeleted && !c.PowderOrdered && c.PowderToOrder > 0
&& (c.InventoryItemId == null || c.InventoryItem!.QuantityOnHand < c.PowderToOrder))
.Select(c =>
{
var vendor = c.Vendor ?? c.InventoryItem?.PrimaryVendor;
return new
{
CoatId = c.Id,
JobId = j.Id,
JobNumber = j.JobNumber,
CustomerName = j.Customer?.CompanyName ?? j.Customer?.ContactFirstName ?? "Unknown",
CoatName = c.CoatName,
ColorName = c.ColorName ?? c.InventoryItem?.ColorName,
ColorCode = c.ColorCode ?? c.InventoryItem?.ColorCode,
Finish = c.Finish ?? c.InventoryItem?.Finish,
SKU = c.InventoryItem?.SKU,
LbsToOrder = c.PowderToOrder!.Value,
CostPerLb = c.PowderCostPerLb ?? c.InventoryItem?.UnitCost,
VendorId = vendor?.Id,
VendorName = vendor?.CompanyName,
VendorPhone = vendor?.Phone,
VendorEmail = vendor?.Email,
};
})))
.ToList();
var powderOrderGroups = powderFlat
.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 : 0),
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,
}).OrderBy(l => l.JobNumber).ThenBy(l => l.CoatName).ToList()
};
})
.OrderBy(g => g.VendorName)
.ToList();
// ---------------------------------------------------------------
// Powder orders placed
// ---------------------------------------------------------------
var placedFlat = data.JobsWithOrderedPowder
.SelectMany(j => j.JobItems
.SelectMany(i => i.Coats
.Where(c => !c.IsDeleted && c.PowderOrdered && !c.PowderReceived)
.Select(c =>
{
var vendor = c.Vendor ?? c.InventoryItem?.PrimaryVendor;
return new
{
CoatId = c.Id,
JobId = j.Id,
JobNumber = j.JobNumber,
CustomerName = j.Customer?.CompanyName ?? j.Customer?.ContactFirstName ?? "Unknown",
CoatName = c.CoatName,
ColorName = c.ColorName ?? c.InventoryItem?.ColorName,
ColorCode = c.ColorCode ?? c.InventoryItem?.ColorCode,
Finish = c.Finish ?? c.InventoryItem?.Finish,
SKU = c.InventoryItem?.SKU,
LbsToOrder = c.PowderToOrder ?? 0m,
CostPerLb = c.PowderCostPerLb ?? c.InventoryItem?.UnitCost,
OrderedAt = c.PowderOrderedAt,
HasInventoryItem = c.InventoryItemId.HasValue,
VendorId = vendor?.Id,
VendorName = vendor?.CompanyName,
VendorPhone = vendor?.Phone,
VendorEmail = vendor?.Email,
};
})))
.ToList();
var powderPlacedGroups = placedFlat
.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 : 0),
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).ThenBy(l => l.JobNumber).ToList()
};
})
.OrderBy(g => g.VendorName)
.ToList();
// ---------------------------------------------------------------
// Bills due
// ---------------------------------------------------------------
var billsDue = data.BillsDue.Select(b => new DashboardBillDto
{
Id = b.Id,
BillNumber = b.BillNumber,
VendorName = b.Vendor.CompanyName,
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.ActiveJobs.Count,
TodaysJobsCount = todaysJobsCount,
OverdueJobsCount = overdueJobsCount,
TodaysAppointmentsCount = todaysAppointmentsCount,
LowStockCount = lowStockCount,
PendingMaintenanceCount = data.UpcomingMaintenance.Count,
PendingQuotesCount = data.PendingQuotes.Count,
PendingQuoteValue = pendingQuoteValue,
MonthlyRevenue = data.MonthlyRevenue,
ActiveCustomersCount = activeCustomersCount,
// Financial KPIs
OutstandingAr = outstandingAr,
CollectedThisMonth = data.CollectedThisMonth,
InvoicedThisMonth = data.InvoicedThisMonth,
OverdueInvoicesCount = overdueInvoicesCount,
OverdueInvoicesAmount = overdueInvoicesAmount,
AgingCurrent = agingCurrent,
AgingDays1To30 = aging1To30,
AgingDays31To60 = aging31To60,
AgingDays61To90 = aging61To90,
AgingDaysOver90 = agingOver90,
// 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 = billsDue.Count,
BillsDueAmount = billsDue.Sum(b => b.BalanceDue),
// Powder orders
PowderOrdersNeeded = powderOrderGroups,
PowderOrdersNeededCount = powderFlat.Count,
PowderOrdersPlaced = powderPlacedGroups,
PowderOrdersPlacedCount = placedFlat.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);
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 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 <c>PowderOrdered</c>, <c>PowderOrderedAt</c>, and
/// <c>PowderOrderedByUserId</c> on the coat record.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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." });
}
}
/// <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
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." });
}
}
/// <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 allCompanies = await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true);
var companies = allCompanies.Where(c => !c.IsDeleted).ToList();
var totalUsers = await _dashboardRead.GetTotalUserCountAsync();
var graceCutoff = today.AddDays(-AppConstants.SubscriptionConstants.GracePeriodDays);
var planConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true))
.OrderBy(c => c.SortOrder)
.ToList();
var planLookup = planConfigs.ToDictionary(c => c.Plan, c => c.DisplayName);
string PlanName(int plan) => planLookup.TryGetValue(plan, out var name) ? name : plan.ToString();
var companyAlerts = companies
.Where(c => c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value.Date < today)
.OrderBy(c => c.SubscriptionEndDate)
.Take(20)
.Select(c =>
{
var daysOverdue = (int)(today - c.SubscriptionEndDate!.Value.Date).TotalDays;
return new PlatformCompanyAlertDto
{
Id = c.Id,
CompanyName = c.CompanyName,
Plan = c.SubscriptionPlan,
PlanDisplayName = PlanName(c.SubscriptionPlan),
Status = c.SubscriptionStatus,
SubscriptionEndDate = c.SubscriptionEndDate,
DaysOverdue = daysOverdue,
IsActive = c.IsActive
};
})
.ToList();
var recentCompanies = companies
.OrderByDescending(c => c.CreatedAt)
.Take(10)
.Select(c => new PlatformRecentCompanyDto
{
Id = c.Id,
CompanyName = c.CompanyName,
Plan = c.SubscriptionPlan,
PlanDisplayName = PlanName(c.SubscriptionPlan),
Status = c.SubscriptionStatus,
IsActive = c.IsActive,
CreatedAt = c.CreatedAt
})
.ToList();
var planDistribution = planConfigs.ToDictionary(
c => c.Plan,
c => (c.DisplayName, companies.Count(comp => comp.SubscriptionPlan == c.Plan)));
var vm = new SuperAdminDashboardViewModel
{
TotalCompanies = companies.Count,
ActiveCompanies = companies.Count(c => c.IsActive),
InactiveCompanies = companies.Count(c => !c.IsActive),
TotalUsers = totalUsers,
PlanDistribution = planDistribution,
ActiveSubscriptions = companies.Count(c => c.SubscriptionStatus == SubscriptionStatus.Active),
GracePeriodCount = companies.Count(c => c.SubscriptionStatus == SubscriptionStatus.GracePeriod),
ExpiredCount = companies.Count(c =>
c.SubscriptionStatus == SubscriptionStatus.Expired ||
c.SubscriptionStatus == SubscriptionStatus.Canceled),
CompanyAlerts = companyAlerts,
RecentCompanies = recentCompanies
};
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 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
};
}