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>
194 lines
11 KiB
JavaScript
194 lines
11 KiB
JavaScript
/* Field view — a touch-optimized screen for updating a Work Package's status,
|
||
constraints, and a photo/note log from the work face. Reads the same shared
|
||
data as the desktop creator (via project-data.js) and saves through the sync
|
||
outbox, so it works offline and syncs when the network returns. */
|
||
'use strict';
|
||
|
||
var PID = '', PROJECT = null, WPS = [], curId = null, pendingPhoto = '', draftNote = '';
|
||
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; }
|
||
function stLabel(s) { return s === 'Issue' ? 'Issue (Hold)' : s; }
|
||
function openCount(p) { return ((p && p.constraints) || []).filter(function (c) { return c.status === 'open'; }).length; }
|
||
// Predecessor packages that aren't Closed yet. A package waiting on upstream work
|
||
// is not release-ready either, so the field list must not call it Ready — the
|
||
// server would refuse to issue it (see enforce_release_gates).
|
||
function waitingCount(p, all) {
|
||
var preds = (p && p.predecessors) || [];
|
||
if (!preds.length) return 0;
|
||
var byId = {};
|
||
(all || []).forEach(function (x) { byId[x.id] = x; });
|
||
return preds.filter(function (id) { var q = byId[id]; return q && q.status !== 'Closed'; }).length;
|
||
}
|
||
function fmtTs(s) { try { return wpFormatDateTime(s); } catch (e) { return s || ''; } }
|
||
function me() { try { return (window.WP_USER && (window.WP_USER.full_name || window.WP_USER.username)) || ''; } catch (e) { return ''; } }
|
||
// S10: see the note on the creator's toast. Role first, then text.
|
||
function toast(m, kind) {
|
||
var t = document.getElementById('toast'); if (!t) return;
|
||
t.setAttribute('role', kind === 'alert' ? 'alert' : 'status');
|
||
t.textContent = m; t.classList.add('show');
|
||
clearTimeout(toast._t); toast._t = setTimeout(function () { t.classList.remove('show'); }, 2000);
|
||
}
|
||
|
||
// ── boot / data ──────────────────────────────────────────────────────────────
|
||
function boot() {
|
||
var params = new URLSearchParams(location.search);
|
||
PID = params.get('project') || (ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||
if (!PID) { showNoProject(); return; }
|
||
if (ProjectData.getActiveId && ProjectData.getActiveId() !== PID) { try { ProjectData.setActive({ id: PID }); } catch (e) {} }
|
||
// Publish the resolved record rather than keeping it to ourselves: line 34 could
|
||
// only write the id, and the app bar reads the shared record, not PROJECT.
|
||
if (ProjectData.get) {
|
||
ProjectData.get(PID).then(function (p) {
|
||
PROJECT = p;
|
||
if (p && p.name) { try { ProjectData.setActive(p); } catch (e) {} }
|
||
renderCtx();
|
||
}).catch(function () {});
|
||
}
|
||
loadWPs();
|
||
}
|
||
function renderCtx() {
|
||
var el = document.getElementById('fld-ctx'); if (!el) return;
|
||
if (PROJECT) el.innerHTML = 'Project: <b>' + esc(PROJECT.name || '') + '</b>' + (PROJECT.number ? ' · ' + esc(PROJECT.number) : '');
|
||
else el.textContent = 'Project: ' + PID;
|
||
}
|
||
function readCache() { try { return JSON.parse(localStorage.getItem(nsKey(PID)) || '[]') || []; } catch (e) { return []; } }
|
||
function writeCache() { try { localStorage.setItem(nsKey(PID), JSON.stringify(WPS)); } catch (e) {} }
|
||
function activePkgs(list) { return list.filter(function (p) { return !p.split && !p.archived; }); } // real work, not masters/archived
|
||
|
||
function loadWPs() {
|
||
WPS = activePkgs(readCache()); // offline-first: show cached packages immediately
|
||
renderList();
|
||
if (ProjectData.pullProject) {
|
||
ProjectData.pullProject(PID).then(function () {
|
||
WPS = activePkgs(readCache());
|
||
if (!curId) renderList(); else renderDetail();
|
||
}).catch(function () {});
|
||
}
|
||
}
|
||
function showNoProject() {
|
||
var s = document.getElementById('screen-list');
|
||
if (s) s.innerHTML = '<div class="fld-empty">No project selected.<br><a href="index.html">Pick a project on the home page</a>, then reopen the field view.</div>';
|
||
}
|
||
|
||
// ── list ───────────────────────────────────────────────────────────────────
|
||
function renderList() {
|
||
var box = document.getElementById('wp-list'); if (!box) return;
|
||
var q = ((document.getElementById('fld-search') || {}).value || '').toLowerCase();
|
||
var rows = WPS.filter(function (p) { return !q || ((p.number || '') + ' ' + (p.subject || '') + ' ' + (p.type || '')).toLowerCase().indexOf(q) >= 0; });
|
||
if (!rows.length) { box.innerHTML = '<div class="fld-empty">' + (WPS.length ? 'No packages match your search.' : 'No work packages for this project yet.') + '</div>'; return; }
|
||
box.innerHTML = rows.map(function (p) {
|
||
var open = openCount(p);
|
||
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>'));
|
||
return '<button class="wp-card ' + cls + '" onclick="openWP(\'' + esc(p.id) + '\')">' +
|
||
'<div class="num">' + esc(p.number || '(no number)') + '</div>' +
|
||
'<div class="subj">' + esc(p.subject || '') + '</div>' +
|
||
'<div class="meta"><span class="pill st">' + esc(stLabel(p.status)) + '</span>' + readyPill +
|
||
(p.type ? '<span class="pill st">' + esc(p.type) + '</span>' : '') + '</div></button>';
|
||
}).join('');
|
||
}
|
||
|
||
// ── detail ─────────────────────────────────────────────────────────────────
|
||
function curWP() { return WPS.find(function (p) { return p.id === curId; }); }
|
||
function openWP(id) { curId = id; pendingPhoto = ''; draftNote = ''; renderDetail(); window.scrollTo(0, 0); }
|
||
function backToList() {
|
||
curId = null; pendingPhoto = ''; draftNote = '';
|
||
document.getElementById('screen-detail').style.display = 'none';
|
||
document.getElementById('screen-list').style.display = '';
|
||
renderList();
|
||
}
|
||
function renderDetail() {
|
||
var p = curWP(); if (!p) { backToList(); return; }
|
||
document.getElementById('screen-list').style.display = 'none';
|
||
var d = document.getElementById('screen-detail'); d.style.display = '';
|
||
|
||
var stBtns = STATUSES.map(function (s) {
|
||
return '<button class="st-btn' + (s === 'Issue' ? ' hold' : '') + (p.status === s ? ' on' : '') + '" onclick="setStatus(\'' + s + '\')">' + esc(stLabel(s)) + '</button>';
|
||
}).join('');
|
||
|
||
var cx = (p.constraints) || [];
|
||
var cxRows = cx.length ? cx.map(function (c, i) {
|
||
var st = c.status || 'open';
|
||
return '<div class="cx-row"><div class="cx-name">' + esc(c.name) + '</div>' +
|
||
'<button class="cx-state ' + st + '" onclick="cycleConstraint(' + i + ')">' + (st === 'cleared' ? 'Cleared' : st === 'na' ? 'N/A' : 'Open') + '</button></div>';
|
||
}).join('') : '<div style="color:var(--cds-text-helper);font-size:14px">No constraints on this package.</div>';
|
||
|
||
var log = ((p.fieldLog) || []).slice().reverse().map(function (e) {
|
||
return '<div class="log-item"><div class="lm">' + esc(e.by || '—') + ' · ' + esc(fmtTs(e.ts)) + (e.status ? ' · ' + esc(stLabel(e.status)) : '') + '</div>' +
|
||
(e.note ? esc(e.note) : '') + (e.photo && /^data:image\//.test(e.photo) ? '<img src="' + esc(e.photo) + '" alt="site photo">' : '') + '</div>';
|
||
}).join('') || '<div style="color:var(--cds-text-helper);font-size:14px">No field updates yet.</div>';
|
||
|
||
d.innerHTML =
|
||
'<button class="fld-back" onclick="backToList()">‹ All packages</button>' +
|
||
'<div class="fld-h1">' + esc(p.number || '(no number)') + '</div>' +
|
||
'<div class="fld-sub">' + esc(p.subject || '') + (p.type ? ' · ' + esc(p.type) : '') + '</div>' +
|
||
'<div class="fld-sec"><h3>Status</h3><div class="st-grid">' + stBtns + '</div></div>' +
|
||
'<div class="fld-sec"><h3>Constraints — ' + openCount(p) + ' open</h3>' + cxRows + '</div>' +
|
||
'<div class="fld-sec"><h3>Add field update</h3>' +
|
||
'<textarea class="fld-note" id="fld-note" placeholder="What happened on site? (progress, blockers, notes)" oninput="draftNote=this.value">' + esc(draftNote) + '</textarea>' +
|
||
'<div class="fld-photo-row"><label class="fld-btn">📷 Add photo<input type="file" accept="image/*" capture="environment" style="display:none" onchange="onPhoto(event)"></label>' +
|
||
'<span id="photo-status" style="font-size:13px;color:var(--cds-text-secondary)">' + (pendingPhoto ? 'Photo attached ✓' : '') + '</span></div>' +
|
||
'<div style="margin-top:12px"><button class="fld-btn primary" onclick="addUpdate()">Add to log</button></div>' +
|
||
'</div>' +
|
||
'<div class="fld-sec"><h3>Field log</h3>' + log + '</div>';
|
||
}
|
||
|
||
// ── mutations (each auto-saves via the outbox; the global sync badge shows state) ──
|
||
function saveWP(p) {
|
||
var ix = WPS.findIndex(function (x) { return x.id === p.id; });
|
||
if (ix >= 0) WPS[ix] = p;
|
||
writeCache();
|
||
if (typeof ProjectData !== 'undefined' && ProjectData.pushWP) ProjectData.pushWP(p, PID);
|
||
}
|
||
function setStatus(s) {
|
||
var p = curWP(); if (!p) return;
|
||
if (GATED.indexOf(s) >= 0 && openCount(p) > 0) { toast('Clear all constraints before moving to ' + stLabel(s)); return; }
|
||
if (p.status === s) return;
|
||
p.status = s;
|
||
if (s === 'Issued' && !p.issuedAt) p.issuedAt = new Date().toISOString();
|
||
saveWP(p); renderDetail(); toast('Status: ' + stLabel(s));
|
||
}
|
||
function cycleConstraint(i) {
|
||
var p = curWP(); if (!p || !p.constraints || !p.constraints[i]) return;
|
||
var order = ['open', 'cleared', 'na'];
|
||
var cur = p.constraints[i].status || 'open';
|
||
p.constraints[i].status = order[(order.indexOf(cur) + 1) % 3];
|
||
saveWP(p); renderDetail();
|
||
}
|
||
function onPhoto(ev) {
|
||
var f = ev.target.files && ev.target.files[0]; if (!f) return;
|
||
var st = document.getElementById('photo-status'); if (st) st.textContent = 'Processing…';
|
||
var url = URL.createObjectURL(f);
|
||
var img = new Image();
|
||
img.onload = function () {
|
||
var max = 1280, w = img.width, h = img.height, scale = Math.min(1, max / Math.max(w, h));
|
||
var cv = document.createElement('canvas');
|
||
cv.width = Math.round(w * scale); cv.height = Math.round(h * scale);
|
||
cv.getContext('2d').drawImage(img, 0, 0, cv.width, cv.height);
|
||
try { pendingPhoto = cv.toDataURL('image/jpeg', 0.7); } catch (e) { pendingPhoto = ''; }
|
||
URL.revokeObjectURL(url);
|
||
if (st) st.textContent = pendingPhoto ? 'Photo attached ✓' : 'Could not read photo';
|
||
};
|
||
img.onerror = function () { URL.revokeObjectURL(url); if (st) st.textContent = 'Could not read photo'; };
|
||
img.src = url;
|
||
}
|
||
function addUpdate() {
|
||
var p = curWP(); if (!p) return;
|
||
var note = (draftNote || '').trim();
|
||
if (!note && !pendingPhoto) { toast('Add a note or photo first'); return; }
|
||
if (!p.fieldLog) p.fieldLog = [];
|
||
p.fieldLog.push({ ts: new Date().toISOString(), by: me(), note: note, photo: pendingPhoto || '', status: p.status });
|
||
pendingPhoto = ''; draftNote = '';
|
||
saveWP(p); renderDetail(); toast('Update added to log');
|
||
}
|
||
|
||
boot();
|