T8.5 - CR-013/D6: the material request is structure, not features

The OneNote comparison from the meeting was "word vomit"; the structure that
replaces it, built at the lightweight scope EXACTLY as approved Aug 14:

- Line items (qty, unit, description) added, edited, removed. Descriptions
  offer the D6 project list through a datalist - which is also precisely what
  keeps free text working when no list is loaded, the state every project is
  in today. Picking a listed material fills its unit; nothing locks.
- Needed-by date, requestor (the signed-in account), delivery location (T8.4's
  fields on this package, composed), and an explicit status set
  (Requested / Filled / Declined). The request rides on the package record
  (data.materialRequests) - server-persisted through the same upsert as
  everything else, never localStorage.
- Submitting notifies the warehouse owner named on the package (CR-010) - the
  routing that replaces the funnel through one person - through the T7.6 gate,
  with the count, the needed-by, the delivery location and the deep link, in
  the house convention. material_requested lands in the audit history.
- The dashboard grows a Material requests queue, filterable by status and by
  delivery location.
- The block lives inside #material-card, so the CR-006 materials toggle
  governs it with no special casing. The whole flow is driven at 390px -
  requests originate in the field.
- NO parts catalog, no inventory count, no warehouse integration - the probe
  greps the block for them.

One infrastructure bug fixed in passing detection (not silently): T8.5's
dashboard-panel insert matched the substring inside "async function
dashIssue", splitting the async keyword from its function - the creator
failed to parse and every boot died. Caught by the probe's first run;
anchored fixes now restore both halves.

Verification (each probe run alone): NEW tests/mreq_check.py 19/19 (request
end-to-end at 390px against the SMTP sink, dashboard filters, fences).
Regressions: frame_check 39/39, sections_check 95/95, kitting_check 26/26.

Items: CR-013, D6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 12:33:34 -07:00
parent 190144c539
commit 898e5dab94
6 changed files with 429 additions and 0 deletions

View File

@@ -68,6 +68,120 @@ const KIT_STATUSES = ['Not Started', 'Picking', 'Staged', 'In Transit', 'Deliver
// the project) is KEPT as a selected "(no account)" option - existing packages
// must never break because the roster changed.
let pkgKitOwnerId = '';
// ── CR-013 / T8.5: material requests - the lightweight scope, exactly ─────────
// Line items, needed-by, requestor, delivery (T8.4's fields), status. Items
// pick from the project material list (D6) through a datalist - which is also
// what keeps free text working when no list has been loaded, the state every
// project is in today. The request rides on the package (data.materialRequests),
// so it is server-persisted through the same upsert as everything else, and the
// server notifies the warehouse owner (CR-010) when one is added.
const MREQ_STATUSES = ['Requested', 'Filled', 'Declined'];
let pkgMatRequests = [];
let _mreqDraft = [];
let _projMaterials = null; // the D6 list; null until fetched, [] when none
async function mreqLoadMaterials(){
if(!activeProjectId){ _projMaterials = []; mreqFillDatalist(); return; }
try{
const r = await fetch('/api/projects/' + encodeURIComponent(activeProjectId) + '/materials');
_projMaterials = r.ok ? ((await r.json()).items || []) : [];
}catch(e){ _projMaterials = []; }
mreqFillDatalist();
}
function mreqFillDatalist(){
const dl = document.getElementById('mat-datalist');
if(!dl) return;
dl.innerHTML = (_projMaterials || []).map(m =>
`<option value="${(m.description||'').replace(/"/g,'&quot;')}">${esc(m.unit||'')}</option>`).join('');
const hint = document.getElementById('mreq-hint');
if(hint) hint.textContent = (_projMaterials && _projMaterials.length)
? 'Descriptions offer the project material list (D6); anything else can be typed.'
: 'No project material list loaded - describe what you need in free text.';
}
function mreqAddItem(){
_mreqDraft.push({qty:'', unit:'', desc:''});
mreqRenderDraft();
}
function mreqRenderDraft(){
const tb = document.getElementById('mreq-items');
if(!tb) return;
tb.innerHTML = '';
_mreqDraft.forEach((it, i) => {
const tr = document.createElement('tr');
tr.innerHTML = `<td><input type="text" inputmode="decimal" value="${(it.qty||'').replace(/"/g,'&quot;')}" aria-label="Quantity, line ${i+1}" oninput="_mreqDraft[${i}].qty=this.value"></td>
<td><input type="text" value="${(it.unit||'').replace(/"/g,'&quot;')}" aria-label="Unit, line ${i+1}" oninput="_mreqDraft[${i}].unit=this.value"></td>
<td><input type="text" list="mat-datalist" value="${(it.desc||'').replace(/"/g,'&quot;')}" aria-label="Description, line ${i+1}"
oninput="_mreqDraft[${i}].desc=this.value" onchange="mreqMaybeUnit(${i}, this.value)"></td>
<td><button type="button" class="btn btn-ghost" aria-label="Remove line ${i+1}" onclick="_mreqDraft.splice(${i},1);mreqRenderDraft()">✕</button></td>`;
tb.appendChild(tr);
});
}
// Picking a listed material fills its unit - a convenience, never a lock.
function mreqMaybeUnit(i, desc){
const hit = (_projMaterials || []).find(m => m.description === desc);
if(hit && _mreqDraft[i] && !_mreqDraft[i].unit){ _mreqDraft[i].unit = hit.unit || ''; mreqRenderDraft(); }
}
function mreqSubmit(){
const err = document.getElementById('mreq-err');
const items = _mreqDraft.map(it => ({qty:(it.qty||'').trim(), unit:(it.unit||'').trim().toUpperCase(), desc:(it.desc||'').trim()}))
.filter(it => it.desc || it.qty);
if(!items.length){
if(err) err.textContent = 'Add at least one line - what material, and how much.';
return;
}
const bad = items.find(it => !it.desc);
if(bad){
if(err) err.textContent = 'Every line needs a description.';
return;
}
if(err) err.textContent = '';
const needed = (document.getElementById('mreq-needed')||{value:''}).value;
const deliv = {
delivBuilding: gv('wp_deliv_building'), delivFloor: gv('wp_deliv_floor'),
delivSector: gv('wp_deliv_sector'), delivDetail: gv('wp_deliv_detail'),
};
pkgMatRequests.push({
id: 'mreq_' + Date.now().toString(36) + Math.random().toString(36).slice(2,6),
ts: new Date().toISOString(),
requestor: (window.WP_USER && (WP_USER.full_name || WP_USER.username)) || '',
neededBy: needed, status: 'Requested', items,
deliveryLoc: deliveryLocOf(deliv),
});
_mreqDraft = [];
const nd = document.getElementById('mreq-needed'); if(nd) nd.value = '';
mreqRenderDraft(); mreqRenderList();
// The request is data on the package: saving is what persists it and what
// makes the server notify the warehouse owner. Do it now rather than leaving
// a submitted-looking request sitting unsaved in a form.
if(editingId){ savePackage(false); }
else toast('Request added - save the package to send it to the warehouse owner.', 'alert');
track('material_requested', {lines: items.length});
}
function mreqSetStatus(id, status){
const r = pkgMatRequests.find(x => x.id === id);
if(!r || MREQ_STATUSES.indexOf(status) < 0) return;
r.status = status;
mreqRenderList();
if(editingId) savePackage(false);
}
function mreqRenderList(){
const box = document.getElementById('mreq-list');
if(!box) return;
if(!pkgMatRequests.length){ box.innerHTML = ''; return; }
box.innerHTML = pkgMatRequests.map(r => `<div class="mreq-item">
<div class="mr-head">
<span class="mr-status">${esc(r.status||'Requested')}</span>
<span>${esc(r.requestor||'')}</span>
${r.neededBy?`<span>needed ${esc(r.neededBy)}</span>`:''}
${r.deliveryLoc?`<span>→ ${esc(r.deliveryLoc)}</span>`:''}
<select aria-label="Status of this request" onchange="mreqSetStatus('${r.id}', this.value)">
${MREQ_STATUSES.map(st=>`<option${st===(r.status||'Requested')?' selected':''}>${esc(st)}</option>`).join('')}
</select>
</div>
<div class="mr-lines">${(r.items||[]).map(it=>esc([it.qty, it.unit, it.desc].filter(Boolean).join(' '))).join(' · ')}</div>
</div>`).join('');
}
function buildKitOwnerPicker(){
const sel=document.getElementById('wp_kit_owner_sel'); if(!sel) return;
const curId=pkgKitOwnerId||'';
@@ -1613,6 +1727,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})),
materialRequests:pkgMatRequests.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
@@ -2808,6 +2923,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}));
pkgMatRequests=(p.materialRequests||[]).map(x=>({...x})); mreqRenderList();
pkgFiles=(p.files||[]).map(x=>({...x})); wpFilesRender(); wpFilesRefreshUsage();
updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm(); wpFormPopulated();
}
@@ -2876,6 +2992,7 @@ function newPackage(){
pkgWorkSteps=['']; buildWorkSteps();
pkgConstraints=[]; buildConstraints(); pkgSignoffs=[]; buildSignoffs();
pkgHolds=[]; pkgQaRejections=[]; pkgFiles=[]; wpFilesRender(); pkgOverrides={};
pkgMatRequests=[]; _mreqDraft=[]; mreqRenderDraft(); mreqRenderList();
set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
prevStatus='Draft';
updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); wpFormPopulated(); renderWpNav(); track('new_package');
@@ -3397,8 +3514,43 @@ function renderDashboard(){
<button class="btn btn-ghost" ${dashPage>=pages-1?'disabled':''} onclick="dashGo(${dashPage+1})">Next </button></span></div>`;
}
h+=`</div>`;
h+=renderMreqPanel(all);
document.getElementById('dash-body').innerHTML=h;
}
// CR-013: every request across the project, on the board, filterable by
// status and by delivery location - the funnel-through-one-person replaced by
// a queue anyone can read.
let mreqDashFilter = {status:'', loc:''};
function renderMreqPanel(all){
const reqs = [];
all.forEach(p => (p.materialRequests||[]).forEach(r => reqs.push({p, r})));
if(!reqs.length) return '';
const locs = [...new Set(reqs.map(x => x.r.deliveryLoc || '').filter(Boolean))].sort();
const rows = reqs.filter(x =>
(!mreqDashFilter.status || (x.r.status||'Requested') === mreqDashFilter.status)
&& (!mreqDashFilter.loc || (x.r.deliveryLoc||'') === mreqDashFilter.loc));
let h = `<div class="dash-panel"><div class="dash-panel-title">Material requests</div>
<div class="dash-filters" style="margin:6px 0">
<select aria-label="Filter requests by status" onchange="mreqDashFilter.status=this.value;renderDashboard()">
<option value="">All statuses</option>
${MREQ_STATUSES.map(st=>`<option${mreqDashFilter.status===st?' selected':''}>${esc(st)}</option>`).join('')}
</select>
<select aria-label="Filter requests by delivery location" onchange="mreqDashFilter.loc=this.value;renderDashboard()">
<option value="">All delivery locations</option>
${locs.map(l=>`<option${mreqDashFilter.loc===l?' selected':''}>${esc(l)}</option>`).join('')}
</select>
</div>`;
h += rows.length ? rows.map(x => `<div class="mreq-item">
<div class="mr-head"><span class="mr-status">${esc(x.r.status||'Requested')}</span>
<b>${esc(x.p.number||'')}</b> <span>${esc(x.r.requestor||'')}</span>
${x.r.neededBy?`<span>needed ${esc(x.r.neededBy)}</span>`:''}
${x.r.deliveryLoc?`<span>→ ${esc(x.r.deliveryLoc)}</span>`:''}</div>
<div class="mr-lines">${(x.r.items||[]).map(it=>esc([it.qty,it.unit,it.desc].filter(Boolean).join(' '))).join(' · ')}</div>
</div>`).join('')
: '<div class="mr-lines">No requests match the filters.</div>';
return h + '</div>';
}
async function dashIssue(id){
const p=WPData.get(id); if(!p) return;
if(wpOpenConstraints(p).length>0){ toast('Cannot issue — open constraints remain.', 'alert'); return; }
@@ -3659,6 +3811,7 @@ function bootData(){
loadMembers();
loadLocations(); // CR-004: the option lists come from the server
wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload
mreqLoadMaterials(); // CR-013/D6: the request datalist comes from the project list
initWpNavDrawer();
renderSavedList();
positionSectionNav();