import type { Appointment, AppointmentBulkAction, AppointmentStatus, CreateAppointmentData, } from '@/admin/api/appointments'; import { appointmentsApi } from '@/admin/api/appointments'; import { availabilitiesApi } from '@/admin/api/availabilities'; import { clientsApi } from '@/admin/api/clients'; import { enrollmentsApi } from '@/admin/api/enrollments'; import { GoogleIntegrationAlert } from '@/admin/components/google-integration-alert'; import { appointmentsQuery } from '@/admin/queries/appointments'; import { DataTable } from '@/components/table'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { BulkActionBar } from '@/components/ui/bulk-action-bar'; import { Button, buttonVariants } from '@/components/ui/button'; import { Calendar } from '@/components/ui/calendar'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { InputGroup, InputGroupAddon, InputGroupInput, } from '@/components/ui/input-group'; import { Label } from '@/components/ui/label'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Spinner } from '@/components/ui/spinner'; import { Textarea } from '@/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { cn } from '@/lib/utils'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { getRouteApi } from '@tanstack/react-router'; import { createColumnHelper } from '@tanstack/react-table'; import { dateI18n, getSettings } from '@wordpress/date'; import { __, sprintf } from '@wordpress/i18n'; import { AlertTriangle, CalendarIcon, CheckCircle2, EyeIcon, MoreVertical, Plus, SearchIcon, Trash2, Video, X, XCircle, } from 'lucide-react'; import { useEffect, useState } from 'react'; import type { DateRange } from 'react-day-picker'; import { toast } from 'sonner'; const routeApi = getRouteApi('/_app/appointments/'); const ADD_APPOINTMENT_DEFAULTS = { client_id: '', enrollment_id: '', scheduled_date: '', start_time: '', end_time: '', notes: '', }; const col = createColumnHelper(); const STATUS_LABELS: Record = { scheduled: __('Scheduled', 'allcoach'), completed: __('Completed', 'allcoach'), cancelled: __('Cancelled', 'allcoach'), no_show: __('No Show', 'allcoach'), }; function extractErrorMessage(error: unknown, fallback: string): string { const err = error as any; const validationError = err?.code === 'allcoach_validation_error' && Object.values(err?.data?.additional_data?.errors ?? {})[0]; return validationError ? (new DOMParser().parseFromString(validationError, 'text/html').body .textContent ?? validationError) : err?.message || fallback; } const STATUS_STYLES: Record = { scheduled: 'bg-teal-50 text-teal-700 border-teal-200', completed: 'bg-gray-100 text-gray-600 border-gray-200', cancelled: 'bg-red-50 text-red-600 border-red-200', no_show: 'bg-orange-50 text-orange-600 border-orange-200', }; function formatDateRange(range: DateRange): string { const fmt = (d: Date) => dateI18n(getSettings().formats.date, d.toISOString()); if (range.from && range.to) return `${fmt(range.from)} – ${fmt(range.to)}`; if (range.from) return fmt(range.from); return ''; } const toDateStr = (d: Date) => d.toISOString().split('T')[0]; const Appointments = () => { const queryClient = useQueryClient(); const navigate = routeApi.useNavigate(); const search = routeApi.useSearch(); const [calendarOpen, setCalendarOpen] = useState(false); const [selectedAppointments, setSelectedAppointments] = useState< Appointment[] >([]); const [resetSelectionKey, setResetSelectionKey] = useState(0); const [viewTarget, setViewTarget] = useState(null); const [cancelTargetId, setCancelTargetId] = useState(null); const [addDialogOpen, setAddDialogOpen] = useState(false); const [addForm, setAddForm] = useState(ADD_APPOINTMENT_DEFAULTS); const [searchInput, setSearchInput] = useState(search.search); const debouncedSearch = useDebouncedValue(searchInput); useEffect(() => { setSearchInput(search.search); }, [search.search]); useEffect(() => { if (debouncedSearch !== search.search) { setFilter({ search: debouncedSearch }); } }, [debouncedSearch]); const setFilter = (updates: Partial) => { navigate({ search: { ...search, ...updates, page: 1 } }); }; const dateRange: DateRange | undefined = search.from_date ? { from: new Date(search.from_date + 'T00:00:00'), to: search.to_date ? new Date(search.to_date + 'T00:00:00') : undefined, } : undefined; const { data, isLoading, isFetching } = useQuery( appointmentsQuery({ page: search.page, per_page: search.per_page, search: debouncedSearch || undefined, status: search.status, from_date: search.from_date, to_date: search.to_date, }), ); const appointments = data?.session_bookings ?? []; const total = data?.total ?? 0; const { data: clientsData } = useQuery({ queryKey: ['clients', 'all'], queryFn: () => clientsApi.list({ per_page: 100 }), enabled: addDialogOpen, }); const { data: enrollmentsData, isFetching: enrollmentsFetching } = useQuery({ queryKey: ['enrollments', addForm.client_id], queryFn: () => enrollmentsApi.list({ client_id: Number(addForm.client_id) }), enabled: addDialogOpen && !!addForm.client_id, }); const { data: availableDaysData } = useQuery({ queryKey: ['availability-days', addForm.enrollment_id], queryFn: () => availabilitiesApi.availableDays(Number(addForm.enrollment_id)), enabled: addDialogOpen && !!addForm.enrollment_id, }); const { data: slotsData, isFetching: slotsFetching } = useQuery({ queryKey: [ 'availability-slots', addForm.enrollment_id, addForm.scheduled_date, ], queryFn: () => availabilitiesApi.slots( Number(addForm.enrollment_id), addForm.scheduled_date, ), enabled: addDialogOpen && !!addForm.enrollment_id && !!addForm.scheduled_date, }); const enrollments = enrollmentsData?.enrollments ?? []; const noEnrollments = !!addForm.client_id && !enrollmentsFetching && enrollments.length === 0; const openAdd = () => { setAddForm(ADD_APPOINTMENT_DEFAULTS); setAddDialogOpen(true); }; const createAppointment = useMutation({ mutationFn: (data: CreateAppointmentData) => appointmentsApi.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['session-bookings'] }); toast.success(__('Appointment created.', 'allcoach')); setAddDialogOpen(false); }, onError: (error: unknown) => { toast.error( extractErrorMessage( error, __('Failed to create appointment. Please try again.', 'allcoach'), ), ); }, }); const handleCreateAppointment = () => { if ( !addForm.enrollment_id || !addForm.scheduled_date || !addForm.start_time || !addForm.end_time ) return; createAppointment.mutate({ enrollment_id: Number(addForm.enrollment_id), scheduled_date: addForm.scheduled_date, start_time: addForm.start_time, end_time: addForm.end_time, notes: addForm.notes.trim() || undefined, }); }; const isAddFormValid = !!addForm.enrollment_id && !!addForm.scheduled_date && !!addForm.start_time && !!addForm.end_time; const completeAppointment = useMutation({ mutationFn: (id: number) => appointmentsApi.complete(id), onSuccess: (updated) => { queryClient.invalidateQueries({ queryKey: ['session-bookings'] }); toast.success(__('Appointment marked as completed.', 'allcoach')); setViewTarget(updated); }, onError: () => { toast.error(__('Failed to complete appointment.', 'allcoach')); }, }); const cancelAppointment = useMutation({ mutationFn: (id: number) => appointmentsApi.cancel(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['session-bookings'] }); toast.success(__('Appointment cancelled.', 'allcoach')); setCancelTargetId(null); setViewTarget(null); }, onError: () => { toast.error(__('Failed to cancel appointment.', 'allcoach')); }, }); const bulkAction = useMutation({ mutationFn: ({ action, ids, }: { action: AppointmentBulkAction; ids: number[]; }) => appointmentsApi.bulk(action, ids), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['session-bookings'] }); setResetSelectionKey((k) => k + 1); if (result.failed === 0) { toast.success( sprintf( /* translators: %d: number of appointments updated */ __('%d appointment(s) updated.', 'allcoach'), result.processed, ), ); } else { toast.warning( sprintf( /* translators: %1$d: number of succeeded actions, %2$d: number of failed actions */ __('%1$d succeeded, %2$d failed.', 'allcoach'), result.processed, result.failed, ), ); } }, onError: () => { toast.error(__('Bulk action failed. Please try again.', 'allcoach')); }, }); const columns = [ col.accessor('id', { header: 'ID', cell: ({ getValue }) => ( #{getValue()} ), }), col.accessor('scheduled_date', { header: __('Appointment Date', 'allcoach'), cell: ({ getValue, row }) => { const { start_time, end_time } = row.original; return (
{dateI18n(getSettings().formats.date, getValue() + 'T00:00:00')} {start_time} – {end_time}
); }, }), col.accessor('client', { header: __('Client', 'allcoach'), cell: ({ getValue, row }) => { const client = getValue(); return (
{!client?.first_name && !client?.last_name ? `#${row.original.enrollment_id}` : (client?.first_name ?? '') + ' ' + (client?.last_name ?? '')} {client?.email && ( {client.email} )}
); }, }), col.accessor('program', { header: __('Program', 'allcoach'), cell: ({ getValue }) => ( {getValue()?.title ?? '—'} ), }), col.accessor('coach', { header: __('Coach', 'allcoach'), cell: ({ getValue }) => ( {getValue()?.name ?? '—'} ), }), col.accessor('status', { header: __('Status', 'allcoach'), cell: ({ getValue, row }) => { const status = getValue() as AppointmentStatus; const missingMeetingUrl = status === 'scheduled' && !row.original.meeting_url; return (
{STATUS_LABELS[status]} {missingMeetingUrl && ( {__("Meeting URL doesn't exist", 'allcoach')} )}
); }, }), col.display({ id: 'actions', header: () =>
{__('Actions')}
, cell: ({ row }) => { const apt = row.original; return (
setViewTarget(apt)} > {__('View', 'allcoach')} {apt.meeting_url && apt.status !== 'completed' ? ( ) : ( )} setCancelTargetId(apt.id)} > {__('Cancel', 'allcoach')}
); }, }), ]; const toolbar = (
setSearchInput(e.target.value)} id="appointments-search" placeholder={__('Search client or program…', 'allcoach')} />
{ setFilter({ from_date: range?.from ? toDateStr(range.from) : undefined, to_date: range?.to ? toDateStr(range.to) : undefined, }); if (range?.from && range?.to) setCalendarOpen(false); }} numberOfMonths={2} autoFocus />
); return (
navigate({ search: { ...search, page } }), onPerPageChange: (perPage) => setFilter({ per_page: perPage as typeof search.per_page }), }} onSelectionChange={(rows) => setSelectedAppointments(rows as Appointment[]) } resetSelectionKey={resetSelectionKey} /> {/* New Appointment dialog */} {__('New Appointment', 'allcoach')}
{/* Client */}
{/* Enrollment / Program */}
{noEnrollments && (

{__( 'This client has no active program enrollments. Create an order first.', 'allcoach', )}

)}
{/* Date */}
setAddForm((f) => ({ ...f, scheduled_date: d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` : '', start_time: '', end_time: '', })) } startMonth={new Date()} disabled={[ { before: new Date() }, (date) => { const days = availableDaysData?.available_days; if (!days || days.length === 0) return false; return !days.includes(date.getDay()); }, ]} autoFocus />
{/* Time Slots */} {addForm.enrollment_id && addForm.scheduled_date && (
{slotsFetching ? (
) : !slotsData?.slots.length ? (

{__('No slots available for this date.', 'allcoach')}

) : (
{slotsData.slots.map((slot) => { const isSelected = addForm.start_time === slot.start_time; return ( ); })}
)} {addForm.start_time && (

{__('Selected:', 'allcoach')} {addForm.start_time} –{' '} {addForm.end_time}

)}
)} {/* Notes */}