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:
@@ -8,6 +8,7 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
@@ -29,8 +30,15 @@ export class TicketsController {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new ticket (any user)' })
|
||||
@ApiOperation({ summary: 'Create a new ticket' })
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -77,6 +85,13 @@ export class TicketsController {
|
||||
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)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@@ -119,6 +119,19 @@ export class TicketsService {
|
||||
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 ───
|
||||
async findByDepartment(department: TicketDepartment, status?: TicketStatus): Promise<Ticket[]> {
|
||||
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-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 className="card p-4 border-l-4 border-l-red-500">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">Unanswered</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 className="card p-4">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">Avg Response Time</p>
|
||||
@@ -70,13 +73,14 @@ export default function AdminTicketsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
{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 className="font-medium">
|
||||
<span className={data.open > 0 ? 'text-red-600' : 'text-green-600'}>{data.open} unanswered</span>
|
||||
<span className="text-gray-400"> / {data.total}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import api from '@/lib/api';
|
||||
|
||||
const userNavItems = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: '📊' },
|
||||
@@ -61,7 +63,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
|
||||
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 + '/'));
|
||||
return (
|
||||
<Link
|
||||
@@ -73,16 +75,43 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 = () => (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="space-y-1 flex-1">
|
||||
{userNavItems.map((item) => (
|
||||
<NavLink key={item.href} item={item} />
|
||||
<NavLink key={item.href} item={item} badge={getBadge(item.href)} />
|
||||
))}
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
@@ -93,7 +122,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
</p>
|
||||
</div>
|
||||
{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>
|
||||
</div>
|
||||
{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>
|
||||
</div>
|
||||
{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 (
|
||||
<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
|
||||
{tickets.length} total tickets
|
||||
</p>
|
||||
</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 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['', 'open', 'waiting', 'answered', 'closed'] as const).map((status) => (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { Ticket, CreateTicketDto, TicketDepartment, TicketPriority } from '@/types';
|
||||
|
||||
@@ -22,10 +23,21 @@ const priorityColors: Record<string, string> = {
|
||||
|
||||
export default function TicketsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
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>({
|
||||
subject: '',
|
||||
department: 'technical',
|
||||
department: availableDepartments[0]?.value || 'technical',
|
||||
priority: 'medium',
|
||||
message: '',
|
||||
});
|
||||
@@ -41,7 +53,7 @@ export default function TicketsPage() {
|
||||
toast.success('Ticket created successfully');
|
||||
queryClient.invalidateQueries({ queryKey: ['my-tickets'] });
|
||||
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'),
|
||||
});
|
||||
@@ -58,13 +70,15 @@ export default function TicketsPage() {
|
||||
<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>
|
||||
{canCreateTicket && (
|
||||
<button onClick={() => setShowCreate(!showCreate)} className="btn-primary">
|
||||
{showCreate ? '✕ Cancel' : '+ New Ticket'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Ticket Form */}
|
||||
{showCreate && (
|
||||
{showCreate && canCreateTicket && (
|
||||
<form onSubmit={handleSubmit} className="card p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Create New Ticket</h2>
|
||||
|
||||
@@ -89,8 +103,9 @@ export default function TicketsPage() {
|
||||
value={form.department}
|
||||
onChange={(e) => setForm({ ...form, department: e.target.value as TicketDepartment })}
|
||||
>
|
||||
<option value="technical">🔧 Technical Support</option>
|
||||
<option value="sales">💼 Sales</option>
|
||||
{availableDepartments.map((dept) => (
|
||||
<option key={dept.value} value={dept.value}>{dept.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user