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

@@ -8,6 +8,8 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@stripe/connect-js": "^3.3.31",
"@stripe/react-connect-js": "^3.3.31",
"@tanstack/react-query": "^5.90.10",
@@ -19,6 +21,7 @@
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0",
"react-i18next": "^16.3.5",
"react-phone-number-input": "^3.4.14",
"react-router-dom": "^7.9.6",
@@ -347,6 +350,45 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -2235,7 +2277,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
@@ -2994,6 +3035,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/goober": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -4006,6 +4056,23 @@
"react": "^19.2.0"
}
},
"node_modules/react-hot-toast": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-i18next": {
"version": "16.3.5",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz",
@@ -4358,6 +4425,12 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -4,6 +4,8 @@
"version": "0.0.0",
"type": "module",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@stripe/connect-js": "^3.3.31",
"@stripe/react-connect-js": "^3.3.31",
"@tanstack/react-query": "^5.90.10",
@@ -15,6 +17,7 @@
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0",
"react-i18next": "^16.3.5",
"react-phone-number-input": "^3.4.14",
"react-router-dom": "^7.9.6",

View File

@@ -55,7 +55,7 @@ import VerifyEmail from './pages/VerifyEmail';
import EmailVerificationRequired from './pages/EmailVerificationRequired';
import AcceptInvitePage from './pages/AcceptInvitePage';
import TenantOnboardPage from './pages/TenantOnboardPage';
import TicketsPage from './pages/TicketsPage'; // Import TicketsPage
import Tickets from './pages/Tickets'; // Import Tickets page
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
const queryClient = new QueryClient({
@@ -469,7 +469,7 @@ const AppContent: React.FC = () => {
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
/>
<Route path="/scheduler" element={<Scheduler />} />
<Route path="/tickets" element={<TicketsPage />} />
<Route path="/tickets" element={<Tickets />} />
<Route
path="/customers"
element={

View File

@@ -1,8 +1,23 @@
import { apiClient } from './client';
import { Ticket, TicketComment } from '../types'; // Assuming types.ts will define these
import apiClient from './client';
import { Ticket, TicketComment, TicketTemplate, CannedResponse, TicketStatus, TicketPriority, TicketCategory, TicketType } from '../types';
export const getTickets = async (): Promise<Ticket[]> => {
const response = await apiClient.get('/api/tickets/');
export interface TicketFilters {
status?: TicketStatus;
priority?: TicketPriority;
category?: TicketCategory;
ticketType?: TicketType;
assignee?: string;
}
export const getTickets = async (filters?: TicketFilters): Promise<Ticket[]> => {
const params = new URLSearchParams();
if (filters?.status) params.append('status', filters.status);
if (filters?.priority) params.append('priority', filters.priority);
if (filters?.category) params.append('category', filters.category);
if (filters?.ticketType) params.append('ticket_type', filters.ticketType);
if (filters?.assignee) params.append('assignee', filters.assignee);
const response = await apiClient.get(`/api/tickets/${params.toString() ? `?${params.toString()}` : ''}`);
return response.data;
};
@@ -34,3 +49,20 @@ export const createTicketComment = async (ticketId: string, data: Partial<Ticket
const response = await apiClient.post(`/api/tickets/${ticketId}/comments/`, data);
return response.data;
};
// Ticket Templates
export const getTicketTemplates = async (): Promise<TicketTemplate[]> => {
const response = await apiClient.get('/api/tickets/templates/');
return response.data;
};
export const getTicketTemplate = async (id: string): Promise<TicketTemplate> => {
const response = await apiClient.get(`/api/tickets/templates/${id}/`);
return response.data;
};
// Canned Responses
export const getCannedResponses = async (): Promise<CannedResponse[]> => {
const response = await apiClient.get('/api/tickets/canned-responses/');
return response.data;
};

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>

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';
/**

View File

@@ -83,18 +83,23 @@
"tickets": {
"title": "Support Tickets",
"description": "Manage your support requests and inquiries.",
"descriptionOwner": "Manage support tickets for your business",
"descriptionStaff": "View and create support tickets",
"newTicket": "New Ticket",
"errorLoading": "Error loading tickets",
"subject": "Subject",
"description": "Description",
"status": "Status",
"priority": "Priority",
"category": "Category",
"ticketType": "Ticket Type",
"assignee": "Assignee",
"assignedTo": "Assigned to",
"createdAt": "Created At",
"unassigned": "Unassigned",
"noTicketsFound": "No tickets found.",
"noTicketsFound": "No tickets found",
"noTicketsInStatus": "No tickets with this status",
"ticketDetails": "Ticket Details",
"categoryPlaceholder": "e.g., Billing, Technical, Feature Request",
"createTicket": "Create Ticket",
"updateTicket": "Update Ticket",
"comments": "Comments",
@@ -103,14 +108,47 @@
"addCommentPlaceholder": "Add a comment...",
"internalComment": "Internal Comment",
"postComment": "Post Comment",
"priority.low": "Low",
"priority.medium": "Medium",
"priority.high": "High",
"priority.urgent": "Urgent",
"status.open": "Open",
"status.in_progress": "In Progress",
"status.resolved": "Resolved",
"status.closed": "Closed"
"tabs": {
"all": "All",
"open": "Open",
"inProgress": "In Progress",
"awaitingResponse": "Awaiting Response",
"resolved": "Resolved",
"closed": "Closed"
},
"priorities": {
"low": "Low",
"medium": "Medium",
"high": "High",
"urgent": "Urgent"
},
"statuses": {
"open": "Open",
"in_progress": "In Progress",
"awaiting_response": "Awaiting Response",
"resolved": "Resolved",
"closed": "Closed"
},
"types": {
"platform": "Platform Support",
"customer": "Customer Inquiry",
"staff_request": "Staff Request",
"internal": "Internal"
},
"categories": {
"billing": "Billing & Payments",
"technical": "Technical Issue",
"feature_request": "Feature Request",
"account": "Account & Settings",
"appointment": "Appointment Issue",
"refund": "Refund Request",
"complaint": "Complaint",
"general_inquiry": "General Inquiry",
"time_off": "Time Off Request",
"schedule_change": "Schedule Change",
"equipment": "Equipment Issue",
"other": "Other"
}
},
"dashboard": {
"title": "Dashboard",

View File

@@ -7,6 +7,7 @@ import { Business, User } from '../types';
import MasqueradeBanner from '../components/MasqueradeBanner';
import OnboardingWizard from '../components/OnboardingWizard';
import { useStopMasquerade } from '../hooks/useAuth';
import { useNotificationWebSocket } from '../hooks/useNotificationWebSocket'; // Import the new hook
import { MasqueradeStackEntry } from '../api/auth';
import { useScrollToTop } from '../hooks/useScrollToTop';
@@ -176,6 +177,8 @@ const BusinessLayout: React.FC<BusinessLayoutProps> = ({ business, user, darkMod
stopMasqueradeMutation.mutate();
};
useNotificationWebSocket(); // Activate the notification WebSocket listener
// Get the previous user from the stack (the one we'll return to)
const previousUser = masqueradeStack.length > 0
? {

View File

@@ -0,0 +1,294 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, User, Clock, AlertCircle, CheckCircle, XCircle, Circle, Loader2 } from 'lucide-react';
import { useTickets } from '../hooks/useTickets';
import { Ticket, TicketStatus } from '../types';
import TicketModal from '../components/TicketModal';
import { useCurrentUser } from '../hooks/useAuth';
const TicketStatusBadge: React.FC<{ status: Ticket['status'] }> = ({ status }) => {
const { t } = useTranslation();
const statusConfig = {
OPEN: {
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
icon: Circle,
label: t('tickets.status.open', 'Open'),
},
IN_PROGRESS: {
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
icon: Loader2,
label: t('tickets.status.in_progress', 'In Progress'),
},
RESOLVED: {
color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
icon: CheckCircle,
label: t('tickets.status.resolved', 'Resolved'),
},
CLOSED: {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
icon: XCircle,
label: t('tickets.status.closed', 'Closed'),
},
};
const config = statusConfig[status] || statusConfig.OPEN;
const Icon = config.icon;
return (
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
<Icon size={12} className={status === 'IN_PROGRESS' ? 'animate-spin' : ''} />
{config.label}
</span>
);
};
const TicketPriorityBadge: React.FC<{ priority: Ticket['priority'] }> = ({ priority }) => {
const { t } = useTranslation();
const priorityConfig = {
LOW: {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
label: t('tickets.priority.low', 'Low'),
},
MEDIUM: {
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
label: t('tickets.priority.medium', 'Medium'),
},
HIGH: {
color: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
label: t('tickets.priority.high', 'High'),
},
URGENT: {
color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
label: t('tickets.priority.urgent', 'Urgent'),
},
};
const config = priorityConfig[priority] || priorityConfig.MEDIUM;
return (
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
<AlertCircle size={12} />
{config.label}
</span>
);
};
const Tickets: React.FC = () => {
const { t } = useTranslation();
const { data: currentUser } = useCurrentUser();
const [statusFilter, setStatusFilter] = useState<TicketStatus | 'ALL'>('ALL');
const [isTicketModalOpen, setIsTicketModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
// Fetch all tickets (backend will filter based on user role)
const { data: tickets = [], isLoading, error } = useTickets();
// Filter tickets by status
const filteredTickets = useMemo(() => {
if (statusFilter === 'ALL') return tickets;
return tickets.filter(ticket => ticket.status === statusFilter);
}, [tickets, statusFilter]);
// Count tickets by status for tabs
const statusCounts = useMemo(() => {
const counts = {
ALL: tickets.length,
OPEN: 0,
IN_PROGRESS: 0,
RESOLVED: 0,
CLOSED: 0,
};
tickets.forEach(ticket => {
counts[ticket.status]++;
});
return counts;
}, [tickets]);
const openTicketModal = (ticket: Ticket | null = null) => {
setSelectedTicket(ticket);
setIsTicketModalOpen(true);
};
const closeTicketModal = () => {
setSelectedTicket(null);
setIsTicketModalOpen(false);
};
const isOwnerOrManager = currentUser?.role === 'owner' || currentUser?.role === 'manager';
if (isLoading) {
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-800 dark:text-red-300">
{t('tickets.errorLoading', 'Error loading tickets')}: {(error as Error).message}
</p>
</div>
</div>
);
}
const tabs: Array<{ id: TicketStatus | 'ALL'; label: string }> = [
{ id: 'ALL', label: t('tickets.tabs.all', 'All') },
{ id: 'OPEN', label: t('tickets.tabs.open', 'Open') },
{ id: 'IN_PROGRESS', label: t('tickets.tabs.inProgress', 'In Progress') },
{ id: 'RESOLVED', label: t('tickets.tabs.resolved', 'Resolved') },
{ id: 'CLOSED', label: t('tickets.tabs.closed', 'Closed') },
];
return (
<div className="p-8 max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{t('tickets.title', 'Support Tickets')}
</h2>
<p className="text-gray-500 dark:text-gray-400">
{isOwnerOrManager
? t('tickets.descriptionOwner', 'Manage support tickets for your business')
: t('tickets.descriptionStaff', 'View and create support tickets')}
</p>
</div>
<button
onClick={() => openTicketModal()}
className="flex items-center justify-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium"
>
<Plus size={18} />
{t('tickets.newTicket', 'New Ticket')}
</button>
</div>
{/* Status Filter Tabs */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="flex -mb-px overflow-x-auto" aria-label="Tabs">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setStatusFilter(tab.id)}
className={`
flex-shrink-0 px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap
${statusFilter === tab.id
? 'border-brand-600 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}
`}
>
{tab.label}
<span className={`ml-2 py-0.5 px-2 rounded-full text-xs ${
statusFilter === tab.id
? 'bg-brand-100 text-brand-800 dark:bg-brand-900/30 dark:text-brand-300'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
}`}>
{statusCounts[tab.id]}
</span>
</button>
))}
</nav>
</div>
{/* Tickets Grid */}
<div className="p-6">
{filteredTickets.length === 0 ? (
<div className="text-center py-12">
<AlertCircle size={48} className="mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p className="text-gray-500 dark:text-gray-400">
{statusFilter === 'ALL'
? t('tickets.noTicketsFound', 'No tickets found')
: t('tickets.noTicketsInStatus', `No ${statusFilter.toLowerCase().replace('_', ' ')} tickets`)}
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{filteredTickets.map(ticket => (
<div
key={ticket.id}
onClick={() => openTicketModal(ticket)}
className="bg-white dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4 hover:border-brand-300 dark:hover:border-brand-600 hover:shadow-md transition-all cursor-pointer group"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-base font-semibold text-gray-900 dark:text-white truncate group-hover:text-brand-600 dark:group-hover:text-brand-400">
{ticket.subject}
</h3>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300 line-clamp-2 mb-3">
{ticket.description}
</p>
<div className="flex flex-wrap items-center gap-3 text-xs">
<TicketStatusBadge status={ticket.status} />
<TicketPriorityBadge priority={ticket.priority} />
{ticket.category && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-full">
{ticket.category}
</span>
)}
<span className="flex items-center gap-1 text-gray-500 dark:text-gray-400">
<User size={12} />
{ticket.creatorFullName || ticket.creatorEmail}
</span>
<span className="flex items-center gap-1 text-gray-500 dark:text-gray-400">
<Clock size={12} />
{new Date(ticket.createdAt).toLocaleDateString()}
</span>
</div>
</div>
<div className="flex-shrink-0 text-right">
{ticket.assigneeFullName ? (
<div className="text-xs text-gray-600 dark:text-gray-400">
<div className="font-medium mb-1">{t('tickets.assignedTo', 'Assigned to')}</div>
<div className="flex items-center gap-1 justify-end">
<User size={12} className="text-brand-500" />
<span className="font-medium text-gray-900 dark:text-white">
{ticket.assigneeFullName}
</span>
</div>
</div>
) : (
<span className="text-xs text-gray-400 dark:text-gray-500">
{t('tickets.unassigned', 'Unassigned')}
</span>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Ticket Create/Detail Modal */}
{isTicketModalOpen && (
<TicketModal
ticket={selectedTicket}
onClose={closeTicketModal}
/>
)}
</div>
);
};
export default Tickets;

View File

@@ -1,201 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Tag, Clock, CircleDot, User, ArrowRight } from 'lucide-react';
import { useTickets } from '../hooks/useTickets';
import { Ticket } from '../types';
import TicketModal from '../components/TicketModal';
const TicketStatusBadge: React.FC<{ status: Ticket['status'] }> = ({ status }) => {
let colorClass = '';
switch (status) {
case 'OPEN':
colorClass = 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
break;
case 'IN_PROGRESS':
colorClass = 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
break;
case 'RESOLVED':
colorClass = 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
break;
case 'CLOSED':
colorClass = 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
break;
default:
colorClass = 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
{status.replace('_', ' ').toLowerCase()}
</span>
);
};
const TicketPriorityBadge: React.FC<{ priority: Ticket['priority'] }> = ({ priority }) => {
let colorClass = '';
switch (priority) {
case 'LOW':
colorClass = 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
break;
case 'MEDIUM':
colorClass = 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
break;
case 'HIGH':
colorClass = 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
break;
case 'URGENT':
colorClass = 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
break;
default:
colorClass = 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
{priority.toLowerCase()}
</span>
);
};
const TicketsPage: React.FC = () => {
const { t } = useTranslation();
const { data: tickets, isLoading, error } = useTickets({ type: 'PLATFORM' }); // Filter for platform tickets
const [isTicketModalOpen, setIsTicketModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const openTicketModal = (ticket: Ticket | null = null) => {
setSelectedTicket(ticket);
setIsTicketModalOpen(true);
};
const closeTicketModal = () => {
setSelectedTicket(null);
setIsTicketModalOpen(false);
};
if (isLoading) {
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-800 dark:text-red-300">{t('tickets.errorLoading')}: {(error as Error).message}</p>
</div>
</div>
);
}
return (
<div className="p-8 max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('tickets.title')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('tickets.description')}</p>
</div>
<button
onClick={() => openTicketModal()}
className="flex items-center justify-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium"
>
<Plus size={18} />
{t('tickets.newTicket')}
</button>
</div>
{/* Tickets List */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden transition-colors duration-200">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('tickets.subject')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('tickets.status')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('tickets.priority')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('tickets.category')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('tickets.assignee')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('tickets.createdAt')}
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">{t('common.actions')}</span>
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{tickets && tickets.length > 0 ? (
tickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="text-sm font-medium text-gray-900 dark:text-white">{ticket.subject}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<TicketStatusBadge status={ticket.status} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<TicketPriorityBadge priority={ticket.priority} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{ticket.category || t('common.none')}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{ticket.assigneeFullName ? (
<div className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<User size={16} className="text-brand-500" />
{ticket.assigneeFullName}
</div>
) : (
<span className="text-sm text-gray-500 dark:text-gray-400">{t('tickets.unassigned')}</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{new Date(ticket.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button onClick={() => openTicketModal(ticket)} className="text-brand-600 hover:text-brand-900 dark:text-brand-400 dark:hover:text-brand-300">
{t('common.view')} <ArrowRight size={16} className="inline-block ml-1" />
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
{t('tickets.noTicketsFound')}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Ticket Create/Detail Modal */}
{isTicketModalOpen && (
<TicketModal
ticket={selectedTicket}
onClose={closeTicketModal}
/>
)}
</div>
);
};
export default TicketsPage;

View File

@@ -1,11 +1,79 @@
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SUPPORT_TICKETS } from '../../mockData';
import { Ticket as TicketIcon, AlertCircle, CheckCircle2, Clock } from 'lucide-react';
import { Ticket as TicketIcon, AlertCircle, CheckCircle2, Clock, Circle, Loader2, XCircle, Plus } from 'lucide-react';
import { useTickets } from '../../hooks/useTickets';
import { Ticket, TicketStatus } from '../../types';
import TicketModal from '../../components/TicketModal';
import Portal from '../../components/Portal';
const PlatformSupport: React.FC = () => {
const { t } = useTranslation();
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [statusFilter, setStatusFilter] = useState<TicketStatus | 'ALL'>('ALL');
// Fetch all tickets (platform admins see all)
const { data: tickets = [], isLoading, error } = useTickets(
statusFilter !== 'ALL' ? { status: statusFilter } : undefined
);
// Filter to show PLATFORM tickets primarily, but also show all for platform admins
const platformTickets = tickets;
const getStatusIcon = (status: TicketStatus) => {
switch (status) {
case 'OPEN': return <Circle size={14} className="text-green-500" />;
case 'IN_PROGRESS': return <Loader2 size={14} className="text-blue-500 animate-spin" />;
case 'AWAITING_RESPONSE': return <Clock size={14} className="text-orange-500" />;
case 'RESOLVED': return <CheckCircle2 size={14} className="text-gray-500" />;
case 'CLOSED': return <XCircle size={14} className="text-gray-400" />;
default: return <AlertCircle size={14} />;
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'URGENT': return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
case 'HIGH': return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400';
case 'MEDIUM': return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400';
case 'LOW': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
default: return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
}
};
const statusTabs: { key: TicketStatus | 'ALL'; label: string }[] = [
{ key: 'ALL', label: t('tickets.tabs.all', 'All') },
{ key: 'OPEN', label: t('tickets.tabs.open', 'Open') },
{ key: 'IN_PROGRESS', label: t('tickets.tabs.inProgress', 'In Progress') },
{ key: 'AWAITING_RESPONSE', label: t('tickets.tabs.awaitingResponse', 'Awaiting') },
{ key: 'RESOLVED', label: t('tickets.tabs.resolved', 'Resolved') },
{ key: 'CLOSED', label: t('tickets.tabs.closed', 'Closed') },
];
const handleTicketClick = (ticket: Ticket) => {
setSelectedTicket(ticket);
setIsModalOpen(true);
};
const handleNewTicket = () => {
setSelectedTicket(null);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedTicket(null);
};
if (error) {
return (
<div className="text-center py-12">
<AlertCircle className="mx-auto h-12 w-12 text-red-400" />
<p className="mt-4 text-red-600 dark:text-red-400">{t('tickets.errorLoading')}</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
@@ -13,54 +81,119 @@ const PlatformSupport: React.FC = () => {
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('platform.supportTickets')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('platform.supportDescription')}</p>
</div>
<button
onClick={handleNewTicket}
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
>
<Plus size={18} />
{t('tickets.newTicket')}
</button>
</div>
{/* Status Tabs */}
<div className="flex flex-wrap gap-2 border-b border-gray-200 dark:border-gray-700 pb-2">
{statusTabs.map((tab) => (
<button
key={tab.key}
onClick={() => setStatusFilter(tab.key)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
statusFilter === tab.key
? 'bg-brand-600 text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
{tab.label}
</button>
))}
</div>
{isLoading ? (
<div className="text-center py-12">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-brand-600" />
<p className="mt-4 text-gray-500">{t('common.loading')}</p>
</div>
) : platformTickets.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<TicketIcon className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-4 text-gray-500 dark:text-gray-400">{t('tickets.noTicketsFound')}</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{SUPPORT_TICKETS.map((ticket) => (
<div key={ticket.id} className="bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow cursor-pointer">
{platformTickets.map((ticket) => (
<div
key={ticket.id}
onClick={() => handleTicketClick(ticket)}
className="bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className={`p-2 rounded-lg shrink-0 ${
ticket.priority === 'High' || ticket.priority === 'Critical' ? 'bg-red-100 text-red-600 dark:bg-red-900/20 dark:text-red-400' :
ticket.priority === 'Medium' ? 'bg-orange-100 text-orange-600 dark:bg-orange-900/20 dark:text-orange-400' :
'bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400'
}`}>
<div className={`p-2 rounded-lg shrink-0 ${getPriorityColor(ticket.priority)}`}>
<TicketIcon size={20} />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-gray-900 dark:text-white">{ticket.subject}</h3>
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-600">
{ticket.id}
#{ticket.id}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${
ticket.ticketType === 'PLATFORM' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' :
ticket.ticketType === 'CUSTOMER' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
}`}>
{t(`tickets.types.${ticket.ticketType.toLowerCase()}`)}
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">{t('platform.reportedBy')} <span className="font-medium text-gray-900 dark:text-white">{ticket.businessName}</span></p>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
{t('platform.reportedBy')} <span className="font-medium text-gray-900 dark:text-white">{ticket.creatorFullName || ticket.creatorEmail}</span>
{ticket.category && (
<span className="ml-2 text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700">
{t(`tickets.categories.${ticket.category.toLowerCase()}`)}
</span>
)}
</p>
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<Clock size={12} /> {ticket.createdAt.toLocaleDateString()}
<Clock size={12} /> {new Date(ticket.createdAt).toLocaleDateString()}
</span>
<span className={`flex items-center gap-1 font-medium ${
ticket.status === 'Open' ? 'text-green-600' :
ticket.status === 'In Progress' ? 'text-blue-600' : 'text-gray-500'
}`}>
{ticket.status === 'Open' && <AlertCircle size={12} />}
{ticket.status === 'Resolved' && <CheckCircle2 size={12} />}
{ticket.status}
<span className="flex items-center gap-1 font-medium">
{getStatusIcon(ticket.status)}
{t(`tickets.statuses.${ticket.status.toLowerCase()}`)}
</span>
{ticket.assigneeFullName && (
<span className="text-gray-400">
{t('tickets.assignedTo')}: {ticket.assigneeFullName}
</span>
)}
</div>
</div>
</div>
<div className="text-right">
<span className={`inline-block px-2 py-1 rounded text-xs font-medium ${
ticket.priority === 'High' ? 'bg-red-50 text-red-700 dark:bg-red-900/30' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
}`}>
{ticket.priority} {t('platform.priority')}
<span className={`inline-block px-2 py-1 rounded text-xs font-medium ${getPriorityColor(ticket.priority)}`}>
{t(`tickets.priorities.${ticket.priority.toLowerCase()}`)}
</span>
{ticket.isOverdue && (
<span className="block mt-1 text-xs text-red-600 dark:text-red-400 font-medium">
Overdue
</span>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* Ticket Modal */}
{isModalOpen && (
<Portal>
<TicketModal
ticket={selectedTicket}
onClose={handleCloseModal}
defaultTicketType="PLATFORM"
/>
</Portal>
)}
</div>
);
};

View File

@@ -179,9 +179,22 @@ export interface Metric {
// --- Platform Types ---
export type TicketType = 'PLATFORM' | 'CUSTOMER' | 'STAFF_REQUEST';
export type TicketStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED';
export type TicketType = 'PLATFORM' | 'CUSTOMER' | 'STAFF_REQUEST' | 'INTERNAL';
export type TicketStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED' | 'AWAITING_RESPONSE';
export type TicketPriority = 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
export type TicketCategory =
| 'BILLING'
| 'TECHNICAL'
| 'FEATURE_REQUEST'
| 'ACCOUNT'
| 'APPOINTMENT'
| 'REFUND'
| 'COMPLAINT'
| 'GENERAL_INQUIRY'
| 'TIME_OFF'
| 'SCHEDULE_CHANGE'
| 'EQUIPMENT'
| 'OTHER';
export interface TicketComment {
id: string;
@@ -208,13 +221,43 @@ export interface Ticket {
priority: TicketPriority;
subject: string;
description: string;
category?: string;
category: TicketCategory;
relatedAppointmentId?: string; // Appointment ID, optional
dueAt?: string; // Date string
firstResponseAt?: string; // Date string
isOverdue?: boolean;
createdAt: string; // Date string
updatedAt: string; // Date string
resolvedAt?: string; // Date string
comments?: TicketComment[]; // Nested comments
}
export interface TicketTemplate {
id: string;
tenant?: string; // Tenant ID, optional for platform templates
name: string;
description: string;
ticketType: TicketType;
category: TicketCategory;
defaultPriority: TicketPriority;
subjectTemplate: string;
descriptionTemplate: string;
isActive: boolean;
createdAt: string; // Date string
}
export interface CannedResponse {
id: string;
tenant?: string; // Tenant ID, optional for platform canned responses
title: string;
content: string;
category?: TicketCategory;
isActive: boolean;
useCount: number;
createdBy?: string; // User ID
createdAt: string; // Date string
}
export interface PlatformMetric {
label: string;
value: string;

View File

@@ -1,3 +1,93 @@
from django.contrib import admin
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse
# Register your models here.
@admin.register(Ticket)
class TicketAdmin(admin.ModelAdmin):
list_display = ('id', 'subject', 'ticket_type', 'status', 'priority', 'creator', 'assignee', 'tenant', 'created_at')
list_filter = ('status', 'priority', 'ticket_type', 'tenant', 'created_at')
search_fields = ('subject', 'description', 'creator__email', 'assignee__email')
readonly_fields = ('created_at', 'updated_at')
raw_id_fields = ('creator', 'assignee', 'tenant')
ordering = ('-created_at',)
fieldsets = (
(None, {
'fields': ('subject', 'description', 'category')
}),
('Classification', {
'fields': ('ticket_type', 'status', 'priority')
}),
('Assignment', {
'fields': ('tenant', 'creator', 'assignee')
}),
('Timestamps', {
'fields': ('created_at', 'updated_at', 'resolved_at'),
'classes': ('collapse',)
}),
)
@admin.register(TicketComment)
class TicketCommentAdmin(admin.ModelAdmin):
list_display = ('id', 'ticket', 'author', 'is_internal', 'created_at')
list_filter = ('is_internal', 'created_at')
search_fields = ('comment_text', 'author__email', 'ticket__subject')
readonly_fields = ('created_at',)
raw_id_fields = ('ticket', 'author')
ordering = ('-created_at',)
@admin.register(TicketTemplate)
class TicketTemplateAdmin(admin.ModelAdmin):
list_display = ('id', 'name', 'ticket_type', 'category', 'default_priority', 'tenant', 'is_active', 'created_at')
list_filter = ('ticket_type', 'category', 'default_priority', 'is_active', 'tenant', 'created_at')
search_fields = ('name', 'description', 'subject_template', 'description_template')
readonly_fields = ('created_at',)
raw_id_fields = ('tenant',)
ordering = ('ticket_type', 'name')
fieldsets = (
(None, {
'fields': ('name', 'description', 'is_active')
}),
('Template Details', {
'fields': ('ticket_type', 'category', 'default_priority')
}),
('Content Templates', {
'fields': ('subject_template', 'description_template')
}),
('Scope', {
'fields': ('tenant',)
}),
('Timestamps', {
'fields': ('created_at',),
'classes': ('collapse',)
}),
)
@admin.register(CannedResponse)
class CannedResponseAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'category', 'tenant', 'is_active', 'use_count', 'created_by', 'created_at')
list_filter = ('category', 'is_active', 'tenant', 'created_at')
search_fields = ('title', 'content', 'created_by__email')
readonly_fields = ('use_count', 'created_at')
raw_id_fields = ('tenant', 'created_by')
ordering = ('-use_count', 'title')
fieldsets = (
(None, {
'fields': ('title', 'content', 'is_active')
}),
('Categorization', {
'fields': ('category',)
}),
('Scope', {
'fields': ('tenant',)
}),
('Metadata', {
'fields': ('use_count', 'created_by', 'created_at'),
'classes': ('collapse',)
}),
)

View File

@@ -0,0 +1,103 @@
# Generated by Django 5.2.8 on 2025-11-28 10:22
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_add_tenant_permissions'),
('tickets', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='CannedResponse',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(help_text='Short title for easy selection.', max_length=100)),
('content', models.TextField(help_text='The response content. Can include placeholders.')),
('category', models.CharField(blank=True, choices=[('BILLING', 'Billing & Payments'), ('TECHNICAL', 'Technical Issue'), ('FEATURE_REQUEST', 'Feature Request'), ('ACCOUNT', 'Account & Settings'), ('APPOINTMENT', 'Appointment Issue'), ('REFUND', 'Refund Request'), ('COMPLAINT', 'Complaint'), ('GENERAL_INQUIRY', 'General Inquiry'), ('TIME_OFF', 'Time Off Request'), ('SCHEDULE_CHANGE', 'Schedule Change'), ('EQUIPMENT', 'Equipment Issue'), ('OTHER', 'Other')], help_text='Category this response is most relevant to.', max_length=50)),
('is_active', models.BooleanField(default=True)),
('use_count', models.PositiveIntegerField(default=0, help_text='Number of times this response was used.')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['-use_count', 'title'],
},
),
migrations.CreateModel(
name='TicketTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Template name.', max_length=100)),
('description', models.TextField(blank=True, help_text='Description of when to use this template.')),
('ticket_type', models.CharField(choices=[('PLATFORM', 'Platform Support'), ('CUSTOMER', 'Customer Inquiry'), ('STAFF_REQUEST', 'Staff Request'), ('INTERNAL', 'Internal Business Ticket')], help_text='Type of ticket this template creates.', max_length=20)),
('category', models.CharField(choices=[('BILLING', 'Billing & Payments'), ('TECHNICAL', 'Technical Issue'), ('FEATURE_REQUEST', 'Feature Request'), ('ACCOUNT', 'Account & Settings'), ('APPOINTMENT', 'Appointment Issue'), ('REFUND', 'Refund Request'), ('COMPLAINT', 'Complaint'), ('GENERAL_INQUIRY', 'General Inquiry'), ('TIME_OFF', 'Time Off Request'), ('SCHEDULE_CHANGE', 'Schedule Change'), ('EQUIPMENT', 'Equipment Issue'), ('OTHER', 'Other')], default='OTHER', max_length=50)),
('default_priority', models.CharField(choices=[('LOW', 'Low'), ('MEDIUM', 'Medium'), ('HIGH', 'High'), ('URGENT', 'Urgent')], default='MEDIUM', max_length=20)),
('subject_template', models.CharField(help_text='Default subject line. Can include placeholders like {customer_name}.', max_length=255)),
('description_template', models.TextField(help_text='Default description. Can include placeholders.')),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['ticket_type', 'name'],
},
),
migrations.AddField(
model_name='ticket',
name='due_at',
field=models.DateTimeField(blank=True, help_text='SLA deadline based on priority.', null=True),
),
migrations.AddField(
model_name='ticket',
name='first_response_at',
field=models.DateTimeField(blank=True, help_text='When the first response was made.', null=True),
),
migrations.AddField(
model_name='ticket',
name='related_appointment_id',
field=models.CharField(blank=True, help_text='ID of the related appointment for customer inquiry tickets.', max_length=50, null=True),
),
migrations.AlterField(
model_name='ticket',
name='category',
field=models.CharField(choices=[('BILLING', 'Billing & Payments'), ('TECHNICAL', 'Technical Issue'), ('FEATURE_REQUEST', 'Feature Request'), ('ACCOUNT', 'Account & Settings'), ('APPOINTMENT', 'Appointment Issue'), ('REFUND', 'Refund Request'), ('COMPLAINT', 'Complaint'), ('GENERAL_INQUIRY', 'General Inquiry'), ('TIME_OFF', 'Time Off Request'), ('SCHEDULE_CHANGE', 'Schedule Change'), ('EQUIPMENT', 'Equipment Issue'), ('OTHER', 'Other')], default='OTHER', help_text='Category of the ticket.', max_length=50),
),
migrations.AlterField(
model_name='ticket',
name='status',
field=models.CharField(choices=[('OPEN', 'Open'), ('IN_PROGRESS', 'In Progress'), ('AWAITING_RESPONSE', 'Awaiting Response'), ('RESOLVED', 'Resolved'), ('CLOSED', 'Closed')], default='OPEN', help_text='Current status of the ticket.', max_length=20),
),
migrations.AlterField(
model_name='ticket',
name='ticket_type',
field=models.CharField(choices=[('PLATFORM', 'Platform Support'), ('CUSTOMER', 'Customer Inquiry'), ('STAFF_REQUEST', 'Staff Request'), ('INTERNAL', 'Internal Business Ticket')], default='CUSTOMER', help_text='Distinguishes between platform support tickets and customer/staff tickets.', max_length=20),
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['creator', 'status'], name='tickets_tic_creator_cc6043_idx'),
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['category'], name='tickets_tic_categor_fc7dd1_idx'),
),
migrations.AddField(
model_name='cannedresponse',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_canned_responses', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='cannedresponse',
name='tenant',
field=models.ForeignKey(blank=True, help_text='Tenant this response belongs to. Null for platform-wide responses.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='canned_responses', to='core.tenant'),
),
migrations.AddField(
model_name='tickettemplate',
name='tenant',
field=models.ForeignKey(blank=True, help_text='Tenant this template belongs to. Null for platform-wide templates.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='ticket_templates', to='core.tenant'),
),
]

View File

@@ -1,23 +1,31 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from core.models import Tenant
from smoothschedule.users.models import User # Assuming smoothschedule.users is the app for User model
from smoothschedule.users.models import User
class Ticket(models.Model):
"""
Represents a support ticket in the system.
Can be a platform-level ticket (Business Owner -> Platform)
or a customer-level ticket (Customer -> Business).
Ticket Types:
- PLATFORM: Business Owner -> Platform Support (billing, technical issues with platform)
- CUSTOMER: Customer -> Business (appointment issues, service inquiries)
- STAFF_REQUEST: Staff -> Business Owner/Manager (time off, schedule changes)
- INTERNAL: Business internal tickets (equipment issues, facilities)
"""
class TicketType(models.TextChoices):
PLATFORM = 'PLATFORM', _('Platform Support')
CUSTOMER = 'CUSTOMER', _('Customer Inquiry')
STAFF_REQUEST = 'STAFF_REQUEST', _('Staff Request')
INTERNAL = 'INTERNAL', _('Internal Business Ticket')
class Status(models.TextChoices):
OPEN = 'OPEN', _('Open')
IN_PROGRESS = 'IN_PROGRESS', _('In Progress')
AWAITING_RESPONSE = 'AWAITING_RESPONSE', _('Awaiting Response')
RESOLVED = 'RESOLVED', _('Resolved')
CLOSED = 'CLOSED', _('Closed')
@@ -27,6 +35,24 @@ class Ticket(models.Model):
HIGH = 'HIGH', _('High')
URGENT = 'URGENT', _('Urgent')
class Category(models.TextChoices):
# Platform ticket categories
BILLING = 'BILLING', _('Billing & Payments')
TECHNICAL = 'TECHNICAL', _('Technical Issue')
FEATURE_REQUEST = 'FEATURE_REQUEST', _('Feature Request')
ACCOUNT = 'ACCOUNT', _('Account & Settings')
# Customer/Business ticket categories
APPOINTMENT = 'APPOINTMENT', _('Appointment Issue')
REFUND = 'REFUND', _('Refund Request')
COMPLAINT = 'COMPLAINT', _('Complaint')
GENERAL_INQUIRY = 'GENERAL_INQUIRY', _('General Inquiry')
# Staff request categories
TIME_OFF = 'TIME_OFF', _('Time Off Request')
SCHEDULE_CHANGE = 'SCHEDULE_CHANGE', _('Schedule Change')
EQUIPMENT = 'EQUIPMENT', _('Equipment Issue')
# General
OTHER = 'OTHER', _('Other')
tenant = models.ForeignKey(
Tenant,
on_delete=models.CASCADE,
@@ -76,9 +102,30 @@ class Ticket(models.Model):
description = models.TextField(help_text="Detailed description of the issue or request.")
category = models.CharField(
max_length=50,
choices=Category.choices,
default=Category.OTHER,
help_text="Category of the ticket."
)
# Related appointment for customer tickets (stored as ID string since Event is tenant-scoped)
related_appointment_id = models.CharField(
max_length=50,
blank=True,
help_text="Category of the ticket (e.g., Billing, Technical, Feature Request)."
null=True,
help_text="ID of the related appointment for customer inquiry tickets."
)
# SLA tracking
due_at = models.DateTimeField(
null=True,
blank=True,
help_text="SLA deadline based on priority."
)
first_response_at = models.DateTimeField(
null=True,
blank=True,
help_text="When the first response was made."
)
created_at = models.DateTimeField(auto_now_add=True)
@@ -91,11 +138,132 @@ class Ticket(models.Model):
models.Index(fields=['tenant', 'status']),
models.Index(fields=['assignee', 'status']),
models.Index(fields=['ticket_type', 'status']),
models.Index(fields=['creator', 'status']),
models.Index(fields=['category']),
]
def __str__(self):
return f"Ticket #{self.id}: {self.subject} ({self.get_status_display()})"
def save(self, *args, **kwargs):
# Set SLA due date based on priority if not already set
if not self.due_at and not self.pk:
self.due_at = self._calculate_sla_due_date()
# Auto-set resolved_at when status changes to RESOLVED or CLOSED
if self.status in [self.Status.RESOLVED, self.Status.CLOSED] and not self.resolved_at:
self.resolved_at = timezone.now()
elif self.status not in [self.Status.RESOLVED, self.Status.CLOSED]:
self.resolved_at = None
super().save(*args, **kwargs)
def _calculate_sla_due_date(self):
"""Calculate SLA due date based on priority."""
from datetime import timedelta
now = timezone.now()
sla_hours = {
self.Priority.URGENT: 1,
self.Priority.HIGH: 4,
self.Priority.MEDIUM: 24,
self.Priority.LOW: 72,
}
hours = sla_hours.get(self.priority, 24)
return now + timedelta(hours=hours)
@property
def is_overdue(self):
"""Check if ticket is past SLA deadline."""
if not self.due_at:
return False
if self.status in [self.Status.RESOLVED, self.Status.CLOSED]:
return False
return timezone.now() > self.due_at
class TicketTemplate(models.Model):
"""
Predefined templates for common ticket types.
Can be platform-wide or tenant-specific.
"""
tenant = models.ForeignKey(
Tenant,
on_delete=models.CASCADE,
related_name='ticket_templates',
null=True,
blank=True,
help_text="Tenant this template belongs to. Null for platform-wide templates."
)
name = models.CharField(max_length=100, help_text="Template name.")
description = models.TextField(blank=True, help_text="Description of when to use this template.")
ticket_type = models.CharField(
max_length=20,
choices=Ticket.TicketType.choices,
help_text="Type of ticket this template creates."
)
category = models.CharField(
max_length=50,
choices=Ticket.Category.choices,
default=Ticket.Category.OTHER,
)
default_priority = models.CharField(
max_length=20,
choices=Ticket.Priority.choices,
default=Ticket.Priority.MEDIUM,
)
subject_template = models.CharField(
max_length=255,
help_text="Default subject line. Can include placeholders like {customer_name}."
)
description_template = models.TextField(
help_text="Default description. Can include placeholders."
)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['ticket_type', 'name']
def __str__(self):
scope = self.tenant.name if self.tenant else "Platform"
return f"{self.name} ({scope})"
class CannedResponse(models.Model):
"""
Predefined responses for support staff to quickly reply to common issues.
"""
tenant = models.ForeignKey(
Tenant,
on_delete=models.CASCADE,
related_name='canned_responses',
null=True,
blank=True,
help_text="Tenant this response belongs to. Null for platform-wide responses."
)
title = models.CharField(max_length=100, help_text="Short title for easy selection.")
content = models.TextField(help_text="The response content. Can include placeholders.")
category = models.CharField(
max_length=50,
choices=Ticket.Category.choices,
blank=True,
help_text="Category this response is most relevant to."
)
is_active = models.BooleanField(default=True)
use_count = models.PositiveIntegerField(default=0, help_text="Number of times this response was used.")
created_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name='created_canned_responses',
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-use_count', 'title']
def __str__(self):
scope = self.tenant.name if self.tenant else "Platform"
return f"{self.title} ({scope})"
class TicketComment(models.Model):
"""
Represents a comment or update on a support ticket.

View File

@@ -1,6 +1,6 @@
from rest_framework import serializers
from .models import Ticket, TicketComment
from smoothschedule.users.models import User # Assuming smoothschedule.users is the app for User model
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse
from smoothschedule.users.models import User
from core.models import Tenant
class TicketCommentSerializer(serializers.ModelSerializer):
@@ -17,7 +17,8 @@ class TicketSerializer(serializers.ModelSerializer):
creator_full_name = serializers.ReadOnlyField(source='creator.full_name')
assignee_email = serializers.ReadOnlyField(source='assignee.email')
assignee_full_name = serializers.ReadOnlyField(source='assignee.full_name')
comments = TicketCommentSerializer(many=True, read_only=True) # Nested serializer for comments
is_overdue = serializers.ReadOnlyField()
comments = TicketCommentSerializer(many=True, read_only=True)
class Meta:
model = Ticket
@@ -25,9 +26,11 @@ class TicketSerializer(serializers.ModelSerializer):
'id', 'tenant', 'creator', 'creator_email', 'creator_full_name',
'assignee', 'assignee_email', 'assignee_full_name',
'ticket_type', 'status', 'priority', 'subject', 'description', 'category',
'related_appointment_id', 'due_at', 'first_response_at', 'is_overdue',
'created_at', 'updated_at', 'resolved_at', 'comments'
]
read_only_fields = ['id', 'creator', 'creator_email', 'creator_full_name', 'created_at', 'updated_at', 'resolved_at', 'comments']
read_only_fields = ['id', 'creator', 'creator_email', 'creator_full_name',
'is_overdue', 'created_at', 'updated_at', 'resolved_at', 'comments']
def create(self, validated_data):
# Automatically set creator to the requesting user if not provided (e.g., for platform admin creating for tenant)
@@ -53,3 +56,78 @@ class TicketSerializer(serializers.ModelSerializer):
validated_data.pop('tenant', None)
validated_data.pop('creator', None)
return super().update(instance, validated_data)
class TicketListSerializer(serializers.ModelSerializer):
"""Lighter version of TicketSerializer for list views without comments."""
creator_email = serializers.ReadOnlyField(source='creator.email')
creator_full_name = serializers.ReadOnlyField(source='creator.full_name')
assignee_email = serializers.ReadOnlyField(source='assignee.email')
assignee_full_name = serializers.ReadOnlyField(source='assignee.full_name')
is_overdue = serializers.ReadOnlyField()
class Meta:
model = Ticket
fields = [
'id', 'tenant', 'creator', 'creator_email', 'creator_full_name',
'assignee', 'assignee_email', 'assignee_full_name',
'ticket_type', 'status', 'priority', 'subject', 'category',
'related_appointment_id', 'due_at', 'first_response_at', 'is_overdue',
'created_at', 'updated_at', 'resolved_at'
]
read_only_fields = ['id', 'creator', 'creator_email', 'creator_full_name',
'is_overdue', 'created_at', 'updated_at', 'resolved_at']
class TicketTemplateSerializer(serializers.ModelSerializer):
"""Serializer for TicketTemplate model."""
class Meta:
model = TicketTemplate
fields = [
'id', 'tenant', 'name', 'description', 'ticket_type', 'category',
'default_priority', 'subject_template', 'description_template',
'is_active', 'created_at'
]
read_only_fields = ['id', 'created_at']
def create(self, validated_data):
# Set tenant based on request context
user = self.context['request'].user
# If tenant is not provided and user has a tenant, use it
if 'tenant' not in validated_data or validated_data['tenant'] is None:
if hasattr(user, 'tenant') and user.tenant:
validated_data['tenant'] = user.tenant
# Platform admins can create platform-wide templates (tenant=null)
return super().create(validated_data)
class CannedResponseSerializer(serializers.ModelSerializer):
"""Serializer for CannedResponse model."""
created_by_email = serializers.ReadOnlyField(source='created_by.email')
created_by_full_name = serializers.ReadOnlyField(source='created_by.full_name')
class Meta:
model = CannedResponse
fields = [
'id', 'tenant', 'title', 'content', 'category', 'is_active',
'use_count', 'created_by', 'created_by_email', 'created_by_full_name',
'created_at'
]
read_only_fields = ['id', 'use_count', 'created_by', 'created_by_email',
'created_by_full_name', 'created_at']
def create(self, validated_data):
# Set created_by to requesting user
user = self.context['request'].user
validated_data['created_by'] = user
# Set tenant based on request context
if 'tenant' not in validated_data or validated_data['tenant'] is None:
if hasattr(user, 'tenant') and user.tenant:
validated_data['tenant'] = user.tenant
# Platform admins can create platform-wide responses (tenant=null)
return super().create(validated_data)

View File

@@ -1,70 +1,223 @@
import logging
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
from django.contrib.contenttypes.models import ContentType
from .models import Ticket, TicketComment
from notifications.models import Notification # Assuming notifications app is installed
from smoothschedule.users.models import User
logger = logging.getLogger(__name__)
def send_websocket_notification(group_name, message_data):
"""Safely send a WebSocket notification, handling errors gracefully."""
try:
channel_layer = get_channel_layer()
if channel_layer is None:
logger.warning("Channel layer not configured, skipping WebSocket notification")
return
async_to_sync(channel_layer.group_send)(
group_name,
{
"type": "notification_message",
"message": message_data
}
)
except Exception as e:
logger.error(f"Failed to send WebSocket notification to {group_name}: {e}")
def create_notification(recipient, actor, verb, action_object, target, data):
"""Safely create a notification, handling import and creation errors."""
try:
from notifications.models import Notification
Notification.objects.create(
recipient=recipient,
actor=actor,
verb=verb,
action_object=action_object,
target=target,
data=data
)
except ImportError:
logger.warning("notifications app not installed, skipping notification creation")
except Exception as e:
logger.error(f"Failed to create notification for {recipient}: {e}")
def get_platform_support_team():
"""Get all platform support team members."""
try:
return User.objects.filter(
role__in=[User.Role.PLATFORM_SUPPORT, User.Role.PLATFORM_MANAGER, User.Role.SUPERUSER],
is_active=True
)
except Exception as e:
logger.error(f"Failed to fetch platform support team: {e}")
return User.objects.none()
def get_tenant_managers(tenant):
"""Get all owners and managers for a tenant."""
try:
if not tenant:
return User.objects.none()
return User.objects.filter(
tenant=tenant,
role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER],
is_active=True
)
except Exception as e:
logger.error(f"Failed to fetch tenant managers for {tenant}: {e}")
return User.objects.none()
@receiver(post_save, sender=Ticket)
def ticket_notification_handler(sender, instance, created, **kwargs):
channel_layer = get_channel_layer()
"""Handle ticket save events and send notifications."""
try:
if created:
# Handle ticket creation notifications
_handle_ticket_creation(instance)
else:
# Handle ticket update notifications
_handle_ticket_update(instance)
except Exception as e:
logger.error(f"Error in ticket_notification_handler for ticket {instance.id}: {e}")
# Logic for Ticket assignment and status change
if not created: # Only on update
# Get old instance (this is tricky with post_save, usually requires pre_save)
# For simplicity, we'll assume the instance passed is the new state,
# and compare against previous state if we had it.
# For now, let's just trigger on any save after creation, and focus on assignee.
# Notify assignee if changed
if instance.assignee:
# Check if assignee actually changed (requires pre_save or a custom field for old value)
# For this iteration, let's just notify the assignee on any update to the ticket they are assigned to
# Or if it's a new assignment.
def _handle_ticket_creation(ticket):
"""Send notifications when a ticket is created."""
try:
creator_name = ticket.creator.full_name if ticket.creator else "Someone"
if ticket.ticket_type == Ticket.TicketType.PLATFORM:
# PLATFORM tickets: Notify platform support team
platform_team = get_platform_support_team()
for member in platform_team:
create_notification(
recipient=member,
actor=ticket.creator,
verb=f"New platform support ticket #{ticket.id}: '{ticket.subject}'",
action_object=ticket,
target=ticket,
data={
'ticket_id': ticket.id,
'subject': ticket.subject,
'priority': ticket.priority,
'category': ticket.category
}
)
send_websocket_notification(
f"user_{member.id}",
{
"type": "new_ticket",
"ticket_id": ticket.id,
"subject": ticket.subject,
"ticket_type": ticket.ticket_type,
"priority": ticket.priority,
"creator_name": creator_name,
"message": f"New platform support ticket from {creator_name}: {ticket.subject}"
}
)
elif ticket.ticket_type in [
Ticket.TicketType.CUSTOMER,
Ticket.TicketType.STAFF_REQUEST,
Ticket.TicketType.INTERNAL
]:
# CUSTOMER, STAFF_REQUEST, INTERNAL tickets: Notify tenant owner/managers
tenant_managers = get_tenant_managers(ticket.tenant)
ticket_type_display = ticket.get_ticket_type_display()
for manager in tenant_managers:
create_notification(
recipient=manager,
actor=ticket.creator,
verb=f"New {ticket_type_display.lower()} ticket #{ticket.id}: '{ticket.subject}'",
action_object=ticket,
target=ticket,
data={
'ticket_id': ticket.id,
'subject': ticket.subject,
'priority': ticket.priority,
'category': ticket.category,
'ticket_type': ticket.ticket_type
}
)
send_websocket_notification(
f"user_{manager.id}",
{
"type": "new_ticket",
"ticket_id": ticket.id,
"subject": ticket.subject,
"ticket_type": ticket.ticket_type,
"priority": ticket.priority,
"creator_name": creator_name,
"message": f"New {ticket_type_display.lower()} from {creator_name}: {ticket.subject}"
}
)
except Exception as e:
logger.error(f"Error handling ticket creation for ticket {ticket.id}: {e}")
def _handle_ticket_update(ticket):
"""Send notifications when a ticket is updated."""
try:
# Notify assignee if one exists
if not ticket.assignee:
return
# Create Notification object for the assignee
Notification.objects.create(
recipient=instance.assignee,
actor=instance.creator, # The one who created the ticket (can be updated later)
verb=f"Ticket #{instance.id} '{instance.subject}' was updated.",
action_object=instance,
target=instance,
data={'ticket_id': instance.id, 'subject': instance.subject, 'status': instance.status}
create_notification(
recipient=ticket.assignee,
actor=ticket.creator,
verb=f"Ticket #{ticket.id} '{ticket.subject}' was updated.",
action_object=ticket,
target=ticket,
data={'ticket_id': ticket.id, 'subject': ticket.subject, 'status': ticket.status}
)
# Send WebSocket message to assignee's personal channel
async_to_sync(channel_layer.group_send)(
f"user_{instance.assignee.id}",
send_websocket_notification(
f"user_{ticket.assignee.id}",
{
"type": "notification_message",
"message": {
"type": "ticket_update",
"ticket_id": instance.id,
"subject": instance.subject,
"status": instance.status,
"assignee_id": str(instance.assignee.id),
"message": f"Ticket #{instance.id} '{instance.subject}' updated. Status: {instance.status}"
}
"ticket_id": ticket.id,
"subject": ticket.subject,
"status": ticket.status,
"assignee_id": str(ticket.assignee.id),
"message": f"Ticket #{ticket.id} '{ticket.subject}' updated. Status: {ticket.status}"
}
)
# General notification for tenant/platform admins (if needed)
# This might be too broad, usually target specific groups/users
pass
except Exception as e:
logger.error(f"Error handling ticket update for ticket {ticket.id}: {e}")
@receiver(post_save, sender=TicketComment)
def comment_notification_handler(sender, instance, created, **kwargs):
if created:
channel_layer = get_channel_layer()
"""Handle comment creation and send notifications to relevant parties."""
if not created:
return
try:
ticket = instance.ticket
author_name = instance.author.full_name if instance.author else "Someone"
# Track first_response_at: when a comment is added by someone other than the ticket creator
if not ticket.first_response_at and instance.author and instance.author != ticket.creator:
try:
ticket.first_response_at = timezone.now()
ticket.save(update_fields=['first_response_at'])
logger.info(f"Set first_response_at for ticket {ticket.id}")
except Exception as e:
logger.error(f"Failed to set first_response_at for ticket {ticket.id}: {e}")
# Notify creator of the ticket (if not the commenter)
if ticket.creator and ticket.creator != instance.author:
# Create Notification object for the ticket creator
Notification.objects.create(
create_notification(
recipient=ticket.creator,
actor=instance.author,
verb=f"New comment on your ticket #{ticket.id} '{ticket.subject}'.",
@@ -73,26 +226,21 @@ def comment_notification_handler(sender, instance, created, **kwargs):
data={'ticket_id': ticket.id, 'subject': ticket.subject, 'comment_id': instance.id}
)
# Send WebSocket message to creator's personal channel
async_to_sync(channel_layer.group_send)(
send_websocket_notification(
f"user_{ticket.creator.id}",
{
"type": "notification_message",
"message": {
"type": "new_comment",
"ticket_id": ticket.id,
"subject": ticket.subject,
"comment_id": instance.id,
"author_name": instance.author.full_name,
"message": f"New comment on your ticket #{ticket.id} from {instance.author.full_name}."
}
"author_name": author_name,
"message": f"New comment on your ticket #{ticket.id} from {author_name}."
}
)
# Notify assignee of the ticket (if not the commenter and not the creator)
if ticket.assignee and ticket.assignee != instance.author and ticket.assignee != ticket.creator:
# Create Notification object for the ticket assignee
Notification.objects.create(
create_notification(
recipient=ticket.assignee,
actor=instance.author,
verb=f"New comment on ticket #{ticket.id} '{ticket.subject}' you are assigned to.",
@@ -100,18 +248,17 @@ def comment_notification_handler(sender, instance, created, **kwargs):
target=ticket,
data={'ticket_id': ticket.id, 'subject': ticket.subject, 'comment_id': instance.id}
)
# Send WebSocket message to assignee's personal channel
async_to_sync(channel_layer.group_send)(
send_websocket_notification(
f"user_{ticket.assignee.id}",
{
"type": "notification_message",
"message": {
"type": "new_comment",
"ticket_id": ticket.id,
"subject": ticket.subject,
"comment_id": instance.id,
"author_name": instance.author.full_name,
"message": f"New comment on ticket #{ticket.id} you are assigned to from {instance.author.full_name}."
}
"author_name": author_name,
"message": f"New comment on ticket #{ticket.id} you are assigned to from {author_name}."
}
)
except Exception as e:
logger.error(f"Error in comment_notification_handler for comment {instance.id}: {e}")

View File

@@ -1,19 +1,32 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import TicketViewSet, TicketCommentViewSet
from .views import (
TicketViewSet, TicketCommentViewSet,
TicketTemplateViewSet, CannedResponseViewSet
)
app_name = 'tickets'
router = DefaultRouter()
router.register(r'tickets', TicketViewSet, basename='ticket')
# Main tickets endpoint - will be at /api/tickets/
router.register(r'', TicketViewSet, basename='ticket')
# Nested comments route
# Nested comments route - will be at /api/tickets/{ticket_pk}/comments/
router.register(
r'tickets/(?P<ticket_pk>[^/.]+)/comments',
r'(?P<ticket_pk>[^/.]+)/comments',
TicketCommentViewSet,
basename='ticket-comment'
)
# Separate router for templates and canned responses
templates_router = DefaultRouter()
templates_router.register(r'', TicketTemplateViewSet, basename='ticket-template')
canned_router = DefaultRouter()
canned_router.register(r'', CannedResponseViewSet, basename='canned-response')
urlpatterns = [
path('', include(router.urls)),
path('templates/', include(templates_router.urls)),
path('canned-responses/', include(canned_router.urls)),
]

View File

@@ -1,12 +1,31 @@
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from django.db.models import Q
from rest_framework.filters import OrderingFilter, SearchFilter
from core.models import Tenant
from smoothschedule.users.models import User
from .models import Ticket, TicketComment
from .serializers import TicketSerializer, TicketCommentSerializer
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse
from .serializers import (
TicketSerializer, TicketListSerializer, TicketCommentSerializer,
TicketTemplateSerializer, CannedResponseSerializer
)
def is_platform_admin(user):
"""Check if user is a platform-level administrator."""
return user.role in [
User.Role.SUPERUSER,
User.Role.PLATFORM_MANAGER,
User.Role.PLATFORM_SUPPORT,
]
def is_customer(user):
"""Check if user is a customer."""
return user.role == User.Role.CUSTOMER
class IsTenantUser(IsAuthenticated):
@@ -18,7 +37,7 @@ class IsTenantUser(IsAuthenticated):
if not super().has_permission(request, view):
return False
# Platform admins can do anything
if request.user.is_platform_admin:
if is_platform_admin(request.user):
return True
# Tenant users can only access their own tenant's data
return hasattr(request.user, 'tenant') and request.user.tenant is not None
@@ -29,7 +48,7 @@ class IsTicketOwnerOrAssigneeOrPlatformAdmin(IsTenantUser):
Custom permission to only allow owners, assignees, or platform admins to view/edit tickets.
"""
def has_object_permission(self, request, view, obj):
if request.user.is_platform_admin:
if is_platform_admin(request.user):
return True
if request.user == obj.creator or request.user == obj.assignee:
return True
@@ -43,10 +62,21 @@ class IsTicketOwnerOrAssigneeOrPlatformAdmin(IsTenantUser):
class TicketViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows tickets to be viewed or edited.
Includes filtering by status, priority, category, ticket_type, and assignee.
"""
queryset = Ticket.objects.all().select_related('tenant', 'creator', 'assignee')
serializer_class = TicketSerializer
permission_classes = [IsTicketOwnerOrAssigneeOrPlatformAdmin]
filter_backends = [OrderingFilter, SearchFilter]
ordering_fields = ['created_at', 'updated_at', 'priority', 'status', 'due_at']
ordering = ['-created_at']
search_fields = ['subject', 'description']
def get_serializer_class(self):
"""Use TicketListSerializer for list view, TicketSerializer for detail view."""
if self.action == 'list':
return TicketListSerializer
return TicketSerializer
def get_queryset(self):
"""
@@ -59,17 +89,36 @@ class TicketViewSet(viewsets.ModelViewSet):
user = self.request.user
queryset = super().get_queryset()
if user.is_platform_admin:
return queryset # Platform admins see everything
if hasattr(user, 'tenant') and user.tenant:
if is_platform_admin(user):
queryset = queryset # Platform admins see everything
elif hasattr(user, 'tenant') and user.tenant:
# Tenant-level users
q_filter = Q(tenant=user.tenant) | Q(creator=user, ticket_type=Ticket.TicketType.PLATFORM)
return queryset.filter(q_filter).distinct()
queryset = queryset.filter(q_filter).distinct()
else:
# Regular users (e.g., customers without an associated tenant, if that's a case)
# They should only see tickets they created
return queryset.filter(creator=user)
queryset = queryset.filter(creator=user)
# Apply query parameter filters
status_filter = self.request.query_params.get('status')
priority_filter = self.request.query_params.get('priority')
category_filter = self.request.query_params.get('category')
ticket_type_filter = self.request.query_params.get('ticket_type')
assignee_filter = self.request.query_params.get('assignee')
if status_filter:
queryset = queryset.filter(status=status_filter)
if priority_filter:
queryset = queryset.filter(priority=priority_filter)
if category_filter:
queryset = queryset.filter(category=category_filter)
if ticket_type_filter:
queryset = queryset.filter(ticket_type=ticket_type_filter)
if assignee_filter:
queryset = queryset.filter(assignee_id=assignee_filter)
return queryset
def perform_create(self, serializer):
# Creator is automatically set by the serializer
@@ -82,6 +131,65 @@ class TicketViewSet(viewsets.ModelViewSet):
serializer.validated_data.pop('tenant', None)
serializer.save()
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
def my_tickets(self, request):
"""
Get all tickets created by or assigned to the requesting user.
URL: /api/tickets/my-tickets/
"""
user = request.user
queryset = self.get_queryset().filter(
Q(creator=user) | Q(assignee=user)
).distinct()
# Apply filters
queryset = self.filter_queryset(queryset)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
def tenant_tickets(self, request):
"""
Get all tickets for the business owner's tenant.
Only accessible by tenant owners, managers, and staff.
URL: /api/tickets/tenant-tickets/
"""
user = request.user
# Check if user has tenant access
if not hasattr(user, 'tenant') or not user.tenant:
return Response(
{'error': 'You do not have access to tenant tickets.'},
status=status.HTTP_403_FORBIDDEN
)
# Customers should use my-tickets instead
if is_customer(user):
return Response(
{'error': 'Customers should use the my-tickets endpoint.'},
status=status.HTTP_403_FORBIDDEN
)
# Get all tickets for the user's tenant
queryset = self.get_queryset().filter(tenant=user.tenant)
# Apply filters
queryset = self.filter_queryset(queryset)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class TicketCommentViewSet(viewsets.ModelViewSet):
"""
@@ -100,7 +208,7 @@ class TicketCommentViewSet(viewsets.ModelViewSet):
queryset = queryset.filter(ticket__pk=ticket_pk)
user = self.request.user
if user.is_platform_admin:
if is_platform_admin(user):
return queryset # Platform admins see all comments
# For tenant-level users, ensure they can only see comments for tickets they can access
@@ -112,7 +220,7 @@ class TicketCommentViewSet(viewsets.ModelViewSet):
queryset = queryset.filter(ticket__creator=user)
# Hide internal comments from customers
if user.is_customer: # Assuming there's an `is_customer` property or role check
if is_customer(user):
queryset = queryset.filter(is_internal=False)
return queryset
@@ -127,3 +235,89 @@ class TicketCommentViewSet(viewsets.ModelViewSet):
# Author is automatically set to the requesting user
serializer.save(ticket=ticket, author=self.request.user)
class TicketTemplateViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing ticket templates.
Platform admins can see all templates. Tenant users see their own + platform-wide templates.
"""
queryset = TicketTemplate.objects.all()
serializer_class = TicketTemplateSerializer
permission_classes = [IsAuthenticated]
filter_backends = [OrderingFilter, SearchFilter]
ordering_fields = ['created_at', 'name']
ordering = ['ticket_type', 'name']
search_fields = ['name', 'description']
def get_queryset(self):
"""
Filter templates based on user role.
- Platform admins see all templates
- Tenant users see their own templates + platform-wide templates (tenant=null)
"""
user = self.request.user
queryset = super().get_queryset()
if is_platform_admin(user):
return queryset
if hasattr(user, 'tenant') and user.tenant:
# Tenant users see their own templates + platform-wide templates
return queryset.filter(Q(tenant=user.tenant) | Q(tenant__isnull=True))
else:
# Users without a tenant can only see platform-wide templates
return queryset.filter(tenant__isnull=True)
def perform_create(self, serializer):
# Tenant is automatically set by the serializer
serializer.save()
class CannedResponseViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing canned responses.
Platform admins can see all responses. Tenant users see their own + platform-wide responses.
"""
queryset = CannedResponse.objects.all().select_related('tenant', 'created_by')
serializer_class = CannedResponseSerializer
permission_classes = [IsAuthenticated]
filter_backends = [OrderingFilter, SearchFilter]
ordering_fields = ['created_at', 'use_count', 'title']
ordering = ['-use_count', 'title']
search_fields = ['title', 'content']
def get_queryset(self):
"""
Filter canned responses based on user role.
- Platform admins see all responses
- Tenant users see their own responses + platform-wide responses (tenant=null)
"""
user = self.request.user
queryset = super().get_queryset()
if is_platform_admin(user):
return queryset
if hasattr(user, 'tenant') and user.tenant:
# Tenant users see their own responses + platform-wide responses
return queryset.filter(Q(tenant=user.tenant) | Q(tenant__isnull=True))
else:
# Users without a tenant can only see platform-wide responses
return queryset.filter(tenant__isnull=True)
def perform_create(self, serializer):
# Tenant and created_by are automatically set by the serializer
serializer.save()
@action(detail=True, methods=['post'], permission_classes=[IsAuthenticated])
def use(self, request, pk=None):
"""
Increment the use_count for a canned response.
URL: /api/canned-responses/{id}/use/
"""
canned_response = self.get_object()
canned_response.use_count += 1
canned_response.save(update_fields=['use_count'])
serializer = self.get_serializer(canned_response)
return Response(serializer.data)