T8.6 - D6: the material list uploads the way the location list does
CR-013 accepted free text because the master workbook never arrived; the
Aug 18 call was the CR-005 call again - build the upload path now.
THE component, extracted: T5.4's paste-or-file machinery (file read in the
browser, ONE parser on the server; dry-run check; a report naming every
rejected row with its source line; an editable list that deactivates rather
than deletes) moved from the location-specific functions into
html/wp-list-import.js. The location list and the new material list are both
instances of it - the done-when's "against the same component, not beside it"
made literally true. The loc* names survive as thin delegates because row
handlers, step entry and the probes call them; locations_check re-pointed its
fetch-count assertion to where the fetches now live and still demands every
read and write reach the server.
The material list itself: description, unit, optional code - one new table
(Alembic a1b8c6d4e2f9, additive), GET/import/POST/PATCH routes on the CR-005
pattern, deactivate-never-delete, reactivation reuses the same row so nothing
referencing it orphans. The sample rows are obviously fake (SAMPLE-EMT-075).
NO inventory, price, stock or warehouse field anywhere - the probe walks the
model's columns by regex. The wizard hosts it on step 11 beside the location
list, optional by design: a project with no list still raises free-text
requests (T8.5 wires that).
Parser bug caught by the probe's first run: strip(',;') ate a LEADING comma,
so ',FT' - an empty description - was accepted as a material named FT.
rstrip only, now; the empty first column is rejected with its line number.
Verification (each probe run alone): NEW tests/materials_check.py 17/17.
Regression: locations_check 58/58 through the shared component.
Items: D6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1244,7 +1244,6 @@ const LOCATION_SAMPLE = [
|
||||
].join('\n');
|
||||
|
||||
let _locNodes = [];
|
||||
let _locLoaded = false;
|
||||
|
||||
function locProjectId(){
|
||||
try {
|
||||
@@ -1259,53 +1258,32 @@ function locApi(suffix){
|
||||
}
|
||||
function locEsc(v){ return escAttr(v); }
|
||||
|
||||
// Every message on this step goes through here so the role is decided in one
|
||||
// place: a report that lost rows interrupts (T4.5), a clean one does not.
|
||||
function locSay(html, isProblem){
|
||||
const el = document.getElementById('loc-report');
|
||||
if(!el) return;
|
||||
el.setAttribute('role', isProblem ? 'alert' : 'status');
|
||||
el.innerHTML = html || '';
|
||||
el.classList.toggle('is-problem', !!isProblem);
|
||||
}
|
||||
|
||||
function locSetAddError(msg){
|
||||
const el = document.getElementById('loc-add-err');
|
||||
if(el) el.textContent = msg || '';
|
||||
const input = document.getElementById('loc-add-name');
|
||||
if(input){
|
||||
if(msg) input.setAttribute('aria-invalid', 'true');
|
||||
else input.removeAttribute('aria-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function locLoad(force){
|
||||
const tool = document.getElementById('loc-tool');
|
||||
const warn = document.getElementById('loc-noproject');
|
||||
const pid = locProjectId();
|
||||
if(!pid){
|
||||
if(tool) tool.style.display = 'none';
|
||||
if(warn){
|
||||
warn.style.display = '';
|
||||
warn.textContent = 'Open this wizard from a project to configure its location list. '
|
||||
+ 'The list is stored against the project on the server, not in this browser.';
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
if(tool) tool.style.display = '';
|
||||
if(warn) warn.style.display = 'none';
|
||||
if(_locLoaded && !force) return Promise.resolve();
|
||||
return fetch(locApi('?include_inactive=true'), {headers:{'Accept':'application/json'}})
|
||||
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(data => { _locNodes = data.nodes || []; _locLoaded = true; locRender(); })
|
||||
.catch(err => {
|
||||
_locLoaded = false;
|
||||
locRender();
|
||||
locSay('⚠ Could not load the location list — ' + locEsc((err && err.message) || 'offline')
|
||||
+ '. It is stored on the server, so nothing local is shown in its place.', true);
|
||||
});
|
||||
}
|
||||
// D6 / T8.6: the paste-or-file import machinery moved to wp-list-import.js -
|
||||
// ONE component; the location list and the material list are both instances of
|
||||
// it. These names survive as thin delegates because the row handlers, the step
|
||||
// entry and the probes all call them.
|
||||
const locList = WPListImport({
|
||||
prefix: 'loc', api: locApi, projectId: locProjectId, sample: LOCATION_SAMPLE,
|
||||
loadKey: 'nodes', esc: locEsc,
|
||||
render: function(){ _locNodes = locList.state.rows; locRender(); },
|
||||
rejectedRow: r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.text)}</code> — ${locEsc(r.reason)}</li>`,
|
||||
duplicateRow: r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.path)}</code> — ${locEsc(r.reason)}</li>`,
|
||||
addPayload: function(){
|
||||
const name = ((document.getElementById('loc-add-name') || {}).value || '').trim();
|
||||
if(!name) return {error: 'Enter a name for the value you are adding.'};
|
||||
const parentId = (document.getElementById('loc-add-parent') || {}).value || '';
|
||||
const parent = _locNodes.find(n => n.id === parentId);
|
||||
const level = !parent ? 'building' : (parent.level === 'building' ? 'floor' : 'sector');
|
||||
return {payload: {level: level, parent_id: parentId || null, name: name}};
|
||||
},
|
||||
addedMessage: body => `Added <code>${locEsc(body.path)}</code>.`,
|
||||
noProjectMessage: 'Open this wizard from a project to configure its location list. '
|
||||
+ 'The list is stored against the project on the server, not in this browser.',
|
||||
});
|
||||
function locSay(html, isProblem){ locList.say(html, isProblem); }
|
||||
function locSetAddError(msg){ locList.setAddError(msg); }
|
||||
|
||||
function locLoad(force){ return locList.load(force); }
|
||||
function locRender(){
|
||||
const list = document.getElementById('loc-list');
|
||||
const count = document.getElementById('loc-count');
|
||||
@@ -1352,130 +1330,14 @@ function locRenderParentPicker(){
|
||||
if(keep && Array.from(sel.options).some(o => o.value === keep)) sel.value = keep;
|
||||
}
|
||||
|
||||
function locReport(res){
|
||||
const bits = [];
|
||||
const problem = (res.rejected || []).length > 0 || (res.duplicates || []).length > 0;
|
||||
const verb = res.dry_run ? 'would be added' : 'added';
|
||||
bits.push(`<p class="loc-report-line"><strong>${res.read} row${res.read===1?'':'s'} read.</strong> `
|
||||
+ `${(res.created||[]).length} value${(res.created||[]).length===1?'':'s'} ${verb}`
|
||||
+ ((res.reactivated||[]).length ? `, ${res.reactivated.length} brought back into use` : '')
|
||||
+ `.</p>`);
|
||||
const rowList = (title, rows, fmt) => {
|
||||
if(!rows || !rows.length) return '';
|
||||
return `<div class="loc-report-group"><div class="loc-report-title">${locEsc(title)} (${rows.length})</div>`
|
||||
+ '<ul class="loc-report-list">' + rows.map(fmt).join('') + '</ul></div>';
|
||||
};
|
||||
bits.push(rowList('Rejected', res.rejected,
|
||||
r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.text)}</code> — ${locEsc(r.reason)}</li>`));
|
||||
bits.push(rowList('Duplicates, not merged', res.duplicates,
|
||||
r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.path)}</code> — ${locEsc(r.reason)}</li>`));
|
||||
if(!problem && !(res.created||[]).length && !(res.reactivated||[]).length){
|
||||
bits.push('<p class="loc-report-line">Nothing to do — every row is already on this project.</p>');
|
||||
}
|
||||
locSay(bits.join(''), problem);
|
||||
}
|
||||
|
||||
function locImport(dryRun){
|
||||
const text = (document.getElementById('loc-paste') || {}).value || '';
|
||||
if(!text.trim()){
|
||||
locSay('Paste some rows or choose a CSV file first.', true);
|
||||
return;
|
||||
}
|
||||
if(!locProjectId()){ locLoad(); return; }
|
||||
locSay('Checking…', false);
|
||||
fetch(locApi('/import'), {
|
||||
method: 'POST', headers: {'Content-Type':'application/json','Accept':'application/json'},
|
||||
body: JSON.stringify({text: text, dry_run: !!dryRun}),
|
||||
})
|
||||
.then(r => r.json().then(j => ({ok: r.ok, status: r.status, body: j})))
|
||||
.then(res => {
|
||||
if(!res.ok){
|
||||
locSay('⚠ Import refused — ' + locEsc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||
return;
|
||||
}
|
||||
locReport(res.body);
|
||||
if(!dryRun) return locLoad(true);
|
||||
})
|
||||
.catch(err => locSay('⚠ Could not reach the server — ' + locEsc((err && err.message) || 'offline')
|
||||
+ '. Nothing was imported.', true));
|
||||
}
|
||||
|
||||
function locAdd(){
|
||||
const nameEl = document.getElementById('loc-add-name');
|
||||
const parentEl = document.getElementById('loc-add-parent');
|
||||
const name = ((nameEl || {}).value || '').trim();
|
||||
if(!name){
|
||||
locSetAddError('Enter a name for the value you are adding.');
|
||||
if(nameEl) nameEl.focus();
|
||||
return;
|
||||
}
|
||||
locSetAddError('');
|
||||
const parentId = (parentEl || {}).value || '';
|
||||
const parent = _locNodes.find(n => n.id === parentId);
|
||||
const level = !parent ? 'building' : (parent.level === 'building' ? 'floor' : 'sector');
|
||||
fetch(locApi(''), {
|
||||
method: 'POST', headers: {'Content-Type':'application/json','Accept':'application/json'},
|
||||
body: JSON.stringify({level: level, parent_id: parentId || null, name: name}),
|
||||
})
|
||||
.then(r => r.json().then(j => ({ok: r.ok, status: r.status, body: j})))
|
||||
.then(res => {
|
||||
if(!res.ok){
|
||||
locSetAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')'));
|
||||
if(nameEl) nameEl.focus();
|
||||
return;
|
||||
}
|
||||
if(nameEl) nameEl.value = '';
|
||||
locSay(`Added <code>${locEsc(res.body.path)}</code>.`, false);
|
||||
return locLoad(true);
|
||||
})
|
||||
.catch(err => locSetAddError('Could not reach the server — ' + ((err && err.message) || 'offline')));
|
||||
}
|
||||
|
||||
function locPatch(id, patch, describe){
|
||||
return fetch(locApi('/' + encodeURIComponent(id)), {
|
||||
method: 'PATCH', headers: {'Content-Type':'application/json','Accept':'application/json'},
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
.then(r => r.json().then(j => ({ok: r.ok, status: r.status, body: j})))
|
||||
.then(res => {
|
||||
if(!res.ok){
|
||||
locSay('⚠ ' + locEsc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||
return locLoad(true);
|
||||
}
|
||||
locSay(describe(res.body), false);
|
||||
return locLoad(true);
|
||||
})
|
||||
.catch(err => locSay('⚠ Could not reach the server — ' + locEsc((err && err.message) || 'offline'), true));
|
||||
}
|
||||
function locImport(dryRun){ locList.importText(dryRun); }
|
||||
function locAdd(){ locList.add(); }
|
||||
function locPatch(id, patch, describe){ return locList.patch(id, patch, describe); }
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
const paste = document.getElementById('loc-paste');
|
||||
const file = document.getElementById('loc-file');
|
||||
const btn = id => document.getElementById(id);
|
||||
|
||||
if(btn('loc-file-btn')) btn('loc-file-btn').addEventListener('click', () => file && file.click());
|
||||
if(file) file.addEventListener('change', function(ev){
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
if(!f) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
// One parser, on the server. Reading the file here and posting its text is
|
||||
// what stops "what does a blank column mean" having two answers.
|
||||
if(paste) paste.value = String(reader.result || '');
|
||||
locSay(`Read <strong>${locEsc(f.name)}</strong>. Check it, then import.`, false);
|
||||
};
|
||||
reader.onerror = () => locSay('⚠ Could not read that file.', true);
|
||||
reader.readAsText(f);
|
||||
ev.target.value = '';
|
||||
});
|
||||
if(btn('loc-check-btn')) btn('loc-check-btn').addEventListener('click', () => locImport(true));
|
||||
if(btn('loc-import-btn')) btn('loc-import-btn').addEventListener('click', () => locImport(false));
|
||||
if(btn('loc-sample-btn')) btn('loc-sample-btn').addEventListener('click', function(){
|
||||
if(paste) paste.value = LOCATION_SAMPLE;
|
||||
locSay('Sample values loaded into the box — obviously fake, and safe to import '
|
||||
+ 'while the real list is being collected. Check them, then import.', false);
|
||||
});
|
||||
if(btn('loc-add-btn')) btn('loc-add-btn').addEventListener('click', locAdd);
|
||||
locList.wire();
|
||||
matList.wire();
|
||||
const addName = btn('loc-add-name');
|
||||
if(addName) addName.addEventListener('keydown', e => { if(e.key === 'Enter'){ e.preventDefault(); locAdd(); } });
|
||||
|
||||
@@ -1961,7 +1823,7 @@ function updateStepUI(){
|
||||
// The location list lives on the server, so it is fetched when the step is
|
||||
// actually opened rather than on every page load. locLoad() is idempotent and
|
||||
// no-ops once it has the list.
|
||||
if(currentStep === 11 && typeof locLoad === 'function') locLoad();
|
||||
if(currentStep === 11 && typeof locLoad === 'function'){ locLoad(); if(typeof matList !== 'undefined') matList.load(); }
|
||||
if(currentStep === 12 && typeof renderSectionToggles === 'function') renderSectionToggles();
|
||||
|
||||
// Update buttons
|
||||
@@ -2105,6 +1967,93 @@ function validateStep(n){
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── PROJECT MATERIAL LIST (D6 / T8.6) ─────────────────────────────────────────
|
||||
// The CR-005 call, made again for materials: build the upload path now rather
|
||||
// than wait for the master workbook. Same component as the location list -
|
||||
// paste or file, dry-run check, rejected rows with source line numbers, and an
|
||||
// editable list whose entries deactivate rather than delete. The field set is
|
||||
// deliberately small: description, unit, an optional code. No inventory level,
|
||||
// no price, no warehouse id - that is the deferred catalog, and it stays deferred.
|
||||
const MATERIAL_SAMPLE = [
|
||||
'Sample 3/4in EMT,FT,SAMPLE-EMT-075',
|
||||
'Sample 1in EMT,FT,SAMPLE-EMT-100',
|
||||
'Sample strut channel,FT,SAMPLE-STRUT',
|
||||
'Sample junction box 4x4,EA',
|
||||
].join('\n');
|
||||
|
||||
function matApi(suffix){
|
||||
return '/api/projects/' + encodeURIComponent(locProjectId()) + '/materials' + (suffix || '');
|
||||
}
|
||||
|
||||
const matList = WPListImport({
|
||||
prefix: 'mat', api: matApi, projectId: locProjectId, sample: MATERIAL_SAMPLE,
|
||||
loadKey: 'items', esc: locEsc,
|
||||
render: matRender,
|
||||
rejectedRow: r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.text)}</code> — ${locEsc(r.reason)}</li>`,
|
||||
duplicateRow: r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.text)}</code> — ${locEsc(r.reason)}</li>`,
|
||||
addPayload: function(){
|
||||
const name = ((document.getElementById('mat-add-name') || {}).value || '').trim();
|
||||
if(!name) return {error: 'Enter a description for the material you are adding.'};
|
||||
const unit = ((document.getElementById('mat-add-unit') || {}).value || '').trim();
|
||||
return {payload: {description: name, unit: unit, code: ''}};
|
||||
},
|
||||
addedMessage: body => `Added <code>${locEsc(body.description)}</code>.`,
|
||||
noProjectMessage: 'Open this wizard from a project to configure its material list. '
|
||||
+ 'The list is stored against the project on the server, not in this browser.',
|
||||
});
|
||||
|
||||
function matRender(){
|
||||
const list = document.getElementById('mat-list');
|
||||
const count = document.getElementById('mat-count');
|
||||
if(!list) return;
|
||||
const rows = matList.state.rows;
|
||||
const active = rows.filter(r => r.active);
|
||||
if(count){
|
||||
count.textContent = rows.length
|
||||
? `${active.length} material${active.length===1?'':'s'} in use`
|
||||
+ (rows.length > active.length ? `, ${rows.length - active.length} deactivated` : '')
|
||||
: '';
|
||||
}
|
||||
if(!rows.length){
|
||||
list.innerHTML = '<p class="field-hint">No material list yet. Requests fall back to free '
|
||||
+ 'text until one is loaded - a project with no list can still raise a request.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = rows.map(r => `<div class="loc-row${r.active ? '' : ' is-off'}" data-id="${locEsc(r.id)}">
|
||||
<span class="loc-level">${locEsc(r.unit || '—')}</span>
|
||||
<input class="mat-desc" type="text" value="${locEsc(r.description)}"
|
||||
aria-label="Description for ${locEsc(r.code || r.description)}" data-id="${locEsc(r.id)}">
|
||||
${r.code ? `<code class="loc-code">${locEsc(r.code)}</code>` : ''}
|
||||
<label class="loc-toggle"><input type="checkbox" class="mat-active" data-id="${locEsc(r.id)}"
|
||||
${r.active ? 'checked' : ''}><span>In use</span></label>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
const list = document.getElementById('mat-list');
|
||||
if(!list) return;
|
||||
list.addEventListener('change', function(e){
|
||||
const t = e.target;
|
||||
if(!t) return;
|
||||
if(t.classList.contains('mat-active')){
|
||||
matList.patch(t.dataset.id, {active: t.checked},
|
||||
b => b.active
|
||||
? `<code>${locEsc(b.description)}</code> is back in use.`
|
||||
: `<code>${locEsc(b.description)}</code> is no longer offered on new requests. `
|
||||
+ 'Requests already referencing it are unchanged.');
|
||||
return;
|
||||
}
|
||||
if(t.classList.contains('mat-desc')){
|
||||
const row = matList.state.rows.find(n => n.id === t.dataset.id);
|
||||
const next = (t.value || '').trim();
|
||||
if(!row || next === row.description){ if(row) t.value = row.description; return; }
|
||||
if(!next){ t.value = row.description; return; }
|
||||
matList.patch(t.dataset.id, {description: next},
|
||||
b => `Renamed to “${locEsc(b.description)}”.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── SOP COMPLETION ────────────────────────────────────────────────────────────
|
||||
// Re-saving a SOP that is already complete changes the project's baseline, which
|
||||
// the server restricts to a Project Admin. Check before doing the work so the
|
||||
|
||||
Reference in New Issue
Block a user