carousel, agenda arquivados, agenda cor, agenda arquivados, grupos pacientes, pacientes arquivados - desativados, sessoes verificadas, ajuste notificações, Prontuario, Agenda Animation, Menu Profile, bagdes Profile, Offline

This commit is contained in:
Leonardo
2026-03-18 09:26:09 -03:00
parent 66f67cd40f
commit d6d2fe29d1
55 changed files with 3655 additions and 1512 deletions
+446 -24
View File
@@ -91,10 +91,10 @@
</div>
<!-- Aviso: fora da jornada -->
<div v-if="hasEventsOutsideWorkHours" class="mx-3 md:mx-4 mb-3 rounded-[6px] p-3" style="background:color-mix(in srgb,var(--yellow-400,#facc15) 10%,var(--surface-card));border:1px solid color-mix(in srgb,var(--yellow-400,#facc15) 35%,transparent);">
<div ref="foraJornadaBannerRef" v-if="hasEventsOutsideWorkHours" class="mx-3 md:mx-4 mb-3 rounded-[6px] p-3" style="background:color-mix(in srgb,var(--yellow-400,#facc15) 10%,var(--surface-card));border:1px solid color-mix(in srgb,var(--yellow-400,#facc15) 35%,transparent);">
<div class="flex items-center gap-3">
<i class="pi pi-exclamation-triangle shrink-0" style="color:var(--yellow-600,#ca8a04);" />
<div class="font-semibold text-sm flex-1">Compromissos fora da jornada</div>
<div class="font-semibold text-sm">Compromissos fora da jornada</div>
<Button label="Ver 24h" size="small" severity="secondary" outlined class="rounded-full shrink-0" @click="timeMode = '24'" />
</div>
</div>
@@ -185,12 +185,21 @@
</div>
<div class="flex-1 min-w-0">
<span class="block text-[1rem] font-semibold truncate">{{ ev.paciente_nome || ev.patient_name || ev.titulo || '—' }}</span>
<div class="flex items-center gap-1 mt-0.5">
<div class="flex items-center gap-1 mt-0.5 flex-wrap">
<span class="text-[0.65rem] bg-[var(--surface-border)] text-[var(--text-color-secondary)] px-1.5 py-px rounded font-semibold">{{ ev.modalidade || 'Presencial' }}</span>
<span v-if="ev.recurrence_id" class="text-[1rem] text-[var(--primary-color,#6366f1)]"></span>
<span v-if="ev.paciente_status === 'Inativo' || ev.paciente_status === 'Arquivado'" class="text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide">{{ ev.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
</div>
</div>
<i class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" :class="statusIcon(ev.status)" />
<button
v-if="ev.patient_id || ev.paciente_id"
class="w-6 h-6 rounded-full flex items-center justify-center border-none bg-transparent text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--primary-color,#6366f1)] transition-colors duration-100 cursor-pointer flex-shrink-0"
title="Opções"
@click.stop="openTodayEvMenu($event, ev)"
>
<i class="pi pi-ellipsis-v text-[0.7rem]" />
</button>
<i v-else class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" :class="statusIcon(ev.status)" />
</div>
</div>
</div>
@@ -199,6 +208,25 @@
<Button label="Novo Compromisso" icon="pi pi-plus" class="w-full rounded-full" @click="onCreateFromButton(); agPanelOpen = false" />
</div>
<!-- Card: Pacientes Desativados/Arquivados com sessões pendentes -->
<div
v-if="desativadoPatients.length"
class="border border-orange-500/40 rounded-md bg-orange-500/5 p-3 cursor-pointer hover:bg-orange-500/10 transition-colors duration-150"
@click="desativadoDialogOpen = true"
>
<div class="flex items-center gap-2 mb-1.5">
<i class="pi pi-exclamation-triangle text-orange-500 text-sm" />
<span class="text-[0.72rem] font-bold text-orange-600 uppercase tracking-wide flex-1">Atenção: sessões pendentes</span>
<span class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-orange-500 text-white text-[0.62rem] font-bold">
{{ desativadoPatients.reduce((s, p) => s + p.sessions.length, 0) }}
</span>
</div>
<p class="text-[0.7rem] text-orange-700/80 leading-snug m-0">
{{ desativadoPatients.length }} paciente{{ desativadoPatients.length > 1 ? 's' : '' }}
desativado{{ desativadoPatients.length > 1 ? 's' : '' }} ou arquivado{{ desativadoPatients.length > 1 ? 's' : '' }}
com sessões agendadas.
</p>
</div>
<!-- Pacientes -->
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
@@ -406,12 +434,21 @@
</div>
<div class="flex-1 min-w-0">
<span class="block text-[1rem] font-semibold truncate">{{ ev.paciente_nome || ev.patient_name || ev.titulo || '—' }}</span>
<div class="flex items-center gap-1 mt-0.5">
<div class="flex items-center gap-1 mt-0.5 flex-wrap">
<span class="text-[0.65rem] bg-[var(--surface-border)] text-[var(--text-color-secondary)] px-1.5 py-px rounded font-semibold">{{ ev.modalidade || 'Presencial' }}</span>
<span v-if="ev.recurrence_id" class="text-[1rem] text-[var(--primary-color,#6366f1)]" title="Recorrente"></span>
<span v-if="ev.paciente_status === 'Inativo' || ev.paciente_status === 'Arquivado'" class="text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide">{{ ev.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
</div>
</div>
<i class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" :class="statusIcon(ev.status)" />
<button
v-if="ev.patient_id || ev.paciente_id"
class="w-6 h-6 rounded-full flex items-center justify-center border-none bg-transparent text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--primary-color,#6366f1)] transition-colors duration-100 cursor-pointer flex-shrink-0"
title="Opções"
@click.stop="openTodayEvMenu($event, ev)"
>
<i class="pi pi-ellipsis-v text-[0.7rem]" />
</button>
<i v-else class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" :class="statusIcon(ev.status)" />
</div>
</div>
</div>
@@ -420,6 +457,25 @@
<Button label="Novo Compromisso" icon="pi pi-plus" class="w-full rounded-full" @click="onCreateFromButton" />
</div>
<!-- Card: Pacientes Desativados/Arquivados com sessões pendentes -->
<div
v-if="desativadoPatients.length"
class="border border-orange-500/40 rounded-md bg-orange-500/5 p-3 cursor-pointer hover:bg-orange-500/10 transition-colors duration-150"
@click="desativadoDialogOpen = true"
>
<div class="flex items-center gap-2 mb-1.5">
<i class="pi pi-exclamation-triangle text-orange-500 text-sm" />
<span class="text-[0.72rem] font-bold text-orange-600 uppercase tracking-wide flex-1">Atenção: sessões pendentes</span>
<span class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-orange-500 text-white text-[0.62rem] font-bold">
{{ desativadoPatients.reduce((s, p) => s + p.sessions.length, 0) }}
</span>
</div>
<p class="text-[0.7rem] text-orange-700/80 leading-snug m-0">
{{ desativadoPatients.length }} paciente{{ desativadoPatients.length > 1 ? 's' : '' }}
desativado{{ desativadoPatients.length > 1 ? 's' : '' }} ou arquivado{{ desativadoPatients.length > 1 ? 's' : '' }}
com sessões agendadas.
</p>
</div>
<!-- Pacientes -->
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
@@ -549,7 +605,7 @@
<!-- Sem resultados -->
<div v-else-if="searchResults.length === 0" class="text-color-secondary text-sm">
Nenhum resultado para <b>{{ searchTrim }}</b>"
Nenhum resultado para "<b>{{ searchTrim }}</b>"
<span class="text-xs"> ({{ searchScope === 'month' ? 'mês inteiro' : 'período atual' }})</span>.
</div>
@@ -579,6 +635,7 @@
<div class="font-semibold text-sm truncate">
{{ r.paciente_nome || r.patient_name || r.titulo || 'Sem título' }}
</div>
<span v-if="r.paciente_status === 'Inativo' || r.paciente_status === 'Arquivado'" class="inline-block text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide mt-0.5">{{ r.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
<!-- Linha 3: título (se paciente diferente de título) -->
<div
@@ -737,6 +794,140 @@
/>
</template>
</Dialog>
<!-- ═══════════════════════════════════════════════════════════
Dialog: Sessões Pendentes de Pacientes Desativados/Arquivados
═══════════════════════════════════════════════════════════ -->
<Dialog
v-model:visible="desativadoDialogOpen"
modal
:draggable="false"
:style="{ width: '1100px', maxWidth: '97vw', height: '85vh' }"
:pt="{ content: { style: 'padding:0; display:flex; flex-direction:column; height:100%; overflow:hidden;' }, header: { style: 'padding: 1rem 1.25rem 0.75rem' } }"
>
<template #header>
<div class="flex items-center gap-2 w-full">
<i class="pi pi-exclamation-triangle text-orange-500" />
<span class="font-semibold text-base">Sessões agendadas — pacientes desativados</span>
<span class="ml-1 inline-flex items-center justify-center min-w-[22px] h-[22px] px-1 rounded-full bg-orange-500 text-white text-xs font-bold">
{{ desativadoPatients.reduce((s, p) => s + p.sessions.length, 0) }}
</span>
<!-- Tabs pacientes -->
<div class="flex gap-1 ml-auto flex-wrap">
<button
v-for="p in desativadoPatients"
:key="p.id"
class="px-3 py-1 rounded-full text-xs font-semibold border transition-all duration-150"
:class="desativadoSelected?.id === p.id
? 'bg-orange-500 text-white border-orange-500'
: 'bg-transparent text-orange-600 border-orange-500/40 hover:bg-orange-500/10'"
@click="desativadoSelected = p"
>
{{ (p.nome_completo || '—').split(' ')[0] }}
<span class="ml-1 opacity-70">({{ p.sessions.length }})</span>
</button>
</div>
</div>
</template>
<!-- Body: split panel -->
<div v-if="desativadoSelected" class="flex flex-col lg:flex-row flex-1 min-h-0 overflow-hidden">
<!-- Sidebar esquerda: lista de sessões -->
<div class="w-full lg:w-[340px] lg:flex-shrink-0 flex flex-col border-b lg:border-b-0 lg:border-r border-[var(--surface-border)]">
<!-- Patient info -->
<div class="px-4 py-3 border-b border-[var(--surface-border)] bg-orange-500/5">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm flex-shrink-0"
style="background: #f97316;">
{{ (desativadoSelected.nome_completo || '?').charAt(0).toUpperCase() }}
</div>
<div class="min-w-0">
<div class="font-semibold text-sm truncate">{{ desativadoSelected.nome_completo }}</div>
<span style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 6px;border-radius:3px;line-height:1.5;">
{{ desativadoSelected.status === 'Arquivado' ? 'arquivado' : 'desativado' }}
</span>
</div>
<div class="ml-auto text-right flex-shrink-0">
<div class="text-lg font-bold text-orange-500">{{ desativadoSelected.sessions.length }}</div>
<div class="text-[0.65rem] text-[var(--text-color-secondary)]">sessão(ões)</div>
</div>
</div>
</div>
<!-- Sessions list -->
<div class="flex-1 overflow-y-auto">
<div class="p-3 flex flex-col gap-2">
<div
v-for="s in desativadoSelected.sessions"
:key="s.id"
class="rounded-lg border p-2.5 cursor-pointer transition-all duration-150 group"
:class="desativadoFocused?.id === s.id
? 'border-orange-500 bg-orange-500/8 shadow-sm'
: 'border-[var(--surface-border)] bg-[var(--surface-card)] hover:border-orange-500/40 hover:bg-orange-500/5'"
@click="focusDesativadoSession(s)"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="text-xs font-bold text-[var(--text-color)]">
{{ fmtDesativadoDate(s.inicio_em) }}
</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">
{{ fmtDesativadoTime(s.inicio_em) }} · {{ fmtDesativadoDur(s.inicio_em, s.fim_em) }}
</div>
<div v-if="s.titulo" class="text-[0.7rem] text-[var(--text-color-secondary)] truncate mt-0.5">{{ s.titulo }}</div>
</div>
<Tag :value="s.modalidade || 'Presencial'" severity="secondary" class="text-[0.6rem] shrink-0" />
</div>
<div class="flex gap-1.5 mt-2">
<Button
label="Ver na agenda"
icon="pi pi-external-link"
size="small"
severity="warn"
outlined
class="flex-1 text-[0.65rem]"
@click.stop="openSessionInMainCalendar(s)"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Painel direito: mini FullCalendar -->
<div class="flex-1 min-w-0 min-h-[300px] lg:min-h-0 overflow-hidden flex flex-col">
<div class="px-4 py-2 border-b border-[var(--surface-border)] flex items-center gap-2">
<i class="pi pi-calendar text-[var(--text-color-secondary)] text-xs" />
<span class="text-xs text-[var(--text-color-secondary)]">Clique em uma sessão para abrir na agenda principal e cancelar ou remarcar</span>
</div>
<div class="flex-1 overflow-auto p-2">
<FullCalendar
v-if="desativadoDialogOpen && desativadoSelected"
ref="desativadoFcRef"
:options="desativadoFcOptions"
/>
</div>
</div>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined @click="desativadoDialogOpen = false" />
</template>
</Dialog>
<!-- Menu contexto: Sessões Hoje -->
<Menu ref="todayEvMenuRef" :model="todayEvMenuItems" :popup="true" />
<!-- Dialog: Prontuário -->
<PatientProntuario
:key="selectedPatient?.id || 'none'"
v-model="prontuarioOpen"
:patient="selectedPatient"
@close="closeProntuario"
/>
</template>
<script setup>
@@ -753,11 +944,13 @@ import Calendar from 'primevue/calendar'
import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid'
import dayGridPlugin from '@fullcalendar/daygrid'
import listPlugin from '@fullcalendar/list'
import interactionPlugin from '@fullcalendar/interaction'
import ptBrLocale from '@fullcalendar/core/locales/pt-br'
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue'
import BloqueioDialog from '@/features/agenda/components/BloqueioDialog.vue'
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
import ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosCard.vue'
import { useSupportDebugStore } from '@/support/supportDebugStore'
@@ -810,7 +1003,7 @@ const {
const commitmentOptionsNormalized = computed(() => {
const list = Array.isArray(determinedCommitments.value) ? determinedCommitments.value : []
// prioridade pra Sessão" primeiro (native_key = session)
// prioridade pra "Sessão" primeiro (native_key = session)
const priority = new Map([
['session', 0],
['class', 1],
@@ -876,13 +1069,16 @@ onMounted(async () => {
if (tid) loadFeriados(tid)
})
// Carrega desativados assim que ownerId estiver disponível
watch(ownerId, (id) => { if (id) loadDesativados() }, { immediate: true })
// Range atual
const currentRange = ref({ start: null, end: null })
// -----------------------------
// Topbar state
// -----------------------------
const onlySessions = ref(true)
const onlySessions = ref(false)
const calendarView = ref('day') // day | week | month
const timeMode = ref('my') // 24 | 12 | my
const search = ref('')
@@ -954,6 +1150,52 @@ const headerMenuRef = ref(null)
const agPanelOpen = ref(false)
const blockMenuRef = ref(null)
// ── Prontuário ────────────────────────────────────────────────
const prontuarioOpen = ref(false)
const selectedPatient = ref(null)
function openProntuario (patientId, patientNome) {
if (!patientId) return
selectedPatient.value = { id: patientId, nome_completo: patientNome || '' }
prontuarioOpen.value = true
}
function closeProntuario () { prontuarioOpen.value = false; selectedPatient.value = null }
// ── Menu de contexto: Sessões Hoje ────────────────────────────
const todayEvMenuRef = ref(null)
const _todayEvAtivo = ref(null)
const todayEvMenuItems = computed(() => [
{
label: 'Opções',
items: [
{
label: 'Ver prontuário',
icon: 'pi pi-file-edit',
disabled: !(_todayEvAtivo.value?.patient_id || _todayEvAtivo.value?.paciente_id),
command: () => {
const id = _todayEvAtivo.value?.patient_id || _todayEvAtivo.value?.paciente_id
const nome = _todayEvAtivo.value?.paciente_nome || _todayEvAtivo.value?.patient_name || ''
openProntuario(id, nome)
},
},
{
label: 'Abrir na agenda',
icon: 'pi pi-calendar',
command: () => {
if (_todayEvAtivo.value) onEventRowClick(_todayEvAtivo.value)
},
},
],
},
])
function openTodayEvMenu (event, ev) {
event.stopPropagation()
_todayEvAtivo.value = ev
todayEvMenuRef.value?.toggle(event)
}
// Bloqueio dialog
const bloqueioDialogOpen = ref(false)
const bloqueioMode = ref('horario')
@@ -1098,8 +1340,10 @@ const allRows = computed(() => [
const calendarRows = computed(() => {
return allRows.value.filter(r => {
const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO)
if (onlySessions.value && tipo !== EVENTO_TIPO.SESSAO) return false
// "Apenas Sessões" = eventos vinculados a paciente.
// Filtrar por tipo não funciona pois o banco usa 'sessao' para todos os compromissos
// que não são bloqueio — compromissos pessoais (Análise, Leitura, etc.) têm o mesmo tipo.
if (onlySessions.value && !(r.patient_id || r.paciente_id)) return false
return true
})
})
@@ -1172,7 +1416,8 @@ const searchResults = computed(() => {
? monthSearchRows.value
: (calendarRows.value || []).map(r => ({
...r,
paciente_nome: r.paciente_nome || r.patient_name || r.nome_paciente || ''
paciente_nome: r.paciente_nome || r.patient_name || r.nome_paciente || '',
paciente_status: r.paciente_status || r.extendedProps?.paciente_status || ''
}))
return source.filter(r => _matchRow(r, q))
})
@@ -1191,7 +1436,7 @@ async function loadMonthSearchRows () {
// mergeWithStoredSessions deduplicar sessões materializadas de séries.
const { data, error } = await supabase
.from('agenda_eventos')
.select('id, owner_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id, recurrence_id, recurrence_date, patients!agenda_eventos_patient_id_fkey(nome_completo)')
.select('id, owner_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id, recurrence_id, recurrence_date, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
.eq('owner_id', uid)
.is('mirror_of_event_id', null)
.gte('inicio_em', startISO)
@@ -1199,7 +1444,7 @@ async function loadMonthSearchRows () {
.order('inicio_em', { ascending: true })
if (error) throw error
const realRows = (data || []).map(r => ({ ...r, paciente_nome: r.patients?.nome_completo || '' }))
const realRows = (data || []).map(r => ({ ...r, paciente_nome: r.patients?.nome_completo || '', paciente_status: r.patients?.status || '' }))
// 2. Ocorrências virtuais de recorrência (não existem em agenda_eventos).
// loadAndExpand retorna merged = reais + virtuais; filtramos só is_occurrence
@@ -1230,10 +1475,27 @@ watch(currentDate, (newD, oldD) => {
})
const calendarEvents = computed(() => {
// Mapa id → {bg_color, text_color} dos commitments já carregados na página.
// Usado para injetar cores nas ocorrências virtuais, que não têm o join
// determined_commitments (useRecurrence faz select('*') sem join).
const colorMap = new Map(
(commitmentOptionsNormalized.value || [])
.filter(c => c.id)
.map(c => [c.id, { bg_color: c.bg_color || null, text_color: c.text_color || null }])
)
// Injeta determined_commitments em qualquer row que ainda não tenha — resolve
// tanto ocorrências virtuais quanto eventos reais com join nulo.
function withCommitmentColors (r) {
if (r.determined_commitments || !r.determined_commitment_id) return r
const colors = colorMap.get(r.determined_commitment_id)
return colors ? { ...r, determined_commitments: colors } : r
}
// separa reais e virtuais para aplicar mapAgendaEventosToCalendarEvents
// em cada grupo — as virtuais precisam do mesmo tratamento de cores
const realRows = calendarRows.value.filter(r => !r.is_occurrence)
const occRows = calendarRows.value.filter(r => r.is_occurrence)
const realRows = calendarRows.value.filter(r => !r.is_occurrence).map(withCommitmentColors)
const occRows = calendarRows.value.filter(r => r.is_occurrence).map(withCommitmentColors)
const base = mapAgendaEventosToCalendarEvents(realRows)
const occEvents = mapAgendaEventosToCalendarEvents(occRows)
@@ -1387,11 +1649,12 @@ const fcOptions = computed(() => ({
eventContent: (arg) => {
const ext = arg.event.extendedProps || {}
const avatarUrl = ext.paciente_avatar || ''
const nome = ext.paciente_nome || ''
const obs = ext.observacoes || ''
const title = arg.event.title || ''
const timeText = arg.timeText || ''
const avatarUrl = ext.paciente_avatar || ''
const nome = ext.paciente_nome || ''
const obs = ext.observacoes || ''
const title = arg.event.title || ''
const timeText = arg.timeText || ''
const pacienteStatus = ext.paciente_status || ''
const esc = (s) => String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
@@ -1410,8 +1673,11 @@ const fcOptions = computed(() => ({
? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>`
: ''
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : ''
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : ''
const inativoBadge = (pacienteStatus === 'Inativo' || pacienteStatus === 'Arquivado')
? `<span style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 5px;border-radius:3px;line-height:1.4;margin-top:2px;">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
: ''
return {
html: `<div class="ev-custom">
@@ -1419,6 +1685,7 @@ const fcOptions = computed(() => ({
<div class="ev-body">
${timeHtml}
<div class="ev-title">${esc(title)}</div>
${inativoBadge}
${obsHtml}
</div>
</div>`
@@ -1442,12 +1709,47 @@ const fcOptions = computed(() => ({
const classes = []
if (tipo === EVENTO_TIPO.SESSAO) classes.push('evt-session')
if (tipo === EVENTO_TIPO.BLOQUEIO) classes.push('evt-block')
// Quando o evento já tem cor do commitment, marca para que o CSS
// não sobrescreva com a cor primária padrão via !important
if (arg?.event?.backgroundColor) classes.push('evt-has-color')
if (qn && hit) classes.push('evt-hit')
if (qn && !hit) classes.push('evt-dim')
return classes
},
eventDidMount: (info) => {
const bgColor = info.event.extendedProps?.commitment_bg_color
if (bgColor) {
info.el.style.setProperty('background-color', bgColor, 'important')
info.el.style.setProperty('border-color', bgColor, 'important')
}
// Marca o elemento com o id do evento para scroll+pulse posterior
info.el.dataset.eventId = info.event.id
}
}))
// ── Scroll + pulse no evento do FullCalendar ─────────────────
const foraJornadaBannerRef = ref(null)
async function scrollToAndPulseEvent (eventId) {
await nextTick()
// Aguarda o FC renderizar após possível mudança de view/data
await new Promise(r => setTimeout(r, 300))
// Pulsa o banner "fora da jornada" se estiver visível
const banner = foraJornadaBannerRef.value
if (banner) {
banner.classList.add('notif-card--highlight')
setTimeout(() => banner.classList.remove('notif-card--highlight'), 2000)
}
const el = document.querySelector(`[data-event-id="${eventId}"]`)
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
el.classList.add('notif-card--highlight')
setTimeout(() => el.classList.remove('notif-card--highlight'), 2000)
}
// ── Resumo do dia (coluna direita) ────────────────────────────────────────────
const todayEvents = computed(() => {
@@ -1508,6 +1810,7 @@ function onEventRowClick (ev) {
if (ev.inicio_em) {
getApi()?.gotoDate?.(new Date(ev.inicio_em))
calendarView.value = 'day'
scrollToAndPulseEvent(ev.id)
}
}
@@ -1569,6 +1872,119 @@ function getApi () {
return fcRef.value?.getApi?.() || null
}
// ── Pacientes Desativados com sessões pendentes ────────────
const desativadoPatients = ref([])
const desativadoDialogOpen = ref(false)
const desativadoSelected = ref(null)
const desativadoFocused = ref(null)
const desativadoFcRef = ref(null)
async function loadDesativados () {
if (!ownerId.value) return
try {
const { data: pats, error: pErr } = await supabase
.from('patients')
.select('id, nome_completo, status')
.eq('owner_id', ownerId.value)
.in('status', ['Inativo', 'Arquivado'])
if (pErr) { console.warn('[loadDesativados] patients error:', pErr); desativadoPatients.value = []; return }
if (!pats?.length) { desativadoPatients.value = []; return }
const patIds = pats.map(p => p.id)
const sessQ = supabase
.from('agenda_eventos')
.select('id, patient_id, inicio_em, fim_em, status, titulo, modalidade, determined_commitment_id')
.in('patient_id', patIds)
.order('inicio_em', { ascending: true })
if (ownerId.value) sessQ.eq('owner_id', ownerId.value)
if (clinicTenantId.value) sessQ.eq('tenant_id', clinicTenantId.value)
const { data: sessions, error: sErr } = await sessQ
if (sErr) { console.warn('[loadDesativados] sessions error:', sErr) }
const byPat = new Map()
for (const s of (sessions || [])) {
if (!byPat.has(s.patient_id)) byPat.set(s.patient_id, [])
byPat.get(s.patient_id).push(s)
}
desativadoPatients.value = pats
.filter(p => byPat.has(p.id))
.map(p => ({ ...p, sessions: byPat.get(p.id) }))
if (desativadoPatients.value.length && !desativadoSelected.value) {
desativadoSelected.value = desativadoPatients.value[0]
}
} catch (e) { console.warn('[loadDesativados] erro:', e) }
}
const desativadoFcOptions = computed(() => {
const patient = desativadoSelected.value
if (!patient) return {}
const events = (patient.sessions || []).map(s => ({
id: s.id,
title: s.titulo || 'Sessão',
start: s.inicio_em,
end: s.fim_em,
backgroundColor: desativadoFocused.value?.id === s.id ? '#ea580c' : '#f97316',
borderColor: desativadoFocused.value?.id === s.id ? '#ea580c' : '#f97316',
textColor: '#fff',
extendedProps: { session: s }
}))
const firstDate = patient.sessions[0]?.inicio_em
return {
plugins: [listPlugin, dayGridPlugin, interactionPlugin],
locale: ptBrLocale,
initialView: 'listMonth',
initialDate: firstDate || new Date().toISOString(),
events,
headerToolbar: { left: 'prev,next today', center: 'title', right: 'listMonth,dayGridMonth' },
height: '100%',
noEventsText: 'Nenhuma sessão encontrada.',
eventClick: (info) => {
const s = info.event.extendedProps.session
openSessionInMainCalendar(s)
}
}
})
watch(desativadoSelected, () => {
desativadoFocused.value = null
})
function focusDesativadoSession (session) {
desativadoFocused.value = session
const api = desativadoFcRef.value?.getApi?.()
if (api) api.gotoDate(new Date(session.inicio_em))
}
function openSessionInMainCalendar (session) {
desativadoDialogOpen.value = false
const date = new Date(session.inicio_em)
currentDate.value = date
getApi()?.gotoDate?.(date)
// Muda para visão de dia para facilitar encontrar a sessão
const api = getApi()
if (api) api.changeView('timeGridDay', date)
}
// Helpers de formato para o painel de desativados
function fmtDesativadoDate (iso) {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' })
}
function fmtDesativadoTime (iso) {
if (!iso) return '—'
return new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })
}
function fmtDesativadoDur (start, end) {
if (!start || !end) return ''
const min = Math.round((new Date(end) - new Date(start)) / 60000)
return `${min}min`
}
watch(calendarView, async () => {
await nextTick()
getApi()?.changeView?.(fcViewName.value)
@@ -1947,6 +2363,7 @@ function onEventClick (info) {
paciente_id: ep.paciente_id ?? null,
paciente_nome: ep.paciente_nome ?? null,
paciente_avatar: ep.paciente_avatar ?? null,
paciente_status: ep.paciente_status ?? null,
tipo: normalizeEventoTipo(ep.tipo, EVENTO_TIPO.SESSAO),
status: ep.status,
titulo: ev.title,
@@ -2847,11 +3264,16 @@ onMounted(async () => {
}
/* ── Cores dos eventos (global — aplicadas pelo FullCalendar) ── */
.fc-event.evt-session {
/* Cor primária padrão só quando o evento não tem cor personalizada do commitment */
.fc-event.evt-session:not(.evt-has-color) {
background-color: var(--p-primary-500, #6366f1) !important;
border-color: var(--p-primary-600, #4f46e5) !important;
color: #fff !important;
}
/* Cor de texto branca garantida para eventos com cor personalizada */
.fc-event.evt-session.evt-has-color {
color: #fff !important;
}
.fc-event.evt-block {
background-color: #ef4444 !important;
border-color: #dc2626 !important;