Compare commits
13 Commits
108a690f66
...
savepoint-
| Author | SHA1 | Date | |
|---|---|---|---|
| e5c450597a | |||
| 3c40b58ff8 | |||
| 4d111d608d | |||
| 0baf4befc0 | |||
| d1f0901897 | |||
| a37cf14e89 | |||
| 362aa633ed | |||
| fd668f0ea2 | |||
| 960b4a4b94 | |||
| 8598606165 | |||
| 65996c2c0a | |||
| b0a3d74412 | |||
| ffcaa571d1 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,3 +11,6 @@ venv/
|
||||
# Local SQLite dev database
|
||||
*.db
|
||||
wpsuite.db
|
||||
|
||||
# Runtime directories (created by containers)
|
||||
logs/
|
||||
|
||||
8
Dockerfile
Normal file
8
Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY server/requirements.txt ./server/
|
||||
RUN pip install --no-cache-dir -r server/requirements.txt
|
||||
COPY server/ ./server/
|
||||
EXPOSE 8000
|
||||
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", \
|
||||
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]
|
||||
@@ -79,6 +79,99 @@ 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`.
|
||||
|
||||
## Multi-project support
|
||||
|
||||
> **Note on layout:** the IT admin moved all static files into **`html/`** and
|
||||
> added a Docker/NGINX deployment (`Dockerfile`, `docker-compose.yml`, `nginx/`).
|
||||
> Front-end paths below are under `html/`. `server/` stayed at the repo root.
|
||||
|
||||
The suite is now multi-project. **Projects are the top-level container**; every
|
||||
SOP and Work Package belongs to one.
|
||||
|
||||
- **Backend:** new `projects` table + CRUD (`/api/projects`). `sops` gained
|
||||
`project_id` (FK, cascade) and `work_packages` gained `project_id`; list/latest/
|
||||
metrics endpoints accept a `project_id` filter.
|
||||
- **Project layer:** [html/project-data.js](html/project-data.js) — a shared,
|
||||
**API-first** `ProjectData` adapter (`list/get/save/remove` hit `/api/projects`)
|
||||
that **falls back to a localStorage mirror** (`wp_projects`) when the API is
|
||||
unreachable, plus active-project helpers (`getActive`/`setActive`, stored in
|
||||
`wp_active_project` / `wp_active_project_obj`).
|
||||
- **Home page** ([html/index.html](html/index.html)): "About This Suite" removed;
|
||||
a **Project** picker added. With no projects it offers *Create Project* / *Use
|
||||
Sample Project*; otherwise a dropdown to select. The tool cards stay hidden
|
||||
until a project is active and then carry `&project=<id>`; the hero shows the
|
||||
active project.
|
||||
- **Suite** ([html/work-package-suite-app.js](html/work-package-suite-app.js)):
|
||||
reads `?project=<id>`, resolves it via `ProjectData`, shows it in the header,
|
||||
and **prefills the SOP project fields** (step 1) from the project record when
|
||||
empty. Passes `&project` into the WP-creator iframe.
|
||||
- **WP creator:** stamps `projectId` onto every saved package (for API sync).
|
||||
|
||||
**Per-project isolation (done, local):** SOP/WP localStorage keys are now
|
||||
namespaced per active project via `ProjectData.key(base)` →
|
||||
`base + '__' + <projectId>` (`SK()` in the suite, `wpKey()` in the creator).
|
||||
So each project keeps its own `wp_suite_sop` / `wp_suite_state` /
|
||||
`wp_suite_sop_complete` / `wp_iwp_v1`. On first load after this change,
|
||||
`project-data.js` runs a **one-time discard** of the legacy un-namespaced keys
|
||||
(guarded by `wp_ns_migrated_v1`) — chosen over migrating, since the local data
|
||||
was throwaway demo content.
|
||||
|
||||
**Still ahead (true Phase 2):** move SOP/WP reads+writes to the API filtered by
|
||||
`project_id` (`GET /api/sops/latest?project_id=…`, `GET /api/wps?project_id=…`)
|
||||
so projects are shared across users, not just isolated per browser. The
|
||||
endpoints already accept the `project_id` filter; the front end still reads
|
||||
localStorage.
|
||||
|
||||
**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`).
|
||||
|
||||
57
docker-compose.yml
Normal file
57
docker-compose.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
services:
|
||||
|
||||
webserver:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: nginx/Dockerfile
|
||||
container_name: nginx_webserver
|
||||
volumes:
|
||||
- nginx_logs:/var/log/nginx
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
networks:
|
||||
- proxy # external — reachable by your reverse proxy / traefik
|
||||
- internal # needs a path to the api container
|
||||
|
||||
api:
|
||||
build: .
|
||||
container_name: wp_api
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy # waits for postgres to accept connections
|
||||
networks:
|
||||
- internal
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: wp_db
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- internal
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
nginx_logs:
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
name: proxy
|
||||
external: true
|
||||
internal:
|
||||
internal: true # no outbound internet access from api/db
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
@@ -348,6 +348,22 @@
|
||||
color: var(--cds-text-primary);
|
||||
}
|
||||
|
||||
/* PROJECT PICKER */
|
||||
.proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; }
|
||||
.proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
|
||||
.proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px;
|
||||
border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; background: #fff; }
|
||||
.proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d);
|
||||
border-radius: 6px; padding: 1.25rem; }
|
||||
.proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); }
|
||||
.proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); border-radius: 6px; background: var(--cds-ui-01, #fff); }
|
||||
.proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; }
|
||||
.proj-form-grid label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); }
|
||||
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; }
|
||||
.proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); }
|
||||
.link-like { background: none; border: none; color: var(--cds-link-01, #0f62fe); cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; }
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (max-width: 768px) {
|
||||
.header-content { flex-direction: column; text-align: center; }
|
||||
@@ -379,12 +395,19 @@
|
||||
|
||||
<!-- HERO -->
|
||||
<div class="hero">
|
||||
<h1>Work Package Suite</h1>
|
||||
<p>Standardized approach to Work Package creation for Prime Controls construction projects. Configure project parameters, define constraints, and generate compliant work packages.</p>
|
||||
<h1 id="hero-title">Work Package Suite</h1>
|
||||
<p id="hero-sub">Standardized Work Package creation for Prime Controls construction projects. Select a project to begin — or create one.</p>
|
||||
</div>
|
||||
|
||||
<!-- TOOL CARDS -->
|
||||
<div class="cards-grid" id="overview">
|
||||
<!-- PROJECT SELECTION -->
|
||||
<div class="section" id="project-section">
|
||||
<h2>Project</h2>
|
||||
<p style="color:var(--cds-text-secondary);font-size:13px;margin:-.25rem 0 1rem">Projects are stored centrally. Pick the project you're working on, or set up a new one.</p>
|
||||
<div id="project-picker"><div class="proj-loading">Loading projects…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- TOOL CARDS (shown once a project is active) -->
|
||||
<div class="cards-grid" id="overview" style="display:none">
|
||||
|
||||
<!-- SOP CONFIG -->
|
||||
<a href="work-package-suite.html?tab=sop" class="card" id="card-sop">
|
||||
@@ -400,6 +423,13 @@
|
||||
<button class="card-button" id="card-wp-btn">Open Tool</button>
|
||||
</a>
|
||||
|
||||
<!-- WP DASHBOARD -->
|
||||
<a href="work-package-suite.html?view=dashboard" class="card" id="card-dash">
|
||||
<h3>Work Package Dashboard</h3>
|
||||
<p>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.</p>
|
||||
<button class="card-button" id="card-dash-btn">Open Dashboard</button>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- QUICK START -->
|
||||
@@ -436,25 +466,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SUPPORT SECTION -->
|
||||
<div class="section">
|
||||
<h2>About This Suite</h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem;">
|
||||
<div>
|
||||
<h3>Two-Step Workflow</h3>
|
||||
<p>Configure the project SOP once, then author every Work Package against it. The Creator stays locked until the SOP is complete, so packages always inherit a valid baseline.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Leave Feedback</h3>
|
||||
<p>Use the feedback section on this page or within any tool. All comments are stored locally and can be exported for team review and iteration.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Offline & Collaborative</h3>
|
||||
<p>All tools work entirely in your browser. Export SOP and Work Package data as JSON for sharing, version control, and integration.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
@@ -463,13 +474,136 @@
|
||||
</footer>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script>
|
||||
// Reflect SOP completion on the tool cards.
|
||||
(function reflectSOPStatus(){
|
||||
// ── PROJECT SELECTION ─────────────────────────────────────────────────────
|
||||
const esc = ProjectData.esc;
|
||||
let _projects = [];
|
||||
|
||||
function initProjects(){
|
||||
ProjectData.list().then(list => {
|
||||
_projects = list || [];
|
||||
// Reconcile the active project against the list; clear if it's gone.
|
||||
const active = ProjectData.getActive();
|
||||
if(active && !_projects.some(p => p.id === active.id)) ProjectData.setActive(null);
|
||||
renderProjectPicker();
|
||||
applyActiveProject();
|
||||
});
|
||||
}
|
||||
|
||||
function createFormHtml(){
|
||||
return `<div class="proj-form" id="proj-form" style="display:none">
|
||||
<div class="proj-form-grid">
|
||||
<label>Project Name *<input type="text" id="np_name" placeholder="e.g. Micron — INC Construction"></label>
|
||||
<label>Project Number<input type="text" id="np_number" placeholder="e.g. 26-67-008"></label>
|
||||
<label>Client<input type="text" id="np_client" placeholder="e.g. Micron Technology, Inc."></label>
|
||||
<label>Division<input type="text" id="np_division" placeholder="e.g. Semiconductor"></label>
|
||||
<label>Site / Location<input type="text" id="np_site" placeholder="e.g. Boise, ID — Fab"></label>
|
||||
</div>
|
||||
<div class="proj-actions">
|
||||
<button class="card-button" onclick="saveNewProject()">Create & Select</button>
|
||||
<button class="close-btn" onclick="hideCreateProject()">Cancel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderProjectPicker(){
|
||||
const box = document.getElementById('project-picker');
|
||||
const activeId = ProjectData.getActiveId();
|
||||
if(!_projects.length){
|
||||
box.innerHTML = `<div class="proj-empty">
|
||||
<p>No projects yet. Create your first project, or start from a sample.</p>
|
||||
<div class="proj-actions">
|
||||
<button class="card-button" onclick="showCreateProject()">+ Create Project</button>
|
||||
<button class="close-btn" onclick="useSampleProject()">Use Sample Project</button>
|
||||
</div>
|
||||
</div>` + createFormHtml();
|
||||
return;
|
||||
}
|
||||
const opts = _projects.map(p =>
|
||||
`<option value="${esc(p.id)}" ${p.id===activeId?'selected':''}>${esc(p.name||'(unnamed)')}${p.number?' — '+esc(p.number):''}${p.sample?' [sample]':''}</option>`
|
||||
).join('');
|
||||
box.innerHTML = `<div class="proj-row">
|
||||
<select id="project-select" onchange="selectProject(this.value)">
|
||||
<option value="">Select a project…</option>${opts}
|
||||
</select>
|
||||
<button class="card-button" onclick="showCreateProject()">+ New</button>
|
||||
<button class="close-btn" onclick="useSampleProject()">Sample</button>
|
||||
</div>
|
||||
<div id="active-project-info"></div>` + createFormHtml();
|
||||
}
|
||||
|
||||
function showCreateProject(){ const f=document.getElementById('proj-form'); if(f){ f.style.display=''; const n=document.getElementById('np_name'); if(n) n.focus(); } }
|
||||
function hideCreateProject(){ const f=document.getElementById('proj-form'); if(f) f.style.display='none'; }
|
||||
|
||||
function saveNewProject(){
|
||||
const v = id => (document.getElementById(id)?.value || '').trim();
|
||||
const name = v('np_name');
|
||||
if(!name){ alert('Project name is required.'); return; }
|
||||
const p = { name, number:v('np_number'), client:v('np_client'), division:v('np_division'), site:v('np_site'), sample:false };
|
||||
ProjectData.save(p).then(saved => { afterProjectChosen(saved); });
|
||||
}
|
||||
|
||||
function useSampleProject(){
|
||||
const existing = _projects.find(p => p.sample);
|
||||
if(existing){ afterProjectChosen(existing); return; }
|
||||
ProjectData.save(Object.assign({}, ProjectData.SAMPLE)).then(saved => { afterProjectChosen(saved); });
|
||||
}
|
||||
|
||||
function selectProject(id){
|
||||
if(!id){ ProjectData.setActive(null); applyActiveProject(); return; }
|
||||
const p = _projects.find(x => x.id === id);
|
||||
if(p){ ProjectData.setActive(p); applyActiveProject(); }
|
||||
}
|
||||
|
||||
function afterProjectChosen(p){
|
||||
if(!_projects.some(x => x.id === p.id)) _projects.unshift(p);
|
||||
ProjectData.setActive(p);
|
||||
renderProjectPicker();
|
||||
applyActiveProject();
|
||||
document.getElementById('overview').scrollIntoView({ behavior:'smooth', block:'start' });
|
||||
}
|
||||
|
||||
// Show/hide the tool cards and stamp the active project into their links.
|
||||
function applyActiveProject(){
|
||||
const active = ProjectData.getActive();
|
||||
const cards = document.getElementById('overview');
|
||||
const heroTitle = document.getElementById('hero-title');
|
||||
const heroSub = document.getElementById('hero-sub');
|
||||
const info = document.getElementById('active-project-info');
|
||||
|
||||
if(!active){
|
||||
cards.style.display = 'none';
|
||||
heroTitle.textContent = 'Work Package Suite';
|
||||
heroSub.textContent = 'Select a project to begin — or create one.';
|
||||
if(info) info.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const q = '&project=' + encodeURIComponent(active.id);
|
||||
const setHref = (id, base) => { const el=document.getElementById(id); if(el) el.href = base + q; };
|
||||
setHref('card-sop', 'work-package-suite.html?tab=sop');
|
||||
setHref('card-wp', 'work-package-suite.html?tab=wp');
|
||||
setHref('card-dash', 'work-package-suite.html?view=dashboard');
|
||||
|
||||
cards.style.display = '';
|
||||
heroTitle.textContent = active.name || 'Work Package Suite';
|
||||
heroSub.textContent = [active.number, active.client, active.site].filter(Boolean).join(' · ') || 'Active project';
|
||||
if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''}
|
||||
<button class="link-like" onclick="clearActiveProject()">change</button></div>`;
|
||||
|
||||
reflectSOPStatus(active);
|
||||
}
|
||||
|
||||
function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); }
|
||||
|
||||
// Reflect SOP completion on the tool cards (scoped to the active project).
|
||||
function reflectSOPStatus(active){
|
||||
let complete = false, projName = '';
|
||||
try {
|
||||
complete = localStorage.getItem('wp_suite_sop_complete') === '1';
|
||||
const sop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null');
|
||||
// Storage is namespaced per project, so these already scope to `active`.
|
||||
complete = localStorage.getItem(ProjectData.key('wp_suite_sop_complete')) === '1';
|
||||
const sop = JSON.parse(localStorage.getItem(ProjectData.key('wp_suite_sop')) || 'null');
|
||||
projName = sop && sop.project && sop.project.name || '';
|
||||
} catch(e){}
|
||||
|
||||
@@ -477,6 +611,12 @@
|
||||
const sopBtn = document.getElementById('card-sop-btn');
|
||||
const wpCard = document.getElementById('card-wp');
|
||||
const wpBtn = document.getElementById('card-wp-btn');
|
||||
if(!sopCard) return;
|
||||
|
||||
// reset (re-render can run multiple times)
|
||||
sopCard.classList.remove('complete');
|
||||
wpCard && wpCard.classList.remove('disabled');
|
||||
const oldStatus = sopCard.querySelector('.card-status'); if(oldStatus) oldStatus.remove();
|
||||
|
||||
if(complete){
|
||||
sopCard.classList.add('complete');
|
||||
@@ -490,7 +630,9 @@
|
||||
if(wpCard) wpCard.classList.add('disabled');
|
||||
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
initProjects();
|
||||
|
||||
let allComments = [];
|
||||
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
96
html/project-data.js
Normal file
96
html/project-data.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/* Shared project layer for the Work Package Suite.
|
||||
Projects are the top-level container — every SOP and Work Package belongs to
|
||||
one. Project records live in the SQL database (via /api/projects); this
|
||||
adapter is API-first and falls back to a localStorage mirror so the suite
|
||||
still works in local dev / offline. Included by the home page and the suite. */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var API = '/api';
|
||||
var LS_PROJECTS = 'wp_projects'; // local mirror of the project list
|
||||
var LS_ACTIVE = 'wp_active_project'; // active project id
|
||||
var LS_ACTIVE_OBJ = 'wp_active_project_obj';
|
||||
|
||||
function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
|
||||
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
|
||||
function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } }
|
||||
function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} }
|
||||
function cacheUpsert(p) {
|
||||
var list = readLocal();
|
||||
var ix = list.findIndex(function (x) { return x.id === p.id; });
|
||||
if (ix >= 0) list[ix] = p; else list.unshift(p);
|
||||
writeLocal(list);
|
||||
}
|
||||
function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); }
|
||||
|
||||
var SAMPLE_PROJECT = {
|
||||
name: 'Micron — INC Construction Work Packages', number: '26-67-008',
|
||||
client: 'Micron Technology, Inc.', division: 'Semiconductor',
|
||||
site: 'Boise, ID — Fab', sample: true
|
||||
};
|
||||
|
||||
var ProjectData = {
|
||||
SAMPLE: SAMPLE_PROJECT,
|
||||
esc: esc,
|
||||
|
||||
// Returns the project list. Tries the API; falls back to the local mirror.
|
||||
list: function () {
|
||||
return fetch(API + '/projects', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||
.then(function (rows) { writeLocal(rows); return rows; })
|
||||
.catch(function () { return readLocal(); });
|
||||
},
|
||||
|
||||
get: function (id) {
|
||||
return fetch(API + '/projects/' + encodeURIComponent(id))
|
||||
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||
.catch(function () { return readLocal().find(function (x) { return x.id === id; }) || null; });
|
||||
},
|
||||
|
||||
// Create or update. Assigns an id when new. Mirrors to localStorage either way.
|
||||
save: function (p) {
|
||||
if (!p.id) p.id = uid();
|
||||
return fetch(API + '/projects', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p)
|
||||
})
|
||||
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||
.then(function (saved) { cacheUpsert(saved); return saved; })
|
||||
.catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only
|
||||
},
|
||||
|
||||
remove: function (id) {
|
||||
return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' })
|
||||
.then(function () { cacheRemove(id); })
|
||||
.catch(function () { cacheRemove(id); });
|
||||
},
|
||||
|
||||
// ── active project context ────────────────────────────────────────────────
|
||||
getActiveId: function () { try { return localStorage.getItem(LS_ACTIVE) || ''; } catch (e) { return ''; } },
|
||||
getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } },
|
||||
setActive: function (p) {
|
||||
try {
|
||||
if (p) { localStorage.setItem(LS_ACTIVE, p.id); localStorage.setItem(LS_ACTIVE_OBJ, JSON.stringify(p)); }
|
||||
else { localStorage.removeItem(LS_ACTIVE); localStorage.removeItem(LS_ACTIVE_OBJ); }
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
// Per-project namespacing for the SOP/WP localStorage keys, e.g.
|
||||
// key('wp_iwp_v1') → 'wp_iwp_v1__proj_ab12'
|
||||
// Falls back to the bare key when no project is active.
|
||||
key: function (base) { var id = this.getActiveId(); return id ? base + '__' + id : base; }
|
||||
};
|
||||
|
||||
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale
|
||||
// global state can't leak across projects. (User chose: discard, don't migrate.)
|
||||
try {
|
||||
if (!localStorage.getItem('wp_ns_migrated_v1')) {
|
||||
['wp_suite_sop', 'wp_suite_state', 'wp_suite_sop_complete', 'wp_iwp_v1'].forEach(function (k) {
|
||||
try { localStorage.removeItem(k); } catch (e) {}
|
||||
});
|
||||
localStorage.setItem('wp_ns_migrated_v1', '1');
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
global.ProjectData = ProjectData;
|
||||
})(window);
|
||||
@@ -4,13 +4,17 @@ let currentStep = 1;
|
||||
let sopComplete = false;
|
||||
let allComments = [];
|
||||
|
||||
// Per-project storage key: 'wp_suite_sop' → 'wp_suite_sop__<projId>' when a
|
||||
// project is active. Keeps each project's SOP separate in the browser.
|
||||
function SK(base){ try { return (typeof ProjectData !== 'undefined' && ProjectData.key) ? ProjectData.key(base) : base; } catch(e){ return base; } }
|
||||
|
||||
let state = {
|
||||
project: {name:'', number:'', client:'', division:'', site:''},
|
||||
team: {pm:'', apm:'', cm:'', qm:''},
|
||||
teamMembers: [],
|
||||
signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}],
|
||||
wpTypes: [],
|
||||
governance: {woformat:'', wosize:'', issuance:[]},
|
||||
governance: {woformat:'', wosize:'', issuance:[], disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:''},
|
||||
quality: {qcreq:'', photo:'', hold:''},
|
||||
platforms: {tracking:'CxAlloy', commissioning:'CxAlloy'},
|
||||
sequence: [],
|
||||
@@ -126,14 +130,18 @@ window.addEventListener('DOMContentLoaded',()=>{
|
||||
renderStandardConstraints();
|
||||
renderSequenceSteps();
|
||||
renderSources();
|
||||
// Resolve the active project FIRST so per-project storage keys are correct
|
||||
// before we restore this project's SOP.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
applyProjectContext(params.get('project'));
|
||||
restoreSavedSOP();
|
||||
updateStepUI();
|
||||
updateProjectDisplay();
|
||||
|
||||
// Deep-link: ?tab=sop | ?tab=wp from the home page cards.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
|
||||
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 +182,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';
|
||||
@@ -198,9 +209,9 @@ function loadSampleData(){
|
||||
function restoreSavedSOP(){
|
||||
let savedState = null, savedSop = null, complete = false;
|
||||
try {
|
||||
complete = localStorage.getItem('wp_suite_sop_complete') === '1';
|
||||
savedState = JSON.parse(localStorage.getItem('wp_suite_state') || 'null');
|
||||
savedSop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null');
|
||||
complete = localStorage.getItem(SK('wp_suite_sop_complete')) === '1';
|
||||
savedState = JSON.parse(localStorage.getItem(SK('wp_suite_state')) || 'null');
|
||||
savedSop = JSON.parse(localStorage.getItem(SK('wp_suite_sop')) || 'null');
|
||||
} catch(e){}
|
||||
if(!complete || !savedState) return;
|
||||
|
||||
@@ -236,6 +247,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 +291,11 @@ 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 sp = new URLSearchParams(window.location.search);
|
||||
const wantDash = sp.get('view') === 'dashboard';
|
||||
const projId = sp.get('project') || (activeProject && activeProject.id) || '';
|
||||
frame.src = 'wp-creation-index.html?embedded=1' + (wantDash ? '&view=dashboard' : '')
|
||||
+ (projId ? '&project=' + encodeURIComponent(projId) : '') + '&t=' + Date.now();
|
||||
}else{
|
||||
gate.style.display = 'block';
|
||||
frame.style.display = 'none';
|
||||
@@ -289,8 +307,43 @@ function onSOPReady(){
|
||||
if(currentTool === 'wp') renderWPTab();
|
||||
}
|
||||
|
||||
// Active project comes from the home page (?project=<id> + ProjectData.getActive()).
|
||||
// When the SOP's project fields are still empty, prefill them from the project
|
||||
// record so the SOP is authored against the chosen project.
|
||||
let activeProject = null;
|
||||
function applyProjectContext(projectId){
|
||||
try {
|
||||
if(typeof ProjectData !== 'undefined'){
|
||||
if(projectId && ProjectData.getActiveId() !== projectId){
|
||||
// Deep-linked to a project that isn't the cached active one. Seed the id
|
||||
// immediately so namespaced storage keys resolve, then fetch the full record.
|
||||
const cached = ProjectData.getActive();
|
||||
ProjectData.setActive(cached && cached.id === projectId ? cached : { id: projectId });
|
||||
ProjectData.get(projectId).then(p => { if(p){ activeProject = p; ProjectData.setActive(p); prefillProjectFields(); updateProjectDisplay(); } });
|
||||
}
|
||||
activeProject = ProjectData.getActive();
|
||||
}
|
||||
} catch(e){}
|
||||
prefillProjectFields();
|
||||
}
|
||||
function prefillProjectFields(){
|
||||
if(!activeProject) return;
|
||||
const set = (id,v)=>{ const el=document.getElementById(id); if(el && !el.value && v) el.value = v; };
|
||||
set('proj_name', activeProject.name);
|
||||
set('proj_number', activeProject.number);
|
||||
set('proj_client', activeProject.client);
|
||||
set('proj_division', activeProject.division);
|
||||
set('proj_site', activeProject.site);
|
||||
if(typeof state !== 'undefined' && state.project){
|
||||
state.project.name = state.project.name || activeProject.name || '';
|
||||
state.project.number = state.project.number || activeProject.number || '';
|
||||
state.project.client = state.project.client || activeProject.client || '';
|
||||
state.project.division = state.project.division || activeProject.division || '';
|
||||
state.project.site = state.project.site || activeProject.site || '';
|
||||
}
|
||||
}
|
||||
function updateProjectDisplay(){
|
||||
const projName = document.getElementById('proj_name')?.value || 'Project';
|
||||
const projName = document.getElementById('proj_name')?.value || (activeProject && activeProject.name) || 'Project';
|
||||
const display = document.getElementById('project-display');
|
||||
if(display) display.textContent = sopComplete ? `✓ ${projName} (SOP Ready)` : projName;
|
||||
}
|
||||
@@ -485,15 +538,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,'>').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)=>`
|
||||
<div style="display:grid; grid-template-columns:150px 150px 250px 150px 30px; gap:1rem; align-items:center; padding:1rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
|
||||
<input type="text" value="${s.label}" placeholder="Label" onchange="state.sources[${i}].label=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" value="${s.system}" placeholder="${s.ph||'System of record'}" onchange="state.sources[${i}].system=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" value="${s.link}" placeholder="URL" onchange="state.sources[${i}].link=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" value="${s.notes}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" value="${escAttr(s.label)}" placeholder="Label" onchange="state.sources[${i}].label=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" value="${escAttr(s.system)}" placeholder="${escAttr(s.ph||'System of record')}" onchange="state.sources[${i}].system=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" value="${escAttr(s.link)}" placeholder="Paste SharePoint 'Copy Link' URL" onchange="state.sources[${i}].link=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" value="${escAttr(s.notes)}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="state.sources.splice(${i},1); renderSources()">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
@@ -573,6 +630,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 +689,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,
|
||||
@@ -656,11 +722,12 @@ function completeSOP(){
|
||||
sopComplete = true;
|
||||
updateProjectDisplay();
|
||||
|
||||
// Persist for the home page (green / "Review") and for the WP Creator tab.
|
||||
// Persist for the home page (green / "Review") and for the WP Creator tab,
|
||||
// namespaced to the active project so each project keeps its own SOP.
|
||||
try {
|
||||
localStorage.setItem('wp_suite_sop', JSON.stringify(sop));
|
||||
localStorage.setItem('wp_suite_state', JSON.stringify(state));
|
||||
localStorage.setItem('wp_suite_sop_complete', '1');
|
||||
localStorage.setItem(SK('wp_suite_sop'), JSON.stringify(sop));
|
||||
localStorage.setItem(SK('wp_suite_state'), JSON.stringify(state));
|
||||
localStorage.setItem(SK('wp_suite_sop_complete'), '1');
|
||||
} catch(e){}
|
||||
|
||||
track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length});
|
||||
@@ -160,17 +160,13 @@
|
||||
<!-- STEP 5: GOVERNANCE -->
|
||||
<div class="step" id="sop-step-5" style="display: none;">
|
||||
<h2>5. Governance & WP Numbering</h2>
|
||||
<div class="notice">Define how Work Packages are formatted, sized, and issued on this project.</div>
|
||||
<div class="notice">Define how Work Packages are formatted, sized, and issued on this project. The choices here decide how the Work Package Creator behaves for every package.</div>
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label>Work Package Number Format *</label>
|
||||
<input type="text" id="gov_woformat" placeholder="e.g., WP##-[Sector]-[TYPE]">
|
||||
<small>Use ## for counter, [Sector] [TYPE] as variables</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Typical WP Size</label>
|
||||
<input type="text" id="gov_wosize" placeholder="e.g., 3–5 days or 40–80 hours">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Issuance Strategy</label>
|
||||
<select id="gov_issuance" multiple size="3">
|
||||
@@ -182,6 +178,39 @@
|
||||
<small>Hold Ctrl to select multiple</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin:1.4rem 0 .4rem">How will Work Packages use disciplines?</h3>
|
||||
<div class="notice">Decide whether a single package can carry more than one discipline (e.g. a chiller skid needing Mechanical install + Electrical wire-pull + Tech terminations), or whether each discipline gets its own package. This drives whether the Creator shows per-discipline scope sections and the <strong>Split by Discipline</strong> button.</div>
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label>Disciplines on this project</label>
|
||||
<input type="text" id="gov_disciplines" placeholder="Mechanical, Electrical, Tech">
|
||||
<small>Comma-separated. These appear as scope sections and instance suffixes in the Creator.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Discipline strategy *</label>
|
||||
<select id="gov_discmode">
|
||||
<option value="choice">Let the planner choose per package (recommended)</option>
|
||||
<option value="single">One discipline per package (many small packages)</option>
|
||||
<option value="multi">Multiple disciplines per package (scope split by discipline)</option>
|
||||
</select>
|
||||
<small>"Choose per package" lets the planner build a large multi-discipline package and split it later.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin:1.4rem 0 .4rem">Work Package sizing</h3>
|
||||
<div class="notice">A Work Package should be a manageable, trackable chunk of work — typically a 1–2 week assignment. The Creator warns the planner when a package exceeds the ceiling so it can be broken down.</div>
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label>Typical WP Size (guidance)</label>
|
||||
<input type="text" id="gov_wosize" placeholder="e.g., 3–5 days or 40–80 hours">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Split threshold — max labor hours</label>
|
||||
<input type="number" id="gov_size_hours_max" min="0" step="1" placeholder="e.g., 120">
|
||||
<small>The Creator flags packages above this so they can be split (by discipline or scope).</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 6: QUALITY -->
|
||||
@@ -340,6 +369,7 @@
|
||||
</div>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="work-package-suite-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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,17 @@ 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 activeProjectId=''; // set at boot from ?project=<id>; stamped onto saved WPs for the API
|
||||
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 +81,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 +94,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();
|
||||
}
|
||||
@@ -201,12 +208,24 @@ function unitOptions(cur){
|
||||
if(c && !list.includes(c)) list.unshift(c);
|
||||
return `<option value="">Unit…</option>`+list.map(u=>`<option ${c===u?'selected':''}>${esc(u)}</option>`).join('');
|
||||
}
|
||||
// Discipline column on the BOM appears only for multi-discipline packages, so a
|
||||
// split hands each instance just its own materials. Keeps a stale value as an
|
||||
// option (mirrors unitOptions) if the discipline was later removed.
|
||||
function matDisciplineOptions(cur){
|
||||
const list=pkgDisciplines.slice(); const c=cur||'';
|
||||
if(c && !list.includes(c)) list.unshift(c);
|
||||
return `<option value="">—</option>`+list.map(d=>`<option ${c===d?'selected':''}>${esc(d)}</option>`).join('');
|
||||
}
|
||||
function buildMaterials(){
|
||||
const multi=isMultiDiscipline();
|
||||
const th=document.getElementById('mat-disc-th'); if(th) th.style.display=multi?'':'none';
|
||||
const tb=document.getElementById('material-body'); tb.innerHTML='';
|
||||
pkgMaterials.forEach((m,i)=>{ const tr=document.createElement('tr');
|
||||
const discCell = multi ? `<td><select onchange="pkgMaterials[${i}].discipline=this.value">${matDisciplineOptions(m.discipline)}</select></td>` : '';
|
||||
tr.innerHTML=`<td><input type="text" value="${(m.qty||'').replace(/"/g,'"')}" placeholder="20" oninput="pkgMaterials[${i}].qty=this.value"></td>
|
||||
<td><select onchange="pkgMaterials[${i}].unit=this.value">${unitOptions(m.unit)}</select></td>
|
||||
<td><input type="text" value="${(m.desc||'').replace(/"/g,'"')}" placeholder="3/8 lock washers gold" oninput="pkgMaterials[${i}].desc=this.value"></td>
|
||||
${discCell}
|
||||
<td class="center"><button class="row-del" onclick="removeMaterial(${i})">✕</button></td>`;
|
||||
tb.appendChild(tr); });
|
||||
}
|
||||
@@ -262,6 +281,171 @@ 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 `<label class="disc-pill ${on?'selected':''}"><input type="${mode==='single'?'radio':'checkbox'}" name="wp_disc" ${on?'checked':''} onchange="toggleDiscipline('${esc(d)}',this.checked)"><span class="dot"></span>${esc(d)}</label>`;
|
||||
}).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(); buildMaterials(); 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)=>`<div class="workstep-row"><span class="ws-num">${i+1}</span>
|
||||
<textarea rows="1" placeholder="Describe ${esc(d)} step ${i+1}…" oninput="setScopeStep('${esc(d)}',${i},this.value)">${esc(s)}</textarea>
|
||||
<button class="row-del" onclick="removeScopeStep('${esc(d)}',${i})" title="Remove step">✕</button></div>`).join('');
|
||||
const opts=STATUS_ORDER.map(o=>`<option ${o===st?'selected':''}>${esc(o)}</option>`).join('');
|
||||
return `<div class="disc-scope">
|
||||
<div class="disc-scope-head"><span class="disc-tag">${esc(d)}</span>
|
||||
<span class="disc-status">Status: <select onchange="setDiscStatus('${esc(d)}',this.value)">${opts}</select></span></div>
|
||||
${rows}
|
||||
<button class="add-btn" onclick="addScopeStep('${esc(d)}')">+ Add ${esc(d)} Step</button>
|
||||
</div>`;
|
||||
}).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&&ix<minIdx) minIdx=ix; });
|
||||
setRadio('status', STATUS_ORDER[minIdx]); prevStatus=STATUS_ORDER[minIdx]; updateReleaseBanner();
|
||||
}
|
||||
|
||||
// ── 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 hrs=parseFloat(gv('wp_hours'));
|
||||
if(max && hrs && hrs>max){
|
||||
el.innerHTML=`<span style="color:var(--accent-amber)">⚠ ${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try <strong>Split by Discipline</strong>).':'.')+`</span>`;
|
||||
} 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',
|
||||
materials: (base.materials||[]).filter(m=>m.discipline===d),
|
||||
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');
|
||||
const untagged=(base.materials||[]).filter(m=>!m.discipline).length;
|
||||
const matNote = untagged ? `\n\nNote: ${untagged} material line${untagged===1?'':'s'} had no discipline tag and stayed on the master only — tag them before splitting to route them to an instance.` : '';
|
||||
alert('Created '+children.length+' instances:\n\n• '+children.map(c=>c.number+' ('+c.disciplines[0]+', '+(c.materials?c.materials.length:0)+' material line'+((c.materials&&c.materials.length===1)?'':'s')+')').join('\n• ')+'\n\nThe master '+baseNumber+' is kept as a roll-up.'+matNote);
|
||||
}
|
||||
|
||||
// ── 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=`<td><input type="text" value="${(a.tag||'').replace(/"/g,'"')}" placeholder="controls.dev asset tag / ID" oninput="pkgAssets[${i}].tag=this.value"></td>
|
||||
<td><input type="text" value="${(a.desc||'').replace(/"/g,'"')}" placeholder="what it is (optional)" oninput="pkgAssets[${i}].desc=this.value"></td>
|
||||
<td><input type="url" value="${(a.link||'').replace(/"/g,'"')}" placeholder="https://controls.dev/..." oninput="pkgAssets[${i}].link=this.value"></td>
|
||||
<td class="center"><button class="row-del" onclick="removeAsset(${i})">✕</button></td>`;
|
||||
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 +459,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/<guid>" 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
|
||||
? `<div class="field-hint">1) Open a folder, multi-select files in SharePoint, then use <b>Copy link</b>:</div>`+
|
||||
`<div class="ref-links">`+srcs.map(s=>`<a href="${esc(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`
|
||||
: `<div class="field-hint">No SOP folders defined — load or import an SOP first.</div>`;
|
||||
}
|
||||
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 +629,22 @@ 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)),
|
||||
projectId: (prev&&prev.projectId) || activeProjectId || '',
|
||||
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 +677,7 @@ function renderPackage(pkg){
|
||||
<tr><th style="width:200px">WP Number</th><td>${cell(pkg.number)}</td></tr>
|
||||
<tr><th>Subject</th><td>${cell(pkg.subject)}</td></tr>
|
||||
<tr><th>Type</th><td>${cell(pkg.type)}</td></tr>
|
||||
${pkg.disciplines&&pkg.disciplines.length?`<tr><th>Discipline(s)</th><td>${esc(pkg.disciplines.join(', '))}${pkg.split?' <span style="color:var(--accent);font-size:10px">[MASTER — split into instances]</span>':''}${pkg.instanceOf?` <span style="color:var(--accent);font-size:10px">[instance of ${esc(pkg.parentNumber||'')}]</span>`:''}</td></tr>`:''}
|
||||
<tr><th>System / Facility Code / UPN</th><td>${cell(pkg.system)}</td></tr>
|
||||
<tr><th>Location</th><td>${cell(pkg.location)}</td></tr>
|
||||
<tr><th>Cost Code</th><td>${pkg.cost?esc(pkg.cost)+(costDesc?' — '+esc(costDesc):''):ns()}</td></tr>
|
||||
@@ -457,42 +688,55 @@ function renderPackage(pkg){
|
||||
<tr><th>Specification Section</th><td>${cell(pkg.spec)}</td></tr>
|
||||
<tr><th>Description</th><td>${cell(pkg.desc)}</td></tr>
|
||||
</tbody></table>`;
|
||||
const stepsArr = pkg.workSteps && pkg.workSteps.length ? pkg.workSteps : (pkg.work?String(pkg.work).split('\n').filter(Boolean):[]);
|
||||
const stepsHtml = stepsArr.length ? '<ol style="margin:0;padding-left:18px">'+stepsArr.map(s=>`<li>${esc(s)}</li>`).join('')+'</ol>' : ns();
|
||||
h+=`<h2>2.0 Scope & Work</h2><table><tbody>
|
||||
<tr><th style="width:200px">Description of Work</th><td>${stepsHtml}</td></tr>
|
||||
if(pkg.assets&&pkg.assets.length){ h+=`<h2>2.0 Assets (controls.dev)</h2><table><thead><tr><th style="width:180px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link</th></tr></thead><tbody>`;
|
||||
pkg.assets.forEach(a=>h+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`); h+=`</tbody></table>`; }
|
||||
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]?` <span style="color:var(--accent);font-size:10px">[${esc(pkg.discStatus[d])}]</span>`:'';
|
||||
return `<div style="margin-bottom:8px"><strong>${esc(d)}</strong>${dst}`+
|
||||
(arr.length?'<ol style="margin:2px 0 0;padding-left:18px">'+arr.map(s=>`<li>${esc(s)}</li>`).join('')+'</ol>':' — '+ns())+`</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
const stepsArr = pkg.workSteps && pkg.workSteps.length ? pkg.workSteps : (pkg.work?String(pkg.work).split('\n').filter(Boolean):[]);
|
||||
scopeHtml = stepsArr.length ? '<ol style="margin:0;padding-left:18px">'+stepsArr.map(s=>`<li>${esc(s)}</li>`).join('')+'</ol>' : ns();
|
||||
}
|
||||
h+=`<h2>3.0 Scope & Work</h2><table><tbody>
|
||||
<tr><th style="width:200px">Description of Work</th><td>${scopeHtml}</td></tr>
|
||||
<tr><th>Labor Est. Hrs.</th><td>${cell(pkg.hours)}</td></tr>
|
||||
<tr><th>Package Predecessor</th><td>${pkg.seq?('After: '+esc(pkg.seq)):'None (no predecessor)'}</td></tr>
|
||||
</tbody></table>`;
|
||||
if(pkg.materials&&pkg.materials.length){ h+=`<h2>3.0 Material List</h2><table><thead><tr><th style="width:80px">Qty</th><th style="width:80px">Unit</th><th>Description</th></tr></thead><tbody>`;
|
||||
pkg.materials.forEach(m=>h+=`<tr><td>${cell(m.qty)}</td><td>${cell(m.unit)}</td><td>${cell(m.desc)}</td></tr>`); h+=`</tbody></table>`; }
|
||||
if(pkg.attachments&&pkg.attachments.length){ h+=`<h2>4.0 Drawings & Attachments</h2><table><thead><tr><th>Document</th><th style="width:60px">Rev</th><th>Link / Note</th></tr></thead><tbody>`;
|
||||
if(pkg.materials&&pkg.materials.length){ const showDisc=pkg.materials.some(m=>m.discipline);
|
||||
h+=`<h2>4.0 Material List</h2><table><thead><tr><th style="width:80px">Qty</th><th style="width:80px">Unit</th><th>Description</th>${showDisc?'<th style="width:120px">Discipline</th>':''}</tr></thead><tbody>`;
|
||||
pkg.materials.forEach(m=>h+=`<tr><td>${cell(m.qty)}</td><td>${cell(m.unit)}</td><td>${cell(m.desc)}</td>${showDisc?`<td>${cell(m.discipline)}</td>`:''}</tr>`); h+=`</tbody></table>`; }
|
||||
if(pkg.attachments&&pkg.attachments.length){ h+=`<h2>5.0 Drawings & Attachments</h2><table><thead><tr><th>Document</th><th style="width:60px">Rev</th><th>Link / Note</th></tr></thead><tbody>`;
|
||||
pkg.attachments.forEach(a=>h+=`<tr><td>${cell(a.doc)}</td><td>${cell(a.rev)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`); h+=`</tbody></table>`; }
|
||||
h+=`<h2>5.0 Kitting & MIMO</h2><table><tbody>
|
||||
h+=`<h2>6.0 Kitting & MIMO</h2><table><tbody>
|
||||
<tr><th style="width:200px">Kitting Status</th><td>${cell(pkg.kitStatus)}</td></tr>
|
||||
<tr><th>Warehouse Owner</th><td>${cell(pkg.kitOwner)}</td></tr>
|
||||
<tr><th>Kitting Need Date</th><td>${cell(pkg.kitDate)}</td></tr>
|
||||
<tr><th>MIMO Sch. Time / Location</th><td>${cell(pkg.mimoTime)} ${pkg.mimoLoc?'· '+esc(pkg.mimoLoc):''}</td></tr>
|
||||
</tbody></table>`;
|
||||
h+=`<h2>6.0 Constraints — Release Readiness</h2><table><thead><tr><th>Constraint</th><th style="width:90px">Status</th><th>Comment</th></tr></thead><tbody>`;
|
||||
h+=`<h2>7.0 Constraints — Release Readiness</h2><table><thead><tr><th>Constraint</th><th style="width:90px">Status</th><th>Comment</th></tr></thead><tbody>`;
|
||||
(pkg.constraints||[]).forEach(c=>{ const lbl=c.status==='cleared'?'Cleared':c.status==='na'?'N/A':'Open'; const col=c.status==='cleared'?'var(--accent-green)':c.status==='na'?'var(--text-dim)':'var(--red)';
|
||||
h+=`<tr><td>${esc(c.name)}</td><td style="color:${col};font-weight:700">${lbl}</td><td>${cell(c.comment)}</td></tr>`; });
|
||||
h+=`</tbody></table>`;
|
||||
h+=`<h2>7.0 Quality & Hold Points</h2><table><tbody>
|
||||
h+=`<h2>8.0 Quality & Hold Points</h2><table><tbody>
|
||||
<tr><th style="width:200px">QC</th><td>${cell(pkg.qc)}${pkg.overrides&&pkg.overrides.wp_qc?` <span style="color:var(--accent-amber)">(overridden: ${esc(pkg.overrides.wp_qc)})</span>`:(pkg.qcFromSOP?' <span style="color:var(--accent);font-size:10px">[from SOP]</span>':'')}</td></tr>
|
||||
<tr><th>Photo Documentation</th><td>${cell(pkg.photo)}${pkg.overrides&&pkg.overrides.wp_photo?` <span style="color:var(--accent-amber)">(overridden: ${esc(pkg.overrides.wp_photo)})</span>`:(pkg.photoFromSOP?' <span style="color:var(--accent);font-size:10px">[from SOP]</span>':'')}</td></tr>
|
||||
<tr><th>Witness / Hold Points</th><td>${cell(pkg.hold)}</td></tr>
|
||||
</tbody></table>`;
|
||||
if(pkg.holds&&pkg.holds.length){
|
||||
h+=`<h2>7.5 Hold Log</h2><table><thead><tr><th style="width:150px">Logged</th><th style="width:200px">Constraint</th><th>Details</th><th style="width:120px">Support</th></tr></thead><tbody>`;
|
||||
h+=`<h2>8.5 Hold Log</h2><table><thead><tr><th style="width:150px">Logged</th><th style="width:200px">Constraint</th><th>Details</th><th style="width:120px">Support</th></tr></thead><tbody>`;
|
||||
pkg.holds.forEach(hd=>{ const when=hd.ts?new Date(hd.ts).toLocaleString():''; const sup=[hd.doc?linkify(hd.doc):'', hd.photo?'<span style="color:var(--accent-green)">photo attached</span>':''].filter(Boolean).join('<br>')||ns();
|
||||
h+=`<tr><td>${esc(when)}</td><td>${cell(hd.constraint)}</td><td>${cell(hd.details)}</td><td>${sup}</td></tr>`; });
|
||||
h+=`</tbody></table>`;
|
||||
}
|
||||
h+=`<h2>8.0 Approvals & Sign-offs</h2><table><thead><tr><th>Role</th><th>Name</th><th style="width:120px">Date</th><th style="width:80px">Signed</th></tr></thead><tbody>`;
|
||||
h+=`<h2>9.0 Approvals & Sign-offs</h2><table><thead><tr><th>Role</th><th>Name</th><th style="width:120px">Date</th><th style="width:80px">Signed</th></tr></thead><tbody>`;
|
||||
(pkg.signoffs||[]).forEach(s=>h+=`<tr><td>${esc(s.role)}</td><td>${cell(s.name)}</td><td>${cell(s.date)}</td><td>${s.signed?'✓':'—'}</td></tr>`);
|
||||
h+=`</tbody></table>`;
|
||||
if(pkg.actualHrs||pkg.installedQty||pkg.redlines||pkg.lessons){ h+=`<h2>9.0 Closeout</h2><table><tbody>
|
||||
if(pkg.actualHrs||pkg.installedQty||pkg.redlines||pkg.lessons){ h+=`<h2>10.0 Closeout</h2><table><tbody>
|
||||
<tr><th style="width:200px">Actual Hrs.</th><td>${cell(pkg.actualHrs)}</td></tr>
|
||||
<tr><th>Installed Quantity</th><td>${cell(pkg.installedQty)}</td></tr>
|
||||
<tr><th>Redlines / As-Built</th><td>${cell(pkg.redlines)}</td></tr>
|
||||
@@ -508,13 +752,16 @@ 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';
|
||||
function saveStore(){ try{ localStorage.setItem(STORE_KEY, JSON.stringify(savedPackages)); }catch(e){} }
|
||||
function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(STORE_KEY)); if(Array.isArray(d)) savedPackages=d; }catch(e){} }
|
||||
// Per-project namespaced key so each project keeps its own packages in the browser.
|
||||
function wpKey(base){ try{ return (typeof ProjectData!=='undefined'&&ProjectData.key)?ProjectData.key(base):base; }catch(e){ return base; } }
|
||||
function saveStore(){ try{ localStorage.setItem(wpKey(STORE_KEY), JSON.stringify(savedPackages)); }catch(e){} }
|
||||
function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(wpKey(STORE_KEY))); savedPackages=Array.isArray(d)?d:[]; }catch(e){ savedPackages=[]; } }
|
||||
function renderSavedList(){
|
||||
const card=document.getElementById('saved-card'), body=document.getElementById('saved-body');
|
||||
document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:'';
|
||||
@@ -522,7 +769,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' ? '<span class="badge badge-N">On Hold</span>' : (open===0?'<span class="badge badge-Y">Ready</span>':`<span class="badge badge-O">${open} open</span>`);
|
||||
return `<tr><td class="row-label">${esc(p.number||'—')}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td>
|
||||
const tag = p.split?' <span class="badge badge-O">master</span>':(p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'instance')}</span>`:'');
|
||||
const disc = (p.disciplines&&p.disciplines.length)?`<div style="font-size:10px;color:var(--text-dim)">${esc(p.disciplines.join(', '))}</div>`:'';
|
||||
return `<tr><td class="row-label">${esc(p.number||'—')}${tag}${disc}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td>
|
||||
<td>${esc(p.status||'')}</td><td>${ready}</td>
|
||||
<td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="row-del" onclick="deletePackage(${i})">✕</button></td></tr>`;
|
||||
}).join('');
|
||||
@@ -547,6 +796,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 +808,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 +828,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 +848,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)=>`<div class="dash-metric ${cls||''}"><div class="dm-val">${val}</div><div class="dm-label">${esc(label)}</div></div>`;
|
||||
let h=`<div class="dash-metrics">
|
||||
${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))}
|
||||
</div>`;
|
||||
|
||||
// status + discipline breakdown chips
|
||||
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>`<span class="dash-chip">${esc(s)}: <b>${byStatus[s]}</b></span>`).join('')
|
||||
+ (byStatus['Issue']?`<span class="dash-chip chip-red">On Hold: <b>${byStatus['Issue']}</b></span>`:'');
|
||||
const discChips=Object.keys(byDisc).map(d=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>';
|
||||
h+=`<div class="dash-breakdown"><div><div class="dash-bd-title">By status</div>${statusChips||'—'}</div>
|
||||
<div><div class="dash-bd-title">By discipline</div>${discChips}</div></div>`;
|
||||
|
||||
// gating panel — what's blocking release
|
||||
const gated=all.filter(p=>wpOpenConstraints(p).length>0);
|
||||
h+=`<div class="dash-panel"><div class="dash-panel-title">⛔ Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)</div>`;
|
||||
h+= gated.length ? `<table class="dash-table"><thead><tr><th>WP #</th><th>Subject</th><th>Blocked by</th></tr></thead><tbody>`+
|
||||
gated.map(p=>`<tr><td class="row-label">${esc(p.number||'—')}</td><td>${esc(p.subject||'')}</td>
|
||||
<td>${wpOpenConstraints(p).map(c=>esc(c.name)+(c.comment?` <span style="color:var(--text-dim)">(${esc(c.comment)})</span>`:'')).join('<br>')}</td></tr>`).join('')+
|
||||
`</tbody></table>` : `<div class="field-hint">No open constraints — every package is clear of gates.</div>`;
|
||||
h+=`</div>`;
|
||||
|
||||
// filters
|
||||
const statusOpts=['<option value="">All statuses</option>'].concat(STATUS_ORDER.concat(['Issue']).map(s=>`<option ${dashFilter.status===s?'selected':''}>${esc(s)}</option>`)).join('');
|
||||
const discList=Object.keys(byDisc);
|
||||
const discOpts=['<option value="">All disciplines</option>'].concat(discList.map(d=>`<option ${dashFilter.discipline===d?'selected':''}>${esc(d)}</option>`)).join('');
|
||||
h+=`<div class="dash-filters">
|
||||
<input type="search" placeholder="Search WP # / subject…" value="${(dashFilter.q||'').replace(/"/g,'"')}" oninput="dashFilter.q=this.value;renderDashboard()">
|
||||
<select onchange="dashFilter.status=this.value;renderDashboard()">${statusOpts}</select>
|
||||
<select onchange="dashFilter.discipline=this.value;renderDashboard()">${discOpts}</select>
|
||||
</div>`;
|
||||
|
||||
// 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+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${rows.length})</div>
|
||||
<table class="dash-table"><thead><tr><th>WP #</th><th>Subject</th><th>Type</th><th>Discipline</th><th>Status</th><th>Gates</th><th>Due</th><th>Hrs</th><th></th></tr></thead><tbody>`;
|
||||
if(!rows.length) h+=`<tr><td colspan="9" class="field-hint" style="padding:14px">No work packages match.</td></tr>`;
|
||||
rows.forEach(p=>{
|
||||
const ix=savedPackages.findIndex(x=>x.id===p.id);
|
||||
const open=wpOpenConstraints(p).length;
|
||||
const gates= p.split?'<span class="badge badge-O">master</span>':(open?`<span class="badge badge-O">${open} open</span>`:`<span class="badge badge-Y">clear</span>`);
|
||||
const due= p.due?`<span style="${isOverdue(p)?'color:var(--red);font-weight:700':''}">${esc(p.due)}</span>`:ns();
|
||||
const canIssue = !p.split && open===0 && p.status!=='Closed' && p.status!=='Issued' && p.status!=='Issue';
|
||||
const issueBtn = canIssue?`<button class="link-btn" onclick="dashIssue('${p.id}')">issue</button>`:'';
|
||||
h+=`<tr><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}</td>
|
||||
<td>${esc(p.subject||'')}</td><td>${esc(p.type||'')}</td>
|
||||
<td style="font-size:11px">${esc((p.disciplines||[]).join(', '))||ns()}</td>
|
||||
<td>${esc(p.status||'')}</td><td>${gates}</td><td>${due}</td><td>${cell(p.hours)}</td>
|
||||
<td class="center" style="white-space:nowrap">${issueBtn} <button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button></td></tr>`;
|
||||
});
|
||||
h+=`</tbody></table></div>`;
|
||||
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; }
|
||||
@@ -648,6 +1014,15 @@ document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventList
|
||||
}));
|
||||
|
||||
// ── BOOT ─────────────────────────────────────────────────────────────────────
|
||||
// Resolve the active project BEFORE loading the store so namespaced keys resolve.
|
||||
(function seedProject(){
|
||||
const params = new URLSearchParams(location.search);
|
||||
activeProjectId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||||
if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()!==activeProjectId){
|
||||
const cached = ProjectData.getActive && ProjectData.getActive();
|
||||
ProjectData.setActive(cached && cached.id===activeProjectId ? cached : { id: activeProjectId });
|
||||
}
|
||||
})();
|
||||
loadStore();
|
||||
(function bootSOP(){
|
||||
// When embedded in the Suite, hide the SOP import/sample controls (SOP is injected)
|
||||
@@ -655,7 +1030,7 @@ loadStore();
|
||||
const params = new URLSearchParams(location.search);
|
||||
if(params.get('embedded')) document.body.classList.add('embedded');
|
||||
try {
|
||||
const raw = localStorage.getItem('wp_suite_sop');
|
||||
const raw = localStorage.getItem(wpKey('wp_suite_sop'));
|
||||
if(raw){
|
||||
const d = JSON.parse(raw);
|
||||
if(d && d.woTypes){ SOP = d; applySOP(); newPackage(); return; }
|
||||
@@ -666,4 +1041,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');
|
||||
@@ -24,6 +24,7 @@
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadSampleSOP()">⤓ Sample SOP</button>
|
||||
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="openSopModal()">👁 View SOP</button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="loadExample()">★ Load Example</button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="newPackage()">+ New</button>
|
||||
<button class="btn btn-ghost" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button>
|
||||
@@ -76,16 +77,35 @@
|
||||
<div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ASSETS (controls.dev) -->
|
||||
<div class="card">
|
||||
<div class="sub-heading">Assets</div>
|
||||
<div class="notice">Every work package is based on one or more assets managed in <strong>controls.dev</strong>. Paste the controls.dev link for each asset this package covers. <span style="color:var(--text-dim)">A direct integration to pick assets from a list is planned — for now, link them manually.</span></div>
|
||||
<div class="table-wrap"><table><thead><tr><th style="width:200px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link <span class="req">*</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div>
|
||||
<button class="add-btn" onclick="addAsset()">+ Add Asset</button>
|
||||
</div>
|
||||
|
||||
<!-- DISCIPLINES -->
|
||||
<div class="card" id="discipline-card" style="display:none">
|
||||
<div class="sub-heading">Disciplines</div>
|
||||
<div class="notice" id="discipline-note"></div>
|
||||
<div class="disc-picker" id="discipline-picker"></div>
|
||||
</div>
|
||||
|
||||
<!-- SCOPE & WORK -->
|
||||
<div class="card">
|
||||
<div class="sub-heading">Scope & Work</div>
|
||||
<div class="field"><label>Description of Work (sequenced steps)</label>
|
||||
<div class="notice">Enter the work as ordered steps — added in sequence, the way the crew performs them.</div>
|
||||
<div id="worksteps-body"></div>
|
||||
<button class="add-btn" onclick="addWorkStep()">+ Add Step</button>
|
||||
<div id="flat-scope">
|
||||
<div class="field"><label>Description of Work (sequenced steps)</label>
|
||||
<div class="notice">Enter the work as ordered steps — added in sequence, the way the crew performs them.</div>
|
||||
<div id="worksteps-body"></div>
|
||||
<button class="add-btn" onclick="addWorkStep()">+ Add Step</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Labor – Est. Hrs.</label><input type="number" id="wp_hours" min="0" step="1" placeholder="e.g. 20"><div class="field-hint" id="size-check"></div></div>
|
||||
<div id="scope-by-discipline" style="display:none"></div>
|
||||
<button class="btn btn-ghost" id="split-disc-btn" style="display:none;margin-top:10px" onclick="splitByDiscipline()" title="Break this multi-discipline package into one numbered instance per discipline">⎘ Split by Discipline</button>
|
||||
<div class="field-grid" style="margin-top:14px">
|
||||
<div class="field"><label>Labor – Est. Hrs.</label><input type="number" id="wp_hours" min="0" step="1" placeholder="e.g. 20" oninput="onHoursChange()"><div class="field-hint" id="size-check"></div></div>
|
||||
<div class="field"><label>Package Predecessor</label><select id="wp_seq"></select><div class="field-hint">The package/step (from the SOP sequence) that must finish before this work can start. Choose "None" if it has no predecessor.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,7 +114,7 @@
|
||||
<div class="card">
|
||||
<div class="sub-heading">Material List</div>
|
||||
<div class="notice">Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.</div>
|
||||
<div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:120px">Unit</th><th>Description</th><th style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div>
|
||||
<div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:120px">Unit</th><th>Description</th><th id="mat-disc-th" style="width:140px;display:none">Discipline</th><th style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div>
|
||||
<div class="material-actions">
|
||||
<button class="add-btn" onclick="addMaterial()">+ Add Material Line</button>
|
||||
<button class="add-btn" onclick="document.getElementById('material-import').click()">⤒ Import from Excel/CSV</button>
|
||||
@@ -109,6 +129,16 @@
|
||||
<div id="sop-ref-links" class="sop-ref-links"></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Document / Drawing</th><th style="width:90px">Rev</th><th>Link / Note</th><th style="width:44px"></th></tr></thead><tbody id="attach-body"></tbody></table></div>
|
||||
<button class="add-btn" onclick="addAttach()">+ Add Document</button>
|
||||
<button class="add-btn" onclick="toggleSopFilePanel()">+ Add files from SOP folder</button>
|
||||
<div id="sop-file-panel" style="display:none; margin-top:0.75rem; padding:0.75rem; border:1px dashed var(--border); border-radius:6px; background:var(--bg);">
|
||||
<div id="sop-file-folders"></div>
|
||||
<div class="field-hint" style="margin-top:0.5rem;">2) Paste the file links here, one per line:</div>
|
||||
<textarea id="sop-file-links" rows="4" placeholder="https://primecontrolsdallas.sharepoint.com/:b:/r/.../Drawings/EE-1YA-2P.pdf?csf=1&web=1&e=..." style="width:100%; font-size:12px; padding:0.5rem; border:1px solid var(--border); border-radius:4px; box-sizing:border-box;"></textarea>
|
||||
<div style="margin-top:0.5rem; display:flex; gap:0.5rem;">
|
||||
<button class="add-btn" onclick="addPastedFileLinks()">Add to attachments</button>
|
||||
<button class="add-btn" onclick="toggleSopFilePanel()" style="background:transparent;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KITTING & MIMO -->
|
||||
@@ -170,6 +200,21 @@
|
||||
<button class="btn btn-generate" onclick="savePackage(true)">⚡ Save & View</button>
|
||||
</div></div>
|
||||
|
||||
<!-- DASHBOARD -->
|
||||
<div id="dashboard-view" style="display:none">
|
||||
<div class="output-toolbar">
|
||||
<button class="btn btn-ghost" onclick="showForm()">← Back to Form</button>
|
||||
<div style="font-weight:700;font-size:15px">Work Package Dashboard</div>
|
||||
<div style="display:flex;gap:10px;margin-left:auto">
|
||||
<button class="btn btn-ghost" onclick="newPackage()">+ New WP</button>
|
||||
<button class="btn btn-ghost" onclick="renderDashboard()">↻ Refresh</button>
|
||||
<button class="btn btn-ghost" onclick="exportPackages()">⤓ Export (JSON)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctx-bar" style="margin:0 0 14px"><div class="field-hint">Status, metrics and gating across all saved work packages on this device. <span style="color:var(--text-dim)">Reads local data now; wires to the shared SQL database in Phase 2.</span></div></div>
|
||||
<div id="dash-body"></div>
|
||||
</div>
|
||||
|
||||
<!-- OUTPUT -->
|
||||
<div id="pkg-output" style="display:none">
|
||||
<div class="output-toolbar">
|
||||
@@ -231,6 +276,7 @@
|
||||
</aside>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="wp-creation-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -206,7 +206,7 @@
|
||||
/* ── MISC ── */
|
||||
.divider { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
|
||||
.sub-heading {
|
||||
font-family: var(--mono); font-size: 10px; font-weight: 600; letter-spacing: .15em;
|
||||
font-family: var(--mono); font-size: 15px; font-weight: 700; letter-spacing: .12em;
|
||||
text-transform: uppercase; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.sub-heading::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
@@ -563,3 +563,39 @@
|
||||
/* Sign-off date override (comment 5) */
|
||||
.so-date { font-size:13px; font-variant-numeric:tabular-nums; }
|
||||
.so-ovr { margin-left:8px; font-size:11px; }
|
||||
|
||||
/* 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);
|
||||
border-radius:20px; font-size:13px; font-weight:600; cursor:pointer; user-select:none; transition:border-color .12s, background .12s, color .12s; }
|
||||
.disc-pill:hover { border-color:var(--accent); color:var(--text); }
|
||||
.disc-pill input { display:none; }
|
||||
.disc-pill .dot { width:7px; height:7px; border-radius:50%; background:var(--border-strong); transition:background .12s; flex-shrink:0; }
|
||||
.disc-pill.selected { border-color:var(--accent); background:var(--accent-dim,#eef3fd); color:var(--accent); }
|
||||
.disc-pill.selected .dot { background:var(--accent); }
|
||||
.disc-scope { border:1px solid var(--border); border-left:3px solid var(--accent); border-radius:6px; padding:12px 14px; margin-bottom:12px; background:var(--bg); }
|
||||
.disc-scope-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; flex-wrap:wrap; }
|
||||
.disc-tag { font-weight:700; font-size:13px; color:var(--accent); text-transform:uppercase; letter-spacing:.03em; }
|
||||
.disc-status { font-size:12px; color:var(--text-muted); }
|
||||
.disc-status select { font-size:12px; padding:3px 6px; }
|
||||
|
||||
/* Dashboard */
|
||||
.dash-metrics { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:12px; margin-bottom:16px; }
|
||||
.dash-metric { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; text-align:center; }
|
||||
.dash-metric .dm-val { font-size:26px; font-weight:800; line-height:1; }
|
||||
.dash-metric .dm-label { font-size:11px; color:var(--text-muted); margin-top:6px; text-transform:uppercase; letter-spacing:.03em; }
|
||||
.dash-metric.dm-green .dm-val { color:var(--accent-green); }
|
||||
.dash-metric.dm-red .dm-val { color:var(--red); }
|
||||
.dash-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; }
|
||||
.dash-chip.chip-red { background:var(--red-dim); color:var(--red); border-color:var(--red); }
|
||||
.dash-panel { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; margin-bottom:16px; }
|
||||
.dash-panel-title { font-weight:700; font-size:13px; margin-bottom:10px; }
|
||||
.dash-table { width:100%; border-collapse:collapse; font-size:12.5px; }
|
||||
.dash-table th { text-align:left; background:var(--surface2); border-bottom:1px solid var(--border); padding:6px 8px; font-size:11px; text-transform:uppercase; color:var(--text-muted); }
|
||||
.dash-table td { border-bottom:1px solid var(--border); padding:6px 8px; vertical-align:top; }
|
||||
.dash-filters { display:flex; flex-wrap:wrap; gap:10px; margin-bottom:14px; }
|
||||
.dash-filters input, .dash-filters select { padding:7px 10px; border:1px solid var(--border-strong); border-radius:6px; font-size:13px; }
|
||||
.dash-filters input[type=search] { flex:1; min-width:200px; }
|
||||
@media (max-width:640px){ .dash-breakdown { grid-template-columns:1fr; } }
|
||||
4
nginx/Dockerfile
Normal file
4
nginx/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM nginx:alpine
|
||||
COPY nginx/conf.d/wp-suite.conf /etc/nginx/conf.d/wp-suite.conf
|
||||
COPY nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY html/ /usr/share/nginx/html/
|
||||
25
nginx/conf.d/wp-suite.conf
Normal file
25
nginx/conf.d/wp-suite.conf
Normal file
@@ -0,0 +1,25 @@
|
||||
# Work Package Suite — NGINX site config
|
||||
# This container sits behind an external reverse proxy that handles SSL.
|
||||
# It listens on port 80 (plain HTTP on the internal Docker network).
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# Proxy /api/ to the FastAPI container (service name "api" on the internal network)
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 5m;
|
||||
}
|
||||
}
|
||||
17
nginx/nginx.conf
Normal file
17
nginx/nginx.conf
Normal file
@@ -0,0 +1,17 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
219
server/README.md
219
server/README.md
@@ -6,7 +6,7 @@ to this service.
|
||||
|
||||
```
|
||||
browser → NGINX ──serves──> static site (index.html, …)
|
||||
└─proxy /api/─> this API (uvicorn/gunicorn :8000) → PostgreSQL
|
||||
└─proxy /api/─> api container (:8000) → db container (postgres)
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
@@ -31,6 +31,8 @@ Interactive docs once running: **`/api/docs`**.
|
||||
The full client document is stored in each row's `data` (JSON) column; common
|
||||
fields (name, number, status, …) are promoted to columns for listing/filtering.
|
||||
|
||||
---
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
@@ -45,42 +47,193 @@ Then open http://localhost:8000/api/docs.
|
||||
> Run uvicorn/gunicorn from the **project root** (the folder that contains the
|
||||
> `server/` directory), because the import path is `server.app:app`.
|
||||
|
||||
## PostgreSQL setup (production)
|
||||
---
|
||||
|
||||
## Production — Docker Compose
|
||||
|
||||
This is the recommended production setup. Three containers run in an isolated
|
||||
internal network; only NGINX is exposed to the outside via the external `proxy`
|
||||
network.
|
||||
|
||||
```sql
|
||||
CREATE DATABASE wpsuite;
|
||||
CREATE USER wpsuite WITH PASSWORD 'CHANGE_ME';
|
||||
GRANT ALL PRIVILEGES ON DATABASE wpsuite TO wpsuite;
|
||||
```
|
||||
Tables are created automatically on first startup. (For future schema changes,
|
||||
introduce Alembic migrations rather than editing tables by hand.)
|
||||
|
||||
## Run in production (gunicorn + systemd)
|
||||
|
||||
`/etc/systemd/system/wp-suite-api.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Work Package Suite API
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/wp-suite
|
||||
Environment="DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite"
|
||||
ExecStart=/opt/wp-suite/.venv/bin/gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 server.app:app
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
[external proxy network]
|
||||
│
|
||||
┌────▼────┐ internal network ┌──────────┐ ┌────────┐
|
||||
│ nginx │ ───────────────────> │ api │ → │ db │
|
||||
└─────────┘ └──────────┘ └────────┘
|
||||
```
|
||||
|
||||
### 1. Create the credentials file
|
||||
|
||||
Create `.env` in the **project root** (same directory as `docker-compose.yml`).
|
||||
This file is never committed — add it to `.gitignore`.
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now wp-suite-api
|
||||
# .env — project root
|
||||
POSTGRES_DB=wpsuite
|
||||
POSTGRES_USER=wpsuite
|
||||
POSTGRES_PASSWORD=<strong-random-password>
|
||||
|
||||
# Must match POSTGRES_* above; hostname is the compose service name "db"
|
||||
DATABASE_URL=postgresql+psycopg://wpsuite:<strong-random-password>@db:5432/wpsuite
|
||||
```
|
||||
|
||||
NGINX already proxies `/api/` to `127.0.0.1:8000` (see `nginx-wp-suite.conf`).
|
||||
Generate a strong password:
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
### 2. Add the Dockerfile
|
||||
|
||||
Create `Dockerfile` in the **project root**:
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY server/requirements.txt ./server/
|
||||
RUN pip install --no-cache-dir -r server/requirements.txt
|
||||
COPY server/ ./server/
|
||||
EXPOSE 8000
|
||||
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", \
|
||||
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]
|
||||
```
|
||||
|
||||
### 3. Update the NGINX site config
|
||||
|
||||
The API is no longer at `127.0.0.1:8000` — it is the `api` container.
|
||||
Update the `/api/` proxy block in your nginx conf (e.g. `nginx/conf.d/wp-suite.conf`):
|
||||
|
||||
```nginx
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000; # ← service name, not localhost
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 5m;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. docker-compose.yml
|
||||
|
||||
Replace your existing `docker-compose.yml` with:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
|
||||
webserver:
|
||||
image: nginx:alpine
|
||||
container_name: nginx_webserver
|
||||
volumes:
|
||||
- ./html:/usr/share/nginx/html:ro
|
||||
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./logs:/var/log/nginx
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
networks:
|
||||
- proxy # external — reachable by your reverse proxy / traefik
|
||||
- internal # needs a path to the api container
|
||||
|
||||
api:
|
||||
build: .
|
||||
container_name: wp_api
|
||||
env_file: .env # loads DATABASE_URL
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy # waits for postgres to accept connections
|
||||
networks:
|
||||
- internal
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: wp_db
|
||||
env_file: .env # loads POSTGRES_DB / USER / PASSWORD
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- internal
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
name: proxy
|
||||
external: true
|
||||
internal:
|
||||
internal: true # no outbound internet access from api/db
|
||||
```
|
||||
|
||||
### 5. First-time startup
|
||||
|
||||
```bash
|
||||
# Build the api image and start all containers
|
||||
docker compose up -d --build
|
||||
|
||||
# Confirm all three containers are running
|
||||
docker compose ps
|
||||
|
||||
# Tail logs (Ctrl-C to stop following)
|
||||
docker compose logs -f api
|
||||
```
|
||||
|
||||
Tables are created automatically on first API startup — no manual `CREATE TABLE`
|
||||
needed.
|
||||
|
||||
### Authentication notes
|
||||
|
||||
**Postgres → API authentication** is handled entirely through `DATABASE_URL` in
|
||||
`.env`. The `db` container uses `POSTGRES_USER` / `POSTGRES_PASSWORD` to
|
||||
initialise the database on first run; the `api` container uses the matching
|
||||
credentials in `DATABASE_URL` to connect. Neither credential ever appears in the
|
||||
compose file itself.
|
||||
|
||||
**Network isolation**: the `db` container is on the `internal` network only —
|
||||
it has no port exposed to the host and is unreachable from outside the compose
|
||||
stack. Only the `api` container can open a connection to it.
|
||||
|
||||
**Changing the password**: update both `POSTGRES_PASSWORD` and the password
|
||||
in `DATABASE_URL` in `.env`, then:
|
||||
```bash
|
||||
# Stop api first (db must keep running to accept the ALTER USER command)
|
||||
docker compose stop api
|
||||
docker compose exec db psql -U wpsuite -c "ALTER USER wpsuite PASSWORD 'new-password';"
|
||||
docker compose start api
|
||||
```
|
||||
|
||||
### Day-to-day operations
|
||||
|
||||
```bash
|
||||
# Rebuild api after a code change
|
||||
docker compose up -d --build api
|
||||
|
||||
# View postgres data directly
|
||||
docker compose exec db psql -U wpsuite -d wpsuite
|
||||
|
||||
# Take a database backup
|
||||
docker compose exec db pg_dump -U wpsuite wpsuite > backup-$(date +%F).sql
|
||||
|
||||
# Restore from backup
|
||||
docker compose exec -T db psql -U wpsuite -d wpsuite < backup-2025-01-01.sql
|
||||
|
||||
# Stop everything (data volume is preserved)
|
||||
docker compose down
|
||||
|
||||
# Stop everything AND delete all data
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick test
|
||||
|
||||
@@ -91,3 +244,9 @@ curl -X POST http://127.0.0.1:8000/api/comments \
|
||||
|
||||
curl http://127.0.0.1:8000/api/comments
|
||||
```
|
||||
|
||||
Or via the nginx proxy (replace with your hostname):
|
||||
|
||||
```bash
|
||||
curl https://wp-suite.company.local/api/health
|
||||
```
|
||||
|
||||
164
server/app.py
164
server/app.py
@@ -41,8 +41,21 @@ def gen_id(prefix: str) -> str:
|
||||
|
||||
|
||||
# ── Request bodies ───────────────────────────────────────────────────────────
|
||||
class ProjectIn(BaseModel):
|
||||
id: Optional[str] = None
|
||||
name: str = ""
|
||||
number: str = ""
|
||||
client: str = ""
|
||||
division: str = ""
|
||||
site: str = ""
|
||||
sample: bool = False
|
||||
created_by: str = ""
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SopIn(BaseModel):
|
||||
id: Optional[str] = None
|
||||
project_id: Optional[str] = None
|
||||
name: str = ""
|
||||
number: str = ""
|
||||
complete: bool = False
|
||||
@@ -52,7 +65,9 @@ class SopIn(BaseModel):
|
||||
|
||||
class WpIn(BaseModel):
|
||||
id: Optional[str] = None
|
||||
project_id: Optional[str] = None
|
||||
sop_id: Optional[str] = None
|
||||
parent_id: Optional[str] = None
|
||||
number: str = ""
|
||||
subject: str = ""
|
||||
type: str = ""
|
||||
@@ -61,6 +76,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")
|
||||
@@ -81,6 +100,50 @@ def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||
@app.post("/api/projects")
|
||||
def upsert_project(body: ProjectIn, db: Session = Depends(get_db)):
|
||||
proj = db.get(models.Project, body.id) if body.id else None
|
||||
if proj is None:
|
||||
proj = models.Project(id=body.id or gen_id("proj"))
|
||||
db.add(proj)
|
||||
proj.name = body.name
|
||||
proj.number = body.number
|
||||
proj.client = body.client
|
||||
proj.division = body.division
|
||||
proj.site = body.site
|
||||
proj.sample = body.sample
|
||||
proj.created_by = body.created_by or proj.created_by
|
||||
proj.data = body.data
|
||||
db.commit()
|
||||
db.refresh(proj)
|
||||
return proj.to_dict()
|
||||
|
||||
|
||||
@app.get("/api/projects")
|
||||
def list_projects(db: Session = Depends(get_db)):
|
||||
rows = db.scalars(select(models.Project).order_by(models.Project.updated_at.desc())).all()
|
||||
return [p.summary() for p in rows]
|
||||
|
||||
|
||||
@app.get("/api/projects/{project_id}")
|
||||
def get_project(project_id: str, db: Session = Depends(get_db)):
|
||||
proj = db.get(models.Project, project_id)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return proj.to_dict()
|
||||
|
||||
|
||||
@app.delete("/api/projects/{project_id}")
|
||||
def delete_project(project_id: str, db: Session = Depends(get_db)):
|
||||
proj = db.get(models.Project, project_id)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
db.delete(proj)
|
||||
db.commit()
|
||||
return {"deleted": project_id}
|
||||
|
||||
|
||||
# ── SOPs ─────────────────────────────────────────────────────────────────────
|
||||
@app.post("/api/sops")
|
||||
def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
||||
@@ -88,6 +151,7 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
||||
if sop is None:
|
||||
sop = models.Sop(id=body.id or gen_id("sop"))
|
||||
db.add(sop)
|
||||
sop.project_id = body.project_id
|
||||
sop.name = body.name
|
||||
sop.number = body.number
|
||||
sop.complete = body.complete
|
||||
@@ -99,16 +163,21 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@app.get("/api/sops")
|
||||
def list_sops(db: Session = Depends(get_db)):
|
||||
rows = db.scalars(select(models.Sop).order_by(models.Sop.updated_at.desc())).all()
|
||||
def list_sops(project_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||
stmt = select(models.Sop)
|
||||
if project_id:
|
||||
stmt = stmt.where(models.Sop.project_id == project_id)
|
||||
rows = db.scalars(stmt.order_by(models.Sop.updated_at.desc())).all()
|
||||
return [s.summary() for s in rows]
|
||||
|
||||
|
||||
@app.get("/api/sops/latest")
|
||||
def latest_sop(complete: Optional[bool] = None, db: Session = Depends(get_db)):
|
||||
def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||
stmt = select(models.Sop)
|
||||
if complete is not None:
|
||||
stmt = stmt.where(models.Sop.complete == complete)
|
||||
if project_id:
|
||||
stmt = stmt.where(models.Sop.project_id == project_id)
|
||||
sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first()
|
||||
if not sop:
|
||||
raise HTTPException(status_code=404, detail="No SOP found")
|
||||
@@ -140,7 +209,9 @@ def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
|
||||
if wp is None:
|
||||
wp = models.WorkPackage(id=body.id or gen_id("wp"))
|
||||
db.add(wp)
|
||||
wp.project_id = body.project_id
|
||||
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 +224,68 @@ 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(
|
||||
project_id: Optional[str] = Query(None),
|
||||
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 project_id:
|
||||
stmt = stmt.where(models.WorkPackage.project_id == project_id)
|
||||
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(project_id: Optional[str] = Query(None), 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 project_id:
|
||||
stmt = stmt.where(models.WorkPackage.project_id == project_id)
|
||||
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 +304,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 {}
|
||||
|
||||
@@ -21,10 +21,42 @@ def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Project(Base):
|
||||
"""A construction project — the top-level container. SOPs and Work Packages
|
||||
belong to a project so the suite can be used for many jobs at once."""
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(300), default="")
|
||||
number: Mapped[str] = mapped_column(String(100), default="", index=True)
|
||||
client: Mapped[str] = mapped_column(String(300), default="")
|
||||
division: Mapped[str] = mapped_column(String(200), default="")
|
||||
site: Mapped[str] = mapped_column(String(300), default="")
|
||||
sample: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
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)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "name": self.name, "number": self.number,
|
||||
"client": self.client, "division": self.division, "site": self.site,
|
||||
"sample": self.sample, "created_by": self.created_by,
|
||||
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||
}
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {**self.summary(), "data": self.data or {}}
|
||||
|
||||
|
||||
class Sop(Base):
|
||||
__tablename__ = "sops"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
project_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(300), default="")
|
||||
number: Mapped[str] = mapped_column(String(100), default="")
|
||||
complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
@@ -35,8 +67,8 @@ class Sop(Base):
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "name": self.name, "number": self.number,
|
||||
"complete": self.complete, "created_by": self.created_by,
|
||||
"id": self.id, "project_id": self.project_id, "name": self.name,
|
||||
"number": self.number, "complete": self.complete, "created_by": self.created_by,
|
||||
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||
}
|
||||
|
||||
@@ -48,13 +80,19 @@ class WorkPackage(Base):
|
||||
__tablename__ = "work_packages"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
project_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
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 +100,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, "project_id": self.project_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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user