From f54c62b3326d176e9a4cd478e31f8374089de181 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 22 Jun 2026 20:24:40 +0000 Subject: [PATCH] =?UTF-8?q?feat(projects):=20projects=20page=20overhaul=20?= =?UTF-8?q?=E2=80=94=20events,=20header,=20module=20toolbars,=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a batch of projects-page UX issues: 1. Vibration Events sub-tab: add a Location filter + clickable column sorting (Timestamp/Location/Serial/Tran/Vert/Long/PVS/Mic). Events are cached client-side so location-filter and sort are instant (no SFM refetch). 3. Drop the misleading single-module "Sound Monitoring" subtitle on the Overview card (combined projects have multiple modules); show the project number · client identity instead. 4. Header cleanup: move the sound-only actions (Generate Combined Report, Night Report, Report Settings) and the Manual/Remote chip out of the global project header and into the Sound tab's module toolbar. The header now carries project-level concerns only (status, modules, merge). The Night Report / Report Settings modals stay defined in the header partial (global), so the relocated buttons still call them. 2. Per-module status UI: each module tab gets a status dropdown (active/on_hold/completed) wired to the new endpoint; the header module chips show a "✓ Done" / "On hold" badge. 5. Project cards redesigned: module mix accent strip, Sound/Vibration chips with per-module status, project number · client identity, and per-module "Sound"/"Vibration" quick-open buttons that deep-link into that module's tab (#sound / #vibration). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015m9FuJvk65kJmmP3c9c6r1 --- .../partials/projects/project_dashboard.html | 15 +- .../partials/projects/project_header.html | 49 +---- templates/partials/projects/project_list.html | 156 ++++++++------ templates/projects/detail.html | 202 +++++++++++++++++- 4 files changed, 293 insertions(+), 129 deletions(-) diff --git a/templates/partials/projects/project_dashboard.html b/templates/partials/projects/project_dashboard.html index 65e1f14..c97a03d 100644 --- a/templates/partials/projects/project_dashboard.html +++ b/templates/partials/projects/project_dashboard.html @@ -3,13 +3,14 @@

{{ project.name }}

-

- {% if project_type %} - {{ project_type.name }} - {% else %} - Project - {% endif %} -

+ {# Identity line — project number / client, not a module name. The + enabled modules are already shown as chips in the page header. #} + {% set _idbits = [] %} + {% if project.project_number %}{% set _ = _idbits.append(project.project_number) %}{% endif %} + {% if project.client_name %}{% set _ = _idbits.append(project.client_name) %}{% endif %} + {% if _idbits %} +

{{ _idbits | join(' · ') }}

+ {% endif %}
{% if project.status == 'upcoming' %} Upcoming diff --git a/templates/partials/projects/project_header.html b/templates/partials/projects/project_header.html index 9ccffb2..a4f5e9b 100644 --- a/templates/partials/projects/project_header.html +++ b/templates/partials/projects/project_header.html @@ -37,6 +37,12 @@ Vibration Monitoring {% else %}{{ m }}{% endif %} + {% set mstatus = (module_status or {}).get(m, 'active') %} + {% if mstatus == 'completed' %} + ✓ Done + {% elif mstatus == 'on_hold' %} + On hold + {% endif %} @@ -47,50 +53,11 @@ Add Module
- {% if project.data_collection_mode == 'remote' %} - - - - - Remote - - {% else %} - - - - - Manual - - {% endif %} - +
- {% if 'sound_monitoring' in modules %} - - - - - Generate Combined Report - - - - {% endif %}
+
+ + +
+ + + + +
+ +
+ + + + + Generate Combined Report + + + +
+ +
`; } } -function _renderProjectEvents(events, total, container) { +// Rebuild the Location dropdown from whatever locations actually have events in +// the current fetch, preserving the operator's current selection if still valid. +function _pvePopulateLocations() { + const sel = document.getElementById('pve-loc'); + if (!sel) return; + const prev = sel.value; + const seen = new Map(); + _pveAllEvents.forEach(ev => { + if (ev.location_id && !seen.has(ev.location_id)) seen.set(ev.location_id, ev.location_name || ev.location_id); + }); + const opts = ['']; + [...seen.entries()] + .sort((a, b) => String(a[1]).localeCompare(String(b[1]))) + .forEach(([id, name]) => opts.push(``)); + sel.innerHTML = opts.join(''); + if (prev && seen.has(prev)) sel.value = prev; +} + +function _pveSortBy(key) { + if (_pveSort.key === key) { + _pveSort.dir = (_pveSort.dir === 'asc') ? 'desc' : 'asc'; + } else { + _pveSort.key = key; + _pveSort.dir = 'desc'; // numbers + dates most useful high→low first + } + _pveApplyAndRender(); +} + +const _PVE_NUM_KEYS = new Set(['tran_ppv', 'vert_ppv', 'long_ppv', 'peak_vector_sum', 'mic_ppv']); +const _PVE_STR_KEYS = new Set(['location_name', 'serial']); + +function _pveApplyAndRender() { + const container = document.getElementById('pve-container'); + if (!container) return; + + const locId = document.getElementById('pve-loc')?.value || ''; + let rows = locId ? _pveAllEvents.filter(ev => ev.location_id === locId) : _pveAllEvents.slice(); + + const { key, dir } = _pveSort; + const mul = dir === 'asc' ? 1 : -1; + rows.sort((a, b) => { + if (_PVE_NUM_KEYS.has(key)) { + const av = (a[key] == null) ? -Infinity : Number(a[key]); + const bv = (b[key] == null) ? -Infinity : Number(b[key]); + return (av - bv) * mul; + } + if (_PVE_STR_KEYS.has(key)) { + return String(a[key] || '').toLowerCase().localeCompare(String(b[key] || '').toLowerCase()) * mul; + } + // timestamp — ISO strings sort lexicographically + return String(a.timestamp || '').localeCompare(String(b.timestamp || '')) * mul; + }); + + _renderProjectEvents(rows, container, locId); +} + +function _pveTh(label, key, align) { + const active = _pveSort.key === key; + const arrow = active ? (_pveSort.dir === 'asc' ? '▲' : '▼') : ''; + const alignCls = align === 'right' ? 'text-right' : 'text-left'; + return ` + ${label}${arrow}`; +} + +function _renderProjectEvents(events, container, locId) { if (!events.length) { container.innerHTML = '
No events for the current filter.
'; return; @@ -1106,19 +1240,22 @@ function _renderProjectEvents(events, total, container) { ${ft} `; }).join(''); + const scope = locId + ? `Showing ${events.length.toLocaleString()} event${events.length === 1 ? '' : 's'} at this location` + : `Showing ${events.length.toLocaleString()} of ${_pveTotal.toLocaleString()} events`; container.innerHTML = ` -
Showing ${events.length} of ${total.toLocaleString()} events
+
${scope}
- - - - - - - - + ${_pveTh('Timestamp', 'timestamp')} + ${_pveTh('Location', 'location_name')} + ${_pveTh('Serial', 'serial')} + ${_pveTh('Tran', 'tran_ppv')} + ${_pveTh('Vert', 'vert_ppv')} + ${_pveTh('Long', 'long_ppv')} + ${_pveTh('PVS', 'peak_vector_sum')} + ${_pveTh('Mic', 'mic_ppv')} @@ -1140,6 +1277,41 @@ function switchSoundSubTab(name) { } } +// ── Per-module status (active / on_hold / completed) ───────────────────── +// Each module has its own lifecycle independent of the parent project, so the +// sound side can be "completed" while vibration keeps running. +const _MODULE_STATUS_LABEL = { active: 'Active', on_hold: 'On hold', completed: 'Completed' }; +async function setModuleStatus(moduleType, status, selectEl) { + const prev = selectEl ? selectEl.getAttribute('data-prev') : null; + try { + const r = await fetch(`/api/projects/${projectId}/modules/${moduleType}/status`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }) + }); + if (!r.ok) throw new Error('HTTP ' + r.status); + if (selectEl) selectEl.setAttribute('data-prev', status); + // Refresh the header so the module chip's status badge updates. + if (window.htmx) htmx.ajax('GET', `/api/projects/${projectId}/header`, { target: '#project-header', swap: 'innerHTML' }); + const name = (moduleType === 'sound_monitoring') ? 'Sound' : 'Vibration'; + if (window.showToast) showToast(`${name} module marked ${_MODULE_STATUS_LABEL[status] || status}.`, 'success'); + } catch (e) { + if (selectEl && prev) selectEl.value = prev; // revert the dropdown on failure + if (window.showToast) showToast('Could not update module status.', 'error'); + else alert('Could not update module status.'); + } +} + +function _renderSoundModeChip(mode) { + const chip = document.getElementById('sound-mode-chip'); + if (!chip) return; + const remote = mode === 'remote'; + chip.classList.remove('hidden'); + chip.className = 'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ' + + (remote ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300' + : 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'); + chip.textContent = remote ? 'Remote — data via FTP' : 'Manual — SD card upload'; +} + // Load project details async function loadProjectDetails() { try { @@ -1169,6 +1341,14 @@ async function loadProjectDetails() { if (modeRadio) modeRadio.checked = true; settingsUpdateModeStyles(); + // Per-module status selects + the (sound-scoped) data-collection chip. + const ms = data.module_status || {}; + const ssel = document.getElementById('sound-module-status'); + if (ssel) { ssel.value = ms.sound_monitoring || 'active'; ssel.setAttribute('data-prev', ssel.value); } + const vsel = document.getElementById('vibration-module-status'); + if (vsel) { vsel.value = ms.vibration_monitoring || 'active'; vsel.setAttribute('data-prev', vsel.value); } + _renderSoundModeChip(mode); + // Show/hide module tabs based on active modules const hasSoundModule = projectModules.includes('sound_monitoring'); const hasVibrationModule = projectModules.includes('vibration_monitoring');
TimestampLocationSerialTranVertLongPVSMicFlags