From 200a6b3dd46bfe829936b82eed8a4e8e6fc86ba6 Mon Sep 17 00:00:00 2001 From: poduck Date: Fri, 28 Nov 2025 05:32:36 -0500 Subject: [PATCH] feat: Enhance ticketing system with categories, templates, SLA tracking, and fix frontend integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/package-lock.json | 75 ++++- frontend/package.json | 3 + frontend/src/App.tsx | 4 +- frontend/src/api/tickets.ts | 40 ++- frontend/src/components/Sidebar.tsx | 5 +- frontend/src/components/TicketModal.tsx | 82 +++-- frontend/src/hooks/useBusiness.ts | 1 + frontend/src/hooks/useTickets.ts | 170 +++++++--- frontend/src/hooks/useUsers.ts | 2 +- frontend/src/i18n/locales/en.json | 58 +++- frontend/src/layouts/BusinessLayout.tsx | 3 + frontend/src/pages/Tickets.tsx | 294 ++++++++++++++++++ frontend/src/pages/TicketsPage.tsx | 201 ------------ .../src/pages/platform/PlatformSupport.tsx | 229 +++++++++++--- frontend/src/types.ts | 49 ++- smoothschedule/tickets/admin.py | 92 +++++- ...e_tickettemplate_ticket_due_at_and_more.py | 103 ++++++ smoothschedule/tickets/models.py | 176 ++++++++++- smoothschedule/tickets/serializers.py | 86 ++++- smoothschedule/tickets/signals.py | 291 ++++++++++++----- smoothschedule/tickets/urls.py | 21 +- smoothschedule/tickets/views.py | 222 ++++++++++++- 22 files changed, 1782 insertions(+), 425 deletions(-) create mode 100644 frontend/src/pages/Tickets.tsx delete mode 100644 frontend/src/pages/TicketsPage.tsx create mode 100644 smoothschedule/tickets/migrations/0002_cannedresponse_tickettemplate_ticket_due_at_and_more.py diff --git a/frontend/package-lock.json b/frontend/package-lock.json index af04d43..f35b032 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 68fe5f4..9daac16 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d9f4b1a..caf6be6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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' ? : } /> } /> - } /> + } /> => { - 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 => { + 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 => { + const response = await apiClient.get('/api/tickets/templates/'); + return response.data; +}; + +export const getTicketTemplate = async (id: string): Promise => { + const response = await apiClient.get(`/api/tickets/templates/${id}/`); + return response.data; +}; + +// Canned Responses +export const getCannedResponses = async (): Promise => { + const response = await apiClient.get('/api/tickets/canned-responses/'); + return response.data; +}; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 4856971..82cee81 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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 = ({ business, user, isCollapsed, toggleCo {!isCollapsed && {t('nav.resources')}} - {/* Using LayoutDashboard icon for now, can change */} + {!isCollapsed && {t('nav.tickets')}} diff --git a/frontend/src/components/TicketModal.tsx b/frontend/src/components/TicketModal.tsx index d56f136..b9075e3 100644 --- a/frontend/src/components/TicketModal.tsx +++ b/frontend/src/components/TicketModal.tsx @@ -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 = ({ ticket, onClose }) => { +// Category options grouped by ticket type +const CATEGORY_OPTIONS: Record = { + 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 = ({ 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(ticket?.priority || 'MEDIUM'); - const [category, setCategory] = useState(ticket?.category || ''); + const [category, setCategory] = useState(ticket?.category || 'OTHER'); + const [ticketType, setTicketType] = useState(ticket?.ticketType || defaultTicketType); const [assigneeId, setAssigneeId] = useState(ticket?.assignee); const [status, setStatus] = useState(ticket?.status || 'OPEN'); const [newCommentText, setNewCommentText] = useState(''); @@ -34,12 +44,16 @@ const TicketModal: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 (
@@ -142,6 +165,25 @@ const TicketModal: React.FC = ({ ticket, onClose }) => { />
+ {/* Ticket Type (only for new tickets) */} + {!ticket && ( +
+ + +
+ )} + {/* Priority & Category */}
@@ -156,7 +198,7 @@ const TicketModal: React.FC = ({ ticket, onClose }) => { disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending} > {priorityOptions.map(opt => ( - + ))}
@@ -164,15 +206,17 @@ const TicketModal: React.FC = ({ ticket, onClose }) => { - 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 => ( + + ))} +
diff --git a/frontend/src/hooks/useBusiness.ts b/frontend/src/hooks/useBusiness.ts index 7fdc3f0..38dd8cb 100644 --- a/frontend/src/hooks/useBusiness.ts +++ b/frontend/src/hooks/useBusiness.ts @@ -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, }; diff --git a/frontend/src/hooks/useTickets.ts b/frontend/src/hooks/useTickets.ts index ee32a65..46f9e51 100644 --- a/frontend/src/hooks/useTickets.ts +++ b/frontend/src/hooks/useTickets.ts @@ -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({ 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({ 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({ + 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({ + 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({ + 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, + })); + }, + }); +}; diff --git a/frontend/src/hooks/useUsers.ts b/frontend/src/hooks/useUsers.ts index a644576..3d1c98c 100644 --- a/frontend/src/hooks/useUsers.ts +++ b/frontend/src/hooks/useUsers.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { apiClient } from '../api/client'; +import apiClient from '../api/client'; import { User } from '../types'; /** diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index bb7e6b4..5ab4d6f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/layouts/BusinessLayout.tsx b/frontend/src/layouts/BusinessLayout.tsx index c4ac758..bd99ee1 100644 --- a/frontend/src/layouts/BusinessLayout.tsx +++ b/frontend/src/layouts/BusinessLayout.tsx @@ -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 = ({ 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 ? { diff --git a/frontend/src/pages/Tickets.tsx b/frontend/src/pages/Tickets.tsx new file mode 100644 index 0000000..b9854d4 --- /dev/null +++ b/frontend/src/pages/Tickets.tsx @@ -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 ( + + + {config.label} + + ); +}; + +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 ( + + + {config.label} + + ); +}; + +const Tickets: React.FC = () => { + const { t } = useTranslation(); + const { data: currentUser } = useCurrentUser(); + const [statusFilter, setStatusFilter] = useState('ALL'); + const [isTicketModalOpen, setIsTicketModalOpen] = useState(false); + const [selectedTicket, setSelectedTicket] = useState(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 ( +
+
+
+
+
+ ); + } + + if (error) { + return ( +
+
+

+ {t('tickets.errorLoading', 'Error loading tickets')}: {(error as Error).message} +

+
+
+ ); + } + + 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 ( +
+ {/* Header */} +
+
+

+ {t('tickets.title', 'Support Tickets')} +

+

+ {isOwnerOrManager + ? t('tickets.descriptionOwner', 'Manage support tickets for your business') + : t('tickets.descriptionStaff', 'View and create support tickets')} +

+
+ +
+ + {/* Status Filter Tabs */} +
+
+ +
+ + {/* Tickets Grid */} +
+ {filteredTickets.length === 0 ? ( +
+ +

+ {statusFilter === 'ALL' + ? t('tickets.noTicketsFound', 'No tickets found') + : t('tickets.noTicketsInStatus', `No ${statusFilter.toLowerCase().replace('_', ' ')} tickets`)} +

+
+ ) : ( +
+ {filteredTickets.map(ticket => ( +
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" + > +
+
+
+

+ {ticket.subject} +

+
+ +

+ {ticket.description} +

+ +
+ + + + {ticket.category && ( + + {ticket.category} + + )} + + + + {ticket.creatorFullName || ticket.creatorEmail} + + + + + {new Date(ticket.createdAt).toLocaleDateString()} + +
+
+ +
+ {ticket.assigneeFullName ? ( +
+
{t('tickets.assignedTo', 'Assigned to')}
+
+ + + {ticket.assigneeFullName} + +
+
+ ) : ( + + {t('tickets.unassigned', 'Unassigned')} + + )} +
+
+
+ ))} +
+ )} +
+
+ + {/* Ticket Create/Detail Modal */} + {isTicketModalOpen && ( + + )} +
+ ); +}; + +export default Tickets; diff --git a/frontend/src/pages/TicketsPage.tsx b/frontend/src/pages/TicketsPage.tsx deleted file mode 100644 index 7c546a1..0000000 --- a/frontend/src/pages/TicketsPage.tsx +++ /dev/null @@ -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 ( - - {status.replace('_', ' ').toLowerCase()} - - ); -}; - -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 ( - - {priority.toLowerCase()} - - ); -}; - -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(null); - - const openTicketModal = (ticket: Ticket | null = null) => { - setSelectedTicket(ticket); - setIsTicketModalOpen(true); - }; - - const closeTicketModal = () => { - setSelectedTicket(null); - setIsTicketModalOpen(false); - }; - - if (isLoading) { - return ( -
-
-
-
-
- ); - } - - if (error) { - return ( -
-
-

{t('tickets.errorLoading')}: {(error as Error).message}

-
-
- ); - } - - return ( -
- {/* Header */} -
-
-

{t('tickets.title')}

-

{t('tickets.description')}

-
- -
- - {/* Tickets List */} -
-
- - - - - - - - - - - - - - {tickets && tickets.length > 0 ? ( - tickets.map((ticket) => ( - - - - - - - - - - )) - ) : ( - - - - )} - -
- {t('tickets.subject')} - - {t('tickets.status')} - - {t('tickets.priority')} - - {t('tickets.category')} - - {t('tickets.assignee')} - - {t('tickets.createdAt')} - - {t('common.actions')} -
-
-
{ticket.subject}
-
-
- - - - - {ticket.category || t('common.none')} - - {ticket.assigneeFullName ? ( -
- - {ticket.assigneeFullName} -
- ) : ( - {t('tickets.unassigned')} - )} -
- {new Date(ticket.createdAt).toLocaleDateString()} - - -
- {t('tickets.noTicketsFound')} -
-
-
- - {/* Ticket Create/Detail Modal */} - {isTicketModalOpen && ( - - )} -
- ); -}; - -export default TicketsPage; \ No newline at end of file diff --git a/frontend/src/pages/platform/PlatformSupport.tsx b/frontend/src/pages/platform/PlatformSupport.tsx index 6fe455f..a404b46 100644 --- a/frontend/src/pages/platform/PlatformSupport.tsx +++ b/frontend/src/pages/platform/PlatformSupport.tsx @@ -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(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [statusFilter, setStatusFilter] = useState('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 ; + case 'IN_PROGRESS': return ; + case 'AWAITING_RESPONSE': return ; + case 'RESOLVED': return ; + case 'CLOSED': return ; + default: return ; + } + }; + + 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 ( +
+ +

{t('tickets.errorLoading')}

+
+ ); + } + return (
@@ -13,54 +81,119 @@ const PlatformSupport: React.FC = () => {

{t('platform.supportTickets')}

{t('platform.supportDescription')}

+
-
- {SUPPORT_TICKETS.map((ticket) => ( -
-
-
-
- -
-
-
-

{ticket.subject}

- - {ticket.id} - -
-

{t('platform.reportedBy')} {ticket.businessName}

-
- - {ticket.createdAt.toLocaleDateString()} - - - {ticket.status === 'Open' && } - {ticket.status === 'Resolved' && } - {ticket.status} - -
-
-
-
- - {ticket.priority} {t('platform.priority')} - -
-
-
+ {/* Status Tabs */} +
+ {statusTabs.map((tab) => ( + ))}
+ + {isLoading ? ( +
+ +

{t('common.loading')}

+
+ ) : platformTickets.length === 0 ? ( +
+ +

{t('tickets.noTicketsFound')}

+
+ ) : ( +
+ {platformTickets.map((ticket) => ( +
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" + > +
+
+
+ +
+
+
+

{ticket.subject}

+ + #{ticket.id} + + + {t(`tickets.types.${ticket.ticketType.toLowerCase()}`)} + +
+

+ {t('platform.reportedBy')} {ticket.creatorFullName || ticket.creatorEmail} + {ticket.category && ( + + {t(`tickets.categories.${ticket.category.toLowerCase()}`)} + + )} +

+
+ + {new Date(ticket.createdAt).toLocaleDateString()} + + + {getStatusIcon(ticket.status)} + {t(`tickets.statuses.${ticket.status.toLowerCase()}`)} + + {ticket.assigneeFullName && ( + + {t('tickets.assignedTo')}: {ticket.assigneeFullName} + + )} +
+
+
+
+ + {t(`tickets.priorities.${ticket.priority.toLowerCase()}`)} + + {ticket.isOverdue && ( + + Overdue + + )} +
+
+
+ ))} +
+ )} + + {/* Ticket Modal */} + {isModalOpen && ( + + + + )}
); }; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a239538..3dfb826 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; diff --git a/smoothschedule/tickets/admin.py b/smoothschedule/tickets/admin.py index 8c38f3f..92a4bdb 100644 --- a/smoothschedule/tickets/admin.py +++ b/smoothschedule/tickets/admin.py @@ -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',) + }), + ) diff --git a/smoothschedule/tickets/migrations/0002_cannedresponse_tickettemplate_ticket_due_at_and_more.py b/smoothschedule/tickets/migrations/0002_cannedresponse_tickettemplate_ticket_due_at_and_more.py new file mode 100644 index 0000000..0aedbe6 --- /dev/null +++ b/smoothschedule/tickets/migrations/0002_cannedresponse_tickettemplate_ticket_due_at_and_more.py @@ -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'), + ), + ] diff --git a/smoothschedule/tickets/models.py b/smoothschedule/tickets/models.py index 564c092..46cfa70 100644 --- a/smoothschedule/tickets/models.py +++ b/smoothschedule/tickets/models.py @@ -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. diff --git a/smoothschedule/tickets/serializers.py b/smoothschedule/tickets/serializers.py index 036287d..d338494 100644 --- a/smoothschedule/tickets/serializers.py +++ b/smoothschedule/tickets/serializers.py @@ -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) diff --git a/smoothschedule/tickets/signals.py b/smoothschedule/tickets/signals.py index f006801..544b05f 100644 --- a/smoothschedule/tickets/signals.py +++ b/smoothschedule/tickets/signals.py @@ -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() - - # 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. + """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}") - # 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. - - # 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} - ) - # Send WebSocket message to assignee's personal channel - async_to_sync(channel_layer.group_send)( - f"user_{instance.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}" +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}" + } + ) - # General notification for tenant/platform admins (if needed) - # This might be too broad, usually target specific groups/users - pass + 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 + 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 + send_websocket_notification( + f"user_{ticket.assignee.id}", + { + "type": "ticket_update", + "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}" + } + ) + 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}." - } + "type": "new_comment", + "ticket_id": ticket.id, + "subject": ticket.subject, + "comment_id": instance.id, + "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}." - } + "type": "new_comment", + "ticket_id": ticket.id, + "subject": ticket.subject, + "comment_id": instance.id, + "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}") diff --git a/smoothschedule/tickets/urls.py b/smoothschedule/tickets/urls.py index 834687f..7595bfb 100644 --- a/smoothschedule/tickets/urls.py +++ b/smoothschedule/tickets/urls.py @@ -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[^/.]+)/comments', + r'(?P[^/.]+)/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)), ] \ No newline at end of file diff --git a/smoothschedule/tickets/views.py b/smoothschedule/tickets/views.py index fa21750..04686cf 100644 --- a/smoothschedule/tickets/views.py +++ b/smoothschedule/tickets/views.py @@ -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,8 +220,8 @@ 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 - queryset = queryset.filter(is_internal=False) + if is_customer(user): + queryset = queryset.filter(is_internal=False) return queryset @@ -126,4 +234,90 @@ class TicketCommentViewSet(viewsets.ModelViewSet): raise status.HTTP_404_NOT_FOUND # Author is automatically set to the requesting user - serializer.save(ticket=ticket, author=self.request.user) \ No newline at end of file + 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) \ No newline at end of file