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>
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
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
|
||||
using PowderCoating.Core.Enums; // Still needed for MaintenanceStatus, EquipmentStatus, PaymentMethod
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces.Services;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using InvoiceStatus = PowderCoating.Core.Enums.InvoiceStatus;
|
||||
|
||||
@@ -17,7 +16,9 @@ public class DashboardController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<DashboardController> _logger;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IDashboardReadService _dashboardRead;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ICompanyConfigHealthService _configHealth;
|
||||
|
||||
private static readonly string[] CompletedStatusCodes =
|
||||
[
|
||||
@@ -39,14 +40,16 @@ public class DashboardController : Controller
|
||||
"QUALITY_CHECK"
|
||||
];
|
||||
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ICompanyConfigHealthService _configHealth;
|
||||
|
||||
public DashboardController(IUnitOfWork unitOfWork, ILogger<DashboardController> logger, ApplicationDbContext context, ITenantContext tenantContext, ICompanyConfigHealthService configHealth)
|
||||
public DashboardController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ILogger<DashboardController> logger,
|
||||
IDashboardReadService dashboardRead,
|
||||
ITenantContext tenantContext,
|
||||
ICompanyConfigHealthService configHealth)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
_context = context;
|
||||
_dashboardRead = dashboardRead;
|
||||
_tenantContext = tenantContext;
|
||||
_configHealth = configHealth;
|
||||
}
|
||||
@@ -66,23 +69,14 @@ public class DashboardController : Controller
|
||||
try
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
var startOfMonth = new DateTime(today.Year, today.Month, 1);
|
||||
var endOfMonth = startOfMonth.AddMonths(1).AddDays(-1);
|
||||
var lookAheadDate = today.AddDays(7); // Changed to 7 days for expiring quotes
|
||||
var lookAheadDate = today.AddDays(7);
|
||||
|
||||
// Active jobs — filter completed/cancelled statuses at database level
|
||||
var activeJobs = await _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.AssignedUser)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobPriority)
|
||||
.Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode))
|
||||
.ToListAsync();
|
||||
var data = await _dashboardRead.GetIndexDataAsync(today);
|
||||
|
||||
var tomorrow = today.AddDays(1);
|
||||
|
||||
// Today's Jobs
|
||||
var todaysJobsFiltered = activeJobs
|
||||
// ---------------------------------------------------------------
|
||||
// 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();
|
||||
@@ -93,8 +87,7 @@ public class DashboardController : Controller
|
||||
.Select(MapJobDto)
|
||||
.ToList();
|
||||
|
||||
// Overdue Jobs
|
||||
var overdueJobsFiltered = activeJobs
|
||||
var overdueJobsFiltered = data.ActiveJobs
|
||||
.Where(j => j.DueDate.HasValue && j.DueDate.Value.Date < today);
|
||||
var overdueJobsCount = overdueJobsFiltered.Count();
|
||||
var overdueJobs = overdueJobsFiltered
|
||||
@@ -104,8 +97,7 @@ public class DashboardController : Controller
|
||||
.Select(MapJobDto)
|
||||
.ToList();
|
||||
|
||||
// In-Progress Jobs
|
||||
var inProgressJobs = activeJobs
|
||||
var inProgressJobs = data.ActiveJobs
|
||||
.Where(j => InProgressStatusCodes.Contains(j.JobStatus.StatusCode))
|
||||
.OrderBy(j => j.JobPriority.DisplayOrder)
|
||||
.ThenBy(j => j.ScheduledDate)
|
||||
@@ -113,26 +105,11 @@ public class DashboardController : Controller
|
||||
.Select(MapJobDto)
|
||||
.ToList();
|
||||
|
||||
// Monthly Revenue — aggregate at database level (no need to load all jobs)
|
||||
var monthlyRevenue = await _context.Jobs
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
|
||||
&& j.UpdatedAt >= startOfMonth
|
||||
&& j.UpdatedAt <= endOfMonth)
|
||||
.SumAsync(j => j.FinalPrice);
|
||||
|
||||
// Today's Appointments — filter at database level
|
||||
var todaysAppointmentsRaw = await _context.Appointments
|
||||
.Include(a => a.Customer)
|
||||
.Include(a => a.AppointmentType)
|
||||
.Include(a => a.AppointmentStatus)
|
||||
.Include(a => a.AssignedUser)
|
||||
.Where(a => a.ScheduledStartTime >= today && a.ScheduledStartTime < tomorrow
|
||||
&& a.AppointmentStatus.StatusCode != "CANCELLED")
|
||||
.OrderBy(a => a.ScheduledStartTime)
|
||||
.ToListAsync();
|
||||
var todaysAppointmentsCount = todaysAppointmentsRaw.Count;
|
||||
var todaysAppointments = todaysAppointmentsRaw
|
||||
// ---------------------------------------------------------------
|
||||
// Appointments
|
||||
// ---------------------------------------------------------------
|
||||
var todaysAppointmentsCount = data.TodaysAppointments.Count;
|
||||
var todaysAppointments = data.TodaysAppointments
|
||||
.Take(10)
|
||||
.Select(a => new DashboardAppointmentDto
|
||||
{
|
||||
@@ -150,9 +127,11 @@ public class DashboardController : Controller
|
||||
AssignedWorkerName = a.AssignedUser?.FullName
|
||||
}).ToList();
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Low stock items
|
||||
// ---------------------------------------------------------------
|
||||
var lowStockAll = await _unitOfWork.InventoryItems.FindAsync(
|
||||
i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||||
i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||||
var lowStockCount = lowStockAll.Count();
|
||||
var lowStockItems = lowStockAll
|
||||
.OrderBy(i => i.QuantityOnHand)
|
||||
@@ -168,21 +147,10 @@ public class DashboardController : Controller
|
||||
UnitOfMeasure = i.UnitOfMeasure
|
||||
}).ToList();
|
||||
|
||||
// Maintenance records — filter to pending/overdue at database level
|
||||
var upcomingMaintenance = await _context.MaintenanceRecords
|
||||
.Include(m => m.Equipment)
|
||||
.Include(m => m.AssignedUser)
|
||||
.Where(m => (m.Status == MaintenanceStatus.Scheduled
|
||||
|| m.Status == MaintenanceStatus.InProgress
|
||||
|| m.Status == MaintenanceStatus.Overdue)
|
||||
&& (m.Status == MaintenanceStatus.Overdue || m.ScheduledDate <= lookAheadDate))
|
||||
.OrderBy(m => m.Status == MaintenanceStatus.Overdue ? 0 : 1)
|
||||
.ThenByDescending(m => m.Priority)
|
||||
.ThenBy(m => m.ScheduledDate)
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
|
||||
var upcomingMaintenanceDtos = upcomingMaintenance
|
||||
// ---------------------------------------------------------------
|
||||
// Maintenance
|
||||
// ---------------------------------------------------------------
|
||||
var upcomingMaintenanceDtos = data.UpcomingMaintenance
|
||||
.Select(m => new DashboardMaintenanceDto
|
||||
{
|
||||
Id = m.Id,
|
||||
@@ -195,14 +163,10 @@ public class DashboardController : Controller
|
||||
AssignedWorkerName = m.AssignedUser?.FullName
|
||||
}).ToList();
|
||||
|
||||
// Pending Quotes — filter to SENT status at database level
|
||||
var pendingQuotesData = await _context.Quotes
|
||||
.Include(q => q.Customer)
|
||||
.Include(q => q.QuoteStatus)
|
||||
.Where(q => q.QuoteStatus.StatusCode == "SENT")
|
||||
.ToListAsync();
|
||||
|
||||
var pendingQuotes = pendingQuotesData
|
||||
// ---------------------------------------------------------------
|
||||
// Quotes
|
||||
// ---------------------------------------------------------------
|
||||
var pendingQuotes = data.PendingQuotes
|
||||
.OrderBy(q => q.ExpirationDate)
|
||||
.Take(10)
|
||||
.Select(q => new DashboardQuoteDto
|
||||
@@ -221,10 +185,9 @@ public class DashboardController : Controller
|
||||
StatusDisplayName = q.QuoteStatus.DisplayName
|
||||
}).ToList();
|
||||
|
||||
var pendingQuoteValue = pendingQuotesData.Sum(q => q.Total);
|
||||
var pendingQuoteValue = data.PendingQuotes.Sum(q => q.Total);
|
||||
|
||||
// Expiring Quotes (next 7 days) - filter at database level
|
||||
var expiringQuotes = pendingQuotesData
|
||||
var expiringQuotes = data.PendingQuotes
|
||||
.Where(q => q.ExpirationDate.HasValue
|
||||
&& q.ExpirationDate.Value.Date >= today
|
||||
&& q.ExpirationDate.Value.Date <= lookAheadDate)
|
||||
@@ -246,33 +209,17 @@ public class DashboardController : Controller
|
||||
StatusDisplayName = q.QuoteStatus.DisplayName
|
||||
}).ToList();
|
||||
|
||||
// Active Customers
|
||||
// ---------------------------------------------------------------
|
||||
// Active customers
|
||||
// ---------------------------------------------------------------
|
||||
var activeCustomersCount = await _unitOfWork.Customers.CountAsync(c => c.IsActive);
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Financial data — Invoices & Payments
|
||||
// Invoices & AR aging
|
||||
// ---------------------------------------------------------------
|
||||
var openStatuses = new[] { InvoiceStatus.Sent, InvoiceStatus.PartiallyPaid, InvoiceStatus.Overdue };
|
||||
var outstandingAr = data.OpenInvoices.Sum(i => i.BalanceDue);
|
||||
|
||||
// Open invoices only — filter at database level
|
||||
var openInvoices = await _context.Invoices
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => openStatuses.Contains(i.Status))
|
||||
.ToListAsync();
|
||||
|
||||
var outstandingAr = openInvoices.Sum(i => i.BalanceDue);
|
||||
|
||||
// Invoiced this month — aggregate at database level
|
||||
var invoicedThisMonth = await _context.Invoices
|
||||
.Where(i => i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.Status != InvoiceStatus.WrittenOff
|
||||
&& i.InvoiceDate >= startOfMonth
|
||||
&& i.InvoiceDate <= endOfMonth)
|
||||
.SumAsync(i => i.Total);
|
||||
|
||||
// Overdue invoices: open and past due date
|
||||
var overdueInvoicesList = openInvoices
|
||||
var overdueInvoicesList = data.OpenInvoices
|
||||
.Where(i => i.DueDate.HasValue && i.DueDate.Value.Date < today)
|
||||
.OrderBy(i => i.DueDate)
|
||||
.ToList();
|
||||
@@ -295,9 +242,9 @@ public class DashboardController : Controller
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// AR Aging — bucket open invoices by days past due
|
||||
// AR Aging buckets
|
||||
decimal agingCurrent = 0, aging1To30 = 0, aging31To60 = 0, aging61To90 = 0, agingOver90 = 0;
|
||||
foreach (var inv in openInvoices)
|
||||
foreach (var inv in data.OpenInvoices)
|
||||
{
|
||||
if (!inv.DueDate.HasValue || inv.DueDate.Value.Date >= today)
|
||||
{
|
||||
@@ -313,17 +260,10 @@ public class DashboardController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Payments this month — aggregate at database level
|
||||
var collectedThisMonth = await _context.Payments
|
||||
.Where(p => p.PaymentDate >= startOfMonth && p.PaymentDate <= endOfMonth)
|
||||
.SumAsync(p => p.Amount);
|
||||
|
||||
// Recent payments — load only the 6 most recent
|
||||
var recentPayments = (await _context.Payments
|
||||
.Include(p => p.Invoice).ThenInclude(i => i!.Customer)
|
||||
.OrderByDescending(p => p.PaymentDate)
|
||||
.Take(6)
|
||||
.ToListAsync())
|
||||
// ---------------------------------------------------------------
|
||||
// Payments
|
||||
// ---------------------------------------------------------------
|
||||
var recentPayments = data.RecentPayments
|
||||
.Select(p => new DashboardPaymentDto
|
||||
{
|
||||
Id = p.Id,
|
||||
@@ -335,44 +275,39 @@ public class DashboardController : Controller
|
||||
PaymentDate = p.PaymentDate,
|
||||
PaymentMethodDisplay = p.PaymentMethod switch
|
||||
{
|
||||
PowderCoating.Core.Enums.PaymentMethod.Cash => "Cash",
|
||||
PowderCoating.Core.Enums.PaymentMethod.Check => "Check",
|
||||
PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard => "Card",
|
||||
PowderCoating.Core.Enums.PaymentMethod.BankTransferACH => "ACH",
|
||||
PowderCoating.Core.Enums.PaymentMethod.DigitalPayment => "Digital",
|
||||
PaymentMethod.Cash => "Cash",
|
||||
PaymentMethod.Check => "Check",
|
||||
PaymentMethod.CreditDebitCard => "Card",
|
||||
PaymentMethod.BankTransferACH => "ACH",
|
||||
PaymentMethod.DigitalPayment => "Digital",
|
||||
_ => "Other"
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Equipment Alerts - filter at database level
|
||||
// ---------------------------------------------------------------
|
||||
// Equipment alerts
|
||||
// ---------------------------------------------------------------
|
||||
var equipmentAlerts = (await _unitOfWork.Equipment.FindAsync(
|
||||
e => e.Status == Core.Enums.EquipmentStatus.NeedsMaintenance ||
|
||||
e.Status == Core.Enums.EquipmentStatus.OutOfService))
|
||||
.OrderByDescending(e => e.Status == Core.Enums.EquipmentStatus.OutOfService ? 1 : 0)
|
||||
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 == Core.Enums.EquipmentStatus.OutOfService ? "Out of Service" : "Needs Maintenance",
|
||||
Severity = e.Status == Core.Enums.EquipmentStatus.OutOfService ? "Critical" : "Warning",
|
||||
Issue = e.Status == EquipmentStatus.OutOfService ? "Out of Service" : "Needs Maintenance",
|
||||
Severity = e.Status == EquipmentStatus.OutOfService ? "Critical" : "Warning",
|
||||
LastMaintenanceDate = e.LastMaintenanceDate,
|
||||
NextMaintenanceDue = null // Equipment doesn't track next maintenance due date
|
||||
NextMaintenanceDue = null
|
||||
}).ToList();
|
||||
|
||||
// Recent Activity (last 10 quotes or jobs created in last 30 days)
|
||||
var last30Days = today.AddDays(-30);
|
||||
|
||||
// Recent quotes — filter to last 30 days at database level
|
||||
var recentQuotes = (await _context.Quotes
|
||||
.Include(q => q.Customer)
|
||||
.Include(q => q.QuoteStatus)
|
||||
.Where(q => q.CreatedAt >= last30Days)
|
||||
.OrderByDescending(q => q.CreatedAt)
|
||||
.Take(5)
|
||||
.ToListAsync())
|
||||
// ---------------------------------------------------------------
|
||||
// Recent activity
|
||||
// ---------------------------------------------------------------
|
||||
var recentQuoteDtos = data.RecentQuotes
|
||||
.Select(q => new DashboardRecentActivityDto
|
||||
{
|
||||
Id = q.Id,
|
||||
@@ -390,14 +325,7 @@ public class DashboardController : Controller
|
||||
Amount = q.Total
|
||||
});
|
||||
|
||||
// Recent jobs — filter to last 30 days at database level
|
||||
var recentJobs = (await _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => j.CreatedAt >= last30Days)
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.Take(5)
|
||||
.ToListAsync())
|
||||
var recentJobDtos = data.RecentJobs
|
||||
.Select(j => new DashboardRecentActivityDto
|
||||
{
|
||||
Id = j.Id,
|
||||
@@ -413,33 +341,15 @@ public class DashboardController : Controller
|
||||
Amount = j.FinalPrice
|
||||
});
|
||||
|
||||
var recentActivity = recentQuotes.Concat(recentJobs)
|
||||
var recentActivity = recentQuoteDtos.Concat(recentJobDtos)
|
||||
.OrderByDescending(a => a.ActivityDate)
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
// === POWDER ORDERS NEEDED ===
|
||||
var jobsNeedingPowder = await _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobItems)
|
||||
.ThenInclude(i => i.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.ThenInclude(inv => inv!.PrimaryVendor)
|
||||
.Include(j => j.JobItems)
|
||||
.ThenInclude(i => i.Coats)
|
||||
.ThenInclude(c => c.Vendor)
|
||||
.Where(j => !j.IsDeleted
|
||||
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
|
||||
&& j.JobItems.Any(i => i.Coats.Any(c =>
|
||||
!c.IsDeleted &&
|
||||
!c.PowderOrdered &&
|
||||
c.PowderToOrder > 0 &&
|
||||
(c.InventoryItemId == null || c.InventoryItem!.QuantityOnHand < c.PowderToOrder))))
|
||||
.ToListAsync();
|
||||
|
||||
// Flatten to individual coat lines that need ordering (with vendor info for grouping)
|
||||
var powderFlat = jobsNeedingPowder
|
||||
// ---------------------------------------------------------------
|
||||
// Powder orders needed
|
||||
// ---------------------------------------------------------------
|
||||
var powderFlat = data.JobsNeedingPowder
|
||||
.SelectMany(j => j.JobItems
|
||||
.SelectMany(i => i.Coats
|
||||
.Where(c => !c.IsDeleted && !c.PowderOrdered && c.PowderToOrder > 0
|
||||
@@ -500,26 +410,10 @@ public class DashboardController : Controller
|
||||
.OrderBy(g => g.VendorName)
|
||||
.ToList();
|
||||
|
||||
// === POWDER ORDERS PLACED (ordered, awaiting receipt) ===
|
||||
var jobsWithOrderedPowder = await _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobItems)
|
||||
.ThenInclude(i => i.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.ThenInclude(inv => inv!.PrimaryVendor)
|
||||
.Include(j => j.JobItems)
|
||||
.ThenInclude(i => i.Coats)
|
||||
.ThenInclude(c => c.Vendor)
|
||||
.Where(j => !j.IsDeleted
|
||||
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
|
||||
&& j.JobItems.Any(i => i.Coats.Any(c =>
|
||||
!c.IsDeleted &&
|
||||
c.PowderOrdered &&
|
||||
!c.PowderReceived)))
|
||||
.ToListAsync();
|
||||
|
||||
var placedFlat = jobsWithOrderedPowder
|
||||
// ---------------------------------------------------------------
|
||||
// Powder orders placed
|
||||
// ---------------------------------------------------------------
|
||||
var placedFlat = data.JobsWithOrderedPowder
|
||||
.SelectMany(j => j.JobItems
|
||||
.SelectMany(i => i.Coats
|
||||
.Where(c => !c.IsDeleted && c.PowderOrdered && !c.PowderReceived)
|
||||
@@ -584,16 +478,10 @@ public class DashboardController : Controller
|
||||
.OrderBy(g => g.VendorName)
|
||||
.ToList();
|
||||
|
||||
// === BILLS DUE ===
|
||||
var billsDueRaw = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Where(b => !b.IsDeleted &&
|
||||
(b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid) &&
|
||||
b.Total > b.AmountPaid)
|
||||
.OrderBy(b => b.DueDate)
|
||||
.Take(15)
|
||||
.ToListAsync();
|
||||
var billsDue = billsDueRaw.Select(b => new DashboardBillDto
|
||||
// ---------------------------------------------------------------
|
||||
// Bills due
|
||||
// ---------------------------------------------------------------
|
||||
var billsDue = data.BillsDue.Select(b => new DashboardBillDto
|
||||
{
|
||||
Id = b.Id,
|
||||
BillNumber = b.BillNumber,
|
||||
@@ -608,21 +496,21 @@ public class DashboardController : Controller
|
||||
var vm = new DashboardViewModel
|
||||
{
|
||||
// Counts
|
||||
ActiveJobsCount = activeJobs.Count(),
|
||||
ActiveJobsCount = data.ActiveJobs.Count,
|
||||
TodaysJobsCount = todaysJobsCount,
|
||||
OverdueJobsCount = overdueJobsCount,
|
||||
TodaysAppointmentsCount = todaysAppointmentsCount,
|
||||
LowStockCount = lowStockCount,
|
||||
PendingMaintenanceCount = upcomingMaintenance.Count,
|
||||
PendingQuotesCount = pendingQuotesData.Count(),
|
||||
PendingMaintenanceCount = data.UpcomingMaintenance.Count,
|
||||
PendingQuotesCount = data.PendingQuotes.Count,
|
||||
PendingQuoteValue = pendingQuoteValue,
|
||||
MonthlyRevenue = monthlyRevenue,
|
||||
MonthlyRevenue = data.MonthlyRevenue,
|
||||
ActiveCustomersCount = activeCustomersCount,
|
||||
|
||||
// Financial KPIs
|
||||
OutstandingAr = outstandingAr,
|
||||
CollectedThisMonth = collectedThisMonth,
|
||||
InvoicedThisMonth = invoicedThisMonth,
|
||||
CollectedThisMonth = data.CollectedThisMonth,
|
||||
InvoicedThisMonth = data.InvoicedThisMonth,
|
||||
OverdueInvoicesCount = overdueInvoicesCount,
|
||||
OverdueInvoicesAmount = overdueInvoicesAmount,
|
||||
AgingCurrent = agingCurrent,
|
||||
@@ -654,7 +542,9 @@ public class DashboardController : Controller
|
||||
PowderOrdersNeeded = powderOrderGroups,
|
||||
PowderOrdersNeededCount = powderFlat.Count,
|
||||
PowderOrdersPlaced = powderPlacedGroups,
|
||||
PowderOrdersPlacedCount = placedFlat.Count
|
||||
PowderOrdersPlacedCount = placedFlat.Count,
|
||||
|
||||
TipOfTheDay = data.TipOfTheDay
|
||||
};
|
||||
|
||||
// Dropdowns for the "Add Custom Powder to Inventory" modal
|
||||
@@ -671,13 +561,6 @@ public class DashboardController : Controller
|
||||
ViewBag.InventoryCategories = inventoryCategories;
|
||||
ViewBag.VendorList = vendors;
|
||||
|
||||
// Random tip of the day
|
||||
var tips = await _context.DashboardTips
|
||||
.Where(t => t.IsActive)
|
||||
.ToListAsync();
|
||||
if (tips.Count > 0)
|
||||
vm.TipOfTheDay = tips[Random.Shared.Next(tips.Count)].TipText;
|
||||
|
||||
// Config health check — surface setup gaps to company admins
|
||||
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (currentCompanyId.HasValue)
|
||||
@@ -705,11 +588,7 @@ public class DashboardController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var coat = await _context.JobItemCoats
|
||||
.Include(c => c.JobItem).ThenInclude(i => i.Job).ThenInclude(j => j.Customer)
|
||||
.Include(c => c.Vendor)
|
||||
.Include(c => c.InventoryItem).ThenInclude(i => i!.PrimaryVendor)
|
||||
.FirstOrDefaultAsync(c => c.Id == coatId);
|
||||
var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId);
|
||||
|
||||
if (coat == null)
|
||||
return Json(new { success = false, message = "Coat not found." });
|
||||
@@ -722,7 +601,7 @@ public class DashboardController : Controller
|
||||
coat.PowderOrdered = true;
|
||||
coat.PowderOrderedAt = DateTime.UtcNow;
|
||||
coat.PowderOrderedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor;
|
||||
var job = coat.JobItem?.Job;
|
||||
@@ -761,9 +640,9 @@ public class DashboardController : Controller
|
||||
/// 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="PowderCoating.Core.Entities.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.
|
||||
/// 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]
|
||||
@@ -771,9 +650,8 @@ public class DashboardController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var coat = await _context.JobItemCoats
|
||||
.Include(c => c.InventoryItem)
|
||||
.FirstOrDefaultAsync(c => c.Id == coatId);
|
||||
// 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." });
|
||||
@@ -781,29 +659,25 @@ public class DashboardController : Controller
|
||||
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
|
||||
// (We need the job for company check; load it if not already included)
|
||||
// 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)
|
||||
{
|
||||
// Reload with parent chain if not included
|
||||
var coatWithJob = await _context.JobItemCoats
|
||||
.Include(c => c.JobItem).ThenInclude(i => i.Job)
|
||||
.FirstOrDefaultAsync(c => c.Id == coatId);
|
||||
coatJobCompanyId = coatWithJob?.JobItem?.Job?.CompanyId;
|
||||
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;
|
||||
|
||||
// Mark coat as received
|
||||
coat.PowderReceived = true;
|
||||
coat.PowderReceivedAt = DateTime.UtcNow;
|
||||
coat.PowderReceivedByUserId = userId;
|
||||
coat.PowderReceivedLbs = lbsReceived;
|
||||
|
||||
// Update inventory if this coat is linked to an inventory item
|
||||
if (coat.InventoryItemId.HasValue && coat.InventoryItem != null)
|
||||
{
|
||||
var item = coat.InventoryItem;
|
||||
@@ -813,26 +687,24 @@ public class DashboardController : Controller
|
||||
if (coat.PowderCostPerLb.HasValue)
|
||||
item.LastPurchasePrice = coat.PowderCostPerLb.Value;
|
||||
|
||||
// Record purchase transaction
|
||||
var transaction = new PowderCoating.Core.Entities.InventoryTransaction
|
||||
var transaction = new InventoryTransaction
|
||||
{
|
||||
CompanyId = item.CompanyId,
|
||||
InventoryItemId = item.Id,
|
||||
TransactionType = PowderCoating.Core.Enums.InventoryTransactionType.Purchase,
|
||||
TransactionType = InventoryTransactionType.Purchase,
|
||||
Quantity = lbsReceived,
|
||||
UnitCost = coat.PowderCostPerLb ?? item.UnitCost,
|
||||
TotalCost = lbsReceived * (coat.PowderCostPerLb ?? item.UnitCost),
|
||||
TransactionDate = DateTime.UtcNow,
|
||||
Reference = coat.JobItem != null ? null : null, // loaded below if needed
|
||||
Notes = $"Received {lbsReceived:N2} lbs for job order",
|
||||
BalanceAfter = previousBalance + lbsReceived,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_context.Set<PowderCoating.Core.Entities.InventoryTransaction>().Add(transaction);
|
||||
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true, updatedInventory = coat.InventoryItemId.HasValue });
|
||||
}
|
||||
@@ -864,7 +736,7 @@ public class DashboardController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var coat = await _context.JobItemCoats.FindAsync(coatId);
|
||||
var coat = await _unitOfWork.JobItemCoats.GetByIdAsync(coatId);
|
||||
if (coat == null)
|
||||
return Json(new { success = false, message = "Coat record not found." });
|
||||
|
||||
@@ -877,16 +749,13 @@ public class DashboardController : Controller
|
||||
if (lbsReceived <= 0)
|
||||
return Json(new { success = false, message = "Quantity received must be greater than zero." });
|
||||
|
||||
// Resolve company id from tenant context
|
||||
var companyId = (await _unitOfWork.InventoryItems.GetAllAsync()).FirstOrDefault()?.CompanyId ?? 0;
|
||||
// More reliably get CompanyId from the job chain
|
||||
var jobItem = await _context.JobItems.Include(i => i.Job).FirstOrDefaultAsync(i => i.Coats.Any(c => c.Id == coatId));
|
||||
if (jobItem?.Job != null)
|
||||
companyId = jobItem.Job.CompanyId;
|
||||
// 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
|
||||
var existingSku = await _context.InventoryItems.AnyAsync(i => i.SKU == sku.Trim());
|
||||
if (existingSku)
|
||||
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
|
||||
@@ -925,10 +794,10 @@ public class DashboardController : Controller
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
_context.InventoryItems.Add(inventoryItem);
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.InventoryItems.AddAsync(inventoryItem);
|
||||
await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id
|
||||
|
||||
// Record opening stock transaction
|
||||
// Opening stock transaction
|
||||
var transaction = new InventoryTransaction
|
||||
{
|
||||
CompanyId = companyId,
|
||||
@@ -943,7 +812,7 @@ public class DashboardController : Controller
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_context.Set<InventoryTransaction>().Add(transaction);
|
||||
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;
|
||||
@@ -953,19 +822,12 @@ public class DashboardController : Controller
|
||||
coat.PowderReceivedLbs = lbsReceived;
|
||||
coat.InventoryItemId = inventoryItem.Id;
|
||||
|
||||
// Scan for other active job coats using the same custom powder and link them
|
||||
var candidateCoats = await _context.JobItemCoats
|
||||
.Include(c => c.JobItem)
|
||||
.Where(c => !c.IsDeleted
|
||||
&& c.Id != coatId
|
||||
&& c.InventoryItemId == null
|
||||
&& c.JobItem.CompanyId == companyId)
|
||||
.ToListAsync();
|
||||
// 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)
|
||||
{
|
||||
// Match by color code first (most specific), then fall back to color name
|
||||
bool colorMatch = !string.IsNullOrWhiteSpace(colorCode)
|
||||
? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase)
|
||||
: !string.IsNullOrWhiteSpace(colorName) &&
|
||||
@@ -973,7 +835,6 @@ public class DashboardController : Controller
|
||||
|
||||
if (!colorMatch) continue;
|
||||
|
||||
// If both coats have a vendor set, they must agree
|
||||
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId)
|
||||
continue;
|
||||
|
||||
@@ -985,7 +846,7 @@ public class DashboardController : Controller
|
||||
_logger.LogInformation("Linked {Count} additional coat(s) to new inventory item {ItemId} ({SKU})",
|
||||
linkedCount, inventoryItem.Id, inventoryItem.SKU);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount });
|
||||
}
|
||||
@@ -1014,13 +875,10 @@ public class DashboardController : Controller
|
||||
var allCompanies = await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true);
|
||||
var companies = allCompanies.Where(c => !c.IsDeleted).ToList();
|
||||
|
||||
var totalUsers = await _context.Users
|
||||
.Where(u => u.CompanyId > 0)
|
||||
.CountAsync();
|
||||
var totalUsers = await _dashboardRead.GetTotalUserCountAsync();
|
||||
|
||||
var graceCutoff = today.AddDays(-AppConstants.SubscriptionConstants.GracePeriodDays);
|
||||
|
||||
// Load plan configs from DB so plan display names and distribution are DB-driven
|
||||
var planConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
|
||||
c => c.IsActive, ignoreQueryFilters: true))
|
||||
.OrderBy(c => c.SortOrder)
|
||||
@@ -1029,7 +887,6 @@ public class DashboardController : Controller
|
||||
var planLookup = planConfigs.ToDictionary(c => c.Plan, c => c.DisplayName);
|
||||
string PlanName(int plan) => planLookup.TryGetValue(plan, out var name) ? name : plan.ToString();
|
||||
|
||||
// Companies needing attention: expired (past grace) or in grace period
|
||||
var companyAlerts = companies
|
||||
.Where(c => c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value.Date < today)
|
||||
.OrderBy(c => c.SubscriptionEndDate)
|
||||
@@ -1066,7 +923,6 @@ public class DashboardController : Controller
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Build plan distribution from DB config (sorted by SortOrder)
|
||||
var planDistribution = planConfigs.ToDictionary(
|
||||
c => c.Plan,
|
||||
c => (c.DisplayName, companies.Count(comp => comp.SubscriptionPlan == c.Plan)));
|
||||
|
||||
Reference in New Issue
Block a user