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
@@ -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,
}
}