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>
280 lines
9.6 KiB
TypeScript
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,
|
|
},
|
|
});
|
|
},
|
|
});
|
|
};
|