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();
|
||||
|
||||
@@ -164,7 +164,21 @@
|
||||
<!-- Location is its own CR-006 section while still living inside General
|
||||
Information's grid. CR-004 gives it structured building/floor/sector
|
||||
fields of its own in wave 6; only this wrapper's contents change then. -->
|
||||
<div class="field" id="location-card"><label>Location</label><input type="text" id="wp_location" placeholder="building / level / sector / room"></div>
|
||||
<!-- CR-004. Three dependent dropdowns off the project's own taxonomy
|
||||
(CR-005 / T5.4), not free text: the values are what CR-018 rolls cost
|
||||
up by, and a rollup keyed on what somebody typed is not a rollup.
|
||||
wp_location survives as a hidden field so a package written before
|
||||
this keeps what it said — CLAUDE.md, removals are hidden not deleted. -->
|
||||
<div class="field" id="location-card">
|
||||
<label for="wp_building">Location<span class="help-tip" data-tip="Building, floor and sector come from this project's own location list, configured on step 11 of the SOP. Cost and progress roll up by these, which is why they are picked rather than typed.">i</span></label>
|
||||
<div class="loc-picker">
|
||||
<select id="wp_building" aria-label="Building"></select>
|
||||
<select id="wp_floor" aria-label="Floor"></select>
|
||||
<select id="wp_sector" aria-label="Sector"></select>
|
||||
</div>
|
||||
<div class="field-hint" id="wp_location_note"></div>
|
||||
<input type="hidden" id="wp_location">
|
||||
</div>
|
||||
<!-- CR-002 hides these two rather than deleting them: the columns, the model
|
||||
and every value already captured stay exactly as they are, and another
|
||||
project can turn them back on without a code change. The ids are what
|
||||
|
||||
@@ -936,6 +936,21 @@
|
||||
color:var(--accent-amber); border-color:var(--wp-status-warning-border-a);
|
||||
background:var(--accent-amber-dim);
|
||||
}
|
||||
/* CR-004: three dependent dropdowns where a free-text box used to be. They wrap
|
||||
rather than shrink — three selects squeezed onto one 390px line are three
|
||||
controls nobody can read the options of. */
|
||||
/* CR-018: one table per level, each adding up to the project on its own. */
|
||||
.loc-rollup { margin-top:12px; }
|
||||
.loc-rollup .dash-table { margin-top:4px; }
|
||||
/* The unassigned row and the total row are marked structurally, not only by
|
||||
colour: one is italic, the other bold with a rule above it. */
|
||||
.dash-table tr.loc-unassigned td { font-style:italic; color:var(--text-muted); }
|
||||
.dash-table tr.loc-total td { font-weight:700; border-top:2px solid var(--border-strong); }
|
||||
|
||||
.loc-picker { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.loc-picker select { flex:1 1 120px; min-width:0; }
|
||||
.loc-picker select:disabled { opacity:.55; cursor:not-allowed; }
|
||||
|
||||
.prio-urgent {
|
||||
color:var(--wp-btn-danger-fill-fg); border-color:var(--red); background:var(--red);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user