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:
keyhan
2026-04-06 12:36:41 +03:30
parent 4fd102468e
commit 3ab647be2e
20 changed files with 1301 additions and 56 deletions
@@ -0,0 +1,176 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import api from '@/lib/api';
import type { Ticket, TicketStats, TicketDepartment, TicketStatus } 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 AdminTicketsPage() {
const [deptFilter, setDeptFilter] = useState<TicketDepartment | ''>('');
const [statusFilter, setStatusFilter] = useState<TicketStatus | ''>('');
const { data: stats } = useQuery<TicketStats>({
queryKey: ['ticket-stats'],
queryFn: () => api.get('/tickets/admin/stats').then((r) => r.data),
});
const { data: tickets = [], isLoading } = useQuery<Ticket[]>({
queryKey: ['admin-tickets', deptFilter, statusFilter],
queryFn: () => {
const params = new URLSearchParams();
if (deptFilter) params.append('department', deptFilter);
if (statusFilter) params.append('status', statusFilter);
const qs = params.toString();
return api.get(`/tickets/admin/all${qs ? `?${qs}` : ''}`).then((r) => r.data);
},
});
const formatResponseTime = (minutes: number) => {
if (minutes < 60) return `${minutes} min`;
if (minutes < 1440) return `${Math.round(minutes / 60)} hours`;
return `${Math.round(minutes / 1440)} days`;
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">📋 All Tickets</h1>
<p className="text-sm text-gray-500 mt-1">Overview of all support tickets across departments</p>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="card p-4">
<p className="text-xs text-gray-500 uppercase font-semibold">Total Tickets</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.totalTickets}</p>
</div>
<div className="card p-4">
<p className="text-xs text-gray-500 uppercase font-semibold">Open Tickets</p>
<p className="text-2xl font-bold text-orange-600 mt-1">{stats.openTickets}</p>
</div>
<div className="card p-4">
<p className="text-xs text-gray-500 uppercase font-semibold">Avg Response Time</p>
<p className="text-2xl font-bold text-primary-600 mt-1">
{stats.avgResponseTimeMinutes > 0 ? formatResponseTime(stats.avgResponseTimeMinutes) : '—'}
</p>
</div>
<div className="card p-4">
<p className="text-xs text-gray-500 uppercase font-semibold">Departments</p>
<div className="mt-1 space-y-0.5">
{Object.entries(stats.byDepartment).map(([dept, data]) => (
<div key={dept} className="flex items-center justify-between text-xs">
<span className="text-gray-600 capitalize">{dept === 'technical' ? '🔧' : '💼'} {dept}</span>
<span className="font-medium text-gray-900">
{data.open} open / {data.total} total
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Filters */}
<div className="flex flex-wrap gap-4">
<div className="flex flex-wrap gap-2">
<span className="text-sm text-gray-500 self-center">Department:</span>
{(['' as const, 'technical' as TicketDepartment, 'sales' as TicketDepartment]).map((dept) => (
<button
key={dept}
onClick={() => setDeptFilter(dept)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
deptFilter === dept
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{dept === '' ? 'All' : dept === 'technical' ? '🔧 Technical' : '💼 Sales'}
</button>
))}
</div>
<div className="flex flex-wrap gap-2">
<span className="text-sm text-gray-500 self-center">Status:</span>
{(['', 'open', 'waiting', 'answered', 'closed'] as const).map((status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
statusFilter === status
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{status === '' ? 'All' : status.charAt(0).toUpperCase() + status.slice(1)}
</button>
))}
</div>
</div>
{/* Tickets Table */}
{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 found</h3>
</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>
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700">
{ticket.department === 'technical' ? '🔧 Technical' : '💼 Sales'}
</span>
</div>
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
{ticket.user && (
<span>
👤 {ticket.user.firstName} {ticket.user.lastName} ({ticket.user.email})
</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} msg{(ticket.messages?.length || 0) !== 1 ? 's' : ''}</span>
</div>
</div>
<span className="text-gray-400 text-sm"></span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
+46 -19
View File
@@ -3,11 +3,14 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import toast from 'react-hot-toast';
import type { AdminUser } from '@/types';
export default function AdminUsersPage() {
const queryClient = useQueryClient();
const currentUser = useAuthStore((s) => s.user);
const isAdmin = currentUser?.role === 'admin';
const [search, setSearch] = useState('');
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({
@@ -15,7 +18,7 @@ export default function AdminUsersPage() {
password: '',
firstName: '',
lastName: '',
role: 'user' as 'user' | 'admin',
role: 'user' as 'user' | 'admin' | 'technical' | 'sales',
});
const { data: users = [], isLoading } = useQuery<AdminUser[]>({
@@ -116,10 +119,12 @@ export default function AdminUsersPage() {
<select
className="input-field w-full sm:w-48"
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value as 'user' | 'admin' })}
onChange={(e) => setForm({ ...form, role: e.target.value as typeof form.role })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="technical">Technical</option>
<option value="sales">Sales</option>
</select>
</div>
<button
@@ -192,14 +197,25 @@ export default function AdminUsersPage() {
</td>
<td className="px-6 py-4 text-sm text-gray-600">{user.email}</td>
<td className="px-6 py-4">
<select
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
value={user.role}
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
{isAdmin ? (
<select
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
value={user.role}
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="technical">Technical</option>
<option value="sales">Sales</option>
</select>
) : (
<span className={`badge ${
user.role === 'admin' ? 'badge-purple' :
user.role === 'technical' ? 'bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs font-medium' :
user.role === 'sales' ? 'bg-green-100 text-green-700 px-2 py-0.5 rounded-full text-xs font-medium' :
'badge-gray'
}`}>{user.role}</span>
)}
</td>
<td className="px-6 py-4">
<span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}>
@@ -241,18 +257,29 @@ export default function AdminUsersPage() {
</div>
<div className="flex items-center gap-3 text-xs text-gray-500">
<span className="badge badge-blue">{user.appCount ?? 0} apps</span>
<span className={`badge ${user.role === 'admin' ? 'badge-purple' : 'badge-gray'}`}>{user.role}</span>
<span className={`badge ${
user.role === 'admin' ? 'badge-purple' :
user.role === 'technical' ? 'bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs font-medium' :
user.role === 'sales' ? 'bg-green-100 text-green-700 px-2 py-0.5 rounded-full text-xs font-medium' :
'badge-gray'
}`}>{user.role}</span>
<span>{new Date(user.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center gap-2 pt-2 border-t border-gray-100">
<select
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
value={user.role}
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
{isAdmin ? (
<select
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
value={user.role}
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="technical">Technical</option>
<option value="sales">Sales</option>
</select>
) : (
<span className="text-sm text-gray-500 capitalize">{user.role}</span>
)}
<button
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
className={`text-sm font-medium ${user.isActive ? 'text-red-600' : 'text-green-600'}`}
+1 -1
View File
@@ -13,7 +13,7 @@ const steps = ['Basic Info', 'Runtime & Database', 'Resources', 'Review'];
export default function DeployPage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'admin';
const isAdmin = user?.role === 'admin' || user?.role === 'technical';
const [step, setStep] = useState(0);
const [form, setForm] = useState<CreateApplicationDto>({
name: '',
+46
View File
@@ -9,12 +9,26 @@ const userNavItems = [
{ href: '/dashboard', label: 'Dashboard', icon: '📊' },
{ href: '/dashboard/apps', label: 'Applications', icon: '📦' },
{ href: '/dashboard/deploy', label: 'New Deploy', icon: '🚀' },
{ href: '/dashboard/tickets', label: 'Tickets', icon: '🎫' },
];
const adminNavItems = [
{ href: '/dashboard/admin/users', label: 'Users', icon: '👥' },
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: '🖥️' },
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: '⚖️' },
{ href: '/dashboard/admin/tickets', label: 'All Tickets', icon: '📋' },
];
const technicalNavItems = [
{ href: '/dashboard/admin/users', label: 'Users', icon: '👥' },
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: '🖥️' },
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: '⚖️' },
{ href: '/dashboard/staff/tickets', label: 'Technical Tickets', icon: '🔧' },
];
const salesNavItems = [
{ href: '/dashboard/admin/users', label: 'Users', icon: '👥' },
{ href: '/dashboard/staff/tickets', label: 'Sales Tickets', icon: '💼' },
];
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
@@ -83,6 +97,32 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
))}
</>
)}
{user?.role === 'technical' && (
<>
<div className="pt-5 pb-2">
<p className="px-3 text-[11px] font-bold text-gray-400 uppercase tracking-widest">
Technical
</p>
</div>
{technicalNavItems.map((item) => (
<NavLink key={item.href} item={item} />
))}
</>
)}
{user?.role === 'sales' && (
<>
<div className="pt-5 pb-2">
<p className="px-3 text-[11px] font-bold text-gray-400 uppercase tracking-widest">
Sales
</p>
</div>
{salesNavItems.map((item) => (
<NavLink key={item.href} item={item} />
))}
</>
)}
</div>
{/* Sidebar footer */}
@@ -151,6 +191,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
{user?.role === 'admin' && (
<span className="badge-purple">Admin</span>
)}
{user?.role === 'technical' && (
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-700">Technical</span>
)}
{user?.role === 'sales' && (
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-700">Sales</span>
)}
</div>
<button
onClick={() => { logout(); router.push('/login'); }}
@@ -0,0 +1,119 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import api from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import type { Ticket, TicketStatus } 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 StaffTicketsPage() {
const user = useAuthStore((s) => s.user);
const [statusFilter, setStatusFilter] = useState<TicketStatus | ''>('');
// Determine which department this staff member handles
const department = user?.role === 'sales' ? 'sales' : 'technical';
const departmentLabel = department === 'technical' ? '🔧 Technical' : '💼 Sales';
const { data: tickets = [], isLoading } = useQuery<Ticket[]>({
queryKey: ['staff-tickets', department, statusFilter],
queryFn: () => {
const params = statusFilter ? `?status=${statusFilter}` : '';
return api.get(`/tickets/staff/${department}${params}`).then((r) => r.data);
},
});
const openCount = tickets.filter((t) => t.status === 'open' || t.status === 'waiting').length;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">{departmentLabel} Tickets</h1>
<p className="text-sm text-gray-500 mt-1">
{openCount} open ticket{openCount !== 1 ? 's' : ''} · {tickets.length} total
</p>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-2">
{(['', 'open', 'waiting', 'answered', 'closed'] as const).map((status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
statusFilter === status
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{status === '' ? 'All' : status.charAt(0).toUpperCase() + status.slice(1)}
</button>
))}
</div>
{/* 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</h3>
<p className="text-sm text-gray-500 mt-1">
{statusFilter ? `No ${statusFilter} tickets` : 'No tickets in this department'}
</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">
{ticket.user && (
<span>
👤 {ticket.user.firstName} {ticket.user.lastName} ({ticket.user.email})
</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} msg{(ticket.messages?.length || 0) !== 1 ? 's' : ''}</span>
</div>
</div>
<span className="text-gray-400 text-sm"></span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
@@ -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>
);
}
+176
View File
@@ -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>
);
}