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,9 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
|
||||
@@ -12,14 +10,14 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
public class AccountingExportController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly PowderCoating.Application.Interfaces.IAuditService _auditService;
|
||||
|
||||
public AccountingExportController(ApplicationDbContext context, ITenantContext tenantContext,
|
||||
public AccountingExportController(IUnitOfWork unitOfWork, ITenantContext tenantContext,
|
||||
PowderCoating.Application.Interfaces.IAuditService auditService)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_auditService = auditService;
|
||||
}
|
||||
@@ -60,42 +58,33 @@ public class AccountingExportController : Controller
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Export(DateTime startDate, DateTime endDate, string format)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var start = startDate.Date;
|
||||
var end = endDate.Date.AddDays(1).AddTicks(-1);
|
||||
|
||||
// ── Load data ─────────────────────────────────────────────────────────
|
||||
var invoices = await _context.Invoices
|
||||
.Include(i => i.InvoiceItems)
|
||||
.Include(i => i.Payments)
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => !i.IsDeleted && i.CompanyId == companyId
|
||||
&& i.InvoiceDate >= start && i.InvoiceDate <= end)
|
||||
var invoices = (await _unitOfWork.Invoices.FindAsync(
|
||||
i => i.InvoiceDate >= start && i.InvoiceDate <= end,
|
||||
false,
|
||||
i => i.InvoiceItems,
|
||||
i => i.Payments,
|
||||
i => i.Customer))
|
||||
.OrderBy(i => i.InvoiceDate)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
var expenses = await _context.Set<PowderCoating.Core.Entities.Expense>()
|
||||
.Include(e => e.Vendor)
|
||||
.Include(e => e.ExpenseAccount)
|
||||
.Include(e => e.PaymentAccount)
|
||||
.Where(e => !e.IsDeleted && e.CompanyId == companyId
|
||||
&& e.Date >= start && e.Date <= end)
|
||||
var expenses = (await _unitOfWork.Expenses.FindAsync(
|
||||
e => e.Date >= start && e.Date <= end,
|
||||
false,
|
||||
e => e.Vendor,
|
||||
e => e.ExpenseAccount,
|
||||
e => e.PaymentAccount))
|
||||
.OrderBy(e => e.Date)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
var bills = await _context.Set<PowderCoating.Core.Entities.Bill>()
|
||||
.Include(b => b.Vendor)
|
||||
.Include(b => b.LineItems).ThenInclude(l => l.Account)
|
||||
.Include(b => b.Payments)
|
||||
.Where(b => !b.IsDeleted && b.CompanyId == companyId
|
||||
&& b.BillDate >= start && b.BillDate <= end)
|
||||
.OrderBy(b => b.BillDate)
|
||||
.ToListAsync();
|
||||
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end);
|
||||
|
||||
var customers = await _context.Customers
|
||||
.Where(c => !c.IsDeleted && c.CompanyId == companyId)
|
||||
var customers = (await _unitOfWork.Customers.GetAllAsync())
|
||||
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
// ── Build ZIP ─────────────────────────────────────────────────────────
|
||||
using var ms = new MemoryStream();
|
||||
|
||||
@@ -8,7 +8,6 @@ using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -19,7 +18,6 @@ public class AiQuickQuoteController : Controller
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IAiQuickQuoteService _aiService;
|
||||
private readonly IPricingCalculationService _pricingService;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<AiQuickQuoteController> _logger;
|
||||
|
||||
@@ -27,14 +25,12 @@ public class AiQuickQuoteController : Controller
|
||||
IUnitOfWork unitOfWork,
|
||||
IAiQuickQuoteService aiService,
|
||||
IPricingCalculationService pricingService,
|
||||
ApplicationDbContext context,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<AiQuickQuoteController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_aiService = aiService;
|
||||
_pricingService = pricingService;
|
||||
_context = context;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -106,9 +102,7 @@ public class AiQuickQuoteController : Controller
|
||||
var walkIn = await GetOrCreateWalkInCustomerAsync(companyId);
|
||||
|
||||
// Draft status — nullable FK, gracefully absent if lookup not seeded
|
||||
var draftStatus = await _context.QuoteStatusLookups
|
||||
.Where(s => s.StatusCode == "DRAFT")
|
||||
.FirstOrDefaultAsync();
|
||||
var draftStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == "DRAFT");
|
||||
|
||||
var quoteNumber = await GenerateQuoteNumberAsync(companyId);
|
||||
var now = DateTime.UtcNow;
|
||||
@@ -300,21 +294,13 @@ public class AiQuickQuoteController : Controller
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var prefs = await _context.CompanyPreferences
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => p.CompanyId == companyId && !p.IsDeleted)
|
||||
.Select(p => new { p.QuoteNumberPrefix })
|
||||
.FirstOrDefaultAsync();
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
var quotePrefix = !string.IsNullOrWhiteSpace(prefs?.QuoteNumberPrefix) ? prefs.QuoteNumberPrefix : "QT";
|
||||
var prefix = $"{quotePrefix}-{now:yy}{now:MM}";
|
||||
|
||||
var lastQuoteNumber = await _context.Quotes
|
||||
.IgnoreQueryFilters()
|
||||
.Where(q => q.CompanyId == companyId && q.QuoteNumber.StartsWith(prefix))
|
||||
.OrderByDescending(q => q.QuoteNumber)
|
||||
.Select(q => q.QuoteNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
var lastQuoteNumber = await _unitOfWork.Quotes.GetLastQuoteNumberByPrefixAsync(companyId, prefix);
|
||||
|
||||
int nextNumber = 1;
|
||||
if (lastQuoteNumber != null)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -9,108 +9,74 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class AiUsageReportController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IAiUsageReportService _aiUsageReport;
|
||||
private readonly ILogger<AiUsageReportController> _logger;
|
||||
|
||||
public AiUsageReportController(ApplicationDbContext context, ILogger<AiUsageReportController> logger)
|
||||
public AiUsageReportController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IAiUsageReportService aiUsageReport,
|
||||
ILogger<AiUsageReportController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
_aiUsageReport = aiUsageReport;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Platform-wide AI usage report. Shows per-company call counts, photo upload totals, top
|
||||
/// feature used, and a usage tier so SuperAdmins can identify abusive or unusually heavy tenants.
|
||||
/// All queries use IgnoreQueryFilters() where needed to cross tenant boundaries.
|
||||
/// Companies and plan configs come from IUnitOfWork; AiUsageLogs aggregations and photo counts
|
||||
/// come from IAiUsageReportService (which runs SQL GROUP BY queries via ApplicationDbContext).
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var todayStart = now.Date;
|
||||
var last7Start = todayStart.AddDays(-7);
|
||||
var last30Start = todayStart.AddDays(-30);
|
||||
|
||||
// Companies (non-deleted only)
|
||||
var companies = await _context.Companies
|
||||
.IgnoreQueryFilters()
|
||||
var companies = (await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true))
|
||||
.Where(c => !c.IsDeleted)
|
||||
.Select(c => new { c.Id, c.CompanyName, c.SubscriptionPlan, c.IsActive })
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
// Plan display names from SubscriptionPlanConfig
|
||||
var planConfigs = await _context.Set<PowderCoating.Core.Entities.SubscriptionPlanConfig>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => !p.IsDeleted)
|
||||
.Select(p => new { p.Plan, p.DisplayName })
|
||||
.ToListAsync();
|
||||
var planConfigs = await _unitOfWork.SubscriptionPlanConfigs.GetAllAsync();
|
||||
var planNames = planConfigs.ToDictionary(p => p.Plan, p => p.DisplayName);
|
||||
|
||||
// All-time usage grouped by company — the four count windows are computed in SQL
|
||||
var usageByCompany = await _context.AiUsageLogs
|
||||
.GroupBy(l => l.CompanyId)
|
||||
.Select(g => new
|
||||
{
|
||||
CompanyId = g.Key,
|
||||
Today = g.Count(l => l.CalledAt >= todayStart),
|
||||
Last7Days = g.Count(l => l.CalledAt >= last7Start),
|
||||
Last30Days = g.Count(l => l.CalledAt >= last30Start),
|
||||
AllTime = g.Count()
|
||||
})
|
||||
.ToListAsync();
|
||||
var data = await _aiUsageReport.GetReportDataAsync();
|
||||
|
||||
// Top feature per company over the last 30 days
|
||||
var featureStats = await _context.AiUsageLogs
|
||||
.Where(l => l.CalledAt >= last30Start)
|
||||
.GroupBy(l => new { l.CompanyId, l.Feature })
|
||||
.Select(g => new { g.Key.CompanyId, g.Key.Feature, Count = g.Count() })
|
||||
.ToListAsync();
|
||||
|
||||
var topFeatureByCompany = featureStats
|
||||
var topFeatureByCompany = data.FeatureStats
|
||||
.GroupBy(f => f.CompanyId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.OrderByDescending(f => f.Count).First().Feature);
|
||||
|
||||
// Feature breakdown per company (last 30 days)
|
||||
var featureBreakdownByCompany = featureStats
|
||||
var featureBreakdownByCompany = data.FeatureStats
|
||||
.GroupBy(f => f.CompanyId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.ToDictionary(f => f.Feature, f => f.Count));
|
||||
|
||||
// Total AI photos per company (all time, including deleted photos)
|
||||
var photoCounts = await _context.QuotePhotos
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => p.IsAiAnalysisPhoto && !p.IsDeleted)
|
||||
.GroupBy(p => p.CompanyId)
|
||||
.Select(g => new { CompanyId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.CompanyId, x => x.Count);
|
||||
|
||||
// Build report rows
|
||||
var usageDict = usageByCompany.ToDictionary(u => u.CompanyId);
|
||||
var usageDict = data.UsageByCompany.ToDictionary(u => u.CompanyId);
|
||||
|
||||
var rows = companies.Select(c =>
|
||||
{
|
||||
usageDict.TryGetValue(c.Id, out var u);
|
||||
photoCounts.TryGetValue(c.Id, out var photos);
|
||||
data.PhotoCountsByCompany.TryGetValue(c.Id, out var photos);
|
||||
topFeatureByCompany.TryGetValue(c.Id, out var topFeature);
|
||||
featureBreakdownByCompany.TryGetValue(c.Id, out var breakdown);
|
||||
planNames.TryGetValue(c.SubscriptionPlan, out var planName);
|
||||
|
||||
return new AiUsageReportRow
|
||||
{
|
||||
CompanyId = c.Id,
|
||||
CompanyName = c.CompanyName,
|
||||
Plan = planName ?? $"Plan {c.SubscriptionPlan}",
|
||||
IsActive = c.IsActive,
|
||||
Today = u?.Today ?? 0,
|
||||
Last7Days = u?.Last7Days ?? 0,
|
||||
Last30Days = u?.Last30Days ?? 0,
|
||||
AllTime = u?.AllTime ?? 0,
|
||||
PhotoCount = photos,
|
||||
TopFeature = topFeature,
|
||||
CompanyId = c.Id,
|
||||
CompanyName = c.CompanyName,
|
||||
Plan = planName ?? $"Plan {c.SubscriptionPlan}",
|
||||
IsActive = c.IsActive,
|
||||
Today = u?.Today ?? 0,
|
||||
Last7Days = u?.Last7Days ?? 0,
|
||||
Last30Days = u?.Last30Days ?? 0,
|
||||
AllTime = u?.AllTime ?? 0,
|
||||
PhotoCount = photos,
|
||||
TopFeature = topFeature,
|
||||
FeatureBreakdown = breakdown ?? []
|
||||
};
|
||||
})
|
||||
@@ -118,15 +84,14 @@ public class AiUsageReportController : Controller
|
||||
.ThenByDescending(r => r.AllTime)
|
||||
.ToList();
|
||||
|
||||
// Platform totals for summary cards
|
||||
var vm = new AiUsageReportViewModel
|
||||
{
|
||||
Rows = rows,
|
||||
TotalCallsLast30Days = rows.Sum(r => r.Last30Days),
|
||||
TotalCallsToday = rows.Sum(r => r.Today),
|
||||
CompaniesActiveToday = rows.Count(r => r.Today > 0),
|
||||
TotalPhotosUploaded = rows.Sum(r => r.PhotoCount),
|
||||
MostActiveCompany = rows.FirstOrDefault(r => r.Last30Days > 0)?.CompanyName ?? "—"
|
||||
Rows = rows,
|
||||
TotalCallsLast30Days = rows.Sum(r => r.Last30Days),
|
||||
TotalCallsToday = rows.Sum(r => r.Today),
|
||||
CompaniesActiveToday = rows.Count(r => r.Today > 0),
|
||||
TotalPhotosUploaded = rows.Sum(r => r.PhotoCount),
|
||||
MostActiveCompany = rows.FirstOrDefault(r => r.Last30Days > 0)?.CompanyName ?? "—"
|
||||
};
|
||||
|
||||
return View(vm);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -11,12 +10,12 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class AnnouncementsController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IInAppNotificationService _inApp;
|
||||
|
||||
public AnnouncementsController(ApplicationDbContext db, IInAppNotificationService inApp)
|
||||
public AnnouncementsController(IUnitOfWork unitOfWork, IInAppNotificationService inApp)
|
||||
{
|
||||
_db = db;
|
||||
_unitOfWork = unitOfWork;
|
||||
_inApp = inApp;
|
||||
}
|
||||
|
||||
@@ -25,18 +24,18 @@ public class AnnouncementsController : Controller
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var announcements = await _db.Announcements
|
||||
var announcements = (await _unitOfWork.Announcements.GetAllAsync())
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
return View(announcements);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the announcement creation form with sensible defaults: starts now, dismissible, and active.
|
||||
/// </summary>
|
||||
public IActionResult Create()
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
PopulateDropdowns();
|
||||
await PopulateDropdownsAsync();
|
||||
return View(new Announcement { StartsAt = DateTime.Now, IsDismissible = true, IsActive = true });
|
||||
}
|
||||
|
||||
@@ -46,7 +45,7 @@ public class AnnouncementsController : Controller
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(Announcement model)
|
||||
{
|
||||
if (!ModelState.IsValid) { PopulateDropdowns(); return View(model); }
|
||||
if (!ModelState.IsValid) { await PopulateDropdownsAsync(); return View(model); }
|
||||
|
||||
model.CreatedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||
model.CreatedByUserName = User.Identity?.Name ?? "SuperAdmin";
|
||||
@@ -54,10 +53,9 @@ public class AnnouncementsController : Controller
|
||||
model.StartsAt = model.StartsAt.ToUniversalTime();
|
||||
if (model.ExpiresAt.HasValue) model.ExpiresAt = model.ExpiresAt.Value.ToUniversalTime();
|
||||
|
||||
_db.Announcements.Add(model);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.Announcements.AddAsync(model);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Dispatch as in-app notifications to targeted companies
|
||||
await DispatchNotificationsAsync(model);
|
||||
|
||||
TempData["Success"] = "Announcement created and sent as notifications.";
|
||||
@@ -69,9 +67,9 @@ public class AnnouncementsController : Controller
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var announcement = await _db.Announcements.FindAsync(id);
|
||||
var announcement = await _unitOfWork.Announcements.GetByIdAsync(id);
|
||||
if (announcement == null) return NotFound();
|
||||
PopulateDropdowns();
|
||||
await PopulateDropdownsAsync();
|
||||
return View(announcement);
|
||||
}
|
||||
|
||||
@@ -81,9 +79,9 @@ public class AnnouncementsController : Controller
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, Announcement model)
|
||||
{
|
||||
if (!ModelState.IsValid) { PopulateDropdowns(); return View(model); }
|
||||
if (!ModelState.IsValid) { await PopulateDropdownsAsync(); return View(model); }
|
||||
|
||||
var existing = await _db.Announcements.FindAsync(id);
|
||||
var existing = await _unitOfWork.Announcements.GetByIdAsync(id);
|
||||
if (existing == null) return NotFound();
|
||||
|
||||
existing.Title = model.Title;
|
||||
@@ -98,7 +96,7 @@ public class AnnouncementsController : Controller
|
||||
existing.IsActive = model.IsActive;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = "Announcement updated.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
@@ -109,49 +107,42 @@ public class AnnouncementsController : Controller
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var announcement = await _db.Announcements.FindAsync(id);
|
||||
var announcement = await _unitOfWork.Announcements.GetByIdAsync(id);
|
||||
if (announcement == null) return NotFound();
|
||||
_db.Announcements.Remove(announcement);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.Announcements.DeleteAsync(announcement);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = "Announcement deleted.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fans out the announcement as in-app notifications to each matching company. IgnoreQueryFilters is required to reach all active companies regardless of tenant context (this runs as SuperAdmin). Filtering by Target/Plan/Company happens before the foreach so only relevant tenants receive the notification.
|
||||
/// Fans out the announcement as in-app notifications to each matching company. IgnoreQueryFilters is required to reach all active companies regardless of tenant context. Filtering by Target/Plan/Company happens after the fetch so only relevant tenants receive the notification.
|
||||
/// </summary>
|
||||
private async Task DispatchNotificationsAsync(Announcement model)
|
||||
{
|
||||
IQueryable<PowderCoating.Core.Entities.Company> companyQuery = _db.Companies
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted && c.IsActive);
|
||||
var companies = (await _unitOfWork.Companies.FindAsync(
|
||||
c => !c.IsDeleted && c.IsActive, ignoreQueryFilters: true)).ToList();
|
||||
|
||||
if (model.Target == "Plan" && model.TargetPlan.HasValue)
|
||||
companyQuery = companyQuery.Where(c => c.SubscriptionPlan == model.TargetPlan.Value);
|
||||
companies = companies.Where(c => c.SubscriptionPlan == model.TargetPlan.Value).ToList();
|
||||
else if (model.Target == "Company" && model.TargetCompanyId.HasValue)
|
||||
companyQuery = companyQuery.Where(c => c.Id == model.TargetCompanyId.Value);
|
||||
companies = companies.Where(c => c.Id == model.TargetCompanyId.Value).ToList();
|
||||
|
||||
var companyIds = await companyQuery.Select(c => c.Id).ToListAsync();
|
||||
|
||||
foreach (var companyId in companyIds)
|
||||
{
|
||||
await _inApp.CreateAsync(
|
||||
companyId,
|
||||
model.Title,
|
||||
model.Message,
|
||||
"Announcement");
|
||||
}
|
||||
foreach (var company in companies)
|
||||
await _inApp.CreateAsync(company.Id, model.Title, model.Message, "Announcement");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads company and plan lists into ViewBag for the Create/Edit form dropdowns. Uses AsNoTracking and IgnoreQueryFilters to bypass soft-delete and tenant filters so all companies are available for targeting.
|
||||
/// Loads company and plan lists into ViewBag for the Create/Edit form dropdowns. Uses IgnoreQueryFilters to bypass soft-delete and tenant filters so all companies are available for targeting.
|
||||
/// </summary>
|
||||
private void PopulateDropdowns()
|
||||
private async Task PopulateDropdownsAsync()
|
||||
{
|
||||
ViewBag.Companies = _db.Companies.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted).OrderBy(c => c.CompanyName)
|
||||
.Select(c => new { c.Id, c.CompanyName }).ToList();
|
||||
ViewBag.PlanConfigs = _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToList();
|
||||
ViewBag.Companies = (await _unitOfWork.Companies.FindAsync(c => !c.IsDeleted, ignoreQueryFilters: true))
|
||||
.OrderBy(c => c.CompanyName)
|
||||
.Select(c => new { c.Id, c.CompanyName })
|
||||
.ToList();
|
||||
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(p => p.IsActive, ignoreQueryFilters: true))
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// the application — they are append-only by design to maintain an unambiguous
|
||||
/// trail of changes across all tenants.
|
||||
/// </summary>
|
||||
// Intentional exception: platform audit log with a long PK; append-only infrastructure table outside the business entity graph; same reasoning as SystemLogsController. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class AuditLogController : Controller
|
||||
{
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -15,28 +14,26 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class BannedIpsController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<BannedIpsController> _logger;
|
||||
|
||||
public BannedIpsController(
|
||||
ApplicationDbContext db,
|
||||
IUnitOfWork unitOfWork,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<BannedIpsController> logger)
|
||||
{
|
||||
_db = db;
|
||||
_unitOfWork = unitOfWork;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Lists all banned IPs, showing active and expired separately.</summary>
|
||||
// GET: BannedIps
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var bans = await _db.BannedIps
|
||||
var bans = (await _unitOfWork.BannedIps.GetAllAsync())
|
||||
.OrderByDescending(b => b.BannedAt)
|
||||
.ToListAsync();
|
||||
|
||||
.ToList();
|
||||
return View(bans);
|
||||
}
|
||||
|
||||
@@ -44,9 +41,7 @@ public class BannedIpsController : Controller
|
||||
/// Adds a new IP ban. Rejects obviously invalid formats but doesn't require
|
||||
/// a perfect regex — admins are trusted to enter valid IPs.
|
||||
/// </summary>
|
||||
// POST: BannedIps/Add
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Add(string ipAddress, string? reason, DateTime? expiresAt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ipAddress))
|
||||
@@ -57,15 +52,13 @@ public class BannedIpsController : Controller
|
||||
|
||||
ipAddress = ipAddress.Trim();
|
||||
|
||||
// Basic sanity check — must look like an IPv4 or IPv6 address
|
||||
if (!System.Net.IPAddress.TryParse(ipAddress, out _))
|
||||
{
|
||||
TempData["Error"] = $"'{ipAddress}' is not a valid IP address.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// Don't duplicate an active ban for the same IP
|
||||
var existing = await _db.BannedIps.FirstOrDefaultAsync(b => b.IpAddress == ipAddress && b.IsActive);
|
||||
var existing = await _unitOfWork.BannedIps.FirstOrDefaultAsync(b => b.IpAddress == ipAddress && b.IsActive);
|
||||
if (existing != null)
|
||||
{
|
||||
TempData["Error"] = $"{ipAddress} already has an active ban (added {existing.BannedAt:MMM dd, yyyy}).";
|
||||
@@ -74,7 +67,7 @@ public class BannedIpsController : Controller
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
_db.BannedIps.Add(new BannedIp
|
||||
await _unitOfWork.BannedIps.AddAsync(new BannedIp
|
||||
{
|
||||
IpAddress = ipAddress,
|
||||
Reason = reason?.Trim(),
|
||||
@@ -83,8 +76,7 @@ public class BannedIpsController : Controller
|
||||
ExpiresAt = expiresAt,
|
||||
IsActive = true
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogWarning("IP {IP} banned by {Admin}. Reason: {Reason}", ipAddress, User.Identity?.Name, reason);
|
||||
TempData["Success"] = $"{ipAddress} has been banned.";
|
||||
@@ -92,12 +84,10 @@ public class BannedIpsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>Lifts a ban immediately by marking IsActive = false.</summary>
|
||||
// POST: BannedIps/Lift/5
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Lift(int id)
|
||||
{
|
||||
var ban = await _db.BannedIps.FindAsync(id);
|
||||
var ban = await _unitOfWork.BannedIps.GetByIdAsync(id);
|
||||
if (ban == null)
|
||||
{
|
||||
TempData["Error"] = "Ban not found.";
|
||||
@@ -105,7 +95,7 @@ public class BannedIpsController : Controller
|
||||
}
|
||||
|
||||
ban.IsActive = false;
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("IP ban lifted for {IP} by {Admin}", ban.IpAddress, User.Identity?.Name);
|
||||
TempData["Success"] = $"Ban on {ban.IpAddress} has been lifted.";
|
||||
@@ -113,25 +103,21 @@ public class BannedIpsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>Permanently deletes a ban record.</summary>
|
||||
// POST: BannedIps/Delete/5
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var ban = await _db.BannedIps.FindAsync(id);
|
||||
var ban = await _unitOfWork.BannedIps.GetByIdAsync(id);
|
||||
if (ban != null)
|
||||
{
|
||||
_db.BannedIps.Remove(ban);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.BannedIps.DeleteAsync(ban);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
_logger.LogInformation("IP ban record deleted for {IP} by {Admin}", ban.IpAddress, User.Identity?.Name);
|
||||
TempData["Success"] = $"Ban record for {ban.IpAddress} deleted.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>Returns the requesting client's IP so the admin can pre-fill it quickly.</summary>
|
||||
// GET: BannedIps/MyIp
|
||||
public IActionResult MyIp()
|
||||
{
|
||||
return Json(new { ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown" });
|
||||
|
||||
@@ -10,7 +10,6 @@ using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -22,7 +21,6 @@ public class BugReportController : Controller
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IAdminNotificationService _adminNotification;
|
||||
private readonly IAzureBlobStorageService _blobService;
|
||||
@@ -40,7 +38,6 @@ public class BugReportController : Controller
|
||||
IMapper mapper,
|
||||
ITenantContext tenantContext,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ApplicationDbContext context,
|
||||
IEmailService emailService,
|
||||
IAdminNotificationService adminNotification,
|
||||
IAzureBlobStorageService blobService,
|
||||
@@ -51,7 +48,6 @@ public class BugReportController : Controller
|
||||
_mapper = mapper;
|
||||
_tenantContext = tenantContext;
|
||||
_userManager = userManager;
|
||||
_context = context;
|
||||
_emailService = emailService;
|
||||
_adminNotification = adminNotification;
|
||||
_blobService = blobService;
|
||||
@@ -153,7 +149,7 @@ public class BugReportController : Controller
|
||||
ContentType = file.ContentType,
|
||||
FileSizeBytes = file.Length
|
||||
};
|
||||
_context.BugReportAttachments.Add(attachment);
|
||||
await _unitOfWork.BugReportAttachments.AddAsync(attachment);
|
||||
uploadedCount++;
|
||||
}
|
||||
else
|
||||
@@ -164,7 +160,7 @@ public class BugReportController : Controller
|
||||
}
|
||||
|
||||
if (uploadedCount > 0)
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Bug report #{Id} submitted by {UserName} ({Company}): {Title} with {AttachmentCount} attachment(s)",
|
||||
@@ -211,16 +207,13 @@ public class BugReportController : Controller
|
||||
pageNumber = Math.Max(1, pageNumber);
|
||||
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
||||
|
||||
var query = _context.BugReports
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(r => !r.IsDeleted)
|
||||
.AsQueryable();
|
||||
var allReports = (await _unitOfWork.BugReports.GetAllAsync(ignoreQueryFilters: true))
|
||||
.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var search = searchTerm.ToLower();
|
||||
query = query.Where(r =>
|
||||
allReports = allReports.Where(r =>
|
||||
r.Title.ToLower().Contains(search) ||
|
||||
r.Description.ToLower().Contains(search) ||
|
||||
r.SubmittedByUserName.ToLower().Contains(search));
|
||||
@@ -228,31 +221,31 @@ public class BugReportController : Controller
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(statusFilter) &&
|
||||
Enum.TryParse<BugReportStatus>(statusFilter, out var status))
|
||||
query = query.Where(r => r.Status == status);
|
||||
allReports = allReports.Where(r => r.Status == status);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(priorityFilter) &&
|
||||
Enum.TryParse<BugReportPriority>(priorityFilter, out var priority))
|
||||
query = query.Where(r => r.Priority == priority);
|
||||
allReports = allReports.Where(r => r.Priority == priority);
|
||||
|
||||
query = (sortColumn, sortDirection == "asc") switch
|
||||
allReports = (sortColumn, sortDirection == "asc") switch
|
||||
{
|
||||
("Title", true) => query.OrderBy(r => r.Title),
|
||||
("Title", false) => query.OrderByDescending(r => r.Title),
|
||||
("Status", true) => query.OrderBy(r => r.Status),
|
||||
("Status", false) => query.OrderByDescending(r => r.Status),
|
||||
("Priority", true) => query.OrderBy(r => r.Priority),
|
||||
("Priority", false) => query.OrderByDescending(r => r.Priority),
|
||||
("Submitted", true) => query.OrderBy(r => r.SubmittedByUserName),
|
||||
("Submitted", false) => query.OrderByDescending(r => r.SubmittedByUserName),
|
||||
(_, true) => query.OrderBy(r => r.CreatedAt),
|
||||
_ => query.OrderByDescending(r => r.CreatedAt)
|
||||
("Title", true) => allReports.OrderBy(r => r.Title),
|
||||
("Title", false) => allReports.OrderByDescending(r => r.Title),
|
||||
("Status", true) => allReports.OrderBy(r => r.Status),
|
||||
("Status", false) => allReports.OrderByDescending(r => r.Status),
|
||||
("Priority", true) => allReports.OrderBy(r => r.Priority),
|
||||
("Priority", false) => allReports.OrderByDescending(r => r.Priority),
|
||||
("Submitted", true) => allReports.OrderBy(r => r.SubmittedByUserName),
|
||||
("Submitted", false) => allReports.OrderByDescending(r => r.SubmittedByUserName),
|
||||
(_, true) => allReports.OrderBy(r => r.CreatedAt),
|
||||
_ => allReports.OrderByDescending(r => r.CreatedAt)
|
||||
};
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
var items = await query
|
||||
var totalCount = allReports.Count();
|
||||
var items = allReports
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
var dtos = _mapper.Map<List<BugReportDto>>(items);
|
||||
|
||||
@@ -291,12 +284,10 @@ public class BugReportController : Controller
|
||||
|
||||
var dto = _mapper.Map<EditBugReportDto>(bugReport);
|
||||
|
||||
var attachments = await _context.BugReportAttachments
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(a => a.BugReportId == id && !a.IsDeleted)
|
||||
var attachments = (await _unitOfWork.BugReportAttachments.FindAsync(
|
||||
a => a.BugReportId == id && !a.IsDeleted, ignoreQueryFilters: true))
|
||||
.OrderBy(a => a.CreatedAt)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
dto.Attachments = _mapper.Map<List<BugReportAttachmentDto>>(attachments);
|
||||
|
||||
@@ -319,10 +310,7 @@ public class BugReportController : Controller
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Attachment(int id)
|
||||
{
|
||||
var attachment = await _context.BugReportAttachments
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(a => a.Id == id && !a.IsDeleted);
|
||||
var attachment = await _unitOfWork.BugReportAttachments.GetByIdAsync(id, ignoreQueryFilters: true);
|
||||
|
||||
if (attachment == null)
|
||||
return NotFound();
|
||||
|
||||
@@ -7,7 +7,7 @@ using PowderCoating.Application.DTOs.Company;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces.Services;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.Extensions;
|
||||
using System.Security.Claims;
|
||||
@@ -24,7 +24,9 @@ public class CompaniesController : Controller
|
||||
private readonly IMapper _mapper;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ISeedDataService _seedDataService;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly ICompanyListService _companyList;
|
||||
private readonly ICompanyDataPurgeService _companyPurge;
|
||||
private readonly IAuditLogService _auditLog;
|
||||
private readonly IInAppNotificationService _inApp;
|
||||
private readonly ILogger<CompaniesController> _logger;
|
||||
|
||||
@@ -33,7 +35,9 @@ public class CompaniesController : Controller
|
||||
IMapper mapper,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ISeedDataService seedDataService,
|
||||
ApplicationDbContext context,
|
||||
ICompanyListService companyList,
|
||||
ICompanyDataPurgeService companyPurge,
|
||||
IAuditLogService auditLog,
|
||||
IInAppNotificationService inApp,
|
||||
ILogger<CompaniesController> logger)
|
||||
{
|
||||
@@ -41,7 +45,9 @@ public class CompaniesController : Controller
|
||||
_mapper = mapper;
|
||||
_userManager = userManager;
|
||||
_seedDataService = seedDataService;
|
||||
_context = context;
|
||||
_companyList = companyList;
|
||||
_companyPurge = companyPurge;
|
||||
_auditLog = auditLog;
|
||||
_inApp = inApp;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -67,88 +73,27 @@ public class CompaniesController : Controller
|
||||
pageNumber = Math.Max(1, pageNumber);
|
||||
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
||||
|
||||
var query = _context.Companies
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted)
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var s = searchTerm.ToLower();
|
||||
query = query.Where(c =>
|
||||
c.CompanyName.ToLower().Contains(s) ||
|
||||
(c.CompanyCode != null && c.CompanyCode.ToLower().Contains(s)) ||
|
||||
(c.PrimaryContactEmail != null && c.PrimaryContactEmail.ToLower().Contains(s)) ||
|
||||
(c.Phone != null && c.Phone.ToLower().Contains(s)));
|
||||
}
|
||||
|
||||
query = (sortColumn, sortDirection == "asc") switch
|
||||
{
|
||||
("CompanyName", true) => query.OrderBy(c => c.CompanyName),
|
||||
("CompanyName", false) => query.OrderByDescending(c => c.CompanyName),
|
||||
("Plan", true) => query.OrderBy(c => c.SubscriptionPlan),
|
||||
("Plan", false) => query.OrderByDescending(c => c.SubscriptionPlan),
|
||||
("Status", true) => query.OrderBy(c => c.IsActive),
|
||||
("Status", false) => query.OrderByDescending(c => c.IsActive),
|
||||
("Created", true) => query.OrderBy(c => c.CreatedAt),
|
||||
("Created", false) => query.OrderByDescending(c => c.CreatedAt),
|
||||
_ => query.OrderBy(c => c.CompanyName)
|
||||
};
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
var companies = await query
|
||||
.Include(c => c.Users)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
var (companies, totalCount) = await _companyList.GetPagedAsync(
|
||||
searchTerm, sortColumn, sortDirection, pageNumber, pageSize);
|
||||
|
||||
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
|
||||
|
||||
// Populate job/quote/customer counts efficiently via group queries
|
||||
if (companyDtos.Any())
|
||||
if (companyDtos.Count > 0)
|
||||
{
|
||||
var ids = companyDtos.Select(c => c.Id).ToList();
|
||||
|
||||
var jobCounts = await _context.Jobs.IgnoreQueryFilters()
|
||||
.Where(j => ids.Contains(j.CompanyId) && !j.IsDeleted)
|
||||
.GroupBy(j => j.CompanyId)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||
|
||||
var quoteCounts = await _context.Quotes.IgnoreQueryFilters()
|
||||
.Where(q => ids.Contains(q.CompanyId) && !q.IsDeleted)
|
||||
.GroupBy(q => q.CompanyId)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||
|
||||
var customerCounts = await _context.Customers.IgnoreQueryFilters()
|
||||
.Where(c => ids.Contains(c.CompanyId) && !c.IsDeleted)
|
||||
.GroupBy(c => c.CompanyId)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||
|
||||
var wizardData = await _context.CompanyPreferences.IgnoreQueryFilters()
|
||||
.Where(p => ids.Contains(p.CompanyId) && p.SetupWizardCompleted)
|
||||
.Select(p => new
|
||||
{
|
||||
p.CompanyId,
|
||||
p.SetupWizardCompletedAt,
|
||||
p.SetupWizardCompletedByName
|
||||
})
|
||||
.ToDictionaryAsync(x => x.CompanyId);
|
||||
var summary = await _companyList.GetCountSummaryAsync(ids);
|
||||
|
||||
foreach (var dto in companyDtos)
|
||||
{
|
||||
dto.JobCount = jobCounts.GetValueOrDefault(dto.Id, 0);
|
||||
dto.QuoteCount = quoteCounts.GetValueOrDefault(dto.Id, 0);
|
||||
dto.CustomerCount = customerCounts.GetValueOrDefault(dto.Id, 0);
|
||||
dto.JobCount = summary.JobCounts.GetValueOrDefault(dto.Id, 0);
|
||||
dto.QuoteCount = summary.QuoteCounts.GetValueOrDefault(dto.Id, 0);
|
||||
dto.CustomerCount = summary.CustomerCounts.GetValueOrDefault(dto.Id, 0);
|
||||
|
||||
if (wizardData.TryGetValue(dto.Id, out var w))
|
||||
if (summary.WizardInfo.TryGetValue(dto.Id, out var w))
|
||||
{
|
||||
dto.WizardCompleted = true;
|
||||
dto.WizardCompletedAt = w.SetupWizardCompletedAt;
|
||||
dto.WizardCompletedByName = w.SetupWizardCompletedByName;
|
||||
dto.WizardCompletedAt = w.CompletedAt;
|
||||
dto.WizardCompletedByName = w.CompletedByName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -380,8 +325,6 @@ public class CompaniesController : Controller
|
||||
}
|
||||
else
|
||||
{
|
||||
// If user creation failed, we should consider rolling back company creation
|
||||
// For now, log the error and inform the user
|
||||
_logger.LogError("Failed to create admin user for company {CompanyName}: {Errors}",
|
||||
company.CompanyName, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
|
||||
@@ -441,14 +384,10 @@ public class CompaniesController : Controller
|
||||
public async Task<IActionResult> Edit(int id, UpdateCompanyDto model)
|
||||
{
|
||||
if (id != model.Id)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(model);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -473,9 +412,7 @@ public class CompaniesController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Update company properties
|
||||
_mapper.Map(model, company);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Company {CompanyName} updated successfully by {User}",
|
||||
@@ -560,7 +497,6 @@ public class CompaniesController : Controller
|
||||
var companyName = company.CompanyName;
|
||||
var userCount = company.Users.Count;
|
||||
|
||||
// Soft-delete the company and deactivate all users
|
||||
company.IsDeleted = true;
|
||||
company.IsActive = false;
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -573,8 +509,7 @@ public class CompaniesController : Controller
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Write audit log
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
await _auditLog.LogAsync(new AuditLog
|
||||
{
|
||||
UserId = adminUserId,
|
||||
UserName = adminName,
|
||||
@@ -588,7 +523,6 @@ public class CompaniesController : Controller
|
||||
Timestamp = DateTime.UtcNow,
|
||||
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning("Company {CompanyName} (ID:{CompanyId}) soft-deleted by {User}",
|
||||
companyName, id, adminName);
|
||||
@@ -625,8 +559,7 @@ public class CompaniesController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var company = await _context.Companies.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
|
||||
|
||||
if (company == null)
|
||||
{
|
||||
@@ -640,91 +573,25 @@ public class CompaniesController : Controller
|
||||
|
||||
try
|
||||
{
|
||||
// ── Tier 1: Leaf children (must go before their parents) ─────────────
|
||||
// JobItem children
|
||||
await _context.JobItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
|
||||
// QuoteItem children
|
||||
await _context.QuoteItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuoteItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
|
||||
// AnnouncementDismissals (no CompanyId — delete by user or company-targeted announcement)
|
||||
// Load user IDs first — needed for announcement-dismissal cleanup in the purge service
|
||||
var userIds = await _userManager.Users.IgnoreQueryFilters()
|
||||
.Where(u => u.CompanyId == id).Select(u => u.Id).ToListAsync();
|
||||
var announcementIds = await _context.Announcements
|
||||
.Where(a => a.TargetCompanyId == id).Select(a => a.Id).ToListAsync();
|
||||
await _context.AnnouncementDismissals.IgnoreQueryFilters()
|
||||
.Where(x => userIds.Contains(x.UserId) || announcementIds.Contains(x.AnnouncementId))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
// ── Tier 2: Mid-level children ────────────────────────────────────────
|
||||
await _context.JobItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuoteItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobStatusHistory.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobPhotos.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobNotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobDailyPriorities.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CustomerNotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.MaintenanceRecords.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.BillLineItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.BillPayments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Payments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.InvoiceItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuotePrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
// Tiers 1-4: bulk delete all business data (service mirrors the original tier ordering)
|
||||
await _companyPurge.DeleteAllBusinessDataAsync(id, userIds);
|
||||
|
||||
// ── Tier 3: Top-level company entities ───────────────────────────────
|
||||
// Order matters: child-side of FK must be deleted before parent-side.
|
||||
// Invoices/Appointments → Customers; Bills/Expenses → Vendors
|
||||
await _context.Invoices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Appointments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Jobs.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Quotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Customers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Bills.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Expenses.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Vendors.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.InventoryItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Equipment.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CatalogItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
// Announcements are platform-wide; only delete company-targeted ones (TargetCompanyId)
|
||||
await _context.Announcements.Where(x => x.TargetCompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
|
||||
// ── Tier 4: Company configs and lookup tables ─────────────────────────
|
||||
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobPriorityLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuoteStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.AppointmentStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.AppointmentTypeLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.PricingTiers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CompanyOperatingCosts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CompanyPreferences.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
|
||||
// ── Tier 5: Users (via Identity to cascade AspNetUser* tables) ────────
|
||||
// Tier 5: delete Identity users so AspNetUser* tables cascade correctly
|
||||
var users = await _userManager.Users.IgnoreQueryFilters()
|
||||
.Where(u => u.CompanyId == id).ToListAsync();
|
||||
var userCount = users.Count;
|
||||
foreach (var user in users)
|
||||
await _userManager.DeleteAsync(user);
|
||||
|
||||
// ── Tier 6: Company record ────────────────────────────────────────────
|
||||
await _context.Companies.IgnoreQueryFilters().Where(c => c.Id == id).ExecuteDeleteAsync();
|
||||
// Tier 6: delete company record
|
||||
await _unitOfWork.Companies.DeleteAsync(company);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Write audit log (use platform default company context — no companyId since it's gone)
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
await _auditLog.LogAsync(new AuditLog
|
||||
{
|
||||
UserId = adminUserId,
|
||||
UserName = adminName,
|
||||
@@ -738,7 +605,6 @@ public class CompaniesController : Controller
|
||||
Timestamp = DateTime.UtcNow,
|
||||
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning("Company {CompanyName} (ID:{CompanyId}) HARD DELETED by {User}. {UserCount} users removed.",
|
||||
companyName, id, adminName, userCount);
|
||||
@@ -764,8 +630,6 @@ public class CompaniesController : Controller
|
||||
/// to record the action. This operation is irreversible.
|
||||
/// </summary>
|
||||
// POST: Companies/ResetData/5
|
||||
// Permanently hard-deletes all business data for a company while keeping the company record,
|
||||
// its users, operating costs, preferences, and lookup tables intact.
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ResetData(int id, string confirmation)
|
||||
@@ -776,8 +640,7 @@ public class CompaniesController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var company = await _context.Companies.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
|
||||
|
||||
if (company == null)
|
||||
{
|
||||
@@ -791,94 +654,9 @@ public class CompaniesController : Controller
|
||||
|
||||
try
|
||||
{
|
||||
// ── Tier 0: Grandchildren ─────────────────────────────────────────────
|
||||
await _context.JobTemplateItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobTemplateItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuoteItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuoteItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.GiftCertificateRedemptions .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CreditMemoApplications .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.OvenBatchItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _companyPurge.ResetBusinessDataAsync(id);
|
||||
|
||||
// AnnouncementDismissals for company-targeted announcements
|
||||
var announcementIds = await _context.Announcements
|
||||
.Where(a => a.TargetCompanyId == id).Select(a => a.Id).ToListAsync();
|
||||
if (announcementIds.Any())
|
||||
await _context.AnnouncementDismissals.IgnoreQueryFilters()
|
||||
.Where(x => announcementIds.Contains(x.AnnouncementId))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
// ── Tier 1: Children ──────────────────────────────────────────────────
|
||||
await _context.JobTemplateItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuoteItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobStatusHistory .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobChangeHistories .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobPhotos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobNotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobDailyPriorities .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobTimeEntries .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.ReworkRecords .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuotePrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuotePhotos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CustomerNotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.MaintenanceRecords .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.BillLineItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.BillPayments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Payments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Deposits .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.InvoiceItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.PurchaseOrderItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.AiItemPredictions .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.PowderUsageLogs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.ShopWorkerRoleCosts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.OvenBatches .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Refunds .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CreditMemos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.GiftCertificates .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
|
||||
// ── Tier 2: Top-level business entities ──────────────────────────────
|
||||
// Order matters: child-side of FK must be deleted before parent-side.
|
||||
// Invoices/Appointments → Customers; Bills/PurchaseOrders/Expenses → Vendors
|
||||
await _context.Invoices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Appointments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Jobs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobTemplates .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Quotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Customers .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Bills .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.PurchaseOrders .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Expenses .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Vendors .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CatalogItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.InventoryItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Equipment .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.OvenCosts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Accounts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.NotificationLogs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.ShopWorkers .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.PrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
// Company-targeted announcements only (platform-wide announcements are left alone)
|
||||
await _context.Announcements.Where(x => x.TargetCompanyId == id).ExecuteDeleteAsync();
|
||||
|
||||
// Reset QB migration wizard progress
|
||||
var prefs = await _context.CompanyPreferences.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == id);
|
||||
if (prefs?.QbMigrationStateJson != null)
|
||||
{
|
||||
prefs.QbMigrationStateJson = null;
|
||||
prefs.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// Audit log
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
await _auditLog.LogAsync(new AuditLog
|
||||
{
|
||||
UserId = adminUserId,
|
||||
UserName = adminName,
|
||||
@@ -892,7 +670,6 @@ public class CompaniesController : Controller
|
||||
Timestamp = DateTime.UtcNow,
|
||||
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning(
|
||||
"Company {CompanyName} (ID:{CompanyId}) ALL BUSINESS DATA RESET by {User}.",
|
||||
@@ -960,9 +737,7 @@ public class CompaniesController : Controller
|
||||
{
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(model.CompanyId, ignoreQueryFilters: true);
|
||||
if (company != null)
|
||||
{
|
||||
model.CompanyName = company.CompanyName;
|
||||
}
|
||||
return View(model);
|
||||
}
|
||||
|
||||
@@ -976,7 +751,6 @@ public class CompaniesController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
var existingUser = await _userManager.FindByEmailAsync(model.Email);
|
||||
if (existingUser != null)
|
||||
{
|
||||
@@ -985,7 +759,6 @@ public class CompaniesController : Controller
|
||||
return View(model);
|
||||
}
|
||||
|
||||
// Create admin user for the company
|
||||
var adminUser = new ApplicationUser
|
||||
{
|
||||
UserName = model.Email,
|
||||
@@ -1018,9 +791,7 @@ public class CompaniesController : Controller
|
||||
else
|
||||
{
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
ModelState.AddModelError("", error.Description);
|
||||
}
|
||||
model.CompanyName = company.CompanyName;
|
||||
return View(model);
|
||||
}
|
||||
@@ -1054,21 +825,13 @@ public class CompaniesController : Controller
|
||||
return NotFound(new { error = "User not found." });
|
||||
|
||||
// Use the viewed company's timezone so timestamps match the tenant's local time
|
||||
var tz = await _context.Companies
|
||||
.Where(c => c.Id == companyId)
|
||||
.Select(c => c.TimeZone)
|
||||
.FirstOrDefaultAsync();
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
|
||||
var tz = company?.TimeZone;
|
||||
|
||||
var logs = new List<dynamic>();
|
||||
try
|
||||
{
|
||||
var rawLogs = await _context.AuditLogs
|
||||
.AsNoTracking()
|
||||
.Where(l => l.UserId == userId && l.EntityType == "ApplicationUser")
|
||||
.OrderByDescending(l => l.Timestamp)
|
||||
.Take(50)
|
||||
.Select(l => new { l.Action, l.IpAddress, l.Timestamp, l.NewValues })
|
||||
.ToListAsync();
|
||||
var rawLogs = await _auditLog.GetUserActivityAsync(userId);
|
||||
|
||||
logs = rawLogs.Select(l => (dynamic)new
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Core.Interfaces.Services;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using System.Security.Claims;
|
||||
@@ -29,7 +30,7 @@ public class CompanySettingsController : Controller
|
||||
private readonly ILookupCacheService _lookupCache;
|
||||
private readonly IStripeConnectService _stripeConnect;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IAuditLogService _auditLog;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||
|
||||
@@ -42,7 +43,7 @@ public class CompanySettingsController : Controller
|
||||
ILookupCacheService lookupCache,
|
||||
IStripeConnectService stripeConnect,
|
||||
IConfiguration configuration,
|
||||
ApplicationDbContext context,
|
||||
IAuditLogService auditLog,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
SignInManager<ApplicationUser> signInManager)
|
||||
{
|
||||
@@ -54,7 +55,7 @@ public class CompanySettingsController : Controller
|
||||
_lookupCache = lookupCache;
|
||||
_stripeConnect = stripeConnect;
|
||||
_configuration = configuration;
|
||||
_context = context;
|
||||
_auditLog = auditLog;
|
||||
_userManager = userManager;
|
||||
_signInManager = signInManager;
|
||||
}
|
||||
@@ -126,9 +127,7 @@ public class CompanySettingsController : Controller
|
||||
var dto = _mapper.Map<CompanySettingsDto>(company);
|
||||
|
||||
// Populate AllowOnlinePayments from subscription plan config
|
||||
var planConfig = await _context.Set<SubscriptionPlanConfig>()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
|
||||
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
|
||||
dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false;
|
||||
|
||||
// Flag whether Stripe Connect is configured (non-placeholder client ID)
|
||||
@@ -2805,10 +2804,10 @@ public class CompanySettingsController : Controller
|
||||
if (company == null) return NotFound();
|
||||
|
||||
var userCount = await _userManager.Users.CountAsync(u => u.CompanyId == companyId.Value);
|
||||
var jobCount = await _context.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
|
||||
var quoteCount = await _context.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
|
||||
var custCount = await _context.Customers.CountAsync(c => c.CompanyId == companyId.Value);
|
||||
var invCount = await _context.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
|
||||
var jobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
|
||||
var quoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
|
||||
var custCount = await _unitOfWork.Customers.CountAsync(c => c.CompanyId == companyId.Value);
|
||||
var invCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
|
||||
|
||||
ViewBag.CompanyName = company.CompanyName;
|
||||
ViewBag.UserCount = userCount;
|
||||
@@ -2859,10 +2858,10 @@ public class CompanySettingsController : Controller
|
||||
|
||||
// ── Gather counts for the audit snapshot ─────────────────────────
|
||||
var userCount = company.Users.Count;
|
||||
var jobCount = await _context.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
|
||||
var quoteCount = await _context.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
|
||||
var custCount = await _context.Customers.CountAsync(c => c.CompanyId == companyId.Value);
|
||||
var invCount = await _context.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
|
||||
var jobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
|
||||
var quoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
|
||||
var custCount = await _unitOfWork.Customers.CountAsync(c => c.CompanyId == companyId.Value);
|
||||
var invCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
|
||||
|
||||
// ── Soft-delete the company ───────────────────────────────────────
|
||||
var now = DateTime.UtcNow;
|
||||
@@ -2881,7 +2880,7 @@ public class CompanySettingsController : Controller
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// ── Write audit log ───────────────────────────────────────────────
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
await _auditLog.LogAsync(new AuditLog
|
||||
{
|
||||
UserId = requestingUserId,
|
||||
UserName = requestingUserName,
|
||||
@@ -2899,7 +2898,6 @@ public class CompanySettingsController : Controller
|
||||
Timestamp = now,
|
||||
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning(
|
||||
"Self-service account deletion: company {CompanyName} (ID:{CompanyId}) deleted by {User}. " +
|
||||
|
||||
@@ -9,7 +9,6 @@ using PowderCoating.Application.DTOs.User;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -25,7 +24,6 @@ public class CompanyUsersController : Controller
|
||||
private readonly ILogger<CompanyUsersController> _logger;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IEmailService _emailService;
|
||||
|
||||
public CompanyUsersController(
|
||||
@@ -34,7 +32,6 @@ public class CompanyUsersController : Controller
|
||||
ILogger<CompanyUsersController> logger,
|
||||
IUnitOfWork unitOfWork,
|
||||
ISubscriptionService subscriptionService,
|
||||
ApplicationDbContext context,
|
||||
IEmailService emailService)
|
||||
{
|
||||
_userManager = userManager;
|
||||
@@ -42,7 +39,6 @@ public class CompanyUsersController : Controller
|
||||
_logger = logger;
|
||||
_unitOfWork = unitOfWork;
|
||||
_subscriptionService = subscriptionService;
|
||||
_context = context;
|
||||
_emailService = emailService;
|
||||
}
|
||||
|
||||
@@ -372,8 +368,8 @@ public class CompanyUsersController : Controller
|
||||
CompanyId = companyId!.Value
|
||||
};
|
||||
|
||||
await _context.ShopWorkers.AddAsync(shopWorker);
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
|
||||
}
|
||||
@@ -639,9 +635,8 @@ public class CompanyUsersController : Controller
|
||||
{
|
||||
// Search by oldEmail so we find the record even when the email just changed
|
||||
var lookupEmail = emailChanged ? oldEmail : user.Email;
|
||||
var existingShopWorker = await _context.ShopWorkers
|
||||
.Where(sw => sw.Email == lookupEmail && sw.CompanyId == user.CompanyId)
|
||||
.ToListAsync();
|
||||
var existingShopWorker = (await _unitOfWork.ShopWorkers.FindAsync(
|
||||
sw => sw.Email == lookupEmail && sw.CompanyId == user.CompanyId)).ToList();
|
||||
|
||||
if (!existingShopWorker.Any())
|
||||
{
|
||||
@@ -656,8 +651,8 @@ public class CompanyUsersController : Controller
|
||||
CompanyId = user.CompanyId
|
||||
};
|
||||
|
||||
await _context.ShopWorkers.AddAsync(shopWorker);
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
|
||||
}
|
||||
@@ -684,7 +679,7 @@ public class CompanyUsersController : Controller
|
||||
shopWorker.Phone = user.PhoneNumber;
|
||||
|
||||
if (shopWorkerDirty)
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -18,39 +17,37 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class DashboardTipsController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
public DashboardTipsController(ApplicationDbContext db)
|
||||
public DashboardTipsController(IUnitOfWork unitOfWork)
|
||||
{
|
||||
_db = db;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a paginated, optionally filtered list of all dashboard tips.
|
||||
/// Active tips are sorted first (then by newest Id) to make the currently
|
||||
/// live pool easy to review at a glance. ViewBag includes both the filtered
|
||||
/// count and the global active/total counts for the header summary cards.
|
||||
/// live pool easy to review at a glance.
|
||||
/// </summary>
|
||||
// GET: /DashboardTips
|
||||
public async Task<IActionResult> Index(string? search, bool? activeOnly, int page = 1)
|
||||
{
|
||||
const int pageSize = 25;
|
||||
|
||||
var query = _db.DashboardTips.AsQueryable();
|
||||
var all = (await _unitOfWork.DashboardTips.GetAllAsync()).ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
query = query.Where(t => t.TipText.Contains(search));
|
||||
all = all.Where(t => t.TipText.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (activeOnly == true)
|
||||
query = query.Where(t => t.IsActive);
|
||||
all = all.Where(t => t.IsActive).ToList();
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var tips = await query
|
||||
var total = all.Count;
|
||||
var tips = all
|
||||
.OrderByDescending(t => t.IsActive)
|
||||
.ThenByDescending(t => t.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
ViewBag.Search = search;
|
||||
ViewBag.ActiveOnly = activeOnly ?? false;
|
||||
@@ -58,23 +55,19 @@ public class DashboardTipsController : Controller
|
||||
ViewBag.PageSize = pageSize;
|
||||
ViewBag.Total = total;
|
||||
ViewBag.TotalPages = (int)Math.Ceiling(total / (double)pageSize);
|
||||
ViewBag.ActiveCount = await _db.DashboardTips.CountAsync(t => t.IsActive);
|
||||
ViewBag.TotalCount = await _db.DashboardTips.CountAsync();
|
||||
ViewBag.ActiveCount = await _unitOfWork.DashboardTips.CountAsync(t => t.IsActive);
|
||||
ViewBag.TotalCount = await _unitOfWork.DashboardTips.CountAsync();
|
||||
|
||||
return View(tips);
|
||||
}
|
||||
|
||||
/// <summary>Returns the Create form with an empty <see cref="DashboardTip"/> model.</summary>
|
||||
// GET: /DashboardTips/Create
|
||||
public IActionResult Create() => View(new DashboardTip());
|
||||
|
||||
/// <summary>
|
||||
/// Persists a new dashboard tip. Text is trimmed before saving to prevent
|
||||
/// whitespace-only entries from appearing as blank tiles on the dashboard.
|
||||
/// Model validation is done manually (rather than relying solely on
|
||||
/// <c>[Required]</c> attributes) to ensure a meaningful error message is shown.
|
||||
/// </summary>
|
||||
// POST: /DashboardTips/Create
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(DashboardTip model)
|
||||
{
|
||||
@@ -84,37 +77,33 @@ public class DashboardTipsController : Controller
|
||||
return View(model);
|
||||
}
|
||||
|
||||
_db.DashboardTips.Add(new DashboardTip
|
||||
await _unitOfWork.DashboardTips.AddAsync(new DashboardTip
|
||||
{
|
||||
TipText = model.TipText.Trim(),
|
||||
IsActive = model.IsActive,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = "Tip added successfully.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>Returns the Edit form for an existing tip, or 404 if not found.</summary>
|
||||
// GET: /DashboardTips/Edit/5
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var tip = await _db.DashboardTips.FindAsync(id);
|
||||
var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
|
||||
if (tip == null) return NotFound();
|
||||
return View(tip);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates text and active flag for an existing tip. Returns the tracked entity
|
||||
/// (not the posted model) to the view on validation failure so the form shows
|
||||
/// the database version rather than potentially mangled posted data.
|
||||
/// Updates text and active flag for an existing tip.
|
||||
/// </summary>
|
||||
// POST: /DashboardTips/Edit/5
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, DashboardTip model)
|
||||
{
|
||||
var tip = await _db.DashboardTips.FindAsync(id);
|
||||
var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
|
||||
if (tip == null) return NotFound();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.TipText))
|
||||
@@ -125,27 +114,25 @@ public class DashboardTipsController : Controller
|
||||
|
||||
tip.TipText = model.TipText.Trim();
|
||||
tip.IsActive = model.IsActive;
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = "Tip updated.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Permanently (hard) deletes a dashboard tip. Tips are platform metadata so
|
||||
/// Permanently (hard) deletes a dashboard tip. Tips are platform metadata so
|
||||
/// they do not use soft delete — they have no foreign-key relationships to
|
||||
/// tenant data and nothing references a deleted tip's Id. A missing Id is
|
||||
/// silently ignored to keep the action idempotent.
|
||||
/// tenant data and nothing references a deleted tip's Id.
|
||||
/// </summary>
|
||||
// POST: /DashboardTips/Delete/5
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var tip = await _db.DashboardTips.FindAsync(id);
|
||||
var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
|
||||
if (tip != null)
|
||||
{
|
||||
_db.DashboardTips.Remove(tip);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.DashboardTips.DeleteAsync(tip);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = "Tip deleted.";
|
||||
}
|
||||
return RedirectToAction(nameof(Index));
|
||||
@@ -153,19 +140,15 @@ public class DashboardTipsController : Controller
|
||||
|
||||
/// <summary>
|
||||
/// Flips the <c>IsActive</c> flag on a tip without a full edit round-trip.
|
||||
/// This lets operators quickly remove a tip from the rotation (deactivate)
|
||||
/// without deleting it, preserving the ability to reactivate it later.
|
||||
/// A missing Id is silently ignored to keep the action idempotent.
|
||||
/// </summary>
|
||||
// POST: /DashboardTips/ToggleActive/5
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ToggleActive(int id)
|
||||
{
|
||||
var tip = await _db.DashboardTips.FindAsync(id);
|
||||
var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
|
||||
if (tip != null)
|
||||
{
|
||||
tip.IsActive = !tip.IsActive;
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ using PowderCoating.Application.DTOs.Company;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
@@ -21,18 +20,15 @@ public class DepositsController : Controller
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<DepositsController> _logger;
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public DepositsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<DepositsController> logger,
|
||||
ApplicationDbContext context)
|
||||
ILogger<DepositsController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -220,11 +216,11 @@ public class DepositsController : Controller
|
||||
{
|
||||
var prefix = $"DEP-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
|
||||
|
||||
var existing = await _context.Set<Deposit>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(d => d.CompanyId == companyId && d.ReceiptNumber.StartsWith(prefix))
|
||||
var existing = (await _unitOfWork.Deposits.FindAsync(
|
||||
d => d.CompanyId == companyId && d.ReceiptNumber.StartsWith(prefix),
|
||||
ignoreQueryFilters: true))
|
||||
.Select(d => d.ReceiptNumber)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
var maxNum = 0;
|
||||
foreach (var num in existing)
|
||||
|
||||
@@ -12,6 +12,7 @@ using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
// Intentional exception: cross-tenant fan-out querying ASP.NET Identity Users table with company joins; Identity entities live outside IUnitOfWork. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class EmailBroadcastController : Controller
|
||||
{
|
||||
|
||||
@@ -14,7 +14,6 @@ using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -25,7 +24,6 @@ public class ExpensesController : Controller
|
||||
private readonly IMapper _mapper;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<ExpensesController> _logger;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IAzureBlobStorageService _blobStorage;
|
||||
private readonly StorageSettings _storageSettings;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
@@ -40,7 +38,6 @@ public class ExpensesController : Controller
|
||||
IMapper mapper,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<ExpensesController> logger,
|
||||
ApplicationDbContext context,
|
||||
IAzureBlobStorageService blobStorage,
|
||||
IOptions<StorageSettings> storageSettings,
|
||||
IAccountBalanceService accountBalanceService,
|
||||
@@ -51,7 +48,6 @@ public class ExpensesController : Controller
|
||||
_mapper = mapper;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_context = context;
|
||||
_blobStorage = blobStorage;
|
||||
_storageSettings = storageSettings.Value;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
@@ -80,28 +76,25 @@ public class ExpensesController : Controller
|
||||
[NonAction]
|
||||
public async Task<IActionResult> IndexLegacy(string? search, int? accountId, DateTime? from, DateTime? to, int page = 1, int pageSize = 25)
|
||||
{
|
||||
var query = _context.Expenses
|
||||
.Include(e => e.Vendor)
|
||||
.Include(e => e.ExpenseAccount)
|
||||
.Include(e => e.PaymentAccount)
|
||||
.Include(e => e.Job)
|
||||
.Where(e => !e.IsDeleted);
|
||||
var allExpenses = (await _unitOfWork.Expenses.GetAllAsync(
|
||||
false, e => e.Vendor, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Job))
|
||||
.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
query = query.Where(e => e.ExpenseNumber.Contains(search) ||
|
||||
e.Memo!.Contains(search) ||
|
||||
allExpenses = allExpenses.Where(e => e.ExpenseNumber.Contains(search) ||
|
||||
(e.Memo != null && e.Memo.Contains(search)) ||
|
||||
(e.Vendor != null && e.Vendor.CompanyName.Contains(search)));
|
||||
|
||||
if (accountId.HasValue)
|
||||
query = query.Where(e => e.ExpenseAccountId == accountId.Value);
|
||||
allExpenses = allExpenses.Where(e => e.ExpenseAccountId == accountId.Value);
|
||||
|
||||
if (from.HasValue)
|
||||
query = query.Where(e => e.Date >= from.Value);
|
||||
allExpenses = allExpenses.Where(e => e.Date >= from.Value);
|
||||
|
||||
if (to.HasValue)
|
||||
query = query.Where(e => e.Date <= to.Value);
|
||||
allExpenses = allExpenses.Where(e => e.Date <= to.Value);
|
||||
|
||||
var expenses = await query.OrderByDescending(e => e.CreatedAt).ToListAsync();
|
||||
var expenses = allExpenses.OrderByDescending(e => e.CreatedAt).ToList();
|
||||
var dtos = _mapper.Map<List<ExpenseListDto>>(expenses);
|
||||
|
||||
ViewBag.Search = search;
|
||||
@@ -110,11 +103,11 @@ public class ExpensesController : Controller
|
||||
ViewBag.To = to?.ToString("yyyy-MM-dd");
|
||||
ViewBag.TotalAmount = dtos.Sum(e => e.Amount);
|
||||
|
||||
var expenseAccounts = await _context.Accounts
|
||||
.Where(a => !a.IsDeleted && a.IsActive &&
|
||||
(a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods))
|
||||
var expenseAccounts = (await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.IsActive &&
|
||||
(a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)))
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
ViewBag.AccountFilter = expenseAccounts
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
@@ -297,12 +290,8 @@ public class ExpensesController : Controller
|
||||
{
|
||||
if (id == null) return NotFound();
|
||||
|
||||
var expense = await _context.Expenses
|
||||
.Include(e => e.Vendor)
|
||||
.Include(e => e.ExpenseAccount)
|
||||
.Include(e => e.PaymentAccount)
|
||||
.Include(e => e.Job)
|
||||
.FirstOrDefaultAsync(e => e.Id == id && !e.IsDeleted);
|
||||
var expense = await _unitOfWork.Expenses.GetByIdAsync(
|
||||
id.Value, false, e => e.Vendor, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Job);
|
||||
|
||||
if (expense == null) return NotFound();
|
||||
return View(_mapper.Map<ExpenseDto>(expense));
|
||||
@@ -445,12 +434,11 @@ public class ExpensesController : Controller
|
||||
private async Task<string> GenerateExpenseNumberAsync()
|
||||
{
|
||||
var prefix = $"EXP-{DateTime.Now:yyMM}-";
|
||||
var last = await _context.Expenses
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => e.ExpenseNumber.StartsWith(prefix))
|
||||
var last = (await _unitOfWork.Expenses.FindAsync(
|
||||
e => e.ExpenseNumber.StartsWith(prefix), ignoreQueryFilters: true))
|
||||
.OrderByDescending(e => e.ExpenseNumber)
|
||||
.Select(e => e.ExpenseNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
.FirstOrDefault();
|
||||
|
||||
int next = 1;
|
||||
if (last != null && int.TryParse(last[prefix.Length..], out int num))
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Web.Extensions;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -10,12 +8,12 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize]
|
||||
public class InAppNotificationsController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenant;
|
||||
|
||||
public InAppNotificationsController(ApplicationDbContext db, ITenantContext tenant)
|
||||
public InAppNotificationsController(IUnitOfWork unitOfWork, ITenantContext tenant)
|
||||
{
|
||||
_db = db;
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenant = tenant;
|
||||
}
|
||||
|
||||
@@ -27,23 +25,15 @@ public class InAppNotificationsController : Controller
|
||||
pageNumber = Math.Max(1, pageNumber);
|
||||
pageSize = pageSize is 10 or 25 or 50 ? pageSize : 25;
|
||||
|
||||
IQueryable<PowderCoating.Core.Entities.InAppNotification> query;
|
||||
var all = _tenant.IsPlatformAdmin()
|
||||
? (await _unitOfWork.InAppNotifications.FindAsync(
|
||||
n => !n.IsDeleted && n.CompanyId == 0, ignoreQueryFilters: true)).ToList()
|
||||
: (await _unitOfWork.InAppNotifications.GetAllAsync()).ToList();
|
||||
|
||||
if (_tenant.IsPlatformAdmin())
|
||||
{
|
||||
query = _db.InAppNotifications
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => !n.IsDeleted && n.CompanyId == 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
query = _db.InAppNotifications.AsQueryable();
|
||||
}
|
||||
var totalCount = all.Count;
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
var items = await query
|
||||
.AsNoTracking()
|
||||
var tz = ViewBag.CompanyTimeZone as string;
|
||||
var items = all
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
@@ -58,7 +48,7 @@ public class InAppNotificationsController : Controller
|
||||
n.ReadAt,
|
||||
CreatedAt = n.CreatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
ViewBag.TotalCount = totalCount;
|
||||
ViewBag.PageNumber = pageNumber;
|
||||
@@ -68,31 +58,22 @@ public class InAppNotificationsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AJAX endpoint that returns the 20 most recent notifications (read and unread) for the bell dropdown. The response includes a count of unread items so the badge can be updated without a separate round-trip.
|
||||
/// AJAX endpoint that returns the 20 most recent notifications (read and unread) for the bell dropdown.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Recent()
|
||||
{
|
||||
IQueryable<PowderCoating.Core.Entities.InAppNotification> query;
|
||||
|
||||
if (_tenant.IsPlatformAdmin())
|
||||
{
|
||||
query = _db.InAppNotifications
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => !n.IsDeleted && n.CompanyId == 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
query = _db.InAppNotifications.AsQueryable();
|
||||
}
|
||||
var all = _tenant.IsPlatformAdmin()
|
||||
? (await _unitOfWork.InAppNotifications.FindAsync(
|
||||
n => !n.IsDeleted && n.CompanyId == 0, ignoreQueryFilters: true)).ToList()
|
||||
: (await _unitOfWork.InAppNotifications.GetAllAsync()).ToList();
|
||||
|
||||
var tz = ViewBag.CompanyTimeZone as string;
|
||||
var items = await query
|
||||
.AsNoTracking()
|
||||
var items = all
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Take(20)
|
||||
.Select(n => new { n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.IsRead, n.CreatedAt })
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
var unreadCount = items.Count(n => !n.IsRead);
|
||||
return Json(new { count = unreadCount, items = items.Select(n => new {
|
||||
@@ -102,34 +83,19 @@ public class InAppNotificationsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AJAX endpoint that returns only unread notifications (up to 20) plus an unread count for the bell badge. Used by the initial page load poll; after that the bell relies on Recent to show history.
|
||||
/// AJAX endpoint that returns only unread notifications (up to 20) plus an unread count for the bell badge.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Unread()
|
||||
{
|
||||
IQueryable<PowderCoating.Core.Entities.InAppNotification> query;
|
||||
|
||||
if (_tenant.IsPlatformAdmin())
|
||||
{
|
||||
// SuperAdmins see only platform-level notifications (CompanyId = 0)
|
||||
query = _db.InAppNotifications
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regular users see their company's notifications (global filter handles tenant isolation)
|
||||
query = _db.InAppNotifications.Where(n => !n.IsRead);
|
||||
}
|
||||
var items = _tenant.IsPlatformAdmin()
|
||||
? (await _unitOfWork.InAppNotifications.FindAsync(
|
||||
n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true))
|
||||
.OrderByDescending(n => n.CreatedAt).Take(20).ToList()
|
||||
: (await _unitOfWork.InAppNotifications.FindAsync(n => !n.IsRead))
|
||||
.OrderByDescending(n => n.CreatedAt).Take(20).ToList();
|
||||
|
||||
var tz = ViewBag.CompanyTimeZone as string;
|
||||
var items = await query
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Take(20)
|
||||
.Select(n => new { n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.CreatedAt })
|
||||
.ToListAsync();
|
||||
|
||||
return Json(new { count = items.Count, items = items.Select(n => new {
|
||||
n.Id, n.Title, n.Message, n.Link, n.NotificationType,
|
||||
CreatedAt = n.CreatedAt.Tz(tz).ToString("MMM d, h:mm tt")
|
||||
@@ -137,44 +103,38 @@ public class InAppNotificationsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a single notification as read and records the timestamp. Tenant isolation is enforced manually for SuperAdmins (CompanyId 0 filter) because IgnoreQueryFilters bypasses the global company filter.
|
||||
/// Marks a single notification as read. Tenant isolation is enforced manually for SuperAdmins (CompanyId 0 filter) because IgnoreQueryFilters bypasses the global company filter.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> MarkRead(int id)
|
||||
{
|
||||
var notification = _tenant.IsPlatformAdmin()
|
||||
? await _db.InAppNotifications.IgnoreQueryFilters().FirstOrDefaultAsync(n => n.Id == id && n.CompanyId == 0 && !n.IsDeleted)
|
||||
: await _db.InAppNotifications.FirstOrDefaultAsync(n => n.Id == id);
|
||||
? await _unitOfWork.InAppNotifications.FirstOrDefaultAsync(
|
||||
n => n.Id == id && n.CompanyId == 0 && !n.IsDeleted, ignoreQueryFilters: true)
|
||||
: await _unitOfWork.InAppNotifications.GetByIdAsync(id);
|
||||
|
||||
if (notification == null) return NotFound();
|
||||
|
||||
notification.IsRead = true;
|
||||
notification.ReadAt = DateTime.UtcNow;
|
||||
notification.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks every unread notification as read for the current user's scope in a single SaveChanges call for efficiency. Returns the count of items marked so the UI can update the badge without refetching.
|
||||
/// Marks every unread notification as read for the current user's scope in a single SaveChanges call.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> MarkAllRead()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
List<PowderCoating.Core.Entities.InAppNotification> unread;
|
||||
if (_tenant.IsPlatformAdmin())
|
||||
{
|
||||
unread = await _db.InAppNotifications.IgnoreQueryFilters()
|
||||
.Where(n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead)
|
||||
.ToListAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
unread = await _db.InAppNotifications.Where(n => !n.IsRead).ToListAsync();
|
||||
}
|
||||
var unread = _tenant.IsPlatformAdmin()
|
||||
? (await _unitOfWork.InAppNotifications.FindAsync(
|
||||
n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true)).ToList()
|
||||
: (await _unitOfWork.InAppNotifications.FindAsync(n => !n.IsRead)).ToList();
|
||||
|
||||
foreach (var n in unread)
|
||||
{
|
||||
@@ -183,7 +143,7 @@ public class InAppNotificationsController : Controller
|
||||
n.UpdatedAt = now;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return Json(new { success = true, count = unread.Count });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using QRCoder;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
@@ -29,7 +27,6 @@ public class InventoryController : Controller
|
||||
private readonly IMeasurementConversionService _measurementService;
|
||||
private readonly IInventoryAiLookupService _aiLookupService;
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
public InventoryController(
|
||||
@@ -40,7 +37,6 @@ public class InventoryController : Controller
|
||||
IMeasurementConversionService measurementService,
|
||||
IInventoryAiLookupService aiLookupService,
|
||||
ISubscriptionService subscriptionService,
|
||||
ApplicationDbContext context,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
@@ -50,7 +46,6 @@ public class InventoryController : Controller
|
||||
_measurementService = measurementService;
|
||||
_aiLookupService = aiLookupService;
|
||||
_subscriptionService = subscriptionService;
|
||||
_context = context;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
@@ -166,12 +161,12 @@ public class InventoryController : Controller
|
||||
TotalCount = totalCount
|
||||
};
|
||||
|
||||
// Push stats and category list to the database rather than loading all rows
|
||||
var statsBase = _context.InventoryItems;
|
||||
ViewBag.Categories = await statsBase.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToListAsync();
|
||||
ViewBag.StatsLowStockCount = await statsBase.CountAsync(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||||
ViewBag.StatsActiveCount = await statsBase.CountAsync(i => i.IsActive);
|
||||
ViewBag.StatsTotalValue = await statsBase.SumAsync(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
|
||||
// Load all items once to compute sidebar stats and category list in memory
|
||||
var allItems = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
|
||||
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
|
||||
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||||
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
|
||||
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
|
||||
|
||||
// Set ViewBag for sorting and filters
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
@@ -560,20 +555,8 @@ public class InventoryController : Controller
|
||||
if (string.IsNullOrEmpty(colorName) && string.IsNullOrEmpty(name))
|
||||
return Json(new { success = true, photos = Array.Empty<object>(), totalCount = 0, page, pageSize });
|
||||
|
||||
IQueryable<JobPhoto> query = _context.JobPhotos
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Job).ThenInclude(j => j.Customer)
|
||||
.Where(p => !p.IsDeleted && p.Tags != null && p.Tags != "");
|
||||
|
||||
if (!string.IsNullOrEmpty(colorName) && !string.IsNullOrEmpty(name) && colorName != name)
|
||||
query = query.Where(p => p.Tags!.ToLower().Contains(colorName) || p.Tags!.ToLower().Contains(name));
|
||||
else if (!string.IsNullOrEmpty(colorName))
|
||||
query = query.Where(p => p.Tags!.ToLower().Contains(colorName));
|
||||
else
|
||||
query = query.Where(p => p.Tags!.ToLower().Contains(name!));
|
||||
|
||||
// Exact tag match (avoid "Black" matching "Gloss Black Semi-Gloss")
|
||||
var allMatches = await query.OrderByDescending(p => p.UploadedDate).ToListAsync();
|
||||
var allMatches = await _unitOfWork.JobPhotos.GetTaggedPhotosAsync(colorName, name);
|
||||
|
||||
var searchTerms = new[] { colorName, name }
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
@@ -620,15 +603,7 @@ public class InventoryController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var photos = await _context.JobPhotos
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Job).ThenInclude(j => j.Customer)
|
||||
.Include(p => p.Job).ThenInclude(j => j.JobItems).ThenInclude(ji => ji.Coats)
|
||||
.Where(p => !p.IsDeleted &&
|
||||
p.Job != null &&
|
||||
p.Job.JobItems.Any(ji => ji.Coats.Any(c => c.InventoryItemId == id)))
|
||||
.OrderByDescending(p => p.UploadedDate)
|
||||
.ToListAsync();
|
||||
var photos = await _unitOfWork.JobPhotos.GetPhotosByPowderItemAsync(id);
|
||||
|
||||
var totalCount = photos.Count;
|
||||
var paged = photos
|
||||
@@ -728,12 +703,12 @@ public class InventoryController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var allCoatings = await _context.InventoryItems
|
||||
.AsNoTracking()
|
||||
.Include(i => i.InventoryCategory)
|
||||
.Where(i => !i.IsDeleted && i.InventoryCategory != null && i.InventoryCategory.IsCoating)
|
||||
var allCoatings = (await _unitOfWork.InventoryItems.FindAsync(
|
||||
i => i.InventoryCategory != null && i.InventoryCategory.IsCoating,
|
||||
false,
|
||||
i => i.InventoryCategory))
|
||||
.OrderBy(i => i.Manufacturer).ThenBy(i => i.ColorName).ThenBy(i => i.Name)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
// Distinct manufacturer list for filter dropdown
|
||||
ViewBag.Manufacturers = allCoatings
|
||||
@@ -955,25 +930,39 @@ public class InventoryController : Controller
|
||||
|
||||
var userId = _userManager.GetUserId(User);
|
||||
|
||||
var myJobs = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => !j.IsDeleted && !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId)
|
||||
var myJobs = (await _unitOfWork.Jobs.FindAsync(
|
||||
j => !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId,
|
||||
false,
|
||||
j => j.Customer,
|
||||
j => j.JobStatus))
|
||||
.OrderBy(j => j.JobNumber)
|
||||
.Select(j => new ScanJobOption { Id = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer != null ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) : "No Customer" })
|
||||
.ToListAsync();
|
||||
.Select(j => new ScanJobOption
|
||||
{
|
||||
Id = j.Id,
|
||||
JobNumber = j.JobNumber,
|
||||
CustomerName = j.Customer != null
|
||||
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
||||
: "No Customer"
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var myJobIds = myJobs.Select(j => j.Id).ToHashSet();
|
||||
var otherJobs = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => !j.IsDeleted && !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id))
|
||||
var otherJobs = (await _unitOfWork.Jobs.FindAsync(
|
||||
j => !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id),
|
||||
false,
|
||||
j => j.Customer,
|
||||
j => j.JobStatus))
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.Take(100)
|
||||
.Select(j => new ScanJobOption { Id = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer != null ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) : "No Customer" })
|
||||
.ToListAsync();
|
||||
.Select(j => new ScanJobOption
|
||||
{
|
||||
Id = j.Id,
|
||||
JobNumber = j.JobNumber,
|
||||
CustomerName = j.Customer != null
|
||||
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
||||
: "No Customer"
|
||||
})
|
||||
.ToList();
|
||||
|
||||
ViewBag.ItemDto = _mapper.Map<InventoryItemDto>(item);
|
||||
ViewBag.MyJobs = myJobs;
|
||||
@@ -1169,29 +1158,8 @@ public class InventoryController : Controller
|
||||
.ToList();
|
||||
|
||||
// Build transactions query
|
||||
var txnQuery = _context.InventoryTransactions
|
||||
.AsNoTracking()
|
||||
.Include(t => t.InventoryItem)
|
||||
.Include(t => t.PurchaseOrder)
|
||||
.Include(t => t.Job)
|
||||
.Where(t => !t.IsDeleted);
|
||||
|
||||
if (inventoryItemId.HasValue)
|
||||
txnQuery = txnQuery.Where(t => t.InventoryItemId == inventoryItemId.Value);
|
||||
|
||||
if (dateFrom.HasValue)
|
||||
txnQuery = txnQuery.Where(t => t.TransactionDate >= dateFrom.Value);
|
||||
|
||||
if (dateTo.HasValue)
|
||||
txnQuery = txnQuery.Where(t => t.TransactionDate < dateTo.Value.AddDays(1));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<InventoryTransactionType>(typeFilter, out var parsedType))
|
||||
txnQuery = txnQuery.Where(t => t.TransactionType == parsedType);
|
||||
|
||||
var transactions = await txnQuery
|
||||
.OrderByDescending(t => t.TransactionDate)
|
||||
.Take(500)
|
||||
.ToListAsync();
|
||||
InventoryTransactionType? parsedType = !string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<InventoryTransactionType>(typeFilter, out var pt) ? pt : null;
|
||||
var transactions = await _unitOfWork.InventoryTransactions.GetForLedgerAsync(inventoryItemId, dateFrom, dateTo, parsedType);
|
||||
|
||||
// Resolve JobId for legacy JobUsage transactions that stored job number in Reference but not JobId
|
||||
var unresolvedRefs = transactions
|
||||
@@ -1202,36 +1170,12 @@ public class InventoryController : Controller
|
||||
var jobRefLookup = new Dictionary<string, (int Id, string JobNumber)>();
|
||||
if (unresolvedRefs.Any())
|
||||
{
|
||||
var matched = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Where(j => unresolvedRefs.Contains(j.JobNumber))
|
||||
.Select(j => new { j.Id, j.JobNumber })
|
||||
.ToListAsync();
|
||||
var matched = await _unitOfWork.Jobs.FindAsync(j => unresolvedRefs.Contains(j.JobNumber));
|
||||
jobRefLookup = matched.ToDictionary(j => j.JobNumber, j => (j.Id, j.JobNumber));
|
||||
}
|
||||
|
||||
// Build powder usage logs query
|
||||
var usageQuery = _context.PowderUsageLogs
|
||||
.AsNoTracking()
|
||||
.Include(u => u.Job).ThenInclude(j => j.Customer)
|
||||
.Include(u => u.InventoryItem)
|
||||
.Include(u => u.JobItemCoat)
|
||||
.Where(u => !u.IsDeleted);
|
||||
|
||||
if (inventoryItemId.HasValue)
|
||||
usageQuery = usageQuery.Where(u => u.InventoryItemId == inventoryItemId.Value);
|
||||
|
||||
if (dateFrom.HasValue)
|
||||
usageQuery = usageQuery.Where(u => u.RecordedAt >= dateFrom.Value);
|
||||
|
||||
if (dateTo.HasValue)
|
||||
usageQuery = usageQuery.Where(u => u.RecordedAt < dateTo.Value.AddDays(1));
|
||||
|
||||
// Exclude JobUsage type from transactions when showing usage tab (avoid double-counting display)
|
||||
var usageLogs = await usageQuery
|
||||
.OrderByDescending(u => u.RecordedAt)
|
||||
.Take(500)
|
||||
.ToListAsync();
|
||||
// Powder usage logs with dynamic date + item filters
|
||||
var usageLogs = await _unitOfWork.PowderUsageLogs.GetForLedgerAsync(inventoryItemId, dateFrom, dateTo);
|
||||
|
||||
InventoryItem? selectedItem = null;
|
||||
if (inventoryItemId.HasValue)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -23,35 +20,28 @@ public class JobTemplatesController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public JobTemplatesController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
ApplicationDbContext context)
|
||||
ITenantContext tenantContext)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays all non-deleted job templates for the current company, ordered by name, with their
|
||||
/// linked customer and item counts. Uses the direct <c>_context</c> query (bypassing
|
||||
/// <c>IUnitOfWork</c>) to leverage EF Core's filtered includes (<c>.Include(t => t.Items)</c>)
|
||||
/// which are not exposed through the generic repository pattern.
|
||||
/// linked customer and item counts. Multi-tenancy and soft-delete scoping are handled by global
|
||||
/// query filters; the typed repository provides the ThenInclude chain for items.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var templates = await _context.JobTemplates
|
||||
.Include(t => t.Customer)
|
||||
.Include(t => t.Items)
|
||||
.Where(t => !t.IsDeleted && t.CompanyId == companyId)
|
||||
.OrderBy(t => t.Name)
|
||||
.ToListAsync();
|
||||
var templates = await _unitOfWork.JobTemplates.GetAllAsync(
|
||||
false,
|
||||
t => t.Customer,
|
||||
t => t.Items);
|
||||
|
||||
templates = templates.OrderBy(t => t.Name).ToList();
|
||||
return View(templates);
|
||||
}
|
||||
|
||||
@@ -59,22 +49,12 @@ public class JobTemplatesController : Controller
|
||||
/// Shows the full template detail including all non-deleted items, each with their coats
|
||||
/// (including the linked inventory item for color/powder info) and prep services (including the
|
||||
/// prep service entity for the service name). Soft-deleted items, coats, and prep services are
|
||||
/// excluded via EF filtered includes so the view reflects the current active configuration.
|
||||
/// excluded via filtered includes in the typed repository.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int id)
|
||||
{
|
||||
var template = await _context.JobTemplates
|
||||
.Include(t => t.Customer)
|
||||
.Include(t => t.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.Include(t => t.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
|
||||
.ThenInclude(p => p.PrepService)
|
||||
.FirstOrDefaultAsync(t => t.Id == id && !t.IsDeleted);
|
||||
|
||||
var template = await _unitOfWork.JobTemplates.LoadForDetailsAsync(id);
|
||||
if (template == null) return NotFound();
|
||||
|
||||
return View(template);
|
||||
}
|
||||
|
||||
@@ -85,9 +65,7 @@ public class JobTemplatesController : Controller
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var template = await _context.JobTemplates
|
||||
.FirstOrDefaultAsync(t => t.Id == id && !t.IsDeleted);
|
||||
|
||||
var template = await _unitOfWork.JobTemplates.GetByIdAsync(id);
|
||||
if (template == null) return NotFound();
|
||||
|
||||
await PopulateCustomerDropdown(template.CustomerId);
|
||||
@@ -150,12 +128,7 @@ public class JobTemplatesController : Controller
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var job = await _context.Jobs
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
|
||||
.FirstOrDefaultAsync(j => j.Id == jobId && !j.IsDeleted);
|
||||
var job = await _unitOfWork.Jobs.LoadForTemplateSnapshotAsync(jobId);
|
||||
|
||||
if (job == null) return NotFound();
|
||||
|
||||
@@ -254,18 +227,7 @@ public class JobTemplatesController : Controller
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetTemplatesJson()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var templates = await _context.JobTemplates
|
||||
.Include(t => t.Customer)
|
||||
.Include(t => t.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||
.Include(t => t.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
|
||||
.ThenInclude(p => p.PrepService)
|
||||
.Where(t => !t.IsDeleted && t.CompanyId == companyId && t.IsActive)
|
||||
.OrderBy(t => t.Name)
|
||||
.ToListAsync();
|
||||
var templates = await _unitOfWork.JobTemplates.GetAllActiveWithFullIncludesAsync();
|
||||
|
||||
var result = templates.Select(t => new
|
||||
{
|
||||
|
||||
@@ -9,7 +9,6 @@ using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.Hubs;
|
||||
|
||||
@@ -19,7 +18,6 @@ namespace PowderCoating.Web.Controllers;
|
||||
public class JobsPriorityController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly ILogger<JobsPriorityController> _logger;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
@@ -27,14 +25,12 @@ public class JobsPriorityController : Controller
|
||||
|
||||
public JobsPriorityController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ApplicationDbContext context,
|
||||
ILogger<JobsPriorityController> logger,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ITenantContext tenantContext,
|
||||
IHubContext<ShopHub> shopHub)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
_userManager = userManager;
|
||||
_tenantContext = tenantContext;
|
||||
@@ -63,13 +59,7 @@ public class JobsPriorityController : Controller
|
||||
var today = date?.Date ?? DateTime.Today;
|
||||
|
||||
// Get all jobs scheduled for today with related data
|
||||
var jobs = await _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobPriority)
|
||||
.Include(j => j.AssignedUser)
|
||||
.Where(j => j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today && !j.IsDeleted)
|
||||
.ToListAsync();
|
||||
var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today);
|
||||
|
||||
// Get existing priority records for today
|
||||
var existingPriorities = await _unitOfWork.JobDailyPriorities
|
||||
@@ -108,15 +98,14 @@ public class JobsPriorityController : Controller
|
||||
.ToListAsync();
|
||||
|
||||
// Get maintenance records scheduled for today (Scheduled or InProgress)
|
||||
var maintenanceItems = await _context.MaintenanceRecords
|
||||
.Include(m => m.Equipment)
|
||||
.Include(m => m.AssignedUser)
|
||||
.Where(m => m.ScheduledDate.Date == today && !m.IsDeleted &&
|
||||
(m.Status == MaintenanceStatus.Scheduled ||
|
||||
m.Status == MaintenanceStatus.InProgress))
|
||||
var maintenanceItems = (await _unitOfWork.MaintenanceRecords.FindAsync(
|
||||
m => m.ScheduledDate.Date == today &&
|
||||
(m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.InProgress),
|
||||
false,
|
||||
m => m.Equipment, m => m.AssignedUser))
|
||||
.OrderByDescending(m => (int)m.Priority)
|
||||
.ThenBy(m => m.ScheduledDate)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
ViewBag.ScheduledDate = today;
|
||||
ViewBag.MaintenanceItems = maintenanceItems;
|
||||
@@ -378,14 +367,10 @@ public class JobsPriorityController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var record = await _context.MaintenanceRecords.FindAsync(maintenanceId);
|
||||
var record = await _unitOfWork.MaintenanceRecords.GetByIdAsync(maintenanceId);
|
||||
if (record == null || record.IsDeleted)
|
||||
return Json(new { success = false, message = "Maintenance record not found" });
|
||||
|
||||
// FindAsync bypasses global query filters — verify company ownership explicitly
|
||||
if (!_tenantContext.IsSuperAdmin() && record.CompanyId != _tenantContext.GetCurrentCompanyId())
|
||||
return Json(new { success = false, message = "Access denied." });
|
||||
|
||||
string workerName = "Unassigned";
|
||||
if (!string.IsNullOrEmpty(workerId))
|
||||
{
|
||||
@@ -402,7 +387,7 @@ public class JobsPriorityController : Controller
|
||||
}
|
||||
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true, message = "Worker assigned successfully", workerName });
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Notification;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -14,31 +13,23 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// <c>CanManageJobs</c> policy (Managers and above within a company).
|
||||
/// The platform-wide equivalent for SuperAdmins lives in
|
||||
/// <see cref="PlatformNotificationsController"/>.
|
||||
/// Uses <see cref="ApplicationDbContext"/> directly to enable LINQ projections
|
||||
/// that avoid loading full message bodies into memory on the list page.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
public class NotificationLogsController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<NotificationLogsController> _logger;
|
||||
|
||||
public NotificationLogsController(ApplicationDbContext context, ILogger<NotificationLogsController> logger)
|
||||
public NotificationLogsController(IUnitOfWork unitOfWork, ILogger<NotificationLogsController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays a paginated, filterable list of notification log entries for the
|
||||
/// current company. Supports filtering by free-text search, channel (Email/SMS),
|
||||
/// delivery status, notification type, and an optional job context.
|
||||
/// <para>
|
||||
/// The <c>pageSize</c> is validated against an allowlist (10/25/50/100) and
|
||||
/// defaults to 25 to prevent callers from requesting arbitrarily large result sets.
|
||||
/// Sorting defaults to most-recent-first (<c>SentAt DESC</c>) because operators
|
||||
/// almost always want to see the latest delivery attempts first.
|
||||
/// </para>
|
||||
/// Displays a paginated, filterable list of notification log entries for the current company.
|
||||
/// Supports filtering by free-text search, channel, delivery status, notification type, and
|
||||
/// an optional job context. Page size is validated against an allowlist (10/25/50/100).
|
||||
/// </summary>
|
||||
// GET: /NotificationLogs
|
||||
public async Task<IActionResult> Index(
|
||||
@@ -55,74 +46,35 @@ public class NotificationLogsController : Controller
|
||||
pageNumber = Math.Max(1, pageNumber);
|
||||
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
||||
|
||||
var query = _context.NotificationLogs
|
||||
.AsNoTracking()
|
||||
.Include(n => n.Customer)
|
||||
.Include(n => n.Job)
|
||||
.Include(n => n.Quote)
|
||||
.AsQueryable();
|
||||
NotificationChannel? channel = Enum.TryParse<NotificationChannel>(channelFilter, out var ch) ? ch : null;
|
||||
NotificationStatus? status = Enum.TryParse<NotificationStatus>(statusFilter, out var st) ? st : null;
|
||||
NotificationType? type = Enum.TryParse<NotificationType>(typeFilter, out var ty) ? ty : null;
|
||||
|
||||
// Filters
|
||||
if (jobId.HasValue)
|
||||
query = query.Where(n => n.JobId == jobId.Value);
|
||||
var (logs, totalCount) = await _unitOfWork.NotificationLogs.GetPagedFilteredAsync(
|
||||
pageNumber, pageSize, searchTerm, channel, status, type, jobId,
|
||||
sortColumn ?? "SentAt", sortDirection);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
var items = logs.Select(n => new NotificationLogDto
|
||||
{
|
||||
var search = searchTerm.ToLower();
|
||||
query = query.Where(n =>
|
||||
n.RecipientName.ToLower().Contains(search) ||
|
||||
n.Recipient.ToLower().Contains(search) ||
|
||||
(n.Subject != null && n.Subject.ToLower().Contains(search)) ||
|
||||
(n.Job != null && n.Job.JobNumber.ToLower().Contains(search)) ||
|
||||
(n.Quote != null && n.Quote.QuoteNumber.ToLower().Contains(search)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channelFilter) && Enum.TryParse<NotificationChannel>(channelFilter, out var channel))
|
||||
query = query.Where(n => n.Channel == channel);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(statusFilter) && Enum.TryParse<NotificationStatus>(statusFilter, out var status))
|
||||
query = query.Where(n => n.Status == status);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<NotificationType>(typeFilter, out var type))
|
||||
query = query.Where(n => n.NotificationType == type);
|
||||
|
||||
// Sorting
|
||||
query = (sortColumn ?? "SentAt") switch
|
||||
{
|
||||
"RecipientName" => sortDirection == "asc" ? query.OrderBy(n => n.RecipientName) : query.OrderByDescending(n => n.RecipientName),
|
||||
"Channel" => sortDirection == "asc" ? query.OrderBy(n => n.Channel) : query.OrderByDescending(n => n.Channel),
|
||||
"Status" => sortDirection == "asc" ? query.OrderBy(n => n.Status) : query.OrderByDescending(n => n.Status),
|
||||
"Type" => sortDirection == "asc" ? query.OrderBy(n => n.NotificationType) : query.OrderByDescending(n => n.NotificationType),
|
||||
_ => sortDirection == "asc" ? query.OrderBy(n => n.SentAt) : query.OrderByDescending(n => n.SentAt)
|
||||
};
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
var items = await query
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(n => new NotificationLogDto
|
||||
{
|
||||
Id = n.Id,
|
||||
Channel = n.Channel,
|
||||
NotificationType = n.NotificationType,
|
||||
Status = n.Status,
|
||||
RecipientName = n.RecipientName,
|
||||
Recipient = n.Recipient,
|
||||
Subject = n.Subject,
|
||||
Message = n.Message,
|
||||
ErrorMessage = n.ErrorMessage,
|
||||
SentAt = n.SentAt,
|
||||
CustomerId = n.CustomerId,
|
||||
JobId = n.JobId,
|
||||
QuoteId = n.QuoteId,
|
||||
JobNumber = n.Job != null ? n.Job.JobNumber : null,
|
||||
QuoteNumber = n.Quote != null ? n.Quote.QuoteNumber : null,
|
||||
CustomerName = n.Customer != null
|
||||
? (n.Customer.CompanyName ?? $"{n.Customer.ContactFirstName} {n.Customer.ContactLastName}".Trim())
|
||||
: null
|
||||
})
|
||||
.ToListAsync();
|
||||
Id = n.Id,
|
||||
Channel = n.Channel,
|
||||
NotificationType = n.NotificationType,
|
||||
Status = n.Status,
|
||||
RecipientName = n.RecipientName,
|
||||
Recipient = n.Recipient,
|
||||
Subject = n.Subject,
|
||||
Message = n.Message,
|
||||
ErrorMessage = n.ErrorMessage,
|
||||
SentAt = n.SentAt,
|
||||
CustomerId = n.CustomerId,
|
||||
JobId = n.JobId,
|
||||
QuoteId = n.QuoteId,
|
||||
JobNumber = n.Job?.JobNumber,
|
||||
QuoteNumber = n.Quote?.QuoteNumber,
|
||||
CustomerName = n.Customer != null
|
||||
? (n.Customer.CompanyName ?? $"{n.Customer.ContactFirstName} {n.Customer.ContactLastName}".Trim())
|
||||
: null
|
||||
}).ToList();
|
||||
|
||||
var pagedResult = new PagedResult<NotificationLogDto>
|
||||
{
|
||||
@@ -144,22 +96,17 @@ public class NotificationLogsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays the full details of a single notification log entry including the
|
||||
/// complete message body and any error message, which are omitted from the list
|
||||
/// view to keep response sizes small. Loads related Customer, Job, and Quote
|
||||
/// navigation properties to resolve display names.
|
||||
/// Displays the full details of a single notification log entry including the complete message
|
||||
/// body and any error message, which are omitted from the list view. Loads related Customer,
|
||||
/// Job, and Quote navigation properties to resolve display names.
|
||||
/// </summary>
|
||||
// GET: /NotificationLogs/Details/5
|
||||
public async Task<IActionResult> Details(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var log = await _context.NotificationLogs
|
||||
.AsNoTracking()
|
||||
.Include(n => n.Customer)
|
||||
.Include(n => n.Job)
|
||||
.Include(n => n.Quote)
|
||||
.FirstOrDefaultAsync(n => n.Id == id);
|
||||
var log = await _unitOfWork.NotificationLogs.GetByIdAsync(
|
||||
id, false, n => n.Customer, n => n.Job, n => n.Quote);
|
||||
|
||||
if (log == null) return NotFound();
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// matches automatically on localhost, dev, staging, and production without any
|
||||
/// environment-specific configuration.
|
||||
/// </summary>
|
||||
// Intentional exception: WebAuthn/FIDO2 identity infrastructure. UserPasskeys is an ASP.NET Identity concern not exposed through IUnitOfWork; the anonymous login path has no tenant context; FIDO2 async callbacks capture _db by closure. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
||||
[Route("[controller]/[action]")]
|
||||
public class PasskeyController : Controller
|
||||
{
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -10,28 +9,21 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// <summary>
|
||||
/// SuperAdmin-only cross-company notification log viewer.
|
||||
/// The company-scoped version lives in NotificationLogsController (CanManageJobs policy).
|
||||
/// Uses IgnoreQueryFilters throughout to bypass the multi-tenancy global filter and see
|
||||
/// logs from all companies simultaneously.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class PlatformNotificationsController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
public PlatformNotificationsController(ApplicationDbContext db) => _db = db;
|
||||
public PlatformNotificationsController(IUnitOfWork unitOfWork) => _unitOfWork = unitOfWork;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a paginated, filterable cross-company notification log view visible
|
||||
/// only to SuperAdmins. Unlike the tenant-scoped
|
||||
/// <see cref="NotificationLogsController.Index"/>, this action uses
|
||||
/// <c>IgnoreQueryFilters()</c> to bypass the multi-tenancy global filter and
|
||||
/// see logs from all companies simultaneously.
|
||||
/// <para>
|
||||
/// Company names are resolved in a single follow-up query after paging (not via
|
||||
/// a JOIN) because company data lives outside the notification-log table's usual
|
||||
/// query scope, and a separate query keeps the main sort/filter logic clean.
|
||||
/// Summary counts (total, failed, last 24 h) are computed as independent queries
|
||||
/// against the full unfiltered dataset so the summary reflects platform-wide
|
||||
/// health regardless of the active filters.
|
||||
/// </para>
|
||||
/// Renders a paginated, filterable cross-company notification log view visible only to SuperAdmins.
|
||||
/// All filter/sort/paging is applied in-memory after a single filtered repository fetch.
|
||||
/// Company names are resolved in a follow-up batch query keyed on the page's distinct company IDs.
|
||||
/// Summary counts reflect the full unfiltered dataset so the health cards are accurate regardless of active filters.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index(
|
||||
int? companyId,
|
||||
@@ -47,41 +39,38 @@ public class PlatformNotificationsController : Controller
|
||||
pageSize = pageSize is 25 or 50 or 100 ? pageSize : 50;
|
||||
page = Math.Max(1, page);
|
||||
|
||||
var query = _db.NotificationLogs
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => !n.IsDeleted)
|
||||
.AsQueryable();
|
||||
// Load all non-deleted logs across all tenants, then filter in-memory.
|
||||
var all = (await _unitOfWork.NotificationLogs.FindAsync(n => !n.IsDeleted, ignoreQueryFilters: true))
|
||||
.AsEnumerable();
|
||||
|
||||
if (companyId.HasValue)
|
||||
query = query.Where(n => n.CompanyId == companyId);
|
||||
all = all.Where(n => n.CompanyId == companyId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(type) && Enum.TryParse<NotificationType>(type, out var typeEnum))
|
||||
query = query.Where(n => n.NotificationType == typeEnum);
|
||||
all = all.Where(n => n.NotificationType == typeEnum);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<NotificationStatus>(status, out var statusEnum))
|
||||
query = query.Where(n => n.Status == statusEnum);
|
||||
all = all.Where(n => n.Status == statusEnum);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channel) && Enum.TryParse<NotificationChannel>(channel, out var channelEnum))
|
||||
query = query.Where(n => n.Channel == channelEnum);
|
||||
all = all.Where(n => n.Channel == channelEnum);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
query = query.Where(n =>
|
||||
n.RecipientName.Contains(search) ||
|
||||
n.Recipient.Contains(search) ||
|
||||
(n.Subject != null && n.Subject.Contains(search)));
|
||||
all = all.Where(n =>
|
||||
n.RecipientName.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||
n.Recipient.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||
(n.Subject != null && n.Subject.Contains(search, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
if (from.HasValue)
|
||||
query = query.Where(n => n.SentAt >= from.Value.Date);
|
||||
all = all.Where(n => n.SentAt >= from.Value.Date);
|
||||
|
||||
if (to.HasValue)
|
||||
query = query.Where(n => n.SentAt < to.Value.Date.AddDays(1));
|
||||
all = all.Where(n => n.SentAt < to.Value.Date.AddDays(1));
|
||||
|
||||
query = query.OrderByDescending(n => n.SentAt);
|
||||
var filtered = all.OrderByDescending(n => n.SentAt).ToList();
|
||||
var totalCount = filtered.Count;
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
var items = await query
|
||||
var items = filtered
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(n => new PlatformNotificationRow
|
||||
@@ -97,30 +86,26 @@ public class PlatformNotificationsController : Controller
|
||||
ErrorMessage = n.ErrorMessage,
|
||||
SentAt = n.SentAt
|
||||
})
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
// Resolve company names for the rows in one query
|
||||
// Resolve company names for the page's rows in one query
|
||||
var cids = items.Select(i => i.CompanyId).Distinct().ToList();
|
||||
var companyNames = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(c => cids.Contains(c.Id))
|
||||
.ToDictionaryAsync(c => c.Id, c => c.CompanyName);
|
||||
|
||||
var companyNames = (await _unitOfWork.Companies.FindAsync(c => cids.Contains(c.Id), ignoreQueryFilters: true))
|
||||
.ToDictionary(c => c.Id, c => c.CompanyName);
|
||||
foreach (var item in items)
|
||||
item.CompanyName = companyNames.GetValueOrDefault(item.CompanyId);
|
||||
|
||||
// Sidebar company list for filter dropdown
|
||||
var companies = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted)
|
||||
var companies = (await _unitOfWork.Companies.FindAsync(c => !c.IsDeleted, ignoreQueryFilters: true))
|
||||
.OrderBy(c => c.CompanyName)
|
||||
.Select(c => new { c.Id, c.CompanyName })
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
// Summary counts
|
||||
ViewBag.TotalCount = await _db.NotificationLogs.IgnoreQueryFilters().CountAsync(n => !n.IsDeleted);
|
||||
ViewBag.FailedCount = await _db.NotificationLogs.IgnoreQueryFilters()
|
||||
.CountAsync(n => !n.IsDeleted && n.Status == NotificationStatus.Failed);
|
||||
ViewBag.Last24hCount = await _db.NotificationLogs.IgnoreQueryFilters()
|
||||
.CountAsync(n => !n.IsDeleted && n.SentAt >= DateTime.UtcNow.AddHours(-24));
|
||||
// Summary counts from the unfiltered dataset
|
||||
var allLogs = await _unitOfWork.NotificationLogs.FindAsync(n => !n.IsDeleted, ignoreQueryFilters: true);
|
||||
ViewBag.TotalCount = allLogs.Count();
|
||||
ViewBag.FailedCount = allLogs.Count(n => n.Status == NotificationStatus.Failed);
|
||||
ViewBag.Last24hCount = allLogs.Count(n => n.SentAt >= DateTime.UtcNow.AddHours(-24));
|
||||
|
||||
ViewBag.Companies = companies;
|
||||
ViewBag.CompanyIdFilter = companyId;
|
||||
@@ -139,23 +124,17 @@ public class PlatformNotificationsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full notification log entry for the given <paramref name="id"/>,
|
||||
/// including the complete message body and error details. The owning company name
|
||||
/// is resolved separately (rather than via navigation property) because company
|
||||
/// records are in a different query-filter context.
|
||||
/// Returns the full notification log entry for the given id, including the complete message body and error details.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int id)
|
||||
{
|
||||
var log = await _db.NotificationLogs.AsNoTracking().IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(n => n.Id == id);
|
||||
var log = await _unitOfWork.NotificationLogs.FirstOrDefaultAsync(n => n.Id == id, ignoreQueryFilters: true);
|
||||
if (log == null) return NotFound();
|
||||
|
||||
string? companyName = null;
|
||||
if (log.CompanyId > 0)
|
||||
companyName = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(c => c.Id == log.CompanyId)
|
||||
.Select(c => c.CompanyName)
|
||||
.FirstOrDefaultAsync();
|
||||
companyName = (await _unitOfWork.Companies.FirstOrDefaultAsync(
|
||||
c => c.Id == log.CompanyId, ignoreQueryFilters: true))?.CompanyName;
|
||||
|
||||
ViewBag.CompanyName = companyName;
|
||||
return View(log);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.Hubs;
|
||||
using PowderCoating.Web.ViewModels;
|
||||
@@ -22,7 +21,7 @@ namespace PowderCoating.Web.Controllers;
|
||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Public)]
|
||||
public class QuoteApprovalController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly INotificationService _notifications;
|
||||
private readonly IInAppNotificationService _inApp;
|
||||
private readonly IStripeConnectService _stripeConnect;
|
||||
@@ -31,7 +30,7 @@ public class QuoteApprovalController : Controller
|
||||
private readonly IHubContext<NotificationHub> _hub;
|
||||
|
||||
public QuoteApprovalController(
|
||||
ApplicationDbContext db,
|
||||
IUnitOfWork unitOfWork,
|
||||
INotificationService notifications,
|
||||
IInAppNotificationService inApp,
|
||||
IStripeConnectService stripeConnect,
|
||||
@@ -39,7 +38,7 @@ public class QuoteApprovalController : Controller
|
||||
IConfiguration configuration,
|
||||
IHubContext<NotificationHub> hub)
|
||||
{
|
||||
_db = db;
|
||||
_unitOfWork = unitOfWork;
|
||||
_notifications = notifications;
|
||||
_inApp = inApp;
|
||||
_stripeConnect = stripeConnect;
|
||||
@@ -50,11 +49,8 @@ public class QuoteApprovalController : Controller
|
||||
|
||||
/// <summary>
|
||||
/// Renders the main customer-facing approval page showing quote line items, totals, and
|
||||
/// Approve/Decline buttons. The <c>[ActionName("View")]</c> attribute overrides the method name
|
||||
/// so the route is <c>/quote-approval/{token}</c> without exposing the internal method name.
|
||||
/// All token validation (expiry, already-acted) is centralised in <see cref="ValidateTokenAsync"/>.
|
||||
/// Approve/Decline buttons.
|
||||
/// </summary>
|
||||
// GET /quote-approval/{token}
|
||||
[HttpGet("{token}")]
|
||||
[ActionName("View")]
|
||||
public async Task<IActionResult> ShowApprovalPage(string token)
|
||||
@@ -67,19 +63,15 @@ public class QuoteApprovalController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the contact-details confirmation step for prospect (non-customer) quotes. Prospects
|
||||
/// have no <c>CustomerId</c> on the quote, so we collect their contact information before
|
||||
/// finalising the approval. Quotes already linked to a customer skip this step and go directly
|
||||
/// to <see cref="ApproveInternal"/> — a customer's details are already on file.
|
||||
/// Shows the contact-details confirmation step for prospect (non-customer) quotes.
|
||||
/// Quotes already linked to a customer skip this step and go directly to ApproveInternal.
|
||||
/// </summary>
|
||||
// GET /quote-approval/{token}/confirm-details
|
||||
[HttpGet("{token}/confirm-details")]
|
||||
public async Task<IActionResult> ConfirmDetails(string token)
|
||||
{
|
||||
var (quote, errorResult) = await ValidateTokenAsync(token);
|
||||
if (errorResult != null) return errorResult;
|
||||
|
||||
// Only prospects need to fill in details; linked customers go straight through.
|
||||
if (quote!.CustomerId.HasValue)
|
||||
return await ApproveInternal(token, quote);
|
||||
|
||||
@@ -88,13 +80,10 @@ public class QuoteApprovalController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the contact-detail form submission from prospects. Requires at minimum a name and
|
||||
/// either an email or phone number — this minimal validation mirrors the fields required to
|
||||
/// later convert the quote to a customer record. On success, persists the prospect contact
|
||||
/// details to the quote and delegates to <see cref="ApproveInternal"/> so the approval and
|
||||
/// audit trail are written in a single path shared with the customer flow.
|
||||
/// Handles the contact-detail form submission from prospects. Persists contact details to the
|
||||
/// quote then delegates to ApproveInternal so the approval and audit trail are written in a single
|
||||
/// path shared with the customer flow.
|
||||
/// </summary>
|
||||
// POST /quote-approval/{token}/confirm-details
|
||||
[HttpPost("{token}/confirm-details")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SubmitDetails(string token,
|
||||
@@ -110,7 +99,6 @@ public class QuoteApprovalController : Controller
|
||||
var (quote, errorResult) = await ValidateTokenAsync(token);
|
||||
if (errorResult != null) return errorResult;
|
||||
|
||||
// Require at minimum a name and either email or phone
|
||||
if (string.IsNullOrWhiteSpace(contactName) ||
|
||||
(string.IsNullOrWhiteSpace(email) && string.IsNullOrWhiteSpace(phone)))
|
||||
{
|
||||
@@ -127,7 +115,6 @@ public class QuoteApprovalController : Controller
|
||||
return base.View("ConfirmDetails", model);
|
||||
}
|
||||
|
||||
// Update prospect fields on the quote
|
||||
quote!.ProspectContactName = contactName?.Trim();
|
||||
quote.ProspectEmail = email?.Trim();
|
||||
quote.ProspectPhone = phone?.Trim();
|
||||
@@ -137,18 +124,15 @@ public class QuoteApprovalController : Controller
|
||||
quote.ProspectState = state?.Trim();
|
||||
quote.ProspectZipCode = zipCode?.Trim();
|
||||
quote.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return await ApproveInternal(token, quote);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for the Approve button on the approval page. For prospect quotes, redirects to
|
||||
/// the contact-details form rather than approving immediately. For customer-linked quotes,
|
||||
/// delegates directly to <see cref="ApproveInternal"/>. This two-path design keeps the main
|
||||
/// approval page simple (one Approve button) while still collecting required prospect data.
|
||||
/// Entry point for the Approve button. For prospect quotes, redirects to the contact-details form.
|
||||
/// For customer-linked quotes, delegates directly to ApproveInternal.
|
||||
/// </summary>
|
||||
// POST /quote-approval/{token}/approve
|
||||
[HttpPost("{token}/approve")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Approve(string token)
|
||||
@@ -156,7 +140,6 @@ public class QuoteApprovalController : Controller
|
||||
var (quote, errorResult) = await ValidateTokenAsync(token);
|
||||
if (errorResult != null) return errorResult;
|
||||
|
||||
// Prospect quotes collect contact details first
|
||||
if (!quote!.CustomerId.HasValue)
|
||||
{
|
||||
var model = await BuildViewModelAsync(quote, token);
|
||||
@@ -167,22 +150,15 @@ public class QuoteApprovalController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core approval logic shared by both the customer path (<see cref="Approve"/>) and the prospect
|
||||
/// path (<see cref="SubmitDetails"/>). Sets the quote status to the company's designated
|
||||
/// <c>IsApprovedStatus</c> lookup entry, records <c>ApprovalTokenUsedAt</c> to prevent token
|
||||
/// reuse, clears any prior decline reason (customers can re-approve after declining), and writes
|
||||
/// a <c>QuoteChangeHistory</c> audit entry with <c>ChangedByUserId = null</c> to indicate the
|
||||
/// action was performed by the customer rather than a staff member. After persisting, a SignalR
|
||||
/// push notifies logged-in staff in real time. If the quote requires a deposit and the company
|
||||
/// has Stripe Connect active, a time-limited deposit payment link token (7 days) is generated
|
||||
/// and saved so the confirmation page can surface a "Pay deposit online" button. Prospect quotes
|
||||
/// skip the deposit link because there is no <c>Customer</c> row to attach a <c>Deposit</c> to.
|
||||
/// Core approval logic shared by the customer path and the prospect path. Sets the quote status,
|
||||
/// records ApprovalTokenUsedAt, writes an audit entry, pushes a SignalR notification to staff,
|
||||
/// and optionally generates a deposit payment link token if Stripe Connect is active.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> ApproveInternal(string token, Quote quote)
|
||||
{
|
||||
var approvedStatus = await _db.QuoteStatusLookups
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(s => s.CompanyId == quote.CompanyId && s.IsApprovedStatus && !s.IsDeleted);
|
||||
var approvedStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(
|
||||
s => s.CompanyId == quote.CompanyId && s.IsApprovedStatus && !s.IsDeleted,
|
||||
ignoreQueryFilters: true);
|
||||
|
||||
var oldStatusName = quote.QuoteStatus?.DisplayName ?? "Unknown";
|
||||
|
||||
@@ -199,9 +175,9 @@ public class QuoteApprovalController : Controller
|
||||
quote.DeclineReason = null;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var approveEntry = new PowderCoating.Core.Entities.QuoteChangeHistory
|
||||
var approveEntry = new QuoteChangeHistory
|
||||
{
|
||||
QuoteId = quote.Id,
|
||||
ChangedByUserId = null,
|
||||
@@ -215,8 +191,8 @@ public class QuoteApprovalController : Controller
|
||||
CompanyId = quote.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.QuoteChangeHistories.Add(approveEntry);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.QuoteChangeHistories.AddAsync(approveEntry);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
await _hub.Clients.Group($"company-{quote.CompanyId}").SendAsync("QuoteActedByCustomer", new
|
||||
{
|
||||
@@ -246,20 +222,19 @@ public class QuoteApprovalController : Controller
|
||||
_logger.LogWarning(ex, "Failed to send approval notification for quote {QuoteId}", quote.Id);
|
||||
}
|
||||
|
||||
// Generate deposit payment link if this quote requires a deposit and the company has Stripe Connect
|
||||
if (quote.RequiresDeposit
|
||||
&& quote.DepositPercent > 0
|
||||
&& quote.CustomerId.HasValue // prospects don't have a Customer row to attach a Deposit to
|
||||
&& quote.CustomerId.HasValue
|
||||
&& quote.DepositAmountPaid <= 0)
|
||||
{
|
||||
var company = await _db.Companies.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == quote.CompanyId && !c.IsDeleted);
|
||||
var company = await _unitOfWork.Companies.FirstOrDefaultAsync(
|
||||
c => c.Id == quote.CompanyId && !c.IsDeleted);
|
||||
|
||||
if (company?.StripeConnectStatus == StripeConnectStatus.Active)
|
||||
{
|
||||
quote.DepositPaymentLinkToken = Guid.NewGuid().ToString("N");
|
||||
quote.DepositPaymentLinkExpiresAt = DateTime.UtcNow.AddDays(7);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,15 +242,9 @@ public class QuoteApprovalController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the customer declining a quote. Requires a non-empty reason (enforced with a
|
||||
/// field-level error, not ModelState) so staff always know why a quote was rejected. The
|
||||
/// decline reason is truncated to 1000 characters before persistence to avoid oversized inputs.
|
||||
/// The customer's IP is recorded (<c>DeclinedByIp</c>) for audit purposes. A SignalR event and
|
||||
/// in-app notification are pushed to staff. The token is marked used (<c>ApprovalTokenUsedAt</c>)
|
||||
/// so the customer cannot approve after declining via the same link — they must request a new
|
||||
/// link from the shop.
|
||||
/// Handles the customer declining a quote. Records the decline reason, marks the token used,
|
||||
/// pushes a SignalR notification and in-app notification to staff.
|
||||
/// </summary>
|
||||
// POST /quote-approval/{token}/decline
|
||||
[HttpPost("{token}/decline")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Decline(string token, [FromForm] string reason)
|
||||
@@ -290,13 +259,12 @@ public class QuoteApprovalController : Controller
|
||||
return base.View("ApprovalPage", model);
|
||||
}
|
||||
|
||||
// Find the rejected status for this company (by flag, or fall back to StatusCode)
|
||||
var rejectedStatus = await _db.QuoteStatusLookups
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(s => s.CompanyId == quote!.CompanyId && s.IsRejectedStatus && !s.IsDeleted)
|
||||
?? await _db.QuoteStatusLookups
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(s => s.CompanyId == quote!.CompanyId && s.StatusCode == "REJECTED" && !s.IsDeleted);
|
||||
var rejectedStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(
|
||||
s => s.CompanyId == quote!.CompanyId && s.IsRejectedStatus && !s.IsDeleted,
|
||||
ignoreQueryFilters: true)
|
||||
?? await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(
|
||||
s => s.CompanyId == quote!.CompanyId && s.StatusCode == "REJECTED" && !s.IsDeleted,
|
||||
ignoreQueryFilters: true);
|
||||
|
||||
var oldDeclineStatusName = quote!.QuoteStatus?.DisplayName ?? "Unknown";
|
||||
|
||||
@@ -308,10 +276,9 @@ public class QuoteApprovalController : Controller
|
||||
quote.DeclinedByIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
quote.ApprovalTokenUsedAt = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Audit log — decline
|
||||
var declineEntry = new PowderCoating.Core.Entities.QuoteChangeHistory
|
||||
var declineEntry = new QuoteChangeHistory
|
||||
{
|
||||
QuoteId = quote.Id,
|
||||
ChangedByUserId = null,
|
||||
@@ -323,10 +290,9 @@ public class QuoteApprovalController : Controller
|
||||
CompanyId = quote.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.QuoteChangeHistories.Add(declineEntry);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.QuoteChangeHistories.AddAsync(declineEntry);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Push real-time toast to any logged-in company users
|
||||
await _hub.Clients.Group($"company-{quote.CompanyId}").SendAsync("QuoteActedByCustomer", new
|
||||
{
|
||||
approved = false,
|
||||
@@ -359,40 +325,30 @@ public class QuoteApprovalController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the post-action confirmation page shown after the customer approves or declines.
|
||||
/// Does NOT re-validate the token against the used/expired guards in
|
||||
/// <see cref="ValidateTokenAsync"/> — by this point the token is already marked used, so those
|
||||
/// guards would incorrectly redirect to AlreadyActed. Instead, it only checks that the quote
|
||||
/// exists. The <c>action</c> query-string value ("approved" / "declined") is set by the
|
||||
/// redirect from <see cref="ApproveInternal"/> or <see cref="Decline"/> and drives the
|
||||
/// confirmation message in the view. The deposit link token is only surfaced if it is present
|
||||
/// and not yet expired.
|
||||
/// Renders the post-action confirmation page. Does NOT re-validate the token against the
|
||||
/// used/expired guards — by this point the token is already marked used.
|
||||
/// </summary>
|
||||
// GET /quote-approval/{token}/confirmation
|
||||
[HttpGet("{token}/confirmation")]
|
||||
public async Task<IActionResult> Confirmation(string token, [FromQuery] string action)
|
||||
{
|
||||
var quote = await _db.Quotes
|
||||
.IgnoreQueryFilters()
|
||||
.Include(q => q.Customer)
|
||||
.FirstOrDefaultAsync(q => q.ApprovalToken == token && !q.IsDeleted);
|
||||
var quote = await _unitOfWork.Quotes.FirstOrDefaultAsync(
|
||||
q => q.ApprovalToken == token && !q.IsDeleted,
|
||||
ignoreQueryFilters: true,
|
||||
q => q.Customer);
|
||||
|
||||
if (quote == null)
|
||||
return base.View("InvalidToken");
|
||||
|
||||
var company = await _db.Companies
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.Id == quote.CompanyId);
|
||||
var company = await _unitOfWork.Companies.FirstOrDefaultAsync(
|
||||
c => c.Id == quote.CompanyId, ignoreQueryFilters: true);
|
||||
|
||||
var prefs = await _db.CompanyPreferences
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == quote.CompanyId && !p.IsDeleted);
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == quote.CompanyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
var depositAmount = quote.RequiresDeposit && quote.DepositPercent > 0
|
||||
? Math.Round(quote.Total * (quote.DepositPercent / 100m), 2)
|
||||
: 0m;
|
||||
|
||||
// Only surface the deposit link if it's valid and not expired
|
||||
var depositToken = (!string.IsNullOrEmpty(quote.DepositPaymentLinkToken)
|
||||
&& quote.DepositPaymentLinkExpiresAt > DateTime.UtcNow)
|
||||
? quote.DepositPaymentLinkToken
|
||||
@@ -424,26 +380,16 @@ public class QuoteApprovalController : Controller
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Validates the approval token and returns the quote if it is still actionable, or an
|
||||
/// <c>IActionResult</c> error view if not. Checks in order: token exists, not expired, not
|
||||
/// already used (<c>ApprovalTokenUsedAt != null</c>), and not already in a terminal status
|
||||
/// (approved, rejected, or converted). The terminal-status check is a belt-and-suspenders guard
|
||||
/// for cases where the status was changed by staff inside the app after the token was issued but
|
||||
/// before the customer clicked. Uses <c>IgnoreQueryFilters</c> because the customer has no
|
||||
/// tenant context. Loads <c>QuoteItems</c>, <c>QuoteStatus</c>, and <c>Customer</c> eagerly to
|
||||
/// avoid N+1 queries in <see cref="BuildViewModelAsync"/>.
|
||||
/// Validates the approval token and returns the quote if still actionable. Uses
|
||||
/// IQuoteRepository.GetByApprovalTokenAsync which loads with IgnoreQueryFilters (the portal is
|
||||
/// unauthenticated — no tenant context exists on the request).
|
||||
/// </summary>
|
||||
private async Task<(Quote? quote, IActionResult? errorResult)> ValidateTokenAsync(string token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return (null, base.View("InvalidToken"));
|
||||
|
||||
var quote = await _db.Quotes
|
||||
.IgnoreQueryFilters()
|
||||
.Include(q => q.QuoteItems)
|
||||
.Include(q => q.QuoteStatus)
|
||||
.Include(q => q.Customer)
|
||||
.FirstOrDefaultAsync(q => q.ApprovalToken == token && !q.IsDeleted);
|
||||
var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token);
|
||||
|
||||
if (quote == null)
|
||||
return (null, base.View("InvalidToken"));
|
||||
@@ -462,7 +408,6 @@ public class QuoteApprovalController : Controller
|
||||
return (null, base.View("AlreadyActed", actedModel));
|
||||
}
|
||||
|
||||
// Also check terminal status
|
||||
if (quote.QuoteStatus != null &&
|
||||
(quote.QuoteStatus.IsApprovedStatus || quote.QuoteStatus.IsRejectedStatus || quote.QuoteStatus.IsConvertedStatus))
|
||||
{
|
||||
@@ -476,22 +421,16 @@ public class QuoteApprovalController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <c>QuoteApprovalViewModel</c> from a validated quote. Fetches company details and
|
||||
/// preferences (e.g. from-email address for the contact section) separately because they are
|
||||
/// not eagerly loaded by <see cref="ValidateTokenAsync"/>. Soft-deleted line items are filtered
|
||||
/// out so the customer only sees active items. Also maps all prospect contact fields so the
|
||||
/// <c>ConfirmDetails</c> view can pre-populate them if the prospect has previously started and
|
||||
/// returned to the approval page.
|
||||
/// Builds the QuoteApprovalViewModel from a validated quote. Fetches company details and
|
||||
/// preferences separately because they are not eagerly loaded by ValidateTokenAsync.
|
||||
/// </summary>
|
||||
private async Task<QuoteApprovalViewModel> BuildViewModelAsync(Quote quote, string token)
|
||||
{
|
||||
var company = await _db.Companies
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.Id == quote.CompanyId);
|
||||
var company = await _unitOfWork.Companies.FirstOrDefaultAsync(
|
||||
c => c.Id == quote.CompanyId, ignoreQueryFilters: true);
|
||||
|
||||
var prefs = await _db.CompanyPreferences
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == quote.CompanyId && !p.IsDeleted);
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == quote.CompanyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
var items = (quote.QuoteItems ?? new List<QuoteItem>())
|
||||
.Where(i => !i.IsDeleted)
|
||||
@@ -537,9 +476,7 @@ public class QuoteApprovalController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the display name for the customer or prospect on a quote. Prefers the company name
|
||||
/// for commercial customers, falls back to contact first/last name, then to prospect fields, and
|
||||
/// finally to the generic "Valued Customer" sentinel so the view always has something to show.
|
||||
/// Resolves the display name for the customer or prospect on a quote.
|
||||
/// </summary>
|
||||
private static string GetCustomerName(Quote quote)
|
||||
{
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -19,17 +18,16 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// </summary>
|
||||
public class ReleaseNotesController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IInAppNotificationService _inApp;
|
||||
|
||||
public ReleaseNotesController(ApplicationDbContext db, IInAppNotificationService inApp)
|
||||
public ReleaseNotesController(IUnitOfWork unitOfWork, IInAppNotificationService inApp)
|
||||
{
|
||||
_db = db;
|
||||
_unitOfWork = unitOfWork;
|
||||
_inApp = inApp;
|
||||
}
|
||||
|
||||
// ── Public: Changelog ────────────────────────────────────────────────────
|
||||
// Visible to all authenticated users
|
||||
|
||||
/// <summary>
|
||||
/// Renders the public changelog — shows only published release notes ordered
|
||||
@@ -39,54 +37,39 @@ public class ReleaseNotesController : Controller
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var notes = await _db.ReleaseNotes
|
||||
.AsNoTracking()
|
||||
.Where(r => r.IsPublished)
|
||||
var notes = (await _unitOfWork.ReleaseNotes.FindAsync(r => r.IsPublished))
|
||||
.OrderByDescending(r => r.ReleasedAt)
|
||||
.ThenByDescending(r => r.Id)
|
||||
.ToListAsync();
|
||||
|
||||
.ToList();
|
||||
return View(notes);
|
||||
}
|
||||
|
||||
// ── SuperAdmin: Manage ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns the SuperAdmin management list of all release notes (published and
|
||||
/// draft alike), ordered newest-first. Unlike <see cref="Index"/> there is no
|
||||
/// <c>IsPublished</c> filter here so admins can see and edit drafts.
|
||||
/// Returns the SuperAdmin management list of all release notes (published and draft alike), ordered newest-first.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public async Task<IActionResult> Manage()
|
||||
{
|
||||
var notes = await _db.ReleaseNotes
|
||||
.AsNoTracking()
|
||||
var notes = (await _unitOfWork.ReleaseNotes.GetAllAsync())
|
||||
.OrderByDescending(r => r.ReleasedAt)
|
||||
.ThenByDescending(r => r.Id)
|
||||
.ToListAsync();
|
||||
|
||||
.ToList();
|
||||
return View(notes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the Create form pre-populated with today's UTC date and the "Feature"
|
||||
/// tag as sensible defaults for new entries, reducing data-entry friction.
|
||||
/// Returns the Create form pre-populated with today's UTC date and the "Feature" tag as sensible defaults.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public IActionResult Create()
|
||||
{
|
||||
return View(new ReleaseNote
|
||||
{
|
||||
ReleasedAt = DateTime.UtcNow,
|
||||
Tag = "Feature"
|
||||
});
|
||||
return View(new ReleaseNote { ReleasedAt = DateTime.UtcNow, Tag = "Feature" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists a new release note and captures the creating SuperAdmin's identity
|
||||
/// (<c>CreatedByUserId</c> / <c>CreatedByUserName</c>) for audit purposes.
|
||||
/// New notes start unpublished by default unless the form explicitly sets
|
||||
/// <c>IsPublished = true</c>, giving authors a chance to review before going live.
|
||||
/// Persists a new release note. New notes start unpublished unless the form explicitly sets IsPublished = true.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
@@ -99,8 +82,8 @@ public class ReleaseNotesController : Controller
|
||||
model.CreatedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
model.CreatedByUserName = User.Identity?.Name;
|
||||
|
||||
_db.ReleaseNotes.Add(model);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.ReleaseNotes.AddAsync(model);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
if (model.IsPublished)
|
||||
await NotifyAllTenantsAsync(model);
|
||||
@@ -109,22 +92,18 @@ public class ReleaseNotesController : Controller
|
||||
return RedirectToAction(nameof(Manage));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the Edit form loaded from the database by primary key.
|
||||
/// </summary>
|
||||
/// <summary>Returns the Edit form loaded from the database by primary key.</summary>
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var note = await _db.ReleaseNotes.FindAsync(id);
|
||||
var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id);
|
||||
if (note == null) return NotFound();
|
||||
return View(note);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the edited values to the tracked entity. Uses explicit field
|
||||
/// mapping (rather than <c>_db.Entry(model).State = Modified</c>) to prevent
|
||||
/// over-posting attacks and to ensure audit fields like <c>CreatedAt</c> and
|
||||
/// <c>CreatedByUserId</c> are never overwritten.
|
||||
/// Applies the edited values to the tracked entity using explicit field mapping to prevent
|
||||
/// over-posting attacks and ensure audit fields are never overwritten.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
@@ -134,40 +113,37 @@ public class ReleaseNotesController : Controller
|
||||
if (!ModelState.IsValid)
|
||||
return View(model);
|
||||
|
||||
var note = await _db.ReleaseNotes.FindAsync(id);
|
||||
var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id);
|
||||
if (note == null) return NotFound();
|
||||
|
||||
note.Version = model.Version;
|
||||
note.Title = model.Title;
|
||||
note.Body = model.Body;
|
||||
note.Tag = model.Tag;
|
||||
note.IsPublished= model.IsPublished;
|
||||
note.ReleasedAt = model.ReleasedAt;
|
||||
note.UpdatedAt = DateTime.UtcNow;
|
||||
note.Version = model.Version;
|
||||
note.Title = model.Title;
|
||||
note.Body = model.Body;
|
||||
note.Tag = model.Tag;
|
||||
note.IsPublished = model.IsPublished;
|
||||
note.ReleasedAt = model.ReleasedAt;
|
||||
note.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Release note v{note.Version} updated.";
|
||||
return RedirectToAction(nameof(Manage));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the published state of a release note. Publishing makes the note
|
||||
/// immediately visible to all authenticated users via <see cref="Index"/>;
|
||||
/// un-publishing hides it without permanently deleting it so it can be revised
|
||||
/// and re-published later.
|
||||
/// Toggles the published state of a release note. Publishing fires an in-app notification to all tenants.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public async Task<IActionResult> TogglePublish(int id)
|
||||
{
|
||||
var note = await _db.ReleaseNotes.FindAsync(id);
|
||||
var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id);
|
||||
if (note == null) return NotFound();
|
||||
|
||||
var wasPublished = note.IsPublished;
|
||||
note.IsPublished = !note.IsPublished;
|
||||
note.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
if (note.IsPublished && !wasPublished)
|
||||
await NotifyAllTenantsAsync(note);
|
||||
@@ -179,29 +155,24 @@ public class ReleaseNotesController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Permanently (hard) deletes a release note. This is intentional — release notes
|
||||
/// are platform metadata, not business data, so they do not use soft delete.
|
||||
/// Use <see cref="TogglePublish"/> to hide a note without permanent removal.
|
||||
/// Permanently (hard) deletes a release note. Release notes are platform metadata and do not use soft delete.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var note = await _db.ReleaseNotes.FindAsync(id);
|
||||
var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id);
|
||||
if (note == null) return NotFound();
|
||||
|
||||
_db.ReleaseNotes.Remove(note);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.ReleaseNotes.DeleteAsync(note);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Release note v{note.Version} deleted.";
|
||||
return RedirectToAction(nameof(Manage));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fans out a "What's New" in-app notification to every active tenant company when
|
||||
/// a release note transitions to published. Notification fires exactly once per
|
||||
/// publish event — re-publishing after unpublishing will send a second notification,
|
||||
/// which is intentional (the content may have changed).
|
||||
/// Fans out a "What's New" in-app notification to every active tenant company when a release note is published.
|
||||
/// </summary>
|
||||
private Task NotifyAllTenantsAsync(ReleaseNote note)
|
||||
{
|
||||
|
||||
@@ -9,7 +9,6 @@ using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.ViewModels.Reports;
|
||||
|
||||
@@ -20,17 +19,19 @@ public class ReportsController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<ReportsController> _logger;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IFinancialReportService _financialReports;
|
||||
private readonly IOperationalReportService _operationalReports;
|
||||
private readonly IPdfService _pdfService;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly IAccountingAiService _accountingAi;
|
||||
private readonly IAiUsageLogger _usageLogger;
|
||||
|
||||
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, ApplicationDbContext context, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger)
|
||||
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
_context = context;
|
||||
_financialReports = financialReports;
|
||||
_operationalReports = operationalReports;
|
||||
_pdfService = pdfService;
|
||||
_userManager = userManager;
|
||||
_accountingAi = accountingAi;
|
||||
@@ -493,11 +494,7 @@ public class ReportsController : Controller
|
||||
.ToList();
|
||||
|
||||
// === EXPENSE / AP ANALYTICS ===
|
||||
var allBills = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Include(b => b.Payments.Where(p => !p.IsDeleted))
|
||||
.Where(b => !b.IsDeleted && b.Status != BillStatus.Voided)
|
||||
.ToListAsync();
|
||||
var allBills = await _operationalReports.GetActiveBillsAsync();
|
||||
|
||||
var totalBilled = allBills.Sum(b => b.Total);
|
||||
var totalBillsPaid = allBills.Sum(b => b.AmountPaid);
|
||||
@@ -536,10 +533,7 @@ public class ReportsController : Controller
|
||||
.ToList();
|
||||
|
||||
// Expenses by account
|
||||
var allExpenses = await _context.Expenses
|
||||
.Include(e => e.ExpenseAccount)
|
||||
.Where(e => !e.IsDeleted)
|
||||
.ToListAsync();
|
||||
var allExpenses = await _operationalReports.GetAllExpensesAsync();
|
||||
|
||||
var expensesByAccount = allExpenses
|
||||
.Where(e => e.ExpenseAccount != null)
|
||||
@@ -664,10 +658,7 @@ public class ReportsController : Controller
|
||||
.ToList();
|
||||
|
||||
// === JOB CYCLE TIME ===
|
||||
var allStatusHistory = await _context.JobStatusHistory
|
||||
.Include(h => h.FromStatus)
|
||||
.Include(h => h.ToStatus)
|
||||
.ToListAsync();
|
||||
var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();
|
||||
|
||||
var historyByJob = allStatusHistory
|
||||
.GroupBy(h => h.JobId)
|
||||
@@ -1002,102 +993,10 @@ public class ReportsController : Controller
|
||||
public async Task<IActionResult> ProfitAndLoss(DateTime? from, DateTime? to)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var toEnd = toDate.AddDays(1).AddTicks(-1);
|
||||
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
// ── Revenue: InvoiceItems posted to revenue accounts ──────────────────
|
||||
var revenueByAccount = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId != null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.GroupBy(ii => ii.RevenueAccountId!.Value)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) })
|
||||
.ToListAsync();
|
||||
|
||||
// Unlinked invoice totals (items without a revenue account) → lump into a default "Sales" bucket
|
||||
var unlinkedRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId == null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
|
||||
var revenueAccounts = await _context.Accounts
|
||||
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
|
||||
.ToDictionaryAsync(a => a.Id);
|
||||
|
||||
var revenueLines = revenueByAccount
|
||||
.Where(r => revenueAccounts.ContainsKey(r.AccountId))
|
||||
.Select(r => new FinancialReportLine
|
||||
{
|
||||
AccountId = r.AccountId,
|
||||
AccountNumber = revenueAccounts[r.AccountId].AccountNumber,
|
||||
AccountName = revenueAccounts[r.AccountId].Name,
|
||||
Amount = r.Amount
|
||||
})
|
||||
.OrderBy(l => l.AccountNumber)
|
||||
.ToList();
|
||||
|
||||
if (unlinkedRevenue > 0)
|
||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
|
||||
|
||||
// ── COGS & Expenses: Expenses + BillLineItems by account type ─────────
|
||||
// Direct expenses
|
||||
var directByAccount = await _context.Expenses
|
||||
.Where(e => e.Date >= fromDate && e.Date <= toEnd)
|
||||
.GroupBy(e => e.ExpenseAccountId)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||
.ToListAsync();
|
||||
|
||||
// Bill line items (skip lines with no account — QB-imported without account mapping)
|
||||
var billLinesByAccount = await _context.BillLineItems
|
||||
.Where(bli => bli.AccountId != null
|
||||
&& bli.Bill.Status != BillStatus.Draft
|
||||
&& bli.Bill.Status != BillStatus.Voided
|
||||
&& bli.Bill.BillDate >= fromDate && bli.Bill.BillDate <= toEnd)
|
||||
.GroupBy(bli => bli.AccountId!.Value)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) })
|
||||
.ToListAsync();
|
||||
|
||||
// Merge the two expense sources per account
|
||||
var expenseAmounts = new Dictionary<int, decimal>();
|
||||
foreach (var e in directByAccount)
|
||||
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
|
||||
foreach (var b in billLinesByAccount)
|
||||
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
|
||||
|
||||
var expAccounts = await _context.Accounts
|
||||
.Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive)
|
||||
.ToDictionaryAsync(a => a.Id);
|
||||
|
||||
var cogsLines = new List<FinancialReportLine>();
|
||||
var expenseLines = new List<FinancialReportLine>();
|
||||
|
||||
foreach (var (accountId, amount) in expenseAmounts.OrderBy(kv => expAccounts.ContainsKey(kv.Key) ? expAccounts[kv.Key].AccountNumber : "999"))
|
||||
{
|
||||
if (!expAccounts.TryGetValue(accountId, out var acct)) continue;
|
||||
var line = new FinancialReportLine { AccountId = accountId, AccountNumber = acct.AccountNumber, AccountName = acct.Name, Amount = amount };
|
||||
if (acct.AccountType == AccountType.CostOfGoods) cogsLines.Add(line);
|
||||
else expenseLines.Add(line);
|
||||
}
|
||||
|
||||
var dto = new ProfitAndLossDto
|
||||
{
|
||||
From = fromDate,
|
||||
To = toDate,
|
||||
CompanyName = companyName,
|
||||
RevenueLines = revenueLines,
|
||||
TotalRevenue = revenueLines.Sum(l => l.Amount),
|
||||
CogsLines = cogsLines,
|
||||
TotalCogs = cogsLines.Sum(l => l.Amount),
|
||||
ExpenseLines = expenseLines,
|
||||
TotalExpenses = expenseLines.Sum(l => l.Amount),
|
||||
};
|
||||
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetProfitAndLossAsync(companyId, fromDate, toDate);
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -1116,157 +1015,9 @@ public class ReportsController : Controller
|
||||
public async Task<IActionResult> BalanceSheet(DateTime? asOf)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var asOfEnd = asOfDate.AddDays(1).AddTicks(-1);
|
||||
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
// ── Pre-compute balance contributions per account (batch queries) ──────
|
||||
|
||||
// Asset: payments deposited INTO account (DEBIT) — exclude voided/written-off invoices
|
||||
var depositsByAcct = await _context.Payments
|
||||
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||
.GroupBy(p => p.DepositAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Asset: expenses paid FROM account (CREDIT)
|
||||
var expFromByAcct = await _context.Expenses
|
||||
.Where(e => e.Date <= asOfEnd)
|
||||
.GroupBy(e => e.PaymentAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Asset: bill payments FROM account (CREDIT)
|
||||
var bpFromByAcct = await _context.BillPayments
|
||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.BankAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Liability: bills posted to AP account (CREDIT)
|
||||
var billsByApAcct = await _context.Bills
|
||||
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
||||
.GroupBy(b => b.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Liability: bill payments reducing AP (DEBIT)
|
||||
var bpByApAcct = await _context.BillPayments
|
||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.Bill.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Liability: sales tax payable (CREDIT)
|
||||
var taxByAcct = await _context.Invoices
|
||||
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate <= asOfEnd)
|
||||
.GroupBy(i => i.SalesTaxAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(i => i.TaxAmount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// AR total (used for AR sub-type accounts)
|
||||
var arDebits = await _context.Invoices.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd).SumAsync(i => (decimal?)i.Total) ?? 0;
|
||||
var arCredits = await _context.Payments.Where(p => p.PaymentDate <= asOfEnd
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff).SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
// ── Retained earnings = net P&L from inception ────────────────────────
|
||||
var lifetimeRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
var lifetimeCogs = await _context.Expenses.Where(e => e.Date <= asOfEnd).Include(e => e.ExpenseAccount).SumAsync(e => (decimal?)e.Amount) ?? 0;
|
||||
var lifetimeBillCosts = await _context.BillLineItems.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd).SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
||||
var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts;
|
||||
|
||||
// ── Compute balance for each account ──────────────────────────────────
|
||||
decimal ComputeBalance(Account a)
|
||||
{
|
||||
bool normalDebit = a.AccountType == AccountType.Asset;
|
||||
decimal debits = 0, credits = 0;
|
||||
|
||||
if (a.AccountSubType == AccountSubType.AccountsReceivable)
|
||||
{
|
||||
debits = arDebits; credits = arCredits;
|
||||
}
|
||||
else if (a.AccountSubType == AccountSubType.AccountsPayable)
|
||||
{
|
||||
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
||||
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
debits += depositsByAcct.GetValueOrDefault(a.Id);
|
||||
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
||||
credits += bpFromByAcct.GetValueOrDefault(a.Id);
|
||||
credits += taxByAcct.GetValueOrDefault(a.Id);
|
||||
}
|
||||
|
||||
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOfDate)
|
||||
? a.OpeningBalance : 0;
|
||||
decimal net = normalDebit ? debits - credits : credits - debits;
|
||||
return opening + net;
|
||||
}
|
||||
|
||||
FinancialReportLine ToLine(Account a) => new()
|
||||
{
|
||||
AccountId = a.Id,
|
||||
AccountNumber = a.AccountNumber,
|
||||
AccountName = a.Name,
|
||||
Amount = ComputeBalance(a)
|
||||
};
|
||||
|
||||
// Load accounts by type
|
||||
var accounts = await _context.Accounts.Where(a => a.IsActive).OrderBy(a => a.AccountNumber).ToListAsync();
|
||||
|
||||
var assetAccts = accounts.Where(a => a.AccountType == AccountType.Asset).ToList();
|
||||
var liabilityAccts = accounts.Where(a => a.AccountType == AccountType.Liability).ToList();
|
||||
var equityAccts = accounts.Where(a => a.AccountType == AccountType.Equity).ToList();
|
||||
|
||||
var currentAssets = assetAccts
|
||||
.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings
|
||||
or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset)
|
||||
.Select(ToLine).ToList();
|
||||
var fixedAssets = assetAccts
|
||||
.Where(a => a.AccountSubType == AccountSubType.FixedAsset)
|
||||
.Select(ToLine).ToList();
|
||||
var otherAssets = assetAccts
|
||||
.Where(a => a.AccountSubType == AccountSubType.OtherAsset)
|
||||
.Select(ToLine).ToList();
|
||||
|
||||
var currentLiabilities = liabilityAccts
|
||||
.Where(a => a.AccountSubType is AccountSubType.AccountsPayable or AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability)
|
||||
.Select(ToLine).ToList();
|
||||
var longTermLiabilities = liabilityAccts
|
||||
.Where(a => a.AccountSubType == AccountSubType.LongTermLiability)
|
||||
.Select(ToLine).ToList();
|
||||
|
||||
var equityLines = equityAccts.Select(ToLine).ToList();
|
||||
|
||||
var totalAssets = currentAssets.Sum(l => l.Amount) + fixedAssets.Sum(l => l.Amount) + otherAssets.Sum(l => l.Amount);
|
||||
var totalLiabilities = currentLiabilities.Sum(l => l.Amount) + longTermLiabilities.Sum(l => l.Amount);
|
||||
var totalEquity = equityLines.Sum(l => l.Amount) + retainedEarnings;
|
||||
|
||||
var dto = new BalanceSheetDto
|
||||
{
|
||||
AsOf = asOfDate,
|
||||
CompanyName = companyName,
|
||||
CurrentAssets = currentAssets,
|
||||
FixedAssets = fixedAssets,
|
||||
OtherAssets = otherAssets,
|
||||
TotalAssets = totalAssets,
|
||||
CurrentLiabilities = currentLiabilities,
|
||||
LongTermLiabilities = longTermLiabilities,
|
||||
TotalLiabilities = totalLiabilities,
|
||||
EquityLines = equityLines,
|
||||
RetainedEarnings = retainedEarnings,
|
||||
TotalEquity = totalEquity,
|
||||
};
|
||||
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetBalanceSheetAsync(companyId, asOfDate);
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -1282,85 +1033,9 @@ public class ReportsController : Controller
|
||||
public async Task<IActionResult> ArAging(DateTime? asOf)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var asOfEnd = asOfDate.AddDays(1).AddTicks(-1);
|
||||
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var openInvoices = await _context.Invoices
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.Status != InvoiceStatus.Paid
|
||||
&& i.InvoiceDate <= asOfEnd
|
||||
&& (i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed) > 0)
|
||||
.OrderBy(i => i.Customer!.CompanyName)
|
||||
.ThenBy(i => i.DueDate)
|
||||
.ToListAsync();
|
||||
|
||||
var customerGroups = openInvoices
|
||||
.GroupBy(i => new { i.CustomerId, i.Customer!.CompanyName, i.Customer.ContactFirstName, i.Customer.ContactLastName, i.Customer.IsCommercial });
|
||||
|
||||
static string AgingBucket(int daysOverdue) => daysOverdue switch
|
||||
{
|
||||
<= 0 => "current",
|
||||
<= 30 => "1-30",
|
||||
<= 60 => "31-60",
|
||||
<= 90 => "61-90",
|
||||
_ => "90+"
|
||||
};
|
||||
|
||||
var customers = new List<ArAgingCustomerDto>();
|
||||
|
||||
foreach (var grp in customerGroups)
|
||||
{
|
||||
var customerName = grp.Key.IsCommercial
|
||||
? grp.Key.CompanyName
|
||||
: $"{grp.Key.ContactFirstName} {grp.Key.ContactLastName}".Trim();
|
||||
|
||||
var custDto = new ArAgingCustomerDto { CustomerId = grp.Key.CustomerId, CustomerName = customerName };
|
||||
|
||||
foreach (var inv in grp)
|
||||
{
|
||||
var balance = inv.BalanceDue;
|
||||
var daysOverdue = inv.DueDate.HasValue ? (int)(asOfDate - inv.DueDate.Value.Date).TotalDays : 0;
|
||||
var bucket = AgingBucket(daysOverdue);
|
||||
|
||||
custDto.Invoices.Add(new ArAgingInvoiceDto
|
||||
{
|
||||
InvoiceId = inv.Id,
|
||||
InvoiceNumber = inv.InvoiceNumber,
|
||||
InvoiceDate = inv.InvoiceDate,
|
||||
DueDate = inv.DueDate,
|
||||
BalanceDue = balance,
|
||||
DaysOverdue = daysOverdue
|
||||
});
|
||||
|
||||
switch (bucket)
|
||||
{
|
||||
case "current": custDto.TotalCurrent += balance; break;
|
||||
case "1-30": custDto.Total1to30 += balance; break;
|
||||
case "31-60": custDto.Total31to60 += balance; break;
|
||||
case "61-90": custDto.Total61to90 += balance; break;
|
||||
default: custDto.TotalOver90 += balance; break;
|
||||
}
|
||||
}
|
||||
|
||||
customers.Add(custDto);
|
||||
}
|
||||
|
||||
var dto = new ArAgingReportDto
|
||||
{
|
||||
AsOf = asOfDate,
|
||||
CompanyName = companyName,
|
||||
Customers = customers.OrderByDescending(c => c.TotalBalance).ToList(),
|
||||
TotalCurrent = customers.Sum(c => c.TotalCurrent),
|
||||
Total1to30 = customers.Sum(c => c.Total1to30),
|
||||
Total31to60 = customers.Sum(c => c.Total31to60),
|
||||
Total61to90 = customers.Sum(c => c.Total61to90),
|
||||
TotalOver90 = customers.Sum(c => c.TotalOver90),
|
||||
};
|
||||
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetArAgingAsync(companyId, asOfDate);
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -1375,90 +1050,10 @@ public class ReportsController : Controller
|
||||
// GET: /Reports/SalesAndIncome
|
||||
public async Task<IActionResult> SalesAndIncome(DateTime? from, DateTime? to)
|
||||
{
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var toEnd = toDate.AddDays(1).AddTicks(-1);
|
||||
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var invoices = await _context.Invoices
|
||||
.Include(i => i.Customer)
|
||||
.Include(i => i.Payments)
|
||||
.Where(i => i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate >= fromDate && i.InvoiceDate <= toEnd)
|
||||
.OrderBy(i => i.InvoiceDate)
|
||||
.ToListAsync();
|
||||
|
||||
// Payments collected within the period (may differ from invoice dates)
|
||||
var collectedInPeriod = await _context.Payments
|
||||
.Where(p => p.PaymentDate >= fromDate && p.PaymentDate <= toEnd)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
// By customer
|
||||
var byCustomer = invoices
|
||||
.GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial
|
||||
? i.Customer.CompanyName
|
||||
: $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() })
|
||||
.Select(g => new SalesByCustomerDto
|
||||
{
|
||||
CustomerId = g.Key.CustomerId,
|
||||
CustomerName = g.Key.Name,
|
||||
InvoiceCount = g.Count(),
|
||||
TotalInvoiced = g.Sum(i => i.Total),
|
||||
TotalPaid = g.Sum(i => i.AmountPaid),
|
||||
BalanceDue = g.Sum(i => i.BalanceDue),
|
||||
})
|
||||
.OrderByDescending(c => c.TotalInvoiced)
|
||||
.ToList();
|
||||
|
||||
// By month
|
||||
var byMonth = invoices
|
||||
.GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
|
||||
.Select(g => new SalesByMonthDto
|
||||
{
|
||||
Year = g.Key.Year,
|
||||
Month = g.Key.Month,
|
||||
Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"),
|
||||
TotalInvoiced = g.Sum(i => i.Total),
|
||||
TotalCollected = g.Sum(i => i.AmountPaid),
|
||||
InvoiceCount = g.Count(),
|
||||
})
|
||||
.OrderBy(m => m.Year).ThenBy(m => m.Month)
|
||||
.ToList();
|
||||
|
||||
// Invoice detail lines
|
||||
var invoiceLines = invoices.Select(i => new SalesInvoiceLineDto
|
||||
{
|
||||
InvoiceId = i.Id,
|
||||
InvoiceNumber = i.InvoiceNumber,
|
||||
CustomerName = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(),
|
||||
InvoiceDate = i.InvoiceDate,
|
||||
DueDate = i.DueDate,
|
||||
Status = i.Status.ToString(),
|
||||
SubTotal = i.SubTotal,
|
||||
TaxAmount = i.TaxAmount,
|
||||
Total = i.Total,
|
||||
AmountPaid = i.AmountPaid,
|
||||
BalanceDue = i.BalanceDue,
|
||||
}).ToList();
|
||||
|
||||
var dto = new SalesIncomeReportDto
|
||||
{
|
||||
From = fromDate,
|
||||
To = toDate,
|
||||
CompanyName = companyName,
|
||||
TotalInvoiced = invoices.Sum(i => i.Total),
|
||||
TotalCollected = collectedInPeriod,
|
||||
TotalTax = invoices.Sum(i => i.TaxAmount),
|
||||
TotalDiscount = invoices.Sum(i => i.DiscountAmount),
|
||||
InvoiceCount = invoices.Count,
|
||||
CustomerCount = invoices.Select(i => i.CustomerId).Distinct().Count(),
|
||||
ByCustomer = byCustomer,
|
||||
ByMonth = byMonth,
|
||||
Invoices = invoiceLines,
|
||||
};
|
||||
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetSalesAndIncomeAsync(companyId, fromDate, toDate);
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -1476,64 +1071,10 @@ public class ReportsController : Controller
|
||||
public async Task<IActionResult> ProfitAndLossPdf(DateTime? from, DateTime? to, bool inline = false)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var toEnd = toDate.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var revenueByAccount = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId != null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.GroupBy(ii => ii.RevenueAccountId!.Value)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) })
|
||||
.ToListAsync();
|
||||
|
||||
var unlinkedRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId == null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
|
||||
var revenueAccounts = await _context.Accounts.Where(a => a.AccountType == AccountType.Revenue && a.IsActive).ToDictionaryAsync(a => a.Id);
|
||||
|
||||
var revenueLines = revenueByAccount
|
||||
.Where(r => revenueAccounts.ContainsKey(r.AccountId))
|
||||
.Select(r => new FinancialReportLine { AccountId = r.AccountId, AccountNumber = revenueAccounts[r.AccountId].AccountNumber, AccountName = revenueAccounts[r.AccountId].Name, Amount = r.Amount })
|
||||
.OrderBy(l => l.AccountNumber).ToList();
|
||||
|
||||
if (unlinkedRevenue > 0)
|
||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
|
||||
|
||||
var directByAccount = await _context.Expenses.Where(e => e.Date >= fromDate && e.Date <= toEnd)
|
||||
.GroupBy(e => e.ExpenseAccountId).Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) }).ToListAsync();
|
||||
|
||||
var billLinesByAccount = await _context.BillLineItems
|
||||
.Where(bli => bli.AccountId != null && bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate >= fromDate && bli.Bill.BillDate <= toEnd)
|
||||
.GroupBy(bli => bli.AccountId!.Value).Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) }).ToListAsync();
|
||||
|
||||
var expenseAmounts = new Dictionary<int, decimal>();
|
||||
foreach (var e in directByAccount) expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
|
||||
foreach (var b in billLinesByAccount) expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
|
||||
|
||||
var expAccounts = await _context.Accounts.Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive).ToDictionaryAsync(a => a.Id);
|
||||
|
||||
var cogsLines = new List<FinancialReportLine>(); var expenseLines = new List<FinancialReportLine>();
|
||||
foreach (var (accountId, amount) in expenseAmounts.OrderBy(kv => expAccounts.ContainsKey(kv.Key) ? expAccounts[kv.Key].AccountNumber : "999"))
|
||||
{
|
||||
if (!expAccounts.TryGetValue(accountId, out var acct)) continue;
|
||||
var line = new FinancialReportLine { AccountId = accountId, AccountNumber = acct.AccountNumber, AccountName = acct.Name, Amount = amount };
|
||||
if (acct.AccountType == AccountType.CostOfGoods) cogsLines.Add(line); else expenseLines.Add(line);
|
||||
}
|
||||
|
||||
var dto = new ProfitAndLossDto
|
||||
{
|
||||
From = fromDate, To = toDate, CompanyName = companyName,
|
||||
RevenueLines = revenueLines, TotalRevenue = revenueLines.Sum(l => l.Amount),
|
||||
CogsLines = cogsLines, TotalCogs = cogsLines.Sum(l => l.Amount),
|
||||
ExpenseLines = expenseLines, TotalExpenses = expenseLines.Sum(l => l.Amount),
|
||||
};
|
||||
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetProfitAndLossAsync(companyId, fromDate, toDate);
|
||||
var pdfBytes = await _pdfService.GenerateProfitAndLossPdfAsync(dto);
|
||||
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"ProfitAndLoss-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
@@ -1546,75 +1087,9 @@ public class ReportsController : Controller
|
||||
public async Task<IActionResult> BalanceSheetPdf(DateTime? asOf, bool inline = false)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var asOfEnd = asOfDate.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var depositsByAcct = await _context.Payments.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null)
|
||||
.GroupBy(p => p.DepositAccountId!.Value).Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var expFromByAcct = await _context.Expenses.Where(e => e.Date <= asOfEnd)
|
||||
.GroupBy(e => e.PaymentAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var bpFromByAcct = await _context.BillPayments.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.BankAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var billsByApAcct = await _context.Bills.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
||||
.GroupBy(b => b.APAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var bpByApAcct = await _context.BillPayments.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.Bill.APAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var taxByAcct = await _context.Invoices.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0 && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd)
|
||||
.GroupBy(i => i.SalesTaxAccountId!.Value).Select(g => new { Id = g.Key, Amount = g.Sum(i => i.TaxAmount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var arDebits = await _context.Invoices.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd).SumAsync(i => (decimal?)i.Total) ?? 0;
|
||||
var arCredits = await _context.Payments.Where(p => p.PaymentDate <= asOfEnd).SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
var lifetimeRevenue = await _context.InvoiceItems.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd).SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
var lifetimeCogs = await _context.Expenses.Where(e => e.Date <= asOfEnd).Include(e => e.ExpenseAccount).SumAsync(e => (decimal?)e.Amount) ?? 0;
|
||||
var lifetimeBillCosts = await _context.BillLineItems.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd).SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
||||
var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts;
|
||||
|
||||
var accounts = await _context.Accounts.Where(a => a.IsActive).OrderBy(a => a.AccountNumber).ToListAsync();
|
||||
|
||||
decimal ComputeBalance(Account a)
|
||||
{
|
||||
bool normalDebit = a.AccountType == AccountType.Asset;
|
||||
decimal debits = 0, credits = 0;
|
||||
if (a.AccountSubType == AccountSubType.AccountsReceivable) { debits = arDebits; credits = arCredits; }
|
||||
else if (a.AccountSubType == AccountSubType.AccountsPayable) { credits = billsByApAcct.GetValueOrDefault(a.Id); debits = bpByApAcct.GetValueOrDefault(a.Id); }
|
||||
else { debits += depositsByAcct.GetValueOrDefault(a.Id); credits += expFromByAcct.GetValueOrDefault(a.Id); credits += bpFromByAcct.GetValueOrDefault(a.Id); credits += taxByAcct.GetValueOrDefault(a.Id); }
|
||||
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOfDate) ? a.OpeningBalance : 0;
|
||||
decimal net = normalDebit ? debits - credits : credits - debits;
|
||||
return opening + net;
|
||||
}
|
||||
|
||||
FinancialReportLine ToLine(Account a) => new() { AccountId = a.Id, AccountNumber = a.AccountNumber, AccountName = a.Name, Amount = ComputeBalance(a) };
|
||||
|
||||
var assetAccts = accounts.Where(a => a.AccountType == AccountType.Asset).ToList();
|
||||
var liabilityAccts = accounts.Where(a => a.AccountType == AccountType.Liability).ToList();
|
||||
var equityAccts = accounts.Where(a => a.AccountType == AccountType.Equity).ToList();
|
||||
|
||||
var currentAssets = assetAccts.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset).Select(ToLine).ToList();
|
||||
var fixedAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.FixedAsset).Select(ToLine).ToList();
|
||||
var otherAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.OtherAsset).Select(ToLine).ToList();
|
||||
var currentLiabilities = liabilityAccts.Where(a => a.AccountSubType is AccountSubType.AccountsPayable or AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability).Select(ToLine).ToList();
|
||||
var longTermLiabilities = liabilityAccts.Where(a => a.AccountSubType == AccountSubType.LongTermLiability).Select(ToLine).ToList();
|
||||
var equityLines = equityAccts.Select(ToLine).ToList();
|
||||
|
||||
var totalAssets = currentAssets.Sum(l => l.Amount) + fixedAssets.Sum(l => l.Amount) + otherAssets.Sum(l => l.Amount);
|
||||
var totalLiabilities = currentLiabilities.Sum(l => l.Amount) + longTermLiabilities.Sum(l => l.Amount);
|
||||
var totalEquity = equityLines.Sum(l => l.Amount) + retainedEarnings;
|
||||
|
||||
var dto = new BalanceSheetDto
|
||||
{
|
||||
AsOf = asOfDate, CompanyName = companyName,
|
||||
CurrentAssets = currentAssets, FixedAssets = fixedAssets, OtherAssets = otherAssets, TotalAssets = totalAssets,
|
||||
CurrentLiabilities = currentLiabilities, LongTermLiabilities = longTermLiabilities, TotalLiabilities = totalLiabilities,
|
||||
EquityLines = equityLines, RetainedEarnings = retainedEarnings, TotalEquity = totalEquity,
|
||||
};
|
||||
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetBalanceSheetAsync(companyId, asOfDate);
|
||||
var pdfBytes = await _pdfService.GenerateBalanceSheetPdfAsync(dto);
|
||||
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"BalanceSheet-{asOfDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
@@ -1627,46 +1102,9 @@ public class ReportsController : Controller
|
||||
public async Task<IActionResult> ArAgingPdf(DateTime? asOf, bool inline = false)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var asOfEnd = asOfDate.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var openInvoices = await _context.Invoices
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.Status != InvoiceStatus.Paid && i.InvoiceDate <= asOfEnd
|
||||
&& (i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed) > 0)
|
||||
.OrderBy(i => i.Customer!.CompanyName).ThenBy(i => i.DueDate)
|
||||
.ToListAsync();
|
||||
|
||||
var customerGroups = openInvoices.GroupBy(i => new { i.CustomerId, i.Customer!.CompanyName, i.Customer.ContactFirstName, i.Customer.ContactLastName, i.Customer.IsCommercial });
|
||||
|
||||
static string AgingBucket(int d) => d switch { <= 0 => "current", <= 30 => "1-30", <= 60 => "31-60", <= 90 => "61-90", _ => "90+" };
|
||||
|
||||
var customers = new List<ArAgingCustomerDto>();
|
||||
foreach (var grp in customerGroups)
|
||||
{
|
||||
var customerName = grp.Key.IsCommercial ? grp.Key.CompanyName : $"{grp.Key.ContactFirstName} {grp.Key.ContactLastName}".Trim();
|
||||
var custDto = new ArAgingCustomerDto { CustomerId = grp.Key.CustomerId, CustomerName = customerName };
|
||||
foreach (var inv in grp)
|
||||
{
|
||||
var balance = inv.BalanceDue;
|
||||
var daysOverdue = inv.DueDate.HasValue ? (int)(asOfDate - inv.DueDate.Value.Date).TotalDays : 0;
|
||||
custDto.Invoices.Add(new ArAgingInvoiceDto { InvoiceId = inv.Id, InvoiceNumber = inv.InvoiceNumber, InvoiceDate = inv.InvoiceDate, DueDate = inv.DueDate, BalanceDue = balance, DaysOverdue = daysOverdue });
|
||||
switch (AgingBucket(daysOverdue)) { case "current": custDto.TotalCurrent += balance; break; case "1-30": custDto.Total1to30 += balance; break; case "31-60": custDto.Total31to60 += balance; break; case "61-90": custDto.Total61to90 += balance; break; default: custDto.TotalOver90 += balance; break; }
|
||||
}
|
||||
customers.Add(custDto);
|
||||
}
|
||||
|
||||
var dto = new ArAgingReportDto
|
||||
{
|
||||
AsOf = asOfDate, CompanyName = companyName,
|
||||
Customers = customers.OrderByDescending(c => c.TotalBalance).ToList(),
|
||||
TotalCurrent = customers.Sum(c => c.TotalCurrent), Total1to30 = customers.Sum(c => c.Total1to30),
|
||||
Total31to60 = customers.Sum(c => c.Total31to60), Total61to90 = customers.Sum(c => c.Total61to90),
|
||||
TotalOver90 = customers.Sum(c => c.TotalOver90),
|
||||
};
|
||||
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetArAgingAsync(companyId, asOfDate);
|
||||
var pdfBytes = await _pdfService.GenerateArAgingPdfAsync(dto);
|
||||
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"AR-Aging-{asOfDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
@@ -1678,48 +1116,10 @@ public class ReportsController : Controller
|
||||
// GET: /Reports/SalesAndIncomePdf
|
||||
public async Task<IActionResult> SalesAndIncomePdf(DateTime? from, DateTime? to, bool inline = false)
|
||||
{
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var toEnd = toDate.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var invoices = await _context.Invoices
|
||||
.Include(i => i.Customer).Include(i => i.Payments)
|
||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate >= fromDate && i.InvoiceDate <= toEnd)
|
||||
.OrderBy(i => i.InvoiceDate).ToListAsync();
|
||||
|
||||
var collectedInPeriod = await _context.Payments
|
||||
.Where(p => p.PaymentDate >= fromDate && p.PaymentDate <= toEnd)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
var byCustomer = invoices
|
||||
.GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() })
|
||||
.Select(g => new SalesByCustomerDto { CustomerId = g.Key.CustomerId, CustomerName = g.Key.Name, InvoiceCount = g.Count(), TotalInvoiced = g.Sum(i => i.Total), TotalPaid = g.Sum(i => i.AmountPaid), BalanceDue = g.Sum(i => i.BalanceDue) })
|
||||
.OrderByDescending(c => c.TotalInvoiced).ToList();
|
||||
|
||||
var byMonth = invoices
|
||||
.GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
|
||||
.Select(g => new SalesByMonthDto { Year = g.Key.Year, Month = g.Key.Month, Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"), TotalInvoiced = g.Sum(i => i.Total), TotalCollected = g.Sum(i => i.AmountPaid), InvoiceCount = g.Count() })
|
||||
.OrderBy(m => m.Year).ThenBy(m => m.Month).ToList();
|
||||
|
||||
var invoiceLines = invoices.Select(i => new SalesInvoiceLineDto
|
||||
{
|
||||
InvoiceId = i.Id, InvoiceNumber = i.InvoiceNumber,
|
||||
CustomerName = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(),
|
||||
InvoiceDate = i.InvoiceDate, DueDate = i.DueDate, Status = i.Status.ToString(),
|
||||
SubTotal = i.SubTotal, TaxAmount = i.TaxAmount, Total = i.Total, AmountPaid = i.AmountPaid, BalanceDue = i.BalanceDue,
|
||||
}).ToList();
|
||||
|
||||
var dto = new SalesIncomeReportDto
|
||||
{
|
||||
From = fromDate, To = toDate, CompanyName = companyName,
|
||||
TotalInvoiced = invoices.Sum(i => i.Total), TotalCollected = collectedInPeriod,
|
||||
TotalTax = invoices.Sum(i => i.TaxAmount), TotalDiscount = invoices.Sum(i => i.DiscountAmount),
|
||||
InvoiceCount = invoices.Count, CustomerCount = invoices.Select(i => i.CustomerId).Distinct().Count(),
|
||||
ByCustomer = byCustomer, ByMonth = byMonth, Invoices = invoiceLines,
|
||||
};
|
||||
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetSalesAndIncomeAsync(companyId, fromDate, toDate);
|
||||
var pdfBytes = await _pdfService.GenerateSalesAndIncomePdfAsync(dto);
|
||||
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"SalesAndIncome-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
@@ -1996,8 +1396,8 @@ public class ReportsController : Controller
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var now = DateTime.UtcNow;
|
||||
var today = DateTime.Today;
|
||||
var allBills = await _context.Bills.Include(b => b.Vendor).Include(b => b.Payments.Where(p => !p.IsDeleted)).Where(b => !b.IsDeleted && b.Status != BillStatus.Voided).ToListAsync();
|
||||
var allExpenses = await _context.Expenses.Include(e => e.ExpenseAccount).Where(e => !e.IsDeleted).ToListAsync();
|
||||
var allBills = await _operationalReports.GetActiveBillsAsync();
|
||||
var allExpenses = await _operationalReports.GetAllExpensesAsync();
|
||||
|
||||
var outstandingBills = allBills.Where(b => b.BalanceDue > 0).ToList();
|
||||
var apAgingBuckets = new List<AgingBucketItem> { new() { Label = "Current (0–30 days)" }, new() { Label = "31–60 days" }, new() { Label = "61–90 days" }, new() { Label = "Over 90 days" } };
|
||||
@@ -2116,7 +1516,7 @@ public class ReportsController : Controller
|
||||
var now = DateTime.UtcNow;
|
||||
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList();
|
||||
var allStatusHistory = await _context.JobStatusHistory.Include(h => h.FromStatus).Include(h => h.ToStatus).ToListAsync();
|
||||
var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();
|
||||
var historyByJob = allStatusHistory.GroupBy(h => h.JobId).ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList());
|
||||
var statusDisplayOrder = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" };
|
||||
var statusTimings = new Dictionary<string, (string DisplayName, List<double> Days)>();
|
||||
@@ -2293,10 +1693,7 @@ public class ReportsController : Controller
|
||||
.Count(i => i.DueDate.HasValue && (today - i.DueDate.Value).TotalDays > 30);
|
||||
|
||||
// Expenses by account
|
||||
var allExpenses = await _context.Expenses
|
||||
.Include(e => e.ExpenseAccount)
|
||||
.Where(e => !e.IsDeleted)
|
||||
.ToListAsync();
|
||||
var allExpenses = await _operationalReports.GetAllExpensesAsync();
|
||||
var expensesByCategory = allExpenses
|
||||
.Where(e => e.ExpenseAccount != null)
|
||||
.GroupBy(e => e.ExpenseAccount!.Name)
|
||||
@@ -2306,7 +1703,7 @@ public class ReportsController : Controller
|
||||
var totalExpenses = allExpenses.Sum(e => e.Amount);
|
||||
|
||||
// Also include bills paid as expenses
|
||||
var allBills = await _context.Bills.Where(b => !b.IsDeleted).ToListAsync();
|
||||
var allBills = await _operationalReports.GetActiveBillsAsync();
|
||||
var billsPaid = allBills.Sum(b => b.AmountPaid);
|
||||
totalExpenses += billsPaid;
|
||||
|
||||
@@ -2400,11 +1797,9 @@ public class ReportsController : Controller
|
||||
}).ToList();
|
||||
|
||||
// Open AP bills
|
||||
var openBills = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Where(b => !b.IsDeleted && b.AmountPaid < b.Total
|
||||
&& b.Status != BillStatus.Voided)
|
||||
.ToListAsync();
|
||||
var openBills = (await _operationalReports.GetActiveBillsAsync())
|
||||
.Where(b => b.AmountPaid < b.Total)
|
||||
.ToList();
|
||||
|
||||
var apItems = openBills.Select(b => new CashFlowApItem
|
||||
{
|
||||
@@ -2415,13 +1810,11 @@ public class ReportsController : Controller
|
||||
}).ToList();
|
||||
|
||||
// Active job pipeline (non-terminal jobs not yet invoiced)
|
||||
var activeJobs = await _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => !j.IsDeleted && j.JobStatus != null && !j.JobStatus.IsTerminalStatus)
|
||||
var activeJobs = (await _unitOfWork.Jobs.GetBoardJobsAsync())
|
||||
.Where(j => j.JobStatus != null && !j.JobStatus.IsTerminalStatus)
|
||||
.OrderByDescending(j => j.FinalPrice > 0 ? j.FinalPrice : j.QuotedPrice)
|
||||
.Take(30)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
var jobItems = activeJobs.Select(j => new CashFlowJobItem
|
||||
{
|
||||
@@ -2484,12 +1877,9 @@ public class ReportsController : Controller
|
||||
var startOfThisMonth = new DateTime(today.Year, today.Month, 1);
|
||||
var startOfLastMonth = startOfThisMonth.AddMonths(-1);
|
||||
|
||||
// Recent bills (last 90 days)
|
||||
var recentBills = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Where(b => !b.IsDeleted && b.BillDate >= ninetyDaysAgo)
|
||||
.OrderByDescending(b => b.BillDate)
|
||||
.ToListAsync();
|
||||
// All active bills — used for both recent-bill candidates and all-time vendor history
|
||||
var allBills = await _operationalReports.GetActiveBillsAsync();
|
||||
var recentBills = allBills.Where(b => b.BillDate >= ninetyDaysAgo).OrderByDescending(b => b.BillDate).ToList();
|
||||
|
||||
var billSummaries = recentBills.Select(b => new AnomalyBillSummary
|
||||
{
|
||||
@@ -2501,12 +1891,6 @@ public class ReportsController : Controller
|
||||
VendorInvoiceNumber = b.VendorInvoiceNumber
|
||||
}).ToList();
|
||||
|
||||
// Vendor history (all time, for averages)
|
||||
var allBills = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Where(b => !b.IsDeleted && b.Status != BillStatus.Voided)
|
||||
.ToListAsync();
|
||||
|
||||
var vendorHistory = allBills
|
||||
.Where(b => b.Vendor != null)
|
||||
.GroupBy(b => b.Vendor!.CompanyName)
|
||||
@@ -2525,10 +1909,7 @@ public class ReportsController : Controller
|
||||
}).ToList();
|
||||
|
||||
// Account spend trends (bills + expenses by account this month vs historical avg)
|
||||
var allExpenses = await _context.Expenses
|
||||
.Include(e => e.ExpenseAccount)
|
||||
.Where(e => !e.IsDeleted)
|
||||
.ToListAsync();
|
||||
var allExpenses = await _operationalReports.GetAllExpensesAsync();
|
||||
|
||||
var accountTrends = allExpenses
|
||||
.Where(e => e.ExpenseAccount != null)
|
||||
@@ -2581,7 +1962,7 @@ public class ReportsController : Controller
|
||||
var companyIdClaim = User.FindFirst("CompanyId")?.Value;
|
||||
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
|
||||
{
|
||||
var company = await _context.Companies.FirstOrDefaultAsync(c => c.Id == companyId);
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
return company?.CompanyName ?? "Your Company";
|
||||
}
|
||||
return "Your Company";
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// directly (bypassing the UoW) because it queries across company boundaries and
|
||||
/// joins plan configs — a pattern that would require multiple unrelated repositories.
|
||||
/// </summary>
|
||||
// Intentional exception: cross-tenant MRR/ARR metrics joining Company + SubscriptionPlanConfig; same pattern as CompanyHealthController. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class RevenueController : Controller
|
||||
{
|
||||
|
||||
@@ -8,7 +8,6 @@ using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -20,7 +19,6 @@ public class SetupWizardController : Controller
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly ISeedDataService _seedDataService;
|
||||
private readonly ILogger<SetupWizardController> _logger;
|
||||
|
||||
@@ -28,14 +26,12 @@ public class SetupWizardController : Controller
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ApplicationDbContext context,
|
||||
ISeedDataService seedDataService,
|
||||
ILogger<SetupWizardController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_userManager = userManager;
|
||||
_context = context;
|
||||
_seedDataService = seedDataService;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -70,14 +66,14 @@ public class SetupWizardController : Controller
|
||||
if (company.Preferences == null)
|
||||
{
|
||||
company.Preferences = new CompanyPreferences { CompanyId = companyId };
|
||||
_context.Set<CompanyPreferences>().Add(company.Preferences);
|
||||
await _unitOfWork.CompanyPreferences.AddAsync(company.Preferences);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
if (company.OperatingCosts == null)
|
||||
{
|
||||
company.OperatingCosts = new CompanyOperatingCosts { CompanyId = companyId };
|
||||
_context.Set<CompanyOperatingCosts>().Add(company.OperatingCosts);
|
||||
await _unitOfWork.CompanyOperatingCosts.AddAsync(company.OperatingCosts);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using System.Text;
|
||||
|
||||
@@ -15,12 +14,12 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public class SmsConsentAuditController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<SmsConsentAuditController> _logger;
|
||||
|
||||
public SmsConsentAuditController(ApplicationDbContext context, ILogger<SmsConsentAuditController> logger)
|
||||
public SmsConsentAuditController(IUnitOfWork unitOfWork, ILogger<SmsConsentAuditController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -31,14 +30,12 @@ public class SmsConsentAuditController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = _context.Customers
|
||||
.AsNoTracking()
|
||||
.Where(c => !c.IsDeleted);
|
||||
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var s = search.Trim().ToLower();
|
||||
query = query.Where(c =>
|
||||
allCustomers = allCustomers.Where(c =>
|
||||
(c.ContactFirstName != null && c.ContactFirstName.ToLower().Contains(s)) ||
|
||||
(c.ContactLastName != null && c.ContactLastName.ToLower().Contains(s)) ||
|
||||
(c.CompanyName != null && c.CompanyName.ToLower().Contains(s)) ||
|
||||
@@ -46,43 +43,25 @@ public class SmsConsentAuditController : Controller
|
||||
(c.Phone != null && c.Phone.Contains(s)));
|
||||
}
|
||||
|
||||
var customers = await query
|
||||
.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.CompanyName,
|
||||
c.ContactFirstName,
|
||||
c.ContactLastName,
|
||||
c.IsCommercial,
|
||||
c.Phone,
|
||||
c.MobilePhone,
|
||||
c.NotifyBySms,
|
||||
c.SmsConsentedAt,
|
||||
c.SmsConsentMethod,
|
||||
c.SmsOptedOutAt
|
||||
})
|
||||
var allRows = allCustomers
|
||||
.OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName)
|
||||
.ToListAsync();
|
||||
.Select(c => new SmsConsentRow
|
||||
{
|
||||
CustomerId = c.Id,
|
||||
CustomerName = GetDisplayName(c.IsCommercial, c.CompanyName, c.ContactFirstName, c.ContactLastName),
|
||||
Phone = c.Phone,
|
||||
MobilePhone = c.MobilePhone,
|
||||
NotifyBySms = c.NotifyBySms,
|
||||
ConsentedAt = c.SmsConsentedAt,
|
||||
ConsentMethod = c.SmsConsentMethod,
|
||||
OptedOutAt = c.SmsOptedOutAt,
|
||||
SmsStatus = ResolveSmsStatus(c.NotifyBySms, c.SmsConsentedAt, c.SmsOptedOutAt)
|
||||
}).ToList();
|
||||
|
||||
var allRows = customers.Select(c => new SmsConsentRow
|
||||
{
|
||||
CustomerId = c.Id,
|
||||
CustomerName = GetDisplayName(c.IsCommercial, c.CompanyName, c.ContactFirstName, c.ContactLastName),
|
||||
Phone = c.Phone,
|
||||
MobilePhone = c.MobilePhone,
|
||||
NotifyBySms = c.NotifyBySms,
|
||||
ConsentedAt = c.SmsConsentedAt,
|
||||
ConsentMethod = c.SmsConsentMethod,
|
||||
OptedOutAt = c.SmsOptedOutAt,
|
||||
SmsStatus = ResolveSmsStatus(c.NotifyBySms, c.SmsConsentedAt, c.SmsOptedOutAt)
|
||||
}).ToList();
|
||||
var optedIn = allRows.Count(r => r.SmsStatus == "active");
|
||||
var optedOut = allRows.Count(r => r.SmsStatus == "opted-out");
|
||||
var never = allRows.Count(r => r.SmsStatus == "never");
|
||||
|
||||
// Stat counts across unfiltered set
|
||||
var optedIn = allRows.Count(r => r.SmsStatus == "active");
|
||||
var optedOut = allRows.Count(r => r.SmsStatus == "opted-out");
|
||||
var never = allRows.Count(r => r.SmsStatus == "never");
|
||||
|
||||
// Apply filter
|
||||
var filtered = filter switch
|
||||
{
|
||||
"opted-in" => allRows.Where(r => r.SmsStatus == "active").ToList(),
|
||||
@@ -119,25 +98,9 @@ public class SmsConsentAuditController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var customers = await _context.Customers
|
||||
.AsNoTracking()
|
||||
.Where(c => !c.IsDeleted)
|
||||
.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.CompanyName,
|
||||
c.ContactFirstName,
|
||||
c.ContactLastName,
|
||||
c.IsCommercial,
|
||||
c.Phone,
|
||||
c.MobilePhone,
|
||||
c.NotifyBySms,
|
||||
c.SmsConsentedAt,
|
||||
c.SmsConsentMethod,
|
||||
c.SmsOptedOutAt
|
||||
})
|
||||
var customers = (await _unitOfWork.Customers.GetAllAsync())
|
||||
.OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Customer Name,Phone,Mobile Phone,SMS Status,Consented At (UTC),Consent Method,Opted Out At (UTC)");
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// delivery, and view the raw JSON payload Stripe sent. Restricted to SuperAdmin because the raw
|
||||
/// event payloads may contain sensitive subscription and billing information.
|
||||
/// </summary>
|
||||
// Intentional exception: StripeWebhookEvents is a platform infrastructure table (not a business entity); same reasoning as StripeWebhookController. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class StripeEventsController : Controller
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// because subscription management is a platform-level concern, not a tenant-domain concern,
|
||||
/// and requires direct access to the Company entity which lives outside the tenant data layer.
|
||||
/// </summary>
|
||||
// Intentional exception: cross-tenant Company management with raw SQL audit log writes that bypass the tenant pipeline; platform-level concern outside IUnitOfWork scope. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class SubscriptionManagementController : Controller
|
||||
{
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -15,12 +14,12 @@ namespace PowderCoating.Web.Controllers;
|
||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Public)]
|
||||
public class UnsubscribeController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<UnsubscribeController> _logger;
|
||||
|
||||
public UnsubscribeController(ApplicationDbContext context, ILogger<UnsubscribeController> logger)
|
||||
public UnsubscribeController(IUnitOfWork unitOfWork, ILogger<UnsubscribeController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -38,11 +37,10 @@ public class UnsubscribeController : Controller
|
||||
|
||||
try
|
||||
{
|
||||
// Bypass global query filters so we can find the customer by token
|
||||
// ignoreQueryFilters=true so we can find the customer by token
|
||||
// regardless of company context (the user clicking is not authenticated)
|
||||
var customer = await _context.Customers
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.UnsubscribeToken == token && !c.IsDeleted);
|
||||
var customer = await _unitOfWork.Customers.FirstOrDefaultAsync(
|
||||
c => c.UnsubscribeToken == token && !c.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
if (customer == null)
|
||||
{
|
||||
@@ -52,7 +50,6 @@ public class UnsubscribeController : Controller
|
||||
|
||||
if (!customer.NotifyByEmail)
|
||||
{
|
||||
// Already unsubscribed — show success page anyway (idempotent)
|
||||
ViewBag.CustomerName = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
|
||||
ViewBag.AlreadyUnsubscribed = true;
|
||||
return View("EmailConfirm");
|
||||
@@ -60,7 +57,7 @@ public class UnsubscribeController : Controller
|
||||
|
||||
customer.NotifyByEmail = false;
|
||||
customer.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Customer {CustomerId} unsubscribed from email notifications via link", customer.Id);
|
||||
|
||||
@@ -88,9 +85,8 @@ public class UnsubscribeController : Controller
|
||||
|
||||
try
|
||||
{
|
||||
var company = await _context.Companies
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.MarketingUnsubscribeToken == token && !c.IsDeleted);
|
||||
var company = await _unitOfWork.Companies.FirstOrDefaultAsync(
|
||||
c => c.MarketingUnsubscribeToken == token && !c.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
if (company == null)
|
||||
{
|
||||
@@ -105,7 +101,7 @@ public class UnsubscribeController : Controller
|
||||
{
|
||||
company.MarketingEmailOptOut = true;
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
_logger.LogInformation("Company {CompanyId} opted out of broadcast emails via link", company.Id);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// efficient bulk-count GROUP BY queries in parallel rather than loading
|
||||
/// each company's data through separate repository calls.
|
||||
/// </summary>
|
||||
// Intentional exception: cross-tenant bulk GROUP BY quota queries that would require O(n) repository round-trips if routed through IUnitOfWork. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class UsageQuotaController : Controller
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Text;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
// Intentional exception: queries ASP.NET Identity ApplicationUser across all tenants with Include(u => u.Company); Identity entities live outside IUnitOfWork. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class UserActivityController : Controller
|
||||
{
|
||||
|
||||
@@ -10,7 +10,6 @@ using PowderCoating.Application.DTOs.Vendor;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -28,20 +27,17 @@ public class VendorsController : Controller
|
||||
private readonly IMapper _mapper;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<VendorsController> _logger;
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public VendorsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IMapper mapper,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<VendorsController> logger,
|
||||
ApplicationDbContext context)
|
||||
ILogger<VendorsController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -163,7 +159,7 @@ public class VendorsController : Controller
|
||||
var vendorDto = _mapper.Map<VendorDto>(vendor);
|
||||
if (vendor.DefaultExpenseAccountId.HasValue)
|
||||
{
|
||||
var acct = await _context.Accounts.FindAsync(vendor.DefaultExpenseAccountId.Value);
|
||||
var acct = await _unitOfWork.Accounts.GetByIdAsync(vendor.DefaultExpenseAccountId.Value);
|
||||
vendorDto.DefaultExpenseAccountName = acct != null ? $"{acct.AccountNumber} – {acct.Name}" : null;
|
||||
}
|
||||
return View(vendorDto);
|
||||
@@ -395,14 +391,13 @@ public class VendorsController : Controller
|
||||
/// </summary>
|
||||
private async Task PopulateExpenseAccountsAsync()
|
||||
{
|
||||
var accounts = await _context.Accounts
|
||||
.Where(a => !a.IsDeleted && a.IsActive &&
|
||||
(a.AccountType == AccountType.Expense ||
|
||||
a.AccountType == AccountType.CostOfGoods ||
|
||||
a.AccountType == AccountType.Asset))
|
||||
var accounts = (await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.IsActive && (a.AccountType == AccountType.Expense ||
|
||||
a.AccountType == AccountType.CostOfGoods ||
|
||||
a.AccountType == AccountType.Asset)))
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
accounts.Insert(0, new SelectListItem("— None —", ""));
|
||||
ViewBag.ExpenseAccounts = accounts;
|
||||
|
||||
Reference in New Issue
Block a user