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:
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user