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();

View File

@@ -348,6 +348,28 @@
<button class="add-btn" onclick="downloadMaterialTemplate()">⤓ Download template</button>
<input type="file" id="material-import" accept=".csv,.xlsx,.xls" style="display:none" onchange="importMaterials(event)">
</div>
<!-- MATERIAL REQUESTS (CR-013 / D6, T8.5). The lightweight scope, exactly:
line items, needed-by, requestor, delivery (T8.4's fields on this
package), status. Items pick from the project material list through
the datalist when one exists and stay free text when it does not.
No catalog, no inventory, no warehouse integration. -->
<div style="margin-top:1.5rem; border-top:1px solid var(--border); padding-top:1rem;">
<div class="sub-heading">Material Requests<span class="help-tip" data-tip="Field requests for material against this package. Submitting notifies the warehouse owner named above (CR-010). Delivery uses this package's delivery location.">i</span></div>
<div id="mreq-list" class="mreq-list"></div>
<div class="mreq-new" id="mreq-new">
<div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:110px">Unit</th><th>Description</th><th style="width:44px"></th></tr></thead><tbody id="mreq-items"></tbody></table></div>
<datalist id="mat-datalist"></datalist>
<div class="mreq-row">
<button type="button" class="add-btn" onclick="mreqAddItem()">+ Add line</button>
<label for="mreq-needed">Needed by</label>
<input type="date" id="mreq-needed">
<button type="button" class="btn btn-generate" onclick="mreqSubmit()">Submit request</button>
</div>
<div class="field-hint" id="mreq-hint"></div>
<div class="field-error" id="mreq-err" role="alert"></div>
</div>
</div>
</div>
<!-- DRAWINGS / ATTACHMENTS -->

View File

@@ -595,6 +595,15 @@
.wp-file-item input { flex:1 1 200px; font-size:12px; }
.wp-file-x { margin-left:auto; }
/* CR-013: material requests. Rows wrap at 390px - requests originate in
the field. */
.mreq-row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:8px; }
.mreq-list { display:flex; flex-direction:column; gap:8px; margin:8px 0; }
.mreq-item { border:1px solid var(--border); border-radius:var(--radius); padding:8px 10px; font-size:12px; }
.mreq-item .mr-head { display:flex; gap:10px; flex-wrap:wrap; align-items:center; font-weight:600; }
.mreq-item .mr-status { padding:1px 8px; border-radius:9px; background:var(--accent-dim); color:var(--accent); font-size:11px; font-weight:700; }
.mreq-item .mr-lines { color:var(--text-muted); margin-top:3px; }
.cstatus { display:inline-flex; border:1px solid var(--border-strong); border-radius:5px; overflow:hidden; }
.cstatus button { border:none; background:var(--surface); color:var(--text-muted); font-family:var(--sans); font-size:11px;
font-weight:600; padding:4px 10px; cursor:pointer; border-right:1px solid var(--border); }