Files
agenciapsilmno/src/features/agenda/components/AgendaCalendar.vue
T
Leonardo 8b0e633aac agenda: centralize FullCalendar touch defaults
Sem long-press delays customizados, tap em slot vazio precisa de 1000ms
antes de disparar select — diverge totalmente do mouse (clique abre na
hora). Mesmo problema em eventDrop. Move pra utils/fcDefaults.js e
aplica nos 4 calendars (AgendaCalendar, AgendaClinicMosaic,
AgendaTerapeutaPage, MelissaAgenda no proximo commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:44:16 -03:00

227 lines
6.2 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/AgendaCalendar.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch, onMounted } from 'vue';
import FullCalendar from '@fullcalendar/vue3';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import dayGridPlugin from '@fullcalendar/daygrid';
import ProgressSpinner from 'primevue/progressspinner';
import { FC_TOUCH_DEFAULTS } from '@/features/agenda/utils/fcDefaults';
const props = defineProps({
// UI
view: { type: String, default: 'day' }, // 'day' | 'week'
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
// calendar behavior
timezone: { type: String, default: 'America/Sao_Paulo' },
slotDuration: { type: String, default: '00:30:00' },
slotMinTime: { type: String, default: '06:00:00' },
slotMaxTime: { type: String, default: '22:00:00' },
businessHours: { type: [Array, Object], default: () => [] },
// data
events: { type: Array, default: () => [] },
loading: { type: Boolean, default: false }
});
const emit = defineEmits(['rangeChange', 'selectTime', 'eventClick', 'eventDrop', 'eventResize']);
const fcRef = ref(null);
const initialView = computed(() => (props.view === 'week' ? 'timeGridWeek' : 'timeGridDay'));
function getApi() {
const inst = fcRef.value;
return inst?.getApi?.() || null;
}
function emitRange() {
const api = getApi();
if (!api) return;
const v = api.view;
emit('rangeChange', { start: v.activeStart, end: v.activeEnd });
}
// -----------------------------
// Calendar options
// -----------------------------
const calendarOptions = computed(() => {
const isWorkHours = props.mode !== 'full_24h';
// No modo 24h, não recorta — mas mantemos min/max caso você queira ainda controlar
const minTime = isWorkHours ? props.slotMinTime : '00:00:00';
// FullCalendar timeGrid costuma aceitar 24:00:00, mas 23:59:59 evita edge-case
const maxTime = isWorkHours ? props.slotMaxTime : '23:59:59';
return {
...FC_TOUCH_DEFAULTS,
plugins: [timeGridPlugin, interactionPlugin, dayGridPlugin],
initialView: initialView.value,
timeZone: props.timezone,
// Header desativado (você controla no Toolbar)
headerToolbar: false,
// Visão "produto": blocos com linhas suaves
nowIndicator: true,
allDaySlot: false,
expandRows: true,
height: 'auto',
// Seleção / DnD / Resize
selectable: true,
selectMirror: true,
editable: true,
eventStartEditable: true,
eventDurationEditable: true,
// Intervalos visuais
slotDuration: props.slotDuration,
slotMinTime: minTime,
slotMaxTime: maxTime,
slotLabelFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: false
},
// Horário "verdadeiro" de funcionamento (se você usar)
businessHours: props.businessHours,
// Dados
events: props.events,
// Melhor UX
weekends: true,
firstDay: 1, // segunda
// Callbacks
datesSet: () => {
// dispara quando muda o intervalo exibido (prev/next/today/view)
emitRange();
},
select: (selection) => {
// selection: { start, end, allDay, ... }
emit('selectTime', selection);
},
eventClick: (info) => emit('eventClick', info),
eventDrop: (info) => emit('eventDrop', info),
eventResize: (info) => emit('eventResize', info)
};
});
// -----------------------------
// Exposed methods (para Toolbar/Page)
// -----------------------------
function goToday() {
getApi()?.today?.();
}
function prev() {
getApi()?.prev?.();
}
function next() {
getApi()?.next?.();
}
function setView(v) {
const api = getApi();
if (!api) return;
api.changeView(v === 'week' ? 'timeGridWeek' : 'timeGridDay');
}
defineExpose({ goToday, prev, next, setView });
// Se a prop view mudar, sincroniza
watch(
() => props.view,
(v) => setView(v)
);
// Emite o range inicial assim que montar
onMounted(() => {
// garante que o FullCalendar já criou a view
setTimeout(() => emitRange(), 0);
});
</script>
<template>
<div class="agenda-calendar-wrap">
<div v-if="loading" class="agenda-calendar-loading">
<div class="flex flex-col gap-2 w-full px-2 py-2">
<Skeleton height="2rem" class="mb-1" />
<div class="grid grid-cols-7 gap-1">
<Skeleton v-for="n in 7" :key="n" height="1.25rem" />
</div>
<Skeleton v-for="n in 8" :key="'row' + n" height="3rem" />
</div>
</div>
<FullCalendar ref="fcRef" :options="calendarOptions" />
</div>
</template>
<style scoped>
.agenda-calendar-wrap {
position: relative;
width: 100%;
}
.agenda-calendar-loading {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
backdrop-filter: blur(3px);
background: color-mix(in srgb, var(--surface-card) 85%, transparent);
border-radius: 16px;
}
/* Deixa o calendário "respirar" dentro de cards/layouts */
:deep(.fc) {
font-size: 0.95rem;
}
:deep(.fc .fc-timegrid-slot) {
height: 2.2rem;
}
:deep(.fc .fc-timegrid-now-indicator-line) {
opacity: 0.75;
}
:deep(.fc .fc-scrollgrid) {
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--surface-border);
}
:deep(.fc .fc-timegrid-axis-cushion),
:deep(.fc .fc-timegrid-slot-label-cushion) {
color: var(--text-color-secondary);
}
</style>