roadmap #13: relatorios export PDF + Excel + CSV

ROADMAP item #1.3 #13. exceljs e jspdf ja estavam no package.json
mas as paginas de relatorio so renderizavam UI — zero export.

src/services/reportExport.service.js (novo) com 3 funcoes:
- exportSessionsToPDF: layout HTML→PDF via pdf.service.js (header
  com branding tenant, KPI grid, tabela A4 com striping)
- exportSessionsToXLSX: ExcelJS workbook formatado (titulo + subtitle
  + KPIs inline + tabela com header escuro + alternating row + frozen
  header). Import dinamico — exceljs e pesado, so carrega no click.
- exportSessionsToCSV: vanilla (sem deps) com BOM UTF-8 + separador
  ';' (Excel-friendly em pt-BR)

3 botoes em ambas paginas:
- RelatoriosPage.vue (/therapist/relatorios): icones pi-file-pdf +
  pi-file-excel + pi-table no header (rounded), tooltip, disabled
  quando total=0 ou loading, toast de sucesso/erro
- MelissaRelatorios.vue (Melissa secao): mesma logica, botoes nativos
  .mr-head-btn no padrao Melissa

Filtro de status da tabela e respeitado no export (exporta o que
o usuario esta vendo). KPIs incluidos no PDF e XLSX.

§1.3 UX = 3/4 fechado: #10 (busca global) + #11 (recently viewed) +
#13 (relatorios export). #12 (papel timbrado) bloqueado em codigo
externo do UniaoApp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-21 05:21:36 -03:00
parent 2dae4a11ae
commit b1e8e010c0
3 changed files with 464 additions and 1 deletions
+96 -1
View File
@@ -16,9 +16,13 @@
-->
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useLayout } from '@/layout/composables/layout';
import { exportSessionsToPDF, exportSessionsToXLSX, exportSessionsToCSV } from '@/services/reportExport.service';
const toast = useToast();
const { layoutConfig, isDarkTheme } = useLayout();
const tenantStore = useTenantStore();
@@ -227,6 +231,68 @@ function patientName(s) {
return s.patients?.nome_completo || '—';
}
// ── Export PDF / Excel / CSV ──────────────────────────────
const exportingPdf = ref(false);
const exportingXlsx = ref(false);
function buildExportParams() {
const period = PERIODS.find(p => p.value === selectedPeriod.value)?.label || '';
// Normaliza patients.nome_completo pra paciente_nome (RelatoriosPage usa nested patients(...))
const normalized = sessionsFiltradas.value.map(s => ({
...s,
paciente_nome: s.patients?.nome_completo || '—'
}));
return {
title: 'Relatório de Sessões',
subtitle: period,
sessions: normalized,
kpis: [
{ label: 'Total', value: total.value },
{ label: 'Realizadas', value: realizadas.value },
{ label: 'Faltas', value: faltas.value },
{ label: 'Canceladas', value: canceladas.value },
{ label: 'Taxa', value: taxaRealizacao.value != null ? `${taxaRealizacao.value}%` : '—' }
],
tenantName: tenantStore.activeTenantName || tenantStore.tenant?.name || '',
terapeutaNome: tenantStore.user?.full_name || tenantStore.user?.email || ''
};
}
async function exportPdf() {
if (exportingPdf.value) return;
exportingPdf.value = true;
try {
const file = await exportSessionsToPDF(buildExportParams());
toast.add({ severity: 'success', summary: 'PDF gerado', detail: file, life: 2500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao gerar PDF', detail: e?.message || '', life: 4500 });
} finally {
exportingPdf.value = false;
}
}
async function exportXlsx() {
if (exportingXlsx.value) return;
exportingXlsx.value = true;
try {
const file = await exportSessionsToXLSX(buildExportParams());
toast.add({ severity: 'success', summary: 'Excel gerado', detail: file, life: 2500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao gerar Excel', detail: e?.message || '', life: 4500 });
} finally {
exportingXlsx.value = false;
}
}
function exportCsv() {
try {
const file = exportSessionsToCSV(buildExportParams());
toast.add({ severity: 'success', summary: 'CSV gerado', detail: file, life: 2500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao gerar CSV', detail: e?.message || '', life: 4500 });
}
}
// ── Watch & mount ─────────────────────────────────────────
watch(selectedPeriod, () => {
filtroTabela.value = null;
@@ -270,8 +336,37 @@ onMounted(loadSessions);
<SelectButton v-model="selectedPeriod" :options="PERIODS" option-label="label" option-value="value" :allow-empty="false" size="small" />
</div>
<!-- Refresh -->
<!-- Refresh + Exports -->
<div class="flex items-center gap-1.5 shrink-0 ml-auto">
<Button
icon="pi pi-file-pdf"
severity="secondary"
outlined
class="h-9 w-9 rounded-full"
:loading="exportingPdf"
:disabled="!hasLoaded || loading || total === 0"
v-tooltip.bottom="'Exportar PDF'"
@click="exportPdf"
/>
<Button
icon="pi pi-file-excel"
severity="secondary"
outlined
class="h-9 w-9 rounded-full"
:loading="exportingXlsx"
:disabled="!hasLoaded || loading || total === 0"
v-tooltip.bottom="'Exportar Excel (.xlsx)'"
@click="exportXlsx"
/>
<Button
icon="pi pi-table"
severity="secondary"
outlined
class="h-9 w-9 rounded-full"
:disabled="!hasLoaded || loading || total === 0"
v-tooltip.bottom="'Exportar CSV'"
@click="exportCsv"
/>
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Recarregar" @click="loadSessions" />
</div>
</div>