feat: Enhance ticketing system with categories, templates, SLA tracking, and fix frontend integration

- Add ticket categories (billing, technical, feature_request, etc.) with type-specific options
- Add TicketTemplate and CannedResponse models for quick ticket creation
- Implement SLA tracking with due_at and first_response_at fields
- Add is_platform_admin and is_customer helper functions to fix permission checks
- Register models in Django admin with filters and fieldsets
- Enhance signals with error handling for WebSocket notifications
- Fix frontend API URLs for templates and canned responses
- Update PlatformSupport page to use real ticketing API
- Add comprehensive i18n translations for all ticket fields

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-28 05:32:36 -05:00
parent 512d95ca2d
commit 200a6b3dd4
22 changed files with 1782 additions and 425 deletions

View File

@@ -46,6 +46,7 @@ export const useCurrentBusiness = () => {
initialSetupComplete: data.initial_setup_complete,
websitePages: data.website_pages || {},
customerDashboardContent: data.customer_dashboard_content || [],
paymentsEnabled: data.payments_enabled ?? false,
// Platform-controlled permissions
canManageOAuthCredentials: data.can_manage_oauth_credentials || false,
};

View File

@@ -1,14 +1,14 @@
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
import { Ticket, TicketComment, TicketTemplate, CannedResponse, TicketStatus, TicketPriority, TicketCategory, TicketType } from '../types';
// 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
status?: TicketStatus;
priority?: TicketPriority;
category?: TicketCategory;
ticketType?: TicketType;
assignee?: string;
}
/**
@@ -18,26 +18,39 @@ 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));
// Use the API filters
const apiFilters: ticketsApi.TicketFilters = {};
if (filters?.status) apiFilters.status = filters.status;
if (filters?.priority) apiFilters.priority = filters.priority;
if (filters?.category) apiFilters.category = filters.category;
if (filters?.ticketType) apiFilters.ticketType = filters.ticketType;
if (filters?.assignee) apiFilters.assignee = filters.assignee;
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)
const data = await ticketsApi.getTickets(apiFilters);
// Transform data to match frontend types if necessary (e.g., snake_case to camelCase)
return data.map((ticket: any) => ({
...ticket,
id: String(ticket.id),
tenant: ticket.tenant ? String(ticket.tenant) : undefined,
creator: String(ticket.creator),
creatorEmail: ticket.creator_email,
creatorFullName: ticket.creator_full_name,
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
assigneeEmail: ticket.assignee_email,
assigneeFullName: ticket.assignee_full_name,
ticketType: ticket.ticket_type,
status: ticket.status,
priority: ticket.priority,
subject: ticket.subject,
description: ticket.description,
category: ticket.category,
relatedAppointmentId: ticket.related_appointment_id || undefined,
dueAt: ticket.due_at,
firstResponseAt: ticket.first_response_at,
isOverdue: ticket.is_overdue,
createdAt: ticket.created_at,
updatedAt: ticket.updated_at,
resolvedAt: ticket.resolved_at,
comments: ticket.comments,
}));
},
});
@@ -50,17 +63,30 @@ export const useTicket = (id: string | undefined) => {
return useQuery<Ticket>({
queryKey: ['tickets', id],
queryFn: async () => {
const { data } = await ticketsApi.getTicket(id as string);
const ticket: any = 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,
id: String(ticket.id),
tenant: ticket.tenant ? String(ticket.tenant) : undefined,
creator: String(ticket.creator),
creatorEmail: ticket.creator_email,
creatorFullName: ticket.creator_full_name,
assignee: ticket.assignee ? String(ticket.assignee) : undefined,
assigneeEmail: ticket.assignee_email,
assigneeFullName: ticket.assignee_full_name,
ticketType: ticket.ticket_type,
status: ticket.status,
priority: ticket.priority,
subject: ticket.subject,
description: ticket.description,
category: ticket.category,
relatedAppointmentId: ticket.related_appointment_id || undefined,
dueAt: ticket.due_at,
firstResponseAt: ticket.first_response_at,
isOverdue: ticket.is_overdue,
createdAt: ticket.created_at,
updatedAt: ticket.updated_at,
resolvedAt: ticket.resolved_at,
comments: ticket.comments,
};
},
enabled: !!id, // Only run query if ID is provided
@@ -82,7 +108,7 @@ export const useCreateTicket = () => {
// No need to send creator or tenant, backend serializer handles it
};
const response = await ticketsApi.createTicket(dataToSend);
return response.data;
return response;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] }); // Invalidate tickets list to refetch
@@ -104,7 +130,7 @@ export const useUpdateTicket = () => {
// creator, tenant, comments are read-only on update
};
const response = await ticketsApi.updateTicket(id, dataToSend);
return response.data;
return response;
},
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
@@ -137,8 +163,8 @@ export const useTicketComments = (ticketId: string | undefined) => {
queryKey: ['ticketComments', ticketId],
queryFn: async () => {
if (!ticketId) return [];
const { data } = await ticketsApi.getTicketComments(ticketId);
return data.map((comment: any) => ({
const comments = await ticketsApi.getTicketComments(ticketId);
return comments.map((comment: any) => ({
...comment,
id: String(comment.id),
ticket: String(comment.ticket),
@@ -166,7 +192,7 @@ export const useCreateTicketComment = () => {
// ticket and author are handled by backend serializer
};
const response = await ticketsApi.createTicketComment(ticketId, dataToSend);
return response.data;
return response;
},
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['ticketComments', variables.ticketId] }); // Invalidate comments for this ticket
@@ -174,3 +200,77 @@ export const useCreateTicketComment = () => {
},
});
};
/**
* Hook to fetch ticket templates
*/
export const useTicketTemplates = () => {
return useQuery<TicketTemplate[]>({
queryKey: ['ticketTemplates'],
queryFn: async () => {
const data = await ticketsApi.getTicketTemplates();
return data.map((template: any) => ({
id: String(template.id),
tenant: template.tenant ? String(template.tenant) : undefined,
name: template.name,
description: template.description,
ticketType: template.ticket_type,
category: template.category,
defaultPriority: template.default_priority,
subjectTemplate: template.subject_template,
descriptionTemplate: template.description_template,
isActive: template.is_active,
createdAt: template.created_at,
}));
},
});
};
/**
* Hook to fetch a single ticket template by ID
*/
export const useTicketTemplate = (id: string | undefined) => {
return useQuery<TicketTemplate>({
queryKey: ['ticketTemplates', id],
queryFn: async () => {
const template: any = await ticketsApi.getTicketTemplate(id as string);
return {
id: String(template.id),
tenant: template.tenant ? String(template.tenant) : undefined,
name: template.name,
description: template.description,
ticketType: template.ticket_type,
category: template.category,
defaultPriority: template.default_priority,
subjectTemplate: template.subject_template,
descriptionTemplate: template.description_template,
isActive: template.is_active,
createdAt: template.created_at,
};
},
enabled: !!id,
});
};
/**
* Hook to fetch canned responses
*/
export const useCannedResponses = () => {
return useQuery<CannedResponse[]>({
queryKey: ['cannedResponses'],
queryFn: async () => {
const data = await ticketsApi.getCannedResponses();
return data.map((response: any) => ({
id: String(response.id),
tenant: response.tenant ? String(response.tenant) : undefined,
title: response.title,
content: response.content,
category: response.category,
isActive: response.is_active,
useCount: response.use_count,
createdBy: response.created_by ? String(response.created_by) : undefined,
createdAt: response.created_at,
}));
},
});
};

View File

@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../api/client';
import apiClient from '../api/client';
import { User } from '../types';
/**