From 512d95ca2dad541b7eeeadeba88d301b4e43b3b2 Mon Sep 17 00:00:00 2001 From: poduck Date: Fri, 28 Nov 2025 04:56:48 -0500 Subject: [PATCH] feat: Implement frontend for business owners' support ticket system --- frontend/src/App.tsx | 4 + frontend/src/api/tickets.ts | 36 ++ frontend/src/components/Sidebar.tsx | 4 + frontend/src/components/TicketModal.tsx | 307 ++++++++++++++++++ .../src/hooks/useNotificationWebSocket.ts | 74 +++++ frontend/src/hooks/useTickets.ts | 176 ++++++++++ frontend/src/hooks/useUsers.ts | 17 + frontend/src/i18n/locales/en.json | 35 +- frontend/src/pages/TicketsPage.tsx | 201 ++++++++++++ frontend/src/types.ts | 35 +- 10 files changed, 884 insertions(+), 5 deletions(-) create mode 100644 frontend/src/api/tickets.ts create mode 100644 frontend/src/components/TicketModal.tsx create mode 100644 frontend/src/hooks/useNotificationWebSocket.ts create mode 100644 frontend/src/hooks/useTickets.ts create mode 100644 frontend/src/hooks/useUsers.ts create mode 100644 frontend/src/pages/TicketsPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 60de0ba..d9f4b1a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -55,6 +55,8 @@ 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 { Toaster } from 'react-hot-toast'; // Import Toaster for notifications const queryClient = new QueryClient({ defaultOptions: { @@ -467,6 +469,7 @@ const AppContent: React.FC = () => { element={user.role === 'resource' ? : } /> } /> + } /> { + {/* Add Toaster component for notifications */} ); }; diff --git a/frontend/src/api/tickets.ts b/frontend/src/api/tickets.ts new file mode 100644 index 0000000..e7e23a8 --- /dev/null +++ b/frontend/src/api/tickets.ts @@ -0,0 +1,36 @@ +import { apiClient } from './client'; +import { Ticket, TicketComment } from '../types'; // Assuming types.ts will define these + +export const getTickets = async (): Promise => { + const response = await apiClient.get('/api/tickets/'); + return response.data; +}; + +export const getTicket = async (id: string): Promise => { + const response = await apiClient.get(`/api/tickets/${id}/`); + return response.data; +}; + +export const createTicket = async (data: Partial): Promise => { + const response = await apiClient.post('/api/tickets/', data); + return response.data; +}; + +export const updateTicket = async (id: string, data: Partial): Promise => { + const response = await apiClient.patch(`/api/tickets/${id}/`, data); + return response.data; +}; + +export const deleteTicket = async (id: string): Promise => { + await apiClient.delete(`/api/tickets/${id}/`); +}; + +export const getTicketComments = async (ticketId: string): Promise => { + const response = await apiClient.get(`/api/tickets/${ticketId}/comments/`); + return response.data; +}; + +export const createTicketComment = async (ticketId: string, data: Partial): Promise => { + const response = await apiClient.post(`/api/tickets/${ticketId}/comments/`, data); + return response.data; +}; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 8f335cc..4856971 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -134,6 +134,10 @@ 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 new file mode 100644 index 0000000..d56f136 --- /dev/null +++ b/frontend/src/components/TicketModal.tsx @@ -0,0 +1,307 @@ +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 { 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'; + +interface TicketModalProps { + ticket?: Ticket | null; // If provided, it's an edit/detail view + onClose: () => void; +} + +const TicketModal: React.FC = ({ ticket, onClose }) => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); // Access the query client for invalidation/updates + 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 [assigneeId, setAssigneeId] = useState(ticket?.assignee); + const [status, setStatus] = useState(ticket?.status || 'OPEN'); + const [newCommentText, setNewCommentText] = useState(''); + const [isInternalComment, setIsInternalComment] = useState(false); + + // Fetch users for assignee dropdown + const { data: users = [] } = useUsers(); // Assuming useUsers fetches all relevant users (staff/platform admins) + + // Fetch comments for the ticket if in detail/edit mode + const { data: comments, isLoading: isLoadingComments } = useTicketComments(ticket?.id); + + // Mutations + const createTicketMutation = useCreateTicket(); + const updateTicketMutation = useUpdateTicket(); + const createCommentMutation = useCreateTicketComment(); + + useEffect(() => { + if (ticket) { + setSubject(ticket.subject); + setDescription(ticket.description); + setPriority(ticket.priority); + setCategory(ticket.category || ''); + setAssigneeId(ticket.assignee); + setStatus(ticket.status); + } else { + // Reset form for new ticket creation + setSubject(''); + setDescription(''); + setPriority('MEDIUM'); + setCategory(''); + setAssigneeId(undefined); + setStatus('OPEN'); + } + }, [ticket]); + + const handleSubmitTicket = async (e: React.FormEvent) => { + e.preventDefault(); + + const ticketData = { + subject, + description, + priority, + category: category || undefined, + assignee: assigneeId, + status: status, + ticketType: 'PLATFORM', // For now, assume platform tickets from this modal + }; + + if (ticket) { + await updateTicketMutation.mutateAsync({ id: ticket.id, updates: ticketData }); + } else { + await createTicketMutation.mutateAsync(ticketData); + } + onClose(); + }; + + const handleAddComment = async (e: React.FormEvent) => { + e.preventDefault(); + if (!ticket?.id || !newCommentText.trim()) return; + + const commentData: Partial = { + commentText: newCommentText.trim(), + isInternal: isInternalComment, + // author and ticket are handled by the backend + }; + + await createCommentMutation.mutateAsync({ ticketId: ticket.id, commentData }); + setNewCommentText(''); + setIsInternalComment(false); + // Invalidate comments query to refetch new comment + queryClient.invalidateQueries({ queryKey: ['ticketComments', ticket.id] }); + }; + + const statusOptions = Object.values(TicketStatus); + const priorityOptions = Object.values(TicketPriority); + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+

+ {ticket ? t('tickets.ticketDetails') : t('tickets.newTicket')} +

+ +
+ + {/* Form / Details */} +
+
+ {/* Subject */} +
+ + setSubject(e.target.value)} + 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" + required + disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending} // Disable if viewing existing and not actively editing + /> +
+ + {/* Description */} +
+ +