Agenda, Agendador, Configurações
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user