@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.
- package/README.md +3 -3
- package/dist/bridges/create-financial-bridge.d.ts +1 -1
- package/dist/bridges/create-financial-bridge.d.ts.map +1 -1
- package/dist/bridges/financial-bridge.d.ts.map +1 -0
- package/dist/{chunk-Q2PAWBPR.js → chunk-76ZOHRBM.js} +2 -2
- package/dist/{chunk-Q2PAWBPR.js.map → chunk-76ZOHRBM.js.map} +1 -1
- package/dist/components/PaymentStatusBadge.d.ts +1 -1
- package/dist/components/PaymentStatusBadge.d.ts.map +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/{AgendaContext.d.ts → context.d.ts} +1 -1
- package/dist/context.d.ts.map +1 -0
- package/dist/data/registries.d.ts.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +170 -7
- package/dist/index.js.map +1 -1
- package/dist/lib/message-events.d.ts +12 -0
- package/dist/lib/message-events.d.ts.map +1 -0
- package/dist/public/{data.d.ts → data/index.d.ts} +4 -4
- package/dist/public/data/index.d.ts.map +1 -0
- package/dist/public/{data.supabase.d.ts → data/supabase.d.ts} +2 -2
- package/dist/public/data/supabase.d.ts.map +1 -0
- package/dist/public/{hooks.d.ts → hooks/index.d.ts} +4 -4
- package/dist/public/hooks/index.d.ts.map +1 -0
- package/dist/public/index.d.ts +4 -4
- package/dist/public/index.d.ts.map +1 -1
- package/dist/public/index.js +3 -3
- package/dist/public/index.js.map +1 -1
- package/dist/public/{format.d.ts → lib/format.d.ts} +1 -1
- package/dist/public/lib/format.d.ts.map +1 -0
- package/dist/public/views/BookingWidget.d.ts.map +1 -0
- package/dist/public/{steps → views/steps}/ContactStep.d.ts +1 -1
- package/dist/public/views/steps/ContactStep.d.ts.map +1 -0
- package/dist/public/{steps → views/steps}/DateTimeStep.d.ts +3 -3
- package/dist/public/views/steps/DateTimeStep.d.ts.map +1 -0
- package/dist/public/{steps → views/steps}/OverviewStep.d.ts +1 -1
- package/dist/public/views/steps/OverviewStep.d.ts.map +1 -0
- package/dist/public/{steps → views/steps}/PaymentStep.d.ts +1 -1
- package/dist/public/views/steps/PaymentStep.d.ts.map +1 -0
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/styles/calendar-styles.d.ts.map +1 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/{AgendaPage.d.ts → views/AgendaPage.d.ts} +3 -3
- package/dist/views/AgendaPage.d.ts.map +1 -0
- package/package.json +6 -9
- package/dist/AgendaContext.d.ts.map +0 -1
- package/dist/AgendaPage.d.ts.map +0 -1
- package/dist/calendar-styles.d.ts.map +0 -1
- package/dist/financial-bridge.d.ts.map +0 -1
- package/dist/public/BookingWidget.d.ts.map +0 -1
- package/dist/public/data.d.ts.map +0 -1
- package/dist/public/data.supabase.d.ts.map +0 -1
- package/dist/public/format.d.ts.map +0 -1
- package/dist/public/hooks.d.ts.map +0 -1
- package/dist/public/steps/ContactStep.d.ts.map +0 -1
- package/dist/public/steps/DateTimeStep.d.ts.map +0 -1
- package/dist/public/steps/OverviewStep.d.ts.map +0 -1
- package/dist/public/steps/PaymentStep.d.ts.map +0 -1
- package/dist/registries.d.ts.map +0 -1
- package/src/AgendaContext.tsx +0 -11
- package/src/AgendaPage.tsx +0 -33
- package/src/bridges/create-financial-bridge.ts +0 -296
- package/src/calendar-styles.css +0 -245
- package/src/calendar-styles.ts +0 -285
- package/src/components/AgendaGeneralSettings.tsx +0 -86
- package/src/components/AgendaMobileList.tsx +0 -103
- package/src/components/AppointmentModal.tsx +0 -1050
- package/src/components/AppointmentPopover.tsx +0 -231
- package/src/components/BookingPaymentPanel.tsx +0 -394
- package/src/components/CalendarSidebar.tsx +0 -119
- package/src/components/CalendarToolbar.tsx +0 -86
- package/src/components/EventContextMenu.tsx +0 -125
- package/src/components/FloatingPanel.tsx +0 -123
- package/src/components/MiniCalendar.tsx +0 -120
- package/src/components/PaymentStatusBadge.tsx +0 -62
- package/src/components/StatusBadge.tsx +0 -23
- package/src/config.ts +0 -368
- package/src/data/index.ts +0 -4
- package/src/data/mock.ts +0 -344
- package/src/data/supabase.ts +0 -636
- package/src/data/types.ts +0 -37
- package/src/financial-bridge.ts +0 -96
- package/src/hooks/useAgendaSettings.ts +0 -17
- package/src/index.ts +0 -297
- package/src/integrations/google-calendar/GoogleCalendarSettings.tsx +0 -136
- package/src/integrations/google-calendar/components/CalendarColorDot.tsx +0 -23
- package/src/integrations/google-calendar/components/ChannelMappingList.tsx +0 -329
- package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +0 -215
- package/src/integrations/google-calendar/components/useAgendaLookups.ts +0 -100
- package/src/integrations/google-calendar/connector.ts +0 -20
- package/src/integrations/google-calendar/connectorDef.tsx +0 -185
- package/src/integrations/google-calendar/data/supabase.ts +0 -170
- package/src/integrations/google-calendar/data/tables.ts +0 -12
- package/src/integrations/google-calendar/index.ts +0 -45
- package/src/integrations/google-calendar/integration.test.ts +0 -199
- package/src/integrations/google-calendar/mapping.ts +0 -183
- package/src/integrations/google-calendar/migrations/001_google_calendar.sql +0 -97
- package/src/integrations/google-calendar/migrations/002_smart_sync.sql +0 -517
- package/src/integrations/google-calendar/migrations/003_plg_rename.sql +0 -273
- package/src/integrations/google-calendar/types.ts +0 -142
- package/src/lib/tenant.ts +0 -3
- package/src/locales/en.ts +0 -220
- package/src/locales/index.ts +0 -7
- package/src/locales/pt-BR.ts +0 -220
- package/src/migrations/000b_v_bookings_rename.sql +0 -21
- package/src/migrations/000c_dedupe_customer_phones.sql +0 -43
- package/src/migrations/001_public_booking.sql +0 -296
- package/src/migrations/002_v_bookings_compat.sql +0 -14
- package/src/migrations/003_public_client_kind.sql +0 -175
- package/src/migrations/004_public_booking_assignee.sql +0 -176
- package/src/migrations/005_agent_rpcs.sql +0 -168
- package/src/migrations/006_agent_rpc_localtime.sql +0 -181
- package/src/migrations/007_schedule_exception_precedence.sql +0 -290
- package/src/migrations/008_agent_rpc_exception_precedence.sql +0 -172
- package/src/migrations/009_public_booking_location.sql +0 -162
- package/src/migrations/010_public_service_curation.sql +0 -31
- package/src/migrations/011_default_assignee.sql +0 -167
- package/src/migrations/012_drop_legacy_booking_overload.sql +0 -29
- package/src/migrations/013_normalize_v_appointments.sql +0 -199
- package/src/migrations/014_agent_rpc_update_appointment.sql +0 -199
- package/src/migrations/index.ts +0 -2377
- package/src/public/BookingWidget.tsx +0 -324
- package/src/public/components/BrandHeader.tsx +0 -27
- package/src/public/components/ServiceCard.tsx +0 -39
- package/src/public/components/SidebarExtras.tsx +0 -30
- package/src/public/components/StepRailItem.tsx +0 -27
- package/src/public/components/SuccessPanel.tsx +0 -27
- package/src/public/components/SummaryRail.tsx +0 -90
- package/src/public/components/defaults.ts +0 -14
- package/src/public/context.tsx +0 -103
- package/src/public/createPublicBookingPlugin.ts +0 -281
- package/src/public/data.supabase.ts +0 -97
- package/src/public/data.ts +0 -105
- package/src/public/format.ts +0 -96
- package/src/public/hooks.ts +0 -110
- package/src/public/index.ts +0 -69
- package/src/public/steps/ContactStep.tsx +0 -184
- package/src/public/steps/DateTimeStep.tsx +0 -138
- package/src/public/steps/OverviewStep.tsx +0 -81
- package/src/public/steps/PaymentStep.tsx +0 -180
- package/src/public/types.ts +0 -250
- package/src/registries.ts +0 -35
- package/src/store.ts +0 -311
- package/src/types.ts +0 -273
- package/src/views/CalendarView.tsx +0 -1112
- package/src/views/ConfirmationsView.tsx +0 -155
- /package/dist/{financial-bridge.d.ts → bridges/financial-bridge.d.ts} +0 -0
- /package/dist/{registries.d.ts → data/registries.d.ts} +0 -0
- /package/dist/public/{BookingWidget.d.ts → views/BookingWidget.d.ts} +0 -0
- /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
|
-
}
|