Productionize WP Suite: auth, security hardening, sync, dashboard, PWA, email

Brings the Work Package Suite from a browser-local prototype to a
multi-tenant, SQL-backed deployment hardened for customer IP.

Auth & access control
- Local username/password login (bcrypt + JWT in an HttpOnly cookie),
  admin-managed users, per-project membership, and project-scoped API access.
- Admin console: change user roles, view the audit trail, manage settings.

Security hardening
- CSP / HSTS / X-Frame-Options / nosniff headers in nginx; Secure cookie via
  X-Forwarded-Proto; CSRF Origin check; attribute-safe output escaping.
- Login lockout, token_version session revocation, stronger password policy,
  fail-closed secret loading, encrypted (AES-256) database backups.

Persistence & schema
- SOPs and Work Packages are now DB-backed and shared across users, written
  through a durable client sync outbox that queues offline edits.
- Alembic migrations applied automatically on container start.

New capabilities
- Phase 2 dashboard (progress, gating, pagination, archive).
- Phase 3 PWA "Field View" with offline caching and auth fallback.
- WP owner assignment with OPTIONAL email notifications, OFF by default and
  toggled from the admin console. SMTP password is read only from the
  SMTP_PASSWORD env var (never stored); emails carry a WP number + deep link,
  never customer IP.

Also: IBM Carbon restyle, Help section, and DEPLOYMENT.md brought up to date.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 17:51:15 -07:00
parent dd37f1f551
commit 39b48055ff
48 changed files with 2867 additions and 507 deletions

View File

@@ -6,18 +6,21 @@
<title>Admin Console — Work Package Suite</title>
<script src="auth-guard.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<style>
:root{ --bg:#f4f5f7; --surface:#fff; --border:#e3e6ec; --border-strong:#d0d5de; --text:#1a2230;
--muted:#5a6675; --dim:#9aa3b2; --accent:#2563d6; --green:#15924f; --green-bg:#e4f6ec;
--red:#cf3b3b; --red-bg:#fbeaea; --amber:#b87100; --amber-bg:#fdf2e0; --mono:'Cascadia Mono',Consolas,monospace; }
:root{ --bg:#f4f4f4; --surface:#fff; --border:#e0e0e0; --border-strong:#8d8d8d; --text:#161616;
--muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --green:#198038; --green-bg:#defbe6;
--red:#da1e28; --red-bg:#fff1f1; --amber:#8e6a00; --amber-bg:#fdf6dd; --mono:'IBM Plex Mono','Cascadia Mono',Consolas,monospace; }
*{ box-sizing:border-box; }
body{ margin:0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
body{ margin:0; font-family:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
.wrap{ max-width:860px; margin:0 auto; padding:28px 20px 80px; }
h1{ font-size:20px; margin:0 0 2px; }
.sub{ color:var(--muted); font-size:13px; margin-bottom:18px; }
.card{ background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:18px 20px; margin-bottom:16px; }
.card{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:18px 20px; margin-bottom:16px; }
.card h2{ font-size:14px; margin:0 0 12px; text-transform:uppercase; letter-spacing:.03em; color:var(--accent); }
button{ font:inherit; font-size:13px; font-weight:600; border-radius:6px; padding:8px 14px; cursor:pointer;
button{ font:inherit; font-size:13px; font-weight:600; border-radius:0; padding:8px 14px; cursor:pointer;
border:1px solid var(--border-strong); background:#fff; color:var(--text); }
button:hover{ border-color:var(--accent); color:var(--accent); }
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
@@ -25,10 +28,10 @@
button.danger{ border-color:var(--red); color:var(--red); }
button.danger:hover{ background:var(--red-bg); }
.row{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
.banner{ padding:10px 14px; border-radius:8px; font-size:13px; font-weight:600; margin-top:10px; border:1px solid var(--border); background:var(--surface); }
.banner{ padding:10px 14px; border-radius:0; font-size:13px; font-weight:600; margin-top:10px; border:1px solid var(--border); background:var(--surface); }
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:var(--green); }
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:var(--red); }
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:8px; padding:12px 14px; font-family:var(--mono);
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:0; padding:12px 14px; font-family:var(--mono);
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:12px 0 0; }
pre.out .p{ color:#56d364; font-weight:700; } pre.out .f{ color:#ff7b72; font-weight:700; }
table.kv{ border-collapse:collapse; font-size:13px; margin-top:8px; }
@@ -36,30 +39,41 @@
table.kv td{ padding:5px 0; font-variant-numeric:tabular-nums; font-weight:700; }
.note{ font-size:12px; color:var(--dim); margin-top:10px; }
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:20px; }
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:28px; max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:28px; max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
.gate-box h2{ margin:0 0 4px; font-size:17px; }
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 16px; }
.gate-box input{ width:100%; padding:10px 12px; font-size:14px; border:1px solid var(--border-strong); border-radius:6px; margin-bottom:12px; }
.gate-box input{ width:100%; padding:10px 12px; font-size:14px; border:1px solid var(--border-strong); border-radius:0; margin-bottom:12px; }
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:8px; }
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:8px; padding:9px 13px; font-size:12px; margin-bottom:16px; }
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:0; padding:9px 13px; font-size:12px; margin-bottom:16px; }
a.home{ color:var(--accent); font-size:13px; text-decoration:none; }
.urow{ display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
.urow input, .urow select{ padding:8px 10px; font:inherit; font-size:13px; border:1px solid var(--border-strong);
border-radius:6px; background:#fff; color:var(--text); }
border-radius:0; background:#fff; color:var(--text); }
.urow input{ flex:1; min-width:130px; }
table.users{ border-collapse:collapse; width:100%; font-size:13px; }
table.users th{ text-align:left; padding:7px 10px; color:var(--muted); font-weight:600; border-bottom:1px solid var(--border); white-space:nowrap; }
table.users td{ padding:7px 10px; border-bottom:1px solid var(--border); vertical-align:middle; }
table.users tr:last-child td{ border-bottom:none; }
.tag{ display:inline-block; padding:1px 9px; border-radius:11px; font-size:11px; font-weight:700; }
.tag.admin{ background:#e7effe; color:#1d4ed8; } .tag.user{ background:#eef1f6; color:#5a6675; }
.tag.admin{ background:#edf5ff; color:#0f62fe; } .tag.user{ background:#e8e8e8; color:#525252; }
.tag.on{ background:var(--green-bg); color:var(--green); } .tag.off{ background:var(--red-bg); color:var(--red); }
button.mini{ padding:4px 9px; font-size:12px; }
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; }
select.role-select{ padding:4px 8px; font:inherit; font-size:12px; border:1px solid var(--border-strong); border-radius:0; background:#fff; color:var(--text); cursor:pointer; }
select.role-select:hover{ border-color:var(--accent); }
select.role-select.is-admin{ color:var(--accent); border-color:var(--accent); font-weight:700; }
</style>
</head>
<body>
<!-- SHARED DARK APP BAR -->
<header class="wp-appbar">
<a href="index.html" class="wp-appbar-brand" title="Back to site">
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
<span class="wp-appbar-title">Work Package Suite <span class="wp-appbar-sub">| Admin Console</span></span>
</a>
</header>
<!-- ADMINS ONLY (shown if the signed-in account isn't an admin) -->
<div class="wrap" id="admin-denied" style="display:none">
<div class="card">
@@ -72,7 +86,7 @@
<!-- CONSOLE -->
<div class="wrap" id="admin-main" style="display:none">
<div class="row" style="justify-content:space-between">
<div><h1>Work Package Suite — Admin Console</h1><div class="sub">Stack diagnostics &amp; tests · talks to <code>/api</code> on this host</div></div>
<div><h1>Admin Console</h1><div class="sub">Stack diagnostics &amp; tests · talks to <code>/api</code> on this host</div></div>
<div class="row"><a class="home" href="index.html">← Site</a></div>
</div>
@@ -103,6 +117,14 @@
<div id="users-create-msg" class="note"></div>
</div>
<!-- NOTIFICATIONS / EMAIL -->
<div class="card">
<h2>Notifications &amp; email</h2>
<div class="sub" style="margin-bottom:10px">Email notifications for work-package assignments. <strong>Off by default</strong> — turn this on only once SMTP is configured. The SMTP <strong>password</strong> is read from the <code>SMTP_PASSWORD</code> environment variable and is never stored here.</div>
<div id="settings-box" class="note">Loading…</div>
<div id="notif-box" class="note" style="margin-top:14px"></div>
</div>
<!-- ALL FEEDBACK / COMMENTS -->
<div class="card">
<h2>All feedback &amp; comments</h2>
@@ -110,11 +132,29 @@
<div class="row">
<button onclick="loadComments()">Refresh comments</button>
<select id="cmt-filter" onchange="renderComments()"><option value="">All sources</option></select>
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:6px;">
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:0;">
</div>
<div id="comments-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
</div>
<!-- ACTIVITY LOG (AUDIT TRAIL) -->
<div class="card">
<h2>Activity log</h2>
<div class="sub" style="margin-bottom:10px">Who changed what, and when — across projects, SOPs, work packages, and user accounts. Stored server-side in the shared database.</div>
<div class="row">
<button onclick="loadAudit()">Refresh</button>
<select id="audit-type" onchange="renderAudit()">
<option value="">All types</option>
<option value="wp">Work packages</option>
<option value="sop">SOPs</option>
<option value="project">Projects</option>
<option value="user">User accounts</option>
</select>
<input id="audit-search" placeholder="Search actor / action / item…" oninput="renderAudit()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:0;">
</div>
<div id="audit-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
</div>
<!-- USAGE LOGS -->
<div class="card">
<h2>Usage logs</h2>

View File

@@ -11,7 +11,10 @@ function reveal(){
document.getElementById('admin-main').style.display='';
checkHealth();
loadUsers();
loadSettings();
loadNotifications();
loadComments();
loadAudit();
loadUsage();
}
function showDenied(){
@@ -189,11 +192,20 @@ function renderUsers(list, meId){
const delBtn = me
? ''
: '<button class="mini danger" onclick="deleteUser(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Delete</button>';
// Role can be changed at any time via an inline dropdown. Your own row is
// locked (a shown-as-tag) so an admin can't accidentally demote themselves.
const escUname = uesc(u.username).replace(/'/g,"\\'");
const roleCell = me
? '<span class="tag '+(u.role==='admin'?'admin':'user')+'">'+uesc(u.role)+'</span><span class="me-tag">locked</span>'
: '<select class="role-select'+(u.role==='admin'?' is-admin':'')+'" title="Change this users role" onchange="changeRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
'<option value="user"'+(u.role==='user'?' selected':'')+'>user</option>'+
'<option value="admin"'+(u.role==='admin'?' selected':'')+'>admin</option>'+
'</select>';
return '<tr>'+
'<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
'<td>'+uesc(u.full_name||'')+'</td>'+
'<td>'+uesc(u.email||'')+'</td>'+
'<td><span class="tag '+(u.role==='admin'?'admin':'user')+'">'+uesc(u.role)+'</span></td>'+
'<td>'+roleCell+'</td>'+
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
'<td style="white-space:nowrap"><div class="row" style="gap:6px">'+
@@ -244,6 +256,18 @@ async function toggleActive(id, makeActive){
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
}
// Change a user's role (user ↔ admin) at any time. The server enforces the same
// admin-only rule as every other user-management call, and refuses to remove the
// last admin. On any failure we reload so the dropdown snaps back to the truth.
async function changeRole(id, role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
if(status===200){ loadUsers(); }
else {
alert('Could not change role for '+username+': '+((json && json.detail)||('HTTP '+status)));
loadUsers();
}
}
async function deleteUser(id, username){
if(!confirm('Delete user "'+username+'"? This cannot be undone.')) return;
const { status, json } = await api('DELETE','/api/auth/users/'+id);
@@ -335,6 +359,118 @@ function renderComments(){
'</tr>').join('')+'</tbody></table>';
}
// ── activity log (audit trail) ──────────────────────────────────────────────────
let _audit = [];
async function loadAudit(){
const box = document.getElementById('audit-admin');
box.textContent = 'Loading…';
const { status, json } = await api('GET','/api/audit?limit=500');
if(status!==200 || !Array.isArray(json)){
box.innerHTML = '<div class="banner bad">Could not load activity (HTTP '+status+').</div>'; return;
}
_audit = json;
renderAudit();
}
function renderAudit(){
const box = document.getElementById('audit-admin');
const type = document.getElementById('audit-type').value;
const q = (document.getElementById('audit-search').value||'').toLowerCase();
let rows = _audit.filter(e => (!type || e.entity_type===type) &&
(!q || ((e.actor||'')+' '+(e.action||'')+' '+(e.summary||'')).toLowerCase().indexOf(q)>=0));
if(!rows.length){ box.innerHTML = '<div class="note">No activity'+((type||q)?' matches the filter.':' yet.')+'</div>'; return; }
const fmt = s => s ? new Date(s).toLocaleString() : '—';
const det = e => {
const d = e.detail || {};
if(d.from!=null || d.to!=null) return uesc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to));
return uesc(Object.keys(d).map(k=>k+': '+d[k]).join(', '));
};
box.innerHTML = '<table class="users"><thead><tr><th>When</th><th>Who</th><th>Action</th><th>Type</th><th>Item</th><th>Detail</th></tr></thead><tbody>'+
rows.map(e => '<tr>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(e.at)+'</td>'+
'<td><strong>'+uesc(e.actor||'—')+'</strong></td>'+
'<td>'+uesc((e.action||'').replace(/_/g,' '))+'</td>'+
'<td>'+uesc(e.entity_type||'')+'</td>'+
'<td>'+uesc(e.summary||e.entity_id||'')+'</td>'+
'<td style="color:var(--muted)">'+det(e)+'</td>'+
'</tr>').join('')+'</tbody></table>';
}
// ── notifications / email settings ──────────────────────────────────────────────
let _settings = {};
async function loadSettings(){
const box = document.getElementById('settings-box');
const { status, json } = await api('GET','/api/settings');
if(status!==200 || !json){ box.innerHTML = '<div class="banner bad">Could not load settings (HTTP '+status+').</div>'; return; }
_settings = json; renderSettings();
}
function renderSettings(){
const s = _settings, box = document.getElementById('settings-box');
const on = !!s.email_enabled;
const pwOk = !!s.smtp_password_set;
box.innerHTML =
'<label style="display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:700;margin-bottom:12px">'+
'<input type="checkbox" id="set-enabled"'+(on?' checked':'')+'> Email notifications are <span style="color:'+(on?'var(--green)':'var(--muted)')+'">'+(on?'ON':'OFF')+'</span></label>'+
'<div class="urow" style="margin-bottom:8px">'+
'<input id="set-host" placeholder="SMTP host (e.g. smtp.company.local)" value="'+uesc(s.smtp_host||'')+'">'+
'<input id="set-port" style="flex:0 0 90px;min-width:70px" placeholder="Port" value="'+uesc(s.smtp_port||587)+'">'+
'<label style="display:inline-flex;align-items:center;gap:6px;font-size:13px;white-space:nowrap"><input type="checkbox" id="set-tls"'+(s.smtp_use_tls?' checked':'')+'> STARTTLS</label>'+
'</div>'+
'<div class="urow" style="margin-bottom:8px">'+
'<input id="set-from" placeholder="From address (e.g. wp-suite@company.com)" value="'+uesc(s.from_addr||'')+'">'+
'<input id="set-fromname" placeholder="From name" value="'+uesc(s.from_name||'')+'">'+
'<input id="set-user" placeholder="SMTP username (optional)" value="'+uesc(s.smtp_username||'')+'">'+
'</div>'+
'<div class="urow" style="margin-bottom:8px">'+
'<input id="set-baseurl" placeholder="App base URL for email links (e.g. https://wp.controls.dev)" value="'+uesc(s.app_base_url||'')+'">'+
'</div>'+
'<div class="note" style="margin-bottom:10px">SMTP password: '+(pwOk?'<span style="color:var(--green);font-weight:600">set via SMTP_PASSWORD env ✓</span>':'<span style="color:var(--amber);font-weight:600">not set — add SMTP_PASSWORD to the environment before enabling</span>')+'</div>'+
'<div class="row">'+
'<button class="primary" onclick="saveSettings()">Save settings</button>'+
'<button onclick="testEmail()">Send test email to me</button>'+
'<span id="set-msg" class="note" style="margin:0"></span>'+
'</div>';
}
async function saveSettings(){
const v = id => document.getElementById(id);
const patch = {
email_enabled: v('set-enabled').checked,
smtp_host: v('set-host').value.trim(),
smtp_port: parseInt(v('set-port').value, 10) || 587,
smtp_use_tls: v('set-tls').checked,
from_addr: v('set-from').value.trim(),
from_name: v('set-fromname').value.trim(),
smtp_username: v('set-user').value.trim(),
app_base_url: v('set-baseurl').value.trim(),
};
const msg = v('set-msg'); msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)';
const { status, json } = await api('PUT','/api/settings', patch);
if(status===200){ _settings = json; renderSettings(); const m = document.getElementById('set-msg'); if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; } }
else { msg.textContent = 'Save failed (HTTP '+status+').'; msg.style.color = 'var(--red)'; }
}
async function testEmail(){
const msg = document.getElementById('set-msg'); msg.textContent = 'Sending test…'; msg.style.color = 'var(--muted)';
const { status, json } = await api('POST','/api/settings/test-email', {});
if(status===200) { msg.textContent = '✅ Test sent to '+((json&&json.to)||'you')+'.'; msg.style.color = 'var(--green)'; }
else { msg.textContent = '❌ '+((json && json.detail) || ('HTTP '+status)); msg.style.color = 'var(--red)'; }
}
async function loadNotifications(){
const box = document.getElementById('notif-box'); if(!box) return;
const { status, json } = await api('GET','/api/notifications?all=1&limit=50');
if(status!==200 || !Array.isArray(json)){ box.innerHTML = ''; return; }
if(!json.length){ box.innerHTML = '<div class="note">No notifications yet.</div>'; return; }
const fmt = s => s ? new Date(s).toLocaleString() : '—';
const stColor = st => st==='sent'?'var(--green)':st==='failed'?'var(--red)':st==='skipped'?'var(--muted)':'var(--amber)';
box.innerHTML = '<div class="sub" style="margin:4px 0 6px;color:var(--muted)">Recent notifications</div>'+
'<table class="users"><thead><tr><th>When</th><th>To</th><th>Kind</th><th>Subject</th><th>Status</th></tr></thead><tbody>'+
json.map(n => '<tr>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(n.created_at)+'</td>'+
'<td>'+uesc(n.email||n.user_id)+'</td>'+
'<td>'+uesc((n.kind||'').replace(/_/g,' '))+'</td>'+
'<td>'+uesc(n.subject||'')+'</td>'+
'<td style="color:'+stColor(n.status)+';font-weight:600">'+uesc(n.status)+(n.error?' <span title="'+uesc(n.error)+'">ⓘ</span>':'')+'</td>'+
'</tr>').join('')+'</tbody></table>';
}
// ── usage logs (read from this browser's localStorage) ──────────────────────────
const USAGE_KEY = 'wp_suite_analytics_v1';
function usageLoad(){ try { return JSON.parse(localStorage.getItem(USAGE_KEY)) || {events:[]}; } catch(e){ return {events:[]}; } }

View File

@@ -14,6 +14,12 @@
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
// Register the PWA service worker (caches the app shell for offline use). Only
// from the top window; the API and writes are never cached (see sw.js).
if (!inIframe && 'serviceWorker' in navigator) {
try { navigator.serviceWorker.register('/sw.js'); } catch (e) {}
}
// Hide the page until we know the user is allowed, to avoid a flash of the app
// before a redirect. A safety timer reveals it even if the check hangs.
var root = document.documentElement;
@@ -34,6 +40,19 @@
}
window.wpLogout = function () {
try {
// Clear the auth cache AND all cached project data (customer IP) from this
// device on sign-out — important on shared/field tablets. The outbox
// (wp_sync_outbox_v1) is left intact so unsynced writes aren't lost.
// (localStorage is not a security boundary; field devices still need
// full-disk encryption / MDM — see DEPLOYMENT.md.)
localStorage.removeItem('wp_auth_cache');
Object.keys(localStorage).forEach(function (k) {
if (/^wp_(iwp_v1|suite_sop|suite_state|projects|active_project)/.test(k)) {
localStorage.removeItem(k);
}
});
} catch (e) {}
fetch('/api/auth/logout', { method: 'POST' })
.catch(function () {})
.then(function () { window.location.replace('login.html'); });
@@ -96,58 +115,99 @@
};
};
function isDarkBg(el) {
try {
var m = (getComputedStyle(el).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
if (!m) return true;
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
} catch (e) { return true; }
}
// The user menu (name · Admin · Password · Sign out). Text colors adapt to the
// bar it sits in (light links on a dark bar, blue links on a light bar).
function buildUserMenu(user, dark) {
var wrap = document.createElement('div');
wrap.id = 'wp-usermenu';
var linkColor = dark ? '#ffffff' : '#0f62fe';
wrap.style.cssText = 'display:flex;align-items:center;gap:8px;margin-left:auto;padding-left:14px;white-space:nowrap;' +
'font:400 13px/1.2 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
'color:' + (dark ? '#c6c6c6' : '#525252') + ';';
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = dark ? '#6f6f6f' : '#a8a8a8'; return s; }
function link(text, onClick, href) {
var a = document.createElement('a'); a.textContent = text; a.href = href || '#';
a.style.cssText = 'color:' + linkColor + ';text-decoration:none;font-weight:600;';
if (onClick) a.addEventListener('click', function (e) { e.preventDefault(); onClick(); });
return a;
}
var who = document.createElement('span');
who.textContent = user.full_name || user.username;
who.style.color = dark ? '#ffffff' : '#161616';
wrap.appendChild(who);
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
if (user.role === 'admin' && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
wrap.appendChild(sep()); wrap.appendChild(link('Password', function () { window.wpChangePassword(); }));
wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); }));
return wrap;
}
function addLogoutPill(user) {
if (inIframe) return; // the parent page already shows it
if (document.getElementById('wp-logout-pill')) return;
if (document.getElementById('wp-usermenu') || document.getElementById('wp-logout-pill')) return;
// Preferred: drop the menu INTO the top bar so it never floats over the
// header's own links (Help, etc.). Works with the dark UI-shell appbar and
// the older .header bars alike.
var host = document.querySelector('.wp-appbar') || document.querySelector('.header');
if (host) {
var menu = buildUserMenu(user, isDarkBg(host));
// The older .header bars already right-align their own toolbar (via flex:1
// or a button's margin-left:auto). A second auto-margin would split the free
// space, so only the .wp-appbar (which may have no spacer, e.g. admin) keeps it.
if (!host.classList.contains('wp-appbar')) menu.style.marginLeft = '0';
host.appendChild(menu);
return;
}
// Fallback for any page with no header bar: a floating pill (as before).
var pill = document.createElement('div');
pill.id = 'wp-logout-pill';
pill.style.cssText = 'position:fixed;top:12px;right:12px;z-index:10001;' +
'display:flex;align-items:center;gap:8px;background:#fff;border:1px solid #e0e0e0;' +
'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;' +
'font:500 12px/1.2 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#525252;';
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = '#a8a8a8'; return s; }
var who = document.createElement('span');
who.textContent = user.full_name || user.username;
pill.appendChild(who);
// Admins get a link to the Admin Console (hidden when already on it).
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
if (user.role === 'admin' && !onAdmin) {
var adm = document.createElement('a');
adm.href = 'admin.html'; adm.textContent = 'Admin';
adm.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
pill.appendChild(sep()); pill.appendChild(adm);
}
var pw = document.createElement('a');
pw.href = '#'; pw.textContent = 'Password';
pw.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
pw.addEventListener('click', function (e) { e.preventDefault(); window.wpChangePassword(); });
pill.appendChild(sep()); pill.appendChild(pw);
var out = document.createElement('a');
out.href = '#'; out.textContent = 'Sign out';
out.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
out.addEventListener('click', function (e) { e.preventDefault(); window.wpLogout(); });
pill.appendChild(sep()); pill.appendChild(out);
'display:flex;align-items:center;background:#fff;border:1px solid #e0e0e0;' +
'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;';
pill.appendChild(buildUserMenu(user, false));
document.body.appendChild(pill);
}
function proceed(user) {
clearTimeout(safety);
window.WP_USER = user;
reveal();
if (window.WP_USER) {
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
if (document.body) addLogoutPill(window.WP_USER);
else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); });
}
}
fetch('/api/auth/me', { headers: { 'Accept': 'application/json' } })
.then(function (r) {
if (r.status === 401 || r.status === 403) { goToLogin(); return; }
if (r.status === 401 || r.status === 403) { try { localStorage.removeItem('wp_auth_cache'); } catch (e) {} goToLogin(); return; }
if (!r.ok) { reveal(); clearTimeout(safety); return; } // unexpected; show page rather than trap
return r.json().then(function (data) {
clearTimeout(safety);
window.WP_USER = data && data.user;
reveal();
if (window.WP_USER) {
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
if (document.body) addLogoutPill(window.WP_USER);
else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); });
}
var user = data && data.user;
// Remember the last good auth so the PWA can open offline. The server is
// still the real gate; offline writes queue in the outbox until reconnect.
try { if (user) localStorage.setItem('wp_auth_cache', JSON.stringify({ user: user, at: Date.now() })); } catch (e) {}
proceed(user);
});
})
.catch(function () { goToLogin(); }); // API unreachable → send to login
.catch(function () {
// Offline / API unreachable: fall back to a recent cached auth if present,
// so the app (and the field view) still open without a network.
try {
var c = JSON.parse(localStorage.getItem('wp_auth_cache') || 'null');
if (c && c.user && (Date.now() - (c.at || 0)) < 12 * 3600 * 1000) { proceed(c.user); return; }
} catch (e) {}
goToLogin();
});
})();

86
html/field.html Normal file
View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Field View — Work Package Suite</title>
<script src="auth-guard.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<style>
* { box-sizing: border-box; }
body { -webkit-text-size-adjust: 100%; }
.field-wrap { max-width: 760px; margin: 0 auto; padding: 16px 16px 40px; }
.fld-ctx { font-size: 13px; color: var(--cds-text-secondary); margin-bottom: 12px; }
.fld-ctx b { color: var(--cds-text-primary); }
.fld-search { width: 100%; padding: 14px; font-size: 16px; border: 1px solid var(--cds-border-strong); background: #fff; margin-bottom: 14px; }
.fld-search:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
.wp-card { display: block; width: 100%; text-align: left; background: var(--cds-layer); border: 1px solid var(--cds-border-subtle); border-left: 4px solid var(--cds-border-strong); padding: 14px 16px; margin-bottom: 10px; cursor: pointer; font-family: inherit; }
.wp-card:active { background: var(--cds-layer-hover); }
.wp-card.ready { border-left-color: var(--cds-support-success); }
.wp-card.hold { border-left-color: var(--cds-support-error); }
.wp-card .num { font-weight: 600; font-size: 16px; color: var(--cds-text-primary); }
.wp-card .subj { color: var(--cds-text-secondary); font-size: 13px; margin-top: 2px; }
.wp-card .meta { margin-top: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.pill { display: inline-block; font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 14px; }
.pill.st { background: var(--cds-layer-accent); color: var(--cds-text-secondary); }
.pill.ok { background: #defbe6; color: #0e6027; }
.pill.warn { background: #fdf6dd; color: #8a6d00; }
.pill.bad { background: #fff1f1; color: #da1e28; }
.fld-empty { padding: 32px; text-align: center; color: var(--cds-text-helper); border: 1px dashed var(--cds-border-strong); background: #fff; }
.fld-empty a { color: var(--cds-link-primary); }
.fld-back { background: none; border: none; color: var(--cds-link-primary); font-size: 15px; padding: 8px 0; cursor: pointer; font-family: inherit; }
.fld-h1 { font-size: 20px; font-weight: 600; margin: 4px 0 2px; }
.fld-sub { color: var(--cds-text-secondary); font-size: 14px; margin-bottom: 16px; }
.fld-sec { background: var(--cds-layer); border: 1px solid var(--cds-border-subtle); padding: 14px 16px; margin-bottom: 14px; }
.fld-sec h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--cds-text-helper); margin-bottom: 10px; }
.st-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; }
.st-btn { padding: 14px 10px; font-size: 15px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; color: var(--cds-text-secondary); cursor: pointer; font-family: inherit; }
.st-btn.on { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); color: #fff; }
.st-btn.hold.on { background: var(--cds-support-error); border-color: var(--cds-support-error); }
.cx-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--cds-border-subtle); }
.cx-row:last-child { border-bottom: none; }
.cx-name { flex: 1; font-size: 15px; }
.cx-state { min-width: 96px; padding: 10px 12px; font-size: 14px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; cursor: pointer; text-align: center; font-family: inherit; }
.cx-state.cleared { background: #defbe6; color: #0e6027; border-color: #a7f0ba; }
.cx-state.na { background: var(--cds-layer-accent); color: var(--cds-text-secondary); }
.cx-state.open { background: #fff1f1; color: #da1e28; border-color: #ffd7d9; }
.fld-note { width: 100%; padding: 12px; font-size: 16px; border: 1px solid var(--cds-border-strong); min-height: 84px; font-family: inherit; resize: vertical; }
.fld-photo-row { display: flex; gap: 10px; align-items: center; margin-top: 10px; flex-wrap: wrap; }
.fld-btn { padding: 12px 18px; font-size: 15px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; cursor: pointer; font-family: inherit; }
.fld-btn.primary { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); color: #fff; }
.log-item { border: 1px solid var(--cds-border-subtle); padding: 10px 12px; margin-bottom: 8px; font-size: 14px; color: var(--cds-text-primary); white-space: pre-wrap; }
.log-item .lm { color: var(--cds-text-helper); font-size: 11px; margin-bottom: 4px; }
.log-item img { max-width: 160px; max-height: 120px; margin-top: 6px; display: block; border: 1px solid var(--cds-border-subtle); }
.fld-toast { position: fixed; bottom: 76px; left: 50%; transform: translateX(-50%); background: #161616; color: #fff; padding: 12px 20px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 50; }
.fld-toast.show { opacity: 1; }
</style>
</head>
<body>
<header class="wp-appbar">
<a href="index.html" class="wp-appbar-brand" title="Home">
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
<span class="wp-appbar-title">Field View</span>
</a>
<div class="wp-appbar-spacer"></div>
<a class="wp-appbar-link" href="index.html">Home</a>
</header>
<div class="field-wrap">
<div class="fld-ctx" id="fld-ctx"></div>
<section id="screen-list">
<input class="fld-search" id="fld-search" type="search" placeholder="Search work packages…" oninput="renderList()" aria-label="Search work packages">
<div id="wp-list"></div>
</section>
<section id="screen-detail" style="display:none"></section>
</div>
<div id="toast" class="fld-toast"></div>
<script src="project-data.js"></script>
<script src="help.js"></script>
<script src="field.js"></script>
</body>
</html>

164
html/field.js Normal file
View File

@@ -0,0 +1,164 @@
/* 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 new Date(s).toLocaleString(); } 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();

View File

@@ -15,64 +15,64 @@
// ── styles ────────────────────────────────────────────────────────────────
var css = `
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px;
margin-left:5px; border-radius:50%; background:#5a6675; color:#fff; font-size:10px; font-weight:700;
margin-left:5px; border-radius:50%; background:#525252; color:#fff; font-size:10px; font-weight:700;
font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; }
.help-tip::after{ content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%);
background:#1a2230; color:#fff; padding:7px 10px; border-radius:6px; font-size:12px; font-weight:400;
background:#161616; color:#fff; padding:7px 10px; border-radius:0; font-size:12px; font-weight:400;
line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999;
opacity:0; pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); }
.help-tip::before{ content:''; position:absolute; bottom:130%; left:50%; transform:translate(-50%,95%);
border:5px solid transparent; border-top-color:#1a2230; opacity:0; transition:opacity .12s; z-index:9999; }
border:5px solid transparent; border-top-color:#161616; opacity:0; transition:opacity .12s; z-index:9999; }
.help-tip:hover::after, .help-tip:hover::before, .help-tip:focus::after, .help-tip:focus::before{ opacity:1; }
.ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:center;
justify-content:center; z-index:10000; padding:4vh 16px; }
.ui-help-overlay.open{ display:flex; }
.ui-help-modal{ background:#fff; color:#1a2230; max-width:980px; width:100%; height:88vh; max-height:880px;
border-radius:10px; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden;
.ui-help-modal{ background:#fff; color:#161616; max-width:980px; width:100%; height:88vh; max-height:880px;
border-radius:0; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden;
font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; }
.ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e3e6ec; flex:none; }
.ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e0e0e0; flex:none; }
.ui-help-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; }
.ui-help-search{ flex:1; position:relative; max-width:420px; }
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #d0d5de; border-radius:7px;
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #8d8d8d; border-radius:0;
font-size:13px; outline:none; background:#f7f8fa; }
.ui-help-search input:focus{ border-color:#2563d6; background:#fff; box-shadow:0 0 0 2px rgba(37,99,214,.15); }
.ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:#5a6675; line-height:1; }
.ui-help-search input:focus{ border-color:#0f62fe; background:#fff; box-shadow:0 0 0 2px rgba(37,99,214,.15); }
.ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:#525252; line-height:1; }
.ui-help-wrap{ display:flex; flex:1; min-height:0; }
.ui-help-nav{ width:230px; flex:none; border-right:1px solid #e3e6ec; overflow:auto; padding:10px 8px; background:#fafbfc; }
.ui-help-nav a{ display:block; padding:7px 10px; border-radius:6px; color:#27313f; text-decoration:none; font-size:13px;
.ui-help-nav{ width:230px; flex:none; border-right:1px solid #e0e0e0; overflow:auto; padding:10px 8px; background:#fafbfc; }
.ui-help-nav a{ display:block; padding:7px 10px; border-radius:0; color:#27313f; text-decoration:none; font-size:13px;
cursor:pointer; margin-bottom:1px; }
.ui-help-nav a:hover{ background:#eef1f6; }
.ui-help-nav a.active{ background:#e7effe; color:#1d4ed8; font-weight:600; }
.ui-help-nav a.active{ background:#edf5ff; color:#0353e9; font-weight:600; }
.ui-help-nav a.nohit{ display:none; }
.ui-help-content{ flex:1; overflow:auto; padding:22px 28px; scroll-behavior:smooth; }
.ui-help-sec{ margin-bottom:30px; }
.ui-help-sec.hide{ display:none; }
.ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:#16213a; scroll-margin-top:10px; }
.ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#2563d6; }
.ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:#161616; scroll-margin-top:10px; }
.ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#0f62fe; }
.ui-help-content p{ font-size:13.5px; line-height:1.62; margin:0 0 9px; color:#27313f; }
.ui-help-content ol, .ui-help-content ul{ margin:0 0 10px; padding-left:20px; font-size:13.5px; line-height:1.6; }
.ui-help-content li{ margin-bottom:5px; }
.ui-help-content code{ background:#eef1f6; padding:1px 5px; border-radius:4px; font-size:12px; }
.ui-help-content table{ border-collapse:collapse; width:100%; font-size:12.5px; margin:6px 0 12px; }
.ui-help-content th, .ui-help-content td{ border:1px solid #e3e6ec; padding:6px 9px; text-align:left; vertical-align:top; }
.ui-help-content th, .ui-help-content td{ border:1px solid #e0e0e0; padding:6px 9px; text-align:left; vertical-align:top; }
.ui-help-content th{ background:#f4f6f9; font-weight:600; }
.ui-help-pill{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600; }
.pill-draft{ background:#eef1f6; color:#5a6675; } .pill-sched{ background:#e7effe; color:#1d4ed8; }
.pill-draft{ background:#eef1f6; color:#525252; } .pill-sched{ background:#edf5ff; color:#0353e9; }
.pill-prog{ background:#fef3e0; color:#b45309; } .pill-issued{ background:#e4f6ec; color:#15924f; }
.pill-qc{ background:#f3e8ff; color:#7c3aed; } .pill-closed{ background:#e2e8f0; color:#334155; }
.pill-hold{ background:#fde8e8; color:#c0392b; }
.ui-help-callout{ background:#f4f8ff; border-left:3px solid #2563d6; padding:10px 14px; border-radius:0 6px 6px 0;
.ui-help-callout{ background:#f4f8ff; border-left:3px solid #0f62fe; padding:10px 14px; border-radius:0;
font-size:13px; line-height:1.55; margin:10px 0; }
.ui-help-noresult{ display:none; color:#5a6675; font-size:14px; padding:10px 2px; }
.ui-help-noresult{ display:none; color:#525252; font-size:14px; padding:10px 2px; }
.ui-help-content mark{ background:#fff1a8; color:inherit; border-radius:2px; padding:0 1px; }
.ui-help-fab{ position:fixed; bottom:12px; left:12px; z-index:9998; width:38px; height:38px; border-radius:50%;
border:none; background:#2563d6; color:#fff; font-size:18px; font-weight:700; cursor:pointer;
border:none; background:#0f62fe; color:#fff; font-size:18px; font-weight:700; cursor:pointer;
box-shadow:0 2px 10px rgba(20,30,50,.28); }
.ui-help-fab:hover{ background:#1d4ed8; }
.ui-help-fab:hover{ background:#0353e9; }
@media (max-width:760px){
.ui-help-modal{ height:92vh; } .ui-help-wrap{ flex-direction:column; }
.ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid #e3e6ec; }
.ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid #e0e0e0; }
.ui-help-nav a{ margin:0; font-size:12px; padding:5px 9px; }
.ui-help-head{ flex-wrap:wrap; }
}`;
@@ -94,6 +94,13 @@
</ol>
<h4>Moving around</h4>
<p>From the home page, open <strong>SOP Configuration</strong>, the <strong>Work Package Creator</strong>, or the <strong>Dashboard</strong>. Inside the suite, switch any time using the top tabs: <strong>⚙️ SOP Configuration</strong>, <strong>📋 Work Package Creation</strong>, and <strong>📊 Dashboard</strong>. The active project and SOP follow you across all of them.</p>
<h4>Quick start</h4>
<ol>
<li><strong>Open “SOP Configuration”</strong> and complete the 10 steps for your project (~15 minutes).</li>
<li><strong>Finish the SOP</strong> — its home-page card turns green and unlocks the Work Package Creator.</li>
<li><strong>Open “Work Package Creation”</strong> to author packages with your SOP defaults pre-populated.</li>
<li><strong>Update from the field</strong> using the <strong>Field View</strong>, and <strong>leave feedback</strong> on any page with the Feedback button.</li>
</ol>
<div class="ui-help-callout">New here? On the home page choose the <strong>Sample Project</strong>, then click <strong>⭐ Load Sample</strong> in the suite to see a fully filled-out SOP and an example Work Package.</div>` },
{ id: 'projects', title: 'Projects', body: `

BIN
html/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
html/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -6,124 +6,67 @@
<title>Work Package Suite — Prime Controls</title>
<script src="auth-guard.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--cds-background);
color: var(--cds-text-primary);
line-height: 1.5;
}
/* HEADER */
.header {
background: var(--cds-layer);
padding: 1.5rem 2rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
border-bottom: 1px solid var(--cds-border-subtle);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 1.5rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 700;
font-size: 16px;
text-decoration: none;
color: var(--cds-text-primary);
background: white;
padding: 0.5rem 0.75rem;
border-radius: 6px;
}
.logo img {
height: 32px;
width: auto;
}
.logo:hover { opacity: 0.9; }
.header-spacer { flex: 1; }
.header-nav {
display: flex;
gap: 1.5rem;
align-items: center;
}
.header-nav a {
color: var(--cds-text-secondary);
text-decoration: none;
font-size: 13px;
transition: color 0.2s;
}
.header-nav a:hover { color: var(--cds-text-primary); }
/* CONTAINER */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 3rem 2rem;
padding: 2.5rem 2rem 3rem;
}
/* HERO */
.hero {
text-align: center;
margin-bottom: 4rem;
margin-bottom: 2.5rem;
}
.hero h1 {
font-size: 2.625rem;
font-size: 2.25rem;
font-weight: 300;
margin-bottom: 1rem;
letter-spacing: -0.01em;
margin-bottom: 0.5rem;
color: var(--cds-text-primary);
}
.hero p {
font-size: 1.125rem;
font-size: 1rem;
color: var(--cds-text-secondary);
margin-bottom: 2rem;
max-width: 700px;
margin-left: auto;
margin-right: auto;
max-width: 760px;
}
/* CARDS */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.card {
background: var(--cds-layer);
border: 1px solid var(--cds-border-subtle);
border-radius: 4px;
border-left: 4px solid var(--cds-border-strong);
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
transition: all 0.2s;
transition: border-color 0.15s, background 0.15s;
text-decoration: none;
color: var(--cds-text-primary);
display: flex;
flex-direction: column;
}
.card:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.4);
transform: translateY(-2px);
border-color: var(--cds-button-primary);
border-left-color: var(--cds-interactive-01);
background: var(--cds-layer-hover);
}
.card-badge {
@@ -153,10 +96,10 @@
.card-button {
display: inline-block;
align-self: flex-start;
background: var(--cds-button-primary);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 3px;
padding: 0.7rem 1.25rem;
text-decoration: none;
font-weight: 600;
text-align: center;
@@ -165,23 +108,22 @@
cursor: pointer;
font-size: 13px;
}
.card-button:hover {
background: var(--cds-hover-primary);
}
/* COMPLETE STATE (SOP done) */
.card.complete {
background: #ecfdf5;
border-color: #16a34a;
border-left-color: var(--cds-support-success);
}
.card.complete .card-button { background: #16a34a; }
.card.complete .card-button:hover { background: #15803d; }
.card.complete .card-button { background: var(--cds-support-success); }
.card.complete .card-button:hover { background: #0e6027; }
.card-status {
display: inline-block;
font-size: 12px;
font-weight: 600;
color: #16a34a;
color: var(--cds-support-success);
margin-bottom: 0.5rem;
}
.card.disabled {
@@ -192,9 +134,8 @@
/* SECTION */
.section {
background: var(--cds-layer);
border-radius: 4px;
padding: 2rem;
margin-bottom: 2rem;
padding: 1.75rem;
margin-bottom: 1.5rem;
border: 1px solid var(--cds-border-subtle);
}
@@ -217,16 +158,6 @@
font-size: 0.95rem;
}
.quick-start {
background: var(--cds-button-primary);
color: white;
padding: 2rem;
}
.quick-start h2 { color: white; }
.quick-start ol { margin-left: 1.5rem; line-height: 2; }
.quick-start li { margin-bottom: 0.5rem; }
/* FOOTER */
.footer {
background: var(--cds-ui-01);
@@ -247,94 +178,93 @@
/* COMMENTS SECTION */
.comments-section {
background: var(--cds-layer);
border-radius: 4px;
padding: 1.5rem;
margin-bottom: 2rem;
margin-bottom: 1.5rem;
border: 1px solid var(--cds-border-subtle);
}
.comments-toggle {
padding: 0.75rem 1.5rem;
padding: 0.7rem 1.25rem;
background: var(--cds-button-primary);
color: white;
border: none;
border-radius: 3px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.comments-toggle:hover { background: var(--cds-hover-primary); }
.comments-panel {
display: none;
margin-top: 1rem;
padding: 1rem;
background: var(--cds-ui-01);
border-radius: 3px;
background: var(--cds-layer-accent);
border: 1px solid var(--cds-border-subtle);
}
.comments-panel.open { display: block; }
.comments-panel input,
.comments-panel textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--cds-border-subtle);
border-radius: 3px;
background: var(--cds-ui-02);
padding: 0.7rem;
border: 1px solid var(--cds-border-strong);
background: var(--cds-field);
color: var(--cds-text-primary);
font-family: inherit;
margin-bottom: 1rem;
}
.comments-panel input:focus,
.comments-panel textarea:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
.comments-panel textarea {
resize: vertical;
min-height: 80px;
}
.comment-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.comment-buttons button {
padding: 0.5rem 1rem;
border: none;
border-radius: 3px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.submit-btn {
background: var(--cds-button-primary);
color: white;
}
.submit-btn:hover { background: var(--cds-hover-primary); }
.close-btn {
background: var(--cds-border-subtle);
background: var(--cds-layer-selected);
color: var(--cds-text-primary);
border: 1px solid var(--cds-border-strong);
}
.close-btn:hover { background: var(--cds-hover-ui); }
.close-btn:hover { background: var(--cds-layer-selected-hover); }
.comments-list {
margin-top: 1rem;
max-height: 300px;
overflow-y: auto;
}
.comment-item {
padding: 0.75rem;
background: var(--cds-background);
border: 1px solid var(--cds-border-subtle);
border-radius: 3px;
margin-bottom: 0.5rem;
font-size: 12px;
}
@@ -353,22 +283,20 @@
.proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; }
.proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
.proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px;
border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; background: #fff; }
border: 1px solid var(--cds-border-strong, #8d8d8d); background: #fff; }
.proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d);
border-radius: 6px; padding: 1.25rem; }
padding: 1.25rem; }
.proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); }
.proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); border-radius: 6px; background: var(--cds-ui-01, #fff); }
.proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); background: var(--cds-ui-01, #fff); }
.proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; }
.proj-form-grid label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); }
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; }
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); }
.proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); }
.link-like { background: none; border: none; color: var(--cds-link-01, #0f62fe); cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; }
/* RESPONSIVE */
@media (max-width: 768px) {
.header-content { flex-direction: column; text-align: center; }
.header-spacer { display: none; }
.hero h1 { font-size: 1.75rem; }
.cards-grid { grid-template-columns: 1fr; }
.container { padding: 1.5rem; }
@@ -377,19 +305,17 @@
</head>
<body>
<!-- HEADER -->
<header class="header">
<div class="header-content">
<a href="index.html" class="logo">
<img src="prime-controls-logo.jpg" alt="Prime Controls">
<div>Work Package Suite</div>
</a>
<div class="header-spacer"></div>
<nav class="header-nav">
<a href="#overview">Overview</a>
<a href="#comments">Feedback</a>
<a href="#" onclick="openHelp();return false;">Help</a>
</nav>
</div>
<header class="wp-appbar">
<a href="index.html" class="wp-appbar-brand" title="Work Package Suite home">
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
<span class="wp-appbar-title">Work Package Suite</span>
</a>
<div class="wp-appbar-spacer"></div>
<nav class="wp-appbar-actions">
<a class="wp-appbar-link" href="#overview">Overview</a>
<a class="wp-appbar-link" href="#comments">Feedback</a>
<a class="wp-appbar-link" href="#" onclick="openHelp();return false;">Help</a>
</nav>
</header>
<!-- MAIN CONTENT -->
@@ -432,17 +358,13 @@
<button class="card-button" id="card-dash-btn">Open Dashboard</button>
</a>
</div>
<!-- FIELD VIEW -->
<a href="field.html" class="card" id="card-field">
<h3>Field View</h3>
<p>A phone-friendly view for the work face — update status, clear constraints, and log photos and notes. Installable to a home screen; works offline and syncs when you're back on network.</p>
<button class="card-button" id="card-field-btn">Open Field View</button>
</a>
<!-- QUICK START -->
<div class="section quick-start">
<h2>Getting Started</h2>
<ol>
<li><strong>Open "SOP Configuration"</strong> and complete the 10 steps for your project (~15 minutes)</li>
<li><strong>Finish the SOP</strong> — this card turns green and unlocks the Work Package Creator</li>
<li><strong>Open "Work Package Creator"</strong> to author Work Packages with your SOP defaults pre-populated</li>
<li><strong>Leave feedback</strong> on any page using the feedback button below</li>
</ol>
</div>
<!-- COMMENTS SECTION -->
@@ -588,6 +510,7 @@
setHref('card-sop', 'work-package-suite.html?tab=sop');
setHref('card-wp', 'work-package-suite.html?tab=wp');
setHref('card-dash', 'work-package-suite.html?view=dashboard');
setHref('card-field', 'field.html?src=home');
cards.style.display = '';
heroTitle.textContent = active.name || 'Work Package Suite';

View File

@@ -21,7 +21,7 @@
max-width: 400px;
background: var(--cds-layer);
border: 1px solid var(--cds-border-subtle);
box-shadow: 0 2px 6px var(--cds-shadow);
border-top: 3px solid var(--cds-interactive-01);
padding: 2.5rem 2rem;
}
.brand {
@@ -97,7 +97,7 @@
<p style="margin-top:1.25rem; text-align:center; font-size:0.8125rem;">
<a href="#" id="forgot-link" style="color:var(--cds-link-primary); text-decoration:none;">Forgot password?</a>
</p>
<div id="forgot-msg" style="display:none; margin-top:0.5rem; font-size:0.8125rem; color:var(--cds-text-secondary); background:var(--cds-layer-accent); border-left:3px solid var(--cds-link-primary); padding:0.75rem; border-radius:0 6px 6px 0;">
<div id="forgot-msg" style="display:none; margin-top:0.5rem; font-size:0.8125rem; color:var(--cds-text-secondary); background:var(--cds-layer-accent); border-left:3px solid var(--cds-link-primary); padding:0.75rem;">
Password resets are handled by an administrator. Contact your project admin and they'll set a new one for you. Once you're signed in, you can change it yourself anytime from the menu in the top-right corner.
</div>

18
html/manifest.webmanifest Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "Prime Work Package Suite",
"short_name": "WP Suite",
"description": "Prime Controls Work Package Suite — SOPs, work packages, and field updates.",
"start_url": "/index.html",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#f4f4f4",
"theme_color": "#161616",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
],
"shortcuts": [
{ "name": "Field View", "short_name": "Field", "url": "/field.html", "description": "Update work packages from the field" }
]
}

View File

@@ -12,7 +12,7 @@
var LS_ACTIVE_OBJ = 'wp_active_project_obj';
function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } }
function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} }
@@ -107,6 +107,7 @@
subject: p.subject || '',
type: p.type || '',
status: p.status || 'Draft',
assignee_id: p.assigneeId || null,
created_by: p.createdBy || currentUser(),
data: p
};
@@ -120,6 +121,8 @@
if (row.type != null) p.type = row.type;
if (row.status) p.status = row.status; // honor server-side status changes
if (row.parent_id) p.instanceOf = row.parent_id;
p.archived = !!row.archived_at;
p.assigneeId = row.assignee_id || '';
return p;
}
@@ -152,37 +155,200 @@
return Promise.all(jobs).then(function () {});
};
// Write a completed SOP (plus the builder's raw state) to the API. Uses a
// deterministic id per project so re-completing updates the same row.
// ── Durable write-through outbox ───────────────────────────────────────────
// SOP/WP saves must survive a flaky network, a reload, or a crash — otherwise a
// silently-failed POST leaves the browser and server divergent. Instead of a
// fire-and-forget request, each mutation is appended to a localStorage-backed
// queue and flushed to the API with retry + backoff. The API upserts by id and
// DELETE is idempotent, so re-sending a queued op is always safe. The app's own
// local cache still updates immediately, so rendering never waits on the network.
var OUTBOX_KEY = 'wp_sync_outbox_v1';
var _flushTimer = null, _backoff = 0, _flushing = false;
function qRead() { try { return JSON.parse(localStorage.getItem(OUTBOX_KEY) || '[]') || []; } catch (e) { return []; } }
function qWrite(list) { try { localStorage.setItem(OUTBOX_KEY, JSON.stringify(list)); } catch (e) {} }
// Append an op, coalescing by (kind,key) so only the latest write per entity is
// queued. A delete supersedes any pending upsert for the same id.
function enqueue(op) {
var q = qRead();
if (op.kind === 'wp-del') {
q = q.filter(function (o) { return !(o.key === op.key && (o.kind === 'wp' || o.kind === 'wp-del')); });
} else {
q = q.filter(function (o) { return !(o.kind === op.kind && o.key === op.key); });
}
op.opId = op.kind + ':' + op.key + ':' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
op.tries = 0;
q.push(op);
qWrite(q);
notifySync();
scheduleFlush(0);
}
function opRequest(op) {
if (op.kind === 'wp-del') {
return fetch(API + '/wps/' + encodeURIComponent(op.key), { method: 'DELETE' });
}
return fetch(API + (op.kind === 'sop' ? '/sops' : '/wps'), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(op.body)
});
}
function bumpTries(opId, err) {
var q = qRead();
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].tries = (q[i].tries || 0) + 1; q[i].lastErr = err; break; } }
qWrite(q);
}
// Permanently-failed op (a 4xx client error) — keep it for visibility but stop
// retrying, so a rejected write can't loop forever.
function markDead(opId, err) {
var q = qRead();
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].dead = true; q[i].lastErr = err; break; } }
qWrite(q);
}
// Attempt every live op; successes are removed, 4xx client errors are marked
// dead (won't succeed on retry), transient failures (429/5xx/network) stay queued.
function flush() {
if (_flushing) return Promise.resolve();
var q = qRead().filter(function (o) { return !o.dead; });
if (!q.length) { notifySync(); return Promise.resolve(); }
_flushing = true; notifySync();
var chain = Promise.resolve(), anyFail = false;
q.forEach(function (op) {
chain = chain.then(function () {
return opRequest(op).then(function (r) {
var status = r ? r.status : 0;
var done = r && (r.ok || (op.kind === 'wp-del' && status === 404)); // 404 on delete = already gone
if (done) { qWrite(qRead().filter(function (o) { return o.opId !== op.opId; })); }
else if (status >= 400 && status < 500 && status !== 429) { markDead(op.opId, 'HTTP ' + status); }
else { anyFail = true; bumpTries(op.opId, 'HTTP ' + status); }
}).catch(function (e) { anyFail = true; bumpTries(op.opId, String(e)); });
});
});
return chain.then(function () {
_flushing = false;
notifySync();
if (qRead().filter(function (o) { return !o.dead; }).length) {
_backoff = anyFail ? Math.min((_backoff || 5000) * 2, 60000) : 0;
scheduleFlush(_backoff || 15000);
} else { _backoff = 0; }
});
}
function scheduleFlush(delay) {
if (_flushTimer) return; // one pending flush at a time
_flushTimer = setTimeout(function () { _flushTimer = null; flush(); }, delay || 0);
}
// ── sync status (drives the indicator + any listeners) ──────────────────────
function syncCounts() {
var q = qRead(), pending = 0, failed = 0;
for (var i = 0; i < q.length; i++) {
if (q[i].dead || (q[i].tries || 0) >= 3) failed++; else pending++;
}
return { pending: pending, failed: failed, syncing: _flushing };
}
ProjectData.syncStatus = syncCounts;
function notifySync() {
var c = syncCounts();
try { document.dispatchEvent(new CustomEvent('wp-sync-changed', { detail: c })); } catch (e) {}
renderSyncBadge(c);
}
// Tiny sync indicator (bottom-left). Rendered only in the top-level window so it
// isn't duplicated inside the embedded creator iframe; the top window still sees
// the iframe's queue changes via the 'storage' event below.
var _isTop = (function () { try { return window.top === window.self; } catch (e) { return true; } })();
var _badgeHideTimer = null;
function renderSyncBadge(c) {
if (!_isTop || !document.body) return;
var el = document.getElementById('wp-sync-badge');
if (!el) {
el = document.createElement('div');
el.id = 'wp-sync-badge';
el.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:9998;pointer-events:none;display:none;align-items:center;gap:7px;' +
'font:500 12px/1.3 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
'padding:6px 12px;border:1px solid #e0e0e0;background:#fff;color:#525252;box-shadow:0 1px 4px rgba(0,0,0,.12);transition:opacity .2s;';
document.body.appendChild(el);
}
if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; }
if (c.failed) {
el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not saved — retrying';
el.style.color = '#8a6d00'; el.style.borderColor = '#f1c21b'; el.style.background = '#fdf6dd'; el.style.display = 'inline-flex';
} else if (c.pending) {
el.textContent = '↻ Saving ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + '…';
el.style.color = '#525252'; el.style.borderColor = '#e0e0e0'; el.style.background = '#fff'; el.style.display = 'inline-flex';
} else {
el.textContent = '✓ All changes saved';
el.style.color = '#0e6027'; el.style.borderColor = '#a7f0ba'; el.style.background = '#defbe6'; el.style.display = 'inline-flex';
_badgeHideTimer = setTimeout(function () { if (el) el.style.display = 'none'; }, 1800);
}
}
// Flush triggers: on reconnect, on cross-frame queue changes, on tab focus, and
// a periodic backstop. Anything left from a previous session flushes on load.
try {
window.addEventListener('online', function () { _backoff = 0; scheduleFlush(0); });
window.addEventListener('storage', function (e) { if (e.key === OUTBOX_KEY) { notifySync(); scheduleFlush(0); } });
document.addEventListener('visibilitychange', function () { if (!document.hidden) scheduleFlush(0); });
setInterval(function () { if (qRead().filter(function (o) { return !o.dead; }).length) scheduleFlush(0); }, 20000);
} catch (e) {}
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { notifySync(); scheduleFlush(0); }); }
else { setTimeout(function () { notifySync(); scheduleFlush(0); }, 0); }
// ── public write API (now durable via the outbox) ───────────────────────────
// Write a completed SOP (plus the builder's raw state). Deterministic id per
// project so re-completing updates the same row.
ProjectData.pushSOP = function (projectId, sop, state) {
if (!projectId) return Promise.resolve(null);
var body = {
id: 'sop__' + projectId,
project_id: projectId,
name: (sop && sop.project && sop.project.name) || 'SOP',
number: (sop && sop.project && sop.project.number) || '',
complete: true,
created_by: currentUser(),
data: { sop: sop, state: state }
};
return fetch(API + '/sops', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
enqueue({
kind: 'sop', key: 'sop__' + projectId,
body: {
id: 'sop__' + projectId, project_id: projectId,
name: (sop && sop.project && sop.project.name) || 'SOP',
number: (sop && sop.project && sop.project.number) || '',
complete: true, created_by: currentUser(), data: { sop: sop, state: state }
}
});
return Promise.resolve(true);
};
// Upsert a single Work Package to the API (fire-and-forget from the caller's
// perspective; the local cache is the source of truth for immediate rendering).
// Upsert a single Work Package. The local cache stays the source of truth for
// immediate rendering; the outbox guarantees the write reaches the server.
ProjectData.pushWP = function (p, projectId) {
if (!p || !p.id) return Promise.resolve(null);
return fetch(API + '/wps', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pkgToServer(p, projectId))
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
enqueue({ kind: 'wp', key: p.id, body: pkgToServer(p, projectId) });
return Promise.resolve(true);
};
ProjectData.removeWP = function (id) {
if (!id) return Promise.resolve();
return fetch(API + '/wps/' + encodeURIComponent(id), { method: 'DELETE' })
.then(function () {}).catch(function () {});
enqueue({ kind: 'wp-del', key: id });
return Promise.resolve(true);
};
// Force a flush now and resolve when the queue drains (or a round-trip is done).
ProjectData.flushSync = function () { _backoff = 0; return flush(); };
// Archive / unarchive a Work Package (hide from active lists without deleting).
// Direct request (not the outbox) — it's a deliberate, low-frequency action and
// the caller updates the view on the returned result.
ProjectData.archiveWP = function (id, archived) {
if (!id) return Promise.resolve(null);
return fetch(API + '/wps/' + encodeURIComponent(id) + '/archive', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ archived: archived !== false })
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
};
// Fetch this project's ARCHIVED packages (full docs) for the dashboard's
// "show archived" view. Returns app-shaped package objects (p.archived === true).
ProjectData.listArchived = function (projectId) {
if (!projectId) return Promise.resolve([]);
return fetch(API + '/wps?full=true&archived=only&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (rows) { return Array.isArray(rows) ? rows.map(serverToPkg) : []; })
.catch(function () { return []; });
};
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale

63
html/sw.js Normal file
View File

@@ -0,0 +1,63 @@
/* Service worker for the Work Package Suite PWA.
Goal: let the app (and especially the field view) load and run offline. Data
durability is already handled by the sync outbox in project-data.js — this
worker only caches the static app shell so the pages open without a network.
Strategy:
• /api/* and non-GET → never touched (pass straight to the network; offline
reads fall back to the app's localStorage cache, writes queue in the outbox).
• same-origin GET → stale-while-revalidate (instant from cache, refreshed
in the background when online).
*/
'use strict';
const CACHE = 'wp-suite-shell-v1';
const SHELL = [
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
'/field.html', '/login.html', '/admin.html',
'/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.css',
'/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js',
'/work-package-suite-app.js', '/wp-creation-app.js', '/field.js',
'/prime-controls-logo.jpg', '/favicon.ico',
'/manifest.webmanifest', '/icon-192.png', '/icon-512.png',
];
self.addEventListener('install', (e) => {
// Cache each shell asset individually so one missing file doesn't abort install.
e.waitUntil(
caches.open(CACHE)
.then((c) => Promise.all(SHELL.map((u) => c.add(u).catch(() => {}))))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (e) => {
const req = e.request;
if (req.method !== 'GET') return; // outbox owns writes
const url = new URL(req.url);
if (url.origin !== self.location.origin) return; // third-party: default
if (url.pathname.startsWith('/api/')) return; // never cache the API
e.respondWith(
caches.match(req).then((cached) => {
const network = fetch(req)
.then((res) => {
if (res && res.ok) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy));
}
return res;
})
.catch(() => cached); // offline → cached copy
return cached || network; // cache-first, then refresh
})
);
});

View File

@@ -157,3 +157,93 @@ input, textarea, select {
font-family: inherit;
color: var(--cds-text-primary);
}
/* ============================================================================
App shell — shared "UI Shell" chrome (Prime Controls, IBM Carbon styling)
----------------------------------------------------------------------------
One dark top bar across every page so the suite reads as a single product.
The Prime Controls logo is a white-background wordmark, so it sits inside a
white "chip" on the near-black bar (reads as intentional, not a stray box).
Flip --wp-appbar-bg to a light value if a light header is ever preferred.
============================================================================ */
:root {
--wp-appbar-bg: #161616; /* near-black UI Shell bar */
--wp-appbar-fg: #ffffff;
--wp-appbar-fg-dim: #c6c6c6;
--wp-appbar-border: #6f6f6f; /* outline for ghost buttons on the bar */
--wp-appbar-hover: #353535;
--wp-appbar-height: 48px;
}
.wp-appbar {
background: var(--wp-appbar-bg);
color: var(--wp-appbar-fg);
display: flex;
align-items: center;
gap: 16px;
height: var(--wp-appbar-height);
padding: 0 16px;
position: sticky;
top: 0;
z-index: 100;
}
.wp-appbar-brand {
display: flex;
align-items: center;
gap: 12px;
height: 100%;
text-decoration: none;
color: var(--wp-appbar-fg);
}
.wp-appbar-brand:hover { text-decoration: none; opacity: .92; }
.wp-logo-chip {
display: inline-flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 4px;
padding: 4px 8px;
}
.wp-logo-chip img { height: 24px; width: auto; display: block; }
.wp-appbar-title {
font-size: 15px;
font-weight: 600;
color: var(--wp-appbar-fg);
white-space: nowrap;
letter-spacing: .01em;
}
.wp-appbar-title .wp-appbar-sub { font-weight: 400; color: var(--wp-appbar-fg-dim); }
.wp-appbar-spacer { flex: 1 1 auto; }
.wp-appbar-meta { font-size: 13px; color: var(--wp-appbar-fg-dim); white-space: nowrap; }
.wp-appbar-actions { display: flex; align-items: center; gap: 8px; }
/* Buttons and links that live on the dark bar */
.wp-appbar-btn {
background: transparent;
color: var(--wp-appbar-fg);
border: 1px solid var(--wp-appbar-border);
border-radius: 0;
padding: 7px 14px;
font-size: 14px;
font-family: inherit;
line-height: 1.2;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
transition: background .15s, border-color .15s;
}
.wp-appbar-btn:hover { background: var(--wp-appbar-hover); color: var(--wp-appbar-fg); text-decoration: none; }
.wp-appbar-btn.primary { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); }
.wp-appbar-btn.primary:hover { background: var(--cds-hover-primary); border-color: var(--cds-hover-primary); }
.wp-appbar-btn:focus-visible { outline: 2px solid var(--wp-appbar-fg); outline-offset: 1px; }
.wp-appbar-count { font-size: 13px; color: var(--wp-appbar-fg-dim); padding: 0 2px; white-space: nowrap; }
/* Plain text links on the dark bar (Overview / Feedback / Help, Admin, etc.) */
.wp-appbar-link { color: var(--wp-appbar-fg-dim); text-decoration: none; font-size: 14px; white-space: nowrap; }
.wp-appbar-link:hover { color: var(--wp-appbar-fg); text-decoration: none; }
@media (max-width: 720px) {
.wp-appbar { height: auto; flex-wrap: wrap; gap: 8px; padding: 8px 12px; }
.wp-appbar-actions { flex-wrap: wrap; }
.wp-appbar-meta { width: 100%; order: 5; }
}

View File

@@ -1095,8 +1095,8 @@ function loadStepComments(){
}else{
list.innerHTML = stepComments.map(c=>`
<div style="padding:0.5rem; background:white; border:1px solid var(--border); border-radius:4px; margin-bottom:0.5rem;">
<div style="font-size:11px; color:var(--text-dim); margin-bottom:0.25rem;"><strong>${c.name}</strong> • ${c.timestamp}</div>
<div style="font-size:12px; color:var(--text);">${c.text.replace(/</g,'&lt;').replace(/>/g,'&gt;')}</div>
<div style="font-size:11px; color:var(--text-dim); margin-bottom:0.25rem;"><strong>${escAttr(c.name)}</strong> • ${escAttr(c.timestamp)}</div>
<div style="font-size:12px; color:var(--text);">${escAttr(c.text)}</div>
</div>
`).join('');
}

View File

@@ -1,22 +1,25 @@
:root {
--primary: #2563eb;
--primary-light: #dbeafe;
--success: #16a34a;
--warning: #ea580c;
--danger: #dc2626;
--text: #1f2937;
--text-light: #6b7280;
--text-dim: #9ca3af;
--border: #e5e7eb;
--bg: #f9fafb;
--primary: #0f62fe;
--primary-light: #edf5ff;
--success: #198038;
--warning: #8e6a00;
--warning-bg: #fdf6dd;
--danger: #da1e28;
--text: #161616;
--text-light: #525252;
--text-dim: #8d8d8d;
--border: #e0e0e0;
--border-strong: #8d8d8d;
--bg: #f4f4f4;
--bg-card: #ffffff;
--shadow: 0 1px 3px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 25px rgba(0,0,0,0.1);
--appbar: #161616;
--shadow: none;
--shadow-lg: 0 4px 16px rgba(0,0,0,0.16);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
color: var(--text);
background: var(--bg);
line-height: 1.5;
@@ -28,43 +31,46 @@ body {
min-height: 100vh;
}
/* HEADER */
/* HEADER — dark UI Shell bar */
.header {
background: #ffffff;
color: var(--text);
padding: 1.5rem 2rem;
background: var(--appbar);
color: #fff;
padding: 0 16px;
height: 48px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: var(--shadow);
border-bottom: 1px solid var(--border);
}
.header-left {
flex: 1;
display: flex;
align-items: center;
gap: 1.5rem;
gap: 14px;
min-width: 0;
}
/* Prime logo (white-background wordmark) sits in a white chip on the dark bar */
.logo {
display: flex;
display: inline-flex;
align-items: center;
gap: 0.5rem;
justify-content: center;
background: #fff;
padding: 4px 8px;
border-radius: 4px;
text-decoration: none;
color: var(--text);
font-weight: 700;
font-size: 14px;
transition: opacity 0.2s;
flex-shrink: 0;
}
.logo:hover { opacity: 0.7; }
.logo:hover { opacity: 0.92; }
.logo img { height: 24px; width: auto; display: block; }
.logo-icon {
width: 36px;
height: 36px;
background: var(--primary-light);
border-radius: 6px;
border-radius: 0;
display: flex;
align-items: center;
justify-content: center;
@@ -73,84 +79,90 @@ body {
}
.header-title {
font-size: 24px;
font-weight: 700;
font-size: 15px;
font-weight: 600;
margin-bottom: 0;
color: var(--text);
color: #fff;
white-space: nowrap;
}
.header-subtitle {
font-size: 13px;
color: var(--text-light);
min-height: 20px;
font-size: 12px;
color: #c6c6c6;
min-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-right {
.header-right {
display: flex;
align-items: center;
gap: 1.5rem;
gap: 8px;
}
.header-button {
padding: 0.5rem 1rem;
background: var(--bg);
color: var(--primary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 14px;
background: transparent;
color: #fff;
border: 1px solid #6f6f6f;
border-radius: 0;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s;
font-size: 14px;
font-weight: 400;
transition: background 0.15s, border-color 0.15s;
}
.header-button:hover {
background: var(--primary-light);
border-color: var(--primary);
background: #353535;
border-color: #6f6f6f;
}
.step-counter {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text-light);
padding: 0.4rem 0.8rem;
background: transparent;
border: 1px solid #6f6f6f;
color: #c6c6c6;
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
/* MAIN NAVIGATION */
/* MAIN NAVIGATION — underline tabs */
.main-nav {
display: flex;
gap: 0.5rem;
padding: 1rem 2rem;
gap: 0;
padding: 0 16px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow);
}
.nav-tab {
padding: 0.75rem 1.5rem;
background: var(--bg);
border: 2px solid var(--border);
border-radius: 6px;
padding: 13px 18px;
background: none;
border: none;
border-bottom: 3px solid transparent;
border-radius: 0;
cursor: pointer;
font-size: 14px;
font-weight: 600;
font-size: 15px;
font-weight: 400;
color: var(--text-light);
display: flex;
align-items: center;
gap: 0.5rem;
transition: all 0.2s;
transition: background 0.15s, color 0.15s;
}
.nav-tab:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--bg);
color: var(--text);
}
.nav-tab.active {
background: var(--primary);
color: white;
border-color: var(--primary);
background: none;
color: var(--text);
border-bottom-color: var(--primary);
font-weight: 600;
}
.tab-icon { font-size: 16px; }
@@ -186,35 +198,37 @@ body {
}
.step-item {
padding: 0.75rem 1rem;
border-radius: 6px;
background: var(--bg);
border: 2px solid var(--border);
padding: 0.6rem 0.9rem;
border-radius: 0;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-light);
cursor: pointer;
font-size: 12px;
font-weight: 600;
font-weight: 500;
white-space: nowrap;
transition: all 0.2s;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.step-item:hover { background: var(--primary-light); border-color: var(--primary); }
.step-item.active { background: var(--primary); color: white; border-color: var(--primary); }
.step-item:hover { background: var(--bg); border-color: var(--border-strong); color: var(--text); }
.step-item.active { background: var(--primary); color: white; border-color: var(--primary); font-weight: 600; }
/* STEP CONTENT */
.step-content {
background: var(--bg-card);
padding: 2rem;
border-radius: 8px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
border-radius: 0;
margin-bottom: 2rem;
}
.step { display: none; }
.step h2 {
font-size: 20px;
font-weight: 700;
margin-bottom: 0.5rem;
font-size: 22px;
font-weight: 400;
letter-spacing: -0.01em;
margin-bottom: 0.75rem;
color: var(--text);
}
@@ -223,7 +237,7 @@ body {
color: var(--text-light);
background: var(--primary-light);
padding: 0.75rem 1rem;
border-radius: 6px;
border-radius: 0;
margin-bottom: 1.5rem;
border-left: 4px solid var(--primary);
}
@@ -253,7 +267,7 @@ body {
.field textarea {
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: 0;
font-size: 14px;
font-family: inherit;
color: var(--text);
@@ -287,7 +301,7 @@ body {
align-items: center;
padding: 1rem;
background: var(--bg);
border-radius: 6px;
border-radius: 0;
border: 1px solid var(--border);
}
@@ -319,7 +333,7 @@ body {
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg);
border-radius: 6px;
border-radius: 0;
margin-bottom: 0.5rem;
border: 1px solid var(--border);
}
@@ -339,31 +353,32 @@ body {
/* BUTTONS */
.add-btn {
padding: 0.75rem 1.25rem;
padding: 0.7rem 1.25rem;
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
border-radius: 0;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.add-btn:hover { background: #1d4ed8; }
.add-btn:hover { background: var(--cds-hover-primary, #0353e9); }
.nav-btn {
padding: 0.75rem 1.5rem;
background: var(--bg);
border: 2px solid var(--border);
border-radius: 6px;
padding: 0.7rem 1.4rem;
background: var(--bg-card);
border: 1px solid var(--border-strong);
border-radius: 0;
font-size: 14px;
font-weight: 600;
color: var(--text);
cursor: pointer;
transition: all 0.2s;
transition: all 0.15s;
}
.nav-btn:hover { border-color: var(--primary); color: var(--primary); }
.nav-btn:hover { border-color: var(--primary); color: var(--primary); background: var(--bg); }
.nav-btn.primary {
background: var(--success);
@@ -371,7 +386,7 @@ body {
border-color: var(--success);
}
.nav-btn.primary:hover { background: #15803d; border-color: #15803d; }
.nav-btn.primary:hover { background: #0e6027; border-color: #0e6027; color: white; }
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
@@ -382,7 +397,7 @@ body {
justify-content: space-between;
padding: 1.5rem;
background: var(--bg-card);
border-radius: 8px;
border-radius: 0;
box-shadow: var(--shadow);
}
@@ -398,7 +413,7 @@ body {
background: var(--primary-light);
color: var(--primary);
border: 1px solid var(--primary);
border-radius: 6px;
border-radius: 0;
font-size: 13px;
font-weight: 600;
cursor: pointer;
@@ -411,7 +426,7 @@ body {
#sequence-list { display: flex; flex-direction: column; gap: 8px; }
.seq-step {
display: flex; align-items: center; gap: 12px; padding: 11px 14px;
background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px;
background: var(--bg-card); border: 1px solid var(--border); border-radius: 0;
box-shadow: var(--shadow); transition: border-color .12s, box-shadow .12s, opacity .12s;
}
.seq-step:hover { border-color: var(--primary); }
@@ -432,7 +447,7 @@ body {
width: 28px; height: 28px; cursor: pointer; font-weight: 600; flex-shrink: 0;
}
.seq-arrow { text-align: center; color: var(--text-dim); font-size: 13px; line-height: .4; margin: -2px 0; }
.seq-step.gate { border-color: var(--warning); background: #fff7ed; border-style: dashed; }
.seq-step.gate { border-color: var(--warning); background: var(--warning-bg); border-style: dashed; }
.seq-step.gate .seq-label { color: var(--warning); font-weight: 500; }
.seq-gate-badge {
flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--warning); color: #fff;
@@ -448,7 +463,7 @@ body {
max-width: calc(100vw - 2rem);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: 0;
box-shadow: var(--shadow-lg);
padding: 1.25rem;
z-index: 1200;
@@ -488,7 +503,7 @@ body {
.modal-content {
background: var(--bg-card);
border-radius: 8px;
border-radius: 0;
padding: 2rem;
max-width: 600px;
max-height: 80vh;
@@ -528,7 +543,7 @@ body {
/* RESPONSIVE */
@media (max-width: 768px) {
.header { flex-direction: column; text-align: center; gap: 1rem; }
.header { height: auto; flex-direction: column; align-items: stretch; text-align: center; gap: 0.75rem; padding: 12px 16px; }
.main-nav { flex-wrap: wrap; }
.content-area { padding: 1rem; }
.step-content { padding: 1rem; }

View File

@@ -6,6 +6,8 @@
<title>Work Package Suite</title>
<script src="auth-guard.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="work-package-suite-styles.css">
</head>
@@ -15,9 +17,9 @@
<div class="header">
<div class="header-left">
<a href="index.html" class="logo" title="Back to Home">
<img src="prime-controls-logo.jpg" alt="Prime Controls" style="height: 36px; width: auto;">
<img src="prime-controls-logo.jpg" alt="Prime Controls" style="height: 24px; width: auto;">
</a>
<div>
<div style="min-width:0;overflow:hidden">
<div class="header-title">Work Package Suite</div>
<div class="header-subtitle" id="project-display"></div>
</div>

View File

@@ -58,7 +58,11 @@ let currentView='Work Package Form';
// ── HELPERS ──────────────────────────────────────────────────────────────────
function gv(id){ return document.getElementById(id)?.value?.trim() || ''; }
function esc(v){ if(v==null) return ''; return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// Attribute-safe HTML escaper (also escapes " and ' so values are safe inside
// href="…" / src="…" attributes, not just element text).
function esc(v){ if(v==null) return ''; return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
// Only allow http(s) URLs into an href; anything else (e.g. javascript:) → '#'.
function hrefAttr(u){ return /^https?:\/\//i.test(String(u||'')) ? esc(u) : '#'; }
function ns(){ return '<span style="color:var(--text-dim)">—</span>'; }
function cell(v){ return v ? esc(v) : ns(); }
function pad2(n){ return n<10?'0'+n:''+n; }
@@ -197,12 +201,12 @@ function renderSopRefLinks(){
const srcs=sopLinkedSources();
if(!srcs.length){ box.innerHTML=''; return; }
box.innerHTML=`<div class="ref-links-title">Reference folders (from SOP) — navigate to find &amp; copy the specific file link:</div>`+
`<div class="ref-links">`+srcs.map(s=>`<a href="${esc(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
}
function renderSpecFolderLink(){
const el=document.getElementById('spec-folder-link'); if(!el) return;
const spec=sopLinkedSources().find(s=>/spec/i.test(s.label));
el.innerHTML = spec ? `<a href="${esc(spec.link)}" target="_blank" rel="noopener" class="ref-link-sm">↗ Open spec folder (${esc(spec.system||'SOP')})</a>` : '';
el.innerHTML = spec ? `<a href="${hrefAttr(spec.link)}" target="_blank" rel="noopener" class="ref-link-sm">↗ Open spec folder (${esc(spec.system||'SOP')})</a>` : '';
}
function buildTypePicker(){
let types = enabledTypes();
@@ -524,7 +528,7 @@ function renderSopFileFolders(){
const srcs=sopLinkedSources();
box.innerHTML = srcs.length
? `<div class="field-hint">1) Open a folder, multi-select files in SharePoint, then use <b>Copy link</b>:</div>`+
`<div class="ref-links">`+srcs.map(s=>`<a href="${esc(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`
: `<div class="field-hint">No SOP folders defined — load or import an SOP first.</div>`;
}
function toggleSopFilePanel(){
@@ -619,7 +623,7 @@ function openHoldModal(preselect, fromConstraint){
}
function holdPhotoChange(ev){
const f=ev.target.files&&ev.target.files[0]; if(!f) return;
const r=new FileReader(); r.onload=()=>{ holdPhotoData=r.result; document.getElementById('hold-photo-preview').innerHTML=`<img src="${holdPhotoData}" alt="supporting photo">`; }; r.readAsDataURL(f);
const r=new FileReader(); r.onload=()=>{ holdPhotoData=r.result; const ok=/^data:image\//.test(holdPhotoData); document.getElementById('hold-photo-preview').innerHTML= ok?`<img src="${esc(holdPhotoData)}" alt="supporting photo">`:''; }; r.readAsDataURL(f);
}
function submitHold(){
const constraint=document.getElementById('hold-constraint').value;
@@ -699,7 +703,7 @@ function collectPackage(){
parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined,
number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'),
type:gv('wp_type'), system:gv('wp_system'), location:gv('wp_location'),
cost:gv('wp_cost'), wbs:gv('wp_wbs'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'),
cost:gv('wp_cost'), wbs:gv('wp_wbs'), assigneeId:gv('wp_assignee'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'),
due:gv('wp_due'), spec:gv('wp_spec'), desc:gv('wp_desc'),
work:steps.join('\n'), workSteps:steps, numberDims:{...numberDims},
disciplines:[...pkgDisciplines],
@@ -839,7 +843,7 @@ function setFormChrome(on){
if(nav) nav.style.display = on ? '' : 'none';
if(save) save.style.display = on ? 'flex' : 'none';
document.body.classList.toggle('has-sticky-save', !!on);
if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); }
if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); initSectionNavAutoHide(); }
}
// Make each form card collapsible by clicking its heading (idempotent).
function makeCollapsible(){
@@ -871,6 +875,27 @@ function buildSectionNav(){
});
nav.innerHTML=chips.join('');
}
// Keep the section-nav pinned just below the sticky header (so it stays put while
// scrolling instead of hiding behind the header), and let it slide out of the way
// while reading (scroll down), snapping back the moment you scroll up.
function positionSectionNav(){
const nav=document.getElementById('section-nav'), hdr=document.querySelector('.header');
if(nav && hdr) nav.style.top = hdr.offsetHeight + 'px';
}
let _snLastY=0, _snBound=false;
function initSectionNavAutoHide(){
positionSectionNav();
if(_snBound) return; _snBound=true;
window.addEventListener('resize', positionSectionNav, {passive:true});
window.addEventListener('scroll', ()=>{
const nav=document.getElementById('section-nav');
if(!nav || nav.style.display==='none') return;
const y=window.scrollY||document.documentElement.scrollTop||0;
if(y>_snLastY+4 && y>140) nav.classList.add('nav-hidden'); // scrolling down
else if(y<_snLastY-4) nav.classList.remove('nav-hidden'); // scrolling up
_snLastY=y;
}, {passive:true});
}
function updateStickyStatus(){
const el=document.getElementById('sticky-status'); if(!el) return;
const r=readiness(); const st=getRadio('status');
@@ -896,10 +921,43 @@ function renderSavedList(){
const disc = (p.disciplines&&p.disciplines.length)?`<div style="font-size:10px;color:var(--text-dim)">${esc(p.disciplines.join(', '))}</div>`:'';
return `<tr><td class="row-label">${esc(p.number||'—')}${tag}${disc}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td>
<td>${statusPill(p.status)}</td><td>${ready}</td>
<td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="row-del" onclick="deletePackage(${i})">✕</button></td></tr>`;
<td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="link-btn" onclick="showHistoryRow(${i})">history</button> <button class="row-del" onclick="deletePackage(${i})">✕</button></td></tr>`;
}).join('');
}
function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
// ── WP HISTORY (audit trail) ─────────────────────────────────────────────────
function showHistoryRow(i){ const p=savedPackages[i]; if(p) showHistory(p.id, p.number||p.subject); }
function showHistoryCurrent(){ showHistory(editingId, (document.getElementById('wp_number')||{}).value); }
function closeHistory(){ const m=document.getElementById('wp-history-modal'); if(m) m.remove(); }
const HIST_LABEL={created:'Created',updated:'Updated',status_changed:'Status changed',issued:'Issued',deleted:'Deleted'};
async function showHistory(wpId, label){
if(!wpId){ toast('Save the package first — history is recorded as it changes.'); return; }
if(!label){ const _p=savedPackages.find(x=>x.id===wpId)||(typeof dashArchived!=='undefined'&&dashArchived.find(x=>x.id===wpId)); label=_p?(_p.number||_p.subject):''; }
closeHistory();
const ov=document.createElement('div');
ov.id='wp-history-modal'; ov.className='modal-overlay open';
ov.innerHTML='<div class="modal" style="max-width:640px"><div class="modal-head"><div class="modal-title">History — '+esc(label||wpId)+'</div>'+
'<button class="cmt-x" onclick="closeHistory()" title="Close">✕</button></div>'+
'<div class="modal-body" id="wp-history-body"><div class="empty-hint">Loading…</div></div>'+
'<div class="modal-foot"><button class="btn btn-primary" onclick="closeHistory()">Close</button></div></div>';
ov.addEventListener('click', e=>{ if(e.target===ov) closeHistory(); });
document.body.appendChild(ov);
let rows=[];
try{ const r=await fetch('/api/audit?entity_type=wp&entity_id='+encodeURIComponent(wpId), {headers:{'Accept':'application/json'}}); if(r.ok) rows=await r.json(); }catch(e){}
const body=document.getElementById('wp-history-body'); if(!body) return;
if(!rows || !rows.length){
body.innerHTML='<div class="empty-hint">No history on the server yet. Changes are recorded as the package is saved and its status changes — if this package was just created it may still be syncing.</div>';
return;
}
const fmt=s=>{ try{ return new Date(s).toLocaleString(); }catch(e){ return s||''; } };
const det=d=>{ d=d||{}; if(d.from!=null||d.to!=null) return esc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to)); if(d.status) return 'status: '+esc(d.status); return ''; };
body.innerHTML='<div class="hist-list">'+rows.map(e=>
'<div class="hist-item"><div class="hist-when">'+esc(fmt(e.at))+'</div>'+
'<div class="hist-main"><span class="hist-action">'+esc(HIST_LABEL[e.action]||(e.action||'').replace(/_/g,' '))+'</span> '+
'<span class="hist-detail">'+det(e.detail)+'</span></div>'+
'<div class="hist-actor">by '+esc(e.actor||'—')+'</div></div>').join('')+'</div>';
}
function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); }
function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); }
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); }
@@ -909,6 +967,7 @@ function loadPackageIntoForm(p){
const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';};
set('wp_subject',p.subject); set('wp_system',p.system); set('wp_location',p.location);
set('wp_assignees',p.assignees); set('wp_distribution',p.distribution);
set('wp_assignee',p.assigneeId);
set('wp_due',p.due); set('wp_spec',p.spec); set('wp_desc',p.desc); set('wp_hours',p.hours);
set('wp_kit_owner',p.kitOwner); set('wp_kit_date',p.kitDate); set('wp_mimo_time',p.mimoTime); set('wp_mimo_loc',p.mimoLoc);
set('wp_actual_hrs',p.actualHrs); set('wp_installed_qty',p.installedQty); set('wp_redlines',p.redlines); set('wp_lessons',p.lessons);
@@ -981,7 +1040,7 @@ function duplicateWP(){
function newPackage(){
editingId=null;
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignee','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value='';
['wp_lod','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
pkgKind='iwp'; applyKind();
@@ -1025,15 +1084,43 @@ let dashFilter={status:'',discipline:'',q:'',flag:''};
function dashToggleFlag(f){
if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:''}; }
else { dashFilter.flag = dashFilter.flag===f ? '' : f; }
renderDashboard();
dashPage=0; renderDashboard();
}
function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; dashPage=0; renderDashboard(); }
// ── Phase 2: pagination, progress %, and archived view ──────────────────────
let dashPage=0, dashShowArchived=false, dashArchived=[];
const DASH_PAGE_SIZE=25;
// Weighted completion by status (0..1) so progress is smoother than done/not-done.
const PROGRESS_W={'Draft':0,'Scheduled':0.25,'Issue':0.4,'Issued':0.5,'In Progress':0.75,'QC':0.9,'Closed':1};
function wpProgress(p){ const w=PROGRESS_W[p.status]; return w==null?0:w; }
function dashGo(pg){ dashPage=pg; renderDashboard(); }
function dashToggleArchived(on){
dashShowArchived=!!on; dashPage=0;
if(dashShowArchived && !dashArchived.length && typeof ProjectData!=='undefined' && ProjectData.listArchived){
ProjectData.listArchived(activeProjectId).then(rows=>{ dashArchived=rows||[]; renderDashboard(); });
} else { renderDashboard(); }
}
function dashArchive(id){
const p=savedPackages.find(x=>x.id===id); if(!p) return;
if(!confirm('Archive "'+(p.number||p.subject||'this package')+'"? It will be hidden from the active board but kept for the record.')) return;
if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,true);
const ix=savedPackages.findIndex(x=>x.id===id); if(ix>=0){ p.archived=true; dashArchived.unshift(p); savedPackages.splice(ix,1); }
saveStore(); renderSavedList(); renderDashboard(); toast('Archived '+(p.number||''));
}
function dashUnarchive(id){
const ix=dashArchived.findIndex(x=>x.id===id); const p=ix>=0?dashArchived[ix]:null; if(!p) return;
if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,false);
p.archived=false; dashArchived.splice(ix,1); if(!savedPackages.some(x=>x.id===id)) savedPackages.push(p);
saveStore(); renderSavedList(); renderDashboard(); toast('Restored '+(p.number||''));
}
function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; renderDashboard(); }
// Consistent colored status pill, reused by the dashboard board and the saved list.
function statusPill(s){
const map={'Draft':'badge-NA','Scheduled':'badge-O','Issued':'badge-Y','In Progress':'badge-O','QC':'badge-O','Closed':'badge-Y','Issue':'badge-N'};
const label = s==='Issue' ? 'Issue (Hold)' : (s||'—');
return `<span class="badge ${map[s]||'badge-NA'}">${esc(label)}</span>`;
}
function myUserId(){ try { return (window.WP_USER && window.WP_USER.id) || ''; } catch(e){ return ''; } }
function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); }
function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todayStr()); }
// Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted.
@@ -1050,11 +1137,12 @@ function showDashboard(){
function renderDashboard(){
const all=countableWPs();
const byStatus={}; STATUS_ORDER.concat(['Issue']).forEach(s=>byStatus[s]=0);
let estH=0, actH=0, ready=0, hold=0, overdue=0; const byDisc={};
let estH=0, actH=0, ready=0, hold=0, overdue=0, mine=0; const byDisc={}; const meId=myUserId();
all.forEach(p=>{
byStatus[p.status]=(byStatus[p.status]||0)+1;
estH+=parseFloat(p.hours)||0; actH+=parseFloat(p.actualHrs)||0;
if(p.status==='Issue') hold++;
if(meId && p.assigneeId===meId) mine++;
if(wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue') ready++;
if(isOverdue(p)) overdue++;
(p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1);
@@ -1067,6 +1155,7 @@ function renderDashboard(){
};
let h=`<div class="dash-metrics">
${card('Total WPs', all.length, '', 'all')}
${meId ? card('My WPs', mine, mine?'dm-blue':'', 'mine') : ''}
${card('Release-ready', ready, ready?'dm-green':'', 'ready')}
${card('On hold', hold, hold?'dm-red':'', 'onhold')}
${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')}
@@ -1082,6 +1171,17 @@ function renderDashboard(){
h+=`<div class="dash-breakdown"><div><div class="dash-bd-title">By status</div>${statusChips||'—'}</div>
<div><div class="dash-bd-title">By discipline</div>${discChips}</div></div>`;
// progress by phase (discipline), weighted by status; archived excluded
const overallPct = all.length ? Math.round(all.reduce((s,p)=>s+wpProgress(p),0)/all.length*100) : 0;
const phaseGroups={};
all.forEach(p=>{ (p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>{ (phaseGroups[d]=phaseGroups[d]||[]).push(p); }); });
let prog=`<div class="dash-panel"><div class="dash-panel-title">Progress by phase</div>`;
prog+=`<div class="prog-row"><div class="prog-name"><strong>Overall</strong></div><div class="prog-bar"><div class="prog-fill" style="width:${overallPct}%"></div></div><div class="prog-pct">${overallPct}%</div></div>`;
Object.keys(phaseGroups).sort().forEach(d=>{ const g=phaseGroups[d]; const pct=g.length?Math.round(g.reduce((s,p)=>s+wpProgress(p),0)/g.length*100):0; const done=g.filter(p=>p.status==='Closed').length;
prog+=`<div class="prog-row"><div class="prog-name">${esc(d)}</div><div class="prog-bar"><div class="prog-fill" style="width:${pct}%"></div></div><div class="prog-pct">${pct}% <span class="prog-sub">${done}/${g.length}</span></div></div>`; });
prog+=`<div class="field-hint" style="margin-top:8px">Weighted by status (Draft 0 · Scheduled 25 · Issued 50 · In&nbsp;Progress 75 · QC 90 · Closed 100%). Archived packages excluded.</div></div>`;
h+=prog;
// gating panel — what's blocking release
const gated=all.filter(p=>wpOpenConstraints(p).length>0);
h+=`<div class="dash-panel"><div class="dash-panel-title">⛔ Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)</div>`;
@@ -1096,39 +1196,60 @@ function renderDashboard(){
const discList=Object.keys(byDisc);
const discOpts=['<option value="">All disciplines</option>'].concat(discList.map(d=>`<option ${dashFilter.discipline===d?'selected':''}>${esc(d)}</option>`)).join('');
h+=`<div class="dash-filters">
<input type="search" placeholder="Search WP # / subject…" value="${(dashFilter.q||'').replace(/"/g,'&quot;')}" oninput="dashFilter.q=this.value;renderDashboard()">
<select onchange="dashFilter.status=this.value;renderDashboard()">${statusOpts}</select>
<select onchange="dashFilter.discipline=this.value;renderDashboard()">${discOpts}</select>
<input type="search" placeholder="Search WP # / subject / type…" value="${(dashFilter.q||'').replace(/"/g,'&quot;')}" oninput="dashFilter.q=this.value;dashPage=0;renderDashboard()">
<select onchange="dashFilter.status=this.value;dashPage=0;renderDashboard()">${statusOpts}</select>
<select onchange="dashFilter.discipline=this.value;dashPage=0;renderDashboard()">${discOpts}</select>
<label class="dash-arch-toggle"><input type="checkbox" ${dashShowArchived?'checked':''} onchange="dashToggleArchived(this.checked)"> Show archived${dashShowArchived?' ('+dashArchived.length+')':''}</label>
</div>`;
// main board (includes masters, marked)
// main board (includes masters, marked; archived only when toggled on)
const q=(dashFilter.q||'').toLowerCase();
const rows=WPData.list().filter(p=>{
const boardSource = dashShowArchived ? WPData.list().concat(dashArchived) : WPData.list();
const rows=boardSource.filter(p=>{
if(dashFilter.status && p.status!==dashFilter.status) return false;
if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false;
if(q && !((p.number||'')+' '+(p.subject||'')).toLowerCase().includes(q)) return false;
if(q && !((p.number||'')+' '+(p.subject||'')+' '+(p.type||'')).toLowerCase().includes(q)) return false;
if(dashFilter.flag==='mine' && p.assigneeId!==myUserId()) return false;
if(dashFilter.flag==='ready' && !(!p.split && wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue')) return false;
if(dashFilter.flag==='onhold' && p.status!=='Issue') return false;
if(dashFilter.flag==='overdue' && !isOverdue(p)) return false;
return true;
});
h+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${rows.length})</div>
const totalRows=rows.length;
const pages=Math.max(1, Math.ceil(totalRows/DASH_PAGE_SIZE));
if(dashPage>=pages) dashPage=pages-1;
if(dashPage<0) dashPage=0;
const pageRows=rows.slice(dashPage*DASH_PAGE_SIZE, dashPage*DASH_PAGE_SIZE+DASH_PAGE_SIZE);
h+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${totalRows})</div>
<table class="dash-table"><thead><tr><th>WP #</th><th>Subject</th><th>Type</th><th>Discipline</th><th>Status</th><th>Gates</th><th>Due</th><th>Hrs</th><th></th></tr></thead><tbody>`;
if(!rows.length) h+=`<tr><td colspan="9" class="field-hint" style="padding:14px">No work packages match.</td></tr>`;
rows.forEach(p=>{
if(!totalRows) h+=`<tr><td colspan="9" class="field-hint" style="padding:14px">No work packages match.</td></tr>`;
pageRows.forEach(p=>{
const ix=savedPackages.findIndex(x=>x.id===p.id);
const open=wpOpenConstraints(p).length;
const gates= p.split?'<span class="badge badge-O">master</span>':(open?`<span class="badge badge-O">${open} open</span>`:`<span class="badge badge-Y">clear</span>`);
const due= p.due?`<span style="${isOverdue(p)?'color:var(--red);font-weight:700':''}">${esc(p.due)}</span>`:ns();
const canIssue = !p.split && open===0 && p.status!=='Closed' && p.status!=='Issued' && p.status!=='Issue';
const issueBtn = canIssue?`<button class="link-btn" onclick="dashIssue('${p.id}')">issue</button>`:'';
h+=`<tr><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}</td>
const pid=esc(p.id);
let actions;
if(p.archived){
actions=`<button class="link-btn" onclick="showHistory('${pid}')">history</button> <button class="link-btn" onclick="dashUnarchive('${pid}')">restore</button>`;
} else {
const canIssue = !p.split && open===0 && p.status!=='Closed' && p.status!=='Issued' && p.status!=='Issue';
const issueBtn = canIssue?`<button class="link-btn" onclick="dashIssue('${pid}')">issue</button> `:'';
actions=`${issueBtn}<button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button> <button class="link-btn" onclick="dashArchive('${pid}')">archive</button>`;
}
h+=`<tr${p.archived?' style="opacity:.6"':''}><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}${p.archived?' <span class="badge badge-NA">archived</span>':''}</td>
<td>${esc(p.subject||'')}</td><td>${esc(p.type||'')}</td>
<td style="font-size:11px">${esc((p.disciplines||[]).join(', '))||ns()}</td>
<td>${statusPill(p.status)}</td><td>${gates}</td><td>${due}</td><td>${cell(p.hours)}</td>
<td class="center" style="white-space:nowrap">${issueBtn} <button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button></td></tr>`;
<td class="center" style="white-space:nowrap">${actions}</td></tr>`;
});
h+=`</tbody></table></div>`;
h+=`</tbody></table>`;
if(pages>1){
h+=`<div class="dash-pager"><span>Page ${dashPage+1} of ${pages} · ${totalRows} packages</span>
<span class="dash-pager-btns"><button class="btn btn-ghost" ${dashPage===0?'disabled':''} onclick="dashGo(${dashPage-1})"> Prev</button>
<button class="btn btn-ghost" ${dashPage>=pages-1?'disabled':''} onclick="dashGo(${dashPage+1})">Next </button></span></div>`;
}
h+=`</div>`;
document.getElementById('dash-body').innerHTML=h;
}
function dashIssue(id){
@@ -1225,10 +1346,27 @@ function bootSOP(){
if(activeProjectId){ SOP=null; renderCtxBar(); newPackage(); }
else { loadSampleSOP(); }
}
// Populate the Owner picker with this project's members (+ admins). The list is
// only used to pick an assignee; the server re-validates on save.
async function loadMembers(){
const sel=document.getElementById('wp_assignee');
if(!sel || !activeProjectId) return;
try {
const r=await fetch('/api/projects/'+encodeURIComponent(activeProjectId)+'/members',{credentials:'same-origin'});
if(!r.ok) return;
const list=await r.json();
const cur=sel.value;
sel.innerHTML='<option value="">— Unassigned —</option>'+
list.map(u=>`<option value="${esc(u.id)}">${esc(u.full_name||u.username)}</option>`).join('');
if(cur) sel.value=cur;
} catch(e){}
}
function bootData(){
loadStore(); // reads the localStorage cache (hydrated from the server below)
bootSOP();
setRadio('status','Draft');
loadMembers();
renderSavedList();
cmtInit();
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard).

View File

@@ -6,6 +6,8 @@
<title>Work Package (IWP) — Prime Controls</title>
<script src="auth-guard.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-creation-styles.css">
</head>
@@ -28,6 +30,7 @@
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="newPackage()">+ New</button>
<button class="btn btn-ghost" style="padding:7px 16px" onclick="duplicateWP()">⧉ Duplicate</button>
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showHistoryCurrent()" title="Change history for this work package">🕘 History</button>
<button class="btn btn-ghost embed-hide" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button>
</div>
@@ -84,6 +87,7 @@
<div class="field"><label>Acumatica Task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div>
</div>
<div class="field-grid">
<div class="field"><label>Owner <span class="help-tip" data-tip="The accountable owner (a user account on this project). Assigning notifies them by email if email notifications are enabled in the admin console.">i</span></label><select id="wp_assignee"><option value="">— Unassigned —</option></select></div>
<div class="field"><label>Assignees</label><input type="text" id="wp_assignees" placeholder="name (company), name (company)"></div>
<div class="field"><label>Distribution</label><input type="text" id="wp_distribution" placeholder="notify list"></div>
<div class="field"><label>Due Date</label><input type="date" id="wp_due"></div>

View File

@@ -7,25 +7,25 @@
body.embedded .embed-first { margin-left: auto; }
:root {
--bg: #f4f5f7;
--bg: #f4f4f4;
--surface: #ffffff;
--surface2: #f7f8fa;
--border: #e3e6ec;
--border-strong: #d0d5de;
--text: #1a2230;
--text-muted: #5a6675;
--text-dim: #9aa3b2;
--accent: #2563d6;
--accent-dim: #e8f0fe;
--accent-green: #15924f;
--accent-green-dim: #e4f6ec;
--accent-amber: #b87100;
--accent-amber-dim: #fdf2e0;
--red: #cf3b3b;
--red-dim: #fbeaea;
--radius: 5px;
--shadow: 0 1px 2px rgba(20,30,50,.04), 0 1px 3px rgba(20,30,50,.06);
--shadow-lg: 0 4px 16px rgba(20,30,50,.08);
--surface2: #f4f4f4;
--border: #e0e0e0;
--border-strong: #8d8d8d;
--text: #161616;
--text-muted: #525252;
--text-dim: #8d8d8d;
--accent: #0f62fe;
--accent-dim: #edf5ff;
--accent-green: #198038;
--accent-green-dim: #defbe6;
--accent-amber: #8e6a00;
--accent-amber-dim: #fdf6dd;
--red: #da1e28;
--red-dim: #fff1f1;
--radius: 0;
--shadow: none;
--shadow-lg: 0 4px 16px rgba(20,30,50,.12);
--mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', Consolas, monospace;
--sans: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
}
@@ -213,7 +213,7 @@
.notice {
background: var(--accent-dim); border: 1px solid #b9d2fb; border-radius: var(--radius);
padding: 10px 14px; font-size: 12px; color: #1a4fad; margin-bottom: 18px; font-family: var(--mono);
padding: 10px 14px; font-size: 12px; color: #0043ce; margin-bottom: 18px; font-family: var(--mono);
}
/* ── DELIVERABLES ── */
@@ -245,9 +245,9 @@
.btn-ghost { background: var(--surface); border-color: var(--border-strong); color: var(--text-muted); }
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: var(--shadow); }
.btn-primary:hover { background: #1d52b8; }
.btn-primary:hover { background: #0353e9; }
.btn-generate { background: var(--accent-green); border-color: var(--accent-green); color: #fff; font-weight: 700; box-shadow: var(--shadow); }
.btn-generate:hover { background: #117a42; }
.btn-generate:hover { background: #0e6027; }
/* ── OUTPUT ── */
#output-section { display: none; }
@@ -419,7 +419,7 @@
.ov-select.ov-unset { color:var(--red) !important; border-color:var(--red); }
.use-btn { display:inline-block; margin-left:8px; padding:4px 14px; font-family:var(--sans); font-size:11px; font-weight:700;
color:#fff; background:var(--accent-green); border:none; border-radius:var(--radius); cursor:pointer; letter-spacing:.03em; }
.use-btn:hover { background:#0f7a40; }
.use-btn:hover { background:#0e6027; }
.sum-chips { display:flex; flex-wrap:wrap; gap:7px; }
.sum-chip { background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb; border-radius:3px;
padding:3px 10px; font-family:var(--mono); font-size:10px; }
@@ -495,7 +495,7 @@
.modal-overlay { position:fixed; inset:0; background:rgba(20,28,40,.55); display:none; align-items:center; justify-content:center; z-index:9000; padding:20px; }
.modal-overlay.open { display:flex; }
.modal { background:var(--surface); border-radius:12px; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; }
.modal { background:var(--surface); border-radius:0; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; }
.modal-head { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); }
.modal-title { font-weight:700; font-size:15px; color:var(--text); }
.modal-body { padding:18px 20px; overflow-y:auto; }
@@ -571,9 +571,11 @@
/* Section nav (jump chips) */
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
padding:8px 12px; background:rgba(255,255,255,.92); backdrop-filter:blur(4px);
border-bottom:1px solid var(--border); }
padding:8px 12px; background:rgba(255,255,255,.94); backdrop-filter:blur(4px);
border-bottom:1px solid var(--border); box-shadow:0 1px 4px rgba(20,30,50,.06);
transition:transform .22s ease; }
.section-nav-bar:empty{ display:none; }
.section-nav-bar.nav-hidden{ transform:translateY(-160%); }
.sec-chip{ font-size:12px; font-weight:600; color:var(--text-muted); background:var(--surface2);
border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; }
.sec-chip:hover{ border-color:var(--accent); color:var(--accent); }
@@ -606,11 +608,12 @@
/* Dashboard */
.dash-metrics { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:12px; margin-bottom:16px; }
.dash-metric { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; text-align:center; }
.dash-metric { background:var(--surface); border:1px solid var(--border); border-radius:0; padding:14px 16px; text-align:center; }
.dash-metric .dm-val { font-size:26px; font-weight:800; line-height:1; }
.dash-metric .dm-label { font-size:11px; color:var(--text-muted); margin-top:6px; text-transform:uppercase; letter-spacing:.03em; }
.dash-metric.dm-green .dm-val { color:var(--accent-green); }
.dash-metric.dm-red .dm-val { color:var(--red); }
.dash-metric.dm-blue .dm-val { color:var(--accent, #0f62fe); }
.dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; }
.dash-metric[onclick]:hover { border-color:var(--accent); }
.dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); }
@@ -620,7 +623,7 @@
.dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; }
.dash-chip { display:inline-block; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:14px; padding:3px 10px; margin:0 6px 6px 0; }
.dash-chip.chip-red { background:var(--red-dim); color:var(--red); border-color:var(--red); }
.dash-panel { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; margin-bottom:16px; }
.dash-panel { background:var(--surface); border:1px solid var(--border); border-radius:0; padding:14px 16px; margin-bottom:16px; }
.dash-panel-title { font-weight:700; font-size:13px; margin-bottom:10px; }
.dash-table { width:100%; border-collapse:collapse; font-size:12.5px; }
.dash-table th { text-align:left; background:var(--surface2); border-bottom:1px solid var(--border); padding:6px 8px; font-size:11px; text-transform:uppercase; color:var(--text-muted); }
@@ -629,3 +632,27 @@
.dash-filters input, .dash-filters select { padding:7px 10px; border:1px solid var(--border-strong); border-radius:6px; font-size:13px; }
.dash-filters input[type=search] { flex:1; min-width:200px; }
@media (max-width:640px){ .dash-breakdown { grid-template-columns:1fr; } }
/* ── WP history (audit trail) modal ──────────────────────────────────────── */
.hist-list { display:flex; flex-direction:column; }
.hist-item { display:grid; grid-template-columns:170px 1fr auto; gap:12px; align-items:baseline;
padding:9px 2px; border-bottom:1px solid var(--border); }
.hist-item:last-child { border-bottom:none; }
.hist-when { font-family:var(--mono); font-size:11px; color:var(--text-muted); white-space:nowrap; }
.hist-action { font-weight:600; color:var(--text); }
.hist-detail { color:var(--accent); font-size:13px; }
.hist-actor { font-size:12px; color:var(--text-muted); white-space:nowrap; }
@media (max-width:560px){ .hist-item { grid-template-columns:1fr; gap:2px; } }
/* ── Dashboard progress bars + pager + archived toggle (Phase 2) ──────────── */
.prog-row { display:grid; grid-template-columns:150px 1fr 96px; gap:10px; align-items:center; margin-bottom:7px; }
.prog-name { font-size:12.5px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.prog-bar { height:10px; background:var(--surface2); border:1px solid var(--border); overflow:hidden; }
.prog-fill { height:100%; background:var(--accent); transition:width .3s ease; }
.prog-pct { font-size:12px; font-weight:600; color:var(--text); text-align:right; white-space:nowrap; }
.prog-sub { font-weight:400; color:var(--text-muted); font-size:11px; }
.dash-arch-toggle { display:inline-flex; align-items:center; gap:6px; font-size:13px; color:var(--text-muted); white-space:nowrap; cursor:pointer; }
.dash-pager { display:flex; align-items:center; gap:12px; margin-top:12px; font-size:12.5px; color:var(--text-muted); }
.dash-pager-btns { margin-left:auto; display:flex; gap:8px; }
.dash-pager .btn { padding:5px 12px; }
@media (max-width:560px){ .prog-row { grid-template-columns:110px 1fr 74px; } }