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:
@@ -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 & tests · talks to <code>/api</code> on this host</div></div>
|
||||
<div><h1>Admin Console</h1><div class="sub">Stack diagnostics & 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 & 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 & 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>
|
||||
|
||||
138
html/admin.js
138
html/admin.js
@@ -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 user’s 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:[]}; } }
|
||||
|
||||
@@ -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
86
html/field.html
Normal 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
164
html/field.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
||||
function nsKey(id) { return 'wp_iwp_v1__' + id; }
|
||||
function stLabel(s) { return s === 'Issue' ? 'Issue (Hold)' : s; }
|
||||
function openCount(p) { return ((p && p.constraints) || []).filter(function (c) { return c.status === 'open'; }).length; }
|
||||
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();
|
||||
49
html/help.js
49
html/help.js
@@ -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
BIN
html/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
html/icon-512.png
Normal file
BIN
html/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
237
html/index.html
237
html/index.html
@@ -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';
|
||||
|
||||
@@ -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
18
html/manifest.webmanifest
Normal 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" }
|
||||
]
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
||||
|
||||
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
63
html/sw.js
Normal 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
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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,'<').replace(/>/g,'>')}</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('');
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
// 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||||
// 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 & 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 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,'"')}" 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,'"')}" 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).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
Reference in New Issue
Block a user