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:
2026-07-15 17:51:15 -07:00
parent dd37f1f551
commit 39b48055ff
48 changed files with 2867 additions and 507 deletions

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
// 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 &amp; 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&nbsp;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,'&quot;')}" 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,'&quot;')}" 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).