feat: Implement frontend for business owners' support ticket system
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
307
frontend/src/components/TicketModal.tsx
Normal file
307
frontend/src/components/TicketModal.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user