Ajuste em Massa - Paciente, Terapeuta, Clinica e Admin - Inicio agenda
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user