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:
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'}`}
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user