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,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>
);
}