@fayz-ai/plugin-agenda 0.9.0 → 0.9.1

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 (153) hide show
  1. package/README.md +3 -3
  2. package/dist/bridges/create-financial-bridge.d.ts +1 -1
  3. package/dist/bridges/create-financial-bridge.d.ts.map +1 -1
  4. package/dist/bridges/financial-bridge.d.ts.map +1 -0
  5. package/dist/{chunk-Q2PAWBPR.js → chunk-76ZOHRBM.js} +2 -2
  6. package/dist/{chunk-Q2PAWBPR.js.map → chunk-76ZOHRBM.js.map} +1 -1
  7. package/dist/components/PaymentStatusBadge.d.ts +1 -1
  8. package/dist/components/PaymentStatusBadge.d.ts.map +1 -1
  9. package/dist/config.d.ts +2 -2
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/{AgendaContext.d.ts → context.d.ts} +1 -1
  12. package/dist/context.d.ts.map +1 -0
  13. package/dist/data/registries.d.ts.map +1 -0
  14. package/dist/index.d.ts +2 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +170 -7
  17. package/dist/index.js.map +1 -1
  18. package/dist/lib/message-events.d.ts +12 -0
  19. package/dist/lib/message-events.d.ts.map +1 -0
  20. package/dist/public/{data.d.ts → data/index.d.ts} +4 -4
  21. package/dist/public/data/index.d.ts.map +1 -0
  22. package/dist/public/{data.supabase.d.ts → data/supabase.d.ts} +2 -2
  23. package/dist/public/data/supabase.d.ts.map +1 -0
  24. package/dist/public/{hooks.d.ts → hooks/index.d.ts} +4 -4
  25. package/dist/public/hooks/index.d.ts.map +1 -0
  26. package/dist/public/index.d.ts +4 -4
  27. package/dist/public/index.d.ts.map +1 -1
  28. package/dist/public/index.js +3 -3
  29. package/dist/public/index.js.map +1 -1
  30. package/dist/public/{format.d.ts → lib/format.d.ts} +1 -1
  31. package/dist/public/lib/format.d.ts.map +1 -0
  32. package/dist/public/views/BookingWidget.d.ts.map +1 -0
  33. package/dist/public/{steps → views/steps}/ContactStep.d.ts +1 -1
  34. package/dist/public/views/steps/ContactStep.d.ts.map +1 -0
  35. package/dist/public/{steps → views/steps}/DateTimeStep.d.ts +3 -3
  36. package/dist/public/views/steps/DateTimeStep.d.ts.map +1 -0
  37. package/dist/public/{steps → views/steps}/OverviewStep.d.ts +1 -1
  38. package/dist/public/views/steps/OverviewStep.d.ts.map +1 -0
  39. package/dist/public/{steps → views/steps}/PaymentStep.d.ts +1 -1
  40. package/dist/public/views/steps/PaymentStep.d.ts.map +1 -0
  41. package/dist/store.d.ts +1 -1
  42. package/dist/store.d.ts.map +1 -1
  43. package/dist/styles/calendar-styles.d.ts.map +1 -0
  44. package/dist/types.d.ts +1 -1
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/{AgendaPage.d.ts → views/AgendaPage.d.ts} +3 -3
  47. package/dist/views/AgendaPage.d.ts.map +1 -0
  48. package/package.json +6 -9
  49. package/dist/AgendaContext.d.ts.map +0 -1
  50. package/dist/AgendaPage.d.ts.map +0 -1
  51. package/dist/calendar-styles.d.ts.map +0 -1
  52. package/dist/financial-bridge.d.ts.map +0 -1
  53. package/dist/public/BookingWidget.d.ts.map +0 -1
  54. package/dist/public/data.d.ts.map +0 -1
  55. package/dist/public/data.supabase.d.ts.map +0 -1
  56. package/dist/public/format.d.ts.map +0 -1
  57. package/dist/public/hooks.d.ts.map +0 -1
  58. package/dist/public/steps/ContactStep.d.ts.map +0 -1
  59. package/dist/public/steps/DateTimeStep.d.ts.map +0 -1
  60. package/dist/public/steps/OverviewStep.d.ts.map +0 -1
  61. package/dist/public/steps/PaymentStep.d.ts.map +0 -1
  62. package/dist/registries.d.ts.map +0 -1
  63. package/src/AgendaContext.tsx +0 -11
  64. package/src/AgendaPage.tsx +0 -33
  65. package/src/bridges/create-financial-bridge.ts +0 -296
  66. package/src/calendar-styles.css +0 -245
  67. package/src/calendar-styles.ts +0 -285
  68. package/src/components/AgendaGeneralSettings.tsx +0 -86
  69. package/src/components/AgendaMobileList.tsx +0 -103
  70. package/src/components/AppointmentModal.tsx +0 -1050
  71. package/src/components/AppointmentPopover.tsx +0 -231
  72. package/src/components/BookingPaymentPanel.tsx +0 -394
  73. package/src/components/CalendarSidebar.tsx +0 -119
  74. package/src/components/CalendarToolbar.tsx +0 -86
  75. package/src/components/EventContextMenu.tsx +0 -125
  76. package/src/components/FloatingPanel.tsx +0 -123
  77. package/src/components/MiniCalendar.tsx +0 -120
  78. package/src/components/PaymentStatusBadge.tsx +0 -62
  79. package/src/components/StatusBadge.tsx +0 -23
  80. package/src/config.ts +0 -368
  81. package/src/data/index.ts +0 -4
  82. package/src/data/mock.ts +0 -344
  83. package/src/data/supabase.ts +0 -636
  84. package/src/data/types.ts +0 -37
  85. package/src/financial-bridge.ts +0 -96
  86. package/src/hooks/useAgendaSettings.ts +0 -17
  87. package/src/index.ts +0 -297
  88. package/src/integrations/google-calendar/GoogleCalendarSettings.tsx +0 -136
  89. package/src/integrations/google-calendar/components/CalendarColorDot.tsx +0 -23
  90. package/src/integrations/google-calendar/components/ChannelMappingList.tsx +0 -329
  91. package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +0 -215
  92. package/src/integrations/google-calendar/components/useAgendaLookups.ts +0 -100
  93. package/src/integrations/google-calendar/connector.ts +0 -20
  94. package/src/integrations/google-calendar/connectorDef.tsx +0 -185
  95. package/src/integrations/google-calendar/data/supabase.ts +0 -170
  96. package/src/integrations/google-calendar/data/tables.ts +0 -12
  97. package/src/integrations/google-calendar/index.ts +0 -45
  98. package/src/integrations/google-calendar/integration.test.ts +0 -199
  99. package/src/integrations/google-calendar/mapping.ts +0 -183
  100. package/src/integrations/google-calendar/migrations/001_google_calendar.sql +0 -97
  101. package/src/integrations/google-calendar/migrations/002_smart_sync.sql +0 -517
  102. package/src/integrations/google-calendar/migrations/003_plg_rename.sql +0 -273
  103. package/src/integrations/google-calendar/types.ts +0 -142
  104. package/src/lib/tenant.ts +0 -3
  105. package/src/locales/en.ts +0 -220
  106. package/src/locales/index.ts +0 -7
  107. package/src/locales/pt-BR.ts +0 -220
  108. package/src/migrations/000b_v_bookings_rename.sql +0 -21
  109. package/src/migrations/000c_dedupe_customer_phones.sql +0 -43
  110. package/src/migrations/001_public_booking.sql +0 -296
  111. package/src/migrations/002_v_bookings_compat.sql +0 -14
  112. package/src/migrations/003_public_client_kind.sql +0 -175
  113. package/src/migrations/004_public_booking_assignee.sql +0 -176
  114. package/src/migrations/005_agent_rpcs.sql +0 -168
  115. package/src/migrations/006_agent_rpc_localtime.sql +0 -181
  116. package/src/migrations/007_schedule_exception_precedence.sql +0 -290
  117. package/src/migrations/008_agent_rpc_exception_precedence.sql +0 -172
  118. package/src/migrations/009_public_booking_location.sql +0 -162
  119. package/src/migrations/010_public_service_curation.sql +0 -31
  120. package/src/migrations/011_default_assignee.sql +0 -167
  121. package/src/migrations/012_drop_legacy_booking_overload.sql +0 -29
  122. package/src/migrations/013_normalize_v_appointments.sql +0 -199
  123. package/src/migrations/014_agent_rpc_update_appointment.sql +0 -199
  124. package/src/migrations/index.ts +0 -2377
  125. package/src/public/BookingWidget.tsx +0 -324
  126. package/src/public/components/BrandHeader.tsx +0 -27
  127. package/src/public/components/ServiceCard.tsx +0 -39
  128. package/src/public/components/SidebarExtras.tsx +0 -30
  129. package/src/public/components/StepRailItem.tsx +0 -27
  130. package/src/public/components/SuccessPanel.tsx +0 -27
  131. package/src/public/components/SummaryRail.tsx +0 -90
  132. package/src/public/components/defaults.ts +0 -14
  133. package/src/public/context.tsx +0 -103
  134. package/src/public/createPublicBookingPlugin.ts +0 -281
  135. package/src/public/data.supabase.ts +0 -97
  136. package/src/public/data.ts +0 -105
  137. package/src/public/format.ts +0 -96
  138. package/src/public/hooks.ts +0 -110
  139. package/src/public/index.ts +0 -69
  140. package/src/public/steps/ContactStep.tsx +0 -184
  141. package/src/public/steps/DateTimeStep.tsx +0 -138
  142. package/src/public/steps/OverviewStep.tsx +0 -81
  143. package/src/public/steps/PaymentStep.tsx +0 -180
  144. package/src/public/types.ts +0 -250
  145. package/src/registries.ts +0 -35
  146. package/src/store.ts +0 -311
  147. package/src/types.ts +0 -273
  148. package/src/views/CalendarView.tsx +0 -1112
  149. package/src/views/ConfirmationsView.tsx +0 -155
  150. /package/dist/{financial-bridge.d.ts → bridges/financial-bridge.d.ts} +0 -0
  151. /package/dist/{registries.d.ts → data/registries.d.ts} +0 -0
  152. /package/dist/public/{BookingWidget.d.ts → views/BookingWidget.d.ts} +0 -0
  153. /package/dist/{calendar-styles.d.ts → styles/calendar-styles.d.ts} +0 -0
@@ -1,1112 +0,0 @@
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, useDataChanged } 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 { AgendaMobileList } from '../components/AgendaMobileList'
23
- import { ConfirmDialog } from '@fayz-ai/ui'
24
- import { Button } from '@fayz-ai/ui'
25
- import type { CalendarBooking, Schedule } from '../types'
26
-
27
- // ---------------------------------------------------------------------------
28
- // Helpers
29
- // ---------------------------------------------------------------------------
30
-
31
- function bookingToEvent(booking: CalendarBooking, statusColors: Record<string, string>): EventInput {
32
- const color = statusColors[booking.status] ?? '#6b7280'
33
- const serviceNames = booking.services?.map((s) => s.name).join(', ') ?? ''
34
- return {
35
- id: booking.id,
36
- resourceId: booking.professionalId ?? undefined,
37
- title: booking.clientName ?? 'Unknown',
38
- start: booking.startsAt,
39
- end: booking.endsAt ?? undefined,
40
- backgroundColor: color + '30',
41
- borderColor: color,
42
- textColor: color,
43
- extendedProps: { booking, serviceNames },
44
- }
45
- }
46
-
47
- /**
48
- * Generate FullCalendar background events showing unavailable time.
49
- * For each day, creates background blocks for the gaps OUTSIDE working hours
50
- * within the visible business hours window. This grays out non-working time.
51
- */
52
- function generateAvailabilityEvents(
53
- schedules: Schedule[],
54
- professionals: { id: string }[],
55
- dateRange: { start: string; end: string } | null,
56
- businessHours?: { startTime: string; endTime: string },
57
- locationMap?: Map<string, string>,
58
- ): EventInput[] {
59
- if (!dateRange || schedules.length === 0) return []
60
-
61
- const bgEvents: EventInput[] = []
62
- const startDate = new Date(dateRange.start)
63
- const endDate = new Date(dateRange.end)
64
- const bizStart = businessHours?.startTime ?? '08:00'
65
- const bizEnd = businessHours?.endTime ?? '20:00'
66
-
67
- // Group schedules by assignee
68
- const byAssignee = new Map<string, Schedule[]>()
69
- for (const s of schedules) {
70
- const aid = s.assigneeId
71
- if (!aid) continue
72
- const list = byAssignee.get(aid) ?? []
73
- list.push(s)
74
- byAssignee.set(aid, list)
75
- }
76
-
77
- function timeToMinutes(t: string): number {
78
- const [h, m] = t.split(':').map(Number)
79
- return h * 60 + m
80
- }
81
-
82
- function minutesToTime(m: number): string {
83
- return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
84
- }
85
-
86
- const bizStartMin = timeToMinutes(bizStart)
87
- const bizEndMin = timeToMinutes(bizEnd)
88
-
89
- for (const prof of professionals) {
90
- const profSchedules = byAssignee.get(prof.id) ?? []
91
-
92
- // Separate weekly and date-specific schedules
93
- const weekly = profSchedules.filter((s) => s.dayOfWeek != null && !s.specificDate)
94
- const exceptions = profSchedules.filter((s) => s.specificDate != null)
95
- const exceptionDates = new Set(exceptions.map((s) => s.specificDate!))
96
-
97
- const current = new Date(startDate)
98
- while (current < endDate) {
99
- const dateStr = current.toISOString().slice(0, 10)
100
- const dayOfWeek = current.getDay()
101
-
102
- // Get working periods for this day
103
- let daySchedules: Schedule[]
104
- if (exceptionDates.has(dateStr)) {
105
- daySchedules = exceptions.filter((s) => s.specificDate === dateStr && s.isActive)
106
- } else {
107
- daySchedules = weekly.filter((s) => s.dayOfWeek === dayOfWeek && s.isActive)
108
- }
109
-
110
- // Convert working periods to sorted minute ranges, preserving locationId
111
- const workRanges = daySchedules
112
- .map((s) => {
113
- const locId = s.locationId ?? (s.metadata?.locationId as string | undefined)
114
- return {
115
- start: timeToMinutes(s.startsAt),
116
- end: timeToMinutes(s.endsAt),
117
- locationName: locId && locationMap ? locationMap.get(locId) : undefined,
118
- }
119
- })
120
- .sort((a, b) => a.start - b.start)
121
-
122
- // Compute unavailable gaps within business hours
123
- let cursor = bizStartMin
124
- for (const range of workRanges) {
125
- const availStart = Math.max(range.start, bizStartMin)
126
- const availEnd = Math.min(range.end, bizEndMin)
127
-
128
- // Gap before this working range = unavailable
129
- if (cursor < availStart) {
130
- bgEvents.push({
131
- resourceId: prof.id,
132
- start: dateStr + 'T' + minutesToTime(cursor) + ':00',
133
- end: dateStr + 'T' + minutesToTime(availStart) + ':00',
134
- display: 'background',
135
- classNames: ['fc-unavailable-bg'],
136
- })
137
- }
138
-
139
- // Location label on available range
140
- if (availStart < availEnd && range.locationName) {
141
- bgEvents.push({
142
- id: `loc-${prof.id}-${dateStr}-${minutesToTime(availStart)}`,
143
- resourceId: prof.id,
144
- start: dateStr + 'T' + minutesToTime(availStart) + ':00',
145
- end: dateStr + 'T' + minutesToTime(availStart + 20) + ':00',
146
- title: range.locationName,
147
- display: 'auto',
148
- editable: false,
149
- backgroundColor: 'transparent',
150
- borderColor: 'transparent',
151
- textColor: '#a3a3a3',
152
- classNames: ['fc-location-label'],
153
- extendedProps: { isLocationLabel: true },
154
- })
155
- }
156
-
157
- cursor = Math.max(cursor, availEnd)
158
- }
159
-
160
- // Gap after last working range = unavailable
161
- if (cursor < bizEndMin) {
162
- bgEvents.push({
163
- resourceId: prof.id,
164
- start: dateStr + 'T' + minutesToTime(cursor) + ':00',
165
- end: dateStr + 'T' + minutesToTime(bizEndMin) + ':00',
166
- display: 'background',
167
- classNames: ['fc-unavailable-bg'],
168
- })
169
- }
170
-
171
-
172
- current.setDate(current.getDate() + 1)
173
- }
174
- }
175
-
176
- return bgEvents
177
- }
178
-
179
- // ---------------------------------------------------------------------------
180
- // Loading skeleton — mimics the resource-timegrid-week layout
181
- // ---------------------------------------------------------------------------
182
-
183
- function CalendarSkeleton({ profCount = 2 }: { profCount?: number }) {
184
- const days = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']
185
- 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']
186
-
187
- return (
188
- <div className="flex-1 overflow-hidden animate-pulse">
189
- <div className="flex border-b" style={{ paddingLeft: 56 }}>
190
- {Array.from({ length: profCount }).map((_, pi) => (
191
- <div key={pi} className="flex-1 border-r last:border-r-0 py-2 px-3">
192
- <div className="flex items-center justify-center gap-2">
193
- <div className="h-6 w-6 rounded-full bg-muted" />
194
- <div className="h-3 w-20 rounded bg-muted" />
195
- </div>
196
- </div>
197
- ))}
198
- </div>
199
- <div className="flex border-b" style={{ paddingLeft: 56 }}>
200
- {Array.from({ length: profCount }).map((_, pi) => (
201
- <div key={pi} className="flex-1 flex border-r last:border-r-0">
202
- {days.map((d) => (
203
- <div key={d} className="flex-1 py-1.5 text-center">
204
- <span className="text-[10px] text-muted-foreground/40">{d}</span>
205
- </div>
206
- ))}
207
- </div>
208
- ))}
209
- </div>
210
- <div className="relative">
211
- {hours.map((h) => (
212
- <div key={h} className="flex border-b border-border/30" style={{ height: 50 }}>
213
- <div className="w-14 shrink-0 pr-2 text-right">
214
- <span className="text-[10px] text-muted-foreground/30">{h}</span>
215
- </div>
216
- <div className="flex-1 flex">
217
- {Array.from({ length: profCount * 7 }).map((_, ci) => (
218
- <div key={ci} className={`flex-1 border-r border-border/20 ${Math.floor(ci / 7) % 2 === 1 ? 'bg-muted/20' : ''}`} />
219
- ))}
220
- </div>
221
- </div>
222
- ))}
223
- </div>
224
- </div>
225
- )
226
- }
227
-
228
- // Resource views — one column per professional. Used when the app has staff.
229
- const RESOURCE_VIEW_OPTIONS = [
230
- { key: 'resourceTimeGridDay', labelKey: 'agenda.calendar.viewDay' },
231
- { key: 'resourceTimeGridWeek', labelKey: 'agenda.calendar.viewWeek' },
232
- { key: 'dayGridMonth', labelKey: 'agenda.calendar.viewMonth' },
233
- ] as const
234
-
235
- // Non-resource variants — used when there are no professionals/resources (e.g. a
236
- // personal-finance agenda). A resource timegrid renders an EMPTY grid with zero
237
- // resource columns and drops events that have no resourceId, so those apps fall
238
- // back to a plain month/day/week calendar.
239
- const PLAIN_VIEW_OPTIONS = [
240
- { key: 'timeGridDay', labelKey: 'agenda.calendar.viewDay' },
241
- { key: 'timeGridWeek', labelKey: 'agenda.calendar.viewWeek' },
242
- { key: 'dayGridMonth', labelKey: 'agenda.calendar.viewMonth' },
243
- { key: 'listMonth', labelKey: 'agenda.calendar.viewList' },
244
- ] as const
245
-
246
- // Sentinel view key for the thumb-friendly mobile agenda list (AgendaMobileList),
247
- // which is NOT a FullCalendar view. Selected by default on <md so B2C opens on the
248
- // list; picking any grid option (Dia/Semana/Mês) mounts the real calendar instead.
249
- const MOBILE_LIST_VIEW = '__mobileList__'
250
-
251
- /** Collapse a resource view to a plain month calendar when there are no resources. */
252
- function viewForNoResources(view: string): string {
253
- return view.startsWith('resource') ? 'dayGridMonth' : view
254
- }
255
-
256
- /** Promote a plain timegrid to its resource variant when the app has resources. */
257
- function viewForResources(view: string): string {
258
- if (view === 'timeGridWeek') return 'resourceTimeGridWeek'
259
- if (view === 'timeGridDay') return 'resourceTimeGridDay'
260
- return view
261
- }
262
-
263
- /** Small viewport (<md) → render the thumb-friendly agenda list instead of the grid. */
264
- function useIsMobile(): boolean {
265
- const [isMobile, setIsMobile] = useState(() =>
266
- typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches)
267
- useEffect(() => {
268
- if (typeof window === 'undefined') return
269
- const mq = window.matchMedia('(max-width: 767px)')
270
- const onChange = () => setIsMobile(mq.matches)
271
- mq.addEventListener('change', onChange)
272
- return () => mq.removeEventListener('change', onChange)
273
- }, [])
274
- return isMobile
275
- }
276
-
277
- // ---------------------------------------------------------------------------
278
- // Calendar View
279
- // ---------------------------------------------------------------------------
280
-
281
- export function CalendarView() {
282
- injectCalendarStyles()
283
- const config = useAgendaConfig()
284
- const t = useTranslation()
285
- const currentLocale = getCurrentLocale()
286
- const fcLocale = currentLocale === 'pt-BR' ? ptBrLocale : undefined
287
- const calendarRef = useRef<FullCalendar>(null)
288
- const prefs = usePluginPrefs('agenda', { calendarView: config.defaultCalendarView ?? 'resourceTimeGridWeek' })
289
- const agendaSettings = useAgendaSettings()
290
- const effectiveBusinessHours = {
291
- startTime: agendaSettings.startTime,
292
- endTime: agendaSettings.endTime,
293
- }
294
-
295
- const bookings = useAgendaStore((s) => s.bookings)
296
- const bookingsLoading = useAgendaStore((s) => s.bookingsLoading)
297
- const professionals = useAgendaStore((s) => s.professionals)
298
- const professionalsLoading = useAgendaStore((s) => s.professionalsLoading)
299
- const currentView = useAgendaStore((s) => s.currentView)
300
- const selectedProfIds = useAgendaStore((s) => s.selectedProfessionalIds)
301
- const selectedStatuses = useAgendaStore((s) => s.selectedStatuses)
302
- const schedules = useAgendaStore((s) => s.schedules)
303
- const fetchBookings = useAgendaStore((s) => s.fetchBookings)
304
- const fetchProfessionals = useAgendaStore((s) => s.fetchProfessionals)
305
- const fetchLocations = useAgendaStore((s) => s.fetchLocations)
306
- const storeLocations = useAgendaStore((s) => s.locations)
307
- const fetchSchedules = useAgendaStore((s) => s.fetchSchedules)
308
- const rescheduleBooking = useAgendaStore((s) => s.rescheduleBooking)
309
- const setView = useAgendaStore((s) => s.setView)
310
- const setFilters = useAgendaStore((s) => s.setFilters)
311
- const _openModal = useAgendaStore((s) => s.openAppointmentModal)
312
- const closeModal = useAgendaStore((s) => s.closeAppointmentModal)
313
- const modalState = useAgendaStore((s) => s.appointmentModal)
314
-
315
- // Resource-capable = the app has professionals/staff. When it doesn't (e.g. a
316
- // personal-finance agenda), resource views render empty, so we force plain
317
- // (non-resource) views and default to a month calendar.
318
- const isMobile = useIsMobile()
319
- const resourceCapable = professionals.length > 0
320
- const viewOptions = resourceCapable ? RESOURCE_VIEW_OPTIONS : PLAIN_VIEW_OPTIONS
321
-
322
- // Mobile (<md) has its own view selection, independent of the desktop pref: it
323
- // defaults to the thumb-friendly list and lets the user switch to any grid view
324
- // (Dia/Semana/Mês) without changing the desktop's stored calendarView. The list
325
- // sentinel replaces FullCalendar's own listMonth so mobile always gets the rich
326
- // AgendaMobileList, and a list option is offered even for resource apps.
327
- const [mobileView, setMobileView] = useState<string>(MOBILE_LIST_VIEW)
328
- const mobileViewOptions = useMemo(
329
- () => [
330
- ...viewOptions.filter((o) => o.key !== 'listMonth'),
331
- { key: MOBILE_LIST_VIEW, labelKey: 'agenda.calendar.viewList' },
332
- ],
333
- [viewOptions],
334
- )
335
- const mobileGridActive = isMobile && mobileView !== MOBILE_LIST_VIEW
336
-
337
- const [selectedBooking, setSelectedBooking] = useState<CalendarBooking | null>(null)
338
- const [popoverAnchor, setPopoverAnchor] = useState<{ x: number; y: number } | null>(null)
339
- const [popoverVisible, _setPopoverVisible] = useState(false)
340
- const popoverVisibleRef = useRef(false)
341
- const setPopoverVisible = useCallback((v: boolean) => { popoverVisibleRef.current = v; _setPopoverVisible(v) }, [])
342
- const [contextMenu, setContextMenu] = useState<{ booking: CalendarBooking; x: number; y: number } | null>(null)
343
- const [contextMenuVisible, _setContextMenuVisible] = useState(false)
344
- const contextMenuVisibleRef = useRef(false)
345
- const setContextMenuVisible = useCallback((v: boolean) => { contextMenuVisibleRef.current = v; _setContextMenuVisible(v) }, [])
346
- const [scheduleConfirm, setScheduleConfirm] = useState<{ onConfirm: () => void } | null>(null)
347
- const [calendarTitle, setCalendarTitle] = useState('')
348
- const [selectedDate, setSelectedDate] = useState(new Date())
349
- const [profSectionOpen, setProfSectionOpen] = useState(true)
350
- const [statusSectionOpen, setStatusSectionOpen] = useState(false)
351
- const [visibleRange, setVisibleRange] = useState<{ start: string; end: string } | null>(null)
352
-
353
- // Wrap openModal to always dismiss popover/context menu first
354
- const openModal: typeof _openModal = useCallback((...args) => {
355
- setPopoverVisible(false); setSelectedBooking(null); setPopoverAnchor(null)
356
- setContextMenuVisible(false); setContextMenu(null)
357
- _openModal(...args)
358
- }, [_openModal])
359
-
360
- const statusColors = useMemo(() => {
361
- const map: Record<string, string> = {}
362
- for (const s of config.statuses) map[s.value] = s.color
363
- return map
364
- }, [config.statuses])
365
-
366
- useEffect(() => {
367
- fetchProfessionals(); fetchSchedules()
368
- if (config.modules.locationSelection) fetchLocations()
369
- }, [])
370
-
371
- // Keep the view consistent with resource-capability, in case a stale/shared
372
- // pref selected the wrong variant. No resources → a resource view is an empty
373
- // grid that drops the seeded events; resources → a plain timegrid loses the
374
- // per-professional columns.
375
- useEffect(() => {
376
- const api = calendarRef.current?.getApi()
377
- if (!api) return
378
- if (!resourceCapable && currentView.startsWith('resource')) {
379
- api.changeView('dayGridMonth')
380
- } else if (resourceCapable && (currentView === 'timeGridWeek' || currentView === 'timeGridDay')) {
381
- api.changeView(viewForResources(currentView))
382
- }
383
- }, [resourceCapable, currentView])
384
-
385
- // Mobile renders the agenda list (no FullCalendar), so the grid's datesSet —
386
- // which normally drives fetchBookings — never fires. Fetch a wide upcoming
387
- // window directly so the list has data.
388
- useEffect(() => {
389
- if (!isMobile) return
390
- const start = new Date(); start.setHours(0, 0, 0, 0); start.setDate(start.getDate() - 1)
391
- const end = new Date(start); end.setDate(end.getDate() + 91)
392
- fetchBookings({ start: start.toISOString(), end: end.toISOString() })
393
- }, [isMobile, fetchBookings])
394
-
395
- // A booking created outside the grid — the assistant, the public page,
396
- // another device — repaints the calendar it belongs to. Without this the
397
- // manager who just said "agenda a Maria amanhã às 10" still sees yesterday's
398
- // grid and reloads the tab by hand.
399
- useDataChanged({}, () => {
400
- if (visibleRange) {
401
- fetchBookings(visibleRange)
402
- return
403
- }
404
- const start = new Date(); start.setHours(0, 0, 0, 0); start.setDate(start.getDate() - 1)
405
- const end = new Date(start); end.setDate(end.getDate() + 91)
406
- fetchBookings({ start: start.toISOString(), end: end.toISOString() })
407
- }, [visibleRange, fetchBookings])
408
-
409
- const resources = useMemo(() => {
410
- const filtered = selectedProfIds.length > 0
411
- ? professionals.filter((p) => selectedProfIds.includes(p.id))
412
- : professionals
413
- return filtered.map((p) => ({
414
- id: p.id, title: p.name,
415
- extendedProps: { avatarUrl: p.avatarUrl, locationName: p.locationName },
416
- }))
417
- }, [professionals, selectedProfIds])
418
-
419
- const bookingEvents = useMemo(() =>
420
- bookings.map((b) => bookingToEvent(b, statusColors)), [bookings, statusColors])
421
-
422
- // Merge static config locations with dynamically fetched locations
423
- const allLocations = useMemo(() => {
424
- const map = new Map<string, string>()
425
- for (const loc of config.locations) map.set(loc.id, loc.name)
426
- for (const loc of storeLocations) map.set(loc.id, loc.name)
427
- return map
428
- }, [config.locations, storeLocations])
429
-
430
- const locationMap = useMemo(() => {
431
- if (!config.modules.locationSelection || allLocations.size === 0) return undefined
432
- return allLocations
433
- }, [config.modules.locationSelection, allLocations])
434
-
435
- const availabilityEvents = useMemo(() =>
436
- generateAvailabilityEvents(schedules, professionals, visibleRange, effectiveBusinessHours, locationMap),
437
- [schedules, professionals, visibleRange, effectiveBusinessHours, locationMap])
438
-
439
- const events = useMemo(() =>
440
- [...bookingEvents, ...availabilityEvents], [bookingEvents, availabilityEvents])
441
-
442
- const storedView = prefs.get('calendarView')
443
- const effectiveInitialView = resourceCapable ? viewForResources(storedView) : viewForNoResources(storedView)
444
-
445
- // ── Calendar handlers ──
446
- const handleDatesSet = useCallback((arg: { startStr: string; endStr: string; view: { title: string; type: string } }) => {
447
- setCalendarTitle(arg.view.title)
448
- setView(arg.view.type)
449
- setVisibleRange({ start: arg.startStr, end: arg.endStr })
450
- fetchBookings({ start: arg.startStr, end: arg.endStr })
451
- }, [fetchBookings, setView])
452
-
453
- // Double-click → open edit directly
454
- // Right-click → context menu
455
- const handleEventDidMount = useCallback((arg: { event: any; el: HTMLElement }) => {
456
- const booking = arg.event.extendedProps?.booking as CalendarBooking | undefined
457
- if (booking) arg.el.setAttribute('data-booking-id', booking.id)
458
- arg.el.addEventListener('dblclick', (e) => {
459
- e.preventDefault()
460
- e.stopPropagation()
461
- skipClickRef.current = true
462
- const booking = arg.event.extendedProps.booking as CalendarBooking
463
- setSelectedBooking(null)
464
- setPopoverAnchor(null)
465
- setContextMenu(null)
466
- openModal('edit', { bookingId: booking.id })
467
- })
468
- arg.el.addEventListener('contextmenu', (e) => {
469
- e.preventDefault()
470
- const booking = arg.event.extendedProps.booking as CalendarBooking
471
- const menuW = 210, menuH = 200
472
- let x = e.clientX + 4
473
- let y = e.clientY + 4
474
- if (x + menuW > window.innerWidth - 12) x = x - menuW
475
- if (y + menuH > window.innerHeight - 12) y = y - menuH
476
- setContextMenu({ booking, x, y })
477
- setContextMenuVisible(true)
478
- setSelectedBooking(null)
479
- setPopoverAnchor(null)
480
- })
481
- }, [])
482
-
483
- // Flag to suppress single-click after a double-click
484
- const skipClickRef = useRef(false)
485
-
486
- // Hover timer — show popover after 4s hover on a booking event
487
- const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
488
-
489
- const handleEventMouseEnter = useCallback((arg: { event: any; el: HTMLElement }) => {
490
- if (arg.event.display === 'background') return
491
- if (arg.event.extendedProps?.isLocationLabel) return
492
- const booking = arg.event.extendedProps.booking as CalendarBooking
493
- if (!booking) return
494
-
495
- hoverTimerRef.current = setTimeout(() => {
496
- const rect = arg.el.getBoundingClientRect()
497
- const popW = 320, popH = 340
498
- let x = rect.right + 8
499
- let y = rect.top + rect.height / 2 - popH / 2
500
- if (x + popW > window.innerWidth - 16) x = rect.left - popW - 8
501
- if (x < 16) x = Math.max(16, rect.left + rect.width / 2 - popW / 2)
502
- if (y + popH > window.innerHeight - 16) y = window.innerHeight - popH - 16
503
- if (y < 16) y = 16
504
- setSelectedBooking(booking)
505
- setPopoverAnchor({ x, y })
506
- setPopoverVisible(true)
507
- }, 3000)
508
- }, [])
509
-
510
- const handleEventMouseLeave = useCallback(() => {
511
- if (hoverTimerRef.current) {
512
- clearTimeout(hoverTimerRef.current)
513
- hoverTimerRef.current = null
514
- }
515
- }, [])
516
-
517
- // Single click — open popover instantly, or slide to new position if already open
518
- const handleEventClick = useCallback((arg: EventClickArg) => {
519
- if (skipClickRef.current) { skipClickRef.current = false; return }
520
- // Ignore clicks on background availability events and location labels
521
- if (arg.event.display === 'background') return
522
- if (arg.event.extendedProps?.isLocationLabel) return
523
- setContextMenu(null); setContextMenuVisible(false)
524
- const booking = arg.event.extendedProps.booking as CalendarBooking
525
- if (!booking) return
526
- const rect = arg.el.getBoundingClientRect()
527
- const popW = 320, popH = 340
528
- let x = rect.right + 8
529
- let y = rect.top + rect.height / 2 - popH / 2
530
- if (x + popW > window.innerWidth - 16) x = rect.left - popW - 8
531
- if (x < 16) x = Math.max(16, rect.left + rect.width / 2 - popW / 2)
532
- if (y + popH > window.innerHeight - 16) y = window.innerHeight - popH - 16
533
- if (y < 16) y = 16
534
- // Always update — if popover is already open it slides to the new position
535
- setSelectedBooking(booking)
536
- setPopoverAnchor({ x, y })
537
- setPopoverVisible(true)
538
- }, [])
539
-
540
- // Find the schedule block covering a given time slot for a professional
541
- const findScheduleForSlot = useCallback((professionalId: string | undefined, dateTimeStr: string): Schedule | null => {
542
- if (!professionalId) return null
543
- const dt = new Date(dateTimeStr)
544
- const dateStr = dt.toISOString().slice(0, 10)
545
- const dayOfWeek = dt.getDay()
546
- const timeMin = dt.getHours() * 60 + dt.getMinutes()
547
-
548
- const profSchedules = schedules.filter((s) => s.assigneeId === professionalId)
549
- const exceptions = profSchedules.filter((s) => s.specificDate === dateStr && s.isActive)
550
- const weekly = profSchedules.filter((s) => s.dayOfWeek === dayOfWeek && s.isActive && !s.specificDate)
551
- const daySchedules = exceptions.length > 0 ? exceptions : weekly
552
-
553
- for (const s of daySchedules) {
554
- const [sh, sm] = s.startsAt.split(':').map(Number)
555
- const [eh, em] = s.endsAt.split(':').map(Number)
556
- if (timeMin >= sh * 60 + sm && timeMin < eh * 60 + em) return s
557
- }
558
- return null
559
- }, [schedules])
560
-
561
- const handleSelect = useCallback((arg: DateSelectArg) => {
562
- calendarRef.current?.getApi().unselect()
563
-
564
- // Don't open create if popover/context menu is showing — dismiss them instead
565
- if (popoverVisibleRef.current || contextMenuVisibleRef.current) {
566
- setPopoverVisible(false); setSelectedBooking(null); setPopoverAnchor(null)
567
- setContextMenuVisible(false); setContextMenu(null)
568
- return
569
- }
570
-
571
- const professionalId = arg.resource?.id
572
- const schedule = findScheduleForSlot(professionalId, arg.startStr)
573
-
574
- const proceed = () => {
575
- const locId = schedule?.locationId ?? (schedule?.metadata?.locationId as string | undefined)
576
- openModal('create', { prefill: { startsAt: arg.startStr, endsAt: arg.endStr, professionalId, locationId: locId || undefined } })
577
- }
578
-
579
- // Warn if no schedule covers this slot — only when the working-hours module
580
- // is on (off by default in eventMode:'simple', so simple/Google-Calendar
581
- // events never surface the "no open schedule for this professional" dialog).
582
- if (config.modules.workingHours && !schedule) {
583
- setScheduleConfirm({ onConfirm: proceed })
584
- return
585
- }
586
-
587
- proceed()
588
- }, [openModal, findScheduleForSlot, config.modules.workingHours])
589
-
590
- const handleEventDrop = useCallback(async (arg: EventDropArg) => {
591
- const booking = arg.event.extendedProps.booking as CalendarBooking
592
- try { await rescheduleBooking(booking.id, arg.event.startStr, arg.event.endStr, arg.newResource?.id ?? undefined) }
593
- catch { arg.revert() }
594
- }, [rescheduleBooking])
595
-
596
- const handleViewChange = useCallback((v: string) => {
597
- calendarRef.current?.getApi().changeView(v)
598
- prefs.set('calendarView', v)
599
- }, [prefs])
600
- // Mobile switcher: swap between the list sentinel and a real grid view. When a
601
- // grid is picked we also sync the store's currentView so the resource-collapse
602
- // effect doesn't fight the freshly-mounted mobile calendar; if it's already
603
- // mounted (grid → grid), changeView animates in place.
604
- const handleMobileViewChange = useCallback((v: string) => {
605
- setMobileView(v)
606
- if (v === MOBILE_LIST_VIEW) return
607
- setView(v)
608
- calendarRef.current?.getApi()?.changeView(v)
609
- }, [setView])
610
- const handlePrev = useCallback(() => calendarRef.current?.getApi().prev(), [])
611
- const handleNext = useCallback(() => calendarRef.current?.getApi().next(), [])
612
- const handleToday = useCallback(() => { calendarRef.current?.getApi().today(); setSelectedDate(new Date()) }, [])
613
-
614
- function handleMiniDateSelect(date: Date) {
615
- setSelectedDate(date)
616
- calendarRef.current?.getApi().gotoDate(date)
617
- }
618
-
619
- function toggleProfessional(id: string) {
620
- const next = selectedProfIds.includes(id)
621
- ? selectedProfIds.filter((p) => p !== id)
622
- : [...selectedProfIds, id]
623
- setFilters({ professionalIds: next })
624
- }
625
-
626
- function toggleStatus(value: string) {
627
- const next = selectedStatuses.includes(value)
628
- ? selectedStatuses.filter((s) => s !== value)
629
- : [...selectedStatuses, value]
630
- setFilters({ statuses: next })
631
- }
632
-
633
- // Alternating resource backgrounds — apply via DOM after render
634
- const wrapperRef = useRef<HTMLDivElement>(null)
635
-
636
- // FullCalendar measures its columns once and only re-measures on a WINDOW
637
- // resize. Opening/closing the assistant rail (or the sidebar) resizes the
638
- // container without one, so the grid stayed locked at the narrow width until
639
- // a real window resize came along. Watch the container and hand FC the new box.
640
- useEffect(() => {
641
- const container = wrapperRef.current
642
- if (!container) return
643
- let frame = 0
644
- let lastWidth = container.clientWidth
645
- let lastHeight = container.clientHeight
646
- const ro = new ResizeObserver(() => {
647
- const width = container.clientWidth
648
- const height = container.clientHeight
649
- if (width === lastWidth && height === lastHeight) return
650
- lastWidth = width
651
- lastHeight = height
652
- // Next frame, not inline: measuring inside the observer callback reads a
653
- // mid-layout box (and trips the RO loop warning). The rail animates, so
654
- // this fires on each frame and the last one settles on the final width.
655
- cancelAnimationFrame(frame)
656
- frame = requestAnimationFrame(() => calendarRef.current?.getApi()?.updateSize())
657
- })
658
- ro.observe(container)
659
- return () => { cancelAnimationFrame(frame); ro.disconnect() }
660
- // Re-attach whenever the calendar branch remounts (skeleton → grid, mobile
661
- // list → grid), since the observed node is gone on the other branches.
662
- }, [isMobile, mobileGridActive, professionalsLoading, professionals.length])
663
-
664
- // Responsive day headers — collapse weekday names to a single initial (S M T
665
- // W T F S, Google-Calendar style) when day columns get too narrow. Measured
666
- // from the actual rendered column width so it adapts to resource count + view.
667
- const [dayHeaderCompact, setDayHeaderCompact] = useState(false)
668
- useEffect(() => {
669
- const container = wrapperRef.current
670
- if (!container) return
671
- let frame = 0
672
- const measure = () => {
673
- // Measure an actual DAY column ([data-date]) — in resource views the first
674
- // .fc-col-header-cell is the wide resource header, not a day column.
675
- const cell = container.querySelector('.fc-col-header-cell[data-date]') as HTMLElement | null
676
- if (!cell) return
677
- const colWidth = cell.getBoundingClientRect().width
678
- // Collapse to single-letter ONLY when the full weekday label can't fit.
679
- // Measure the widest full line ("WED" / "12/14") in the header's font so
680
- // the breakpoint adapts to font + locale instead of a magic number.
681
- const cushion = (cell.querySelector('.fc-col-header-cell-cushion') as HTMLElement | null) ?? cell
682
- const cs = getComputedStyle(cushion)
683
- const probe = document.createElement('span')
684
- probe.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;top:-9999px'
685
- probe.style.fontSize = cs.fontSize
686
- probe.style.fontWeight = cs.fontWeight
687
- probe.style.fontFamily = cs.fontFamily
688
- probe.style.letterSpacing = cs.letterSpacing
689
- probe.style.textTransform = cs.textTransform
690
- document.body.appendChild(probe)
691
- probe.textContent = 'Wed'
692
- const weekdayWidth = probe.getBoundingClientRect().width
693
- probe.textContent = '12/14'
694
- const dateWidth = probe.getBoundingClientRect().width
695
- probe.remove()
696
- const needed = Math.max(weekdayWidth, dateWidth) + 12 // text + cushion padding
697
- setDayHeaderCompact(colWidth < needed)
698
- }
699
- // FullCalendar reflows its inner day columns on a later tick than the
700
- // container resize, so re-measure across a few frames to read settled widths.
701
- const timers: ReturnType<typeof setTimeout>[] = []
702
- const schedule = () => {
703
- cancelAnimationFrame(frame)
704
- frame = requestAnimationFrame(measure)
705
- timers.forEach(clearTimeout)
706
- timers.length = 0
707
- timers.push(setTimeout(measure, 130), setTimeout(measure, 330))
708
- }
709
- schedule()
710
- const ro = new ResizeObserver(schedule)
711
- ro.observe(container)
712
- return () => { cancelAnimationFrame(frame); timers.forEach(clearTimeout); ro.disconnect() }
713
- }, [resources, currentView, visibleRange])
714
-
715
- const applyResourceStriping = useCallback(() => {
716
- const container = wrapperRef.current
717
- if (!container) return
718
- const resourceIds = resources.map((r: { id: string }) => r.id)
719
- if (resourceIds.length < 2) return
720
- container.querySelectorAll('[data-resource-id]').forEach((el: Element) => {
721
- const rid = el.getAttribute('data-resource-id')
722
- const idx = rid ? resourceIds.indexOf(rid) : -1
723
- el.classList.toggle('fc-resource-alt', idx % 2 === 1)
724
- })
725
- }, [resources])
726
-
727
- useEffect(() => {
728
- applyResourceStriping()
729
- const t1 = setTimeout(applyResourceStriping, 100)
730
- const t2 = setTimeout(applyResourceStriping, 300)
731
- return () => { clearTimeout(t1); clearTimeout(t2) }
732
- }, [applyResourceStriping, currentView, visibleRange, bookings])
733
-
734
- // Listen for cross-plugin booking open requests (e.g., from financial invoice detail)
735
- useEffect(() => {
736
- function handleOpenBooking(e: Event) {
737
- const detail = (e as CustomEvent).detail ?? {}
738
- const externalSource = detail.source
739
- ? { type: String(detail.source), id: detail.waitlistId, waitlistId: detail.waitlistId }
740
- : undefined
741
- if (detail.bookingId) {
742
- openModal('edit', { bookingId: detail.bookingId, externalSource })
743
- return
744
- }
745
- openModal('create', {
746
- prefill: detail.prefill ?? {
747
- clientId: detail.clientId,
748
- professionalId: detail.professionalId,
749
- locationId: detail.locationId,
750
- startsAt: detail.startsAt,
751
- },
752
- externalSource,
753
- })
754
- }
755
- window.addEventListener('agenda:open-booking', handleOpenBooking)
756
- return () => window.removeEventListener('agenda:open-booking', handleOpenBooking)
757
- }, [openModal])
758
-
759
- // Custom day header: weekday + date stacked. When columns get narrow it
760
- // collapses the weekday to a single initial (S / 16, Google-Calendar style).
761
- // Always returns explicit content — returning undefined renders an EMPTY
762
- // header in the React adapter rather than falling back to the default.
763
- const dayHeaderContent = useCallback((arg: any) => {
764
- const date: Date = arg.date
765
- const dateLocale = currentLocale === 'pt-BR' ? 'pt-BR' : 'en'
766
- // Inline styles (not CSS classes) so stacking is guaranteed regardless of
767
- // the runtime style-injection state.
768
- const wrapStyle: React.CSSProperties = { display: 'flex', flexDirection: 'column', alignItems: 'center', lineHeight: 1.15 }
769
- const dateStyle: React.CSSProperties = { fontSize: '0.65rem', opacity: 0.75 }
770
- if (dayHeaderCompact) {
771
- const letter = date.toLocaleDateString(dateLocale, { weekday: 'narrow' })
772
- return (
773
- <div style={wrapStyle}>
774
- <span style={{ fontWeight: 600 }}>{letter}</span>
775
- <span style={dateStyle}>{date.getDate()}</span>
776
- </div>
777
- )
778
- }
779
- const weekday = date.toLocaleDateString(dateLocale, { weekday: 'short' })
780
- return (
781
- <div style={wrapStyle}>
782
- <span>{weekday}</span>
783
- <span style={dateStyle}>{`${date.getMonth() + 1}/${date.getDate()}`}</span>
784
- </div>
785
- )
786
- }, [dayHeaderCompact, currentLocale])
787
-
788
- const resourceLabelContent = useCallback((arg: any) => {
789
- const { id, title, extendedProps } = arg.resource
790
- const initials = title.split(' ').map((n: string) => n[0]).join('').slice(0, 2)
791
- return (
792
- <div className="flex items-center gap-2 px-2 py-1.5">
793
- <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>
794
- <div className="min-w-0">
795
- <PersonLink
796
- personId={id}
797
- name={title}
798
- size="sm"
799
- tab="schedule"
800
- profileHref={`#/registry/staff/${id}/schedule`}
801
- />
802
- {extendedProps?.locationName && <p className="text-[10px] text-muted-foreground truncate">{extendedProps.locationName}</p>}
803
- </div>
804
- </div>
805
- )
806
- }, [])
807
-
808
- const eventContent = useCallback((arg: any) => {
809
- // Location label events — render as a small tag
810
- if (arg.event.extendedProps?.isLocationLabel) {
811
- return (
812
- <div className="px-1.5 py-0.5 overflow-hidden">
813
- <p className="text-[10px] font-medium truncate">{arg.event.title}</p>
814
- </div>
815
- )
816
- }
817
- const serviceNames = arg.event.extendedProps?.serviceNames
818
- const start = arg.event.start
819
- const end = arg.event.end
820
- const fmt = (d: Date) => `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
821
- const timeRange = start && end ? `${fmt(start)} – ${fmt(end)}` : start ? fmt(start) : ''
822
- return (
823
- <div className="px-1.5 py-0.5 overflow-hidden leading-tight h-full">
824
- <div className="flex items-start justify-between gap-1">
825
- <p className="text-[11px] font-semibold truncate">{arg.event.title}</p>
826
- {serviceNames && <p className="text-[10px] opacity-60 truncate shrink-0 max-w-[45%] text-right">{serviceNames}</p>}
827
- </div>
828
- {timeRange && <p className="text-[9px] opacity-50">{timeRange}</p>}
829
- </div>
830
- )
831
- }, [])
832
-
833
- return (
834
- <div className="flex h-[calc(100vh-7.5rem)]">
835
- {/* ═══ LEFT SIDEBAR — hidden on mobile ═══ */}
836
- <div className="hidden md:flex w-[220px] shrink-0 flex-col overflow-y-auto py-4 px-3 mr-4">
837
- {/* + Create button */}
838
- <button
839
- onClick={() => openModal('create')}
840
- 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"
841
- >
842
- <Plus className="h-4 w-4" />
843
- {t('agenda.sidebar.create')}
844
- </button>
845
-
846
- {/* Mini month calendar */}
847
- <MiniCalendar selectedDate={selectedDate} onDateSelect={handleMiniDateSelect} />
848
-
849
- {/* Agendas (professional filter) — hidden when no booking type uses a
850
- professional (e.g. eventMode:'simple'). */}
851
- {config.capabilities.professional && (<>
852
- <div className="h-px bg-border my-4" />
853
-
854
- <button onClick={() => setProfSectionOpen((p) => !p)}
855
- className="flex items-center justify-between w-full text-xs font-medium text-muted-foreground hover:text-foreground py-1 transition-colors">
856
- <span>Agendas</span>
857
- <ChevronDown className={`h-3.5 w-3.5 transition-transform ${profSectionOpen ? '' : '-rotate-90'}`} />
858
- </button>
859
- {profSectionOpen && (
860
- <div className="mt-1.5 space-y-0.5">
861
- {professionals.map((prof) => {
862
- const active = selectedProfIds.length === 0 || selectedProfIds.includes(prof.id)
863
- return (
864
- <label key={prof.id} className="flex items-center gap-2.5 rounded px-1 py-1 cursor-pointer hover:bg-muted/50 transition-colors">
865
- <span className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border-2 transition-colors ${
866
- active ? 'border-primary bg-primary' : 'border-muted-foreground/30'
867
- }`}>
868
- {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>}
869
- </span>
870
- <span className={`text-xs truncate ${active ? 'text-foreground' : 'text-muted-foreground'}`}>{prof.name}</span>
871
- <input type="checkbox" className="sr-only" checked={active} onChange={() => toggleProfessional(prof.id)} />
872
- </label>
873
- )
874
- })}
875
- </div>
876
- )}
877
- </>)}
878
-
879
- {/* Status filter — hidden when no booking type uses a status workflow
880
- (e.g. eventMode:'simple'). */}
881
- {config.capabilities.status && (<>
882
- <div className="h-px bg-border my-4" />
883
-
884
- <button onClick={() => setStatusSectionOpen((p) => !p)}
885
- className="flex items-center justify-between w-full text-xs font-medium text-muted-foreground hover:text-foreground py-1 transition-colors">
886
- <span>Status</span>
887
- <ChevronDown className={`h-3.5 w-3.5 transition-transform ${statusSectionOpen ? '' : '-rotate-90'}`} />
888
- </button>
889
- {statusSectionOpen && (
890
- <div className="mt-1.5 space-y-0.5">
891
- {config.statuses.map((st) => {
892
- const active = selectedStatuses.length === 0 || selectedStatuses.includes(st.value)
893
- return (
894
- <label key={st.value} className="flex items-center gap-2.5 rounded px-1 py-1 cursor-pointer hover:bg-muted/50 transition-colors">
895
- <span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border-2 transition-colors"
896
- style={{ borderColor: active ? st.color : st.color + '40', backgroundColor: active ? st.color : 'transparent' }}>
897
- {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>}
898
- </span>
899
- <span className={`text-xs ${active ? 'text-foreground' : 'text-muted-foreground'}`}>{st.label}</span>
900
- <input type="checkbox" className="sr-only" checked={active} onChange={() => toggleStatus(st.value)} />
901
- </label>
902
- )
903
- })}
904
- </div>
905
- )}
906
- </>)}
907
- </div>
908
-
909
- {/* ═══ MAIN AREA ═══ */}
910
- <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
911
- {/* Toolbar */}
912
- <div className="shrink-0 border-b">
913
- {/* Row 1 — create + date nav + title (left), desktop switcher + settings
914
- (right). Extra right padding on mobile clears the transparent header's
915
- floating profile avatar, which otherwise overlaps the settings gear. */}
916
- <div className="flex items-center justify-between pl-4 pr-14 md:px-4 h-12">
917
- <div className="flex items-center gap-3 min-w-0">
918
- {/* Mobile create button */}
919
- <button onClick={() => openModal('create')}
920
- className="md:hidden flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
921
- <Plus className="h-4 w-4" />
922
- </button>
923
-
924
- {/* Date navigation drives the FullCalendar api — shown on desktop and,
925
- on mobile, only when a grid view is active (the list paginates
926
- itself, so it has no grid to page through). */}
927
- <div className={`${mobileGridActive ? 'flex' : 'hidden'} md:flex items-center gap-3`}>
928
- <button onClick={handleToday}
929
- 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">
930
- {t('agenda.toolbar.today')}
931
- </button>
932
- <div className="flex items-center gap-0.5">
933
- <button onClick={handlePrev} className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-muted transition-colors">
934
- <ChevronLeft className="h-4 w-4" />
935
- </button>
936
- <button onClick={handleNext} className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-muted transition-colors">
937
- <ChevronRight className="h-4 w-4" />
938
- </button>
939
- </div>
940
- </div>
941
- <h1 className="text-base sm:text-lg font-normal truncate">{isMobile && !mobileGridActive ? t('agenda.calendar.viewAgenda') : calendarTitle}</h1>
942
- </div>
943
-
944
- <div className="flex items-center gap-1 shrink-0">
945
- <div className="hidden md:flex items-center gap-1">
946
- {viewOptions.map((opt) => (
947
- <button key={opt.key} onClick={() => handleViewChange(opt.key)}
948
- className={`rounded-md px-3 py-1 text-sm transition-colors ${
949
- currentView === opt.key
950
- ? 'bg-primary/10 text-primary font-medium'
951
- : 'text-muted-foreground hover:bg-muted/50'
952
- }`}>
953
- {t(opt.labelKey)}
954
- </button>
955
- ))}
956
- </div>
957
- <Button
958
- variant="outline"
959
- size="icon"
960
- onClick={() => window.location.hash = '/settings/agenda'}
961
- title="Agenda Settings"
962
- className="ml-1"
963
- >
964
- <Settings className="h-4 w-4" />
965
- </Button>
966
- </div>
967
- </div>
968
-
969
- {/* Row 2 (mobile only) — compact segmented view switcher on its own row so
970
- it never collides with the floating avatar. Dia/Semana/Mês mount the
971
- calendar; Lista returns to the thumb-friendly agenda list. */}
972
- <div className="flex md:hidden items-center gap-1 overflow-x-auto px-3 pb-2">
973
- {mobileViewOptions.map((opt) => (
974
- <button key={opt.key} onClick={() => handleMobileViewChange(opt.key)}
975
- className={`shrink-0 rounded-md px-3 py-1 text-sm transition-colors ${
976
- mobileView === opt.key
977
- ? 'bg-primary/10 text-primary font-medium'
978
- : 'text-muted-foreground hover:bg-muted/50'
979
- }`}>
980
- {t(opt.labelKey)}
981
- </button>
982
- ))}
983
- </div>
984
- </div>
985
-
986
- {/* Mobile list view → thumb-friendly agenda list; otherwise (desktop, or a
987
- mobile grid view the user switched to) → FullCalendar grid. */}
988
- {isMobile && !mobileGridActive ? (
989
- <AgendaMobileList
990
- bookings={bookings}
991
- statusColors={statusColors}
992
- currencyCode={config.currency.code}
993
- onSelect={(b) => openModal('edit', { bookingId: b.id })}
994
- />
995
- ) : professionalsLoading && professionals.length === 0 ? (
996
- <CalendarSkeleton profCount={2} />
997
- ) : (
998
- <div ref={wrapperRef} className="flex-1 overflow-hidden agenda-calendar">
999
- <FullCalendar
1000
- ref={calendarRef}
1001
- plugins={[dayGridPlugin, timeGridPlugin, resourceTimeGridPlugin, interactionPlugin, listPlugin]}
1002
- initialView={mobileGridActive ? mobileView : effectiveInitialView}
1003
- schedulerLicenseKey="CC-Attribution-NonCommercial-NoDerivatives"
1004
- resources={resourceCapable ? resources : undefined}
1005
- events={events}
1006
- headerToolbar={false}
1007
- locale={fcLocale}
1008
- height="100%"
1009
- slotMinTime={effectiveBusinessHours.startTime + ':00'}
1010
- slotMaxTime={effectiveBusinessHours.endTime + ':00'}
1011
- slotDuration={{ minutes: config.slotDuration }}
1012
- allDaySlot={false}
1013
- nowIndicator={true}
1014
- editable={config.modules.dragAndDrop}
1015
- selectable={true}
1016
- selectMirror={true}
1017
- eventOverlap={false}
1018
- datesSet={handleDatesSet}
1019
- eventDidMount={handleEventDidMount}
1020
- eventClick={handleEventClick}
1021
- eventMouseEnter={handleEventMouseEnter}
1022
- eventMouseLeave={handleEventMouseLeave}
1023
- select={handleSelect}
1024
- eventDrop={handleEventDrop}
1025
- resourceLabelContent={resourceLabelContent}
1026
- dayHeaderContent={dayHeaderContent}
1027
- eventContent={eventContent}
1028
- resourceOrder="title"
1029
- stickyHeaderDates={true}
1030
- expandRows={false}
1031
- eventTimeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
1032
- slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
1033
- />
1034
- </div>
1035
- )}
1036
- </div>
1037
-
1038
- {/* Popover — stays mounted until animation finishes */}
1039
- {selectedBooking && popoverAnchor && popoverVisible && (
1040
- <AppointmentPopover booking={selectedBooking} position={popoverAnchor}
1041
- onClose={() => {
1042
- // Called AFTER the popover's exit animation finishes
1043
- setPopoverVisible(false)
1044
- setSelectedBooking(null)
1045
- setPopoverAnchor(null)
1046
- }}
1047
- onEdit={(id) => {
1048
- setPopoverVisible(false)
1049
- setSelectedBooking(null)
1050
- setPopoverAnchor(null)
1051
- openModal('edit', { bookingId: id })
1052
- }} />
1053
- )}
1054
-
1055
- {/* Context Menu */}
1056
- {contextMenu && (
1057
- <EventContextMenu
1058
- booking={contextMenu.booking}
1059
- position={{ x: contextMenu.x, y: contextMenu.y }}
1060
- onClose={() => { setContextMenuVisible(false); setContextMenu(null) }}
1061
- onEdit={(id) => { setContextMenuVisible(false); setContextMenu(null); openModal('edit', { bookingId: id }) }}
1062
- />
1063
- )}
1064
-
1065
- {/* No-schedule confirm */}
1066
- <ConfirmDialog
1067
- open={!!scheduleConfirm}
1068
- variant="warning"
1069
- title={t('agenda.calendar.noScheduleConfirm')}
1070
- confirmLabel={t('common.confirm')}
1071
- cancelLabel={t('common.cancel')}
1072
- onConfirm={() => { const cb = scheduleConfirm?.onConfirm; setScheduleConfirm(null); cb?.() }}
1073
- onCancel={() => setScheduleConfirm(null)}
1074
- />
1075
-
1076
- {/* Modal — always mounted, Radix controls open/close animation */}
1077
- <AppointmentModal
1078
- open={modalState.open}
1079
- mode={modalState.mode}
1080
- bookingId={modalState.bookingId}
1081
- prefill={modalState.prefill}
1082
- initialTab={modalState.initialTab}
1083
- externalSource={modalState.externalSource}
1084
- onClose={closeModal}
1085
- onCreated={(newId, source) => {
1086
- if (config.onBookingCreated) {
1087
- void Promise.resolve(config.onBookingCreated({ bookingId: newId, source })).catch((error: unknown) => {
1088
- console.error('Failed to run agenda booking-created bridge', error)
1089
- })
1090
- }
1091
- // After a short delay (let calendar re-render), find the event and show popover
1092
- setTimeout(() => {
1093
- const booking = bookings.find((b: CalendarBooking) => b.id === newId)
1094
- if (!booking) return
1095
- const el = document.querySelector(`[data-booking-id="${newId}"]`) as HTMLElement | null
1096
- if (el) {
1097
- const rect = el.getBoundingClientRect()
1098
- const popW = 320, popH = 340
1099
- let x = rect.right + 8
1100
- let y = rect.top + rect.height / 2 - popH / 2
1101
- if (x + popW > window.innerWidth - 16) x = rect.left - popW - 8
1102
- if (y + popH > window.innerHeight - 16) y = window.innerHeight - popH - 16
1103
- if (y < 16) y = 16
1104
- setSelectedBooking(booking)
1105
- setPopoverAnchor({ x, y })
1106
- setPopoverVisible(true)
1107
- }
1108
- }, 400)
1109
- }} />
1110
- </div>
1111
- )
1112
- }