Compare commits

..

5 Commits

Author SHA1 Message Date
spouliot f95397204c Fix platform admin subtle badge styling 2026-05-12 10:16:20 -04:00
spouliot 31d305b66a Group platform admin tools into hub pages
- add grouped platform admin hub pages, view models, and shared card UI\n- simplify the super admin nav and dashboard quick links around the new hubs\n- fix the AiQuoteService EstimatedMinutes assignment so the infrastructure project builds cleanly
2026-05-12 09:03:18 -04:00
spouliot 42a8c089d5 Fix AI photo quote always returning shop minimum price
Two bugs caused AI estimates to collapse to the shop minimum floor:

1. Coating rate with no guard: when a shop hadn't calibrated their
   coating gun (rate = 0), the prompt injected '~0 sqft/hr' paired
   with 'MUST use shop-specific rates' — Claude returned near-zero
   estimatedMinutes, zeroing labor cost and triggering the floor.
   Fixed to mirror the existing blast-rate guard: rate=0 now sends
   a fallback instruction to use conservative industry-average times.

2. Per-item minutes divided by quantity: both the system prompt and
   user prompt explicitly tell Claude to return estimatedMinutes 'per
   single item', but CalculatePricingPreview() was dividing by qty
   anyway. For qty > 1 this halved (or more) the labor cost, again
   pushing toward the floor. Removed the incorrect divide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:10:05 -04:00
spouliot 2c353f2e7f Three small UX fixes: customer contact validation, pagination dropdown width
- Customer validation now accepts Mobile Phone as a valid contact method;
  previously only Email or Phone satisfied the requirement
- Create customer hint text updated to match the new validation rule
- Pagination page-size dropdown gets min-width: 5rem so the number has
  breathing room from the caret arrow across all paginated lists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 18:20:43 -04:00
spouliot c02a5584b4 Hide Converted quotes from default listing to reduce clutter
The Quotes index now excludes Converted status by default so the list
stays focused on active work. Converted quotes remain accessible via
the status filter dropdown. Selecting any explicit status filter
(including Converted) bypasses the exclusion as expected.

Also consolidated the two GetQuoteStatusLookupsAsync calls into one
at the top of the action since the cache makes it free.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:45:09 -04:00
17 changed files with 414 additions and 147 deletions
@@ -140,12 +140,12 @@ public class CreateCustomerDto : IValidatableObject
new[] { nameof(CompanyName), nameof(ContactFirstName), nameof(ContactLastName) });
}
// At least one contact method is required (Email OR Phone)
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone))
// At least one contact method is required (Email, Phone, or Mobile Phone)
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone) && string.IsNullOrWhiteSpace(MobilePhone))
{
yield return new ValidationResult(
"Please provide at least one contact method (Email or Phone)",
new[] { nameof(Email), nameof(Phone) });
"Please provide at least one contact method (Email, Phone, or Mobile Phone)",
new[] { nameof(Email), nameof(Phone), nameof(MobilePhone) });
}
// Validate each address in comma-separated email fields
@@ -435,7 +435,15 @@ Only ask follow-up questions if truly needed — prefer to make reasonable assum
shopSpeedLine = "- Shop blast rate: not calibrated — use conservative industry-average times for this shop tier";
}
var coatingSpeedLine = $"- THIS SHOP'S coating application rate: ~{coatingRate:F0} sqft/hr";
string coatingSpeedLine;
if (coatingRate > 0)
coatingSpeedLine = $"- THIS SHOP'S coating application rate: ~{coatingRate:F0} sqft/hr — use this to derive coating time (surface area ÷ coating rate), NOT generic industry averages";
else
coatingSpeedLine = "- Shop coating rate: not calibrated — use conservative industry-average coating times for this shop tier";
var rateInstruction = (blastRate > 0 || coatingRate > 0)
? "IMPORTANT: For estimatedMinutes, you MUST use this shop's specific rates above where provided, not generic industry speeds."
: "IMPORTANT: For estimatedMinutes, use conservative industry-average times appropriate for a professional powder coating shop.";
return $@"Please analyze the item(s) in the photo(s) for powder coating estimation.
@@ -453,7 +461,7 @@ Company operating costs for your reference:
{shopSpeedLine}
{coatingSpeedLine}
IMPORTANT: For estimatedMinutes, you MUST use this shop's specific blast and coating rates above, not generic industry speeds.
{rateInstruction}
Sandblasting time = surface area of item ÷ shop blast rate (sqft/hr), adjusted for part complexity (harder-to-reach areas take more passes).
Coating time = surface area ÷ shop coating rate, adjusted for masking and complexity.
Include racking/unracking, inspection, and any material-specific prep (preheat handling, chemical stripping) as ACTIVE labor time.
@@ -547,9 +555,9 @@ Respond with the JSON object only.";
_ => 0
};
// Labor cost — AI returns total batch minutes, so divide by quantity to get per-item minutes.
// The unit price × quantity must equal the total batch labor cost.
var rawPerItemMinutes = aiResult.EstimatedMinutes / Math.Max(1m, (decimal)request.Quantity);
// Labor cost — AI returns per-item minutes (both system prompt and user prompt say "per single item").
// Unit price is per item; the caller multiplies by quantity for the line total.
var rawPerItemMinutes = aiResult.EstimatedMinutes;
var minFloorApplied = materialMinMinutes > 0 && rawPerItemMinutes < materialMinMinutes;
var perItemMinutes = minFloorApplied ? materialMinMinutes : rawPerItemMinutes;
var laborHours = perItemMinutes / 60m;
@@ -611,7 +619,7 @@ Respond with the JSON object only.";
CoatCount = request.CoatCount,
MaterialCost = Math.Round(materialCost, 2),
ConsumablesCost = Math.Round(consumablesSurcharge, 2),
EstimatedMinutes = (int)Math.Round(perItemMinutes),
EstimatedMinutes = perItemMinutes,
MaterialMinMinutes = materialMinMinutes,
MinFloorApplied = minFloorApplied,
LaborCost = Math.Round(laborCost, 2),
@@ -0,0 +1,133 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.ViewModels.PlatformAdmin;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class PlatformAdminController : Controller
{
private static readonly bool ShowRawLogFiles = string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME"));
public IActionResult TenantsBilling() => View(BuildTenantsBillingHub());
public IActionResult PeopleActivity() => View(BuildPeopleActivityHub());
public IActionResult ContentMessaging() => View(BuildContentMessagingHub());
public IActionResult Observability() => View(BuildObservabilityHub());
public IActionResult Maintenance() => View(BuildMaintenanceHub());
private static PlatformAdminHubViewModel BuildTenantsBillingHub() => new()
{
Title = "Tenants & Billing",
PageIcon = "bi-building-gear",
Intro = "Manage tenant accounts, subscription health, pricing plans, and payment-system signals from one place.",
Cards = new List<PlatformAdminLinkCardViewModel>
{
Card("Companies", "Browse all tenant companies, create new accounts, and jump into company-level details.", "Companies", "Index", "bi-building", "Daily", SubtleBadge("primary")),
Card("Company Health", "Review readiness, setup gaps, and health signals across every company.", "CompanyHealth", "Index", "bi-heart-pulse", "Review", SubtleBadge("success")),
Card("Subscriptions", "Manage tenant subscriptions, grace periods, expirations, and billing status.", "SubscriptionManagement", "Index", "bi-credit-card", "Daily", SubtleBadge("warning")),
Card("Subscription Plans", "Adjust platform plan definitions, limits, pricing, and feature packaging.", "PlatformSubscription", "Index", "bi-layers", "Config", SubtleBadge("info")),
Card("Revenue Dashboard", "Track platform-level revenue, plan mix, and commercial performance over time.", "Revenue", "Index", "bi-graph-up-arrow", "Monitor", SubtleBadge("secondary")),
Card("Stripe Events", "Inspect incoming Stripe webhook activity and payment-provider events.", "StripeEvents", "Index", "bi-lightning-charge", "Advanced", SubtleBadge("secondary")),
Card("SMS Agreements", "Audit company SMS agreement status and identify accounts blocked by compliance gating.", "SmsAgreements", "Index", "bi-file-earmark-check", "Compliance", SubtleBadge("danger"))
}
};
private static PlatformAdminHubViewModel BuildPeopleActivityHub() => new()
{
Title = "People & Activity",
PageIcon = "bi-people",
Intro = "See who is using the platform, what they are doing, and which tenants are still onboarding.",
Cards = new List<PlatformAdminLinkCardViewModel>
{
Card("Platform Users", "Manage SuperAdmin accounts and review platform-user access details.", "PlatformUsers", "Index", "bi-people-fill", "Daily", SubtleBadge("primary")),
Card("User Activity", "Review cross-tenant usage history, filters, and behavioral trends.", "UserActivity", "Index", "bi-person-lines-fill", "Review", SubtleBadge("success")),
Card("Online Now", "Check who is currently active in the application right now.", "UserActivity", "Online", "bi-broadcast-pin", "Live", SubtleBadge("success")),
Card("Onboarding Progress", "Track which companies are still working through setup and activation milestones.", "OnboardingProgress", "Index", "bi-rocket-takeoff", "Support", SubtleBadge("warning")),
Card("Platform Notifications", "Review platform-level in-app notifications and operational follow-ups.", "PlatformNotifications", "Index", "bi-bell", "Monitor", SubtleBadge("info"))
}
};
private static PlatformAdminHubViewModel BuildContentMessagingHub() => new()
{
Title = "Content & Messaging",
PageIcon = "bi-megaphone",
Intro = "Manage the platform-facing announcements, release communication, admin outreach, and inbound support signals.",
Cards = new List<PlatformAdminLinkCardViewModel>
{
Card("Announcements", "Publish or retire platform-wide announcements shown to tenant users.", "Announcements", "Index", "bi-megaphone", "Publish", SubtleBadge("primary")),
Card("Dashboard Tips", "Curate the rotating tips and educational nudges shown in the product dashboard.", "DashboardTips", "Index", "bi-lightbulb", "Content", SubtleBadge("warning")),
Card("Email Broadcast", "Send platform-admin broadcast emails to selected tenant companies.", "EmailBroadcast", "Index", "bi-broadcast", "Support", SubtleBadge("info")),
Card("Release Notes", "Publish and maintain customer-facing release notes and change summaries.", "ReleaseNotes", "Manage", "bi-journal-text", "Publish", SubtleBadge("success")),
Card("Contact Submissions", "Review inbound contact requests, questions, and support follow-up items.", "Contact", "Submissions", "bi-envelope", "Inbox", SubtleBadge("secondary")),
Card("Bug Reports", "Triage submitted product bugs and inspect the supporting report detail.", "BugReport", "Index", "bi-bug", "Triage", SubtleBadge("danger"))
}
};
private static PlatformAdminHubViewModel BuildObservabilityHub()
{
var cards = new List<PlatformAdminLinkCardViewModel>
{
Card("Audit Log", "Review the platform audit trail for sensitive actions and administrative changes.", "AuditLog", "Index", "bi-shield-check", "Investigate", SubtleBadge("primary")),
Card("System Logs", "Inspect application warning and error logs from the structured logging pipeline.", "SystemLogs", "Index", "bi-database-exclamation", "Investigate", SubtleBadge("danger")),
Card("System Info", "Review environment, infrastructure, and runtime diagnostics for the platform.", "SystemInfo", "Index", "bi-cpu", "Advanced", SubtleBadge("secondary")),
Card("AI Usage", "Monitor AI feature usage, cross-tenant consumption, and feature mix.", "AiUsageReport", "Index", "bi-robot", "Monitor", SubtleBadge("info")),
Card("Usage & Quota", "Review platform-wide usage ceilings, quota trends, and consumption pressure.", "UsageQuota", "Index", "bi-speedometer2", "Monitor", SubtleBadge("warning")),
Card("Banned IPs", "Manage blocked IP addresses and platform-level abuse controls.", "BannedIps", "Index", "bi-slash-circle", "Security", SubtleBadge("danger"))
};
if (ShowRawLogFiles)
{
cards.Add(Card("Raw Log Files", "Open raw log-file views for deeper troubleshooting in environments where file logs are available.", "Diagnostics", "ViewLogs", "bi-file-text", "Advanced", SubtleBadge("secondary")));
}
return new PlatformAdminHubViewModel
{
Title = "Observability",
PageIcon = "bi-binoculars",
Intro = "Investigate platform behavior across audit trails, system logs, AI usage, quotas, and security-oriented diagnostics.",
Cards = cards
};
}
private static PlatformAdminHubViewModel BuildMaintenanceHub() => new()
{
Title = "Maintenance",
PageIcon = "bi-wrench-adjustable-circle",
Intro = "Use these tools for exceptional maintenance work, migration tasks, and destructive admin operations. They are not routine day-to-day workflows.",
WarningTitle = "Use With Care",
WarningMessage = "These tools can expose bulk data, change platform state, or permanently remove records. Use them deliberately and preferably with a written reason or ticket.",
Cards = new List<PlatformAdminLinkCardViewModel>
{
Card("Data Export", "Export a tenant company's data set for audits, offboarding, support, or migration work.", "DataExport", "Index", "bi-file-earmark-arrow-down", "Maintenance", SubtleBadge("warning")),
Card("Data Purge", "Permanently delete soft-deleted records after previewing impact and cutoff windows.", "DataPurge", "Index", "bi-trash3", "Dangerous", SubtleBadge("danger")),
Card("Storage Migration", "Run one-off migration of local media files into cloud storage.", "StorageMigration", "Index", "bi-cloud-upload", "One-off", SubtleBadge("info")),
Card("Seed Data", "Seed or remove system and demo data for setup, QA, or controlled test scenarios.", "SeedData", "Index", "bi-database-fill-gear", "Restricted", SubtleBadge("danger"))
}
};
private static PlatformAdminLinkCardViewModel Card(
string title,
string description,
string controller,
string action,
string icon,
string badgeText,
string badgeStyle) => new()
{
Title = title,
Description = description,
Controller = controller,
Action = action,
Icon = icon,
BadgeText = badgeText,
BadgeStyle = badgeStyle
};
private static string SubtleBadge(string tone) =>
$"bg-{tone}-subtle text-{tone}-emphasis border border-{tone}-subtle";
}
@@ -112,12 +112,14 @@ public class QuotesController : Controller
{
try
{
// Load statuses once up front — needed for statusCode resolution and default filter
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
// Resolve statusCode → statusFilter ID if provided (e.g. from dashboard links)
if (!string.IsNullOrWhiteSpace(statusCode) && !statusFilter.HasValue)
{
var companyIdForLookup = _tenantContext.GetCurrentCompanyId() ?? 0;
var allStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyIdForLookup);
var match = allStatuses.FirstOrDefault(s =>
var match = quoteStatuses.FirstOrDefault(s =>
s.StatusCode.Equals(statusCode.Trim().ToUpper(), StringComparison.OrdinalIgnoreCase));
if (match != null)
statusFilter = match.Id;
@@ -169,6 +171,18 @@ public class QuotesController : Controller
var statusId = statusFilter.Value;
filter = q => q.QuoteStatusId == statusId;
}
else
{
// Default (no filter): hide Converted to keep the list clean.
// Users can view converted quotes by selecting Converted from the status filter.
var convertedId = quoteStatuses
.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted)?.Id;
if (convertedId.HasValue)
{
var cId = convertedId.Value;
filter = q => q.QuoteStatusId != cId;
}
}
// Build orderBy function
Func<IQueryable<Quote>, IOrderedQueryable<Quote>> orderBy = gridRequest.SortColumn switch
@@ -216,9 +230,6 @@ public class QuotesController : Controller
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection;
// Use cached quote statuses
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
ViewBag.QuoteStatuses = quoteStatuses
.OrderBy(s => s.DisplayOrder)
.Select(s => new SelectListItem
@@ -0,0 +1,11 @@
namespace PowderCoating.Web.ViewModels.PlatformAdmin;
public class PlatformAdminHubViewModel
{
public string Title { get; set; } = string.Empty;
public string PageIcon { get; set; } = string.Empty;
public string Intro { get; set; } = string.Empty;
public string? WarningTitle { get; set; }
public string? WarningMessage { get; set; }
public IReadOnlyList<PlatformAdminLinkCardViewModel> Cards { get; set; } = Array.Empty<PlatformAdminLinkCardViewModel>();
}
@@ -0,0 +1,13 @@
namespace PowderCoating.Web.ViewModels.PlatformAdmin;
public class PlatformAdminLinkCardViewModel
{
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Controller { get; set; } = string.Empty;
public string Action { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
public string BadgeText { get; set; } = string.Empty;
public string BadgeStyle { get; set; } = string.Empty;
public Dictionary<string, string>? RouteValues { get; set; }
}
@@ -59,7 +59,7 @@
</h5>
<div class="alert alert-info alert-permanent py-2 px-3 mb-3" style="font-size:.875rem;">
<i class="bi bi-info-circle me-1"></i>
<strong>Required:</strong> At least one of Company Name, First Name, or Last Name — and at least one of Email or Phone.
<strong>Required:</strong> At least one of Company Name, First Name, or Last Name — and at least one of Email, Phone, or Mobile Phone.
</div>
<div class="row g-3">
<div class="col-md-6">
@@ -251,6 +251,21 @@
<a asp-controller="Companies" asp-action="Create" class="btn btn-sm btn-outline-primary">
<i class="bi bi-plus-circle me-1"></i>New Company
</a>
<a asp-controller="PlatformAdmin" asp-action="TenantsBilling" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-building-gear me-1"></i>Tenants &amp; Billing
</a>
<a asp-controller="PlatformAdmin" asp-action="PeopleActivity" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-people me-1"></i>People &amp; Activity
</a>
<a asp-controller="PlatformAdmin" asp-action="ContentMessaging" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-megaphone me-1"></i>Content &amp; Messaging
</a>
<a asp-controller="PlatformAdmin" asp-action="Observability" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-binoculars me-1"></i>Observability
</a>
<a asp-controller="PlatformAdmin" asp-action="Maintenance" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-wrench-adjustable me-1"></i>Maintenance
</a>
<a asp-controller="PlatformUsers" asp-action="Index" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-people me-1"></i>Platform Users
</a>
@@ -263,7 +278,7 @@
@if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME")))
{
<a asp-controller="Diagnostics" asp-action="ViewLogs" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-text me-1"></i>View Logs
<i class="bi bi-file-text me-1"></i>Raw Logs
</a>
}
<a asp-controller="SeedData" asp-action="Index" class="btn btn-sm btn-outline-secondary">
@@ -57,7 +57,7 @@
<h3 class="mb-1"><i class="bi bi-envelope-paper me-2 text-primary"></i>Admin Email Wizard</h3>
<p class="text-muted mb-0">Step 1 of 3: write the subject and rich-text message.</p>
</div>
<div class="badge text-bg-primary-subtle border border-primary-subtle px-3 py-2">Super Admin Only</div>
<div class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle px-3 py-2">Super Admin Only</div>
</div>
@if (TempData["Success"] != null)
@@ -0,0 +1,9 @@
@using PowderCoating.Web.ViewModels.PlatformAdmin
@model PlatformAdminHubViewModel
@{
ViewData["Title"] = Model.Title;
ViewData["PageIcon"] = Model.PageIcon;
}
@await Html.PartialAsync("_HubCardGrid", Model)
@@ -0,0 +1,9 @@
@using PowderCoating.Web.ViewModels.PlatformAdmin
@model PlatformAdminHubViewModel
@{
ViewData["Title"] = Model.Title;
ViewData["PageIcon"] = Model.PageIcon;
}
@await Html.PartialAsync("_HubCardGrid", Model)
@@ -0,0 +1,9 @@
@using PowderCoating.Web.ViewModels.PlatformAdmin
@model PlatformAdminHubViewModel
@{
ViewData["Title"] = Model.Title;
ViewData["PageIcon"] = Model.PageIcon;
}
@await Html.PartialAsync("_HubCardGrid", Model)
@@ -0,0 +1,9 @@
@using PowderCoating.Web.ViewModels.PlatformAdmin
@model PlatformAdminHubViewModel
@{
ViewData["Title"] = Model.Title;
ViewData["PageIcon"] = Model.PageIcon;
}
@await Html.PartialAsync("_HubCardGrid", Model)
@@ -0,0 +1,9 @@
@using PowderCoating.Web.ViewModels.PlatformAdmin
@model PlatformAdminHubViewModel
@{
ViewData["Title"] = Model.Title;
ViewData["PageIcon"] = Model.PageIcon;
}
@await Html.PartialAsync("_HubCardGrid", Model)
@@ -0,0 +1,138 @@
@using PowderCoating.Web.ViewModels.PlatformAdmin
@model PlatformAdminHubViewModel
@{
bool hasWarning = !string.IsNullOrWhiteSpace(Model.WarningTitle) || !string.IsNullOrWhiteSpace(Model.WarningMessage);
}
<style>
.platform-admin-hub {
max-width: 1240px;
}
.platform-admin-intro {
max-width: 820px;
}
.platform-admin-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
.platform-admin-card {
display: flex;
flex-direction: column;
gap: 0.9rem;
min-height: 100%;
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0.95rem;
padding: 1.2rem;
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
}
.platform-admin-card:hover {
border-color: var(--bs-primary);
box-shadow: 0 6px 18px rgba(13, 110, 253, 0.12);
transform: translateY(-2px);
color: inherit;
}
.platform-admin-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.platform-admin-card-icon {
width: 46px;
height: 46px;
border-radius: 0.9rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: rgba(13, 110, 253, 0.12);
color: #0d6efd;
font-size: 1.25rem;
}
.platform-admin-card-title {
font-size: 0.98rem;
font-weight: 700;
margin: 0;
}
.platform-admin-card-copy {
color: var(--bs-secondary-color);
font-size: 0.84rem;
line-height: 1.5;
margin: 0;
flex: 1;
}
.platform-admin-card-arrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--bs-primary);
}
</style>
<div class="platform-admin-hub container-fluid py-2">
<div class="mb-4">
<h4 class="fw-bold mb-2">
<i class="bi @Model.PageIcon me-2 text-primary"></i>@Model.Title
</h4>
<p class="platform-admin-intro text-muted mb-0">@Model.Intro</p>
</div>
@if (hasWarning)
{
<div class="alert alert-warning alert-permanent border mb-4">
@if (!string.IsNullOrWhiteSpace(Model.WarningTitle))
{
<div class="fw-semibold mb-1">
<i class="bi bi-exclamation-triangle me-2"></i>@Model.WarningTitle
</div>
}
@if (!string.IsNullOrWhiteSpace(Model.WarningMessage))
{
<div class="small">@Model.WarningMessage</div>
}
</div>
}
<div class="platform-admin-grid">
@foreach (var card in Model.Cards)
{
<a asp-controller="@card.Controller"
asp-action="@card.Action"
asp-all-route-data="@card.RouteValues"
class="platform-admin-card">
<div class="platform-admin-card-head">
<div class="platform-admin-card-icon">
<i class="bi @card.Icon"></i>
</div>
@if (!string.IsNullOrWhiteSpace(card.BadgeText))
{
<span class="badge @card.BadgeStyle">@card.BadgeText</span>
}
</div>
<div>
<h5 class="platform-admin-card-title">@card.Title</h5>
</div>
<p class="platform-admin-card-copy">@card.Description</p>
<div class="platform-admin-card-arrow">
Open area <i class="bi bi-arrow-right"></i>
</div>
</a>
}
</div>
</div>
+20 -127
View File
@@ -1281,140 +1281,37 @@
@* Multi-tenancy: SuperAdmin Platform Management (hidden while impersonating) *@
@if (User.IsInRole("SuperAdmin") && !isImpersonating)
{
<div class="nav-section-title">Tenants &amp; Billing</div>
<a asp-controller="Companies" asp-action="Index" class="nav-link">
<i class="bi bi-building"></i>
<span>Companies</span>
</a>
<a asp-controller="CompanyHealth" asp-action="Index" class="nav-link">
<i class="bi bi-heart-pulse"></i>
<span>Company Health</span>
</a>
<a asp-controller="SubscriptionManagement" asp-action="Index" class="nav-link">
<i class="bi bi-credit-card"></i>
<span>Subscriptions</span>
</a>
<a asp-controller="PlatformSubscription" asp-action="Index" class="nav-link">
<i class="bi bi-layers"></i>
<span>Subscription Plans</span>
</a>
<a asp-controller="Revenue" asp-action="Index" class="nav-link">
<i class="bi bi-graph-up-arrow"></i>
<span>Revenue Dashboard</span>
</a>
<a asp-controller="StripeEvents" asp-action="Index" class="nav-link">
<i class="bi bi-lightning-charge"></i>
<span>Stripe Events</span>
</a>
<a asp-controller="SmsAgreements" asp-action="Index" class="nav-link">
<i class="bi bi-file-earmark-check"></i>
<span>SMS Agreements</span>
</a>
<div class="nav-section-title">Content &amp; Communication</div>
<a asp-controller="Announcements" asp-action="Index" class="nav-link">
<i class="bi bi-megaphone"></i>
<span>Announcements</span>
</a>
<a asp-controller="DashboardTips" asp-action="Index" class="nav-link">
<i class="bi bi-lightbulb"></i>
<span>Dashboard Tips</span>
</a>
<a asp-controller="EmailBroadcast" asp-action="Index" class="nav-link">
<i class="bi bi-broadcast"></i>
<span>Email Broadcast</span>
</a>
<div class="nav-section-title">Users &amp; Activity</div>
<a asp-controller="OnboardingProgress" asp-action="Index" class="nav-link">
<i class="bi bi-rocket-takeoff"></i>
<span>Onboarding Progress</span>
</a>
<a asp-controller="PlatformUsers" asp-action="Index" class="nav-link">
<i class="bi bi-people-fill"></i>
<span>Platform Users</span>
</a>
<a asp-controller="UserActivity" asp-action="Index" class="nav-link">
<i class="bi bi-person-lines-fill"></i>
<span>User Activity</span>
</a>
<a asp-controller="UserActivity" asp-action="Online" class="nav-link d-flex align-items-center justify-content-between">
<span><i class="bi bi-circle-fill me-2" style="color:#22c55e;font-size:.55rem;vertical-align:middle;"></i>Online Now</span>
@{ var _onlineCount = OnlineUserTracker.GetActiveCount(15); }
@if (_onlineCount > 0)
{
<span class="badge rounded-pill" style="background:#22c55e;color:#fff;font-size:.7rem;">@_onlineCount</span>
}
</a>
<a asp-controller="PlatformNotifications" asp-action="Index" class="nav-link">
<i class="bi bi-bell"></i>
<span>Notification Log</span>
</a>
<div class="nav-section-title">AI &amp; Usage</div>
<a asp-controller="AiUsageReport" asp-action="Index" class="nav-link">
<i class="bi bi-robot"></i>
<span>AI Usage</span>
</a>
<a asp-controller="UsageQuota" asp-action="Index" class="nav-link">
<i class="bi bi-speedometer2"></i>
<span>Usage &amp; Quota</span>
</a>
<div class="nav-section-title">Logs &amp; Support</div>
<a asp-controller="Contact" asp-action="Submissions" class="nav-link">
<i class="bi bi-envelope"></i>
<span>Contact Submissions</span>
</a>
<a asp-controller="BugReport" asp-action="Index" class="nav-link">
<i class="bi bi-bug"></i>
<span>Bug Reports</span>
</a>
<a asp-controller="AuditLog" asp-action="Index" class="nav-link">
<div class="nav-section-title">Platform Admin</div>
<a asp-controller="Dashboard" asp-action="SuperAdminDashboard" class="nav-link">
<i class="bi bi-shield-check"></i>
<span>Audit Log</span>
<span>Platform Overview</span>
</a>
<a asp-controller="BannedIps" asp-action="Index" class="nav-link">
<i class="bi bi-slash-circle"></i>
<span>Banned IPs</span>
<a asp-controller="PlatformAdmin" asp-action="TenantsBilling" class="nav-link">
<i class="bi bi-building-gear"></i>
<span>Tenants &amp; Billing</span>
</a>
<a asp-controller="SystemLogs" asp-action="Index" class="nav-link">
<i class="bi bi-database-exclamation"></i>
<span>System Logs</span>
<a asp-controller="PlatformAdmin" asp-action="PeopleActivity" class="nav-link">
<i class="bi bi-people"></i>
<span>People &amp; Activity</span>
</a>
@if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME")))
{
<a asp-controller="Diagnostics" asp-action="ViewLogs" class="nav-link">
<i class="bi bi-file-text"></i>
<span>Raw Log Files</span>
<a asp-controller="PlatformAdmin" asp-action="ContentMessaging" class="nav-link">
<i class="bi bi-megaphone"></i>
<span>Content &amp; Messaging</span>
</a>
}
<a asp-controller="SystemInfo" asp-action="Index" class="nav-link">
<i class="bi bi-cpu"></i>
<span>System Info</span>
<a asp-controller="PlatformAdmin" asp-action="Observability" class="nav-link">
<i class="bi bi-binoculars"></i>
<span>Observability</span>
</a>
<a asp-controller="PlatformAdmin" asp-action="Maintenance" class="nav-link">
<i class="bi bi-wrench-adjustable-circle"></i>
<span>Maintenance</span>
</a>
<div class="nav-section-title">Data &amp; Tools</div>
<a asp-controller="DataExport" asp-action="Index" class="nav-link">
<i class="bi bi-file-earmark-arrow-down"></i>
<span>Data Export</span>
</a>
<a asp-controller="DataPurge" asp-action="Index" class="nav-link">
<i class="bi bi-trash3"></i>
<span>Data Purge</span>
</a>
<a asp-controller="StorageMigration" asp-action="Index" class="nav-link">
<i class="bi bi-cloud-upload"></i>
<span>Storage Migration</span>
</a>
<div class="nav-section-title">Platform Configuration</div>
<a asp-controller="PlatformSettings" asp-action="Index" class="nav-link">
<i class="bi bi-sliders"></i>
<span>Platform Settings</span>
</a>
<a asp-controller="SeedData" asp-action="Index" class="nav-link">
<i class="bi bi-database-fill-gear"></i>
<span>Seed Data</span>
</a>
<a asp-controller="PowderCatalog" asp-action="Index" class="nav-link">
<i class="bi bi-palette2"></i>
<span>Powder Catalog</span>
@@ -1423,10 +1320,6 @@
<i class="bi bi-link-45deg"></i>
<span>Manufacturer Lookup Patterns</span>
</a>
<a asp-controller="ReleaseNotes" asp-action="Manage" class="nav-link">
<i class="bi bi-journal-text"></i>
<span>Release Notes</span>
</a>
}
}
@@ -24,7 +24,7 @@
<span class="me-2">Showing @Model.StartIndex-@Model.EndIndex of @Model.TotalCount results</span>
<span class="me-2">|</span>
<label class="me-2 mb-0">Page size:</label>
<select class="form-select form-select-sm w-auto" onchange="changePageSize(this.value)">
<select class="form-select form-select-sm w-auto" style="min-width: 5rem;" onchange="changePageSize(this.value)">
<option value="10" selected="@(Model.PageSize == 10)">10</option>
<option value="25" selected="@(Model.PageSize == 25)">25</option>
<option value="50" selected="@(Model.PageSize == 50)">50</option>