Files
Project-SDE-WP-Suite/html/field.js
n.siegfried 61d1cf4bff Wave 2: form cleanups from the site comments, plus localization, project switcher and global search
Site comments (8/3)
- BIM card: LOD removed, IFF # added next to the coordination status, and required
  once that status is "Signed off (IFF)" — an unnumbered sign-off isn't traceable.
  A LOD already stored on a package is preserved and shown as legacy, not blanked.
- The blue "from SOP types" subtext under a field is now a SOP chip on the label
  with the detail in a tooltip. The chip stays visible rather than hover-only:
  field tablets have no hover, and "this came from the SOP" is the part that
  matters. The hint elements stay in the DOM (hidden) so the code writing to them
  keeps working; an observer mirrors their text into the tooltip.
- Specification Section is no longer typed per package. Each WP type carries a
  spec section on the SOP; the field is read-only in the Creator and follows the
  type, with the SOP's spec folder linked underneath. This reads both spec
  comments as one intent — stop typing it, derive it.
- Assignees and Distribution are multi-selects over the SOP project team, showing
  each person's job function, with the CM pre-added to Distribution (removable per
  package) and a free-text option for people with no account. The stored display
  strings are unchanged so print/export/dashboard keep working; account ids ride
  alongside for the notification work in wave 3.

Localization + time
- Per-user locale/timezone (Language & time in the user menu), an app-wide default
  in the admin console, then the browser. Timezones are validated against the
  server's zoneinfo and the picker is fed from it. Calendar dates are formatted
  from their parts so a due date never reads a day early in another zone.
- Every displayed timestamp now goes through the shared helpers.

Top-bar chrome
- Project switcher beside the logo and a centered global search, injected into
  either generation of top bar; skipped in an iframe so the embedded Creator
  doesn't get a second one. Ctrl/Cmd-K focuses search.
- GET /api/search covers work packages, projects and SOPs, scoped to the caller's
  projects, hiding archived packages, with LIKE wildcards escaped.

Fixed along the way: showForm() cleared every card's inline display, which undid
applyKind() — so the Package Type and BIM cards reappeared on an install-only
project. Split out applyKindVisibility() and re-apply it there.

Verified: 100 API checks on a fresh database (44 permissions + 22 password reset +
34 search/localization), 24 driven UI checks against the real Creator page in
headless Chrome (SOP chips, both people pickers, spec auto-fill, critical tags,
BIM suppression), and the chrome harness on both bar styles. Screenshots reviewed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:17:51 -07:00

165 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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', 'QC', 'Closed', 'Issue'];
var GATED = ['Issued', 'In Progress', 'QC', 'Closed']; // need all constraints cleared to enter
function esc(s) { return s == null ? '' : String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
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; }
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 ''; } }
function toast(m) { var t = document.getElementById('toast'); if (!t) return; 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) {} }
if (ProjectData.get) { ProjectData.get(PID).then(function (p) { PROJECT = p; 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 cls = p.status === 'Issue' ? 'hold' : (open === 0 ? 'ready' : '');
var readyPill = p.status === 'Issue' ? '<span class="pill bad">On hold</span>' : (open ? '<span class="pill warn">' + open + ' open</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();