From a64f8407b9fec9996bab96f27d4717e7a8ad2e64 Mon Sep 17 00:00:00 2001 From: keyhan Date: Mon, 6 Apr 2026 13:55:15 +0330 Subject: [PATCH] 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 --- backend/src/tickets/tickets.controller.ts | 17 +++++++- backend/src/tickets/tickets.service.ts | 13 ++++++ .../src/app/dashboard/admin/tickets/page.tsx | 16 +++++--- frontend/src/app/dashboard/layout.tsx | 41 ++++++++++++++++--- .../src/app/dashboard/staff/tickets/page.tsx | 28 ++++++++++++- frontend/src/app/dashboard/tickets/page.tsx | 31 ++++++++++---- 6 files changed, 123 insertions(+), 23 deletions(-) diff --git a/backend/src/tickets/tickets.controller.ts b/backend/src/tickets/tickets.controller.ts index 05e2ffc..5402c25 100644 --- a/backend/src/tickets/tickets.controller.ts +++ b/backend/src/tickets/tickets.controller.ts @@ -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) // ═══════════════════════════════════════════ diff --git a/backend/src/tickets/tickets.service.ts b/backend/src/tickets/tickets.service.ts index 59a1b1c..2fb1e32 100644 --- a/backend/src/tickets/tickets.service.ts +++ b/backend/src/tickets/tickets.service.ts @@ -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 { const where: any = { department }; diff --git a/frontend/src/app/dashboard/admin/tickets/page.tsx b/frontend/src/app/dashboard/admin/tickets/page.tsx index ab54a5f..4e0fa5c 100644 --- a/frontend/src/app/dashboard/admin/tickets/page.tsx +++ b/frontend/src/app/dashboard/admin/tickets/page.tsx @@ -59,9 +59,12 @@ export default function AdminTicketsPage() {

Total Tickets

{stats.totalTickets}

-
-

Open Tickets

-

{stats.openTickets}

+
+

Unanswered

+

0 ? 'text-red-600' : 'text-green-600'}`}> + {stats.openTickets} +

+

Need staff response

Avg Response Time

@@ -70,13 +73,14 @@ export default function AdminTicketsPage() {

-

Departments

+

By Department

{Object.entries(stats.byDepartment).map(([dept, data]) => (
{dept === 'technical' ? '🔧' : '💼'} {dept} - - {data.open} open / {data.total} total + + 0 ? 'text-red-600' : 'text-green-600'}>{data.open} unanswered + / {data.total}
))} diff --git a/frontend/src/app/dashboard/layout.tsx b/frontend/src/app/dashboard/layout.tsx index 9510d23..b820bfe 100644 --- a/frontend/src/app/dashboard/layout.tsx +++ b/frontend/src/app/dashboard/layout.tsx @@ -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 ( {item.icon} - {item.label} + {item.label} + {badge !== undefined && badge > 0 && ( + + {badge} + + )} ); }; + // 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 = () => (
{userNavItems.map((item) => ( - + ))} {user?.role === 'admin' && ( @@ -93,7 +122,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod

{adminNavItems.map((item) => ( - + ))} )} @@ -106,7 +135,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod

{technicalNavItems.map((item) => ( - + ))} )} @@ -119,7 +148,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod

{salesNavItems.map((item) => ( - + ))} )} diff --git a/frontend/src/app/dashboard/staff/tickets/page.tsx b/frontend/src/app/dashboard/staff/tickets/page.tsx index 9c30d0a..a7722e0 100644 --- a/frontend/src/app/dashboard/staff/tickets/page.tsx +++ b/frontend/src/app/dashboard/staff/tickets/page.tsx @@ -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 (

{departmentLabel} Tickets

- {openCount} open ticket{openCount !== 1 ? 's' : ''} · {tickets.length} total + {tickets.length} total tickets

+ {/* Summary Cards */} +
+
+

Unanswered

+

0 ? 'text-red-600' : 'text-green-600'}`}> + {unansweredCount} +

+

Need response

+
+
+

Answered

+

{answeredCount}

+

Waiting for user

+
+
+

Total Open

+

+ {tickets.filter((t) => t.status !== 'closed').length} +

+

Excluding closed

+
+
+ {/* Filters */}
{(['', 'open', 'waiting', 'answered', 'closed'] as const).map((status) => ( diff --git a/frontend/src/app/dashboard/tickets/page.tsx b/frontend/src/app/dashboard/tickets/page.tsx index 0fd0272..e13ed72 100644 --- a/frontend/src/app/dashboard/tickets/page.tsx +++ b/frontend/src/app/dashboard/tickets/page.tsx @@ -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 = { 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({ 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() {

My Tickets

Support tickets and their status

- + {canCreateTicket && ( + + )}
{/* Create Ticket Form */} - {showCreate && ( + {showCreate && canCreateTicket && (

Create New Ticket

@@ -89,8 +103,9 @@ export default function TicketsPage() { value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value as TicketDepartment })} > - - + {availableDepartments.map((dept) => ( + + ))}