The field wants the specific PDF attached, not a link to a Bluebeam session.
Storage: a new wp_files table (Alembic f3a9d2c1e8b7, additive only) holding
the BYTES in the same database as everything else - the Aug 18 decision: a
backup that excludes the drawings is a backup you cannot restore from. The
D8 numbers bound the cost and are enforced ON THE SERVER as well as in the
browser: 5MB a file (413, naming the limit), PDF and image mimes only (400,
naming what is accepted), 2GB a project (413 naming the ceiling; response
flags the 80% warning). The ceiling is env-overridable for tests; the shipped
default is the decision, asserted from source.
The package record carries a server-owned meta mirror (data.files): the
upload/patch/delete routes rewrite it, and the upsert re-asserts the stored
copy over whatever a client sends - a save from a browser that had not seen
an upload land cannot erase the list.
Creator: uploads live beside the links (links still work), the limits and the
running project total sit ABOVE the picker (amber from 80%, red at full), a
refused file costs nothing but a toast and never leaves the browser (the
probe counts fetch calls), and each drawing has a description ("Tray section,
Level 3 east only") editable inline and persisted server-side. Uploads attach
to the saved record, so T4.3's autosave keeps the surrounding form safe (X8).
Export: uploads print with the package - name, size tag, description on the
attachments table, images inline as the sheet itself, PDFs as links.
Offline (D8): the service worker gains a drawings cache (cache-first on
/api/files/), and field.js prefetches ONLY the requesting user's assigned
packages - assignment-scoped by decision, not project-wide. The probe's first
offline check used CDP network emulation and PASSED FOR THE WRONG REASON: the
emulation binds to the page's session and the service worker fetches on its
own target, straight past it. The shipped check kills the server instead -
my drawing opens, the other package's does not, against a genuinely dead
network.
Field View: a Drawings section on the package detail, 44px rows, description
inline, inside the 390px screen.
Verification (each probe run alone): NEW tests/files_check.py 36/36; the
Alembic chain applied end-to-end to a scratch DB and the table verified.
Regressions: form_structure_check 50/51 (the standing F6 height gap),
frame_check 39/39.
Items: CR-007, D8 (X8 honored)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
213 lines
12 KiB
JavaScript
213 lines
12 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();
|
||
prefetchMyDrawings();
|
||
}).catch(function () {});
|
||
}
|
||
}
|
||
|
||
// CR-007/D8: warm the drawing cache for MY packages while there is a network.
|
||
// Deliberately only the requesting user's assignments - the decision was that
|
||
// offline coverage follows assignment, not the whole project's 2GB.
|
||
function prefetchMyDrawings() {
|
||
var myId = (window.WP_USER || {}).id;
|
||
if (!myId || !('serviceWorker' in navigator)) return;
|
||
WPS.filter(function (p) { return p.assigneeId === myId; }).forEach(function (p) {
|
||
((p.files) || []).forEach(function (f) {
|
||
if (f && f.id) fetch('/api/files/' + f.id).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>' +
|
||
(((p.files) || []).length ? '<div class="fld-sec"><h3>Drawings</h3>' +
|
||
p.files.map(function (f) {
|
||
return '<a class="fld-drawing" href="/api/files/' + esc(f.id) + '" target="_blank" rel="noopener">' +
|
||
'📄 ' + esc(f.name || 'drawing') + (f.description ? ' — ' + esc(f.description) : '') + '</a>';
|
||
}).join('') + '</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();
|