feat: ticketing system with technical/sales roles and department routing

Backend:
- Added TECHNICAL and SALES roles to UserRole enum
- Added TicketDepartment, TicketStatus, TicketPriority enums
- Created Ticket and TicketMessage entities with relationships
- Created TicketsModule with full CRUD service and controller
- Ticket routing: users create tickets to technical/sales departments
- Staff reply updates status (answered), user reply sets waiting
- Role-based access: technical staff sees technical tickets, sales sees sales tickets
- Admin sees all tickets with stats (total, open, avg response time)
- Updated access control: technical role has admin-level access (except role change)
- Sales role can view users and handle sales tickets
- Clusters controller: technical role can manage clusters/pools
- Users controller: technical/sales can view users, only admin changes roles

Frontend:
- New user ticket pages: list (with create form) + detail (chat-style messages)
- Staff ticket panel: filtered by department with status filters
- Admin all-tickets page with statistics dashboard and department/status filters
- Updated sidebar: role-based nav items (admin/technical/sales sections)
- Role badges in header for technical (blue) and sales (green)
- Admin users page: new roles in dropdowns, role change restricted to admin only
- Deploy page: technical role gets cluster selection access like admin
This commit is contained in:
keyhan
2026-04-06 12:36:41 +03:30
parent 4fd102468e
commit 3ab647be2e
20 changed files with 1301 additions and 56 deletions
+2
View File
@@ -9,6 +9,7 @@ import { DeploymentsModule } from './deployments/deployments.module';
import { ClustersModule } from './clusters/clusters.module'; import { ClustersModule } from './clusters/clusters.module';
import { KubernetesModule } from './kubernetes/kubernetes.module'; import { KubernetesModule } from './kubernetes/kubernetes.module';
import { BuildModule } from './build/build.module'; import { BuildModule } from './build/build.module';
import { TicketsModule } from './tickets/tickets.module';
import configuration from './config/configuration'; import configuration from './config/configuration';
@Module({ @Module({
@@ -56,6 +57,7 @@ import configuration from './config/configuration';
ClustersModule, ClustersModule,
KubernetesModule, KubernetesModule,
BuildModule, BuildModule,
TicketsModule,
], ],
}) })
export class AppModule {} export class AppModule {}
@@ -21,9 +21,9 @@ export class ApplicationsService {
) {} ) {}
async create(userId: string, dto: CreateApplicationDto, userRole?: string): Promise<Application> { async create(userId: string, dto: CreateApplicationDto, userRole?: string): Promise<Application> {
// Only admin users can manually select cluster or pool // Only admin/technical users can manually select cluster or pool
// Regular users always get the default cluster assignment // Regular users always get the default cluster assignment
if (userRole !== UserRole.ADMIN) { if (userRole !== UserRole.ADMIN && userRole !== UserRole.TECHNICAL) {
if (dto.clusterId || dto.poolId) { if (dto.clusterId || dto.poolId) {
this.logger.warn(`Non-admin user ${userId} attempted manual cluster/pool selection — ignoring`); this.logger.warn(`Non-admin user ${userId} attempted manual cluster/pool selection — ignoring`);
} }
+24 -24
View File
@@ -41,50 +41,50 @@ export class ClustersController {
// ─── Cluster admin endpoints ────────────────────────────────────── // ─── Cluster admin endpoints ──────────────────────────────────────
@Post() @Post()
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Register a new Kubernetes cluster (Admin only)' }) @ApiOperation({ summary: 'Register a new Kubernetes cluster (Admin/Technical)' })
async create(@Body() dto: CreateClusterDto) { async create(@Body() dto: CreateClusterDto) {
return this.clustersService.create(dto); return this.clustersService.create(dto);
} }
@Get() @Get()
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'List all clusters (Admin only)' }) @ApiOperation({ summary: 'List all clusters (Admin/Technical)' })
async findAll() { async findAll() {
return this.clustersService.findAll(); return this.clustersService.findAll();
} }
@Get(':id/resources') @Get(':id/resources')
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Get cluster resource usage — nodes, CPU, memory, pods (Admin only)' }) @ApiOperation({ summary: 'Get cluster resource usage — nodes, CPU, memory, pods (Admin/Technical)' })
async getClusterResources(@Param('id') id: string) { async getClusterResources(@Param('id') id: string) {
return this.clustersService.getClusterResources(id); return this.clustersService.getClusterResources(id);
} }
@Get(':id') @Get(':id')
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Get cluster details (Admin only)' }) @ApiOperation({ summary: 'Get cluster details (Admin/Technical)' })
async findOne(@Param('id') id: string) { async findOne(@Param('id') id: string) {
return this.clustersService.findOne(id); return this.clustersService.findOne(id);
} }
@Patch(':id') @Patch(':id')
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Update cluster configuration (Admin only)' }) @ApiOperation({ summary: 'Update cluster configuration (Admin/Technical)' })
async update(@Param('id') id: string, @Body() dto: UpdateClusterDto) { async update(@Param('id') id: string, @Body() dto: UpdateClusterDto) {
return this.clustersService.update(id, dto); return this.clustersService.update(id, dto);
} }
@Post(':id/test') @Post(':id/test')
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Test connectivity to a registered cluster (Admin only)' }) @ApiOperation({ summary: 'Test connectivity to a registered cluster (Admin/Technical)' })
async testConnection(@Param('id') id: string) { async testConnection(@Param('id') id: string) {
return this.clustersService.testClusterById(id); return this.clustersService.testClusterById(id);
} }
@Delete(':id') @Delete(':id')
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Remove a cluster (Admin only)' }) @ApiOperation({ summary: 'Remove a cluster (Admin/Technical)' })
async delete(@Param('id') id: string) { async delete(@Param('id') id: string) {
await this.clustersService.delete(id); await this.clustersService.delete(id);
return { message: 'Cluster deleted' }; return { message: 'Cluster deleted' };
@@ -93,36 +93,36 @@ export class ClustersController {
// ─── Cluster Pool admin endpoints ───────────────────────────────── // ─── Cluster Pool admin endpoints ─────────────────────────────────
@Post('pools') @Post('pools')
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Create a cluster pool for load balancing (Admin only)' }) @ApiOperation({ summary: 'Create a cluster pool for load balancing (Admin/Technical)' })
async createPool(@Body() dto: CreateClusterPoolDto) { async createPool(@Body() dto: CreateClusterPoolDto) {
return this.clustersService.createPool(dto); return this.clustersService.createPool(dto);
} }
@Get('pools') @Get('pools')
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'List all cluster pools (Admin only)' }) @ApiOperation({ summary: 'List all cluster pools (Admin/Technical)' })
async findAllPools() { async findAllPools() {
return this.clustersService.findAllPools(); return this.clustersService.findAllPools();
} }
@Get('pools/:id') @Get('pools/:id')
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Get cluster pool details (Admin only)' }) @ApiOperation({ summary: 'Get cluster pool details (Admin/Technical)' })
async findOnePool(@Param('id') id: string) { async findOnePool(@Param('id') id: string) {
return this.clustersService.findOnePool(id); return this.clustersService.findOnePool(id);
} }
@Patch('pools/:id') @Patch('pools/:id')
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Update cluster pool (Admin only)' }) @ApiOperation({ summary: 'Update cluster pool (Admin/Technical)' })
async updatePool(@Param('id') id: string, @Body() dto: UpdateClusterPoolDto) { async updatePool(@Param('id') id: string, @Body() dto: UpdateClusterPoolDto) {
return this.clustersService.updatePool(id, dto); return this.clustersService.updatePool(id, dto);
} }
@Delete('pools/:id') @Delete('pools/:id')
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Delete a cluster pool (Admin only)' }) @ApiOperation({ summary: 'Delete a cluster pool (Admin/Technical)' })
async deletePool(@Param('id') id: string) { async deletePool(@Param('id') id: string) {
await this.clustersService.deletePool(id); await this.clustersService.deletePool(id);
return { message: 'Cluster pool deleted' }; return { message: 'Cluster pool deleted' };
+20
View File
@@ -3,6 +3,26 @@
export enum UserRole { export enum UserRole {
USER = 'user', USER = 'user',
ADMIN = 'admin', ADMIN = 'admin',
TECHNICAL = 'technical',
SALES = 'sales',
}
export enum TicketDepartment {
TECHNICAL = 'technical',
SALES = 'sales',
}
export enum TicketStatus {
OPEN = 'open',
ANSWERED = 'answered',
WAITING = 'waiting', // waiting for staff reply
CLOSED = 'closed',
}
export enum TicketPriority {
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
} }
export enum AppRuntime { export enum AppRuntime {
+40
View File
@@ -0,0 +1,40 @@
import { IsString, IsEnum, IsOptional, MinLength, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { TicketDepartment, TicketPriority, TicketStatus } from '../../common/enums';
export class CreateTicketDto {
@ApiProperty({ example: 'Cannot deploy my app' })
@IsString()
@MinLength(3)
@MaxLength(200)
subject: string;
@ApiProperty({ enum: TicketDepartment, example: 'technical' })
@IsEnum(TicketDepartment)
department: TicketDepartment;
@ApiPropertyOptional({ enum: TicketPriority, example: 'medium' })
@IsOptional()
@IsEnum(TicketPriority)
priority?: TicketPriority;
@ApiProperty({ example: 'I am getting an error when trying to deploy...' })
@IsString()
@MinLength(10)
@MaxLength(5000)
message: string;
}
export class ReplyTicketDto {
@ApiProperty({ example: 'We are looking into your issue...' })
@IsString()
@MinLength(1)
@MaxLength(5000)
message: string;
}
export class UpdateTicketStatusDto {
@ApiProperty({ enum: TicketStatus })
@IsEnum(TicketStatus)
status: TicketStatus;
}
@@ -0,0 +1,40 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Ticket } from './ticket.entity';
import { User } from '../../users/entities/user.entity';
import { UserRole } from '../../common/enums';
@Entity('ticket_messages')
export class TicketMessage {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'text' })
message: string;
@Column()
ticketId: string;
@ManyToOne(() => Ticket, (ticket) => ticket.messages, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'ticketId' })
ticket: Ticket;
@Column()
senderId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'senderId' })
sender: User;
@Column({ type: 'enum', enum: UserRole })
senderRole: UserRole;
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,50 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';
import { TicketDepartment, TicketStatus, TicketPriority } from '../../common/enums';
import { User } from '../../users/entities/user.entity';
import { TicketMessage } from './ticket-message.entity';
@Entity('tickets')
export class Ticket {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
subject: string;
@Column({ type: 'enum', enum: TicketDepartment })
department: TicketDepartment;
@Column({ type: 'enum', enum: TicketStatus, default: TicketStatus.OPEN })
status: TicketStatus;
@Column({ type: 'enum', enum: TicketPriority, default: TicketPriority.MEDIUM })
priority: TicketPriority;
@Column()
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@OneToMany(() => TicketMessage, (msg) => msg.ticket, { cascade: true })
messages: TicketMessage[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@Column({ type: 'timestamp', nullable: true })
closedAt: Date;
}
+117
View File
@@ -0,0 +1,117 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Body,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole, TicketDepartment, TicketStatus } from '../common/enums';
import { CreateTicketDto, ReplyTicketDto, UpdateTicketStatusDto } from './dto/ticket.dto';
@ApiTags('Tickets')
@ApiBearerAuth()
@Controller('tickets')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class TicketsController {
constructor(private readonly ticketsService: TicketsService) {}
// ═══════════════════════════════════════════
// STATIC ROUTES FIRST (before :id param routes)
// ═══════════════════════════════════════════
@Post()
@ApiOperation({ summary: 'Create a new ticket (any user)' })
async create(@Request() req: any, @Body() dto: CreateTicketDto) {
return this.ticketsService.create(req.user.id, req.user.role, dto);
}
@Get('my')
@ApiOperation({ summary: 'List my tickets' })
async findMyTickets(@Request() req: any) {
return this.ticketsService.findMyTickets(req.user.id);
}
// ─── Staff endpoints ───
@Get('staff/technical')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'List technical department tickets (Technical/Admin)' })
@ApiQuery({ name: 'status', required: false, enum: TicketStatus })
async findTechnicalTickets(@Query('status') status?: TicketStatus) {
return this.ticketsService.findByDepartment(TicketDepartment.TECHNICAL, status);
}
@Get('staff/sales')
@Roles(UserRole.ADMIN, UserRole.SALES)
@ApiOperation({ summary: 'List sales department tickets (Sales/Admin)' })
@ApiQuery({ name: 'status', required: false, enum: TicketStatus })
async findSalesTickets(@Query('status') status?: TicketStatus) {
return this.ticketsService.findByDepartment(TicketDepartment.SALES, status);
}
// ─── Admin endpoints ───
@Get('admin/all')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List all tickets (Admin only)' })
@ApiQuery({ name: 'department', required: false, enum: TicketDepartment })
@ApiQuery({ name: 'status', required: false, enum: TicketStatus })
async findAllTickets(
@Query('department') department?: TicketDepartment,
@Query('status') status?: TicketStatus,
) {
return this.ticketsService.findAll(department, status);
}
@Get('admin/stats')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Get ticket statistics (Admin only)' })
async getStats() {
return this.ticketsService.getStats();
}
// ═══════════════════════════════════════════
// PARAMETERIZED ROUTES (after static routes)
// ═══════════════════════════════════════════
@Get(':id')
@ApiOperation({ summary: 'Get ticket detail with messages' })
async findOne(@Param('id') id: string, @Request() req: any) {
return this.ticketsService.findOne(id, req.user.id, req.user.role);
}
@Post(':id/reply')
@ApiOperation({ summary: 'Reply to a ticket' })
async reply(@Param('id') id: string, @Request() req: any, @Body() dto: ReplyTicketDto) {
return this.ticketsService.reply(id, req.user.id, req.user.role, dto);
}
@Patch(':id/close')
@ApiOperation({ summary: 'Close a ticket' })
async close(@Param('id') id: string, @Request() req: any) {
return this.ticketsService.close(id, req.user.id, req.user.role);
}
@Patch(':id/status')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL, UserRole.SALES)
@ApiOperation({ summary: 'Update ticket status (Staff/Admin)' })
async updateStatus(
@Param('id') id: string,
@Request() req: any,
@Body() dto: UpdateTicketStatusDto,
) {
const ticket = await this.ticketsService.findOne(id, req.user.id, req.user.role);
ticket.status = dto.status;
if (dto.status === TicketStatus.CLOSED) {
ticket.closedAt = new Date();
}
return this.ticketsService['ticketsRepo'].save(ticket);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TicketsService } from './tickets.service';
import { TicketsController } from './tickets.controller';
import { Ticket } from './entities/ticket.entity';
import { TicketMessage } from './entities/ticket-message.entity';
@Module({
imports: [TypeOrmModule.forFeature([Ticket, TicketMessage])],
controllers: [TicketsController],
providers: [TicketsService],
exports: [TicketsService],
})
export class TicketsModule {}
+197
View File
@@ -0,0 +1,197 @@
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Ticket } from './entities/ticket.entity';
import { TicketMessage } from './entities/ticket-message.entity';
import { CreateTicketDto, ReplyTicketDto } from './dto/ticket.dto';
import { TicketDepartment, TicketStatus, TicketPriority, UserRole } from '../common/enums';
@Injectable()
export class TicketsService {
private readonly logger = new Logger(TicketsService.name);
constructor(
@InjectRepository(Ticket)
private ticketsRepo: Repository<Ticket>,
@InjectRepository(TicketMessage)
private messagesRepo: Repository<TicketMessage>,
) {}
// ─── User: Create ticket ───
async create(userId: string, userRole: UserRole, dto: CreateTicketDto): Promise<Ticket> {
const ticket = this.ticketsRepo.create({
subject: dto.subject,
department: dto.department,
priority: dto.priority || TicketPriority.MEDIUM,
userId,
status: TicketStatus.OPEN,
});
const saved = await this.ticketsRepo.save(ticket);
// Create first message
const msg = this.messagesRepo.create({
ticketId: saved.id,
senderId: userId,
senderRole: userRole,
message: dto.message,
});
await this.messagesRepo.save(msg);
this.logger.log(`Ticket created: "${dto.subject}" → ${dto.department} by user ${userId}`);
return this.findOne(saved.id, userId, userRole);
}
// ─── User: List my tickets ───
async findMyTickets(userId: string): Promise<Ticket[]> {
return this.ticketsRepo.find({
where: { userId },
relations: ['messages', 'messages.sender'],
order: { updatedAt: 'DESC' },
});
}
// ─── User/Staff: Get single ticket ───
async findOne(ticketId: string, userId: string, userRole: UserRole): Promise<Ticket> {
const ticket = await this.ticketsRepo.findOne({
where: { id: ticketId },
relations: ['messages', 'messages.sender', 'user'],
});
if (!ticket) {
throw new NotFoundException('Ticket not found');
}
// Access control: user can only see own tickets
// Staff can see tickets of their department (or admin sees all)
if (userRole === UserRole.USER && ticket.userId !== userId) {
throw new ForbiddenException('Access denied');
}
if (userRole === UserRole.TECHNICAL && ticket.department !== TicketDepartment.TECHNICAL) {
// Technical staff can only see technical tickets (unless admin)
throw new ForbiddenException('This ticket belongs to another department');
}
if (userRole === UserRole.SALES && ticket.department !== TicketDepartment.SALES) {
throw new ForbiddenException('This ticket belongs to another department');
}
// ADMIN can see all
// Sort messages by date
if (ticket.messages) {
ticket.messages.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}
return ticket;
}
// ─── Reply to ticket ───
async reply(ticketId: string, userId: string, userRole: UserRole, dto: ReplyTicketDto): Promise<TicketMessage> {
const ticket = await this.findOne(ticketId, userId, userRole);
const msg = this.messagesRepo.create({
ticketId: ticket.id,
senderId: userId,
senderRole: userRole,
message: dto.message,
});
const saved = await this.messagesRepo.save(msg);
// Update ticket status based on who replied
if (userRole === UserRole.USER) {
ticket.status = TicketStatus.WAITING;
} else {
// Staff or admin replied
ticket.status = TicketStatus.ANSWERED;
}
await this.ticketsRepo.save(ticket);
this.logger.log(`Reply on ticket ${ticketId} by ${userRole} user ${userId}`);
return saved;
}
// ─── Close ticket ───
async close(ticketId: string, userId: string, userRole: UserRole): Promise<Ticket> {
const ticket = await this.findOne(ticketId, userId, userRole);
ticket.status = TicketStatus.CLOSED;
ticket.closedAt = new Date();
return this.ticketsRepo.save(ticket);
}
// ─── Staff: List department tickets ───
async findByDepartment(department: TicketDepartment, status?: TicketStatus): Promise<Ticket[]> {
const where: any = { department };
if (status) {
where.status = status;
}
return this.ticketsRepo.find({
where,
relations: ['user', 'messages'],
order: { updatedAt: 'DESC' },
});
}
// ─── Admin: List all tickets ───
async findAll(department?: TicketDepartment, status?: TicketStatus): Promise<Ticket[]> {
const where: any = {};
if (department) where.department = department;
if (status) where.status = status;
return this.ticketsRepo.find({
where,
relations: ['user', 'messages'],
order: { updatedAt: 'DESC' },
});
}
// ─── Admin: Ticket statistics ───
async getStats(): Promise<{
totalTickets: number;
openTickets: number;
avgResponseTimeMinutes: number;
byDepartment: Record<string, { total: number; open: number; answered: number; closed: number }>;
}> {
const allTickets = await this.ticketsRepo.find({
relations: ['messages', 'messages.sender'],
});
const totalTickets = allTickets.length;
const openTickets = allTickets.filter((t) => t.status !== TicketStatus.CLOSED).length;
// Calculate average first response time
let totalResponseMs = 0;
let respondedCount = 0;
for (const ticket of allTickets) {
if (!ticket.messages || ticket.messages.length < 2) continue;
// Sort messages by date
const sorted = [...ticket.messages].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
// Find first staff reply
const firstUserMsg = sorted[0];
const firstStaffReply = sorted.find(
(m) => m.senderRole !== UserRole.USER && m.senderId !== ticket.userId,
);
if (firstStaffReply) {
totalResponseMs +=
new Date(firstStaffReply.createdAt).getTime() - new Date(firstUserMsg.createdAt).getTime();
respondedCount++;
}
}
const avgResponseTimeMinutes = respondedCount > 0 ? Math.round(totalResponseMs / respondedCount / 60000) : 0;
// By department breakdown
const byDepartment: Record<string, { total: number; open: number; answered: number; closed: number }> = {};
for (const dept of Object.values(TicketDepartment)) {
const deptTickets = allTickets.filter((t) => t.department === dept);
byDepartment[dept] = {
total: deptTickets.length,
open: deptTickets.filter((t) => t.status === TicketStatus.OPEN || t.status === TicketStatus.WAITING).length,
answered: deptTickets.filter((t) => t.status === TicketStatus.ANSWERED).length,
closed: deptTickets.filter((t) => t.status === TicketStatus.CLOSED).length,
};
}
return { totalTickets, openTickets, avgResponseTimeMinutes, byDepartment };
}
}
+8 -8
View File
@@ -63,16 +63,16 @@ export class UsersController {
} }
@Get() @Get()
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.TECHNICAL, UserRole.SALES)
@ApiOperation({ summary: 'List all users with optional search (Admin only)' }) @ApiOperation({ summary: 'List all users with optional search (Staff/Admin)' })
@ApiQuery({ name: 'search', required: false, description: 'Search by name or email' }) @ApiQuery({ name: 'search', required: false, description: 'Search by name or email' })
async findAll(@Query('search') search?: string) { async findAll(@Query('search') search?: string) {
return this.usersService.findAll(search); return this.usersService.findAll(search);
} }
@Post() @Post()
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Create a new user (Admin only)' }) @ApiOperation({ summary: 'Create a new user (Admin/Technical only)' })
async adminCreate(@Body() dto: AdminCreateUserDto) { async adminCreate(@Body() dto: AdminCreateUserDto) {
return this.usersService.adminCreate(dto); return this.usersService.adminCreate(dto);
} }
@@ -86,16 +86,16 @@ export class UsersController {
} }
@Patch(':id/deactivate') @Patch(':id/deactivate')
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Deactivate a user (Admin only)' }) @ApiOperation({ summary: 'Deactivate a user (Admin/Technical)' })
async deactivate(@Param('id') id: string) { async deactivate(@Param('id') id: string) {
await this.usersService.deactivate(id); await this.usersService.deactivate(id);
return { message: 'User deactivated' }; return { message: 'User deactivated' };
} }
@Patch(':id/activate') @Patch(':id/activate')
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Activate a user (Admin only)' }) @ApiOperation({ summary: 'Activate a user (Admin/Technical)' })
async activate(@Param('id') id: string) { async activate(@Param('id') id: string) {
await this.usersService.activate(id); await this.usersService.activate(id);
return { message: 'User activated' }; return { message: 'User activated' };
@@ -0,0 +1,176 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import api from '@/lib/api';
import type { Ticket, TicketStats, TicketDepartment, TicketStatus } from '@/types';
const statusColors: Record<string, string> = {
open: 'bg-yellow-100 text-yellow-700',
waiting: 'bg-orange-100 text-orange-700',
answered: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-500',
};
const priorityColors: Record<string, string> = {
low: 'bg-blue-100 text-blue-700',
medium: 'bg-yellow-100 text-yellow-700',
high: 'bg-red-100 text-red-700',
};
export default function AdminTicketsPage() {
const [deptFilter, setDeptFilter] = useState<TicketDepartment | ''>('');
const [statusFilter, setStatusFilter] = useState<TicketStatus | ''>('');
const { data: stats } = useQuery<TicketStats>({
queryKey: ['ticket-stats'],
queryFn: () => api.get('/tickets/admin/stats').then((r) => r.data),
});
const { data: tickets = [], isLoading } = useQuery<Ticket[]>({
queryKey: ['admin-tickets', deptFilter, statusFilter],
queryFn: () => {
const params = new URLSearchParams();
if (deptFilter) params.append('department', deptFilter);
if (statusFilter) params.append('status', statusFilter);
const qs = params.toString();
return api.get(`/tickets/admin/all${qs ? `?${qs}` : ''}`).then((r) => r.data);
},
});
const formatResponseTime = (minutes: number) => {
if (minutes < 60) return `${minutes} min`;
if (minutes < 1440) return `${Math.round(minutes / 60)} hours`;
return `${Math.round(minutes / 1440)} days`;
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">📋 All Tickets</h1>
<p className="text-sm text-gray-500 mt-1">Overview of all support tickets across departments</p>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="card p-4">
<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>
<div className="card p-4">
<p className="text-xs text-gray-500 uppercase font-semibold">Avg Response Time</p>
<p className="text-2xl font-bold text-primary-600 mt-1">
{stats.avgResponseTimeMinutes > 0 ? formatResponseTime(stats.avgResponseTimeMinutes) : '—'}
</p>
</div>
<div className="card p-4">
<p className="text-xs text-gray-500 uppercase font-semibold">Departments</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>
</div>
))}
</div>
</div>
</div>
)}
{/* Filters */}
<div className="flex flex-wrap gap-4">
<div className="flex flex-wrap gap-2">
<span className="text-sm text-gray-500 self-center">Department:</span>
{(['' as const, 'technical' as TicketDepartment, 'sales' as TicketDepartment]).map((dept) => (
<button
key={dept}
onClick={() => setDeptFilter(dept)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
deptFilter === dept
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{dept === '' ? 'All' : dept === 'technical' ? '🔧 Technical' : '💼 Sales'}
</button>
))}
</div>
<div className="flex flex-wrap gap-2">
<span className="text-sm text-gray-500 self-center">Status:</span>
{(['', 'open', 'waiting', 'answered', 'closed'] as const).map((status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
statusFilter === status
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{status === '' ? 'All' : status.charAt(0).toUpperCase() + status.slice(1)}
</button>
))}
</div>
</div>
{/* Tickets Table */}
{isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary-600 border-t-transparent mx-auto" />
</div>
) : tickets.length === 0 ? (
<div className="card p-12 text-center">
<span className="text-4xl">📭</span>
<h3 className="mt-3 text-lg font-semibold text-gray-700">No tickets found</h3>
</div>
) : (
<div className="space-y-3">
{tickets.map((ticket) => (
<Link
key={ticket.id}
href={`/dashboard/tickets/${ticket.id}`}
className="card p-4 block hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900 truncate">{ticket.subject}</h3>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
{ticket.status}
</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[ticket.priority]}`}>
{ticket.priority}
</span>
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700">
{ticket.department === 'technical' ? '🔧 Technical' : '💼 Sales'}
</span>
</div>
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
{ticket.user && (
<span>
👤 {ticket.user.firstName} {ticket.user.lastName} ({ticket.user.email})
</span>
)}
<span></span>
<span>{new Date(ticket.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
<span></span>
<span>{ticket.messages?.length || 0} msg{(ticket.messages?.length || 0) !== 1 ? 's' : ''}</span>
</div>
</div>
<span className="text-gray-400 text-sm"></span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
@@ -3,11 +3,14 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
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 { AdminUser } from '@/types'; import type { AdminUser } from '@/types';
export default function AdminUsersPage() { export default function AdminUsersPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const currentUser = useAuthStore((s) => s.user);
const isAdmin = currentUser?.role === 'admin';
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ const [form, setForm] = useState({
@@ -15,7 +18,7 @@ export default function AdminUsersPage() {
password: '', password: '',
firstName: '', firstName: '',
lastName: '', lastName: '',
role: 'user' as 'user' | 'admin', role: 'user' as 'user' | 'admin' | 'technical' | 'sales',
}); });
const { data: users = [], isLoading } = useQuery<AdminUser[]>({ const { data: users = [], isLoading } = useQuery<AdminUser[]>({
@@ -116,10 +119,12 @@ export default function AdminUsersPage() {
<select <select
className="input-field w-full sm:w-48" className="input-field w-full sm:w-48"
value={form.role} value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value as 'user' | 'admin' })} onChange={(e) => setForm({ ...form, role: e.target.value as typeof form.role })}
> >
<option value="user">User</option> <option value="user">User</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
<option value="technical">Technical</option>
<option value="sales">Sales</option>
</select> </select>
</div> </div>
<button <button
@@ -192,6 +197,7 @@ export default function AdminUsersPage() {
</td> </td>
<td className="px-6 py-4 text-sm text-gray-600">{user.email}</td> <td className="px-6 py-4 text-sm text-gray-600">{user.email}</td>
<td className="px-6 py-4"> <td className="px-6 py-4">
{isAdmin ? (
<select <select
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
value={user.role} value={user.role}
@@ -199,7 +205,17 @@ export default function AdminUsersPage() {
> >
<option value="user">User</option> <option value="user">User</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
<option value="technical">Technical</option>
<option value="sales">Sales</option>
</select> </select>
) : (
<span className={`badge ${
user.role === 'admin' ? 'badge-purple' :
user.role === 'technical' ? 'bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs font-medium' :
user.role === 'sales' ? 'bg-green-100 text-green-700 px-2 py-0.5 rounded-full text-xs font-medium' :
'badge-gray'
}`}>{user.role}</span>
)}
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}> <span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}>
@@ -241,10 +257,16 @@ export default function AdminUsersPage() {
</div> </div>
<div className="flex items-center gap-3 text-xs text-gray-500"> <div className="flex items-center gap-3 text-xs text-gray-500">
<span className="badge badge-blue">{user.appCount ?? 0} apps</span> <span className="badge badge-blue">{user.appCount ?? 0} apps</span>
<span className={`badge ${user.role === 'admin' ? 'badge-purple' : 'badge-gray'}`}>{user.role}</span> <span className={`badge ${
user.role === 'admin' ? 'badge-purple' :
user.role === 'technical' ? 'bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs font-medium' :
user.role === 'sales' ? 'bg-green-100 text-green-700 px-2 py-0.5 rounded-full text-xs font-medium' :
'badge-gray'
}`}>{user.role}</span>
<span>{new Date(user.createdAt).toLocaleDateString()}</span> <span>{new Date(user.createdAt).toLocaleDateString()}</span>
</div> </div>
<div className="flex items-center gap-2 pt-2 border-t border-gray-100"> <div className="flex items-center gap-2 pt-2 border-t border-gray-100">
{isAdmin ? (
<select <select
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
value={user.role} value={user.role}
@@ -252,7 +274,12 @@ export default function AdminUsersPage() {
> >
<option value="user">User</option> <option value="user">User</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
<option value="technical">Technical</option>
<option value="sales">Sales</option>
</select> </select>
) : (
<span className="text-sm text-gray-500 capitalize">{user.role}</span>
)}
<button <button
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })} onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
className={`text-sm font-medium ${user.isActive ? 'text-red-600' : 'text-green-600'}`} className={`text-sm font-medium ${user.isActive ? 'text-red-600' : 'text-green-600'}`}
+1 -1
View File
@@ -13,7 +13,7 @@ const steps = ['Basic Info', 'Runtime & Database', 'Resources', 'Review'];
export default function DeployPage() { export default function DeployPage() {
const router = useRouter(); const router = useRouter();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin' || user?.role === 'technical';
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [form, setForm] = useState<CreateApplicationDto>({ const [form, setForm] = useState<CreateApplicationDto>({
name: '', name: '',
+46
View File
@@ -9,12 +9,26 @@ const userNavItems = [
{ href: '/dashboard', label: 'Dashboard', icon: '📊' }, { href: '/dashboard', label: 'Dashboard', icon: '📊' },
{ href: '/dashboard/apps', label: 'Applications', icon: '📦' }, { href: '/dashboard/apps', label: 'Applications', icon: '📦' },
{ href: '/dashboard/deploy', label: 'New Deploy', icon: '🚀' }, { href: '/dashboard/deploy', label: 'New Deploy', icon: '🚀' },
{ href: '/dashboard/tickets', label: 'Tickets', icon: '🎫' },
]; ];
const adminNavItems = [ const adminNavItems = [
{ href: '/dashboard/admin/users', label: 'Users', icon: '👥' }, { href: '/dashboard/admin/users', label: 'Users', icon: '👥' },
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: '🖥️' }, { href: '/dashboard/admin/clusters', label: 'Clusters', icon: '🖥️' },
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: '⚖️' }, { href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: '⚖️' },
{ href: '/dashboard/admin/tickets', label: 'All Tickets', icon: '📋' },
];
const technicalNavItems = [
{ href: '/dashboard/admin/users', label: 'Users', icon: '👥' },
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: '🖥️' },
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: '⚖️' },
{ href: '/dashboard/staff/tickets', label: 'Technical Tickets', icon: '🔧' },
];
const salesNavItems = [
{ href: '/dashboard/admin/users', label: 'Users', icon: '👥' },
{ href: '/dashboard/staff/tickets', label: 'Sales Tickets', icon: '💼' },
]; ];
export default function DashboardLayout({ children }: { children: React.ReactNode }) { export default function DashboardLayout({ children }: { children: React.ReactNode }) {
@@ -83,6 +97,32 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
))} ))}
</> </>
)} )}
{user?.role === 'technical' && (
<>
<div className="pt-5 pb-2">
<p className="px-3 text-[11px] font-bold text-gray-400 uppercase tracking-widest">
Technical
</p>
</div>
{technicalNavItems.map((item) => (
<NavLink key={item.href} item={item} />
))}
</>
)}
{user?.role === 'sales' && (
<>
<div className="pt-5 pb-2">
<p className="px-3 text-[11px] font-bold text-gray-400 uppercase tracking-widest">
Sales
</p>
</div>
{salesNavItems.map((item) => (
<NavLink key={item.href} item={item} />
))}
</>
)}
</div> </div>
{/* Sidebar footer */} {/* Sidebar footer */}
@@ -151,6 +191,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
{user?.role === 'admin' && ( {user?.role === 'admin' && (
<span className="badge-purple">Admin</span> <span className="badge-purple">Admin</span>
)} )}
{user?.role === 'technical' && (
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-700">Technical</span>
)}
{user?.role === 'sales' && (
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-700">Sales</span>
)}
</div> </div>
<button <button
onClick={() => { logout(); router.push('/login'); }} onClick={() => { logout(); router.push('/login'); }}
@@ -0,0 +1,119 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import api from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import type { Ticket, TicketStatus } from '@/types';
const statusColors: Record<string, string> = {
open: 'bg-yellow-100 text-yellow-700',
waiting: 'bg-orange-100 text-orange-700',
answered: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-500',
};
const priorityColors: Record<string, string> = {
low: 'bg-blue-100 text-blue-700',
medium: 'bg-yellow-100 text-yellow-700',
high: 'bg-red-100 text-red-700',
};
export default function StaffTicketsPage() {
const user = useAuthStore((s) => s.user);
const [statusFilter, setStatusFilter] = useState<TicketStatus | ''>('');
// Determine which department this staff member handles
const department = user?.role === 'sales' ? 'sales' : 'technical';
const departmentLabel = department === 'technical' ? '🔧 Technical' : '💼 Sales';
const { data: tickets = [], isLoading } = useQuery<Ticket[]>({
queryKey: ['staff-tickets', department, statusFilter],
queryFn: () => {
const params = statusFilter ? `?status=${statusFilter}` : '';
return api.get(`/tickets/staff/${department}${params}`).then((r) => r.data);
},
});
const openCount = tickets.filter((t) => t.status === 'open' || t.status === 'waiting').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
</p>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-2">
{(['', 'open', 'waiting', 'answered', 'closed'] as const).map((status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
statusFilter === status
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{status === '' ? 'All' : status.charAt(0).toUpperCase() + status.slice(1)}
</button>
))}
</div>
{/* Tickets List */}
{isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary-600 border-t-transparent mx-auto" />
</div>
) : tickets.length === 0 ? (
<div className="card p-12 text-center">
<span className="text-4xl"></span>
<h3 className="mt-3 text-lg font-semibold text-gray-700">No tickets</h3>
<p className="text-sm text-gray-500 mt-1">
{statusFilter ? `No ${statusFilter} tickets` : 'No tickets in this department'}
</p>
</div>
) : (
<div className="space-y-3">
{tickets.map((ticket) => (
<Link
key={ticket.id}
href={`/dashboard/tickets/${ticket.id}`}
className="card p-4 block hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900 truncate">{ticket.subject}</h3>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
{ticket.status}
</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[ticket.priority]}`}>
{ticket.priority}
</span>
</div>
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
{ticket.user && (
<span>
👤 {ticket.user.firstName} {ticket.user.lastName} ({ticket.user.email})
</span>
)}
<span></span>
<span>{new Date(ticket.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
<span></span>
<span>{ticket.messages?.length || 0} msg{(ticket.messages?.length || 0) !== 1 ? 's' : ''}</span>
</div>
</div>
<span className="text-gray-400 text-sm"></span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,177 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useRouter } from 'next/navigation';
import api from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import toast from 'react-hot-toast';
import type { Ticket } from '@/types';
const statusColors: Record<string, string> = {
open: 'bg-yellow-100 text-yellow-700',
waiting: 'bg-orange-100 text-orange-700',
answered: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-500',
};
export default function TicketDetailPage() {
const { id } = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const [reply, setReply] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const { data: ticket, isLoading } = useQuery<Ticket>({
queryKey: ['ticket', id],
queryFn: () => api.get(`/tickets/${id}`).then((r) => r.data),
refetchInterval: 10000,
});
const replyMutation = useMutation({
mutationFn: (message: string) => api.post(`/tickets/${id}/reply`, { message }).then((r) => r.data),
onSuccess: () => {
setReply('');
queryClient.invalidateQueries({ queryKey: ['ticket', id] });
toast.success('Reply sent');
},
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to send reply'),
});
const closeMutation = useMutation({
mutationFn: () => api.patch(`/tickets/${id}/close`).then((r) => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticket', id] });
toast.success('Ticket closed');
},
});
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [ticket?.messages]);
const handleSubmitReply = (e: React.FormEvent) => {
e.preventDefault();
if (reply.trim()) {
replyMutation.mutate(reply.trim());
}
};
if (isLoading) {
return (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary-600 border-t-transparent mx-auto" />
</div>
);
}
if (!ticket) {
return <div className="card p-12 text-center text-gray-500">Ticket not found</div>;
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<button onClick={() => router.back()} className="text-sm text-gray-500 hover:text-gray-700 mb-2">
Back
</button>
<h1 className="text-xl font-bold text-gray-900">{ticket.subject}</h1>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
{ticket.status}
</span>
<span className="text-xs text-gray-500">
{ticket.department === 'technical' ? '🔧 Technical' : '💼 Sales'}
</span>
<span className="text-xs text-gray-400">
{new Date(ticket.createdAt).toLocaleString()}
</span>
{ticket.user && (
<span className="text-xs text-gray-400">
by {ticket.user.firstName} {ticket.user.lastName}
</span>
)}
</div>
</div>
{ticket.status !== 'closed' && (
<button
onClick={() => closeMutation.mutate()}
className="btn-ghost text-sm text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={closeMutation.isPending}
>
Close Ticket
</button>
)}
</div>
{/* Messages */}
<div className="card p-4 space-y-4 max-h-[500px] overflow-y-auto">
{ticket.messages?.map((msg) => {
const isMe = msg.senderId === user?.id;
const isStaff = msg.senderRole !== 'user';
return (
<div key={msg.id} className={`flex ${isMe ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[80%] rounded-2xl px-4 py-3 ${
isMe
? 'bg-primary-500 text-white rounded-br-md'
: isStaff
? 'bg-blue-50 text-gray-900 border border-blue-200 rounded-bl-md'
: 'bg-gray-100 text-gray-900 rounded-bl-md'
}`}>
{!isMe && (
<p className={`text-xs font-semibold mb-1 ${isStaff ? 'text-blue-600' : 'text-gray-500'}`}>
{msg.sender ? `${msg.sender.firstName} ${msg.sender.lastName}` : 'Unknown'}
{isStaff && (
<span className="ml-1 px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded text-[10px]">
{msg.senderRole === 'admin' ? 'Admin' : msg.senderRole === 'technical' ? 'Technical' : 'Sales'}
</span>
)}
</p>
)}
<p className="text-sm whitespace-pre-wrap">{msg.message}</p>
<p className={`text-[10px] mt-1 ${isMe ? 'text-white/70' : 'text-gray-400'}`}>
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
{/* Reply Box */}
{ticket.status !== 'closed' ? (
<form onSubmit={handleSubmitReply} className="card p-4">
<div className="flex gap-3">
<textarea
className="input-field flex-1 min-h-[60px] resize-none"
placeholder="Type your reply..."
value={reply}
onChange={(e) => setReply(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmitReply(e);
}
}}
/>
<button
type="submit"
className="btn-primary self-end"
disabled={!reply.trim() || replyMutation.isPending}
>
{replyMutation.isPending ? '...' : 'Send'}
</button>
</div>
</form>
) : (
<div className="card p-4 text-center text-sm text-gray-500">
This ticket is closed. Create a new ticket if you need further assistance.
</div>
)}
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import api from '@/lib/api';
import toast from 'react-hot-toast';
import type { Ticket, CreateTicketDto, TicketDepartment, TicketPriority } from '@/types';
const statusColors: Record<string, string> = {
open: 'bg-yellow-100 text-yellow-700',
waiting: 'bg-orange-100 text-orange-700',
answered: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-500',
};
const priorityColors: Record<string, string> = {
low: 'bg-blue-100 text-blue-700',
medium: 'bg-yellow-100 text-yellow-700',
high: 'bg-red-100 text-red-700',
};
export default function TicketsPage() {
const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState<CreateTicketDto>({
subject: '',
department: 'technical',
priority: 'medium',
message: '',
});
const { data: tickets = [], isLoading } = useQuery<Ticket[]>({
queryKey: ['my-tickets'],
queryFn: () => api.get('/tickets/my').then((r) => r.data),
});
const createMutation = useMutation({
mutationFn: (data: CreateTicketDto) => api.post('/tickets', data).then((r) => r.data),
onSuccess: () => {
toast.success('Ticket created successfully');
queryClient.invalidateQueries({ queryKey: ['my-tickets'] });
setShowCreate(false);
setForm({ subject: '', department: 'technical', priority: 'medium', message: '' });
},
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to create ticket'),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createMutation.mutate(form);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<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>
</div>
{/* Create Ticket Form */}
{showCreate && (
<form onSubmit={handleSubmit} className="card p-6 space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Create New Ticket</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Subject</label>
<input
type="text"
className="input-field"
placeholder="Brief description of your issue"
value={form.subject}
onChange={(e) => setForm({ ...form, subject: e.target.value })}
required
minLength={3}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Department</label>
<select
className="input-field"
value={form.department}
onChange={(e) => setForm({ ...form, department: e.target.value as TicketDepartment })}
>
<option value="technical">🔧 Technical Support</option>
<option value="sales">💼 Sales</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Priority</label>
<select
className="input-field"
value={form.priority}
onChange={(e) => setForm({ ...form, priority: e.target.value as TicketPriority })}
>
<option value="low">🟢 Low</option>
<option value="medium">🟡 Medium</option>
<option value="high">🔴 High</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Message</label>
<textarea
className="input-field min-h-[120px]"
placeholder="Describe your issue in detail..."
value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })}
required
minLength={10}
/>
</div>
<div className="flex justify-end">
<button type="submit" className="btn-primary" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Submit Ticket'}
</button>
</div>
</form>
)}
{/* Tickets List */}
{isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary-600 border-t-transparent mx-auto" />
</div>
) : tickets.length === 0 ? (
<div className="card p-12 text-center">
<span className="text-4xl">🎫</span>
<h3 className="mt-3 text-lg font-semibold text-gray-700">No tickets yet</h3>
<p className="text-sm text-gray-500 mt-1">Create a ticket if you need help</p>
</div>
) : (
<div className="space-y-3">
{tickets.map((ticket) => (
<Link
key={ticket.id}
href={`/dashboard/tickets/${ticket.id}`}
className="card p-4 block hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900 truncate">{ticket.subject}</h3>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
{ticket.status}
</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[ticket.priority]}`}>
{ticket.priority}
</span>
</div>
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
<span>{ticket.department === 'technical' ? '🔧 Technical' : '💼 Sales'}</span>
<span></span>
<span>{new Date(ticket.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
<span></span>
<span>{ticket.messages?.length || 0} message{(ticket.messages?.length || 0) !== 1 ? 's' : ''}</span>
</div>
</div>
<span className="text-gray-400 text-sm"></span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
+45 -1
View File
@@ -3,7 +3,7 @@ export interface User {
email: string; email: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
role: 'user' | 'admin'; role: 'user' | 'admin' | 'technical' | 'sales';
isActive: boolean; isActive: boolean;
namespace?: string; namespace?: string;
createdAt: string; createdAt: string;
@@ -181,3 +181,47 @@ export interface ClusterResources {
nodeCount: number; nodeCount: number;
appCount: number; appCount: number;
} }
// ─── Ticket types ───────────────────────────────────
export type TicketDepartment = 'technical' | 'sales';
export type TicketStatus = 'open' | 'answered' | 'waiting' | 'closed';
export type TicketPriority = 'low' | 'medium' | 'high';
export interface Ticket {
id: string;
subject: string;
department: TicketDepartment;
status: TicketStatus;
priority: TicketPriority;
userId: string;
user?: User;
messages?: TicketMessageType[];
createdAt: string;
updatedAt: string;
closedAt?: string;
}
export interface TicketMessageType {
id: string;
message: string;
ticketId: string;
senderId: string;
senderRole: 'user' | 'admin' | 'technical' | 'sales';
sender?: User;
createdAt: string;
}
export interface CreateTicketDto {
subject: string;
department: TicketDepartment;
priority?: TicketPriority;
message: string;
}
export interface TicketStats {
totalTickets: number;
openTickets: number;
avgResponseTimeMinutes: number;
byDepartment: Record<string, { total: number; open: number; answered: number; closed: number }>;
}
File diff suppressed because one or more lines are too long