Remove discipline from WP types; prep for firewall hosting + feedback collection

WP type discipline removal:
- SOP WP Types step now has Enabled / Special Rules-Notes / WO Complete
  Approval columns (discipline column and DISCIPLINES/type-discipline maps gone)
- WP Creator: drop the derived Discipline field, the type-trade number code,
  and discipline from saved packages and output; number tokens are now generic
- Default WP number format no longer includes [Discipline]
- Harden buildConstraints against object-shaped constraint names

Firewall hosting:
- Remove external Google Fonts @import; fall back to system fonts (no
  outbound calls, runs fully behind a firewall)

Feedback collection:
- Add shared feedback-config.js with a single FEEDBACK_ENDPOINT hook +
  best-effort postFeedback() (backend or Power Automate/SharePoint)
- Add Export / Import to home and SOP feedback; wire central-post on all three
  surfaces (home, SOP step comments, WP review comments)
- Add DEPLOYMENT.md documenting hosting and both feedback paths

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 10:16:42 -07:00
parent 2af4b58a9e
commit d2dc6ff4f7
9 changed files with 288 additions and 78 deletions

114
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,114 @@
# Deployment & Feedback Collection
The Work Package Suite is a **static client-side app** — plain HTML, CSS, and
JavaScript. There is no build step and no database.
## Hosting it behind the firewall
Copy the whole folder to any internal web server and serve it over HTTP(S):
- **IIS / Apache / nginx** — drop the files in the site root. `index.html` is the
entry point.
- **SharePoint / network share** — works too, as long as the files are served
over `http(s)://` (not opened as `file://...`). Serving over HTTP makes
`localStorage` and the embedded Work Package Creator (an `<iframe>`) behave
reliably.
The app makes **no outbound internet calls** — the logo and all scripts are
local, and the previous Google-Fonts dependency has been removed (fonts now fall
back to system UI fonts). So it runs fully air-gapped behind a corporate
firewall.
## Where data lives
By default **everything is stored in each user's own browser** (`localStorage`):
the SOP configuration, the saved Work Packages, the usage logs, and all feedback
/ comments. This means:
- Data is **per-user and per-device** — it is not shared between people, and
clearing browser data erases it.
- Nothing is transmitted anywhere unless you enable central collection (below).
## Feedback collection
There are two layers, and they work together.
### 1. Export / Import (no server required — works today)
Every feedback surface has **Export** and **Import** buttons:
- Home page → *Leave Feedback* panel
- SOP Configuration → *Step Comments* (header button)
- Work Package Creator → *Comments* drawer
A reviewer clicks **Export** to download a JSON file and sends it to you; you
click **Import** on your machine to merge everyone's feedback together (imports
de-duplicate, so re-importing is safe). This needs zero infrastructure and works
behind any firewall.
### 2. Central auto-collection (optional — flip on when hosting is known)
To also gather every submission automatically into one place, set a single value
in [`feedback-config.js`](feedback-config.js):
```js
window.FEEDBACK_ENDPOINT = 'https://your-endpoint-url';
```
When set, each submission is additionally `POST`ed as JSON to that URL (saving
locally still happens, so a failed/disabled endpoint never loses feedback). The
endpoint can be either of:
#### Option A — a small backend on your host
Any server that can run code and append the request body to a file you can
download. Example (Node/Express):
```js
const express = require('express');
const fs = require('fs');
const app = express();
app.use(express.json());
app.post('/feedback', (req, res) => {
fs.appendFileSync('feedback.jsonl', JSON.stringify(req.body) + '\n');
res.sendStatus(204);
});
app.listen(8080);
```
Each line of `feedback.jsonl` is one submission; download it anytime. (PHP/
Python/ASP.NET equivalents are a few lines too.)
#### Option B — Microsoft Power Automate → SharePoint / Excel (good fit for M365)
1. Create a flow with the **"When an HTTP request is received"** trigger.
2. Paste its generated URL into `FEEDBACK_ENDPOINT`.
3. Add an action: **Add a row into a table** (Excel) or **Create item**
(SharePoint list), mapping the JSON fields (`type`, `name`/`author`, `text`,
`submittedAt`, `page`, …).
The "downloadable file" is then just the Excel/SharePoint list, viewable live or
exported — all inside your corporate cloud.
### CORS note
If the endpoint is on a **different origin** than the site, it must return CORS
headers allowing the site's origin (e.g.
`Access-Control-Allow-Origin: https://wp-suite.yourcompany.local`). A Power
Automate HTTP trigger and a same-host backend both handle this cleanly; a
same-origin backend needs no CORS at all.
## Feedback payload shape
Each POST body looks like:
```json
{
"app": "Work Package Suite",
"page": "/work-package-suite.html",
"submittedAt": "2026-06-15T18:20:00.000Z",
"type": "sop_step_comment",
"name": "J. Park",
"text": "Consider adding a fiber WP type",
"step": 4
}
```
`type` is one of `home_feedback`, `sop_step_comment`, or `wp_review_comment`.

42
feedback-config.js Normal file
View File

@@ -0,0 +1,42 @@
/* ──────────────────────────────────────────────────────────────────────────
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.
────────────────────────────────────────────────────────────────────────── */
window.FEEDBACK_ENDPOINT = '';
/* 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);
}
};

View File

@@ -426,7 +426,10 @@
</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>
@@ -458,6 +461,7 @@
<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(){
@@ -512,12 +516,49 @@
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');

View File

@@ -21,19 +21,6 @@ let state = {
let sop = null; // Generated SOP for WP tool
// ── LIBRARIES ─────────────────────────────────────────────────────────────────
const DISCIPLINES = [
'Conduit Installation',
'Cable Tray Installation',
'Instrument Raceway Installation',
'Wire / Cable Pull',
'Electrical/Instrument Cable Installation',
'Cable Terminations',
'Electrical Junction Box Installation',
'Instrument Installation',
'Control Panel Installation',
'Heat Tracing Installation'
];
const DEFAULT_WP_TYPES = [
{name:'Rough-In', enabled:true},
{name:'Mechanical Install', enabled:true},
@@ -50,22 +37,6 @@ const DEFAULT_WP_TYPES = [
{name:'Calibrations', enabled:false}
];
const TYPE_DISCIPLINE_MAP = {
'Rough-In':'Conduit Installation',
'Mechanical Install':'Heat Tracing Installation',
'Panel Install':'Control Panel Installation',
'Conduit Install':'Conduit Installation',
'Tray Install':'Cable Tray Installation',
'Mechanical Tubing':'Heat Tracing Installation',
'Instrument Install':'Instrument Installation',
'Wire Pull':'Wire / Cable Pull',
'Terminations':'Cable Terminations',
'Prefab/Kitting':'Electrical Junction Box Installation',
'Network Cabling':'Cable Tray Installation',
'Fiber':'Wire / Cable Pull',
'Calibrations':'Instrument Installation'
};
const STANDARD_10_CONSTRAINTS = [
{name:'Safety & Permitting', description:'Permits, safety reviews, environmental clearances'},
{name:'Quality Control / Inspection', description:'QC approval, inspection readiness'},
@@ -177,7 +148,7 @@ window.addEventListener('DOMContentLoaded',()=>{
window.addEventListener('beforeunload', trackStepDwell);
function initializeWPTypes(){
state.wpTypes = JSON.parse(JSON.stringify(DEFAULT_WP_TYPES)).map(t=>({...t,discipline:TYPE_DISCIPLINE_MAP[t.name]||''}));
state.wpTypes = JSON.parse(JSON.stringify(DEFAULT_WP_TYPES)).map(t=>({...t,notes:'',approval:''}));
renderWPTypes();
}
@@ -201,7 +172,7 @@ function loadSampleData(){
document.getElementById('role_foreman_name').value = 'Mike Jones';
// Populate Step 5
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[Discipline]-[TYPE]';
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
document.getElementById('gov_wosize').value = '35 days / 4080 hours';
// Populate Step 6
@@ -327,18 +298,20 @@ function updateProjectDisplay(){
// ── RENDERING (SOP) ────────────────────────────────────────────────────────────
function renderWPTypes(){
const container = document.getElementById('wp-types-table');
container.innerHTML = `<div style="padding:0.5rem; font-size:12px; font-weight:600; display:grid; grid-template-columns:30px 200px 200px; gap:1rem; margin-bottom:1rem; border-bottom:1px solid var(--border);">
<div></div><div>Type</div><div>Discipline</div>
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 = `
<input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})">
<div>${t.name}</div>
<select onchange="state.wpTypes[${i}].discipline=this.value">
${DISCIPLINES.map(d=>`<option ${t.discipline===d?'selected':''}>${d}</option>`).join('')}
</select>
<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);
});
@@ -660,7 +633,8 @@ function completeSOP(){
woTypes: state.wpTypes.filter(t=>t.enabled).map(t=>({
name: t.name,
enabled: true,
discipline: t.discipline || ''
notes: t.notes || '',
approval: t.approval || ''
})),
sources: state.sources.filter(s=>s.label),
field: {trackPlatform: state.platforms.tracking},
@@ -719,13 +693,50 @@ function submitComment(){
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);

View File

@@ -314,16 +314,29 @@ body {
.wp-type-row {
display: grid;
grid-template-columns: 30px 200px 200px;
grid-template-columns: 1.2fr 90px 2fr 1.5fr;
gap: 1rem;
align-items: center;
padding: 1rem;
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;
@@ -520,5 +533,6 @@ body {
.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

@@ -151,8 +151,8 @@
<!-- STEP 4: WORK PACKAGE TYPES -->
<div class="step" id="sop-step-4" style="display: none;">
<h2>4. Work Package Types & Disciplines</h2>
<div class="notice">Enable the WP types your project will use. Assign a discipline to each for IWP constraint checklists.</div>
<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>
@@ -163,8 +163,8 @@
<div class="field-grid">
<div class="field">
<label>Work Package Number Format *</label>
<input type="text" id="gov_woformat" placeholder="e.g., WP##-[Sector]-[Discipline]-[TYPE]">
<small>Use ## for counter, [Sector] [Discipline] [TYPE] as variables</small>
<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>
@@ -317,7 +317,12 @@
<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>
<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>
<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>
@@ -333,6 +338,7 @@
</div>
</div>
<script src="feedback-config.js"></script>
<script src="work-package-suite-app.js"></script>
</body>
</html>

View File

@@ -8,7 +8,7 @@ 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]-[Discipline]-[TYPE]' },
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},
@@ -26,9 +26,6 @@ const SAMPLE_SOP = {
costCodes:['5100 - Rough In','5200 - Wire Pull','5300 - Terminations','5400 - Instrumentation'],
};
// AWP / COAA discipline checklists (Vol II, Appendix I)
// Limited to Prime's I&C scope of work (subset of the AWP Appendix I discipline checklists)
const DISCIPLINES = ['Conduit Installation','Cable Tray Installation','Instrument Raceway Installation','Wire / Cable Pull','Electrical/Instrument Cable Installation','Cable Terminations','Electrical Junction Box Installation','Instrument Installation','Control Panel Installation','Heat Tracing Installation'];
// 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'];
@@ -39,10 +36,6 @@ const ISSUED_IDX = STATUS_ORDER.indexOf('Issued');
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'];
// WP type → AWP discipline (comment 6: discipline derived from type)
const TYPE_DISCIPLINE = {'Conduit Install':'Conduit Installation','Tray Install':'Cable Tray Installation','Wire Pull':'Wire / Cable Pull','Terminations':'Cable Terminations','Instrument Install':'Instrument Installation','Panel Install':'Control Panel Installation'};
// WP type → short discipline/trade code used in the WP number (comment 2: single source = type)
const TYPE_TRADE = {'Conduit Install':'ELEC','Tray Install':'ELEC','Wire Pull':'ELEC','Terminations':'ELEC','Instrument Install':'INST','Panel Install':'ELEC'};
// 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"};
@@ -168,10 +161,7 @@ function renderSpecFolderLink(){
}
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 derivedDiscipline(){ const t=gv('wp_type'); return TYPE_DISCIPLINE[t]||''; }
function onTypeChange(){
const d=derivedDiscipline(); const el=document.getElementById('wp_discipline_derived');
if(el) el.textContent = d ? d : '—';
updateNumber(); track('type_selected');
}
@@ -184,11 +174,6 @@ function buildNumberDims(){
const wrap=document.getElementById('number-dims'); if(!wrap) return;
const toks=numberTokens();
wrap.innerHTML = toks.length ? toks.map(t=>{
if(/^discipline$/i.test(t)){
return `<div class="field"><label>${esc(t)} code <span class="auto-tag">auto</span></label>
<div class="derived-box" id="ndim_disc_box">${esc(numberDims[t]||'—')}</div>
<div class="field-hint">trade code from WP type — same discipline used for tracking</div></div>`;
}
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>';
@@ -199,9 +184,7 @@ function updateNumber(){
const t=gv('wp_type');
let out=fmt.replace(/WO##|WP##/i,'WP'+pad2(editingSeq()));
numberTokens().forEach(tok=>{
let v;
if(/^discipline$/i.test(tok)){ v=TYPE_TRADE[t]||''; numberDims[tok]=v; const b=document.getElementById('ndim_disc_box'); if(b) b.textContent=v||'—'; }
else v=(numberDims[tok]||'').trim();
const v=(numberDims[tok]||'').trim();
out=out.split('['+tok+']').join(v||('['+tok+']'));
});
out=out.split('[TYPE]').join(t?typeNumberCode(t):'[TYPE]');
@@ -294,7 +277,7 @@ function removeAttach(i){ pkgAttach.splice(i,1); if(!pkgAttach.length)pkgAttach=
// ── CONSTRAINTS + RELEASE GATE ───────────────────────────────────────────────
function buildConstraints(){
const names=constraintNames();
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:''});
@@ -427,7 +410,7 @@ function collectPackage(){
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'), discipline:derivedDiscipline(), system:gv('wp_system'), location:gv('wp_location'),
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},
@@ -463,7 +446,7 @@ function renderPackage(pkg){
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 / Discipline</th><td>${cell(pkg.type)}${pkg.discipline?' · '+esc(pkg.discipline):''}</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>
@@ -564,8 +547,6 @@ function loadPackageIntoForm(p){
setRadio('status',p.status||'Draft');
// number dimensions
numberDims = p.numberDims ? {...p.numberDims} : {}; buildNumberDims();
// derived discipline
const dd=document.getElementById('wp_discipline_derived'); if(dd) dd.textContent=derivedDiscipline()||'—';
// overrides + locked quality/hold
pkgOverrides=p.overrides?{...p.overrides}:{};
set('wp_qc', p.qc!=null?p.qc:sopValueFor('wp_qc'));
@@ -590,7 +571,6 @@ 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='';
const dd=document.getElementById('wp_discipline_derived'); if(dd) dd.textContent='—';
setRadio('status','Draft');
numberDims={}; buildNumberDims();
pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials();
@@ -648,7 +628,7 @@ function cmtSave(d){ try{ localStorage.setItem(COMMENTS_KEY, JSON.stringify(d));
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; } d.author=author; d.comments.push({id:'m_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5), view:currentView, author, clientId:d.clientId, text, ts:new Date().toISOString()}); cmtSave(d); document.getElementById('cmt-input').value=''; renderComments(); refreshCommentBadges(); track('comment_added'); }
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'; } }

View File

@@ -61,7 +61,6 @@
<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>Discipline</label><div class="derived-box" id="wp_discipline_derived"></div><div class="field-hint">derived from WP type · drives the readiness checklist</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>
@@ -230,6 +229,7 @@
<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>

View File

@@ -1,4 +1,6 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300&display=swap');
/* 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; }
@@ -24,8 +26,8 @@
--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', monospace;
--sans: 'IBM Plex Sans', sans-serif;
--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; }