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:
@@ -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,'"')}">${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,'"')}" aria-label="Quantity, line ${i+1}" oninput="_mreqDraft[${i}].qty=this.value"></td>
|
||||
<td><input type="text" value="${(it.unit||'').replace(/"/g,'"')}" 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,'"')}" 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();
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user