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,
|
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)
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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>
|
||||||
<button onClick={() => setShowCreate(!showCreate)} className="btn-primary">
|
{canCreateTicket && (
|
||||||
{showCreate ? '✕ Cancel' : '+ New Ticket'}
|
<button onClick={() => setShowCreate(!showCreate)} className="btn-primary">
|
||||||
</button>
|
{showCreate ? '✕ Cancel' : '+ New Ticket'}
|
||||||
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user