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
@@ -11,9 +11,9 @@ import ptBrLocale from '@fullcalendar/core/locales/pt-br'
const props = defineProps({
view: { type: String, default: 'day' }, // 'day' | 'week' | 'month'
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
timezone: { type: String, default: 'America/Sao_Paulo' },
timezone: { type: String, default: 'local' },
slotDuration: { type: String, default: '00:30:00' },
slotDuration: { type: String, default: '00:15:00' },
slotMinTime: { type: String, default: '06:00:00' },
slotMaxTime: { type: String, default: '22:00:00' },
@@ -35,7 +35,14 @@ const props = defineProps({
clinicSubtitle: { type: String, default: 'Agenda da clínica' },
// subtitle terapeutas
staffSubtitle: { type: String, default: 'Visão diária operacional' }
staffSubtitle: { type: String, default: 'Visão diária operacional' },
// jornada por dia: [{ daysOfWeek:[n], startTime:'HH:MM', endTime:'HH:MM' }]
businessHours: { type: Array, default: () => [] },
// Array de ISO strings (yyyy-mm-dd) de dias totalmente bloqueados.
// Exibe banner vermelho no topo de cada coluna (view "day") e fundo no calendário.
blockedDates: { type: Array, default: () => [] }
})
const emit = defineEmits([
@@ -44,7 +51,6 @@ const emit = defineEmits([
'eventClick',
'eventDrop',
'eventResize',
// ✅ debug
'debugColumn'
])
@@ -89,13 +95,18 @@ function forEachApi (fn) {
}
}
function goToday () { forEachApi(api => api.today()) }
function prev () { forEachApi(api => api.prev()) }
function next () { forEachApi(api => api.next()) }
function goToday () {
const d = new Date(); d.setHours(12, 0, 0, 0)
trackedDate = d
forEachApi(api => api.today())
}
function prev () { trackedDate = null; forEachApi(api => api.prev()) }
function next () { trackedDate = null; forEachApi(api => api.next()) }
function gotoDate (date) {
if (!date) return
const dt = (date instanceof Date) ? new Date(date) : new Date(date)
dt.setHours(12, 0, 0, 0) // anti “voltar dia”
dt.setHours(12, 0, 0, 0)
trackedDate = new Date(dt)
forEachApi(api => api.gotoDate(dt))
}
@@ -107,16 +118,70 @@ function setMode () {}
defineExpose({ goToday, prev, next, gotoDate, setView, setMode })
// ── Dias bloqueados ────────────────────────────────────────────
const blockedSet = computed(() => new Set(props.blockedDates || []))
// Eventos de background que colorem o dia inteiro de vermelho suave no FullCalendar
const blockedBgEvents = computed(() =>
(props.blockedDates || []).map(iso => ({
id: `_blocked_bg_${iso}`,
start: iso,
allDay: true,
display: 'background',
color: 'rgba(239,68,68,0.13)',
classNames: ['fc-blocked-day']
}))
)
// ISO do dia sendo exibido atualmente — atualizado pelo datesSet
const currentViewISO = ref('')
// Retorna true se o dia exibido está bloqueado (banner só aparece na view "day")
function isCurrentDayBlocked () {
return props.view === 'day' && !!currentViewISO.value && blockedSet.value.has(currentViewISO.value)
}
function eventsFor (ownerId) {
const list = props.events || []
return list.filter(e => String(e?.extendedProps?.owner_id || '') === String(ownerId || ''))
const list = (props.events || []).filter(
e => String(e?.extendedProps?.owner_id || '') === String(ownerId || '')
)
return [...list, ...blockedBgEvents.value]
}
// ---- range sync ----
let lastRangeKey = ''
let suppressSync = false
let trackedDate = null // data-alvo atual (definida via gotoDate/goToday; limpa em prev/next)
function sameDay (a, b) {
if (!a || !b) return false
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
}
function onDatesSet (arg) {
const cd = arg.view?.currentStart || arg.start
if (cd) {
currentViewISO.value = `${cd.getFullYear()}-${String(cd.getMonth()+1).padStart(2,'0')}-${String(cd.getDate()).padStart(2,'0')}`
}
// Calendário recém-montado disparou datesSet com data antiga (ex: hoje) enquanto
// a agenda já estava em outra data (trackedDate). Navega silenciosamente para a
// data correta sem emitir rangeChange nem atualizar lastRangeKey.
if (trackedDate && cd && !sameDay(cd, trackedDate)) {
if (!suppressSync) {
suppressSync = true
forEachApi((api) => {
const cur = api.view?.currentStart
if (!cur || sameDay(cur, trackedDate)) return
api.gotoDate(trackedDate)
})
Promise.resolve().then(() => { suppressSync = false })
}
return
}
const key = `${arg.startStr}__${arg.endStr}__${arg.view?.type || ''}`
if (key === lastRangeKey) return
lastRangeKey = key
@@ -127,13 +192,13 @@ function onDatesSet (arg) {
startStr: arg.startStr,
endStr: arg.endStr,
viewType: arg.view.type,
currentDate: arg.view?.currentStart || arg.start
currentDate: cd
})
if (suppressSync) return
suppressSync = true
const masterDate = arg.view?.currentStart || arg.start
const masterDate = cd
forEachApi((api) => {
const cur = api.view?.currentStart
if (!cur || !masterDate) return
@@ -148,6 +213,16 @@ watch(() => props.view, async () => {
setView(props.view)
})
// ✅ Fix: watch combinado — evita render intermediário que colapsava labels de hora cheia
watch([computedSlotMinTime, computedSlotMaxTime], async ([minT, maxT]) => {
await nextTick()
forEachApi(api => {
api.setOption?.('slotMinTime', minT)
api.setOption?.('slotMaxTime', maxT)
api.updateSize?.()
})
})
// ---------- helpers UI ----------
function colSubtitle (p) {
return p?.__kind === 'clinic' ? props.clinicSubtitle : props.staffSubtitle
@@ -179,8 +254,20 @@ function buildFcOptions (ownerId) {
selectMirror: true,
slotDuration: props.slotDuration,
snapDuration: '00:15:00',
slotMinTime: computedSlotMinTime.value,
slotMaxTime: computedSlotMaxTime.value,
slotLabelInterval: '00:30',
slotLabelContent: (arg) => {
const min = arg.date.getMinutes()
if (min === 0) {
const h = String(arg.date.getHours()).padStart(2, '0')
return { html: `<span class="fc-slot-label-hour">${h}:00</span>` }
}
return { html: `<span class="fc-slot-label-half">:${String(min).padStart(2, '0')}</span>` }
},
businessHours: props.businessHours,
height: 'auto',
expandRows: true,
@@ -191,7 +278,47 @@ function buildFcOptions (ownerId) {
eventClick: (info) => emit('eventClick', info),
eventDrop: (info) => emit('eventDrop', info),
eventResize: (info) => emit('eventResize', info)
eventResize: (info) => emit('eventResize', info),
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 esc = (s) => String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;')
const initials = (n) => {
const p = String(n).trim().split(/\s+/).filter(Boolean)
if (!p.length) return '?'
if (p.length === 1) return p[0].slice(0, 2).toUpperCase()
return (p[0][0] + p[p.length - 1][0]).toUpperCase()
}
const avatarHtml = avatarUrl
? `<img src="${esc(avatarUrl)}" class="ev-avatar ev-avatar-img" />`
: nome
? `<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>` : ''
return {
html: `<div class="ev-custom">
${avatarHtml}
<div class="ev-body">
${timeHtml}
<div class="ev-title">${esc(title)}</div>
${obsHtml}
</div>
</div>`
}
}
}
base.select = (selection) => {
@@ -230,6 +357,12 @@ function buildFcOptions (ownerId) {
</div>
</div>
<!-- Banner: dia bloqueado aparece apenas na view "day" -->
<div v-if="isCurrentDayBlocked()" class="mosaic-blocked-banner">
<i class="pi pi-lock text-xs" />
<span>Dia bloqueado apenas compromissos de sessão não são permitidos</span>
</div>
<div class="p-2">
<FullCalendar
:ref="(el) => setCalendarRef(el, 0)"
@@ -260,6 +393,12 @@ function buildFcOptions (ownerId) {
</div>
</div>
<!-- Banner: dia bloqueado aparece apenas na view "day" -->
<div v-if="isCurrentDayBlocked()" class="mosaic-blocked-banner">
<i class="pi pi-lock text-xs" />
<span>Dia bloqueado apenas compromissos de sessão não são permitidos</span>
</div>
<div class="p-2">
<FullCalendar
:ref="(el) => setCalendarRef(el, (clinicColumn ? (sIdx + 1) : sIdx))"
@@ -273,48 +412,120 @@ function buildFcOptions (ownerId) {
</div>
</template>
<style>
/* Evento customizado — unscoped pois é HTML injetado pelo FullCalendar */
.ev-custom {
display: flex;
align-items: flex-start;
gap: 5px;
overflow: hidden;
padding: 1px 2px;
height: 100%;
width: 100%;
}
.ev-avatar {
width: 22px;
height: 22px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 1px;
}
.ev-avatar-img { object-fit: cover; }
.ev-avatar-initials {
background: rgba(255,255,255,0.25);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 700;
letter-spacing: .5px;
}
.ev-body { min-width: 0; flex: 1; overflow: hidden; }
.ev-time { font-size: 10px; opacity: 0.8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ev-title { font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.3; }
.ev-obs { font-size: 10px; opacity: 0.75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
</style>
<style scoped>
.mosaic-shell{
display:flex;
gap:12px;
.mosaic-shell {
display: flex;
gap: 12px;
padding: 8px;
}
@media (min-width: 768px){
.mosaic-shell{ padding: 12px; }
@media (min-width: 768px) {
.mosaic-shell { padding: 12px; }
}
.mosaic-fixed{
.mosaic-fixed {
flex: 0 0 auto;
width: 420px;
min-width: 320px;
max-width: 460px;
}
.mosaic-scroll{
.mosaic-scroll {
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
}
.mosaic-grid{
display:grid;
.mosaic-grid {
display: grid;
grid-auto-flow: column;
gap:12px;
gap: 12px;
}
.mosaic-col{
.mosaic-col {
border-radius: 1.25rem;
border: 1px solid var(--surface-border);
background: color-mix(in_srgb, var(--surface-card), transparent 12%);
overflow:hidden;
background: color-mix(in srgb, var(--surface-card), transparent 12%);
overflow: hidden;
}
.mosaic-col-head{
.mosaic-col-head {
padding: 12px;
border-bottom: 1px solid var(--surface-border);
display:flex;
align-items:center;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
/* Banner vermelho de dia bloqueado no topo de cada coluna */
.mosaic-blocked-banner {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
font-size: 0.75rem;
font-weight: 600;
color: var(--red-700, #b91c1c);
background: color-mix(in srgb, var(--red-400, #f87171) 15%, var(--surface-card));
border-bottom: 1px solid color-mix(in srgb, var(--red-400, #f87171) 30%, transparent);
}
</style>
<style>
.fc-slot-label-hour {
display: inline-block;
font-size: 0.8rem;
font-weight: 700;
color: var(--text-color);
letter-spacing: -0.01em;
line-height: 1;
}
.fc-slot-label-half {
display: inline-block;
font-size: 0.68rem;
font-weight: 400;
color: var(--text-color-secondary);
opacity: 0.5;
line-height: 1;
padding-left: 2px;
}
/* Garante opacidade total nos dias bloqueados (background event do FullCalendar) */
.fc-blocked-day {
opacity: 1 !important;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,603 @@
<!-- src/features/agenda/components/BloqueioDialog.vue -->
<script setup>
import { ref, computed, watch } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useFeriados } from '@/composables/useFeriados'
import { useToast } from 'primevue/usetoast'
import DatePicker from 'primevue/datepicker'
const props = defineProps({
modelValue: Boolean,
mode: { type: String, default: 'horario' }, // 'horario' | 'periodo' | 'dia' | 'feriados'
workRules: { type: Array, default: () => [] },
settings: { type: Object, default: null },
ownerId: { type: String, default: '' },
tenantId: { type: [String, null], default: null }
})
const emit = defineEmits(['update:modelValue', 'saved'])
const toast = useToast()
const saving = ref(false)
// ── Feriados ──────────────────────────────────────────────────────────────
const { proximos, load: loadFeriados, criar: criarFeriado } = useFeriados()
// ── Mode: horario ─────────────────────────────────────────────────────────
const todayDow = new Date().getDay()
const timeSlots = computed(() => {
const rule = props.workRules.find(r => Number(r.dia_semana) === todayDow)
if (!rule) return []
const dur = props.settings?.session_duration_min ?? props.settings?.duracao_padrao_minutos ?? 50
const [sh, sm] = String(rule.hora_inicio || '08:00').slice(0, 5).split(':').map(Number)
const [eh, em] = String(rule.hora_fim || '18:00').slice(0, 5).split(':').map(Number)
const startMin = sh * 60 + sm
const endMin = eh * 60 + em
const slots = []
for (let t = startMin; t + dur <= endMin; t += dur) {
const h1 = Math.floor(t / 60), m1 = t % 60
const t2 = t + dur, h2 = Math.floor(t2 / 60), m2 = t2 % 60
const hi = `${String(h1).padStart(2, '0')}:${String(m1).padStart(2, '0')}`
const hf = `${String(h2).padStart(2, '0')}:${String(m2).padStart(2, '0')}`
slots.push({ label: `${hi} ${hf}`, hora_inicio: hi, hora_fim: hf })
}
return slots
})
const selectedSlotIndices = ref(new Set())
function toggleSlot (idx) {
const s = new Set(selectedSlotIndices.value)
if (s.has(idx)) s.delete(idx)
else s.add(idx)
selectedSlotIndices.value = s
}
// ── Mode: periodo ─────────────────────────────────────────────────────────
const periodos = ref([
{ label: 'Manhã', sub: '06:00 12:00', icon: 'pi pi-sun', hora_inicio: '06:00', hora_fim: '12:00', selected: false },
{ label: 'Tarde', sub: '12:00 18:00', icon: 'pi pi-cloud-sun', hora_inicio: '12:00', hora_fim: '18:00', selected: false },
{ label: 'Noite', sub: '18:00 23:00', icon: 'pi pi-moon', hora_inicio: '18:00', hora_fim: '23:00', selected: false }
])
const periodoDate = ref(new Date())
// ── Mode: dia ─────────────────────────────────────────────────────────────
const selectedDays = ref([])
// ── Mode: feriados ────────────────────────────────────────────────────────
const upcomingFeriados = computed(() => proximos(90))
const feriadosDecisao = ref({}) // { [iso]: true (trabalha) | false (não trabalha) }
// Dialog feriado municipal
const fdlgOpen = ref(false)
const fsaving = ref(false)
const fform = ref({ nome: '', data: null, observacao: '' })
// ── Reset ao abrir ────────────────────────────────────────────────────────
watch(() => props.modelValue, (v) => {
if (!v) return
selectedSlotIndices.value = new Set()
periodos.value.forEach(p => { p.selected = false })
periodoDate.value = new Date()
selectedDays.value = []
feriadosDecisao.value = {}
if (props.mode === 'feriados' && props.tenantId) {
loadFeriados(props.tenantId)
}
})
// ── Helpers ───────────────────────────────────────────────────────────────
function toISO (d) {
if (!d) return null
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function fmtDateLong (iso) {
if (!iso) return ''
const [y, m, d] = iso.split('-').map(Number)
return new Date(y, m - 1, d).toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long' })
}
function setFeriadoDecisao (data, rawVal) {
const val = rawVal === 'sim' ? true : rawVal === 'nao' ? false : undefined
const copy = { ...feriadosDecisao.value }
if (val === undefined) delete copy[data]
else copy[data] = val
feriadosDecisao.value = copy
}
// ── UI ────────────────────────────────────────────────────────────────────
const dialogTitle = computed(() => ({
horario: 'Bloquear por Horário',
periodo: 'Bloquear por Período',
dia: 'Bloquear por Dia',
feriados: 'Bloqueio por Feriados'
}[props.mode] || 'Bloquear'))
const canConfirm = computed(() => {
if (props.mode === 'horario') return selectedSlotIndices.value.size > 0
if (props.mode === 'periodo') return periodos.value.some(p => p.selected)
if (props.mode === 'dia') return selectedDays.value.length > 0
if (props.mode === 'feriados') return Object.values(feriadosDecisao.value).some(v => v === false)
return false
})
function close () { emit('update:modelValue', false) }
// ── Confirmar bloqueio ────────────────────────────────────────────────────
async function confirmar () {
if (!props.ownerId || !props.tenantId) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Configurações da agenda não carregadas.', life: 3000 })
return
}
saving.value = true
try {
const base = {
owner_id: props.ownerId,
tenant_id: props.tenantId,
tipo: 'bloqueio',
recorrente: false
}
const rows = []
if (props.mode === 'horario') {
const iso = toISO(new Date())
timeSlots.value.forEach((slot, idx) => {
if (!selectedSlotIndices.value.has(idx)) return
rows.push({ ...base,
titulo: `Bloqueio ${slot.hora_inicio}${slot.hora_fim}`,
data_inicio: iso,
data_fim: iso,
hora_inicio: slot.hora_inicio,
hora_fim: slot.hora_fim,
origem: 'agenda_horario'
})
})
} else if (props.mode === 'periodo') {
const iso = toISO(periodoDate.value)
periodos.value.filter(p => p.selected).forEach(p => {
rows.push({ ...base,
titulo: `Bloqueio ${p.label}`,
data_inicio: iso,
data_fim: iso,
hora_inicio: p.hora_inicio,
hora_fim: p.hora_fim,
origem: 'agenda_periodo'
})
})
} else if (props.mode === 'dia') {
selectedDays.value.forEach(d => {
rows.push({ ...base,
titulo: 'Dia bloqueado',
data_inicio: toISO(d),
data_fim: toISO(d),
hora_inicio: null,
hora_fim: null,
origem: 'agenda_dia'
})
})
} else if (props.mode === 'feriados') {
for (const [data, trabalha] of Object.entries(feriadosDecisao.value)) {
if (trabalha !== false) continue
const f = upcomingFeriados.value.find(f => f.data === data)
rows.push({ ...base,
titulo: f ? `Feriado: ${f.nome}` : 'Feriado bloqueado',
data_inicio: data,
data_fim: data,
hora_inicio: null,
hora_fim: null,
origem: 'agenda_feriado'
})
}
}
if (!rows.length) {
toast.add({ severity: 'warn', summary: 'Seleção vazia', detail: 'Selecione ao menos um item para bloquear.', life: 2500 })
return
}
const { error } = await supabase.from('agenda_bloqueios').insert(rows)
if (error) throw error
// Marcar sessões existentes como "remarcar"
await marcarSessoesParaRemarcar(rows)
toast.add({
severity: 'success',
summary: 'Bloqueio criado',
detail: `${rows.length} bloqueio(s) registrado(s). Sessões existentes marcadas para reagendamento.`,
life: 4500
})
emit('saved')
close()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao criar bloqueio.', life: 4000 })
} finally {
saving.value = false
}
}
async function marcarSessoesParaRemarcar (bloqueios) {
// Para cada bloqueio, tenta marcar sessões existentes como 'remarcar'
for (const b of bloqueios) {
try {
let query = supabase
.from('agenda_eventos')
.update({ status: 'remarcar' })
.eq('owner_id', props.ownerId)
.eq('tipo', 'sessao')
.gte('inicio_em', `${b.data_inicio}T00:00:00`)
.lte('inicio_em', `${b.data_fim}T23:59:59`)
if (b.hora_inicio && b.hora_fim) {
// filtra pela hora aproximada — comparação UTC simplificada
query = query
.gte('inicio_em', `${b.data_inicio}T${b.hora_inicio}:00`)
.lte('inicio_em', `${b.data_inicio}T${b.hora_fim}:00`)
}
await query
} catch { /* ignora erros parciais — o bloqueio já foi criado */ }
}
}
// ── Feriado municipal ─────────────────────────────────────────────────────
async function salvarFeriadoMunicipal () {
if (!fform.value.nome || !fform.value.data) return
fsaving.value = true
const iso = toISO(fform.value.data)
try {
await criarFeriado({
tenant_id: props.tenantId,
owner_id: props.ownerId,
tipo: 'municipal',
nome: fform.value.nome.trim(),
data: iso,
observacao: fform.value.observacao || null,
bloqueia_sessoes: true
})
toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 })
// Auto-marca como "não trabalha" para facilitar
feriadosDecisao.value = { ...feriadosDecisao.value, [iso]: false }
fdlgOpen.value = false
fform.value = { nome: '', data: null, observacao: '' }
// Recarrega feriados
if (props.tenantId) loadFeriados(props.tenantId)
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
} finally {
fsaving.value = false
}
}
</script>
<template>
<!-- Dialog principal -->
<Dialog
:visible="modelValue"
modal
:draggable="false"
:header="dialogTitle"
:style="{ width: '540px', maxWidth: '96vw' }"
@update:visible="emit('update:modelValue', $event)"
>
<!-- Banner de aviso -->
<div class="blq-warning mb-4">
<i class="pi pi-exclamation-triangle blq-warning__icon" />
<div class="text-sm leading-relaxed">
<b>Atenção:</b> sessões existentes nos períodos bloqueados serão marcadas como
<b>Remarcar</b> e os pacientes receberão aviso por e-mail/SMS para reagendamento.<br />
<span class="opacity-70 text-xs">O bloqueio prevalece sobre qualquer compromisso agendado.</span>
</div>
</div>
<!-- Modo: Horário -->
<div v-if="mode === 'horario'" class="flex flex-col gap-3">
<p class="text-sm text-[var(--text-color-secondary)]">
Selecione os horários de <b>hoje</b> que deseja bloquear (baseados na sua jornada).
Presencial e online serão bloqueados simultaneamente.
</p>
<div v-if="timeSlots.length === 0" class="blq-empty">
<i class="pi pi-info-circle" />
Hoje não é um dia de trabalho configurado na agenda.
</div>
<div v-else class="flex flex-wrap gap-2">
<button
v-for="(slot, idx) in timeSlots"
:key="idx"
class="blq-chip"
:class="{ 'blq-chip--on': selectedSlotIndices.has(idx) }"
type="button"
@click="toggleSlot(idx)"
>
<i class="pi pi-clock text-xs" />
{{ slot.label }}
</button>
</div>
<p v-if="selectedSlotIndices.size > 0" class="text-xs text-[var(--text-color-secondary)]">
<i class="pi pi-lock mr-1" style="color:var(--red-500)" />
{{ selectedSlotIndices.size }} horário(s) selecionado(s)
</p>
</div>
<!-- Modo: Período -->
<div v-else-if="mode === 'periodo'" class="flex flex-col gap-4">
<p class="text-sm text-[var(--text-color-secondary)]">
Selecione o dia e os períodos que deseja bloquear.
</p>
<div>
<label class="blq-label">Data *</label>
<DatePicker
v-model="periodoDate"
showIcon fluid iconDisplay="input"
dateFormat="dd/mm/yy"
:manualInput="false"
class="mt-1"
>
<template #inputicon="sp">
<i class="pi pi-calendar" @click="sp.clickCallback" />
</template>
</DatePicker>
</div>
<div class="grid grid-cols-3 gap-3">
<button
v-for="p in periodos"
:key="p.label"
class="blq-period-card"
:class="{ 'blq-period-card--on': p.selected }"
type="button"
@click="p.selected = !p.selected"
>
<i :class="p.icon" class="text-xl mb-1" />
<span class="font-semibold text-sm">{{ p.label }}</span>
<span class="text-xs opacity-60">{{ p.sub }}</span>
</button>
</div>
</div>
<!-- Modo: Dia -->
<div v-else-if="mode === 'dia'" class="flex flex-col gap-3">
<p class="text-sm text-[var(--text-color-secondary)]">
Clique nos dias que deseja bloquear. O dia inteiro ficará indisponível para agendamentos.
</p>
<Calendar
v-model="selectedDays"
inline
selectionMode="multiple"
:minDate="new Date()"
class="w-full"
/>
<p v-if="selectedDays.length" class="text-xs text-[var(--text-color-secondary)]">
<i class="pi pi-lock mr-1" style="color:var(--red-500)" />
{{ selectedDays.length }} dia(s) selecionado(s)
</p>
</div>
<!-- Modo: Feriados -->
<div v-else-if="mode === 'feriados'" class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-2 flex-wrap">
<p class="text-sm text-[var(--text-color-secondary)] m-0">
Próximos feriados (90 dias). Indique se vai trabalhar em cada um.
</p>
<Button
label="+ Feriado municipal"
icon="pi pi-map-marker"
size="small"
severity="secondary"
outlined
class="shrink-0 rounded-full"
@click="fdlgOpen = true"
/>
</div>
<div v-if="upcomingFeriados.length === 0" class="blq-empty">
<i class="pi pi-calendar" />
Nenhum feriado nos próximos 90 dias.
</div>
<div v-else class="flex flex-col gap-2 max-h-[320px] overflow-y-auto pr-1">
<div
v-for="f in upcomingFeriados"
:key="f.data"
class="blq-feriado-row"
:class="{ 'blq-feriado-row--blocked': feriadosDecisao[f.data] === false }"
>
<div class="flex-1 min-w-0">
<div class="font-medium text-sm truncate">{{ f.nome }}</div>
<div class="text-xs text-[var(--text-color-secondary)] capitalize">
{{ fmtDateLong(f.data) }}
</div>
</div>
<div class="flex items-center gap-2 shrink-0 flex-wrap justify-end">
<span class="text-xs text-[var(--text-color-secondary)] whitespace-nowrap">Vai trabalhar?</span>
<SelectButton
:modelValue="feriadosDecisao[f.data] === true ? 'sim' : feriadosDecisao[f.data] === false ? 'nao' : null"
:options="[{ label: 'Sim', value: 'sim' }, { label: 'Não', value: 'nao' }]"
optionLabel="label"
optionValue="value"
:allowEmpty="true"
size="small"
@update:modelValue="(v) => setFeriadoDecisao(f.data, v)"
/>
</div>
</div>
</div>
</div>
<!-- Footer -->
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="close" />
<Button
label="Confirmar Bloqueio"
icon="pi pi-lock"
severity="danger"
:loading="saving"
:disabled="!canConfirm"
@click="confirmar"
/>
</template>
</Dialog>
<!-- Dialog feriado municipal -->
<Dialog
v-model:visible="fdlgOpen"
modal
:draggable="false"
header="Cadastrar feriado municipal"
:style="{ width: '420px' }"
>
<div class="flex flex-col gap-4 pt-1">
<div>
<label class="blq-label">Nome *</label>
<InputText
v-model="fform.nome"
class="w-full mt-1"
placeholder="Ex.: Aniversário da cidade, Padroeiro…"
/>
</div>
<div>
<label class="blq-label">Data *</label>
<DatePicker
v-model="fform.data"
showIcon fluid iconDisplay="input"
dateFormat="dd/mm/yy"
:manualInput="false"
class="mt-1"
>
<template #inputicon="sp">
<i class="pi pi-calendar" @click="sp.clickCallback" />
</template>
</DatePicker>
</div>
<div>
<label class="blq-label">Observação <span class="opacity-60">(opcional)</span></label>
<Textarea v-model="fform.observacao" class="w-full mt-1" rows="2" autoResize />
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="fdlgOpen = false" />
<Button
label="Cadastrar"
icon="pi pi-check"
:disabled="!fform.nome || !fform.data"
:loading="fsaving"
@click="salvarFeriadoMunicipal"
/>
</template>
</Dialog>
</template>
<style scoped>
/* ── Aviso ─────────────────────────────────────────────── */
.blq-warning {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.75rem 1rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--red-400, #f87171) 10%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--red-400, #f87171) 30%, transparent);
}
.blq-warning__icon {
color: var(--red-500, #ef4444);
flex-shrink: 0;
margin-top: 2px;
}
/* ── Label ─────────────────────────────────────────────── */
.blq-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-color-secondary);
}
/* ── Empty ─────────────────────────────────────────────── */
.blq-empty {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1.5rem 1rem;
border-radius: 0.875rem;
border: 1px dashed var(--surface-border);
font-size: 0.875rem;
color: var(--text-color-secondary);
justify-content: center;
}
/* ── Chips de horário ──────────────────────────────────── */
.blq-chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.4rem 0.875rem;
border-radius: 999px;
border: 1.5px solid var(--surface-border);
background: var(--surface-card);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.14s;
color: var(--text-color);
}
.blq-chip:hover {
border-color: var(--red-300, #fca5a5);
background: color-mix(in srgb, var(--red-400, #f87171) 8%, var(--surface-card));
}
.blq-chip--on {
border-color: var(--red-500, #ef4444) !important;
background: color-mix(in srgb, var(--red-500, #ef4444) 15%, var(--surface-card)) !important;
color: var(--red-700, #b91c1c);
}
/* ── Cards de período ──────────────────────────────────── */
.blq-period-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.2rem;
padding: 1.25rem 0.5rem;
border-radius: 1rem;
border: 1.5px solid var(--surface-border);
background: var(--surface-card);
cursor: pointer;
transition: all 0.14s;
color: var(--text-color);
}
.blq-period-card:hover {
border-color: var(--red-300, #fca5a5);
background: color-mix(in srgb, var(--red-400, #f87171) 8%, var(--surface-card));
}
.blq-period-card--on {
border-color: var(--red-500, #ef4444) !important;
background: color-mix(in srgb, var(--red-500, #ef4444) 15%, var(--surface-card)) !important;
color: var(--red-700, #b91c1c);
}
/* ── Feriados ──────────────────────────────────────────── */
.blq-feriado-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.875rem;
border-radius: 0.875rem;
border: 1.5px solid var(--surface-border);
background: var(--surface-card);
transition: all 0.14s;
}
.blq-feriado-row--blocked {
border-color: var(--red-500, #ef4444);
background: color-mix(in srgb, var(--red-500, #ef4444) 10%, var(--surface-card));
}
</style>
@@ -0,0 +1,442 @@
<!-- src/features/agenda/components/ProximosFeriadosCard.vue -->
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useToast } from 'primevue/usetoast'
import { useFeriados } from '@/composables/useFeriados'
import DatePicker from 'primevue/datepicker'
defineOptions({ inheritAttrs: false })
const props = defineProps({
// Quando passados pelas páginas de agenda, dispensam o boot() interno
ownerId: { type: String, default: null },
tenantId: { type: String, default: null },
workRules: { type: Array, default: () => [] }
})
const emit = defineEmits(['bloqueado'])
const router = useRouter()
const tenantStore = useTenantStore()
const toast = useToast()
const { nacionais, municipais, todos, loading, load, criar, remover, isDuplicata, doMes } = useFeriados()
// ── Auth — só faz boot interno se as props não vieram ────────
const _ownerId = ref(props.ownerId)
const _tenantId = ref(props.tenantId)
watch(() => props.ownerId, v => { if (v) _ownerId.value = v })
watch(() => props.tenantId, v => { if (v) _tenantId.value = v })
async function boot () {
if (!_ownerId.value) {
const { data } = await supabase.auth.getUser()
_ownerId.value = data?.user?.id || null
}
if (!_tenantId.value) {
_tenantId.value = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null
}
if (_tenantId.value) await load(_tenantId.value)
}
onMounted(boot)
// ── Feriados do mês atual ────────────────────────────────────
const mesAtual = new Date().getMonth() + 1
const feriadosMes = computed(() => doMes(mesAtual))
const MESES = ['Janeiro','Fevereiro','Março','Abril','Maio','Junho','Julho','Agosto','Setembro','Outubro','Novembro','Dezembro']
const nomeMes = MESES[mesAtual - 1]
// ── Dias de trabalho (dow) ────────────────────────────────────
const workDowSet = computed(() =>
new Set((props.workRules || []).filter(r => r.ativo).map(r => Number(r.dia_semana)))
)
function isDiaUtil (iso) {
if (!iso) return false
const [y, m, d] = iso.split('-').map(Number)
const dow = new Date(y, m - 1, d).getDay()
// Se não tem workRules, assume que todo dia pode ser relevante
if (!props.workRules?.length) return true
return workDowSet.value.has(dow)
}
// ── Bloqueios já existentes para o mês ───────────────────────
const bloqueiosDatas = ref(new Set()) // Set de ISO strings já bloqueadas (feriado)
const loadingBloqueios = ref(false)
async function loadBloqueiosMes () {
if (!_ownerId.value) return
const ano = new Date().getFullYear()
const start = `${ano}-${String(mesAtual).padStart(2,'0')}-01`
const end = `${ano}-${String(mesAtual).padStart(2,'0')}-31`
loadingBloqueios.value = true
try {
const { data } = await supabase
.from('agenda_bloqueios')
.select('data_inicio')
.eq('owner_id', _ownerId.value)
.in('origem', ['agenda_feriado', 'agenda_dia'])
.gte('data_inicio', start)
.lte('data_inicio', end)
bloqueiosDatas.value = new Set((data || []).map(r => r.data_inicio))
} catch { /* silencioso */ }
finally { loadingBloqueios.value = false }
}
watch(_ownerId, v => { if (v) loadBloqueiosMes() })
onMounted(() => { if (_ownerId.value) loadBloqueiosMes() })
function jaFoiBloqueado (iso) {
return bloqueiosDatas.value.has(iso)
}
// ── Dupla confirmação inline ──────────────────────────────────
const confirmandoIso = ref(null) // ISO do feriado aguardando confirmação
const salvandoIso = ref(null) // ISO sendo gravado
function pedirConfirmacao (iso) {
// Se já está confirmando outro, cancela e abre o novo
confirmandoIso.value = confirmandoIso.value === iso ? null : iso
}
function cancelarConfirmacao () {
confirmandoIso.value = null
}
async function confirmarBloqueio (feriado) {
if (!_ownerId.value || !_tenantId.value) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Configurações da agenda não carregadas.', life: 3000 })
return
}
salvandoIso.value = feriado.data
confirmandoIso.value = null
try {
const row = {
owner_id: _ownerId.value,
tenant_id: _tenantId.value,
tipo: 'bloqueio',
recorrente: false,
titulo: `Feriado: ${feriado.nome}`,
data_inicio: feriado.data,
data_fim: feriado.data,
hora_inicio: null,
hora_fim: null,
origem: 'agenda_feriado'
}
const { error } = await supabase.from('agenda_bloqueios').insert([row])
if (error) throw error
// Marcar sessões existentes no dia como 'remarcar'
await supabase
.from('agenda_eventos')
.update({ status: 'remarcar' })
.eq('owner_id', _ownerId.value)
.eq('tipo', 'sessao')
.gte('inicio_em', `${feriado.data}T00:00:00`)
.lte('inicio_em', `${feriado.data}T23:59:59`)
bloqueiosDatas.value = new Set([...bloqueiosDatas.value, feriado.data])
toast.add({
severity: 'success',
summary: 'Dia bloqueado',
detail: `${feriado.nome} bloqueado. Sessões existentes marcadas para reagendamento.`,
life: 4000
})
emit('bloqueado', feriado)
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao bloquear.', life: 4000 })
} finally {
salvandoIso.value = null
}
}
// ── Dialog cadastro municipal ─────────────────────────────────
const dlgOpen = ref(false)
const saving = ref(false)
const form = ref({ nome: '', data: null, observacao: '', bloqueia_sessoes: false })
const formValid = computed(() => !!form.value.nome.trim() && !!form.value.data)
function abrirDialog () {
form.value = { nome: '', data: null, observacao: '', bloqueia_sessoes: false }
dlgOpen.value = true
}
function dateToISO (d) {
if (!d) return null
const dt = d instanceof Date ? d : new Date(d)
return `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`
}
async function salvar () {
if (!formValid.value) return
const iso = dateToISO(form.value.data)
if (isDuplicata(iso, form.value.nome)) {
toast.add({ severity: 'warn', summary: 'Duplicado', detail: 'Já existe um feriado com esse nome nessa data.', life: 3000 })
return
}
saving.value = true
try {
await criar({
tenant_id: _tenantId.value,
owner_id: _ownerId.value,
tipo: 'municipal',
nome: form.value.nome.trim(),
data: iso,
observacao: form.value.observacao || null,
bloqueia_sessoes: form.value.bloqueia_sessoes
})
toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 })
dlgOpen.value = false
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
} finally {
saving.value = false
}
}
// ── Helpers ───────────────────────────────────────────────────
function fmtDate (iso) {
if (!iso) return ''
const [, m, d] = String(iso).split('-')
return `${d}/${m}`
}
</script>
<template>
<div v-bind="$attrs" class="rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm">
<!-- Cabeçalho -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border)]">
<div class="flex items-center gap-2">
<i class="pi pi-star text-amber-500 text-sm" />
<span class="font-semibold text-sm">Próximos feriados</span>
</div>
<span class="text-xs text-[var(--text-color-secondary)]">{{ nomeMes }}</span>
</div>
<!-- Lista -->
<div class="px-4 py-3">
<div v-if="loading" class="flex justify-center py-3">
<i class="pi pi-spinner pi-spin opacity-40" />
</div>
<div v-else-if="!feriadosMes.length" class="text-sm text-[var(--text-color-secondary)] py-1">
Nenhum feriado este mês.
</div>
<ul v-else class="flex flex-col gap-2">
<li
v-for="f in feriadosMes"
:key="f.data + f.nome"
class="flex flex-col gap-1"
>
<!-- Linha principal do feriado -->
<div class="flex items-center gap-2 text-sm">
<span class="text-[var(--text-color-secondary)] font-mono text-xs w-10 shrink-0">{{ fmtDate(f.data) }}</span>
<span class="flex-1 truncate" :class="{ 'line-through opacity-50': jaFoiBloqueado(f.data) }">{{ f.nome }}</span>
<Tag
:value="f.tipo === 'nacional' ? 'Nacional' : 'Municipal'"
:severity="f.tipo === 'nacional' ? 'info' : 'warn'"
class="text-xs shrink-0"
/>
<!-- Botão bloquear / bloqueado -->
<template v-if="isDiaUtil(f.data)">
<!-- bloqueado -->
<span
v-if="jaFoiBloqueado(f.data)"
v-tooltip.top="'Dia bloqueado'"
class="pfc-lock pfc-lock--done"
>
<i class="pi pi-lock text-xs" />
</span>
<!-- Salvando -->
<span v-else-if="salvandoIso === f.data" class="pfc-lock">
<i class="pi pi-spinner pi-spin text-xs" />
</span>
<!-- Aguardando confirmação ícone ativo -->
<button
v-else-if="confirmandoIso === f.data"
v-tooltip.top="'Cancelar'"
class="pfc-lock pfc-lock--active"
@click="cancelarConfirmacao"
>
<i class="pi pi-times text-xs" />
</button>
<!-- Estado normal abre confirmação -->
<button
v-else
v-tooltip.top="'Bloquear este dia'"
class="pfc-lock pfc-lock--idle"
@click="pedirConfirmacao(f.data)"
>
<i class="pi pi-lock-open text-xs" />
</button>
</template>
</div>
<!-- Confirmação inline (expande abaixo do item) -->
<Transition name="pfc-expand">
<div
v-if="confirmandoIso === f.data"
class="pfc-confirm"
>
<i class="pi pi-exclamation-triangle pfc-confirm__icon" />
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold mb-0.5">Bloquear {{ f.nome }}?</p>
<p class="text-xs opacity-70 leading-snug">O dia inteiro ficará indisponível. Sessões existentes serão marcadas para reagendamento.</p>
</div>
<div class="flex gap-1.5 shrink-0">
<Button label="Não" size="small" severity="secondary" outlined class="rounded-full h-7 text-xs px-3" @click="cancelarConfirmacao" />
<Button label="Bloquear" size="small" severity="danger" icon="pi pi-lock" class="rounded-full h-7 text-xs px-3" @click="confirmarBloqueio(f)" />
</div>
</div>
</Transition>
</li>
</ul>
</div>
<!-- Ações -->
<div class="flex flex-col gap-1.5 px-4 pb-4">
<Button
icon="pi pi-plus"
label="Cadastrar feriado municipal"
severity="secondary"
outlined
size="small"
class="w-full rounded-full"
@click="abrirDialog"
/>
<Button
icon="pi pi-list"
label="Ver todos os feriados"
text
size="small"
class="w-full rounded-full"
@click="router.push('/configuracoes/bloqueios')"
/>
</div>
</div>
<!-- Dialog cadastro -->
<Dialog
v-model:visible="dlgOpen"
modal
:draggable="false"
header="Cadastrar feriado municipal"
:style="{ width: '420px' }"
>
<div class="flex flex-col gap-4 pt-1">
<div>
<label class="text-xs text-[var(--text-color-secondary)] font-medium">Nome do feriado *</label>
<InputText v-model="form.nome" class="w-full mt-1" placeholder="Ex.: Aniversário da cidade, Padroeiro…" />
</div>
<div>
<label class="text-xs text-[var(--text-color-secondary)] font-medium">Data *</label>
<DatePicker
v-model="form.data"
showIcon fluid iconDisplay="input"
dateFormat="dd/mm/yy"
:manualInput="false"
class="mt-1"
>
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
<div>
<label class="text-xs text-[var(--text-color-secondary)] font-medium">Observação <span class="opacity-60">(opcional)</span></label>
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
</div>
<div v-if="form.data && form.nome && isDuplicata(dateToISO(form.data), form.nome)"
class="text-sm text-red-500 flex items-center gap-2">
<i class="pi pi-exclamation-triangle" />
existe um feriado com esse nome nessa data.
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="dlgOpen = false" />
<Button
label="Cadastrar"
icon="pi pi-check"
:disabled="!formValid || (form.data && form.nome && isDuplicata(dateToISO(form.data), form.nome))"
:loading="saving"
@click="salvar"
/>
</template>
</Dialog>
</template>
<style scoped>
/* ── Ícone de cadeado por feriado ────────────────────────── */
.pfc-lock {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
flex-shrink: 0;
transition: all 0.14s;
}
.pfc-lock--idle {
color: var(--text-color-secondary);
background: transparent;
border: 1.5px solid var(--surface-border);
cursor: pointer;
}
.pfc-lock--idle:hover {
color: var(--red-600, #dc2626);
border-color: var(--red-400, #f87171);
background: color-mix(in srgb, var(--red-400, #f87171) 10%, transparent);
}
.pfc-lock--active {
color: var(--red-600, #dc2626);
border: 1.5px solid var(--red-400, #f87171);
background: color-mix(in srgb, var(--red-400, #f87171) 12%, transparent);
cursor: pointer;
}
.pfc-lock--done {
color: var(--text-color-secondary);
opacity: 0.45;
cursor: default;
}
/* ── Confirmação inline ───────────────────────────────────── */
.pfc-confirm {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--red-400, #f87171) 10%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--red-400, #f87171) 30%, transparent);
margin-left: 2.75rem; /* alinha com o nome, após a data */
}
.pfc-confirm__icon {
color: var(--red-500, #ef4444);
flex-shrink: 0;
margin-top: 2px;
font-size: 0.8rem;
}
/* ── Transição expand ─────────────────────────────────────── */
.pfc-expand-enter-active,
.pfc-expand-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.pfc-expand-enter-from,
.pfc-expand-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
@@ -0,0 +1,277 @@
/**
* useRecurrence.spec.js
*
* Testa as funções puras do módulo de recorrência:
* - generateDates → geração de datas por tipo de regra
* - expandRules → aplicação de exceções sobre as ocorrências
* - mergeWithStoredSessions → merge de ocorrências virtuais com eventos reais
*
* Não usa Supabase — sem mocks necessários.
*/
import { describe, it, expect } from 'vitest'
import { generateDates, expandRules, mergeWithStoredSessions } from '../useRecurrence.js'
// ─── helpers de fixture ───────────────────────────────────────────────────────
function d (iso) {
const [y, m, day] = iso.split('-').map(Number)
return new Date(y, m - 1, day)
}
function rule (overrides = {}) {
return {
id: 'rule-1',
owner_id: 'owner-1',
tenant_id: 'tenant-1',
patient_id: 'patient-1',
therapist_id: 'therapist-1',
status: 'ativo',
type: 'weekly',
weekdays: [1], // segunda
interval: 1,
start_date: '2026-03-02', // segunda
end_date: null,
max_occurrences: null,
open_ended: true,
start_time: '09:00',
end_time: '10:00',
...overrides,
}
}
function exception (overrides = {}) {
return {
id: 'exc-1',
recurrence_id: 'rule-1',
original_date: '2026-03-09',
type: 'cancel_session',
new_date: null,
...overrides,
}
}
// ─── generateDates ────────────────────────────────────────────────────────────
describe('generateDates — weekly', () => {
it('gera ocorrências semanais dentro do range', () => {
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-02' })
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'))
const isos = dates.map(d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`)
expect(isos).toEqual(['2026-03-02', '2026-03-09', '2026-03-16', '2026-03-23', '2026-03-30'])
})
it('não gera antes do start_date da regra', () => {
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-16' })
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'))
expect(dates.every(d => d >= new Date(2026, 2, 16))).toBe(true)
})
it('não gera após o end_date da regra', () => {
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-02', end_date: '2026-03-16' })
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'))
expect(dates.length).toBe(3) // 02, 09, 16
})
it('respeita max_occurrences dentro do range', () => {
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-02', max_occurrences: 2 })
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'))
expect(dates.length).toBe(2)
})
it('respeita max_occurrences globalmente — range começa na 3ª semana', () => {
// 4 ocorrências totais, range começa na semana 3 → só 2 dentro do range
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-02', max_occurrences: 4 })
const dates = generateDates(r, d('2026-03-15'), d('2026-04-30'))
// 16, 23, 30 → mas max=4 globalmente (2 antes do range + 2 no range)
expect(dates.length).toBe(2) // 2026-03-16, 2026-03-23
})
})
describe('generateDates — biweekly', () => {
it('gera ocorrências a cada 2 semanas', () => {
const r = rule({ type: 'biweekly', weekdays: [1], interval: 2, start_date: '2026-03-02' })
const dates = generateDates(r, d('2026-03-01'), d('2026-04-30'))
const isos = dates.map(d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`)
expect(isos).toEqual(['2026-03-02', '2026-03-16', '2026-03-30', '2026-04-13', '2026-04-27'])
})
})
describe('generateDates — custom_weekdays', () => {
it('gera ocorrências em múltiplos dias da semana', () => {
const r = rule({ type: 'custom_weekdays', weekdays: [1, 3], start_date: '2026-03-02' }) // seg e qua
const dates = generateDates(r, d('2026-03-01'), d('2026-03-08'))
const isos = dates.map(d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`)
expect(isos).toEqual(['2026-03-02', '2026-03-04'])
})
it('respeita max_occurrences globalmente com custom_weekdays', () => {
// 2 dias/semana, max=3 → semana 1 (02,04), semana 2 (09) e para
const r = rule({ type: 'custom_weekdays', weekdays: [1, 3], start_date: '2026-03-02', max_occurrences: 3 })
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'))
expect(dates.length).toBe(3)
})
it('max_occurrences globalmente — range começa na semana 2', () => {
// semana 1 já consumiu 2 ocorrências (02, 04), max=3 → só 1 no range (09)
const r = rule({ type: 'custom_weekdays', weekdays: [1, 3], start_date: '2026-03-02', max_occurrences: 3 })
const dates = generateDates(r, d('2026-03-08'), d('2026-03-31'))
expect(dates.length).toBe(1)
})
})
describe('generateDates — monthly', () => {
it('gera ocorrências mensais no mesmo dia', () => {
const r = rule({ type: 'monthly', weekdays: [1], start_date: '2026-01-15' })
const dates = generateDates(r, d('2026-01-01'), d('2026-04-30'))
expect(dates.length).toBe(4)
expect(dates.every(d => d.getDate() === 15)).toBe(true)
})
it('respeita max_occurrences globalmente — range começa no mês 3', () => {
const r = rule({ type: 'monthly', weekdays: [1], start_date: '2026-01-15', max_occurrences: 3 })
const dates = generateDates(r, d('2026-03-01'), d('2026-12-31'))
expect(dates.length).toBe(1) // só março (jan+fev já consumiram 2 de 3)
})
})
describe('generateDates — yearly', () => {
it('gera ocorrências anuais', () => {
const r = rule({ type: 'yearly', weekdays: [1], start_date: '2024-06-15' })
const dates = generateDates(r, d('2024-01-01'), d('2027-12-31'))
expect(dates.length).toBe(4) // 2024, 2025, 2026, 2027
})
it('respeita max_occurrences globalmente — range começa no ano 3', () => {
const r = rule({ type: 'yearly', weekdays: [1], start_date: '2024-06-15', max_occurrences: 3 })
const dates = generateDates(r, d('2026-01-01'), d('2030-12-31'))
expect(dates.length).toBe(1) // só 2026 (2024+2025 já consumiram 2 de 3)
})
})
// ─── expandRules ─────────────────────────────────────────────────────────────
describe('expandRules', () => {
it('gera ocorrência normal sem exceção', () => {
const rules = [rule()]
const occs = expandRules(rules, [], d('2026-03-01'), d('2026-03-08'))
expect(occs.length).toBe(1)
expect(occs[0].status).toBe('agendado')
expect(occs[0].exception_type).toBeNull()
})
it('cancela ocorrência com cancel_session', () => {
const rules = [rule()]
const excs = [exception({ type: 'cancel_session', original_date: '2026-03-02' })]
const occs = expandRules(rules, excs, d('2026-03-01'), d('2026-03-08'))
expect(occs[0].status).toBe('cancelado')
expect(occs[0].exception_type).toBe('cancel_session')
})
it('marca falta com patient_missed', () => {
const rules = [rule()]
const excs = [exception({ type: 'patient_missed', original_date: '2026-03-02' })]
const occs = expandRules(rules, excs, d('2026-03-01'), d('2026-03-08'))
expect(occs[0].status).toBe('faltou')
})
it('remarca ocorrência para nova data', () => {
const rules = [rule()]
const excs = [exception({
type: 'reschedule_session',
original_date: '2026-03-02',
new_date: '2026-03-04',
})]
const occs = expandRules(rules, excs, d('2026-03-01'), d('2026-03-08'))
// A ocorrência do dia 02 foi movida para 04
expect(occs[0].status).toBe('remarcado')
expect(occs[0].exception_type).toBe('reschedule_session')
// inicio_em reflete a nova data (04); original_date no main loop recebe new_date
expect(occs[0].inicio_em).toContain('2026-03-04')
})
it('post-pass: remarcação inbound — original fora do range, new_date dentro', () => {
// Regra começa em 02/03 (segunda). original_date = 09/03 está FORA do range 16-22.
// new_date = 17/03 está DENTRO do range.
const rules = [rule({ start_date: '2026-03-02' })]
const excs = [exception({
type: 'reschedule_session',
original_date: '2026-03-09', // fora do range
new_date: '2026-03-17', // dentro do range
})]
const occs = expandRules(rules, excs, d('2026-03-16'), d('2026-03-22'))
const remarcado = occs.find(o => o.status === 'remarcado')
expect(remarcado).toBeDefined()
expect(remarcado.original_date).toBe('2026-03-09')
expect(remarcado.inicio_em).toContain('2026-03-17')
})
it('ignora regra cancelada', () => {
const rules = [rule({ status: 'cancelado' })]
const occs = expandRules(rules, [], d('2026-03-01'), d('2026-03-31'))
expect(occs.length).toBe(0)
})
})
// ─── mergeWithStoredSessions ──────────────────────────────────────────────────
describe('mergeWithStoredSessions', () => {
it('sessão real substitui ocorrência virtual para a mesma regra+data', () => {
const occs = [{
recurrence_id: 'rule-1',
original_date: '2026-03-02',
status: 'agendado',
is_occurrence: true,
is_real_session: false,
titulo: 'Virtual',
}]
const storedRows = [{
id: 'ev-real-1',
recurrence_id: 'rule-1',
recurrence_date: '2026-03-02',
status: 'realizado',
titulo: 'Real',
}]
const merged = mergeWithStoredSessions(occs, storedRows)
expect(merged.length).toBe(1)
expect(merged[0].is_real_session).toBe(true)
expect(merged[0].status).toBe('realizado')
expect(merged[0].titulo).toBe('Real')
})
it('mantém ocorrência virtual quando não há sessão real', () => {
const occs = [{
recurrence_id: 'rule-1',
original_date: '2026-03-02',
status: 'agendado',
is_occurrence: true,
}]
const merged = mergeWithStoredSessions(occs, [])
expect(merged.length).toBe(1)
expect(merged[0].is_occurrence).toBe(true)
})
it('adiciona sessão real órfã (sem ocorrência correspondente)', () => {
const storedRows = [{
id: 'ev-orphan',
recurrence_id: 'rule-1',
recurrence_date: '2026-03-30', // data fora do range expandido
status: 'agendado',
}]
const merged = mergeWithStoredSessions([], storedRows)
expect(merged.length).toBe(1)
expect(merged[0].is_real_session).toBe(true)
})
it('não duplica quando há tanto ocorrência quanto sessão real', () => {
const occs = [
{ recurrence_id: 'rule-1', original_date: '2026-03-02', is_occurrence: true },
{ recurrence_id: 'rule-1', original_date: '2026-03-09', is_occurrence: true },
]
const stored = [
{ recurrence_id: 'rule-1', recurrence_date: '2026-03-02', status: 'realizado' }
]
const merged = mergeWithStoredSessions(occs, stored)
expect(merged.length).toBe(2)
})
})
@@ -1,106 +1,186 @@
// src/features/agenda/composables/useAgendaEvents.js
import { ref } from 'vue'
/**
* useAgendaEvents.js
* src/features/agenda/composables/useAgendaEvents.js
*
* Gerencia apenas eventos reais (agenda_eventos).
* Sessões com recurrence_id são sessões reais de uma série.
*/
import {
listMyAgendaEvents,
listClinicEvents,
createAgendaEvento,
updateAgendaEvento,
deleteAgendaEvento
} from '../services/agendaRepository.js'
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
// ─── helpers internos ────────────────────────────────────────────────────────
function assertTenantId (tenantId) {
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar na agenda.')
}
}
async function getUid () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const uid = data?.user?.id
if (!uid) throw new Error('Usuário não autenticado.')
return uid
}
const BASE_SELECT = `
id, owner_id, patient_id, tipo, status,
titulo, titulo_custom, observacoes, inicio_em, fim_em,
terapeuta_id, tenant_id, visibility_scope,
determined_commitment_id, link_online, extra_fields, modalidade,
recurrence_id, recurrence_date,
mirror_of_event_id, price,
patients!agenda_eventos_patient_id_fkey (
id, nome_completo, avatar_url
),
determined_commitments!agenda_eventos_determined_commitment_fk (
id, bg_color, text_color
)
`.trim()
export function useAgendaEvents () {
const rows = ref([])
const loading = ref(false)
const error = ref('')
const rows = ref([])
const error = ref(null)
async function loadMyRange (start, end, ownerId) {
if (!ownerId) return
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
assertTenantId(tenantId)
async function loadMyRange (startISO, endISO) {
loading.value = true
error.value = ''
error.value = null
try {
rows.value = await listMyAgendaEvents({ startISO, endISO })
return rows.value
const { data, error: err } = await supabase
.from('agenda_eventos')
.select(BASE_SELECT)
.eq('tenant_id', tenantId)
.eq('owner_id', ownerId)
.is('mirror_of_event_id', null)
.gte('inicio_em', start)
.lte('inicio_em', end)
.order('inicio_em', { ascending: true })
if (err) throw err
rows.value = (data || []).map(flattenRow)
} catch (e) {
error.value = e?.message || 'Falha ao carregar eventos.'
error.value = e?.message || 'Erro ao carregar eventos'
rows.value = []
return []
} finally {
loading.value = false
}
}
async function loadClinicRange (ownerIds, startISO, endISO) {
loading.value = true
error.value = ''
try {
// ✅ evita erro "invalid input syntax for type uuid: null"
const safeIds = (ownerIds || []).filter(id => typeof id === 'string' && id && id !== 'null' && id !== 'undefined')
if (!safeIds.length) {
rows.value = []
return []
}
rows.value = await listClinicEvents({ ownerIds: safeIds, startISO, endISO })
return rows.value
} catch (e) {
error.value = e?.message || 'Falha ao carregar eventos da clínica.'
rows.value = []
return []
} finally {
loading.value = false
}
}
/**
* Cria um evento injetando tenant_id e owner_id automaticamente.
* owner_id é sempre o usuário autenticado — nunca vem do payload externo.
* tenant_id vem do tenantStore ativo — nunca do payload externo.
*/
async function create (payload) {
loading.value = true
error.value = ''
try {
const created = await createAgendaEvento(payload)
return created
} catch (e) {
error.value = e?.message || 'Falha ao criar evento.'
throw e
} finally {
loading.value = false
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
assertTenantId(tenantId)
const uid = await getUid()
// eslint-disable-next-line no-unused-vars
const { paciente_id: _dropped, ...rest } = payload
const safePayload = {
...rest,
tenant_id: tenantId,
owner_id: uid,
}
const { data, error: err } = await supabase
.from('agenda_eventos')
.insert([safePayload])
.select(BASE_SELECT)
.single()
if (err) throw err
return flattenRow(data)
}
async function update (id, patch) {
loading.value = true
error.value = ''
try {
const updated = await updateAgendaEvento(id, patch)
return updated
} catch (e) {
error.value = e?.message || 'Falha ao atualizar evento.'
throw e
} finally {
loading.value = false
}
if (!id) throw new Error('ID inválido.')
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
assertTenantId(tenantId)
// eslint-disable-next-line no-unused-vars
const { paciente_id: _dropped, ...safePatch } = patch
const { data, error: err } = await supabase
.from('agenda_eventos')
.update(safePatch)
.eq('id', id)
.eq('tenant_id', tenantId)
.select(BASE_SELECT)
.single()
if (err) throw err
return flattenRow(data)
}
async function remove (id) {
loading.value = true
error.value = ''
try {
await deleteAgendaEvento(id)
return true
} catch (e) {
error.value = e?.message || 'Falha ao excluir evento.'
throw e
} finally {
loading.value = false
}
if (!id) throw new Error('ID inválido.')
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
assertTenantId(tenantId)
const { error: err } = await supabase
.from('agenda_eventos')
.delete()
.eq('id', id)
.eq('tenant_id', tenantId)
if (err) throw err
}
return {
loading,
error,
rows,
loadMyRange,
loadClinicRange,
create,
update,
remove
async function removeSeriesFrom (recurrenceId, fromDateISO) {
if (!recurrenceId) throw new Error('recurrenceId inválido.')
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
assertTenantId(tenantId)
const { error: err } = await supabase
.from('agenda_eventos')
.delete()
.eq('recurrence_id', recurrenceId)
.eq('tenant_id', tenantId)
.gte('recurrence_date', fromDateISO)
if (err) throw err
}
async function removeAllSeries (recurrenceId) {
if (!recurrenceId) throw new Error('recurrenceId inválido.')
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
assertTenantId(tenantId)
const { error: err } = await supabase
.from('agenda_eventos')
.delete()
.eq('recurrence_id', recurrenceId)
.eq('tenant_id', tenantId)
if (err) throw err
}
return { rows, loading, error, loadMyRange, create, update, remove, removeSeriesFrom, removeAllSeries }
}
function flattenRow (r) {
if (!r) return r
const patient = r.patients || null
const out = { ...r }
delete out.patients
out.paciente_nome = patient?.nome_completo || out.paciente_nome || ''
out.paciente_avatar = patient?.avatar_url || out.paciente_avatar || ''
return out
}
@@ -1,24 +1,31 @@
// src/features/agenda/composables/useAgendaSettings.js
import { ref } from 'vue'
import { getMyAgendaSettings } from '../services/agendaRepository'
import { getMyAgendaSettings, getMyWorkSchedule } from '../services/agendaRepository'
export function useAgendaSettings () {
const loading = ref(false)
const error = ref('')
const settings = ref(null)
const workRules = ref([]) // [{ dia_semana, hora_inicio, hora_fim }]
async function load () {
loading.value = true
error.value = ''
try {
settings.value = await getMyAgendaSettings()
const [cfg, rules] = await Promise.all([
getMyAgendaSettings(),
getMyWorkSchedule()
])
settings.value = cfg
workRules.value = rules
} catch (e) {
error.value = e?.message || 'Falha ao carregar configurações da agenda.'
settings.value = null
workRules.value = []
} finally {
loading.value = false
}
}
return { loading, error, settings, load }
}
return { loading, error, settings, workRules, load }
}
@@ -0,0 +1,57 @@
// src/features/agenda/composables/useProfessionalPricing.js
//
// Carrega a tabela professional_pricing do owner logado e expõe
// getPriceFor(commitmentId) → number | null
//
// null = commitment_id
// Regra: lookup exato → fallback NULL → null se nenhum configurado
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
export function useProfessionalPricing () {
const rows = ref([]) // professional_pricing rows
const loading = ref(false)
const error = ref('')
// ── Carregar todos os preços do owner ──────────────────────────────
async function load (ownerId) {
if (!ownerId) return
loading.value = true
error.value = ''
try {
const { data, error: err } = await supabase
.from('professional_pricing')
.select('id, determined_commitment_id, price, notes')
.eq('owner_id', ownerId)
if (err) throw err
rows.value = data || []
} catch (e) {
error.value = e?.message || 'Falha ao carregar precificação.'
rows.value = []
} finally {
loading.value = false
}
}
// ── Consulta: preço para um tipo de compromisso ────────────────────
// 1. Linha com determined_commitment_id === commitmentId
// 2. Fallback: linha com determined_commitment_id === null (preço padrão)
// 3. null se nada configurado
function getPriceFor (commitmentId) {
if (!rows.value.length) return null
// match exato
if (commitmentId) {
const exact = rows.value.find(r => r.determined_commitment_id === commitmentId)
if (exact && exact.price != null) return Number(exact.price)
}
// fallback padrão (commitment_id IS NULL)
const def = rows.value.find(r => r.determined_commitment_id === null)
return def && def.price != null ? Number(def.price) : null
}
return { rows, loading, error, load, getPriceFor }
}
@@ -0,0 +1,653 @@
/**
* useRecurrence.js
* src/features/agenda/composables/useRecurrence.js
*
* Coração da nova arquitetura de recorrência.
* Gera ocorrências dinamicamente no frontend a partir das regras.
* Nunca grava eventos futuros no banco — apenas regras + exceções.
*
* Fluxo:
* 1. loadRules(ownerId, rangeStart, rangeEnd) → busca regras ativas
* 2. loadExceptions(ruleIds, rangeStart, rangeEnd) → busca exceções no range
* 3. expandRules(rules, exceptions, rangeStart, rangeEnd) → gera ocorrências
* 4. mergeWithStoredSessions(occurrences, storedRows) → sessões reais sobrepõem
*/
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { logRecurrence, logError, logPerf } from '@/support/supportLogger'
// ─── helpers de data ────────────────────────────────────────────────────────
/** 'YYYY-MM-DD' → Date (local, sem UTC shift) */
function parseDate (iso) {
const [y, m, d] = String(iso).slice(0, 10).split('-').map(Number)
return new Date(y, m - 1, d)
}
/** Date → 'YYYY-MM-DD' */
function toISO (d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** 'HH:MM' ou 'HH:MM:SS' → { hours, minutes } */
function parseTime (t) {
const [h, m] = String(t || '00:00').split(':').map(Number)
return { hours: h || 0, minutes: m || 0 }
}
/** Aplica HH:MM a um Date, retorna novo Date */
function applyTime (date, timeStr) {
const d = new Date(date)
const { hours, minutes } = parseTime(timeStr)
d.setHours(hours, minutes, 0, 0)
return d
}
/** Avança cursor para o próximo dia-da-semana especificado */
function nextWeekday (fromDate, targetDow) {
const d = new Date(fromDate)
const diff = (targetDow - d.getDay() + 7) % 7
d.setDate(d.getDate() + (diff === 0 ? 0 : diff))
return d
}
// ─── geradores de datas por tipo ─────────────────────────────────────────────
/**
* Gera array de datas (Date) para uma regra no intervalo [rangeStart, rangeEnd]
* Respeita: start_date, end_date, max_occurrences, open_ended
*/
export function generateDates (rule, rangeStart, rangeEnd) {
const ruleStart = parseDate(rule.start_date)
const ruleEnd = rule.end_date ? parseDate(rule.end_date) : null
const effStart = ruleStart > rangeStart ? ruleStart : rangeStart
const effEnd = ruleEnd && ruleEnd < rangeEnd ? ruleEnd : rangeEnd
const interval = Number(rule.interval || 1)
const weekdays = (rule.weekdays || []).map(Number)
if (!weekdays.length) return []
const dates = []
if (rule.type === 'weekly' || rule.type === 'biweekly') {
const dow = weekdays[0]
// primeira ocorrência da série (a partir do start_date da regra)
const firstInSerie = nextWeekday(ruleStart, dow)
// conta quantas ocorrências já existem ANTES do range atual
// para saber o occurrenceCount global correto
let globalCount = 0
const counter = new Date(firstInSerie)
while (counter < effStart) {
globalCount++
counter.setDate(counter.getDate() + 7 * interval)
}
// agora itera a partir do effStart gerando as do range
const cur = new Date(counter) // está na primeira data >= effStart
while (cur <= effEnd) {
if (rule.max_occurrences && globalCount >= rule.max_occurrences) break
dates.push(new Date(cur))
globalCount++
cur.setDate(cur.getDate() + 7 * interval)
}
} else if (rule.type === 'custom_weekdays') {
// múltiplos dias da semana, intervalo semanal
// Conta ocorrências ANTES do range para respeitar max_occurrences globalmente
let occurrenceCount = 0
const sortedDows = [...weekdays].sort()
// Início da semana de ruleStart
const preStart = new Date(ruleStart)
preStart.setDate(preStart.getDate() - preStart.getDay())
// Pré-conta ocorrências entre ruleStart e effStart (cursor separado)
const preCur = new Date(preStart)
while (preCur < effStart) {
for (const dow of sortedDows) {
const d = new Date(preCur)
d.setDate(d.getDate() + dow)
if (d >= ruleStart && d < effStart) occurrenceCount++
}
preCur.setDate(preCur.getDate() + 7)
}
// Itera a partir da semana que contém effStart (cursor independente do preCur)
const weekOfEffStart = new Date(effStart)
weekOfEffStart.setDate(weekOfEffStart.getDate() - weekOfEffStart.getDay())
const cur = new Date(weekOfEffStart)
while (cur <= effEnd) {
for (const dow of sortedDows) {
const d = new Date(cur)
d.setDate(d.getDate() + dow)
if (d >= effStart && d <= effEnd && d >= ruleStart) {
if (rule.max_occurrences && occurrenceCount >= rule.max_occurrences) break
dates.push(new Date(d))
occurrenceCount++
}
}
cur.setDate(cur.getDate() + 7)
}
} else if (rule.type === 'monthly') {
// mesmo dia do mês
// Conta ocorrências ANTES do range para respeitar max_occurrences globalmente
let occurrenceCount = 0
const dayOfMonth = ruleStart.getDate()
// Pré-conta: de ruleStart até o mês anterior a effStart
const preCur = new Date(ruleStart.getFullYear(), ruleStart.getMonth(), dayOfMonth)
while (preCur < effStart) {
if (preCur >= ruleStart) occurrenceCount++
preCur.setMonth(preCur.getMonth() + interval)
}
// Itera a partir do primeiro mês dentro do range
const cur = new Date(preCur)
while (cur <= effEnd) {
if (cur >= ruleStart) {
if (rule.max_occurrences && occurrenceCount >= rule.max_occurrences) break
dates.push(new Date(cur))
occurrenceCount++
}
cur.setMonth(cur.getMonth() + interval)
}
} else if (rule.type === 'yearly') {
// Conta ocorrências ANTES do range para respeitar max_occurrences globalmente
let occurrenceCount = 0
// Pré-conta: de ruleStart até o ano anterior a effStart
const preCur = new Date(ruleStart)
while (preCur < effStart) {
occurrenceCount++
preCur.setFullYear(preCur.getFullYear() + interval)
}
// Itera a partir do primeiro ano dentro do range
const cur = new Date(preCur)
while (cur <= effEnd) {
if (rule.max_occurrences && occurrenceCount >= rule.max_occurrences) break
dates.push(new Date(cur))
occurrenceCount++
cur.setFullYear(cur.getFullYear() + interval)
}
}
return dates
}
// ─── expansão principal ──────────────────────────────────────────────────────
/**
* Expande regras em ocorrências, aplica exceções.
*
* @param {Array} rules - regras do banco
* @param {Array} exceptions - exceções do banco (todas as das regras carregadas)
* @param {Date} rangeStart
* @param {Date} rangeEnd
* @returns {Array} occurrences — objetos com shape compatível com FullCalendar
*/
export function expandRules (rules, exceptions, rangeStart, rangeEnd) {
// índice de exceções por regra+data
const exMap = new Map()
for (const ex of exceptions || []) {
const key = `${ex.recurrence_id}::${ex.original_date}`
exMap.set(key, ex)
}
const occurrences = []
// Rastreia IDs de exceções consumidas no loop principal
const handledExIds = new Set()
for (const rule of rules || []) {
if (rule.status === 'cancelado') continue
const dates = generateDates(rule, rangeStart, rangeEnd)
for (const date of dates) {
const iso = toISO(date)
const exKey = `${rule.id}::${iso}`
const exception = exMap.get(exKey)
if (exception) handledExIds.add(exception.id)
// ── exceção: cancela esta ocorrência ──
if (exception?.type === 'cancel_session'
|| exception?.type === 'patient_missed'
|| exception?.type === 'therapist_canceled'
|| exception?.type === 'holiday_block') {
// ainda inclui no calendário mas com status especial
occurrences.push(buildOccurrence(rule, date, iso, exception))
continue
}
// ── exceção: remarca esta ocorrência ──
if (exception?.type === 'reschedule_session') {
const newDate = exception.new_date ? parseDate(exception.new_date) : date
const newIso = exception.new_date || iso
occurrences.push(buildOccurrence(rule, newDate, newIso, exception))
continue
}
// ── ocorrência normal ──
occurrences.push(buildOccurrence(rule, date, iso, null))
}
}
// ── post-pass: remarcações inbound ──────────────────────────────────────────
// Cobre exceções do tipo reschedule_session cujo original_date estava FORA do
// range (não gerado pelo loop acima) mas cujo new_date cai DENTRO do range.
// Essas exceções chegam aqui via loadExceptions query 2, mas nunca são
// alcançadas no loop principal — sem este post-pass o slot ficaria vazio.
const ruleMap = new Map((rules || []).map(r => [r.id, r]))
const startISO = toISO(rangeStart)
const endISO = toISO(rangeEnd)
for (const ex of exceptions || []) {
if (handledExIds.has(ex.id)) continue
if (ex.type !== 'reschedule_session') continue
if (!ex.new_date) continue
if (ex.new_date < startISO || ex.new_date > endISO) continue
const rule = ruleMap.get(ex.recurrence_id)
if (!rule || rule.status === 'cancelado') continue
const newDate = parseDate(ex.new_date)
occurrences.push(buildOccurrence(rule, newDate, ex.original_date, ex))
}
return occurrences
}
/**
* Constrói o objeto de ocorrência no formato que o calendário e o dialog esperam
*/
function buildOccurrence (rule, date, originalIso, exception) {
const effectiveStartTime = exception?.new_start_time || rule.start_time
const effectiveEndTime = exception?.new_end_time || rule.end_time
const start = applyTime(date, effectiveStartTime)
const end = applyTime(date, effectiveEndTime)
const exType = exception?.type || null
return {
// identificação
id: `rec::${rule.id}::${originalIso}`, // id virtual
recurrence_id: rule.id,
original_date: originalIso,
is_occurrence: true, // flag para diferenciar de eventos reais
is_real_session: false,
// dados da regra
determined_commitment_id: rule.determined_commitment_id,
patient_id: rule.patient_id,
paciente_id: rule.patient_id,
owner_id: rule.owner_id,
therapist_id: rule.therapist_id,
terapeuta_id: rule.therapist_id,
tenant_id: rule.tenant_id,
// nome do paciente — injetado pelo loadAndExpand via _patient
paciente_nome: rule._patient?.nome_completo ?? null,
paciente_avatar: rule._patient?.avatar_url ?? null,
patient_name: rule._patient?.nome_completo ?? null,
// tempo
inicio_em: start.toISOString(),
fim_em: end.toISOString(),
// campos opcionais
modalidade: exception?.modalidade || rule.modalidade || 'presencial',
titulo_custom: exception?.titulo_custom || rule.titulo_custom || null,
observacoes: exception?.observacoes || rule.observacoes || null,
extra_fields: exception?.extra_fields || rule.extra_fields || null,
price: rule.price ?? null,
// estado da exceção
exception_type: exType,
exception_id: exception?.id || null,
exception_reason: exception?.reason || null,
// status derivado da exceção
status: _statusFromException(exType),
// para o FullCalendar
tipo: 'sessao',
}
}
function _statusFromException (exType) {
if (!exType) return 'agendado'
if (exType === 'cancel_session') return 'cancelado'
if (exType === 'patient_missed') return 'faltou'
if (exType === 'therapist_canceled') return 'cancelado'
if (exType === 'holiday_block') return 'bloqueado'
if (exType === 'reschedule_session') return 'remarcado'
return 'agendado'
}
/**
* Merge ocorrências geradas com sessões reais do banco.
* Sessões reais (is_real_session=true) sobrepõem ocorrências geradas
* para a mesma regra+data.
*
* @param {Array} occurrences - geradas por expandRules
* @param {Array} storedRows - linhas de agenda_eventos com recurrence_id
* @returns {Array} merged
*/
export function mergeWithStoredSessions (occurrences, storedRows) {
// índice de sessões reais por recurrence_id + recurrence_date
const realMap = new Map()
for (const row of storedRows || []) {
if (!row.recurrence_id || !row.recurrence_date) continue
const key = `${row.recurrence_id}::${row.recurrence_date}`
realMap.set(key, { ...row, is_real_session: true, is_occurrence: false })
}
const result = []
for (const occ of occurrences) {
const key = `${occ.recurrence_id}::${occ.original_date}`
if (realMap.has(key)) {
result.push(realMap.get(key))
realMap.delete(key) // evita duplicata
} else {
result.push(occ)
}
}
// adiciona sessões reais que não tiveram ocorrência correspondente
// (ex: sessões avulsas ligadas a uma regra mas fora do range normal)
for (const real of realMap.values()) {
result.push(real)
}
return result
}
// ─── composable principal ────────────────────────────────────────────────────
export function useRecurrence () {
const rules = ref([])
const exceptions = ref([])
const loading = ref(false)
const error = ref(null)
/**
* Carrega regras ativas para um owner no range dado.
* @param {string} ownerId
* @param {Date} rangeStart
* @param {Date} rangeEnd
* @param {string|null} tenantId — se fornecido, filtra também por tenant (multi-clínica)
*/
async function loadRules (ownerId, rangeStart, rangeEnd, tenantId = null) {
if (!ownerId) { logRecurrence('loadRules: ownerId vazio, abortando'); return }
const endPerf = logPerf('useRecurrence', 'loadRules')
try {
const startISO = toISO(rangeStart)
const endISO = toISO(rangeEnd)
logRecurrence('loadRules →', { ownerId, tenantId, startISO, endISO })
// Busca regras sem end_date (abertas) + regras com end_date >= rangeStart
// Dois selects separados evitam problemas com .or() + .is.null no Supabase JS
const baseQuery = () => {
let q = supabase
.from('recurrence_rules')
.select('*')
.eq('owner_id', ownerId)
.eq('status', 'ativo')
.lte('start_date', endISO)
.order('start_date', { ascending: true })
// Filtra por tenant quando disponível — defesa em profundidade
if (tenantId && tenantId !== 'null' && tenantId !== 'undefined') {
q = q.eq('tenant_id', tenantId)
}
return q
}
const [resOpen, resWithEnd] = await Promise.all([
baseQuery().is('end_date', null),
baseQuery().gte('end_date', startISO).not('end_date', 'is', null),
])
if (resOpen.error) throw resOpen.error
if (resWithEnd.error) throw resWithEnd.error
// deduplica por id (improvável mas seguro)
const merged = [...(resOpen.data || []), ...(resWithEnd.data || [])]
const seen = new Set()
rules.value = merged.filter(r => { if (seen.has(r.id)) return false; seen.add(r.id); return true })
logRecurrence('loadRules ← regras encontradas', { count: rules.value.length })
endPerf({ ruleCount: rules.value.length })
} catch (e) {
logError('useRecurrence', 'loadRules ERRO', e)
error.value = e?.message || 'Erro ao carregar regras'
rules.value = []
}
}
/**
* Carrega exceções para as regras carregadas no range.
*
* Dois casos cobertos:
* 1. original_date no range → cobre cancels, faltas, remarcações-para-fora e remarcações-normais
* 2. reschedule_session com new_date no range (original fora do range)
* → "remarcação inbound": sessão de outra semana/mês movida para cair neste range
*
* Ambos os resultados são mesclados e deduplicados por id.
*/
async function loadExceptions (rangeStart, rangeEnd) {
const ids = rules.value.map(r => r.id)
if (!ids.length) { exceptions.value = []; return }
try {
const startISO = toISO(rangeStart)
const endISO = toISO(rangeEnd)
// Query 1 — comportamento original: exceções cujo original_date está no range
const q1 = supabase
.from('recurrence_exceptions')
.select('*')
.in('recurrence_id', ids)
.gte('original_date', startISO)
.lte('original_date', endISO)
// Query 2 — bug fix: remarcações cujo new_date cai neste range
// (original_date pode estar antes ou depois do range)
const q2 = supabase
.from('recurrence_exceptions')
.select('*')
.in('recurrence_id', ids)
.eq('type', 'reschedule_session')
.not('new_date', 'is', null)
.gte('new_date', startISO)
.lte('new_date', endISO)
const [res1, res2] = await Promise.all([q1, q2])
if (res1.error) throw res1.error
if (res2.error) throw res2.error
// Mescla e deduplica por id
const merged = [...(res1.data || []), ...(res2.data || [])]
const seen = new Set()
exceptions.value = merged.filter(ex => {
if (seen.has(ex.id)) return false
seen.add(ex.id)
return true
})
} catch (e) {
error.value = e?.message || 'Erro ao carregar exceções'
exceptions.value = []
}
}
/**
* Carrega tudo e retorna ocorrências expandidas + merged com sessões reais.
* @param {string} ownerId
* @param {Date} rangeStart
* @param {Date} rangeEnd
* @param {Array} storedRows — eventos reais já carregados
* @param {string|null} tenantId — filtra regras por tenant (multi-clínica)
*/
async function loadAndExpand (ownerId, rangeStart, rangeEnd, storedRows = [], tenantId = null) {
loading.value = true
error.value = null
const endPerf = logPerf('useRecurrence', 'loadAndExpand')
logRecurrence('loadAndExpand START', { ownerId, tenantId, storedRows: storedRows.length })
try {
await loadRules(ownerId, rangeStart, rangeEnd, tenantId)
await loadExceptions(rangeStart, rangeEnd)
// Busca nomes dos pacientes das regras carregadas
const patientIds = [...new Set(rules.value.map(r => r.patient_id).filter(Boolean))]
if (patientIds.length) {
const { data: patients } = await supabase
.from('patients')
.select('id, nome_completo, avatar_url')
.in('id', patientIds)
// injeta nome diretamente na regra para o buildOccurrence usar
const pMap = new Map((patients || []).map(p => [p.id, p]))
for (const rule of rules.value) {
if (rule.patient_id && pMap.has(rule.patient_id)) {
rule._patient = pMap.get(rule.patient_id)
}
}
}
const occurrences = expandRules(rules.value, exceptions.value, rangeStart, rangeEnd)
logRecurrence('expandRules → ocorrências', { count: occurrences.length })
const merged = mergeWithStoredSessions(occurrences, storedRows)
logRecurrence('merged final', { count: merged.length })
endPerf({ occurrences: occurrences.length, merged: merged.length })
return merged
} catch (e) {
logError('useRecurrence', 'loadAndExpand ERRO', e)
error.value = e?.message || 'Erro ao expandir recorrências'
return []
} finally {
loading.value = false
}
}
// ── CRUD de regras ──────────────────────────────────────────────────────────
/**
* Cria uma nova regra de recorrência
* @param {Object} rule - campos da tabela recurrence_rules
* @returns {Object} regra criada
*/
async function createRule (rule) {
logRecurrence('createRule →', { patient_id: rule?.patient_id, type: rule?.type })
const { data, error: err } = await supabase
.from('recurrence_rules')
.insert([rule])
.select('*')
.single()
if (err) { logError('useRecurrence', 'createRule ERRO', err); throw err }
logRecurrence('createRule ← criado', { id: data?.id })
return data
}
/**
* Atualiza a regra toda (editar todos)
*/
async function updateRule (id, patch) {
const { data, error: err } = await supabase
.from('recurrence_rules')
.update({ ...patch, updated_at: new Date().toISOString() })
.eq('id', id)
.select('*')
.single()
if (err) throw err
return data
}
/**
* Cancela a série inteira
*/
async function cancelRule (id) {
const { error: err } = await supabase
.from('recurrence_rules')
.update({ status: 'cancelado', updated_at: new Date().toISOString() })
.eq('id', id)
if (err) throw err
}
/**
* Divide a série a partir de uma data (este e os seguintes)
* Retorna o id da nova regra criada
*/
async function splitRuleAt (id, fromDateISO) {
const { data, error: err } = await supabase
.rpc('split_recurrence_at', {
p_recurrence_id: id,
p_from_date: fromDateISO
})
if (err) throw err
return data // new rule id
}
/**
* Cancela a série a partir de uma data
*/
async function cancelRuleFrom (id, fromDateISO) {
const { error: err } = await supabase
.rpc('cancel_recurrence_from', {
p_recurrence_id: id,
p_from_date: fromDateISO
})
if (err) throw err
}
// ── CRUD de exceções ────────────────────────────────────────────────────────
/**
* Cria ou atualiza uma exceção para uma ocorrência específica
*/
async function upsertException (ex) {
const { data, error: err } = await supabase
.from('recurrence_exceptions')
.upsert([ex], { onConflict: 'recurrence_id,original_date' })
.select('*')
.single()
if (err) throw err
return data
}
/**
* Remove uma exceção (restaura a ocorrência ao normal)
*/
async function deleteException (recurrenceId, originalDate) {
const { error: err } = await supabase
.from('recurrence_exceptions')
.delete()
.eq('recurrence_id', recurrenceId)
.eq('original_date', originalDate)
if (err) throw err
}
return {
rules,
exceptions,
loading,
error,
loadRules,
loadExceptions,
loadAndExpand,
createRule,
updateRule,
cancelRule,
splitRuleAt,
cancelRuleFrom,
upsertException,
deleteException,
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,605 @@
<!-- src/features/agenda/pages/AgendaRecorrenciasPage.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useAgendaClinicStaff } from '@/features/agenda/composables/useAgendaClinicStaff'
import { useToast } from 'primevue/usetoast'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const tenantStore = useTenantStore()
const mode = computed(() => route.meta?.mode || 'therapist')
const isClinic = computed(() => mode.value === 'clinic')
const tenantId = computed(() => tenantStore.activeTenantId || tenantStore.tenantId)
// ── state ──────────────────────────────────────────────────────────────────────
const loading = ref(false)
const userId = ref(null)
const rules = ref([])
const exceptionsMap = ref({}) // ruleId → Exception[]
const sessionsMap = ref({}) // ruleId → AgendaEvento[]
const expandedId = ref(null)
const filterStatus = ref('ativo')
const filterOwner = ref(null)
const { staff, load: loadStaff } = useAgendaClinicStaff()
const staffOptions = computed(() =>
(staff.value || []).map(s => ({
label: s.full_name || s.nome || s.name || 'Profissional',
value: s.user_id
}))
)
const staffMap = computed(() => {
const m = {}
for (const s of staff.value || []) m[s.user_id] = s.full_name || s.nome || s.name || 'Profissional'
return m
})
// ── auth / init ────────────────────────────────────────────────────────────────
async function init () {
const { data } = await supabase.auth.getUser()
userId.value = data?.user?.id || null
if (isClinic.value && tenantId.value) await loadStaff(tenantId.value)
await load()
}
// ── data load ──────────────────────────────────────────────────────────────────
async function load () {
if (!userId.value) return
loading.value = true
try {
let q = supabase.from('recurrence_rules').select('*').order('start_date', { ascending: false })
if (isClinic.value) {
if (!tenantId.value) return
q = q.eq('tenant_id', tenantId.value)
if (filterOwner.value) q = q.eq('owner_id', filterOwner.value)
} else {
q = q.eq('owner_id', userId.value)
}
if (filterStatus.value !== 'all') q = q.eq('status', filterStatus.value)
const { data: rData, error: rErr } = await q
if (rErr) throw rErr
const rawRules = rData || []
// patient names
const patientIds = [...new Set(rawRules.map(r => r.patient_id).filter(Boolean))]
const patientMap = {}
if (patientIds.length) {
const { data: pts } = await supabase.from('patients').select('id, nome_completo, avatar_url').in('id', patientIds)
for (const p of pts || []) patientMap[p.id] = p
}
for (const r of rawRules) r._patient = patientMap[r.patient_id] || null
rules.value = rawRules
const ruleIds = rawRules.map(r => r.id)
if (ruleIds.length) await reloadSessions(ruleIds)
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro ao carregar', detail: e?.message, life: 3500 })
} finally {
loading.value = false
}
}
async function reloadSessions (ruleIds) {
const [exRes, sessRes] = await Promise.all([
supabase.from('recurrence_exceptions').select('*').in('recurrence_id', ruleIds).order('original_date'),
supabase.from('agenda_eventos')
.select('id, recurrence_id, recurrence_date, status, inicio_em, fim_em')
.in('recurrence_id', ruleIds).order('inicio_em')
])
const exm = {}
for (const ex of exRes.data || []) {
if (!exm[ex.recurrence_id]) exm[ex.recurrence_id] = []
exm[ex.recurrence_id].push(ex)
}
exceptionsMap.value = { ...exceptionsMap.value, ...exm, ...Object.fromEntries(ruleIds.filter(id => !exm[id]).map(id => [id, []])) }
const sm = {}
for (const s of sessRes.data || []) {
if (!sm[s.recurrence_id]) sm[s.recurrence_id] = []
sm[s.recurrence_id].push(s)
}
sessionsMap.value = { ...sessionsMap.value, ...sm, ...Object.fromEntries(ruleIds.filter(id => !sm[id]).map(id => [id, []])) }
}
// ── date generation ────────────────────────────────────────────────────────────
function generateAllDates (rule) {
const { type, interval = 1, weekdays = [], start_date, end_date, max_occurrences } = rule
if (!start_date || !Array.isArray(weekdays) || !weekdays.length) return []
const maxOcc = Math.min(max_occurrences || 500, 500)
const endLimitISO = end_date || null
const dates = []
if (type === 'custom_weekdays') {
const cursor = new Date(start_date + 'T12:00:00')
let safety = 0
while (dates.length < maxOcc && safety < 3000) {
safety++
const iso = cursor.toISOString().slice(0, 10)
if (endLimitISO && iso > endLimitISO) break
if (weekdays.includes(cursor.getDay())) dates.push(iso)
cursor.setDate(cursor.getDate() + 1)
}
} else {
// weekly / biweekly
const dow = weekdays[0]
const cursor = new Date(start_date + 'T12:00:00')
while (cursor.getDay() !== dow) cursor.setDate(cursor.getDate() + 1)
while (dates.length < maxOcc) {
const iso = cursor.toISOString().slice(0, 10)
if (endLimitISO && iso > endLimitISO) break
dates.push(iso)
cursor.setDate(cursor.getDate() + 7 * (interval || 1))
}
}
return dates
}
// ── sessions (merged) ──────────────────────────────────────────────────────────
const TODAY = new Date().toISOString().slice(0, 10)
function buildSessions (rule) {
const exByDate = {}
for (const ex of exceptionsMap.value[rule.id] || []) exByDate[ex.original_date] = ex
const sessByDate = {}
for (const s of sessionsMap.value[rule.id] || []) sessByDate[s.recurrence_date] = s
return generateAllDates(rule).map(iso => {
const real = sessByDate[iso]
const ex = exByDate[iso]
let status = 'agendado'
if (real) {
status = real.status || 'agendado'
} else if (ex) {
if (ex.type === 'cancel_session' || ex.type === 'therapist_canceled') status = 'cancelado'
else if (ex.type === 'patient_missed') status = 'faltou'
else if (ex.type === 'reschedule_session') status = 'remarcado'
}
return { date: iso, status, real_id: real?.id || null }
})
}
// ── stats ──────────────────────────────────────────────────────────────────────
const STATUS_DONE = new Set(['compareceu', 'veio', 'realizado', 'presente'])
function ruleStats (rule) {
const sessions = buildSessions(rule)
const total = sessions.length
const done = sessions.filter(s => STATUS_DONE.has(s.status)).length
const faltou = sessions.filter(s => s.status === 'faltou').length
const cancelado = sessions.filter(s => s.status === 'cancelado').length
const pendentes = sessions.filter(s => s.status === 'agendado' || s.status === 'remarcado').length
const progress = total ? Math.round((done / total) * 100) : 0
return { total, done, faltou, cancelado, pendentes, progress }
}
// ── formatters ─────────────────────────────────────────────────────────────────
const DIAS_SHORT = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']
function fmtDate (iso) {
if (!iso) return ''
const [y, m, d] = iso.split('-')
return `${d}/${m}/${y}`
}
function fmtRuleDesc (rule) {
const days = (rule.weekdays || []).map(d => DIAS_SHORT[d]).join(', ')
const time = rule.start_time ? rule.start_time.slice(0, 5) : ''
const freq = rule.interval > 1 ? `a cada ${rule.interval} sem.` : ''
return [days, time, freq].filter(Boolean).join(' · ')
}
function fmtPeriod (rule) {
const s = fmtDate(rule.start_date)
if (rule.end_date) return `${s} até ${fmtDate(rule.end_date)}`
if (rule.max_occurrences) return `${s} · ${rule.max_occurrences} sessões`
return `A partir de ${s}`
}
function fmtPillDate (iso) {
const [, m, d] = iso.split('-')
const dow = DIAS_SHORT[new Date(iso + 'T12:00:00').getDay()]
return `${dow} ${Number(d)}/${Number(m)}`
}
// ── status UI ──────────────────────────────────────────────────────────────────
const STATUS_OPTS = [
{ label: 'Agendado', value: 'agendado' },
{ label: 'Compareceu', value: 'compareceu' },
{ label: 'Faltou', value: 'faltou' },
{ label: 'Cancelado', value: 'cancelado' },
{ label: 'Remarcado', value: 'remarcado' },
]
const PILL_CLASS = {
agendado: 'pill--pending',
compareceu: 'pill--done',
veio: 'pill--done',
realizado: 'pill--done',
presente: 'pill--done',
faltou: 'pill--missed',
cancelado: 'pill--canceled',
remarcado: 'pill--rescheduled',
}
// ── actions ────────────────────────────────────────────────────────────────────
async function onPillStatusChange (rule, s, newStatus) {
try {
if (s.real_id) {
await supabase.from('agenda_eventos').update({ status: newStatus }).eq('id', s.real_id)
} else {
const { data: ex } = await supabase
.from('agenda_eventos').select('id')
.eq('recurrence_id', rule.id).eq('recurrence_date', s.date).maybeSingle()
if (ex?.id) {
await supabase.from('agenda_eventos').update({ status: newStatus }).eq('id', ex.id)
} else {
await supabase.from('agenda_eventos').insert({
recurrence_id: rule.id,
recurrence_date: s.date,
owner_id: rule.owner_id,
tenant_id: rule.tenant_id,
tipo: 'sessao',
status: newStatus,
inicio_em: s.date + 'T' + (rule.start_time || '00:00') + ':00',
fim_em: s.date + 'T' + (rule.end_time || '01:00') + ':00',
visibility_scope: 'public',
titulo: 'Sessão',
paciente_id: rule.patient_id || null,
patient_id: rule.patient_id || null,
})
}
}
toast.add({ severity: 'success', summary: 'Status atualizado', life: 1500 })
await reloadSessions([rule.id])
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message, life: 3500 })
}
}
async function onCancelRule (rule) {
const name = rule._patient?.nome_completo || 'paciente'
if (!confirm(`Encerrar a série de "${name}"?\n\nSessões futuras deixarão de ser geradas. Sessões passadas já registradas são mantidas.`)) return
try {
await supabase.from('recurrence_rules')
.update({ status: 'cancelado', updated_at: new Date().toISOString() })
.eq('id', rule.id)
toast.add({ severity: 'success', summary: 'Série encerrada', life: 2000 })
await load()
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message, life: 3500 })
}
}
async function onReactivateRule (rule) {
try {
await supabase.from('recurrence_rules')
.update({ status: 'ativo', updated_at: new Date().toISOString() })
.eq('id', rule.id)
toast.add({ severity: 'success', summary: 'Série reativada', life: 2000 })
await load()
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message, life: 3500 })
}
}
function toggleExpand (ruleId) {
expandedId.value = expandedId.value === ruleId ? null : ruleId
}
// ── navigation ─────────────────────────────────────────────────────────────────
function goBack () {
if (isClinic.value) router.push({ name: 'admin-agenda-clinica' })
else router.push({ name: 'therapist-agenda' })
}
onMounted(init)
</script>
<template>
<Toast />
<!-- Header -->
<div class="rr-page mx-3 md:mx-5">
<div class="rr-header">
<div class="flex items-center gap-3">
<Button icon="pi pi-arrow-left" text severity="secondary" class="h-9 w-9 rounded-full shrink-0" v-tooltip.bottom="'Voltar à agenda'" @click="goBack" />
<div>
<div class="text-xl font-bold leading-tight">Recorrências</div>
<div class="text-sm opacity-55">
{{ isClinic ? 'Todas as séries da clínica' : 'Suas séries de sessões recorrentes' }}
</div>
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
<!-- Status filter -->
<SelectButton
v-model="filterStatus"
:options="[
{ label: 'Ativas', value: 'ativo' },
{ label: 'Encerradas', value: 'cancelado' },
{ label: 'Todas', value: 'all' }
]"
optionLabel="label" optionValue="value" :allowEmpty="false"
@change="load"
/>
<!-- Therapist filter (clinic only) -->
<Select
v-if="isClinic && staffOptions.length"
v-model="filterOwner"
:options="[{ label: 'Todos os terapeutas', value: null }, ...staffOptions]"
optionLabel="label" optionValue="value"
placeholder="Todos os terapeutas"
class="w-[220px]"
@change="load"
/>
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" v-tooltip.bottom="'Recarregar'" @click="load" />
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex flex-col gap-3 mt-4">
<Skeleton v-for="i in 4" :key="i" height="130px" class="rounded-2xl" />
</div>
<!-- Empty -->
<div v-else-if="!rules.length" class="rr-empty">
<i class="pi pi-calendar-times text-5xl opacity-25" />
<div class="text-lg font-semibold opacity-50">Nenhuma série encontrada</div>
<div class="text-sm opacity-35">
{{ filterStatus === 'ativo' ? 'Crie sessões recorrentes na agenda para vê-las aqui.' : 'Altere o filtro de status.' }}
</div>
<Button label="Voltar à agenda" icon="pi pi-calendar" outlined severity="secondary" class="rounded-full mt-2" @click="goBack" />
</div>
<!-- Rule cards -->
<div v-else class="flex flex-col gap-4 mt-4 pb-8">
<div v-for="rule in rules" :key="rule.id" class="rr-card">
<!-- Card head: patient info + status badge -->
<div class="rr-card__head">
<div class="flex items-start gap-3 min-w-0 flex-1">
<Avatar
:label="(rule._patient?.nome_completo || '?')[0].toUpperCase()"
shape="circle" size="large"
class="shrink-0"
style="background:var(--primary-100,#e0e7ff);color:var(--primary-700,#3730a3);font-weight:700"
/>
<div class="min-w-0 flex-1">
<div class="font-bold text-base truncate leading-tight">
{{ rule._patient?.nome_completo || 'Paciente não encontrado' }}
</div>
<div v-if="isClinic && staffMap[rule.owner_id]" class="text-xs opacity-55 mt-0.5 truncate">
<i class="pi pi-user text-xs mr-1" />{{ staffMap[rule.owner_id] }}
</div>
<div class="text-sm opacity-65 mt-1">
<i class="pi pi-clock text-xs mr-1" />{{ fmtRuleDesc(rule) }}
</div>
<div class="text-xs opacity-45 mt-0.5">
<i class="pi pi-calendar text-xs mr-1" />{{ fmtPeriod(rule) }}
</div>
</div>
</div>
<Tag
:value="rule.status === 'ativo' ? 'Ativa' : 'Encerrada'"
:severity="rule.status === 'ativo' ? 'success' : 'secondary'"
class="shrink-0 self-start"
/>
</div>
<!-- Stats + progress -->
<template v-for="stats in [ruleStats(rule)]" :key="'stats-' + rule.id">
<div class="rr-stats-row">
<span class="rr-stat rr-stat--done">{{ stats.done }} compareceu</span>
<span v-if="stats.faltou" class="rr-stat rr-stat--missed">{{ stats.faltou }} faltou</span>
<span v-if="stats.cancelado" class="rr-stat rr-stat--canceled">{{ stats.cancelado }} cancelada{{ stats.cancelado !== 1 ? 's' : '' }}</span>
<span class="rr-stat rr-stat--pending">{{ stats.pendentes }} pendente{{ stats.pendentes !== 1 ? 's' : '' }}</span>
<span class="rr-stat rr-stat--total ml-auto">{{ stats.total }} sessões</span>
</div>
<div class="px-4 pb-1">
<ProgressBar :value="stats.progress" class="h-1.5 rounded-full" />
</div>
</template>
<!-- Card footer: actions -->
<div class="rr-card__foot">
<Button
:icon="expandedId === rule.id ? 'pi pi-chevron-up' : 'pi pi-list'"
:label="expandedId === rule.id ? 'Ocultar sessões' : `Ver sessões (${ruleStats(rule).total})`"
severity="secondary" outlined size="small" class="rounded-full"
@click="toggleExpand(rule.id)"
/>
<div class="flex gap-2 ml-auto">
<Button
v-if="rule.status === 'ativo'"
label="Encerrar série"
icon="pi pi-times-circle"
severity="danger" text size="small" class="rounded-full"
@click="onCancelRule(rule)"
/>
<Button
v-else
label="Reativar"
icon="pi pi-undo"
severity="success" text size="small" class="rounded-full"
@click="onReactivateRule(rule)"
/>
</div>
</div>
<!-- Sessions panel (expanded) -->
<div v-if="expandedId === rule.id" class="rr-sessions">
<div class="rr-sessions__grid">
<div
v-for="s in buildSessions(rule)"
:key="s.date"
class="rr-pill"
:class="[
PILL_CLASS[s.status] || 'pill--pending',
s.date < TODAY ? 'rr-pill--past' : s.date === TODAY ? 'rr-pill--today' : 'rr-pill--future'
]"
>
<div class="rr-pill__date">{{ fmtPillDate(s.date) }}</div>
<Select
:modelValue="s.status"
:options="STATUS_OPTS"
optionLabel="label" optionValue="value"
class="rr-pill__sel"
@change="e => onPillStatusChange(rule, s, e.value)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* ── Page ─────────────────────────────────────────────────────────── */
.rr-page {
padding-bottom: 2rem;
}
/* ── Header ───────────────────────────────────────────────────────── */
.rr-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 20px 0 16px;
border-bottom: 1px solid var(--surface-border);
margin-bottom: 4px;
}
/* ── Empty ────────────────────────────────────────────────────────── */
.rr-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 64px 24px;
text-align: center;
}
/* ── Card ─────────────────────────────────────────────────────────── */
.rr-card {
border-radius: 1.25rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
transition: box-shadow 0.15s;
}
.rr-card:hover {
box-shadow: 0 2px 16px color-mix(in srgb, var(--primary-400, #818cf8) 10%, transparent);
}
.rr-card__head {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px 16px 12px;
}
.rr-card__foot {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px 14px;
border-top: 1px solid var(--surface-border);
}
/* ── Stats row ────────────────────────────────────────────────────── */
.rr-stats-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 4px 16px 8px;
}
.rr-stat {
font-size: 0.72rem;
font-weight: 600;
border-radius: 999px;
padding: 2px 8px;
}
.rr-stat--done { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.rr-stat--missed { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
.rr-stat--canceled { background: var(--orange-100, #ffedd5); color: var(--orange-700, #c2410c); }
.rr-stat--pending { background: var(--surface-200, #e5e7eb); color: var(--text-color-secondary); }
.rr-stat--total { background: transparent; color: var(--text-color-secondary); font-weight: 400; }
/* ── Sessions panel ───────────────────────────────────────────────── */
.rr-sessions {
border-top: 1px solid var(--surface-border);
background: color-mix(in srgb, var(--surface-ground) 60%, transparent);
padding: 14px 16px;
max-height: 420px;
overflow-y: auto;
}
.rr-sessions__grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* ── Pill ─────────────────────────────────────────────────────────── */
.rr-pill {
display: flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 4px 4px 4px 10px;
border: 1px solid transparent;
min-width: 0;
}
.rr-pill--past { opacity: 0.7; }
.rr-pill--today { box-shadow: 0 0 0 2px var(--primary-400, #818cf8); opacity: 1 !important; }
.rr-pill--future { }
.pill--pending { background: var(--surface-200, #e5e7eb); border-color: var(--surface-300, #d1d5db); color: var(--text-color-secondary); }
.pill--done { background: var(--green-50, #f0fdf4); border-color: var(--green-200, #bbf7d0); color: var(--green-800, #166534); }
.pill--missed { background: var(--red-50, #fff1f2); border-color: var(--red-200, #fecaca); color: var(--red-700, #b91c1c); }
.pill--canceled { background: var(--orange-50, #fff7ed); border-color: var(--orange-200, #fed7aa); color: var(--orange-700, #c2410c); }
.pill--rescheduled{ background: var(--blue-50, #eff6ff); border-color: var(--blue-200, #bfdbfe); color: var(--blue-700, #1d4ed8); }
.rr-pill__date {
font-size: 0.72rem;
font-weight: 700;
white-space: nowrap;
letter-spacing: -0.01em;
}
.rr-pill__sel {
/* shrink the PrimeVue Select to pill size */
--p-select-padding-x: 6px;
--p-select-padding-y: 2px;
font-size: 0.7rem;
min-width: 0;
border: none;
background: transparent !important;
box-shadow: none !important;
}
:deep(.rr-pill__sel .p-select-label) {
font-size: 0.7rem;
padding: 2px 4px;
font-weight: 600;
}
:deep(.rr-pill__sel .p-select-dropdown) {
width: 1.4rem;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,849 @@
<!-- src/features/agenda/pages/AgendamentosRecebidosPage.vue -->
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useTenantStore } from '@/stores/tenantStore'
import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue'
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings'
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments'
import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents'
const toast = useToast()
const router = useRouter()
const tenantStore = useTenantStore()
// ── Identidade do usuário logado ─────────────────────────────────
const isClinic = computed(() => tenantStore.role === 'clinic_admin' || tenantStore.role === 'tenant_admin')
const tenantId = computed(() => tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null)
// owner_id = auth user ID do terapeuta (não é o tenant_id)
const ownerId = ref(null)
async function loadOwnerId () {
const { data } = await supabase.auth.getUser()
ownerId.value = data?.user?.id || null
}
// ── Filtros ──────────────────────────────────────────────────────
const filtroStatus = ref('pendente')
const filtroBusca = ref('')
const statusOpts = [
{ label: 'Pendentes', value: 'pendente', icon: 'pi-clock', sev: 'warn' },
{ label: 'Autorizados', value: 'autorizado', icon: 'pi-check-circle', sev: 'success' },
{ label: 'Convertidos', value: 'convertido', icon: 'pi-calendar-plus', sev: 'info' },
{ label: 'Recusados', value: 'recusado', icon: 'pi-times-circle', sev: 'danger' },
{ label: 'Todos', value: null, icon: 'pi-list', sev: 'secondary' }
]
// ── Lista ────────────────────────────────────────────────────────
const solicitacoes = ref([])
const loading = ref(false)
const totalPendentes = ref(0)
async function load () {
if (!ownerId.value) return
loading.value = true
try {
let q = supabase
.from('agendador_solicitacoes')
.select(`
id, owner_id, tenant_id,
paciente_nome, paciente_sobrenome, paciente_email, paciente_celular, paciente_cpf,
tipo, modalidade, data_solicitada, hora_solicitada,
reservado_ate, motivo, como_conheceu,
status, created_at
`)
.order('data_solicitada', { ascending: false })
.order('hora_solicitada', { ascending: true })
if (isClinic.value) {
q = q.eq('tenant_id', tenantId.value)
} else {
q = q.eq('owner_id', ownerId.value)
}
if (filtroStatus.value) q = q.eq('status', filtroStatus.value)
const { data, error } = await q
if (error) throw error
solicitacoes.value = data || []
// Conta pendentes para badge
if (filtroStatus.value !== 'pendente') {
let qp = supabase
.from('agendador_solicitacoes')
.select('id', { count: 'exact', head: true })
.eq('status', 'pendente')
if (isClinic.value) qp = qp.eq('tenant_id', tenantId.value)
else qp = qp.eq('owner_id', ownerId.value)
const { count } = await qp
totalPendentes.value = count || 0
} else {
totalPendentes.value = solicitacoes.value.length
}
} catch (e) {
console.error('[AgendamentosRecebidos]', e)
toast.add({ severity: 'error', summary: 'Erro', detail: 'Não foi possível carregar as solicitações.', life: 4000 })
} finally {
loading.value = false
}
}
watch(filtroStatus, load)
// ── Filtro de busca local ────────────────────────────────────────
const listaFiltrada = computed(() => {
const q = filtroBusca.value.trim().toLowerCase()
if (!q) return solicitacoes.value
return solicitacoes.value.filter(s =>
`${s.paciente_nome} ${s.paciente_sobrenome}`.toLowerCase().includes(q) ||
(s.paciente_email || '').toLowerCase().includes(q) ||
(s.paciente_celular || '').includes(q)
)
})
// ── Helpers de formatação ────────────────────────────────────────
function fmtData (iso) {
if (!iso) return '—'
const [y, m, d] = iso.split('-')
const dias = ['Dom','Seg','Ter','Qua','Qui','Sex','Sáb']
const dow = new Date(+y, +m - 1, +d).getDay()
return `${dias[dow]}, ${d}/${m}/${y}`
}
function fmtHora (h) { return h ? String(h).slice(0, 5) : '—' }
function nomeCompleto (s) { return `${s.paciente_nome || ''} ${s.paciente_sobrenome || ''}`.trim() || '—' }
const tipoLabel = { primeira: 'Primeira Entrevista', retorno: 'Retorno', reagendar: 'Reagendamento' }
const modalLabel = { presencial: 'Presencial', online: 'Online', ambos: 'Ambos' }
function statusSev (st) {
return { pendente: 'warn', autorizado: 'success', recusado: 'danger', convertido: 'info', expirado: 'secondary' }[st] || 'secondary'
}
function statusLabel (st) {
return { pendente: 'Pendente', autorizado: 'Autorizado', recusado: 'Recusado', convertido: 'Convertido', expirado: 'Expirado' }[st] || st
}
function isExpirada (s) {
if (s.status !== 'pendente') return false
if (!s.reservado_ate) return false
return new Date(s.reservado_ate) < new Date()
}
// ── Detalhe / expandido ──────────────────────────────────────────
const expandedId = ref(null)
function toggleExpand (id) {
expandedId.value = expandedId.value === id ? null : id
}
// ── Aprovar ──────────────────────────────────────────────────────
const aprovando = ref(null)
async function aprovar (s) {
aprovando.value = s.id
try {
const { error } = await supabase
.from('agendador_solicitacoes')
.update({ status: 'autorizado', autorizado_em: new Date().toISOString() })
.eq('id', s.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Autorizado', detail: `Solicitação de ${nomeCompleto(s)} autorizada.`, life: 3000 })
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
} finally {
aprovando.value = null
}
}
// ── Recusar ──────────────────────────────────────────────────────
const recusandoId = ref(null)
const recusaMotivo = ref('')
const recusaDialogOpen = ref(false)
let _recusaTarget = null
function abrirRecusa (s) {
_recusaTarget = s
recusaMotivo.value = ''
recusaDialogOpen.value = true
recusandoId.value = null
}
async function confirmarRecusa () {
const s = _recusaTarget
if (!s) return
recusandoId.value = s.id
try {
const { error } = await supabase
.from('agendador_solicitacoes')
.update({ status: 'recusado', recusado_motivo: recusaMotivo.value || null })
.eq('id', s.id)
if (error) throw error
recusaDialogOpen.value = false
toast.add({ severity: 'info', summary: 'Recusado', detail: `Solicitação de ${nomeCompleto(s)} recusada.`, life: 3000 })
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
} finally {
recusandoId.value = null
}
}
// ── Converter em sessão ─────────────────────────────────────────
const { settings, load: loadSettings } = useAgendaSettings()
const { create: createEvento } = useAgendaEvents()
const { rows: commitmentRows, load: loadCommitments } = useDeterminedCommitments(tenantId)
const commitmentOptions = computed(() => (commitmentRows.value || []).filter(c => c.active !== false))
const sessionCommitmentId = computed(() => {
const c = commitmentOptions.value.find(c => c.native_key === 'session')
return c?.id || null
})
const eventDialogOpen = ref(false)
const eventRow = ref(null)
const convertendoId = ref(null)
let _convertTarget = null
async function converterEmSessao (s) {
_convertTarget = s
convertendoId.value = s.id
try {
// 1. Busca ou cria o paciente
const pacienteId = await encontrarOuCriarPaciente(s)
// 2. Monta o eventRow com paciente já vinculado
// inicio_em como ISO local para resetForm() calcular dia e startTime corretamente
const hora = fmtHora(s.hora_solicitada) // "HH:MM"
const inicio_em = `${s.data_solicitada}T${hora}:00`
eventRow.value = {
owner_id: s.owner_id,
tipo: 'sessao',
modalidade: s.modalidade || 'presencial',
inicio_em,
patient_id: pacienteId,
paciente_id: pacienteId, // alias para o dialog pré-preencher o nome
paciente_nome: nomeCompleto(s),
_solicitacaoId: s.id,
}
eventDialogOpen.value = true
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
} finally {
convertendoId.value = null
}
}
async function encontrarOuCriarPaciente (s) {
const email = s.paciente_email?.toLowerCase().trim()
// Tenta achar paciente pelo email no tenant
if (email) {
const { data: found } = await supabase
.from('patients')
.select('id, nome_completo')
.eq('tenant_id', tenantId.value)
.ilike('email_principal', email)
.maybeSingle()
if (found?.id) return found.id
}
// Não encontrou → busca o responsible_member_id do usuário logado
const { data: memberData, error: memberErr } = await supabase
.from('tenant_members')
.select('id')
.eq('tenant_id', tenantId.value)
.eq('user_id', ownerId.value)
.eq('status', 'active')
.maybeSingle()
if (memberErr || !memberData?.id) throw new Error('Membro ativo não encontrado para criação do paciente.')
// Cria o paciente com os dados da solicitação
// Se veio pelo link da clínica → scope 'clinic'; pelo link do terapeuta → scope 'therapist'
const scope = isClinic.value ? 'clinic' : 'therapist'
const nomeCompleto_ = [s.paciente_nome, s.paciente_sobrenome].filter(Boolean).join(' ')
const { data: novo, error: criErr } = await supabase
.from('patients')
.insert({
tenant_id: tenantId.value,
responsible_member_id: memberData.id,
owner_id: ownerId.value,
nome_completo: nomeCompleto_,
email_principal: email || null,
telefone: s.paciente_celular?.replace(/\D/g, '') || null,
cpf: s.paciente_cpf?.replace(/\D/g, '') || null,
onde_nos_conheceu: s.como_conheceu || null,
observacoes: s.motivo ? `Motivo da consulta: ${s.motivo}` : null,
patient_scope: scope,
therapist_member_id: scope === 'therapist' ? memberData.id : null,
status: 'Ativo',
})
.select('id')
.single()
if (criErr) throw new Error(`Falha ao criar paciente: ${criErr.message}`)
toast.add({ severity: 'info', summary: 'Paciente criado', detail: `${nomeCompleto_} foi adicionado à sua lista de pacientes.`, life: 3000 })
return novo.id
}
function isUuid (v) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''))
}
async function onEventSaved (arg) {
eventDialogOpen.value = false
if (!_convertTarget) return
const target = _convertTarget
_convertTarget = null
convertendoId.value = target.id
try {
// 1. Normaliza o payload do dialog (mesmo padrão do AgendaTerapeutaPage)
const isWrapped = !!arg && Object.prototype.hasOwnProperty.call(arg, 'payload')
const raw = isWrapped ? arg.payload : arg
const normalized = { ...raw }
if (!normalized.owner_id) normalized.owner_id = ownerId.value
normalized.tenant_id = tenantId.value
normalized.tipo = 'sessao'
if (!normalized.status) normalized.status = 'agendado'
if (!String(normalized.titulo || '').trim()) normalized.titulo = 'Sessão'
if (!normalized.visibility_scope) normalized.visibility_scope = 'public'
if (!isUuid(normalized.paciente_id)) normalized.paciente_id = null
if (normalized.determined_commitment_id && !isUuid(normalized.determined_commitment_id)) {
normalized.determined_commitment_id = null
}
// 2. Salva o evento na agenda
const dbFields = [
'tenant_id','owner_id','terapeuta_id','patient_id','tipo','status','titulo',
'observacoes','inicio_em','fim_em','visibility_scope',
'determined_commitment_id','titulo_custom','extra_fields','modalidade',
]
const dbPayload = {}
for (const k of dbFields) { if (normalized[k] !== undefined) dbPayload[k] = normalized[k] }
await createEvento(dbPayload)
// 3. Marca solicitação como convertida
const { error } = await supabase
.from('agendador_solicitacoes')
.update({ status: 'convertido' })
.eq('id', target.id)
if (error) throw error
toast.add({
severity: 'success',
summary: 'Convertido!',
detail: `Sessão criada para ${nomeCompleto(target)}.`,
life: 4000,
})
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao converter', detail: e.message, life: 4000 })
} finally {
convertendoId.value = null
}
}
// ── agendaSettings para o dialog ────────────────────────────────
onMounted(async () => {
await loadOwnerId()
await Promise.all([loadSettings(), loadCommitments(), load()])
})
// ── Navegar para a agenda na data do agendamento ─────────────────
function irParaAgenda (s) {
const base = isClinic.value ? '/admin/agenda/clinica' : '/therapist/agenda'
router.push({ path: base, query: { date: s.data_solicitada } })
}
// ── Fechar dialog sem converter ──────────────────────────────────
function onEventDialogClose () {
eventDialogOpen.value = false
_convertTarget = null
eventRow.value = null
}
</script>
<template>
<Toast />
<!-- SENTINEL -->
<div class="ar-sentinel" />
<!-- HERO -->
<div class="ar-hero mx-3 md:mx-5 mb-4">
<!-- blobs decorativos -->
<div class="ar-blobs" aria-hidden="true">
<div class="ar-blob ar-blob--1" />
<div class="ar-blob ar-blob--2" />
<div class="ar-blob ar-blob--3" />
</div>
<!-- Linha principal -->
<div class="ar-hero__row">
<!-- Brand -->
<div class="ar-hero__brand">
<div class="ar-hero__icon">
<i class="pi pi-inbox text-lg" />
</div>
<div class="min-w-0">
<div class="ar-hero__title">
Agendamentos Recebidos
<span v-if="totalPendentes > 0" class="ar-badge-count">{{ totalPendentes }}</span>
</div>
<div class="ar-hero__sub">
{{ isClinic ? 'Toda a clínica' : 'Sua agenda online' }} · Solicitações públicas
</div>
</div>
</div>
<!-- Busca -->
<div class="ar-hero__search">
<IconField>
<InputIcon class="pi pi-search" />
<InputText
v-model="filtroBusca"
placeholder="Buscar por nome, e-mail..."
class="w-full"
autocomplete="off"
/>
</IconField>
</div>
<!-- Atualizar -->
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
class="h-9 w-9 rounded-full shrink-0"
:loading="loading"
title="Atualizar"
@click="load"
/>
</div>
<!-- Chips de filtro -->
<div class="ar-status-chips">
<button
v-for="opt in statusOpts"
:key="opt.value ?? 'all'"
class="ar-chip"
:class="{ 'ar-chip--active': filtroStatus === opt.value }"
@click="filtroStatus = opt.value"
>
<i :class="`pi ${opt.icon} text-xs`" />
{{ opt.label }}
<span v-if="opt.value === 'pendente' && totalPendentes > 0" class="ar-chip-badge">
{{ totalPendentes }}
</span>
</button>
</div>
</div>
<!-- CONTEÚDO -->
<div class="mx-3 md:mx-5">
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col gap-3">
<div v-for="n in 4" :key="n" class="ar-card ar-card--skel">
<div class="ar-skel ar-skel--avatar" />
<div class="flex flex-col gap-2 flex-1">
<div class="ar-skel ar-skel--title" />
<div class="ar-skel ar-skel--sub" />
</div>
</div>
</div>
<!-- Vazio -->
<div v-else-if="!listaFiltrada.length" class="ar-empty">
<div class="ar-empty__icon">
<i class="pi pi-inbox text-4xl" />
</div>
<div class="ar-empty__title">Nenhuma solicitação</div>
<div class="ar-empty__sub">
{{ filtroStatus ? `Não há solicitações com status "${statusLabel(filtroStatus)}".` : 'Nenhuma solicitação encontrada.' }}
</div>
</div>
<!-- Lista -->
<div v-else class="flex flex-col gap-3 pb-8">
<div
v-for="s in listaFiltrada"
:key="s.id"
class="ar-card"
:class="{ 'ar-card--expanded': expandedId === s.id, 'ar-card--expirada': isExpirada(s) }"
>
<!-- Linha principal -->
<div class="ar-card__main" @click="toggleExpand(s.id)">
<!-- Avatar inicial -->
<div class="ar-avatar">
{{ (s.paciente_nome || '?')[0].toUpperCase() }}
</div>
<!-- Dados -->
<div class="ar-card__info flex-1 min-w-0">
<div class="ar-card__name">
{{ nomeCompleto(s) }}
<Tag
:value="statusLabel(s.status)"
:severity="statusSev(s.status)"
class="ml-2 text-xs"
/>
<Tag
v-if="isExpirada(s)"
value="Reserva expirada"
severity="secondary"
class="ml-1 text-xs"
/>
</div>
<div class="ar-card__meta">
<span><i class="pi pi-calendar text-xs mr-1" />{{ fmtData(s.data_solicitada) }}</span>
<span><i class="pi pi-clock text-xs mr-1" />{{ fmtHora(s.hora_solicitada) }}</span>
<span><i class="pi pi-tag text-xs mr-1" />{{ tipoLabel[s.tipo] || s.tipo }}</span>
<span v-if="s.modalidade"><i class="pi pi-map-marker text-xs mr-1" />{{ modalLabel[s.modalidade] || s.modalidade }}</span>
</div>
</div>
<!-- Ações rápidas (pendente) -->
<div v-if="s.status === 'pendente'" class="ar-card__actions" @click.stop>
<Button
label="Aprovar"
icon="pi pi-check"
size="small"
severity="success"
class="rounded-full"
:loading="aprovando === s.id"
@click="aprovar(s)"
/>
<Button
label="Recusar"
icon="pi pi-times"
size="small"
severity="danger"
outlined
class="rounded-full"
@click="abrirRecusa(s)"
/>
<Button
label="Converter"
icon="pi pi-calendar-plus"
size="small"
severity="info"
outlined
class="rounded-full"
:loading="convertendoId === s.id"
@click="converterEmSessao(s)"
/>
</div>
<!-- Ações para autorizado (ainda pode converter) -->
<div v-else-if="s.status === 'autorizado'" class="ar-card__actions" @click.stop>
<Button
label="Converter em sessão"
icon="pi pi-calendar-plus"
size="small"
severity="info"
outlined
class="rounded-full"
:loading="convertendoId === s.id"
@click="converterEmSessao(s)"
/>
</div>
<!-- Ações para convertido: ir à agenda -->
<div v-else-if="s.status === 'convertido'" class="ar-card__actions" @click.stop>
<Button
label="Ver na agenda"
icon="pi pi-calendar"
size="small"
severity="secondary"
outlined
class="rounded-full"
@click="irParaAgenda(s)"
/>
</div>
<!-- Chevron -->
<i
class="pi ar-chevron shrink-0"
:class="expandedId === s.id ? 'pi-chevron-up' : 'pi-chevron-down'"
/>
</div>
<!-- Detalhe expandido -->
<Transition name="ar-expand">
<div v-if="expandedId === s.id" class="ar-card__detail">
<div class="ar-detail-grid">
<div class="ar-detail-item">
<span class="ar-detail-label">E-mail</span>
<span class="ar-detail-val">{{ s.paciente_email || '—' }}</span>
</div>
<div class="ar-detail-item">
<span class="ar-detail-label">Celular</span>
<span class="ar-detail-val">{{ s.paciente_celular || '—' }}</span>
</div>
<div class="ar-detail-item">
<span class="ar-detail-label">CPF</span>
<span class="ar-detail-val">{{ s.paciente_cpf || '—' }}</span>
</div>
<div class="ar-detail-item">
<span class="ar-detail-label">Solicitado em</span>
<span class="ar-detail-val">{{ s.created_at ? new Date(s.created_at).toLocaleString('pt-BR') : '—' }}</span>
</div>
<div v-if="s.motivo" class="ar-detail-item col-span-2">
<span class="ar-detail-label">Motivo</span>
<span class="ar-detail-val">{{ s.motivo }}</span>
</div>
<div v-if="s.como_conheceu" class="ar-detail-item">
<span class="ar-detail-label">Como conheceu</span>
<span class="ar-detail-val">{{ s.como_conheceu }}</span>
</div>
</div>
</div>
</Transition>
</div>
</div>
</div>
<!-- DIALOG RECUSAR -->
<Dialog
v-model:visible="recusaDialogOpen"
modal
header="Recusar solicitação"
:draggable="false"
:style="{ width: '440px', maxWidth: '96vw' }"
>
<p class="text-sm text-color-secondary mb-4">
Você pode informar o motivo da recusa. O paciente poderá visualizar isso na sua conta.
</p>
<FloatLabel variant="on">
<Textarea
id="ar-recusa-motivo"
v-model="recusaMotivo"
rows="3"
class="w-full"
autocomplete="off"
/>
<label for="ar-recusa-motivo">Motivo da recusa <span class="text-color-secondary">(opcional)</span></label>
</FloatLabel>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="recusaDialogOpen = false" />
<Button
label="Confirmar recusa"
icon="pi pi-times"
severity="danger"
class="rounded-full"
:loading="!!recusandoId"
@click="confirmarRecusa"
/>
</template>
</Dialog>
<!-- AGENDA EVENT DIALOG (converter) -->
<AgendaEventDialog
v-model="eventDialogOpen"
:event-row="eventRow"
:owner-id="ownerId"
:tenant-id="tenantId"
:agenda-settings="settings"
:commitment-options="commitmentOptions"
:preset-commitment-id="sessionCommitmentId"
:restrict-patients-to-owner="!isClinic"
:patient-scope-owner-id="!isClinic ? ownerId : null"
@save="onEventSaved"
@update:modelValue="v => { if (!v) onEventDialogClose() }"
/>
</template>
<style scoped>
/* ── Sentinel ─────────────────────────────────────────────────── */
.ar-sentinel { height: 1px; }
/* ── Hero ─────────────────────────────────────────────────────── */
.ar-hero {
position: relative;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: .875rem;
}
/* blobs */
.ar-blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.ar-blob { position: absolute; border-radius: 50%; filter: blur(65px); }
.ar-blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,.10); }
.ar-blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(52,211,153,.08); }
.ar-blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 22%; background: rgba(251,146,60,.07); }
/* Row principal */
.ar-hero__row {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
}
.ar-hero__brand { display: flex; align-items: center; gap: .75rem; flex-shrink: 0; }
.ar-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: .875rem; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.ar-hero__title {
font-size: 1.05rem; font-weight: 700;
letter-spacing: -.02em;
color: var(--text-color);
display: flex; align-items: center; gap: 6px;
}
.ar-hero__sub { font-size: .75rem; color: var(--text-color-secondary); margin-top: 2px; }
.ar-badge-count {
display: inline-flex; align-items: center; justify-content: center;
min-width: 20px; height: 20px; border-radius: 999px; padding: 0 5px;
background: var(--p-orange-500, #f97316); color: #fff;
font-size: .7rem; font-weight: 800;
}
.ar-hero__search { flex: 1; min-width: 200px; max-width: 280px; }
/* Chips de status */
.ar-status-chips {
position: relative; z-index: 1;
display: flex; flex-wrap: wrap; gap: 6px;
}
.ar-chip {
display: inline-flex; align-items: center; gap: 5px;
padding: 5px 14px; border-radius: 999px;
font-size: .78rem; font-weight: 600;
border: 1.5px solid var(--surface-border);
background: var(--surface-ground);
color: var(--text-color-secondary);
cursor: pointer; transition: all .15s;
position: relative;
}
.ar-chip:hover { border-color: var(--p-primary-400, #818cf8); color: var(--text-color); }
.ar-chip--active {
background: var(--p-primary-500, #6366f1);
border-color: var(--p-primary-500, #6366f1);
color: #fff;
}
.ar-chip-badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 18px; height: 18px; border-radius: 999px;
background: rgba(255,255,255,.3); font-size: .68rem; font-weight: 800;
padding: 0 4px;
}
.ar-chip--active .ar-chip-badge { background: rgba(255,255,255,.25); }
/* ── Cards ────────────────────────────────────────────────────── */
.ar-card {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 1.25rem;
overflow: hidden;
transition: box-shadow .15s;
}
.ar-card:hover { box-shadow: 0 4px 20px rgba(0,0,0,.08); }
.ar-card--expirada { opacity: .65; }
.ar-card--skel { padding: 1rem; display: flex; gap: 1rem; align-items: center; }
.ar-card__main {
display: flex; align-items: center; gap: .875rem;
padding: 1rem 1.25rem;
cursor: pointer;
transition: background .12s;
}
.ar-card__main:hover { background: var(--surface-hover); }
.ar-avatar {
width: 42px; height: 42px; border-radius: 50%;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 15%, transparent);
color: var(--p-primary-500, #6366f1);
display: grid; place-items: center;
font-weight: 800; font-size: 1rem;
flex-shrink: 0;
}
.ar-card__name {
font-weight: 700; font-size: .92rem;
color: var(--text-color);
display: flex; align-items: center; flex-wrap: wrap; gap: 4px;
margin-bottom: 4px;
}
.ar-card__meta {
display: flex; flex-wrap: wrap; gap: 10px;
font-size: .75rem; color: var(--text-color-secondary);
}
.ar-card__actions {
display: flex; gap: 6px; flex-wrap: wrap; flex-shrink: 0;
}
.ar-chevron { color: var(--text-color-secondary); font-size: .8rem; transition: transform .2s; }
.ar-card--expanded .ar-chevron { transform: rotate(180deg); }
/* Detalhe expandido */
.ar-card__detail {
padding: .75rem 1.25rem 1rem;
border-top: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.ar-detail-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: .75rem;
}
.ar-detail-item { display: flex; flex-direction: column; gap: 2px; }
.ar-detail-label { font-size: .7rem; font-weight: 700; color: var(--text-color-secondary); text-transform: uppercase; letter-spacing: .06em; }
.ar-detail-val { font-size: .85rem; color: var(--text-color); word-break: break-word; }
/* ── Skeletons ────────────────────────────────────────────────── */
.ar-skel {
border-radius: .5rem;
background: linear-gradient(90deg, var(--surface-border) 25%, var(--surface-hover) 50%, var(--surface-border) 75%);
background-size: 200% 100%;
animation: ar-shimmer 1.2s infinite;
}
.ar-skel--avatar { width: 42px; height: 42px; border-radius: 50%; flex-shrink: 0; }
.ar-skel--title { height: 14px; width: 60%; }
.ar-skel--sub { height: 11px; width: 40%; }
/* ── Empty ────────────────────────────────────────────────────── */
.ar-empty {
display: flex; flex-direction: column; align-items: center;
padding: 4rem 2rem; text-align: center;
}
.ar-empty__icon {
width: 72px; height: 72px; border-radius: 1.5rem;
background: var(--surface-hover); display: grid; place-items: center;
color: var(--text-color-secondary); margin-bottom: 1rem;
}
.ar-empty__title { font-weight: 700; font-size: 1rem; color: var(--text-color); margin-bottom: 4px; }
.ar-empty__sub { font-size: .85rem; color: var(--text-color-secondary); }
/* ── Expand transition ────────────────────────────────────────── */
.ar-expand-enter-active,
.ar-expand-leave-active { transition: all .22s ease; overflow: hidden; }
.ar-expand-enter-from,
.ar-expand-leave-to { opacity: 0; max-height: 0; }
.ar-expand-enter-to,
.ar-expand-leave-from { opacity: 1; max-height: 400px; }
/* ── Responsivo ───────────────────────────────────────────────── */
@media (max-width: 640px) {
.ar-card__actions { display: none; }
.ar-card--expanded .ar-card__actions { display: flex; padding: .75rem 1.25rem; border-top: 1px solid var(--surface-border); }
}
/* ── Animations ───────────────────────────────────────────────── */
@keyframes ar-shimmer { to { background-position: -200% 0; } }
</style>
@@ -0,0 +1,288 @@
/**
* agendaMappers.spec.js
*
* Testa as funções de mapeamento de dados da agenda:
* - mapAgendaEventosToCalendarEvents
* - mapAgendaEventosToClinicResourceEvents
* - buildNextSessions
* - minutesToDuration
* - tituloFallback
* - calcDefaultSlotDuration
* - buildWeeklyBreakBackgroundEvents
*/
import { describe, it, expect } from 'vitest'
import {
mapAgendaEventosToCalendarEvents,
mapAgendaEventosToClinicResourceEvents,
buildNextSessions,
minutesToDuration,
tituloFallback,
calcDefaultSlotDuration,
buildWeeklyBreakBackgroundEvents,
} from '../agendaMappers.js'
// ─── fixtures ─────────────────────────────────────────────────────────────────
function evento (overrides = {}) {
return {
id: 'ev-1',
titulo: 'Sessão Teste',
tipo: 'sessao',
status: 'agendado',
inicio_em: '2026-03-10T09:00:00',
fim_em: '2026-03-10T10:00:00',
owner_id: 'owner-1',
tenant_id: 'tenant-1',
patient_id: 'patient-1',
modalidade: 'presencial',
...overrides,
}
}
// ─── mapAgendaEventosToCalendarEvents ─────────────────────────────────────────
describe('mapAgendaEventosToCalendarEvents', () => {
it('mapeia um evento simples para o shape do FullCalendar', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento()])
expect(ev.id).toBe('ev-1')
expect(ev.start).toBe('2026-03-10T09:00:00')
expect(ev.end).toBe('2026-03-10T10:00:00')
expect(ev.extendedProps.tipo).toBe('sessao')
expect(ev.extendedProps.status).toBe('agendado')
})
it('filtra rows null/undefined', () => {
const result = mapAgendaEventosToCalendarEvents([null, undefined, evento()])
expect(result.length).toBe(1)
})
it('retorna array vazio para input vazio', () => {
expect(mapAgendaEventosToCalendarEvents([])).toEqual([])
expect(mapAgendaEventosToCalendarEvents(null)).toEqual([])
})
it('inclui ícone ✓ no título para status realizado', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'realizado' })])
expect(ev.title).toContain('✓')
})
it('inclui ícone ✗ no título para status faltou', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'faltou' })])
expect(ev.title).toContain('✗')
})
it('inclui ícone ∅ no título para status cancelado', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'cancelado' })])
expect(ev.title).toContain('∅')
})
it('inclui ícone ↺ no título para status remarcado', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'remarcado' })])
expect(ev.title).toContain('↺')
})
it('inclui ícone ↻ para ocorrências de série', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ recurrence_id: 'rule-1', is_occurrence: true })])
expect(ev.title).toContain('↻')
})
it('aplica cor de fundo para status faltou', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'faltou' })])
expect(ev.backgroundColor).toBe('#ef4444')
})
it('aplica cor de fundo para status cancelado', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'cancelado' })])
expect(ev.backgroundColor).toBe('#f97316')
})
it('aplica cor de fundo para status remarcado', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'remarcado' })])
expect(ev.backgroundColor).toBe('#a855f7')
})
it('usa titulo_custom quando disponível', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ titulo_custom: 'Personalizado' })])
expect(ev.title).toContain('Personalizado')
})
it('usa nome do paciente via patients join quando titulo ausente', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({
titulo: null,
titulo_custom: null,
patients: { nome_completo: 'João Silva', avatar_url: null }
})])
expect(ev.title).toContain('João Silva')
})
it('mapeia patient_id corretamente', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ patient_id: 'p-123' })])
expect(ev.extendedProps.patient_id).toBe('p-123')
expect(ev.extendedProps.paciente_id).toBe('p-123') // alias
})
it('mapeia recurrence_id e original_date', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({
recurrence_id: 'rule-abc',
original_date: '2026-03-10',
})])
expect(ev.extendedProps.recurrence_id).toBe('rule-abc')
expect(ev.extendedProps.original_date).toBe('2026-03-10')
})
it('mapeia exception_type', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({
exception_type: 'patient_missed',
status: 'faltou',
})])
expect(ev.extendedProps.exception_type).toBe('patient_missed')
expect(ev.extendedProps.is_exception).toBe(true)
})
})
// ─── mapAgendaEventosToClinicResourceEvents ───────────────────────────────────
describe('mapAgendaEventosToClinicResourceEvents', () => {
it('adiciona resourceId baseado em owner_id', () => {
const [ev] = mapAgendaEventosToClinicResourceEvents([evento({ owner_id: 'owner-99' })])
expect(ev.resourceId).toBe('owner-99')
})
it('usa terapeuta_id como fallback para resourceId', () => {
const [ev] = mapAgendaEventosToClinicResourceEvents([evento({ owner_id: null, terapeuta_id: 'tera-1' })])
expect(ev.resourceId).toBe('tera-1')
})
})
// ─── buildNextSessions ────────────────────────────────────────────────────────
describe('buildNextSessions', () => {
it('filtra sessões no passado', () => {
const now = new Date('2026-03-10T12:00:00')
const rows = [
evento({ id: 'past', fim_em: '2026-03-09T10:00:00' }),
evento({ id: 'future', fim_em: '2026-03-11T10:00:00' }),
]
const result = buildNextSessions(rows, now)
expect(result.length).toBe(1)
expect(result[0].id).toBe('future')
})
it('inclui sessão cujo fim_em é agora (mesmo ms)', () => {
const now = new Date('2026-03-10T10:00:00')
const rows = [evento({ fim_em: '2026-03-10T10:00:00' })]
const result = buildNextSessions(rows, now)
expect(result.length).toBe(1)
})
it('limita a 6 sessões', () => {
const now = new Date('2026-01-01')
const rows = Array.from({ length: 10 }, (_, i) => evento({
id: `ev-${i}`,
fim_em: `2026-03-${String(i + 10).padStart(2,'0')}T10:00:00`,
}))
const result = buildNextSessions(rows, now)
expect(result.length).toBe(6)
})
it('retorna shape correto', () => {
const now = new Date('2026-01-01')
const [s] = buildNextSessions([evento()], now)
expect(s).toMatchObject({
id: 'ev-1',
title: 'Sessão Teste',
startISO: '2026-03-10T09:00:00',
endISO: '2026-03-10T10:00:00',
tipo: 'sessao',
status: 'agendado',
})
})
it('mapeia pacienteId de patient_id', () => {
const now = new Date('2026-01-01')
const [s] = buildNextSessions([evento({ patient_id: 'p-999' })], now)
expect(s.pacienteId).toBe('p-999')
})
})
// ─── minutesToDuration ────────────────────────────────────────────────────────
describe('minutesToDuration', () => {
it('30 minutos → 00:30:00', () => expect(minutesToDuration(30)).toBe('00:30:00'))
it('60 minutos → 01:00:00', () => expect(minutesToDuration(60)).toBe('01:00:00'))
it('90 minutos → 01:30:00', () => expect(minutesToDuration(90)).toBe('01:30:00'))
it('0 minutos → 00:00:00', () => expect(minutesToDuration(0)).toBe('00:00:00'))
})
// ─── tituloFallback ───────────────────────────────────────────────────────────
describe('tituloFallback', () => {
it('sessao → Sessão', () => expect(tituloFallback('sessao')).toBe('Sessão'))
it('bloqueio → Bloqueio', () => expect(tituloFallback('bloqueio')).toBe('Bloqueio'))
it('pessoal → Pessoal', () => expect(tituloFallback('pessoal')).toBe('Pessoal'))
it('clinica → Clínica', () => expect(tituloFallback('clinica')).toBe('Clínica'))
it('desconhecido → Compromisso', () => expect(tituloFallback('outro')).toBe('Compromisso'))
it('null → Compromisso', () => expect(tituloFallback(null)).toBe('Compromisso'))
})
// ─── calcDefaultSlotDuration ──────────────────────────────────────────────────
describe('calcDefaultSlotDuration', () => {
it('usa granularidade custom quando ativa', () => {
const s = { usar_granularidade_custom: true, granularidade_min: 15 }
expect(calcDefaultSlotDuration(s)).toBe('00:15:00')
})
it('usa admin_slot_visual_minutos como fallback', () => {
const s = { admin_slot_visual_minutos: 20 }
expect(calcDefaultSlotDuration(s)).toBe('00:20:00')
})
it('usa 30 min como padrão quando nenhuma configuração', () => {
expect(calcDefaultSlotDuration({})).toBe('00:30:00')
expect(calcDefaultSlotDuration(null)).toBe('00:30:00')
})
})
// ─── buildWeeklyBreakBackgroundEvents ────────────────────────────────────────
describe('buildWeeklyBreakBackgroundEvents', () => {
it('retorna vazio para input vazio', () => {
const result = buildWeeklyBreakBackgroundEvents([], new Date('2026-03-01'), new Date('2026-03-08'))
expect(result).toEqual([])
})
it('gera eventos de background para pausa no dia correto', () => {
const pausas = [{ weekday: 1, start: '12:00', end: '13:00', label: 'Almoço' }] // segunda
const result = buildWeeklyBreakBackgroundEvents(
pausas,
new Date(2026, 2, 1), // dom
new Date(2026, 2, 8), // dom
)
expect(result.length).toBe(1)
expect(result[0].display).toBe('background')
expect(result[0].start).toContain('2026-03-02') // segunda
expect(result[0].extendedProps.label).toBe('Almoço')
})
it('gera uma pausa por semana quando range cobre 2 semanas', () => {
const pausas = [{ weekday: 1, start: '12:00', end: '13:00' }] // toda segunda
const result = buildWeeklyBreakBackgroundEvents(
pausas,
new Date(2026, 2, 1), // dom 01/03
new Date(2026, 2, 15), // dom 15/03
)
expect(result.length).toBe(2) // seg 02 e seg 09
})
it('não gera para dias diferentes', () => {
const pausas = [{ weekday: 5, start: '12:00', end: '13:00' }] // sexta
const result = buildWeeklyBreakBackgroundEvents(
pausas,
new Date(2026, 2, 2), // seg
new Date(2026, 2, 5), // qui
)
expect(result.length).toBe(0)
})
})
@@ -30,7 +30,7 @@ export async function listClinicEvents ({ tenantId, ownerIds, startISO, endISO }
const { data, error } = await supabase
.from('agenda_eventos')
.select('*')
.select('*, patients!agenda_eventos_patient_id_fkey(id, nome_completo, avatar_url), determined_commitments!agenda_eventos_determined_commitment_fk(id, bg_color, text_color)')
.eq('tenant_id', tenantId)
.in('owner_id', safeOwnerIds)
.gte('inicio_em', startISO)
+198 -128
View File
@@ -1,124 +1,189 @@
// src/features/agenda/services/agendaMappers.js
//
// Suporta dois tipos de linha:
// 1. Evento real (agenda_eventos do banco) — is_occurrence = false/undefined
// 2. Ocorrência virtual (gerada por useRecurrence) — is_occurrence = true
//
// Em ambos os casos o shape de saída para o FullCalendar é idêntico.
// ─────────────────────────────────────────────────────────────────────────────
// mapAgendaEventosToCalendarEvents
// ─────────────────────────────────────────────────────────────────────────────
export function mapAgendaEventosToCalendarEvents (rows) {
return (rows || []).map((r) => {
// 🔥 regra importante:
// prioridade: owner_id
// fallback: terapeuta_id
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
const commitment = r.determined_commitments
const bgColor = commitment?.bg_color ? `#${commitment.bg_color}` : undefined
const txtColor = commitment?.text_color || undefined
return {
id: r.id,
title: r.titulo || tituloFallback(r.tipo),
start: r.inicio_em,
end: r.fim_em,
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
...(txtColor && { textColor: txtColor }),
extendedProps: {
// 🔥 ESSENCIAL PARA O MOSAICO
owner_id: ownerId,
tipo: r.tipo ?? null,
status: r.status ?? null,
paciente_id: r.paciente_id ?? null,
paciente_nome: r.patients?.nome_completo ?? null,
paciente_avatar: r.patients?.avatar_url ?? null,
terapeuta_id: r.terapeuta_id ?? null,
observacoes: r.observacoes ?? null,
// ✅ usados na clínica p/ mascarar/privacidade
visibility_scope: r.visibility_scope ?? null,
masked: !!r.masked,
// ✅ compromisso determinístico
determined_commitment_id: r.determined_commitment_id ?? null,
commitment_bg_color: bgColor ?? null,
commitment_text_color: txtColor ?? null,
// ✅ campos customizados
titulo_custom: r.titulo_custom ?? null,
extra_fields: r.extra_fields ?? null
}
}
})
return (rows || []).map(_mapRow).filter(Boolean)
}
// ─────────────────────────────────────────────────────────────────────────────
// mapAgendaEventosToClinicResourceEvents
// ─────────────────────────────────────────────────────────────────────────────
export function mapAgendaEventosToClinicResourceEvents (rows) {
return (rows || []).map((r) => {
const ev = _mapRow(r)
if (!ev) return null
ev.resourceId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
return ev
}).filter(Boolean)
}
// ─────────────────────────────────────────────────────────────────────────────
// mapper interno
// ─────────────────────────────────────────────────────────────────────────────
function _mapRow (r) {
if (!r) return null
const isOccurrence = !!r.is_occurrence
const isRealSession = !isOccurrence
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
// commitment / cores
const commitment = r.determined_commitments ?? r.commitment ?? null
const baseBg = commitment?.bg_color ? `#${commitment.bg_color}` : null
const baseTxt = commitment?.text_color ? `#${commitment.text_color}` : null
const statusBg = _statusBgColor(r.status)
const bgColor = statusBg ?? baseBg ?? undefined
const txtColor = baseTxt ?? (statusBg ? '#ffffff' : undefined)
// título
const nomeP = r.patients?.nome_completo ?? r.paciente_nome ?? r.patient_name ?? ''
const titleBase = r.titulo_custom || r.titulo || (nomeP ? nomeP : tituloFallback(r.tipo))
const icon = _statusIcon(r.status, isOccurrence, !!r.recurrence_id)
const title = `${icon}${titleBase}`
// recorrência — nova + fallback legada
const recurrenceId = r.recurrence_id ?? null
const originalDate = r.original_date ?? r.recurrence_date ?? null
const exceptionType = r.exception_type ?? null
return {
id: r.id ?? `occ::${recurrenceId}::${originalDate}`,
title,
start: r.inicio_em,
end: r.fim_em,
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
...(txtColor && { textColor: txtColor }),
extendedProps: {
// identidade
dbId: r.id ?? null,
isOccurrence,
isRealSession,
// owner
owner_id: ownerId,
terapeuta_id: normalizeId(r?.terapeuta_id ?? null),
// compromisso
tipo: r.tipo ?? null,
status: r.status ?? null,
determined_commitment_id: r.determined_commitment_id ?? null,
commitment_bg_color: bgColor ?? null,
commitment_text_color: txtColor ?? null,
// paciente
patient_id: r.patient_id ?? null,
paciente_id: r.patient_id ?? null, // alias para compatibilidade com dialog/form
paciente_nome: nomeP,
paciente_avatar: r.patients?.avatar_url ?? r.paciente_avatar ?? null,
// campos
observacoes: r.observacoes ?? null,
titulo_custom: r.titulo_custom ?? null,
extra_fields: r.extra_fields ?? null,
modalidade: r.modalidade ?? null,
// privacidade (clínica)
visibility_scope: r.visibility_scope ?? null,
masked: !!r.masked,
// recorrência — NOVA arquitetura
recurrence_id: recurrenceId,
original_date: originalDate,
exception_type: exceptionType,
exception_id: r.exception_id ?? null,
exception_reason: r.exception_reason ?? null,
// recorrência — fallback LEGADA (não quebra enquanto migra)
serie_id: r.serie_id ?? recurrenceId ?? null,
serie_dia_semana: r.agenda_series?.dia_semana ?? r.serie_dia_semana ?? null,
serie_hora: r.agenda_series?.hora_inicio ?? r.serie_hora ?? null,
serie_duracao: r.agenda_series?.duracao_min ?? r.serie_duracao ?? null,
serie_status: r.agenda_series?.status ?? r.serie_status ?? null,
is_exception: r.is_exception ?? (exceptionType != null),
// financeiro
price: r.price ?? null,
// timestamps
inicio_em: r.inicio_em,
fim_em: r.fim_em,
tenant_id: r.tenant_id ?? null,
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// buildNextSessions
// ─────────────────────────────────────────────────────────────────────────────
export function buildNextSessions (rows, now = new Date()) {
const nowMs = now.getTime()
return (rows || [])
.filter((r) => new Date(r.fim_em).getTime() >= nowMs)
.filter(r => new Date(r.fim_em).getTime() >= nowMs)
.slice(0, 6)
.map((r) => ({
id: r.id,
title: r.titulo || tituloFallback(r.tipo),
.map(r => ({
id: r.id,
title: r.titulo || tituloFallback(r.tipo),
startISO: r.inicio_em,
endISO: r.fim_em,
tipo: r.tipo,
status: r.status,
pacienteId: r.paciente_id || null
endISO: r.fim_em,
tipo: r.tipo,
status: r.status,
pacienteId: r.patient_id || null
}))
}
// ─────────────────────────────────────────────────────────────────────────────
// calcDefaultSlotDuration
// ─────────────────────────────────────────────────────────────────────────────
export function calcDefaultSlotDuration (settings) {
const min =
((settings?.usar_granularidade_custom && settings?.granularidade_min) || 0) ||
settings?.admin_slot_visual_minutos ||
30
return minutesToDuration(min)
}
export function minutesToDuration (min) {
const h = Math.floor(min / 60)
const m = min % 60
const hh = String(h).padStart(2, '0')
const mm = String(m).padStart(2, '0')
return `${hh}:${mm}:00`
}
// ─────────────────────────────────────────────────────────────────────────────
// buildWeeklyBreakBackgroundEvents — código original preservado integralmente
// ─────────────────────────────────────────────────────────────────────────────
export function tituloFallback (tipo) {
const t = String(tipo || '').toLowerCase()
if (t.includes('sess')) return 'Sessão'
if (t.includes('block') || t.includes('bloq')) return 'Bloqueio'
if (t.includes('pessoal')) return 'Pessoal'
if (t.includes('clin')) return 'Clínica'
return 'Compromisso'
}
/**
* Pausas semanais (jsonb) -> background events do FullCalendar.
* Leitura flexível:
* - esperado: [{ weekday: 1..7 ou 0..6, start:"HH:MM", end:"HH:MM", label }]
*/
export function buildWeeklyBreakBackgroundEvents (pausas, rangeStart, rangeEnd) {
if (!Array.isArray(pausas) || pausas.length === 0) return []
const out = []
const out = []
const dayMs = 24 * 60 * 60 * 1000
for (let ts = startOfDay(rangeStart).getTime(); ts < rangeEnd.getTime(); ts += dayMs) {
const d = new Date(ts)
const dow = d.getDay() // 0..6
const d = new Date(ts)
const dow = d.getDay()
for (const p of pausas) {
const wd = normalizeWeekday(p?.weekday)
if (wd === null) continue
if (wd !== dow) continue
const wd = normalizeWeekday(p?.weekday ?? p?.dia_semana)
if (wd === null || wd !== dow) continue
const start = asTime(p?.start ?? p?.inicio ?? p?.from)
const end = asTime(p?.end ?? p?.fim ?? p?.to)
const end = asTime(p?.end ?? p?.fim ?? p?.to)
if (!start || !end) continue
out.push({
id: `break-${ts}-${start}-${end}`,
start: combineDateTimeISO(d, start),
end: combineDateTimeISO(d, end),
id: `break-${ts}-${start}-${end}`,
start: combineDateTimeISO(d, start),
end: combineDateTimeISO(d, end),
display: 'background',
overlap: false,
extendedProps: { kind: 'break', label: p?.label ?? 'Pausa' }
@@ -129,48 +194,53 @@ export function buildWeeklyBreakBackgroundEvents (pausas, rangeStart, rangeEnd)
return out
}
export function mapAgendaEventosToClinicResourceEvents (rows) {
return (rows || []).map((r) => {
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
// ─────────────────────────────────────────────────────────────────────────────
// minutesToDuration / tituloFallback
// ─────────────────────────────────────────────────────────────────────────────
const commitment = r.determined_commitments
const bgColor = commitment?.bg_color ? `#${commitment.bg_color}` : undefined
const txtColor = commitment?.text_color || undefined
return {
id: r.id,
title: r.titulo || tituloFallback(r.tipo),
start: r.inicio_em,
end: r.fim_em,
// 🔥 resourceId também precisa ser confiável
resourceId: ownerId,
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
...(txtColor && { textColor: txtColor }),
extendedProps: {
owner_id: ownerId,
tipo: r.tipo ?? null,
status: r.status ?? null,
paciente_id: r.paciente_id ?? null,
terapeuta_id: r.terapeuta_id ?? null,
observacoes: r.observacoes ?? null,
visibility_scope: r.visibility_scope ?? null,
masked: !!r.masked,
determined_commitment_id: r.determined_commitment_id ?? null,
commitment_bg_color: bgColor ?? null,
commitment_text_color: txtColor ?? null
}
}
})
export function minutesToDuration (min) {
const h = Math.floor(min / 60)
const m = min % 60
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:00`
}
// -------------------- helpers --------------------
export function tituloFallback (tipo) {
const t = String(tipo || '').toLowerCase()
if (t.includes('sess')) return 'Sessão'
if (t.includes('block') || t.includes('bloq')) return 'Bloqueio'
if (t.includes('pessoal')) return 'Pessoal'
if (t.includes('clin')) return 'Clínica'
return 'Compromisso'
}
// ─────────────────────────────────────────────────────────────────────────────
// helpers de status
// ─────────────────────────────────────────────────────────────────────────────
function _statusBgColor (status) {
const map = {
realizado: '#6b7280',
faltou: '#ef4444',
cancelado: '#f97316',
bloqueado: '#6b7280',
remarcado: '#a855f7',
}
return map[status] ?? null
}
function _statusIcon (status, isOccurrence, hasSerie) {
if (status === 'realizado') return '✓ '
if (status === 'faltou') return '✗ '
if (status === 'cancelado') return '∅ '
if (status === 'bloqueado') return '⊘ '
if (status === 'remarcado') return '↺ '
if (hasSerie || isOccurrence) return '↻ '
return ''
}
// ─────────────────────────────────────────────────────────────────────────────
// helpers internos — originais preservados
// ─────────────────────────────────────────────────────────────────────────────
function normalizeId (v) {
if (v === null || v === undefined) return null
@@ -190,7 +260,7 @@ function normalizeWeekday (value) {
function asTime (v) {
if (!v || typeof v !== 'string') return null
const s = v.trim()
if (/^\d{2}:\d{2}$/.test(s)) return `${s}:00`
if (/^\d{2}:\d{2}$/.test(s)) return `${s}:00`
if (/^\d{2}:\d{2}:\d{2}$/.test(s)) return s
return null
}
@@ -203,7 +273,7 @@ function startOfDay (d) {
function combineDateTimeISO (date, timeHHMMSS) {
const yyyy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}T${timeHHMMSS}`
}
@@ -36,6 +36,24 @@ export async function getMyAgendaSettings () {
return data
}
/**
* Regras semanais de jornada (agenda_regras_semanais):
* retorna os dias ativos com hora_inicio/hora_fim por dia.
*/
export async function getMyWorkSchedule () {
const uid = await getUid()
const { data, error } = await supabase
.from('agenda_regras_semanais')
.select('dia_semana, hora_inicio, hora_fim, ativo')
.eq('owner_id', uid)
.eq('ativo', true)
.order('dia_semana')
if (error) throw error
return data || []
}
/**
* Lista agenda do terapeuta (somente do owner logado) dentro do tenant ativo.
* Isso impede misturar eventos caso o terapeuta atue em múltiplas clínicas.
@@ -59,27 +77,7 @@ export async function listMyAgendaEvents ({ startISO, endISO, tenantId: tenantId
.order('inicio_em', { ascending: true })
if (error) throw error
const rows = data || []
// Eventos antigos têm paciente_id mas patient_id=null (sem FK) → join retorna null.
// Fazemos um segundo fetch para esses casos e mesclamos.
const orphanIds = [...new Set(
rows.filter(r => r.paciente_id && !r.patients).map(r => r.paciente_id)
)]
if (orphanIds.length) {
const { data: pts } = await supabase
.from('patients')
.select('id, nome_completo, avatar_url')
.in('id', orphanIds)
if (pts?.length) {
const map = Object.fromEntries(pts.map(p => [p.id, p]))
for (const r of rows) {
if (r.paciente_id && !r.patients) r.patients = map[r.paciente_id] || null
}
}
}
return rows
return data || []
}
/**