feat: Implement frontend for business owners' support ticket system

This commit is contained in:
poduck
2025-11-28 04:56:48 -05:00
parent aa3854a13f
commit 512d95ca2d
10 changed files with 884 additions and 5 deletions

View File

@@ -134,6 +134,10 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
<ClipboardList size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.resources')}</span>}
</Link>
<Link to="/tickets" className={getNavClass('/tickets')} title={t('nav.tickets')}>
<LayoutDashboard size={20} className="shrink-0" /> {/* Using LayoutDashboard icon for now, can change */}
{!isCollapsed && <span>{t('nav.tickets')}</span>}
</Link>
</>
)}

View File

@@ -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<TicketModalProps> = ({ 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<TicketPriority>(ticket?.priority || 'MEDIUM');
const [category, setCategory] = useState(ticket?.category || '');
const [assigneeId, setAssigneeId] = useState<string | undefined>(ticket?.assignee);
const [status, setStatus] = useState<TicketStatus>(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<TicketComment> = {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{ticket ? t('tickets.ticketDetails') : t('tickets.newTicket')}
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 p-1 rounded-full">
<X size={20} />
</button>
</div>
{/* Form / Details */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
<form onSubmit={handleSubmitTicket} className="space-y-4">
{/* Subject */}
<div>
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('tickets.subject')}
</label>
<input
type="text"
id="subject"
value={subject}
onChange={(e) => 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
/>
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('tickets.description')}
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
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
/>
</div>
{/* Priority & Category */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('tickets.priority')}
</label>
<select
id="priority"
value={priority}
onChange={(e) => setPriority(e.target.value as TicketPriority)}
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"
disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending}
>
{priorityOptions.map(opt => (
<option key={opt} value={opt}>{t(`tickets.priority.${opt.toLowerCase()}`)}</option>
))}
</select>
</div>
<div>
<label htmlFor="category" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('tickets.category')}
</label>
<input
type="text"
id="category"
value={category}
onChange={(e) => setCategory(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"
placeholder={t('tickets.categoryPlaceholder')}
disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending}
/>
</div>
</div>
{/* Assignee & Status (only visible for existing tickets or if user has permission to assign) */}
{ticket && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="assignee" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('tickets.assignee')}
</label>
<select
id="assignee"
value={assigneeId || ''}
onChange={(e) => setAssigneeId(e.target.value || undefined)}
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"
>
<option value="">{t('tickets.unassigned')}</option>
{users.map(user => (
<option key={user.id} value={user.id}>{user.name}</option>
))}
</select>
</div>
<div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('tickets.status')}
</label>
<select
id="status"
value={status}
onChange={(e) => setStatus(e.target.value as TicketStatus)}
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"
>
{statusOptions.map(opt => (
<option key={opt} value={opt}>{t(`tickets.status.${opt.toLowerCase()}`)}</option>
))}
</select>
</div>
</div>
)}
{/* Submit Button for Ticket */}
{!ticket && ( // Only show submit for new tickets
<div className="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="submit"
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
disabled={createTicketMutation.isPending}
>
{createTicketMutation.isPending ? t('common.saving') : t('tickets.createTicket')}
</button>
</div>
)}
{ticket && ( // Show update button for existing tickets
<div className="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="submit"
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
disabled={updateTicketMutation.isPending}
>
{updateTicketMutation.isPending ? t('common.saving') : t('tickets.updateTicket')}
</button>
</div>
)}
</form>
{/* Comments Section */}
{ticket && (
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 space-y-4">
<h4 className="text-md font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<MessageSquare size={18} className="text-brand-500" /> {t('tickets.comments')}
</h4>
{isLoadingComments ? (
<div className="text-center text-gray-500 dark:text-gray-400">{t('common.loading')}</div>
) : comments && comments.length > 0 ? (
<div className="space-y-4 max-h-60 overflow-y-auto custom-scrollbar pr-2">
{comments.map((comment) => (
<div key={comment.id} className="bg-gray-50 dark:bg-gray-700 rounded-lg p-3 shadow-sm">
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-1">
<User size={12} />
<span>{comment.authorFullName || comment.authorEmail}</span>
{comment.isInternal && <span className="ml-2 px-1.5 py-0.5 rounded-full bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300 text-[10px]">{t('tickets.internal')}</span>}
</div>
<Clock size={12} className="inline-block mr-1" />
<span>{new Date(comment.createdAt).toLocaleString()}</span>
</div>
<p className="mt-2 text-sm text-gray-700 dark:text-gray-200">{comment.commentText}</p>
</div>
))}
</div>
) : (
<p className="text-gray-500 dark:text-gray-400 text-sm">{t('tickets.noComments')}</p>
)}
{/* Add Comment Form */}
<form onSubmit={handleAddComment} className="pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
<textarea
value={newCommentText}
onChange={(e) => setNewCommentText(e.target.value)}
rows={3}
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.addCommentPlaceholder')}
required
/>
<div className="flex items-center justify-between">
<label className="flex items-center text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={isInternalComment}
onChange={(e) => setIsInternalComment(e.target.checked)}
className="form-checkbox h-4 w-4 text-brand-600 transition duration-150 ease-in-out rounded border-gray-300 focus:ring-brand-500 mr-2"
/>
{t('tickets.internalComment')}
</label>
<button
type="submit"
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
disabled={createCommentMutation.isPending || !newCommentText.trim()}
>
<Send size={16} /> {createCommentMutation.isPending ? t('common.sending') : t('tickets.postComment')}
</button>
</div>
</form>
</div>
)}
</div>
</div>
</div>
);
};
export default TicketModal;