From d1f0901897af8077e47041cef5223ddc2898d196 Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Mon, 15 Jun 2026 13:29:41 -0700 Subject: [PATCH 1/9] Add discipline strategy, WP sizing, Split-by-Discipline & dashboard SOP config (Governance step) now sets project discipline policy: - disciplines list, discipline strategy (single/multi/planner-choice), letter instance-suffix style, and a max-hours split threshold. WP Creator becomes discipline-aware: - discipline picker; selecting 2+ turns the flat scope into per-discipline scope sections, each with its own status (rolls up to least-advanced). - "Split by Discipline" turns a multi-discipline WP into WP01A/B/C instances linked to a kept master (instanceOf/parentNumber/split/children). - est-hours warning against the SOP split threshold. New WP Dashboard (header button, home card, ?view=dashboard deep-link): metrics, status/discipline breakdowns, a gating panel, and a filterable board with view/edit/issue. Reads localStorage via an API-ready WPData adapter; masters excluded from counts. Backend: POST /api/wps/{id}/issue (enforces the constraint gate), POST /api/wps/{id}/status, GET /api/wps/metrics; work_packages gains parent_id + issued_at. Co-Authored-By: Claude Opus 4.8 (1M context) --- ONBOARDING.md | 50 +++++ index.html | 7 + server/app.py | 88 ++++++++- server/models.py | 8 +- work-package-suite-app.js | 39 +++- work-package-suite.html | 39 +++- wp-creation-app.js | 389 ++++++++++++++++++++++++++++++++++++-- wp-creation-index.html | 57 +++++- wp-creation-styles.css | 38 +++- 9 files changed, 671 insertions(+), 44 deletions(-) diff --git a/ONBOARDING.md b/ONBOARDING.md index af097e2..171f605 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -79,6 +79,56 @@ API. The API is already built and testable on its own. - Firewall hardening (no external calls). - Export/Import feedback on every surface. - Phase 1 backend (API + schema) + NGINX proxy + deploy docs. +- **Discipline strategy, sizing, split & dashboard** (branch `feat/wp-discipline-split-dashboard`) — see below. + +## Discipline strategy, WP sizing, Split-by-Discipline & Dashboard + +Discipline came back — but as a *project policy* set in the SOP, not a fixed +field. The Governance step (SOP config, step 5) now also captures: + +- **Disciplines** (default Mechanical / Electrical / Tech) — comma list. +- **Discipline strategy** (`governance.discMode`): `single` (one discipline per + WP), `multi` (one WP bundles disciplines, scope split per discipline), or + `choice` (planner decides per package — build big, split later). +- **Split threshold** (`governance.sizeHoursMax`) — the Creator warns when a + package's est. hours exceed it. +- `governance.instanceSuffix` = `letter` (instances get A/B/C suffixes). + +In the **WP Creator** ([wp-creation-app.js](wp-creation-app.js)): + +- A **Disciplines** picker (hidden unless the SOP defines disciplines). Pick 2+ + and the single flat work-step list becomes **per-discipline scope sections**, + each with its own steps and its own status (e.g. *Issued — Electrical* while + *Mechanical* is still *In Progress*). Overall WP status rolls up to the + least-advanced discipline. +- **⎘ Split by Discipline** turns a multi-discipline package into one instance + per discipline: `WP01-…` → `WP01A` (Mech), `WP01B` (Elec), `WP01C` (Tech). + Each instance is a single-discipline WP linked to the master via `instanceOf` + / `parentNumber`; the master is kept as a roll-up (`split:true`, `children:[]`). +- New per-WP fields on the saved object: `disciplines`, `scope` (`{discipline: + [steps]}`), `discStatus`, `instanceOf`, `instanceLabel`, `parentNumber`, + `split`, `children`. + +The **Dashboard** (📊 in the Creator header; home card *Work Package Dashboard*; +deep-link `work-package-suite.html?view=dashboard` or `…?view=dashboard#…`): +metrics (total / release-ready / on-hold / overdue / est vs actual hrs), +breakdowns by status & discipline, a **gating panel** (what's blocking each +package), and a filterable board with **view / edit / issue** per package. +Masters are excluded from counts so split hours aren't double-counted. + +Data source today is `localStorage` via the `WPData` adapter in +[wp-creation-app.js](wp-creation-app.js) — swap `list()`/`issue()`/`setStatus()` +to `fetch('/api/wps…')` in Phase 2 and the UI is unchanged. + +**Backend** ([server/](server/)) gained the matching endpoints: +`POST /api/wps/{id}/issue` (refuses if constraints are open — the AWP gate), +`POST /api/wps/{id}/status`, and `GET /api/wps/metrics`. `work_packages` gained +`parent_id` and `issued_at` columns. + +> **Migration caveat:** tables are still auto-created on startup, so the new +> `parent_id` / `issued_at` columns appear on a **fresh** DB only. Before there's +> real data this is fine; once there is, add Alembic (see open question #2) and +> migrate rather than relying on `create_all`. **Pending — Phase 2: wire the front end to the API** - SOP: on *SOP Complete*, `POST /api/sops`; on load, `GET /api/sops/latest` to hydrate the Creator (currently uses `localStorage` key `wp_suite_sop`). diff --git a/index.html b/index.html index 1ef63a8..5136b02 100644 --- a/index.html +++ b/index.html @@ -400,6 +400,13 @@ + + +

Work Package Dashboard

+

Track status and gating across every Work Package — release-readiness, on-hold packages, overdue work, hours, and breakdowns by status and discipline. Issue release-ready packages in one click.

+ +
+ diff --git a/server/app.py b/server/app.py index 4ac52fc..4eee88a 100644 --- a/server/app.py +++ b/server/app.py @@ -53,6 +53,7 @@ class SopIn(BaseModel): class WpIn(BaseModel): id: Optional[str] = None sop_id: Optional[str] = None + parent_id: Optional[str] = None number: str = "" subject: str = "" type: str = "" @@ -61,6 +62,10 @@ class WpIn(BaseModel): data: dict[str, Any] = Field(default_factory=dict) +class StatusIn(BaseModel): + status: str + + class CommentIn(BaseModel): # Tolerate any extra keys the feedback payload includes (timestamp, app, …). model_config = ConfigDict(extra="allow") @@ -141,6 +146,7 @@ def upsert_wp(body: WpIn, db: Session = Depends(get_db)): wp = models.WorkPackage(id=body.id or gen_id("wp")) db.add(wp) wp.sop_id = body.sop_id + wp.parent_id = body.parent_id wp.number = body.number wp.subject = body.subject wp.type = body.type @@ -153,14 +159,63 @@ def upsert_wp(body: WpIn, db: Session = Depends(get_db)): @app.get("/api/wps") -def list_wps(sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)): +def list_wps( + sop_id: Optional[str] = Query(None), + parent_id: Optional[str] = Query(None), + status: Optional[str] = Query(None), + db: Session = Depends(get_db), +): stmt = select(models.WorkPackage) if sop_id: stmt = stmt.where(models.WorkPackage.sop_id == sop_id) + if parent_id: + stmt = stmt.where(models.WorkPackage.parent_id == parent_id) + if status: + stmt = stmt.where(models.WorkPackage.status == status) rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all() return [w.summary() for w in rows] +@app.get("/api/wps/metrics") +def wp_metrics(sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)): + """Aggregates for the dashboard. Masters (data.split == true) are excluded + from counts so a split package's hours aren't double-counted with its + instances.""" + stmt = select(models.WorkPackage) + if sop_id: + stmt = stmt.where(models.WorkPackage.sop_id == sop_id) + rows = db.scalars(stmt).all() + + by_status: dict[str, int] = {} + by_discipline: dict[str, int] = {} + total = ready = on_hold = est_hours = actual_hours = 0 + for w in rows: + data = w.data or {} + if data.get("split"): + continue + total += 1 + by_status[w.status] = by_status.get(w.status, 0) + 1 + if w.status == "Issue": + on_hold += 1 + constraints = data.get("constraints") or [] + open_count = sum(1 for c in constraints if c.get("status") == "open") + if open_count == 0 and w.status not in ("Closed", "Issue"): + ready += 1 + try: + est_hours += float(data.get("hours") or 0) + actual_hours += float(data.get("actualHrs") or 0) + except (TypeError, ValueError): + pass + for d in (data.get("disciplines") or ["(none)"]): + by_discipline[d] = by_discipline.get(d, 0) + 1 + + return { + "total": total, "release_ready": ready, "on_hold": on_hold, + "est_hours": round(est_hours), "actual_hours": round(actual_hours), + "by_status": by_status, "by_discipline": by_discipline, + } + + @app.get("/api/wps/{wp_id}") def get_wp(wp_id: str, db: Session = Depends(get_db)): wp = db.get(models.WorkPackage, wp_id) @@ -179,6 +234,37 @@ def delete_wp(wp_id: str, db: Session = Depends(get_db)): return {"deleted": wp_id} +@app.post("/api/wps/{wp_id}/issue") +def issue_wp(wp_id: str, db: Session = Depends(get_db)): + """Release a Work Package to the field. Refuses if any constraint is still + open (the AWP release gate).""" + wp = db.get(models.WorkPackage, wp_id) + if not wp: + raise HTTPException(status_code=404, detail="Work Package not found") + constraints = (wp.data or {}).get("constraints") or [] + open_names = [c.get("name") for c in constraints if c.get("status") == "open"] + if open_names: + raise HTTPException(status_code=409, detail={"message": "Open constraints block issuance", "open": open_names}) + wp.status = "Issued" + wp.issued_at = models.utcnow() + db.commit() + db.refresh(wp) + return wp.to_dict() + + +@app.post("/api/wps/{wp_id}/status") +def set_wp_status(wp_id: str, body: StatusIn, db: Session = Depends(get_db)): + wp = db.get(models.WorkPackage, wp_id) + if not wp: + raise HTTPException(status_code=404, detail="Work Package not found") + wp.status = body.status + if body.status == "Issued" and wp.issued_at is None: + wp.issued_at = models.utcnow() + db.commit() + db.refresh(wp) + return wp.to_dict() + + # ── Comments / feedback ────────────────────────────────────────────────────── def _save_comment(body: CommentIn, db: Session) -> dict: extra = body.model_extra or {} diff --git a/server/models.py b/server/models.py index ce1dac0..4696b57 100644 --- a/server/models.py +++ b/server/models.py @@ -51,10 +51,13 @@ class WorkPackage(Base): sop_id: Mapped[Optional[str]] = mapped_column( String(40), ForeignKey("sops.id", ondelete="SET NULL"), nullable=True, index=True ) + # parent_id links a discipline instance (WP01A) back to its master (WP01). + parent_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True) number: Mapped[str] = mapped_column(String(120), default="") subject: Mapped[str] = mapped_column(String(400), default="") type: Mapped[str] = mapped_column(String(120), default="") status: Mapped[str] = mapped_column(String(40), default="Draft") + issued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) data: Mapped[dict] = mapped_column(JSON, default=dict) created_by: Mapped[str] = mapped_column(String(200), default="") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) @@ -62,8 +65,9 @@ class WorkPackage(Base): def summary(self) -> dict: return { - "id": self.id, "sop_id": self.sop_id, "number": self.number, - "subject": self.subject, "type": self.type, "status": self.status, + "id": self.id, "sop_id": self.sop_id, "parent_id": self.parent_id, + "number": self.number, "subject": self.subject, "type": self.type, + "status": self.status, "issued_at": _iso(self.issued_at), "created_by": self.created_by, "created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at), } diff --git a/work-package-suite-app.js b/work-package-suite-app.js index 823c188..5165672 100644 --- a/work-package-suite-app.js +++ b/work-package-suite-app.js @@ -10,7 +10,7 @@ let state = { teamMembers: [], signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}], wpTypes: [], - governance: {woformat:'', wosize:'', issuance:[]}, + governance: {woformat:'', wosize:'', issuance:[], disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:''}, quality: {qcreq:'', photo:'', hold:''}, platforms: {tracking:'CxAlloy', commissioning:'CxAlloy'}, sequence: [], @@ -130,10 +130,11 @@ window.addEventListener('DOMContentLoaded',()=>{ updateStepUI(); updateProjectDisplay(); - // Deep-link: ?tab=sop | ?tab=wp from the home page cards. + // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page cards. const params = new URLSearchParams(window.location.search); const tab = params.get('tab'); - if(tab === 'wp' || tab === 'sop') switchTool(tab); + if(params.get('view') === 'dashboard') switchTool('wp'); + else if(tab === 'wp' || tab === 'sop') switchTool(tab); track('app_open'); let _fieldTimer; @@ -174,6 +175,9 @@ function loadSampleData(){ // Populate Step 5 document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]'; document.getElementById('gov_wosize').value = '3–5 days / 40–80 hours'; + document.getElementById('gov_disciplines').value = 'Mechanical, Electrical, Tech'; + document.getElementById('gov_discmode').value = 'choice'; + document.getElementById('gov_size_hours_max').value = '120'; // Populate Step 6 document.getElementById('qual_qcreq').value = 'Yes — Detailed inspection items'; @@ -236,6 +240,9 @@ function repopulateForm(){ 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('gov_disciplines', (state.governance.disciplines||[]).join(', ')); + set('gov_discmode', state.governance.discMode); + set('gov_size_hours_max', state.governance.sizeHoursMax); set('qual_qcreq', state.quality.qcreq); set('qual_photo', state.quality.photo); set('qual_hold', state.quality.hold); @@ -277,7 +284,8 @@ function renderWPTab(){ 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(); + const wantDash = new URLSearchParams(window.location.search).get('view') === 'dashboard'; + frame.src = 'wp-creation-index.html?embedded=1' + (wantDash ? '&view=dashboard' : '') + '&t=' + Date.now(); }else{ gate.style.display = 'block'; frame.style.display = 'none'; @@ -485,15 +493,19 @@ const DEFAULT_SOURCES = [ {label:'Safety Documentation', ph:'e.g. site safety binder'} ]; +// Escape a value for safe use inside a double-quoted HTML attribute. +// SharePoint "Copy Link" URLs contain & (and labels/notes may contain & " < >), +// so attribute values must be escaped or a re-render corrupts the field. +function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } 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)=>`
- - - - + + + +
`).join(''); @@ -573,6 +585,10 @@ function collectStepData(){ 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); + state.governance.disciplines = (document.getElementById('gov_disciplines').value||'') + .split(',').map(d=>d.trim()).filter(Boolean); + state.governance.discMode = document.getElementById('gov_discmode').value; + state.governance.sizeHoursMax = document.getElementById('gov_size_hours_max').value; break; case 6: state.quality.qcreq = document.getElementById('qual_qcreq').value; @@ -628,7 +644,12 @@ function completeSOP(){ governance: { issuance: state.governance.issuance.length ? state.governance.issuance : ['By Sector / Area'], woSize: state.governance.wosize, - woFormat: state.governance.woformat + woFormat: state.governance.woformat, + disciplines: (state.governance.disciplines && state.governance.disciplines.length) + ? state.governance.disciplines : ['Mechanical','Electrical','Tech'], + discMode: state.governance.discMode || 'choice', + instanceSuffix: state.governance.instanceSuffix || 'letter', + sizeHoursMax: state.governance.sizeHoursMax || '' }, woTypes: state.wpTypes.filter(t=>t.enabled).map(t=>({ name: t.name, diff --git a/work-package-suite.html b/work-package-suite.html index 84a5ceb..82ecc7e 100644 --- a/work-package-suite.html +++ b/work-package-suite.html @@ -160,17 +160,13 @@ diff --git a/wp-creation-app.js b/wp-creation-app.js index 589464a..9726421 100644 --- a/wp-creation-app.js +++ b/wp-creation-app.js @@ -8,15 +8,16 @@ 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:'3–5 days', woFormat:'WP##-[Sector]-[TYPE]' }, + governance:{ issuance:['By Sector / Area','By Discipline'], woSize:'3–5 days', woFormat:'WP##-[Sector]-[TYPE]', disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:'120' }, 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:'Design Drawings',system:'SharePoint',notes:'',link:'https://primecontrolsdallas.sharepoint.com/:f:/r/sites/BusinessTechnologyGroup/Shared%20Documents/Current%20projects/Project%20SDE/Piloting/Test%20Files/Drawings?csf=1&web=1&e=Hx4lZF'}, + {label:'Data Sheets',system:'SharePoint',notes:'',link:'https://primecontrolsdallas.sharepoint.com/:f:/r/sites/BusinessTechnologyGroup/Shared%20Documents/Current%20projects/Project%20SDE/Piloting/Test%20Files/DataSheets?csf=1&web=1&e=PkPrXc'}, + {label:'Specifications',system:'SharePoint',notes:'',link:'https://primecontrolsdallas.sharepoint.com/:f:/r/sites/BusinessTechnologyGroup/Shared%20Documents/Current%20projects/Project%20SDE/Piloting/Test%20Files/Specs?csf=1&web=1&e=ZQ19ai'}, {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'}, ], @@ -38,13 +39,16 @@ const COST_CODES = ['1000|Project Management','2000|Design and Development','210 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"}; +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","assets":[{"tag":"1P-HS-NORTH","desc":"1P North horn/strobe circuit","link":"https://controls.dev/assets/1P-HS-NORTH"}],"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 pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[]; let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3) +let pkgDisciplines=[]; // disciplines this WP covers (from SOP governance.disciplines) +let pkgScope={}; // {discipline:[steps]} — per-discipline scope when multi-discipline +let pkgDiscStatus={}; // {discipline:status} — per-discipline phase tracking (e.g. Issued-Electrical) let prevStatus='Draft'; let devMode=false; // comment 7: pauses usage tracking during review let savedPackages=[]; @@ -76,6 +80,7 @@ function importSOP(ev){ } function applySOP(){ renderCtxBar(); buildTypePicker(); buildCostCodes(); buildSequencePicker(); buildNumberDims(); + buildDisciplinePicker(); renderScope(); onHoursChange(); renderSopRefLinks(); renderSpecFolderLink(); // SOP-inherited Quality fields — populated then locked (editable only with a logged reason) if(SOP.quality){ @@ -88,6 +93,7 @@ function applySOP(){ buildConstraints(); buildSignoffs(); if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); } if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); } + if(!pkgAssets.length){ pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); } if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); } updateNumber(); updateReleaseBanner(); } @@ -262,6 +268,168 @@ function buildWorkSteps(){ function addWorkStep(){ pkgWorkSteps.push(''); buildWorkSteps(); } function removeWorkStep(i){ pkgWorkSteps.splice(i,1); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps(); } +// ── DISCIPLINES, PER-DISCIPLINE SCOPE & STATUS ─────────────────────────────── +// A project's SOP decides how WPs use disciplines (governance.discMode): +// 'single' one discipline per WP 'multi' one WP carries several disciplines +// 'choice' planner decides per package (build big, split later) +function sopDisciplines(){ const g=(SOP&&SOP.governance)||{}; return (g.disciplines&&g.disciplines.length)?g.disciplines:['Mechanical','Electrical','Tech']; } +function sopDiscMode(){ return (SOP&&SOP.governance&&SOP.governance.discMode)||'choice'; } +function disciplinesConfigured(){ return !!(SOP&&SOP.governance&&Array.isArray(SOP.governance.disciplines)&&SOP.governance.disciplines.length); } +function isMultiDiscipline(){ return pkgDisciplines.length>1; } +function instanceSuffixStyle(){ return (SOP&&SOP.governance&&SOP.governance.instanceSuffix)||'letter'; } + +function buildDisciplinePicker(){ + const wrap=document.getElementById('discipline-picker'); if(!wrap) return; + const card=document.getElementById('discipline-card'); + const list=sopDisciplines(); + if(!disciplinesConfigured() || !list.length){ if(card) card.style.display='none'; return; } + if(card) card.style.display=''; + const mode=sopDiscMode(); + const note=document.getElementById('discipline-note'); + if(note){ + note.textContent = mode==='single' + ? 'This project issues one discipline per package — choose the single discipline.' + : mode==='multi' + ? 'This project bundles disciplines into one package — select every discipline this package covers; each gets its own scope section.' + : 'Select every discipline this package covers. Choose more than one to build a combined package you can split later.'; + } + wrap.innerHTML = list.map(d=>{ + const on=pkgDisciplines.includes(d); + return ``; + }).join(''); +} +function toggleDiscipline(d,on){ + if(sopDiscMode()==='single'){ pkgDisciplines = on?[d]:[]; } + else { if(on){ if(!pkgDisciplines.includes(d)) pkgDisciplines.push(d); } else { pkgDisciplines=pkgDisciplines.filter(x=>x!==d); } } + // keep per-discipline scope/status maps in sync with the selection + pkgDisciplines.forEach(x=>{ if(!pkgScope[x]) pkgScope[x]=['']; if(!pkgDiscStatus[x]) pkgDiscStatus[x]=getRadio('status')||'Draft'; }); + Object.keys(pkgScope).forEach(x=>{ if(!pkgDisciplines.includes(x)) delete pkgScope[x]; }); + Object.keys(pkgDiscStatus).forEach(x=>{ if(!pkgDisciplines.includes(x)) delete pkgDiscStatus[x]; }); + buildDisciplinePicker(); renderScope(); track('discipline_toggle',{count:pkgDisciplines.length}); +} + +// Show the flat work-step list for 0–1 disciplines; per-discipline sections for 2+. +function renderScope(){ + const flat=document.getElementById('flat-scope'); + const multi=document.getElementById('scope-by-discipline'); + const splitBtn=document.getElementById('split-disc-btn'); + if(!multi) return; + if(isMultiDiscipline()){ + if(flat) flat.style.display='none'; + multi.style.display=''; + if(splitBtn) splitBtn.style.display=''; + buildScopeGroups(); + } else { + if(flat) flat.style.display=''; + multi.style.display='none'; + multi.innerHTML=''; + if(splitBtn) splitBtn.style.display='none'; + } +} +function buildScopeGroups(){ + const multi=document.getElementById('scope-by-discipline'); if(!multi) return; + multi.innerHTML = pkgDisciplines.map(d=>{ + const steps=pkgScope[d]&&pkgScope[d].length?pkgScope[d]:['']; + pkgScope[d]=steps; + const st=pkgDiscStatus[d]||'Draft'; + const rows=steps.map((s,i)=>`
${i+1} + +
`).join(''); + const opts=STATUS_ORDER.map(o=>``).join(''); + return `
+
${esc(d)} + Status:
+ ${rows} + +
`; + }).join(''); +} +function setScopeStep(d,i,v){ if(!pkgScope[d])pkgScope[d]=['']; pkgScope[d][i]=v; } +function addScopeStep(d){ if(!pkgScope[d])pkgScope[d]=['']; pkgScope[d].push(''); buildScopeGroups(); } +function removeScopeStep(d,i){ if(!pkgScope[d])return; pkgScope[d].splice(i,1); if(!pkgScope[d].length)pkgScope[d]=['']; buildScopeGroups(); } +function setDiscStatus(d,v){ pkgDiscStatus[d]=v; rollupDisciplineStatus(); track('disc_status',{discipline:d,status:v}); } +// Overall WP status rolls up to the least-advanced discipline (so a WP isn't "Closed" while a discipline lags). +function rollupDisciplineStatus(){ + if(!isMultiDiscipline()) return; + let minIdx=STATUS_ORDER.length-1; + pkgDisciplines.forEach(d=>{ const ix=STATUS_ORDER.indexOf(pkgDiscStatus[d]||'Draft'); if(ix>=0&&ixmax){ + el.innerHTML=`⚠ ${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try Split by Discipline).':'.')+``; + } else if(max){ el.textContent=`Split threshold: ${max} hrs (from SOP).`; } + else { el.textContent=''; } +} + +// ── SPLIT BY DISCIPLINE ────────────────────────────────────────────────────── +// Turns a multi-discipline package into one instance per discipline: +// WP01-… → WP01A-… (Mechanical), WP01B-… (Electrical), WP01C-… (Tech) +// Each instance is a single-discipline package linked back to the master. +function instanceSuffixFor(index, discipline){ + if(instanceSuffixStyle()==='discipline'){ return '_'+typeNumberCode(discipline); } + return String.fromCharCode(65+index); // A, B, C… +} +function splitByDiscipline(){ + if(!isMultiDiscipline()){ alert('Select two or more disciplines before splitting.'); return; } + if(!gv('wp_subject')){ alert('Add a Subject before splitting.'); return; } + const base=collectPackage(); + const baseNumber=base.number||('WP'+pad2(editingSeq())); + if(!confirm(`Split "${baseNumber}" into ${pkgDisciplines.length} discipline instances (${pkgDisciplines.map((d,i)=>baseNumber+instanceSuffixFor(i,d)).join(', ')})?\n\nThe master package is kept as a roll-up; each instance becomes its own single-discipline package.`)) return; + + // Master: flagged as a split container, keeps all disciplines for roll-up tracking. + const masterId = editingId || base.id; + const master = {...base, id:masterId, split:true, children:[]}; + + const children = pkgDisciplines.map((d,i)=>{ + const suffix=instanceSuffixFor(i,d); + const steps=(pkgScope[d]||[]).map(s=>s.trim()).filter(Boolean); + const childId='wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)+i; + master.children.push(childId); + return { + ...base, + id: childId, + number: baseNumber+suffix, + disciplines:[d], + scope:{[d]:steps.length?steps:['']}, + discStatus:{[d]: (pkgDiscStatus[d]||base.status||'Draft')}, + status: pkgDiscStatus[d]||base.status||'Draft', + workSteps: steps, work: steps.join('\n'), + instanceOf: masterId, instanceLabel: suffix, parentNumber: baseNumber, + split:false, children:undefined, + updatedAt:new Date().toISOString() + }; + }); + + // Upsert master + children into the saved store. + const upsert=(p)=>{ const ix=savedPackages.findIndex(x=>x.id===p.id); if(ix>=0) savedPackages[ix]=p; else savedPackages.push(p); }; + upsert(master); children.forEach(upsert); + editingId=masterId; saveStore(); renderSavedList(); + track('wp_split',{disciplines:pkgDisciplines.length}); + toast('Split into '+children.length+' discipline instances'); + alert('Created '+children.length+' instances:\n\n• '+children.map(c=>c.number+' ('+c.disciplines[0]+')').join('\n• ')+'\n\nThe master '+baseNumber+' is kept as a roll-up. Edit each instance from the Saved Work Packages list.'); +} + +// ── ASSETS (controls.dev) ──────────────────────────────────────────────────── +// Interim: assets are linked manually back to controls.dev. A future direct +// integration will let the user pick them from a list instead of pasting links. +function buildAssets(){ + const tb=document.getElementById('asset-body'); if(!tb) return; tb.innerHTML=''; + pkgAssets.forEach((a,i)=>{ const tr=document.createElement('tr'); + tr.innerHTML=` + + + `; + tb.appendChild(tr); }); +} +function addAsset(){ pkgAssets.push({tag:'',desc:'',link:''}); buildAssets(); track('asset_added'); } +function removeAsset(i){ pkgAssets.splice(i,1); if(!pkgAssets.length)pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); } + // ── ATTACHMENTS ────────────────────────────────────────────────────────────── function buildAttach(){ const tb=document.getElementById('attach-body'); tb.innerHTML=''; @@ -275,6 +443,44 @@ function buildAttach(){ function addAttach(){ pkgAttach.push({doc:'',rev:'',link:''}); buildAttach(); } function removeAttach(i){ pkgAttach.splice(i,1); if(!pkgAttach.length)pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); } +// ── ADD FILES FROM SOP FOLDER (no-auth interim) ────────────────────────────── +// Opens the SOP source folders, then turns pasted SharePoint file links into +// attachment rows with the file name parsed from the URL. (A true embedded +// picker needs an Azure AD app registration — see DEPLOYMENT.md.) +function fileNameFromUrl(url){ + try{ + const path=String(url).split('?')[0].split('#')[0]; + const seg=decodeURIComponent(path.split('/').filter(Boolean).pop()||''); + // Only use it as a doc name if it looks like a real file (has an extension); + // short "/:f:/s/" sharing links have no filename, so leave doc blank. + return /\.[a-z0-9]{2,6}$/i.test(seg) ? seg : ''; + }catch(e){ return ''; } +} +function renderSopFileFolders(){ + const box=document.getElementById('sop-file-folders'); if(!box) return; + const srcs=sopLinkedSources(); + box.innerHTML = srcs.length + ? `
1) Open a folder, multi-select files in SharePoint, then use Copy link:
`+ + `` + : `
No SOP folders defined — load or import an SOP first.
`; +} +function toggleSopFilePanel(){ + const p=document.getElementById('sop-file-panel'); if(!p) return; + const show = !p.style.display || p.style.display==='none'; + p.style.display = show ? 'block' : 'none'; + if(show){ renderSopFileFolders(); track('sop_file_panel_opened'); } +} +function addPastedFileLinks(){ + const ta=document.getElementById('sop-file-links'); if(!ta) return; + const links=ta.value.split(/\r?\n/).map(s=>s.trim()).filter(Boolean); + if(!links.length){ toast('Paste one or more file links first'); return; } + // drop the single empty placeholder row if that's all there is + if(pkgAttach.length===1 && !pkgAttach[0].doc && !pkgAttach[0].rev && !pkgAttach[0].link) pkgAttach=[]; + links.forEach(link=>pkgAttach.push({doc:fileNameFromUrl(link), rev:'', link})); + buildAttach(); ta.value=''; toggleSopFilePanel(); + toast(links.length+' file'+(links.length>1?'s':'')+' added — set Rev as needed'); track('files_added_from_sop'); +} + // ── CONSTRAINTS + RELEASE GATE ─────────────────────────────────────────────── function buildConstraints(){ const names=constraintNames().map(n=> typeof n==='string' ? n : ((n&&n.name)||String(n))); @@ -407,14 +613,21 @@ function onSignoffDateOverride(i){ // ── SAVE / OUTPUT ──────────────────────────────────────────────────────────── function collectPackage(){ const steps=pkgWorkSteps.map(s=>s.trim()).filter(Boolean); + const prev=editingId?savedPackages.find(p=>p.id===editingId):null; // carry instance/split linkage across edits return { id: editingId || ('wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)), + instanceOf: prev?prev.instanceOf:undefined, instanceLabel: prev?prev.instanceLabel:undefined, + parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined, number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'), type:gv('wp_type'), system:gv('wp_system'), location:gv('wp_location'), cost:gv('wp_cost'), wbs:gv('wp_wbs'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'), due:gv('wp_due'), spec:gv('wp_spec'), desc:gv('wp_desc'), work:steps.join('\n'), workSteps:steps, numberDims:{...numberDims}, + disciplines:[...pkgDisciplines], + scope: isMultiDiscipline() ? Object.fromEntries(pkgDisciplines.map(d=>[d,(pkgScope[d]||[]).map(s=>s.trim()).filter(Boolean)])) : undefined, + discStatus: isMultiDiscipline() ? {...pkgDiscStatus} : undefined, hours:gv('wp_hours'), seq:gv('wp_seq'), + assets:pkgAssets.filter(a=>a.tag||a.link||a.desc), 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'), @@ -447,6 +660,7 @@ function renderPackage(pkg){ WP Number${cell(pkg.number)} Subject${cell(pkg.subject)} Type${cell(pkg.type)} + ${pkg.disciplines&&pkg.disciplines.length?`Discipline(s)${esc(pkg.disciplines.join(', '))}${pkg.split?' [MASTER — split into instances]':''}${pkg.instanceOf?` [instance of ${esc(pkg.parentNumber||'')}]`:''}`:''} System / Facility Code / UPN${cell(pkg.system)} Location${cell(pkg.location)} Cost Code${pkg.cost?esc(pkg.cost)+(costDesc?' — '+esc(costDesc):''):ns()} @@ -457,42 +671,54 @@ function renderPackage(pkg){ Specification Section${cell(pkg.spec)} Description${cell(pkg.desc)} `; - const stepsArr = pkg.workSteps && pkg.workSteps.length ? pkg.workSteps : (pkg.work?String(pkg.work).split('\n').filter(Boolean):[]); - const stepsHtml = stepsArr.length ? '
    '+stepsArr.map(s=>`
  1. ${esc(s)}
  2. `).join('')+'
' : ns(); - h+=`

2.0 Scope & Work

- + if(pkg.assets&&pkg.assets.length){ h+=`

2.0 Assets (controls.dev)

Description of Work${stepsHtml}
`; + pkg.assets.forEach(a=>h+=``); h+=`
Asset Tag / IDDescriptioncontrols.dev Link
${cell(a.tag)}${cell(a.desc)}${a.link?linkify(a.link):ns()}
`; } + let scopeHtml; + if(pkg.scope && Object.keys(pkg.scope).length){ // per-discipline scope sections + scopeHtml = Object.keys(pkg.scope).map(d=>{ + const arr=(pkg.scope[d]||[]).filter(Boolean); + const dst=pkg.discStatus&&pkg.discStatus[d]?` [${esc(pkg.discStatus[d])}]`:''; + return `
${esc(d)}${dst}`+ + (arr.length?'
    '+arr.map(s=>`
  1. ${esc(s)}
  2. `).join('')+'
':' — '+ns())+`
`; + }).join(''); + } else { + const stepsArr = pkg.workSteps && pkg.workSteps.length ? pkg.workSteps : (pkg.work?String(pkg.work).split('\n').filter(Boolean):[]); + scopeHtml = stepsArr.length ? '
    '+stepsArr.map(s=>`
  1. ${esc(s)}
  2. `).join('')+'
' : ns(); + } + h+=`

3.0 Scope & Work

+
Description of Work${scopeHtml}
Labor Est. Hrs.${cell(pkg.hours)}
Package Predecessor${pkg.seq?('After: '+esc(pkg.seq)):'None (no predecessor)'}
`; - if(pkg.materials&&pkg.materials.length){ h+=`

3.0 Material List

`; + if(pkg.materials&&pkg.materials.length){ h+=`

4.0 Material List

QtyUnitDescription
`; pkg.materials.forEach(m=>h+=``); h+=`
QtyUnitDescription
${cell(m.qty)}${cell(m.unit)}${cell(m.desc)}
`; } - if(pkg.attachments&&pkg.attachments.length){ h+=`

4.0 Drawings & Attachments

`; + if(pkg.attachments&&pkg.attachments.length){ h+=`

5.0 Drawings & Attachments

DocumentRevLink / Note
`; pkg.attachments.forEach(a=>h+=``); h+=`
DocumentRevLink / Note
${cell(a.doc)}${cell(a.rev)}${a.link?linkify(a.link):ns()}
`; } - h+=`

5.0 Kitting & MIMO

+ h+=`

6.0 Kitting & MIMO

Kitting Status${cell(pkg.kitStatus)}
Warehouse Owner${cell(pkg.kitOwner)}
Kitting Need Date${cell(pkg.kitDate)}
MIMO Sch. Time / Location${cell(pkg.mimoTime)} ${pkg.mimoLoc?'· '+esc(pkg.mimoLoc):''}
`; - h+=`

6.0 Constraints — Release Readiness

`; + h+=`

7.0 Constraints — Release Readiness

ConstraintStatusComment
`; (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+=``; }); h+=`
ConstraintStatusComment
${esc(c.name)}${lbl}${cell(c.comment)}
`; - h+=`

7.0 Quality & Hold Points

+ h+=`

8.0 Quality & Hold Points

QC${cell(pkg.qc)}${pkg.overrides&&pkg.overrides.wp_qc?` (overridden: ${esc(pkg.overrides.wp_qc)})`:(pkg.qcFromSOP?' [from SOP]':'')}
Photo Documentation${cell(pkg.photo)}${pkg.overrides&&pkg.overrides.wp_photo?` (overridden: ${esc(pkg.overrides.wp_photo)})`:(pkg.photoFromSOP?' [from SOP]':'')}
Witness / Hold Points${cell(pkg.hold)}
`; if(pkg.holds&&pkg.holds.length){ - h+=`

7.5 Hold Log

`; + h+=`

8.5 Hold Log

LoggedConstraintDetailsSupport
`; pkg.holds.forEach(hd=>{ const when=hd.ts?new Date(hd.ts).toLocaleString():''; const sup=[hd.doc?linkify(hd.doc):'', hd.photo?'photo attached':''].filter(Boolean).join('
')||ns(); h+=``; }); h+=`
LoggedConstraintDetailsSupport
${esc(when)}${cell(hd.constraint)}${cell(hd.details)}${sup}
`; } - h+=`

8.0 Approvals & Sign-offs

`; + h+=`

9.0 Approvals & Sign-offs

RoleNameDateSigned
`; (pkg.signoffs||[]).forEach(s=>h+=``); h+=`
RoleNameDateSigned
${esc(s.role)}${cell(s.name)}${cell(s.date)}${s.signed?'✓':'—'}
`; - if(pkg.actualHrs||pkg.installedQty||pkg.redlines||pkg.lessons){ h+=`

9.0 Closeout

+ if(pkg.actualHrs||pkg.installedQty||pkg.redlines||pkg.lessons){ h+=`

10.0 Closeout

@@ -508,8 +734,9 @@ function printPackage(){ } // ── 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'}); } +function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; } +function showOutput(){ hideDashboard(); 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(){ hideDashboard(); 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'; buildDisciplinePicker(); renderScope(); currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); } // ── SAVED PACKAGES ─────────────────────────────────────────────────────────── const STORE_KEY='wp_iwp_v1'; @@ -522,7 +749,9 @@ function renderSavedList(){ 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' ? 'On Hold' : (open===0?'Ready':`${open} open`); - return ` + const tag = p.split?' master':(p.instanceOf?` ${esc(p.instanceLabel||'instance')}`:''); + const disc = (p.disciplines&&p.disciplines.length)?`
${esc(p.disciplines.join(', '))}
`:''; + return ``; }).join(''); @@ -547,6 +776,11 @@ function loadPackageIntoForm(p){ setRadio('status',p.status||'Draft'); // number dimensions numberDims = p.numberDims ? {...p.numberDims} : {}; buildNumberDims(); + // disciplines + per-discipline scope/status + pkgDisciplines = Array.isArray(p.disciplines) ? [...p.disciplines] : []; + pkgScope = p.scope ? JSON.parse(JSON.stringify(p.scope)) : {}; + pkgDiscStatus = p.discStatus ? {...p.discStatus} : {}; + buildDisciplinePicker(); renderScope(); onHoursChange(); // overrides + locked quality/hold pkgOverrides=p.overrides?{...p.overrides}:{}; set('wp_qc', p.qc!=null?p.qc:sopValueFor('wp_qc')); @@ -554,6 +788,7 @@ function loadPackageIntoForm(p){ set('wp_hold', (p.hold&&p.hold.trim())?p.hold:sopValueFor('wp_hold')); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold'); // collections + pkgAssets=(p.assets&&p.assets.length)?p.assets.map(a=>({...a})):[{tag:'',desc:'',link:''}]; buildAssets(); 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(); @@ -573,6 +808,8 @@ function newPackage(){ document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value=''; setRadio('status','Draft'); numberDims={}; buildNumberDims(); + pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange(); + pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); pkgWorkSteps=['']; buildWorkSteps(); @@ -591,6 +828,115 @@ function exportPackages(){ document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('packages_exported',{count:savedPackages.length}); } +// ── DASHBOARD ──────────────────────────────────────────────────────────────── +// Data adapter: localStorage today. In Phase 2 swap list()/issue()/setStatus() +// bodies for fetch() calls to /api/wps — the dashboard UI doesn't change. +const WPData = { + list(){ return savedPackages.slice(); }, // → GET /api/wps + get(id){ return savedPackages.find(p=>p.id===id); }, // → GET /api/wps/{id} + issue(id){ const p=savedPackages.find(x=>x.id===id); if(!p) return false; + p.status='Issued'; p.issuedAt=new Date().toISOString(); p.updatedAt=p.issuedAt; saveStore(); return true; }, // → POST /api/wps/{id}/issue + setStatus(id,status){ const p=savedPackages.find(x=>x.id===id); if(!p) return false; + p.status=status; p.updatedAt=new Date().toISOString(); saveStore(); return true; }, // → POST /api/wps/{id}/status +}; + +let dashFilter={status:'',discipline:'',q:''}; +function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); } +function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todayStr()); } +// Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted. +function countableWPs(){ return WPData.list().filter(p=>!p.split); } + +function showDashboard(){ + document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); + document.getElementById('pkg-output').style.display='none'; + const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display=''; + currentView='Dashboard'; cmtUpdateCurStep(); renderDashboard(); + window.scrollTo({top:0,behavior:'smooth'}); track('dashboard_open'); +} +function renderDashboard(){ + const all=countableWPs(); + const byStatus={}; STATUS_ORDER.concat(['Issue']).forEach(s=>byStatus[s]=0); + let estH=0, actH=0, ready=0, hold=0, overdue=0; const byDisc={}; + all.forEach(p=>{ + byStatus[p.status]=(byStatus[p.status]||0)+1; + estH+=parseFloat(p.hours)||0; actH+=parseFloat(p.actualHrs)||0; + if(p.status==='Issue') hold++; + if(wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue') ready++; + if(isOverdue(p)) overdue++; + (p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1); + }); + const card=(label,val,cls)=>`
${val}
${esc(label)}
`; + let h=`
+ ${card('Total WPs', all.length)} + ${card('Release-ready', ready, ready?'dm-green':'')} + ${card('On hold', hold, hold?'dm-red':'')} + ${card('Overdue', overdue, overdue?'dm-red':'')} + ${card('Est. hrs', Math.round(estH))} + ${card('Actual hrs', Math.round(actH))} +
`; + + // status + discipline breakdown chips + const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>`${esc(s)}: ${byStatus[s]}`).join('') + + (byStatus['Issue']?`On Hold: ${byStatus['Issue']}`:''); + const discChips=Object.keys(byDisc).map(d=>`${esc(d)}: ${byDisc[d]}`).join('')||''; + h+=`
By status
${statusChips||'—'}
+
By discipline
${discChips}
`; + + // gating panel — what's blocking release + const gated=all.filter(p=>wpOpenConstraints(p).length>0); + h+=`
⛔ Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)
`; + h+= gated.length ? `
Actual Hrs.${cell(pkg.actualHrs)}
Installed Quantity${cell(pkg.installedQty)}
Redlines / As-Built${cell(pkg.redlines)}
${esc(p.number||'—')}${esc(p.type||'')}${esc(p.subject||'')}
${esc(p.number||'—')}${tag}${disc}${esc(p.type||'')}${esc(p.subject||'')} ${esc(p.status||'')}${ready}
`+ + gated.map(p=>` + `).join('')+ + `
WP #SubjectBlocked by
${esc(p.number||'—')}${esc(p.subject||'')}${wpOpenConstraints(p).map(c=>esc(c.name)+(c.comment?` (${esc(c.comment)})`:'')).join('
')}
` : `
No open constraints — every package is clear of gates.
`; + h+=``; + + // filters + const statusOpts=[''].concat(STATUS_ORDER.concat(['Issue']).map(s=>``)).join(''); + const discList=Object.keys(byDisc); + const discOpts=[''].concat(discList.map(d=>``)).join(''); + h+=`
+ + + +
`; + + // main board (includes masters, marked) + const q=(dashFilter.q||'').toLowerCase(); + const rows=WPData.list().filter(p=>{ + if(dashFilter.status && p.status!==dashFilter.status) return false; + if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false; + if(q && !((p.number||'')+' '+(p.subject||'')).toLowerCase().includes(q)) return false; + return true; + }); + h+=`
Work Packages (${rows.length})
+ `; + if(!rows.length) h+=``; + rows.forEach(p=>{ + const ix=savedPackages.findIndex(x=>x.id===p.id); + const open=wpOpenConstraints(p).length; + const gates= p.split?'master':(open?`${open} open`:`clear`); + const due= p.due?`${esc(p.due)}`:ns(); + const canIssue = !p.split && open===0 && p.status!=='Closed' && p.status!=='Issued' && p.status!=='Issue'; + const issueBtn = canIssue?``:''; + h+=` + + + + `; + }); + h+=`
WP #SubjectTypeDisciplineStatusGatesDueHrs
No work packages match.
${esc(p.number||'—')}${p.instanceOf?` ${esc(p.instanceLabel||'')}`:''}${esc(p.subject||'')}${esc(p.type||'')}${esc((p.disciplines||[]).join(', '))||ns()}${esc(p.status||'')}${gates}${due}${cell(p.hours)}${issueBtn}
`; + document.getElementById('dash-body').innerHTML=h; +} +function dashIssue(id){ + const p=WPData.get(id); if(!p) return; + if(wpOpenConstraints(p).length>0){ alert('Cannot issue — open constraints remain.'); return; } + if(!confirm('Issue work package "'+(p.number||p.subject)+'"? This marks it released to the field.')) return; + WPData.issue(id); renderDashboard(); renderSavedList(); toast('Issued '+(p.number||'')); track('dashboard_issue'); +} +function dashView(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); } +function dashEdit(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); } + // ── VIEW SOP REFERENCE (comment 2) ─────────────────────────────────────────── function openSopModal(){ if(!SOP){ alert('No SOP loaded.'); return; } @@ -666,4 +1012,7 @@ loadStore(); setRadio('status','Draft'); renderSavedList(); cmtInit(); +// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard). +(function(){ const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); } })(); +window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); }); track('app_open'); diff --git a/wp-creation-index.html b/wp-creation-index.html index 96b0d9b..2f21596 100644 --- a/wp-creation-index.html +++ b/wp-creation-index.html @@ -24,6 +24,7 @@ + @@ -76,16 +77,35 @@
+ +
+
Assets
+
Every work package is based on one or more assets managed in controls.dev. Paste the controls.dev link for each asset this package covers. A direct integration to pick assets from a list is planned — for now, link them manually.
+
Asset Tag / IDDescriptioncontrols.dev Link *
+ +
+ + + +
Scope & Work
-
-
Enter the work as ordered steps — added in sequence, the way the crew performs them.
-
- +
+
+
Enter the work as ordered steps — added in sequence, the way the crew performs them.
+
+ +
-
-
+ + +
+
The package/step (from the SOP sequence) that must finish before this work can start. Choose "None" if it has no predecessor.
@@ -109,6 +129,16 @@
Document / DrawingRevLink / Note
+ +
@@ -170,6 +200,21 @@
+ + +
- - The Creator flags packages above this so they can be split (by discipline or scope). + + Auto-set from the size above (editable). The Creator flags packages over this so they can be split.
diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index 93771fe..0159e9a 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -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:'3–5 days', woFormat:'WP##-[Sector]-[TYPE]', disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:'120' }, + governance:{ issuance:['By Sector / Area','By Discipline'], woSize:'Standard — 3–5 days (≈40–80 hrs)', woFormat:'WP##-[Sector]-[TYPE]', disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:'80' }, woTypes:[ {name:'Conduit Install', enabled:true}, {name:'Tray Install', enabled:true}, {name:'Wire Pull', enabled:true}, {name:'Terminations', enabled:true}, @@ -372,12 +372,15 @@ function rollupDisciplineStatus(){ // ── WP SIZING WARNING (governance.sizeHoursMax) ────────────────────────────── function onHoursChange(){ const el=document.getElementById('size-check'); if(!el) return; - const max=parseFloat((SOP&&SOP.governance&&SOP.governance.sizeHoursMax)||''); + const g=(SOP&&SOP.governance)||{}; + const band=g.woSize?('Target: '+g.woSize+'. '):''; + const max=parseFloat(g.sizeHoursMax||''); const hrs=parseFloat(gv('wp_hours')); if(max && hrs && hrs>max){ - el.innerHTML=`⚠ ${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try Split by Discipline).':'.')+``; - } else if(max){ el.textContent=`Split threshold: ${max} hrs (from SOP).`; } - else { el.textContent=''; } + el.innerHTML=`${esc(band)}⚠ ${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try Split by Discipline).':'.')+``; + } else if(band || max){ + el.innerHTML=`${esc(band)}${max?'Split threshold: '+max+' hrs.':''}`; + } else { el.textContent=''; } } // ── SPLIT BY DISCIPLINE ────────────────────────────────────────────────────── From a18ae487f6b7dd956457a705b6bdb57318b2afd1 Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Mon, 15 Jun 2026 16:29:14 -0700 Subject: [PATCH 6/9] =?UTF-8?q?Fix=20SOP=E2=86=92home=20flow,=20stop=20sam?= =?UTF-8?q?ple-data=20fallback=20for=20real=20projects,=20add=20custom=20W?= =?UTF-8?q?P=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SOP Complete now returns to the project home page after the confirmation popup (instead of staying on the SOP tab), and stamps projectId onto the SOP. - WP creator no longer substitutes the Micron SAMPLE_SOP when a project is active but its SOP isn't found — it shows a "complete the SOP first" empty state instead. Sample is only used for a standalone (no-project) preview. This was the source of "loaded with sample data I didn't select." - SOP WP Types step gains "+ Add Custom Type" (editable name + remove); blank-named custom types are dropped from the generated SOP. Custom types round-trip via the saved state. Co-Authored-By: Claude Opus 4.8 (1M context) --- html/work-package-suite-app.js | 41 +++++++++++++++++++++++++++++----- html/wp-creation-app.js | 13 +++++++++-- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js index cab1e0b..c5faf2c 100644 --- a/html/work-package-suite-app.js +++ b/html/work-package-suite-app.js @@ -381,14 +381,24 @@ function renderWPTypes(){ state.wpTypes.forEach((t,i)=>{ const row = document.createElement('div'); row.className = 'wp-type-row'; + const nameCell = t.custom + ? `
+ + +
` + : `
${t.name}
`; row.innerHTML = ` -
${t.name}
+ ${nameCell}
`; container.appendChild(row); }); + const addRow = document.createElement('div'); + addRow.style.cssText = 'margin-top:0.85rem;'; + addRow.innerHTML = ``; + container.appendChild(addRow); } function toggleWPType(i){ @@ -396,6 +406,21 @@ function toggleWPType(i){ renderWPTypes(); } +function addCustomWPType(){ + state.wpTypes.push({name:'', enabled:true, notes:'', approval:'', custom:true}); + renderWPTypes(); + // Focus the new custom row's name input. + const rows = document.querySelectorAll('#wp-types-table .wp-type-row'); + const last = rows[rows.length-1]; + const nameInput = last && last.querySelector('input[type="text"]'); + if(nameInput) nameInput.focus(); +} + +function removeWPType(i){ + state.wpTypes.splice(i,1); + renderWPTypes(); +} + function renderTeamMembers(){ const container = document.getElementById('team-members-list'); if(!container) return; @@ -717,8 +742,8 @@ function completeSOP(){ instanceSuffix: state.governance.instanceSuffix || 'letter', sizeHoursMax: state.governance.sizeHoursMax || '' }, - woTypes: state.wpTypes.filter(t=>t.enabled).map(t=>({ - name: t.name, + woTypes: state.wpTypes.filter(t=>t.enabled && (t.name||'').trim()).map(t=>({ + name: t.name.trim(), enabled: true, notes: t.notes || '', approval: t.approval || '' @@ -741,6 +766,8 @@ function completeSOP(){ }; sopComplete = true; + // Stamp the active project onto the SOP so it's unambiguously tied to it. + try { if(typeof ProjectData!=='undefined' && ProjectData.getActiveId()) sop.projectId = ProjectData.getActiveId(); } catch(e){} updateProjectDisplay(); // Persist for the home page (green / "Review") and for the WP Creator tab, @@ -753,10 +780,12 @@ function completeSOP(){ 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. + // Hand the SOP to the embedded Work Package Creator and unlock its tab (in case + // the user stays), then return to the project home page per the requested flow. if(typeof onSOPReady === 'function') onSOPReady(sop); + + alert('✓ SOP Configuration Complete!\n\nReturning to the project home page.'); + window.location.href = 'index.html'; } // ── COMMENTS ────────────────────────────────────────────────────────────────── diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index 0159e9a..bdc2f21 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -136,7 +136,12 @@ function editQuality(id){ } function renderCtxBar(){ const bar=document.getElementById('ctx-bar'); - if(!SOP){ bar.innerHTML=`
No SOP loaded — or import one from the Configuration tool.
`; return; } + if(!SOP){ + bar.innerHTML = activeProjectId + ? `
No SOP found for this project yet — complete the SOP Configuration first, then return here.
` + : `
No SOP loaded — or import one from the Configuration tool.
`; + return; + } const p=SOP.project||{}, g=SOP.governance||{}; const sample=SOP.meta&&SOP.meta.sample?`SAMPLE`:''; bar.innerHTML=`
${esc(p.name||'Untitled')} ${sample}
@@ -1039,7 +1044,11 @@ loadStore(); if(d && d.woTypes){ SOP = d; applySOP(); newPackage(); return; } } } catch(e){} - loadSampleSOP(); + // No SOP found. Only show the Micron SAMPLE for a standalone preview (no project + // context). For a real project, never substitute sample data — show the empty + // state so it's clear the project's SOP must be completed first. + if(activeProjectId){ SOP=null; renderCtxBar(); newPackage(); } + else { loadSampleSOP(); } })(); setRadio('status','Draft'); renderSavedList(); From 561d4f24087db5d300f156002adbc7b631cdf1cb Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Mon, 15 Jun 2026 16:36:14 -0700 Subject: [PATCH 7/9] Menu/UX cleanup: cost codes, Duplicate WP, condensed toolbars, Dashboard tab - Cost codes: drop everything after 4990 (material/quality/admin codes removed). - WP creator: add "Duplicate" (asks how many copies; each a clean Draft with unique number/subject, approvals & closeout cleared). When embedded the WP toolbar now shows only New + Duplicate. - Condense duplicated toolbars: the embedded creator's Usage Data, Load Example/ Sample SOP, Comments, View SOP and Dashboard buttons are hidden; the suite's top-right "Usage Logs" and "Load Sample" are the single instances. - Load Sample is context-aware: SOP tab loads the sample SOP, WP/Dashboard tab loads the example Work Package in the creator. - Dashboard moved to a nav-tab next to SOP Configuration / Work Package Creation. - Remove "Prime Controls" branding from the embedded WP menu. - Remove "Bill Clarida" from the example WP distribution and the step-comments name placeholder. Co-Authored-By: Claude Opus 4.8 (1M context) --- html/work-package-suite-app.js | 42 +++++++++++++++++++++------------- html/work-package-suite.html | 7 ++++-- html/wp-creation-app.js | 34 +++++++++++++++++++++++++-- html/wp-creation-index.html | 17 +++++++------- 4 files changed, 72 insertions(+), 28 deletions(-) diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js index c5faf2c..bc64b85 100644 --- a/html/work-package-suite-app.js +++ b/html/work-package-suite-app.js @@ -155,7 +155,7 @@ window.addEventListener('DOMContentLoaded',()=>{ // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page. const tab = params.get('tab'); - if(params.get('view') === 'dashboard') switchTool('wp'); + if(params.get('view') === 'dashboard') switchTool('dashboard'); else if(tab === 'wp' || tab === 'sop') switchTool(tab); track('app_open'); @@ -176,7 +176,15 @@ function initializeWPTypes(){ } // ── LOAD SAMPLE DATA ────────────────────────────────────────────────────────── +// Context-aware: on the SOP tab it loads the sample SOP; on the WP / Dashboard +// tab it loads the example Work Package inside the embedded creator. function loadSampleData(){ + if(currentTool && currentTool !== 'sop'){ + const f = document.getElementById('wp-frame'); + if(f && f.contentWindow && typeof f.contentWindow.loadExample === 'function'){ f.contentWindow.loadExample(); } + else { alert('Open the Work Package Creation tab first, then load the sample.'); } + return; + } // Populate Step 1 document.getElementById('proj_name').value = 'MICRON_PH1_CUP_HPM_FMCS INSTALL'; document.getElementById('proj_number').value = '26-67-008'; @@ -281,30 +289,32 @@ function repopulateForm(){ // ── TOOL SWITCHING ──────────────────────────────────────────────────────────── function switchTool(tool){ currentTool = tool; - + // 'dashboard' is a pseudo-tab: it reuses the WP tool's content (the embedded + // creator) but opens it straight to the dashboard view. + const isDash = (tool === 'dashboard'); + const contentTool = isDash ? 'wp' : tool; + // Update nav tabs document.querySelectorAll('.nav-tab').forEach(t=>t.classList.remove('active')); - document.querySelector(`[data-tab="${tool}"]`).classList.add('active'); - + const tabBtn = document.querySelector(`[data-tab="${tool}"]`); + if(tabBtn) tabBtn.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 = '—'; - } + document.getElementById(`tool-${contentTool}`).classList.add('active'); - if(tool === 'wp') renderWPTab(); + // Reset step counter + document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—'; + + if(contentTool === 'wp') renderWPTab(isDash); updateStepUI(); updateProjectDisplay(); } // Show the gate or the embedded Work Package Creator depending on SOP status. -function renderWPTab(){ +// wantDash=true opens the creator straight to the dashboard view. +function renderWPTab(wantDash){ const gate = document.getElementById('wp-gate'); const frame = document.getElementById('wp-frame'); if(!gate || !frame) return; @@ -313,9 +323,9 @@ function renderWPTab(){ frame.style.display = 'block'; // Reload each time so the creator picks up the latest SOP from localStorage. const sp = new URLSearchParams(window.location.search); - const wantDash = sp.get('view') === 'dashboard'; + const dash = wantDash || sp.get('view') === 'dashboard'; const projId = sp.get('project') || (activeProject && activeProject.id) || ''; - frame.src = 'wp-creation-index.html?embedded=1' + (wantDash ? '&view=dashboard' : '') + frame.src = 'wp-creation-index.html?embedded=1' + (dash ? '&view=dashboard' : '') + (projId ? '&project=' + encodeURIComponent(projId) : '') + '&t=' + Date.now(); }else{ gate.style.display = 'block'; diff --git a/html/work-package-suite.html b/html/work-package-suite.html index cea153c..cdc26cd 100644 --- a/html/work-package-suite.html +++ b/html/work-package-suite.html @@ -22,7 +22,7 @@
- + 1 / 10 @@ -37,6 +37,9 @@ +
@@ -348,7 +351,7 @@
- +
diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index bdc2f21..68235f2 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -34,12 +34,12 @@ 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']; +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']; // 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","assets":[{"tag":"1P-HS-NORTH","desc":"1P North horn/strobe circuit","link":"https://controls.dev/assets/1P-HS-NORTH"}],"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"}; +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":"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","assets":[{"tag":"1P-HS-NORTH","desc":"1P North horn/strobe circuit","link":"https://controls.dev/assets/1P-HS-NORTH"}],"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; @@ -830,6 +830,36 @@ function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; bu 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(); } +// Duplicate the current work package N times (asks how many). Each copy is a +// fresh Draft with a unique number/subject and approvals/closeout cleared. +function duplicateWP(){ + if(!gv('wp_subject')){ alert('Open or fill in a work package first, then Duplicate.'); return; } + const ans=prompt('How many copies of this work package do you want to create?','1'); + if(ans===null) return; + const n=parseInt(ans,10); + if(!n || n<1 || n>50){ alert('Enter a whole number between 1 and 50.'); return; } + const base=collectPackage(); + const baseNum = base.number || ('WP'+pad2(editingSeq())); + const made=[]; + for(let i=1;i<=n;i++){ + const c=JSON.parse(JSON.stringify(base)); + c.id='wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)+i; + c.number = baseNum + '-C' + i; + c.subject = base.subject + (n>1 ? ' (copy '+i+')' : ' (copy)'); + c.status='Draft'; + c.instanceOf=undefined; c.instanceLabel=undefined; c.parentNumber=undefined; c.split=undefined; c.children=undefined; + if(Array.isArray(c.signoffs)) c.signoffs=c.signoffs.map(s=>({...s, signed:false, date:'', dateReason:''})); + c.holds=[]; c.actualHrs=''; c.installedQty=''; c.redlines=''; c.lessons=''; + c.projectId = base.projectId || activeProjectId || ''; + c.updatedAt=new Date().toISOString(); + savedPackages.push(c); made.push(c); + } + editingId=null; saveStore(); renderSavedList(); + toast('Created '+n+' duplicate'+(n>1?'s':'')); + track('wp_duplicated',{count:n}); + alert('Created '+n+' duplicate'+(n>1?'s':'')+':\n\n• '+made.map(m=>m.number).join('\n• ')+'\n\nThey are in the Saved Work Packages list — edit each as needed.'); +} + 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='';}); diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html index 846c12f..e684e1e 100644 --- a/html/wp-creation-index.html +++ b/html/wp-creation-index.html @@ -13,21 +13,22 @@
Saving work package…
-
+
-
|
+
|
Work Package (IWP)
- - - - - - + + + + + + +
From e5f77846adcb1b82dce69fe716c5302ce8a266b6 Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Tue, 16 Jun 2026 08:37:01 -0700 Subject: [PATCH 8/9] GUI polish: Help section, tooltips, sticky save bar + section nav, dashboard filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Help: shared help.js injects a Help modal (workflow + key concepts) and the .help-tip tooltip component. "❔ Help" added to the suite header and home nav. - Tooltips: ⓘ hover hints on the trickiest fields (WP number auto-build, disciplines, scope/split, constraints, materials-by-discipline, discipline strategy, split threshold). - WP creator: sticky section-nav jump chips at the top and an always-visible sticky save bar (Save Draft / Save & View) showing live release readiness. - Dashboard: metric cards (Release-ready / On hold / Overdue / Total) and the status chips are now clickable filters for the board. - Consistent colored status pills in the dashboard board and the saved list. Theme unification (home Carbon vs tools palette) intentionally left for a separate pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- html/help.js | 80 ++++++++++++++++++++++++++++++++++++ html/index.html | 2 + html/work-package-suite.html | 6 ++- html/wp-creation-app.js | 78 +++++++++++++++++++++++++++++------ html/wp-creation-index.html | 23 ++++++++--- html/wp-creation-styles.css | 25 +++++++++++ 6 files changed, 194 insertions(+), 20 deletions(-) create mode 100644 html/help.js diff --git a/html/help.js b/html/help.js new file mode 100644 index 0000000..4597c61 --- /dev/null +++ b/html/help.js @@ -0,0 +1,80 @@ +/* Shared Help + tooltip module for the Work Package Suite. + Included by the home page, the suite, and the embedded creator. It injects: + - tooltip styles for the .help-tip (ⓘ) component and [data-tip] hovers + - a Help modal (workflow + key concepts) opened via window.openHelp() + Add a "❔ Help" button anywhere with onclick="openHelp()". */ +(function (global) { + 'use strict'; + + var css = ` + .help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px; + margin-left:5px; border-radius:50%; background:#5a6675; color:#fff; font-size:10px; font-weight:700; + font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; } + .help-tip::after{ content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%); + background:#1a2230; color:#fff; padding:7px 10px; border-radius:6px; font-size:12px; font-weight:400; + line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999; + opacity:0; pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); } + .help-tip::before{ content:''; position:absolute; bottom:130%; left:50%; transform:translate(-50%,95%); + border:5px solid transparent; border-top-color:#1a2230; opacity:0; transition:opacity .12s; z-index:9999; } + .help-tip:hover::after, .help-tip:hover::before, .help-tip:focus::after, .help-tip:focus::before{ opacity:1; } + + .ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:flex-start; + justify-content:center; z-index:10000; padding:5vh 16px; overflow:auto; } + .ui-help-overlay.open{ display:flex; } + .ui-help-modal{ background:#fff; color:#1a2230; max-width:680px; width:100%; border-radius:10px; + box-shadow:0 12px 40px rgba(20,30,50,.3); font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; } + .ui-help-head{ display:flex; align-items:center; justify-content:space-between; padding:16px 20px; + border-bottom:1px solid #e3e6ec; font-size:16px; } + .ui-help-head button{ background:none; border:none; font-size:18px; cursor:pointer; color:#5a6675; line-height:1; } + .ui-help-body{ padding:18px 22px; font-size:13.5px; line-height:1.6; } + .ui-help-body h4{ margin:18px 0 6px; font-size:13px; text-transform:uppercase; letter-spacing:.03em; color:#2563d6; } + .ui-help-body h4:first-child{ margin-top:0; } + .ui-help-body ol, .ui-help-body ul{ margin:0 0 6px; padding-left:20px; } + .ui-help-body li{ margin-bottom:5px; } + .ui-help-body code{ background:#f0f2f5; padding:1px 5px; border-radius:4px; font-size:12px; } + `; + var style = document.createElement('style'); + style.textContent = css; + (document.head || document.documentElement).appendChild(style); + + var HELP_HTML = ` +

How the suite works

+
    +
  1. Pick or create a Project on the home page — projects are stored centrally and each keeps its own SOP and Work Packages.
  2. +
  3. SOP Configuration — set the project baseline (team, sign-offs, WP types, governance & sizing, quality, sequence, constraints, sources). Every Work Package inherits these defaults.
  4. +
  5. Work Package Creation — author individual IWPs against the SOP. Use New for a blank one or Duplicate to copy an existing one.
  6. +
  7. Dashboard — track status, hours, and what's gating each package across the project.
  8. +
+ +

Key concepts

+
    +
  • Constraints & release readiness: a package can't move to Issued until every constraint is Cleared or N/A. If a constraint reopens after release, the package drops to Issue (Hold).
  • +
  • Disciplines & Split: a package can carry more than one discipline (e.g. Mechanical + Electrical + Tech), each with its own scope section and status. Split by Discipline breaks it into instances — WP01A, WP01B, WP01C — each tied to the master.
  • +
  • WP size: the SOP sets a typical size band, which sets a max-hours split threshold. The creator warns when a package's estimated hours exceed it so it can be broken down.
  • +
  • Material by discipline: on a multi-discipline package each material line can be tagged to a discipline; splitting routes each instance only its own materials.
  • +
+ +

Tips

+
    +
  • Load Sample is context-aware — it loads the sample SOP on the SOP tab and an example Work Package on the WP tab.
  • +
  • Data is kept per project; switch projects from the home page.
  • +
  • Hover the i icons for inline hints.
  • +
`; + + function buildModal() { + if (document.getElementById('ui-help-overlay')) return; + var overlay = document.createElement('div'); + overlay.className = 'ui-help-overlay'; + overlay.id = 'ui-help-overlay'; + overlay.innerHTML = ''; + overlay.addEventListener('click', function (e) { if (e.target === overlay) closeHelp(); }); + document.body.appendChild(overlay); + } + + global.openHelp = function () { buildModal(); document.getElementById('ui-help-overlay').classList.add('open'); }; + global.closeHelp = function () { var o = document.getElementById('ui-help-overlay'); if (o) o.classList.remove('open'); }; + document.addEventListener('keydown', function (e) { if (e.key === 'Escape') global.closeHelp(); }); +})(window); diff --git a/html/index.html b/html/index.html index 5967045..d258f44 100644 --- a/html/index.html +++ b/html/index.html @@ -386,6 +386,7 @@
@@ -475,6 +476,7 @@ + + diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index 68235f2..f98b895 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -539,6 +539,7 @@ function updateReleaseBanner(){ 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=`
${txt}
`; + updateStickyStatus(); } function onStatusChange(target){ const idx=STATUS_ORDER.indexOf(target); @@ -761,8 +762,37 @@ function printPackage(){ // ── VIEWS ──────────────────────────────────────────────────────────────────── function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; } -function showOutput(){ hideDashboard(); 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(){ hideDashboard(); 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'; buildDisciplinePicker(); renderScope(); currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); } +function showOutput(){ hideDashboard(); setFormChrome(false); 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(){ hideDashboard(); 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'; buildDisciplinePicker(); renderScope(); setFormChrome(true); currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); } + +// Sticky save bar + section-nav chrome (shown only on the editable form view). +function setFormChrome(on){ + const nav=document.getElementById('section-nav'), save=document.getElementById('sticky-save'); + if(nav) nav.style.display = on ? '' : 'none'; + if(save) save.style.display = on ? 'flex' : 'none'; + document.body.classList.toggle('has-sticky-save', !!on); + if(on){ buildSectionNav(); updateStickyStatus(); } +} +function buildSectionNav(){ + const nav=document.getElementById('section-nav'); if(!nav) return; + const chips=[]; + document.querySelectorAll('.main > .card').forEach((card,i)=>{ + if(card.id==='saved-card' || card.style.display==='none') return; + const h=card.querySelector('.section-title, .sub-heading'); if(!h) return; + const clone=h.cloneNode(true); clone.querySelectorAll('.help-tip').forEach(x=>x.remove()); + const label=clone.textContent.trim().replace(/\s+/g,' '); if(!label) return; + if(!card.id) card.id='sec-'+i; + chips.push(`${esc(label)}`); + }); + nav.innerHTML=chips.join(''); +} +function updateStickyStatus(){ + const el=document.getElementById('sticky-status'); if(!el) return; + const r=readiness(); const st=getRadio('status'); + if(st==='Issue'){ el.className='sticky-status ss-hold'; el.textContent=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened`; } + else if(r.ready){ el.className='sticky-status ss-ready'; el.textContent=`✓ Release-ready — all ${r.total} constraints cleared`; } + else { el.className='sticky-status ss-notready'; el.textContent=`⚠ ${r.open} of ${r.total} constraint${r.open===1?'':'s'} open`; } +} // ── SAVED PACKAGES ─────────────────────────────────────────────────────────── const STORE_KEY='wp_iwp_v1'; @@ -780,7 +810,7 @@ function renderSavedList(){ const tag = p.split?' master':(p.instanceOf?` ${esc(p.instanceLabel||'instance')}`:''); const disc = (p.disciplines&&p.disciplines.length)?`
${esc(p.disciplines.join(', '))}
`:''; return `${esc(p.number||'—')}${tag}${disc}${esc(p.type||'')}${esc(p.subject||'')} - ${esc(p.status||'')}${ready} + ${statusPill(p.status)}${ready} `; }).join(''); } @@ -898,13 +928,26 @@ const WPData = { p.status=status; p.updatedAt=new Date().toISOString(); saveStore(); return true; }, // → POST /api/wps/{id}/status }; -let dashFilter={status:'',discipline:'',q:''}; +let dashFilter={status:'',discipline:'',q:'',flag:''}; +function dashToggleFlag(f){ + if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:''}; } + else { dashFilter.flag = dashFilter.flag===f ? '' : f; } + renderDashboard(); +} +function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; renderDashboard(); } +// Consistent colored status pill, reused by the dashboard board and the saved list. +function statusPill(s){ + const map={'Draft':'badge-NA','Scheduled':'badge-O','Issued':'badge-Y','In Progress':'badge-O','QC':'badge-O','Closed':'badge-Y','Issue':'badge-N'}; + const label = s==='Issue' ? 'Issue (Hold)' : (s||'—'); + return `${esc(label)}`; +} function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); } function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todayStr()); } // Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted. function countableWPs(){ return WPData.list().filter(p=>!p.split); } function showDashboard(){ + setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display='none'; const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display=''; @@ -923,19 +966,25 @@ function renderDashboard(){ if(isOverdue(p)) overdue++; (p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1); }); - const card=(label,val,cls)=>`
${val}
${esc(label)}
`; + // Clickable metric cards filter the board (flag-based); a card with no flag is static. + const card=(label,val,cls,flag)=>{ + const active = flag && dashFilter.flag===flag ? ' dm-active' : ''; + const attr = flag ? ` onclick="dashToggleFlag('${flag}')" title="Click to filter the board"` : ''; + return `
${val}
${esc(label)}
`; + }; let h=`
- ${card('Total WPs', all.length)} - ${card('Release-ready', ready, ready?'dm-green':'')} - ${card('On hold', hold, hold?'dm-red':'')} - ${card('Overdue', overdue, overdue?'dm-red':'')} + ${card('Total WPs', all.length, '', 'all')} + ${card('Release-ready', ready, ready?'dm-green':'', 'ready')} + ${card('On hold', hold, hold?'dm-red':'', 'onhold')} + ${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')} ${card('Est. hrs', Math.round(estH))} ${card('Actual hrs', Math.round(actH))}
`; - // status + discipline breakdown chips - const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>`${esc(s)}: ${byStatus[s]}`).join('') - + (byStatus['Issue']?`On Hold: ${byStatus['Issue']}`:''); + // status + discipline breakdown chips (status chips also filter the board) + const statusChip=(label,count,cls,status)=>`${esc(label)}: ${count}`; + const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>statusChip(s,byStatus[s],'',s)).join('') + + (byStatus['Issue']?statusChip('On Hold',byStatus['Issue'],'chip-red','Issue'):''); const discChips=Object.keys(byDisc).map(d=>`${esc(d)}: ${byDisc[d]}`).join('')||''; h+=`
By status
${statusChips||'—'}
By discipline
${discChips}
`; @@ -965,6 +1014,9 @@ function renderDashboard(){ if(dashFilter.status && p.status!==dashFilter.status) return false; if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false; if(q && !((p.number||'')+' '+(p.subject||'')).toLowerCase().includes(q)) return false; + if(dashFilter.flag==='ready' && !(!p.split && wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue')) return false; + if(dashFilter.flag==='onhold' && p.status!=='Issue') return false; + if(dashFilter.flag==='overdue' && !isOverdue(p)) return false; return true; }); h+=`
Work Packages (${rows.length})
@@ -980,7 +1032,7 @@ function renderDashboard(){ h+=`${esc(p.number||'—')}${p.instanceOf?` ${esc(p.instanceLabel||'')}`:''} ${esc(p.subject||'')}${esc(p.type||'')} ${esc((p.disciplines||[]).join(', '))||ns()} - ${esc(p.status||'')}${gates}${due}${cell(p.hours)} + ${statusPill(p.status)}${gates}${due}${cell(p.hours)} ${issueBtn} `; }); h+=`
`; diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html index e684e1e..08e959e 100644 --- a/html/wp-creation-index.html +++ b/html/wp-creation-index.html @@ -38,6 +38,9 @@
+ +
+
@@ -45,7 +48,7 @@
General Information
Parameters in blue are inherited from the project SOP. Fill the rest for this package.
-
+
@@ -88,14 +91,14 @@
-
Scope & Work
+
Scope & Worki
Enter the work as ordered steps — added in sequence, the way the crew performs them.
@@ -113,7 +116,7 @@
-
Material List
+
Material Listi
Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.
QtyUnitDescription
@@ -157,7 +160,7 @@
-
Constraints — Release Readiness
+
Constraints — Release Readinessi
Per AWP, a package is not released to the field until every constraint is Cleared or N/A. If a constraint reopens after release, status drops to Issue (Hold).
ConstraintStatusComment
@@ -276,8 +279,18 @@
+ + + + diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css index 92953ac..9c84d05 100644 --- a/html/wp-creation-styles.css +++ b/html/wp-creation-styles.css @@ -564,6 +564,26 @@ .so-date { font-size:13px; font-variant-numeric:tabular-nums; } .so-ovr { margin-left:8px; font-size:11px; } + /* Section nav (jump chips) */ + .section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px; + padding:8px 12px; background:rgba(255,255,255,.92); backdrop-filter:blur(4px); + border-bottom:1px solid var(--border); } + .section-nav-bar:empty{ display:none; } + .sec-chip{ font-size:12px; font-weight:600; color:var(--text-muted); background:var(--surface2); + border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; } + .sec-chip:hover{ border-color:var(--accent); color:var(--accent); } + + /* Sticky save bar */ + .sticky-save{ position:fixed; left:0; right:0; bottom:0; z-index:40; display:flex; align-items:center; + justify-content:space-between; gap:14px; padding:10px 20px; background:#fff; + border-top:1px solid var(--border-strong); box-shadow:0 -2px 10px rgba(20,30,50,.08); } + .sticky-save .sticky-status{ font-size:13px; font-weight:600; } + .sticky-save .sticky-actions{ display:flex; gap:10px; } + .ss-ready{ color:var(--accent-green); } + .ss-notready{ color:var(--accent-amber); } + .ss-hold{ color:var(--red); } + body.has-sticky-save .main{ padding-bottom:74px; } + /* Disciplines + per-discipline scope */ .disc-picker { display:flex; flex-wrap:wrap; gap:8px; } .disc-pill { display:flex; align-items:center; gap:7px; padding:7px 13px; border:1px solid var(--border-strong); @@ -586,6 +606,11 @@ .dash-metric .dm-label { font-size:11px; color:var(--text-muted); margin-top:6px; text-transform:uppercase; letter-spacing:.03em; } .dash-metric.dm-green .dm-val { color:var(--accent-green); } .dash-metric.dm-red .dm-val { color:var(--red); } + .dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; } + .dash-metric[onclick]:hover { border-color:var(--accent); } + .dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); } + .dash-chip[onclick] { cursor:pointer; } + .dash-chip.chip-active { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); } .dash-breakdown { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; } .dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; } .dash-chip { display:inline-block; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:14px; padding:3px 10px; margin:0 6px 6px 0; } From a32c275f769b9f18e9224ba6e2d24f2849e28297 Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Tue, 16 Jun 2026 09:27:38 -0700 Subject: [PATCH 9/9] Rewrite DEPLOYMENT.md for the SQL-backed Docker deployment Replaces the stale non-Docker/systemd guide with an admin-facing, start-to- finish guide for the actual stack (nginx serving html/, FastAPI api, Postgres db). Covers prerequisites (external proxy network), the root .env credentials, reverse-proxy wiring, bring-up, and verification. Adds the current data model (projects + project_id/parent_id/issued_at columns), the full endpoint list, a "what's stored in SQL today vs Phase 2" table, backups, and the schema-migration caveat (create_all adds tables, not columns). Points to server/README.md for the deep container reference. Co-Authored-By: Claude Opus 4.8 (1M context) --- DEPLOYMENT.md | 222 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 165 insertions(+), 57 deletions(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 3ac9464..e5e71c6 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,81 +1,189 @@ # Deployment -The Work Package Suite has two parts: +Audience: the IT admin standing this up inside the firewall. This covers the +**SQL-backed deployment** — NGINX serving the static front end and a Python API +backed by **PostgreSQL**. -- a **static front end** (plain HTML/CSS/JS — no build step), and -- a **Python API** (FastAPI) backed by **PostgreSQL**, which stores the project - SOPs, Work Packages, and comments so they are shared across users instead of - living in each person's browser. +The repo already contains everything needed to run it as a Docker stack: +`Dockerfile`, `docker-compose.yml`, the `nginx/` config, the front end in +`html/`, and the API in `server/`. The detailed container reference (endpoints, +password rotation, day-to-day commands) lives in +[`server/README.md`](server/README.md) — this doc is the start-to-finish guide. ``` -browser → NGINX ──serves──> static site (index.html, …) - └─proxy /api/─> Python API (uvicorn/gunicorn :8000) → PostgreSQL + [ your TLS reverse proxy / traefik ] ← HTTPS terminates here + │ (external "proxy" network) + ┌────▼────┐ internal network ┌──────────┐ ┌────────────┐ + browser ───────────────────────│ nginx │ ───── /api/ ───────> │ api │ → │ postgres │ + │ (html/) │ │ FastAPI │ │ (db) │ + └─────────┘ └──────────┘ └────────────┘ ``` Everything runs inside your firewall; the app makes **no outbound internet -calls** (the logo and scripts are local and the old Google-Fonts dependency was -removed). +calls** (logo and scripts are local). -## 1. Front end (NGINX) +> **Architecture note:** all static files live under **`html/`** and are *baked +> into the nginx image* at build time (not bind-mounted). So after any front-end +> change you rebuild the `webserver` image (see *Updating* below). The API image +> is built from the root `Dockerfile`. -Copy the project files to a web root and serve them over HTTPS. The provided -[`nginx-wp-suite.conf`](nginx-wp-suite.conf) serves the static files and proxies -`/api/` to the Python API. Set `server_name`, the `ssl_certificate` paths, and -`root`, then `sudo nginx -t && sudo systemctl reload nginx`. +--- -Serving over real HTTP(S) (not `file://`) also makes the embedded Work Package -Creator (`