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:
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user