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
|
||||
+ 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (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 -->
|
||||
<div v-if="filteredActions.length" class="gs-group">
|
||||
<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 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 || []),
|
||||
|
||||
@@ -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 (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 -->
|
||||
<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>
|
||||
|
||||
@@ -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) -->
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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>
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user