feat: Implement frontend for business owners' support ticket system

This commit is contained in:
poduck
2025-11-28 04:56:48 -05:00
parent aa3854a13f
commit 512d95ca2d
10 changed files with 884 additions and 5 deletions

View 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
};

View 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
},
});
};

View 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;
},
});
};