Productionize WP Suite: auth, security hardening, sync, dashboard, PWA, email
Brings the Work Package Suite from a browser-local prototype to a multi-tenant, SQL-backed deployment hardened for customer IP. Auth & access control - Local username/password login (bcrypt + JWT in an HttpOnly cookie), admin-managed users, per-project membership, and project-scoped API access. - Admin console: change user roles, view the audit trail, manage settings. Security hardening - CSP / HSTS / X-Frame-Options / nosniff headers in nginx; Secure cookie via X-Forwarded-Proto; CSRF Origin check; attribute-safe output escaping. - Login lockout, token_version session revocation, stronger password policy, fail-closed secret loading, encrypted (AES-256) database backups. Persistence & schema - SOPs and Work Packages are now DB-backed and shared across users, written through a durable client sync outbox that queues offline edits. - Alembic migrations applied automatically on container start. New capabilities - Phase 2 dashboard (progress, gating, pagination, archive). - Phase 3 PWA "Field View" with offline caching and auth fallback. - WP owner assignment with OPTIONAL email notifications, OFF by default and toggled from the admin console. SMTP password is read only from the SMTP_PASSWORD env var (never stored); emails carry a WP number + deep link, never customer IP. Also: IBM Carbon restyle, Help section, and DEPLOYMENT.md brought up to date. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -58,7 +58,11 @@ let currentView='Work Package Form';
|
||||
|
||||
// ── HELPERS ──────────────────────────────────────────────────────────────────
|
||||
function gv(id){ return document.getElementById(id)?.value?.trim() || ''; }
|
||||
function esc(v){ if(v==null) return ''; return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
// Attribute-safe HTML escaper (also escapes " and ' so values are safe inside
|
||||
// href="…" / src="…" attributes, not just element text).
|
||||
function esc(v){ if(v==null) return ''; return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||||
// Only allow http(s) URLs into an href; anything else (e.g. javascript:) → '#'.
|
||||
function hrefAttr(u){ return /^https?:\/\//i.test(String(u||'')) ? esc(u) : '#'; }
|
||||
function ns(){ return '<span style="color:var(--text-dim)">—</span>'; }
|
||||
function cell(v){ return v ? esc(v) : ns(); }
|
||||
function pad2(n){ return n<10?'0'+n:''+n; }
|
||||
@@ -197,12 +201,12 @@ function renderSopRefLinks(){
|
||||
const srcs=sopLinkedSources();
|
||||
if(!srcs.length){ box.innerHTML=''; return; }
|
||||
box.innerHTML=`<div class="ref-links-title">Reference folders (from SOP) — navigate to find & copy the specific file link:</div>`+
|
||||
`<div class="ref-links">`+srcs.map(s=>`<a href="${esc(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
|
||||
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
|
||||
}
|
||||
function renderSpecFolderLink(){
|
||||
const el=document.getElementById('spec-folder-link'); if(!el) return;
|
||||
const spec=sopLinkedSources().find(s=>/spec/i.test(s.label));
|
||||
el.innerHTML = spec ? `<a href="${esc(spec.link)}" target="_blank" rel="noopener" class="ref-link-sm">↗ Open spec folder (${esc(spec.system||'SOP')})</a>` : '';
|
||||
el.innerHTML = spec ? `<a href="${hrefAttr(spec.link)}" target="_blank" rel="noopener" class="ref-link-sm">↗ Open spec folder (${esc(spec.system||'SOP')})</a>` : '';
|
||||
}
|
||||
function buildTypePicker(){
|
||||
let types = enabledTypes();
|
||||
@@ -524,7 +528,7 @@ function renderSopFileFolders(){
|
||||
const srcs=sopLinkedSources();
|
||||
box.innerHTML = srcs.length
|
||||
? `<div class="field-hint">1) Open a folder, multi-select files in SharePoint, then use <b>Copy link</b>:</div>`+
|
||||
`<div class="ref-links">`+srcs.map(s=>`<a href="${esc(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`
|
||||
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`
|
||||
: `<div class="field-hint">No SOP folders defined — load or import an SOP first.</div>`;
|
||||
}
|
||||
function toggleSopFilePanel(){
|
||||
@@ -619,7 +623,7 @@ function openHoldModal(preselect, fromConstraint){
|
||||
}
|
||||
function holdPhotoChange(ev){
|
||||
const f=ev.target.files&&ev.target.files[0]; if(!f) return;
|
||||
const r=new FileReader(); r.onload=()=>{ holdPhotoData=r.result; document.getElementById('hold-photo-preview').innerHTML=`<img src="${holdPhotoData}" alt="supporting photo">`; }; r.readAsDataURL(f);
|
||||
const r=new FileReader(); r.onload=()=>{ holdPhotoData=r.result; const ok=/^data:image\//.test(holdPhotoData); document.getElementById('hold-photo-preview').innerHTML= ok?`<img src="${esc(holdPhotoData)}" alt="supporting photo">`:''; }; r.readAsDataURL(f);
|
||||
}
|
||||
function submitHold(){
|
||||
const constraint=document.getElementById('hold-constraint').value;
|
||||
@@ -699,7 +703,7 @@ function collectPackage(){
|
||||
parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined,
|
||||
number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'),
|
||||
type:gv('wp_type'), system:gv('wp_system'), location:gv('wp_location'),
|
||||
cost:gv('wp_cost'), wbs:gv('wp_wbs'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'),
|
||||
cost:gv('wp_cost'), wbs:gv('wp_wbs'), assigneeId:gv('wp_assignee'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'),
|
||||
due:gv('wp_due'), spec:gv('wp_spec'), desc:gv('wp_desc'),
|
||||
work:steps.join('\n'), workSteps:steps, numberDims:{...numberDims},
|
||||
disciplines:[...pkgDisciplines],
|
||||
@@ -839,7 +843,7 @@ function setFormChrome(on){
|
||||
if(nav) nav.style.display = on ? '' : 'none';
|
||||
if(save) save.style.display = on ? 'flex' : 'none';
|
||||
document.body.classList.toggle('has-sticky-save', !!on);
|
||||
if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); }
|
||||
if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); initSectionNavAutoHide(); }
|
||||
}
|
||||
// Make each form card collapsible by clicking its heading (idempotent).
|
||||
function makeCollapsible(){
|
||||
@@ -871,6 +875,27 @@ function buildSectionNav(){
|
||||
});
|
||||
nav.innerHTML=chips.join('');
|
||||
}
|
||||
// Keep the section-nav pinned just below the sticky header (so it stays put while
|
||||
// scrolling instead of hiding behind the header), and let it slide out of the way
|
||||
// while reading (scroll down), snapping back the moment you scroll up.
|
||||
function positionSectionNav(){
|
||||
const nav=document.getElementById('section-nav'), hdr=document.querySelector('.header');
|
||||
if(nav && hdr) nav.style.top = hdr.offsetHeight + 'px';
|
||||
}
|
||||
let _snLastY=0, _snBound=false;
|
||||
function initSectionNavAutoHide(){
|
||||
positionSectionNav();
|
||||
if(_snBound) return; _snBound=true;
|
||||
window.addEventListener('resize', positionSectionNav, {passive:true});
|
||||
window.addEventListener('scroll', ()=>{
|
||||
const nav=document.getElementById('section-nav');
|
||||
if(!nav || nav.style.display==='none') return;
|
||||
const y=window.scrollY||document.documentElement.scrollTop||0;
|
||||
if(y>_snLastY+4 && y>140) nav.classList.add('nav-hidden'); // scrolling down
|
||||
else if(y<_snLastY-4) nav.classList.remove('nav-hidden'); // scrolling up
|
||||
_snLastY=y;
|
||||
}, {passive:true});
|
||||
}
|
||||
function updateStickyStatus(){
|
||||
const el=document.getElementById('sticky-status'); if(!el) return;
|
||||
const r=readiness(); const st=getRadio('status');
|
||||
@@ -896,10 +921,43 @@ function renderSavedList(){
|
||||
const disc = (p.disciplines&&p.disciplines.length)?`<div style="font-size:10px;color:var(--text-dim)">${esc(p.disciplines.join(', '))}</div>`:'';
|
||||
return `<tr><td class="row-label">${esc(p.number||'—')}${tag}${disc}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td>
|
||||
<td>${statusPill(p.status)}</td><td>${ready}</td>
|
||||
<td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="row-del" onclick="deletePackage(${i})">✕</button></td></tr>`;
|
||||
<td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="link-btn" onclick="showHistoryRow(${i})">history</button> <button class="row-del" onclick="deletePackage(${i})">✕</button></td></tr>`;
|
||||
}).join('');
|
||||
}
|
||||
function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
|
||||
|
||||
// ── WP HISTORY (audit trail) ─────────────────────────────────────────────────
|
||||
function showHistoryRow(i){ const p=savedPackages[i]; if(p) showHistory(p.id, p.number||p.subject); }
|
||||
function showHistoryCurrent(){ showHistory(editingId, (document.getElementById('wp_number')||{}).value); }
|
||||
function closeHistory(){ const m=document.getElementById('wp-history-modal'); if(m) m.remove(); }
|
||||
const HIST_LABEL={created:'Created',updated:'Updated',status_changed:'Status changed',issued:'Issued',deleted:'Deleted'};
|
||||
async function showHistory(wpId, label){
|
||||
if(!wpId){ toast('Save the package first — history is recorded as it changes.'); return; }
|
||||
if(!label){ const _p=savedPackages.find(x=>x.id===wpId)||(typeof dashArchived!=='undefined'&&dashArchived.find(x=>x.id===wpId)); label=_p?(_p.number||_p.subject):''; }
|
||||
closeHistory();
|
||||
const ov=document.createElement('div');
|
||||
ov.id='wp-history-modal'; ov.className='modal-overlay open';
|
||||
ov.innerHTML='<div class="modal" style="max-width:640px"><div class="modal-head"><div class="modal-title">History — '+esc(label||wpId)+'</div>'+
|
||||
'<button class="cmt-x" onclick="closeHistory()" title="Close">✕</button></div>'+
|
||||
'<div class="modal-body" id="wp-history-body"><div class="empty-hint">Loading…</div></div>'+
|
||||
'<div class="modal-foot"><button class="btn btn-primary" onclick="closeHistory()">Close</button></div></div>';
|
||||
ov.addEventListener('click', e=>{ if(e.target===ov) closeHistory(); });
|
||||
document.body.appendChild(ov);
|
||||
let rows=[];
|
||||
try{ const r=await fetch('/api/audit?entity_type=wp&entity_id='+encodeURIComponent(wpId), {headers:{'Accept':'application/json'}}); if(r.ok) rows=await r.json(); }catch(e){}
|
||||
const body=document.getElementById('wp-history-body'); if(!body) return;
|
||||
if(!rows || !rows.length){
|
||||
body.innerHTML='<div class="empty-hint">No history on the server yet. Changes are recorded as the package is saved and its status changes — if this package was just created it may still be syncing.</div>';
|
||||
return;
|
||||
}
|
||||
const fmt=s=>{ try{ return new Date(s).toLocaleString(); }catch(e){ return s||''; } };
|
||||
const det=d=>{ d=d||{}; if(d.from!=null||d.to!=null) return esc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to)); if(d.status) return 'status: '+esc(d.status); return ''; };
|
||||
body.innerHTML='<div class="hist-list">'+rows.map(e=>
|
||||
'<div class="hist-item"><div class="hist-when">'+esc(fmt(e.at))+'</div>'+
|
||||
'<div class="hist-main"><span class="hist-action">'+esc(HIST_LABEL[e.action]||(e.action||'').replace(/_/g,' '))+'</span> '+
|
||||
'<span class="hist-detail">'+det(e.detail)+'</span></div>'+
|
||||
'<div class="hist-actor">by '+esc(e.actor||'—')+'</div></div>').join('')+'</div>';
|
||||
}
|
||||
function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); }
|
||||
function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); }
|
||||
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); }
|
||||
@@ -909,6 +967,7 @@ function loadPackageIntoForm(p){
|
||||
const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';};
|
||||
set('wp_subject',p.subject); set('wp_system',p.system); set('wp_location',p.location);
|
||||
set('wp_assignees',p.assignees); set('wp_distribution',p.distribution);
|
||||
set('wp_assignee',p.assigneeId);
|
||||
set('wp_due',p.due); set('wp_spec',p.spec); set('wp_desc',p.desc); set('wp_hours',p.hours);
|
||||
set('wp_kit_owner',p.kitOwner); set('wp_kit_date',p.kitDate); set('wp_mimo_time',p.mimoTime); set('wp_mimo_loc',p.mimoLoc);
|
||||
set('wp_actual_hrs',p.actualHrs); set('wp_installed_qty',p.installedQty); set('wp_redlines',p.redlines); set('wp_lessons',p.lessons);
|
||||
@@ -981,7 +1040,7 @@ function duplicateWP(){
|
||||
|
||||
function newPackage(){
|
||||
editingId=null;
|
||||
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||||
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignee','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||||
document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value='';
|
||||
['wp_lod','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||||
pkgKind='iwp'; applyKind();
|
||||
@@ -1025,15 +1084,43 @@ let dashFilter={status:'',discipline:'',q:'',flag:''};
|
||||
function dashToggleFlag(f){
|
||||
if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:''}; }
|
||||
else { dashFilter.flag = dashFilter.flag===f ? '' : f; }
|
||||
renderDashboard();
|
||||
dashPage=0; renderDashboard();
|
||||
}
|
||||
function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; dashPage=0; renderDashboard(); }
|
||||
|
||||
// ── Phase 2: pagination, progress %, and archived view ──────────────────────
|
||||
let dashPage=0, dashShowArchived=false, dashArchived=[];
|
||||
const DASH_PAGE_SIZE=25;
|
||||
// Weighted completion by status (0..1) so progress is smoother than done/not-done.
|
||||
const PROGRESS_W={'Draft':0,'Scheduled':0.25,'Issue':0.4,'Issued':0.5,'In Progress':0.75,'QC':0.9,'Closed':1};
|
||||
function wpProgress(p){ const w=PROGRESS_W[p.status]; return w==null?0:w; }
|
||||
function dashGo(pg){ dashPage=pg; renderDashboard(); }
|
||||
function dashToggleArchived(on){
|
||||
dashShowArchived=!!on; dashPage=0;
|
||||
if(dashShowArchived && !dashArchived.length && typeof ProjectData!=='undefined' && ProjectData.listArchived){
|
||||
ProjectData.listArchived(activeProjectId).then(rows=>{ dashArchived=rows||[]; renderDashboard(); });
|
||||
} else { renderDashboard(); }
|
||||
}
|
||||
function dashArchive(id){
|
||||
const p=savedPackages.find(x=>x.id===id); if(!p) return;
|
||||
if(!confirm('Archive "'+(p.number||p.subject||'this package')+'"? It will be hidden from the active board but kept for the record.')) return;
|
||||
if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,true);
|
||||
const ix=savedPackages.findIndex(x=>x.id===id); if(ix>=0){ p.archived=true; dashArchived.unshift(p); savedPackages.splice(ix,1); }
|
||||
saveStore(); renderSavedList(); renderDashboard(); toast('Archived '+(p.number||''));
|
||||
}
|
||||
function dashUnarchive(id){
|
||||
const ix=dashArchived.findIndex(x=>x.id===id); const p=ix>=0?dashArchived[ix]:null; if(!p) return;
|
||||
if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,false);
|
||||
p.archived=false; dashArchived.splice(ix,1); if(!savedPackages.some(x=>x.id===id)) savedPackages.push(p);
|
||||
saveStore(); renderSavedList(); renderDashboard(); toast('Restored '+(p.number||''));
|
||||
}
|
||||
function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; renderDashboard(); }
|
||||
// Consistent colored status pill, reused by the dashboard board and the saved list.
|
||||
function statusPill(s){
|
||||
const map={'Draft':'badge-NA','Scheduled':'badge-O','Issued':'badge-Y','In Progress':'badge-O','QC':'badge-O','Closed':'badge-Y','Issue':'badge-N'};
|
||||
const label = s==='Issue' ? 'Issue (Hold)' : (s||'—');
|
||||
return `<span class="badge ${map[s]||'badge-NA'}">${esc(label)}</span>`;
|
||||
}
|
||||
function myUserId(){ try { return (window.WP_USER && window.WP_USER.id) || ''; } catch(e){ return ''; } }
|
||||
function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); }
|
||||
function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todayStr()); }
|
||||
// Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted.
|
||||
@@ -1050,11 +1137,12 @@ function showDashboard(){
|
||||
function renderDashboard(){
|
||||
const all=countableWPs();
|
||||
const byStatus={}; STATUS_ORDER.concat(['Issue']).forEach(s=>byStatus[s]=0);
|
||||
let estH=0, actH=0, ready=0, hold=0, overdue=0; const byDisc={};
|
||||
let estH=0, actH=0, ready=0, hold=0, overdue=0, mine=0; const byDisc={}; const meId=myUserId();
|
||||
all.forEach(p=>{
|
||||
byStatus[p.status]=(byStatus[p.status]||0)+1;
|
||||
estH+=parseFloat(p.hours)||0; actH+=parseFloat(p.actualHrs)||0;
|
||||
if(p.status==='Issue') hold++;
|
||||
if(meId && p.assigneeId===meId) mine++;
|
||||
if(wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue') ready++;
|
||||
if(isOverdue(p)) overdue++;
|
||||
(p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1);
|
||||
@@ -1067,6 +1155,7 @@ function renderDashboard(){
|
||||
};
|
||||
let h=`<div class="dash-metrics">
|
||||
${card('Total WPs', all.length, '', 'all')}
|
||||
${meId ? card('My WPs', mine, mine?'dm-blue':'', 'mine') : ''}
|
||||
${card('Release-ready', ready, ready?'dm-green':'', 'ready')}
|
||||
${card('On hold', hold, hold?'dm-red':'', 'onhold')}
|
||||
${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')}
|
||||
@@ -1082,6 +1171,17 @@ function renderDashboard(){
|
||||
h+=`<div class="dash-breakdown"><div><div class="dash-bd-title">By status</div>${statusChips||'—'}</div>
|
||||
<div><div class="dash-bd-title">By discipline</div>${discChips}</div></div>`;
|
||||
|
||||
// progress by phase (discipline), weighted by status; archived excluded
|
||||
const overallPct = all.length ? Math.round(all.reduce((s,p)=>s+wpProgress(p),0)/all.length*100) : 0;
|
||||
const phaseGroups={};
|
||||
all.forEach(p=>{ (p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>{ (phaseGroups[d]=phaseGroups[d]||[]).push(p); }); });
|
||||
let prog=`<div class="dash-panel"><div class="dash-panel-title">Progress by phase</div>`;
|
||||
prog+=`<div class="prog-row"><div class="prog-name"><strong>Overall</strong></div><div class="prog-bar"><div class="prog-fill" style="width:${overallPct}%"></div></div><div class="prog-pct">${overallPct}%</div></div>`;
|
||||
Object.keys(phaseGroups).sort().forEach(d=>{ const g=phaseGroups[d]; const pct=g.length?Math.round(g.reduce((s,p)=>s+wpProgress(p),0)/g.length*100):0; const done=g.filter(p=>p.status==='Closed').length;
|
||||
prog+=`<div class="prog-row"><div class="prog-name">${esc(d)}</div><div class="prog-bar"><div class="prog-fill" style="width:${pct}%"></div></div><div class="prog-pct">${pct}% <span class="prog-sub">${done}/${g.length}</span></div></div>`; });
|
||||
prog+=`<div class="field-hint" style="margin-top:8px">Weighted by status (Draft 0 · Scheduled 25 · Issued 50 · In Progress 75 · QC 90 · Closed 100%). Archived packages excluded.</div></div>`;
|
||||
h+=prog;
|
||||
|
||||
// gating panel — what's blocking release
|
||||
const gated=all.filter(p=>wpOpenConstraints(p).length>0);
|
||||
h+=`<div class="dash-panel"><div class="dash-panel-title">⛔ Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)</div>`;
|
||||
@@ -1096,39 +1196,60 @@ function renderDashboard(){
|
||||
const discList=Object.keys(byDisc);
|
||||
const discOpts=['<option value="">All disciplines</option>'].concat(discList.map(d=>`<option ${dashFilter.discipline===d?'selected':''}>${esc(d)}</option>`)).join('');
|
||||
h+=`<div class="dash-filters">
|
||||
<input type="search" placeholder="Search WP # / subject…" value="${(dashFilter.q||'').replace(/"/g,'"')}" oninput="dashFilter.q=this.value;renderDashboard()">
|
||||
<select onchange="dashFilter.status=this.value;renderDashboard()">${statusOpts}</select>
|
||||
<select onchange="dashFilter.discipline=this.value;renderDashboard()">${discOpts}</select>
|
||||
<input type="search" placeholder="Search WP # / subject / type…" value="${(dashFilter.q||'').replace(/"/g,'"')}" oninput="dashFilter.q=this.value;dashPage=0;renderDashboard()">
|
||||
<select onchange="dashFilter.status=this.value;dashPage=0;renderDashboard()">${statusOpts}</select>
|
||||
<select onchange="dashFilter.discipline=this.value;dashPage=0;renderDashboard()">${discOpts}</select>
|
||||
<label class="dash-arch-toggle"><input type="checkbox" ${dashShowArchived?'checked':''} onchange="dashToggleArchived(this.checked)"> Show archived${dashShowArchived?' ('+dashArchived.length+')':''}</label>
|
||||
</div>`;
|
||||
|
||||
// main board (includes masters, marked)
|
||||
// main board (includes masters, marked; archived only when toggled on)
|
||||
const q=(dashFilter.q||'').toLowerCase();
|
||||
const rows=WPData.list().filter(p=>{
|
||||
const boardSource = dashShowArchived ? WPData.list().concat(dashArchived) : WPData.list();
|
||||
const rows=boardSource.filter(p=>{
|
||||
if(dashFilter.status && p.status!==dashFilter.status) return false;
|
||||
if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false;
|
||||
if(q && !((p.number||'')+' '+(p.subject||'')).toLowerCase().includes(q)) return false;
|
||||
if(q && !((p.number||'')+' '+(p.subject||'')+' '+(p.type||'')).toLowerCase().includes(q)) return false;
|
||||
if(dashFilter.flag==='mine' && p.assigneeId!==myUserId()) return false;
|
||||
if(dashFilter.flag==='ready' && !(!p.split && wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue')) return false;
|
||||
if(dashFilter.flag==='onhold' && p.status!=='Issue') return false;
|
||||
if(dashFilter.flag==='overdue' && !isOverdue(p)) return false;
|
||||
return true;
|
||||
});
|
||||
h+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${rows.length})</div>
|
||||
const totalRows=rows.length;
|
||||
const pages=Math.max(1, Math.ceil(totalRows/DASH_PAGE_SIZE));
|
||||
if(dashPage>=pages) dashPage=pages-1;
|
||||
if(dashPage<0) dashPage=0;
|
||||
const pageRows=rows.slice(dashPage*DASH_PAGE_SIZE, dashPage*DASH_PAGE_SIZE+DASH_PAGE_SIZE);
|
||||
h+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${totalRows})</div>
|
||||
<table class="dash-table"><thead><tr><th>WP #</th><th>Subject</th><th>Type</th><th>Discipline</th><th>Status</th><th>Gates</th><th>Due</th><th>Hrs</th><th></th></tr></thead><tbody>`;
|
||||
if(!rows.length) h+=`<tr><td colspan="9" class="field-hint" style="padding:14px">No work packages match.</td></tr>`;
|
||||
rows.forEach(p=>{
|
||||
if(!totalRows) h+=`<tr><td colspan="9" class="field-hint" style="padding:14px">No work packages match.</td></tr>`;
|
||||
pageRows.forEach(p=>{
|
||||
const ix=savedPackages.findIndex(x=>x.id===p.id);
|
||||
const open=wpOpenConstraints(p).length;
|
||||
const gates= p.split?'<span class="badge badge-O">master</span>':(open?`<span class="badge badge-O">${open} open</span>`:`<span class="badge badge-Y">clear</span>`);
|
||||
const due= p.due?`<span style="${isOverdue(p)?'color:var(--red);font-weight:700':''}">${esc(p.due)}</span>`:ns();
|
||||
const canIssue = !p.split && open===0 && p.status!=='Closed' && p.status!=='Issued' && p.status!=='Issue';
|
||||
const issueBtn = canIssue?`<button class="link-btn" onclick="dashIssue('${p.id}')">issue</button>`:'';
|
||||
h+=`<tr><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}</td>
|
||||
const pid=esc(p.id);
|
||||
let actions;
|
||||
if(p.archived){
|
||||
actions=`<button class="link-btn" onclick="showHistory('${pid}')">history</button> <button class="link-btn" onclick="dashUnarchive('${pid}')">restore</button>`;
|
||||
} else {
|
||||
const canIssue = !p.split && open===0 && p.status!=='Closed' && p.status!=='Issued' && p.status!=='Issue';
|
||||
const issueBtn = canIssue?`<button class="link-btn" onclick="dashIssue('${pid}')">issue</button> `:'';
|
||||
actions=`${issueBtn}<button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button> <button class="link-btn" onclick="dashArchive('${pid}')">archive</button>`;
|
||||
}
|
||||
h+=`<tr${p.archived?' style="opacity:.6"':''}><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}${p.archived?' <span class="badge badge-NA">archived</span>':''}</td>
|
||||
<td>${esc(p.subject||'')}</td><td>${esc(p.type||'')}</td>
|
||||
<td style="font-size:11px">${esc((p.disciplines||[]).join(', '))||ns()}</td>
|
||||
<td>${statusPill(p.status)}</td><td>${gates}</td><td>${due}</td><td>${cell(p.hours)}</td>
|
||||
<td class="center" style="white-space:nowrap">${issueBtn} <button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button></td></tr>`;
|
||||
<td class="center" style="white-space:nowrap">${actions}</td></tr>`;
|
||||
});
|
||||
h+=`</tbody></table></div>`;
|
||||
h+=`</tbody></table>`;
|
||||
if(pages>1){
|
||||
h+=`<div class="dash-pager"><span>Page ${dashPage+1} of ${pages} · ${totalRows} packages</span>
|
||||
<span class="dash-pager-btns"><button class="btn btn-ghost" ${dashPage===0?'disabled':''} onclick="dashGo(${dashPage-1})">‹ Prev</button>
|
||||
<button class="btn btn-ghost" ${dashPage>=pages-1?'disabled':''} onclick="dashGo(${dashPage+1})">Next ›</button></span></div>`;
|
||||
}
|
||||
h+=`</div>`;
|
||||
document.getElementById('dash-body').innerHTML=h;
|
||||
}
|
||||
function dashIssue(id){
|
||||
@@ -1225,10 +1346,27 @@ function bootSOP(){
|
||||
if(activeProjectId){ SOP=null; renderCtxBar(); newPackage(); }
|
||||
else { loadSampleSOP(); }
|
||||
}
|
||||
// Populate the Owner picker with this project's members (+ admins). The list is
|
||||
// only used to pick an assignee; the server re-validates on save.
|
||||
async function loadMembers(){
|
||||
const sel=document.getElementById('wp_assignee');
|
||||
if(!sel || !activeProjectId) return;
|
||||
try {
|
||||
const r=await fetch('/api/projects/'+encodeURIComponent(activeProjectId)+'/members',{credentials:'same-origin'});
|
||||
if(!r.ok) return;
|
||||
const list=await r.json();
|
||||
const cur=sel.value;
|
||||
sel.innerHTML='<option value="">— Unassigned —</option>'+
|
||||
list.map(u=>`<option value="${esc(u.id)}">${esc(u.full_name||u.username)}</option>`).join('');
|
||||
if(cur) sel.value=cur;
|
||||
} catch(e){}
|
||||
}
|
||||
|
||||
function bootData(){
|
||||
loadStore(); // reads the localStorage cache (hydrated from the server below)
|
||||
bootSOP();
|
||||
setRadio('status','Draft');
|
||||
loadMembers();
|
||||
renderSavedList();
|
||||
cmtInit();
|
||||
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard).
|
||||
|
||||
Reference in New Issue
Block a user