e2f9e9ae4f
- Standardize modal dismiss/cancel buttons to btn-outline-secondary across 70+ views - Remove btn-sm from page-level Create and Back buttons (Index + Detail pages) - Fix Edit buttons on Details pages: btn-secondary -> btn-warning - Fix form Cancel/Back links: btn-secondary -> btn-outline-secondary - Add 10 CSS patches to site.css for mobile/tablet responsiveness: top-navbar overflow prevention, page-header flex-wrap at 575px, table action button min-height override, notification dropdown width cap, tablet content padding Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2345 lines
105 KiB
Plaintext
2345 lines
105 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 — 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 — 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 — 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 — 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 — 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 — 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 — 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 — 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 — 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…" 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 hasShopWorkers = _isAdminOrManager || User.HasClaim("Permission", "ManageShopWorkers");
|
|
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>
|
|
}
|
|
|
|
@* ── Billing & Payments ───────────────────────────────────── *@
|
|
@if (hasInvoices)
|
|
{
|
|
var _allowOnlinePayments = Context.Items["AllowOnlinePayments"] as bool? ?? false;
|
|
<div class="nav-section-title" data-nav="ops">Billing & 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 & 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">Tenants & 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 & 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 & 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 & 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 & Quota</span>
|
|
</a>
|
|
|
|
<div class="nav-section-title">Logs & 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">
|
|
<i class="bi bi-shield-check"></i>
|
|
<span>Audit Log</span>
|
|
</a>
|
|
<a asp-controller="BannedIps" asp-action="Index" class="nav-link">
|
|
<i class="bi bi-slash-circle"></i>
|
|
<span>Banned IPs</span>
|
|
</a>
|
|
<a asp-controller="SystemLogs" asp-action="Index" class="nav-link">
|
|
<i class="bi bi-database-exclamation"></i>
|
|
<span>System Logs</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>
|
|
}
|
|
<a asp-controller="SystemInfo" asp-action="Index" class="nav-link">
|
|
<i class="bi bi-cpu"></i>
|
|
<span>System Info</span>
|
|
</a>
|
|
|
|
<div class="nav-section-title">Data & 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>
|
|
<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>
|
|
</a>
|
|
<a asp-controller="ManufacturerLookupPatterns" asp-action="Index" class="nav-link">
|
|
<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>
|
|
}
|
|
|
|
}
|
|
</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 —
|
|
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><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>Notification 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 & 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 · @_versionString</span>
|
|
</a>
|
|
<span class="text-muted d-none d-md-inline">·</span>
|
|
<a asp-controller="Home" asp-action="TermsOfService" class="page-footer-link" style="font-size:.7rem">Terms</a>
|
|
<span class="text-muted">·</span>
|
|
<a asp-controller="Home" asp-action="Privacy" class="page-footer-link" style="font-size:.7rem">Privacy</a>
|
|
<span class="text-muted">·</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">·</span>
|
|
<a asp-controller="Home" asp-action="ServiceLevelAgreement" class="page-footer-link" style="font-size:.7rem">SLA</a>
|
|
<span class="text-muted">·</span>
|
|
<a asp-controller="Home" asp-action="Security" class="page-footer-link" style="font-size:.7rem">Security</a>
|
|
<span class="text-muted">·</span>
|
|
<a asp-controller="Home" asp-action="DataProcessingAddendum" class="page-footer-link" style="font-size:.7rem">DPA</a>
|
|
<span class="text-muted">·</span>
|
|
<a asp-controller="Home" asp-action="Accessibility" class="page-footer-link" style="font-size:.7rem">Accessibility</a>
|
|
<span class="text-muted">·</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> — ${data.detail} <small class="d-block text-muted">by ${data.changedBy}</small>`
|
|
: `<a href="${jobLink}" class="text-white fw-bold">${data.jobNumber}</a> — ${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' }
|
|
};
|
|
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 }
|
|
);
|
|
});
|
|
|
|
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 — 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 — 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 — 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
|
|
document.addEventListener('DOMContentLoaded', load);
|
|
|
|
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…</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>
|