Compare commits
4 Commits
36402cd0bf
...
d7cd2541e4
| Author | SHA1 | Date | |
|---|---|---|---|
| d7cd2541e4 | |||
| b1e8e010c0 | |||
| 2dae4a11ae | |||
| e7a9bdab5f |
@@ -1318,3 +1318,40 @@ quando record.status === 'paid'. Toast + loading state.
|
|||||||
PROXIMO: UX §1.3 (busca global + recently viewed + papel timbrado
|
PROXIMO: UX §1.3 (busca global + recently viewed + papel timbrado
|
||||||
+ relatorios export) OU sweep residual (M4 cutover billing decisoes
|
+ relatorios export) OU sweep residual (M4 cutover billing decisoes
|
||||||
#2/#3/#6).
|
#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.
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ Do `project_graphify_findings_20260504`:
|
|||||||
- #8 (nome social): JÁ INTEGRADO — `patients.nome_social` schema existia + UI em 7 arquivos.
|
- #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`.
|
- #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] **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).
|
- [ ] NFS-e emissão (#15) — Esforço L, decisão de provider pendente (Focus NF-e vs prefeitura direta).
|
||||||
- [ ] E2E Playwright crítico (#16)
|
- [ ] E2E Playwright crítico (#16)
|
||||||
- [ ] Sentry (#18)
|
- [ ] Sentry (#18)
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import InputText from 'primevue/inputtext';
|
|||||||
import { supabase } from '@/lib/supabase/client';
|
import { supabase } from '@/lib/supabase/client';
|
||||||
import { useTenantStore } from '@/stores/tenantStore';
|
import { useTenantStore } from '@/stores/tenantStore';
|
||||||
import { searchPages } from './pagesIndex';
|
import { searchPages } from './pagesIndex';
|
||||||
|
import { useRecentPatients } from '@/composables/useRecentPatients';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const tenantStore = useTenantStore();
|
const tenantStore = useTenantStore();
|
||||||
|
const { items: recentPatients, hasItems: hasRecentPatients } = useRecentPatients();
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────
|
||||||
// State
|
// State
|
||||||
@@ -67,9 +69,14 @@ const filteredPages = computed(() => {
|
|||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────
|
||||||
// Flat list pra navegação por teclado
|
// 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 flatList = computed(() => {
|
||||||
const out = [];
|
const out = [];
|
||||||
filteredActions.value.forEach((a, i) => out.push({ group: 'actions', item: a, idx: i }));
|
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.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.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 }));
|
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) {
|
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;
|
const target = entry?.item?.to || entry?.item?.deeplink || entry?.item?.path;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
showPanel.value = false;
|
showPanel.value = false;
|
||||||
@@ -266,6 +283,27 @@ const kbdModifier = kbdIsMac ? '⌘' : 'Ctrl';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<!-- Acessados recentemente (só 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 -->
|
<!-- Ações -->
|
||||||
<div v-if="filteredActions.length" class="gs-group">
|
<div v-if="filteredActions.length" class="gs-group">
|
||||||
<div class="gs-group__title">{{ query.trim() ? 'Ações' : 'Atalhos' }}</div>
|
<div class="gs-group__title">{{ query.trim() ? 'Ações' : 'Atalhos' }}</div>
|
||||||
|
|||||||
@@ -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 DocumentsListPage from '@/features/documents/DocumentsListPage.vue';
|
||||||
import PatientConversationsTab from './PatientConversationsTab.vue';
|
import PatientConversationsTab from './PatientConversationsTab.vue';
|
||||||
|
import { registerPatientVisit } from '@/composables/useRecentPatients';
|
||||||
|
|
||||||
// ── PROPS / EMITS ──────────────────────────────────────────────
|
// ── PROPS / EMITS ──────────────────────────────────────────────
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -934,6 +935,8 @@ async function loadDetail(id) {
|
|||||||
const [p, rel] = await Promise.all([getPatientById(id), getPatientRelations(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).');
|
if (!p) throw new Error('Paciente não retornou dados (RLS bloqueando ou ID não existe no banco).');
|
||||||
patientFull.value = p;
|
patientFull.value = p;
|
||||||
|
// Registra no "recentemente acessados" (localStorage)
|
||||||
|
try { await registerPatientVisit(p); } catch { /* ignore */ }
|
||||||
const [g, t] = await Promise.all([
|
const [g, t] = await Promise.all([
|
||||||
getGroupsByIds(rel.groupIds || []),
|
getGroupsByIds(rel.groupIds || []),
|
||||||
getTagsByIds(rel.tagIds || []),
|
getTagsByIds(rel.tagIds || []),
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
* Quando promover pra produção: trocar a busca por chamada à RPC
|
* Quando promover pra produção: trocar a busca por chamada à RPC
|
||||||
* `search_global` + manter a mesma estrutura de panel/items.
|
* `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({
|
const props = defineProps({
|
||||||
pacientes: { type: Array, default: () => [] },
|
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 rootEl = ref(null);
|
||||||
const inputEl = ref(null);
|
const inputEl = ref(null);
|
||||||
@@ -39,6 +41,19 @@ const query = ref('');
|
|||||||
const showPanel = ref(false);
|
const showPanel = ref(false);
|
||||||
const activeIndex = ref(-1);
|
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) {
|
function normalize(s) {
|
||||||
return String(s || '')
|
return String(s || '')
|
||||||
.normalize('NFD')
|
.normalize('NFD')
|
||||||
@@ -62,9 +77,22 @@ const filteredAtalhos = computed(() => {
|
|||||||
}).slice(0, 5);
|
}).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 filteredPacientes = computed(() => {
|
||||||
const q = normalize(query.value);
|
const q = normalize(query.value);
|
||||||
if (q.length < 2) return [];
|
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
|
return props.pacientes
|
||||||
.filter((p) => normalize(p.nome).includes(q))
|
.filter((p) => normalize(p.nome).includes(q))
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
@@ -83,11 +111,20 @@ const filteredEventos = computed(() => {
|
|||||||
.slice(0, 5);
|
.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 flatList = computed(() => {
|
||||||
const out = [];
|
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 }));
|
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 }));
|
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 }));
|
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;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,7 +137,11 @@ function findFlatIndex(group, idx) {
|
|||||||
function selectEntry(entry) {
|
function selectEntry(entry) {
|
||||||
if (entry.group === 'atalhos') emit('acao', entry.item.id);
|
if (entry.group === 'atalhos') emit('acao', entry.item.id);
|
||||||
else if (entry.group === 'pacientes') emit('paciente', entry.item);
|
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 === '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();
|
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(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('mousedown', onClickOutside);
|
document.addEventListener('mousedown', onClickOutside);
|
||||||
window.addEventListener('keydown', onGlobalKeydown);
|
window.addEventListener('keydown', onGlobalKeydown);
|
||||||
@@ -156,6 +240,7 @@ onMounted(() => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('mousedown', onClickOutside);
|
document.removeEventListener('mousedown', onClickOutside);
|
||||||
window.removeEventListener('keydown', onGlobalKeydown);
|
window.removeEventListener('keydown', onGlobalKeydown);
|
||||||
|
if (debounceT) clearTimeout(debounceT);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -184,6 +269,26 @@ onBeforeUnmount(() => {
|
|||||||
Nada encontrado pra "<span class="text-white/80">{{ query.trim() }}</span>"
|
Nada encontrado pra "<span class="text-white/80">{{ query.trim() }}</span>"
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Acessados recentemente (só 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 -->
|
<!-- Atalhos -->
|
||||||
<div v-if="filteredAtalhos.length" class="mb-group">
|
<div v-if="filteredAtalhos.length" class="mb-group">
|
||||||
<div class="mb-group__title">{{ query.trim() ? 'Ações' : 'Atalhos' }}</div>
|
<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" />
|
<i class="mb-item__go pi pi-arrow-right" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2337,8 +2337,10 @@ function onKeydown(e) {
|
|||||||
:pacientes="pacientesReais"
|
:pacientes="pacientesReais"
|
||||||
:eventos="eventosHojeReais"
|
:eventos="eventosHojeReais"
|
||||||
@acao="abrirSecao"
|
@acao="abrirSecao"
|
||||||
@paciente="() => abrirSecao('pacientes')"
|
@paciente="(p) => p?.id ? router.push({ path: '/melissa/paciente', query: { id: String(p.id) } }) : abrirSecao('pacientes')"
|
||||||
@evento="abrirEvento"
|
@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) -->
|
<!-- Timeline horizontal + vertical (responsivo) -->
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import DocumentsListPage from '@/features/documents/DocumentsListPage.vue';
|
|||||||
import PatientConversationsTab from '@/features/patients/prontuario/PatientConversationsTab.vue';
|
import PatientConversationsTab from '@/features/patients/prontuario/PatientConversationsTab.vue';
|
||||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
||||||
import { usePatientDetail } from '@/features/patients/composables/usePatientDetail';
|
import { usePatientDetail } from '@/features/patients/composables/usePatientDetail';
|
||||||
|
import { registerPatientVisit } from '@/composables/useRecentPatients';
|
||||||
import { usePatientSessions } from '@/features/patients/composables/usePatientSessions';
|
import { usePatientSessions } from '@/features/patients/composables/usePatientSessions';
|
||||||
import { usePatientFinancial } from '@/features/patients/composables/usePatientFinancial';
|
import { usePatientFinancial } from '@/features/patients/composables/usePatientFinancial';
|
||||||
import { usePatientMessages } from '@/features/patients/composables/usePatientMessages';
|
import { usePatientMessages } from '@/features/patients/composables/usePatientMessages';
|
||||||
@@ -617,6 +618,13 @@ async function loadAll(id) {
|
|||||||
documentsHook.load(id),
|
documentsHook.load(id),
|
||||||
recorrenciasHook.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) => {
|
watch(() => props.patientId, async (id) => {
|
||||||
|
|||||||
@@ -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