From 532204708e557140e9b572174903d8697525a4fc Mon Sep 17 00:00:00 2001 From: Leonardo Date: Wed, 6 May 2026 10:14:16 -0300 Subject: [PATCH] Documentos + Templates + Relatorios nativas (so resta online-scheduling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promove '/melissa/documentos', '/melissa/documentos-templates' e '/melissa/relatorios' do embed pra paginas nativas Melissa. MelissaDocumentos (~700L): - Sidebar com stats (Total / Tamanho / Tipos / Pendentes amber) + filtro Tipo (Select com TIPOS_DOCUMENTO 11 opcoes) + filtro Tag (Select dinamico com usedTags) + footer fixo Limpar filtros - Main: toolbar busca + lista de DocumentCard (componente reusado) - Modo "todos os pacientes" — patientId null. Upload/Gerar exigem abrir paciente especifico no prontuario (botoes nao aparecem). - Dialogs reusados: PreviewDialog + SignatureDialog + ShareDialog + ConfirmDialog (delete). MelissaDocumentosTemplates (~700L): - Layout 1-col empilhado, 3 views: list / create / edit - Header com botao "Novo template" (list) ou "Cancelar/Salvar" (create/edit) + back button - 2 sections distintas: "Templates padrao do sistema" (info-blue, click duplica) e "Meus templates" (accent, click edita + menu de acoes Duplicar/Editar/Desativar) - Cards em grid responsivo (auto-fill 280px), com badge "padrao"/ "inativo" e count de variaveis - DocumentTemplateEditor reusado pra create/edit - ConfirmDialog reusado MelissaRelatorios (~1100L): - Sidebar com 6 stats (Total / Realizadas verde / Faltas red / Canceladas warn / Agendadas info / Taxa realizacao) + filtro Periodo (button list: semana/mes/3meses/6meses) + filtro Status (Realizadas/Faltas/Canceladas/Agendadas com cores) + footer Limpar filtros - Main: card Grafico (Chart.js stacked bar agrupado por semana/mes) + card DataTable de sessoes filtradas (Data/Hora sortable / Paciente / Sessao / Modalidade / Status) - Empty states distintos: sem sessoes no periodo / sem resultado do filtro Logica preservada das paginas originais. Composables/services nao foram tocados — apenas adaptacao do chrome pra blueprint Melissa. DocumentsListPage / DocumentTemplatesPage / RelatoriosPage continuam intactas no layout Rail (/therapist/*, /admin/*). Wire-up MelissaLayout: imports + 3 render blocks + 'documentos', 'documentos-templates', 'relatorios' literais em NON_CONFIG_SLUGS; removidos de MELISSA_EMBED_KEYS. Entries removidos do EMBED_MAP em MelissaEmbed (resta apenas 'online-scheduling'). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/layout/melissa/MelissaDocumentos.vue | 703 ++++++++++++ .../melissa/MelissaDocumentosTemplates.vue | 708 ++++++++++++ src/layout/melissa/MelissaEmbed.vue | 31 +- src/layout/melissa/MelissaLayout.vue | 25 +- src/layout/melissa/MelissaRelatorios.vue | 1014 +++++++++++++++++ 5 files changed, 2450 insertions(+), 31 deletions(-) create mode 100644 src/layout/melissa/MelissaDocumentos.vue create mode 100644 src/layout/melissa/MelissaDocumentosTemplates.vue create mode 100644 src/layout/melissa/MelissaRelatorios.vue diff --git a/src/layout/melissa/MelissaDocumentos.vue b/src/layout/melissa/MelissaDocumentos.vue new file mode 100644 index 0000000..266f915 --- /dev/null +++ b/src/layout/melissa/MelissaDocumentos.vue @@ -0,0 +1,703 @@ + + + + + diff --git a/src/layout/melissa/MelissaDocumentosTemplates.vue b/src/layout/melissa/MelissaDocumentosTemplates.vue new file mode 100644 index 0000000..e770243 --- /dev/null +++ b/src/layout/melissa/MelissaDocumentosTemplates.vue @@ -0,0 +1,708 @@ + + + + + diff --git a/src/layout/melissa/MelissaEmbed.vue b/src/layout/melissa/MelissaEmbed.vue index 726d5c2..f77ab14 100644 --- a/src/layout/melissa/MelissaEmbed.vue +++ b/src/layout/melissa/MelissaEmbed.vue @@ -29,39 +29,14 @@ const emit = defineEmits(['close']); // Mantido neste arquivo (não no MelissaLayout) pra que adicionar uma // nova page aqui não exija mexer no parent. const EMBED_MAP = { - // 'financeiro' e 'financeiro-lancamentos' foram promovidos pra páginas - // nativas (MelissaFinanceiro / MelissaFinanceiroLancamentos). - 'documentos': { - label: 'Documentos', - desc: 'Documentos clínicos do tenant — geração, edição e histórico.', - icon: 'pi pi-file', - comp: defineAsyncComponent(() => import('@/features/documents/DocumentsListPage.vue')) - }, - 'documentos-templates': { - label: 'Templates de documentos', - desc: 'Modelos reutilizáveis pra prontuários e relatórios.', - icon: 'pi pi-file-edit', - comp: defineAsyncComponent(() => import('@/features/documents/DocumentTemplatesPage.vue')) - }, - // 'agendamentos-recebidos' migrou pra Melissa Page nativa - // (MelissaAgendamentosRecebidos.vue) — segue o blueprint - // melissa-table-page-blueprint.md. Removido do embed map. + // Quase todas as pages foram promovidas pra Melissa nativas (eliminando + // o triplo-header). Resta apenas 'online-scheduling' por enquanto. 'online-scheduling': { label: 'Agendador online', desc: 'Configure o link público pra pacientes solicitarem horários.', icon: 'pi pi-calendar-clock', comp: defineAsyncComponent(() => import('@/views/pages/therapist/OnlineSchedulingPage.vue')) - }, - 'relatorios': { - label: 'Relatórios', - desc: 'Indicadores e relatórios do tenant — clínico e financeiro.', - icon: 'pi pi-chart-bar', - comp: defineAsyncComponent(() => import('@/views/pages/therapist/RelatoriosPage.vue')) - }, - // 'notificacoes' e 'link-externo' foram promovidos pra páginas nativas - // Melissa (MelissaNotificacoes / MelissaLinkExterno) — eliminado o - // triplo header que aparecia no embed. Wire-up agora no MelissaLayout.vue, - // não passam mais por aqui. + } }; const info = computed(() => EMBED_MAP[props.secaoRota] || null); diff --git a/src/layout/melissa/MelissaLayout.vue b/src/layout/melissa/MelissaLayout.vue index 5399a83..606ea2d 100644 --- a/src/layout/melissa/MelissaLayout.vue +++ b/src/layout/melissa/MelissaLayout.vue @@ -40,6 +40,9 @@ import MelissaLinkExterno from './MelissaLinkExterno.vue'; import MelissaNotificacoes from './MelissaNotificacoes.vue'; import MelissaFinanceiro from './MelissaFinanceiro.vue'; import MelissaFinanceiroLancamentos from './MelissaFinanceiroLancamentos.vue'; +import MelissaDocumentos from './MelissaDocumentos.vue'; +import MelissaDocumentosTemplates from './MelissaDocumentosTemplates.vue'; +import MelissaRelatorios from './MelissaRelatorios.vue'; import MelissaMedicos from './MelissaMedicos.vue'; import MelissaEventoPanel from './MelissaEventoPanel.vue'; import { TOQUES, playToque } from './melissaToques'; @@ -178,9 +181,9 @@ const SECOES = { }; // Set de keys que renderizam via MelissaEmbed (Onda 1 — pages 1-coluna). -// 'link-externo', 'notificacoes', 'financeiro' e 'financeiro-lancamentos' -// foram promovidos pra páginas nativas pra remover o triplo-header. -const MELISSA_EMBED_KEYS = ['documentos', 'documentos-templates', 'online-scheduling', 'relatorios']; +// Quase todas foram promovidas pra páginas nativas; resta apenas +// 'online-scheduling' por enquanto. +const MELISSA_EMBED_KEYS = ['online-scheduling']; // Slugs reservados pra páginas dedicadas (não-config) — agenda, pacientes, // conversas etc. + as MELISSA_EMBED_KEYS. Usado no isConfigRoute pra @@ -189,6 +192,7 @@ const MELISSA_NON_CONFIG_SLUGS = new Set([ 'agenda', 'pacientes', 'compromissos', 'recorrencias', 'conversas', 'tags', 'grupos', 'cadastros-recebidos', 'medicos', 'agendamentos-recebidos', 'link-externo', 'notificacoes', 'financeiro', 'financeiro-lancamentos', + 'documentos', 'documentos-templates', 'relatorios', ...MELISSA_EMBED_KEYS ]); // Aliases "bonitos" + INLINE_KEYS reconhecidos pelo MelissaConfiguracoes. @@ -2188,6 +2192,21 @@ function onKeydown(e) { @close="fecharSecao" /> + + + + + + +/* + * MelissaRelatorios — Página nativa Melissa pra relatórios de sessões + * (substitui o embed). Aplica blueprint melissa-table-page-blueprint.md. + * + * Layout 2-col: + * - COL 1 — Sidebar: stats clicáveis + filtros (Período / Status na + * tabela) + footer fixo "Limpar filtros" + * - COL 2 — Main: gráfico + DataTable de sessões filtradas + * + * Lógica idêntica à RelatoriosPage (query agenda_eventos + grouping + * isoWeek/isoMonth + Chart.js). + */ +import { ref, computed, watch, onMounted } from 'vue'; +import { supabase } from '@/lib/supabase/client'; +import { useTenantStore } from '@/stores/tenantStore'; +// Chart/DataTable/Column/Tag/Skeleton: auto via PrimeVueResolver + +const emit = defineEmits(['close']); +const tenantStore = useTenantStore(); + +// ── Período ──────────────────────────────────────────── +const PERIOD_OPTIONS = [ + { key: 'week', label: 'Esta semana', icon: 'pi pi-calendar' }, + { key: 'month', label: 'Este mês', icon: 'pi pi-calendar' }, + { key: '3months', label: 'Últimos 3 meses', icon: 'pi pi-calendar-clock' }, + { key: '6months', label: 'Últimos 6 meses', icon: 'pi pi-calendar-clock' } +]; + +const selectedPeriod = ref('month'); + +function periodRange(period) { + const now = new Date(); + let start, end; + if (period === 'week') { + start = new Date(now); + start.setDate(now.getDate() - now.getDay()); + start.setHours(0, 0, 0, 0); + end = new Date(now); + end.setHours(23, 59, 59, 999); + } else if (period === 'month') { + start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0); + end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999); + } else if (period === '3months') { + start = new Date(now.getFullYear(), now.getMonth() - 2, 1, 0, 0, 0, 0); + end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999); + } else if (period === '6months') { + start = new Date(now.getFullYear(), now.getMonth() - 5, 1, 0, 0, 0, 0); + end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999); + } + return { start, end }; +} + +const periodLabel = computed(() => + PERIOD_OPTIONS.find((p) => p.key === selectedPeriod.value)?.label ?? '' +); + +// ── Dados ────────────────────────────────────────────── +const loading = ref(false); +const hasLoaded = ref(false); +const sessions = ref([]); +const loadError = ref(''); + +async function loadSessions() { + const uid = tenantStore.user?.id || null; + const tenantId = tenantStore.activeTenantId || null; + if (!uid || !tenantId) return; + + const { start, end } = periodRange(selectedPeriod.value); + loading.value = true; + loadError.value = ''; + sessions.value = []; + + try { + const { data, error } = await supabase + .from('agenda_eventos') + .select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, patient_id, patients(nome_completo)') + .eq('tenant_id', tenantId) + .eq('owner_id', uid) + .gte('inicio_em', start.toISOString()) + .lte('inicio_em', end.toISOString()) + .order('inicio_em', { ascending: false }) + .limit(500); + if (error) throw error; + sessions.value = data || []; + } catch (e) { + loadError.value = e?.message || 'Falha ao carregar relatório.'; + } finally { + loading.value = false; + hasLoaded.value = true; + } +} + +// ── Métricas agregadas ──────────────────────────────── +const total = computed(() => sessions.value.length); +const realizadas = computed(() => sessions.value.filter((s) => s.status === 'realizado').length); +const faltas = computed(() => sessions.value.filter((s) => s.status === 'faltou').length); +const canceladas = computed(() => sessions.value.filter((s) => s.status === 'cancelado').length); +const agendadas = computed(() => sessions.value.filter((s) => !s.status || s.status === 'agendado').length); +const taxaRealizacao = computed(() => { + const denom = realizadas.value + faltas.value + canceladas.value; + if (!denom) return null; + return Math.round((realizadas.value / denom) * 100); +}); + +// ── Filtro de status na tabela ──────────────────────── +const statusFilter = ref(null); // null | 'realizado' | 'faltou' | 'cancelado' | 'agendado' + +const STATUS_FILTER_OPTIONS = [ + { key: 'realizado', label: 'Realizadas', icon: 'pi pi-check-circle' }, + { key: 'faltou', label: 'Faltas', icon: 'pi pi-times-circle' }, + { key: 'cancelado', label: 'Canceladas', icon: 'pi pi-ban' }, + { key: 'agendado', label: 'Agendadas', icon: 'pi pi-calendar' } +]; + +const sessionsFiltered = computed(() => { + if (!statusFilter.value) return sessions.value; + if (statusFilter.value === 'agendado') { + return sessions.value.filter((s) => !s.status || s.status === 'agendado'); + } + return sessions.value.filter((s) => s.status === statusFilter.value); +}); + +function setStatusFilter(s) { + statusFilter.value = statusFilter.value === s ? null : s; +} + +const hasActiveFilters = computed(() => + selectedPeriod.value !== 'month' || statusFilter.value !== null +); + +function clearAllFilters() { + statusFilter.value = null; + if (selectedPeriod.value !== 'month') { + selectedPeriod.value = 'month'; + } +} + +// ── Stats com classes pra cores ─────────────────────── +const stats = computed(() => [ + { key: 'total', label: 'Total', value: total.value, cls: 'neutral' }, + { key: 'realizado', label: 'Realizadas', value: realizadas.value, cls: realizadas.value > 0 ? 'ok' : 'neutral' }, + { key: 'faltou', label: 'Faltas', value: faltas.value, cls: faltas.value > 0 ? 'danger' : 'neutral' }, + { key: 'cancelado', label: 'Canceladas', value: canceladas.value, cls: canceladas.value > 0 ? 'warn' : 'neutral' }, + { key: 'agendado', label: 'Agendadas', value: agendadas.value, cls: agendadas.value > 0 ? 'info' : 'neutral' }, + { key: 'taxa', label: 'Taxa realização', value: taxaRealizacao.value != null ? `${taxaRealizacao.value}%` : '—', cls: taxaRealizacao.value != null && taxaRealizacao.value >= 85 ? 'ok' : 'neutral' } +]); + +// ── Gráfico ──────────────────────────────────────────── +function isoWeek(d) { + const dt = new Date(d); + const day = dt.getDay() || 7; + dt.setDate(dt.getDate() + 4 - day); + const yearStart = new Date(dt.getFullYear(), 0, 1); + const wk = Math.ceil(((dt - yearStart) / 86400000 + 1) / 7); + return `${dt.getFullYear()}-S${String(wk).padStart(2, '0')}`; +} +function isoMonth(d) { + const dt = new Date(d); + return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}`; +} +function monthLabel(key) { + const [y, m] = key.split('-'); + const names = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez']; + return `${names[Number(m) - 1]}/${y}`; +} + +const chartData = computed(() => { + const groupBy = selectedPeriod.value === 'week' ? isoWeek : isoMonth; + const labelFn = selectedPeriod.value === 'week' ? (k) => k : monthLabel; + const buckets = {}; + for (const s of sessions.value) { + const key = groupBy(s.inicio_em); + if (!buckets[key]) buckets[key] = { realizado: 0, faltou: 0, cancelado: 0, outros: 0 }; + const st = s.status || 'agendado'; + if (st === 'realizado') buckets[key].realizado++; + else if (st === 'faltou') buckets[key].faltou++; + else if (st === 'cancelado') buckets[key].cancelado++; + else buckets[key].outros++; + } + const keys = Object.keys(buckets).sort(); + return { + labels: keys.map(labelFn), + datasets: [ + { label: 'Realizadas', backgroundColor: 'rgb(22, 163, 74)', data: keys.map((k) => buckets[k].realizado), barThickness: 20 }, + { label: 'Faltas', backgroundColor: 'rgb(220, 38, 38)', data: keys.map((k) => buckets[k].faltou), barThickness: 20 }, + { label: 'Canceladas', backgroundColor: 'rgb(217, 119, 6)', data: keys.map((k) => buckets[k].cancelado), barThickness: 20 }, + { label: 'Outros', backgroundColor: 'rgb(2, 132, 199)', data: keys.map((k) => buckets[k].outros), barThickness: 20 } + ] + }; +}); + +const chartOptions = computed(() => { + const ds = getComputedStyle(document.documentElement); + const borderColor = ds.getPropertyValue('--p-content-border-color').trim() || '#e2e8f0'; + const textMutedColor = ds.getPropertyValue('--p-text-muted-color').trim() || '#64748b'; + return { + maintainAspectRatio: false, + plugins: { legend: { labels: { color: textMutedColor } } }, + scales: { + x: { stacked: true, ticks: { color: textMutedColor }, grid: { color: 'transparent' } }, + y: { stacked: true, ticks: { color: textMutedColor, precision: 0 }, grid: { color: borderColor, drawTicks: false } } + } + }; +}); + +// ── Tabela helpers ───────────────────────────────────── +const STATUS_LABEL = { + agendado: 'Agendado', + realizado: 'Realizado', + faltou: 'Faltou', + cancelado: 'Cancelado', + remarcado: 'Remarcado', + bloqueado: 'Bloqueado' +}; +const STATUS_SEVERITY = { + agendado: 'info', + realizado: 'success', + faltou: 'danger', + cancelado: 'warn', + remarcado: 'secondary', + bloqueado: 'secondary' +}; + +function fmtDateTimeBR(iso) { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const dd = String(d.getDate()).padStart(2, '0'); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const yy = d.getFullYear(); + const hh = String(d.getHours()).padStart(2, '0'); + const mi = String(d.getMinutes()).padStart(2, '0'); + return `${dd}/${mm}/${yy} ${hh}:${mi}`; +} +function sessionTitle(s) { + return s.titulo_custom || s.titulo || (s.tipo ? s.tipo : 'Sessão'); +} +function patientName(s) { + return s.patients?.nome_completo || '—'; +} + +watch(selectedPeriod, () => { + statusFilter.value = null; + loadSessions(); +}); + +onMounted(loadSessions); + + + + +