T6.3/T6.4 - CR-004 and CR-018: picked not typed, and totals that add up
CR-004 and CR-018 are the same change seen from two ends. CR-018 is why the
Acumatica cost code came out rather than being relabelled — the tracking
dimension the team wants is floor and area, not an accounting code — and CR-004
is what makes that dimension exist. Committed together because a rollup keyed on
free text is not a rollup, and structured location with nothing rolling up by it
is a form change nobody asked for.
CR-004 - three dependent dropdowns
Building filters Floor filters Sector, off the project's own taxonomy from
T5.4. Clearing a parent clears its children: not doing that is how a package
ends up filed under a floor that is not in the building it claims.
PATHS are stored, not names and not bare codes. A floor's own code is not
unique across buildings; `B-ONE/L1` is. That is what lets the dashboard filter
by a building and match everything beneath it with a prefix test, and it is
what CR-018 groups on.
The list is fetched with include_inactive=true, which is not a contradiction of
CR-005's "deactivating hides it from new work packages" — they are two
questions. What may be CHOSEN is active only. What may be SHOWN is everything,
because a package already referencing a deactivated value still has to render
its label, and blanking it on open would write the blank back on the next save.
A deactivated value that IS on the package is offered, labelled "(no longer
offered)"; on a fresh package it is not offered at all. Both checked.
X5, checked the way aggregates_check checks its own: localStorage is poisoned
with a fake building and the dropdown is required to ignore it.
wp_location survives as a hidden field. A package written before this keeps
what it said, and the form says so rather than dropping it.
CR-018 - the rollup
LOCATION_DIMENSIONS is now ("building", "floor", "sector"). T4.1's note said
"only this tuple and the keys inside each group change - the response shape
does not", and that held exactly.
Rolled up at EVERY level, server-side, not just at the leaf. "How many on
floor 2" is the question CR-018 asks and it is a level above the leaf groups;
summing them in the browser would be the same per-browser arithmetic B4
removed. Actual Hours rolls up along the same dimensions - that is the field
CR-017 retained, and this is why that decision mattered.
Packages with no location are an explicit "(unassigned)" row, not a gap. The
reason is arithmetic: a group set that silently omits them does not add up to
the project total, and a rollup that does not reconcile is decoration. The
probe checks every level sums to the project total, and to the estimated and
actual hour totals, using distinct primes so a mis-sum cannot land on the
right number by luck.
A package with a building but no floor lands in the floor-level unassigned row
alongside the one with no location at all - which is the honest answer, and is
asserted by its hours rather than by its count.
Free text captured before CR-004 groups under itself as a building rather than
collapsing into unassigned, one level deep. Pretending free text is a
hierarchy would file "FAB / LVL 1" under a building called "FAB / LVL 1".
server/app.py dimensions, _location_levels, hours per group
html/wp-creation-index.html three selects where the text box was
html/wp-creation-app.js the pickers, the filters, the rollup panel
html/wp-creation-styles.css .loc-picker, .loc-rollup
tests/rollup_check.py new - 63 checks
Done when — CR-004
[x] all three render as dropdowns populated from project configuration
[x] dependent filtering works, and clearing a parent clears its children
[x] values persist as codes; confirmed by reading what collectPackage stored
[x] the dashboard filters by each of the three
[x] a work package referencing a deactivated value still renders correctly
[x] all option data comes from the server - proved by poisoning the cache
Done when — CR-018
[x] the dashboard groups and totals by Building, Floor and Sector
[x] totals reconcile against an unfiltered count, at every level
[x] Actual Hours rolls up along the same dimensions
[x] grouping is computed server-side - proved by putting nine fake packages in
localStorage and requiring the panel to show none of them
[x] work packages with no location appear in an explicit unassigned group
No migration: location lives in the work package's JSON data blob like every
other per-package field. No colour literal added.
Verified one at a time
rollup_check 63/63 new
generalinfo_check 49/49
browser_check 71/71
pipeline 43/43
a11y 22/22
aggregates 16/16
f_items F1-F5 FIXED, F6 REPRODUCES (T7.2)
Question for the PR, per CLAUDE.md: the dashboard's location filters and the
rollup both key on the path, so a package saved with free text and no codes is
unreachable by any location filter and sits in its own building-level row. That
is correct and it is also a migration question - whether the existing free-text
locations should be mapped onto the taxonomy once the B100 list arrives, or left
as history. Nothing here decides it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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){
|
||||
<tr><th>Type</th><td>${cell(pkg.type)}</td></tr>
|
||||
${pkg.disciplines&&pkg.disciplines.length?`<tr><th>Discipline(s)</th><td>${esc(pkg.disciplines.join(', '))}${pkg.split?' <span style="color:var(--accent);font-size:10px">[MASTER — split into instances]</span>':''}${pkg.instanceOf?` <span style="color:var(--accent);font-size:10px">[instance of ${esc(pkg.parentNumber||'')}]</span>`:''}</td></tr>`:''}
|
||||
<tr><th>System / Facility Code / UPN</th><td>${cell(pkg.system)}</td></tr>
|
||||
${sectionOn('location')?`<tr><th>Location</th><td>${cell(pkg.location)}</td></tr>`:''}
|
||||
${sectionOn('location')?`<tr><th>Location</th><td>${cell(wpLocationText(pkg))}</td></tr>`:''}
|
||||
${fieldOn('costCode')?`<tr><th>Cost Code</th><td>${pkg.cost?esc(pkg.cost)+(costDesc?' — '+esc(costDesc):''):ns()}</td></tr>`:''}
|
||||
${fieldOn('acumaticaTask')?`<tr><th>Acumatica Task</th><td>${cell(pkg.wbs)}</td></tr>`:''}
|
||||
<tr><th>Assignees</th><td>${cell(pkg.assignees)}</td></tr>
|
||||
@@ -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 <select>, 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 = `<option value="">${esc(placeholder)}</option>`
|
||||
+ options.map(n => `<option value="${esc(n.path)}"${n.path === keep ? ' selected' : ''}>`
|
||||
+ `${esc(locLabelFull(n.path))}</option>`).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(`<select aria-label="Filter by ${esc(label.toLowerCase())}"`
|
||||
+ ` onchange="dashSetLocationFilter('${level}', this.value)">`
|
||||
+ `<option value="">All ${esc(level)}s</option>`
|
||||
+ opts.map(n => `<option value="${esc(n.path)}"${dashFilter[level] === n.path ? ' selected' : ''}>`
|
||||
+ `${esc(locLabelFull(n.path))}</option>`).join('')
|
||||
+ `</select>`);
|
||||
});
|
||||
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 = `<div class="dash-panel"><div class="dash-panel-title">By location</div>`;
|
||||
h += `<div class="field-hint">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 <strong>Unassigned</strong> rather than being left out.</div>`;
|
||||
levels.forEach(dim => {
|
||||
const rows = loc.levels[dim] || [];
|
||||
const sum = k => rows.reduce((a, r) => a + (r[k] || 0), 0);
|
||||
h += `<div class="loc-rollup"><div class="dash-bd-title">By ${esc(dim)}</div>`
|
||||
+ `<table class="dash-table"><thead><tr>`
|
||||
+ `<th>${esc(dim.charAt(0).toUpperCase() + dim.slice(1))}</th>`
|
||||
+ `<th>WPs</th><th>Ready</th><th>On hold</th><th>Overdue</th>`
|
||||
+ `<th>Est. hrs</th><th>Actual hrs</th></tr></thead><tbody>`;
|
||||
rows.forEach(r => {
|
||||
const isUnset = r.path === unset;
|
||||
h += `<tr${isUnset ? ' class="loc-unassigned"' : ''}>`
|
||||
+ `<td class="row-label">${esc(label(r.path))}</td>`
|
||||
+ `<td>${r.total}</td><td>${r.release_ready}</td><td>${r.on_hold}</td>`
|
||||
+ `<td>${r.overdue}</td><td>${r.est_hours}</td><td>${r.actual_hours}</td></tr>`;
|
||||
});
|
||||
// The total row is what makes "reconciles against an unfiltered count"
|
||||
// checkable by looking rather than by trusting.
|
||||
h += `<tr class="loc-total"><td class="row-label">All ${esc(dim)}s</td>`
|
||||
+ `<td>${sum('total')}</td><td>${sum('release_ready')}</td><td>${sum('on_hold')}</td>`
|
||||
+ `<td>${sum('overdue')}</td><td>${sum('est_hours')}</td><td>${sum('actual_hours')}</td></tr>`;
|
||||
h += `</tbody></table></div>`;
|
||||
});
|
||||
return h + `</div>`;
|
||||
}
|
||||
|
||||
function dashHeaderCells(){
|
||||
return DASH_COLUMNS.map(c => {
|
||||
if(c.sortable === false) return `<th>${esc(c.label)}</th>`;
|
||||
@@ -2270,6 +2499,7 @@ function renderDashboard(){
|
||||
prog+=`<div class="prog-row"><div class="prog-name">${esc(g.name)}</div><div class="prog-bar"><div class="prog-fill" style="width:${g.pct}%"></div></div><div class="prog-pct">${g.pct}% <span class="prog-sub">${g.done}/${g.total}</span></div></div>`; });
|
||||
prog+=`<div class="field-hint" style="margin-top:8px">Weighted by status (Draft 0 · Scheduled 25 · Issued 50 · In Progress 75 · QC 90 · Closed 100%). Archived packages excluded.</div></div>`;
|
||||
h+=prog;
|
||||
h+=renderLocationRollup(m);
|
||||
|
||||
// gating panel — what's blocking release, from the server
|
||||
const gated=m.gating||[];
|
||||
@@ -2290,6 +2520,7 @@ function renderDashboard(){
|
||||
<select onchange="dashFilter.status=this.value;dashPage=0;renderDashboard()">${statusOpts}</select>
|
||||
<select onchange="dashFilter.discipline=this.value;dashPage=0;renderDashboard()">${discOpts}</select>
|
||||
<select aria-label="Filter by priority" onchange="dashFilter.priority=this.value;dashPage=0;renderDashboard()">${prioOpts}</select>
|
||||
${locFilterSelects()}
|
||||
<label class="dash-arch-toggle"><input type="checkbox" ${dashShowArchived?'checked':''} onchange="dashToggleArchived(this.checked)"> Show archived${dashShowArchived?' ('+dashArchived.length+')':''}</label>
|
||||
</div>`;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user