Compare commits
5 Commits
savepoint-
...
feat/wp-di
| Author | SHA1 | Date | |
|---|---|---|---|
| 561d4f2408 | |||
| a18ae487f6 | |||
| 68c1c803d6 | |||
| e5c450597a | |||
| 3c40b58ff8 |
@@ -130,6 +130,49 @@ to `fetch('/api/wps…')` in Phase 2 and the UI is unchanged.
|
||||
> 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`).
|
||||
- WP Creator: save packages via `POST /api/wps`; list/load via `GET /api/wps`
|
||||
|
||||
191
html/index.html
191
html/index.html
@@ -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">
|
||||
@@ -443,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 -->
|
||||
@@ -470,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){}
|
||||
|
||||
@@ -484,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');
|
||||
@@ -497,7 +630,9 @@
|
||||
if(wpCard) wpCard.classList.add('disabled');
|
||||
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
initProjects();
|
||||
|
||||
let allComments = [];
|
||||
|
||||
|
||||
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,6 +4,25 @@ 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; } }
|
||||
|
||||
// WP size presets — the dropdown label maps to a default split-threshold (max
|
||||
// labor hours). The label is exported as governance.woSize (human-readable
|
||||
// guidance); the number drives the Creator's "consider splitting" warning.
|
||||
const WP_SIZE_PRESETS = {
|
||||
'Small — 1–2 days (≈8–24 hrs)': 24,
|
||||
'Standard — 3–5 days (≈40–80 hrs)': 80,
|
||||
'Large — 1–2 weeks (≈80–160 hrs)': 160
|
||||
};
|
||||
function onSizePresetChange(){
|
||||
const label = document.getElementById('gov_wosize').value;
|
||||
const max = WP_SIZE_PRESETS[label];
|
||||
if(max != null){ document.getElementById('gov_size_hours_max').value = max; }
|
||||
// 'Custom…' / '' leave the threshold for manual entry.
|
||||
}
|
||||
|
||||
let state = {
|
||||
project: {name:'', number:'', client:'', division:'', site:''},
|
||||
team: {pm:'', apm:'', cm:'', qm:''},
|
||||
@@ -126,14 +145,17 @@ 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 | ?view=dashboard 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(params.get('view') === 'dashboard') switchTool('wp');
|
||||
if(params.get('view') === 'dashboard') switchTool('dashboard');
|
||||
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
|
||||
|
||||
track('app_open');
|
||||
@@ -154,7 +176,15 @@ function initializeWPTypes(){
|
||||
}
|
||||
|
||||
// ── LOAD SAMPLE DATA ──────────────────────────────────────────────────────────
|
||||
// Context-aware: on the SOP tab it loads the sample SOP; on the WP / Dashboard
|
||||
// tab it loads the example Work Package inside the embedded creator.
|
||||
function loadSampleData(){
|
||||
if(currentTool && currentTool !== 'sop'){
|
||||
const f = document.getElementById('wp-frame');
|
||||
if(f && f.contentWindow && typeof f.contentWindow.loadExample === 'function'){ f.contentWindow.loadExample(); }
|
||||
else { alert('Open the Work Package Creation tab first, then load the sample.'); }
|
||||
return;
|
||||
}
|
||||
// Populate Step 1
|
||||
document.getElementById('proj_name').value = 'MICRON_PH1_CUP_HPM_FMCS INSTALL';
|
||||
document.getElementById('proj_number').value = '26-67-008';
|
||||
@@ -174,10 +204,10 @@ 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_wosize').value = 'Standard — 3–5 days (≈40–80 hrs)';
|
||||
document.getElementById('gov_disciplines').value = 'Mechanical, Electrical, Tech';
|
||||
document.getElementById('gov_discmode').value = 'choice';
|
||||
document.getElementById('gov_size_hours_max').value = '120';
|
||||
document.getElementById('gov_size_hours_max').value = '80';
|
||||
|
||||
// Populate Step 6
|
||||
document.getElementById('qual_qcreq').value = 'Yes — Detailed inspection items';
|
||||
@@ -202,9 +232,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;
|
||||
|
||||
@@ -239,6 +269,12 @@ function repopulateForm(){
|
||||
if(state.signoffRoles[0]) set('role_super_name', state.signoffRoles[0].name);
|
||||
if(state.signoffRoles[1]) set('role_foreman_name', state.signoffRoles[1].name);
|
||||
set('gov_woformat', state.governance.woformat);
|
||||
// gov_wosize is now a <select>; if a saved value isn't one of the presets
|
||||
// (e.g. legacy free text), add it as an option so the round-trip preserves it.
|
||||
const wsEl = document.getElementById('gov_wosize');
|
||||
if(wsEl && state.governance.wosize && !Array.from(wsEl.options).some(o=>o.value===state.governance.wosize)){
|
||||
wsEl.add(new Option(state.governance.wosize, state.governance.wosize));
|
||||
}
|
||||
set('gov_wosize', state.governance.wosize);
|
||||
set('gov_disciplines', (state.governance.disciplines||[]).join(', '));
|
||||
set('gov_discmode', state.governance.discMode);
|
||||
@@ -253,30 +289,32 @@ function repopulateForm(){
|
||||
// ── TOOL SWITCHING ────────────────────────────────────────────────────────────
|
||||
function switchTool(tool){
|
||||
currentTool = tool;
|
||||
|
||||
// 'dashboard' is a pseudo-tab: it reuses the WP tool's content (the embedded
|
||||
// creator) but opens it straight to the dashboard view.
|
||||
const isDash = (tool === 'dashboard');
|
||||
const contentTool = isDash ? 'wp' : tool;
|
||||
|
||||
// Update nav tabs
|
||||
document.querySelectorAll('.nav-tab').forEach(t=>t.classList.remove('active'));
|
||||
document.querySelector(`[data-tab="${tool}"]`).classList.add('active');
|
||||
|
||||
const tabBtn = document.querySelector(`[data-tab="${tool}"]`);
|
||||
if(tabBtn) tabBtn.classList.add('active');
|
||||
|
||||
// Update content
|
||||
document.querySelectorAll('.tool').forEach(t=>t.classList.remove('active'));
|
||||
document.getElementById(`tool-${tool}`).classList.add('active');
|
||||
|
||||
// Reset step counter
|
||||
if(tool === 'sop'){
|
||||
document.getElementById('total-steps').textContent = '10';
|
||||
}else{
|
||||
document.getElementById('total-steps').textContent = '—';
|
||||
}
|
||||
document.getElementById(`tool-${contentTool}`).classList.add('active');
|
||||
|
||||
if(tool === 'wp') renderWPTab();
|
||||
// Reset step counter
|
||||
document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—';
|
||||
|
||||
if(contentTool === 'wp') renderWPTab(isDash);
|
||||
|
||||
updateStepUI();
|
||||
updateProjectDisplay();
|
||||
}
|
||||
|
||||
// Show the gate or the embedded Work Package Creator depending on SOP status.
|
||||
function renderWPTab(){
|
||||
// wantDash=true opens the creator straight to the dashboard view.
|
||||
function renderWPTab(wantDash){
|
||||
const gate = document.getElementById('wp-gate');
|
||||
const frame = document.getElementById('wp-frame');
|
||||
if(!gate || !frame) return;
|
||||
@@ -284,8 +322,11 @@ function renderWPTab(){
|
||||
gate.style.display = 'none';
|
||||
frame.style.display = 'block';
|
||||
// Reload each time so the creator picks up the latest SOP from localStorage.
|
||||
const wantDash = new URLSearchParams(window.location.search).get('view') === 'dashboard';
|
||||
frame.src = 'wp-creation-index.html?embedded=1' + (wantDash ? '&view=dashboard' : '') + '&t=' + Date.now();
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
const dash = wantDash || sp.get('view') === 'dashboard';
|
||||
const projId = sp.get('project') || (activeProject && activeProject.id) || '';
|
||||
frame.src = 'wp-creation-index.html?embedded=1' + (dash ? '&view=dashboard' : '')
|
||||
+ (projId ? '&project=' + encodeURIComponent(projId) : '') + '&t=' + Date.now();
|
||||
}else{
|
||||
gate.style.display = 'block';
|
||||
frame.style.display = 'none';
|
||||
@@ -297,8 +338,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;
|
||||
}
|
||||
@@ -315,14 +391,24 @@ function renderWPTypes(){
|
||||
state.wpTypes.forEach((t,i)=>{
|
||||
const row = document.createElement('div');
|
||||
row.className = 'wp-type-row';
|
||||
const nameCell = t.custom
|
||||
? `<div style="display:flex; gap:6px; align-items:center;">
|
||||
<input type="text" placeholder="Custom type name" value="${(t.name||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].name=this.value" style="flex:1; padding:0.5rem; border:1px solid var(--border); border-radius:4px; font-weight:600;">
|
||||
<button onclick="removeWPType(${i})" title="Remove custom type" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600; flex:none;">✕</button>
|
||||
</div>`
|
||||
: `<div style="font-weight:600;">${t.name}</div>`;
|
||||
row.innerHTML = `
|
||||
<div style="font-weight:600;">${t.name}</div>
|
||||
${nameCell}
|
||||
<div style="text-align:center;"><input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})" style="width:18px; height:18px; cursor:pointer;"></div>
|
||||
<input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
||||
`;
|
||||
container.appendChild(row);
|
||||
});
|
||||
const addRow = document.createElement('div');
|
||||
addRow.style.cssText = 'margin-top:0.85rem;';
|
||||
addRow.innerHTML = `<button onclick="addCustomWPType()" style="background:var(--primary,#0f62fe); color:#fff; border:none; padding:0.55rem 1rem; border-radius:4px; font-weight:600; cursor:pointer; font-size:13px;">+ Add Custom Type</button>`;
|
||||
container.appendChild(addRow);
|
||||
}
|
||||
|
||||
function toggleWPType(i){
|
||||
@@ -330,6 +416,21 @@ function toggleWPType(i){
|
||||
renderWPTypes();
|
||||
}
|
||||
|
||||
function addCustomWPType(){
|
||||
state.wpTypes.push({name:'', enabled:true, notes:'', approval:'', custom:true});
|
||||
renderWPTypes();
|
||||
// Focus the new custom row's name input.
|
||||
const rows = document.querySelectorAll('#wp-types-table .wp-type-row');
|
||||
const last = rows[rows.length-1];
|
||||
const nameInput = last && last.querySelector('input[type="text"]');
|
||||
if(nameInput) nameInput.focus();
|
||||
}
|
||||
|
||||
function removeWPType(i){
|
||||
state.wpTypes.splice(i,1);
|
||||
renderWPTypes();
|
||||
}
|
||||
|
||||
function renderTeamMembers(){
|
||||
const container = document.getElementById('team-members-list');
|
||||
if(!container) return;
|
||||
@@ -651,8 +752,8 @@ function completeSOP(){
|
||||
instanceSuffix: state.governance.instanceSuffix || 'letter',
|
||||
sizeHoursMax: state.governance.sizeHoursMax || ''
|
||||
},
|
||||
woTypes: state.wpTypes.filter(t=>t.enabled).map(t=>({
|
||||
name: t.name,
|
||||
woTypes: state.wpTypes.filter(t=>t.enabled && (t.name||'').trim()).map(t=>({
|
||||
name: t.name.trim(),
|
||||
enabled: true,
|
||||
notes: t.notes || '',
|
||||
approval: t.approval || ''
|
||||
@@ -675,21 +776,26 @@ function completeSOP(){
|
||||
};
|
||||
|
||||
sopComplete = true;
|
||||
// Stamp the active project onto the SOP so it's unambiguously tied to it.
|
||||
try { if(typeof ProjectData!=='undefined' && ProjectData.getActiveId()) sop.projectId = ProjectData.getActiveId(); } catch(e){}
|
||||
updateProjectDisplay();
|
||||
|
||||
// Persist for the home page (green / "Review") and for the WP Creator tab.
|
||||
// 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});
|
||||
|
||||
alert('✓ SOP Configuration Complete!\n\nSwitch to "Work Package Creation" to start creating Work Packages.');
|
||||
|
||||
// Hand the SOP to the embedded Work Package Creator and unlock its tab.
|
||||
// Hand the SOP to the embedded Work Package Creator and unlock its tab (in case
|
||||
// the user stays), then return to the project home page per the requested flow.
|
||||
if(typeof onSOPReady === 'function') onSOPReady(sop);
|
||||
|
||||
alert('✓ SOP Configuration Complete!\n\nReturning to the project home page.');
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
|
||||
// ── COMMENTS ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load example SOP data">⭐ Load Sample</button>
|
||||
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load sample data for the current tool (SOP or Work Package)">⭐ Load Sample</button>
|
||||
<button class="header-button" onclick="toggleComments()" title="View and add comments for the current step">💬 Step Comments</button>
|
||||
<button class="header-button" onclick="showAnalytics()" title="Review usage logs for this tool">📊 Usage Logs</button>
|
||||
<span class="step-counter"><span id="current-step">1</span> / <span id="total-steps">10</span></span>
|
||||
@@ -37,6 +37,9 @@
|
||||
<button class="nav-tab" data-tab="wp" onclick="switchTool('wp')">
|
||||
<span class="tab-icon">📋</span> Work Package Creation
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="dashboard" onclick="switchTool('dashboard')">
|
||||
<span class="tab-icon">📊</span> Dashboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- CONTENT AREA -->
|
||||
@@ -202,13 +205,20 @@
|
||||
<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">
|
||||
<label>Typical WP Size</label>
|
||||
<select id="gov_wosize" onchange="onSizePresetChange()">
|
||||
<option value="">Select…</option>
|
||||
<option value="Small — 1–2 days (≈8–24 hrs)">Small — 1–2 days (≈8–24 hrs)</option>
|
||||
<option value="Standard — 3–5 days (≈40–80 hrs)">Standard — 3–5 days (≈40–80 hrs)</option>
|
||||
<option value="Large — 1–2 weeks (≈80–160 hrs)">Large — 1–2 weeks (≈80–160 hrs)</option>
|
||||
<option value="Custom…">Custom…</option>
|
||||
</select>
|
||||
<small>Sets the split threshold automatically; choose Custom to enter your own.</small>
|
||||
</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>
|
||||
<input type="number" id="gov_size_hours_max" min="0" step="1" placeholder="e.g., 80">
|
||||
<small>Auto-set from the size above (editable). The Creator flags packages over this so they can be split.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -341,7 +351,7 @@
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="font-weight: 600; font-size: 13px;">Your Name (optional)</label>
|
||||
<input type="text" id="commenter-name" placeholder="e.g., Bill Clarida" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem;">
|
||||
<input type="text" id="commenter-name" placeholder="e.g., your name" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem;">
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="font-weight: 600; font-size: 13px;">Feedback</label>
|
||||
@@ -369,6 +379,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,7 +8,7 @@ const SAMPLE_SOP = {
|
||||
meta:{tool:'Work Package Configuration', sample:true},
|
||||
project:{name:'Micron — INC Construction Work Packages', number:'26-67-008', client:'Micron Technology, Inc.', division:'Semiconductor', pm:'Nick Siegfried', cm:'K. Boyd', qm:'D. Nguyen', site:'Boise, ID — Fab'},
|
||||
roles:[{role:'General Foreman',name:'M. Torres'},{role:'Superintendent',name:'K. Boyd'},{role:'Safety Manager / Lead',name:'A. Reyes'},{role:'Quality Manager',name:'D. Nguyen'},{role:'Planner',name:'L. Graver'}],
|
||||
governance:{ issuance:['By Sector / Area','By Discipline'], woSize:'3–5 days', woFormat:'WP##-[Sector]-[TYPE]', disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:'120' },
|
||||
governance:{ issuance:['By Sector / Area','By Discipline'], woSize:'Standard — 3–5 days (≈40–80 hrs)', woFormat:'WP##-[Sector]-[TYPE]', disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:'80' },
|
||||
woTypes:[
|
||||
{name:'Conduit Install', enabled:true}, {name:'Tray Install', enabled:true},
|
||||
{name:'Wire Pull', enabled:true}, {name:'Terminations', enabled:true},
|
||||
@@ -34,15 +34,16 @@ const STATUS_ORDER = ['Draft','Scheduled','Issued','In Progress','QC','Closed'];
|
||||
const ISSUED_IDX = STATUS_ORDER.indexOf('Issued');
|
||||
|
||||
// Acumatica cost codes (comment 10) — code|description
|
||||
const COST_CODES = ['1000|Project Management','2000|Design and Development','2100|Design','2110|Control System Design','2120|Instrument Design','2130|Electrical Design','2140|Panel Design','2141|Panel Design Rework','2150|BIM','2151|BIM Rework','2160|Documentation','2200|Development','2210|PLC Programming','2220|OIT Programming','2230|SCADA Programming','2240|Simulation Development','2290|Programming Subcontract','2300|Customer Training','3000|Operational Technology','3100|OT Design','3200|Rack Assembly','3300|Network Configuration','3400|Computer Configuration','4000|Construction','4010|Instruments Install','4020|Network & Computers Install','4040|PLC Install','4050|Panel Install','4060|Electrical Install','4070|Mechanical Install','4080|Security Install','4090|Radio Install','4100|Commissioning','4940|Contract Labor','4960|Electrical Subcontract','4970|Mechanical Subcontract','4980|Security Subcontract','4990|Other/Radio Subcontract','5010|Instrument Material','5020|Network & Computers Material','5030|Software Material','5040|PLC Material','5050|Panel Material','5060|Electrical Material','5070|Mechanical Material','5080|Security Material','5090|Radio Material','6000|Production','7000|Quality','7100|Panel Quality Control','7200|Factory Acceptance Testing','7300|Site Acceptance Testing','8000|Safety','9000|Administration','9100|Warranty','9200|Freight','9300|Travel','9350|Jobsite Costs/Consumable/Other Direct Costs','9400|Contingency','9450|Other','9500|Accrued Incentive Compensation','9600|Sales Tax','9650|Job Cost Labor Burden','9700|Bonding','9800|Non-Billable Compensation'];
|
||||
const COST_CODES = ['1000|Project Management','2000|Design and Development','2100|Design','2110|Control System Design','2120|Instrument Design','2130|Electrical Design','2140|Panel Design','2141|Panel Design Rework','2150|BIM','2151|BIM Rework','2160|Documentation','2200|Development','2210|PLC Programming','2220|OIT Programming','2230|SCADA Programming','2240|Simulation Development','2290|Programming Subcontract','2300|Customer Training','3000|Operational Technology','3100|OT Design','3200|Rack Assembly','3300|Network Configuration','3400|Computer Configuration','4000|Construction','4010|Instruments Install','4020|Network & Computers Install','4040|PLC Install','4050|Panel Install','4060|Electrical Install','4070|Mechanical Install','4080|Security Install','4090|Radio Install','4100|Commissioning','4940|Contract Labor','4960|Electrical Subcontract','4970|Mechanical Subcontract','4980|Security Subcontract','4990|Other/Radio Subcontract'];
|
||||
// Acumatica allowed units of measure (comment 15) — common first
|
||||
const ACU_UNITS = ['EA','EACH','FT','M','METER','HR','DAYS','MINUTE','KG','LITER','CASE','LOT','LS','PK','PACK','PALLET','PIECE','BOTTLE','CAN'];
|
||||
|
||||
// Example built work package (comment 4 / "Load Example") — WP02 export
|
||||
const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":"FAB 1P Horn Strobe Conduit","type":"Conduit Install","system":"Chilled Water","location":"FAB / LVL 1 / Sect P","cost":"4060","wbs":"2001","assignees":"David Velazquez (Catapult Solutions Group), Jesus Casiano-Figueroa (Prime Controls)","distribution":"Bill Clarida (Prime Controls), Sean Tolley (Prime Controls), Jefferson Dufriend (Prime Controls)","due":"2026-06-18","spec":"26_05_33_31 - Conduit","desc":"1P Conduit run for horns and strobes","assets":[{"tag":"1P-HS-NORTH","desc":"1P North horn/strobe circuit","link":"https://controls.dev/assets/1P-HS-NORTH"}],"work":"Layout conduit route\nUsing ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.\nWhen complete initiate inspection with Prime QAQC","workSteps":["Layout conduit route","Using ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.","When complete initiate inspection with Prime QAQC"],"numberDims":{"Sector":"1P","Discipline":"ELEC"},"hours":"60","seq":"Layout","materials":[{"qty":"400","unit":"FT","desc":"3/4\" EMT"},{"qty":"300","unit":"FT","desc":"1\" EMT"},{"qty":"50","unit":"FT","desc":"7/8\" strut"},{"qty":"50","unit":"FT","desc":"1-5/8\" strut"},{"qty":"8","unit":"EA","desc":"4x4x4 NEMA 3 Box"},{"qty":"2","unit":"EA","desc":"1\" C-Type Conduit Body"},{"qty":"1","unit":"EA","desc":"1\" T-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" C-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" T-Type Conduit Body"},{"qty":"3","unit":"EA","desc":"3/4\" EMT LB & Cover"},{"qty":"6","unit":"EA","desc":"Hoffman F44GCPNK Horn Strobe Box"},{"qty":"2","unit":"EA","desc":"EMO 2x4 Box"},{"qty":"4","unit":"EA","desc":"1\" Bond Bushing w/ Lug"},{"qty":"4","unit":"EA","desc":"3/4\" Bond Bushing w/ Lug"},{"qty":"50","unit":"EA","desc":"1/4\"x3\" Toggle Bolt"},{"qty":"100","unit":"EA","desc":"Drywall Anchor"},{"qty":"5","unit":"EA","desc":"1\" to 3/4\" threaded reducer"}],"attachments":[{"doc":"EE-1YA-2P-5_ HPM PANEL CALLOUT 05","rev":"0","link":"https://us02.procore.com/webclients/host/companies/562949953431440/projects/562949954073428/tools/document-viewer/prostore/562950644886790"}],"kitStatus":"Open","kitOwner":"Ian Spielburg","kitDate":"2026-06-15","mimoTime":"2026-06-11T10:30","mimoLoc":"04","constraints":[{"name":"Safety & Permitting","status":"cleared","comment":""},{"name":"Quality Control / Inspection","status":"cleared","comment":""},{"name":"IFC Drawings & Specs","status":"cleared","comment":""},{"name":"Schedule","status":"cleared","comment":""},{"name":"Materials (on site, bagged & tagged)","status":"cleared","comment":""},{"name":"Prefabrication","status":"cleared","comment":""},{"name":"Work Access & Laydown","status":"cleared","comment":""},{"name":"Craft Availability","status":"cleared","comment":""},{"name":"Construction Equipment & Tools","status":"open","comment":"Boom lift not yet on site"},{"name":"Scaffolding / Access Equipment","status":"cleared","comment":""}],"qc":"Yes — Detailed inspection items","photo":"Key checkpoints only","hold":"HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.","overrides":{"wp_photo":"Change Order"},"signoffs":[{"role":"Planner","name":"L. Graver","date":"2026-06-10","signed":true,"dateReason":""},{"role":"Superintendent","name":"K. Boyd","date":"2026-06-10","signed":true,"dateReason":""},{"role":"HSE Professional","name":"A. Reyes","date":"","signed":false,"dateReason":""},{"role":"Quality Representative","name":"D. Nguyen","date":"","signed":false,"dateReason":""},{"role":"Work Foreman","name":"M. Torres","date":"","signed":false,"dateReason":""}],"holds":[],"actualHrs":"54","installedQty":"200 ft","redlines":"Conduit size changed. need to update model","lessons":"Prepping trapeze hangers with conduit straps saved time","project":"Micron — INC Construction Work Packages","track":"CxAlloy"};
|
||||
const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":"FAB 1P Horn Strobe Conduit","type":"Conduit Install","system":"Chilled Water","location":"FAB / LVL 1 / Sect P","cost":"4060","wbs":"2001","assignees":"David Velazquez (Catapult Solutions Group), Jesus Casiano-Figueroa (Prime Controls)","distribution":"Sean Tolley (Prime Controls), Jefferson Dufriend (Prime Controls)","due":"2026-06-18","spec":"26_05_33_31 - Conduit","desc":"1P Conduit run for horns and strobes","assets":[{"tag":"1P-HS-NORTH","desc":"1P North horn/strobe circuit","link":"https://controls.dev/assets/1P-HS-NORTH"}],"work":"Layout conduit route\nUsing ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.\nWhen complete initiate inspection with Prime QAQC","workSteps":["Layout conduit route","Using ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.","When complete initiate inspection with Prime QAQC"],"numberDims":{"Sector":"1P","Discipline":"ELEC"},"hours":"60","seq":"Layout","materials":[{"qty":"400","unit":"FT","desc":"3/4\" EMT"},{"qty":"300","unit":"FT","desc":"1\" EMT"},{"qty":"50","unit":"FT","desc":"7/8\" strut"},{"qty":"50","unit":"FT","desc":"1-5/8\" strut"},{"qty":"8","unit":"EA","desc":"4x4x4 NEMA 3 Box"},{"qty":"2","unit":"EA","desc":"1\" C-Type Conduit Body"},{"qty":"1","unit":"EA","desc":"1\" T-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" C-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" T-Type Conduit Body"},{"qty":"3","unit":"EA","desc":"3/4\" EMT LB & Cover"},{"qty":"6","unit":"EA","desc":"Hoffman F44GCPNK Horn Strobe Box"},{"qty":"2","unit":"EA","desc":"EMO 2x4 Box"},{"qty":"4","unit":"EA","desc":"1\" Bond Bushing w/ Lug"},{"qty":"4","unit":"EA","desc":"3/4\" Bond Bushing w/ Lug"},{"qty":"50","unit":"EA","desc":"1/4\"x3\" Toggle Bolt"},{"qty":"100","unit":"EA","desc":"Drywall Anchor"},{"qty":"5","unit":"EA","desc":"1\" to 3/4\" threaded reducer"}],"attachments":[{"doc":"EE-1YA-2P-5_ HPM PANEL CALLOUT 05","rev":"0","link":"https://us02.procore.com/webclients/host/companies/562949953431440/projects/562949954073428/tools/document-viewer/prostore/562950644886790"}],"kitStatus":"Open","kitOwner":"Ian Spielburg","kitDate":"2026-06-15","mimoTime":"2026-06-11T10:30","mimoLoc":"04","constraints":[{"name":"Safety & Permitting","status":"cleared","comment":""},{"name":"Quality Control / Inspection","status":"cleared","comment":""},{"name":"IFC Drawings & Specs","status":"cleared","comment":""},{"name":"Schedule","status":"cleared","comment":""},{"name":"Materials (on site, bagged & tagged)","status":"cleared","comment":""},{"name":"Prefabrication","status":"cleared","comment":""},{"name":"Work Access & Laydown","status":"cleared","comment":""},{"name":"Craft Availability","status":"cleared","comment":""},{"name":"Construction Equipment & Tools","status":"open","comment":"Boom lift not yet on site"},{"name":"Scaffolding / Access Equipment","status":"cleared","comment":""}],"qc":"Yes — Detailed inspection items","photo":"Key checkpoints only","hold":"HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.","overrides":{"wp_photo":"Change Order"},"signoffs":[{"role":"Planner","name":"L. Graver","date":"2026-06-10","signed":true,"dateReason":""},{"role":"Superintendent","name":"K. Boyd","date":"2026-06-10","signed":true,"dateReason":""},{"role":"HSE Professional","name":"A. Reyes","date":"","signed":false,"dateReason":""},{"role":"Quality Representative","name":"D. Nguyen","date":"","signed":false,"dateReason":""},{"role":"Work Foreman","name":"M. Torres","date":"","signed":false,"dateReason":""}],"holds":[],"actualHrs":"54","installedQty":"200 ft","redlines":"Conduit size changed. need to update model","lessons":"Prepping trapeze hangers with conduit straps saved time","project":"Micron — INC Construction Work Packages","track":"CxAlloy"};
|
||||
|
||||
// ── STATE ────────────────────────────────────────────────────────────────────
|
||||
let SOP=null, editingId=null, numberDirty=false;
|
||||
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)
|
||||
@@ -135,7 +136,12 @@ function editQuality(id){
|
||||
}
|
||||
function renderCtxBar(){
|
||||
const bar=document.getElementById('ctx-bar');
|
||||
if(!SOP){ bar.innerHTML=`<div class="ctx-empty">No SOP loaded — <button class="link-btn" onclick="loadSampleSOP()">load the sample</button> or import one from the Configuration tool.</div>`; return; }
|
||||
if(!SOP){
|
||||
bar.innerHTML = activeProjectId
|
||||
? `<div class="ctx-empty">No SOP found for this project yet — complete the <strong>SOP Configuration</strong> first, then return here.</div>`
|
||||
: `<div class="ctx-empty">No SOP loaded — <button class="link-btn" onclick="loadSampleSOP()">load the sample</button> or import one from the Configuration tool.</div>`;
|
||||
return;
|
||||
}
|
||||
const p=SOP.project||{}, g=SOP.governance||{};
|
||||
const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:'';
|
||||
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}</div>
|
||||
@@ -371,12 +377,15 @@ function rollupDisciplineStatus(){
|
||||
// ── WP SIZING WARNING (governance.sizeHoursMax) ──────────────────────────────
|
||||
function onHoursChange(){
|
||||
const el=document.getElementById('size-check'); if(!el) return;
|
||||
const max=parseFloat((SOP&&SOP.governance&&SOP.governance.sizeHoursMax)||'');
|
||||
const g=(SOP&&SOP.governance)||{};
|
||||
const band=g.woSize?('Target: '+g.woSize+'. '):'';
|
||||
const max=parseFloat(g.sizeHoursMax||'');
|
||||
const hrs=parseFloat(gv('wp_hours'));
|
||||
if(max && hrs && hrs>max){
|
||||
el.innerHTML=`<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=''; }
|
||||
el.innerHTML=`<span style="color:var(--accent-amber)">${esc(band)}⚠ ${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try <strong>Split by Discipline</strong>).':'.')+`</span>`;
|
||||
} else if(band || max){
|
||||
el.innerHTML=`<span>${esc(band)}${max?'Split threshold: '+max+' hrs.':''}</span>`;
|
||||
} else { el.textContent=''; }
|
||||
}
|
||||
|
||||
// ── SPLIT BY DISCIPLINE ──────────────────────────────────────────────────────
|
||||
@@ -631,6 +640,7 @@ function collectPackage(){
|
||||
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'),
|
||||
@@ -756,8 +766,10 @@ function showForm(){ hideDashboard(); document.querySelectorAll('.main > .card')
|
||||
|
||||
// ── 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})`:'';
|
||||
@@ -818,6 +830,36 @@ function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; bu
|
||||
buildConstraints(); }
|
||||
function renderSignoffRows(){ const tmp=pkgSignoffs; pkgSignoffs=[]; buildSignoffs(); tmp.forEach(s=>{ const c=pkgSignoffs.find(x=>x.role===s.role); if(c){ c.name=s.name; c.date=s.date; c.signed=s.signed; c.dateReason=s.dateReason||''; }}); buildSignoffs(); }
|
||||
|
||||
// Duplicate the current work package N times (asks how many). Each copy is a
|
||||
// fresh Draft with a unique number/subject and approvals/closeout cleared.
|
||||
function duplicateWP(){
|
||||
if(!gv('wp_subject')){ alert('Open or fill in a work package first, then Duplicate.'); return; }
|
||||
const ans=prompt('How many copies of this work package do you want to create?','1');
|
||||
if(ans===null) return;
|
||||
const n=parseInt(ans,10);
|
||||
if(!n || n<1 || n>50){ alert('Enter a whole number between 1 and 50.'); return; }
|
||||
const base=collectPackage();
|
||||
const baseNum = base.number || ('WP'+pad2(editingSeq()));
|
||||
const made=[];
|
||||
for(let i=1;i<=n;i++){
|
||||
const c=JSON.parse(JSON.stringify(base));
|
||||
c.id='wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)+i;
|
||||
c.number = baseNum + '-C' + i;
|
||||
c.subject = base.subject + (n>1 ? ' (copy '+i+')' : ' (copy)');
|
||||
c.status='Draft';
|
||||
c.instanceOf=undefined; c.instanceLabel=undefined; c.parentNumber=undefined; c.split=undefined; c.children=undefined;
|
||||
if(Array.isArray(c.signoffs)) c.signoffs=c.signoffs.map(s=>({...s, signed:false, date:'', dateReason:''}));
|
||||
c.holds=[]; c.actualHrs=''; c.installedQty=''; c.redlines=''; c.lessons='';
|
||||
c.projectId = base.projectId || activeProjectId || '';
|
||||
c.updatedAt=new Date().toISOString();
|
||||
savedPackages.push(c); made.push(c);
|
||||
}
|
||||
editingId=null; saveStore(); renderSavedList();
|
||||
toast('Created '+n+' duplicate'+(n>1?'s':''));
|
||||
track('wp_duplicated',{count:n});
|
||||
alert('Created '+n+' duplicate'+(n>1?'s':'')+':\n\n• '+made.map(m=>m.number).join('\n• ')+'\n\nThey are in the Saved Work Packages list — edit each as needed.');
|
||||
}
|
||||
|
||||
function newPackage(){
|
||||
editingId=null;
|
||||
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||||
@@ -1010,6 +1052,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)
|
||||
@@ -1017,13 +1068,17 @@ 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; }
|
||||
}
|
||||
} catch(e){}
|
||||
loadSampleSOP();
|
||||
// No SOP found. Only show the Micron SAMPLE for a standalone preview (no project
|
||||
// context). For a real project, never substitute sample data — show the empty
|
||||
// state so it's clear the project's SOP must be completed first.
|
||||
if(activeProjectId){ SOP=null; renderCtxBar(); newPackage(); }
|
||||
else { loadSampleSOP(); }
|
||||
})();
|
||||
setRadio('status','Draft');
|
||||
renderSavedList();
|
||||
|
||||
@@ -13,21 +13,22 @@
|
||||
<div class="loading-overlay" id="loadingOverlay"><div class="spinner"></div><div class="loading-text">Saving work package…</div></div>
|
||||
|
||||
<div class="header">
|
||||
<div class="logo-wrap">
|
||||
<div class="logo-wrap embed-hide">
|
||||
<div class="header-logo">Prime Controls</div>
|
||||
<button id="dev-toggle" class="dev-toggle" onclick="toggleDevMode()" title="dev mode" aria-label="dev mode"></button>
|
||||
</div>
|
||||
<div class="header-sep">|</div>
|
||||
<div class="header-sep embed-hide">|</div>
|
||||
<div class="header-title">Work Package (IWP)</div>
|
||||
<button class="btn btn-ghost embed-hide" style="margin-left:auto;padding:7px 16px" onclick="document.getElementById('sop-import').click()">⤒ Import SOP</button>
|
||||
<input type="file" id="sop-import" accept="application/json" style="display:none" onchange="importSOP(event)">
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadSampleSOP()">⤓ Sample SOP</button>
|
||||
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="openSopModal()">👁 View SOP</button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="loadExample()">★ Load Example</button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="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>
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="openSopModal()">👁 View SOP</button>
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadExample()">★ Load Example</button>
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
|
||||
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="newPackage()">+ New</button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="duplicateWP()">⧉ Duplicate</button>
|
||||
<button class="btn btn-ghost embed-hide" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button>
|
||||
</div>
|
||||
|
||||
<div class="dev-banner" id="dev-banner" style="display:none">⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.</div>
|
||||
@@ -276,6 +277,7 @@
|
||||
</aside>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="wp-creation-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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,6 +65,7 @@ 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 = ""
|
||||
@@ -86,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)):
|
||||
@@ -93,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
|
||||
@@ -104,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")
|
||||
@@ -145,6 +209,7 @@ 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
|
||||
@@ -160,12 +225,15 @@ def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
|
||||
|
||||
@app.get("/api/wps")
|
||||
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:
|
||||
@@ -177,11 +245,13 @@ def list_wps(
|
||||
|
||||
|
||||
@app.get("/api/wps/metrics")
|
||||
def wp_metrics(sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||
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()
|
||||
|
||||
@@ -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,6 +80,9 @@ 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
|
||||
)
|
||||
@@ -65,9 +100,9 @@ class WorkPackage(Base):
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "sop_id": self.sop_id, "parent_id": self.parent_id,
|
||||
"number": self.number, "subject": self.subject, "type": self.type,
|
||||
"status": self.status, "issued_at": _iso(self.issued_at),
|
||||
"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