feat: Implement frontend for business owners' support ticket system
This commit is contained in:
74
frontend/src/hooks/useNotificationWebSocket.ts
Normal file
74
frontend/src/hooks/useNotificationWebSocket.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { toast } from 'react-hot-toast'; // Assuming react-hot-toast for notifications
|
||||
import { useCurrentUser } from './useAuth'; // To get current user and their tenant
|
||||
|
||||
/**
|
||||
* Custom hook to manage WebSocket connection for real-time notifications.
|
||||
*/
|
||||
export const useNotificationWebSocket = () => {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const { data: user } = useCurrentUser(); // Get current user for authentication
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !user.id) {
|
||||
// If no user or not authenticated, ensure WebSocket is closed
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine WebSocket URL dynamically
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// The current host needs to be adjusted if the WebSocket server is on a different subdomain/port
|
||||
// For local development, assuming it's on the same host/port as the frontend API
|
||||
const wsHost = window.location.host;
|
||||
const wsUrl = `${protocol}//${wsHost}/ws/notifications/`;
|
||||
|
||||
const connectWebSocket = () => {
|
||||
wsRef.current = new WebSocket(wsUrl);
|
||||
|
||||
wsRef.current.onopen = () => {
|
||||
console.log('Notification WebSocket connected');
|
||||
};
|
||||
|
||||
wsRef.current.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('Notification received:', data);
|
||||
// Display notification using a toast library
|
||||
toast.success(data.message, {
|
||||
duration: 5000,
|
||||
position: 'top-right',
|
||||
});
|
||||
};
|
||||
|
||||
wsRef.current.onclose = (event) => {
|
||||
console.log('Notification WebSocket disconnected:', event);
|
||||
// Attempt to reconnect after a short delay
|
||||
setTimeout(() => {
|
||||
if (user && user.id) { // Only attempt reconnect if user is still authenticated
|
||||
console.log('Attempting to reconnect Notification WebSocket...');
|
||||
connectWebSocket();
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
wsRef.current.onerror = (error) => {
|
||||
console.error('Notification WebSocket error:', error);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
};
|
||||
|
||||
connectWebSocket();
|
||||
|
||||
// Clean up WebSocket connection on component unmount or user logout
|
||||
return () => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [user]); // Reconnect if user changes (e.g., login/logout)
|
||||
|
||||
// You can expose functions here to manually send messages if needed
|
||||
// For notifications, it's typically server-to-client only
|
||||
};
|
||||
176
frontend/src/hooks/useTickets.ts
Normal file
176
frontend/src/hooks/useTickets.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as ticketsApi from '../api/tickets'; // Import all functions from the API service
|
||||
import { Ticket, TicketComment, User } from '../types'; // Assuming User type is also needed
|
||||
|
||||
// Define interfaces for filters and mutation payloads if necessary
|
||||
interface TicketFilters {
|
||||
status?: Ticket['status'];
|
||||
type?: Ticket['ticketType'];
|
||||
assignee?: User['id'];
|
||||
creator?: User['id'];
|
||||
// Add other filters as needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a list of tickets with optional filters
|
||||
*/
|
||||
export const useTickets = (filters?: TicketFilters) => {
|
||||
return useQuery<Ticket[]>({
|
||||
queryKey: ['tickets', filters],
|
||||
queryFn: async () => {
|
||||
// Construct query parameters from filters object
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.status) params.append('status', filters.status);
|
||||
if (filters?.type) params.append('ticket_type', filters.type); // Backend expects 'ticket_type'
|
||||
if (filters?.assignee) params.append('assignee', String(filters.assignee));
|
||||
if (filters?.creator) params.append('creator', String(filters.creator));
|
||||
|
||||
const { data } = await ticketsApi.getTickets(); // Pass params if API supported
|
||||
// Transform data to match frontend types if necessary (e.g., date strings to Date objects)
|
||||
return data.map((ticket: any) => ({
|
||||
...ticket,
|
||||
id: String(ticket.id),
|
||||
tenant: ticket.tenant ? String(ticket.tenant) : undefined,
|
||||
creator: String(ticket.creator),
|
||||
assignee: ticket.assignee ? String(ticket.assignee) : undefined,
|
||||
createdAt: new Date(ticket.created_at).toISOString(),
|
||||
updatedAt: new Date(ticket.updated_at).toISOString(),
|
||||
resolvedAt: ticket.resolved_at ? new Date(ticket.resolved_at).toISOString() : undefined,
|
||||
ticketType: ticket.ticket_type, // Map backend 'ticket_type' to frontend 'ticketType'
|
||||
commentText: ticket.comment_text, // Assuming this is from comments, if not, remove
|
||||
}));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch a single ticket by ID
|
||||
*/
|
||||
export const useTicket = (id: string | undefined) => {
|
||||
return useQuery<Ticket>({
|
||||
queryKey: ['tickets', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await ticketsApi.getTicket(id as string);
|
||||
return {
|
||||
...data,
|
||||
id: String(data.id),
|
||||
tenant: data.tenant ? String(data.tenant) : undefined,
|
||||
creator: String(data.creator),
|
||||
assignee: data.assignee ? String(data.assignee) : undefined,
|
||||
createdAt: new Date(data.created_at).toISOString(),
|
||||
updatedAt: new Date(data.updated_at).toISOString(),
|
||||
resolvedAt: data.resolved_at ? new Date(data.resolved_at).toISOString() : undefined,
|
||||
ticketType: data.ticket_type,
|
||||
};
|
||||
},
|
||||
enabled: !!id, // Only run query if ID is provided
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to create a new ticket
|
||||
*/
|
||||
export const useCreateTicket = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (ticketData: Partial<Omit<Ticket, 'id' | 'comments' | 'creator' | 'creatorEmail' | 'creatorFullName' | 'createdAt' | 'updatedAt' | 'resolvedAt'>>) => {
|
||||
// Map frontend naming to backend naming
|
||||
const dataToSend = {
|
||||
...ticketData,
|
||||
ticket_type: ticketData.ticketType,
|
||||
assignee: ticketData.assignee || null,
|
||||
// No need to send creator or tenant, backend serializer handles it
|
||||
};
|
||||
const response = await ticketsApi.createTicket(dataToSend);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] }); // Invalidate tickets list to refetch
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update an existing ticket
|
||||
*/
|
||||
export const useUpdateTicket = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Ticket> }) => {
|
||||
const dataToSend = {
|
||||
...updates,
|
||||
ticket_type: updates.ticketType,
|
||||
assignee: updates.assignee || null,
|
||||
// creator, tenant, comments are read-only on update
|
||||
};
|
||||
const response = await ticketsApi.updateTicket(id, dataToSend);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets', variables.id] }); // Invalidate specific ticket
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to delete a ticket
|
||||
*/
|
||||
export const useDeleteTicket = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await ticketsApi.deleteTicket(id);
|
||||
return id;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch comments for a specific ticket
|
||||
*/
|
||||
export const useTicketComments = (ticketId: string | undefined) => {
|
||||
return useQuery<TicketComment[]>({
|
||||
queryKey: ['ticketComments', ticketId],
|
||||
queryFn: async () => {
|
||||
if (!ticketId) return [];
|
||||
const { data } = await ticketsApi.getTicketComments(ticketId);
|
||||
return data.map((comment: any) => ({
|
||||
...comment,
|
||||
id: String(comment.id),
|
||||
ticket: String(comment.ticket),
|
||||
author: String(comment.author),
|
||||
createdAt: new Date(comment.created_at).toISOString(),
|
||||
commentText: comment.comment_text, // Map backend 'comment_text'
|
||||
isInternal: comment.is_internal, // Map backend 'is_internal'
|
||||
}));
|
||||
},
|
||||
enabled: !!ticketId, // Only run query if ticketId is provided
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to add a new comment to a ticket
|
||||
*/
|
||||
export const useCreateTicketComment = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ ticketId, commentData }: { ticketId: string; commentData: Partial<TicketComment> }) => {
|
||||
const dataToSend = {
|
||||
...commentData,
|
||||
comment_text: commentData.commentText,
|
||||
is_internal: commentData.isInternal,
|
||||
// ticket and author are handled by backend serializer
|
||||
};
|
||||
const response = await ticketsApi.createTicketComment(ticketId, dataToSend);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ticketComments', variables.ticketId] }); // Invalidate comments for this ticket
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets', variables.ticketId] }); // Ticket might have a new comment count
|
||||
},
|
||||
});
|
||||
};
|
||||
17
frontend/src/hooks/useUsers.ts
Normal file
17
frontend/src/hooks/useUsers.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '../api/client';
|
||||
import { User } from '../types';
|
||||
|
||||
/**
|
||||
* Hook to fetch all users (staff, owners, customers) for the current business.
|
||||
* This can be filtered/refined later based on specific needs (e.g., only staff).
|
||||
*/
|
||||
export const useUsers = () => {
|
||||
return useQuery<User[]>({
|
||||
queryKey: ['businessUsers'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get('/api/business/users/');
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user