Files
smoothschedule/frontend/src/hooks/useAppointments.ts
poduck 2e111364a2 Initial commit: SmoothSchedule multi-tenant scheduling platform
This commit includes:
- Django backend with multi-tenancy (django-tenants)
- React + TypeScript frontend with Vite
- Platform administration API with role-based access control
- Authentication system with token-based auth
- Quick login dev tools for testing different user roles
- CORS and CSRF configuration for local development
- Docker development environment setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 01:43:20 -05:00

280 lines
9.6 KiB
TypeScript

/**
* Appointment Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Appointment, AppointmentStatus } from '../types';
import { format } from 'date-fns';
interface AppointmentFilters {
resource?: string;
status?: AppointmentStatus;
startDate?: Date;
endDate?: Date;
}
/**
* Hook to fetch appointments with optional filters
*/
export const useAppointments = (filters?: AppointmentFilters) => {
return useQuery<Appointment[]>({
queryKey: ['appointments', filters],
queryFn: async () => {
const params = new URLSearchParams();
if (filters?.resource) params.append('resource', filters.resource);
if (filters?.status) params.append('status', filters.status);
// Send full ISO datetime strings to avoid timezone issues
// The backend will compare datetime fields properly
if (filters?.startDate) {
// Start of day in local timezone, converted to ISO
const startOfDay = new Date(filters.startDate);
startOfDay.setHours(0, 0, 0, 0);
params.append('start_date', startOfDay.toISOString());
}
if (filters?.endDate) {
// End of day (or start of next day) in local timezone, converted to ISO
const endOfDay = new Date(filters.endDate);
endOfDay.setHours(0, 0, 0, 0);
params.append('end_date', endOfDay.toISOString());
}
const { data } = await apiClient.get(`/api/appointments/?${params}`);
// Transform backend format to frontend format
return data.map((a: any) => ({
id: String(a.id),
resourceId: a.resource_id ? String(a.resource_id) : null,
customerId: String(a.customer_id || a.customer),
customerName: a.customer_name || '',
serviceId: String(a.service_id || a.service),
startTime: new Date(a.start_time),
durationMinutes: a.duration_minutes || calculateDuration(a.start_time, a.end_time),
status: a.status as AppointmentStatus,
notes: a.notes || '',
}));
},
});
};
/**
* Calculate duration in minutes from start and end times
*/
function calculateDuration(startTime: string, endTime: string): number {
const start = new Date(startTime);
const end = new Date(endTime);
return Math.round((end.getTime() - start.getTime()) / (1000 * 60));
}
/**
* Hook to get a single appointment
*/
export const useAppointment = (id: string) => {
return useQuery<Appointment>({
queryKey: ['appointments', id],
queryFn: async () => {
const { data } = await apiClient.get(`/api/appointments/${id}/`);
return {
id: String(data.id),
resourceId: data.resource_id ? String(data.resource_id) : null,
customerId: String(data.customer_id || data.customer),
customerName: data.customer_name || '',
serviceId: String(data.service_id || data.service),
startTime: new Date(data.start_time),
durationMinutes: data.duration_minutes || calculateDuration(data.start_time, data.end_time),
status: data.status as AppointmentStatus,
notes: data.notes || '',
};
},
enabled: !!id,
});
};
/**
* Hook to create an appointment
*/
export const useCreateAppointment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (appointmentData: Omit<Appointment, 'id'>) => {
const startTime = appointmentData.startTime;
const endTime = new Date(startTime.getTime() + appointmentData.durationMinutes * 60000);
const backendData: Record<string, unknown> = {
service: parseInt(appointmentData.serviceId),
resource: appointmentData.resourceId ? parseInt(appointmentData.resourceId) : null,
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
notes: appointmentData.notes || '',
};
// Include customer if provided (for business-created appointments)
if (appointmentData.customerId) {
backendData.customer = parseInt(appointmentData.customerId);
}
const { data } = await apiClient.post('/api/appointments/', backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['appointments'] });
},
});
};
/**
* Hook to update an appointment with optimistic updates for instant UI feedback
*/
export const useUpdateAppointment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Appointment> }) => {
const backendData: any = {};
if (updates.serviceId) backendData.service = parseInt(updates.serviceId);
if (updates.resourceId !== undefined) {
backendData.resource = updates.resourceId ? parseInt(updates.resourceId) : null;
}
if (updates.startTime) {
backendData.start_time = updates.startTime.toISOString();
// Calculate end_time if we have duration, otherwise backend will keep existing duration
if (updates.durationMinutes) {
const endTime = new Date(updates.startTime.getTime() + updates.durationMinutes * 60000);
backendData.end_time = endTime.toISOString();
}
} else if (updates.durationMinutes) {
// If only duration changed, we need to get the current appointment to calculate new end time
// For now, just send duration and let backend handle it
// This case is handled by the resize logic which sends both startTime and durationMinutes
}
if (updates.status) backendData.status = updates.status;
if (updates.notes !== undefined) backendData.notes = updates.notes;
const { data } = await apiClient.patch(`/api/appointments/${id}/`, backendData);
return data;
},
// Optimistic update: update UI immediately before API call completes
onMutate: async ({ id, updates }) => {
// Cancel any outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['appointments'] });
// Get all appointment queries and update them optimistically
const queryCache = queryClient.getQueryCache();
const appointmentQueries = queryCache.findAll({ queryKey: ['appointments'] });
const previousData: { queryKey: unknown[]; data: Appointment[] | undefined }[] = [];
appointmentQueries.forEach((query) => {
const data = queryClient.getQueryData<Appointment[]>(query.queryKey);
if (data) {
previousData.push({ queryKey: query.queryKey, data });
queryClient.setQueryData<Appointment[]>(query.queryKey, (old) => {
if (!old) return old;
return old.map((apt) =>
apt.id === id ? { ...apt, ...updates } : apt
);
});
}
});
// Return context with the previous values for rollback
return { previousData };
},
// If mutation fails, rollback to the previous values
onError: (error, _variables, context) => {
console.error('Failed to update appointment', error);
if (context?.previousData) {
context.previousData.forEach(({ queryKey, data }) => {
queryClient.setQueryData(queryKey, data);
});
}
},
// Always refetch after error or success to ensure server state
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['appointments'] });
},
});
};
/**
* Hook to delete an appointment with optimistic updates
*/
export const useDeleteAppointment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/api/appointments/${id}/`);
return id;
},
// Optimistic update: remove from UI immediately
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['appointments'] });
// Get all appointment queries and update them optimistically
const queryCache = queryClient.getQueryCache();
const appointmentQueries = queryCache.findAll({ queryKey: ['appointments'] });
const previousData: { queryKey: unknown[]; data: Appointment[] | undefined }[] = [];
appointmentQueries.forEach((query) => {
const data = queryClient.getQueryData<Appointment[]>(query.queryKey);
if (data) {
previousData.push({ queryKey: query.queryKey, data });
queryClient.setQueryData<Appointment[]>(query.queryKey, (old) => {
if (!old) return old;
return old.filter((apt) => apt.id !== id);
});
}
});
return { previousData };
},
onError: (error, _id, context) => {
console.error('Failed to delete appointment', error);
if (context?.previousData) {
context.previousData.forEach(({ queryKey, data }) => {
queryClient.setQueryData(queryKey, data);
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['appointments'] });
},
});
};
/**
* Hook to reschedule an appointment (update start time and resource)
*/
export const useRescheduleAppointment = () => {
const updateMutation = useUpdateAppointment();
return useMutation({
mutationFn: async ({
id,
newStartTime,
newResourceId,
}: {
id: string;
newStartTime: Date;
newResourceId?: string | null;
}) => {
const appointment = await apiClient.get(`/api/appointments/${id}/`);
const durationMinutes = appointment.data.duration_minutes;
return updateMutation.mutateAsync({
id,
updates: {
startTime: newStartTime,
durationMinutes,
resourceId: newResourceId !== undefined ? newResourceId : undefined,
},
});
},
});
};