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>
This commit is contained in:
279
frontend/src/hooks/useAppointments.ts
Normal file
279
frontend/src/hooks/useAppointments.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user