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 },
paper_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 },
yaxis: { gridcolor: SPC_COLORS.gridColor, zeroline: false },
};
const PLOTLY_CONFIG = {
responsive: true,
displayModeBar: true,
displayModeBar: 'hover',
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false,
};
@@ -154,7 +154,6 @@ function renderControlChart(containerId, data) {
const layout = {
...PLOTLY_LAYOUT_DEFAULTS,
title: { text: _t('controlChart'), font: { size: 14 } },
xaxis: { ...PLOTLY_LAYOUT_DEFAULTS.xaxis, title: _t('measureNum') },
yaxis: { ...PLOTLY_LAYOUT_DEFAULTS.yaxis, title: _t('value') },
shapes,
@@ -231,7 +230,6 @@ function renderHistogram(containerId, data, tol) {
const layout = {
...PLOTLY_LAYOUT_DEFAULTS,
title: { text: _t('histogram'), font: { size: 14 } },
xaxis: { ...PLOTLY_LAYOUT_DEFAULTS.xaxis, title: _t('value') },
yaxis: { ...PLOTLY_LAYOUT_DEFAULTS.yaxis, title: _t('frequency') },
shapes,
+23 -3
View File
@@ -108,6 +108,12 @@
{{ _("Applica filtri") }}
</button>
</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>
<!-- Nessuna ricetta selezionata -->
@@ -266,6 +272,7 @@ function spcDashboard() {
loading: false,
hasData: false,
errorMessage: '',
reportError: '',
downloadingReport: false,
init() {
@@ -387,7 +394,7 @@ function spcDashboard() {
renderHistogram('histogram-chart', this.histogram, tol);
} else {
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
@@ -404,6 +411,7 @@ function spcDashboard() {
},
async downloadReport(type) {
this.reportError = '';
this.downloadingReport = type;
try {
const params = new URLSearchParams();
@@ -417,7 +425,19 @@ function spcDashboard() {
if (!resp.ok) {
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;
}
@@ -434,7 +454,7 @@ function spcDashboard() {
URL.revokeObjectURL(a.href);
} catch (e) {
console.error('Error downloading report:', e);
this.errorMessage = '{{ _("Errore di connessione al server") }}';
this.reportError = '{{ _("Errore di connessione al server") }}';
} finally {
this.downloadingReport = false;
}
+21
View File
@@ -268,6 +268,14 @@ async def seed_demo_data(body: PasswordBody):
created_users: list[User] = []
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(
username=u["username"],
password_hash=hash_password(u["password"]),
@@ -286,6 +294,19 @@ async def seed_demo_data(body: PasswordBody):
admin_user = created_users[0]
# ---- 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(
code="DEMO-001",
name="Demo Measurement Recipe",
+7 -2
View File
@@ -603,7 +603,8 @@
showToast('Setup is disabled. Set SETUP_PASSWORD in .env', 'error');
return;
}
// Password accepted (status endpoint is accessible)
// Validate password via init-db (idempotent, no tables needed)
await apiPost('/init-db', { password: pwd });
setupPassword = pwd;
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('setup-panel').classList.remove('hidden');
@@ -611,7 +612,11 @@
await loadUsers();
showToast('Setup panel unlocked', 'success');
} catch (e) {
showToast('Connection error: ' + e.message, 'error');
if (e.message.indexOf('403') !== -1 || e.message.indexOf('Invalid') !== -1) {
showToast('Invalid setup password', 'error');
} else {
showToast('Connection error: ' + e.message, 'error');
}
} finally {
setLoading('btn-login', false);
}