Files
sap-erp/public/index.html
T
John eead8f5ffd
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s
sale order
2026-10-05 18:45:17 +05:30

708 lines
41 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"/>
<title>MITRA ERP Portal</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
<script src="/lucide.min.js?v=399"></script>
<style>
:root{
--blue:#2563eb;--blue2:#1d4ed8;--blue-light:#eff6ff;
--text:#111827;--text2:#374151;--text3:#6b7280;--text4:#9ca3af;
--bg:#f9fafb;--surface:#fff;--border:#e5e7eb;
--font:'Space Grotesk',sans-serif;
--red:#dc2626;--green:#16a34a;--amber:#d97706;--purple:#7c3aed;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--bg);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
/* ── Login — professional split panel ── */
.login-wrap{min-height:100vh;display:flex;align-items:center;justify-content:center;background:var(--bg);padding:20px}
.login-shell{display:flex;width:100%;max-width:880px;min-height:560px;border-radius:20px;overflow:hidden;box-shadow:0 20px 60px rgba(15,23,42,.14);border:1px solid var(--border)}
.login-brand{
flex:1 1 46%;position:relative;overflow:hidden;
background:linear-gradient(150deg,#1e3a8a 0%,#2563eb 55%,#4f46e5 100%);
padding:44px 40px;display:flex;flex-direction:column;justify-content:space-between;
color:#fff;min-width:0;
}
.login-brand::before{content:'';position:absolute;top:-60px;right:-60px;width:260px;height:260px;border-radius:50%;background:rgba(255,255,255,.07);pointer-events:none}
.login-brand::after{content:'';position:absolute;bottom:-90px;left:-40px;width:220px;height:220px;border-radius:50%;background:rgba(255,255,255,.05);pointer-events:none}
.lb-logo{display:flex;align-items:center;gap:11px;z-index:1}
.lb-logo-mark{width:38px;height:38px;border-radius:10px;background:#fff;border:1px solid rgba(255,255,255,.25);display:flex;align-items:center;justify-content:center;padding:5px;flex-shrink:0}
.lb-logo-mark svg,.lb-logo-mark img{width:100%;height:100%;display:block;object-fit:contain}
.lb-logo-text{font-size:14px;font-weight:700;letter-spacing:.2px}
.lb-logo-text small{display:block;font-size:10.5px;font-weight:400;color:rgba(255,255,255,.65);margin-top:1px}
.lb-mid{z-index:1;margin:36px 0}
.lb-title{font-size:26px;font-weight:800;line-height:1.25;margin-bottom:10px}
.lb-sub{font-size:13px;color:rgba(255,255,255,.75);line-height:1.6;max-width:320px}
.lb-features{z-index:1;display:flex;flex-direction:column;gap:12px}
.lb-feat{display:flex;align-items:center;gap:10px;font-size:12.5px;color:rgba(255,255,255,.85)}
.lb-feat-ico{width:26px;height:26px;border-radius:7px;background:rgba(255,255,255,.14);display:flex;align-items:center;justify-content:center;flex-shrink:0}
.login-formside{flex:1 1 54%;background:#fff;padding:48px 44px;display:flex;flex-direction:column;justify-content:center;min-width:0}
.login-h{font-size:23px;font-weight:800;color:var(--text);margin-bottom:5px}
.login-sub{font-size:13px;color:var(--text3);margin-bottom:26px}
.field{margin-bottom:16px}
.field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--text3);margin-bottom:6px;display:block}
.field-input{width:100%;padding:11px 13px;border:1.5px solid var(--border);border-radius:9px;color:var(--text);font-size:13.5px;font-family:var(--font);outline:none;transition:border .15s,box-shadow .15s;background:#fff}
.field-input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(37,99,235,.1)}
.field-input::placeholder{color:var(--text4)}
/* Show/hide password (eye) button */
.pw-wrap{position:relative}.pw-wrap .field-input{padding-right:42px}
.pw-eye{position:absolute;right:6px;top:50%;transform:translateY(-50%);width:32px;height:32px;border:none;background:transparent;cursor:pointer;color:var(--text4);display:flex;align-items:center;justify-content:center;border-radius:6px}
.pw-eye:hover{color:var(--blue);background:rgba(37,99,235,.08)}
.login-row2{display:flex;align-items:center;justify-content:flex-end;margin:-8px 0 18px}
.forgot-link{font-size:12px;font-weight:600;color:var(--blue);background:none;border:none;cursor:pointer;font-family:var(--font);padding:0}
.forgot-link:hover{color:var(--blue2);text-decoration:underline}
.btn-login{width:100%;padding:12px;background:var(--blue);color:#fff;border:none;border-radius:9px;font-size:14px;font-weight:700;font-family:var(--font);cursor:pointer;transition:background .15s;box-shadow:0 4px 14px rgba(37,99,235,.25)}
.btn-login:hover:not(:disabled){background:var(--blue2)}
.btn-login:disabled{opacity:.6;cursor:not-allowed}
.login-foot{margin-top:22px;font-size:11.5px;color:var(--text4);text-align:center}
.err-box{background:#fef2f2;border:1px solid #fecaca;color:var(--red);padding:10px 12px;border-radius:8px;font-size:12px;margin-bottom:14px;display:none}
.err-box.show{display:block}
.sp{width:13px;height:13px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .65s linear infinite;display:inline-block;vertical-align:middle;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
@media(max-width:760px){
.login-shell{flex-direction:column;max-width:420px;min-height:0}
.login-brand{padding:28px 26px;min-height:0}
.lb-mid{margin:20px 0}
.lb-title{font-size:20px}
.lb-features{display:none}
.login-formside{padding:32px 26px}
}
/* Forgot-password modal */
.fp-overlay{position:fixed;inset:0;background:rgba(15,23,42,.5);z-index:2000;display:none;align-items:center;justify-content:center;padding:20px}
.fp-overlay.show{display:flex}
.fp-modal{background:#fff;border-radius:16px;width:100%;max-width:400px;box-shadow:0 24px 60px rgba(15,23,42,.25);padding:28px}
.fp-hd{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:6px}
.fp-title{font-size:17px;font-weight:800;color:var(--text)}
.fp-sub{font-size:12.5px;color:var(--text3);margin-bottom:20px;line-height:1.5}
.fp-close{flex-shrink:0;width:28px;height:28px;border-radius:8px;border:1px solid var(--border);background:#fff;color:var(--text3);cursor:pointer}
.fp-actions{display:flex;gap:8px;margin-top:6px}
.fp-cancel{flex:0 0 auto;padding:11px 16px;background:#fff;border:1.5px solid var(--border);color:var(--text2);border-radius:9px;font-size:13px;font-weight:700;font-family:var(--font);cursor:pointer}
.fp-submit{flex:1;padding:11px;background:var(--blue);color:#fff;border:none;border-radius:9px;font-size:13px;font-weight:700;font-family:var(--font);cursor:pointer}
.fp-submit:hover:not(:disabled){background:var(--blue2)}
.fp-submit:disabled{opacity:.6;cursor:not-allowed}
.fp-done{text-align:center;padding:8px 0}
.fp-done-icon{width:48px;height:48px;border-radius:50%;background:#f0fdf4;color:var(--green);display:flex;align-items:center;justify-content:center;margin:0 auto 14px}
/* ── Dashboard page ───────────────────── */
.dash-page{padding:28px 32px 48px;min-height:100vh}
/* Welcome card */
.welcome-card{
background:linear-gradient(135deg,#1e40af 0%,#2563eb 50%,#3b82f6 100%);
border-radius:16px;padding:28px 32px;margin-bottom:28px;
display:flex;align-items:center;justify-content:space-between;gap:20px;
box-shadow:0 4px 24px rgba(37,99,235,.25);position:relative;overflow:hidden;
}
.welcome-card::before{
content:'';position:absolute;top:-40px;right:-40px;
width:200px;height:200px;border-radius:50%;
background:rgba(255,255,255,.06);pointer-events:none;
}
.welcome-card::after{
content:'';position:absolute;bottom:-60px;right:120px;
width:150px;height:150px;border-radius:50%;
background:rgba(255,255,255,.04);pointer-events:none;
}
.wc-left{display:flex;align-items:center;gap:18px;z-index:1}
.wc-avatar{
width:54px;height:54px;border-radius:14px;
background:rgba(255,255,255,.15);border:2px solid rgba(255,255,255,.25);
display:flex;align-items:center;justify-content:center;
font-size:20px;font-weight:800;color:#fff;flex-shrink:0;letter-spacing:0;
}
.wc-info{}
.wc-greeting{font-size:12px;font-weight:600;color:rgba(255,255,255,.65);text-transform:uppercase;letter-spacing:.8px;margin-bottom:4px}
.wc-name{font-size:22px;font-weight:800;color:#fff;line-height:1.1;margin-bottom:4px}
.wc-un{font-size:11.5px;color:rgba(255,255,255,.55);font-family:monospace}
.wc-right{text-align:right;z-index:1;flex-shrink:0}
.wc-badge{
display:inline-flex;align-items:center;gap:5px;
padding:4px 12px;border-radius:20px;font-size:11px;font-weight:700;
letter-spacing:.4px;margin-bottom:8px;
background:rgba(255,255,255,.18);color:#fff;border:1px solid rgba(255,255,255,.25);
}
.wc-company{font-size:12px;font-weight:600;color:rgba(255,255,255,.75);margin-bottom:3px}
.wc-login{font-size:11px;color:rgba(255,255,255,.45)}
.wc-divider{width:1px;background:rgba(255,255,255,.12);align-self:stretch;margin:0 8px;z-index:1}
.wc-stats{display:flex;gap:24px;z-index:1}
.wc-stat{text-align:center}
.wc-stat-val{font-size:20px;font-weight:800;color:#fff;line-height:1}
.wc-stat-lbl{font-size:10px;color:rgba(255,255,255,.5);text-transform:uppercase;letter-spacing:.6px;margin-top:3px;font-weight:600}
.role-badge{display:inline-flex;align-items:center;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:600;letter-spacing:.3px;margin-bottom:4px}
.role-badge.admin{background:#f3e8ff;color:#7c3aed}
.role-badge.user{background:#dcfce7;color:#16a34a}
.role-badge.manager{background:#dbeafe;color:var(--blue)}
@media(max-width:768px){
.welcome-card{flex-direction:column;align-items:flex-start;padding:20px}
.wc-right{text-align:left}
.wc-divider,.wc-stats{display:none}
}
/* Section header */
.sec-hdr{display:flex;align-items:center;gap:7px;margin-bottom:12px;margin-top:4px}
.sec-hdr svg{color:var(--text4);flex-shrink:0}
.sec-hdr span{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.8px;color:var(--text4)}
/* Module cards grid */
.mods-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:14px;margin-bottom:28px}
/* Pending-count badge on a module card (top-right corner) */
.mc-badge{
position:absolute;top:10px;right:10px;
background:#dc2626;color:#fff;font-size:10px;font-weight:800;
border-radius:10px;padding:1px 7px;line-height:1.6;min-width:8px;text-align:center;
box-shadow:0 1px 3px rgba(220,38,38,.4);
}
/* Module card matches MSPL exactly */
.mc{
background:#fff;
border:1px solid var(--border);
border-radius:12px;
padding:18px;
cursor:pointer;text-decoration:none;
display:flex;flex-direction:column;gap:14px;
transition:box-shadow .18s,border-color .18s;
box-shadow:0 1px 3px rgba(0,0,0,.05);
position:relative;
}
.mc:hover{box-shadow:0 4px 16px rgba(0,0,0,.09);border-color:#d1d5db}
.mc-top{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}
.mc-icon{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.mc-arrow{color:#d1d5db;flex-shrink:0;margin-top:2px;transition:color .15s,transform .15s}
.mc:hover .mc-arrow{color:#9ca3af;transform:translateX(2px)}
.mc-title{font-size:13px;font-weight:700;color:var(--text);margin-bottom:2px}
.mc-desc{font-size:11.5px;color:var(--text3);line-height:1.45}
/* Icon colour schemes */
.ic-blue{background:#eff6ff;color:#2563eb}
.ic-indigo{background:#eef2ff;color:#4338ca}
.ic-teal{background:#f0fdfa;color:#0d9488}
.ic-green{background:#f0fdf4;color:#16a34a}
.ic-amber{background:#fffbeb;color:#d97706}
.ic-orange{background:#fff7ed;color:#ea580c}
.ic-red{background:#fef2f2;color:#dc2626}
.ic-purple{background:#faf5ff;color:#7c3aed}
.ic-pink{background:#fdf2f8;color:#be185d}
.ic-grey{background:#f9fafb;color:#4b5563}
/* Toast */
.toast-c{position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none}
.toast{padding:11px 16px;border-radius:9px;font-size:12px;font-weight:600;box-shadow:0 4px 16px rgba(0,0,0,.12);display:flex;align-items:center;gap:7px;max-width:300px;pointer-events:auto;animation:tIn .2s ease}
.toast.ok{background:var(--green);color:#fff}.toast.err{background:var(--red);color:#fff}.toast.warn{background:var(--amber);color:#fff}
@keyframes tIn{from{transform:translateY(12px);opacity:0}to{transform:translateY(0);opacity:1}}
@media(max-width:768px){.dash-page{padding:14px 14px 80px}.mods-grid{grid-template-columns:repeat(2,1fr);gap:10px}.welcome-row{flex-direction:column;margin-bottom:20px}.mc{padding:14px;gap:10px}.mc-icon{width:34px;height:34px}.mc-title{font-size:12px}.mc-desc{display:none}}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<div id="root"></div>
<script>
'use strict';
let _tok=null,_user=null,_cfg=null;
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; }
}
function getCookie(name){
const m=document.cookie.match(new RegExp('(?:^|; )'+name+'=([^;]*)'));
return m?decodeURIComponent(m[1]):null;
}
(function(){
try{
// Session-only auth: login is mirrored into a SESSION COOKIE (no
// expires/max-age) at login time — cookies are shared across every tab
// of the same browser (unlike sessionStorage), so Ctrl+Click/"open in
// new tab" doesn't force a fresh login. A true session cookie is still
// wiped by the browser itself when it fully closes, so closing the
// browser still always ends the session.
// Defensive cleanup: wipe any leftover localStorage entries from an even
// older version of this scheme, so a lingering copy can't grant access.
try{ localStorage.removeItem('token'); localStorage.removeItem('portal_user'); }catch{}
if(!sessionStorage.getItem('portal_token')){
const cTok=getCookie('portal_token'), cUser=getCookie('portal_user');
if(cTok){
sessionStorage.setItem('portal_token',cTok);
if(cUser) sessionStorage.setItem('portal_user',cUser);
}
}
_tok=sessionStorage.getItem('portal_token')||null;
const u=sessionStorage.getItem('portal_user');
if(u)_user=JSON.parse(u);
// Stale-but-present token from a past session: treat exactly like no
// token so the login screen shows immediately, without waiting on the
// async /api/config round trip (which sidebar.js's own render doesn't
// wait on either — see sidebar.js's matching jwtExpired check).
if(_tok&&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{}
_tok=null;_user=null;
}
}catch{}
})();
const BACKEND='/api';
async function api(method,path,body=null){
const h={'Content-Type':'application/json'};
if(_tok)h['Authorization']='Bearer '+_tok;
const opt={method:method.toUpperCase(),headers:h};
if(body)opt.body=JSON.stringify(body);
let r,d;
try{r=await fetch(BACKEND+path,opt);d=await r.json();}
catch(e){throw new Error('Cannot reach server');}
if(r.status===401){doLogout();throw new Error('Session expired');}
if(!r.ok)throw new Error(d?.message||'HTTP '+r.status);
return d;
}
function toast(msg,type='ok'){
const tc=document.getElementById('toast-c');
const el=document.createElement('div');
el.className='toast '+type;
el.textContent=msg;tc.appendChild(el);
setTimeout(()=>el.remove(),3500);
}
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function doLogout(){_tok=null;_user=null;_cfg=null;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';;localStorage.removeItem('token');localStorage.removeItem('portal_user')}catch{}window.location.reload();}
async function loadConfig(){try{const r=await api('GET','/config');_cfg=r;}catch{}}
// icon helper
function ico(name,size=20){return`<i data-lucide="${name}" style="width:${size}px;height:${size}px;stroke-width:1.75"></i>`}
// ─── LOGIN ───────────────────────────────────────────────
function buildLogin(){
const wrap=document.createElement('div');
wrap.innerHTML=`
<div class="login-wrap">
<div class="login-shell">
<div class="login-brand">
<div class="lb-logo">
<div class="lb-logo-mark"><img class="lb-img" src="/logo.png" alt="Logo" onerror="this.style.display='none';this.nextElementSibling.style.display='block'"/><svg class="lb-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="lb-logo-text">MITRA<small>ERP Portal</small></div>
</div>
<div class="lb-mid">
<div class="lb-title">Run your ERP operations from one MITRA portal</div>
<div class="lb-sub">Work orders, production and approvals — all in sync with SAP B1.</div>
</div>
<div class="lb-features">
<div class="lb-feat"><span class="lb-feat-ico">${ico('shield-check',15)}</span> Role-based approval workflows</div>
<div class="lb-feat"><span class="lb-feat-ico">${ico('workflow',15)}</span> Live SAP Business One integration</div>
<div class="lb-feat"><span class="lb-feat-ico">${ico('layout-grid',15)}</span> One portal for every module</div>
</div>
</div>
<div class="login-formside">
<div class="login-h">Welcome back</div>
<div class="login-sub">Sign in to your portal account</div>
<div class="err-box" id="login-err"></div>
<div class="field">
<label class="field-label">Username</label>
<input class="field-input" id="l-user" placeholder="Enter username" autocomplete="username"/>
</div>
<div class="field">
<label class="field-label">Password</label>
<div class="pw-wrap"><input class="field-input" id="l-pass" type="password" placeholder="Enter password" autocomplete="current-password"/>
<button type="button" class="pw-eye" id="l-eye" title="Show password" aria-label="Show password"></button></div>
</div>
<div class="login-row2"><button type="button" class="forgot-link" id="l-forgot">Forgot password?</button></div>
<button class="btn-login" id="l-btn">Sign In</button>
<div class="login-foot">Trouble signing in? Contact your administrator.</div>
</div>
</div>
</div>
<div class="fp-overlay" id="fp-overlay">
<div class="fp-modal" id="fp-modal"></div>
</div>`;
function renderForgotForm(){
const m=wrap.querySelector('#fp-modal');
m.innerHTML=`
<div class="fp-hd">
<div class="fp-title">Forgot password?</div>
<button class="fp-close" id="fp-close">${ico('x',15)}</button>
</div>
<div class="fp-sub">This portal doesn't send reset emails — submit your username below and an administrator will reset your password for you.</div>
<div class="err-box" id="fp-err"></div>
<div class="field">
<label class="field-label">Username</label>
<input class="field-input" id="fp-user" placeholder="Your username" autocomplete="username"/>
</div>
<div class="field">
<label class="field-label">Note (optional)</label>
<input class="field-input" id="fp-note" placeholder="Anything the admin should know"/>
</div>
<div class="fp-actions">
<button class="fp-cancel" id="fp-cancel">Cancel</button>
<button class="fp-submit" id="fp-submit">Submit Request</button>
</div>`;
m.querySelector('#fp-close').onclick=closeForgot;
m.querySelector('#fp-cancel').onclick=closeForgot;
const submit=async()=>{
const u=m.querySelector('#fp-user').value.trim();
const err=m.querySelector('#fp-err');
const btn=m.querySelector('#fp-submit');
err.classList.remove('show');
if(!u){err.textContent='Username is required';err.classList.add('show');return;}
btn.disabled=true;btn.innerHTML='<span class="sp"></span> Submitting';
try{
const r=await fetch(BACKEND+'/password-reset',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,note:m.querySelector('#fp-note').value.trim()})});
const d=await r.json();
if(!r.ok||!d.success)throw new Error(d?.message||'Could not submit request');
renderForgotDone();
}catch(e){
err.textContent=e.message;err.classList.add('show');
btn.disabled=false;btn.textContent='Submit Request';
}
};
m.querySelector('#fp-submit').onclick=submit;
m.querySelector('#fp-user').focus();
if(window._injectLucide)window._injectLucide();
}
function renderForgotDone(){
const m=wrap.querySelector('#fp-modal');
m.innerHTML=`
<div class="fp-done">
<div class="fp-done-icon">${ico('check',24)}</div>
<div class="fp-title" style="margin-bottom:8px">Request submitted</div>
<div class="fp-sub">An administrator will reset your password and let you know. You can close this window.</div>
<button class="fp-submit" id="fp-ok" style="width:100%">Close</button>
</div>`;
m.querySelector('#fp-ok').onclick=closeForgot;
if(window._injectLucide)window._injectLucide();
}
function openForgot(){renderForgotForm();wrap.querySelector('#fp-overlay').classList.add('show');}
function closeForgot(){wrap.querySelector('#fp-overlay').classList.remove('show');}
setTimeout(()=>{
const go=async()=>{
const u=wrap.querySelector('#l-user').value.trim();
const p=wrap.querySelector('#l-pass').value;
const err=wrap.querySelector('#login-err');
const btn=wrap.querySelector('#l-btn');
err.classList.remove('show');
if(!u||!p){err.textContent='Username and password required';err.classList.add('show');return;}
btn.disabled=true;btn.innerHTML='<span class="sp"></span> Signing';
try{
const r=await fetch(BACKEND+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})});
const d=await r.json();
if(!r.ok)throw new Error(d?.message||'Login failed');
_tok=d.token;_user=d.user;
try{
sessionStorage.setItem('portal_token',_tok);sessionStorage.setItem('portal_user',JSON.stringify(_user));
// Session cookie (no expires/max-age) — shared across tabs, wiped when the browser fully closes.
document.cookie='portal_token='+encodeURIComponent(_tok)+';path=/;SameSite=Lax';
document.cookie='portal_user='+encodeURIComponent(JSON.stringify(_user))+';path=/;SameSite=Lax';
}catch{}
window.location.reload();
}catch(e){
err.textContent=e.message;err.classList.add('show');
btn.disabled=false;btn.textContent='Sign In';
}
};
wrap.querySelector('#l-btn').onclick=go;
wrap.querySelector('#l-pass').addEventListener('keydown',e=>{if(e.key==='Enter')go();});
// Eye button: toggle password visibility (keeps focus/cursor in the field).
(()=>{const pw=wrap.querySelector('#l-pass'),eye=wrap.querySelector('#l-eye');if(!pw||!eye)return;
const EYE='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>';
const OFF='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 19c-6.5 0-10-7-10-7a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c6.5 0 10 7 10 7a18.5 18.5 0 0 1-2.16 3.19"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/><line x1="2" y1="2" x2="22" y2="22"/></svg>';
eye.innerHTML=EYE;
eye.addEventListener('mousedown',e=>e.preventDefault());
eye.addEventListener('click',()=>{const show=pw.type==='password';pw.type=show?'text':'password';eye.innerHTML=show?OFF:EYE;
eye.title=show?'Hide password':'Show password';eye.setAttribute('aria-label',eye.title);pw.focus();});})();
wrap.querySelector('#l-forgot').onclick=openForgot;
wrap.querySelector('#fp-overlay').onclick=e=>{if(e.target.id==='fp-overlay')closeForgot();};
wrap.querySelector('#l-user').focus();
if(window._injectLucide)window._injectLucide();
},0);
return wrap;
}
// ─── DASHBOARD ───────────────────────────────────────────
function buildDashboard(){
const wrap=document.createElement('div');
wrap.className='dash-page';
const role=_user?.role||'user';
const isAdmin=role==='admin'||role==='sap_adder'; // full tile bypass (SAP Adder kept its original admin-like visibility)
const isSubAdmin=role==='system_admin'; // System Admin: sees ONLY granted modules
const userModules=Array.isArray(_user?.modules)?_user.modules:null;
// Backward-compat: a user granted the OLD broad key (e.g. 'production',
// before it was split into per-page sub-modules) still sees every card
// under that group.
const LEGACY_BROAD_MODULES={production:'production-',purchase:'purchase-',costing:'finance-',projects:'projects-'};
const canSee=m=>{
if(isAdmin)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;
};
// Header Profiles has no module of its own — same as sidebar.js, it's
// gated purely by the 'work_order:approved_mgr_qa' step (the existing
// "QA Manager" step), so a QA Manager without production-work-order still
// needs to see this card.
const hasQaMgrStepAccess=(()=>{
if(isAdmin)return true;
const steps=Array.isArray(_user?.approvalSteps)?_user.approvalSteps:[];
return steps.some(s=>{
if(typeof s==='string')return s==='work_order:approved_mgr_qa';
return s&&s.step==='work_order:approved_mgr_qa'&&Array.isArray(s.perms)&&s.perms.length>0;
});
})();
// Same idea as sidebar.js's hasPrePwoStepAccess: a pure Pre-PWO Store
// reviewer has no reason to hold the broad 'production-create' module, so
// holding either Pre-PWO approval step (any perm) is enough to see the
// Production Order card too — otherwise they can reach the page (via
// auth-guard.js's own exception) but the dashboard would never show them
// how to get there.
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 the 'oee:entry' approval step (any perm) is
// enough to see the card even without the broader 'oee' module — mirrors
// the identical exception already made for Man Power's own module gate
// being the looser, more commonly-granted one.
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 canSeeItem=m=>canSee(m.module)
||(m.href==='/wo-header-profiles'&&hasQaMgrStepAccess)
||(m.href==='/production'&&hasPrePwoStepAccess)
||(m.href==='/oee'&&hasOeeStepAccess);
const name=_user?.name||_user?.username||'User';
const username=_user?.username||'';
const badgeCls=(isAdmin||isSubAdmin)?'admin':role.includes('manager')?'manager':'user';
// const roleLabel=({system_admin:'System Admin',board_member:'Board Member',sap_adder:'SAP Adder',sr_manager:'Sr. Manager',project_approver:'Project Approver'})[role]||(role||'').replace(/_/g,' ');
const roleLabel=(role||'').replace(/_/g,' ');
const company=_user?.company||'MSPL';
const loginAt=_user?.loginAt?new Date(_user.loginAt).toLocaleString('en-IN',{dateStyle:'medium',timeStyle:'short'}):'';
// ── Module definitions ─────────────────────────────────
const GROUPS=[
{
id:'inventory',label:'Inventory',icon:'package',
items:[
{href:'/bom', module:'bom', icon:'cpu', ic:'ic-blue', title:'BOM', desc:'Bill of Materials with approval workflow.'},
{href:'/items', module:'items', icon:'package', ic:'ic-teal', title:'Items', desc:'Create & manage SAP B1 item master data.'},
]
},
{
id:'production',label:'Production',icon:'factory',
items:[
{href:'/production-dashboard', module:'production-dashboard', icon:'gauge', ic:'ic-teal', title:'Dashboard', desc:'Production output, orders & workflow analytics.'},
{href:'/production', module:'production-create', icon:'factory', ic:'ic-purple',title:'Production Order', desc:'Create & manage production orders.'},
{href:'/issue-production', module:'production-issue', icon:'arrow-up-right', ic:'ic-orange',title:'Issue', desc:'Issue components for production.'},
{href:'/receipt-production',module:'production-receipt', icon:'arrow-down-left', ic:'ic-green', title:'Receipt', desc:'Receive finished goods from production.'},
{href:'/verify-work-order', module:'work-order-verify', icon:'clipboard-check', ic:'ic-green', title:'Verify Work Order',desc:'Sign off Issued/Received/Verified per material row.'},
{href:'/verify-production', module:'production-verify', icon:'clipboard-check', ic:'ic-green', title:'Verify', desc:'Post-Issuance verification sign-off.'},
{href:'/receipts-history', module:'production-receipt', icon:'history', ic:'ic-teal', title:'Receipts History',desc:'All posted receipts from production.'},
{href:'/close-production', module:'production-close', icon:'check-circle', ic:'ic-grey', title:'Close', desc:'Close completed production orders.'},
{href:'/requirements', module:'production-requirements', icon:'clipboard-list', ic:'ic-teal', title:'Requirements', desc:'Raise logistics material requirements.'},
{href:'/batch-issuance', module:'production-batch-issuance',icon:'layers', ic:'ic-indigo',title:'Batch Issuance', desc:'Batch issuance intimation from requirements.'},
{href:'/work-order', module:'production-work-order', icon:'file-cog', ic:'ic-blue', title:'Work Order', desc:'Production work orders with QA/QC approval.'},
{href:'/wo-header-profiles',module:'production-work-order', icon:'file-badge-2', ic:'ic-teal', title:'Header Profiles', desc:'Per-product-group Work Order PDF letterhead (also usable by QA Manager).'},
{href:'/man-power', module:'man-power', icon:'users', ic:'ic-purple',title:'Man Power', desc:'Daily manpower & production data entry.'},
{href:'/oee', module:'oee', icon:'activity', ic:'ic-orange',title:'OEE', desc:'Overall Equipment Efficiency data entry.'},
]
},
{
id:'purchase',label:'Purchase',icon:'shopping-cart',
items:[
{href:'/purchase-request', module:'purchase-request', icon:'file-text', ic:'ic-teal', title:'Purchase Request', desc:'Raise & track purchase requests.'},
{href:'/purchase-quotation', module:'purchase-quotation',icon:'tag', ic:'ic-indigo',title:'Quotation', desc:'Manage purchase quotations from vendors.'},
{href:'/purchase-order', module:'purchase-order', icon:'shopping-cart',ic:'ic-blue', title:'Purchase Order', desc:'Create and send purchase orders.'},
{href:'/grpo', module:'purchase-grpo', icon:'truck', ic:'ic-green', title:'GRPO', desc:'Goods receipt against purchase orders.'},
]
},
{
id:'approval',label:'Approvals',icon:'check-square',
items:[
{href:'/approvals', module:'approvals', icon:'check-square', ic:'ic-amber',title:'External Approval',desc:'Review & approve external requests.'},
{href:'/sap-approvals',module:'sap-approvals',icon:'shield-check', ic:'ic-green',title:'SAP Approval', desc:'Approve & push orders to SAP B1.'},
]
},
{
id:'forms',label:'Forms',icon:'file-plus',
items:[
{href:'/vendor-register',module:'vendors', icon:'building-2', ic:'ic-orange',title:'Vendor', desc:'Register new vendors with documents.'},
{href:'/register', module:'customers', icon:'user-plus', ic:'ic-teal', title:'Customer', desc:'Register new customers with contacts.'},
]
},
{
id:'projects',label:'Projects',icon:'briefcase',
items:[
{href:'/project-form?new=1', module:'projects-new', icon:'plus-circle', ic:'ic-blue', title:'New Project', desc:'Create a new project and assign team.'},
{href:'/project-form?list=1',module:'projects-view', icon:'list', ic:'ic-indigo',title:'View Projects', desc:'Browse and manage all projects.'},
{href:'/project-approvals', module:'projects-approvals',icon:'check-circle', ic:'ic-green', title:'Project Approvals', desc:'Review and approve project requests.'},
]
},
{
id:'finance',label:'Finance',icon:'bar-chart-2',
items:[
{href:'/costing-pl', module:'finance-monthly', icon:'bar-chart-2', ic:'ic-blue', title:'Monthly Accounts',desc:'P&L with Trial Balance snapshots.'},
{href:'/costing-comparison',module:'finance-comparison', icon:'trending-up', ic:'ic-indigo',title:'P&L Comparison', desc:'Compare P&L across periods.'},
{href:'/cost-sheet', module:'finance-costsheet', icon:'clipboard', ic:'ic-teal', title:'Cost Sheet', desc:'Analyse project cost breakdowns.'},
{href:'/balance-sheet', module:'finance-balancesheet',icon:'scale', ic:'ic-green', title:'Balance Sheet', desc:'Assets, liabilities and equity.'},
{href:'/cash-flow', module:'finance-cashflow', icon:'arrow-left-right',ic:'ic-amber',title:'Cash Flow', desc:'Statement of cash flow analysis.'},
{href:'/salary', module:'salary', icon:'credit-card', ic:'ic-purple',title:'Salary', desc:'Payroll and salary register.'},
]
},
{
id:'gstr',label:'GST Reports',icon:'receipt',
items:[
{href:'/gstr1',module:'gstr1',icon:'receipt', ic:'ic-indigo',title:'GSTR-1', desc:'GST outward supply return.'},
{href:'/gstr2',module:'gstr2',icon:'file-input', ic:'ic-teal', title:'GSTR-2 Purchase',desc:'GST purchase reconciliation.'},
{href:'/itc04',module:'itc04',icon:'repeat', ic:'ic-orange',title:'ITC-04 Job Work', desc:'Job work ITC reconciliation.'},
]
},
{
id:'reports',label:'Reports',icon:'pie-chart',
items:[
{href:'/board-dashboard', module:'board-dashboard', icon:'trending-up', ic:'ic-purple', title:'Board Dashboard', desc:'Company growth analytics for the board.'},
{href:'/reports', module:'reports', icon:'pie-chart',ic:'ic-grey',title:'Reports', desc:'Custom business reports.'},
{href:'/general-ledger',module:'general-ledger',icon:'book-open', ic:'ic-blue',title:'General Ledger',desc:'SAP B1 general ledger browser.'},
{href:'/documents',module:'documents',icon:'folder-open', ic:'ic-grey',title:'View Documents', desc:'All uploaded documents.'},
{href:'/audit-logs',module:'audit',icon:'scroll-text',ic:'ic-grey',title:'Audit Logs',desc:'Every mutation logged across the portal.'},
{href:'/mail-logs',module:'mail_log',icon:'mail',ic:'ic-grey',title:'Mail Log',desc:'Every email the portal has sent, with delivery status.'},
]
},
{
id:'masters',label:'Masters',icon:'database',
items:[
{href:'/business-master',module:'business-master',icon:'building',ic:'ic-indigo',title:'Business Master',desc:'Company/branch master data.'},
]
},
];
if(isAdmin||isSubAdmin){
// Sub-admins still need the 'admin' MODULE granted — canSee() filters below.
GROUPS.push({
id:'admin',label:'Admin',icon:'settings',
items:[
{href:'/admin#users',module:'admin',icon:'users',ic:'ic-purple',title:'Manage Users',desc:'Create, view and manage portal users.'},
{href:'/admin#settings',module:'admin',icon:'settings',ic:'ic-grey',title:'System Settings',desc:'Portal configuration & workflow rules.'},
{href:'/item-group-classification',module:'item-group-classification',icon:'layers-3',ic:'ic-teal',title:'Item Group Rules',desc:'Classify SAP item groups as Raw/Pack/Component.'},
]
});
}
let html='';
for(const g of GROUPS){
const visible=g.items.filter(canSeeItem);
if(!visible.length)continue;
html+=`
<div class="sec-hdr">
${ico(g.icon,14)}
<span>${g.label.toUpperCase()}</span>
</div>
<div class="mods-grid">`;
for(const m of visible){
html+=`<a href="${esc(m.href)}" class="mc">
<div class="mc-top">
<div class="mc-icon ${m.ic}">${ico(m.icon,20)}</div>
<i data-lucide="chevron-right" class="mc-arrow" style="width:15px;height:15px;stroke-width:1.75"></i>
</div>
<div>
<div class="mc-title">${esc(m.title)}</div>
<div class="mc-desc">${esc(m.desc)}</div>
</div>
</a>`;
}
html+=`</div>`;
}
const initials = name.split(' ').map(w=>w[0]||'').join('').slice(0,2).toUpperCase() || '?';
const totalModules = GROUPS.reduce((s,g)=>s+g.items.filter(canSeeItem).length, 0);
const now = new Date();
const todayStr = now.toLocaleDateString('en-IN',{weekday:'short',day:'numeric',month:'short',year:'numeric'});
const hourGreet = now.getHours() < 12 ? 'Good Morning' : now.getHours() < 17 ? 'Good Afternoon' : 'Good Evening';
wrap.innerHTML=`
<div class="welcome-card">
<div class="wc-left">
<div class="wc-avatar">${esc(initials)}</div>
<div class="wc-info">
<div class="wc-greeting">${hourGreet}</div>
<div class="wc-name">${esc(name)}</div>
<div class="wc-un">@${esc(username)}</div>
</div>
</div>
</div>
${html}`;
// `wrap` isn't attached to the document yet at this point (buildDashboard()
// is still running as an argument to root.appendChild()) — document.getElementById
// would find nothing, so hand the container itself to loadPendingCounts
// instead of having it search the live document.
loadPendingCounts(wrap);
setTimeout(()=>{if(window._injectLucide)window._injectLucide();},0);
return wrap;
}
// ─── PENDING-COUNT BADGES ON MODULE CARDS ─────────────────────────
// Same aggregation the sidebar bell uses (routes/notifications.js) — rather
// than a separate dashboard list, each count is shown as a small badge on
// the module card it belongs to. Grouped by cardHref (not module) since a
// single module — Production Order — spans several distinct cards
// (Create/View, Issue, Receipt, Close), each needing its own count.
const PA_HREF_FALLBACK={work_order:'/work-order',production_order:'/production',bom:'/bom',password_reset:'/admin'};
async function loadPendingCounts(root){
try{
const r=await fetch(BACKEND+'/notifications/pending',{headers:_tok?{Authorization:'Bearer '+_tok}:{}});
const d=await r.json();
const items=(d&&d.success&&d.data)||[];
const counts={};
items.forEach(it=>{
const href=it.cardHref||PA_HREF_FALLBACK[it.module];
if(!href)return;
counts[href]=(counts[href]||0)+1;
});
Object.keys(counts).forEach(href=>{
const card=root.querySelector(`a.mc[href="${href}"]`);
if(!card||card.querySelector('.mc-badge'))return;
const n=counts[href];
const badge=document.createElement('span');
badge.className='mc-badge';
badge.textContent=n>99?'99+':String(n);
badge.title=`${n} pending`;
card.appendChild(badge);
});
}catch(_e){/* dashboard still works fine without pending counts */}
}
// ─── INIT ────────────────────────────────────────────────
(async()=>{
// Board Members land straight on their analytics dashboard — both right
// after login (the reload comes back through here) and whenever they open
// "/" while logged in. The sidebar still lets them navigate anywhere granted.
if(_tok&&_user&&_user.role==='board_member'){window.location.replace('/board-dashboard');return;}
if(_tok)await loadConfig();
const root=document.getElementById('root');
root.innerHTML='';
if(!_tok||!_user){root.appendChild(buildLogin());}
else{root.appendChild(buildDashboard());}
if(window._injectLucide)window._injectLucide();
})();
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>