Wave 3: predecessor references with a release gate, and critical-constraint reopen alerts

Predecessors are real references now
- data.predecessors holds work-package ids, replacing a free-text SOP phase label
  that couldn't express "WP04 waits on WP02" and gated nothing. The SOP phase
  survives beside it as the descriptive "Sequence phase" field.
- readiness() has two gates: constraints clear AND every predecessor Closed. The
  banner, sticky bar, left rail, dashboard Gates column and the ready counters all
  reflect the second one.
- Enforced server-side by enforce_release_gates() on every path that sets a status
  — the plain upsert included, since that's how the browser and the offline outbox
  save. /issue and /status would otherwise have been ways around it.
- Cycles are refused directly and through a chain, with a message naming the
  package that already waits on this one. The Creator's picker also hides itself
  and its own descendants, so a cycle is hard to build in the first place.
- A deleted predecessor does not block: it would freeze everything downstream of a
  package someone removed.
- The gate is refusable, on purpose. Planners release ahead of upstream close-out,
  so an explicit reason (data.gateOverride) allows it, gets a gate_overridden audit
  event naming what was skipped, and prints on the package. A blank reason is not
  an override, and changing the predecessor set clears it. The dashboard won't
  release a blocked package at all — it points at the form where the reason is
  captured.

Critical constraints reopened after release
- Reopening a SOP-critical constraint on a released package emails the owner, PM,
  CM and the package's distribution list (minus whoever did it) and writes a
  constraint_reopened audit event.
- Detected by diffing the incoming constraints against the stored ones inside the
  normal upsert rather than via a new endpoint: the sync outbox only replays
  POST /api/wps, so a dedicated route would be lost offline. It fires only on a
  real cleared→open transition, so re-saving an already-open constraint doesn't
  re-announce, and never before release or for a non-critical constraint.
- Bodies carry the constraint name, WP number and a link — never package contents.

Verified: 139 API checks on one fresh database (44 permissions + 22 password reset
+ 34 search/localization + 39 gates/notifications), including every bypass path,
cycle shapes, the deleted-predecessor case, blank-reason overrides, and the four
recipients confirmed both in the outbox and on the wire against a local SMTP sink.
27 driven UI checks against the real Creator page in headless Chrome covering the
picker, the override prompt (accept and cancel), override invalidation, the cycle
exclusions and the dashboard refusal. Screenshots reviewed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 16:15:36 -07:00
parent 61d1cf4bff
commit b38348e6ae
4 changed files with 476 additions and 21 deletions

View File

@@ -382,6 +382,114 @@ function resetPeopleForNewPackage(){
renderPeoplePicker('distribution');
}
// ── PREDECESSOR WORK PACKAGES ─────────────────────────────────────────────────
// "Package Predecessor" used to be a free-text SOP phase label, which couldn't
// express "WP04 waits on WP02" and gated nothing. It's now a set of references to
// other packages on the project, and an unclosed predecessor makes a package not
// release-ready (server: enforce_release_gates). The SOP phase survives as the
// descriptive "Sequence phase" field beside it.
let pkgPredecessors = []; // [wpId]
let pkgGateOverride = null; // {reason, at, by} once a planner releases early
function wpById(id){ return savedPackages.find(p => p.id === id) || null; }
// Everything this package already blocks, directly or through a chain — offering
// any of them as a predecessor would create a cycle, so they're excluded.
function descendantsOf(id){
const out = new Set();
const stack = [id];
while(stack.length){
const cur = stack.pop();
savedPackages.forEach(p => {
if((p.predecessors || []).includes(cur) && !out.has(p.id)){
out.add(p.id);
stack.push(p.id);
}
});
}
return out;
}
function predCandidates(){
const self = editingId || '';
const banned = self ? descendantsOf(self) : new Set();
return savedPackages.filter(p => p.id !== self && !banned.has(p.id));
}
// Predecessors that aren't Closed yet. Missing ones (deleted since) don't block —
// a deleted package must not freeze everything downstream of it.
function blockingPredecessors(){
return pkgPredecessors.map(id => wpById(id)).filter(p => p && p.status !== 'Closed');
}
function predLabel(p){ return (p.number || '(unnumbered)') + ' — ' + (p.subject || 'untitled'); }
function renderPredPicker(){
const box = document.getElementById('pick_predecessors');
if(!box) return;
const chips = pkgPredecessors.map((id, i) => {
const p = wpById(id);
const gone = !p;
const done = p && p.status === 'Closed';
const title = gone ? 'This package no longer exists — it does not block release.'
: (done ? 'Closed — cleared' : p.status + ' — blocks release until Closed');
const cls = done ? ' pp-locked' : '';
const name = gone ? '(deleted package)' : (p.number || p.subject || id);
return `<span class="pp-chip${cls}" title="${esc(title)}"><span class="pp-name">${done?'✓ ':''}${esc(name)}</span>` +
`<button type="button" class="pp-x" title="Remove" onclick="removePredecessor(${i})">✕</button></span>`;
}).join('');
const cands = predCandidates();
const opts = cands.map(p => {
const on = pkgPredecessors.includes(p.id);
return `<label class="pp-opt"><input type="checkbox" ${on?'checked':''} onchange="togglePredecessor('${esc(p.id)}',this.checked)">` +
`<span>${esc(p.number || '(unnumbered)')} <span class="pp-role">${esc(p.status || 'Draft')}</span><br>` +
`<span class="pp-role">${esc((p.subject||'').slice(0,60))}</span></span></label>`;
}).join('');
box.innerHTML = chips +
`<span class="pp-add">
<button type="button" class="pp-add-btn" onclick="togglePeopleMenu('predecessors')">+ Add</button>
<div class="pp-menu" id="ppmenu_predecessors" hidden>
${cands.length ? `<div class="pp-group">Work packages on this project</div>${opts}`
: `<div class="pp-group">No other packages saved yet</div>`}
</div>
</span>`;
renderPredHint();
}
function renderPredHint(){
const el = document.getElementById('pred-hint');
if(!el) return;
const blocking = blockingPredecessors();
if(!pkgPredecessors.length){
el.textContent = 'None — this package can be released as soon as its constraints are cleared.';
el.style.color = '';
} else if(blocking.length){
el.innerHTML = '⛔ Waiting on ' + blocking.map(p => esc(p.number || p.id) + ' (' + esc(p.status) + ')').join(', ') +
'. Release is gated until they are Closed.';
el.style.color = 'var(--red)';
} else {
el.textContent = '✓ All predecessors are Closed.';
el.style.color = 'var(--accent-green)';
}
}
function togglePredecessor(id, on){
const ix = pkgPredecessors.indexOf(id);
if(on && ix < 0) pkgPredecessors.push(id);
else if(!on && ix >= 0) pkgPredecessors.splice(ix, 1);
// The set changed, so a previously-granted override no longer describes reality.
pkgGateOverride = null;
renderPredPicker();
updateReleaseBanner();
}
function removePredecessor(i){
pkgPredecessors.splice(i, 1);
pkgGateOverride = null;
renderPredPicker();
updateReleaseBanner();
}
// ── SOP-inherited hints → label tooltips (site comment 8/3) ───────────────────
// The blue "from SOP types" subtext under a field becomes a small SOP chip on the
// label, with the detail on hover. The hint elements stay in the DOM (hidden) so
@@ -809,7 +917,13 @@ function setConstraint(i,val){
// issue it and scroll up to the status control so the change is visible.
if(before==='open' && val!=='open' && readiness().open===0){
const st=getRadio('status');
if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){
// Don't offer to issue while a predecessor is still open — that would walk the
// user straight into the override prompt they didn't ask for.
if(readiness().blocking.length){
renderPredHint();
toast('All constraints cleared — still waiting on '+readiness().blocking.length+' predecessor package(s).');
}
else if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){
if(confirm('All constraints are cleared — this Work Package is release-ready.\n\nMark it as Issued now?')){
setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner();
track('status_change',{status:'Issued',via:'constraint_clear'});
@@ -819,23 +933,73 @@ function setConstraint(i,val){
}
}
}
function readiness(){ const open=pkgConstraints.filter(c=>c.status==='open').length; return {open, total:pkgConstraints.length, cleared:pkgConstraints.filter(c=>c.status==='cleared').length, ready:open===0}; }
// Release readiness has two gates now: every constraint cleared/N-A, and every
// predecessor package Closed. `ready` means both; `open`/`blocking` say which.
function readiness(){
const open = pkgConstraints.filter(c=>c.status==='open').length;
const blocking = blockingPredecessors();
return {
open, blocking,
total: pkgConstraints.length,
cleared: pkgConstraints.filter(c=>c.status==='cleared').length,
constraintsClear: open === 0,
ready: open === 0 && blocking.length === 0
};
}
function updateReleaseBanner(){
const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status');
let cls, txt;
if(st==='Issue'){ cls='rb-hold'; txt=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; }
else if(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; }
else { cls='rb-notready'; txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open.`; }
else if(r.constraintsClear){
// Constraints are done; what's left is upstream work.
cls='rb-notready';
txt=`⚠ Not release-ready — waiting on ${r.blocking.map(p=>esc(p.number||p.id)+' ('+esc(p.status)+')').join(', ')}.`;
}
else {
cls='rb-notready';
const extra = r.blocking.length ? ` · also waiting on ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}` : '';
txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open${extra}.`;
}
b.innerHTML=`<div class="rb-inner ${cls}">${txt}</div>`;
updateStickyStatus();
}
// Releasing with an unclosed predecessor is allowed but must be explained. The
// reason rides on the package (data.gateOverride) and the server writes it to the
// audit log. Returns false if the user backed out.
function confirmEarlyRelease(blocking){
const list=blocking.map(p=>'• '+(p.number||p.id)+' — '+p.status).join('\n');
const reason=prompt('These predecessor packages are not Closed yet:\n\n'+list+
'\n\nYou can still release this package, but the reason is recorded on it and in the audit log.\n\n'+
'Why is it being released now? (Cancel to stop.)');
if(reason===null || !reason.trim()) return false;
pkgGateOverride={
reason: reason.trim(),
at: new Date().toISOString(),
by: (window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
blocking: blocking.map(p=>p.number||p.id)
};
track('predecessor_gate_overridden');
return true;
}
function onStatusChange(target){
const idx=STATUS_ORDER.indexOf(target);
if(idx>=ISSUED_IDX && readiness().open>0){
const r=readiness();
// Constraints are a hard gate: nothing releases with one open.
if(idx>=ISSUED_IDX && r.open>0){
const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name);
alert('Cannot move to "'+target+'" — these constraints are still open:\n\n• '+open.join('\n• ')+'\n\nClear or mark N/A first.');
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
}
// Predecessors are a gate you can refuse: planners genuinely need to release
// ahead of upstream work closing out. Refusing it requires a reason, which is
// stored on the package and written to the audit log by the server.
if(idx>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){
if(!confirmEarlyRelease(r.blocking)){
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
}
}
if(target==='Issue'){ openHoldModal(); return; } // modal commits or reverts
prevStatus=target; updateReleaseBanner(); track('status_change',{status:target});
}
@@ -945,7 +1109,12 @@ function collectPackage(){
disciplines:[...pkgDisciplines],
scope: isMultiDiscipline() ? Object.fromEntries(pkgDisciplines.map(d=>[d,(pkgScope[d]||[]).map(s=>s.trim()).filter(Boolean)])) : undefined,
discStatus: isMultiDiscipline() ? {...pkgDiscStatus} : undefined,
hours:gv('wp_hours'), seq:gv('wp_seq'),
hours:gv('wp_hours'),
// seq is the SOP sequence phase (descriptive). predecessors are references to
// other packages and are what actually gate release.
seq:gv('wp_seq'),
predecessors:pkgPredecessors.slice(),
gateOverride:pkgGateOverride || undefined,
assets:pkgAssets.filter(a=>a.tag||a.link||a.desc),
materials:pkgMaterials.filter(m=>m.qty||m.desc), attachments:pkgAttach.filter(a=>a.doc),
kitStatus:gv('wp_kit_status'), kitOwner:gv('wp_kit_owner'), kitDate:gv('wp_kit_date'),
@@ -974,6 +1143,12 @@ function collectPackage(){
function savePackage(view){
if(!gv('wp_subject') || !gv('wp_type')){ alert('Subject and WP Type are required.'); return; }
// A BIM package marked "Signed off (IFF)" without the number isn't traceable.
// The status can also be set programmatically (the per-discipline roll-up), so
// re-check the predecessor gate at the point of saving.
const _r=readiness();
if(STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX && _r.blocking.length && !pkgGateOverride){
if(!confirmEarlyRelease(_r.blocking)) return;
}
if(isEwp() && iffRequired() && !gv('wp_iff').trim()){
alert('Coordination is set to "Signed off (IFF)" — enter the IFF number so the sign-off is traceable.');
const el=document.getElementById('wp_iff'); if(el){ el.focus(); el.scrollIntoView({behavior:'smooth',block:'center'}); }
@@ -1031,7 +1206,13 @@ function renderPackage(pkg){
h+=`<h2>3.0 Scope & Work</h2><table><tbody>
<tr><th style="width:200px">Description of Work</th><td>${scopeHtml}</td></tr>
<tr><th>Labor Est. Hrs.</th><td>${cell(pkg.hours)}</td></tr>
<tr><th>Package Predecessor</th><td>${pkg.seq?('After: '+esc(pkg.seq)):'None (no predecessor)'}</td></tr>
<tr><th>Predecessor packages</th><td>${
(pkg.predecessors && pkg.predecessors.length)
? pkg.predecessors.map(id=>{ const q=wpById(id); return q ? (esc(q.number||id)+' — '+esc(q.status)) : esc(id)+' (deleted)'; }).join('<br>')
: 'None'
}</td></tr>
<tr><th>Sequence phase</th><td>${pkg.seq?esc(pkg.seq):ns()}</td></tr>
${pkg.gateOverride?`<tr><th>Released early</th><td><strong>Predecessor gate overridden.</strong><br>${esc(pkg.gateOverride.reason||'')}<br><span style="color:var(--text-dim)">${esc(pkg.gateOverride.by||'')} · ${esc(pkg.gateOverride.at?wpFormatDateTime(pkg.gateOverride.at):'')}</span></td></tr>`:''}
</tbody></table>`;
if(pkg.materials&&pkg.materials.length){ const showDisc=pkg.materials.some(m=>m.discipline);
h+=`<h2>4.0 Material List</h2><table><thead><tr><th style="width:80px">Qty</th><th style="width:80px">Unit</th><th>Description</th>${showDisc?'<th style="width:120px">Discipline</th>':''}</tr></thead><tbody>`;
@@ -1164,7 +1345,8 @@ function updateStickyStatus(){
const r=readiness(); const st=getRadio('status');
if(st==='Issue'){ el.className='sticky-status ss-hold'; el.textContent=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened`; }
else if(r.ready){ el.className='sticky-status ss-ready'; el.textContent=`✓ Release-ready — all ${r.total} constraints cleared`; }
else { el.className='sticky-status ss-notready'; el.textContent=`${r.open} of ${r.total} constraint${r.open===1?'':'s'} open`; }
else if(r.constraintsClear){ el.className='sticky-status ss-notready'; el.textContent=` Waiting on ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}`; }
else { el.className='sticky-status ss-notready'; el.textContent=`${r.open} of ${r.total} constraint${r.open===1?'':'s'} open`+(r.blocking.length?` · ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}`:''); }
}
// ── SAVED PACKAGES ───────────────────────────────────────────────────────────
@@ -1176,6 +1358,7 @@ function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(wpKey(STORE_K
function renderSavedList(){
const card=document.getElementById('saved-card'), body=document.getElementById('saved-body');
renderWpNav();
renderPredPicker(); // candidates + blocking state change as packages are saved
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='';
@@ -1223,8 +1406,11 @@ function renderWpNav(){
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');
// Waiting on an unclosed predecessor is 'not ready' too, not just open constraints.
const waiting=(p.predecessors||[]).map(id=>wpById(id)).filter(x=>x&&x.status!=='Closed').length;
const dot = p.status==='Issue' ? 'hold' : ((open===0 && !waiting) ? 'ok' : 'open');
const state = p.status==='Issue' ? 'on hold'
: (open ? open+' open' : (waiting ? 'waits on '+waiting : 'ready'));
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>'+
@@ -1303,6 +1489,9 @@ function loadPackageIntoForm(p){
set('wp_wbs',p.wbs);
document.getElementById('wp_kit_status').value=p.kitStatus||'';
buildSequencePicker(); document.getElementById('wp_seq').value=p.seq||'';
pkgPredecessors=Array.isArray(p.predecessors)?p.predecessors.slice():[];
pkgGateOverride=p.gateOverride||null;
renderPredPicker();
setRadio('status',p.status||'Draft');
// number dimensions
numberDims = p.numberDims ? {...p.numberDims} : {}; buildNumberDims();
@@ -1371,6 +1560,7 @@ function newPackage(){
onClashChange();
pkgKind='iwp'; applyKind();
resetPeopleForNewPackage();
pkgPredecessors=[]; pkgGateOverride=null; renderPredPicker();
setRadio('status','Draft');
numberDims={}; buildNumberDims();
pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange();
@@ -1449,6 +1639,9 @@ function statusPill(s){
}
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'); }
// Predecessor packages of `p` that aren't Closed. Deleted ones don't block.
function wpWaitingOn(p){ return (p.predecessors||[]).map(id=>wpById(id)).filter(x=>x&&x.status!=='Closed'); }
function wpReleaseBlocked(p){ return wpOpenConstraints(p).length>0 || wpWaitingOn(p).length>0; }
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.
function countableWPs(){ return WPData.list().filter(p=>!p.split); }
@@ -1470,7 +1663,7 @@ function renderDashboard(){
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(!wpReleaseBlocked(p) && 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);
});
@@ -1537,7 +1730,7 @@ function renderDashboard(){
if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) 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==='ready' && !(!p.split && !wpReleaseBlocked(p) && 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;
@@ -1553,14 +1746,18 @@ function renderDashboard(){
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 waiting=wpWaitingOn(p);
const gates= p.split?'<span class="badge badge-O">master</span>'
:(open?`<span class="badge badge-O">${open} open</span>`
:(waiting.length?`<span class="badge badge-O" title="${esc(waiting.map(w=>(w.number||w.id)+' — '+w.status).join(', '))}">waits on ${waiting.length}</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 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 canIssue = !p.split && open===0 && !waiting.length && 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>`;
}
@@ -1582,6 +1779,15 @@ function renderDashboard(){
function dashIssue(id){
const p=WPData.get(id); if(!p) return;
if(wpOpenConstraints(p).length>0){ alert('Cannot issue — open constraints remain.'); return; }
const waiting=wpWaitingOn(p);
if(waiting.length){
// Releasing early needs a reason, same as on the form — the dashboard must not
// be the quiet way around the gate.
alert('Cannot issue from here — waiting on:\n\n• '+
waiting.map(w=>(w.number||w.id)+' — '+w.status).join('\n• ')+
'\n\nOpen the package to release it early with a logged reason.');
return;
}
if(!confirm('Issue work package "'+(p.number||p.subject)+'"? This marks it released to the field.')) return;
WPData.issue(id); renderDashboard(); renderSavedList(); toast('Issued '+(p.number||'')); track('dashboard_issue');
}

View File

@@ -164,7 +164,11 @@
<button class="btn btn-ghost" id="split-disc-btn" style="display:none;margin-top:10px" onclick="splitByDiscipline()" title="Break this multi-discipline package into one numbered instance per discipline">⎘ Split by Discipline</button>
<div class="field-grid" style="margin-top:14px">
<div class="field"><label>Labor Est. Hrs.</label><input type="number" id="wp_hours" min="0" step="1" placeholder="e.g. 20" oninput="onHoursChange()"><div class="field-hint" id="size-check"></div></div>
<div class="field"><label>Package Predecessor</label><select id="wp_seq"></select><div class="field-hint">The package/step (from the SOP sequence) that must finish before this work can start. Choose "None" if it has no predecessor.</div></div>
<div class="field"><label>Predecessor work packages<span class="help-tip" data-tip="The packages that must be Closed before this one can be released. A package with an open predecessor is not release-ready — you can still release it, but the override is logged.">i</span></label>
<div class="people-pick" id="pick_predecessors"></div>
<div class="field-hint" id="pred-hint"></div></div>
<div class="field"><label>Sequence phase <span class="help-tip" data-tip="Which phase of the SOP's construction sequence this package belongs to. Descriptive — it does not gate release; predecessor packages do.">i</span></label>
<select id="wp_seq"></select><div class="field-hint sop-hint">from the SOP construction sequence</div></div>
</div>
</div>