3158 lines
216 KiB
HTML
3158 lines
216 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<script src="/auth-guard.js?v=4"></script>
|
||
<script src="/company-list.js"></script>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
|
||
<title>Admin — MITRA ERP Portal</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
|
||
<style>
|
||
:root{
|
||
--ink:#080e18;--ink2:#0d1626;--ink3:#111f35;
|
||
--blue:#1a6fff;--blue2:#0d4fd4;--blue-glow:rgba(26,111,255,.2);
|
||
--amber:#f5a623;--amber2:#d4891b;--green:#0dbb8a;--red:#e53e3e;--purple:#9f5afd;
|
||
--border:#1c2e48;--border2:#243650;
|
||
--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;
|
||
--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;
|
||
--radius:8px;--radius-lg:14px;
|
||
--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;
|
||
--shadow:0 8px 32px rgba(0,0,0,.5);
|
||
}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;overflow-x:hidden;-webkit-font-smoothing:antialiased}
|
||
body::before{content:'';position:fixed;inset:0;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.03'/%3E%3C/svg%3E");opacity:.4;pointer-events:none;z-index:0}
|
||
.bg-glow{position:fixed;inset:0;z-index:0;pointer-events:none;background:radial-gradient(ellipse 60% 50% at 80% 30%,rgba(159,90,253,.06) 0%,transparent 60%)}
|
||
|
||
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 16px;height:54px;display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||
.nav-brand{display:flex;align-items:center;gap:10px;flex-shrink:0}
|
||
.nav-logo{background:var(--purple);color:#fff;font-family:var(--display);font-size:17px;letter-spacing:2px;padding:4px 9px;border-radius:6px}
|
||
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
|
||
.nav-link{padding:5px 10px;border-radius:6px;font-size:11px;font-weight:600;color:var(--text2);cursor:pointer;transition:all .15s;text-decoration:none;white-space:nowrap;border:none;background:none;font-family:var(--font)}
|
||
.nav-link:hover{color:var(--text);background:rgba(255,255,255,.05)}
|
||
.nav-link.active{color:var(--purple);background:rgba(159,90,253,.1)}
|
||
.nav-right{display:flex;align-items:center;gap:6px;flex-shrink:0}
|
||
.btn-sm{padding:5px 10px;border-radius:6px;font-size:11px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;transition:all .15s;font-family:var(--font)}
|
||
.btn-sm:hover{border-color:var(--red);color:var(--red)}
|
||
.role-badge{font-size:10px;font-weight:700;padding:3px 10px;border-radius:10px;background:rgba(159,90,253,.1);border:1px solid rgba(159,90,253,.25);color:var(--purple)}
|
||
|
||
main{position:relative;z-index:1;padding:16px;max-width:1300px;margin:0 auto}
|
||
|
||
/* ACCESS DENIED */
|
||
.access-denied{text-align:center;padding:80px 20px}
|
||
.ad-icon{font-size:52px;margin-bottom:16px}
|
||
.ad-title{font-family:var(--display);font-size:28px;color:var(--red);margin-bottom:8px}
|
||
.ad-msg{font-size:13px;color:var(--text3);max-width:360px;margin:0 auto}
|
||
|
||
/* PAGE HEADER — hero banner */
|
||
.page-header{margin-bottom:18px}
|
||
.admin-hero{position:relative;overflow:hidden;border-radius:var(--radius-lg);border:1px solid var(--border2);
|
||
background:linear-gradient(135deg,#101b38 0%,#181245 48%,#2a1157 100%);
|
||
padding:22px 26px;display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap;box-shadow:var(--shadow)}
|
||
.admin-hero::before{content:'';position:absolute;top:-70px;right:-70px;width:240px;height:240px;border-radius:50%;background:rgba(159,90,253,.13);pointer-events:none}
|
||
.admin-hero::after{content:'';position:absolute;bottom:-90px;right:190px;width:200px;height:200px;border-radius:50%;background:rgba(26,111,255,.08);pointer-events:none}
|
||
.ah-left{display:flex;align-items:center;gap:16px;z-index:1;min-width:0}
|
||
.ah-icon{width:48px;height:48px;border-radius:13px;background:rgba(159,90,253,.16);border:1px solid rgba(159,90,253,.35);display:flex;align-items:center;justify-content:center;font-size:21px;flex-shrink:0}
|
||
.ah-eyebrow{font-size:9.5px;font-weight:700;letter-spacing:1.8px;text-transform:uppercase;color:#9d8bd8;margin-bottom:3px}
|
||
.ah-title{font-family:var(--display);font-size:30px;letter-spacing:1.5px;color:#fff;line-height:1}
|
||
.ah-sub{font-size:11.5px;color:#8fa3c8;margin-top:5px}
|
||
.ah-right{display:flex;align-items:center;gap:10px;z-index:1;flex-wrap:wrap}
|
||
.ah-stat{background:rgba(255,255,255,.055);border:1px solid rgba(255,255,255,.09);border-radius:11px;padding:8px 14px;text-align:center;min-width:72px}
|
||
.ah-stat b{display:block;font-family:var(--mono);font-size:16px;color:#fff;line-height:1.2}
|
||
.ah-stat span{font-size:8.5px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:#8fa3c8}
|
||
.ah-stat.warn b{color:var(--amber)}
|
||
.ah-viewer{display:flex;align-items:center;gap:9px;background:rgba(255,255,255,.055);border:1px solid rgba(255,255,255,.09);border-radius:999px;padding:5px 15px 5px 6px}
|
||
.ah-viewer .av{width:30px;height:30px;border-radius:50%;background:linear-gradient(135deg,var(--purple),var(--blue));color:#fff;font-size:12px;font-weight:800;display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||
.ah-viewer .nm{font-size:11.5px;font-weight:700;color:#fff;line-height:1.15}
|
||
.ah-viewer .rl{font-size:8.5px;font-weight:700;letter-spacing:.7px;text-transform:uppercase;color:#b79aff}
|
||
@media(max-width:640px){.admin-hero{padding:18px}.ah-title{font-size:24px}.ah-stat{min-width:62px;padding:6px 10px}}
|
||
|
||
/* TABS — segmented pills */
|
||
.admin-tabs{display:inline-flex;gap:4px;margin-bottom:20px;background:var(--surface3);border:1px solid var(--border);border-radius:12px;padding:4px;flex-wrap:wrap}
|
||
.admin-tab{padding:8px 16px;font-size:11.5px;font-weight:700;color:var(--text2);cursor:pointer;border:none;background:none;font-family:var(--font);border-radius:9px;transition:all .15s;letter-spacing:.3px;display:flex;align-items:center;gap:7px}
|
||
.admin-tab:hover{color:var(--text);background:rgba(255,255,255,.04)}
|
||
.admin-tab.on{color:#fff;background:linear-gradient(135deg,var(--purple),#7c3aed);box-shadow:0 3px 12px rgba(159,90,253,.35)}
|
||
.admin-tab .tab-count{background:rgba(159,90,253,.15);color:var(--purple);border-radius:8px;padding:1px 7px;font-size:9px;font-weight:800}
|
||
.admin-tab.on .tab-count{background:rgba(255,255,255,.22);color:#fff}
|
||
|
||
/* PORTAL USERS — role chips + card grid */
|
||
.pu-chips{display:flex;gap:6px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--border);background:var(--ink2)}
|
||
.pu-chip{padding:5px 12px;border-radius:999px;font-size:10.5px;font-weight:700;cursor:pointer;border:1px solid var(--border2);background:var(--surface2);color:var(--text2);font-family:var(--font);transition:all .12s;display:flex;align-items:center;gap:6px}
|
||
.pu-chip:hover{color:var(--text);border-color:var(--text3)}
|
||
.pu-chip.on{background:linear-gradient(135deg,var(--purple),#7c3aed);color:#fff;border-color:transparent;box-shadow:0 2px 10px rgba(159,90,253,.3)}
|
||
.pu-chip b{font-family:var(--mono);font-size:9.5px;opacity:.85}
|
||
.pu-chip .cdot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||
.pu-chip.c-act.on{background:linear-gradient(135deg,var(--green),#0a9a72);box-shadow:0 2px 10px rgba(13,187,138,.3)}
|
||
.pu-chip.c-inact.on{background:linear-gradient(135deg,#5b6b85,#3d5470);box-shadow:0 2px 10px rgba(0,0,0,.3)}
|
||
.pu-chips .sep{width:1px;align-self:stretch;background:var(--border2);margin:0 4px}
|
||
.pu-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px;padding:14px}
|
||
.pu-card{background:var(--ink2);border:1px solid var(--border);border-radius:13px;padding:14px;display:flex;flex-direction:column;gap:10px;transition:all .15s;position:relative}
|
||
.pu-card:hover{border-color:var(--border2);transform:translateY(-2px);box-shadow:0 8px 22px rgba(0,0,0,.35)}
|
||
.pu-status{position:absolute;top:13px;right:13px;display:flex;align-items:center;gap:5px;font-size:9px;font-weight:800;letter-spacing:.5px}
|
||
.pu-status .dot{width:7px;height:7px;border-radius:50%}
|
||
.pu-top{display:flex;align-items:center;gap:11px;min-width:0;padding-right:64px}
|
||
.pu-av{width:42px;height:42px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:800;color:#fff;flex-shrink:0}
|
||
.pu-name{font-size:13px;font-weight:700;color:var(--text);line-height:1.25;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||
.pu-user{font-family:var(--mono);font-size:10.5px;color:var(--text3);margin-top:1px}
|
||
.pu-you{font-size:8.5px;background:rgba(26,111,255,.12);color:var(--blue);padding:1px 6px;border-radius:4px;font-weight:700}
|
||
.pu-meta{display:flex;flex-direction:column;gap:5px;font-size:11px;color:var(--text2);border-top:1px solid var(--border);padding-top:9px}
|
||
.pu-meta .row{display:flex;align-items:center;gap:7px;min-width:0}
|
||
.pu-meta .row span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.pu-meta .ic{width:14px;text-align:center;color:var(--text3);flex-shrink:0;font-size:11px}
|
||
.pu-foot{display:flex;gap:6px;margin-top:auto;padding-top:9px;border-top:1px solid var(--border)}
|
||
.pu-btn{flex:1;padding:6px 0;font-size:10.5px;font-weight:700;border-radius:7px;cursor:pointer;font-family:var(--font);transition:all .12s;border:1px solid transparent}
|
||
.pu-btn.edit{background:rgba(26,111,255,.1);color:var(--blue);border-color:rgba(26,111,255,.25)}.pu-btn.edit:hover{background:rgba(26,111,255,.2)}
|
||
.pu-btn.pwd{background:rgba(245,166,35,.1);color:var(--amber);border-color:rgba(245,166,35,.25)}.pu-btn.pwd:hover{background:rgba(245,166,35,.2)}
|
||
.pu-btn.del{flex:0 0 36px;background:rgba(229,62,62,.1);color:var(--red);border-color:rgba(229,62,62,.25)}.pu-btn.del:hover{background:rgba(229,62,62,.2)}
|
||
.pu-locked{font-size:10px;color:var(--text3);text-align:center;flex:1;padding:7px 0;background:var(--surface3);border-radius:7px}
|
||
|
||
/* WIN */
|
||
.win{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;margin-bottom:16px}
|
||
.win-bar{background:var(--surface3);padding:10px 16px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);flex-wrap:wrap;gap:8px}
|
||
.win-title{font-weight:700;font-size:13px;color:var(--text);display:flex;align-items:center;gap:8px}
|
||
.win-actions{display:flex;gap:6px;align-items:center}
|
||
|
||
/* STAT CARDS */
|
||
.stats-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin-bottom:20px}
|
||
@media(min-width:640px){.stats-grid{grid-template-columns:repeat(4,1fr)}}
|
||
.stat-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:16px;position:relative;overflow:hidden}
|
||
.stat-card::after{content:'';position:absolute;top:0;left:0;right:0;height:2px}
|
||
.stat-card.sc-blue::after{background:var(--blue)}.stat-card.sc-amber::after{background:var(--amber)}
|
||
.stat-card.sc-green::after{background:var(--green)}.stat-card.sc-red::after{background:var(--red)}
|
||
.stat-card.sc-purple::after{background:var(--purple)}
|
||
.stat-val{font-family:var(--display);font-size:34px;letter-spacing:1px;color:var(--text);line-height:1}
|
||
.stat-label{font-size:11px;color:var(--text3);margin-top:5px;text-transform:uppercase;letter-spacing:.4px;font-weight:600}
|
||
|
||
/* USER TABLE */
|
||
.user-table{width:100%;border-collapse:collapse;font-size:12px}
|
||
.user-table thead th{background:var(--surface3);padding:8px 12px;text-align:left;font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;border-bottom:1px solid var(--border);white-space:nowrap}
|
||
.user-table tbody tr{transition:background .1s;cursor:pointer}
|
||
.user-table tbody tr:hover td{background:rgba(26,111,255,.04)}
|
||
.user-table td{padding:10px 12px;border-bottom:1px solid rgba(28,46,72,.5);vertical-align:middle}
|
||
.user-table .u-avatar{width:30px;height:30px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;font-family:var(--display);flex-shrink:0}
|
||
.u-av-mgr{background:rgba(245,166,35,.2);color:var(--amber)}
|
||
.u-av-srm{background:rgba(159,90,253,.2);color:var(--purple)}
|
||
.u-av-sap{background:rgba(13,187,138,.2);color:var(--green)}
|
||
.u-name{font-weight:600;color:var(--text);font-size:12.5px}
|
||
.u-username{font-family:var(--mono);font-size:10.5px;color:var(--text3)}
|
||
.user-tbl-actions{display:flex;gap:5px;align-items:center}
|
||
|
||
/* ROLE BADGES */
|
||
.role-pill{font-size:10px;font-weight:700;padding:3px 9px;border-radius:10px;white-space:nowrap}
|
||
.rp-manager{background:rgba(245,166,35,.12);color:var(--amber);border:1px solid rgba(245,166,35,.25)}
|
||
.rp-sr_manager{background:rgba(159,90,253,.12);color:var(--purple);border:1px solid rgba(159,90,253,.25)}
|
||
.rp-sap_adder{background:rgba(13,187,138,.12);color:var(--green);border:1px solid rgba(13,187,138,.25)}
|
||
.rp-user{background:rgba(26,111,255,.12);color:var(--blue);border:1px solid rgba(26,111,255,.25)}
|
||
.rp-admin{background:rgba(220,53,69,.12);color:#dc3545;border:1px solid rgba(220,53,69,.25)}
|
||
.rp-project_approver{background:rgba(124,58,237,.12);color:#7c3aed;border:1px solid rgba(124,58,237,.25)}
|
||
|
||
/* STATUS DOT */
|
||
.status-dot{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:5px}
|
||
.sd-active{background:var(--green);box-shadow:0 0 4px rgba(13,187,138,.5)}
|
||
.sd-inactive{background:var(--text3)}
|
||
|
||
/* BUTTONS */
|
||
.btn{padding:6px 13px;border-radius:6px;border:none;font-family:var(--font);font-size:11px;font-weight:700;cursor:pointer;transition:all .18s;display:inline-flex;align-items:center;gap:5px}
|
||
.btn:disabled{opacity:.45;cursor:not-allowed}
|
||
.b-primary{background:var(--blue);color:#fff}
|
||
.b-primary:hover:not(:disabled){background:var(--blue2);box-shadow:0 3px 12px rgba(26,111,255,.4)}
|
||
.b-purple{background:var(--purple);color:#fff}
|
||
.b-purple:hover:not(:disabled){background:#8b44ef;box-shadow:0 3px 12px rgba(159,90,253,.4)}
|
||
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}
|
||
.b-cancel:hover{border-color:var(--text2);color:var(--text)}
|
||
.b-edit{background:rgba(26,111,255,.1);color:var(--blue);border:1px solid rgba(26,111,255,.25)}
|
||
.b-edit:hover{background:rgba(26,111,255,.2)}
|
||
.b-del{background:rgba(229,62,62,.08);color:var(--red);border:1px solid rgba(229,62,62,.2)}
|
||
.b-del:hover{background:rgba(229,62,62,.18)}
|
||
.b-amber{background:rgba(245,166,35,.1);color:var(--amber);border:1px solid rgba(245,166,35,.25)}
|
||
.b-amber:hover{background:rgba(245,166,35,.2)}
|
||
.b-green{background:rgba(13,187,138,.1);color:var(--green);border:1px solid rgba(13,187,138,.25)}
|
||
.b-green:hover{background:rgba(13,187,138,.2)}
|
||
|
||
/* MODAL */
|
||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:500;display:flex;align-items:flex-start;justify-content:center;padding:16px;overflow-y:auto}
|
||
/* Modal caps at 90% of viewport height and only the body scrolls — header,
|
||
tabs and footer stay pinned in view instead of the whole modal (and its
|
||
Save button) running off-screen on long forms like User Management. */
|
||
.modal{background:var(--ink2);border:1px solid var(--border2);border-radius:var(--radius-lg);width:100%;max-width:500px;max-height:90vh;margin:auto;box-shadow:0 24px 80px rgba(0,0,0,.6);animation:mIn .2s ease;display:flex;flex-direction:column}
|
||
.modal.wide{max-width:700px}
|
||
@keyframes mIn{from{transform:translateY(16px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||
.modal-header{padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
|
||
.modal-title{font-weight:700;font-size:13px;color:var(--text);display:flex;align-items:center;gap:8px}
|
||
.modal-close{width:28px;height:28px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;transition:all .15s}
|
||
.modal-close:hover{background:var(--red);border-color:var(--red);color:#fff}
|
||
.modal-body{padding:18px;overflow-y:auto;flex:1 1 auto;min-height:0}
|
||
.modal-footer{padding:12px 18px;border-top:1px solid var(--border);display:flex;align-items:center;gap:8px;justify-content:flex-end;flex-shrink:0}
|
||
|
||
/* FORM FIELDS */
|
||
.form-group{margin-bottom:14px}
|
||
.form-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);margin-bottom:6px;display:flex;align-items:center;gap:4px}
|
||
.form-label .req{color:var(--red)}
|
||
.form-input{width:100%;padding:9px 12px;border:1.5px solid var(--border2);border-radius:var(--radius);font-size:12.5px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;transition:border .15s,box-shadow .15s;height:36px}
|
||
.form-input:focus{border-color:var(--blue);box-shadow:0 0 0 2px var(--blue-glow)}
|
||
.form-input::placeholder{color:var(--text3)}
|
||
.form-select{width:100%;padding:0 30px 0 12px;border:1.5px solid var(--border2);border-radius:var(--radius);font-size:12.5px;font-family:var(--font);background:var(--surface2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5 4.5-5z' fill='%233d5470'/%3E%3C/svg%3E") no-repeat right 10px center;color:var(--text);outline:none;height:36px;cursor:pointer;appearance:none}
|
||
.form-select:focus{border-color:var(--blue)}
|
||
.form-select option{background:var(--surface2)}
|
||
.form-err{font-size:11px;color:var(--red);margin-top:4px}
|
||
.form-hint{font-size:10.5px;color:var(--text3);margin-top:3px}
|
||
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||
@media(max-width:480px){.form-grid{grid-template-columns:1fr}}
|
||
|
||
/* ROLE INFO CARDS */
|
||
.role-info-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:18px}
|
||
@media(max-width:600px){.role-info-grid{grid-template-columns:1fr}}
|
||
.role-info-card{border:1.5px solid var(--border);border-radius:var(--radius);padding:12px 14px;cursor:pointer;transition:all .15s;background:var(--surface)}
|
||
.role-info-card:hover{border-color:var(--border2)}
|
||
.role-info-card.selected-mgr{border-color:var(--amber);background:rgba(245,166,35,.06)}
|
||
.role-info-card.selected-srm{border-color:var(--purple);background:rgba(159,90,253,.06)}
|
||
.role-info-card.selected-sap{border-color:var(--green);background:rgba(13,187,138,.06)}
|
||
.role-info-card.selected-user{border-color:var(--blue);background:rgba(26,111,255,.06)}
|
||
.ric-icon{font-size:22px;margin-bottom:6px}
|
||
.ric-title{font-size:12px;font-weight:700;color:var(--text);margin-bottom:3px}
|
||
.ric-desc{font-size:10.5px;color:var(--text3);line-height:1.4}
|
||
.ric-check{display:none;font-size:14px;float:right;margin-top:-28px}
|
||
.role-info-card.selected-proj{border-color:#7c3aed;background:rgba(124,58,237,.06)}
|
||
.role-info-card.selected-mgr .ric-check,.role-info-card.selected-srm .ric-check,.role-info-card.selected-sap .ric-check,.role-info-card.selected-user .ric-check,.role-info-card.selected-proj .ric-check{display:block}
|
||
|
||
/* USER MODAL — TABS */
|
||
.uc-tabs{display:flex;flex-wrap:wrap;row-gap:2px;gap:2px;padding:0 18px;border-bottom:1px solid var(--border);background:var(--surface3);flex-shrink:0}
|
||
.uc-tab{padding:11px 4px;margin-right:22px;font-size:11px;font-weight:700;letter-spacing:.3px;text-transform:uppercase;color:var(--text3);background:none;border:none;border-bottom:2px solid transparent;cursor:pointer;font-family:var(--font);transition:all .15s;display:flex;align-items:center;gap:6px}
|
||
.uc-tab:hover{color:var(--text2)}
|
||
.uc-tab.on{color:var(--purple);border-bottom-color:var(--purple)}
|
||
.uc-tab .uc-tab-count{font-size:9px;font-weight:800;padding:1px 6px;border-radius:8px;background:var(--surface2);color:var(--text3)}
|
||
.uc-tab.on .uc-tab-count{background:rgba(159,90,253,.15);color:var(--purple)}
|
||
.uc-pane{display:none}
|
||
.uc-pane.on{display:block}
|
||
|
||
/* USER MODAL — permission card groups (Module Access / Approval Steps) */
|
||
.perm-groups{display:grid;grid-template-columns:1fr;gap:10px}
|
||
.perm-flatrow{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:2px}
|
||
.perm-group{border:1px solid var(--border2);border-radius:var(--radius);overflow:hidden;background:var(--surface)}
|
||
.perm-group-hd{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--surface3);border-bottom:1px solid var(--border)}
|
||
.perm-group-title{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;color:var(--text2)}
|
||
.perm-group-toggle{font-size:9.5px;font-weight:700;color:var(--text3);background:none;border:none;cursor:pointer;padding:2px 6px;border-radius:4px;font-family:var(--font)}
|
||
.perm-group-toggle:hover{color:var(--purple);background:rgba(159,90,253,.08)}
|
||
.perm-group-body{display:flex;flex-wrap:wrap;gap:6px;padding:10px 12px}
|
||
.perm-pill{display:flex;align-items:center;gap:6px;padding:6px 11px;border:1.5px solid var(--border2);border-radius:20px;cursor:pointer;font-size:11px;font-weight:600;color:var(--text3);background:var(--surface2);transition:all .12s}
|
||
.perm-pill:hover{border-color:var(--text3)}
|
||
.perm-pill input{accent-color:var(--amber)}
|
||
.perm-pill.pill-on{border-color:var(--amber);background:rgba(245,166,35,.08);color:var(--amber)}
|
||
.perm-pill.pill-step input{accent-color:var(--green)}
|
||
.perm-pill.pill-step.pill-on{border-color:var(--green);background:rgba(13,187,138,.08);color:var(--green)}
|
||
.perm-group-body.step-body{flex-direction:column;gap:5px}
|
||
.step-row{display:flex;flex-direction:column;gap:8px;padding:8px 10px;border:1px solid var(--border2);border-radius:8px;background:var(--surface2);width:100%}
|
||
.step-row-main{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
|
||
.step-row-label{font-size:11px;font-weight:600;color:var(--text2);flex:1;min-width:160px}
|
||
.step-perms{display:flex;gap:8px;flex-shrink:0}
|
||
.step-perm-cb{display:flex;align-items:center;gap:3px;font-size:9.5px;font-weight:700;color:var(--text3);cursor:pointer;text-transform:uppercase;letter-spacing:.3px;white-space:nowrap}
|
||
.step-perm-cb input{accent-color:var(--green)}
|
||
.step-perm-cb.on{color:var(--green)}
|
||
.step-row-extra{border-top:1px solid var(--border2);padding-top:8px;margin-top:2px}
|
||
.uc-section-note{font-size:11px;color:var(--text3);margin-bottom:12px;line-height:1.5}
|
||
.uc-custom-box{margin-top:12px;padding:12px;border:1px dashed var(--border2);border-radius:var(--radius);background:var(--surface3)}
|
||
.uc-custom-box .ucb-title{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);margin-bottom:8px;display:flex;align-items:center;gap:6px}
|
||
|
||
/* BOM REQUEST TABLE */
|
||
.req-table{width:100%;border-collapse:collapse;font-size:11.5px}
|
||
.req-table thead th{background:var(--surface3);padding:7px 10px;text-align:left;font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;border-bottom:1px solid var(--border);white-space:nowrap}
|
||
.req-table tbody tr{cursor:pointer;transition:background .1s}
|
||
.req-table tbody tr:hover td{background:rgba(26,111,255,.04)}
|
||
.req-table td{padding:8px 10px;border-bottom:1px solid rgba(28,46,72,.5);vertical-align:middle}
|
||
|
||
/* STATUS BADGES */
|
||
.status-badge{font-size:10px;font-weight:700;padding:2px 8px;border-radius:8px;white-space:nowrap}
|
||
.sb-PENDING{background:rgba(26,111,255,.15);color:var(--blue);border:1px solid rgba(26,111,255,.2)}
|
||
.sb-L1_APPROVED{background:rgba(245,166,35,.15);color:var(--amber);border:1px solid rgba(245,166,35,.2)}
|
||
.sb-L2_APPROVED{background:rgba(159,90,253,.15);color:var(--purple);border:1px solid rgba(159,90,253,.2)}
|
||
.sb-L3_APPROVED{background:rgba(245,100,35,.15);color:#f5641a;border:1px solid rgba(245,100,35,.2)}
|
||
.sb-SAP_PUSHED{background:rgba(13,187,138,.15);color:var(--green);border:1px solid rgba(13,187,138,.2)}
|
||
.sb-REJECTED{background:rgba(229,62,62,.15);color:var(--red);border:1px solid rgba(229,62,62,.2)}
|
||
.sb-CANCELLED{background:rgba(61,84,112,.15);color:var(--text3);border:1px solid rgba(61,84,112,.2)}
|
||
|
||
/* ALERTS */
|
||
.alert{padding:10px 14px;border-radius:var(--radius);font-size:12px;margin-bottom:12px;display:flex;align-items:flex-start;gap:8px;line-height:1.5}
|
||
.al-info{background:rgba(26,111,255,.08);border:1px solid rgba(26,111,255,.2);color:#60a5fa}
|
||
.al-amber{background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.2);color:#fcd34d}
|
||
.al-red{background:rgba(229,62,62,.08);border:1px solid rgba(229,62,62,.2);color:#fc8181}
|
||
|
||
/* SEARCH */
|
||
.search-wrap{position:relative;display:flex;align-items:center}
|
||
.search-input{padding:6px 12px 6px 32px;border:1.5px solid var(--border2);border-radius:6px;font-size:12px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;width:200px;transition:border .15s}
|
||
.search-input:focus{border-color:var(--blue);width:240px}
|
||
.set-row{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:14px 0;border-bottom:1px solid var(--border)}
|
||
.set-row.set-col{flex-direction:column;align-items:stretch;gap:12px}
|
||
.set-row:last-of-type{border-bottom:none}
|
||
.set-info{flex:1}
|
||
.set-l{font-size:13px;font-weight:600;color:var(--text)}
|
||
.set-h{font-size:11px;color:var(--text3);margin-top:2px}
|
||
/* Professional checklist */
|
||
.chk-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||
.chk-count{font-size:11px;font-weight:700;color:var(--blue);background:var(--blue-glow);padding:2px 9px;border-radius:20px}
|
||
.chk-tools{display:flex;gap:6px}
|
||
.chk-mini{font-size:10.5px;font-weight:700;color:var(--text2);background:var(--surface2);border:1px solid var(--border2);border-radius:6px;padding:4px 10px;cursor:pointer;font-family:var(--font);transition:all .15s}
|
||
.chk-mini:hover{color:var(--blue);border-color:var(--blue)}
|
||
.chk-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(168px,1fr));gap:8px}
|
||
.chk-item{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1.5px solid var(--border2);border-radius:9px;background:var(--surface2);cursor:pointer;transition:all .15s;user-select:none}
|
||
.chk-item:hover{border-color:#3a5580}
|
||
.chk-item input{position:absolute;opacity:0;pointer-events:none}
|
||
.chk-box{width:19px;height:19px;border-radius:5px;border:1.5px solid var(--border2);background:var(--ink2);flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:all .15s}
|
||
.chk-box svg{width:12px;height:12px;stroke:#fff;stroke-width:3.2;fill:none;stroke-linecap:round;stroke-linejoin:round;opacity:0;transform:scale(.5);transition:all .15s}
|
||
.chk-label{font-size:12.5px;font-weight:600;color:var(--text2);transition:color .15s}
|
||
.chk-item:has(input:checked){border-color:var(--blue);background:rgba(26,111,255,.08)}
|
||
.chk-item:has(input:checked) .chk-box{background:var(--blue);border-color:var(--blue)}
|
||
.chk-item:has(input:checked) .chk-box svg{opacity:1;transform:scale(1)}
|
||
.chk-item:has(input:checked) .chk-label{color:var(--text)}
|
||
.search-icon{position:absolute;left:10px;color:var(--text3);font-size:13px;pointer-events:none}
|
||
|
||
/* EMPTY */
|
||
.empty-state{padding:40px 20px;text-align:center;color:var(--text3)}
|
||
.empty-state .es-icon{font-size:36px;margin-bottom:10px;opacity:.5}
|
||
|
||
/* SP */
|
||
.sp{width:12px;height:12px;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:spin .65s linear infinite;display:inline-block;flex-shrink:0}
|
||
.sp-purple{border-color:rgba(159,90,253,.25);border-top-color:var(--purple)}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
|
||
/* TOAST */
|
||
.toast-c{position:fixed;top:62px;right:14px;z-index:9999;display:flex;flex-direction:column;gap:6px;pointer-events:none}
|
||
.toast{padding:9px 13px;border-radius:var(--radius);font-size:12px;font-weight:600;box-shadow:var(--shadow);animation:sIn .22s ease;display:flex;align-items:center;gap:7px;max-width:300px;pointer-events:auto}
|
||
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}.t-warn{background:var(--amber);color:#fff}
|
||
@keyframes sIn{from{transform:translateX(110%);opacity:0}to{transform:translateX(0);opacity:1}}
|
||
|
||
/* CONFIRM DIALOG */
|
||
.confirm-overlay{position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:600;display:flex;align-items:center;justify-content:center;padding:16px}
|
||
.confirm-box{background:var(--ink2);border:1px solid var(--border2);border-radius:var(--radius-lg);padding:24px;max-width:400px;width:100%;text-align:center;box-shadow:0 24px 80px rgba(0,0,0,.6);animation:mIn .2s ease}
|
||
.confirm-icon{font-size:40px;margin-bottom:12px}
|
||
.confirm-title{font-size:15px;font-weight:700;color:var(--text);margin-bottom:6px}
|
||
.confirm-msg{font-size:12px;color:var(--text3);margin-bottom:20px;line-height:1.5}
|
||
.confirm-btns{display:flex;gap:8px;justify-content:center}
|
||
</style>
|
||
<link rel="stylesheet" href="/theme.css?v=3"/>
|
||
</head>
|
||
<body>
|
||
<div class="bg-glow"></div>
|
||
<div class="toast-c" id="toast-c"></div>
|
||
|
||
<nav>
|
||
<div class="nav-brand"><span class="nav-logo">ADM</span></div>
|
||
<div class="nav-links">
|
||
<a href="/" class="nav-link">⌂ Home</a>
|
||
<a href="/bom" class="nav-link">⊞ BOM</a>
|
||
<a href="/approvals" class="nav-link">Approvals</a>
|
||
<a href="/register" class="nav-link">◧ Customers</a>
|
||
<a href="/admin" class="nav-link active">⚙ Admin</a>
|
||
</div>
|
||
<div class="nav-right">
|
||
<span class="role-badge" id="role-badge">ADMIN</span>
|
||
<button class="btn-sm" id="btn-logout">Logout</button>
|
||
</div>
|
||
</nav>
|
||
|
||
<main id="main-area">
|
||
<div style="padding:60px;text-align:center;color:var(--text3)">
|
||
<span class="sp sp-purple"></span>
|
||
<div style="margin-top:12px">Loading admin panel…</div>
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
'use strict';
|
||
|
||
// ════════════════════════════════════════
|
||
// STATE
|
||
// ════════════════════════════════════════
|
||
let _tok = null, _user = null, _cfg = null;
|
||
let _users = [], _bomRequests = [], _customerStats = {};
|
||
let _pwResets = [];
|
||
let _settings = null; // System Settings (was .env) — services/appSettingsStore.js
|
||
let _activeTab = 'overview';
|
||
let _userSearch = '';
|
||
let _userRoleFilter = ''; // '' = all roles — set by the Portal Users role chips
|
||
let _userStatusFilter = 'active'; // 'active' by DEFAULT — inactive users are hidden until the Inactive chip is clicked ('' = show both, by clicking the selected chip again)
|
||
let _reqFilter = 'ALL';
|
||
|
||
(function(){
|
||
try {
|
||
_tok = sessionStorage.getItem('portal_token') || null;
|
||
const u = sessionStorage.getItem('portal_user');
|
||
if (u) _user = JSON.parse(u);
|
||
} catch {}
|
||
})();
|
||
|
||
if (!_tok) { window.location.href = '/'; }
|
||
|
||
document.getElementById('btn-logout').onclick = () => {
|
||
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 = '/';
|
||
};
|
||
|
||
const BACKEND = '/api';
|
||
|
||
// ════════════════════════════════════════
|
||
// 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) { window.location.href = '/'; 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 t-${type}`;
|
||
el.textContent = (type === 'ok' ? '✓ ' : type === 'err' ? '✗ ' : '⚠ ') + msg;
|
||
tc.appendChild(el);
|
||
setTimeout(() => el.remove(), 4500);
|
||
}
|
||
|
||
// Groups with `items` render as an expandable sub-module box (page-level
|
||
// access); groups with `flat` render as a single checkbox (whole-module
|
||
// access). Matches the per-page module keys in public/sidebar.js.
|
||
const MODULE_GROUPS=[
|
||
{flat:true,k:'bom',l:'BOM'},
|
||
{flat:true,k:'items',l:'Items'},
|
||
{group:'Production',items:[
|
||
{k:'production-dashboard',l:'Dashboard'},{k:'production-create',l:'Create Order'},{k:'production-issue',l:'Issue'},
|
||
{k:'production-receipt',l:'Receipt'},{k:'work-order-verify',l:'Verify Work Order'},{k:'production-verify',l:'Verify (Production Order — legacy)'},{k:'production-close',l:'Close'},
|
||
{k:'production-requirements',l:'Requirements'},{k:'production-batch-issuance',l:'Batch Issuance'},
|
||
{k:'production-work-order',l:'Work Order'},{k:'man-power',l:'Man Power'},{k:'oee',l:'OEE'},{k:'downtime-analysis',l:'Downtime Analysis'},
|
||
{k:'rejection-register',l:'Rejection Register'},{k:'rejection-analytics',l:'Rejection Analytics'},
|
||
{k:'production-planning',l:'Production Planning'},{k:'production-deviations',l:'Deviations'},
|
||
]},
|
||
{group:'Purchase',items:[
|
||
{k:'purchase-request',l:'Purchase Request'},{k:'purchase-quotation',l:'Quotation'},
|
||
{k:'purchase-order',l:'Purchase Order'},{k:'purchase-grpo',l:'Goods Receipt PO'},
|
||
]},
|
||
{group:'Sales',items:[
|
||
{k:'sales-orders',l:'Sales Orders'},{k:'sales-samples',l:'Sample Requests'},{k:'sales-reports',l:'Sales Reports'},{k:'sales-admin',l:'Sales Admin'},
|
||
]},
|
||
{flat:true,k:'approvals',l:'Approvals'},
|
||
{flat:true,k:'sap-approvals',l:'SAP Approvals'},
|
||
{flat:true,k:'customers',l:'Customers'},
|
||
{flat:true,k:'vendors',l:'Vendors'},
|
||
{flat:true,k:'budget',l:'Budget'},
|
||
{flat:true,k:'documents',l:'Documents'},
|
||
{group:'Projects',items:[
|
||
{k:'projects-new',l:'New Project'},{k:'projects-view',l:'View Projects'},{k:'projects-approvals',l:'Project Approvals'},
|
||
]},
|
||
{group:'Finance',items:[
|
||
{k:'finance-monthly',l:'Monthly Accounts'},{k:'finance-comparison',l:'P&L Comparison'},
|
||
{k:'finance-costsheet',l:'Cost Sheet'},{k:'finance-balancesheet',l:'Balance Sheet'},{k:'finance-cashflow',l:'Cash Flow'},
|
||
]},
|
||
{flat:true,k:'salary',l:'Salary'},
|
||
{flat:true,k:'reports',l:'Reports'},
|
||
{flat:true,k:'ppc-report',l:'PPC Report'},
|
||
{flat:true,k:'pwo-source-audit',l:'PWO Source Report'},
|
||
{flat:true,k:'inventory-status-report',l:'Inventory Status Report'},
|
||
{flat:true,k:'batch-no-transaction',l:'Batch No. Transaction Report'},
|
||
{flat:true,k:'inventory-transfer',l:'Inventory Transfer'},
|
||
{flat:true,k:'inventory-posting-list',l:'Inventory Posting List'},
|
||
{flat:true,k:'gstr1',l:'GSTR-1'},
|
||
{flat:true,k:'gstr2',l:'GSTR-2 Purchase'},
|
||
{flat:true,k:'itc04',l:'ITC-04 Job Work'},
|
||
{flat:true,k:'business-master',l:'Business Master Data'},
|
||
{flat:true,k:'admin',l:'Admin'},
|
||
{flat:true,k:'audit',l:'Audit Logs'},
|
||
{flat:true,k:'mail_log',l:'Mail Log'},
|
||
{flat:true,k:'item-group-classification',l:'Item Group Rules'},
|
||
{flat:true,k:'board-dashboard',l:'Board Dashboard'},
|
||
];
|
||
// Old broad key ('production' etc) — pre-dates the sub-module split — implies
|
||
// ALL of that group's new sub-keys are checked, so nothing looks silently
|
||
// revoked in the UI (and re-saving migrates it forward to explicit sub-keys).
|
||
const LEGACY_BROAD_MODULES={production:'production-',purchase:'purchase-',costing:'finance-',projects:'projects-'};
|
||
function moduleChecked(mods,k){
|
||
if(mods.includes(k))return true;
|
||
for(const b in LEGACY_BROAD_MODULES){if(mods.includes(b)&&k.indexOf(LEGACY_BROAD_MODULES[b])===0)return true;}
|
||
return false;
|
||
}
|
||
function moduleCb(mods,k,l){
|
||
const c=moduleChecked(mods,k);
|
||
return '<label class="perm-pill'+(c?' pill-on':'')+'"><input type="checkbox" class="uc-mod-cb" value="'+k+'" '+(c?'checked':'')+'/> '+l+'</label>';
|
||
}
|
||
// Sub-admins may only hand out access they themselves hold — the pickers are
|
||
// filtered down to their own modules/steps (plus whatever the edited user
|
||
// already has, so nothing existing is silently dropped on save). The server
|
||
// enforces the same rule, so this is UX, not the security boundary.
|
||
function subAdminAllowedModules(target){
|
||
if(_user?.role!=='system_admin')return null; // null = unrestricted
|
||
const s=new Set(Array.isArray(_user?.modules)?_user.modules:[]);
|
||
(target&&Array.isArray(target.modules)?target.modules:[]).forEach(m=>s.add(m));
|
||
return s;
|
||
}
|
||
function subAdminAllowedSteps(target){
|
||
if(_user?.role!=='system_admin')return null;
|
||
const key=x=>typeof x==='string'?x:(x&&x.step);
|
||
const s=new Set((Array.isArray(_user?.approvalSteps)?_user.approvalSteps:[]).map(key).filter(Boolean));
|
||
(target&&Array.isArray(target.approvalSteps)?target.approvalSteps:[]).map(key).filter(Boolean).forEach(k=>s.add(k));
|
||
return s;
|
||
}
|
||
const SUBADMIN_GRANT_NOTE='<div style="font-size:10.5px;color:var(--amber);background:rgba(245,166,35,.07);border:1px solid rgba(245,166,35,.2);border-radius:7px;padding:7px 10px;margin-bottom:10px">As a System Admin you can grant only the access assigned to you — options outside your own assignment are hidden.</div>';
|
||
|
||
function buildModuleCheckboxes(user){
|
||
const mods=user&&Array.isArray(user.modules)?user.modules:[];
|
||
const allow=subAdminAllowedModules(user);
|
||
const flats=MODULE_GROUPS.filter(g=>g.flat&&(!allow||allow.has(g.k))).map(function(g){return moduleCb(mods,g.k,g.l);}).join('');
|
||
const groups=MODULE_GROUPS.filter(g=>!g.flat).map(function(g){
|
||
const items=g.items.filter(it=>!allow||allow.has(it.k));
|
||
if(!items.length)return '';
|
||
const inner=items.map(function(it){return moduleCb(mods,it.k,it.l);}).join('');
|
||
return '<div class="perm-group" data-perm-group>'
|
||
+'<div class="perm-group-hd"><span class="perm-group-title">'+esc(g.group)+'</span>'
|
||
+'<button type="button" class="perm-group-toggle" data-toggle-group>Select all</button></div>'
|
||
+'<div class="perm-group-body">'+inner+'</div></div>';
|
||
}).filter(Boolean).join('');
|
||
const note=allow?SUBADMIN_GRANT_NOTE:'';
|
||
if(allow&&!flats&&!groups)return note+'<div style="font-size:11px;color:var(--text3)">You have no modules assigned, so there is nothing you can grant.</div>';
|
||
return note+'<div class="perm-flatrow">'+flats+'</div><div class="perm-groups">'+groups+'</div>';
|
||
}
|
||
|
||
// ── Approval Steps (generic, admin-extensible registry) ─────────────────────
|
||
let APPROVAL_STEP_GROUPS=[];
|
||
async function loadApprovalSteps(){
|
||
try{ const r=await api('GET','/approval-steps'); APPROVAL_STEP_GROUPS=r.data||[]; }
|
||
catch(e){ console.warn('Approval steps load failed:', e.message); }
|
||
}
|
||
// SAP Item Groups — for the per-user "Issue Items" restriction checklist.
|
||
let ITEM_GROUPS=[];
|
||
async function loadItemGroups(){
|
||
try{ const r=await api('GET','/sap/lookup/item-groups'); ITEM_GROUPS=(r.data||[]).map(g=>({code:String(g.code),name:g.name||String(g.code)})); }
|
||
catch(e){ console.warn('Item groups load failed:', e.message); }
|
||
}
|
||
// Man Power tabs — for the per-user "Man Power" tab-permission checklist.
|
||
let MANPOWER_TABS=[];
|
||
async function loadManpowerTabs(){
|
||
try{ const r=await api('GET','/manpower/tabs'); MANPOWER_TABS=(r.data||[]).map(t=>({key:String(t.key),label:t.label||String(t.key)})); }
|
||
catch(e){ console.warn('Man Power tabs load failed:', e.message); }
|
||
}
|
||
// Checklist of Man Power tabs; pre-checks the user's saved manpowerTabs.
|
||
function buildManpowerTabCheckboxes(user){
|
||
const sel=new Set((user&&Array.isArray(user.manpowerTabs)?user.manpowerTabs:[]).map(String));
|
||
if(!MANPOWER_TABS.length)return '<div style="font-size:11px;color:var(--text3);padding:6px">Man Power tabs not loaded.</div>';
|
||
return MANPOWER_TABS.map(t=>`<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text2);padding:6px 8px;border-radius:6px;cursor:pointer">
|
||
<input type="checkbox" class="uc-mptab-cb" value="${esc(t.key)}" ${sel.has(t.key)?'checked':''} style="accent-color:var(--purple)"/> ${esc(t.label)}
|
||
</label>`).join('');
|
||
}
|
||
// OEE forms — for the per-user "OEE" tab-permission checklist.
|
||
let OEE_TABS=[];
|
||
async function loadOeeTabs(){
|
||
try{ const r=await api('GET','/oee/tabs'); OEE_TABS=(r.data||[]).map(t=>({key:String(t.key),label:t.label||String(t.key),ready:!!t.ready})); }
|
||
catch(e){ console.warn('OEE tabs load failed:', e.message); }
|
||
}
|
||
// Checklist of OEE forms; pre-checks the user's saved oeeTabs.
|
||
function buildOeeTabCheckboxes(user){
|
||
const sel=new Set((user&&Array.isArray(user.oeeTabs)?user.oeeTabs:[]).map(String));
|
||
if(!OEE_TABS.length)return '<div style="font-size:11px;color:var(--text3);padding:6px">OEE forms not loaded.</div>';
|
||
return OEE_TABS.map(t=>`<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text2);padding:6px 8px;border-radius:6px;cursor:pointer">
|
||
<input type="checkbox" class="uc-oeetab-cb" value="${esc(t.key)}" ${sel.has(t.key)?'checked':''} style="accent-color:var(--purple)"/> ${esc(t.label)}${t.ready?'':' <span style="font-size:9px;color:var(--text3)">(coming soon)</span>'}
|
||
</label>`).join('');
|
||
}
|
||
// SAP companies — for the per-user "Displayed SAP Company" checklist. Uses
|
||
// the same live company-scan the rest of the app uses (window.fetchCompanyList,
|
||
// backed by GET /api/companies), not a hardcoded list.
|
||
let ALL_COMPANIES=[];
|
||
async function loadAllCompanies(){
|
||
try{ ALL_COMPANIES=await window.fetchCompanyList(); }
|
||
catch(e){ console.warn('Company list load failed:', e.message); }
|
||
}
|
||
// SAP Departments (OUDP) — for the per-user "PR Departments" restriction
|
||
// checklist (Purchase Request's Department field).
|
||
let PR_DEPARTMENTS=[];
|
||
async function loadPrDepartments(){
|
||
try{ const r=await api('GET','/sap/lookup/departments'); PR_DEPARTMENTS=(r.data||[]).map(d=>({code:String(d.code),name:d.name||String(d.code)})); }
|
||
catch(e){ console.warn('Departments load failed:', e.message); }
|
||
}
|
||
// Rejection Register Stage master — for the per-user Stage allow-list
|
||
// nested under the rejection_register:entry approval step (see stepRow's
|
||
// extraHtml). Empty selection = unrestricted (every active Stage), same
|
||
// convention as allowedCompanies/allowedDepartments.
|
||
let REJECTION_STAGES=[];
|
||
let _rejectionStagesLoadFailed=false;
|
||
async function loadRejectionStages(){
|
||
try{ const r=await api('GET','/rejection-register/stages'); REJECTION_STAGES=(r.data||[]).filter(s=>s.active!==false); _rejectionStagesLoadFailed=false; }
|
||
catch(e){ console.warn('Rejection stages load failed:', e.message); _rejectionStagesLoadFailed=true; }
|
||
}
|
||
// Checklist of Rejection Register Stages; pre-checks whatever `stages` array
|
||
// already sits on this user's rejection_register:entry step entry (if any).
|
||
function buildRejectionStageCheckboxes(steps){
|
||
const entry=(steps||[]).find(s=>s&&typeof s==='object'&&s.step==='rejection_register:entry');
|
||
const sel=new Set(entry&&Array.isArray(entry.stages)?entry.stages:[]);
|
||
if(!REJECTION_STAGES.length)return _rejectionStagesLoadFailed
|
||
?'<div style="font-size:10.5px;color:var(--red)">Couldn\'t load Stages — retry by reopening this modal.</div>'
|
||
:'<div style="font-size:10.5px;color:var(--text3)">No Stages configured yet (Rejection Analytics → Root Cause Setup).</div>';
|
||
return '<div style="font-size:10px;color:var(--text3);margin-bottom:6px">Restrict which Stage(s) this person can record rejections for — leave all unchecked for no restriction (every Stage):</div>'
|
||
+'<div style="display:flex;flex-wrap:wrap;gap:6px">'
|
||
+REJECTION_STAGES.map(s=>`<label style="display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--text2);padding:4px 9px;border:1px solid var(--border2);border-radius:999px;background:var(--surface3);cursor:pointer">
|
||
<input type="checkbox" class="uc-rrstage-cb" value="${esc(s.v)}" ${sel.has(s.v)?'checked':''} style="accent-color:var(--purple)"/> ${esc(s.label)}
|
||
</label>`).join('')
|
||
+'</div>';
|
||
}
|
||
// Checklist of SAP Departments; pre-checks the user's saved allowedDepartments.
|
||
function buildPrDepartmentCheckboxes(user){
|
||
const sel=new Set((user&&Array.isArray(user.allowedDepartments)?user.allowedDepartments:[]).map(String));
|
||
if(!PR_DEPARTMENTS.length)return '<div style="font-size:11px;color:var(--text3);padding:6px">Departments not loaded (SAP unreachable?).</div>';
|
||
return PR_DEPARTMENTS.map(d=>`<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text2);padding:6px 8px;border-radius:6px;cursor:pointer">
|
||
<input type="checkbox" class="uc-prdept-cb" value="${esc(d.name)}" ${sel.has(d.name)?'checked':''} style="accent-color:var(--purple)"/> ${esc(d.name)}
|
||
</label>`).join('');
|
||
}
|
||
// Checklist of SAP companies; pre-checks the user's saved allowedCompanies.
|
||
// Empty selection = no restriction (sees every company).
|
||
function buildCompanyCheckboxes(user){
|
||
const sel=new Set((user&&Array.isArray(user.allowedCompanies)?user.allowedCompanies:[]).map(String));
|
||
if(!ALL_COMPANIES.length)return '<div style="font-size:11px;color:var(--text3);padding:6px">Company list not loaded (SQL Server unreachable?).</div>';
|
||
return ALL_COMPANIES.map(c=>`<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text2);padding:6px 8px;border-radius:6px;cursor:pointer">
|
||
<input type="checkbox" class="uc-company-cb" value="${esc(c.value)}" ${sel.has(c.value)?'checked':''} style="accent-color:var(--purple)"/>
|
||
<span style="font-family:var(--mono);color:var(--text3);font-size:10px">${esc(c.value)}</span> ${esc(c.label||c.value)}
|
||
</label>`).join('');
|
||
}
|
||
// SAP Approval document types — ObjectType codes, matching
|
||
// public/sap-approvals.html's OBJ_MAP/docTypeOpts exactly (kept in sync
|
||
// manually; there is no live SAP lookup for "every object type an Approval
|
||
// Procedure could target").
|
||
const SAP_APPROVAL_TYPE_OPTS=[
|
||
{v:'1470000113',l:'Purchase Request'},{v:'22',l:'Purchase Order'},{v:'20',l:'Goods Receipt PO'},
|
||
{v:'18',l:'AP Invoice'},{v:'19',l:'AP Credit Note'},{v:'21',l:'Goods Return'},
|
||
{v:'13',l:'AR Invoice'},{v:'14',l:'AR Credit Memo'},{v:'16',l:'Return'},
|
||
{v:'17',l:'Sales Order'},{v:'15',l:'Delivery'},{v:'202',l:'Production Order'},
|
||
{v:'59',l:'Goods Issue'},{v:'60',l:'Goods Receipt'},{v:'46',l:'Blanket Agreement'},
|
||
{v:'67',l:'Inventory Transfer Request'},
|
||
];
|
||
// Checklist of SAP Approval document types; pre-checks the user's saved sapApprovalTypes.
|
||
function buildSapApprovalTypeCheckboxes(user){
|
||
const sel=new Set((user&&Array.isArray(user.sapApprovalTypes)?user.sapApprovalTypes:[]).map(String));
|
||
return SAP_APPROVAL_TYPE_OPTS.map(o=>`<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text2);padding:6px 8px;border-radius:6px;cursor:pointer">
|
||
<input type="checkbox" class="uc-sapappr-cb" value="${esc(o.v)}" ${sel.has(o.v)?'checked':''} style="accent-color:var(--purple)"/>
|
||
${esc(o.l)}
|
||
</label>`).join('');
|
||
}
|
||
// Checklist of item groups; pre-checks the user's saved issueItemGroups.
|
||
function buildIssueGroupCheckboxes(user){
|
||
const sel=new Set((user&&Array.isArray(user.issueItemGroups)?user.issueItemGroups:[]).map(String));
|
||
if(!ITEM_GROUPS.length)return '<div style="font-size:11px;color:var(--text3);padding:6px">Item groups not loaded (SAP unreachable?).</div>';
|
||
return ITEM_GROUPS.map(g=>`<label class="uc-ig-item" data-ig-label="${esc((g.code+' '+g.name).toLowerCase())}" style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text2);padding:5px 6px;border-radius:6px;cursor:pointer">
|
||
<input type="checkbox" class="uc-issue-cb" value="${esc(g.code)}" ${sel.has(g.code)?'checked':''} style="accent-color:var(--purple)"/>
|
||
<span style="font-family:var(--mono);color:var(--text3);font-size:10px">${esc(g.code)}</span> ${esc(g.name)}
|
||
</label>`).join('');
|
||
}
|
||
// Checklist of item groups for Production Order — Manual Entry; pre-checks
|
||
// the user's saved manualPoItemGroups. Separate list from Issue Items above
|
||
// — a different action, can have a different scope.
|
||
function buildManualPoGroupCheckboxes(user){
|
||
const sel=new Set((user&&Array.isArray(user.manualPoItemGroups)?user.manualPoItemGroups:[]).map(String));
|
||
if(!ITEM_GROUPS.length)return '<div style="font-size:11px;color:var(--text3);padding:6px">Item groups not loaded (SAP unreachable?).</div>';
|
||
return ITEM_GROUPS.map(g=>`<label class="uc-mpg-item" data-mpg-label="${esc((g.code+' '+g.name).toLowerCase())}" style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text2);padding:5px 6px;border-radius:6px;cursor:pointer">
|
||
<input type="checkbox" class="uc-manualpo-cb" value="${esc(g.code)}" ${sel.has(g.code)?'checked':''} style="accent-color:var(--purple)"/>
|
||
<span style="font-family:var(--mono);color:var(--text3);font-size:10px">${esc(g.code)}</span> ${esc(g.name)}
|
||
</label>`).join('');
|
||
}
|
||
// approvalSteps entries are either legacy plain strings ("workflow:key" — full
|
||
// perms, for backward compat) or {step,perms:[...]} objects (see middleware/auth.js
|
||
// normalizeSteps, which the server applies the same way).
|
||
// 'approve' is distinct from 'edit': performing the sign-off action at a
|
||
// stage (e.g. "did QC check it, yes/no") is NOT the same as editing the
|
||
// record's own fields — someone can be trusted to check a stage off without
|
||
// being able to alter the underlying data.
|
||
const STEP_ALL_PERMS=['view','add','edit','approve','delete'];
|
||
const STEP_PERM_LABEL={view:'V',add:'Ad',edit:'Ed',approve:'Ap',delete:'Del'}; // short badges — 'add' vs 'approve' both start with A, so spell out 2 letters
|
||
function stepPermsFor(steps,fullKey){
|
||
for(const s of steps){
|
||
if(typeof s==='string'){ if(s===fullKey) return STEP_ALL_PERMS.slice(); }
|
||
else if(s&&s.step===fullKey){ return Array.isArray(s.perms)?s.perms:STEP_ALL_PERMS.slice(); }
|
||
}
|
||
return [];
|
||
}
|
||
function stepRow(steps,fullKey,label,extraHtml){
|
||
const perms=stepPermsFor(steps,fullKey);
|
||
const cbs=STEP_ALL_PERMS.map(function(p){
|
||
const on=perms.includes(p);
|
||
return '<label class="step-perm-cb'+(on?' on':'')+'" title="'+esc(p)+'"><input type="checkbox" class="uc-step-perm-cb" data-step="'+fullKey+'" data-perm="'+p+'" '+(on?'checked':'')+'/>'+STEP_PERM_LABEL[p]+'</label>';
|
||
}).join('');
|
||
const extra=extraHtml?'<div class="step-row-extra">'+extraHtml+'</div>':'';
|
||
return '<div class="step-row" data-step-row="'+fullKey+'"><div class="step-row-main"><span class="step-row-label">'+esc(label)+'</span><div class="step-perms">'+cbs+'</div></div>'+extra+'</div>';
|
||
}
|
||
function buildApprovalStepCheckboxes(user){
|
||
const steps=user&&Array.isArray(user.approvalSteps)?user.approvalSteps:[];
|
||
if(!APPROVAL_STEP_GROUPS.length)return '<div style="font-size:11px;color:var(--text3)">No approval steps defined yet.</div>';
|
||
const allow=subAdminAllowedSteps(user); // sub-admins: only steps they hold (or the user already has)
|
||
const groups=APPROVAL_STEP_GROUPS.map(function(g){
|
||
const stepsIn=g.steps.filter(s=>!allow||allow.has(s.fullKey));
|
||
if(!stepsIn.length)return '';
|
||
const inner=stepsIn.map(function(s){
|
||
const extra=s.fullKey==='rejection_register:entry'?buildRejectionStageCheckboxes(steps):null;
|
||
return stepRow(steps,s.fullKey,s.label,extra);
|
||
}).join('');
|
||
return '<div class="perm-group" data-perm-group>'
|
||
+'<div class="perm-group-hd"><span class="perm-group-title">'+esc(g.label)+'</span>'
|
||
+'<button type="button" class="perm-group-toggle" data-toggle-group>Select all</button></div>'
|
||
+'<div class="perm-group-body step-body">'+inner+'</div></div>';
|
||
}).filter(Boolean).join('');
|
||
const note=allow?SUBADMIN_GRANT_NOTE:'';
|
||
if(allow&&!groups)return note+'<div style="font-size:11px;color:var(--text3)">You have no approval steps assigned, so there is nothing you can grant.</div>';
|
||
return note+'<div class="perm-groups">'+groups+'</div>';
|
||
}
|
||
// Collect the modal's step checkboxes back into the {step,perms} array shape.
|
||
// rejection_register:entry additionally picks up a `stages` allow-list from
|
||
// its nested checklist (buildRejectionStageCheckboxes) — only meaningful
|
||
// alongside that one step, so it's merged in here rather than given its own
|
||
// top-level user field.
|
||
function collectApprovalSteps(root){
|
||
const map=new Map();
|
||
root.querySelectorAll('.uc-step-perm-cb:checked').forEach(function(cb){
|
||
const step=cb.dataset.step, perm=cb.dataset.perm;
|
||
if(!map.has(step))map.set(step,[]);
|
||
map.get(step).push(perm);
|
||
});
|
||
const rrStages=Array.from(root.querySelectorAll('.uc-rrstage-cb:checked')).map(cb=>cb.value);
|
||
return Array.from(map.entries()).map(function(e){
|
||
const out={step:e[0],perms:e[1]};
|
||
if(e[0]==='rejection_register:entry'&&rrStages.length)out.stages=rrStages;
|
||
return out;
|
||
});
|
||
}
|
||
// Wire "Select all / Clear" toggles + live pill styling for a permission section.
|
||
function wirePermGroups(root){
|
||
root.querySelectorAll('[data-perm-group]').forEach(function(grp){
|
||
const boxes=()=>grp.querySelectorAll('.perm-pill input[type=checkbox]');
|
||
if(!boxes().length)return; // step-perm groups (Approval Steps tab) are wired by wireStepPermGroups
|
||
const btn=grp.querySelector('[data-toggle-group]');
|
||
const sync=()=>{ btn.textContent = Array.from(boxes()).every(b=>b.checked) ? 'Clear' : 'Select all'; };
|
||
btn.onclick=()=>{
|
||
const all=Array.from(boxes()).every(b=>b.checked);
|
||
boxes().forEach(b=>{ b.checked=!all; b.closest('.perm-pill').classList.toggle('pill-on',!all); });
|
||
sync();
|
||
};
|
||
sync();
|
||
});
|
||
root.querySelectorAll('.perm-pill input[type=checkbox]').forEach(function(cb){
|
||
cb.addEventListener('change',()=>{
|
||
cb.closest('.perm-pill').classList.toggle('pill-on',cb.checked);
|
||
const grp=cb.closest('[data-perm-group]');
|
||
if(grp){const btn=grp.querySelector('[data-toggle-group]');btn.textContent=Array.from(grp.querySelectorAll('.perm-pill input[type=checkbox]')).every(b=>b.checked)?'Clear':'Select all';}
|
||
});
|
||
});
|
||
}
|
||
// Select-all/clear + live styling for the Approval Steps tab's per-step
|
||
// view/add/edit/delete checkboxes (separate from wirePermGroups' single-checkbox pills).
|
||
function wireStepPermGroups(root){
|
||
root.querySelectorAll('[data-perm-group]').forEach(function(grp){
|
||
const boxes=()=>grp.querySelectorAll('.uc-step-perm-cb');
|
||
if(!boxes().length)return;
|
||
const btn=grp.querySelector('[data-toggle-group]');
|
||
const sync=()=>{ btn.textContent = Array.from(boxes()).every(b=>b.checked) ? 'Clear' : 'Select all'; };
|
||
btn.onclick=()=>{
|
||
const all=Array.from(boxes()).every(b=>b.checked);
|
||
boxes().forEach(b=>{ b.checked=!all; b.closest('.step-perm-cb').classList.toggle('on',!all); });
|
||
sync();
|
||
};
|
||
sync();
|
||
});
|
||
root.querySelectorAll('.uc-step-perm-cb').forEach(function(cb){
|
||
cb.addEventListener('change',()=>{
|
||
cb.closest('.step-perm-cb').classList.toggle('on',cb.checked);
|
||
const grp=cb.closest('[data-perm-group]');
|
||
if(grp){const btn=grp.querySelector('[data-toggle-group]');const boxes=grp.querySelectorAll('.uc-step-perm-cb');btn.textContent=Array.from(boxes).every(b=>b.checked)?'Clear':'Select all';}
|
||
});
|
||
});
|
||
}
|
||
|
||
function esc(v) {
|
||
return (v || '').toString()
|
||
.replace(/&/g,'&').replace(/</g,'<')
|
||
.replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// ROLE HELPERS
|
||
// ════════════════════════════════════════
|
||
const ROLE_LABELS = {
|
||
manager: 'Manager',
|
||
sr_manager: 'Sr. Manager',
|
||
sap_adder: 'SAP Adder',
|
||
system_admin: 'System Admin',
|
||
board_member: 'Board Member',
|
||
admin: 'Admin',
|
||
user: 'User',
|
||
project_approver: 'Project Approver',
|
||
};
|
||
const ROLE_DESCS = {
|
||
manager: 'L1 approver — reviews PENDING BOM requests',
|
||
sr_manager: 'L2 approver — reviews L1-approved requests',
|
||
sap_adder: 'Final approver — pushes approved BOMs to SAP B1. Also manages users.',
|
||
system_admin: 'User management, System Settings & Item Group Rules. Cannot touch Admin accounts; sees only granted modules.',
|
||
admin: 'Full system access — manages users and approves at all levels.',
|
||
board_member: 'Company leadership — read-only growth analytics dashboard (sales, purchases, customers).',
|
||
user: 'Can submit items for approval. Items go to PENDING for admin review.',
|
||
project_approver: 'Can approve project requests at any step in the project approval chain (Purchase, Engineering, QA, QC, Legal, Owner, Plant, Finance).',
|
||
};
|
||
|
||
function getRolePillClass(role) {
|
||
return { manager:'rp-manager', sr_manager:'rp-sr_manager', sap_adder:'rp-sap_adder', system_admin:'rp-admin', board_member:'rp-sr_manager', admin:'rp-admin', user:'rp-user', project_approver:'rp-project_approver' }[role] || 'rp-manager';
|
||
}
|
||
|
||
function getAvatarClass(role) {
|
||
return { manager:'u-av-mgr', sr_manager:'u-av-srm', sap_adder:'u-av-sap', system_admin:'u-av-sap', board_member:'u-av-srm', admin:'u-av-sap', user:'u-av-mgr', project_approver:'u-av-srm' }[role] || 'u-av-mgr';
|
||
}
|
||
|
||
const STATUS_LABELS = {
|
||
PENDING:'Pending', L1_APPROVED:'L1 Approved', L2_APPROVED:'L2 Approved',
|
||
L3_APPROVED:'L3 Approved', SAP_PUSHED:'In SAP B1', REJECTED:'Rejected', CANCELLED:'Cancelled',
|
||
};
|
||
|
||
// ════════════════════════════════════════
|
||
// INIT
|
||
// ════════════════════════════════════════
|
||
async function init() {
|
||
const main = document.getElementById('main-area');
|
||
|
||
// Check access — admin, sap_adder or system_admin
|
||
if (!['sap_adder', 'system_admin', 'admin'].includes(_user?.role)) {
|
||
main.innerHTML = `
|
||
<div class="access-denied">
|
||
<div class="ad-icon"></div>
|
||
<div class="ad-title">Access Denied</div>
|
||
<div class="ad-msg">The Admin panel requires <strong>Admin, SAP Adder or System Admin</strong> role. Your current role is <strong>${esc(_user?.role || 'unknown')}</strong>.</div>
|
||
<div style="margin-top:24px"><a href="/" class="btn b-primary" style="text-decoration:none;display:inline-flex">← Back to Home</a></div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
document.getElementById('role-badge').textContent = (ROLE_LABELS[_user?.role] || 'ADMIN').toUpperCase();
|
||
|
||
// Load everything in parallel
|
||
await Promise.all([loadConfig(), loadUsers(), loadBomRequests(), loadApprovalSteps(), loadItemGroups(), loadManpowerTabs(), loadOeeTabs(), loadAllCompanies(), loadPrDepartments(), loadRejectionStages(), loadBoardGroups(), loadPpcGroups(), loadPwResets(), loadSettings(), loadRmOvgRows()]);
|
||
// Deep link from the sidebar "Pending Actions" bell (?tab=pwresets) —
|
||
// jump straight to the Password Resets tab instead of Overview.
|
||
const params = new URLSearchParams(window.location.search);
|
||
// Deep-link a tab via ?tab=… OR #… (the dashboard tiles use /admin#settings)
|
||
const VALID_TABS = ['overview', 'users', 'bom', 'pwresets', 'settings'];
|
||
const wantTab = params.get('tab') || (window.location.hash || '').replace('#', '');
|
||
if (VALID_TABS.includes(wantTab)) _activeTab = wantTab;
|
||
render();
|
||
}
|
||
|
||
async function loadSettings() {
|
||
try { const r = await api('GET', '/settings'); _settings = r.data || null; }
|
||
catch (e) { console.warn('Settings load failed:', e.message); }
|
||
}
|
||
|
||
async function loadConfig() {
|
||
try { const r = await api('GET', '/config'); _cfg = r; } catch {}
|
||
}
|
||
|
||
async function loadUsers() {
|
||
try { const r = await api('GET', '/users'); _users = r.data || []; }
|
||
catch (e) { console.warn('Users load failed:', e.message); }
|
||
}
|
||
|
||
async function loadBomRequests() {
|
||
try { const r = await api('GET', '/bom-requests?status=ALL'); _bomRequests = r.data || []; }
|
||
catch (e) { console.warn('BOM requests load failed:', e.message); }
|
||
}
|
||
|
||
async function loadPwResets() {
|
||
try { const r = await api('GET', '/password-reset'); _pwResets = r.data || []; }
|
||
catch (e) { console.warn('Password reset requests load failed:', e.message); }
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// MAIN RENDER
|
||
// ════════════════════════════════════════
|
||
function render() {
|
||
const main = document.getElementById('main-area');
|
||
main.innerHTML = '';
|
||
|
||
// Page header
|
||
const ph = document.createElement('div');
|
||
ph.className = 'page-header';
|
||
const _activeUsers = _users.filter(u => u.active).length;
|
||
const _pendingPw = _pwResets.filter(r => r.status === 'PENDING').length;
|
||
const _viewerName = _user?.name || _user?.username || 'Admin';
|
||
ph.innerHTML = `
|
||
<div class="admin-hero">
|
||
<div class="ah-left">
|
||
<div class="ah-icon">🛡️</div>
|
||
<div>
|
||
<div class="ah-eyebrow">Control Center</div>
|
||
<div class="ah-title">Admin Panel</div>
|
||
<div class="ah-sub">${_user?.role === 'system_admin'
|
||
? 'User management · Permissions · Password resets'
|
||
: 'User management · Permissions · BOM requests · System settings'}</div>
|
||
</div>
|
||
</div>
|
||
<div class="ah-right">
|
||
<div class="ah-stat"><b>${_users.length}</b><span>Users</span></div>
|
||
<div class="ah-stat"><b>${_activeUsers}</b><span>Active</span></div>
|
||
<div class="ah-stat${_pendingPw ? ' warn' : ''}"><b>${_pendingPw}</b><span>Pw Requests</span></div>
|
||
<div class="ah-viewer">
|
||
<div class="av">${esc(_viewerName[0].toUpperCase())}</div>
|
||
<div>
|
||
<div class="nm">${esc(_viewerName)}</div>
|
||
<div class="rl">${esc(ROLE_LABELS[_user?.role] || _user?.role || '')}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
main.appendChild(ph);
|
||
|
||
// Tabs
|
||
const pendingUsers = _users.filter(u => !u.active).length;
|
||
const myTurnBoms = _bomRequests.filter(r => r.status === 'L2_APPROVED' || r.status === 'L3_APPROVED').length;
|
||
const pendingPwResets = _pwResets.filter(r => r.status === 'PENDING').length;
|
||
let tabs = [
|
||
{ id:'overview', icon:'⊟', label:'Overview' },
|
||
{ id:'users', icon:'', label:`Users`, count: _users.length },
|
||
{ id:'bom', icon:'⊞', label:'BOM Requests', count: _bomRequests.filter(r=>r.status==='PENDING'||r.status.includes('APPROVED')).length },
|
||
{ id:'pwresets', icon:'🔑', label:'Password Resets', count: pendingPwResets },
|
||
{ id:'settings', icon:'⚙', label:'System Settings' },
|
||
];
|
||
// System Admins: user management + System Settings — BOM Requests stay outside
|
||
// their scope (that's the SAP Adder's approval queue).
|
||
if (_user?.role === 'system_admin') {
|
||
tabs = tabs.filter(t => ['overview','users','pwresets','settings'].includes(t.id));
|
||
if (!tabs.some(t => t.id === _activeTab)) _activeTab = 'users';
|
||
}
|
||
|
||
const tabBar = document.createElement('div');
|
||
tabBar.className = 'admin-tabs';
|
||
tabs.forEach(tab => {
|
||
const btn = document.createElement('button');
|
||
btn.className = `admin-tab${_activeTab === tab.id ? ' on' : ''}`;
|
||
btn.innerHTML = `${tab.icon} ${tab.label}${tab.count !== undefined ? `<span class="tab-count">${tab.count}</span>` : ''}`;
|
||
btn.onclick = () => { _activeTab = tab.id; render(); };
|
||
tabBar.appendChild(btn);
|
||
});
|
||
main.appendChild(tabBar);
|
||
|
||
// Content
|
||
const content = document.createElement('div');
|
||
if (_activeTab === 'overview') content.appendChild(buildOverview());
|
||
else if (_activeTab === 'users') content.appendChild(buildUsersTab());
|
||
else if (_activeTab === 'bom') content.appendChild(buildBomTab());
|
||
else if (_activeTab === 'pwresets') content.appendChild(buildPwResetsTab());
|
||
else if (_activeTab === 'settings') content.appendChild(buildSettingsTab());
|
||
main.appendChild(content);
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// OVERVIEW TAB
|
||
// ════════════════════════════════════════
|
||
function buildOverview() {
|
||
const div = document.createElement('div');
|
||
const levels = _cfg?.approvalLevels || 3;
|
||
const flow = _cfg?.statusFlow || [];
|
||
|
||
// Stats
|
||
const pending = _bomRequests.filter(r => r.status === 'PENDING').length;
|
||
const inProg = _bomRequests.filter(r => r.status.includes('APPROVED')).length;
|
||
const pushed = _bomRequests.filter(r => r.status === 'SAP_PUSHED').length;
|
||
const rejected = _bomRequests.filter(r => r.status === 'REJECTED').length;
|
||
const managers = _users.filter(u => u.role === 'manager').length;
|
||
const srMgrs = _users.filter(u => u.role === 'sr_manager').length;
|
||
const sapAdders= _users.filter(u => u.role === 'sap_adder').length;
|
||
const portalUsers = _users.filter(u => u.role === 'user').length;
|
||
|
||
div.innerHTML = `
|
||
|
||
<!-- BOM Stats -->
|
||
<div class="stats-grid" style="grid-template-columns:repeat(auto-fill,minmax(140px,1fr))">
|
||
<div class="stat-card sc-blue"><div class="stat-val">${pending}</div><div class="stat-label">Pending</div></div>
|
||
<div class="stat-card sc-amber"><div class="stat-val">${inProg}</div><div class="stat-label">In Progress</div></div>
|
||
<div class="stat-card sc-green"><div class="stat-val">${pushed}</div><div class="stat-label">In SAP B1</div></div>
|
||
<div class="stat-card sc-red"><div class="stat-val">${rejected}</div><div class="stat-label">Rejected</div></div>
|
||
<div class="stat-card sc-purple"><div class="stat-val">${_users.length}</div><div class="stat-label">Portal Users</div></div>
|
||
</div>
|
||
|
||
<!-- Users Summary -->
|
||
<div class="win">
|
||
<div class="win-bar">
|
||
<div class="win-title">User Roster</div>
|
||
<button class="btn b-purple" onclick="document.querySelector('.admin-tab:nth-child(2)').click()">Manage Users →</button>
|
||
</div>
|
||
<div style="padding:14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px">
|
||
<div style="border:1px solid rgba(245,166,35,.25);background:rgba(245,166,35,.06);border-radius:var(--radius);padding:14px">
|
||
<div style="font-size:10px;font-weight:700;color:var(--amber);text-transform:uppercase;letter-spacing:.4px;margin-bottom:4px">Managers (L1)</div>
|
||
<div style="font-family:var(--display);font-size:28px;color:var(--amber)">${managers}</div>
|
||
<div style="font-size:10.5px;color:var(--text3);margin-top:3px">Approve PENDING requests</div>
|
||
</div>
|
||
<div style="border:1px solid rgba(159,90,253,.25);background:rgba(159,90,253,.06);border-radius:var(--radius);padding:14px">
|
||
<div style="font-size:10px;font-weight:700;color:var(--purple);text-transform:uppercase;letter-spacing:.4px;margin-bottom:4px">Sr. Managers (L2)</div>
|
||
<div style="font-family:var(--display);font-size:28px;color:var(--purple)">${srMgrs}</div>
|
||
<div style="font-size:10.5px;color:var(--text3);margin-top:3px">Approve L1-approved requests</div>
|
||
</div>
|
||
<div style="border:1px solid rgba(13,187,138,.25);background:rgba(13,187,138,.06);border-radius:var(--radius);padding:14px">
|
||
<div style="font-size:10px;font-weight:700;color:var(--green);text-transform:uppercase;letter-spacing:.4px;margin-bottom:4px">SAP Adders (Final)</div>
|
||
<div style="font-family:var(--display);font-size:28px;color:var(--green)">${sapAdders}</div>
|
||
<div style="font-size:10.5px;color:var(--text3);margin-top:3px">Final approval + SAP push</div>
|
||
</div>
|
||
<div style="border:1px solid rgba(26,111,255,.25);background:rgba(26,111,255,.06);border-radius:var(--radius);padding:14px">
|
||
<div style="font-size:10px;font-weight:700;color:var(--blue);text-transform:uppercase;letter-spacing:.4px;margin-bottom:4px">Portal Users</div>
|
||
<div style="font-family:var(--display);font-size:28px;color:var(--blue)">${portalUsers}</div>
|
||
<div style="font-size:10.5px;color:var(--text3);margin-top:3px">Submit items for approval</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
return div;
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// USERS TAB
|
||
// ════════════════════════════════════════
|
||
function buildUsersTab() {
|
||
const div = document.createElement('div');
|
||
|
||
const win = document.createElement('div');
|
||
win.className = 'win';
|
||
|
||
const bar = document.createElement('div');
|
||
bar.className = 'win-bar';
|
||
bar.innerHTML = `
|
||
<div class="win-title">Portal Users <span style="font-size:11px;color:var(--text3);font-weight:400">${_users.length} total</span></div>
|
||
<div class="win-actions">
|
||
<div class="search-wrap">
|
||
<span class="search-icon"></span>
|
||
<input class="search-input" id="user-search" placeholder="Search users…" value="${esc(_userSearch)}" autocomplete="off"/>
|
||
</div>
|
||
<button class="btn-sm" id="export-users-csv-btn" title="Export the users currently shown (respects search/role/status filters) as CSV">⬇ Export CSV</button>
|
||
<button class="btn b-purple" id="add-user-btn">+ Add User</button>
|
||
</div>`;
|
||
win.appendChild(bar);
|
||
|
||
// Role filter chips (replaces the static role legend) — one chip per role
|
||
// that actually exists, with live counts.
|
||
const roleCounts = {};
|
||
_users.forEach(u => { roleCounts[u.role] = (roleCounts[u.role] || 0) + 1; });
|
||
const chipOrder = ['admin', 'system_admin', 'sap_adder', 'sr_manager', 'manager', 'project_approver', 'user'];
|
||
const legend = document.createElement('div');
|
||
legend.className = 'pu-chips';
|
||
const nActive = _users.filter(u => u.active).length;
|
||
const nInactive = _users.length - nActive;
|
||
legend.innerHTML =
|
||
`<button class="pu-chip${!_userRoleFilter ? ' on' : ''}" data-rolechip="">All <b>${_users.length}</b></button>` +
|
||
chipOrder.filter(r => roleCounts[r]).map(r =>
|
||
`<button class="pu-chip${_userRoleFilter === r ? ' on' : ''}" data-rolechip="${r}">${esc(ROLE_LABELS[r] || r)} <b>${roleCounts[r]}</b></button>`
|
||
).join('') +
|
||
// Status filter — click the selected chip again to clear it
|
||
`<span class="sep"></span>
|
||
<button class="pu-chip c-act${_userStatusFilter === 'active' ? ' on' : ''}" data-statuschip="active"><span class="cdot" style="background:var(--green)"></span>Active <b>${nActive}</b></button>
|
||
<button class="pu-chip c-inact${_userStatusFilter === 'inactive' ? ' on' : ''}" data-statuschip="inactive"><span class="cdot" style="background:var(--text3)"></span>Inactive <b>${nInactive}</b></button>`;
|
||
win.appendChild(legend);
|
||
|
||
// Filter results — role chip + status chip + search text
|
||
const filtered = _users.filter(u => {
|
||
if (_userRoleFilter && u.role !== _userRoleFilter) return false;
|
||
if (_userStatusFilter === 'active' && !u.active) return false;
|
||
if (_userStatusFilter === 'inactive' && u.active) return false;
|
||
if (!_userSearch) return true;
|
||
const q = _userSearch.toLowerCase();
|
||
return (u.username || '').toLowerCase().includes(q)
|
||
|| (u.fullName || '').toLowerCase().includes(q)
|
||
|| (u.email || '').toLowerCase().includes(q)
|
||
|| (u.role || '').toLowerCase().includes(q);
|
||
});
|
||
|
||
const tableWrap = document.createElement('div');
|
||
|
||
if (!filtered.length) {
|
||
tableWrap.innerHTML = `
|
||
<div class="empty-state">
|
||
<div class="es-icon"></div>
|
||
<p style="font-size:13px;color:var(--text2);margin-bottom:6px">No users found</p>
|
||
<p>Try adjusting your search / role filter, or add a new user.</p>
|
||
</div>`;
|
||
} else {
|
||
// Professional card grid instead of a data table
|
||
tableWrap.innerHTML = `<div class="pu-grid">
|
||
${filtered.map(u => {
|
||
const initial = (u.fullName || u.username || 'U')[0].toUpperCase();
|
||
const isMe = u.username === _user?.username;
|
||
// Sub-admins cannot edit/delete/reset full Admin accounts — the server
|
||
// enforces this too; hiding the buttons avoids dead 403 clicks.
|
||
const lockAdmin = u.role === 'admin' && _user?.role !== 'admin';
|
||
return `<div class="pu-card" data-uid="${u.id}">
|
||
<div class="pu-status">
|
||
<span class="dot" style="background:${u.active ? 'var(--green)' : 'var(--text3)'}"></span>
|
||
<span style="color:${u.active ? 'var(--green)' : 'var(--text3)'}">${u.active ? 'ACTIVE' : 'INACTIVE'}</span>
|
||
</div>
|
||
<div class="pu-top">
|
||
<div class="pu-av ${getAvatarClass(u.role)}">${initial}</div>
|
||
<div style="min-width:0">
|
||
<div class="pu-name">${esc(u.fullName || u.username)}${isMe ? '<span class="pu-you">YOU</span>' : ''}</div>
|
||
<div class="pu-user">@${esc(u.username)}</div>
|
||
</div>
|
||
</div>
|
||
<span class="role-pill ${getRolePillClass(u.role)}" style="align-self:flex-start">${esc(ROLE_LABELS[u.role] || u.role)}</span>
|
||
<div class="pu-meta">
|
||
<div class="row"><span class="ic">✉</span><span>${esc(u.email || '—')}</span></div>
|
||
<div class="row"><span class="ic">🕒</span><span>Last login: ${u.lastLogin ? new Date(u.lastLogin).toLocaleDateString('en-IN', {day:'2-digit',month:'short',year:'numeric'}) : 'Never'}</span></div>
|
||
</div>
|
||
<div class="pu-foot">
|
||
${lockAdmin ? `<span class="pu-locked">🔒 Admin — locked</span>` : `
|
||
<button class="pu-btn edit" data-action="edit" data-uid="${u.id}">✎ Edit</button>
|
||
<button class="pu-btn pwd" data-action="pwd" data-uid="${u.id}">Password</button>
|
||
${!isMe ? `<button class="pu-btn del" data-action="del" data-uid="${u.id}" title="Delete user">✕</button>` : ''}`}
|
||
</div>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
win.appendChild(tableWrap);
|
||
div.appendChild(win);
|
||
|
||
// Wire events
|
||
setTimeout(() => {
|
||
const searchEl = document.getElementById('user-search');
|
||
if (searchEl) {
|
||
// Rebuilding the tab replaces the input, which resets the caret to the
|
||
// start (typing then appears right-to-left) — capture and restore it.
|
||
searchEl.oninput = e => {
|
||
_userSearch = e.target.value;
|
||
const pos = e.target.selectionStart;
|
||
buildUsersTab_inline(div);
|
||
const s2 = document.getElementById('user-search');
|
||
if (s2) { s2.focus(); s2.setSelectionRange(pos, pos); }
|
||
};
|
||
}
|
||
|
||
document.getElementById('add-user-btn').onclick = () => openUserModal(null);
|
||
document.getElementById('export-users-csv-btn').onclick = () => exportUsersCsv(filtered);
|
||
|
||
// Role filter chips
|
||
legend.querySelectorAll('[data-rolechip]').forEach(c => {
|
||
c.onclick = () => { _userRoleFilter = c.dataset.rolechip; buildUsersTab_inline(div); };
|
||
});
|
||
// Active/Inactive status chips — clicking the selected one clears the filter
|
||
legend.querySelectorAll('[data-statuschip]').forEach(c => {
|
||
c.onclick = () => {
|
||
_userStatusFilter = (_userStatusFilter === c.dataset.statuschip) ? '' : c.dataset.statuschip;
|
||
buildUsersTab_inline(div);
|
||
};
|
||
});
|
||
|
||
// Action buttons via delegation
|
||
tableWrap.addEventListener('click', e => {
|
||
const btn = e.target.closest('[data-action]');
|
||
if (!btn) return;
|
||
const uid = parseInt(btn.dataset.uid);
|
||
const action = btn.dataset.action;
|
||
const user = _users.find(u => u.id === uid);
|
||
if (!user) return;
|
||
if (action === 'edit') openUserModal(user);
|
||
else if (action === 'pwd') openPasswordModal(user);
|
||
else if (action === 'del') confirmDelete(user);
|
||
});
|
||
}, 0);
|
||
|
||
return div;
|
||
}
|
||
|
||
// Inline rebuild of user table (after search)
|
||
function buildUsersTab_inline(container) {
|
||
container.innerHTML = '';
|
||
container.appendChild(buildUsersTab());
|
||
}
|
||
|
||
// ── Export Users → CSV ───────────────────────────────────────────────────
|
||
// Exports whatever's currently shown in the table (search/role/status
|
||
// filters already applied by the caller) — never passwords or SAP
|
||
// credentials, just identity + permission summary fields.
|
||
function csvCell(v) {
|
||
const s = v == null ? '' : String(v);
|
||
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
||
}
|
||
function exportUsersCsv(list) {
|
||
if (!list.length) { toast('No users to export with the current filters', 'err'); return; }
|
||
const headers = ['Username', 'Full Name', 'Email', 'Role', 'Status', 'Approval Dept', 'Modules', 'Approval Steps', 'Issue Item Groups', 'Manual PWO Item Groups', 'Man Power Tabs', 'OEE Tabs', 'Allowed Companies', 'Has SAP Login'];
|
||
const stepKey = s => (typeof s === 'string' ? s : s?.step);
|
||
const rows = list.map(u => [
|
||
u.username || '', u.fullName || '', u.email || '', ROLE_LABELS[u.role] || u.role || '',
|
||
u.active ? 'Active' : 'Inactive', u.approvalDept || '',
|
||
Array.isArray(u.modules) ? u.modules.join('; ') : 'All',
|
||
Array.isArray(u.approvalSteps) ? u.approvalSteps.map(stepKey).filter(Boolean).join('; ') : '',
|
||
Array.isArray(u.issueItemGroups) && u.issueItemGroups.length ? u.issueItemGroups.join('; ') : 'All',
|
||
Array.isArray(u.manualPoItemGroups) && u.manualPoItemGroups.length ? u.manualPoItemGroups.join('; ') : 'All',
|
||
Array.isArray(u.manpowerTabs) && u.manpowerTabs.length ? u.manpowerTabs.join('; ') : 'All',
|
||
Array.isArray(u.oeeTabs) && u.oeeTabs.length ? u.oeeTabs.join('; ') : 'All',
|
||
Array.isArray(u.allowedCompanies) && u.allowedCompanies.length ? u.allowedCompanies.join('; ') : 'All',
|
||
u.hasSapLogin ? 'Yes' : 'No',
|
||
]);
|
||
const csv = [headers, ...rows].map(r => r.map(csvCell).join(',')).join('\r\n');
|
||
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
const stamp = new Date().toISOString().slice(0, 10);
|
||
a.href = url; a.download = `portal-users-${stamp}.csv`;
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
URL.revokeObjectURL(url);
|
||
toast(`✓ Exported ${list.length} user${list.length === 1 ? '' : 's'} to CSV`);
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// PASSWORD RESET REQUESTS TAB
|
||
// This portal has no email/SMTP — a "forgot password" submission from the
|
||
// login page just lands here as a request for an admin to action: reset
|
||
// the password (reuses the same modal as the Users tab's Password button)
|
||
// and mark it resolved, or dismiss it.
|
||
// ════════════════════════════════════════
|
||
function buildPwResetsTab() {
|
||
const div = document.createElement('div');
|
||
const win = document.createElement('div');
|
||
win.className = 'win';
|
||
|
||
const bar = document.createElement('div');
|
||
bar.className = 'win-bar';
|
||
bar.innerHTML = `<div class="win-title">Password Reset Requests <span style="font-size:11px;color:var(--text3);font-weight:400">${_pwResets.length} total</span></div>`;
|
||
win.appendChild(bar);
|
||
|
||
if (!_pwResets.length) {
|
||
const empty = document.createElement('div');
|
||
empty.style.cssText = 'padding:40px;text-align:center;color:var(--text3);font-size:13px';
|
||
empty.textContent = 'No password reset requests.';
|
||
win.appendChild(empty);
|
||
} else {
|
||
const table = document.createElement('table');
|
||
table.className = 'user-table';
|
||
table.innerHTML = `
|
||
<thead><tr><th>Username</th><th>Note</th><th>Submitted</th><th>Status</th><th></th></tr></thead>
|
||
<tbody>${_pwResets.map(r => `
|
||
<tr>
|
||
<td class="u-username">@${esc(r.username)}</td>
|
||
<td style="max-width:260px;white-space:normal">${esc(r.note || '—')}</td>
|
||
<td style="font-size:11px;color:var(--text3)">${new Date(r.createdAt).toLocaleString()}</td>
|
||
<td><span class="role-pill ${r.status === 'PENDING' ? 'rp-manager' : r.status === 'RESOLVED' ? 'rp-sap_adder' : 'rp-user'}">${esc(r.status)}</span></td>
|
||
<td>
|
||
${r.status === 'PENDING' ? `
|
||
<div class="user-tbl-actions">
|
||
<button class="btn b-amber" data-pw-reset="${r.id}" style="padding:4px 9px;font-size:10px">Reset Password</button>
|
||
<button class="btn b-cancel" data-pw-dismiss="${r.id}" style="padding:4px 9px;font-size:10px">Dismiss</button>
|
||
</div>` : `<span style="font-size:11px;color:var(--text3)">by @${esc(r.resolvedBy || '')}</span>`}
|
||
</td>
|
||
</tr>`).join('')}</tbody>`;
|
||
win.appendChild(table);
|
||
}
|
||
div.appendChild(win);
|
||
|
||
setTimeout(() => {
|
||
div.querySelectorAll('[data-pw-reset]').forEach(btn => {
|
||
btn.onclick = () => {
|
||
const req = _pwResets.find(r => String(r.id) === btn.dataset.pwReset);
|
||
if (!req) return;
|
||
const user = _users.find(u => u.username.toLowerCase() === req.username.toLowerCase());
|
||
if (!user) { toast(`No portal user found matching username "@${req.username}" — check spelling or ask them to confirm their exact login.`, 'err'); return; }
|
||
openPasswordModal(user, async () => {
|
||
try { await api('POST', `/password-reset/${req.id}/resolve`, { status: 'RESOLVED' }); } catch {}
|
||
await loadPwResets(); render();
|
||
});
|
||
};
|
||
});
|
||
div.querySelectorAll('[data-pw-dismiss]').forEach(btn => {
|
||
btn.onclick = async () => {
|
||
const id = btn.dataset.pwDismiss;
|
||
try {
|
||
await api('POST', `/password-reset/${id}/resolve`, { status: 'DISMISSED' });
|
||
await loadPwResets(); render();
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
};
|
||
});
|
||
}, 0);
|
||
|
||
return div;
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// SYSTEM SETTINGS TAB (was .env — services/appSettingsStore.js)
|
||
// ════════════════════════════════════════
|
||
const PROD_TYPE_OPTS = [
|
||
{ v:'bopotStandard', l:'Standard' },
|
||
{ v:'bopotSpecial', l:'Special' },
|
||
{ v:'bopotDisassembly', l:'Disassembly' },
|
||
];
|
||
const DEVIATION_TYPE_OPTS = [
|
||
{ v:'SHORTAGE', l:'Shortage — top up qty of the same item' },
|
||
{ v:'SUBSTITUTION', l:'Substitution — issue a different item instead' },
|
||
{ v:'DAMAGE', l:'Damage/Loss — write off a damaged qty' },
|
||
{ v:'ADDITION', l:'Addition — add a new item/component not on the Production Order' },
|
||
];
|
||
const MANPOWER_TAB_OPTS = [
|
||
{ v:'bb',l:'BB Production' },{ v:'packing',l:'Packing' },{ v:'autoclave',l:'Autoclave' },
|
||
{ v:'capd',l:'CAPD Production' },{ v:'moulding',l:'Moulding' },{ v:'sheet',l:'Sheet Plant' },
|
||
{ v:'lamination',l:'Lamination' },{ v:'stent',l:'Stent' },{ v:'equipment',l:'Equipment' },{ v:'needle',l:'Needle' },
|
||
];
|
||
// Company Growth (Board) Dashboard's SAP Item Groups — live from SAP (via the
|
||
// unrestricted admin endpoint), same pattern as ALL_COMPANIES.
|
||
let BOARD_GROUPS=[];
|
||
async function loadBoardGroups(){
|
||
try{ const r=await api('GET','/board/groups/all'); BOARD_GROUPS=(r.data||[]).map(g=>({v:String(g.code),l:g.name||String(g.code)})); }
|
||
catch(e){ console.warn('Board product groups load failed:', e.message); }
|
||
}
|
||
// PPC Report's SAP Item Groups — same live/unrestricted-admin-endpoint pattern as BOARD_GROUPS above.
|
||
let PPC_GROUPS=[];
|
||
async function loadPpcGroups(){
|
||
try{ const r=await api('GET','/ppc/item-groups/all'); PPC_GROUPS=(r.data||[]).map(g=>({v:String(g.code),l:g.name||String(g.code)})); }
|
||
catch(e){ console.warn('PPC item groups load failed:', e.message); }
|
||
}
|
||
// Steps/modules a fixed extra-recipient email can be attached to — mirrors
|
||
// services/approvalStepsStore.js's work_order/production_order steps plus the
|
||
// Batch Issuance sidebar module (which has no approval-step workflow at all).
|
||
const NOTIFY_TARGET_OPTS = [
|
||
{ v:'work_order:prepared_qa', l:'Work Order — Prepared By QA' },
|
||
{ v:'work_order:checked_qc', l:'Work Order — Checked By QC' },
|
||
{ v:'work_order:checked_production', l:'Work Order — Checked By Store In-Charge' },
|
||
{ v:'work_order:checked_mgr_production', l:'Work Order — Checked By (Manager Production)' },
|
||
{ v:'work_order:approved_mgr_qa', l:'Work Order — Approved By (Manager QA)' },
|
||
{ v:'work_order:issue', l:'Work Order — Issued By (per-row)' },
|
||
{ v:'work_order:receive', l:'Work Order — Received By (per-row)' },
|
||
{ v:'work_order:verify', l:'Work Order — Verified By (per-row)' },
|
||
{ v:'production_order:release', l:'Production Order — Release' },
|
||
{ v:'production_order:issuance', l:'Production Order — Issuance (Issue for Production)' },
|
||
{ v:'production_order:receipt', l:'Production Order — Receipt from Production' },
|
||
{ v:'production_order:transfer_fg', l:'Production Order — Transfer to Finished Goods' },
|
||
{ v:'production_order:close', l:'Production Order — Close' },
|
||
{ v:'production_order:deviation', l:'Production Order — Deviation (Shortage/Substitution/Damage)' },
|
||
{ v:'module:production-batch-issuance', l:'Batch Issuance Intimation — New Intimation' },
|
||
];
|
||
// Raw Material Ovg% Override rows (services/rmOvgSettingsStore.js) — its own
|
||
// small CRUD list, separate from the batch "Save" button mechanism the rest
|
||
// of this page uses, since each row is its own DB record.
|
||
let RM_OVG_ROWS=[];
|
||
async function loadRmOvgRows(){
|
||
try{ const r=await api('GET','/rm-ovg-settings'); RM_OVG_ROWS=r.data||[]; }
|
||
catch(e){ console.warn('RM Ovg% rows load failed:', e.message); }
|
||
}
|
||
function renderRmOvgList(){
|
||
const el=document.getElementById('rmovg-list');
|
||
if(!el)return;
|
||
if(!RM_OVG_ROWS.length){ el.innerHTML='<div style="font-size:11px;color:var(--text3)">No rows configured yet.</div>'; return; }
|
||
const groupName=(code)=>{ const g=ITEM_GROUPS.find(x=>String(x.code)===String(code)); return g?g.name:('#'+code); };
|
||
el.innerHTML=`<table style="width:100%;border-collapse:collapse;font-size:12px">
|
||
<thead><tr style="text-align:left;color:var(--text3);font-size:10px;text-transform:uppercase"><th style="padding:4px 6px">Item Code</th><th style="padding:4px 6px">Item Group</th><th style="padding:4px 6px">Ovg %</th><th></th></tr></thead>
|
||
<tbody>${RM_OVG_ROWS.map(r=>`<tr style="border-top:1px solid var(--border2)">
|
||
<td style="padding:4px 6px;font-family:var(--mono)">${esc(r.itemCode||'—')}</td>
|
||
<td style="padding:4px 6px">${r.itemGroup!=null?esc(groupName(r.itemGroup)):'—'}</td>
|
||
<td style="padding:4px 6px">${esc(String(r.ovgPercent))}</td>
|
||
<td style="padding:4px 6px;text-align:right"><button class="btn-sm rmovg-del" data-id="${r.id}" style="background:rgba(229,62,62,.08);color:var(--red);border:1px solid rgba(229,62,62,.25)">Delete</button></td>
|
||
</tr>`).join('')}</tbody>
|
||
</table>`;
|
||
el.querySelectorAll('.rmovg-del').forEach(b=>b.onclick=async()=>{
|
||
if(!confirm('Delete this row?'))return;
|
||
try{ await api('DELETE',`/rm-ovg-settings/${b.dataset.id}`); await loadRmOvgRows(); renderRmOvgList(); }
|
||
catch(e){ alert(e.message); }
|
||
});
|
||
}
|
||
function wireRmOvgList(div){
|
||
renderRmOvgList();
|
||
const btn=div.querySelector('#rmovg-add-btn');
|
||
if(btn)btn.onclick=async()=>{
|
||
const itemCode=div.querySelector('#rmovg-new-code').value.trim();
|
||
const itemGroup=div.querySelector('#rmovg-new-group').value;
|
||
const ovgPercent=div.querySelector('#rmovg-new-pct').value;
|
||
if(!itemCode&&!itemGroup){ alert('Provide an Item Code and/or Item Group'); return; }
|
||
if(ovgPercent===''||isNaN(parseFloat(ovgPercent))){ alert('Ovg % is required'); return; }
|
||
btn.disabled=true;
|
||
try{
|
||
await api('POST','/rm-ovg-settings',{itemCode,itemGroup,ovgPercent:parseFloat(ovgPercent)});
|
||
div.querySelector('#rmovg-new-code').value='';
|
||
div.querySelector('#rmovg-new-group').value='';
|
||
div.querySelector('#rmovg-new-pct').value='';
|
||
await loadRmOvgRows(); renderRmOvgList();
|
||
}catch(e){ alert(e.message); }
|
||
btn.disabled=false;
|
||
};
|
||
}
|
||
|
||
function buildSettingsTab() {
|
||
const div = document.createElement('div');
|
||
const s = _settings || {};
|
||
|
||
const enabledTypes = String(s.prodOrderTypes || '').split(',').map(x => x.trim()).filter(Boolean);
|
||
const enabledDevTypes = String(s.deviationEnabledTypes || '').split(',').map(x => x.trim().toUpperCase()).filter(Boolean);
|
||
const enabledMpTabs = String(s.manpowerTabs || '').split(',').map(x => x.trim()).filter(Boolean);
|
||
const enabledBoardGroups = String(s.boardProductGroups || '').split(',').map(x => x.trim()).filter(Boolean);
|
||
const enabledPpcGroups = String(s.ppcItemGroups || '').split(',').map(x => x.trim()).filter(Boolean);
|
||
const yesNo = (id, val) => `
|
||
<select class="form-select" id="${id}" style="width:150px">
|
||
<option value="true" ${String(val).toLowerCase()==='true'?'selected':''}>Enabled</option>
|
||
<option value="false" ${String(val).toLowerCase()!=='true'?'selected':''}>Disabled</option>
|
||
</select>`;
|
||
|
||
// Professional redesign: instead of one long flat page ending in a single
|
||
// giant Save button, System Settings is now split into focused topic
|
||
// cards, each with its OWN Save button that submits only that card's
|
||
// fields (via saveSettingsSection() below) — saving one topic never
|
||
// touches, or risks discarding unsaved edits in, any other open card.
|
||
const topBar = document.createElement('div');
|
||
topBar.className = 'win';
|
||
topBar.innerHTML = `<div class="win-bar"><div class="win-title">⚙ System Settings <span style="font-size:11px;color:var(--text3);font-weight:400">— these moved out of .env; each card below saves independently and applies immediately</span></div></div>`;
|
||
div.appendChild(topBar);
|
||
|
||
// Search across every card's settings (name + description), not just card
|
||
// titles — there are ~13 cards, all collapsed by default, and a setting's
|
||
// name doesn't always hint which card it lives in (e.g. Receipt settings
|
||
// live under "Production Order — General"). Typing auto-expands and
|
||
// isolates only the cards/rows that match; clearing it restores the
|
||
// normal collapsed view.
|
||
const searchBar = document.createElement('div');
|
||
searchBar.className = 'win';
|
||
searchBar.innerHTML = `<div class="win-bar" style="cursor:default">
|
||
<input class="form-input" id="settings-search" placeholder="🔎 Search settings by name or description…" autocomplete="off" style="flex:1;max-width:420px"/>
|
||
<span id="settings-search-count" style="font-size:11px;color:var(--text3);margin-left:10px"></span>
|
||
</div>`;
|
||
div.appendChild(searchBar);
|
||
|
||
// section(title, icon, innerHtml, saveBtnId) → one .win card, header holds
|
||
// the icon+title on the left and that card's own Save button on the right.
|
||
// Collapsed by default (there are ~13 of these — showing them all open at
|
||
// once was a wall of scrolling) — click anywhere on the header (except the
|
||
// Save button itself) to expand/collapse. Purely a display:none toggle on
|
||
// the body div, no re-render, so in-progress edits in OTHER open cards are
|
||
// never touched.
|
||
function section(icon, title, bodyHtml, saveBtnId) {
|
||
const w = document.createElement('div');
|
||
w.className = 'win';
|
||
const bodyId = saveBtnId + '-body';
|
||
w.innerHTML = `
|
||
<div class="win-bar" id="${saveBtnId}-hd" style="cursor:pointer;user-select:none">
|
||
<div class="win-title"><span id="${saveBtnId}-chev" style="display:inline-block;transition:transform .15s;margin-right:2px">▸</span> ${icon} ${esc(title)}</div>
|
||
<button class="btn b-primary" id="${saveBtnId}" style="font-size:11px;padding:6px 14px">Save</button>
|
||
</div>
|
||
<div id="${bodyId}" style="display:none;padding:16px 18px;flex-direction:column;gap:2px">${bodyHtml}</div>`;
|
||
div.appendChild(w);
|
||
// Wired directly on `w` (not document.getElementById) — buildSettingsTab()
|
||
// builds this whole tab in a detached div before it's attached to the
|
||
// document, same reason every other handler here uses div.querySelector.
|
||
const hd = w.querySelector(`#${saveBtnId}-hd`);
|
||
const body = w.querySelector(`#${bodyId}`);
|
||
const chev = w.querySelector(`#${saveBtnId}-chev`);
|
||
if (hd && body && chev) {
|
||
hd.onclick = (e) => {
|
||
if (e.target.closest('button')) return; // don't toggle when clicking Save
|
||
const open = body.style.display !== 'none';
|
||
body.style.display = open ? 'none' : 'flex';
|
||
chev.style.transform = open ? '' : 'rotate(90deg)';
|
||
};
|
||
}
|
||
return w;
|
||
}
|
||
|
||
section('📋', 'BOM & Work Order Approvals', `
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">BOM Approval Levels</div><div class="set-h">How many approval steps a BOM request passes through.</div></div>
|
||
<select class="form-select" id="set-bomApprovalLevels" style="width:150px">
|
||
${[2,3,4].map(n=>`<option value="${n}" ${String(s.bomApprovalLevels)===String(n)?'selected':''}>${n} levels</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Skip "Checked By QC" (Work Order)</div><div class="set-h">When enabled, new work orders start already past the QC step.</div></div>
|
||
${yesNo('set-workOrderSkipQc', s.workOrderSkipQc)}
|
||
</div>`, 'set-save-bom');
|
||
|
||
section('📊', 'Requirement Calculator', `
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Period A</div><div class="set-h">Show/use the Period A comparison range. At least one of Period A/B must stay enabled.</div></div>
|
||
${yesNo('set-reqCalcPeriodAEnabled', s.reqCalcPeriodAEnabled)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Period A Label</div><div class="set-h">Display name shown for Period A in the Calculator (e.g. "Last Year").</div></div>
|
||
<input class="form-input" id="set-reqCalcPeriodALabel" value="${esc(s.reqCalcPeriodALabel||'Period A')}" style="width:200px"/>
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Period B</div><div class="set-h">Show/use the Period B comparison range. When only one period is enabled, Average Sale/Month = that period's qty ÷ its own month span (not the manual Months divisor).</div></div>
|
||
${yesNo('set-reqCalcPeriodBEnabled', s.reqCalcPeriodBEnabled)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Period B Label</div><div class="set-h">Display name shown for Period B in the Calculator (e.g. "This Year").</div></div>
|
||
<input class="form-input" id="set-reqCalcPeriodBLabel" value="${esc(s.reqCalcPeriodBLabel||'Period B')}" style="width:200px"/>
|
||
</div>`, 'set-save-reqcalc');
|
||
|
||
section('📦', 'Batch Issuance', `
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Allow Exceeding Pending Qty</div><div class="set-h">When OFF (default), a product's Total Units can never exceed its requirement's pending qty. When ON, users may enter more (e.g. rounding/safety buffer).</div></div>
|
||
${yesNo('set-biAllowExceedPending', s.biAllowExceedPending)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Enable SFG Workflow</div><div class="set-h">When ON, "New Intimation" gets a second "Without Intimation" tab — direct item search, any item, no Requirement needed at all. Off by default.</div></div>
|
||
${yesNo('set-biSfgWorkflowEnabled', s.biSfgWorkflowEnabled)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Multi-Solution Volumes</div><div class="set-h">Off by default: a batch's single "Batch Volume (Ltr)" is broadcast to EVERY Solution group when generating a Work Order — correct for a one-solution product, wrong for a multi-solution one (e.g. a dual-chamber bag with its own anticoagulant + additive solutions), which silently forces every solution to the same batch size. When ON, each batch can instead list per-solution Name + Volume pairs; Work Order generation matches each by name to the BOM's own Solution groups and applies only that group's volume — an unmatched group keeps its plain BOM-calculated size instead of being overridden.</div></div>
|
||
${yesNo('set-biMultiSolutionVolumesEnabled', s.biMultiSolutionVolumesEnabled)}
|
||
</div>`, 'set-save-batchissuance');
|
||
|
||
section('🔁', 'Requirement — Store Review Workflow', `
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Enable Workflow</div><div class="set-h">Whole-feature switch. When OFF (default), Requirements behave exactly as before this existed — straight to Batch Intimation, no extra step shown anywhere. When ON, Production can "Share with Store" a Requirement; Store cross-checks it against SAP's own MRP Wizard output, edits the line items, and reverts it back to Production (a single round trip — no further back-and-forth) before the Batch Intimation is raised.</div></div>
|
||
${yesNo('set-requirementStoreReviewEnabled', s.requirementStoreReviewEnabled)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Hard Gate Batch Intimation</div><div class="set-h">Only matters when the switch above is ON. ON: a Batch Intimation cannot be raised from a Requirement until it has completed the Store review round trip. OFF (default): the review loop is available but optional — Production can still skip straight to Batch Intimation without waiting on Store.</div></div>
|
||
${yesNo('set-requirementStoreReviewHardGate', s.requirementStoreReviewHardGate)}
|
||
</div>`, 'set-save-requirementreview');
|
||
|
||
section('🧾', 'Pre-PWO — Store Review Before SAP', `
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Enable Workflow</div><div class="set-h">Whole-feature switch. When OFF (default), "Create Production Order from Work Order" behaves exactly as today — it writes straight to SAP. When ON, that action instead creates a portal-only "Pre-PWO" (same component/BOM table, nothing sent to SAP yet). Production must "Share with Store", who can add/remove/substitute component lines outright and revert it back (a single round trip). <strong>Hard gate:</strong> the Pre-PWO cannot be pushed to SAP until Store has completed that review — enforced on the server, not just hidden in the UI. Once pushed to SAP, the Pre-PWO locks and the existing Release → Issuance → Receipt → Transfer → Close lifecycle continues exactly as before.</div></div>
|
||
${yesNo('set-preWoStoreReviewEnabled', s.preWoStoreReviewEnabled)}
|
||
</div>`, 'set-save-prepwo');
|
||
|
||
section('🏭', 'Work Order — BOM & Batch Rules', `
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Hide SFG from Packing Material</div><div class="set-h">When ON, a semi-finished good (an item with its own BOM) found at the top level of the product's BOM is dropped from the Packing Material table. Only applies when Packing Material is a real separate table (a Solution/raw-material section exists) — in the combined "Component" table (no raw material), SFG items still show.</div></div>
|
||
${yesNo('set-woHideSfgFromPacking', s.woHideSfgFromPacking)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Show WFI (SFG00090) in Raw Material</div><div class="set-h">A Solution's BOM is exploded down to its real leaf ingredients for the Raw Material table — but WFI (item SFG00090) has its own SAP BOM too, so by default it gets recursed through instead of listed. When ON, WFI stops the explosion and shows as its own Raw Material row (its own sub-ingredients are not shown). Scoped to this one item code only.</div></div>
|
||
${yesNo('set-woShowWfiInRawMaterial', s.woShowWfiInRawMaterial)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Hide Std. Qty/Unit's manual unit picker</div><div class="set-h">Packing Material/Components' "Std. Qty/Unit" cell has a manual unit dropdown (No./mg/gm/Kg/etc) next to the qty input, alongside the separate read-only UOM column (SAP's actual stock UOM). When ON, that manual picker is hidden — only the qty input and the UOM column remain.</div></div>
|
||
${yesNo('set-woHideStdQtyUnitPicker', s.woHideStdQtyUnitPicker)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Solution Batch Size limit · Blood Bag (Ltr)</div><div class="set-h">On a NEW Work Order for a Blood Bag product, the Solution Batch Size is editable only when below this many litres; at or above it, the field is locked to the auto-calculated value.</div></div>
|
||
<input class="form-input" id="set-woSolutionBatchMaxEditLtr" type="number" min="0" step="any" value="${esc(s.woSolutionBatchMaxEditLtr!=null?s.woSolutionBatchMaxEditLtr:200)}" style="width:110px;font-family:var(--mono)"/>
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Solution Batch Size limit · PD (Ltr)</div><div class="set-h">Same rule for a NEW Work Order of a PD (CAPD) product — editable only below this many litres, else locked.</div></div>
|
||
<input class="form-input" id="set-woSolutionBatchMaxEditLtrPD" type="number" min="0" step="any" value="${esc(s.woSolutionBatchMaxEditLtrPD!=null?s.woSolutionBatchMaxEditLtrPD:200)}" style="width:110px;font-family:var(--mono)"/>
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Batch Size round-off · Blood Bag (Ltr)</div><div class="set-h">Auto-calculated Solution Batch Size is rounded to the nearest this many litres for Blood Bag products.</div></div>
|
||
<input class="form-input" id="set-woSolutionBatchRoundLtr" type="number" min="1" step="any" value="${esc(s.woSolutionBatchRoundLtr!=null?s.woSolutionBatchRoundLtr:10)}" style="width:110px;font-family:var(--mono)"/>
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Batch Size round-off · PD (Ltr)</div><div class="set-h">Auto-calculated Solution Batch Size is rounded to the nearest this many litres for PD (CAPD) products.</div></div>
|
||
<input class="form-input" id="set-woSolutionBatchRoundLtrPD" type="number" min="1" step="any" value="${esc(s.woSolutionBatchRoundLtrPD!=null?s.woSolutionBatchRoundLtrPD:50)}" style="width:110px;font-family:var(--mono)"/>
|
||
</div>
|
||
<div class="set-row set-col">
|
||
<div class="set-info"><div class="set-l">Weighing Balance IDs</div><div class="set-h">Comma-separated list of allowed Weighing Balance IDs (e.g. "WB-01, WB-02, WB-03"). When non-empty, the "Weighing Balance ID" field on Work Order creation's raw material rows AND on Verify Work Order's per-row issuance edit becomes a dropdown restricted to this list. Leave empty to keep it free text (default).</div></div>
|
||
<input class="form-input" id="set-woWeighingBalanceIds" value="${esc(s.woWeighingBalanceIds||'')}" placeholder="WB-01, WB-02, WB-03" style="width:100%"/>
|
||
</div>
|
||
<div class="set-row set-col">
|
||
<div class="set-info"><div class="set-l">Custom Std. Qty/Unit Options</div><div class="set-h">Comma-separated list of extra plain-count units to add to the "Std. Qty/Unit" dropdown on Work Order's Packing Material rows, alongside the built-in "No." and "Units" (e.g. "Box, Roll, Set"). These behave exactly like "Units" — no conversion math, just a label of your choosing.</div></div>
|
||
<input class="form-input" id="set-woCustomQtyUnits" value="${esc(s.woCustomQtyUnits||'')}" placeholder="Box, Roll, Set" style="width:100%"/>
|
||
</div>
|
||
<div class="set-row set-col">
|
||
<div class="set-info"><div class="set-l">Raw Material SOP Display Factors</div><div class="set-h">Per-item cosmetic display factor for the Raw Material table's "Qty Req./100 ml" figure only — e.g. a material labeled "60% USP" whose SOP paperwork wants that figure shown at 60% of the calculated value. SAP's BOM quantity already accounts for the material's supplied strength, so the calculated "Qty Req./100 ml" and final "Qty Req." are both correct as-is — this setting changes nothing about the calculation, the stored data, or what's posted to SAP; it only re-scales what's shown for "Qty Req./100 ml". One item code per line, "CODE=PERCENT" (e.g. "RM00003=60"). Leave a code out to keep it unadjusted (100%).</div></div>
|
||
<textarea class="form-input" id="set-woRawPotencyFactors" rows="3" placeholder="RM00003=60" style="width:100%;font-family:var(--mono)">${esc((() => { let m={}; try { m = JSON.parse(s.woRawPotencyFactors || '{}') || {}; } catch (_e) { m = {}; } return Object.entries(m).map(([k,v])=>`${k}=${v}`).join('\n'); })())}</textarea>
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Work Order "Qty Issued" Source</div><div class="set-h">Applies to every material table — Raw Material, Packing Material, and Components alike. Raw Material items are never SAP Production Order lines themselves (they're the exploded recipe of a Solution/SFG item, which IS the PO line), so their "Qty Issued" always comes from a persisted value; Packing Material/Components rows ARE real PO lines, so they normally read it live off SAP instead. <strong>Issue for Production (default)</strong>: Raw Material is calculated from the real SAP posting — (this issue's quantity ÷ that Solution item's planned quantity) × the row's own full Qty Req., persisted cumulatively; Packing Material/Components read live off the linked Production Order. <strong>Mark Issued</strong>: for EVERY table, the manual one-click "Mark Issued" stamp on a row (same action that sets Issued By/Date) sets Qty Issued = that row's full Qty Req., regardless of what SAP actually shows.</div></div>
|
||
<select class="form-select" id="set-woRawQtyIssuedSource" style="width:220px">
|
||
<option value="issue_for_production" ${(s.woRawQtyIssuedSource||'issue_for_production')==='issue_for_production'?'selected':''}>Issue for Production</option>
|
||
<option value="mark_issued" ${s.woRawQtyIssuedSource==='mark_issued'?'selected':''}>Mark Issued (manual)</option>
|
||
</select>
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Round Up Packing/Component Qty Req. (no unit picked)</div><div class="set-h">Applies to Packing Material/Components rows where NO unit is picked in the "Std. Qty/Unit" dropdown — treated as a discrete physical count. <strong>ON (default)</strong>: rounds UP to the nearest whole number (Math.ceil), so a real fractional requirement never shows as "0 needed". <strong>OFF</strong>: uses the plain calculated value to 3 decimals instead — matches SAP's own Planned Qty for items that are genuinely fractional even without a picked unit (e.g. SAP shows 0.12 or 0.1, not rounded to 1).</div></div>
|
||
${yesNo('set-woPackQtyRoundUp', s.woPackQtyRoundUp!==false)}
|
||
</div>`, 'set-save-workorder');
|
||
|
||
section('🧮', 'Raw Material — Ovg% Override', `
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Enable Override</div><div class="set-h">When ON, a Raw Material row whose item code (or its SAP item group) has a row below gets its Ovg. cell pre-filled from here (still editable per row/batch), and its "Qty Req./100 ml" is recalculated as (BOM value) − (BOM value × Ovg%/100) instead of the plain BOM figure — Qty Req. itself is then just that reduced /100ml figure × the batch ratio, with no further Ovg% applied on top. Items with no match here are completely unaffected. OFF = today's behavior everywhere, unchanged.</div></div>
|
||
${yesNo('set-rmOvgOverrideEnabled', s.rmOvgOverrideEnabled)}
|
||
</div>
|
||
<div class="set-row set-col">
|
||
<div class="set-info"><div class="set-l">Item Code / Item Group → Ovg%</div><div class="set-h">A row needs an Item Code, an Item Group, or both. When an item matches BOTH its own item-code row AND its item-group row, the item-code row wins. Saved immediately on Add/Delete — not part of the "Save" button above.</div></div>
|
||
<div id="rmovg-list" style="width:100%">Loading…</div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;width:100%">
|
||
<input class="form-input" id="rmovg-new-code" placeholder="Item Code (optional)" style="width:160px;font-family:var(--mono)"/>
|
||
<select class="form-select" id="rmovg-new-group" style="width:220px"><option value="">— Item Group (optional) —</option>${ITEM_GROUPS.map(g=>`<option value="${esc(g.code)}">${esc(g.name)} (${esc(g.code)})</option>`).join('')}</select>
|
||
<input class="form-input" id="rmovg-new-pct" type="number" step="any" placeholder="Ovg %" style="width:100px"/>
|
||
<button class="btn-sm" id="rmovg-add-btn" style="background:var(--purple);color:#fff;border:none">+ Add</button>
|
||
</div>
|
||
</div>`, 'set-save-rmovg');
|
||
|
||
section('⚠', 'Production Deviations', `
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Enabled Types</div><div class="set-h">Which Deviation types are available at all when raising one — enable all 3, or narrow it down to just the ones your team actually uses. At least one must stay enabled.</div></div>
|
||
<div style="width:280px;display:flex;flex-direction:column;gap:5px">
|
||
${DEVIATION_TYPE_OPTS.map(o=>`<label style="display:flex;align-items:center;gap:7px;font-size:12px;cursor:pointer"><input type="checkbox" class="set-devtype" value="${o.v}" ${enabledDevTypes.includes(o.v)?'checked':''}/> ${esc(o.l)}</label>`).join('')}
|
||
</div>
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Scrap Warehouse</div><div class="set-h">Destination warehouse code for a Damage/Loss deviation's SAP write-off (Stock Transfer), when the user chooses to post one.</div></div>
|
||
<input class="form-input" id="set-deviationScrapWarehouse" value="${esc(s.deviationScrapWarehouse||'')}" placeholder="e.g. 14" style="width:150px;font-family:var(--mono)"/>
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Require QA Approval</div><div class="set-h">When ON (default), a raised Deviation (Shortage/Substitution/Damage) sits "Pending QA Review" until someone with the Deviation step's approve permission signs off. The SAP posting itself always happens immediately either way — this only gates the paper-trail closure, never production.</div></div>
|
||
${yesNo('set-deviationRequireQaApproval', s.deviationRequireQaApproval)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Defer Issue Until Approval</div><div class="set-h">When ON, raising a Deviation does NOT touch the Production Order at all while it's Pending QA Review — nothing is added or changed in SAP until someone actually approves it. Only once approved does the affected component get added/updated (Shortage tops up the planned qty, Substitution adds the item as a real new line); the concerned user then issues it manually via Issue for Production, same as any other component. If rejected instead, nothing ever touched the Production Order, so there's nothing to undo. Damage/Loss has no Issue for Production equivalent, so an approved-but-unposted Damage deviation instead shows its own "Post to SAP" button. Requires "Require QA Approval" above to also be ON — otherwise there's no approval step to defer to.</div></div>
|
||
${yesNo('set-deviationDeferIssueUntilApproval', s.deviationDeferIssueUntilApproval)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Show "Remove from SAP" Button</div><div class="set-h">Controls how a Substitution's replaced (now-superseded, zeroed-out) old item line actually gets deleted from SAP via DI API. ON (default): the button is shown and someone must press it — a manual, reviewed, case-by-case decision. OFF: no button at all — the app deletes it automatically right after the substitution is applied, whenever it's safe to. Either way, a line that's had ANY quantity physically issued against it is NEVER removable (auto or manual) — deleting it would destroy that audit record.</div></div>
|
||
${yesNo('set-deviationShowRemoveOldLineButton', s.deviationShowRemoveOldLineButton)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Damage/Loss Series</div><div class="set-h">SAP document numbering Series ID that a Damage/Loss deviation's Stock Transfer write-off (and its reversal, if QA later rejects it) posts under — find the numeric Series ID under Administration → System Initialization → Document Numbering in SAP, NOT the series name. Leave blank to let SAP use its own default series.</div></div>
|
||
<input class="form-input" id="set-deviationDamageSeries" value="${esc(s.deviationDamageSeries!=null?String(s.deviationDamageSeries):'')}" placeholder="e.g. 5" style="width:150px;font-family:var(--mono)"/>
|
||
</div>`, 'set-save-deviation');
|
||
|
||
section('🔁', 'Inventory Transfer', `
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Document Series</div><div class="set-h">SAP document numbering series that every Inventory Transfer posts under — pulled live from SAP (Administration → System Initialization → Document Numbering) so you pick it by its recognizable name, not a raw internal ID.</div></div>
|
||
<select class="form-input" id="set-inventoryTransferSeries" data-current="${esc(s.inventoryTransferSeries!=null?String(s.inventoryTransferSeries):'')}" style="width:220px">
|
||
<option value="">Loading…</option>
|
||
</select>
|
||
</div>`, 'set-save-invtransfer');
|
||
|
||
let itemCodeFloors = {};
|
||
try { itemCodeFloors = JSON.parse(s.itemCodeSeriesFloor || '{}') || {}; } catch { itemCodeFloors = {}; }
|
||
section('🔢', 'Item Master — Create Item', `
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Item Approval Workflow</div><div class="set-h">When ON (default), a new item submitted by anyone other than admin/SAP Adder/system admin queues as PENDING under Approvals → Item Approvals until one of them reviews it. When OFF, every submission — any role — pushes straight to SAP, same as an approver's own submission; there is no review queue at all.</div></div>
|
||
${yesNo('set-itemApprovalEnabled', s.itemApprovalEnabled)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Start next code from</div><div class="set-h">Create Item's auto-numbering normally suggests the highest existing code for that prefix, +1. If a mistaken/out-of-order entry already used a much higher number than intended, that suggestion jumps straight past the whole unused gap. Set a floor here to force the next SUGGESTED code to at least this number, per prefix — leave blank/0 for no override (natural behavior, as before). This only ever pushes the suggestion UP; it can never cause a collision with a real existing item.</div></div>
|
||
<div style="display:flex;flex-direction:column;gap:8px;width:220px">
|
||
${['RM','PK','ICO'].map(p=>`<div style="display:flex;align-items:center;gap:8px">
|
||
<span style="font-family:var(--mono);font-size:11px;color:var(--text3);width:42px">${p}</span>
|
||
<input class="form-input set-itemcodefloor" data-prefix="${p}" type="number" min="0" value="${esc(itemCodeFloors[p]?String(itemCodeFloors[p]):'')}" placeholder="no override" style="flex:1"/>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>`, 'set-save-itemcode');
|
||
|
||
section('🏗', 'Production Order — General', `
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Production Order Types</div><div class="set-h">Which "Type" options appear in New Production Order.</div></div>
|
||
<div style="width:150px;display:flex;flex-direction:column;gap:5px">
|
||
${PROD_TYPE_OPTS.map(o=>`<label style="display:flex;align-items:center;gap:7px;font-size:12px;cursor:pointer"><input type="checkbox" class="set-prodtype" value="${o.v}" ${enabledTypes.includes(o.v)?'checked':''}/> ${o.l}</label>`).join('')}
|
||
</div>
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Require stock availability for PWO creation</div><div class="set-h">When ON, a Production Order (any creation path — manual, from Work Order, "+PWO" from a component) is blocked if any real component's Planned Quantity exceeds what's actually on hand in its warehouse (Resources and Consumable Orders' Service items are never checked). New Production Order shows an "Available Qty" next to each component either way — this only controls whether a shortage actually blocks Save.</div></div>
|
||
${yesNo('set-poRequireStockAvailability', s.poRequireStockAvailability)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Receipt — require full quantity</div><div class="set-h">Block partial receipts: total received must equal remaining planned qty.</div></div>
|
||
${yesNo('set-receiptRequireFullQty', s.receiptRequireFullQty)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Receipt — exclude by-product/process-loss from total</div><div class="set-h">When "require full quantity" above is on, a by-product/process-loss line (e.g. ICO10791 — a different component, its own BaseLine) doesn't count toward the FG's remaining planned qty. <strong>On (default)</strong> is correct for almost everyone; turn off only to restore the old behavior of summing every line together.</div></div>
|
||
${yesNo('set-receiptExcludeByProductFromTotal', s.receiptExcludeByProductFromTotal)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Close — require full quantity</div><div class="set-h">Close a Production Order only when Completed Qty equals Planned Qty (orders with a rejected receipt are exempt).</div></div>
|
||
${yesNo('set-closeRequireFullQty', s.closeRequireFullQty)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Close — require local tracking</div><div class="set-h">Refuse to close a Production Order that has no local stage-tracking record (so Issue/Receipt/Transfer completion can't be verified). Default ON. Turn OFF only if you have genuine legacy/external SAP orders this portal never tracked that still need to be closeable.</div></div>
|
||
${yesNo('set-poCloseRequireTracking', s.poCloseRequireTracking)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Require full issuance before Receipt / Close</div><div class="set-h">A Production Order moves to "Receipt" after the first issue action even if it was only partial (you don't have to issue every component at once). When ON (default), this adds a stricter separate check: Receipt and Close are both blocked until every component's Issued Qty actually reaches its Planned Qty in SAP.</div></div>
|
||
${yesNo('set-poRequireFullIssuance', s.poRequireFullIssuance)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Autoclave Rejection (Receipt, FG)</div><div class="set-h">Show the FG-only autoclave rejection field on Receipt from Production.</div></div>
|
||
${yesNo('set-receiptAutoclaveRejection', s.receiptAutoclaveRejection)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Autoclave FG Item Groups</div><div class="set-h">Comma-separated SAP Item Group codes (OITB.ItmsGrpCod) treated as FG.</div></div>
|
||
<input class="form-input" id="set-receiptAutoclaveItemGroups" value="${esc(s.receiptAutoclaveItemGroups || '')}" placeholder="101,102,143" style="width:150px;font-family:var(--mono)"/>
|
||
</div>`, 'set-save-prodorder');
|
||
|
||
section('🧾', 'Rejection Register', `
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Batch lookup — also search Production Order Remarks</div><div class="set-h">The Rejection Register's batch lookup always checks this portal's own Work Order record and SAP's batch master (OBTN) first. When ON, it ALSO searches SAP Production Orders' own Remarks/Comments field (OWOR.Comments) — where this portal, and evidently direct SAP entry too, writes the Batch No. since a Production Order has no dedicated field for it. This catches a batch that's Released but not yet Received (so OBTN has no row for it yet) — but it's a looser match (free text anyone could have typed for any reason), so it's OFF by default. Turn it on if your site reliably uses Remarks for the batch number.</div></div>
|
||
${yesNo('set-rejectionLookupProdOrderRemarks', s.rejectionLookupProdOrderRemarks)}
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Shift Master</div><div class="set-h">Comma-separated list of Shift options shown on the Rejection Register entry wizard's "Which batch" step. Edit the labels/timings here instead of in code.</div></div>
|
||
<input class="form-input" id="set-rejectionShifts" value="${esc(s.rejectionShifts||'')}" placeholder="A (06:00–14:00), B (14:00–22:00), C (22:00–06:00)" style="width:100%"/>
|
||
</div>`, 'set-save-rejection');
|
||
|
||
section('👷', 'Man Power Tabs', `
|
||
<div class="set-row set-col">
|
||
<div class="chk-head">
|
||
<div class="set-info"><div class="set-l">Visible Tabs</div><div class="set-h">Which production-area tabs are shown on the Man Power page.</div></div>
|
||
<div style="display:flex;align-items:center;gap:10px">
|
||
<span class="chk-count" id="mpt-count"></span>
|
||
<div class="chk-tools"><button type="button" class="chk-mini" id="mpt-all">Select all</button><button type="button" class="chk-mini" id="mpt-none">Clear all</button></div>
|
||
</div>
|
||
</div>
|
||
<div class="chk-grid">
|
||
${MANPOWER_TAB_OPTS.map(o=>`<label class="chk-item"><input type="checkbox" class="set-mptab" value="${o.v}" ${enabledMpTabs.includes(o.v)?'checked':''}/><span class="chk-box"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span><span class="chk-label">${o.l}</span></label>`).join('')}
|
||
</div>
|
||
</div>`, 'set-save-manpower');
|
||
|
||
section('📈', 'Overall Equipment Efficiency', `
|
||
<div class="set-row set-col">
|
||
<div class="set-info"><div class="set-l">Machine Names — per OEE Type</div><div class="set-h">Each OEE form/type (EBB Production, PD Production, …) has its own machine list — comma-separated (e.g. "EBB-01, EBB-02"). A type left blank keeps its "Machine Name" field as free text; a type with names here turns it into a dropdown restricted to that type's own list.</div></div>
|
||
${!OEE_TABS.length?'<div style="font-size:11px;color:var(--text3)">OEE types not loaded.</div>':(()=>{
|
||
let machMap={}; try{ machMap=JSON.parse(s.oeeMachineNames||'{}')||{}; }catch(_e){ machMap={}; }
|
||
return OEE_TABS.map(t=>`
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
|
||
<label style="width:170px;flex-shrink:0;font-size:12px;font-weight:600;color:var(--text2)">${esc(t.label)}</label>
|
||
<input class="form-input set-oee-machines" data-tab="${esc(t.key)}" value="${esc(machMap[t.key]||'')}" placeholder="e.g. EBB-01, EBB-02" style="flex:1"/>
|
||
</div>`).join('');
|
||
})()}
|
||
</div>`, 'set-save-oee');
|
||
|
||
section('📈', 'Company Growth Dashboard', `
|
||
<div class="set-row set-col">
|
||
<div class="chk-head">
|
||
<div class="set-info"><div class="set-l">Product Groups</div><div class="set-h">Which SAP Item Groups may appear in the "Sales by Product Group" card and its filter, out of every group SAP has. <strong>Leave all unchecked = no restriction</strong> (every group shows, as before).</div></div>
|
||
<div style="display:flex;align-items:center;gap:10px">
|
||
<span class="chk-count" id="bpg-count"></span>
|
||
<div class="chk-tools"><button type="button" class="chk-mini" id="bpg-all">Select all</button><button type="button" class="chk-mini" id="bpg-none">Clear all</button></div>
|
||
</div>
|
||
</div>
|
||
<div class="chk-grid">
|
||
${BOARD_GROUPS.length?BOARD_GROUPS.map(o=>`<label class="chk-item"><input type="checkbox" class="set-boardgrp" value="${esc(o.v)}" ${enabledBoardGroups.includes(o.v)?'checked':''}/><span class="chk-box"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span><span class="chk-label">${esc(o.l)}</span></label>`).join(''):'<div style="font-size:11px;color:var(--text3);padding:6px">Product groups not loaded (SAP unreachable?).</div>'}
|
||
</div>
|
||
</div>`, 'set-save-dashboard');
|
||
|
||
section('📋', 'PPC Report', `
|
||
<div class="set-row set-col">
|
||
<div class="chk-head">
|
||
<div class="set-info"><div class="set-l">Item Groups</div><div class="set-h">Which SAP Item Groups may appear in the PPC Report's "Item Group" filter, out of every group SAP has — e.g. just the FG groups (BB, CAPD, Equipment, …), not raw material/packing groups too. <strong>Leave all unchecked = no restriction</strong> (every group shows, as before).</div></div>
|
||
<div style="display:flex;align-items:center;gap:10px">
|
||
<span class="chk-count" id="ppcg-count"></span>
|
||
<div class="chk-tools"><button type="button" class="chk-mini" id="ppcg-all">Select all</button><button type="button" class="chk-mini" id="ppcg-none">Clear all</button></div>
|
||
</div>
|
||
</div>
|
||
<div class="chk-grid">
|
||
${PPC_GROUPS.length?PPC_GROUPS.map(o=>`<label class="chk-item"><input type="checkbox" class="set-ppcgrp" value="${esc(o.v)}" ${enabledPpcGroups.includes(o.v)?'checked':''}/><span class="chk-box"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span><span class="chk-label">${esc(o.l)}</span></label>`).join(''):'<div style="font-size:11px;color:var(--text3);padding:6px">Item groups not loaded (SAP unreachable?).</div>'}
|
||
</div>
|
||
</div>
|
||
<div class="set-row set-col">
|
||
<div class="set-info"><div class="set-l">Order Types</div><div class="set-h">Options shown in the "Order Type" filter — sent as-is to SAP's free-text <code>U_SalesType</code> field, which isn't a fixed picklist. Add a new value here (comma-separated) whenever one starts appearing in SAP, instead of needing a code change.</div></div>
|
||
<input class="form-input" id="set-ppcOrderTypes" value="${esc(s.ppcOrderTypes||'')}" placeholder="Trade, Institute, Institute Ind" style="width:100%"/>
|
||
</div>
|
||
<div class="set-row set-col">
|
||
<div class="set-info"><div class="set-l">External API Access</div><div class="set-h">Lets an outside app/script pull this report's data (GET <code>/api/ppc/invoice-report</code> and <code>/api/ppc/item-groups</code>) without a portal login — it sends this key in an <code>X-API-Key</code> header (or <code>?apiKey=</code> query param). <strong>Empty = external access is off.</strong> Click Generate to turn it on, or clear the field and Save to turn it back off.</div></div>
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<input class="form-input" id="set-ppcApiKey" value="${esc(s.ppcApiKey||'')}" placeholder="(external access disabled)" style="font-family:monospace;flex:1" readonly/>
|
||
<button type="button" class="btn-sm" id="ppc-key-gen">Generate New Key</button>
|
||
<button type="button" class="btn-sm" id="ppc-key-copy">Copy</button>
|
||
<button type="button" class="btn-sm" id="ppc-key-clear">Clear</button>
|
||
</div>
|
||
</div>`, 'set-save-ppc');
|
||
|
||
(function(){
|
||
const w = document.createElement('div'); w.className = 'win';
|
||
w.innerHTML = `
|
||
<div class="win-bar" id="wohp-hd" style="cursor:pointer;user-select:none"><div class="win-title"><span id="wohp-chev" style="display:inline-block;transition:transform .15s;margin-right:2px">▸</span> 🖨 Work Order — Print / PDF Header</div></div>
|
||
<div id="wohp-body" style="display:none;padding:16px 18px">
|
||
<div class="set-row" style="border-bottom:none;padding-bottom:0">
|
||
<div class="set-info"><div class="set-l">Header Details</div><div class="set-h">Company details, form number, dates and logo printed at the top of the Work Order PDF. Since different product groups (Blood Bag / CAPD / Accessories…) can need different letterheads, this moved to its own screen — Work Order → Header Profiles — where multiple named profiles can be managed, each mapped to a set of SAP Item Groups, with one always marked Default. That screen is also usable by whoever holds the "Approved By (Manager QA)" Work Order step, not just Admin.</div></div>
|
||
<a href="/wo-header-profiles" target="_blank" class="btn b-primary" style="font-size:11px;padding:8px 16px;text-decoration:none;white-space:nowrap">Manage Header Profiles →</a>
|
||
</div>
|
||
</div>`;
|
||
div.appendChild(w);
|
||
const wohpHd=w.querySelector('#wohp-hd'), wohpBody=w.querySelector('#wohp-body'), wohpChev=w.querySelector('#wohp-chev');
|
||
if(wohpHd&&wohpBody&&wohpChev){
|
||
wohpHd.onclick=(e)=>{
|
||
if(e.target.closest('a,button'))return;
|
||
const open=wohpBody.style.display!=='none';
|
||
wohpBody.style.display=open?'none':'block';
|
||
wohpChev.style.transform=open?'':'rotate(90deg)';
|
||
};
|
||
}
|
||
})();
|
||
|
||
const notifyDisabledSet = new Set(String(s.notifyDisabledSteps||'').split(',').map(x=>x.trim()).filter(Boolean));
|
||
section('✉', 'Stage-Change Email Notifications', `
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Enable Email Notifications</div><div class="set-h">Emails whoever is next assigned an approval step (Work Order, Production Order, Issue for Production, Verify Work Order) or holds the Batch Issuance module, at every stage transition. SMTP server itself is configured in .env — this only controls the on/off switch and optional fixed extra recipients per step. Global switch — turn off to silence all stage-change emails without touching SMTP config.</div></div>
|
||
${yesNo('set-notifyEmailsEnabled', s.notifyEmailsEnabled)}
|
||
</div>
|
||
<div class="set-row set-col">
|
||
<div class="chk-head">
|
||
<div class="set-info"><div class="set-l">Extra Recipients per Step/Module</div><div class="set-h">Optional — cc a fixed inbox (e.g. a supervisor or shared QA address) on top of whoever actually holds that step.</div></div>
|
||
<button type="button" class="chk-mini" id="notify-add-row">+ Add</button>
|
||
</div>
|
||
<div id="notify-rows" style="display:flex;flex-direction:column;gap:6px;margin-top:6px"></div>
|
||
</div>
|
||
<div class="set-row set-col">
|
||
<div class="chk-head">
|
||
<div class="set-info"><div class="set-l">Notify by Item Group</div><div class="set-h">Optional — route an extra recipient by the order's MAIN PRODUCT's SAP Item Group (not every component's group), in addition to whoever holds the step. E.g. every Work Order/Production Order for a "Solutions" item also emails that team's inbox, regardless of who's assigned the approval step.</div></div>
|
||
<button type="button" class="chk-mini" id="notifyig-add-row">+ Add</button>
|
||
</div>
|
||
<div id="notifyig-rows" style="display:flex;flex-direction:column;gap:6px;margin-top:6px"></div>
|
||
</div>
|
||
<div class="set-row set-col">
|
||
<div class="set-info"><div class="set-l">Notify Per Step</div><div class="set-h">Uncheck a step to silence its stage-change email entirely — for everyone assigned to it AND any fixed extra recipient above — without touching who's actually assigned. Checked (default) = email fires normally.</div></div>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:4px 14px;margin-top:6px;max-height:220px;overflow-y:auto;border:1px solid var(--border2);border-radius:8px;padding:8px 10px">
|
||
${NOTIFY_TARGET_OPTS.map(o=>`<label style="display:flex;align-items:center;gap:7px;font-size:12px;cursor:pointer"><input type="checkbox" class="set-notifystep" value="${esc(o.v)}" ${notifyDisabledSet.has(o.v)?'':'checked'}/> ${esc(o.l)}</label>`).join('')}
|
||
</div>
|
||
</div>
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Confirm Email Delivery</div><div class="set-h">Check SMTP connects with the credentials in .env, and/or send a real test email to confirm it's actually delivered. (Not part of the Save above — these run immediately.)</div></div>
|
||
<div style="display:flex;flex-direction:column;gap:6px;width:280px">
|
||
<button type="button" class="btn b-cancel" id="notify-test-verify" style="width:100%">Test SMTP Connection</button>
|
||
<div style="display:flex;gap:6px">
|
||
<input class="form-input" id="notify-test-to" type="email" placeholder="you@company.com" style="flex:1"/>
|
||
<button type="button" class="btn b-primary" id="notify-test-send">Send Test</button>
|
||
</div>
|
||
<div id="notify-test-result" style="font-size:11px;min-height:14px"></div>
|
||
</div>
|
||
</div>`, 'set-save-notify');
|
||
|
||
// ── Downtime Analysis (services/downtimeAnalysisStore.js) ──
|
||
// Default period/Base Days + the canonical cause taxonomy + per-OEE-tab
|
||
// field→cause mapping. Field lists below are a hardcoded mirror of each
|
||
// tab's own OEE_SCHEMA (public/oee.html) — duplicated rather than fetched,
|
||
// same convention as WORK_ORDER_STEP_KEYS elsewhere in this app, since the
|
||
// schema itself lives client-side and rarely changes.
|
||
const DT_TAB_FIELDS = {
|
||
ebb: [['waterTea','Water / Tea Break'],['meal','Meal break'],['breakdown','Breakdown'],['cleaning','Cleaning'],['changeOver','Change over'],['slowSpeed','Slow speed / High Cycle Time'],['microStops','Micro Stops < 10 min'],['emptyRun','Empty run'],['startShut','Start up / shut down']],
|
||
pd: [['waterTea','Water / Tea Break'],['meal','Meal break'],['breakdown','Breakdown'],['cleaning','Cleaning'],['changeOver','Change over'],['slowSpeed','Slow speed / High Cycle Time'],['microStops','Micro Stops < 10 min'],['emptyRun','Empty run'],['startShut','Start up / shut down']],
|
||
sheet: [['waterTea','Water / Tea Break'],['meal','Meal break'],['breakdown','Breakdown'],['cleaning','Cleaning'],['changeOver','Change over'],['slowSpeed','Slow speed / High Cycle Time'],['headClean','Head Clean'],['otherProblem','Other Problem'],['mcStartShut','M/C Start up / shut down']],
|
||
moulding: [['waterTea','Water / Tea Break'],['meal','Meal break'],['breakdown','Breakdown'],['cleaning','Cleaning'],['changeOver','Change over'],['powerCutSlow','Power Cut / Slow Speed'],['headClean','Head Clean'],['nozzleClean','Nozzle Clean'],['mcStartShut','M/C Start up / shut down']],
|
||
autoclave:[['boiler','Boiler breakdown'],['waterSys','Water system breakdown'],['bopt','BOPT breakdown'],['coolingTower','Cooling tower breakdown'],['others','Others']],
|
||
};
|
||
const DT_TAB_LABELS = { ebb:'EBB Production', pd:'PD Production', sheet:'Plastic Sheet Plant', moulding:'Moulding', autoclave:'Autoclave' };
|
||
let dtCfg = {};
|
||
try { dtCfg = JSON.parse(s.downtimeAnalysisConfig || '{}') || {}; } catch (_e) { dtCfg = {}; }
|
||
const dtCauses = Array.isArray(dtCfg.causes) ? dtCfg.causes : [];
|
||
const dtMap = dtCfg.tabFieldMap || {};
|
||
function dtCauseOptions(sel) {
|
||
return `<option value="">— Not included —</option>` + dtCauses.map(c => `<option value="${esc(c)}" ${sel===c?'selected':''}>${esc(c)}</option>`).join('');
|
||
}
|
||
section('⏱', 'Downtime Analysis', `
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Default Period & Base Days</div><div class="set-h">The report's own page lets a user override these per run — this is just what it starts pre-filled with. Base Days is the common working-day calendar for the period (e.g. 330); a machine whose OEE entries only start partway through the period gets this prorated down automatically.</div></div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||
<div><label class="fl">Period From</label><input class="form-input" id="set-dtPeriodFrom" type="date" value="${esc(dtCfg.periodFrom||'')}" style="width:150px"/></div>
|
||
<div><label class="fl">Period To</label><input class="form-input" id="set-dtPeriodTo" type="date" value="${esc(dtCfg.periodTo||'')}" style="width:150px"/></div>
|
||
<div><label class="fl">Base Days</label><input class="form-input" id="set-dtBaseDays" type="number" min="1" value="${esc(dtCfg.baseDays||330)}" style="width:100px"/></div>
|
||
</div>
|
||
</div>
|
||
<div class="set-row set-col">
|
||
<div class="set-info"><div class="set-l">Downtime Causes</div><div class="set-h">Comma-separated — this is the standardized list every OEE tab's own fields get mapped onto below. Add a new cause here, Save, then reopen this section to assign fields to it.</div></div>
|
||
<textarea class="form-input" id="set-dtCauses" rows="2" style="margin-top:6px;resize:vertical">${esc(dtCauses.join(', '))}</textarea>
|
||
</div>
|
||
<div class="set-row set-col">
|
||
<div class="set-info"><div class="set-l">Field → Cause Mapping (per OEE tab)</div><div class="set-h">Different tabs use different field names for the same real-world cause (e.g. Sheet's "Head Clean" vs EBB's "Micro Stops") — map each one onto a standard cause above, or leave it "Not included" to exclude it from the report entirely.</div></div>
|
||
<div style="display:flex;flex-direction:column;gap:14px;margin-top:8px">
|
||
${Object.keys(DT_TAB_FIELDS).map(tab => `
|
||
<div>
|
||
<div style="font-size:11px;font-weight:700;color:var(--text2);margin-bottom:5px">${esc(DT_TAB_LABELS[tab]||tab)}</div>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px">
|
||
${DT_TAB_FIELDS[tab].map(([key,label]) => `
|
||
<div style="display:flex;flex-direction:column;gap:5px;padding:8px 10px;border:1px solid var(--border2);border-radius:7px">
|
||
<span style="font-size:10.5px;color:var(--text2)">${esc(label)}</span>
|
||
<select class="form-input dt-map-sel" data-tab="${esc(tab)}" data-field="${esc(key)}" style="width:100%;height:32px;font-size:11px">${dtCauseOptions(dtMap[tab]&&dtMap[tab][key])}</select>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>`, 'set-save-downtime');
|
||
|
||
// ── Short in Stock — Auto Email Alerts (services/shortStockAlertStore.js) ──
|
||
// Fully independent feature: a background job (started once in server.js,
|
||
// runs every 15 min) sums each watched item/group's on-hand quantity and
|
||
// emails the concerned user(s) the FIRST time it hits zero — never on a
|
||
// report screen, never gated by the Stage-Change Email settings above.
|
||
let shortStockRules = [];
|
||
try { shortStockRules = JSON.parse(s.shortStockAlertRules || '[]') || []; } catch (_e) { shortStockRules = []; }
|
||
section('📉', 'Short in Stock — Auto Email Alerts', `
|
||
<div class="set-row">
|
||
<div class="set-info"><div class="set-l">Enable Auto Alerts</div><div class="set-h">A background check (every 15 minutes) emails the concerned user(s) below the FIRST time a watched item/group's total on-hand quantity (across every warehouse) hits zero — not repeatedly while it stays at zero, only again once it's restocked and drops to zero a second time.</div></div>
|
||
${yesNo('set-shortStockAlertEnabled', s.shortStockAlertEnabled)}
|
||
</div>
|
||
<div class="set-row set-col">
|
||
<div class="chk-head">
|
||
<div class="set-info"><div class="set-l">Watch-List</div><div class="set-h">Add a specific Item Code, or an entire Item Group (every item in it), each with its own concerned user(s).</div></div>
|
||
<button type="button" class="chk-mini" id="sstock-add-row">+ Add</button>
|
||
</div>
|
||
<div id="sstock-rows" style="display:flex;flex-direction:column;gap:8px;margin-top:6px"></div>
|
||
</div>`, 'set-save-shortstock');
|
||
|
||
// ── Receipt: Warehouse → Transaction Type mapping (per company) ──
|
||
const whWin = document.createElement('div'); whWin.className = 'win';
|
||
whWin.innerHTML = `
|
||
<div class="win-bar" id="wtt-hd" style="cursor:pointer;user-select:none"><div class="win-title"><span id="wtt-chev" style="display:inline-block;transition:transform .15s;margin-right:2px">▸</span> 🔀 Receipt — Warehouse Transaction Types</div></div>
|
||
<div id="wtt-body" style="display:none;padding:14px">
|
||
<div class="uc-section-note">Map each warehouse to a Transaction Type for <strong>Receipt from Production</strong>. On the receipt page the type is filled automatically from this mapping — the user can't pick it. Unmapped warehouses default to <strong>Complete</strong>.</div>
|
||
<div style="display:flex;gap:8px;align-items:center;margin:10px 0">
|
||
<label class="form-label" style="margin:0">Company</label>
|
||
<select class="form-input" id="wtt-company" style="width:220px"></select>
|
||
<button class="btn b-primary" id="wtt-save" style="margin-left:auto" disabled>Save Mapping</button>
|
||
</div>
|
||
<div id="wtt-list"><div style="color:var(--text3);font-size:12px;padding:8px">Loading…</div></div>
|
||
</div>`;
|
||
div.appendChild(whWin);
|
||
{
|
||
const wttHd = whWin.querySelector('#wtt-hd');
|
||
const wttBody = whWin.querySelector('#wtt-body');
|
||
const wttChev = whWin.querySelector('#wtt-chev');
|
||
if (wttHd && wttBody && wttChev) {
|
||
wttHd.onclick = (e) => {
|
||
if (e.target.closest('button')) return;
|
||
const open = wttBody.style.display !== 'none';
|
||
wttBody.style.display = open ? 'none' : 'block';
|
||
wttChev.style.transform = open ? '' : 'rotate(90deg)';
|
||
};
|
||
}
|
||
}
|
||
|
||
setTimeout(() => {
|
||
// ── Stage-change email notification recipients (per step/module) ──
|
||
let notifyExtra = {};
|
||
try { notifyExtra = JSON.parse(s.notifyExtraEmails || '{}') || {}; } catch (_e) { notifyExtra = {}; }
|
||
const notifyRowsEl = div.querySelector('#notify-rows');
|
||
function notifyRowHtml(target, emails) {
|
||
return `<div class="notify-row" style="display:flex;gap:8px;align-items:center">
|
||
<select class="form-input notify-target" style="width:280px">
|
||
${NOTIFY_TARGET_OPTS.map(o=>`<option value="${esc(o.v)}" ${o.v===target?'selected':''}>${esc(o.l)}</option>`).join('')}
|
||
</select>
|
||
<input class="form-input notify-emails" style="flex:1" placeholder="comma-separated emails" value="${esc(emails||'')}"/>
|
||
<button type="button" class="chk-mini notify-rm" title="Remove">✕</button>
|
||
</div>`;
|
||
}
|
||
function renderNotifyRows() {
|
||
const keys = Object.keys(notifyExtra);
|
||
notifyRowsEl.innerHTML = keys.length
|
||
? keys.map(k => notifyRowHtml(k, notifyExtra[k])).join('')
|
||
: '<div style="color:var(--text3);font-size:12px">No extra recipients configured.</div>';
|
||
notifyRowsEl.querySelectorAll('.notify-rm').forEach(b => b.onclick = function(){ this.closest('.notify-row').remove(); if(!notifyRowsEl.children.length) renderNotifyRows(); });
|
||
}
|
||
renderNotifyRows();
|
||
const notifyAddBtn = div.querySelector('#notify-add-row');
|
||
if (notifyAddBtn) notifyAddBtn.onclick = () => {
|
||
if (notifyRowsEl.querySelector('div[style*="color:var(--text3)"]')) notifyRowsEl.innerHTML = '';
|
||
const wrap = document.createElement('div');
|
||
wrap.innerHTML = notifyRowHtml(NOTIFY_TARGET_OPTS[0].v, '');
|
||
const row = wrap.firstElementChild;
|
||
notifyRowsEl.appendChild(row);
|
||
row.querySelector('.notify-rm').onclick = () => { row.remove(); if(!notifyRowsEl.children.length) renderNotifyRows(); };
|
||
};
|
||
function collectNotifyExtra() {
|
||
const out = {};
|
||
notifyRowsEl.querySelectorAll('.notify-row').forEach(row => {
|
||
const k = row.querySelector('.notify-target').value;
|
||
const v = row.querySelector('.notify-emails').value.trim();
|
||
if (k && v) out[k] = v;
|
||
});
|
||
return out;
|
||
}
|
||
|
||
// ── Notify by Item Group rows ──
|
||
let notifyByGroup = {};
|
||
try { notifyByGroup = JSON.parse(s.notifyItemGroupEmails || '{}') || {}; } catch (_e) { notifyByGroup = {}; }
|
||
const notifyIgRowsEl = div.querySelector('#notifyig-rows');
|
||
function notifyIgRowHtml(groupCode, emails) {
|
||
return `<div class="notify-row" style="display:flex;gap:8px;align-items:center">
|
||
<select class="form-input notifyig-group" style="width:280px">
|
||
${ITEM_GROUPS.map(g=>`<option value="${esc(g.code)}" ${String(g.code)===String(groupCode)?'selected':''}>${esc(g.name)} (${esc(g.code)})</option>`).join('')}
|
||
</select>
|
||
<input class="form-input notifyig-emails" style="flex:1" placeholder="comma-separated emails" value="${esc(emails||'')}"/>
|
||
<button type="button" class="chk-mini notifyig-rm" title="Remove">✕</button>
|
||
</div>`;
|
||
}
|
||
function renderNotifyIgRows() {
|
||
const keys = Object.keys(notifyByGroup);
|
||
notifyIgRowsEl.innerHTML = keys.length
|
||
? keys.map(k => notifyIgRowHtml(k, notifyByGroup[k])).join('')
|
||
: '<div style="color:var(--text3);font-size:12px">No item-group recipients configured.</div>';
|
||
notifyIgRowsEl.querySelectorAll('.notifyig-rm').forEach(b => b.onclick = function(){ this.closest('.notify-row').remove(); if(!notifyIgRowsEl.children.length) renderNotifyIgRows(); });
|
||
}
|
||
renderNotifyIgRows();
|
||
const notifyIgAddBtn = div.querySelector('#notifyig-add-row');
|
||
if (notifyIgAddBtn) notifyIgAddBtn.onclick = () => {
|
||
if (!ITEM_GROUPS.length) { toast('Item groups not loaded (SAP unreachable?)', 'err'); return; }
|
||
if (notifyIgRowsEl.querySelector('div[style*="color:var(--text3)"]')) notifyIgRowsEl.innerHTML = '';
|
||
const wrap = document.createElement('div');
|
||
wrap.innerHTML = notifyIgRowHtml(ITEM_GROUPS[0].code, '');
|
||
const row = wrap.firstElementChild;
|
||
notifyIgRowsEl.appendChild(row);
|
||
row.querySelector('.notifyig-rm').onclick = () => { row.remove(); if(!notifyIgRowsEl.children.length) renderNotifyIgRows(); };
|
||
};
|
||
function collectNotifyByGroup() {
|
||
const out = {};
|
||
notifyIgRowsEl.querySelectorAll('.notify-row').forEach(row => {
|
||
const k = row.querySelector('.notifyig-group')?.value;
|
||
const v = row.querySelector('.notifyig-emails')?.value.trim();
|
||
if (k && v) out[k] = v;
|
||
});
|
||
return out;
|
||
}
|
||
|
||
// ── Short in Stock — Watch-List rows ──
|
||
const sstockRowsEl = div.querySelector('#sstock-rows');
|
||
function sstockRowHtml(rule) {
|
||
const recip = new Set(rule.recipients || []);
|
||
return `<div class="sstock-row" data-id="${esc(rule.id)}" style="display:flex;flex-direction:column;gap:6px;padding:10px;border:1px solid var(--glass-border);border-radius:9px;background:var(--surface2,#f8fafc)">
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<select class="form-input sstock-type" style="width:110px">
|
||
<option value="ITEM" ${rule.type!=='GROUP'?'selected':''}>Item Code</option>
|
||
<option value="GROUP" ${rule.type==='GROUP'?'selected':''}>Item Group</option>
|
||
</select>
|
||
<input class="form-input sstock-item-val" style="flex:1;font-family:var(--mono);${rule.type==='GROUP'?'display:none':''}" placeholder="Item Code" value="${rule.type!=='GROUP'?esc(rule.value||''):''}"/>
|
||
<select class="form-select sstock-group-val" style="flex:1;${rule.type==='GROUP'?'':'display:none'}">
|
||
<option value="">— Select Item Group —</option>
|
||
${ITEM_GROUPS.map(g=>`<option value="${esc(g.code)}" ${String(rule.value)===String(g.code)?'selected':''}>${esc(g.name)} (${esc(g.code)})</option>`).join('')}
|
||
</select>
|
||
<button type="button" class="chk-mini sstock-rm" title="Remove">✕</button>
|
||
</div>
|
||
<div>
|
||
<div style="font-size:10px;color:var(--text3,#94a3b8);text-transform:uppercase;letter-spacing:.3px;margin-bottom:4px">Concerned User(s)</div>
|
||
<div class="sstock-users" style="display:flex;flex-wrap:wrap;gap:4px 12px;max-height:110px;overflow-y:auto;border:1px solid var(--glass-border);border-radius:7px;padding:6px 8px">
|
||
${_users.map(u=>`<label style="display:flex;align-items:center;gap:5px;font-size:11.5px;cursor:pointer"><input type="checkbox" class="sstock-user-cb" value="${esc(u.username)}" ${recip.has(u.username)?'checked':''}/> ${esc(u.fullName||u.username)}</label>`).join('')}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
function renderSstockRows() {
|
||
sstockRowsEl.innerHTML = shortStockRules.length
|
||
? shortStockRules.map(sstockRowHtml).join('')
|
||
: '<div style="color:var(--text3);font-size:12px">No items/groups watched yet.</div>';
|
||
wireSstockRows();
|
||
}
|
||
function wireSstockRows() {
|
||
sstockRowsEl.querySelectorAll('.sstock-row').forEach(row => {
|
||
const typeSel = row.querySelector('.sstock-type');
|
||
const itemInp = row.querySelector('.sstock-item-val');
|
||
const groupSel = row.querySelector('.sstock-group-val');
|
||
typeSel.onchange = () => {
|
||
const isGroup = typeSel.value === 'GROUP';
|
||
itemInp.style.display = isGroup ? 'none' : '';
|
||
groupSel.style.display = isGroup ? '' : 'none';
|
||
};
|
||
row.querySelector('.sstock-rm').onclick = () => { row.remove(); if (!sstockRowsEl.children.length) renderSstockRows(); };
|
||
});
|
||
}
|
||
renderSstockRows();
|
||
const sstockAddBtn = div.querySelector('#sstock-add-row');
|
||
if (sstockAddBtn) sstockAddBtn.onclick = () => {
|
||
if (sstockRowsEl.querySelector('div[style*="color:var(--text3)"]')) sstockRowsEl.innerHTML = '';
|
||
const wrap = document.createElement('div');
|
||
wrap.innerHTML = sstockRowHtml({ id: 'r' + Date.now(), type: 'ITEM', value: '', recipients: [] });
|
||
const row = wrap.firstElementChild;
|
||
sstockRowsEl.appendChild(row);
|
||
wireSstockRows();
|
||
};
|
||
function collectSstockRules() {
|
||
const out = [];
|
||
sstockRowsEl.querySelectorAll('.sstock-row').forEach(row => {
|
||
const type = row.querySelector('.sstock-type').value;
|
||
const value = type === 'GROUP' ? row.querySelector('.sstock-group-val').value : row.querySelector('.sstock-item-val').value.trim().toUpperCase();
|
||
const recipients = Array.from(row.querySelectorAll('.sstock-user-cb:checked')).map(cb => cb.value);
|
||
if (value && recipients.length) out.push({ id: row.dataset.id, type, value, recipients });
|
||
});
|
||
return out;
|
||
}
|
||
|
||
// ── Confirm email delivery (SMTP test) ──
|
||
const testResult = div.querySelector('#notify-test-result');
|
||
const showTestResult = (ok, msg) => { testResult.style.color = ok ? 'var(--green,#0aa457)' : 'var(--red,#c81e1e)'; testResult.textContent = msg; };
|
||
const testVerifyBtn = div.querySelector('#notify-test-verify');
|
||
if (testVerifyBtn) testVerifyBtn.onclick = async () => {
|
||
testVerifyBtn.disabled = true; showTestResult(true, 'Checking…');
|
||
try { const r = await api('GET', '/settings/test-email/verify'); showTestResult(r.success, r.message); }
|
||
catch (e) { showTestResult(false, e.message || 'Check failed'); }
|
||
testVerifyBtn.disabled = false;
|
||
};
|
||
const testSendBtn = div.querySelector('#notify-test-send');
|
||
if (testSendBtn) testSendBtn.onclick = async () => {
|
||
const to = div.querySelector('#notify-test-to').value.trim();
|
||
if (!to) { showTestResult(false, 'Enter a recipient email first'); return; }
|
||
testSendBtn.disabled = true; showTestResult(true, 'Sending…');
|
||
try { const r = await api('POST', '/settings/test-email', { to }); showTestResult(r.success, r.message); }
|
||
catch (e) { showTestResult(false, e.message || 'Send failed'); }
|
||
testSendBtn.disabled = false;
|
||
};
|
||
|
||
// Man Power tabs checklist — live count + select-all / clear-all.
|
||
const mpBoxes = () => Array.from(div.querySelectorAll('.set-mptab'));
|
||
const mpCount = () => { const el = div.querySelector('#mpt-count'); if (el) el.textContent = `${mpBoxes().filter(c=>c.checked).length} of ${mpBoxes().length} shown`; };
|
||
mpCount();
|
||
mpBoxes().forEach(c => c.addEventListener('change', mpCount));
|
||
const mpAll = div.querySelector('#mpt-all'); if (mpAll) mpAll.onclick = () => { mpBoxes().forEach(c=>c.checked=true); mpCount(); };
|
||
const mpNone = div.querySelector('#mpt-none'); if (mpNone) mpNone.onclick = () => { mpBoxes().forEach(c=>c.checked=false); mpCount(); };
|
||
|
||
// Board Dashboard product-groups checklist — live count + select-all / clear-all.
|
||
const bpgBoxes = () => Array.from(div.querySelectorAll('.set-boardgrp'));
|
||
const bpgCount = () => { const el = div.querySelector('#bpg-count'); if (el) el.textContent = `${bpgBoxes().filter(c=>c.checked).length} of ${bpgBoxes().length} shown`; };
|
||
bpgCount();
|
||
bpgBoxes().forEach(c => c.addEventListener('change', bpgCount));
|
||
const bpgAll = div.querySelector('#bpg-all'); if (bpgAll) bpgAll.onclick = () => { bpgBoxes().forEach(c=>c.checked=true); bpgCount(); };
|
||
const bpgNone = div.querySelector('#bpg-none'); if (bpgNone) bpgNone.onclick = () => { bpgBoxes().forEach(c=>c.checked=false); bpgCount(); };
|
||
|
||
// PPC Report item-groups checklist — live count + select-all / clear-all.
|
||
const ppcgBoxes = () => Array.from(div.querySelectorAll('.set-ppcgrp'));
|
||
const ppcgCount = () => { const el = div.querySelector('#ppcg-count'); if (el) el.textContent = `${ppcgBoxes().filter(c=>c.checked).length} of ${ppcgBoxes().length} shown`; };
|
||
ppcgCount();
|
||
ppcgBoxes().forEach(c => c.addEventListener('change', ppcgCount));
|
||
const ppcgAll = div.querySelector('#ppcg-all'); if (ppcgAll) ppcgAll.onclick = () => { ppcgBoxes().forEach(c=>c.checked=true); ppcgCount(); };
|
||
const ppcgNone = div.querySelector('#ppcg-none'); if (ppcgNone) ppcgNone.onclick = () => { ppcgBoxes().forEach(c=>c.checked=false); ppcgCount(); };
|
||
|
||
// PPC Report external API key — generated client-side (random, not
|
||
// secret-derived), only takes effect once Save is clicked like any
|
||
// other setting here.
|
||
const ppcKeyInput = div.querySelector('#set-ppcApiKey');
|
||
const ppcKeyGen = div.querySelector('#ppc-key-gen');
|
||
if (ppcKeyGen) ppcKeyGen.onclick = () => {
|
||
const bytes = new Uint8Array(24);
|
||
(window.crypto || window.msCrypto).getRandomValues(bytes);
|
||
ppcKeyInput.value = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
||
toast('New key generated — click Save to activate it');
|
||
};
|
||
const ppcKeyCopy = div.querySelector('#ppc-key-copy');
|
||
if (ppcKeyCopy) ppcKeyCopy.onclick = async () => {
|
||
if (!ppcKeyInput.value) { toast('No key to copy', 'err'); return; }
|
||
try { await navigator.clipboard.writeText(ppcKeyInput.value); toast('Copied to clipboard'); }
|
||
catch { toast('Copy failed — select and copy manually', 'err'); }
|
||
};
|
||
const ppcKeyClear = div.querySelector('#ppc-key-clear');
|
||
if (ppcKeyClear) ppcKeyClear.onclick = () => { ppcKeyInput.value = ''; toast('Cleared — click Save to disable external access'); };
|
||
|
||
// ── Warehouse → Transaction Type mapping ──
|
||
(async () => {
|
||
const coSel = div.querySelector('#wtt-company');
|
||
const listEl = div.querySelector('#wtt-list');
|
||
const saveBtn = div.querySelector('#wtt-save');
|
||
if (!coSel) return;
|
||
let companies = [{ value: window.__DEFAULT_COMPANY__, label: window.__DEFAULT_COMPANY__ }];
|
||
try { const list = await window.fetchCompanyList(); if (list && list.length) companies = list; } catch (_e) {}
|
||
// Pre-select the app's OPERATING company (window.__DEFAULT_COMPANY__) so the
|
||
// mapping is saved under the same company the Receipt page reads by default.
|
||
const def = window.__DEFAULT_COMPANY__;
|
||
if (def && !companies.some(c => c.value === def)) companies.unshift({ value: def, label: def });
|
||
coSel.innerHTML = companies.map(c => `<option value="${esc(c.value)}" ${c.value === def ? 'selected' : ''}>${esc(c.label || c.value)}</option>`).join('');
|
||
async function loadWtt() {
|
||
const company = coSel.value;
|
||
listEl.innerHTML = '<div style="color:var(--text3);font-size:12px;padding:8px">Loading warehouses…</div>';
|
||
saveBtn.disabled = true;
|
||
let whs = [], map = {};
|
||
try { const w = await api('GET', `/sap/lookup/warehouses?company=${encodeURIComponent(company)}`); whs = w.data || []; } catch (_e) {}
|
||
try { const m = await api('GET', `/warehouse-trantype?company=${encodeURIComponent(company)}`); map = m.data || {}; } catch (_e) {}
|
||
if (!whs.length) { listEl.innerHTML = '<div style="color:var(--text3);font-size:12px;padding:8px">No warehouses found for this company.</div>'; return; }
|
||
listEl.innerHTML = `<div style="max-height:340px;overflow:auto;display:grid;grid-template-columns:1fr 1fr;gap:8px">${whs.map(w => `
|
||
<label style="display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px solid var(--border);border-radius:8px">
|
||
<span style="font-family:var(--mono);font-size:11px;color:var(--text3);min-width:52px">${esc(w.code)}</span>
|
||
<span style="flex:1;font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(w.name || '')}</span>
|
||
<select class="form-input wtt-sel" data-wh="${esc(w.code)}" style="width:110px;height:30px;padding:2px 8px">
|
||
<option value="C" ${map[w.code] !== 'R' ? 'selected' : ''}>Complete</option>
|
||
<option value="R" ${map[w.code] === 'R' ? 'selected' : ''}>Reject</option>
|
||
</select>
|
||
</label>`).join('')}</div>`;
|
||
saveBtn.disabled = false;
|
||
}
|
||
coSel.onchange = loadWtt;
|
||
// ── Inventory Transfer Series dropdown (lives in the same company
|
||
// context as the warehouse mapping above — SAP's NNM1 series table is
|
||
// per-company) ──
|
||
(async () => {
|
||
const selEl = div.querySelector('#set-inventoryTransferSeries');
|
||
if (!selEl) return;
|
||
const current = selEl.dataset.current || '';
|
||
try {
|
||
const r = await api('GET', `/sap/lookup/series?objectCode=67&company=${encodeURIComponent(window.__DEFAULT_COMPANY__ || '')}`);
|
||
const list = (r.data || []).filter(x => !x.locked);
|
||
const opts = ['<option value="">— SAP default series —</option>']
|
||
.concat(list.map(x => `<option value="${x.series}" ${String(x.series) === current ? 'selected' : ''}>${esc(x.seriesName)} (${x.series})</option>`));
|
||
// Keep a currently-saved value visible even if it's locked/missing from the live list.
|
||
if (current && !list.some(x => String(x.series) === current)) {
|
||
opts.push(`<option value="${esc(current)}" selected>#${esc(current)} (not found in SAP — keeping saved value)</option>`);
|
||
}
|
||
selEl.innerHTML = opts.join('');
|
||
} catch (_e) {
|
||
selEl.innerHTML = `<option value="${esc(current)}" selected>#${esc(current) || '(none)'} — could not load series names</option>`;
|
||
}
|
||
})();
|
||
saveBtn.onclick = async () => {
|
||
const map = {};
|
||
div.querySelectorAll('.wtt-sel').forEach(s => { map[s.dataset.wh] = s.value; });
|
||
saveBtn.disabled = true;
|
||
try { await api('PUT', '/warehouse-trantype', { company: coSel.value, map }); toast('✓ Warehouse mapping saved'); }
|
||
catch (e) { toast(e.message || 'Save failed', 'err'); }
|
||
saveBtn.disabled = false;
|
||
};
|
||
loadWtt();
|
||
})();
|
||
|
||
// Shared save handler for every per-card button — submits ONLY that
|
||
// card's own fields (the backend already merges a partial payload), so
|
||
// saving one card never touches or discards unsaved edits sitting in
|
||
// any other still-open card. Deliberately does NOT call render() on
|
||
// success — that would rebuild the whole tab from _settings and wipe
|
||
// out anything unsaved elsewhere on the page (same principle as never
|
||
// calling a full render() from an input handler).
|
||
async function saveSettingsSection(btn, getPayload, validate) {
|
||
if (!btn) return;
|
||
const payload = getPayload();
|
||
if (validate) { const err = validate(payload); if (err) { toast(err, 'err'); return; } }
|
||
const orig = btn.textContent;
|
||
btn.disabled = true; btn.textContent = 'Saving…';
|
||
try {
|
||
const r = await api('PUT', '/settings', payload);
|
||
_settings = r.data || _settings;
|
||
toast('✓ Saved');
|
||
btn.textContent = '✓ Saved';
|
||
setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 1200);
|
||
} catch (e) {
|
||
toast(e.message, 'err');
|
||
btn.textContent = orig; btn.disabled = false;
|
||
}
|
||
}
|
||
const wireSave = (btnId, getPayload, validate) => {
|
||
const btn = div.querySelector(`#${btnId}`);
|
||
if (btn) btn.onclick = () => saveSettingsSection(btn, getPayload, validate);
|
||
};
|
||
|
||
wireSave('set-save-bom', () => ({
|
||
bomApprovalLevels: div.querySelector('#set-bomApprovalLevels').value,
|
||
workOrderSkipQc: div.querySelector('#set-workOrderSkipQc').value,
|
||
}));
|
||
|
||
wireSave('set-save-reqcalc', () => ({
|
||
reqCalcPeriodAEnabled: div.querySelector('#set-reqCalcPeriodAEnabled').value,
|
||
reqCalcPeriodBEnabled: div.querySelector('#set-reqCalcPeriodBEnabled').value,
|
||
reqCalcPeriodALabel: div.querySelector('#set-reqCalcPeriodALabel').value,
|
||
reqCalcPeriodBLabel: div.querySelector('#set-reqCalcPeriodBLabel').value,
|
||
}), p => (p.reqCalcPeriodAEnabled==='false' && p.reqCalcPeriodBEnabled==='false') ? 'At least one of Period A / Period B must stay enabled' : null);
|
||
|
||
wireSave('set-save-batchissuance', () => ({
|
||
biAllowExceedPending: div.querySelector('#set-biAllowExceedPending').value,
|
||
biSfgWorkflowEnabled: div.querySelector('#set-biSfgWorkflowEnabled').value,
|
||
biMultiSolutionVolumesEnabled: div.querySelector('#set-biMultiSolutionVolumesEnabled').value,
|
||
}));
|
||
|
||
wireSave('set-save-requirementreview', () => ({
|
||
requirementStoreReviewEnabled: div.querySelector('#set-requirementStoreReviewEnabled').value,
|
||
requirementStoreReviewHardGate: div.querySelector('#set-requirementStoreReviewHardGate').value,
|
||
}));
|
||
|
||
wireSave('set-save-prepwo', () => ({
|
||
preWoStoreReviewEnabled: div.querySelector('#set-preWoStoreReviewEnabled').value,
|
||
}));
|
||
|
||
wireSave('set-save-workorder', () => ({
|
||
woHideSfgFromPacking: div.querySelector('#set-woHideSfgFromPacking').value,
|
||
woShowWfiInRawMaterial: div.querySelector('#set-woShowWfiInRawMaterial').value,
|
||
woHideStdQtyUnitPicker: div.querySelector('#set-woHideStdQtyUnitPicker').value,
|
||
woSolutionBatchMaxEditLtr: div.querySelector('#set-woSolutionBatchMaxEditLtr').value,
|
||
woSolutionBatchMaxEditLtrPD: div.querySelector('#set-woSolutionBatchMaxEditLtrPD').value,
|
||
woSolutionBatchRoundLtr: div.querySelector('#set-woSolutionBatchRoundLtr').value,
|
||
woSolutionBatchRoundLtrPD: div.querySelector('#set-woSolutionBatchRoundLtrPD').value,
|
||
woWeighingBalanceIds: div.querySelector('#set-woWeighingBalanceIds').value,
|
||
woCustomQtyUnits: div.querySelector('#set-woCustomQtyUnits').value,
|
||
woRawPotencyFactors: JSON.stringify((() => {
|
||
const m = {};
|
||
String(div.querySelector('#set-woRawPotencyFactors').value || '').split('\n').forEach(line => {
|
||
const [code, pct] = line.split('=').map(s => s.trim());
|
||
if (code && pct && !isNaN(parseFloat(pct))) m[code] = parseFloat(pct);
|
||
});
|
||
return m;
|
||
})()),
|
||
woRawQtyIssuedSource: div.querySelector('#set-woRawQtyIssuedSource').value,
|
||
woPackQtyRoundUp: div.querySelector('#set-woPackQtyRoundUp').value,
|
||
}));
|
||
|
||
wireSave('set-save-rmovg', () => ({
|
||
rmOvgOverrideEnabled: div.querySelector('#set-rmOvgOverrideEnabled').value,
|
||
}));
|
||
wireRmOvgList(div);
|
||
|
||
wireSave('set-save-deviation', () => ({
|
||
deviationEnabledTypes: Array.from(div.querySelectorAll('.set-devtype:checked')).map(c=>c.value).join(','),
|
||
deviationScrapWarehouse: div.querySelector('#set-deviationScrapWarehouse').value,
|
||
deviationRequireQaApproval: div.querySelector('#set-deviationRequireQaApproval').value,
|
||
deviationDeferIssueUntilApproval: div.querySelector('#set-deviationDeferIssueUntilApproval').value,
|
||
deviationShowRemoveOldLineButton: div.querySelector('#set-deviationShowRemoveOldLineButton').value,
|
||
deviationDamageSeries: div.querySelector('#set-deviationDamageSeries').value.trim(),
|
||
}), p => !p.deviationEnabledTypes ? 'Select at least one Deviation type' : null);
|
||
|
||
wireSave('set-save-invtransfer', () => ({
|
||
inventoryTransferSeries: div.querySelector('#set-inventoryTransferSeries').value.trim(),
|
||
}));
|
||
|
||
wireSave('set-save-itemcode', () => {
|
||
const floors = {};
|
||
div.querySelectorAll('.set-itemcodefloor').forEach(inp => {
|
||
const n = parseInt(inp.value);
|
||
if (!isNaN(n) && n > 0) floors[inp.dataset.prefix] = n;
|
||
});
|
||
return {
|
||
itemApprovalEnabled: div.querySelector('#set-itemApprovalEnabled').value,
|
||
itemCodeSeriesFloor: JSON.stringify(floors),
|
||
};
|
||
});
|
||
|
||
wireSave('set-save-prodorder', () => ({
|
||
prodOrderTypes: Array.from(div.querySelectorAll('.set-prodtype:checked')).map(c=>c.value).join(','),
|
||
poRequireStockAvailability: div.querySelector('#set-poRequireStockAvailability').value,
|
||
receiptRequireFullQty: div.querySelector('#set-receiptRequireFullQty').value,
|
||
receiptExcludeByProductFromTotal: div.querySelector('#set-receiptExcludeByProductFromTotal').value,
|
||
closeRequireFullQty: div.querySelector('#set-closeRequireFullQty').value,
|
||
poCloseRequireTracking: div.querySelector('#set-poCloseRequireTracking').value,
|
||
poRequireFullIssuance: div.querySelector('#set-poRequireFullIssuance').value,
|
||
receiptAutoclaveRejection: div.querySelector('#set-receiptAutoclaveRejection').value,
|
||
receiptAutoclaveItemGroups: div.querySelector('#set-receiptAutoclaveItemGroups').value,
|
||
}), p => !p.prodOrderTypes ? 'Select at least one Production Order type' : null);
|
||
|
||
wireSave('set-save-rejection', () => ({
|
||
rejectionLookupProdOrderRemarks: div.querySelector('#set-rejectionLookupProdOrderRemarks').value,
|
||
rejectionShifts: div.querySelector('#set-rejectionShifts').value,
|
||
}));
|
||
|
||
wireSave('set-save-manpower', () => ({
|
||
manpowerTabs: Array.from(div.querySelectorAll('.set-mptab:checked')).map(c=>c.value).join(','),
|
||
}), p => !p.manpowerTabs ? 'Select at least one Man Power tab' : null);
|
||
|
||
wireSave('set-save-oee', () => ({
|
||
oeeMachineNames: JSON.stringify((() => {
|
||
const m = {};
|
||
div.querySelectorAll('.set-oee-machines').forEach(el => { if (el.value.trim()) m[el.dataset.tab] = el.value.trim(); });
|
||
return m;
|
||
})()),
|
||
}));
|
||
|
||
wireSave('set-save-dashboard', () => ({
|
||
boardProductGroups: Array.from(div.querySelectorAll('.set-boardgrp:checked')).map(c=>c.value).join(','),
|
||
}));
|
||
|
||
wireSave('set-save-ppc', () => ({
|
||
ppcItemGroups: Array.from(div.querySelectorAll('.set-ppcgrp:checked')).map(c=>c.value).join(','),
|
||
ppcApiKey: div.querySelector('#set-ppcApiKey')?.value || '',
|
||
ppcOrderTypes: div.querySelector('#set-ppcOrderTypes')?.value || '',
|
||
}));
|
||
|
||
wireSave('set-save-notify', () => ({
|
||
notifyEmailsEnabled: div.querySelector('#set-notifyEmailsEnabled').value,
|
||
notifyExtraEmails: JSON.stringify(collectNotifyExtra()),
|
||
notifyItemGroupEmails: JSON.stringify(collectNotifyByGroup()),
|
||
notifyDisabledSteps: NOTIFY_TARGET_OPTS.map(o=>o.v).filter(v=>!div.querySelector(`.set-notifystep[value="${CSS.escape(v)}"]`)?.checked).join(','),
|
||
}));
|
||
|
||
wireSave('set-save-shortstock', () => ({
|
||
shortStockAlertEnabled: div.querySelector('#set-shortStockAlertEnabled').value,
|
||
shortStockAlertRules: JSON.stringify(collectSstockRules()),
|
||
}));
|
||
|
||
wireSave('set-save-downtime', () => {
|
||
const causes = (div.querySelector('#set-dtCauses')?.value || '').split(',').map(s => s.trim()).filter(Boolean);
|
||
const tabFieldMap = {};
|
||
div.querySelectorAll('.dt-map-sel').forEach(sel => {
|
||
const tab = sel.dataset.tab, field = sel.dataset.field, cause = sel.value;
|
||
if (!cause) return;
|
||
(tabFieldMap[tab] = tabFieldMap[tab] || {})[field] = cause;
|
||
});
|
||
return {
|
||
downtimeAnalysisConfig: JSON.stringify({
|
||
periodFrom: div.querySelector('#set-dtPeriodFrom')?.value || '',
|
||
periodTo: div.querySelector('#set-dtPeriodTo')?.value || '',
|
||
baseDays: parseInt(div.querySelector('#set-dtBaseDays')?.value) || 330,
|
||
causes, tabFieldMap,
|
||
}),
|
||
};
|
||
});
|
||
|
||
// Settings search — filters every card's .set-row by its visible text
|
||
// (label + description), not just the card title. A card with any
|
||
// matching row auto-expands and stays visible; a card with zero matches
|
||
// (title included) hides entirely. Clearing the box restores every card
|
||
// to its normal collapsed state.
|
||
const searchInput = div.querySelector('#settings-search');
|
||
const searchCountEl = div.querySelector('#settings-search-count');
|
||
if (searchInput) {
|
||
searchInput.oninput = function () {
|
||
const q = this.value.trim().toLowerCase();
|
||
let totalMatches = 0;
|
||
div.querySelectorAll('.win').forEach(win => {
|
||
if (win === topBar || win === searchBar) return;
|
||
const body = win.querySelector('[id$="-body"]');
|
||
const chev = win.querySelector('[id$="-chev"]');
|
||
const rows = Array.from(win.querySelectorAll('.set-row'));
|
||
if (!q) {
|
||
win.style.display = '';
|
||
rows.forEach(r => { r.style.display = ''; });
|
||
if (body) body.style.display = 'none';
|
||
if (chev) chev.style.transform = '';
|
||
return;
|
||
}
|
||
const titleEl = win.querySelector('.win-title');
|
||
const titleMatch = !!(titleEl && titleEl.textContent.toLowerCase().includes(q));
|
||
let cardMatches = 0;
|
||
rows.forEach(r => {
|
||
const match = titleMatch || r.textContent.toLowerCase().includes(q);
|
||
r.style.display = match ? '' : 'none';
|
||
if (match) cardMatches++;
|
||
});
|
||
const show = cardMatches > 0;
|
||
win.style.display = show ? '' : 'none';
|
||
if (show) {
|
||
if (body) body.style.display = 'flex';
|
||
if (chev) chev.style.transform = 'rotate(90deg)';
|
||
}
|
||
totalMatches += cardMatches;
|
||
});
|
||
if (searchCountEl) searchCountEl.textContent = q ? `${totalMatches} setting${totalMatches !== 1 ? 's' : ''} found` : '';
|
||
};
|
||
}
|
||
}, 0);
|
||
|
||
return div;
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// BOM REQUESTS TAB
|
||
// ════════════════════════════════════════
|
||
function buildBomTab() {
|
||
const div = document.createElement('div');
|
||
|
||
// Filter buttons
|
||
const filterBar = document.createElement('div');
|
||
filterBar.style.cssText = 'display:flex;gap:6px;margin-bottom:14px;flex-wrap:wrap;align-items:center';
|
||
|
||
const statuses = ['ALL', 'PENDING', 'L1_APPROVED', 'L2_APPROVED', 'L3_APPROVED', 'SAP_PUSHED', 'REJECTED', 'CANCELLED'];
|
||
const levels = _cfg?.approvalLevels || 3;
|
||
const visibleStatuses = statuses.filter(s => {
|
||
if (s === 'L2_APPROVED' && levels < 3) return false;
|
||
if (s === 'L3_APPROVED' && levels < 4) return false;
|
||
return true;
|
||
});
|
||
|
||
visibleStatuses.forEach(s => {
|
||
const count = s === 'ALL' ? _bomRequests.length : _bomRequests.filter(r => r.status === s).length;
|
||
const btn = document.createElement('button');
|
||
btn.className = 'btn' + (s === _reqFilter ? ' b-primary' : ' b-cancel');
|
||
btn.style.fontSize = '11px';
|
||
btn.innerHTML = `${s === 'ALL' ? 'All' : STATUS_LABELS[s] || s} <span style="opacity:.7">(${count})</span>`;
|
||
btn.onclick = () => { _reqFilter = s; buildBomTab_inline(div); };
|
||
filterBar.appendChild(btn);
|
||
});
|
||
|
||
const refreshBtn = document.createElement('button');
|
||
refreshBtn.className = 'btn b-amber';
|
||
refreshBtn.style.cssText = 'font-size:11px;margin-left:auto';
|
||
refreshBtn.innerHTML = '↻ Refresh';
|
||
refreshBtn.onclick = async () => {
|
||
refreshBtn.disabled = true;
|
||
await loadBomRequests();
|
||
buildBomTab_inline(div);
|
||
toast('Refreshed');
|
||
};
|
||
filterBar.appendChild(refreshBtn);
|
||
div.appendChild(filterBar);
|
||
|
||
// Table
|
||
const filtered = _reqFilter === 'ALL'
|
||
? _bomRequests
|
||
: _bomRequests.filter(r => r.status === _reqFilter);
|
||
|
||
const win = document.createElement('div');
|
||
win.className = 'win';
|
||
|
||
const bar = document.createElement('div');
|
||
bar.className = 'win-bar';
|
||
bar.innerHTML = `<div class="win-title">⊞ BOM Requests <span style="font-size:11px;color:var(--text3);font-weight:400">${filtered.length} shown</span></div>`;
|
||
win.appendChild(bar);
|
||
|
||
const tableWrap = document.createElement('div');
|
||
tableWrap.style.overflowX = 'auto';
|
||
|
||
if (!filtered.length) {
|
||
tableWrap.innerHTML = `<div class="empty-state"><div class="es-icon"></div><p style="font-size:13px;color:var(--text2)">No requests found</p></div>`;
|
||
} else {
|
||
const tbl = document.createElement('table');
|
||
tbl.className = 'req-table';
|
||
tbl.innerHTML = `
|
||
<thead>
|
||
<tr>
|
||
<th>#</th><th>Type</th><th>Item Code</th><th>Description</th>
|
||
<th>Status</th><th>Submitted By</th><th>Submitted At</th>
|
||
<th>Components</th><th>Approval Steps</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${filtered.map(r => {
|
||
const logCount = (r.approvalLog || []).length;
|
||
return `<tr>
|
||
<td style="font-family:var(--mono);color:var(--text3);font-size:10px">${r.id}</td>
|
||
<td><span class="status-badge ${r.type === 'CREATE' ? 'sb-SAP_PUSHED' : 'sb-L1_APPROVED'}" style="font-size:9px">${r.type}</span></td>
|
||
<td style="font-family:var(--mono);color:var(--blue);font-size:11px;font-weight:600">${esc(r.itemCode)}</td>
|
||
<td style="color:var(--text2);max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(r.itemName || '—')}</td>
|
||
<td><span class="status-badge sb-${r.status}">${STATUS_LABELS[r.status] || r.status}</span></td>
|
||
<td style="font-size:11px">${esc(r.submittedByName || r.submittedBy)}</td>
|
||
<td style="font-size:11px;color:var(--text3);white-space:nowrap">${r.submittedAt ? new Date(r.submittedAt).toLocaleDateString('en-IN',{day:'2-digit',month:'short'}) : '—'}</td>
|
||
<td style="font-family:var(--mono);text-align:center;color:var(--text3)">${(r.components||[]).length}</td>
|
||
<td style="font-family:var(--mono);font-size:11px;color:var(--text3)">${logCount}/${levels} done</td>
|
||
</tr>`;
|
||
}).join('')}
|
||
</tbody>`;
|
||
tableWrap.appendChild(tbl);
|
||
}
|
||
|
||
win.appendChild(tableWrap);
|
||
div.appendChild(win);
|
||
|
||
return div;
|
||
}
|
||
|
||
function buildBomTab_inline(container) {
|
||
container.innerHTML = '';
|
||
container.appendChild(buildBomTab());
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// ADD / EDIT USER MODAL
|
||
// ════════════════════════════════════════
|
||
function openUserModal(user, cloneFrom) {
|
||
const isEdit = !!user;
|
||
// When creating a new user, cloneFrom (an existing user picked from the
|
||
// "Copy permissions from" dropdown below) supplies the role/modules/
|
||
// approval-steps/etc. to pre-fill — everything EXCEPT identity fields
|
||
// (username/password/fullName/email/SAP login/signature/active status),
|
||
// which always stay blank/default for a real new account.
|
||
const prefillUser = isEdit ? user : (cloneFrom || null);
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay';
|
||
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal wide';
|
||
|
||
const initialModCount = prefillUser && Array.isArray(prefillUser.modules) ? prefillUser.modules.length : 0;
|
||
const initialStepCount = prefillUser && Array.isArray(prefillUser.approvalSteps) ? prefillUser.approvalSteps.length : 0; // refined once rendered — see stepCountEl listener
|
||
|
||
modal.innerHTML = `
|
||
<div class="modal-header">
|
||
<div class="modal-title">${isEdit ? `✎ Edit User — @${esc(user.username)}` : '+ Add New User'}</div>
|
||
<button class="modal-close" id="uc-close">✕</button>
|
||
</div>
|
||
<div class="uc-tabs">
|
||
<button type="button" class="uc-tab on" data-uc-tab="profile">Profile</button>
|
||
<button type="button" class="uc-tab" data-uc-tab="modules">Module Access <span class="uc-tab-count" id="uc-tab-mod-count">${initialModCount || 'None'}</span></button>
|
||
<button type="button" class="uc-tab" data-uc-tab="steps">Approval Steps <span class="uc-tab-count" id="uc-tab-step-count">${initialStepCount}</span></button>
|
||
<button type="button" class="uc-tab" data-uc-tab="issue">Issue Items <span class="uc-tab-count" id="uc-tab-issue-count">${prefillUser && Array.isArray(prefillUser.issueItemGroups) && prefillUser.issueItemGroups.length ? prefillUser.issueItemGroups.length : 'All'}</span></button>
|
||
<button type="button" class="uc-tab" data-uc-tab="manualpo">Manual PWO Item Groups <span class="uc-tab-count" id="uc-tab-manualpo-count">${prefillUser && Array.isArray(prefillUser.manualPoItemGroups) && prefillUser.manualPoItemGroups.length ? prefillUser.manualPoItemGroups.length : 'All'}</span></button>
|
||
<button type="button" class="uc-tab" data-uc-tab="manpower">Man Power <span class="uc-tab-count" id="uc-tab-mp-count">${prefillUser && Array.isArray(prefillUser.manpowerTabs) && prefillUser.manpowerTabs.length ? prefillUser.manpowerTabs.length : 'All'}</span></button>
|
||
<button type="button" class="uc-tab" data-uc-tab="oee">OEE <span class="uc-tab-count" id="uc-tab-oee-count">${prefillUser && Array.isArray(prefillUser.oeeTabs) && prefillUser.oeeTabs.length ? prefillUser.oeeTabs.length : 'All'}</span></button>
|
||
<button type="button" class="uc-tab" data-uc-tab="company">Displayed SAP Company <span class="uc-tab-count" id="uc-tab-company-count">${prefillUser && Array.isArray(prefillUser.allowedCompanies) && prefillUser.allowedCompanies.length ? prefillUser.allowedCompanies.length : 'All'}</span></button>
|
||
<button type="button" class="uc-tab" data-uc-tab="sapappr">SAP Approval Types <span class="uc-tab-count" id="uc-tab-sapappr-count">${prefillUser && Array.isArray(prefillUser.sapApprovalTypes) && prefillUser.sapApprovalTypes.length ? prefillUser.sapApprovalTypes.length : 'All'}</span></button>
|
||
<button type="button" class="uc-tab" data-uc-tab="prdept">PR Departments <span class="uc-tab-count" id="uc-tab-prdept-count">${prefillUser && Array.isArray(prefillUser.allowedDepartments) && prefillUser.allowedDepartments.length ? prefillUser.allowedDepartments.length : 'All'}</span></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
|
||
<div class="uc-pane on" data-uc-pane="profile">
|
||
${!isEdit ? `
|
||
<div class="form-group">
|
||
<div class="form-label">Copy Permissions From <span style="font-weight:400;font-size:10px;color:var(--text3)">optional — pre-fills role, module access, approval steps & every other permission tab below from an existing user. Username, password, email and SAP login always start blank.</span></div>
|
||
<select class="form-input" id="uc-clone-from">
|
||
<option value="">— None (start blank) —</option>
|
||
${_users.slice().sort((a,b)=>(a.username||'').localeCompare(b.username||'')).map(u=>`<option value="${u.id}" ${cloneFrom&&cloneFrom.id===u.id?'selected':''}>@${esc(u.username)}${u.fullName?` — ${esc(u.fullName)}`:''} (${esc(u.role)})</option>`).join('')}
|
||
</select>
|
||
</div>` : ''}
|
||
<!-- Role Selector Cards -->
|
||
<div class="form-group">
|
||
<div class="form-label">Select Role <span class="req">*</span></div>
|
||
<div class="role-info-grid" id="role-cards">
|
||
<div class="role-info-card${(!prefillUser || prefillUser.role==='manager') ? ' selected-mgr' : ''}" data-role="manager">
|
||
<div class="ric-check" style="color:var(--amber)">✓</div>
|
||
<div class="ric-icon"></div>
|
||
<div class="ric-title">Manager (L1)</div>
|
||
<div class="ric-desc">Reviews and approves PENDING BOM requests as first approver</div>
|
||
</div>
|
||
<div class="role-info-card${prefillUser && prefillUser.role==='sr_manager' ? ' selected-srm' : ''}" data-role="sr_manager">
|
||
<div class="ric-check" style="color:var(--purple)">✓</div>
|
||
<div class="ric-icon"></div>
|
||
<div class="ric-title">Sr. Manager (L2)</div>
|
||
<div class="ric-desc">Reviews L1-approved requests as second approver in the chain</div>
|
||
</div>
|
||
<div class="role-info-card${prefillUser && prefillUser.role==='sap_adder' ? ' selected-sap' : ''}" data-role="sap_adder">
|
||
<div class="ric-check" style="color:var(--green)">✓</div>
|
||
<div class="ric-icon"></div>
|
||
<div class="ric-title">SAP Adder (Final)</div>
|
||
<div class="ric-desc">Final approver who pushes BOMs to SAP B1. Also manages portal users.</div>
|
||
</div>
|
||
<div class="role-info-card${prefillUser && prefillUser.role==='system_admin' ? ' selected-sap' : ''}" data-role="system_admin">
|
||
<div class="ric-check" style="color:var(--green)">✓</div>
|
||
<div class="ric-icon"></div>
|
||
<div class="ric-title">System Admin</div>
|
||
<div class="ric-desc">User management, System Settings & Item Group Rules. Cannot create, modify or delete Admin accounts, and sees only the modules granted to them.</div>
|
||
</div>
|
||
<div class="role-info-card${prefillUser && prefillUser.role==='board_member' ? ' selected-srm' : ''}" data-role="board_member">
|
||
<div class="ric-check" style="color:var(--purple)">✓</div>
|
||
<div class="ric-icon"></div>
|
||
<div class="ric-title">Board Member</div>
|
||
<div class="ric-desc">Company leadership. Sees the Board Dashboard — growth analytics (sales, purchases, top customers & products). Read-only; grant the "Board Dashboard" module.</div>
|
||
</div>
|
||
<div class="role-info-card${prefillUser && prefillUser.role==='user' ? ' selected-user' : ''}" data-role="user">
|
||
<div class="ric-check" style="color:var(--blue)">✓</div>
|
||
<div class="ric-icon"></div>
|
||
<div class="ric-title">User</div>
|
||
<div class="ric-desc">Can submit items for approval. Items go to PENDING queue for admin review before SAP push.</div>
|
||
</div>
|
||
<div class="role-info-card${prefillUser && prefillUser.role==='project_approver' ? ' selected-proj' : ''}" data-role="project_approver">
|
||
<div class="ric-check" style="color:#7c3aed">✓</div>
|
||
<div class="ric-icon"></div>
|
||
<div class="ric-title" style="color:#7c3aed">Project Approver</div>
|
||
<div class="ric-desc">Can approve project requests — Purchase, Engineering, QA, QC, Legal, Owner, Plant Manager, Finance steps.</div>
|
||
</div>
|
||
</div>
|
||
<input type="hidden" id="uc-role" value="${prefillUser ? esc(prefillUser.role) : 'manager'}"/>
|
||
</div>
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<div class="form-label">Username <span class="req">*</span></div>
|
||
<input class="form-input" id="uc-username" placeholder="john.doe" value="${isEdit ? esc(user.username) : ''}" ${isEdit ? 'readonly style="opacity:.6;cursor:not-allowed"' : ''}/>
|
||
<div class="form-hint">Lowercase, no spaces</div>
|
||
<div class="form-err" id="e-uc-username"></div>
|
||
</div>
|
||
${!isEdit ? `
|
||
<div class="form-group">
|
||
<div class="form-label">Password <span class="req">*</span></div>
|
||
<input class="form-input" id="uc-password" type="password" placeholder="Min 6 characters"/>
|
||
<div class="form-err" id="e-uc-password"></div>
|
||
</div>` : '<div></div>'}
|
||
<div class="form-group">
|
||
<div class="form-label">Full Name</div>
|
||
<input class="form-input" id="uc-fullname" placeholder="John Doe" value="${isEdit ? esc(user.fullName || '') : ''}"/>
|
||
</div>
|
||
<div class="form-group">
|
||
<div class="form-label">Email</div>
|
||
<input class="form-input" id="uc-email" type="email" placeholder="john@company.com" value="${isEdit ? esc(user.email || '') : ''}"/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SAP Service-Layer login (per-user posting identity) -->
|
||
<div class="form-group">
|
||
<div class="form-label">SAP Login — User & Password <span style="font-weight:400;font-size:10px;color:var(--text3)">${isEdit && user.hasSapLogin ? '(set — re-enter to change)' : '(not set)'}; validated against SAP on save. Applies to the <strong>default company</strong> only — if this user works across multiple SAP companies with different passwords, they should add the rest themselves under Profile → My SAP Account.</span></div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||
<input class="form-input" id="uc-saploginuser" placeholder="SAP User ID" autocomplete="off" value="${isEdit ? esc(user.sapLoginUser || '') : ''}"/>
|
||
<input class="form-input" id="uc-saploginpwd" type="password" placeholder="${isEdit && user.hasSapLogin ? '•••••• (unchanged)' : 'SAP Password'}" autocomplete="new-password"/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Signature image — drawn on printed Work Orders where this user signed -->
|
||
<div class="form-group">
|
||
<div class="form-label">Signature Image <span style="font-weight:400;font-size:10px;color:var(--text3)">${isEdit && user.hasSignature ? '(on file — upload to replace)' : '(not set)'}; shown on printed Work Orders</span></div>
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<img id="uc-sig-prev" alt="" style="height:44px;max-width:180px;object-fit:contain;border:1px solid var(--border2);border-radius:6px;background:#fff;display:none"/>
|
||
<input type="file" id="uc-sig-file" accept="image/*" style="font-size:11px"/>
|
||
<button type="button" class="btn-sm" id="uc-sig-clear">Remove</button>
|
||
</div>
|
||
<input type="hidden" id="uc-signature" value=""/>
|
||
</div>
|
||
|
||
<!-- Stage-change email notifications — opt-out per user (checked by default) -->
|
||
<div class="form-group">
|
||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||
<input type="checkbox" id="uc-email-notify" ${(!isEdit || user.emailNotify!==false)?'checked':''}/>
|
||
<span style="font-size:12px;font-weight:600">Send stage-change email notifications to this user</span>
|
||
</label>
|
||
<div style="font-size:10.5px;color:var(--text3);margin-top:3px;margin-left:24px">When unchecked, this user's own email is never used as a notification recipient — admin-configured extra recipients (System Settings) are unaffected.</div>
|
||
</div>
|
||
|
||
<!-- Work Order sign override — off by default, a narrow explicit grant -->
|
||
<div class="form-group">
|
||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||
<input type="checkbox" id="uc-wo-verify-override" ${user&&user.woVerifyOverride?'checked':''}/>
|
||
<span style="font-size:12px;font-weight:600">Verify Work Order: can sign any step as any user & backdate</span>
|
||
</label>
|
||
<div style="font-size:10.5px;color:var(--text3);margin-top:3px;margin-left:24px">When checked, this user's Issued/Received/Verified stamps on a Work Order row (on any step they're separately assigned "approve" on) let them pick who it's signed as (any user) and set the sign date to a backdate — including RE-signing a row that's already been stamped, to correct it. Everyone else can only sign as themselves at the current time, once.</div>
|
||
</div>
|
||
|
||
<!-- Impersonate — off by default, a narrow explicit grant -->
|
||
<div class="form-group">
|
||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||
<input type="checkbox" id="uc-can-impersonate" ${user&&user.canImpersonate?'checked':''}/>
|
||
<span style="font-size:12px;font-weight:600">Can impersonate other users</span>
|
||
</label>
|
||
<div style="font-size:10.5px;color:var(--text3);margin-top:3px;margin-left:24px">When checked, this user can pick another user and act on their behalf — seeing exactly what that user sees, with that user's permissions — until they return to their own account. Cannot be used to impersonate an admin unless this user is themselves an admin. Every action taken while impersonating is recorded in the Audit Trail against both identities.</div>
|
||
</div>
|
||
|
||
${isEdit ? `
|
||
<div class="form-group">
|
||
<div class="form-label">Account Status</div>
|
||
<div style="display:flex;gap:10px">
|
||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:8px 14px;border:1.5px solid ${user.active?'var(--green)':'var(--border2)'};border-radius:var(--radius);background:${user.active?'rgba(13,187,138,.06)':'var(--surface2)'}">
|
||
<input type="radio" name="uc-active" value="1" ${user.active?'checked':''} style="accent-color:var(--green)"/>
|
||
<span style="font-size:12px;font-weight:600;color:${user.active?'var(--green)':'var(--text3)'}">Active</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:8px 14px;border:1.5px solid ${!user.active?'var(--red)':'var(--border2)'};border-radius:var(--radius);background:${!user.active?'rgba(229,62,62,.06)':'var(--surface2)'}">
|
||
<input type="radio" name="uc-active" value="0" ${!user.active?'checked':''} style="accent-color:var(--red)"/>
|
||
<span style="font-size:12px;font-weight:600;color:${!user.active?'var(--red)':'var(--text3)'}">Inactive</span>
|
||
</label>
|
||
</div>
|
||
</div>` : ''}
|
||
|
||
<!-- Approval Department — only for project_approver role -->
|
||
<div class="form-group" id="uc-dept-group" style="display:${prefillUser&&prefillUser.role==='project_approver'?'block':'none'}">
|
||
<div class="form-label">Approval Department <span class="req">*</span></div>
|
||
<select class="form-input" id="uc-dept">
|
||
<option value="">-- Select Department --</option>
|
||
<option value="purchase" ${prefillUser&&prefillUser.approvalDept==='purchase' ?'selected':''}>Purchase</option>
|
||
<option value="engineering" ${prefillUser&&prefillUser.approvalDept==='engineering'?'selected':''}>Engineering</option>
|
||
<option value="qa" ${prefillUser&&prefillUser.approvalDept==='qa' ?'selected':''}>QA</option>
|
||
<option value="qc" ${prefillUser&&prefillUser.approvalDept==='qc' ?'selected':''}>QC</option>
|
||
<option value="legal" ${prefillUser&&prefillUser.approvalDept==='legal' ?'selected':''}>Legal</option>
|
||
<option value="owner" ${prefillUser&&prefillUser.approvalDept==='owner' ?'selected':''}>Project Owner</option>
|
||
<option value="plant" ${prefillUser&&prefillUser.approvalDept==='plant' ?'selected':''}>Plant Manager</option>
|
||
<option value="finance" ${prefillUser&&prefillUser.approvalDept==='finance' ?'selected':''}>Finance & Accounts</option>
|
||
</select>
|
||
<div class="form-hint">This user can only approve projects at the selected step.</div>
|
||
<div class="form-err" id="e-uc-dept"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="uc-pane" data-uc-pane="modules">
|
||
<div class="uc-section-note">Choose exactly which pages this user can open. <strong>Unchecked modules are not accessible</strong> — Admin and SAP Adder roles bypass this; a System Admin sees only their checked modules.</div>
|
||
<div id="uc-modules">${buildModuleCheckboxes(prefillUser)}</div>
|
||
</div>
|
||
|
||
<div class="uc-pane" data-uc-pane="issue">
|
||
<div class="uc-section-note">Restrict which items this user can issue in <strong>Issue for Production</strong>, by SAP Item Group. <strong>Leave all unchecked = no restriction</strong> (can issue any item). When any are checked, only those groups' items are shown & issuable.</div>
|
||
<input class="form-input" id="uc-issue-search" placeholder="Search item group…" autocomplete="off" style="margin-bottom:8px"/>
|
||
<div style="display:flex;gap:8px;margin-bottom:8px"><button type="button" class="btn-sm" id="uc-issue-all">Select all</button><button type="button" class="btn-sm" id="uc-issue-none">Clear</button></div>
|
||
<div id="uc-issue-groups" style="max-height:320px;overflow:auto;display:grid;grid-template-columns:1fr 1fr;gap:2px">${buildIssueGroupCheckboxes(prefillUser)}</div>
|
||
</div>
|
||
|
||
<div class="uc-pane" data-uc-pane="manualpo">
|
||
<div class="uc-section-note">Restrict which items this user can originate a standalone <strong>Production Order (Manual Entry)</strong> for, by SAP Item Group — separate from Issue Items above, since issuing materials and originating a manual PWO are different actions. <strong>Leave all unchecked = no restriction</strong> (can create for any item).</div>
|
||
<input class="form-input" id="uc-manualpo-search" placeholder="Search item group…" autocomplete="off" style="margin-bottom:8px"/>
|
||
<div style="display:flex;gap:8px;margin-bottom:8px"><button type="button" class="btn-sm" id="uc-manualpo-all">Select all</button><button type="button" class="btn-sm" id="uc-manualpo-none">Clear</button></div>
|
||
<div id="uc-manualpo-groups" style="max-height:320px;overflow:auto;display:grid;grid-template-columns:1fr 1fr;gap:2px">${buildManualPoGroupCheckboxes(prefillUser)}</div>
|
||
</div>
|
||
|
||
<div class="uc-pane" data-uc-pane="manpower">
|
||
<div class="uc-section-note">Choose which <strong>Man Power</strong> tabs this user may fill. <strong>Leave all unchecked = all tabs</strong> (no restriction). When any are checked, the user sees & fills only those tabs (within the globally-enabled tabs).</div>
|
||
<div style="display:flex;gap:8px;margin-bottom:8px"><button type="button" class="btn-sm" id="uc-mp-all">Select all</button><button type="button" class="btn-sm" id="uc-mp-none">Clear</button></div>
|
||
<div id="uc-mptabs" style="display:grid;grid-template-columns:1fr 1fr;gap:2px">${buildManpowerTabCheckboxes(prefillUser)}</div>
|
||
</div>
|
||
|
||
<div class="uc-pane" data-uc-pane="oee">
|
||
<div class="uc-section-note">Choose which <strong>OEE</strong> forms this user may fill. <strong>Leave all unchecked = all forms</strong> (no restriction). When any are checked, the user sees & fills only those forms.</div>
|
||
<div style="display:flex;gap:8px;margin-bottom:8px"><button type="button" class="btn-sm" id="uc-oee-all">Select all</button><button type="button" class="btn-sm" id="uc-oee-none">Clear</button></div>
|
||
<div id="uc-oeetabs" style="display:grid;grid-template-columns:1fr 1fr;gap:2px">${buildOeeTabCheckboxes(prefillUser)}</div>
|
||
</div>
|
||
|
||
<div class="uc-pane" data-uc-pane="company">
|
||
<div class="uc-section-note">Choose which <strong>SAP companies</strong> appear in this user's company dropdown, out of every company found on the SQL Server. <strong>Leave all unchecked = all companies</strong> (no restriction). This only controls what's shown — not a hard access boundary.</div>
|
||
<div style="display:flex;gap:8px;margin-bottom:8px"><button type="button" class="btn-sm" id="uc-company-all">Select all</button><button type="button" class="btn-sm" id="uc-company-none">Clear</button></div>
|
||
<div id="uc-companies" style="display:grid;grid-template-columns:1fr 1fr;gap:2px">${buildCompanyCheckboxes(prefillUser)}</div>
|
||
</div>
|
||
|
||
<div class="uc-pane" data-uc-pane="sapappr">
|
||
<div class="uc-section-note">Choose which <strong>SAP Approval Request</strong> document types this user sees in SAP Approval (Purchase Request, Goods Receipt PO, etc.). <strong>Leave all unchecked = all types</strong> (no restriction) — checking any restricts them to only those types, hiding the rest from the list entirely (enforced on the server, not just hidden in the UI).</div>
|
||
<div style="display:flex;gap:8px;margin-bottom:8px"><button type="button" class="btn-sm" id="uc-sapappr-all">Select all</button><button type="button" class="btn-sm" id="uc-sapappr-none">Clear</button></div>
|
||
<div id="uc-sapapprtypes" style="display:grid;grid-template-columns:1fr 1fr;gap:2px">${buildSapApprovalTypeCheckboxes(prefillUser)}</div>
|
||
</div>
|
||
|
||
<div class="uc-pane" data-uc-pane="prdept">
|
||
<div class="uc-section-note">Choose which <strong>Departments</strong> (SAP OUDP master) this user may pick when creating a Purchase Request. <strong>Leave all unchecked = all departments</strong> (no restriction) — checking any restricts the Department dropdown to only those (enforced on the server too, not just hidden in the UI).</div>
|
||
<div style="display:flex;gap:8px;margin-bottom:8px"><button type="button" class="btn-sm" id="uc-prdept-all">Select all</button><button type="button" class="btn-sm" id="uc-prdept-none">Clear</button></div>
|
||
<div id="uc-prdepts" style="display:grid;grid-template-columns:1fr 1fr;gap:2px">${buildPrDepartmentCheckboxes(prefillUser)}</div>
|
||
</div>
|
||
|
||
<div class="uc-pane" data-uc-pane="steps">
|
||
<div class="uc-section-note">Choose which workflow approval steps this user may act on (approve/reject). Leave a workflow's steps unchecked to give this user none of them.</div>
|
||
<div id="uc-steps">${buildApprovalStepCheckboxes(prefillUser)}</div>
|
||
${_user?.role==='admin' ? `
|
||
<div class="uc-custom-box">
|
||
<div class="ucb-title">+ Add Custom Approval Step (admin only)</div>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||
<input class="form-input" id="ncs-workflow" placeholder="workflow (e.g. work_order)" style="flex:1;min-width:120px;font-size:11px;padding:6px 8px;height:32px"/>
|
||
<input class="form-input" id="ncs-key" placeholder="key (e.g. final_check)" style="flex:1;min-width:120px;font-size:11px;padding:6px 8px;height:32px"/>
|
||
<input class="form-input" id="ncs-label" placeholder="Display label" style="flex:2;min-width:140px;font-size:11px;padding:6px 8px;height:32px"/>
|
||
<button class="btn-sm" id="ncs-add" style="border-color:var(--green);color:var(--green)" type="button">+ Add</button>
|
||
</div>
|
||
<div class="form-err" id="e-ncs"></div>
|
||
</div>` : ''}
|
||
</div>
|
||
|
||
<div class="form-err" id="e-uc-global" style="padding:0 2px"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn b-cancel" id="uc-cancel">Cancel</button>
|
||
<button class="btn b-purple" id="uc-save">
|
||
<span id="uc-sp" style="display:none" class="sp"></span>
|
||
${isEdit ? '✓ Save Changes' : '+ Create User'}
|
||
</button>
|
||
</div>`;
|
||
|
||
overlay.appendChild(modal);
|
||
document.body.appendChild(overlay);
|
||
|
||
// Tab switching
|
||
modal.querySelectorAll('[data-uc-tab]').forEach(tabBtn => {
|
||
tabBtn.onclick = () => {
|
||
modal.querySelectorAll('[data-uc-tab]').forEach(b => b.classList.remove('on'));
|
||
modal.querySelectorAll('[data-uc-pane]').forEach(p => p.classList.remove('on'));
|
||
tabBtn.classList.add('on');
|
||
modal.querySelector(`[data-uc-pane="${tabBtn.dataset.ucTab}"]`).classList.add('on');
|
||
};
|
||
});
|
||
|
||
// Select-all/clear toggles + live pill styling for both permission tabs
|
||
wirePermGroups(modal.querySelector('[data-uc-pane="modules"]'));
|
||
wireStepPermGroups(modal.querySelector('[data-uc-pane="steps"]'));
|
||
|
||
// Issue Items tab — search, select-all/clear, live count (checked = restricted; none = All)
|
||
const issueCbs = () => Array.from(modal.querySelectorAll('.uc-issue-cb'));
|
||
const issueCountEl = modal.querySelector('#uc-tab-issue-count');
|
||
const syncIssueCount = () => { if(issueCountEl){ const n=issueCbs().filter(c=>c.checked).length; issueCountEl.textContent = n?String(n):'All'; } };
|
||
issueCbs().forEach(c => c.addEventListener('change', syncIssueCount));
|
||
const issueSearch = modal.querySelector('#uc-issue-search');
|
||
if(issueSearch) issueSearch.oninput = function(){ const q=this.value.trim().toLowerCase(); modal.querySelectorAll('.uc-ig-item').forEach(l=>{ l.style.display=(!q||l.dataset.igLabel.includes(q))?'flex':'none'; }); };
|
||
const visIssue = () => Array.from(modal.querySelectorAll('.uc-ig-item')).filter(l=>l.style.display!=='none').map(l=>l.querySelector('.uc-issue-cb'));
|
||
modal.querySelector('#uc-issue-all')?.addEventListener('click', ()=>{ visIssue().forEach(c=>c.checked=true); syncIssueCount(); });
|
||
modal.querySelector('#uc-issue-none')?.addEventListener('click', ()=>{ visIssue().forEach(c=>c.checked=false); syncIssueCount(); });
|
||
|
||
// Manual PWO Item Groups tab — search, select-all/clear, live count (checked = restricted; none = All)
|
||
const manualPoCbs = () => Array.from(modal.querySelectorAll('.uc-manualpo-cb'));
|
||
const manualPoCountEl = modal.querySelector('#uc-tab-manualpo-count');
|
||
const syncManualPoCount = () => { if(manualPoCountEl){ const n=manualPoCbs().filter(c=>c.checked).length; manualPoCountEl.textContent = n?String(n):'All'; } };
|
||
manualPoCbs().forEach(c => c.addEventListener('change', syncManualPoCount));
|
||
const manualPoSearch = modal.querySelector('#uc-manualpo-search');
|
||
if(manualPoSearch) manualPoSearch.oninput = function(){ const q=this.value.trim().toLowerCase(); modal.querySelectorAll('.uc-mpg-item').forEach(l=>{ l.style.display=(!q||l.dataset.mpgLabel.includes(q))?'flex':'none'; }); };
|
||
const visManualPo = () => Array.from(modal.querySelectorAll('.uc-mpg-item')).filter(l=>l.style.display!=='none').map(l=>l.querySelector('.uc-manualpo-cb'));
|
||
modal.querySelector('#uc-manualpo-all')?.addEventListener('click', ()=>{ visManualPo().forEach(c=>c.checked=true); syncManualPoCount(); });
|
||
modal.querySelector('#uc-manualpo-none')?.addEventListener('click', ()=>{ visManualPo().forEach(c=>c.checked=false); syncManualPoCount(); });
|
||
|
||
// Man Power tab permission — select-all/clear + live count (none = All)
|
||
const mpCbs = () => Array.from(modal.querySelectorAll('.uc-mptab-cb'));
|
||
const mpCountEl = modal.querySelector('#uc-tab-mp-count');
|
||
const syncMpCount = () => { if(mpCountEl){ const n=mpCbs().filter(c=>c.checked).length; mpCountEl.textContent = n?String(n):'All'; } };
|
||
mpCbs().forEach(c => c.addEventListener('change', syncMpCount));
|
||
modal.querySelector('#uc-mp-all')?.addEventListener('click', ()=>{ mpCbs().forEach(c=>c.checked=true); syncMpCount(); });
|
||
modal.querySelector('#uc-mp-none')?.addEventListener('click', ()=>{ mpCbs().forEach(c=>c.checked=false); syncMpCount(); });
|
||
|
||
// OEE form permission — select-all/clear + live count (none = All)
|
||
const oeeCbs = () => Array.from(modal.querySelectorAll('.uc-oeetab-cb'));
|
||
const oeeCountEl = modal.querySelector('#uc-tab-oee-count');
|
||
const syncOeeCount = () => { if(oeeCountEl){ const n=oeeCbs().filter(c=>c.checked).length; oeeCountEl.textContent = n?String(n):'All'; } };
|
||
oeeCbs().forEach(c => c.addEventListener('change', syncOeeCount));
|
||
modal.querySelector('#uc-oee-all')?.addEventListener('click', ()=>{ oeeCbs().forEach(c=>c.checked=true); syncOeeCount(); });
|
||
modal.querySelector('#uc-oee-none')?.addEventListener('click', ()=>{ oeeCbs().forEach(c=>c.checked=false); syncOeeCount(); });
|
||
|
||
// Displayed SAP Company — select-all/clear + live count (none = All)
|
||
const companyCbs = () => Array.from(modal.querySelectorAll('.uc-company-cb'));
|
||
const companyCountEl = modal.querySelector('#uc-tab-company-count');
|
||
const syncCompanyCount = () => { if(companyCountEl){ const n=companyCbs().filter(c=>c.checked).length; companyCountEl.textContent = n?String(n):'All'; } };
|
||
companyCbs().forEach(c => c.addEventListener('change', syncCompanyCount));
|
||
modal.querySelector('#uc-company-all')?.addEventListener('click', ()=>{ companyCbs().forEach(c=>c.checked=true); syncCompanyCount(); });
|
||
modal.querySelector('#uc-company-none')?.addEventListener('click', ()=>{ companyCbs().forEach(c=>c.checked=false); syncCompanyCount(); });
|
||
|
||
// SAP Approval Types — select-all/clear + live count (none = All)
|
||
const sapApprCbs = () => Array.from(modal.querySelectorAll('.uc-sapappr-cb'));
|
||
const sapApprCountEl = modal.querySelector('#uc-tab-sapappr-count');
|
||
const syncSapApprCount = () => { if(sapApprCountEl){ const n=sapApprCbs().filter(c=>c.checked).length; sapApprCountEl.textContent = n?String(n):'All'; } };
|
||
sapApprCbs().forEach(c => c.addEventListener('change', syncSapApprCount));
|
||
modal.querySelector('#uc-sapappr-all')?.addEventListener('click', ()=>{ sapApprCbs().forEach(c=>c.checked=true); syncSapApprCount(); });
|
||
modal.querySelector('#uc-sapappr-none')?.addEventListener('click', ()=>{ sapApprCbs().forEach(c=>c.checked=false); syncSapApprCount(); });
|
||
|
||
// PR Departments — select-all/clear + live count (none = All)
|
||
const prDeptCbs = () => Array.from(modal.querySelectorAll('.uc-prdept-cb'));
|
||
const prDeptCountEl = modal.querySelector('#uc-tab-prdept-count');
|
||
const syncPrDeptCount = () => { if(prDeptCountEl){ const n=prDeptCbs().filter(c=>c.checked).length; prDeptCountEl.textContent = n?String(n):'All'; } };
|
||
prDeptCbs().forEach(c => c.addEventListener('change', syncPrDeptCount));
|
||
modal.querySelector('#uc-prdept-all')?.addEventListener('click', ()=>{ prDeptCbs().forEach(c=>c.checked=true); syncPrDeptCount(); });
|
||
modal.querySelector('#uc-prdept-none')?.addEventListener('click', ()=>{ prDeptCbs().forEach(c=>c.checked=false); syncPrDeptCount(); });
|
||
|
||
// Signature upload — file → downscaled base64 in #uc-signature.
|
||
// Hidden value: '' = unchanged, '__CLEAR__' = remove, else new base64.
|
||
const sigPrev = modal.querySelector('#uc-sig-prev');
|
||
const sigHidden = modal.querySelector('#uc-signature');
|
||
const sigFile = modal.querySelector('#uc-sig-file');
|
||
if (isEdit && user.hasSignature && sigPrev) {
|
||
api('GET', `/users/${user.id}/signature`).then(r=>{ if(r.signature){ sigPrev.src=r.signature; sigPrev.style.display=''; } }).catch(()=>{});
|
||
}
|
||
if (sigFile) sigFile.onchange = function(){
|
||
const f = this.files && this.files[0]; if(!f) return;
|
||
const rd = new FileReader();
|
||
rd.onload = e => { const img=new Image(); img.onload=()=>{
|
||
const maxW=360, scale=Math.min(1, maxW/img.width);
|
||
const cv=document.createElement('canvas'); cv.width=Math.round(img.width*scale); cv.height=Math.round(img.height*scale);
|
||
cv.getContext('2d').drawImage(img,0,0,cv.width,cv.height);
|
||
const data=cv.toDataURL('image/png');
|
||
sigHidden.value=data; sigPrev.src=data; sigPrev.style.display='';
|
||
}; img.src=e.target.result; };
|
||
rd.readAsDataURL(f);
|
||
};
|
||
modal.querySelector('#uc-sig-clear')?.addEventListener('click', ()=>{
|
||
sigHidden.value='__CLEAR__'; if(sigPrev){sigPrev.src=''; sigPrev.style.display='none';} if(sigFile) sigFile.value='';
|
||
});
|
||
const modCountEl = modal.querySelector('#uc-tab-mod-count');
|
||
const stepCountEl = modal.querySelector('#uc-tab-step-count');
|
||
modal.querySelector('[data-uc-pane="modules"]').addEventListener('change', () => {
|
||
const n = modal.querySelectorAll('.uc-mod-cb:checked').length;
|
||
modCountEl.textContent = n || 'None';
|
||
});
|
||
modal.querySelector('[data-uc-pane="steps"]').addEventListener('change', () => {
|
||
// Count distinct steps with at least one perm checked, not raw checkbox count.
|
||
const stepsWithPerm = new Set(Array.from(modal.querySelectorAll('.uc-step-perm-cb:checked')).map(cb=>cb.dataset.step));
|
||
stepCountEl.textContent = stepsWithPerm.size;
|
||
});
|
||
|
||
// Role card selection
|
||
let selectedRole = prefillUser ? prefillUser.role : 'manager';
|
||
modal.querySelectorAll('[data-role]').forEach(card => {
|
||
card.onclick = () => {
|
||
modal.querySelectorAll('[data-role]').forEach(c => {
|
||
c.className = 'role-info-card';
|
||
});
|
||
const role = card.dataset.role;
|
||
selectedRole = role;
|
||
modal.querySelector('#uc-role').value = role;
|
||
const cls = { manager:'selected-mgr', sr_manager:'selected-srm', sap_adder:'selected-sap', system_admin:'selected-sap', board_member:'selected-srm', admin:'selected-sap', user:'selected-user', project_approver:'selected-proj' }[role] || 'selected-mgr';
|
||
card.classList.add(cls);
|
||
// show/hide dept field
|
||
const dg = modal.querySelector('#uc-dept-group');
|
||
if (dg) dg.style.display = role === 'project_approver' ? 'block' : 'none';
|
||
};
|
||
});
|
||
|
||
const closeModal = () => overlay.remove();
|
||
modal.querySelector('#uc-close').onclick = closeModal;
|
||
modal.querySelector('#uc-cancel').onclick = closeModal;
|
||
overlay.onclick = e => { if (e.target === overlay) closeModal(); };
|
||
|
||
// "Copy Permissions From" — reopen the modal pre-filled from the picked
|
||
// user's role/modules/approval-steps/etc. (identity fields stay blank).
|
||
const cloneSel = modal.querySelector('#uc-clone-from');
|
||
if (cloneSel) cloneSel.onchange = function(){
|
||
const picked = this.value ? _users.find(u => String(u.id) === this.value) : null;
|
||
closeModal();
|
||
openUserModal(null, picked);
|
||
};
|
||
|
||
const ncsAdd = modal.querySelector('#ncs-add');
|
||
if (ncsAdd) ncsAdd.onclick = async () => {
|
||
const eErr = modal.querySelector('#e-ncs');
|
||
eErr.textContent = '';
|
||
const workflow = (modal.querySelector('#ncs-workflow')?.value || '').trim();
|
||
const key = (modal.querySelector('#ncs-key')?.value || '').trim();
|
||
const label = (modal.querySelector('#ncs-label')?.value || '').trim();
|
||
if (!workflow || !key || !label) { eErr.textContent = 'All three fields are required'; return; }
|
||
try {
|
||
await api('POST', '/approval-steps', { workflow, key, label });
|
||
await loadApprovalSteps();
|
||
modal.querySelector('#uc-steps').innerHTML = buildApprovalStepCheckboxes(prefillUser);
|
||
modal.querySelector('#ncs-workflow').value = '';
|
||
modal.querySelector('#ncs-key').value = '';
|
||
modal.querySelector('#ncs-label').value = '';
|
||
toast('✓ Custom approval step added');
|
||
} catch (e) { eErr.textContent = e.message; }
|
||
};
|
||
|
||
modal.querySelector('#uc-save').onclick = async () => {
|
||
const btn = modal.querySelector('#uc-save');
|
||
const sp = modal.querySelector('#uc-sp');
|
||
const err = modal.querySelector('#e-uc-global');
|
||
err.textContent = '';
|
||
|
||
const username = (modal.querySelector('#uc-username')?.value || '').trim().toLowerCase();
|
||
const password = modal.querySelector('#uc-password')?.value || '';
|
||
const fullName = (modal.querySelector('#uc-fullname')?.value || '').trim();
|
||
const email = (modal.querySelector('#uc-email')?.value || '').trim();
|
||
const role = selectedRole;
|
||
const active = isEdit ? modal.querySelector('input[name="uc-active"]:checked')?.value !== '0' : true;
|
||
const modules = Array.from(modal.querySelectorAll('.uc-mod-cb:checked')).map(cb=>cb.value);
|
||
const modulesVal = modules.length ? modules : null;
|
||
const approvalSteps = collectApprovalSteps(modal);
|
||
const issueItemGroups = Array.from(modal.querySelectorAll('.uc-issue-cb:checked')).map(cb=>cb.value);
|
||
const manualPoItemGroups = Array.from(modal.querySelectorAll('.uc-manualpo-cb:checked')).map(cb=>cb.value);
|
||
const manpowerTabs = Array.from(modal.querySelectorAll('.uc-mptab-cb:checked')).map(cb=>cb.value);
|
||
const oeeTabs = Array.from(modal.querySelectorAll('.uc-oeetab-cb:checked')).map(cb=>cb.value);
|
||
const allowedCompanies = Array.from(modal.querySelectorAll('.uc-company-cb:checked')).map(cb=>cb.value);
|
||
const sapApprovalTypes = Array.from(modal.querySelectorAll('.uc-sapappr-cb:checked')).map(cb=>cb.value);
|
||
const allowedDepartments = Array.from(modal.querySelectorAll('.uc-prdept-cb:checked')).map(cb=>cb.value);
|
||
const emailNotify = !!modal.querySelector('#uc-email-notify')?.checked;
|
||
const woVerifyOverride = !!modal.querySelector('#uc-wo-verify-override')?.checked;
|
||
const canImpersonate = !!modal.querySelector('#uc-can-impersonate')?.checked;
|
||
// signature: undefined = leave unchanged, '' = clear, else new base64
|
||
let signature; { const v = modal.querySelector('#uc-signature')?.value || ''; if (v === '__CLEAR__') signature=''; else if (v) signature=v; }
|
||
const approvalDept = (modal.querySelector('#uc-dept')?.value||'').trim()||null;
|
||
const sapLoginUser = (modal.querySelector('#uc-saploginuser')?.value||'').trim();
|
||
const sapLoginPwd = modal.querySelector('#uc-saploginpwd')?.value||'';
|
||
|
||
// Validate
|
||
let ok = true;
|
||
if (!isEdit) {
|
||
if (!username) { modal.querySelector('#e-uc-username').textContent = 'Username required'; ok = false; }
|
||
else if (!/^[a-z0-9._-]+$/.test(username)) { modal.querySelector('#e-uc-username').textContent = 'Only lowercase letters, numbers, . _ -'; ok = false; }
|
||
if (!password || password.length < 6) { modal.querySelector('#e-uc-password').textContent = 'Min 6 characters'; ok = false; }
|
||
}
|
||
if (!role) { err.textContent = 'Please select a role'; ok = false; }
|
||
if (role === 'project_approver' && !approvalDept) {
|
||
const de = modal.querySelector('#e-uc-dept');
|
||
if (de) de.textContent = 'Please select a department for this approver';
|
||
ok = false;
|
||
}
|
||
if (!ok) return;
|
||
|
||
btn.disabled = true; sp.style.display = 'inline-block';
|
||
|
||
try {
|
||
if (isEdit) {
|
||
const patch = { fullName, email, role, active, modules: modulesVal, approvalDept, approvalSteps, issueItemGroups, manualPoItemGroups, manpowerTabs, oeeTabs, allowedCompanies, sapApprovalTypes, allowedDepartments, emailNotify, woVerifyOverride, canImpersonate };
|
||
if (signature !== undefined) patch.signature = signature;
|
||
// Only touch SAP login when the admin actually typed something —
|
||
// sending both validates against SAP; a blank password leaves the
|
||
// stored one unchanged (but lets the SAP User ID alone be updated).
|
||
if (sapLoginUser || sapLoginPwd) {
|
||
if (sapLoginUser) patch.sapLoginUser = sapLoginUser;
|
||
if (sapLoginPwd) patch.sapLoginPwd = sapLoginPwd;
|
||
}
|
||
await api('PATCH', `/users/${user.id}`, patch);
|
||
toast(`✓ User @${user.username} updated`);
|
||
} else {
|
||
const body = { username, password, fullName, email, role, modules: modulesVal, approvalDept, approvalSteps, issueItemGroups, manualPoItemGroups, manpowerTabs, oeeTabs, allowedCompanies, sapApprovalTypes, allowedDepartments, emailNotify, woVerifyOverride, canImpersonate };
|
||
if (signature) body.signature = signature;
|
||
// Optional SAP Service-Layer login — validated against SAP on save.
|
||
if (sapLoginUser) body.sapLoginUser = sapLoginUser;
|
||
if (sapLoginPwd) body.sapLoginPwd = sapLoginPwd;
|
||
await api('POST', '/users', body);
|
||
toast(`✓ User @${username} created`);
|
||
}
|
||
closeModal();
|
||
await loadUsers();
|
||
render();
|
||
} catch (e) {
|
||
err.textContent = e.message;
|
||
btn.disabled = false; sp.style.display = 'none';
|
||
}
|
||
};
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// CHANGE PASSWORD MODAL
|
||
// ════════════════════════════════════════
|
||
function openPasswordModal(user, onDone) {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay';
|
||
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal';
|
||
modal.innerHTML = `
|
||
<div class="modal-header">
|
||
<div class="modal-title">Change Password — @${esc(user.username)}</div>
|
||
<button class="modal-close" id="pw-close">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="alert al-info">Setting a new password will immediately take effect. The user will need to use the new password on their next login.</div>
|
||
<div class="form-group">
|
||
<div class="form-label">New Password <span class="req">*</span></div>
|
||
<input class="form-input" id="pw-new" type="password" placeholder="Minimum 6 characters" autocomplete="new-password"/>
|
||
<div class="form-err" id="e-pw-new"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<div class="form-label">Confirm Password <span class="req">*</span></div>
|
||
<input class="form-input" id="pw-confirm" type="password" placeholder="Repeat new password"/>
|
||
<div class="form-err" id="e-pw-confirm"></div>
|
||
</div>
|
||
<div class="form-err" id="e-pw-global"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn b-cancel" id="pw-cancel">Cancel</button>
|
||
<button class="btn b-amber" id="pw-save">
|
||
<span id="pw-sp" style="display:none" class="sp"></span>
|
||
Set Password
|
||
</button>
|
||
</div>`;
|
||
|
||
overlay.appendChild(modal);
|
||
document.body.appendChild(overlay);
|
||
|
||
const closeModal = () => overlay.remove();
|
||
modal.querySelector('#pw-close').onclick = closeModal;
|
||
modal.querySelector('#pw-cancel').onclick = closeModal;
|
||
overlay.onclick = e => { if (e.target === overlay) closeModal(); };
|
||
|
||
modal.querySelector('#pw-save').onclick = async () => {
|
||
const newPw = modal.querySelector('#pw-new').value;
|
||
const confPw = modal.querySelector('#pw-confirm').value;
|
||
const errN = modal.querySelector('#e-pw-new');
|
||
const errC = modal.querySelector('#e-pw-confirm');
|
||
const errG = modal.querySelector('#e-pw-global');
|
||
errN.textContent = errC.textContent = errG.textContent = '';
|
||
|
||
let ok = true;
|
||
if (!newPw || newPw.length < 6) { errN.textContent = 'Minimum 6 characters'; ok = false; }
|
||
if (newPw !== confPw) { errC.textContent = 'Passwords do not match'; ok = false; }
|
||
if (!ok) return;
|
||
|
||
const btn = modal.querySelector('#pw-save');
|
||
const sp = modal.querySelector('#pw-sp');
|
||
btn.disabled = true; sp.style.display = 'inline-block';
|
||
|
||
try {
|
||
await api('PATCH', `/users/${user.id}`, { password: newPw });
|
||
toast(`✓ Password updated for @${user.username}`);
|
||
closeModal();
|
||
if (onDone) onDone();
|
||
} catch (e) {
|
||
errG.textContent = e.message;
|
||
btn.disabled = false; sp.style.display = 'none';
|
||
}
|
||
};
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// DELETE CONFIRMATION
|
||
// ════════════════════════════════════════
|
||
function confirmDelete(user) {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'confirm-overlay';
|
||
|
||
const box = document.createElement('div');
|
||
box.className = 'confirm-box';
|
||
box.innerHTML = `
|
||
<div class="confirm-icon"></div>
|
||
<div class="confirm-title">Delete User?</div>
|
||
<div class="confirm-msg">
|
||
Are you sure you want to delete <strong>@${esc(user.username)}</strong>
|
||
(${esc(user.fullName || user.username)})?<br/>
|
||
<span style="color:var(--red)">This action cannot be undone.</span>
|
||
</div>
|
||
<div class="confirm-btns">
|
||
<button class="btn b-cancel" id="del-cancel">Cancel</button>
|
||
<button class="btn b-del" id="del-confirm">
|
||
<span id="del-sp" style="display:none" class="sp"></span>
|
||
✕ Delete User
|
||
</button>
|
||
</div>`;
|
||
|
||
overlay.appendChild(box);
|
||
document.body.appendChild(overlay);
|
||
|
||
const closeOverlay = () => overlay.remove();
|
||
box.querySelector('#del-cancel').onclick = closeOverlay;
|
||
overlay.onclick = e => { if (e.target === overlay) closeOverlay(); };
|
||
|
||
box.querySelector('#del-confirm').onclick = async () => {
|
||
const btn = box.querySelector('#del-confirm');
|
||
const sp = box.querySelector('#del-sp');
|
||
btn.disabled = true; sp.style.display = 'inline-block';
|
||
try {
|
||
await api('DELETE', `/users/${user.id}`);
|
||
toast(`✓ User @${user.username} deleted`, 'warn');
|
||
closeOverlay();
|
||
await loadUsers();
|
||
render();
|
||
} catch (e) {
|
||
toast(e.message, 'err');
|
||
btn.disabled = false; sp.style.display = 'none';
|
||
}
|
||
};
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// START
|
||
// ════════════════════════════════════════
|
||
init();
|
||
</script>
|
||
<script src="/sidebar.js?v=31"></script>
|
||
</body>
|
||
</html>
|