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

@@ -10,7 +10,8 @@ import {
MessageSquare,
LogOut,
ClipboardList,
Briefcase
Briefcase,
Ticket
} from 'lucide-react';
import { Business, User } from '../types';
import { useLogout } from '../hooks/useAuth';
@@ -135,7 +136,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{!isCollapsed && <span>{t('nav.resources')}</span>}
</Link>
<Link to="/tickets" className={getNavClass('/tickets')} title={t('nav.tickets')}>
<LayoutDashboard size={20} className="shrink-0" /> {/* Using LayoutDashboard icon for now, can change */}
<Ticket size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.tickets')}</span>}
</Link>
</>

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, User, Send, MessageSquare, BookOpen, Clock, Tag, Priority as PriorityIcon, FileText, Settings, Key } from 'lucide-react';
import { Ticket, TicketComment, TicketPriority, TicketStatus } from '../types';
import { X, User, Send, MessageSquare, Clock, AlertCircle } from 'lucide-react';
import { Ticket, TicketComment, TicketStatus, TicketPriority, TicketCategory, TicketType } from '../types';
import { useCreateTicket, useUpdateTicket, useTicketComments, useCreateTicketComment } from '../hooks/useTickets';
import { useUsers } from '../hooks/useUsers'; // Assuming a useUsers hook exists to fetch users for assignee dropdown
import { useQueryClient } from '@tanstack/react-query';
@@ -9,15 +9,25 @@ import { useQueryClient } from '@tanstack/react-query';
interface TicketModalProps {
ticket?: Ticket | null; // If provided, it's an edit/detail view
onClose: () => void;
defaultTicketType?: TicketType; // Allow specifying default ticket type
}
const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose }) => {
// Category options grouped by ticket type
const CATEGORY_OPTIONS: Record<TicketType, TicketCategory[]> = {
PLATFORM: ['BILLING', 'TECHNICAL', 'FEATURE_REQUEST', 'ACCOUNT', 'OTHER'],
CUSTOMER: ['APPOINTMENT', 'REFUND', 'COMPLAINT', 'GENERAL_INQUIRY', 'OTHER'],
STAFF_REQUEST: ['TIME_OFF', 'SCHEDULE_CHANGE', 'EQUIPMENT', 'OTHER'],
INTERNAL: ['EQUIPMENT', 'GENERAL_INQUIRY', 'OTHER'],
};
const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicketType = 'CUSTOMER' }) => {
const { t } = useTranslation();
const queryClient = useQueryClient(); // Access the query client for invalidation/updates
const queryClient = useQueryClient();
const [subject, setSubject] = useState(ticket?.subject || '');
const [description, setDescription] = useState(ticket?.description || '');
const [priority, setPriority] = useState<TicketPriority>(ticket?.priority || 'MEDIUM');
const [category, setCategory] = useState(ticket?.category || '');
const [category, setCategory] = useState<TicketCategory>(ticket?.category || 'OTHER');
const [ticketType, setTicketType] = useState<TicketType>(ticket?.ticketType || defaultTicketType);
const [assigneeId, setAssigneeId] = useState<string | undefined>(ticket?.assignee);
const [status, setStatus] = useState<TicketStatus>(ticket?.status || 'OPEN');
const [newCommentText, setNewCommentText] = useState('');
@@ -34,12 +44,16 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose }) => {
const updateTicketMutation = useUpdateTicket();
const createCommentMutation = useCreateTicketComment();
// Get available categories based on ticket type
const availableCategories = CATEGORY_OPTIONS[ticketType] || CATEGORY_OPTIONS.CUSTOMER;
useEffect(() => {
if (ticket) {
setSubject(ticket.subject);
setDescription(ticket.description);
setPriority(ticket.priority);
setCategory(ticket.category || '');
setCategory(ticket.category || 'OTHER');
setTicketType(ticket.ticketType);
setAssigneeId(ticket.assignee);
setStatus(ticket.status);
} else {
@@ -47,11 +61,19 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose }) => {
setSubject('');
setDescription('');
setPriority('MEDIUM');
setCategory('');
setCategory('OTHER');
setTicketType(defaultTicketType);
setAssigneeId(undefined);
setStatus('OPEN');
}
}, [ticket]);
}, [ticket, defaultTicketType]);
// Reset category when ticket type changes (if current category not available)
useEffect(() => {
if (!availableCategories.includes(category)) {
setCategory('OTHER');
}
}, [ticketType, availableCategories, category]);
const handleSubmitTicket = async (e: React.FormEvent) => {
e.preventDefault();
@@ -60,10 +82,10 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose }) => {
subject,
description,
priority,
category: category || undefined,
category,
assignee: assigneeId,
status: status,
ticketType: 'PLATFORM', // For now, assume platform tickets from this modal
status,
ticketType,
};
if (ticket) {
@@ -91,8 +113,9 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose }) => {
queryClient.invalidateQueries({ queryKey: ['ticketComments', ticket.id] });
};
const statusOptions = Object.values(TicketStatus);
const priorityOptions = Object.values(TicketPriority);
const statusOptions: TicketStatus[] = ['OPEN', 'IN_PROGRESS', 'AWAITING_RESPONSE', 'RESOLVED', 'CLOSED'];
const priorityOptions: TicketPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'URGENT'];
const ticketTypeOptions: TicketType[] = ['CUSTOMER', 'STAFF_REQUEST', 'INTERNAL', 'PLATFORM'];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
@@ -142,6 +165,25 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose }) => {
/>
</div>
{/* Ticket Type (only for new tickets) */}
{!ticket && (
<div>
<label htmlFor="ticketType" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('tickets.ticketType')}
</label>
<select
id="ticketType"
value={ticketType}
onChange={(e) => setTicketType(e.target.value as TicketType)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
{ticketTypeOptions.map(opt => (
<option key={opt} value={opt}>{t(`tickets.types.${opt.toLowerCase()}`)}</option>
))}
</select>
</div>
)}
{/* Priority & Category */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
@@ -156,7 +198,7 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose }) => {
disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending}
>
{priorityOptions.map(opt => (
<option key={opt} value={opt}>{t(`tickets.priority.${opt.toLowerCase()}`)}</option>
<option key={opt} value={opt}>{t(`tickets.priorities.${opt.toLowerCase()}`)}</option>
))}
</select>
</div>
@@ -164,15 +206,17 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose }) => {
<label htmlFor="category" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('tickets.category')}
</label>
<input
type="text"
<select
id="category"
value={category}
onChange={(e) => setCategory(e.target.value)}
onChange={(e) => setCategory(e.target.value as TicketCategory)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder={t('tickets.categoryPlaceholder')}
disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending}
/>
>
{availableCategories.map(cat => (
<option key={cat} value={cat}>{t(`tickets.categories.${cat.toLowerCase()}`)}</option>
))}
</select>
</div>
</div>