3962 lines
272 KiB
HTML
3962 lines
272 KiB
HTML
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="en">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>Pinnacle Decision Portal</title>
|
|||
|
|
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Source+Sans+3:wght@300;400;500;600&display=swap" rel="stylesheet">
|
|||
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
|||
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js"></script>
|
|||
|
|
<!-- EmailJS -->
|
|||
|
|
<script src="https://cdn.jsdelivr.net/npm/@emailjs/browser@4/dist/email.min.js"></script>
|
|||
|
|
<script>
|
|||
|
|
function initEmailJS(){
|
|||
|
|
var pk = DB && DB._ejPk;
|
|||
|
|
if(pk && window.emailjs){ try{ emailjs.init({publicKey: pk}); }catch(e){} }
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
<style>
|
|||
|
|
:root{--navy:#0d2137;--nm:#1a3a5c;--nl:#2e5f8a;--gold:#c9a227;--gl:#e8c660;--gp:#f5e9c0;--off:#f8f6f0;--g50:#fafafa;--g100:#f4f4f5;--g200:#e4e4e7;--g300:#d1d5db;--g400:#a1a1aa;--g600:#52525b;--g800:#27272a;--success:#15803d;--sbg:#dcfce7;--warn:#b45309;--wbg:#fef3c7;--danger:#b91c1c;--dbg:#fee2e2;--info:#1d4ed8;--ibg:#dbeafe;--sw:255px;--hh:62px;--r:8px;--sh:0 1px 3px rgba(0,0,0,.12);--shm:0 4px 16px rgba(13,33,55,.18);--shl:0 10px 32px rgba(13,33,55,.22)}
|
|||
|
|
*{box-sizing:border-box;margin:0;padding:0}html{font-size:15px}
|
|||
|
|
body{font-family:"Source Sans 3",sans-serif;background:var(--off);color:var(--g800);min-height:100vh}
|
|||
|
|
a{color:inherit;text-decoration:none;cursor:pointer}.hidden{display:none!important}
|
|||
|
|
.sidebar{position:fixed;top:0;left:0;width:var(--sw);height:100vh;background:var(--navy);display:flex;flex-direction:column;z-index:100;overflow-y:auto;transition:transform .3s}
|
|||
|
|
.sb-brand{padding:17px 18px 13px;border-bottom:1px solid rgba(201,162,39,.2);display:flex;align-items:center;gap:10px;min-height:var(--hh)}
|
|||
|
|
.sb-icon{width:35px;height:35px;background:var(--gold);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:16px;color:var(--navy);flex-shrink:0}
|
|||
|
|
.sb-title{font-family:"Playfair Display",serif;font-size:13px;font-weight:700;color:#fff}
|
|||
|
|
.sb-sub{font-size:10px;color:var(--gold);letter-spacing:1.5px;text-transform:uppercase}
|
|||
|
|
.nav-sec{padding:12px 9px 0}.nav-lbl{font-size:9px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:rgba(201,162,39,.55);padding:0 9px 6px}
|
|||
|
|
.nav-item{display:flex;align-items:center;gap:9px;padding:8px 9px;border-radius:6px;color:rgba(255,255,255,.65);font-size:13px;transition:all .15s;margin-bottom:2px;cursor:pointer}
|
|||
|
|
.nav-item:hover{background:rgba(255,255,255,.08);color:#fff}.nav-item.active{background:rgba(201,162,39,.15);color:var(--gold);font-weight:500}
|
|||
|
|
.nav-icon{width:16px;text-align:center;font-size:12px;flex-shrink:0}
|
|||
|
|
.sb-foot{margin-top:auto;padding:12px 9px;border-top:1px solid rgba(255,255,255,.08)}
|
|||
|
|
.user-card{display:flex;align-items:center;gap:8px;padding:8px;border-radius:8px;background:rgba(255,255,255,.06)}
|
|||
|
|
.user-av{width:30px;height:30px;border-radius:50%;background:var(--gold);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:var(--navy);flex-shrink:0}
|
|||
|
|
.user-name{font-size:12px;font-weight:500;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|||
|
|
.user-role{font-size:10px;color:var(--gold);text-transform:capitalize}
|
|||
|
|
.main-wrap{margin-left:var(--sw);min-height:100vh;display:flex;flex-direction:column}
|
|||
|
|
.topbar{height:var(--hh);background:#fff;border-bottom:1px solid var(--g200);display:flex;align-items:center;justify-content:space-between;padding:0 24px;position:sticky;top:0;z-index:90;box-shadow:var(--sh)}
|
|||
|
|
.pg-title{font-family:"Playfair Display",serif;font-size:18px;font-weight:600;color:var(--navy)}
|
|||
|
|
.tb-right{display:flex;align-items:center;gap:12px}
|
|||
|
|
.tb-btn{position:relative;width:33px;height:33px;border-radius:7px;background:var(--g100);border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--g600);font-size:13px;transition:all .15s}
|
|||
|
|
.tb-btn:hover{background:var(--navy);color:#fff}
|
|||
|
|
.ndot{position:absolute;top:-3px;right:-3px;background:var(--gold);color:var(--navy);font-size:8px;font-weight:700;width:14px;height:14px;border-radius:50%;display:flex;align-items:center;justify-content:center}
|
|||
|
|
.content{flex:1;padding:24px}
|
|||
|
|
.card{background:#fff;border-radius:var(--r);box-shadow:var(--sh);border:1px solid var(--g200);margin-bottom:18px}
|
|||
|
|
.card-hdr{padding:13px 17px;border-bottom:1px solid var(--g100);display:flex;align-items:center;justify-content:space-between}
|
|||
|
|
.card-title{font-family:"Playfair Display",serif;font-size:14.5px;font-weight:600;color:var(--navy)}
|
|||
|
|
.card-body{padding:17px}
|
|||
|
|
.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(155px,1fr));gap:13px;margin-bottom:20px}
|
|||
|
|
.stat-card{background:#fff;border-radius:var(--r);padding:15px 17px;border:1px solid var(--g200);box-shadow:var(--sh);display:flex;align-items:center;gap:12px;position:relative;overflow:hidden}
|
|||
|
|
/* ── TOP 3 BLOCKERS ── */
|
|||
|
|
.blockers-panel{background:linear-gradient(135deg,#0f172a 0%,#1e1b4b 100%);border-radius:14px;padding:18px 20px;margin-bottom:20px;position:relative;overflow:hidden}
|
|||
|
|
.blockers-panel::before{content:'';position:absolute;top:-40px;right:-40px;width:160px;height:160px;border-radius:50%;background:rgba(244,63,94,.08);pointer-events:none}
|
|||
|
|
.blockers-panel::after{content:'';position:absolute;bottom:-30px;left:20%;width:120px;height:120px;border-radius:50%;background:rgba(99,102,241,.06);pointer-events:none}
|
|||
|
|
.blockers-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
|
|||
|
|
.blockers-hdr-left{display:flex;align-items:center;gap:10px}
|
|||
|
|
.blockers-title{font-size:14px;font-weight:700;color:#fff;letter-spacing:.3px}
|
|||
|
|
.blockers-badge{background:rgba(244,63,94,.2);color:#fda4af;font-size:10px;font-weight:800;padding:2px 9px;border-radius:20px;letter-spacing:.5px;border:1px solid rgba(244,63,94,.3)}
|
|||
|
|
.blockers-sub{font-size:11px;color:rgba(255,255,255,.45);font-weight:500}
|
|||
|
|
.blocker-items{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
|
|||
|
|
@media(max-width:700px){.blocker-items{grid-template-columns:1fr}}
|
|||
|
|
.blocker-item{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);border-radius:10px;padding:13px 14px;position:relative;cursor:pointer;transition:background .15s,border-color .15s}
|
|||
|
|
.blocker-item:hover{background:rgba(255,255,255,.09);border-color:rgba(255,255,255,.2)}
|
|||
|
|
.blocker-item.sev-critical{border-left:3px solid #f43f5e}
|
|||
|
|
.blocker-item.sev-high{border-left:3px solid #f97316}
|
|||
|
|
.blocker-item.sev-medium{border-left:3px solid #eab308}
|
|||
|
|
.blocker-rank{font-size:9px;font-weight:800;letter-spacing:1px;text-transform:uppercase;margin-bottom:7px;display:flex;align-items:center;gap:6px}
|
|||
|
|
.blocker-rank-num{width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:900;flex-shrink:0}
|
|||
|
|
.blocker-name{font-size:12.5px;font-weight:700;color:#fff;line-height:1.35;margin-bottom:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|||
|
|
.blocker-meta{font-size:10.5px;color:rgba(255,255,255,.5);line-height:1.5}
|
|||
|
|
.blocker-days{display:inline-flex;align-items:center;gap:4px;background:rgba(244,63,94,.15);color:#fca5a5;font-size:9.5px;font-weight:800;padding:2px 7px;border-radius:10px;margin-top:7px;border:1px solid rgba(244,63,94,.2)}
|
|||
|
|
.blocker-days.amber{background:rgba(234,179,8,.12);color:#fde047;border-color:rgba(234,179,8,.2)}
|
|||
|
|
.blocker-days.orange{background:rgba(249,115,22,.12);color:#fdba74;border-color:rgba(249,115,22,.2)}
|
|||
|
|
.blocker-type-pill{font-size:9px;font-weight:700;padding:1px 6px;border-radius:8px;display:inline-block;margin-bottom:5px}
|
|||
|
|
.tp-risk{background:rgba(139,92,246,.2);color:#c4b5fd}
|
|||
|
|
.tp-activity{background:rgba(14,165,233,.2);color:#7dd3fc}
|
|||
|
|
.tp-decision{background:rgba(245,158,11,.2);color:#fde68a}
|
|||
|
|
.no-blockers{text-align:center;padding:22px;color:rgba(255,255,255,.4);font-size:13px}
|
|||
|
|
.no-blockers i{font-size:28px;display:block;margin-bottom:8px;opacity:.4}
|
|||
|
|
.escalate-btn{display:inline-flex;align-items:center;gap:4px;font-size:9.5px;font-weight:700;color:rgba(255,255,255,.5);background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:6px;padding:3px 8px;margin-top:8px;cursor:pointer;transition:background .15s,color .15s}
|
|||
|
|
.escalate-btn:hover{background:rgba(244,63,94,.2);color:#fda4af;border-color:rgba(244,63,94,.3)}
|
|||
|
|
.stat-card::before{content:"";position:absolute;top:0;left:0;width:4px;height:100%;background:var(--sc,var(--gold))}
|
|||
|
|
.stat-icon{width:38px;height:38px;border-radius:9px;background:var(--scb,var(--gp));display:flex;align-items:center;justify-content:center;color:var(--sc,var(--gold));font-size:16px;flex-shrink:0}
|
|||
|
|
.stat-val{font-size:23px;font-weight:700;color:var(--navy);line-height:1}.stat-lbl{font-size:10.5px;color:var(--g400);margin-top:3px}
|
|||
|
|
.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:600}
|
|||
|
|
.b-success{background:var(--sbg);color:var(--success)}.b-warn{background:var(--wbg);color:var(--warn)}
|
|||
|
|
.b-danger{background:var(--dbg);color:var(--danger)}.b-info{background:var(--ibg);color:var(--info)}
|
|||
|
|
.b-gray{background:var(--g100);color:var(--g600)}.b-navy{background:var(--navy);color:#fff}.b-gold{background:var(--gp);color:var(--warn)}
|
|||
|
|
.btn{display:inline-flex;align-items:center;gap:6px;padding:7px 14px;border-radius:6px;border:none;cursor:pointer;font-size:13px;font-weight:500;font-family:inherit;transition:all .15s;white-space:nowrap}
|
|||
|
|
.btn-primary{background:var(--navy);color:#fff}.btn-primary:hover{background:var(--nm)}
|
|||
|
|
.btn-gold{background:var(--gold);color:var(--navy)}.btn-gold:hover{background:var(--gl)}
|
|||
|
|
.btn-outline{background:transparent;border:1px solid var(--g200);color:var(--g600)}.btn-outline:hover{border-color:var(--navy);color:var(--navy)}
|
|||
|
|
.btn-danger{background:var(--dbg);color:var(--danger);border:1px solid #fca5a5}.btn-danger:hover{background:var(--danger);color:#fff}
|
|||
|
|
.btn-success{background:var(--sbg);color:var(--success);border:1px solid #86efac}
|
|||
|
|
.btn-sm{padding:5px 10px;font-size:12px}.btn-xs{padding:3px 7px;font-size:11px}
|
|||
|
|
.tbl-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse}
|
|||
|
|
th{background:var(--navy);color:#fff;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:.8px;text-transform:uppercase;text-align:left;white-space:nowrap}
|
|||
|
|
td{padding:9px 12px;border-bottom:1px solid var(--g100);font-size:13px;vertical-align:middle}
|
|||
|
|
tr:last-child td{border-bottom:none}tr:hover td{background:var(--g50)}
|
|||
|
|
.fg{margin-bottom:13px}
|
|||
|
|
.fg label{display:block;font-size:11px;font-weight:600;color:var(--g600);margin-bottom:4px;letter-spacing:.3px;text-transform:uppercase}
|
|||
|
|
.fg input,.fg select,.fg textarea{width:100%;padding:8px 10px;border:1px solid var(--g200);border-radius:6px;font-family:inherit;font-size:13px;color:var(--g800);background:#fff;transition:border-color .15s}
|
|||
|
|
.fg input:focus,.fg select:focus,.fg textarea:focus{outline:none;border-color:var(--nl);box-shadow:0 0 0 3px rgba(46,95,138,.1)}
|
|||
|
|
.fg textarea{resize:vertical;min-height:70px}.fg-2{display:grid;grid-template-columns:1fr 1fr;gap:13px}.fg-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:13px}
|
|||
|
|
.prog-wrap{background:var(--g200);border-radius:20px;height:7px;overflow:hidden}
|
|||
|
|
.prog-bar{height:100%;background:linear-gradient(90deg,var(--nm),var(--gold));border-radius:20px;transition:width .5s}
|
|||
|
|
.prog-bar.ps{background:linear-gradient(90deg,#15803d,#4ade80)}.prog-bar.pd{background:linear-gradient(90deg,#dc2626,#f87171)}
|
|||
|
|
.alert{padding:10px 13px;border-radius:6px;font-size:13px;display:flex;align-items:center;gap:8px;margin-bottom:13px;animation:fadeIn .3s}
|
|||
|
|
@keyframes fadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
|
|||
|
|
.alert-success{background:var(--sbg);color:var(--success);border-left:4px solid var(--success)}
|
|||
|
|
.alert-danger{background:var(--dbg);color:var(--danger);border-left:4px solid var(--danger)}
|
|||
|
|
.overlay{position:fixed;inset:0;background:rgba(13,33,55,.55);z-index:500;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .2s}
|
|||
|
|
.overlay.open{opacity:1;pointer-events:all}
|
|||
|
|
.modal{background:#fff;border-radius:12px;width:100%;max-width:580px;max-height:92vh;overflow-y:auto;box-shadow:var(--shl);transform:translateY(16px);transition:transform .2s}
|
|||
|
|
.overlay.open .modal{transform:none}
|
|||
|
|
.modal-hdr{padding:15px 20px;border-bottom:1px solid var(--g100);display:flex;align-items:center;justify-content:space-between;background:var(--navy);border-radius:12px 12px 0 0;position:sticky;top:0;z-index:10}
|
|||
|
|
.modal-title{font-family:"Playfair Display",serif;font-size:15px;color:#fff}
|
|||
|
|
.modal-close{background:none;border:none;color:rgba(255,255,255,.6);font-size:17px;cursor:pointer}.modal-close:hover{color:#fff}
|
|||
|
|
.modal-body{padding:20px}.modal-foot{padding:12px 20px;border-top:1px solid var(--g100);display:flex;justify-content:flex-end;gap:8px;background:var(--g50);border-radius:0 0 12px 12px}
|
|||
|
|
.nd{position:absolute;top:40px;right:0;width:310px;background:#fff;border-radius:10px;box-shadow:var(--shl);border:1px solid var(--g200);z-index:300;display:none;max-height:400px;overflow-y:auto}
|
|||
|
|
.nd.open{display:block}.nd-hdr{padding:10px 14px;border-bottom:1px solid var(--g100);display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;background:#fff}
|
|||
|
|
.nd-item{padding:10px 14px;border-bottom:1px solid var(--g100);font-size:12.5px;cursor:pointer;transition:background .15s}
|
|||
|
|
.nd-item:hover{background:var(--g50)}.nd-item.unread{background:rgba(201,162,39,.06)}
|
|||
|
|
.login-screen{position:fixed;inset:0;background:var(--navy);display:flex;align-items:center;justify-content:center;z-index:999}
|
|||
|
|
.login-box{background:#fff;border-radius:16px;overflow:hidden;width:380px;box-shadow:var(--shl)}
|
|||
|
|
.login-hdr{background:var(--navy);padding:28px 32px;text-align:center;border-bottom:3px solid var(--gold)}
|
|||
|
|
.login-logo{width:52px;height:52px;background:var(--gold);border-radius:12px;margin:0 auto 12px;display:flex;align-items:center;justify-content:center;font-size:22px;color:var(--navy)}
|
|||
|
|
.login-body{padding:26px 32px}
|
|||
|
|
.login-body label{display:block;font-size:11px;font-weight:700;color:var(--g600);margin-bottom:4px;text-transform:uppercase;letter-spacing:.5px}
|
|||
|
|
.login-body input{width:100%;padding:9px 12px;border:1.5px solid var(--g200);border-radius:7px;font-size:13.5px;font-family:inherit;margin-bottom:13px}
|
|||
|
|
.login-body input:focus{outline:none;border-color:var(--nl)}
|
|||
|
|
.login-btn{width:100%;padding:11px;background:var(--navy);color:#fff;border:none;border-radius:7px;font-family:inherit;font-size:14px;font-weight:600;cursor:pointer}
|
|||
|
|
.login-btn:hover{background:var(--nm)}
|
|||
|
|
.login-err{background:var(--dbg);color:var(--danger);padding:8px 12px;border-radius:6px;font-size:12.5px;margin-bottom:12px;border-left:3px solid var(--danger)}
|
|||
|
|
.phase-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:15px}
|
|||
|
|
.phase-card{background:#fff;border-radius:10px;padding:16px;border:1px solid var(--g200);box-shadow:var(--sh);border-top:4px solid var(--pc,var(--gold));transition:box-shadow .2s,transform .2s;cursor:pointer}
|
|||
|
|
.phase-card:hover{box-shadow:var(--shm);transform:translateY(-2px)}
|
|||
|
|
.phase-card.active-phase{border:2px solid var(--pc,var(--gold));border-top:4px solid var(--pc,var(--gold));box-shadow:0 0 0 3px rgba(201,162,39,.18),var(--shm);background:linear-gradient(160deg,#fffbef 0%,#fff 60%);position:relative}
|
|||
|
|
.phase-card.active-phase::after{content:"IN PROGRESS";position:absolute;top:10px;right:10px;font-size:8.5px;font-weight:800;letter-spacing:1.2px;background:var(--pc,var(--gold));color:#fff;padding:2px 7px;border-radius:20px}
|
|||
|
|
.upcoming-list{list-style:none;padding:0;margin:0}
|
|||
|
|
.stat-tiles{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-top:16px}
|
|||
|
|
.stat-tile{border-radius:14px;padding:14px 16px;display:flex;align-items:center;gap:13px;cursor:default;transition:transform .18s,box-shadow .18s;position:relative;overflow:hidden;border:1px solid transparent}
|
|||
|
|
.stat-tile:hover{transform:translateY(-3px);box-shadow:0 8px 24px rgba(0,0,0,.13)}
|
|||
|
|
.stat-tile-icon{width:42px;height:42px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:17px;flex-shrink:0;box-shadow:0 4px 10px rgba(0,0,0,.12);color:#fff}
|
|||
|
|
.stat-tile-val{font-size:26px;font-weight:900;line-height:1;font-family:'Playfair Display',serif;letter-spacing:-1px;color:#0f172a}
|
|||
|
|
.stat-tile-lbl{font-size:10px;font-weight:800;letter-spacing:.8px;text-transform:uppercase;margin-top:3px;color:#475569}
|
|||
|
|
.stat-tile-pct{font-size:10px;font-weight:700;margin-top:2px;opacity:.75}
|
|||
|
|
.chart-centre-wrap{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none}
|
|||
|
|
.chart-centre-val{font-size:30px;font-weight:900;color:#0f172a;font-family:'Playfair Display',serif;line-height:1;transition:color .2s}
|
|||
|
|
.chart-centre-lbl{font-size:9.5px;font-weight:800;color:#64748b;letter-spacing:1.2px;text-transform:uppercase;margin-top:3px}
|
|||
|
|
.upcoming-item{display:flex;align-items:center;gap:12px;padding:9px 0;border-bottom:1px solid var(--g100)}
|
|||
|
|
.upcoming-item:last-child{border-bottom:none}
|
|||
|
|
.upcoming-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
|
|||
|
|
.upcoming-days{font-size:10px;font-weight:700;min-width:52px;text-align:right;flex-shrink:0}
|
|||
|
|
.pill{display:inline-flex;align-items:center;gap:4px;padding:3px 9px;background:var(--g50);border:1px solid var(--g200);border-radius:6px;font-size:11.5px;color:var(--g600);cursor:pointer;transition:all .15s}
|
|||
|
|
.pill:hover{border-color:var(--navy);color:var(--navy)}
|
|||
|
|
.bc{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--g400);margin-bottom:14px}
|
|||
|
|
.bc .lnk{color:var(--gold);cursor:pointer}.bc .lnk:hover{text-decoration:underline}
|
|||
|
|
.divider{height:1px;background:var(--g200);margin:13px 0}
|
|||
|
|
.flex{display:flex}.ic{align-items:center}.jb{justify-content:space-between}
|
|||
|
|
.gap-2{gap:8px}.gap-3{gap:12px}.mt-2{margin-top:8px}.mt-3{margin-top:12px}.mt-4{margin-top:16px}
|
|||
|
|
.mb-3{margin-bottom:12px}.mb-4{margin-bottom:16px}.fw6{font-weight:600}.fs12{font-size:12px}.fs13{font-size:13px}
|
|||
|
|
.muted{color:var(--g400)}.tgold{color:var(--gold)}.tnavy{color:var(--navy)}
|
|||
|
|
.empty{text-align:center;padding:36px 16px;color:var(--g400)}.empty i{font-size:30px;margin-bottom:10px;opacity:.35;display:block}
|
|||
|
|
/* ── PMT / PSC COMMITTEES ── */
|
|||
|
|
.committee-banner{display:flex;gap:12px;margin-bottom:18px;flex-wrap:wrap}
|
|||
|
|
.committee-card{flex:1;min-width:220px;border-radius:14px;padding:16px 18px;position:relative;overflow:hidden;border:1px solid transparent}
|
|||
|
|
.committee-card::before{content:'';position:absolute;top:-30px;right:-30px;width:100px;height:100px;border-radius:50%;opacity:.12;pointer-events:none}
|
|||
|
|
.committee-card.pmt{background:linear-gradient(135deg,#1e3a5f,#1e4976);border-color:#2563eb44}
|
|||
|
|
.committee-card.pmt::before{background:#60a5fa}
|
|||
|
|
.committee-card.psc{background:linear-gradient(135deg,#3b1f6e,#4c1d95);border-color:#7c3aed44}
|
|||
|
|
.committee-card.psc::before{background:#a78bfa}
|
|||
|
|
.committee-hdr{display:flex;align-items:center;gap:10px;margin-bottom:10px}
|
|||
|
|
.committee-icon{width:36px;height:36px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:15px;flex-shrink:0}
|
|||
|
|
.committee-card.pmt .committee-icon{background:rgba(96,165,250,.2);color:#60a5fa}
|
|||
|
|
.committee-card.psc .committee-icon{background:rgba(167,139,250,.2);color:#a78bfa}
|
|||
|
|
.committee-name{font-size:13px;font-weight:800;color:#fff;letter-spacing:.3px}
|
|||
|
|
.committee-role{font-size:10px;color:rgba(255,255,255,.5);margin-top:1px;font-weight:500}
|
|||
|
|
.committee-stats{display:flex;gap:8px;flex-wrap:wrap}
|
|||
|
|
.committee-stat{background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:7px 10px;text-align:center;flex:1;min-width:60px}
|
|||
|
|
.committee-stat-val{font-size:18px;font-weight:900;color:#fff;font-family:'Playfair Display',serif;line-height:1}
|
|||
|
|
.committee-stat-lbl{font-size:9px;color:rgba(255,255,255,.5);margin-top:2px;font-weight:600;letter-spacing:.4px;text-transform:uppercase}
|
|||
|
|
.committee-members{display:flex;gap:4px;flex-wrap:wrap;margin-top:10px}
|
|||
|
|
.cm-avatar{width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:800;color:#fff;border:2px solid rgba(255,255,255,.15);cursor:pointer;transition:transform .15s}
|
|||
|
|
.cm-avatar:hover{transform:scale(1.15);z-index:2}
|
|||
|
|
.cm-more{background:rgba(255,255,255,.1);color:rgba(255,255,255,.6);font-size:9px}
|
|||
|
|
/* Approval badges on milestone/deliverable cards */
|
|||
|
|
.approval-row{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px}
|
|||
|
|
.approval-badge{display:inline-flex;align-items:center;gap:4px;font-size:9.5px;font-weight:700;padding:3px 8px;border-radius:6px;letter-spacing:.3px}
|
|||
|
|
.ab-pmt-endorsed{background:rgba(37,99,235,.12);color:#3b82f6;border:1px solid rgba(37,99,235,.25)}
|
|||
|
|
.ab-pmt-escalated{background:rgba(239,68,68,.1);color:#dc2626;border:1px solid rgba(239,68,68,.25)}
|
|||
|
|
.ab-pmt-pending{background:rgba(234,179,8,.1);color:#ca8a04;border:1px solid rgba(234,179,8,.25)}
|
|||
|
|
.ab-pmt-returned{background:rgba(249,115,22,.1);color:#ea580c;border:1px solid rgba(249,115,22,.25)}
|
|||
|
|
.ab-psc-approved{background:rgba(124,58,237,.12);color:#7c3aed;border:1px solid rgba(124,58,237,.25)}
|
|||
|
|
.ab-psc-pending{background:rgba(249,115,22,.1);color:#ea580c;border:1px solid rgba(249,115,22,.25)}
|
|||
|
|
.ab-psc-rejected{background:rgba(239,68,68,.1);color:#dc2626;border:1px solid rgba(239,68,68,.25)}
|
|||
|
|
/* Committee page */
|
|||
|
|
.committee-section{background:#fff;border-radius:12px;border:1px solid var(--g200);box-shadow:var(--sh);margin-bottom:18px;overflow:hidden}
|
|||
|
|
.committee-section-hdr{padding:14px 18px;border-bottom:1px solid var(--g100);display:flex;align-items:center;justify-content:space-between}
|
|||
|
|
.committee-section-title{font-size:14px;font-weight:700;display:flex;align-items:center;gap:8px}
|
|||
|
|
.approval-table{width:100%;border-collapse:collapse}
|
|||
|
|
.approval-table th{background:var(--g50);padding:9px 14px;font-size:10px;font-weight:700;text-align:left;color:var(--g500);letter-spacing:.8px;text-transform:uppercase;border-bottom:1px solid var(--g200)}
|
|||
|
|
.approval-table td{padding:10px 14px;font-size:12.5px;border-bottom:1px solid var(--g100);vertical-align:middle}
|
|||
|
|
.approval-table tr:last-child td{border-bottom:none}
|
|||
|
|
.approval-table tr:hover td{background:var(--g50)}
|
|||
|
|
.status-pill{display:inline-flex;align-items:center;gap:4px;padding:3px 9px;border-radius:20px;font-size:10px;font-weight:700}
|
|||
|
|
.sp-approved{background:#dcfce7;color:#15803d}
|
|||
|
|
.sp-pending{background:#fef9c3;color:#854d0e}
|
|||
|
|
.sp-rejected{background:#fee2e2;color:#991b1b}
|
|||
|
|
.sp-not-required{background:#f1f5f9;color:#64748b}
|
|||
|
|
.workflow-line{display:flex;align-items:center;gap:0;margin:16px 0;overflow-x:auto;padding:0 18px}
|
|||
|
|
.wf-step{display:flex;flex-direction:column;align-items:center;min-width:90px}
|
|||
|
|
.wf-dot{width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;border:2px solid}
|
|||
|
|
.wf-dot.done{background:#dcfce7;border-color:#16a34a;color:#16a34a}
|
|||
|
|
.wf-dot.active{background:#dbeafe;border-color:#2563eb;color:#2563eb;animation:pulse-green 2s infinite}
|
|||
|
|
.wf-dot.pending{background:#f8fafc;border-color:#cbd5e1;color:#94a3b8}
|
|||
|
|
.wf-lbl{font-size:9.5px;font-weight:700;color:var(--g500);margin-top:5px;text-align:center;max-width:80px;line-height:1.3}
|
|||
|
|
.wf-arrow{flex:1;height:2px;background:linear-gradient(90deg,#e2e8f0,#e2e8f0);min-width:20px;margin-bottom:18px}
|
|||
|
|
.wf-arrow.done{background:linear-gradient(90deg,#16a34a,#16a34a)}
|
|||
|
|
/* == JSONBIN SYNC == */
|
|||
|
|
.jb-status-bar{display:flex;align-items:center;gap:6px;font-size:11px;padding:5px 10px;border-radius:6px;cursor:pointer;color:rgba(255,255,255,.7);transition:background .15s;margin-top:2px}
|
|||
|
|
.jb-status-bar:hover{background:rgba(255,255,255,.1)}
|
|||
|
|
.jb-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;transition:background .3s}
|
|||
|
|
.jb-dot.live{background:#22c55e;box-shadow:0 0 0 3px rgba(34,197,94,.2);animation:pulse-green 2s infinite}
|
|||
|
|
.jb-dot.syncing{background:#f59e0b;animation:pulse-green 2s infinite}
|
|||
|
|
.jb-dot.off{background:#6b7280}
|
|||
|
|
.jb-dot.error{background:#ef4444}
|
|||
|
|
/* == SYNC STATUS == */
|
|||
|
|
.sync-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:5px;flex-shrink:0}
|
|||
|
|
.sync-dot.live{background:#22c55e;box-shadow:0 0 0 2px rgba(34,197,94,.25);animation:pulse-green 2s infinite}
|
|||
|
|
.sync-dot.local{background:#f59e0b}
|
|||
|
|
.sync-dot.error{background:#ef4444}
|
|||
|
|
@keyframes pulse-green{0%,100%{box-shadow:0 0 0 2px rgba(34,197,94,.25)}50%{box-shadow:0 0 0 5px rgba(34,197,94,.1)}}
|
|||
|
|
.sync-bar{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--g400);padding:3px 8px;background:rgba(255,255,255,.06);border-radius:6px;cursor:pointer}
|
|||
|
|
.sync-bar:hover{background:rgba(255,255,255,.1)}
|
|||
|
|
/* == GANTT ENHANCED == */
|
|||
|
|
/* ═══════════════════════════════════════
|
|||
|
|
GANTT CHART — REVAMPED PROFESSIONAL
|
|||
|
|
═══════════════════════════════════════ */
|
|||
|
|
:root{
|
|||
|
|
--g-ph1:#6366f1; /* indigo */
|
|||
|
|
--g-ph2:#0ea5e9; /* sky blue */
|
|||
|
|
--g-ph3:#10b981; /* emerald */
|
|||
|
|
--g-ph4:#f59e0b; /* amber */
|
|||
|
|
--g-ph5:#ec4899; /* pink */
|
|||
|
|
--g-ph6:#8b5cf6; /* violet */
|
|||
|
|
--g-done:#10b981;
|
|||
|
|
--g-prog:#6366f1;
|
|||
|
|
--g-delay:#f43f5e;
|
|||
|
|
--g-none:#94a3b8;
|
|||
|
|
--g-today:#f43f5e;
|
|||
|
|
}
|
|||
|
|
.gantt-wrap{overflow-x:auto;padding:0;position:relative;user-select:none;border-radius:10px;box-shadow:0 4px 24px rgba(0,0,0,.10)}
|
|||
|
|
.gantt-header{display:flex;background:linear-gradient(135deg,#0f172a 0%,#1e293b 100%);border-radius:10px 10px 0 0;position:sticky;top:0;z-index:10}
|
|||
|
|
.gantt-label-col{width:220px;flex-shrink:0;padding:12px 16px;font-size:9.5px;font-weight:800;color:rgba(255,255,255,.5);letter-spacing:2px;text-transform:uppercase;border-right:1px solid rgba(255,255,255,.08)}
|
|||
|
|
.gantt-months{flex:1;display:flex;position:relative}
|
|||
|
|
.gantt-month{font-size:10px;color:rgba(255,255,255,.7);padding:12px 8px;font-weight:700;border-left:1px solid rgba(255,255,255,.08);letter-spacing:.3px;white-space:nowrap;overflow:hidden;transition:background .2s}
|
|||
|
|
.gantt-month-active{background:rgba(99,102,241,.25)!important;color:#fff!important;position:relative}
|
|||
|
|
.gantt-month-active::after{content:'NOW';position:absolute;bottom:3px;right:5px;font-size:7px;font-weight:900;color:#a5b4fc;letter-spacing:1px}
|
|||
|
|
|
|||
|
|
/* Zebra rows */
|
|||
|
|
.gantt-body{min-width:600px;background:#fff}
|
|||
|
|
.gantt-row{display:flex;align-items:center;border-bottom:1px solid #f1f5f9;min-height:46px;transition:background .15s;position:relative}
|
|||
|
|
.gantt-row:nth-child(even){background:#fafbff}
|
|||
|
|
.gantt-row:nth-child(odd){background:#fff}
|
|||
|
|
.gantt-row:hover{background:#f0f4ff!important}
|
|||
|
|
.gantt-row:last-child{border-bottom:none}
|
|||
|
|
|
|||
|
|
/* Label column */
|
|||
|
|
.gantt-row-label{width:220px;flex-shrink:0;padding:8px 14px;border-right:2px solid #f1f5f9;display:flex;flex-direction:column;gap:3px}
|
|||
|
|
.gantt-row-label .mil-name{font-weight:700;color:#0f172a;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:190px;line-height:1.3}
|
|||
|
|
.gantt-row-label .ph-tag{font-size:9.5px;font-weight:800;letter-spacing:.8px;padding:1px 6px;border-radius:20px;display:inline-block;width:fit-content}
|
|||
|
|
|
|||
|
|
/* Track & bars */
|
|||
|
|
.gantt-track{flex:1;position:relative;height:46px}
|
|||
|
|
.gantt-bar{position:absolute;top:10px;height:26px;border-radius:8px;display:flex;align-items:center;padding:0 10px;cursor:pointer;transition:transform .15s,box-shadow .15s;overflow:hidden}
|
|||
|
|
.gantt-bar:hover{transform:scaleY(1.08) translateY(-1px);box-shadow:0 6px 20px rgba(0,0,0,.25)!important;z-index:6}
|
|||
|
|
.gantt-bar-fill{position:absolute;left:0;top:0;height:100%;background:rgba(255,255,255,.22);border-radius:8px 0 0 8px;transition:width .7s cubic-bezier(.4,0,.2,1)}
|
|||
|
|
.gantt-bar-pct{font-size:9px;color:rgba(255,255,255,.95);font-weight:900;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;position:relative;z-index:1;text-shadow:0 1px 2px rgba(0,0,0,.3)}
|
|||
|
|
.gantt-bar-shine{position:absolute;top:0;left:0;right:0;height:50%;background:linear-gradient(180deg,rgba(255,255,255,.2),transparent);border-radius:8px 8px 0 0;pointer-events:none}
|
|||
|
|
|
|||
|
|
/* Active row */
|
|||
|
|
.gantt-active-row{background:linear-gradient(90deg,#f0f4ff,#fafbff)!important}
|
|||
|
|
.gantt-active-row .gantt-row-label{border-left:3px solid #6366f1}
|
|||
|
|
|
|||
|
|
/* TODAY elements */
|
|||
|
|
.gantt-now-pointer{position:absolute;top:-36px;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;z-index:20;pointer-events:none;filter:drop-shadow(0 2px 4px rgba(244,63,94,.4))}
|
|||
|
|
.gantt-now-label{background:linear-gradient(135deg,#f43f5e,#e11d48);color:#fff;font-size:8px;font-weight:900;padding:3px 8px;border-radius:5px;letter-spacing:.8px;white-space:nowrap}
|
|||
|
|
.gantt-now-arrow{width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-top:6px solid #e11d48}
|
|||
|
|
.gantt-now-line{position:absolute;top:0;width:2px;height:100%;z-index:15;pointer-events:none;background:linear-gradient(180deg,#f43f5e,rgba(244,63,94,.15));box-shadow:0 0 8px rgba(244,63,94,.35)}
|
|||
|
|
.gantt-pulse-dot{width:12px;height:12px;border-radius:50%;background:#f43f5e;border:2px solid #fff;position:absolute;transform:translate(-50%,-50%);top:50%;z-index:20;animation:gantt-pulse 1.8s ease-out infinite}
|
|||
|
|
@keyframes gantt-pulse{0%{box-shadow:0 0 0 0 rgba(244,63,94,.6)}70%{box-shadow:0 0 0 10px rgba(244,63,94,0)}100%{box-shadow:0 0 0 0 rgba(244,63,94,0)}}
|
|||
|
|
|
|||
|
|
/* Tooltip */
|
|||
|
|
.gantt-tooltip{position:fixed;background:linear-gradient(135deg,#0f172a,#1e293b);color:#fff;padding:12px 16px;border-radius:10px;font-size:12px;z-index:9999;pointer-events:none;box-shadow:0 8px 32px rgba(0,0,0,.35);border-left:3px solid #6366f1;max-width:280px;display:none;line-height:1.6}
|
|||
|
|
|
|||
|
|
/* Legend */
|
|||
|
|
.gantt-legend{display:flex;flex-wrap:wrap;gap:12px;padding:12px 16px;border-top:2px solid #f1f5f9;background:linear-gradient(135deg,#f8fafc,#f1f5f9);border-radius:0 0 10px 10px;align-items:center}
|
|||
|
|
.gantt-legend-item{display:flex;align-items:center;gap:6px;font-size:11px;color:#64748b;font-weight:600}
|
|||
|
|
.gantt-legend-dot{width:14px;height:14px;border-radius:4px;flex-shrink:0}
|
|||
|
|
|
|||
|
|
/* Phase colour pills */
|
|||
|
|
.ph-pill-1{background:rgba(99,102,241,.12);color:#6366f1}
|
|||
|
|
.ph-pill-2{background:rgba(14,165,233,.12);color:#0ea5e9}
|
|||
|
|
.ph-pill-3{background:rgba(16,185,129,.12);color:#10b981}
|
|||
|
|
.ph-pill-4{background:rgba(245,158,11,.12);color:#d97706}
|
|||
|
|
.ph-pill-5{background:rgba(236,72,153,.12);color:#ec4899}
|
|||
|
|
.ph-pill-6{background:rgba(139,92,246,.12);color:#8b5cf6}
|
|||
|
|
|
|||
|
|
/* Month grid lines in track */
|
|||
|
|
.gantt-grid-line{position:absolute;top:0;width:1px;height:100%;background:#f1f5f9;pointer-events:none;z-index:1}
|
|||
|
|
|
|||
|
|
/* ── EXECUTIVE SUMMARY ── */
|
|||
|
|
.exec-hero{background:linear-gradient(135deg,#0f172a 0%,#1e2d5a 60%,#1e1b4b 100%);border-radius:16px;padding:24px 28px;margin-bottom:20px;position:relative;overflow:hidden;color:#fff}
|
|||
|
|
.exec-hero::before{content:'';position:absolute;top:-60px;right:-60px;width:220px;height:220px;border-radius:50%;background:rgba(99,102,241,.1);pointer-events:none}
|
|||
|
|
.exec-hero::after{content:'';position:absolute;bottom:-40px;left:10%;width:160px;height:160px;border-radius:50%;background:rgba(201,162,39,.06);pointer-events:none}
|
|||
|
|
.exec-hero-top{display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:16px;margin-bottom:20px}
|
|||
|
|
.exec-hero-title{font-size:11px;color:rgba(255,255,255,.5);letter-spacing:1.5px;text-transform:uppercase;font-weight:700;margin-bottom:4px}
|
|||
|
|
.exec-hero-name{font-family:'Playfair Display',serif;font-size:22px;font-weight:700;color:#fff;line-height:1.2}
|
|||
|
|
.exec-hero-sub{font-size:12px;color:rgba(255,255,255,.5);margin-top:4px}
|
|||
|
|
.health-ring-wrap{display:flex;flex-direction:column;align-items:center;gap:6px}
|
|||
|
|
.health-score-big{font-family:'Playfair Display',serif;font-size:48px;font-weight:900;line-height:1;letter-spacing:-2px}
|
|||
|
|
.health-score-lbl{font-size:10px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;opacity:.6}
|
|||
|
|
.health-trend{font-size:11px;font-weight:700;margin-top:2px}
|
|||
|
|
.exec-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:4px}
|
|||
|
|
@media(max-width:600px){.exec-kpis{grid-template-columns:repeat(2,1fr)}}
|
|||
|
|
.exec-kpi{background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.1);border-radius:10px;padding:12px 14px;text-align:center}
|
|||
|
|
.exec-kpi-val{font-size:24px;font-weight:900;font-family:'Playfair Display',serif;color:#fff;line-height:1}
|
|||
|
|
.exec-kpi-lbl{font-size:9.5px;color:rgba(255,255,255,.5);font-weight:700;letter-spacing:.5px;text-transform:uppercase;margin-top:4px}
|
|||
|
|
.rag-dot{width:10px;height:10px;border-radius:50%;display:inline-block;margin-right:4px}
|
|||
|
|
.rag-green{background:#22c55e}
|
|||
|
|
.rag-amber{background:#f59e0b}
|
|||
|
|
.rag-red{background:#ef4444}
|
|||
|
|
/* Narrative box */
|
|||
|
|
.narrative-box{background:#fff;border:1px solid var(--g200);border-radius:12px;padding:16px 18px;margin-bottom:16px}
|
|||
|
|
.narrative-box .nb-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
|
|||
|
|
.narrative-box .nb-title{font-size:13px;font-weight:700;color:var(--navy);display:flex;align-items:center;gap:7px}
|
|||
|
|
.narrative-text{font-size:13px;color:var(--g600);line-height:1.7;white-space:pre-wrap;min-height:60px}
|
|||
|
|
/* Decisions Required */
|
|||
|
|
.dec-required-panel{background:#fff;border:1px solid var(--g200);border-radius:12px;overflow:hidden;margin-bottom:16px}
|
|||
|
|
.dec-required-hdr{padding:14px 18px;background:linear-gradient(135deg,#1e3a5f,#1e2d5a);display:flex;align-items:center;justify-content:space-between}
|
|||
|
|
.dec-required-title{font-size:14px;font-weight:800;color:#fff;display:flex;align-items:center;gap:8px}
|
|||
|
|
.dec-item{display:flex;align-items:flex-start;gap:14px;padding:13px 18px;border-bottom:1px solid var(--g100)}
|
|||
|
|
.dec-item:last-child{border-bottom:none}
|
|||
|
|
.dec-item:hover{background:var(--g50)}
|
|||
|
|
.dec-urgency{width:42px;height:42px;border-radius:10px;display:flex;flex-direction:column;align-items:center;justify-content:center;flex-shrink:0;font-size:14px;font-weight:900;line-height:1}
|
|||
|
|
.dec-urgency-lbl{font-size:7px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;margin-top:2px}
|
|||
|
|
.dec-body{flex:1;min-width:0}
|
|||
|
|
.dec-title-text{font-size:13px;font-weight:700;color:var(--navy);margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|||
|
|
.dec-meta{font-size:11px;color:var(--g400);line-height:1.5}
|
|||
|
|
.dec-consequence{font-size:11px;color:#b91c1c;font-weight:600;margin-top:4px;display:flex;align-items:center;gap:4px}
|
|||
|
|
.dec-action-btn{flex-shrink:0;align-self:center}
|
|||
|
|
/* Meetings tab */
|
|||
|
|
.meeting-card{background:#fff;border:1px solid var(--g200);border-radius:12px;margin-bottom:14px;overflow:hidden}
|
|||
|
|
.meeting-card-hdr{padding:14px 18px;display:flex;align-items:center;justify-content:space-between;cursor:pointer;transition:background .15s}
|
|||
|
|
.meeting-card-hdr:hover{background:var(--g50)}
|
|||
|
|
.meeting-type-badge{font-size:10px;font-weight:800;padding:3px 10px;border-radius:20px;letter-spacing:.5px}
|
|||
|
|
.mtb-pmt{background:#dbeafe;color:#1d4ed8}
|
|||
|
|
.mtb-psc{background:#ede9fe;color:#6d28d9}
|
|||
|
|
.meeting-body{padding:0 18px 16px;display:none}
|
|||
|
|
.meeting-body.open{display:block}
|
|||
|
|
.agenda-item{display:flex;align-items:flex-start;gap:10px;padding:8px 0;border-bottom:1px solid var(--g100)}
|
|||
|
|
.agenda-item:last-child{border-bottom:none}
|
|||
|
|
.agenda-num{width:22px;height:22px;border-radius:50%;background:var(--navy);color:#fff;font-size:10px;font-weight:800;display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1px}
|
|||
|
|
.agenda-text{font-size:12.5px;color:var(--g700);flex:1}
|
|||
|
|
.agenda-status{font-size:10px;font-weight:700;padding:2px 7px;border-radius:8px;margin-left:auto;white-space:nowrap;flex-shrink:0}
|
|||
|
|
.as-agreed{background:#dcfce7;color:#15803d}
|
|||
|
|
.as-noted{background:#dbeafe;color:#1d4ed8}
|
|||
|
|
.as-deferred{background:#fef9c3;color:#854d0e}
|
|||
|
|
.as-pending{background:#fee2e2;color:#991b1b}
|
|||
|
|
.meeting-decision-box{background:linear-gradient(135deg,#f0fdf4,#dcfce7);border:1px solid #bbf7d0;border-radius:8px;padding:12px 14px;margin-top:12px}
|
|||
|
|
.meeting-decision-box h5{font-size:11px;font-weight:800;color:#15803d;letter-spacing:.5px;text-transform:uppercase;margin-bottom:5px}
|
|||
|
|
.meeting-decision-box p{font-size:12.5px;color:#166534;line-height:1.6}
|
|||
|
|
/* ── SCHEDULE PERFORMANCE ── */
|
|||
|
|
.spi-wrap{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px}
|
|||
|
|
@media(max-width:600px){.spi-wrap{grid-template-columns:1fr}}
|
|||
|
|
.spi-card{background:#fff;border:1px solid var(--g200);border-radius:14px;padding:18px 20px;position:relative;overflow:hidden}
|
|||
|
|
.spi-card::before{content:'';position:absolute;top:-20px;right:-20px;width:80px;height:80px;border-radius:50%;opacity:.08;pointer-events:none}
|
|||
|
|
.spi-card.blue::before{background:#6366f1}
|
|||
|
|
.spi-card.green::before{background:#10b981}
|
|||
|
|
.spi-card.amber::before{background:#f59e0b}
|
|||
|
|
.spi-card.red::before{background:#f43f5e}
|
|||
|
|
.spi-label{font-size:10px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:var(--g400);margin-bottom:8px}
|
|||
|
|
.spi-value{font-size:36px;font-weight:900;font-family:'Playfair Display',serif;line-height:1;letter-spacing:-1px}
|
|||
|
|
.spi-sub{font-size:12px;color:var(--g500);margin-top:5px;line-height:1.5}
|
|||
|
|
.spi-bar-wrap{margin-top:12px}
|
|||
|
|
.spi-bar-labels{display:flex;justify-content:space-between;font-size:10px;color:var(--g400);font-weight:600;margin-bottom:4px}
|
|||
|
|
.spi-bar-track{height:8px;background:var(--g100);border-radius:4px;position:relative;overflow:visible}
|
|||
|
|
.spi-bar-fill{height:100%;border-radius:4px;transition:width .8s cubic-bezier(.4,0,.2,1)}
|
|||
|
|
.spi-bar-needle{position:absolute;top:-4px;width:3px;height:16px;border-radius:2px;transform:translateX(-50%);z-index:2}
|
|||
|
|
.proj-timeline{background:#fff;border:1px solid var(--g200);border-radius:14px;padding:18px 20px;margin-bottom:16px}
|
|||
|
|
.proj-tl-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;flex-wrap:wrap;gap:8px}
|
|||
|
|
.proj-tl-title{font-size:13px;font-weight:700;color:var(--navy);display:flex;align-items:center;gap:7px}
|
|||
|
|
.proj-tl-track{position:relative;height:12px;background:var(--g100);border-radius:6px;margin:10px 0 6px}
|
|||
|
|
.proj-tl-elapsed{height:100%;border-radius:6px;background:linear-gradient(90deg,#6366f1,#4f46e5);transition:width .8s ease}
|
|||
|
|
.proj-tl-done{position:absolute;top:0;height:100%;border-radius:6px;background:linear-gradient(90deg,#10b981,#059669);opacity:.7}
|
|||
|
|
.proj-tl-needle{position:absolute;top:-6px;width:4px;height:24px;background:#f43f5e;border-radius:2px;transform:translateX(-50%);box-shadow:0 0 6px rgba(244,63,94,.4)}
|
|||
|
|
.proj-tl-needle::before{content:'TODAY';position:absolute;top:-16px;left:50%;transform:translateX(-50%);font-size:7px;font-weight:900;color:#f43f5e;white-space:nowrap;letter-spacing:.5px}
|
|||
|
|
.proj-badges{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
|||
|
|
.proj-badge{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:700;padding:4px 10px;border-radius:8px}
|
|||
|
|
.pb-blue{background:#eef2ff;color:#4f46e5}
|
|||
|
|
.pb-green{background:#f0fdf4;color:#15803d}
|
|||
|
|
.pb-amber{background:#fffbeb;color:#b45309}
|
|||
|
|
.pb-red{background:#fff1f2;color:#be123c}
|
|||
|
|
.variance-box{display:flex;align-items:center;gap:10px;padding:10px 14px;border-radius:10px;margin-top:10px;font-size:12.5px;font-weight:600}
|
|||
|
|
.vb-green{background:#f0fdf4;border:1px solid #bbf7d0;color:#15803d}
|
|||
|
|
.vb-amber{background:#fffbeb;border:1px solid #fde68a;color:#b45309}
|
|||
|
|
.vb-red{background:#fff1f2;border:1px solid #fecdd3;color:#be123c}
|
|||
|
|
|
|||
|
|
/* ── STAKEHOLDER TRACKER ── */
|
|||
|
|
.sh-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-bottom:16px}
|
|||
|
|
.sh-card{background:#fff;border:1px solid var(--g200);border-radius:12px;overflow:hidden;transition:box-shadow .15s,transform .15s}
|
|||
|
|
.sh-card:hover{box-shadow:var(--shm);transform:translateY(-2px)}
|
|||
|
|
.sh-card-top{padding:14px 16px;display:flex;align-items:flex-start;gap:12px}
|
|||
|
|
.sh-avatar{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:17px;font-weight:900;color:#fff;flex-shrink:0}
|
|||
|
|
.sh-name{font-size:13px;font-weight:700;color:var(--navy);line-height:1.2}
|
|||
|
|
.sh-org{font-size:11px;color:var(--g400);margin-top:2px}
|
|||
|
|
.sh-type{font-size:9.5px;font-weight:800;padding:2px 7px;border-radius:10px;display:inline-block;margin-top:4px;letter-spacing:.4px}
|
|||
|
|
.st-internal{background:#dbeafe;color:#1d4ed8}
|
|||
|
|
.st-regulatory{background:#fce7f3;color:#9d174d}
|
|||
|
|
.st-vendor{background:#dcfce7;color:#15803d}
|
|||
|
|
.st-board{background:#ede9fe;color:#6d28d9}
|
|||
|
|
.st-external{background:#fef9c3;color:#854d0e}
|
|||
|
|
.sh-card-bottom{padding:8px 16px 12px;border-top:1px solid var(--g100)}
|
|||
|
|
.sh-engagement-bar{height:5px;background:var(--g100);border-radius:3px;margin:6px 0 4px}
|
|||
|
|
.sh-engagement-fill{height:100%;border-radius:3px;transition:width .6s ease}
|
|||
|
|
.sh-meta{display:flex;justify-content:space-between;align-items:center;font-size:10.5px;color:var(--g400)}
|
|||
|
|
.sh-status-dot{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:4px}
|
|||
|
|
.eng-high{background:#22c55e}
|
|||
|
|
.eng-medium{background:#f59e0b}
|
|||
|
|
.eng-low{background:#ef4444}
|
|||
|
|
.eng-inactive{background:#9ca3af}
|
|||
|
|
.sh-last{font-size:10px;color:var(--g300)}
|
|||
|
|
.sh-filter-bar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center}
|
|||
|
|
.sh-filter-btn{font-size:11px;font-weight:700;padding:4px 12px;border-radius:20px;border:1px solid var(--g200);background:#fff;color:var(--g500);cursor:pointer;transition:all .15s}
|
|||
|
|
.sh-filter-btn.active{background:var(--navy);color:#fff;border-color:var(--navy)}
|
|||
|
|
.sh-summary-row{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px}
|
|||
|
|
.sh-sum-tile{flex:1;min-width:80px;background:#fff;border:1px solid var(--g200);border-radius:10px;padding:10px 12px;text-align:center}
|
|||
|
|
.sh-sum-val{font-size:22px;font-weight:900;font-family:'Playfair Display',serif;color:var(--navy)}
|
|||
|
|
.sh-sum-lbl{font-size:9.5px;font-weight:700;color:var(--g400);letter-spacing:.4px;text-transform:uppercase;margin-top:2px}
|
|||
|
|
/* == EMAIL TOGGLE == */
|
|||
|
|
.email-toggle{display:flex;align-items:center;gap:10px}
|
|||
|
|
.toggle-switch{position:relative;width:38px;height:20px;flex-shrink:0}
|
|||
|
|
.toggle-switch input{opacity:0;width:0;height:0;position:absolute}
|
|||
|
|
.toggle-slider{position:absolute;inset:0;background:var(--g200);border-radius:20px;cursor:pointer;transition:.3s}
|
|||
|
|
.toggle-slider::before{content:'';position:absolute;height:14px;width:14px;left:3px;bottom:3px;background:#fff;border-radius:50%;transition:.3s;box-shadow:0 1px 3px rgba(0,0,0,.2)}
|
|||
|
|
.toggle-switch input:checked+.toggle-slider{background:var(--gold)}
|
|||
|
|
.toggle-switch input:checked+.toggle-slider::before{transform:translateX(18px)}
|
|||
|
|
/* == SETUP BANNER == */
|
|||
|
|
.setup-banner{background:linear-gradient(135deg,#1e3a5f,#2563eb);color:#fff;padding:12px 18px;border-radius:8px;margin-bottom:16px;font-size:13px;display:flex;align-items:flex-start;gap:12px}
|
|||
|
|
.setup-banner i{font-size:20px;flex-shrink:0;margin-top:2px;color:#93c5fd}
|
|||
|
|
</style>
|
|||
|
|
<style>
|
|||
|
|
.od-bar{display:flex;align-items:center;gap:6px;font-size:11px;padding:4px 10px;border-radius:6px;cursor:pointer;transition:background .15s;color:rgba(255,255,255,.7);margin-top:4px}
|
|||
|
|
.od-bar:hover{background:rgba(255,255,255,.1)}
|
|||
|
|
.od-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;transition:background .3s}
|
|||
|
|
.od-dot.connected{background:#22c55e;box-shadow:0 0 0 3px rgba(34,197,94,.2);animation:pulse-green 2s infinite}
|
|||
|
|
.od-dot.syncing{background:#f59e0b;animation:pulse-amber 1s infinite}
|
|||
|
|
.od-dot.disconnected{background:#6b7280}
|
|||
|
|
.od-dot.error{background:#ef4444}
|
|||
|
|
@keyframes pulse-amber{0%,100%{box-shadow:0 0 0 2px rgba(245,158,11,.2)}50%{box-shadow:0 0 0 5px rgba(245,158,11,.05)}}
|
|||
|
|
.od-badge{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:700;padding:2px 8px;border-radius:20px;letter-spacing:.4px}
|
|||
|
|
.od-badge.on{background:#dcfce7;color:#15803d}
|
|||
|
|
.od-badge.off{background:#f3f4f6;color:#6b7280}
|
|||
|
|
.od-setup-step{background:#f8faff;border:1px solid #dbeafe;border-radius:8px;padding:14px;margin-bottom:12px}
|
|||
|
|
.od-setup-step h4{font-size:12px;font-weight:700;color:#1e40af;margin:0 0 6px;display:flex;align-items:center;gap:6px}
|
|||
|
|
.od-setup-step p{font-size:11.5px;color:#475569;margin:0;line-height:1.6}
|
|||
|
|
.od-setup-step code{background:#e0e7ff;color:#3730a3;padding:1px 5px;border-radius:3px;font-size:11px}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div class="login-screen" id="loginScreen">
|
|||
|
|
<div id="jbQuickBanner" style="display:none;position:fixed;top:0;left:0;right:0;background:linear-gradient(135deg,#7c3aed,#6d28d9);color:#fff;padding:10px 20px;font-size:13px;z-index:9999;display:flex;align-items:center;justify-content:center;gap:12px">
|
|||
|
|
<i class="fas fa-circle-exclamation" style="color:#fde68a"></i>
|
|||
|
|
<span>JSONBin not configured — changes will only save locally. <b style="cursor:pointer;text-decoration:underline" onclick="document.getElementById('jbQuickBanner').style.display='none';document.getElementById('loginScreen').classList.add('hidden');document.getElementById('appShell').classList.remove('hidden');openSyncSetup()">Click here to connect</b></span>
|
|||
|
|
<button onclick="document.getElementById('jbQuickBanner').style.display='none'" style="background:none;border:none;color:rgba(255,255,255,.7);cursor:pointer;font-size:16px;margin-left:8px">✕</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="login-box">
|
|||
|
|
<div class="login-hdr">
|
|||
|
|
<div class="login-logo"><i class="fas fa-crown"></i></div>
|
|||
|
|
<div style="font-family:'Playfair Display',serif;font-size:20px;color:#fff;font-weight:700">Pinnacle</div>
|
|||
|
|
<div style="font-size:10px;color:var(--gold);letter-spacing:2px;text-transform:uppercase;margin-top:3px">Decision Portal</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="login-body">
|
|||
|
|
<div id="loginErr" class="login-err hidden"></div>
|
|||
|
|
<label>Username</label>
|
|||
|
|
<input type="text" id="loginUser" placeholder="Enter username">
|
|||
|
|
<label>Password</label>
|
|||
|
|
<input type="password" id="loginPass" placeholder="Enter password">
|
|||
|
|
<button class="login-btn" id="loginBtn">Sign In <i class="fas fa-arrow-right"></i></button>
|
|||
|
|
<div style="text-align:center;font-size:11px;color:var(--g400);margin-top:14px">Default: <strong>admin</strong> / <strong>Admin</strong></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div id="appShell" class="hidden">
|
|||
|
|
<aside class="sidebar" id="sidebar">
|
|||
|
|
<div class="sb-brand">
|
|||
|
|
<div class="sb-icon"><i class="fas fa-crown"></i></div>
|
|||
|
|
<div><div class="sb-title">Pinnacle</div><div class="sb-sub">Decision Portal</div></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="nav-sec">
|
|||
|
|
<div class="nav-lbl">Navigation</div>
|
|||
|
|
<div class="nav-item" id="nav-dashboard" onclick="showPage("dashboard")"><i class="fas fa-chart-pie nav-icon"></i>Project Dashboard</div>
|
|||
|
|
<div class="nav-item" id="nav-phases" onclick="showPage("phases")"><i class="fas fa-layer-group nav-icon"></i>Phases & Milestones</div>
|
|||
|
|
<div class="nav-item" id="nav-decisions" onclick="showPage("decisions")"><i class="fas fa-gavel nav-icon"></i>Decisions</div>
|
|||
|
|
<div class="nav-item" id="nav-committees" onclick="showPage("committees")"><i class="fas fa-people-roof nav-icon"></i>PMT & PSC</div>
|
|||
|
|
<div class="nav-item" id="nav-meetings" onclick="showPage("meetings")"><i class="fas fa-calendar-check nav-icon"></i>Meetings</div>
|
|||
|
|
<div class="nav-item" id="nav-stakeholders" onclick="showPage("stakeholders")"><i class="fas fa-handshake nav-icon"></i>Stakeholders</div>
|
|||
|
|
<div class="nav-item" id="nav-risks" onclick="showPage("risks")"><i class="fas fa-shield-halved nav-icon"></i>Risk Register</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="nav-sec" id="adminNav" style="margin-top:6px">
|
|||
|
|
<div class="nav-lbl">Administration</div>
|
|||
|
|
<div class="nav-item" id="nav-users" onclick="showPage("users")"><i class="fas fa-users-gear nav-icon"></i>User Management</div>
|
|||
|
|
<div class="nav-item" id="nav-logs" onclick="showPage("logs")"><i class="fas fa-file-lines nav-icon"></i>Activity Logs</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="sb-foot">
|
|||
|
|
<div class="user-card">
|
|||
|
|
<div class="user-av" id="userAv">A</div>
|
|||
|
|
<div style="flex:1;min-width:0"><div class="user-name" id="userName">Admin</div><div class="user-role" id="userRole">admin</div></div>
|
|||
|
|
<span onclick="doLogout()" style="color:rgba(255,255,255,.4);cursor:pointer;font-size:13px;margin-left:auto;padding:4px" title="Logout"><i class="fas fa-arrow-right-from-bracket"></i></span>
|
|||
|
|
</div>
|
|||
|
|
<div class="sync-bar" id="syncStatusBar" onclick="openSyncSetup()" title="Click to configure live sync">
|
|||
|
|
<span class="sync-dot local" id="emailDot"></span> <span id="emailSyncLbl">Email: Off</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="jb-status-bar" id="jbStatusBar" onclick="openSyncSetup()" title="JSONBin Sync — click to configure">
|
|||
|
|
<span class="jb-dot off"></span><span class="jb-lbl">Sync: Off</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="od-bar" id="odSideBar" onclick="openSyncSetup()">
|
|||
|
|
<span class="od-dot disconnected"></span><span class="od-txt">Sync: Off</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</aside>
|
|||
|
|
<div class="main-wrap">
|
|||
|
|
<header class="topbar">
|
|||
|
|
<div class="flex ic gap-2">
|
|||
|
|
<button class="tb-btn" id="mobBtn" onclick="document.getElementById('sidebar').classList.toggle('open')" style="display:none"><i class="fas fa-bars"></i></button>
|
|||
|
|
<h1 class="pg-title" id="pageTitle">Dashboard</h1>
|
|||
|
|
</div>
|
|||
|
|
<div class="tb-right">
|
|||
|
|
<button class="tb-btn" onclick="openSyncSetup()" title="Live Sync & Email Setup" id="cloudBtn"><i class="fas fa-cloud-arrow-up"></i></button>
|
|||
|
|
<button class="tb-btn" onclick="openIE()" title="Import / Export Data"><i class="fas fa-exchange-alt"></i></button>
|
|||
|
|
<div style="position:relative">
|
|||
|
|
<button class="tb-btn" onclick="toggleND()" id="ndBtn"><i class="fas fa-bell"></i><span class="ndot" id="ndCount" style="display:none">0</span></button>
|
|||
|
|
<div class="nd" id="ndDrop">
|
|||
|
|
<div class="nd-hdr"><strong style="font-size:12.5px">Notifications</strong><button onclick="markAllRead()" class="btn btn-xs btn-outline">Mark all read</button></div>
|
|||
|
|
<div id="ndList"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<button class="tb-btn" onclick="showPage("profile")" title="Profile"><i class="fas fa-user"></i></button>
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
<main class="content" id="mainContent"></main>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<script>
|
|||
|
|
var DB=null,CU=null,pc=null,sc=null;
|
|||
|
|
|
|||
|
|
/* ═══════════════════════════════════════════════════
|
|||
|
|
JSONBIN.IO SYNC ENGINE
|
|||
|
|
Free shared database — all users read/write the
|
|||
|
|
same JSON bin. Changes visible to everyone instantly.
|
|||
|
|
Setup: create free account at jsonbin.io, create a
|
|||
|
|
bin, paste the Bin ID and API key in Settings.
|
|||
|
|
═══════════════════════════════════════════════════ */
|
|||
|
|
var _JB = {
|
|||
|
|
enabled: false,
|
|||
|
|
binId: '',
|
|||
|
|
apiKey: '',
|
|||
|
|
pollTimer: null,
|
|||
|
|
lastVer: 0,
|
|||
|
|
syncing: false,
|
|||
|
|
BASE: 'https://api.jsonbin.io/v3'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function jbSetStatus(state, text){
|
|||
|
|
var bar = document.getElementById('jbStatusBar');
|
|||
|
|
if(!bar) return;
|
|||
|
|
var dot = bar.querySelector('.jb-dot');
|
|||
|
|
var lbl = bar.querySelector('.jb-lbl');
|
|||
|
|
if(dot) dot.className = 'jb-dot ' + state;
|
|||
|
|
if(lbl) lbl.textContent = text || (state==='live'?'Synced':state==='syncing'?'Syncing...':state==='error'?'Sync Error':'Sync: Off');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function jbSave(data){
|
|||
|
|
if(!_JB.binId || !_JB.apiKey) return;
|
|||
|
|
if(_JB.syncing) return;
|
|||
|
|
_JB.syncing = true;
|
|||
|
|
jbSetStatus('syncing','Saving...');
|
|||
|
|
try{
|
|||
|
|
// Ensure we never send null/empty — JSONBin rejects blank bodies
|
|||
|
|
var payload = data || DB || {};
|
|||
|
|
var body = JSON.stringify(payload);
|
|||
|
|
if(!body || body === 'null' || body === '{}') throw new Error('No data to save');
|
|||
|
|
var r = await fetch(_JB.BASE+'/b/'+_JB.binId, {
|
|||
|
|
method: 'PUT',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
'X-Master-Key': _JB.apiKey,
|
|||
|
|
'X-Bin-Versioning': 'false'
|
|||
|
|
},
|
|||
|
|
body: body
|
|||
|
|
});
|
|||
|
|
if(r.status===401) throw new Error('Invalid API key — check your Master Key');
|
|||
|
|
if(r.status===404) throw new Error('Bin not found — check your Bin ID');
|
|||
|
|
if(!r.ok){
|
|||
|
|
var errTxt = await r.text().catch(function(){return '';});
|
|||
|
|
throw new Error('HTTP '+r.status+(errTxt?' — '+errTxt.slice(0,80):''));
|
|||
|
|
}
|
|||
|
|
var j = await r.json();
|
|||
|
|
_JB.lastVer = j.metadata ? j.metadata.version : _JB.lastVer;
|
|||
|
|
_JB.enabled = true;
|
|||
|
|
jbSetStatus('live','Synced');
|
|||
|
|
showSyncToast('Saved — all users updated');
|
|||
|
|
} catch(e){
|
|||
|
|
console.warn('JSONBin save error:',e);
|
|||
|
|
jbSetStatus('error','Save failed');
|
|||
|
|
flash('Sync failed: '+e.message+'. Check your Bin ID and Master Key in the sync settings.','danger');
|
|||
|
|
}
|
|||
|
|
_JB.syncing = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function jbLoad(){
|
|||
|
|
if(!_JB.binId || !_JB.apiKey) return null;
|
|||
|
|
try{
|
|||
|
|
var r = await fetch(_JB.BASE+'/b/'+_JB.binId+'/latest', {
|
|||
|
|
headers:{'X-Master-Key': _JB.apiKey}
|
|||
|
|
});
|
|||
|
|
if(r.status===404) return null;
|
|||
|
|
if(!r.ok) throw new Error('HTTP '+r.status);
|
|||
|
|
var j = await r.json();
|
|||
|
|
_JB.lastVer = j.metadata ? j.metadata.version : 0;
|
|||
|
|
return j.record || null;
|
|||
|
|
} catch(e){
|
|||
|
|
console.warn('JSONBin load error:',e);
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function jbStartPolling(){
|
|||
|
|
if(_JB.pollTimer) clearInterval(_JB.pollTimer);
|
|||
|
|
_JB.pollTimer = setInterval(async function(){
|
|||
|
|
if(!_JB.enabled || _JB.syncing) return;
|
|||
|
|
try{
|
|||
|
|
var r = await fetch(_JB.BASE+'/b/'+_JB.binId+'/latest', {
|
|||
|
|
headers:{'X-Master-Key':_JB.apiKey}
|
|||
|
|
});
|
|||
|
|
if(!r.ok) return;
|
|||
|
|
var j = await r.json();
|
|||
|
|
var ver = j.metadata ? j.metadata.version : 0;
|
|||
|
|
if(ver > _JB.lastVer){
|
|||
|
|
_JB.lastVer = ver;
|
|||
|
|
var remote = j.record;
|
|||
|
|
if(!remote) return;
|
|||
|
|
DB = remote;
|
|||
|
|
if(!DB.ids) DB.ids={user:2,phase:5,milestone:30,activity:100,deliverable:1,decision:1,risk:1,comment:1,log:1,notification:1,meeting:1,stakeholder:100};
|
|||
|
|
localStorage.setItem(SK, JSON.stringify(DB));
|
|||
|
|
if(CU){
|
|||
|
|
var pg = document.querySelector('.nav-item.active');
|
|||
|
|
showPage(pg ? pg.id.replace('nav-','') : 'dashboard');
|
|||
|
|
renderNC();
|
|||
|
|
showSyncToast('Dashboard updated by another user');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch(e){}
|
|||
|
|
}, 10000); // poll every 10 seconds
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function jbStopPolling(){
|
|||
|
|
if(_JB.pollTimer){ clearInterval(_JB.pollTimer); _JB.pollTimer=null; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function jbConnect(binId, apiKey){
|
|||
|
|
if(!binId || !apiKey){flash('Please enter both the Bin ID and Master Key.','danger');return;}
|
|||
|
|
_JB.binId = binId.trim();
|
|||
|
|
_JB.apiKey = apiKey.trim();
|
|||
|
|
_JB.enabled = false;
|
|||
|
|
jbSetStatus('syncing','Connecting...');
|
|||
|
|
try{
|
|||
|
|
// Try to read existing bin data
|
|||
|
|
var readR = await fetch(_JB.BASE+'/b/'+_JB.binId+'/latest', {
|
|||
|
|
headers:{'X-Master-Key': _JB.apiKey, 'X-Access-Key': _JB.apiKey}
|
|||
|
|
});
|
|||
|
|
if(readR.status===401||readR.status===403) throw new Error('Invalid Master Key. Copy the X-MASTER-KEY from JSONBin Account page.');
|
|||
|
|
if(readR.status===404) throw new Error('Bin not found. Double-check your Bin ID.');
|
|||
|
|
|
|||
|
|
var j = await readR.json();
|
|||
|
|
var remote = j.record || j;
|
|||
|
|
|
|||
|
|
if(remote && remote.users && Array.isArray(remote.users) && remote.users.length>0){
|
|||
|
|
// Remote has data — use it
|
|||
|
|
DB = remote;
|
|||
|
|
if(!DB.ids)DB.ids={user:2,phase:5,milestone:30,activity:100,deliverable:1,decision:1,risk:1,comment:1,log:1,notification:1,meeting:1,stakeholder:100};
|
|||
|
|
localStorage.setItem(SK,JSON.stringify(DB));
|
|||
|
|
_JB.lastVer = j.metadata?j.metadata.version:0;
|
|||
|
|
} else {
|
|||
|
|
// Bin is empty or has no real data — push our local DB
|
|||
|
|
if(!DB||!DB.users){loadDB();}
|
|||
|
|
var pushR = await fetch(_JB.BASE+'/b/'+_JB.binId,{
|
|||
|
|
method:'PUT',
|
|||
|
|
headers:{'Content-Type':'application/json','X-Master-Key':_JB.apiKey,'X-Bin-Versioning':'false'},
|
|||
|
|
body: JSON.stringify(DB)
|
|||
|
|
});
|
|||
|
|
if(!pushR.ok){
|
|||
|
|
var et = await pushR.text().catch(function(){return '';});
|
|||
|
|
throw new Error('Write failed: HTTP '+pushR.status+' — '+et.slice(0,80));
|
|||
|
|
}
|
|||
|
|
var pj = await pushR.json();
|
|||
|
|
_JB.lastVer = pj.metadata?pj.metadata.version:0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Save config and activate
|
|||
|
|
_JB.enabled = true;
|
|||
|
|
DB._jbBinId = _JB.binId;
|
|||
|
|
DB._jbApiKey = _JB.apiKey;
|
|||
|
|
localStorage.setItem(SK,JSON.stringify(DB));
|
|||
|
|
jbSetStatus('live','Synced');
|
|||
|
|
jbStartPolling();
|
|||
|
|
updateSyncUI();
|
|||
|
|
var m=document.getElementById('syncModal');if(m)m.remove();
|
|||
|
|
flash('<i class="fas fa-check-circle"></i> Connected! Changes now sync to all users every 10 seconds.','success');
|
|||
|
|
var pg=document.querySelector('.nav-item.active');
|
|||
|
|
showPage(pg?pg.id.replace('nav-',''):'dashboard');
|
|||
|
|
} catch(e){
|
|||
|
|
_JB.enabled=false;_JB.binId='';_JB.apiKey='';
|
|||
|
|
jbSetStatus('error','Failed');
|
|||
|
|
flash('Connection failed: '+e.message,'danger');
|
|||
|
|
var btn=document.querySelector('#syncModal .btn-primary');
|
|||
|
|
if(btn){btn.disabled=false;btn.innerHTML='<i class="fas fa-plug"></i> Connect JSONBin';}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
async function jbLoadOnLogin(){
|
|||
|
|
if(!_JB.enabled || !_JB.binId) return;
|
|||
|
|
jbSetStatus('syncing','Loading latest...');
|
|||
|
|
try{
|
|||
|
|
var remote = await jbLoad();
|
|||
|
|
if(remote && Object.keys(remote).length > 0){
|
|||
|
|
DB = remote;
|
|||
|
|
if(!DB.ids) DB.ids={user:2,phase:5,milestone:30,activity:100,deliverable:1,decision:1,risk:1,comment:1,log:1,notification:1,meeting:1,stakeholder:100};
|
|||
|
|
localStorage.setItem(SK, JSON.stringify(DB));
|
|||
|
|
}
|
|||
|
|
jbSetStatus('live','Synced');
|
|||
|
|
} catch(e){
|
|||
|
|
jbSetStatus('error','Load failed');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
/* =========================================================
|
|||
|
|
SYNC ENGINE — localStorage + optional manual export/import
|
|||
|
|
Firebase removed. Use Netlify hosting + shared URL for
|
|||
|
|
multi-user access. Data persists in each user's browser.
|
|||
|
|
For true multi-user sync, use the Export/Import feature.
|
|||
|
|
========================================================= */
|
|||
|
|
var _FB = {enabled:false,ref:null}; // stub so existing refs don't break
|
|||
|
|
|
|||
|
|
function fbSave(data){ localStorage.setItem(SK,JSON.stringify(data)); }
|
|||
|
|
function fbConnect(){ flash('Live sync via Firebase has been removed. Share the portal URL on Netlify for multi-user access.','danger'); }
|
|||
|
|
function fbDisconnect(){}
|
|||
|
|
function fbStartListening(){}
|
|||
|
|
async function fbLoadOnce(){ return null; }
|
|||
|
|
async function odLoadOnLogin(){}
|
|||
|
|
function odSave(data){ fbSave(data); }
|
|||
|
|
function odStartPolling(){}
|
|||
|
|
function spSave(data){ fbSave(data); }
|
|||
|
|
function spStartPolling(){}
|
|||
|
|
|
|||
|
|
var SK='pinnacle_v3';
|
|||
|
|
var PC=['#1a3a5c','#c9a227','#2e6da4','#8b6914','#15803d','#7c3aed'];
|
|||
|
|
|
|||
|
|
function defaultDB(){
|
|||
|
|
var n=new Date().toISOString();
|
|||
|
|
var phases=[
|
|||
|
|
{id:1,num:1,name:'Market Intelligence & Insights / Diagnostic & Discovery',desc:'Initial research, stakeholder surveys, and market diagnostic activities.',order:1},
|
|||
|
|
{id:2,num:2,name:'Dialogue & Expert Engagement',desc:'Stakeholder validation, internal performance analysis, and comparative assessment.',order:2},
|
|||
|
|
{id:3,num:3,name:'Strategy Formulation & Quantification',desc:'Co-creation of strategic vision, frameworks, and business plan development.',order:3},
|
|||
|
|
{id:4,num:4,name:'Strategy Finalization & Issuance',desc:'Management presentation, revisions, BOD approval, and final closeout.',order:4}
|
|||
|
|
];
|
|||
|
|
var raw=[
|
|||
|
|
[1,1,'Project Kick-off','1',null,['Kick-off Meeting','Project Plan']],
|
|||
|
|
[1,2,'Quantitative & Qualitative Research Plan','1','2026-06-01',['Inception Report','Communication Strategy and Survey Roll-out Plan']],
|
|||
|
|
[1,3,'Desktop Research','1-2',null,['Desktop Research Execution']],
|
|||
|
|
[1,4,'Drafting of Stakeholder Surveys','1',null,['Draft Stakeholder Surveys']],
|
|||
|
|
[1,5,'Conduct Interviews','2',null,['Conduct Stakeholder Interviews']],
|
|||
|
|
[1,6,'Prepare Draft Market Diagnostic Report','3',null,['Market Diagnostic Paper','PESTEL & Porter Five Forces Analysis']],
|
|||
|
|
[2,7,'Validate Diagnostic Findings','3',null,['Completion of Research','Common Understanding Among Stakeholders','Stakeholder Interviews and Workshops Outputs']],
|
|||
|
|
[2,8,'Internal Performance Analysis','4',null,['Internal Performance Review']],
|
|||
|
|
[2,9,'SWOT Analysis','5',null,['SWOT Analysis Report']],
|
|||
|
|
[2,10,'Comparative Analysis','5',null,['Competitive Analysis Report']],
|
|||
|
|
[3,11,'Co-Create Strategic Vision','6',null,['Draft Strategic Vision']],
|
|||
|
|
[3,12,'Design Operational Risk Framework, Branding & Development Strategies','6',null,['Risk and Compliance Framework','Branding Strategy','Training and Skills Development Plan']],
|
|||
|
|
[3,13,'Develop Business Plan with Financial Projections','7','2026-12-15',['Draft Business Plan','Financial Model Based on Assumptions','Draft Implementation Roadmap']],
|
|||
|
|
[4,14,'Present Strategic Business Plan to Management','7',null,['Presentation to the Management']],
|
|||
|
|
[4,15,'Incorporate Changes as per Management Feedback','9',null,['Validated Strategic Business Plan Incorporating Management Feedback']],
|
|||
|
|
[4,16,'Present Updated Strategic Business Plan to BOD & Closeout','9','2027-01-15',['Final Business Plan Along with Financial Projection','Presentation for BOD']]
|
|||
|
|
];
|
|||
|
|
var mid=1,aid=1,milestones=[],activities=[];
|
|||
|
|
for(var i=0;i<raw.length;i++){
|
|||
|
|
var r=raw[i];
|
|||
|
|
var isKO=(r[2]==='Project Kick-off');
|
|||
|
|
milestones.push({id:mid,phaseId:r[0],serial:r[1],title:r[2],month:r[3],tentDate:r[4],startDate:null,endDate:r[4],status:isKO?'completed':'not_started',priority:'medium',owner:'',notes:'',desc:'',createdAt:n});
|
|||
|
|
for(var j=0;j<r[5].length;j++){
|
|||
|
|
activities.push({id:aid++,milestoneId:mid,title:r[5][j],month:r[3],tentDate:r[4],startDate:null,endDate:r[4],status:isKO?'completed':'not_started',progress:isKO?100:0,assignedTo:'',notes:'',desc:'',createdAt:n});
|
|||
|
|
}
|
|||
|
|
mid++;
|
|||
|
|
}
|
|||
|
|
return{
|
|||
|
|
users:[{id:1,username:'admin',password:'Admin',role:'admin',fullName:'System Administrator',email:'admin@pinnacle.local',dept:'Administration',active:true,lastLogin:null}],
|
|||
|
|
projectStart:'2026-01-01',
|
|||
|
|
projectDuration:12,
|
|||
|
|
phases:phases,milestones:milestones,activities:activities,
|
|||
|
|
committees:{
|
|||
|
|
pmt:{
|
|||
|
|
name:'Project Management Team',
|
|||
|
|
abbr:'PMT',
|
|||
|
|
role:'Responsible for day-to-day project management, activity execution, milestone tracking and progress reporting.',
|
|||
|
|
chair:'Project Manager',
|
|||
|
|
members:[],
|
|||
|
|
meetingCadence:'Weekly',
|
|||
|
|
responsibilities:['Activity oversight','Milestone review & endorsement','Risk monitoring & escalation','Progress reporting to PSC','Resource management']
|
|||
|
|
},
|
|||
|
|
psc:{
|
|||
|
|
name:'Project Steering Committee',
|
|||
|
|
abbr:'PSC',
|
|||
|
|
role:'Responsible for strategic decisions, formal approvals of milestones and deliverables, budget authority and project direction.',
|
|||
|
|
chair:'Executive Sponsor',
|
|||
|
|
members:[],
|
|||
|
|
meetingCadence:'Monthly',
|
|||
|
|
responsibilities:['Milestone approval','Deliverable sign-off','Budget approval','Strategic decisions','Issue escalation resolution']
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
meetings:[],
|
|||
|
|
projectNarrative:{thisWeek:'',nextWeek:'',keyMessage:'',lastUpdated:'',updatedBy:''},
|
|||
|
|
projectDuration:12,
|
|||
|
|
stakeholders:[
|
|||
|
|
{id:1,name:'Board of Directors',org:'Bank Board',type:'board',engagementLevel:85,status:'active',role:'Ultimate authority & approval',linkedPhases:[1,2,3,4,5],lastContact:'',nextContact:'',notes:'Quarterly briefings required',owner:'CEO'},
|
|||
|
|
{id:2,name:'Project Steering Committee',org:'Internal',type:'internal',engagementLevel:90,status:'active',role:'Strategic decisions & milestone approval',linkedPhases:[1,2,3,4,5],lastContact:'',nextContact:'',notes:'Monthly meetings',owner:'Project Sponsor'},
|
|||
|
|
{id:3,name:'Central Bank / Regulator',org:'Regulatory Body',type:'regulatory',engagementLevel:60,status:'active',role:'Regulatory compliance & approval',linkedPhases:[2,3,4],lastContact:'',nextContact:'',notes:'Regulatory sign-off required before Phase 3 go-live',owner:'Compliance Officer'},
|
|||
|
|
{id:4,name:'Core Banking Vendor',org:'Technology Partner',type:'vendor',engagementLevel:75,status:'active',role:'System delivery & integration',linkedPhases:[3,4],lastContact:'',nextContact:'',notes:'Weekly technical meetings with PMT',owner:'IT Director'},
|
|||
|
|
{id:5,name:'Internal Audit',org:'Bank Internal',type:'internal',engagementLevel:55,status:'monitoring',role:'Audit oversight & control assessment',linkedPhases:[1,2,3,4,5],lastContact:'',nextContact:'',notes:'Quarterly audit reviews',owner:'Chief Audit Officer'},
|
|||
|
|
{id:6,name:'Business Units',org:'Bank Operations',type:'internal',engagementLevel:70,status:'active',role:'Requirements & UAT participation',linkedPhases:[2,3,4],lastContact:'',nextContact:'',notes:'User acceptance testing leads',owner:'COO'}
|
|||
|
|
],
|
|||
|
|
deliverables:[],decisions:[],risks:[],comments:[],logs:[],notifications:[],
|
|||
|
|
ids:{user:2,phase:5,milestone:mid,activity:aid,deliverable:1,decision:1,risk:1,comment:1,log:1,notification:1}
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? SYNC / STORAGE LAYER ?? */
|
|||
|
|
var _syncMode='local'; // legacy stub
|
|||
|
|
var _fbUnsubscribe=null;
|
|||
|
|
var _lastRemoteHash='';
|
|||
|
|
var _syncPaused=false;
|
|||
|
|
var _emailSettings={}; // userId -> {enabled, email}
|
|||
|
|
|
|||
|
|
function dbHash(data){
|
|||
|
|
try{var s=JSON.stringify(data);var h=0;for(var i=0;i<Math.min(s.length,500);i++){h=(h*31+s.charCodeAt(i))&0xffffffff;}return String(h);}catch(e){return '';}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function loadDB(){
|
|||
|
|
try{DB=JSON.parse(localStorage.getItem(SK));}catch(e){DB=null;}
|
|||
|
|
if(!DB)DB=defaultDB();
|
|||
|
|
try{_emailSettings=JSON.parse(localStorage.getItem(SK+'_email'))||{};}catch(e){_emailSettings={};}
|
|||
|
|
// Auto-restore JSONBin config if previously saved
|
|||
|
|
if(DB._jbBinId && DB._jbApiKey){
|
|||
|
|
_JB.binId = DB._jbBinId;
|
|||
|
|
_JB.apiKey = DB._jbApiKey;
|
|||
|
|
_JB.enabled = true;
|
|||
|
|
jbSetStatus('live','Synced');
|
|||
|
|
jbStartPolling();
|
|||
|
|
}
|
|||
|
|
if(DB._jbBinId && DB._jbApiKey){
|
|||
|
|
_JB.binId=DB._jbBinId; _JB.apiKey=DB._jbApiKey; _JB.enabled=true;
|
|||
|
|
jbSetStatus('live','Synced'); jbStartPolling();
|
|||
|
|
}
|
|||
|
|
updateSyncUI();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveDB(triggerEmailNotif, changeDesc){
|
|||
|
|
localStorage.setItem(SK,JSON.stringify(DB));
|
|||
|
|
// Push to JSONBin if connected
|
|||
|
|
if(_JB.enabled && _JB.binId){ jbSave(DB); }
|
|||
|
|
updateSyncUI();
|
|||
|
|
if(triggerEmailNotif&&changeDesc&&CU){
|
|||
|
|
broadcastEmailNotification(changeDesc);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveEmailSettings(){
|
|||
|
|
localStorage.setItem(SK+'_email', JSON.stringify(_emailSettings));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getUserEmailPref(uid){
|
|||
|
|
return _emailSettings[uid]||{enabled:false,email:''};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setUserEmailPref(uid,enabled,email){
|
|||
|
|
_emailSettings[uid]={enabled:enabled,email:email||''};
|
|||
|
|
saveEmailSettings();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function initFirebaseSync(){
|
|||
|
|
if(!window._fbReady||!window._fbOnValue){
|
|||
|
|
flash('Firebase not configured. Using local storage only. See Setup Guide in Settings.','danger');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
_syncMode='firebase';
|
|||
|
|
// Listen for remote changes
|
|||
|
|
_fbUnsubscribe=window._fbOnValue(function(snapshot){
|
|||
|
|
if(_syncPaused)return;
|
|||
|
|
var remoteData=snapshot.val();
|
|||
|
|
if(!remoteData)return;
|
|||
|
|
var h=dbHash(remoteData);
|
|||
|
|
if(h===_lastRemoteHash)return; // no change
|
|||
|
|
_lastRemoteHash=h;
|
|||
|
|
// Don't overwrite if we just pushed
|
|||
|
|
var remoteStr=JSON.stringify(remoteData);
|
|||
|
|
var localStr=JSON.stringify(DB);
|
|||
|
|
if(remoteStr===localStr)return;
|
|||
|
|
DB=remoteData;
|
|||
|
|
if(!DB.ids)DB.ids={user:2,phase:5,milestone:30,activity:100,deliverable:1,decision:1,risk:1,comment:1,log:1,notification:1};
|
|||
|
|
localStorage.setItem(SK,JSON.stringify(DB));
|
|||
|
|
updateSyncUI();
|
|||
|
|
// Re-render current page if app is open
|
|||
|
|
if(CU){
|
|||
|
|
var activeNav=document.querySelector('.nav-item.active');
|
|||
|
|
var page=activeNav?activeNav.id.replace('nav-',''):'dashboard';
|
|||
|
|
if(page)showPage(page);
|
|||
|
|
renderNC();
|
|||
|
|
showSyncToast('Dashboard updated by another user');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
updateSyncUI();
|
|||
|
|
flash('<i class="fas fa-wifi"></i> Live sync enabled — changes sync instantly across all users.','success');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function disconnectFirebase(){
|
|||
|
|
if(_fbUnsubscribe){try{_fbUnsubscribe();}catch(e){}}
|
|||
|
|
_fbUnsubscribe=null;
|
|||
|
|
_syncMode='local';
|
|||
|
|
updateSyncUI();
|
|||
|
|
flash('Switched to local storage.','danger');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* updateSyncUI defined above */
|
|||
|
|
|
|||
|
|
/* showSyncToast defined above */
|
|||
|
|
|
|||
|
|
/* ?? EMAIL NOTIFICATIONS (EmailJS) ?? */
|
|||
|
|
var EMAILJS_SERVICE='YOUR_SERVICE_ID';
|
|||
|
|
var EMAILJS_TEMPLATE='YOUR_TEMPLATE_ID';
|
|||
|
|
|
|||
|
|
function broadcastEmailNotification(changeDescription){
|
|||
|
|
var svc=DB._ejSvc||''; var tpl=DB._ejTpl||''; var pk=DB._ejPk||'';
|
|||
|
|
if(!pk||!svc||!tpl){console.warn('EmailJS not configured');return;}
|
|||
|
|
if(!window.emailjs){console.warn('EmailJS library not loaded');return;}
|
|||
|
|
try{emailjs.init({publicKey:pk});}catch(e){}
|
|||
|
|
var recipients=[];
|
|||
|
|
(DB.users||[]).forEach(function(u){
|
|||
|
|
if(u.id===CU.id)return;
|
|||
|
|
var pref=getUserEmailPref(u.id);
|
|||
|
|
var emailAddr=(pref.email||u.email||'').trim();
|
|||
|
|
if(pref.enabled&&emailAddr&&emailAddr.indexOf('@')>0){
|
|||
|
|
recipients.push({name:u.fullName||u.username,email:emailAddr});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
if(!recipients.length){console.log('No email recipients with notifications enabled');return;}
|
|||
|
|
var sent=0;
|
|||
|
|
recipients.forEach(function(r){
|
|||
|
|
emailjs.send(svc, tpl, {
|
|||
|
|
to_name: r.name,
|
|||
|
|
to_email: r.email,
|
|||
|
|
from_name: 'Pinnacle Decision Portal',
|
|||
|
|
reply_to: CU.email||'',
|
|||
|
|
changed_by: CU.fullName||CU.username,
|
|||
|
|
change_description: changeDescription,
|
|||
|
|
portal_url: window.location.href,
|
|||
|
|
timestamp: new Date().toLocaleString()
|
|||
|
|
}).then(function(){
|
|||
|
|
sent++;
|
|||
|
|
if(sent===1) flash('<i class="fas fa-envelope-circle-check"></i> Email notification sent to '+recipients.length+' user'+(recipients.length>1?'s':'')+'.');
|
|||
|
|
}).catch(function(e){
|
|||
|
|
console.warn('EmailJS send error:',e);
|
|||
|
|
flash('Email failed: '+(e.text||e.message||JSON.stringify(e)),'danger');
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? SYNC SETUP MODAL ?? */
|
|||
|
|
function openSyncSetup(){
|
|||
|
|
var old=document.getElementById('syncModal'); if(old)old.remove();
|
|||
|
|
var jbOn=_JB.enabled&&_JB.binId;
|
|||
|
|
var overlay=document.createElement('div');
|
|||
|
|
overlay.className='overlay open';
|
|||
|
|
overlay.id='syncModal';
|
|||
|
|
overlay.innerHTML='<div class="modal" style="max-width:600px;max-height:88vh;overflow-y:auto">'
|
|||
|
|
+'<div class="modal-hdr"><span class="modal-title"><i class="fas fa-cloud-arrow-up"></i> Sync & Notifications</span>'
|
|||
|
|
|
|||
|
|
+'<div class="modal-body">'
|
|||
|
|
// ── JSONBin Section ──
|
|||
|
|
+'<div class="setup-banner" style="background:linear-gradient(135deg,#7c3aed,#6d28d9);margin-bottom:14px">'
|
|||
|
|
+'<i class="fas fa-database"></i><div>'
|
|||
|
|
+'<strong>JSONBin.io — Live Multi-User Sync</strong> '
|
|||
|
|
+'<span class="od-badge '+(jbOn?'on':'off')+'">'+(jbOn?'Connected':'Not connected')+'</span><br>'
|
|||
|
|
+'Free shared database. All users reading the same Netlify URL will see each others updates within 10 seconds.</div></div>'
|
|||
|
|
+'<div class="od-setup-step">'
|
|||
|
|
+'<h4><i class="fas fa-list-ol" style="color:#7c3aed"></i> Setup (2 minutes)</h4>'
|
|||
|
|
+'<p>'
|
|||
|
|
+'<b>1.</b> Go to <a href="https://jsonbin.io" target="_blank" style="color:#7c3aed;font-weight:700">jsonbin.io</a> and create a free account<br>'
|
|||
|
|
+'<b>2.</b> Click <b>Create Bin</b> — enter any name — paste <code>{"init":true}</code> as content — click <b>Create</b><br>'
|
|||
|
|
+'<b>3.</b> Copy the <b>Bin ID</b> from the URL bar — it is the long string after <code>/b/</code> (24+ characters)<br>'
|
|||
|
|
+'<b>4.</b> Click your account icon top-right → <b>API Keys</b> → copy the <b>X-MASTER-KEY</b> (starts with <code>$2b$</code>)<br>'
|
|||
|
|
+'<b>5.</b> Paste both below and click <b>Connect JSONBin</b> — setup takes about 30 seconds'
|
|||
|
|
+'</p></div>'
|
|||
|
|
+'<div class="fg"><label><i class="fas fa-hashtag" style="color:#7c3aed"></i> Bin ID</label>'
|
|||
|
|
+'<input type="text" id="jbBinIn" placeholder="64abc123def456..." value="'+(DB._jbBinId||'')+'" style="font-family:monospace"></div>'
|
|||
|
|
+'<div class="fg"><label><i class="fas fa-key" style="color:#7c3aed"></i> Master Key (X-Master-Key)</label>'
|
|||
|
|
+'<input type="text" id="jbKeyIn" placeholder="$2b$10$..." value="'+(DB._jbApiKey||'')+'" style="font-family:monospace"></div>'
|
|||
|
|
+'<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:6px">'
|
|||
|
|
+'<button class="btn btn-primary" style="background:#7c3aed;border-color:#7c3aed" onclick="applyJBConfig()"><i class="fas fa-plug"></i> '+(jbOn?'Re-connect':'Connect JSONBin')+'</button>'
|
|||
|
|
+(jbOn?'<button class="btn btn-danger btn-sm" onclick="jbDisconnect()"><i class="fas fa-unlink"></i> Disconnect</button>':'')
|
|||
|
|
+(jbOn?'<button class="btn btn-outline btn-sm" onclick="jbSave(DB).then(function(){flash("Pushed!","success")})"><i class="fas fa-upload"></i> Push Now</button>':'')
|
|||
|
|
+'</div>'
|
|||
|
|
+'<hr style="border:none;border-top:1px solid var(--g200);margin:16px 0">'
|
|||
|
|
// ── Email section header ──
|
|||
|
|
+'<div class="modal-title" style="font-size:13px;font-weight:700;color:var(--navy);margin-bottom:12px"><i class="fas fa-envelope tgold"></i> Email Notifications</div>'
|
|||
|
|
+'<button class="modal-close" onclick="document.getElementById("syncModal").remove()">✕</button></div>'
|
|||
|
|
+'<div class="modal-body">'
|
|||
|
|
+'<div class="setup-banner" style="background:linear-gradient(135deg,#1e3a5f,#0ea5e9);margin-bottom:14px">'
|
|||
|
|
+'<i class="fas fa-envelope-circle-check"></i><div>'
|
|||
|
|
+'<strong>EmailJS — Send Notifications from the Portal</strong><br>'
|
|||
|
|
+'Free service. Create an account at <a href="https://emailjs.com" target="_blank" style="color:#93c5fd;font-weight:700">emailjs.com</a>, '
|
|||
|
|
+'set up a service and template, then paste your keys below.<br>'
|
|||
|
|
+'<span style="font-size:11px;opacity:.8">Your EmailJS template must include these variables: '
|
|||
|
|
+'<b>{{to_name}}, {{to_email}}, {{changed_by}}, {{change_description}}, {{timestamp}}</b>. '
|
|||
|
|
+'Set the <b>To Email</b> field in your template to <b>{{to_email}}</b>.</span>'
|
|||
|
|
+'</div></div>'
|
|||
|
|
+'<div class="od-setup-step">'
|
|||
|
|
+'<h4><i class="fas fa-list-ol" style="color:#0ea5e9"></i> Setup Steps</h4>'
|
|||
|
|
+'<p>'
|
|||
|
|
+'<b>1.</b> Sign up free at <a href="https://emailjs.com" target="_blank" style="color:#2563eb">emailjs.com</a><br>'
|
|||
|
|
+'<b>2.</b> Add an Email Service (Gmail, Outlook, etc.) — copy the <b>Service ID</b><br>'
|
|||
|
|
+'<b>3.</b> Create a Template with the variables above — copy the <b>Template ID</b><br>'
|
|||
|
|
+'<b>4.</b> Go to Account → API Keys — copy your <b>Public Key</b><br>'
|
|||
|
|
+'<b>5.</b> Paste all three below and click Save'
|
|||
|
|
+'</p></div>'
|
|||
|
|
+'<div class="fg"><label><i class="fas fa-key" style="color:#0ea5e9"></i> Public Key</label>'
|
|||
|
|
+'<input type="text" id="ejPk" placeholder="user_xxxxxxxxxxxx" value="'+(DB._ejPk||'')+'" style="font-family:monospace"></div>'
|
|||
|
|
+'<div class="fg-2">'
|
|||
|
|
+'<div class="fg"><label>Service ID</label><input type="text" id="ejSvc" placeholder="service_xxxxxxx" value="'+(DB._ejSvc||'')+'" style="font-family:monospace"></div>'
|
|||
|
|
+'<div class="fg"><label>Template ID</label><input type="text" id="ejTpl" placeholder="template_xxxxxxx" value="'+(DB._ejTpl||'')+'" style="font-family:monospace"></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div style="display:flex;gap:8px;flex-wrap:wrap">'
|
|||
|
|
+'<button class="btn btn-primary" onclick="applyEmailConfig()"><i class="fas fa-save"></i> Save & Activate</button>'
|
|||
|
|
+((DB._ejPk&&DB._ejSvc&&DB._ejTpl)?'<button class="btn btn-outline btn-sm" onclick="testEmailNotif()"><i class="fas fa-paper-plane"></i> Send Test Email</button>':'')
|
|||
|
|
+'</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="modal-foot"><button class="btn btn-outline" onclick="document.getElementById("syncModal").remove()">Close</button></div>'
|
|||
|
|
+'</div>';
|
|||
|
|
document.body.appendChild(overlay);
|
|||
|
|
overlay.addEventListener('click',function(e){if(e.target===overlay)overlay.remove();});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
function applyJBConfig(){
|
|||
|
|
var binId = ((document.getElementById('jbBinIn')||{}).value||'').trim();
|
|||
|
|
var apiKey = ((document.getElementById('jbKeyIn')||{}).value||'').trim();
|
|||
|
|
if(!binId){flash('Bin ID cannot be blank. Copy it from your JSONBin dashboard URL.','danger');return;}
|
|||
|
|
if(!apiKey){flash('Master Key cannot be blank. Copy it from JSONBin Account → API Keys.','danger');return;}
|
|||
|
|
if(binId.length < 10){flash('Bin ID looks too short. It should be around 24 characters.','danger');return;}
|
|||
|
|
if(!apiKey.startsWith('$2')){flash('Master Key format looks incorrect — it should start with $2a$ or $2b$. Please copy it again from JSONBin Account page.','danger');return;}
|
|||
|
|
var btn=document.querySelector('#syncModal .btn-primary');
|
|||
|
|
if(btn){btn.disabled=true;btn.innerHTML='<i class="fas fa-spinner fa-spin"></i> Connecting...';}
|
|||
|
|
jbConnect(binId, apiKey).then(function(){
|
|||
|
|
if(btn){btn.disabled=false;btn.innerHTML='<i class="fas fa-plug"></i> Re-connect JSONBin';}
|
|||
|
|
}).catch(function(e){
|
|||
|
|
if(btn){btn.disabled=false;btn.innerHTML='<i class="fas fa-plug"></i> Connect JSONBin';}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
function applyEmailConfig(){
|
|||
|
|
var pk=(document.getElementById('ejPk')||{}).value||'';
|
|||
|
|
var svc=(document.getElementById('ejSvc')||{}).value||'';
|
|||
|
|
var tpl=(document.getElementById('ejTpl')||{}).value||'';
|
|||
|
|
if(!pk||!svc||!tpl){flash('All three EmailJS fields are required.','danger');return;}
|
|||
|
|
pk=pk.trim();svc=svc.trim();tpl=tpl.trim();
|
|||
|
|
DB._ejPk=pk;DB._ejSvc=svc;DB._ejTpl=tpl;
|
|||
|
|
EMAILJS_SERVICE=svc;EMAILJS_TEMPLATE=tpl;
|
|||
|
|
if(window.emailjs){
|
|||
|
|
try{emailjs.init({publicKey:pk});console.log('EmailJS initialised with key:',pk.slice(0,8)+'...');}
|
|||
|
|
catch(e){flash('EmailJS init error: '+e.message,'danger');return;}
|
|||
|
|
} else {flash('EmailJS library failed to load. Check your internet connection.','danger');return;}
|
|||
|
|
saveDB();
|
|||
|
|
flash('<i class="fas fa-envelope-circle-check"></i> Email config saved & activated! Go to Profile → Email Notifications to enable alerts.','success');
|
|||
|
|
}
|
|||
|
|
/* applyODConfig removed, replaced by spConnect() */
|
|||
|
|
|
|||
|
|
function updateSyncUI(){
|
|||
|
|
var emailOn = !!(DB._ejPk&&DB._ejSvc&&DB._ejTpl);
|
|||
|
|
var jbOn = _JB.enabled && !!_JB.binId;
|
|||
|
|
// Top bar
|
|||
|
|
var el = document.getElementById('syncStatusBar');
|
|||
|
|
if(el) el.innerHTML = '<span class="sync-dot '+(jbOn?'live':'local')+'"></span>'+(jbOn?'Live Sync':'Local');
|
|||
|
|
// Email indicator
|
|||
|
|
var dot=document.getElementById('emailDot'),lbl=document.getElementById('emailSyncLbl');
|
|||
|
|
if(dot) dot.className='sync-dot '+(emailOn?'live':'local');
|
|||
|
|
if(lbl) lbl.textContent = emailOn?'Email: On':'Email: Off';
|
|||
|
|
// JSONBin indicator
|
|||
|
|
jbSetStatus(jbOn?'live':'off', jbOn?'Live Sync':'Sync: Off');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
function updateProjStart(val){
|
|||
|
|
if(!val)return;
|
|||
|
|
DB.projectStart = val+'-01';
|
|||
|
|
saveDB();
|
|||
|
|
// Re-render Gantt
|
|||
|
|
var phases=DB.phases.filter(function(p){return DB.milestones.some(function(m){return m.phaseId===p.id;});}).sort(function(a,b){return a.order-b.order;});
|
|||
|
|
renderGantt(phases);
|
|||
|
|
flash('Project start date updated to '+val);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function nid(t){return DB.ids[t]++;}
|
|||
|
|
function isAdmin(){return CU&&CU.role==='admin';}
|
|||
|
|
function isEditor(){return CU&&(CU.role==='admin'||CU.role==='editor');}
|
|||
|
|
function today(){return new Date().toISOString().slice(0,10);}
|
|||
|
|
function addDays(d,n){var dt=new Date(d);dt.setDate(dt.getDate()+n);return dt.toISOString().slice(0,10);}
|
|||
|
|
function isDelayed(ed,st){return ed&&ed<today()&&st!=='completed';}
|
|||
|
|
|
|||
|
|
function addLog(action,et,eid,en,det){
|
|||
|
|
DB.logs.push({id:nid('log'),userId:CU?CU.id:0,action:action,entityType:et||'',entityId:eid||0,entityName:en||'',details:det||'',timestamp:new Date().toISOString()});
|
|||
|
|
saveDB();
|
|||
|
|
}
|
|||
|
|
function addNotif(uid,title,msg,type){
|
|||
|
|
DB.notifications.push({id:nid('notification'),userId:uid,title:title,message:msg||'',type:type||'info',read:false,createdAt:new Date().toISOString()});
|
|||
|
|
saveDB();renderNC();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sBadge(s){
|
|||
|
|
var m={completed:'b-success',in_progress:'b-info',delayed:'b-danger',not_started:'b-gray',pending:'b-warn',approved:'b-success',rejected:'b-danger',open:'b-danger',mitigated:'b-info',closed:'b-success',deferred:'b-gray',on_hold:'b-gray',in_review:'b-warn',accepted:'b-gray'};
|
|||
|
|
return '<span class="badge '+(m[s]||'b-gray')+'">'+s.replace(/_/g,' ').replace(/\b\w/g,function(c){return c.toUpperCase();})+'</span>';
|
|||
|
|
}
|
|||
|
|
function pBadge(p){
|
|||
|
|
var m={low:'b-gray',medium:'b-warn',high:'b-danger',critical:'b-danger'};
|
|||
|
|
return '<span class="badge '+(m[p]||'b-gray')+'">'+p.charAt(0).toUpperCase()+p.slice(1)+'</span>';
|
|||
|
|
}
|
|||
|
|
function fIcon(ft){
|
|||
|
|
if(ft==='pdf')return 'fa-file-pdf';
|
|||
|
|
if(ft==='xlsx'||ft==='csv')return 'fa-file-excel';
|
|||
|
|
if(ft==='docx'||ft==='doc')return 'fa-file-word';
|
|||
|
|
if(ft==='pptx'||ft==='ppt')return 'fa-file-powerpoint';
|
|||
|
|
if(ft==='jpg'||ft==='jpeg'||ft==='png')return 'fa-file-image';
|
|||
|
|
return 'fa-file-alt';
|
|||
|
|
}
|
|||
|
|
function flash(msg,type){
|
|||
|
|
type=type||'success';
|
|||
|
|
var d=document.createElement('div');
|
|||
|
|
d.className='alert alert-'+type;
|
|||
|
|
d.innerHTML='<i class="fas fa-'+(type==='success'?'check-circle':'exclamation-circle')+'"></i>'+msg+'<span style="margin-left:auto;cursor:pointer" onclick="this.parentElement.remove()">✕</span>';
|
|||
|
|
var mc=document.getElementById('mainContent');
|
|||
|
|
if(mc)mc.prepend(d);
|
|||
|
|
setTimeout(function(){if(d.parentNode)d.remove();},3500);
|
|||
|
|
}
|
|||
|
|
function g(id){return document.getElementById(id);}
|
|||
|
|
function gv(id){var el=g(id);return el?el.value:'';}
|
|||
|
|
function pct(a,b){return a?Math.round(b/a*1000)/10:0;}
|
|||
|
|
|
|||
|
|
function phasePct(pid){
|
|||
|
|
var mids=DB.milestones.filter(function(m){return m.phaseId===pid;}).map(function(m){return m.id;});
|
|||
|
|
var acts=DB.activities.filter(function(a){return mids.indexOf(a.milestoneId)>=0;});
|
|||
|
|
return pct(acts.length,acts.filter(function(a){return a.status==='completed';}).length);
|
|||
|
|
}
|
|||
|
|
function milestonePct(mid){
|
|||
|
|
var mil=DB.milestones.find(function(m){return m.id===mid;});
|
|||
|
|
if(mil&&mil.status==='completed')return 100;
|
|||
|
|
var acts=DB.activities.filter(function(a){return a.milestoneId===mid;});
|
|||
|
|
if(!acts.length)return(mil&&mil.status==='in_progress')?50:0;
|
|||
|
|
var done=acts.filter(function(a){return a.status==='completed';}).length;
|
|||
|
|
return Math.round(done/acts.length*100);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? AUTH ?? */
|
|||
|
|
function doLogin(){
|
|||
|
|
var u=g('loginUser').value.trim(),p=g('loginPass').value;
|
|||
|
|
if(_JB.enabled && _JB.binId){
|
|||
|
|
var btn=g('loginBtn');
|
|||
|
|
if(btn){btn.disabled=true;btn.innerHTML='<i class="fas fa-spinner fa-spin"></i> Loading...';}
|
|||
|
|
jbLoadOnLogin().then(function(){
|
|||
|
|
_doLoginCore(u,p);
|
|||
|
|
if(btn){btn.disabled=false;btn.innerHTML='<i class="fas fa-sign-in-alt"></i> Sign In';}
|
|||
|
|
}).catch(function(){
|
|||
|
|
_doLoginCore(u,p);
|
|||
|
|
if(btn){btn.disabled=false;btn.innerHTML='<i class="fas fa-sign-in-alt"></i> Sign In';}
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
_doLoginCore(u,p);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
function _doLoginCore(u,p){
|
|||
|
|
var user=null;
|
|||
|
|
for(var i=0;i<DB.users.length;i++){if(DB.users[i].username===u&&DB.users[i].password===p&&DB.users[i].active){user=DB.users[i];break;}}
|
|||
|
|
if(!user){var el=g('loginErr');el.textContent='Invalid username or password.';el.classList.remove('hidden');return;}
|
|||
|
|
CU=user;addLog('LOGIN','user',CU.id,CU.username);
|
|||
|
|
g('appShell').classList.remove('hidden');g('loginScreen').classList.add('hidden');
|
|||
|
|
g('userName').textContent=user.fullName||user.username;
|
|||
|
|
g('userRole').textContent=user.role;
|
|||
|
|
g('adminNav').style.display=user.role==='admin'?'':'none';
|
|||
|
|
renderNC();showPage('dashboard');
|
|||
|
|
g('loginUser').value='';g('loginPass').value='';
|
|||
|
|
g('loginErr').classList.add('hidden');
|
|||
|
|
if(_FB.enabled)fbStartListening();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Login button event listeners
|
|||
|
|
g('loginBtn').addEventListener('click', doLogin);
|
|||
|
|
g('loginPass').addEventListener('keydown', function(e){ if(e.key==='Enter') doLogin(); });
|
|||
|
|
g('loginUser').addEventListener('keydown', function(e){ if(e.key==='Enter') g('loginPass').focus(); });
|
|||
|
|
|
|||
|
|
function doLogout(){
|
|||
|
|
addLog('LOGOUT','user',CU.id,CU.username);CU=null;
|
|||
|
|
if(pc){pc.destroy();pc=null;}if(sc){sc.destroy();sc=null;}
|
|||
|
|
g('appShell').classList.add('hidden');g('loginScreen').classList.remove('hidden');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? NOTIFICATIONS ?? */
|
|||
|
|
function renderNC(){
|
|||
|
|
if(!CU)return;
|
|||
|
|
var c=DB.notifications.filter(function(n){return n.userId===CU.id&&!n.read;}).length;
|
|||
|
|
var el=g('ndCount');el.textContent=c>9?'9+':c;el.style.display=c>0?'flex':'none';
|
|||
|
|
}
|
|||
|
|
function toggleND(){
|
|||
|
|
var d=g('ndDrop');d.classList.toggle('open');
|
|||
|
|
if(d.classList.contains('open'))renderNL();
|
|||
|
|
}
|
|||
|
|
function renderNL(){
|
|||
|
|
var items=DB.notifications.filter(function(n){return n.userId===CU.id;}).sort(function(a,b){return b.createdAt.localeCompare(a.createdAt);}).slice(0,30);
|
|||
|
|
g('ndList').innerHTML=items.length?items.map(function(n){
|
|||
|
|
return '<div class="nd-item'+(n.read?'':' unread')+'" onclick="openNotif('+n.id+')">'
|
|||
|
|
+'<div style="font-weight:'+(n.read?500:700)+';font-size:13px">'+n.title+'</div>'
|
|||
|
|
+'<div class="muted" style="font-size:11.5px;margin-top:2px">'+(n.message||'').slice(0,70)+(n.message&&n.message.length>70?'...':'')+'</div>'
|
|||
|
|
+'<div class="flex" style="gap:8px;margin-top:4px"><span class="muted" style="font-size:11px">'+n.createdAt.slice(0,16).replace('T',' ')+'</span>'
|
|||
|
|
+(n.read?'':'<span style="font-size:10px;background:var(--gold);color:var(--navy);padding:1px 6px;border-radius:8px;font-weight:700">NEW</span>')
|
|||
|
|
+'</div></div>';
|
|||
|
|
}).join(''):'<div style="padding:20px;text-align:center;color:var(--g400);font-size:13px">No notifications</div>';
|
|||
|
|
}
|
|||
|
|
function markNR(id){var n=DB.notifications.find(function(x){return x.id===id;});if(n)n.read=true;saveDB();renderNC();renderNL();}
|
|||
|
|
function openNotif(id){
|
|||
|
|
var n=DB.notifications.find(function(x){return x.id===id;});
|
|||
|
|
if(!n)return;
|
|||
|
|
n.read=true;saveDB();renderNC();renderNL();
|
|||
|
|
var old=document.getElementById('notifPopup');
|
|||
|
|
if(old)old.remove();
|
|||
|
|
var overlay=document.createElement('div');
|
|||
|
|
overlay.className='overlay open';
|
|||
|
|
overlay.id='notifPopup';
|
|||
|
|
var modal=document.createElement('div');
|
|||
|
|
modal.className='modal';
|
|||
|
|
modal.style.maxWidth='480px';
|
|||
|
|
var hdr=document.createElement('div');
|
|||
|
|
hdr.className='modal-hdr';
|
|||
|
|
hdr.innerHTML='<span class="modal-title"><i class="fas fa-bell"></i> Notification</span>';
|
|||
|
|
var xb=document.createElement('button');
|
|||
|
|
xb.className='modal-close';
|
|||
|
|
xb.innerHTML='✕';
|
|||
|
|
xb.onclick=function(){var p=document.getElementById('notifPopup');if(p)p.remove();};
|
|||
|
|
hdr.appendChild(xb);
|
|||
|
|
modal.appendChild(hdr);
|
|||
|
|
var body=document.createElement('div');
|
|||
|
|
body.className='modal-body';
|
|||
|
|
var msgBody=n.message||'No message body.';
|
|||
|
|
body.innerHTML='<div style="font-size:16px;font-weight:700;color:#0d2137;margin-bottom:6px">'+n.title+'</div>'
|
|||
|
|
+'<div style="font-size:12px;color:#a1a1aa;margin-bottom:12px">'+n.createdAt.slice(0,16).replace('T',' ')
|
|||
|
|
+' <span style="background:#f4f4f5;color:#52525b;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600">'+n.type+'</span></div>'
|
|||
|
|
+'<div style="font-size:14px;line-height:1.6;background:#fafafa;padding:14px;border-radius:8px;border:1px solid #e4e4e7;margin-bottom:14px;color:#27272a">'+msgBody+'</div>'
|
|||
|
|
+'<div style="border-top:1px solid #e4e4e7;padding-top:12px">'
|
|||
|
|
+'<label style="display:block;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#52525b;margin-bottom:6px">Reply / Note</label>'
|
|||
|
|
+'<textarea id="notifReply" rows="3" placeholder="Type a reply or note..." style="width:100%;padding:8px 10px;border:1px solid #e4e4e7;border-radius:6px;font-size:13px;resize:vertical;font-family:inherit"></textarea>'
|
|||
|
|
+'<div id="notifBtnRow" style="display:flex;gap:8px;margin-top:8px"></div>'
|
|||
|
|
+'</div>';
|
|||
|
|
modal.appendChild(body);
|
|||
|
|
overlay.appendChild(modal);
|
|||
|
|
document.body.appendChild(overlay);
|
|||
|
|
var row=document.getElementById('notifBtnRow');
|
|||
|
|
var b1=document.createElement('button');b1.className='btn btn-primary btn-sm';b1.innerHTML='<i class="fas fa-paper-plane"></i> Send Reply';
|
|||
|
|
b1.onclick=function(){sendNotifReply(id);};row.appendChild(b1);
|
|||
|
|
var b2=document.createElement('button');b2.className='btn btn-danger btn-sm';b2.innerHTML='<i class="fas fa-trash"></i> Delete';
|
|||
|
|
b2.onclick=function(){deleteNotif(id);};row.appendChild(b2);
|
|||
|
|
var b3=document.createElement('button');b3.className='btn btn-outline btn-sm';b3.textContent='Close';
|
|||
|
|
b3.onclick=function(){var p=document.getElementById('notifPopup');if(p)p.remove();};row.appendChild(b3);
|
|||
|
|
overlay.addEventListener('click',function(e){if(e.target===overlay)overlay.remove();});
|
|||
|
|
}
|
|||
|
|
function sendNotifReply(id){
|
|||
|
|
var txt=g('notifReply')?g('notifReply').value.trim():'';
|
|||
|
|
if(!txt){alert('Please type a reply first.');return;}
|
|||
|
|
var n=DB.notifications.find(function(x){return x.id===id;});
|
|||
|
|
addNotif(CU.id,'Reply: '+(n?n.title:'Notification'),'Your reply: '+txt,'info');
|
|||
|
|
addLog('NOTIF_REPLY','notification',id,'Reply sent');
|
|||
|
|
saveDB();if(g('notifPopup'))g('notifPopup').remove();flash('Reply saved as notification.');
|
|||
|
|
}
|
|||
|
|
function deleteNotif(id){
|
|||
|
|
DB.notifications=DB.notifications.filter(function(x){return x.id!==id;});
|
|||
|
|
saveDB();renderNC();renderNL();if(g('notifPopup'))g('notifPopup').remove();flash('Notification deleted.');
|
|||
|
|
}
|
|||
|
|
function markAllRead(){DB.notifications.filter(function(n){return n.userId===CU.id;}).forEach(function(n){n.read=true;});saveDB();renderNC();renderNL();}
|
|||
|
|
document.addEventListener('click',function(e){
|
|||
|
|
var d=g('ndDrop'),b=g('ndBtn');
|
|||
|
|
if(d&&b&&!d.contains(e.target)&&!b.contains(e.target))d.classList.remove('open');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/* ?? ROUTING ?? */
|
|||
|
|
function showPage(page,param){
|
|||
|
|
if(pc){pc.destroy();pc=null;}if(sc){sc.destroy();sc=null;}
|
|||
|
|
document.querySelectorAll('.nav-item').forEach(function(n){n.classList.remove('active');});
|
|||
|
|
var nav=g('nav-'+page);if(nav)nav.classList.add('active');
|
|||
|
|
var titles={dashboard:'Project Dashboard',phases:'Phases & Milestones',decisions:'Decision Register',risks:'Risk Register',users:'User Management',logs:'Activity Logs',profile:'My Profile',committees:'PMT & PSC Oversight',meetings:'Committee Meetings',stakeholders:'Stakeholder Engagement Tracker'};
|
|||
|
|
g('pageTitle').textContent=titles[page]||'Pinnacle';
|
|||
|
|
g('mainContent').innerHTML='';
|
|||
|
|
if(page==='dashboard')renderDashboard();
|
|||
|
|
else if(page==='phases')renderPhases();
|
|||
|
|
else if(page==='phaseDetail')renderPhaseDetail(param);
|
|||
|
|
else if(page==='milestoneDetail')renderMilestoneDetail(param);
|
|||
|
|
else if(page==='decisions')renderDecisions();
|
|||
|
|
else if(page==='risks')renderRisks();
|
|||
|
|
else if(page==='users')renderUsers();
|
|||
|
|
else if(page==='logs')renderLogs();
|
|||
|
|
else if(page==='profile')renderProfile();
|
|||
|
|
else if(page==='committees')renderCommittees();
|
|||
|
|
else if(page==='meetings')renderMeetings();
|
|||
|
|
else if(page==='stakeholders')renderStakeholders();
|
|||
|
|
}
|
|||
|
|
function openModal(id){var m=g(id);if(m)m.classList.add('open');}
|
|||
|
|
function closeModal(id){var m=g(id);if(m)m.classList.remove('open');}
|
|||
|
|
document.addEventListener('click',function(e){if(e.target.classList.contains('overlay'))e.target.classList.remove('open');});
|
|||
|
|
|
|||
|
|
/* ?? DASHBOARD ?? */
|
|||
|
|
function renderBlockers(){
|
|||
|
|
var td=today();
|
|||
|
|
var blockers=[];
|
|||
|
|
|
|||
|
|
// SOURCE 1: Overdue activities (past endDate, not completed)
|
|||
|
|
DB.activities.forEach(function(a){
|
|||
|
|
if(a.status==='completed'||!a.endDate)return;
|
|||
|
|
if(a.endDate>=td)return;
|
|||
|
|
var daysLate=Math.round((new Date(td)-new Date(a.endDate))/(1000*60*60*24));
|
|||
|
|
var mil=DB.milestones.find(function(m){return m.id===a.milestoneId;})||{};
|
|||
|
|
var ph=DB.phases.find(function(p){return p.id===mil.phaseId;})||{};
|
|||
|
|
blockers.push({
|
|||
|
|
type:'activity',label:'Overdue Activity',name:a.title,
|
|||
|
|
meta:'Phase '+(ph.num||'?')+' · '+(mil.title||''),
|
|||
|
|
daysLate:daysLate,owner:a.owner||'Unassigned',
|
|||
|
|
sev:daysLate>14?'critical':daysLate>7?'high':'medium',
|
|||
|
|
score:daysLate*3,link:'phases'
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// SOURCE 2: Open high/critical risks
|
|||
|
|
DB.risks.forEach(function(r){
|
|||
|
|
if(r.status!=='open')return;
|
|||
|
|
var sev=(r.severity||r.impact||'medium').toLowerCase();
|
|||
|
|
if(sev!=='high'&&sev!=='critical'&&sev!=='very high')return;
|
|||
|
|
var daysOpen=0;
|
|||
|
|
if(r.dateRaised){daysOpen=Math.round((new Date(td)-new Date(r.dateRaised))/(1000*60*60*24));}
|
|||
|
|
blockers.push({
|
|||
|
|
type:'risk',label:'Open Risk',name:r.title,
|
|||
|
|
meta:'Severity: '+(r.severity||'High')+' · Owner: '+(r.owner||'Unassigned'),
|
|||
|
|
daysLate:daysOpen,owner:r.owner||'Unassigned',
|
|||
|
|
sev:sev==='critical'?'critical':'high',
|
|||
|
|
score:daysOpen*2+(sev==='critical'?100:50),link:'risks'
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// SOURCE 3: Pending decisions overdue (older than 7 days)
|
|||
|
|
DB.decisions.forEach(function(d){
|
|||
|
|
if(d.status!=='pending')return;
|
|||
|
|
var daysOpen=0;
|
|||
|
|
if(d.dateRaised||d.created){
|
|||
|
|
daysOpen=Math.round((new Date(td)-new Date(d.dateRaised||d.created))/(1000*60*60*24));
|
|||
|
|
}
|
|||
|
|
if(daysOpen<7)return;
|
|||
|
|
blockers.push({
|
|||
|
|
type:'decision',label:'Pending Decision',name:d.title,
|
|||
|
|
meta:'Awaiting approval · '+(d.owner||''),
|
|||
|
|
daysLate:daysOpen,owner:d.owner||'Unassigned',
|
|||
|
|
sev:daysOpen>21?'critical':daysOpen>14?'high':'medium',
|
|||
|
|
score:daysOpen*2+(d.priority==='high'?40:0),link:'decisions'
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Sort by score desc, take top 3
|
|||
|
|
blockers.sort(function(a,b){return b.score-a.score;});
|
|||
|
|
var top=blockers.slice(0,3);
|
|||
|
|
|
|||
|
|
var sevConfig={
|
|||
|
|
critical:{col:'#f43f5e',bg:'rgba(244,63,94,.15)',txt:'CRITICAL',numBg:'#f43f5e',numCol:'#fff'},
|
|||
|
|
high: {col:'#f97316',bg:'rgba(249,115,22,.12)',txt:'HIGH', numBg:'#f97316',numCol:'#fff'},
|
|||
|
|
medium: {col:'#eab308',bg:'rgba(234,179,8,.10)', txt:'MEDIUM', numBg:'#eab308',numCol:'#fff'}
|
|||
|
|
};
|
|||
|
|
var typeConfig={
|
|||
|
|
activity:{pill:'tp-activity',icon:'fa-bolt'},
|
|||
|
|
risk: {pill:'tp-risk', icon:'fa-shield-exclamation'},
|
|||
|
|
decision:{pill:'tp-decision',icon:'fa-gavel'}
|
|||
|
|
};
|
|||
|
|
var daysClass=function(d){return d>14?'':d>7?' orange':' amber';};
|
|||
|
|
|
|||
|
|
var inner='';
|
|||
|
|
if(!top.length){
|
|||
|
|
inner='<div class="no-blockers"><i class="fas fa-shield-check"></i>No active blockers — project is on track!</div>';
|
|||
|
|
} else {
|
|||
|
|
// Pad to 3 items with placeholder
|
|||
|
|
while(top.length<3){
|
|||
|
|
top.push(null);
|
|||
|
|
}
|
|||
|
|
inner='<div class="blocker-items">';
|
|||
|
|
top.forEach(function(b,i){
|
|||
|
|
if(!b){
|
|||
|
|
inner+='<div class="blocker-item" style="opacity:.3;cursor:default">'
|
|||
|
|
+'<div class="blocker-rank"><div class="blocker-rank-num" style="background:rgba(255,255,255,.1);color:rgba(255,255,255,.3)">'+(i+1)+'</div>'
|
|||
|
|
+'<span style="color:rgba(255,255,255,.25);font-size:9px">No blocker</span></div>'
|
|||
|
|
+'<div class="blocker-name" style="color:rgba(255,255,255,.2)">All clear</div>'
|
|||
|
|
+'</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
var sc=sevConfig[b.sev]||sevConfig.medium;
|
|||
|
|
var tc=typeConfig[b.type]||typeConfig.activity;
|
|||
|
|
var dCls=b.daysLate>14?'':b.daysLate>7?' orange':' amber';
|
|||
|
|
var dLabel=b.daysLate===0?'Today':b.daysLate===1?'1 day':b.daysLate+' days';
|
|||
|
|
var dType=b.type==='activity'?'overdue':b.type==='risk'?'open':'pending';
|
|||
|
|
inner+='<div class="blocker-item sev-'+b.sev+'" onclick="showPage("'+b.link+'")" title="Click to view '+b.label+'">'
|
|||
|
|
+'<div class="blocker-rank">'
|
|||
|
|
+'<div class="blocker-rank-num" style="background:'+sc.numBg+';color:'+sc.numCol+'">'+(i+1)+'</div>'
|
|||
|
|
+'<span class="blocker-type-pill '+tc.pill+'"><i class="fas '+tc.icon+'" style="margin-right:3px"></i>'+b.label+'</span>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="blocker-name">'+b.name+'</div>'
|
|||
|
|
+'<div class="blocker-meta">'+b.meta+'</div>'
|
|||
|
|
+'<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:6px">'
|
|||
|
|
+'<div class="blocker-days'+(b.daysLate>14?'':b.daysLate>7?' orange':' amber')+'"><i class="fas fa-clock" style="font-size:8px"></i> '+dLabel+' '+dType+'</div>'
|
|||
|
|
+'<div class="escalate-btn" onclick="event.stopPropagation();openEscalateModal("'+b.name.replace(/["<>]/g,'')+'","'+b.owner+'")"><i class="fas fa-arrow-up-right-from-square" style="font-size:8px"></i> Escalate</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'</div>';
|
|||
|
|
});
|
|||
|
|
inner+='</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var totalCount=blockers.length;
|
|||
|
|
return '<div class="blockers-panel">'
|
|||
|
|
+'<div class="blockers-hdr">'
|
|||
|
|
+'<div class="blockers-hdr-left">'
|
|||
|
|
+'<div style="width:32px;height:32px;background:rgba(244,63,94,.2);border-radius:9px;display:flex;align-items:center;justify-content:center;border:1px solid rgba(244,63,94,.3)">'
|
|||
|
|
+'<i class="fas fa-radar" style="color:#f43f5e;font-size:14px"></i></div>'
|
|||
|
|
+'<div><div class="blockers-title">Action Intelligence Centre</div>'
|
|||
|
|
+'<div class="blockers-sub">'+totalCount+' item'+(totalCount!==1?'s':'')+' requiring attention · Ranked by priority & urgency</div>'
|
|||
|
|
+'</div></div>'
|
|||
|
|
+(totalCount>3?'<div class="blockers-badge">+' +(totalCount-3)+' more</div>':'')
|
|||
|
|
+'<button class="btn btn-outline btn-sm" style="border-color:rgba(255,255,255,.2);color:rgba(255,255,255,.6);background:rgba(255,255,255,.05)" onclick="showAllBlockers()">'
|
|||
|
|
+'<i class="fas fa-list"></i> View All</button>'
|
|||
|
|
+'</div>'
|
|||
|
|
+inner
|
|||
|
|
+'</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function escalateBlocker(name,owner){
|
|||
|
|
var m=document.createElement('div');
|
|||
|
|
m.className='overlay open';
|
|||
|
|
m.innerHTML='<div class="modal" style="max-width:440px">'
|
|||
|
|
+'<div class="modal-hdr"><span class="modal-title" style="color:#f43f5e"><i class="fas fa-arrow-up-right-from-square"></i> Escalate Blocker</span>'
|
|||
|
|
+'<button class="modal-close" onclick="this.closest(".overlay").remove()">✕</button></div>'
|
|||
|
|
+'<div class="modal-body">'
|
|||
|
|
+'<div style="background:#fff8f8;border:1px solid #fecdd3;border-radius:8px;padding:14px;margin-bottom:16px">'
|
|||
|
|
+'<div style="font-weight:700;color:#be123c;margin-bottom:4px"><i class="fas fa-exclamation-circle"></i> '+name+'</div>'
|
|||
|
|
+'<div style="font-size:12px;color:#9f1239">Owner: '+owner+'</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="fg"><label>Escalate to</label>'
|
|||
|
|
+'<select id="escTo"><option>Project Sponsor</option><option>Department Head</option><option>Steering Committee</option><option>CEO / MD</option></select></div>'
|
|||
|
|
+'<div class="fg"><label>Message</label>'
|
|||
|
|
+'<textarea id="escMsg" rows="3" style="resize:vertical" placeholder="Describe the impact and what decision is needed...">This blocker ('+name+') requires your immediate attention as it is impacting project delivery.</textarea></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="modal-foot">'
|
|||
|
|
+'<button class="btn btn-outline" onclick="this.closest(".overlay").remove()">Cancel</button>'
|
|||
|
|
+'<button class="btn btn-gold" id="sendEscBtn" onclick="sendEscalation()"><i class="fas fa-paper-plane"></i> Send Escalation</button>'
|
|||
|
|
+'</div></div>';
|
|||
|
|
document.body.appendChild(m);
|
|||
|
|
m.addEventListener('click',function(e){if(e.target===m)m.remove();});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openEscalateModal(name,owner){escalateBlocker(name,owner);}
|
|||
|
|
function sendEscalation(name){
|
|||
|
|
if(!name){
|
|||
|
|
var title=document.querySelector('.overlay.open .modal-title');
|
|||
|
|
name=title?title.textContent.replace('Escalate Blocker','').trim():'Unknown';
|
|||
|
|
}
|
|||
|
|
var to=(document.getElementById('escTo')||{}).value||'';
|
|||
|
|
var msg=(document.getElementById('escMsg')||{}).value||'';
|
|||
|
|
addLog('ESCALATE','blocker',0,'Escalated: '+name+' → '+to);
|
|||
|
|
saveDB();
|
|||
|
|
document.querySelector('.overlay.open').remove();
|
|||
|
|
flash('<i class="fas fa-check-circle"></i> Escalation sent to '+to+' for: '+name,'success');
|
|||
|
|
broadcastEmailNotification('BLOCKER ESCALATED: "'+name+'" has been escalated to '+to+' by '+CU.fullName+'. Message: '+msg);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showAllBlockers(){
|
|||
|
|
var td=today();
|
|||
|
|
var all=[];
|
|||
|
|
DB.activities.forEach(function(a){
|
|||
|
|
if(a.status==='completed'||!a.endDate||a.endDate>=td)return;
|
|||
|
|
var d=Math.round((new Date(td)-new Date(a.endDate))/(1000*60*60*24));
|
|||
|
|
var mil=DB.milestones.find(function(m){return m.id===a.milestoneId;})||{};
|
|||
|
|
all.push({type:'activity',name:a.title,days:d,sev:d>14?'Critical':d>7?'High':'Medium',owner:a.owner||'-'});
|
|||
|
|
});
|
|||
|
|
DB.risks.forEach(function(r){
|
|||
|
|
if(r.status!=='open')return;
|
|||
|
|
var d=r.dateRaised?Math.round((new Date(td)-new Date(r.dateRaised))/(1000*60*60*24)):0;
|
|||
|
|
all.push({type:'risk',name:r.title,days:d,sev:r.severity||'High',owner:r.owner||'-'});
|
|||
|
|
});
|
|||
|
|
DB.decisions.forEach(function(d){
|
|||
|
|
if(d.status!=='pending')return;
|
|||
|
|
var days=d.dateRaised?Math.round((new Date(td)-new Date(d.dateRaised))/(1000*60*60*24)):0;
|
|||
|
|
if(days<7)return;
|
|||
|
|
all.push({type:'decision',name:d.title,days:days,sev:days>21?'Critical':'High',owner:d.owner||'-'});
|
|||
|
|
});
|
|||
|
|
all.sort(function(a,b){return b.days-a.days;});
|
|||
|
|
var rows=all.map(function(b){
|
|||
|
|
var col=b.sev==='Critical'?'#f43f5e':b.sev==='High'?'#f97316':'#eab308';
|
|||
|
|
return '<tr>'
|
|||
|
|
+'<td><span style="font-size:9px;font-weight:700;background:rgba(0,0,0,.06);padding:2px 6px;border-radius:4px">'+b.type.toUpperCase()+'</span></td>'
|
|||
|
|
+'<td style="font-weight:600">'+b.name+'</td>'
|
|||
|
|
+'<td><span style="color:'+col+';font-weight:700">'+b.sev+'</span></td>'
|
|||
|
|
+'<td style="color:#b91c1c;font-weight:700">'+b.days+' days</td>'
|
|||
|
|
+'<td>'+b.owner+'</td>'
|
|||
|
|
+'</tr>';
|
|||
|
|
}).join('');
|
|||
|
|
var m=document.createElement('div');
|
|||
|
|
m.className='overlay open';
|
|||
|
|
m.innerHTML='<div class="modal" style="max-width:680px">'
|
|||
|
|
+'<div class="modal-hdr"><span class="modal-title"><i class="fas fa-triangle-exclamation" style="color:#f43f5e"></i> All Blockers ('+all.length+')</span>'
|
|||
|
|
+'<button class="modal-close" onclick="this.closest(".overlay").remove()">✕</button></div>'
|
|||
|
|
+'<div class="modal-body"><div class="tbl-wrap"><table><thead><tr><th>Type</th><th>Blocker</th><th>Severity</th><th>Days</th><th>Owner</th></tr></thead>'
|
|||
|
|
+'<tbody>'+(rows||'<tr><td colspan="5" style="text-align:center;padding:22px;color:var(--g400)">No blockers found</td></tr>')+'</tbody></table></div></div>'
|
|||
|
|
+'<div class="modal-foot"><button class="btn btn-outline" onclick="this.closest(".overlay").remove()">Close</button></div>'
|
|||
|
|
+'</div>';
|
|||
|
|
document.body.appendChild(m);
|
|||
|
|
m.addEventListener('click',function(e){if(e.target===m)m.remove();});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getCommitteeColours(type){
|
|||
|
|
return type==='pmt'
|
|||
|
|
?{bg:'#60a5fa',light:'#dbeafe',dark:'#1d4ed8',pill:'ab-pmt'}
|
|||
|
|
:{bg:'#a78bfa',light:'#ede9fe',dark:'#6d28d9',pill:'ab-psc'};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderCommitteeBanner(){
|
|||
|
|
var pmt=DB.committees&&DB.committees.pmt?DB.committees.pmt:{name:'Project Management Team',abbr:'PMT',chair:'Project Manager',meetingCadence:'Weekly',members:[]};
|
|||
|
|
var psc=DB.committees&&DB.committees.psc?DB.committees.psc:{name:'Project Steering Committee',abbr:'PSC',chair:'Executive Sponsor',meetingCadence:'Monthly',members:[]};
|
|||
|
|
// Count stats for each committee
|
|||
|
|
var totalMils=DB.milestones.length;
|
|||
|
|
var pmtReviewed=DB.milestones.filter(function(m){return m.pmtStatus&&(m.pmtStatus==='endorsed'||m.pmtStatus==='escalated');}).length;
|
|||
|
|
var pscApproved=DB.milestones.filter(function(m){return m.pscStatus==='approved';}).length;
|
|||
|
|
var pscPending=DB.milestones.filter(function(m){return m.pscStatus==='pending';}).length;
|
|||
|
|
var pmtMembers=pmt.members||[];
|
|||
|
|
var pscMembers=psc.members||[];
|
|||
|
|
var pmtAvatars=pmtMembers.slice(0,4).map(function(u){
|
|||
|
|
var bg=['#6366f1','#0ea5e9','#10b981','#f59e0b'][pmtMembers.indexOf(u)%4];
|
|||
|
|
return '<div class="cm-avatar" style="background:'+bg+'" title="'+u+'">'+u.charAt(0).toUpperCase()+'</div>';
|
|||
|
|
}).join('');
|
|||
|
|
if(pmtMembers.length>4)pmtAvatars+='<div class="cm-avatar cm-more">+' +(pmtMembers.length-4)+'</div>';
|
|||
|
|
var pscAvatars=pscMembers.slice(0,4).map(function(u){
|
|||
|
|
var bg=['#8b5cf6','#ec4899','#f97316','#14b8a6'][pscMembers.indexOf(u)%4];
|
|||
|
|
return '<div class="cm-avatar" style="background:'+bg+'" title="'+u+'">'+u.charAt(0).toUpperCase()+'</div>';
|
|||
|
|
}).join('');
|
|||
|
|
if(pscMembers.length>4)pscAvatars+='<div class="cm-avatar cm-more">+' +(pscMembers.length-4)+'</div>';
|
|||
|
|
|
|||
|
|
return '<div class="committee-banner">'
|
|||
|
|
// PMT Card
|
|||
|
|
+'<div class="committee-card pmt" onclick="showPage("committees")" style="cursor:pointer">'
|
|||
|
|
+'<div class="committee-hdr">'
|
|||
|
|
+'<div class="committee-icon"><i class="fas fa-users-gear"></i></div>'
|
|||
|
|
+'<div><div class="committee-name">'+pmt.abbr+' — '+pmt.name+'</div>'
|
|||
|
|
+'<div class="committee-role">'+pmt.chair+' · Meets '+pmt.meetingCadence+'</div>'
|
|||
|
|
+'</div></div>'
|
|||
|
|
+'<div class="committee-stats">'
|
|||
|
|
+'<div class="committee-stat"><div class="committee-stat-val">'+pmtReviewed+'</div><div class="committee-stat-lbl">Reviewed</div></div>'
|
|||
|
|
+'<div class="committee-stat"><div class="committee-stat-val">'+(totalMils-pmtReviewed)+'</div><div class="committee-stat-lbl">Pending</div></div>'
|
|||
|
|
+'<div class="committee-stat"><div class="committee-stat-val">'+DB.activities.filter(function(a){return a.status==='in_progress';}).length+'</div><div class="committee-stat-lbl">Active</div></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+(pmtAvatars?'<div class="committee-members">'+pmtAvatars+'</div>':'')
|
|||
|
|
+'</div>'
|
|||
|
|
// PSC Card
|
|||
|
|
+'<div class="committee-card psc" onclick="showPage("committees")" style="cursor:pointer">'
|
|||
|
|
+'<div class="committee-hdr">'
|
|||
|
|
+'<div class="committee-icon"><i class="fas fa-people-roof"></i></div>'
|
|||
|
|
+'<div><div class="committee-name">'+psc.abbr+' — '+psc.name+'</div>'
|
|||
|
|
+'<div class="committee-role">'+psc.chair+' · Meets '+psc.meetingCadence+'</div>'
|
|||
|
|
+'</div></div>'
|
|||
|
|
+'<div class="committee-stats">'
|
|||
|
|
+'<div class="committee-stat"><div class="committee-stat-val">'+pscApproved+'</div><div class="committee-stat-lbl">Approved</div></div>'
|
|||
|
|
+'<div class="committee-stat"><div class="committee-stat-val">'+pscPending+'</div><div class="committee-stat-lbl">Pending</div></div>'
|
|||
|
|
+'<div class="committee-stat"><div class="committee-stat-val">'+DB.decisions.filter(function(d){return d.status==='pending';}).length+'</div><div class="committee-stat-lbl">Decisions</div></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+(pscAvatars?'<div class="committee-members">'+pscAvatars+'</div>':'')
|
|||
|
|
+'</div>'
|
|||
|
|
+'</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function calcHealthScore(){
|
|||
|
|
var total=DB.activities.length||1;
|
|||
|
|
var done=DB.activities.filter(function(a){return a.status==='completed';}).length;
|
|||
|
|
var td=today();
|
|||
|
|
var overdue=DB.activities.filter(function(a){return a.endDate&&a.endDate<td&&a.status!=='completed';}).length;
|
|||
|
|
var openRisksHigh=DB.risks.filter(function(r){return r.status==='open'&&(r.severity==='high'||r.severity==='critical');}).length;
|
|||
|
|
var pendDec=DB.decisions.filter(function(d){return d.status==='pending';}).length;
|
|||
|
|
var pscApproved=DB.milestones.filter(function(m){return m.pscStatus==='approved';}).length;
|
|||
|
|
var totalMils=DB.milestones.length||1;
|
|||
|
|
var milPct=Math.round(done/total*100);
|
|||
|
|
var schedScore=Math.max(0,100-overdue*8);
|
|||
|
|
var riskScore=Math.max(0,100-openRisksHigh*15);
|
|||
|
|
var decScore=Math.max(0,100-pendDec*10);
|
|||
|
|
var pscScore=Math.round(pscApproved/totalMils*100);
|
|||
|
|
var composite=Math.round(milPct*0.30+schedScore*0.25+decScore*0.20+riskScore*0.15+pscScore*0.10);
|
|||
|
|
return{score:composite,milPct:milPct,schedScore:schedScore,riskScore:riskScore,decScore:decScore,pscScore:pscScore,
|
|||
|
|
rag:composite>=80?'green':composite>=60?'amber':'red',
|
|||
|
|
ragLabel:composite>=80?'On Track':composite>=60?'Needs Attention':'At Risk'};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderDecisionsRequired(){
|
|||
|
|
var td=today();
|
|||
|
|
var monthEnd=today().slice(0,7)+'-'+(new Date(new Date().getFullYear(),new Date().getMonth()+1,0).getDate());
|
|||
|
|
// All pending decisions
|
|||
|
|
var pending=DB.decisions.filter(function(d){return d.status==='pending';});
|
|||
|
|
// Sort by days open descending
|
|||
|
|
pending.sort(function(a,b){
|
|||
|
|
var da=a.dateRaised?Math.round((new Date(td)-new Date(a.dateRaised))/(1000*60*60*24)):0;
|
|||
|
|
var db2=b.dateRaised?Math.round((new Date(td)-new Date(b.dateRaised))/(1000*60*60*24)):0;
|
|||
|
|
return db2-da;
|
|||
|
|
});
|
|||
|
|
if(!pending.length){
|
|||
|
|
return '<div class="dec-required-panel">'
|
|||
|
|
+'<div class="dec-required-hdr"><div class="dec-required-title"><i class="fas fa-circle-check"></i> Decisions Required This Month</div></div>'
|
|||
|
|
+'<div style="padding:24px;text-align:center;color:var(--g400)"><i class="fas fa-check-circle" style="font-size:28px;color:#22c55e;display:block;margin-bottom:8px"></i>No pending decisions — all clear!</div>'
|
|||
|
|
+'</div>';
|
|||
|
|
}
|
|||
|
|
var rows=pending.map(function(d){
|
|||
|
|
var days=d.dateRaised?Math.round((new Date(td)-new Date(d.dateRaised))/(1000*60*60*24)):0;
|
|||
|
|
var urgColor=days>21?'#fee2e2':days>14?'#fff7ed':'#fef9c3';
|
|||
|
|
var urgTxt=days>21?'#991b1b':days>14?'#9a3412':'#854d0e';
|
|||
|
|
var urgLabel=days>21?'URGENT':days>14?'HIGH':'REVIEW';
|
|||
|
|
var committee=d.committee||'PSC';
|
|||
|
|
return '<div class="dec-item">'
|
|||
|
|
+'<div class="dec-urgency" style="background:'+urgColor+';color:'+urgTxt+'">'
|
|||
|
|
+'<span>'+days+'</span><span class="dec-urgency-lbl">days</span>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="dec-body">'
|
|||
|
|
+'<div class="dec-title-text">'+d.title+'</div>'
|
|||
|
|
+'<div class="dec-meta">'
|
|||
|
|
+'<span style="background:#ede9fe;color:#6d28d9;font-size:9.5px;font-weight:700;padding:1px 6px;border-radius:6px;margin-right:6px">'+committee+'</span>'
|
|||
|
|
+'Owner: '+(d.owner||'Unassigned')
|
|||
|
|
+(d.dateRaised?' · Raised: '+d.dateRaised:'')
|
|||
|
|
+'</div>'
|
|||
|
|
+(d.consequence?'<div class="dec-consequence"><i class="fas fa-exclamation-triangle" style="font-size:9px"></i> Consequence of delay: '+d.consequence+'</div>':'')
|
|||
|
|
+'</div>'
|
|||
|
|
+(isEditor()?'<div class="dec-action-btn"><button class="btn btn-primary btn-sm" onclick="openDecisionModal('+d.id+')"><i class="fas fa-gavel"></i> Decide</button></div>':'')
|
|||
|
|
+'</div>';
|
|||
|
|
}).join('');
|
|||
|
|
return '<div class="dec-required-panel">'
|
|||
|
|
+'<div class="dec-required-hdr">'
|
|||
|
|
+'<div class="dec-required-title"><i class="fas fa-gavel"></i> Decisions Required This Month</div>'
|
|||
|
|
+'<span style="font-size:10px;color:rgba(255,255,255,.5);font-weight:600">'+pending.length+' pending · PSC authority</span>'
|
|||
|
|
+'</div>'
|
|||
|
|
+rows
|
|||
|
|
+'</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderExecutive(){
|
|||
|
|
if(!DB.committees)DB.committees={pmt:{name:'Project Management Team',abbr:'PMT',chair:'',meetingCadence:'Weekly',members:[],responsibilities:[]},psc:{name:'Project Steering Committee',abbr:'PSC',chair:'',meetingCadence:'Monthly',members:[],responsibilities:[]}};
|
|||
|
|
if(!DB.projectNarrative)DB.projectNarrative={thisWeek:'',nextWeek:'',keyMessage:'',lastUpdated:'',updatedBy:''};
|
|||
|
|
var h=calcHealthScore();
|
|||
|
|
var scoreColor=h.rag==='green'?'#22c55e':h.rag==='amber'?'#f59e0b':'#ef4444';
|
|||
|
|
var td=today();
|
|||
|
|
var totalMils=DB.milestones.length;
|
|||
|
|
var doneMils=DB.milestones.filter(function(m){return m.status==='completed';}).length;
|
|||
|
|
var openRisks=DB.risks.filter(function(r){return r.status==='open';}).length;
|
|||
|
|
var pendDec=DB.decisions.filter(function(d){return d.status==='pending';}).length;
|
|||
|
|
var overdue=DB.activities.filter(function(a){return a.endDate&&a.endDate<td&&a.status!=='completed';}).length;
|
|||
|
|
var in30=addDays(td,30);
|
|||
|
|
var _ps=DB.projectStart?new Date(DB.projectStart):new Date();
|
|||
|
|
var upcomingMils=DB.milestones.filter(function(m){
|
|||
|
|
var r=resolveMilDate(m,_ps);
|
|||
|
|
return r.ed&&r.ed>=new Date(td)&&r.ed<=new Date(in30)&&m.status!=='completed';
|
|||
|
|
}).length;
|
|||
|
|
var pmt=DB.committees.pmt, psc=DB.committees.psc;
|
|||
|
|
var lastMeeting=DB.meetings&&DB.meetings.length?DB.meetings[DB.meetings.length-1]:null;
|
|||
|
|
var n=DB.projectNarrative;
|
|||
|
|
|
|||
|
|
g('mainContent').innerHTML=
|
|||
|
|
// Hero scorecard
|
|||
|
|
'<div class="exec-hero">'
|
|||
|
|
+'<div class="exec-hero-top">'
|
|||
|
|
+'<div>'
|
|||
|
|
+'<div class="exec-hero-title">Pinnacle Decision Portal</div>'
|
|||
|
|
+'<div class="exec-hero-name">Project Executive Summary</div>'
|
|||
|
|
+'<div class="exec-hero-sub">As of '+new Date().toLocaleDateString('default',{weekday:'long',day:'numeric',month:'long',year:'numeric'})+'</div>'
|
|||
|
|
+'<div style="display:flex;align-items:center;gap:8px;margin-top:12px;flex-wrap:wrap">'
|
|||
|
|
+'<span><span class="rag-dot rag-'+h.rag+'"></span><span style="font-size:12px;color:rgba(255,255,255,.8);font-weight:700">'+h.ragLabel+'</span></span>'
|
|||
|
|
+'<span style="color:rgba(255,255,255,.2)">|</span>'
|
|||
|
|
+'<span style="font-size:11px;color:rgba(255,255,255,.5)">PMT Chair: '+(pmt.chair||'—')+'</span>'
|
|||
|
|
+'<span style="color:rgba(255,255,255,.2)">|</span>'
|
|||
|
|
+'<span style="font-size:11px;color:rgba(255,255,255,.5)">PSC Chair: '+(psc.chair||'—')+'</span>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="health-ring-wrap">'
|
|||
|
|
+'<div class="health-score-big" style="color:'+scoreColor+'">'+h.score+'</div>'
|
|||
|
|
+'<div class="health-score-lbl">Health Score</div>'
|
|||
|
|
+'<div class="health-trend" style="color:'+scoreColor+'">/ 100</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="exec-kpis">'
|
|||
|
|
+'<div class="exec-kpi"><div class="exec-kpi-val">'+doneMils+'/'+totalMils+'</div><div class="exec-kpi-lbl">Milestones Done</div></div>'
|
|||
|
|
+'<div class="exec-kpi"><div class="exec-kpi-val" style="color:'+(overdue>0?'#f43f5e':'#22c55e')+'">'+overdue+'</div><div class="exec-kpi-lbl">Overdue Items</div></div>'
|
|||
|
|
+'<div class="exec-kpi"><div class="exec-kpi-val" style="color:'+(pendDec>0?'#f59e0b':'#22c55e')+'">'+pendDec+'</div><div class="exec-kpi-lbl">Pending Decisions</div></div>'
|
|||
|
|
+'<div class="exec-kpi"><div class="exec-kpi-val" style="color:'+(openRisks>2?'#f43f5e':openRisks>0?'#f59e0b':'#22c55e')+'">'+openRisks+'</div><div class="exec-kpi-lbl">Open Risks</div></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
// Key message / narrative
|
|||
|
|
+'<div class="narrative-box">'
|
|||
|
|
+'<div class="nb-hdr">'
|
|||
|
|
+'<div class="nb-title"><i class="fas fa-pen-to-square tgold"></i> Management Commentary</div>'
|
|||
|
|
+(isEditor()?'<button class="btn btn-outline btn-sm" onclick="editNarrative()"><i class="fas fa-pen"></i> Update</button>':'')
|
|||
|
|
+'</div>'
|
|||
|
|
+(n.keyMessage
|
|||
|
|
?'<div class="narrative-text">'+n.keyMessage+'</div>'
|
|||
|
|
+(n.lastUpdated?'<div style="font-size:10px;color:var(--g400);margin-top:8px">Last updated: '+n.lastUpdated+' by '+(n.updatedBy||'—')+'</div>':'')
|
|||
|
|
:'<div class="narrative-text" style="color:var(--g300);font-style:italic">No message entered yet. '+(isEditor()?'Click Update to add the Management Commentary.':'Contact your Project Manager to add a board message.')+'</div>')
|
|||
|
|
+'</div>'
|
|||
|
|
// PM Weekly summary
|
|||
|
|
+'<div class="card"><div class="card-hdr"><span class="card-title"><i class="fas fa-calendar-week tgold"></i> This Week / Next Week</span>'
|
|||
|
|
+(isEditor()?'<button class="btn btn-outline btn-sm" onclick="editNarrative()"><i class="fas fa-pen"></i> Update</button>':'')
|
|||
|
|
+'</div><div class="card-body"><div class="fg-2">'
|
|||
|
|
+'<div class="fg"><div style="font-size:10px;font-weight:800;color:var(--navy);letter-spacing:.5px;text-transform:uppercase;margin-bottom:6px">This Week</div>'
|
|||
|
|
+'<div style="font-size:13px;color:var(--g600);line-height:1.7;min-height:50px">'+(n.thisWeek||'<span style="color:var(--g300);font-style:italic">Not yet updated</span>')+'</div></div>'
|
|||
|
|
+'<div class="fg"><div style="font-size:10px;font-weight:800;color:var(--navy);letter-spacing:.5px;text-transform:uppercase;margin-bottom:6px">Next Week</div>'
|
|||
|
|
+'<div style="font-size:13px;color:var(--g600);line-height:1.7;min-height:50px">'+(n.nextWeek||'<span style="color:var(--g300);font-style:italic">Not yet updated</span>')+'</div></div>'
|
|||
|
|
+'</div></div></div>'
|
|||
|
|
// Decisions Required
|
|||
|
|
+renderDecisionsRequired()
|
|||
|
|
// Upcoming milestones
|
|||
|
|
+'<div class="card"><div class="card-hdr"><span class="card-title"><i class="fas fa-flag-checkered tgold"></i> Milestones Due in Next 30 Days</span>'
|
|||
|
|
+'<button class="btn btn-outline btn-sm" onclick="showPage("phases")"><i class="fas fa-layer-group"></i> Full View</button>'
|
|||
|
|
+'</div><div class="card-body" style="padding:0">'
|
|||
|
|
+(function(){
|
|||
|
|
var td2=today();
|
|||
|
|
var in30=addDays(td2,30);
|
|||
|
|
var projStart=DB.projectStart?new Date(DB.projectStart):new Date();
|
|||
|
|
projStart=new Date(projStart.getFullYear(),projStart.getMonth(),1);
|
|||
|
|
function localResolve(m){return resolveMilDate(m,null);}
|
|||
|
|
var mils=DB.milestones.filter(function(m){
|
|||
|
|
var r=localResolve(m);
|
|||
|
|
return r.ed&&r.ed>=new Date(td2)&&r.ed<=new Date(in30)&&m.status!=='completed';
|
|||
|
|
}).sort(function(a,b){
|
|||
|
|
var ra=localResolve(a),rb=localResolve(b);
|
|||
|
|
return (ra.ed||0)-(rb.ed||0);
|
|||
|
|
});
|
|||
|
|
if(!mils.length)return '<div style="padding:20px;text-align:center;color:var(--g400)">No milestones due in the next 30 days.</div>';
|
|||
|
|
return mils.map(function(m){
|
|||
|
|
var ph=DB.phases.find(function(p){return p.id===m.phaseId;})||{num:'?'};
|
|||
|
|
var r=localResolve(m);
|
|||
|
|
var daysLeft=r.ed?Math.round((r.ed-new Date(td2))/(1000*60*60*24)):'-';
|
|||
|
|
var pscBadge=m.pscStatus==='approved'?'<span style="background:#dcfce7;color:#15803d;font-size:9px;font-weight:700;padding:1px 6px;border-radius:6px;margin-left:6px">PSC Approved</span>':m.pscStatus==='pending'?'<span style="background:#fee2e2;color:#991b1b;font-size:9px;font-weight:700;padding:1px 6px;border-radius:6px;margin-left:6px">PSC Pending</span>':'';
|
|||
|
|
return '<div style="display:flex;align-items:center;gap:12px;padding:10px 18px;border-bottom:1px solid var(--g100)">'
|
|||
|
|
+'<div style="width:40px;height:40px;border-radius:9px;background:var(--g50);border:1px solid var(--g200);display:flex;flex-direction:column;align-items:center;justify-content:center;flex-shrink:0">'
|
|||
|
|
+'<span style="font-size:16px;font-weight:900;color:var(--navy);line-height:1">'+daysLeft+'</span>'
|
|||
|
|
+'<span style="font-size:8px;color:var(--g400);font-weight:700">days</span>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div style="flex:1;min-width:0">'
|
|||
|
|
+'<div style="font-size:13px;font-weight:700;color:var(--navy);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">'+m.title+pscBadge+'</div>'
|
|||
|
|
+'<div style="font-size:11px;color:var(--g400);margin-top:2px">Phase '+ph.num+(r.ed?' · Due: '+r.ed.toLocaleDateString('default',{day:'numeric',month:'short',year:'numeric'}):'')+'</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div><span style="font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;background:var(--g100);color:var(--g600)">'+milestonePct(m.id)+'%</span></div>'
|
|||
|
|
+'</div>';
|
|||
|
|
}).join('');
|
|||
|
|
})()
|
|||
|
|
+'</div></div>'
|
|||
|
|
// SPI mini widget on executive page
|
|||
|
|
+(function(){
|
|||
|
|
var s=calcSPI();
|
|||
|
|
var spiCol=s.spi>=0.95?'#10b981':s.spi>=0.80?'#f59e0b':s.spi>=0.65?'#f97316':'#f43f5e';
|
|||
|
|
return '<div class="card" style="margin-bottom:16px"><div class="card-hdr">'
|
|||
|
|
+'<span class="card-title"><i class="fas fa-gauge-high tgold"></i> Schedule Performance</span>'
|
|||
|
|
+'<button class="btn btn-outline btn-sm" onclick="showPage("schedule")"><i class="fas fa-chart-line"></i> Full View</button>'
|
|||
|
|
+'</div><div class="card-body"><div style="display:flex;gap:20px;flex-wrap:wrap;align-items:center">'
|
|||
|
|
+'<div style="text-align:center;min-width:80px">'
|
|||
|
|
+'<div style="font-size:36px;font-weight:900;font-family:Playfair Display,serif;color:'+spiCol+';line-height:1">'+s.spi.toFixed(2)+'</div>'
|
|||
|
|
+'<div style="font-size:10px;font-weight:800;color:var(--g400);letter-spacing:.8px;text-transform:uppercase;margin-top:3px">SPI</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div style="flex:1;min-width:200px">'
|
|||
|
|
+'<div style="font-size:13px;font-weight:700;color:var(--navy);margin-bottom:4px">'+s.statusLabel+'</div>'
|
|||
|
|
+'<div style="font-size:12px;color:var(--g500)">Projected completion: <b style="color:var(--navy)">'+s.projEnd2.toLocaleDateString('default',{month:'long',year:'numeric'})+'</b></div>'
|
|||
|
|
+'<div style="font-size:12px;color:var(--g500);margin-top:2px">'+s.elapsedPct+'% of timeline elapsed · '+s.completePct+'% of milestones complete</div>'
|
|||
|
|
+(s.daysVariance>0?'<div style="font-size:11px;color:#b91c1c;font-weight:600;margin-top:4px"><i class="fas fa-clock"></i> '+s.daysVariance+' days behind schedule</div>':'<div style="font-size:11px;color:#15803d;font-weight:600;margin-top:4px"><i class="fas fa-check-circle"></i> On track</div>')
|
|||
|
|
+'</div>'
|
|||
|
|
+'<button class="btn btn-outline btn-sm" onclick="showPage("stakeholders")" style="flex-shrink:0"><i class="fas fa-handshake"></i> Stakeholders</button>'
|
|||
|
|
+'</div></div></div>';
|
|||
|
|
})()
|
|||
|
|
// Score breakdown
|
|||
|
|
+'<div class="card"><div class="card-hdr"><span class="card-title"><i class="fas fa-chart-bar tgold"></i> Health Score Breakdown</span></div><div class="card-body">'
|
|||
|
|
+[
|
|||
|
|
{label:'Milestone Completion',val:h.milPct,weight:'30%',col:'#6366f1'},
|
|||
|
|
{label:'Schedule Adherence',val:h.schedScore,weight:'25%',col:'#0ea5e9'},
|
|||
|
|
{label:'Decision Velocity',val:h.decScore,weight:'20%',col:'#10b981'},
|
|||
|
|
{label:'Risk Control',val:h.riskScore,weight:'15%',col:'#f59e0b'},
|
|||
|
|
{label:'PSC Approval Rate',val:h.pscScore,weight:'10%',col:'#ec4899'},
|
|||
|
|
].map(function(s){
|
|||
|
|
return '<div style="margin-bottom:12px">'
|
|||
|
|
+'<div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:4px">'
|
|||
|
|
+'<span style="font-weight:600;color:var(--navy)">'+s.label+'</span>'
|
|||
|
|
+'<span style="color:var(--g400);font-size:11px">Weight: '+s.weight+' <b style="color:var(--navy)">'+s.val+'</b></span>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div style="height:6px;background:var(--g100);border-radius:3px">'
|
|||
|
|
+'<div style="height:100%;width:'+s.val+'%;background:'+s.col+';border-radius:3px;transition:width .6s ease"></div>'
|
|||
|
|
+'</div></div>';
|
|||
|
|
}).join('')
|
|||
|
|
+'</div></div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function editNarrative(){
|
|||
|
|
if(!DB.projectNarrative)DB.projectNarrative={thisWeek:'',nextWeek:'',keyMessage:'',lastUpdated:'',updatedBy:''};
|
|||
|
|
var n=DB.projectNarrative;
|
|||
|
|
var m=document.createElement('div');
|
|||
|
|
m.className='overlay open';
|
|||
|
|
m.innerHTML='<div class="modal" style="max-width:580px">'
|
|||
|
|
+'<div class="modal-hdr"><span class="modal-title"><i class="fas fa-pen-to-square tgold"></i> Update Management Commentary</span>'
|
|||
|
|
+'<button class="modal-close" onclick="this.closest(".overlay").remove()">✕</button></div>'
|
|||
|
|
+'<div class="modal-body">'
|
|||
|
|
+'<div class="fg"><label>Management Commentary</label>'
|
|||
|
|
+'<textarea id="pmKeyMsg" rows="3" placeholder="Provide a concise management commentary on project status, key achievements, risks, and decisions required for the board...">'+n.keyMessage+'</textarea></div>'
|
|||
|
|
+'<div class="fg-2">'
|
|||
|
|
+'<div class="fg"><label>This Week — Achievements</label><textarea id="pmThisWeek" rows="3" placeholder="What was accomplished this week...">'+n.thisWeek+'</textarea></div>'
|
|||
|
|
+'<div class="fg"><label>Next Week — Planned</label><textarea id="pmNextWeek" rows="3" placeholder="What is planned for next week...">'+n.nextWeek+'</textarea></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="modal-foot">'
|
|||
|
|
+'<button class="btn btn-outline" onclick="this.closest(".overlay").remove()">Cancel</button>'
|
|||
|
|
+'<button class="btn btn-primary" onclick="saveNarrative()"><i class="fas fa-save"></i> Save Narrative</button>'
|
|||
|
|
+'</div></div>';
|
|||
|
|
document.body.appendChild(m);
|
|||
|
|
m.addEventListener('click',function(e){if(e.target===m)m.remove();});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveNarrative(){
|
|||
|
|
if(!DB.projectNarrative)DB.projectNarrative={};
|
|||
|
|
DB.projectNarrative.keyMessage=(document.getElementById('pmKeyMsg')||{}).value||'';
|
|||
|
|
DB.projectNarrative.thisWeek=(document.getElementById('pmThisWeek')||{}).value||'';
|
|||
|
|
DB.projectNarrative.nextWeek=(document.getElementById('pmNextWeek')||{}).value||'';
|
|||
|
|
DB.projectNarrative.lastUpdated=today();
|
|||
|
|
DB.projectNarrative.updatedBy=CU?CU.fullName||CU.username:'';
|
|||
|
|
saveDB(true,'Management Commentary updated by '+(CU?CU.fullName:''));
|
|||
|
|
document.querySelector('.overlay.open').remove();
|
|||
|
|
flash('Management Commentary updated successfully.');
|
|||
|
|
renderExecutive();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderMeetings(){
|
|||
|
|
if(!DB.meetings)DB.meetings=[];
|
|||
|
|
var pmt=DB.committees&&DB.committees.pmt?DB.committees.pmt:{name:'Project Management Team',abbr:'PMT'};
|
|||
|
|
var psc=DB.committees&&DB.committees.psc?DB.committees.psc:{name:'Project Steering Committee',abbr:'PSC'};
|
|||
|
|
|
|||
|
|
var meetingRows=DB.meetings.length?DB.meetings.slice().reverse().map(function(mt,i){
|
|||
|
|
var agendaRows=(mt.agenda||[]).map(function(ag,j){
|
|||
|
|
var stClass={agreed:'as-agreed',noted:'as-noted',deferred:'as-deferred',pending:'as-pending'}[ag.status]||'as-pending';
|
|||
|
|
var stLabel={agreed:'Agreed',noted:'Noted',deferred:'Deferred',pending:'Pending'}[ag.status]||'Pending';
|
|||
|
|
return '<div class="agenda-item">'
|
|||
|
|
+'<div class="agenda-num">'+(j+1)+'</div>'
|
|||
|
|
+'<div class="agenda-text">'+ag.item+'</div>'
|
|||
|
|
+'<span class="agenda-status '+stClass+'">'+stLabel+'</span>'
|
|||
|
|
+'</div>';
|
|||
|
|
}).join('');
|
|||
|
|
var decBox=mt.finalDecision?'<div class="meeting-decision-box"><h5><i class="fas fa-gavel"></i> Final Decision / Resolution</h5><p>'+mt.finalDecision+'</p></div>':'';
|
|||
|
|
var typeClass=mt.type==='PSC'?'mtb-psc':'mtb-pmt';
|
|||
|
|
return '<div class="meeting-card">'
|
|||
|
|
+'<div class="meeting-card-hdr" onclick="this.nextElementSibling.classList.toggle("open")">'
|
|||
|
|
+'<div style="display:flex;align-items:center;gap:10px;min-width:0">'
|
|||
|
|
+'<span class="meeting-type-badge '+typeClass+'">'+mt.type+'</span>'
|
|||
|
|
+'<div style="min-width:0">'
|
|||
|
|
+'<div style="font-size:13px;font-weight:700;color:var(--navy);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">'+mt.title+'</div>'
|
|||
|
|
+'<div style="font-size:11px;color:var(--g400)">'+mt.date+(mt.chair?' · Chaired by: '+mt.chair:'')+(mt.attendees?' · '+mt.attendees+' attendees':'')+'</div>'
|
|||
|
|
+'</div></div>'
|
|||
|
|
+'<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">'
|
|||
|
|
+(isAdmin()?'<button class="btn btn-outline btn-xs" onclick="event.stopPropagation();editMeeting('+mt.id+')"><i class="fas fa-pen"></i></button>':'')
|
|||
|
|
+'<i class="fas fa-chevron-down" style="font-size:11px;color:var(--g400);transition:transform .2s"></i>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="meeting-body">'
|
|||
|
|
+(mt.notes?'<div style="font-size:12.5px;color:var(--g600);padding:10px 0 6px;line-height:1.6;border-bottom:1px solid var(--g100)">'+mt.notes+'</div>':'')
|
|||
|
|
+(agendaRows?'<div style="margin-top:10px;font-size:10px;font-weight:800;color:var(--g400);letter-spacing:.8px;text-transform:uppercase;margin-bottom:6px">Agenda Items</div>'+agendaRows:'')
|
|||
|
|
+decBox
|
|||
|
|
+'</div>'
|
|||
|
|
+'</div>';
|
|||
|
|
}).join('')
|
|||
|
|
:'<div style="padding:32px;text-align:center;color:var(--g400)"><i class="fas fa-calendar-plus" style="font-size:32px;display:block;margin-bottom:10px;opacity:.3"></i>No meetings recorded yet. Click Add Meeting to log your first PMT or PSC meeting.</div>';
|
|||
|
|
|
|||
|
|
g('mainContent').innerHTML=
|
|||
|
|
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:8px">'
|
|||
|
|
+'<div style="display:flex;gap:8px">'
|
|||
|
|
+'<div style="background:#dbeafe;color:#1d4ed8;font-size:11px;font-weight:700;padding:4px 12px;border-radius:20px"><i class="fas fa-users-gear"></i> PMT: '+DB.meetings.filter(function(m){return m.type==='PMT';}).length+' meetings</div>'
|
|||
|
|
+'<div style="background:#ede9fe;color:#6d28d9;font-size:11px;font-weight:700;padding:4px 12px;border-radius:20px"><i class="fas fa-people-roof"></i> PSC: '+DB.meetings.filter(function(m){return m.type==='PSC';}).length+' meetings</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+(isEditor()?'<button class="btn btn-primary" onclick="openAddMeeting()"><i class="fas fa-plus"></i> Add Meeting</button>':'')
|
|||
|
|
+'</div>'
|
|||
|
|
+meetingRows;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openAddMeeting(editId){
|
|||
|
|
var mt=editId?DB.meetings.find(function(x){return x.id===editId;}):null;
|
|||
|
|
var agendaLines=mt&&mt.agenda?mt.agenda.map(function(a){return a.item;}).join('\n'):'';
|
|||
|
|
var m=document.createElement('div');
|
|||
|
|
m.className='overlay open';
|
|||
|
|
m.innerHTML='<div class="modal" style="max-width:600px">'
|
|||
|
|
+'<div class="modal-hdr"><span class="modal-title"><i class="fas fa-calendar-plus tgold"></i> '+(mt?'Edit':'Add')+' Meeting</span>'
|
|||
|
|
+'<button class="modal-close" onclick="this.closest(".overlay").remove()">✕</button></div>'
|
|||
|
|
+'<div class="modal-body">'
|
|||
|
|
+'<div class="fg-2">'
|
|||
|
|
+'<div class="fg"><label>Committee</label><select id="mtType"><option'+(mt&&mt.type==='PMT'?' selected':'')+'>PMT</option><option'+(mt&&mt.type==='PSC'?' selected':'')+'>PSC</option></select></div>'
|
|||
|
|
+'<div class="fg"><label>Meeting Date</label><input type="date" id="mtDate" value="'+(mt?mt.date:today())+'"></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="fg"><label>Meeting Title</label><input type="text" id="mtTitle" placeholder="e.g. PMT Weekly #12 / PSC Q2 Review" value="'+(mt?mt.title:'')+'"></div>'
|
|||
|
|
+'<div class="fg-2">'
|
|||
|
|
+'<div class="fg"><label>Chaired By</label><input type="text" id="mtChair" placeholder="Name of chair" value="'+(mt?mt.chair||'':'')+'"></div>'
|
|||
|
|
+'<div class="fg"><label>No. of Attendees</label><input type="number" id="mtAttendees" min="1" value="'+(mt?mt.attendees||'':'')+'"></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="fg"><label>Meeting Notes / Context</label><textarea id="mtNotes" rows="2" placeholder="Brief context or summary...">'+((mt&&mt.notes)||'')+'</textarea></div>'
|
|||
|
|
+'<div class="fg"><label>Agenda Items (one per line)</label><textarea id="mtAgenda" rows="5" placeholder="Item 1: Project status update | Item 2: Risk review | Item 3: Budget approval...">'+agendaLines+'</textarea></div>'
|
|||
|
|
+'<div class="fg"><label>Final Decision / Resolution</label><textarea id="mtDecision" rows="2" placeholder="Summary of key decisions made in this meeting...">'+((mt&&mt.finalDecision)||'')+'</textarea></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="modal-foot">'
|
|||
|
|
+'<button class="btn btn-outline" onclick="this.closest(".overlay").remove()">Cancel</button>'
|
|||
|
|
+'<button class="btn btn-primary" onclick="saveMeeting('+(mt?mt.id:'null')+')"><i class="fas fa-save"></i> Save Meeting</button>'
|
|||
|
|
+'</div></div>';
|
|||
|
|
document.body.appendChild(m);
|
|||
|
|
m.addEventListener('click',function(e){if(e.target===m)m.remove();});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function editMeeting(id){openAddMeeting(id);}
|
|||
|
|
|
|||
|
|
function saveMeeting(editId){
|
|||
|
|
if(!DB.meetings)DB.meetings=[];
|
|||
|
|
if(!DB.ids.meeting)DB.ids.meeting=1;
|
|||
|
|
var type=(document.getElementById('mtType')||{}).value||'PMT';
|
|||
|
|
var date=(document.getElementById('mtDate')||{}).value||today();
|
|||
|
|
var title=(document.getElementById('mtTitle')||{}).value||'Meeting';
|
|||
|
|
var chair=(document.getElementById('mtChair')||{}).value||'';
|
|||
|
|
var attendees=parseInt((document.getElementById('mtAttendees')||{}).value)||0;
|
|||
|
|
var notes=(document.getElementById('mtNotes')||{}).value||'';
|
|||
|
|
var agendaRaw=(document.getElementById('mtAgenda')||{}).value||'';
|
|||
|
|
var decision=(document.getElementById('mtDecision')||{}).value||'';
|
|||
|
|
var agendaItems=agendaRaw.split('\n').filter(function(s){return s.trim();}).map(function(s){
|
|||
|
|
return{item:s.trim(),status:'pending'};
|
|||
|
|
});
|
|||
|
|
if(editId&&editId!=='null'){
|
|||
|
|
var ex=DB.meetings.find(function(x){return x.id===editId;});
|
|||
|
|
if(ex){
|
|||
|
|
ex.type=type;ex.date=date;ex.title=title;ex.chair=chair;ex.attendees=attendees;
|
|||
|
|
ex.notes=notes;ex.agenda=agendaItems;ex.finalDecision=decision;
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
DB.meetings.push({id:DB.ids.meeting++,type:type,date:date,title:title,chair:chair,
|
|||
|
|
attendees:attendees,notes:notes,agenda:agendaItems,finalDecision:decision,createdBy:CU?CU.username:''});
|
|||
|
|
}
|
|||
|
|
saveDB(true,type+' meeting "'+title+'" recorded by '+(CU?CU.fullName:''));
|
|||
|
|
document.querySelector('.overlay.open').remove();
|
|||
|
|
flash(type+' meeting saved successfully.');
|
|||
|
|
renderMeetings();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── SCHEDULE PERFORMANCE INDEX & PROJECTED COMPLETION ── */
|
|||
|
|
function calcSPI(){
|
|||
|
|
var ps=DB.projectStart?new Date(DB.projectStart):new Date();
|
|||
|
|
ps=new Date(ps.getFullYear(),ps.getMonth(),1);
|
|||
|
|
var durationMonths=DB.projectDuration||12;
|
|||
|
|
var projEnd=new Date(ps.getFullYear(),ps.getMonth()+durationMonths,0);
|
|||
|
|
var now=new Date();
|
|||
|
|
var totalMs=projEnd-ps;
|
|||
|
|
var elapsedMs=Math.max(0,Math.min(totalMs,now-ps));
|
|||
|
|
var elapsedPct=Math.round(elapsedMs/totalMs*100);
|
|||
|
|
var totalMils=DB.milestones.length||1;
|
|||
|
|
var doneMils=DB.milestones.filter(function(m){return m.status==='completed';}).length;
|
|||
|
|
var completePct=Math.round(doneMils/totalMils*100);
|
|||
|
|
var totalActs=DB.activities.length||1;
|
|||
|
|
var doneActs=DB.activities.filter(function(a){return a.status==='completed';}).length;
|
|||
|
|
var actPct=Math.round(doneActs/totalActs*100);
|
|||
|
|
// SPI = work done % / time elapsed %
|
|||
|
|
var spi=elapsedPct>0?Math.round((completePct/elapsedPct)*100)/100:1;
|
|||
|
|
// Projected completion
|
|||
|
|
var projMonths=spi>0?Math.round(durationMonths/spi):durationMonths*2;
|
|||
|
|
var projEnd2=new Date(ps.getFullYear(),ps.getMonth()+projMonths,0);
|
|||
|
|
var daysVariance=Math.round((projEnd2-projEnd)/(1000*60*60*24));
|
|||
|
|
var monthsRemaining=Math.max(0,Math.round((projEnd2-now)/(1000*60*60*24*30)));
|
|||
|
|
return{
|
|||
|
|
spi:spi,elapsedPct:elapsedPct,completePct:completePct,actPct:actPct,
|
|||
|
|
projEnd:projEnd,projEnd2:projEnd2,daysVariance:daysVariance,
|
|||
|
|
monthsRemaining:monthsRemaining,durationMonths:durationMonths,
|
|||
|
|
projStart:ps,doneMils:doneMils,totalMils:totalMils,
|
|||
|
|
status:spi>=0.95?'on_track':spi>=0.80?'minor_delay':spi>=0.65?'at_risk':'critical',
|
|||
|
|
statusLabel:spi>=0.95?'On Track':spi>=0.80?'Minor Delay':spi>=0.65?'At Risk':'Critical Delay'
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderSchedule(){
|
|||
|
|
if(!DB.projectDuration)DB.projectDuration=12;
|
|||
|
|
var s=calcSPI();
|
|||
|
|
var spiColor=s.spi>=0.95?'#10b981':s.spi>=0.80?'#f59e0b':s.spi>=0.65?'#f97316':'#f43f5e';
|
|||
|
|
var spiClass=s.spi>=0.95?'green':s.spi>=0.80?'amber':s.spi>=0.65?'amber':'red';
|
|||
|
|
var varClass=s.daysVariance<=0?'vb-green':s.daysVariance<=60?'vb-amber':'vb-red';
|
|||
|
|
var varIcon=s.daysVariance<=0?'fa-check-circle':s.daysVariance<=60?'fa-exclamation-triangle':'fa-times-circle';
|
|||
|
|
var varMsg=s.daysVariance<=0?'Project is projected to complete on or before the planned end date'
|
|||
|
|
:'Project is projected to be '+s.daysVariance+' days ('+(Math.round(s.daysVariance/30.4))+' months) behind schedule';
|
|||
|
|
// Phase completion breakdown
|
|||
|
|
var phaseRows=DB.phases.map(function(p){
|
|||
|
|
var mils=DB.milestones.filter(function(m){return m.phaseId===p.id;});
|
|||
|
|
var done=mils.filter(function(m){return m.status==='completed';}).length;
|
|||
|
|
var pct=mils.length?Math.round(done/mils.length*100):0;
|
|||
|
|
var acts=DB.activities.filter(function(a){return DB.milestones.some(function(m){return m.phaseId===p.id&&m.id===a.milestoneId;});});
|
|||
|
|
var actsDone=acts.filter(function(a){return a.status==='completed';}).length;
|
|||
|
|
var td=today();
|
|||
|
|
var overdue=acts.filter(function(a){return a.endDate&&a.endDate<td&&a.status!=='completed';}).length;
|
|||
|
|
var col=pct===100?'#10b981':pct>50?'#6366f1':pct>0?'#f59e0b':'#e2e8f0';
|
|||
|
|
return '<div style="margin-bottom:14px">'
|
|||
|
|
+'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:5px">'
|
|||
|
|
+'<div style="font-size:12.5px;font-weight:700;color:var(--navy)">Phase '+p.num+': '+(p.name.length>40?p.name.slice(0,38)+'...':p.name)+'</div>'
|
|||
|
|
+'<div style="display:flex;align-items:center;gap:8px">'
|
|||
|
|
+(overdue?'<span style="font-size:10px;color:#b91c1c;font-weight:700"><i class="fas fa-exclamation-circle"></i> '+overdue+' overdue</span>':'')
|
|||
|
|
+'<span style="font-size:12px;font-weight:900;color:'+col+'">'+pct+'%</span>'
|
|||
|
|
+'</div></div>'
|
|||
|
|
+'<div style="height:8px;background:var(--g100);border-radius:4px;overflow:hidden">'
|
|||
|
|
+'<div style="height:100%;width:'+pct+'%;background:'+col+';border-radius:4px;transition:width .8s ease"></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--g400);margin-top:3px">'
|
|||
|
|
+'<span>'+done+'/'+mils.length+' milestones · '+actsDone+'/'+acts.length+' activities</span>'
|
|||
|
|
+'<span>'+(pct===100?'<span style="color:#10b981;font-weight:700">Complete</span>':pct>0?'In Progress':'Not Started')+'</span>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'</div>';
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
g('mainContent').innerHTML=
|
|||
|
|
// Project settings bar
|
|||
|
|
(isAdmin()?'<div style="background:#fff;border:1px solid var(--g200);border-radius:10px;padding:12px 16px;margin-bottom:16px;display:flex;align-items:center;gap:16px;flex-wrap:wrap">'
|
|||
|
|
+'<div style="font-size:12px;font-weight:700;color:var(--navy)"><i class="fas fa-cog" style="color:var(--gold)"></i> Project Settings</div>'
|
|||
|
|
+'<div style="display:flex;align-items:center;gap:8px;font-size:12px"><label style="color:var(--g500);font-weight:600">Start:</label>'
|
|||
|
|
+'<input type="month" id="spStartIn" value="'+(DB.projectStart?DB.projectStart.slice(0,7):new Date().toISOString().slice(0,7))+'" style="font-size:11px;padding:3px 8px;border:1px solid var(--g200);border-radius:6px" onchange="updateProjStart(this.value)"></div>'
|
|||
|
|
+'<div style="display:flex;align-items:center;gap:8px;font-size:12px"><label style="color:var(--g500);font-weight:600">Duration (months):</label>'
|
|||
|
|
+'<input type="number" id="spDurIn" value="'+(DB.projectDuration||12)+'" min="1" max="120" style="font-size:11px;padding:3px 8px;border:1px solid var(--g200);border-radius:6px;width:70px" onchange="updateProjDuration(this.value)"></div>'
|
|||
|
|
+'<div style="font-size:11px;color:var(--g400)">Planned end: '+s.projEnd.toLocaleDateString('default',{month:'long',year:'numeric'})+'</div>'
|
|||
|
|
+'</div>':'')
|
|||
|
|
// SPI + projected completion cards
|
|||
|
|
+'<div class="spi-wrap">'
|
|||
|
|
// SPI Card
|
|||
|
|
+'<div class="spi-card '+spiClass+'">'
|
|||
|
|
+'<div class="spi-label">Schedule Performance Index (SPI)</div>'
|
|||
|
|
+'<div class="spi-value" style="color:'+spiColor+'">'+s.spi.toFixed(2)+'</div>'
|
|||
|
|
+'<div class="spi-sub">'
|
|||
|
|
+(s.spi>=0.95?'<i class="fas fa-check-circle" style="color:#10b981"></i> Project is on schedule'
|
|||
|
|
:s.spi>=0.80?'<i class="fas fa-exclamation-triangle" style="color:#f59e0b"></i> Minor delay — monitor closely'
|
|||
|
|
:s.spi>=0.65?'<i class="fas fa-exclamation-circle" style="color:#f97316"></i> At risk — corrective action needed'
|
|||
|
|
:'<i class="fas fa-times-circle" style="color:#f43f5e"></i> Critical delay — immediate intervention required')
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="spi-bar-wrap">'
|
|||
|
|
+'<div class="spi-bar-labels"><span>0.0</span><span>0.5</span><span style="font-weight:800;color:var(--navy)">1.0 Target</span><span>1.5</span></div>'
|
|||
|
|
+'<div class="spi-bar-track">'
|
|||
|
|
+'<div class="spi-bar-fill" style="width:'+Math.min(100,Math.round(s.spi/1.5*100))+'%;background:'+spiColor+'"></div>'
|
|||
|
|
+'<div class="spi-bar-needle" style="left:'+(100/1.5)+'%;background:#0f172a"></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div style="font-size:9px;color:var(--g400);margin-top:3px">SPI = Work Completed % ÷ Time Elapsed % | Target ≥ 1.0</div>'
|
|||
|
|
+'</div></div>'
|
|||
|
|
// Projected Completion Card
|
|||
|
|
+'<div class="spi-card blue">'
|
|||
|
|
+'<div class="spi-label">Projected Completion</div>'
|
|||
|
|
+'<div class="spi-value" style="color:#6366f1;font-size:26px">'+s.projEnd2.toLocaleDateString('default',{month:'long',year:'numeric'})+'</div>'
|
|||
|
|
+'<div class="spi-sub" style="margin-top:6px">'
|
|||
|
|
+'<span style="font-weight:700;color:var(--navy)">'+s.monthsRemaining+' months</span> remaining at current pace'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="proj-badges" style="margin-top:10px">'
|
|||
|
|
+'<span class="proj-badge pb-blue"><i class="fas fa-calendar"></i> Planned: '+s.projEnd.toLocaleDateString('default',{month:'short',year:'numeric'})+'</span>'
|
|||
|
|
+'<span class="proj-badge '+(s.daysVariance<=0?'pb-green':s.daysVariance<=60?'pb-amber':'pb-red')+'">'
|
|||
|
|
+(s.daysVariance<=0?'<i class="fas fa-check"></i> On time':'<i class="fas fa-clock"></i> +'+s.daysVariance+' days')
|
|||
|
|
+'</span>'
|
|||
|
|
+'</div></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
// Variance explanation box
|
|||
|
|
+'<div class="variance-box '+varClass+'" style="margin-bottom:16px">'
|
|||
|
|
+'<i class="fas '+varIcon+'" style="font-size:18px;flex-shrink:0"></i>'
|
|||
|
|
+'<div><div style="font-weight:700;font-size:13px">'+s.statusLabel+'</div>'
|
|||
|
|
+'<div style="font-size:12px;margin-top:2px;opacity:.85">'+varMsg+'</div></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
// Visual timeline
|
|||
|
|
+'<div class="proj-timeline">'
|
|||
|
|
+'<div class="proj-tl-hdr">'
|
|||
|
|
+'<div class="proj-tl-title"><i class="fas fa-timeline tgold"></i> Project Timeline</div>'
|
|||
|
|
+'<div style="display:flex;gap:10px;font-size:11px;flex-wrap:wrap">'
|
|||
|
|
+'<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#6366f1;margin-right:4px"></span>Time elapsed</span>'
|
|||
|
|
+'<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#10b981;margin-right:4px"></span>Work complete</span>'
|
|||
|
|
+'</div></div>'
|
|||
|
|
+'<div class="proj-tl-track">'
|
|||
|
|
+'<div class="proj-tl-elapsed" style="width:'+s.elapsedPct+'%"></div>'
|
|||
|
|
+'<div class="proj-tl-done" style="width:'+s.completePct+'%"></div>'
|
|||
|
|
+'<div class="proj-tl-needle" style="left:'+s.elapsedPct+'%"></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--g400);margin-top:4px">'
|
|||
|
|
+'<span>'+s.projStart.toLocaleDateString('default',{month:'short',year:'numeric'})+'</span>'
|
|||
|
|
+'<span>'+s.projEnd.toLocaleDateString('default',{month:'short',year:'numeric'})+'</span>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div style="display:flex;gap:16px;margin-top:12px;flex-wrap:wrap">'
|
|||
|
|
+'<div style="font-size:12px"><span style="font-weight:800;color:#6366f1">'+s.elapsedPct+'%</span> <span style="color:var(--g500)">time elapsed</span></div>'
|
|||
|
|
+'<div style="font-size:12px"><span style="font-weight:800;color:#10b981">'+s.completePct+'%</span> <span style="color:var(--g500)">milestones complete</span></div>'
|
|||
|
|
+'<div style="font-size:12px"><span style="font-weight:800;color:#f59e0b">'+s.actPct+'%</span> <span style="color:var(--g500)">activities complete</span></div>'
|
|||
|
|
+'</div></div>'
|
|||
|
|
// Phase breakdown
|
|||
|
|
+'<div class="card"><div class="card-hdr"><span class="card-title"><i class="fas fa-layer-group tgold"></i> Phase-by-Phase Completion</span></div>'
|
|||
|
|
+'<div class="card-body">'+phaseRows+'</div></div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateProjDuration(val){
|
|||
|
|
var v=parseInt(val);
|
|||
|
|
if(!v||v<1)return;
|
|||
|
|
DB.projectDuration=v;
|
|||
|
|
saveDB();
|
|||
|
|
renderSchedule();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── STAKEHOLDER ENGAGEMENT TRACKER ── */
|
|||
|
|
function renderStakeholders(){
|
|||
|
|
if(!DB.stakeholders)DB.stakeholders=[];
|
|||
|
|
var filter=window._shFilter||'all';
|
|||
|
|
var types=['board','regulatory','vendor','internal','external'];
|
|||
|
|
var typeLabels={board:'Board',regulatory:'Regulatory',vendor:'Vendor',internal:'Internal',external:'External'};
|
|||
|
|
var typeCols={board:'#7c3aed',regulatory:'#be185d',vendor:'#15803d',internal:'#1d4ed8',external:'#b45309'};
|
|||
|
|
var typeClasses={board:'st-board',regulatory:'st-regulatory',vendor:'st-vendor',internal:'st-internal',external:'st-external'};
|
|||
|
|
var engColors={high:'#22c55e',medium:'#f59e0b',low:'#ef4444',inactive:'#9ca3af'};
|
|||
|
|
|
|||
|
|
function engLabel(level){return level>=75?'High':level>=50?'Medium':level>0?'Low':'Inactive';}
|
|||
|
|
function engStatus(level){return level>=75?'high':level>=50?'medium':level>0?'low':'inactive';}
|
|||
|
|
|
|||
|
|
// Summary tiles
|
|||
|
|
var high=DB.stakeholders.filter(function(s){return s.engagementLevel>=75;}).length;
|
|||
|
|
var med=DB.stakeholders.filter(function(s){return s.engagementLevel>=50&&s.engagementLevel<75;}).length;
|
|||
|
|
var low=DB.stakeholders.filter(function(s){return s.engagementLevel<50&&s.engagementLevel>0;}).length;
|
|||
|
|
var inactive=DB.stakeholders.filter(function(s){return s.engagementLevel===0||s.status==='inactive';}).length;
|
|||
|
|
|
|||
|
|
var filtered=filter==='all'?DB.stakeholders:DB.stakeholders.filter(function(s){return s.type===filter;});
|
|||
|
|
|
|||
|
|
var cards=filtered.map(function(sh){
|
|||
|
|
var col=typeCols[sh.type]||'#64748b';
|
|||
|
|
var tcls=typeClasses[sh.type]||'st-external';
|
|||
|
|
var eLevel=sh.engagementLevel||0;
|
|||
|
|
var eStat=engStatus(eLevel);
|
|||
|
|
var eCol=engColors[eStat];
|
|||
|
|
var initials=sh.name.split(' ').map(function(w){return w[0];}).join('').slice(0,2).toUpperCase();
|
|||
|
|
var phaseNames=(sh.linkedPhases||[]).map(function(pid){
|
|||
|
|
var p=DB.phases.find(function(x){return x.id===pid;});
|
|||
|
|
return p?'Ph.'+p.num:'';
|
|||
|
|
}).filter(Boolean).join(', ');
|
|||
|
|
return '<div class="sh-card">'
|
|||
|
|
+'<div class="sh-card-top">'
|
|||
|
|
+'<div class="sh-avatar" style="background:'+col+'">'+initials+'</div>'
|
|||
|
|
+'<div style="flex:1;min-width:0">'
|
|||
|
|
+'<div class="sh-name">'+sh.name+'</div>'
|
|||
|
|
+'<div class="sh-org">'+sh.org+'</div>'
|
|||
|
|
+'<span class="sh-type '+tcls+'">'+typeLabels[sh.type]+'</span>'
|
|||
|
|
+'</div>'
|
|||
|
|
+(isEditor()?'<button class="btn btn-outline btn-xs" style="flex-shrink:0" onclick="editStakeholder('+sh.id+')"><i class="fas fa-pen"></i></button>':'')
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="sh-card-bottom">'
|
|||
|
|
+'<div style="font-size:11px;color:var(--g600);margin-bottom:6px">'+sh.role+'</div>'
|
|||
|
|
+'<div style="font-size:10px;font-weight:700;color:var(--g400);margin-bottom:3px;display:flex;justify-content:space-between">'
|
|||
|
|
+'<span>Engagement Level</span><span style="color:'+eCol+';font-weight:800">'+eLevel+'% — '+engLabel(eLevel)+'</span>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="sh-engagement-bar">'
|
|||
|
|
+'<div class="sh-engagement-fill" style="width:'+eLevel+'%;background:'+eCol+'"></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="sh-meta">'
|
|||
|
|
+'<span><span class="sh-status-dot eng-'+eStat+'"></span>'+sh.status.charAt(0).toUpperCase()+sh.status.slice(1)+'</span>'
|
|||
|
|
+(phaseNames?'<span style="font-size:10px;color:var(--g400)">'+phaseNames+'</span>':'')
|
|||
|
|
+'</div>'
|
|||
|
|
+(sh.notes?'<div style="font-size:10.5px;color:var(--g500);margin-top:6px;padding-top:6px;border-top:1px solid var(--g100);font-style:italic">'+sh.notes+'</div>':'')
|
|||
|
|
+(sh.owner?'<div style="font-size:10px;color:var(--g400);margin-top:5px"><i class="fas fa-user-tie" style="font-size:9px"></i> Relationship owner: <b>'+sh.owner+'</b></div>':'')
|
|||
|
|
+'</div></div>';
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
g('mainContent').innerHTML=
|
|||
|
|
'<div class="sh-summary-row">'
|
|||
|
|
+'<div class="sh-sum-tile"><div class="sh-sum-val">'+DB.stakeholders.length+'</div><div class="sh-sum-lbl">Total</div></div>'
|
|||
|
|
+'<div class="sh-sum-tile"><div class="sh-sum-val" style="color:#22c55e">'+high+'</div><div class="sh-sum-lbl">High Engaged</div></div>'
|
|||
|
|
+'<div class="sh-sum-tile"><div class="sh-sum-val" style="color:#f59e0b">'+med+'</div><div class="sh-sum-lbl">Medium</div></div>'
|
|||
|
|
+'<div class="sh-sum-tile"><div class="sh-sum-val" style="color:#ef4444">'+low+'</div><div class="sh-sum-lbl">Low Engaged</div></div>'
|
|||
|
|
+'<div class="sh-sum-tile"><div class="sh-sum-val" style="color:#9ca3af">'+inactive+'</div><div class="sh-sum-lbl">Inactive</div></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="sh-filter-bar">'
|
|||
|
|
+'<span style="font-size:11px;font-weight:700;color:var(--g500)">Filter:</span>'
|
|||
|
|
+'<button class="sh-filter-btn'+(filter==='all'?' active':'')+'" onclick="shFilter("all")">All ('+DB.stakeholders.length+')</button>'
|
|||
|
|
+types.map(function(t){
|
|||
|
|
var cnt=DB.stakeholders.filter(function(s){return s.type===t;}).length;
|
|||
|
|
if(!cnt)return '';
|
|||
|
|
return '<button class="sh-filter-btn'+(filter===t?' active':'')+'" onclick="shFilter("'+t+'")">'+typeLabels[t]+' ('+cnt+')</button>';
|
|||
|
|
}).join('')
|
|||
|
|
+(isEditor()?'<button class="btn btn-primary btn-sm" style="margin-left:auto" onclick="editStakeholder(null)"><i class="fas fa-plus"></i> Add Stakeholder</button>':'')
|
|||
|
|
+'</div>'
|
|||
|
|
+(cards?'<div class="sh-grid">'+cards+'</div>'
|
|||
|
|
:'<div style="text-align:center;padding:32px;color:var(--g400)"><i class="fas fa-handshake" style="font-size:32px;display:block;margin-bottom:10px;opacity:.3"></i>No stakeholders found.</div>');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function shFilter(type){
|
|||
|
|
window._shFilter=type;
|
|||
|
|
renderStakeholders();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function editStakeholder(id){
|
|||
|
|
if(!DB.stakeholders)DB.stakeholders=[];
|
|||
|
|
var sh=id?DB.stakeholders.find(function(x){return x.id===id;}):null;
|
|||
|
|
var phOpts=DB.phases.map(function(p){
|
|||
|
|
var sel=sh&&sh.linkedPhases&&sh.linkedPhases.indexOf(p.id)>=0?' selected':'';
|
|||
|
|
return '<option value="'+p.id+'"'+sel+'>Phase '+p.num+': '+p.name.slice(0,30)+'</option>';
|
|||
|
|
}).join('');
|
|||
|
|
var m=document.createElement('div');
|
|||
|
|
m.className='overlay open';
|
|||
|
|
m.innerHTML='<div class="modal" style="max-width:520px">'
|
|||
|
|
+'<div class="modal-hdr"><span class="modal-title"><i class="fas fa-handshake tgold"></i> '+(sh?'Edit':'Add')+' Stakeholder</span>'
|
|||
|
|
+'<button class="modal-close" onclick="this.closest(".overlay").remove()">✕</button></div>'
|
|||
|
|
+'<div class="modal-body">'
|
|||
|
|
+'<div class="fg-2">'
|
|||
|
|
+'<div class="fg"><label>Name / Organisation</label><input type="text" id="shName" value="'+(sh?sh.name:'')+'" placeholder="e.g. Central Bank"></div>'
|
|||
|
|
+'<div class="fg"><label>Organisation / Department</label><input type="text" id="shOrg" value="'+(sh?sh.org:'')+'" placeholder="e.g. State Bank of Pakistan"></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="fg-2">'
|
|||
|
|
+'<div class="fg"><label>Type</label><select id="shType">'
|
|||
|
|
+['board','regulatory','vendor','internal','external'].map(function(t){return '<option value="'+t+'"'+(sh&&sh.type===t?' selected':'')+'>'+t.charAt(0).toUpperCase()+t.slice(1)+'</option>';}).join('')
|
|||
|
|
+'</select></div>'
|
|||
|
|
+'<div class="fg"><label>Status</label><select id="shStatus">'
|
|||
|
|
+['active','monitoring','at_risk','inactive'].map(function(t){return '<option value="'+t+'"'+(sh&&sh.status===t?' selected':'')+'>'+t.replace('_',' ').replace(/\w/g,function(c){return c.toUpperCase();})+'</option>';}).join('')
|
|||
|
|
+'</select></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="fg"><label>Role / Relevance to Project</label><input type="text" id="shRole" value="'+(sh?sh.role:'')+'" placeholder="e.g. Regulatory approval authority"></div>'
|
|||
|
|
+'<div class="fg-2">'
|
|||
|
|
+'<div class="fg"><label>Engagement Level (0–100)</label><input type="range" id="shEng" min="0" max="100" value="'+(sh?sh.engagementLevel:50)+'" oninput="document.getElementById("shEngVal").textContent=this.value+"%""><span id="shEngVal">'+(sh?sh.engagementLevel:50)+'%</span></div>'
|
|||
|
|
+'<div class="fg"><label>Relationship Owner</label><input type="text" id="shOwner" value="'+(sh?sh.owner||'':'')+'" placeholder="e.g. Chief Risk Officer"></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="fg"><label>Linked Phases (hold Ctrl to select multiple)</label>'
|
|||
|
|
+'<select id="shPhases" multiple style="height:90px">'+phOpts+'</select></div>'
|
|||
|
|
+'<div class="fg-2">'
|
|||
|
|
+'<div class="fg"><label>Last Contact Date</label><input type="date" id="shLast" value="'+(sh&&sh.lastContact?sh.lastContact:'')+'"></div>'
|
|||
|
|
+'<div class="fg"><label>Next Contact Date</label><input type="date" id="shNext" value="'+(sh&&sh.nextContact?sh.nextContact:'')+'"></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="fg"><label>Notes</label><textarea id="shNotes" rows="2">'+((sh&&sh.notes)||'')+'</textarea></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="modal-foot">'
|
|||
|
|
+(sh&&isAdmin()?'<button class="btn btn-danger btn-sm" onclick="deleteStakeholder('+sh.id+')"><i class="fas fa-trash"></i> Delete</button>':'')
|
|||
|
|
+'<button class="btn btn-outline" onclick="this.closest(".overlay").remove()">Cancel</button>'
|
|||
|
|
+'<button class="btn btn-primary" onclick="saveStakeholder('+(sh?sh.id:'null')+')"><i class="fas fa-save"></i> Save</button>'
|
|||
|
|
+'</div></div>';
|
|||
|
|
document.body.appendChild(m);
|
|||
|
|
m.addEventListener('click',function(e){if(e.target===m)m.remove();});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveStakeholder(editId){
|
|||
|
|
if(!DB.stakeholders)DB.stakeholders=[];
|
|||
|
|
if(!DB.ids.stakeholder)DB.ids.stakeholder=100;
|
|||
|
|
var name=(document.getElementById('shName')||{}).value||'';
|
|||
|
|
var org=(document.getElementById('shOrg')||{}).value||'';
|
|||
|
|
var type=(document.getElementById('shType')||{}).value||'internal';
|
|||
|
|
var status=(document.getElementById('shStatus')||{}).value||'active';
|
|||
|
|
var role=(document.getElementById('shRole')||{}).value||'';
|
|||
|
|
var eng=parseInt((document.getElementById('shEng')||{}).value)||50;
|
|||
|
|
var owner=(document.getElementById('shOwner')||{}).value||'';
|
|||
|
|
var notes=(document.getElementById('shNotes')||{}).value||'';
|
|||
|
|
var last=(document.getElementById('shLast')||{}).value||'';
|
|||
|
|
var next=(document.getElementById('shNext')||{}).value||'';
|
|||
|
|
var phSel=document.getElementById('shPhases');
|
|||
|
|
var phases=phSel?Array.from(phSel.selectedOptions).map(function(o){return parseInt(o.value);}):[];
|
|||
|
|
if(!name){flash('Please enter a stakeholder name.','danger');return;}
|
|||
|
|
if(editId&&editId!=='null'){
|
|||
|
|
var ex=DB.stakeholders.find(function(x){return x.id===editId;});
|
|||
|
|
if(ex){ex.name=name;ex.org=org;ex.type=type;ex.status=status;ex.role=role;ex.engagementLevel=eng;ex.owner=owner;ex.notes=notes;ex.lastContact=last;ex.nextContact=next;ex.linkedPhases=phases;}
|
|||
|
|
} else {
|
|||
|
|
DB.stakeholders.push({id:DB.ids.stakeholder++,name:name,org:org,type:type,status:status,role:role,engagementLevel:eng,owner:owner,notes:notes,lastContact:last,nextContact:next,linkedPhases:phases});
|
|||
|
|
}
|
|||
|
|
saveDB(true,'Stakeholder "'+name+'" '+(editId&&editId!=='null'?'updated':'added')+' by '+(CU?CU.fullName:''));
|
|||
|
|
document.querySelector('.overlay.open').remove();
|
|||
|
|
flash('Stakeholder saved.');
|
|||
|
|
renderStakeholders();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function deleteStakeholder(id){
|
|||
|
|
if(!confirm('Delete this stakeholder?'))return;
|
|||
|
|
DB.stakeholders=DB.stakeholders.filter(function(s){return s.id!==id;});
|
|||
|
|
saveDB();
|
|||
|
|
document.querySelector('.overlay.open').remove();
|
|||
|
|
flash('Stakeholder deleted.');
|
|||
|
|
renderStakeholders();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderCommittees(){
|
|||
|
|
if(!DB.committees)DB.committees={pmt:{name:'Project Management Team',abbr:'PMT',chair:'Project Manager',meetingCadence:'Weekly',members:[],responsibilities:['Activity oversight','Milestone review & endorsement','Risk monitoring & escalation','Progress reporting to PSC','Resource management']},psc:{name:'Project Steering Committee',abbr:'PSC',chair:'Executive Sponsor',meetingCadence:'Monthly',members:[],responsibilities:['Milestone approval','Deliverable sign-off','Budget approval','Strategic decisions','Issue escalation resolution']}};
|
|||
|
|
var pmt=DB.committees.pmt, psc=DB.committees.psc;
|
|||
|
|
|
|||
|
|
// Build approval workflow table rows for milestones
|
|||
|
|
var milRows=DB.milestones.map(function(m){
|
|||
|
|
var ph=DB.phases.find(function(p){return p.id===m.phaseId;})||{num:'?'};
|
|||
|
|
var pmtSt=m.pmtStatus||'not_reviewed';
|
|||
|
|
var pscSt=m.pscStatus||'not_required';
|
|||
|
|
var pmtPill=pmtSt==='endorsed'?'sp-approved':pmtSt==='escalated'||pmtSt==='returned'?'sp-rejected':pmtSt==='under_review'?'sp-pending':'sp-not-required';
|
|||
|
|
var pscPill=pscSt==='approved'?'sp-approved':pscSt==='rejected'?'sp-rejected':pscSt==='pending'?'sp-pending':'sp-not-required';
|
|||
|
|
var pmtLbl=pmtSt.replace(/_/g,' ').replace(/\w/g,function(c){return c.toUpperCase();});
|
|||
|
|
var pscLbl=pscSt.replace(/_/g,' ').replace(/\w/g,function(c){return c.toUpperCase();});
|
|||
|
|
return '<tr>'
|
|||
|
|
+'<td><span style="font-size:10px;background:var(--g100);padding:2px 7px;border-radius:4px;font-weight:700">Ph.'+ph.num+'</span></td>'
|
|||
|
|
+'<td style="font-weight:600;color:var(--navy)">'+m.title+'</td>'
|
|||
|
|
+'<td><span style="font-size:10px;color:var(--g400)">Month '+(m.month||'-')+'</span></td>'
|
|||
|
|
+'<td>'
|
|||
|
|
+'<span class="status-pill '+pmtPill+'">'+pmtLbl+'</span>'
|
|||
|
|
+(isEditor()?'<select onchange="updateMilCommittee('+m.id+',"pmt",this.value)" style="font-size:10px;margin-left:6px;padding:2px 4px;border:1px solid var(--g200);border-radius:4px">'
|
|||
|
|
+['not_reviewed','under_review','endorsed','escalated','returned'].map(function(s){return '<option value="'+s+'"'+(pmtSt===s?' selected':'')+'>'+s.replace(/_/g,' ').replace(/\b\w/g,function(c){return c.toUpperCase();})+'</option>';}).join('')
|
|||
|
|
+'</select>':'')
|
|||
|
|
+'</td>'
|
|||
|
|
+'<td>'
|
|||
|
|
+'<span class="status-pill '+pscPill+'">'+pscLbl+'</span>'
|
|||
|
|
+(isEditor()?'<select onchange="updateMilCommittee('+m.id+',"psc",this.value)" style="font-size:10px;margin-left:6px;padding:2px 4px;border:1px solid var(--g200);border-radius:4px">'
|
|||
|
|
+['not_required','pending','approved','rejected'].map(function(s){return '<option value="'+s+'"'+(pscSt===s?' selected':'')+'>'+s.replace(/_/g,' ')+'</option>';}).join('')
|
|||
|
|
+'</select>':'')
|
|||
|
|
+'</td>'
|
|||
|
|
+'</tr>';
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
// Decision rows awaiting PSC
|
|||
|
|
var decRows=DB.decisions.filter(function(d){return d.status==='pending';}).map(function(d){
|
|||
|
|
return '<tr>'
|
|||
|
|
+'<td style="font-weight:600;color:var(--navy)">'+d.title+'</td>'
|
|||
|
|
+'<td><span class="status-pill sp-pending">Awaiting PSC</span></td>'
|
|||
|
|
+'<td style="font-size:11px;color:var(--g500)">'+(d.owner||'—')+'</td>'
|
|||
|
|
+'<td style="font-size:11px;color:var(--g400)">'+(d.dateRaised||'—')+'</td>'
|
|||
|
|
+(isEditor()?'<td><button class="btn btn-outline btn-xs" onclick="approveDecisionPSC('+d.id+')"><i class="fas fa-check"></i> PSC Approve</button></td>':'<td></td>')
|
|||
|
|
+'</tr>';
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
// Workflow diagram for a milestone journey
|
|||
|
|
var wfSteps=[
|
|||
|
|
{label:'PMT Review',icon:'fas fa-users-gear',desc:'PMT reviews milestone readiness'},
|
|||
|
|
{label:'PMT Endorsed',icon:'fas fa-thumbs-up',desc:'PMT endorses for PSC'},
|
|||
|
|
{label:'PSC Review',icon:'fas fa-people-roof',desc:'PSC reviews for approval'},
|
|||
|
|
{label:'PSC Approved',icon:'fas fa-circle-check',desc:'Formal approval granted'},
|
|||
|
|
{label:'Implemented',icon:'fas fa-rocket',desc:'Milestone executed'}
|
|||
|
|
];
|
|||
|
|
var wfHTML='<div class="workflow-line">';
|
|||
|
|
wfSteps.forEach(function(s,i){
|
|||
|
|
if(i>0)wfHTML+='<div class="wf-arrow done"></div>';
|
|||
|
|
wfHTML+='<div class="wf-step"><div class="wf-dot done"><i class="'+s.icon+'"></i></div><div class="wf-lbl">'+s.label+'</div></div>';
|
|||
|
|
});
|
|||
|
|
wfHTML+='</div>';
|
|||
|
|
|
|||
|
|
g('mainContent').innerHTML=
|
|||
|
|
// Committee cards
|
|||
|
|
'<div class="committee-banner" style="margin-bottom:20px">'
|
|||
|
|
+'<div class="committee-card pmt" style="cursor:default">'
|
|||
|
|
+'<div class="committee-hdr"><div class="committee-icon"><i class="fas fa-users-gear"></i></div>'
|
|||
|
|
+'<div><div class="committee-name">'+pmt.name+' (PMT)</div>'
|
|||
|
|
+'<div class="committee-role">Chair: <b style="color:rgba(255,255,255,.8)">'+pmt.chair+'</b> · '+pmt.meetingCadence+' meetings</div>'
|
|||
|
|
+'</div></div>'
|
|||
|
|
+'<div style="font-size:11.5px;color:rgba(255,255,255,.6);line-height:1.6;margin-bottom:10px">'+(DB.committees.pmt.role||'')+'</div>'
|
|||
|
|
+'<div style="font-size:10px;color:rgba(255,255,255,.45);font-weight:700;letter-spacing:.5px;text-transform:uppercase;margin-bottom:6px">Responsibilities</div>'
|
|||
|
|
+'<div style="display:flex;flex-wrap:wrap;gap:5px">'
|
|||
|
|
+(pmt.responsibilities||[]).map(function(r){return '<span style="background:rgba(96,165,250,.15);color:#93c5fd;font-size:10px;padding:2px 8px;border-radius:10px;border:1px solid rgba(96,165,250,.2)">'+r+'</span>';}).join('')
|
|||
|
|
+'</div>'
|
|||
|
|
+(isAdmin()?'<div style="margin-top:12px"><button class="btn btn-outline btn-sm" style="border-color:rgba(255,255,255,.2);color:rgba(255,255,255,.7)" onclick="editCommittee("pmt")"><i class="fas fa-pen"></i> Edit PMT</button></div>':'')
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="committee-card psc" style="cursor:default">'
|
|||
|
|
+'<div class="committee-hdr"><div class="committee-icon"><i class="fas fa-people-roof"></i></div>'
|
|||
|
|
+'<div><div class="committee-name">'+psc.name+' (PSC)</div>'
|
|||
|
|
+'<div class="committee-role">Chair: <b style="color:rgba(255,255,255,.8)">'+psc.chair+'</b> · '+psc.meetingCadence+' meetings</div>'
|
|||
|
|
+'</div></div>'
|
|||
|
|
+'<div style="font-size:11.5px;color:rgba(255,255,255,.6);line-height:1.6;margin-bottom:10px">'+(DB.committees.psc.role||'')+'</div>'
|
|||
|
|
+'<div style="font-size:10px;color:rgba(255,255,255,.45);font-weight:700;letter-spacing:.5px;text-transform:uppercase;margin-bottom:6px">Responsibilities</div>'
|
|||
|
|
+'<div style="display:flex;flex-wrap:wrap;gap:5px">'
|
|||
|
|
+(psc.responsibilities||[]).map(function(r){return '<span style="background:rgba(167,139,250,.15);color:#c4b5fd;font-size:10px;padding:2px 8px;border-radius:10px;border:1px solid rgba(167,139,250,.2)">'+r+'</span>';}).join('')
|
|||
|
|
+'</div>'
|
|||
|
|
+(isAdmin()?'<div style="margin-top:12px"><button class="btn btn-outline btn-sm" style="border-color:rgba(255,255,255,.2);color:rgba(255,255,255,.7)" onclick="editCommittee("psc")"><i class="fas fa-pen"></i> Edit PSC</button></div>':'')
|
|||
|
|
+'</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
// Approval workflow
|
|||
|
|
+'<div class="committee-section">'
|
|||
|
|
+'<div class="committee-section-hdr">'
|
|||
|
|
+'<div class="committee-section-title"><i class="fas fa-diagram-project tgold"></i> Milestone Approval Workflow</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+wfHTML
|
|||
|
|
+'</div>'
|
|||
|
|
// Milestone approval table
|
|||
|
|
+'<div class="committee-section">'
|
|||
|
|
+'<div class="committee-section-hdr">'
|
|||
|
|
+'<div class="committee-section-title"><i class="fas fa-list-check tgold"></i> Milestone Approval Status</div>'
|
|||
|
|
+'<div style="font-size:11px;color:var(--g400)">PMT reviews · PSC approves</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="tbl-wrap"><table class="approval-table"><thead><tr>'
|
|||
|
|
+'<th>Phase</th><th>Milestone</th><th>Timeline</th>'
|
|||
|
|
+'<th><i class="fas fa-users-gear" style="color:#3b82f6"></i> PMT Review</th>'
|
|||
|
|
+'<th><i class="fas fa-people-roof" style="color:#7c3aed"></i> PSC Status</th>'
|
|||
|
|
+'</tr></thead><tbody>'+(milRows||'<tr><td colspan="5" style="text-align:center;padding:20px;color:var(--g400)">No milestones found</td></tr>')+'</tbody></table></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
// Pending PSC decisions
|
|||
|
|
+(decRows?'<div class="committee-section">'
|
|||
|
|
+'<div class="committee-section-hdr">'
|
|||
|
|
+'<div class="committee-section-title"><i class="fas fa-gavel tgold"></i> Decisions Awaiting PSC Approval</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="tbl-wrap"><table class="approval-table"><thead><tr>'
|
|||
|
|
+'<th>Decision</th><th>Status</th><th>Owner</th><th>Raised</th><th>Action</th>'
|
|||
|
|
+'</tr></thead><tbody>'+decRows+'</tbody></table></div>'
|
|||
|
|
+'</div>':'');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateMilCommittee(milId, committee, status){
|
|||
|
|
var m=DB.milestones.find(function(x){return x.id===milId;});
|
|||
|
|
if(!m)return;
|
|||
|
|
if(committee==='pmt') m.pmtStatus=status;
|
|||
|
|
else m.pscStatus=status;
|
|||
|
|
saveDB(true, committee.toUpperCase()+' updated "'+m.title+'" to '+status);
|
|||
|
|
flash(committee.toUpperCase()+' status updated to "'+status+'" for: '+m.title);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function approveDecisionPSC(decId){
|
|||
|
|
var d=DB.decisions.find(function(x){return x.id===decId;});
|
|||
|
|
if(!d)return;
|
|||
|
|
d.status='approved'; d.approvedBy='PSC'; d.approvedDate=today();
|
|||
|
|
saveDB(true,'PSC approved decision: '+d.title);
|
|||
|
|
flash('PSC approval recorded for: '+d.title,'success');
|
|||
|
|
renderCommittees();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function editCommittee(type){
|
|||
|
|
var c=DB.committees[type];
|
|||
|
|
if(!c)return;
|
|||
|
|
var col=type==='pmt'?'#3b82f6':'#7c3aed';
|
|||
|
|
var m=document.createElement('div');
|
|||
|
|
m.className='overlay open';
|
|||
|
|
m.innerHTML='<div class="modal" style="max-width:500px">'
|
|||
|
|
+'<div class="modal-hdr" style="border-bottom:2px solid '+col+'20">'
|
|||
|
|
+'<span class="modal-title" style="color:'+col+'"><i class="fas fa-'+(type==='pmt'?'users-gear':'people-roof')+'"></i> Edit '+c.abbr+'</span>'
|
|||
|
|
+'<button class="modal-close" onclick="this.closest(".overlay").remove()">✕</button></div>'
|
|||
|
|
+'<div class="modal-body">'
|
|||
|
|
+'<div class="fg"><label>Committee Name</label><input type="text" id="cName" value="'+c.name+'"></div>'
|
|||
|
|
+'<div class="fg-2">'
|
|||
|
|
+'<div class="fg"><label>Chair / Lead</label><input type="text" id="cChair" value="'+(c.chair||'')+'"></div>'
|
|||
|
|
+'<div class="fg"><label>Meeting Cadence</label><select id="cCadence">'
|
|||
|
|
+['Daily','Weekly','Bi-weekly','Monthly','Quarterly'].map(function(o){return '<option'+(c.meetingCadence===o?' selected':'')+'>'+o+'</option>';}).join('')
|
|||
|
|
+'</select></div></div>'
|
|||
|
|
+'<div class="fg"><label>Role Description</label><textarea id="cRole" rows="2">'+c.role+'</textarea></div>'
|
|||
|
|
+'<div class="fg"><label>Members (comma-separated names)</label><input type="text" id="cMembers" value="'+(c.members||[]).join(', ')+'"></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="modal-foot">'
|
|||
|
|
+'<button class="btn btn-outline" onclick="this.closest(".overlay").remove()">Cancel</button>'
|
|||
|
|
+'<button class="btn btn-primary" onclick="saveCommittee("'+type+'")"><i class="fas fa-save"></i> Save</button>'
|
|||
|
|
+'</div></div>';
|
|||
|
|
document.body.appendChild(m);
|
|||
|
|
m.addEventListener('click',function(e){if(e.target===m)m.remove();});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveCommittee(type){
|
|||
|
|
if(!DB.committees)DB.committees={};
|
|||
|
|
if(!DB.committees[type])DB.committees[type]={};
|
|||
|
|
var c=DB.committees[type];
|
|||
|
|
c.name=(document.getElementById('cName')||{}).value||c.name;
|
|||
|
|
c.chair=(document.getElementById('cChair')||{}).value||c.chair;
|
|||
|
|
c.meetingCadence=(document.getElementById('cCadence')||{}).value||c.meetingCadence;
|
|||
|
|
c.role=(document.getElementById('cRole')||{}).value||c.role;
|
|||
|
|
var memStr=(document.getElementById('cMembers')||{}).value||'';
|
|||
|
|
c.members=memStr.split(',').map(function(s){return s.trim();}).filter(Boolean);
|
|||
|
|
saveDB(true, type.toUpperCase()+' committee updated by '+CU.fullName);
|
|||
|
|
document.querySelector('.overlay.open').remove();
|
|||
|
|
flash(type.toUpperCase()+' committee updated successfully.');
|
|||
|
|
renderCommittees();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderDashboard(){
|
|||
|
|
var acts=DB.activities,total=acts.length,done=acts.filter(function(a){return a.status==='completed';}).length;
|
|||
|
|
var td=today();
|
|||
|
|
var delayed=acts.filter(function(a){return a.endDate&&a.endDate<td&&a.status!=='completed';}).length;
|
|||
|
|
var inWeek=acts.filter(function(a){return a.endDate&&a.endDate>=td&&a.endDate<=addDays(td,7)&&a.status!=='completed';}).length;
|
|||
|
|
var pendD=DB.decisions.filter(function(d){return d.status==='pending';}).length;
|
|||
|
|
var openR=DB.risks.filter(function(r){return r.status==='open';}).length;
|
|||
|
|
var pendA=DB.deliverables.filter(function(d){return d.approvalStatus==='pending';}).length;
|
|||
|
|
var pscPend=DB.milestones.filter(function(m){return m.pscStatus==='pending';}).length;
|
|||
|
|
var overall=pct(total,done);
|
|||
|
|
var phases=DB.phases.slice().sort(function(a,b){return a.order-b.order;});
|
|||
|
|
// Determine active phase: first phase that is not 0% and not 100%
|
|||
|
|
var activePhaseId=null;
|
|||
|
|
for(var _pi=0;_pi<phases.length;_pi++){
|
|||
|
|
var _pp2=phasePct(phases[_pi].id);
|
|||
|
|
if(_pp2>0&&_pp2<100){activePhaseId=phases[_pi].id;break;}
|
|||
|
|
}
|
|||
|
|
// If none in-progress, pick first incomplete
|
|||
|
|
if(activePhaseId===null){
|
|||
|
|
for(var _pi2=0;_pi2<phases.length;_pi2++){
|
|||
|
|
if(phasePct(phases[_pi2].id)<100){activePhaseId=phases[_pi2].id;break;}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
var phCards=phases.map(function(p,i){
|
|||
|
|
var pp=phasePct(p.id);
|
|||
|
|
var mils=DB.milestones.filter(function(m){return m.phaseId===p.id;});
|
|||
|
|
var mDone=mils.filter(function(m){var a=DB.activities.filter(function(x){return x.milestoneId===m.id;});return a.length&&a.every(function(x){return x.status==='completed';});}).length;
|
|||
|
|
var isActive=p.id===activePhaseId;
|
|||
|
|
return '<div class="phase-card'+(isActive?' active-phase':'')+'" style="--pc:'+PC[i%PC.length]+'" onclick="showPage(\'phaseDetail\','+p.id+')">'
|
|||
|
|
+'<div style="font-size:10px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:'+PC[i%PC.length]+';margin-bottom:4px">Phase '+p.num+'</div>'
|
|||
|
|
+'<div style="font-family:\"Playfair Display\",serif;font-size:14px;font-weight:600;color:var(--navy);margin-bottom:11px;line-height:1.3">'+p.name+'</div>'
|
|||
|
|
+'<div class="prog-wrap"><div class="prog-bar" style="width:'+pp+'%;background:'+PC[i%PC.length]+'"></div></div>'
|
|||
|
|
+'<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--g400);margin-top:8px"><span>'+mDone+'/'+mils.length+' milestones</span><span style="font-weight:700;color:'+PC[i%PC.length]+'">'+pp+'%</span></div>'
|
|||
|
|
+'</div>';
|
|||
|
|
}).join('');
|
|||
|
|
var logs=[].concat(DB.logs).reverse().slice(0,12).map(function(l){
|
|||
|
|
var u=DB.users.find(function(x){return x.id===l.userId;});
|
|||
|
|
return '<tr><td class="muted fs12">'+l.timestamp.slice(0,16).replace('T',' ')+'</td>'
|
|||
|
|
+'<td><span class="badge b-navy" style="font-size:10px">'+(u?u.username:'System')+'</span></td>'
|
|||
|
|
+'<td><span class="badge b-gold" style="font-size:10px">'+l.action+'</span></td>'
|
|||
|
|
+'<td class="muted fs12">'+(l.entityName||'—')+'</td></tr>';
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
g('mainContent').innerHTML=
|
|||
|
|
renderBlockers()
|
|||
|
|
+renderCommitteeBanner()
|
|||
|
|
+'<div class="stat-grid">'
|
|||
|
|
+'<div class="stat-card" style="--sc:#c9a227;--scb:#f5e9c0;cursor:pointer" onclick="showKpiModal(0)"><div class="stat-icon"><i class="fas fa-chart-pie"></i></div><div><div class="stat-val">'+overall+'%</div><div class="stat-lbl">Overall Completion</div></div></div>'
|
|||
|
|
+'<div class="stat-card" style="--sc:#15803d;--scb:#dcfce7;cursor:pointer" onclick="showKpiModal(1)"><div class="stat-icon"><i class="fas fa-check-double"></i></div><div><div class="stat-val">'+done+'</div><div class="stat-lbl">Completed Activities</div></div></div>'
|
|||
|
|
+'<div class="stat-card" style="--sc:#b91c1c;--scb:#fee2e2;cursor:pointer" onclick="showKpiModal(2)"><div class="stat-icon"><i class="fas fa-clock"></i></div><div><div class="stat-val">'+delayed+'</div><div class="stat-lbl">Delayed Tasks</div></div></div>'
|
|||
|
|
+'<div class="stat-card" style="--sc:#d97706;--scb:#fef3c7;cursor:pointer" onclick="showKpiModal(3)"><div class="stat-icon"><i class="fas fa-hourglass-half"></i></div><div><div class="stat-val">'+inWeek+'</div><div class="stat-lbl">Due This Week</div></div></div>'
|
|||
|
|
+'<div class="stat-card" style="--sc:#1d4ed8;--scb:#dbeafe;cursor:pointer" onclick="showKpiModal(4)"><div class="stat-icon"><i class="fas fa-gavel"></i></div><div><div class="stat-val">'+pendD+'</div><div class="stat-lbl">Pending Decisions</div></div></div>'
|
|||
|
|
+'<div class="stat-card" style="--sc:#7c3aed;--scb:#ede9fe;cursor:pointer" onclick="showPage(\'committees\')"><div class="stat-icon"><i class="fas fa-people-roof"></i></div><div><div class="stat-val">'+pscPend+'</div><div class="stat-lbl">PSC Pending</div></div></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="card"><div class="card-hdr"><span class="card-title"><i class="fas fa-bullseye tgold"></i> Project Overall Progress</span><span style="font-size:20px;font-weight:700;color:var(--navy)">'+overall+'%</span></div>'
|
|||
|
|
+'<div class="card-body" style="padding:14px 17px"><div class="prog-wrap" style="height:13px"><div class="prog-bar '+(overall>=75?'ps':overall<30?'pd':'')+'" style="width:'+overall+'%"></div></div>'
|
|||
|
|
+'<div class="flex jb mt-2 fs12 muted"><span>'+done+' of '+total+' activities completed</span><span>'+(total-done)+' remaining</span></div></div></div>'
|
|||
|
|
+'<div class="flex jb ic mb-3 mt-4"><h2 style="font-family:\'Playfair Display\',serif;font-size:16px;color:var(--navy)">Phase Progress</h2><button class="btn btn-outline btn-sm" onclick="showPage(\'phases\')"><i class="fas fa-layer-group"></i> All Phases</button></div>'
|
|||
|
|
+'<div class="phase-grid mb-4">'+(phCards||'<div class="empty" style="grid-column:1/-1"><i class="fas fa-layer-group"></i><p>No phases yet.</p></div>')+'</div>'
|
|||
|
|
+'<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-bottom:18px">'
|
|||
|
|
+'<div class="card" style="margin-bottom:0"><div class="card-hdr"><span class="card-title"><i class="fas fa-stream tgold"></i> Project Timeline (Gantt)</span>'
|
|||
|
|
+'<div style="display:flex;align-items:center;gap:8px">'
|
|||
|
|
+'<label style="font-size:11px;color:var(--g400);font-weight:600;white-space:nowrap">Project Start:</label>'
|
|||
|
|
+'<input type="month" id="projStartInput" value="'+(DB.projectStart?DB.projectStart.slice(0,7):new Date().toISOString().slice(0,7))+'" style="font-size:11px;padding:3px 6px;border:1px solid var(--g200);border-radius:5px;color:var(--navy)" onchange="updateProjStart(this.value)">'
|
|||
|
|
+'</div>'
|
|||
|
|
+'</div><div class="card-body" style="padding:0" id="ganttBox"></div></div>'
|
|||
|
|
+'<div class="card" style="margin-bottom:0"><div class="card-hdr"><span class="card-title"><i class="fas fa-chart-pie tgold"></i> Activity Breakdown</span></div><div class="card-body" style="padding:16px 18px"><div style="position:relative;width:100%;height:230px"><canvas id="sch"></canvas><div class="chart-centre-wrap" id="chartCentre"><div class="chart-centre-val" id="chartCentreVal">-</div><div class="chart-centre-lbl" id="chartCentreLbl">TOTAL</div></div></div><div class="stat-tiles" id="actStatTiles" style="margin-top:14px"></div></div></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="card" id="upcomingCard"><div class="card-hdr"><span class="card-title"><i class="fas fa-calendar-days tgold"></i> Upcoming Activities <span style="font-size:11px;font-weight:400;color:var(--g400);margin-left:6px">Next 30 days</span></span></div>'+'<div class="card-body" style="padding:0 17px 4px" id="upcomingList"></div></div>'+'<div class="card"><div class="card-hdr"><span class="card-title"><i class="fas fa-history tgold"></i> Recent Activity</span>'+(isAdmin()?'<button class="btn btn-outline btn-sm" onclick="showPage(\'logs\')">Full Log</button>':'')+'</div>'
|
|||
|
|
+'<div class="card-body" style="padding:0"><div class="tbl-wrap"><table><thead><tr><th>Time</th><th>User</th><th>Action</th><th>Entity</th></tr></thead><tbody>'+(logs||'<tr><td colspan="4" style="text-align:center;padding:22px;color:var(--g400)">No activity yet</td></tr>')+'</tbody></table></div></div></div>';
|
|||
|
|
|
|||
|
|
renderGantt(phases);
|
|||
|
|
// Destroy any stale chart instance on this canvas
|
|||
|
|
var schEl=g('sch'); if(!schEl){console.warn('canvas #sch not found');return;}
|
|||
|
|
var existingChart=Chart.getChart(schEl);
|
|||
|
|
if(existingChart){existingChart.destroy();}
|
|||
|
|
if(sc){try{sc.destroy();}catch(e){}sc=null;}
|
|||
|
|
var inp=Math.max(0,total-done-delayed);
|
|||
|
|
var cv=g('chartCentreVal'); if(cv)cv.textContent=total;
|
|||
|
|
var notStarted2=Math.max(0,total-done-inp-delayed);
|
|||
|
|
var COLORS=['rgba(16,185,129,.92)','rgba(99,102,241,.92)','rgba(244,63,94,.92)','rgba(148,163,184,.85)'];
|
|||
|
|
var COLORS_H=['#059669','#4f46e5','#e11d48','#64748b'];
|
|||
|
|
var CLABELS=['Completed','In Progress','Delayed','Not Started'];
|
|||
|
|
var CVALS=[done,inp,delayed,notStarted2];
|
|||
|
|
sc=new Chart(schEl,{type:'doughnut',
|
|||
|
|
data:{labels:CLABELS,datasets:[{
|
|||
|
|
data:CVALS,
|
|||
|
|
backgroundColor:COLORS,
|
|||
|
|
hoverBackgroundColor:COLORS_H,
|
|||
|
|
borderWidth:4,borderColor:'#fff',hoverBorderColor:'#fff',hoverBorderWidth:4,
|
|||
|
|
hoverOffset:14,borderRadius:6,spacing:3
|
|||
|
|
}]},
|
|||
|
|
options:{responsive:true,maintainAspectRatio:false,
|
|||
|
|
animation:{animateRotate:true,animateScale:true,duration:950,easing:'easeOutQuart'},
|
|||
|
|
plugins:{
|
|||
|
|
legend:{position:'bottom',labels:{font:{size:11,weight:'700'},padding:16,usePointStyle:true,pointStyleWidth:12,color:'#475569'}},
|
|||
|
|
tooltip:{backgroundColor:'#0f172a',titleColor:'#fff',bodyColor:'rgba(255,255,255,.8)',padding:12,cornerRadius:10,boxWidth:10,boxHeight:10,
|
|||
|
|
callbacks:{title:function(i){return i[0].label;},label:function(ctx){var v=ctx.raw||0,t=total||1;return ' '+v+' ('+Math.round(v/t*100)+'%)';}}}
|
|||
|
|
},
|
|||
|
|
cutout:'70%',
|
|||
|
|
onHover:function(e,els){
|
|||
|
|
var cv2=g('chartCentreVal'),cl2=g('chartCentreLbl');
|
|||
|
|
if(!cv2||!cl2)return;
|
|||
|
|
if(els.length){
|
|||
|
|
var i=els[0].index;
|
|||
|
|
cv2.textContent=CVALS[i]; cv2.style.color=COLORS_H[i];
|
|||
|
|
cl2.textContent=CLABELS[i].toUpperCase();
|
|||
|
|
}else{
|
|||
|
|
cv2.textContent=total; cv2.style.color='#0f172a';
|
|||
|
|
cl2.textContent='TOTAL';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
renderUpcomingActivities();
|
|||
|
|
renderActivityStatTiles(total,done,inp,delayed);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderActivityStatTiles(total,done,inp,delayed){
|
|||
|
|
if(!g('actStatTiles'))return;
|
|||
|
|
var box=g('actStatTiles'); if(!box)return;
|
|||
|
|
var notStarted=Math.max(0,total-done-inp-delayed);
|
|||
|
|
var pctDone=total?Math.round(done/total*100):0;
|
|||
|
|
var pctInp=total?Math.round(inp/total*100):0;
|
|||
|
|
var pctDel=total?Math.round(delayed/total*100):0;
|
|||
|
|
var pctNs=total?Math.round(notStarted/total*100):0;
|
|||
|
|
var tiles=[
|
|||
|
|
{val:total, lbl:'Total', sub:'All in scope', pct:100, bg:'linear-gradient(135deg,#eef2ff,#e0e7ff)',border:'#c7d2fe',ic:'fas fa-layer-group', icBg:'linear-gradient(135deg,#6366f1,#4f46e5)',bar:'#6366f1'},
|
|||
|
|
{val:done, lbl:'Completed', sub:pctDone+'% done', pct:pctDone, bg:'linear-gradient(135deg,#ecfdf5,#d1fae5)',border:'#a7f3d0',ic:'fas fa-circle-check', icBg:'linear-gradient(135deg,#10b981,#059669)',bar:'#10b981'},
|
|||
|
|
{val:inp, lbl:'In Progress', sub:pctInp+'% active', pct:pctInp, bg:'linear-gradient(135deg,#eff6ff,#dbeafe)',border:'#bfdbfe',ic:'fas fa-bolt', icBg:'linear-gradient(135deg,#3b82f6,#2563eb)',bar:'#3b82f6'},
|
|||
|
|
{val:delayed, lbl:'Overdue', sub:pctDel+'% late', pct:pctDel, bg:'linear-gradient(135deg,#fff1f2,#ffe4e6)',border:'#fecdd3',ic:'fas fa-circle-exclamation',icBg:'linear-gradient(135deg,#f43f5e,#e11d48)',bar:'#f43f5e'},
|
|||
|
|
];
|
|||
|
|
box.innerHTML=tiles.map(function(t){
|
|||
|
|
return '<div class="stat-tile" style="background:'+t.bg+';border-color:'+t.border+'">'
|
|||
|
|
+'<div class="stat-tile-icon" style="background:'+t.icBg+'"><i class="'+t.ic+'"></i></div>'
|
|||
|
|
+'<div style="flex:1;min-width:0">'
|
|||
|
|
+'<div class="stat-tile-val">'+t.val+'</div>'
|
|||
|
|
+'<div class="stat-tile-lbl">'+t.lbl+'</div>'
|
|||
|
|
+'<div style="height:3px;border-radius:2px;background:#e2e8f0;margin-top:6px">'
|
|||
|
|
+'<div style="height:100%;border-radius:2px;background:'+t.bar+';width:'+Math.min(100,t.pct)+'%;transition:width .6s ease"></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="stat-tile-pct" style="color:'+t.bar+'">'+t.sub+'</div>'
|
|||
|
|
+'</div></div>';
|
|||
|
|
}).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderUpcomingActivities(){
|
|||
|
|
var box=g('upcomingList'); if(!box)return;
|
|||
|
|
var td=today();
|
|||
|
|
var in30=addDays(td,30);
|
|||
|
|
var statusColors={completed:'#15803d',delayed:'#b91c1c',in_progress:'#1d4ed8',not_started:'#6b7280'};
|
|||
|
|
var upcoming=DB.activities.filter(function(a){
|
|||
|
|
return a.endDate&&a.endDate>=td&&a.endDate<=in30&&a.status!=='completed';
|
|||
|
|
}).sort(function(a,b){return a.endDate>b.endDate?1:-1;});
|
|||
|
|
if(!upcoming.length){
|
|||
|
|
box.innerHTML='<div class="empty" style="padding:18px 0"><i class="fas fa-calendar-check"></i><p style="margin:6px 0 0">No activities due in the next 30 days.</p></div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
var rows=upcoming.map(function(a){
|
|||
|
|
var daysLeft=Math.round((new Date(a.endDate)-new Date(td))/(1000*60*60*24));
|
|||
|
|
var mil=DB.milestones.find(function(m){return m.id===a.milestoneId;})||{};
|
|||
|
|
var ph=DB.phases.find(function(p){return p.id===mil.phaseId;})||{};
|
|||
|
|
var phIdx=DB.phases.indexOf(ph);
|
|||
|
|
var phColor=PC[phIdx>=0?phIdx%PC.length:0];
|
|||
|
|
var sc2=statusColors[a.status]||'#6b7280';
|
|||
|
|
var urgency=daysLeft<=3?'color:#b91c1c;font-weight:800':daysLeft<=7?'color:#d97706;font-weight:700':'color:var(--g400)';
|
|||
|
|
var dueLabel=daysLeft===0?'Due today':daysLeft===1?'Due tomorrow':'In '+daysLeft+' days';
|
|||
|
|
return '<li class="upcoming-item">'
|
|||
|
|
+'<div class="upcoming-dot" style="background:'+sc2+'"></div>'
|
|||
|
|
+'<div style="flex:1;min-width:0">'
|
|||
|
|
+'<div style="font-size:12.5px;font-weight:600;color:var(--navy);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">'+a.title+'</div>'
|
|||
|
|
+'<div style="font-size:10.5px;color:var(--g400);margin-top:2px">'
|
|||
|
|
+(ph.name?'<span style="color:'+phColor+';font-weight:600">Phase '+(ph.num||'')+'</span> · ':'')+(mil.title||'')+'</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div style="text-align:right;flex-shrink:0">'
|
|||
|
|
+'<div class="upcoming-days" style="'+urgency+'">'+dueLabel+'</div>'
|
|||
|
|
+'<div style="font-size:10px;color:var(--g400);margin-top:1px">'+a.endDate+'</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'</li>';
|
|||
|
|
}).join('');
|
|||
|
|
box.innerHTML='<ul class="upcoming-list">'+rows+'</ul>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? PHASES ?? */
|
|||
|
|
function renderPhases(){
|
|||
|
|
var phases=DB.phases.slice().sort(function(a,b){return a.order-b.order;});
|
|||
|
|
var html=phases.map(function(p,i){
|
|||
|
|
var pp=phasePct(p.id);
|
|||
|
|
var mils=DB.milestones.filter(function(m){return m.phaseId===p.id;});
|
|||
|
|
var mDone=mils.filter(function(m){var a=DB.activities.filter(function(x){return x.milestoneId===m.id;});return a.length&&a.every(function(x){return x.status==='completed';});}).length;
|
|||
|
|
var pills=mils.slice(0,6).map(function(m){
|
|||
|
|
var c=m.status==='completed'?'var(--success)':m.status==='in_progress'?'var(--info)':m.status==='delayed'?'var(--danger)':'var(--g400)';
|
|||
|
|
return '<span class="pill" onclick="showPage(\'milestoneDetail\','+m.id+')">'
|
|||
|
|
+'<span style="width:7px;height:7px;border-radius:50%;flex-shrink:0;background:'+c+'"></span>'
|
|||
|
|
+(m.title.length>35?m.title.slice(0,35)+'...':m.title)+'</span>';
|
|||
|
|
}).join('');
|
|||
|
|
return '<div class="card" style="border-left:4px solid '+PC[i%PC.length]+'">'
|
|||
|
|
+'<div class="card-hdr"><div>'
|
|||
|
|
+'<div style="font-size:10px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:'+PC[i%PC.length]+';margin-bottom:3px">Phase '+p.num+'</div>'
|
|||
|
|
+'<span class="card-title">'+p.name+'</span>'+(p.desc?'<div class="muted fs12 mt-2">'+p.desc+'</div>':'')+'</div>'
|
|||
|
|
+'<div class="flex ic gap-2"><span style="font-size:18px;font-weight:700;color:'+PC[i%PC.length]+'">'+pp+'%</span>'
|
|||
|
|
+'<button class="btn btn-outline btn-sm" onclick="showPage(\'phaseDetail\','+p.id+')"><i class="fas fa-eye"></i> View</button>'
|
|||
|
|
+(isEditor()?'<button class="btn btn-outline btn-sm" onclick="openPM('+p.id+')"><i class="fas fa-pencil"></i></button>':'')
|
|||
|
|
+(isAdmin()?'<button class="btn btn-danger btn-sm" onclick="delPhase('+p.id+')"><i class="fas fa-trash"></i></button>':'')
|
|||
|
|
+'</div></div>'
|
|||
|
|
+'<div class="card-body"><div class="prog-wrap mb-3"><div class="prog-bar" style="width:'+pp+'%;background:'+PC[i%PC.length]+'"></div></div>'
|
|||
|
|
+'<div class="flex gap-3 fs13 mb-3" style="color:var(--g600)"><span><i class="fas fa-flag tgold"></i> '+mils.length+' milestones</span><span><i class="fas fa-check" style="color:var(--success)"></i> '+mDone+' completed</span></div>'
|
|||
|
|
+(mils.length?'<div style="display:flex;flex-wrap:wrap;gap:6px">'+pills+(mils.length>6?'<span class="pill tgold" onclick="showPage(\'phaseDetail\','+p.id+')">+' +(mils.length-6)+' more</span>':'')+'</div>':'')
|
|||
|
|
+'</div></div>';
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
g('mainContent').innerHTML=
|
|||
|
|
'<div class="flex jb ic mb-4"><p class="muted">All project phases and milestone progress</p>'+(isEditor()?'<button class="btn btn-gold" onclick="openPM()"><i class="fas fa-plus"></i> Add Phase</button>':'')+'</div>'
|
|||
|
|
+html+'<div class="overlay" id="phMod"><div class="modal" style="max-width:480px">'
|
|||
|
|
+'<div class="modal-hdr"><span class="modal-title" id="phMT">Add Phase</span><button class="modal-close" onclick="closeModal(\'phMod\')">✕</button></div>'
|
|||
|
|
+'<div class="modal-body"><input type="hidden" id="phMId">'
|
|||
|
|
+'<div class="fg-2"><div class="fg"><label>Phase Number</label><input type="number" id="phMN"></div><div class="fg"><label>Display Order</label><input type="number" id="phMO"></div></div>'
|
|||
|
|
+'<div class="fg"><label>Phase Name *</label><input type="text" id="phMNm"></div>'
|
|||
|
|
+'<div class="fg"><label>Description</label><textarea id="phMD"></textarea></div></div>'
|
|||
|
|
+'<div class="modal-foot"><button class="btn btn-outline" onclick="closeModal(\'phMod\')">Cancel</button><button class="btn btn-primary" onclick="savePhase()"><i class="fas fa-save"></i> Save</button></div>'
|
|||
|
|
+'</div></div>';
|
|||
|
|
}
|
|||
|
|
function openPM(id){
|
|||
|
|
g('phMT').textContent=id?'Edit Phase':'Add Phase';g('phMId').value=id||'';
|
|||
|
|
if(id){var p=DB.phases.find(function(x){return x.id===id;});g('phMN').value=p.num;g('phMO').value=p.order;g('phMNm').value=p.name;g('phMD').value=p.desc||'';}
|
|||
|
|
else{['phMN','phMO','phMNm','phMD'].forEach(function(x){g(x).value='';});}
|
|||
|
|
openModal('phMod');
|
|||
|
|
}
|
|||
|
|
function savePhase(){
|
|||
|
|
var name=g('phMNm').value.trim();if(!name){alert('Name required.');return;}
|
|||
|
|
var id=parseInt(g('phMId').value)||null;
|
|||
|
|
if(id){var p=DB.phases.find(function(x){return x.id===id;});p.num=parseInt(g('phMN').value)||p.num;p.name=name;p.order=parseInt(g('phMO').value)||p.order;p.desc=g('phMD').value;addLog('EDIT_PHASE','phase',id,name);}
|
|||
|
|
else{var np={id:nid('phase'),num:parseInt(g('phMN').value)||DB.phases.length+1,name:name,desc:g('phMD').value,order:parseInt(g('phMO').value)||DB.phases.length+1};DB.phases.push(np);addLog('ADD_PHASE','phase',np.id,name);}
|
|||
|
|
saveDB(true,'Phase "'+(name)+'" was updated by '+CU.fullName);closeModal('phMod');renderPhases();flash('Phase saved.');
|
|||
|
|
}
|
|||
|
|
function delPhase(id){
|
|||
|
|
if(!confirm('Delete this phase and ALL its data?'))return;
|
|||
|
|
var mids=DB.milestones.filter(function(m){return m.phaseId===id;}).map(function(m){return m.id;});
|
|||
|
|
var aids=DB.activities.filter(function(a){return mids.indexOf(a.milestoneId)>=0;}).map(function(a){return a.id;});
|
|||
|
|
DB.deliverables=DB.deliverables.filter(function(d){return aids.indexOf(d.activityId)<0&&mids.indexOf(d.milestoneId)<0;});
|
|||
|
|
DB.activities=DB.activities.filter(function(a){return mids.indexOf(a.milestoneId)<0;});
|
|||
|
|
DB.milestones=DB.milestones.filter(function(m){return m.phaseId!==id;});
|
|||
|
|
var p=DB.phases.find(function(x){return x.id===id;});addLog('DELETE_PHASE','phase',id,p?p.name:'');
|
|||
|
|
DB.phases=DB.phases.filter(function(x){return x.id!==id;});saveDB();renderPhases();flash('Phase deleted.','danger');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? PHASE DETAIL ?? */
|
|||
|
|
function renderPhaseDetail(pid){
|
|||
|
|
var phase=DB.phases.find(function(p){return p.id===pid;});if(!phase){showPage('phases');return;}
|
|||
|
|
g('pageTitle').textContent='Phase '+phase.num+': '+phase.name;
|
|||
|
|
var milestones=DB.milestones.filter(function(m){return m.phaseId===pid;}).sort(function(a,b){return (a.serial||0)-(b.serial||0);});
|
|||
|
|
var pp=phasePct(pid);
|
|||
|
|
var mCards=milestones.map(function(m){
|
|||
|
|
var mp=milestonePct(m.id);
|
|||
|
|
var acts=DB.activities.filter(function(a){return a.milestoneId===m.id;});
|
|||
|
|
var pills=acts.map(function(a){
|
|||
|
|
var bc=a.status==='completed'?'var(--sbg)':a.status==='delayed'?'var(--dbg)':a.status==='in_progress'?'var(--ibg)':'var(--g100)';
|
|||
|
|
var tc=a.status==='completed'?'var(--success)':a.status==='delayed'?'var(--danger)':a.status==='in_progress'?'var(--info)':'var(--g600)';
|
|||
|
|
return '<span style="padding:2px 8px;border-radius:10px;font-size:11px;background:'+bc+';color:'+tc+'">'+(a.title.length>45?a.title.slice(0,45)+'...':a.title)+'</span>';
|
|||
|
|
}).join('');
|
|||
|
|
var bc=m.status==='completed'?'var(--success)':m.status==='delayed'?'var(--danger)':m.status==='in_progress'?'var(--info)':'var(--g300)';
|
|||
|
|
return '<div class="card" style="border-left:3px solid '+bc+'">'
|
|||
|
|
+'<div class="card-hdr"><div>'
|
|||
|
|
+'<div class="flex ic gap-2 mb-1">'+(m.serial?'<span class="badge b-navy" style="font-size:10px">#'+m.serial+'</span>':'')+sBadge(m.status)+'</div>'
|
|||
|
|
+'<span style="font-family:\'Playfair Display\',serif;font-size:14px;font-weight:600;color:var(--navy);cursor:pointer" onclick="showPage(\'milestoneDetail\','+m.id+')" onmouseover="this.style.color=\'var(--gold)\'" onmouseout="this.style.color=\'var(--navy)\'">'+m.title+'</span>'
|
|||
|
|
+'<div class="approval-row">'
|
|||
|
|
+(m.pmtStatus&&m.pmtStatus!=='not_reviewed'?'<span class="approval-badge ab-pmt-'+m.pmtStatus+'"><i class="fas fa-users-gear" style="font-size:8px"></i> PMT: '+(m.pmtStatus.replace(/_/g,' '))+'</span>':'')
|
|||
|
|
+(m.pscStatus&&m.pscStatus!=='not_required'?'<span class="approval-badge ab-psc-'+m.pscStatus+'"><i class="fas fa-people-roof" style="font-size:8px"></i> PSC: '+(m.pscStatus.replace(/_/g,' '))+'</span>':'')
|
|||
|
|
+'</div>'
|
|||
|
|
+(m.owner?'<div class="muted fs12 mt-1"><i class="fas fa-user"></i> '+m.owner+'</div>':'')+'</div>'
|
|||
|
|
+'<div class="flex ic gap-2">'+(m.tentDate?'<span class="muted fs12">'+m.tentDate+'</span>':'')+'<span style="font-weight:700;color:var(--navy)">'+mp+'%</span>'
|
|||
|
|
+'<button class="btn btn-outline btn-xs" onclick="showPage(\'milestoneDetail\','+m.id+')"><i class="fas fa-eye"></i></button>'
|
|||
|
|
+(isEditor()?'<button class="btn btn-outline btn-xs" onclick="openMM('+pid+','+m.id+')"><i class="fas fa-pencil"></i></button>'
|
|||
|
|
+'<button class="btn btn-danger btn-xs" onclick="delMilestone('+m.id+','+pid+')"><i class="fas fa-trash"></i></button>':'')
|
|||
|
|
+'</div></div>'
|
|||
|
|
+'<div class="card-body" style="padding:10px 17px"><div class="prog-wrap" style="height:5px;margin-bottom:8px"><div class="prog-bar '+(mp>=100?'ps':'')+'" style="width:'+mp+'%"></div></div>'
|
|||
|
|
+(acts.length?'<div style="display:flex;flex-wrap:wrap;gap:5px">'+pills+'</div>':'<span class="muted fs12">No activities yet.</span>')
|
|||
|
|
+'</div></div>';
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
g('mainContent').innerHTML=
|
|||
|
|
'<div class="bc"><span class="lnk" onclick="showPage(\'phases\')">Phases</span><i class="fas fa-chevron-right" style="font-size:9px"></i><span>Phase '+phase.num+'</span></div>'
|
|||
|
|
+'<div class="flex jb ic mb-4"><p class="muted fs12">'+pp+'% complete · '+milestones.length+' milestones</p>'
|
|||
|
|
+'<div class="flex gap-2">'+(isEditor()?'<button class="btn btn-outline btn-sm" onclick="openPM('+pid+')"><i class="fas fa-pencil"></i> Edit</button><button class="btn btn-gold btn-sm" onclick="openMM('+pid+')"><i class="fas fa-plus"></i> Add Milestone</button>':'')+'</div></div>'
|
|||
|
|
+'<div class="card"><div class="card-body" style="padding:13px 17px">'
|
|||
|
|
+'<div class="flex jb ic mb-2"><span class="fw6 fs13">Phase Progress</span><span style="font-weight:700;color:var(--navy)">'+pp+'%</span></div>'
|
|||
|
|
+'<div class="prog-wrap"><div class="prog-bar" style="width:'+pp+'%"></div></div>'
|
|||
|
|
+'<div class="flex gap-4 mt-2 fs12 muted"><span><i class="fas fa-flag tgold"></i> '+milestones.length+' milestones</span>'
|
|||
|
|
+'<span><i class="fas fa-check" style="color:var(--success)"></i> '+milestones.filter(function(m){return m.status==='completed';}).length+' completed</span>'
|
|||
|
|
+'<span><i class="fas fa-exclamation" style="color:var(--danger)"></i> '+milestones.filter(function(m){return isDelayed(m.endDate,m.status);}).length+' delayed</span></div>'
|
|||
|
|
+'</div></div>'
|
|||
|
|
+mCards+'<div id="mmModAnchor"></div><div id="amModAnchor"></div>';
|
|||
|
|
|
|||
|
|
renderMilestoneModal(pid);renderActivityModal(0);
|
|||
|
|
// Ensure phase edit modal is available on phase detail page
|
|||
|
|
if(!g('phMod')){
|
|||
|
|
var phOverlay=document.createElement('div');
|
|||
|
|
phOverlay.className='overlay';phOverlay.id='phMod';
|
|||
|
|
var phMod=document.createElement('div');phMod.className='modal';phMod.style.maxWidth='480px';
|
|||
|
|
var phHdr=document.createElement('div');phHdr.className='modal-hdr';
|
|||
|
|
phHdr.innerHTML='<span class="modal-title" id="phMT">Edit Phase</span>';
|
|||
|
|
var phX=document.createElement('button');phX.className='modal-close';phX.innerHTML='✕';
|
|||
|
|
phX.onclick=function(){closeModal('phMod');};phHdr.appendChild(phX);phMod.appendChild(phHdr);
|
|||
|
|
var phBod=document.createElement('div');phBod.className='modal-body';
|
|||
|
|
phBod.innerHTML='<input type="hidden" id="phMId">'
|
|||
|
|
+'<div class="fg-2"><div class="fg"><label>Phase Number</label><input type="number" id="phMN"></div>'
|
|||
|
|
+'<div class="fg"><label>Display Order</label><input type="number" id="phMO"></div></div>'
|
|||
|
|
+'<div class="fg"><label>Phase Name *</label><input type="text" id="phMNm"></div>'
|
|||
|
|
+'<div class="fg"><label>Description</label><textarea id="phMD"></textarea></div>';
|
|||
|
|
phMod.appendChild(phBod);
|
|||
|
|
var phFoot=document.createElement('div');phFoot.className='modal-foot';
|
|||
|
|
var phCan=document.createElement('button');phCan.className='btn btn-outline';phCan.textContent='Cancel';
|
|||
|
|
phCan.onclick=function(){closeModal('phMod');};phFoot.appendChild(phCan);
|
|||
|
|
var phSav=document.createElement('button');phSav.className='btn btn-primary';
|
|||
|
|
phSav.innerHTML='<i class="fas fa-save"></i> Save';
|
|||
|
|
phSav.onclick=function(){savePhase();};phFoot.appendChild(phSav);
|
|||
|
|
phMod.appendChild(phFoot);phOverlay.appendChild(phMod);document.body.appendChild(phOverlay);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? MILESTONE MODAL ?? */
|
|||
|
|
function renderMilestoneModal(pid){
|
|||
|
|
var phOpts=DB.phases.slice().sort(function(a,b){return a.order-b.order;}).map(function(p){return '<option value="'+p.id+'">Phase '+p.num+': '+p.name+'</option>';}).join('');
|
|||
|
|
var el=document.createElement('div');
|
|||
|
|
el.innerHTML='<div class="overlay" id="mmMod"><div class="modal">'
|
|||
|
|
+'<div class="modal-hdr"><span class="modal-title" id="mmT">Add Milestone</span><button class="modal-close" onclick="closeModal(\'mmMod\')">✕</button></div>'
|
|||
|
|
+'<div class="modal-body"><input type="hidden" id="mmId"><input type="hidden" id="mmPid" value="'+pid+'">'
|
|||
|
|
+'<div class="fg-2"><div class="fg"><label>Phase *</label><select id="mmPh">'+phOpts+'</select></div><div class="fg"><label>Serial No.</label><input type="number" id="mmSer"></div></div>'
|
|||
|
|
+'<div class="fg"><label>Title *</label><input type="text" id="mmTi"></div>'
|
|||
|
|
+'<div class="fg"><label>Description</label><textarea id="mmDe"></textarea></div>'
|
|||
|
|
+'<div class="fg-3"><div class="fg"><label>Status</label><select id="mmSt"><option value="not_started">Not Started</option><option value="in_progress">In Progress</option><option value="completed">Completed</option><option value="delayed">Delayed</option></select></div>'
|
|||
|
|
+'<div class="fg"><label>Priority</label><select id="mmPr"><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option><option value="critical">Critical</option></select></div>'
|
|||
|
|
+'<div class="fg"><label>Month</label><input type="text" id="mmMo" placeholder="e.g. 1-3"></div></div>'
|
|||
|
|
+'<div class="fg-3"><div class="fg"><label>Tentative Date</label><input type="date" id="mmTD"></div><div class="fg"><label>Start Date</label><input type="date" id="mmSD"></div><div class="fg"><label>End Date</label><input type="date" id="mmED"></div></div>'
|
|||
|
|
+'<div class="fg"><label>Owner</label><input type="text" id="mmOw"></div>'
|
|||
|
|
+'<div class="fg"><label>Notes</label><textarea id="mmNo"></textarea></div></div>'
|
|||
|
|
+'<div class="modal-foot"><button class="btn btn-outline" onclick="closeModal(\'mmMod\')">Cancel</button><button class="btn btn-primary" onclick="saveMilestone()"><i class="fas fa-save"></i> Save</button></div>'
|
|||
|
|
+'</div></div>';
|
|||
|
|
document.body.appendChild(el.firstElementChild);
|
|||
|
|
}
|
|||
|
|
function openMM(pid,mid){
|
|||
|
|
if(!g('mmMod'))renderMilestoneModal(pid);
|
|||
|
|
g('mmT').textContent=mid?'Edit Milestone':'Add Milestone';
|
|||
|
|
g('mmId').value=mid||'';g('mmPid').value=pid;
|
|||
|
|
if(mid){var m=DB.milestones.find(function(x){return x.id===mid;});
|
|||
|
|
if(g('mmPh'))g('mmPh').value=m.phaseId;
|
|||
|
|
g('mmSer').value=m.serial||'';g('mmTi').value=m.title;g('mmDe').value=m.desc||'';
|
|||
|
|
g('mmSt').value=m.status;g('mmPr').value=m.priority;g('mmMo').value=m.month||'';
|
|||
|
|
g('mmTD').value=m.tentDate||'';g('mmSD').value=m.startDate||'';g('mmED').value=m.endDate||'';
|
|||
|
|
g('mmOw').value=m.owner||'';g('mmNo').value=m.notes||'';
|
|||
|
|
} else {
|
|||
|
|
['mmSer','mmTi','mmDe','mmMo','mmTD','mmSD','mmED','mmOw','mmNo'].forEach(function(x){if(g(x))g(x).value='';});
|
|||
|
|
if(g('mmSt'))g('mmSt').value='not_started';if(g('mmPr'))g('mmPr').value='medium';
|
|||
|
|
if(g('mmPh'))g('mmPh').value=pid;
|
|||
|
|
}
|
|||
|
|
openModal('mmMod');
|
|||
|
|
}
|
|||
|
|
function saveMilestone(){
|
|||
|
|
var title=g('mmTi').value.trim();if(!title){alert('Title required.');return;}
|
|||
|
|
var id=parseInt(g('mmId').value)||null;
|
|||
|
|
var pid=parseInt(g('mmPh')?g('mmPh').value:g('mmPid').value);
|
|||
|
|
var d={phaseId:pid,serial:parseInt(g('mmSer').value)||null,title:title,desc:g('mmDe').value,status:g('mmSt').value,priority:g('mmPr').value,month:g('mmMo').value,tentDate:g('mmTD').value||null,startDate:g('mmSD').value||null,endDate:g('mmED').value||null,owner:g('mmOw').value,notes:g('mmNo').value};
|
|||
|
|
if(id){Object.assign(DB.milestones.find(function(x){return x.id===id;}),d);addLog('EDIT_MILESTONE','milestone',id,title);}
|
|||
|
|
else{var nm=Object.assign({id:nid('milestone'),createdAt:new Date().toISOString()},d);DB.milestones.push(nm);addLog('ADD_MILESTONE','milestone',nm.id,title);}
|
|||
|
|
saveDB();closeModal('mmMod');renderPhaseDetail(pid);flash('Milestone saved.');
|
|||
|
|
}
|
|||
|
|
function delMilestone(mid,pid){
|
|||
|
|
if(!confirm('Delete milestone and all its activities?'))return;
|
|||
|
|
var m=DB.milestones.find(function(x){return x.id===mid;});
|
|||
|
|
var aids=DB.activities.filter(function(a){return a.milestoneId===mid;}).map(function(a){return a.id;});
|
|||
|
|
DB.deliverables=DB.deliverables.filter(function(d){return aids.indexOf(d.activityId)<0&&d.milestoneId!==mid;});
|
|||
|
|
DB.activities=DB.activities.filter(function(a){return a.milestoneId!==mid;});
|
|||
|
|
DB.milestones=DB.milestones.filter(function(x){return x.id!==mid;});
|
|||
|
|
addLog('DELETE_MILESTONE','milestone',mid,m?m.title:'');
|
|||
|
|
saveDB();renderPhaseDetail(pid);flash('Milestone deleted.','danger');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? ACTIVITY MODAL ?? */
|
|||
|
|
function renderActivityModal(mid){
|
|||
|
|
if(g('amMod'))return;
|
|||
|
|
var el=document.createElement('div');
|
|||
|
|
el.innerHTML='<div class="overlay" id="amMod"><div class="modal">'
|
|||
|
|
+'<div class="modal-hdr"><span class="modal-title" id="amT">Add Activity</span><button class="modal-close" onclick="closeModal(\'amMod\')">✕</button></div>'
|
|||
|
|
+'<div class="modal-body"><input type="hidden" id="amId"><input type="hidden" id="amMid">'
|
|||
|
|
+'<div class="fg"><label>Title *</label><input type="text" id="amTi"></div>'
|
|||
|
|
+'<div class="fg"><label>Description</label><textarea id="amDe"></textarea></div>'
|
|||
|
|
+'<div class="fg-3"><div class="fg"><label>Status</label><select id="amSt"><option value="not_started">Not Started</option><option value="in_progress">In Progress</option><option value="completed">Completed</option><option value="delayed">Delayed</option><option value="on_hold">On Hold</option></select></div>'
|
|||
|
|
+'<div class="fg"><label>Progress % <span style=\"font-size:10px;color:var(--g400);text-transform:none;letter-spacing:0\">(auto or manual)</span></label><input type="number" id="amPr" min="0" max="100" value="0"><div style=\"font-size:11px;color:var(--g400);margin-top:3px\"><i class=\"fas fa-magic\"></i> Auto-calculated from dates when Start & End are set</div></div>'
|
|||
|
|
+'<div class="fg"><label>Month</label><input type="text" id="amMo"></div></div>'
|
|||
|
|
+'<div class="fg-3"><div class="fg"><label>Tentative Date</label><input type="date" id="amTD"></div><div class="fg"><label>Start Date</label><input type="date" id="amSD"></div><div class="fg"><label>End Date</label><input type="date" id="amED"></div></div>'
|
|||
|
|
+'<div class="fg"><label>Assigned To</label><input type="text" id="amAs"></div>'
|
|||
|
|
+'<div class="fg"><label>Notes</label><textarea id="amNo"></textarea></div></div>'
|
|||
|
|
+'<div class="modal-foot"><button class="btn btn-outline" onclick="closeModal(\'amMod\')">Cancel</button><button class="btn btn-primary" onclick="saveActivity()"><i class="fas fa-save"></i> Save</button></div>'
|
|||
|
|
+'</div></div>';
|
|||
|
|
document.body.appendChild(el.firstElementChild);
|
|||
|
|
}
|
|||
|
|
function openAM(mid,aid){
|
|||
|
|
renderActivityModal(mid);
|
|||
|
|
g('amT').textContent=aid?'Edit Activity':'Add Activity';
|
|||
|
|
g('amId').value=aid||'';g('amMid').value=mid;
|
|||
|
|
if(aid){var a=DB.activities.find(function(x){return x.id===aid;});
|
|||
|
|
g('amTi').value=a.title;g('amDe').value=a.desc||'';g('amSt').value=a.status;g('amPr').value=a.progress||0;
|
|||
|
|
g('amMo').value=a.month||'';g('amTD').value=a.tentDate||'';g('amSD').value=a.startDate||'';g('amED').value=a.endDate||'';
|
|||
|
|
g('amAs').value=a.assignedTo||'';g('amNo').value=a.notes||'';
|
|||
|
|
} else {
|
|||
|
|
['amTi','amDe','amMo','amTD','amSD','amED','amAs','amNo'].forEach(function(x){g(x).value='';});
|
|||
|
|
g('amSt').value='not_started';g('amPr').value=0;
|
|||
|
|
}
|
|||
|
|
openModal('amMod');
|
|||
|
|
}
|
|||
|
|
function saveActivity(){
|
|||
|
|
var title=g('amTi').value.trim();if(!title){alert('Title required.');return;}
|
|||
|
|
var id=parseInt(g('amId').value)||null;
|
|||
|
|
var mid=parseInt(g('amMid').value);
|
|||
|
|
var rawProg=parseInt(g('amPr').value)||0;
|
|||
|
|
var autoP=calcProgress(g('amSD').value,g('amED').value,g('amSt').value);
|
|||
|
|
var finalProg=(g('amSt').value==='completed')?100:(g('amSt').value==='not_started')?0:(autoP>0?autoP:rawProg);
|
|||
|
|
var d={milestoneId:mid,title:title,desc:g('amDe').value,status:g('amSt').value,progress:finalProg,month:g('amMo').value,tentDate:g('amTD').value||null,startDate:g('amSD').value||null,endDate:g('amED').value||null,assignedTo:g('amAs').value,notes:g('amNo').value};
|
|||
|
|
if(id){Object.assign(DB.activities.find(function(x){return x.id===id;}),d);addLog('EDIT_ACTIVITY','activity',id,title);}
|
|||
|
|
else{var na=Object.assign({id:nid('activity'),createdAt:new Date().toISOString()},d);DB.activities.push(na);addLog('ADD_ACTIVITY','activity',na.id,title);}
|
|||
|
|
saveDB();closeModal('amMod');renderMilestoneDetail(mid);flash('Activity saved.');
|
|||
|
|
}
|
|||
|
|
function delActivity(aid,mid){
|
|||
|
|
if(!confirm('Delete activity?'))return;
|
|||
|
|
var a=DB.activities.find(function(x){return x.id===aid;});
|
|||
|
|
DB.deliverables=DB.deliverables.filter(function(d){return d.activityId!==aid;});
|
|||
|
|
DB.activities=DB.activities.filter(function(x){return x.id!==aid;});
|
|||
|
|
addLog('DELETE_ACTIVITY','activity',aid,a?a.title:'');
|
|||
|
|
saveDB();renderMilestoneDetail(mid);flash('Deleted.','danger');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? FILE HELPERS ?? */
|
|||
|
|
function attachRow(d,mid){
|
|||
|
|
return '<div class="attach-row">'
|
|||
|
|
+'<i class="fas '+fIcon(d.fileType||'')+'" style="color:var(--gold);font-size:14px;flex-shrink:0"></i>'
|
|||
|
|
+'<div style="flex:1;min-width:0"><div class="fw6 fs13">'+d.title+'</div>'
|
|||
|
|
+(d.fileName?'<div class="muted" style="font-size:11px"><i class="fas fa-paperclip" style="margin-right:3px"></i>'+d.fileName+(d.fileSize?' ('+Math.round(d.fileSize/1024)+'KB)':'')+'</div>':'<div class="muted" style="font-size:11px">No file attached</div>')
|
|||
|
|
+(d.uploadedAt?'<div class="muted" style="font-size:11px">'+d.uploadedAt.slice(0,10)+'</div>':'')+'</div>'
|
|||
|
|
+sBadge(d.approvalStatus||'pending')
|
|||
|
|
+(d.fileData?'<button class="btn btn-outline btn-xs" onclick="dlFile('+d.id+')" title="Download"><i class="fas fa-download"></i></button>':'')
|
|||
|
|
+(isAdmin()&&d.approvalStatus==='pending'?'<button class="btn btn-success btn-xs" onclick="approveD('+d.id+',\'approved\','+mid+')"><i class="fas fa-check"></i> Approve</button>':'')
|
|||
|
|
+(isEditor()?'<button class="btn btn-danger btn-xs" onclick="delD('+d.id+','+mid+')"><i class="fas fa-trash"></i></button>':'')
|
|||
|
|
+'</div>';
|
|||
|
|
}
|
|||
|
|
function toggleUp(id){var e=g(id);if(e)e.style.display=e.style.display==='none'?'block':'none';}
|
|||
|
|
function dlFile(did){
|
|||
|
|
var d=DB.deliverables.find(function(x){return x.id===did;});
|
|||
|
|
if(!d||!d.fileData){alert('No file attached.');return;}
|
|||
|
|
var a=document.createElement('a');a.href=d.fileData;a.download=d.fileName||'file';a.click();
|
|||
|
|
addLog('DOWNLOAD_FILE','deliverable',did,d.fileName);saveDB();
|
|||
|
|
}
|
|||
|
|
function approveD(did,action,mid){
|
|||
|
|
var d=DB.deliverables.find(function(x){return x.id===did;});if(!d)return;
|
|||
|
|
d.approvalStatus=action;d.approvedBy=CU.id;d.approvedAt=new Date().toISOString();
|
|||
|
|
addLog('APPROVE_FILE','deliverable',did,d.title);saveDB();renderMilestoneDetail(mid);flash('File '+action+'.');
|
|||
|
|
}
|
|||
|
|
function delD(did,mid){
|
|||
|
|
if(!confirm('Delete this file/deliverable?'))return;
|
|||
|
|
var d=DB.deliverables.find(function(x){return x.id===did;});
|
|||
|
|
addLog('DELETE_FILE','deliverable',did,d?d.title:'');
|
|||
|
|
DB.deliverables=DB.deliverables.filter(function(x){return x.id!==did;});
|
|||
|
|
saveDB();renderMilestoneDetail(mid);flash('Deleted.','danger');
|
|||
|
|
}
|
|||
|
|
function doUpload(titleId,dateId,fileId,mid,aid){
|
|||
|
|
var title=g(titleId).value.trim();if(!title){alert('Title required.');return;}
|
|||
|
|
var fi=g(fileId);var file=fi?fi.files[0]:null;
|
|||
|
|
var ok=['pdf','docx','xlsx','pptx','jpg','jpeg'];
|
|||
|
|
var base={id:nid('deliverable'),milestoneId:mid,activityId:aid||null,title:title,dueDate:g(dateId)?g(dateId).value||null:null,approvalStatus:'pending',uploadedBy:CU.id,uploadedAt:new Date().toISOString()};
|
|||
|
|
if(file){
|
|||
|
|
var ext=file.name.split('.').pop().toLowerCase();
|
|||
|
|
if(ok.indexOf(ext)<0){alert('Only pdf, docx, xlsx, pptx, jpg allowed.');return;}
|
|||
|
|
if(file.size>10*1024*1024){alert('Max 10MB.');return;}
|
|||
|
|
var r=new FileReader();
|
|||
|
|
r.onload=function(e){
|
|||
|
|
var d=Object.assign({},base,{fileName:file.name,fileType:ext,fileSize:file.size,fileData:e.target.result});
|
|||
|
|
DB.deliverables.push(d);addLog('UPLOAD_FILE','deliverable',d.id,title);
|
|||
|
|
DB.users.filter(function(u){return u.role==='admin'&&u.id!==CU.id;}).forEach(function(u){addNotif(u.id,'New file uploaded','"'+file.name+'" uploaded.','upload');});
|
|||
|
|
saveDB();renderMilestoneDetail(mid);flash('File uploaded successfully.');
|
|||
|
|
};r.readAsDataURL(file);
|
|||
|
|
} else {
|
|||
|
|
var d2=Object.assign({},base,{fileName:null,fileType:null,fileSize:null,fileData:null});
|
|||
|
|
DB.deliverables.push(d2);addLog('ADD_DELIVERABLE','deliverable',d2.id,title);
|
|||
|
|
saveDB();renderMilestoneDetail(mid);flash('Deliverable added.');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? MILESTONE DETAIL ?? */
|
|||
|
|
function renderMilestoneDetail(mid){
|
|||
|
|
var m=DB.milestones.find(function(x){return x.id===mid;});if(!m){showPage('phases');return;}
|
|||
|
|
var phase=DB.phases.find(function(p){return p.id===m.phaseId;});
|
|||
|
|
g('pageTitle').textContent='Milestone Detail';
|
|||
|
|
var mp=milestonePct(mid);
|
|||
|
|
var activities=DB.activities.filter(function(a){return a.milestoneId===mid;});
|
|||
|
|
var comments=DB.comments.filter(function(c){return c.milestoneId===mid;}).sort(function(a,b){return a.createdAt.localeCompare(b.createdAt);});
|
|||
|
|
var delayed=isDelayed(m.endDate,m.status);
|
|||
|
|
var mFiles=DB.deliverables.filter(function(d){return d.milestoneId===mid&&!d.activityId;});
|
|||
|
|
|
|||
|
|
var mFilesHtml=mFiles.map(function(d){return attachRow(d,mid);}).join('');
|
|||
|
|
var actHtml=activities.map(function(a){
|
|||
|
|
var delivs=DB.deliverables.filter(function(d){return d.activityId===a.id;});
|
|||
|
|
var dHtml=delivs.map(function(d){return attachRow(d,mid);}).join('');
|
|||
|
|
var aBC=a.status==='completed'?'var(--success)':a.status==='delayed'?'var(--danger)':a.status==='in_progress'?'var(--info)':'var(--g300)';
|
|||
|
|
return '<div class="card" style="border-left:3px solid '+aBC+'">'
|
|||
|
|
+'<div class="card-hdr" style="padding:10px 15px">'
|
|||
|
|
+'<div style="flex:1"><div class="flex ic gap-2">'+sBadge(a.status)+'<span class="fw6 fs13">'+a.title+'</span></div>'
|
|||
|
|
+(a.assignedTo?'<div class="muted fs12 mt-1"><i class="fas fa-user"></i> '+a.assignedTo+'</div>':'')+'</div>'
|
|||
|
|
+'<div class="flex ic gap-2">'+(a.endDate?'<span class="muted fs12"><i class="fas fa-calendar" style="margin-right:3px"></i>'+a.endDate+'</span>':'')
|
|||
|
|
+(isEditor()?'<button class="btn btn-outline btn-xs" onclick="openAM('+mid+','+a.id+')"><i class="fas fa-pencil"></i></button>'
|
|||
|
|
+'<button class="btn btn-danger btn-xs" onclick="delActivity('+a.id+','+mid+')"><i class="fas fa-trash"></i></button>':'')
|
|||
|
|
+'</div></div>'
|
|||
|
|
+'<div class="card-body" style="padding:10px 15px">'
|
|||
|
|
+(a.desc?'<p class="muted fs12 mb-3">'+a.desc+'</p>':'')
|
|||
|
|
+'<div class="flex jb ic mb-1"><span class="fs12 muted">Progress</span><span class="fs12 fw6">'+a.progress+'%</span></div>'
|
|||
|
|
+'<div class="prog-wrap" style="height:5px;margin-bottom:12px"><div class="prog-bar '+(a.progress>=100?'ps':'')+'" style="width:'+a.progress+'%"></div></div>'
|
|||
|
|
+'<div style="background:var(--g50);border:1px solid var(--g200);border-radius:8px;padding:12px">'
|
|||
|
|
+'<div class="flex jb ic mb-2"><span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--g600)"><i class="fas fa-paperclip tgold" style="margin-right:5px"></i>Files & Deliverables ('+delivs.length+')</span>'
|
|||
|
|
+(isEditor()?'<button class="btn btn-gold btn-xs" onclick="toggleUp(\'aup-'+a.id+'\')"><i class="fas fa-upload"></i> Upload File</button>':'')+'</div>'
|
|||
|
|
+(dHtml||'<p class="muted fs12">No files attached yet.</p>')
|
|||
|
|
+(isEditor()?'<div id="aup-'+a.id+'" style="display:none;border-top:1px solid var(--g200);margin-top:10px;padding-top:10px">'
|
|||
|
|
+'<div class="fg-2" style="margin-bottom:8px"><div class="fg"><label>Title *</label><input type="text" id="at-'+a.id+'" placeholder="e.g. Draft Report"></div><div class="fg"><label>Due Date</label><input type="date" id="ad-'+a.id+'"></div></div>'
|
|||
|
|
+'<div class="fg" style="margin-bottom:8px"><label>Select File (pdf, docx, xlsx, pptx, jpg — max 10MB)</label>'
|
|||
|
|
+'<input type="file" id="af-'+a.id+'" accept=".pdf,.docx,.xlsx,.pptx,.jpg,.jpeg" style="border:1px solid var(--g200);border-radius:6px;padding:5px 8px;font-size:12px"></div>'
|
|||
|
|
+'<div class="flex gap-2"><button class="btn btn-primary btn-sm" onclick="doUpload(\'at-'+a.id+'\',\'ad-'+a.id+'\',\'af-'+a.id+'\','+mid+','+a.id+')"><i class="fas fa-upload"></i> Upload</button>'
|
|||
|
|
+'<button class="btn btn-outline btn-sm" onclick="toggleUp(\'aup-'+a.id+'\')">Cancel</button></div></div>':'')
|
|||
|
|
+'</div></div></div>';
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
var cHtml=comments.map(function(c){
|
|||
|
|
var u=DB.users.find(function(x){return x.id===c.userId;});
|
|||
|
|
var canEdit=isAdmin()||(CU&&CU.id===c.userId);
|
|||
|
|
return '<div id="cmt-'+c.id+'" style="display:flex;gap:10px;margin-bottom:12px;padding-bottom:12px;border-bottom:1px solid var(--g100)">'
|
|||
|
|
+'<div style="width:28px;height:28px;border-radius:50%;background:var(--navy);display:flex;align-items:center;justify-content:center;color:#fff;font-size:11px;font-weight:700;flex-shrink:0">'+(u?u.username:'?')[0].toUpperCase()+'</div>'
|
|||
|
|
+'<div style="flex:1">'
|
|||
|
|
+'<div class="flex jb ic"><div class="flex ic gap-2"><span class="fw6 fs13">'+(u?u.fullName||u.username:'?')+'</span><span class="muted fs12">'+c.createdAt.slice(0,16).replace('T',' ')+'</span>'+(c.edited?'<span class="muted fs12">(edited)</span>':'')+'</div>'
|
|||
|
|
+(canEdit?'<div class="flex gap-1"><button class="btn btn-outline btn-xs" onclick="editComment('+c.id+','+mid+')"><i class="fas fa-pencil"></i></button><button class="btn btn-danger btn-xs" onclick="delComment('+c.id+','+mid+')"><i class="fas fa-trash"></i></button></div>':'')
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div id="cmt-text-'+c.id+'" class="fs13 mt-2">'+c.content+'</div>'
|
|||
|
|
+'<div id="cmt-edit-'+c.id+'" style="display:none;margin-top:8px">'
|
|||
|
|
+'<textarea id="cmt-ta-'+c.id+'" rows="2" style="width:100%;padding:7px 10px;border:1px solid var(--g200);border-radius:6px;font-family:inherit;font-size:13px">'+c.content+'</textarea>'
|
|||
|
|
+'<div class="flex gap-2 mt-2"><button class="btn btn-primary btn-xs" onclick="saveEditComment('+c.id+','+mid+')"><i class="fas fa-save"></i> Save</button><button class="btn btn-outline btn-xs" onclick="cancelEditComment('+c.id+')">Cancel</button></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'</div></div>';
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
var bc=m.status==='completed'?'var(--success)':m.status==='delayed'?'var(--danger)':m.status==='in_progress'?'var(--info)':'var(--g300)';
|
|||
|
|
g('mainContent').innerHTML=
|
|||
|
|
'<div class="bc"><span class="lnk" onclick="showPage(\'phases\')">Phases</span><i class="fas fa-chevron-right" style="font-size:9px"></i>'
|
|||
|
|
+(phase?'<span class="lnk" onclick="showPage(\'phaseDetail\','+m.phaseId+')">Phase '+phase.num+'</span>':'')
|
|||
|
|
+'<i class="fas fa-chevron-right" style="font-size:9px"></i><span>'+(m.title.length>40?m.title.slice(0,40)+'...':m.title)+'</span></div>'
|
|||
|
|
+'<div class="card" style="border-left:4px solid '+bc+'">'
|
|||
|
|
+'<div class="card-hdr"><div>'
|
|||
|
|
+'<div class="flex ic gap-2 mb-1">'+(m.serial?'<span class="badge b-navy">#'+m.serial+'</span>':'')+sBadge(m.status)+pBadge(m.priority)+(delayed?'<span class="badge b-danger"><i class="fas fa-exclamation-triangle"></i> Overdue</span>':'')+'</div>'
|
|||
|
|
+'<h2 style="font-family:\'Playfair Display\',serif;font-size:18px;color:var(--navy)">'+m.title+'</h2>'
|
|||
|
|
+(m.desc?'<p class="muted mt-2">'+m.desc+'</p>':'')+'</div>'
|
|||
|
|
+(isEditor()?'<button class="btn btn-outline btn-sm" onclick="openMM('+m.phaseId+','+m.id+')"><i class="fas fa-pencil"></i> Edit</button>':'')+'</div>'
|
|||
|
|
+'<div class="card-body">'
|
|||
|
|
+'<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:13px;margin-bottom:13px">'
|
|||
|
|
+(m.owner?'<div><div class="fs12 muted mb-1">Owner</div><div class="fw6 fs13">'+m.owner+'</div></div>':'')
|
|||
|
|
+(m.month?'<div><div class="fs12 muted mb-1">Month</div><div class="fw6 fs13">Month '+m.month+'</div></div>':'')
|
|||
|
|
+(m.tentDate?'<div><div class="fs12 muted mb-1">Tentative Date</div><div class="fw6 fs13">'+m.tentDate+'</div></div>':'')
|
|||
|
|
+(m.endDate?'<div><div class="fs12 muted mb-1">End Date</div><div class="fw6 fs13 '+(delayed?'tgold':'')+'">'+m.endDate+'</div></div>':'')
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="flex jb ic mb-1"><span class="fs12 muted">Completion</span><span class="fw6 fs13">'+mp+'%</span></div>'
|
|||
|
|
+'<div class="prog-wrap"><div class="prog-bar '+(mp>=100?'ps':'')+'" style="width:'+mp+'%"></div></div>'
|
|||
|
|
+'</div></div>'
|
|||
|
|
|
|||
|
|
+'<div class="card">'
|
|||
|
|
+'<div class="card-hdr"><span class="card-title"><i class="fas fa-folder-open tgold"></i> Milestone Files & Deliverables <span class="badge b-navy" style="margin-left:6px;font-size:11px">'+mFiles.length+'</span></span>'
|
|||
|
|
+(isEditor()?'<button class="btn btn-gold btn-sm" onclick="toggleUp(\'mup-'+mid+'\')"><i class="fas fa-upload"></i> Upload File</button>':'')+'</div>'
|
|||
|
|
+'<div class="card-body">'
|
|||
|
|
+(mFilesHtml||'<p class="muted fs12">No milestone-level files yet. Click Upload File to attach documents directly to this milestone.</p>')
|
|||
|
|
+(isEditor()?'<div id="mup-'+mid+'" style="display:none;background:var(--g50);border:1px solid var(--g200);border-radius:8px;padding:14px;margin-top:12px">'
|
|||
|
|
+'<div class="fw6 fs13 mb-3"><i class="fas fa-upload tgold"></i> Upload Milestone Document</div>'
|
|||
|
|
+'<div class="fg-2" style="margin-bottom:8px"><div class="fg"><label>Document Title *</label><input type="text" id="mt-'+mid+'" placeholder="e.g. Approval Letter, Phase Report"></div><div class="fg"><label>Due Date</label><input type="date" id="md-'+mid+'"></div></div>'
|
|||
|
|
+'<div class="fg" style="margin-bottom:10px"><label>Select File (pdf, docx, xlsx, pptx, jpg — max 10MB)</label>'
|
|||
|
|
+'<input type="file" id="mf-'+mid+'" accept=".pdf,.docx,.xlsx,.pptx,.jpg,.jpeg" style="border:1px solid var(--g200);border-radius:6px;padding:5px 8px;font-size:12px"></div>'
|
|||
|
|
+'<div class="flex gap-2"><button class="btn btn-primary" onclick="doUpload(\'mt-'+mid+'\',\'md-'+mid+'\',\'mf-'+mid+'\','+mid+',null)"><i class="fas fa-upload"></i> Upload to Milestone</button>'
|
|||
|
|
+'<button class="btn btn-outline" onclick="toggleUp(\'mup-'+mid+'\')">Cancel</button></div></div>':'')
|
|||
|
|
+'</div></div>'
|
|||
|
|
|
|||
|
|
+'<div class="flex jb ic mb-3"><h3 style="font-family:\'Playfair Display\',serif;font-size:15px;color:var(--navy)"><i class="fas fa-tasks tgold"></i> Activities</h3>'
|
|||
|
|
+(isEditor()?'<button class="btn btn-gold btn-sm" onclick="openAM('+mid+')"><i class="fas fa-plus"></i> Add Activity</button>':'')+'</div>'
|
|||
|
|
+(actHtml||'<div class="empty" style="padding:22px"><i class="fas fa-tasks"></i><p>No activities yet.</p></div>')
|
|||
|
|
|
|||
|
|
+'<div class="card mt-4"><div class="card-hdr"><span class="card-title"><i class="fas fa-comments tgold"></i> Comments & Notes</span></div>'
|
|||
|
|
+'<div class="card-body">'+cHtml
|
|||
|
|
+(isEditor()?'<div class="fg mt-3"><label>Add Comment</label><textarea id="cTxt" rows="2" placeholder="Add a comment or note..."></textarea></div>'
|
|||
|
|
+'<button class="btn btn-primary btn-sm" onclick="saveComment('+mid+')"><i class="fas fa-paper-plane"></i> Post</button>':'')
|
|||
|
|
+'</div></div>';
|
|||
|
|
|
|||
|
|
renderMilestoneModal(m.phaseId);renderActivityModal(mid);
|
|||
|
|
}
|
|||
|
|
function saveComment(mid){
|
|||
|
|
var txt=g('cTxt')?g('cTxt').value.trim():'';if(!txt){alert('Comment cannot be empty.');return;}
|
|||
|
|
DB.comments.push({id:nid('comment'),milestoneId:mid,userId:CU.id,content:txt,createdAt:new Date().toISOString()});
|
|||
|
|
addLog('ADD_COMMENT','milestone',mid);saveDB();renderMilestoneDetail(mid);flash('Comment added.');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? DECISIONS ?? */
|
|||
|
|
function renderDecisions(){
|
|||
|
|
var decs=DB.decisions.slice().sort(function(a,b){return b.createdAt.localeCompare(a.createdAt);});
|
|||
|
|
var rows=decs.map(function(d){
|
|||
|
|
var u=DB.users.find(function(x){return x.id===d.raisedBy;});
|
|||
|
|
var p=DB.phases.find(function(x){return x.id===d.phaseId;});
|
|||
|
|
return '<tr><td><div class="fw6 fs13">'+d.title+'</div>'+(d.description?'<div class="muted fs12">'+(d.description.length>65?d.description.slice(0,65)+'...':d.description)+'</div>':'')+'</td>'
|
|||
|
|
+'<td>'+(d.type?'<span class="badge b-navy" style="font-size:10px">'+d.type.charAt(0).toUpperCase()+d.type.slice(1)+'</span>':'—')+'</td>'
|
|||
|
|
+'<td>'+(p?'<span class="fs12">Ph.'+p.num+'</span>':'—')+'</td>'
|
|||
|
|
+'<td>'+sBadge(d.status)+'</td><td>'+pBadge(d.priority)+'</td>'
|
|||
|
|
+'<td class="fs12">'+(u?u.username:'—')+'</td>'
|
|||
|
|
+'<td class="fs12">'+(d.dueDate||'—')+'</td>'
|
|||
|
|
+'<td>'
|
|||
|
|
+(d.fileData?'<button class="btn btn-outline btn-xs" onclick="dlDecFile(this.dataset.did)" data-did="'+d.id+'" title="Download document" style="margin-bottom:3px"><i class="fas fa-file-download"></i> Doc</button><br>':'')
|
|||
|
|
+(isEditor()?'<div class="flex gap-1"><button class="btn btn-outline btn-xs" onclick="openDM('+d.id+')"><i class="fas fa-pencil"></i></button><button class="btn btn-danger btn-xs" onclick="delDec('+d.id+')"><i class="fas fa-trash"></i></button></div>':'')
|
|||
|
|
+'</td></tr>';
|
|||
|
|
}).join('');
|
|||
|
|
var phOpts=DB.phases.slice().sort(function(a,b){return a.order-b.order;}).map(function(p){return '<option value="'+p.id+'">Phase '+p.num+': '+p.name+'</option>';}).join('');
|
|||
|
|
g('mainContent').innerHTML=
|
|||
|
|
'<div class="flex jb ic mb-4"><p class="muted">Track all project decisions and approvals</p>'+(isEditor()?'<button class="btn btn-gold" onclick="openDM()"><i class="fas fa-plus"></i> New Decision</button>':'')+'</div>'
|
|||
|
|
+'<div class="stat-grid" style="grid-template-columns:repeat(4,1fr)">'
|
|||
|
|
+'<div class="stat-card" style="--sc:#d97706;--scb:#fef3c7"><div class="stat-icon"><i class="fas fa-hourglass-half"></i></div><div><div class="stat-val">'+decs.filter(function(d){return d.status==='pending';}).length+'</div><div class="stat-lbl">Pending</div></div></div>'
|
|||
|
|
+'<div class="stat-card" style="--sc:#15803d;--scb:#dcfce7"><div class="stat-icon"><i class="fas fa-check-circle"></i></div><div><div class="stat-val">'+decs.filter(function(d){return d.status==='approved';}).length+'</div><div class="stat-lbl">Approved</div></div></div>'
|
|||
|
|
+'<div class="stat-card" style="--sc:#1d4ed8;--scb:#dbeafe"><div class="stat-icon"><i class="fas fa-clock-rotate-left"></i></div><div><div class="stat-val">'+decs.filter(function(d){return d.status==='deferred';}).length+'</div><div class="stat-lbl">Deferred</div></div></div>'
|
|||
|
|
+'<div class="stat-card" style="--sc:#b91c1c;--scb:#fee2e2"><div class="stat-icon"><i class="fas fa-ban"></i></div><div><div class="stat-val">'+decs.filter(function(d){return d.status==='rejected';}).length+'</div><div class="stat-lbl">Rejected</div></div></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="card"><div class="card-body" style="padding:0"><div class="tbl-wrap"><table>'
|
|||
|
|
+'<thead><tr><th>Decision</th><th>Type</th><th>Phase</th><th>Status</th><th>Priority</th><th>Raised By</th><th>Due Date</th><th>Actions</th></tr></thead>'
|
|||
|
|
+'<tbody>'+(rows||'<tr><td colspan="8" style="text-align:center;padding:26px;color:var(--g400)">No decisions recorded.</td></tr>')+'</tbody>'
|
|||
|
|
+'</table></div></div></div>'
|
|||
|
|
+'<div class="overlay" id="dMod"><div class="modal"><div class="modal-hdr"><span class="modal-title" id="dMT">New Decision</span><button class="modal-close" onclick="closeModal(\'dMod\')">✕</button></div>'
|
|||
|
|
+'<div class="modal-body"><input type="hidden" id="dMId">'
|
|||
|
|
+'<div class="fg"><label>Title *</label><input type="text" id="dMTi"></div>'
|
|||
|
|
+'<div class="fg"><label>Description</label><textarea id="dMDe"></textarea></div>'
|
|||
|
|
+'<div class="fg-2"><div class="fg"><label>Type</label><select id="dMTy"><option value="">— Select —</option><option value="strategic">Strategic</option><option value="operational">Operational</option><option value="financial">Financial</option><option value="risk">Risk</option><option value="technical">Technical</option><option value="resource">Resource</option></select></div>'
|
|||
|
|
+'<div class="fg"><label>Priority</label><select id="dMPr"><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option><option value="critical">Critical</option></select></div></div>'
|
|||
|
|
+'<div class="fg-2"><div class="fg"><label>Status</label><select id="dMSt"><option value="pending">Pending</option><option value="in_review">In Review</option><option value="approved">Approved</option><option value="rejected">Rejected</option><option value="deferred">Deferred</option></select></div>'
|
|||
|
|
+'<div class="fg"><label>Due Date</label><input type="date" id="dMDu"></div></div>'
|
|||
|
|
+'<div class="fg"><label>Related Phase</label><select id="dMPh"><option value="">— None —</option>'+phOpts+'</select></div>'
|
|||
|
|
+'<div class="fg"><label>Notes / Rationale</label><textarea id="dMNo"></textarea></div></div>'
|
|||
|
|
+'<div class="modal-foot"><button class="btn btn-outline" onclick="closeModal(\'dMod\')">Cancel</button><button class="btn btn-primary" onclick="saveDec()"><i class="fas fa-save"></i> Save</button></div>'
|
|||
|
|
+'</div></div>';
|
|||
|
|
}
|
|||
|
|
function openDM(id){
|
|||
|
|
g('dMT').textContent=id?'Edit Decision':'New Decision';g('dMId').value=id||'';
|
|||
|
|
if(g('dMFi'))g('dMFi').value='';
|
|||
|
|
if(id){
|
|||
|
|
var d=DB.decisions.find(function(x){return x.id===id;});
|
|||
|
|
g('dMTi').value=d.title;g('dMDe').value=d.description||'';g('dMTy').value=d.type||'';
|
|||
|
|
g('dMPr').value=d.priority;g('dMSt').value=d.status;g('dMDu').value=d.dueDate||'';
|
|||
|
|
g('dMPh').value=d.phaseId||'';g('dMNo').value=d.notes||'';
|
|||
|
|
// Show existing document if any
|
|||
|
|
if(d.fileName&&g('dMExisting')){
|
|||
|
|
g('dMExisting').style.display='block';
|
|||
|
|
var _er=g('dMExistingRow');_er.innerHTML='';
|
|||
|
|
var _ar=document.createElement('div');_ar.className='attach-row';
|
|||
|
|
_ar.innerHTML='<i class="fas '+fIcon(d.fileType||'')+'" style="color:var(--gold);font-size:14px;flex-shrink:0"></i>'
|
|||
|
|
+'<div style="flex:1"><div class="fw6 fs12">'+(d.docTitle||d.fileName||'Document')+'</div>'
|
|||
|
|
+'<div class="muted" style="font-size:11px">'+(d.fileName||'')+'</div></div>';
|
|||
|
|
var _dlb=document.createElement('button');_dlb.className='btn btn-outline btn-xs';
|
|||
|
|
_dlb.innerHTML='<i class="fas fa-download"></i> Download';
|
|||
|
|
_dlb.onclick=(function(did){return function(){dlDecFile(did);};})(id);_ar.appendChild(_dlb);
|
|||
|
|
var _rmb=document.createElement('button');_rmb.className='btn btn-danger btn-xs';
|
|||
|
|
_rmb.innerHTML='<i class="fas fa-trash"></i> Remove';
|
|||
|
|
_rmb.onclick=(function(did){return function(){removeDecFile(did);};})(id);_ar.appendChild(_rmb);
|
|||
|
|
_er.appendChild(_ar);
|
|||
|
|
} else if(g('dMExisting')){g('dMExisting').style.display='none';}
|
|||
|
|
} else {
|
|||
|
|
['dMTi','dMDe','dMDu','dMNo'].forEach(function(x){g(x).value='';});
|
|||
|
|
g('dMTy').value='';g('dMPr').value='medium';g('dMSt').value='pending';g('dMPh').value='';
|
|||
|
|
if(g('dMExisting'))g('dMExisting').style.display='none';
|
|||
|
|
}
|
|||
|
|
openModal('dMod');
|
|||
|
|
}
|
|||
|
|
function dlDecFile(id){
|
|||
|
|
var d=DB.decisions.find(function(x){return x.id===parseInt(id);});
|
|||
|
|
if(!d||!d.fileData){alert('No file attached.');return;}
|
|||
|
|
var a=document.createElement('a');a.href=d.fileData;a.download=d.fileName||'document';a.click();
|
|||
|
|
addLog('DOWNLOAD_DEC_FILE','decision',d.id,d.fileName);saveDB();
|
|||
|
|
}
|
|||
|
|
function removeDecFile(id){
|
|||
|
|
if(!confirm('Remove the attached document?'))return;
|
|||
|
|
var d=DB.decisions.find(function(x){return x.id===parseInt(id);});
|
|||
|
|
if(!d)return;
|
|||
|
|
d.fileName=null;d.fileData=null;d.fileType=null;d.fileSize=null;d.docTitle=null;
|
|||
|
|
saveDB();openDM(parseInt(id));flash('Document removed.');
|
|||
|
|
}
|
|||
|
|
function saveDec(){
|
|||
|
|
var title=g('dMTi').value.trim();if(!title){alert('Title required.');return;}
|
|||
|
|
var id=parseInt(g('dMId').value)||null;
|
|||
|
|
var d={title:title,description:g('dMDe').value,type:g('dMTy').value,priority:g('dMPr').value,status:g('dMSt').value,dueDate:g('dMDu').value||null,phaseId:parseInt(g('dMPh').value)||null,notes:g('dMNo').value};
|
|||
|
|
var fi=g('dMFi');var file=fi?fi.files[0]:null;
|
|||
|
|
var ok=['pdf','docx','xlsx','pptx','jpg','jpeg'];
|
|||
|
|
function doSave(fileFields){
|
|||
|
|
var finalD=fileFields?Object.assign(d,fileFields):d;
|
|||
|
|
if(id){Object.assign(DB.decisions.find(function(x){return x.id===id;}),finalD);addLog('EDIT_DECISION','decision',id,title);}
|
|||
|
|
else{var nd=Object.assign({id:nid('decision'),raisedBy:CU.id,createdAt:new Date().toISOString()},finalD);DB.decisions.push(nd);addLog('ADD_DECISION','decision',nd.id,title);DB.users.filter(function(u){return u.role==='admin';}).forEach(function(u){addNotif(u.id,'New decision pending','"'+title+'" needs attention.','approval');});}
|
|||
|
|
saveDB();closeModal('dMod');renderDecisions();flash('Decision saved.');
|
|||
|
|
}
|
|||
|
|
if(file){
|
|||
|
|
var ext=file.name.split('.').pop().toLowerCase();
|
|||
|
|
if(ok.indexOf(ext)<0){alert('Only pdf, docx, xlsx, pptx, jpg allowed.');return;}
|
|||
|
|
if(file.size>10*1024*1024){alert('Max 10MB.');return;}
|
|||
|
|
var r=new FileReader();
|
|||
|
|
r.onload=function(e){doSave({fileName:file.name,fileType:ext,fileSize:file.size,fileData:e.target.result,docTitle:title+' - Document'});};
|
|||
|
|
r.readAsDataURL(file);
|
|||
|
|
} else {doSave(null);}
|
|||
|
|
}
|
|||
|
|
function delDec(id){
|
|||
|
|
if(!confirm('Delete decision?'))return;
|
|||
|
|
var d=DB.decisions.find(function(x){return x.id===id;});addLog('DELETE_DECISION','decision',id,d?d.title:'');
|
|||
|
|
DB.decisions=DB.decisions.filter(function(x){return x.id!==id;});saveDB();renderDecisions();flash('Deleted.','danger');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? RISKS ?? */
|
|||
|
|
function renderRisks(){
|
|||
|
|
var risks=DB.risks.slice().sort(function(a,b){return b.createdAt.localeCompare(a.createdAt);});
|
|||
|
|
var pb={low:'b-gray',medium:'b-warn',high:'b-danger'};
|
|||
|
|
var rows=risks.map(function(r){
|
|||
|
|
var p=DB.phases.find(function(x){return x.id===r.phaseId;});
|
|||
|
|
return '<tr><td><div class="fw6 fs13">'+r.title+'</div>'+(r.description?'<div class="muted fs12">'+(r.description.length>65?r.description.slice(0,65)+'...':r.description)+'</div>':'')+(r.mitigation?'<div class="fs12 mt-1" style="color:var(--success)"><i class="fas fa-shield-halved"></i> '+(r.mitigation.length>55?r.mitigation.slice(0,55)+'...':r.mitigation)+'</div>':'')+'</td>'
|
|||
|
|
+'<td>'+(r.category?'<span class="badge b-navy" style="font-size:10px">'+r.category.charAt(0).toUpperCase()+r.category.slice(1)+'</span>':'—')+'</td>'
|
|||
|
|
+'<td><span class="badge '+(pb[r.probability]||'b-gray')+'">'+(r.probability||'—').charAt(0).toUpperCase()+(r.probability||'').slice(1)+'</span></td>'
|
|||
|
|
+'<td><span class="badge '+(pb[r.impact]||'b-gray')+'">'+(r.impact||'—').charAt(0).toUpperCase()+(r.impact||'').slice(1)+'</span></td>'
|
|||
|
|
+'<td>'+sBadge(r.status)+'</td>'
|
|||
|
|
+'<td class="fs12">'+(r.owner||'—')+'</td>'
|
|||
|
|
+'<td>'+(p?'<span class="fs12">Ph.'+p.num+'</span>':'—')+'</td>'
|
|||
|
|
+'<td>'+(isEditor()?'<div class="flex gap-1"><button class="btn btn-outline btn-xs" onclick="openRM('+r.id+')"><i class="fas fa-pencil"></i></button><button class="btn btn-danger btn-xs" onclick="delRisk('+r.id+')"><i class="fas fa-trash"></i></button></div>':'')+'</td></tr>';
|
|||
|
|
}).join('');
|
|||
|
|
var phOpts=DB.phases.slice().sort(function(a,b){return a.order-b.order;}).map(function(p){return '<option value="'+p.id+'">Phase '+p.num+': '+p.name+'</option>';}).join('');
|
|||
|
|
g('mainContent').innerHTML=
|
|||
|
|
'<div class="flex jb ic mb-4"><p class="muted">Monitor and manage project risks</p>'+(isEditor()?'<button class="btn btn-gold" onclick="openRM()"><i class="fas fa-plus"></i> Log Risk</button>':'')+'</div>'
|
|||
|
|
+'<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px;margin-bottom:18px">'
|
|||
|
|
+'<div style="text-align:center;padding:11px;background:var(--dbg);border-radius:8px"><div style="font-size:22px;font-weight:700;color:var(--danger)">'+risks.filter(function(r){return r.status==='open';}).length+'</div><div style="font-size:11px;color:var(--danger)">Open</div></div>'
|
|||
|
|
+'<div style="text-align:center;padding:11px;background:var(--wbg);border-radius:8px"><div style="font-size:22px;font-weight:700;color:var(--warn)">'+risks.filter(function(r){return r.impact==='high';}).length+'</div><div style="font-size:11px;color:var(--warn)">High Impact</div></div>'
|
|||
|
|
+'<div style="text-align:center;padding:11px;background:var(--ibg);border-radius:8px"><div style="font-size:22px;font-weight:700;color:var(--info)">'+risks.filter(function(r){return r.status==='mitigated';}).length+'</div><div style="font-size:11px;color:var(--info)">Mitigated</div></div>'
|
|||
|
|
+'<div style="text-align:center;padding:11px;background:var(--sbg);border-radius:8px"><div style="font-size:22px;font-weight:700;color:var(--success)">'+risks.filter(function(r){return r.status==='closed';}).length+'</div><div style="font-size:11px;color:var(--success)">Closed</div></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="card"><div class="card-body" style="padding:0"><div class="tbl-wrap"><table>'
|
|||
|
|
+'<thead><tr><th>Risk</th><th>Category</th><th>Probability</th><th>Impact</th><th>Status</th><th>Owner</th><th>Phase</th><th>Actions</th></tr></thead>'
|
|||
|
|
+'<tbody>'+(rows||'<tr><td colspan="8" style="text-align:center;padding:26px;color:var(--g400)">No risks logged.</td></tr>')+'</tbody>'
|
|||
|
|
+'</table></div></div></div>'
|
|||
|
|
+'<div class="overlay" id="rMod"><div class="modal"><div class="modal-hdr"><span class="modal-title" id="rMT">Log Risk</span><button class="modal-close" onclick="closeModal(\'rMod\')">✕</button></div>'
|
|||
|
|
+'<div class="modal-body"><input type="hidden" id="rMId">'
|
|||
|
|
+'<div class="fg"><label>Title *</label><input type="text" id="rMTi"></div>'
|
|||
|
|
+'<div class="fg"><label>Description</label><textarea id="rMDe"></textarea></div>'
|
|||
|
|
+'<div class="fg-3"><div class="fg"><label>Category</label><select id="rMCa"><option value="">— Select —</option><option value="financial">Financial</option><option value="operational">Operational</option><option value="technical">Technical</option><option value="regulatory">Regulatory</option><option value="reputational">Reputational</option><option value="resource">Resource</option><option value="timeline">Timeline</option></select></div>'
|
|||
|
|
+'<div class="fg"><label>Probability</label><select id="rMPb"><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option></select></div>'
|
|||
|
|
+'<div class="fg"><label>Impact</label><select id="rMIm"><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option></select></div></div>'
|
|||
|
|
+'<div class="fg-2"><div class="fg"><label>Status</label><select id="rMSt"><option value="open">Open</option><option value="in_review">In Review</option><option value="mitigated">Mitigated</option><option value="accepted">Accepted</option><option value="closed">Closed</option></select></div>'
|
|||
|
|
+'<div class="fg"><label>Owner</label><input type="text" id="rMOw"></div></div>'
|
|||
|
|
+'<div class="fg"><label>Related Phase</label><select id="rMPh"><option value="">— None —</option>'+phOpts+'</select></div>'
|
|||
|
|
+'<div class="fg"><label>Mitigation Plan</label><textarea id="rMMi"></textarea></div></div>'
|
|||
|
|
+'<div class="modal-foot"><button class="btn btn-outline" onclick="closeModal(\'rMod\')">Cancel</button><button class="btn btn-primary" onclick="saveRisk()"><i class="fas fa-save"></i> Save</button></div>'
|
|||
|
|
+'</div></div>';
|
|||
|
|
}
|
|||
|
|
function openRM(id){
|
|||
|
|
g('rMT').textContent=id?'Edit Risk':'Log Risk';g('rMId').value=id||'';
|
|||
|
|
if(id){var r=DB.risks.find(function(x){return x.id===id;});g('rMTi').value=r.title;g('rMDe').value=r.description||'';g('rMCa').value=r.category||'';g('rMPb').value=r.probability||'medium';g('rMIm').value=r.impact||'medium';g('rMSt').value=r.status;g('rMOw').value=r.owner||'';g('rMPh').value=r.phaseId||'';g('rMMi').value=r.mitigation||'';}
|
|||
|
|
else{['rMTi','rMDe','rMOw','rMMi'].forEach(function(x){g(x).value='';});g('rMCa').value='';g('rMPb').value='medium';g('rMIm').value='medium';g('rMSt').value='open';g('rMPh').value='';}
|
|||
|
|
openModal('rMod');
|
|||
|
|
}
|
|||
|
|
function saveRisk(){
|
|||
|
|
var title=g('rMTi').value.trim();if(!title){alert('Title required.');return;}
|
|||
|
|
var id=parseInt(g('rMId').value)||null;
|
|||
|
|
var d={title:title,description:g('rMDe').value,category:g('rMCa').value,probability:g('rMPb').value,impact:g('rMIm').value,status:g('rMSt').value,owner:g('rMOw').value,phaseId:parseInt(g('rMPh').value)||null,mitigation:g('rMMi').value};
|
|||
|
|
if(id){Object.assign(DB.risks.find(function(x){return x.id===id;}),d);addLog('EDIT_RISK','risk',id,title);}
|
|||
|
|
else{var nr=Object.assign({id:nid('risk'),createdBy:CU.id,createdAt:new Date().toISOString()},d);DB.risks.push(nr);addLog('ADD_RISK','risk',nr.id,title);}
|
|||
|
|
saveDB(true,'Risk "'+title+'" was '+(id?'updated':'added')+' by '+CU.fullName);closeModal('rMod');renderRisks();flash('Risk saved.');
|
|||
|
|
}
|
|||
|
|
function delRisk(id){
|
|||
|
|
if(!confirm('Delete risk?'))return;
|
|||
|
|
var r=DB.risks.find(function(x){return x.id===id;});addLog('DELETE_RISK','risk',id,r?r.title:'');
|
|||
|
|
DB.risks=DB.risks.filter(function(x){return x.id!==id;});saveDB();renderRisks();flash('Deleted.','danger');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? USERS ?? */
|
|||
|
|
function renderUsers(){
|
|||
|
|
if(!isAdmin()){g('mainContent').innerHTML='<div class="empty"><i class="fas fa-lock"></i><p>Admin access required.</p></div>';return;}
|
|||
|
|
var rows=DB.users.map(function(u){
|
|||
|
|
return '<tr><td><div class="flex ic gap-2">'
|
|||
|
|
+'<div style="width:27px;height:27px;border-radius:50%;background:var(--navy);display:flex;align-items:center;justify-content:center;color:#fff;font-size:11px;font-weight:700">'+u.username[0].toUpperCase()+'</div>'
|
|||
|
|
+'<div><div class="fw6 fs13">'+(u.fullName||u.username)+'</div><div class="muted fs12">@'+u.username+'</div></div></div></td>'
|
|||
|
|
+'<td class="fs12">'+u.email+'</td><td class="fs12">'+(u.dept||'—')+'</td>'
|
|||
|
|
+'<td><span class="badge '+(u.role==='admin'?'b-navy':u.role==='editor'?'b-info':'b-gray')+'">'+u.role.charAt(0).toUpperCase()+u.role.slice(1)+'</span></td>'
|
|||
|
|
+'<td><span class="badge '+(u.active?'b-success':'b-danger')+'">'+(u.active?'Active':'Inactive')+'</span></td>'
|
|||
|
|
+'<td class="muted fs12">'+(u.lastLogin?u.lastLogin.slice(0,16).replace('T',' '):'Never')+'</td>'
|
|||
|
|
+'<td><div class="flex gap-1"><button class="btn btn-outline btn-xs" onclick="openUM('+u.id+')"><i class="fas fa-pencil"></i></button>'
|
|||
|
|
+(u.id!==CU.id?'<button class="btn btn-danger btn-xs" onclick="delUser('+u.id+')"><i class="fas fa-trash"></i></button>':'')
|
|||
|
|
+'</div></td></tr>';
|
|||
|
|
}).join('');
|
|||
|
|
g('mainContent').innerHTML=
|
|||
|
|
'<div class="flex jb ic mb-4"><p class="muted">Manage users, roles and portal access</p><button class="btn btn-gold" onclick="openUM()"><i class="fas fa-user-plus"></i> Add User</button></div>'
|
|||
|
|
+'<div class="stat-grid" style="grid-template-columns:repeat(4,1fr);margin-bottom:18px">'
|
|||
|
|
+'<div class="stat-card" style="--sc:#c9a227;--scb:#f5e9c0"><div class="stat-icon"><i class="fas fa-users"></i></div><div><div class="stat-val">'+DB.users.length+'</div><div class="stat-lbl">Total Users</div></div></div>'
|
|||
|
|
+'<div class="stat-card" style="--sc:#1a3a5c;--scb:#e0e7f0"><div class="stat-icon"><i class="fas fa-user-shield"></i></div><div><div class="stat-val">'+DB.users.filter(function(u){return u.role==='admin';}).length+'</div><div class="stat-lbl">Admins</div></div></div>'
|
|||
|
|
+'<div class="stat-card" style="--sc:#1d4ed8;--scb:#dbeafe"><div class="stat-icon"><i class="fas fa-user-pen"></i></div><div><div class="stat-val">'+DB.users.filter(function(u){return u.role==='editor';}).length+'</div><div class="stat-lbl">Editors</div></div></div>'
|
|||
|
|
+'<div class="stat-card" style="--sc:#15803d;--scb:#dcfce7"><div class="stat-icon"><i class="fas fa-eye"></i></div><div><div class="stat-val">'+DB.users.filter(function(u){return u.role==='viewer';}).length+'</div><div class="stat-lbl">Viewers</div></div></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="card mb-4"><div class="card-hdr"><span class="card-title"><i class="fas fa-users tgold"></i> All Users</span></div>'
|
|||
|
|
+'<div class="card-body" style="padding:0"><div class="tbl-wrap"><table>'
|
|||
|
|
+'<thead><tr><th>User</th><th>Email</th><th>Department</th><th>Role</th><th>Status</th><th>Last Login</th><th>Actions</th></tr></thead>'
|
|||
|
|
+'<tbody>'+rows+'</tbody></table></div></div></div>'
|
|||
|
|
+'<div class="card"><div class="card-hdr"><span class="card-title"><i class="fas fa-bell tgold"></i> Send Notification</span></div><div class="card-body">'
|
|||
|
|
+'<div class="fg"><label>Recipients</label><div style="display:flex;flex-wrap:wrap;gap:7px">'
|
|||
|
|
+DB.users.map(function(u){return '<label style="display:flex;align-items:center;gap:6px;font-size:13px;text-transform:none;letter-spacing:0;cursor:pointer;padding:4px 9px;background:var(--g50);border:1px solid var(--g200);border-radius:6px"><input type="checkbox" name="nuid" value="'+u.id+'" style="width:auto;margin:0"> '+(u.fullName||u.username)+'</label>';}).join('')
|
|||
|
|
+'</div></div>'
|
|||
|
|
+'<div class="fg-2"><div class="fg"><label>Title *</label><input type="text" id="nTi" placeholder="Title"></div><div class="fg"><label>Message</label><input type="text" id="nMs" placeholder="Optional message"></div></div>'
|
|||
|
|
+'<button class="btn btn-primary" onclick="sendNotif()"><i class="fas fa-paper-plane"></i> Send</button></div></div>'
|
|||
|
|
+'<div class="overlay" id="uMod"><div class="modal"><div class="modal-hdr"><span class="modal-title" id="uMT">Add User</span><button class="modal-close" onclick="closeModal(\'uMod\')">✕</button></div>'
|
|||
|
|
+'<div class="modal-body"><input type="hidden" id="uMId">'
|
|||
|
|
+'<div class="fg-2"><div class="fg"><label>Username *</label><input type="text" id="uMUn"></div><div class="fg"><label>Full Name</label><input type="text" id="uMFn"></div></div>'
|
|||
|
|
+'<div class="fg-2"><div class="fg"><label>Email *</label><input type="email" id="uMEm"></div><div class="fg"><label>Department</label><input type="text" id="uMDe"></div></div>'
|
|||
|
|
+'<div class="fg-2"><div class="fg"><label>Role *</label><select id="uMRo"><option value="viewer">Viewer</option><option value="editor">Editor</option><option value="admin">Admin</option></select>'
|
|||
|
|
+'<div class="muted fs12 mt-2">Admin: full · Editor: modify · Viewer: read-only</div></div>'
|
|||
|
|
+'<div class="fg"><label>Active</label><div style="display:flex;align-items:center;gap:8px;margin-top:10px"><input type="checkbox" id="uMAc" checked style="width:auto"><label for="uMAc" style="font-size:13px;text-transform:none;letter-spacing:0;cursor:pointer;margin:0">Active account</label></div></div></div>'
|
|||
|
|
+'<div class="divider"></div>'
|
|||
|
|
+'<div class="fg-2"><div class="fg"><label id="uMPL">Password *</label><input type="password" id="uMPw"></div><div class="fg"><label>Confirm</label><input type="password" id="uMPw2"></div></div></div>'
|
|||
|
|
+'<div class="modal-foot"><button class="btn btn-outline" onclick="closeModal(\'uMod\')">Cancel</button><button class="btn btn-primary" onclick="saveUser()"><i class="fas fa-save"></i> Save</button></div>'
|
|||
|
|
+'</div></div>';
|
|||
|
|
}
|
|||
|
|
function openUM(id){
|
|||
|
|
g('uMT').textContent=id?'Edit User':'Add User';g('uMId').value=id||'';
|
|||
|
|
g('uMPL').textContent=id?'New Password (leave blank to keep)':'Password *';
|
|||
|
|
if(id){var u=DB.users.find(function(x){return x.id===id;});g('uMUn').value=u.username;g('uMFn').value=u.fullName||'';g('uMEm').value=u.email;g('uMDe').value=u.dept||'';g('uMRo').value=u.role;g('uMAc').checked=u.active;g('uMPw').value='';g('uMPw2').value='';}
|
|||
|
|
else{['uMUn','uMFn','uMEm','uMDe','uMPw','uMPw2'].forEach(function(x){g(x).value='';});g('uMRo').value='viewer';g('uMAc').checked=true;}
|
|||
|
|
openModal('uMod');
|
|||
|
|
}
|
|||
|
|
function saveUser(){
|
|||
|
|
var uname=g('uMUn').value.trim(),email=g('uMEm').value.trim();
|
|||
|
|
var pw=g('uMPw').value,pw2=g('uMPw2').value;
|
|||
|
|
var id=parseInt(g('uMId').value)||null;
|
|||
|
|
if(!uname||!email){alert('Username and email required.');return;}
|
|||
|
|
if(pw&&pw!==pw2){alert('Passwords do not match.');return;}
|
|||
|
|
if(!id&&!pw){alert('Password required for new user.');return;}
|
|||
|
|
if(!id&&DB.users.find(function(u){return u.username===uname;})){alert('Username already exists.');return;}
|
|||
|
|
var oldRole=id?DB.users.find(function(u){return u.id===id;}).role:null;
|
|||
|
|
var d={username:uname,email:email,fullName:g('uMFn').value,dept:g('uMDe').value,role:g('uMRo').value,active:g('uMAc').checked};
|
|||
|
|
if(pw)d.password=pw;
|
|||
|
|
if(id){Object.assign(DB.users.find(function(u){return u.id===id;}),d);addLog('EDIT_USER','user',id,uname,'Role:'+d.role);if(oldRole!==d.role)addNotif(id,'Permission Updated','Your role changed from '+oldRole+' to '+d.role+'.','permission');}
|
|||
|
|
else{var nu=Object.assign({id:nid('user'),lastLogin:null},d);DB.users.push(nu);addLog('CREATE_USER','user',nu.id,uname);addNotif(nu.id,'Welcome to Pinnacle','Your account was created with role: '+d.role+'.','permission');}
|
|||
|
|
saveDB(true,'User "'+uname+'" was '+(id?'updated':'added')+' by '+CU.fullName);closeModal('uMod');renderUsers();flash('User saved.');
|
|||
|
|
}
|
|||
|
|
function delUser(id){
|
|||
|
|
if(id===CU.id){alert("Cannot delete your own account.");return;}
|
|||
|
|
if(!confirm('Delete user?'))return;
|
|||
|
|
var u=DB.users.find(function(x){return x.id===id;});addLog('DELETE_USER','user',id,u?u.username:'');
|
|||
|
|
DB.users=DB.users.filter(function(x){return x.id!==id;});saveDB();renderUsers();flash('User deleted.','danger');
|
|||
|
|
}
|
|||
|
|
function sendNotif(){
|
|||
|
|
var title=g('nTi').value.trim();if(!title){alert('Title required.');return;}
|
|||
|
|
var msg=g('nMs').value;
|
|||
|
|
var boxes=[].slice.call(document.querySelectorAll('input[name="nuid"]:checked'));
|
|||
|
|
if(!boxes.length){alert('Select at least one recipient.');return;}
|
|||
|
|
boxes.forEach(function(b){addNotif(parseInt(b.value),title,msg,'admin');});
|
|||
|
|
saveDB();flash('Notification sent to '+boxes.length+' user(s).');
|
|||
|
|
g('nTi').value='';g('nMs').value='';
|
|||
|
|
document.querySelectorAll('input[name="nuid"]').forEach(function(b){b.checked=false;});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? LOGS ?? */
|
|||
|
|
function renderLogs(){
|
|||
|
|
if(!isAdmin()){g('mainContent').innerHTML='<div class="empty"><i class="fas fa-lock"></i><p>Admin access required.</p></div>';return;}
|
|||
|
|
var logs=[].concat(DB.logs).reverse();
|
|||
|
|
var rows=logs.map(function(l){
|
|||
|
|
var u=DB.users.find(function(x){return x.id===l.userId;});
|
|||
|
|
return '<tr><td class="muted fs12" style="white-space:nowrap">'+l.timestamp.slice(0,19).replace('T',' ')+'</td>'
|
|||
|
|
+'<td><span class="badge b-navy" style="font-size:10px">'+(u?u.username:'System')+'</span></td>'
|
|||
|
|
+'<td><span class="badge b-gold" style="font-size:10px">'+l.action+'</span></td>'
|
|||
|
|
+'<td class="muted fs12">'+(l.entityType||'—')+'</td>'
|
|||
|
|
+'<td class="fs12">'+((l.entityName||'').slice(0,40)||'—')+'</td>'
|
|||
|
|
+'<td class="muted fs12">'+((l.details||'').slice(0,50)||'—')+'</td></tr>';
|
|||
|
|
}).join('');
|
|||
|
|
g('mainContent').innerHTML=
|
|||
|
|
'<div class="flex jb ic mb-4"><p class="muted">Full system audit trail — '+logs.length+' entries</p>'
|
|||
|
|
+'<button class="btn btn-outline btn-sm" onclick="exportLogs()"><i class="fas fa-file-csv"></i> Export CSV</button></div>'
|
|||
|
|
+'<div class="card"><div class="card-body" style="padding:0"><div class="tbl-wrap"><table>'
|
|||
|
|
+'<thead><tr><th>Timestamp</th><th>User</th><th>Action</th><th>Entity</th><th>Name</th><th>Details</th></tr></thead>'
|
|||
|
|
+'<tbody>'+(rows||'<tr><td colspan="6" style="text-align:center;padding:26px;color:var(--g400)">No logs yet.</td></tr>')+'</tbody>'
|
|||
|
|
+'</table></div></div></div>';
|
|||
|
|
}
|
|||
|
|
function exportLogs(){
|
|||
|
|
var logs=[].concat(DB.logs).reverse();
|
|||
|
|
var hdr='Timestamp,User,Action,Entity Type,Entity Name,Details\n';
|
|||
|
|
var rows=logs.map(function(l){var u=DB.users.find(function(x){return x.id===l.userId;});return [l.timestamp.slice(0,19).replace('T',' '),u?u.username:'System',l.action,l.entityType||'',l.entityName||'',l.details||''].map(function(v){return '"'+String(v).replace(/"/g,'""')+'"';}).join(',');}).join('\n');
|
|||
|
|
var a=document.createElement('a');a.href=URL.createObjectURL(new Blob([hdr+rows],{type:'text/csv'}));a.download='pinnacle_logs_'+today()+'.csv';a.click();
|
|||
|
|
addLog('EXPORT_LOGS','system');saveDB();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? PROFILE ?? */
|
|||
|
|
function renderProfile(){
|
|||
|
|
var pref=getUserEmailPref(CU.id);
|
|||
|
|
var notifEmail=pref.email||CU.email||'';
|
|||
|
|
g('mainContent').innerHTML=
|
|||
|
|
'<div style="max-width:620px">'
|
|||
|
|
+'<div class="card"><div class="card-hdr"><span class="card-title"><i class="fas fa-user-circle tgold"></i> Account Settings</span></div><div class="card-body">'
|
|||
|
|
+'<div class="fg-2"><div class="fg"><label>Full Name</label><input type="text" id="pFn" value="'+(CU.fullName||'')+'"></div><div class="fg"><label>Username</label><input type="text" value="'+CU.username+'" disabled style="background:var(--g50);color:var(--g400)"></div></div>'
|
|||
|
|
+'<div class="fg-2"><div class="fg"><label>Email</label><input type="email" id="pEm" value="'+(CU.email||'')+'"></div><div class="fg"><label>Department</label><input type="text" id="pDe" value="'+(CU.dept||'')+'"></div></div>'
|
|||
|
|
+'<div class="divider"></div>'
|
|||
|
|
+'<div style="font-family:\'Playfair Display\',serif;font-size:14px;color:var(--navy);margin-bottom:10px">Change Password</div>'
|
|||
|
|
+'<div class="fg-2"><div class="fg"><label>Current Password</label><input type="password" id="pCp"></div><div class="fg"><label>New Password</label><input type="password" id="pNp"></div></div>'
|
|||
|
|
+'<div class="flex gap-2"><button class="btn btn-primary" onclick="saveProfile()"><i class="fas fa-save"></i> Save Changes</button><button class="btn btn-outline" onclick="showPage(\'dashboard\')">Cancel</button></div>'
|
|||
|
|
+'</div></div>'
|
|||
|
|
// Email notification preferences
|
|||
|
|
+'<div class="card"><div class="card-hdr"><span class="card-title"><i class="fas fa-envelope tgold"></i> Email Notifications</span></div><div class="card-body">'
|
|||
|
|
+'<p style="font-size:13px;color:var(--g600);margin-bottom:14px">Receive an email whenever any user makes a change to the dashboard. Requires EmailJS to be configured by an admin.</p>'
|
|||
|
|
+'<div style="background:var(--g50);border:1px solid var(--g200);border-radius:8px;padding:14px;margin-bottom:14px">'
|
|||
|
|
+'<div class="email-toggle" style="margin-bottom:12px">'
|
|||
|
|
+'<label class="toggle-switch"><input type="checkbox" id="emailToggle" '+(pref.enabled?'checked':'')+' onchange="previewEmailToggle()"><span class="toggle-slider"></span></label>'
|
|||
|
|
+'<div><div style="font-size:13px;font-weight:600;color:var(--navy)">Email alerts for dashboard changes</div>'
|
|||
|
|
+'<div style="font-size:11px;color:var(--g400);margin-top:2px">You will be notified when others update data</div></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="fg" style="margin-bottom:6px"><label>Notification Email Address</label>'
|
|||
|
|
+'<input type="email" id="notifEmail" placeholder="your@email.com" value="'+notifEmail+'">'
|
|||
|
|
+'<div style="font-size:11px;color:var(--g400);margin-top:4px">Leave blank to use your account email ('+CU.email+')</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div style="display:flex;gap:8px;flex-wrap:wrap">'
|
|||
|
|
+'<button class="btn btn-primary" onclick="saveEmailPrefs()"><i class="fas fa-save"></i> Save Notification Prefs</button>'
|
|||
|
|
+'<button class="btn btn-outline btn-sm" onclick="testEmailNotif()" id="testEmailBtn" style="margin-left:auto"><i class="fas fa-paper-plane"></i> Send Test Email</button>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'</div></div>';
|
|||
|
|
}
|
|||
|
|
function previewEmailToggle(){
|
|||
|
|
var on=document.getElementById('emailToggle')&&document.getElementById('emailToggle').checked;
|
|||
|
|
// visual only
|
|||
|
|
}
|
|||
|
|
function saveEmailPrefs(){
|
|||
|
|
var enabled=document.getElementById('emailToggle')&&document.getElementById('emailToggle').checked;
|
|||
|
|
var email=(document.getElementById('notifEmail')||{}).value||'';
|
|||
|
|
setUserEmailPref(CU.id,enabled,email);
|
|||
|
|
flash(enabled?'Email notifications enabled. You will receive alerts when changes are made.':'Email notifications disabled.');
|
|||
|
|
}
|
|||
|
|
function testEmailNotif(){
|
|||
|
|
var pk=DB._ejPk||''; var svc=DB._ejSvc||''; var tpl=DB._ejTpl||'';
|
|||
|
|
if(!pk||!svc||!tpl){flash('EmailJS is not configured yet. Click the ☁ button in the topbar → fill in Step 2.','danger');return;}
|
|||
|
|
if(!window.emailjs){flash('EmailJS library not loaded. Check your internet connection.','danger');return;}
|
|||
|
|
var pref=getUserEmailPref(CU.id);
|
|||
|
|
var emailAddr=(pref.email||CU.email||'').trim();
|
|||
|
|
if(!emailAddr||emailAddr.indexOf('@')<0){flash('Please enter a valid notification email address below first.','danger');return;}
|
|||
|
|
var btn=document.getElementById('testEmailBtn');
|
|||
|
|
if(btn){btn.disabled=true;btn.innerHTML='<i class="fas fa-spinner fa-spin"></i> Sending...';}
|
|||
|
|
try{emailjs.init({publicKey:pk});}catch(e){}
|
|||
|
|
emailjs.send(svc, tpl, {
|
|||
|
|
to_name: CU.fullName||CU.username,
|
|||
|
|
to_email: emailAddr,
|
|||
|
|
from_name: 'Pinnacle Decision Portal',
|
|||
|
|
reply_to: emailAddr,
|
|||
|
|
changed_by: 'Test System',
|
|||
|
|
change_description: 'This is a test notification from the Pinnacle Decision Portal. If you received this, email notifications are working correctly.',
|
|||
|
|
portal_url: window.location.href,
|
|||
|
|
timestamp: new Date().toLocaleString()
|
|||
|
|
}).then(function(){
|
|||
|
|
flash('<i class="fas fa-check-circle"></i> Test email sent to '+emailAddr+' — check your inbox!','success');
|
|||
|
|
if(btn){btn.disabled=false;btn.innerHTML='<i class="fas fa-paper-plane"></i> Send Test Email';}
|
|||
|
|
}).catch(function(e){
|
|||
|
|
var msg=e.text||e.message||JSON.stringify(e);
|
|||
|
|
flash('Email failed: '+msg+' — double-check your Service ID, Template ID, and that the template uses {{to_email}} as the recipient.','danger');
|
|||
|
|
if(btn){btn.disabled=false;btn.innerHTML='<i class="fas fa-paper-plane"></i> Send Test Email';}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
function saveProfile(){
|
|||
|
|
var cp=g('pCp').value,np=g('pNp').value;
|
|||
|
|
if(np){if(cp!==CU.password){flash('Current password incorrect.','danger');return;}CU.password=np;}
|
|||
|
|
CU.fullName=g('pFn').value;CU.email=g('pEm').value;CU.dept=g('pDe').value;
|
|||
|
|
Object.assign(DB.users.find(function(x){return x.id===CU.id;}),CU);
|
|||
|
|
addLog('UPDATE_PROFILE','user',CU.id,CU.username);saveDB();
|
|||
|
|
g('userAv').textContent=(CU.fullName||CU.username)[0].toUpperCase();
|
|||
|
|
g('userName').textContent=CU.fullName||CU.username;
|
|||
|
|
flash('Profile updated.');renderProfile();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? IMPORT / EXPORT ?? */
|
|||
|
|
function openIE(){
|
|||
|
|
if(!g('ieModal')){
|
|||
|
|
var el=document.createElement('div');
|
|||
|
|
el.innerHTML='<div class="overlay open" id="ieModal"><div class="modal" style="max-width:520px">'
|
|||
|
|
+'<div class="modal-hdr"><span class="modal-title"><i class="fas fa-exchange-alt"></i> Import / Export Data</span><button class="modal-close" onclick="closeModal(\'ieModal\')">✕</button></div>'
|
|||
|
|
+'<div class="modal-body">'
|
|||
|
|
+'<div style="background:var(--ibg);border:1px solid #bfdbfe;border-radius:8px;padding:14px;margin-bottom:18px;font-size:13px;color:var(--info)">'
|
|||
|
|
+'<strong><i class="fas fa-info-circle"></i> How to share with your boss:</strong><br><br>'
|
|||
|
|
+'<b>Step 1:</b> Click <em>Export Data</em> — saves a .json file<br>'
|
|||
|
|
+'<b>Step 2:</b> Email <b>both</b> the HTML portal file <b>and</b> the .json file<br>'
|
|||
|
|
+'<b>Step 3:</b> Boss opens the HTML, clicks the ⇄ button, imports the .json — sees all your data<br>'
|
|||
|
|
+'<b>Step 4:</b> Boss makes updates, exports their .json, emails it back<br>'
|
|||
|
|
+'<b>Step 5:</b> You import it — portal updates with all changes</div>'
|
|||
|
|
+'<div style="border:1px solid var(--g200);border-radius:8px;padding:16px;margin-bottom:14px">'
|
|||
|
|
+'<div style="font-family:\'Playfair Display\',serif;font-size:14px;color:var(--navy);margin-bottom:6px"><i class="fas fa-file-export tgold"></i> Export Current Data</div>'
|
|||
|
|
+'<p style="font-size:13px;color:var(--g600);margin-bottom:12px">Downloads all data as a single .json file.</p>'
|
|||
|
|
+'<button class="btn btn-primary" onclick="doExport()"><i class="fas fa-download"></i> Export Data as JSON</button></div>'
|
|||
|
|
+'<div style="border:1px solid var(--g200);border-radius:8px;padding:16px">'
|
|||
|
|
+'<div style="font-family:\'Playfair Display\',serif;font-size:14px;color:var(--navy);margin-bottom:6px"><i class="fas fa-file-import tgold"></i> Import Data</div>'
|
|||
|
|
+'<p style="font-size:13px;color:var(--g600);margin-bottom:12px">Select a Pinnacle export .json file. <strong style="color:var(--danger)">This will replace all current data.</strong></p>'
|
|||
|
|
+'<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">'
|
|||
|
|
+'<input type="file" id="ieFile" accept=".json" style="font-size:13px;flex:1;min-width:200px;border:1px solid var(--g200);border-radius:6px;padding:6px 10px">'
|
|||
|
|
+'<button class="btn btn-gold" onclick="doImport()"><i class="fas fa-upload"></i> Import</button></div></div>'
|
|||
|
|
+'</div><div class="modal-foot"><button class="btn btn-outline" onclick="closeModal(\'ieModal\')">Close</button></div>'
|
|||
|
|
+'</div></div>';
|
|||
|
|
document.body.appendChild(el.firstElementChild);
|
|||
|
|
} else { openModal('ieModal'); }
|
|||
|
|
}
|
|||
|
|
function doExport(){
|
|||
|
|
var payload={exportedAt:new Date().toISOString(),exportedBy:CU.fullName||CU.username,version:'pinnacle_v3',data:DB};
|
|||
|
|
var a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}));
|
|||
|
|
a.download='Pinnacle_Data_'+today()+'.json';a.click();
|
|||
|
|
addLog('EXPORT_DATA','system');saveDB();flash('Data exported successfully.');
|
|||
|
|
}
|
|||
|
|
function doImport(){
|
|||
|
|
var fi=g('ieFile');var file=fi?fi.files[0]:null;
|
|||
|
|
if(!file){alert('Please select a .json file first.');return;}
|
|||
|
|
var r=new FileReader();
|
|||
|
|
r.onload=function(e){
|
|||
|
|
try{
|
|||
|
|
var p=JSON.parse(e.target.result);
|
|||
|
|
var incoming=p.data||p;
|
|||
|
|
if(!incoming.users||!incoming.phases||!incoming.milestones){alert('Invalid file. Please select a Pinnacle export file.');return;}
|
|||
|
|
if(!confirm('Replace ALL current data with the imported data?'))return;
|
|||
|
|
DB=incoming;
|
|||
|
|
if(!DB.ids)DB.ids={user:999,phase:999,milestone:999,activity:999,deliverable:999,decision:999,risk:999,comment:999,log:999,notification:999};
|
|||
|
|
saveDB();
|
|||
|
|
var mu=DB.users.find(function(u){return u.username===CU.username;});
|
|||
|
|
if(mu){CU=mu;g('userAv').textContent=(CU.fullName||CU.username)[0].toUpperCase();g('userName').textContent=CU.fullName||CU.username;g('userRole').textContent=CU.role;g('adminNav').style.display=CU.role==='admin'?'':'none';}
|
|||
|
|
closeModal('ieModal');showPage('dashboard');flash('Data imported successfully!');
|
|||
|
|
}catch(err){alert('Failed to read file.\n'+err.message);}
|
|||
|
|
};r.readAsText(file);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? INIT ?? */
|
|||
|
|
loadDB(); initEmailJS(); updateSyncUI();
|
|||
|
|
|
|||
|
|
/* ── AUTO-CONNECT JSONBIN ──
|
|||
|
|
Replace the two values below with your NEW credentials
|
|||
|
|
after regenerating them on jsonbin.io
|
|||
|
|
── ────────────────────── */
|
|||
|
|
(function(){
|
|||
|
|
var BIN_ID = DB._jbBinId || ''; // set via Settings → ☁ button
|
|||
|
|
var API_KEY = DB._jbApiKey || ''; // set via Settings → ☁ button
|
|||
|
|
if(BIN_ID && API_KEY){
|
|||
|
|
_JB.binId = BIN_ID;
|
|||
|
|
_JB.apiKey = API_KEY;
|
|||
|
|
_JB.enabled = true;
|
|||
|
|
jbSetStatus('live','Synced');
|
|||
|
|
jbStartPolling();
|
|||
|
|
updateSyncUI();
|
|||
|
|
console.log('JSONBin auto-connected from saved config.');
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
if(window.innerWidth<900)g('mobBtn').style.display='flex';
|
|||
|
|
|
|||
|
|
/* ?? GANTT CHART ? ENHANCED INTERACTIVE ?? */
|
|||
|
|
/* Global date resolver — used by Gantt, Executive Summary, and Upcoming panels */
|
|||
|
|
function resolveMilDate(m, projStart){
|
|||
|
|
var ps = projStart || (DB.projectStart ? new Date(DB.projectStart) : new Date());
|
|||
|
|
ps = new Date(ps.getFullYear(), ps.getMonth(), 1);
|
|||
|
|
var sd = m.tentDate ? new Date(m.tentDate) : (m.startDate ? new Date(m.startDate) : null);
|
|||
|
|
var ed = m.endDate ? new Date(m.endDate) : null;
|
|||
|
|
if(!sd && !ed && m.month){
|
|||
|
|
var parts = String(m.month).split('-').map(function(p){return parseInt(p.trim(),10);});
|
|||
|
|
var sm2 = parts[0]||1;
|
|||
|
|
var em2 = parts[parts.length-1]||sm2;
|
|||
|
|
sd = new Date(ps.getFullYear(), ps.getMonth()+sm2-1, 1);
|
|||
|
|
ed = new Date(ps.getFullYear(), ps.getMonth()+em2, 0);
|
|||
|
|
}
|
|||
|
|
if(sd && !ed) ed = sd;
|
|||
|
|
if(!sd && ed) sd = ed;
|
|||
|
|
return {sd:sd, ed:ed};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var _ganttTooltip=null;
|
|||
|
|
function renderGantt(phases){
|
|||
|
|
var box=document.getElementById('ganttBox');
|
|||
|
|
if(!box)return;
|
|||
|
|
|
|||
|
|
// Collect all milestones
|
|||
|
|
var allM=[];
|
|||
|
|
phases.forEach(function(p){
|
|||
|
|
DB.milestones.filter(function(m){return m.phaseId===p.id;}).forEach(function(m){
|
|||
|
|
allM.push({phase:p,milestone:m});
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
if(!allM.length){box.innerHTML='<div class="empty" style="padding:30px"><i class="fas fa-stream"></i><p>Add milestones with dates to see the Gantt chart.</p></div>';return;}
|
|||
|
|
|
|||
|
|
// Resolve dates — use tentDate/endDate if set, otherwise derive from month field
|
|||
|
|
var projStart = DB.projectStart ? new Date(DB.projectStart) : new Date();
|
|||
|
|
projStart = new Date(projStart.getFullYear(), projStart.getMonth(), 1);
|
|||
|
|
|
|||
|
|
function monthFieldToDates(monthStr){
|
|||
|
|
if(!monthStr) return {s:null,e:null};
|
|||
|
|
var parts = String(monthStr).split('-').map(function(p){return parseInt(p.trim(),10);});
|
|||
|
|
var startM = parts[0]||1;
|
|||
|
|
var endM = parts[parts.length-1]||startM;
|
|||
|
|
var s = new Date(projStart.getFullYear(), projStart.getMonth()+startM-1, 1);
|
|||
|
|
var e = new Date(projStart.getFullYear(), projStart.getMonth()+endM, 0);
|
|||
|
|
return {s:s, e:e};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolveDate(m){
|
|||
|
|
return resolveMilDate(m, projStart);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var dates=[];
|
|||
|
|
allM.forEach(function(x){
|
|||
|
|
var r=resolveDate(x.milestone);
|
|||
|
|
if(r.sd) dates.push(r.sd);
|
|||
|
|
if(r.ed) dates.push(r.ed);
|
|||
|
|
});
|
|||
|
|
if(!dates.length){box.innerHTML='<div class="empty" style="padding:30px"><i class="fas fa-stream"></i><p>Set milestone dates or a project start date to see the Gantt chart.</p></div>';return;}
|
|||
|
|
|
|||
|
|
// Timeline bounds — snap to month boundaries
|
|||
|
|
var minRaw=new Date(Math.min.apply(null,dates));
|
|||
|
|
var maxRaw=new Date(Math.max.apply(null,dates));
|
|||
|
|
var minD=new Date(minRaw.getFullYear(),minRaw.getMonth(),1); // start of first month
|
|||
|
|
var maxD=new Date(maxRaw.getFullYear(),maxRaw.getMonth()+1,0); // end of last month
|
|||
|
|
var totalMs=maxD-minD;
|
|||
|
|
var totalDays=totalMs/(1000*60*60*24);
|
|||
|
|
|
|||
|
|
// Colours
|
|||
|
|
// Phase colour palette — rich professional colours
|
|||
|
|
var pColors=[
|
|||
|
|
['#6366f1','#4f46e5','ph-pill-1'], // indigo
|
|||
|
|
['#0ea5e9','#0284c7','ph-pill-2'], // sky
|
|||
|
|
['#10b981','#059669','ph-pill-3'], // emerald
|
|||
|
|
['#f59e0b','#d97706','ph-pill-4'], // amber
|
|||
|
|
['#ec4899','#db2777','ph-pill-5'], // pink
|
|||
|
|
['#8b5cf6','#7c3aed','ph-pill-6'], // violet
|
|||
|
|
];
|
|||
|
|
var statusColors={
|
|||
|
|
completed: ['#10b981','#059669'],
|
|||
|
|
delayed: ['#f43f5e','#e11d48'],
|
|||
|
|
in_progress:['#6366f1','#4f46e5'],
|
|||
|
|
not_started:['#94a3b8','#64748b']
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Build month list — each month covers exactly its calendar days
|
|||
|
|
var months=[];
|
|||
|
|
var cur=new Date(minD.getFullYear(),minD.getMonth(),1);
|
|||
|
|
while(cur<=maxD){
|
|||
|
|
var mEnd=new Date(cur.getFullYear(),cur.getMonth()+1,0); // last day of month
|
|||
|
|
var days=(mEnd-cur)/(1000*60*60*24)+1;
|
|||
|
|
months.push({
|
|||
|
|
label: cur.toLocaleString('default',{month:'long',year:'numeric'}),
|
|||
|
|
shortLabel: cur.toLocaleString('default',{month:'short',year:'2-digit'}),
|
|||
|
|
start: new Date(cur),
|
|||
|
|
end: new Date(cur.getFullYear(),cur.getMonth()+1,0,23,59,59),
|
|||
|
|
days: days,
|
|||
|
|
pct: days/totalDays*100
|
|||
|
|
});
|
|||
|
|
cur.setMonth(cur.getMonth()+1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Today metrics
|
|||
|
|
var now=new Date();
|
|||
|
|
var todayPct=Math.max(0,Math.min(100,(now-minD)/totalMs*100));
|
|||
|
|
var nowInRange=now>=minD&&now<=maxD;
|
|||
|
|
var todayStr=now.toLocaleDateString('default',{weekday:'short',day:'numeric',month:'long',year:'numeric'});
|
|||
|
|
|
|||
|
|
// Which month is current?
|
|||
|
|
var currentMonthIdx=-1;
|
|||
|
|
months.forEach(function(m,i){if(now>=m.start&&now<=m.end)currentMonthIdx=i;});
|
|||
|
|
|
|||
|
|
// Which milestone is currently active (in_progress and today within its range)?
|
|||
|
|
var activeMillId=-1;
|
|||
|
|
allM.forEach(function(x){
|
|||
|
|
var m=x.milestone;
|
|||
|
|
var s=m.tentDate?new Date(m.tentDate):null;
|
|||
|
|
var e=m.endDate?new Date(m.endDate):null;
|
|||
|
|
if(s&&e&&now>=s&&now<=e&&m.status!=='completed'){activeMillId=m.id;}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Phase index
|
|||
|
|
var phaseIdx={};
|
|||
|
|
phases.forEach(function(p,i){phaseIdx[p.id]=i;});
|
|||
|
|
|
|||
|
|
// Tooltip element
|
|||
|
|
if(!_ganttTooltip){
|
|||
|
|
_ganttTooltip=document.createElement('div');
|
|||
|
|
_ganttTooltip.className='gantt-tooltip';
|
|||
|
|
document.body.appendChild(_ganttTooltip);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── HEADER — month columns ──
|
|||
|
|
var hdr='<div class="gantt-header"><div class="gantt-label-col">Milestone</div><div class="gantt-months" style="position:relative">';
|
|||
|
|
months.forEach(function(m,i){
|
|||
|
|
var isNow=i===currentMonthIdx;
|
|||
|
|
hdr+='<div class="gantt-month'+(isNow?' gantt-month-active':'')+'" style="width:'+m.pct+'%;flex-shrink:0">'+m.shortLabel+'</div>';
|
|||
|
|
});
|
|||
|
|
// "NOW" pointer in header track
|
|||
|
|
if(nowInRange){
|
|||
|
|
hdr+='<div class="gantt-now-pointer" style="left:'+todayPct+'%">'
|
|||
|
|
+'<div class="gantt-now-label">TODAY</div>'
|
|||
|
|
+'<div class="gantt-now-arrow"></div>'
|
|||
|
|
+'</div>';
|
|||
|
|
}
|
|||
|
|
hdr+='</div></div>';
|
|||
|
|
|
|||
|
|
// ── ROWS ──
|
|||
|
|
var rows='<div class="gantt-body" style="position:relative">';
|
|||
|
|
|
|||
|
|
// Full-height NOW line across entire body (rendered once as overlay)
|
|||
|
|
if(nowInRange){
|
|||
|
|
rows+='<div class="gantt-now-line" style="left:calc(220px + '+(todayPct*(100)/100)+'% * (100% - 220px) / 100)" id="ganttNowLine"></div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
allM.forEach(function(x,ri){
|
|||
|
|
var m=x.milestone,p=x.phase;
|
|||
|
|
var _pc=pColors[phaseIdx[p.id]%pColors.length];
|
|||
|
|
var col=_pc[0], colDark=_pc[1], colPill=_pc[2];
|
|||
|
|
var _rd=resolveDate(m);
|
|||
|
|
var sDate=_rd.sd, eDate=_rd.ed;
|
|||
|
|
var pct=milestonePct(m.id);
|
|||
|
|
var _sc=statusColors[m.status]||[col,colDark];
|
|||
|
|
var barCol=_sc[0], barColDark=_sc[1];
|
|||
|
|
var labelShort=m.title.length>26?m.title.slice(0,24)+'…':m.title;
|
|||
|
|
var isActiveRow=m.id===activeMillId;
|
|||
|
|
|
|||
|
|
rows+='<div class="gantt-row'+(isActiveRow?' gantt-active-row':'')+'" id="gr-'+m.id+'">'
|
|||
|
|
+'<div class="gantt-row-label"><div class="mil-name" title="'+m.title+'">'+labelShort+'</div>'
|
|||
|
|
+'<div class="ph-tag '+colPill+'">Phase '+p.num+'</div></div>'
|
|||
|
|
+'<div class="gantt-track">';
|
|||
|
|
|
|||
|
|
// Per-row today line
|
|||
|
|
if(nowInRange){
|
|||
|
|
rows+='<div style="position:absolute;left:'+todayPct+'%;top:0;width:2px;height:100%;z-index:3;pointer-events:none;background:linear-gradient(180deg,rgba(239,68,68,.7),rgba(239,68,68,.1))"></div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if(sDate&&eDate){
|
|||
|
|
var barLeft=((sDate-minD)/totalMs)*100;
|
|||
|
|
var barWidth=Math.max(1.5,((eDate-sDate+86400000)/totalMs)*100);
|
|||
|
|
barLeft=Math.max(0,Math.min(99,barLeft));
|
|||
|
|
barWidth=Math.min(100-barLeft,barWidth);
|
|||
|
|
var sStr=sDate.toLocaleDateString('default',{month:'short',day:'numeric',year:'numeric'});
|
|||
|
|
var eStr=eDate.toLocaleDateString('default',{month:'short',day:'numeric',year:'numeric'});
|
|||
|
|
var statusLabel=(m.status||'not_started').replace(/_/g,' ').replace(/\b\w/g,function(c){return c.toUpperCase();});
|
|||
|
|
var tooltipContent='<strong>'+m.title+'</strong>'
|
|||
|
|
+(isActiveRow?'<span style="background:#ef4444;color:#fff;font-size:9px;font-weight:800;padding:1px 5px;border-radius:3px;margin-left:6px">ACTIVE</span>':'')
|
|||
|
|
+'<br><span style="opacity:.7;font-size:11px">'+sStr+' → '+eStr+'</span><br>'
|
|||
|
|
+'<span style="font-size:11px;margin-top:4px;display:block">Status: <b>'+statusLabel+'</b> | Progress: <b>'+pct+'%</b></span>';
|
|||
|
|
|
|||
|
|
rows+='<div class="gantt-bar" style="left:'+barLeft+'%;width:'+barWidth+'%;background:linear-gradient(90deg,'+barCol+','+barCol+'cc);'
|
|||
|
|
+(isActiveRow?'box-shadow:0 0 0 2px rgba(201,162,39,.6),0 2px 8px rgba(0,0,0,.25);':'')
|
|||
|
|
+'"'
|
|||
|
|
+' onmouseenter="showGanttTip(event,\''+tooltipContent.replace(/'/g,''').replace(/"/g,'"')+'\')" onmouseleave="hideGanttTip()"'
|
|||
|
|
+' onclick="showPage(\'milestoneDetail\','+m.id+')">'
|
|||
|
|
+'<div class="gantt-bar-fill" style="width:'+pct+'%"></div>'
|
|||
|
|
+'<span class="gantt-bar-pct">'+pct+'%</span>'
|
|||
|
|
+'</div>';
|
|||
|
|
|
|||
|
|
// Pulsing dot on active milestone at today's position
|
|||
|
|
if(isActiveRow&&nowInRange&&todayPct>=barLeft&&todayPct<=barLeft+barWidth){
|
|||
|
|
rows+='<div class="gantt-pulse-dot" style="left:'+todayPct+'%"></div>';
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
rows+='<div style="position:absolute;left:2%;font-size:10px;color:var(--g400);line-height:42px">No dates set</div>';
|
|||
|
|
}
|
|||
|
|
rows+='</div></div>';
|
|||
|
|
});
|
|||
|
|
rows+='</div>';
|
|||
|
|
|
|||
|
|
// ── LEGEND ──
|
|||
|
|
var legend='<div class="gantt-legend">'
|
|||
|
|
+'<div class="gantt-legend-item"><div class="gantt-legend-dot" style="background:linear-gradient(135deg,#10b981,#059669)"></div>Completed</div>'
|
|||
|
|
+'<div class="gantt-legend-item"><div class="gantt-legend-dot" style="background:linear-gradient(135deg,#6366f1,#4f46e5)"></div>In Progress</div>'
|
|||
|
|
+'<div class="gantt-legend-item"><div class="gantt-legend-dot" style="background:linear-gradient(135deg,#f43f5e,#e11d48)"></div>Delayed</div>'
|
|||
|
|
+'<div class="gantt-legend-item"><div class="gantt-legend-dot" style="background:linear-gradient(135deg,#94a3b8,#64748b)"></div>Not Started</div>'
|
|||
|
|
+'<div class="gantt-legend-item"><div style="width:12px;height:12px;border-radius:50%;background:#f43f5e;border:2px solid #fff;box-shadow:0 0 0 2px rgba(244,63,94,.3);animation:gantt-pulse 1.8s infinite"></div>Active Now</div>'
|
|||
|
|
+'<div style="margin-left:auto;font-size:11px;color:#64748b;font-weight:600;display:flex;align-items:center;gap:6px">'
|
|||
|
|
+'<div style="width:2px;height:16px;background:linear-gradient(180deg,#f43f5e,rgba(244,63,94,.3));border-radius:1px"></div>'
|
|||
|
|
+(nowInRange?todayStr:'Today outside project range')
|
|||
|
|
+'</div>'
|
|||
|
|
+'</div>';
|
|||
|
|
|
|||
|
|
box.innerHTML='<div class="gantt-wrap">'+hdr+rows+legend+'</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showGanttTip(e,html){
|
|||
|
|
if(!_ganttTooltip)return;
|
|||
|
|
_ganttTooltip.innerHTML=html;
|
|||
|
|
_ganttTooltip.style.display='block';
|
|||
|
|
_ganttTooltip.style.left=(e.clientX+14)+'px';
|
|||
|
|
_ganttTooltip.style.top=(e.clientY-10)+'px';
|
|||
|
|
// Boundary check
|
|||
|
|
var rect=_ganttTooltip.getBoundingClientRect();
|
|||
|
|
if(rect.right>window.innerWidth-10)_ganttTooltip.style.left=(e.clientX-rect.width-10)+'px';
|
|||
|
|
}
|
|||
|
|
function hideGanttTip(){if(_ganttTooltip)_ganttTooltip.style.display='none';}
|
|||
|
|
|
|||
|
|
/* ?? PROGRESS AUTO-CALC ?? */
|
|||
|
|
function calcProgress(startDate, endDate, status){
|
|||
|
|
if(status==='completed') return 100;
|
|||
|
|
if(status==='not_started') return 0;
|
|||
|
|
if(!startDate||!endDate) return 0;
|
|||
|
|
var s=new Date(startDate), e=new Date(endDate), t=new Date();
|
|||
|
|
if(t<=s) return 0;
|
|||
|
|
if(t>=e) return status==='completed'?100:90;
|
|||
|
|
var prog=Math.round(((t-s)/(e-s))*100);
|
|||
|
|
return Math.min(99,Math.max(1,prog));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? COMMENT HELPERS ?? */
|
|||
|
|
function editComment(cid,mid){
|
|||
|
|
var textEl=document.getElementById('cmt-text-'+cid);
|
|||
|
|
var editEl=document.getElementById('cmt-edit-'+cid);
|
|||
|
|
if(textEl)textEl.style.display='none';
|
|||
|
|
if(editEl)editEl.style.display='block';
|
|||
|
|
var ta=document.getElementById('cmt-ta-'+cid);
|
|||
|
|
if(ta)ta.focus();
|
|||
|
|
}
|
|||
|
|
function cancelEditComment(cid){
|
|||
|
|
var textEl=document.getElementById('cmt-text-'+cid);
|
|||
|
|
var editEl=document.getElementById('cmt-edit-'+cid);
|
|||
|
|
if(textEl)textEl.style.display='block';
|
|||
|
|
if(editEl)editEl.style.display='none';
|
|||
|
|
}
|
|||
|
|
function saveEditComment(cid,mid){
|
|||
|
|
var ta=document.getElementById('cmt-ta-'+cid);
|
|||
|
|
if(!ta)return;
|
|||
|
|
var txt=ta.value.trim();
|
|||
|
|
if(!txt){alert('Comment cannot be empty.');return;}
|
|||
|
|
var c=DB.comments.find(function(x){return x.id===cid;});
|
|||
|
|
if(!c)return;
|
|||
|
|
c.content=txt;c.edited=true;c.editedAt=new Date().toISOString();
|
|||
|
|
addLog('EDIT_COMMENT','comment',cid);saveDB();renderMilestoneDetail(mid);flash('Comment updated.');
|
|||
|
|
}
|
|||
|
|
function delComment(cid,mid){
|
|||
|
|
if(!confirm('Delete this comment?'))return;
|
|||
|
|
DB.comments=DB.comments.filter(function(x){return x.id!==cid;});
|
|||
|
|
addLog('DELETE_COMMENT','comment',cid);saveDB();renderMilestoneDetail(mid);flash('Comment deleted.','danger');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ?? KPI CARD MODALS ?? */
|
|||
|
|
function closeKpi(){var p=document.getElementById('kpiModal');if(p)p.remove();}
|
|||
|
|
function showKpiModal(type){
|
|||
|
|
var _types=['overall','completed','delayed','dueweek','decisions','risks'];
|
|||
|
|
if(typeof type==='number')type=_types[type];
|
|||
|
|
var old=document.getElementById('kpiModal');
|
|||
|
|
if(old)old.remove();
|
|||
|
|
var title='', content='', color='', icon='';
|
|||
|
|
var td=today();
|
|||
|
|
var in7=addDays(td,7);
|
|||
|
|
|
|||
|
|
if(type==='overall'){
|
|||
|
|
title='Overall Project Completion';color='#c9a227';icon='fa-chart-pie';
|
|||
|
|
var phases=DB.phases.slice().sort(function(a,b){return a.order-b.order;});
|
|||
|
|
var rows=phases.map(function(p){
|
|||
|
|
var pp=phasePct(p.id);
|
|||
|
|
var mils=DB.milestones.filter(function(m){return m.phaseId===p.id;});
|
|||
|
|
var done=mils.filter(function(m){var a=DB.activities.filter(function(x){return x.milestoneId===m.id;});return a.length&&a.every(function(x){return x.status==='completed';});}).length;
|
|||
|
|
return '<tr><td style="font-weight:600">Phase '+p.num+': '+p.name+'</td>'
|
|||
|
|
+'<td>'+mils.length+'</td><td>'+done+'</td>'
|
|||
|
|
+'<td><div style="display:flex;align-items:center;gap:8px"><div class="prog-wrap" style="flex:1;height:6px"><div class="prog-bar '+(pp>=100?'ps':'')+'" style="width:'+pp+'%"></div></div><span style="font-size:12px;font-weight:700;min-width:35px">'+pp+'%</span></div></td>'
|
|||
|
|
+'<td>'+sBadge(mils.length&&mils.every(function(m){return m.status==='completed';})&&mils.length>0?'completed':'in_progress')+'</td></tr>';
|
|||
|
|
}).join('');
|
|||
|
|
var total=DB.activities.length, done2=DB.activities.filter(function(a){return a.status==='completed';}).length;
|
|||
|
|
content='<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:18px">'
|
|||
|
|
+'<div style="text-align:center;padding:12px;background:#f5e9c0;border-radius:8px"><div style="font-size:24px;font-weight:700;color:#c9a227">'+pct(total,done2)+'%</div><div style="font-size:11px;color:#b45309">Overall</div></div>'
|
|||
|
|
+'<div style="text-align:center;padding:12px;background:#dcfce7;border-radius:8px"><div style="font-size:24px;font-weight:700;color:#15803d">'+done2+'</div><div style="font-size:11px;color:#15803d">Completed</div></div>'
|
|||
|
|
+'<div style="text-align:center;padding:12px;background:#f4f4f5;border-radius:8px"><div style="font-size:24px;font-weight:700;color:#52525b">'+(total-done2)+'</div><div style="font-size:11px;color:#52525b">Remaining</div></div>'
|
|||
|
|
+'</div>'
|
|||
|
|
+'<div class="tbl-wrap"><table><thead><tr><th>Phase</th><th>Milestones</th><th>Done</th><th>Progress</th><th>Status</th></tr></thead><tbody>'+rows+'</tbody></table></div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
else if(type==='completed'){
|
|||
|
|
title='Completed Activities';color='#15803d';icon='fa-check-double';
|
|||
|
|
var acts=DB.activities.filter(function(a){return a.status==='completed';});
|
|||
|
|
var rows=acts.map(function(a){
|
|||
|
|
var m=DB.milestones.find(function(x){return x.id===a.milestoneId;});
|
|||
|
|
var p=m?DB.phases.find(function(x){return x.id===m.phaseId;}):null;
|
|||
|
|
return '<tr>'
|
|||
|
|
+'<td style="font-weight:600">'+a.title+'</td>'
|
|||
|
|
+'<td class="fs12">'+(m?m.title.slice(0,30)+(m.title.length>30?'...':''):'—')+'</td>'
|
|||
|
|
+'<td>'+(p?'<span class="badge b-navy" style="font-size:10px">Ph.'+p.num+'</span>':'—')+'</td>'
|
|||
|
|
+'<td class="fs12">'+(a.endDate||'—')+'</td>'
|
|||
|
|
+'<td>'+sBadge('completed')+'</td></tr>';
|
|||
|
|
}).join('');
|
|||
|
|
content=(acts.length?'<div class="tbl-wrap"><table><thead><tr><th>Activity</th><th>Milestone</th><th>Phase</th><th>End Date</th><th>Status</th></tr></thead><tbody>'+rows+'</tbody></table></div>'
|
|||
|
|
:'<div class="empty"><i class="fas fa-check-double"></i><p>No completed activities yet.</p></div>');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
else if(type==='delayed'){
|
|||
|
|
title='Delayed Tasks';color='#b91c1c';icon='fa-clock';
|
|||
|
|
var acts=DB.activities.filter(function(a){return a.endDate&&a.endDate<td&&a.status!=='completed';});
|
|||
|
|
var rows=acts.map(function(a){
|
|||
|
|
var m=DB.milestones.find(function(x){return x.id===a.milestoneId;});
|
|||
|
|
var p=m?DB.phases.find(function(x){return x.id===m.phaseId;}):null;
|
|||
|
|
var daysLate=Math.round((new Date(td)-new Date(a.endDate))/(1000*60*60*24));
|
|||
|
|
return '<tr>'
|
|||
|
|
+'<td style="font-weight:600">'+a.title+'</td>'
|
|||
|
|
+'<td class="fs12">'+(m?m.title.slice(0,28)+(m.title.length>28?'...':''):'—')+'</td>'
|
|||
|
|
+'<td>'+(p?'<span class="badge b-navy" style="font-size:10px">Ph.'+p.num+'</span>':'—')+'</td>'
|
|||
|
|
+'<td><span style="color:#b91c1c;font-weight:700;font-size:13px">'+a.endDate+'</span></td>'
|
|||
|
|
+'<td><span style="color:#b91c1c;font-size:12px;font-weight:600">'+daysLate+' day'+(daysLate!==1?'s':'')+' overdue</span></td>'
|
|||
|
|
+'<td>'+sBadge(a.status)+'</td>'
|
|||
|
|
+'<td>'+(isEditor()?'<button class="btn btn-outline btn-xs" onclick="closeKpi();showPage(\'phaseDetail\',' +(p?p.id:0)+ ')" >View</button>':'')+'</td></tr>';
|
|||
|
|
}).join('');
|
|||
|
|
content=(acts.length?'<div class="tbl-wrap"><table><thead><tr><th>Activity</th><th>Milestone</th><th>Phase</th><th>Due Date</th><th>Overdue By</th><th>Status</th><th></th></tr></thead><tbody>'+rows+'</tbody></table></div>'
|
|||
|
|
:'<div class="empty"><i class="fas fa-clock"></i><p>No delayed tasks. Great work!</p></div>');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
else if(type==='dueweek'){
|
|||
|
|
title='Due This Week';color='#d97706';icon='fa-hourglass-half';
|
|||
|
|
var acts=DB.activities.filter(function(a){return a.endDate&&a.endDate>=td&&a.endDate<=in7&&a.status!=='completed';});
|
|||
|
|
var rows=acts.map(function(a){
|
|||
|
|
var m=DB.milestones.find(function(x){return x.id===a.milestoneId;});
|
|||
|
|
var p=m?DB.phases.find(function(x){return x.id===m.phaseId;}):null;
|
|||
|
|
var daysLeft=Math.round((new Date(a.endDate)-new Date(td))/(1000*60*60*24));
|
|||
|
|
var urgColor=daysLeft<=1?'#b91c1c':daysLeft<=3?'#d97706':'#15803d';
|
|||
|
|
return '<tr>'
|
|||
|
|
+'<td style="font-weight:600">'+a.title+'</td>'
|
|||
|
|
+'<td class="fs12">'+(m?m.title.slice(0,28)+(m.title.length>28?'...':''):'—')+'</td>'
|
|||
|
|
+'<td>'+(p?'<span class="badge b-navy" style="font-size:10px">Ph.'+p.num+'</span>':'—')+'</td>'
|
|||
|
|
+'<td><span style="color:'+urgColor+';font-weight:700;font-size:13px">'+a.endDate+'</span></td>'
|
|||
|
|
+'<td><span style="color:'+urgColor+';font-size:12px;font-weight:600">'+(daysLeft===0?'Today':daysLeft===1?'Tomorrow':daysLeft+' days')+'</span></td>'
|
|||
|
|
+'<td>'+sBadge(a.status)+'</td>'
|
|||
|
|
+'<td>'+(isEditor()?'<button class="btn btn-outline btn-xs" onclick="closeKpi();openAM(' +a.milestoneId+ ',' +a.id+ ')" >Edit</button>':'')+'</td></tr>';
|
|||
|
|
}).join('');
|
|||
|
|
content=(acts.length?'<div class="tbl-wrap"><table><thead><tr><th>Activity</th><th>Milestone</th><th>Phase</th><th>Due Date</th><th>Urgency</th><th>Status</th><th></th></tr></thead><tbody>'+rows+'</tbody></table></div>'
|
|||
|
|
:'<div class="empty"><i class="fas fa-hourglass-half"></i><p>Nothing due in the next 7 days.</p></div>');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
else if(type==='decisions'){
|
|||
|
|
title='Pending Decisions';color='#1d4ed8';icon='fa-gavel';
|
|||
|
|
var decs=DB.decisions.filter(function(d){return d.status==='pending'||d.status==='in_review';});
|
|||
|
|
var rows=decs.map(function(d){
|
|||
|
|
var u=DB.users.find(function(x){return x.id===d.raisedBy;});
|
|||
|
|
var p=DB.phases.find(function(x){return x.id===d.phaseId;});
|
|||
|
|
var overdue=d.dueDate&&d.dueDate<td;
|
|||
|
|
return '<tr>'
|
|||
|
|
+'<td><div style="font-weight:600">'+d.title+'</div>'+(d.description?'<div class="muted fs12">'+d.description.slice(0,60)+(d.description.length>60?'...':'')+'</div>':'')+'</td>'
|
|||
|
|
+'<td>'+(p?'<span class="badge b-navy" style="font-size:10px">Ph.'+p.num+'</span>':'—')+'</td>'
|
|||
|
|
+'<td>'+sBadge(d.status)+'</td>'
|
|||
|
|
+'<td>'+pBadge(d.priority)+'</td>'
|
|||
|
|
+'<td class="fs12">'+(d.raisedBy?(u?u.fullName||u.username:'—'):'—')+'</td>'
|
|||
|
|
+'<td><span style="'+(overdue?'color:#b91c1c;font-weight:700;':'')+' font-size:12px">'+(d.dueDate||'—')+(overdue?' ⚠':'')+'</span></td>'
|
|||
|
|
+'<td>'+(d.fileData?'<button class="btn btn-outline btn-xs" onclick="dlDecFile('+d.id+')" title="Download document"><i class="fas fa-file-download"></i></button>':'')+'</td></tr>';
|
|||
|
|
}).join('');
|
|||
|
|
content=(decs.length?'<div class="tbl-wrap"><table><thead><tr><th>Decision</th><th>Phase</th><th>Status</th><th>Priority</th><th>Raised By</th><th>Due Date</th><th>Doc</th></tr></thead><tbody>'+rows+'</tbody></table></div>'
|
|||
|
|
:'<div class="empty"><i class="fas fa-gavel"></i><p>No pending decisions.</p></div>');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
else if(type==='risks'){
|
|||
|
|
title='Open Risks';color='#7c3aed';icon='fa-shield-halved';
|
|||
|
|
var risks=DB.risks.filter(function(r){return r.status==='open'||r.status==='in_review';});
|
|||
|
|
var pb={low:'b-gray',medium:'b-warn',high:'b-danger'};
|
|||
|
|
var rows=risks.map(function(r){
|
|||
|
|
var p=DB.phases.find(function(x){return x.id===r.phaseId;});
|
|||
|
|
var heat=(r.probability==='high'&&r.impact==='high')?'critical':(r.probability==='high'||r.impact==='high')?'high':'medium';
|
|||
|
|
return '<tr>'
|
|||
|
|
+'<td><div style="font-weight:600">'+r.title+'</div>'+(r.mitigation?'<div class="fs12 mt-1" style="color:#15803d"><i class="fas fa-shield-halved"></i> '+r.mitigation.slice(0,50)+(r.mitigation.length>50?'...':'')+'</div>':'')+'</td>'
|
|||
|
|
+'<td><span class="badge '+(pb[r.probability]||'b-gray')+'">'+(r.probability||'—')+'</span></td>'
|
|||
|
|
+'<td><span class="badge '+(pb[r.impact]||'b-gray')+'">'+(r.impact||'—')+'</span></td>'
|
|||
|
|
+'<td><span class="badge '+(heat==='critical'?'b-danger':heat==='high'?'b-warn':'b-gray')+'">'+heat+'</span></td>'
|
|||
|
|
+'<td>'+sBadge(r.status)+'</td>'
|
|||
|
|
+'<td class="fs12">'+(r.owner||'—')+'</td>'
|
|||
|
|
+'<td>'+(p?'<span class="badge b-navy" style="font-size:10px">Ph.'+p.num+'</span>':'—')+'</td></tr>';
|
|||
|
|
}).join('');
|
|||
|
|
content=(risks.length?'<div class="tbl-wrap"><table><thead><tr><th>Risk</th><th>Probability</th><th>Impact</th><th>Heat</th><th>Status</th><th>Owner</th><th>Phase</th></tr></thead><tbody>'+rows+'</tbody></table></div>'
|
|||
|
|
:'<div class="empty"><i class="fas fa-shield-halved"></i><p>No open risks.</p></div>');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Build modal using DOM to avoid quote issues
|
|||
|
|
var overlay=document.createElement('div');
|
|||
|
|
overlay.className='overlay open';
|
|||
|
|
overlay.id='kpiModal';
|
|||
|
|
var modal=document.createElement('div');
|
|||
|
|
modal.className='modal';
|
|||
|
|
modal.style.maxWidth='760px';
|
|||
|
|
// Header
|
|||
|
|
var hdr=document.createElement('div');
|
|||
|
|
hdr.className='modal-hdr';
|
|||
|
|
hdr.style.borderBottom='3px solid '+color;
|
|||
|
|
hdr.innerHTML='<span class="modal-title"><i class="fas '+icon+'" style="margin-right:8px;color:'+color+'"></i>'+title+'</span>';
|
|||
|
|
var xb=document.createElement('button');xb.className='modal-close';xb.innerHTML='✕';
|
|||
|
|
xb.onclick=function(){var p=document.getElementById('kpiModal');if(p)p.remove();};
|
|||
|
|
hdr.appendChild(xb);modal.appendChild(hdr);
|
|||
|
|
// Body
|
|||
|
|
var body=document.createElement('div');body.className='modal-body';body.innerHTML=content;
|
|||
|
|
modal.appendChild(body);
|
|||
|
|
// Footer
|
|||
|
|
var foot=document.createElement('div');foot.className='modal-foot';
|
|||
|
|
var cls=document.createElement('button');cls.className='btn btn-outline';cls.textContent='Close';
|
|||
|
|
cls.onclick=function(){var p=document.getElementById('kpiModal');if(p)p.remove();};foot.appendChild(cls);
|
|||
|
|
modal.appendChild(foot);
|
|||
|
|
overlay.appendChild(modal);
|
|||
|
|
document.body.appendChild(overlay);
|
|||
|
|
overlay.addEventListener('click',function(e){if(e.target===overlay)overlay.remove();});
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|