feat: ticketing system with technical/sales roles and department routing
Backend: - Added TECHNICAL and SALES roles to UserRole enum - Added TicketDepartment, TicketStatus, TicketPriority enums - Created Ticket and TicketMessage entities with relationships - Created TicketsModule with full CRUD service and controller - Ticket routing: users create tickets to technical/sales departments - Staff reply updates status (answered), user reply sets waiting - Role-based access: technical staff sees technical tickets, sales sees sales tickets - Admin sees all tickets with stats (total, open, avg response time) - Updated access control: technical role has admin-level access (except role change) - Sales role can view users and handle sales tickets - Clusters controller: technical role can manage clusters/pools - Users controller: technical/sales can view users, only admin changes roles Frontend: - New user ticket pages: list (with create form) + detail (chat-style messages) - Staff ticket panel: filtered by department with status filters - Admin all-tickets page with statistics dashboard and department/status filters - Updated sidebar: role-based nav items (admin/technical/sales sections) - Role badges in header for technical (blue) and sales (green) - Admin users page: new roles in dropdowns, role change restricted to admin only - Deploy page: technical role gets cluster selection access like admin
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { Ticket } from '@/types';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
open: 'bg-yellow-100 text-yellow-700',
|
||||
waiting: 'bg-orange-100 text-orange-700',
|
||||
answered: 'bg-green-100 text-green-700',
|
||||
closed: 'bg-gray-100 text-gray-500',
|
||||
};
|
||||
|
||||
export default function TicketDetailPage() {
|
||||
const { id } = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [reply, setReply] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: ticket, isLoading } = useQuery<Ticket>({
|
||||
queryKey: ['ticket', id],
|
||||
queryFn: () => api.get(`/tickets/${id}`).then((r) => r.data),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const replyMutation = useMutation({
|
||||
mutationFn: (message: string) => api.post(`/tickets/${id}/reply`, { message }).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
setReply('');
|
||||
queryClient.invalidateQueries({ queryKey: ['ticket', id] });
|
||||
toast.success('Reply sent');
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to send reply'),
|
||||
});
|
||||
|
||||
const closeMutation = useMutation({
|
||||
mutationFn: () => api.patch(`/tickets/${id}/close`).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ticket', id] });
|
||||
toast.success('Ticket closed');
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [ticket?.messages]);
|
||||
|
||||
const handleSubmitReply = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (reply.trim()) {
|
||||
replyMutation.mutate(reply.trim());
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary-600 border-t-transparent mx-auto" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
return <div className="card p-12 text-center text-gray-500">Ticket not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<button onClick={() => router.back()} className="text-sm text-gray-500 hover:text-gray-700 mb-2">
|
||||
← Back
|
||||
</button>
|
||||
<h1 className="text-xl font-bold text-gray-900">{ticket.subject}</h1>
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
|
||||
{ticket.status}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{ticket.department === 'technical' ? '🔧 Technical' : '💼 Sales'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(ticket.createdAt).toLocaleString()}
|
||||
</span>
|
||||
{ticket.user && (
|
||||
<span className="text-xs text-gray-400">
|
||||
by {ticket.user.firstName} {ticket.user.lastName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{ticket.status !== 'closed' && (
|
||||
<button
|
||||
onClick={() => closeMutation.mutate()}
|
||||
className="btn-ghost text-sm text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={closeMutation.isPending}
|
||||
>
|
||||
Close Ticket
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="card p-4 space-y-4 max-h-[500px] overflow-y-auto">
|
||||
{ticket.messages?.map((msg) => {
|
||||
const isMe = msg.senderId === user?.id;
|
||||
const isStaff = msg.senderRole !== 'user';
|
||||
return (
|
||||
<div key={msg.id} className={`flex ${isMe ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[80%] rounded-2xl px-4 py-3 ${
|
||||
isMe
|
||||
? 'bg-primary-500 text-white rounded-br-md'
|
||||
: isStaff
|
||||
? 'bg-blue-50 text-gray-900 border border-blue-200 rounded-bl-md'
|
||||
: 'bg-gray-100 text-gray-900 rounded-bl-md'
|
||||
}`}>
|
||||
{!isMe && (
|
||||
<p className={`text-xs font-semibold mb-1 ${isStaff ? 'text-blue-600' : 'text-gray-500'}`}>
|
||||
{msg.sender ? `${msg.sender.firstName} ${msg.sender.lastName}` : 'Unknown'}
|
||||
{isStaff && (
|
||||
<span className="ml-1 px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded text-[10px]">
|
||||
{msg.senderRole === 'admin' ? 'Admin' : msg.senderRole === 'technical' ? 'Technical' : 'Sales'}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm whitespace-pre-wrap">{msg.message}</p>
|
||||
<p className={`text-[10px] mt-1 ${isMe ? 'text-white/70' : 'text-gray-400'}`}>
|
||||
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Reply Box */}
|
||||
{ticket.status !== 'closed' ? (
|
||||
<form onSubmit={handleSubmitReply} className="card p-4">
|
||||
<div className="flex gap-3">
|
||||
<textarea
|
||||
className="input-field flex-1 min-h-[60px] resize-none"
|
||||
placeholder="Type your reply..."
|
||||
value={reply}
|
||||
onChange={(e) => setReply(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmitReply(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary self-end"
|
||||
disabled={!reply.trim() || replyMutation.isPending}
|
||||
>
|
||||
{replyMutation.isPending ? '...' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="card p-4 text-center text-sm text-gray-500">
|
||||
This ticket is closed. Create a new ticket if you need further assistance.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { Ticket, CreateTicketDto, TicketDepartment, TicketPriority } from '@/types';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
open: 'bg-yellow-100 text-yellow-700',
|
||||
waiting: 'bg-orange-100 text-orange-700',
|
||||
answered: 'bg-green-100 text-green-700',
|
||||
closed: 'bg-gray-100 text-gray-500',
|
||||
};
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
low: 'bg-blue-100 text-blue-700',
|
||||
medium: 'bg-yellow-100 text-yellow-700',
|
||||
high: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
export default function TicketsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState<CreateTicketDto>({
|
||||
subject: '',
|
||||
department: 'technical',
|
||||
priority: 'medium',
|
||||
message: '',
|
||||
});
|
||||
|
||||
const { data: tickets = [], isLoading } = useQuery<Ticket[]>({
|
||||
queryKey: ['my-tickets'],
|
||||
queryFn: () => api.get('/tickets/my').then((r) => r.data),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateTicketDto) => api.post('/tickets', data).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
toast.success('Ticket created successfully');
|
||||
queryClient.invalidateQueries({ queryKey: ['my-tickets'] });
|
||||
setShowCreate(false);
|
||||
setForm({ subject: '', department: 'technical', priority: 'medium', message: '' });
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to create ticket'),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate(form);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">My Tickets</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Support tickets and their status</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(!showCreate)} className="btn-primary">
|
||||
{showCreate ? '✕ Cancel' : '+ New Ticket'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create Ticket Form */}
|
||||
{showCreate && (
|
||||
<form onSubmit={handleSubmit} className="card p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Create New Ticket</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="Brief description of your issue"
|
||||
value={form.subject}
|
||||
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Department</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={form.department}
|
||||
onChange={(e) => setForm({ ...form, department: e.target.value as TicketDepartment })}
|
||||
>
|
||||
<option value="technical">🔧 Technical Support</option>
|
||||
<option value="sales">💼 Sales</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Priority</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={form.priority}
|
||||
onChange={(e) => setForm({ ...form, priority: e.target.value as TicketPriority })}
|
||||
>
|
||||
<option value="low">🟢 Low</option>
|
||||
<option value="medium">🟡 Medium</option>
|
||||
<option value="high">🔴 High</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Message</label>
|
||||
<textarea
|
||||
className="input-field min-h-[120px]"
|
||||
placeholder="Describe your issue in detail..."
|
||||
value={form.message}
|
||||
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
||||
required
|
||||
minLength={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="submit" className="btn-primary" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating...' : 'Submit Ticket'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Tickets List */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary-600 border-t-transparent mx-auto" />
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="card p-12 text-center">
|
||||
<span className="text-4xl">🎫</span>
|
||||
<h3 className="mt-3 text-lg font-semibold text-gray-700">No tickets yet</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Create a ticket if you need help</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tickets.map((ticket) => (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
href={`/dashboard/tickets/${ticket.id}`}
|
||||
className="card p-4 block hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{ticket.subject}</h3>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
|
||||
{ticket.status}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[ticket.priority]}`}>
|
||||
{ticket.priority}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
|
||||
<span>{ticket.department === 'technical' ? '🔧 Technical' : '💼 Sales'}</span>
|
||||
<span>•</span>
|
||||
<span>{new Date(ticket.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span>•</span>
|
||||
<span>{ticket.messages?.length || 0} message{(ticket.messages?.length || 0) !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">→</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user