T7.6 - CR-014/D2/D9/D10: the Ready for QA gate, notification only, shipped off
Marlena's ask: QA sees inbound work ahead of time, not after the fact. The rung: 'Ready for QA' sits between In Progress and QC in BOTH ladders (wp-creation-app.js STATUS_ORDER, server/app.py STATUS_ORDER) and in Field View's list - inside the T7.3 transition model, not beside it: entering it from an unreleased state crosses the release gates, and 'Issue' (hold) stays a branch. The dashboard filter and the navigator grouping learned the state from the ladder without their own edits. Who hears (D2): the QA GROUP, a multi-pick of project members on the SOP wizard's team step, stored as account ids at data.sop.project.qaGroupIds. Entering Ready for QA emails that list and nobody else. A rejection emails the owner AND the same list (amended answer), returns the package to In Progress, and REQUIRES a fresh comment - server-enforced on both write paths (the first version accepted any old comment already on the record, which made every rejection after the first one free; the gate now demands a new entry). Accept and reject are real buttons on the release banner; the comment modal enforces its field; qa_ready / qa_rejected / status_changed all land in the audit history. The link (X1): wp_link() now opens THE package - wp-creation-index.html ?project&wp=<id>, which the creator boots directly and login.html?next= round-trips for a signed-out recipient. It previously pointed at the suite root, which is exactly the failure X1 names; assignment mail inherits the fix. Email discipline (D10 + standing rules): ships OFF (the stored setting the admin console already owns; PUT /api/settings is admin-only, 403 for anyone else, and audited). With it off, transitions write outbox rows marked 'skipped' and the sink receives nothing. With it on, the probe runs a REAL SMTP conversation against an in-process capture sink and asserts the count and the exact recipient set. A dead SMTP host leaves a 'failed' outbox row with the error recorded. The SMTP password exists only in the environment. DEVIATION, stated: the task's Do-paragraph asks the email to include location and a scope summary; the done-when list (and CLAUDE.md) says no customer IP in a message body. The done-when wins: bodies carry the WP number, who moved it, and the deep link. A location canary planted on the package is asserted absent from every captured message. If the fuller body is wanted, that is a product call - needs Nick. Found while building, logged not fixed (BL-021): project_sop_team() reads sop.data['project'], a path pushSOP never writes - the critical-reopen email has never actually reached the PM/CM. One-line fix, owned by T9.9. Field View (D9): 'Ready for QA' is carried by TEXT on the card at 390px. Verification (each probe run alone): NEW tests/qa_gate_check.py 40/40. Regressions: hold_check 50/50, pipeline_check 44/44, aggregates_check 16/16, frame_check 39/39, validation_check 83/83. Items: CR-014, D2, D9, D10 (X1, X3 honored) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,8 +5,8 @@
|
||||
'use strict';
|
||||
|
||||
var PID = '', PROJECT = null, WPS = [], curId = null, pendingPhoto = '', draftNote = '';
|
||||
var STATUSES = ['Draft', 'Scheduled', 'Issued', 'In Progress', 'QC', 'Closed', 'Issue'];
|
||||
var GATED = ['Issued', 'In Progress', 'QC', 'Closed']; // need all constraints cleared to enter
|
||||
var STATUSES = ['Draft', 'Scheduled', 'Issued', 'In Progress', 'Ready for QA', 'QC', 'Closed', 'Issue'];
|
||||
var GATED = ['Issued', 'In Progress', 'Ready for QA', 'QC', 'Closed']; // need all constraints cleared to enter
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
||||
function nsKey(id) { return 'wp_iwp_v1__' + id; }
|
||||
@@ -84,6 +84,7 @@ function renderList() {
|
||||
var waiting = waitingCount(p, WPS); // the full set, not the filtered rows
|
||||
var cls = p.status === 'Issue' ? 'hold' : ((open === 0 && !waiting) ? 'ready' : '');
|
||||
var readyPill = p.status === 'Issue' ? '<span class="pill bad">On hold</span>'
|
||||
: p.status === 'Ready for QA' ? '<span class="pill ok">Ready for QA</span>'
|
||||
: (open ? '<span class="pill warn">' + open + ' open</span>'
|
||||
: (waiting ? '<span class="pill warn">waits on ' + waiting + '</span>'
|
||||
: '<span class="pill ok">Ready</span>'));
|
||||
|
||||
@@ -32,6 +32,7 @@ let state = {
|
||||
// notification routing uses — a typed name can't be emailed.
|
||||
team: {pm:'', apm:'', cm:'', qm:''},
|
||||
teamIds: {pm:'', apm:'', cm:'', qm:''},
|
||||
qaGroupIds: [], // D2: who is emailed when a package reaches Ready for QA
|
||||
teamMembers: [],
|
||||
signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}],
|
||||
wpTypes: [],
|
||||
@@ -419,6 +420,7 @@ function loadSampleData(){
|
||||
state.team.cm = 'K. Boyd';
|
||||
state.team.qm = 'D. Nguyen';
|
||||
state.teamIds = {pm:'', apm:'', cm:'', qm:''};
|
||||
state.qaGroupIds = [];
|
||||
renderTeamPickers();
|
||||
|
||||
// Step 3 — standard required roles
|
||||
@@ -500,6 +502,7 @@ function restoreSavedSOP(){
|
||||
// A SOP saved before the team was account-backed has no teamIds; default them
|
||||
// so the pickers render (the stored names show as "(no account)" until linked).
|
||||
if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''};
|
||||
if(!Array.isArray(state.qaGroupIds)) state.qaGroupIds = []; // D2, pre-T7.6 SOPs
|
||||
|
||||
// Re-render dynamic lists from restored state.
|
||||
renderWPTypes();
|
||||
@@ -820,6 +823,7 @@ function renderTeamPickers(){
|
||||
sel.innerHTML = html;
|
||||
sel.onchange = function(){ setTeamLead(key, this.value); };
|
||||
});
|
||||
renderQaGroupPicker();
|
||||
if(!warn) return;
|
||||
if(projectUsersLoaded && !projectUsers.length){
|
||||
warn.style.display = '';
|
||||
@@ -835,6 +839,20 @@ function renderTeamPickers(){
|
||||
}
|
||||
}
|
||||
|
||||
// D2: the QA group - a multi-pick of project members, chosen once here and read
|
||||
// by the server when a package reaches Ready for QA. Stored as account ids;
|
||||
// display names are derived at save so a rename never breaks the routing.
|
||||
function renderQaGroupPicker(){
|
||||
const sel = document.getElementById('proj_qagroup');
|
||||
if(!sel) return;
|
||||
const cur = new Set(state.qaGroupIds || []);
|
||||
sel.innerHTML = projectUsers.map(u =>
|
||||
`<option value="${escAttr(u.id)}"${cur.has(u.id)?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
|
||||
sel.onchange = function(){
|
||||
state.qaGroupIds = [...this.selectedOptions].map(o => o.value).filter(Boolean);
|
||||
};
|
||||
}
|
||||
|
||||
// One <select> of project people, reused everywhere the SOP names someone. Keeps a
|
||||
// name that has no matching account as a selected "(no account)" option so older
|
||||
// SOPs — and the sample's fictional names — are never silently dropped.
|
||||
@@ -2124,6 +2142,9 @@ function completeSOP(){
|
||||
apmId: state.teamIds.apm || '',
|
||||
cmId: state.teamIds.cm || '',
|
||||
qmId: state.teamIds.qm || '',
|
||||
// D2: the QA group - ids are the routing, names are for display.
|
||||
qaGroupIds: (state.qaGroupIds || []).slice(),
|
||||
qaGroup: (state.qaGroupIds || []).map(id => { const u = userById(id); return u ? (u.full_name || u.username) : ''; }).filter(Boolean),
|
||||
site: state.project.site,
|
||||
teamMembers: state.teamMembers.filter(m=>(m.role||m.name||m.userId))
|
||||
},
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
<label>Quality manager (QM)</label>
|
||||
<select id="proj_qm" class="team-pick" data-team="qm"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>QA group — notified at Ready for QA</label>
|
||||
<select id="proj_qagroup" multiple size="4" aria-describedby="qagroup-hint"></select>
|
||||
<div class="field-hint" id="qagroup-hint">Everyone picked here is emailed when a work package reaches <strong>Ready for QA</strong> (CR-014). Hold Ctrl (Cmd on Mac) to pick several.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
||||
<div class="sub-heading">Additional Team Members (optional)</div>
|
||||
|
||||
@@ -40,7 +40,10 @@ const SAMPLE_SOP = {
|
||||
// Standard AWP constraint set (Vol I §2.3.2; Vol II IWP checklists)
|
||||
const DEFAULT_CONSTRAINTS = ['Safety & Permitting','Quality Control / Inspection','IFC Drawings & Specs','Schedule','Materials (on site, bagged & tagged)','Prefabrication','Work Access & Laydown','Craft Availability','Construction Equipment & Tools','Scaffolding / Access Equipment'];
|
||||
const SIGNOFF_ROLES = ['Planner','Superintendent','HSE Professional','Quality Representative','Work Foreman'];
|
||||
const STATUS_ORDER = ['Draft','Scheduled','Issued','In Progress','QC','Closed'];
|
||||
// CR-014: 'Ready for QA' is a rung between In Progress and QC - the queue QA
|
||||
// plans their week from. 'Issue' (hold) stays OUT of this ladder: it is a
|
||||
// branch, not a rung (T7.3).
|
||||
const STATUS_ORDER = ['Draft','Scheduled','Issued','In Progress','Ready for QA','QC','Closed'];
|
||||
const ISSUED_IDX = STATUS_ORDER.indexOf('Issued');
|
||||
|
||||
// Acumatica cost codes (comment 10) — code|description
|
||||
@@ -62,7 +65,7 @@ const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":
|
||||
let SOP=null, editingId=null, numberDirty=false;
|
||||
let pkgKind='iwp'; // 'iwp' (install) | 'ewp' (BIM) — per-package, only relevant when SOP.bimEnabled
|
||||
let activeProjectId=''; // set at boot from ?project=<id>; stamped onto saved WPs for the API
|
||||
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[];
|
||||
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[], pkgQaRejections=[];
|
||||
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
|
||||
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
|
||||
let pkgDisciplines=[]; // disciplines this WP covers (from SOP governance.disciplines)
|
||||
@@ -989,6 +992,15 @@ 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(st==='Ready for QA'){
|
||||
// CR-014: the QA decision lives where the state is announced. Accept moves to
|
||||
// QC; reject returns to the crew and REQUIRES a comment (the modal enforces
|
||||
// it, and the server refuses the transition without one).
|
||||
cls='rb-qa';
|
||||
txt=`◉ In the QA queue — accept to begin QC, or return it to the crew with a comment.`
|
||||
+` <button type="button" class="rb-act" onclick="qaAccept()">Accept — start QC</button>`
|
||||
+`<button type="button" class="rb-act rb-act-ghost" onclick="qaRejectOpen()">Reject…</button>`;
|
||||
}
|
||||
else if(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; }
|
||||
else if(r.constraintsClear){
|
||||
// Constraints are done; what's left is upstream work.
|
||||
@@ -1081,6 +1093,32 @@ function releaseHold(clearedName){
|
||||
if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
}
|
||||
|
||||
// CR-014: accept / reject, from the banner. Accept is a plain forward step.
|
||||
// Reject is the audited return: a comment is mandatory, rides on the package
|
||||
// (data.qaRejections), and the server emails the owner and the QA group.
|
||||
function qaAccept(){
|
||||
setRadio('status','QC'); prevStatus='QC';
|
||||
updateReleaseBanner(); toast('Accepted — package is in QC.');
|
||||
track('qa_accepted');
|
||||
}
|
||||
function qaRejectOpen(){
|
||||
const el=document.getElementById('qa-reject-comment'); if(el) el.value='';
|
||||
document.getElementById('qa-reject-modal').classList.add('open');
|
||||
if(el) el.focus();
|
||||
}
|
||||
function qaRejectCancel(){ document.getElementById('qa-reject-modal').classList.remove('open'); }
|
||||
function qaRejectSubmit(){
|
||||
const c=(document.getElementById('qa-reject-comment')||{value:''}).value.trim();
|
||||
if(!c){ alert('A comment is required — the crew needs to know what to fix.'); return; }
|
||||
pkgQaRejections.push({ ts:new Date().toISOString(), comment:c,
|
||||
by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
|
||||
from:'Ready for QA' });
|
||||
document.getElementById('qa-reject-modal').classList.remove('open');
|
||||
setRadio('status','In Progress'); prevStatus='In Progress';
|
||||
updateReleaseBanner(); toast('Returned to In Progress — the comment is on the record.');
|
||||
track('qa_rejected');
|
||||
}
|
||||
|
||||
// D4: the banner's primary action for an Urgent package blocked by constraints.
|
||||
// It runs the SAME audited path the status control runs - one override, one log.
|
||||
function urgentOverrideRelease(){
|
||||
@@ -1253,6 +1291,7 @@ function collectPackage(){
|
||||
qcFromSOP: !pkgOverrides['wp_qc'], photoFromSOP: !pkgOverrides['wp_photo'], holdFromSOP: !pkgOverrides['wp_hold'], overrides:{...pkgOverrides},
|
||||
signoffs:pkgSignoffs.map(s=>({role:s.role,name:s.name,date:s.date,signed:s.signed,dateReason:s.dateReason||''})),
|
||||
holds:pkgHolds.map(h=>({...h})),
|
||||
qaRejections:pkgQaRejections.map(x=>({...x})),
|
||||
actualHrs:gv('wp_actual_hrs'), installedQty:gv('wp_installed_qty'), redlines:gv('wp_redlines'), lessons:gv('wp_lessons'),
|
||||
kind: pkgKind, // 'iwp' (install) | 'ewp' (BIM) — drives which fields/types/gates apply
|
||||
// AWP traceability: which BIM/model package(s) enabled this install package.
|
||||
@@ -2043,7 +2082,7 @@ 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'];
|
||||
const WPNAV_ORDER=['Issue','Ready for QA','In Progress','Issued','QC','Scheduled','Draft','Closed'];
|
||||
// The panel's ESSENTIAL layout ships with this script rather than living only in
|
||||
// wp-creation-styles.css. The two files are cached independently, so a browser can
|
||||
// run new markup against an old stylesheet — and without these rules the panel is
|
||||
@@ -2395,6 +2434,7 @@ function loadPackageIntoForm(p){
|
||||
pkgConstraints=(p.constraints||[]).map(c=>({...c})); if(!pkgConstraints.length) buildConstraints(); else renderConstraintRows();
|
||||
pkgSignoffs=(p.signoffs||[]).map(s=>({...s, fromSOP:!!s.name})); if(!pkgSignoffs.length) buildSignoffs(); else renderSignoffRows();
|
||||
pkgHolds=(p.holds||[]).map(h=>({...h}));
|
||||
pkgQaRejections=(p.qaRejections||[]).map(x=>({...x}));
|
||||
updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm(); wpFormPopulated();
|
||||
}
|
||||
function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; buildConstraints();
|
||||
@@ -2457,7 +2497,7 @@ function newPackage(){
|
||||
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
|
||||
pkgWorkSteps=['']; buildWorkSteps();
|
||||
pkgConstraints=[]; buildConstraints(); pkgSignoffs=[]; buildSignoffs();
|
||||
pkgHolds=[]; pkgOverrides={};
|
||||
pkgHolds=[]; pkgQaRejections=[]; pkgOverrides={};
|
||||
set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
||||
prevStatus='Draft';
|
||||
updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); wpFormPopulated(); renderWpNav(); track('new_package');
|
||||
|
||||
@@ -194,6 +194,7 @@
|
||||
<label class="radio-pill" data-val="Scheduled"><input type="radio" name="status"><span class="dot"></span>Scheduled</label>
|
||||
<label class="radio-pill" data-val="Issued"><input type="radio" name="status"><span class="dot"></span>Issued</label>
|
||||
<label class="radio-pill" data-val="In Progress"><input type="radio" name="status"><span class="dot"></span>In progress</label>
|
||||
<label class="radio-pill" data-val="Ready for QA"><input type="radio" name="status"><span class="dot"></span>Ready for QA</label>
|
||||
<label class="radio-pill pill-hold" data-val="Issue"><input type="radio" name="status"><span class="dot"></span>Issue (hold)</label>
|
||||
<label class="radio-pill" data-val="QC"><input type="radio" name="status"><span class="dot"></span>QC</label>
|
||||
<label class="radio-pill" data-val="Closed"><input type="radio" name="status"><span class="dot"></span>Closed</label>
|
||||
@@ -501,6 +502,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QA REJECT MODAL (CR-014). The comment is not optional: a rejection with no
|
||||
reason is the after-the-fact surprise this gate exists to end, and the server
|
||||
refuses the transition without one. -->
|
||||
<div class="modal-overlay" id="qa-reject-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-head"><div class="modal-title">Return to the crew — QA rejection</div><button class="cmt-x" onclick="qaRejectCancel()" title="Cancel">✕</button></div>
|
||||
<div class="modal-body">
|
||||
<div class="notice">The package goes back to <strong>In Progress</strong>. The owner and the QA group are notified, and the comment stays on the package.</div>
|
||||
<div class="field"><label>What needs fixing <span class="req">*</span></label><textarea id="qa-reject-comment" rows="3" placeholder="What QA found — required"></textarea></div>
|
||||
</div>
|
||||
<div class="modal-foot"><button class="btn btn-ghost" onclick="qaRejectCancel()">Cancel</button><button class="btn btn-generate" onclick="qaRejectSubmit()">Reject — back to In Progress</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VIEW SOP MODAL (comment 2) -->
|
||||
<div class="modal-overlay" id="sop-modal">
|
||||
<div class="modal" style="max-width:640px">
|
||||
|
||||
@@ -568,6 +568,10 @@
|
||||
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid var(--wp-status-success-border-b); }
|
||||
.rb-notready { background:var(--accent-amber-dim); color:var(--accent-amber); border:1px solid var(--wp-status-warning-border-b); }
|
||||
.rb-hold { background:var(--red-dim); color:var(--red); border:1px solid var(--wp-status-error-border-b); }
|
||||
/* CR-014: the QA-queue state. Informational, not alarming - accent, not amber. */
|
||||
.rb-qa { background:var(--accent-dim); color:var(--accent); border:1px solid var(--accent); }
|
||||
.rb-act-ghost { background:transparent; color:var(--accent); border:1px solid var(--accent); margin-left:8px; }
|
||||
.rb-act-ghost:hover { background:var(--accent-dim); }
|
||||
/* D4: the audited-override action on the release banner. A real button in the
|
||||
primary position for an Urgent package; it simply never renders otherwise. */
|
||||
.rb-act { margin-left:auto; border:none; border-radius:var(--radius); cursor:pointer;
|
||||
|
||||
Reference in New Issue
Block a user