723 lines
42 KiB
JavaScript
723 lines
42 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(){
|
|
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;
|
|
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});
|
|
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},
|
|
// 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}
|
|
}
|
|
`;
|
|
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">›</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">☰</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>`:''}
|
|
<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='/';
|
|
};
|
|
|
|
// ── 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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
|
|
|
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){}
|
|
});
|
|
};
|
|
})();
|
|
})();
|
|
|