diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index a909a297..1798e01b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -29,6 +29,8 @@ import { AccessControlModule } from './access-control/access-control.module'; import { WaitlistModule } from './waitlist/waitlist.module'; import { EventsModule } from './events/events.module'; import { MembershipPlansModule } from './membership-plans/membership-plans.module'; +import { MaintenanceModule } from './maintenance/maintenance.module'; +import { LeadsModule } from './leads/leads.module'; @Module({ imports: [ @@ -114,6 +116,8 @@ import { MembershipPlansModule } from './membership-plans/membership-plans.modul WaitlistModule, EventsModule, MembershipPlansModule, + MaintenanceModule, + LeadsModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/leads/dto/create-lead.dto.ts b/backend/src/leads/dto/create-lead.dto.ts new file mode 100644 index 00000000..b97c784a --- /dev/null +++ b/backend/src/leads/dto/create-lead.dto.ts @@ -0,0 +1,22 @@ +import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { LeadSource } from '../enums/lead-source.enum'; + +export class CreateLeadDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsEmail() + email: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + company?: string; + + @IsEnum(LeadSource) + source: LeadSource; +} diff --git a/backend/src/leads/dto/lead-query.dto.ts b/backend/src/leads/dto/lead-query.dto.ts new file mode 100644 index 00000000..74740586 --- /dev/null +++ b/backend/src/leads/dto/lead-query.dto.ts @@ -0,0 +1,30 @@ +import { IsEnum, IsInt, IsOptional, IsUUID, Min } from 'class-validator'; +import { Type } from 'class-transformer'; +import { LeadStatus } from '../enums/lead-status.enum'; +import { LeadSource } from '../enums/lead-source.enum'; + +export class LeadQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number = 20; + + @IsOptional() + @IsEnum(LeadStatus) + status?: LeadStatus; + + @IsOptional() + @IsEnum(LeadSource) + source?: LeadSource; + + @IsOptional() + @IsUUID() + assignedToStaffId?: string; +} diff --git a/backend/src/leads/dto/update-lead.dto.ts b/backend/src/leads/dto/update-lead.dto.ts new file mode 100644 index 00000000..fb1ee430 --- /dev/null +++ b/backend/src/leads/dto/update-lead.dto.ts @@ -0,0 +1,16 @@ +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; +import { LeadStatus } from '../enums/lead-status.enum'; + +export class UpdateLeadDto { + @IsOptional() + @IsEnum(LeadStatus) + status?: LeadStatus; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsUUID() + assignedToStaffId?: string; +} diff --git a/backend/src/leads/entities/lead.entity.ts b/backend/src/leads/entities/lead.entity.ts new file mode 100644 index 00000000..890884b9 --- /dev/null +++ b/backend/src/leads/entities/lead.entity.ts @@ -0,0 +1,59 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { LeadSource } from '../enums/lead-source.enum'; +import { LeadStatus } from '../enums/lead-status.enum'; + +@Entity('leads') +export class Lead { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column() + email: string; + + @Column({ nullable: true }) + phone: string | null; + + @Column({ nullable: true }) + company: string | null; + + @Column({ type: 'enum', enum: LeadSource, default: LeadSource.OTHER }) + source: LeadSource; + + @Column({ type: 'enum', enum: LeadStatus, default: LeadStatus.NEW }) + status: LeadStatus; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @Column('uuid', { nullable: true }) + assignedToStaffId: string | null; + + @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'assignedToStaffId' }) + assignedToStaff: User; + + @Column({ type: 'timestamptz', nullable: true }) + convertedAt: Date | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @DeleteDateColumn() + deletedAt: Date | null; +} diff --git a/backend/src/leads/enums/lead-source.enum.ts b/backend/src/leads/enums/lead-source.enum.ts new file mode 100644 index 00000000..ba77d574 --- /dev/null +++ b/backend/src/leads/enums/lead-source.enum.ts @@ -0,0 +1,6 @@ +export enum LeadSource { + CONTACT_FORM = 'CONTACT_FORM', + REFERRAL = 'REFERRAL', + WALK_IN = 'WALK_IN', + OTHER = 'OTHER', +} diff --git a/backend/src/leads/enums/lead-status.enum.ts b/backend/src/leads/enums/lead-status.enum.ts new file mode 100644 index 00000000..bfebd32f --- /dev/null +++ b/backend/src/leads/enums/lead-status.enum.ts @@ -0,0 +1,7 @@ +export enum LeadStatus { + NEW = 'NEW', + CONTACTED = 'CONTACTED', + QUALIFIED = 'QUALIFIED', + CONVERTED = 'CONVERTED', + LOST = 'LOST', +} diff --git a/backend/src/leads/leads.controller.ts b/backend/src/leads/leads.controller.ts new file mode 100644 index 00000000..fa8798bb --- /dev/null +++ b/backend/src/leads/leads.controller.ts @@ -0,0 +1,69 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + Query, + UseGuards, + ParseUUIDPipe, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { LeadsService } from './leads.service'; +import { CreateLeadDto } from './dto/create-lead.dto'; +import { UpdateLeadDto } from './dto/update-lead.dto'; +import { LeadQueryDto } from './dto/lead-query.dto'; +import { JwtAuthGuard } from '../auth/guard/jwt.auth.guard'; +import { RolesGuard } from '../auth/guard/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorators'; +import { UserRole } from '../users/enums/userRoles.enum'; + +@ApiTags('Leads') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN, UserRole.STAFF) +@Controller('leads') +export class LeadsController { + constructor(private readonly service: LeadsService) {} + + @Post() + async create(@Body() dto: CreateLeadDto) { + const data = await this.service.create(dto); + return { message: 'Lead created', data }; + } + + @Get() + async findAll(@Query() query: LeadQueryDto) { + return this.service.findAll(query); + } + + @Get(':id') + async findOne(@Param('id', ParseUUIDPipe) id: string) { + const data = await this.service.findOne(id); + return { data }; + } + + @Patch(':id') + async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLeadDto) { + const data = await this.service.update(id, dto); + return { message: 'Lead updated', data }; + } + + @Post(':id/convert') + async convert(@Param('id', ParseUUIDPipe) id: string) { + const data = await this.service.convert(id); + return { message: 'Lead converted', data }; + } + + @Delete(':id') + @Roles(UserRole.ADMIN) + @HttpCode(HttpStatus.OK) + async remove(@Param('id', ParseUUIDPipe) id: string) { + await this.service.softDelete(id); + return { message: 'Lead deleted' }; + } +} diff --git a/backend/src/leads/leads.module.ts b/backend/src/leads/leads.module.ts new file mode 100644 index 00000000..f0b3ba7c --- /dev/null +++ b/backend/src/leads/leads.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Lead } from './entities/lead.entity'; +import { LeadsService } from './leads.service'; +import { LeadsController } from './leads.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Lead])], + controllers: [LeadsController], + providers: [LeadsService], + exports: [LeadsService], +}) +export class LeadsModule {} diff --git a/backend/src/leads/leads.service.ts b/backend/src/leads/leads.service.ts new file mode 100644 index 00000000..651458b0 --- /dev/null +++ b/backend/src/leads/leads.service.ts @@ -0,0 +1,71 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Lead } from './entities/lead.entity'; +import { CreateLeadDto } from './dto/create-lead.dto'; +import { UpdateLeadDto } from './dto/update-lead.dto'; +import { LeadQueryDto } from './dto/lead-query.dto'; +import { LeadStatus } from './enums/lead-status.enum'; +import { LeadSource } from './enums/lead-source.enum'; + +@Injectable() +export class LeadsService { + constructor( + @InjectRepository(Lead) + private readonly repo: Repository, + ) {} + + async create(dto: CreateLeadDto): Promise { + return this.repo.save(this.repo.create(dto)); + } + + async createFromContactForm(name: string, email: string, phone?: string): Promise { + return this.repo.save(this.repo.create({ + name, email, phone: phone ?? null, + source: LeadSource.CONTACT_FORM, + status: LeadStatus.NEW, + })); + } + + async findAll(query: LeadQueryDto) { + const { page = 1, limit = 20, status, source, assignedToStaffId } = query; + const where: Record = {}; + if (status) where.status = status; + if (source) where.source = source; + if (assignedToStaffId) where.assignedToStaffId = assignedToStaffId; + + const [items, total] = await this.repo.findAndCount({ + where, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + relations: ['assignedToStaff'], + withDeleted: false, + }); + return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; + } + + async findOne(id: string): Promise { + const item = await this.repo.findOne({ where: { id }, relations: ['assignedToStaff'] }); + if (!item) throw new NotFoundException(`Lead ${id} not found`); + return item; + } + + async update(id: string, dto: UpdateLeadDto): Promise { + const item = await this.findOne(id); + Object.assign(item, dto); + return this.repo.save(item); + } + + async convert(id: string): Promise { + const item = await this.findOne(id); + item.status = LeadStatus.CONVERTED; + item.convertedAt = new Date(); + return this.repo.save(item); + } + + async softDelete(id: string): Promise { + const item = await this.findOne(id); + await this.repo.softDelete(item.id); + } +} diff --git a/backend/src/maintenance/dto/create-maintenance-request.dto.ts b/backend/src/maintenance/dto/create-maintenance-request.dto.ts new file mode 100644 index 00000000..c3ef9e3d --- /dev/null +++ b/backend/src/maintenance/dto/create-maintenance-request.dto.ts @@ -0,0 +1,19 @@ +import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { MaintenanceCategory } from '../enums/maintenance-category.enum'; + +export class CreateMaintenanceRequestDto { + @IsOptional() + @IsUUID() + workspaceId?: string; + + @IsEnum(MaintenanceCategory) + category: MaintenanceCategory; + + @IsString() + @IsNotEmpty() + description: string; + + @IsOptional() + @IsString() + imageUrl?: string; +} diff --git a/backend/src/maintenance/dto/maintenance-query.dto.ts b/backend/src/maintenance/dto/maintenance-query.dto.ts new file mode 100644 index 00000000..165b613e --- /dev/null +++ b/backend/src/maintenance/dto/maintenance-query.dto.ts @@ -0,0 +1,30 @@ +import { IsEnum, IsInt, IsOptional, IsUUID, Min } from 'class-validator'; +import { Type } from 'class-transformer'; +import { MaintenanceCategory } from '../enums/maintenance-category.enum'; +import { MaintenanceStatus } from '../enums/maintenance-status.enum'; + +export class MaintenanceQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number = 20; + + @IsOptional() + @IsEnum(MaintenanceStatus) + status?: MaintenanceStatus; + + @IsOptional() + @IsEnum(MaintenanceCategory) + category?: MaintenanceCategory; + + @IsOptional() + @IsUUID() + workspaceId?: string; +} diff --git a/backend/src/maintenance/dto/update-maintenance-status.dto.ts b/backend/src/maintenance/dto/update-maintenance-status.dto.ts new file mode 100644 index 00000000..1dae2f4a --- /dev/null +++ b/backend/src/maintenance/dto/update-maintenance-status.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { MaintenanceStatus } from '../enums/maintenance-status.enum'; + +export class UpdateMaintenanceStatusDto { + @IsEnum(MaintenanceStatus) + status: MaintenanceStatus; +} diff --git a/backend/src/maintenance/entities/maintenance-request.entity.ts b/backend/src/maintenance/entities/maintenance-request.entity.ts new file mode 100644 index 00000000..0e75e5f5 --- /dev/null +++ b/backend/src/maintenance/entities/maintenance-request.entity.ts @@ -0,0 +1,61 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Workspace } from '../../workspaces/entities/workspace.entity'; +import { MaintenanceCategory } from '../enums/maintenance-category.enum'; +import { MaintenanceStatus } from '../enums/maintenance-status.enum'; + +@Entity('maintenance_requests') +export class MaintenanceRequest { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + reportedByUserId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'reportedByUserId' }) + reportedBy: User; + + @Column('uuid', { nullable: true }) + workspaceId: string | null; + + @ManyToOne(() => Workspace, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'workspaceId' }) + workspace: Workspace; + + @Column({ type: 'enum', enum: MaintenanceCategory }) + category: MaintenanceCategory; + + @Column({ type: 'text' }) + description: string; + + @Column({ nullable: true }) + imageUrl: string | null; + + @Column({ type: 'enum', enum: MaintenanceStatus, default: MaintenanceStatus.OPEN }) + status: MaintenanceStatus; + + @Column({ type: 'timestamptz', nullable: true }) + resolvedAt: Date | null; + + @Column('uuid', { nullable: true }) + resolvedByStaffId: string | null; + + @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'resolvedByStaffId' }) + resolvedByStaff: User; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/maintenance/enums/maintenance-category.enum.ts b/backend/src/maintenance/enums/maintenance-category.enum.ts new file mode 100644 index 00000000..fa8bae63 --- /dev/null +++ b/backend/src/maintenance/enums/maintenance-category.enum.ts @@ -0,0 +1,6 @@ +export enum MaintenanceCategory { + EQUIPMENT = 'EQUIPMENT', + FACILITY = 'FACILITY', + SAFETY = 'SAFETY', + OTHER = 'OTHER', +} diff --git a/backend/src/maintenance/enums/maintenance-status.enum.ts b/backend/src/maintenance/enums/maintenance-status.enum.ts new file mode 100644 index 00000000..91536362 --- /dev/null +++ b/backend/src/maintenance/enums/maintenance-status.enum.ts @@ -0,0 +1,5 @@ +export enum MaintenanceStatus { + OPEN = 'OPEN', + IN_PROGRESS = 'IN_PROGRESS', + RESOLVED = 'RESOLVED', +} diff --git a/backend/src/maintenance/maintenance.controller.ts b/backend/src/maintenance/maintenance.controller.ts new file mode 100644 index 00000000..0e846efc --- /dev/null +++ b/backend/src/maintenance/maintenance.controller.ts @@ -0,0 +1,64 @@ +import { + Controller, + Get, + Post, + Patch, + Param, + Body, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { MaintenanceService } from './maintenance.service'; +import { CreateMaintenanceRequestDto } from './dto/create-maintenance-request.dto'; +import { UpdateMaintenanceStatusDto } from './dto/update-maintenance-status.dto'; +import { MaintenanceQueryDto } from './dto/maintenance-query.dto'; +import { JwtAuthGuard } from '../auth/guard/jwt.auth.guard'; +import { RolesGuard } from '../auth/guard/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorators'; +import { CurrentUser } from '../auth/decorators/current.user.decorators'; +import { UserRole } from '../users/enums/userRoles.enum'; +import { User } from '../users/entities/user.entity'; + +@ApiTags('Maintenance') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('maintenance') +export class MaintenanceController { + constructor(private readonly service: MaintenanceService) {} + + @Post() + async create(@Body() dto: CreateMaintenanceRequestDto, @CurrentUser() user: User) { + const data = await this.service.create(dto, user.id); + return { message: 'Maintenance request submitted', data }; + } + + @Get('mine') + async findMine(@CurrentUser() user: User, @Query() query: MaintenanceQueryDto) { + return this.service.findMine(user.id, query); + } + + @Get() + @Roles(UserRole.ADMIN, UserRole.STAFF) + async findAll(@Query() query: MaintenanceQueryDto) { + return this.service.findAll(query); + } + + @Get(':id') + async findOne(@Param('id', ParseUUIDPipe) id: string) { + const data = await this.service.findOne(id); + return { data }; + } + + @Patch(':id/status') + @Roles(UserRole.ADMIN, UserRole.STAFF) + async updateStatus( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateMaintenanceStatusDto, + @CurrentUser() user: User, + ) { + const data = await this.service.updateStatus(id, dto, user.id); + return { message: 'Status updated', data }; + } +} diff --git a/backend/src/maintenance/maintenance.module.ts b/backend/src/maintenance/maintenance.module.ts new file mode 100644 index 00000000..f7a3cd21 --- /dev/null +++ b/backend/src/maintenance/maintenance.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MaintenanceRequest } from './entities/maintenance-request.entity'; +import { MaintenanceService } from './maintenance.service'; +import { MaintenanceController } from './maintenance.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([MaintenanceRequest])], + controllers: [MaintenanceController], + providers: [MaintenanceService], + exports: [MaintenanceService], +}) +export class MaintenanceModule {} diff --git a/backend/src/maintenance/maintenance.service.ts b/backend/src/maintenance/maintenance.service.ts new file mode 100644 index 00000000..c6400343 --- /dev/null +++ b/backend/src/maintenance/maintenance.service.ts @@ -0,0 +1,65 @@ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { MaintenanceRequest } from './entities/maintenance-request.entity'; +import { CreateMaintenanceRequestDto } from './dto/create-maintenance-request.dto'; +import { UpdateMaintenanceStatusDto } from './dto/update-maintenance-status.dto'; +import { MaintenanceQueryDto } from './dto/maintenance-query.dto'; +import { MaintenanceStatus } from './enums/maintenance-status.enum'; + +@Injectable() +export class MaintenanceService { + constructor( + @InjectRepository(MaintenanceRequest) + private readonly repo: Repository, + ) {} + + async create(dto: CreateMaintenanceRequestDto, userId: string): Promise { + const entity = this.repo.create({ ...dto, reportedByUserId: userId }); + return this.repo.save(entity); + } + + async findMine(userId: string, query: MaintenanceQueryDto) { + const { page = 1, limit = 20 } = query; + const [items, total] = await this.repo.findAndCount({ + where: { reportedByUserId: userId }, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; + } + + async findAll(query: MaintenanceQueryDto) { + const { page = 1, limit = 20, status, category, workspaceId } = query; + const where: Record = {}; + if (status) where.status = status; + if (category) where.category = category; + if (workspaceId) where.workspaceId = workspaceId; + + const [items, total] = await this.repo.findAndCount({ + where, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + relations: ['reportedBy'], + }); + return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; + } + + async findOne(id: string): Promise { + const item = await this.repo.findOne({ where: { id }, relations: ['reportedBy', 'workspace'] }); + if (!item) throw new NotFoundException(`Maintenance request ${id} not found`); + return item; + } + + async updateStatus(id: string, dto: UpdateMaintenanceStatusDto, staffId: string): Promise { + const item = await this.findOne(id); + item.status = dto.status; + if (dto.status === MaintenanceStatus.RESOLVED) { + item.resolvedAt = new Date(); + item.resolvedByStaffId = staffId; + } + return this.repo.save(item); + } +} diff --git a/frontend/app/maintenance/page.tsx b/frontend/app/maintenance/page.tsx new file mode 100644 index 00000000..e96c693b --- /dev/null +++ b/frontend/app/maintenance/page.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useState } from "react"; +import DashboardLayout from "@/components/dashboard/DashboardLayout"; +import { useGetMyMaintenanceRequests } from "@/lib/react-query/hooks/maintenance/useGetMyMaintenanceRequests"; +import { useSubmitMaintenanceRequest } from "@/lib/react-query/hooks/maintenance/useSubmitMaintenanceRequest"; +import { Wrench, Plus, X } from "lucide-react"; + +const CATEGORIES = ["EQUIPMENT", "FACILITY", "SAFETY", "OTHER"]; + +const statusColor: Record = { + OPEN: "bg-red-100 text-red-700", + IN_PROGRESS: "bg-yellow-100 text-yellow-700", + RESOLVED: "bg-green-100 text-green-700", +}; + +export default function MaintenancePage() { + const [showModal, setShowModal] = useState(false); + const [form, setForm] = useState({ category: "EQUIPMENT", description: "" }); + const { data, isLoading } = useGetMyMaintenanceRequests(); + const submit = useSubmitMaintenanceRequest(); + + const requests = (data as any)?.items ?? []; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await submit.mutateAsync(form); + setShowModal(false); + setForm({ category: "EQUIPMENT", description: "" }); + }; + + return ( + +
+
+

Maintenance Requests

+

Report and track workspace issues.

+
+ +
+ + {isLoading ? ( +
Loading...
+ ) : requests.length === 0 ? ( +
+ +

No maintenance requests yet.

+
+ ) : ( +
+ {requests.map((req: any) => ( +
+
+
+ + {req.category} + + + {req.status.replace("_", " ")} + +
+

{req.description}

+

+ {new Date(req.createdAt).toLocaleDateString()} +

+
+
+ ))} +
+ )} + + {showModal && ( +
+
+
+

Report an Issue

+ +
+
+
+ + +
+
+ +