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:
@@ -12,12 +12,15 @@
|
|||||||
* isoWeek/isoMonth + Chart.js).
|
* isoWeek/isoMonth + Chart.js).
|
||||||
*/
|
*/
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import { supabase } from '@/lib/supabase/client';
|
||||||
import { useTenantStore } from '@/stores/tenantStore';
|
import { useTenantStore } from '@/stores/tenantStore';
|
||||||
|
import { exportSessionsToPDF, exportSessionsToXLSX, exportSessionsToCSV } from '@/services/reportExport.service';
|
||||||
// Chart/DataTable/Column/Tag/Skeleton: auto via PrimeVueResolver
|
// Chart/DataTable/Column/Tag/Skeleton: auto via PrimeVueResolver
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close']);
|
||||||
const tenantStore = useTenantStore();
|
const tenantStore = useTenantStore();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
// ── Breakpoints + drawer mobile ────────────────────────
|
// ── Breakpoints + drawer mobile ────────────────────────
|
||||||
const drawerOpen = ref(false);
|
const drawerOpen = ref(false);
|
||||||
@@ -251,6 +254,66 @@ function patientName(s) {
|
|||||||
return s.patients?.nome_completo || '—';
|
return s.patients?.nome_completo || '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Export PDF / Excel / CSV ──────────────────────────
|
||||||
|
const exportingPdf = ref(false);
|
||||||
|
const exportingXlsx = ref(false);
|
||||||
|
|
||||||
|
function buildExportParams() {
|
||||||
|
const period = PERIOD_OPTIONS.find(p => p.key === selectedPeriod.value)?.label || '';
|
||||||
|
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 }
|
||||||
|
],
|
||||||
|
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(selectedPeriod, () => {
|
watch(selectedPeriod, () => {
|
||||||
statusFilter.value = null;
|
statusFilter.value = null;
|
||||||
loadSessions();
|
loadSessions();
|
||||||
@@ -308,6 +371,30 @@ onBeforeUnmount(() => {
|
|||||||
<span class="mr-page__count">{{ periodLabel }}</span>
|
<span class="mr-page__count">{{ periodLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mr-page__actions">
|
<div class="mr-page__actions">
|
||||||
|
<button
|
||||||
|
class="mr-head-btn"
|
||||||
|
v-tooltip.bottom="'Exportar PDF'"
|
||||||
|
:disabled="exportingPdf || loading || total === 0"
|
||||||
|
@click="exportPdf"
|
||||||
|
>
|
||||||
|
<i :class="exportingPdf ? 'pi pi-spin pi-spinner' : 'pi pi-file-pdf'" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mr-head-btn"
|
||||||
|
v-tooltip.bottom="'Exportar Excel (.xlsx)'"
|
||||||
|
:disabled="exportingXlsx || loading || total === 0"
|
||||||
|
@click="exportXlsx"
|
||||||
|
>
|
||||||
|
<i :class="exportingXlsx ? 'pi pi-spin pi-spinner' : 'pi pi-file-excel'" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mr-head-btn"
|
||||||
|
v-tooltip.bottom="'Exportar CSV'"
|
||||||
|
:disabled="loading || total === 0"
|
||||||
|
@click="exportCsv"
|
||||||
|
>
|
||||||
|
<i class="pi pi-table" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="mr-head-btn"
|
class="mr-head-btn"
|
||||||
v-tooltip.bottom="'Recarregar'"
|
v-tooltip.bottom="'Recarregar'"
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Arquivo: src/services/reportExport.service.js
|
||||||
|
|
|
||||||
|
| Export de relatórios em PDF (via pdf.service) e Excel (.xlsx via exceljs).
|
||||||
|
| Foco em sessões da agenda — o relatório principal do MVP.
|
||||||
|
|
|
||||||
|
| Outros relatórios (financeiro, evolução por escala etc) podem reusar
|
||||||
|
| as mesmas helpers genéricas (buildSheetFromRows, buildPdfHtml).
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { htmlToPdfDownload } from '@/services/pdf.service';
|
||||||
|
|
||||||
|
const STATUS_LABEL = {
|
||||||
|
agendado: 'Agendado',
|
||||||
|
realizado: 'Realizado',
|
||||||
|
faltou: 'Faltou',
|
||||||
|
cancelado: 'Cancelado',
|
||||||
|
remarcado: 'Remarcado'
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtDateTime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString('pt-BR') + ' ' + d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString('pt-BR');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFilename(base, ext) {
|
||||||
|
const slug = String(base || 'relatorio')
|
||||||
|
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||||
|
.replace(/[^a-zA-Z0-9_-]/g, '_').replace(/_+/g, '_');
|
||||||
|
const ts = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
|
return `${slug}_${ts}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PDF ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gera PDF do relatório de sessões. Layout simples: header + KPIs + tabela.
|
||||||
|
*
|
||||||
|
* @param {object} params
|
||||||
|
* @param {string} params.title Ex: "Relatório de Sessões"
|
||||||
|
* @param {string} params.subtitle Ex: "Outubro/2026" — qualquer texto
|
||||||
|
* @param {Array} params.sessions Lista normalizada (paciente_nome, inicio_em, status, modalidade, tipo)
|
||||||
|
* @param {Array} params.kpis [{ label, value }] — opcional
|
||||||
|
* @param {string} params.tenantName Nome da clínica (cabeçalho)
|
||||||
|
* @param {string} params.terapeutaNome Nome do terapeuta (subtitle)
|
||||||
|
*/
|
||||||
|
export async function exportSessionsToPDF({ title = 'Relatório de Sessões', subtitle = '', sessions = [], kpis = [], tenantName = '', terapeutaNome = '' } = {}) {
|
||||||
|
const rows = sessions.map(s => `
|
||||||
|
<tr>
|
||||||
|
<td>${fmtDateTime(s.inicio_em)}</td>
|
||||||
|
<td>${escapeHtml(s.paciente_nome || s.patients?.nome_completo || '—')}</td>
|
||||||
|
<td>${escapeHtml(STATUS_LABEL[s.status] || s.status || 'Agendado')}</td>
|
||||||
|
<td>${escapeHtml(s.modalidade || '—')}</td>
|
||||||
|
<td>${escapeHtml(s.tipo || s.titulo || '—')}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const kpiHtml = kpis.length ? `
|
||||||
|
<table class="kpi-grid">
|
||||||
|
<tr>
|
||||||
|
${kpis.map(k => `
|
||||||
|
<td>
|
||||||
|
<div class="kpi-label">${escapeHtml(k.label)}</div>
|
||||||
|
<div class="kpi-value">${escapeHtml(String(k.value ?? '—'))}</div>
|
||||||
|
</td>
|
||||||
|
`).join('')}
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR" style="color-scheme:light;">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@page { size: A4; margin: 18mm 12mm; }
|
||||||
|
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 11pt; color: #1a1a1a; }
|
||||||
|
h1 { font-size: 18pt; margin: 0 0 4px; }
|
||||||
|
h2 { font-size: 12pt; margin: 0 0 16px; color: #4b5563; font-weight: 500; }
|
||||||
|
.header { border-bottom: 2px solid #1d4ed8; padding-bottom: 10px; margin-bottom: 20px; }
|
||||||
|
.header__brand { font-size: 10pt; color: #6b7280; margin-bottom: 4px; }
|
||||||
|
.kpi-grid { width: 100%; border-collapse: collapse; margin-bottom: 22px; }
|
||||||
|
.kpi-grid td { background: #f3f4f6; border: 1px solid #e5e7eb; padding: 10px 12px; text-align: center; width: ${Math.floor(100 / Math.max(kpis.length, 1))}%; }
|
||||||
|
.kpi-label { font-size: 9pt; color: #6b7280; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.kpi-value { font-size: 16pt; font-weight: 700; color: #1d4ed8; margin-top: 4px; }
|
||||||
|
table.data { width: 100%; border-collapse: collapse; font-size: 10pt; }
|
||||||
|
table.data th { background: #1e293b; color: white; padding: 8px 10px; text-align: left; font-weight: 600; font-size: 9pt; text-transform: uppercase; letter-spacing: 0.02em; }
|
||||||
|
table.data td { border-bottom: 1px solid #e5e7eb; padding: 7px 10px; vertical-align: top; }
|
||||||
|
table.data tr:nth-child(even) td { background: #f9fafb; }
|
||||||
|
.footer { margin-top: 24px; font-size: 9pt; color: #9ca3af; text-align: center; border-top: 1px solid #e5e7eb; padding-top: 8px; }
|
||||||
|
.empty { text-align: center; padding: 40px 0; color: #9ca3af; font-style: italic; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div class="header__brand">${escapeHtml(tenantName)}${terapeutaNome ? ' · ' + escapeHtml(terapeutaNome) : ''}</div>
|
||||||
|
<h1>${escapeHtml(title)}</h1>
|
||||||
|
${subtitle ? `<h2>${escapeHtml(subtitle)}</h2>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${kpiHtml}
|
||||||
|
|
||||||
|
${sessions.length === 0
|
||||||
|
? '<div class="empty">Nenhuma sessão encontrada no período selecionado.</div>'
|
||||||
|
: `<table class="data">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 140px;">Data/hora</th>
|
||||||
|
<th>Paciente</th>
|
||||||
|
<th style="width: 100px;">Status</th>
|
||||||
|
<th style="width: 90px;">Modalidade</th>
|
||||||
|
<th style="width: 100px;">Tipo</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
Gerado por AgênciaPSI em ${fmtDate(new Date().toISOString())} · ${sessions.length} sessão(ões)
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`.trim();
|
||||||
|
|
||||||
|
const filename = safeFilename(title, 'pdf');
|
||||||
|
await htmlToPdfDownload(html, filename);
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Excel (.xlsx via exceljs) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gera planilha .xlsx do relatório de sessões.
|
||||||
|
* Cabeçalhos formatados, larguras de coluna razoáveis, 1 worksheet "Sessões".
|
||||||
|
*/
|
||||||
|
export async function exportSessionsToXLSX({ title = 'Relatorio_Sessoes', subtitle = '', sessions = [], kpis = [], tenantName = '' } = {}) {
|
||||||
|
// Import dinâmico — exceljs é pesado, só carrega quando user clicar export
|
||||||
|
const ExcelJSModule = await import('exceljs');
|
||||||
|
const ExcelJS = ExcelJSModule.default || ExcelJSModule;
|
||||||
|
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
wb.creator = tenantName || 'AgênciaPSI';
|
||||||
|
wb.created = new Date();
|
||||||
|
|
||||||
|
const ws = wb.addWorksheet('Sessões', {
|
||||||
|
properties: { tabColor: { argb: 'FF1D4ED8' } },
|
||||||
|
views: [{ state: 'frozen', ySplit: 4 }]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Header da planilha (linhas 1-3): título + subtitle + KPIs
|
||||||
|
ws.mergeCells('A1:E1');
|
||||||
|
const titleCell = ws.getCell('A1');
|
||||||
|
titleCell.value = title;
|
||||||
|
titleCell.font = { size: 16, bold: true, color: { argb: 'FF1D4ED8' } };
|
||||||
|
titleCell.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||||
|
ws.getRow(1).height = 24;
|
||||||
|
|
||||||
|
if (subtitle) {
|
||||||
|
ws.mergeCells('A2:E2');
|
||||||
|
const subCell = ws.getCell('A2');
|
||||||
|
subCell.value = subtitle;
|
||||||
|
subCell.font = { size: 11, color: { argb: 'FF6B7280' } };
|
||||||
|
ws.getRow(2).height = 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kpis.length) {
|
||||||
|
ws.mergeCells('A3:E3');
|
||||||
|
const kpiCell = ws.getCell('A3');
|
||||||
|
kpiCell.value = kpis.map(k => `${k.label}: ${k.value}`).join(' · ');
|
||||||
|
kpiCell.font = { size: 10, italic: true, color: { argb: 'FF374151' } };
|
||||||
|
ws.getRow(3).height = 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header da tabela (linha 4)
|
||||||
|
const headerRow = ws.getRow(4);
|
||||||
|
headerRow.values = ['Data/hora', 'Paciente', 'Status', 'Modalidade', 'Tipo'];
|
||||||
|
headerRow.eachCell((cell) => {
|
||||||
|
cell.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 10 };
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E293B' } };
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||||
|
cell.border = { top: { style: 'thin' }, bottom: { style: 'thin' } };
|
||||||
|
});
|
||||||
|
headerRow.height = 20;
|
||||||
|
|
||||||
|
// Larguras (em "characters" do Excel)
|
||||||
|
ws.columns = [
|
||||||
|
{ width: 20 },
|
||||||
|
{ width: 32 },
|
||||||
|
{ width: 14 },
|
||||||
|
{ width: 14 },
|
||||||
|
{ width: 16 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Linhas de dados
|
||||||
|
sessions.forEach((s, idx) => {
|
||||||
|
const r = ws.addRow([
|
||||||
|
fmtDateTime(s.inicio_em),
|
||||||
|
s.paciente_nome || s.patients?.nome_completo || '—',
|
||||||
|
STATUS_LABEL[s.status] || s.status || 'Agendado',
|
||||||
|
s.modalidade || '—',
|
||||||
|
s.tipo || s.titulo || '—'
|
||||||
|
]);
|
||||||
|
if (idx % 2 === 1) {
|
||||||
|
r.eachCell((cell) => {
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF9FAFB' } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Footer numa célula isolada
|
||||||
|
const lastRow = ws.lastRow.number + 2;
|
||||||
|
ws.mergeCells(`A${lastRow}:E${lastRow}`);
|
||||||
|
const footerCell = ws.getCell(`A${lastRow}`);
|
||||||
|
footerCell.value = `Gerado em ${fmtDate(new Date().toISOString())} · ${sessions.length} registro(s)`;
|
||||||
|
footerCell.font = { size: 9, italic: true, color: { argb: 'FF9CA3AF' } };
|
||||||
|
|
||||||
|
// Download via blob
|
||||||
|
const buf = await wb.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||||
|
const filename = safeFilename(title, 'xlsx');
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||||||
|
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CSV fallback (vanilla, sem deps) ──────────────────────────────────────
|
||||||
|
|
||||||
|
export function exportSessionsToCSV({ title = 'Relatorio_Sessoes', sessions = [] } = {}) {
|
||||||
|
const csvEscape = (v) => {
|
||||||
|
const s = String(v ?? '');
|
||||||
|
if (/[",\n;]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const headers = ['Data/hora', 'Paciente', 'Status', 'Modalidade', 'Tipo'];
|
||||||
|
const rows = sessions.map(s => [
|
||||||
|
fmtDateTime(s.inicio_em),
|
||||||
|
s.paciente_nome || s.patients?.nome_completo || '',
|
||||||
|
STATUS_LABEL[s.status] || s.status || 'Agendado',
|
||||||
|
s.modalidade || '',
|
||||||
|
s.tipo || s.titulo || ''
|
||||||
|
]);
|
||||||
|
const csv = [headers, ...rows].map(r => r.map(csvEscape).join(';')).join('\r\n');
|
||||||
|
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const filename = safeFilename(title, 'csv');
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
@@ -16,9 +16,13 @@
|
|||||||
-->
|
-->
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted } from 'vue';
|
import { ref, computed, watch, onMounted } from 'vue';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import { supabase } from '@/lib/supabase/client';
|
||||||
import { useTenantStore } from '@/stores/tenantStore';
|
import { useTenantStore } from '@/stores/tenantStore';
|
||||||
import { useLayout } from '@/layout/composables/layout';
|
import { useLayout } from '@/layout/composables/layout';
|
||||||
|
import { exportSessionsToPDF, exportSessionsToXLSX, exportSessionsToCSV } from '@/services/reportExport.service';
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const { layoutConfig, isDarkTheme } = useLayout();
|
const { layoutConfig, isDarkTheme } = useLayout();
|
||||||
const tenantStore = useTenantStore();
|
const tenantStore = useTenantStore();
|
||||||
@@ -227,6 +231,68 @@ function patientName(s) {
|
|||||||
return s.patients?.nome_completo || '—';
|
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 & mount ─────────────────────────────────────────
|
||||||
watch(selectedPeriod, () => {
|
watch(selectedPeriod, () => {
|
||||||
filtroTabela.value = null;
|
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" />
|
<SelectButton v-model="selectedPeriod" :options="PERIODS" option-label="label" option-value="value" :allow-empty="false" size="small" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Refresh -->
|
<!-- Refresh + Exports -->
|
||||||
<div class="flex items-center gap-1.5 shrink-0 ml-auto">
|
<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" />
|
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Recarregar" @click="loadSessions" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user