Agenda, Agendador, Configurações
This commit is contained in:
@@ -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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"')
|
||||
|
||||
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 já 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 / já bloqueado -->
|
||||
<template v-if="isDiaUtil(f.data)">
|
||||
<!-- Já bloqueado -->
|
||||
<span
|
||||
v-if="jaFoiBloqueado(f.data)"
|
||||
v-tooltip.top="'Dia já 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" />
|
||||
Já 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)
|
||||
|
||||
@@ -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 || []
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user