diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md index 7202408..5b5de6c 100644 --- a/docs/reference/file-map.md +++ b/docs/reference/file-map.md @@ -292,6 +292,7 @@ Wave 8 adds these: ```bash python tests/kitting_check.py # CR-009/010/012 - statuses, owner, delivery 26 checks python tests/kitting_notify_check.py # CR-011 - kitting mail, coalesced, gated 17 checks +python tests/materials_check.py # D6 - material list, the CR-005 pattern 17 checks ``` **Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js index f9cdb2e..8d05f80 100644 --- a/html/work-package-suite-app.js +++ b/html/work-package-suite-app.js @@ -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 => `
${locEsc(r.text)} — ${locEsc(r.reason)}${locEsc(r.path)} — ${locEsc(r.reason)}${locEsc(body.path)}.`,
+ 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(`${res.read} row${res.read===1?'':'s'} read. ` - + `${(res.created||[]).length} value${(res.created||[]).length===1?'':'s'} ${verb}` - + ((res.reactivated||[]).length ? `, ${res.reactivated.length} brought back into use` : '') - + `.
`); - const rowList = (title, rows, fmt) => { - if(!rows || !rows.length) return ''; - return `${locEsc(r.text)} — ${locEsc(r.reason)}${locEsc(r.path)} — ${locEsc(r.reason)}Nothing to do — every row is already on this project.
'); - } - 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${locEsc(res.body.path)}.`, 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 ${locEsc(f.name)}. 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 => `${locEsc(r.text)} — ${locEsc(r.reason)}${locEsc(r.text)} — ${locEsc(r.reason)}${locEsc(body.description)}.`,
+ 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 = 'No material list yet. Requests fall back to free ' + + 'text until one is loaded - a project with no list can still raise a request.
'; + return; + } + list.innerHTML = rows.map(r => `${locEsc(r.code)}` : ''}
+
+ ${locEsc(b.description)} is back in use.`
+ : `${locEsc(b.description)} 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
diff --git a/html/work-package-suite.html b/html/work-package-suite.html
index 3094481..24ed35a 100644
--- a/html/work-package-suite.html
+++ b/html/work-package-suite.html
@@ -12,6 +12,7 @@
during their own boot. -->
+
+