fix: improve SPC dashboard UX — hover toolbar, visible report errors

- Change Plotly modebar to hover-only mode to avoid overlapping chart content
- Remove redundant Plotly titles (container headers already provide them)
- Add Content-Type check and inline error display for report downloads
- Fix setup login validation and seed idempotency for existing data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-24 16:42:47 +01:00
parent e07a4e4f89
commit 429c94da94
4 changed files with 53 additions and 9 deletions
+2 -4
View File
@@ -35,14 +35,14 @@ const PLOTLY_LAYOUT_DEFAULTS = {
font: { family: 'Inter, system-ui, sans-serif', color: SPC_COLORS.textColor }, font: { family: 'Inter, system-ui, sans-serif', color: SPC_COLORS.textColor },
paper_bgcolor: 'rgba(0,0,0,0)', paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)',
margin: { t: 40, r: 30, b: 50, l: 60 }, margin: { t: 20, r: 30, b: 50, l: 60 },
xaxis: { gridcolor: SPC_COLORS.gridColor, zeroline: false }, xaxis: { gridcolor: SPC_COLORS.gridColor, zeroline: false },
yaxis: { gridcolor: SPC_COLORS.gridColor, zeroline: false }, yaxis: { gridcolor: SPC_COLORS.gridColor, zeroline: false },
}; };
const PLOTLY_CONFIG = { const PLOTLY_CONFIG = {
responsive: true, responsive: true,
displayModeBar: true, displayModeBar: 'hover',
modeBarButtonsToRemove: ['lasso2d', 'select2d'], modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false, displaylogo: false,
}; };
@@ -154,7 +154,6 @@ function renderControlChart(containerId, data) {
const layout = { const layout = {
...PLOTLY_LAYOUT_DEFAULTS, ...PLOTLY_LAYOUT_DEFAULTS,
title: { text: _t('controlChart'), font: { size: 14 } },
xaxis: { ...PLOTLY_LAYOUT_DEFAULTS.xaxis, title: _t('measureNum') }, xaxis: { ...PLOTLY_LAYOUT_DEFAULTS.xaxis, title: _t('measureNum') },
yaxis: { ...PLOTLY_LAYOUT_DEFAULTS.yaxis, title: _t('value') }, yaxis: { ...PLOTLY_LAYOUT_DEFAULTS.yaxis, title: _t('value') },
shapes, shapes,
@@ -231,7 +230,6 @@ function renderHistogram(containerId, data, tol) {
const layout = { const layout = {
...PLOTLY_LAYOUT_DEFAULTS, ...PLOTLY_LAYOUT_DEFAULTS,
title: { text: _t('histogram'), font: { size: 14 } },
xaxis: { ...PLOTLY_LAYOUT_DEFAULTS.xaxis, title: _t('value') }, xaxis: { ...PLOTLY_LAYOUT_DEFAULTS.xaxis, title: _t('value') },
yaxis: { ...PLOTLY_LAYOUT_DEFAULTS.yaxis, title: _t('frequency') }, yaxis: { ...PLOTLY_LAYOUT_DEFAULTS.yaxis, title: _t('frequency') },
shapes, shapes,
+23 -3
View File
@@ -108,6 +108,12 @@
{{ _("Applica filtri") }} {{ _("Applica filtri") }}
</button> </button>
</div> </div>
<!-- Inline report error (shown below buttons row) -->
<div x-show="reportError" x-cloak
class="mt-2 text-sm text-red-600 dark:text-red-400 flex items-center gap-1"
x-text="reportError"
x-transition>
</div>
</div> </div>
<!-- Nessuna ricetta selezionata --> <!-- Nessuna ricetta selezionata -->
@@ -266,6 +272,7 @@ function spcDashboard() {
loading: false, loading: false,
hasData: false, hasData: false,
errorMessage: '', errorMessage: '',
reportError: '',
downloadingReport: false, downloadingReport: false,
init() { init() {
@@ -387,7 +394,7 @@ function spcDashboard() {
renderHistogram('histogram-chart', this.histogram, tol); renderHistogram('histogram-chart', this.histogram, tol);
} else { } else {
const el = document.getElementById('histogram-chart'); const el = document.getElementById('histogram-chart');
if (el) el.innerHTML = '<p class="text-center text-steel py-12">{{ _("Seleziona un punto di misura per l\'istogramma") }}</p>'; if (el) el.innerHTML = '<p class="text-center text-steel py-12">' + {{ _("Seleziona un punto di misura per l'istogramma")|tojson }} + '</p>';
} }
// Capability gauge // Capability gauge
@@ -404,6 +411,7 @@ function spcDashboard() {
}, },
async downloadReport(type) { async downloadReport(type) {
this.reportError = '';
this.downloadingReport = type; this.downloadingReport = type;
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -417,7 +425,19 @@ function spcDashboard() {
if (!resp.ok) { if (!resp.ok) {
const errData = await resp.json().catch(() => ({})); const errData = await resp.json().catch(() => ({}));
this.errorMessage = errData.detail || '{{ _("Errore nella generazione del report") }}'; const msg = errData.detail || '{{ _("Errore nella generazione del report") }}';
console.error('Report download failed (' + resp.status + '):', msg, errData);
this.reportError = msg;
return;
}
// Check Content-Type: server may return JSON error with 200 status
const contentType = resp.headers.get('Content-Type') || '';
if (!contentType.includes('application/pdf')) {
const errData = await resp.json().catch(() => ({}));
const msg = errData.detail || '{{ _("Errore nella generazione del report") }}';
console.error('Report returned non-PDF content-type "' + contentType + '":', msg, errData);
this.reportError = msg;
return; return;
} }
@@ -434,7 +454,7 @@ function spcDashboard() {
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
} catch (e) { } catch (e) {
console.error('Error downloading report:', e); console.error('Error downloading report:', e);
this.errorMessage = '{{ _("Errore di connessione al server") }}'; this.reportError = '{{ _("Errore di connessione al server") }}';
} finally { } finally {
this.downloadingReport = false; this.downloadingReport = false;
} }
+21
View File
@@ -268,6 +268,14 @@ async def seed_demo_data(body: PasswordBody):
created_users: list[User] = [] created_users: list[User] = []
for u in users_data: for u in users_data:
# Skip users that already exist
existing = await session.execute(
select(User).where(User.username == u["username"])
)
existing_user = existing.scalar_one_or_none()
if existing_user:
created_users.append(existing_user)
continue
user = User( user = User(
username=u["username"], username=u["username"],
password_hash=hash_password(u["password"]), password_hash=hash_password(u["password"]),
@@ -286,6 +294,19 @@ async def seed_demo_data(body: PasswordBody):
admin_user = created_users[0] admin_user = created_users[0]
# ---- Recipe ------------------------------------------------------- # ---- Recipe -------------------------------------------------------
# Skip if recipe already exists
existing_recipe = await session.execute(
select(Recipe).where(Recipe.code == "DEMO-001")
)
if existing_recipe.scalar_one_or_none():
return {
"status": "ok",
"message": "Demo data already exists, skipped",
"users_created": [u["username"] for u in users_data],
"recipe_created": "DEMO-001",
"measurements_created": 0,
}
recipe = Recipe( recipe = Recipe(
code="DEMO-001", code="DEMO-001",
name="Demo Measurement Recipe", name="Demo Measurement Recipe",
+6 -1
View File
@@ -603,7 +603,8 @@
showToast('Setup is disabled. Set SETUP_PASSWORD in .env', 'error'); showToast('Setup is disabled. Set SETUP_PASSWORD in .env', 'error');
return; return;
} }
// Password accepted (status endpoint is accessible) // Validate password via init-db (idempotent, no tables needed)
await apiPost('/init-db', { password: pwd });
setupPassword = pwd; setupPassword = pwd;
document.getElementById('login-screen').classList.add('hidden'); document.getElementById('login-screen').classList.add('hidden');
document.getElementById('setup-panel').classList.remove('hidden'); document.getElementById('setup-panel').classList.remove('hidden');
@@ -611,7 +612,11 @@
await loadUsers(); await loadUsers();
showToast('Setup panel unlocked', 'success'); showToast('Setup panel unlocked', 'success');
} catch (e) { } catch (e) {
if (e.message.indexOf('403') !== -1 || e.message.indexOf('Invalid') !== -1) {
showToast('Invalid setup password', 'error');
} else {
showToast('Connection error: ' + e.message, 'error'); showToast('Connection error: ' + e.message, 'error');
}
} finally { } finally {
setLoading('btn-login', false); setLoading('btn-login', false);
} }