@fayz-ai/plugin-agenda 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +51 -0
  2. package/dist/AgendaContext.d.ts +13 -0
  3. package/dist/AgendaContext.d.ts.map +1 -0
  4. package/dist/AgendaPage.d.ts +11 -0
  5. package/dist/AgendaPage.d.ts.map +1 -0
  6. package/dist/bridges/create-financial-bridge.d.ts +19 -0
  7. package/dist/bridges/create-financial-bridge.d.ts.map +1 -0
  8. package/dist/calendar-styles.d.ts +8 -0
  9. package/dist/calendar-styles.d.ts.map +1 -0
  10. package/dist/chunk-3LJKZQK4.js +12 -0
  11. package/dist/chunk-3LJKZQK4.js.map +1 -0
  12. package/dist/chunk-SYDBDI3Q.cjs +15 -0
  13. package/dist/chunk-SYDBDI3Q.cjs.map +1 -0
  14. package/dist/components/AgendaGeneralSettings.d.ts +3 -0
  15. package/dist/components/AgendaGeneralSettings.d.ts.map +1 -0
  16. package/dist/components/AppointmentModal.d.ts +16 -0
  17. package/dist/components/AppointmentModal.d.ts.map +1 -0
  18. package/dist/components/AppointmentPopover.d.ts +14 -0
  19. package/dist/components/AppointmentPopover.d.ts.map +1 -0
  20. package/dist/components/BookingPaymentPanel.d.ts +18 -0
  21. package/dist/components/BookingPaymentPanel.d.ts.map +1 -0
  22. package/dist/components/CalendarSidebar.d.ts +3 -0
  23. package/dist/components/CalendarSidebar.d.ts.map +1 -0
  24. package/dist/components/CalendarToolbar.d.ts +13 -0
  25. package/dist/components/CalendarToolbar.d.ts.map +1 -0
  26. package/dist/components/EventContextMenu.d.ts +14 -0
  27. package/dist/components/EventContextMenu.d.ts.map +1 -0
  28. package/dist/components/FloatingPanel.d.ts +27 -0
  29. package/dist/components/FloatingPanel.d.ts.map +1 -0
  30. package/dist/components/MiniCalendar.d.ts +9 -0
  31. package/dist/components/MiniCalendar.d.ts.map +1 -0
  32. package/dist/components/PaymentStatusBadge.d.ts +13 -0
  33. package/dist/components/PaymentStatusBadge.d.ts.map +1 -0
  34. package/dist/components/SearchCombobox.d.ts +38 -0
  35. package/dist/components/SearchCombobox.d.ts.map +1 -0
  36. package/dist/components/StatusBadge.d.ts +5 -0
  37. package/dist/components/StatusBadge.d.ts.map +1 -0
  38. package/dist/config.d.ts +151 -0
  39. package/dist/config.d.ts.map +1 -0
  40. package/dist/data/index.d.ts +4 -0
  41. package/dist/data/index.d.ts.map +1 -0
  42. package/dist/data/mock.d.ts +3 -0
  43. package/dist/data/mock.d.ts.map +1 -0
  44. package/dist/data/supabase.d.ts +3 -0
  45. package/dist/data/supabase.d.ts.map +1 -0
  46. package/dist/data/types.d.ts +26 -0
  47. package/dist/data/types.d.ts.map +1 -0
  48. package/dist/financial-bridge.d.ts +92 -0
  49. package/dist/financial-bridge.d.ts.map +1 -0
  50. package/dist/hooks/useAgendaSettings.d.ts +7 -0
  51. package/dist/hooks/useAgendaSettings.d.ts.map +1 -0
  52. package/dist/index.cjs +5135 -0
  53. package/dist/index.cjs.map +1 -0
  54. package/dist/index.d.ts +9 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +5120 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/lib/tenant.d.ts +3 -0
  59. package/dist/lib/tenant.d.ts.map +1 -0
  60. package/dist/locales/en.d.ts +2 -0
  61. package/dist/locales/en.d.ts.map +1 -0
  62. package/dist/locales/index.d.ts +2 -0
  63. package/dist/locales/index.d.ts.map +1 -0
  64. package/dist/locales/pt-BR.d.ts +2 -0
  65. package/dist/locales/pt-BR.d.ts.map +1 -0
  66. package/dist/registries.d.ts +3 -0
  67. package/dist/registries.d.ts.map +1 -0
  68. package/dist/store.d.ts +66 -0
  69. package/dist/store.d.ts.map +1 -0
  70. package/dist/tenant-222EHYUC.cjs +16 -0
  71. package/dist/tenant-222EHYUC.cjs.map +1 -0
  72. package/dist/tenant-LAUWVUYB.js +3 -0
  73. package/dist/tenant-LAUWVUYB.js.map +1 -0
  74. package/dist/types.d.ts +202 -0
  75. package/dist/types.d.ts.map +1 -0
  76. package/dist/views/CalendarView.d.ts +3 -0
  77. package/dist/views/CalendarView.d.ts.map +1 -0
  78. package/dist/views/ConfirmationsView.d.ts +3 -0
  79. package/dist/views/ConfirmationsView.d.ts.map +1 -0
  80. package/package.json +63 -0
  81. package/src/AgendaContext.tsx +11 -0
  82. package/src/AgendaPage.tsx +33 -0
  83. package/src/bridges/create-financial-bridge.ts +298 -0
  84. package/src/calendar-styles.css +245 -0
  85. package/src/calendar-styles.ts +285 -0
  86. package/src/components/AgendaGeneralSettings.tsx +64 -0
  87. package/src/components/AppointmentModal.tsx +1031 -0
  88. package/src/components/AppointmentPopover.tsx +221 -0
  89. package/src/components/BookingPaymentPanel.tsx +394 -0
  90. package/src/components/CalendarSidebar.tsx +119 -0
  91. package/src/components/CalendarToolbar.tsx +83 -0
  92. package/src/components/EventContextMenu.tsx +122 -0
  93. package/src/components/FloatingPanel.tsx +123 -0
  94. package/src/components/MiniCalendar.tsx +120 -0
  95. package/src/components/PaymentStatusBadge.tsx +62 -0
  96. package/src/components/SearchCombobox.tsx +216 -0
  97. package/src/components/StatusBadge.tsx +23 -0
  98. package/src/config.ts +269 -0
  99. package/src/data/index.ts +3 -0
  100. package/src/data/mock.ts +324 -0
  101. package/src/data/supabase.ts +570 -0
  102. package/src/data/types.ts +37 -0
  103. package/src/financial-bridge.ts +96 -0
  104. package/src/hooks/useAgendaSettings.ts +17 -0
  105. package/src/index.ts +180 -0
  106. package/src/lib/tenant.ts +3 -0
  107. package/src/locales/en.ts +207 -0
  108. package/src/locales/index.ts +7 -0
  109. package/src/locales/pt-BR.ts +207 -0
  110. package/src/registries.ts +36 -0
  111. package/src/store.ts +304 -0
  112. package/src/types.ts +265 -0
  113. package/src/views/CalendarView.tsx +904 -0
  114. package/src/views/ConfirmationsView.tsx +155 -0
@@ -0,0 +1,904 @@
1
+ import React, { useRef, useEffect, useCallback, useMemo, useState } from 'react'
2
+ import { injectCalendarStyles } from '../calendar-styles'
3
+ import FullCalendar from '@fullcalendar/react'
4
+ import dayGridPlugin from '@fullcalendar/daygrid'
5
+ import timeGridPlugin from '@fullcalendar/timegrid'
6
+ import resourceTimeGridPlugin from '@fullcalendar/resource-timegrid'
7
+ import interactionPlugin from '@fullcalendar/interaction'
8
+ import listPlugin from '@fullcalendar/list'
9
+ import ptBrLocale from '@fullcalendar/core/locales/pt-br'
10
+ import type { EventInput, EventClickArg, DateSelectArg, EventDropArg } from '@fullcalendar/core'
11
+ import { ChevronLeft, ChevronRight, Plus, ChevronDown, Settings } from 'lucide-react'
12
+ import { usePluginPrefs } from '@fayz-ai/saas'
13
+ import { getCurrentLocale } from '@fayz-ai/core'
14
+ import { useTranslation } from '@fayz-ai/core'
15
+ import { useAgendaConfig, useAgendaStore } from '../AgendaContext'
16
+ import { useAgendaSettings } from '../hooks/useAgendaSettings'
17
+ import { MiniCalendar } from '../components/MiniCalendar'
18
+ import { AppointmentModal } from '../components/AppointmentModal'
19
+ import { AppointmentPopover } from '../components/AppointmentPopover'
20
+ import { PersonLink } from '@fayz-ai/saas'
21
+ import { EventContextMenu } from '../components/EventContextMenu'
22
+ import { ConfirmDialog } from '@fayz-ai/ui'
23
+ import { Button } from '@fayz-ai/ui'
24
+ import type { CalendarBooking, Schedule } from '../types'
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ function bookingToEvent(booking: CalendarBooking, statusColors: Record<string, string>): EventInput {
31
+ const color = statusColors[booking.status] ?? '#6b7280'
32
+ const serviceNames = booking.services?.map((s) => s.name).join(', ') ?? ''
33
+ return {
34
+ id: booking.id,
35
+ resourceId: booking.professionalId ?? undefined,
36
+ title: booking.clientName ?? 'Unknown',
37
+ start: booking.startsAt,
38
+ end: booking.endsAt ?? undefined,
39
+ backgroundColor: color + '30',
40
+ borderColor: color,
41
+ textColor: color,
42
+ extendedProps: { booking, serviceNames },
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Generate FullCalendar background events showing unavailable time.
48
+ * For each day, creates background blocks for the gaps OUTSIDE working hours
49
+ * within the visible business hours window. This grays out non-working time.
50
+ */
51
+ function generateAvailabilityEvents(
52
+ schedules: Schedule[],
53
+ professionals: { id: string }[],
54
+ dateRange: { start: string; end: string } | null,
55
+ businessHours?: { startTime: string; endTime: string },
56
+ locationMap?: Map<string, string>,
57
+ ): EventInput[] {
58
+ if (!dateRange || schedules.length === 0) return []
59
+
60
+ const bgEvents: EventInput[] = []
61
+ const startDate = new Date(dateRange.start)
62
+ const endDate = new Date(dateRange.end)
63
+ const bizStart = businessHours?.startTime ?? '08:00'
64
+ const bizEnd = businessHours?.endTime ?? '20:00'
65
+
66
+ // Group schedules by assignee
67
+ const byAssignee = new Map<string, Schedule[]>()
68
+ for (const s of schedules) {
69
+ const aid = s.assigneeId
70
+ if (!aid) continue
71
+ const list = byAssignee.get(aid) ?? []
72
+ list.push(s)
73
+ byAssignee.set(aid, list)
74
+ }
75
+
76
+ function timeToMinutes(t: string): number {
77
+ const [h, m] = t.split(':').map(Number)
78
+ return h * 60 + m
79
+ }
80
+
81
+ function minutesToTime(m: number): string {
82
+ return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
83
+ }
84
+
85
+ const bizStartMin = timeToMinutes(bizStart)
86
+ const bizEndMin = timeToMinutes(bizEnd)
87
+
88
+ for (const prof of professionals) {
89
+ const profSchedules = byAssignee.get(prof.id) ?? []
90
+
91
+ // Separate weekly and date-specific schedules
92
+ const weekly = profSchedules.filter((s) => s.dayOfWeek != null && !s.specificDate)
93
+ const exceptions = profSchedules.filter((s) => s.specificDate != null)
94
+ const exceptionDates = new Set(exceptions.map((s) => s.specificDate!))
95
+
96
+ const current = new Date(startDate)
97
+ while (current < endDate) {
98
+ const dateStr = current.toISOString().slice(0, 10)
99
+ const dayOfWeek = current.getDay()
100
+
101
+ // Get working periods for this day
102
+ let daySchedules: Schedule[]
103
+ if (exceptionDates.has(dateStr)) {
104
+ daySchedules = exceptions.filter((s) => s.specificDate === dateStr && s.isActive)
105
+ } else {
106
+ daySchedules = weekly.filter((s) => s.dayOfWeek === dayOfWeek && s.isActive)
107
+ }
108
+
109
+ // Convert working periods to sorted minute ranges, preserving locationId
110
+ const workRanges = daySchedules
111
+ .map((s) => {
112
+ const locId = s.locationId ?? (s.metadata?.locationId as string | undefined)
113
+ return {
114
+ start: timeToMinutes(s.startsAt),
115
+ end: timeToMinutes(s.endsAt),
116
+ locationName: locId && locationMap ? locationMap.get(locId) : undefined,
117
+ }
118
+ })
119
+ .sort((a, b) => a.start - b.start)
120
+
121
+ // Compute unavailable gaps within business hours
122
+ let cursor = bizStartMin
123
+ for (const range of workRanges) {
124
+ const availStart = Math.max(range.start, bizStartMin)
125
+ const availEnd = Math.min(range.end, bizEndMin)
126
+
127
+ // Gap before this working range = unavailable
128
+ if (cursor < availStart) {
129
+ bgEvents.push({
130
+ resourceId: prof.id,
131
+ start: dateStr + 'T' + minutesToTime(cursor) + ':00',
132
+ end: dateStr + 'T' + minutesToTime(availStart) + ':00',
133
+ display: 'background',
134
+ classNames: ['fc-unavailable-bg'],
135
+ })
136
+ }
137
+
138
+ // Location label on available range
139
+ if (availStart < availEnd && range.locationName) {
140
+ bgEvents.push({
141
+ id: `loc-${prof.id}-${dateStr}-${minutesToTime(availStart)}`,
142
+ resourceId: prof.id,
143
+ start: dateStr + 'T' + minutesToTime(availStart) + ':00',
144
+ end: dateStr + 'T' + minutesToTime(availStart + 20) + ':00',
145
+ title: range.locationName,
146
+ display: 'auto',
147
+ editable: false,
148
+ backgroundColor: 'transparent',
149
+ borderColor: 'transparent',
150
+ textColor: '#a3a3a3',
151
+ classNames: ['fc-location-label'],
152
+ extendedProps: { isLocationLabel: true },
153
+ })
154
+ }
155
+
156
+ cursor = Math.max(cursor, availEnd)
157
+ }
158
+
159
+ // Gap after last working range = unavailable
160
+ if (cursor < bizEndMin) {
161
+ bgEvents.push({
162
+ resourceId: prof.id,
163
+ start: dateStr + 'T' + minutesToTime(cursor) + ':00',
164
+ end: dateStr + 'T' + minutesToTime(bizEndMin) + ':00',
165
+ display: 'background',
166
+ classNames: ['fc-unavailable-bg'],
167
+ })
168
+ }
169
+
170
+
171
+ current.setDate(current.getDate() + 1)
172
+ }
173
+ }
174
+
175
+ return bgEvents
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Loading skeleton — mimics the resource-timegrid-week layout
180
+ // ---------------------------------------------------------------------------
181
+
182
+ function CalendarSkeleton({ profCount = 2 }: { profCount?: number }) {
183
+ const days = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']
184
+ const hours = ['07:00', '08:00', '09:00', '10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00', '20:00']
185
+
186
+ return (
187
+ <div className="flex-1 overflow-hidden animate-pulse">
188
+ <div className="flex border-b" style={{ paddingLeft: 56 }}>
189
+ {Array.from({ length: profCount }).map((_, pi) => (
190
+ <div key={pi} className="flex-1 border-r last:border-r-0 py-2 px-3">
191
+ <div className="flex items-center justify-center gap-2">
192
+ <div className="h-6 w-6 rounded-full bg-muted" />
193
+ <div className="h-3 w-20 rounded bg-muted" />
194
+ </div>
195
+ </div>
196
+ ))}
197
+ </div>
198
+ <div className="flex border-b" style={{ paddingLeft: 56 }}>
199
+ {Array.from({ length: profCount }).map((_, pi) => (
200
+ <div key={pi} className="flex-1 flex border-r last:border-r-0">
201
+ {days.map((d) => (
202
+ <div key={d} className="flex-1 py-1.5 text-center">
203
+ <span className="text-[10px] text-muted-foreground/40">{d}</span>
204
+ </div>
205
+ ))}
206
+ </div>
207
+ ))}
208
+ </div>
209
+ <div className="relative">
210
+ {hours.map((h) => (
211
+ <div key={h} className="flex border-b border-border/30" style={{ height: 50 }}>
212
+ <div className="w-14 shrink-0 pr-2 text-right">
213
+ <span className="text-[10px] text-muted-foreground/30">{h}</span>
214
+ </div>
215
+ <div className="flex-1 flex">
216
+ {Array.from({ length: profCount * 7 }).map((_, ci) => (
217
+ <div key={ci} className={`flex-1 border-r border-border/20 ${Math.floor(ci / 7) % 2 === 1 ? 'bg-muted/20' : ''}`} />
218
+ ))}
219
+ </div>
220
+ </div>
221
+ ))}
222
+ </div>
223
+ </div>
224
+ )
225
+ }
226
+
227
+ const VIEW_OPTIONS = [
228
+ { key: 'resourceTimeGridDay', labelKey: 'agenda.calendar.viewDay' },
229
+ { key: 'resourceTimeGridWeek', labelKey: 'agenda.calendar.viewWeek' },
230
+ { key: 'dayGridMonth', labelKey: 'agenda.calendar.viewMonth' },
231
+ ] as const
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Calendar View
235
+ // ---------------------------------------------------------------------------
236
+
237
+ export function CalendarView() {
238
+ injectCalendarStyles()
239
+ const config = useAgendaConfig()
240
+ const t = useTranslation()
241
+ const currentLocale = getCurrentLocale()
242
+ const fcLocale = currentLocale === 'pt-BR' ? ptBrLocale : undefined
243
+ const calendarRef = useRef<FullCalendar>(null)
244
+ const prefs = usePluginPrefs('agenda', { calendarView: config.defaultCalendarView ?? 'resourceTimeGridWeek' })
245
+ const agendaSettings = useAgendaSettings()
246
+ const effectiveBusinessHours = {
247
+ startTime: agendaSettings.startTime,
248
+ endTime: agendaSettings.endTime,
249
+ }
250
+
251
+ const bookings = useAgendaStore((s) => s.bookings)
252
+ const bookingsLoading = useAgendaStore((s) => s.bookingsLoading)
253
+ const professionals = useAgendaStore((s) => s.professionals)
254
+ const professionalsLoading = useAgendaStore((s) => s.professionalsLoading)
255
+ const currentView = useAgendaStore((s) => s.currentView)
256
+ const selectedProfIds = useAgendaStore((s) => s.selectedProfessionalIds)
257
+ const selectedStatuses = useAgendaStore((s) => s.selectedStatuses)
258
+ const schedules = useAgendaStore((s) => s.schedules)
259
+ const fetchBookings = useAgendaStore((s) => s.fetchBookings)
260
+ const fetchProfessionals = useAgendaStore((s) => s.fetchProfessionals)
261
+ const fetchLocations = useAgendaStore((s) => s.fetchLocations)
262
+ const storeLocations = useAgendaStore((s) => s.locations)
263
+ const fetchSchedules = useAgendaStore((s) => s.fetchSchedules)
264
+ const rescheduleBooking = useAgendaStore((s) => s.rescheduleBooking)
265
+ const setView = useAgendaStore((s) => s.setView)
266
+ const setFilters = useAgendaStore((s) => s.setFilters)
267
+ const _openModal = useAgendaStore((s) => s.openAppointmentModal)
268
+ const closeModal = useAgendaStore((s) => s.closeAppointmentModal)
269
+ const modalState = useAgendaStore((s) => s.appointmentModal)
270
+
271
+ const [selectedBooking, setSelectedBooking] = useState<CalendarBooking | null>(null)
272
+ const [popoverAnchor, setPopoverAnchor] = useState<{ x: number; y: number } | null>(null)
273
+ const [popoverVisible, _setPopoverVisible] = useState(false)
274
+ const popoverVisibleRef = useRef(false)
275
+ const setPopoverVisible = useCallback((v: boolean) => { popoverVisibleRef.current = v; _setPopoverVisible(v) }, [])
276
+ const [contextMenu, setContextMenu] = useState<{ booking: CalendarBooking; x: number; y: number } | null>(null)
277
+ const [contextMenuVisible, _setContextMenuVisible] = useState(false)
278
+ const contextMenuVisibleRef = useRef(false)
279
+ const setContextMenuVisible = useCallback((v: boolean) => { contextMenuVisibleRef.current = v; _setContextMenuVisible(v) }, [])
280
+ const [scheduleConfirm, setScheduleConfirm] = useState<{ onConfirm: () => void } | null>(null)
281
+ const [calendarTitle, setCalendarTitle] = useState('')
282
+ const [selectedDate, setSelectedDate] = useState(new Date())
283
+ const [profSectionOpen, setProfSectionOpen] = useState(true)
284
+ const [statusSectionOpen, setStatusSectionOpen] = useState(false)
285
+ const [visibleRange, setVisibleRange] = useState<{ start: string; end: string } | null>(null)
286
+
287
+ // Wrap openModal to always dismiss popover/context menu first
288
+ const openModal: typeof _openModal = useCallback((...args) => {
289
+ setPopoverVisible(false); setSelectedBooking(null); setPopoverAnchor(null)
290
+ setContextMenuVisible(false); setContextMenu(null)
291
+ _openModal(...args)
292
+ }, [_openModal])
293
+
294
+ const statusColors = useMemo(() => {
295
+ const map: Record<string, string> = {}
296
+ for (const s of config.statuses) map[s.value] = s.color
297
+ return map
298
+ }, [config.statuses])
299
+
300
+ useEffect(() => {
301
+ fetchProfessionals(); fetchSchedules()
302
+ if (config.modules.locationSelection) fetchLocations()
303
+ }, [])
304
+
305
+ const resources = useMemo(() => {
306
+ const filtered = selectedProfIds.length > 0
307
+ ? professionals.filter((p) => selectedProfIds.includes(p.id))
308
+ : professionals
309
+ return filtered.map((p) => ({
310
+ id: p.id, title: p.name,
311
+ extendedProps: { avatarUrl: p.avatarUrl, locationName: p.locationName },
312
+ }))
313
+ }, [professionals, selectedProfIds])
314
+
315
+ const bookingEvents = useMemo(() =>
316
+ bookings.map((b) => bookingToEvent(b, statusColors)), [bookings, statusColors])
317
+
318
+ // Merge static config locations with dynamically fetched locations
319
+ const allLocations = useMemo(() => {
320
+ const map = new Map<string, string>()
321
+ for (const loc of config.locations) map.set(loc.id, loc.name)
322
+ for (const loc of storeLocations) map.set(loc.id, loc.name)
323
+ return map
324
+ }, [config.locations, storeLocations])
325
+
326
+ const locationMap = useMemo(() => {
327
+ if (!config.modules.locationSelection || allLocations.size === 0) return undefined
328
+ return allLocations
329
+ }, [config.modules.locationSelection, allLocations])
330
+
331
+ const availabilityEvents = useMemo(() =>
332
+ generateAvailabilityEvents(schedules, professionals, visibleRange, effectiveBusinessHours, locationMap),
333
+ [schedules, professionals, visibleRange, effectiveBusinessHours, locationMap])
334
+
335
+ const events = useMemo(() =>
336
+ [...bookingEvents, ...availabilityEvents], [bookingEvents, availabilityEvents])
337
+
338
+ // ── Calendar handlers ──
339
+ const handleDatesSet = useCallback((arg: { startStr: string; endStr: string; view: { title: string; type: string } }) => {
340
+ setCalendarTitle(arg.view.title)
341
+ setView(arg.view.type)
342
+ setVisibleRange({ start: arg.startStr, end: arg.endStr })
343
+ fetchBookings({ start: arg.startStr, end: arg.endStr })
344
+ }, [fetchBookings, setView])
345
+
346
+ // Double-click → open edit directly
347
+ // Right-click → context menu
348
+ const handleEventDidMount = useCallback((arg: { event: any; el: HTMLElement }) => {
349
+ const booking = arg.event.extendedProps?.booking as CalendarBooking | undefined
350
+ if (booking) arg.el.setAttribute('data-booking-id', booking.id)
351
+ arg.el.addEventListener('dblclick', (e) => {
352
+ e.preventDefault()
353
+ e.stopPropagation()
354
+ skipClickRef.current = true
355
+ const booking = arg.event.extendedProps.booking as CalendarBooking
356
+ setSelectedBooking(null)
357
+ setPopoverAnchor(null)
358
+ setContextMenu(null)
359
+ openModal('edit', { bookingId: booking.id })
360
+ })
361
+ arg.el.addEventListener('contextmenu', (e) => {
362
+ e.preventDefault()
363
+ const booking = arg.event.extendedProps.booking as CalendarBooking
364
+ const menuW = 210, menuH = 200
365
+ let x = e.clientX + 4
366
+ let y = e.clientY + 4
367
+ if (x + menuW > window.innerWidth - 12) x = x - menuW
368
+ if (y + menuH > window.innerHeight - 12) y = y - menuH
369
+ setContextMenu({ booking, x, y })
370
+ setContextMenuVisible(true)
371
+ setSelectedBooking(null)
372
+ setPopoverAnchor(null)
373
+ })
374
+ }, [])
375
+
376
+ // Flag to suppress single-click after a double-click
377
+ const skipClickRef = useRef(false)
378
+
379
+ // Hover timer — show popover after 4s hover on a booking event
380
+ const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
381
+
382
+ const handleEventMouseEnter = useCallback((arg: { event: any; el: HTMLElement }) => {
383
+ if (arg.event.display === 'background') return
384
+ if (arg.event.extendedProps?.isLocationLabel) return
385
+ const booking = arg.event.extendedProps.booking as CalendarBooking
386
+ if (!booking) return
387
+
388
+ hoverTimerRef.current = setTimeout(() => {
389
+ const rect = arg.el.getBoundingClientRect()
390
+ const popW = 320, popH = 340
391
+ let x = rect.right + 8
392
+ let y = rect.top + rect.height / 2 - popH / 2
393
+ if (x + popW > window.innerWidth - 16) x = rect.left - popW - 8
394
+ if (x < 16) x = Math.max(16, rect.left + rect.width / 2 - popW / 2)
395
+ if (y + popH > window.innerHeight - 16) y = window.innerHeight - popH - 16
396
+ if (y < 16) y = 16
397
+ setSelectedBooking(booking)
398
+ setPopoverAnchor({ x, y })
399
+ setPopoverVisible(true)
400
+ }, 3000)
401
+ }, [])
402
+
403
+ const handleEventMouseLeave = useCallback(() => {
404
+ if (hoverTimerRef.current) {
405
+ clearTimeout(hoverTimerRef.current)
406
+ hoverTimerRef.current = null
407
+ }
408
+ }, [])
409
+
410
+ // Single click — open popover instantly, or slide to new position if already open
411
+ const handleEventClick = useCallback((arg: EventClickArg) => {
412
+ if (skipClickRef.current) { skipClickRef.current = false; return }
413
+ // Ignore clicks on background availability events and location labels
414
+ if (arg.event.display === 'background') return
415
+ if (arg.event.extendedProps?.isLocationLabel) return
416
+ setContextMenu(null); setContextMenuVisible(false)
417
+ const booking = arg.event.extendedProps.booking as CalendarBooking
418
+ if (!booking) return
419
+ const rect = arg.el.getBoundingClientRect()
420
+ const popW = 320, popH = 340
421
+ let x = rect.right + 8
422
+ let y = rect.top + rect.height / 2 - popH / 2
423
+ if (x + popW > window.innerWidth - 16) x = rect.left - popW - 8
424
+ if (x < 16) x = Math.max(16, rect.left + rect.width / 2 - popW / 2)
425
+ if (y + popH > window.innerHeight - 16) y = window.innerHeight - popH - 16
426
+ if (y < 16) y = 16
427
+ // Always update — if popover is already open it slides to the new position
428
+ setSelectedBooking(booking)
429
+ setPopoverAnchor({ x, y })
430
+ setPopoverVisible(true)
431
+ }, [])
432
+
433
+ // Find the schedule block covering a given time slot for a professional
434
+ const findScheduleForSlot = useCallback((professionalId: string | undefined, dateTimeStr: string): Schedule | null => {
435
+ if (!professionalId) return null
436
+ const dt = new Date(dateTimeStr)
437
+ const dateStr = dt.toISOString().slice(0, 10)
438
+ const dayOfWeek = dt.getDay()
439
+ const timeMin = dt.getHours() * 60 + dt.getMinutes()
440
+
441
+ const profSchedules = schedules.filter((s) => s.assigneeId === professionalId)
442
+ const exceptions = profSchedules.filter((s) => s.specificDate === dateStr && s.isActive)
443
+ const weekly = profSchedules.filter((s) => s.dayOfWeek === dayOfWeek && s.isActive && !s.specificDate)
444
+ const daySchedules = exceptions.length > 0 ? exceptions : weekly
445
+
446
+ for (const s of daySchedules) {
447
+ const [sh, sm] = s.startsAt.split(':').map(Number)
448
+ const [eh, em] = s.endsAt.split(':').map(Number)
449
+ if (timeMin >= sh * 60 + sm && timeMin < eh * 60 + em) return s
450
+ }
451
+ return null
452
+ }, [schedules])
453
+
454
+ const handleSelect = useCallback((arg: DateSelectArg) => {
455
+ calendarRef.current?.getApi().unselect()
456
+
457
+ // Don't open create if popover/context menu is showing — dismiss them instead
458
+ if (popoverVisibleRef.current || contextMenuVisibleRef.current) {
459
+ setPopoverVisible(false); setSelectedBooking(null); setPopoverAnchor(null)
460
+ setContextMenuVisible(false); setContextMenu(null)
461
+ return
462
+ }
463
+
464
+ const professionalId = arg.resource?.id
465
+ const schedule = findScheduleForSlot(professionalId, arg.startStr)
466
+
467
+ const proceed = () => {
468
+ const locId = schedule?.locationId ?? (schedule?.metadata?.locationId as string | undefined)
469
+ openModal('create', { prefill: { startsAt: arg.startStr, endsAt: arg.endStr, professionalId, locationId: locId || undefined } })
470
+ }
471
+
472
+ // Warn if no schedule covers this slot
473
+ if (!schedule) {
474
+ setScheduleConfirm({ onConfirm: proceed })
475
+ return
476
+ }
477
+
478
+ proceed()
479
+ }, [openModal, findScheduleForSlot])
480
+
481
+ const handleEventDrop = useCallback(async (arg: EventDropArg) => {
482
+ const booking = arg.event.extendedProps.booking as CalendarBooking
483
+ try { await rescheduleBooking(booking.id, arg.event.startStr, arg.event.endStr, arg.newResource?.id ?? undefined) }
484
+ catch { arg.revert() }
485
+ }, [rescheduleBooking])
486
+
487
+ const handleViewChange = useCallback((v: string) => {
488
+ calendarRef.current?.getApi().changeView(v)
489
+ prefs.set('calendarView', v)
490
+ }, [prefs])
491
+ const handlePrev = useCallback(() => calendarRef.current?.getApi().prev(), [])
492
+ const handleNext = useCallback(() => calendarRef.current?.getApi().next(), [])
493
+ const handleToday = useCallback(() => { calendarRef.current?.getApi().today(); setSelectedDate(new Date()) }, [])
494
+
495
+ function handleMiniDateSelect(date: Date) {
496
+ setSelectedDate(date)
497
+ calendarRef.current?.getApi().gotoDate(date)
498
+ }
499
+
500
+ function toggleProfessional(id: string) {
501
+ const next = selectedProfIds.includes(id)
502
+ ? selectedProfIds.filter((p) => p !== id)
503
+ : [...selectedProfIds, id]
504
+ setFilters({ professionalIds: next })
505
+ }
506
+
507
+ function toggleStatus(value: string) {
508
+ const next = selectedStatuses.includes(value)
509
+ ? selectedStatuses.filter((s) => s !== value)
510
+ : [...selectedStatuses, value]
511
+ setFilters({ statuses: next })
512
+ }
513
+
514
+ // Alternating resource backgrounds — apply via DOM after render
515
+ const wrapperRef = useRef<HTMLDivElement>(null)
516
+
517
+ // Responsive day headers — collapse weekday names to a single initial (S M T
518
+ // W T F S, Google-Calendar style) when day columns get too narrow. Measured
519
+ // from the actual rendered column width so it adapts to resource count + view.
520
+ const [dayHeaderCompact, setDayHeaderCompact] = useState(false)
521
+ useEffect(() => {
522
+ const container = wrapperRef.current
523
+ if (!container) return
524
+ let frame = 0
525
+ const measure = () => {
526
+ // Measure an actual DAY column ([data-date]) — in resource views the first
527
+ // .fc-col-header-cell is the wide resource header, not a day column.
528
+ const cell = container.querySelector('.fc-col-header-cell[data-date]') as HTMLElement | null
529
+ if (!cell) return
530
+ const colWidth = cell.getBoundingClientRect().width
531
+ // Collapse to single-letter ONLY when the full weekday label can't fit.
532
+ // Measure the widest full line ("WED" / "12/14") in the header's font so
533
+ // the breakpoint adapts to font + locale instead of a magic number.
534
+ const cushion = (cell.querySelector('.fc-col-header-cell-cushion') as HTMLElement | null) ?? cell
535
+ const cs = getComputedStyle(cushion)
536
+ const probe = document.createElement('span')
537
+ probe.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;top:-9999px'
538
+ probe.style.fontSize = cs.fontSize
539
+ probe.style.fontWeight = cs.fontWeight
540
+ probe.style.fontFamily = cs.fontFamily
541
+ probe.style.letterSpacing = cs.letterSpacing
542
+ probe.style.textTransform = cs.textTransform
543
+ document.body.appendChild(probe)
544
+ probe.textContent = 'Wed'
545
+ const weekdayWidth = probe.getBoundingClientRect().width
546
+ probe.textContent = '12/14'
547
+ const dateWidth = probe.getBoundingClientRect().width
548
+ probe.remove()
549
+ const needed = Math.max(weekdayWidth, dateWidth) + 12 // text + cushion padding
550
+ setDayHeaderCompact(colWidth < needed)
551
+ }
552
+ // FullCalendar reflows its inner day columns on a later tick than the
553
+ // container resize, so re-measure across a few frames to read settled widths.
554
+ const timers: ReturnType<typeof setTimeout>[] = []
555
+ const schedule = () => {
556
+ cancelAnimationFrame(frame)
557
+ frame = requestAnimationFrame(measure)
558
+ timers.forEach(clearTimeout)
559
+ timers.length = 0
560
+ timers.push(setTimeout(measure, 130), setTimeout(measure, 330))
561
+ }
562
+ schedule()
563
+ const ro = new ResizeObserver(schedule)
564
+ ro.observe(container)
565
+ return () => { cancelAnimationFrame(frame); timers.forEach(clearTimeout); ro.disconnect() }
566
+ }, [resources, currentView, visibleRange])
567
+
568
+ const applyResourceStriping = useCallback(() => {
569
+ const container = wrapperRef.current
570
+ if (!container) return
571
+ const resourceIds = resources.map((r: { id: string }) => r.id)
572
+ if (resourceIds.length < 2) return
573
+ container.querySelectorAll('[data-resource-id]').forEach((el: Element) => {
574
+ const rid = el.getAttribute('data-resource-id')
575
+ const idx = rid ? resourceIds.indexOf(rid) : -1
576
+ el.classList.toggle('fc-resource-alt', idx % 2 === 1)
577
+ })
578
+ }, [resources])
579
+
580
+ useEffect(() => {
581
+ applyResourceStriping()
582
+ const t1 = setTimeout(applyResourceStriping, 100)
583
+ const t2 = setTimeout(applyResourceStriping, 300)
584
+ return () => { clearTimeout(t1); clearTimeout(t2) }
585
+ }, [applyResourceStriping, currentView, visibleRange, bookings])
586
+
587
+ // Listen for cross-plugin booking open requests (e.g., from financial invoice detail)
588
+ useEffect(() => {
589
+ function handleOpenBooking(e: Event) {
590
+ const bookingId = (e as CustomEvent).detail?.bookingId
591
+ if (bookingId) openModal('edit', { bookingId })
592
+ }
593
+ window.addEventListener('agenda:open-booking', handleOpenBooking)
594
+ return () => window.removeEventListener('agenda:open-booking', handleOpenBooking)
595
+ }, [openModal])
596
+
597
+ // Custom day header: weekday + date stacked. When columns get narrow it
598
+ // collapses the weekday to a single initial (S / 16, Google-Calendar style).
599
+ // Always returns explicit content — returning undefined renders an EMPTY
600
+ // header in the React adapter rather than falling back to the default.
601
+ const dayHeaderContent = useCallback((arg: any) => {
602
+ const date: Date = arg.date
603
+ const dateLocale = currentLocale === 'pt-BR' ? 'pt-BR' : 'en'
604
+ // Inline styles (not CSS classes) so stacking is guaranteed regardless of
605
+ // the runtime style-injection state.
606
+ const wrapStyle: React.CSSProperties = { display: 'flex', flexDirection: 'column', alignItems: 'center', lineHeight: 1.15 }
607
+ const dateStyle: React.CSSProperties = { fontSize: '0.65rem', opacity: 0.75 }
608
+ if (dayHeaderCompact) {
609
+ const letter = date.toLocaleDateString(dateLocale, { weekday: 'narrow' })
610
+ return (
611
+ <div style={wrapStyle}>
612
+ <span style={{ fontWeight: 600 }}>{letter}</span>
613
+ <span style={dateStyle}>{date.getDate()}</span>
614
+ </div>
615
+ )
616
+ }
617
+ const weekday = date.toLocaleDateString(dateLocale, { weekday: 'short' })
618
+ return (
619
+ <div style={wrapStyle}>
620
+ <span>{weekday}</span>
621
+ <span style={dateStyle}>{`${date.getMonth() + 1}/${date.getDate()}`}</span>
622
+ </div>
623
+ )
624
+ }, [dayHeaderCompact, currentLocale])
625
+
626
+ const resourceLabelContent = useCallback((arg: any) => {
627
+ const { id, title, extendedProps } = arg.resource
628
+ const initials = title.split(' ').map((n: string) => n[0]).join('').slice(0, 2)
629
+ return (
630
+ <div className="flex items-center gap-2 px-2 py-1.5">
631
+ <div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[10px] font-bold text-primary">{initials}</div>
632
+ <div className="min-w-0">
633
+ <PersonLink
634
+ personId={id}
635
+ name={title}
636
+ size="sm"
637
+ tab="schedule"
638
+ profileHref={`#/registry/staff/${id}/schedule`}
639
+ />
640
+ {extendedProps?.locationName && <p className="text-[10px] text-muted-foreground truncate">{extendedProps.locationName}</p>}
641
+ </div>
642
+ </div>
643
+ )
644
+ }, [])
645
+
646
+ const eventContent = useCallback((arg: any) => {
647
+ // Location label events — render as a small tag
648
+ if (arg.event.extendedProps?.isLocationLabel) {
649
+ return (
650
+ <div className="px-1.5 py-0.5 overflow-hidden">
651
+ <p className="text-[10px] font-medium truncate">{arg.event.title}</p>
652
+ </div>
653
+ )
654
+ }
655
+ const serviceNames = arg.event.extendedProps?.serviceNames
656
+ const start = arg.event.start
657
+ const end = arg.event.end
658
+ const fmt = (d: Date) => `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
659
+ const timeRange = start && end ? `${fmt(start)} – ${fmt(end)}` : start ? fmt(start) : ''
660
+ return (
661
+ <div className="px-1.5 py-0.5 overflow-hidden leading-tight h-full">
662
+ <div className="flex items-start justify-between gap-1">
663
+ <p className="text-[11px] font-semibold truncate">{arg.event.title}</p>
664
+ {serviceNames && <p className="text-[10px] opacity-60 truncate shrink-0 max-w-[45%] text-right">{serviceNames}</p>}
665
+ </div>
666
+ {timeRange && <p className="text-[9px] opacity-50">{timeRange}</p>}
667
+ </div>
668
+ )
669
+ }, [])
670
+
671
+ return (
672
+ <div className="flex h-[calc(100vh-7.5rem)]">
673
+ {/* ═══ LEFT SIDEBAR — hidden on mobile ═══ */}
674
+ <div className="hidden md:flex w-[220px] shrink-0 flex-col overflow-y-auto py-4 px-3 mr-4">
675
+ {/* + Create button */}
676
+ <button
677
+ onClick={() => openModal('create')}
678
+ className="flex items-center gap-2 rounded-button bg-primary text-primary-foreground border border-primary pl-3 pr-4 py-1.5 text-sm font-semibold hover:bg-primary/90 shadow-button-primary active:shadow-button-inset transition-all mb-6 w-fit"
679
+ >
680
+ <Plus className="h-4 w-4" />
681
+ {t('agenda.sidebar.create')}
682
+ </button>
683
+
684
+ {/* Mini month calendar */}
685
+ <MiniCalendar selectedDate={selectedDate} onDateSelect={handleMiniDateSelect} />
686
+
687
+ <div className="h-px bg-border my-4" />
688
+
689
+ {/* Agendas */}
690
+ <button onClick={() => setProfSectionOpen((p) => !p)}
691
+ className="flex items-center justify-between w-full text-xs font-medium text-muted-foreground hover:text-foreground py-1 transition-colors">
692
+ <span>Agendas</span>
693
+ <ChevronDown className={`h-3.5 w-3.5 transition-transform ${profSectionOpen ? '' : '-rotate-90'}`} />
694
+ </button>
695
+ {profSectionOpen && (
696
+ <div className="mt-1.5 space-y-0.5">
697
+ {professionals.map((prof) => {
698
+ const active = selectedProfIds.length === 0 || selectedProfIds.includes(prof.id)
699
+ return (
700
+ <label key={prof.id} className="flex items-center gap-2.5 rounded px-1 py-1 cursor-pointer hover:bg-muted/50 transition-colors">
701
+ <span className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border-2 transition-colors ${
702
+ active ? 'border-primary bg-primary' : 'border-muted-foreground/30'
703
+ }`}>
704
+ {active && <svg className="h-2.5 w-2.5 text-primary-foreground" viewBox="0 0 12 12" fill="none"><path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>}
705
+ </span>
706
+ <span className={`text-xs truncate ${active ? 'text-foreground' : 'text-muted-foreground'}`}>{prof.name}</span>
707
+ <input type="checkbox" className="sr-only" checked={active} onChange={() => toggleProfessional(prof.id)} />
708
+ </label>
709
+ )
710
+ })}
711
+ </div>
712
+ )}
713
+
714
+ <div className="h-px bg-border my-4" />
715
+
716
+ {/* Status */}
717
+ <button onClick={() => setStatusSectionOpen((p) => !p)}
718
+ className="flex items-center justify-between w-full text-xs font-medium text-muted-foreground hover:text-foreground py-1 transition-colors">
719
+ <span>Status</span>
720
+ <ChevronDown className={`h-3.5 w-3.5 transition-transform ${statusSectionOpen ? '' : '-rotate-90'}`} />
721
+ </button>
722
+ {statusSectionOpen && (
723
+ <div className="mt-1.5 space-y-0.5">
724
+ {config.statuses.map((st) => {
725
+ const active = selectedStatuses.length === 0 || selectedStatuses.includes(st.value)
726
+ return (
727
+ <label key={st.value} className="flex items-center gap-2.5 rounded px-1 py-1 cursor-pointer hover:bg-muted/50 transition-colors">
728
+ <span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border-2 transition-colors"
729
+ style={{ borderColor: active ? st.color : st.color + '40', backgroundColor: active ? st.color : 'transparent' }}>
730
+ {active && <svg className="h-2.5 w-2.5 text-white" viewBox="0 0 12 12" fill="none"><path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>}
731
+ </span>
732
+ <span className={`text-xs ${active ? 'text-foreground' : 'text-muted-foreground'}`}>{st.label}</span>
733
+ <input type="checkbox" className="sr-only" checked={active} onChange={() => toggleStatus(st.value)} />
734
+ </label>
735
+ )
736
+ })}
737
+ </div>
738
+ )}
739
+ </div>
740
+
741
+ {/* ═══ MAIN AREA ═══ */}
742
+ <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
743
+ {/* Toolbar */}
744
+ <div className="flex items-center justify-between px-4 h-12 shrink-0 border-b">
745
+ <div className="flex items-center gap-3">
746
+ {/* Mobile create button */}
747
+ <button onClick={() => openModal('create')}
748
+ className="md:hidden flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground">
749
+ <Plus className="h-4 w-4" />
750
+ </button>
751
+
752
+ <button onClick={handleToday}
753
+ className="rounded-md border px-3 py-1 text-sm font-medium hover:bg-muted bg-card shadow-button active:shadow-button-inset transition-colors">
754
+ {t('agenda.toolbar.today')}
755
+ </button>
756
+ <div className="flex items-center gap-0.5">
757
+ <button onClick={handlePrev} className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-muted transition-colors">
758
+ <ChevronLeft className="h-4 w-4" />
759
+ </button>
760
+ <button onClick={handleNext} className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-muted transition-colors">
761
+ <ChevronRight className="h-4 w-4" />
762
+ </button>
763
+ </div>
764
+ <h1 className="text-base sm:text-lg font-normal truncate">{calendarTitle}</h1>
765
+ </div>
766
+
767
+ <div className="flex items-center gap-1">
768
+ <div className="hidden sm:flex items-center gap-1">
769
+ {VIEW_OPTIONS.map((opt) => (
770
+ <button key={opt.key} onClick={() => handleViewChange(opt.key)}
771
+ className={`rounded-md px-3 py-1 text-sm transition-colors ${
772
+ currentView === opt.key
773
+ ? 'bg-primary/10 text-primary font-medium'
774
+ : 'text-muted-foreground hover:bg-muted/50'
775
+ }`}>
776
+ {t(opt.labelKey)}
777
+ </button>
778
+ ))}
779
+ </div>
780
+ <Button
781
+ variant="outline"
782
+ size="icon"
783
+ onClick={() => window.location.hash = '/settings/agenda'}
784
+ title="Agenda Settings"
785
+ className="ml-1"
786
+ >
787
+ <Settings className="h-4 w-4" />
788
+ </Button>
789
+ </div>
790
+ </div>
791
+
792
+ {/* Calendar */}
793
+ {professionalsLoading && professionals.length === 0 ? (
794
+ <CalendarSkeleton profCount={2} />
795
+ ) : (
796
+ <div ref={wrapperRef} className="flex-1 overflow-hidden agenda-calendar">
797
+ <FullCalendar
798
+ ref={calendarRef}
799
+ plugins={[dayGridPlugin, timeGridPlugin, resourceTimeGridPlugin, interactionPlugin, listPlugin]}
800
+ initialView={prefs.get('calendarView') === 'timeGridWeek' ? 'resourceTimeGridWeek' : prefs.get('calendarView')}
801
+ schedulerLicenseKey="CC-Attribution-NonCommercial-NoDerivatives"
802
+ resources={resources}
803
+ events={events}
804
+ headerToolbar={false}
805
+ locale={fcLocale}
806
+ height="100%"
807
+ slotMinTime={effectiveBusinessHours.startTime + ':00'}
808
+ slotMaxTime={effectiveBusinessHours.endTime + ':00'}
809
+ slotDuration={{ minutes: config.slotDuration }}
810
+ allDaySlot={false}
811
+ nowIndicator={true}
812
+ editable={config.modules.dragAndDrop}
813
+ selectable={true}
814
+ selectMirror={true}
815
+ eventOverlap={false}
816
+ datesSet={handleDatesSet}
817
+ eventDidMount={handleEventDidMount}
818
+ eventClick={handleEventClick}
819
+ eventMouseEnter={handleEventMouseEnter}
820
+ eventMouseLeave={handleEventMouseLeave}
821
+ select={handleSelect}
822
+ eventDrop={handleEventDrop}
823
+ resourceLabelContent={resourceLabelContent}
824
+ dayHeaderContent={dayHeaderContent}
825
+ eventContent={eventContent}
826
+ resourceOrder="title"
827
+ stickyHeaderDates={true}
828
+ expandRows={false}
829
+ eventTimeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
830
+ slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
831
+ />
832
+ </div>
833
+ )}
834
+ </div>
835
+
836
+ {/* Popover — stays mounted until animation finishes */}
837
+ {selectedBooking && popoverAnchor && popoverVisible && (
838
+ <AppointmentPopover booking={selectedBooking} position={popoverAnchor}
839
+ onClose={() => {
840
+ // Called AFTER the popover's exit animation finishes
841
+ setPopoverVisible(false)
842
+ setSelectedBooking(null)
843
+ setPopoverAnchor(null)
844
+ }}
845
+ onEdit={(id) => {
846
+ setPopoverVisible(false)
847
+ setSelectedBooking(null)
848
+ setPopoverAnchor(null)
849
+ openModal('edit', { bookingId: id })
850
+ }} />
851
+ )}
852
+
853
+ {/* Context Menu */}
854
+ {contextMenu && (
855
+ <EventContextMenu
856
+ booking={contextMenu.booking}
857
+ position={{ x: contextMenu.x, y: contextMenu.y }}
858
+ onClose={() => { setContextMenuVisible(false); setContextMenu(null) }}
859
+ onEdit={(id) => { setContextMenuVisible(false); setContextMenu(null); openModal('edit', { bookingId: id }) }}
860
+ />
861
+ )}
862
+
863
+ {/* No-schedule confirm */}
864
+ <ConfirmDialog
865
+ open={!!scheduleConfirm}
866
+ variant="warning"
867
+ title={t('agenda.calendar.noScheduleConfirm')}
868
+ confirmLabel={t('common.confirm')}
869
+ cancelLabel={t('common.cancel')}
870
+ onConfirm={() => { const cb = scheduleConfirm?.onConfirm; setScheduleConfirm(null); cb?.() }}
871
+ onCancel={() => setScheduleConfirm(null)}
872
+ />
873
+
874
+ {/* Modal — always mounted, Radix controls open/close animation */}
875
+ <AppointmentModal
876
+ open={modalState.open}
877
+ mode={modalState.mode}
878
+ bookingId={modalState.bookingId}
879
+ prefill={modalState.prefill}
880
+ initialTab={modalState.initialTab}
881
+ onClose={closeModal}
882
+ onCreated={(newId) => {
883
+ // After a short delay (let calendar re-render), find the event and show popover
884
+ setTimeout(() => {
885
+ const booking = bookings.find((b: CalendarBooking) => b.id === newId)
886
+ if (!booking) return
887
+ const el = document.querySelector(`[data-booking-id="${newId}"]`) as HTMLElement | null
888
+ if (el) {
889
+ const rect = el.getBoundingClientRect()
890
+ const popW = 320, popH = 340
891
+ let x = rect.right + 8
892
+ let y = rect.top + rect.height / 2 - popH / 2
893
+ if (x + popW > window.innerWidth - 16) x = rect.left - popW - 8
894
+ if (y + popH > window.innerHeight - 16) y = window.innerHeight - popH - 16
895
+ if (y < 16) y = 16
896
+ setSelectedBooking(booking)
897
+ setPopoverAnchor({ x, y })
898
+ setPopoverVisible(true)
899
+ }
900
+ }, 400)
901
+ }} />
902
+ </div>
903
+ )
904
+ }