Move static files into html/ for Docker bind mount

This commit is contained in:
2026-06-15 14:47:24 -05:00
parent 960b4a4b94
commit fd668f0ea2
11 changed files with 0 additions and 0 deletions

BIN
html/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

48
html/feedback-config.js Normal file
View File

@@ -0,0 +1,48 @@
/* ──────────────────────────────────────────────────────────────────────────
CENTRAL FEEDBACK CONFIGURATION
----------------------------------------------------------------------------
By default the suite stores all feedback in each user's own browser
(localStorage) and reviewers share it via Export / Import. That needs no
server and works behind any firewall.
To ALSO collect feedback automatically into one central place, set
FEEDBACK_ENDPOINT below to a URL that accepts an HTTP POST of JSON. This
works with either:
• a small backend on your host (Node/PHP/Python/ASP.NET) that appends the
body to a feedback.json / .csv you can download, or
• a Microsoft Power Automate "When an HTTP request is received" trigger
that writes to a SharePoint list / Excel table.
Leave it as an empty string to stay fully local (export/import only).
See DEPLOYMENT.md for setup details and sample receivers.
This is set to the same-origin path '/api/feedback', which the NGINX reverse
proxy (see nginx-wp-suite.conf) forwards to the Power Automate HTTP trigger.
Keeping it relative means no CORS and the secret trigger URL never appears in
client code. Locally (no proxy) the POST simply fails silently and feedback is
still saved/exported from the browser.
────────────────────────────────────────────────────────────────────────── */
window.FEEDBACK_ENDPOINT = '/api/feedback';
/* Best-effort send to the central endpoint. Never throws and never blocks the
UI: feedback is always saved locally first by the caller, so a failed or
disabled POST simply means "not centrally collected." Returns a Promise that
resolves true on success, false otherwise. */
window.postFeedback = function (payload) {
if (!window.FEEDBACK_ENDPOINT) return Promise.resolve(false);
try {
return fetch(window.FEEDBACK_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
app: 'Work Package Suite',
page: (typeof location !== 'undefined' && location.pathname) || '',
submittedAt: new Date().toISOString(),
...payload
}),
keepalive: true // allow the send to complete even if the page unloads
}).then(function (r) { return r.ok; }).catch(function () { return false; });
} catch (e) {
return Promise.resolve(false);
}
};

582
html/index.html Normal file
View File

@@ -0,0 +1,582 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Work Package Suite — Prime Controls</title>
<link rel="icon" href="favicon.ico" sizes="any">
<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;
}
/* HERO */
.hero {
text-align: center;
margin-bottom: 4rem;
}
.hero h1 {
font-size: 2.625rem;
font-weight: 300;
margin-bottom: 1rem;
color: var(--cds-text-primary);
}
.hero p {
font-size: 1.125rem;
color: var(--cds-text-secondary);
margin-bottom: 2rem;
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
/* CARDS */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.card {
background: var(--cds-layer);
border: 1px solid var(--cds-border-subtle);
border-radius: 4px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
transition: all 0.2s;
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);
}
.card-badge {
display: inline-block;
background: var(--cds-button-primary);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
margin-bottom: 1rem;
width: fit-content;
}
.card h3 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.card p {
color: var(--cds-text-secondary);
margin-bottom: 1.5rem;
flex: 1;
font-size: 0.95rem;
}
.card-button {
display: inline-block;
background: var(--cds-button-primary);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 3px;
text-decoration: none;
font-weight: 600;
text-align: center;
transition: background 0.2s;
border: none;
cursor: pointer;
font-size: 13px;
}
.card-button:hover {
background: var(--cds-hover-primary);
}
/* COMPLETE STATE (SOP done) */
.card.complete {
background: #ecfdf5;
border-color: #16a34a;
}
.card.complete .card-button { background: #16a34a; }
.card.complete .card-button:hover { background: #15803d; }
.card-status {
display: inline-block;
font-size: 12px;
font-weight: 600;
color: #16a34a;
margin-bottom: 0.5rem;
}
.card.disabled {
opacity: 0.6;
pointer-events: none;
}
/* SECTION */
.section {
background: var(--cds-layer);
border-radius: 4px;
padding: 2rem;
margin-bottom: 2rem;
border: 1px solid var(--cds-border-subtle);
}
.section h2 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: var(--cds-text-primary);
}
.section h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.section p {
color: var(--cds-text-secondary);
margin-bottom: 1rem;
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);
color: var(--cds-text-secondary);
padding: 2rem;
text-align: center;
font-size: 12px;
border-top: 1px solid var(--cds-border-subtle);
}
.footer a {
color: var(--cds-link-primary);
text-decoration: none;
}
.footer a:hover { text-decoration: underline; }
/* COMMENTS SECTION */
.comments-section {
background: var(--cds-layer);
border-radius: 4px;
padding: 1.5rem;
margin-bottom: 2rem;
border: 1px solid var(--cds-border-subtle);
}
.comments-toggle {
padding: 0.75rem 1.5rem;
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;
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);
color: var(--cds-text-primary);
font-family: inherit;
margin-bottom: 1rem;
}
.comments-panel textarea {
resize: vertical;
min-height: 80px;
}
.comment-buttons {
display: flex;
gap: 0.5rem;
}
.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);
color: var(--cds-text-primary);
}
.close-btn:hover { background: var(--cds-hover-ui); }
.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;
}
.comment-meta {
font-size: 11px;
color: var(--cds-text-secondary);
margin-bottom: 0.25rem;
}
.comment-text {
color: var(--cds-text-primary);
}
/* 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; }
}
</style>
</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>
</nav>
</div>
</header>
<!-- MAIN CONTENT -->
<div class="container">
<!-- HERO -->
<div class="hero">
<h1>Work Package Suite</h1>
<p>Standardized approach to Work Package creation for Prime Controls construction projects. Configure project parameters, define constraints, and generate compliant work packages.</p>
</div>
<!-- TOOL CARDS -->
<div class="cards-grid" id="overview">
<!-- SOP CONFIG -->
<a href="work-package-suite.html?tab=sop" class="card" id="card-sop">
<h3>SOP Configuration</h3>
<p>Define the project baseline in 10 steps — team, sign-offs, WP types, governance, quality, platforms, sequence, constraints, and sources. Every Work Package inherits these defaults.</p>
<button class="card-button" id="card-sop-btn">Open Tool</button>
</a>
<!-- WP CREATOR -->
<a href="work-package-suite.html?tab=wp" class="card" id="card-wp">
<h3>Work Package Creator</h3>
<p>Author individual Work Packages against the project SOP — pre-populated defaults, constraint checklists, and exportable IWPs. Complete the SOP first to unlock.</p>
<button class="card-button" id="card-wp-btn">Open Tool</button>
</a>
</div>
<!-- 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 -->
<div class="comments-section" id="comments">
<button class="comments-toggle" onclick="toggleComments()">Leave Feedback</button>
<div class="comments-panel" id="comments-panel">
<div style="margin-bottom: 1rem;">
<label style="font-weight: 600; font-size: 13px; color: var(--cds-text-primary);">Name (optional)</label>
<input type="text" id="commenter-name" placeholder="Your name">
</div>
<div style="margin-bottom: 1rem;">
<label style="font-weight: 600; font-size: 13px; color: var(--cds-text-primary);">Feedback</label>
<textarea id="comment-text" placeholder="Your feedback here..."></textarea>
</div>
<div class="comment-buttons">
<button class="submit-btn" onclick="submitComment()">Submit</button>
<button class="close-btn" onclick="exportFeedback()">⤓ Export</button>
<button class="close-btn" onclick="document.getElementById('feedback-import').click()">⤒ Import</button>
<button class="close-btn" onclick="toggleComments()">Close</button>
<input type="file" id="feedback-import" accept="application/json" style="display:none" onchange="importFeedback(event)">
</div>
<div class="comments-list" id="comments-list"></div>
</div>
</div>
<!-- SUPPORT SECTION -->
<div class="section">
<h2>About This Suite</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem;">
<div>
<h3>Two-Step Workflow</h3>
<p>Configure the project SOP once, then author every Work Package against it. The Creator stays locked until the SOP is complete, so packages always inherit a valid baseline.</p>
</div>
<div>
<h3>Leave Feedback</h3>
<p>Use the feedback section on this page or within any tool. All comments are stored locally and can be exported for team review and iteration.</p>
</div>
<div>
<h3>Offline & Collaborative</h3>
<p>All tools work entirely in your browser. Export SOP and Work Package data as JSON for sharing, version control, and integration.</p>
</div>
</div>
</div>
</div>
<!-- FOOTER -->
<footer class="footer">
<p>Work Package Suite v1.0 | Prime Controls | All files work offline with local browser storage</p>
</footer>
<script src="feedback-config.js"></script>
<script>
// Reflect SOP completion on the tool cards.
(function reflectSOPStatus(){
let complete = false, projName = '';
try {
complete = localStorage.getItem('wp_suite_sop_complete') === '1';
const sop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null');
projName = sop && sop.project && sop.project.name || '';
} catch(e){}
const sopCard = document.getElementById('card-sop');
const sopBtn = document.getElementById('card-sop-btn');
const wpCard = document.getElementById('card-wp');
const wpBtn = document.getElementById('card-wp-btn');
if(complete){
sopCard.classList.add('complete');
sopBtn.textContent = 'Review';
const status = document.createElement('div');
status.className = 'card-status';
status.textContent = '✓ SOP Complete' + (projName ? ' — ' + projName : '');
sopCard.insertBefore(status, sopCard.firstChild);
if(wpBtn) wpBtn.textContent = 'Open Creator';
} else {
if(wpCard) wpCard.classList.add('disabled');
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
}
})();
let allComments = [];
function toggleComments() {
const panel = document.getElementById('comments-panel');
panel.classList.toggle('open');
if (panel.classList.contains('open')) loadComments();
}
function submitComment() {
const name = document.getElementById('commenter-name').value || 'Anonymous';
const text = document.getElementById('comment-text').value.trim();
if (!text) {
alert('Please enter feedback.');
return;
}
const comment = {
name,
text,
timestamp: new Date().toLocaleString()
};
allComments.push(comment);
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
if (window.postFeedback) window.postFeedback({ type: 'home_feedback', ...comment });
document.getElementById('comment-text').value = '';
document.getElementById('commenter-name').value = '';
loadComments();
alert('Thank you! Feedback submitted.');
}
function exportFeedback() {
const saved = localStorage.getItem('wp_suite_index_comments');
const data = saved ? JSON.parse(saved) : [];
if (!data.length) { alert('No feedback to export yet.'); return; }
const payload = { app: 'Work Package Suite', source: 'home', exportedAt: new Date().toISOString(), comments: data };
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'wp-suite-feedback-home-' + new Date().toISOString().slice(0, 10) + '.json';
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
function importFeedback(ev) {
const f = ev.target.files && ev.target.files[0];
if (!f) return;
const r = new FileReader();
r.onload = () => {
try {
const inc = JSON.parse(r.result);
const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
if (!incoming.length) { alert('No feedback found in that file.'); return; }
const saved = localStorage.getItem('wp_suite_index_comments');
allComments = saved ? JSON.parse(saved) : [];
const seen = new Set(allComments.map(c => c.timestamp + '|' + c.text));
let added = 0;
incoming.forEach(c => { const k = c.timestamp + '|' + c.text; if (c.text && !seen.has(k)) { allComments.push(c); seen.add(k); added++; } });
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
loadComments();
alert('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
} catch (e) { alert('Could not read that file.'); }
ev.target.value = '';
};
r.readAsText(f);
}
function loadComments() {
const saved = localStorage.getItem('wp_suite_index_comments');
if (saved) allComments = JSON.parse(saved);
const list = document.getElementById('comments-list');
if (allComments.length === 0) {
list.innerHTML = '<div style="color: var(--cds-text-secondary); font-style: italic; font-size: 12px;">No feedback yet. Be the first to share!</div>';
} else {
list.innerHTML = allComments.map(c => `
<div class="comment-item">
<div class="comment-meta"><strong>${c.name}</strong> • ${c.timestamp}</div>
<div class="comment-text">${c.text.replace(/</g,'&lt;').replace(/>/g,'&gt;')}</div>
</div>
`).join('');
}
}
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

159
html/theme-light.css Normal file
View File

@@ -0,0 +1,159 @@
/* IBM Carbon Design System - Light Theme (g10) */
:root {
--cds-interactive-01: #0f62fe;
--cds-interactive-02: #393939;
--cds-interactive-03: #0f62fe;
--cds-interactive-04: #0f62fe;
--cds-ui-background: #f4f4f4;
--cds-ui-01: #ffffff;
--cds-ui-02: #f4f4f4;
--cds-ui-03: #e0e0e0;
--cds-ui-04: #8d8d8d;
--cds-ui-05: #161616;
--cds-text-01: #161616;
--cds-text-02: #525252;
--cds-text-03: #a8a8a8;
--cds-text-04: #ffffff;
--cds-text-05: #6f6f6f;
--cds-text-error: #da1e28;
--cds-icon-01: #161616;
--cds-icon-02: #525252;
--cds-icon-03: #ffffff;
--cds-link-01: #0f62fe;
--cds-link-02: #0043ce;
--cds-inverse-link: #78a9ff;
--cds-field-01: #ffffff;
--cds-field-02: #f4f4f4;
--cds-inverse-01: #ffffff;
--cds-inverse-02: #393939;
--cds-support-01: #da1e28;
--cds-support-02: #198038;
--cds-support-03: #f1c21b;
--cds-support-04: #0043ce;
--cds-inverse-support-01: #fa4d56;
--cds-inverse-support-02: #42be65;
--cds-inverse-support-03: #f1c21b;
--cds-inverse-support-04: #4589ff;
--cds-overlay: rgba(22, 22, 22, .5);
--cds-danger: #da1e28;
--cds-focus: #0f62fe;
--cds-hover-primary: #0353e9;
--cds-active-primary: #002d9c;
--cds-hover-primary-text: #0043ce;
--cds-hover-secondary: #4c4c4c;
--cds-active-secondary: #6f6f6f;
--cds-hover-tertiary: #0353e9;
--cds-active-tertiary: #002d9c;
--cds-hover-ui: #e5e5e5;
--cds-hover-light-ui: #e5e5e5;
--cds-hover-selected-ui: #cacaca;
--cds-active-ui: #c6c6c6;
--cds-active-light-ui: #cacaca;
--cds-selected-ui: #e5e5e5;
--cds-selected-light-ui: #e5e5e5;
--cds-inverse-hover-ui: #353535;
--cds-hover-danger: #ba121b;
--cds-active-danger: #750e13;
--cds-hover-row: #f4f4f4;
--cds-visited-link: #0043ce;
--cds-disabled-01: #f4f4f4;
--cds-disabled-02: #bdbdbd;
--cds-disabled-03: #8d8d8d;
--cds-button-primary: #0f62fe;
--cds-button-secondary: #393939;
--cds-button-tertiary: #0f62fe;
--cds-button-danger-primary: #da1e28;
--cds-button-danger-secondary: #da1e28;
--cds-background: #f4f4f4;
--cds-background-inverse: #161616;
--cds-background-brand: #0f62fe;
--cds-background-active: #e5e5e5;
--cds-background-hover: #e8e8e8;
--cds-background-selected: #e5e5e5;
--cds-background-selected-hover: #cacaca;
--cds-layer: #ffffff;
--cds-layer-accent: #f4f4f4;
--cds-layer-accent-hover: #e8e8e8;
--cds-layer-accent-active: #e0e0e0;
--cds-layer-hover: #e8e8e8;
--cds-layer-active: #e5e5e5;
--cds-layer-selected: #e5e5e5;
--cds-layer-selected-hover: #cacaca;
--cds-layer-selected-inverse: #393939;
--cds-field: #ffffff;
--cds-field-hover: #e8e8e8;
--cds-border-subtle: #e0e0e0;
--cds-border-strong: #8d8d8d;
--cds-border-inverse: #393939;
--cds-border-interactive: #0f62fe;
--cds-border-subtle-selected: #c6c6c6;
--cds-text-primary: #161616;
--cds-text-secondary: #525252;
--cds-text-placeholder: #a8a8a8;
--cds-text-helper: #6f6f6f;
--cds-text-on-color: #ffffff;
--cds-text-inverse: #f4f4f4;
--cds-text-disabled: #bdbdbd;
--cds-link-primary: #0f62fe;
--cds-link-secondary: #0043ce;
--cds-link-visited: #0043ce;
--cds-link-inverse: #78a9ff;
--cds-link-primary-hover: #0353e9;
--cds-icon-primary: #161616;
--cds-icon-secondary: #525252;
--cds-icon-on-color: #ffffff;
--cds-icon-inverse: #f4f4f4;
--cds-icon-disabled: #bdbdbd;
--cds-support-error: #da1e28;
--cds-support-success: #198038;
--cds-support-warning: #f1c21b;
--cds-support-info: #0043ce;
--cds-support-error-inverse: #fa4d56;
--cds-support-success-inverse: #42be65;
--cds-support-warning-inverse: #f1c21b;
--cds-support-info-inverse: #4589ff;
--cds-interactive: #0f62fe;
--cds-shadow: rgba(0, 0, 0, .16);
}
/* Typography */
body {
font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
color: var(--cds-text-primary);
background: var(--cds-background);
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
}
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.28572;
}
h1 { font-size: 2rem; }
h2 { font-size: 1.75rem; }
h3 { font-size: 1.25rem; }
h4 { font-size: 1rem; }
h5 { font-size: .875rem; }
h6 { font-size: .75rem; }
a {
color: var(--cds-link-primary);
text-decoration: none;
}
a:hover {
color: var(--cds-link-primary-hover);
text-decoration: underline;
}
button {
font-family: inherit;
cursor: pointer;
}
input, textarea, select {
font-family: inherit;
color: var(--cds-text-primary);
}

View File

@@ -0,0 +1,819 @@
// ── GLOBAL STATE ──────────────────────────────────────────────────────────────
let currentTool = 'sop';
let currentStep = 1;
let sopComplete = false;
let allComments = [];
let state = {
project: {name:'', number:'', client:'', division:'', site:''},
team: {pm:'', apm:'', cm:'', qm:''},
teamMembers: [],
signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}],
wpTypes: [],
governance: {woformat:'', wosize:'', issuance:[]},
quality: {qcreq:'', photo:'', hold:''},
platforms: {tracking:'CxAlloy', commissioning:'CxAlloy'},
sequence: [],
constraints: [],
sources: []
};
let sop = null; // Generated SOP for WP tool
// ── LIBRARIES ─────────────────────────────────────────────────────────────────
const DEFAULT_WP_TYPES = [
{name:'Rough-In', enabled:true},
{name:'Mechanical Install', enabled:true},
{name:'Panel Install', enabled:true},
{name:'Conduit Install', enabled:true},
{name:'Tray Install', enabled:true},
{name:'Mechanical Tubing', enabled:false},
{name:'Instrument Install', enabled:true},
{name:'Wire Pull', enabled:true},
{name:'Terminations', enabled:true},
{name:'Prefab/Kitting', enabled:false},
{name:'Network Cabling', enabled:false},
{name:'Fiber', enabled:false},
{name:'Calibrations', enabled:false}
];
const STANDARD_10_CONSTRAINTS = [
{name:'Safety & Permitting', description:'Permits, safety reviews, environmental clearances'},
{name:'Quality Control / Inspection', description:'QC approval, inspection readiness'},
{name:'IFC Drawings & Specs', description:'Issued-for-Construction drawings and specifications'},
{name:'Schedule', description:'Scheduled sequence timing confirmed'},
{name:'Materials (on site, bagged & tagged)', description:'All required materials on site and staged'},
{name:'Prefabrication', description:'Prefabricated assemblies complete'},
{name:'Work Access & Laydown', description:'Work area accessible and staged'},
{name:'Craft Availability', description:'Required craft trades available'},
{name:'Construction Equipment & Tools', description:'Special equipment and tools on site'},
{name:'Scaffolding / Access Equipment', description:'Temporary access structures in place'}
];
const CONSTRAINT_LIBRARY = [
'Utility Clearances',
'Third-Party Approvals',
'Engineering Change Notices',
'Commissioning Sign-Off',
'As-Built Documentation',
'Labeling & Identification',
'Testing & Certification',
'Rework Completion',
'Coordination with Other Trades',
'Environmental Controls',
'Critical Path Gate',
'Client Walkthrough Approval'
];
const OPTIONAL_ROLES = [
'Assistant Project Manager',
'General Foreman',
'Construction Manager',
'HSE Professional',
'Quality Representative',
'Planner',
'Safety Manager',
'Project Controls Manager'
];
const LABOR_COST_CODES = [
'1000|Project Management',
'2000|Design and Development',
'2100|Design',
'2110|Control System Design',
'2120|Instrument Design',
'2130|Electrical Design',
'2140|Panel Design',
'2141|Panel Design Rework',
'2150|BIM',
'2151|BIM Rework',
'2160|Documentation',
'2200|Development',
'2210|PLC Programming',
'2220|OIT Programming',
'2230|SCADA Programming',
'2240|Simulation Development',
'2300|Customer Training',
'3000|Operational Technology',
'3100|OT Design',
'3200|Rack Assembly',
'3300|Network Configuration',
'3400|Computer Configuration',
'4000|Construction',
'4010|Instruments Install',
'4020|Network & Computers Install',
'4040|PLC Install',
'4050|Panel Install',
'4060|Electrical Install',
'4070|Mechanical Install',
'4080|Security Install',
'4090|Radio Install',
'4100|Commissioning',
'6000|Production',
'7000|Quality',
'7100|Panel Quality Control',
'7200|Factory Acceptance Testing',
'7300|Site Acceptance Testing',
'8000|Safety',
'9000|Administration'
];
// ── INITIALIZATION ────────────────────────────────────────────────────────────
window.addEventListener('DOMContentLoaded',()=>{
initializeWPTypes();
renderTeamMembers();
renderOptionalRoles();
renderStandardConstraints();
renderSequenceSteps();
renderSources();
restoreSavedSOP();
updateStepUI();
updateProjectDisplay();
// Deep-link: ?tab=sop | ?tab=wp from the home page cards.
const params = new URLSearchParams(window.location.search);
const tab = params.get('tab');
if(tab === 'wp' || tab === 'sop') switchTool(tab);
track('app_open');
let _fieldTimer;
document.addEventListener('input', e=>{
const t = e.target;
if(t && t.id && /^(INPUT|SELECT|TEXTAREA)$/.test(t.tagName)){
clearTimeout(_fieldTimer);
_fieldTimer = setTimeout(()=> track('field_edit', {field:t.id}), 600);
}
});
});
window.addEventListener('beforeunload', trackStepDwell);
function initializeWPTypes(){
state.wpTypes = JSON.parse(JSON.stringify(DEFAULT_WP_TYPES)).map(t=>({...t,notes:'',approval:''}));
renderWPTypes();
}
// ── LOAD SAMPLE DATA ──────────────────────────────────────────────────────────
function loadSampleData(){
// Populate Step 1
document.getElementById('proj_name').value = 'MICRON_PH1_CUP_HPM_FMCS INSTALL';
document.getElementById('proj_number').value = '26-67-008';
document.getElementById('proj_client').value = 'Micron Technology, Inc.';
document.getElementById('proj_division').value = 'Semiconductor';
document.getElementById('proj_site').value = 'Boise, ID — Fab 7';
// Populate Step 2
document.getElementById('proj_pm').value = 'Mariano Sanchez';
document.getElementById('proj_apm').value = 'Assistant PM';
document.getElementById('proj_cm').value = 'K. Boyd';
document.getElementById('proj_qm').value = 'D. Nguyen';
// Step 3 already has defaults
document.getElementById('role_super_name').value = 'John Smith';
document.getElementById('role_foreman_name').value = 'Mike Jones';
// Populate Step 5
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
document.getElementById('gov_wosize').value = '35 days / 4080 hours';
// Populate Step 6
document.getElementById('qual_qcreq').value = 'Yes — Detailed inspection items';
document.getElementById('qual_photo').value = 'Key checkpoints only';
document.getElementById('qual_hold').value = 'HOLD: Prime QAQC to inspect rough-in before cover/cover-up.\nWITNESS: Client QC to observe megger test before energization.';
// Step 7 already has defaults
// Collect all data
collectStepData();
track('sample_loaded');
alert('✓ Sample data loaded!\n\nNavigate through the SOP steps to see example values. You can edit or replace any field.');
// Switch to step 1
currentStep = 1;
updateStepUI();
updateProjectDisplay();
}
// ── RESTORE A COMPLETED SOP (for "Review" + WP tab across reloads) ─────────────
function restoreSavedSOP(){
let savedState = null, savedSop = null, complete = false;
try {
complete = localStorage.getItem('wp_suite_sop_complete') === '1';
savedState = JSON.parse(localStorage.getItem('wp_suite_state') || 'null');
savedSop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null');
} catch(e){}
if(!complete || !savedState) return;
state = savedState;
sop = savedSop;
sopComplete = true;
// Re-render dynamic lists from restored state.
renderWPTypes();
renderTeamMembers();
renderOptionalRoles();
renderStandardConstraints();
renderSequenceSteps();
renderSources();
repopulateForm();
if(typeof onSOPReady === 'function') onSOPReady(sop);
}
// Push restored state values back into the static form inputs.
function repopulateForm(){
const set = (id,val)=>{ const el=document.getElementById(id); if(el!=null && val!=null) el.value = val; };
set('proj_name', state.project.name);
set('proj_number', state.project.number);
set('proj_client', state.project.client);
set('proj_division', state.project.division);
set('proj_site', state.project.site);
set('proj_pm', state.team.pm);
set('proj_apm', state.team.apm);
set('proj_cm', state.team.cm);
set('proj_qm', state.team.qm);
if(state.signoffRoles[0]) set('role_super_name', state.signoffRoles[0].name);
if(state.signoffRoles[1]) set('role_foreman_name', state.signoffRoles[1].name);
set('gov_woformat', state.governance.woformat);
set('gov_wosize', state.governance.wosize);
set('qual_qcreq', state.quality.qcreq);
set('qual_photo', state.quality.photo);
set('qual_hold', state.quality.hold);
set('plat_tracking', state.platforms.tracking);
set('plat_commissioning', state.platforms.commissioning);
}
// ── TOOL SWITCHING ────────────────────────────────────────────────────────────
function switchTool(tool){
currentTool = tool;
// Update nav tabs
document.querySelectorAll('.nav-tab').forEach(t=>t.classList.remove('active'));
document.querySelector(`[data-tab="${tool}"]`).classList.add('active');
// Update content
document.querySelectorAll('.tool').forEach(t=>t.classList.remove('active'));
document.getElementById(`tool-${tool}`).classList.add('active');
// Reset step counter
if(tool === 'sop'){
document.getElementById('total-steps').textContent = '10';
}else{
document.getElementById('total-steps').textContent = '—';
}
if(tool === 'wp') renderWPTab();
updateStepUI();
updateProjectDisplay();
}
// Show the gate or the embedded Work Package Creator depending on SOP status.
function renderWPTab(){
const gate = document.getElementById('wp-gate');
const frame = document.getElementById('wp-frame');
if(!gate || !frame) return;
if(sopComplete){
gate.style.display = 'none';
frame.style.display = 'block';
// Reload each time so the creator picks up the latest SOP from localStorage.
frame.src = 'wp-creation-index.html?embedded=1&t=' + Date.now();
}else{
gate.style.display = 'block';
frame.style.display = 'none';
}
}
// Called from completeSOP / restoreSavedSOP once an SOP is available.
function onSOPReady(){
if(currentTool === 'wp') renderWPTab();
}
function updateProjectDisplay(){
const projName = document.getElementById('proj_name')?.value || 'Project';
const display = document.getElementById('project-display');
if(display) display.textContent = sopComplete ? `${projName} (SOP Ready)` : projName;
}
// ── RENDERING (SOP) ────────────────────────────────────────────────────────────
function renderWPTypes(){
const container = document.getElementById('wp-types-table');
container.innerHTML = `<div class="wp-types-header">
<div>Work Order Type</div>
<div style="text-align:center;">Enabled</div>
<div>Special Rules / Notes</div>
<div>WO Complete Approval</div>
</div>`;
state.wpTypes.forEach((t,i)=>{
const row = document.createElement('div');
row.className = 'wp-type-row';
row.innerHTML = `
<div style="font-weight:600;">${t.name}</div>
<div style="text-align:center;"><input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})" style="width:18px; height:18px; cursor:pointer;"></div>
<input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
`;
container.appendChild(row);
});
}
function toggleWPType(i){
state.wpTypes[i].enabled = !state.wpTypes[i].enabled;
renderWPTypes();
}
function renderTeamMembers(){
const container = document.getElementById('team-members-list');
if(!container) return;
container.innerHTML = state.teamMembers.map((m,i)=>`
<div style="display:grid; grid-template-columns:1fr 1fr 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
<input type="text" placeholder="Role / title (e.g., Scheduler)" value="${(m.role||'').replace(/"/g,'&quot;')}" onchange="state.teamMembers[${i}].role=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<input type="text" placeholder="Name" value="${(m.name||'').replace(/"/g,'&quot;')}" onchange="state.teamMembers[${i}].name=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="removeTeamMember(${i})">✕</button>
</div>
`).join('');
}
function addTeamMember(){
state.teamMembers.push({role:'',name:''});
renderTeamMembers();
}
function removeTeamMember(i){
state.teamMembers.splice(i,1);
renderTeamMembers();
}
function renderOptionalRoles(){
const container = document.getElementById('optional-roles-list');
const current = state.signoffRoles.filter(r=>r.role!=='Superintendent'&&r.role!=='Foreman');
container.innerHTML = current.map((r,i)=>`
<div style="display:grid; grid-template-columns:1fr 200px 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
<select onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].role=this.value">
${OPTIONAL_ROLES.map(o=>`<option ${r.role===o?'selected':''}>${o}</option>`).join('')}
</select>
<input type="text" placeholder="Name (optional)" value="${r.name||''}" onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].name=this.value">
<button class="row-del" style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer;" onclick="removeRole(${state.signoffRoles.indexOf(r)})">✕</button>
</div>
`).join('');
}
function addOptionalRole(){
state.signoffRoles.push({role:'General Foreman',name:''});
renderOptionalRoles();
}
function removeRole(i){
state.signoffRoles.splice(i,1);
renderOptionalRoles();
}
function renderStandardConstraints(){
const container = document.getElementById('standard-constraints');
container.innerHTML = STANDARD_10_CONSTRAINTS.map(c=>`
<div style="display:flex; align-items:start; gap:0.75rem; padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
<input type="checkbox" id="const_${c.name}" checked onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;">
<div style="flex:1;">
<label for="const_${c.name}" style="margin:0; font-weight:600; display:block; cursor:pointer;">${c.name}</label>
<div style="font-size:12px; color:var(--text-dim); margin-top:0.25rem;">${c.description}</div>
</div>
</div>
`).join('');
state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c}));
}
function toggleConstraint(name){
const idx = state.constraints.findIndex(c=>c.name===name);
if(idx>=0) state.constraints.splice(idx,1);
else state.constraints.push(STANDARD_10_CONSTRAINTS.find(c=>c.name===name));
}
function showConstraintLibrary(){
const modal = document.getElementById('constraint-modal');
const lib = document.getElementById('constraint-library');
lib.innerHTML = CONSTRAINT_LIBRARY.map(c=>`
<div class="constraint-option" style="padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem; cursor:pointer; transition:all 0.2s;" onmouseover="this.style.borderColor='var(--primary)'; this.style.background='var(--primary-light)'" onmouseout="this.style.borderColor='var(--border)'; this.style.background='var(--bg)'" onclick="addCustomConstraint('${c}')">
<strong>${c}</strong>
<div style="font-size:12px; color:var(--text-light); margin-top:0.25rem;">Click to add to this project</div>
</div>
`).join('');
modal.style.display = 'flex';
}
function closeConstraintModal(){
document.getElementById('constraint-modal').style.display = 'none';
}
function addCustomConstraint(name){
if(!state.constraints.find(c=>c.name===name)){
state.constraints.push({name,description:''});
}
closeConstraintModal();
renderStandardConstraints();
}
const DEFAULT_SEQUENCE = ['Layout','Conduit Install','Tray Install','Wire Pull','Device Install','Termination','QC Inspection','Commissioning'];
let seqDragIndex = null;
function renderSequenceSteps(){
const container = document.getElementById('sequence-list');
if(!container) return;
if(!state.sequence.length) state.sequence = DEFAULT_SEQUENCE.map(s=>({label:s,kind:'step'}));
container.innerHTML = '';
let stepNo = 0;
state.sequence.forEach((item,i)=>{
const isGate = item.kind==='gate';
if(!isGate) stepNo++;
const row = document.createElement('div');
row.className = 'seq-step' + (isGate?' gate':'');
row.draggable = true;
row.dataset.idx = i;
const badge = isGate ? `<span class="seq-gate-badge" title="QC / hold gate">◆ HOLD</span>`
: `<span class="seq-num">${stepNo}</span>`;
row.innerHTML = `
<span class="seq-handle" title="Drag to reorder">⠿</span>
${badge}
<input type="text" class="seq-label" value="${(item.label||'').replace(/"/g,'&quot;')}" oninput="state.sequence[${i}].label=this.value">
<button class="seq-del" title="Remove" onclick="removeSeqStep(${i})">✕</button>`;
row.addEventListener('dragstart',e=>{ seqDragIndex=i; row.classList.add('dragging'); e.dataTransfer.effectAllowed='move'; });
row.addEventListener('dragend',()=>{ row.classList.remove('dragging'); document.querySelectorAll('.seq-step').forEach(s=>s.classList.remove('drag-over')); });
row.addEventListener('dragover',e=>{ e.preventDefault(); row.classList.add('drag-over'); e.dataTransfer.dropEffect='move'; });
row.addEventListener('dragleave',()=>row.classList.remove('drag-over'));
row.addEventListener('drop',e=>{
e.preventDefault(); row.classList.remove('drag-over');
const from=seqDragIndex, to=i;
if(from===null||from===to) return;
const moved=state.sequence.splice(from,1)[0];
state.sequence.splice(to,0,moved);
seqDragIndex=null; renderSequenceSteps();
});
container.appendChild(row);
if(i<state.sequence.length-1){ const a=document.createElement('div'); a.className='seq-arrow'; a.textContent='↓'; container.appendChild(a); }
});
}
function addSequenceStep(){
const inp = document.getElementById('seq-add-input');
const v = (inp && inp.value || '').trim();
state.sequence.push({label: v || 'New Step', kind:'step'});
if(inp) inp.value = '';
renderSequenceSteps();
}
function addSequenceGate(){
state.sequence.push({label:'QC Hold', kind:'gate'});
renderSequenceSteps();
}
function removeSeqStep(i){
state.sequence.splice(i,1);
renderSequenceSteps();
}
const DEFAULT_SOURCES = [
{label:'Design Drawings', ph:'e.g. Procore, Bluebeam'},
{label:'Spool Drawings', ph:'e.g. SharePoint, BIM'},
{label:'Installation Detail Drawings', ph:'e.g. Prime details + Micron MVDs'},
{label:'IO List', ph:'e.g. controls.dev, Excel'},
{label:'Cable Schedule', ph:'e.g. Excel on SharePoint'},
{label:'Conduit Schedule', ph:'e.g. Excel on SharePoint'},
{label:'Tray Schedule', ph:'e.g. Excel on SharePoint'},
{label:'Datasheets', ph:'e.g. Procore'},
{label:'Specifications', ph:'e.g. client portal'},
{label:'Asset Register', ph:'e.g. CxAlloy, Excel'},
{label:'RFIs', ph:'e.g. Procore RFI log'},
{label:'Safety Documentation', ph:'e.g. site safety binder'}
];
function renderSources(){
const container = document.getElementById('sources-list');
if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph}));
container.innerHTML = state.sources.map((s,i)=>`
<div style="display:grid; grid-template-columns:150px 150px 250px 150px 30px; gap:1rem; align-items:center; padding:1rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
<input type="text" value="${s.label}" placeholder="Label" onchange="state.sources[${i}].label=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
<input type="text" value="${s.system}" placeholder="${s.ph||'System of record'}" onchange="state.sources[${i}].system=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
<input type="text" value="${s.link}" placeholder="URL" onchange="state.sources[${i}].link=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
<input type="text" value="${s.notes}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="state.sources.splice(${i},1); renderSources()">✕</button>
</div>
`).join('');
}
function addSource(){
state.sources.push({label:'',system:'',notes:'',link:''});
renderSources();
}
// ── STEP NAVIGATION ────────────────────────────────────────────────────────────
function goToStep(n){
if(!validateStep(currentStep)) return;
currentStep = n;
updateStepUI();
}
function nextStep(){
if(!validateStep(currentStep)) return;
if(currentStep < 10){
currentStep++;
updateStepUI();
}
}
function previousStep(){
if(currentStep > 1){
currentStep--;
updateStepUI();
}
}
function updateStepUI(){
trackStepDwell();
track('step_view', {step: currentStep});
// SOP steps
document.querySelectorAll('[id^="sop-step-"]').forEach(s=>s.style.display='none');
document.getElementById(`sop-step-${currentStep}`)?.style.display && (document.getElementById(`sop-step-${currentStep}`).style.display='block');
// Step indicators
document.querySelectorAll('.step-item').forEach(s=>s.classList.remove('active'));
document.querySelector(`[data-step="${currentStep}"]`)?.classList.add('active');
// Update counter
document.getElementById('current-step').textContent = currentStep;
// Update buttons
document.getElementById('sop-prev-btn').disabled = currentStep === 1;
document.getElementById('sop-next-btn').style.display = currentStep < 10 ? 'block' : 'none';
document.getElementById('sop-complete-btn').style.display = currentStep === 10 ? 'block' : 'none';
// Collect form data
collectStepData();
}
function collectStepData(){
switch(currentStep){
case 1:
state.project.name = document.getElementById('proj_name').value;
state.project.number = document.getElementById('proj_number').value;
state.project.client = document.getElementById('proj_client').value;
state.project.division = document.getElementById('proj_division').value;
state.project.site = document.getElementById('proj_site').value;
break;
case 2:
state.team.pm = document.getElementById('proj_pm').value;
state.team.apm = document.getElementById('proj_apm').value;
state.team.cm = document.getElementById('proj_cm').value;
state.team.qm = document.getElementById('proj_qm').value;
break;
case 3:
state.signoffRoles[0].name = document.getElementById('role_super_name').value;
state.signoffRoles[1].name = document.getElementById('role_foreman_name').value;
break;
case 5:
state.governance.woformat = document.getElementById('gov_woformat').value;
state.governance.wosize = document.getElementById('gov_wosize').value;
const sel = document.getElementById('gov_issuance');
state.governance.issuance = Array.from(sel.selectedOptions).map(o=>o.value);
break;
case 6:
state.quality.qcreq = document.getElementById('qual_qcreq').value;
state.quality.photo = document.getElementById('qual_photo').value;
state.quality.hold = document.getElementById('qual_hold').value;
break;
case 7:
state.platforms.tracking = document.getElementById('plat_tracking').value;
state.platforms.commissioning = document.getElementById('plat_commissioning').value;
break;
}
}
function validateStep(n){
collectStepData();
const proj = state.project;
if(n===1 && (!proj.name || !proj.number || !proj.client || !proj.division || !proj.site)){
alert('Please complete all required fields: Project Name, Number, Client, Division, and Site Location.');
return false;
}
if(n===5 && !state.governance.woformat){
alert('Please enter a Work Package Number Format.');
return false;
}
if(n===6 && !state.quality.qcreq){
alert('Please select a QC requirement.');
return false;
}
return true;
}
// ── SOP COMPLETION ────────────────────────────────────────────────────────────
function completeSOP(){
if(!validateStep(10)) return;
collectStepData();
sop = {
meta: {tool:'Work Package Configuration', sample:false},
project: {
name: state.project.name,
number: state.project.number,
client: state.project.client,
division: state.project.division,
pm: state.team.pm,
apm: state.team.apm,
cm: state.team.cm,
qm: state.team.qm,
site: state.project.site,
teamMembers: state.teamMembers.filter(m=>(m.role||m.name))
},
roles: state.signoffRoles.filter(r=>r.role),
governance: {
issuance: state.governance.issuance.length ? state.governance.issuance : ['By Sector / Area'],
woSize: state.governance.wosize,
woFormat: state.governance.woformat
},
woTypes: state.wpTypes.filter(t=>t.enabled).map(t=>({
name: t.name,
enabled: true,
notes: t.notes || '',
approval: t.approval || ''
})),
sources: state.sources.filter(s=>s.label),
field: {trackPlatform: state.platforms.tracking},
commissioning: {tool: state.platforms.commissioning},
quality: {
qcReq: state.quality.qcreq,
photo: state.quality.photo,
holdPoints: state.quality.hold,
cxTool: state.platforms.commissioning
},
sequence: state.sequence.filter(s=>s.label).map(s=>({
label: s.label,
kind: s.kind || 'step'
})),
costCodes: LABOR_COST_CODES,
constraints: state.constraints.map(c=>({name: c.name, description: c.description || ''}))
};
sopComplete = true;
updateProjectDisplay();
// Persist for the home page (green / "Review") and for the WP Creator tab.
try {
localStorage.setItem('wp_suite_sop', JSON.stringify(sop));
localStorage.setItem('wp_suite_state', JSON.stringify(state));
localStorage.setItem('wp_suite_sop_complete', '1');
} catch(e){}
track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length});
alert('✓ SOP Configuration Complete!\n\nSwitch to "Work Package Creation" to start creating Work Packages.');
// Hand the SOP to the embedded Work Package Creator and unlock its tab.
if(typeof onSOPReady === 'function') onSOPReady(sop);
}
// ── COMMENTS ──────────────────────────────────────────────────────────────────
function toggleComments(){
const panel = document.getElementById('comments-panel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
if(panel.style.display === 'block') loadStepComments();
}
function submitComment(){
const name = document.getElementById('commenter-name').value || 'Anonymous';
const text = document.getElementById('comment-text').value.trim();
if(!text){ alert('Please enter a comment.'); return; }
const comment = {
step: currentStep,
name: name,
text: text,
timestamp: new Date().toLocaleString()
};
allComments.push(comment);
localStorage.setItem('wp_suite_comments', JSON.stringify(allComments));
if(window.postFeedback) window.postFeedback({type:'sop_step_comment', ...comment});
document.getElementById('comment-text').value = '';
document.getElementById('commenter-name').value = '';
loadStepComments();
alert('✓ Comment submitted!');
}
function exportComments(){
const saved = localStorage.getItem('wp_suite_comments');
const data = saved ? JSON.parse(saved) : [];
if(!data.length){ alert('No comments to export yet.'); return; }
const payload = {app:'Work Package Suite', source:'sop', exportedAt:new Date().toISOString(), comments:data};
const blob = new Blob([JSON.stringify(payload,null,2)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'wp-suite-comments-sop-' + new Date().toISOString().slice(0,10) + '.json';
a.click();
setTimeout(()=>URL.revokeObjectURL(a.href), 1000);
}
function importComments(ev){
const f = ev.target.files && ev.target.files[0];
if(!f) return;
const r = new FileReader();
r.onload = ()=>{
try{
const inc = JSON.parse(r.result);
const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
if(!incoming.length){ alert('No comments found in that file.'); return; }
const saved = localStorage.getItem('wp_suite_comments');
allComments = saved ? JSON.parse(saved) : [];
const seen = new Set(allComments.map(c=>c.step+'|'+c.timestamp+'|'+c.text));
let added = 0;
incoming.forEach(c=>{ const k=c.step+'|'+c.timestamp+'|'+c.text; if(c.text && !seen.has(k)){ allComments.push(c); seen.add(k); added++; }});
localStorage.setItem('wp_suite_comments', JSON.stringify(allComments));
loadStepComments();
alert('Imported ' + added + ' comment' + (added===1?'':'s') + '.');
}catch(e){ alert('Could not read that file.'); }
ev.target.value = '';
};
r.readAsText(f);
}
function loadStepComments(){
const saved = localStorage.getItem('wp_suite_comments');
if(saved) allComments = JSON.parse(saved);
const stepComments = allComments.filter(c=>c.step===currentStep);
const list = document.getElementById('comments-list');
if(stepComments.length === 0){
list.innerHTML = '<div style="font-size:12px; color:var(--text-dim); font-style:italic;">No comments yet on this step.</div>';
}else{
list.innerHTML = stepComments.map(c=>`
<div style="padding:0.5rem; background:white; border:1px solid var(--border); border-radius:4px; margin-bottom:0.5rem;">
<div style="font-size:11px; color:var(--text-dim); margin-bottom:0.25rem;"><strong>${c.name}</strong> • ${c.timestamp}</div>
<div style="font-size:12px; color:var(--text);">${c.text.replace(/</g,'&lt;').replace(/>/g,'&gt;')}</div>
</div>
`).join('');
}
}
// ── USAGE ANALYTICS ─────────────────────────────────────────────────────────
// Lightweight usage analytics stored in localStorage so the tool owner can review
// engagement over time. No field VALUES are stored (field-edit events record only
// the field id), keeping captured data non-sensitive.
const ANALYTICS_KEY = 'wp_suite_analytics_v1';
let _stepEnter = Date.now();
const _session = 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2,6);
function analyticsLoad(){
try { return JSON.parse(localStorage.getItem(ANALYTICS_KEY)) || {events:[]}; }
catch(e){ return {events:[]}; }
}
function analyticsSave(data){
try { localStorage.setItem(ANALYTICS_KEY, JSON.stringify(data)); }
catch(e){ /* storage unavailable — degrade silently */ }
}
function track(event, detail){
try {
const data = analyticsLoad();
data.events.push({ ts: new Date().toISOString(), session: _session, event, detail: detail||null });
if(data.events.length > 5000) data.events = data.events.slice(-5000);
analyticsSave(data);
} catch(e){}
}
function trackStepDwell(){
const ms = Date.now() - _stepEnter;
if(ms > 400 && ms < 1000*60*60) track('step_dwell', {step: currentStep, ms});
_stepEnter = Date.now();
}
function analyticsSummary(){
const data = analyticsLoad();
const byEvent = {}, byStep = {}, dwell = {}, sessions = new Set();
data.events.forEach(e=>{
byEvent[e.event] = (byEvent[e.event]||0)+1;
sessions.add(e.session);
if(e.event==='step_view' && e.detail) byStep[e.detail.step]=(byStep[e.detail.step]||0)+1;
if(e.event==='step_dwell' && e.detail){ dwell[e.detail.step]=(dwell[e.detail.step]||0)+e.detail.ms; }
});
return {total:data.events.length, sessions:sessions.size, byEvent, byStep, dwell, first:data.events[0]?.ts, last:data.events[data.events.length-1]?.ts};
}
function showAnalytics(){
const s = analyticsSummary();
const fmtMin = ms => (ms/60000).toFixed(1)+' min';
let txt = `USAGE LOGS\n\nSessions: ${s.sessions} Events: ${s.total}\nRange: ${s.first?new Date(s.first).toLocaleString():'—'}${s.last?new Date(s.last).toLocaleString():'—'}\n\nStep views:\n`;
for(let i=1;i<=10;i++) txt += ` Step ${i}: ${s.byStep[i]||0} views` + (s.dwell[i]?`, ${fmtMin(s.dwell[i])} total`:'') + `\n`;
txt += `\nActions:\n`;
Object.keys(s.byEvent).filter(k=>!['step_view','step_dwell','field_edit'].includes(k)).forEach(k=> txt += ` ${k}: ${s.byEvent[k]}\n`);
txt += ` field edits: ${s.byEvent['field_edit']||0}\n`;
txt += `\nDownload full event log as JSON?`;
if(confirm(txt)) downloadAnalytics();
}
function downloadAnalytics(){
const data = analyticsLoad();
const blob = new Blob([JSON.stringify(data,null,2)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'wp-suite-usage-' + new Date().toISOString().slice(0,10) + '.json';
a.click();
URL.revokeObjectURL(a.href);
track('analytics_exported');
}

View File

@@ -0,0 +1,538 @@
:root {
--primary: #2563eb;
--primary-light: #dbeafe;
--success: #16a34a;
--warning: #ea580c;
--danger: #dc2626;
--text: #1f2937;
--text-light: #6b7280;
--text-dim: #9ca3af;
--border: #e5e7eb;
--bg: #f9fafb;
--bg-card: #ffffff;
--shadow: 0 1px 3px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 25px rgba(0,0,0,0.1);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
color: var(--text);
background: var(--bg);
line-height: 1.5;
}
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* HEADER */
.header {
background: #ffffff;
color: var(--text);
padding: 1.5rem 2rem;
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;
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: var(--text);
font-weight: 700;
font-size: 14px;
transition: opacity 0.2s;
}
.logo:hover { opacity: 0.7; }
.logo-icon {
width: 36px;
height: 36px;
background: var(--primary-light);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
}
.header-title {
font-size: 24px;
font-weight: 700;
margin-bottom: 0;
color: var(--text);
}
.header-subtitle {
font-size: 13px;
color: var(--text-light);
min-height: 20px;
}
.header-right {
display: flex;
align-items: center;
gap: 1.5rem;
}
.header-button {
padding: 0.5rem 1rem;
background: var(--bg);
color: var(--primary);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s;
}
.header-button:hover {
background: var(--primary-light);
border-color: var(--primary);
}
.step-counter {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text-light);
padding: 0.4rem 0.8rem;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
/* MAIN NAVIGATION */
.main-nav {
display: flex;
gap: 0.5rem;
padding: 1rem 2rem;
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;
cursor: pointer;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
transition: all 0.2s;
}
.nav-tab:hover {
border-color: var(--primary);
color: var(--primary);
}
.nav-tab.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.tab-icon { font-size: 16px; }
/* CONTENT AREA */
.content-area {
flex: 1;
padding: 2rem;
max-width: 1000px;
margin: 0 auto;
width: 100%;
}
.tool {
display: none;
}
.tool.active {
display: block;
}
/* STEP NAV */
.step-nav {
margin-bottom: 2rem;
overflow-x: auto;
}
.steps-container {
display: flex;
gap: 0.5rem;
min-width: min-content;
padding: 0.5rem;
}
.step-item {
padding: 0.75rem 1rem;
border-radius: 6px;
background: var(--bg);
border: 2px solid var(--border);
cursor: pointer;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
transition: all 0.2s;
}
.step-item:hover { background: var(--primary-light); border-color: var(--primary); }
.step-item.active { background: var(--primary); color: white; border-color: var(--primary); }
/* STEP CONTENT */
.step-content {
background: var(--bg-card);
padding: 2rem;
border-radius: 8px;
box-shadow: var(--shadow);
margin-bottom: 2rem;
}
.step { display: none; }
.step h2 {
font-size: 20px;
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--text);
}
.notice {
font-size: 13px;
color: var(--text-light);
background: var(--primary-light);
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 1.5rem;
border-left: 4px solid var(--primary);
}
/* FIELDS */
.field-grid {
display: grid;
gap: 1.5rem;
}
.field-grid.col1 { grid-template-columns: 1fr; }
.field {
display: flex;
flex-direction: column;
}
.field label {
font-size: 13px;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text);
}
.field input,
.field select,
.field textarea {
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
font-family: inherit;
color: var(--text);
background: var(--bg);
transition: border-color 0.2s;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
.field small {
font-size: 12px;
color: var(--text-dim);
margin-top: 0.25rem;
}
/* ROLES */
.required-roles {
display: flex;
flex-direction: column;
gap: 1rem;
}
.role-required {
display: flex;
align-items: center;
padding: 1rem;
background: var(--bg);
border-radius: 6px;
border: 1px solid var(--border);
}
.role-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
}
.role-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.role-checkbox label {
margin: 0;
font-weight: 600;
cursor: pointer;
}
/* WP TYPES */
#wp-types-table { overflow-x: auto; }
.wp-type-row {
display: grid;
grid-template-columns: 1.2fr 90px 2fr 1.5fr;
gap: 1rem;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg);
border-radius: 6px;
margin-bottom: 0.5rem;
border: 1px solid var(--border);
}
.wp-types-header {
display: grid;
grid-template-columns: 1.2fr 90px 2fr 1.5fr;
gap: 1rem;
padding: 0.5rem 1rem;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-light);
margin-bottom: 0.5rem;
}
/* BUTTONS */
.add-btn {
padding: 0.75rem 1.25rem;
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.add-btn:hover { background: #1d4ed8; }
.nav-btn {
padding: 0.75rem 1.5rem;
background: var(--bg);
border: 2px solid var(--border);
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.nav-btn:hover { border-color: var(--primary); color: var(--primary); }
.nav-btn.primary {
background: var(--success);
color: white;
border-color: var(--success);
}
.nav-btn.primary:hover { background: #15803d; border-color: #15803d; }
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* NAVIGATION */
.step-navigation {
display: flex;
gap: 1rem;
justify-content: space-between;
padding: 1.5rem;
background: var(--bg-card);
border-radius: 8px;
box-shadow: var(--shadow);
}
/* COMMENTS */
.comments-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 2px solid var(--border);
}
.comments-toggle {
padding: 0.5rem 1rem;
background: var(--primary-light);
color: var(--primary);
border: 1px solid var(--primary);
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.comments-toggle:hover { background: var(--primary); color: white; }
/* CONSTRUCTION SEQUENCE (drag & drop) */
#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;
box-shadow: var(--shadow); transition: border-color .12s, box-shadow .12s, opacity .12s;
}
.seq-step:hover { border-color: var(--primary); }
.seq-step.dragging { opacity: .35; border-style: dashed; }
.seq-step.drag-over { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-light); }
.seq-handle { cursor: grab; color: var(--text-dim); font-size: 16px; line-height: 1; user-select: none; flex-shrink: 0; }
.seq-handle:active { cursor: grabbing; }
.seq-num {
width: 24px; height: 24px; flex-shrink: 0; border-radius: 50%; background: var(--primary-light); color: var(--primary);
font-size: 11px; font-weight: 600; display: flex; align-items: center; justify-content: center;
}
.seq-step input.seq-label {
border: 1px solid transparent; background: transparent; font-size: 14px; padding: 5px 8px; color: var(--text); flex: 1; border-radius: 4px;
}
.seq-step input.seq-label:focus { background: var(--bg); border-color: var(--primary); outline: none; }
.seq-del {
background: var(--danger); color: white; border: none; border-radius: 4px;
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 .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;
font-size: 9px; font-weight: 600; letter-spacing: .08em; white-space: nowrap;
}
/* STEP COMMENTS DROPDOWN */
.comments-dropdown {
position: fixed;
top: 80px;
right: 2rem;
width: 360px;
max-width: calc(100vw - 2rem);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow-lg);
padding: 1.25rem;
z-index: 1200;
}
.comments-dropdown-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.comments-dropdown-close {
background: none;
border: none;
font-size: 16px;
cursor: pointer;
color: var(--text-light);
line-height: 1;
}
.comments-dropdown-close:hover { color: var(--text); }
/* MODAL */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--bg-card);
border-radius: 8px;
padding: 2rem;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
padding-bottom: 1rem;
}
.modal-header h3 { font-size: 18px; margin: 0; }
.modal-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: var(--text-dim);
}
.modal-close:hover { color: var(--text); }
/* UTILITY */
.sub-heading {
font-size: 14px;
font-weight: 700;
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* RESPONSIVE */
@media (max-width: 768px) {
.header { flex-direction: column; text-align: center; gap: 1rem; }
.main-nav { flex-wrap: wrap; }
.content-area { padding: 1rem; }
.step-content { padding: 1rem; }
.wp-type-row { grid-template-columns: 1fr; }
.wp-types-header { display: none; }
.step-navigation { flex-direction: column; }
}

View File

@@ -0,0 +1,345 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Work Package Suite</title>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="work-package-suite-styles.css">
</head>
<body>
<div class="app-container">
<!-- HEADER -->
<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;">
</a>
<div>
<div class="header-title">Work Package Suite</div>
<div class="header-subtitle" id="project-display"></div>
</div>
</div>
<div class="header-right">
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load example SOP data">⭐ Load Sample</button>
<button class="header-button" onclick="toggleComments()" title="View and add comments for the current step">💬 Step Comments</button>
<button class="header-button" onclick="showAnalytics()" title="Review usage logs for this tool">📊 Usage Logs</button>
<span class="step-counter"><span id="current-step">1</span> / <span id="total-steps">10</span></span>
</div>
</div>
<!-- MAIN NAVIGATION -->
<div class="main-nav">
<button class="nav-tab active" data-tab="sop" onclick="switchTool('sop')">
<span class="tab-icon">⚙️</span> SOP Configuration
</button>
<button class="nav-tab" data-tab="wp" onclick="switchTool('wp')">
<span class="tab-icon">📋</span> Work Package Creation
</button>
</div>
<!-- CONTENT AREA -->
<div class="content-area">
<!-- ═════════════════════════════════════════════════════════════════════ -->
<!-- SOP CONFIGURATION TOOL -->
<!-- ═════════════════════════════════════════════════════════════════════ -->
<div id="tool-sop" class="tool active">
<!-- SOP STEP INDICATORS -->
<div class="step-nav">
<div class="steps-container">
<div class="step-item active" data-step="1" onclick="goToStep(1)">Project</div>
<div class="step-item" data-step="2" onclick="goToStep(2)">Team</div>
<div class="step-item" data-step="3" onclick="goToStep(3)">Sign-Offs</div>
<div class="step-item" data-step="4" onclick="goToStep(4)">WP Types</div>
<div class="step-item" data-step="5" onclick="goToStep(5)">Governance</div>
<div class="step-item" data-step="6" onclick="goToStep(6)">Quality</div>
<div class="step-item" data-step="7" onclick="goToStep(7)">Platforms</div>
<div class="step-item" data-step="8" onclick="goToStep(8)">Sequence</div>
<div class="step-item" data-step="9" onclick="goToStep(9)">Constraints</div>
<div class="step-item" data-step="10" onclick="goToStep(10)">Sources</div>
</div>
</div>
<!-- SOP STEP CONTENT -->
<div class="step-content">
<!-- STEP 1: PROJECT BASICS -->
<div class="step" id="sop-step-1" style="display: block;">
<h2>1. Project Basics</h2>
<div class="notice">Define the core project information that will be inherited by all Work Packages.</div>
<div class="field-grid">
<div class="field">
<label>Project Name *</label>
<input type="text" id="proj_name" placeholder="e.g., Micron — INC Construction Work Packages" oninput="updateProjectDisplay()">
</div>
<div class="field">
<label>Project Number *</label>
<input type="text" id="proj_number" placeholder="e.g., 26-67-008">
</div>
<div class="field">
<label>Client Name *</label>
<input type="text" id="proj_client" placeholder="e.g., Micron Technology, Inc.">
</div>
<div class="field">
<label>Division / Sector *</label>
<input type="text" id="proj_division" placeholder="e.g., Semiconductor, Oil & Gas, Data Center">
</div>
<div class="field">
<label>Site Location *</label>
<input type="text" id="proj_site" placeholder="e.g., Boise, ID — Fab 7">
</div>
</div>
</div>
<!-- STEP 2: PROJECT TEAM -->
<div class="step" id="sop-step-2" style="display: none;">
<h2>2. Project Team Leadership</h2>
<div class="notice">Name the key project leaders. These are informational and will appear in SOP exports.</div>
<div class="field-grid">
<div class="field">
<label>Project Manager (PM)</label>
<input type="text" id="proj_pm" placeholder="e.g., Mariano Sanchez">
</div>
<div class="field">
<label>Assistant Project Manager (APM)</label>
<input type="text" id="proj_apm" placeholder="e.g., Assistant PM name">
</div>
<div class="field">
<label>Construction Manager (CM)</label>
<input type="text" id="proj_cm" placeholder="e.g., K. Boyd">
</div>
<div class="field">
<label>Quality Manager (QM)</label>
<input type="text" id="proj_qm" placeholder="e.g., D. Nguyen">
</div>
</div>
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
<div class="sub-heading">Additional Team Members (optional)</div>
<div id="team-members-list" style="margin-top: 1rem;"></div>
<button class="add-btn" onclick="addTeamMember()">+ Add Team Member</button>
</div>
</div>
<!-- STEP 3: SIGN-OFF ROLES -->
<div class="step" id="sop-step-3" style="display: none;">
<h2>3. Required Sign-Off Roles</h2>
<div class="notice">Superintendent and Foreman are required. Add other roles as needed for your project structure.</div>
<div class="required-roles">
<div class="role-required">
<div class="role-checkbox">
<input type="checkbox" id="role_super" checked disabled>
<label>Superintendent *</label>
</div>
<input type="text" id="role_super_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;">
</div>
<div class="role-required">
<div class="role-checkbox">
<input type="checkbox" id="role_foreman" checked disabled>
<label>Foreman *</label>
</div>
<input type="text" id="role_foreman_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;">
</div>
</div>
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
<div class="sub-heading">Optional Additional Roles</div>
<div id="optional-roles-list" style="margin-top: 1rem;"></div>
<button class="add-btn" onclick="addOptionalRole()">+ Add Role</button>
</div>
</div>
<!-- STEP 4: WORK PACKAGE TYPES -->
<div class="step" id="sop-step-4" style="display: none;">
<h2>4. Work Package Types</h2>
<div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion.</div>
<div id="wp-types-table" style="margin-top: 1.5rem;"></div>
</div>
<!-- STEP 5: GOVERNANCE -->
<div class="step" id="sop-step-5" style="display: none;">
<h2>5. Governance & WP Numbering</h2>
<div class="notice">Define how Work Packages are formatted, sized, and issued on this project.</div>
<div class="field-grid">
<div class="field">
<label>Work Package Number Format *</label>
<input type="text" id="gov_woformat" placeholder="e.g., WP##-[Sector]-[TYPE]">
<small>Use ## for counter, [Sector] [TYPE] as variables</small>
</div>
<div class="field">
<label>Typical WP Size</label>
<input type="text" id="gov_wosize" placeholder="e.g., 35 days or 4080 hours">
</div>
<div class="field">
<label>Issuance Strategy</label>
<select id="gov_issuance" multiple size="3">
<option selected>By Sector / Area</option>
<option>By Discipline</option>
<option>By Phase / Sequence</option>
<option>By Resource Availability</option>
</select>
<small>Hold Ctrl to select multiple</small>
</div>
</div>
</div>
<!-- STEP 6: QUALITY -->
<div class="step" id="sop-step-6" style="display: none;">
<h2>6. Quality & Inspection Strategy</h2>
<div class="notice">Establish project-wide quality expectations that cascade to every Work Package.</div>
<div class="field-grid col1">
<div class="field">
<label>QC Required? *</label>
<select id="qual_qcreq">
<option>Yes</option>
<option>Yes — Detailed inspection items</option>
<option>Yes — Sample / Spot-check</option>
<option>No</option>
</select>
</div>
<div class="field">
<label>Photo / Documentation Standard</label>
<select id="qual_photo">
<option selected>Key checkpoints only</option>
<option>Every step documented</option>
<option>As-built final condition only</option>
<option>None</option>
</select>
</div>
<div class="field">
<label>Hold Points & Witness Requirements</label>
<textarea id="qual_hold" rows="3" placeholder="e.g., HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger test before energization."></textarea>
</div>
</div>
</div>
<!-- STEP 7: PLATFORMS -->
<div class="step" id="sop-step-7" style="display: none;">
<h2>7. Tracking & Commissioning Platforms</h2>
<div class="notice">Select the tools used for construction tracking and commissioning. These can be the same or different systems.</div>
<div class="field-grid">
<div class="field">
<label>Construction Tracking Platform *</label>
<select id="plat_tracking">
<option>CxAlloy</option>
<option>Procore</option>
<option>Autodesk Construction Cloud (ACC)</option>
<option>Other</option>
</select>
</div>
<div class="field">
<label>Commissioning Tool *</label>
<select id="plat_commissioning">
<option selected>CxAlloy</option>
<option>Procore</option>
<option>Autodesk Construction Cloud (ACC)</option>
<option>Other</option>
</select>
</div>
</div>
</div>
<!-- STEP 8: SEQUENCE -->
<div class="step" id="sop-step-8" style="display: none;">
<h2>8. Construction Sequence</h2>
<div class="notice">Comes pre-sequenced with a typical install flow. Drag the ⠿ handle to reorder steps, edit labels inline, add a QC hold gate, or remove any step.</div>
<div id="sequence-list" style="margin-top: 1rem;"></div>
<div style="display:flex; gap:0.5rem; margin-top:1rem; flex-wrap:wrap;">
<input type="text" id="seq-add-input" placeholder="New step name" onkeydown="if(event.key==='Enter'){addSequenceStep();}" style="flex:1; min-width:200px; padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<button class="add-btn" onclick="addSequenceStep()">+ Add Step</button>
<button class="add-btn" onclick="addSequenceGate()" style="background:var(--warning);">◆ Add QC Hold</button>
</div>
</div>
<!-- STEP 9: CONSTRAINTS -->
<div class="step" id="sop-step-9" style="display: none;">
<h2>9. Release Gate Constraints</h2>
<div class="notice">Constraints are readiness items that must be cleared before a WP can be issued.</div>
<div style="margin-bottom: 2rem;">
<div class="sub-heading">Standard AWP Constraints (Vol II §2.3.2)</div>
<div id="standard-constraints" style="margin-top: 1rem;"></div>
</div>
<div style="border-top: 1px solid var(--border); padding-top: 1.5rem;">
<div class="sub-heading">Custom Constraints (Optional)</div>
<div id="custom-constraints-list" style="margin-top: 1rem;"></div>
<button class="add-btn" onclick="showConstraintLibrary()">+ Add Custom Constraint</button>
</div>
</div>
<!-- STEP 10: SOURCES -->
<div class="step" id="sop-step-10" style="display: none;">
<h2>10. Engineering Sources & References</h2>
<div class="notice">Link to key documents and systems that WP authors will reference.</div>
<div id="sources-list" style="margin-top: 1rem;"></div>
<button class="add-btn" onclick="addSource()">+ Add Source</button>
</div>
</div>
<!-- SOP NAVIGATION -->
<div class="step-navigation">
<button class="nav-btn" id="sop-prev-btn" onclick="previousStep()">← Back</button>
<button class="nav-btn" id="sop-next-btn" onclick="nextStep()">Next →</button>
<button class="nav-btn primary" id="sop-complete-btn" onclick="completeSOP()" style="display: none;">✓ SOP Complete</button>
</div>
</div>
<!-- ═════════════════════════════════════════════════════════════════════ -->
<!-- WORK PACKAGE CREATION TOOL -->
<!-- ═════════════════════════════════════════════════════════════════════ -->
<div id="tool-wp" class="tool">
<!-- Shown until the SOP is complete -->
<div id="wp-gate" style="padding: 3rem 2rem; text-align: center;">
<h2>📋 Work Package Creation</h2>
<p style="color: var(--text-light); margin: 1rem 0;">Complete the SOP Configuration first to enable Work Package creation. Once the SOP is finished, the full creator loads here with your project defaults pre-populated.</p>
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">← Go to SOP Configuration</button>
</div>
<!-- The real Work Package Creator, embedded once the SOP is complete -->
<iframe id="wp-frame" title="Work Package Creator" style="display:none; width:100%; border:0; min-height: calc(100vh - 200px);"></iframe>
</div>
</div>
</div>
<!-- STEP COMMENTS DROPDOWN (toggled from header) -->
<div id="comments-panel" class="comments-dropdown" style="display: none;">
<div class="comments-dropdown-header">
<strong>💬 Step Comments</strong>
<button onclick="toggleComments()" class="comments-dropdown-close" title="Close"></button>
</div>
<div style="margin-bottom: 1rem;">
<label style="font-weight: 600; font-size: 13px;">Your Name (optional)</label>
<input type="text" id="commenter-name" placeholder="e.g., Bill Clarida" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem;">
</div>
<div style="margin-bottom: 1rem;">
<label style="font-weight: 600; font-size: 13px;">Feedback</label>
<textarea id="comment-text" rows="3" placeholder="Your feedback here..." style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem; font-family: inherit;"></textarea>
</div>
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
<button onclick="submitComment()" style="background: var(--primary); color: white; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Submit</button>
<button onclick="exportComments()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">⤓ Export</button>
<button onclick="document.getElementById('sop-comments-import').click()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">⤒ Import</button>
<input type="file" id="sop-comments-import" accept="application/json" style="display:none" onchange="importComments(event)">
</div>
<div id="comments-list" style="margin-top: 1rem; max-height: 240px; overflow-y: auto;"></div>
</div>
<!-- CONSTRAINT LIBRARY MODAL -->
<div id="constraint-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>Add Custom Constraint</h3>
<button class="modal-close" onclick="closeConstraintModal()"></button>
</div>
<div id="constraint-library" style="max-height: 400px; overflow-y: auto; margin: 1rem 0;"></div>
<button class="nav-btn" onclick="closeConstraintModal()">Done</button>
</div>
</div>
<script src="feedback-config.js"></script>
<script src="work-package-suite-app.js"></script>
</body>
</html>

669
html/wp-creation-app.js Normal file
View File

@@ -0,0 +1,669 @@
/* Work Package (IWP) form — the downstream installation work package record.
Parameters (numbering, types, QC, sources) are inherited from the project SOP
exported by the Configuration tool. Implements the AWP constraint checklist +
release gate from IR 272-2 Vol II. */
// ── SAMPLE SOP (same shape the Configuration tool exports) ───────────────────
const SAMPLE_SOP = {
meta:{tool:'Work Package Configuration', sample:true},
project:{name:'Micron — INC Construction Work Packages', number:'26-67-008', client:'Micron Technology, Inc.', division:'Semiconductor', pm:'Nick Siegfried', cm:'K. Boyd', qm:'D. Nguyen', site:'Boise, ID — Fab'},
roles:[{role:'General Foreman',name:'M. Torres'},{role:'Superintendent',name:'K. Boyd'},{role:'Safety Manager / Lead',name:'A. Reyes'},{role:'Quality Manager',name:'D. Nguyen'},{role:'Planner',name:'L. Graver'}],
governance:{ issuance:['By Sector / Area','By Discipline'], woSize:'35 days', woFormat:'WP##-[Sector]-[TYPE]' },
woTypes:[
{name:'Conduit Install', enabled:true}, {name:'Tray Install', enabled:true},
{name:'Wire Pull', enabled:true}, {name:'Terminations', enabled:true},
{name:'Instrument Install', enabled:true}, {name:'Panel Install', enabled:true},
],
sources:[
{label:'Design Drawings',system:'Procore',notes:'',link:'https://us02.procore.com/562949954073428/project/documents/folders/drawings'},
{label:'Specifications',system:'Procore',notes:'',link:'https://us02.procore.com/562949954073428/project/documents/folders/specs'},
{label:'IO List',system:'controls.dev',notes:'',link:'https://controls.dev/io'},
{label:'Cable Schedule',system:'SharePoint',notes:'',link:'https://primecontrols.sharepoint.com/cable-schedule'},
],
field:{ trackPlatform:'CxAlloy' },
quality:{ qcReq:'Yes', qcScope:'Detailed inspection items', photo:'Key checkpoints only', cxTool:'CxAlloy', holdPoints:'HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.' },
sequence:[{label:'Layout',kind:'step'},{label:'Conduit Install',kind:'step'},{label:'Tray Install',kind:'step'},{label:'Wire Pull',kind:'step'},{label:'Terminations',kind:'step'},{label:'Commissioning',kind:'step'}],
costCodes:['5100 - Rough In','5200 - Wire Pull','5300 - Terminations','5400 - Instrumentation'],
};
// Standard AWP constraint set (Vol I §2.3.2; Vol II IWP checklists)
const DEFAULT_CONSTRAINTS = ['Safety & Permitting','Quality Control / Inspection','IFC Drawings & Specs','Schedule','Materials (on site, bagged & tagged)','Prefabrication','Work Access & Laydown','Craft Availability','Construction Equipment & Tools','Scaffolding / Access Equipment'];
const SIGNOFF_ROLES = ['Planner','Superintendent','HSE Professional','Quality Representative','Work Foreman'];
const STATUS_ORDER = ['Draft','Scheduled','Issued','In Progress','QC','Closed'];
const ISSUED_IDX = STATUS_ORDER.indexOf('Issued');
// Acumatica cost codes (comment 10) — code|description
const COST_CODES = ['1000|Project Management','2000|Design and Development','2100|Design','2110|Control System Design','2120|Instrument Design','2130|Electrical Design','2140|Panel Design','2141|Panel Design Rework','2150|BIM','2151|BIM Rework','2160|Documentation','2200|Development','2210|PLC Programming','2220|OIT Programming','2230|SCADA Programming','2240|Simulation Development','2290|Programming Subcontract','2300|Customer Training','3000|Operational Technology','3100|OT Design','3200|Rack Assembly','3300|Network Configuration','3400|Computer Configuration','4000|Construction','4010|Instruments Install','4020|Network & Computers Install','4040|PLC Install','4050|Panel Install','4060|Electrical Install','4070|Mechanical Install','4080|Security Install','4090|Radio Install','4100|Commissioning','4940|Contract Labor','4960|Electrical Subcontract','4970|Mechanical Subcontract','4980|Security Subcontract','4990|Other/Radio Subcontract','5010|Instrument Material','5020|Network & Computers Material','5030|Software Material','5040|PLC Material','5050|Panel Material','5060|Electrical Material','5070|Mechanical Material','5080|Security Material','5090|Radio Material','6000|Production','7000|Quality','7100|Panel Quality Control','7200|Factory Acceptance Testing','7300|Site Acceptance Testing','8000|Safety','9000|Administration','9100|Warranty','9200|Freight','9300|Travel','9350|Jobsite Costs/Consumable/Other Direct Costs','9400|Contingency','9450|Other','9500|Accrued Incentive Compensation','9600|Sales Tax','9650|Job Cost Labor Burden','9700|Bonding','9800|Non-Billable Compensation'];
// Acumatica allowed units of measure (comment 15) — common first
const ACU_UNITS = ['EA','EACH','FT','M','METER','HR','DAYS','MINUTE','KG','LITER','CASE','LOT','LS','PK','PACK','PALLET','PIECE','BOTTLE','CAN'];
// Example built work package (comment 4 / "Load Example") — WP02 export
const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":"FAB 1P Horn Strobe Conduit","type":"Conduit Install","system":"Chilled Water","location":"FAB / LVL 1 / Sect P","cost":"4060","wbs":"2001","assignees":"David Velazquez (Catapult Solutions Group), Jesus Casiano-Figueroa (Prime Controls)","distribution":"Bill Clarida (Prime Controls), Sean Tolley (Prime Controls), Jefferson Dufriend (Prime Controls)","due":"2026-06-18","spec":"26_05_33_31 - Conduit","desc":"1P Conduit run for horns and strobes","work":"Layout conduit route\nUsing ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.\nWhen complete initiate inspection with Prime QAQC","workSteps":["Layout conduit route","Using ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.","When complete initiate inspection with Prime QAQC"],"numberDims":{"Sector":"1P","Discipline":"ELEC"},"hours":"60","seq":"Layout","materials":[{"qty":"400","unit":"FT","desc":"3/4\" EMT"},{"qty":"300","unit":"FT","desc":"1\" EMT"},{"qty":"50","unit":"FT","desc":"7/8\" strut"},{"qty":"50","unit":"FT","desc":"1-5/8\" strut"},{"qty":"8","unit":"EA","desc":"4x4x4 NEMA 3 Box"},{"qty":"2","unit":"EA","desc":"1\" C-Type Conduit Body"},{"qty":"1","unit":"EA","desc":"1\" T-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" C-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" T-Type Conduit Body"},{"qty":"3","unit":"EA","desc":"3/4\" EMT LB & Cover"},{"qty":"6","unit":"EA","desc":"Hoffman F44GCPNK Horn Strobe Box"},{"qty":"2","unit":"EA","desc":"EMO 2x4 Box"},{"qty":"4","unit":"EA","desc":"1\" Bond Bushing w/ Lug"},{"qty":"4","unit":"EA","desc":"3/4\" Bond Bushing w/ Lug"},{"qty":"50","unit":"EA","desc":"1/4\"x3\" Toggle Bolt"},{"qty":"100","unit":"EA","desc":"Drywall Anchor"},{"qty":"5","unit":"EA","desc":"1\" to 3/4\" threaded reducer"}],"attachments":[{"doc":"EE-1YA-2P-5_ HPM PANEL CALLOUT 05","rev":"0","link":"https://us02.procore.com/webclients/host/companies/562949953431440/projects/562949954073428/tools/document-viewer/prostore/562950644886790"}],"kitStatus":"Open","kitOwner":"Ian Spielburg","kitDate":"2026-06-15","mimoTime":"2026-06-11T10:30","mimoLoc":"04","constraints":[{"name":"Safety & Permitting","status":"cleared","comment":""},{"name":"Quality Control / Inspection","status":"cleared","comment":""},{"name":"IFC Drawings & Specs","status":"cleared","comment":""},{"name":"Schedule","status":"cleared","comment":""},{"name":"Materials (on site, bagged & tagged)","status":"cleared","comment":""},{"name":"Prefabrication","status":"cleared","comment":""},{"name":"Work Access & Laydown","status":"cleared","comment":""},{"name":"Craft Availability","status":"cleared","comment":""},{"name":"Construction Equipment & Tools","status":"open","comment":"Boom lift not yet on site"},{"name":"Scaffolding / Access Equipment","status":"cleared","comment":""}],"qc":"Yes — Detailed inspection items","photo":"Key checkpoints only","hold":"HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.","overrides":{"wp_photo":"Change Order"},"signoffs":[{"role":"Planner","name":"L. Graver","date":"2026-06-10","signed":true,"dateReason":""},{"role":"Superintendent","name":"K. Boyd","date":"2026-06-10","signed":true,"dateReason":""},{"role":"HSE Professional","name":"A. Reyes","date":"","signed":false,"dateReason":""},{"role":"Quality Representative","name":"D. Nguyen","date":"","signed":false,"dateReason":""},{"role":"Work Foreman","name":"M. Torres","date":"","signed":false,"dateReason":""}],"holds":[],"actualHrs":"54","installedQty":"200 ft","redlines":"Conduit size changed. need to update model","lessons":"Prepping trapeze hangers with conduit straps saved time","project":"Micron — INC Construction Work Packages","track":"CxAlloy"};
// ── STATE ────────────────────────────────────────────────────────────────────
let SOP=null, editingId=null, numberDirty=false;
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[];
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
let prevStatus='Draft';
let devMode=false; // comment 7: pauses usage tracking during review
let savedPackages=[];
let currentView='Work Package Form';
// ── HELPERS ──────────────────────────────────────────────────────────────────
function gv(id){ return document.getElementById(id)?.value?.trim() || ''; }
function esc(v){ if(v==null) return ''; return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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; }
function typeCode(name){ return (name||'').replace(/[^a-z0-9]+/gi,''); }
function linkify(v){ if(!v) return ''; return esc(v).replace(/(https?:\/\/[^\s]+)/g,u=>`<a href="${u}" target="_blank" rel="noopener">${u}</a>`); }
function toast(msg){ let t=document.getElementById('toast'); if(!t){ t=document.createElement('div'); t.id='toast'; document.body.appendChild(t); } t.textContent=msg; t.classList.add('show'); clearTimeout(toast._t); toast._t=setTimeout(()=>t.classList.remove('show'),2200); }
function getRadio(name){ const s=document.querySelector(`.radio-pill.selected input[name="${name}"]`); return s?.closest('.radio-pill')?.dataset?.val||''; }
function setRadio(name,val){ document.querySelectorAll(`.radio-pill input[name="${name}"]`).forEach(i=>{const p=i.closest('.radio-pill'); const on=p.dataset.val===val; p.classList.toggle('selected',on); i.checked=on;}); }
function enabledTypes(){ return ((SOP&&SOP.woTypes)||[]).filter(t=>t.enabled!==false); }
function constraintNames(){ return (SOP&&Array.isArray(SOP.constraints)&&SOP.constraints.length)?SOP.constraints:DEFAULT_CONSTRAINTS; }
function nextSeq(){ return savedPackages.length+1; }
// ── SOP LOADING ──────────────────────────────────────────────────────────────
function loadSampleSOP(){ SOP=JSON.parse(JSON.stringify(SAMPLE_SOP)); applySOP(); newPackage(); toast('Sample SOP loaded — ' + (SOP.project&&SOP.project.name||'project')); track('sample_loaded'); }
function importSOP(ev){
const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader();
r.onload=()=>{ try{ const d=JSON.parse(r.result); if(!d.woTypes){ alert('That file does not look like an SOP export from the Configuration tool.'); return; }
SOP=d; applySOP(); newPackage(); toast('Loaded SOP — '+((d.project&&d.project.name)||'project')); track('sop_imported');
}catch(e){ alert('Could not read that file.'); } ev.target.value=''; };
r.readAsText(f);
}
function applySOP(){
renderCtxBar(); buildTypePicker(); buildCostCodes(); buildSequencePicker(); buildNumberDims();
renderSopRefLinks(); renderSpecFolderLink();
// SOP-inherited Quality fields — populated then locked (editable only with a logged reason)
if(SOP.quality){
document.getElementById('wp_qc').value=[SOP.quality.qcReq,SOP.quality.qcScope].filter(Boolean).join(' — ');
document.getElementById('wp_photo').value=SOP.quality.photo||'';
document.getElementById('wp_hold').value=SOP.quality.holdPoints||'';
}
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
const hint=document.getElementById('wp_number_hint'); hint.textContent = SOP.governance&&SOP.governance.woFormat ? 'auto-built · format: '+SOP.governance.woFormat : '';
buildConstraints(); buildSignoffs();
if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); }
updateNumber(); updateReleaseBanner();
}
function buildCostCodes(){
const sel=document.getElementById('wp_cost'); const cur=sel.value;
sel.innerHTML=`<option value="">Select cost code…</option>`+COST_CODES.map(c=>{const [code,desc]=c.split('|'); return `<option value="${code}">${code}${esc(desc)}</option>`;}).join('');
if(cur) sel.value=cur;
}
// ── SOP-LOCKED QUALITY FIELDS (comment 3) ────────────────────────────────────
function sopValueFor(id){
if(!SOP||!SOP.quality) return '';
if(id==='wp_qc') return [SOP.quality.qcReq,SOP.quality.qcScope].filter(Boolean).join(' — ');
if(id==='wp_photo') return SOP.quality.photo||'';
if(id==='wp_hold') return SOP.quality.holdPoints||'';
return '';
}
function lockQuality(id){
const el=document.getElementById(id); if(!el) return;
const fromSOP = !!(SOP&&SOP.quality) && !pkgOverrides[id];
el.readOnly = fromSOP;
el.classList.toggle('sop-inherited', fromSOP); // comment 8: blue-tinted SOP field
el.classList.toggle('locked-field', false);
const wrap=el.closest('.field'); const btn=wrap&&wrap.querySelector('.lock-edit'); const note=wrap&&wrap.querySelector('.override-note');
if(btn) btn.textContent = fromSOP ? '🔒 Edit (reason required)' : '↺ Revert to SOP';
if(note) note.innerHTML = pkgOverrides[id] ? `Overridden: ${esc(pkgOverrides[id])}` : '';
}
function editQuality(id){
if(pkgOverrides[id]){ // currently overridden -> revert to SOP value
delete pkgOverrides[id];
document.getElementById(id).value=sopValueFor(id);
lockQuality(id); track('quality_reverted',{field:id}); return;
}
const reason=prompt('This field is set by the project SOP. Enter the reason for overriding it on this package:');
if(reason===null) return;
if(!reason.trim()){ alert('A reason is required to override an SOP field.'); return; }
pkgOverrides[id]=reason.trim();
const el=document.getElementById(id); el.readOnly=false; el.classList.remove('locked-field'); el.focus();
lockQuality(id); track('quality_overridden',{field:id});
}
function renderCtxBar(){
const bar=document.getElementById('ctx-bar');
if(!SOP){ bar.innerHTML=`<div class="ctx-empty">No SOP loaded — <button class="link-btn" onclick="loadSampleSOP()">load the sample</button> or import one from the Configuration tool.</div>`; return; }
const p=SOP.project||{}, g=SOP.governance||{};
const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:'';
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}</div>
<div class="ctx-sub">${esc(p.number||'')}${p.division?' · '+esc(p.division):''}</div></div>
<div class="ctx-meta"><span><b>${enabledTypes().length}</b> types</span><span>format <code>${esc(g.woFormat||'—')}</code></span><span>track: <b>${esc((SOP.field&&SOP.field.trackPlatform)||'—')}</b></span></div>`;
}
// ── DEV MODE (comment 7) ─────────────────────────────────────────────────────
function toggleDevMode(){
devMode=!devMode;
document.body.classList.toggle('dev-mode', devMode);
document.getElementById('dev-banner').style.display = devMode ? '' : 'none';
toast(devMode ? 'DEV MODE on — usage tracking paused' : 'DEV MODE off — tracking resumed');
}
// ── SOP REFERENCE LINKS (comments 1, 3, 4) ───────────────────────────────────
function sopLinkedSources(){ return ((SOP&&SOP.sources)||[]).filter(s=>s.link); }
function renderSopRefLinks(){
const box=document.getElementById('sop-ref-links'); if(!box) return;
const srcs=sopLinkedSources();
if(!srcs.length){ box.innerHTML=''; return; }
box.innerHTML=`<div class="ref-links-title">Reference folders (from SOP) — navigate to find &amp; copy the specific file link:</div>`+
`<div class="ref-links">`+srcs.map(s=>`<a href="${esc(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
}
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>` : '';
}
function buildTypePicker(){ document.getElementById('wp_type').innerHTML=`<option value="">Select…</option>`+enabledTypes().map(t=>`<option>${esc(t.name)}</option>`).join(''); }
function buildSequencePicker(){ const steps=((SOP&&SOP.sequence)||[]).filter(s=>s.kind!=='gate'&&(s.label||'').trim()); document.getElementById('wp_seq').innerHTML=`<option value="">None (no predecessor)</option>`+steps.map(s=>`<option>${esc(s.label)}</option>`).join(''); }
function onTypeChange(){
updateNumber(); track('type_selected');
}
// ── WP NUMBER (auto-built from per-WP dimensions + type + sequence) ───────────
function numberTokens(){
const fmt=(SOP&&SOP.governance&&SOP.governance.woFormat)||'WP##-[TYPE]';
return (fmt.match(/\[[^\]]+\]/g)||[]).map(t=>t.replace(/[\[\]]/g,'')).filter(t=>t.toUpperCase()!=='TYPE');
}
function buildNumberDims(){
const wrap=document.getElementById('number-dims'); if(!wrap) return;
const toks=numberTokens();
wrap.innerHTML = toks.length ? toks.map(t=>{
return `<div class="field"><label>${esc(t)}</label>
<input type="text" id="ndim_${esc(t)}" value="${(numberDims[t]||'').replace(/"/g,'&quot;')}" placeholder="${esc(t)} code" oninput="numberDims['${esc(t)}']=this.value;updateNumber()"></div>`;
}).join('') : '<div class="field-hint">SOP number format has no scope tokens.</div>';
}
function typeNumberCode(name){ return (name||'').replace(/\b(install|installation)\b/ig,'').replace(/[^a-z0-9]+/gi,'').trim(); }
function updateNumber(){
const fmt=(SOP&&SOP.governance&&SOP.governance.woFormat)||'WP##-[TYPE]';
const t=gv('wp_type');
let out=fmt.replace(/WO##|WP##/i,'WP'+pad2(editingSeq()));
numberTokens().forEach(tok=>{
const v=(numberDims[tok]||'').trim();
out=out.split('['+tok+']').join(v||('['+tok+']'));
});
out=out.split('[TYPE]').join(t?typeNumberCode(t):'[TYPE]');
document.getElementById('wp_number').value=out;
}
function editingSeq(){ // keep an existing package's sequence number if editing, else next
if(editingId){ const ix=savedPackages.findIndex(p=>p.id===editingId); if(ix>=0) return ix+1; }
return nextSeq();
}
// ── MATERIAL LIST ────────────────────────────────────────────────────────────
function unitOptions(cur){
const list=ACU_UNITS.slice(); const c=(cur||'').toUpperCase();
if(c && !list.includes(c)) list.unshift(c);
return `<option value="">Unit…</option>`+list.map(u=>`<option ${c===u?'selected':''}>${esc(u)}</option>`).join('');
}
function buildMaterials(){
const tb=document.getElementById('material-body'); tb.innerHTML='';
pkgMaterials.forEach((m,i)=>{ const tr=document.createElement('tr');
tr.innerHTML=`<td><input type="text" value="${(m.qty||'').replace(/"/g,'&quot;')}" placeholder="20" oninput="pkgMaterials[${i}].qty=this.value"></td>
<td><select onchange="pkgMaterials[${i}].unit=this.value">${unitOptions(m.unit)}</select></td>
<td><input type="text" value="${(m.desc||'').replace(/"/g,'&quot;')}" placeholder="3/8 lock washers gold" oninput="pkgMaterials[${i}].desc=this.value"></td>
<td class="center"><button class="row-del" onclick="removeMaterial(${i})">✕</button></td>`;
tb.appendChild(tr); });
}
function addMaterial(){ pkgMaterials.push({qty:'',unit:'',desc:''}); buildMaterials(); }
function removeMaterial(i){ pkgMaterials.splice(i,1); if(!pkgMaterials.length)pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
function downloadMaterialTemplate(){
const csv='Qty,Unit,Description\n10,FT,Single deep strut gold\n4,EA,2-hole strut base gold\n200,FT,3/4 EMT conduit\n';
const blob=new Blob([csv],{type:'text/csv'}); const a=document.createElement('a');
a.href=URL.createObjectURL(blob); a.download='material-list-template.csv'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('material_template');
}
function parseCSV(text){
const rows=[]; text.replace(/\r\n/g,'\n').split('\n').forEach(line=>{
if(!line.trim()) return;
const cells=[]; let cur='', q=false;
for(let i=0;i<line.length;i++){ const ch=line[i];
if(q){ if(ch==='"'){ if(line[i+1]==='"'){cur+='"';i++;} else q=false; } else cur+=ch; }
else { if(ch===','){cells.push(cur);cur='';} else if(ch==='"'){q=true;} else cur+=ch; } }
cells.push(cur); rows.push(cells);
});
return rows;
}
function importMaterials(ev){
const f=ev.target.files&&ev.target.files[0]; if(!f) return;
if(/\.xlsx?$/i.test(f.name)){ alert('Please save the Excel file as CSV first (File → Save As → CSV), then import. The template download is already CSV.'); ev.target.value=''; return; }
const r=new FileReader(); r.onload=()=>{
try{
const rows=parseCSV(r.result); if(!rows.length){ alert('No rows found.'); return; }
let start=0; const h=rows[0].map(c=>c.trim().toLowerCase());
const qi=h.indexOf('qty'), ui=h.indexOf('unit'), di=h.indexOf('description');
let cq=0,cu=1,cd=2;
if(qi>-1||ui>-1||di>-1){ start=1; if(qi>-1)cq=qi; if(ui>-1)cu=ui; if(di>-1)cd=di; }
const imported=[];
for(let i=start;i<rows.length;i++){ const c=rows[i]; const desc=(c[cd]||'').trim();
if(!desc && !(c[cq]||'').trim()) continue;
imported.push({qty:(c[cq]||'').trim(), unit:(c[cu]||'').trim().toUpperCase(), desc}); }
if(!imported.length){ alert('No material rows found.'); return; }
pkgMaterials=imported; buildMaterials(); toast('Imported '+imported.length+' material rows'); track('material_imported',{count:imported.length});
}catch(e){ alert('Could not parse that CSV.'); }
ev.target.value='';
};
r.readAsText(f);
}
// ── DESCRIPTION OF WORK — STEP LIST (comment 16) ─────────────────────────────
function buildWorkSteps(){
const tb=document.getElementById('worksteps-body'); if(!tb) return; tb.innerHTML='';
pkgWorkSteps.forEach((s,i)=>{ const row=document.createElement('div'); row.className='workstep-row';
row.innerHTML=`<span class="ws-num">${i+1}</span>
<textarea rows="1" placeholder="Describe step ${i+1}…" oninput="pkgWorkSteps[${i}]=this.value">${esc(s)}</textarea>
<button class="row-del" onclick="removeWorkStep(${i})" title="Remove step">✕</button>`;
tb.appendChild(row); });
}
function addWorkStep(){ pkgWorkSteps.push(''); buildWorkSteps(); }
function removeWorkStep(i){ pkgWorkSteps.splice(i,1); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps(); }
// ── ATTACHMENTS ──────────────────────────────────────────────────────────────
function buildAttach(){
const tb=document.getElementById('attach-body'); tb.innerHTML='';
pkgAttach.forEach((a,i)=>{ const tr=document.createElement('tr');
tr.innerHTML=`<td><input type="text" value="${(a.doc||'').replace(/"/g,'&quot;')}" placeholder="drawing / spec name" oninput="pkgAttach[${i}].doc=this.value"></td>
<td><input type="text" value="${(a.rev||'').replace(/"/g,'&quot;')}" placeholder="0" oninput="pkgAttach[${i}].rev=this.value"></td>
<td><input type="text" value="${(a.link||'').replace(/"/g,'&quot;')}" placeholder="link or note" oninput="pkgAttach[${i}].link=this.value"></td>
<td class="center"><button class="row-del" onclick="removeAttach(${i})">✕</button></td>`;
tb.appendChild(tr); });
}
function addAttach(){ pkgAttach.push({doc:'',rev:'',link:''}); buildAttach(); }
function removeAttach(i){ pkgAttach.splice(i,1); if(!pkgAttach.length)pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
// ── CONSTRAINTS + RELEASE GATE ───────────────────────────────────────────────
function buildConstraints(){
const names=constraintNames().map(n=> typeof n==='string' ? n : ((n&&n.name)||String(n)));
// preserve existing statuses if rebuilding
const prev={}; pkgConstraints.forEach(c=>prev[c.name]=c);
pkgConstraints=names.map(n=> prev[n] || {name:n, status:'open', comment:''});
const tb=document.getElementById('constraint-body'); tb.innerHTML='';
pkgConstraints.forEach((c,i)=>{ const tr=document.createElement('tr');
tr.innerHTML=`<td class="row-label">${esc(c.name)}</td>
<td><span class="cstatus">
<button class="${c.status==='open'?'on-open':''}" onclick="setConstraint(${i},'open')">Open</button>
<button class="${c.status==='cleared'?'on-cleared':''}" onclick="setConstraint(${i},'cleared')">Cleared</button>
<button class="${c.status==='na'?'on-na':''}" onclick="setConstraint(${i},'na')">N/A</button>
</span></td>
<td><input type="text" value="${(c.comment||'').replace(/"/g,'&quot;')}" placeholder="note" oninput="pkgConstraints[${i}].comment=this.value"></td>`;
tb.appendChild(tr); });
}
function setConstraint(i,val){
const before=pkgConstraints[i].status;
pkgConstraints[i].status=val; buildConstraints(); updateReleaseBanner();
// If a constraint is flagged Open after the package was released, require logging the issue
if(val==='open' && STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX){
prevStatus=getRadio('status'); holdContext={index:i, before};
openHoldModal(pkgConstraints[i].name, true);
}
}
function readiness(){ const open=pkgConstraints.filter(c=>c.status==='open').length; return {open, total:pkgConstraints.length, cleared:pkgConstraints.filter(c=>c.status==='cleared').length, ready:open===0}; }
function updateReleaseBanner(){
const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status');
let cls, txt;
if(st==='Issue'){ cls='rb-hold'; txt=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; }
else if(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; }
else { cls='rb-notready'; txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open.`; }
b.innerHTML=`<div class="rb-inner ${cls}">${txt}</div>`;
}
function onStatusChange(target){
const idx=STATUS_ORDER.indexOf(target);
if(idx>=ISSUED_IDX && readiness().open>0){
const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name);
alert('Cannot move to "'+target+'" — these constraints are still open:\n\n• '+open.join('\n• ')+'\n\nClear or mark N/A first.');
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
}
if(target==='Issue'){ openHoldModal(); return; } // modal commits or reverts
prevStatus=target; updateReleaseBanner(); track('status_change',{status:target});
}
// ── HOLD LOG MODAL (comments 7) ──────────────────────────────────────────────
let holdPhotoData='', holdContext=null;
function openHoldModal(preselect, fromConstraint){
holdPhotoData=''; if(!fromConstraint) holdContext=null;
const sel=document.getElementById('hold-constraint');
sel.innerHTML = pkgConstraints.map(c=>`<option ${c.name===preselect?'selected':''}>${esc(c.name)}</option>`).join('') + '<option>Other</option>';
const existing = fromConstraint ? (pkgConstraints.find(c=>c.name===preselect)||{}).comment||'' : '';
document.getElementById('hold-details').value=existing;
document.getElementById('hold-doclink').value='';
document.getElementById('hold-photo-preview').innerHTML='';
document.getElementById('hold-modal').classList.add('open');
}
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);
}
function submitHold(){
const constraint=document.getElementById('hold-constraint').value;
const details=document.getElementById('hold-details').value.trim();
const doclink=document.getElementById('hold-doclink').value.trim();
if(!details){ alert('A comment defining the issue is required.'); return; }
pkgHolds.push({ ts:new Date().toISOString(), constraint, details, doc:doclink, photo:holdPhotoData||'' });
const c=pkgConstraints.find(x=>x.name===constraint); if(c){ c.status='open'; c.comment=details; }
buildConstraints(); setRadio('status','Issue'); prevStatus='Issue'; holdContext=null;
document.getElementById('hold-modal').classList.remove('open');
updateReleaseBanner(); toast('Hold logged'); track('hold_logged',{constraint});
}
function cancelHold(){
if(holdContext){ // came from flagging a constraint on a released package -> revert that constraint
pkgConstraints[holdContext.index].status=holdContext.before; holdContext=null; buildConstraints(); updateReleaseBanner();
document.getElementById('hold-modal').classList.remove('open'); return;
}
closeHoldModal(false);
}
function closeHoldModal(committed){
document.getElementById('hold-modal').classList.remove('open');
if(!committed){ setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); }
}
// ── SIGN-OFFS ────────────────────────────────────────────────────────────────
function sopNameForSignoff(role){
if(!SOP) return '';
const roles=SOP.roles||[]; const proj=SOP.project||{};
const find=(re)=>{ const m=roles.find(r=>re.test(r.role||'')); return m&&m.name||''; };
switch(role){
case 'Planner': return find(/planner/i);
case 'Superintendent': return find(/superintendent/i) || proj.cm || '';
case 'HSE Professional': return find(/safety|hse|ehs/i);
case 'Quality Representative': return find(/quality/i) || proj.qm || '';
case 'Work Foreman': return find(/general foreman|foreman/i);
}
return '';
}
function buildSignoffs(){
const prev={}; pkgSignoffs.forEach(s=>prev[s.role]=s);
pkgSignoffs=SIGNOFF_ROLES.map(r=>{
if(prev[r]) return prev[r];
return {role:r, name:sopNameForSignoff(r), date:'', signed:false, fromSOP:!!sopNameForSignoff(r)};
});
const tb=document.getElementById('signoff-body'); tb.innerHTML='';
pkgSignoffs.forEach((s,i)=>{ const tr=document.createElement('tr');
tr.innerHTML=`<td class="row-label">${esc(s.role)}</td>
<td><input type="text" value="${(s.name||'').replace(/"/g,'&quot;')}" placeholder="name" oninput="pkgSignoffs[${i}].name=this.value">${s.fromSOP?'<span class="sop-tag">from SOP</span>':''}</td>
<td><span class="so-date">${s.date?esc(s.date):'—'}</span> <button class="lock-edit so-ovr" onclick="onSignoffDateOverride(${i})" title="Manual date override (reason required)">✎ override</button>${s.dateReason?`<span class="override-note" title="${esc(s.dateReason)}">overridden</span>`:''}</td>
<td class="center"><input type="checkbox" class="signoff-check" ${s.signed?'checked':''} onchange="onSignoffSigned(${i},this.checked)"></td>`;
tb.appendChild(tr); });
}
function todayStr(){ const d=new Date(); return d.getFullYear()+'-'+pad2(d.getMonth()+1)+'-'+pad2(d.getDate()); }
function onSignoffSigned(i,checked){
pkgSignoffs[i].signed=checked;
if(checked && !pkgSignoffs[i].date){ pkgSignoffs[i].date=todayStr(); pkgSignoffs[i].dateReason=''; }
buildSignoffs(); track('signoff',{role:pkgSignoffs[i].role, signed:checked});
}
function onSignoffDateOverride(i){
const s=pkgSignoffs[i];
const nd=prompt('Manual sign-off date for '+s.role+' (YYYY-MM-DD). The date is normally set automatically when Signed is checked.', s.date||todayStr());
if(nd===null) return;
if(!/^\d{4}-\d{2}-\d{2}$/.test(nd.trim())){ alert('Enter the date as YYYY-MM-DD.'); return; }
const reason=prompt('Reason for manually overriding the '+s.role+' sign-off date:');
if(reason===null || !reason.trim()){ alert('A reason is required for a manual date override.'); return; }
s.date=nd.trim(); s.dateReason=reason.trim(); buildSignoffs(); track('signoff_date_override',{role:s.role});
}
// ── SAVE / OUTPUT ────────────────────────────────────────────────────────────
function collectPackage(){
const steps=pkgWorkSteps.map(s=>s.trim()).filter(Boolean);
return {
id: editingId || ('wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)),
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'),
due:gv('wp_due'), spec:gv('wp_spec'), desc:gv('wp_desc'),
work:steps.join('\n'), workSteps:steps, numberDims:{...numberDims},
hours:gv('wp_hours'), seq:gv('wp_seq'),
materials:pkgMaterials.filter(m=>m.qty||m.desc), attachments:pkgAttach.filter(a=>a.doc),
kitStatus:gv('wp_kit_status'), kitOwner:gv('wp_kit_owner'), kitDate:gv('wp_kit_date'),
mimoTime:gv('wp_mimo_time'), mimoLoc:gv('wp_mimo_loc'),
constraints:pkgConstraints.map(c=>({name:c.name,status:c.status,comment:c.comment})),
qc:gv('wp_qc'), photo:gv('wp_photo'), hold:gv('wp_hold'),
qcFromSOP: !pkgOverrides['wp_qc'], photoFromSOP: !pkgOverrides['wp_photo'], holdFromSOP: !pkgOverrides['wp_hold'], overrides:{...pkgOverrides},
signoffs:pkgSignoffs.map(s=>({role:s.role,name:s.name,date:s.date,signed:s.signed,dateReason:s.dateReason||''})),
holds:pkgHolds.map(h=>({...h})),
actualHrs:gv('wp_actual_hrs'), installedQty:gv('wp_installed_qty'), redlines:gv('wp_redlines'), lessons:gv('wp_lessons'),
project:(SOP&&SOP.project&&SOP.project.name)||'', track:(SOP&&SOP.field&&SOP.field.trackPlatform)||'',
updatedAt:new Date().toISOString()
};
}
function savePackage(view){
if(!gv('wp_subject') || !gv('wp_type')){ alert('Subject and WP Type are required.'); return; }
const pkg=collectPackage();
const ix=savedPackages.findIndex(p=>p.id===pkg.id);
if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg);
editingId=pkg.id; saveStore(); renderSavedList(); track('package_saved',{status:pkg.status});
document.getElementById('loadingOverlay').classList.add('active');
setTimeout(()=>{ document.getElementById('loadingOverlay').classList.remove('active'); if(view) renderPackage(pkg); }, 400);
}
function renderPackage(pkg){
const r = pkg.constraints ? {open:pkg.constraints.filter(c=>c.status==='open').length,total:pkg.constraints.length} : {open:0,total:0};
const readyTxt = pkg.status==='Issue' ? 'ON HOLD' : (r.open===0?'RELEASE-READY':(r.open+' OPEN CONSTRAINTS'));
let h=`<h1>${esc(pkg.number||'(no number)')} — Work Package</h1>
<div class="doc-subtitle">${esc(pkg.project)} · TYPE: ${esc(pkg.type).toUpperCase()} · STATUS: ${esc(pkg.status).toUpperCase()} · ${readyTxt}</div>`;
const costDesc = (COST_CODES.find(c=>c.split('|')[0]===pkg.cost)||'').split('|')[1];
h+=`<h2>1.0 General Information</h2><table><tbody>
<tr><th style="width:200px">WP Number</th><td>${cell(pkg.number)}</td></tr>
<tr><th>Subject</th><td>${cell(pkg.subject)}</td></tr>
<tr><th>Type</th><td>${cell(pkg.type)}</td></tr>
<tr><th>System / Facility Code / UPN</th><td>${cell(pkg.system)}</td></tr>
<tr><th>Location</th><td>${cell(pkg.location)}</td></tr>
<tr><th>Cost Code</th><td>${pkg.cost?esc(pkg.cost)+(costDesc?' — '+esc(costDesc):''):ns()}</td></tr>
<tr><th>Acumatica Task</th><td>${cell(pkg.wbs)}</td></tr>
<tr><th>Assignees</th><td>${cell(pkg.assignees)}</td></tr>
<tr><th>Distribution</th><td>${cell(pkg.distribution)}</td></tr>
<tr><th>Due Date</th><td>${cell(pkg.due)}</td></tr>
<tr><th>Specification Section</th><td>${cell(pkg.spec)}</td></tr>
<tr><th>Description</th><td>${cell(pkg.desc)}</td></tr>
</tbody></table>`;
const stepsArr = pkg.workSteps && pkg.workSteps.length ? pkg.workSteps : (pkg.work?String(pkg.work).split('\n').filter(Boolean):[]);
const stepsHtml = stepsArr.length ? '<ol style="margin:0;padding-left:18px">'+stepsArr.map(s=>`<li>${esc(s)}</li>`).join('')+'</ol>' : ns();
h+=`<h2>2.0 Scope & Work</h2><table><tbody>
<tr><th style="width:200px">Description of Work</th><td>${stepsHtml}</td></tr>
<tr><th>Labor Est. Hrs.</th><td>${cell(pkg.hours)}</td></tr>
<tr><th>Package Predecessor</th><td>${pkg.seq?('After: '+esc(pkg.seq)):'None (no predecessor)'}</td></tr>
</tbody></table>`;
if(pkg.materials&&pkg.materials.length){ h+=`<h2>3.0 Material List</h2><table><thead><tr><th style="width:80px">Qty</th><th style="width:80px">Unit</th><th>Description</th></tr></thead><tbody>`;
pkg.materials.forEach(m=>h+=`<tr><td>${cell(m.qty)}</td><td>${cell(m.unit)}</td><td>${cell(m.desc)}</td></tr>`); h+=`</tbody></table>`; }
if(pkg.attachments&&pkg.attachments.length){ h+=`<h2>4.0 Drawings & Attachments</h2><table><thead><tr><th>Document</th><th style="width:60px">Rev</th><th>Link / Note</th></tr></thead><tbody>`;
pkg.attachments.forEach(a=>h+=`<tr><td>${cell(a.doc)}</td><td>${cell(a.rev)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`); h+=`</tbody></table>`; }
h+=`<h2>5.0 Kitting & MIMO</h2><table><tbody>
<tr><th style="width:200px">Kitting Status</th><td>${cell(pkg.kitStatus)}</td></tr>
<tr><th>Warehouse Owner</th><td>${cell(pkg.kitOwner)}</td></tr>
<tr><th>Kitting Need Date</th><td>${cell(pkg.kitDate)}</td></tr>
<tr><th>MIMO Sch. Time / Location</th><td>${cell(pkg.mimoTime)} ${pkg.mimoLoc?'· '+esc(pkg.mimoLoc):''}</td></tr>
</tbody></table>`;
h+=`<h2>6.0 Constraints — Release Readiness</h2><table><thead><tr><th>Constraint</th><th style="width:90px">Status</th><th>Comment</th></tr></thead><tbody>`;
(pkg.constraints||[]).forEach(c=>{ const lbl=c.status==='cleared'?'Cleared':c.status==='na'?'N/A':'Open'; const col=c.status==='cleared'?'var(--accent-green)':c.status==='na'?'var(--text-dim)':'var(--red)';
h+=`<tr><td>${esc(c.name)}</td><td style="color:${col};font-weight:700">${lbl}</td><td>${cell(c.comment)}</td></tr>`; });
h+=`</tbody></table>`;
h+=`<h2>7.0 Quality & Hold Points</h2><table><tbody>
<tr><th style="width:200px">QC</th><td>${cell(pkg.qc)}${pkg.overrides&&pkg.overrides.wp_qc?` <span style="color:var(--accent-amber)">(overridden: ${esc(pkg.overrides.wp_qc)})</span>`:(pkg.qcFromSOP?' <span style="color:var(--accent);font-size:10px">[from SOP]</span>':'')}</td></tr>
<tr><th>Photo Documentation</th><td>${cell(pkg.photo)}${pkg.overrides&&pkg.overrides.wp_photo?` <span style="color:var(--accent-amber)">(overridden: ${esc(pkg.overrides.wp_photo)})</span>`:(pkg.photoFromSOP?' <span style="color:var(--accent);font-size:10px">[from SOP]</span>':'')}</td></tr>
<tr><th>Witness / Hold Points</th><td>${cell(pkg.hold)}</td></tr>
</tbody></table>`;
if(pkg.holds&&pkg.holds.length){
h+=`<h2>7.5 Hold Log</h2><table><thead><tr><th style="width:150px">Logged</th><th style="width:200px">Constraint</th><th>Details</th><th style="width:120px">Support</th></tr></thead><tbody>`;
pkg.holds.forEach(hd=>{ const when=hd.ts?new Date(hd.ts).toLocaleString():''; const sup=[hd.doc?linkify(hd.doc):'', hd.photo?'<span style="color:var(--accent-green)">photo attached</span>':''].filter(Boolean).join('<br>')||ns();
h+=`<tr><td>${esc(when)}</td><td>${cell(hd.constraint)}</td><td>${cell(hd.details)}</td><td>${sup}</td></tr>`; });
h+=`</tbody></table>`;
}
h+=`<h2>8.0 Approvals & Sign-offs</h2><table><thead><tr><th>Role</th><th>Name</th><th style="width:120px">Date</th><th style="width:80px">Signed</th></tr></thead><tbody>`;
(pkg.signoffs||[]).forEach(s=>h+=`<tr><td>${esc(s.role)}</td><td>${cell(s.name)}</td><td>${cell(s.date)}</td><td>${s.signed?'✓':'—'}</td></tr>`);
h+=`</tbody></table>`;
if(pkg.actualHrs||pkg.installedQty||pkg.redlines||pkg.lessons){ h+=`<h2>9.0 Closeout</h2><table><tbody>
<tr><th style="width:200px">Actual Hrs.</th><td>${cell(pkg.actualHrs)}</td></tr>
<tr><th>Installed Quantity</th><td>${cell(pkg.installedQty)}</td></tr>
<tr><th>Redlines / As-Built</th><td>${cell(pkg.redlines)}</td></tr>
<tr><th>Lessons Learned</th><td>${cell(pkg.lessons)}</td></tr>
</tbody></table>`; }
h+=`<p style="margin-top:18px;color:var(--text-dim);font-size:11px">Pushed to: ${cell(pkg.track)}</p>`;
document.getElementById('pkg-doc').innerHTML=h; showOutput();
}
function printPackage(){
const c=document.getElementById('pkg-doc').innerHTML; const w=window.open('','_blank');
w.document.write(`<!DOCTYPE html><html><head><title>Work Package</title><style>body{font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:#1a2230;padding:40px;max-width:820px;margin:0 auto}h1{font-size:20px;margin-bottom:4px}h2{font-size:13px;font-weight:700;text-transform:uppercase;border-bottom:2px solid #cdd9f2;padding-bottom:4px;margin-top:22px;color:#2563d6}table{width:100%;border-collapse:collapse;margin:10px 0;font-size:11px}th{background:#f0f2f5;border:1px solid #ccc;padding:5px 8px;text-align:left}td{border:1px solid #ccc;padding:5px 8px}@page{margin:18mm}</style></head><body>${c}</body></html>`);
w.document.close(); w.print();
}
// ── VIEWS ────────────────────────────────────────────────────────────────────
function showOutput(){ document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); }
function showForm(){ document.querySelectorAll('.main > .card').forEach(e=>e.style.display=''); document.querySelector('.main > .nav-row').style.display='flex'; document.getElementById('pkg-output').style.display='none'; document.getElementById('saved-card').style.display = savedPackages.length?'':'none'; currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); }
// ── SAVED PACKAGES ───────────────────────────────────────────────────────────
const STORE_KEY='wp_iwp_v1';
function saveStore(){ try{ localStorage.setItem(STORE_KEY, JSON.stringify(savedPackages)); }catch(e){} }
function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(STORE_KEY)); if(Array.isArray(d)) savedPackages=d; }catch(e){} }
function renderSavedList(){
const card=document.getElementById('saved-card'), body=document.getElementById('saved-body');
document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:'';
if(!savedPackages.length){ card.style.display='none'; return; }
if(document.getElementById('pkg-output').style.display==='none' || document.getElementById('pkg-output').style.display==='') card.style.display='';
body.innerHTML=savedPackages.map((p,i)=>{ const open=(p.constraints||[]).filter(c=>c.status==='open').length;
const ready = p.status==='Issue' ? '<span class="badge badge-N">On Hold</span>' : (open===0?'<span class="badge badge-Y">Ready</span>':`<span class="badge badge-O">${open} open</span>`);
return `<tr><td class="row-label">${esc(p.number||'—')}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td>
<td>${esc(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>`;
}).join('');
}
function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
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; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); }
function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages on this device?')) return; savedPackages=[]; saveStore(); renderSavedList(); }
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); }
function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); }
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_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);
buildTypePicker(); document.getElementById('wp_type').value=p.type||'';
buildCostCodes(); document.getElementById('wp_cost').value=p.cost||'';
set('wp_wbs',p.wbs);
document.getElementById('wp_kit_status').value=p.kitStatus||'';
buildSequencePicker(); document.getElementById('wp_seq').value=p.seq||'';
setRadio('status',p.status||'Draft');
// number dimensions
numberDims = p.numberDims ? {...p.numberDims} : {}; buildNumberDims();
// overrides + locked quality/hold
pkgOverrides=p.overrides?{...p.overrides}:{};
set('wp_qc', p.qc!=null?p.qc:sopValueFor('wp_qc'));
set('wp_photo', p.photo!=null?p.photo:sopValueFor('wp_photo'));
set('wp_hold', (p.hold&&p.hold.trim())?p.hold:sopValueFor('wp_hold'));
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
// collections
pkgMaterials=(p.materials&&p.materials.length)?p.materials.map(m=>({...m,unit:(m.unit||'').toUpperCase()})):[{qty:'',unit:'',desc:''}]; buildMaterials();
pkgAttach=(p.attachments&&p.attachments.length)?p.attachments.map(a=>({...a})):[{doc:'',rev:'',link:''}]; buildAttach();
pkgWorkSteps=(p.workSteps&&p.workSteps.length)?p.workSteps.slice():(p.work?String(p.work).split('\n').filter(Boolean):['']); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps();
pkgConstraints=(p.constraints||[]).map(c=>({...c})); if(!pkgConstraints.length) buildConstraints(); else renderConstraintRows();
pkgSignoffs=(p.signoffs||[]).map(s=>({...s, fromSOP:!!s.name})); if(!pkgSignoffs.length) buildSignoffs(); else renderSignoffRows();
pkgHolds=(p.holds||[]).map(h=>({...h}));
updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm();
}
function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; buildConstraints();
tmp.forEach(s=>{ const c=pkgConstraints.find(x=>x.name===s.name); if(c){ c.status=s.status; c.comment=s.comment; }});
buildConstraints(); }
function renderSignoffRows(){ const tmp=pkgSignoffs; pkgSignoffs=[]; buildSignoffs(); tmp.forEach(s=>{ const c=pkgSignoffs.find(x=>x.role===s.role); if(c){ c.name=s.name; c.date=s.date; c.signed=s.signed; c.dateReason=s.dateReason||''; }}); buildSignoffs(); }
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'].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='';
setRadio('status','Draft');
numberDims={}; buildNumberDims();
pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials();
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
pkgWorkSteps=['']; buildWorkSteps();
pkgConstraints=[]; buildConstraints(); pkgSignoffs=[]; buildSignoffs();
pkgHolds=[]; pkgOverrides={};
set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
prevStatus='Draft';
updateNumber(); updateReleaseBanner(); showForm(); track('new_package');
}
function set_quality_from_sop(){ const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; set('wp_qc',sopValueFor('wp_qc')); set('wp_photo',sopValueFor('wp_photo')); set('wp_hold',sopValueFor('wp_hold')); }
function exportPackages(){
if(!savedPackages.length){ alert('No packages saved yet.'); return; }
const payload={tool:'Work Package (IWP)', project:(SOP&&SOP.project&&SOP.project.name)||'', exportedAt:new Date().toISOString(), packages:savedPackages};
const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const a=document.createElement('a');
a.href=URL.createObjectURL(blob); a.download='work-packages-'+new Date().toISOString().slice(0,10)+'.json';
document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('packages_exported',{count:savedPackages.length});
}
// ── VIEW SOP REFERENCE (comment 2) ───────────────────────────────────────────
function openSopModal(){
if(!SOP){ alert('No SOP loaded.'); return; }
const p=SOP.project||{}, g=SOP.governance||{}, q=SOP.quality||{};
const row=(k,v)=>`<tr><th style="width:170px;text-align:left;padding:4px 8px;background:var(--surface2)">${esc(k)}</th><td style="padding:4px 8px">${v||'—'}</td></tr>`;
let h=`<table style="width:100%;border-collapse:collapse;font-size:13px">`;
h+=row('Project', esc(p.name)); h+=row('Number', esc(p.number)); h+=row('Client', esc(p.client));
h+=row('Division', esc(p.division)); h+=row('PM / CM / QM', [p.pm,p.cm,p.qm].filter(Boolean).map(esc).join(' / '));
h+=row('WP number format', `<code>${esc(g.woFormat||'—')}</code>`); h+=row('Issuance', esc((g.issuance||[]).join(', ')));
h+=row('Target WP size', esc(g.woSize));
h+=row('WP types', enabledTypes().map(t=>esc(t.name)).join(', '));
h+=row('QC', esc([q.qcReq,q.qcScope].filter(Boolean).join(' — '))); h+=row('Photo', esc(q.photo));
h+=row('Hold points', esc(q.holdPoints)); h+=row('Tracking tool', esc((SOP.field&&SOP.field.trackPlatform)));
h+=row('Sequence', ((SOP.sequence||[]).map(s=>esc(s.label)+(s.kind==='gate'?' (gate)':'')).join(' → ')));
h+=row('Roles', (SOP.roles||[]).map(r=>esc(r.role)+': '+esc(r.name)).join('; '));
h+=row('Sources', (SOP.sources||[]).map(s=>esc(s.label)+' ('+esc(s.system)+')').join('; '));
h+=`</table>`;
document.getElementById('sop-modal-body').innerHTML=h;
document.getElementById('sop-modal').classList.add('open'); track('view_sop');
}
function closeSopModal(){ document.getElementById('sop-modal').classList.remove('open'); }
const ANALYTICS_KEY='wp_iwp_analytics_v1'; const _session='s_'+Date.now().toString(36)+Math.random().toString(36).slice(2,6);
function analyticsLoad(){ try{ return JSON.parse(localStorage.getItem(ANALYTICS_KEY))||{events:[]}; }catch(e){ return {events:[]}; } }
function analyticsSave(d){ try{ localStorage.setItem(ANALYTICS_KEY, JSON.stringify(d)); }catch(e){} }
function track(event,detail){ if(devMode) return; try{ const d=analyticsLoad(); d.events.push({ts:new Date().toISOString(),session:_session,event,detail:detail||null}); if(d.events.length>5000)d.events=d.events.slice(-5000); analyticsSave(d); }catch(e){} }
function showAnalytics(){ const d=analyticsLoad(); const by={}; const ses=new Set(); d.events.forEach(e=>{by[e.event]=(by[e.event]||0)+1;ses.add(e.session);});
let t=`USAGE ANALYTICS\n\nSessions: ${ses.size} Events: ${d.events.length}\n\nActions:\n`; Object.keys(by).forEach(k=>t+=` ${k}: ${by[k]}\n`); t+=`\nSaved packages (this device): ${savedPackages.length}\n\nDownload full log as JSON?`;
if(confirm(t)) downloadAnalytics(); }
function downloadAnalytics(){ const d=analyticsLoad(); const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='wp-iwp-usage-'+new Date().toISOString().slice(0,10)+'.json'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('analytics_exported'); }
// ── COMMENTS ─────────────────────────────────────────────────────────────────
const COMMENTS_KEY='wp_iwp_comments_v1';
function cmtLoad(){ let d; try{ d=JSON.parse(localStorage.getItem(COMMENTS_KEY)); }catch(e){} if(!d||typeof d!=='object')d={}; if(!d.clientId)d.clientId='c_'+Date.now().toString(36)+Math.random().toString(36).slice(2,6); if(typeof d.author!=='string')d.author=''; if(!Array.isArray(d.comments))d.comments=[]; return d; }
function cmtSave(d){ try{ localStorage.setItem(COMMENTS_KEY, JSON.stringify(d)); }catch(e){} }
function cmtSaveAuthor(v){ const d=cmtLoad(); d.author=v; cmtSave(d); }
function toggleComments(){ const dr=document.getElementById('cmt-drawer'),ov=document.getElementById('cmt-overlay'); const open=!dr.classList.contains('open'); dr.classList.toggle('open',open); ov.classList.toggle('open',open); dr.setAttribute('aria-hidden',open?'false':'true'); if(open){ cmtUpdateCurStep(); renderComments(); const a=document.getElementById('cmt-author'); if(a&&!a.value)a.focus(); else document.getElementById('cmt-input')?.focus(); } }
function cmtUpdateCurStep(){ const el=document.getElementById('cmt-cur-step'); if(el) el.textContent=currentView; }
function addComment(){ const d=cmtLoad(); const author=(document.getElementById('cmt-author').value||'').trim(); const text=(document.getElementById('cmt-input').value||'').trim(); if(!author){ alert('Please add your name first.'); document.getElementById('cmt-author').focus(); return; } if(!text){ document.getElementById('cmt-input').focus(); return; } const entry={id:'m_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5), view:currentView, author, clientId:d.clientId, text, ts:new Date().toISOString()}; d.author=author; d.comments.push(entry); cmtSave(d); if(window.postFeedback) window.postFeedback({type:'wp_review_comment', ...entry}); document.getElementById('cmt-input').value=''; renderComments(); refreshCommentBadges(); track('comment_added'); }
function deleteComment(id){ const d=cmtLoad(); d.comments=d.comments.filter(c=>c.id!==id); cmtSave(d); renderComments(); refreshCommentBadges(); }
function renderComments(){ const d=cmtLoad(); const list=document.getElementById('cmt-list'); if(!list) return; if(!d.comments.length){ list.innerHTML='<div class="cmt-empty">No comments yet.</div>'; return; } const sorted=[...d.comments].sort((a,b)=>(b.ts||'').localeCompare(a.ts||'')); list.innerHTML=sorted.map(c=>{ const mine=c.clientId===d.clientId; const when=c.ts?new Date(c.ts).toLocaleString():''; return `<div class="cmt-item${mine?' mine':''}"><div class="cmt-meta"><span class="cmt-author">${esc(c.author||'Anonymous')}</span><span class="cmt-step">${esc(c.view||'')}</span><span class="cmt-time">${esc(when)}</span></div><div class="cmt-text">${esc(c.text)}</div>${mine?`<div><button class="cmt-del" style="float:right" onclick="deleteComment('${c.id}')">Delete</button></div>`:''}</div>`; }).join(''); }
function refreshCommentBadges(){ const d=cmtLoad(); const t=document.getElementById('cbadge-total'); if(t){ if(d.comments.length){ t.style.display=''; t.textContent=d.comments.length; } else t.style.display='none'; } }
function exportComments(){ const d=cmtLoad(); const payload={tool:'Work Package (IWP)', exportedBy:d.author||'', exportedAt:new Date().toISOString(), clientId:d.clientId, comments:d.comments}; const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const safe=(d.author||'review').replace(/[^a-z0-9]+/gi,'-').toLowerCase(); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`wp-iwp-comments-${safe}-${new Date().toISOString().slice(0,10)}.json`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('comments_exported'); }
function importComments(ev){ const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader(); r.onload=()=>{ try{ const inc=JSON.parse(r.result); const incoming=Array.isArray(inc)?inc:(inc.comments||[]); if(!incoming.length){ alert('No comments found.'); return; } const d=cmtLoad(); const seen=new Set(d.comments.map(c=>c.id)); let added=0; incoming.forEach(c=>{ if(c&&c.id&&!seen.has(c.id)){ d.comments.push(c); seen.add(c.id); added++; }}); cmtSave(d); renderComments(); refreshCommentBadges(); alert(`Imported ${added} comment${added===1?'':'s'}.`); }catch(e){ alert('Could not read that file.'); } ev.target.value=''; }; r.readAsText(f); }
function clearMyComments(){ const d=cmtLoad(); const mine=d.comments.filter(c=>c.clientId===d.clientId).length; if(!mine){ alert('No comments to clear.'); return; } if(!confirm(`Delete your ${mine} comment(s)?`)) return; d.comments=d.comments.filter(c=>c.clientId!==d.clientId); cmtSave(d); renderComments(); refreshCommentBadges(); }
function cmtInit(){ const d=cmtLoad(); cmtSave(d); const a=document.getElementById('cmt-author'); if(a)a.value=d.author||''; cmtUpdateCurStep(); renderComments(); refreshCommentBadges(); }
// ── STATUS PILLS ─────────────────────────────────────────────────────────────
document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventListener('click',e=>{
if(e.target.tagName==='INPUT') return;
const v=p.dataset.val;
prevStatus = getRadio('status') || 'Draft'; // capture before switching (for revert)
document.querySelectorAll('#status-group .radio-pill').forEach(x=>x.classList.remove('selected'));
p.classList.add('selected'); p.querySelector('input').checked=true;
onStatusChange(v);
}));
// ── BOOT ─────────────────────────────────────────────────────────────────────
loadStore();
(function bootSOP(){
// When embedded in the Suite, hide the SOP import/sample controls (SOP is injected)
// and prefer the SOP the Suite just completed (persisted to localStorage).
const params = new URLSearchParams(location.search);
if(params.get('embedded')) document.body.classList.add('embedded');
try {
const raw = localStorage.getItem('wp_suite_sop');
if(raw){
const d = JSON.parse(raw);
if(d && d.woTypes){ SOP = d; applySOP(); newPackage(); return; }
}
} catch(e){}
loadSampleSOP();
})();
setRadio('status','Draft');
renderSavedList();
cmtInit();
track('app_open');

236
html/wp-creation-index.html Normal file
View File

@@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Work Package (IWP) — Prime Controls</title>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-creation-styles.css">
</head>
<body>
<div class="loading-overlay" id="loadingOverlay"><div class="spinner"></div><div class="loading-text">Saving work package…</div></div>
<div class="header">
<div class="logo-wrap">
<div class="header-logo">Prime Controls</div>
<button id="dev-toggle" class="dev-toggle" onclick="toggleDevMode()" title="dev mode" aria-label="dev mode"></button>
</div>
<div class="header-sep">|</div>
<div class="header-title">Work Package (IWP)</div>
<button class="btn btn-ghost embed-hide" style="margin-left:auto;padding:7px 16px" onclick="document.getElementById('sop-import').click()">⤒ Import SOP</button>
<input type="file" id="sop-import" accept="application/json" style="display:none" onchange="importSOP(event)">
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadSampleSOP()">⤓ Sample SOP</button>
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="openSopModal()">👁 View SOP</button>
<button class="btn btn-ghost" style="padding:7px 16px" onclick="loadExample()">★ Load Example</button>
<button class="btn btn-ghost" style="padding:7px 16px" onclick="newPackage()">+ New</button>
<button class="btn btn-ghost" 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" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button>
</div>
<div class="dev-banner" id="dev-banner" style="display:none">⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.</div>
<div class="ctx-bar" id="ctx-bar"></div>
<!-- RELEASE READINESS BANNER -->
<div class="release-banner" id="release-banner"></div>
<div class="main">
<!-- GENERAL INFORMATION -->
<div class="card">
<div class="section-header"><div class="section-title">General Information</div>
<div class="section-desc">Parameters in <span style="color:var(--accent)">blue</span> are inherited from the project SOP. Fill the rest for this package.</div></div>
<div class="field-grid">
<div class="field"><label>WP Number <span class="auto-tag">auto</span></label><input type="text" id="wp_number" readonly class="locked-field" placeholder="auto-built"><div class="field-hint sop-hint" id="wp_number_hint"></div></div>
<div class="field"><label>Status</label>
<div class="radio-group" id="status-group" style="margin-bottom:0">
<label class="radio-pill" data-val="Draft"><input type="radio" name="status"><span class="dot"></span>Draft</label>
<label class="radio-pill" data-val="Scheduled"><input type="radio" name="status"><span class="dot"></span>Scheduled</label>
<label class="radio-pill" data-val="Issued"><input type="radio" name="status"><span class="dot"></span>Issued</label>
<label class="radio-pill" data-val="In Progress"><input type="radio" name="status"><span class="dot"></span>In Progress</label>
<label class="radio-pill pill-hold" data-val="Issue"><input type="radio" name="status"><span class="dot"></span>Issue (Hold)</label>
<label class="radio-pill" data-val="QC"><input type="radio" name="status"><span class="dot"></span>QC</label>
<label class="radio-pill" data-val="Closed"><input type="radio" name="status"><span class="dot"></span>Closed</label>
</div>
<div class="field-hint">Cannot move to <strong>Issued</strong> or beyond until all constraints are cleared.</div>
</div>
</div>
<div class="notice">WP number builds automatically from these scope fields + the WP type (per the SOP naming format):</div>
<div class="field-grid" id="number-dims"></div>
<div class="field field-grid col1"><div class="field"><label>Subject / Title <span class="req">*</span></label><input type="text" id="wp_subject" placeholder="e.g. Utility Level 2P Inert Gas Room Wall Mount Midas/ Rack"></div></div>
<div class="field-grid">
<div class="field"><label>WP Type <span class="req">*</span></label><select id="wp_type" onchange="onTypeChange()"></select><div class="field-hint sop-hint">from SOP types</div></div>
<div class="field"><label>System / Facility Code / UPN</label><input type="text" id="wp_system" placeholder="ties to controls.dev / COIN"></div>
<div class="field"><label>Location</label><input type="text" id="wp_location" placeholder="building / level / sector / room"></div>
<div class="field"><label>Cost Code</label><select id="wp_cost"></select><div class="field-hint sop-hint">Acumatica cost codes</div></div>
<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>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>
<div class="field"><label>Specification Section</label><input type="text" id="wp_spec" placeholder="e.g. 26_05_33_00 - Raceway and Boxes"><div class="field-hint" id="spec-folder-link"></div></div>
</div>
<div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div>
</div>
<!-- SCOPE & WORK -->
<div class="card">
<div class="sub-heading">Scope & Work</div>
<div class="field"><label>Description of Work (sequenced steps)</label>
<div class="notice">Enter the work as ordered steps — added in sequence, the way the crew performs them.</div>
<div id="worksteps-body"></div>
<button class="add-btn" onclick="addWorkStep()">+ Add Step</button>
</div>
<div class="field-grid">
<div class="field"><label>Labor Est. Hrs.</label><input type="number" id="wp_hours" min="0" step="1" placeholder="e.g. 20"><div class="field-hint" id="size-check"></div></div>
<div class="field"><label>Package Predecessor</label><select id="wp_seq"></select><div class="field-hint">The package/step (from the SOP sequence) that must finish before this work can start. Choose "None" if it has no predecessor.</div></div>
</div>
</div>
<!-- MATERIAL LIST -->
<div class="card">
<div class="sub-heading">Material List</div>
<div class="notice">Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.</div>
<div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:120px">Unit</th><th>Description</th><th style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div>
<div class="material-actions">
<button class="add-btn" onclick="addMaterial()">+ Add Material Line</button>
<button class="add-btn" onclick="document.getElementById('material-import').click()">⤒ Import from Excel/CSV</button>
<button class="add-btn" onclick="downloadMaterialTemplate()">⤓ Download Template</button>
<input type="file" id="material-import" accept=".csv,.xlsx,.xls" style="display:none" onchange="importMaterials(event)">
</div>
</div>
<!-- DRAWINGS / ATTACHMENTS -->
<div class="card">
<div class="sub-heading">Drawings & Attachments</div>
<div id="sop-ref-links" class="sop-ref-links"></div>
<div class="table-wrap"><table><thead><tr><th>Document / Drawing</th><th style="width:90px">Rev</th><th>Link / Note</th><th style="width:44px"></th></tr></thead><tbody id="attach-body"></tbody></table></div>
<button class="add-btn" onclick="addAttach()">+ Add Document</button>
</div>
<!-- KITTING & MIMO -->
<div class="card">
<div class="sub-heading">Kitting & Material Movement (MIMO)</div>
<div class="field-grid">
<div class="field"><label>Kitting Status</label>
<select id="wp_kit_status"><option value=""></option><option>Open</option><option>In Progress</option><option>Kitted</option><option>Delivered</option></select></div>
<div class="field"><label>Warehouse Owner</label><input type="text" id="wp_kit_owner" placeholder="name"></div>
<div class="field"><label>Kitting Need Date</label><input type="date" id="wp_kit_date"></div>
<div class="field"><label>MIMO Sch. Time</label><input type="datetime-local" id="wp_mimo_time"><div class="field-hint">scheduled material-move date &amp; time</div></div>
<div class="field"><label>MIMO Location</label><input type="text" id="wp_mimo_loc" placeholder="staging / move location"></div>
</div>
</div>
<!-- CONSTRAINTS / RELEASE READINESS -->
<div class="card" id="constraint-card">
<div class="sub-heading">Constraints — Release Readiness</div>
<div class="notice">Per AWP, a package is not released to the field until every constraint is <strong>Cleared</strong> or <strong>N/A</strong>. If a constraint reopens after release, status drops to <strong>Issue (Hold)</strong>.</div>
<div class="table-wrap"><table><thead><tr><th>Constraint</th><th style="width:230px">Status</th><th>Comment</th></tr></thead><tbody id="constraint-body"></tbody></table></div>
</div>
<!-- QUALITY & HOLD POINTS -->
<div class="card">
<div class="sub-heading">Quality, Inspection & Hold Points</div>
<div class="field-grid">
<div class="field"><label>QC Required</label><input type="text" id="wp_qc" placeholder="from SOP" readonly>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_qc')">🔒 Edit (reason required)</button><span class="override-note"></span></div></div>
<div class="field"><label>Photo Documentation</label><input type="text" id="wp_photo" placeholder="from SOP" readonly>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_photo')">🔒 Edit (reason required)</button><span class="override-note"></span></div></div>
</div>
<div class="field field-grid col1"><div class="field"><label>Witness / Hold Points</label><textarea id="wp_hold" rows="2" readonly placeholder="from SOP"></textarea>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_hold')">🔒 Edit (reason required)</button><span class="override-note"></span></div>
<div class="field-hint">Inherited from the SOP. A <strong>Hold Point</strong> stops work until inspection sign-off; a <strong>Witness Point</strong> is offered for inspection but work may proceed if declined.</div></div></div>
</div>
<!-- SIGN-OFFS -->
<div class="card">
<div class="sub-heading">Approvals & Sign-offs</div>
<div class="notice">Per the AWP IWP checklist. A package should be signed by these roles before release.</div>
<div class="table-wrap"><table><thead><tr><th style="width:220px">Role</th><th>Name</th><th style="width:150px">Date</th><th style="width:80px;text-align:center">Signed</th></tr></thead><tbody id="signoff-body"></tbody></table></div>
</div>
<!-- CLOSEOUT -->
<div class="card" id="closeout-card">
<div class="sub-heading">Closeout</div>
<div class="notice">Completed at QC / Closed — captures as-built reality and lessons learned.</div>
<div class="field-grid">
<div class="field"><label>Actual Hrs.</label><input type="number" id="wp_actual_hrs" min="0" step="1"></div>
<div class="field"><label>Installed Quantity</label><input type="text" id="wp_installed_qty" placeholder="e.g. 42 of 42 tags"></div>
</div>
<div class="field field-grid col1"><div class="field"><label>Redlines / As-Built Notes</label><textarea id="wp_redlines" rows="2"></textarea></div></div>
<div class="field field-grid col1"><div class="field"><label>Lessons Learned</label><textarea id="wp_lessons" rows="2"></textarea></div></div>
</div>
<div class="nav-row"><button class="btn btn-ghost" onclick="newPackage()">↺ Clear</button>
<div style="display:flex;gap:10px">
<button class="btn btn-ghost" onclick="savePackage(false)">Save Draft</button>
<button class="btn btn-generate" onclick="savePackage(true)">⚡ Save &amp; View</button>
</div></div>
<!-- OUTPUT -->
<div id="pkg-output" style="display:none">
<div class="output-toolbar">
<button class="btn btn-ghost" onclick="showForm()">← Back to Form</button>
<button class="btn btn-primary" onclick="printPackage()">⎙ Print / Save PDF</button>
<button class="btn btn-ghost" onclick="exportPackages()">⤓ Export (JSON)</button>
</div>
<div class="output-doc" id="pkg-doc"></div>
</div>
<!-- SAVED -->
<div class="card" id="saved-card" style="display:none">
<div class="sub-heading">Saved Work Packages <span id="saved-count"></span></div>
<div class="table-wrap"><table><thead><tr><th>WP #</th><th>Type</th><th>Subject</th><th>Status</th><th style="width:110px">Ready?</th><th style="width:120px"></th></tr></thead><tbody id="saved-body"></tbody></table></div>
<button class="add-btn" onclick="exportPackages()">⤓ Export All (JSON)</button>
<button class="add-btn" onclick="clearSaved()">Clear All</button>
</div>
</div>
<!-- HOLD LOG MODAL (comment 7) -->
<div class="modal-overlay" id="hold-modal">
<div class="modal">
<div class="modal-head"><div class="modal-title">Log Hold — On-Hold Constraint</div><button class="cmt-x" onclick="cancelHold()" title="Cancel"></button></div>
<div class="modal-body">
<div class="notice">Moving a package to <strong>Issue (Hold)</strong> requires logging the constraint that blocked it.</div>
<div class="field"><label>Constraint type <span class="req">*</span></label><select id="hold-constraint"></select></div>
<div class="field"><label>Details <span class="req">*</span></label><textarea id="hold-details" rows="3" placeholder="What reopened / blocked this package?"></textarea></div>
<div class="field"><label>Supporting document link</label><input type="text" id="hold-doclink" placeholder="link to RFI, photo, email, etc. (optional)"></div>
<div class="field"><label>Supporting photo</label><input type="file" id="hold-photo" accept="image/*" onchange="holdPhotoChange(event)"><div class="hold-photo-preview" id="hold-photo-preview"></div></div>
</div>
<div class="modal-foot"><button class="btn btn-ghost" onclick="cancelHold()">Cancel</button><button class="btn btn-generate" onclick="submitHold()">Log Hold</button></div>
</div>
</div>
<!-- VIEW SOP MODAL (comment 2) -->
<div class="modal-overlay" id="sop-modal">
<div class="modal" style="max-width:640px">
<div class="modal-head"><div class="modal-title">Project SOP — Reference</div><button class="cmt-x" onclick="closeSopModal()" title="Close"></button></div>
<div class="modal-body" id="sop-modal-body"></div>
<div class="modal-foot"><button class="btn btn-primary" onclick="closeSopModal()">Close</button></div>
</div>
</div>
<!-- COMMENTS DRAWER -->
<div class="cmt-overlay" id="cmt-overlay" onclick="toggleComments()"></div>
<aside class="cmt-drawer" id="cmt-drawer" aria-hidden="true">
<div class="cmt-head"><div class="cmt-title">Review Comments</div><button class="cmt-x" onclick="toggleComments()" title="Close"></button></div>
<div class="cmt-namebar"><label>Your name</label><input type="text" id="cmt-author" placeholder="e.g. J. Park" oninput="cmtSaveAuthor(this.value)"></div>
<div class="cmt-compose"><div class="cmt-compose-label">Comment on <strong id="cmt-cur-step">this form</strong></div>
<textarea id="cmt-input" rows="3" placeholder="Add feedback…"></textarea>
<button class="btn btn-primary cmt-add" onclick="addComment()">Add Comment</button></div>
<div class="cmt-list" id="cmt-list"></div>
<div class="cmt-foot"><div class="cmt-note">Comments are saved in your browser. Use <strong>Export</strong> to send feedback back; the owner can <strong>Import</strong> each file.</div>
<div class="cmt-foot-btns"><button class="btn btn-ghost" onclick="exportComments()">⤓ Export</button>
<button class="btn btn-ghost" onclick="document.getElementById('cmt-import').click()">⤒ Import</button>
<button class="btn btn-ghost cmt-clear" onclick="clearMyComments()">Clear Mine</button>
<input type="file" id="cmt-import" accept="application/json" style="display:none" onchange="importComments(event)"></div></div>
</aside>
<script src="feedback-config.js"></script>
<script src="wp-creation-app.js"></script>
</body>
</html>

565
html/wp-creation-styles.css Normal file
View File

@@ -0,0 +1,565 @@
/* Fonts are referenced by name only — no external @import, so the app works
fully behind a firewall. If IBM Plex is installed/self-hosted it is used;
otherwise it falls back to the system UI fonts. */
/* Embedded-in-Suite tweaks */
body.embedded .embed-hide { display: none !important; }
body.embedded .embed-first { margin-left: auto; }
:root {
--bg: #f4f5f7;
--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);
--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;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.6;
min-height: 100vh;
}
/* ── HEADER ── */
.header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 14px 32px;
display: flex;
align-items: center;
gap: 14px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow);
}
.header-logo {
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
letter-spacing: .15em;
color: var(--accent);
text-transform: uppercase;
}
.header-sep { color: var(--border-strong); }
.header-title { font-size: 13px; font-weight: 500; color: var(--text-muted); }
/* ── STEPPER ── */
.stepper-wrap { padding: 22px 32px 0; max-width: 1000px; margin: 0 auto; }
.stepper {
display: flex;
flex-wrap: wrap;
gap: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
background: var(--surface);
box-shadow: var(--shadow);
}
.step-tab {
flex: 1 0 10%;
padding: 9px 6px;
text-align: center;
font-family: var(--mono);
font-size: 9.5px;
font-weight: 500;
letter-spacing: .04em;
color: var(--text-dim);
background: var(--surface);
border-right: 1px solid var(--border);
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all .15s;
line-height: 1.35;
text-transform: uppercase;
}
.step-tab:hover { background: var(--surface2); color: var(--text-muted); }
.step-tab.active { background: var(--accent-dim); color: var(--accent); border-bottom-color: var(--accent); }
.step-tab.done { color: var(--accent-green); background: var(--accent-green-dim); }
.step-num { display: block; font-size: 9px; opacity: .65; margin-bottom: 2px; }
/* ── MAIN ── */
.main { max-width: 1000px; margin: 0 auto; padding: 28px 32px 64px; }
.section { display: none; }
.section.active { display: block; animation: fade .25s ease; }
@keyframes fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 28px 32px;
}
.section-header { margin-bottom: 24px; padding-bottom: 14px; border-bottom: 1px solid var(--border); }
.section-label {
font-family: var(--mono); font-size: 10px; font-weight: 600;
letter-spacing: .2em; color: var(--accent); text-transform: uppercase; margin-bottom: 5px;
}
.section-title { font-size: 22px; font-weight: 300; color: var(--text); letter-spacing: -.02em; }
.section-desc { margin-top: 5px; font-size: 13px; color: var(--text-muted); font-weight: 400; }
/* ── FIELDS ── */
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 18px; }
.field-grid.col3 { grid-template-columns: 1fr 1fr 1fr; }
.field-grid.col1 { grid-template-columns: 1fr; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field.span2 { grid-column: span 2; }
label {
font-family: var(--mono); font-size: 10px; font-weight: 500;
letter-spacing: .1em; color: var(--text-muted); text-transform: uppercase;
}
label .req { color: var(--accent); margin-left: 3px; }
input[type=text], input[type=date], input[type=number], textarea, select {
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
padding: 9px 12px;
outline: none;
transition: border-color .15s, box-shadow .15s;
width: 100%;
}
input:focus, textarea:focus, select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
textarea { resize: vertical; min-height: 70px; line-height: 1.5; }
::placeholder { color: var(--text-dim); }
/* ── PILLS ── */
.check-group, .radio-group { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; }
.check-pill, .radio-pill {
display: flex; align-items: center; gap: 7px;
padding: 7px 13px; border: 1px solid var(--border-strong); border-radius: 20px;
cursor: pointer; transition: all .12s; user-select: none; font-size: 13px; color: var(--text-muted);
background: var(--surface);
}
.check-pill:hover, .radio-pill:hover { border-color: var(--accent); color: var(--text); }
.check-pill input, .radio-pill input { display: none; }
.check-pill .dot, .radio-pill .dot {
width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong);
transition: background .12s; flex-shrink: 0;
}
.check-pill.checked { border-color: var(--accent); background: var(--accent-dim); color: var(--accent); }
.check-pill.checked .dot { background: var(--accent); }
.radio-pill.selected { border-color: var(--accent-amber); background: var(--accent-amber-dim); color: var(--accent-amber); }
.radio-pill.selected .dot { background: var(--accent-amber); }
/* ── TABLES ── */
.table-wrap { overflow-x: auto; margin-bottom: 22px; border: 1px solid var(--border); border-radius: var(--radius); }
table { width: 100%; border-collapse: collapse; }
th {
background: var(--surface2); border-bottom: 1px solid var(--border); border-right: 1px solid var(--border);
padding: 8px 10px; font-family: var(--mono); font-size: 9px; font-weight: 600;
letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted); text-align: left; white-space: nowrap;
}
td { border-bottom: 1px solid var(--border); border-right: 1px solid var(--border); padding: 4px; vertical-align: middle; }
th:last-child, td:last-child { border-right: none; }
tr:last-child td { border-bottom: none; }
td.row-label {
padding: 8px 10px; font-family: var(--mono); font-size: 11px; color: var(--text-muted);
background: var(--surface2); white-space: nowrap; min-width: 150px;
}
td input[type=text], td select { border: 1px solid transparent; border-radius: 3px; background: transparent; font-size: 12px; padding: 6px 8px; }
td input[type=text]:focus, td select:focus { background: var(--surface); border-color: var(--accent); box-shadow: none; }
td.center { text-align: center; }
.toggle-cell { display: flex; justify-content: center; align-items: center; padding: 6px; }
.toggle-btn {
width: 28px; height: 28px; border-radius: 4px; border: 1px solid var(--border-strong);
background: var(--surface); color: var(--text-dim); font-size: 15px; cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all .12s; font-family: var(--mono); line-height: 1;
}
.toggle-btn.enabled { border-color: var(--accent-green); background: var(--accent-green-dim); color: var(--accent-green); }
.toggle-btn.disabled { border-color: var(--red); background: var(--red-dim); color: var(--red); text-decoration: line-through; font-size: 11px; }
.ov-select { width: 58px; font-size: 10px !important; padding: 5px 2px !important; text-align: center; font-family: var(--mono) !important; font-weight: 600; }
/* ── MISC ── */
.divider { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
.sub-heading {
font-family: var(--mono); font-size: 10px; font-weight: 600; letter-spacing: .15em;
text-transform: uppercase; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 10px;
}
.sub-heading::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.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);
}
/* ── DELIVERABLES ── */
.deliv-group { margin-bottom: 20px; }
.deliv-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.deliv-item {
display: flex; align-items: flex-start; gap: 9px; padding: 9px 12px;
border: 1px solid var(--border-strong); border-radius: var(--radius); cursor: pointer;
transition: all .12s; user-select: none; background: var(--surface);
}
.deliv-item:hover { border-color: var(--accent); }
.deliv-item.checked { border-color: var(--accent); background: var(--accent-dim); }
.deliv-item input { display: none; }
.deliv-box {
width: 16px; height: 16px; border: 1.5px solid var(--border-strong); border-radius: 3px;
flex-shrink: 0; margin-top: 2px; display: flex; align-items: center; justify-content: center;
font-size: 11px; color: #fff; transition: all .12s;
}
.deliv-item.checked .deliv-box { background: var(--accent); border-color: var(--accent); }
.deliv-text { font-size: 12.5px; line-height: 1.35; color: var(--text); }
.deliv-text .dt-sub { display: block; font-size: 11px; color: var(--text-muted); margin-top: 1px; }
/* ── NAV ── */
.nav-row { display: flex; justify-content: space-between; align-items: center; padding-top: 24px; margin-top: 24px; border-top: 1px solid var(--border); }
.btn {
padding: 10px 22px; border-radius: var(--radius); font-family: var(--mono); font-size: 11px; font-weight: 600;
letter-spacing: .08em; text-transform: uppercase; cursor: pointer; border: 1px solid; transition: all .15s;
}
.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-generate { background: var(--accent-green); border-color: var(--accent-green); color: #fff; font-weight: 700; box-shadow: var(--shadow); }
.btn-generate:hover { background: #117a42; }
/* ── OUTPUT ── */
#output-section { display: none; }
#output-section.active { display: block; }
.output-toolbar { display: flex; gap: 10px; margin-bottom: 18px; align-items: center; flex-wrap: wrap; }
.output-doc {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-lg); padding: 52px 56px; font-family: var(--sans); line-height: 1.7;
color: var(--text); font-size: 13px; max-height: 74vh; overflow-y: auto;
}
.output-doc h1 { font-size: 22px; font-weight: 600; margin-bottom: 4px; letter-spacing: -.02em; }
.output-doc .doc-subtitle { font-size: 11px; color: var(--text-muted); margin-bottom: 28px; font-family: var(--mono); letter-spacing: .08em; }
.output-doc h2 {
font-size: 13px; font-weight: 600; font-family: var(--mono); letter-spacing: .08em; text-transform: uppercase;
color: var(--accent); margin-top: 30px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 2px solid var(--accent-dim);
}
.output-doc h3 { font-size: 11px; font-weight: 600; color: var(--text-muted); margin-top: 16px; margin-bottom: 6px; font-family: var(--mono); text-transform: uppercase; letter-spacing: .08em; }
.output-doc p { margin-bottom: 10px; }
.output-doc ul { padding-left: 20px; margin-bottom: 10px; }
.output-doc li { margin-bottom: 3px; }
.output-doc table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 12px; border: 1px solid var(--border); }
.output-doc th { background: var(--surface2); border: 1px solid var(--border); padding: 6px 10px; font-family: var(--mono); font-size: 9px; letter-spacing: .08em; text-transform: uppercase; text-align: left; color: var(--text-muted); }
.output-doc td { border: 1px solid var(--border); padding: 7px 10px; vertical-align: top; }
.output-doc .badge { display: inline-block; padding: 1px 8px; border-radius: 3px; font-family: var(--mono); font-size: 10px; font-weight: 600; }
.badge-R { background: var(--accent-green-dim); color: var(--accent-green); }
.badge-O { background: var(--accent-amber-dim); color: var(--accent-amber); }
.badge-NA { background: var(--surface2); color: var(--text-dim); }
.badge-Y { background: var(--accent-green-dim); color: var(--accent-green); }
.badge-N { background: var(--red-dim); color: var(--red); }
.info-kv { display: flex; gap: 8px; font-size: 12px; margin-bottom: 6px; }
.info-kv .k { font-family: var(--mono); font-size: 10px; color: var(--text-muted); white-space: nowrap; min-width: 130px; }
.info-kv .v { color: var(--text); }
/* ── LOADING ── */
.loading-overlay {
display: none; position: fixed; inset: 0; background: rgba(244,245,247,.82); z-index: 200;
align-items: center; justify-content: center; flex-direction: column; gap: 16px; backdrop-filter: blur(2px);
}
.loading-overlay.active { display: flex; }
.spinner { width: 36px; height: 36px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text { font-family: var(--mono); font-size: 11px; color: var(--text-muted); letter-spacing: .1em; }
/* ── VALIDATION ── */
.step-tab.invalid { color: var(--red); background: var(--red-dim); border-bottom-color: var(--red); }
.step-tab.invalid.active { color: var(--red); background: var(--red-dim); border-bottom-color: var(--red); }
.step-tab .step-flag { display: none; }
.step-tab.invalid .step-flag { display: inline; margin-left: 4px; }
input.input-invalid, textarea.input-invalid, select.input-invalid { border-color: var(--red); box-shadow: 0 0 0 3px var(--red-dim); }
.radio-group.group-invalid, .check-group.group-invalid { outline: 1px solid var(--red); outline-offset: 4px; border-radius: var(--radius); }
.req-hint { font-family: var(--mono); font-size: 10px; color: var(--red); margin-top: 4px; display: none; }
.req-hint.show { display: block; }
/* ── KEY ROLES ── */
.roles-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
.role-row { display: grid; grid-template-columns: 220px 1fr 36px; gap: 10px; align-items: center; }
.role-row .role-other { display: none; }
.role-row.is-other { grid-template-columns: 220px 1fr 1fr 36px; }
.role-row.is-other .role-other { display: block; }
.row-del {
width: 34px; height: 34px; border-radius: var(--radius); border: 1px solid var(--border-strong);
background: var(--surface); color: var(--text-dim); cursor: pointer; font-size: 16px; line-height: 1;
display: flex; align-items: center; justify-content: center; transition: all .12s; flex-shrink: 0;
}
.row-del:hover { border-color: var(--red); color: var(--red); background: var(--red-dim); }
.add-btn {
display: inline-flex; align-items: center; gap: 7px; padding: 8px 16px; border-radius: var(--radius);
border: 1px dashed var(--border-strong); background: var(--surface); color: var(--text-muted);
font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase;
cursor: pointer; transition: all .15s;
}
.add-btn:hover { border-color: var(--accent); color: var(--accent); border-style: solid; background: var(--accent-dim); }
/* ── WO TYPE NAME EDITING ── */
td.wo-name-cell { padding: 4px; background: var(--surface2); }
td.wo-name-cell input { font-family: var(--mono); font-size: 11px; color: var(--text); font-weight: 500; }
.wo-custom-tag { font-family: var(--mono); font-size: 8px; color: var(--accent); letter-spacing: .1em; margin-left: 6px; vertical-align: middle; }
/* ── SEQUENCE BUILDER ── */
.seq-builder { margin-bottom: 18px; }
.seq-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; }
.seq-step {
display: flex; align-items: center; gap: 12px; padding: 11px 14px;
background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius);
box-shadow: var(--shadow); transition: border-color .12s, box-shadow .12s, opacity .12s;
}
.seq-step:hover { border-color: var(--accent); }
.seq-step.dragging { opacity: .35; border-style: dashed; }
.seq-step.drag-over { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
.seq-handle { cursor: grab; color: var(--text-dim); font-size: 16px; line-height: 1; user-select: none; flex-shrink: 0; }
.seq-handle:active { cursor: grabbing; }
.seq-num {
width: 24px; height: 24px; flex-shrink: 0; border-radius: 50%; background: var(--accent-dim); color: var(--accent);
font-family: var(--mono); font-size: 11px; font-weight: 600; display: flex; align-items: center; justify-content: center;
}
.seq-step input.seq-label {
border: 1px solid transparent; background: transparent; font-size: 14px; padding: 5px 8px; color: var(--text); flex: 1;
}
.seq-step input.seq-label:focus { background: var(--surface2); border-color: var(--accent); box-shadow: none; }
.seq-arrow { text-align: center; color: var(--text-dim); font-size: 13px; line-height: .4; margin: -2px 0; }
.seq-add-row { display: flex; gap: 10px; align-items: center; }
.seq-add-row input { flex: 1; }
.seq-step.gate { border-color: var(--accent-amber); background: var(--accent-amber-dim); border-style: dashed; }
.seq-step.gate .seq-label { color: var(--accent-amber); font-weight: 500; }
.seq-gate-badge {
flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--accent-amber); color: #fff;
font-family: var(--mono); font-size: 9px; font-weight: 600; letter-spacing: .08em; white-space: nowrap;
}
.add-btn-gate { border-color: var(--accent-amber); color: var(--accent-amber); }
.add-btn-gate:hover { border-color: var(--accent-amber); color: var(--accent-amber); background: var(--accent-amber-dim); border-style: solid; }
.field-hint { font-family: var(--mono); font-size: 10px; color: var(--text-dim); margin-top: 5px; line-height: 1.5; }
.field-hint code { background: var(--surface2); padding: 1px 6px; border-radius: 3px; color: var(--accent); }
.field-hint a { color: var(--accent); text-decoration: none; margin-left: 6px; font-weight: 600; }
.field-hint a:hover { text-decoration: underline; }
.other-field { margin-top: 6px; }
/* ── REVIEW COMMENTS ─────────────────────────────────────────────── */
.cbadge-total { display:inline-block; min-width:16px; padding:0 5px; margin-left:4px; font-family:var(--mono);
font-size:10px; font-weight:700; line-height:16px; text-align:center; color:#fff; background:var(--accent); border-radius:9px; }
.step-tab { position:relative; }
.step-tab .cbadge { position:absolute; top:4px; right:4px; min-width:15px; height:15px; padding:0 4px;
font-family:var(--mono); font-size:9px; font-weight:700; line-height:15px; text-align:center;
color:#fff; background:var(--accent-amber); border-radius:8px; box-shadow:0 0 0 2px var(--surface); }
.cmt-overlay { position:fixed; inset:0; background:rgba(20,30,50,.28); opacity:0; pointer-events:none;
transition:opacity .2s ease; z-index:60; }
.cmt-overlay.open { opacity:1; pointer-events:auto; }
.cmt-drawer { position:fixed; top:0; right:0; height:100vh; width:380px; max-width:92vw; background:var(--surface);
border-left:1px solid var(--border); box-shadow:var(--shadow-lg); transform:translateX(100%);
transition:transform .24s ease; z-index:61; display:flex; flex-direction:column; }
.cmt-drawer.open { transform:translateX(0); }
.cmt-head { display:flex; align-items:center; justify-content:space-between; padding:16px 18px;
border-bottom:1px solid var(--border); }
.cmt-title { font-weight:700; font-size:14px; color:var(--text); }
.cmt-x { background:none; border:none; color:var(--text-muted); font-size:15px; cursor:pointer; padding:4px 8px; border-radius:4px; }
.cmt-x:hover { background:var(--surface2); color:var(--text); }
.cmt-namebar { padding:12px 18px; border-bottom:1px solid var(--border); }
.cmt-namebar label { display:block; font-size:10px; text-transform:uppercase; letter-spacing:.04em; color:var(--text-muted); margin-bottom:5px; }
.cmt-namebar input { width:100%; padding:8px 10px; border:1px solid var(--border-strong); border-radius:var(--radius); font-family:var(--sans); font-size:13px; color:var(--text); background:var(--surface); }
.cmt-compose { padding:14px 18px; border-bottom:1px solid var(--border); background:var(--surface2); }
.cmt-compose-label { font-size:11px; color:var(--text-muted); margin-bottom:7px; }
.cmt-compose-label strong { color:var(--accent); }
.cmt-compose textarea { width:100%; padding:9px 11px; border:1px solid var(--border-strong); border-radius:var(--radius);
font-family:var(--sans); font-size:13px; color:var(--text); resize:vertical; background:var(--surface); }
.cmt-add { width:100%; margin-top:9px; }
.cmt-list { flex:1; overflow-y:auto; padding:10px 14px; }
.cmt-empty { text-align:center; color:var(--text-dim); font-size:12px; padding:36px 12px; }
.cmt-item { border:1px solid var(--border); border-radius:var(--radius); padding:10px 12px; margin-bottom:9px; background:var(--surface); }
.cmt-item.mine { border-color:var(--accent); background:var(--accent-dim); }
.cmt-meta { display:flex; align-items:center; gap:7px; margin-bottom:5px; font-size:11px; }
.cmt-author { font-weight:700; color:var(--text); }
.cmt-step { font-family:var(--mono); font-size:9px; text-transform:uppercase; letter-spacing:.03em;
color:var(--accent); background:var(--accent-dim); padding:1px 6px; border-radius:9px; }
.cmt-time { color:var(--text-dim); margin-left:auto; font-size:10px; }
.cmt-text { font-size:13px; color:var(--text); line-height:1.5; white-space:pre-wrap; word-break:break-word; }
.cmt-del { background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:11px; padding:2px 5px; border-radius:4px; }
.cmt-del:hover { background:var(--red-dim); color:var(--red); }
.cmt-jump { background:none; border:none; color:var(--accent); cursor:pointer; font-size:10px; padding:0; margin-top:4px; }
.cmt-jump:hover { text-decoration:underline; }
.cmt-foot { border-top:1px solid var(--border); padding:12px 18px; }
.cmt-note { font-size:10px; color:var(--text-muted); line-height:1.5; margin-bottom:10px; }
.cmt-foot-btns { display:flex; gap:7px; flex-wrap:wrap; }
.cmt-foot-btns .btn { padding:6px 11px; font-size:11px; }
.cmt-clear { margin-left:auto; color:var(--text-muted); }
/* ── REV1: validation, governance, summary ───────────────────────── */
.radio-group.group-invalid, .check-group.group-invalid { outline:2px solid var(--red); outline-offset:4px; border-radius:var(--radius); }
.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; }
.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; }
.sum-warn { margin-top:10px; color:var(--accent-amber); background:var(--accent-amber-dim); border:1px solid #f0d9ad;
border-radius:var(--radius); padding:7px 10px; font-size:11px; }
/* ── CREATION TOOL ───────────────────────────────────────────────── */
.ctx-bar { max-width:1080px; margin:0 auto; padding:12px 28px; display:flex; align-items:center; gap:20px;
border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; }
.ctx-empty { color:var(--text-muted); font-size:13px; }
.ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; }
.ctx-main .ctx-sub { font-size:11px; color:var(--text-muted); margin-top:2px; }
.ctx-sample { font-family:var(--mono); font-size:9px; font-weight:700; color:var(--accent-amber);
background:var(--accent-amber-dim); border:1px solid #f0d9ad; border-radius:9px; padding:1px 7px; margin-left:6px; vertical-align:middle; }
.ctx-meta { margin-left:auto; display:flex; gap:16px; font-size:11px; color:var(--text-muted); flex-wrap:wrap; }
.ctx-meta b { color:var(--accent); }
.ctx-meta code { background:var(--surface2); padding:1px 6px; border-radius:3px; color:var(--accent); }
.link-btn { background:none; border:none; color:var(--accent); cursor:pointer; font-size:inherit; padding:0; text-decoration:underline; }
.mode-wrap { max-width:1080px; margin:0 auto; padding:16px 28px 0; display:flex; align-items:center; gap:16px; }
.mode-toggle { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px; overflow:hidden; }
.mode-btn { padding:8px 18px; font-family:var(--sans); font-size:13px; font-weight:600; border:none; background:var(--surface);
color:var(--text-muted); cursor:pointer; }
.mode-btn.active { background:var(--accent); color:#fff; }
.created-count { font-size:11px; color:var(--text-muted); }
.wo-section { border:1px solid var(--border); border-radius:var(--radius); padding:11px 13px; margin-bottom:9px; background:var(--surface); }
.wo-section-head { display:flex; align-items:center; gap:9px; margin-bottom:7px; flex-wrap:wrap; }
.wo-section-name { font-weight:700; font-size:13px; color:var(--text); }
.wo-section-src { font-family:var(--mono); font-size:10px; color:var(--text-muted); margin-left:auto; }
.wo-section textarea { width:100%; padding:8px 10px; border:1px solid var(--border-strong); border-radius:var(--radius);
font-family:var(--sans); font-size:13px; color:var(--text); resize:vertical; background:var(--surface); }
.empty-hint { color:var(--text-dim); font-size:13px; padding:18px 6px; text-align:center; }
.crew-row { grid-template-columns:1fr 36px !important; }
.crew-row.is-other { grid-template-columns:1fr 1fr 70px 36px !important; }
.crew-row:not(.is-other) { grid-template-columns:1fr 70px 36px !important; }
.batch-head { display:flex; align-items:center; gap:16px; margin:14px 0 10px; font-size:13px; flex-wrap:wrap; }
#batch-preview table input { width:100%; }
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
.sop-hint { color:var(--accent) !important; }
.release-banner { max-width:1080px; margin:0 auto; padding:0 28px; }
.release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600;
display:flex; align-items:center; gap:10px; }
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid #b6e3c6; }
.rb-notready { background:var(--accent-amber-dim); color:var(--accent-amber); border:1px solid #f0d9ad; }
.rb-hold { background:var(--red-dim); color:var(--red); border:1px solid #f3c4c4; }
.pill-hold.selected { background:var(--red) !important; border-color:var(--red) !important; }
.pill-hold.selected .dot { background:#fff !important; }
.cstatus { display:inline-flex; border:1px solid var(--border-strong); border-radius:5px; overflow:hidden; }
.cstatus button { border:none; background:var(--surface); color:var(--text-muted); font-family:var(--sans); font-size:11px;
font-weight:600; padding:4px 10px; cursor:pointer; border-right:1px solid var(--border); }
.cstatus button:last-child { border-right:none; }
.cstatus button.on-open { background:var(--red); color:#fff; }
.cstatus button.on-cleared { background:var(--accent-green); color:#fff; }
.cstatus button.on-na { background:var(--text-muted); color:#fff; }
#material-body input, #attach-body input, #constraint-body input, #signoff-body input { width:100%; }
.signoff-check { width:18px; height:18px; cursor:pointer; }
/* ── COMMENT-DRIVEN ADDITIONS ────────────────────────────────────── */
.locked-field { background:var(--surface2)!important; color:var(--text-muted); cursor:not-allowed; }
.lock-row { display:flex; align-items:center; gap:10px; margin-top:5px; }
.lock-edit { background:none; border:none; color:var(--accent); cursor:pointer; font-size:11px; padding:0; }
.override-note { font-size:11px; color:var(--accent-amber); }
.sop-tag { font-size:9px; font-weight:700; color:var(--accent); background:var(--accent-dim,#eaf0fd); border:1px solid #cdd9f2; border-radius:9px; padding:1px 6px; margin-left:6px; vertical-align:middle; }
#toast { position:fixed; bottom:26px; left:50%; transform:translateX(-50%) translateY(20px); background:var(--text); color:#fff;
padding:10px 20px; border-radius:8px; font-size:13px; font-weight:600; opacity:0; pointer-events:none; transition:all .25s; z-index:9999; box-shadow:0 6px 24px rgba(0,0,0,.25); }
#toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
.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-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; }
.modal-body .field { margin-bottom:14px; }
.modal-foot { display:flex; justify-content:flex-end; gap:10px; padding:14px 20px; border-top:1px solid var(--border); }
.hold-photo-preview img { max-width:160px; max-height:120px; border-radius:6px; border:1px solid var(--border); margin-top:8px; display:block; }
/* ── REV 2 ADDITIONS ─────────────────────────────────────────────── */
.auto-tag { font-size:9px; font-weight:700; color:var(--accent-green); background:var(--accent-green-dim); border:1px solid #b6e3c6; border-radius:9px; padding:1px 6px; margin-left:6px; vertical-align:middle; }
.derived-box { padding:9px 11px; border:1px dashed var(--border-strong); border-radius:var(--radius); background:var(--surface2); color:var(--text); font-size:13px; font-weight:600; min-height:38px; display:flex; align-items:center; }
.material-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:8px; }
.workstep-row { display:flex; align-items:flex-start; gap:10px; margin-bottom:8px; }
.workstep-row .ws-num { flex:0 0 26px; height:26px; border-radius:50%; background:var(--accent); color:#fff; font-size:12px; font-weight:700; display:flex; align-items:center; justify-content:center; margin-top:5px; }
.workstep-row textarea { flex:1; padding:8px 10px; border:1px solid var(--border-strong); border-radius:var(--radius); font-family:var(--sans); font-size:13px; resize:vertical; min-height:38px; }
.workstep-row .row-del { flex:0 0 auto; margin-top:6px; }
/* ── MOBILE (comment 5) ──────────────────────────────────────────── */
@media (max-width: 720px) {
.header { flex-wrap:wrap; gap:6px; padding:10px 14px; }
.header-title { width:100%; order:5; }
.header .btn { margin-left:0 !important; padding:6px 10px !important; font-size:12px; }
.header .btn:first-of-type { margin-left:auto !important; }
.main { padding:14px !important; }
.ctx-bar, .release-banner { padding-left:14px; padding-right:14px; }
.ctx-meta { margin-left:0; width:100%; }
.field-grid { grid-template-columns:1fr !important; }
.radio-group { flex-wrap:wrap; }
.card { padding:16px 14px !important; }
.modal { max-width:100% !important; }
.output-toolbar { flex-wrap:wrap; }
.material-actions .add-btn { flex:1 1 auto; }
table { min-width:520px; } /* keep columns legible; .table-wrap scrolls */
.cstatus button { padding:6px 8px; }
}
@media (max-width: 480px) {
.header-logo { font-size:14px; }
.workstep-row textarea { font-size:16px; } /* avoid iOS zoom */
input, select, textarea { font-size:16px; } /* avoid iOS zoom on focus */
}
/* ── REV 3 ADDITIONS ─────────────────────────────────────────────── */
/* SOP-inherited field highlight (comment 8) */
.sop-inherited { background:rgba(37,99,214,0.07) !important; border-color:var(--accent) !important; opacity:0.85; color:var(--text); }
.sop-inherited:focus { opacity:1; }
/* Dev mode (comment 7) */
.logo-wrap { position:relative; display:flex; align-items:center; }
.dev-toggle { position:absolute; left:2px; bottom:-9px; width:18px; height:7px; padding:0; border:none;
background:var(--text-dim); opacity:0.10; border-radius:3px; cursor:pointer; }
.dev-toggle:hover { opacity:0.35; }
.dev-banner { background:#3a2a00; color:#ffd479; font-size:12.5px; font-weight:700; text-align:center; padding:7px 14px; letter-spacing:.3px; }
body.dev-mode .header { box-shadow: inset 0 -3px 0 #ffb000; }
/* SOP reference links (comments 1,3,4) */
.sop-ref-links { margin-bottom:12px; }
.ref-links-title { font-size:12px; color:var(--text-muted); margin-bottom:6px; }
.ref-links { display:flex; flex-wrap:wrap; gap:8px; }
.ref-link { display:inline-flex; align-items:center; gap:6px; font-size:12.5px; font-weight:600; color:var(--accent);
background:var(--accent-dim,#eef3fd); border:1px solid #cdd9f2; border-radius:8px; padding:6px 11px; text-decoration:none; }
.ref-link:hover { background:#e2ecfc; }
.ref-link .ref-sys { font-weight:400; color:var(--text-muted); font-size:11px; }
.ref-link-sm { font-size:12px; font-weight:600; color:var(--accent); text-decoration:none; }
.ref-link-sm:hover { text-decoration:underline; }
/* Sign-off date override (comment 5) */
.so-date { font-size:13px; font-variant-numeric:tabular-nums; }
.so-ovr { margin-left:8px; font-size:11px; }