feat: FASE 6.5 - Report PDF (WeasyPrint + Kaleido)

Add PDF report generation for Metrologist dashboard with SPC and
measurement reports including SVG charts, capability indices, and
company logo embedding.

New files:
- server/services/report_service.py (Jinja2 + Plotly/Kaleido + WeasyPrint)
- server/routers/reports.py (2 GET endpoints with auth)
- server/templates/reports/ (base, spc, measurement HTML templates)

Modified:
- server/main.py (register reports router)
- client dashboard (download buttons + proxy routes)
- i18n strings IT/EN

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-07 15:24:32 +01:00
parent bcd807e57d
commit 26e5b9343d
10 changed files with 1056 additions and 3 deletions
+73 -2
View File
@@ -61,8 +61,41 @@
</div>
</div>
<!-- Apply button -->
<div class="mt-4 flex justify-end">
<!-- Apply button + Download -->
<div class="mt-4 flex justify-between items-center">
<!-- Download buttons (visible only when data is loaded) -->
<div class="flex gap-2" x-show="hasData" x-cloak>
<button @click="downloadReport('spc')"
:disabled="!selectedSubtaskId || downloadingReport"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
<template x-if="downloadingReport === 'spc'">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</template>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" x-show="downloadingReport !== 'spc'">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _("Report SPC") }}
</button>
<button @click="downloadReport('measurements')"
:disabled="downloadingReport"
class="px-4 py-2 bg-[var(--bg-primary)] hover:bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
<template x-if="downloadingReport === 'measurements'">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</template>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" x-show="downloadingReport !== 'measurements'">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _("Report Misurazioni") }}
</button>
</div>
<div x-show="!hasData"></div>
<button @click="loadData()"
:disabled="!selectedRecipeId || loading"
class="px-5 py-2 bg-primary hover:bg-primary-dark text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
@@ -233,6 +266,7 @@ function spcDashboard() {
loading: false,
hasData: false,
errorMessage: '',
downloadingReport: false,
init() {
// Nothing to pre-load
@@ -368,6 +402,43 @@ function spcDashboard() {
if (value >= 1.0) return 'text-amber-600';
return 'text-red-600';
},
async downloadReport(type) {
this.downloadingReport = type;
try {
const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
const url = '/statistics/api/report-' + type + '?' + params.toString();
const resp = await fetch(url);
if (!resp.ok) {
const errData = await resp.json().catch(() => ({}));
this.errorMessage = errData.detail || '{{ _("Errore nella generazione del report") }}';
return;
}
// Download the PDF
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = type === 'spc'
? 'spc_report_' + this.selectedRecipeId + '.pdf'
: 'measurements_report_' + this.selectedRecipeId + '.pdf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
} catch (e) {
console.error('Error downloading report:', e);
this.errorMessage = '{{ _("Errore di connessione al server") }}';
} finally {
this.downloadingReport = false;
}
},
};
}
</script>