2b89fcf483
DashboardReadService no longer loads full entity lists and filters in memory. All job panels (today/overdue/in-progress) now execute targeted COUNT + capped SELECT queries in SQL. AR aging buckets, powder order lines, bill totals, and active-customer counts are all aggregated at the DB level. The SuperAdmin action previously loaded every company row to compute plan distribution and alert lists; it now delegates to a new GetSuperAdminDashboardDataAsync() that uses SQL GROUP BY and projections instead. DashboardIndexData record updated to carry pre-sliced counts and capped lists so the controller only does lightweight DTO projection. DashboardPowderOrderLineData replaces the deep Job→JobItem→Coat Include chains with a single flat coat query projected in SQL. OnlineUserMiddleware switches its per-user throttle from a static ConcurrentDictionary (grows forever) to IMemoryCache with a 60-second sliding expiry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
940 lines
44 KiB
C#
940 lines
44 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using PowderCoating.Application.DTOs.Dashboard;
|
|
using PowderCoating.Application.Interfaces;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Core.Enums; // Still needed for MaintenanceStatus, EquipmentStatus, PaymentMethod
|
|
using PowderCoating.Core.Interfaces;
|
|
using PowderCoating.Core.Interfaces.Services;
|
|
using PowderCoating.Shared.Constants;
|
|
using PowderCoating.Web.ViewModels.Dashboard;
|
|
using PowderCoating.Web.ViewModels.GuidedActivation;
|
|
using InvoiceStatus = PowderCoating.Core.Enums.InvoiceStatus;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
[Authorize]
|
|
public class DashboardController : Controller
|
|
{
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly ILogger<DashboardController> _logger;
|
|
private readonly IDashboardReadService _dashboardRead;
|
|
private readonly ITenantContext _tenantContext;
|
|
private readonly ICompanyConfigHealthService _configHealth;
|
|
private readonly UserManager<ApplicationUser> _userManager;
|
|
private readonly ISubscriptionService _subscriptionService;
|
|
|
|
public DashboardController(
|
|
IUnitOfWork unitOfWork,
|
|
ILogger<DashboardController> logger,
|
|
IDashboardReadService dashboardRead,
|
|
ITenantContext tenantContext,
|
|
ICompanyConfigHealthService configHealth,
|
|
UserManager<ApplicationUser> userManager,
|
|
ISubscriptionService subscriptionService)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_logger = logger;
|
|
_dashboardRead = dashboardRead;
|
|
_tenantContext = tenantContext;
|
|
_configHealth = configHealth;
|
|
_userManager = userManager;
|
|
_subscriptionService = subscriptionService;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the operator dashboard for the current tenant company. Aggregates KPI cards,
|
|
/// job panels (today / overdue / in-progress), financial summary (AR aging, invoices, payments),
|
|
/// inventory alerts, maintenance schedule, powder-order pipeline, and the rotating tip-of-the-day
|
|
/// from <see cref="DashboardTip"/>. SuperAdmins who are not currently impersonating a company
|
|
/// are redirected to <see cref="SuperAdminDashboard"/> instead.
|
|
/// </summary>
|
|
public async Task<IActionResult> Index()
|
|
{
|
|
if (User.IsInRole("SuperAdmin") && HttpContext.Session.GetString("ImpersonatingCompanyName") == null)
|
|
return RedirectToAction(nameof(SuperAdminDashboard));
|
|
|
|
try
|
|
{
|
|
var today = DateTime.Today;
|
|
var data = await _dashboardRead.GetIndexDataAsync(today);
|
|
|
|
// ---------------------------------------------------------------
|
|
// Job panels
|
|
// ---------------------------------------------------------------
|
|
var todaysJobs = data.TodaysJobs
|
|
.Select(MapJobDto)
|
|
.ToList();
|
|
|
|
var overdueJobs = data.OverdueJobs
|
|
.Select(MapJobDto)
|
|
.ToList();
|
|
|
|
var inProgressJobs = data.InProgressJobs
|
|
.Select(MapJobDto)
|
|
.ToList();
|
|
|
|
// ---------------------------------------------------------------
|
|
// Appointments
|
|
// ---------------------------------------------------------------
|
|
var todaysAppointments = data.TodaysAppointments
|
|
.Select(a => new DashboardAppointmentDto
|
|
{
|
|
Id = a.Id,
|
|
AppointmentNumber = a.AppointmentNumber,
|
|
Title = a.Title,
|
|
CustomerName = a.Customer?.CompanyName ?? "Unknown",
|
|
ScheduledStartTime = a.ScheduledStartTime,
|
|
ScheduledEndTime = a.ScheduledEndTime,
|
|
IsAllDay = a.IsAllDay,
|
|
TypeDisplayName = a.AppointmentType?.DisplayName ?? "Unknown",
|
|
TypeColorClass = a.AppointmentType?.ColorClass ?? "secondary",
|
|
StatusDisplayName = a.AppointmentStatus?.DisplayName ?? "Unknown",
|
|
StatusColorClass = a.AppointmentStatus?.ColorClass ?? "secondary",
|
|
AssignedWorkerName = a.AssignedUser?.FullName
|
|
}).ToList();
|
|
|
|
// ---------------------------------------------------------------
|
|
// Low stock items
|
|
// ---------------------------------------------------------------
|
|
var lowStockItems = data.LowStockItems
|
|
.Select(i => new DashboardLowStockDto
|
|
{
|
|
Id = i.Id,
|
|
Name = i.Name,
|
|
ColorName = i.ColorName,
|
|
Manufacturer = i.Manufacturer,
|
|
QuantityOnHand = i.QuantityOnHand,
|
|
ReorderPoint = i.ReorderPoint,
|
|
UnitOfMeasure = i.UnitOfMeasure
|
|
}).ToList();
|
|
|
|
// ---------------------------------------------------------------
|
|
// Maintenance
|
|
// ---------------------------------------------------------------
|
|
var upcomingMaintenanceDtos = data.UpcomingMaintenance
|
|
.Select(m => new DashboardMaintenanceDto
|
|
{
|
|
Id = m.Id,
|
|
EquipmentName = m.Equipment?.EquipmentName ?? "Unknown",
|
|
MaintenanceType = m.MaintenanceType,
|
|
Status = m.Status,
|
|
Priority = m.Priority,
|
|
ScheduledDate = m.ScheduledDate,
|
|
Description = m.Description,
|
|
AssignedWorkerName = m.AssignedUser?.FullName
|
|
}).ToList();
|
|
|
|
// ---------------------------------------------------------------
|
|
// Quotes
|
|
// ---------------------------------------------------------------
|
|
var pendingQuotes = data.PendingQuotes
|
|
.Select(q => new DashboardQuoteDto
|
|
{
|
|
Id = q.Id,
|
|
QuoteNumber = q.QuoteNumber,
|
|
CustomerName = q.Customer?.CompanyName
|
|
?? (q.Customer != null ? $"{q.Customer.ContactFirstName} {q.Customer.ContactLastName}".Trim() : null)
|
|
?? q.ProspectCompanyName
|
|
?? q.ProspectContactName
|
|
?? "Unknown",
|
|
QuoteDate = q.QuoteDate,
|
|
ExpirationDate = q.ExpirationDate,
|
|
Total = q.Total,
|
|
StatusCode = q.QuoteStatus.StatusCode,
|
|
StatusDisplayName = q.QuoteStatus.DisplayName
|
|
}).ToList();
|
|
|
|
var expiringQuotes = data.ExpiringQuotes
|
|
.Select(q => new DashboardQuoteDto
|
|
{
|
|
Id = q.Id,
|
|
QuoteNumber = q.QuoteNumber,
|
|
CustomerName = q.Customer?.CompanyName
|
|
?? (q.Customer != null ? $"{q.Customer.ContactFirstName} {q.Customer.ContactLastName}".Trim() : null)
|
|
?? q.ProspectCompanyName
|
|
?? q.ProspectContactName
|
|
?? "Unknown",
|
|
QuoteDate = q.QuoteDate,
|
|
ExpirationDate = q.ExpirationDate,
|
|
Total = q.Total,
|
|
StatusCode = q.QuoteStatus.StatusCode,
|
|
StatusDisplayName = q.QuoteStatus.DisplayName
|
|
}).ToList();
|
|
|
|
// ---------------------------------------------------------------
|
|
// Invoices & AR aging
|
|
// ---------------------------------------------------------------
|
|
var overdueInvoices = data.OverdueInvoices
|
|
.Select(i => new DashboardInvoiceDto
|
|
{
|
|
Id = i.Id,
|
|
InvoiceNumber = i.InvoiceNumber,
|
|
CustomerName = i.Customer?.CompanyName
|
|
?? $"{i.Customer?.ContactFirstName} {i.Customer?.ContactLastName}".Trim(),
|
|
Total = i.Total,
|
|
BalanceDue = i.BalanceDue,
|
|
DueDate = i.DueDate,
|
|
DaysOverdue = i.DueDate.HasValue ? (int)(today - i.DueDate.Value.Date).TotalDays : 0
|
|
})
|
|
.ToList();
|
|
|
|
// ---------------------------------------------------------------
|
|
// Payments
|
|
// ---------------------------------------------------------------
|
|
var recentPayments = data.RecentPayments
|
|
.Select(p => new DashboardPaymentDto
|
|
{
|
|
Id = p.Id,
|
|
InvoiceId = p.InvoiceId,
|
|
InvoiceNumber = p.Invoice?.InvoiceNumber ?? "-",
|
|
CustomerName = p.Invoice?.Customer?.CompanyName
|
|
?? $"{p.Invoice?.Customer?.ContactFirstName} {p.Invoice?.Customer?.ContactLastName}".Trim(),
|
|
Amount = p.Amount,
|
|
PaymentDate = p.PaymentDate,
|
|
PaymentMethodDisplay = p.PaymentMethod switch
|
|
{
|
|
PaymentMethod.Cash => "Cash",
|
|
PaymentMethod.Check => "Check",
|
|
PaymentMethod.CreditDebitCard => "Card",
|
|
PaymentMethod.BankTransferACH => "ACH",
|
|
PaymentMethod.DigitalPayment => "Digital",
|
|
_ => "Other"
|
|
}
|
|
})
|
|
.ToList();
|
|
|
|
// ---------------------------------------------------------------
|
|
// Equipment alerts
|
|
// ---------------------------------------------------------------
|
|
var equipmentAlerts = data.EquipmentAlerts
|
|
.Select(e => new DashboardEquipmentAlertDto
|
|
{
|
|
Id = e.Id,
|
|
EquipmentName = e.EquipmentName,
|
|
EquipmentType = e.EquipmentType,
|
|
Issue = e.Status == EquipmentStatus.OutOfService ? "Out of Service" : "Needs Maintenance",
|
|
Severity = e.Status == EquipmentStatus.OutOfService ? "Critical" : "Warning",
|
|
LastMaintenanceDate = e.LastMaintenanceDate,
|
|
NextMaintenanceDue = null
|
|
}).ToList();
|
|
|
|
// ---------------------------------------------------------------
|
|
// Recent activity
|
|
// ---------------------------------------------------------------
|
|
var recentQuoteDtos = data.RecentQuotes
|
|
.Select(q => new DashboardRecentActivityDto
|
|
{
|
|
Id = q.Id,
|
|
Type = "Quote",
|
|
Title = q.QuoteNumber,
|
|
Description = "Quote created",
|
|
CustomerName = q.Customer?.CompanyName
|
|
?? (q.Customer != null ? $"{q.Customer.ContactFirstName} {q.Customer.ContactLastName}".Trim() : null)
|
|
?? q.ProspectCompanyName
|
|
?? q.ProspectContactName
|
|
?? "Unknown",
|
|
ActivityDate = q.CreatedAt,
|
|
StatusDisplayName = q.QuoteStatus.DisplayName,
|
|
StatusColorClass = q.QuoteStatus.ColorClass,
|
|
Amount = q.Total
|
|
});
|
|
|
|
var recentJobDtos = data.RecentJobs
|
|
.Select(j => new DashboardRecentActivityDto
|
|
{
|
|
Id = j.Id,
|
|
Type = "Job",
|
|
Title = j.JobNumber,
|
|
Description = j.Description ?? "Job created",
|
|
CustomerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName)
|
|
? j.Customer.CompanyName
|
|
: $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(),
|
|
ActivityDate = j.CreatedAt,
|
|
StatusDisplayName = j.JobStatus.DisplayName,
|
|
StatusColorClass = j.JobStatus.ColorClass,
|
|
Amount = j.FinalPrice
|
|
});
|
|
|
|
var recentActivity = recentQuoteDtos.Concat(recentJobDtos)
|
|
.OrderByDescending(a => a.ActivityDate)
|
|
.Take(10)
|
|
.ToList();
|
|
|
|
// ---------------------------------------------------------------
|
|
// Powder orders needed
|
|
// ---------------------------------------------------------------
|
|
var powderOrderGroups = MapPowderOrderGroups(data.PowderOrdersNeeded);
|
|
|
|
// ---------------------------------------------------------------
|
|
// Powder orders placed
|
|
// ---------------------------------------------------------------
|
|
var powderPlacedGroups = MapPowderOrderGroups(data.PowderOrdersPlaced);
|
|
|
|
// ---------------------------------------------------------------
|
|
// Bills due
|
|
// ---------------------------------------------------------------
|
|
var billsDue = data.BillsDue.Select(b => new DashboardBillDto
|
|
{
|
|
Id = b.Id,
|
|
BillNumber = b.BillNumber,
|
|
VendorName = b.Vendor?.CompanyName ?? "Unknown",
|
|
BalanceDue = b.BalanceDue,
|
|
DueDate = b.DueDate,
|
|
IsOverdue = b.DueDate.HasValue && b.DueDate.Value.Date < today,
|
|
DaysOverdue = b.DueDate.HasValue && b.DueDate.Value.Date < today
|
|
? (int)(today - b.DueDate.Value.Date).TotalDays : 0
|
|
}).ToList();
|
|
|
|
var vm = new DashboardViewModel
|
|
{
|
|
// Counts
|
|
ActiveJobsCount = data.ActiveJobsCount,
|
|
TodaysJobsCount = data.TodaysJobsCount,
|
|
OverdueJobsCount = data.OverdueJobsCount,
|
|
TodaysAppointmentsCount = data.TodaysAppointmentsCount,
|
|
LowStockCount = data.LowStockCount,
|
|
PendingMaintenanceCount = data.PendingMaintenanceCount,
|
|
PendingQuotesCount = data.PendingQuotesCount,
|
|
PendingQuoteValue = data.PendingQuoteValue,
|
|
MonthlyRevenue = data.MonthlyRevenue,
|
|
ActiveCustomersCount = data.ActiveCustomersCount,
|
|
|
|
// Financial KPIs
|
|
OutstandingAr = data.OutstandingAr,
|
|
CollectedThisMonth = data.CollectedThisMonth,
|
|
InvoicedThisMonth = data.InvoicedThisMonth,
|
|
OverdueInvoicesCount = data.OverdueInvoicesCount,
|
|
OverdueInvoicesAmount = data.OverdueInvoicesAmount,
|
|
AgingCurrent = data.ArAging.Current,
|
|
AgingDays1To30 = data.ArAging.Days1To30,
|
|
AgingDays31To60 = data.ArAging.Days31To60,
|
|
AgingDays61To90 = data.ArAging.Days61To90,
|
|
AgingDaysOver90 = data.ArAging.DaysOver90,
|
|
|
|
// Sections
|
|
TodaysJobs = todaysJobs,
|
|
TodaysAppointments = todaysAppointments,
|
|
OverdueJobs = overdueJobs,
|
|
ExpiringQuotes = expiringQuotes,
|
|
ActiveJobs = inProgressJobs,
|
|
LowStockItems = lowStockItems,
|
|
UpcomingMaintenance = upcomingMaintenanceDtos,
|
|
EquipmentAlerts = equipmentAlerts,
|
|
PendingQuotes = pendingQuotes,
|
|
RecentActivity = recentActivity,
|
|
OverdueInvoices = overdueInvoices,
|
|
RecentPayments = recentPayments,
|
|
|
|
// Bills Due
|
|
BillsDue = billsDue,
|
|
BillsDueCount = data.BillsDueCount,
|
|
BillsDueAmount = data.BillsDueAmount,
|
|
|
|
// Powder orders
|
|
PowderOrdersNeeded = powderOrderGroups,
|
|
PowderOrdersNeededCount = data.PowderOrdersNeeded.Count,
|
|
PowderOrdersPlaced = powderPlacedGroups,
|
|
PowderOrdersPlacedCount = data.PowderOrdersPlaced.Count,
|
|
|
|
TipOfTheDay = data.TipOfTheDay
|
|
};
|
|
|
|
// Dropdowns for the "Add Custom Powder to Inventory" modal
|
|
var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.GetAllAsync())
|
|
.Where(c => c.IsActive)
|
|
.OrderBy(c => c.DisplayOrder)
|
|
.Select(c => new { c.Id, c.DisplayName })
|
|
.ToList();
|
|
var vendors = (await _unitOfWork.Vendors.GetAllAsync())
|
|
.Where(v => v.IsActive)
|
|
.OrderBy(v => v.CompanyName)
|
|
.Select(v => new { v.Id, v.CompanyName })
|
|
.ToList();
|
|
ViewBag.InventoryCategories = inventoryCategories;
|
|
ViewBag.VendorList = vendors;
|
|
|
|
// Config health check — surface setup gaps to company admins
|
|
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
|
|
if (currentCompanyId.HasValue)
|
|
{
|
|
ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value);
|
|
|
|
// Load prefs once and share between both banner and progress widget builders
|
|
var companyPrefs = await _unitOfWork.CompanyPreferences
|
|
.FirstOrDefaultAsync(p => p.CompanyId == currentCompanyId.Value && !p.IsDeleted);
|
|
|
|
ViewBag.GuidedActivationBanner = BuildGuidedActivationBanner(companyPrefs);
|
|
ViewBag.ShopProgressWidget = await BuildShopProgressWidgetAsync(currentCompanyId.Value, companyPrefs);
|
|
}
|
|
|
|
return View(vm);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error loading dashboard");
|
|
TempData["Error"] = "An error occurred while loading the dashboard.";
|
|
return View(new DashboardViewModel());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks a job-item coat as having its powder ordered. Called via AJAX from the Powder Orders
|
|
/// Needed panel. Verifies company ownership through the parent job (JobItemCoat has no direct
|
|
/// CompanyId) before updating <c>PowderOrdered</c>, <c>PowderOrderedAt</c>, and
|
|
/// <c>PowderOrderedByUserId</c> on the coat record.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> MarkPowderOrdered(int coatId)
|
|
{
|
|
try
|
|
{
|
|
var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId);
|
|
|
|
if (coat == null)
|
|
return Json(new { success = false, message = "Coat not found." });
|
|
|
|
// JobItemCoat has no CompanyId — verify ownership via parent job
|
|
var parentCompanyId = coat.JobItem?.Job?.CompanyId;
|
|
if (!_tenantContext.IsSuperAdmin() && parentCompanyId != _tenantContext.GetCurrentCompanyId())
|
|
return Json(new { success = false, message = "Access denied." });
|
|
|
|
coat.PowderOrdered = true;
|
|
coat.PowderOrderedAt = DateTime.UtcNow;
|
|
coat.PowderOrderedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor;
|
|
var job = coat.JobItem?.Job;
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
coat = new
|
|
{
|
|
coatId = coat.Id,
|
|
jobId = job?.Id,
|
|
jobNumber = job?.JobNumber,
|
|
customerName = job?.Customer?.CompanyName,
|
|
colorName = coat.ColorName,
|
|
colorCode = coat.ColorCode,
|
|
finish = coat.Finish,
|
|
sku = coat.InventoryItem?.SKU,
|
|
lbsToOrder = coat.PowderToOrder,
|
|
costPerLb = coat.PowderCostPerLb,
|
|
orderedAt = coat.PowderOrderedAt,
|
|
hasInventory = coat.InventoryItemId.HasValue,
|
|
vendorId = vendor?.Id,
|
|
vendorName = vendor?.CompanyName ?? "No Vendor Assigned",
|
|
vendorPhone = vendor?.Phone
|
|
}
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error marking coat {CoatId} as powder ordered", coatId);
|
|
return Json(new { success = false, message = "An error occurred." });
|
|
}
|
|
}
|
|
|
|
private GuidedActivationBannerViewModel? BuildGuidedActivationBanner(CompanyPreferences? prefs)
|
|
{
|
|
var companyRole = User.FindFirst("CompanyRole")?.Value;
|
|
if (companyRole != AppConstants.CompanyRoles.CompanyAdmin)
|
|
return null;
|
|
|
|
if (prefs == null || !prefs.SetupWizardCompleted || prefs.FirstWorkflowCompleted)
|
|
return null;
|
|
|
|
return new GuidedActivationBannerViewModel
|
|
{
|
|
Show = true,
|
|
IsDismissed = prefs.GuidedActivationDismissedAt.HasValue,
|
|
Title = prefs.GuidedActivationDismissedAt.HasValue
|
|
? "Start your first workflow when you're ready"
|
|
: "Create your first job or quote",
|
|
Message = prefs.GuidedActivationDismissedAt.HasValue
|
|
? "You can come back anytime to run a short walkthrough using real quotes, jobs, and invoices."
|
|
: "Run a quick 2-minute workflow to see how the system works.",
|
|
ActionText = "Start first workflow"
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the "Get the most out of your shop" activation checklist for CompanyAdmins.
|
|
/// Returns null when the wizard is not yet complete, the viewer is not a CompanyAdmin,
|
|
/// or all six tasks are already done (so the widget disappears naturally at 100%).
|
|
/// Three DB checks are fired in parallel to keep the overhead to a minimum.
|
|
/// </summary>
|
|
private async Task<ShopProgressWidgetViewModel?> BuildShopProgressWidgetAsync(int companyId, CompanyPreferences? prefs)
|
|
{
|
|
var companyRole = User.FindFirst("CompanyRole")?.Value;
|
|
if (companyRole != AppConstants.CompanyRoles.CompanyAdmin)
|
|
return null;
|
|
|
|
if (prefs == null || !prefs.SetupWizardCompleted)
|
|
return null;
|
|
|
|
// These share the same scoped DbContext so must run sequentially
|
|
var hasStatusHistory = await _unitOfWork.JobStatusHistory.AnyAsync(_ => true);
|
|
// ignoreQueryFilters so soft-deleted lookups (UpdatedAt set on delete) are also visible
|
|
var hasCustomizedLookups = await _unitOfWork.JobStatusLookups.AnyAsync(
|
|
j => j.CompanyId == companyId && j.UpdatedAt != null,
|
|
ignoreQueryFilters: true);
|
|
var teamCount = await _userManager.Users
|
|
.CountAsync(u => u.CompanyId == companyId && u.IsActive && !u.IsBanned);
|
|
var (_, maxUsers) = await _subscriptionService.GetUserCountAsync(companyId);
|
|
var planAllowsMultipleUsers = maxUsers != 1;
|
|
|
|
var items = new List<ShopProgressItem?>
|
|
{
|
|
new()
|
|
{
|
|
Done = prefs.FirstJobCreatedAt.HasValue || prefs.FirstQuoteCreatedAt.HasValue,
|
|
Label = "Create your first job or quote",
|
|
SubLabel = "Get customer sign-off before you start — takes about 2 minutes.",
|
|
DoneSubLabel = "Your first job is now being tracked.",
|
|
Icon = "bi-file-earmark-plus",
|
|
CtaText = "Create a quote",
|
|
CtaUrl = Url.Action("Start", "GuidedActivation")!
|
|
},
|
|
new()
|
|
{
|
|
Done = hasStatusHistory,
|
|
Label = "Move a job through your workflow",
|
|
SubLabel = "Move a job through your board so your crew always knows what's next.",
|
|
DoneSubLabel = "You've started tracking work through your shop.",
|
|
Icon = "bi-arrow-right-circle",
|
|
CtaText = "Go to jobs board",
|
|
CtaUrl = Url.Action("Board", "Jobs")!
|
|
},
|
|
new()
|
|
{
|
|
Done = prefs.FirstInvoiceCreatedAt.HasValue,
|
|
Label = "Send your first invoice",
|
|
SubLabel = "When the work is done, turn it into an invoice and send it in seconds.",
|
|
DoneSubLabel = "You're ready to get paid.",
|
|
Icon = "bi-receipt",
|
|
CtaText = "Create invoice",
|
|
CtaUrl = Url.Action("Create", "Invoices")!
|
|
},
|
|
planAllowsMultipleUsers ? new()
|
|
{
|
|
Done = teamCount > 1,
|
|
Label = "Bring your crew in",
|
|
SubLabel = "Add your crew so everyone stays on the same page in real time.",
|
|
DoneSubLabel = "Your team is in the system.",
|
|
Icon = "bi-people",
|
|
CtaText = "Invite team",
|
|
CtaUrl = Url.Action("Index", "CompanyUsers")!
|
|
} : null!,
|
|
new()
|
|
{
|
|
Done = hasCustomizedLookups,
|
|
Label = "Customize your workflow",
|
|
SubLabel = "Adjust stages and services to match how your shop runs.",
|
|
DoneSubLabel = "Your workflow speaks your shop's language.",
|
|
Icon = "bi-list-ul",
|
|
CtaText = "Customize workflow",
|
|
CtaUrl = Url.Action("Index", "CompanySettings") + "#data-lookups"
|
|
},
|
|
new()
|
|
{
|
|
Done = prefs.DefaultPaymentTerms != "Net 30"
|
|
|| prefs.DefaultQuoteValidityDays != 30
|
|
|| prefs.DefaultTurnaroundDays != 7
|
|
|| prefs.QtDefaultTerms != null,
|
|
Label = "Set how you get paid",
|
|
SubLabel = "Set your payment terms and timing so every job goes out right.",
|
|
DoneSubLabel = "Your payment defaults are locked in.",
|
|
Icon = "bi-file-earmark-text",
|
|
CtaText = "Set payment terms",
|
|
CtaUrl = Url.Action("Index", "CompanySettings") + "#app-defaults"
|
|
}
|
|
};
|
|
|
|
var vm = new ShopProgressWidgetViewModel { Items = items.Where(i => i != null).Select(i => i!).ToList() };
|
|
|
|
// Suppress widget if the user already dismissed it after completing all steps
|
|
if (vm.AllDone && prefs.GuidedActivationDismissedAt.HasValue)
|
|
return null;
|
|
|
|
return vm;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Persists the company admin's dismissal of the progress widget completion state.
|
|
/// Sets <c>GuidedActivationDismissedAt</c> so the widget stays hidden across devices
|
|
/// and browser sessions (localStorage alone wouldn't survive a cleared cache).
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> DismissProgressWidget()
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null)
|
|
return Json(new { success = false });
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.Preferences!);
|
|
if (company?.Preferences == null)
|
|
return Json(new { success = false });
|
|
|
|
company.Preferences.GuidedActivationDismissedAt = DateTime.UtcNow;
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Progress widget dismissed for company {CompanyId}", companyId.Value);
|
|
|
|
return Json(new { success = true });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records receipt of a powder shipment against an existing powder order. Sets
|
|
/// <c>PowderReceived</c>, <c>PowderReceivedLbs</c>, and <c>PowderReceivedAt</c> on the coat,
|
|
/// and — when the coat is linked to an inventory item — increases <c>QuantityOnHand</c> and
|
|
/// writes a <c>Purchase</c> <see cref="InventoryTransaction"/> so the stock movement is fully
|
|
/// traceable. Company ownership is verified through the parent job because <c>JobItemCoat</c>
|
|
/// carries no <c>CompanyId</c> of its own.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> ReceivePowder(int coatId, decimal lbsReceived)
|
|
{
|
|
try
|
|
{
|
|
// Load coat with inventory item for the stock update
|
|
var coat = await _unitOfWork.JobItemCoats.LoadWithInventoryAsync(coatId);
|
|
|
|
if (coat == null)
|
|
return Json(new { success = false, message = "Coat record not found." });
|
|
|
|
if (lbsReceived <= 0)
|
|
return Json(new { success = false, message = "Quantity received must be greater than zero." });
|
|
|
|
// Verify ownership — JobItemCoat has no CompanyId, check via parent job.
|
|
// If JobItem/Job wasn't populated by the initial load, bring in the chain via a second
|
|
// query; EF Core identity-map fixup will propagate the navigation to the tracked coat.
|
|
var coatJobCompanyId = coat.JobItem?.Job?.CompanyId;
|
|
if (coatJobCompanyId == null)
|
|
{
|
|
await _unitOfWork.JobItemCoats.LoadWithJobChainAsync(coatId);
|
|
coatJobCompanyId = coat.JobItem?.Job?.CompanyId;
|
|
}
|
|
if (!_tenantContext.IsSuperAdmin() && coatJobCompanyId != _tenantContext.GetCurrentCompanyId())
|
|
return Json(new { success = false, message = "Access denied." });
|
|
|
|
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
|
|
coat.PowderReceived = true;
|
|
coat.PowderReceivedAt = DateTime.UtcNow;
|
|
coat.PowderReceivedByUserId = userId;
|
|
coat.PowderReceivedLbs = lbsReceived;
|
|
|
|
if (coat.InventoryItemId.HasValue && coat.InventoryItem != null)
|
|
{
|
|
var item = coat.InventoryItem;
|
|
var previousBalance = item.QuantityOnHand;
|
|
item.QuantityOnHand += lbsReceived;
|
|
item.LastPurchaseDate = DateTime.UtcNow;
|
|
if (coat.PowderCostPerLb.HasValue)
|
|
item.LastPurchasePrice = coat.PowderCostPerLb.Value;
|
|
|
|
var transaction = new InventoryTransaction
|
|
{
|
|
CompanyId = item.CompanyId,
|
|
InventoryItemId = item.Id,
|
|
TransactionType = InventoryTransactionType.Purchase,
|
|
Quantity = lbsReceived,
|
|
UnitCost = coat.PowderCostPerLb ?? item.UnitCost,
|
|
TotalCost = lbsReceived * (coat.PowderCostPerLb ?? item.UnitCost),
|
|
TransactionDate = DateTime.UtcNow,
|
|
Notes = $"Received {lbsReceived:N2} lbs for job order",
|
|
BalanceAfter = previousBalance + lbsReceived,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow,
|
|
};
|
|
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
return Json(new { success = true, updatedInventory = coat.InventoryItemId.HasValue });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error receiving powder for coat {CoatId}", coatId);
|
|
return Json(new { success = false, message = "An error occurred while recording the receipt." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new inventory item from a custom (non-catalogued) powder order and immediately
|
|
/// marks the originating coat as received. After creating the item, the action scans other
|
|
/// open job coats in the same company for matching color (by color code first, then color name)
|
|
/// and links them to the new inventory item so future powder receipt correctly updates stock.
|
|
/// A <c>Purchase</c> inventory transaction is written for the opening balance. Only custom
|
|
/// powders — those with no existing <c>InventoryItemId</c> — should reach this action; the
|
|
/// standard receive flow is <see cref="ReceivePowder"/>.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> AddCustomPowderToInventory(
|
|
int coatId, string sku, string itemName, decimal lbsReceived,
|
|
int? inventoryCategoryId, string? manufacturer, string? description,
|
|
string? colorName, string? colorCode, string? finish,
|
|
string? vendorPartNumber, int? primaryVendorId,
|
|
decimal? unitCost, decimal reorderPoint, decimal reorderQuantity,
|
|
string? location, string? notes)
|
|
{
|
|
try
|
|
{
|
|
var coat = await _unitOfWork.JobItemCoats.GetByIdAsync(coatId);
|
|
if (coat == null)
|
|
return Json(new { success = false, message = "Coat record not found." });
|
|
|
|
if (string.IsNullOrWhiteSpace(sku))
|
|
return Json(new { success = false, message = "SKU is required." });
|
|
|
|
if (string.IsNullOrWhiteSpace(itemName))
|
|
return Json(new { success = false, message = "Item name is required." });
|
|
|
|
if (lbsReceived <= 0)
|
|
return Json(new { success = false, message = "Quantity received must be greater than zero." });
|
|
|
|
// Resolve company id from the job chain; fall back to tenant context
|
|
var jobItem = await _unitOfWork.JobItems.FirstOrDefaultAsync(
|
|
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
|
|
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
|
|
// Check SKU uniqueness
|
|
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim()))
|
|
return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." });
|
|
|
|
// Determine category display name for legacy field
|
|
string categoryDisplay = string.Empty;
|
|
if (inventoryCategoryId.HasValue)
|
|
{
|
|
var cat = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(inventoryCategoryId.Value);
|
|
categoryDisplay = cat?.DisplayName ?? string.Empty;
|
|
}
|
|
|
|
var inventoryItem = new InventoryItem
|
|
{
|
|
CompanyId = companyId,
|
|
SKU = sku.Trim(),
|
|
Name = itemName.Trim(),
|
|
Description = description?.Trim(),
|
|
InventoryCategoryId = inventoryCategoryId,
|
|
Category = categoryDisplay,
|
|
Manufacturer = manufacturer?.Trim(),
|
|
ColorName = colorName?.Trim(),
|
|
ColorCode = colorCode?.Trim(),
|
|
Finish = finish?.Trim(),
|
|
VendorPartNumber = vendorPartNumber?.Trim(),
|
|
PrimaryVendorId = primaryVendorId,
|
|
QuantityOnHand = lbsReceived,
|
|
UnitOfMeasure = "lbs",
|
|
UnitCost = unitCost ?? 0,
|
|
LastPurchasePrice = unitCost ?? 0,
|
|
LastPurchaseDate = DateTime.UtcNow,
|
|
ReorderPoint = reorderPoint,
|
|
ReorderQuantity = reorderQuantity,
|
|
Location = location?.Trim(),
|
|
Notes = notes?.Trim(),
|
|
IsActive = true,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow,
|
|
};
|
|
|
|
await _unitOfWork.InventoryItems.AddAsync(inventoryItem);
|
|
await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id
|
|
|
|
// Opening stock transaction
|
|
var transaction = new InventoryTransaction
|
|
{
|
|
CompanyId = companyId,
|
|
InventoryItemId = inventoryItem.Id,
|
|
TransactionType = InventoryTransactionType.Purchase,
|
|
Quantity = lbsReceived,
|
|
UnitCost = unitCost ?? 0,
|
|
TotalCost = lbsReceived * (unitCost ?? 0),
|
|
TransactionDate = DateTime.UtcNow,
|
|
Notes = $"Initial stock — received from powder order for job {jobItem?.Job?.JobNumber}",
|
|
BalanceAfter = lbsReceived,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow,
|
|
};
|
|
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
|
|
|
// Mark coat as received and link to the new inventory item
|
|
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
coat.PowderReceived = true;
|
|
coat.PowderReceivedAt = DateTime.UtcNow;
|
|
coat.PowderReceivedByUserId = userId;
|
|
coat.PowderReceivedLbs = lbsReceived;
|
|
coat.InventoryItemId = inventoryItem.Id;
|
|
|
|
// Scan for sibling coats with the same custom powder and link them to the new item
|
|
var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coatId, companyId);
|
|
|
|
int linkedCount = 0;
|
|
foreach (var other in candidateCoats)
|
|
{
|
|
bool colorMatch = !string.IsNullOrWhiteSpace(colorCode)
|
|
? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase)
|
|
: !string.IsNullOrWhiteSpace(colorName) &&
|
|
string.Equals(other.ColorName?.Trim(), colorName.Trim(), StringComparison.OrdinalIgnoreCase);
|
|
|
|
if (!colorMatch) continue;
|
|
|
|
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId)
|
|
continue;
|
|
|
|
other.InventoryItemId = inventoryItem.Id;
|
|
linkedCount++;
|
|
}
|
|
|
|
if (linkedCount > 0)
|
|
_logger.LogInformation("Linked {Count} additional coat(s) to new inventory item {ItemId} ({SKU})",
|
|
linkedCount, inventoryItem.Id, inventoryItem.SKU);
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error adding custom powder to inventory for coat {CoatId}", coatId);
|
|
return Json(new { success = false, message = "An error occurred while saving." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Platform-level dashboard visible only to SuperAdmins who are not impersonating a tenant.
|
|
/// Displays a cross-company overview: total/active/inactive company counts, user count,
|
|
/// subscription plan distribution (sourced from DB-backed <c>SubscriptionPlanConfig</c> so
|
|
/// display names stay in sync), companies currently in their grace period or expired, and
|
|
/// the 10 most recently created companies. Regular dashboard traffic is routed here from
|
|
/// <see cref="Index"/> when the session contains no impersonation context.
|
|
/// </summary>
|
|
[Authorize(Roles = "SuperAdmin")]
|
|
public async Task<IActionResult> SuperAdminDashboard()
|
|
{
|
|
try
|
|
{
|
|
var today = DateTime.Today;
|
|
var data = await _dashboardRead.GetSuperAdminDashboardDataAsync(today);
|
|
|
|
var vm = new SuperAdminDashboardViewModel
|
|
{
|
|
TotalCompanies = data.TotalCompanies,
|
|
ActiveCompanies = data.ActiveCompanies,
|
|
InactiveCompanies = data.InactiveCompanies,
|
|
TotalUsers = data.TotalUsers,
|
|
PlanDistribution = data.PlanDistribution.ToDictionary(
|
|
kvp => kvp.Key,
|
|
kvp => (kvp.Value.DisplayName, kvp.Value.Count)),
|
|
ActiveSubscriptions = data.ActiveSubscriptions,
|
|
GracePeriodCount = data.GracePeriodCount,
|
|
ExpiredCount = data.ExpiredCount,
|
|
CompanyAlerts = data.CompanyAlerts.Select(c => new PlatformCompanyAlertDto
|
|
{
|
|
Id = c.Id,
|
|
CompanyName = c.CompanyName,
|
|
Plan = c.Plan,
|
|
PlanDisplayName = c.PlanDisplayName,
|
|
Status = c.Status,
|
|
SubscriptionEndDate = c.SubscriptionEndDate,
|
|
DaysOverdue = c.DaysOverdue,
|
|
IsActive = c.IsActive
|
|
}).ToList(),
|
|
RecentCompanies = data.RecentCompanies.Select(c => new PlatformRecentCompanyDto
|
|
{
|
|
Id = c.Id,
|
|
CompanyName = c.CompanyName,
|
|
Plan = c.Plan,
|
|
PlanDisplayName = c.PlanDisplayName,
|
|
Status = c.Status,
|
|
IsActive = c.IsActive,
|
|
CreatedAt = c.CreatedAt
|
|
}).ToList()
|
|
};
|
|
|
|
return View(vm);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error loading SuperAdmin dashboard");
|
|
TempData["Error"] = "An error occurred while loading the platform dashboard.";
|
|
return View(new SuperAdminDashboardViewModel());
|
|
}
|
|
}
|
|
|
|
private static List<PowderOrderVendorGroupDto> MapPowderOrderGroups(
|
|
IEnumerable<DashboardPowderOrderLineData> lines) =>
|
|
lines.GroupBy(l => l.VendorId)
|
|
.Select(g =>
|
|
{
|
|
var first = g.First();
|
|
return new PowderOrderVendorGroupDto
|
|
{
|
|
VendorId = g.Key,
|
|
VendorName = first.VendorName ?? "No Vendor Assigned",
|
|
VendorPhone = first.VendorPhone,
|
|
VendorEmail = first.VendorEmail,
|
|
TotalLbsNeeded = g.Sum(l => l.LbsToOrder),
|
|
TotalEstCost = g.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0m),
|
|
Lines = g.Select(l => new PowderOrderLineDto
|
|
{
|
|
CoatId = l.CoatId,
|
|
JobId = l.JobId,
|
|
JobNumber = l.JobNumber,
|
|
CustomerName = l.CustomerName,
|
|
CoatName = l.CoatName,
|
|
ColorName = l.ColorName,
|
|
ColorCode = l.ColorCode,
|
|
Finish = l.Finish,
|
|
SKU = l.SKU,
|
|
LbsToOrder = l.LbsToOrder,
|
|
CostPerLb = l.CostPerLb,
|
|
OrderedAt = l.OrderedAt,
|
|
HasInventoryItem = l.HasInventoryItem,
|
|
VendorId = l.VendorId
|
|
})
|
|
.OrderBy(l => l.OrderedAt ?? DateTime.MinValue)
|
|
.ThenBy(l => l.JobNumber)
|
|
.ThenBy(l => l.CoatName)
|
|
.ToList()
|
|
};
|
|
})
|
|
.OrderBy(g => g.VendorName)
|
|
.ToList();
|
|
|
|
/// <summary>
|
|
/// Projects a <see cref="Core.Entities.Job"/> into a lightweight <see cref="DashboardJobDto"/>
|
|
/// for use in dashboard job lists. Centralising the mapping in one static helper ensures that
|
|
/// all three job panels (today, overdue, in-progress) render identical column data without
|
|
/// duplicating the projection expression.
|
|
/// </summary>
|
|
private static DashboardJobDto MapJobDto(Core.Entities.Job j) => new()
|
|
{
|
|
Id = j.Id,
|
|
JobNumber = j.JobNumber,
|
|
CustomerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName)
|
|
? j.Customer.CompanyName
|
|
: $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(),
|
|
Description = j.Description,
|
|
StatusCode = j.JobStatus.StatusCode,
|
|
StatusDisplayName = j.JobStatus.DisplayName,
|
|
StatusColorClass = j.JobStatus.ColorClass,
|
|
PriorityCode = j.JobPriority.PriorityCode,
|
|
PriorityDisplayName = j.JobPriority.DisplayName,
|
|
PriorityColorClass = j.JobPriority.ColorClass,
|
|
ScheduledDate = j.ScheduledDate,
|
|
DueDate = j.DueDate,
|
|
AssignedWorkerName = j.AssignedUser?.FullName
|
|
};
|
|
}
|