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