Files
sap-erp/public/sidebar.js
T
John 69b4e68baf
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s
first commit
2026-09-23 17:31:02 +05:30

838 lines
50 KiB
JavaScript

// sidebar.js — Shared sidebar navigation for all portal pages
(function(){
// Favicon (company logo) — set here once rather than in every page's
// <head>, since this script is already loaded on every page including the
// login screen. Runs before the auth check below so it applies even when
// logged out. Removes any pre-existing icon links first so this always wins.
(function setFavicon(){
document.querySelectorAll('link[rel~="icon"]').forEach(el=>el.remove());
const link=document.createElement('link');
link.rel='icon';link.type='image/png';link.href='/logo.png';
document.head.appendChild(link);
})();
// Session-only auth: login lives ONLY in sessionStorage (see auth-guard.js),
// never localStorage, so a new tab intentionally needs its own login.
const tok=sessionStorage.getItem('portal_token');
let userStr=null;try{userStr=sessionStorage.getItem('portal_user');}catch(_e){}
if(!tok||!userStr) return;
// sidebar.js renders straight into document.body (prepend below), completely
// independent of whatever #root shows — so on pages like index.html (which
// don't load auth-guard.js and branch login-vs-dashboard themselves), a
// stale-but-still-present EXPIRED token used to render a fully functional
// sidebar right alongside the login screen. Token presence alone isn't
// enough; check the JWT's own exp claim before rendering anything.
function jwtExpired(t){
try{
const payload=JSON.parse(atob(t.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')));
return !payload.exp || (Date.now()>=payload.exp*1000);
}catch(_e){ return true; }
}
if(jwtExpired(tok)){
try{sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';;}catch(_e){}
return;
}
// Refresh cached user (modules AND approval-step grants) from the server so
// admin-side access changes apply without forcing a logout — reload once if
// anything access-relevant actually changed.
//
// approvalSteps/modules/role are baked into the JWT ITSELF at login, and
// every server-side permission check (requireApprovalStep/requireWorkflowPerm
// in middleware/auth.js) decodes that token directly — no DB lookup. So it's
// not enough to update the cached portal_user for the UI; the bearer token
// used on every /api call must also be replaced, via POST /api/auth/refresh,
// or a newly granted Approval Step keeps failing server-side ("You are not
// assigned...") even after the sidebar/UI already shows it as granted.
(function refreshUserModules(){
// Never auto-refresh while impersonating — /auth/refresh would mint a
// plain token for the TARGET user without impersonatedBy, silently
// ending the impersonation (banner gone) while still leaving the
// session logged in AS that user instead of bouncing back to the real
// one. Impersonation sessions are short-lived by design; "Return to my
// account" is the only way out, not a background refresh.
let _cu=null; try{_cu=JSON.parse(userStr);}catch(_e){}
if(_cu&&_cu.impersonatedBy) return;
fetch('/api/auth/profile',{headers:{Authorization:'Bearer '+tok}})
.then(r=>r.json())
.then(d=>{
if(!d||!d.success||!d.user) return;
const fresh=d.user;
const cached=JSON.parse(userStr);
const same=JSON.stringify(fresh.modules||null)===JSON.stringify(cached.modules||null)
&& JSON.stringify(fresh.approvalSteps||null)===JSON.stringify(cached.approvalSteps||null)
&& JSON.stringify(fresh.allowedCompanies||null)===JSON.stringify(cached.allowedCompanies||null)
&& fresh.role===cached.role && fresh.active!==false
&& !!fresh.woVerifyOverride===!!cached.woVerifyOverride;
if(fresh.active===false){ try{sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';;}catch(_e){} location.replace('/'); return; }
if(!same){
// Mint a fresh token (carries the new approvalSteps/modules/role)
// BEFORE reloading, so the reloaded page's very first API call
// already authorizes correctly.
fetch('/api/auth/refresh',{method:'POST',headers:{Authorization:'Bearer '+tok}})
.then(r=>r.json())
.then(rd=>{
const merged=Object.assign({},cached,{modules:fresh.modules,approvalSteps:fresh.approvalSteps,allowedCompanies:fresh.allowedCompanies,role:fresh.role,fullName:fresh.fullName,name:fresh.fullName||cached.name,woVerifyOverride:fresh.woVerifyOverride});
try{
if(rd&&rd.success&&rd.token) sessionStorage.setItem('portal_token',rd.token);
sessionStorage.setItem('portal_user',JSON.stringify(merged));
document.cookie='portal_user='+encodeURIComponent(JSON.stringify(merged))+';path=/;SameSite=Lax';
if(rd&&rd.success&&rd.token) document.cookie='portal_token='+encodeURIComponent(rd.token)+';path=/;SameSite=Lax';
}catch(_e){}
if(!sessionStorage.getItem('portal_modules_reloaded')){
sessionStorage.setItem('portal_modules_reloaded','1');
location.reload();
}
})
.catch(()=>{});
} else {
sessionStorage.removeItem('portal_modules_reloaded');
}
})
.catch(()=>{});
})();
// Ensure lucide is loaded before building sidebar
function withLucide(cb){
if(window.lucide&&lucide.icons&&lucide.createElement){cb();return;}
var s=document.createElement('script');
s.src='/lucide.min.js?v=399';
s.onload=cb;
s.onerror=cb; // build even if lucide fails (icons will be blank)
document.head.appendChild(s);
}
function sbIco(name,size=15){
if(window.lucide&&lucide.icons&&lucide.createElement){
var p=name.replace(/(^[a-z]|-[a-z0-9])/g,function(m){return m.replace('-','').toUpperCase();});
var d=lucide.icons[p];
if(d){try{
var svg=lucide.createElement([d[0],Object.assign({},d[1],{width:size,height:size}),d[2]]);
svg.style.flexShrink='0';
return svg.outerHTML;
}catch(e){}}
}
return'';
}
withLucide(buildSidebar);
function buildSidebar(){
// Read the role up-front (the shared `user` variable is only declared further
// down — referencing it inside the LINKS initializer would TDZ-crash).
let _role=null;try{_role=(JSON.parse(sessionStorage.getItem('portal_user')||'null')||{}).role;}catch(_e){}
const LINKS=[
{href:'/',lucide:'layout-dashboard',label:'Home',module:'home'},
// Board Dashboard link is for NON-board-member users granted the module —
// board members skip it (they're auto-redirected there on login anyway).
...(_role!=='board_member'?[{href:'/board-dashboard',lucide:'trending-up',label:'Board Dashboard',module:'board-dashboard'}]:[]),
// Inventory
{type:'group',label:'Inventory',lucide:'boxes'},
{href:'/bom', lucide:'list-tree', label:'BOM', module:'bom', sub:true},
{href:'/items', lucide:'package-plus', label:'Create Item', module:'items', sub:true,exact:true},
{href:'/items?list=1', lucide:'list', label:'View Items', module:'items', sub:true,exact:true},
// Production
{type:'group',label:'Production',lucide:'factory'},
{href:'/production-dashboard',lucide:'gauge', label:'Dashboard', module:'production-dashboard', sub:true},
{href:'/production', lucide:'file-plus-2', label:'Production Order', module:'production-create', sub:true},
{href:'/issue-production', lucide:'package-open', label:'Issue', module:'production-issue', sub:true},
{href:'/receipt-production',lucide:'package-check', label:'Receipt', module:'production-receipt', sub:true},
{href:'/verify-work-order', lucide:'clipboard-check', label:'Verify Work Order',module:'work-order-verify', sub:true},
{href:'/verify-production', lucide:'clipboard-check', label:'Verify', module:'production-verify', sub:true},
{href:'/receipts-history', lucide:'history', label:'Receipts History', module:'production-receipt', sub:true},
{href:'/close-production', lucide:'circle-check-big',label:'Close', module:'production-close', sub:true},
{href:'/requirements', lucide:'clipboard-list', label:'Requirements', module:'production-requirements', sub:true},
{href:'/batch-issuance', lucide:'layers', label:'Batch Issuance', module:'production-batch-issuance', sub:true},
{href:'/work-order', lucide:'file-cog', label:'Work Order', module:'production-work-order', sub:true},
{href:'/man-power', lucide:'users', label:'Man Power', module:'man-power', sub:true},
{href:'/oee', lucide:'activity', label:'OEE', module:'oee', sub:true},
{href:'/ppc-report', lucide:'table-2', label:'PPC Report', module:'ppc-report', sub:true},
{href:'/pwo-source-audit', lucide:'shield-check', label:'PWO Source Report',module:'pwo-source-audit', sub:true},
{href:'/rejection-register',lucide:'clipboard-x', label:'Rejection Register',module:'rejection-register', sub:true},
{href:'/rejection-analytics',lucide:'bar-chart-3', label:'Rejection Analytics',module:'rejection-analytics', sub:true},
{href:'/production-planning',lucide:'calendar-clock', label:'Production Planning',module:'production-planning', sub:true},
// Purchase
{type:'group',label:'Purchase',lucide:'shopping-cart'},
{href:'/purchase-request', lucide:'file-text', label:'Purchase Request', module:'purchase-request', sub:true},
{href:'/purchase-quotation',lucide:'file-pen', label:'Quotation', module:'purchase-quotation', sub:true},
{href:'/purchase-order', lucide:'shopping-bag', label:'Purchase Order', module:'purchase-order', sub:true},
{href:'/grpo', lucide:'truck', label:'Goods Receipt PO', module:'purchase-grpo', sub:true},
// Approval
{type:'group',label:'Approvals',lucide:'badge-check'},
{href:'/approvals', lucide:'stamp', label:'External Approval',module:'approvals', sub:true},
{href:'/sap-approvals', lucide:'shield-check', label:'SAP Approval', module:'sap-approvals', sub:true},
// Forms
{type:'group',label:'Forms',lucide:'clipboard-pen'},
{href:'/vendor-register', lucide:'building-2', label:'Vendor', module:'vendors', sub:true},
{href:'/register', lucide:'user-plus', label:'Customer', module:'customers', sub:true},
// Documents
{href:'/documents', lucide:'folder-open', label:'Documents', module:'documents'},
// Projects
{type:'group',label:'Projects',lucide:'briefcase'},
{href:'/project-form?new=1', lucide:'folder-plus', label:'New Project', module:'projects-new', sub:true,exact:true},
{href:'/project-form?list=1',lucide:'folders', label:'View Projects', module:'projects-view', sub:true,exact:true},
{href:'/project-approvals', lucide:'clipboard-check',label:'Project Approvals',module:'projects-approvals', sub:true,exact:true},
// Finance
{type:'group',label:'Finance',lucide:'landmark'},
{href:'/costing-pl', lucide:'calendar-range',label:'Monthly Accounts', module:'finance-monthly', sub:true,exact:true},
{href:'/costing-comparison', lucide:'trending-up', label:'P&L Comparison', module:'finance-comparison', sub:true,exact:true},
{href:'/cost-sheet', lucide:'file-spreadsheet',label:'Cost Sheet', module:'finance-costsheet', sub:true,exact:true},
{href:'/balance-sheet', lucide:'scale', label:'Balance Sheet', module:'finance-balancesheet',sub:true,exact:true},
{href:'/cash-flow', lucide:'banknote', label:'Cash Flow', module:'finance-cashflow', sub:true,exact:true},
{href:'/salary', lucide:'hand-coins', label:'Salary', module:'salary', sub:true,exact:true},
// GSTR
{type:'group',label:'GST Reports',lucide:'receipt'},
{href:'/gstr1', lucide:'file-output', label:'GSTR-1', module:'gstr1', sub:true},
{href:'/gstr2', lucide:'file-input', label:'GSTR-2 Purchase', module:'gstr2', sub:true},
{href:'/itc04', lucide:'repeat', label:'ITC-04 Job Work', module:'itc04', sub:true},
// Masters
{type:'group',label:'Masters',lucide:'database'},
{href:'/business-master', lucide:'building', label:'Business Master', module:'business-master', sub:true},
// Reports
{type:'group',label:'Reports',lucide:'pie-chart'},
{href:'/reports', lucide:'bar-chart-3', label:'Reports', module:'reports', sub:true},
{href:'/general-ledger', lucide:'book-open', label:'General Ledger', module:'general-ledger', sub:true},
// Admin — the '/admin' link itself moved to the fixed bottom cluster
// (near Profile/Guide, always visible without scrolling — see sb-bottom
// below); these two stay in the regular scrollable list.
{href:'/item-group-classification', lucide:'layers-3', label:'Item Group Rules', module:'item-group-classification', adminOnly:true},
{href:'/audit-logs', lucide:'scroll-text', label:'Audit Logs', module:'audit'},
{href:'/mail-logs', lucide:'mail', label:'Mail Log', module:'mail_log'},
];
const path=window.location.pathname;
const fullPath=window.location.pathname+window.location.search;
let user=null;
try{const u=sessionStorage.getItem('portal_user');if(u)user=JSON.parse(u);}catch(_e){}
const isAdmin=user?.role==='admin'||user?.role==='sap_adder'; // full module bypass (SAP Adder kept its original admin-like visibility)
const isSubAdmin=user?.role==='system_admin'; // System Admin: sees ONLY granted modules (but may open admin-only pages he's been granted)
const userModules=user?.modules||null;
const themeLink=document.createElement('link');themeLink.rel='stylesheet';themeLink.href='/theme.css?v=3';document.head.appendChild(themeLink);
const oldNav=document.querySelector('nav');
if(oldNav)oldNav.remove();
const style=document.createElement('style');
style.textContent=`
/* ── Sidebar shell ── */
.sb{
position:fixed;left:0;top:0;bottom:0;
background:#ffffff;
border-right:1px solid #eef1f5;
box-shadow:1px 0 0 rgba(15,23,42,.02),2px 0 12px rgba(15,23,42,.03);
z-index:400;display:flex;flex-direction:column;
overflow-x:hidden;overflow-y:auto;
font-feature-settings:"tnum";
}
.sb::-webkit-scrollbar{width:0}
/* ── Logo ── */
.sb-logo{
padding:18px 16px 16px;
border-bottom:1px solid #f1f4f8;
flex-shrink:0;display:flex;align-items:center;gap:11px
}
.sb-logo-mark{
width:40px;height:40px;
display:flex;align-items:center;justify-content:center;
flex-shrink:0;
}
.sb-logo-mark img,.sb-logo-mark svg{width:100%;height:100%;object-fit:contain;display:block}
.sb-logo-text{font-family:'Space Grotesk',sans-serif;font-size:13.5px;font-weight:700;color:#0f172a;letter-spacing:.1px;line-height:1.25}
.sb-logo-text small{display:block;font-size:10px;font-weight:500;color:#94a3b8;letter-spacing:.3px;margin-top:2px;text-transform:uppercase}
/* ── Pending-actions bell ── */
.sb-bell-row{padding:8px 10px 0}
.sb-bell{
width:100%;display:flex;align-items:center;gap:9px;
padding:8px 10px;border-radius:9px;border:1px solid #eef1f5;background:#f8fafc;
color:#475569;font-size:12.5px;font-weight:600;font-family:'Space Grotesk',sans-serif;
cursor:pointer;transition:all .15s;position:relative;
}
.sb-bell:hover{background:#eff6ff;border-color:#bfdbfe;color:#1d4ed8}
.sb-bell svg{flex-shrink:0;color:#64748b}
.sb-bell:hover svg{color:#2563eb}
.sb-bell.has-pending svg{color:#d97706}
.sb-bell-badge{
margin-left:auto;background:#dc2626;color:#fff;font-size:10px;font-weight:800;
border-radius:10px;padding:1px 7px;line-height:1.5;flex-shrink:0;
}
.sb-bell-panel{
display:none;position:fixed;background:#fff;border:1px solid #e2e8f0;border-radius:12px;
box-shadow:0 16px 40px rgba(15,23,42,.16);z-index:900;width:320px;max-height:420px;
overflow-y:auto;font-family:'Space Grotesk',sans-serif;
}
.sb-bell-panel.show{display:block}
.sb-bp-hd{padding:12px 16px;border-bottom:1px solid #f1f4f8;font-size:12px;font-weight:700;color:#0f172a;position:sticky;top:0;background:#fff}
.sb-bp-empty{padding:28px 16px;text-align:center;font-size:12px;color:#94a3b8}
.sb-bp-item{display:block;padding:10px 16px;border-bottom:1px solid #f8fafc;text-decoration:none;transition:background .12s}
.sb-bp-item:hover{background:#f8fafc}
.sb-bp-item:last-child{border-bottom:none}
.sb-bp-mod{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#2563eb;margin-bottom:2px}
.sb-bp-title{font-size:12.5px;font-weight:700;color:#0f172a}
.sb-bp-detail{font-size:11px;color:#64748b;margin-top:2px;line-height:1.4}
/* ── Nav links ── */
.sb-links{flex:1;overflow-y:auto;overflow-x:hidden;padding:10px 10px}
.sb-links::-webkit-scrollbar{width:0}
.sb-link{
display:flex;align-items:center;gap:9px;
padding:8px 10px;
color:#64748b;
text-decoration:none;font-size:13px;font-weight:500;
white-space:nowrap;
border-radius:8px;
font-family:'Space Grotesk',sans-serif;
margin:1.5px 0;
transition:background .15s,color .15s;
}
.sb-link:hover{color:#0f172a;background:#f8fafc}
.sb-link.active{color:#1d4ed8;background:#eff6ff;font-weight:600}
.sb-link svg{color:#94a3b8;flex-shrink:0;transition:color .15s}
.sb-link:hover svg{color:#334155}
.sb-link.active svg{color:#2563eb}
.sb-link .sb-lbl{font-size:13px}
/* ── Section divider ── */
.sb-divider{height:1px;background:#f1f4f8;margin:8px 10px}
/* ── Group headers ── */
.sb-group{
display:flex;align-items:center;gap:8px;
padding:7px 10px;
color:#94a3af;
font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;
white-space:nowrap;margin-top:14px;
font-family:'Space Grotesk',sans-serif;
cursor:pointer;user-select:none;
border-radius:8px;
transition:all .15s;
}
.sb-group:hover{color:#475569;background:#f8fafc}
.sb-group.open{color:#1e293b}
.sb-group svg{flex-shrink:0;opacity:.85;color:#94a3b8}
.sb-chev{
margin-left:auto;font-size:11px;line-height:1;
transition:transform .2s ease;display:inline-block;
color:#cbd5e1;
}
.sb-group.open .sb-chev{transform:rotate(90deg);color:#2563eb}
/* ── Collapsible sub-group ── */
.sb-sub-group{overflow:hidden;max-height:0;transition:max-height .28s cubic-bezier(.4,0,.2,1)}
.sb-sub-group.open{max-height:600px}
.sb-sub{padding-left:24px!important;font-size:12.5px!important}
.sb-sub svg{color:#94a3b8!important}
.sb-sub.active svg{color:#2563eb!important}
.sb-sub .sb-lbl{font-size:12px!important}
/* ── Bottom section ── */
.sb-bottom{
padding:12px 10px;
border-top:1px solid #f1f4f8;
flex-shrink:0;
}
.sb-user{
display:flex;align-items:center;gap:10px;
padding:9px 10px;
background:#f8fafc;
border:1px solid #eef1f5;
border-radius:11px;
cursor:pointer;
transition:all .15s;
}
.sb-user svg{color:#cbd5e1;flex-shrink:0;transition:transform .15s}
.sb-user:hover svg{transform:translateX(2px);color:#94a3b8}
.sb-user:hover{background:#eff6ff;border-color:#bfdbfe}
.sb-avatar{
width:31px;height:31px;border-radius:9px;
background:linear-gradient(135deg,#2563eb 0%,#4338ca 100%);
box-shadow:0 2px 6px rgba(37,99,235,.22);
display:flex;align-items:center;justify-content:center;
font-size:12px;font-weight:700;color:#fff;flex-shrink:0;
}
.sb-user-info{min-width:0;flex:1}
.sb-role{display:block;font-size:9.5px;font-weight:700;color:#94a3b8;text-transform:uppercase;letter-spacing:.6px}
.sb-name{display:block;font-size:12.5px;font-weight:600;color:#0f172a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:1px}
.sb-logout{
width:100%;padding:7px 10px;border-radius:8px;margin-top:4px;
font-size:11.5px;font-weight:600;
border:none;
color:#94a3b8;background:transparent;
cursor:pointer;font-family:'Space Grotesk',sans-serif;
transition:all .15s;
text-align:left;display:flex;align-items:center;gap:7px;
}
.sb-logout:hover{background:#fef2f2;color:#dc2626}
.sb-logout svg{flex-shrink:0}
.sb-help{
width:100%;padding:7px 10px;border-radius:8px;margin-top:6px;
font-size:11.5px;font-weight:600;
border:none;text-decoration:none;
color:#94a3b8;background:transparent;
cursor:pointer;font-family:'Space Grotesk',sans-serif;
transition:all .15s;
text-align:left;display:flex;align-items:center;gap:7px;
}
.sb-help:hover{background:#eff6ff;color:#2563eb}
.sb-help svg{flex-shrink:0}
.sb-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:399;display:none;backdrop-filter:blur(2px)}
.sb-overlay.show{display:block}
/* ── Toggle button (desktop) ── */
.sb-toggle-btn{
background:none;border:none;cursor:pointer;
color:#9ca3af;font-size:16px;line-height:1;
padding:4px 5px;border-radius:6px;margin-left:auto;flex-shrink:0;
transition:color .15s,background .15s;
}
.sb-toggle-btn:hover{color:#374151;background:#f3f4f6}
@media(min-width:769px){
.sb{width:210px;transition:width .25s ease;overflow:hidden}
.sb .sb-lbl{display:inline}
.sb .sb-user,.sb .sb-logout,.sb .sb-help{display:flex}
.sb-logout{display:block!important}
body{margin-left:210px!important;transition:margin-left .25s ease}
.mob-bar{display:none!important}
.sb-overlay{display:none!important}
/* Collapsed = icon-only rail: links stay visible & clickable as icons,
only the text labels/chevrons hide. Hover tooltips come from the
title attributes set at render time. */
body.sb-hidden .sb{width:56px}
body.sb-hidden{margin-left:56px!important}
body.sb-hidden .sb-lbl,
body.sb-hidden .sb-chev,
body.sb-hidden .sb-logo-text,
body.sb-hidden .sb-logo-mark,
body.sb-hidden .sb-bell-panel,
body.sb-hidden .sb-user-info,
body.sb-hidden .sb-user>svg{display:none!important}
body.sb-hidden .sb-logo{justify-content:center;padding:12px 0}
body.sb-hidden .sb-toggle-btn{color:#6b7280;margin:0;font-size:18px;padding:6px;width:auto}
body.sb-hidden .sb-bell-row{display:flex;justify-content:center}
body.sb-hidden .sb-bell{justify-content:center;padding:8px}
body.sb-hidden .sb-links{padding:10px 8px}
body.sb-hidden .sb-link{justify-content:center;padding:9px 0;gap:0}
body.sb-hidden .sb-sub{padding-left:0!important}
body.sb-hidden .sb-group{justify-content:center;padding:8px 0;gap:0;margin-top:10px}
body.sb-hidden .sb-user{justify-content:center;padding:6px 0}
body.sb-hidden .sb-logout{display:flex!important;justify-content:center}
body.sb-hidden .sb-help{display:flex!important;justify-content:center}
}
@media(max-width:768px){
.sb{width:260px;transform:translateX(-100%);transition:transform .25s ease}
.sb.open{transform:translateX(0)}
.sb .sb-lbl{display:inline}
body{margin-left:0!important;padding-bottom:56px!important}
.mob-bar{position:fixed;bottom:0;left:0;right:0;height:56px;background:#fff;border-top:1px solid #e5e7eb;z-index:300;display:flex;align-items:center;justify-content:space-around;padding:0 4px;box-shadow:0 -2px 8px rgba(0,0,0,.04)}
.mob-bar a{display:flex;flex-direction:column;align-items:center;gap:2px;color:#6b7280;text-decoration:none;font-size:9px;font-weight:600;font-family:'Space Grotesk',sans-serif;padding:6px 4px;border-radius:8px;transition:all .12s;flex:1;text-align:center}
.mob-bar a .mb-ico{display:flex;align-items:center;justify-content:center}
.mob-bar a .mb-ico svg{color:#6b7280}
.mob-bar a.active{color:#2563eb;background:#eff6ff}
.mob-bar a.active .mb-ico svg{color:#2563eb}
.mob-bar .mb-more{color:#6b7280;cursor:pointer;border:none;background:none;font-family:'Space Grotesk',sans-serif;font-size:9px;font-weight:600;display:flex;flex-direction:column;align-items:center;gap:2px;padding:6px 4px;flex:1}
.mob-bar .mb-more .mb-ico{display:flex;align-items:center;justify-content:center}
.mob-bar .mb-more .mb-ico svg{color:#6b7280}
}
/* ── Impersonation banner + picker modal ── */
.sb-imp-banner{position:fixed;top:0;left:0;right:0;height:34px;z-index:600;background:#7c3aed;color:#fff;display:flex;align-items:center;justify-content:center;gap:10px;font-family:'Space Grotesk',sans-serif;font-size:12px;font-weight:600;padding:0 12px;box-shadow:0 2px 8px rgba(124,58,237,.3)}
.sb-imp-banner b{font-weight:800}
.sb-imp-banner button{background:#fff;color:#7c3aed;border:none;border-radius:6px;padding:4px 12px;font-size:11px;font-weight:700;cursor:pointer;font-family:inherit}
.sb-imp-banner button:hover{background:#f3e8ff}
body.sb-impersonating{padding-top:34px}
.sb-imp-modal-ov{display:none;position:fixed;inset:0;z-index:9998;background:rgba(15,23,42,.5);align-items:flex-start;justify-content:center;padding:60px 12px}
.sb-imp-modal-ov.show{display:flex}
.sb-imp-modal{width:100%;max-width:400px;background:#fff;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.25);overflow:hidden;font-family:'Space Grotesk',sans-serif}
.sb-imp-modal-hd{padding:14px 16px;border-bottom:1px solid #eef1f5;font-size:14px;font-weight:700;color:#1f2937;display:flex;align-items:center;gap:10px}
.sb-imp-modal-hd button{margin-left:auto;width:26px;height:26px;border-radius:7px;border:1px solid #eef1f5;background:none;color:#6b7280;cursor:pointer}
.sb-imp-modal-bd{padding:16px}
.sb-imp-modal-bd label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:#94a3b8;margin-bottom:5px;display:block}
.sb-imp-modal-bd input{width:100%;padding:8px 10px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:13px;font-family:inherit;outline:none;box-sizing:border-box}
.sb-imp-modal-bd input:focus{border-color:#7c3aed}
.sb-imp-modal-ft{display:flex;gap:8px;padding:12px 16px;border-top:1px solid #eef1f5;background:#f8fafc}
.sb-imp-modal-ft button{flex:1;padding:9px;border-radius:8px;border:none;font-size:12.5px;font-weight:700;cursor:pointer;font-family:inherit}
.sb-imp-cancel{background:#eef1f5;color:#475569}
.sb-imp-confirm{background:#7c3aed;color:#fff}
.sb-imp-confirm:disabled{opacity:.5;cursor:not-allowed}
`;
document.head.appendChild(style);
// ── Helpers ──────────────────────────────────────────────────────────────────
// Backward-compat: a user granted the OLD broad key (e.g. 'production',
// before it was split into per-page sub-modules) still gets every new
// sub-key under it, so nobody silently loses access when this ships.
const LEGACY_BROAD_MODULES={production:'production-',purchase:'purchase-',costing:'finance-',projects:'projects-'};
const hasModule=(m)=>{
if(isAdmin||m==='home')return true;
if(!userModules)return false; // no modules explicitly granted → no access (was: unchecked = full access)
if(userModules.includes(m))return true;
for(const broad in LEGACY_BROAD_MODULES){
if(userModules.includes(broad)&&m.indexOf(LEGACY_BROAD_MODULES[broad])===0)return true;
}
return false;
};
// Mirrors the same exception in auth-guard.js: a pure Store reviewer for
// Pre-PWO Store Review has no reason to hold the broad 'production-create'
// module (that would ALSO grant full Production Order creation) — holding
// either Pre-PWO approval step (any perm) is enough to show the sidebar
// link too, otherwise auth-guard.js lets them into the page but they'd
// have no way to find it.
const hasPrePwoStepAccess=(()=>{
if(isAdmin)return true;
const steps=Array.isArray(user?.approvalSteps)?user.approvalSteps:[];
const keys=['production_order:prepwo_share','production_order:prepwo_review'];
return steps.some(s=>{
if(typeof s==='string')return keys.includes(s);
return s&&keys.includes(s.step)&&Array.isArray(s.perms)&&s.perms.length>0;
});
})();
// Same idea for OEE: holding 'oee:entry' (any perm) is enough to see the
// sidebar link even without the broader 'oee' module.
const hasOeeStepAccess=(()=>{
if(isAdmin)return true;
const steps=Array.isArray(user?.approvalSteps)?user.approvalSteps:[];
return steps.some(s=>{
if(typeof s==='string')return s==='oee:entry';
return s&&s.step==='oee:entry'&&Array.isArray(s.perms)&&s.perms.length>0;
});
})();
const canSeeLink=(l)=>hasModule(l.module)
||(l.href==='/production'&&hasPrePwoStepAccess)
||(l.href==='/oee'&&hasOeeStepAccess);
// Prefix match must land on a real path boundary (path is exactly l.href,
// or continues with '/') — plain startsWith() would wrongly match e.g.
// '/production-dashboard' against the shorter '/production', marking both
// "Production Order" (/production) and "Dashboard" (/production-dashboard)
// active at once. `path` is window.location.pathname (no query string).
function isActive(l){
if(l.exact) return fullPath===l.href;
if(path===l.href) return true;
if(l.href==='/') return false;
return path.startsWith(l.href) && path[l.href.length]==='/';
}
// ── Filter links: groups only if at least one child is visible ────────────────
const filteredLinks=[];
for(let i=0;i<LINKS.length;i++){
const l=LINKS[i];
if(l.adminOnly&&!isAdmin&&!isSubAdmin) continue; // admin-only links: sub-admins still need the module granted (checked below)
if(l.type==='group'){
let hasChild=false;
for(let j=i+1;j<LINKS.length;j++){
const c=LINKS[j];
if(c.type==='group'||!c.sub) break;
if((!c.adminOnly||isAdmin||isSubAdmin)&&canSeeLink(c)){hasChild=true;break;}
}
if(hasChild) filteredLinks.push(l);
continue;
}
if(!canSeeLink(l)) continue;
filteredLinks.push(l);
}
// ── Build grouped structure ───────────────────────────────────────────────────
// Each entry is either a standalone item or {group, children[]}
const sections=[];
let cur=null;
for(const l of filteredLinks){
if(l.type==='group'){
cur={group:l,children:[]};
sections.push(cur);
} else if(cur&&l.sub){
cur.children.push(l);
} else {
cur=null;
sections.push({item:l});
}
}
// ── Render ────────────────────────────────────────────────────────────────────
const linksHtml=sections.map(s=>{
if(s.item){
const l=s.item;
const a=isActive(l);
return`<a href="${l.href}" class="sb-link${a?' active':''}" title="${l.label}">${sbIco(l.lucide||'circle',15)}<span class="sb-lbl">${l.label}</span></a>`;
}
const hasActiveChild=s.children.some(isActive);
const openCls=hasActiveChild?' open':'';
const childrenHtml=s.children.map(l=>{
const a=isActive(l);
return`<a href="${l.href}" class="sb-link sb-sub${a?' active':''}" title="${l.label}">${sbIco(l.lucide||'circle',13)}<span class="sb-lbl">${l.label}</span></a>`;
}).join('');
return`<div class="sb-group${openCls}" data-sb-group title="${s.group.label}">${sbIco(s.group.lucide||'folder',13)}<span class="sb-lbl">${s.group.label}</span><span class="sb-chev">&#8250;</span></div><div class="sb-sub-group${openCls}">${childrenHtml}</div>`;
}).join('');
// ── Build sidebar DOM ─────────────────────────────────────────────────────────
const sb=document.createElement('div');sb.className='sb';sb.id='sidebar';
const initials=((user?.name||user?.username||'?').trim().split(' ').map(w=>w[0]||'').join('').toUpperCase().slice(0,2))||'U';
const company=user?.company||'MIPL';
sb.innerHTML=`
<div class="sb-logo">
<div class="sb-logo-mark"><img class="sb-logo-img" src="/logo.png" alt="Logo" onerror="this.style.display='none';this.nextElementSibling.style.display='block'"/><svg class="sb-logo-svg" style="display:none" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-label="Logo"><g stroke-linejoin="round" stroke-linecap="round"><polygon points="66,16 78,16 90,84 78,84" fill="#7cc36f" stroke="#7cc36f" stroke-width="6"/><polygon points="42,22 54,22 66,84 54,84" fill="#23a06e" stroke="#23a06e" stroke-width="6"/><polygon points="26,54 6,86 44,86" fill="#1866a1" stroke="#1866a1" stroke-width="6"/></g></svg></div>
<div class="sb-logo-text">${company}<small>MITRA ERP Portal</small></div>
<button class="sb-toggle-btn" id="sb-toggle-btn" title="Collapse sidebar">&#9776;</button>
</div>
<div class="sb-bell-row">
<button class="sb-bell" id="sb-bell" title="Pending actions">${sbIco('bell',16)}<span class="sb-lbl">Pending Actions</span><span class="sb-bell-badge" id="sb-bell-badge" style="display:none">0</span></button>
</div>
<div class="sb-bell-panel" id="sb-bell-panel"></div>
<div class="sb-links">${linksHtml}</div>
<div class="sb-bottom">
${(isAdmin||(isSubAdmin&&hasModule('admin')))?`<a href="/admin" class="sb-help">${sbIco('settings',15)}<span class="sb-lbl">Admin</span></a>`:''}
${(!user?.impersonatedBy&&(isAdmin||user?.canImpersonate))?`<button class="sb-help" id="sb-impersonate" style="width:100%;text-align:left">${sbIco('user-cog',15)}<span class="sb-lbl">Impersonate</span></button>`:''}
<a href="/guide" class="sb-help">${sbIco('life-buoy',15)}<span class="sb-lbl">Guide / Help</span></a>
<a href="/profile" class="sb-user" style="text-decoration:none">
<div class="sb-avatar">${initials}</div>
<div class="sb-user-info">
<span class="sb-role">${({system_admin:'System Admin',board_member:'Board Member',sap_adder:'SAP Adder',sr_manager:'Sr. Manager',project_approver:'Project Approver'})[user?.role]||(user?.role||'').replace(/_/g,' ')}</span>
<span class="sb-name">${user?.name||user?.username||''}</span>
</div>
${sbIco('chevron-right',13)}
</a>
<button class="sb-logout" id="sb-logout" title="Log Out">${sbIco('log-out',13)}<span class="sb-lbl"> Log Out</span></button>
</div>`;
document.body.prepend(sb);
// ── Wire group toggle clicks ──────────────────────────────────────────────────
sb.querySelectorAll('[data-sb-group]').forEach(groupEl=>{
groupEl.addEventListener('click',()=>{
const subGroup=groupEl.nextElementSibling;
const opening=!groupEl.classList.contains('open');
groupEl.classList.toggle('open',opening);
subGroup.classList.toggle('open',opening);
});
});
// ── Mobile bottom bar ─────────────────────────────────────────────────────────
const mobLinks=[
{href:'/',lucide:'layout-dashboard',label:'Home'},
{href:'/items',lucide:'boxes',label:'Items'},
{href:'/production',lucide:'factory',label:'Prod'},
{href:'/approvals',lucide:'badge-check',label:'Approve'},
{href:'/documents',lucide:'folder-open',label:'Docs'},
];
const mobBar=document.createElement('div');mobBar.className='mob-bar';
mobBar.innerHTML=mobLinks.map(l=>{
const a=path===l.href||(l.href!=='/'&&path.startsWith(l.href));
return`<a href="${l.href}"${a?' class="active"':''}><span class="mb-ico">${sbIco(l.lucide,18)}</span>${l.label}</a>`;
}).join('')+`<button class="mb-more" id="mob-more"><span class="mb-ico">${sbIco('menu',18)}</span>More</button>`;
document.body.appendChild(mobBar);
// ── Overlay ───────────────────────────────────────────────────────────────────
const overlay=document.createElement('div');overlay.className='sb-overlay';overlay.id='sb-overlay';
document.body.appendChild(overlay);
const toggleMob=()=>{sb.classList.toggle('open');overlay.classList.toggle('show');};
document.getElementById('mob-more').onclick=toggleMob;
overlay.onclick=toggleMob;
sb.querySelectorAll('.sb-link').forEach(l=>{l.addEventListener('click',()=>{if(window.innerWidth<=768){sb.classList.remove('open');overlay.classList.remove('show');}});});
document.getElementById('sb-logout').onclick=()=>{
if(!confirm('Log out of your account?'))return;
sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';;localStorage.removeItem('token');localStorage.removeItem('portal_user');window.location.href='/';
};
// ── Impersonate ──────────────────────────────────────────────────────────
// A canImpersonate/admin user picks another user and, for the rest of this
// session, acts fully as them (their permissions, their name recorded on
// every action) — until "Return to my account". The original token/user
// are cached under separate keys so returning is a pure client-side swap,
// no server call needed.
if(user&&user.impersonatedBy){
document.body.classList.add('sb-impersonating');
const banner=document.createElement('div');
banner.className='sb-imp-banner';
banner.innerHTML=`${sbIco('user-cog',14)} Viewing as <b>${(user.name||user.username||'').replace(/</g,'&lt;')}</b> — impersonated by ${(user.impersonatedBy.name||user.impersonatedBy.username||'').replace(/</g,'&lt;')} <button id="sb-imp-stop">Return to my account</button>`;
document.body.prepend(banner);
document.getElementById('sb-imp-stop').onclick=()=>{
const realTok=sessionStorage.getItem('portal_real_token');
const realUser=sessionStorage.getItem('portal_real_user');
if(realTok&&realUser){
sessionStorage.setItem('portal_token',realTok);
sessionStorage.setItem('portal_user',realUser);
document.cookie='portal_token='+encodeURIComponent(realTok)+';path=/;SameSite=Lax';
document.cookie='portal_user='+encodeURIComponent(realUser)+';path=/;SameSite=Lax';
}
sessionStorage.removeItem('portal_real_token');
sessionStorage.removeItem('portal_real_user');
window.location.href='/';
};
}
const impBtn=document.getElementById('sb-impersonate');
if(impBtn){
let _userNamesCache=null;
impBtn.onclick=async()=>{
if(!_userNamesCache){
try{
const r=await fetch('/api/users/names',{headers:{Authorization:'Bearer '+tok}});
const d=await r.json();
_userNamesCache=(d&&d.success&&d.data)||[];
}catch(_e){_userNamesCache=[];}
}
const ov=document.createElement('div');
ov.className='sb-imp-modal-ov show';
const esc=v=>(v==null?'':String(v)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const optLabel=u=>`${u.fullName} (${u.username})`;
const others=_userNamesCache.filter(u=>u.username!==(user&&user.username));
ov.innerHTML=`
<div class="sb-imp-modal">
<div class="sb-imp-modal-hd">${sbIco('user-cog',16)} Impersonate a user<button id="sb-imp-x">✕</button></div>
<div class="sb-imp-modal-bd">
<label>User</label>
<input type="text" id="sb-imp-input" list="sb-imp-list" autocomplete="off" placeholder="Search by name..."/>
<datalist id="sb-imp-list">${others.map(u=>`<option value="${esc(optLabel(u))}"></option>`).join('')}</datalist>
</div>
<div class="sb-imp-modal-ft">
<button class="sb-imp-cancel" id="sb-imp-cancel">Cancel</button>
<button class="sb-imp-confirm" id="sb-imp-confirm">Impersonate</button>
</div>
</div>`;
document.body.appendChild(ov);
const close=()=>ov.remove();
document.getElementById('sb-imp-x').onclick=close;
document.getElementById('sb-imp-cancel').onclick=close;
ov.onclick=(e)=>{if(e.target===ov)close();};
document.getElementById('sb-imp-confirm').onclick=async()=>{
const typed=(document.getElementById('sb-imp-input').value||'').trim();
const match=others.find(u=>optLabel(u)===typed);
if(!match){alert('Please pick a valid user from the list');return;}
const btn=document.getElementById('sb-imp-confirm');
btn.disabled=true;btn.textContent='Impersonating…';
try{
const r=await fetch('/api/auth/impersonate',{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+tok},body:JSON.stringify({username:match.username})});
const d=await r.json();
if(!d||!d.success){alert((d&&d.message)||'Could not impersonate that user');btn.disabled=false;btn.textContent='Impersonate';return;}
sessionStorage.setItem('portal_real_token',tok);
sessionStorage.setItem('portal_real_user',userStr);
sessionStorage.setItem('portal_token',d.token);
sessionStorage.setItem('portal_user',JSON.stringify(d.user));
document.cookie='portal_token='+encodeURIComponent(d.token)+';path=/;SameSite=Lax';
document.cookie='portal_user='+encodeURIComponent(JSON.stringify(d.user))+';path=/;SameSite=Lax';
window.location.href='/';
}catch(e){alert('Could not reach the server');btn.disabled=false;btn.textContent='Impersonate';}
};
};
}
// ── Desktop sidebar hide / show ───────────────────────────────────────────────
const SB_KEY='sb_hidden';
function setSidebarHidden(hidden){
document.body.classList.toggle('sb-hidden',hidden);
try{localStorage.setItem(SB_KEY,hidden?'1':'0');}catch(_e){}
}
// Restore saved state — collapsed (icon rail) BY DEFAULT; only stays
// expanded when the user explicitly expanded it before ('0' saved).
try{if(localStorage.getItem(SB_KEY)!=='0') setSidebarHidden(true);}catch(_e){}
// Toggle button works both ways click always flips the state
const toggleSb=()=>setSidebarHidden(!document.body.classList.contains('sb-hidden'));
document.getElementById('sb-toggle-btn').onclick=(e)=>{e.stopPropagation();toggleSb();};
// Clicking anywhere on the collapsed strip also opens it
document.querySelector('.sb-logo').addEventListener('click',()=>{
if(document.body.classList.contains('sb-hidden')) toggleSb();
});
// ── Pending-actions bell ─────────────────────────────────────────────────
// Aggregates "pending on me" across Work Order / Production Order / BOM
// Requests / (admin) Password Reset requests — see routes/notifications.js
// for exactly what counts as pending for which role/permission.
(function initBell(){
const bellBtn=document.getElementById('sb-bell');
const badge=document.getElementById('sb-bell-badge');
const panel=document.getElementById('sb-bell-panel');
if(!bellBtn||!panel)return;
let _items=[];
function esc(v){return(v==null?'':v).toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function renderPanel(){
if(!_items.length){
panel.innerHTML='<div class="sb-bp-hd">Pending Actions</div><div class="sb-bp-empty">Nothing pending — you\'re all caught up.</div>';
return;
}
panel.innerHTML='<div class="sb-bp-hd">Pending Actions ('+_items.length+')</div>'+_items.map(it=>
`<a class="sb-bp-item" href="${it.link}"><div class="sb-bp-mod">${esc(it.label)}</div><div class="sb-bp-title">${esc(it.title)}</div><div class="sb-bp-detail">${esc(it.detail)}</div></a>`
).join('');
}
async function loadPending(){
try{
const r=await fetch('/api/notifications/pending',{headers:{Authorization:'Bearer '+tok}});
const d=await r.json();
_items=(d&&d.success&&d.data)||[];
}catch(_e){_items=[];}
if(_items.length){
badge.textContent=_items.length>99?'99+':String(_items.length);
badge.style.display='inline-block';
bellBtn.classList.add('has-pending');
}else{
badge.style.display='none';
bellBtn.classList.remove('has-pending');
}
renderPanel();
}
function positionPanel(){
const r=bellBtn.getBoundingClientRect();
panel.style.top=(r.bottom+6)+'px';
panel.style.left=Math.max(8,r.left)+'px';
}
function closePanel(){panel.classList.remove('show');}
function openPanel(){positionPanel();panel.classList.add('show');loadPending();}
bellBtn.onclick=(e)=>{
e.stopPropagation();
if(panel.classList.contains('show'))closePanel();else openPanel();
};
document.addEventListener('click',(e)=>{
if(panel.classList.contains('show')&&!panel.contains(e.target)&&!bellBtn.contains(e.target))closePanel();
});
window.addEventListener('resize',()=>{if(panel.classList.contains('show'))positionPanel();});
// Load once up front so the badge count is visible without the user
// having to click the bell first.
loadPending();
})();
// ── AI Chatbot widget ──────────────────────────────────────────────────────
// chandan (function loadChatbot(){
// if(document.getElementById('cb-fab')) return;
// const s=document.createElement('script');
// s.src='/chatbot.js';
// document.body.appendChild(s);
// })();
} // end buildSidebar
// ── _injectLucide: called by index.html after building module cards ─────────
window._injectLucide=(function(){
function toP(s){return s.replace(/(^[a-z]|-[a-z])/g,function(m){return m.replace('-','').toUpperCase();});}
return function injectIcons(){
var ic=window.lucide&&lucide.icons;
var fn=window.lucide&&lucide.createElement;
if(!ic||!fn) return;
document.querySelectorAll('[data-lucide]').forEach(function(el){
var name=el.getAttribute('data-lucide');
var def=ic[toP(name)];
if(!def) return;
try{
var ws=parseInt(el.style.width)||16;
var hs=parseInt(el.style.height)||16;
var svg=fn([def[0],Object.assign({},def[1],{width:ws,height:hs}),def[2]]);
svg.style.flexShrink='0';
if(el.parentNode) el.parentNode.replaceChild(svg,el);
}catch(e){}
});
};
})();
})();