Widen the IWP screen and add a left work-package navigator

The Work Package form was capped at a 1000px column, which wasted most of a
desktop screen, and the only way to reach another package was to scroll to the
Saved table at the bottom.

- Put the form in a wide two-column shell (max 1760px); ctx-bar, mode-wrap and
  the release banner widened to match.
- Above 1200px the two-up field grids flow to 3-4 columns instead of stretching
  two fields across the whole card. Narrow layouts are unchanged.
- New sticky left rail listing every saved package, grouped by status in field
  order, with WP number, subject, readiness dot and type. Click to open it in
  the form; the package being edited is highlighted. Filter box, + New and
  Dashboard shortcuts, collapsible (state persisted), hidden under 1100px where
  the Saved table still covers navigation.
- The rail re-renders from renderSavedList(), so saves, deletes, splits,
  archive/restore and the project pull all keep it current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 12:59:34 -07:00
parent 39b48055ff
commit 1d004cab75
3 changed files with 135 additions and 7 deletions

View File

@@ -844,6 +844,7 @@ function setFormChrome(on){
if(save) save.style.display = on ? 'flex' : 'none';
document.body.classList.toggle('has-sticky-save', !!on);
if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); initSectionNavAutoHide(); }
else positionSectionNav();
}
// Make each form card collapsible by clicking its heading (idempotent).
function makeCollapsible(){
@@ -881,6 +882,9 @@ function buildSectionNav(){
function positionSectionNav(){
const nav=document.getElementById('section-nav'), hdr=document.querySelector('.header');
if(nav && hdr) nav.style.top = hdr.offsetHeight + 'px';
// The WP navigator rail sticks below the header + section-nav chrome.
const top=(hdr?hdr.offsetHeight:0)+((nav && nav.style.display!=='none')?nav.offsetHeight:0);
document.documentElement.style.setProperty('--rail-top', top+'px');
}
let _snLastY=0, _snBound=false;
function initSectionNavAutoHide(){
@@ -912,6 +916,7 @@ function saveStore(){ try{ localStorage.setItem(wpKey(STORE_KEY), JSON.stringify
function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(wpKey(STORE_KEY))); savedPackages=Array.isArray(d)?d:[]; }catch(e){ savedPackages=[]; } }
function renderSavedList(){
const card=document.getElementById('saved-card'), body=document.getElementById('saved-body');
renderWpNav();
document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:'';
if(!savedPackages.length){ card.style.display='none'; return; }
if(document.getElementById('pkg-output').style.display==='none' || document.getElementById('pkg-output').style.display==='') card.style.display='';
@@ -926,6 +931,58 @@ function renderSavedList(){
}
function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
// ── WP NAVIGATOR (left rail) ─────────────────────────────────────────────────
// Every saved package on this project, grouped by status, filterable. Clicking a
// row opens it in the form (same path as the Saved table's "edit").
const WPNAV_ORDER=['Issue','In Progress','Issued','QC','Scheduled','Draft','Closed'];
function toggleWpNav(){
document.body.classList.toggle('wp-nav-collapsed');
try{ localStorage.setItem('wp_nav_collapsed', document.body.classList.contains('wp-nav-collapsed')?'1':''); }catch(e){}
}
function renderWpNav(){
const list=document.getElementById('wp-nav-list'); if(!list) return;
const cnt=document.getElementById('wp-nav-count');
if(cnt) cnt.textContent = savedPackages.length ? '('+savedPackages.length+')' : '';
const q=((document.getElementById('wp-nav-search')||{}).value||'').trim().toLowerCase();
// Keep the original index — edit/view act on savedPackages by position.
const rows=savedPackages.map((p,i)=>({p:p,i:i})).filter(r=>{
if(!q) return true;
const p=r.p;
return [p.number,p.subject,p.type,p.location,p.system].some(v=>String(v||'').toLowerCase().includes(q));
});
if(!rows.length){
list.innerHTML='<div class="wp-nav-empty">'+(savedPackages.length?'No packages match “'+esc(q)+'”.':'No work packages saved yet. Fill the form and Save Draft.')+'</div>';
return;
}
const groups={};
rows.forEach(r=>{ const s=r.p.status||'Draft'; (groups[s]=groups[s]||[]).push(r); });
const keys=Object.keys(groups).sort((a,b)=>{
const ia=WPNAV_ORDER.indexOf(a), ib=WPNAV_ORDER.indexOf(b);
return (ia<0?99:ia)-(ib<0?99:ib);
});
list.innerHTML=keys.map(k=>{
const label = k==='Issue' ? 'Issue (Hold)' : k;
return '<div class="wp-nav-group">'+esc(label)+' · '+groups[k].length+'</div>'+groups[k].map(r=>{
const p=r.p, open=(p.constraints||[]).filter(c=>c.status==='open').length;
const dot = p.status==='Issue' ? 'hold' : (open===0 ? 'ok' : 'open');
const state = p.status==='Issue' ? 'on hold' : (open===0 ? 'ready' : open+' open');
const active = (editingId && p.id===editingId) ? ' active' : '';
return '<button type="button" class="wp-nav-item'+active+'" onclick="wpNavOpen('+r.i+')" title="'+esc((p.number||'')+' — '+(p.subject||''))+'">'+
'<span class="wp-nav-num">'+esc(p.number||'(unnumbered)')+'</span>'+
'<span class="wp-nav-subj">'+esc(p.subject||'untitled')+'</span>'+
'<span class="wp-nav-meta"><span class="wp-nav-dot '+dot+'"></span>'+esc(state)+
(p.type?' · '+esc(p.type):'')+'</span></button>';
}).join('');
}).join('');
}
function wpNavOpen(i){
const p=savedPackages[i]; if(!p) return;
editingId=p.id;
loadPackageIntoForm(p); // ends in showForm(), so this also leaves the dashboard / package view
renderWpNav();
track('wp_nav_open');
}
// ── 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); }
@@ -960,7 +1017,7 @@ async function showHistory(wpId, label){
}
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); }
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); renderWpNav(); }
function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); }
function loadPackageIntoForm(p){
pkgKind = (p.kind === 'ewp') ? 'ewp' : 'iwp'; // set before type/constraint pickers so they filter correctly
@@ -1055,7 +1112,7 @@ function newPackage(){
pkgHolds=[]; pkgOverrides={};
set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
prevStatus='Draft';
updateNumber(); updateReleaseBanner(); showForm(); track('new_package');
updateNumber(); updateReleaseBanner(); showForm(); renderWpNav(); track('new_package');
}
function set_quality_from_sop(){ const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; set('wp_qc',sopValueFor('wp_qc')); set('wp_photo',sopValueFor('wp_photo')); set('wp_hold',sopValueFor('wp_hold')); }
function exportPackages(){
@@ -1367,7 +1424,9 @@ function bootData(){
bootSOP();
setRadio('status','Draft');
loadMembers();
try{ if(localStorage.getItem('wp_nav_collapsed')) document.body.classList.add('wp-nav-collapsed'); }catch(e){}
renderSavedList();
positionSectionNav();
cmtInit();
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard).
const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); }