T5.4 - CR-005: a per-project location taxonomy, stored as codes
CLAUDE.md lists CR-005 among the change requests that get "silently half-built if
you treat them as frontend-only". This is the server half and the wizard half
together: a new table, four routes, an Alembic revision, and step 11.
CODES, NOT DISPLAY STRINGS, because CR-018 rolls cost up by these values and a
rollup keyed on a label breaks the day somebody fixes a typo in it. Two columns
carry that: `code` is a node's own slug, derived once at import and never
recomputed; `path` is the full slug path, unique per project, and is what a work
package will store. Renaming a value changes `name` alone - the probe renames a
floor and demands its path comes back byte-identical, with its children's paths
intact.
DEACTIVATE, NEVER DELETE. There is no DELETE route, and the probe checks for its
absence (405) rather than trusting that nobody added one. Deactivating hides a
value from new work packages and cascades DOWN, because a floor nobody can pick
must not keep offering its sectors. Reactivating walks UP only - a sector may
have been switched off for its own reasons, and silently resurrecting it would
undo a decision nobody made twice. That asymmetry is deliberate and is pinned by
a named check so it does not get "fixed" into a surprise.
Import reports rather than merges. Rejected rows come back with the SOURCE line
number and a reason; duplicates are listed as duplicates, separated into "already
in this project" and "already on line N of this import". Reusing a parent is not
a duplicate - B1/L2/1P and B1/L2/2P share a building and a floor by design, and
only the full path repeating counts. Re-importing a deactivated value brings the
same row back rather than creating a second one; the probe checks the id.
One parser, on the server. A CSV is read in the browser and posted as text
exactly as a paste is, so "what does a blank column mean" has one answer.
Comma, semicolon and tab all work - a paste out of a spreadsheet is tab
separated and a saved CSV is not, and which one somebody has is a question the
machine can answer.
No guessed floor names. IMPLEMENTATION.md section 8 says the B100 list has not
been supplied. The seeded sample has "Sample" inside every string, and the probe
greps html/ and server/ for a location-shaped assignment containing any of the
review's real names.
server/models.py LocationNode
server/alembic/versions/e2a4c7d91b30_location_taxonomy.py
server/app.py GET/POST/PATCH + import, parser, slug
html/work-package-suite.html step 11, an 11th rail button
html/work-package-suite-app.js the step's logic; LAST_STEP replaces 10
html/work-package-suite-styles.css the list, the report
html/theme-light.css .field-error, now declared once
tests/locations_check.py new - 58 checks
tests/stepper_check.py STEP_COUNT 10 -> 11
Done when
[x] CSV upload and paste both work and report rejected rows with reasons
[x] duplicates are detected and reported rather than silently merged
[x] values are editable after import - rename, add, deactivate
[x] deactivating hides it from new work packages; an existing package
referencing it still resolves, because the row is retained
[x] values are stored as codes suitable for grouping
[x] no guessed real-world floor names exist anywhere in the code
Two decisions worth disagreeing with
Step 11, appended, not step 2, inserted. Locations belong beside Project by
subject. Renumbering 2-10 would touch every sop-step-N id, every
collectStepData case, every gate key and the analytics history - a large
silent-mismatch surface for an ordering change. The count now lives in one
place (LAST_STEP), so reordering later is cheap.
Any project member may edit the list, not only a Project Admin. It matches how
the SOP baseline itself is authored: the Project Admin gate is on CHANGING a
completed SOP, not on writing one. If the location list should be tighter than
the SOP it belongs to, that is a product call.
Verified one at a time
locations_check 58/58 new
stepper_check 70/70 (11 steps)
browser_check 71/71
a11y 22/22 sop now rings 38 focusable elements
url_state 23/23
autosave 34/34
aggregates 16/16
pipeline 43/43
launcher 58/58
f_items F1-F5 FIXED, F6 REPRODUCES (T7.2)
alembic upgrade / downgrade / upgrade all clean on a throwaway SQLite
file, and the migrated schema matches Base.metadata.create_all
column for column - dev auto-creates and production migrates,
so a divergence between the two is invisible until it ships
.field-error was declared in two page sheets by the end of T5.2 and would have
been three by T5.8, so it moved to theme-light.css. No colour literal added
anywhere: still 0 across all page sheets and inline blocks.
Question for the PR, per CLAUDE.md: the levels are fixed at building / floor /
sector. Micron's floors behave like buildings, which this handles by letting a
project use whichever levels it needs - but a job that wants a fourth level, or
different names for the three, cannot say so. Whether that is worth a
per-project level vocabulary is a product question; the schema would take it
without a migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -311,11 +311,8 @@
|
||||
BL-014 as controls falling back to the UA focus ring; two of that entry's
|
||||
four sites no longer exist. */
|
||||
|
||||
/* An inline error at the field it belongs to, rather than a native dialog that
|
||||
names no field and highlights nothing (C1, and the pattern T5.8 uses in the
|
||||
wizard). :empty so the element can sit in the markup and announce on change. */
|
||||
.field-error { color: var(--cds-text-error); font-size: 12px; font-weight: 600; margin-top: 0.3rem; }
|
||||
.field-error:empty { display: none; }
|
||||
/* .field-error — the inline error beside a field — is declared once, in
|
||||
theme-light.css, because three surfaces in wave 5 grew one. */
|
||||
|
||||
/* PIPELINE STRIP — B4 surface (T5.3)
|
||||
Four counts, every one of them from /api/wps/metrics. There is deliberately
|
||||
|
||||
@@ -508,6 +508,22 @@ input, textarea, select {
|
||||
.wp-draft-retry:hover { background: var(--wp-btn-danger-soft-bg); }
|
||||
.wp-draft-retry:focus-visible { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
|
||||
|
||||
/* An inline validation error, at the field it belongs to (C1).
|
||||
----------------------------------------------------------------------------
|
||||
Three surfaces grew one of these within wave 5 — the launcher's create-project
|
||||
form (T5.2), the SOP wizard's location list (T5.4), and T5.8's step validation
|
||||
— so it lives here rather than as three page-sheet rules that would drift.
|
||||
The markup pairs it with aria-describedby and role="alert" on the element; the
|
||||
:empty rule is what lets it sit in the page permanently and announce on change
|
||||
rather than being created at the moment somebody needs to hear it. */
|
||||
.field-error {
|
||||
color: var(--cds-text-error);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.field-error:empty { display: none; }
|
||||
|
||||
/* ============================================================================
|
||||
FOCUS (S12 / T4.7)
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
@@ -309,7 +309,7 @@ window.addEventListener('DOMContentLoaded',()=>{
|
||||
else if(tab === 'wp' || tab === 'sop') switchTool(tab, {fromUrl:true});
|
||||
else if(_deepLinkWp) switchTool('wp', {fromUrl:true});
|
||||
const bootStep = parseInt(params.get('step'), 10);
|
||||
if(bootStep >= 1 && bootStep <= 10 && currentTool === 'sop') goToStep(bootStep, {fromUrl:true});
|
||||
if(bootStep >= 1 && bootStep <= LAST_STEP && currentTool === 'sop') goToStep(bootStep, {fromUrl:true});
|
||||
}
|
||||
if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
|
||||
ProjectData.pullProject(projId).then(afterPull).catch(afterPull);
|
||||
@@ -439,7 +439,7 @@ function loadSampleData(){
|
||||
|
||||
// Switch to step 1. Every step now holds sample content, so the rail marks them
|
||||
// all as visited — otherwise a fully populated wizard shows ten unstarted steps.
|
||||
for(let i = 1; i <= 10; i++) _visitedSteps.add(i);
|
||||
for(let i = 1; i <= LAST_STEP; i++) _visitedSteps.add(i);
|
||||
currentStep = 1;
|
||||
updateStepUI();
|
||||
updateProjectDisplay();
|
||||
@@ -472,7 +472,7 @@ function restoreSavedSOP(){
|
||||
repopulateForm();
|
||||
// A completed SOP has been all the way through: the rail marks every step done
|
||||
// rather than showing ten unstarted steps over a finished configuration.
|
||||
for(let i = 1; i <= 10; i++) _visitedSteps.add(i);
|
||||
for(let i = 1; i <= LAST_STEP; i++) _visitedSteps.add(i);
|
||||
|
||||
if(typeof onSOPReady === 'function') onSOPReady(sop);
|
||||
}
|
||||
@@ -1213,6 +1213,294 @@ function addSource(){
|
||||
renderSources();
|
||||
}
|
||||
|
||||
// ── STEP 11: LOCATION LIST (CR-005) ───────────────────────────────────────────
|
||||
// Server-backed throughout. CLAUDE.md: "If a task touches one of these and you
|
||||
// find yourself writing to localStorage, you are building the wrong thing."
|
||||
// CR-018 rolls cost up by these values, and a taxonomy each browser keeps its own
|
||||
// copy of cannot be rolled up by anything.
|
||||
//
|
||||
// The list is fetched with include_inactive=true here, deliberately: this is the
|
||||
// screen where you MANAGE the list, so a value somebody deactivated has to be
|
||||
// visible in order to be brought back. The work package form asks for the default
|
||||
// (active only), which is what makes deactivating hide it from new packages.
|
||||
|
||||
// Obviously fake, and labelled as such in the values themselves. IMPLEMENTATION.md
|
||||
// section 8: the B100 floor and area list has not been supplied, and hardcoding a
|
||||
// guess would put invented Micron floor names into the repository. "Sample" is in
|
||||
// every string so nobody can mistake one for real data.
|
||||
const LOCATION_SAMPLE = [
|
||||
'Sample Building One,Sample Level 1,Sample Sector A',
|
||||
'Sample Building One,Sample Level 1,Sample Sector B',
|
||||
'Sample Building One,Sample Level 2,Sample Sector A',
|
||||
'Sample Building Two,Sample Level 1,Sample Sector A',
|
||||
].join('\n');
|
||||
|
||||
let _locNodes = [];
|
||||
let _locLoaded = false;
|
||||
|
||||
function locProjectId(){
|
||||
try {
|
||||
const fromUrl = new URLSearchParams(window.location.search).get('project');
|
||||
if(fromUrl) return fromUrl;
|
||||
if(typeof ProjectData !== 'undefined' && ProjectData.getActiveId) return ProjectData.getActiveId() || '';
|
||||
} catch(e){}
|
||||
return '';
|
||||
}
|
||||
function locApi(suffix){
|
||||
return '/api/projects/' + encodeURIComponent(locProjectId()) + '/locations' + (suffix || '');
|
||||
}
|
||||
function locEsc(v){ return escAttr(v); }
|
||||
|
||||
// Every message on this step goes through here so the role is decided in one
|
||||
// place: a report that lost rows interrupts (T4.5), a clean one does not.
|
||||
function locSay(html, isProblem){
|
||||
const el = document.getElementById('loc-report');
|
||||
if(!el) return;
|
||||
el.setAttribute('role', isProblem ? 'alert' : 'status');
|
||||
el.innerHTML = html || '';
|
||||
el.classList.toggle('is-problem', !!isProblem);
|
||||
}
|
||||
|
||||
function locSetAddError(msg){
|
||||
const el = document.getElementById('loc-add-err');
|
||||
if(el) el.textContent = msg || '';
|
||||
const input = document.getElementById('loc-add-name');
|
||||
if(input){
|
||||
if(msg) input.setAttribute('aria-invalid', 'true');
|
||||
else input.removeAttribute('aria-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function locLoad(force){
|
||||
const tool = document.getElementById('loc-tool');
|
||||
const warn = document.getElementById('loc-noproject');
|
||||
const pid = locProjectId();
|
||||
if(!pid){
|
||||
if(tool) tool.style.display = 'none';
|
||||
if(warn){
|
||||
warn.style.display = '';
|
||||
warn.textContent = 'Open this wizard from a project to configure its location list. '
|
||||
+ 'The list is stored against the project on the server, not in this browser.';
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
if(tool) tool.style.display = '';
|
||||
if(warn) warn.style.display = 'none';
|
||||
if(_locLoaded && !force) return Promise.resolve();
|
||||
return fetch(locApi('?include_inactive=true'), {headers:{'Accept':'application/json'}})
|
||||
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(data => { _locNodes = data.nodes || []; _locLoaded = true; locRender(); })
|
||||
.catch(err => {
|
||||
_locLoaded = false;
|
||||
locRender();
|
||||
locSay('⚠ Could not load the location list — ' + locEsc((err && err.message) || 'offline')
|
||||
+ '. It is stored on the server, so nothing local is shown in its place.', true);
|
||||
});
|
||||
}
|
||||
|
||||
function locRender(){
|
||||
const list = document.getElementById('loc-list');
|
||||
const count = document.getElementById('loc-count');
|
||||
if(!list) return;
|
||||
const active = _locNodes.filter(n => n.active);
|
||||
if(count){
|
||||
count.textContent = _locNodes.length
|
||||
? `${active.length} value${active.length===1?'':'s'} in use`
|
||||
+ (_locNodes.length > active.length ? `, ${_locNodes.length - active.length} deactivated` : '')
|
||||
: '';
|
||||
}
|
||||
if(!_locNodes.length){
|
||||
list.innerHTML = '<p class="field-hint">Nothing configured yet. Paste or upload a list above, '
|
||||
+ 'or add values one at a time.</p>';
|
||||
locRenderParentPicker();
|
||||
return;
|
||||
}
|
||||
// Ordered by path from the server, so a plain walk is already the tree order.
|
||||
list.innerHTML = _locNodes.map(n => {
|
||||
const depth = (n.path.match(/\//g) || []).length;
|
||||
return `<div class="loc-row loc-depth-${depth}${n.active ? '' : ' is-off'}" data-id="${locEsc(n.id)}">
|
||||
<span class="loc-level">${locEsc(n.level)}</span>
|
||||
<input class="loc-name" type="text" value="${locEsc(n.name)}"
|
||||
aria-label="Name for ${locEsc(n.path)}" data-id="${locEsc(n.id)}">
|
||||
<code class="loc-code" title="The stable code cost rolls up by. Renaming does not change it.">${locEsc(n.path)}</code>
|
||||
<label class="loc-toggle"><input type="checkbox" class="loc-active" data-id="${locEsc(n.id)}"
|
||||
${n.active ? 'checked' : ''}><span>In use</span></label>
|
||||
</div>`;
|
||||
}).join('');
|
||||
locRenderParentPicker();
|
||||
}
|
||||
|
||||
// What a hand-added value can hang off. A building has nothing above it, so the
|
||||
// empty option is a real choice rather than a prompt.
|
||||
function locRenderParentPicker(){
|
||||
const sel = document.getElementById('loc-add-parent');
|
||||
if(!sel) return;
|
||||
const keep = sel.value;
|
||||
const opts = ['<option value="">(nothing — add a building)</option>'].concat(
|
||||
_locNodes.filter(n => n.active && n.level !== 'sector')
|
||||
.map(n => `<option value="${locEsc(n.id)}">${locEsc(n.path)} — adds a ${n.level === 'building' ? 'floor' : 'sector'}</option>`)
|
||||
);
|
||||
sel.innerHTML = opts.join('');
|
||||
if(keep && Array.from(sel.options).some(o => o.value === keep)) sel.value = keep;
|
||||
}
|
||||
|
||||
function locReport(res){
|
||||
const bits = [];
|
||||
const problem = (res.rejected || []).length > 0 || (res.duplicates || []).length > 0;
|
||||
const verb = res.dry_run ? 'would be added' : 'added';
|
||||
bits.push(`<p class="loc-report-line"><strong>${res.read} row${res.read===1?'':'s'} read.</strong> `
|
||||
+ `${(res.created||[]).length} value${(res.created||[]).length===1?'':'s'} ${verb}`
|
||||
+ ((res.reactivated||[]).length ? `, ${res.reactivated.length} brought back into use` : '')
|
||||
+ `.</p>`);
|
||||
const rowList = (title, rows, fmt) => {
|
||||
if(!rows || !rows.length) return '';
|
||||
return `<div class="loc-report-group"><div class="loc-report-title">${locEsc(title)} (${rows.length})</div>`
|
||||
+ '<ul class="loc-report-list">' + rows.map(fmt).join('') + '</ul></div>';
|
||||
};
|
||||
bits.push(rowList('Rejected', res.rejected,
|
||||
r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.text)}</code> — ${locEsc(r.reason)}</li>`));
|
||||
bits.push(rowList('Duplicates, not merged', res.duplicates,
|
||||
r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.path)}</code> — ${locEsc(r.reason)}</li>`));
|
||||
if(!problem && !(res.created||[]).length && !(res.reactivated||[]).length){
|
||||
bits.push('<p class="loc-report-line">Nothing to do — every row is already on this project.</p>');
|
||||
}
|
||||
locSay(bits.join(''), problem);
|
||||
}
|
||||
|
||||
function locImport(dryRun){
|
||||
const text = (document.getElementById('loc-paste') || {}).value || '';
|
||||
if(!text.trim()){
|
||||
locSay('Paste some rows or choose a CSV file first.', true);
|
||||
return;
|
||||
}
|
||||
if(!locProjectId()){ locLoad(); return; }
|
||||
locSay('Checking…', false);
|
||||
fetch(locApi('/import'), {
|
||||
method: 'POST', headers: {'Content-Type':'application/json','Accept':'application/json'},
|
||||
body: JSON.stringify({text: text, dry_run: !!dryRun}),
|
||||
})
|
||||
.then(r => r.json().then(j => ({ok: r.ok, status: r.status, body: j})))
|
||||
.then(res => {
|
||||
if(!res.ok){
|
||||
locSay('⚠ Import refused — ' + locEsc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||
return;
|
||||
}
|
||||
locReport(res.body);
|
||||
if(!dryRun) return locLoad(true);
|
||||
})
|
||||
.catch(err => locSay('⚠ Could not reach the server — ' + locEsc((err && err.message) || 'offline')
|
||||
+ '. Nothing was imported.', true));
|
||||
}
|
||||
|
||||
function locAdd(){
|
||||
const nameEl = document.getElementById('loc-add-name');
|
||||
const parentEl = document.getElementById('loc-add-parent');
|
||||
const name = ((nameEl || {}).value || '').trim();
|
||||
if(!name){
|
||||
locSetAddError('Enter a name for the value you are adding.');
|
||||
if(nameEl) nameEl.focus();
|
||||
return;
|
||||
}
|
||||
locSetAddError('');
|
||||
const parentId = (parentEl || {}).value || '';
|
||||
const parent = _locNodes.find(n => n.id === parentId);
|
||||
const level = !parent ? 'building' : (parent.level === 'building' ? 'floor' : 'sector');
|
||||
fetch(locApi(''), {
|
||||
method: 'POST', headers: {'Content-Type':'application/json','Accept':'application/json'},
|
||||
body: JSON.stringify({level: level, parent_id: parentId || null, name: name}),
|
||||
})
|
||||
.then(r => r.json().then(j => ({ok: r.ok, status: r.status, body: j})))
|
||||
.then(res => {
|
||||
if(!res.ok){
|
||||
locSetAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')'));
|
||||
if(nameEl) nameEl.focus();
|
||||
return;
|
||||
}
|
||||
if(nameEl) nameEl.value = '';
|
||||
locSay(`Added <code>${locEsc(res.body.path)}</code>.`, false);
|
||||
return locLoad(true);
|
||||
})
|
||||
.catch(err => locSetAddError('Could not reach the server — ' + ((err && err.message) || 'offline')));
|
||||
}
|
||||
|
||||
function locPatch(id, patch, describe){
|
||||
return fetch(locApi('/' + encodeURIComponent(id)), {
|
||||
method: 'PATCH', headers: {'Content-Type':'application/json','Accept':'application/json'},
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
.then(r => r.json().then(j => ({ok: r.ok, status: r.status, body: j})))
|
||||
.then(res => {
|
||||
if(!res.ok){
|
||||
locSay('⚠ ' + locEsc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||
return locLoad(true);
|
||||
}
|
||||
locSay(describe(res.body), false);
|
||||
return locLoad(true);
|
||||
})
|
||||
.catch(err => locSay('⚠ Could not reach the server — ' + locEsc((err && err.message) || 'offline'), true));
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
const paste = document.getElementById('loc-paste');
|
||||
const file = document.getElementById('loc-file');
|
||||
const btn = id => document.getElementById(id);
|
||||
|
||||
if(btn('loc-file-btn')) btn('loc-file-btn').addEventListener('click', () => file && file.click());
|
||||
if(file) file.addEventListener('change', function(ev){
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
if(!f) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
// One parser, on the server. Reading the file here and posting its text is
|
||||
// what stops "what does a blank column mean" having two answers.
|
||||
if(paste) paste.value = String(reader.result || '');
|
||||
locSay(`Read <strong>${locEsc(f.name)}</strong>. Check it, then import.`, false);
|
||||
};
|
||||
reader.onerror = () => locSay('⚠ Could not read that file.', true);
|
||||
reader.readAsText(f);
|
||||
ev.target.value = '';
|
||||
});
|
||||
if(btn('loc-check-btn')) btn('loc-check-btn').addEventListener('click', () => locImport(true));
|
||||
if(btn('loc-import-btn')) btn('loc-import-btn').addEventListener('click', () => locImport(false));
|
||||
if(btn('loc-sample-btn')) btn('loc-sample-btn').addEventListener('click', function(){
|
||||
if(paste) paste.value = LOCATION_SAMPLE;
|
||||
locSay('Sample values loaded into the box — obviously fake, and safe to import '
|
||||
+ 'while the real list is being collected. Check them, then import.', false);
|
||||
});
|
||||
if(btn('loc-add-btn')) btn('loc-add-btn').addEventListener('click', locAdd);
|
||||
const addName = btn('loc-add-name');
|
||||
if(addName) addName.addEventListener('keydown', e => { if(e.key === 'Enter'){ e.preventDefault(); locAdd(); } });
|
||||
|
||||
const list = document.getElementById('loc-list');
|
||||
if(list){
|
||||
// Rename on blur, not on every keystroke: a rename is a write, and one per
|
||||
// character would be a hundred writes for a hundred-character name.
|
||||
list.addEventListener('change', function(e){
|
||||
const t = e.target;
|
||||
if(!t) return;
|
||||
if(t.classList.contains('loc-active')){
|
||||
const node = _locNodes.find(n => n.id === t.dataset.id);
|
||||
locPatch(t.dataset.id, {active: t.checked},
|
||||
b => b.active
|
||||
? `<code>${locEsc(b.path)}</code> is back in use.`
|
||||
: `<code>${locEsc(b.path)}</code> is no longer offered on new work packages. `
|
||||
+ 'Existing packages that reference it are unchanged.');
|
||||
if(node) node.active = t.checked;
|
||||
return;
|
||||
}
|
||||
if(t.classList.contains('loc-name')){
|
||||
const node = _locNodes.find(n => n.id === t.dataset.id);
|
||||
const next = (t.value || '').trim();
|
||||
if(!node || next === node.name){ if(node) t.value = node.name; return; }
|
||||
if(!next){ t.value = node.name; return; }
|
||||
locPatch(t.dataset.id, {name: next},
|
||||
b => `Renamed to “${locEsc(b.name)}”. Its code <code>${locEsc(b.path)}</code> is unchanged, `
|
||||
+ 'so work packages already using it still point at the same value.');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── STEP GATES (A4 / S9) ──────────────────────────────────────────────────────
|
||||
// validateStep() guarded steps 1, 5 and 6 with three hand-written conditions and
|
||||
// three hand-written messages. The rail needs the same answer for every step, not
|
||||
@@ -1237,7 +1525,9 @@ const STEP_GATES = {
|
||||
};
|
||||
|
||||
const STEP_LABELS = {1:'Project', 2:'Team', 3:'Sign-offs', 4:'WP types', 5:'Governance',
|
||||
6:'Quality', 7:'Platforms', 8:'Sequence', 9:'Constraints', 10:'Sources'};
|
||||
6:'Quality', 7:'Platforms', 8:'Sequence', 9:'Constraints', 10:'Sources',
|
||||
11:'Locations'};
|
||||
const LAST_STEP = 11;
|
||||
|
||||
// Steps you have actually been on. A stepper's tick means "done", and a step you
|
||||
// have never opened is not done however its defaults happen to read — step 6's QC
|
||||
@@ -1307,7 +1597,7 @@ if(typeof WPUrl !== 'undefined'){
|
||||
|
||||
function nextStep(){
|
||||
if(!validateStep(currentStep)) return;
|
||||
if(currentStep < 10){
|
||||
if(currentStep < LAST_STEP){
|
||||
railMessage('');
|
||||
currentStep++;
|
||||
_visitedSteps.add(currentStep);
|
||||
@@ -1375,6 +1665,8 @@ function renderStepRail(){
|
||||
});
|
||||
const pos = document.getElementById('step-rail-pos');
|
||||
if(pos) pos.textContent = currentStep;
|
||||
const total = document.getElementById('step-rail-total');
|
||||
if(total) total.textContent = LAST_STEP;
|
||||
const here = document.getElementById('step-rail-here');
|
||||
if(here) here.textContent = STEP_LABELS[currentStep] || '';
|
||||
}
|
||||
@@ -1475,10 +1767,15 @@ function updateStepUI(){
|
||||
|
||||
renderStepRail();
|
||||
|
||||
// The location list lives on the server, so it is fetched when the step is
|
||||
// actually opened rather than on every page load. locLoad() is idempotent and
|
||||
// no-ops once it has the list.
|
||||
if(currentStep === 11 && typeof locLoad === 'function') locLoad();
|
||||
|
||||
// Update buttons
|
||||
document.getElementById('sop-prev-btn').disabled = currentStep === 1;
|
||||
document.getElementById('sop-next-btn').style.display = currentStep < 10 ? 'block' : 'none';
|
||||
document.getElementById('sop-complete-btn').style.display = currentStep === 10 ? 'block' : 'none';
|
||||
document.getElementById('sop-next-btn').style.display = currentStep < LAST_STEP ? 'block' : 'none';
|
||||
document.getElementById('sop-complete-btn').style.display = currentStep === LAST_STEP ? 'block' : 'none';
|
||||
|
||||
// Collect form data
|
||||
collectStepData();
|
||||
@@ -1558,7 +1855,7 @@ function completeSOP(){
|
||||
'Ask a project admin to make the change — the SOP is the baseline every work package inherits.');
|
||||
return;
|
||||
}
|
||||
if(!validateStep(10)) return;
|
||||
if(!validateStep(LAST_STEP)) return;
|
||||
collectStepData();
|
||||
|
||||
sop = {
|
||||
@@ -1800,7 +2097,7 @@ function showAnalytics(){
|
||||
const s = analyticsSummary();
|
||||
const fmtMin = ms => (ms/60000).toFixed(1)+' min';
|
||||
let txt = `USAGE LOGS\n\nSessions: ${s.sessions} Events: ${s.total}\nRange: ${s.first?new Date(s.first).toLocaleString():'—'} → ${s.last?new Date(s.last).toLocaleString():'—'}\n\nStep views:\n`;
|
||||
for(let i=1;i<=10;i++) txt += ` Step ${i}: ${s.byStep[i]||0} views` + (s.dwell[i]?`, ${fmtMin(s.dwell[i])} total`:'') + `\n`;
|
||||
for(let i=1;i<=LAST_STEP;i++) txt += ` Step ${i}: ${s.byStep[i]||0} views` + (s.dwell[i]?`, ${fmtMin(s.dwell[i])} total`:'') + `\n`;
|
||||
txt += `\nActions:\n`;
|
||||
Object.keys(s.byEvent).filter(k=>!['step_view','step_dwell','field_edit'].includes(k)).forEach(k=> txt += ` ${k}: ${s.byEvent[k]}\n`);
|
||||
txt += ` field edits: ${s.byEvent['field_edit']||0}\n`;
|
||||
|
||||
@@ -625,6 +625,83 @@ body.embed-full { overflow: hidden; }
|
||||
|
||||
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ══ LOCATION LIST — CR-005 (step 11) ═════════════════════════════════════════
|
||||
Building / floor / sector, configured per project. The code beside each name
|
||||
is the thing CR-018 rolls cost up by, and it is shown rather than hidden so it
|
||||
is obvious that renaming a value does not move it. */
|
||||
.loc-actions { display: flex; gap: var(--wp-s2); flex-wrap: wrap; margin-top: var(--wp-s3); }
|
||||
|
||||
.loc-report { margin-top: var(--wp-s3); font-size: 13px; }
|
||||
.loc-report:empty { display: none; }
|
||||
.loc-report-line { margin: 0 0 var(--wp-s2); }
|
||||
.loc-report-group { margin-top: var(--wp-s3); }
|
||||
.loc-report-title { font-size: 12px; font-weight: 600; color: var(--text-light); }
|
||||
.loc-report-list { margin: var(--wp-s1) 0 0; padding-left: var(--wp-s5); }
|
||||
.loc-report-list li { margin-bottom: var(--wp-s1); color: var(--text-light); }
|
||||
.loc-line { font-weight: 600; color: var(--text); }
|
||||
/* A report that lost rows says so in its own right, not only by wording. */
|
||||
.loc-report.is-problem { border-left: 3px solid var(--warning); padding-left: var(--wp-s3);
|
||||
background: var(--warning-bg); padding-top: var(--wp-s2); padding-bottom: var(--wp-s2); }
|
||||
|
||||
#loc-list { display: flex; flex-direction: column; margin-top: var(--wp-s3); }
|
||||
.loc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wp-s3);
|
||||
padding: var(--wp-s2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* Indentation is the hierarchy, and it is not the only cue — .loc-level names
|
||||
the level in words beside it (C1: never colour or position alone). */
|
||||
.loc-depth-1 { padding-left: var(--wp-s5); }
|
||||
.loc-depth-2 { padding-left: var(--wp-s6); }
|
||||
.loc-level {
|
||||
flex: none; min-width: 68px;
|
||||
font-size: 11px; font-weight: 600; letter-spacing: .04em;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.loc-name {
|
||||
flex: 1 1 180px; min-width: 0;
|
||||
padding: var(--wp-s1) var(--wp-s2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0;
|
||||
background: var(--cds-field);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.loc-code {
|
||||
flex: none;
|
||||
font-family: var(--wp-font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.loc-toggle { display: flex; align-items: center; gap: var(--wp-s1); font-size: 12px; color: var(--text-light); }
|
||||
/* A deactivated value is still listed — that is how it is brought back — but it
|
||||
reads as out of use, by strikethrough as well as by the unticked box. */
|
||||
.loc-row.is-off .loc-name { text-decoration: line-through; color: var(--text-light); }
|
||||
.loc-row.is-off .loc-code { text-decoration: line-through; }
|
||||
|
||||
.loc-addrow {
|
||||
display: flex; align-items: center; gap: var(--wp-s2); flex-wrap: wrap;
|
||||
margin-top: var(--wp-s4);
|
||||
}
|
||||
.loc-addlabel { font-size: 12px; font-weight: 600; color: var(--text-light); }
|
||||
.loc-addrow select, .loc-addrow input {
|
||||
padding: var(--wp-s2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0;
|
||||
background: var(--cds-field);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.loc-addrow input { flex: 1 1 200px; min-width: 0; }
|
||||
|
||||
/* .field-error is declared once, in theme-light.css — the launcher's create form
|
||||
and T5.8's step validation use the same component. */
|
||||
|
||||
/* NAVIGATION */
|
||||
.step-navigation {
|
||||
grid-column: 2;
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
where-you-are and opens on demand. -->
|
||||
<button type="button" class="step-rail-toggle" id="step-rail-toggle"
|
||||
aria-expanded="false" aria-controls="step-rail-list">
|
||||
<span class="step-rail-toggle-text">Step <span id="step-rail-pos">1</span> of 10 · <span id="step-rail-here">Project</span></span>
|
||||
<span class="step-rail-toggle-text">Step <span id="step-rail-pos">1</span> of <span id="step-rail-total">11</span> · <span id="step-rail-here">Project</span></span>
|
||||
<span class="step-rail-toggle-chev" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<ol class="step-rail-list" id="step-rail-list">
|
||||
@@ -98,6 +98,13 @@
|
||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="8"><span class="step-btn-marker" aria-hidden="true">8</span><span class="step-btn-body"><span class="step-btn-label">Sequence</span><span class="step-btn-state"></span></span></button></li>
|
||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="9"><span class="step-btn-marker" aria-hidden="true">9</span><span class="step-btn-body"><span class="step-btn-label">Constraints</span><span class="step-btn-state"></span></span></button></li>
|
||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="10"><span class="step-btn-marker" aria-hidden="true">10</span><span class="step-btn-body"><span class="step-btn-label">Sources</span><span class="step-btn-state"></span></span></button></li>
|
||||
<!-- CR-005. Appended rather than inserted next to Project, which is where
|
||||
it belongs by subject: renumbering steps 2-10 would touch every
|
||||
sop-step-N id, every collectStepData case, every gate key and the
|
||||
analytics history, for an ordering change. Numbers are cheap to
|
||||
reorder once nothing depends on them; a renumbering buried in a
|
||||
feature diff is not. -->
|
||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="11"><span class="step-btn-marker" aria-hidden="true">11</span><span class="step-btn-body"><span class="step-btn-label">Locations</span><span class="step-btn-state"></span></span></button></li>
|
||||
</ol>
|
||||
<!-- Why a step you asked for did not open. role="alert" because a refused
|
||||
navigation is an error and waiting for a pause to say so is too late
|
||||
@@ -385,6 +392,61 @@
|
||||
<button class="add-btn" onclick="addSource()">+ Add source</button>
|
||||
</div>
|
||||
|
||||
<!-- STEP 11: LOCATIONS (CR-005)
|
||||
Server-backed, not localStorage: CR-018 rolls cost up by these
|
||||
values, and a taxonomy each browser keeps its own copy of cannot
|
||||
be rolled up by anything. See CLAUDE.md, "Frontend and backend
|
||||
boundary". -->
|
||||
<div class="step" id="sop-step-11" style="display: none;">
|
||||
<h2>11. Location List</h2>
|
||||
<div class="notice">Building, floor and sector for this project. The shape of a
|
||||
location differs per job — on some, floors within one building behave like
|
||||
separate buildings and are the unit of both execution and cost tracking — so it
|
||||
is configured here rather than assumed. Work packages pick from this list, and
|
||||
cost rolls up by it.</div>
|
||||
|
||||
<div id="loc-noproject" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
|
||||
|
||||
<div id="loc-tool">
|
||||
<div class="field-grid col1">
|
||||
<div class="field">
|
||||
<label for="loc-paste">Paste rows, or upload a CSV</label>
|
||||
<textarea id="loc-paste" rows="6" aria-describedby="loc-paste-hint"
|
||||
placeholder="One row per sector, e.g. Building, Floor, Sector"></textarea>
|
||||
<small id="loc-paste-hint">One row per value, deepest level last:
|
||||
<strong>building, floor, sector</strong>. Two columns describe a floor,
|
||||
one describes a building. Comma, semicolon or tab separated — a paste
|
||||
straight out of a spreadsheet works. A header row is ignored.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loc-actions">
|
||||
<input type="file" id="loc-file" accept=".csv,.txt,text/csv,text/plain" hidden>
|
||||
<button type="button" class="add-btn" id="loc-file-btn">Choose a CSV file…</button>
|
||||
<button type="button" class="add-btn" id="loc-check-btn">Check without importing</button>
|
||||
<button type="button" class="add-btn" id="loc-import-btn">Import</button>
|
||||
<button type="button" class="add-btn" id="loc-sample-btn">Load sample values</button>
|
||||
</div>
|
||||
<!-- Rejected rows and duplicates land here, with line numbers. An
|
||||
import that says "42 rows" over a file with 50 in it has lost
|
||||
eight and told nobody, which is what this exists to prevent. -->
|
||||
<div class="loc-report" id="loc-report" role="status"></div>
|
||||
|
||||
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
||||
<div class="sub-heading">Current list</div>
|
||||
<p class="field-hint" id="loc-count"></p>
|
||||
<div id="loc-list"></div>
|
||||
<div class="loc-addrow">
|
||||
<label class="loc-addlabel" for="loc-add-parent">Add under</label>
|
||||
<select id="loc-add-parent"></select>
|
||||
<label class="loc-addlabel" for="loc-add-name">Name</label>
|
||||
<input type="text" id="loc-add-name" placeholder="e.g. a new sector">
|
||||
<button type="button" class="add-btn" id="loc-add-btn">+ Add</button>
|
||||
</div>
|
||||
<div class="field-error" id="loc-add-err" role="alert"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- SOP NAVIGATION -->
|
||||
|
||||
Reference in New Issue
Block a user