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