diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md index 0a38e13..860f3eb 100644 --- a/docs/reference/file-map.md +++ b/docs/reference/file-map.md @@ -281,6 +281,7 @@ python tests/hold_check.py # CR-015/A1/D4 - the hold clears, gates hol python tests/warning_check.py # A2 - one warning, a badge from anywhere 17 checks python tests/triage_check.py # A6 - the sidebar answers the stand-up 16 checks python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 40 checks +python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks ``` **Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live diff --git a/html/field.html b/html/field.html index 1a1bac7..9177376 100644 --- a/html/field.html +++ b/html/field.html @@ -64,6 +64,11 @@ .log-item img { max-width: 160px; max-height: 120px; margin-top: 6px; display: block; border: 1px solid var(--cds-border-subtle); } .fld-toast { position: fixed; bottom: 76px; left: 50%; transform: translateX(-50%); background: var(--cds-ui-05); color: var(--cds-text-on-color); padding: 12px 20px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 50; } .fld-toast.show { opacity: 1; } + /* CR-007: drawings open from the card, offline once prefetched. 44px rows. */ + .fld-drawing { display:block; padding:12px 10px; min-height:44px; box-sizing:border-box; + border:1px solid var(--cds-border-subtle-01); border-radius:6px; margin-bottom:8px; + color: var(--cds-link-primary); text-decoration:none; font-size:14px; } + .fld-drawing:active { background: var(--cds-layer-hover-01); } diff --git a/html/field.js b/html/field.js index d3203df..090edcf 100644 --- a/html/field.js +++ b/html/field.js @@ -65,9 +65,23 @@ function loadWPs() { ProjectData.pullProject(PID).then(function () { WPS = activePkgs(readCache()); if (!curId) renderList(); else renderDetail(); + prefetchMyDrawings(); }).catch(function () {}); } } + +// CR-007/D8: warm the drawing cache for MY packages while there is a network. +// Deliberately only the requesting user's assignments - the decision was that +// offline coverage follows assignment, not the whole project's 2GB. +function prefetchMyDrawings() { + var myId = (window.WP_USER || {}).id; + if (!myId || !('serviceWorker' in navigator)) return; + WPS.filter(function (p) { return p.assigneeId === myId; }).forEach(function (p) { + ((p.files) || []).forEach(function (f) { + if (f && f.id) fetch('/api/files/' + f.id).catch(function () {}); + }); + }); +} function showNoProject() { var s = document.getElementById('screen-list'); if (s) s.innerHTML = '
No project selected.
Pick a project on the home page, then reopen the field view.
'; @@ -132,6 +146,11 @@ function renderDetail() { '
' + esc(p.subject || '') + (p.type ? ' ยท ' + esc(p.type) : '') + '
' + '

Status

' + stBtns + '
' + '

Constraints โ€” ' + openCount(p) + ' open

' + cxRows + '
' + + (((p.files) || []).length ? '

Drawings

' + + p.files.map(function (f) { + return '' + + '๐Ÿ“„ ' + esc(f.name || 'drawing') + (f.description ? ' โ€” ' + esc(f.description) : '') + ''; + }).join('') + '
' : '') + '

Add field update

' + '' + '
' + diff --git a/html/sw.js b/html/sw.js index 2704232..6921cf2 100644 --- a/html/sw.js +++ b/html/sw.js @@ -15,6 +15,11 @@ // Bumped when the shell file list changes, so clients fetch the new assets // instead of serving a half-old shell from the previous cache. const CACHE = 'wp-suite-shell-v6'; +// CR-007/D8: uploaded drawings, cached at first fetch so an assigned package's +// sheets open with no network. The CLIENT decides what gets fetched (field.js +// prefetches only the requesting user's assigned packages); this worker just +// keeps whatever came through. Never precached - a fresh sign-in starts empty. +const DRAWINGS = 'wp-suite-drawings-v1'; const SHELL = [ '/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html', '/field.html', '/login.html', '/admin.html', '/users.html', @@ -43,7 +48,7 @@ self.addEventListener('install', (e) => { self.addEventListener('activate', (e) => { e.waitUntil( caches.keys() - .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE && k !== DRAWINGS).map((k) => caches.delete(k)))) .then(() => self.clients.claim()) ); }); @@ -65,6 +70,18 @@ self.addEventListener('fetch', (e) => { if (req.method !== 'GET') return; // outbox owns writes const url = new URL(req.url); if (url.origin !== self.location.origin) return; // third-party: default + // Drawing bytes are immutable once uploaded (edits replace the row id), so + // cache-first is safe and is what makes them open offline (CR-007/D8). + if (url.pathname.startsWith('/api/files/')) { + e.respondWith( + caches.open(DRAWINGS).then((c) => c.match(req).then((hit) => hit || + fetch(req).then((res) => { + if (res && res.ok) c.put(req, res.clone()); + return res; + }))) + ); + return; + } if (url.pathname.startsWith('/api/')) return; // never cache the API const isCode = CODE_RE.test(url.pathname); diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index 7c21d46..f8d2482 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -66,6 +66,7 @@ let SOP=null, editingId=null, numberDirty=false; let pkgKind='iwp'; // 'iwp' (install) | 'ewp' (BIM) โ€” per-package, only relevant when SOP.bimEnabled let activeProjectId=''; // set at boot from ?project=; stamped onto saved WPs for the API let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[], pkgQaRejections=[]; +let pkgFiles=[]; // CR-007: uploaded drawing METAS - bytes live on the server, data['files'] is server-owned let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3) let pkgDisciplines=[]; // disciplines this WP covers (from SOP governance.disciplines) @@ -868,6 +869,116 @@ function buildAttach(){ tb.appendChild(tr); }); } function addAttach(){ pkgAttach.push({doc:'',rev:'',link:''}); buildAttach(); } + +// โ”€โ”€ CR-007 / D8: drawing uploads โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Files attach to the SAVED record (they are rows, not form state), so the form +// around an upload is never at risk: a refused or failed upload changes nothing +// but the toast. Both limits are ALSO enforced by the server - these checks are +// the courtesy of refusing before the bytes travel. +function wpFileSize(n){ + if(n>=1024*1024) return (n/1024/1024).toFixed(1)+'MB'; + if(n>=1024) return Math.round(n/1024)+'KB'; + return n+'B'; +} +async function wpFileUpload(ev){ + const inp=ev.target; const f=inp.files&&inp.files[0]; inp.value=''; + if(!f) return; + if(!/^(application\/pdf|image\/)/.test(f.type||'')){ + toast('Only PDF and image files are accepted โ€” "'+(f.type||f.name.split('.').pop()||'that')+'" is neither.'); + return; + } + if(f.size>5*1024*1024){ + toast('Files are limited to 5MB each โ€” this one is '+wpFileSize(f.size)+'.'); + return; + } + if(!editingId){ + toast('Save the package first โ€” uploads attach to the saved record.'); + return; + } + const desc=(document.getElementById('wp-file-desc')||{value:''}).value.trim(); + let b64=''; + try{ + b64=await new Promise((res,rej)=>{ const r=new FileReader(); + r.onload=()=>res(String(r.result).split(',')[1]||''); r.onerror=rej; r.readAsDataURL(f); }); + }catch(e){ toast('Could not read that file โ€” the form is untouched.'); return; } + let resp, out={}; + try{ + resp=await fetch('/api/wps/'+encodeURIComponent(editingId)+'/files',{ + method:'POST', headers:{'Content-Type':'application/json'}, + body:JSON.stringify({name:f.name, mime:f.type, description:desc, data_base64:b64})}); + out=await resp.json().catch(()=>({})); + }catch(e){ + toast('Upload failed โ€” the network dropped. Nothing else was lost; try again.'); + return; + } + if(!resp.ok){ + const d=out&&out.detail; + const msg=(d&&d.message)||(typeof d==='string'?d:'Upload failed ('+resp.status+')'); + toast(msg); + return; + } + pkgFiles.push(out.file); + wpFilesMirror(); + const de=document.getElementById('wp-file-desc'); if(de) de.value=''; + wpFilesRender(); wpFileUsageSet(out.used, out.ceiling); + toast('Uploaded '+f.name+'.'); track('file_uploaded'); +} +// Keep the local saved-package copy in step so exports and the offline cache +// see the list without another fetch. The server copy is authoritative. +function wpFilesMirror(){ + const ix=savedPackages.findIndex(x=>x.id===editingId); + if(ix>=0){ savedPackages[ix].files=pkgFiles.map(x=>({...x})); saveStore(); } +} +function wpFilesRender(){ + const box=document.getElementById('wp-file-list'); if(!box) return; + box.innerHTML=pkgFiles.map(f=>``).join(''); +} +async function wpFileDescSave(id, v){ + try{ + const r=await fetch('/api/files/'+encodeURIComponent(id),{method:'PATCH', + headers:{'Content-Type':'application/json'}, body:JSON.stringify({description:v})}); + if(!r.ok) throw 0; + const f=pkgFiles.find(x=>x.id===id); if(f) f.description=v; + wpFilesMirror(); + }catch(e){ toast('Could not save the description โ€” it is shown but not saved yet.'); } +} +async function wpFileDelete(id){ + if(!confirm('Remove this drawing from the package?')) return; + try{ + const r=await fetch('/api/files/'+encodeURIComponent(id),{method:'DELETE'}); + if(!r.ok) throw 0; + const out=await r.json(); + pkgFiles=pkgFiles.filter(f=>f.id!==id); + wpFilesMirror(); wpFilesRender(); wpFileUsageSet(out.used, out.ceiling); + toast('Drawing removed.'); + }catch(e){ toast('Could not remove the drawing โ€” try again.'); } +} +// The running total, on the same line as the rules (D8: warn from 80%, refuse at +// the ceiling - the refusal itself comes from the server and names the number). +function wpFileUsageSet(used, ceiling){ + const el=document.getElementById('file-usage'); if(el==null) return; + if(typeof used!=='number' || !ceiling){ el.textContent=''; return; } + const pct=used/ceiling; + const txt=`Project storage: ${wpFileSize(used)} of ${wpFileSize(ceiling)} used.`; + el.innerHTML = pct>=1 ? `${txt} Full โ€” remove a drawing to make room.` + : pct>=0.8 ? `${txt} Approaching the ceiling.` + : esc(txt); +} +async function wpFilesRefreshUsage(){ + if(!activeProjectId) return; + try{ + const r=await fetch('/api/projects/'+encodeURIComponent(activeProjectId)+'/storage'); + if(!r.ok) return; + const out=await r.json(); + wpFileUsageSet(out.used, out.ceiling); + }catch(e){} +} function removeAttach(i){ pkgAttach.splice(i,1); if(!pkgAttach.length)pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); } // โ”€โ”€ ADD FILES FROM SOP FOLDER (no-auth interim) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -1292,6 +1403,7 @@ function collectPackage(){ signoffs:pkgSignoffs.map(s=>({role:s.role,name:s.name,date:s.date,signed:s.signed,dateReason:s.dateReason||''})), holds:pkgHolds.map(h=>({...h})), qaRejections:pkgQaRejections.map(x=>({...x})), + files:pkgFiles.map(x=>({...x})), // metas only; the server re-asserts this key on save actualHrs:gv('wp_actual_hrs'), installedQty:gv('wp_installed_qty'), redlines:gv('wp_redlines'), lessons:gv('wp_lessons'), kind: pkgKind, // 'iwp' (install) | 'ewp' (BIM) โ€” drives which fields/types/gates apply // AWP traceability: which BIM/model package(s) enabled this install package. @@ -1423,10 +1535,18 @@ function renderPackage(pkg){ pkg.materials.forEach(m=>t+=`${cell(m.qty)}${cell(m.unit)}${cell(m.desc)}${showDisc?`${cell(m.discipline)}`:''}`); add('materials', 'Material List', t+``); } - if(pkg.attachments&&pkg.attachments.length){ - let t=``; - pkg.attachments.forEach(a=>t+=``); - add('drawings', 'Drawings & Attachments', t+`
DocumentRevLink / Note
${cell(a.doc)}${cell(a.rev)}${a.link?linkify(a.link):ns()}
`); } + if((pkg.attachments&&pkg.attachments.length)||(pkg.files&&pkg.files.length)){ + let t=``; + (pkg.attachments||[]).forEach(a=>t+=``); + // CR-007: uploaded drawings print WITH the package - the row for every file, + // the image itself inline (it is the sheet the crew needs), PDFs as links. + (pkg.files||[]).forEach(f=>t+=``); + t+=`
DocumentRevLink / Note / Focus area
${cell(a.doc)}${cell(a.rev)}${a.link?linkify(a.link):ns()}
${esc(f.name||'drawing')} [uploaded, ${wpFileSize(f.size||0)}]${ns()}${cell(f.description)}
`; + (pkg.files||[]).filter(f=>/^image\//.test(f.mime||'')).forEach(f=>{ + t+=`
${(f.name||'').replace(/` + +(f.description?`
${esc(f.description)}
`:'')+`
`; + }); + add('drawings', 'Drawings & Attachments', t); } add('kitting', 'Kitting & MIMO', ` @@ -2435,6 +2555,7 @@ function loadPackageIntoForm(p){ pkgSignoffs=(p.signoffs||[]).map(s=>({...s, fromSOP:!!s.name})); if(!pkgSignoffs.length) buildSignoffs(); else renderSignoffRows(); pkgHolds=(p.holds||[]).map(h=>({...h})); pkgQaRejections=(p.qaRejections||[]).map(x=>({...x})); + pkgFiles=(p.files||[]).map(x=>({...x})); wpFilesRender(); wpFilesRefreshUsage(); updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm(); wpFormPopulated(); } function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; buildConstraints(); @@ -2497,7 +2618,7 @@ function newPackage(){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); pkgWorkSteps=['']; buildWorkSteps(); pkgConstraints=[]; buildConstraints(); pkgSignoffs=[]; buildSignoffs(); - pkgHolds=[]; pkgQaRejections=[]; pkgOverrides={}; + pkgHolds=[]; pkgQaRejections=[]; pkgFiles=[]; wpFilesRender(); pkgOverrides={}; set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold'); prevStatus='Draft'; updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); wpFormPopulated(); renderWpNav(); track('new_package'); @@ -3269,6 +3390,7 @@ function bootData(){ setRadio('status','Draft'); loadMembers(); loadLocations(); // CR-004: the option lists come from the server + wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload initWpNavDrawer(); renderSavedList(); positionSectionNav(); diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html index b935fe6..b529fe4 100644 --- a/html/wp-creation-index.html +++ b/html/wp-creation-index.html @@ -355,6 +355,20 @@
Drawings & Attachments
Kitting Status${cell(pkg.kitStatus)}
Document / DrawingRevLink / Note
+ +
+ Uploads: PDF or image, up to 5MB a file. + +
+
+
+ + +