diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md index a3939b2..0a38e13 100644 --- a/docs/reference/file-map.md +++ b/docs/reference/file-map.md @@ -280,6 +280,7 @@ python tests/form_structure_check.py # F6/D3 - rail, disclosure, one open sectio python tests/hold_check.py # CR-015/A1/D4 - the hold clears, gates hold 50 checks python tests/warning_check.py # A2 - one warning, a badge from anywhere 17 checks python tests/triage_check.py # A6 - the sidebar answers the stand-up 16 checks +python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 40 checks ``` **Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live diff --git a/docs/waves/backlog.md b/docs/waves/backlog.md index 98031b7..c217c93 100644 --- a/docs/waves/backlog.md +++ b/docs/waves/backlog.md @@ -457,3 +457,20 @@ deliberately deferred. departure intentional, not a blanket disabling of the guard. If it is to be kept, `T7.2`'s side navigation is the place to make saving obvious enough that the prompt stops being a surprise. + +### BL-021 — `project_sop_team()` reads a path `pushSOP` never writes + +- **Found during:** T7.6 +- **Where:** `server/app.py`, `project_sop_team()` +- **What:** the function reads `sop.data["project"]`, but `ProjectData.pushSOP` + stores every SOP row as `data = {sop: ..., state: ...}` — the project block + lives at `data["sop"]["project"]`. The lookup therefore always returns `[]`, + and the critical-constraint-reopened email (Phase S wave) has never actually + reached the PM or CM it names as recipients; only the owner and distribution + got it. Found while writing `project_qa_group()` for `CR-014`, which reads the + correct path (and tolerates the flat one for safety). +- **Why not now:** T7.6 is scoped to the QA gate; fixing another feature's + recipient list inside it is the drive-by CLAUDE.md forbids. The fix is one + line, but it deserves its own verification against the capture sink. +- **Suggested wave or follow-up:** wave 9 backlog sweep (`T9.9`), verified with + the `tests/qa_gate_check.py` sink pattern. diff --git a/html/field.js b/html/field.js index dadf9fd..d3203df 100644 --- a/html/field.js +++ b/html/field.js @@ -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, '''); } 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' ? 'On hold' + : p.status === 'Ready for QA' ? 'Ready for QA' : (open ? '' + open + ' open' : (waiting ? 'waits on ' + waiting + '' : 'Ready')); diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js index c19b38b..abc1726 100644 --- a/html/work-package-suite-app.js +++ b/html/work-package-suite-app.js @@ -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 => + ``).join(''); + sel.onchange = function(){ + state.qaGroupIds = [...this.selectedOptions].map(o => o.value).filter(Boolean); + }; +} + // One +
+ + +
Everyone picked here is emailed when a work package reaches Ready for QA (CR-014). Hold Ctrl (Cmd on Mac) to pick several.
+
Additional Team Members (optional)
diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index f922db1..7c21d46 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -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=; 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.` + +` ` + +``; + } 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'); diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html index 29e5452..b935fe6 100644 --- a/html/wp-creation-index.html +++ b/html/wp-creation-index.html @@ -194,6 +194,7 @@ + @@ -501,6 +502,20 @@
+ + +