feat: unanswered ticket badges, dashboard counts, cross-department ticket creation

- Add /tickets/unanswered-counts API endpoint for staff/admin
- Show red badge with unanswered count on sidebar nav (staff tickets, admin tickets)
- Add summary cards (unanswered/answered/total) to staff tickets page
- Highlight unanswered count in admin tickets stats cards
- Staff can create tickets to OTHER departments only (not their own)
- Frontend hides own department from ticket creation dropdown
- Sidebar badges auto-refresh every 30 seconds
This commit is contained in:
keyhan
2026-04-06 13:55:15 +03:30
parent a1dbd0a85f
commit a64f8407b9
6 changed files with 123 additions and 23 deletions
+16 -1
View File
@@ -8,6 +8,7 @@ import {
Query, Query,
UseGuards, UseGuards,
Request, Request,
ForbiddenException,
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
@@ -29,8 +30,15 @@ export class TicketsController {
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
@Post() @Post()
@ApiOperation({ summary: 'Create a new ticket (any user)' }) @ApiOperation({ summary: 'Create a new ticket' })
async create(@Request() req: any, @Body() dto: CreateTicketDto) { async create(@Request() req: any, @Body() dto: CreateTicketDto) {
// Staff cannot create tickets to their own department
if (req.user.role === UserRole.TECHNICAL && dto.department === 'technical') {
throw new ForbiddenException('You cannot create a ticket to your own department');
}
if (req.user.role === UserRole.SALES && dto.department === 'sales') {
throw new ForbiddenException('You cannot create a ticket to your own department');
}
return this.ticketsService.create(req.user.id, req.user.role, dto); return this.ticketsService.create(req.user.id, req.user.role, dto);
} }
@@ -77,6 +85,13 @@ export class TicketsController {
return this.ticketsService.getStats(); return this.ticketsService.getStats();
} }
@Get('unanswered-counts')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL, UserRole.SALES)
@ApiOperation({ summary: 'Get unanswered ticket counts per department (Staff/Admin)' })
async getUnansweredCounts() {
return this.ticketsService.getUnansweredCounts();
}
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
// PARAMETERIZED ROUTES (after static routes) // PARAMETERIZED ROUTES (after static routes)
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
+13
View File
@@ -119,6 +119,19 @@ export class TicketsService {
await this.ticketsRepo.update(ticketId, data); await this.ticketsRepo.update(ticketId, data);
} }
// ─── Unanswered ticket counts ───
async getUnansweredCounts(): Promise<{ technical: number; sales: number; total: number }> {
const unanswered = await this.ticketsRepo.find({
where: [
{ status: TicketStatus.OPEN },
{ status: TicketStatus.WAITING },
],
});
const technical = unanswered.filter((t) => t.department === TicketDepartment.TECHNICAL).length;
const sales = unanswered.filter((t) => t.department === TicketDepartment.SALES).length;
return { technical, sales, total: technical + sales };
}
// ─── Staff: List department tickets ─── // ─── Staff: List department tickets ───
async findByDepartment(department: TicketDepartment, status?: TicketStatus): Promise<Ticket[]> { async findByDepartment(department: TicketDepartment, status?: TicketStatus): Promise<Ticket[]> {
const where: any = { department }; const where: any = { department };
@@ -59,9 +59,12 @@ export default function AdminTicketsPage() {
<p className="text-xs text-gray-500 uppercase font-semibold">Total Tickets</p> <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> <p className="text-2xl font-bold text-gray-900 mt-1">{stats.totalTickets}</p>
</div> </div>
<div className="card p-4"> <div className="card p-4 border-l-4 border-l-red-500">
<p className="text-xs text-gray-500 uppercase font-semibold">Open Tickets</p> <p className="text-xs text-gray-500 uppercase font-semibold">Unanswered</p>
<p className="text-2xl font-bold text-orange-600 mt-1">{stats.openTickets}</p> <p className={`text-2xl font-bold mt-1 ${stats.openTickets > 0 ? 'text-red-600' : 'text-green-600'}`}>
{stats.openTickets}
</p>
<p className="text-xs text-gray-400 mt-0.5">Need staff response</p>
</div> </div>
<div className="card p-4"> <div className="card p-4">
<p className="text-xs text-gray-500 uppercase font-semibold">Avg Response Time</p> <p className="text-xs text-gray-500 uppercase font-semibold">Avg Response Time</p>
@@ -70,13 +73,14 @@ export default function AdminTicketsPage() {
</p> </p>
</div> </div>
<div className="card p-4"> <div className="card p-4">
<p className="text-xs text-gray-500 uppercase font-semibold">Departments</p> <p className="text-xs text-gray-500 uppercase font-semibold">By Department</p>
<div className="mt-1 space-y-0.5"> <div className="mt-1 space-y-0.5">
{Object.entries(stats.byDepartment).map(([dept, data]) => ( {Object.entries(stats.byDepartment).map(([dept, data]) => (
<div key={dept} className="flex items-center justify-between text-xs"> <div key={dept} className="flex items-center justify-between text-xs">
<span className="text-gray-600 capitalize">{dept === 'technical' ? '🔧' : '💼'} {dept}</span> <span className="text-gray-600 capitalize">{dept === 'technical' ? '🔧' : '💼'} {dept}</span>
<span className="font-medium text-gray-900"> <span className="font-medium">
{data.open} open / {data.total} total <span className={data.open > 0 ? 'text-red-600' : 'text-green-600'}>{data.open} unanswered</span>
<span className="text-gray-400"> / {data.total}</span>
</span> </span>
</div> </div>
))} ))}
+35 -6
View File
@@ -3,7 +3,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import api from '@/lib/api';
const userNavItems = [ const userNavItems = [
{ href: '/dashboard', label: 'Dashboard', icon: '📊' }, { href: '/dashboard', label: 'Dashboard', icon: '📊' },
@@ -61,7 +63,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
if (!isAuthenticated) return null; if (!isAuthenticated) return null;
const NavLink = ({ item }: { item: { href: string; label: string; icon: string } }) => { const NavLink = ({ item, badge }: { item: { href: string; label: string; icon: string }; badge?: number }) => {
const isActive = pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href + '/')); const isActive = pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href + '/'));
return ( return (
<Link <Link
@@ -73,16 +75,43 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
}`} }`}
> >
<span className="text-base">{item.icon}</span> <span className="text-base">{item.icon}</span>
<span>{item.label}</span> <span className="flex-1">{item.label}</span>
{badge !== undefined && badge > 0 && (
<span className="min-w-[20px] h-5 flex items-center justify-center px-1.5 text-xs font-bold rounded-full bg-red-500 text-white">
{badge}
</span>
)}
</Link> </Link>
); );
}; };
// Fetch unanswered ticket counts for staff/admin roles
const isStaffOrAdmin = user?.role === 'admin' || user?.role === 'technical' || user?.role === 'sales';
const { data: unansweredCounts } = useQuery<{ technical: number; sales: number; total: number }>({
queryKey: ['unanswered-counts'],
queryFn: () => api.get('/tickets/unanswered-counts').then((r) => r.data),
enabled: isStaffOrAdmin,
refetchInterval: 30000, // refresh every 30 seconds
});
const getBadge = (href: string): number | undefined => {
if (!unansweredCounts) return undefined;
if (href === '/dashboard/staff/tickets') {
// Staff ticket page: show count for their department
if (user?.role === 'technical') return unansweredCounts.technical;
if (user?.role === 'sales') return unansweredCounts.sales;
}
if (href === '/dashboard/admin/tickets') {
return unansweredCounts.total;
}
return undefined;
};
const SidebarContent = () => ( const SidebarContent = () => (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="space-y-1 flex-1"> <div className="space-y-1 flex-1">
{userNavItems.map((item) => ( {userNavItems.map((item) => (
<NavLink key={item.href} item={item} /> <NavLink key={item.href} item={item} badge={getBadge(item.href)} />
))} ))}
{user?.role === 'admin' && ( {user?.role === 'admin' && (
@@ -93,7 +122,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</p> </p>
</div> </div>
{adminNavItems.map((item) => ( {adminNavItems.map((item) => (
<NavLink key={item.href} item={item} /> <NavLink key={item.href} item={item} badge={getBadge(item.href)} />
))} ))}
</> </>
)} )}
@@ -106,7 +135,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</p> </p>
</div> </div>
{technicalNavItems.map((item) => ( {technicalNavItems.map((item) => (
<NavLink key={item.href} item={item} /> <NavLink key={item.href} item={item} badge={getBadge(item.href)} />
))} ))}
</> </>
)} )}
@@ -119,7 +148,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</p> </p>
</div> </div>
{salesNavItems.map((item) => ( {salesNavItems.map((item) => (
<NavLink key={item.href} item={item} /> <NavLink key={item.href} item={item} badge={getBadge(item.href)} />
))} ))}
</> </>
)} )}
@@ -36,17 +36,41 @@ export default function StaffTicketsPage() {
}, },
}); });
const openCount = tickets.filter((t) => t.status === 'open' || t.status === 'waiting').length; const unansweredCount = tickets.filter((t) => t.status === 'open' || t.status === 'waiting').length;
const answeredCount = tickets.filter((t) => t.status === 'answered').length;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">{departmentLabel} Tickets</h1> <h1 className="text-2xl font-bold text-gray-900">{departmentLabel} Tickets</h1>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
{openCount} open ticket{openCount !== 1 ? 's' : ''} · {tickets.length} total {tickets.length} total tickets
</p> </p>
</div> </div>
{/* Summary Cards */}
<div className="grid grid-cols-3 gap-4">
<div className="card p-4">
<p className="text-xs text-gray-500 uppercase font-semibold">Unanswered</p>
<p className={`text-2xl font-bold mt-1 ${unansweredCount > 0 ? 'text-red-600' : 'text-green-600'}`}>
{unansweredCount}
</p>
<p className="text-xs text-gray-400 mt-0.5">Need response</p>
</div>
<div className="card p-4">
<p className="text-xs text-gray-500 uppercase font-semibold">Answered</p>
<p className="text-2xl font-bold text-green-600 mt-1">{answeredCount}</p>
<p className="text-xs text-gray-400 mt-0.5">Waiting for user</p>
</div>
<div className="card p-4">
<p className="text-xs text-gray-500 uppercase font-semibold">Total Open</p>
<p className="text-2xl font-bold text-gray-900 mt-1">
{tickets.filter((t) => t.status !== 'closed').length}
</p>
<p className="text-xs text-gray-400 mt-0.5">Excluding closed</p>
</div>
</div>
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{(['', 'open', 'waiting', 'answered', 'closed'] as const).map((status) => ( {(['', 'open', 'waiting', 'answered', 'closed'] as const).map((status) => (
+20 -5
View File
@@ -4,6 +4,7 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link'; import Link from 'next/link';
import api from '@/lib/api'; import api from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import type { Ticket, CreateTicketDto, TicketDepartment, TicketPriority } from '@/types'; import type { Ticket, CreateTicketDto, TicketDepartment, TicketPriority } from '@/types';
@@ -22,10 +23,21 @@ const priorityColors: Record<string, string> = {
export default function TicketsPage() { export default function TicketsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
// Staff can only create tickets to other departments
const availableDepartments: { value: TicketDepartment; label: string }[] = [];
if (user?.role !== 'technical') {
availableDepartments.push({ value: 'technical', label: '🔧 Technical Support' });
}
if (user?.role !== 'sales') {
availableDepartments.push({ value: 'sales', label: '💼 Sales' });
}
const canCreateTicket = availableDepartments.length > 0;
const [form, setForm] = useState<CreateTicketDto>({ const [form, setForm] = useState<CreateTicketDto>({
subject: '', subject: '',
department: 'technical', department: availableDepartments[0]?.value || 'technical',
priority: 'medium', priority: 'medium',
message: '', message: '',
}); });
@@ -41,7 +53,7 @@ export default function TicketsPage() {
toast.success('Ticket created successfully'); toast.success('Ticket created successfully');
queryClient.invalidateQueries({ queryKey: ['my-tickets'] }); queryClient.invalidateQueries({ queryKey: ['my-tickets'] });
setShowCreate(false); setShowCreate(false);
setForm({ subject: '', department: 'technical', priority: 'medium', message: '' }); setForm({ subject: '', department: availableDepartments[0]?.value || 'technical', priority: 'medium', message: '' });
}, },
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to create ticket'), onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to create ticket'),
}); });
@@ -58,13 +70,15 @@ export default function TicketsPage() {
<h1 className="text-2xl font-bold text-gray-900">My Tickets</h1> <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> <p className="text-sm text-gray-500 mt-1">Support tickets and their status</p>
</div> </div>
{canCreateTicket && (
<button onClick={() => setShowCreate(!showCreate)} className="btn-primary"> <button onClick={() => setShowCreate(!showCreate)} className="btn-primary">
{showCreate ? '✕ Cancel' : '+ New Ticket'} {showCreate ? '✕ Cancel' : '+ New Ticket'}
</button> </button>
)}
</div> </div>
{/* Create Ticket Form */} {/* Create Ticket Form */}
{showCreate && ( {showCreate && canCreateTicket && (
<form onSubmit={handleSubmit} className="card p-6 space-y-4"> <form onSubmit={handleSubmit} className="card p-6 space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Create New Ticket</h2> <h2 className="text-lg font-semibold text-gray-900">Create New Ticket</h2>
@@ -89,8 +103,9 @@ export default function TicketsPage() {
value={form.department} value={form.department}
onChange={(e) => setForm({ ...form, department: e.target.value as TicketDepartment })} onChange={(e) => setForm({ ...form, department: e.target.value as TicketDepartment })}
> >
<option value="technical">🔧 Technical Support</option> {availableDepartments.map((dept) => (
<option value="sales">💼 Sales</option> <option key={dept.value} value={dept.value}>{dept.label}</option>
))}
</select> </select>
</div> </div>
<div> <div>