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,
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)
// ═══════════════════════════════════════════
+13
View File
@@ -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>
))}
+35 -6
View File
@@ -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) => (
+23 -8
View File
@@ -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>