4 Commits

Author SHA1 Message Date
Leonardo d7cd2541e4 wiki + padronizacao: §1.3 UX 3/4 fechado (#10/#11/#13 done · #12 bloqueado)
Atualiza PADRONIZACAO.md marcando §1.3 UX como 3 de 4 fechados.
#12 papel timbrado documentado como bloqueado em codigo externo
do UniaoApp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:22:25 -03:00
Leonardo b1e8e010c0 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>
2026-05-21 05:21:36 -03:00
Leonardo 2dae4a11ae roadmap #11: recently viewed (ultimos 5 pacientes acessados)
ROADMAP item #1.3 #11. localStorage por user_id pra isolar sessoes
diferentes no mesmo browser. ROADMAP sugeria localStorage OU tabela
user_recent_access — escolhi localStorage por simplicidade (sem
migration adicional + zero round-trip por visita).

composables/useRecentPatients.js:
- useRecentPatients() — composable reativo Tipo A: items + hasItems
  + addVisit + remove + clear + refresh
- registerPatientVisit(patient) — helper stateless pra usar fora
  de setup (ex: navigation guards, action handlers)
- Sincroniza entre instancias na mesma aba via CustomEvent + 'storage'
- Max 5 items. Dedup por id, novo no topo.

Wire-up de visita (registra ao carregar prontuario):
- MelissaPaciente.vue: registerPatientVisit no loadAll apos detail.load
- PatientProntuario.vue: registerPatientVisit em loadDetail apos p resolved

Wire-up de visualizacao (mostra quando query vazia):
- GlobalSearch.vue: grupo "Acessados recentemente" antes dos Atalhos.
  goTo("recent") navega pra /therapist/patients/:id.
- MelissaBusca.vue: grupo "Acessados recentemente". emit('paciente')
  reusando a logica do MelissaLayout que ja navega pra
  /melissa/paciente?id=X.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:17:51 -03:00
Leonardo e7a9bdab5f roadmap #10: MelissaBusca usa RPC search_global (promovida de preview)
Fecha ROADMAP #1.3 #10 (busca global topbar). GlobalSearch.vue
classic+rail ja usava RPC. MelissaBusca era client-side preview com
fallback nas props (pacientes+eventos do dia) — agora consulta a
mesma RPC search_global com debounce 200ms + searchSeq pra descartar
respostas obsoletas.

3 grupos novos exibidos quando RPC retorna:
  - rpc-appointments  -> sessoes qualquer data (alem de "hoje")
  - rpc-documents     -> documentos por nome/tipo
  - rpc-intakes       -> cadastros recebidos

Pacientes mescla: RPC tem prioridade (todos os pacientes); props
mantida como fallback rapido (digitacao curta antes do debounce).
Emits estendidos: novos 'documento' + 'intake' alem dos existentes
'acao' + 'paciente' + 'evento'.

MelissaLayout atualizado:
  - @paciente agora navega pra /melissa/paciente?id=X (antes ignorava
    payload e so abria secao generica — bug existente)
  - @documento abre prontuario do paciente com tab=documentos
  - @intake abre /melissa cadastros-recebidos

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:13:47 -03:00
11 changed files with 896 additions and 4 deletions
+37
View File
@@ -1318,3 +1318,40 @@ quando record.status === 'paid'. Toast + loading state.
PROXIMO: UX §1.3 (busca global + recently viewed + papel timbrado
+ relatorios export) OU sweep residual (M4 cutover billing decisoes
#2/#3/#6).
## [2026-05-21 evening] session | ROADMAP §1.3 UX 3/4 (#10/#11/#13)
Touched: none
3 commits fechando 3 dos 4 itens da Fase 1.3 UX:
#10 Busca global topbar — GlobalSearch.vue ja estava feito no Rail/
classic. **MelissaBusca promovida** de preview client-side pra RPC
search_global com debounce 200ms + searchSeq pra ignorar respostas
obsoletas. 3 grupos novos exibidos quando RPC retorna: sessoes,
documentos, cadastros recebidos. @paciente no MelissaLayout
corrigido (antes ignorava payload — bug). Emits novos: documento,
intake.
#11 Recently viewed — composables/useRecentPatients.js (localStorage
por user_id, max 5, dedup, eventos CustomEvent + 'storage' pra sync
entre instancias no mesmo browser). registerPatientVisit chamado
em MelissaPaciente.loadAll e PatientProntuario.loadDetail. Grupo
"Acessados recentemente" no GlobalSearch.vue + MelissaBusca.vue
quando query vazia. Decisao: localStorage > tabela user_recent_access
por simplicidade + zero round-trip por visita.
#13 Relatorios export PDF/Excel — services/reportExport.service.js
com exportSessionsToPDF (pdf.service HTML→PDF + KPIs + tabela A4),
exportSessionsToXLSX (exceljs com import dinamico, frozen header,
alternating rows, branded), exportSessionsToCSV (vanilla, BOM UTF-8,
separador ';'). 3 botoes pi-file-pdf/pi-file-excel/pi-table em
RelatoriosPage.vue (therapist) + MelissaRelatorios.vue. Respeita
filtro de status da tabela.
#12 Papel timbrado — BLOQUEADO: codigo no UniaoApp. Quando user
importar, plugar como cabecalho_html/rodape_html global em
document_templates ou setting tenants.letterhead_html.
PROXIMO: NFS-e (#15, esforco L), §1.5 Sentry (#18 nao-teste),
sweep residual (M4 cutover billing — bloqueado decisoes #2/#3/#6),
ou agenda Fase 4 residual.
+5
View File
@@ -114,6 +114,11 @@ Do `project_graphify_findings_20260504`:
- #8 (nome social): JÁ INTEGRADO — `patients.nome_social` schema existia + UI em 7 arquivos.
- #9 (especialidades): `20260521000004_specialties.sql` (tabela + profile_specialties M:N + RLS) + `seed_050_specialties.sql` (33 specialties) + `src/services/specialtiesService.js`.
- [x] **Recibo profissional PDF (#14 · 2026-05-21)**`valorExtenso.js` helper pt-BR. `DocumentGenerate.service` puxa registro profissional do profile (auto-formato `CRP 12345/SP`), formata `cpf_cnpj` do tenant, computa `valor`+`valor_extenso`, mapeia `templateTipo``tipo_documento` (recibo_pagamento → 'recibo'). Migration `20260521000008` substitui `{{terapeuta_crp}}` por `{{terapeuta_registro}}` no template — universal pra qualquer conselho. `emitirReciboParaSessao(eventoId, opts)` é quick path one-call. Botão "Emitir recibo" no `AgendaEventoFinanceiroPanel` quando `record.status === 'paid'`. #15 NFS-e ainda em aberto.
- [x] **§1.3 UX block 3/4 (#10 + #11 + #13 · 2026-05-21)** —
- #10 Busca global: `GlobalSearch.vue` (RPC `search_global`) já estava completo no AppTopbar/Rail. **MelissaBusca promovida** de client-side preview pra RPC com debounce 200ms + searchSeq. 3 grupos novos (rpc-appointments, rpc-documents, rpc-intakes). `@paciente` no MelissaLayout corrigido pra navegar pro paciente clicado (era bug — ignorava payload).
- #11 Recently viewed: `composables/useRecentPatients.js` (localStorage por user_id, max 5, dedup, eventos sync entre instâncias). `registerPatientVisit` chamado em `MelissaPaciente.loadAll` + `PatientProntuario.loadDetail`. Grupo "Acessados recentemente" no GlobalSearch + MelissaBusca quando query vazia.
- #13 Relatórios export: `services/reportExport.service.js` com 3 funções (PDF via pdf.service, Excel via exceljs com import dinâmico, CSV vanilla). 3 botões no header de `RelatoriosPage.vue` e `MelissaRelatorios.vue`.
- [ ] **#12 Papel timbrado (BLOQUEADO)** — código no UniaoApp (projeto externo). Quando user importar o código, plugar como variável `cabecalho_html`/`rodape_html` global em `document_templates` ou criar setting `tenants.letterhead_html`.
- [ ] NFS-e emissão (#15) — Esforço L, decisão de provider pendente (Focus NF-e vs prefeitura direta).
- [ ] E2E Playwright crítico (#16)
- [ ] Sentry (#18)
+38
View File
@@ -15,9 +15,11 @@ import InputText from 'primevue/inputtext';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { searchPages } from './pagesIndex';
import { useRecentPatients } from '@/composables/useRecentPatients';
const router = useRouter();
const tenantStore = useTenantStore();
const { items: recentPatients, hasItems: hasRecentPatients } = useRecentPatients();
//
// State
@@ -67,9 +69,14 @@ const filteredPages = computed(() => {
//
// Flat list pra navegação por teclado
//
// Recently-viewed só aparece quando a query está vazia não polui resultados de busca.
const showRecent = computed(() => !query.value.trim() && hasRecentPatients.value);
const recentItems = computed(() => showRecent.value ? (recentPatients.value || []).slice(0, 5) : []);
const flatList = computed(() => {
const out = [];
filteredActions.value.forEach((a, i) => out.push({ group: 'actions', item: a, idx: i }));
recentItems.value.forEach((p, i) => out.push({ group: 'recent', item: p, idx: i }));
results.value.patients.forEach((p, i) => out.push({ group: 'patients', item: p, idx: i }));
results.value.intakes.forEach((r, i) => out.push({ group: 'intakes', item: r, idx: i }));
results.value.appointments.forEach((a, i) => out.push({ group: 'appointments', item: a, idx: i }));
@@ -195,6 +202,16 @@ function onInputKeydown(e) {
}
async function goTo(entry) {
// Recent patients: usa id pra navegar pro prontuário do paciente
if (entry?.group === 'recent' && entry?.item?.id) {
showPanel.value = false;
query.value = '';
resetResults();
activeIndex.value = -1;
await router.push({ path: '/therapist/patients/' + entry.item.id });
return;
}
const target = entry?.item?.to || entry?.item?.deeplink || entry?.item?.path;
if (!target) return;
showPanel.value = false;
@@ -266,6 +283,27 @@ const kbdModifier = kbdIsMac ? '⌘' : 'Ctrl';
</div>
<template v-else>
<!-- Acessados recentemente ( quando query vazia) -->
<div v-if="showRecent" class="gs-group">
<div class="gs-group__title">Acessados recentemente</div>
<button
v-for="(p, i) in recentItems"
:key="'rp-' + p.id"
type="button"
class="gs-item"
:class="{ 'is-active': findFlatIndex('recent', i) === activeIndex }"
@mouseenter="activeIndex = findFlatIndex('recent', i)"
@click="goTo({ group: 'recent', item: p, idx: i })"
>
<span class="gs-item__icon"><i class="pi pi-history" /></span>
<span class="gs-item__main">
<span class="gs-item__label">{{ p.nome }}</span>
<span class="gs-item__sub">{{ p.extras?.telefone || p.extras?.email || 'Abrir prontuário' }}</span>
</span>
<i class="gs-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Ações -->
<div v-if="filteredActions.length" class="gs-group">
<div class="gs-group__title">{{ query.trim() ? 'Ações' : 'Atalhos' }}</div>
+171
View File
@@ -0,0 +1,171 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useRecentPatients.js
|
| Tracking dos últimos pacientes acessados pelo usuário logado.
| Armazenado em localStorage por user_id pra isolar sessões diferentes
| no mesmo browser (multi-conta).
|
| Usado pelo GlobalSearch.vue / MelissaBusca.vue como "recently viewed"
| quando o input está vazio, e pode ser embedido em dashboards.
|--------------------------------------------------------------------------
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { supabase } from '@/lib/supabase/client';
const MAX_ITEMS = 5; // top N exibido
const STORAGE_PREFIX = 'agpsi:recent-patients:';
const STORAGE_EVENT = 'agpsi:recent-patients:changed';
function storageKey(userId) {
return `${STORAGE_PREFIX}${userId || 'anon'}`;
}
function loadFromStorage(userId) {
try {
const raw = localStorage.getItem(storageKey(userId));
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveToStorage(userId, items) {
try {
localStorage.setItem(storageKey(userId), JSON.stringify(items));
// Notifica outras instâncias do composable nesta mesma aba
window.dispatchEvent(new CustomEvent(STORAGE_EVENT, { detail: { userId } }));
} catch {
// Quota cheia / modo privado — silenciar
}
}
/**
* @returns composable reativo com `items` (array de pacientes recentes),
* `addVisit(patient)` e `clear()`.
*
* Forma do patient esperado em addVisit:
* { id: string, nome: string, ... } extras (avatar, telefone, etc) são opt-in
*
* Forma do item armazenado:
* { id, nome, visited_at: ISO, extras: {} }
*/
export function useRecentPatients() {
const userId = ref(null);
const items = ref([]);
async function resolveUserId() {
if (userId.value) return userId.value;
const { data } = await supabase.auth.getUser();
userId.value = data?.user?.id || 'anon';
return userId.value;
}
async function refresh() {
const uid = await resolveUserId();
items.value = loadFromStorage(uid);
}
async function addVisit(patient) {
if (!patient?.id) return;
const uid = await resolveUserId();
const current = loadFromStorage(uid);
const entry = {
id: String(patient.id),
nome: patient.nome_completo || patient.nome_social || patient.nome || '(sem nome)',
visited_at: new Date().toISOString(),
extras: {
nome_social: patient.nome_social || null,
avatar_url: patient.avatar_url || null,
telefone: patient.telefone || null,
email: patient.email_principal || patient.email || null
}
};
// Remove duplicata + insere no topo + limita a MAX_ITEMS
const dedup = current.filter(x => x.id !== entry.id);
dedup.unshift(entry);
const trimmed = dedup.slice(0, MAX_ITEMS);
saveToStorage(uid, trimmed);
items.value = trimmed;
}
async function remove(patientId) {
const uid = await resolveUserId();
const current = loadFromStorage(uid);
const filtered = current.filter(x => String(x.id) !== String(patientId));
saveToStorage(uid, filtered);
items.value = filtered;
}
async function clear() {
const uid = await resolveUserId();
saveToStorage(uid, []);
items.value = [];
}
// Sincroniza entre instâncias do composable na mesma aba
function onChange() {
refresh();
}
function onStorage(ev) {
if (typeof ev?.key === 'string' && ev.key.startsWith(STORAGE_PREFIX)) {
refresh();
}
}
onMounted(() => {
refresh();
window.addEventListener(STORAGE_EVENT, onChange);
window.addEventListener('storage', onStorage);
});
onBeforeUnmount(() => {
window.removeEventListener(STORAGE_EVENT, onChange);
window.removeEventListener('storage', onStorage);
});
const hasItems = computed(() => items.value.length > 0);
return {
items,
hasItems,
addVisit,
remove,
clear,
refresh
};
}
// ── Stateless helpers — usáveis fora de componentes (ex: action handlers) ──
/**
* Registra uma visita SEM usar Vue reactivity. Útil pra hooks que não
* estão dentro de setup() (ex: router.beforeEach, navigation guards).
*/
export async function registerPatientVisit(patient) {
if (!patient?.id) return;
const { data } = await supabase.auth.getUser();
const uid = data?.user?.id || 'anon';
const current = loadFromStorage(uid);
const entry = {
id: String(patient.id),
nome: patient.nome_completo || patient.nome_social || patient.nome || '(sem nome)',
visited_at: new Date().toISOString(),
extras: {
nome_social: patient.nome_social || null,
avatar_url: patient.avatar_url || null,
telefone: patient.telefone || null,
email: patient.email_principal || patient.email || null
}
};
const dedup = current.filter(x => x.id !== entry.id);
dedup.unshift(entry);
saveToStorage(uid, dedup.slice(0, MAX_ITEMS));
}
@@ -32,6 +32,7 @@ import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import DocumentsListPage from '@/features/documents/DocumentsListPage.vue';
import PatientConversationsTab from './PatientConversationsTab.vue';
import { registerPatientVisit } from '@/composables/useRecentPatients';
// PROPS / EMITS
const props = defineProps({
@@ -934,6 +935,8 @@ async function loadDetail(id) {
const [p, rel] = await Promise.all([getPatientById(id), getPatientRelations(id)]);
if (!p) throw new Error('Paciente não retornou dados (RLS bloqueando ou ID não existe no banco).');
patientFull.value = p;
// Registra no "recentemente acessados" (localStorage)
try { await registerPatientVisit(p); } catch { /* ignore */ }
const [g, t] = await Promise.all([
getGroupsByIds(rel.groupIds || []),
getTagsByIds(rel.tagIds || []),
+167 -2
View File
@@ -14,7 +14,9 @@
* Quando promover pra produção: trocar a busca por chamada à RPC
* `search_global` + manter a mesma estrutura de panel/items.
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useRecentPatients } from '@/composables/useRecentPatients';
const props = defineProps({
pacientes: { type: Array, default: () => [] },
@@ -31,7 +33,7 @@ const props = defineProps({
}
});
const emit = defineEmits(['acao', 'paciente', 'evento']);
const emit = defineEmits(['acao', 'paciente', 'evento', 'documento', 'intake']);
const rootEl = ref(null);
const inputEl = ref(null);
@@ -39,6 +41,19 @@ const query = ref('');
const showPanel = ref(false);
const activeIndex = ref(-1);
// RPC search_global results preenchidos por debounce conforme o usuário digita.
// Cliente-side (props.pacientes / props.eventos) continua sendo fallback rápido
// pra digitação curta (1 char) e como complemento aos primeiros resultados.
const rpcResults = ref({ patients: [], appointments: [], documents: [], services: [], intakes: [] });
const searching = ref(false);
let debounceT = null;
let searchSeq = 0;
// Recently viewed (localStorage) só aparece quando o input está vazio
const { items: recentPatients, hasItems: hasRecentPatients } = useRecentPatients();
const showRecent = computed(() => !query.value.trim() && hasRecentPatients.value);
const recentItems = computed(() => showRecent.value ? (recentPatients.value || []).slice(0, 5) : []);
function normalize(s) {
return String(s || '')
.normalize('NFD')
@@ -62,9 +77,22 @@ const filteredAtalhos = computed(() => {
}).slice(0, 5);
});
// Pacientes combina RPC (autoritativo, todos os pacientes) com props (preview de hoje).
// RPC tem prioridade; props complementa quando RPC ainda não trouxe nada.
const filteredPacientes = computed(() => {
const q = normalize(query.value);
if (q.length < 2) return [];
const rpc = rpcResults.value.patients || [];
if (rpc.length) {
return rpc.slice(0, 5).map(p => ({
id: p.id,
nome: p.nome_completo || p.nome_social || p.nome || '(sem nome)',
email: p.email,
telefone: p.telefone
}));
}
// Fallback client-side
return props.pacientes
.filter((p) => normalize(p.nome).includes(q))
.slice(0, 5);
@@ -83,11 +111,20 @@ const filteredEventos = computed(() => {
.slice(0, 5);
});
// Resultados exclusivos da RPC (não há fallback client-side)
const rpcAppointments = computed(() => rpcResults.value.appointments || []);
const rpcDocuments = computed(() => rpcResults.value.documents || []);
const rpcIntakes = computed(() => rpcResults.value.intakes || []);
const flatList = computed(() => {
const out = [];
recentItems.value.forEach((p, i) => out.push({ group: 'recent', item: p, idx: i }));
filteredAtalhos.value.forEach((a, i) => out.push({ group: 'atalhos', item: a, idx: i }));
filteredPacientes.value.forEach((p, i) => out.push({ group: 'pacientes', item: p, idx: i }));
filteredEventos.value.forEach((e, i) => out.push({ group: 'eventos', item: e, idx: i }));
rpcAppointments.value.forEach((a, i) => out.push({ group: 'rpc-appointments', item: a, idx: i }));
rpcDocuments.value.forEach((d, i) => out.push({ group: 'rpc-documents', item: d, idx: i }));
rpcIntakes.value.forEach((r, i) => out.push({ group: 'rpc-intakes', item: r, idx: i }));
return out;
});
@@ -100,7 +137,11 @@ function findFlatIndex(group, idx) {
function selectEntry(entry) {
if (entry.group === 'atalhos') emit('acao', entry.item.id);
else if (entry.group === 'pacientes') emit('paciente', entry.item);
else if (entry.group === 'recent') emit('paciente', { id: entry.item.id, nome: entry.item.nome, ...entry.item.extras });
else if (entry.group === 'eventos') emit('evento', entry.item);
else if (entry.group === 'rpc-appointments') emit('evento', entry.item);
else if (entry.group === 'rpc-documents') emit('documento', entry.item);
else if (entry.group === 'rpc-intakes') emit('intake', entry.item);
closePanel();
}
@@ -149,6 +190,49 @@ function onGlobalKeydown(e) {
}
}
// RPC search_global debounced
// Mesmo padrão do GlobalSearch.vue: query >= 2 chars dispara em 200ms,
// controla ordem via searchSeq pra ignorar respostas obsoletas.
function resetRpcResults() {
rpcResults.value = { patients: [], appointments: [], documents: [], services: [], intakes: [] };
}
watch(query, (v) => {
if (debounceT) clearTimeout(debounceT);
const q = String(v || '').trim();
if (q.length < 2) {
resetRpcResults();
searching.value = false;
return;
}
searching.value = true;
const mySeq = ++searchSeq;
debounceT = setTimeout(async () => {
try {
const { data, error } = await supabase.rpc('search_global', { p_q: q, p_limit: 6 });
if (mySeq !== searchSeq) return;
if (error) {
console.error('[MelissaBusca search_global]', error);
resetRpcResults();
} else {
rpcResults.value = {
patients: Array.isArray(data?.patients) ? data.patients : [],
appointments: Array.isArray(data?.appointments) ? data.appointments : [],
documents: Array.isArray(data?.documents) ? data.documents : [],
services: Array.isArray(data?.services) ? data.services : [],
intakes: Array.isArray(data?.intakes) ? data.intakes : []
};
}
} catch (e) {
if (mySeq !== searchSeq) return;
console.error('[MelissaBusca search_global exception]', e);
resetRpcResults();
} finally {
if (mySeq === searchSeq) searching.value = false;
}
}, 200);
});
onMounted(() => {
document.addEventListener('mousedown', onClickOutside);
window.addEventListener('keydown', onGlobalKeydown);
@@ -156,6 +240,7 @@ onMounted(() => {
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onClickOutside);
window.removeEventListener('keydown', onGlobalKeydown);
if (debounceT) clearTimeout(debounceT);
});
</script>
@@ -184,6 +269,26 @@ onBeforeUnmount(() => {
Nada encontrado pra "<span class="text-white/80">{{ query.trim() }}</span>"
</div>
<!-- Acessados recentemente ( quando query vazia) -->
<div v-if="showRecent" class="mb-group">
<div class="mb-group__title">Acessados recentemente</div>
<button
v-for="(p, i) in recentItems"
:key="'mr-' + p.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('recent', i) === activeIndex }"
@click="selectEntry({ group: 'recent', item: p })"
@mouseenter="activeIndex = findFlatIndex('recent', i)"
>
<span class="mb-item__icon"><i class="pi pi-history" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ p.nome }}</span>
<span class="mb-item__sub">{{ p.extras?.telefone || p.extras?.email || 'Abrir prontuário' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Atalhos -->
<div v-if="filteredAtalhos.length" class="mb-group">
<div class="mb-group__title">{{ query.trim() ? 'Ações' : 'Atalhos' }}</div>
@@ -248,6 +353,66 @@ onBeforeUnmount(() => {
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
<!-- RPC: Sessões/agendamentos (qualquer data) -->
<div v-if="rpcAppointments.length" class="mb-group">
<div class="mb-group__title">Sessões</div>
<button
v-for="(e, i) in rpcAppointments"
:key="'rpc-a-' + e.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('rpc-appointments', i) === activeIndex }"
@click="selectEntry({ group: 'rpc-appointments', item: e })"
@mouseenter="activeIndex = findFlatIndex('rpc-appointments', i)"
>
<span class="mb-item__icon"><i class="pi pi-calendar" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ e.paciente_nome || e.title || 'Sessão' }}</span>
<span class="mb-item__sub">{{ e.inicio_em ? new Date(e.inicio_em).toLocaleDateString('pt-BR') + ' ' + new Date(e.inicio_em).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : 'Sem data' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
<!-- RPC: Documentos -->
<div v-if="rpcDocuments.length" class="mb-group">
<div class="mb-group__title">Documentos</div>
<button
v-for="(d, i) in rpcDocuments"
:key="'rpc-d-' + d.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('rpc-documents', i) === activeIndex }"
@click="selectEntry({ group: 'rpc-documents', item: d })"
@mouseenter="activeIndex = findFlatIndex('rpc-documents', i)"
>
<span class="mb-item__icon"><i class="pi pi-file" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ d.nome_original || 'Documento' }}</span>
<span class="mb-item__sub">{{ d.paciente_nome ? `${d.paciente_nome} · ` : '' }}{{ d.tipo_documento || 'outro' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
<!-- RPC: Cadastros recebidos (intakes) -->
<div v-if="rpcIntakes.length" class="mb-group">
<div class="mb-group__title">Cadastros recebidos</div>
<button
v-for="(r, i) in rpcIntakes"
:key="'rpc-i-' + r.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('rpc-intakes', i) === activeIndex }"
@click="selectEntry({ group: 'rpc-intakes', item: r })"
@mouseenter="activeIndex = findFlatIndex('rpc-intakes', i)"
>
<span class="mb-item__icon"><i class="pi pi-inbox" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ r.nome_completo || 'Cadastro' }}</span>
<span class="mb-item__sub">{{ r.created_at ? new Date(r.created_at).toLocaleDateString('pt-BR') : '' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
</div>
</Transition>
</div>
+3 -1
View File
@@ -2337,8 +2337,10 @@ function onKeydown(e) {
:pacientes="pacientesReais"
:eventos="eventosHojeReais"
@acao="abrirSecao"
@paciente="() => abrirSecao('pacientes')"
@paciente="(p) => p?.id ? router.push({ path: '/melissa/paciente', query: { id: String(p.id) } }) : abrirSecao('pacientes')"
@evento="abrirEvento"
@documento="(d) => d?.patient_id ? router.push({ path: '/melissa/paciente', query: { id: String(d.patient_id), tab: 'documentos' } }) : abrirSecao('pacientes')"
@intake="() => abrirSecao('cadastros-recebidos')"
/>
<!-- Timeline horizontal + vertical (responsivo) -->
+8
View File
@@ -28,6 +28,7 @@ import DocumentsListPage from '@/features/documents/DocumentsListPage.vue';
import PatientConversationsTab from '@/features/patients/prontuario/PatientConversationsTab.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import { usePatientDetail } from '@/features/patients/composables/usePatientDetail';
import { registerPatientVisit } from '@/composables/useRecentPatients';
import { usePatientSessions } from '@/features/patients/composables/usePatientSessions';
import { usePatientFinancial } from '@/features/patients/composables/usePatientFinancial';
import { usePatientMessages } from '@/features/patients/composables/usePatientMessages';
@@ -617,6 +618,13 @@ async function loadAll(id) {
documentsHook.load(id),
recorrenciasHook.load(id)
]);
// Registra visita no histórico "recentemente acessados" (localStorage).
// Fora do Promise.all pra não bloquear renderização.
const p = detail.patient?.value;
if (p?.id) {
try { await registerPatientVisit(p); } catch { /* ignore */ }
}
}
watch(() => props.patientId, async (id) => {
+87
View File
@@ -12,12 +12,15 @@
* isoWeek/isoMonth + Chart.js).
*/
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { exportSessionsToPDF, exportSessionsToXLSX, exportSessionsToCSV } from '@/services/reportExport.service';
// Chart/DataTable/Column/Tag/Skeleton: auto via PrimeVueResolver
const emit = defineEmits(['close']);
const tenantStore = useTenantStore();
const toast = useToast();
// Breakpoints + drawer mobile
const drawerOpen = ref(false);
@@ -251,6 +254,66 @@ function patientName(s) {
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, () => {
statusFilter.value = null;
loadSessions();
@@ -308,6 +371,30 @@ onBeforeUnmount(() => {
<span class="mr-page__count">{{ periodLabel }}</span>
</div>
<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
class="mr-head-btn"
v-tooltip.bottom="'Recarregar'"
+281
View File
@@ -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) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[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;
}
+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>