diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0e1c631 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,108 @@ +# Working rules — Work Package Suite + +This repo is being changed against a fixed spec. Read `IMPLEMENTATION.md` before starting +any task, and read the wave file for the task you are on. Do not work from this file alone. + +## The spec is the source of truth + +Every change traces to an item ID (`CR-001`, `F1`, `S1`, `A1`, `B1`, `C1`, `D1`). If you are about +to make a change that has no ID, stop. Either it belongs to an existing item and you should +say which, or it is out of scope and should be logged in `docs/waves/backlog.md` instead of +built. + +Do not renumber, merge, or reinterpret item IDs. They are referenced in documents outside +this repo that other people are reading. New scope decided mid-build gets a **new** ID rather +than a widened old one - that is what the `D` prefix is for. See +`docs/waves/decisions-2026-08-18.md`. + +## Scope discipline + +- **One task per PR.** Task IDs are `T.`. Reference the task ID and the item IDs in + the commit message and PR title. +- **Do not fix things you notice in passing.** The codebase has known problems documented as + S1 through S13, all scheduled. Fixing S6 while doing T3.2 makes the diff unreviewable and + breaks the wave ordering. Log it, move on. +- **Do not reorder waves.** The ordering is dependency-driven and documented in + `IMPLEMENTATION.md` section 4. Waves 1 through 4 are prerequisites: they produce almost no + visible change and every later wave assumes them. +- **Do not start a wave until the previous wave is merged**, unless the task explicitly says + it is independent. + +## Frontend and backend boundary + +The UX review that produced F1-F6 and S1-S13 covered `html/` only. Several change requests +need server work and will be silently half-built if you treat them as frontend-only: + +| Item | Needs server work | +|---|---| +| CR-004, CR-018 | Structured location storage and aggregate endpoints. Not localStorage. | +| CR-007 | File upload, storage, and retrieval for drawing attachments. | +| CR-011, CR-014 | Outbound email and a durable link target per work package. | +| CR-013 | Material request persistence. | +| B4 | Aggregate endpoints replacing localStorage-derived counts. | + +If a task touches one of these and you find yourself writing to `localStorage`, you are +building the wrong thing. Say so and stop. + +## Things that must not change + +These are recorded decisions, not oversights. Do not "clean them up": + +- **Actual Hours stays in Closeout (CR-017).** Its removal was proposed and rejected. +- **Localization stays (A7).** `admin.js` language and time handling is a shipped feature. +- **Uppercase card headers in `console.css` stay (A5).** The sentence-case rule applies to + buttons and field labels only. The uppercase header idiom is deliberate. +- **The logged-override path for predecessors stays (A1).** It is an audited business rule, + not a bug. It is `confirmEarlyRelease()` in `wp-creation-app.js`, called from the issue and + release paths. Named by function, not by line: this file and `IMPLEMENTATION.md` X2 both + cited `wp-creation-app.js:1962-1972` until Aug 18 2026, and those lines are + `deletePackage()`/`clearSaved()` - a different rule entirely. Corrected before T7.3, which + is the task told not to remove it. +- **Removed fields are hidden, not deleted (CR-002, CR-016).** Retain the data and the model. + Removal is expressed through the CR-006 section toggles. + +## The token rule + +After wave 3 there is exactly one place a color, spacing or type value is defined. Page +stylesheets alias that source and declare nothing new. + +Adding a raw hex value to a page stylesheet is a defect regardless of what the task asked +for. Four parallel token systems is what produced S5, and the `.field-hint` comment at +`work-package-suite-styles.css:336` is the bug that resulted. Do not recreate it. + +## Accessibility is in scope + +Approved Aug 14, 2026 (C1). Any component you rebuild ships accessible or it is not done: + +- Interactive elements are `
- +
- - + + +
- + @@ -428,10 +601,18 @@
+ + + + diff --git a/html/wp-autosave.js b/html/wp-autosave.js new file mode 100644 index 0000000..508170f --- /dev/null +++ b/html/wp-autosave.js @@ -0,0 +1,230 @@ +/* Autosave, unsaved-work guard and draft recovery — S2 / T4.3. + --------------------------------------------------------------------------- + The work package form is roughly 4,700px tall and there was no autosave and no + unsaved-work guard on it. The only beforeunload listener in the app was + analytics dwell tracking (work-package-suite-app.js), so a mis-click, a closed + tab or a crash lost everything typed since the last explicit Save. + + Three separate things, often confused: + + THE DRAFT what you have typed, saved here, locally, continuously. + THE RECORD what you have explicitly Saved, which goes to the server. + THE OUTBOX project-data.js, which gets the RECORD to the server reliably. + + This file owns the first only. It never writes to the server: a draft is + "unfinished work this browser is holding for you", and pushing unfinished work + to a shared project is a different feature with different consequences. + + The guard fires only when the form actually differs from the record. A dialog + that appears on every exit gets clicked through within a day and is worse than + no dialog, which is why `Do not: fire the guard when nothing has changed` is + part of the task rather than a nicety. +*/ +(function (window, document) { + 'use strict'; + + var DRAFT_PREFIX = 'wp_draft::'; + var DEBOUNCE_MS = 1200; + + var reg = null; // the single registered surface for this page + var timer = null; + var status = { state: 'idle', at: null, error: null }; + var statusSubs = []; + var guardInstalled = false; + + function now() { return new Date().toISOString(); } + + function emit() { + statusSubs.forEach(function (fn) { + try { fn(Object.assign({}, status), reg ? reg.isDirty() : false); } catch (e) {} + }); + } + + function setStatus(state, extra) { + status = Object.assign({ state: state, at: status.at, error: null }, extra || {}); + emit(); + } + + function draftKey(id) { return DRAFT_PREFIX + id; } + + function readDraft(id) { + try { return JSON.parse(window.localStorage.getItem(draftKey(id)) || 'null'); } + catch (e) { return null; } + } + + function writeDraft(id, payload) { + // A failed write is the one case that MUST be surfaced rather than swallowed: + // it means the safety net is not there, and the user is the only one who can + // act on that (close a tab, free some quota, save explicitly now). + window.localStorage.setItem(draftKey(id), JSON.stringify(payload)); + } + + function clearDraft(id) { + try { window.localStorage.removeItem(draftKey(id)); } catch (e) {} + } + + function save(reason) { + if (!reg) return false; + if (!reg.isDirty()) { setStatus('idle'); return false; } + setStatus('saving'); + try { + writeDraft(reg.draftId(), { + v: 1, at: now(), reason: reason || 'debounce', + entity: reg.id, data: reg.collect(), + }); + setStatus('saved', { at: now() }); + return true; + } catch (e) { + // QuotaExceededError, private-mode storage, a locked profile. + setStatus('failed', { error: (e && e.message) || String(e) }); + return false; + } + } + + function schedule(reason) { + if (!reg) return; + clearTimeout(timer); + timer = setTimeout(function () { save(reason || 'debounce'); }, DEBOUNCE_MS); + } + + function installGuard() { + if (guardInstalled) return; + guardInstalled = true; + // ADDED alongside the analytics dwell listener, never replacing it. Both fire; + // beforeunload supports multiple listeners and the analytics one does not + // preventDefault, so the two do not interact. + window.addEventListener('beforeunload', function (e) { + if (!reg || !reg.isDirty()) return undefined; // nothing unsaved: stay silent + save('unload'); // one last draft write + e.preventDefault(); + e.returnValue = ''; // required by Chrome + return ''; + }); + // A crash or a killed tab never fires beforeunload. `visibilitychange` to + // hidden does, and it is the last reliable moment to write the draft - which + // is what makes the recovery survive "kill the tab and reopen". + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'hidden') save('hidden'); + }); + } + + window.WPAutosave = { + /* Register the page's editable surface. + + id stable name for the surface, e.g. 'wp-form' + scope element to watch for input/change (defaults to document) + draftId () => storage id, usually project + entity so two projects do + not share one draft + collect () => a JSON-serialisable snapshot of the form + isDirty () => does the form differ from the last explicitly saved record + restore (data) => put a recovered snapshot back into the form + */ + register: function (opts) { + reg = { + id: opts.id, + scope: opts.scope || document, + draftId: opts.draftId || function () { return opts.id; }, + collect: opts.collect, + isDirty: opts.isDirty, + restore: opts.restore, + }; + reg.scope.addEventListener('input', function () { schedule('input'); }); + reg.scope.addEventListener('change', function () { schedule('change'); }); + installGuard(); + return window.WPAutosave; + }, + + // Autosave now rather than on the debounce - for a step or section change, + // where the user has visibly moved on and expects the previous part kept. + flush: function (reason) { clearTimeout(timer); return save(reason || 'flush'); }, + + isDirty: function () { return !!(reg && reg.isDirty()); }, + status: function () { return Object.assign({}, status); }, + onStatus: function (fn) { + statusSubs.push(fn); + try { fn(Object.assign({}, status), reg ? reg.isDirty() : false); } catch (e) {} + return function () { + var i = statusSubs.indexOf(fn); + if (i >= 0) statusSubs.splice(i, 1); + }; + }, + + /* Recovery. Returns the stored draft for an id, or null. The caller decides + whether to offer it - only it knows whether the draft is actually newer + than the record, and offering to restore work that is already saved is its + own kind of alarming. */ + peek: function (id) { return readDraft(id); }, + discard: function (id) { clearTimeout(timer); clearDraft(id); setStatus('idle'); }, + + /* Called after an explicit Save succeeded: the record now holds this work, so + the draft is no longer protecting anything and keeping it would make the + next load offer to "recover" work that is already saved. + + clearTimeout FIRST. A save typically follows typing, so there is usually a + debounced write already scheduled; without cancelling it, that write lands + a second after the draft was cleared and resurrects it — and the next load + offers to recover work that is already saved, which is the exact thing this + method exists to prevent. */ + settled: function (id) { clearTimeout(timer); clearDraft(id); setStatus('idle'); }, + + /* A persistent draft-state indicator — B5 / T4.4. + + The suite already showed "✓ All changes saved", but that badge belonged to + the OUTBOX: it reported whether saved records had reached the project, and + went green when the queue emptied whether or not anything in the form had + been saved at all. This indicator speaks only for the draft, and the outbox + badge's wording now names the project explicitly, so neither can be read as + the other. + + role="status" so the state is announced politely (S10 / T4.5); a failure + swaps in role="alert" so it interrupts, because a failed autosave means the + safety net is not there and waiting for a pause to say so is too late. */ + mountIndicator: function (host, opts) { + if (!host) return function () {}; + opts = opts || {}; + var el = document.createElement('div'); + el.className = 'wp-draft-status'; + el.id = opts.id || 'wp-draft-status'; + host.appendChild(el); + + function fmt(ts) { + try { return new Date(ts).toLocaleTimeString(); } catch (e) { return ''; } + } + + var un = window.WPAutosave.onStatus(function (st, dirty) { + var role = 'status', cls = '', text = ''; + if (st.state === 'failed') { + role = 'alert'; + cls = 'is-failed'; + text = '⚠ Draft not saved on this device — ' + (st.error || 'storage unavailable'); + } else if (st.state === 'saving') { + cls = 'is-saving'; + text = '↻ Saving draft…'; + } else if (st.state === 'saved') { + cls = 'is-saved'; + text = '✓ Draft saved at ' + fmt(st.at); + } else { + cls = 'is-idle'; + // "No unsaved changes" is a different statement from "saved", and it is + // the true one when nothing has been typed. + text = dirty ? 'Unsaved changes' : 'No unsaved changes'; + } + el.className = 'wp-draft-status ' + cls; + el.setAttribute('role', role); + el.textContent = text; + // Retry is only offered where it can do something. + if (st.state === 'failed') { + var b = document.createElement('button'); + b.type = 'button'; + b.className = 'wp-draft-retry'; + b.textContent = 'Retry'; + b.onclick = function () { window.WPAutosave.flush('retry'); }; + el.appendChild(b); + } + }); + return function () { un(); if (el.parentNode) el.parentNode.removeChild(el); }; + }, + + _key: draftKey, + }; +})(window, document); diff --git a/html/wp-chrome.css b/html/wp-chrome.css index 60b9ed9..9da595d 100644 --- a/html/wp-chrome.css +++ b/html/wp-chrome.css @@ -14,25 +14,28 @@ min-width: 0; /* lets the search shrink instead of overflowing */ flex: 1 1 auto; } +/* Seven role names, resolved twice — once for each kind of host bar. The switch + is the point of this block and it stays; only the literals move behind the + canonical tokens in theme-light.css (T3.2 / S5 / C3). */ /* Light host bar (the two tool pages) */ .wp-chrome { - --wpc-fg: #161616; - --wpc-fg-dim: #525252; - --wpc-bg: #ffffff; - --wpc-bg-soft: #f4f4f4; - --wpc-border: #c6c6c6; - --wpc-hover: #e8e8e8; - --wpc-accent: #0f62fe; + --wpc-fg: var(--cds-text-primary); + --wpc-fg-dim: var(--cds-text-secondary); + --wpc-bg: var(--cds-layer); + --wpc-bg-soft: var(--cds-layer-accent); + --wpc-border: var(--cds-border-subtle-selected); + --wpc-hover: var(--cds-layer-hover); + --wpc-accent: var(--cds-interactive-01); } /* Dark host bar (the UI-shell appbar) */ .wp-chrome[data-bar="dark"] { - --wpc-fg: #ffffff; - --wpc-fg-dim: #c6c6c6; - --wpc-bg: #262626; - --wpc-bg-soft: #393939; - --wpc-border: #6f6f6f; - --wpc-hover: #353535; - --wpc-accent: #78a9ff; + --wpc-fg: var(--wp-appbar-fg); + --wpc-fg-dim: var(--wp-appbar-fg-dim); + --wpc-bg: var(--wp-appbar-layer); + --wpc-bg-soft: var(--cds-inverse-02); + --wpc-border: var(--wp-appbar-border); + --wpc-hover: var(--wp-appbar-hover); + --wpc-accent: var(--cds-link-inverse); } /* ── archived-project banner ──────────────────────────────────────────────── */ @@ -47,11 +50,11 @@ align-items: flex-start; gap: 8px; padding: 9px 16px; - background: #fdf6dd; - color: #8e6a00; - border-bottom: 1px solid #f1c21b; + background: var(--wp-status-warning-bg); + color: var(--wp-status-warning-text); + border-bottom: 1px solid var(--cds-support-warning); border-radius: 0; - font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-family: var(--wp-font-sans-2); font-size: 13.5px; line-height: 1.35; } @@ -90,6 +93,13 @@ text-transform: uppercase; color: var(--wpc-fg-dim); } +/* B2. These two caps are the "does not fit 280px" the review measured: a real name + ran past 240px and was ellipsised on the one control whose job is to say which job + you are in. Above 1024px the bar has the room, so the caps are raised until a real + project name fits — "Micron EUV Cleanroom Enable 2667008" is the one to test with. + Below 1024px the label is the project number instead (see wp-chrome.js), which is + short enough that these caps never bite. The ellipsis stays only as a backstop for + a name longer than anything real. */ .wpc-proj-name { display: block; font-weight: 600; @@ -98,6 +108,10 @@ text-overflow: ellipsis; max-width: 240px; } +@media (min-width: 1024px) { + .wpc-proj-btn { max-width: 400px; } + .wpc-proj-name { max-width: 340px; } +} .wpc-caret { flex: 0 0 auto; align-self: flex-end; margin-bottom: 3px; font-size: 10px; line-height: 1; color: var(--wpc-fg-dim); } @@ -111,10 +125,10 @@ max-width: min(460px, 92vw); max-height: min(70vh, 560px); overflow-y: auto; - background: #fff; - color: #161616; - border: 1px solid #e0e0e0; - box-shadow: 0 8px 28px rgba(20, 30, 50, .22); + background: var(--cds-layer); + color: var(--cds-text-primary); + border: 1px solid var(--cds-border-subtle); + box-shadow: var(--wp-shadow-pop); border-radius: 4px; } .wpc-pop[hidden] { display: none; } @@ -124,8 +138,8 @@ font-weight: 700; letter-spacing: .07em; text-transform: uppercase; - color: #6f6f6f; - border-bottom: 1px solid #f0f0f0; + color: var(--cds-text-helper); + border-bottom: 1px solid var(--wp-pop-divider); } .wpc-item { display: block; @@ -137,19 +151,19 @@ text-align: left; font: inherit; font-size: 13px; - color: #161616; + color: var(--cds-text-primary); cursor: pointer; text-decoration: none; } -.wpc-item:hover, .wpc-item.is-active { background: #f4f4f4; } -.wpc-item.is-current { border-left-color: #0f62fe; background: #edf5ff; } +.wpc-item:hover, .wpc-item.is-active { background: var(--cds-layer-accent); } +.wpc-item.is-current { border-left-color: var(--cds-interactive-01); background: var(--cds-highlight); } .wpc-item-title { display: block; font-weight: 600; } -.wpc-item-sub { display: block; font-size: 11.5px; color: #6f6f6f; } -.wpc-item-mono { font-family: 'IBM Plex Mono', ui-monospace, Consolas, monospace; font-size: 12px; color: #0f62fe; } -.wpc-empty { padding: 14px 12px; font-size: 13px; color: #6f6f6f; } +.wpc-item-sub { display: block; font-size: 11.5px; color: var(--cds-text-helper); } +.wpc-item-mono { font-family: var(--wp-font-mono-3); font-size: 12px; color: var(--cds-interactive-01); } +.wpc-empty { padding: 14px 12px; font-size: 13px; color: var(--cds-text-helper); } .wpc-pop-foot { padding: 8px 12px; - border-top: 1px solid #f0f0f0; + border-top: 1px solid var(--wp-pop-divider); display: flex; gap: 8px; flex-wrap: wrap; @@ -159,14 +173,14 @@ font-size: 12px; font-weight: 600; padding: 5px 10px; - border: 1px solid #c6c6c6; - background: #fff; - color: #161616; + border: 1px solid var(--cds-border-subtle-selected); + background: var(--cds-layer); + color: var(--cds-text-primary); border-radius: 3px; cursor: pointer; text-decoration: none; } -.wpc-foot-btn:hover { border-color: #0f62fe; color: #0f62fe; } +.wpc-foot-btn:hover { border-color: var(--cds-interactive-01); color: var(--cds-interactive-01); } /* ── global search ────────────────────────────────────────────────────────── */ /* Centered in the bar: the wrapper takes the free space and centres a capped box, @@ -192,6 +206,11 @@ border: 1px solid var(--wpc-border); border-radius: 3px; } +/* The ring is drawn on the BOX, not the input: the input is a borderless field + inside a bordered shell, so ringing the input would draw a rectangle floating + inside another rectangle. --wpc-accent resolves to #0f62fe on a light bar + (8.6:1 against #ffffff) and #78a9ff on the dark one (6.6:1 against #262626), + so it clears 3:1 on both hosts. */ .wpc-search-box:focus-within { outline: 2px solid var(--wpc-accent); outline-offset: -2px; } .wpc-search-ico { flex: 0 0 auto; color: var(--wpc-fg-dim); font-size: 13px; } .wpc-search-input { @@ -199,6 +218,9 @@ min-width: 0; background: transparent; border: 0; + /* S12: the ONE `outline: none` left in the app, and it has its replacement in + the rule above — the shell rings on :focus-within, which fires for exactly the + same interactions. Ringing both would draw two. */ outline: none; color: var(--wpc-fg); font: inherit; @@ -207,7 +229,7 @@ .wpc-search-input::placeholder { color: var(--wpc-fg-dim); } .wpc-kbd { flex: 0 0 auto; - font-family: 'IBM Plex Mono', ui-monospace, Consolas, monospace; + font-family: var(--wp-font-mono-3); font-size: 10.5px; color: var(--wpc-fg-dim); border: 1px solid var(--wpc-border); @@ -235,3 +257,82 @@ .wpc-proj-kicker { display: none; } .wpc-search { flex: 1 1 120px; } } + + +/* ============================================================================ + TOOL TAB STRIP (B7 / T7.1) + ---------------------------------------------------------------------------- + SOP Configuration | Work Package Creation | Dashboard. + + These rules lived in work-package-suite-styles.css while the creator was an + iframe child, because only the suite page ever drew the strip. Dissolving the + frame makes the creator a document of its own that draws the same strip, so + they move to the sheet both pages already load. Nothing changed in the move - + same values, same tokens, no literal added; the fallbacks below exist only + because the two host pages alias the role names under different local names. + + The strip is navigation between two documents now, so `.nav-tab` has to look + identical as a
`; + tb.appendChild(tr); + }); +} +// Picking a listed material fills its unit - a convenience, never a lock. +function mreqMaybeUnit(i, desc){ + const hit = (_projMaterials || []).find(m => m.description === desc); + if(hit && _mreqDraft[i] && !_mreqDraft[i].unit){ _mreqDraft[i].unit = hit.unit || ''; mreqRenderDraft(); } +} +function mreqSubmit(){ + const err = document.getElementById('mreq-err'); + const items = _mreqDraft.map(it => ({qty:(it.qty||'').trim(), unit:(it.unit||'').trim().toUpperCase(), desc:(it.desc||'').trim()})) + .filter(it => it.desc || it.qty); + if(!items.length){ + if(err) err.textContent = 'Add at least one line - what material, and how much.'; + return; + } + const bad = items.find(it => !it.desc); + if(bad){ + if(err) err.textContent = 'Every line needs a description.'; + return; + } + if(err) err.textContent = ''; + const needed = (document.getElementById('mreq-needed')||{value:''}).value; + const deliv = { + delivBuilding: gv('wp_deliv_building'), delivFloor: gv('wp_deliv_floor'), + delivSector: gv('wp_deliv_sector'), delivDetail: gv('wp_deliv_detail'), + }; + pkgMatRequests.push({ + id: 'mreq_' + Date.now().toString(36) + Math.random().toString(36).slice(2,6), + ts: new Date().toISOString(), + requestor: (window.WP_USER && (WP_USER.full_name || WP_USER.username)) || '', + neededBy: needed, status: 'Requested', items, + deliveryLoc: deliveryLocOf(deliv), + }); + _mreqDraft = []; + const nd = document.getElementById('mreq-needed'); if(nd) nd.value = ''; + mreqRenderDraft(); mreqRenderList(); + // The request is data on the package: saving is what persists it and what + // makes the server notify the warehouse owner. Do it now rather than leaving + // a submitted-looking request sitting unsaved in a form. + if(editingId){ savePackage(false); } + else toast('Request added - save the package to send it to the warehouse owner.', 'alert'); + track('material_requested', {lines: items.length}); +} +function mreqSetStatus(id, status){ + const r = pkgMatRequests.find(x => x.id === id); + if(!r || MREQ_STATUSES.indexOf(status) < 0) return; + r.status = status; + mreqRenderList(); + if(editingId) savePackage(false); +} +function mreqRenderList(){ + const box = document.getElementById('mreq-list'); + if(!box) return; + if(!pkgMatRequests.length){ box.innerHTML = ''; return; } + box.innerHTML = pkgMatRequests.map(r => `
+
+ ${esc(r.status||'Requested')} + ${esc(r.requestor||'')} + ${r.neededBy?`needed ${esc(r.neededBy)}`:''} + ${r.deliveryLoc?`→ ${esc(r.deliveryLoc)}`:''} + +
+
${(r.items||[]).map(it=>esc([it.qty, it.unit, it.desc].filter(Boolean).join(' '))).join(' · ')}
+
`).join(''); +} +function buildKitOwnerPicker(){ + const sel=document.getElementById('wp_kit_owner_sel'); if(!sel) return; + const curId=pkgKitOwnerId||''; + const curName=gv('wp_kit_owner'); + let html=''+ + (projectMembers||[]).map(u=>``).join(''); + const known=(projectMembers||[]).some(u=>u.id===curId); + if(curName && !known){ + html+=``; + } + sel.innerHTML=html; + sel.onchange=function(){ + if(this.value==='__orphan__') return; // re-picking the legacy name changes nothing + const u=(projectMembers||[]).find(x=>x.id===this.value); + pkgKitOwnerId=u?u.id:''; + const hid=document.getElementById('wp_kit_owner'); + if(hid) hid.value=u?(u.full_name||u.username):''; + }; +} +function buildKitStatusOptions(current){ + const sel=document.getElementById('wp_kit_status'); if(!sel) return; + const cur=(current!=null?current:sel.value)||''; + let html=''+KIT_STATUSES.map(k=> + `${esc(k)}`).join(''); + if(cur && KIT_STATUSES.indexOf(cur)<0){ + html+=``; + } + sel.innerHTML=html; +} +const WP_PRIORITY_DEFAULT = 'Normal'; + const COST_CODES = ['1000|Project Management','2000|Design and Development','2100|Design','2110|Control System Design','2120|Instrument Design','2130|Electrical Design','2140|Panel Design','2141|Panel Design Rework','2150|BIM','2151|BIM Rework','2160|Documentation','2200|Development','2210|PLC Programming','2220|OIT Programming','2230|SCADA Programming','2240|Simulation Development','2290|Programming Subcontract','2300|Customer Training','3000|Operational Technology','3100|OT Design','3200|Rack Assembly','3300|Network Configuration','3400|Computer Configuration','4000|Construction','4010|Instruments Install','4020|Network & Computers Install','4040|PLC Install','4050|Panel Install','4060|Electrical Install','4070|Mechanical Install','4080|Security Install','4090|Radio Install','4100|Commissioning','4940|Contract Labor','4960|Electrical Subcontract','4970|Mechanical Subcontract','4980|Security Subcontract','4990|Other/Radio Subcontract']; // Acumatica allowed units of measure (comment 15) — common first const ACU_UNITS = ['EA','EACH','FT','M','METER','HR','DAYS','MINUTE','KG','LITER','CASE','LOT','LS','PK','PACK','PALLET','PIECE','BOTTLE','CAN']; @@ -45,7 +228,8 @@ const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject": let SOP=null, editingId=null, numberDirty=false; let pkgKind='iwp'; // 'iwp' (install) | 'ewp' (BIM) — per-package, only relevant when SOP.bimEnabled let activeProjectId=''; // set at boot from ?project=; stamped onto saved WPs for the API -let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[]; +let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[], pkgQaRejections=[]; +let pkgFiles=[]; // CR-007: uploaded drawing METAS - bytes live on the server, data['files'] is server-owned let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3) let pkgDisciplines=[]; // disciplines this WP covers (from SOP governance.disciplines) @@ -68,7 +252,144 @@ function cell(v){ return v ? esc(v) : ns(); } function pad2(n){ return n<10?'0'+n:''+n; } function typeCode(name){ return (name||'').replace(/[^a-z0-9]+/gi,''); } function linkify(v){ if(!v) return ''; return esc(v).replace(/(https?:\/\/[^\s]+)/g,u=>`${u}`); } -function toast(msg){ let t=document.getElementById('toast'); if(!t){ t=document.createElement('div'); t.id='toast'; document.body.appendChild(t); } t.textContent=msg; t.classList.add('show'); clearTimeout(toast._t); toast._t=setTimeout(()=>t.classList.remove('show'),2200); } +// S10: a toast that nobody hears is not a notification. role="status" by default +// so a confirmation waits its turn; toast(msg, 'alert') for anything the user has +// to act on, which interrupts. Same vocabulary as login.html, which was the only +// place in the app already doing this correctly. +function toast(msg, kind){ + let t=document.getElementById('toast'); + if(!t){ t=document.createElement('div'); t.id='toast'; document.body.appendChild(t); } + // Set the role BEFORE the text: assistive technology announces on the content + // change, so a role applied afterwards describes the next message, not this one. + t.setAttribute('role', kind === 'alert' ? 'alert' : 'status'); + t.textContent=msg; t.classList.add('show'); + clearTimeout(toast._t); toast._t=setTimeout(()=>t.classList.remove('show'),2200); +} +// ── DIALOG KIT (S1 / T7.9) ──────────────────────────────────────────────────── +// The creator carried 43 native dialogs. One modal replaces them: a message, an +// optional input with an inline, announced error, and real buttons. Everything +// is promise-based, so a caller reads exactly like the confirm() it replaced - +// just awaited. Escape and the corner control both cancel. +let _dlgResolve=null; +function _openDialog(opts){ + return new Promise(res=>{ + _dlgResolve=res; + const ov=document.getElementById('wp-dialog'); ov._opts=opts||{}; + document.getElementById('wp-dialog-title').textContent=opts.title||'Confirm'; + document.getElementById('wp-dialog-msg').textContent=opts.message||''; + const wrap=document.getElementById('wp-dialog-input-wrap'); + wrap.style.display=opts.input?'':'none'; + document.getElementById('wp-dialog-label').textContent=opts.label||''; + const inp=document.getElementById('wp-dialog-input'); + inp.value=(opts.value!=null?String(opts.value):''); + document.getElementById('wp-dialog-err').textContent=''; + document.getElementById('wp-dialog-ok').textContent=opts.okLabel||'OK'; + document.getElementById('wp-dialog-cancel').textContent=opts.cancelLabel||'Cancel'; + document.getElementById('wp-dialog-cancel').style.display=opts.okOnly?'none':''; + ov.classList.add('open'); + setTimeout(()=>{ (opts.input?inp:document.getElementById('wp-dialog-ok')).focus(); },0); + }); +} +function wpDialogOk(){ + const ov=document.getElementById('wp-dialog'); const opts=ov._opts||{}; + if(opts.input){ + const v=document.getElementById('wp-dialog-input').value; + if(opts.validate){ + const err=opts.validate(v); + if(err){ document.getElementById('wp-dialog-err').textContent=err; + document.getElementById('wp-dialog-input').focus(); return; } + } + _closeDialog(v); + } else _closeDialog(true); +} +function wpDialogCancel(){ + const opts=(document.getElementById('wp-dialog')||{})._opts||{}; + _closeDialog(opts.input?null:false); +} +function _closeDialog(val){ + document.getElementById('wp-dialog').classList.remove('open'); + const r=_dlgResolve; _dlgResolve=null; if(r) r(val); +} +// confirm() said yes or no; this says true or false. +function wpConfirmDialog(opts){ return _openDialog({...opts, input:false}); } +// prompt() said string or null; so does this - and a validate() answer renders +// AT the input instead of round-tripping through another dialog. +function wpPromptDialog(opts){ return _openDialog({...opts, input:true}); } +// alert() said one thing and offered one button; so does this (D11 - the asset +// importer arrived using alert(), and the kit had no one-button shape). +// Escape still closes it; the resolved value is not meaningful for alerts. +function wpAlertDialog(opts){ return _openDialog({...opts, input:false, okOnly:true}); } +document.addEventListener('keydown', e=>{ + const ov=document.getElementById('wp-dialog'); + if(e.key==='Escape' && ov && ov.classList.contains('open')) wpDialogCancel(); +}); + +// ── INLINE VALIDATION (S1 / T7.9) ──────────────────────────────────────────── +// One table: field id, the section card that holds it, the label the message +// uses. Adding a required field is one row; its error box, aria wiring and +// announcement all follow (the wizard's T5.8 pattern, applied to the creator). +const WP_REQUIRED = [ + ['wp_subject', 'general-card', 'Subject'], + ['wp_type', 'general-card', 'WP type'], +]; +function wpErrBox(id){ + const el=document.getElementById(id); if(!el) return null; + let box=document.getElementById(id+'_err'); + if(!box){ + box=document.createElement('div'); + box.className='field-error'; box.id=id+'_err'; + box.setAttribute('role','alert'); + (el.parentNode||document.body).insertBefore(box, el.nextSibling); + } + const prior=(el.getAttribute('aria-describedby')||'').split(/\s+/).filter(Boolean); + if(prior.indexOf(box.id)<0){ prior.push(box.id); el.setAttribute('aria-describedby', prior.join(' ')); } + return box; +} +function wpSetFieldError(id, msg){ + const el=document.getElementById(id); const box=wpErrBox(id); + if(box) box.textContent=msg||''; + if(el){ if(msg) el.setAttribute('aria-invalid','true'); else el.removeAttribute('aria-invalid'); } +} +function wpMarkRailErrors(sectionIds){ + document.querySelectorAll('.sec-rail-item').forEach(b=>{ + const on=sectionIds.has(b.dataset.sec); + b.classList.toggle('has-error', on); + let mark=b.querySelector('.sec-err'); + if(on && !mark){ + mark=document.createElement('span'); mark.className='sec-err'; + mark.textContent='!'; mark.setAttribute('aria-label','this section has errors'); + b.insertBefore(mark, b.firstChild); + } + if(!on && mark) mark.remove(); + }); +} +// Validate the whole form: mark every failing field, mark the rail entries of +// the sections that hold them, then go to the FIRST one - switching sections if +// needed - and put focus in it. Returns whether the form may save. +function wpValidateForm(){ + const bad=[]; + WP_REQUIRED.forEach(([id, section, label])=>{ + const ok=!!gv(id); + wpSetFieldError(id, ok?'':label+' is required.'); + if(!ok) bad.push([id, section]); + }); + if(typeof isEwp==='function' && isEwp() && typeof iffRequired==='function' + && iffRequired() && !gv('wp_iff').trim()){ + wpSetFieldError('wp_iff','Coordination is set to "Signed off (IFF)" — enter the IFF number so the sign-off is traceable.'); + bad.push(['wp_iff','bim-card']); + } else if(document.getElementById('wp_iff')) wpSetFieldError('wp_iff',''); + wpMarkRailErrors(new Set(bad.map(b=>b[1]))); + if(bad.length){ + const [firstId, firstSection]=bad[0]; + if(typeof gotoSection==='function') gotoSection(firstSection); + setTimeout(()=>{ const el=document.getElementById(firstId); + if(el){ el.focus(); el.scrollIntoView({behavior:'smooth',block:'center'}); } }, 150); + toast((bad.length===1?'1 required field needs':bad.length+' required fields need') + +' attention — the first is focused.', 'alert'); + } + return !bad.length; +} + function getRadio(name){ const s=document.querySelector(`.radio-pill.selected input[name="${name}"]`); return s?.closest('.radio-pill')?.dataset?.val||''; } function setRadio(name,val){ document.querySelectorAll(`.radio-pill input[name="${name}"]`).forEach(i=>{const p=i.closest('.radio-pill'); const on=p.dataset.val===val; p.classList.toggle('selected',on); i.checked=on;}); } function enabledTypes(){ return ((SOP&&SOP.woTypes)||[]).filter(t=>t.enabled!==false); } @@ -81,12 +402,26 @@ function constraintNames(){ function nextSeq(){ return savedPackages.length+1; } // ── SOP LOADING ────────────────────────────────────────────────────────────── +// S7 / T9.4: the one sample entry point. loadSampleSOP()/loadExample() stay as +// internals (probes and this function call them); the USER reaches sample data +// through exactly this, which says what it will and will not do before doing it. +async function loadSampleAll(){ + if(!(await wpConfirmDialog({ + title:'Load sample data', + message:'Replaces the SOP shown on this page with the fictional Micron sample and fills ' + + 'the form with the example work package.\n\nThis page only: nothing is written to ' + + 'the project unless you then save.', + okLabel:'Load the sample'}))) return; + loadSampleSOP(); + loadExample(); +} + function loadSampleSOP(){ SOP=JSON.parse(JSON.stringify(SAMPLE_SOP)); applySOP(); newPackage(); toast('Sample SOP loaded — ' + (SOP.project&&SOP.project.name||'project')); track('sample_loaded'); } function importSOP(ev){ const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader(); - r.onload=()=>{ try{ const d=JSON.parse(r.result); if(!d.woTypes){ alert('That file does not look like an SOP export from the Configuration tool.'); return; } + r.onload=()=>{ try{ const d=JSON.parse(r.result); if(!d.woTypes){ toast('That file does not look like an SOP export from the Configuration tool.', 'alert'); return; } SOP=d; applySOP(); newPackage(); toast('Loaded SOP — '+((d.project&&d.project.name)||'project')); track('sop_imported'); - }catch(e){ alert('Could not read that file.'); } ev.target.value=''; }; + }catch(e){ toast('Could not read that file.', 'alert'); } ev.target.value=''; }; r.readAsText(f); } function applySOP(){ @@ -105,9 +440,12 @@ function applySOP(){ applyKind(); if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); } if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); } - if(!pkgAssets.length){ pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); } + if(!pkgAssets.length){ buildAssets(); } // renders the "no assets yet" empty state if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); } updateNumber(); updateReleaseBanner(); + // CR-006. Last, after every builder has rendered its card — applying it earlier + // would be undone by whichever builder touched the same card afterwards. + applySopSections(SOP && SOP.sections, SOP && SOP.fields); } // Per-package kind. A project whose SOP has bimEnabled produces both install (IWP) // and BIM (EWP) packages; the kind selector tailors which fields, WP types, and @@ -134,7 +472,7 @@ function applyKindVisibility(){ const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; }; show('kind-row', bimProj); show('bim-card', ewp); // model area / clash + IFF # / scan - show('asset-card', !ewp); // controls.dev assets + show('asset-card', !ewp); // Micron DB assets show('material-card', !ewp); // bill of materials show('mimo-card', !ewp); // kitting / MIMO show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it @@ -186,7 +524,7 @@ function lockQuality(id){ el.classList.toggle('sop-inherited', fromSOP); // comment 8: blue-tinted SOP field el.classList.toggle('locked-field', false); const wrap=el.closest('.field'); const btn=wrap&&wrap.querySelector('.lock-edit'); const note=wrap&&wrap.querySelector('.override-note'); - if(btn) btn.textContent = fromSOP ? '🔒 Edit (reason required)' : '↺ Revert to SOP'; + if(btn) btn.textContent = fromSOP ? '✎ Edit (reason required)' : '↺ Revert to SOP'; if(note) note.innerHTML = pkgOverrides[id] ? `Overridden: ${esc(pkgOverrides[id])}` : ''; } function editQuality(id){ @@ -195,24 +533,34 @@ function editQuality(id){ document.getElementById(id).value=sopValueFor(id); lockQuality(id); track('quality_reverted',{field:id}); return; } - const reason=prompt('This field is set by the project SOP. Enter the reason for overriding it on this package:'); - if(reason===null) return; - if(!reason.trim()){ alert('A reason is required to override an SOP field.'); return; } - pkgOverrides[id]=reason.trim(); - const el=document.getElementById(id); el.readOnly=false; el.classList.remove('locked-field'); el.focus(); - lockQuality(id); track('quality_overridden',{field:id}); + wpPromptDialog({ + title:'Override an SOP field', + message:'This field is set by the project SOP. The override and its reason stay on the package.', + label:'Reason for overriding it', okLabel:'Override', + validate:v=>v.trim()?'':'A reason is required to override an SOP field.', + }).then(reason=>{ + if(reason===null) return; + pkgOverrides[id]=reason.trim(); + const el=document.getElementById(id); el.readOnly=false; el.classList.remove('locked-field'); el.focus(); + lockQuality(id); track('quality_overridden',{field:id}); + }); } function renderCtxBar(){ const bar=document.getElementById('ctx-bar'); + const archMark = window._projArchived + ? ` ARCHIVED — READ-ONLY` : ''; if(!SOP){ bar.innerHTML = activeProjectId - ? `
No SOP found for this project yet — complete the SOP Configuration first, then return here.
` - : `
No SOP loaded — or import one from the Configuration tool.
`; + ? `
No SOP found for this project yet — complete the SOP Configuration first, then return here.${archMark}
` + : `
No SOP loaded — import one from the Configuration tool, or use Load sample data in the toolbar above.${archMark}
`; return; } const p=SOP.project||{}, g=SOP.governance||{}; + // D7: an archived project is read-only. The chip is the courtesy; the server's + // write refusal is the rule, and savePackage() says so before the round trip. + const archived = archMark; const sample=SOP.meta&&SOP.meta.sample?`SAMPLE`:''; - bar.innerHTML=`
${esc(p.name||'Untitled')} ${sample}
+ bar.innerHTML=`
${esc(p.name||'Untitled')} ${sample}${archived}
${esc(p.number||'')}${p.division?' · '+esc(p.division):''}
${enabledTypes().length} typesformat ${esc(g.woFormat||'—')}track: ${esc((SOP.field&&SOP.field.trackPlatform)||'—')}
`; } @@ -232,7 +580,7 @@ function renderSopRefLinks(){ const srcs=sopLinkedSources(); if(!srcs.length){ box.innerHTML=''; return; } box.innerHTML=``+ - ``; + ``; } function renderSpecFolderLink(){ const el=document.getElementById('spec-folder-link'); if(!el) return; @@ -464,7 +812,7 @@ function renderPredHint(){ el.textContent = 'None — this package can be released as soon as its constraints are cleared.'; el.style.color = ''; } else if(blocking.length){ - el.innerHTML = '⛔ Waiting on ' + blocking.map(p => esc(p.number || p.id) + ' (' + esc(p.status) + ')').join(', ') + + el.innerHTML = '⊘ Waiting on ' + blocking.map(p => esc(p.number || p.id) + ' (' + esc(p.status) + ')').join(', ') + '. Release is gated until they are Closed.'; el.style.color = 'var(--red)'; } else { @@ -622,10 +970,10 @@ function parseCSV(text){ } function importMaterials(ev){ const f=ev.target.files&&ev.target.files[0]; if(!f) return; - if(/\.xlsx?$/i.test(f.name)){ alert('Please save the Excel file as CSV first (File → Save As → CSV), then import. The template download is already CSV.'); ev.target.value=''; return; } + if(/\.xlsx?$/i.test(f.name)){ toast('Save the Excel file as CSV first (File → Save As → CSV), then import. The template download is already CSV.', 'alert'); ev.target.value=''; return; } const r=new FileReader(); r.onload=()=>{ try{ - const rows=parseCSV(r.result); if(!rows.length){ alert('No rows found.'); return; } + const rows=parseCSV(r.result); if(!rows.length){ toast('No rows found in that file.', 'alert'); return; } let start=0; const h=rows[0].map(c=>c.trim().toLowerCase()); const qi=h.indexOf('qty'), ui=h.indexOf('unit'), di=h.indexOf('description'); let cq=0,cu=1,cd=2; @@ -634,9 +982,9 @@ function importMaterials(ev){ for(let i=start;i${esc(d)} Status:
${rows} - + `; }).join(''); } @@ -764,12 +1112,20 @@ function instanceSuffixFor(index, discipline){ if(instanceSuffixStyle()==='discipline'){ return '_'+typeNumberCode(discipline); } return String.fromCharCode(65+index); // A, B, C… } -function splitByDiscipline(){ - if(!isMultiDiscipline()){ alert('Select two or more disciplines before splitting.'); return; } - if(!gv('wp_subject')){ alert('Add a Subject before splitting.'); return; } +async function splitByDiscipline(){ + if(!isMultiDiscipline()){ toast('Select two or more disciplines before splitting.', 'alert'); return; } + if(!gv('wp_subject')){ + wpSetFieldError('wp_subject','Add a Subject before splitting.'); + wpMarkRailErrors(new Set(['general-card'])); gotoSection('general-card'); + setTimeout(()=>{ const el=document.getElementById('wp_subject'); if(el) el.focus(); },150); + return; + } const base=collectPackage(); const baseNumber=base.number||('WP'+pad2(editingSeq())); - if(!confirm(`Split "${baseNumber}" into ${pkgDisciplines.length} discipline instances (${pkgDisciplines.map((d,i)=>baseNumber+instanceSuffixFor(i,d)).join(', ')})?\n\nThe master package is kept as a roll-up; each instance becomes its own single-discipline package.`)) return; + if(!(await wpConfirmDialog({ + title:'Split by discipline', + message:`Split "${baseNumber}" into ${pkgDisciplines.length} discipline instances (${pkgDisciplines.map((d,i)=>baseNumber+instanceSuffixFor(i,d)).join(', ')})?\n\nThe master package is kept as a roll-up; each instance becomes its own single-discipline package.`, + okLabel:'Split it'}))) return; // Master: flagged as a split container, keeps all disciplines for roll-up tracking. const masterId = editingId || base.id; @@ -803,24 +1159,352 @@ function splitByDiscipline(){ track('wp_split',{disciplines:pkgDisciplines.length}); toast('Split into '+children.length+' discipline instances'); const untagged=(base.materials||[]).filter(m=>!m.discipline).length; - const matNote = untagged ? `\n\nNote: ${untagged} material line${untagged===1?'':'s'} had no discipline tag and stayed on the master only — tag them before splitting to route them to an instance.` : ''; - alert('Created '+children.length+' instances:\n\n• '+children.map(c=>c.number+' ('+c.disciplines[0]+', '+(c.materials?c.materials.length:0)+' material line'+((c.materials&&c.materials.length===1)?'':'s')+')').join('\n• ')+'\n\nThe master '+baseNumber+' is kept as a roll-up.'+matNote); + if(untagged) toast(untagged+' material line'+(untagged===1?'':'s')+' had no discipline tag and stayed on the master only.', 'alert'); +} + +// ── ASSETS (Micron asset catalog) ──────────────────────────────────────────── +// Assets are picked from the Micron asset catalog, a SQL Server database this app +// reads through /api/assets. The lookup is strictly read-only — picking an asset +// never writes to the catalog, and there is no endpoint that could. +// +// The catalog can be absent (not configured) or unreachable (VPN/host down), and +// neither may block someone from writing a work package: in both cases the picker +// says so and manual entry carries on. Manually entered assets are marked +// source:'manual' so it stays visible which rows the catalog vouches for. +// The whole catalog is fetched once when the page loads and searched in memory — +// it is slow-moving reference data, so a request per keystroke would buy nothing +// and cost latency on every one. +let assetCatalog = []; // the full catalog, loaded once +let assetCatalogIndex = new Map(); // lowercased id -> the Micron DB's own casing +let assetCatalogState = 'loading'; // loading | ready | absent | error +let assetResults = []; // current matches; the result list indexes into this +const ASSET_RESULT_MAX = 500; // results shown at once — the box scrolls, not the search +const ASSET_IMPORT_MAX = 1000; // rows accepted from one CSV — see importAssets() + +function assetKey(a){ return String((a && a.tag) || '').trim().toLowerCase(); } +function assetAlreadyAdded(tag){ + const k = String(tag||'').trim().toLowerCase(); + return pkgAssets.some(a => assetKey(a) === k && k); } -// ── ASSETS (controls.dev) ──────────────────────────────────────────────────── -// Interim: assets are linked manually back to controls.dev. A future direct -// integration will let the user pick them from a list instead of pasting links. function buildAssets(){ const tb=document.getElementById('asset-body'); if(!tb) return; tb.innerHTML=''; - pkgAssets.forEach((a,i)=>{ const tr=document.createElement('tr'); - tr.innerHTML=` - - - `; - tb.appendChild(tr); }); + if(!pkgAssets.length){ + tb.innerHTML = `No assets yet — search the Micron DB above to add the assets this package covers.`; + return; + } + pkgAssets.forEach((a,i)=>{ + const tr=document.createElement('tr'); + // The asset ID on a catalog row is shown as text, not an input: the catalog + // is the source of truth for it and a locally edited copy would silently + // disagree. The note is always the user's own, so it stays editable either + // way. Every interpolation below is esc()'d text content or a numeric index + // — never a raw string inside an inline handler, which is the bug pattern + // recorded in KNOWN-ISSUES.md §1. + const idCell = a.source === 'catalog' + ? `${esc(a.tag)} Micron DB` + : ``; + tr.innerHTML = idCell + + ` + `; + tb.appendChild(tr); + }); +} + +// Kept for saved packages written before the picker existed: their rows have no +// `source`, so they would render as read-only catalog rows with no way to fix a +// typo. Anything that didn't come from the catalog is treated as manual. +function normaliseAsset(a){ + const o = Object.assign({ tag:'', desc:'', link:'', source:'manual' }, a||{}); + if(o.source !== 'catalog') o.source = 'manual'; + return o; +} + +function addManualAsset(){ + pkgAssets.push(normaliseAsset({})); + buildAssets(); + track('asset_added',{source:'manual'}); +} + +// ── CSV IMPORT ─────────────────────────────────────────────────────────────── +// Bulk-add a list of asset ids. Each imported id is checked against the loaded +// Micron DB: a hit is added as a catalog row (badge, id locked, stored with the +// DB's own casing); a miss is added as a manual row so it is visibly NOT vouched +// for rather than silently dropped. Nothing is ever written back to Micron. +const ASSET_ID_HEADERS = ['asset id','assetid','asset_id','asset','asset tag','assettag','tag','id']; + +function importAssets(ev){ + const f = ev.target.files && ev.target.files[0]; + if(!f){ return; } + const clear = () => { ev.target.value = ''; }; + if(/\.xlsx?$/i.test(f.name)){ + wpAlertDialog({title:'Load from CSV', message:'Please save the workbook as CSV first (File → Save As → CSV), then load it here.'}); + clear(); return; + } + const r = new FileReader(); + r.onload = () => { + let rows; + try { rows = parseCSV(r.result); } + catch(e){ toast('Could not parse that CSV.', 'alert'); clear(); return; } + if(!rows.length){ toast('That file has no rows.', 'alert'); clear(); return; } + applyImportedAssets(rows); + clear(); + }; + r.onerror = () => { toast('Could not read that file.', 'alert'); clear(); }; + r.readAsText(f); +} + +// Which column holds the ids, and whether row 0 is a header. +// - a recognised header name wins outright; +// - otherwise pick the column with the most Micron DB hits, so an export with +// the ids in column D works without the user rearranging it; +// - failing both (nothing matches — e.g. the DB is offline), use column 0. +function pickAssetColumn(rows){ + const head = (rows[0] || []).map(c => String(c || '').trim().toLowerCase()); + const named = head.findIndex(h => ASSET_ID_HEADERS.includes(h)); + if(named >= 0) return { col: named, start: 1 }; + + const width = rows.slice(0, 200).reduce((w, r) => Math.max(w, r.length), 1); + let best = 0, bestHits = 0; + for(let c = 0; c < width; c++){ + let hits = 0; + for(let i = 0; i < Math.min(rows.length, 200); i++){ + const v = String((rows[i] || [])[c] || '').trim(); + if(v && assetCatalogIndex.has(v.toLowerCase())) hits++; + } + if(hits > bestHits){ bestHits = hits; best = c; } + } + return { col: best, start: 0 }; +} + +function applyImportedAssets(rows){ + const { col, start } = pickAssetColumn(rows); + + // Collect, trimmed and de-duplicated within the file itself. + const seen = new Set(), ids = []; + for(let i = start; i < rows.length; i++){ + const v = String((rows[i] || [])[col] || '').trim(); + if(!v) continue; + const k = v.toLowerCase(); + if(seen.has(k)) continue; + seen.add(k); ids.push(v); + } + if(!ids.length){ toast('No asset ids found in that file.', 'alert'); return; } + + // Cap the import rather than building a table with thousands of rows. Reported, + // never silent — a truncated import that looked complete would be worse. + const capped = ids.length > ASSET_IMPORT_MAX; + const take = capped ? ids.slice(0, ASSET_IMPORT_MAX) : ids; + + let matched = 0, unmatched = 0, dupes = 0; + take.forEach(id => { + if(assetAlreadyAdded(id)){ dupes++; return; } + const canonical = assetCatalogIndex.get(id.toLowerCase()); + if(canonical){ + pkgAssets.push({ tag: canonical, desc: '', link: '', source: 'catalog' }); + matched++; + } else { + pkgAssets.push({ tag: id, desc: '', link: '', source: 'manual' }); + unmatched++; + } + }); + + buildAssets(); + renderAssetResults(); // rows just added should now read "added" + track('asset_imported', { matched: matched, unmatched: unmatched }); + + // Every id matched, nothing skipped, nothing truncated: a toast is enough. + // Anything the user needs to act on — unmatched ids, a silent-looking + // truncation, an unchecked import — interrupts with the detail instead. + const offline = assetCatalogState !== 'ready'; + if(matched && !unmatched && !dupes && !capped && !offline){ + toast('Added ' + matched + ' asset' + (matched === 1 ? '' : 's') + ' from the Micron DB'); + return; + } + + const parts = []; + if(matched) parts.push(matched + ' found in the Micron DB'); + if(unmatched) parts.push(unmatched + ' not in the Micron DB (added as manual rows)'); + if(dupes) parts.push(dupes + ' already on this package (skipped)'); + let msg = 'Imported ' + (matched + unmatched) + ' asset' + ((matched + unmatched) === 1 ? '' : 's') + + (parts.length ? ':\n\n• ' + parts.join('\n• ') : ''); + if(capped) msg += '\n\nThe list held ' + ids.length.toLocaleString() + ' ids — only the first ' + + ASSET_IMPORT_MAX.toLocaleString() + ' were added.'; + if(offline) msg += '\n\nNote: the Micron DB was not loaded, so nothing could be ' + + 'checked against it — every row was added as manual.'; + wpAlertDialog({title:'Asset import', message:msg}); +} + +function removeAsset(i){ + pkgAssets.splice(i,1); + buildAssets(); + renderAssetResults(); // a removed asset becomes addable again +} + +// ── Catalog lookup ─────────────────────────────────────────────────────────── +function assetSourceNote(msg, tone){ + const el = document.getElementById('asset-source-note'); if(!el) return; + el.textContent = msg || ''; + el.style.color = tone === 'warn' ? 'var(--red)' : ''; +} + +function initAssetPicker(){ + const box = document.getElementById('asset-search'); if(!box) return; + box.addEventListener('input', () => runAssetSearch(box.value)); + // Re-open on focus only when the list is actually closed. Adding an asset + // returns focus to this box, and re-running the search there would rebuild the + // list under the cursor and throw away the scroll position mid-multi-add. + box.addEventListener('focus', () => { + const results = document.getElementById('asset-results'); + if(results && results.hidden && box.value.trim()) runAssetSearch(box.value); + }); + // Pasting a column of ids straight out of Excel adds them all, rather than + // dropping a multi-line blob into a search box that can only match one thing. + // Excel gives \r\n between rows and \t between columns — i.e. exactly the CSV + // importer's row/cell shape, so it goes through the same matching path. + // A single value is left alone: that is an ordinary search, not a bulk add. + box.addEventListener('paste', e => { + const cb = e.clipboardData || window.clipboardData; + const text = cb ? cb.getData('text') : ''; + if(!text) return; + const lines = text.replace(/\r\n?/g, '\n').split('\n').filter(l => l.trim()); + if(lines.length < 2) return; // one id — paste it and search as normal + e.preventDefault(); + applyImportedAssets(lines.map(l => l.split('\t'))); + box.value = ''; + assetResults = []; + openAssetResults(false); + }); + box.addEventListener('keydown', e => { + if(e.key === 'Escape'){ openAssetResults(false); box.blur(); } + // Enter adds the first result that isn't already on the package — the common + // case of typing an exact tag and taking it without reaching for the mouse. + if(e.key === 'Enter'){ + e.preventDefault(); + const ix = assetResults.findIndex(tag => !assetAlreadyAdded(tag)); + if(ix >= 0) addCatalogAsset(ix); + } + }); + // Click-away closes, matching the .pp-menu pickers elsewhere on this form. + // Tested against composedPath() rather than e.target: adding an asset can + // re-render the row that was clicked, and a detached target reports itself as + // outside every container, which would close the list on every add. + document.addEventListener('click', e => { + const path = typeof e.composedPath === 'function' ? e.composedPath() : null; + const inside = path && path.length + ? path.some(n => n && n.id === 'asset-pick') + : !!(e.target.closest && e.target.closest('#asset-pick')); + if(!inside) openAssetResults(false); + }); + // Delegated so result rows never need an inline handler carrying catalog text. + const results = document.getElementById('asset-results'); + if(results) results.addEventListener('click', e => { + const row = e.target.closest('[data-asset-ix]'); if(!row) return; + addCatalogAsset(parseInt(row.getAttribute('data-asset-ix'), 10)); + }); + + box.disabled = true; + box.placeholder = 'Loading asset IDs from the Micron DB…'; + assetSourceNote('Loading asset IDs from the Micron DB…'); + + fetch('/api/assets', { headers:{ 'Accept':'application/json' } }) + .then(r => r.ok ? r.json() + : r.json().catch(() => ({})).then(b => Promise.reject(b.detail || 'The Micron DB could not be read.'))) + .then(body => { + if(!body.configured){ + assetCatalogState = 'absent'; + box.placeholder = 'Micron DB not configured — add assets manually below'; + assetSourceNote('The Micron DB is not connected, so assets are entered by hand. Use “+ Add asset not in the Micron DB”.'); + return; + } + assetCatalog = (body.assets || []).map(a => String(a.tag || '')); + // Lowercased lookup for the CSV importer: it decides whether an imported id + // is a real Micron asset, and maps it back to the DB's own casing so an + // id typed as "ahu-2p-014" is stored exactly as Micron spells it. + assetCatalogIndex = new Map(assetCatalog.map(t => [t.toLowerCase(), t])); + assetCatalogState = 'ready'; + box.disabled = false; + box.placeholder = 'Search asset IDs, or paste a column from Excel…'; + assetSourceNote(assetCatalog.length.toLocaleString() + ' asset IDs loaded from the Micron DB (read-only).'); + }) + .catch(err => { + assetCatalogState = 'error'; + box.placeholder = 'Micron DB unavailable — add assets manually below'; + assetSourceNote(typeof err === 'string' ? err + ' You can still add assets manually.' + : 'The Micron DB could not be reached. You can still add assets manually.', 'warn'); + }); +} + +// Filters the loaded catalog in memory. Exact match, then prefix, then contains — +// so typing a full asset ID puts that asset first rather than whichever ID +// happens to sort first. +function runAssetSearch(q){ + q = String(q||'').trim().toLowerCase(); + if(!q || assetCatalogState !== 'ready'){ + assetResults = []; renderAssetResults(); openAssetResults(false); return; + } + const exact=[], prefix=[], other=[]; + // The cap bounds each TIER, never the scan: breaking on a combined count let + // 500 alphabetically-early contains-matches evict an exact or prefix match + // that sorted after them - and Enter then added the wrong asset. The scan is + // in-memory and cheap; "the box scrolls, not the search" means the search + // sees everything. + for(const tag of assetCatalog){ + const t = tag.toLowerCase(); + if(t === q) exact.push(tag); + else if(t.startsWith(q)){ if(prefix.length < ASSET_RESULT_MAX) prefix.push(tag); } + else if(t.includes(q)){ if(other.length < ASSET_RESULT_MAX) other.push(tag); } + } + assetResults = exact.concat(prefix, other).slice(0, ASSET_RESULT_MAX); + renderAssetResults(); + openAssetResults(true); +} + +function renderAssetResults(){ + const box = document.getElementById('asset-results'); if(!box) return; + if(!assetResults.length){ + box.innerHTML = `
No matching asset IDs. Add it manually if it isn’t in the Micron DB yet.
`; + return; + } + box.innerHTML = assetResults.map((tag,ix) => { + const on = assetAlreadyAdded(tag); + return ``; + }).join(''); +} + +function openAssetResults(open){ + const box = document.getElementById('asset-results'); + const inp = document.getElementById('asset-search'); + if(box) box.hidden = !open; + if(inp) inp.setAttribute('aria-expanded', open ? 'true' : 'false'); +} + +function addCatalogAsset(ix){ + const tag = assetResults[ix]; if(!tag) return; + if(assetAlreadyAdded(tag)){ toast('That asset is already on this package'); return; } + pkgAssets.push({ tag: tag, desc: '', link: '', source: 'catalog' }); + buildAssets(); + toast('Added ' + tag); // announced (role=status) - a keyboard pick is otherwise silent + // Mark just this row instead of re-rendering the list: the results stay open + // for the next pick, the scroll position holds, and the clicked element is + // never detached mid-click (see the composedPath note in initAssetPicker). + markAssetResultAdded(ix); + const box = document.getElementById('asset-search'); + if(box) box.focus(); // keep typing straight into the next search + track('asset_added',{source:'catalog'}); +} + +function markAssetResultAdded(ix){ + const row = document.querySelector('#asset-results [data-asset-ix="' + ix + '"]'); + if(!row) return; + row.classList.add('is-added'); + row.disabled = true; + const label = row.querySelector('.asset-result-add'); + if(label) label.textContent = 'added'; } -function addAsset(){ pkgAssets.push({tag:'',desc:'',link:''}); buildAssets(); track('asset_added'); } -function removeAsset(i){ pkgAssets.splice(i,1); if(!pkgAssets.length)pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); } // ── ATTACHMENTS ────────────────────────────────────────────────────────────── function buildAttach(){ @@ -833,6 +1517,118 @@ function buildAttach(){ tb.appendChild(tr); }); } function addAttach(){ pkgAttach.push({doc:'',rev:'',link:''}); buildAttach(); } + +// ── CR-007 / D8: drawing uploads ───────────────────────────────────────────── +// Files attach to the SAVED record (they are rows, not form state), so the form +// around an upload is never at risk: a refused or failed upload changes nothing +// but the toast. Both limits are ALSO enforced by the server - these checks are +// the courtesy of refusing before the bytes travel. +function wpFileSize(n){ + if(n>=1024*1024) return (n/1024/1024).toFixed(1)+'MB'; + if(n>=1024) return Math.round(n/1024)+'KB'; + return n+'B'; +} +async function wpFileUpload(ev){ + const inp=ev.target; const f=inp.files&&inp.files[0]; inp.value=''; + if(!f) return; + if(!/^(application\/pdf|image\/)/.test(f.type||'')){ + toast('Only PDF and image files are accepted — "'+(f.type||f.name.split('.').pop()||'that')+'" is neither.'); + return; + } + if(f.size>5*1024*1024){ + toast('Files are limited to 5MB each — this one is '+wpFileSize(f.size)+'.'); + return; + } + if(!editingId){ + toast('Save the package first — uploads attach to the saved record.'); + return; + } + const desc=(document.getElementById('wp-file-desc')||{value:''}).value.trim(); + let b64=''; + try{ + b64=await new Promise((res,rej)=>{ const r=new FileReader(); + r.onload=()=>res(String(r.result).split(',')[1]||''); r.onerror=rej; r.readAsDataURL(f); }); + }catch(e){ toast('Could not read that file — the form is untouched.'); return; } + let resp, out={}; + try{ + resp=await fetch('/api/wps/'+encodeURIComponent(editingId)+'/files',{ + method:'POST', headers:{'Content-Type':'application/json'}, + body:JSON.stringify({name:f.name, mime:f.type, description:desc, data_base64:b64})}); + out=await resp.json().catch(()=>({})); + }catch(e){ + toast('Upload failed — the network dropped. Nothing else was lost; try again.'); + return; + } + if(!resp.ok){ + const d=out&&out.detail; + const msg=(d&&d.message)||(typeof d==='string'?d:'Upload failed ('+resp.status+')'); + toast(msg); + return; + } + pkgFiles.push(out.file); + wpFilesMirror(); + const de=document.getElementById('wp-file-desc'); if(de) de.value=''; + wpFilesRender(); wpFileUsageSet(out.used, out.ceiling); + toast('Uploaded '+f.name+'.'); track('file_uploaded'); +} +// Keep the local saved-package copy in step so exports and the offline cache +// see the list without another fetch. The server copy is authoritative. +function wpFilesMirror(){ + const ix=savedPackages.findIndex(x=>x.id===editingId); + if(ix>=0){ savedPackages[ix].files=pkgFiles.map(x=>({...x})); saveStore(); } +} +function wpFilesRender(){ + const box=document.getElementById('wp-file-list'); if(!box) return; + box.innerHTML=pkgFiles.map(f=>`
+ ${esc(f.name||'drawing')} + ${wpFileSize(f.size||0)} + + +
`).join(''); +} +async function wpFileDescSave(id, v){ + try{ + const r=await fetch('/api/files/'+encodeURIComponent(id),{method:'PATCH', + headers:{'Content-Type':'application/json'}, body:JSON.stringify({description:v})}); + if(!r.ok) throw 0; + const f=pkgFiles.find(x=>x.id===id); if(f) f.description=v; + wpFilesMirror(); + }catch(e){ toast('Could not save the description — it is shown but not saved yet.'); } +} +async function wpFileDelete(id){ + if(!(await wpConfirmDialog({title:'Remove drawing', + message:'Remove this drawing from the package? The bytes are deleted from the project storage.', + okLabel:'Remove'}))) return; + try{ + const r=await fetch('/api/files/'+encodeURIComponent(id),{method:'DELETE'}); + if(!r.ok) throw 0; + const out=await r.json(); + pkgFiles=pkgFiles.filter(f=>f.id!==id); + wpFilesMirror(); wpFilesRender(); wpFileUsageSet(out.used, out.ceiling); + toast('Drawing removed.'); + }catch(e){ toast('Could not remove the drawing — try again.'); } +} +// The running total, on the same line as the rules (D8: warn from 80%, refuse at +// the ceiling - the refusal itself comes from the server and names the number). +function wpFileUsageSet(used, ceiling){ + const el=document.getElementById('file-usage'); if(el==null) return; + if(typeof used!=='number' || !ceiling){ el.textContent=''; return; } + const pct=used/ceiling; + const txt=`Project storage: ${wpFileSize(used)} of ${wpFileSize(ceiling)} used.`; + el.innerHTML = pct>=1 ? `${txt} Full — remove a drawing to make room.` + : pct>=0.8 ? `${txt} Approaching the ceiling.` + : esc(txt); +} +async function wpFilesRefreshUsage(){ + if(!activeProjectId) return; + try{ + const r=await fetch('/api/projects/'+encodeURIComponent(activeProjectId)+'/storage'); + if(!r.ok) return; + const out=await r.json(); + wpFileUsageSet(out.used, out.ceiling); + }catch(e){} +} function removeAttach(i){ pkgAttach.splice(i,1); if(!pkgAttach.length)pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); } // ── ADD FILES FROM SOP FOLDER (no-auth interim) ────────────────────────────── @@ -853,7 +1649,7 @@ function renderSopFileFolders(){ const srcs=sopLinkedSources(); box.innerHTML = srcs.length ? `
1) Open a folder, multi-select files in SharePoint, then use Copy link:
`+ - `` + `` : `
No SOP folders defined — load or import an SOP first.
`; } function toggleSopFilePanel(){ @@ -917,6 +1713,13 @@ function setConstraint(i,val){ // issue it and scroll up to the status control so the change is visible. if(before==='open' && val!=='open' && readiness().open===0){ const st=getRadio('status'); + // CR-015: hold state is recalculated on EVERY constraint change, never read + // once and left to go stale. Clearing the last open constraint releases the + // hold right here - no refresh, no dialog - and returns the package to the + // status it held when the hold was placed. The old code fell through to the + // "Mark it as Issued now?" offer because STATUS_ORDER.indexOf('Issue') is -1, + // and declining it left the package on hold with nothing open. + if(st==='Issue'){ releaseHold(pkgConstraints[i].name); return; } // Don't offer to issue while a predecessor is still open — that would walk the // user straight into the override prompt they didn't ask for. if(readiness().blocking.length){ @@ -924,10 +1727,14 @@ function setConstraint(i,val){ toast('All constraints cleared — still waiting on '+readiness().blocking.length+' predecessor package(s).'); } else if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){ - if(confirm('All constraints are cleared — this Work Package is release-ready.\n\nMark it as Issued now?')){ - setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner(); - track('status_change',{status:'Issued',via:'constraint_clear'}); - } + wpConfirmDialog({title:'Release-ready', + message:'All constraints are cleared — this Work Package is release-ready.', + okLabel:'Mark it as Issued', cancelLabel:'Not yet'}).then(ok=>{ + if(ok){ + setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner(); + track('status_change',{status:'Issued',via:'constraint_clear'}); + } + }); const sg=document.getElementById('status-group'); if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'}); } @@ -949,7 +1756,16 @@ function readiness(){ function updateReleaseBanner(){ const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status'); let cls, txt; - if(st==='Issue'){ cls='rb-hold'; txt=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; } + if(st==='Issue'){ cls='rb-hold'; txt=`⊘ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; } + else if(st==='Ready for QA'){ + // CR-014: the QA decision lives where the state is announced. Accept moves to + // QC; reject returns to the crew and REQUIRES a comment (the modal enforces + // it, and the server refuses the transition without one). + cls='rb-qa'; + txt=`◉ In the QA queue — accept to begin QC, or return it to the crew with a comment.` + +` ` + +``; + } else if(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; } else if(r.constraintsClear){ // Constraints are done; what's left is upstream work. @@ -960,43 +1776,158 @@ function updateReleaseBanner(){ cls='rb-notready'; const extra = r.blocking.length ? ` · also waiting on ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}` : ''; txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open${extra}.`; + // D4: an Urgent package gets the audited override as the banner's primary + // action - not a hidden menu item, and not on Normal or High, whose gate is + // exactly as prominent as it was before this change. + if(STATUS_ORDER.indexOf(st)Release now — audited override`; + } } - b.innerHTML=`
${txt}
`; - updateStickyStatus(); + // A live region announces every rewrite, and this runs on saves and loads + // that change nothing - only touch the DOM when the message actually moved. + const html=`
${txt}
`; + if(b._rbLast!==html){ b._rbLast=html; b.innerHTML=html; } + updateConstraintBadge(); } // Releasing with an unclosed predecessor is allowed but must be explained. The // reason rides on the package (data.gateOverride) and the server writes it to the // audit log. Returns false if the user backed out. -function confirmEarlyRelease(blocking){ - const list=blocking.map(p=>'• '+(p.number||p.id)+' — '+p.status).join('\n'); - const reason=prompt('These predecessor packages are not Closed yet:\n\n'+list+ - '\n\nYou can still release this package, but the reason is recorded on it and in the audit log.\n\n'+ - 'Why is it being released now? (Cancel to stop.)'); - if(reason===null || !reason.trim()) return false; +async function confirmEarlyRelease(blocking, openConstraints){ + // D4 extended this to open constraints on an URGENT package - the same audited + // path, not a new one. A silent bypass would destroy the delay-documentation + // use case that justifies the constraint workflow, so the reason is mandatory + // and the override records exactly which gates it crossed. + const open=(openConstraints||[]).slice(); + const parts=[]; + if(open.length) parts.push('These constraints are still OPEN:\n\n'+open.map(n=>'• '+n).join('\n')); + if(blocking.length) parts.push('These predecessor packages are not Closed yet:\n\n'+ + blocking.map(p=>'• '+(p.number||p.id)+' — '+p.status).join('\n')); + const reason=await wpPromptDialog({ + title:'Release with a logged override', + message:parts.join('\n\n')+'\n\nYou can still release this package, but the reason is recorded on it and in the audit log.', + label:'Why is it being released now?', okLabel:'Release — log the reason', + validate:v=>v.trim()?'':'A reason is required — it goes on the package and in the audit log.', + }); + if(reason===null) return false; pkgGateOverride={ reason: reason.trim(), at: new Date().toISOString(), by: (window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '', blocking: blocking.map(p=>p.number||p.id) }; + // The server only honours a constraint override that NAMES what it covers, so + // a constraint opened after the override cannot ride through on an old reason. + if(open.length) pkgGateOverride.constraints=open; track('predecessor_gate_overridden'); return true; } -function onStatusChange(target){ +// Which status an on-hold package returns to. The hold entry records it at the +// moment the hold is placed (`from`); the newest hold entry wins. Falls back to +// 'Issued' for a package held before this field existed - it must have been +// released to be flagged, and 'Issued' is the most conservative released state. +function holdReturnStatus(){ + for(let i=pkgHolds.length-1;i>=0;i--){ + const h=pkgHolds[i]; + if(!h || h.released) continue; + if(h.from && STATUS_ORDER.indexOf(h.from)>=0) return h.from; + } + if(prevStatus && STATUS_ORDER.indexOf(prevStatus)>=0) return prevStatus; + return 'Issued'; +} + +// CR-015: leaving hold. Writes the release to the same history the hold went to - +// timestamp, user, what cleared it - and returns to the recorded prior status. +// Predecessors stay a refusable gate on the way back out (A1): if one reopened +// while the package sat on hold, the audited override is offered, never skipped. +async function releaseHold(clearedName){ + const back=holdReturnStatus(); + const r=readiness(); + if(STATUS_ORDER.indexOf(back)>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){ + if(!(await confirmEarlyRelease(r.blocking))){ + toast('Constraint cleared — still on hold: predecessor package(s) are not Closed.'); + updateReleaseBanner(); return; + } + } + pkgHolds.push({ ts:new Date().toISOString(), released:true, + constraint:clearedName||'', + details:'Hold released — last open constraint cleared'+(clearedName?': '+clearedName:''), + by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '', + to:back }); + setRadio('status', back); prevStatus=back; + updateReleaseBanner(); toast('Hold released — back to '+back+'.'); + track('hold_released',{to:back}); + const sg=document.getElementById('status-group'); + if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'}); +} + +// CR-014: accept / reject, from the banner. Accept is a plain forward step. +// Reject is the audited return: a comment is mandatory, rides on the package +// (data.qaRejections), and the server emails the owner and the QA group. +function qaAccept(){ + setRadio('status','QC'); prevStatus='QC'; + updateReleaseBanner(); toast('Accepted — package is in QC.'); + track('qa_accepted'); +} +function qaRejectOpen(){ + const el=document.getElementById('qa-reject-comment'); if(el) el.value=''; + document.getElementById('qa-reject-modal').classList.add('open'); + if(el) el.focus(); +} +function qaRejectCancel(){ document.getElementById('qa-reject-modal').classList.remove('open'); } +function qaRejectSubmit(){ + const c=(document.getElementById('qa-reject-comment')||{value:''}).value.trim(); + const errBox=document.getElementById('qa-reject-comment_err'); + if(!c){ + if(errBox) errBox.textContent='A comment is required — the crew needs to know what to fix.'; + const el=document.getElementById('qa-reject-comment'); if(el){ el.setAttribute('aria-invalid','true'); el.focus(); } + return; + } + if(errBox) errBox.textContent=''; + const qel=document.getElementById('qa-reject-comment'); if(qel) qel.removeAttribute('aria-invalid'); + pkgQaRejections.push({ ts:new Date().toISOString(), comment:c, + by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '', + from:'Ready for QA' }); + document.getElementById('qa-reject-modal').classList.remove('open'); + setRadio('status','In Progress'); prevStatus='In Progress'; + updateReleaseBanner(); toast('Returned to In Progress — the comment is on the record.'); + track('qa_rejected'); +} + +// D4: the banner's primary action for an Urgent package blocked by constraints. +// It runs the SAME audited path the status control runs - one override, one log. +async function urgentOverrideRelease(){ + const r=readiness(); + const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name); + if(!open.length && !r.blocking.length){ updateReleaseBanner(); return; } + if(!(await confirmEarlyRelease(r.blocking, open))) return; + setRadio('status','Issued'); prevStatus='Issued'; + updateReleaseBanner(); track('status_change',{status:'Issued', via:'urgent_override'}); +} + +async function onStatusChange(target){ const idx=STATUS_ORDER.indexOf(target); const r=readiness(); - // Constraints are a hard gate: nothing releases with one open. + // Constraints are a hard gate for Normal and High: nothing releases with one + // open. For an URGENT package the gate is refusable through the audited + // override (D4) - the same confirmEarlyRelease() path, never a silent bypass. if(idx>=ISSUED_IDX && r.open>0){ const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name); - alert('Cannot move to "'+target+'" — these constraints are still open:\n\n• '+open.join('\n• ')+'\n\nClear or mark N/A first.'); - setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return; + if(wpPriorityOf({priority: gv('wp_priority')})==='Urgent'){ + if(!(await confirmEarlyRelease(r.blocking, open))){ + setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return; + } + } else { + toast('Cannot move to "'+target+'" — '+open.length+' constraint'+(open.length===1?' is':'s are')+' still open. Clear or mark N/A first.', 'alert'); + gotoSection('constraint-card'); + setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return; + } } // Predecessors are a gate you can refuse: planners genuinely need to release // ahead of upstream work closing out. Refusing it requires a reason, which is // stored on the package and written to the audit log by the server. if(idx>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){ - if(!confirmEarlyRelease(r.blocking)){ + if(!(await confirmEarlyRelease(r.blocking))){ setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return; } } @@ -1024,8 +1955,19 @@ function submitHold(){ const constraint=document.getElementById('hold-constraint').value; const details=document.getElementById('hold-details').value.trim(); const doclink=document.getElementById('hold-doclink').value.trim(); - if(!details){ alert('A comment defining the issue is required.'); return; } - pkgHolds.push({ ts:new Date().toISOString(), constraint, details, doc:doclink, photo:holdPhotoData||'' }); + const hErr=document.getElementById('hold-details_err'); + if(!details){ + if(hErr) hErr.textContent='A comment defining the issue is required.'; + const hel=document.getElementById('hold-details'); if(hel){ hel.setAttribute('aria-invalid','true'); hel.focus(); } + return; + } + if(hErr) hErr.textContent=''; + const hel2=document.getElementById('hold-details'); if(hel2) hel2.removeAttribute('aria-invalid'); + pkgHolds.push({ ts:new Date().toISOString(), constraint, details, doc:doclink, photo:holdPhotoData||'', + by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '', + // What the release returns to. prevStatus is captured before the status pill + // switches; guard against re-holding while already on hold. + from:(prevStatus && prevStatus!=='Issue') ? prevStatus : holdReturnStatus() }); const c=pkgConstraints.find(x=>x.name===constraint); if(c){ c.status='open'; c.comment=details; } buildConstraints(); setRadio('status','Issue'); prevStatus='Issue'; holdContext=null; document.getElementById('hold-modal').classList.remove('open'); @@ -1077,13 +2019,22 @@ function onSignoffSigned(i,checked){ if(checked && !pkgSignoffs[i].date){ pkgSignoffs[i].date=todayStr(); pkgSignoffs[i].dateReason=''; } buildSignoffs(); track('signoff',{role:pkgSignoffs[i].role, signed:checked}); } -function onSignoffDateOverride(i){ +async function onSignoffDateOverride(i){ const s=pkgSignoffs[i]; - const nd=prompt('Manual sign-off date for '+s.role+' (YYYY-MM-DD). The date is normally set automatically when Signed is checked.', s.date||todayStr()); + const nd=await wpPromptDialog({ + title:'Manual sign-off date — '+s.role, + message:'The date is normally set automatically when Signed is checked.', + label:'Sign-off date (YYYY-MM-DD)', value:s.date||todayStr(), okLabel:'Next', + validate:v=>/^\d{4}-\d{2}-\d{2}$/.test(v.trim())?'':'Enter the date as YYYY-MM-DD.', + }); if(nd===null) return; - if(!/^\d{4}-\d{2}-\d{2}$/.test(nd.trim())){ alert('Enter the date as YYYY-MM-DD.'); return; } - const reason=prompt('Reason for manually overriding the '+s.role+' sign-off date:'); - if(reason===null || !reason.trim()){ alert('A reason is required for a manual date override.'); return; } + const reason=await wpPromptDialog({ + title:'Manual sign-off date — '+s.role, + message:'The override and its reason stay on the package.', + label:'Reason for the manual date', okLabel:'Override the date', + validate:v=>v.trim()?'':'A reason is required for a manual date override.', + }); + if(reason===null) return; s.date=nd.trim(); s.dateReason=reason.trim(); buildSignoffs(); track('signoff_date_override',{role:s.role}); } @@ -1098,6 +2049,14 @@ function collectPackage(){ parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined, 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 + + cost:gv('wp_cost'), wbs:gv('wp_wbs'), assigneeId:gv('wp_assignee'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'), // Account ids behind those names — notification routing needs an account; @@ -1117,13 +2076,16 @@ function collectPackage(){ gateOverride:pkgGateOverride || undefined, assets:pkgAssets.filter(a=>a.tag||a.link||a.desc), materials:pkgMaterials.filter(m=>m.qty||m.desc), attachments:pkgAttach.filter(a=>a.doc), - kitStatus:gv('wp_kit_status'), kitOwner:gv('wp_kit_owner'), kitDate:gv('wp_kit_date'), + kitStatus:gv('wp_kit_status'), kitOwner:gv('wp_kit_owner'), kitOwnerId:pkgKitOwnerId||'', delivBuilding:gv('wp_deliv_building'), delivFloor:gv('wp_deliv_floor'), delivSector:gv('wp_deliv_sector'), delivDetail:gv('wp_deliv_detail'), deliveryLoc:deliveryLocOf({delivBuilding:gv('wp_deliv_building'), delivFloor:gv('wp_deliv_floor'), delivSector:gv('wp_deliv_sector'), delivDetail:gv('wp_deliv_detail')}), kitDate:gv('wp_kit_date'), mimoTime:gv('wp_mimo_time'), mimoLoc:gv('wp_mimo_loc'), constraints:pkgConstraints.map(c=>({name:c.name,status:c.status,comment:c.comment})), qc:gv('wp_qc'), photo:gv('wp_photo'), hold:gv('wp_hold'), qcFromSOP: !pkgOverrides['wp_qc'], photoFromSOP: !pkgOverrides['wp_photo'], holdFromSOP: !pkgOverrides['wp_hold'], overrides:{...pkgOverrides}, signoffs:pkgSignoffs.map(s=>({role:s.role,name:s.name,date:s.date,signed:s.signed,dateReason:s.dateReason||''})), holds:pkgHolds.map(h=>({...h})), + qaRejections:pkgQaRejections.map(x=>({...x})), + materialRequests:pkgMatRequests.map(x=>({...x})), + files:pkgFiles.map(x=>({...x})), // metas only; the server re-asserts this key on save actualHrs:gv('wp_actual_hrs'), installedQty:gv('wp_installed_qty'), redlines:gv('wp_redlines'), lessons:gv('wp_lessons'), kind: pkgKind, // 'iwp' (install) | 'ewp' (BIM) — drives which fields/types/gates apply // AWP traceability: which BIM/model package(s) enabled this install package. @@ -1140,57 +2102,94 @@ function collectPackage(){ updatedAt:new Date().toISOString() }; } -function savePackage(view){ - if(!gv('wp_subject') || !gv('wp_type')){ alert('Subject and WP Type are required.'); return; } +async function savePackage(view){ + if(window._projArchived){ + toast('This project is archived — read-only. Nothing can be saved to it.', 'alert'); + return; + } + if(!wpValidateForm()) return; // A BIM package marked "Signed off (IFF)" without the number isn't traceable. // The status can also be set programmatically (the per-discipline roll-up), so // re-check the predecessor gate at the point of saving. const _r=readiness(); if(STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX && _r.blocking.length && !pkgGateOverride){ - if(!confirmEarlyRelease(_r.blocking)) return; - } - if(isEwp() && iffRequired() && !gv('wp_iff').trim()){ - alert('Coordination is set to "Signed off (IFF)" — enter the IFF number so the sign-off is traceable.'); - const el=document.getElementById('wp_iff'); if(el){ el.focus(); el.scrollIntoView({behavior:'smooth',block:'center'}); } - return; + if(!(await confirmEarlyRelease(_r.blocking))) return; } + // The IFF rule is part of wpValidateForm() now - checked above with the rest. const pkg=collectPackage(); const ix=savedPackages.findIndex(p=>p.id===pkg.id); if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg); editingId=pkg.id; saveStore(); renderSavedList(); track('package_saved',{status:pkg.status}); + wpMarkFormClean(); + // The record now holds this work, so the draft is no longer protecting anything. + // Left in place it would make the next load offer to "recover" work already saved. + if(typeof WPAutosave!=='undefined') WPAutosave.settled(wpDraftId()); if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(pkg, activeProjectId); // share to server document.getElementById('loadingOverlay').classList.add('active'); setTimeout(()=>{ document.getElementById('loadingOverlay').classList.remove('active'); if(view) renderPackage(pkg); }, 400); } +// The package as a document — this is both the detail view and, via +// printPackage(), the PDF export: the print window reuses #pkg-doc's HTML, so a +// section suppressed here is suppressed in both. That is why CR-006 needed one +// change rather than two. +// +// Built as a LIST of (section id, html) blocks rather than one long string, so a +// section this project has turned off is dropped and what survives is numbered +// 1.0, 2.0, 3.0 — not 1.0, 3.0, 4.0 with a hole where Assets used to be. +let _lastRenderedPkg = null; + function renderPackage(pkg){ + _lastRenderedPkg = pkg; const r = pkg.constraints ? {open:pkg.constraints.filter(c=>c.status==='open').length,total:pkg.constraints.length} : {open:0,total:0}; const readyTxt = pkg.status==='Issue' ? 'ON HOLD' : (r.open===0?'RELEASE-READY':(r.open+' OPEN CONSTRAINTS')); const kindLbl = pkg.kind==='ewp' ? 'BIM (EWP)' : 'IWP'; - let h=`

${esc(pkg.number||'(no number)')} — Work Package

+ let head=`

${esc(pkg.number||'(no number)')} — Work Package

${esc(pkg.project)} · ${kindLbl} · TYPE: ${esc(pkg.type).toUpperCase()} · STATUS: ${esc(pkg.status).toUpperCase()} · ${readyTxt}
`; const costDesc = (COST_CODES.find(c=>c.split('|')[0]===pkg.cost)||'').split('|')[1]; // Project system links travel with the WP; fall back to the live SOP for older packages. const plinks = (pkg.projectLinks&&pkg.projectLinks.length) ? pkg.projectLinks : ((SOP&&SOP.projectLinks)||[]); - h+=`

1.0 General Information

+ + // Each entry is [section id, title, body]. `null` bodies are skipped, which is + // how a section that simply has no content behaves — different from a section + // this project does not use, which never reaches the list at all. + const blocks = []; + const add = (id, title, body) => { if(body != null && sectionOn(id)) blocks.push([title, body]); }; + + add('general', 'General Information', `
${pkg.disciplines&&pkg.disciplines.length?``:''} - - - + ${sectionOn('location')?``:''} + ${fieldOn('costCode')?``:''} + ${fieldOn('acumaticaTask')?``:''} - + + + ${plinks.length?``:''} ${pkg.bimlink?``:''} ${(pkg.lod||pkg.iff||pkg.modelArea||pkg.clash||pkg.scanLink)?``:''} -
WP Number${cell(pkg.number)}
Subject${cell(pkg.subject)}
Type${cell(pkg.type)}
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)}
Location${cell(pkg.location)}
Cost Code${pkg.cost?esc(pkg.cost)+(costDesc?' — '+esc(costDesc):''):ns()}
Acumatica Task${cell(pkg.wbs)}
Location${cell(wpLocationText(pkg))}
Cost Code${pkg.cost?esc(pkg.cost)+(costDesc?' — '+esc(costDesc):''):ns()}
Acumatica Task${cell(pkg.wbs)}
Assignees${cell(pkg.assignees)}
Distribution${cell(pkg.distribution)}
Due Date${cell(pkg.due)}
Priority${esc(wpPriorityOf(pkg))}
Due Date${cell(pkg.due)}${pkg.p6Id?` · P6 activity ${esc(pkg.p6Id)}`:''}
P6 Activity${pkg.p6Id?esc(pkg.p6Id)+(pkg.p6Desc?' — '+esc(pkg.p6Desc):''):ns()}
Specification Section${cell(pkg.spec)}
Description${cell(pkg.desc)}
Project Systems${plinks.map(l=>esc(l.label)+': '+linkify(l.url)).join('
')}
Enabled by (BIM)${/^https?:\/\//i.test(pkg.bimlink)?linkify(pkg.bimlink):cell(pkg.bimlink)}
BIM / Model${[pkg.modelArea?'Area: '+esc(pkg.modelArea):'', pkg.clash?'Coordination: '+esc(pkg.clash):'', pkg.iff?'IFF #: '+esc(pkg.iff):'', pkg.scanLink?'Scan: '+linkify(pkg.scanLink):'', pkg.lod?'LOD: '+esc(pkg.lod)+' (legacy)':''].filter(Boolean).join('
')}
`; - if(pkg.assets&&pkg.assets.length){ h+=`

2.0 Assets (controls.dev)

`; - pkg.assets.forEach(a=>h+=``); h+=`
Asset Tag / IDDescriptioncontrols.dev Link
${cell(a.tag)}${cell(a.desc)}${a.link?linkify(a.link):ns()}
`; } + `); + + // Location has no block of its own yet — it is a row inside General + // Information, suppressed above. CR-004 gives it structured fields in wave 6 + // and this is where its own block will go. Recorded rather than left implicit, + // because "the toggle does nothing" and "the toggle governs one row" look the + // same from outside. + + // D11: two columns now. The controls.dev link column died with the link field; + // a catalog row's identity is its ID, and the note is the user's own text. + if(pkg.assets&&pkg.assets.length){ + let t=``; + pkg.assets.forEach(a=>t+=``); + add('assets', 'Assets', t+`
Asset IDNote
${cell(a.tag)}${cell(a.desc)}
`); + } + let scopeHtml; if(pkg.scope && Object.keys(pkg.scope).length){ // per-discipline scope sections scopeHtml = Object.keys(pkg.scope).map(d=>{ @@ -1203,7 +2202,7 @@ function renderPackage(pkg){ const stepsArr = pkg.workSteps && pkg.workSteps.length ? pkg.workSteps : (pkg.work?String(pkg.work).split('\n').filter(Boolean):[]); scopeHtml = stepsArr.length ? '
    '+stepsArr.map(s=>`
  1. ${esc(s)}
  2. `).join('')+'
' : ns(); } - h+=`

3.0 Scope & Work

+ add('scope', 'Scope & Work', `
${pkg.gateOverride?``:''} -
Description of Work${scopeHtml}
Labor Est. Hrs.${cell(pkg.hours)}
Predecessor packages${ @@ -1213,54 +2212,87 @@ function renderPackage(pkg){ }
Sequence phase${pkg.seq?esc(pkg.seq):ns()}
Released earlyPredecessor gate overridden.
${esc(pkg.gateOverride.reason||'')}
${esc(pkg.gateOverride.by||'')} · ${esc(pkg.gateOverride.at?wpFormatDateTime(pkg.gateOverride.at):'')}
`; + `); + if(pkg.materials&&pkg.materials.length){ const showDisc=pkg.materials.some(m=>m.discipline); - h+=`

4.0 Material List

${showDisc?'':''}`; - pkg.materials.forEach(m=>h+=`${showDisc?``:''}`); h+=`
QtyUnitDescriptionDiscipline
${cell(m.qty)}${cell(m.unit)}${cell(m.desc)}${cell(m.discipline)}
`; } - if(pkg.attachments&&pkg.attachments.length){ h+=`

5.0 Drawings & Attachments

`; - pkg.attachments.forEach(a=>h+=``); h+=`
DocumentRevLink / Note
${cell(a.doc)}${cell(a.rev)}${a.link?linkify(a.link):ns()}
`; } - h+=`

6.0 Kitting & MIMO

+ let t=`
${showDisc?'':''}`; + pkg.materials.forEach(m=>t+=`${showDisc?``:''}`); + add('materials', 'Material List', t+`
QtyUnitDescriptionDiscipline
${cell(m.qty)}${cell(m.unit)}${cell(m.desc)}${cell(m.discipline)}
`); } + + if((pkg.attachments&&pkg.attachments.length)||(pkg.files&&pkg.files.length)){ + let t=``; + (pkg.attachments||[]).forEach(a=>t+=``); + // CR-007: uploaded drawings print WITH the package - the row for every file, + // the image itself inline (it is the sheet the crew needs), PDFs as links. + (pkg.files||[]).forEach(f=>t+=``); + t+=`
DocumentRevLink / Note / Focus area
${cell(a.doc)}${cell(a.rev)}${a.link?linkify(a.link):ns()}
${esc(f.name||'drawing')} [uploaded, ${wpFileSize(f.size||0)}]${ns()}${cell(f.description)}
`; + (pkg.files||[]).filter(f=>/^image\//.test(f.mime||'')).forEach(f=>{ + t+=`
${(f.name||'').replace(/` + +(f.description?`
${esc(f.description)}
`:'')+`
`; + }); + add('drawings', 'Drawings & Attachments', t); } + + add('kitting', 'Kitting & MIMO', ` -
Kitting Status${cell(pkg.kitStatus)}
Warehouse Owner${cell(pkg.kitOwner)}
Kitting Need Date${cell(pkg.kitDate)}
MIMO Sch. Time / Location${cell(pkg.mimoTime)} ${pkg.mimoLoc?'· '+esc(pkg.mimoLoc):''}
`; - h+=`

7.0 Constraints — Release Readiness

`; + +
ConstraintStatusComment
Delivery Location${cell(pkg.deliveryLoc || deliveryLocOf(pkg))}
`); + + let ct=``; (pkg.constraints||[]).forEach(c=>{ const lbl=c.status==='cleared'?'Cleared':c.status==='na'?'N/A':'Open'; const col=c.status==='cleared'?'var(--accent-green)':c.status==='na'?'var(--text-dim)':'var(--red)'; - h+=``; }); - h+=`
ConstraintStatusComment
${esc(c.name)}${lbl}${cell(c.comment)}
`; - h+=`

8.0 Quality & Hold Points

+ ct+=``; }); + add('constraints', 'Constraints — Release Readiness', ct+`
${esc(c.name)}${lbl}${cell(c.comment)}
`); + + add('qaqc', 'Quality & Hold Points', ` -
QC${cell(pkg.qc)}${pkg.overrides&&pkg.overrides.wp_qc?` (overridden: ${esc(pkg.overrides.wp_qc)})`:(pkg.qcFromSOP?' [from SOP]':'')}
Photo Documentation${cell(pkg.photo)}${pkg.overrides&&pkg.overrides.wp_photo?` (overridden: ${esc(pkg.overrides.wp_photo)})`:(pkg.photoFromSOP?' [from SOP]':'')}
Witness / Hold Points${cell(pkg.hold)}
`; + `); + if(pkg.holds&&pkg.holds.length){ - h+=`

8.5 Hold Log

`; + let t=`
LoggedConstraintDetailsSupport
`; pkg.holds.forEach(hd=>{ const when=hd.ts?wpFormatDateTime(hd.ts):''; const sup=[hd.doc?linkify(hd.doc):'', hd.photo?'photo attached':''].filter(Boolean).join('
')||ns(); - h+=``; }); - h+=`
LoggedByConstraintDetailsSupport
${esc(when)}${cell(hd.constraint)}${cell(hd.details)}${sup}
`; + t+=`${esc(when)}${cell(hd.by)}${cell(hd.constraint)}${cell(hd.details)}${sup}`; }); + // The hold LOG belongs to QA/QC — it is the record of the hold points that + // section defines, so it goes with it rather than surviving on its own. + add('qaqc', 'Hold Log', t+``); } - h+=`

9.0 Approvals & Sign-offs

`; - (pkg.signoffs||[]).forEach(s=>h+=``); - h+=`
RoleNameDateSigned
${esc(s.role)}${cell(s.name)}${cell(s.date)}${s.signed?'✓':'—'}
`; - if(pkg.actualHrs||pkg.installedQty||pkg.redlines||pkg.lessons){ h+=`

10.0 Closeout

+ + // Approvals are not one of CR-006's ten sections and are not toggleable: a + // package nobody signed is not a shorter package, it is an unapproved one. + let st=`
`; + (pkg.signoffs||[]).forEach(s=>st+=``); + blocks.push(['Approvals & Sign-offs', st+`
RoleNameDateSigned
${esc(s.role)}${cell(s.name)}${cell(s.date)}${s.signed?'✓':'—'}
`]); + + if(pkg.actualHrs||pkg.installedQty||pkg.redlines||pkg.lessons){ + add('closeout', 'Closeout', ` -
Actual Hrs.${cell(pkg.actualHrs)}
Installed Quantity${cell(pkg.installedQty)}
Redlines / As-Built${cell(pkg.redlines)}
Lessons Learned${cell(pkg.lessons)}
`; } + `); } + + let h = head + blocks.map(([title, body], i) => `

${i+1}.0 ${esc(title)}

${body}`).join(''); h+=`

Pushed to: ${cell(pkg.track)}

`; document.getElementById('pkg-doc').innerHTML=h; showOutput(); } function printPackage(){ const c=document.getElementById('pkg-doc').innerHTML; const w=window.open('','_blank'); - w.document.write(`Work Package${c}`); + // C4/T9.9 (and BL-008): the popup document has no stylesheet, so the theme's + // values are read from the live page and inlined - including THE blue where + // the second brand blue (#2563d6) used to be. + const tok=(name)=>getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + w.document.write(`Work Package${c}`); w.document.close(); w.print(); } // ── VIEWS ──────────────────────────────────────────────────────────────────── function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; } -function showOutput(){ hideDashboard(); setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); } +function showOutput(){ hideDashboard(); setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; syncToolTabs(); window.scrollTo({top:0,behavior:'smooth'}); } +// Populating the form establishes the clean state it is later compared against. +function wpFormPopulated(){ setTimeout(wpMarkFormClean, 0); } function showForm(){ hideDashboard(); // This clears every card's inline display, which also clears the "hidden" set by @@ -1274,82 +2306,595 @@ function showForm(){ buildDisciplinePicker(); renderScope(); setFormChrome(true); currentView='Work Package Form'; + syncToolTabs(); window.scrollTo({top:0,behavior:'smooth'}); } +// ── TOOL TABS (B7 / T7.1) ───────────────────────────────────────── +// The strip the suite page used to own alone. Two of its three tabs switch view +// inside this document; the third goes back to the wizard, and carries the +// project so the wizard opens on the same one. +// +// `aria-current` rather than a class alone: the active tab has to be announceable, +// and the 3px underline is a colour cue that a screen reader cannot see. +function syncToolTabs(){ + const link = document.querySelector('a.nav-tab[data-nav-href]'); + if(link){ + const base = link.getAttribute('data-nav-href') || ''; + link.setAttribute('href', base + (activeProjectId + ? (base.indexOf('?') >= 0 ? '&' : '?') + 'project=' + encodeURIComponent(activeProjectId) + : '')); + } + const current = (currentView === 'Dashboard') ? 'dashboard' : 'wp'; + document.querySelectorAll('.nav-tab').forEach(t => { + const on = t.getAttribute('data-tab') === current; + t.classList.toggle('active', on); + if(on) t.setAttribute('aria-current', 'page'); else t.removeAttribute('aria-current'); + }); +} + // Sticky save bar + section-nav chrome (shown only on the editable form view). function setFormChrome(on){ - const nav=document.getElementById('section-nav'), save=document.getElementById('sticky-save'); - if(nav) nav.style.display = on ? '' : 'none'; + const rail=document.getElementById('section-rail'), save=document.getElementById('sticky-save'); + if(rail) rail.style.display = on ? '' : 'none'; if(save) save.style.display = on ? 'flex' : 'none'; document.body.classList.toggle('has-sticky-save', !!on); - if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); initSectionNavAutoHide(); } + document.body.classList.toggle('has-sec-rail', !!on); + if(on){ initSectionNavAutoHide(); buildSectionRail(); updateConstraintBadge(); } else positionSectionNav(); } -// Make each form card collapsible by clicking its heading (idempotent). -function makeCollapsible(){ - document.querySelectorAll('.main > .card').forEach(card=>{ - if(card.id==='saved-card') return; - const head=card.querySelector('.section-header, .sub-heading'); - if(!head || head.dataset.collapsible) return; - head.dataset.collapsible='1'; - head.style.cursor='pointer'; - const chev=document.createElement('span'); chev.className='collapse-chev'; chev.textContent='▾'; - head.insertBefore(chev, head.firstChild); - head.addEventListener('click', e=>{ - if(['INPUT','SELECT','TEXTAREA','BUTTON','A'].includes(e.target.tagName) || e.target.classList.contains('help-tip')) return; - const collapsed=card.classList.toggle('collapsed'); - chev.textContent = collapsed ? '▸' : '▾'; +// ── SECTION STRUCTURE (F6 / D3) ─────────────────────────────────────────────── +// F6 measured this form at 11 cards in one 5,399px scroll, with a strip of jump +// chips standing in for structure. D3 settled the shape, and it is not tabs: +// one page, navigation down the side, sections collapsible, only the current one +// open, plus an `Expand all` for people who would rather scroll straight through. +// +// Tabs were rejected because they hide sections a first-time author does not know +// exist. The uncollapsed long form was rejected because it is the page F6 exists +// to fix. So the height criterion became "at rest" - the state the page loads in - +// which is what D3 amended it to, in writing, rather than quietly failing the old +// one. +// +// Two accessibility defects come out with the old strip, both named in CLAUDE.md: +// - the jump chips were ``; there were two left in the app +// - collapsing was a click listener on a heading `
`, so it was mouse-only +// Every control here is a real button. The rail is built FROM THE CARDS, so a +// section added later, or suppressed by a CR-006 toggle, changes the rail without +// anyone remembering to maintain a second list. + +const SEC_EXPAND_KEY = 'wp_iwp_expand_all'; +let secExpandAll = false; +let secCurrent = ''; +let _secSpyBound = false; + +// The cards that are sections: rendered, and carrying a heading to name them. +// `hidden` and `display:none` are how CR-006 and applyKindVisibility() suppress +// one, and a table of contents has to lose exactly what the form lost. +function secCards(){ + return [...document.querySelectorAll('.main > .card')].filter(card => { + // `Saved work packages` is a list of OTHER packages, sitting at the bottom of + // the form for editing THIS one - and the navigator panel already lists exactly + // the same thing. It is not a section of the package, so it gets no rail entry. + // It is still collapsed by default (secApplyOpenState), because 310px of a + // duplicate list is not what the page should open on. + if(card.id === 'saved-card') return false; + if(card.hidden || card.style.display === 'none') return false; + return !!card.querySelector('.section-title, .sub-heading'); + }); +} + +function secLabel(card){ + const h = card.querySelector('.section-title, .sub-heading'); + if(!h) return ''; + const clone = h.cloneNode(true); + clone.querySelectorAll('.help-tip, .auto-tag, .collapse-chev').forEach(x => x.remove()); + return clone.textContent.trim().replace(/\s+/g, ' '); +} + +// Everything below the heading, wrapped once so `aria-controls` has a real target +// and collapsing is `hidden` on one element rather than a `:not()` rule listing +// the heading classes. The old CSS did the latter, which meant the disclosure +// state lived nowhere a screen reader could read it. +function secBody(card){ + let body = card.querySelector(':scope > .card-body'); + if(body) return body; + const head = card.querySelector(':scope > .section-header, :scope > .sub-heading'); + if(!head) return null; + body = document.createElement('div'); + body.className = 'card-body'; + body.id = (card.id || 'sec') + '-body'; + const rest = []; + for(let n = head.nextSibling; n; n = n.nextSibling) rest.push(n); + rest.forEach(n => body.appendChild(n)); + card.appendChild(body); + return body; +} + +// Turn the heading into a disclosure button, in place and once. The label goes +// inside the button; the help tip and the description stay OUTSIDE it, because a +// tooltip trigger nested in a button is two controls in one hit area. +function secMakeToggle(card){ + if(card.dataset.secReady) return; + const body = secBody(card); + if(!body) return; + const title = card.querySelector(':scope > .section-header > .section-title') + || card.querySelector(':scope > .sub-heading'); + if(!title) return; + + const label = secLabel(card); + // Every ELEMENT in the heading has to survive, not only the text. `saved-card`'s + // heading carries , which renderSavedList() writes to on + // every save - clearing the heading destroyed it, renderSavedList() then threw on + // a null, and boot stopped one line short of setting wpCreatorReady. The page + // still rendered, so it looked like a slow load rather than a crash. + // + // Help tips go OUTSIDE the button: a tooltip trigger nested inside a button is + // two controls sharing one hit area. Everything else goes inside, because it is + // part of the heading's own text - a count, a badge, a tag. + const kids = [...title.children]; + const tips = kids.filter(el => el.classList.contains('help-tip')); + const inside = kids.filter(el => !el.classList.contains('help-tip')); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'card-toggle'; + btn.setAttribute('aria-expanded', 'true'); + btn.setAttribute('aria-controls', body.id); + btn.innerHTML = '' + + ''; + const labelEl = btn.querySelector('.card-toggle-label'); + labelEl.textContent = label; + inside.forEach(el => labelEl.appendChild(el)); + title.textContent = ''; + title.appendChild(btn); + tips.forEach(t => title.appendChild(t)); + + btn.addEventListener('click', () => { + const open = card.classList.contains('collapsed'); + secSetOpen(card, open); + // Opening a section by its own header makes it the one you are in, which the + // rail has to agree with - otherwise the rail marks a section you left. + if(open){ secCurrent = card.id; secMarkRail(); secSyncUrl(); } + }); + card.dataset.secReady = '1'; +} + +function secSetOpen(card, open){ + const btn = card.querySelector(':scope .card-toggle'); + const body = card.querySelector(':scope > .card-body'); + card.classList.toggle('collapsed', !open); + if(btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + if(body) body.hidden = !open; +} + +function secIsOpen(card){ return !card.classList.contains('collapsed'); } + +// ── the rail ───────────────────────────────────────────────────────────────── +function buildSectionRail(){ + const rail = document.getElementById('section-rail'); + const list = document.getElementById('sec-rail-list'); + if(!rail || !list) return; + let cards = secCards(); + // Every section carries a real id in the markup. A positional fallback was here + // and it was a bug waiting to be shipped: the id it invents goes into ?section= + // as a shareable address, and the INDEX moves whenever the set of visible + // sections changes - a CR-006 toggle, the BIM flag, or a card being inserted + // ahead of it. A link somebody sent then opens a different section, silently and + // with no error. Name the card in the markup instead; complain loudly if not. + cards.forEach(card => { + if(!card.id){ + // Not a toast: this is a defect in the page, not something a user can act on. + try { console.error('T7.2: a form section has no id; its rail entry cannot be' + + ' addressed. Give it one in wp-creation-index.html.', card); } catch(e){} + return; + } + secMakeToggle(card); + }); + cards = cards.filter(c => c.id); + + list.innerHTML = cards.map(card => + '
  • ').join(''); + list.querySelectorAll('.sec-rail-item').forEach(b => { + b.addEventListener('click', () => gotoSection(b.dataset.sec)); + }); + rail.hidden = cards.length < 2; + + // Whatever the URL asks for, else the first section. Never "all of them", which + // is the state F6 is about. + const want = (typeof WPUrl !== 'undefined' && WPUrl.get && WPUrl.get('section')) || ''; + const target = cards.some(c => c.id === want) ? want : (cards[0] && cards[0].id) || ''; + secApplyOpenState(target); + updateConstraintBadge(); + secBindSpy(); +} + +// One section open, or all of them. Called on every render, so it is also what +// keeps a suppressed section from being left expanded behind the scenes. +function secApplyOpenState(currentId){ + const cards = secCards(); + secCurrent = currentId || secCurrent || (cards[0] && cards[0].id) || ''; + cards.forEach(card => secSetOpen(card, secExpandAll || card.id === secCurrent)); + // The saved-package list is not in `cards` - it is not a section - but it is on + // the page and it is 310px tall, so it collapses like everything else. It gets + // its disclosure button here because the rail loop no longer reaches it. + const saved = document.getElementById('saved-card'); + if(saved && saved.style.display !== 'none'){ + secMakeToggle(saved); + secSetOpen(saved, secExpandAll); + } + secMarkRail(); +} + +function secMarkRail(){ + document.querySelectorAll('.sec-rail-item').forEach(b => { + const on = b.dataset.sec === secCurrent; + b.classList.toggle('is-current', on); + // aria-current="true", not "page": these are places in a page, not pages. + if(on) b.setAttribute('aria-current', 'true'); + else b.removeAttribute('aria-current'); + }); +} + +function secSyncUrl(opts){ + if(typeof WPUrl === 'undefined') return; + const patch = { section: secCurrent || '' }; + (opts && opts.replace ? WPUrl.replace : WPUrl.push).call(WPUrl, patch); +} + +// Open a section and go to it. Focus moves to the section's own heading button, +// not just the scroll position: a keyboard user who activates a rail item has to +// land somewhere, and landing nowhere is why jump links are not navigation. +function gotoSection(id, opts){ + const card = document.getElementById(id); + if(!card) return false; + // Moving section is a "you have visibly moved on" moment, so flush the draft + // rather than wait out the debounce. This was bound to the old jump chips at + // initAutosave() time, which only worked because the chips were built before it + // ran; it belongs here, where every section change goes through one door - + // rail click, keyboard, deep link and Back alike. + if(typeof WPAutosave !== 'undefined' && WPAutosave.flush) WPAutosave.flush('section'); + secApplyOpenState(id); + const btn = card.querySelector('.card-toggle'); + if(btn){ + btn.focus({preventScroll:true}); + card.scrollIntoView({behavior:'smooth', block:'start'}); + } + if(!(opts && opts.fromUrl)) secSyncUrl(); + return true; +} + +function toggleExpandAll(){ + setExpandAll(!secExpandAll); +} + +function setExpandAll(on){ + secExpandAll = !!on; + try { localStorage.setItem(SEC_EXPAND_KEY, secExpandAll ? '1' : '0'); } catch(e){} + const btn = document.getElementById('sec-expand-all'); + if(btn){ + btn.setAttribute('aria-pressed', secExpandAll ? 'true' : 'false'); + btn.textContent = secExpandAll ? 'Collapse all' : 'Expand all'; + } + secApplyOpenState(secCurrent); + track(secExpandAll ? 'sections_expand_all' : 'sections_collapse_all'); +} + +// With one section open the current one is whatever you opened. With Expand all +// on, that answer is wrong the moment you scroll, so the rail follows the form. +function secBindSpy(){ + if(_secSpyBound) return; + _secSpyBound = true; + window.addEventListener('scroll', () => { + if(!secExpandAll) return; + const rail = document.getElementById('section-rail'); + if(!rail || rail.hidden) return; + const line = (document.querySelector('.header') || {offsetHeight:0}).offsetHeight + 80; + let seen = ''; + secCards().forEach(card => { + if(card.getBoundingClientRect().top <= line) seen = card.id; }); - }); -} -function buildSectionNav(){ - const nav=document.getElementById('section-nav'); if(!nav) return; - const chips=[]; - document.querySelectorAll('.main > .card').forEach((card,i)=>{ - if(card.id==='saved-card' || card.style.display==='none') return; - const h=card.querySelector('.section-title, .sub-heading'); if(!h) return; - const clone=h.cloneNode(true); clone.querySelectorAll('.help-tip').forEach(x=>x.remove()); - const label=clone.textContent.trim().replace(/\s+/g,' '); if(!label) return; - if(!card.id) card.id='sec-'+i; - chips.push(`${esc(label)}`); - }); - nav.innerHTML=chips.join(''); -} -// Keep the section-nav pinned just below the sticky header (so it stays put while -// scrolling instead of hiding behind the header), and let it slide out of the way -// while reading (scroll down), snapping back the moment you scroll up. -function positionSectionNav(){ - const nav=document.getElementById('section-nav'), hdr=document.querySelector('.header'); - if(nav && hdr) nav.style.top = hdr.offsetHeight + 'px'; - // The navigator drawer + its handle hang below the page header. Deliberately NOT - // including the section-nav height: that bar is sticky, so at scroll 0 it sits - // further down the page and the handle would float over the chrome. - document.documentElement.style.setProperty('--rail-top', (hdr?hdr.offsetHeight:0)+'px'); -} -let _snLastY=0, _snBound=false; -function initSectionNavAutoHide(){ - positionSectionNav(); - if(_snBound) return; _snBound=true; - window.addEventListener('resize', positionSectionNav, {passive:true}); - window.addEventListener('scroll', ()=>{ - const nav=document.getElementById('section-nav'); - if(!nav || nav.style.display==='none') return; - const y=window.scrollY||document.documentElement.scrollTop||0; - if(y>_snLastY+4 && y>140) nav.classList.add('nav-hidden'); // scrolling down - else if(y<_snLastY-4) nav.classList.remove('nav-hidden'); // scrolling up - _snLastY=y; + if(seen && seen !== secCurrent){ secCurrent = seen; secMarkRail(); } }, {passive:true}); } -function updateStickyStatus(){ - const el=document.getElementById('sticky-status'); if(!el) return; - const r=readiness(); const st=getRadio('status'); - if(st==='Issue'){ el.className='sticky-status ss-hold'; el.textContent=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened`; } - else if(r.ready){ el.className='sticky-status ss-ready'; el.textContent=`✓ Release-ready — all ${r.total} constraints cleared`; } - else if(r.constraintsClear){ el.className='sticky-status ss-notready'; el.textContent=`⚠ Waiting on ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}`; } - 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'}`:''); } + +// The drawer and the comments panel hang below the page chrome, and the chrome is +// taller since T7.1 - app bar, tab strip, toolbar. Measure it rather than assume. +function positionSectionNav(){ + const parts = ['.header', '.main-nav', '.wp-toolbar'] + .map(sel => document.querySelector(sel)) + .filter(Boolean); + const h = parts.reduce((n, el) => n + el.offsetHeight, 0); + document.documentElement.style.setProperty('--rail-top', h + 'px'); + const rail = document.getElementById('section-rail'); + if(rail) rail.style.setProperty('--sec-rail-top', h + 'px'); } +let _secChromeBound = false; +function initSectionNavAutoHide(){ + positionSectionNav(); + if(_secChromeBound) return; + _secChromeBound = true; + window.addEventListener('resize', positionSectionNav, {passive:true}); + try { setExpandAll(localStorage.getItem(SEC_EXPAND_KEY) === '1'); } catch(e){} +} + +// A2: the count badge on the Constraints rail entry. The rail is sticky at every +// width, so this is the thing a user can see from ANY section - the top banner +// is the warning, this is the pointer to it. It replaced updateStickyStatus(), +// which wrote the same warning a second time into the sticky save bar - and +// destroyed the B5 autosave indicator mounted in the same span every time it did. +function updateConstraintBadge(){ + const btn=document.querySelector('.sec-rail-item[data-sec="constraint-card"]'); + if(!btn) return; + let badge=btn.querySelector('.sec-badge'); + if(!badge){ + badge=document.createElement('span'); + badge.className='sec-badge'; + btn.appendChild(badge); + } + const open=readiness().open; + badge.hidden = open===0; + badge.textContent = open || ''; + btn.setAttribute('aria-label', 'Constraints'+(open?` — ${open} open`:'')); +} + +// ── 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(); buildDeliveryPickers(); 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(); + buildDeliveryPickers(); +} + +/* Fill one level's to a value with no option does + // NOTHING, silently - so a code that left COST_CODES blanked on open and the + // next save wrote the blank over the record. Same fix as gov_wosize: keep + // the stored value as an option so the round-trip preserves it. + { const cs=document.getElementById('wp_cost'); + if(p.cost && ![...cs.options].some(o=>o.value===p.cost)){ + const o=document.createElement('option'); + o.value=p.cost; o.textContent=p.cost+' (not in the current list)'; + cs.appendChild(o); + } + cs.value=p.cost||''; } set('wp_wbs',p.wbs); document.getElementById('wp_kit_status').value=p.kitStatus||''; buildSequencePicker(); document.getElementById('wp_seq').value=p.seq||''; @@ -1688,14 +3304,17 @@ function loadPackageIntoForm(p){ set('wp_hold', (p.hold&&p.hold.trim())?p.hold:sopValueFor('wp_hold')); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold'); // collections - pkgAssets=(p.assets&&p.assets.length)?p.assets.map(a=>({...a})):[{tag:'',desc:'',link:''}]; buildAssets(); + pkgAssets=(p.assets||[]).map(normaliseAsset); buildAssets(); pkgMaterials=(p.materials&&p.materials.length)?p.materials.map(m=>({...m,unit:(m.unit||'').toUpperCase()})):[{qty:'',unit:'',desc:''}]; buildMaterials(); pkgAttach=(p.attachments&&p.attachments.length)?p.attachments.map(a=>({...a})):[{doc:'',rev:'',link:''}]; buildAttach(); pkgWorkSteps=(p.workSteps&&p.workSteps.length)?p.workSteps.slice():(p.work?String(p.work).split('\n').filter(Boolean):['']); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps(); pkgConstraints=(p.constraints||[]).map(c=>({...c})); if(!pkgConstraints.length) buildConstraints(); else renderConstraintRows(); pkgSignoffs=(p.signoffs||[]).map(s=>({...s, fromSOP:!!s.name})); if(!pkgSignoffs.length) buildSignoffs(); else renderSignoffRows(); pkgHolds=(p.holds||[]).map(h=>({...h})); - updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm(); + pkgQaRejections=(p.qaRejections||[]).map(x=>({...x})); + pkgMatRequests=(p.materialRequests||[]).map(x=>({...x})); mreqRenderList(); + pkgFiles=(p.files||[]).map(x=>({...x})); wpFilesRender(); wpFilesRefreshUsage(); + updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm(); wpFormPopulated(); } function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; buildConstraints(); tmp.forEach(s=>{ const c=pkgConstraints.find(x=>x.name===s.name); if(c){ c.status=s.status; c.comment=s.comment; }}); @@ -1704,12 +3323,15 @@ function renderSignoffRows(){ const tmp=pkgSignoffs; pkgSignoffs=[]; buildSignof // Duplicate the current work package N times (asks how many). Each copy is a // fresh Draft with a unique number/subject and approvals/closeout cleared. -function duplicateWP(){ - if(!gv('wp_subject')){ alert('Open or fill in a work package first, then Duplicate.'); return; } - const ans=prompt('How many copies of this work package do you want to create?','1'); +async function duplicateWP(){ + if(!gv('wp_subject')){ toast('Open or fill in a work package first, then Duplicate.', 'alert'); return; } + const ans=await wpPromptDialog({ + title:'Duplicate this work package', + label:'How many copies?', value:'1', okLabel:'Create copies', + validate:v=>{ const k=parseInt(v,10); return (k>=1&&k<=50&&String(k)===v.trim())?'':'Enter a whole number between 1 and 50.'; }, + }); if(ans===null) return; const n=parseInt(ans,10); - if(!n || n<1 || n>50){ alert('Enter a whole number between 1 and 50.'); return; } const base=collectPackage(); const baseNum = base.number || ('WP'+pad2(editingSeq())); const made=[]; @@ -1728,15 +3350,23 @@ function duplicateWP(){ } editingId=null; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.pushWP) made.forEach(m=>ProjectData.pushWP(m, activeProjectId)); // share to server - toast('Created '+n+' duplicate'+(n>1?'s':'')); + toast('Created '+n+' duplicate'+(n>1?'s':'')+' — in the Saved Work Packages list, ready to edit.'); track('wp_duplicated',{count:n}); - alert('Created '+n+' duplicate'+(n>1?'s':'')+':\n\n• '+made.map(m=>m.number).join('\n• ')+'\n\nThey are in the Saved Work Packages list — edit each as needed.'); } function newPackage(){ + // Only clear the address once the app is up. bootSOP() calls this during boot, so + // without the guard a deep link to ?wp= had its own parameter deleted by the + // page it was opening - the link worked, then erased itself, and Back had nothing + // to return to. + if(typeof WPUrl !== 'undefined' && window.wpCreatorReady && WPUrl.get('wp')) urlSyncPackage('', {replace:true}); editingId=null; - ['wp_subject','wp_system','wp_location','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=''; + ['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=''; buildKitStatusOptions(''); pkgKitOwnerId=''; buildKitOwnerPicker(); 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 + buildDeliveryPickers({building:'', floor:'', sector:''}); // CR-012 + { const dd=document.getElementById('wp_deliv_detail'); if(dd) dd.value=''; } ['wp_iff','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';}); onClashChange(); pkgKind='iwp'; applyKind(); @@ -1745,19 +3375,20 @@ function newPackage(){ setRadio('status','Draft'); numberDims={}; buildNumberDims(); pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange(); - pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); + pkgAssets=[]; buildAssets(); pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); pkgWorkSteps=['']; buildWorkSteps(); pkgConstraints=[]; buildConstraints(); pkgSignoffs=[]; buildSignoffs(); - pkgHolds=[]; pkgOverrides={}; + pkgHolds=[]; pkgQaRejections=[]; pkgFiles=[]; wpFilesRender(); pkgOverrides={}; + pkgMatRequests=[]; _mreqDraft=[]; mreqRenderDraft(); mreqRenderList(); set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold'); prevStatus='Draft'; - updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); renderWpNav(); track('new_package'); + updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); wpFormPopulated(); renderWpNav(); track('new_package'); } function set_quality_from_sop(){ const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; set('wp_qc',sopValueFor('wp_qc')); set('wp_photo',sopValueFor('wp_photo')); set('wp_hold',sopValueFor('wp_hold')); } function exportPackages(){ - if(!savedPackages.length){ alert('No packages saved yet.'); return; } + if(!savedPackages.length){ toast('No packages saved yet.', 'alert'); return; } const payload={tool:'Work Package (IWP)', project:(SOP&&SOP.project&&SOP.project.name)||'', exportedAt:new Date().toISOString(), packages:savedPackages}; const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='work-packages-'+new Date().toISOString().slice(0,10)+'.json'; @@ -1778,20 +3409,232 @@ const WPData = { if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; }, }; -let dashFilter={status:'',discipline:'',q:'',flag:''}; -function dashToggleFlag(f){ - if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:''}; } +let dashFilter={status:'',discipline:'',q:'',flag:'',priority:'',building:'',floor:'',sector:'',kitOwner:''}; +// 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 +// B4 removed, just arriving a different way. +function dashRefreshAfterWrite(){ + const done = () => loadDashMetrics(); + try { + if(typeof ProjectData!=='undefined' && ProjectData.flushSync){ + Promise.resolve(ProjectData.flushSync()).then(done, done); + return; + } + } catch(e){} + done(); +} + +// The four flags the dashboard filters on. Named here because the launcher's +// pipeline strip (T5.3 / B4) links straight to them, and a cell that links to a +// filter this file does not recognise is a dead link that still looks live. +const DASH_FLAGS = ['ready', 'onhold', 'overdue', 'mine']; + +function dashToggleFlag(f, opts){ + if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:'',priority:'',building:'',floor:'',sector:''}; } else { dashFilter.flag = dashFilter.flag===f ? '' : f; } - dashPage=0; renderDashboard(); + dashPage=0; + // S3: which slice of the board you are looking at is state, so it belongs in + // the URL. Without this a pipeline cell could open the filter but nobody could + // send anyone the result — which is the half of T5.3 that is about sharing. + if(!(opts && opts.fromUrl)) urlSyncDashFlag(); + renderDashboard(); +} + +function urlSyncDashFlag(opts){ + if(typeof WPUrl === 'undefined') return; + const patch = { view: 'dashboard', wp: '', flag: dashFilter.flag || '' }; + if(activeProjectId) patch.project = activeProjectId; + (opts && opts.replace ? WPUrl.replace : WPUrl.push).call(WPUrl, patch); +} + +// Apply a flag arriving from outside. Until T7.1 that meant two things - this +// page's own URL, or the embedding shell forwarding one across the frame because +// the frame's src carried only the project. There is one source now: the URL. +// Never pushes history; the caller's own navigation is what put us here. +function dashApplyFlag(f){ + const next = DASH_FLAGS.indexOf(f) >= 0 ? f : ''; + if(dashFilter.flag === next) return next; + dashFilter.flag = next; + dashPage = 0; + if(currentView === 'Dashboard') renderDashboard(); + return next; } function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; dashPage=0; renderDashboard(); } +// ── Board sorting (CR-001 / T6.1) ──────────────────────────────────────────── +// The board had no sorting at all. CR-001 asks for a sortable P6 activity column +// and CR-003 asks for a sortable priority one, so the mechanism is built once and +// declared as data — a column added later is a row in this table, not another +// hand-written and another comparator. +// +// EMPTIES SORT LAST, in both directions. That is the whole of "sorts correctly, +// including with empty values": ascending by P6 activity means "the ones with an +// activity, in order, then the ones without", because nobody sorts by a column in +// order to look at the rows that have nothing in it. Reversing the direction +// reverses the filled rows and leaves the blanks where they are. +const DASH_COLUMNS = [ + {key:'number', label:'WP #', get:p => p.number || ''}, + {key:'subject', label:'Subject', get:p => p.subject || ''}, + {key:'type', label:'Type', get:p => p.type || ''}, + {key:'disciplines', label:'Discipline', get:p => (p.disciplines || []).join(', ')}, + {key:'p6Id', label:'P6 activity',get:p => p.p6Id || ''}, + // Sorted by the ESCALATION order, not alphabetically: High, Normal, Urgent + // would put the most urgent last, which is the one thing the column is for. + {key:'priority', label:'Priority', numeric:true, + get:p => WP_PRIORITIES.indexOf(wpPriorityOf(p))}, + {key:'status', label:'Status', get:p => p.status || ''}, + {key:'gates', label:'Gates', sortable:false}, + {key:'due', label:'Due', get:p => p.due || ''}, + {key:'hours', label:'Hrs', numeric:true, get:p => p.hours}, + {key:'actions', label:'', sortable:false}, +]; + +let dashSort = {key:'', dir:1}; + +function dashSetSort(key){ + const col = DASH_COLUMNS.find(c => c.key === key); + if(!col || col.sortable === false) return; + dashSort = (dashSort.key === key) ? {key: key, dir: -dashSort.dir} : {key: key, dir: 1}; + dashPage = 0; + renderDashboard(); +} + +function dashSortRows(rows){ + const col = DASH_COLUMNS.find(c => c.key === dashSort.key); + if(!col || col.sortable === false) return rows; + const blank = v => v === null || v === undefined || String(v).trim() === ''; + // A copy: `rows` is derived from the live list and sorting it in place would + // reorder the board's own source. + return rows.slice().sort((a, b) => { + const va = col.get(a), vb = col.get(b); + const ea = blank(va), eb = blank(vb); + if(ea && eb) return 0; + if(ea) return 1; // empties last, whichever way the arrow points + if(eb) return -1; + if(col.numeric){ + const na = parseFloat(va), nb = parseFloat(vb); + // A non-numeric value in a numeric column is not an empty and is not a + // number either; it sorts after the numbers rather than as NaN, which + // compares false against everything and leaves the order undefined. + if(isNaN(na) && isNaN(nb)) return 0; + if(isNaN(na)) return 1; + if(isNaN(nb)) return -1; + return (na - nb) * dashSort.dir; + } + return String(va).localeCompare(String(vb), undefined, {numeric:true, sensitivity:'base'}) + * dashSort.dir; + }); +} + +// 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(``); + }); + return out.join(''); +} + +// Clearing a parent clears its children here too, for the same reason as on the +// form: a floor filter under a different building filters to nothing and looks +// like an empty project. +function dashSetLocationFilter(level, value){ + const i = LOC_LEVELS.indexOf(level); + dashFilter[level] = value; + for(let k = i + 1; k < LOC_LEVELS.length; k++) dashFilter[LOC_LEVELS[k]] = ''; + dashPage = 0; + renderDashboard(); +} + +// ── CR-018 / T6.4: rollup by building, floor and sector ───────────────────── +// This is why the Acumatica cost code was removed rather than relabelled: the +// tracking dimension the team wants is floor and area, not an accounting code. +// +// Every number here is the SERVER's. The browser does not sum the leaf groups to +// get a floor total — that would be the same per-browser arithmetic B4 removed, +// and it would silently disagree with the header count the moment this browser's +// copy is behind. server/app.py::_location_levels does it once, for everyone. +function renderLocationRollup(m){ + const loc = m && m.by_location; + if(!loc || !loc.levels) return ''; + const unset = loc.unassigned_key || '(unassigned)'; + const levels = (loc.dimensions || []).filter(d => (loc.levels[d] || []).length); + if(!levels.length) return ''; + + // Each row's `path` is a full location path — a floor is `B-ONE/L1` — so it + // resolves straight to a name. A group whose value at this level is unassigned + // is SHOWN, not hidden: it is the row that makes the totals add up, and hiding + // it is how a rollup ends up quietly missing work. + const label = path => path === unset ? 'Unassigned' : locLabelFull(path); + + let h = `
    By location
    `; + h += `
    Totals come from the server and are computed at every level, ` + + `so each table adds up to the project on its own. Packages with no location assigned ` + + `appear as Unassigned rather than being left out.
    `; + levels.forEach(dim => { + const rows = loc.levels[dim] || []; + const sum = k => rows.reduce((a, r) => a + (r[k] || 0), 0); + h += `
    By ${esc(dim)}
    ` + + `` + + `` + + `` + + ``; + rows.forEach(r => { + const isUnset = r.path === unset; + h += `` + + `` + + `` + + ``; + }); + // The total row is what makes "reconciles against an unfiltered count" + // checkable by looking rather than by trusting. + h += `` + + `` + + ``; + h += `
    ${esc(dim.charAt(0).toUpperCase() + dim.slice(1))}WPsReadyOn holdOverdueEst. hrsActual hrs
    ${esc(label(r.path))}${r.total}${r.release_ready}${r.on_hold}${r.overdue}${r.est_hours}${r.actual_hours}
    All ${esc(dim)}s${sum('total')}${sum('release_ready')}${sum('on_hold')}${sum('overdue')}${sum('est_hours')}${sum('actual_hours')}
    `; + }); + return h + `
    `; +} + +function dashHeaderCells(){ + return DASH_COLUMNS.map(c => { + if(c.sortable === false) return `${esc(c.label)}`; + const on = dashSort.key === c.key; + const aria = on ? (dashSort.dir > 0 ? 'ascending' : 'descending') : 'none'; + const arrow = on ? (dashSort.dir > 0 ? ' ▲' : ' ▼') : ''; + // A real button inside the th, so it is in the tab order and Enter/Space work + // for free (C1). The direction is said by aria-sort as well as by the arrow. + return ``; + }).join(''); +} + // ── Phase 2: pagination, progress %, and archived view ────────────────────── let dashPage=0, dashShowArchived=false, dashArchived=[]; const DASH_PAGE_SIZE=25; // Weighted completion by status (0..1) so progress is smoother than done/not-done. -const PROGRESS_W={'Draft':0,'Scheduled':0.25,'Issue':0.4,'Issued':0.5,'In Progress':0.75,'QC':0.9,'Closed':1}; -function wpProgress(p){ const w=PROGRESS_W[p.status]; return w==null?0:w; } +// The progress weights moved to server/app.py PROGRESS_WEIGHT at T4.1 (B4), so the +// bar is computed once for everyone instead of once per browser. Deleted here +// rather than left in place: a second copy of a weighting table is how the two +// drift apart, and nothing in this file reads it any more. function dashGo(pg){ dashPage=pg; renderDashboard(); } function dashToggleArchived(on){ dashShowArchived=!!on; dashPage=0; @@ -1799,18 +3642,20 @@ function dashToggleArchived(on){ ProjectData.listArchived(activeProjectId).then(rows=>{ dashArchived=rows||[]; renderDashboard(); }); } else { renderDashboard(); } } -function dashArchive(id){ +async function dashArchive(id){ const p=savedPackages.find(x=>x.id===id); if(!p) return; - if(!confirm('Archive "'+(p.number||p.subject||'this package')+'"? It will be hidden from the active board but kept for the record.')) return; + if(!(await wpConfirmDialog({title:'Archive work package', + message:'Archive "'+(p.number||p.subject||'this package')+'"? It will be hidden from the active board but kept for the record.', + okLabel:'Archive'}))) return; if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,true); const ix=savedPackages.findIndex(x=>x.id===id); if(ix>=0){ p.archived=true; dashArchived.unshift(p); savedPackages.splice(ix,1); } - saveStore(); renderSavedList(); renderDashboard(); toast('Archived '+(p.number||'')); + saveStore(); renderSavedList(); toast('Archived '+(p.number||'')); dashRefreshAfterWrite(); } function dashUnarchive(id){ const ix=dashArchived.findIndex(x=>x.id===id); const p=ix>=0?dashArchived[ix]:null; if(!p) return; if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,false); p.archived=false; dashArchived.splice(ix,1); if(!savedPackages.some(x=>x.id===id)) savedPackages.push(p); - saveStore(); renderSavedList(); renderDashboard(); toast('Restored '+(p.number||'')); + saveStore(); renderSavedList(); toast('Restored '+(p.number||'')); dashRefreshAfterWrite(); } // Consistent colored status pill, reused by the dashboard board and the saved list. function statusPill(s){ @@ -1818,6 +3663,22 @@ function statusPill(s){ const label = s==='Issue' ? 'Issue (Hold)' : (s||'—'); return `${esc(label)}`; } +// A package saved before CR-003 has no priority at all, and so does one whose +// value has been hand-edited to something not on the list. Both mean Normal — +// the baseline — rather than blank, which would sort and filter as its own +// invisible fourth level. +function wpPriorityOf(p){ + const v = (p && p.priority) || ''; + return WP_PRIORITIES.indexOf(v) >= 0 ? v : WP_PRIORITY_DEFAULT; +} + +function priorityPill(p){ + const v = wpPriorityOf(p); + // The LABEL is always present. The colour is a second channel, never the only + // one (C1 / X7), and every one of them is a canonical token. + return `${esc(v)}`; +} + function myUserId(){ try { return (window.WP_USER && window.WP_USER.id) || ''; } catch(e){ return ''; } } function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); } // Predecessor packages of `p` that aren't Closed. Deleted ones don't block. @@ -1827,35 +3688,89 @@ function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todaySt // Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted. function countableWPs(){ return WPData.list().filter(p=>!p.split); } -function showDashboard(){ +// Restoring the dashboard because the user pressed Back: same rendering, no new +// history entry. +function showDashboardFromUrl(){ showDashboard({fromUrl:true}); } +function showDashboard(opts){ + const fromUrl = !!(opts && opts.fromUrl); setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display='none'; const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display=''; - currentView='Dashboard'; cmtUpdateCurStep(); renderDashboard(); + currentView='Dashboard'; cmtUpdateCurStep(); + if(typeof WPUrl !== 'undefined' && !fromUrl){ + const patch = { view: 'dashboard', wp: '', flag: dashFilter.flag || '' }; + if(activeProjectId) patch.project = activeProjectId; + WPUrl.push(patch); + } + // Ask the server every time the dashboard is opened. Counts that were correct + // when you last looked are not evidence that they are correct now. + dashMetrics=null; loadDashMetrics(); + syncToolTabs(); window.scrollTo({top:0,behavior:'smooth'}); track('dashboard_open'); } +// ── B4: dashboard counts come from the server ──────────────────────────────── +// Every number on this dashboard used to be summed in the browser from +// savedPackages, which is this browser's localStorage. Two people on the same +// project saw different totals and neither was told. /api/wps/metrics computes +// them from the database instead, so the answer is the project's answer. +// +// There is no localStorage fallback. A silently-stale number that looks +// authoritative is the failure being fixed, so a failed fetch renders an error +// panel and a retry - not a zero, and not the last good answer. +let dashMetrics = null; // last successful server response +let dashMetricsErr = null; // Error, if the last fetch failed +let dashMetricsLoading = false; + +function dashMetricsUrl(){ + const q = []; + if(activeProjectId) q.push('project_id=' + encodeURIComponent(activeProjectId)); + return '/api/wps/metrics' + (q.length ? '?' + q.join('&') : ''); +} + +function loadDashMetrics(){ + dashMetricsLoading = true; dashMetricsErr = null; + return fetch(dashMetricsUrl(), { headers: { 'Accept': 'application/json' } }) + .then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) + .then(m => { dashMetrics = m; dashMetricsErr = null; }) + .catch(e => { dashMetricsErr = e; dashMetrics = null; }) + .then(() => { dashMetricsLoading = false; renderDashboard(); }); +} + function renderDashboard(){ const all=countableWPs(); - const byStatus={}; STATUS_ORDER.concat(['Issue']).forEach(s=>byStatus[s]=0); - let estH=0, actH=0, ready=0, hold=0, overdue=0, mine=0; const byDisc={}; const meId=myUserId(); - all.forEach(p=>{ - byStatus[p.status]=(byStatus[p.status]||0)+1; - estH+=parseFloat(p.hours)||0; actH+=parseFloat(p.actualHrs)||0; - if(p.status==='Issue') hold++; - if(meId && p.assigneeId===meId) mine++; - if(!wpReleaseBlocked(p) && p.status!=='Closed' && p.status!=='Issue') ready++; - if(isOverdue(p)) overdue++; - (p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1); - }); + const m = dashMetrics; + const byStatus = m ? Object.assign({}, m.by_status) : {}; + const byDisc = m ? Object.assign({}, m.by_discipline) : {}; + if(m) STATUS_ORDER.concat(['Issue']).forEach(s=>{ if(byStatus[s]==null) byStatus[s]=0; }); + const estH=m?m.est_hours:0, actH=m?m.actual_hours:0, ready=m?m.release_ready:0, + hold=m?m.on_hold:0, overdue=m?m.overdue:0, mine=m?m.mine:0; + const meId=myUserId(); // Clickable metric cards filter the board (flag-based); a card with no flag is static. const card=(label,val,cls,flag)=>{ const active = flag && dashFilter.flag===flag ? ' dm-active' : ''; const attr = flag ? ` onclick="dashToggleFlag('${flag}')" title="Click to filter the board"` : ''; return `
    ${val}
    ${esc(label)}
    `; }; + // The counts panel is server-derived; if that request failed, say so instead of + // rendering a row of zeros that reads as "this project is empty". + if(dashMetricsErr){ + let eh = ``; + const host0=document.getElementById('dashboard-view'); + if(host0){ host0.innerHTML = eh; } + return; + } + if(!m && dashMetricsLoading){ + const host1=document.getElementById('dashboard-view'); + if(host1){ host1.innerHTML = `
    Loading project totals…
    `; } + return; + } + if(!m){ loadDashMetrics(); return; } + let h=`
    - ${card('Total WPs', all.length, '', 'all')} + ${card('Total WPs', m.total, '', 'all')} ${meId ? card('My WPs', mine, mine?'dm-blue':'', 'mine') : ''} ${card('Release-ready', ready, ready?'dm-green':'', 'ready')} ${card('On hold', hold, hold?'dm-red':'', 'onhold')} @@ -1865,30 +3780,32 @@ function renderDashboard(){
    `; // status + discipline breakdown chips (status chips also filter the board) - const statusChip=(label,count,cls,status)=>`${esc(label)}: ${count}`; + // C1/T9.5: the chip filters the board, so it is a button. + const statusChip=(label,count,cls,status)=>``; const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>statusChip(s,byStatus[s],'',s)).join('') + (byStatus['Issue']?statusChip('On Hold',byStatus['Issue'],'chip-red','Issue'):''); const discChips=Object.keys(byDisc).map(d=>`${esc(d)}: ${byDisc[d]}`).join('')||''; h+=`
    By status
    ${statusChips||'—'}
    By discipline
    ${discChips}
    `; - // progress by phase (discipline), weighted by status; archived excluded - const overallPct = all.length ? Math.round(all.reduce((s,p)=>s+wpProgress(p),0)/all.length*100) : 0; - const phaseGroups={}; - all.forEach(p=>{ (p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>{ (phaseGroups[d]=phaseGroups[d]||[]).push(p); }); }); + // progress by phase (discipline), weighted by status; archived excluded. + // The weights live in server/app.py PROGRESS_WEIGHT now, so one definition + // produces the bar for everyone rather than one per browser. + const overallPct = m.progress.overall_pct; let prog=`
    Progress by phase
    `; prog+=`
    Overall
    ${overallPct}%
    `; - Object.keys(phaseGroups).sort().forEach(d=>{ const g=phaseGroups[d]; const pct=g.length?Math.round(g.reduce((s,p)=>s+wpProgress(p),0)/g.length*100):0; const done=g.filter(p=>p.status==='Closed').length; - prog+=`
    ${esc(d)}
    ${pct}% ${done}/${g.length}
    `; }); + m.progress.by_discipline.forEach(g=>{ + 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 - const gated=all.filter(p=>wpOpenConstraints(p).length>0); - h+=`
    ⛔ Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)
    `; + // gating panel — what's blocking release, from the server + const gated=m.gating||[]; + h+=`
    ⊘ Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)
    `; h+= gated.length ? ``+ - gated.map(p=>` - `).join('')+ + gated.map(g=>` + `).join('')+ `
    WP #SubjectBlocked by
    ${esc(p.number||'—')}${esc(p.subject||'')}${wpOpenConstraints(p).map(c=>esc(c.name)+(c.comment?` (${esc(c.comment)})`:'')).join('
    ')}
    ${esc(g.number||'—')}${esc(g.subject||'')}${(g.blocked_by||[]).map(c=>esc(c.name)+(c.comment?` (${esc(c.comment)})`:'')).join('
    ')}
    ` : `
    No open constraints — every package is clear of gates.
    `; h+=`
    `; @@ -1896,10 +3813,19 @@ function renderDashboard(){ const statusOpts=[''].concat(STATUS_ORDER.concat(['Issue']).map(s=>``)).join(''); const discList=Object.keys(byDisc); const discOpts=[''].concat(discList.map(d=>``)).join(''); + const prioOpts=[''].concat(WP_PRIORITIES.map(v=>``)).join(''); + // CR-010: filter by warehouse owner, so the person doing fulfillment sees + // exactly their queue. Options are the owners PRESENT in the data - a filter + // offering people with nothing to fulfill is noise. + const kitOwners=[...new Set(all.map(p=>(p.kitOwner||'').trim()).filter(Boolean))].sort(); + const kitOpts=[''].concat(kitOwners.map(v=>``)).join(''); h+=`
    + + + ${locFilterSelects()}
    `; @@ -1909,6 +3835,15 @@ function renderDashboard(){ const rows=boardSource.filter(p=>{ 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; + if(dashFilter.kitOwner && (p.kitOwner||'').trim()!==dashFilter.kitOwner) 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; @@ -1916,14 +3851,26 @@ function renderDashboard(){ if(dashFilter.flag==='overdue' && !isOverdue(p)) return false; return true; }); - const totalRows=rows.length; + const sorted=dashSortRows(rows); + const totalRows=sorted.length; const pages=Math.max(1, Math.ceil(totalRows/DASH_PAGE_SIZE)); if(dashPage>=pages) dashPage=pages-1; if(dashPage<0) dashPage=0; - const pageRows=rows.slice(dashPage*DASH_PAGE_SIZE, dashPage*DASH_PAGE_SIZE+DASH_PAGE_SIZE); - h+=`
    Work Packages (${totalRows})
    - `; - if(!totalRows) h+=``; + const pageRows=sorted.slice(dashPage*DASH_PAGE_SIZE, dashPage*DASH_PAGE_SIZE+DASH_PAGE_SIZE); + // The board is a LIST, not a rollup: it renders the packages this browser holds, + // which is what keeps the field view usable offline. Its header is therefore the + // only count on this page not computed by the server — so it is reconciled against + // the server's instead of being left to disagree in silence, which is the whole of + // what B4 objects to. A divergence means this browser's copy is behind (a pending + // outbox write, a stale tab), and saying so is more useful than hiding it. + const localCountable = (dashShowArchived ? WPData.list() : WPData.list().filter(p=>!p.archived)) + .filter(p=>!p.split).length; + const drift = (!dashShowArchived && m && localCountable !== m.total) + ? ` this browser has ${localCountable} of ${m.total}` + : ''; + h+=`
    Work packages (${totalRows})${drift}
    +
    WP #SubjectTypeDisciplineStatusGatesDueHrs
    No work packages match.
    ${dashHeaderCells()}`; + if(!totalRows) h+=``; pageRows.forEach(p=>{ const ix=savedPackages.findIndex(x=>x.id===p.id); const open=wpOpenConstraints(p).length; @@ -1945,6 +3892,8 @@ function renderDashboard(){ h+=` + + `; }); @@ -1955,29 +3904,66 @@ function renderDashboard(){ `; } h+=``; + h+=renderMreqPanel(all); document.getElementById('dash-body').innerHTML=h; } -function dashIssue(id){ +// CR-013: every request across the project, on the board, filterable by +// status and by delivery location - the funnel-through-one-person replaced by +// a queue anyone can read. +let mreqDashFilter = {status:'', loc:''}; +function renderMreqPanel(all){ + const reqs = []; + all.forEach(p => (p.materialRequests||[]).forEach(r => reqs.push({p, r}))); + if(!reqs.length) return ''; + const locs = [...new Set(reqs.map(x => x.r.deliveryLoc || '').filter(Boolean))].sort(); + const rows = reqs.filter(x => + (!mreqDashFilter.status || (x.r.status||'Requested') === mreqDashFilter.status) + && (!mreqDashFilter.loc || (x.r.deliveryLoc||'') === mreqDashFilter.loc)); + let h = `
    Material requests
    +
    + + +
    `; + h += rows.length ? rows.map(x => `
    +
    ${esc(x.r.status||'Requested')} + ${esc(x.p.number||'')} ${esc(x.r.requestor||'')} + ${x.r.neededBy?`needed ${esc(x.r.neededBy)}`:''} + ${x.r.deliveryLoc?`→ ${esc(x.r.deliveryLoc)}`:''}
    +
    ${(x.r.items||[]).map(it=>esc([it.qty,it.unit,it.desc].filter(Boolean).join(' '))).join(' · ')}
    +
    `).join('') + : '
    No requests match the filters.
    '; + return h + '
    '; +} + +async function dashIssue(id){ const p=WPData.get(id); if(!p) return; - if(wpOpenConstraints(p).length>0){ alert('Cannot issue — open constraints remain.'); return; } + if(wpOpenConstraints(p).length>0){ toast('Cannot issue — open constraints remain.', 'alert'); return; } const waiting=wpWaitingOn(p); if(waiting.length){ // Releasing early needs a reason, same as on the form — the dashboard must not // be the quiet way around the gate. - alert('Cannot issue from here — waiting on:\n\n• '+ - waiting.map(w=>(w.number||w.id)+' — '+w.status).join('\n• ')+ - '\n\nOpen the package to release it early with a logged reason.'); + toast('Cannot issue from here — waiting on '+waiting.map(w=>(w.number||w.id)).join(', ') + +'. Open the package to release it early with a logged reason.', 'alert'); return; } - if(!confirm('Issue work package "'+(p.number||p.subject)+'"? This marks it released to the field.')) return; - WPData.issue(id); renderDashboard(); renderSavedList(); toast('Issued '+(p.number||'')); track('dashboard_issue'); + if(!(await wpConfirmDialog({title:'Issue work package', + message:'Issue work package "'+(p.number||p.subject)+'"? This marks it released to the field.', + okLabel:'Issue it'}))) return; + WPData.issue(id); renderSavedList(); toast('Issued '+(p.number||'')); track('dashboard_issue'); + dashRefreshAfterWrite(); } -function dashView(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); } -function dashEdit(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); } +function dashView(i){ if(savedPackages[i]){ renderPackage(savedPackages[i]); urlSyncPackage(savedPackages[i].id); } } +function dashEdit(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); urlSyncPackage(p.id); } // ── VIEW SOP REFERENCE (comment 2) ─────────────────────────────────────────── function openSopModal(){ - if(!SOP){ alert('No SOP loaded.'); return; } + if(!SOP){ toast('No SOP loaded.', 'alert'); return; } const p=SOP.project||{}, g=SOP.governance||{}, q=SOP.quality||{}; const row=(k,v)=>``; let h=`
    No work packages match.
    ${esc(p.number||'—')}${p.instanceOf?` ${esc(p.instanceLabel||'')}`:''}${p.archived?' archived':''} ${esc(p.subject||'')}${esc(p.type||'')} ${esc((p.disciplines||[]).join(', '))||ns()}${cell(p.p6Id)}${priorityPill(p)} ${statusPill(p.status)}${gates}${due}${cell(p.hours)} ${actions}
    ${esc(k)}${v||'—'}
    `; @@ -1996,14 +3982,11 @@ function openSopModal(){ document.getElementById('sop-modal').classList.add('open'); track('view_sop'); } function closeSopModal(){ document.getElementById('sop-modal').classList.remove('open'); } -const ANALYTICS_KEY='wp_iwp_analytics_v1'; const _session='s_'+Date.now().toString(36)+Math.random().toString(36).slice(2,6); -function analyticsLoad(){ try{ return JSON.parse(localStorage.getItem(ANALYTICS_KEY))||{events:[]}; }catch(e){ return {events:[]}; } } -function analyticsSave(d){ try{ localStorage.setItem(ANALYTICS_KEY, JSON.stringify(d)); }catch(e){} } -function track(event,detail){ if(devMode) return; try{ const d=analyticsLoad(); d.events.push({ts:new Date().toISOString(),session:_session,event,detail:detail||null}); if(d.events.length>5000)d.events=d.events.slice(-5000); analyticsSave(d); }catch(e){} } -function showAnalytics(){ const d=analyticsLoad(); const by={}; const ses=new Set(); d.events.forEach(e=>{by[e.event]=(by[e.event]||0)+1;ses.add(e.session);}); - let t=`USAGE ANALYTICS\n\nSessions: ${ses.size} Events: ${d.events.length}\n\nActions:\n`; Object.keys(by).forEach(k=>t+=` ${k}: ${by[k]}\n`); t+=`\nSaved packages (this device): ${savedPackages.length}\n\nDownload full log as JSON?`; - if(confirm(t)) downloadAnalytics(); } -function downloadAnalytics(){ const d=analyticsLoad(); const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='wp-iwp-usage-'+new Date().toISOString().slice(0,10)+'.json'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('analytics_exported'); } +// D5 / T7.10: the analytics implementation lives in wp-usage.js - ONE copy for +// the whole suite - and its report lives on the admin console, where an +// operator-facing readout belongs. This page only records. Same key, same +// event shape: everything recorded before the move is still readable after it. +function track(event,detail){ if(devMode) return; WPUsage.track(WPUsage.KEYS.creator, event, detail); } // ── COMMENTS ───────────────────────────────────────────────────────────────── const COMMENTS_KEY='wp_iwp_comments_v1'; @@ -2012,14 +3995,19 @@ function cmtSave(d){ try{ localStorage.setItem(COMMENTS_KEY, JSON.stringify(d)); function cmtSaveAuthor(v){ const d=cmtLoad(); d.author=v; cmtSave(d); } function toggleComments(){ const dr=document.getElementById('cmt-drawer'),ov=document.getElementById('cmt-overlay'); const open=!dr.classList.contains('open'); dr.classList.toggle('open',open); ov.classList.toggle('open',open); dr.setAttribute('aria-hidden',open?'false':'true'); if(open){ cmtUpdateCurStep(); renderComments(); const a=document.getElementById('cmt-author'); if(a&&!a.value)a.focus(); else document.getElementById('cmt-input')?.focus(); } } function cmtUpdateCurStep(){ const el=document.getElementById('cmt-cur-step'); if(el) el.textContent=currentView; } -function addComment(){ const d=cmtLoad(); const author=(document.getElementById('cmt-author').value||'').trim(); const text=(document.getElementById('cmt-input').value||'').trim(); if(!author){ alert('Please add your name first.'); document.getElementById('cmt-author').focus(); return; } if(!text){ document.getElementById('cmt-input').focus(); return; } const entry={id:'m_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5), view:currentView, author, clientId:d.clientId, text, ts:new Date().toISOString()}; d.author=author; d.comments.push(entry); cmtSave(d); if(window.postFeedback) window.postFeedback({type:'wp_review_comment', ...entry}); document.getElementById('cmt-input').value=''; renderComments(); refreshCommentBadges(); track('comment_added'); } +function addComment(){ const d=cmtLoad(); const author=(document.getElementById('cmt-author').value||'').trim(); const text=(document.getElementById('cmt-input').value||'').trim(); if(!author){ wpSetFieldError('cmt-author','Add your name first.'); document.getElementById('cmt-author').focus(); return; } wpSetFieldError('cmt-author',''); if(!text){ document.getElementById('cmt-input').focus(); return; } const entry={id:'m_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5), view:currentView, author, clientId:d.clientId, text, ts:new Date().toISOString()}; d.author=author; d.comments.push(entry); cmtSave(d); if(window.postFeedback) window.postFeedback({type:'wp_review_comment', ...entry}); document.getElementById('cmt-input').value=''; renderComments(); refreshCommentBadges(); track('comment_added'); } function deleteComment(id){ const d=cmtLoad(); d.comments=d.comments.filter(c=>c.id!==id); cmtSave(d); renderComments(); refreshCommentBadges(); } function renderComments(){ const d=cmtLoad(); const list=document.getElementById('cmt-list'); if(!list) return; if(!d.comments.length){ list.innerHTML='
    No comments yet.
    '; return; } const sorted=[...d.comments].sort((a,b)=>(b.ts||'').localeCompare(a.ts||'')); list.innerHTML=sorted.map(c=>{ const mine=c.clientId===d.clientId; const when=c.ts?wpFormatDateTime(c.ts):''; return `
    ${esc(c.author||'Anonymous')}${esc(c.view||'')}${esc(when)}
    ${esc(c.text)}
    ${mine?`
    `:''}
    `; }).join(''); } function refreshCommentBadges(){ const d=cmtLoad(); const t=document.getElementById('cbadge-total'); if(t){ if(d.comments.length){ t.style.display=''; t.textContent=d.comments.length; } else t.style.display='none'; } } function exportComments(){ const d=cmtLoad(); const payload={tool:'Work Package (IWP)', exportedBy:d.author||'', exportedAt:new Date().toISOString(), clientId:d.clientId, comments:d.comments}; const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const safe=(d.author||'review').replace(/[^a-z0-9]+/gi,'-').toLowerCase(); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`wp-iwp-comments-${safe}-${new Date().toISOString().slice(0,10)}.json`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('comments_exported'); } -function importComments(ev){ const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader(); r.onload=()=>{ try{ const inc=JSON.parse(r.result); const incoming=Array.isArray(inc)?inc:(inc.comments||[]); if(!incoming.length){ alert('No comments found.'); return; } const d=cmtLoad(); const seen=new Set(d.comments.map(c=>c.id)); let added=0; incoming.forEach(c=>{ if(c&&c.id&&!seen.has(c.id)){ d.comments.push(c); seen.add(c.id); added++; }}); cmtSave(d); renderComments(); refreshCommentBadges(); alert(`Imported ${added} comment${added===1?'':'s'}.`); }catch(e){ alert('Could not read that file.'); } ev.target.value=''; }; r.readAsText(f); } -function clearMyComments(){ const d=cmtLoad(); const mine=d.comments.filter(c=>c.clientId===d.clientId).length; if(!mine){ alert('No comments to clear.'); return; } if(!confirm(`Delete your ${mine} comment(s)?`)) return; d.comments=d.comments.filter(c=>c.clientId!==d.clientId); cmtSave(d); renderComments(); refreshCommentBadges(); } -function cmtInit(){ const d=cmtLoad(); cmtSave(d); const a=document.getElementById('cmt-author'); if(a)a.value=d.author||''; cmtUpdateCurStep(); renderComments(); refreshCommentBadges(); } +function importComments(ev){ const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader(); r.onload=()=>{ try{ const inc=JSON.parse(r.result); const incoming=Array.isArray(inc)?inc:(inc.comments||[]); if(!incoming.length){ toast('No comments found in that file.', 'alert'); return; } const d=cmtLoad(); const seen=new Set(d.comments.map(c=>c.id)); let added=0; incoming.forEach(c=>{ if(c&&c.id&&!seen.has(c.id)){ d.comments.push(c); seen.add(c.id); added++; }}); cmtSave(d); renderComments(); refreshCommentBadges(); toast(`Imported ${added} comment${added===1?'':'s'}.`); }catch(e){ toast('Could not read that file.', 'alert'); } ev.target.value=''; }; r.readAsText(f); } +async function clearMyComments(){ const d=cmtLoad(); const mine=d.comments.filter(c=>c.clientId===d.clientId).length; + if(!mine){ toast('No comments to clear.', 'alert'); return; } + if(!(await wpConfirmDialog({title:'Clear my comments', message:`Delete your ${mine} comment(s)?`, okLabel:'Delete them'}))) return; + d.comments=d.comments.filter(c=>c.clientId!==d.clientId); cmtSave(d); renderComments(); refreshCommentBadges(); } +function cmtInit(){ + const ov=document.getElementById('cmt-overlay'); + if(ov && !ov._wired){ ov._wired=true; ov.addEventListener('click', toggleComments); } const d=cmtLoad(); cmtSave(d); const a=document.getElementById('cmt-author'); if(a)a.value=d.author||''; cmtUpdateCurStep(); renderComments(); refreshCommentBadges(); } // ── STATUS PILLS ───────────────────────────────────────────────────────────── document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventListener('click',e=>{ @@ -2031,6 +4019,97 @@ document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventList onStatusChange(v); })); + +// ── AUTOSAVE / UNSAVED-WORK GUARD / RECOVERY (S2) ──────────────────────────── +// A draft is per project AND per package, so two projects cannot overwrite each +// other's recovery and a new package does not inherit the last one's draft. +function wpDraftId(){ return 'wp-form::' + (activeProjectId || 'none') + '::' + (editingId || 'new'); } + +// Volatile fields move on their own (timestamps, the id assigned at collect time) +// and would make an untouched form look edited, which is exactly the false-positive +// that makes an unsaved-work dialog worthless. +function wpFormFingerprint(pkg){ + if(!pkg) return ''; + const copy = Object.assign({}, pkg); + ['id','updatedAt','createdAt','issuedAt'].forEach(k=>delete copy[k]); + try { return JSON.stringify(copy); } catch(e){ return ''; } +} + +// The baseline is taken when the form is populated and again when it is saved, so +// "dirty" means "changed since then". Comparing the form against savedPackages +// instead does not work: those records come back from the server through +// serverToPkg() in a leaner shape, so a freshly loaded, untouched form differed +// from its own record and every exit would have prompted - which is precisely the +// dialog-that-gets-clicked-through the task forbids. +let _wpFormBaseline = null; +function wpMarkFormClean(){ + try { _wpFormBaseline = wpFormFingerprint(collectPackage()); } catch(e){ _wpFormBaseline = null; } +} + +function wpFormIsDirty(){ + // Only the form can be dirty. On the dashboard or the printed view nothing is + // being edited, so there is nothing to warn about. + if(currentView !== 'Work Package Form') return false; + if(_wpFormBaseline === null) return false; + const el = document.getElementById('wp_subject'); + if(!el) return false; + let live; + try { live = collectPackage(); } catch(e){ return false; } + return wpFormFingerprint(live) !== _wpFormBaseline; +} + +function initAutosave(){ + if(typeof WPAutosave === 'undefined') return; + WPAutosave.register({ + id: 'wp-form', + scope: document, + draftId: wpDraftId, + collect: collectPackage, + isDirty: wpFormIsDirty, + }); + // B5: the indicator lives in the sticky save bar, beside the button it is telling + // the truth about. The bar is rendered by the app, so mount when it exists. + const mountHost = document.querySelector('#sticky-save .sticky-status') || document.querySelector('#sticky-save'); + if(mountHost) WPAutosave.mountIndicator(mountHost); + offerDraftRecovery(); +} + +// Recovery. Offered only when the draft is genuinely ahead of the record - being +// asked to recover work that is already saved is its own kind of alarming. +function offerDraftRecovery(){ + if(typeof WPAutosave === 'undefined') return; + const id = wpDraftId(); + const d = WPAutosave.peek(id); + if(!d || !d.data) return; + const saved = editingId ? savedPackages.find(p=>p.id===editingId) : null; + if(saved && wpFormFingerprint(d.data) === wpFormFingerprint(saved)){ WPAutosave.discard(id); return; } + if(!d.data.subject && !d.data.number && !d.data.type){ WPAutosave.discard(id); return; } + showDraftRecoveryBar(id, d); +} + +function showDraftRecoveryBar(id, d){ + const host = document.querySelector('.main'); + if(!host) return; + const old = document.getElementById('draft-recovery'); if(old) old.remove(); + const when = (function(){ try { return new Date(d.at).toLocaleString(); } catch(e){ return d.at; } })(); + const bar = document.createElement('div'); + bar.id = 'draft-recovery'; + bar.className = 'dash-panel'; + bar.setAttribute('role','status'); + bar.innerHTML = `
    Unsaved work from ${esc(when)}
    +
    This browser was holding changes to ${esc(d.data.number || d.data.subject || 'a work package')} that were never saved. Nothing has been sent to the project.
    +
    + + +
    `; + host.insertBefore(bar, host.firstChild); + document.getElementById('draft-restore').onclick = ()=>{ + loadPackageIntoForm(Object.assign({}, d.data, {id: (editingId || d.data.id)})); + bar.remove(); toast('Unsaved work restored — Save draft to keep it.'); + }; + document.getElementById('draft-discard').onclick = ()=>{ WPAutosave.discard(id); bar.remove(); }; +} + // ── BOOT ───────────────────────────────────────────────────────────────────── // Resolve the active project BEFORE loading the store so namespaced keys resolve. (function seedProject(){ @@ -2042,11 +4121,14 @@ document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventList } })(); function bootSOP(){ - // When embedded in the Suite, hide the SOP import/sample controls (SOP is injected) - // and prefer the SOP the Suite just completed (persisted to localStorage, which - // we've already hydrated from the server for this project). - const params = new URLSearchParams(location.search); - if(params.get('embedded')) document.body.classList.add('embedded'); + // Prefer the SOP the wizard just completed (persisted to localStorage, already + // hydrated from the server for this project). + // + // B7/T7.1: this used to also set `body.embedded` from ?embedded=1, which hid + // this page's own header, its two sample controls and its analytics button + // because they duplicated the shell's. There is no shell. The controls live in + // the toolbar and are visible (D1); the parameter is not read any more, and an + // old link that still carries it is simply ignored rather than half-obeyed. try { const raw = localStorage.getItem(wpKey('wp_suite_sop')); if(raw){ @@ -2090,6 +4172,7 @@ async function loadMembers(){ (others.length?`${others.map(opt).join('')}`:''); if(cur) sel.value=cur; else defaultOwnerToMe(); + buildKitOwnerPicker(); // CR-010: same roster, second picker // The people pickers list the same accounts, so (re)render them now that the // member list has arrived. if(editingId){ const p=savedPackages.find(x=>x.id===editingId); if(p) loadPeopleFromPkg(p); } @@ -2106,11 +4189,31 @@ 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 + wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload + mreqLoadMaterials(); // CR-013/D6: the request datalist comes from the project list + initAssetPicker(); // D11: the Micron catalog, fetched once per page load + // D7: the read-only courtesy needs the SERVER's answer, not a stale local + // summary - the page's project comes from the URL, which may not be the one + // last stored. The chip and the save guard both read this flag. + if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.get){ + ProjectData.get(activeProjectId).then(p=>{ + window._projArchived = !!(p && p.archived); + if(window._projArchived) renderCtxBar(); + }).catch(()=>{}); + } initWpNavDrawer(); renderSavedList(); positionSectionNav(); @@ -2123,11 +4226,56 @@ function bootData(){ if(ix>=0) wpNavOpen(ix); else toast('That work package is not on this project (it may have been deleted).'); } - if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); } + // A pipeline cell links straight to a filtered board, so the filter has to be + // applied BEFORE the first render — applying it after would paint the whole + // board and then throw it away, which on a big project is visible. + if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ + dashApplyFlag(p.get('flag') || ''); + showDashboard({fromUrl:true}); + } + + // Back / Forward. The URL is the state, so restoring is "read it and show that", + // not a bespoke undo stack. Guarded against re-pushing while we restore, or every + // Back would immediately add a new entry and the button would appear to do nothing. + if(typeof WPUrl !== 'undefined'){ + WPUrl.onChange(function(state, viaPop){ + if(!viaPop) return; + if(state.view === 'dashboard'){ + // The flag first: showDashboardFromUrl() renders, so setting it after + // would paint the unfiltered board and then replace it. dashApplyFlag + // re-renders itself only when the board is already open. + dashApplyFlag(state.flag || ''); + if(currentView !== 'Dashboard') showDashboardFromUrl(); + syncToolTabs(); + return; + } + const want = state.wp || ''; + if(want){ + const ix = savedPackages.findIndex(x => x.id === want); + if(ix >= 0){ hideDashboard(); editingId=savedPackages[ix].id; + loadPackageIntoForm(savedPackages[ix]); renderWpNav(); } + } else if(currentView === 'Dashboard'){ + hideDashboard(); setFormChrome(true); currentView='Work Package Form'; + document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display=''); + syncToolTabs(); + } + // F6/D3: which section you are in is state, so Back moves between sections + // as well as between packages. fromUrl, because the URL already says this - + // recording it again would make the first Back appear to do nothing. + if(state.section && currentView !== 'Dashboard'){ + gotoSection(state.section, {fromUrl:true}); + } + }); + } + initAutosave(); + wpMarkFormClean(); + syncToolTabs(); track('app_open'); - // The embedding shell needs to know when the packages are actually in hand: the - // frame's `load` event fires long before pullProject() resolves, so anything that - // acts on a specific package has to wait for this instead. + // Kept after T7.1, with a different reader. It existed because the frame's + // `load` event fired long before pullProject() resolved, so the shell could not + // act on a specific package until this said the data was in hand. There is no + // shell now - but the probes wait on exactly this signal for exactly the same + // reason, and a page that says when its data has landed is worth having. window.wpCreatorReady = true; try { document.dispatchEvent(new CustomEvent('wp-creator-ready')); } catch(e){} } diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html index 4ec9bf1..6483cbf 100644 --- a/html/wp-creation-index.html +++ b/html/wp-creation-index.html @@ -8,45 +8,108 @@ + + + + + + + + + +
    Saving work package…
    +
    -
    - +
    + + Prime Controls + +
    Work Package Suite
    -
    |
    -
    Work Package (IWP)
    - + +
    + + +
    +
    + + + + + +
    + + + + + + - - - - - - - - - + + +
    - -
    + +
    - -
    +
    @@ -121,34 +184,79 @@
    -
    + +
    General Information
    Parameters in blue are inherited from the project SOP. Fill the rest for this package.
    -
    +
    - - + + +
    -
    Cannot move to Issued or beyond until all constraints are cleared.
    WP number builds automatically from these scope fields + the WP type (per the SOP naming format):
    -
    +
    -
    from SOP types
    -
    -
    -
    Acumatica cost codes
    -
    +
    from SOP types
    +
    + + +
    + +
    + + + +
    +
    + +
    + +
    Acumatica cost codes
    +
    + +
    + +
    +
    + +
    + + +
    +
    Assignment & Schedule
    @@ -157,13 +265,27 @@
    -
    -
    - -
    + +
    +
    +
    + +
    +
    +
    +
    -
    -
    @@ -171,22 +293,34 @@
    BIM / Model Details
    For BIM/VDC work packages — the model deliverable's level of detail, area, source scan, and coordination status.
    -
    -
    +
    +
    -
    +
    - +
    -
    Assets
    -
    Every work package is based on one or more assets managed in controls.dev. Paste the controls.dev link for each asset this package covers. A direct integration to pick assets from a list is planned — for now, link them manually.
    -
    Asset Tag / IDDescriptioncontrols.dev Link *
    - +
    Assetsi
    +
    Every work package is based on one or more assets from the Micron DB. Search by asset ID, or paste a column of IDs straight from Excel, to add each asset this package covers. The Micron DB is read-only — nothing you do here changes it.
    +
    + + +
    + +
    +
    Asset IDNote (what this asset is / why it's in scope)
    +
    + + + +
    @@ -197,19 +331,19 @@
    -
    +
    Scope & Worki
    -
    +
    Enter the work as ordered steps — added in sequence, the way the crew performs them.
    - +
    - +
    -
    +
    @@ -224,19 +358,55 @@
    Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.
    QtyUnitDescription
    - + - +
    + + +
    +
    Material Requestsi
    +
    +
    +
    QtyUnitDescription
    + +
    + + + + +
    +
    + +
    +
    -
    +
    Drawings & Attachments
    Document / DrawingRevLink / Note
    - + +
    + Uploads: PDF or image, up to 5MB a file. + +
    +
    +
    + + +
    + -
    +
    Quality, Inspection & Hold Points
    -
    -
    -
    -
    +
    +
    +
    +
    -
    -
    +
    +
    Inherited from the SOP. A Hold Point stops work until inspection sign-off; a Witness Point is offered for inspection but work may proceed if declined.
    -
    + +
    Approvals & Sign-offs
    Per the AWP IWP checklist. A package should be signed by these roles before release.
    RoleNameDateSigned
    @@ -295,23 +478,23 @@
    Closeout
    Completed at QC / Closed — captures as-built reality and lessons learned.
    -
    -
    +
    +
    -
    -
    +
    +
    + + + + + + @@ -369,33 +603,44 @@
    -
    + +
    Comments are saved in your browser. Use Export to send feedback back; the owner can Import each file.
    - +
    - + + + + + diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css index a976737..a593b06 100644 --- a/html/wp-creation-styles.css +++ b/html/wp-creation-styles.css @@ -2,32 +2,47 @@ fully behind a firewall. If IBM Plex is installed/self-hosted it is used; otherwise it falls back to the system UI fonts. */ - /* Embedded-in-Suite tweaks */ - body.embedded .embed-hide { display: none !important; } - body.embedded .embed-first { margin-left: auto; } + /* B7/T7.1: `body.embedded` and `.embed-hide` are gone. They existed to hide + this page's own header, its sample-data controls and its analytics button + while it was an iframe child. There is no frame, so "framed" is not a state + any more: the header became the app bar, the sample controls are visible in + the toolbar (D1) and the analytics button moves to the console at T7.10. */ + /* Names only. Every value comes from theme-light.css, which this page loads + first - this sheet declares nothing of its own (T3.2 / S5 / C3). The names + stay because wp-creation-app.js reads eight of them from JavaScript, where + a rename fails silently. See docs/reference/tokens.md section 9. */ :root { - --bg: #f4f4f4; - --surface: #ffffff; - --surface2: #f4f4f4; - --border: #e0e0e0; - --border-strong: #8d8d8d; - --text: #161616; - --text-muted: #525252; - --text-dim: #8d8d8d; - --accent: #0f62fe; - --accent-dim: #edf5ff; - --accent-green: #198038; - --accent-green-dim: #defbe6; - --accent-amber: #8e6a00; - --accent-amber-dim: #fdf6dd; - --red: #da1e28; - --red-dim: #fff1f1; - --radius: 0; - --shadow: none; - --shadow-lg: 0 4px 16px rgba(20,30,50,.12); - --mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', Consolas, monospace; - --sans: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; + --bg: var(--cds-background); + --surface: var(--cds-layer); + --surface2: var(--cds-layer-accent); + --border: var(--cds-border-subtle); + --border-strong: var(--cds-border-strong); + --text: var(--cds-text-primary); + --text-muted: var(--cds-text-secondary); + --text-dim: var(--cds-text-helper); /* S11: was --cds-ui-04 (#8d8d8d, 3.32:1) */ + --accent: var(--cds-interactive-01); + --accent-dim: var(--cds-highlight); + --accent-green: var(--cds-support-success); + --accent-green-dim: var(--wp-status-success-bg); + --accent-amber: var(--wp-status-warning-text); + --accent-amber-dim: var(--wp-status-warning-bg); + --red: var(--cds-support-error); + --red-dim: var(--wp-status-error-bg); + --radius: var(--wp-radius-0); + --shadow: var(--wp-shadow-none); + /* Cool-tinted, where the wizard's --shadow-lg is neutral. Same geometry in + both - 0 4px 16px - so the two can be merged later without moving + anything; merging is still a rendered change. tokens.md section 8-F. */ + --shadow-lg: var(--wp-shadow-lg-cool); + --mono: var(--wp-font-mono); + --sans: var(--wp-font-sans); + /* The tab strip moved into wp-chrome.css at T7.1 and is drawn by both tool + pages. It reads the wizard sheet's role names, so this sheet aliases the + same three - from the same canonical tokens, not from new values. */ + --bg-card: var(--cds-layer); + --text-light: var(--cds-text-secondary); + --primary: var(--cds-interactive-01); } * { box-sizing: border-box; margin: 0; padding: 0; } @@ -54,16 +69,46 @@ z-index: 100; box-shadow: var(--shadow); } - .header-logo { - font-family: var(--mono); - font-size: 11px; - font-weight: 600; - letter-spacing: .15em; - color: var(--accent); - text-transform: uppercase; + /* The brand is the shared logo chip now, the same as every other page (T2.2). + .header-logo was this page's own mono wordmark - the one thing in the suite + that spelled "Prime Controls" out in monospace instead of showing the mark. + .wp-logo-chip comes from theme-light.css, which this page already loads. */ + .header-logo-chip { + display: inline-flex; + align-items: center; + text-decoration: none; + flex-shrink: 0; } + .header-logo-chip:hover { opacity: .92; } .header-sep { color: var(--border-strong); } .header-title { font-size: 13px; font-weight: 500; color: var(--text-muted); } + /* Same two-group header the suite page uses, so wp-chrome.js inserts the + project switcher between them rather than in front of the brand. */ + .header-left { display: flex; align-items: center; gap: 12px; min-width: 0; flex-shrink: 0; } + .header-right { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; } + + /* ── PACKAGE TOOLBAR ── + What the old header row became once the brand, the feedback panel and the + Dashboard button moved out. Actions on the package, or on the SOP behind it. */ + .wp-toolbar { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 8px 32px; + background: var(--surface); + border-bottom: 1px solid var(--border); + } + .wp-toolbar .btn { padding: 7px 14px; } + .wp-toolbar-sep { + width: 1px; + align-self: stretch; + margin: 2px 6px; + background: var(--border); + } + @media (max-width: 860px) { + .wp-toolbar { padding: 8px 16px; } + } /* ── STEPPER ── */ .stepper-wrap { padding: 22px 32px 0; max-width: 1000px; margin: 0 auto; } @@ -104,7 +149,10 @@ auto-hiding overlay drawer (see below) rather than a column, so it never takes width away from the form — which matters most when this page is embedded in the suite's tab and every pixel is shared with the app chrome. */ - .wp-layout { display: block; width: 100%; margin: 0; } + /* F6/D3: flex, so the section rail declared AFTER the form in the markup can sit + ABOVE it at narrow widths (order:-1) and BESIDE it at desk width. Declaring it + after .main is what lets it be a sticky column without wrapping the layout. */ + .wp-layout { display: flex; flex-direction: column; width: 100%; margin: 0; } .main { min-width: 0; max-width: none; margin: 0; padding: 22px 28px 72px calc(var(--nav-w,288px) + 28px); transition: padding-left .18s ease; } @@ -141,9 +189,12 @@ .field { display: flex; flex-direction: column; gap: 6px; } .field.span2 { grid-column: span 2; } + /* A5 scopes the sentence-case rule to buttons and FIELD LABELS, which is this + rule. The mono face, the size and the tracking are the creator's idiom and + are left alone; only the forced uppercase goes. */ label { font-family: var(--mono); font-size: 10px; font-weight: 500; - letter-spacing: .1em; color: var(--text-muted); text-transform: uppercase; + letter-spacing: .1em; color: var(--text-muted); } label .req { color: var(--accent); margin-left: 3px; } @@ -155,14 +206,22 @@ font-family: var(--sans); font-size: 14px; padding: 9px 12px; - outline: none; transition: border-color .15s, box-shadow .15s; width: 100%; } + /* S12 / BL-013. `outline: none` used to sit in the rule above, replaced on focus + by a 3px --accent-dim glow: #edf5ff against a #ffffff field is 1.05:1, which is + not a visible indicator. The border change and the glow stay as secondary cues; + the ring is the theme's, inset over the control's own edge so it does not shift + the layout of a dense form. */ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); } + input:focus-visible, textarea:focus-visible, select:focus-visible { + outline: 2px solid var(--cds-focus); + outline-offset: -2px; + } textarea { resize: vertical; min-height: 70px; line-height: 1.5; } ::placeholder { color: var(--text-dim); } @@ -224,8 +283,8 @@ .sub-heading::after { content: ''; flex: 1; height: 1px; background: var(--border); } .notice { - background: var(--accent-dim); border: 1px solid #b9d2fb; border-radius: var(--radius); - padding: 10px 14px; font-size: 12px; color: #0043ce; margin-bottom: 18px; font-family: var(--mono); + background: var(--accent-dim); border: 1px solid var(--wp-accent-border-a); border-radius: var(--radius); + padding: 10px 14px; font-size: 12px; color: var(--cds-link-secondary); margin-bottom: 18px; font-family: var(--mono); } /* ── DELIVERABLES ── */ @@ -242,7 +301,7 @@ .deliv-box { width: 16px; height: 16px; border: 1.5px solid var(--border-strong); border-radius: 3px; flex-shrink: 0; margin-top: 2px; display: flex; align-items: center; justify-content: center; - font-size: 11px; color: #fff; transition: all .12s; + font-size: 11px; color: var(--cds-text-on-color); transition: all .12s; } .deliv-item.checked .deliv-box { background: var(--accent); border-color: var(--accent); } .deliv-text { font-size: 12.5px; line-height: 1.35; color: var(--text); } @@ -252,14 +311,19 @@ .nav-row { display: flex; justify-content: space-between; align-items: center; padding-top: 24px; margin-top: 24px; border-top: 1px solid var(--border); } .btn { padding: 10px 22px; border-radius: var(--radius); font-family: var(--mono); font-size: 11px; font-weight: 600; - letter-spacing: .08em; text-transform: uppercase; cursor: pointer; border: 1px solid; transition: all .15s; + letter-spacing: .08em; cursor: pointer; border: 1px solid; transition: all .15s; } - .btn-ghost { background: var(--surface); border-color: var(--border-strong); color: var(--text-muted); } - .btn-ghost:hover { border-color: var(--accent); color: var(--accent); } - .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: var(--shadow); } - .btn-primary:hover { background: #0353e9; } - .btn-generate { background: var(--accent-green); border-color: var(--accent-green); color: #fff; font-weight: 700; box-shadow: var(--shadow); } - .btn-generate:hover { background: #0e6027; } + /* secondary */ + .btn-ghost { background: var(--wp-btn-secondary-bg); border-color: var(--wp-btn-secondary-border); color: var(--text-muted); } + .btn-ghost:hover { border-color: var(--wp-btn-secondary-hover-fg); color: var(--wp-btn-secondary-hover-fg); } + /* primary */ + .btn-primary { background: var(--wp-btn-primary-bg); border-color: var(--wp-btn-primary-bg); color: var(--wp-btn-primary-fg); box-shadow: var(--shadow); } + .btn-primary:hover { background: var(--wp-btn-primary-hover); } + /* A5: .btn-generate was green. It is the same role as .btn-primary and now + renders identically; the class is kept because the markup and the scripts + both use it, and renaming it is T7.2's business, not a colour task's. */ + .btn-generate { background: var(--wp-btn-primary-bg); border-color: var(--wp-btn-primary-bg); color: var(--wp-btn-primary-fg); font-weight: 700; box-shadow: var(--shadow); } + .btn-generate:hover { background: var(--wp-btn-primary-hover); } /* ── OUTPUT ── */ #output-section { display: none; } @@ -280,7 +344,19 @@ .output-doc p { margin-bottom: 10px; } .output-doc ul { padding-left: 20px; margin-bottom: 10px; } .output-doc li { margin-bottom: 3px; } - .output-doc table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 12px; border: 1px solid var(--border); } + .output-doc table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 12px; border: 1px solid var(--border); + /* CR-008 / T9.1: the export opens on tablets. Fixed layout + wrap-anywhere + keeps every table inside the screen instead of laying out at its content's + natural 520px and dragging the whole document sideways. */ + table-layout: fixed; } + .output-doc td, .output-doc th { overflow-wrap: anywhere; } + /* On a phone/tablet the inline column widths the desktop layout carries + (width:200px on header cells) exceed the available line; !important is the + one CSS mechanism that outranks an inline style, which is exactly the job. */ + @media (max-width: 768px) { + .output-doc { padding: 20px 14px; } + .output-doc th, .output-doc td { width: auto !important; } + } .output-doc th { background: var(--surface2); border: 1px solid var(--border); padding: 6px 10px; font-family: var(--mono); font-size: 9px; letter-spacing: .08em; text-transform: uppercase; text-align: left; color: var(--text-muted); } .output-doc td { border: 1px solid var(--border); padding: 7px 10px; vertical-align: top; } .output-doc .badge { display: inline-block; padding: 1px 8px; border-radius: 3px; font-family: var(--mono); font-size: 10px; font-weight: 600; } @@ -296,7 +372,7 @@ /* ── LOADING ── */ .loading-overlay { - display: none; position: fixed; inset: 0; background: rgba(244,245,247,.82); z-index: 200; + display: none; position: fixed; inset: 0; background: var(--wp-scrim-loading); z-index: 200; align-items: center; justify-content: center; flex-direction: column; gap: 16px; backdrop-filter: blur(2px); } .loading-overlay.active { display: flex; } @@ -325,11 +401,12 @@ background: var(--surface); color: var(--text-dim); cursor: pointer; font-size: 16px; line-height: 1; display: flex; align-items: center; justify-content: center; transition: all .12s; flex-shrink: 0; } - .row-del:hover { border-color: var(--red); color: var(--red); background: var(--red-dim); } + /* danger, outlined */ + .row-del:hover { border-color: var(--wp-btn-danger-border); color: var(--wp-btn-danger-fg); background: var(--wp-btn-danger-soft-bg); } .add-btn { display: inline-flex; align-items: center; gap: 7px; padding: 8px 16px; border-radius: var(--radius); border: 1px dashed var(--border-strong); background: var(--surface); color: var(--text-muted); - font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; + font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: .06em; cursor: pointer; transition: all .15s; } .add-btn:hover { border-color: var(--accent); color: var(--accent); border-style: solid; background: var(--accent-dim); } @@ -366,7 +443,7 @@ .seq-step.gate { border-color: var(--accent-amber); background: var(--accent-amber-dim); border-style: dashed; } .seq-step.gate .seq-label { color: var(--accent-amber); font-weight: 500; } .seq-gate-badge { - flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--accent-amber); color: #fff; + flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--accent-amber); color: var(--cds-text-on-color); font-family: var(--mono); font-size: 9px; font-weight: 600; letter-spacing: .08em; white-space: nowrap; } .add-btn-gate { border-color: var(--accent-amber); color: var(--accent-amber); } @@ -379,26 +456,46 @@ /* ── REVIEW COMMENTS ─────────────────────────────────────────────── */ .cbadge-total { display:inline-block; min-width:16px; padding:0 5px; margin-left:4px; font-family:var(--mono); - font-size:10px; font-weight:700; line-height:16px; text-align:center; color:#fff; background:var(--accent); border-radius:9px; } + font-size:10px; font-weight:700; line-height:16px; text-align:center; color:var(--cds-text-on-color); background:var(--accent); border-radius:9px; } .step-tab { position:relative; } .step-tab .cbadge { position:absolute; top:4px; right:4px; min-width:15px; height:15px; padding:0 4px; font-family:var(--mono); font-size:9px; font-weight:700; line-height:15px; text-align:center; - color:#fff; background:var(--accent-amber); border-radius:8px; box-shadow:0 0 0 2px var(--surface); } + color:var(--cds-text-on-color); background:var(--accent-amber); border-radius:8px; box-shadow:0 0 0 2px var(--surface); } - .cmt-overlay { position:fixed; inset:0; background:rgba(20,30,50,.28); opacity:0; pointer-events:none; + .cmt-overlay { position:fixed; inset:0; background:var(--wp-scrim-cool); opacity:0; pointer-events:none; transition:opacity .2s ease; z-index:60; } .cmt-overlay.open { opacity:1; pointer-events:auto; } - .cmt-drawer { position:fixed; top:0; right:0; height:100vh; width:380px; max-width:92vw; background:var(--surface); + /* Starts below the header, not behind it. The containing block was already the + viewport (this is a body child, no transformed ancestor), so `top:0` put the + drawer's own head — its title and its ✕ — underneath the sticky .header, which + carries z-index:100 against the drawer's 61 and therefore won. The drawer was + not off-screen so much as roofed over, and the close button was unreachable. + Raising z-index would have put the panel OVER the header instead, which is the + same collision with the layers swapped. --rail-top is the header's measured + height, set by wp-creation-app.js:1328 and already used by .wp-nav for exactly + this — reusing it keeps one definition of "below the header". */ + /* S12 / C1: `visibility:hidden` while closed, not just translated off-screen. + A transform moves a thing; it does not remove it from the tab order. The + closed drawer's name field, its textarea, its Add comment button and its ✕ + were all still focusable, so a keyboard user tabbing through the form fell + into a panel they could not see and could not tell they were in. Found by + tests/a11y_check.py, which measured a focus ring on a control no sighted + user could be looking at. + The transition delays visibility to the end of the slide when closing, and + applies it immediately when opening, so the panel still animates both ways. */ + .cmt-drawer { position:fixed; top:var(--rail-top,48px); right:0; height:calc(100vh - var(--rail-top,48px)); + width:380px; max-width:92vw; background:var(--surface); border-left:1px solid var(--border); box-shadow:var(--shadow-lg); transform:translateX(100%); - transition:transform .24s ease; z-index:61; display:flex; flex-direction:column; } - .cmt-drawer.open { transform:translateX(0); } + visibility:hidden; + transition:transform .24s ease, visibility 0s linear .24s; z-index:61; display:flex; flex-direction:column; } + .cmt-drawer.open { transform:translateX(0); visibility:visible; transition:transform .24s ease, visibility 0s; } .cmt-head { display:flex; align-items:center; justify-content:space-between; padding:16px 18px; border-bottom:1px solid var(--border); } .cmt-title { font-weight:700; font-size:14px; color:var(--text); } .cmt-x { background:none; border:none; color:var(--text-muted); font-size:15px; cursor:pointer; padding:4px 8px; border-radius:4px; } .cmt-x:hover { background:var(--surface2); color:var(--text); } .cmt-namebar { padding:12px 18px; border-bottom:1px solid var(--border); } - .cmt-namebar label { display:block; font-size:10px; text-transform:uppercase; letter-spacing:.04em; color:var(--text-muted); margin-bottom:5px; } + .cmt-namebar label { display:block; font-size:10px; letter-spacing:.04em; color:var(--text-muted); margin-bottom:5px; } .cmt-namebar input { width:100%; padding:8px 10px; border:1px solid var(--border-strong); border-radius:var(--radius); font-family:var(--sans); font-size:13px; color:var(--text); background:var(--surface); } .cmt-compose { padding:14px 18px; border-bottom:1px solid var(--border); background:var(--surface2); } .cmt-compose-label { font-size:11px; color:var(--text-muted); margin-bottom:7px; } @@ -418,7 +515,7 @@ .cmt-text { font-size:13px; color:var(--text); line-height:1.5; white-space:pre-wrap; word-break:break-word; } .cmt-del { background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:11px; padding:2px 5px; border-radius:4px; } .cmt-del:hover { background:var(--red-dim); color:var(--red); } - .cmt-jump { background:none; border:none; color:var(--accent); cursor:pointer; font-size:10px; padding:0; margin-top:4px; } + .cmt-jump { background:none; border:none; color:var(--wp-btn-tertiary-fg); cursor:pointer; font-size:10px; padding:0; margin-top:4px; } .cmt-jump:hover { text-decoration:underline; } .cmt-foot { border-top:1px solid var(--border); padding:12px 18px; } .cmt-note { font-size:10px; color:var(--text-muted); line-height:1.5; margin-bottom:10px; } @@ -429,13 +526,14 @@ /* ── REV1: validation, governance, summary ───────────────────────── */ .radio-group.group-invalid, .check-group.group-invalid { outline:2px solid var(--red); outline-offset:4px; border-radius:var(--radius); } .ov-select.ov-unset { color:var(--red) !important; border-color:var(--red); } + /* primary. Was green (A5) — it applies a suggested value, which is an action. */ .use-btn { display:inline-block; margin-left:8px; padding:4px 14px; font-family:var(--sans); font-size:11px; font-weight:700; - color:#fff; background:var(--accent-green); border:none; border-radius:var(--radius); cursor:pointer; letter-spacing:.03em; } - .use-btn:hover { background:#0e6027; } + color:var(--wp-btn-primary-fg); background:var(--wp-btn-primary-bg); border:none; border-radius:var(--radius); cursor:pointer; letter-spacing:.03em; } + .use-btn:hover { background:var(--wp-btn-primary-hover); } .sum-chips { display:flex; flex-wrap:wrap; gap:7px; } - .sum-chip { background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb; border-radius:3px; + .sum-chip { background:var(--accent-dim); color:var(--accent); border:1px solid var(--wp-accent-border-a); border-radius:3px; padding:3px 10px; font-family:var(--mono); font-size:10px; } - .sum-warn { margin-top:10px; color:var(--accent-amber); background:var(--accent-amber-dim); border:1px solid #f0d9ad; + .sum-warn { margin-top:10px; color:var(--accent-amber); background:var(--accent-amber-dim); border:1px solid var(--wp-status-warning-border-b); border-radius:var(--radius); padding:7px 10px; font-size:11px; } /* ── CREATION TOOL ───────────────────────────────────────────────── */ @@ -445,17 +543,18 @@ .ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; } .ctx-main .ctx-sub { font-size:11px; color:var(--text-muted); margin-top:2px; } .ctx-sample { font-family:var(--mono); font-size:9px; font-weight:700; color:var(--accent-amber); - background:var(--accent-amber-dim); border:1px solid #f0d9ad; border-radius:9px; padding:1px 7px; margin-left:6px; vertical-align:middle; } + background:var(--accent-amber-dim); border:1px solid var(--wp-status-warning-border-b); border-radius:9px; padding:1px 7px; margin-left:6px; vertical-align:middle; } .ctx-meta { margin-left:auto; display:flex; gap:16px; font-size:11px; color:var(--text-muted); flex-wrap:wrap; } .ctx-meta b { color:var(--accent); } .ctx-meta code { background:var(--surface2); padding:1px 6px; border-radius:3px; color:var(--accent); } - .link-btn { background:none; border:none; color:var(--accent); cursor:pointer; font-size:inherit; padding:0; text-decoration:underline; } + /* tertiary */ + .link-btn { background:none; border:none; color:var(--wp-btn-tertiary-fg); cursor:pointer; font-size:inherit; padding:0; text-decoration:underline; } .mode-wrap { max-width:none; margin:0; padding:16px 28px 0; display:flex; align-items:center; gap:16px; } .mode-toggle { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px; overflow:hidden; } .mode-btn { padding:8px 18px; font-family:var(--sans); font-size:13px; font-weight:600; border:none; background:var(--surface); color:var(--text-muted); cursor:pointer; } - .mode-btn.active { background:var(--accent); color:#fff; } + .mode-btn.active { background:var(--accent); color:var(--cds-text-on-color); } .created-count { font-size:11px; color:var(--text-muted); } .wo-section { border:1px solid var(--border); border-radius:var(--radius); padding:11px 13px; margin-bottom:9px; background:var(--surface); } @@ -477,37 +576,70 @@ .sop-hint { color:var(--accent) !important; } .release-banner { max-width:none; margin:0; padding:0 28px 0 calc(var(--nav-w,288px) + 28px); } .release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600; - display:flex; align-items:center; gap:10px; } - .rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid #b6e3c6; } - .rb-notready { background:var(--accent-amber-dim); color:var(--accent-amber); border:1px solid #f0d9ad; } - .rb-hold { background:var(--red-dim); color:var(--red); border:1px solid #f3c4c4; } + display:flex; align-items:center; gap:10px; flex-wrap:wrap; } + .rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid var(--wp-status-success-border-b); } + .rb-notready { background:var(--accent-amber-dim); color:var(--accent-amber); border:1px solid var(--wp-status-warning-border-b); } + .rb-hold { background:var(--red-dim); color:var(--red); border:1px solid var(--wp-status-error-border-b); } + /* CR-014: the QA-queue state. Informational, not alarming - accent, not amber. */ + .rb-qa { background:var(--accent-dim); color:var(--accent); border:1px solid var(--accent); } + .rb-act-ghost { background:transparent; color:var(--accent); border:1px solid var(--accent); margin-left:8px; } + .rb-act-ghost:hover { background:var(--accent-dim); } + /* D4: the audited-override action on the release banner. A real button in the + primary position for an Urgent package; it simply never renders otherwise. */ + .rb-act { margin-left:auto; border:none; border-radius:var(--radius); cursor:pointer; + background:var(--primary); color:var(--cds-text-on-color); font-family:var(--sans); + font-size:12px; font-weight:600; padding:7px 14px; min-height:32px; } + .rb-act:hover { background:var(--cds-hover-primary); } .pill-hold.selected { background:var(--red) !important; border-color:var(--red) !important; } - .pill-hold.selected .dot { background:#fff !important; } + .pill-hold.selected .dot { background:var(--cds-text-on-color) !important; } + + /* CR-007/D8: the upload strip in Drawings & Attachments. */ + .file-rules { margin:10px 0 6px; font-size:12px; color:var(--text-muted); } + .file-rules .fr-warn { color:var(--accent-amber); font-weight:700; } + .file-rules .fr-full { color:var(--red); font-weight:700; } + .wp-file-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:6px 0; } + .wp-file-row input[type="text"] { flex:1 1 240px; } + .wp-file-list { display:flex; flex-direction:column; gap:6px; margin:6px 0; } + .wp-file-item { display:flex; gap:10px; align-items:center; flex-wrap:wrap; + border:1px solid var(--border); border-radius:var(--radius); padding:8px 10px; } + .wp-file-item a { color:var(--accent); text-decoration:none; font-weight:600; overflow-wrap:anywhere; } + .wp-file-item .wf-size { color:var(--text-muted); font-size:11px; } + .wp-file-item input { flex:1 1 200px; font-size:12px; } + .wp-file-x { margin-left:auto; } + + /* CR-013: material requests. Rows wrap at 390px - requests originate in + the field. */ + .mreq-row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:8px; } + .mreq-list { display:flex; flex-direction:column; gap:8px; margin:8px 0; } + .mreq-item { border:1px solid var(--border); border-radius:var(--radius); padding:8px 10px; font-size:12px; } + .mreq-item .mr-head { display:flex; gap:10px; flex-wrap:wrap; align-items:center; font-weight:600; } + .mreq-item .mr-status { padding:1px 8px; border-radius:9px; background:var(--accent-dim); color:var(--accent); font-size:11px; font-weight:700; } + .mreq-item .mr-lines { color:var(--text-muted); margin-top:3px; } .cstatus { display:inline-flex; border:1px solid var(--border-strong); border-radius:5px; overflow:hidden; } .cstatus button { border:none; background:var(--surface); color:var(--text-muted); font-family:var(--sans); font-size:11px; font-weight:600; padding:4px 10px; cursor:pointer; border-right:1px solid var(--border); } .cstatus button:last-child { border-right:none; } - .cstatus button.on-open { background:var(--red); color:#fff; } - .cstatus button.on-cleared { background:var(--accent-green); color:#fff; } - .cstatus button.on-na { background:var(--text-muted); color:#fff; } + .cstatus button.on-open { background:var(--red); color:var(--cds-text-on-color); } + .cstatus button.on-cleared { background:var(--accent-green); color:var(--cds-text-on-color); } + .cstatus button.on-na { background:var(--text-muted); color:var(--cds-text-on-color); } #material-body input, #attach-body input, #constraint-body input, #signoff-body input { width:100%; } .signoff-check { width:18px; height:18px; cursor:pointer; } /* ── COMMENT-DRIVEN ADDITIONS ────────────────────────────────────── */ .locked-field { background:var(--surface2)!important; color:var(--text-muted); cursor:not-allowed; } .lock-row { display:flex; align-items:center; gap:10px; margin-top:5px; } - .lock-edit { background:none; border:none; color:var(--accent); cursor:pointer; font-size:11px; padding:0; } + .lock-edit { background:none; border:none; color:var(--wp-btn-tertiary-fg); cursor:pointer; font-size:11px; padding:0; } .override-note { font-size:11px; color:var(--accent-amber); } - .sop-tag { font-size:9px; font-weight:700; color:var(--accent); background:var(--accent-dim,#eaf0fd); border:1px solid #cdd9f2; border-radius:9px; padding:1px 6px; margin-left:6px; vertical-align:middle; } + .sop-tag { font-size:9px; font-weight:700; color:var(--accent); background:var(--accent-dim); border:1px solid var(--wp-accent-border-b); border-radius:9px; padding:1px 6px; margin-left:6px; vertical-align:middle; } - #toast { position:fixed; bottom:26px; left:50%; transform:translateX(-50%) translateY(20px); background:var(--text); color:#fff; - padding:10px 20px; border-radius:8px; font-size:13px; font-weight:600; opacity:0; pointer-events:none; transition:all .25s; z-index:9999; box-shadow:0 6px 24px rgba(0,0,0,.25); } + #toast { position:fixed; bottom:26px; left:50%; transform:translateX(-50%) translateY(20px); background:var(--text); color:var(--cds-text-on-color); + padding:10px 20px; border-radius:8px; font-size:13px; font-weight:600; opacity:0; pointer-events:none; transition:all .25s; z-index:9999; box-shadow:var(--wp-shadow-toast); } #toast.show { opacity:1; transform:translateX(-50%) translateY(0); } - .modal-overlay { position:fixed; inset:0; background:rgba(20,28,40,.55); display:none; align-items:center; justify-content:center; z-index:9000; padding:20px; } + .modal-overlay { position:fixed; inset:0; background:var(--wp-scrim-cool-strong); display:none; align-items:center; justify-content:center; z-index:9000; padding:20px; } .modal-overlay.open { display:flex; } - .modal { background:var(--surface); border-radius:0; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; } + .modal { background:var(--surface); border-radius:0; width:100%; max-width:520px; box-shadow:var(--wp-shadow-modal-lg); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; } .modal-head { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); } .modal-title { font-weight:700; font-size:15px; color:var(--text); } .modal-body { padding:18px 20px; overflow-y:auto; } @@ -516,11 +648,11 @@ .hold-photo-preview img { max-width:160px; max-height:120px; border-radius:6px; border:1px solid var(--border); margin-top:8px; display:block; } /* ── REV 2 ADDITIONS ─────────────────────────────────────────────── */ - .auto-tag { font-size:9px; font-weight:700; color:var(--accent-green); background:var(--accent-green-dim); border:1px solid #b6e3c6; border-radius:9px; padding:1px 6px; margin-left:6px; vertical-align:middle; } + .auto-tag { font-size:9px; font-weight:700; color:var(--accent-green); background:var(--accent-green-dim); border:1px solid var(--wp-status-success-border-b); border-radius:9px; padding:1px 6px; margin-left:6px; vertical-align:middle; } .derived-box { padding:9px 11px; border:1px dashed var(--border-strong); border-radius:var(--radius); background:var(--surface2); color:var(--text); font-size:13px; font-weight:600; min-height:38px; display:flex; align-items:center; } .material-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:8px; } .workstep-row { display:flex; align-items:flex-start; gap:10px; margin-bottom:8px; } - .workstep-row .ws-num { flex:0 0 26px; height:26px; border-radius:50%; background:var(--accent); color:#fff; font-size:12px; font-weight:700; display:flex; align-items:center; justify-content:center; margin-top:5px; } + .workstep-row .ws-num { flex:0 0 26px; height:26px; border-radius:50%; background:var(--accent); color:var(--cds-text-on-color); font-size:12px; font-weight:700; display:flex; align-items:center; justify-content:center; margin-top:5px; } .workstep-row textarea { flex:1; padding:8px 10px; border:1px solid var(--border-strong); border-radius:var(--radius); font-family:var(--sans); font-size:13px; resize:vertical; min-height:38px; } .workstep-row .row-del { flex:0 0 auto; margin-top:6px; } @@ -539,35 +671,39 @@ .modal { max-width:100% !important; } .output-toolbar { flex-wrap:wrap; } .material-actions .add-btn { flex:1 1 auto; } - table { min-width:520px; } /* keep columns legible; .table-wrap scrolls */ + /* Scoped to the FORM's scroll containers only. This was a bare `table` and + it reached the export document too, forcing every printed table to 520px + inside a 390px screen - the export must FIT a tablet, not scroll (CR-008). */ + .table-wrap table { min-width:520px; } /* keep columns legible; .table-wrap scrolls */ .cstatus button { padding:6px 8px; } } @media (max-width: 480px) { - .header-logo { font-size:14px; } .workstep-row textarea { font-size:16px; } /* avoid iOS zoom */ input, select, textarea { font-size:16px; } /* avoid iOS zoom on focus */ } /* ── REV 3 ADDITIONS ─────────────────────────────────────────────── */ /* SOP-inherited field highlight (comment 8) */ - .sop-inherited { background:rgba(37,99,214,0.07) !important; border-color:var(--accent) !important; opacity:0.85; color:var(--text); } + .sop-inherited { background:var(--wp-sop-inherited-bg) !important; border-color:var(--accent) !important; opacity:0.85; color:var(--text); } .sop-inherited:focus { opacity:1; } /* Dev mode (comment 7) */ .logo-wrap { position:relative; display:flex; align-items:center; } - .dev-toggle { position:absolute; left:2px; bottom:-9px; width:18px; height:7px; padding:0; border:none; + /* C2/T9.6: a deliberately unobtrusive dev switch is still a control - it + meets the 24px floor and earns its subtlety with opacity, not size. */ + .dev-toggle { position:absolute; left:2px; bottom:-12px; width:24px; height:24px; padding:0; border:none; background:var(--text-dim); opacity:0.10; border-radius:3px; cursor:pointer; } .dev-toggle:hover { opacity:0.35; } - .dev-banner { background:#3a2a00; color:#ffd479; font-size:12.5px; font-weight:700; text-align:center; padding:7px 14px; letter-spacing:.3px; } - body.dev-mode .header { box-shadow: inset 0 -3px 0 #ffb000; } + .dev-banner { background:var(--wp-dev-bg); color:var(--wp-dev-fg); font-size:12.5px; font-weight:700; text-align:center; padding:7px 14px; letter-spacing:.3px; } + body.dev-mode .header { box-shadow: inset 0 -3px 0 var(--wp-dev-rule); } /* SOP reference links (comments 1,3,4) */ .sop-ref-links { margin-bottom:12px; } .ref-links-title { font-size:12px; color:var(--text-muted); margin-bottom:6px; } .ref-links { display:flex; flex-wrap:wrap; gap:8px; } .ref-link { display:inline-flex; align-items:center; gap:6px; font-size:12.5px; font-weight:600; color:var(--accent); - background:var(--accent-dim,#eef3fd); border:1px solid #cdd9f2; border-radius:8px; padding:6px 11px; text-decoration:none; } - .ref-link:hover { background:#e2ecfc; } + background:var(--accent-dim); border:1px solid var(--wp-accent-border-b); border-radius:8px; padding:6px 11px; text-decoration:none; } + .ref-link:hover { background:var(--wp-accent-soft-hover); } .ref-link .ref-sys { font-weight:400; color:var(--text-muted); font-size:11px; } .ref-link-sm { font-size:12px; font-weight:600; color:var(--accent); text-decoration:none; } .ref-link-sm:hover { text-decoration:underline; } @@ -576,21 +712,148 @@ .so-date { font-size:13px; font-variant-numeric:tabular-nums; } .so-ovr { margin-left:8px; font-size:11px; } - /* Collapsible form sections */ - .collapse-chev { display:inline-block; width:1em; margin-right:7px; color:var(--text-muted); font-size:11px; user-select:none; } - .card.collapsed > :not(.section-header):not(.sub-heading) { display:none !important; } - .card.collapsed .section-desc { display:none; } + /* ── COLLAPSIBLE SECTIONS + SECTION RAIL (F6 / D3) ───────────────────────── + What stood here: a `.section-nav-bar` of `` jump chips, and a + `.card.collapsed > :not(.section-header):not(.sub-heading)` rule that hid a + card's contents without any element carrying the disclosure state. Neither + was reachable by keyboard, and neither said anything to a screen reader. - /* Section nav (jump chips) */ - .section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px; - padding:8px 12px 8px calc(var(--nav-w,288px) + 28px); background:rgba(255,255,255,.94); backdrop-filter:blur(4px); - border-bottom:1px solid var(--border); box-shadow:0 1px 4px rgba(20,30,50,.06); - transition:transform .22s ease; } - .section-nav-bar:empty{ display:none; } - .section-nav-bar.nav-hidden{ transform:translateY(-160%); } - .sec-chip{ font-size:12px; font-weight:600; color:var(--text-muted); background:var(--surface2); - border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; } - .sec-chip:hover{ border-color:var(--accent); color:var(--accent); } + Now: every heading is a real disclosure button, its contents are one + `.card-body` so `aria-controls` has a target, and the table of contents is a + rail of real buttons that marks where you are. + + D3 amended F6's height criterion to "at rest". At rest one section is open, + which is what keeps this page under two screen heights; `Expand all` is a + deliberate choice to exceed it. */ + + /* The disclosure button lives INSIDE the existing heading, so .sub-heading's + trailing rule and the help tip beside it keep working untouched. */ + .card-toggle { + display: inline-flex; align-items: center; gap: 9px; + background: none; border: 0; padding: 0; margin: 0; + font: inherit; color: inherit; text-align: left; cursor: pointer; + border-radius: var(--radius); + } + .card-toggle:hover { color: var(--accent); } + .card-toggle-label { min-width: 0; } + + /* Drawn, not typed. A glyph here would be a fourth icon idiom on a page S6 is + already going to have to reconcile, and it would render differently per + platform - which is half of what S6 is about. */ + .collapse-chev { + flex: 0 0 auto; width: 7px; height: 7px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(45deg) translate(-2px, -2px); + transition: transform .15s ease; + } + .card-toggle[aria-expanded="false"] .collapse-chev { + transform: rotate(-45deg) translate(-2px, 2px); + } + + .card-body[hidden] { display: none !important; } + .card.collapsed .section-desc { display: none; } + /* A collapsed card is a heading, so it should read as a row rather than a box + with one line in it. */ + /* A collapsed section should read as a ROW in a list, not as a box with one line + in it. Eleven of them at the card's own 28px padding is 660px of nothing - + which is a third of what F6 was measuring, arriving by a different door. */ + .card.collapsed { padding-top: 10px; padding-bottom: 10px; } + .card.collapsed .section-header { margin-bottom: 0; padding-bottom: 0; border-bottom: 0; } + .card.collapsed .sub-heading { margin-bottom: 0; } + + /* ── the rail ── + A horizontal strip above the form by default - which is what fits at 390px - + and a sticky column beside it from 1200px, where there is width to spare. */ + .sec-rail { + order: -1; + position: sticky; + top: 0; + z-index: 30; + padding: 8px 28px 8px calc(var(--nav-w, 288px) + 28px); + background: var(--surface); + border-bottom: 1px solid var(--border); + } + .sec-rail-head { + display: flex; align-items: center; gap: 12px; + margin-bottom: 6px; + } + .sec-rail-title { + font-family: var(--mono); font-size: 10px; font-weight: 600; + letter-spacing: .08em; text-transform: uppercase; color: var(--text-dim); + } + .sec-rail-all { + margin-left: auto; + background: none; border: 1px solid var(--border); border-radius: var(--radius); + padding: 3px 10px; font: inherit; font-size: 11px; color: var(--text-muted); + cursor: pointer; + } + .sec-rail-all:hover { border-color: var(--accent); color: var(--accent); } + .sec-rail-all[aria-pressed="true"] { + border-color: var(--accent); color: var(--accent); background: var(--accent-dim); + } + .sec-rail-list { + display: flex; flex-wrap: wrap; gap: 4px; + list-style: none; margin: 0; padding: 0; + } + .sec-rail-item { + display: block; width: 100%; + background: none; border: 1px solid transparent; border-radius: var(--radius); + padding: 7px 11px; font: inherit; font-size: 12px; font-weight: 500; + color: var(--text-muted); cursor: pointer; text-align: left; white-space: nowrap; + min-height: 34px; + } + .sec-rail-item:hover { color: var(--accent); border-color: var(--border); } + /* Not colour alone: the current entry is bolder, keeps a left marker and is the + one carrying aria-current. */ + /* S1 / T7.9: inline validation. The message sits AT the field (role=alert in + the DOM, so it announces), and a section holding an error says so on its + rail entry with a character, not only a colour. */ + .field-error { color: var(--red); font-size: 12px; font-weight: 600; margin-top: 4px; } + .field-error:empty { display: none; } + [aria-invalid="true"] { border-color: var(--red) !important; } + .sec-rail-item .sec-err { display:inline-block; margin-right:6px; min-width:16px; + text-align:center; border-radius:8px; background:var(--red); + color:var(--cds-text-on-color); font-size:11px; font-weight:700; line-height:16px; } + + /* A2: the open-constraint count. Red chip + a number - the number is the + content, so the state is never colour-only. Sized to stay legible at 390px. */ + .sec-badge { display:inline-block; margin-left:8px; min-width:18px; padding:1px 6px; + border-radius:9px; background:var(--red); color:var(--cds-text-on-color); + font-size:12px; font-weight:700; line-height:16px; text-align:center; } + .sec-badge[hidden] { display:none; } + .sec-rail-item.is-current { + color: var(--accent); font-weight: 700; + background: var(--accent-dim); + border-color: var(--accent-dim); + box-shadow: inset 2px 0 0 0 var(--accent); + } + + @media (max-width: 1199px) { + /* Horizontal strip: the entries sit side by side and the list scrolls rather + than stacking eleven full-width rows above the form. */ + .sec-rail-list { flex-wrap: nowrap; overflow-x: auto; } + .sec-rail-item { width: auto; } + } + + @media (min-width: 1200px) { + .wp-layout { flex-direction: row; align-items: flex-start; } + .main { flex: 1 1 auto; } + .sec-rail { + order: 0; + flex: 0 0 224px; + align-self: flex-start; + top: var(--sec-rail-top, 48px); + max-height: calc(100vh - var(--sec-rail-top, 48px)); + overflow-y: auto; + margin: 22px 28px 0 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + } + .sec-rail-list { flex-direction: column; flex-wrap: nowrap; } + .sec-rail-item { white-space: normal; } + } /* ── SOP-inherited marker ─────────────────────────────────────────────────── The "from SOP types" subtext used to sit under the field. It's now a small @@ -599,16 +862,16 @@ hover, and "this value came from the SOP" is the part people need to see. */ .field-hint.sop-hint { display: none; } .sop-chip { display:inline-block; margin-left:6px; padding:0 6px; border-radius:9px; - background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb; + background:var(--accent-dim); color:var(--accent); border:1px solid var(--wp-accent-border-a); font-size:9.5px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; vertical-align:middle; cursor:help; position:relative; } .sop-chip::after { content:attr(data-tip); position:absolute; bottom:135%; left:50%; - transform:translateX(-50%); background:#161616; color:#fff; padding:7px 10px; font-size:12px; + transform:translateX(-50%); background:var(--cds-ui-05); color:var(--cds-text-on-color); padding:7px 10px; font-size:12px; font-weight:400; letter-spacing:0; text-transform:none; line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999; opacity:0; - pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); } + pointer-events:none; transition:opacity .12s; box-shadow:var(--wp-shadow-tooltip); } .sop-chip::before { content:''; position:absolute; bottom:135%; left:50%; - transform:translate(-50%,95%); border:5px solid transparent; border-top-color:#161616; + transform:translate(-50%,95%); border:5px solid transparent; border-top-color:var(--cds-ui-05); opacity:0; transition:opacity .12s; z-index:9999; } .sop-chip:hover::after, .sop-chip:hover::before, .sop-chip:focus::after, .sop-chip:focus::before { opacity:1; } @@ -621,7 +884,7 @@ .pp-chip { display:inline-flex; align-items:center; gap:5px; padding:2px 6px 2px 8px; background:var(--surface2); border:1px solid var(--border); border-radius:12px; font-size:12px; max-width:100%; } - .pp-chip.pp-locked { background:var(--accent-dim); border-color:#b9d2fb; color:var(--accent); } + .pp-chip.pp-locked { background:var(--accent-dim); border-color:var(--wp-accent-border-a); color:var(--accent); } .pp-chip .pp-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .pp-chip .pp-x { background:none; border:0; cursor:pointer; color:var(--text-muted); font-size:12px; line-height:1; padding:0 1px; } @@ -632,7 +895,7 @@ .pp-add-btn:hover { border-color:var(--accent); color:var(--accent); } .pp-menu { position:absolute; top:calc(100% + 4px); left:0; z-index:60; min-width:270px; max-height:300px; overflow-y:auto; background:var(--surface); border:1px solid var(--border-strong); - box-shadow:0 8px 24px rgba(20,30,50,.18); border-radius:4px; padding:6px 0; } + box-shadow:var(--wp-shadow-menu); border-radius:4px; padding:6px 0; } .pp-menu[hidden] { display:none; } .pp-group { font-size:9.5px; font-weight:700; letter-spacing:.07em; text-transform:uppercase; color:var(--text-dim); padding:7px 10px 3px; } @@ -645,10 +908,42 @@ border:1px solid var(--border); border-radius:3px; } .pp-free .field-hint { margin-top:4px; } + /* ── asset picker (Micron asset catalog) ──────────────────────────────────── + A search box over a read-only catalog. Results drop below the input and are + added to the table as rows; the catalog itself is never written to. */ + .asset-pick { position:relative; margin-bottom:10px; } + .asset-search { width:100%; padding:8px 10px; font:inherit; font-size:13px; + border:1px solid var(--border-strong); border-radius:4px; background:var(--surface); + box-sizing:border-box; } + .asset-search:focus { outline:2px solid var(--accent); outline-offset:-2px; } + .asset-search:disabled { background:var(--surface2); color:var(--text-dim); cursor:not-allowed; } + .asset-results { position:absolute; top:calc(100% + 4px); left:0; right:0; z-index:60; + max-height:320px; overflow-y:auto; background:var(--surface); + border:1px solid var(--border-strong); border-radius:4px; padding:4px 0; + box-shadow:0 8px 24px rgba(20,30,50,.18); } + .asset-results[hidden] { display:none; } + .asset-result { display:flex; align-items:baseline; justify-content:space-between; gap:10px; + width:100%; text-align:left; background:none; border:0; + font:inherit; font-size:13px; padding:7px 12px; cursor:pointer; color:var(--text); } + .asset-result:hover:not(:disabled) { background:var(--surface2); } + .asset-result:disabled { cursor:default; opacity:.55; } + .asset-result-tag { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .asset-result-add { color:var(--accent); font-size:11.5px; font-weight:700; white-space:nowrap; } + .asset-result.is-added .asset-result-add { color:var(--text-dim); font-weight:400; } + .asset-result-note { padding:9px 12px; font-size:12.5px; color:var(--text-muted); } + /* Marks rows the catalog vouches for, so a manually typed asset is never + mistaken for a looked-up one. */ + .asset-badge { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; + font-size:10px; font-weight:700; letter-spacing:.02em; text-transform:uppercase; + color:var(--accent); background:var(--accent-dim); vertical-align:middle; + white-space:nowrap; } /* two words now — must not wrap under the asset ID */ + .asset-tag { font-weight:600; } + .asset-empty { color:var(--text-dim); font-size:12.5px; font-style:italic; } + /* Critical constraint marker (from the SOP) */ .crit-tag { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; font-size:10px; font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim); - border:1px solid #ffc4c4; white-space:nowrap; vertical-align:middle; } + border:1px solid var(--wp-status-error-border-c); white-space:nowrap; vertical-align:middle; } /* -- WORK PACKAGE NAVIGATOR ------------------------------------------------ A persistent side panel in the spirit of MS Planner: collapse toggle, one @@ -665,7 +960,7 @@ z-index: 120; display: flex; flex-direction: column; - background: #fbfbfc; + background: var(--wp-nav-bg); border-right: 1px solid var(--border); overflow: hidden; transition: width .16s ease; @@ -673,6 +968,18 @@ body { --nav-w: 288px; } body.wp-nav-collapsed { --nav-w: 56px; } + /* A6: the triage line and the inline hold reason. These WRAP - an ellipsis + here would hide exactly the data the row exists to show. The hold reason is + clamped at three lines so one essay of a reason cannot swallow the panel. */ + .wp-nav-item { align-items: flex-start; } + .wp-nav-item .wp-nav-badge { margin-top: 2px; } + .wp-nav-triage { display: block; font-size: 10.5px; line-height: 1.5; + color: var(--text-muted); white-space: normal; margin-top: 1px; } + .wp-nav-hold { display: -webkit-box; -webkit-line-clamp: 3; + -webkit-box-orient: vertical; overflow: hidden; + font-size: 10.5px; line-height: 1.45; color: var(--red); + white-space: normal; margin-top: 2px; } + /* -- collapse toggle -- */ .wp-nav-top { display: flex; align-items: center; padding: 8px 10px 2px; } .wp-nav-toggle { @@ -681,7 +988,7 @@ background: transparent; border: 1px solid transparent; border-radius: 5px; color: var(--text-muted); cursor: pointer; } - .wp-nav-toggle:hover { background: #eef0f3; color: var(--text); } + .wp-nav-toggle:hover { background: var(--wp-nav-hover); color: var(--text); } /* Arrow flips to point right when the panel is closed. */ body.wp-nav-collapsed .wp-nav-toggle-arrow { transform: rotate(180deg); transform-origin: 11px 10px; } @@ -691,23 +998,23 @@ flex: 1 1 auto; min-width: 0; display: inline-flex; align-items: center; justify-content: flex-start; gap: 9px; height: 40px; padding: 0 14px; - background: var(--accent); color: #fff; + background: var(--wp-btn-primary-bg); color: var(--wp-btn-primary-fg); border: 0; border-radius: 6px 0 0 6px; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; white-space: nowrap; } - .wp-nav-cta:hover { background: #0353e9; } + .wp-nav-cta:hover { background: var(--wp-btn-primary-hover); } .wp-nav-cta-plus { font-size: 17px; font-weight: 400; line-height: 1; } .wp-nav-cta-more { flex: 0 0 auto; width: 30px; height: 40px; - background: var(--accent); color: #fff; border: 0; border-left: 1px solid rgba(255,255,255,.28); + background: var(--wp-btn-primary-bg); color: var(--wp-btn-primary-fg); border: 0; border-left: 1px solid var(--wp-on-accent-divider); border-radius: 0 6px 6px 0; font: inherit; font-size: 12px; cursor: pointer; } - .wp-nav-cta-more:hover { background: #0353e9; } + .wp-nav-cta-more:hover { background: var(--wp-btn-primary-hover); } .wp-nav-menu { position: absolute; top: calc(100% - 6px); left: 10px; right: 10px; z-index: 10; background: var(--surface); border: 1px solid var(--border-strong); border-radius: 6px; - box-shadow: 0 10px 26px rgba(20,30,50,.18); padding: 5px 0; + box-shadow: var(--wp-shadow-menu-lg); padding: 5px 0; } .wp-nav-menu[hidden] { display: none; } .wp-nav-menu button { @@ -725,8 +1032,8 @@ font: inherit; font-size: 14px; color: var(--text); cursor: pointer; text-align: left; white-space: nowrap; } - .wp-nav-link:hover { background: #eef0f3; } - .wp-nav-link.is-current { background: #e8eaed; font-weight: 600; } + .wp-nav-link:hover { background: var(--wp-nav-hover); } + .wp-nav-link.is-current { background: var(--wp-nav-active); font-weight: 600; } .wp-nav-ico { flex: 0 0 20px; width: 20px; text-align: center; font-size: 15px; color: var(--text-muted); } .wp-nav-link-label { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; } .wp-nav-link-n { flex: 0 0 auto; font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; } @@ -743,7 +1050,9 @@ width: 100%; padding: 7px 10px; font: inherit; font-size: 13px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); } - .wp-nav-search:focus { outline: none; border-color: var(--accent); } + /* S12: keeps the border cue, drops the bare `outline: none`; the theme's ring + applies on keyboard focus. */ + .wp-nav-search:focus { border-color: var(--accent); } /* -- package rows -- */ .wp-nav-list { flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; padding: 0 8px 14px; } @@ -758,8 +1067,8 @@ background: none; border: 0; border-radius: 6px; font: inherit; color: var(--text); text-align: left; cursor: pointer; } - .wp-nav-item:hover { background: #eef0f3; } - .wp-nav-item.active { background: #e8eaed; } + .wp-nav-item:hover { background: var(--wp-nav-hover); } + .wp-nav-item.active { background: var(--wp-nav-active); } /* Left accent bar on the current package, like Planner's selected plan. */ .wp-nav-item.active::before { content: ''; position: absolute; left: 0; top: 6px; bottom: 6px; @@ -769,7 +1078,7 @@ flex: 0 0 28px; width: 28px; height: 28px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; font-family: var(--sans); font-size: 11px; font-weight: 700; letter-spacing: .02em; - color: #fff; text-transform: uppercase; + color: var(--cds-text-on-color); text-transform: uppercase; } .wp-nav-body { min-width: 0; flex: 1 1 auto; } .wp-nav-num { display: block; font-size: 13.5px; font-weight: 600; color: var(--text); @@ -803,18 +1112,15 @@ /* Narrow screens: keep the rail collapsed-width so the form still has room. */ @media (max-width: 860px) { body { --nav-w: 56px; } - body:not(.wp-nav-collapsed) .wp-nav { width: 288px; box-shadow: 6px 0 22px rgba(20,30,50,.16); } + body:not(.wp-nav-collapsed) .wp-nav { width: 288px; box-shadow: var(--wp-shadow-rail); } } /* Sticky save bar */ .sticky-save{ position:fixed; left:var(--nav-w,288px); right:0; bottom:0; z-index:40; display:flex; align-items:center; - justify-content:space-between; gap:14px; padding:10px 20px; background:#fff; - border-top:1px solid var(--border-strong); box-shadow:0 -2px 10px rgba(20,30,50,.08); } + justify-content:space-between; gap:14px; padding:10px 20px; background:var(--surface); + border-top:1px solid var(--border-strong); box-shadow:var(--wp-shadow-sticky); } .sticky-save .sticky-status{ font-size:13px; font-weight:600; } .sticky-save .sticky-actions{ display:flex; gap:10px; } - .ss-ready{ color:var(--accent-green); } - .ss-notready{ color:var(--accent-amber); } - .ss-hold{ color:var(--red); } body.has-sticky-save .main{ padding-bottom:74px; } /* Disciplines + per-discipline scope */ @@ -824,7 +1130,7 @@ .disc-pill:hover { border-color:var(--accent); color:var(--text); } .disc-pill input { display:none; } .disc-pill .dot { width:7px; height:7px; border-radius:50%; background:var(--border-strong); transition:background .12s; flex-shrink:0; } - .disc-pill.selected { border-color:var(--accent); background:var(--accent-dim,#eef3fd); color:var(--accent); } + .disc-pill.selected { border-color:var(--accent); background:var(--accent-dim); color:var(--accent); } .disc-pill.selected .dot { background:var(--accent); } .disc-scope { border:1px solid var(--border); border-left:3px solid var(--accent); border-radius:6px; padding:12px 14px; margin-bottom:12px; background:var(--bg); } .disc-scope-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; flex-wrap:wrap; } @@ -839,11 +1145,12 @@ .dash-metric .dm-label { font-size:11px; color:var(--text-muted); margin-top:6px; text-transform:uppercase; letter-spacing:.03em; } .dash-metric.dm-green .dm-val { color:var(--accent-green); } .dash-metric.dm-red .dm-val { color:var(--red); } - .dash-metric.dm-blue .dm-val { color:var(--accent, #0f62fe); } + .dash-metric.dm-blue .dm-val { color:var(--accent); } .dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; } .dash-metric[onclick]:hover { border-color:var(--accent); } .dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); } - .dash-chip[onclick] { cursor:pointer; } + /* Chips are buttons since T9.5; reset the button chrome, keep the chip look. */ + button.dash-chip { font:inherit; font-size:12px; cursor:pointer; } .dash-chip.chip-active { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); } .dash-breakdown { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; } .dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; } @@ -854,6 +1161,56 @@ .dash-table { width:100%; border-collapse:collapse; font-size:12.5px; } .dash-table th { text-align:left; background:var(--surface2); border-bottom:1px solid var(--border); padding:6px 8px; font-size:11px; text-transform:uppercase; color:var(--text-muted); } .dash-table td { border-bottom:1px solid var(--border); padding:6px 8px; vertical-align:top; } + /* CR-001 / T6.1: a sortable header is a real ' + - '' + + '
    ' + + '' + + '' + '
    ' + '
    '; function close() { var m = document.getElementById('wp-prefs-modal'); if (m) m.remove(); } function msg(text, ok) { var e = document.getElementById('wp-prefs-msg'); e.style.display = 'block'; e.textContent = text; - e.style.background = ok ? '#defbe6' : '#fff1f1'; - e.style.color = ok ? '#0e6027' : '#da1e28'; + e.style.background = ok ? 'var(--wp-status-success-bg)' : 'var(--wp-status-error-bg)'; + e.style.color = ok ? 'var(--wp-status-success-text)' : 'var(--cds-support-error)'; } ov.addEventListener('click', function (e) { if (e.target === ov) close(); }); document.body.appendChild(ov); diff --git a/html/wp-list-import.js b/html/wp-list-import.js new file mode 100644 index 0000000..412e25e --- /dev/null +++ b/html/wp-list-import.js @@ -0,0 +1,210 @@ +/* The project-list import component — T5.4's machinery, extracted (D6 / T8.6). + + One implementation of paste-or-file → server-side import with dry-run → + a report that names every rejected row with its SOURCE line number → an + editable list whose entries deactivate rather than delete. The location list + (CR-005) and the material list (D6) are both instances of this; building the + material path "the same way and against the same component, not beside it" + is the T8.6 instruction, and extracting the component is what makes that + literally true rather than a copy with the names changed. + + The page supplies what differs: the API base, the sample, how a row renders, + and what the add-row collects. Everything generic — the file reader feeding + the paste box (one parser, on the server), the dry-run wiring, the report + roles (a report that lost rows interrupts; a clean one does not, per T4.5) — + lives here once. + + Classic script, no modules: exposes window.WPListImport. */ +'use strict'; + +(function () { + function el(id) { return document.getElementById(id); } + + window.WPListImport = function (cfg) { + // cfg.prefix DOM id prefix: '

    -paste', '

    -file', '

    -file-btn', + // '

    -check-btn', '

    -import-btn', '

    -sample-btn', + // '

    -report', '

    -add-name', '

    -add-btn', '

    -add-err', + // '

    -tool', '

    -noproject' + // cfg.api(suffix) URL builder for the project-scoped routes + // cfg.projectId() current project id ('' = not opened from a project) + // cfg.sample text the sample button loads + // cfg.loadKey response key holding the rows ('nodes' | 'items') + // cfg.render() paints the current list from state.rows + // cfg.rejectedRow(r)

  • HTML for one rejected row + // cfg.duplicateRow(r)
  • HTML for one duplicate row + // cfg.addPayload() reads the add-row; {payload} to POST or {error} + // cfg.addedMessage(body) confirmation HTML after a successful add + // cfg.noProjectMessage what to say when there is no project + // cfg.esc the page's escaper + var p = cfg.prefix; + var esc = cfg.esc; + var state = { rows: [], loaded: false }; + + // Every message 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 say(html, isProblem) { + var box = el(p + '-report'); + if (!box) return; + box.setAttribute('role', isProblem ? 'alert' : 'status'); + box.innerHTML = html || ''; + box.classList.toggle('is-problem', !!isProblem); + } + + function setAddError(msg) { + var box = el(p + '-add-err'); + if (box) box.textContent = msg || ''; + var input = el(p + '-add-name'); + if (input) { + if (msg) input.setAttribute('aria-invalid', 'true'); + else input.removeAttribute('aria-invalid'); + } + } + + function load(force) { + var tool = el(p + '-tool'); + var warn = el(p + '-noproject'); + var pid = cfg.projectId(); + if (!pid) { + if (tool) tool.style.display = 'none'; + if (warn) { warn.style.display = ''; warn.textContent = cfg.noProjectMessage; } + return Promise.resolve(); + } + if (tool) tool.style.display = ''; + if (warn) warn.style.display = 'none'; + if (state.loaded && !force) return Promise.resolve(); + return fetch(cfg.api('?include_inactive=true'), { headers: { 'Accept': 'application/json' } }) + .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) + .then(function (data) { state.rows = data[cfg.loadKey] || []; state.loaded = true; cfg.render(); }) + .catch(function (err) { + state.loaded = false; + cfg.render(); + say('⚠ Could not load the list — ' + esc((err && err.message) || 'offline') + + '. It is stored on the server, so nothing local is shown in its place.', true); + }); + } + + function report(res) { + var bits = []; + var problem = (res.rejected || []).length > 0 || (res.duplicates || []).length > 0; + var verb = res.dry_run ? 'would be added' : 'added'; + var nCreated = (res.created || []).length; + bits.push('

    ' + res.read + ' row' + (res.read === 1 ? '' : 's') + + ' read. ' + nCreated + ' value' + (nCreated === 1 ? '' : 's') + ' ' + verb + + ((res.reactivated || []).length ? ', ' + res.reactivated.length + ' brought back into use' : '') + + '.

    '); + var rowList = function (title, rows, fmt) { + if (!rows || !rows.length) return ''; + return '
    ' + esc(title) + + ' (' + rows.length + ')
      ' + rows.map(fmt).join('') + '
    '; + }; + bits.push(rowList('Rejected', res.rejected, cfg.rejectedRow)); + bits.push(rowList('Duplicates, not merged', res.duplicates, cfg.duplicateRow)); + if (!problem && !nCreated && !(res.reactivated || []).length) { + bits.push('

    Nothing to do — every row is already on this project.

    '); + } + say(bits.join(''), problem); + } + + function importText(dryRun) { + var text = (el(p + '-paste') || {}).value || ''; + if (!text.trim()) { say('Paste some rows or choose a CSV file first.', true); return; } + if (!cfg.projectId()) { load(); return; } + say('Checking…', false); + fetch(cfg.api('/import'), { + method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ text: text, dry_run: !!dryRun }), + }) + .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); }) + .then(function (res) { + if (!res.ok) { + say('⚠ Import refused — ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true); + return; + } + report(res.body); + if (!dryRun) return load(true); + }) + .catch(function (err) { + say('⚠ Could not reach the server — ' + esc((err && err.message) || 'offline') + + '. Nothing was imported.', true); + }); + } + + function add() { + var read = cfg.addPayload(); + if (read.error) { + setAddError(read.error); + var input = el(p + '-add-name'); + if (input) input.focus(); + return; + } + setAddError(''); + fetch(cfg.api(''), { + method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(read.payload), + }) + .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); }) + .then(function (res) { + if (!res.ok) { + setAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')')); + return; + } + var input = el(p + '-add-name'); + if (input) input.value = ''; + say(cfg.addedMessage(res.body), false); + return load(true); + }) + .catch(function (err) { setAddError('Could not reach the server — ' + ((err && err.message) || 'offline')); }); + } + + function patch(id, patchBody, describe) { + return fetch(cfg.api('/' + encodeURIComponent(id)), { + method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(patchBody), + }) + .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); }) + .then(function (res) { + if (!res.ok) { + say('⚠ ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true); + return load(true); + } + say(describe(res.body), false); + return load(true); + }) + .catch(function (err) { + say('⚠ Could not reach the server — ' + esc((err && err.message) || 'offline'), true); + }); + } + + function wire() { + var paste = el(p + '-paste'); + var file = el(p + '-file'); + var btn = function (suffix) { return el(p + suffix); }; + if (btn('-file-btn')) btn('-file-btn').addEventListener('click', function () { if (file) file.click(); }); + if (file) file.addEventListener('change', function (ev) { + var f = ev.target.files && ev.target.files[0]; + if (!f) return; + var reader = new FileReader(); + reader.onload = function () { + // 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 || ''); + say('Read ' + esc(f.name) + '. Check it, then import.', false); + }; + reader.onerror = function () { say('⚠ Could not read that file.', true); }; + reader.readAsText(f); + ev.target.value = ''; + }); + if (btn('-check-btn')) btn('-check-btn').addEventListener('click', function () { importText(true); }); + if (btn('-import-btn')) btn('-import-btn').addEventListener('click', function () { importText(false); }); + if (btn('-sample-btn')) btn('-sample-btn').addEventListener('click', function () { + if (paste) paste.value = cfg.sample; + say('Sample values loaded into the box — obviously fake, and safe to import ' + + 'on a throwaway project.', false); + }); + if (btn('-add-btn')) btn('-add-btn').addEventListener('click', add); + } + + return { state: state, say: say, setAddError: setAddError, load: load, + importText: importText, add: add, patch: patch, wire: wire }; + }; +})(); diff --git a/html/wp-sections.js b/html/wp-sections.js new file mode 100644 index 0000000..ed56a7b --- /dev/null +++ b/html/wp-sections.js @@ -0,0 +1,157 @@ +/* Work package section toggles — CR-006 / T5.5. + --------------------------------------------------------------------------- + The structural fix behind most of the removal requests in the plan. Rather + than deleting fields globally, each project turns on only the sections it + uses: it is what lets Micron drop Kitting and Assets while another project + keeps them, and it is why CR-002 and CR-016 are toggles rather than deletions. + + THE ONE RULE: toggling a section OFF never deletes anything. It stops the + section rendering — in the creation form, in the detail view and in the PDF + export — and that is all. Whatever was captured stays on the package, and + toggling back on shows it again, intact. Everything in this file is about + what is DISPLAYED; nothing here writes to a package. + + This list lives in its own file because three surfaces read it and they must + not drift: the SOP wizard renders the toggles, the creator applies them to + its form and its printed output, and a work package's detail view honours + them. A fourth copy is how "Assets is off" and "Assets is off, except in the + export" happen. + + IDS ARE PERMANENT. They are written into every SOP that has ever been saved, + so renaming one silently turns that section back on for every existing + project. Change `label` freely; never change `id`. +*/ +(function (window) { + 'use strict'; + + var LIST = [ + { id: 'general', label: 'General Information', + note: 'Holds the WP number, subject and type. Turning this off leaves nothing to identify a package by — it is listed for completeness, not as a suggestion.' }, + { id: 'location', label: 'Location', + note: 'Where the work happens. CR-004 gives this its own structured fields; today it is the location field inside General Information.' }, + { id: 'scope', label: 'Scope of Work', + note: 'The ordered steps the crew performs, and the labour estimate.' }, + { id: 'assets', label: 'Assets', + note: 'Asset IDs picked read-only from the Micron DB (D11), with manual entry for anything not listed. Off for Micron EUV — the customer’s own database stays the source of truth; this section only references it (CR-016).' }, + { id: 'materials', label: 'Materials', + note: 'The bill of materials that feeds kitting.' }, + { id: 'kitting', label: 'Kitting', + note: 'Kitting status, warehouse owner and MIMO. Off for Micron EUV, which is not kitting today (CR-009).' }, + { id: 'drawings', label: 'Drawings and Attachments', + note: 'Drawing references and attachment links.' }, + { id: 'constraints', label: 'Constraints', + note: 'Release-readiness items. A package cannot be issued while one is open.' }, + { id: 'qaqc', label: 'QA/QC', + note: 'Quality requirements, photo standard and hold points.' }, + { id: 'closeout', label: 'Closeout', + note: 'Actual hours, installed quantity, redlines and lessons learned. Actual Hours stays here — its removal was proposed and rejected (CR-017).' }, + ]; + + var IDS = LIST.map(function (s) { return s.id; }); + + /* Individual fields that can be switched off inside a section — CR-002. + + A second, narrower list rather than more sections, because a section is a + block of the document and these are two rows inside one. BL-000b asks + whether General Information wants per-field toggles generally; this is not + that. It is the two fields CR-002 names, expressed as toggles because + CLAUDE.md says removals are expressed through toggles and the data is + retained — the columns and the model stay exactly as they are. + + Same rule as sections: an id is permanent, absent means ON. */ + var FIELDS = [ + { id: 'costCode', section: 'general', label: 'Acumatica cost code', + note: 'Effectively constant on a job, so it is noise on a field work package (CR-002). The value stays on every package that has one.' }, + { id: 'acumaticaTask', section: 'general', label: 'Acumatica task', + note: 'A PM concern rather than a field one (CR-002). The cost visibility the team actually wants is by building and floor — CR-004 and CR-018.' }, + ]; + + var FIELD_IDS = FIELDS.map(function (f) { return f.id; }); + + function fieldDefaults() { + var out = {}; + FIELD_IDS.forEach(function (id) { out[id] = true; }); + return out; + } + + function normalizeFields(stored) { + var out = fieldDefaults(); + if (stored && typeof stored === 'object') { + FIELD_IDS.forEach(function (id) { + if (Object.prototype.hasOwnProperty.call(stored, id)) out[id] = stored[id] !== false; + }); + } + return out; + } + + function fieldsFor(sectionId) { + return FIELDS.filter(function (f) { return f.section === sectionId; }); + } + + function defaults() { + // A new SOP has everything on. A project opts OUT of what it does not use; + // it does not have to discover and opt in to what it does. + var out = {}; + IDS.forEach(function (id) { out[id] = true; }); + return out; + } + + /* Fill in anything a stored SOP does not mention. + + This is what makes adding an eleventh section safe: every SOP saved before + it existed says nothing about it, and "says nothing" has to mean ON. The + alternative — absent meaning off — would switch a brand-new section off for + every project in the estate the moment it shipped. */ + function normalize(stored) { + var out = defaults(); + if (stored && typeof stored === 'object') { + IDS.forEach(function (id) { + if (Object.prototype.hasOwnProperty.call(stored, id)) out[id] = stored[id] !== false; + }); + } + return out; + } + + function isOn(stored, id) { + if (IDS.indexOf(id) < 0) return true; // not a section we govern + return normalize(stored)[id]; + } + + function offList(stored) { + var s = normalize(stored); + return LIST.filter(function (x) { return !s[x.id]; }).map(function (x) { return x.label; }); + } + + /* A field is on only if its own toggle is on AND the section holding it is. + Asked as one question so no caller has to remember to ask both — a field + showing inside a hidden section is not a state anyone wants to reason + about. */ + function fieldOn(sections, fields, id) { + var f = FIELDS.filter(function (x) { return x.id === id; })[0]; + if (!f) return true; + if (!isOn(sections, f.section)) return false; + return normalizeFields(fields)[id]; + } + + function offFieldList(fields) { + var s = normalizeFields(fields); + return FIELDS.filter(function (x) { return !s[x.id]; }).map(function (x) { return x.label; }); + } + + window.WPSections = { + LIST: LIST, + IDS: IDS, + defaults: defaults, + normalize: normalize, + isOn: isOn, + offList: offList, + + FIELDS: FIELDS, + FIELD_IDS: FIELD_IDS, + fieldDefaults: fieldDefaults, + normalizeFields: normalizeFields, + fieldsFor: fieldsFor, + fieldOn: fieldOn, + offFieldList: offFieldList, + }; +})(window); diff --git a/html/wp-sidenav.css b/html/wp-sidenav.css new file mode 100644 index 0000000..2b0304a --- /dev/null +++ b/html/wp-sidenav.css @@ -0,0 +1,115 @@ +/* Global app navigation drawer (see wp-sidenav.js). + + An off-canvas panel rather than a pinned rail, at every width: the field view is a + centred 760px column read on a phone or a tablet in a glove, and a permanent + sidebar would either squeeze that column or hide on the one device that matters. + Overlay behaves identically everywhere, which is also one less layout to test. + + Colours come from the dark app bar it hangs off, so the drawer reads as an + extension of the bar. Until T3.2 that intent was written as hardcoded hex — + 30 of them — which made the drawer only accidentally match the bar. It now + reads the same --wp-appbar-* tokens the bar does, so the stated intent is + actually true and flipping --wp-appbar-bg takes the drawer with it. */ + +.wp-navbtn{ + flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; + width: 40px; height: 40px; margin-right: 4px; padding: 0; + background: none; border: none; border-radius: 0; cursor: pointer; + color: var(--cds-text-inverse); font-family: inherit; line-height: 1; +} +.wp-navbtn:hover{ background: var(--wp-appbar-hover); } +.wp-navbtn:focus-visible{ outline: 2px solid var(--wp-appbar-fg); outline-offset: -2px; } +/* A light bar (the SOP suite / creator headers) needs the opposite ink. */ +.wp-navbtn[data-bar="light"]{ color: var(--cds-text-primary); } +.wp-navbtn[data-bar="light"]:hover{ background: var(--cds-layer-hover); } +/* ...and the opposite ring. S12: the rule above is white, which is correct on the + near-black bar and invisible on the creator's white header — the same button, + the same class, two hosts. Measured 1.00:1 before this line existed. */ +.wp-navbtn[data-bar="light"]:focus-visible{ outline-color: var(--cds-focus); } + +.wp-navscrim{ + position: fixed; inset: 0; z-index: 10010; + background: var(--wp-scrim-drawer); + opacity: 0; transition: opacity .18s ease; +} +.wp-navscrim.is-open{ opacity: 1; } +.wp-navscrim[hidden]{ display: none; } + +.wp-sidenav{ + position: fixed; top: 0; left: 0; bottom: 0; z-index: 10011; + width: min(284px, 84vw); + display: flex; flex-direction: column; + background: var(--wp-appbar-bg); color: var(--cds-text-inverse); + font-family: var(--wp-font-sans-2); + transform: translateX(-100%); transition: transform .2s ease; + box-shadow: var(--wp-shadow-drawer); + overflow: hidden; +} +.wp-sidenav.is-open{ transform: translateX(0); } +/* Respect a reduced-motion preference: the drawer still opens, it just doesn't slide. */ +@media (prefers-reduced-motion: reduce){ + .wp-sidenav, .wp-navscrim{ transition: none; } +} + +.wp-sidenav-head{ + display: flex; align-items: center; gap: 10px; + padding: 12px 14px; border-bottom: 1px solid var(--cds-inverse-02); flex: 0 0 auto; +} +.wp-sidenav-head .wp-logo-chip{ flex: 0 0 auto; } +.wp-sidenav-title{ font-size: 13px; font-weight: 600; line-height: 1.25; } +.wp-sidenav-title span{ display: block; font-size: 11px; font-weight: 400; color: var(--cds-text-placeholder); } + +/* The active project, in full (B2). Below 1024px the app bar shows the project + number alone, so this is where the whole name has to be readable — it wraps on + as many lines as it needs and is never truncated. */ +.wp-sidenav-proj{ + padding: 12px 14px; border-bottom: 1px solid var(--cds-inverse-02); flex: 0 0 auto; + display: flex; flex-direction: column; gap: 2px; +} +.wp-sidenav-proj[hidden]{ display: none; } +.wp-sidenav-proj-k{ + font-size: 10px; font-weight: 600; letter-spacing: .08em; + text-transform: uppercase; color: var(--cds-text-placeholder); +} +.wp-sidenav-proj strong{ + font-size: 13px; font-weight: 600; line-height: 1.3; color: var(--cds-text-inverse); + overflow-wrap: anywhere; +} +.wp-sidenav-proj-n{ font-size: 11px; color: var(--wp-appbar-fg-dim); } +.wp-sidenav-close{ + margin-left: auto; width: 32px; height: 32px; padding: 0; flex: 0 0 auto; + background: none; border: none; border-radius: 0; color: var(--wp-appbar-fg-dim); + font-size: 18px; line-height: 1; cursor: pointer; font-family: inherit; +} +.wp-sidenav-close:hover{ background: var(--wp-appbar-hover); color: var(--wp-appbar-fg); } + +.wp-sidenav-body{ flex: 1 1 auto; overflow-y: auto; padding: 6px 0 18px; } +.wp-sidenav-sect{ + padding: 14px 16px 4px; font-size: 11px; font-weight: 600; + letter-spacing: .06em; text-transform: uppercase; color: var(--cds-ui-04); +} +.wp-sidenav-link{ + display: flex; align-items: center; gap: 12px; width: 100%; + /* 44px minimum: this is tapped with a work glove on. */ + min-height: 44px; padding: 10px 16px; + background: none; border: none; border-left: 3px solid transparent; border-radius: 0; + color: var(--cds-text-inverse); font: inherit; font-size: 14px; text-align: left; text-decoration: none; + cursor: pointer; +} +.wp-sidenav-link:hover{ background: var(--wp-appbar-hover); } +.wp-sidenav-link:focus-visible{ outline: 2px solid var(--wp-appbar-fg); outline-offset: -2px; } +.wp-sidenav-link.is-current{ background: var(--wp-appbar-layer); border-left-color: var(--cds-interactive-01); font-weight: 600; } +.wp-sidenav-ico{ + flex: 0 0 20px; width: 20px; text-align: center; font-size: 15px; color: var(--wp-appbar-fg-dim); +} +.wp-sidenav-link.is-current .wp-sidenav-ico{ color: var(--cds-link-inverse); } +.wp-sidenav-label{ flex: 1 1 auto; min-width: 0; } +.wp-sidenav-label small{ display: block; font-size: 11.5px; font-weight: 400; color: var(--cds-text-placeholder); } + +.wp-sidenav-foot{ + flex: 0 0 auto; border-top: 1px solid var(--cds-inverse-02); padding: 8px 0; +} +.wp-sidenav-who{ + padding: 6px 16px 8px; font-size: 12px; color: var(--cds-text-placeholder); +} +.wp-sidenav-who strong{ display: block; color: var(--cds-text-inverse); font-size: 13px; font-weight: 600; } diff --git a/html/wp-sidenav.js b/html/wp-sidenav.js new file mode 100644 index 0000000..3ca490e --- /dev/null +++ b/html/wp-sidenav.js @@ -0,0 +1,278 @@ +/* Global app navigation drawer for the Work Package Suite. + + The suite grew page by page and the only way between them was the browser's back + button or the home page. This is the one place that lists everywhere you can go — + a ☰ button in the app bar opening an off-canvas drawer. + + ROLE GATING: the drawer only offers what the signed-in account can actually reach. + The Admin Console is admins-only, so it appears for admins only; the User Directory + is readable by everyone (that's the point of a directory), so it always appears. + Every destination re-checks server-side — this is navigation, not a permission. + + PROJECT CONTEXT: links that open a project-scoped page carry the active ?project= + so the drawer doesn't silently drop the job you were looking at. + + Add it to a page with: + + + after auth-guard.js. It mounts itself into whichever top bar the page has. + It used to skip iframes, because the embedded WP creator lived inside a page + that already had a drawer; B7/T7.1 dissolved that frame. */ +(function () { + 'use strict'; + + // ── the map ──────────────────────────────────────────────────────────────── + // `match` is what marks a link current; `project` means "carry ?project=". + // `show` is an optional gate, evaluated once the user is known. + var LINKS = [ + { section: 'Work' }, + { href: 'index.html', match: /(^|\/)(index\.html)?$/, icon: '⌂', label: 'Home', + sub: 'Projects & what\'s next' }, + { href: 'work-package-suite.html?tab=sop', match: /work-package-suite\.html/, icon: '⚙', + label: 'SOP Configuration', sub: 'The project baseline', project: true, tab: 'sop' }, + // B7/T7.1: both of these were tabs of the suite page, opened by swapping an + // iframe, so `match` had to be null - one URL could not tell them apart. The + // creator is its own document now, so they have real addresses and the drawer + // can mark which one you are on. + { href: 'wp-creation-index.html', match: /wp-creation-index\.html/, icon: '▤', + label: 'Work Package Creator', sub: 'Build and edit IWPs', project: true, tab: 'wp' }, + { href: 'wp-creation-index.html?view=dashboard', match: null, icon: '▦', + label: 'Dashboard', sub: 'Status & release gates', project: true, tab: 'dashboard' }, + { href: 'field.html', match: /(^|\/)field\.html$/, icon: '⚒', label: 'Field View', + sub: 'Update packages on site', project: true }, + { section: 'People' }, + { href: 'users.html', match: /(^|\/)users\.html$/, icon: '☺', label: 'User Directory', + sub: 'Who\'s on the project' }, + { href: 'admin.html', match: /(^|\/)admin\.html$/, icon: '⚙', label: 'Admin Console', + sub: 'Settings & diagnostics', + show: function () { return typeof window.wpIsAdmin === 'function' && window.wpIsAdmin(); } }, + // Account actions, inherited from the flat user menu that used to sit in the app + // bar (T2.2). Everything else that menu offered — Admin, Users, Sign out — the + // drawer already had; these two were its only unique contents, so they moved here + // rather than being lost with it. `action` items render as buttons, not links. + { section: 'Account' }, + { action: 'wpPreferences', icon: '◷', label: 'Language & time', + sub: 'Dates, numbers and time zone' }, + { action: 'wpChangePassword', icon: '⚿', label: 'Password', sub: 'Change your password' }, + ]; + + function esc(v) { + return String(v == null ? '' : v) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + function isDark(node) { + try { + var m = (getComputedStyle(node).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/); + if (!m) return true; + return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140; + } catch (e) { return true; } + } + + function activeProjectId() { + try { + var q = new URLSearchParams(location.search).get('project'); + if (q) return q; + return (window.ProjectData && ProjectData.getActiveId && ProjectData.getActiveId()) || ''; + } catch (e) { return ''; } + } + + // The suite page reads ?tab= and ?project=; keeping the current project on the link + // is the difference between "open the dashboard" and "open the dashboard, then pick + // the job again". + function hrefFor(item) { + if (!item.project) return item.href; + var pid = activeProjectId(); + if (!pid) return item.href; + var sep = item.href.indexOf('?') >= 0 ? '&' : '?'; + return item.href + sep + 'project=' + encodeURIComponent(pid); + } + + // Current-page marking. The three suite tabs share one file, so they're told apart + // by ?tab= (defaulting to sop, which is what work-package-suite.html itself does). + function isCurrent(item) { + var path = location.pathname; + if (item.tab) { + if (!/work-package-suite\.html$/.test(path)) return false; + var tab = ''; + try { tab = new URLSearchParams(location.search).get('tab') || 'sop'; } catch (e) { tab = 'sop'; } + return tab === item.tab; + } + return !!(item.match && item.match.test(path)); + } + + // ── build ────────────────────────────────────────────────────────────────── + var drawer, scrim, btn, lastFocus = null; + + function buildDrawer(user) { + scrim = document.createElement('div'); + scrim.className = 'wp-navscrim'; + scrim.hidden = true; + scrim.addEventListener('click', close); + + drawer = document.createElement('nav'); + drawer.className = 'wp-sidenav'; + drawer.id = 'wp-sidenav'; + drawer.setAttribute('aria-label', 'Suite navigation'); + drawer.setAttribute('aria-hidden', 'true'); + + var rows = ''; + LINKS.forEach(function (item) { + if (item.section) { rows += '
    ' + esc(item.section) + '
    '; return; } + if (item.show && !item.show()) return; + var inner = + '' + + '' + esc(item.label) + + (item.sub ? '' + esc(item.sub) + '' : '') + ''; + // An action opens a dialog on the current page rather than going anywhere, so + // it is a button. Never a
    with a click handler — CLAUDE.md, and the + // drawer is keyboard-navigable precisely because everything in it is focusable. + if (item.action) { + rows += ''; + return; + } + rows += '' + + inner + ''; + }); + + var who = user ? (user.full_name || user.username || '') : ''; + drawer.innerHTML = + '
    ' + + 'Prime Controls' + + 'Work Package SuitePrime Controls' + + '' + + '
    ' + + // The active project in full, wrapped rather than truncated. Below 1024px the + // app bar shows the project NUMBER alone (B2), so this is where the whole name + // is always readable. It is also the only place it is guaranteed to fit. + '
    ' + + '
    ' + rows + '
    ' + + '
    ' + + (who ? '
    Signed in as' + esc(who) + '
    ' : '') + + '' + + '
    '; + + drawer.querySelector('.wp-sidenav-close').addEventListener('click', close); + drawer.querySelector('#wp-sidenav-signout').addEventListener('click', function () { + if (typeof window.wpLogout === 'function') window.wpLogout(); + }); + // Close first, then act: these open a dialog, and leaving the drawer over it + // would put a scrim between the user and the thing they just asked for. The + // handler is looked up at click time because wp-format.js may still be parsing + // when the drawer is built — the flat menu had the same note. + Array.prototype.forEach.call(drawer.querySelectorAll('[data-action]'), function (el) { + el.addEventListener('click', function () { + var fn = window[el.getAttribute('data-action')]; + close(); + if (typeof fn === 'function') fn(); + }); + }); + document.body.appendChild(scrim); + document.body.appendChild(drawer); + paintProject(); + // Selecting a project on the launcher does not reload, so subscribe rather than + // paint once — the same single source T1.1 established. + try { + if (window.ProjectData && ProjectData.onActiveChange) { + ProjectData.onActiveChange(paintProject); + } + } catch (e) {} + } + + // Full name, never abbreviated. Absent rather than empty when no project is active, + // so the drawer does not carry a stray blank band. + function paintProject() { + var box = document.getElementById('wp-sidenav-proj'); + if (!box) return; + var p = null; + try { p = (window.ProjectData && ProjectData.getActive && ProjectData.getActive()) || null; } catch (e) {} + if (!p || !(p.name || p.number)) { box.innerHTML = ''; box.hidden = true; return; } + box.hidden = false; + box.innerHTML = 'Project' + + '' + esc(p.name || '(unnamed)') + '' + + (p.number ? '' + esc(p.number) + '' : ''); + } + + function focusables() { + return drawer ? drawer.querySelectorAll('a[href], button:not([disabled])') : []; + } + + function open() { + if (!drawer) return; + lastFocus = document.activeElement; + scrim.hidden = false; + // Two frames: the element has to be laid out un-transitioned before the class + // that animates it lands, or it simply appears. + requestAnimationFrame(function () { + scrim.classList.add('is-open'); + drawer.classList.add('is-open'); + }); + drawer.setAttribute('aria-hidden', 'false'); + btn.setAttribute('aria-expanded', 'true'); + var f = focusables(); + if (f.length) f[0].focus(); + } + + function close() { + if (!drawer) return; + drawer.classList.remove('is-open'); + scrim.classList.remove('is-open'); + drawer.setAttribute('aria-hidden', 'true'); + btn.setAttribute('aria-expanded', 'false'); + // Keep the scrim in the tree until the slide-out finishes, or the panel snaps. + setTimeout(function () { if (!drawer.classList.contains('is-open')) scrim.hidden = true; }, 220); + if (lastFocus && lastFocus.focus) lastFocus.focus(); + } + + function isOpen() { return !!(drawer && drawer.classList.contains('is-open')); } + + // Escape closes; Tab cycles inside the drawer while it's open, so focus can't walk + // off into the page behind the scrim. + document.addEventListener('keydown', function (e) { + if (!isOpen()) return; + if (e.key === 'Escape') { e.preventDefault(); close(); return; } + if (e.key !== 'Tab') return; + var f = focusables(); + if (!f.length) return; + var first = f[0], last = f[f.length - 1]; + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + }); + + // ── mount ────────────────────────────────────────────────────────────────── + // The button goes at the START of the bar, before the brand: that is where a menu + // affordance is looked for, and it keeps clear of the project switcher and search + // that wp-chrome.js inserts into the middle of the same bar. + function mount() { + if (document.getElementById('wp-sidenav')) return; + var host = document.querySelector('.wp-appbar') || document.querySelector('.header'); + if (!host) return; + + btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'wp-navbtn'; + btn.id = 'wp-navbtn'; + btn.title = 'Menu'; + btn.setAttribute('aria-label', 'Open navigation'); + btn.setAttribute('aria-haspopup', 'true'); + btn.setAttribute('aria-expanded', 'false'); + btn.setAttribute('aria-controls', 'wp-sidenav'); + if (!isDark(host)) btn.setAttribute('data-bar', 'light'); + btn.innerHTML = ''; + btn.addEventListener('click', function () { if (isOpen()) close(); else open(); }); + + host.insertBefore(btn, host.firstChild); + buildDrawer(window.WP_USER); + } + + // Wait for the auth guard: the gated links depend on the signed-in role, and an + // unauthenticated page is about to redirect anyway. + if (window.WP_USER) mount(); + else document.addEventListener('wp-auth-ready', mount); +})(); diff --git a/html/wp-url.js b/html/wp-url.js new file mode 100644 index 0000000..8727e62 --- /dev/null +++ b/html/wp-url.js @@ -0,0 +1,126 @@ +/* Addressable state — S3 / T4.2. + --------------------------------------------------------------------------- + Before this file there was no pushState anywhere in the suite. Every page read + its query string once at boot and never wrote one again, so: + + • you could not send anyone a link to WP07 — the URL said the same thing + whatever you were looking at; + • a refresh dropped you back at the default view; + • Back left the app entirely, because the app had never added a history entry. + + CR-011 and CR-014 both promise an email containing a direct link to a work + package (X1). Those emails cannot exist until a work package has an address, + which is what this provides. + + WHAT IT IS NOT: a router. Nothing here intercepts navigation or renders + anything. It is the query string, treated as state that can be read, merged, + written and subscribed to. Pages keep their own rendering. + + Query parameters, not a hash: the server serves these paths already, so a hash + would be a workaround for a problem this app does not have, and hashes are not + sent to the server — which matters the day a link needs to be resolved before + the page boots. + + Nothing secret goes in the URL. It is copied into emails, chat and tickets. +*/ +(function (window, document) { + 'use strict'; + + var listeners = []; + var LAST = serialize(current()); + + function current() { + var out = {}; + try { + new URLSearchParams(window.location.search).forEach(function (v, k) { out[k] = v; }); + } catch (e) {} + return out; + } + + function serialize(state) { + var keys = Object.keys(state).filter(function (k) { + return state[k] !== '' && state[k] != null && state[k] !== false; + }).sort(); + var sp = new URLSearchParams(); + keys.forEach(function (k) { sp.set(k, String(state[k])); }); + return sp.toString(); + } + + // Merge a patch over the current state. Undefined/null/'' removes a key, so a + // caller can clear `wp` without having to know what else is in the URL — the + // usual reason ad-hoc URL building loses the active project. + function merge(patch) { + var next = current(); + Object.keys(patch || {}).forEach(function (k) { + var v = patch[k]; + if (v === undefined || v === null || v === '' || v === false) delete next[k]; + else next[k] = v; + }); + return next; + } + + function href(patch) { + var qs = serialize(merge(patch)); + return window.location.pathname + (qs ? '?' + qs : '') + window.location.hash; + } + + function apply(patch, opts) { + opts = opts || {}; + var next = merge(patch); + var qs = serialize(next); + if (qs === LAST && !opts.force) return false; // nothing to record + var url = window.location.pathname + (qs ? '?' + qs : '') + window.location.hash; + try { + if (opts.replace) window.history.replaceState({ wpurl: qs }, '', url); + else window.history.pushState({ wpurl: qs }, '', url); + } catch (e) { + return false; // file:// and the like + } + LAST = qs; + return true; + } + + function notify(state, viaPop) { + listeners.forEach(function (fn) { + try { fn(state, viaPop); } catch (e) { /* one bad subscriber must not stop the rest */ } + }); + } + + window.addEventListener('popstate', function () { + LAST = serialize(current()); + notify(current(), true); + }); + + window.WPUrl = { + // Read one parameter, or everything. + get: function (name) { var s = current(); return name == null ? s : (s[name] || ''); }, + all: current, + + /* Record a state change in history. Merges over what is already there. + WPUrl.push({ wp: id }) -> new history entry, Back returns + WPUrl.push({ wp: '' }) -> clears it + WPUrl.replace({ view: 'form' }) -> corrects the URL without a new entry + replace() is for normalising on load or for a change the user did not ask + for; push() is for one they did, because Back should undo exactly the + things they chose to do. */ + push: function (patch) { return apply(patch, { replace: false }); }, + replace: function (patch) { return apply(patch, { replace: true }); }, + + // A URL string for the same merge, without navigating. For hrefs and for the + // links that go into CR-011 / CR-014 emails. + href: href, + absolute: function (patch) { + return window.location.origin + href(patch); + }, + + /* Subscribe to state changes. Called on Back/Forward with viaPop === true. + Returns an unsubscribe function. */ + onChange: function (fn) { + listeners.push(fn); + return function () { + var i = listeners.indexOf(fn); + if (i >= 0) listeners.splice(i, 1); + }; + }, + }; +})(window, document); diff --git a/html/wp-usage.js b/html/wp-usage.js new file mode 100644 index 0000000..b4ee13a --- /dev/null +++ b/html/wp-usage.js @@ -0,0 +1,58 @@ +/* Usage analytics core — the ONE implementation (D5 / T7.10). + + This existed three times: the creator's copy, the wizard's copy (which had no + caller — the button lived on the creator), and the admin console's own reader. + Once the creator stopped being an iframe (B7/T7.1) the first two sat in one + document as five colliding globals; an unreferenced duplicate is exactly what + produced D5. One core now; the pages keep only a thin track() wrapper because + page state (the creator's dev-mode pause) belongs to the page. + + The storage KEYS are unchanged on purpose: everything recorded before this + file existed is still readable through it. No field VALUES are ever stored — + a field-edit event records the field id, nothing else. + + Classic script, no modules: exposes window.WPUsage. */ +'use strict'; + +(function () { + var SESSION = 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + + function load(key) { + try { return JSON.parse(localStorage.getItem(key)) || { events: [] }; } + catch (e) { return { events: [] }; } + } + + function save(key, data) { + try { localStorage.setItem(key, JSON.stringify(data)); } + catch (e) { /* storage unavailable — degrade silently */ } + } + + function track(key, event, detail) { + try { + var d = load(key); + d.events.push({ ts: new Date().toISOString(), session: SESSION, event: event, detail: detail || null }); + if (d.events.length > 5000) d.events = d.events.slice(-5000); + save(key, d); + } catch (e) { /* never let telemetry break the tool it watches */ } + } + + function download(key, prefix) { + var blob = new Blob([JSON.stringify(load(key), null, 2)], { type: 'application/json' }); + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = (prefix || 'wp-usage') + '-' + new Date().toISOString().slice(0, 10) + '.json'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000); + } + + window.WPUsage = { + load: load, + save: save, + track: track, + download: download, + // The pre-D5 keys, verbatim — continuity of the recorded data is a done-when. + KEYS: { creator: 'wp_iwp_analytics_v1', wizard: 'wp_suite_analytics_v1' }, + }; +})(); diff --git a/server/.env.example b/server/.env.example index fa15079..6087901 100644 --- a/server/.env.example +++ b/server/.env.example @@ -30,3 +30,25 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above # notifications are marked "skipped", nothing is sent) until both the toggle is # on and SMTP is configured. # SMTP_PASSWORD=your-smtp-app-password + +# ── Micron asset catalog (optional) ─────────────────────────────────────────── +# Backs the searchable asset picker in the work package creator. READ-ONLY: the +# app only ever runs the single SELECT in server/assets_db.py, so give it a +# db_datareader login and nothing more. +# +# Leave this unset and the suite works normally — the picker reports that no +# catalog is configured and people type asset tags in by hand. +# +# URL-encode special characters in the password (@ = %40, # = %23, / = %2F …). +# MICRON_DB_URL=mssql+pymssql://readonly_user:PASSWORD@sqlhost.example.com:1433/MicronDB +# +# To use pyodbc instead of pymssql you must also add pyodbc to requirements.txt +# and install the Microsoft ODBC driver in the image: +# MICRON_DB_URL=mssql+pyodbc://readonly_user:PASSWORD@sqlhost.example.com/MicronDB?driver=ODBC+Driver+18+for+SQL+Server +# +# Two things to check when the picker says the catalog is unreachable: +# 1. The table/column names in ASSET_QUERY (server/assets_db.py) match the real +# Micron schema — that one constant is the whole schema contract. +# 2. The api container is on the `outbound` network in docker-compose.yml. The +# `internal` network has no default gateway, which blocks the VPN as well as +# the internet. diff --git a/server/alembic/versions/a1b8c6d4e2f9_material_items_project_list.py b/server/alembic/versions/a1b8c6d4e2f9_material_items_project_list.py new file mode 100644 index 0000000..4661c90 --- /dev/null +++ b/server/alembic/versions/a1b8c6d4e2f9_material_items_project_list.py @@ -0,0 +1,45 @@ +"""per-project material list (D6 / T8.6) + +CR-013 was written to accept free text because Nate's spreadsheet and the +master material workbook had not been supplied - and they still have not. +The Aug 18 call was the same one made for locations at CR-005: build the +upload path now. One row per line item a request can pick from: description, +unit, an optional code. Deliberately NO inventory level, price or warehouse +id - a project-scoped uploaded list is not the deferred parts catalog. + +Additive only: a new table, no change to any existing one. + +Revision ID: a1b8c6d4e2f9 +Revises: f3a9d2c1e8b7 +Create Date: 2026-08-19 15:40:00.000000 +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a1b8c6d4e2f9' +down_revision = 'f3a9d2c1e8b7' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'material_items', + sa.Column('id', sa.String(length=40), nullable=False), + sa.Column('project_id', sa.String(length=40), nullable=False), + sa.Column('code', sa.String(length=80), nullable=False, server_default=''), + sa.Column('description', sa.String(length=300), nullable=False, server_default=''), + sa.Column('unit', sa.String(length=20), nullable=False, server_default=''), + sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.text('1')), + sa.Column('sort', sa.Integer(), nullable=False, server_default='0'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_material_items_project_id', 'material_items', ['project_id']) + + +def downgrade() -> None: + op.drop_index('ix_material_items_project_id', table_name='material_items') + op.drop_table('material_items') diff --git a/server/alembic/versions/e2a4c7d91b30_location_taxonomy.py b/server/alembic/versions/e2a4c7d91b30_location_taxonomy.py new file mode 100644 index 0000000..9a1b6c7 --- /dev/null +++ b/server/alembic/versions/e2a4c7d91b30_location_taxonomy.py @@ -0,0 +1,65 @@ +"""per-project Building / Floor / Sector taxonomy (CR-005) + +The location taxonomy differs per project — on Micron, floors within B100 behave +like separate buildings — so it is configured once per SOP instead of hard-coded. +CR-018 rolls cost up by these values, which is why the table stores CODES +(`code`, `path`) beside the display `name`: a rollup keyed on a label breaks the +day somebody fixes a typo in it. + +`active` rather than a delete. Deactivating hides a value from new work packages +while every package already referencing it still resolves its label, which is the +same rule CR-002 and CR-016 apply to fields. + +Additive only: a new table, no change to any existing one, so nothing to backfill +and nothing to migrate. + +Revision ID: e2a4c7d91b30 +Revises: a7c31f9e5b02 +Create Date: 2026-08-16 09:41:02.118307 +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e2a4c7d91b30' +down_revision = 'a7c31f9e5b02' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'location_nodes', + sa.Column('id', sa.String(length=40), nullable=False), + sa.Column('project_id', sa.String(length=40), nullable=False), + sa.Column('parent_id', sa.String(length=40), nullable=True), + sa.Column('level', sa.String(length=20), nullable=False, server_default='building'), + sa.Column('code', sa.String(length=60), nullable=False, server_default=''), + sa.Column('path', sa.String(length=200), nullable=False, server_default=''), + sa.Column('name', sa.String(length=200), nullable=False, server_default=''), + sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column('sort', sa.Integer(), nullable=False, server_default='0'), + sa.Column('created_by', sa.String(length=200), nullable=False, server_default=''), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now()), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + # One row per (project, path). This is what makes a re-import report a + # duplicate instead of quietly creating a second B100/L2/1P. + sa.UniqueConstraint('project_id', 'path', name='uq_location_path'), + ) + op.create_index(op.f('ix_location_nodes_project_id'), 'location_nodes', + ['project_id'], unique=False) + op.create_index(op.f('ix_location_nodes_parent_id'), 'location_nodes', + ['parent_id'], unique=False) + op.create_index(op.f('ix_location_nodes_path'), 'location_nodes', ['path'], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f('ix_location_nodes_path'), table_name='location_nodes') + op.drop_index(op.f('ix_location_nodes_parent_id'), table_name='location_nodes') + op.drop_index(op.f('ix_location_nodes_project_id'), table_name='location_nodes') + op.drop_table('location_nodes') diff --git a/server/alembic/versions/f3a9d2c1e8b7_wp_files_drawing_uploads.py b/server/alembic/versions/f3a9d2c1e8b7_wp_files_drawing_uploads.py new file mode 100644 index 0000000..b7c8ddf --- /dev/null +++ b/server/alembic/versions/f3a9d2c1e8b7_wp_files_drawing_uploads.py @@ -0,0 +1,51 @@ +"""drawing uploads stored with the package (CR-007 / D8) + +The field wants the specific PDF attached, not a link to a Bluebeam session: a +general foreman opens the package and sees exactly the sheet relevant to their +scope, offline. The bytes live in this table - IN the same database as +everything else, settled Aug 18: splitting files out was rejected because a +backup that excludes the drawings is a backup you cannot restore from. The cost +of that decision is bounded by the D8 numbers, enforced in the API: 5MB a file, +PDFs and images only, 2GB per project with a warning at 80%. + +Additive only: a new table, no change to any existing one, so nothing to +backfill and nothing to migrate. + +Revision ID: f3a9d2c1e8b7 +Revises: e2a4c7d91b30 +Create Date: 2026-08-19 11:20:00.000000 +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f3a9d2c1e8b7' +down_revision = 'e2a4c7d91b30' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'wp_files', + sa.Column('id', sa.String(length=40), nullable=False), + sa.Column('wp_id', sa.String(length=40), nullable=False), + sa.Column('project_id', sa.String(length=40), nullable=False), + sa.Column('name', sa.String(length=300), nullable=False, server_default=''), + sa.Column('mime', sa.String(length=100), nullable=False, server_default=''), + sa.Column('size', sa.Integer(), nullable=False, server_default='0'), + sa.Column('description', sa.String(length=500), nullable=False, server_default=''), + sa.Column('data', sa.LargeBinary(), nullable=False), + sa.Column('uploaded_by', sa.String(length=120), nullable=False, server_default=''), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_wp_files_wp_id', 'wp_files', ['wp_id']) + op.create_index('ix_wp_files_project_id', 'wp_files', ['project_id']) + + +def downgrade() -> None: + op.drop_index('ix_wp_files_project_id', table_name='wp_files') + op.drop_index('ix_wp_files_wp_id', table_name='wp_files') + op.drop_table('wp_files') diff --git a/server/app.py b/server/app.py index d904e7d..b8b6fe7 100644 --- a/server/app.py +++ b/server/app.py @@ -8,6 +8,7 @@ Run (dev): uvicorn server.app:app --reload --port 8000 Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 server.app:app Interactive docs: http:///api/docs """ +import base64 import os import re import uuid @@ -25,7 +26,7 @@ from sqlalchemy import select, delete, func from sqlalchemy.orm import Session from .db import Base, engine, get_db -from . import models, auth, notify +from . import models, auth, notify, assets_db # Schema management: # • Local dev (SQLite) auto-creates tables for a zero-config run. @@ -163,7 +164,9 @@ def require_project_admin(db: Session, user: "models.User", project_id: Optional project, and editing a SOP that has already been completed. Requires project access AND Project Admin *on that project*.""" require_project_access(db, user, project_id) - if effective_role(db, user, project_id) not in (auth.ROLE_ADMIN, auth.ROLE_PROJECT_ADMIN): + if effective_role(db, user, project_id) not in ( + auth.ROLE_ADMIN, auth.ROLE_PROJECT_SUPER, auth.ROLE_PROJECT_ADMIN, + ): raise HTTPException( status_code=403, detail=f"{what} requires the Project Admin role on this project", @@ -185,7 +188,19 @@ def require_project_writable(db: Session, user_or_none, project_id: Optional[str if not project_id: return proj = db.get(models.Project, project_id) - if proj is not None and proj.archived_at is not None: + if proj is None: + # The project this write targets is gone — usually an outbox op queued before + # someone deleted the job. Refusing here is what keeps it a clean 409 instead + # of a foreign-key violation surfacing as a 500: the row could never be + # inserted anyway now that both engines enforce their FKs (see db.py). 409 + # also matters because project-data.js retires a 4xx op and would retry a 5xx + # forever, so this is the difference between one quiet failure and a loop. + raise HTTPException( + status_code=409, + detail=f"{what} — this project no longer exists. It was deleted, so there is " + f"nothing to save it against.", + ) + if proj.archived_at is not None: raise HTTPException( status_code=409, detail=(f"{what} — this project is archived (read-only). An administrator " @@ -247,6 +262,163 @@ def add_default_members(db: Session, project_id: str, actor) -> list[str]: return added +# ── User-administration scope ────────────────────────────────────────────────── +# User administration used to be one thing: an app admin did all of it. It is now +# two, because a project admin has to be able to staff their own job without an app +# admin on the phone. An app admin still manages every account; a PROJECT SUPER USER +# manages the accounts on the projects they hold that role on. +# +# Three questions, deliberately separate, because they have different answers: +# managed_project_ids which projects do I administer the users of? +# visible_user_ids whose entry may I SEE in the directory? +# manage_user_problem may I change this account? (much narrower than seeing it) +def managed_project_ids(db: Session, caller: "models.User") -> Optional[set[str]]: + """Projects where `caller` may administer user accounts. None means every project + (an app admin). Read per-membership so the per-project override decides: a super + user demoted to plain member on one job does not administer its users, and an + ordinary account made super user on one job does administer that one.""" + if auth.is_admin(caller): + return None + rows = db.scalars( + select(models.ProjectMember).where(models.ProjectMember.user_id == caller.id) + ).all() + account_role = auth.normalize_role(caller.role) + out = set() + for r in rows: + role = auth.normalize_role(r.role) if (r.role or "").strip() else account_role + if role == auth.ROLE_PROJECT_SUPER: + out.add(r.project_id) + return out + + +def is_user_manager(db: Session, user: "models.User") -> bool: + """May this account administer users at all? THE one definition — derived from the + managed set, never from the account role alone, because the super-user role can be + held on a single project (ProjectMember.role) by an otherwise ordinary account. + + Falls out of it that a super user with no project memberships manages nobody, + which is right: the authority comes from the jobs, not the job title.""" + managed = managed_project_ids(db, user) + return managed is None or bool(managed) + + +def require_user_manager(user: "models.User" = Depends(auth.get_current_user), + db: Session = Depends(get_db)) -> "models.User": + """First gate on the user-administration routes: does this caller administer the + users of ANY project? Which accounts they may then touch is a second, narrower + check per target — require_manage_user.""" + if not is_user_manager(db, user): + raise HTTPException( + status_code=403, + detail="Managing user accounts requires the Administrator role, or Project " + "Super User on a project", + ) + return user + + +def member_project_ids(db: Session, user_id: str) -> set[str]: + """Every project this user has a membership row for (no admin shortcut — this is + the raw set, which is exactly what the scope checks need to reason about).""" + return set(db.scalars( + select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user_id) + ).all()) + + +def visible_user_ids(db: Session, caller: "models.User") -> Optional[set[str]]: + """Whose directory entry `caller` may read. None means everyone (an app admin). + + Anyone signed in may look up the people they actually work with — their own + projects' members — plus the app admins, who are on every project implicitly and + are who you go to when something needs unblocking. Nobody else: the directory + must not become a company-wide address book for a single-project subcontractor.""" + if auth.is_admin(caller): + return None + ids = {caller.id} + mine = accessible_project_ids(db, caller) or set() + if mine: + ids |= set(db.scalars( + select(models.ProjectMember.user_id).where(models.ProjectMember.project_id.in_(mine)) + ).all()) + ids |= set(db.scalars( + select(models.User.id).where(models.User.role == auth.ROLE_ADMIN) + ).all()) + return ids + + +def manage_user_problem(db: Session, caller: "models.User", target: "models.User", + cache: Optional[dict] = None) -> Optional[str]: + """None if `caller` may make ACCOUNT-level changes to `target` (password, name, + permissions role, enable/disable, delete); otherwise the reason they may not, in + words the console can show verbatim. + + An app admin may always. A super user may only when the account sits ENTIRELY + inside the projects they administer, and is not itself an admin or super user. + Both limits matter: + • Exclusive scope, because these changes are global. Resetting a password or + disabling an account reaches every project that person is on, so a super user + must not be able to reach into a job they don't run by way of a shared member. + • No admin/super targets, because otherwise the role could be used to take over + a peer's account and inherit their scope. + Project-scoped changes (adding someone to MY project, their role THERE) are not + account-level and are checked against `managed_project_ids` instead. + + `cache` lets a caller judging a whole page of users hand in the two lookups this + needs ('managed', and 'members' as {user_id: {project_id}}) so the verdict for + thirty rows costs two queries instead of sixty. The rule itself lives only here.""" + if auth.is_admin(caller): + return None + cache = cache if cache is not None else {} + managed = cache.get("managed") + if managed is None: + managed = cache["managed"] = managed_project_ids(db, caller) or set() + if not managed: + return ("You don't administer the users of any project — that needs the Project " + "Super User role on the project") + if auth.normalize_role(target.role) in (auth.ROLE_ADMIN, auth.ROLE_PROJECT_SUPER): + return "Only an application administrator can change an Administrator or Project Super User account" + members = cache.get("members") + theirs = members.get(target.id, set()) if members is not None else member_project_ids(db, target.id) + if not theirs: + return ("This account isn't on any project, so only an application administrator " + "can change it") + outside = theirs - managed + if outside: + return (f"{target.username} is also on {len(outside)} project(s) you don't administer — " + "account changes there have to come from an application administrator. " + "You can still change their access and role on your own projects.") + return None + + +def require_manage_user(db: Session, caller: "models.User", target: "models.User") -> None: + problem = manage_user_problem(db, caller, target) + if problem: + raise HTTPException(status_code=403, detail=problem) + + +def require_see_user(db: Session, caller: "models.User", target: "models.User") -> None: + """404, not 403: whether an account exists outside your projects is itself not + yours to learn, and a 403 would confirm the username.""" + visible = visible_user_ids(db, caller) + if visible is not None and target.id not in visible: + raise HTTPException(status_code=404, detail="User not found") + + +def grantable_roles(caller: "models.User") -> tuple: + """Permissions roles `caller` may hand out. A super user may staff their job with + project admins and project users — never another admin or super user, which is the + line that keeps the role from being a route to app-wide control.""" + if auth.is_admin(caller): + return auth.ROLES + return (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER) + + +def load_target_user(db: Session, user_id: str) -> "models.User": + u = db.get(models.User, user_id) + if not u: + raise HTTPException(status_code=404, detail="User not found") + return u + + # ── Audit trail ──────────────────────────────────────────────────────────────── def log_event(db: Session, actor, action: str, entity_type: str, entity_id: str, project_id: Optional[str] = None, summary: str = "", detail: Optional[dict] = None) -> None: @@ -277,8 +449,12 @@ def require_assignable(db: Session, user_id: str, project_id: Optional[str]) -> def wp_link(db: Session, wp: "models.WorkPackage") -> str: + """A link that opens THIS work package. X1: never the app root - a recipient + who has to hunt for the package after signing in stops opening the emails. + The creator has been its own document since T7.1 and boots ?wp= deep links; + a signed-out recipient rides login.html?next= straight back to it.""" base = (notify.get_settings(db).get("app_base_url") or "").rstrip("/") - path = f"/work-package-suite.html?tab=wp&project={wp.project_id or ''}" + path = f"/wp-creation-index.html?project={wp.project_id or ''}&wp={wp.id}" return (base + path) if base else path @@ -351,8 +527,31 @@ class TestEmailIn(BaseModel): to: Optional[str] = None +# CR-007 / D8: the drawing-upload numbers, as settled Aug 18. The ceiling is +# env-overridable so a test can drive the 80% warning and the refusal without +# writing two gigabytes; the DEFAULT is the decision. +FILE_MAX_BYTES = 5 * 1024 * 1024 +FILE_PROJECT_CEILING = int(os.getenv("WP_FILE_PROJECT_CEILING", str(2 * 1024 * 1024 * 1024))) +FILE_ALLOWED_MIME_RE = re.compile(r"^(application/pdf|image/[a-z0-9.+-]+)$") + + +class FileUploadIn(BaseModel): + name: str = "" + mime: str = "" + description: str = "" + data_base64: str = "" + + +class FileDescIn(BaseModel): + description: str = "" + + class StatusIn(BaseModel): status: str + # CR-014: a rejection (Ready for QA -> In Progress) must say why. The upsert + # route carries the comment inside data.qaRejections; this route has no data, + # so it carries the comment here and the server appends the record itself. + comment: Optional[str] = None class ArchiveIn(BaseModel): @@ -392,6 +591,10 @@ class NewUserIn(BaseModel): email: str = "" role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES project_role: str = "" # job function on the project (no permissions) + # Projects to put the new account on straight away. Optional for an app admin + # (who can assign later); REQUIRED for a super user, whose authority over an + # account comes from the projects it is on — see create_user. + project_ids: list[str] = Field(default_factory=list) class ProjectRoleIn(BaseModel): @@ -603,8 +806,11 @@ def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)): @app.get("/api/auth/me") def whoami(user: models.User = Depends(auth.get_current_user)): - """Who is logged in. The frontend guard calls this on every page load.""" - return {"user": user.to_dict()} + """Who is logged in. The frontend guard calls this on every page load. + + `role` is normalized here so no page has to know that a pre-roles account stores + 'user' where it now means 'project_user'.""" + return {"user": {**user.to_dict(), "role": auth.normalize_role(user.role)}} # ── Display preferences (self-service) ───────────────────────────────────────── @@ -667,22 +873,122 @@ def change_password(body: PasswordChangeIn, request: Request, response: Response return {"ok": True} -# ── User administration (admin only) ──────────────────────────────────────────── +# ── User administration ───────────────────────────────────────────────────────── +# Two kinds of caller reach these routes: an app admin, who manages every account, +# and a Project Super User, who manages the accounts on the projects they administer. +# Every route therefore asks TWO questions — does this role carry user administration +# (require_user_manager), and may it touch THIS account (require_manage_user) — +# and the read route asks a third, wider one (visible_user_ids) because looking a +# colleague up is not the same as being able to change them. +def directory_entry(db: Session, u: "models.User", caller: "models.User", + counts: Optional[dict] = None, cache: Optional[dict] = None) -> dict: + """One row of the user directory, cut to what `caller` is entitled to see. + + A manager gets the administrative record (last login, the auto-add flags, and a + `manageable` verdict with the reason when it's no). Everyone else gets the contact + card only — a project user has no business reading their colleagues' login history + out of a page whose job is "who is on this project and how do I reach them".""" + if not is_user_manager(db, caller): + return { + "id": u.id, "username": u.username, "full_name": u.full_name, "email": u.email, + "role": auth.normalize_role(u.role), "project_role": u.project_role or "", + "is_active": u.is_active, "manageable": False, + } + problem = manage_user_problem(db, caller, u, cache) + n = None if counts is None else counts.get(u.id, 0) + return { + **u.to_dict(), + "role": auth.normalize_role(u.role), + "manageable": problem is None, + "manage_blocked_reason": problem or "", + "project_count": n, + } + + @app.get("/api/auth/users") -def list_users(_admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)): - rows = db.scalars(select(models.User).order_by(models.User.username)).all() - return [u.to_dict() for u in rows] +def list_users(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): + """The user directory, scoped to the caller. An app admin sees every account; a + project member sees the people on their own projects (plus the app admins).""" + visible = visible_user_ids(db, user) + stmt = select(models.User).order_by(models.User.username) + if visible is not None: + stmt = stmt.where(models.User.id.in_(visible)) + rows = db.scalars(stmt).all() + # One pass over the membership table serves both the project-access count and the + # per-row "may I manage this account" verdict. The old console fetched the counts + # with one HTTP request per user. + counts, cache = None, None + if is_user_manager(db, user): + members: dict[str, set] = {} + for uid, pid in db.execute( + select(models.ProjectMember.user_id, models.ProjectMember.project_id) + ).all(): + members.setdefault(uid, set()).add(pid) + counts = {uid: len(pids) for uid, pids in members.items()} + cache = {"members": members} + return [directory_entry(db, u, user, counts, cache) for u in rows] + + +@app.get("/api/auth/user-scope") +def user_scope(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): + """What the signed-in account may do on the user directory page, so the page can + render the right controls instead of guessing at the rules and drawing buttons + that 403. Advisory only — every route re-checks server-side.""" + managed = managed_project_ids(db, user) + if managed is None: + rows = db.scalars(select(models.Project).order_by(models.Project.name)).all() + else: + rows = db.scalars( + select(models.Project).where(models.Project.id.in_(managed)).order_by(models.Project.name) + ).all() if managed else [] + manager = managed is None or bool(managed) + return { + "can_manage_users": manager, + "scope": "all" if managed is None else "projects", + "role": auth.normalize_role(user.role), + "grantable_roles": list(grantable_roles(user)) if manager else [], + "grantable_project_roles": list( + auth.PROJECT_SCOPED_ROLES if auth.is_admin(user) + else (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER) + ) if manager else [], + "role_labels": auth.ROLE_LABELS, + "project_roles": list(auth.PROJECT_ROLES), + "managed_projects": [{"id": p.id, "name": p.name, "number": p.number, + "archived": p.archived_at is not None} for p in rows], + } @app.post("/api/auth/users") -def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)): +def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)): problem = auth.password_problem(body.password, body.username, body.email) if problem: raise HTTPException(status_code=400, detail=problem) - if body.role not in auth.ROLES: - raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}") + allowed = grantable_roles(actor) + if body.role not in allowed: + raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}") if auth.find_user(db, body.username): raise HTTPException(status_code=409, detail="A user with that username already exists") + managed = managed_project_ids(db, actor) + requested = [p for p in dict.fromkeys(body.project_ids) if p] + for pid in requested: + check_id(pid) + if managed is not None: + # A super user's authority over an account is derived from the projects that + # account is on. Creating one with no project — or on a job they don't run — + # would either produce an account they instantly cannot manage, or reach into + # someone else's job. Both are refused rather than silently narrowed. + if not requested: + raise HTTPException( + status_code=400, + detail="Choose at least one project for the new account — you administer users per project", + ) + outside = [p for p in requested if p not in managed] + if outside: + raise HTTPException(status_code=403, detail="You don't administer the users of one of those projects") + valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(requested))).all()) if requested else set() + missing = [p for p in requested if p not in valid] + if missing: + raise HTTPException(status_code=400, detail="One of those projects no longer exists") u = models.User( id=gen_id("user"), username=body.username.strip(), @@ -693,54 +999,75 @@ def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admi project_role=body.project_role.strip()[:120], ) db.add(u) - log_event(db, _admin, "user_created", "user", u.id, summary=u.username, - detail={"role": u.role, "project_role": u.project_role}) + # Flush the account before adding memberships that point at it. The ORM decides + # flush order from relationship() declarations, and models.py deliberately has + # none (plain columns + ForeignKey), so it will happily emit the project_members + # INSERT before the users one — which the database then rejects. Without this the + # whole call fails with a foreign-key violation on any engine that actually + # enforces them, which is every engine we run: Postgres always, and SQLite since + # db.py started setting `PRAGMA foreign_keys=ON`. + db.flush() + log_event(db, actor, "user_created", "user", u.id, summary=u.username, + detail={"role": u.role, "project_role": u.project_role, + "projects": len(valid)}) + for pid in requested: + if pid in valid: + grant_project_access(db, u.id, pid) + if valid: + log_event(db, actor, "project_access_changed", "user", u.id, summary=u.username, + detail={"projects": len(valid), "reason": "created_with_access"}) db.commit() db.refresh(u) - return u.to_dict() + return directory_entry(db, u, actor) @app.post("/api/auth/users/{user_id}/password") -def admin_reset_password(user_id: str, body: AdminPasswordIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)): - u = db.get(models.User, user_id) - if not u: - raise HTTPException(status_code=404, detail="User not found") +def admin_reset_password(user_id: str, body: AdminPasswordIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)): + u = load_target_user(db, user_id) + require_see_user(db, actor, u) + require_manage_user(db, actor, u) problem = auth.password_problem(body.new_password, u.username, u.email) if problem: raise HTTPException(status_code=400, detail=problem) u.password_hash = auth.hash_password(body.new_password) u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions + # An administrative password reset was the one user-account change that left no + # trace; it is the most impersonation-adjacent thing on this page, so it logs. + log_event(db, actor, "password_reset", "user", u.id, summary=u.username, + detail={"by": "administrator"}) db.commit() return {"ok": True} @app.post("/api/auth/users/{user_id}/active") -def set_user_active(user_id: str, body: ActiveIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)): - u = db.get(models.User, user_id) - if not u: - raise HTTPException(status_code=404, detail="User not found") - if u.id == admin.id and not body.is_active: +def set_user_active(user_id: str, body: ActiveIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)): + u = load_target_user(db, user_id) + require_see_user(db, actor, u) + require_manage_user(db, actor, u) + if u.id == actor.id and not body.is_active: raise HTTPException(status_code=400, detail="You cannot disable your own account") u.is_active = body.is_active - log_event(db, admin, "user_enabled" if body.is_active else "user_disabled", "user", u.id, + log_event(db, actor, "user_enabled" if body.is_active else "user_disabled", "user", u.id, summary=u.username, detail={"is_active": bool(body.is_active)}) db.commit() - return u.to_dict() + return directory_entry(db, u, actor) @app.post("/api/auth/users/{user_id}/role") -def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)): - """Change a user's PERMISSIONS role (admin / project_admin / project_user). - Their job function on the project is separate — see set_user_project_role. +def set_user_role(user_id: str, body: RoleIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)): + """Change a user's PERMISSIONS role. Their job function on the project is separate + — see set_user_project_role. - Guards: you can't change your own role (avoids self-lockout), and the last - remaining admin can't be demoted (keeps the app manageable).""" - if body.role not in auth.ROLES: - raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}") - u = db.get(models.User, user_id) - if not u: - raise HTTPException(status_code=404, detail="User not found") - if u.id == admin.id: + Guards: you can't change your own role (avoids self-lockout), the last remaining + admin can't be demoted (keeps the app manageable), and a super user may only hand + out the roles in `grantable_roles` — never admin or another super user.""" + allowed = grantable_roles(actor) + if body.role not in allowed: + raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}") + u = load_target_user(db, user_id) + require_see_user(db, actor, u) + require_manage_user(db, actor, u) + if u.id == actor.id: raise HTTPException(status_code=400, detail="You cannot change your own role") if auth.is_admin(u) and body.role != auth.ROLE_ADMIN: other_admins = db.scalars( @@ -763,27 +1090,27 @@ def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth. u.auto_add_projects = False u.auto_add_role = "" detail["auto_add_cleared"] = True - log_event(db, admin, "role_changed", "user", u.id, summary=u.username, detail=detail) + log_event(db, actor, "role_changed", "user", u.id, summary=u.username, detail=detail) db.commit() db.refresh(u) - return u.to_dict() + return directory_entry(db, u, actor) @app.post("/api/auth/users/{user_id}/project-role") -def set_user_project_role(user_id: str, body: ProjectRoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)): +def set_user_project_role(user_id: str, body: ProjectRoleIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)): """Set a user's job function on the project (Project Manager, Superintendent, …). Purely descriptive — it grants nothing. This is what the SOP team pickers and notification routing read, so it's worth keeping accurate.""" - u = db.get(models.User, user_id) - if not u: - raise HTTPException(status_code=404, detail="User not found") + u = load_target_user(db, user_id) + require_see_user(db, actor, u) + require_manage_user(db, actor, u) old = u.project_role or "" u.project_role = (body.project_role or "").strip()[:120] - log_event(db, admin, "project_role_changed", "user", u.id, summary=u.username, + log_event(db, actor, "project_role_changed", "user", u.id, summary=u.username, detail={"from": old, "to": u.project_role}) db.commit() db.refresh(u) - return u.to_dict() + return directory_entry(db, u, actor) @app.post("/api/auth/users/{user_id}/auto-add") @@ -791,13 +1118,17 @@ def set_user_auto_add(user_id: str, body: AutoAddIn, admin: models.User = Depend """Flag a user as a default member of every project created from here on, with an optional role on those projects. It only touches NEW projects — existing assignments stay under the admin's hand (see set_user_projects), because - back-filling everyone onto historical jobs is never what this flag means.""" - allowed = ("", auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER) + back-filling everyone onto historical jobs is never what this flag means. + + App-admin only, unlike the rest of user administration: this is a standing rule + about every project that will ever exist, including the ones a super user has no + part in.""" + allowed = ("",) + auth.PROJECT_SCOPED_ROLES role = (body.role or "").strip() if role not in allowed: raise HTTPException( status_code=400, - detail=f"role must be '' (inherit) or one of {auth.ROLE_PROJECT_ADMIN}, {auth.ROLE_PROJECT_USER}", + detail=f"role must be '' (inherit) or one of {', '.join(auth.PROJECT_SCOPED_ROLES)}", ) u = db.get(models.User, user_id) if not u: @@ -814,57 +1145,97 @@ def set_user_auto_add(user_id: str, body: AutoAddIn, admin: models.User = Depend @app.delete("/api/auth/users/{user_id}") -def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)): - u = db.get(models.User, user_id) - if not u: - raise HTTPException(status_code=404, detail="User not found") - if u.id == admin.id: +def delete_user(user_id: str, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)): + u = load_target_user(db, user_id) + require_see_user(db, actor, u) + require_manage_user(db, actor, u) + if u.id == actor.id: raise HTTPException(status_code=400, detail="You cannot delete your own account") - log_event(db, admin, "user_deleted", "user", u.id, summary=u.username, detail={"role": u.role}) + log_event(db, actor, "user_deleted", "user", u.id, summary=u.username, detail={"role": u.role}) db.delete(u) db.commit() return {"deleted": user_id} @app.get("/api/auth/users/{user_id}/projects") -def get_user_projects(user_id: str, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)): - """Which projects a user is assigned to, plus the full project list for the - assignment UI. (Admins implicitly access every project regardless.)""" - u = db.get(models.User, user_id) - if not u: - raise HTTPException(status_code=404, detail="User not found") +def get_user_projects(user_id: str, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)): + """Which projects a user is assigned to, plus the project list to choose from. + (Admins implicitly access every project regardless of what's ticked here.) + + A super user is shown ONLY the projects they administer, and `other_projects` says + how many more the person is on — enough for the dialog to be honest that it is + editing a slice of this account's access, without naming jobs that aren't theirs.""" + u = load_target_user(db, user_id) + require_see_user(db, actor, u) + require_manage_user(db, actor, u) rows = db.scalars(select(models.ProjectMember).where(models.ProjectMember.user_id == user_id)).all() - projects = db.scalars(select(models.Project).order_by(models.Project.name)).all() + managed = managed_project_ids(db, actor) + if managed is None: + projects = db.scalars(select(models.Project).order_by(models.Project.name)).all() + in_scope = rows + else: + projects = db.scalars( + select(models.Project).where(models.Project.id.in_(managed)).order_by(models.Project.name) + ).all() if managed else [] + in_scope = [r for r in rows if r.project_id in managed] return { - "user": u.to_dict(), - "assigned": [r.project_id for r in rows], + "user": directory_entry(db, u, actor), + "assigned": [r.project_id for r in in_scope], # Per-project role overrides, keyed by project id ('' = inherit the account's). - "roles": {r.project_id: (r.role or "") for r in rows}, + "roles": {r.project_id: (r.role or "") for r in in_scope}, # Archived projects stay on this list on purpose — an existing assignment has # to remain visible and removable — but they're flagged so the dialog can say # so, rather than offering a finished job as though it were live work. "projects": [{"id": p.id, "name": p.name, "number": p.number, "archived": p.archived_at is not None} for p in projects], + "other_projects": len(rows) - len(in_scope), + "grantable_project_roles": list( + auth.PROJECT_SCOPED_ROLES if auth.is_admin(actor) + else (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER) + ), } @app.put("/api/auth/users/{user_id}/projects") -def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)): - """Replace a user's project assignments with the given set.""" - u = db.get(models.User, user_id) - if not u: - raise HTTPException(status_code=404, detail="User not found") - valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(body.project_ids))).all()) if body.project_ids else set() - # Only the two project-scoped roles make sense here: app admin is global, and - # anything unrecognised falls back to inheriting the account's own role. - allowed = (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER) +def set_user_projects(user_id: str, body: ProjectAssignIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)): + """Replace a user's project assignments with the given set. + + For an app admin the given set IS the whole answer. For a super user it replaces + only their own slice: memberships on projects they don't administer are left + exactly as they were, because a payload that simply omits them would otherwise cut + someone off from a job the caller can't even see.""" + u = load_target_user(db, user_id) + require_see_user(db, actor, u) + require_manage_user(db, actor, u) + requested = [p for p in dict.fromkeys(body.project_ids) if p] + for pid in requested: + check_id(pid) + managed = managed_project_ids(db, actor) + if managed is not None: + outside = [p for p in requested if p not in managed] + if outside: + raise HTTPException(status_code=403, detail="You don't administer the users of one of those projects") + valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(requested))).all()) if requested else set() + # A super user may hand out the project-scoped roles below their own; only an app + # admin can make someone a super user on a project. Anything unrecognised falls + # back to inheriting the account's own role. + allowed = auth.PROJECT_SCOPED_ROLES if auth.is_admin(actor) else (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER) roles = {pid: r for pid, r in (body.roles or {}).items() if r in allowed} - db.execute(delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id)) + # Rebuild only the rows this caller owns. Scoping the DELETE is the whole of the + # "leave other jobs alone" guarantee — get it wrong and a super user's save + # silently revokes access everywhere else. + doomed = delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id) + if managed is not None: + # in_() on an empty set is a valid always-false predicate, so a caller who + # administers nothing deletes nothing (require_manage_user already refused them). + doomed = doomed.where(models.ProjectMember.project_id.in_(managed)) + db.execute(doomed) for pid in valid: db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid, role=roles.get(pid, ""))) - log_event(db, _admin, "project_access_changed", "user", u.id, summary=u.username, + log_event(db, actor, "project_access_changed", "user", u.id, summary=u.username, detail={"projects": len(valid), + "scope": "all" if managed is None else "managed", "overrides": {p: r for p, r in roles.items() if p in valid}}) db.commit() return {"assigned": sorted(valid), "roles": {p: roles.get(p, "") for p in sorted(valid)}} @@ -923,6 +1294,15 @@ def list_projects( elif archived != "all": stmt = stmt.where(models.Project.archived_at.is_(None)) # default: hide archived rows = db.scalars(stmt.order_by(models.Project.updated_at.desc())).all() + # D7 / T9.8: archived projects are readable by PROJECT ADMINS only - anyone + # below that sees them nowhere, counts and pickers included. The default + # listing already excludes them; asking for them is what gets gated, and it + # is gated per project, so admin-on-Job-A does not surface archived Job B. + if archived != "exclude": + rows = [p for p in rows + if p.archived_at is None + or effective_role(db, user, p.id) in ( + auth.ROLE_ADMIN, auth.ROLE_PROJECT_SUPER, auth.ROLE_PROJECT_ADMIN)] return [p.summary() for p in rows] @@ -1062,14 +1442,24 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user), # ── Release gates (constraints + predecessors) ───────────────────────────────── -# Status ladder, mirrored in the front end (wp-creation-app.js STATUS_ORDER). -STATUS_ORDER = ["Draft", "Scheduled", "Issued", "In Progress", "Issue", "QC", "Closed"] +# Status ladder. The front end's STATUS_ORDER (wp-creation-app.js) has no "Issue" +# in it at all - the hold is a BRANCH off the released states, not a rung. It sits +# in this list so unknown statuses can still be told apart from known ones, but +# _released() must never count it: counting it is what let every transition out of +# hold skip the release gates as "already released", which made /status a side +# door past an open constraint (CR-015 / T7.3). +STATUS_ORDER = ["Draft", "Scheduled", "Issued", "In Progress", "Ready for QA", "Issue", "QC", "Closed"] +QA_READY_STATUS = "Ready for QA" ISSUED_IDX = STATUS_ORDER.index("Issued") DONE_STATUS = "Closed" +HOLD_STATUS = "Issue" def _released(status: Optional[str]) -> bool: - """Has this package been released to the field (Issued or anything after)?""" + """Is this status a field state (Issued or beyond)? The hold is NOT one: a + package leaves hold through the gates, and enters it without them.""" + if status == HOLD_STATUS: + return False try: return STATUS_ORDER.index(status or "") >= ISSUED_IDX except ValueError: @@ -1095,6 +1485,23 @@ def gate_override(data: Optional[dict]) -> Optional[dict]: return None +def _urgent_constraint_override(data: Optional[dict], open_names: list) -> bool: + """D4: only an URGENT package may release past an open constraint, and only + through the audited override - the same gateOverride record the predecessor + gate uses, never a separate path. The override must NAME every constraint it + covers: a constraint opened after the reason was written cannot ride through + on it. Normal and High are unchanged - a hard refusal.""" + if str((data or {}).get("priority") or "") != "Urgent": + return False + ov = gate_override(data) + if not ov: + return False + covered = ov.get("constraints") + if not isinstance(covered, list): + return False + return {str(n) for n in open_names} <= {str(c) for c in covered} + + def blocking_predecessors(db: Session, wp_id: Optional[str], data: Optional[dict]) -> list[dict]: """Predecessors that are not Closed yet. A predecessor that no longer exists is NOT blocking — a deleted package must not freeze everything downstream.""" @@ -1143,7 +1550,7 @@ def enforce_release_gates(db: Session, wp_id: Optional[str], data: Optional[dict return # not a release transition constraints = (data or {}).get("constraints") or [] open_names = [c.get("name") for c in constraints if isinstance(c, dict) and c.get("status") == "open"] - if open_names: + if open_names and not _urgent_constraint_override(data, open_names): raise HTTPException(status_code=409, detail={ "message": "Open constraints block release", "open": open_names, }) @@ -1194,6 +1601,108 @@ def project_sop_team(db: Session, project_id: Optional[str]) -> list[str]: return [i for i in (proj.get("pmId"), proj.get("cmId")) if i] +def project_qa_group(db: Session, project_id: Optional[str]) -> list["models.User"]: + """D2: the QA group named on the project's latest complete SOP. pushSOP writes + the row as data={sop, state}, so the project block is data['sop']['project'] - + note that project_sop_team above reads data['project'], which that shape never + has (BL-021, logged, not fixed here).""" + if not project_id: + return [] + sop = db.scalars( + select(models.Sop) + .where((models.Sop.project_id == project_id) & (models.Sop.complete.is_(True))) + .order_by(models.Sop.updated_at.desc()) + .limit(1) + ).first() + if not sop: + return [] + data = sop.data or {} + proj = ((data.get("sop") or {}).get("project") + or data.get("project") or {}) + ids = [i for i in (proj.get("qaGroupIds") or []) if isinstance(i, str) and i] + if not ids: + return [] + return list(db.scalars(select(models.User).where(models.User.id.in_(ids)))) + + +def qa_rejection_comment(data: Optional[dict]) -> str: + rejs = [r for r in ((data or {}).get("qaRejections") or []) if isinstance(r, dict)] + if not rejs: + return "" + return str(rejs[-1].get("comment") or "").strip() + + +def enforce_qa_rejection_comment(data: Optional[dict], new_status: str, + old_status: Optional[str], + old_data: Optional[dict] = None) -> None: + """CR-014: a rejection with no reason is the after-the-fact surprise this gate + exists to end. Runs before anything is written, like the release gates. + The comment must be FRESH: a rejection entry already on the record satisfied + the first version of this check, which made every rejection after the first + one free. New entry, non-empty comment, or the transition is refused.""" + if old_status == QA_READY_STATUS and new_status == "In Progress": + new_rejs = [r for r in ((data or {}).get("qaRejections") or []) if isinstance(r, dict)] + old_rejs = [r for r in ((old_data or {}).get("qaRejections") or []) if isinstance(r, dict)] + fresh = len(new_rejs) > len(old_rejs) and str(new_rejs[-1].get("comment") or "").strip() + if not fresh: + raise HTTPException(status_code=409, detail={ + "message": "Returning a package from Ready for QA requires a comment", + }) + + +def qa_ready_body(user: "models.User", wp: "models.WorkPackage", + actor: "models.User", link: str) -> str: + # A WP number and a deep link - NOT the package contents. The task text asked + # for location and a scope summary, but the done-when list (and the standing + # rule) says no customer IP in a message body; the link is the summary. + who = actor.full_name or actor.username + name = user.full_name or user.username + return ( + f"Hi {name},\n\n" + f"{who} moved {wp.number or 'a work package'} to Ready for QA.\n" + f"It is in the QA queue waiting to be accepted or returned.\n\n" + f"Open it here:\n{link}\n\n" + f"— This is an automated message from the Work Package Suite." + ) + + +def qa_reject_body(user: "models.User", wp: "models.WorkPackage", + actor: "models.User", link: str) -> str: + who = actor.full_name or actor.username + name = user.full_name or user.username + return ( + f"Hi {name},\n\n" + f"{who} returned {wp.number or 'a work package'} from Ready for QA to In Progress.\n" + f"The reason is recorded on the package.\n\n" + f"Open it here:\n{link}\n\n" + f"— This is an automated message from the Work Package Suite." + ) + + +def notify_qa_transition(db: Session, wp: "models.WorkPackage", + actor: "models.User", rejected: bool) -> list: + """Entering Ready for QA emails the QA group and nobody else (D2). A rejection + emails the owner AND the same group (D9's sibling decision). Deduplicated; + every send is an outbox row, so a failure is recorded, never silent.""" + recipients = {u.id: u for u in project_qa_group(db, wp.project_id)} + if rejected and wp.assignee_id: + owner = db.get(models.User, wp.assignee_id) + if owner: + recipients[owner.id] = owner + out = [] + link = wp_link(db, wp) + for u in recipients.values(): + out.append(notify.enqueue( + db, user=u, kind="qa_rejected" if rejected else "qa_ready", + subject=(f"Returned from QA: {wp.number or 'work package'}" if rejected + else f"Ready for QA: {wp.number or 'work package'}"), + body=(qa_reject_body(u, wp, actor, link) if rejected + else qa_ready_body(u, wp, actor, link)), + link=link, wp_id=wp.id, project_id=wp.project_id, + )) + return out + + def hold_body(user: "models.User", wp: "models.WorkPackage", names: list[str], actor: "models.User", link: str) -> str: # Constraint names and a WP number only — no package contents, same rule as the @@ -1211,6 +1720,92 @@ def hold_body(user: "models.User", wp: "models.WorkPackage", names: list[str], ) +def kitting_body(user: "models.User", wp: "models.WorkPackage", actor: "models.User", + old_status: str, new_status: str, link: str) -> str: + # Same convention as the assignment and hold mails: a greeting, one line of + # what happened, the deep link, the footer. The delivery location is MIMO + # logistics (where material stages), not package contents - the wave 8 spec + # names it in the done-when. deliveryLoc arrives with CR-012 (T8.4); + # mimoLoc is what exists today, so both are read. + who = actor.full_name or actor.username + name = user.full_name or user.username + delivery = str((wp.data or {}).get("deliveryLoc") + or (wp.data or {}).get("mimoLoc") or "").strip() or "not set" + return ( + f"Hi {name},\n\n" + f"{who} moved kitting on {wp.number or 'a work package'} from " + f"{old_status or 'Not Started'} to {new_status or 'Not Started'}.\n" + f"Delivery location: {delivery}.\n\n" + f"Open it here:\n{link}\n\n" + f"— This is an automated message from the Work Package Suite." + ) + + +def material_request_body(user: "models.User", wp: "models.WorkPackage", + actor: "models.User", n_lines: int, needed: str, + delivery: str, link: str) -> str: + who = actor.full_name or actor.username + name = user.full_name or user.username + needed_line = f" needed by {needed}" if needed else "" + return ( + f"Hi {name},\n\n" + f"{who} raised a material request on {wp.number or 'a work package'}: " + f"{n_lines} line{'' if n_lines == 1 else 's'}{needed_line}.\n" + f"Delivery location: {delivery}.\n\n" + f"Open it here:\n{link}\n\n" + f"— This is an automated message from the Work Package Suite." + ) + + +def notify_kitting_change(db: Session, wp: "models.WorkPackage", actor: "models.User", + old_status: str, new_status: str) -> list: + """CR-011: the package's distribution list plus its warehouse owner (CR-010's + default recipient), minus the actor, deduplicated. COALESCED: if an unsent + kitting notification already exists for this package and recipient, it is + rewritten to the newest transition instead of joined by a sibling - rapid + consecutive changes produce one email saying where kitting ended up, not a + burst of near-identical ones.""" + ids = [i for i in ((wp.data or {}).get("distributionIds") or []) if isinstance(i, str)] + ko = (wp.data or {}).get("kitOwnerId") + if isinstance(ko, str) and ko: + ids.append(ko) + link = wp_link(db, wp) + subject = f"Kitting {new_status or 'updated'}: {wp.number or 'work package'}" + existing = {n.user_id: n for n in db.scalars( + select(models.Notification).where( + (models.Notification.wp_id == wp.id) + & (models.Notification.kind == "kitting_status") + & (models.Notification.status.in_(("pending", "skipped"))))).all()} + s_cfg = notify.get_settings(db) + deliverable = bool(s_cfg.get("email_enabled")) and notify.smtp_ready(s_cfg) + seen, out = set(), [] + for uid in ids: + if uid in seen or uid == actor.id: + continue + seen.add(uid) + u = db.get(models.User, uid) + if not u or not u.is_active: + continue + held = existing.get(uid) + if held is not None: + held.subject = subject[:300] + held.body = kitting_body(u, wp, actor, old_status, new_status, link) + held.link = link[:500] + # A row held while email was OFF stays 'skipped' forever unless the + # change that finds email ON promotes it - otherwise turning the + # gate on silently orphans everything coalesced before it. + if deliverable and u.email and held.status == "skipped": + held.status = "pending" + out.append(held) + continue + out.append(notify.enqueue( + db, user=u, kind="kitting_status", subject=subject, + body=kitting_body(u, wp, actor, old_status, new_status, link), + link=link, wp_id=wp.id, project_id=wp.project_id, + )) + return out + + def notify_critical_reopen(db: Session, wp: "models.WorkPackage", names: list[str], actor: "models.User") -> list["models.Notification"]: """Owner + PM + CM + everyone on the package's distribution list, minus whoever @@ -1263,6 +1858,7 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User = wp_id_for_checks = body.id or wp.id check_predecessor_cycle(db, wp_id_for_checks, body.data) enforce_release_gates(db, wp_id_for_checks, body.data, body.status, old_status) + enforce_qa_rejection_comment(body.data, body.status, old_status, old_data) wp.project_id = body.project_id wp.sop_id = body.sop_id wp.parent_id = body.parent_id @@ -1275,6 +1871,14 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User = require_assignable(db, new_assignee, body.project_id) wp.assignee_id = new_assignee wp.created_by = body.created_by or wp.created_by + # data["files"] is SERVER-owned (CR-007): it mirrors the wp_files table and + # is rewritten by the upload/delete/patch routes. A client that saved before + # an upload landed would otherwise erase the list with its stale copy. + if not is_new: + _stored_files = (old_data or {}).get("files") + if _stored_files is not None: + body.data = dict(body.data or {}) + body.data["files"] = _stored_files wp.data = body.data if is_new: _act, _detail = "created", {"status": wp.status} @@ -1290,19 +1894,93 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User = ov = gate_override(body.data) if ov and _released(wp.status) and not _released(old_status): blockers = blocking_predecessors(db, wp.id, body.data) + _ov_constraints = [str(c) for c in ov.get("constraints") or [] if c] log_event(db, user, "gate_overridden", "wp", wp.id, project_id=wp.project_id, summary=(wp.number or wp.subject or wp.id), detail={"reason": str(ov.get("reason"))[:300], - "blocking": [b["number"] or b["id"] for b in blockers]}) + "blocking": [b["number"] or b["id"] for b in blockers], + "constraints": _ov_constraints}) + # CR-015: holds and releases are history, not just state. Entering or leaving + # the hold branch gets its own audit line with the actor and the reason the + # browser recorded in data.holds - `status_changed` alone says from/to but + # not why, and the WHY is what a notice of delay is built from. + if not is_new and old_status != wp.status and "Issue" in (old_status, wp.status): + _hlds = [h for h in ((wp.data or {}).get("holds") or []) if isinstance(h, dict)] + if wp.status == "Issue": + _h = next((h for h in reversed(_hlds) if not h.get("released")), None) + log_event(db, user, "hold_logged", "wp", wp.id, project_id=wp.project_id, + summary=(wp.number or wp.subject or wp.id), + detail={"from": old_status, + "constraint": str((_h or {}).get("constraint") or "")[:200], + "reason": str((_h or {}).get("details") or "")[:300]}) + else: + _h = next((h for h in reversed(_hlds) if h.get("released")), None) + log_event(db, user, "hold_released", "wp", wp.id, project_id=wp.project_id, + summary=(wp.number or wp.subject or wp.id), + detail={"to": wp.status, + "constraint": str((_h or {}).get("constraint") or "")[:200], + "reason": str((_h or {}).get("details") or "")[:300]}) # A critical constraint reopened after release: log it and tell the people who # need to know (owner, PM, CM, distribution). - if not is_new and _released(old_status): + if not is_new and (_released(old_status) or old_status == HOLD_STATUS): reopened = reopened_critical(old_data, wp.data) if reopened: log_event(db, user, "constraint_reopened", "wp", wp.id, project_id=wp.project_id, summary=(wp.number or wp.subject or wp.id), detail={"critical": reopened, "status": wp.status}) notifs.extend(notify_critical_reopen(db, wp, reopened, user)) + # CR-014: the QA gate. Entering Ready for QA tells the QA group their queue + # grew; a rejection tells the owner and the same group. Both write their own + # audit line - status_changed says from/to, these say what it MEANS. + if not is_new and old_status != wp.status: + if wp.status == QA_READY_STATUS: + log_event(db, user, "qa_ready", "wp", wp.id, project_id=wp.project_id, + summary=(wp.number or wp.subject or wp.id), detail={"from": old_status}) + notifs.extend(notify_qa_transition(db, wp, user, rejected=False)) + elif old_status == QA_READY_STATUS and wp.status == "In Progress": + log_event(db, user, "qa_rejected", "wp", wp.id, project_id=wp.project_id, + summary=(wp.number or wp.subject or wp.id), + detail={"comment": qa_rejection_comment(wp.data)[:300]}) + notifs.extend(notify_qa_transition(db, wp, user, rejected=True)) + # CR-011: a kitting status change tells the distribution list and the + # warehouse owner where the material stands. Detected here because the + # browser saves kitting through this upsert (the outbox replays it too). + if not is_new: + _kit_old = str((old_data or {}).get("kitStatus") or "") + _kit_new = str((wp.data or {}).get("kitStatus") or "") + if _kit_old != _kit_new: + log_event(db, user, "kitting_status_changed", "wp", wp.id, + project_id=wp.project_id, + summary=(wp.number or wp.subject or wp.id), + detail={"from": _kit_old, "to": _kit_new}) + notifs.extend(notify_kitting_change(db, wp, user, _kit_old, _kit_new)) + # CR-013 / T8.5: a new material request notifies the warehouse owner named + # on the package (CR-010) - the routing that replaces the informal funnel + # through one person. Same gate, same outbox, same link discipline. + if not is_new: + _mr_old = [r for r in ((old_data or {}).get("materialRequests") or []) if isinstance(r, dict)] + _mr_new = [r for r in ((wp.data or {}).get("materialRequests") or []) if isinstance(r, dict)] + if len(_mr_new) > len(_mr_old): + fresh = _mr_new[len(_mr_old):] + for req in fresh: + log_event(db, user, "material_requested", "wp", wp.id, + project_id=wp.project_id, + summary=(wp.number or wp.subject or wp.id), + detail={"lines": len(req.get("items") or []), + "neededBy": str(req.get("neededBy") or "")[:40]}) + ko = (wp.data or {}).get("kitOwnerId") + owner = db.get(models.User, ko) if isinstance(ko, str) and ko else None + if owner and owner.is_active and owner.id != user.id: + link = wp_link(db, wp) + n_lines = sum(len(r.get("items") or []) for r in fresh) + needed = str(fresh[-1].get("neededBy") or "").strip() + deliv = str(fresh[-1].get("deliveryLoc") or "").strip() or "not set" + notifs.append(notify.enqueue( + db, user=owner, kind="material_requested", + subject=f"Material request: {wp.number or 'work package'}", + body=material_request_body(owner, wp, user, n_lines, needed, deliv, link), + link=link, wp_id=wp.id, project_id=wp.project_id, + )) # Notify a newly-assigned owner (skip self-assignment). notif = None if new_assignee and new_assignee != old_assignee and new_assignee != user.id: @@ -1374,11 +2052,96 @@ def list_wps( return [(w.to_dict() if full else w.summary()) for w in rows] +# Weighted completion by status, 0..1, so progress reads as a slope rather than +# done/not-done. Mirrors PROGRESS_W in wp-creation-app.js — the dashboard used to +# compute this in the browser from localStorage, which is exactly what B4 removes. +PROGRESS_WEIGHT = { + "Draft": 0.0, "Scheduled": 0.25, "Issue": 0.4, "Issued": 0.5, + "In Progress": 0.75, "QC": 0.9, "Closed": 1.0, +} + +# The dimensions a location rollup is grouped by. Today a work package carries one +# free-text `location` ("building / level / sector / room"), so that is the single +# dimension. CR-004 replaces it with structured building / floor / sector in wave 6; +# when it does, only this tuple and the keys inside each group change — the response +# shape does not, which is what T4.1 means by "can be grouped by location without a +# schema change". CR-018's rollup consumes `by_location.groups` either way. +# CR-004 landed at T6.3, so this is now what it was designed to become. T4.1's +# note said "only this tuple and the keys inside each group change — the response +# shape does not", and that held: CR-018's rollup consumes `by_location.groups` +# exactly as it did when there was one free-text dimension. +LOCATION_DIMENSIONS = ("building", "floor", "sector") + +# A package with no location is not dropped from the rollup. `CR-018`'s own +# done-when says so, and the reason is arithmetic: a group set that silently +# omits the unlocated packages does not add up to the project total, and a rollup +# that does not reconcile is worse than no rollup. +LOCATION_UNSET = "(unassigned)" + + +def _location_key(data: dict) -> dict: + """The location dimensions of one package, as a dict keyed by dimension name. + + Structured fields win; the free-text `location` a package captured before + CR-004 is kept as its own dimension value so those packages group together + under what they actually said rather than all collapsing into one bucket.""" + structured = {d: (data.get(d) or "").strip() for d in LOCATION_DIMENSIONS} + if any(structured.values()): + return {k: v or LOCATION_UNSET for k, v in structured.items()} + legacy = (data.get("location") or "").strip() + if legacy: + # One dimension deep, deliberately: free text is not a hierarchy and + # pretending it is would put "FAB / LVL 1" under a building called + # "FAB / LVL 1". + return {"building": legacy, "floor": LOCATION_UNSET, "sector": LOCATION_UNSET} + return {d: LOCATION_UNSET for d in LOCATION_DIMENSIONS} + + +def _location_levels(loc_groups: dict) -> dict: + """Totals at each level of the hierarchy, not only at the leaf. + + `by_location.groups` is one row per distinct (building, floor, sector). The + question CR-018 asks — "what is on floor 2" — is a level above that, and + every level has to reconcile against the project total or the rollup is + decoration. Each level therefore sums EVERY package, including the ones whose + value at that level is unassigned.""" + out: dict[str, list] = {} + for dim in LOCATION_DIMENSIONS: + buckets: dict[str, dict] = {} + for g in loc_groups.values(): + # Grouped by the value AT THIS LEVEL alone, not by the tuple of levels + # above it. Each stored value is already a full path — a floor is + # `B-ONE/L1`, not `L1` — so it carries its own ancestry and is unique + # across buildings without being re-qualified. Only the unassigned + # bucket is shared, which is what it should be: "these have no floor + # recorded" is one answer, not one answer per building. + path = g["key"].get(dim, LOCATION_UNSET) or LOCATION_UNSET + slot = buckets.setdefault(path, { + "dimension": dim, + "path": path, + "total": 0, "release_ready": 0, "on_hold": 0, "overdue": 0, + "est_hours": 0.0, "actual_hours": 0.0, + }) + for f in ("total", "release_ready", "on_hold", "overdue", "est_hours", "actual_hours"): + slot[f] += g[f] + out[dim] = [ + {**b, "est_hours": round(b["est_hours"]), "actual_hours": round(b["actual_hours"])} + for b in sorted(buckets.values(), key=lambda b: b["path"]) + ] + return out + + @app.get("/api/wps/metrics") def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): - """Aggregates for the dashboard. Masters (data.split == true) are excluded - from counts so a split package's hours aren't double-counted with its - instances.""" + """Every count and rollup the creator's dashboard shows, computed from the + database rather than from the caller's own browser (B4). + + Masters (data.split == true) are excluded so a split package's hours are not + double-counted with its instances. Archived packages are excluded throughout. + + Two people on the same project get the same numbers from this endpoint. They + did not when each browser derived them from its own localStorage, and neither + was told — which is the failure B4 exists to remove.""" stmt = select(models.WorkPackage).where(models.WorkPackage.archived_at.is_(None)) if project_id: stmt = stmt.where(models.WorkPackage.project_id == project_id) @@ -1387,9 +2150,17 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user) rows = db.scalars(stmt).all() + today = models.utcnow().date().isoformat() + by_status: dict[str, int] = {} by_discipline: dict[str, int] = {} - total = ready = on_hold = est_hours = actual_hours = 0 + loc_groups: dict[tuple, dict] = {} + prog_by_disc: dict[str, dict] = {} + gating: list[dict] = [] + total = ready = on_hold = overdue = mine = 0 + est_hours = actual_hours = 0.0 + progress_sum = 0.0 + for w in rows: data = w.data or {} if data.get("split"): @@ -1398,22 +2169,513 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = by_status[w.status] = by_status.get(w.status, 0) + 1 if w.status == "Issue": on_hold += 1 + if w.assignee_id and w.assignee_id == user.id: + mine += 1 + + due = (data.get("due") or "").strip() + is_overdue = bool(due and w.status != "Closed" and due < today) + if is_overdue: + overdue += 1 + constraints = data.get("constraints") or [] - open_count = sum(1 for c in constraints if c.get("status") == "open") - if open_count == 0 and w.status not in ("Closed", "Issue"): + open_constraints = [c for c in constraints if c.get("status") == "open"] + # `waitingOn` are predecessor packages not yet closed; the browser counted + # them as blocking too, so the server has to, or "release-ready" changes + # meaning the moment the dashboard stops computing it locally. + waiting_on = [x for x in (data.get("waitingOn") or []) if x] + blocked = bool(open_constraints or waiting_on) + if not blocked and w.status not in ("Closed", "Issue"): ready += 1 + if open_constraints: + gating.append({ + "id": w.id, "number": w.number, "subject": w.subject, + "blocked_by": [ + {"name": c.get("name") or "", "comment": c.get("comment") or ""} + for c in open_constraints + ], + }) + try: est_hours += float(data.get("hours") or 0) + except (TypeError, ValueError): + pass + try: actual_hours += float(data.get("actualHrs") or 0) except (TypeError, ValueError): pass - for d in (data.get("disciplines") or ["(none)"]): + + weight = PROGRESS_WEIGHT.get(w.status, 0.0) + progress_sum += weight + + disciplines = data.get("disciplines") or ["(none)"] + for d in disciplines: by_discipline[d] = by_discipline.get(d, 0) + 1 + slot = prog_by_disc.setdefault(d, {"total": 0, "done": 0, "weight": 0.0}) + slot["total"] += 1 + slot["weight"] += weight + if w.status == "Closed": + slot["done"] += 1 + + key = _location_key(data) + kt = tuple(key.get(d, "(unset)") for d in LOCATION_DIMENSIONS) if len(key) == len(LOCATION_DIMENSIONS) else tuple(sorted(key.items())) + slot = loc_groups.setdefault(kt, {"key": key, "total": 0, "release_ready": 0, + "on_hold": 0, "overdue": 0, "by_status": {}, + "est_hours": 0.0, "actual_hours": 0.0}) + slot["total"] += 1 + slot["by_status"][w.status] = slot["by_status"].get(w.status, 0) + 1 + if not blocked and w.status not in ("Closed", "Issue"): + slot["release_ready"] += 1 + if w.status == "Issue": + slot["on_hold"] += 1 + if is_overdue: + slot["overdue"] += 1 + # CR-018: hours roll up along the same dimensions. Actual Hours is the one + # CR-017 retained and is the reason that decision mattered — it is what + # makes a floor's real cost visible. + try: + slot["est_hours"] += float(data.get("hours") or 0) + except (TypeError, ValueError): + pass + try: + slot["actual_hours"] += float(data.get("actualHrs") or 0) + except (TypeError, ValueError): + pass + + dimensions = list(LOCATION_DIMENSIONS) + if loc_groups: + first = next(iter(loc_groups.values()))["key"] + dimensions = list(first.keys()) return { - "total": total, "release_ready": ready, "on_hold": on_hold, + "total": total, "mine": mine, "release_ready": ready, "on_hold": on_hold, + "overdue": overdue, "est_hours": round(est_hours), "actual_hours": round(actual_hours), "by_status": by_status, "by_discipline": by_discipline, + "progress": { + "overall_pct": round(progress_sum / total * 100) if total else 0, + "by_discipline": [ + {"name": d, "pct": round(v["weight"] / v["total"] * 100) if v["total"] else 0, + "done": v["done"], "total": v["total"]} + for d, v in sorted(prog_by_disc.items()) + ], + }, + "gating": sorted(gating, key=lambda g: (g["number"] or "", g["id"])), + "by_location": { + "dimensions": dimensions, + "unassigned_key": LOCATION_UNSET, + "groups": [ + {**g, "est_hours": round(g["est_hours"]), "actual_hours": round(g["actual_hours"])} + for g in sorted(loc_groups.values(), + key=lambda g: tuple(str(v) for v in g["key"].values())) + ], + # Rolled up one level at a time as well as by the full triple, because + # "how many on floor 2" is the question CR-018 is actually about and + # summing the leaf groups in the browser would be the same per-browser + # arithmetic B4 removed. + "levels": _location_levels(loc_groups), + }, + "generated_at": models.utcnow().isoformat(), + } + + +# ── Location taxonomy — CR-005 ──────────────────────────────────────────────── +# Building / Floor / Sector, configured once per project rather than hard-coded. +# See models.LocationNode for why this stores codes as well as names, and why +# nothing here deletes. + +LOCATION_LEVELS = ("building", "floor", "sector") +_SLUG_STRIP = re.compile(r"[^A-Z0-9]+") + + +def location_slug(name: str) -> str: + """A stable code from a display name: upper-cased, non-alphanumerics collapsed + to a single dash, trimmed. `Level 2` -> `LEVEL-2`, `1P (chase)` -> `1P-CHASE`. + + Derived ONCE, at import, and never recomputed — see LocationNode. Returns '' + when there is nothing to make a code from, which the caller turns into a + rejected row with a reason rather than a silently-skipped one.""" + return _SLUG_STRIP.sub("-", (name or "").strip().upper()).strip("-")[:60] + + +def parse_location_rows(text: str) -> tuple[list[tuple[int, list[str]]], list[dict]]: + """Split pasted or uploaded text into (rows, rejected). + + Rows come back paired with their SOURCE line number, not their index among the + accepted ones. "Duplicate on row 12" has to mean row 12 of the file somebody is + looking at, or the report sends them to the wrong line. + + Accepts comma, tab or semicolon separators — a paste out of Excel is + tab-separated and a saved CSV is not, and asking which one somebody has is a + question the machine can answer. A header line naming the levels is skipped. + + Every rejection carries the line number and a reason. A silent skip is the + failure mode this endpoint exists to avoid: an import that says "42 rows" over + a file with 50 in it has lost eight and told nobody.""" + rows: list[tuple[int, list[str]]] = [] + rejected: list[dict] = [] + for i, raw in enumerate((text or "").splitlines(), start=1): + line = raw.strip() + if not line: + continue + if "\t" in raw: + parts = [c.strip() for c in raw.split("\t")] + elif ";" in line and "," not in line: + parts = [c.strip() for c in line.split(";")] + else: + parts = [c.strip() for c in line.split(",")] + parts = [p.strip().strip('"').strip() for p in parts] + while parts and not parts[-1]: + parts.pop() + if not parts: + continue + low = [p.lower() for p in parts] + if i == 1 and low[:1] in (["building"], ["bldg"]): + continue # header row + if len(parts) > len(LOCATION_LEVELS): + rejected.append({"line": i, "text": line, + "reason": "more than 3 columns — expected building, floor, sector"}) + continue + if not parts[0]: + rejected.append({"line": i, "text": line, + "reason": "no building — a floor or sector needs one above it"}) + continue + # A gap in the middle ("B100,,1P") would attach a sector to nothing. + gap = next((n for n, p in enumerate(parts) if not p), None) + if gap is not None and any(parts[gap + 1:]): + rejected.append({"line": i, "text": line, + "reason": "a %s is named with no %s above it" + % (LOCATION_LEVELS[len(parts) - 1], LOCATION_LEVELS[gap])}) + continue + parts = [p for p in parts if p] + if any(not location_slug(p) for p in parts): + rejected.append({"line": i, "text": line, + "reason": "no letters or digits to make a code from"}) + continue + rows.append((i, parts)) + return rows, rejected + + +def _location_tree(db: Session, project_id: str, include_inactive: bool): + stmt = select(models.LocationNode).where(models.LocationNode.project_id == project_id) + if not include_inactive: + stmt = stmt.where(models.LocationNode.active.is_(True)) + nodes = db.scalars(stmt.order_by(models.LocationNode.path)).all() + return nodes + + +@app.get("/api/projects/{project_id}/locations") +def list_locations(project_id: str, + include_inactive: bool = Query(False), + user: models.User = Depends(auth.get_current_user), + db: Session = Depends(get_db)): + """The project's taxonomy, flat, ordered by path so a caller can rebuild the + tree without a second query. + + `include_inactive` defaults to FALSE, which is what makes a deactivated value + disappear from new work packages. It is available as true because a package + that already references a deactivated value still has to render its label — + deactivating hides a choice, it does not rewrite history.""" + if not db.get(models.Project, project_id): + raise HTTPException(status_code=404, detail="Project not found") + require_project_access(db, user, project_id) + nodes = _location_tree(db, project_id, include_inactive) + return { + "project_id": project_id, + "levels": list(LOCATION_LEVELS), + "nodes": [n.to_dict() for n in nodes], + "counts": {lvl: sum(1 for n in nodes if n.level == lvl) for lvl in LOCATION_LEVELS}, + "generated_at": models.utcnow().isoformat(), + } + + +class LocationImportIn(BaseModel): + text: str = "" + dry_run: bool = False + + +@app.post("/api/projects/{project_id}/locations/import") +def import_locations(project_id: str, body: LocationImportIn, + user: models.User = Depends(auth.get_current_user), + db: Session = Depends(get_db)): + """Bulk import from CSV or a paste. Same code path for both: a file is read in + the browser and posted as text, because two parsers would be two sets of rules + about what a blank column means. + + Reports rather than merges. A row naming a building/floor/sector combination + that already exists — in this file or in the project — comes back in + `duplicates` with its line number. Reusing a PARENT is not a duplicate: + `B100,L2,1P` and `B100,L2,2P` share a building and a floor by design, and it + is only the full path repeating that is a duplicate. + + `dry_run` parses and reports without writing, which is what lets the wizard + show what an import will do before it does it.""" + if not db.get(models.Project, project_id): + raise HTTPException(status_code=404, detail="Project not found") + # Any project member, matching how the SOP baseline itself is authored — the + # Project Admin gate is on CHANGING a completed SOP, not on writing one. + require_project_access(db, user, project_id) + require_project_writable(db, user, project_id, "The location list cannot be changed") + + rows, rejected = parse_location_rows(body.text) + + existing = {n.path: n for n in db.scalars( + select(models.LocationNode).where(models.LocationNode.project_id == project_id) + ).all()} + before = set(existing) + created: list[dict] = [] + duplicates: list[dict] = [] + reactivated: list[dict] = [] + seen_in_file: dict[str, int] = {} + next_sort = max((n.sort for n in existing.values()), default=0) + + for line_no, parts in rows: + segs = [location_slug(p) for p in parts] + full = "/".join(segs) + if full in seen_in_file: + duplicates.append({"line": line_no, "path": full, "names": parts, + "reason": "already on line %d of this import" % seen_in_file[full]}) + continue + seen_in_file[full] = line_no + if full in before: + node = existing[full] + if not node.active: + # Re-importing a value somebody deactivated is a request to bring it + # back, not a duplicate — and it must reuse the SAME row, or every + # work package pointing at the old path is orphaned. + if not body.dry_run: + node.active = True + reactivated.append({"path": full, "names": parts}) + else: + duplicates.append({"line": line_no, "path": full, "names": parts, + "reason": "already in this project"}) + continue + # Create any missing ancestors, then the leaf. Sharing a parent is the + # normal case, not a collision. + parent_id = None + for depth, seg in enumerate(segs): + sub = "/".join(segs[:depth + 1]) + node = existing.get(sub) + if node is None: + next_sort += 1 + node = models.LocationNode( + id=gen_id("loc"), project_id=project_id, parent_id=parent_id, + level=LOCATION_LEVELS[depth], code=seg, path=sub, + name=parts[depth], active=True, sort=next_sort, + created_by=user.username, + ) + existing[sub] = node + if not body.dry_run: + db.add(node) + if sub not in before: + created.append({"path": sub, "level": LOCATION_LEVELS[depth], + "code": seg, "name": parts[depth]}) + parent_id = node.id + + result = { + "project_id": project_id, "dry_run": bool(body.dry_run), + "read": len(rows) + len(rejected), + "created": created, "duplicates": duplicates, + "reactivated": reactivated, "rejected": rejected, + } + if body.dry_run: + db.rollback() + return result + log_event(db, user, "locations_imported", "project", project_id, project_id, + summary="%d location value(s) added" % len(created), + detail={"created": len(created), "duplicates": len(duplicates), + "rejected": len(rejected), "reactivated": len(reactivated)}) + db.commit() + return result + + +class MaterialIn(BaseModel): + description: str = "" + unit: str = "" + code: str = "" + + +class MaterialPatchIn(BaseModel): + description: Optional[str] = None + unit: Optional[str] = None + code: Optional[str] = None + active: Optional[bool] = None + + +class MaterialImportIn(BaseModel): + text: str = "" + dry_run: bool = False + + +class LocationIn(BaseModel): + level: str = "building" + parent_id: Optional[str] = None + name: str = "" + + +@app.post("/api/projects/{project_id}/locations") +def add_location(project_id: str, body: LocationIn, + user: models.User = Depends(auth.get_current_user), + db: Session = Depends(get_db)): + """Add one value by hand. Same rules as the import — an import you cannot + correct afterwards is an import nobody trusts enough to run.""" + if not db.get(models.Project, project_id): + raise HTTPException(status_code=404, detail="Project not found") + # Any project member, matching how the SOP baseline itself is authored — the + # Project Admin gate is on CHANGING a completed SOP, not on writing one. + require_project_access(db, user, project_id) + require_project_writable(db, user, project_id, "The location list cannot be changed") + + name = (body.name or "").strip() + code = location_slug(name) + if not code: + raise HTTPException(status_code=400, + detail="That name has no letters or digits to make a code from") + level = (body.level or "").strip().lower() + if level not in LOCATION_LEVELS: + raise HTTPException(status_code=400, + detail="Level must be one of: " + ", ".join(LOCATION_LEVELS)) + depth = LOCATION_LEVELS.index(level) + parent = None + if body.parent_id: + parent = db.get(models.LocationNode, body.parent_id) + if not parent or parent.project_id != project_id: + raise HTTPException(status_code=400, detail="Parent is not on this project") + if depth == 0 and parent is not None: + raise HTTPException(status_code=400, detail="A building has nothing above it") + if depth > 0 and parent is None: + raise HTTPException(status_code=400, + detail="A %s needs a %s above it" % (level, LOCATION_LEVELS[depth - 1])) + if parent is not None and LOCATION_LEVELS.index(parent.level) != depth - 1: + raise HTTPException(status_code=400, + detail="A %s cannot sit under a %s" % (level, parent.level)) + + path = (parent.path + "/" + code) if parent else code + clash = db.scalars(select(models.LocationNode).where( + (models.LocationNode.project_id == project_id) & (models.LocationNode.path == path) + )).first() + if clash: + if not clash.active: + clash.active = True + log_event(db, user, "location_reactivated", "project", project_id, project_id, + summary=path) + db.commit() + return clash.to_dict() + raise HTTPException(status_code=409, detail="“%s” is already on this project" % name) + + top = db.scalar(select(func.max(models.LocationNode.sort)).where( + models.LocationNode.project_id == project_id)) or 0 + node = models.LocationNode( + id=gen_id("loc"), project_id=project_id, parent_id=(parent.id if parent else None), + level=level, code=code, path=path, name=name, active=True, sort=top + 1, + created_by=user.username, + ) + db.add(node) + log_event(db, user, "location_added", "project", project_id, project_id, summary=path) + db.commit() + return node.to_dict() + + +class LocationPatch(BaseModel): + name: Optional[str] = None + active: Optional[bool] = None + + +@app.patch("/api/projects/{project_id}/locations/{node_id}") +def update_location(project_id: str, node_id: str, body: LocationPatch, + user: models.User = Depends(auth.get_current_user), + db: Session = Depends(get_db)): + """Rename or deactivate. There is no DELETE, and that is the design: + + rename changes `name` only. `code` and `path` are untouched, so every + work package pointing at this value keeps pointing at it. That + is the whole reason the two are separate columns. + deactivate hides the value from new work packages. Cascades DOWN — a floor + nobody can pick makes its sectors unpickable too, and leaving + them offered would be offering a path to nowhere. Reactivating + a child reactivates its ancestors for the same reason. + """ + if not db.get(models.Project, project_id): + raise HTTPException(status_code=404, detail="Project not found") + # Any project member, matching how the SOP baseline itself is authored — the + # Project Admin gate is on CHANGING a completed SOP, not on writing one. + require_project_access(db, user, project_id) + require_project_writable(db, user, project_id, "The location list cannot be changed") + node = db.get(models.LocationNode, node_id) + if not node or node.project_id != project_id: + raise HTTPException(status_code=404, detail="Location not found on this project") + + changed = {} + if body.name is not None: + name = body.name.strip() + if not name: + raise HTTPException(status_code=400, detail="A name cannot be empty") + if name != node.name: + changed["name"] = {"from": node.name, "to": name} + node.name = name + if body.active is not None and bool(body.active) != bool(node.active): + changed["active"] = {"from": bool(node.active), "to": bool(body.active)} + node.active = bool(body.active) + if not node.active: + for child in db.scalars(select(models.LocationNode).where( + (models.LocationNode.project_id == project_id) + & (models.LocationNode.path.like(node.path + "/%")) + )).all(): + child.active = False + else: + # Walk up by path rather than by parent_id: one query, and it cannot + # loop on a malformed chain. + segs = node.path.split("/") + ancestors = ["/".join(segs[:n]) for n in range(1, len(segs))] + if ancestors: + for anc in db.scalars(select(models.LocationNode).where( + (models.LocationNode.project_id == project_id) + & (models.LocationNode.path.in_(ancestors)) + )).all(): + anc.active = True + + if changed: + log_event(db, user, "location_updated", "project", project_id, project_id, + summary=node.path, detail=changed) + db.commit() + return node.to_dict() + + +@app.get("/api/projects/{project_id}/summary") +def project_summary(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): + """What the launcher needs to describe a project without asking the browser + what it remembers (B4). + + The launcher used to read `wp_suite_sop_complete` out of localStorage, which is + a per-browser mirror: a colleague completing the SOP on their machine left your + card saying "Complete SOP first" with nothing to indicate the answer was stale.""" + proj = db.get(models.Project, project_id) + if not proj: + raise HTTPException(status_code=404, detail="Project not found") + require_project_access(db, user, project_id) + + sop = db.scalars( + select(models.Sop) + .where(models.Sop.project_id == project_id, models.Sop.complete.is_(True)) + .order_by(models.Sop.updated_at.desc()) + .limit(1) + ).first() + + wp_total = db.scalar( + select(func.count()).select_from(models.WorkPackage).where( + models.WorkPackage.project_id == project_id, + models.WorkPackage.archived_at.is_(None), + ) + ) or 0 + + return { + "project_id": project_id, + "project_name": proj.name, + "sop_complete": sop is not None, + "sop_id": sop.id if sop else None, + "sop_name": (sop.name if sop else "") or "", + "sop_updated_at": models._iso(sop.updated_at) if sop else None, + "wp_total": wp_total, + "generated_at": models.utcnow().isoformat(), } @@ -1462,23 +2724,63 @@ def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: @app.post("/api/wps/{wp_id}/status") -def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): +def set_wp_status(wp_id: str, body: StatusIn, background_tasks: BackgroundTasks, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): wp = db.get(models.WorkPackage, wp_id) if not wp: raise HTTPException(status_code=404, detail="Work Package not found") require_project_access(db, user, wp.project_id) require_project_writable(db, user, wp.project_id, "Changing a work package's status") old_status = wp.status + _qa_notifs = [] # Same gates as /issue — this route must not be a way around them. enforce_release_gates(db, wp.id, wp.data, body.status, old_status) + if old_status == QA_READY_STATUS and body.status == "In Progress": + comment = str(body.comment or "").strip() + if not comment: + raise HTTPException(status_code=409, detail={ + "message": "Returning a package from Ready for QA requires a comment", + }) + data = dict(wp.data or {}) + rejs = [r for r in (data.get("qaRejections") or []) if isinstance(r, dict)] + rejs.append({"ts": models.utcnow().isoformat(), "comment": comment, + "by": user.full_name or user.username, "from": QA_READY_STATUS}) + data["qaRejections"] = rejs + wp.data = data wp.status = body.status if body.status == "Issued" and wp.issued_at is None: wp.issued_at = models.utcnow() if old_status != body.status: log_event(db, user, "status_changed", "wp", wp.id, project_id=wp.project_id, summary=(wp.number or wp.subject or wp.id), detail={"from": old_status, "to": body.status}) + if "Issue" in (old_status, body.status): + _hlds = [h for h in ((wp.data or {}).get("holds") or []) if isinstance(h, dict)] + if body.status == "Issue": + _h = next((h for h in reversed(_hlds) if not h.get("released")), None) + log_event(db, user, "hold_logged", "wp", wp.id, project_id=wp.project_id, + summary=(wp.number or wp.subject or wp.id), + detail={"from": old_status, + "constraint": str((_h or {}).get("constraint") or "")[:200], + "reason": str((_h or {}).get("details") or "")[:300]}) + else: + _h = next((h for h in reversed(_hlds) if h.get("released")), None) + log_event(db, user, "hold_released", "wp", wp.id, project_id=wp.project_id, + summary=(wp.number or wp.subject or wp.id), + detail={"to": body.status, + "constraint": str((_h or {}).get("constraint") or "")[:200], + "reason": str((_h or {}).get("details") or "")[:300]}) + if body.status == QA_READY_STATUS: + log_event(db, user, "qa_ready", "wp", wp.id, project_id=wp.project_id, + summary=(wp.number or wp.subject or wp.id), detail={"from": old_status}) + _qa_notifs = notify_qa_transition(db, wp, user, rejected=False) + elif old_status == QA_READY_STATUS and body.status == "In Progress": + log_event(db, user, "qa_rejected", "wp", wp.id, project_id=wp.project_id, + summary=(wp.number or wp.subject or wp.id), + detail={"comment": qa_rejection_comment(wp.data)[:300]}) + _qa_notifs = notify_qa_transition(db, wp, user, rejected=True) db.commit() db.refresh(wp) + for n in _qa_notifs: + background_tasks.add_task(notify.deliver, n.id) return wp.to_dict() @@ -1508,6 +2810,286 @@ def archive_wp(wp_id: str, body: ArchiveIn, user: models.User = Depends(auth.get # ── Audit trail (history) ────────────────────────────────────────────────────── +# ── Drawing uploads (CR-007 / D8) ────────────────────────────────────────────── +def project_storage_used(db: Session, project_id: str) -> int: + return int(db.scalar( + select(func.coalesce(func.sum(models.WpFile.size), 0)) + .where(models.WpFile.project_id == project_id)) or 0) + + +def _wp_files_meta(db: Session, wp_id: str) -> list[dict]: + rows = db.scalars(select(models.WpFile).where(models.WpFile.wp_id == wp_id) + .order_by(models.WpFile.created_at)).all() + return [r.to_dict() for r in rows] + + +def _sync_wp_files(db: Session, wp: "models.WorkPackage") -> None: + """Mirror the meta list into data["files"] - server-owned, so exports and the + offline cache read it straight off the package record.""" + data = dict(wp.data or {}) + data["files"] = _wp_files_meta(db, wp.id) + wp.data = data + + +@app.get("/api/projects/{project_id}/storage") +def project_storage(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): + require_project_access(db, user, project_id) + used = project_storage_used(db, project_id) + return {"used": used, "ceiling": FILE_PROJECT_CEILING, + "warn_at": int(FILE_PROJECT_CEILING * 0.8)} + + +@app.get("/api/wps/{wp_id}/files") +def list_wp_files(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): + wp = db.get(models.WorkPackage, wp_id) + if not wp: + raise HTTPException(status_code=404, detail="Work Package not found") + require_project_access(db, user, wp.project_id) + used = project_storage_used(db, wp.project_id or "") + return {"files": _wp_files_meta(db, wp_id), "used": used, + "ceiling": FILE_PROJECT_CEILING} + + +@app.post("/api/wps/{wp_id}/files") +def upload_wp_file(wp_id: str, body: FileUploadIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): + """D8, enforced HERE, not only in the browser: 5MB a file, PDF or image, and + a per-project ceiling (2GB by default) that refuses by NAME when reached.""" + wp = db.get(models.WorkPackage, wp_id) + if not wp: + raise HTTPException(status_code=404, detail="Work Package not found") + require_project_access(db, user, wp.project_id) + require_project_writable(db, user, wp.project_id, "Uploading a drawing") + mime = (body.mime or "").strip().lower() + if not FILE_ALLOWED_MIME_RE.match(mime): + raise HTTPException(status_code=400, detail={ + "message": "Only PDF and image files are accepted", "mime": mime}) + try: + raw = base64.b64decode(body.data_base64 or "", validate=True) + except Exception: + raise HTTPException(status_code=400, detail="File data is not valid base64") + if not raw: + raise HTTPException(status_code=400, detail="The file is empty") + if len(raw) > FILE_MAX_BYTES: + raise HTTPException(status_code=413, detail={ + "message": "Files are limited to 5MB each", "size": len(raw), + "limit": FILE_MAX_BYTES}) + used = project_storage_used(db, wp.project_id or "") + if used + len(raw) > FILE_PROJECT_CEILING: + raise HTTPException(status_code=413, detail={ + "message": f"This project's drawing storage is full ({FILE_PROJECT_CEILING} bytes). " + "Remove an old drawing to make room.", + "used": used, "ceiling": FILE_PROJECT_CEILING}) + row = models.WpFile( + id=gen_id("file"), wp_id=wp.id, project_id=wp.project_id or "", + name=(body.name or "drawing")[:300], mime=mime, size=len(raw), + description=(body.description or "")[:500], data=raw, + uploaded_by=user.full_name or user.username) + db.add(row) + db.flush() + _sync_wp_files(db, wp) + log_event(db, user, "file_uploaded", "wp", wp.id, project_id=wp.project_id, + summary=(wp.number or wp.subject or wp.id), + detail={"file": row.name, "size": row.size, "mime": row.mime}) + db.commit() + used = project_storage_used(db, wp.project_id or "") + return {"file": row.to_dict(), "used": used, "ceiling": FILE_PROJECT_CEILING, + "warn": used >= int(FILE_PROJECT_CEILING * 0.8)} + + +@app.get("/api/files/{file_id}") +def get_wp_file(file_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): + row = db.get(models.WpFile, file_id) + if not row: + raise HTTPException(status_code=404, detail="File not found") + require_project_access(db, user, row.project_id) + return Response(content=row.data, media_type=row.mime or "application/octet-stream", + headers={"Content-Disposition": + f'inline; filename="{(row.name or "file").replace(chr(34), "")}"', + "Cache-Control": "private, max-age=86400"}) + + +@app.patch("/api/files/{file_id}") +def patch_wp_file(file_id: str, body: FileDescIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): + row = db.get(models.WpFile, file_id) + if not row: + raise HTTPException(status_code=404, detail="File not found") + require_project_access(db, user, row.project_id) + require_project_writable(db, user, row.project_id, "Editing a drawing description") + row.description = (body.description or "")[:500] + wp = db.get(models.WorkPackage, row.wp_id) + if wp: + _sync_wp_files(db, wp) + db.commit() + return row.to_dict() + + +@app.delete("/api/files/{file_id}") +def delete_wp_file(file_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): + row = db.get(models.WpFile, file_id) + if not row: + raise HTTPException(status_code=404, detail="File not found") + require_project_access(db, user, row.project_id) + require_project_writable(db, user, row.project_id, "Removing a drawing") + wp = db.get(models.WorkPackage, row.wp_id) + log_event(db, user, "file_deleted", "wp", row.wp_id, project_id=row.project_id, + summary=(wp.number if wp else row.wp_id), + detail={"file": row.name, "size": row.size}) + db.delete(row) + db.flush() + if wp: + _sync_wp_files(db, wp) + db.commit() + used = project_storage_used(db, row.project_id) + return {"deleted": file_id, "used": used, "ceiling": FILE_PROJECT_CEILING} + + +# ── Project material list (D6 / T8.6) - the CR-005 pattern, for materials ───── +def parse_material_rows(text: str): + """description[,unit[,code]] per row - comma, semicolon or tab separated, a + paste straight out of a spreadsheet. A header row is ignored. Rejections come + back with the SOURCE line number: an import that says '42 rows' over a file + with 50 has lost eight and told nobody.""" + rows, rejected = [], [] + header_words = ("description", "desc", "item", "material") + for i, raw in enumerate((text or "").split(chr(10)), start=1): + # rstrip only: a LEADING separator means the first column is empty, and + # the first column is the description - eating it would accept ",FT" as + # a material named FT (found by the probe on the first run). + line = raw.strip().rstrip(",;") + if not line: + continue + parts = [p.strip() for p in re.split(r"[,;\t]", line)] + if i == 1 and parts and parts[0].lower() in header_words: + continue + parts = [p for p in parts] + if not parts or not parts[0]: + rejected.append({"line": i, "text": raw.strip()[:120], + "reason": "no description in the first column"}) + continue + if len(parts) > 3: + rejected.append({"line": i, "text": raw.strip()[:120], + "reason": "more than three columns - description, unit, code is the whole shape"}) + continue + rows.append((i, parts)) + return rows, rejected + + +def material_key(desc: str, code: str) -> str: + return (code or "").strip().lower() or location_slug(desc).lower() + + +@app.get("/api/projects/{project_id}/materials") +def list_materials(project_id: str, include_inactive: bool = Query(False), + user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): + require_project_access(db, user, project_id) + stmt = select(models.MaterialItem).where(models.MaterialItem.project_id == project_id) + if not include_inactive: + stmt = stmt.where(models.MaterialItem.active.is_(True)) + rows = db.scalars(stmt.order_by(models.MaterialItem.sort, models.MaterialItem.description)).all() + return {"items": [r.to_dict() for r in rows]} + + +@app.post("/api/projects/{project_id}/materials/import") +def import_materials(project_id: str, body: MaterialImportIn, + user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): + if not db.get(models.Project, project_id): + raise HTTPException(status_code=404, detail="Project not found") + require_project_access(db, user, project_id) + require_project_writable(db, user, project_id, "The material list cannot be changed") + rows, rejected = parse_material_rows(body.text) + existing = {material_key(r.description, r.code): r for r in db.scalars( + select(models.MaterialItem).where(models.MaterialItem.project_id == project_id)).all()} + created, duplicates, reactivated = [], [], [] + seen_in_file = {} + next_sort = max((r.sort for r in existing.values()), default=0) + for line_no, parts in rows: + desc = parts[0][:300] + unit = (parts[1] if len(parts) > 1 else "")[:20].upper() + code = (parts[2] if len(parts) > 2 else "")[:80] + key = material_key(desc, code) + if key in seen_in_file: + duplicates.append({"line": line_no, "text": desc, + "reason": "already on line %d of this import" % seen_in_file[key]}) + continue + seen_in_file[key] = line_no + if key in existing: + row = existing[key] + if not row.active: + if not body.dry_run: + row.active = True + reactivated.append({"text": desc}) + else: + duplicates.append({"line": line_no, "text": desc, + "reason": "already on this project"}) + continue + next_sort += 1 + created.append({"description": desc, "unit": unit, "code": code}) + if not body.dry_run: + item = models.MaterialItem(id=gen_id("mat"), project_id=project_id, + code=code, description=desc, unit=unit, + active=True, sort=next_sort) + db.add(item) + existing[key] = item + if not body.dry_run: + log_event(db, user, "materials_imported", "project", project_id, + project_id=project_id, summary="material list", + detail={"created": len(created), "rejected": len(rejected)}) + db.commit() + return {"read": len(rows) + len(rejected), "created": created, + "rejected": rejected, "duplicates": duplicates, + "reactivated": reactivated, "dry_run": body.dry_run} + + +@app.post("/api/projects/{project_id}/materials") +def add_material(project_id: str, body: MaterialIn, + user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): + if not db.get(models.Project, project_id): + raise HTTPException(status_code=404, detail="Project not found") + require_project_access(db, user, project_id) + require_project_writable(db, user, project_id, "The material list cannot be changed") + desc = (body.description or "").strip() + if not desc: + raise HTTPException(status_code=400, detail="A description is required.") + key = material_key(desc, body.code) + clash = [r for r in db.scalars(select(models.MaterialItem) + .where(models.MaterialItem.project_id == project_id)).all() + if material_key(r.description, r.code) == key] + if clash: + raise HTTPException(status_code=409, detail="That material is already on this project.") + next_sort = (db.scalar(select(func.coalesce(func.max(models.MaterialItem.sort), 0)) + .where(models.MaterialItem.project_id == project_id)) or 0) + 1 + item = models.MaterialItem(id=gen_id("mat"), project_id=project_id, + code=(body.code or "").strip()[:80], + description=desc[:300], + unit=(body.unit or "").strip()[:20].upper(), + active=True, sort=next_sort) + db.add(item) + db.commit() + return item.to_dict() + + +@app.patch("/api/projects/{project_id}/materials/{item_id}") +def patch_material(project_id: str, item_id: str, body: MaterialPatchIn, + user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): + row = db.get(models.MaterialItem, item_id) + if not row or row.project_id != project_id: + raise HTTPException(status_code=404, detail="Material not found") + require_project_access(db, user, project_id) + require_project_writable(db, user, project_id, "The material list cannot be changed") + if body.description is not None: + row.description = body.description.strip()[:300] + if body.unit is not None: + row.unit = body.unit.strip()[:20].upper() + if body.code is not None: + row.code = body.code.strip()[:80] + if body.active is not None: + # Deactivate, never delete - a request already referencing the line must + # keep rendering it (the CR-005 rule, applied to materials). + row.active = bool(body.active) + db.commit() + return row.to_dict() + + @app.get("/api/audit") def list_audit( entity_type: Optional[str] = Query(None), @@ -1810,6 +3392,29 @@ def list_comments( return [c.to_dict() for c in rows] +# ── Micron asset catalog (read-only lookup) ──────────────────────────────────── +# Backs the asset picker in the work package creator. This is a *lookup*, not a +# resource this app owns: there is no POST, and nothing here ever writes to the +# Micron database. It is deliberately not project-scoped by the app's own access +# rules — the catalog is reference data, and any signed-in user who can build a +# work package needs to be able to name the assets it covers. Authentication is +# still required (the auth_gate middleware covers every /api/ path). +@app.get("/api/assets") +def list_assets(_user: models.User = Depends(auth.get_current_user)): + """The whole catalog, fetched once when the creator loads. Searching happens + in the browser — there is no per-keystroke endpoint by design.""" + if not assets_db.configured(): + # Not an error — the suite is designed to run without Micron wired up. + # The picker reads this and switches to manual entry. + return {"configured": False, "assets": [], "detail": assets_db.status()["detail"]} + try: + return {"configured": True, "assets": assets_db.load()} + except assets_db.AssetSourceError as exc: + # 503, not 500: the suite is healthy, its upstream lookup is not. The + # picker degrades to manual entry rather than blocking the package. + raise HTTPException(status_code=503, detail=str(exc)) + + # ── Local dev convenience: serve the static site from this app ────────────────── # In production NGINX serves html/ and only proxies /api/ here, so this app never # receives "/" requests, and the api Docker image doesn't even include html/ — so diff --git a/server/assets_db.py b/server/assets_db.py new file mode 100644 index 0000000..76b0e15 --- /dev/null +++ b/server/assets_db.py @@ -0,0 +1,246 @@ +"""Read-only reader for the Micron asset catalog. + +The work package creator used to ask people to paste a controls.dev link for +every asset. Assets actually live in the Micron database — a SQL Server instance +that is NOT part of this repo and whose schema is not managed here. This module +gives the API a *read-only* window onto it so the creator can offer a searchable +picker instead of free-text links. + +How it works: the whole catalog is fetched in one query and handed to the browser +when the creator loads. Searching then happens in the browser with no round trip +at all. The catalog is a list of asset IDs — about 9k of them today and not +expected past 100k — so it is small enough to send whole, and it is slow-moving +reference data, so there is nothing to gain from querying it per keystroke and a +lot of latency to lose. A short server-side cache keeps a room full of people +opening the page from turning into a query each. + +Other deliberate constraints: + + * **Read-only, always.** The only statement in this file is the SELECT below. + Point it at a login with `db_datareader` and nothing else. + * **No prime_db dependency.** A plain SQLAlchemy connection built from a + connection string, kept separate from the app's own engine in `db.py`, so a + Micron outage can never affect the suite's own database. + +Unconfigured is a first-class state: with no `MICRON_DB_URL` set, `configured()` +returns False, the API says so, and the UI falls back to manual entry. The suite +boots and runs fine without the Micron database being reachable. +""" +import os +import time +import logging +import threading + +from sqlalchemy import create_engine, text +from sqlalchemy.exc import SQLAlchemyError + +log = logging.getLogger(__name__) + +try: + from dotenv import load_dotenv + load_dotenv() +except Exception: + pass + + +# ── The query ───────────────────────────────────────────────────────────────── +# The only place the Micron schema appears; everything else here is plumbing. +# Returns one row per asset, aliased `tag`. No row cap: the catalog is small +# enough to hand over whole, and a partial list would silently hide assets. +# +# Add a WHERE clause here if some rows should never be offered at all +# (decommissioned assets, other sites, …). Filtering at the source keeps the +# payload small, which matters more than anything else here. +ASSET_QUERY = """ + SELECT a.AssetID AS tag + FROM Asset.Asset AS a + ORDER BY a.AssetID +""" + +def _env_int(name: str, default: int) -> int: + """A malformed tuning knob degrades to its default; it must never keep the + suite from booting. app.py imports this module unconditionally, so a bare + int() here would turn "300s" in someone's .env into a crash-looping API - + the total-outage switch an OPTIONAL feature is not allowed to own.""" + raw = os.getenv(name, "") + try: + return int(raw.strip()) if raw.strip() else default + except ValueError: + log.warning("%s=%r is not an integer; using the default %d.", name, raw, default) + return default + + +# How long a fetched catalog is reused before the next page load re-queries. +CACHE_SECONDS = _env_int("MICRON_ASSETS_CACHE_SECONDS", 300) # 5 min +# How long a FAILURE is remembered before the next request retries the source. +# Without this, every page load during a Micron outage spends CONNECT_TIMEOUT +# seconds inside a worker thread; enough concurrent loads exhaust the app's +# shared sync threadpool and take unrelated endpoints down with the picker. +FAIL_CACHE_SECONDS = _env_int("MICRON_ASSETS_FAIL_CACHE_SECONDS", 30) +CONNECT_TIMEOUT = 8 + + +class AssetSourceError(RuntimeError): + """The catalog is configured but could not be read.""" + + +# ── Engine (lazy, process-wide) ─────────────────────────────────────────────── +# A full SQLAlchemy URL, e.g. +# mssql+pymssql://user:pass@host:1433/MicronDB +# mssql+pyodbc://user:pass@host/MicronDB?driver=ODBC+Driver+18+for+SQL+Server +# URL-encode any special characters in the password. +_engine = None +_engine_lock = threading.Lock() + + +def _db_url() -> str: + return os.getenv("MICRON_DB_URL", "").strip() + + +def configured() -> bool: + return bool(_db_url()) + + +def _validate_url(url: str) -> None: + """Catch the one URL mistake that produces a baffling error message. + + A password containing an unencoded '@' makes the URL ambiguous: the parser + splits on the first '@', so part of the password ends up parsed as the host. + The driver then reports a connection failure against a nonsense hostname that + happens to contain a fragment of the password — confusing to read and unsafe + to display. Detect it here and say plainly what is wrong. + + Nothing from the URL is included in the message; it never leaves this process. + """ + authority = url.split("://", 1)[-1].split("/", 1)[0] + if authority.count("@") > 1: + raise AssetSourceError( + "MICRON_DB_URL is ambiguous: the username or password contains an " + "unencoded '@'. Percent-encode the special characters — @ = %40, " + ": = %3A, / = %2F, # = %23, ? = %3F, % = %25." + ) + + +def _connect_args(url: str) -> dict: + """Per-driver connect timeouts, so an unreachable Micron host fails fast + instead of tying up a worker until the OS gives up.""" + if url.startswith("mssql+pymssql"): + return {"login_timeout": CONNECT_TIMEOUT, "timeout": CONNECT_TIMEOUT} + if url.startswith("mssql+pyodbc"): + return {"timeout": CONNECT_TIMEOUT} + return {} + + +def _get_engine(): + global _engine + if _engine is not None: + return _engine + url = _db_url() + if not url: + raise AssetSourceError("The Micron DB is not configured.") + _validate_url(url) + with _engine_lock: + if _engine is None: + try: + _engine = create_engine( + url, + connect_args=_connect_args(url), + pool_pre_ping=True, # a recycled dead connection retries instead of erroring + pool_recycle=1800, + pool_size=1, # one catalog query now and then, not a workload + max_overflow=1, + future=True, + ) + except Exception as exc: # bad URL, missing driver package, … + # See the note on load() — the exception text can echo the + # connection string, so it is logged and not propagated. + log.error("Micron asset catalog: could not open the connection: %s", exc) + raise AssetSourceError( + "Could not open a connection to the Micron DB. " + "Check MICRON_DB_URL and the API log for the driver error." + ) from exc + return _engine + + +# ── Cache ───────────────────────────────────────────────────────────────────── +# Every page load asks for the whole catalog, so without this a shift change +# would be one full-table query per person. Held per worker process. +_cache: list[dict] | None = None +_cached_at = 0.0 +_error: str | None = None # negative cache: the last failure's user-safe text +_error_at = 0.0 +_cache_lock = threading.Lock() + + +def load(force: bool = False) -> list[dict]: + """Return the whole catalog as [{'tag': …}, …]. Never writes. + + Failures are handled in two tiers so a Micron outage stays the picker's + problem and never the suite's (the module contract above): + * a previously fetched catalog is served STALE - it is slow-moving + reference data, and old-but-real beats an error; + * with nothing to serve, the failure itself is cached for + FAIL_CACHE_SECONDS, so an outage costs one CONNECT_TIMEOUT per window + instead of one per page load stacking up in the shared threadpool.""" + global _cache, _cached_at, _error, _error_at + with _cache_lock: + if _cache is not None and not force and (time.monotonic() - _cached_at) < CACHE_SECONDS: + return _cache + if (_error is not None and not force + and (time.monotonic() - _error_at) < FAIL_CACHE_SECONDS + and _cache is None): + raise AssetSourceError(_error) + + try: + engine = _get_engine() + with engine.connect() as conn: + result = conn.execute(text(ASSET_QUERY)).mappings().all() + except AssetSourceError as exc: + # _get_engine already logged and sanitised; remember or stale-serve. + with _cache_lock: + if _cache is not None: + log.warning("Micron asset catalog unavailable; serving the cached " + "catalog (%d rows).", len(_cache)) + return _cache + _error, _error_at = str(exc), time.monotonic() + raise + except SQLAlchemyError as exc: + # The driver's message is NOT propagated. AssetSourceError text reaches the + # browser, and connection errors quote the host, the login, and — when the + # URL is malformed — fragments of the password. Operators get the detail + # from the API log, where it belongs; users get a message they can act on. + log.error("Micron asset catalog query failed: %s", exc) + msg = ("The Micron DB could not be read. Check that the host is " + "reachable, that the login has SELECT on the asset table, and that " + "ASSET_QUERY matches the real schema — the API log has the driver error.") + with _cache_lock: + if _cache is not None: + log.warning("Micron asset catalog unavailable; serving the cached " + "catalog (%d rows).", len(_cache)) + return _cache + _error, _error_at = msg, time.monotonic() + raise AssetSourceError(msg) from exc + + # Drop rows with no identifier — an asset with no tag is not selectable and + # would render as a blank line in the picker. + rows = [{"tag": str(r["tag"])} for r in result if r.get("tag") not in (None, "")] + with _cache_lock: + _cache, _cached_at = rows, time.monotonic() + _error = None + return rows + + +def status() -> dict: + """Describe the source for the UI, so it can explain itself rather than just + showing an empty dropdown.""" + if not configured(): + return { + "configured": False, "ok": False, "count": 0, + "detail": "The Micron DB is not configured — enter assets manually.", + } + try: + rows = load() + except AssetSourceError as exc: + return {"configured": True, "ok": False, "count": 0, "detail": str(exc)} + return {"configured": True, "ok": True, "count": len(rows), + "detail": f"{len(rows):,} asset IDs from the Micron DB."} diff --git a/server/auth.py b/server/auth.py index 6ddc778..dac4e8d 100644 --- a/server/auth.py +++ b/server/auth.py @@ -20,11 +20,21 @@ Permissions roles (`User.role`) — distinct from a person's job function on the project, which lives in `User.project_role` and grants nothing: • admin application administrator: user administration, app settings, and implicit access to every project. + • project_super_user + everything a project_admin may do, plus USER ADMINISTRATION + scoped to the projects they hold the role on: they create and + manage the accounts on their own jobs without an app admin + having to do it for them. They cannot reach app settings, and + they cannot create or alter an admin / super-user account. • project_admin within their assigned projects: may delete work packages, modify a SOP after it has been completed, and delete projects. • project_user normal member: creates and edits work packages, authors a SOP up to completion. May NOT delete WPs or change a completed SOP. +The user-administration SCOPE of a super user is worked out in server/app.py +(`managed_project_ids`, `manage_user_problem`), because it depends on project +membership rows — this module only decides which roles carry the power at all. + Password reset: a short-lived signed token (see `create_reset_token`) is emailed to the account's address. It is single-use by construction — it embeds the user's `token_version`, which is bumped when the password changes, so a used or @@ -56,14 +66,21 @@ RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60")) # ── permissions roles ───────────────────────────────────────────────────────── ROLE_ADMIN = "admin" +ROLE_PROJECT_SUPER = "project_super_user" ROLE_PROJECT_ADMIN = "project_admin" ROLE_PROJECT_USER = "project_user" -ROLES = (ROLE_ADMIN, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER) +# Ordered most- to least-privileged; the console renders dropdowns in this order. +ROLES = (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER) ROLE_LABELS = { ROLE_ADMIN: "Administrator", + ROLE_PROJECT_SUPER: "Project Super User", ROLE_PROJECT_ADMIN: "Project Admin", ROLE_PROJECT_USER: "Project User", } +# Roles that may be held ON A SINGLE PROJECT via ProjectMember.role, so someone can +# run the users on one job and be an ordinary member of the next. '' means "inherit +# the account's own role" and is always allowed alongside these. +PROJECT_SCOPED_ROLES = (ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER) # Job functions offered in the admin console. Free text underneath, so a project # can use a title that isn't on this list. PROJECT_ROLES = ( @@ -90,9 +107,19 @@ def is_admin(user: "models.User") -> bool: def is_project_admin(user: "models.User") -> bool: - """True for app admins and project admins — the two roles allowed to delete - work packages and change a completed SOP.""" - return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_ADMIN) + """True for the roles allowed to delete work packages and change a completed + SOP. A super user is a project admin with user administration on top, so it is + included here — never enumerate the two roles by hand.""" + return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN) + + + +# NOTE: "may this account administer users?" is deliberately NOT answered here. The +# super-user role can be held per project (ProjectMember.role), so the question needs +# membership rows to answer and lives in app.py — `is_user_manager` / +# `require_user_manager` / `managed_project_ids`. An account-role-only version of the +# same question used to exist here and silently disagreed with the scoped one, which +# locked per-project super users out of the routes they were entitled to. # Password policy (shared by the API and the CLI). MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12")) @@ -297,6 +324,8 @@ def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.Us return user + + # ── account helpers (shared by routes and the CLI) ────────────────────────────── def find_user(db: Session, username: str) -> Optional["models.User"]: """Look up by username, case-insensitively (also matches on email).""" diff --git a/server/db.py b/server/db.py index a47c283..5e1f602 100644 --- a/server/db.py +++ b/server/db.py @@ -12,7 +12,7 @@ Connection precedence: The schema is identical either way (SQLAlchemy handles dialect differences). """ import os -from sqlalchemy import create_engine, URL +from sqlalchemy import create_engine, event, URL from sqlalchemy.orm import sessionmaker, DeclarativeBase # Load a local .env if present (dev convenience). @@ -46,6 +46,25 @@ _is_sqlite = isinstance(DATABASE_URL, str) and DATABASE_URL.startswith("sqlite") connect_args = {"check_same_thread": False} if _is_sqlite else {} engine = create_engine(DATABASE_URL, connect_args=connect_args, pool_pre_ping=True, future=True) + +if _is_sqlite: + # SQLite ships with foreign keys DISABLED and the pragma is per-connection, so + # without this every `ondelete="CASCADE"` in models.py is silently a no-op on a + # dev database while working correctly on Postgres. That divergence is worse than + # it sounds: deleting a project left its SOPs, work packages and membership rows + # behind as orphans pointing at an id that no longer exists, and deleting a user + # left their project_members rows — and the smoke test's cascade assertion failed + # on dev while passing in production, which is the exact failure that makes a + # smoke test worth ignoring. + # + # Registered on the engine, not a session, because the pragma has to be set on + # each new DBAPI connection as the pool creates it. + @event.listens_for(engine, "connect") + def _sqlite_enforce_foreign_keys(dbapi_connection, _record): + cur = dbapi_connection.cursor() + cur.execute("PRAGMA foreign_keys=ON") + cur.close() + SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) diff --git a/server/manage_users.py b/server/manage_users.py index 3d2c7f6..08280b8 100644 --- a/server/manage_users.py +++ b/server/manage_users.py @@ -45,8 +45,12 @@ def _prompt_password(provided: str | None, username: str = "") -> str: def cmd_create(args, role: str | None = None) -> None: role = role or args.role - if role not in ("admin", "user"): - sys.exit("role must be 'admin' or 'user'") + # 'user' is the pre-roles spelling of 'project_user' and is still accepted so the + # documented one-liners keep working; anything else has to be a current role. + if role == "user": + role = auth.ROLE_PROJECT_USER + if role not in auth.ROLES: + sys.exit(f"role must be one of {', '.join(auth.ROLES)}") pw = _prompt_password(getattr(args, "password", None), args.username) with SessionLocal() as db: if auth.find_user(db, args.username): @@ -70,9 +74,10 @@ def cmd_list(args) -> None: if not rows: print("No users yet. Create one with: create-admin ") return - print(f"{'USERNAME':<24}{'ROLE':<8}{'ACTIVE':<8}{'NAME'}") + print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}") for u in rows: - print(f"{u.username:<24}{u.role:<8}{('yes' if u.is_active else 'no'):<8}{u.full_name}") + print(f"{u.username:<24}{auth.normalize_role(u.role):<20}" + f"{('yes' if u.is_active else 'no'):<8}{u.full_name}") def cmd_reset_password(args) -> None: @@ -113,7 +118,8 @@ def main() -> None: add_create("create-admin", "create an admin account") c = add_create("create", "create an account") - c.add_argument("--role", choices=["admin", "user"], default="user") + c.add_argument("--role", choices=list(auth.ROLES) + ["user"], default=auth.ROLE_PROJECT_USER, + help="permissions role ('user' is the legacy name for project_user)") sub.add_parser("list", help="list all accounts") diff --git a/server/models.py b/server/models.py index d2a65a1..f841809 100644 --- a/server/models.py +++ b/server/models.py @@ -9,10 +9,25 @@ The full client document for a SOP or WP is kept verbatim in a JSON `data` column, with the most-queried fields promoted to real columns for listing and filtering. IDs are short strings (client- or server-generated) so the browser can upsert without round-tripping a sequence. + +NO relationship() DECLARATIONS, ON PURPOSE — and one consequence to know about. +Every link here is a plain column plus a ForeignKey; nothing is navigable as +`project.work_packages`. Queries are explicit selects, which suits an API that +mostly reads one scoped list at a time and never wants a lazy load firing inside +a response. + +The consequence: SQLAlchemy's unit of work derives FLUSH ORDER from relationships, +not from ForeignKey metadata. With none declared it has no dependency edge to +follow, so if you add a parent and its child in the SAME flush it may emit the +child's INSERT first and the database will reject it. Both engines enforce foreign +keys (Postgres always; SQLite since db.py sets `PRAGMA foreign_keys=ON`), so this +is a real error, not a dev-only quirk. Call `db.flush()` after adding the parent — +see `create_user` in app.py, which creates an account and its ProjectMember rows +together. """ from datetime import datetime, timezone from typing import Optional -from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON, UniqueConstraint +from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON, LargeBinary, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from .db import Base @@ -132,7 +147,8 @@ class User(Base): Two independent notions of "role", deliberately separate: • role the PERMISSIONS role — what the account may do in the app. - 'admin' | 'project_admin' | 'project_user' (see auth.ROLES). + 'admin' | 'project_super_user' | 'project_admin' | + 'project_user' (see auth.ROLES). • project_role the person's JOB FUNCTION on the project (Project Manager, Superintendent, QA/QC, …). Carries no permissions; it's what the SOP team pickers and notification routing read. @@ -189,8 +205,11 @@ class ProjectMember(Base): entirely). One row per (user, project) pair. `role` is the permissions role ON THIS PROJECT: someone can be Project Admin on - one job and a normal Project User on another. Empty means "inherit the account's - own role" (User.role), which is how every existing row behaves.""" + one job and a normal Project User on another, or a Project Super User (who + administers that job's user accounts) on one job only. Empty means "inherit the + account's own role" (User.role), which is how every existing row behaves. + Values: '' | 'project_super_user' | 'project_admin' | 'project_user' + (auth.PROJECT_SCOPED_ROLES) — never 'admin', which is app-wide by definition.""" __tablename__ = "project_members" __table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),) @@ -205,6 +224,68 @@ class ProjectMember(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) +class LocationNode(Base): + """One value in a project's Building / Floor / Sector taxonomy — CR-005. + + The taxonomy differs per project. On Micron, floors within B100 behave like + separate buildings, so floor and sector are the unit of both execution and + cost tracking; on another job "building" may be the only level that means + anything. So it is configured once per SOP rather than hard-coded, and no + real-world floor name appears anywhere in this repository. + + CODES, NOT DISPLAY STRINGS. `CR-018` rolls cost up by these, and a rollup + keyed on a label breaks the day somebody fixes a typo in it. Two columns + carry that: + + code this node's own slug among its siblings, derived once from the name + it was imported with and then NEVER recomputed — renaming a node is + a display change, which is exactly what makes rename safe for the + work packages already pointing at it. + path the full slug path from the root, '/'-joined and unique per project + (`B100/L2/1P`). This is the grouping key and the value a work + package stores. + + DEACTIVATE, NEVER DELETE. `active=False` hides a value from new work + packages; every existing package referencing it still resolves its label, + because the row is still there. Same rule as `CR-002`/`CR-016`: removal is + expressed as a toggle, and the data is retained. + """ + __tablename__ = "location_nodes" + __table_args__ = ( + UniqueConstraint("project_id", "path", name="uq_location_path"), + ) + + LEVELS = ("building", "floor", "sector") + + id: Mapped[str] = mapped_column(String(40), primary_key=True) + project_id: Mapped[str] = mapped_column( + String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True + ) + # Self-reference by id. No ForeignKey to its own table for the same reason the + # rest of this file declares none — see the module docstring — and because a + # self-referential FK plus SQLite's deferred-constraint behaviour makes a bulk + # import fiddly for no gain. Orphans are prevented in the API, which is the + # only writer. + parent_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True) + level: Mapped[str] = mapped_column(String(20), default="building") # building | floor | sector + code: Mapped[str] = mapped_column(String(60), default="") # own slug + path: Mapped[str] = mapped_column(String(200), default="", index=True) # full slug path + name: Mapped[str] = mapped_column(String(200), default="") # display label + active: Mapped[bool] = mapped_column(Boolean, default=True) + sort: Mapped[int] = mapped_column(Integer, default=0) + created_by: Mapped[str] = mapped_column(String(200), default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + def to_dict(self) -> dict: + return { + "id": self.id, "project_id": self.project_id, "parent_id": self.parent_id, + "level": self.level, "code": self.code, "path": self.path, "name": self.name, + "active": bool(self.active), "sort": self.sort, + "created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at), + } + + class Comment(Base): __tablename__ = "comments" @@ -266,6 +347,63 @@ class AppSetting(Base): updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) +class MaterialItem(Base): + """One line of a project's material list - D6 / T8.6, the CR-005 call made + again: build the upload path now rather than wait for the master workbook. + Deliberately small: description, unit, an optional code. NO inventory level, + NO price, NO warehouse id - a project-scoped list the project uploaded is + not the deferred parts catalog, and the moment a stock count appears here it + has crossed the line IMPLEMENTATION.md section 7 draws. `active` rather than + delete, same as everything else: a request already referencing a line must + keep rendering it.""" + __tablename__ = "material_items" + + id: Mapped[str] = mapped_column(String(40), primary_key=True) + project_id: Mapped[str] = mapped_column(String(40), index=True) + code: Mapped[str] = mapped_column(String(80), default="") + description: Mapped[str] = mapped_column(String(300), default="") + unit: Mapped[str] = mapped_column(String(20), default="") + active: Mapped[bool] = mapped_column(Boolean, default=True) + sort: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + def to_dict(self) -> dict: + return {"id": self.id, "project_id": self.project_id, "code": self.code, + "description": self.description, "unit": self.unit, + "active": self.active, "sort": self.sort} + + +class WpFile(Base): + """CR-007 / D8: a drawing uploaded onto a work package. The BYTES live here, + in the same database as everything else - settled Aug 18: a backup that + excludes the drawings is a backup you cannot restore from. The limits are + the D8 numbers: 5MB a file, PDFs and images, 2GB per project (80% warning). + A meta copy (no bytes) is mirrored into the package's data["files"] by the + server so the list is exportable and readable offline; that key is + server-owned and survives client upserts.""" + __tablename__ = "wp_files" + + id: Mapped[str] = mapped_column(String(40), primary_key=True) + wp_id: Mapped[str] = mapped_column(String(40), index=True) + project_id: Mapped[str] = mapped_column(String(40), index=True) + name: Mapped[str] = mapped_column(String(300), default="") + mime: Mapped[str] = mapped_column(String(100), default="") + size: Mapped[int] = mapped_column(Integer, default=0) + description: Mapped[str] = mapped_column(String(500), default="") + data: Mapped[bytes] = mapped_column(LargeBinary, default=b"") + uploaded_by: Mapped[str] = mapped_column(String(120), default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + def to_dict(self) -> dict: + # Meta only - the bytes go through GET /api/files/{id}, never through JSON. + return { + "id": self.id, "wp_id": self.wp_id, "project_id": self.project_id, + "name": self.name, "mime": self.mime, "size": self.size, + "description": self.description, "uploaded_by": self.uploaded_by, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + + class Notification(Base): """Outbox for user notifications (an in-app record + an optional email). A row is written when something notable happens (e.g. a WP assignment); the email diff --git a/server/requirements.txt b/server/requirements.txt index 20f9b31..28ac028 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -9,6 +9,12 @@ gunicorn==26.0.0 sqlalchemy==2.0.51 alembic==1.18.5 # database migrations psycopg[binary]==3.3.4 +pymssql==2.3.13 # read-only lookups against the Micron asset DB (SQL Server). + # Chosen over pyodbc because it ships self-contained wheels — + # pyodbc would also need msodbcsql18 + unixODBC installed in + # the image. To use pyodbc instead, add it here, install the + # Microsoft ODBC driver in the Dockerfile, and switch + # MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server pydantic==2.13.4 python-dotenv==1.2.2 bcrypt==5.0.0 # password hashing diff --git a/server/seed_demo.py b/server/seed_demo.py index 76d67c1..5000b1c 100644 --- a/server/seed_demo.py +++ b/server/seed_demo.py @@ -7,31 +7,59 @@ a multi-discipline master with its split instances (A/B/C), an overdue package, and an over-threshold draft. Use it to prove the SQL + Python layer end-to-end and to have data to inspect. +Every /api/ route except /api/health requires a session, so this signs in first and +keeps the session cookie for the rest of the run — the same way server/smoketest.py +does, reusing its opener rather than growing a second implementation of it. +Credentials come from the environment so the password never has to appear in a +command line or shell history: + + export WP_SEED_USER= # or WP_SMOKE_USER, which is reused + export WP_SEED_PASSWORD='…' # or WP_SMOKE_PASSWORD + +…or pass --user / --password. Use an admin account: seeding creates a project, and +--clean deletes one, which needs Project Admin on it. + USAGE python3 server/seed_demo.py https://wp-suite.company.local --insecure docker compose exec api python /app/server/seed_demo.py http://localhost:8000 python3 server/seed_demo.py https://wp-suite.company.local --clean # remove DEMO-* projects -IMPORTANT — what shows where: - * The DEMO **project** is API/SQL-backed, so it appears in the home-page - project picker immediately (proves the projects → SQL path in the UI). - * The DEMO **SOP and Work Packages** are written to SQL too, but the current - front end still reads SOPs/WPs from the browser (localStorage), so they will - NOT render in the WP Creator / Dashboard yet — that's the pending Phase 2 - wiring. Verify them at the SQL/API layer instead: +WHAT SHOWS WHERE + Everything it writes is API/SQL-backed and renders in the UI: the project appears + in the home-page picker, and selecting it shows its Work Packages in the Field + View. Verified at T1.6 — 7 cards from a fresh seed. + + (This block used to warn that SOPs and Work Packages would NOT render because the + front end still read them from localStorage, pending "Phase 2 wiring". That + stopped being true when the sync layer landed, and the warning outlived it. If + you are checking whether seeding worked, the UI is now a fair test.) + + To check at the SQL/API layer instead: python3 server/smoketest.py # automated end-to-end check docker compose exec db psql -U wpsuite -d wpsuite \ -c "select number,subject,status from work_packages order by number;" """ import argparse import json +import os import ssl import sys import urllib.error import urllib.request +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# The session handling is smoketest.py's, imported rather than copied: one cookie +# jar implementation, one login flow, one place to fix. Importing is safe — that +# module does its work under `if __name__ == "__main__"`. +from smoketest import build_opener # noqa: E402 + BASE = "" CTX = None +# Carries the cookie jar holding the session issued by /api/auth/login. This +# script used to call urllib.request.urlopen() directly, which has no cookie +# support, so the session was dropped and every data route answered 401 (S13). +OPENER = None DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data @@ -41,7 +69,7 @@ def call(method, path, body=None): req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json", "Accept": "application/json"}) try: - with urllib.request.urlopen(req, context=CTX, timeout=20) as r: + with OPENER.open(req, timeout=20) as r: raw = r.read().decode(); status = r.status except urllib.error.HTTPError as e: raw = e.read().decode(); status = e.code @@ -52,6 +80,27 @@ def call(method, path, body=None): return status, parsed +def abort(msg, hint=""): + """Could not run, as distinct from ran and failed.""" + print("\nABORT " + msg) + if hint: + print(hint) + print() + return 2 + + +def expect(status, body, what): + """Stop on the first refused write with the status, instead of dying on a + KeyError three lines later. A 401 here used to surface as + `TypeError: 'NoneType' object is not subscriptable`, which reads like a broken + stack rather than a missing session.""" + if status not in (200, 201): + detail = body.get("detail") if isinstance(body, dict) else body + raise SystemExit(abort(f"{what} failed (HTTP {status}): {detail}", + " The account needs Project Admin to create and delete projects.")) + return body + + def constraints(open_names=()): base = ["Safety & Permitting", "Quality Control / Inspection", "IFC Drawings & Specs", "Schedule", "Materials (on site, bagged & tagged)", "Work Access & Laydown"] @@ -60,16 +109,36 @@ def constraints(open_names=()): def main(): - global BASE, CTX + global BASE, CTX, OPENER ap = argparse.ArgumentParser(description="Seed a demo project into the Work Package Suite") ap.add_argument("base_url", nargs="?", default="http://localhost:8000", help="Site root, no /api (default: http://localhost:8000)") ap.add_argument("--insecure", action="store_true", help="skip TLS verification") ap.add_argument("--clean", action="store_true", help="delete existing DEMO-* projects and exit") + ap.add_argument("--user", default=os.getenv("WP_SEED_USER", "") or os.getenv("WP_SMOKE_USER", ""), + help="account to sign in as (default: $WP_SEED_USER, then $WP_SMOKE_USER). " + "Use an admin account.") + ap.add_argument("--password", + default=os.getenv("WP_SEED_PASSWORD", "") or os.getenv("WP_SMOKE_PASSWORD", ""), + help="its password (default: $WP_SEED_PASSWORD, then $WP_SMOKE_PASSWORD — " + "preferred, so it stays out of shell history)") args = ap.parse_args() BASE = args.base_url.rstrip("/") if args.insecure: CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE + OPENER = build_opener(CTX) + + if not args.user or not args.password: + missing = " and ".join(n for n, v in (("WP_SEED_USER", args.user), + ("WP_SEED_PASSWORD", args.password)) if not v) + return abort( + f"no credentials — {missing} not set.", + " Every /api/ route except /api/health needs a session, so there is nothing\n" + " this can seed without one. Set them and re-run:\n\n" + " export WP_SEED_USER=\n" + " export WP_SEED_PASSWORD='…'\n\n" + " Or pass --user/--password. WP_SMOKE_USER / WP_SMOKE_PASSWORD are accepted\n" + " too, so one set of credentials serves this and smoketest.py.") # health gate try: @@ -79,6 +148,31 @@ def main(): if st != 200: print(f"ABORT: /api/health returned {st}"); return 1 + # Sign in. The cookie the response sets is held by OPENER's jar and rides every + # request after this one. + st, body = call("POST", "/api/auth/login", + {"username": args.user, "password": args.password}) + if st != 200: + detail = body.get("detail") if isinstance(body, dict) else body + hint = (" The account may be locked: the API locks an account for a while after a\n" + " few consecutive failures, so retrying with the wrong password makes this\n" + " worse. Check the password, then wait out the lockout window." + if st in (401, 403, 423, 429) else + " Unexpected status from the login endpoint — check the API logs.") + return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint) + logged_in = True + print(f"Signed in as {args.user}.") + + try: + return seed(args) + finally: + if logged_in: + try: call("POST", "/api/auth/logout") + except Exception: pass + + +def seed(args): + # --clean: remove any prior demo projects (cascade removes their SOP + WPs). # archived=all because /api/projects hides archived projects by default — an # archived DEMO project is still a DEMO project, and --clean has to find it. @@ -90,14 +184,22 @@ def main(): call("DELETE", f"/api/projects/{p['id']}") print(f"Removed {len(demos)} DEMO project(s).") return 0 + # Refuse rather than pile up a second identical DEMO project. This used to be a + # note that scrolled past, and running the script twice left two of everything + # with no way to tell them apart. if demos: - print(f"Note: {len(demos)} DEMO project(s) already exist. Run with --clean first to avoid duplicates.\n") + names = ", ".join(f"{p.get('number','?')} ({p.get('id','?')})" for p in demos[:5]) + print(f"\n{len(demos)} DEMO project(s) already exist: {names}") + print("Nothing was created. Remove them first, then re-run:\n") + print(f" python3 server/seed_demo.py {BASE} --clean\n") + return 1 # 1) Project st, proj = call("POST", "/api/projects", { "name": "DEMO — Micron INC (test data)", "number": DEMO_NUMBER, "client": "Micron Technology, Inc.", "division": "Semiconductor", "site": "Boise, ID — Fab", "created_by": "seed_demo"}) + expect(st, proj, "creating the DEMO project") pid = proj["id"] print(f"Project: {proj['name']} ({pid})") @@ -109,6 +211,7 @@ def main(): "disciplines": ["Mechanical", "Electrical", "Tech"], "discMode": "choice", "instanceSuffix": "letter", "woSize": "Standard — 3–5 days (≈40–80 hrs)", "sizeHoursMax": "80"}}}) + expect(st, sop, "creating the DEMO SOP") sid = sop["id"] print(f"SOP: complete ({sid})") diff --git a/server/smoketest.py b/server/smoketest.py index c4425af..ea29323 100644 --- a/server/smoketest.py +++ b/server/smoketest.py @@ -5,6 +5,24 @@ Exercises the real HTTP endpoints the way the front end does, proving that NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq. +AUTHENTICATION + Every /api/ route except /api/health requires a session (auth_gate in + server/app.py), so the script signs in first and keeps the session cookie for + the rest of the run. Credentials come from the environment by preference, so a + password never has to appear in a command line or shell history: + + export WP_SMOKE_USER=smoketest + export WP_SMOKE_PASSWORD='…' + python3 server/smoketest.py https://wp-suite.company.local + + …or pass --user / --password explicitly. + + Use an ADMIN account. The script creates a project and deletes it again at the + end, and deleting one takes Project Admin on that project (require_project_admin); + a plain project_user can create a project but not clean it up. The script checks + the signed-in role up front and warns if it is too low, rather than letting you + discover it in the cleanup step. + USAGE # Against the deployed site (through the NGINX proxy): python3 server/smoketest.py https://wp-suite.company.local @@ -13,16 +31,24 @@ USAGE python3 server/smoketest.py https://wp-suite.company.local --insecure # From inside the api container (hits FastAPI directly): - docker compose exec api python /app/server/smoketest.py http://localhost:8000 + docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \ + python /app/server/smoketest.py http://localhost:8000 # Leave the demo project in the database so you can open it in the UI: python3 server/smoketest.py https://wp-suite.company.local --keep The base URL is the SITE root (no /api). Default: http://localhost:8000 -Exit code 0 = all checks passed, 1 = one or more failed. + +Exit codes: 0 = all checks passed · 1 = one or more checks failed · 2 = the run +could not start (unreachable host, missing or rejected credentials). 2 is kept +distinct on purpose: "I could not test this" is not the same answer as "this is +broken", and conflating them is what made an unauthenticated version of this +script report a wall of failures against a perfectly healthy stack. """ import argparse +import http.cookiejar import json +import os import ssl import sys import urllib.error @@ -40,6 +66,18 @@ def check(name, cond, detail=""): BASE = "" CTX = None +# One opener for the whole run, carrying the cookie jar that holds the session +# issued by /api/auth/login. urlopen() has no cookie support, which is why the +# session used to be dropped on the floor and every data route answered 401. +OPENER = None + + +def build_opener(ctx=None): + handlers = [urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())] + if ctx is not None: + handlers.append(urllib.request.HTTPSHandler(context=ctx)) + return urllib.request.build_opener(*handlers) + def call(method, path, body=None): """Returns (status_code, parsed_body). Never raises on HTTP status.""" @@ -50,7 +88,7 @@ def call(method, path, body=None): headers={"Content-Type": "application/json", "Accept": "application/json"}, ) try: - with urllib.request.urlopen(req, context=CTX, timeout=20) as r: + with OPENER.open(req, timeout=20) as r: raw = r.read().decode(); status = r.status except urllib.error.HTTPError as e: raw = e.read().decode(); status = e.code @@ -61,33 +99,95 @@ def call(method, path, body=None): return status, parsed +def abort(msg, hint=""): + """Could not run — distinct from 'ran and found problems'. See exit codes above.""" + print(_c("\nABORT", "31") + " " + msg) + if hint: + print(hint) + print() + return 2 + + def main(): - global BASE, CTX + global BASE, CTX, OPENER ap = argparse.ArgumentParser(description="Work Package Suite API smoke test") ap.add_argument("base_url", nargs="?", default="http://localhost:8000", help="Site root, no /api (default: http://localhost:8000)") ap.add_argument("--insecure", action="store_true", help="skip TLS verification") ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)") + ap.add_argument("--user", default=os.getenv("WP_SMOKE_USER", ""), + help="account to sign in as (default: $WP_SMOKE_USER). Use an admin account.") + ap.add_argument("--password", default=os.getenv("WP_SMOKE_PASSWORD", ""), + help="its password (default: $WP_SMOKE_PASSWORD — preferred, " + "so it stays out of shell history)") args = ap.parse_args() BASE = args.base_url.rstrip("/") if args.insecure: CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE + OPENER = build_opener(CTX) print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n") + # Refuse to start without credentials rather than running headlong into 401s. + if not args.user or not args.password: + missing = " and ".join( + n for n, v in (("WP_SMOKE_USER", args.user), ("WP_SMOKE_PASSWORD", args.password)) if not v) + return abort( + f"no credentials — {missing} not set.", + " Every /api/ route except /api/health needs a session, so there is nothing\n" + " meaningful to test without one. Set them and re-run:\n\n" + " export WP_SMOKE_USER=\n" + " export WP_SMOKE_PASSWORD='…'\n\n" + " Or pass --user/--password. Use an admin account: the run creates a project\n" + " and deletes it again, and the delete needs Project Admin on it.") + project_id = None + # Guards the sign-out in `finally`. Without it an ABORT on a rejected login still + # ran the logout checks, which "passed" — a session that never existed is trivially + # refused after logout — and printed PASS lines underneath an abort message. + logged_in = False try: - # 1) Health — API is up and reachable through the proxy. + # 1) Health — API is up and reachable through the proxy. Exempt from auth, + # so this also isolates "host unreachable" from "credentials rejected". try: st, body = call("GET", "/api/health") except urllib.error.URLError as e: - print(_c("\nABORT", "31") + f" cannot reach {BASE}/api/health — {e}\n" - " Is the stack up (docker compose ps) and the URL correct?\n") - return 1 + return abort(f"cannot reach {BASE}/api/health — {e}", + " Is the stack up (docker compose ps) and the URL correct?") check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True, f"status={st} body={body}") - # 2) Create a project (writes to the projects table). + # 2) Sign in. The cookie the response sets is held by OPENER's jar and rides + # every request after this one. + st, body = call("POST", "/api/auth/login", + {"username": args.user, "password": args.password}) + if st != 200: + detail = body.get("detail") if isinstance(body, dict) else body + hint = (" The account may be locked: the API locks an account for a while after\n" + " a few consecutive failures (AUTH_MAX_ATTEMPTS / AUTH_LOCKOUT_MINUTES),\n" + " so re-running with the wrong password makes this worse, not better.\n" + " Check the password, then wait out the lockout window." + if st in (401, 403, 423, 429) else + " Unexpected status from the login endpoint — check the API logs.") + return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint) + logged_in = True + check("login issues a session", st == 200) + + # 3) Prove the session actually travels — this is the check whose absence let + # an unauthenticated version of this script look like a broken stack. + st, me = call("GET", "/api/auth/me") + who = (me or {}).get("user", {}) if isinstance(me, dict) else {} + check("session is accepted on an authenticated route", + st == 200 and who.get("username", "").lower() == args.user.lower(), + f"status={st} body={me}") + role = who.get("role", "?") + print(f" ..... signed in as {who.get('username', args.user)} (role: {role})") + if role not in ("admin", "project_super_user", "project_admin"): + print(_c(" NOTE", "33") + f" '{role}' cannot archive or delete a project, so the " + "archive checks and the\n cleanup step will fail and a stray test project " + "will be left behind.\n Re-run with an admin account for a clean pass.") + + # 4) Create a project (writes to the projects table). st, proj = call("POST", "/api/projects", { "name": "ZZ Smoke Test Project", "number": "SMOKE-001", "client": "Internal QA", "division": "Controls", "site": "Test Host", @@ -96,14 +196,14 @@ def main(): project_id = proj.get("id") if isinstance(proj, dict) else None check("create project", st == 200 and bool(project_id), f"status={st}") - # 3) Read it back + confirm it's in the list (SQL round-trip). + # 5) Read it back + confirm it's in the list (SQL round-trip). st, got = call("GET", f"/api/projects/{project_id}") check("fetch project by id", st == 200 and got.get("number") == "SMOKE-001", f"status={st}") st, lst = call("GET", "/api/projects") check("project appears in list", st == 200 and any(p.get("id") == project_id for p in lst), f"status={st} count={len(lst) if isinstance(lst, list) else '?'}") - # 4) Create a SOP linked to the project. + # 6) Create a SOP linked to the project. st, sop = call("POST", "/api/sops", { "project_id": project_id, "name": "ZZ Smoke SOP", "number": "SMOKE-001", "complete": True, "created_by": "smoketest", @@ -116,7 +216,7 @@ def main(): st, latest = call("GET", f"/api/sops/latest?project_id={project_id}") check("latest SOP for project resolves", st == 200 and latest.get("id") == sop_id, f"status={st}") - # 5) Create a Work Package with one OPEN constraint (not release-ready). + # 7) Create a Work Package with one OPEN constraint (not release-ready). st, wp = call("POST", "/api/wps", { "project_id": project_id, "sop_id": sop_id, "number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install", @@ -128,11 +228,11 @@ def main(): wp_id = wp.get("id") if isinstance(wp, dict) else None check("create work package", st == 200 and bool(wp_id), f"status={st}") - # 6) The AWP release gate: issuing with an open constraint must be REFUSED (409). + # 8) The AWP release gate: issuing with an open constraint must be REFUSED (409). st, refused = call("POST", f"/api/wps/{wp_id}/issue") check("issue is blocked while a constraint is open (409)", st == 409, f"status={st} body={refused}") - # 7) Clear the constraint (upsert), then issue must SUCCEED (200, status Issued). + # 9) Clear the constraint (upsert), then issue must SUCCEED (200, status Issued). call("POST", "/api/wps", { "id": wp_id, "project_id": project_id, "sop_id": sop_id, "number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install", @@ -146,16 +246,16 @@ def main(): f"status={st}") check("issued_at timestamp is set", isinstance(issued, dict) and bool(issued.get("issued_at"))) - # 8) Status transition endpoint. + # 10) Status transition endpoint. st, prog = call("POST", f"/api/wps/{wp_id}/status", {"status": "In Progress"}) check("status transition endpoint", st == 200 and prog.get("status") == "In Progress", f"status={st}") - # 9) Metrics aggregate for the project (Python aggregation over SQL rows). + # 11) Metrics aggregate for the project (Python aggregation over SQL rows). st, m = call("GET", f"/api/wps/metrics?project_id={project_id}") check("metrics endpoint aggregates", st == 200 and isinstance(m, dict) and m.get("total", 0) >= 1, f"status={st} metrics={m}") - # 10) Comment / feedback write + read. + # 12) Comment / feedback write + read. st, c = call("POST", "/api/feedback", { "type": "wp_review_comment", "name": "smoketest", "wp_id": wp_id, "text": "SMOKE TEST comment — safe to delete", "page": "/smoketest"}) @@ -164,11 +264,11 @@ def main(): check("comment is queryable", st == 200 and any("SMOKE TEST" in (x.get("text") or "") for x in comments), f"status={st}") - # 11) WPs filter by project. + # 13) WPs filter by project. st, wps = call("GET", f"/api/wps?project_id={project_id}") check("list WPs by project", st == 200 and any(w.get("id") == wp_id for w in wps), f"status={st}") - # 12) Archiving a project: it leaves the default list, stays reachable with + # 14) Archiving a project: it leaves the default list, stays reachable with # archived=all, and freezes read-only — then unarchiving restores all three. # The freeze is the whole point of the feature, so it is asserted, not assumed. st, arch = call("POST", f"/api/projects/{project_id}/archive", {"archived": True}) @@ -193,7 +293,7 @@ def main(): check("writing succeeds again once unarchived", st == 200, f"status={st}") finally: - # 13) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE). + # 15) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE). if project_id and not args.keep: st, _ = call("DELETE", f"/api/projects/{project_id}") check("delete project (cascades SOP + WPs)", st == 200, f"status={st}") @@ -203,6 +303,15 @@ def main(): elif project_id and args.keep: print(f"\n --keep: left demo project {project_id} ('ZZ Smoke Test Project') in the database.") + # 16) Sign out. Exercises the logout endpoint, and means a run does not end + # holding a live session — which matters when this is run from a shared + # jump host or a CI worker. Only if we got one: see `logged_in`. + if logged_in: + st, _ = call("POST", "/api/auth/logout") + check("logout clears the session", st == 200, f"status={st}") + st, _ = call("GET", "/api/auth/me") + check("session is refused after logout (401)", st == 401, f"status={st}") + # ── summary ──────────────────────────────────────────────────────────────── total = len(_PASS) + len(_FAIL) print(f"\n{'-'*52}\n{len(_PASS)}/{total} checks passed.") diff --git a/tests/a11y_check.py b/tests/a11y_check.py new file mode 100644 index 0000000..dbb3311 --- /dev/null +++ b/tests/a11y_check.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Announcements, contrast and focus — S10 / S11 / S12 (T4.5, T4.6, T4.7). + +Wave 0 counted zero aria-live regions app-wide, helper text at 3.32:1, and +`outline: none` in six places. login.html's role="alert" / role="status" pair was +the only correct example of any of it in the codebase. + + T4.5 every toast and banner announces; errors interrupt, confirmations do not + T4.6 helper and hint text measures >= 4.5:1 against its REAL background + T4.7 every interactive element shows a visible ring on keyboard focus, >= 3:1, + and a mouse click leaves none + +Contrast is measured against the background actually painted behind the text, +walking up the ancestors for the first non-transparent one — not against an +assumed white, which is how "it passes on paper" and "it fails on the page" end +up disagreeing. + +Exit 0 all passed, 1 a failure, 2 could not run. +""" +import os +import subprocess +import sys +import tempfile +import time + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import cdp # noqa: E402 +from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402 + +PAGES = [("login", "/login.html", None), ("launcher", "/index.html", "root"), + ("sop", "/work-package-suite.html?project=projA", "root"), + ("creator", "/wp-creation-index.html?project=projA", "root"), + ("admin", "/admin.html", "root"), ("users", "/users.html", "root"), + ("field", "/field.html?project=projA", "root")] + +# Effective background + contrast, computed in the page. +CONTRAST_JS = r""" +(() => { + const lum = (c) => { + const m = c.match(/[\d.]+/g); if (!m) return null; + const [r,g,b] = m.slice(0,3).map(Number); + const a = m.length > 3 ? Number(m[3]) : 1; + if (a === 0) return null; + const f = (v) => { v /= 255; return v <= 0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4); }; + return 0.2126*f(r) + 0.7152*f(g) + 0.0722*f(b); + }; + const bgOf = (el) => { + let n = el; + while (n && n.nodeType === 1) { + const c = getComputedStyle(n).backgroundColor; + const l = lum(c); + if (l !== null) return {color: c, lum: l}; + n = n.parentElement; + } + return {color: 'rgb(255,255,255)', lum: 1}; + }; + const ratio = (a, b) => { const hi = Math.max(a,b), lo = Math.min(a,b); return (hi+0.05)/(lo+0.05); }; + + // Helper/hint text: the classes that carry it, plus anything at <= 12px that is + // real text. Hidden elements are skipped - they have no contrast to measure. + const sel = '.field-hint, .note, .sub, small, .field small, .dm-label, .prog-sub, ' + + '.wp-nav-subj, .cmt-note, .req-hint, .empty-hint, .me-tag, .card-status'; + const out = []; + for (const el of document.querySelectorAll(sel)) { + const r = el.getBoundingClientRect(); + if (!r.width || !r.height) continue; + const txt = (el.textContent || '').trim(); + if (!txt) continue; + const cs = getComputedStyle(el); + if (cs.visibility === 'hidden' || cs.opacity === '0') continue; + const fl = lum(cs.color); if (fl === null) continue; + const bg = bgOf(el); + const size = parseFloat(cs.fontSize); + const bold = parseInt(cs.fontWeight, 10) >= 700; + // WCAG "large text": >=24px, or >=18.66px bold. + const large = size >= 24 || (bold && size >= 18.66); + out.push({ + cls: el.className || el.tagName, size: size, large: large, + fg: cs.color, bg: bg.color, ratio: +ratio(fl, bg.lum).toFixed(2), + floor: large ? 3.0 : 4.5, + text: txt.slice(0, 40), + }); + } + return JSON.stringify(out); +})() +""" + +FOCUS_JS = r""" +(() => { + const lum = (c) => { + const m = c.match(/[\d.]+/g); if (!m) return null; + const [r,g,b] = m.slice(0,3).map(Number); + if (m.length > 3 && Number(m[3]) === 0) return null; + const f = (v) => { v /= 255; return v <= 0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4); }; + return 0.2126*f(r) + 0.7152*f(g) + 0.0722*f(b); + }; + const bgOf = (el) => { + let n = el; + while (n && n.nodeType === 1) { + const l = lum(getComputedStyle(n).backgroundColor); + if (l !== null) return l; + n = n.parentElement; + } + return 1; + }; + const ratio = (a, b) => { const hi = Math.max(a,b), lo = Math.min(a,b); return (hi+0.05)/(lo+0.05); }; + + const els = [...document.querySelectorAll( + 'a[href], button, input:not([type=hidden]), select, textarea, [tabindex]:not([tabindex="-1"])')] + .filter(el => { const r = el.getBoundingClientRect(); return r.width && r.height && !el.disabled; }); + + const bad = []; + let checked = 0; + for (const el of els.slice(0, 120)) { + el.focus(); + // focus() on a visibility:hidden or inert control does nothing, and a control + // nobody can reach has no focus ring to measure. Ask whether the focus actually + // landed rather than assuming it did — a closed drawer still has layout, so a + // bounding box is not evidence that a user can get to its contents. + if (document.activeElement !== el) { continue; } + if (!el.matches(':focus-visible')) { el.blur(); continue; } // not keyboard-focusable here + checked++; + const cs = getComputedStyle(el); + const hasOutline = cs.outlineStyle !== 'none' && parseFloat(cs.outlineWidth) > 0; + let ok = false, detail = ''; + if (hasOutline) { + const ol = lum(cs.outlineColor); + // WHICH background the ring is actually drawn on depends on the offset. A + // positive offset puts it outside the border box, on whatever the PARENT + // paints; a negative one puts it over the element's own fill. Measuring both + // against the element is how a blue ring on a blue primary button reads as + // 8.6:1 on paper and is invisible on screen. + // WHICH surface the ring is drawn against depends on the offset the browser + // ends up using — not the one the stylesheet asked for. Chromium redraws a + // low-contrast author ring in white or black at offset 0 on a filled control, + // which is MORE contrast than was asked for, not less. + // offset > 0 outside the border box, on whatever the parent paints + // offset < 0 inset, over the element's own fill + // offset = 0 flush against the edge, touching both — visible if it + // contrasts with either + const off = parseFloat(cs.outlineOffset) || 0; + const own = bgOf(el); + const par = el.parentElement ? bgOf(el.parentElement) : own; + let r; + if (ol === null) r = 0; + else if (off > 0) r = ratio(ol, par); + else if (off < 0) r = ratio(ol, own); + else r = Math.max(ratio(ol, own), ratio(ol, par)); + ok = r >= 3.0; + detail = cs.outlineWidth + ' ' + cs.outlineColor + ' offset ' + cs.outlineOffset + + ' @ ' + r.toFixed(2) + ':1'; + } else { + // A component may ring its SHELL instead of the control — the chrome's search + // field is a borderless input inside a bordered box that outlines on + // :focus-within. Ringing both would draw two rectangles, so an ancestor ring + // counts, as long as it is really there while this element has focus. + let n = el.parentElement, anc = null; + while (n && n.nodeType === 1 && !anc) { + const acs = getComputedStyle(n); + if (acs.outlineStyle !== 'none' && parseFloat(acs.outlineWidth) > 0) anc = { n: n, cs: acs }; + n = n.parentElement; + } + if (anc) { + const ol = lum(anc.cs.outlineColor); + const off = parseFloat(anc.cs.outlineOffset) || 0; + const surface = off >= 0 && anc.n.parentElement ? bgOf(anc.n.parentElement) : bgOf(anc.n); + const r = ol === null ? 0 : ratio(ol, surface); + ok = r >= 3.0; + detail = 'ancestor ' + (anc.n.className || anc.n.tagName) + ' @ ' + r.toFixed(2) + ':1'; + } else { + detail = (cs.boxShadow && cs.boxShadow !== 'none') + ? 'box-shadow only: ' + cs.boxShadow.slice(0, 50) : 'no indicator'; + } + } + if (!ok) { + var chain = [], n2 = el; + while (n2 && n2.nodeType === 1 && chain.length < 4) { + chain.push(n2.tagName.toLowerCase() + (n2.id ? '#' + n2.id : '') + + (typeof n2.className === 'string' && n2.className.trim() + ? '.' + n2.className.trim().split(/\s+/)[0] : '')); + n2 = n2.parentElement; + } + bad.push({ tag: el.tagName.toLowerCase(), cls: (el.className||'').toString().slice(0,40), + detail: detail, where: chain.join(' < '), text: (el.textContent||'').trim().slice(0,24) }); + } + el.blur(); + } + return JSON.stringify({ checked, bad }); +})() +""" + + +def main(): + exe = cdp.find_browser() + if not exe: + print("no headless-capable browser found; set WP_BROWSER.") + return 2 + + tmpdir = tempfile.mkdtemp(prefix="wpsuite-a11y-") + db_path = os.path.join(tmpdir, "check.db") + server = None + try: + tok = seed(db_path) + port = cdp.free_port() + base = "http://127.0.0.1:%d" % port + server = start_server(port, db_path) + if server is None: + print("the test server would not start.") + return 2 + print("\nAnnouncements, contrast and focus — S10/S11/S12\nTarget: %s" % base) + + browser = cdp.Browser(exe) + page = browser.page() + # Without focus emulation the headless page is not the focused document, + # :focus-visible never matches, and every focus reading comes back clean — + # which looks like a pass and is not one. + page.ws.call("Emulation.setFocusEmulationEnabled", {"enabled": True}) + try: + import json + + print("\nT4.6 — helper text contrast against its real background") + worst = [] + for label, path, user in PAGES: + page.clear_cookies() + if user: + page.set_cookie("wp_session", tok[user]) + page.goto(base + path) + time.sleep(1.4) + rows = json.loads(page.eval(CONTRAST_JS)) + fails = [r for r in rows if r["ratio"] < r["floor"]] + if rows: + worst.append((label, min(r["ratio"] for r in rows), len(rows))) + chk("%-9s %d helper/hint elements, all >= their floor" % (label, len(rows)), + not fails, + "; ".join("%s %.2f:1 (needs %.1f) %r" % (f["cls"][:24], f["ratio"], f["floor"], f["text"]) + for f in fails[:3])) + for label, w, n in worst: + print(" %-9s tightest %.2f:1 across %d elements" % (label, w, n)) + + print("\nT4.5 — toasts and banners announce") + page.clear_cookies() + page.set_cookie("wp_session", tok["root"]) + page.goto(base + "/admin.html") + time.sleep(1.6) + roles = json.loads(page.eval( + "JSON.stringify([...document.querySelectorAll('.banner,[id$=\"-banner\"]')]" + ".map(e => ({cls: e.className, role: e.getAttribute('role')})))")) + chk("admin banners all carry a role", + bool(roles) and all(r["role"] in ("alert", "status") for r in roles), + [r for r in roles if r["role"] not in ("alert", "status")][:3]) + chk("...an error banner interrupts (role=alert)", + page.eval("""(() => { + const b = document.getElementById('health-banner'); + b.className = 'banner bad'; b.textContent = 'probe'; + return new Promise(res => setTimeout(() => res(b.getAttribute('role')), 120)); + })()""") == "alert") + chk("...a success banner does not (role=status)", + page.eval("""(() => { + const b = document.getElementById('health-banner'); + b.className = 'banner ok'; b.textContent = 'probe'; + return new Promise(res => setTimeout(() => res(b.getAttribute('role')), 120)); + })()""") == "status") + chk("...and a banner added later is caught too", + page.eval("""(() => { + const d = document.createElement('div'); + d.className = 'banner bad'; d.textContent = 'late'; + document.querySelector('.wrap').appendChild(d); + return new Promise(res => setTimeout(() => res(d.getAttribute('role')), 120)); + })()""") == "alert") + + page.goto(base + "/wp-creation-index.html?project=projA") + for _ in range(30): + if page.eval("!!window.wpCreatorReady"): + break + time.sleep(0.3) + time.sleep(0.8) + chk("the creator's toast announces politely by default", + page.eval("toast('probe'); document.getElementById('toast').getAttribute('role')") + == "status") + chk("...and interrupts when told to", + page.eval("toast('probe','alert'); document.getElementById('toast').getAttribute('role')") + == "alert") + chk("the sync badge announces politely", + page.eval("""(() => { + const b = document.getElementById('wp-sync-badge'); + return b ? b.getAttribute('role') : 'status'; + })()""") == "status") + + print("\nT4.7 — a visible focus ring on every interactive element") + for label, path, user in PAGES: + page.clear_cookies() + if user: + page.set_cookie("wp_session", tok[user]) + page.goto(base + path) + time.sleep(1.4) + res = json.loads(page.eval(FOCUS_JS)) + chk("%-9s %d focusable elements, all ring at >= 3:1" + % (label, res["checked"]), + not res["bad"], + "; ".join("%r %s | %s | %s" % (b.get("text",""), b["cls"][:24], + b["detail"], b.get("where","")) + for b in res["bad"][:3])) + + print("\nT4.7 — a mouse click leaves no ring") + page.goto(base + "/admin.html") + time.sleep(1.2) + clicked = page.eval("""(() => { + const b = document.querySelector('button'); + if (!b) return 'none'; + b.dispatchEvent(new MouseEvent('mousedown', {bubbles:true})); + b.focus(); + b.dispatchEvent(new MouseEvent('mouseup', {bubbles:true})); + b.dispatchEvent(new MouseEvent('click', {bubbles:true})); + return b.matches(':focus-visible') ? 'ring' : 'no-ring'; + })()""") + chk("a mouse-focused button shows no persistent ring", + clicked in ("no-ring", "none"), clicked) + finally: + page.close() + browser.close() + finally: + if server: + server.kill() + try: + server.wait(timeout=10) + except subprocess.TimeoutExpired: + pass + try: + from server.db import engine + engine.dispose() + except Exception: + pass + import shutil + for _ in range(10): + shutil.rmtree(tmpdir, ignore_errors=True) + if not os.path.exists(tmpdir): + break + time.sleep(0.3) + + total = len(_PASS) + len(_FAIL) + print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total)) + if _FAIL: + for f in _FAIL: + print(" - " + f) + return 1 + print("\nResult: " + _c("ALL PASS — it announces, it is legible, focus is visible.", "32") + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/aggregates_check.py b/tests/aggregates_check.py new file mode 100644 index 0000000..70c6c40 --- /dev/null +++ b/tests/aggregates_check.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Do the counts come from the server? — B4 / T4.1. + +The defect B4 names is not "the numbers are wrong". It is that they were derived +from the caller's own localStorage, so two people on the same project saw +different numbers and neither was told. A test that only checks the totals are +correct would have passed before this change, because on one browser with one +cache they were correct. + +So this checks the thing that was actually broken: + + 1. the same project reports the same aggregates to two different users + 2. the dashboard shows the SERVER's total even when the browser's own cache has + been poisoned with a different one — which it cannot do if it is summing + localStorage + 3. a failed aggregate request renders an explicit error and a retry, not a zero + and not the last good answer + 4. the launcher's SOP status survives a poisoned cache the same way + 5. the aggregate response can be grouped by location without a schema change + +Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome over +CDP, all torn down. Exit 0 all passed, 1 a failure, 2 could not run. +""" +import json +import os +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import cdp # noqa: E402 +from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402 + + +def api(base, path, token, method="GET", body=None): + req = urllib.request.Request(base + path, method=method) + req.add_header("Cookie", "wp_session=" + token) + req.add_header("Accept", "application/json") + data = None + if body is not None: + data = json.dumps(body).encode() + req.add_header("Content-Type", "application/json") + with urllib.request.urlopen(req, data, timeout=15) as r: + return json.loads(r.read().decode() or "null") + + +def main(): + exe = cdp.find_browser() + if not exe: + print("no headless-capable browser found; set WP_BROWSER.") + return 2 + + tmpdir = tempfile.mkdtemp(prefix="wpsuite-aggregates-") + db_path = os.path.join(tmpdir, "check.db") + server = None + try: + tok = seed(db_path) + port = cdp.free_port() + base = "http://127.0.0.1:%d" % port + server = start_server(port, db_path) + if server is None: + print("the test server would not start.") + return 2 + print("\nAggregate counts — B4 / T4.1\nTarget: %s" % base) + + # Extra packages so the counts are not all the same number: one on hold, + # one blocked by an open constraint, one closed, each with a location. + for i, (num, status, constraint, loc, hours) in enumerate([ + ("WP03-HOLD", "Issue", "cleared", "B100 / L2 / P", "12"), + ("WP04-GATE", "Draft", "open", "B100 / L2 / P", "8"), + ("WP05-DONE", "Closed", "cleared", "B100 / L3 / Q", "20"), + ]): + api(base, "/api/wps", tok["root"], "POST", { + "id": "wpX%d" % i, "project_id": "projA", "sop_id": "sopA", + "number": num, "subject": num, "type": "Conduit Install", + "status": status, + "data": {"disciplines": ["Electrical"], "hours": hours, + "location": loc, + "constraints": [{"name": "Materials", "status": constraint, + "comment": "waiting on delivery"}]}, + }) + + print("\n1. the same project reports the same aggregates to two users") + as_root = api(base, "/api/wps/metrics?project_id=projA", tok["root"]) + as_pat = api(base, "/api/wps/metrics?project_id=projA", tok["pat"]) + comparable = ["total", "release_ready", "on_hold", "overdue", + "est_hours", "actual_hours", "by_status", "by_discipline"] + same = {k: as_root[k] for k in comparable} == {k: as_pat[k] for k in comparable} + chk("root and pat get identical aggregates for projA", same, + "root=%s pat=%s" % ({k: as_root[k] for k in comparable}, + {k: as_pat[k] for k in comparable})) + chk("the fixture is not degenerate (>=5 packages, a hold, a gate)", + as_root["total"] >= 5 and as_root["on_hold"] >= 1 and len(as_root["gating"]) >= 1, + "total=%s on_hold=%s gating=%s" % (as_root["total"], as_root["on_hold"], + len(as_root["gating"]))) + chk("'mine' is per-user, so it is allowed to differ", + "mine" in as_root and "mine" in as_pat) + + print("\n5. the aggregate can be grouped by location without a schema change") + loc = as_root.get("by_location") or {} + chk("by_location carries its dimensions", isinstance(loc.get("dimensions"), list) + and len(loc["dimensions"]) >= 1, loc.get("dimensions")) + groups = loc.get("groups") or [] + chk("by_location groups are keyed by those dimensions", + bool(groups) and all(set(g["key"]) == set(loc["dimensions"]) for g in groups), + [g.get("key") for g in groups[:3]]) + chk("each group carries its own rollup, not just a count", + bool(groups) and all({"total", "release_ready", "on_hold", "by_status"} <= set(g) + for g in groups), + list(groups[0]) if groups else None) + chk("the groups sum to the project total", + sum(g["total"] for g in groups) == as_root["total"], + "%s vs %s" % (sum(g["total"] for g in groups), as_root["total"])) + + browser = cdp.Browser(exe) + page = browser.page() + try: + print("\n2. the dashboard shows the server's total, not the browser's") + page.clear_cookies() + page.set_cookie("wp_session", tok["root"]) + page.goto(base + "/wp-creation-index.html?project=projA") + time.sleep(1.5) + # Poison this browser's cache with a different number of packages than + # the server has. If any displayed count still tracks localStorage, it + # will report 2 and the server's total will not match. + page.eval("""(() => { + const fake = [ + {id:'fake1', number:'FAKE-1', subject:'not on the server', status:'Draft', + disciplines:['Electrical'], hours:'999', constraints:[]}, + {id:'fake2', number:'FAKE-2', subject:'also not', status:'Draft', + disciplines:['Electrical'], hours:'999', constraints:[]} + ]; + localStorage.setItem('wp_iwp_v1::projA', JSON.stringify(fake)); + localStorage.setItem('wp_iwp_v1', JSON.stringify(fake)); + return true; + })()""") + page.goto(base + "/wp-creation-index.html?project=projA&view=dashboard") + time.sleep(1.2) + page.eval("typeof showDashboard==='function' && showDashboard()") + for _ in range(30): + ready = page.eval("!!document.querySelector('.dash-metric .dm-val')") + if ready: + break + time.sleep(0.3) + shown = page.eval( + "(()=>{const e=[...document.querySelectorAll('.dash-metric')]" + ".find(x=>/Total WPs/i.test(x.textContent));" + "return e?e.querySelector('.dm-val').textContent.trim():null})()") + chk("dashboard 'Total WPs' equals the server total", + str(shown) == str(as_root["total"]), + "shown=%r server=%r (poisoned cache said 2)" % (shown, as_root["total"])) + chk("...and is therefore not the poisoned cache's 2", str(shown) != "2", shown) + + print("\n3. a failed aggregate request is an error, not a zero") + page.eval("""(() => { + const real = window.fetch; + window.fetch = function(u, o){ + if (String(u).indexOf('/api/wps/metrics') !== -1) + return Promise.reject(new Error('simulated outage')); + return real.apply(this, arguments); + }; + dashMetrics = null; dashMetricsErr = null; + loadDashMetrics(); + return true; + })()""") + time.sleep(1.2) + txt = page.eval("(document.getElementById('dashboard-view')||{}).textContent||''") + chk("an explicit error panel is shown", "Counts unavailable" in txt, txt[:120]) + chk("...naming the failure", "simulated outage" in txt, txt[:160]) + chk("...offering a retry", + page.eval("!!document.querySelector('#dashboard-view button')")) + chk("no metric tiles are rendered alongside the error", + page.eval("document.querySelectorAll('#dashboard-view .dash-metric').length") == 0) + chk("the error region announces itself", + page.eval("!!document.querySelector('#dashboard-view [role=alert]')")) + + print("\n4. the launcher's SOP status comes from the server too") + page.goto(base + "/index.html") + time.sleep(0.6) + page.eval("""(() => { + try { + localStorage.setItem('wp_active_project_id', 'projA'); + localStorage.setItem('wp_suite_sop_complete::projA', '0'); + localStorage.removeItem('wp_suite_sop::projA'); + } catch(e){} + return true; + })()""") + page.goto(base + "/index.html") + # A7/T6.5 gave the card a status line in EVERY state, including while + # the request is in flight ("Checking the SOP..."). So waiting for the + # line to be non-empty is no longer waiting for the answer — wait for + # it to stop saying it is checking. + for _ in range(30): + s = page.eval("(document.querySelector('#card-sop .card-status')||{}).textContent||''") + if s and "Checking" not in s: + break + time.sleep(0.3) + status = page.eval( + "(document.querySelector('#card-sop .card-status')||{}).textContent||''") + # The wording moved at T6.5 ("SOP complete" -> "Complete - "). + # What this check is about is WHERE the answer came from, not how it is + # phrased, so it asserts the answer and not the sentence. + chk("launcher reports the SOP complete despite a cache that says otherwise", + "Complete" in status and "Not finished" not in status, + "card said %r" % status.encode("ascii", "replace").decode("ascii")) + + page.goto(base + "/index.html") + time.sleep(0.4) + page.eval("""(() => { + const real = window.fetch; + window.fetch = function(u, o){ + if (String(u).indexOf('/summary') !== -1) + return Promise.reject(new Error('simulated outage')); + return real.apply(this, arguments); + }; + return true; + })()""") + page.eval("typeof applyActiveProject==='function' && applyActiveProject()") + time.sleep(1.0) + status = page.eval( + "(document.querySelector('#card-sop .card-status')||{}).textContent||''") + # Same again: the wording moved at T6.5 ("Could not check SOP status" + # -> "Unknown - could not reach the server"). The assertion is that the + # card states the failure rather than guessing, in whatever words. + chk("a failed status request says so on the card", + "Unknown" in status and "could not reach" in status.lower(), + "card said %r" % status.encode("ascii", "replace").decode("ascii")) + finally: + page.close() + browser.close() + except urllib.error.HTTPError as e: + print("API error: %s %s" % (e.code, e.read()[:300])) + return 2 + finally: + if server: + server.kill() + try: + server.wait(timeout=10) + except subprocess.TimeoutExpired: + pass + try: + from server.db import engine + engine.dispose() + except Exception: + pass + import shutil + for _ in range(10): + shutil.rmtree(tmpdir, ignore_errors=True) + if not os.path.exists(tmpdir): + break + time.sleep(0.3) + + total = len(_PASS) + len(_FAIL) + print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total)) + if _FAIL: + for f in _FAIL: + print(" - " + f) + return 1 + print("\nResult: " + _c("ALL PASS — counts come from the server.", "32") + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/archived_check.py b/tests/archived_check.py new file mode 100644 index 0000000..f5c9fee --- /dev/null +++ b/tests/archived_check.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Can a project admin get back into an archived project — and only they? — D7, T9.8. + +Archiving read as deletion because there was no way back in. Now: a separate, +labelled, read-only list on the launcher for project admins; the server filters +the answer by per-project role, refuses every write regardless of what the +browser sends, and shows archived projects to nobody else anywhere - counts and +pickers included. + +Exit 0 all passed, 1 a failure, 2 could not run. +""" +import json +import os +import re +import sys +import tempfile +import time + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import cdp # noqa: E402 +from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402 +from sections_check import set_sop # noqa: E402 +from stepper_check import dismiss_dialogs # noqa: E402 +from qa_gate_check import api # noqa: E402 + + +def ascii_(v, n=280): + return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n] + + +def settle(seconds=0.5): + time.sleep(seconds) + + +def archive_projB(db_path): + from server.db import SessionLocal + from server import models + with SessionLocal() as db: + proj = db.get(models.Project, "projB") + proj.archived_at = models.utcnow() + db.commit() + + +def main(): + exe = cdp.find_browser() + if not exe: + print("no headless-capable browser found; set WP_BROWSER.") + return 2 + + tmpdir = tempfile.mkdtemp(prefix="wpsuite-arch-") + db_path = os.path.join(tmpdir, "check.db") + server = None + browser = None + try: + tok = seed(db_path) + set_sop(db_path, {}) + archive_projB(db_path) + port = cdp.free_port() + base = "http://127.0.0.1:%d" % port + server = start_server(port, db_path) + root, bob, pat = tok["root"], tok["bob"], tok["pat"] + + # ── 1. who sees what ────────────────────────────────────────────────── + print("\n1. visibility, by role") + _, rows = api(base, "/api/projects", root) + chk("the default list hides archived projects from EVERYONE, admin included", + all(p["id"] != "projB" for p in rows), ascii_([p["id"] for p in rows])) + _, rows = api(base, "/api/projects?archived=only", root) + chk("an admin asking for the archived list gets it", + [p["id"] for p in rows] == ["projB"], ascii_(rows)) + _, rows = api(base, "/api/projects?archived=only", bob) + chk("a plain project user ON that project gets an empty list - no leak", + rows == [], ascii_(rows)) + _, rows = api(base, "/api/projects?archived=all", bob) + chk("...and cannot smuggle it through archived=all either", + all(p["id"] != "projB" for p in rows), ascii_(rows)) + _, rows = api(base, "/api/projects?archived=only", pat) + chk("a user with no access to it sees nothing, same as before", + rows == [], ascii_(rows)) + + # ── 2. the server refuses writes regardless of the browser ─────────── + print("\n2. frozen means frozen") + code, out = api(base, "/api/wps", root, "POST", { + "id": "wpArch1", "project_id": "projB", "number": "AR-1", + "subject": "write into the archive", "status": "Draft", + "data": {"constraints": []}}) + chk("a direct write to an archived project is refused, even for an admin", + code in (403, 409) and "archived" in str(out).lower(), ascii_((code, out))) + code, _ = api(base, "/api/projects/projB/materials", root, "POST", + {"description": "Sample sneak", "unit": "EA"}) + chk("...and so is every other write route (material list)", code in (403, 409), code) + code, wps = api(base, "/api/wps?project_id=projB", root) + chk("reading it still works - archived is readable, not gone", + code == 200, code) + + # ── 3. the launcher, both roles, at 390px ───────────────────────────── + print("\n3. the launcher") + browser = cdp.Browser(exe) + page = browser.page() + page.clear_cookies() + page.set_cookie("wp_session", root) + page.viewport(390, 844, mobile=True) + page.goto(base + "/index.html") + dismiss_dialogs(page) + settle(2.5) + sec = json.loads(page.eval("""JSON.stringify((() => { + const s = document.getElementById('archived-projects'); + return {hidden: !s || s.hidden, + text: s ? s.textContent : '', + buttons: s ? s.querySelectorAll('button').length : 0}; + })())""")) + chk("a project admin sees the archived list, separate and labelled", + not sec["hidden"] and "Archived projects" in sec["text"] + and "read-only" in sec["text"].lower() and sec["buttons"] == 1, ascii_(sec)) + chk("...and it fits at 390px", page.eval( + "document.getElementById('archived-projects').scrollWidth <= 392")) + + page.eval("document.querySelector('[data-open-archived]').click()") + settle(2.0) + chk("opening one makes it the active project", + page.eval("(ProjectData.getActive()||{}).id") == "projB") + + # the creator's read-only courtesy on top of the server's rule + page.goto(base + "/wp-creation-index.html?project=projB") + dismiss_dialogs(page) + settle(2.5) + page.eval("window.alert=()=>{}; window.confirm=()=>false; window.prompt=()=>null;") + chk("the creator says ARCHIVED where the project is named", + "ARCHIVED" in page.eval( + "(document.getElementById('ctx-bar')||{textContent:''}).textContent")) + page.eval("document.getElementById('wp_subject').value='x'") + page.eval("document.getElementById('wp_type').value='Conduit Install'") + n0 = page.eval("savedPackages.length") + page.eval("void savePackage(false)") + settle(0.8) + chk("saving is refused with a reason, before the round trip", + page.eval("savedPackages.length") == n0 + and "archived" in page.eval( + "(document.getElementById('toast')||{textContent:''}).textContent").lower()) + + # a NON-admin's launcher shows no archived section at all + page.clear_cookies() + page.set_cookie("wp_session", bob) + page.goto(base + "/index.html") + dismiss_dialogs(page) + settle(2.5) + chk("a non-admin's launcher never shows the section", + page.eval("(() => { const s=document.getElementById('archived-projects');" + " return !s || s.hidden; })()")) + + # projB has no SOP, and GET /api/sops/latest answering 404 for it is the + # correct answer, not an error - the seed fixture documents exactly this + # false alarm. + js_errors = [e for e in page.js_errors() + if "beforeunload" not in e and "sops/latest" not in e] + chk("no JavaScript errors anywhere in this run", not js_errors, + ascii_(js_errors[:2])) + + finally: + if browser is not None: + try: + browser.close() + except Exception: + pass + if server is not None: + try: + server.terminate() + except Exception: + pass + + print("\n" + "-" * 54) + print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL))) + for f in _FAIL: + print(" - " + f) + return 1 if _FAIL else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/assets_check.py b/tests/assets_check.py new file mode 100644 index 0000000..b2f6f05 --- /dev/null +++ b/tests/assets_check.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Is the Micron asset picker read-only, and does it degrade to manual entry? — D11. + +Cody Schaefer's `origin/Micron-Assets` branch, merged Aug 20 2026 and adapted to +the R2 creator (decisions-2026-08-20.md). The properties this pins: + + * **Read-only, structurally.** assets_db.py holds one SELECT and nothing else; + /api/assets has no writing verb. Picking an asset can never change Micron. + * **Unconfigured is a first-class state.** No MICRON_DB_URL -> configured:false, + the picker says so, and manual entry carries the package. The suite must run + without Micron existing at all — every other probe implicitly relies on that. + * **Broken is not a leak.** A configured-but-unusable URL 503s with a message + that never echoes the connection string (whose parse errors can quote + password fragments). + * **The client honours the catalog.** Search ranks exact matches first, a + picked row is locked to the DB's own casing and badged, imports canonicalise + casing / fall back to manual / skip duplicates, and the import summary goes + through the T7.9 dialog kit, not a native alert(). + +Boots its own throwaway SQLite + uvicorn + headless browser; run it alone, not +back to back with other probes. Exit 0 all passed, 1 a failure, 2 could not run. +""" +import io +import json +import os +import re +import sys +import tempfile +import time + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import cdp # noqa: E402 +from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402 +from sections_check import set_sop # noqa: E402 +from stepper_check import dismiss_dialogs # noqa: E402 +from qa_gate_check import api # noqa: E402 + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +HTML = os.path.join(ROOT, "html") +SERVER = os.path.join(ROOT, "server") + + +def ascii_(v, n=240): + return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n] + + +def wait_creator(page, tries=40): + for _ in range(tries): + if page.eval("!!window.wpCreatorReady"): + return True + time.sleep(0.3) + return False + + +def strip_py(src): + src = re.sub(r'""".*?"""', "", src, flags=re.S) + return "\n".join(re.sub(r"#.*$", "", ln) for ln in src.split("\n")) + + +def main(): + exe = cdp.find_browser() + if not exe: + print("no headless-capable browser found; set WP_BROWSER.") + return 2 + + # ── 1. read-only, structurally ───────────────────────────────────────────── + print("\n1. read-only, structurally") + src = strip_py(io.open(os.path.join(SERVER, "assets_db.py"), encoding="utf-8").read()) + verbs = re.findall(r"\b(INSERT|UPDATE|DELETE|MERGE|EXEC|TRUNCATE|DROP|ALTER)\b", + src, re.I) + chk("assets_db.py contains no writing SQL verb", not verbs, verbs) + chk("...and exactly one SELECT (the whole schema contract)", + len(re.findall(r"\bSELECT\b", src, re.I)) == 1) + app_src = io.open(os.path.join(SERVER, "app.py"), encoding="utf-8").read() + chk("/api/assets is a GET and only a GET", + len(re.findall(r'@app\.get\("/api/assets"\)', app_src)) == 1 + and not re.findall(r'@app\.(post|put|patch|delete)\("/api/assets', app_src)) + outside = [f for f in ("models.py", "auth.py", "notify.py") + if "MICRON_DB_URL" in io.open(os.path.join(SERVER, f), encoding="utf-8").read()] + chk("the connection string is env-only plumbing, not model or auth state", + not outside, outside) + + tmpdir = tempfile.mkdtemp(prefix="wpsuite-assets-") + db_path = os.path.join(tmpdir, "check.db") + server = None + browser = None + try: + tok = seed(db_path) + set_sop(db_path, {}) + port = cdp.free_port() + base = "http://127.0.0.1:%d" % port + server = start_server(port, db_path) + + # ── 2. the API's unconfigured state ──────────────────────────────────── + print("\n2. unconfigured is a first-class state") + st, _ = api(base, "/api/assets", "not-a-session") + chk("anonymous gets 401, same as every other /api/ path", st == 401, st) + st, body = api(base, "/api/assets", tok["root"]) + chk("signed in, no MICRON_DB_URL: 200 with configured:false", + st == 200 and body and body.get("configured") is False + and body.get("assets") == [], ascii_(body)) + chk("...and the detail tells the user what to do instead", + "manual" in (body.get("detail") or "").lower(), ascii_(body)) + + # ── 3. the picker, catalog absent ────────────────────────────────────── + print("\n3. the picker degrades to manual entry") + browser = cdp.Browser(exe) + page = browser.page() + page.clear_cookies() + page.set_cookie("wp_session", tok["root"]) + page.viewport(1440, 900) + page.goto(base + "/wp-creation-index.html?project=projA") + dismiss_dialogs(page) + chk("the creator boots", wait_creator(page)) + time.sleep(1.2) + chk("the search box is disabled and says the catalog is not configured", + page.eval("(() => { const b=document.getElementById('asset-search');" + " return b.disabled && /not configured/i.test(b.placeholder); })()")) + chk("the source note announces it (role=status, non-empty)", + page.eval("(() => { const n=document.getElementById('asset-source-note');" + " return n.getAttribute('role')==='status' && n.textContent.length>0; })()")) + chk("no assets yet: the empty state renders instead of a blank table", + page.eval("/No assets yet/.test(document.getElementById('asset-body').textContent)")) + page.eval("addManualAsset()") + chk("+ Add asset adds an editable manual row", + page.eval("pkgAssets.length") == 1 + and page.eval("pkgAssets[0].source") == "manual" + and page.eval("!!document.querySelector('#asset-body input')")) + page.eval("document.querySelector('#asset-body input').value='HAND-01';" + "document.querySelector('#asset-body input')" + ".dispatchEvent(new Event('input',{bubbles:true}))") + chk("...and typing lands in the model", page.eval("pkgAssets[0].tag") == "HAND-01") + + # ── 4. the client honours the catalog (injected; no SQL Server here) ── + print("\n4. search, pick, import — against an injected catalog") + page.eval("pkgAssets=[]; buildAssets();" + "assetCatalog=['AHU-2P-014','AHU-2P-015','PUMP-01','XPUMP-PUMP-011','CT-100'];" + "assetCatalogIndex=new Map(assetCatalog.map(t=>[t.toLowerCase(),t]));" + "assetCatalogState='ready';" + "(() => { const b=document.getElementById('asset-search');" + " b.disabled=false; b.placeholder='Search asset IDs'; })()") + page.eval("runAssetSearch('pump-01')") + chk("an exact match outranks a longer contains-match", + page.eval("JSON.stringify(assetResults)") == '["PUMP-01","XPUMP-PUMP-011"]', + ascii_(page.eval("JSON.stringify(assetResults)"))) + chk("results render as real