diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index f38c77d..48ef150 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -29,6 +29,9 @@ 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 { ShiftsModule } from './shifts/shifts.module'; +import { FloorPlanModule } from './floor-plan/floor-plan.module'; +import { ReportsModule } from './reports/reports.module'; import { EmailCampaignsModule } from './email-campaigns/email-campaigns.module'; import { InventoryModule } from './inventory/inventory.module'; import { AuditLogModule } from './audit-log/audit-log.module'; @@ -124,6 +127,9 @@ import { TeamsModule } from './teams/teams.module'; WaitlistModule, EventsModule, MembershipPlansModule, + ShiftsModule, + FloorPlanModule, + ReportsModule, EmailCampaignsModule, InventoryModule, AuditLogModule, diff --git a/backend/src/floor-plan/dto/create-floor-plan.dto.ts b/backend/src/floor-plan/dto/create-floor-plan.dto.ts new file mode 100644 index 0000000..bca6aba --- /dev/null +++ b/backend/src/floor-plan/dto/create-floor-plan.dto.ts @@ -0,0 +1,19 @@ +import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; + +export class CreateFloorPlanDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsOptional() + @IsNumber() + canvasWidth?: number; + + @IsOptional() + @IsNumber() + canvasHeight?: number; + + @IsOptional() + @IsString() + backgroundImageUrl?: string; +} diff --git a/backend/src/floor-plan/dto/save-zones.dto.ts b/backend/src/floor-plan/dto/save-zones.dto.ts new file mode 100644 index 0000000..4485495 --- /dev/null +++ b/backend/src/floor-plan/dto/save-zones.dto.ts @@ -0,0 +1,35 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsNumber, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator'; + +export class ZoneDto { + @IsOptional() + @IsUUID() + workspaceId?: string; + + @IsNumber() + x: number; + + @IsNumber() + y: number; + + @IsNumber() + width: number; + + @IsNumber() + height: number; + + @IsOptional() + @IsString() + label?: string; + + @IsOptional() + @IsString() + color?: string; +} + +export class SaveZonesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ZoneDto) + zones: ZoneDto[]; +} diff --git a/backend/src/floor-plan/entities/floor-plan-zone.entity.ts b/backend/src/floor-plan/entities/floor-plan-zone.entity.ts new file mode 100644 index 0000000..e10431a --- /dev/null +++ b/backend/src/floor-plan/entities/floor-plan-zone.entity.ts @@ -0,0 +1,42 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { FloorPlan } from './floor-plan.entity'; + +@Entity('floor_plan_zones') +export class FloorPlanZone { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + floorPlanId: string; + + @ManyToOne(() => FloorPlan, (fp) => fp.zones, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'floorPlanId' }) + floorPlan: FloorPlan; + + @Column('uuid', { nullable: true }) + workspaceId: string | null; + + @Column({ type: 'float' }) + x: number; + + @Column({ type: 'float' }) + y: number; + + @Column({ type: 'float' }) + width: number; + + @Column({ type: 'float' }) + height: number; + + @Column({ nullable: true }) + label: string | null; + + @Column({ nullable: true, default: '#6366f1' }) + color: string | null; +} diff --git a/backend/src/floor-plan/entities/floor-plan.entity.ts b/backend/src/floor-plan/entities/floor-plan.entity.ts new file mode 100644 index 0000000..46adf20 --- /dev/null +++ b/backend/src/floor-plan/entities/floor-plan.entity.ts @@ -0,0 +1,39 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToMany, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { FloorPlanZone } from './floor-plan-zone.entity'; + +@Entity('floor_plans') +export class FloorPlan { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column({ type: 'int', default: 1200 }) + canvasWidth: number; + + @Column({ type: 'int', default: 800 }) + canvasHeight: number; + + @Column({ type: 'text', nullable: true }) + backgroundImageUrl: string | null; + + @Column({ default: false }) + isActive: boolean; + + @OneToMany(() => FloorPlanZone, (zone) => zone.floorPlan, { cascade: true }) + zones: FloorPlanZone[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/floor-plan/floor-plan.controller.ts b/backend/src/floor-plan/floor-plan.controller.ts new file mode 100644 index 0000000..725bbcf --- /dev/null +++ b/backend/src/floor-plan/floor-plan.controller.ts @@ -0,0 +1,71 @@ +import { + Controller, + Get, + Post, + Patch, + Put, + Param, + Body, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { FloorPlanService } from './floor-plan.service'; +import { CreateFloorPlanDto } from './dto/create-floor-plan.dto'; +import { SaveZonesDto } from './dto/save-zones.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('Floor Plan') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('floor-plan') +export class FloorPlanController { + constructor(private readonly service: FloorPlanService) {} + + @Post() + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + async create(@Body() dto: CreateFloorPlanDto) { + const data = await this.service.create(dto); + return { message: 'Floor plan created', data }; + } + + @Get() + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + async findAll() { + const data = await this.service.findAll(); + return { data }; + } + + @Get('active') + async getActive() { + const data = await this.service.getActive(); + return { data }; + } + + @Patch(':id') + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: Partial, + ) { + const data = await this.service.update(id, dto); + return { message: 'Floor plan updated', data }; + } + + @Put(':id/zones') + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + async saveZones(@Param('id', ParseUUIDPipe) id: string, @Body() dto: SaveZonesDto) { + const data = await this.service.saveZones(id, dto); + return { message: 'Zones saved', data }; + } + + @Patch(':id/activate') + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + async activate(@Param('id', ParseUUIDPipe) id: string) { + const data = await this.service.activate(id); + return { message: 'Floor plan activated', data }; + } +} diff --git a/backend/src/floor-plan/floor-plan.module.ts b/backend/src/floor-plan/floor-plan.module.ts new file mode 100644 index 0000000..d87c07f --- /dev/null +++ b/backend/src/floor-plan/floor-plan.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FloorPlan } from './entities/floor-plan.entity'; +import { FloorPlanZone } from './entities/floor-plan-zone.entity'; +import { FloorPlanService } from './floor-plan.service'; +import { FloorPlanController } from './floor-plan.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([FloorPlan, FloorPlanZone])], + controllers: [FloorPlanController], + providers: [FloorPlanService], + exports: [FloorPlanService], +}) +export class FloorPlanModule {} diff --git a/backend/src/floor-plan/floor-plan.service.ts b/backend/src/floor-plan/floor-plan.service.ts new file mode 100644 index 0000000..49d6e1c --- /dev/null +++ b/backend/src/floor-plan/floor-plan.service.ts @@ -0,0 +1,55 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { FloorPlan } from './entities/floor-plan.entity'; +import { FloorPlanZone } from './entities/floor-plan-zone.entity'; +import { CreateFloorPlanDto } from './dto/create-floor-plan.dto'; +import { SaveZonesDto } from './dto/save-zones.dto'; + +@Injectable() +export class FloorPlanService { + constructor( + @InjectRepository(FloorPlan) + private readonly planRepo: Repository, + @InjectRepository(FloorPlanZone) + private readonly zoneRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + async create(dto: CreateFloorPlanDto): Promise { + const plan = this.planRepo.create(dto); + return this.planRepo.save(plan); + } + + async update(id: string, dto: Partial): Promise { + const plan = await this.planRepo.findOne({ where: { id } }); + if (!plan) throw new NotFoundException(`Floor plan ${id} not found`); + Object.assign(plan, dto); + return this.planRepo.save(plan); + } + + async saveZones(id: string, dto: SaveZonesDto): Promise { + const plan = await this.planRepo.findOne({ where: { id }, relations: ['zones'] }); + if (!plan) throw new NotFoundException(`Floor plan ${id} not found`); + await this.zoneRepo.delete({ floorPlanId: id }); + const zones = dto.zones.map((z) => this.zoneRepo.create({ ...z, floorPlanId: id })); + await this.zoneRepo.save(zones); + return this.planRepo.findOne({ where: { id }, relations: ['zones'] }) as Promise; + } + + async getActive(): Promise { + return this.planRepo.findOne({ where: { isActive: true }, relations: ['zones'] }); + } + + async findAll(): Promise { + return this.planRepo.find({ order: { createdAt: 'DESC' } }); + } + + async activate(id: string): Promise { + await this.planRepo.update({}, { isActive: false }); + const plan = await this.planRepo.findOne({ where: { id } }); + if (!plan) throw new NotFoundException(`Floor plan ${id} not found`); + plan.isActive = true; + return this.planRepo.save(plan); + } +} diff --git a/backend/src/reports/reports.controller.ts b/backend/src/reports/reports.controller.ts new file mode 100644 index 0000000..6099182 --- /dev/null +++ b/backend/src/reports/reports.controller.ts @@ -0,0 +1,81 @@ +import { Controller, Get, Query, Res, UseGuards } from '@nestjs/common'; +import { Response } from 'express'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { ReportsService } from './reports.service'; +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('Reports') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) +@Controller('reports') +export class ReportsController { + constructor(private readonly service: ReportsService) {} + + @Get('bookings') + async bookings( + @Query('from') from: string, + @Query('to') to: string, + @Query('format') format: string, + @Res({ passthrough: true }) res: Response, + ) { + const data = await this.service.bookingsReport(from, to); + if (format === 'csv') { + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', 'attachment; filename=bookings.csv'); + return this.service.toCsv(data); + } + return { data }; + } + + @Get('revenue') + async revenue( + @Query('from') from: string, + @Query('to') to: string, + @Query('format') format: string, + @Res({ passthrough: true }) res: Response, + ) { + const result = await this.service.revenueReport(from, to); + if (format === 'csv') { + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', 'attachment; filename=revenue.csv'); + return this.service.toCsv(result.invoices); + } + return { data: result }; + } + + @Get('members') + async members( + @Query('from') from: string, + @Query('to') to: string, + @Query('format') format: string, + @Res({ passthrough: true }) res: Response, + ) { + const result = await this.service.membersReport(from, to); + if (format === 'csv') { + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', 'attachment; filename=members.csv'); + return this.service.toCsv(result.members); + } + return { data: result }; + } + + @Get('occupancy') + async occupancy( + @Query('from') from: string, + @Query('to') to: string, + @Query('format') format: string, + @Res({ passthrough: true }) res: Response, + ) { + const data = await this.service.occupancyReport(from, to); + if (format === 'csv') { + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', 'attachment; filename=occupancy.csv'); + return this.service.toCsv(data); + } + return { data }; + } +} diff --git a/backend/src/reports/reports.module.ts b/backend/src/reports/reports.module.ts new file mode 100644 index 0000000..1c80694 --- /dev/null +++ b/backend/src/reports/reports.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Booking } from '../bookings/entities/booking.entity'; +import { Invoice } from '../invoices/entities/invoice.entity'; +import { User } from '../users/entities/user.entity'; +import { Workspace } from '../workspaces/entities/workspace.entity'; +import { ReportsService } from './reports.service'; +import { ReportsController } from './reports.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Booking, Invoice, User, Workspace])], + controllers: [ReportsController], + providers: [ReportsService], +}) +export class ReportsModule {} diff --git a/backend/src/reports/reports.service.ts b/backend/src/reports/reports.service.ts new file mode 100644 index 0000000..f8955ff --- /dev/null +++ b/backend/src/reports/reports.service.ts @@ -0,0 +1,115 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Between, Repository } from 'typeorm'; +import { Booking } from '../bookings/entities/booking.entity'; +import { Invoice } from '../invoices/entities/invoice.entity'; +import { User } from '../users/entities/user.entity'; +import { Workspace } from '../workspaces/entities/workspace.entity'; + +@Injectable() +export class ReportsService { + constructor( + @InjectRepository(Booking) + private readonly bookingRepo: Repository, + @InjectRepository(Invoice) + private readonly invoiceRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + @InjectRepository(Workspace) + private readonly workspaceRepo: Repository, + ) {} + + private dateRange(from?: string, to?: string) { + const start = from ? new Date(from) : new Date(Date.now() - 30 * 86400000); + const end = to ? new Date(to) : new Date(); + return Between(start, end); + } + + async bookingsReport(from?: string, to?: string) { + const bookings = await this.bookingRepo.find({ + where: { createdAt: this.dateRange(from, to) as any }, + relations: ['user', 'workspace'], + order: { createdAt: 'DESC' }, + }); + return bookings.map((b) => ({ + id: b.id, + member: (b as any).user?.fullName ?? b.userId, + workspace: (b as any).workspace?.name ?? b.workspaceId, + startDate: b.startDate, + endDate: b.endDate, + status: b.status, + totalKobo: b.totalAmount, + })); + } + + async revenueReport(from?: string, to?: string) { + const invoices = await this.invoiceRepo.find({ + where: { createdAt: this.dateRange(from, to) as any }, + order: { createdAt: 'DESC' }, + }); + const total = invoices.reduce((s, i) => s + Number(i.amountKobo), 0); + const paid = invoices.filter((i) => i.status === 'paid').reduce((s, i) => s + Number(i.amountKobo), 0); + return { + totalKobo: total, + paidKobo: paid, + outstandingKobo: total - paid, + count: invoices.length, + invoices: invoices.map((i) => ({ + id: i.id, + userId: i.userId, + amountKobo: i.amountKobo, + status: i.status, + createdAt: i.createdAt, + })), + }; + } + + async membersReport(from?: string, to?: string) { + const users = await this.userRepo.find({ order: { createdAt: 'DESC' } }); + const inRange = users.filter((u) => { + const t = new Date(u.createdAt).getTime(); + const s = from ? new Date(from).getTime() : Date.now() - 30 * 86400000; + const e = to ? new Date(to).getTime() : Date.now(); + return t >= s && t <= e; + }); + return { + total: users.length, + newInPeriod: inRange.length, + members: inRange.map((u) => ({ + id: u.id, + fullName: u.fullName, + email: u.email, + role: u.role, + membershipStatus: u.membershipStatus, + createdAt: u.createdAt, + })), + }; + } + + async occupancyReport(from?: string, to?: string) { + const workspaces = await this.workspaceRepo.find(); + const bookings = await this.bookingRepo.find({ + where: { createdAt: this.dateRange(from, to) as any }, + }); + return workspaces.map((ws) => { + const wsBookings = bookings.filter((b) => b.workspaceId === ws.id); + return { + workspaceId: ws.id, + name: ws.name, + type: ws.type, + capacity: ws.capacity, + bookingCount: wsBookings.length, + }; + }); + } + + toCsv(rows: Record[]): string { + if (!rows.length) return ''; + const headers = Object.keys(rows[0]); + const lines = [ + headers.join(','), + ...rows.map((r) => headers.map((h) => JSON.stringify(r[h] ?? '')).join(',')), + ]; + return lines.join('\n'); + } +} diff --git a/backend/src/shifts/dto/create-shift.dto.ts b/backend/src/shifts/dto/create-shift.dto.ts new file mode 100644 index 0000000..93c4472 --- /dev/null +++ b/backend/src/shifts/dto/create-shift.dto.ts @@ -0,0 +1,20 @@ +import { IsDateString, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class CreateShiftDto { + @IsUUID() + staffUserId: string; + + @IsDateString() + startTime: string; + + @IsDateString() + endTime: string; + + @IsString() + @IsNotEmpty() + roleName: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/backend/src/shifts/entities/shift.entity.ts b/backend/src/shifts/entities/shift.entity.ts new file mode 100644 index 0000000..4bd2d7c --- /dev/null +++ b/backend/src/shifts/entities/shift.entity.ts @@ -0,0 +1,48 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('shifts') +export class Shift { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + staffUserId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'staffUserId' }) + staff: User; + + @Column({ type: 'timestamptz' }) + startTime: Date; + + @Column({ type: 'timestamptz' }) + endTime: Date; + + @Column() + roleName: string; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @Column('uuid') + createdByAdminId: string; + + @ManyToOne(() => User, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'createdByAdminId' }) + createdByAdmin: User; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/shifts/shifts.controller.ts b/backend/src/shifts/shifts.controller.ts new file mode 100644 index 0000000..59d7423 --- /dev/null +++ b/backend/src/shifts/shifts.controller.ts @@ -0,0 +1,66 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { ShiftsService } from './shifts.service'; +import { CreateShiftDto } from './dto/create-shift.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('Shifts') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('shifts') +export class ShiftsController { + constructor(private readonly service: ShiftsService) {} + + @Post() + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + async create(@Body() dto: CreateShiftDto, @CurrentUser() user: User) { + const data = await this.service.create(dto, user.id); + return { message: 'Shift created', data }; + } + + @Get() + async findAll( + @CurrentUser() user: User, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ) { + const data = await this.service.findAll(user.id, user.role, startDate, endDate); + return { data }; + } + + @Get('this-week') + async thisWeek(@CurrentUser() user: User) { + const data = await this.service.thisWeek(user.id, user.role); + return { data }; + } + + @Patch(':id') + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: Partial) { + const data = await this.service.update(id, dto); + return { message: 'Shift updated', data }; + } + + @Delete(':id') + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + async remove(@Param('id', ParseUUIDPipe) id: string) { + await this.service.remove(id); + return { message: 'Shift deleted' }; + } +} diff --git a/backend/src/shifts/shifts.module.ts b/backend/src/shifts/shifts.module.ts new file mode 100644 index 0000000..3fd653e --- /dev/null +++ b/backend/src/shifts/shifts.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Shift } from './entities/shift.entity'; +import { ShiftsService } from './shifts.service'; +import { ShiftsController } from './shifts.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Shift])], + controllers: [ShiftsController], + providers: [ShiftsService], + exports: [ShiftsService], +}) +export class ShiftsModule {} diff --git a/backend/src/shifts/shifts.service.ts b/backend/src/shifts/shifts.service.ts new file mode 100644 index 0000000..e59de1d --- /dev/null +++ b/backend/src/shifts/shifts.service.ts @@ -0,0 +1,61 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Between, Repository } from 'typeorm'; +import { Shift } from './entities/shift.entity'; +import { CreateShiftDto } from './dto/create-shift.dto'; +import { UserRole } from '../users/enums/userRoles.enum'; + +@Injectable() +export class ShiftsService { + constructor( + @InjectRepository(Shift) + private readonly repo: Repository, + ) {} + + async create(dto: CreateShiftDto, adminId: string): Promise { + const shift = this.repo.create({ + ...dto, + startTime: new Date(dto.startTime), + endTime: new Date(dto.endTime), + createdByAdminId: adminId, + }); + return this.repo.save(shift); + } + + async findAll(userId: string, role: UserRole, startDate?: string, endDate?: string): Promise { + const where: any = {}; + if (role !== UserRole.ADMIN && role !== UserRole.SUPER_ADMIN) { + where.staffUserId = userId; + } + if (startDate && endDate) { + where.startTime = Between(new Date(startDate), new Date(endDate)); + } + return this.repo.find({ where, order: { startTime: 'ASC' }, relations: ['staff'] }); + } + + async thisWeek(userId: string, role: UserRole): Promise { + const now = new Date(); + const day = now.getDay(); + const mon = new Date(now); + mon.setDate(now.getDate() - (day === 0 ? 6 : day - 1)); + mon.setHours(0, 0, 0, 0); + const sun = new Date(mon); + sun.setDate(mon.getDate() + 6); + sun.setHours(23, 59, 59, 999); + return this.findAll(userId, role, mon.toISOString(), sun.toISOString()); + } + + async update(id: string, dto: Partial): Promise { + const shift = await this.repo.findOne({ where: { id } }); + if (!shift) throw new NotFoundException(`Shift ${id} not found`); + if (dto.startTime) shift.startTime = new Date(dto.startTime); + if (dto.endTime) shift.endTime = new Date(dto.endTime); + if (dto.roleName) shift.roleName = dto.roleName; + if (dto.notes !== undefined) shift.notes = dto.notes ?? null; + return this.repo.save(shift); + } + + async remove(id: string): Promise { + await this.repo.delete(id); + } +} diff --git a/frontend/app/admin/facilities/page.tsx b/frontend/app/admin/facilities/page.tsx new file mode 100644 index 0000000..57743d5 --- /dev/null +++ b/frontend/app/admin/facilities/page.tsx @@ -0,0 +1,291 @@ +"use client"; + +import { useState, useRef } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Plus, Save, Zap, Trash2 } from "lucide-react"; +import api from "@/lib/axios"; + +type Zone = { + id?: string; + workspaceId?: string; + x: number; + y: number; + width: number; + height: number; + label?: string; + color?: string; +}; + +type FloorPlan = { + id: string; + name: string; + canvasWidth: number; + canvasHeight: number; + isActive: boolean; + zones: Zone[]; +}; + +const CANVAS_W = 900; +const CANVAS_H = 600; + +export default function FacilitiesEditorPage() { + const qc = useQueryClient(); + const svgRef = useRef(null); + + const [selectedPlanId, setSelectedPlanId] = useState(null); + const [zones, setZones] = useState([]); + const [drawing, setDrawing] = useState<{ x: number; y: number } | null>(null); + const [current, setCurrent] = useState(null); + const [showNewPlan, setShowNewPlan] = useState(false); + const [newPlanName, setNewPlanName] = useState(""); + + const { data: plans = [] } = useQuery({ + queryKey: ["floor-plans"], + queryFn: async () => { + const r = await api.get("/floor-plan"); + return r.data.data as FloorPlan[]; + }, + }); + + const createPlanMutation = useMutation({ + mutationFn: (name: string) => + api.post("/floor-plan", { name, canvasWidth: CANVAS_W, canvasHeight: CANVAS_H }), + onSuccess: (r) => { + qc.invalidateQueries({ queryKey: ["floor-plans"] }); + setSelectedPlanId(r.data.data.id); + setZones([]); + setShowNewPlan(false); + setNewPlanName(""); + }, + }); + + const saveZonesMutation = useMutation({ + mutationFn: (id: string) => + api.put(`/floor-plan/${id}/zones`, { zones }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["floor-plans"] }), + }); + + const activateMutation = useMutation({ + mutationFn: (id: string) => api.patch(`/floor-plan/${id}/activate`), + onSuccess: () => qc.invalidateQueries({ queryKey: ["floor-plans"] }), + }); + + const loadPlan = (plan: FloorPlan) => { + setSelectedPlanId(plan.id); + setZones(plan.zones ?? []); + }; + + const getSvgCoords = (e: React.MouseEvent) => { + const rect = svgRef.current!.getBoundingClientRect(); + return { + x: Math.round(((e.clientX - rect.left) / rect.width) * CANVAS_W), + y: Math.round(((e.clientY - rect.top) / rect.height) * CANVAS_H), + }; + }; + + const handleMouseDown = (e: React.MouseEvent) => { + if (e.target !== svgRef.current) return; + const pt = getSvgCoords(e); + setDrawing(pt); + setCurrent({ ...pt, width: 0, height: 0, color: "#6366f1", label: "Zone" }); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!drawing || !current) return; + const pt = getSvgCoords(e); + setCurrent({ + ...current, + width: Math.max(0, pt.x - drawing.x), + height: Math.max(0, pt.y - drawing.y), + }); + }; + + const handleMouseUp = () => { + if (current && current.width > 10 && current.height > 10) { + setZones((z) => [...z, current]); + } + setDrawing(null); + setCurrent(null); + }; + + const updateZoneLabel = (idx: number, label: string) => { + setZones((z) => z.map((zone, i) => (i === idx ? { ...zone, label } : zone))); + }; + + const removeZone = (idx: number) => { + setZones((z) => z.filter((_, i) => i !== idx)); + }; + + const selectedPlan = plans.find((p) => p.id === selectedPlanId); + + return ( +
+
+

Floor Plan Editor

+ +
+ +
+ {plans.map((p) => ( + + ))} +
+ + {selectedPlanId ? ( +
+

+ Draw zones by clicking and dragging on the canvas below. +

+ +
+ + {zones.map((z, i) => ( + + + + {z.label ?? "Zone"} + + + ))} + {current && ( + + )} + +
+ + {zones.length > 0 && ( +
+

Zones

+ {zones.map((z, i) => ( +
+ updateZoneLabel(i, e.target.value)} + placeholder="Zone label" + /> + + {z.width}×{z.height} @ ({z.x},{z.y}) + + +
+ ))} +
+ )} + +
+ + {selectedPlan && !selectedPlan.isActive && ( + + )} +
+
+ ) : ( +
+ Select a floor plan or create a new one to start editing. +
+ )} + + {showNewPlan && ( +
+
+

New Floor Plan

+ setNewPlanName(e.target.value)} + /> +
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/app/admin/staff/page.tsx b/frontend/app/admin/staff/page.tsx new file mode 100644 index 0000000..042041a --- /dev/null +++ b/frontend/app/admin/staff/page.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react"; +import api from "@/lib/axios"; + +type Shift = { + id: string; + staffUserId: string; + staff?: { fullName: string; email: string }; + startTime: string; + endTime: string; + roleName: string; + notes: string | null; +}; + +const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +function getWeekStart(offset = 0) { + const now = new Date(); + const day = now.getDay(); + const mon = new Date(now); + mon.setDate(now.getDate() - (day === 0 ? 6 : day - 1) + offset * 7); + mon.setHours(0, 0, 0, 0); + return mon; +} + +function isSameDay(a: Date, b: Date) { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +export default function AdminStaffPage() { + const qc = useQueryClient(); + const [weekOffset, setWeekOffset] = useState(0); + const [showModal, setShowModal] = useState(false); + const [form, setForm] = useState({ + staffUserId: "", + startTime: "", + endTime: "", + roleName: "", + notes: "", + }); + + const weekStart = getWeekStart(weekOffset); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekStart.getDate() + 6); + weekEnd.setHours(23, 59, 59); + + const { data } = useQuery({ + queryKey: ["shifts", weekOffset], + queryFn: async () => { + const r = await api.get("/shifts", { + params: { + startDate: weekStart.toISOString(), + endDate: weekEnd.toISOString(), + }, + }); + return r.data.data as Shift[]; + }, + }); + + const createMutation = useMutation({ + mutationFn: (body: typeof form) => api.post("/shifts", body), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["shifts"] }); + setShowModal(false); + setForm({ staffUserId: "", startTime: "", endTime: "", roleName: "", notes: "" }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.delete(`/shifts/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ["shifts"] }), + }); + + const shifts = data ?? []; + const weekDays = Array.from({ length: 7 }, (_, i) => { + const d = new Date(weekStart); + d.setDate(weekStart.getDate() + i); + return d; + }); + + const fmt = (d: Date) => + d.toLocaleDateString("en-GB", { day: "2-digit", month: "short" }); + + return ( +
+
+

Staff Schedule

+ +
+ +
+ + + {fmt(weekStart)} – {fmt(weekEnd)} + + +
+ +
+ + + + {weekDays.map((d, i) => ( + + ))} + + + + + {weekDays.map((d, i) => { + const dayShifts = shifts.filter((s) => + isSameDay(new Date(s.startTime), d) + ); + return ( + + ); + })} + + +
+
{DAYS[i]}
+
{fmt(d)}
+
+ {dayShifts.map((s) => ( +
+
+ {s.staff?.fullName ?? s.staffUserId.slice(0, 8)} +
+
{s.roleName}
+
+ {new Date(s.startTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} + – + {new Date(s.endTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} +
+ +
+ ))} +
+
+ + {showModal && ( +
+
+

Add Shift

+ {[ + { label: "Staff User ID", key: "staffUserId" }, + { label: "Role", key: "roleName" }, + { label: "Notes", key: "notes" }, + ].map(({ label, key }) => ( +
+ + setForm((f) => ({ ...f, [key]: e.target.value }))} + /> +
+ ))} +
+ + setForm((f) => ({ ...f, startTime: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, endTime: e.target.value }))} + /> +
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/app/my-shifts/page.tsx b/frontend/app/my-shifts/page.tsx new file mode 100644 index 0000000..9158ac4 --- /dev/null +++ b/frontend/app/my-shifts/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Clock, CalendarDays } from "lucide-react"; +import api from "@/lib/axios"; + +type Shift = { + id: string; + startTime: string; + endTime: string; + roleName: string; + notes: string | null; +}; + +export default function MyShiftsPage() { + const { data, isLoading } = useQuery({ + queryKey: ["my-shifts"], + queryFn: async () => { + const r = await api.get("/shifts/this-week"); + return r.data.data as Shift[]; + }, + }); + + const shifts = data ?? []; + + const groupByDay = () => { + const groups: Record = {}; + shifts.forEach((s) => { + const day = new Date(s.startTime).toLocaleDateString("en-GB", { + weekday: "long", + day: "2-digit", + month: "short", + }); + if (!groups[day]) groups[day] = []; + groups[day].push(s); + }); + return groups; + }; + + const groups = groupByDay(); + + if (isLoading) { + return ( +
Loading shifts…
+ ); + } + + return ( +
+
+ +

My Shifts This Week

+
+ + {shifts.length === 0 ? ( +
+ +

No shifts scheduled this week

+
+ ) : ( + Object.entries(groups).map(([day, dayShifts]) => ( +
+

+ {day} +

+ {dayShifts.map((s) => ( +
+
+

{s.roleName}

+ {s.notes && ( +

{s.notes}

+ )} +
+
+
+ {new Date(s.startTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} +
+
+ →{" "} + {new Date(s.endTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} +
+
+
+ ))} +
+ )) + )} +
+ ); +} diff --git a/frontend/components/dashboard/DashboardSidebar.tsx b/frontend/components/dashboard/DashboardSidebar.tsx index 97b1636..d445c37 100644 --- a/frontend/components/dashboard/DashboardSidebar.tsx +++ b/frontend/components/dashboard/DashboardSidebar.tsx @@ -19,6 +19,8 @@ import { Bell, BarChart3, CreditCard, + Calendar, + MapPin, Wrench, } from "lucide-react"; import { useState } from "react"; @@ -35,6 +37,7 @@ const navItems = [ { label: "My Locker", href: "/lockers", icon: Lock }, { label: "Maintenance", href: "/maintenance", icon: Wrench }, { label: "Profile", href: "/profile", icon: User }, + { label: "My Shifts", href: "/my-shifts", icon: Calendar }, { label: "Settings", href: "/settings", icon: Settings }, ]; @@ -46,6 +49,8 @@ const adminItems = [ { label: "Members", href: "/admin/members", icon: Users }, { label: "Invoices", href: "/admin/invoices", icon: FileText }, { label: "Newsletter", href: "/dashboard?tab=newsletter", icon: Mail }, + { label: "Staff Schedule", href: "/admin/staff", icon: Calendar }, + { label: "Facilities", href: "/admin/facilities", icon: MapPin }, ]; export default function DashboardSidebar() {