Add Micron asset picker to work package creator
Adds an optional read-only Micron asset catalog lookup for the WP creator, with searchable asset IDs, CSV import, and graceful fallback to manual asset entry when the catalog is absent or unreachable. This includes the backend /api/assets endpoint, SQL Server connector configuration, Docker network changes for outbound access, and UI updates/documentation to make the catalog read-only and clearly distinguish Micron-vetted assets from manual entries.
This commit is contained in:
@@ -105,7 +105,7 @@ function applySOP(){
|
||||
applyKind();
|
||||
if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
|
||||
if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
|
||||
if(!pkgAssets.length){ pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
|
||||
if(!pkgAssets.length){ buildAssets(); } // renders the "no assets yet" empty state
|
||||
if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); }
|
||||
updateNumber(); updateReleaseBanner();
|
||||
}
|
||||
@@ -134,7 +134,7 @@ function applyKindVisibility(){
|
||||
const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; };
|
||||
show('kind-row', bimProj);
|
||||
show('bim-card', ewp); // model area / clash + IFF # / scan
|
||||
show('asset-card', !ewp); // controls.dev assets
|
||||
show('asset-card', !ewp); // Micron DB assets
|
||||
show('material-card', !ewp); // bill of materials
|
||||
show('mimo-card', !ewp); // kitting / MIMO
|
||||
show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it
|
||||
@@ -807,20 +807,344 @@ function splitByDiscipline(){
|
||||
alert('Created '+children.length+' instances:\n\n• '+children.map(c=>c.number+' ('+c.disciplines[0]+', '+(c.materials?c.materials.length:0)+' material line'+((c.materials&&c.materials.length===1)?'':'s')+')').join('\n• ')+'\n\nThe master '+baseNumber+' is kept as a roll-up.'+matNote);
|
||||
}
|
||||
|
||||
// ── ASSETS (controls.dev) ────────────────────────────────────────────────────
|
||||
// Interim: assets are linked manually back to controls.dev. A future direct
|
||||
// integration will let the user pick them from a list instead of pasting links.
|
||||
// ── ASSETS (Micron asset catalog) ────────────────────────────────────────────
|
||||
// Assets are picked from the Micron asset catalog, a SQL Server database this app
|
||||
// reads through /api/assets. The lookup is strictly read-only — picking an asset
|
||||
// never writes to the catalog, and there is no endpoint that could.
|
||||
//
|
||||
// The catalog can be absent (not configured) or unreachable (VPN/host down), and
|
||||
// neither may block someone from writing a work package: in both cases the picker
|
||||
// says so and manual entry carries on. Manually entered assets are marked
|
||||
// source:'manual' so it stays visible which rows the catalog vouches for.
|
||||
// The whole catalog is fetched once when the page loads and searched in memory —
|
||||
// it is slow-moving reference data, so a request per keystroke would buy nothing
|
||||
// and cost latency on every one.
|
||||
let assetCatalog = []; // the full catalog, loaded once
|
||||
let assetCatalogIndex = new Map(); // lowercased id -> the Micron DB's own casing
|
||||
let assetCatalogState = 'loading'; // loading | ready | absent | error
|
||||
let assetResults = []; // current matches; the result list indexes into this
|
||||
const ASSET_RESULT_MAX = 500; // results shown at once — the box scrolls, not the search
|
||||
const ASSET_IMPORT_MAX = 1000; // rows accepted from one CSV — see importAssets()
|
||||
|
||||
function assetKey(a){ return String((a && a.tag) || '').trim().toLowerCase(); }
|
||||
function assetAlreadyAdded(tag){
|
||||
const k = String(tag||'').trim().toLowerCase();
|
||||
return pkgAssets.some(a => assetKey(a) === k && k);
|
||||
}
|
||||
|
||||
function buildAssets(){
|
||||
const tb=document.getElementById('asset-body'); if(!tb) return; tb.innerHTML='';
|
||||
pkgAssets.forEach((a,i)=>{ const tr=document.createElement('tr');
|
||||
tr.innerHTML=`<td><input type="text" value="${(a.tag||'').replace(/"/g,'"')}" placeholder="controls.dev asset tag / ID" oninput="pkgAssets[${i}].tag=this.value"></td>
|
||||
<td><input type="text" value="${(a.desc||'').replace(/"/g,'"')}" placeholder="what it is (optional)" oninput="pkgAssets[${i}].desc=this.value"></td>
|
||||
<td><input type="url" value="${(a.link||'').replace(/"/g,'"')}" placeholder="https://controls.dev/..." oninput="pkgAssets[${i}].link=this.value"></td>
|
||||
<td class="center"><button class="row-del" onclick="removeAsset(${i})">✕</button></td>`;
|
||||
tb.appendChild(tr); });
|
||||
if(!pkgAssets.length){
|
||||
tb.innerHTML = `<tr><td colspan="3" class="asset-empty">No assets yet — search the Micron DB above to add the assets this package covers.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
pkgAssets.forEach((a,i)=>{
|
||||
const tr=document.createElement('tr');
|
||||
// The asset ID on a catalog row is shown as text, not an input: the catalog
|
||||
// is the source of truth for it and a locally edited copy would silently
|
||||
// disagree. The note is always the user's own, so it stays editable either
|
||||
// way. Every interpolation below is esc()'d text content or a numeric index
|
||||
// — never a raw string inside an inline handler, which is the bug pattern
|
||||
// recorded in KNOWN-ISSUES.md §1.
|
||||
const idCell = a.source === 'catalog'
|
||||
? `<td><span class="asset-tag">${esc(a.tag)}</span> <span class="asset-badge" title="From the Micron DB">Micron DB</span></td>`
|
||||
: `<td><input type="text" value="${esc(a.tag)}" placeholder="asset ID" oninput="pkgAssets[${i}].tag=this.value"></td>`;
|
||||
tr.innerHTML = idCell +
|
||||
`<td><input type="text" value="${esc(a.desc)}" placeholder="what it is / why it's in scope" oninput="pkgAssets[${i}].desc=this.value"></td>
|
||||
<td class="center"><button class="row-del" onclick="removeAsset(${i})" title="Remove">✕</button></td>`;
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
// Kept for saved packages written before the picker existed: their rows have no
|
||||
// `source`, so they would render as read-only catalog rows with no way to fix a
|
||||
// typo. Anything that didn't come from the catalog is treated as manual.
|
||||
function normaliseAsset(a){
|
||||
const o = Object.assign({ tag:'', desc:'', link:'', source:'manual' }, a||{});
|
||||
if(o.source !== 'catalog') o.source = 'manual';
|
||||
return o;
|
||||
}
|
||||
|
||||
function addManualAsset(){
|
||||
pkgAssets.push(normaliseAsset({}));
|
||||
buildAssets();
|
||||
track('asset_added',{source:'manual'});
|
||||
}
|
||||
|
||||
// ── CSV IMPORT ───────────────────────────────────────────────────────────────
|
||||
// Bulk-add a list of asset ids. Each imported id is checked against the loaded
|
||||
// Micron DB: a hit is added as a catalog row (badge, id locked, stored with the
|
||||
// DB's own casing); a miss is added as a manual row so it is visibly NOT vouched
|
||||
// for rather than silently dropped. Nothing is ever written back to Micron.
|
||||
const ASSET_ID_HEADERS = ['asset id','assetid','asset_id','asset','asset tag','assettag','tag','id'];
|
||||
|
||||
function importAssets(ev){
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
if(!f){ return; }
|
||||
const clear = () => { ev.target.value = ''; };
|
||||
if(/\.xlsx?$/i.test(f.name)){
|
||||
alert('Please save the workbook as CSV first (File → Save As → CSV), then load it here.');
|
||||
clear(); return;
|
||||
}
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
let rows;
|
||||
try { rows = parseCSV(r.result); }
|
||||
catch(e){ alert('Could not parse that CSV.'); clear(); return; }
|
||||
if(!rows.length){ alert('That file has no rows.'); clear(); return; }
|
||||
applyImportedAssets(rows);
|
||||
clear();
|
||||
};
|
||||
r.onerror = () => { alert('Could not read that file.'); clear(); };
|
||||
r.readAsText(f);
|
||||
}
|
||||
|
||||
// Which column holds the ids, and whether row 0 is a header.
|
||||
// - a recognised header name wins outright;
|
||||
// - otherwise pick the column with the most Micron DB hits, so an export with
|
||||
// the ids in column D works without the user rearranging it;
|
||||
// - failing both (nothing matches — e.g. the DB is offline), use column 0.
|
||||
function pickAssetColumn(rows){
|
||||
const head = (rows[0] || []).map(c => String(c || '').trim().toLowerCase());
|
||||
const named = head.findIndex(h => ASSET_ID_HEADERS.includes(h));
|
||||
if(named >= 0) return { col: named, start: 1 };
|
||||
|
||||
const width = rows.slice(0, 200).reduce((w, r) => Math.max(w, r.length), 1);
|
||||
let best = 0, bestHits = 0;
|
||||
for(let c = 0; c < width; c++){
|
||||
let hits = 0;
|
||||
for(let i = 0; i < Math.min(rows.length, 200); i++){
|
||||
const v = String((rows[i] || [])[c] || '').trim();
|
||||
if(v && assetCatalogIndex.has(v.toLowerCase())) hits++;
|
||||
}
|
||||
if(hits > bestHits){ bestHits = hits; best = c; }
|
||||
}
|
||||
return { col: best, start: 0 };
|
||||
}
|
||||
|
||||
function applyImportedAssets(rows){
|
||||
const { col, start } = pickAssetColumn(rows);
|
||||
|
||||
// Collect, trimmed and de-duplicated within the file itself.
|
||||
const seen = new Set(), ids = [];
|
||||
for(let i = start; i < rows.length; i++){
|
||||
const v = String((rows[i] || [])[col] || '').trim();
|
||||
if(!v) continue;
|
||||
const k = v.toLowerCase();
|
||||
if(seen.has(k)) continue;
|
||||
seen.add(k); ids.push(v);
|
||||
}
|
||||
if(!ids.length){ alert('No asset ids found in that file.'); return; }
|
||||
|
||||
// Cap the import rather than building a table with thousands of rows. Reported,
|
||||
// never silent — a truncated import that looked complete would be worse.
|
||||
const capped = ids.length > ASSET_IMPORT_MAX;
|
||||
const take = capped ? ids.slice(0, ASSET_IMPORT_MAX) : ids;
|
||||
|
||||
let matched = 0, unmatched = 0, dupes = 0;
|
||||
take.forEach(id => {
|
||||
if(assetAlreadyAdded(id)){ dupes++; return; }
|
||||
const canonical = assetCatalogIndex.get(id.toLowerCase());
|
||||
if(canonical){
|
||||
pkgAssets.push({ tag: canonical, desc: '', link: '', source: 'catalog' });
|
||||
matched++;
|
||||
} else {
|
||||
pkgAssets.push({ tag: id, desc: '', link: '', source: 'manual' });
|
||||
unmatched++;
|
||||
}
|
||||
});
|
||||
|
||||
buildAssets();
|
||||
renderAssetResults(); // rows just added should now read "added"
|
||||
track('asset_imported', { matched: matched, unmatched: unmatched });
|
||||
|
||||
// Every id matched, nothing skipped, nothing truncated: a toast is enough.
|
||||
// Anything the user needs to act on — unmatched ids, a silent-looking
|
||||
// truncation, an unchecked import — interrupts with the detail instead.
|
||||
const offline = assetCatalogState !== 'ready';
|
||||
if(matched && !unmatched && !dupes && !capped && !offline){
|
||||
toast('Added ' + matched + ' asset' + (matched === 1 ? '' : 's') + ' from the Micron DB');
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
if(matched) parts.push(matched + ' found in the Micron DB');
|
||||
if(unmatched) parts.push(unmatched + ' not in the Micron DB (added as manual rows)');
|
||||
if(dupes) parts.push(dupes + ' already on this package (skipped)');
|
||||
let msg = 'Imported ' + (matched + unmatched) + ' asset' + ((matched + unmatched) === 1 ? '' : 's') +
|
||||
(parts.length ? ':\n\n• ' + parts.join('\n• ') : '');
|
||||
if(capped) msg += '\n\nThe list held ' + ids.length.toLocaleString() + ' ids — only the first ' +
|
||||
ASSET_IMPORT_MAX.toLocaleString() + ' were added.';
|
||||
if(offline) msg += '\n\nNote: the Micron DB was not loaded, so nothing could be ' +
|
||||
'checked against it — every row was added as manual.';
|
||||
alert(msg);
|
||||
}
|
||||
|
||||
function removeAsset(i){
|
||||
pkgAssets.splice(i,1);
|
||||
buildAssets();
|
||||
renderAssetResults(); // a removed asset becomes addable again
|
||||
}
|
||||
|
||||
// ── Catalog lookup ───────────────────────────────────────────────────────────
|
||||
function assetSourceNote(msg, tone){
|
||||
const el = document.getElementById('asset-source-note'); if(!el) return;
|
||||
el.textContent = msg || '';
|
||||
el.style.color = tone === 'warn' ? 'var(--red)' : '';
|
||||
}
|
||||
|
||||
function initAssetPicker(){
|
||||
const box = document.getElementById('asset-search'); if(!box) return;
|
||||
box.addEventListener('input', () => runAssetSearch(box.value));
|
||||
// Re-open on focus only when the list is actually closed. Adding an asset
|
||||
// returns focus to this box, and re-running the search there would rebuild the
|
||||
// list under the cursor and throw away the scroll position mid-multi-add.
|
||||
box.addEventListener('focus', () => {
|
||||
const results = document.getElementById('asset-results');
|
||||
if(results && results.hidden && box.value.trim()) runAssetSearch(box.value);
|
||||
});
|
||||
// Pasting a column of ids straight out of Excel adds them all, rather than
|
||||
// dropping a multi-line blob into a search box that can only match one thing.
|
||||
// Excel gives \r\n between rows and \t between columns — i.e. exactly the CSV
|
||||
// importer's row/cell shape, so it goes through the same matching path.
|
||||
// A single value is left alone: that is an ordinary search, not a bulk add.
|
||||
box.addEventListener('paste', e => {
|
||||
const cb = e.clipboardData || window.clipboardData;
|
||||
const text = cb ? cb.getData('text') : '';
|
||||
if(!text) return;
|
||||
const lines = text.replace(/\r\n?/g, '\n').split('\n').filter(l => l.trim());
|
||||
if(lines.length < 2) return; // one id — paste it and search as normal
|
||||
e.preventDefault();
|
||||
applyImportedAssets(lines.map(l => l.split('\t')));
|
||||
box.value = '';
|
||||
assetResults = [];
|
||||
openAssetResults(false);
|
||||
});
|
||||
box.addEventListener('keydown', e => {
|
||||
if(e.key === 'Escape'){ openAssetResults(false); box.blur(); }
|
||||
// Enter adds the first result that isn't already on the package — the common
|
||||
// case of typing an exact tag and taking it without reaching for the mouse.
|
||||
if(e.key === 'Enter'){
|
||||
e.preventDefault();
|
||||
const ix = assetResults.findIndex(tag => !assetAlreadyAdded(tag));
|
||||
if(ix >= 0) addCatalogAsset(ix);
|
||||
}
|
||||
});
|
||||
// Click-away closes, matching the .pp-menu pickers elsewhere on this form.
|
||||
// Tested against composedPath() rather than e.target: adding an asset can
|
||||
// re-render the row that was clicked, and a detached target reports itself as
|
||||
// outside every container, which would close the list on every add.
|
||||
document.addEventListener('click', e => {
|
||||
const path = typeof e.composedPath === 'function' ? e.composedPath() : null;
|
||||
const inside = path && path.length
|
||||
? path.some(n => n && n.id === 'asset-pick')
|
||||
: !!(e.target.closest && e.target.closest('#asset-pick'));
|
||||
if(!inside) openAssetResults(false);
|
||||
});
|
||||
// Delegated so result rows never need an inline handler carrying catalog text.
|
||||
const results = document.getElementById('asset-results');
|
||||
if(results) results.addEventListener('click', e => {
|
||||
const row = e.target.closest('[data-asset-ix]'); if(!row) return;
|
||||
addCatalogAsset(parseInt(row.getAttribute('data-asset-ix'), 10));
|
||||
});
|
||||
|
||||
box.disabled = true;
|
||||
box.placeholder = 'Loading asset IDs from the Micron DB…';
|
||||
assetSourceNote('Loading asset IDs from the Micron DB…');
|
||||
|
||||
fetch('/api/assets', { headers:{ 'Accept':'application/json' } })
|
||||
.then(r => r.ok ? r.json()
|
||||
: r.json().catch(() => ({})).then(b => Promise.reject(b.detail || 'The Micron DB could not be read.')))
|
||||
.then(body => {
|
||||
if(!body.configured){
|
||||
assetCatalogState = 'absent';
|
||||
box.placeholder = 'Micron DB not configured — add assets manually below';
|
||||
assetSourceNote('The Micron DB is not connected, so assets are entered by hand. Use “+ Add asset not in the Micron DB”.');
|
||||
return;
|
||||
}
|
||||
assetCatalog = (body.assets || []).map(a => String(a.tag || ''));
|
||||
// Lowercased lookup for the CSV importer: it decides whether an imported id
|
||||
// is a real Micron asset, and maps it back to the DB's own casing so an
|
||||
// id typed as "ahu-2p-014" is stored exactly as Micron spells it.
|
||||
assetCatalogIndex = new Map(assetCatalog.map(t => [t.toLowerCase(), t]));
|
||||
assetCatalogState = 'ready';
|
||||
box.disabled = false;
|
||||
box.placeholder = 'Search asset IDs, or paste a column from Excel…';
|
||||
assetSourceNote(assetCatalog.length.toLocaleString() + ' asset IDs loaded from the Micron DB (read-only).');
|
||||
})
|
||||
.catch(err => {
|
||||
assetCatalogState = 'error';
|
||||
box.placeholder = 'Micron DB unavailable — add assets manually below';
|
||||
assetSourceNote(typeof err === 'string' ? err + ' You can still add assets manually.'
|
||||
: 'The Micron DB could not be reached. You can still add assets manually.', 'warn');
|
||||
});
|
||||
}
|
||||
|
||||
// Filters the loaded catalog in memory. Exact match, then prefix, then contains —
|
||||
// so typing a full asset ID puts that asset first rather than whichever ID
|
||||
// happens to sort first.
|
||||
function runAssetSearch(q){
|
||||
q = String(q||'').trim().toLowerCase();
|
||||
if(!q || assetCatalogState !== 'ready'){
|
||||
assetResults = []; renderAssetResults(); openAssetResults(false); return;
|
||||
}
|
||||
const exact=[], prefix=[], other=[];
|
||||
for(const tag of assetCatalog){
|
||||
const t = tag.toLowerCase();
|
||||
if(t === q) exact.push(tag);
|
||||
else if(t.startsWith(q)) prefix.push(tag);
|
||||
else if(t.includes(q)) other.push(tag);
|
||||
if(exact.length + prefix.length + other.length >= ASSET_RESULT_MAX) break;
|
||||
}
|
||||
assetResults = exact.concat(prefix, other).slice(0, ASSET_RESULT_MAX);
|
||||
renderAssetResults();
|
||||
openAssetResults(true);
|
||||
}
|
||||
|
||||
function renderAssetResults(){
|
||||
const box = document.getElementById('asset-results'); if(!box) return;
|
||||
if(!assetResults.length){
|
||||
box.innerHTML = `<div class="asset-result-note">No matching asset IDs. Add it manually if it isn’t in the Micron DB yet.</div>`;
|
||||
return;
|
||||
}
|
||||
box.innerHTML = assetResults.map((tag,ix) => {
|
||||
const on = assetAlreadyAdded(tag);
|
||||
return `<button type="button" class="asset-result${on?' is-added':''}" ${on?'disabled':''} data-asset-ix="${ix}">
|
||||
<span class="asset-result-tag">${esc(tag)}</span>
|
||||
<span class="asset-result-add">${on ? 'added' : '+ add'}</span>
|
||||
</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openAssetResults(open){
|
||||
const box = document.getElementById('asset-results');
|
||||
const inp = document.getElementById('asset-search');
|
||||
if(box) box.hidden = !open;
|
||||
if(inp) inp.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function addCatalogAsset(ix){
|
||||
const tag = assetResults[ix]; if(!tag) return;
|
||||
if(assetAlreadyAdded(tag)){ toast('That asset is already on this package'); return; }
|
||||
pkgAssets.push({ tag: tag, desc: '', link: '', source: 'catalog' });
|
||||
buildAssets();
|
||||
// Mark just this row instead of re-rendering the list: the results stay open
|
||||
// for the next pick, the scroll position holds, and the clicked element is
|
||||
// never detached mid-click (see the composedPath note in initAssetPicker).
|
||||
markAssetResultAdded(ix);
|
||||
const box = document.getElementById('asset-search');
|
||||
if(box) box.focus(); // keep typing straight into the next search
|
||||
track('asset_added',{source:'catalog'});
|
||||
}
|
||||
|
||||
function markAssetResultAdded(ix){
|
||||
const row = document.querySelector('#asset-results [data-asset-ix="' + ix + '"]');
|
||||
if(!row) return;
|
||||
row.classList.add('is-added');
|
||||
row.disabled = true;
|
||||
const label = row.querySelector('.asset-result-add');
|
||||
if(label) label.textContent = 'added';
|
||||
}
|
||||
function addAsset(){ pkgAssets.push({tag:'',desc:'',link:''}); buildAssets(); track('asset_added'); }
|
||||
function removeAsset(i){ pkgAssets.splice(i,1); if(!pkgAssets.length)pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
|
||||
|
||||
// ── ATTACHMENTS ──────────────────────────────────────────────────────────────
|
||||
function buildAttach(){
|
||||
@@ -1189,8 +1513,8 @@ function renderPackage(pkg){
|
||||
${pkg.bimlink?`<tr><th>Enabled by (BIM)</th><td>${/^https?:\/\//i.test(pkg.bimlink)?linkify(pkg.bimlink):cell(pkg.bimlink)}</td></tr>`:''}
|
||||
${(pkg.lod||pkg.iff||pkg.modelArea||pkg.clash||pkg.scanLink)?`<tr><th>BIM / Model</th><td>${[pkg.modelArea?'Area: '+esc(pkg.modelArea):'', pkg.clash?'Coordination: '+esc(pkg.clash):'', pkg.iff?'IFF #: '+esc(pkg.iff):'', pkg.scanLink?'Scan: '+linkify(pkg.scanLink):'', pkg.lod?'LOD: '+esc(pkg.lod)+' (legacy)':''].filter(Boolean).join('<br>')}</td></tr>`:''}
|
||||
</tbody></table>`;
|
||||
if(pkg.assets&&pkg.assets.length){ h+=`<h2>2.0 Assets (controls.dev)</h2><table><thead><tr><th style="width:180px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link</th></tr></thead><tbody>`;
|
||||
pkg.assets.forEach(a=>h+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`); h+=`</tbody></table>`; }
|
||||
if(pkg.assets&&pkg.assets.length){ h+=`<h2>2.0 Assets</h2><table><thead><tr><th style="width:240px">Asset ID</th><th>Note</th></tr></thead><tbody>`;
|
||||
pkg.assets.forEach(a=>h+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td></tr>`); h+=`</tbody></table>`; }
|
||||
let scopeHtml;
|
||||
if(pkg.scope && Object.keys(pkg.scope).length){ // per-discipline scope sections
|
||||
scopeHtml = Object.keys(pkg.scope).map(d=>{
|
||||
@@ -1688,7 +2012,7 @@ function loadPackageIntoForm(p){
|
||||
set('wp_hold', (p.hold&&p.hold.trim())?p.hold:sopValueFor('wp_hold'));
|
||||
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
||||
// collections
|
||||
pkgAssets=(p.assets&&p.assets.length)?p.assets.map(a=>({...a})):[{tag:'',desc:'',link:''}]; buildAssets();
|
||||
pkgAssets=(p.assets||[]).map(normaliseAsset); buildAssets();
|
||||
pkgMaterials=(p.materials&&p.materials.length)?p.materials.map(m=>({...m,unit:(m.unit||'').toUpperCase()})):[{qty:'',unit:'',desc:''}]; buildMaterials();
|
||||
pkgAttach=(p.attachments&&p.attachments.length)?p.attachments.map(a=>({...a})):[{doc:'',rev:'',link:''}]; buildAttach();
|
||||
pkgWorkSteps=(p.workSteps&&p.workSteps.length)?p.workSteps.slice():(p.work?String(p.work).split('\n').filter(Boolean):['']); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps();
|
||||
@@ -1745,7 +2069,7 @@ function newPackage(){
|
||||
setRadio('status','Draft');
|
||||
numberDims={}; buildNumberDims();
|
||||
pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange();
|
||||
pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets();
|
||||
pkgAssets=[]; buildAssets();
|
||||
pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials();
|
||||
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
|
||||
pkgWorkSteps=['']; buildWorkSteps();
|
||||
@@ -2111,6 +2435,7 @@ function bootData(){
|
||||
bootSOP();
|
||||
setRadio('status','Draft');
|
||||
loadMembers();
|
||||
initAssetPicker();
|
||||
initWpNavDrawer();
|
||||
renderSavedList();
|
||||
positionSectionNav();
|
||||
|
||||
Reference in New Issue
Block a user