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:
@@ -346,6 +346,48 @@ hides the BIM/VDC section and every project is install-only (IWP). A SOP that
|
||||
already has BIM enabled keeps its data — it just stops being offered — so turning
|
||||
the flag off never deletes BIM types, gates, or sequence steps.
|
||||
|
||||
## Release gates (constraints + predecessors)
|
||||
|
||||
A work package reaches **Issued** only when both gates are met:
|
||||
|
||||
1. every constraint is **Cleared** or **N/A** — a hard gate, no override;
|
||||
2. every **predecessor work package** (`data.predecessors`, a list of WP ids) is
|
||||
**Closed**.
|
||||
|
||||
Enforced by `enforce_release_gates()` on **every** path that can set a status —
|
||||
`/api/wps` (the browser and the offline outbox both save through it),
|
||||
`/api/wps/{id}/issue`, and `/api/wps/{id}/status`. Also:
|
||||
|
||||
- **Overridable, deliberately.** Planners legitimately release ahead of upstream
|
||||
close-out, so the predecessor gate accepts `data.gateOverride = {reason, by, at}`.
|
||||
A blank reason is not an override. The server writes a `gate_overridden` audit
|
||||
event naming the reason and what was skipped, and the reason prints on the
|
||||
package. Changing the predecessor set clears the override.
|
||||
- **Cycles are refused** (`check_predecessor_cycle`) — direct and through a chain,
|
||||
with a 400 explaining which package already waits on this one.
|
||||
- **A deleted predecessor does not block.** It would otherwise freeze everything
|
||||
downstream of a package someone removed.
|
||||
- The Creator's picker hides itself and any package that already waits on it, so a
|
||||
cycle is hard to build in the first place; the dashboard refuses to issue a
|
||||
blocked package and points at the form for the logged override.
|
||||
|
||||
`data.seq` (the SOP sequence phase) is still stored and shown, but it is
|
||||
descriptive — it gates nothing.
|
||||
|
||||
## Critical constraints reopened after release
|
||||
|
||||
A constraint marked **Critical** on the SOP that reopens **after** the package was
|
||||
released emails the **owner, PM, CM and everyone on the package's distribution
|
||||
list** (minus whoever reopened it), and writes a `constraint_reopened` audit event.
|
||||
|
||||
Detected by comparing incoming constraints against the stored ones inside the
|
||||
normal upsert — *not* a separate endpoint, because the browser saves through the
|
||||
sync outbox, which only replays `POST /api/wps`; anything hung off another route
|
||||
would be lost offline. It fires only on a real transition (cleared/N-A → open), so
|
||||
re-saving an already-open constraint doesn't re-announce, and never for a package
|
||||
that was never released or a non-critical constraint. Bodies carry the constraint
|
||||
name, WP number and a link — never the package contents.
|
||||
|
||||
## Localization (dates, times, numbers)
|
||||
|
||||
Three levels, most specific first — resolved in `html/wp-format.js`:
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
217
server/app.py
217
server/app.py
@@ -873,6 +873,183 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user),
|
||||
return {"deleted": sop_id}
|
||||
|
||||
|
||||
# ── Release gates (constraints + predecessors) ─────────────────────────────────
|
||||
# Status ladder, mirrored in the front end (wp-creation-app.js STATUS_ORDER).
|
||||
STATUS_ORDER = ["Draft", "Scheduled", "Issued", "In Progress", "Issue", "QC", "Closed"]
|
||||
ISSUED_IDX = STATUS_ORDER.index("Issued")
|
||||
DONE_STATUS = "Closed"
|
||||
|
||||
|
||||
def _released(status: Optional[str]) -> bool:
|
||||
"""Has this package been released to the field (Issued or anything after)?"""
|
||||
try:
|
||||
return STATUS_ORDER.index(status or "") >= ISSUED_IDX
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def predecessor_ids(data: Optional[dict]) -> list[str]:
|
||||
"""Work-package ids this package waits on. Ignores anything malformed rather
|
||||
than failing a save — a bad id simply can't block."""
|
||||
raw = (data or {}).get("predecessors") or []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [p for p in raw if isinstance(p, str) and _ID_RE.match(p)]
|
||||
|
||||
|
||||
def gate_override(data: Optional[dict]) -> Optional[dict]:
|
||||
"""A deliberate, reasoned override of the predecessor gate. Planners legitimately
|
||||
need to stage packages ahead of the work finishing, so the gate is refusable —
|
||||
but only explicitly, and it lands in the audit log."""
|
||||
ov = (data or {}).get("gateOverride")
|
||||
if isinstance(ov, dict) and str(ov.get("reason") or "").strip():
|
||||
return ov
|
||||
return None
|
||||
|
||||
|
||||
def blocking_predecessors(db: Session, wp_id: Optional[str], data: Optional[dict]) -> list[dict]:
|
||||
"""Predecessors that are not Closed yet. A predecessor that no longer exists is
|
||||
NOT blocking — a deleted package must not freeze everything downstream."""
|
||||
ids = [p for p in predecessor_ids(data) if p != wp_id]
|
||||
if not ids:
|
||||
return []
|
||||
rows = db.scalars(select(models.WorkPackage).where(models.WorkPackage.id.in_(ids))).all()
|
||||
return [
|
||||
{"id": r.id, "number": r.number, "subject": r.subject, "status": r.status}
|
||||
for r in rows if r.status != DONE_STATUS
|
||||
]
|
||||
|
||||
|
||||
def check_predecessor_cycle(db: Session, wp_id: str, data: Optional[dict]) -> None:
|
||||
"""Refuse a predecessor set that would make A wait on itself (directly or
|
||||
through a chain). Walks the graph from the proposed predecessors; the visited
|
||||
set also bounds the walk, so a cycle that already exists elsewhere in the data
|
||||
can't spin here."""
|
||||
start = [p for p in predecessor_ids(data)]
|
||||
if wp_id in start:
|
||||
raise HTTPException(status_code=400, detail="A work package cannot be its own predecessor.")
|
||||
seen, stack = set(), list(start)
|
||||
while stack:
|
||||
cur = stack.pop()
|
||||
if cur in seen:
|
||||
continue
|
||||
seen.add(cur)
|
||||
row = db.get(models.WorkPackage, cur)
|
||||
if row is None:
|
||||
continue
|
||||
nxt = predecessor_ids(row.data)
|
||||
if wp_id in nxt:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"That would create a circular dependency ({row.number or row.id} already waits on this package).",
|
||||
)
|
||||
stack.extend(n for n in nxt if n not in seen)
|
||||
|
||||
|
||||
def enforce_release_gates(db: Session, wp_id: Optional[str], data: Optional[dict],
|
||||
new_status: str, old_status: Optional[str]) -> None:
|
||||
"""Refuse a move to Issued (or beyond) while a release gate is unmet. Applied to
|
||||
every path that can set a status — the plain upsert included, since that's how
|
||||
the browser and the offline outbox save."""
|
||||
if not _released(new_status) or _released(old_status):
|
||||
return # not a release transition
|
||||
constraints = (data or {}).get("constraints") or []
|
||||
open_names = [c.get("name") for c in constraints if isinstance(c, dict) and c.get("status") == "open"]
|
||||
if open_names:
|
||||
raise HTTPException(status_code=409, detail={
|
||||
"message": "Open constraints block release", "open": open_names,
|
||||
})
|
||||
blockers = blocking_predecessors(db, wp_id, data)
|
||||
if blockers and not gate_override(data):
|
||||
raise HTTPException(status_code=409, detail={
|
||||
"message": "Predecessors are not closed yet",
|
||||
"blocking": [f"{b['number'] or b['id']} ({b['status']})" for b in blockers],
|
||||
})
|
||||
|
||||
|
||||
# ── Critical-constraint notification ───────────────────────────────────────────
|
||||
# A constraint flagged CRITICAL on the SOP, reopened after the package was
|
||||
# released, is announced by email. We detect it by comparing the incoming
|
||||
# constraints against the stored ones during the normal upsert rather than adding a
|
||||
# separate endpoint: the browser saves through the sync outbox, which only replays
|
||||
# POST /api/wps, so anything hung off another route would be lost offline.
|
||||
def reopened_critical(old_data: Optional[dict], new_data: Optional[dict]) -> list[str]:
|
||||
old = {}
|
||||
for c in (old_data or {}).get("constraints") or []:
|
||||
if isinstance(c, dict) and c.get("name"):
|
||||
old[c["name"]] = c.get("status")
|
||||
out = []
|
||||
for c in (new_data or {}).get("constraints") or []:
|
||||
if not isinstance(c, dict) or not c.get("critical"):
|
||||
continue
|
||||
name = c.get("name")
|
||||
if not name or c.get("status") != "open":
|
||||
continue
|
||||
if old.get(name) not in (None, "open"): # was cleared/na, now open
|
||||
out.append(name)
|
||||
return out
|
||||
|
||||
|
||||
def project_sop_team(db: Session, project_id: Optional[str]) -> list[str]:
|
||||
"""PM + CM user ids from the project's most recent complete SOP."""
|
||||
if not project_id:
|
||||
return []
|
||||
sop = db.scalars(
|
||||
select(models.Sop)
|
||||
.where((models.Sop.project_id == project_id) & (models.Sop.complete.is_(True)))
|
||||
.order_by(models.Sop.updated_at.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
if not sop:
|
||||
return []
|
||||
proj = (sop.data or {}).get("project") or {}
|
||||
return [i for i in (proj.get("pmId"), proj.get("cmId")) if i]
|
||||
|
||||
|
||||
def hold_body(user: "models.User", wp: "models.WorkPackage", names: list[str],
|
||||
actor: "models.User", link: str) -> str:
|
||||
# Constraint names and a WP number only — no package contents, same rule as the
|
||||
# assignment mail.
|
||||
who = user.full_name or user.username
|
||||
by = actor.full_name or actor.username
|
||||
which = ", ".join(names)
|
||||
return (
|
||||
f"Hi {who},\n\n"
|
||||
f"A critical constraint was reopened on {wp.number or 'a work package'} "
|
||||
f"after it was released to the field, so the package is on hold.\n\n"
|
||||
f"Constraint: {which}\n"
|
||||
f"Reopened by: {by}\n\n"
|
||||
f"Open the package:\n{link}\n"
|
||||
)
|
||||
|
||||
|
||||
def notify_critical_reopen(db: Session, wp: "models.WorkPackage", names: list[str],
|
||||
actor: "models.User") -> list["models.Notification"]:
|
||||
"""Owner + PM + CM + everyone on the package's distribution list, minus whoever
|
||||
did it (they already know) and minus duplicates."""
|
||||
ids = []
|
||||
if wp.assignee_id:
|
||||
ids.append(wp.assignee_id)
|
||||
ids.extend(project_sop_team(db, wp.project_id))
|
||||
ids.extend([i for i in ((wp.data or {}).get("distributionIds") or []) if isinstance(i, str)])
|
||||
seen, out = set(), []
|
||||
link = wp_link(db, wp)
|
||||
for uid in ids:
|
||||
if uid in seen or uid == actor.id:
|
||||
continue
|
||||
seen.add(uid)
|
||||
u = db.get(models.User, uid)
|
||||
if not u or not u.is_active:
|
||||
continue
|
||||
out.append(notify.enqueue(
|
||||
db, user=u, kind="wp_constraint_reopened",
|
||||
subject=f"On hold: {wp.number or 'work package'} — critical constraint reopened",
|
||||
body=hold_body(u, wp, names, actor, link),
|
||||
link=link, wp_id=wp.id, project_id=wp.project_id,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
# ── Work Packages ────────────────────────────────────────────────────────────
|
||||
@app.post("/api/wps")
|
||||
def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
@@ -886,9 +1063,15 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User =
|
||||
is_new = wp is None
|
||||
old_status = None if is_new else wp.status
|
||||
old_assignee = None if is_new else wp.assignee_id
|
||||
old_data = None if is_new else (wp.data or {})
|
||||
if wp is None:
|
||||
wp = models.WorkPackage(id=body.id or gen_id("wp"))
|
||||
db.add(wp)
|
||||
# Predecessors: reject a cycle, and refuse a release while a gate is unmet.
|
||||
# Both run before anything is written so a rejected save changes nothing.
|
||||
wp_id_for_checks = body.id or wp.id
|
||||
check_predecessor_cycle(db, wp_id_for_checks, body.data)
|
||||
enforce_release_gates(db, wp_id_for_checks, body.data, body.status, old_status)
|
||||
wp.project_id = body.project_id
|
||||
wp.sop_id = body.sop_id
|
||||
wp.parent_id = body.parent_id
|
||||
@@ -910,6 +1093,25 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User =
|
||||
_act, _detail = "updated", {"status": wp.status}
|
||||
log_event(db, user, _act, "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id), detail=_detail)
|
||||
notifs = []
|
||||
# A reasoned override of the predecessor gate is worth its own audit line —
|
||||
# "released early, and here's why" is exactly what a reviewer looks for later.
|
||||
ov = gate_override(body.data)
|
||||
if ov and _released(wp.status) and not _released(old_status):
|
||||
blockers = blocking_predecessors(db, wp.id, body.data)
|
||||
log_event(db, user, "gate_overridden", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id),
|
||||
detail={"reason": str(ov.get("reason"))[:300],
|
||||
"blocking": [b["number"] or b["id"] for b in blockers]})
|
||||
# A critical constraint reopened after release: log it and tell the people who
|
||||
# need to know (owner, PM, CM, distribution).
|
||||
if not is_new and _released(old_status):
|
||||
reopened = reopened_critical(old_data, wp.data)
|
||||
if reopened:
|
||||
log_event(db, user, "constraint_reopened", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id),
|
||||
detail={"critical": reopened, "status": wp.status})
|
||||
notifs.extend(notify_critical_reopen(db, wp, reopened, user))
|
||||
# Notify a newly-assigned owner (skip self-assignment).
|
||||
notif = None
|
||||
if new_assignee and new_assignee != old_assignee and new_assignee != user.id:
|
||||
@@ -927,7 +1129,9 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User =
|
||||
db.commit()
|
||||
db.refresh(wp)
|
||||
if notif is not None:
|
||||
background_tasks.add_task(notify.deliver, notif.id)
|
||||
notifs.append(notif)
|
||||
for n in notifs:
|
||||
background_tasks.add_task(notify.deliver, n.id)
|
||||
return wp.to_dict()
|
||||
|
||||
|
||||
@@ -1048,16 +1252,13 @@ def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db
|
||||
|
||||
@app.post("/api/wps/{wp_id}/issue")
|
||||
def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
"""Release a Work Package to the field. Refuses if any constraint is still
|
||||
open (the AWP release gate)."""
|
||||
"""Release a Work Package to the field. Refuses while a release gate is unmet:
|
||||
any open constraint, or a predecessor package that isn't Closed."""
|
||||
wp = db.get(models.WorkPackage, wp_id)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
constraints = (wp.data or {}).get("constraints") or []
|
||||
open_names = [c.get("name") for c in constraints if c.get("status") == "open"]
|
||||
if open_names:
|
||||
raise HTTPException(status_code=409, detail={"message": "Open constraints block issuance", "open": open_names})
|
||||
enforce_release_gates(db, wp.id, wp.data, "Issued", wp.status)
|
||||
wp.status = "Issued"
|
||||
wp.issued_at = models.utcnow()
|
||||
log_event(db, user, "issued", "wp", wp.id, project_id=wp.project_id,
|
||||
@@ -1074,6 +1275,8 @@ def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.g
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
old_status = wp.status
|
||||
# Same gates as /issue — this route must not be a way around them.
|
||||
enforce_release_gates(db, wp.id, wp.data, body.status, old_status)
|
||||
wp.status = body.status
|
||||
if body.status == "Issued" and wp.issued_at is None:
|
||||
wp.issued_at = models.utcnow()
|
||||
|
||||
Reference in New Issue
Block a user