Agenda, Agendador, Configurações

This commit is contained in:
Leonardo
2026-03-12 08:58:36 -03:00
parent f733db8436
commit f4b185ae17
197 changed files with 33405 additions and 6507 deletions
+133 -2
View File
@@ -467,9 +467,10 @@
</template>
</Column>
<Column :key="'col-acoes'" header="Ações" style="width: 16rem;" frozen alignFrozen="right">
<Column :key="'col-acoes'" header="Ações" style="width: 20rem;" frozen alignFrozen="right">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<Button label="Sessões" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(data)" />
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(data)" />
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Excluir'" @click="confirmDeleteOne(data)" />
@@ -521,7 +522,8 @@
</div>
<!-- Ações -->
<div class="mt-3 flex gap-2 justify-end">
<div class="mt-3 flex gap-2 justify-end flex-wrap">
<Button label="Sessões" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(pat)" />
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(pat)" />
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="goEdit(pat)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" @click="confirmDeleteOne(pat)" />
@@ -585,6 +587,61 @@
/>
<ConfirmDialog />
<!-- ── DIALOG SESSÕES DO PACIENTE ─────────────────────────── -->
<Dialog
v-model:visible="sessoesOpen"
modal
:draggable="false"
:style="{ width: '700px', maxWidth: '96vw' }"
:header="sessoesPaciente ? `Sessões — ${sessoesPaciente.nome_completo}` : 'Sessões'"
>
<div v-if="sessoesLoading" class="flex justify-center py-8">
<ProgressSpinner />
</div>
<div v-else>
<!-- Recorrências ativas -->
<div v-if="recorrencias.length" class="mb-5">
<div class="text-sm font-semibold text-color-secondary mb-2 flex items-center gap-2">
<i class="pi pi-sync" /> Recorrências
</div>
<div class="flex flex-col gap-2">
<div
v-for="r in recorrencias"
:key="r.id"
class="sess-rec-card"
>
<Tag :value="r.status === 'ativo' ? 'Ativa' : 'Encerrada'" :severity="r.status === 'ativo' ? 'success' : 'secondary'" />
<span class="text-sm">{{ fmtRecorrencia(r) }}</span>
<span class="text-xs text-color-secondary ml-auto">
{{ r.start_date }} {{ r.end_date ? `→ ${r.end_date}` : '(em aberto)' }}
</span>
</div>
</div>
</div>
<!-- Lista de sessões -->
<div class="text-sm font-semibold text-color-secondary mb-2 flex items-center gap-2">
<i class="pi pi-calendar" /> Sessões ({{ sessoesLista.length }})
</div>
<div v-if="sessoesLista.length === 0" class="text-center py-6 text-color-secondary text-sm">
Nenhuma sessão encontrada para este paciente.
</div>
<div v-else class="sess-list">
<div v-for="ev in sessoesLista" :key="ev.id" class="sess-item">
<div class="flex items-center gap-3">
<Tag :value="ev.status || 'agendado'" :severity="statusSessaoSev(ev.status)" />
<span class="font-semibold text-sm">{{ fmtDataSessao(ev.inicio_em) }}</span>
<Tag v-if="ev.modalidade" :value="ev.modalidade" severity="secondary" class="ml-auto" />
</div>
<div v-if="ev.titulo" class="text-xs text-color-secondary mt-1">{{ ev.titulo }}</div>
</div>
</div>
</div>
</Dialog>
</template>
<script setup>
@@ -602,6 +659,59 @@ import ProgressSpinner from 'primevue/progressspinner'
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
// ── Sessões do paciente ──────────────────────────────────────────
const sessoesOpen = ref(false)
const sessoesPaciente = ref(null) // { id, nome_completo }
const sessoesLoading = ref(false)
const sessoesLista = ref([])
const recorrencias = ref([])
const MESES_BR = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez']
function fmtDataSessao (iso) {
if (!iso) return '—'
const d = new Date(iso)
return `${String(d.getDate()).padStart(2,'0')} ${MESES_BR[d.getMonth()]} ${d.getFullYear()} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
}
function statusSessaoSev (st) {
return { agendado: 'info', realizado: 'success', cancelado: 'danger', faltou: 'warn' }[st] || 'secondary'
}
async function abrirSessoes (pat) {
sessoesPaciente.value = pat
sessoesOpen.value = true
sessoesLoading.value = true
sessoesLista.value = []
recorrencias.value = []
try {
const [evts, recs] = await Promise.all([
supabase
.from('agenda_eventos')
.select('id, titulo, tipo, status, inicio_em, fim_em, modalidade')
.eq('patient_id', pat.id)
.order('inicio_em', { ascending: false })
.limit(100),
supabase
.from('recurrence_rules')
.select('id, type, interval, weekdays, start_date, end_date, start_time, duration_min, status')
.eq('patient_id', pat.id)
.order('start_date', { ascending: false }),
])
sessoesLista.value = evts.data || []
recorrencias.value = recs.data || []
} catch (e) {
console.error('Erro ao carregar sessões:', e)
} finally {
sessoesLoading.value = false
}
}
const DIAS_SEMANA = ['Dom','Seg','Ter','Qua','Qui','Sex','Sáb']
function fmtRecorrencia (r) {
const dias = (r.weekdays || []).map(d => DIAS_SEMANA[d]).join(', ')
const freq = r.type === 'weekly' && r.interval === 2 ? 'Quinzenal' : r.type === 'weekly' ? 'Semanal' : 'Personalizado'
return `${freq} · ${dias} · ${r.start_time?.slice(0,5) || '—'} · ${r.duration_min || 50}min`
}
const router = useRouter()
const route = useRoute()
@@ -1328,4 +1438,25 @@ function updateKpis() {
/* Fade */
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* ── Dialog Sessões ──────────────────────────────────────────── */
.sess-list {
display: flex; flex-direction: column; gap: .5rem;
max-height: 55vh; overflow-y: auto;
padding-right: .25rem;
}
.sess-item {
padding: 10px 14px;
border: 1px solid var(--surface-border);
border-radius: .75rem;
background: var(--surface-ground);
}
.sess-rec-card {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 10px 14px;
border: 1px solid var(--surface-border);
border-radius: .75rem;
background: var(--surface-ground);
font-size: .85rem;
}
</style>
@@ -55,7 +55,7 @@ function pick(obj, keys = []) {
// ------------------------------------------------------
// accordion (pode abrir vários) + scroll
// ------------------------------------------------------
const accordionValues = ['0', '1', '2', '3', '4']
const accordionValues = ['0', '1', '2', '3', '4', '5']
const activeValues = ref(['0']) // começa com o primeiro aberto
const activeValue = computed(() => activeValues.value?.[0] ?? null)
@@ -91,7 +91,8 @@ const navItems = [
{ value: '1', label: 'Endereço', icon: 'pi pi-map-marker' },
{ value: '2', label: 'Dados adicionais', icon: 'pi pi-tags' },
{ value: '3', label: 'Responsável', icon: 'pi pi-users' },
{ value: '4', label: 'Anotações', icon: 'pi pi-file-edit' }
{ value: '4', label: 'Anotações', icon: 'pi pi-file-edit' },
{ value: '5', label: 'Sessões', icon: 'pi pi-calendar' }
]
const navPopover = ref(null)
@@ -307,6 +308,74 @@ const observacaoResponsavel = computed(() => pick(patientData.value, ['observaca
// notas internas
const notasInternas = computed(() => pick(patientData.value, ['notas_internas', 'notes']))
// ------------------------------------------------------
// Sessões do paciente (integração agenda)
// ------------------------------------------------------
const sessions = ref([])
const sessionsLoading = ref(false)
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 sessionDuration (inicio, fim) {
if (!inicio || !fim) return null
const diff = new Date(fim) - new Date(inicio)
if (diff <= 0) return null
const min = Math.round(diff / 60000)
if (min < 60) return `${min} min`
const h = Math.floor(min / 60)
const m = min % 60
return m ? `${h}h ${m}min` : `${h}h`
}
async function loadSessions (patientId) {
sessionsLoading.value = true
sessions.value = []
try {
const { data, error } = await supabase
.from('agenda_eventos')
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, observacoes, tenant_id')
.eq('patient_id', patientId)
.order('inicio_em', { ascending: false })
.limit(100)
if (error) throw error
sessions.value = data || []
} catch (e) {
// falha silenciosa — prontuário continua sem a seção de sessões
sessions.value = []
} finally {
sessionsLoading.value = false
}
}
async function getPatientById(id) {
const { data, error } = await supabase
.from('patients')
@@ -385,15 +454,21 @@ async function loadProntuario(id) {
tags.value = []
try {
const p = await getPatientById(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
const rel = await getPatientRelations(id)
groups.value = await getGroupsByIds(rel.groupIds || [])
tags.value = await getTagsByIds(rel.tagIds || [])
const [g, t] = await Promise.all([
getGroupsByIds(rel.groupIds || []),
getTagsByIds(rel.tagIds || []),
loadSessions(id),
])
groups.value = g
tags.value = t
} catch (e) {
loadError.value = e?.message || 'Falha ao buscar dados no Supabase.'
toast.add({ severity: 'error', summary: 'Erro ao carregar prontuário', detail: loadError.value, life: 4500 })
@@ -984,6 +1059,42 @@ Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
</FloatLabel>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="5">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 5)">6. SESSÕES</AccordionHeader>
<AccordionContent>
<div v-if="sessionsLoading" class="text-slate-500 text-sm py-2">Carregando sessões</div>
<div v-else-if="!sessions.length" class="text-slate-500 text-sm py-2">Nenhuma sessão registrada para este paciente.</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="s in sessions"
:key="s.id"
class="rounded-xl border border-slate-200 bg-white px-4 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2"
>
<div class="flex flex-col gap-1">
<div class="font-medium text-slate-800">
{{ s.titulo_custom || s.titulo || (s.tipo ? s.tipo : 'Sessão') }}
</div>
<div class="text-sm text-slate-500 flex flex-wrap gap-x-3 gap-y-1">
<span><i class="pi pi-calendar mr-1 opacity-60" />{{ fmtDateTimeBR(s.inicio_em) }}</span>
<span v-if="sessionDuration(s.inicio_em, s.fim_em)">
<i class="pi pi-clock mr-1 opacity-60" />{{ sessionDuration(s.inicio_em, s.fim_em) }}
</span>
<span v-if="s.modalidade">
<i class="pi pi-video mr-1 opacity-60" />{{ s.modalidade === 'online' ? 'Online' : 'Presencial' }}
</span>
</div>
<div v-if="s.observacoes" class="text-sm text-slate-600 mt-1 line-clamp-2">{{ s.observacoes }}</div>
</div>
<Tag
:value="STATUS_LABEL[s.status] || s.status || 'Agendado'"
:severity="STATUS_SEVERITY[s.status] || 'info'"
class="shrink-0"
/>
</div>
</div>
</AccordionContent>
</AccordionPanel>
</Accordion>
</main>
</div>