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 , filtered to the children of `parent`.
+
+ `keep` is the value already on the package. It is offered even when it is
+ inactive — otherwise opening an old package would silently blank its location
+ and the next save would write that blank back. */
+function fillLocSelect(level, parent, keep){
+ const sel = document.getElementById(LOC_FIELD[level]);
+ if(!sel) return;
+ const wanted = LOC_LEVELS.indexOf(level);
+ const options = wpLocations.filter(n =>
+ n.level === level
+ && (wanted === 0 ? !n.parent_id : (n.path.indexOf(parent + '/') === 0
+ && n.path.split('/').length === wanted + 1))
+ && (n.active || n.path === keep));
+ const placeholder = wanted === 0 ? 'Building…' : (level === 'floor' ? 'Floor…' : 'Sector…');
+ sel.innerHTML = `${esc(placeholder)} `
+ + options.map(n => ``
+ + `${esc(locLabelFull(n.path))} `).join('');
+ // A level with nothing under it is disabled rather than an empty dropdown that
+ // looks broken — and a level whose parent is unset is disabled for the same
+ // reason: there is nothing it could sensibly offer yet.
+ const noParent = wanted > 0 && !parent;
+ sel.disabled = noParent || (!options.length && !keep);
+ sel.title = noParent
+ ? 'Choose a ' + LOC_LEVELS[wanted - 1] + ' first'
+ : (!options.length ? 'This project has no ' + level + ' values configured here' : '');
+ if(keep) sel.value = keep;
+}
+
+function buildLocationPickers(keepValues){
+ const cur = keepValues || {
+ building: (document.getElementById('wp_building') || {}).value || '',
+ floor: (document.getElementById('wp_floor') || {}).value || '',
+ sector: (document.getElementById('wp_sector') || {}).value || '',
+ };
+ fillLocSelect('building', '', cur.building);
+ fillLocSelect('floor', cur.building, cur.building ? cur.floor : '');
+ fillLocSelect('sector', cur.floor, cur.floor ? cur.sector : '');
+ renderLocationNote();
+}
+
+// Clearing a parent clears its children. Not doing this is how a package ends up
+// filed under a floor that is not in the building it claims.
+function onLocationChange(level){
+ const b = (document.getElementById('wp_building') || {}).value || '';
+ const f = (document.getElementById('wp_floor') || {}).value || '';
+ const s = (document.getElementById('wp_sector') || {}).value || '';
+ if(level === 'building') buildLocationPickers({building: b, floor: '', sector: ''});
+ else if(level === 'floor') buildLocationPickers({building: b, floor: f, sector: ''});
+ else buildLocationPickers({building: b, floor: f, sector: s});
+}
+
+function renderLocationNote(){
+ const note = document.getElementById('wp_location_note');
+ if(!note) return;
+ const legacy = (document.getElementById('wp_location') || {}).value || '';
+ if(!wpLocationsLoaded){ note.textContent = 'Loading this project\'s location list…'; return; }
+ if(!wpLocations.length){
+ note.textContent = 'This project has no location list yet — configure it on step 11 of the SOP.';
+ return;
+ }
+ // A package written before CR-004 has free text and no codes. It is SHOWN
+ // rather than dropped: the data is retained, which is the rule everywhere else
+ // in this plan too.
+ note.textContent = legacy && !(document.getElementById('wp_building') || {}).value
+ ? 'Previously recorded as free text: “' + legacy + '”. Pick the structured values to replace it.'
+ : '';
+}
+
+function wpLocationOf(p){
+ return {building: p.building || '', floor: p.floor || '', sector: p.sector || ''};
+}
+// One readable line from whatever a package holds — structured if it has it,
+// the old free text if that is all there is.
+function wpLocationText(p){
+ const parts = LOC_LEVELS.map(l => p[l]).filter(Boolean).map(locLabelFull);
+ if(parts.length) return parts.join(' / ');
+ return p.location || '';
+}
+
// ── SECTION TOGGLES (CR-006 / T5.5) ──────────────────────────────────────────
// Which sections this project uses. The list is wp-sections.js, shared with the
// SOP wizard; this file owns the mapping from a section id to the DOM it governs
@@ -1843,6 +1983,7 @@ function loadPackageIntoForm(p){
const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';};
set('wp_subject',p.subject); set('wp_system',p.system); set('wp_location',p.location);
set('wp_p6_id',p.p6Id); set('wp_p6_desc',p.p6Desc); // CR-001
+ buildLocationPickers(wpLocationOf(p)); // CR-004
set('wp_priority', wpPriorityOf(p)); // CR-003
set('wp_assignees',p.assignees); set('wp_distribution',p.distribution);
loadPeopleFromPkg(p);
@@ -1932,6 +2073,7 @@ function newPackage(){
['wp_subject','wp_system','wp_location','wp_p6_id','wp_p6_desc','wp_wbs','wp_assignee','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value='';
const prio=document.getElementById('wp_priority'); if(prio) prio.value=WP_PRIORITY_DEFAULT; // CR-003
+ buildLocationPickers({building:'', floor:'', sector:''}); // CR-004
['wp_iff','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
onClashChange();
pkgKind='iwp'; applyKind();
@@ -1973,7 +2115,7 @@ const WPData = {
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; },
};
-let dashFilter={status:'',discipline:'',q:'',flag:'',priority:''};
+let dashFilter={status:'',discipline:'',q:'',flag:'',priority:'',building:'',floor:'',sector:''};
// A write has to reach the server before the server can count it. The outbox is
// the only path writes take, so flush it and then re-read - otherwise the refresh
// races the push and shows the pre-write totals, which is the same stale number
@@ -1995,7 +2137,7 @@ function dashRefreshAfterWrite(){
const DASH_FLAGS = ['ready', 'onhold', 'overdue', 'mine'];
function dashToggleFlag(f, opts){
- if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:'',priority:''}; }
+ if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:'',priority:'',building:'',floor:'',sector:''}; }
else { dashFilter.flag = dashFilter.flag===f ? '' : f; }
dashPage=0;
// S3: which slice of the board you are looking at is state, so it belongs in
@@ -2090,6 +2232,93 @@ function dashSortRows(rows){
});
}
+// CR-004: one filter per level, dependent the same way the form's are — picking
+// a building narrows the floors offered. Rendered only when the project has a
+// taxonomy: three empty dropdowns on a project that has not configured one are
+// three controls that cannot do anything.
+function locFilterSelects(){
+ if(!wpLocations.length) return '';
+ const out = [];
+ LOC_LEVELS.forEach((level, i) => {
+ const parent = i === 0 ? '' : dashFilter[LOC_LEVELS[i - 1]];
+ if(i > 0 && !parent) return; // nothing to narrow to yet
+ const opts = wpLocations.filter(n =>
+ n.level === level
+ && (i === 0 ? !n.parent_id : (n.path.indexOf(parent + '/') === 0
+ && n.path.split('/').length === i + 1)));
+ if(!opts.length) return;
+ const label = level.charAt(0).toUpperCase() + level.slice(1);
+ out.push(``
+ + `All ${esc(level)}s `
+ + opts.map(n => ``
+ + `${esc(locLabelFull(n.path))} `).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)}
`
+ + `
`
+ + `${esc(dim.charAt(0).toUpperCase() + dim.slice(1))} `
+ + `WPs Ready On hold Overdue `
+ + `Est. hrs Actual hrs `;
+ rows.forEach(r => {
+ const isUnset = r.path === unset;
+ h += ``
+ + `${esc(label(r.path))} `
+ + `${r.total} ${r.release_ready} ${r.on_hold} `
+ + `${r.overdue} ${r.est_hours} ${r.actual_hours} `;
+ });
+ // The total row is what makes "reconciles against an unfiltered count"
+ // checkable by looking rather than by trusting.
+ h += `All ${esc(dim)}s `
+ + `${sum('total')} ${sum('release_ready')} ${sum('on_hold')} `
+ + `${sum('overdue')} ${sum('est_hours')} ${sum('actual_hours')} `;
+ h += `
`;
+ });
+ 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(){
${statusOpts}
${discOpts}
${prioOpts}
+ ${locFilterSelects()}
Show archived${dashShowArchived?' ('+dashArchived.length+')':''}
`;
@@ -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 @@
- Location
+
+
+
Locationi
+
+
+
+
+
+
+
+