diff --git a/html/index.html b/html/index.html index 5dcc5c7..51fefd6 100644 --- a/html/index.html +++ b/html/index.html @@ -595,7 +595,10 @@ if(info) info.innerHTML = `
✓ Active project: ${esc(active.name||'')}${active.number?' ('+esc(active.number)+')':''}  
`; - reflectSOPStatus(active); + // Pull the project's shared SOP from the server into the local cache first, + // so the SOP "Complete / Review" status reflects what other users have done. + if(ProjectData.pullProject){ ProjectData.pullProject(active.id).then(()=>reflectSOPStatus(active)).catch(()=>reflectSOPStatus(active)); } + else reflectSOPStatus(active); } function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); } @@ -704,22 +707,36 @@ r.readAsText(f); } - function loadComments() { - const saved = localStorage.getItem('wp_suite_index_comments'); - if (saved) allComments = JSON.parse(saved); - + function renderComments() { const list = document.getElementById('comments-list'); if (allComments.length === 0) { list.innerHTML = '
No feedback yet. Be the first to share!
'; } else { list.innerHTML = allComments.map(c => `
-
${c.name} • ${c.timestamp}
-
${c.text.replace(//g,'>')}
+
${(c.name||'Anonymous').replace(/ • ${c.timestamp||''}
+
${(c.text||'').replace(//g,'>')}
`).join(''); } } + + function loadComments() { + // Server is authoritative (so feedback is shared across users); fall back to + // the local cache if the API is unreachable. + const saved = localStorage.getItem('wp_suite_index_comments'); + if (saved) { try { allComments = JSON.parse(saved) || []; } catch(e) { allComments = []; } } + renderComments(); + fetch('/api/comments?source=home_feedback', { headers: { 'Accept': 'application/json' } }) + .then(r => r.ok ? r.json() : null) + .then(rows => { + if (Array.isArray(rows)) { + allComments = rows.map(c => ({ name: c.author, text: c.text, timestamp: c.created_at ? new Date(c.created_at).toLocaleString() : '' })); + renderComments(); + } + }) + .catch(() => {}); + } diff --git a/html/project-data.js b/html/project-data.js index b6090b8..5ebe2f3 100644 --- a/html/project-data.js +++ b/html/project-data.js @@ -81,6 +81,107 @@ key: function (base) { var id = this.getActiveId(); return id ? base + '__' + id : base; } }; + // ── Server sync for SOPs and Work Packages ───────────────────────────────── + // SOPs and WPs are authoritative on the server (so every user of a project sees + // the same data). To avoid rewriting the two apps, we keep their existing + // localStorage keys as a per-browser CACHE: pullProject() hydrates those exact + // keys from the API on page load, and the push* helpers write through to the + // API whenever the apps save. The apps' own (synchronous) reads are unchanged. + function nsKey(base, id) { return id ? base + '__' + id : base; } + function currentUser() { + try { return (window.WP_USER && (window.WP_USER.username || window.WP_USER.full_name)) || ''; } catch (e) { return ''; } + } + + // A saved Work Package is a flat object in the browser; the API splits it into + // promoted columns + a `data` blob. We store the whole flat object in `data` + // for perfect round-tripping, and mirror the few fields the API promotes. + function pkgToServer(p, projectId) { + return { + id: p.id, + project_id: p.projectId || projectId || null, + parent_id: p.instanceOf || null, + number: p.number || '', + subject: p.subject || '', + type: p.type || '', + status: p.status || 'Draft', + created_by: p.createdBy || currentUser(), + data: p + }; + } + function serverToPkg(row) { + var p = Object.assign({}, row.data || {}); // full flat object lives in data + p.id = row.id; + p.projectId = row.project_id || p.projectId || ''; + if (row.number) p.number = row.number; + if (row.subject != null) p.subject = row.subject; + if (row.type != null) p.type = row.type; + if (row.status) p.status = row.status; // honor server-side status changes + if (row.parent_id) p.instanceOf = row.parent_id; + return p; + } + + // Pull this project's SOP + WPs from the API into the localStorage keys the + // apps read. Resolves even on failure (offline / no API) so boot continues. + ProjectData.pullProject = function (projectId) { + if (!projectId) return Promise.resolve(); + var jobs = []; + jobs.push( + fetch(API + '/sops/latest?complete=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (sopRow) { + if (sopRow && sopRow.data) { + var d = sopRow.data; // { sop, state } as written by pushSOP + if (d.sop) localStorage.setItem(nsKey('wp_suite_sop', projectId), JSON.stringify(d.sop)); + if (d.state) localStorage.setItem(nsKey('wp_suite_state', projectId), JSON.stringify(d.state)); + localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1'); + } + }).catch(function () {}) + ); + jobs.push( + fetch(API + '/wps?full=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (rows) { + if (Array.isArray(rows)) { + localStorage.setItem(nsKey('wp_iwp_v1', projectId), JSON.stringify(rows.map(serverToPkg))); + } + }).catch(function () {}) + ); + return Promise.all(jobs).then(function () {}); + }; + + // Write a completed SOP (plus the builder's raw state) to the API. Uses a + // deterministic id per project so re-completing updates the same row. + ProjectData.pushSOP = function (projectId, sop, state) { + if (!projectId) return Promise.resolve(null); + var body = { + id: 'sop__' + projectId, + project_id: projectId, + name: (sop && sop.project && sop.project.name) || 'SOP', + number: (sop && sop.project && sop.project.number) || '', + complete: true, + created_by: currentUser(), + data: { sop: sop, state: state } + }; + return fetch(API + '/sops', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) + }).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); + }; + + // Upsert a single Work Package to the API (fire-and-forget from the caller's + // perspective; the local cache is the source of truth for immediate rendering). + ProjectData.pushWP = function (p, projectId) { + if (!p || !p.id) return Promise.resolve(null); + return fetch(API + '/wps', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pkgToServer(p, projectId)) + }).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); + }; + + ProjectData.removeWP = function (id) { + if (!id) return Promise.resolve(); + return fetch(API + '/wps/' + encodeURIComponent(id), { method: 'DELETE' }) + .then(function () {}).catch(function () {}); + }; + // 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 { diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js index 205ea22..431836c 100644 --- a/html/work-package-suite-app.js +++ b/html/work-package-suite-app.js @@ -148,15 +148,25 @@ window.addEventListener('DOMContentLoaded',()=>{ // 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); + const projId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || ''; applyProjectContext(params.get('project')); - restoreSavedSOP(); - updateStepUI(); - updateProjectDisplay(); - // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page. - const tab = params.get('tab'); - if(params.get('view') === 'dashboard') switchTool('dashboard'); - else if(tab === 'wp' || tab === 'sop') switchTool(tab); + // Pull the project's shared SOP from the server into the local cache, THEN + // restore it. Falls back to the local cache if offline. + function afterPull(){ + restoreSavedSOP(); + updateStepUI(); + updateProjectDisplay(); + // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page. + const tab = params.get('tab'); + if(params.get('view') === 'dashboard') switchTool('dashboard'); + else if(tab === 'wp' || tab === 'sop') switchTool(tab); + } + if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){ + ProjectData.pullProject(projId).then(afterPull).catch(afterPull); + } else { + afterPull(); + } track('app_open'); @@ -842,6 +852,12 @@ function completeSOP(){ localStorage.setItem(SK('wp_suite_sop_complete'), '1'); } catch(e){} + // Share the SOP to the server so every user of this project gets it. + try { + const pid = (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || sop.projectId || ''; + if(pid && ProjectData.pushSOP) ProjectData.pushSOP(pid, sop, state); + } catch(e){} + track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length}); // Hand the SOP to the embedded Work Package Creator and unlock its tab (in case diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index 6367548..1c0cf92 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -687,6 +687,7 @@ function savePackage(view){ const ix=savedPackages.findIndex(p=>p.id===pkg.id); if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg); editingId=pkg.id; saveStore(); renderSavedList(); track('package_saved',{status:pkg.status}); + if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(pkg, activeProjectId); // share to server document.getElementById('loadingOverlay').classList.add('active'); setTimeout(()=>{ document.getElementById('loadingOverlay').classList.remove('active'); if(view) renderPackage(pkg); }, 400); } @@ -846,8 +847,8 @@ function renderSavedList(){ }).join(''); } function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); } -function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); } -function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages on this device?')) return; savedPackages=[]; saveStore(); renderSavedList(); } +function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); } +function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); } function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); } function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); } function loadPackageIntoForm(p){ @@ -916,6 +917,7 @@ function duplicateWP(){ savedPackages.push(c); made.push(c); } editingId=null; saveStore(); renderSavedList(); + if(typeof ProjectData!=='undefined' && ProjectData.pushWP) made.forEach(m=>ProjectData.pushWP(m, activeProjectId)); // share to server 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.'); @@ -951,12 +953,14 @@ function exportPackages(){ // 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} + list(){ return savedPackages.slice(); }, // hydrated from GET /api/wps on boot + get(id){ return savedPackages.find(p=>p.id===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 + p.status='Issued'; p.issuedAt=new Date().toISOString(); p.updatedAt=p.issuedAt; saveStore(); + if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; }, 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 + p.status=status; p.updatedAt=new Date().toISOString(); saveStore(); + if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; }, }; let dashFilter={status:'',discipline:'',q:'',flag:''}; @@ -1144,10 +1148,10 @@ document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventList ProjectData.setActive(cached && cached.id===activeProjectId ? cached : { id: activeProjectId }); } })(); -loadStore(); -(function bootSOP(){ +function bootSOP(){ // When embedded in the Suite, hide the SOP import/sample controls (SOP is injected) - // and prefer the SOP the Suite just completed (persisted to localStorage). + // and prefer the SOP the Suite just completed (persisted to localStorage, which + // we've already hydrated from the server for this project). const params = new URLSearchParams(location.search); if(params.get('embedded')) document.body.classList.add('embedded'); try { @@ -1162,11 +1166,22 @@ loadStore(); // 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(); -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(); } })(); +} +function bootData(){ + loadStore(); // reads the localStorage cache (hydrated from the server below) + bootSOP(); + setRadio('status','Draft'); + renderSavedList(); + cmtInit(); + // Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard). + const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); } + track('app_open'); +} window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); }); -track('app_open'); +// Pull this project's shared SOP + Work Packages from the server first, then boot +// off the refreshed cache. Falls back to whatever is cached locally if offline. +if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.pullProject){ + ProjectData.pullProject(activeProjectId).then(bootData).catch(bootData); +} else { + bootData(); +} diff --git a/server/app.py b/server/app.py index eeb3753..666469d 100644 --- a/server/app.py +++ b/server/app.py @@ -407,13 +407,15 @@ def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user), @app.get("/api/sops") -def list_sops(project_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): +def list_sops(project_id: Optional[str] = Query(None), full: bool = Query(False), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): stmt = select(models.Sop) if project_id: stmt = stmt.where(models.Sop.project_id == project_id) stmt = scope_to_access(stmt, models.Sop.project_id, db, user) rows = db.scalars(stmt.order_by(models.Sop.updated_at.desc())).all() - return [s.summary() for s in rows] + # full=true includes the data JSON (the whole SOP document) for hydration; + # the default summary view stays lean for listing. + return [(s.to_dict() if full else s.summary()) for s in rows] @app.get("/api/sops/latest") @@ -480,6 +482,7 @@ def list_wps( sop_id: Optional[str] = Query(None), parent_id: Optional[str] = Query(None), status: Optional[str] = Query(None), + full: bool = Query(False), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db), ): @@ -494,7 +497,9 @@ def list_wps( stmt = stmt.where(models.WorkPackage.status == status) stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user) rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all() - return [w.summary() for w in rows] + # full=true includes the data JSON (full package document) so the creator can + # rehydrate everything in one request; default stays lean for listing. + return [(w.to_dict() if full else w.summary()) for w in rows] @app.get("/api/wps/metrics")