diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index 55e1aa1..83d6b94 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -1131,6 +1131,10 @@ function collectPackage(){ number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'), type:gv('wp_type'), system:gv('wp_system'), location:gv('wp_location'), p6Id:gv('wp_p6_id'), p6Desc:gv('wp_p6_desc'), // CR-001 + // CR-004: codes (paths), not display strings. `location` keeps whatever + // free text the package already had — retained, never overwritten. + building:gv('wp_building'), floor:gv('wp_floor'), sector:gv('wp_sector'), + priority:wpPriorityOf({priority: gv('wp_priority')}), // CR-003 @@ -1235,7 +1239,7 @@ function renderPackage(pkg){ Type${cell(pkg.type)} ${pkg.disciplines&&pkg.disciplines.length?`Discipline(s)${esc(pkg.disciplines.join(', '))}${pkg.split?' [MASTER — split into instances]':''}${pkg.instanceOf?` [instance of ${esc(pkg.parentNumber||'')}]`:''}`:''} System / Facility Code / UPN${cell(pkg.system)} - ${sectionOn('location')?`Location${cell(pkg.location)}`:''} + ${sectionOn('location')?`Location${cell(wpLocationText(pkg))}`:''} ${fieldOn('costCode')?`Cost Code${pkg.cost?esc(pkg.cost)+(costDesc?' — '+esc(costDesc):''):ns()}`:''} ${fieldOn('acumaticaTask')?`Acumatica Task${cell(pkg.wbs)}`:''} Assignees${cell(pkg.assignees)} @@ -1443,6 +1447,142 @@ function updateStickyStatus(){ else { el.className='sticky-status ss-notready'; el.textContent=`⚠ ${r.open} of ${r.total} constraint${r.open===1?'':'s'} open`+(r.blocking.length?` · ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}`:''); } } +// ── LOCATION (CR-004 / T6.3) ───────────────────────────────────────────────── +// Three dependent dropdowns off the project's own taxonomy (CR-005 / T5.4). The +// values stored are PATHS — 'B-ONE/L1/S-A' — not display strings, because CR-018 +// rolls cost up by them and a rollup keyed on a label breaks the day somebody +// fixes a typo in it. A floor's own code is not unique across buildings; its path +// is. +// +// Loaded with include_inactive=true, which is not a contradiction of CR-005's +// "deactivating hides it from new work packages". Two different questions: +// +// what may be CHOSEN active only — the option lists below filter on it +// what may be SHOWN everything, because a package already referencing a +// deactivated value still has to render its label +// +// Nothing here reads localStorage. X5 is explicit: the option lists cannot come +// from the browser's own copy. +let wpLocations = []; +let wpLocationsLoaded = false; + +const LOC_LEVELS = ['building', 'floor', 'sector']; +const LOC_FIELD = {building: 'wp_building', floor: 'wp_floor', sector: 'wp_sector'}; + +function locNode(path){ + return wpLocations.find(n => n.path === path) || null; +} +function locLabel(path){ + const n = locNode(path); + return n ? n.name : (path || ''); +} +// The label as it should READ on a package — a value that has since been +// deactivated says so, rather than looking like any other choice. +function locLabelFull(path){ + const n = locNode(path); + if(!n) return path || ''; + return n.active ? n.name : n.name + ' (no longer offered)'; +} + +async function loadLocations(){ + if(!activeProjectId){ wpLocations = []; wpLocationsLoaded = true; buildLocationPickers(); return; } + try { + const r = await fetch('/api/projects/' + encodeURIComponent(activeProjectId) + + '/locations?include_inactive=true', + {credentials:'same-origin', headers:{'Accept':'application/json'}}); + if(!r.ok) throw new Error('HTTP ' + r.status); + const data = await r.json(); + wpLocations = data.nodes || []; + } catch(e){ + // No cache fallback, deliberately: an option list remembered from last time + // can offer a value this project no longer has, and B4's whole objection is + // to a per-browser answer that looks authoritative. + wpLocations = []; + } + wpLocationsLoaded = true; + buildLocationPickers(); +} + +/* Fill one level's ` + + `` + + opts.map(n => ``).join('') + + ``); + }); + return out.join(''); +} + +// Clearing a parent clears its children here too, for the same reason as on the +// form: a floor filter under a different building filters to nothing and looks +// like an empty project. +function dashSetLocationFilter(level, value){ + const i = LOC_LEVELS.indexOf(level); + dashFilter[level] = value; + for(let k = i + 1; k < LOC_LEVELS.length; k++) dashFilter[LOC_LEVELS[k]] = ''; + dashPage = 0; + renderDashboard(); +} + +// ── CR-018 / T6.4: rollup by building, floor and sector ───────────────────── +// This is why the Acumatica cost code was removed rather than relabelled: the +// tracking dimension the team wants is floor and area, not an accounting code. +// +// Every number here is the SERVER's. The browser does not sum the leaf groups to +// get a floor total — that would be the same per-browser arithmetic B4 removed, +// and it would silently disagree with the header count the moment this browser's +// copy is behind. server/app.py::_location_levels does it once, for everyone. +function renderLocationRollup(m){ + const loc = m && m.by_location; + if(!loc || !loc.levels) return ''; + const unset = loc.unassigned_key || '(unassigned)'; + const levels = (loc.dimensions || []).filter(d => (loc.levels[d] || []).length); + if(!levels.length) return ''; + + // Each row's `path` is a full location path — a floor is `B-ONE/L1` — so it + // resolves straight to a name. A group whose value at this level is unassigned + // is SHOWN, not hidden: it is the row that makes the totals add up, and hiding + // it is how a rollup ends up quietly missing work. + const label = path => path === unset ? 'Unassigned' : locLabelFull(path); + + let h = `
By location
`; + h += `
Totals come from the server and are computed at every level, ` + + `so each table adds up to the project on its own. Packages with no location assigned ` + + `appear as Unassigned rather than being left out.
`; + levels.forEach(dim => { + const rows = loc.levels[dim] || []; + const sum = k => rows.reduce((a, r) => a + (r[k] || 0), 0); + h += `
By ${esc(dim)}
` + + `` + + `` + + `` + + ``; + rows.forEach(r => { + const isUnset = r.path === unset; + h += `` + + `` + + `` + + ``; + }); + // The total row is what makes "reconciles against an unfiltered count" + // checkable by looking rather than by trusting. + h += `` + + `` + + ``; + h += `
${esc(dim.charAt(0).toUpperCase() + dim.slice(1))}WPsReadyOn holdOverdueEst. hrsActual hrs
${esc(label(r.path))}${r.total}${r.release_ready}${r.on_hold}${r.overdue}${r.est_hours}${r.actual_hours}
All ${esc(dim)}s${sum('total')}${sum('release_ready')}${sum('on_hold')}${sum('overdue')}${sum('est_hours')}${sum('actual_hours')}
`; + }); + return h + `
`; +} + function dashHeaderCells(){ return DASH_COLUMNS.map(c => { if(c.sortable === false) return `${esc(c.label)}`; @@ -2270,6 +2499,7 @@ function renderDashboard(){ prog+=`
${esc(g.name)}
${g.pct}% ${g.done}/${g.total}
`; }); prog+=`
Weighted by status (Draft 0 · Scheduled 25 · Issued 50 · In Progress 75 · QC 90 · Closed 100%). Archived packages excluded.
`; h+=prog; + h+=renderLocationRollup(m); // gating panel — what's blocking release, from the server const gated=m.gating||[]; @@ -2290,6 +2520,7 @@ function renderDashboard(){ + ${locFilterSelects()} `; @@ -2300,6 +2531,13 @@ function renderDashboard(){ if(dashFilter.status && p.status!==dashFilter.status) return false; if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false; if(dashFilter.priority && wpPriorityOf(p)!==dashFilter.priority) return false; + // CR-004: filtering by a BUILDING matches every package under it, because the + // stored value is a path. Filtering by the floor code alone could not do that. + if(dashFilter.building && !((p.building||'')===dashFilter.building + || (p.floor||'').indexOf(dashFilter.building+'/')===0)) return false; + if(dashFilter.floor && !((p.floor||'')===dashFilter.floor + || (p.sector||'').indexOf(dashFilter.floor+'/')===0)) return false; + if(dashFilter.sector && (p.sector||'')!==dashFilter.sector) return false; if(q && !((p.number||'')+' '+(p.subject||'')+' '+(p.type||'')).toLowerCase().includes(q)) return false; if(dashFilter.flag==='mine' && p.assigneeId!==myUserId()) return false; if(dashFilter.flag==='ready' && !(!p.split && !wpReleaseBlocked(p) && p.status!=='Closed' && p.status!=='Issue')) return false; @@ -2606,11 +2844,19 @@ function defaultOwnerToMe(){ if(me && Array.from(sel.options).some(o=>o.value===me)) sel.value=me; } +document.addEventListener('DOMContentLoaded', function(){ + LOC_LEVELS.forEach(level => { + const el = document.getElementById(LOC_FIELD[level]); + if(el) el.addEventListener('change', () => onLocationChange(level)); + }); +}); + function bootData(){ loadStore(); // reads the localStorage cache (hydrated from the server below) bootSOP(); setRadio('status','Draft'); loadMembers(); + loadLocations(); // CR-004: the option lists come from the server initWpNavDrawer(); renderSavedList(); positionSectionNav(); diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html index 2e6dee3..ab1f3a9 100644 --- a/html/wp-creation-index.html +++ b/html/wp-creation-index.html @@ -164,7 +164,21 @@ -
+ +
+ +
+ + + +
+
+ +