- Remove WIP badge from staff sidebar navigation - Make action buttons consistent between Customers and Staff pages - Edit button: icon + text with gray border - Masquerade button: icon + text with indigo border - Verify email button: icon-only with colored border (green/amber) - Add sortable columns to Staff list (name and role) - Include migrations for tenant manager role removal 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
229 lines
6.3 KiB
TypeScript
229 lines
6.3 KiB
TypeScript
/**
|
|
* Customer Management Hooks
|
|
*/
|
|
|
|
import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import apiClient from '../api/client';
|
|
import { Customer } from '../types';
|
|
|
|
interface CustomerFilters {
|
|
status?: 'Active' | 'Inactive' | 'Blocked';
|
|
search?: string;
|
|
}
|
|
|
|
interface PaginatedResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: any[];
|
|
}
|
|
|
|
/**
|
|
* Transform backend customer data to frontend format
|
|
*/
|
|
const transformCustomer = (c: any): Customer => ({
|
|
id: String(c.id),
|
|
name: c.name || c.user?.name || '',
|
|
email: c.email || c.user?.email || '',
|
|
phone: c.phone || '',
|
|
city: c.city,
|
|
state: c.state,
|
|
zip: c.zip,
|
|
totalSpend: parseFloat(c.total_spend || 0),
|
|
lastVisit: c.last_visit ? new Date(c.last_visit) : null,
|
|
status: c.status,
|
|
avatarUrl: c.avatar_url,
|
|
tags: c.tags || [],
|
|
userId: String(c.user_id || c.user),
|
|
paymentMethods: [],
|
|
user_data: c.user_data,
|
|
notes: c.notes || '',
|
|
email_verified: c.email_verified ?? false,
|
|
});
|
|
|
|
/**
|
|
* Hook to fetch customers with infinite scroll pagination
|
|
*/
|
|
export const useCustomersInfinite = (filters?: CustomerFilters) => {
|
|
return useInfiniteQuery<PaginatedResponse>({
|
|
queryKey: ['customers', 'infinite', filters || {}],
|
|
queryFn: async ({ pageParam = 1 }) => {
|
|
const params = new URLSearchParams();
|
|
params.append('page', String(pageParam));
|
|
params.append('page_size', '25');
|
|
if (filters?.status) params.append('status', filters.status);
|
|
if (filters?.search) params.append('search', filters.search);
|
|
|
|
const { data } = await apiClient.get(`/customers/?${params}`);
|
|
|
|
// Handle both paginated and non-paginated responses
|
|
if (Array.isArray(data)) {
|
|
return {
|
|
count: data.length,
|
|
next: null,
|
|
previous: null,
|
|
results: data,
|
|
};
|
|
}
|
|
return data;
|
|
},
|
|
initialPageParam: 1,
|
|
getNextPageParam: (lastPage) => {
|
|
if (lastPage.next) {
|
|
// Extract page number from next URL
|
|
const url = new URL(lastPage.next);
|
|
const page = url.searchParams.get('page');
|
|
return page ? parseInt(page, 10) : undefined;
|
|
}
|
|
return undefined;
|
|
},
|
|
retry: false,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook to fetch all customers (non-paginated, for backward compatibility)
|
|
*/
|
|
export const useCustomers = (filters?: CustomerFilters) => {
|
|
return useQuery<Customer[]>({
|
|
queryKey: ['customers', filters],
|
|
queryFn: async () => {
|
|
const params = new URLSearchParams();
|
|
if (filters?.status) params.append('status', filters.status);
|
|
if (filters?.search) params.append('search', filters.search);
|
|
|
|
const { data } = await apiClient.get(`/customers/?${params}`);
|
|
|
|
// Handle paginated response
|
|
const results = data.results || data;
|
|
return results.map(transformCustomer);
|
|
},
|
|
retry: false,
|
|
});
|
|
};
|
|
|
|
interface CreateCustomerData {
|
|
name?: string;
|
|
firstName?: string;
|
|
lastName?: string;
|
|
email: string;
|
|
phone?: string;
|
|
city?: string;
|
|
state?: string;
|
|
zip?: string;
|
|
}
|
|
|
|
/**
|
|
* Hook to create a customer
|
|
*/
|
|
export const useCreateCustomer = () => {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (customerData: CreateCustomerData) => {
|
|
// Parse name into first_name and last_name if provided as single field
|
|
let firstName = customerData.firstName;
|
|
let lastName = customerData.lastName;
|
|
|
|
if (customerData.name && !firstName && !lastName) {
|
|
const nameParts = customerData.name.trim().split(/\s+/);
|
|
firstName = nameParts[0] || '';
|
|
lastName = nameParts.slice(1).join(' ') || '';
|
|
}
|
|
|
|
const backendData = {
|
|
first_name: firstName,
|
|
last_name: lastName,
|
|
email: customerData.email,
|
|
phone: customerData.phone || '',
|
|
// Note: city, state, zip are TODO in backend - not stored yet
|
|
};
|
|
|
|
const { data } = await apiClient.post('/customers/', backendData);
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
|
},
|
|
});
|
|
};
|
|
|
|
interface UpdateCustomerData {
|
|
name?: string;
|
|
firstName?: string;
|
|
lastName?: string;
|
|
email?: string;
|
|
phone?: string;
|
|
isActive?: boolean;
|
|
notes?: string;
|
|
}
|
|
|
|
/**
|
|
* Hook to update a customer
|
|
*/
|
|
export const useUpdateCustomer = () => {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ id, updates }: { id: string; updates: UpdateCustomerData }) => {
|
|
// Parse name into first_name and last_name if provided as single field
|
|
let firstName = updates.firstName;
|
|
let lastName = updates.lastName;
|
|
|
|
if (updates.name && !firstName && !lastName) {
|
|
const nameParts = updates.name.trim().split(/\s+/);
|
|
firstName = nameParts[0] || '';
|
|
lastName = nameParts.slice(1).join(' ') || '';
|
|
}
|
|
|
|
const backendData: Record<string, unknown> = {};
|
|
|
|
if (firstName !== undefined) backendData.first_name = firstName;
|
|
if (lastName !== undefined) backendData.last_name = lastName;
|
|
if (updates.email !== undefined) backendData.email = updates.email;
|
|
if (updates.phone !== undefined) backendData.phone = updates.phone;
|
|
if (updates.isActive !== undefined) backendData.is_active = updates.isActive;
|
|
if (updates.notes !== undefined) backendData.notes = updates.notes;
|
|
|
|
const { data } = await apiClient.patch(`/customers/${id}/`, backendData);
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook to delete a customer
|
|
*/
|
|
export const useDeleteCustomer = () => {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (id: string) => {
|
|
await apiClient.delete(`/customers/${id}/`);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook to verify a customer's email address
|
|
*/
|
|
export const useVerifyCustomerEmail = () => {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (id: string) => {
|
|
const { data } = await apiClient.post(`/customers/${id}/verify_email/`);
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
|
},
|
|
});
|
|
};
|