Ajuste em Massa - Paciente, Terapeuta, Clinica e Admin - Inicio agenda

This commit is contained in:
Leonardo
2026-02-22 17:56:01 -03:00
parent 6eff67bf22
commit 89b4ecaba1
77 changed files with 9433 additions and 1995 deletions
@@ -0,0 +1,449 @@
<!-- src/features/agenda/pages/AgendaTerapeutaPage.vue -->
<script setup>
import { computed, onMounted, ref } from 'vue'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import AgendaEventDialog from '../components/AgendaEventDialog.vue'
import AgendaToolbar from '../components/AgendaToolbar.vue'
import AgendaCalendar from '../components/AgendaCalendar.vue'
import AgendaRightPanel from '../components/AgendaRightPanel.vue'
import AgendaNextSessionsCardList from '../components/cards/AgendaNextSessionsCardList.vue'
import AgendaPulseCardGrid from '../components/cards/AgendaPulseCardGrid.vue'
import { useAgendaSettings } from '../composables/useAgendaSettings'
import { useAgendaEvents } from '../composables/useAgendaEvents'
import {
mapAgendaEventosToCalendarEvents,
buildNextSessions,
buildWeeklyBreakBackgroundEvents,
calcDefaultSlotDuration,
minutesToDuration
} from '../services/agendaMappers'
const toast = useToast()
// -----------------------------
// State
// -----------------------------
const view = ref('day') // 'day' | 'week'
const mode = ref('work_hours') // 'full_24h' | 'work_hours'
const searchQuery = ref('')
const calendarRef = ref(null)
const { loading: loadingSettings, error: settingsError, settings, load: loadSettings } = useAgendaSettings()
const { loading: loadingEvents, error: eventsError, rows, loadMyRange, create, update, remove } = useAgendaEvents()
const dialogOpen = ref(false)
const dialogEventRow = ref(null)
const dialogStartISO = ref('')
const dialogEndISO = ref('')
const currentRange = ref({ start: null, end: null })
// Range atual (FullCalendar)
const currentRange = ref({ start: new Date(), end: new Date() })
// -----------------------------
// Derived: settings -> calendar behavior
// -----------------------------
const timezone = computed(() => settings.value?.timezone || 'America/Sao_Paulo')
const slotDuration = computed(() => {
if (!settings.value) return '00:30:00'
return calcDefaultSlotDuration(settings.value)
})
// work hours recorte (visual)
const slotMinTime = computed(() => {
if (!settings.value) return '06:00:00'
// Se estiver no modo "work_hours", você quer mostrar um pouco antes
// Aqui respeitamos admin_inicio_visualizacao se usar_horario_admin_custom estiver true,
// senão tentamos agenda_custom_start, senão default.
const s = settings.value
const base =
(s.usar_horario_admin_custom && s.admin_inicio_visualizacao) ||
s.agenda_custom_start ||
'06:00:00'
// padding -1h
return padTime(base, -60)
})
const slotMaxTime = computed(() => {
if (!settings.value) return '22:00:00'
const s = settings.value
const base =
(s.usar_horario_admin_custom && s.admin_fim_visualizacao) ||
s.agenda_custom_end ||
'22:00:00'
// padding +1h
return padTime(base, +60)
})
// business hours “verdadeiro” (sem padding)
const businessHours = computed(() => {
if (!settings.value) return []
const s = settings.value
const start =
(s.usar_horario_admin_custom && s.admin_inicio_visualizacao) ||
s.agenda_custom_start ||
'08:00:00'
const end =
(s.usar_horario_admin_custom && s.admin_fim_visualizacao) ||
s.agenda_custom_end ||
'18:00:00'
// Semana inteira (você pode trocar isso pra algo vindo de agenda_regras_semanais depois)
return [
{ daysOfWeek: [1,2,3,4,5], startTime: start, endTime: end }
]
})
// Eventos do banco -> FullCalendar
const calendarEvents = computed(() => {
const base = mapAgendaEventosToCalendarEvents(rows.value || [])
// Pausas semanais (jsonb) -> background events
const breaks = settings.value
? buildWeeklyBreakBackgroundEvents(
settings.value.pausas_semanais,
currentRange.value.start,
currentRange.value.end
)
: []
return [...base, ...breaks]
})
// Cards de próximas sessões
const nextSessions = computed(() => buildNextSessions(rows.value || []))
// Pulse stats (bem inicial, mas já útil)
const pulseStats = computed(() => {
const list = rows.value || []
const totalSessions = list.filter(r => (r.tipo || '').toLowerCase().includes('sess')).length
const totalMinutes = list.reduce((acc, r) => {
const ms = new Date(r.fim_em).getTime() - new Date(r.inicio_em).getTime()
return acc + Math.max(0, Math.round(ms / 60000))
}, 0)
const pending = list.filter(r => (r.status || '').toLowerCase().includes('pend')).length
const reschedules = list.filter(r => (r.status || '').toLowerCase().includes('remarc')).length
const attentions = pending + reschedules
// Sugerir encaixes (placeholder): depois vamos calcular via gaps no range.
const suggested1 = '—'
const suggested2 = '—'
const nextBreak = '—' // depois calculamos pela pausa semanal + "agora"
return {
totalSessions,
totalMinutes,
biggestFreeWindow: '—',
pending,
reschedules,
attentions,
suggested1,
suggested2,
nextBreak
}
})
// -----------------------------
// Lifecycle
// -----------------------------
onMounted(async () => {
await loadSettings()
if (settingsError.value) {
toast.add({ severity: 'warn', summary: 'Agenda', detail: settingsError.value, life: 4500 })
}
// aplica modo inicial vindo da config
if (settings.value?.agenda_view_mode) {
mode.value = settings.value.agenda_view_mode === 'full_24h' ? 'full_24h' : 'work_hours'
}
})
// -----------------------------
// Actions: toolbar
// -----------------------------
function onToday() { calendarRef.value?.goToday?.() }
function onPrev() { calendarRef.value?.prev?.() }
function onNext() { calendarRef.value?.next?.() }
function onChangeView(v) {
view.value = v
calendarRef.value?.setView?.(v)
}
function onToggleMode(m) {
mode.value = m
}
function onSearch(q) {
searchQuery.value = q || ''
// Por enquanto a busca não filtra o FullCalendar (isso exige requery ou filtro local).
// Vamos plugar isso quando tiver patient join e título mais rico.
}
function onCreateSession() {
toast.add({ severity: 'info', summary: 'Nova sessão', detail: 'Abrir modal de criação (próximo passo).', life: 2500 })
}
function onCreateBlock() {
toast.add({ severity: 'info', summary: 'Bloquear horário', detail: 'Abrir modal de bloqueio (próximo passo).', life: 2500 })
}
const staffCols = computed(() => (staff.value || [])
.filter(s => typeof s.user_id === 'string' && s.user_id && s.user_id !== 'null' && s.user_id !== 'undefined')
.map(s => ({
id: s.user_id,
title: s.full_name || s.nome || s.name || s.email || 'Profissional'
}))
)
const ownerIds = computed(() => staffCols.value.map(s => s.id))
const allEvents = computed(() => mapAgendaEventosToCalendarEvents(rows.value || []))
// -----------------------------
// FullCalendar callbacks
// -----------------------------
async function onRangeChange ({ start, end }) {
currentRange.value = { start, end }
const ids = ownerIds.value
if (!ids.length) return
await loadClinicRange(ids, new Date(start).toISOString(), new Date(end).toISOString())
if (eventsError.value) {
toast.add({ severity: 'warn', summary: 'Eventos', detail: eventsError.value, life: 4500 })
}
}
function onSelectTime (selection) {
const durMin = settings.value?.session_duration_min ?? settings.value?.duracao_padrao_minutos ?? 50
const startISO = new Date(selection.start).toISOString()
const endISO = new Date(new Date(selection.start).getTime() + durMin * 60000).toISOString()
dialogEventRow.value = null
dialogStartISO.value = startISO
dialogEndISO.value = endISO
dialogOpen.value = true
}
function onEventClick (info) {
const ev = info?.event
if (!ev) return
dialogEventRow.value = {
id: ev.id,
owner_id: ev.extendedProps?.owner_id,
terapeuta_id: ev.extendedProps?.terapeuta_id ?? null,
paciente_id: ev.extendedProps?.paciente_id ?? null,
tipo: ev.extendedProps?.tipo,
status: ev.extendedProps?.status,
titulo: ev.title,
observacoes: ev.extendedProps?.observacoes ?? null,
inicio_em: ev.start?.toISOString?.() || ev.startStr,
fim_em: ev.end?.toISOString?.() || ev.endStr
}
dialogStartISO.value = ''
dialogEndISO.value = ''
dialogOpen.value = true
}
async function persistMoveOrResize (info, actionLabel) {
try {
const ev = info?.event
if (!ev) return
const id = ev.id
const startISO = ev.start ? ev.start.toISOString() : null
const endISO = ev.end ? ev.end.toISOString() : null
if (!startISO || !endISO) throw new Error('Evento sem start/end.')
await update(id, { inicio_em: startISO, fim_em: endISO })
toast.add({
severity: 'success',
summary: actionLabel,
detail: 'Alteração salva.',
life: 1800
})
} catch (e) {
// desfaz no calendário
info?.revert?.()
toast.add({
severity: 'warn',
summary: 'Erro',
detail: eventsError.value || e?.message || 'Falha ao salvar alteração.',
life: 4500
})
}
}
function onEventDrop (info) {
persistMoveOrResize(info, 'Movido')
}
function onEventResize (info) {
persistMoveOrResize(info, 'Redimensionado')
}
function onOpenFromCard (it) {
toast.add({
severity: 'info',
summary: 'Evento',
detail: it?.title || 'Evento',
life: 2500
})
}
function onConfirmFromCard () {
toast.add({
severity: 'success',
summary: 'Confirmar',
detail: 'Ação de confirmar (próximo passo: update no banco).',
life: 2500
})
}
function onRescheduleFromCard () {
toast.add({
severity: 'info',
summary: 'Remarcar',
detail: 'Ação de remarcar (próximo passo: fluxo de reagendamento).',
life: 2500
})
}
// -----------------------------
// Utils
// -----------------------------
function padTime(hhmmss, deltaMin) {
// hh:mm:ss
const [hh, mm, ss] = String(hhmmss || '00:00:00').split(':').map(Number)
let total = (hh * 60 + mm) + deltaMin
if (total < 0) total = 0
if (total > 24 * 60) total = 24 * 60
return minutesToDuration(total)
}
async function onDialogSave ({ id, payload }) {
try {
if (id) {
await update(id, payload)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Evento atualizado.', life: 2500 })
} else {
await create(payload)
toast.add({ severity: 'success', summary: 'Criado', detail: 'Evento criado.', life: 2500 })
}
dialogOpen.value = false
// recarrega o range atual
await loadMyRange(
new Date(currentRange.value.start).toISOString(),
new Date(currentRange.value.end).toISOString()
)
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || 'Falha ao salvar.', life: 4500 })
}
}
async function onDialogDelete (id) {
try {
await remove(id)
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Evento removido.', life: 2500 })
dialogOpen.value = false
await loadMyRange(
new Date(currentRange.value.start).toISOString(),
new Date(currentRange.value.end).toISOString()
)
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || 'Falha ao excluir.', life: 4500 })
}
}
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<AgendaToolbar
title="Minha agenda"
:view="view"
:mode="mode"
@today="onToday"
@prev="onPrev"
@next="onNext"
@changeView="onChangeView"
@toggleMode="onToggleMode"
@createSession="onCreateSession"
@createBlock="onCreateBlock"
@search="onSearch"
/>
<div class="grid gap-3 md:gap-4" style="grid-template-columns: 1fr; align-items: stretch;">
<div class="grid gap-3 md:gap-4 md:grid-cols-[1fr_380px]">
<!-- LEFT: Calendar -->
<AgendaCalendar
ref="calendarRef"
:view="view"
:mode="mode"
:timezone="timezone"
:slotDuration="slotDuration"
:slotMinTime="slotMinTime"
:slotMaxTime="slotMaxTime"
:businessHours="businessHours"
:events="calendarEvents"
:loading="loadingSettings || loadingEvents"
@rangeChange="onRangeChange"
@selectTime="onSelectTime"
@eventClick="onEventClick"
@eventDrop="onEventDrop"
@eventResize="onEventResize"
/>
<!-- RIGHT: Panel -->
<AgendaRightPanel>
<template #top>
<AgendaNextSessionsCardList
:items="nextSessions"
@open="onOpenFromCard"
@confirm="onConfirmFromCard"
@reschedule="onRescheduleFromCard"
/>
</template>
<template #bottom>
<AgendaPulseCardGrid
:stats="pulseStats"
@quickBlock="onCreateBlock"
@quickCreate="onCreateSession"
/>
</template>
</AgendaRightPanel>
</div>
</div>
<AgendaEventDialog
v-model="dialogOpen"
:eventRow="dialogEventRow"
:initialStartISO="dialogStartISO"
:initialEndISO="dialogEndISO"
:ownerId="(settings?.owner_id || '')"
@save="onDialogSave"
@delete="onDialogDelete"
/>
</div>
</template>