Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Shared/_Layout.cshtml
T
spouliot a0bdd2b5b4 Sweep all .cshtml files for encoding corruption; add pre-commit guard
Replace all corruption variants with HTML entities across 226 view files:
- 3-char UTF-8-as-Win1252 sequences (ae-corruption)
- Standalone smart/curly quotes that break C# Razor expressions
- Partially re-corrupted variants where the 3rd byte was normalised to ASCII

tools/Fix-Encoding.ps1: re-runnable sweep; uses [char] code points so the
script itself never contains a literal non-ASCII character; supports -DryRun

.githooks/pre-commit: blocks commits containing the ae-corruption byte
signature (xc3xa2xe2x82xac); git core.hooksPath = .githooks so the
hook is repo-committed and active for all future work on this machine.

Build clean; 225 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 21:37:10 -04:00

2267 lines
100 KiB
Plaintext

@inject PowderCoating.Core.Interfaces.ITenantContext TenantContext
@inject PowderCoating.Core.Interfaces.IUnitOfWork UnitOfWork
@inject PowderCoating.Web.Services.IOnlineUserTracker OnlineUserTracker
@inject Microsoft.AspNetCore.Hosting.IWebHostEnvironment _hostEnv
@{
string? companyName = null;
bool companyHasLogo = false;
long logoVersion = 1;
var isSuperAdmin = TenantContext.IsSuperAdmin();
var isImpersonating = Context.Session.GetString("ImpersonatingCompanyName") != null;
// While impersonating, treat the session as a regular company user (not platform admin)
var isPlatformAdmin = TenantContext.IsPlatformAdmin() && !isImpersonating;
var theme = User.FindFirst("Theme")?.Value ?? "light";
var sidebarColor = User.FindFirst("SidebarColor")?.Value ?? "ocean";
var _surfaceCookie = Context.Request.Cookies["pcl_surface"];
var _themeFromClaim = User.FindFirst("Theme")?.Value; // "light" or "dark" from profile
var _pclSurface = _surfaceCookie == "ink" ? "ink"
: _surfaceCookie == "paper" ? "paper"
: _themeFromClaim == "dark" ? "ink"
: "paper";
var _bsTheme = (_pclSurface == "ink") ? "dark" : "light";
var hasProfilePic = User.FindFirst("HasProfilePicture")?.Value == "true";
var _envName = _hostEnv.EnvironmentName; // Development, Staging, Production, etc.
var _isNonProd = !_hostEnv.IsProduction();
var _envColor = _envName.ToLower() switch
{
"development" => "#b45309", // amber-700
"staging" => "#7c3aed", // violet-700
"testing" => "#0369a1", // sky-700
_ => "#374151" // gray-700 for anything else
};
// Nav mode: used by FOUC-prevention inline script + nav-mode.js
var _finCtrlSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase) {
"bills", "accounts", "journalentries", "vendorcredits",
"bankreconciliations", "fixedassets", "budgets", "recurringtemplates",
"accountingexport", "taxrates"
};
var _navController = ViewContext.RouteData.Values["controller"]?.ToString() ?? "";
var _serverNavMode = _finCtrlSet.Contains(_navController) ? "fin" : "ops";
if (User.Identity?.IsAuthenticated == true)
{
if (isImpersonating)
{
// Show impersonated company name and logo in sidebar
companyName = Context.Session.GetString("ImpersonatingCompanyName");
var impersonatedId = Context.Session.GetInt32("ImpersonatingCompanyId");
if (impersonatedId.HasValue)
{
var company = await UnitOfWork.Companies.GetByIdAsync(impersonatedId.Value, ignoreQueryFilters: true);
if (company != null)
{
companyHasLogo = !string.IsNullOrEmpty(company.LogoFilePath) || (company.LogoData?.Length > 0);
logoVersion = (company.UpdatedAt ?? DateTime.UtcNow).Ticks;
}
}
}
else
{
var companyId = TenantContext.GetCurrentCompanyId();
if (isPlatformAdmin)
{
// Platform admins (demo company) see "Super Admin Mode"
companyName = "Super Admin Mode";
// Can still see their assigned company's logo
if (companyId.HasValue && companyId.Value > 0)
{
var company = await UnitOfWork.Companies.GetByIdAsync(companyId.Value, ignoreQueryFilters: true);
if (company != null && (!string.IsNullOrEmpty(company.LogoFilePath) || (company.LogoData != null && company.LogoData.Length > 0)))
{
companyHasLogo = true;
logoVersion = (company.UpdatedAt ?? DateTime.UtcNow).Ticks;
}
}
}
else
{
// Regular users AND company SuperAdmins see their real company name and logo
if (companyId.HasValue && companyId.Value > 0)
{
var company = await UnitOfWork.Companies.GetByIdAsync(companyId.Value, ignoreQueryFilters: true);
if (company != null)
{
companyName = company.CompanyName;
companyHasLogo = !string.IsNullOrEmpty(company.LogoFilePath) || (company.LogoData != null && company.LogoData.Length > 0);
logoVersion = (company.UpdatedAt ?? DateTime.UtcNow).Ticks;
}
else
{
companyName = "No Company";
}
}
else
{
companyName = "No Company";
}
}
}
}
}
<!DOCTYPE html>
<html lang="en" data-surface="@_pclSurface" data-bs-theme="@_bsTheme" data-sidebar="@sidebarColor">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - Powder Coating Logix</title>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#1A1A1C" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="PCLogix" />
<link rel="apple-touch-icon" sizes="180x180" href="/images/apple-touch-icon.png" />
<!-- First-paint theme: server already stamped data-surface from cookie.
This script only corrects first-visit (no cookie) using OS preference. -->
<script>
(function () {
var hasCookie = document.cookie.indexOf('pcl_surface=') !== -1;
if (!hasCookie && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-surface', 'ink');
document.documentElement.setAttribute('data-bs-theme', 'dark');
}
})();
</script>
<!-- Bootstrap 5 CSS -->
<link href="~/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="~/lib/bootstrap-icons/font/bootstrap-icons.css">
<!-- PCL design-system fonts: Inter · IBM Plex Mono · Fraunces -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@@9..144,500&family=IBM+Plex+Mono:wght@@400;500&family=Inter:wght@@400;500;600&display=swap" rel="stylesheet">
<!-- Site-wide styles (tokens, utilities, component overrides) -->
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<!-- Mobile Card Styles -->
<link rel="stylesheet" href="~/css/mobile-cards.css" />
<style>
:root {
--sidebar-width: 260px;
--sidebar-hover: rgba(255,255,255,0.07);
--primary-color: #4f46e5;
--primary-hover: #4338ca;
--env-banner-height: @(_isNonProd ? "32px" : "0px");
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-ui, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
}
/* Sidebar Styles */
.sidebar {
position: fixed;
top: var(--env-banner-height);
left: 0;
height: calc(100vh - var(--env-banner-height));
width: var(--sidebar-width);
color: white;
padding: 0;
z-index: 1000;
transition: all 0.3s ease;
overflow-y: auto;
scrollbar-gutter: stable;
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 1.5rem 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 700;
color: white;
text-decoration: none;
}
.sidebar-brand:hover {
color: white;
}
.sidebar-brand i {
font-size: 1.75rem;
color: var(--sidebar-accent, var(--primary-color));
flex-shrink: 0;
}
.brand-title {
font-size: 1.25rem;
font-weight: 700;
line-height: 1.2;
}
.brand-company {
font-size: 0.75rem;
font-weight: 400;
color: var(--sidebar-accent, var(--primary-color));
margin-top: 0.25rem;
opacity: 0.9;
line-height: 1;
}
.brand-superadmin {
color: #fbbf24;
font-weight: 600;
opacity: 1;
display: flex;
align-items: center;
gap: 0.25rem;
}
.brand-superadmin i {
font-size: 0.85rem;
}
.company-logo {
max-width: 185px;
max-height: 95px;
object-fit: contain;
margin-bottom: 0.35rem;
}
.company-logo-name {
font-size: 0.8rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.75);
letter-spacing: 0.02em;
text-align: center;
line-height: 1.2;
}
.sidebar-nav {
padding: 1rem 0;
flex: 1;
overflow-y: auto;
}
.page-footer {
padding: 0.6rem 2rem;
border-top: 1px solid var(--bs-border-color);
display: flex;
justify-content: flex-end;
}
.page-footer-link {
display: flex;
align-items: center;
gap: 0.4rem;
text-decoration: none;
color: var(--bs-secondary-color);
opacity: 0.75;
font-size: 0.7rem;
transition: opacity 0.2s;
}
.page-footer-link:hover {
opacity: 0.85;
color: var(--bs-secondary-color);
}
.page-footer-logo {
height: 16px;
width: auto;
object-fit: contain;
}
.nav-section-title {
padding: 0.65rem 1.5rem 0.3rem;
font-family: var(--font-mono, 'IBM Plex Mono', ui-monospace, monospace);
font-size: 0.68rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--pcl-steel);
}
.nav-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.55rem 1.5rem;
color: rgba(255,255,255,0.75);
text-decoration: none;
transition: all 0.2s;
border-left: 3px solid transparent;
}
.nav-link:hover {
background-color: var(--sidebar-hover);
color: white;
}
.nav-link.active {
background-color: var(--sidebar-hover);
color: white;
}
.nav-link.active i {
color: var(--pcl-ember, #d97706);
}
.nav-link i {
font-size: 1.25rem;
width: 1.5rem;
}
/* ── Nav mode: FOUC prevention &mdash; CSS wins before JS runs ─────── */
html[data-nav-mode="ops"] [data-nav="fin"] { display: none !important; }
html[data-nav-mode="fin"] [data-nav="ops"] { display: none !important; }
/* ── Nav mode strip (Operations | Finance tab switcher) ──────── */
.nav-mode-strip {
display: flex;
border-bottom: 1px solid rgba(255,255,255,0.1);
padding: 0 8px;
margin-bottom: 4px;
flex-shrink: 0;
}
.nav-mode-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 10px 4px 8px;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
margin-bottom: -1px;
font-family: var(--font-mono, 'IBM Plex Mono', ui-monospace, monospace);
font-size: 0.64rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
background: transparent;
color: rgba(255,255,255,0.38);
white-space: nowrap;
}
.nav-mode-btn i {
font-size: 0.85rem;
flex-shrink: 0;
}
.nav-mode-btn:hover {
color: rgba(255,255,255,0.75);
}
.nav-mode-btn.active {
color: #fff;
border-bottom-color: var(--pcl-ember, #d97706);
}
/* Main Content */
.main-content {
margin-left: var(--sidebar-width);
min-height: 100vh;
}
/* Top Navigation Bar */
.top-navbar {
background: var(--bs-body-bg);
border-bottom: 1px solid var(--bs-border-color);
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 900;
}
.page-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--bs-emphasis-color);
margin: 0;
}
.user-menu {
display: flex;
align-items: center;
gap: 1rem;
}
.install-app-btn {
display: inline-flex;
align-items: center;
gap: 0.45rem;
white-space: nowrap;
}
.install-app-btn i {
font-size: 0.95rem;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--pcl-graphite, #1A1A1C);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-family: var(--font-mono, 'IBM Plex Mono', ui-monospace, monospace);
cursor: pointer;
}
.gear-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: var(--bs-tertiary-bg);
color: var(--bs-secondary-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
cursor: pointer;
transition: background 0.15s, color 0.15s, transform 0.2s;
}
.gear-btn:hover {
background: var(--bs-secondary-bg);
color: var(--bs-emphasis-color);
transform: rotate(30deg);
}
.gear-btn:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.25);
}
.bell-btn {
position: relative;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: var(--bs-tertiary-bg);
color: var(--bs-secondary-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.bell-btn:hover { background: var(--bs-secondary-bg); color: var(--bs-emphasis-color); }
.bell-btn:focus { outline: none; box-shadow: 0 0 0 3px rgba(99,102,241,.25); }
.bell-badge {
position: absolute;
top: 2px; right: 2px;
min-width: 16px; height: 16px;
font-size: 0.65rem; font-weight: 700;
line-height: 16px; padding: 0 3px;
border-radius: 8px;
background: #dc3545; color: #fff;
pointer-events: none;
}
.notif-dropdown {
width: 360px;
max-height: 480px;
overflow-y: auto;
padding: 0;
}
.notif-item {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--bs-border-color);
cursor: pointer;
transition: background 0.1s;
}
.notif-item:hover { background: var(--bs-tertiary-bg); }
.notif-item.unread { border-left: 3px solid #6366f1; background: rgba(99,102,241,.05); }
.notif-item.read { border-left: 3px solid transparent; opacity: 0.7; }
.notif-item .notif-title { font-weight: 600; font-size: 0.875rem; }
.notif-item.read .notif-title { font-weight: 400; }
.notif-item .notif-msg { font-size: 0.8rem; color: var(--bs-secondary-color); }
.notif-item .notif-time { font-size: 0.75rem; color: var(--bs-secondary-color); margin-top: 2px; }
.notif-unread-dot { width: 8px; height: 8px; background: #6366f1; border-radius: 50%; flex-shrink: 0; margin-top: 6px; }
.notif-dropdown-footer { padding: 0.5rem 1rem; border-top: 1px solid var(--bs-border-color); text-align: center; position: sticky; bottom: 0; background: var(--bs-body-bg); }
.notif-type-icon { font-size: 1.1rem; }
.notif-type-icon.approved { color: #198754; }
.notif-type-icon.declined { color: #dc3545; }
.notif-type-icon.paid { color: #0d6efd; }
/* Content Area */
.content-area {
padding: 2rem;
background: var(--bs-secondary-bg);
min-height: calc(100vh - 73px);
}
/* Cards */
.card {
border: 1px solid var(--bs-border-color);
border-radius: 0.75rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
margin-bottom: 1.5rem;
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
}
.card-header {
background: var(--bs-body-bg);
border-bottom: 1px solid var(--bs-border-color);
padding: 1.25rem 1.5rem;
font-weight: 600;
border-radius: 0.75rem 0.75rem 0 0;
}
.card-body {
padding: 1.5rem;
}
/* Buttons */
.btn-primary {
background-color: var(--primary-color);
border-color: var(--primary-color);
padding: 0.625rem 1.25rem;
font-weight: 500;
border-radius: 0.5rem;
}
.btn-primary:hover {
background-color: var(--primary-hover);
border-color: var(--primary-hover);
}
/* Table Styles */
.table {
margin-bottom: 0;
--bs-table-bg: var(--bs-body-bg);
--bs-table-hover-bg: var(--bs-tertiary-bg);
--bs-table-border-color: var(--bs-border-color);
}
.table > thead > tr > th,
.table > thead > tr > td {
background-color: var(--bs-tertiary-bg) !important;
color: var(--bs-secondary-color) !important;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
border-bottom: 2px solid var(--bs-border-color) !important;
padding: 0.75rem;
}
.table tbody tr {
border-bottom: 1px solid var(--bs-border-color);
}
.table tbody tr:hover {
background-color: var(--bs-tertiary-bg);
}
/* Badges */
.badge {
padding: 0.375rem 0.75rem;
font-weight: 500;
border-radius: 0.375rem;
font-size: 0.75rem;
}
/* Hamburger Button */
.hamburger-btn {
color: var(--bs-emphasis-color);
padding: 0.5rem;
border: none;
background: none;
cursor: pointer;
}
.sidebar-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.sidebar-overlay.show { display: block; }
/* Responsive */
@@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
z-index: 1000;
}
.sidebar.show {
transform: translateX(0);
}
.main-content {
margin-left: 0;
}
.content-area {
padding: 1rem;
}
.top-navbar {
padding: 0.75rem 1rem;
}
.page-title {
font-size: 1.25rem;
}
/* Typography Optimization */
body {
font-size: 0.9375rem;
}
h1 {
font-size: 1.75rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
h4 {
font-size: 1.125rem;
}
h5 {
font-size: 1rem;
}
p, .form-label, .text-muted {
line-height: 1.6;
}
/* User Menu Optimization */
.user-menu {
gap: 0.5rem;
}
.user-menu + div .badge {
display: none;
}
.user-avatar,
.user-avatar-img {
width: 36px;
height: 36px;
}
}
@@media (max-width: 576px) {
/* Stats Cards - 2 columns on phone */
.stat-card .fs-2 {
font-size: 1.5rem !important;
}
.stat-card .small {
font-size: 0.75rem !important;
}
.stat-card i {
font-size: 1.25rem !important;
}
.install-app-btn .install-label {
display: none;
}
}
/* Loading States */
.spinner-border-sm {
width: 1rem;
height: 1rem;
}
/* Contextual help icons */
.help-icon {
color: var(--bs-secondary-color);
opacity: 0.6;
text-decoration: none;
font-size: .85em;
vertical-align: middle;
margin-left: .25rem;
cursor: pointer;
}
.help-icon:hover, .help-icon:focus { opacity: 1; color: var(--bs-primary); outline: none; }
/* Alerts */
.alert {
border-radius: 0.5rem;
padding: 1rem 1.25rem;
}
/* ⌘K search affordance */
.sidebar-search-wrap {
display: flex; align-items: center; gap: .45rem;
margin: .75rem 1rem .25rem;
padding: .4rem .7rem;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: var(--radius, 6px);
width: calc(100% - 2rem);
transition: border-color 0.15s, background 0.15s;
position: relative;
}
.sidebar-search-wrap:focus-within {
border-color: var(--pcl-ember, #d97706);
background: rgba(255,255,255,0.09);
}
.sidebar-search-icon {
font-size: .8rem;
color: rgba(255,255,255,0.35);
flex-shrink: 0;
pointer-events: none;
}
.sidebar-search-input {
flex: 1; min-width: 0;
background: transparent; border: none; outline: none;
color: rgba(255,255,255,0.85);
font-size: .8rem;
font-family: var(--font-ui,'Inter',sans-serif);
}
.sidebar-search-input::placeholder { color: rgba(255,255,255,0.35); }
.sidebar-search-input::-webkit-search-cancel-button { display: none; }
.sidebar-search-kbd {
margin-left: auto; flex-shrink: 0;
font-family: var(--font-mono,'IBM Plex Mono',ui-monospace,monospace);
font-size: .62rem;
color: rgba(255,255,255,0.3);
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 3px;
padding: 1px 5px;
transition: opacity 0.15s;
pointer-events: none;
}
.sidebar-search-wrap:focus-within .sidebar-search-kbd { opacity: 0; }
.sidebar-search-results {
display: none;
position: absolute;
left: 1rem; right: 1rem;
z-index: 1100;
background: #1e1e20;
border: 1px solid rgba(255,255,255,0.15);
border-radius: var(--radius, 6px);
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
overflow: hidden;
max-height: 320px;
overflow-y: auto;
}
.sidebar-search-results.open { display: block; }
.sidebar-search-result {
display: flex; align-items: center; gap: .6rem;
padding: .55rem .9rem;
color: rgba(255,255,255,0.75);
font-size: .82rem;
text-decoration: none;
cursor: pointer;
border-left: 2px solid transparent;
}
.sidebar-search-result:hover,
.sidebar-search-result.focused {
background: rgba(255,255,255,0.07);
color: #fff;
border-left-color: var(--pcl-ember, #d97706);
}
.sidebar-search-result i { font-size: 1rem; width: 1.25rem; flex-shrink: 0; color: rgba(255,255,255,0.45); }
.sidebar-search-result.focused i { color: var(--pcl-ember, #d97706); }
.sidebar-search-no-results {
padding: .75rem .9rem;
font-size: .8rem;
color: rgba(255,255,255,0.35);
text-align: center;
}
/* Sidebar themes &mdash; hardcoded solids, NOT token-referenced, so they never flip with surface */
:root,
[data-sidebar="ink"],
[data-sidebar="ocean"],
[data-sidebar="slate"],
[data-sidebar="midnight"] { --sidebar-bg: #1A1A1C; } /* graphite &mdash; default */
[data-sidebar="navy"] { --sidebar-bg: #0D1B2E; } /* deep navy */
[data-sidebar="forest"],
[data-sidebar="emerald"] { --sidebar-bg: #0D2116; } /* deep forest */
[data-sidebar="purple"],
[data-sidebar="indigo"] { --sidebar-bg: #1A0D2E; } /* deep plum */
[data-sidebar="crimson"],
[data-sidebar="rose"] { --sidebar-bg: #1C0A0E; } /* deep crimson */
[data-sidebar="teal"],
[data-sidebar="amber"] { --sidebar-bg: #0A1F1E; } /* deep teal */
[data-sidebar="paper"] { --sidebar-bg: #FFFFFF; }
.sidebar { background: var(--sidebar-bg) !important; }
.nav-link.active { border-left-color: var(--pcl-ember, #d97706) !important; }
.nav-link:hover { border-left-color: var(--pcl-ember, #d97706) !important; }
/* Profile photo avatar */
.user-avatar-img { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; cursor: pointer; }
/* Theme-aware overrides &mdash; make Bootstrap light-only utilities respond to data-bs-theme */
.bg-white { background-color: var(--bs-body-bg) !important; }
.bg-light { background-color: var(--bs-tertiary-bg) !important; color: var(--bs-body-color) !important; }
.card-footer { background-color: var(--bs-body-bg) !important; border-color: var(--bs-border-color) !important; }
.table-light { --bs-table-bg: var(--bs-tertiary-bg); --bs-table-color: var(--bs-body-color); --bs-table-border-color: var(--bs-border-color); }
/* Table contextual row colors use hardcoded light values in Bootstrap &mdash; make them theme-aware */
[data-bs-theme="dark"] tr.table-warning > td,
[data-bs-theme="dark"] tr.table-warning > th {
background-color: rgba(255, 193, 7, 0.15) !important;
box-shadow: none !important;
color: var(--bs-body-color) !important;
}
[data-bs-theme="dark"] tr.table-danger > td,
[data-bs-theme="dark"] tr.table-danger > th {
background-color: rgba(220, 53, 69, 0.15) !important;
box-shadow: none !important;
color: var(--bs-body-color) !important;
}
[data-bs-theme="dark"] tr.table-success > td,
[data-bs-theme="dark"] tr.table-success > th {
background-color: rgba(25, 135, 84, 0.15) !important;
box-shadow: none !important;
color: var(--bs-body-color) !important;
}
[data-bs-theme="dark"] tr.table-info > td,
[data-bs-theme="dark"] tr.table-info > th {
background-color: rgba(13, 202, 240, 0.12) !important;
box-shadow: none !important;
color: var(--bs-body-color) !important;
}
.input-group-text { background-color: var(--bs-tertiary-bg) !important; border-color: var(--bs-border-color) !important; color: var(--bs-secondary-color) !important; }
.form-control, .form-select { background-color: var(--bs-body-bg); color: var(--bs-body-color); border: 1.5px solid #9ca3af; }
.form-control:focus, .form-select:focus { background-color: var(--bs-body-bg); color: var(--bs-body-color); }
.form-control:disabled, .form-select:disabled { background-color: var(--bs-secondary-bg); }
.modal-content { background-color: var(--bs-body-bg); color: var(--bs-body-color); }
.list-group-item { background-color: var(--bs-body-bg); color: var(--bs-body-color); border-color: var(--bs-border-color); }
.nav-tabs .nav-link { color: var(--bs-secondary-color); }
.nav-tabs .nav-link.active { background-color: var(--bs-body-bg); color: var(--bs-body-color); border-color: var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg); }
.breadcrumb { background-color: transparent; }
/* Dropdowns follow theme */
.dropdown-menu {
background-color: var(--bs-body-bg);
border-color: var(--bs-border-color);
z-index: 99999;
}
.dropdown-item {
color: var(--bs-body-color);
}
.dropdown-item:hover {
background-color: var(--bs-tertiary-bg);
color: var(--bs-emphasis-color);
}
.dropdown-divider {
border-color: var(--bs-border-color);
}
/* Powder combo-box dropdown (item wizard step 3) */
.powder-combo-dropdown { background: #fff; border: 1px solid #dee2e6; color: #212529; }
[data-bs-theme="dark"] .powder-combo-dropdown { background: var(--bs-body-bg) !important; border-color: var(--bs-border-color) !important; color: var(--bs-body-color) !important; }
[data-bs-theme="dark"] .powder-opt { color: var(--bs-body-color) !important; }
/* Tom Select &mdash; dark mode
The bootstrap5 theme hardcodes color:#343a40 on .ts-dropdown which
cascades to every option row making them near-invisible on dark bg. */
[data-bs-theme="dark"] .ts-control {
color: var(--bs-body-color) !important;
background-color: var(--bs-body-bg) !important;
border-color: var(--bs-border-color) !important;
}
[data-bs-theme="dark"] .ts-control input {
color: var(--bs-body-color) !important;
}
[data-bs-theme="dark"] .ts-control input::placeholder {
color: var(--bs-secondary-color) !important;
}
[data-bs-theme="dark"] .ts-control .item {
color: var(--bs-body-color) !important;
}
[data-bs-theme="dark"] .ts-dropdown {
color: var(--bs-body-color) !important;
background-color: var(--bs-body-bg) !important;
border-color: var(--bs-border-color) !important;
}
[data-bs-theme="dark"] .ts-dropdown .option,
[data-bs-theme="dark"] .ts-dropdown .no-results,
[data-bs-theme="dark"] .ts-dropdown .optgroup-header,
[data-bs-theme="dark"] .ts-dropdown .create {
color: var(--bs-body-color) !important;
background-color: var(--bs-body-bg) !important;
}
[data-bs-theme="dark"] .ts-dropdown .option:hover,
[data-bs-theme="dark"] .ts-dropdown .option.active {
background-color: var(--bs-primary) !important;
color: #fff !important;
}
</style>
<!-- Toastr CSS -->
<link rel="stylesheet" href="~/lib/toastr/toastr.min.css" />
@* Page-specific styles *@
@await RenderSectionAsync("Styles", required: false)
</head>
<body style="padding-top: var(--env-banner-height);" data-controller="@_navController.ToLower()">
<script>
(function(){var s='@_serverNavMode',p=localStorage.getItem('pcl-nav-mode')||'ops';document.documentElement.dataset.navMode=s==='fin'?'fin':p;})();
</script>
@if (_isNonProd)
{
<div style="position:fixed;top:0;left:0;width:100%;height:var(--env-banner-height);z-index:2000;
background:@_envColor;color:#fff;
display:flex;align-items:center;justify-content:center;gap:0.5rem;
font-size:0.78rem;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;">
<i class="bi bi-exclamation-triangle-fill"></i>
@_envName Environment &mdash; Not Production
<i class="bi bi-exclamation-triangle-fill"></i>
</div>
}
@* Hidden containers for TempData messages (read by toast-notifications.js) *@
@if (TempData["Success"] != null)
{
<div id="tempdata-success-message" style="display:none;">@TempData["Success"]</div>
}
@if (TempData["Error"] != null)
{
<div id="tempdata-error-message" style="display:none;">@TempData["Error"]</div>
}
@* Permanent variants &mdash; displayed with no auto-dismiss timeout *@
@if (TempData["SuccessPermanent"] != null)
{
<div id="tempdata-success-permanent-message" style="display:none;">@TempData["SuccessPermanent"]</div>
}
@if (TempData["ErrorPermanent"] != null)
{
<div id="tempdata-error-permanent-message" style="display:none;">@TempData["ErrorPermanent"]</div>
}
@if (TempData["Warning"] != null)
{
<div id="tempdata-warning-message" style="display:none;">@TempData["Warning"]</div>
}
@if (TempData["WarningPermanent"] != null)
{
<div id="tempdata-warning-permanent-message" style="display:none;">@TempData["WarningPermanent"]</div>
}
@if (TempData["Info"] != null)
{
<div id="tempdata-info-message" style="display:none;">@TempData["Info"]</div>
}
@* Hidden container for ModelState errors (read by toast-notifications.js) *@
@if (!ViewData.ModelState.IsValid && ViewData.ModelState.ErrorCount > 0)
{
var errors = ViewData.ModelState.Values
.SelectMany(v => v.Errors)
.Select(e => e.ErrorMessage)
.Where(m => !string.IsNullOrEmpty(m))
.ToList();
if (errors.Any())
{
<div id="modelstate-errors" style="display:none;">@Json.Serialize(errors)</div>
}
}
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-header">
<a href="/" class="sidebar-brand">
@if (User.Identity?.IsAuthenticated == true)
{
<div class="d-flex flex-column align-items-center w-100">
@if (companyHasLogo)
{
<img src="@Url.Action("Logo", "CompanySettings")?v=@logoVersion" alt="Company Logo" class="company-logo" />
@if (!string.IsNullOrEmpty(companyName))
{
<span class="company-logo-name">@companyName</span>
}
}
else
{
<img src="/images/pcl-logo.png" alt="Powder Coating Logix" class="company-logo" />
@if (!string.IsNullOrEmpty(companyName))
{
<span class="company-logo-name">@companyName</span>
}
}
@if (isSuperAdmin)
{
<small class="brand-company brand-superadmin mt-2">
<i class="bi bi-shield-check"></i> Super Admin
</small>
}
</div>
}
else
{
<img src="/images/pcl-logo.png" alt="Powder Coating Logix" class="company-logo" />
}
</a>
</div>
<nav class="sidebar-nav">
@if (User.Identity?.IsAuthenticated == true)
{
@* ⌘K scaffold &mdash; wire to command palette later *@
<div class="sidebar-search-wrap" style="position:relative">
<i class="bi bi-search sidebar-search-icon"></i>
<input type="search" class="sidebar-search-input" id="cmdKInput"
placeholder="Quick search&hellip;" autocomplete="off"
title="Search (⌘K / Ctrl+K)" />
<span class="sidebar-search-kbd">⌘K</span>
<div class="sidebar-search-results" id="cmdKResults"
style="top:calc(100% + 6px)"></div>
</div>
}
@if (User.Identity?.IsAuthenticated == true)
{
var _companyRole = User.FindFirst("CompanyRole")?.Value;
var _isAdminOrManager = _companyRole == "CompanyAdmin" || _companyRole == "Manager" || User.IsInRole("SuperAdmin");
var hasCustomers = User.HasClaim("Permission", "ManageCustomers") || User.IsInRole("SuperAdmin");
var hasQuotes = User.HasClaim("Permission", "CreateQuotes") || User.IsInRole("SuperAdmin");
var hasInvoices = _isAdminOrManager || User.HasClaim("Permission", "ManageInvoices") || User.HasClaim("Permission", "ManageJobs");
var hasJobs = User.HasClaim("Permission", "ManageJobs") || User.IsInRole("SuperAdmin");
var hasCalendar = User.HasClaim("Permission", "ManageCalendar") || User.HasClaim("Permission", "ViewCalendar") || User.IsInRole("SuperAdmin");
var hasProducts = User.HasClaim("Permission", "ManageProducts") || User.HasClaim("Permission", "ViewProducts") || User.IsInRole("SuperAdmin");
var hasInventory = User.HasClaim("Permission", "ManageInventory") || User.IsInRole("SuperAdmin");
var hasVendors = User.HasClaim("Permission", "ManageVendors") || User.IsInRole("SuperAdmin");
var hasEquipment = User.HasClaim("Permission", "ManageEquipment") || User.IsInRole("SuperAdmin");
var hasMaintenance = User.HasClaim("Permission", "ManageMaintenance") || User.IsInRole("SuperAdmin");
var hasFinance = _isAdminOrManager || User.HasClaim("Permission", "ManageFinance");
var hasReports = _isAdminOrManager || User.HasClaim("Permission", "ViewReports");
var showOperations = hasCustomers || hasQuotes || hasInvoices || hasJobs || hasCalendar;
var showInventorySection = hasInventory || hasVendors;
var showEquipmentSection = hasEquipment || hasMaintenance;
<a asp-controller="Dashboard" asp-action="Index" class="nav-link" data-nav="both">
<i class="bi bi-house-door"></i>
<span>Dashboard</span>
</a>
@if (!isPlatformAdmin)
{
<div class="nav-mode-strip" role="tablist" aria-label="Navigation mode">
<button class="nav-mode-btn" data-mode="ops" role="tab" aria-selected="true" title="Operations">
<i class="bi bi-tools"></i>
<span>Operations</span>
</button>
<button class="nav-mode-btn" data-mode="fin" role="tab" aria-selected="false" title="Finance">
<i class="bi bi-journal-bookmark"></i>
<span>Finance</span>
</button>
</div>
@* ── Operations ───────────────────────────────────────────── *@
@if (showOperations)
{
<div class="nav-section-title" data-nav="ops">Operations</div>
}
@if (hasCustomers)
{
<a asp-controller="Customers" asp-action="Index" class="nav-link" data-nav="ops">
<i class="bi bi-people"></i>
<span>Customers</span>
</a>
}
@if (hasQuotes)
{
<a asp-controller="Quotes" asp-action="Index" class="nav-link" data-nav="ops">
<i class="bi bi-file-text"></i>
<span>Quotes</span>
</a>
}
@if (hasJobs)
{
<a asp-controller="Jobs" asp-action="Index" class="nav-link" data-nav="ops">
<i class="bi bi-briefcase"></i>
<span>Jobs</span>
</a>
}
@if (hasInvoices)
{
<a asp-controller="Invoices" asp-action="Index" class="nav-link" data-nav="ops">
<i class="bi bi-receipt"></i>
<span>Invoices</span>
</a>
}
@if (hasCalendar)
{
<a asp-controller="Appointments" asp-action="Calendar" asp-route-view="month" class="nav-link" data-nav="ops">
<i class="bi bi-calendar-event"></i>
<span>Appointments</span>
</a>
}
@if (hasJobs)
{
<a asp-controller="JobsPriority" asp-action="Index" class="nav-link" data-nav="ops">
<i class="bi bi-clipboard2-check"></i>
<span>Daily Board</span>
</a>
}
@if (hasJobs)
{
<a asp-controller="Kiosk" asp-action="Intakes" class="nav-link" data-nav="ops">
<i class="bi bi-tablet"></i>
<span>Intake Sessions</span>
</a>
}
@* ── Billing & Payments ───────────────────────────────────── *@
@if (hasInvoices)
{
var _allowOnlinePayments = Context.Items["AllowOnlinePayments"] as bool? ?? false;
<div class="nav-section-title" data-nav="ops">Billing &amp; Payments</div>
@if (_allowOnlinePayments)
{
<a asp-controller="Invoices" asp-action="OnlinePayments" class="nav-link" data-nav="ops">
<i class="bi bi-credit-card"></i>
<span>Online Payments</span>
</a>
}
<a asp-controller="CreditMemos" asp-action="Index" class="nav-link" data-nav="ops">
<i class="bi bi-journal-minus"></i>
<span>Credit Memos</span>
</a>
<a asp-controller="GiftCertificates" asp-action="Index" class="nav-link" data-nav="ops">
<i class="bi bi-gift"></i>
<span>Gift Certificates</span>
</a>
}
@* ── Inventory & Purchasing ───────────────────────────────── *@
@if (hasProducts || showInventorySection)
{
<div class="nav-section-title" data-nav="ops">Inventory &amp; Purchasing</div>
}
@if (hasProducts)
{
<a asp-controller="CatalogItems" asp-action="Index" class="nav-link" data-nav="ops">
<i class="bi bi-book"></i>
<span>Product Catalog</span>
</a>
}
@if (hasInventory)
{
<a asp-controller="Inventory" asp-action="Index" class="nav-link" data-nav="ops">
<i class="bi bi-box-seam"></i>
<span>Inventory</span>
</a>
}
@if (hasVendors)
{
<a asp-controller="Vendors" asp-action="Index" class="nav-link" data-nav="ops">
<i class="bi bi-truck"></i>
<span>Vendors</span>
</a>
<a asp-controller="PurchaseOrders" asp-action="Index" class="nav-link" data-nav="ops">
<i class="bi bi-cart-check"></i>
<span>Purchase Orders</span>
</a>
}
@* ── Finance ──────────────────────────────────────────────── *@
@if (hasFinance)
{
var _allowAccounting = Context.Items["AllowAccounting"] as bool? ?? false;
if (_allowAccounting)
{
<div class="nav-section-title" data-nav="fin">Finance</div>
<a asp-controller="Bills" asp-action="Index" class="nav-link" data-nav="fin">
<i class="bi bi-receipt-cutoff"></i>
<span>Bills / Expenses</span>
</a>
<a asp-controller="Vendors" asp-action="Index" class="nav-link" data-nav="fin">
<i class="bi bi-truck"></i>
<span>Vendors</span>
</a>
<a asp-controller="Accounts" asp-action="Index" class="nav-link" data-nav="fin">
<i class="bi bi-journal-bookmark"></i>
<span>Chart of Accounts</span>
</a>
<a asp-controller="JournalEntries" asp-action="Index" class="nav-link" data-nav="fin">
<i class="bi bi-journal-text"></i>
<span>Journal Entries</span>
</a>
<a asp-controller="VendorCredits" asp-action="Index" class="nav-link" data-nav="fin">
<i class="bi bi-arrow-return-left"></i>
<span>Vendor Credits</span>
</a>
<a asp-controller="BankReconciliations" asp-action="Index" class="nav-link" data-nav="fin">
<i class="bi bi-bank2"></i>
<span>Bank Reconciliation</span>
</a>
<a asp-controller="FixedAssets" asp-action="Index" class="nav-link" data-nav="fin">
<i class="bi bi-building-gear"></i>
<span>Fixed Assets</span>
</a>
<a asp-controller="Budgets" asp-action="Index" class="nav-link" data-nav="fin">
<i class="bi bi-pie-chart"></i>
<span>Budgets</span>
</a>
<a asp-controller="Accounts" asp-action="YearEndClose" class="nav-link" data-nav="fin">
<i class="bi bi-calendar-check"></i>
<span>Year-End Close</span>
</a>
<a asp-controller="RecurringTemplates" asp-action="Index" class="nav-link" data-nav="fin">
<i class="bi bi-arrow-repeat"></i>
<span>Recurring Transactions</span>
</a>
if (hasReports)
{
<a asp-controller="AccountingExport" asp-action="Index" class="nav-link" data-nav="fin">
<i class="bi bi-box-arrow-up"></i>
<span>Accounting Export</span>
</a>
}
}
}
@* ── Shop Floor ───────────────────────────────────────────── *@
<div class="nav-section-title" data-nav="ops">Shop Floor</div>
@if (hasEquipment)
{
<a asp-controller="Equipment" asp-action="Index" class="nav-link" data-nav="ops">
<i class="bi bi-gear"></i>
<span>Equipment</span>
</a>
}
@if (hasMaintenance)
{
<a asp-controller="Maintenance" asp-action="Index" class="nav-link" data-nav="ops">
<i class="bi bi-tools"></i>
<span>Maintenance</span>
</a>
}
@* ── Reports & Templates ──────────────────────────────────── *@
@if (hasReports || hasJobs)
{
@if (hasReports)
{
<a asp-controller="Reports" asp-action="Landing" class="nav-link" data-nav="both">
<i class="bi bi-bar-chart-line"></i>
<span>Reports</span>
</a>
}
}
} @* end !isPlatformAdmin company sections *@
@* Multi-tenancy: SuperAdmin Platform Management (hidden while impersonating) *@
@if (User.IsInRole("SuperAdmin") && !isImpersonating)
{
<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>Platform Overview</span>
</a>
<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="PlatformAdmin" asp-action="PeopleActivity" class="nav-link">
<i class="bi bi-people"></i>
<span>People &amp; Activity</span>
</a>
<a asp-controller="UserActivity" asp-action="Online" class="nav-link">
<i class="bi bi-broadcast-pin"></i>
<span>Online Now</span>
@{ var _onlineCount = OnlineUserTracker.GetActiveCount(); }
@if (_onlineCount > 0)
{
<span class="badge bg-success ms-auto">@_onlineCount</span>
}
</a>
<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="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">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="PowderCatalog" asp-action="Index" class="nav-link">
<i class="bi bi-palette2"></i>
<span>Powder Catalog</span>
</a>
<a asp-controller="ManufacturerLookupPatterns" asp-action="Index" class="nav-link">
<i class="bi bi-link-45deg"></i>
<span>Manufacturer Lookup Patterns</span>
</a>
}
}
</nav>
</div>
<!-- Main Content -->
<div class="main-content">
<!-- Subscription Warning Banner (CompanyAdmins only) -->
@{
var subscriptionDaysRemaining = Context.Items["SubscriptionDaysRemaining"] as int?;
var subscriptionPlanValue = Context.Items["SubscriptionPlan"] as int?;
var bannerIsCompanyAdmin = User.FindFirst("CompanyRole")?.Value == "CompanyAdmin"
|| User.IsInRole("SuperAdmin");
}
@if (subscriptionDaysRemaining != null && bannerIsCompanyAdmin)
{
var daysAbs = Math.Abs(subscriptionDaysRemaining.Value);
var isGracePeriod = subscriptionDaysRemaining.Value < 0;
<div class="alert alert-permanent @(isGracePeriod ? "alert-danger" : "alert-warning") alert-dismissible mb-0 rounded-0 border-0 d-flex align-items-center gap-3" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0"></i>
<span>
@if (isGracePeriod)
{
<strong>Subscription expired @daysAbs day@(daysAbs == 1 ? "" : "s") ago.</strong>
<text> You are in the grace period. Renew now to avoid losing access.</text>
}
else
{
<strong>Your subscription expires in @daysAbs day@(daysAbs == 1 ? "" : "s").</strong>
<text> Renew now to ensure uninterrupted access.</text>
}
</span>
@if (subscriptionPlanValue.HasValue)
{
<form method="post" action="/Billing/Checkout" class="flex-shrink-0">
@Html.AntiForgeryToken()
<input type="hidden" name="plan" value="@subscriptionPlanValue.Value" />
<button type="submit" class="btn btn-sm fw-semibold @(isGracePeriod ? "btn-danger" : "btn-warning")">
<i class="bi bi-arrow-repeat me-1"></i>Renew Now
</button>
</form>
}
<a asp-controller="Billing" asp-action="Index" class="btn btn-sm btn-outline-secondary flex-shrink-0">
View Details
</a>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@{
var _impersonatingName = Context.Session.GetString("ImpersonatingCompanyName");
}
@if (_impersonatingName != null)
{
<div style="background:#ffc107;color:#000;padding:7px 16px;text-align:center;font-weight:500;font-size:.9rem;z-index:9999;position:relative;">
<i class="bi bi-eye-fill me-2"></i>
<strong>Impersonating:</strong> @_impersonatingName &nbsp;&mdash;&nbsp;
All data is scoped to this company.
<form method="post" asp-controller="Companies" asp-action="StopImpersonating" class="d-inline ms-2">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-dark py-0">
<i class="bi bi-x-circle me-1"></i>Stop Impersonating
</button>
</form>
</div>
}
<!-- Top Navigation Bar -->
<div class="top-navbar">
<div class="d-flex align-items-center gap-3">
<button class="btn btn-link d-lg-none hamburger-btn me-3"
id="sidebarToggle"
aria-label="Toggle sidebar">
<i class="bi bi-list" style="font-size: 1.5rem;"></i>
</button>
<div class="d-flex align-items-center gap-2" style="min-width:0;overflow:hidden;">
@if (ViewData["PageIcon"] != null)
{
<i class="bi @ViewData["PageIcon"]" style="font-size:1.25rem;color:var(--bs-secondary-color);flex-shrink:0;"></i>
}
<h1 class="page-title mb-0">@ViewData["Title"]</h1>
@if (ViewData["PageHelpContent"] != null)
{
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="bottom" data-bs-trigger="focus"
data-bs-title="@ViewData["PageHelpTitle"]"
data-bs-content="@ViewData["PageHelpContent"]">
<i class="bi bi-question-circle"></i>
</a>
}
</div>
@* Multi-tenancy: Display current company *@
@if (User.Identity?.IsAuthenticated == true)
{
var companyName = User.FindFirst("CompanyName")?.Value;
if (!string.IsNullOrEmpty(companyName))
{
<span class="badge bg-primary">
<i class="bi bi-building me-1"></i>@companyName
</span>
}
}
</div>
<div class="user-menu">
<button type="button" class="btn btn-outline-primary btn-sm install-app-btn d-none"
id="installAppBtn" aria-label="Install app">
<i class="bi bi-download"></i>
<span class="install-label">Install App</span>
</button>
<!-- Theme toggle -->
<button type="button" class="pcl-theme-toggle"
data-theme-toggle aria-pressed="false" aria-label="Toggle dark mode">
<i class="bi bi-moon"></i>
</button>
@if (User.Identity?.IsAuthenticated == true)
{
<!-- Notification Bell -->
<div class="dropdown">
<button class="bell-btn" id="notifBellBtn" data-bs-toggle="dropdown" aria-expanded="false" title="Notifications">
<i class="bi bi-bell-fill"></i>
<span class="bell-badge d-none" id="notifBadge">0</span>
</button>
<div class="dropdown-menu dropdown-menu-end notif-dropdown" id="notifDropdown">
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
<span class="fw-semibold">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted" id="markAllReadBtn" onclick="notifBell.markAllRead()">Mark all read</button>
</div>
<div id="notifList">
<div class="text-center text-muted py-4 small">
<i class="bi bi-bell-slash fs-3 d-block mb-2 opacity-25"></i>
No new notifications
</div>
</div>
<div class="notif-dropdown-footer">
<a href="/InAppNotifications" class="small text-decoration-none">View all notifications</a>
</div>
</div>
</div>
}
@if (User.Identity?.IsAuthenticated == true)
{
var gearCompanyRole = User.FindFirst("CompanyRole")?.Value;
var gearIsSuperAdmin = User.IsInRole("SuperAdmin");
var gearIsAdmin = gearCompanyRole == "CompanyAdmin" || gearIsSuperAdmin;
if (!isPlatformAdmin && (gearIsAdmin || isImpersonating))
{
<div class="dropdown">
<button class="gear-btn" data-bs-toggle="dropdown" aria-expanded="false" title="Settings">
<i class="bi bi-gear-fill"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><h6 class="dropdown-header">Settings</h6></li>
<li><a class="dropdown-item" asp-controller="CompanySettings" asp-action="Index"><i class="bi bi-building me-2"></i>Company Settings</a></li>
@if (gearIsAdmin)
{
<li><a class="dropdown-item" asp-controller="CompanyUsers" asp-action="Index"><i class="bi bi-people-fill me-2"></i>Manage Users</a></li>
<li><a class="dropdown-item" asp-controller="PricingTiers" asp-action="Index"><i class="bi bi-tags me-2"></i>Pricing Tiers</a></li>
<li><a class="dropdown-item" asp-controller="TaxRates" asp-action="Index"><i class="bi bi-percent me-2"></i>Tax Rates</a></li>
<li><a class="dropdown-item" asp-controller="Kiosk" asp-action="Activate"><i class="bi bi-tablet me-2"></i>Kiosk Setup</a></li>
}
<li><hr class="dropdown-divider"></li>
@if (gearIsAdmin)
{
<li><a class="dropdown-item" asp-controller="Billing" asp-action="Index"><i class="bi bi-credit-card me-2"></i>Billing</a></li>
<li><a class="dropdown-item" asp-controller="Tools" asp-action="Index"><i class="bi bi-wrench-adjustable me-2"></i>Tools</a></li>
<li><hr class="dropdown-divider"></li>
}
<li><a class="dropdown-item" asp-controller="NotificationLogs" asp-action="Index"><i class="bi bi-bell me-2"></i>Email &amp; SMS Log</a></li>
<li><a class="dropdown-item" asp-controller="BugReport" asp-action="Submit"><i class="bi bi-bug me-2"></i>Report a Bug</a></li>
</ul>
</div>
}
<div class="dropdown">
@if (hasProfilePic)
{
<img src="@Url.Action("Photo", "Profile")" class="user-avatar-img" data-bs-toggle="dropdown" aria-expanded="false" alt="Profile" />
}
else
{
<div class="user-avatar" data-bs-toggle="dropdown" aria-expanded="false">
@(User.Identity.Name?.Substring(0, 1).ToUpper() ?? "U")
</div>
}
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" asp-controller="Profile" asp-action="Index"><i class="bi bi-person me-2"></i>Profile</a></li>
<li><a class="dropdown-item" asp-controller="Passkey" asp-action="Manage"><i class="bi bi-fingerprint me-2"></i>Passkeys &amp; Biometrics</a></li>
<li><a class="dropdown-item" asp-controller="TwoFactorSetup" asp-action="Index"><i class="bi bi-shield-lock me-2"></i>Two-Factor Auth</a></li>
<li><a class="dropdown-item" asp-controller="ReleaseNotes" asp-action="Index"><i class="bi bi-rocket-takeoff me-2"></i>What's New</a></li>
<li><a class="dropdown-item" asp-controller="Help" asp-action="Index"><i class="bi bi-question-circle me-2"></i>Help</a></li>
<li><a class="dropdown-item" asp-controller="Contact" asp-action="Index"><i class="bi bi-envelope me-2"></i>Contact Us</a></li>
<li><a class="dropdown-item" href="https://www.facebook.com/share/g/19CtDWf61N/" target="_blank" rel="noopener noreferrer"><i class="bi bi-facebook me-2"></i>Community Group</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<form asp-area="Identity" asp-page="/Account/Logout" method="post" class="d-inline">
<button type="submit" class="dropdown-item">
<i class="bi bi-box-arrow-right me-2"></i>Logout
</button>
</form>
</li>
</ul>
</div>
}
else
{
<a asp-area="Identity" asp-page="/Account/Login" class="btn btn-primary">
<i class="bi bi-box-arrow-in-right me-2"></i>Login
</a>
}
</div>
</div>
<!-- Content Area -->
<div class="content-area">
@RenderBody()
</div>
<!-- Page Footer -->
@{
var _appVersion = System.Reflection.Assembly
.GetEntryAssembly()?
.GetName().Version;
var _versionString = _appVersion != null
? $"v{_appVersion.Major}.{_appVersion.Minor}.{_appVersion.Build}"
: "v2.1.0";
}
<div class="page-footer" style="flex-wrap:wrap;gap:.25rem .5rem;">
<a href="http://www.powdercoatinglogix.com" target="_blank" rel="noopener noreferrer" class="page-footer-link me-2">
<img src="/images/pcl-logo.png" alt="Powder Coating Logix" class="page-footer-logo" />
<span>Powered by Powder Coating Logix &middot; @_versionString</span>
</a>
<span class="text-muted d-none d-md-inline">&middot;</span>
<a asp-controller="Home" asp-action="TermsOfService" class="page-footer-link" style="font-size:.7rem">Terms</a>
<span class="text-muted">&middot;</span>
<a asp-controller="Home" asp-action="Privacy" class="page-footer-link" style="font-size:.7rem">Privacy</a>
<span class="text-muted">&middot;</span>
<a asp-controller="Home" asp-action="Privacy" asp-fragment="p-ccpa" class="page-footer-link" style="font-size:.7rem">CA Privacy</a>
<span class="text-muted">&middot;</span>
<a asp-controller="Home" asp-action="ServiceLevelAgreement" class="page-footer-link" style="font-size:.7rem">SLA</a>
<span class="text-muted">&middot;</span>
<a asp-controller="Home" asp-action="Security" class="page-footer-link" style="font-size:.7rem">Security</a>
<span class="text-muted">&middot;</span>
<a asp-controller="Home" asp-action="DataProcessingAddendum" class="page-footer-link" style="font-size:.7rem">DPA</a>
<span class="text-muted">&middot;</span>
<a asp-controller="Home" asp-action="Accessibility" class="page-footer-link" style="font-size:.7rem">Accessibility</a>
<span class="text-muted">&middot;</span>
<a href="https://www.facebook.com/share/g/19CtDWf61N/" target="_blank" rel="noopener noreferrer" class="page-footer-link" style="font-size:.7rem"><i class="bi bi-facebook me-1"></i>Community</a>
</div>
</div>
<!-- Cookie Consent Banner -->
<div id="cookieConsent" class="cookie-consent-bar" role="dialog" aria-label="Cookie notice" aria-live="polite">
<span>
We use essential cookies to keep you logged in and the site working.
See our <a asp-controller="Home" asp-action="Privacy" style="color:inherit;text-decoration:underline">Privacy Policy</a>.
</span>
<button id="cookieConsentBtn" class="btn btn-sm btn-light ms-3 flex-shrink-0">Got it</button>
</div>
<style>
.cookie-consent-bar {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 2000;
background: #212529;
color: #f8f9fa;
padding: .65rem 1.25rem;
font-size: .85rem;
align-items: center;
justify-content: center;
gap: .5rem;
flex-wrap: wrap;
box-shadow: 0 -2px 8px rgba(0,0,0,.25);
}
.cookie-consent-bar.visible {
display: flex;
}
</style>
<script>
(function () {
var bar = document.getElementById('cookieConsent');
var btn = document.getElementById('cookieConsentBtn');
if (!bar || !btn) return;
if (!localStorage.getItem('pcl_cookies_ok')) {
bar.classList.add('visible');
}
btn.addEventListener('click', function () {
localStorage.setItem('pcl_cookies_ok', '1');
bar.classList.remove('visible');
});
})();
</script>
<!-- jQuery -->
<script src="~/lib/jquery/jquery.min.js"></script>
<!-- Bootstrap 5 JS Bundle -->
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<!-- Nav mode switcher (Operations / Finance) -->
<script src="~/js/nav-mode.js" asp-append-version="true"></script>
<!-- Custom Scripts -->
<script>
// Add active class to current nav item
document.addEventListener('DOMContentLoaded', function() {
const currentPath = window.location.pathname;
const navLinks = document.querySelectorAll('.nav-link');
navLinks.forEach(link => {
const href = link.getAttribute('href') || '';
const linkPath = href.split('?')[0].split('#')[0];
if (!linkPath || linkPath === '/') return;
// Exact segment match: /Foo or /Foo/... but not /FooBar
if (currentPath === linkPath || currentPath.startsWith(linkPath + '/')) {
link.classList.add('active');
}
});
});
// Initialize Bootstrap popovers globally
document.querySelectorAll('[data-bs-toggle="popover"]').forEach(el => {
new bootstrap.Popover(el, { html: true, trigger: 'focus' });
});
// Auto-dismiss alerts after 5 seconds
setTimeout(function() {
const alerts = document.querySelectorAll('.alert:not(.alert-permanent)');
alerts.forEach(alert => {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
});
}, 5000);
// Hamburger menu functionality
const sidebarToggle = document.getElementById('sidebarToggle');
const sidebar = document.querySelector('.sidebar');
let sidebarOverlay = null;
if (sidebarToggle) {
sidebarToggle.addEventListener('click', function() {
if (!sidebarOverlay) {
sidebarOverlay = document.createElement('div');
sidebarOverlay.className = 'sidebar-overlay';
document.body.appendChild(sidebarOverlay);
sidebarOverlay.addEventListener('click', closeSidebar);
}
sidebar.classList.toggle('show');
sidebarOverlay.classList.toggle('show');
document.body.style.overflow = sidebar.classList.contains('show') ? 'hidden' : '';
});
}
function closeSidebar() {
sidebar?.classList.remove('show');
sidebarOverlay?.classList.remove('show');
document.body.style.overflow = '';
}
// Auto-close sidebar on mobile when clicking nav links
if (window.innerWidth <= 768) {
document.querySelectorAll('.sidebar .nav-link').forEach(link => {
link.addEventListener('click', closeSidebar);
});
}
// ⌘K / Ctrl+K scaffold
document.addEventListener('keydown', function (e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
document.getElementById('cmdKInput')?.focus();
}
});
// ⌘K live search
(function () {
const input = document.getElementById('cmdKInput');
const panel = document.getElementById('cmdKResults');
if (!input || !panel) return;
// Build nav index from sidebar links (skip section titles)
const navItems = [];
document.querySelectorAll('.sidebar .nav-link').forEach(link => {
const icon = link.querySelector('i')?.className || '';
const text = (link.querySelector('span')?.textContent || link.textContent).trim();
const href = link.getAttribute('href') || '';
if (text && href) navItems.push({ icon, text, href });
});
let focusedIdx = -1;
function render(query) {
const q = query.trim().toLowerCase();
panel.innerHTML = '';
focusedIdx = -1;
if (!q) { panel.classList.remove('open'); return; }
const matches = navItems.filter(item =>
item.text.toLowerCase().includes(q)
);
if (!matches.length) {
panel.innerHTML = '<div class="sidebar-search-no-results">No matches</div>';
panel.classList.add('open');
return;
}
matches.slice(0, 8).forEach((item, i) => {
const a = document.createElement('a');
a.href = item.href;
a.className = 'sidebar-search-result';
a.dataset.idx = i;
a.innerHTML = `<i class="${item.icon}"></i><span>${item.text}</span>`;
a.addEventListener('mouseenter', () => setFocus(i));
panel.appendChild(a);
});
panel.classList.add('open');
}
function setFocus(idx) {
panel.querySelectorAll('.sidebar-search-result').forEach((el, i) => {
el.classList.toggle('focused', i === idx);
});
focusedIdx = idx;
}
function navigate() {
const focused = panel.querySelector('.sidebar-search-result.focused');
if (focused) { window.location.href = focused.href; return; }
// Enter with no selection → go to first result
const first = panel.querySelector('.sidebar-search-result');
if (first) window.location.href = first.href;
}
input.addEventListener('input', () => render(input.value));
input.addEventListener('keydown', e => {
const results = [...panel.querySelectorAll('.sidebar-search-result')];
if (e.key === 'ArrowDown') {
e.preventDefault();
setFocus(Math.min(focusedIdx + 1, results.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setFocus(Math.max(focusedIdx - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
navigate();
} else if (e.key === 'Escape') {
input.value = '';
panel.classList.remove('open');
input.blur();
}
});
// Close on outside click
document.addEventListener('click', e => {
if (!input.contains(e.target) && !panel.contains(e.target)) {
panel.classList.remove('open');
}
});
})();
</script>
<!-- Toastr JS -->
<script src="~/lib/toastr/toastr.min.js"></script>
<!-- Toast Notification System -->
<script src="~/js/toast-notifications.js"></script>
<!-- Tag chip input widget -->
<script src="~/js/tag-input.js" asp-append-version="true"></script>
@if (User.Identity?.IsAuthenticated == true)
{
<!-- Notification Detail Modal -->
<div class="modal fade" id="notifDetailModal" tabindex="-1" aria-labelledby="notifDetailTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header gap-2">
<i class="bi fs-5 flex-shrink-0" id="notifDetailIcon"></i>
<h5 class="modal-title fw-semibold mb-0" id="notifDetailTitle"></h5>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="mb-2" id="notifDetailMessage" style="white-space:pre-wrap;"></p>
<small class="text-muted" id="notifDetailTime"></small>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-danger" id="notifDetailDismissBtn">Dismiss</button>
<a href="#" class="btn btn-primary d-none" id="notifDetailViewBtn"><i class="bi bi-box-arrow-up-right me-1"></i>View</a>
</div>
</div>
</div>
</div>
<!-- SignalR real-time notifications -->
<script src="~/lib/microsoft/signalr/dist/browser/signalr.min.js" asp-append-version="true"></script>
<script>
(function () {
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/notifications")
.withAutomaticReconnect()
.build();
connection.on("JobBoardUpdated", function (data) {
const jobLink = `/Jobs/Details/${data.jobId}`;
const icons = {
StatusChanged: 'bi-arrow-repeat',
PriorityChanged: 'bi-flag',
WorkerChanged: 'bi-person-check',
Created: 'bi-plus-circle',
Deleted: 'bi-trash',
Completed: 'bi-check-circle'
};
const icon = icons[data.eventType] || 'bi-pencil';
const isDelete = data.eventType === 'Deleted';
const msg = isDelete
? `<strong>${data.jobNumber}</strong> &mdash; ${data.detail} <small class="d-block text-muted">by ${data.changedBy}</small>`
: `<a href="${jobLink}" class="text-white fw-bold">${data.jobNumber}</a> &mdash; ${data.detail} <small class="d-block text-muted">by ${data.changedBy}</small>`;
toastr.info(msg, `<i class="bi ${icon} me-1"></i> Job Updated`, {
timeOut: 8000, extendedTimeOut: 2000, closeButton: true, enableHtml: true
});
// If user is on the jobs list or board view, show a refresh banner
const path = window.location.pathname.toLowerCase();
const isJobBoard = path === '/jobs' || path.startsWith('/jobs/board');
if (isJobBoard && !window._jobBoardRefreshPending) {
window._jobBoardRefreshPending = true;
const banner = document.createElement('div');
banner.id = 'job-board-refresh-banner';
banner.className = 'alert alert-info alert-permanent d-flex align-items-center gap-2 mb-0 rounded-0 border-0 border-bottom';
banner.style.cssText = 'position:sticky;top:0;z-index:1050;cursor:pointer;';
banner.innerHTML = '<i class="bi bi-arrow-clockwise"></i> The job board has been updated. <strong>Click to refresh.</strong>';
banner.onclick = () => window.location.reload();
document.body.prepend(banner);
}
});
connection.on("QuoteActedByCustomer", function (data) {
const quoteLink = `/Quotes/Details/${data.quoteId}`;
if (data.approved) {
const convertLink = data.isProspect
? ` <a href="/Quotes/ConvertToCustomer/${data.quoteId}" class="text-white fw-bold">Convert to customer →</a>`
: '';
toastr.success(
`<strong>${data.customerName}</strong> approved quote <a href="${quoteLink}" class="text-white fw-bold">${data.quoteNumber}</a>.${convertLink}`,
'Quote Approved',
{ timeOut: 10000, extendedTimeOut: 3000, closeButton: true, enableHtml: true }
);
} else {
toastr.warning(
`<strong>${data.customerName}</strong> declined quote <a href="${quoteLink}" class="text-white fw-bold">${data.quoteNumber}</a>.<br><small>${data.declineReason ?? ''}</small>`,
'Quote Declined',
{ timeOut: 15000, extendedTimeOut: 5000, closeButton: true, enableHtml: true }
);
}
});
connection.on("NewInAppNotification", function (data) {
notifBell.addItem(data);
notifBell.incrementBadge();
const icons = {
QuoteApproved: { icon: 'bi-check-circle-fill', cls: 'success', title: 'Quote Approved' },
QuoteDeclined: { icon: 'bi-x-circle-fill', cls: 'danger', title: 'Quote Declined' },
InvoicePaid: { icon: 'bi-cash-coin', cls: 'primary', title: 'Payment Received' },
KioskConsent: { icon: 'bi-chat-fill', cls: 'success', title: 'SMS Consent' }
};
const t = icons[data.notificationType] || { icon: 'bi-bell', cls: 'info', title: 'Notification' };
toastr[t.cls === 'danger' ? 'warning' : t.cls === 'primary' ? 'info' : 'success'](
data.link ? `<a href="${data.link}" class="text-white">${data.message}</a>` : data.message,
`<i class="bi ${t.icon} me-1"></i>${t.title}`,
{ timeOut: 10000, extendedTimeOut: 3000, closeButton: true, enableHtml: true }
);
if (data.notificationType === 'KioskConsent' && data.customerId) {
const path = window.location.pathname.toLowerCase();
if (path === `/customers/details/${data.customerId}`) {
window.updateCustomerSmsStatus?.();
}
}
});
connection.start().catch(err => console.warn('SignalR connection failed:', err));
})();
// ── Notification Bell ─────────────────────────────────────────
const notifBell = (() => {
const badge = document.getElementById('notifBadge');
const list = document.getElementById('notifList');
const btn = document.getElementById('notifBellBtn');
const typeIcon = {
QuoteApproved: { icon: 'bi-check-circle-fill', cls: 'approved' },
QuoteDeclined: { icon: 'bi-x-circle-fill', cls: 'declined' },
InvoicePaid: { icon: 'bi-cash-coin', cls: 'paid' },
Announcement: { icon: 'bi-megaphone-fill', cls: '' },
NewCompany: { icon: 'bi-building-add', cls: '' },
SubscriptionCanceled: { icon: 'bi-x-octagon-fill', cls: 'declined' },
PaymentFailed: { icon: 'bi-exclamation-triangle-fill', cls: 'declined' }
};
let activeItem = null; // { n, el } currently shown in the detail modal
function fmtNotifTime(val) {
if (!val) return '';
try {
const d = new Date(val);
if (isNaN(d)) return val;
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true });
} catch { return val; }
}
function renderItem(n) {
const ti = typeIcon[n.notificationType] || { icon: 'bi-bell', cls: '' };
const div = document.createElement('div');
div.className = n.isRead ? 'notif-item read' : 'notif-item unread';
div.dataset.id = n.id;
div.dataset.isRead = n.isRead ? '1' : '0';
const dot = n.isRead ? '' : '<div class="notif-unread-dot"></div>';
div.innerHTML = `
<div class="d-flex gap-2 align-items-start">
<i class="bi ${ti.icon} notif-type-icon ${ti.cls} mt-1 flex-shrink-0"></i>
<div class="flex-grow-1 overflow-hidden">
<div class="notif-title">${n.title}</div>
<div class="notif-msg text-truncate">${n.message}</div>
<div class="notif-time">${fmtNotifTime(n.createdAt)}</div>
</div>
${dot}
</div>`;
div.addEventListener('click', (e) => {
e.stopPropagation();
openDetail(n, div);
});
return div;
}
function openDetail(n, el) {
activeItem = { n, el };
const ti = typeIcon[n.notificationType] || { icon: 'bi-bell', cls: '' };
document.getElementById('notifDetailIcon').className = `bi ${ti.icon} fs-5 flex-shrink-0`;
document.getElementById('notifDetailTitle').textContent = n.title;
document.getElementById('notifDetailMessage').textContent = n.message;
document.getElementById('notifDetailTime').textContent = fmtNotifTime(n.createdAt);
const viewBtn = document.getElementById('notifDetailViewBtn');
if (n.link) {
viewBtn.href = n.link;
viewBtn.classList.remove('d-none');
} else {
viewBtn.href = '#';
viewBtn.classList.add('d-none');
}
// Mark as read immediately &mdash; decrements the badge but keeps item in the list
markRead(n.id, el);
// Close the bell dropdown before opening the modal
const dropdown = bootstrap.Dropdown.getInstance(document.getElementById('notifBellBtn'));
dropdown?.hide();
bootstrap.Modal.getOrCreateInstance(document.getElementById('notifDetailModal')).show();
}
function updateBadge(count) {
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('d-none');
btn?.classList.add('text-warning');
} else {
badge.classList.add('d-none');
btn?.classList.remove('text-warning');
}
}
async function markRead(id, el) {
if (el?.dataset.isRead === '1') return; // already read
el?.classList.remove('unread');
el?.classList.add('read');
el?.querySelector('.notif-unread-dot')?.remove();
if (el) el.dataset.isRead = '1';
try {
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
await fetch(`/InAppNotifications/MarkRead/${id}`, {
method: 'POST', headers: { 'RequestVerificationToken': tok }
});
const remaining = list.querySelectorAll('.notif-item.unread').length;
updateBadge(remaining);
} catch {}
}
function showEmpty() {
if (!list.querySelector('.notif-item')) {
list.innerHTML = `<div class="text-center text-muted py-4 small"><i class="bi bi-bell-slash fs-3 d-block mb-2 opacity-25"></i>No notifications yet</div>`;
}
}
async function load() {
try {
const r = await fetch('/InAppNotifications/Recent');
const data = await r.json();
if (!list) return;
list.innerHTML = '';
if (data.items.length === 0) { showEmpty(); updateBadge(0); return; }
data.items.forEach(n => list.appendChild(renderItem(n)));
updateBadge(data.count);
} catch {}
}
async function markAllRead() {
try {
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
await fetch('/InAppNotifications/MarkAllRead', {
method: 'POST', headers: { 'RequestVerificationToken': tok }
});
list.querySelectorAll('.notif-item.unread').forEach(el => {
el.classList.remove('unread');
el.classList.add('read');
el.querySelector('.notif-unread-dot')?.remove();
el.dataset.isRead = '1';
});
updateBadge(0);
} catch {}
}
function addItem(n) {
if (!list) return;
const empty = list.querySelector('.bi-bell-slash');
if (empty) list.innerHTML = '';
n.isRead = false;
list.prepend(renderItem(n));
}
function incrementBadge() {
const current = parseInt(badge?.textContent) || 0;
updateBadge(current + 1);
}
// Modal button wiring
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('notifDetailModal');
// Dismiss &mdash; remove from list and close (already marked read when modal opened)
document.getElementById('notifDetailDismissBtn')?.addEventListener('click', () => {
if (activeItem) {
activeItem.el.remove();
showEmpty();
activeItem = null;
}
bootstrap.Modal.getInstance(modal)?.hide();
});
// View &mdash; remove from list then navigate (already marked read when modal opened)
document.getElementById('notifDetailViewBtn')?.addEventListener('click', () => {
if (activeItem) {
activeItem.el.remove();
showEmpty();
activeItem = null;
}
// navigation follows naturally via href
});
});
// Load on page ready and refresh when dropdown is opened
document.addEventListener('DOMContentLoaded', () => {
load();
btn?.addEventListener('show.bs.dropdown', load);
});
// Fallback poll every 60 s in case SignalR misses a push
setInterval(load, 60_000);
return { addItem, incrementBadge, markAllRead, openDetail, markRead };
})();
</script>
}
@await RenderSectionAsync("Scripts", required: false)
<script>
// Persist sidebar scroll position across page navigations
(function () {
var nav = document.querySelector('.sidebar-nav');
if (!nav) return;
var KEY = 'sidebarScrollTop';
// Restore saved position immediately (before paint to avoid visible jump)
var saved = sessionStorage.getItem(KEY);
if (saved) nav.scrollTop = parseInt(saved, 10);
// Save position whenever a nav link is clicked
nav.addEventListener('click', function (e) {
var link = e.target.closest('a.nav-link');
if (link) sessionStorage.setItem(KEY, nav.scrollTop);
});
})();
</script>
@if (User.Identity?.IsAuthenticated == true)
{
@* @await Html.PartialAsync("_AiQuickQuoteWidget") *@
@await Html.PartialAsync("_AiHelpWidget")
<script src="~/js/passkey.js"></script>
}
<!-- ── Quick-Add Modal (reusable inline form host) ─────────────────────── -->
<div class="modal fade" id="quickAddModal" tabindex="-1" aria-labelledby="quickAddModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header">
<h5 class="modal-title fw-semibold" id="quickAddModalLabel">Add New</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="quickAddModalBody">
<div class="d-flex align-items-center justify-content-center py-5">
<div class="spinner-border text-primary me-3" role="status"></div>
<span class="text-muted">Loading&hellip;</span>
</div>
</div>
<div class="modal-footer d-none" id="quickAddModalErrors">
<div class="alert alert-danger alert-permanent w-100 mb-0 small" id="quickAddModalErrorList"></div>
</div>
</div>
</div>
</div>
<script src="~/js/quick-add.js" asp-append-version="true"></script>
<script src="~/js/theme-toggle.js" asp-append-version="true"></script>
<script src="~/js/install-app.js" asp-append-version="true"></script>
<div class="modal fade" id="installHelpModal" tabindex="-1" aria-labelledby="installHelpModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header">
<h5 class="modal-title fw-semibold" id="installHelpModalLabel">Install App</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="d-flex gap-3">
<div class="fs-3 text-primary">
<i class="bi bi-phone"></i>
</div>
<div>
<p class="mb-2" id="installHelpMessage"></p>
<div class="small text-muted" id="installHelpSteps"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Got It</button>
</div>
</div>
</div>
</div>
<!-- ── Global Confirm Dialog ──────────────────────────────────────────── -->
<div class="modal fade" id="globalConfirmModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" style="max-width:420px;">
<div class="modal-content border-0 shadow-lg">
<div class="modal-body text-center px-4 pt-4 pb-3">
<div id="globalConfirmIcon" class="mb-3" style="font-size:2.5rem; line-height:1;">⚠️</div>
<h5 class="fw-semibold mb-2" id="globalConfirmTitle">Are you sure?</h5>
<p class="text-muted mb-0" id="globalConfirmMessage"></p>
</div>
<div class="modal-footer border-0 justify-content-center gap-2 pb-4">
<button type="button" class="btn btn-outline-secondary px-4" id="globalConfirmCancel">Cancel</button>
<button type="button" class="btn btn-danger px-4" id="globalConfirmOk">Confirm</button>
</div>
</div>
</div>
</div>
<script>
(function () {
const modalEl = document.getElementById('globalConfirmModal');
const bsModal = new bootstrap.Modal(modalEl, { backdrop: 'static', keyboard: false });
let _resolve = null;
document.getElementById('globalConfirmOk').addEventListener('click', function () {
bsModal.hide(); if (_resolve) _resolve(true);
});
document.getElementById('globalConfirmCancel').addEventListener('click', function () {
bsModal.hide(); if (_resolve) _resolve(false);
});
modalEl.addEventListener('hidden.bs.modal', function () {
if (_resolve) { _resolve(false); _resolve = null; }
});
window.showConfirm = function (message, title, okLabel, okClass) {
document.getElementById('globalConfirmMessage').textContent = message || '';
document.getElementById('globalConfirmTitle').textContent = title || 'Are you sure?';
const okBtn = document.getElementById('globalConfirmOk');
okBtn.textContent = okLabel || 'Confirm';
okBtn.className = 'btn px-4 ' + (okClass || 'btn-danger');
return new Promise(function (resolve) {
_resolve = resolve;
bsModal.show();
});
};
// Intercept forms with data-confirm attribute
document.addEventListener('submit', async function (e) {
const form = e.target;
const msg = form.getAttribute('data-confirm');
if (!msg) return;
if (form._confirmed) { form._confirmed = false; return; }
e.preventDefault();
const ok = await window.showConfirm(msg, form.getAttribute('data-confirm-title') || 'Are you sure?',
form.getAttribute('data-confirm-ok') || 'Confirm',
form.getAttribute('data-confirm-class') || 'btn-danger');
if (ok) { form._confirmed = true; form.requestSubmit(); }
}, true);
})();
</script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
</script>
</body>
</html>