T7.7 - CR-007/D8: the sheet travels with the package, and opens offline

The field wants the specific PDF attached, not a link to a Bluebeam session.

Storage: a new wp_files table (Alembic f3a9d2c1e8b7, additive only) holding
the BYTES in the same database as everything else - the Aug 18 decision: a
backup that excludes the drawings is a backup you cannot restore from. The
D8 numbers bound the cost and are enforced ON THE SERVER as well as in the
browser: 5MB a file (413, naming the limit), PDF and image mimes only (400,
naming what is accepted), 2GB a project (413 naming the ceiling; response
flags the 80% warning). The ceiling is env-overridable for tests; the shipped
default is the decision, asserted from source.

The package record carries a server-owned meta mirror (data.files): the
upload/patch/delete routes rewrite it, and the upsert re-asserts the stored
copy over whatever a client sends - a save from a browser that had not seen
an upload land cannot erase the list.

Creator: uploads live beside the links (links still work), the limits and the
running project total sit ABOVE the picker (amber from 80%, red at full), a
refused file costs nothing but a toast and never leaves the browser (the
probe counts fetch calls), and each drawing has a description ("Tray section,
Level 3 east only") editable inline and persisted server-side. Uploads attach
to the saved record, so T4.3's autosave keeps the surrounding form safe (X8).

Export: uploads print with the package - name, size tag, description on the
attachments table, images inline as the sheet itself, PDFs as links.

Offline (D8): the service worker gains a drawings cache (cache-first on
/api/files/), and field.js prefetches ONLY the requesting user's assigned
packages - assignment-scoped by decision, not project-wide. The probe's first
offline check used CDP network emulation and PASSED FOR THE WRONG REASON: the
emulation binds to the page's session and the service worker fetches on its
own target, straight past it. The shipped check kills the server instead -
my drawing opens, the other package's does not, against a genuinely dead
network.

Field View: a Drawings section on the package detail, 44px rows, description
inline, inside the 390px screen.

Verification (each probe run alone): NEW tests/files_check.py 36/36; the
Alembic chain applied end-to-end to a scratch DB and the table verified.
Regressions: form_structure_check 50/51 (the standing F6 height gap),
frame_check 39/39.

Items: CR-007, D8 (X8 honored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 11:09:35 -07:00
parent 2486f87010
commit c084f730b3
11 changed files with 774 additions and 7 deletions

View File

@@ -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=<id>; 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=>`<div class="wp-file-item">
<a href="/api/files/${encodeURIComponent(f.id)}" target="_blank" rel="noopener">${f.mime==='application/pdf'?'📄':'🖼'} ${esc(f.name||'drawing')}</a>
<span class="wf-size">${wpFileSize(f.size||0)}</span>
<input type="text" value="${(f.description||'').replace(/"/g,'&quot;')}" placeholder="focus area, e.g. Tray section, Level 3 east only"
aria-label="Description of ${esc(f.name||'drawing')}" onchange="wpFileDescSave('${f.id}', this.value)">
<button type="button" class="btn btn-ghost wp-file-x" onclick="wpFileDelete('${f.id}')">Remove</button>
</div>`).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 ? `<span class="fr-full">${txt} Full — remove a drawing to make room.</span>`
: pct>=0.8 ? `<span class="fr-warn">${txt} Approaching the ceiling.</span>`
: 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+=`<tr><td>${cell(m.qty)}</td><td>${cell(m.unit)}</td><td>${cell(m.desc)}</td>${showDisc?`<td>${cell(m.discipline)}</td>`:''}</tr>`);
add('materials', 'Material List', t+`</tbody></table>`); }
if(pkg.attachments&&pkg.attachments.length){
let t=`<table><thead><tr><th>Document</th><th style="width:60px">Rev</th><th>Link / Note</th></tr></thead><tbody>`;
pkg.attachments.forEach(a=>t+=`<tr><td>${cell(a.doc)}</td><td>${cell(a.rev)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`);
add('drawings', 'Drawings & Attachments', t+`</tbody></table>`); }
if((pkg.attachments&&pkg.attachments.length)||(pkg.files&&pkg.files.length)){
let t=`<table><thead><tr><th>Document</th><th style="width:60px">Rev</th><th>Link / Note / Focus area</th></tr></thead><tbody>`;
(pkg.attachments||[]).forEach(a=>t+=`<tr><td>${cell(a.doc)}</td><td>${cell(a.rev)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`);
// 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+=`<tr><td><a href="/api/files/${encodeURIComponent(f.id)}" target="_blank" rel="noopener">${esc(f.name||'drawing')}</a> <span style="font-size:10px;color:var(--text-dim)">[uploaded, ${wpFileSize(f.size||0)}]</span></td><td>${ns()}</td><td>${cell(f.description)}</td></tr>`);
t+=`</tbody></table>`;
(pkg.files||[]).filter(f=>/^image\//.test(f.mime||'')).forEach(f=>{
t+=`<figure style="margin:10px 0"><img src="/api/files/${encodeURIComponent(f.id)}" alt="${(f.name||'').replace(/"/g,'&quot;')}" style="max-width:100%">`
+(f.description?`<figcaption style="font-size:11px;color:var(--text-muted)">${esc(f.description)}</figcaption>`:'')+`</figure>`;
});
add('drawings', 'Drawings & Attachments', t); }
add('kitting', 'Kitting & MIMO', `<table><tbody>
<tr><th style="width:200px">Kitting Status</th><td>${cell(pkg.kitStatus)}</td></tr>
@@ -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();