1cb7a8ca4a
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>
981 lines
46 KiB
C#
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
|
|
};
|
|
}
|