diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 529fe68..847b8ff 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,11 +4,16 @@
-
+
+
+
+
+
-
-
+
+
+
@@ -128,7 +133,7 @@
1774643312599
-
+
@@ -162,7 +167,15 @@
1774707328435
-
+
+
+ 1774707617715
+
+
+
+ 1774707617715
+
+
@@ -173,6 +186,7 @@
-
+
+
\ No newline at end of file
diff --git a/apps/api/src/events/events.gateway.ts b/apps/api/src/events/events.gateway.ts
index 13e39bf..832db1e 100644
--- a/apps/api/src/events/events.gateway.ts
+++ b/apps/api/src/events/events.gateway.ts
@@ -36,10 +36,11 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
console.log(`Client ${client.id} left floor: ${floorId}`);
}
- emitSpaceStatusChanged(floorId: string, spaceId: string, isOccupied: boolean) {
+ emitSpaceStatusChanged(floorId: string, spaceId: string, isOccupied: boolean, reservationData?: any) {
this.server.to(floorId).emit('space_status_changed', {
spaceId,
isOccupied,
+ ...reservationData,
});
}
}
diff --git a/apps/api/src/reservations/reservations.controller.ts b/apps/api/src/reservations/reservations.controller.ts
index 4f9665d..9cbde9f 100644
--- a/apps/api/src/reservations/reservations.controller.ts
+++ b/apps/api/src/reservations/reservations.controller.ts
@@ -22,6 +22,12 @@ export class ReservationsController {
return this.reservationsService.create(req.user.id, createReservationDto);
}
+ @Get('space/:spaceId')
+ findAllBySpaceId(@Param('spaceId') spaceId: string, @Req() req) {
+ // Optionally we could filter by date if passed as query param
+ return this.reservationsService.findAllBySpaceId(spaceId);
+ }
+
@Delete(':id')
cancel(@Param('id') id: string) {
return this.reservationsService.cancel(id);
diff --git a/apps/api/src/reservations/reservations.service.ts b/apps/api/src/reservations/reservations.service.ts
index 50a3828..f4f39bf 100644
--- a/apps/api/src/reservations/reservations.service.ts
+++ b/apps/api/src/reservations/reservations.service.ts
@@ -1,7 +1,8 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EventsGateway } from '../events/events.gateway';
import { CreateReservationDto } from './dto/create-reservation.dto';
+import { SpaceType } from '@prisma/client';
@Injectable()
export class ReservationsService {
@@ -21,17 +22,72 @@ export class ReservationsService {
throw new NotFoundException(`Space with ID ${spaceId} not found`);
}
- const reservation = await this.prisma.reservation.create({
- data: {
- startTime: new Date(startTime),
- endTime: new Date(endTime),
- userId,
+ const start = new Date(startTime);
+ const end = new Date(endTime);
+
+ // Validation
+ if (start >= end) {
+ throw new BadRequestException('Start time must be before end time');
+ }
+
+ // Rules
+ if (space.type === SpaceType.DESK) {
+ // Desks: Full day (setting to midnight of start and end of that day)
+ start.setHours(0, 0, 0, 0);
+ end.setHours(23, 59, 59, 999);
+ } else if (space.type === SpaceType.MEETING_ROOM) {
+ // Meeting rooms: 15-min increments, between 07:00 and 20:00
+ if (start.getHours() < 7 || (end.getHours() > 20 || (end.getHours() === 20 && end.getMinutes() > 0))) {
+ throw new BadRequestException('Meeting rooms can only be booked between 07:00 and 20:00');
+ }
+ if (start.getMinutes() % 15 !== 0 || end.getMinutes() % 15 !== 0) {
+ throw new BadRequestException('Meeting rooms can only be booked in 15-minute increments');
+ }
+ } else if (space.type === SpaceType.AMENITY) {
+ throw new BadRequestException('Amenities cannot be booked');
+ }
+
+ // Overlap check
+ const existing = await this.prisma.reservation.findFirst({
+ where: {
spaceId,
+ OR: [
+ { startTime: { lt: end }, endTime: { gt: start } },
+ ],
},
});
+ if (existing) {
+ throw new BadRequestException('Space is already booked for the selected time');
+ }
+
+ const reservation = await this.prisma.reservation.create({
+ data: {
+ startTime: start,
+ endTime: end,
+ userId,
+ spaceId,
+ },
+ include: {
+ user: {
+ select: {
+ id: true,
+ email: true,
+ role: true,
+ }
+ }
+ }
+ });
+
// Notify clients on the floor
- this.eventsGateway.emitSpaceStatusChanged(space.floorId, spaceId, true);
+ this.eventsGateway.emitSpaceStatusChanged(space.floorId, spaceId, true, {
+ reservation: {
+ id: reservation.id,
+ startTime: reservation.startTime,
+ endTime: reservation.endTime,
+ user: reservation.user
+ }
+ });
return reservation;
}
@@ -66,4 +122,22 @@ export class ReservationsService {
include: { space: true },
});
}
+
+ async findAllBySpaceId(spaceId: string) {
+ return this.prisma.reservation.findMany({
+ where: { spaceId },
+ include: {
+ user: {
+ select: {
+ id: true,
+ email: true,
+ role: true,
+ }
+ }
+ },
+ orderBy: {
+ startTime: 'asc',
+ },
+ });
+ }
}
diff --git a/apps/api/src/spaces/spaces.controller.ts b/apps/api/src/spaces/spaces.controller.ts
index 0781877..5ddf33e 100644
--- a/apps/api/src/spaces/spaces.controller.ts
+++ b/apps/api/src/spaces/spaces.controller.ts
@@ -1,4 +1,4 @@
-import { Controller, Get, Post, Body, Param, UseGuards, Request, Patch } from '@nestjs/common';
+import { Controller, Get, Post, Body, Param, UseGuards, Request, Patch, Query } from '@nestjs/common';
import { SpacesService } from './spaces.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
@@ -11,8 +11,8 @@ export class SpacesController {
constructor(private readonly spacesService: SpacesService) {}
@Get('floor/:id')
- async getFloorLayout(@Param('id') id: string) {
- return this.spacesService.getFloorLayout(id);
+ async getFloorLayout(@Param('id') id: string, @Query('date') date?: string) {
+ return this.spacesService.getFloorLayout(id, date ? new Date(date) : undefined);
}
@Get('building/:id')
diff --git a/apps/api/src/spaces/spaces.service.ts b/apps/api/src/spaces/spaces.service.ts
index 6b178b4..3da918e 100644
--- a/apps/api/src/spaces/spaces.service.ts
+++ b/apps/api/src/spaces/spaces.service.ts
@@ -7,7 +7,7 @@ export class SpacesService {
constructor(private prisma: PrismaService) {}
- async getFloorLayout(floorId: string) {
+ async getFloorLayout(floorId: string, date: Date = new Date()) {
return this.prisma.floor.findUnique({
where: { id: floorId },
include: {
@@ -16,10 +16,19 @@ export class SpacesService {
reservations: {
where: {
endTime: {
- gte: new Date(),
+ gte: date,
},
startTime: {
- lte: new Date(),
+ lte: date,
+ },
+ },
+ include: {
+ user: {
+ select: {
+ id: true,
+ email: true,
+ role: true,
+ },
},
},
},
diff --git a/apps/web/src/lib/components/BookingModal.svelte b/apps/web/src/lib/components/BookingModal.svelte
new file mode 100644
index 0000000..b2e6fae
--- /dev/null
+++ b/apps/web/src/lib/components/BookingModal.svelte
@@ -0,0 +1,265 @@
+
+
+
+
+
+
+
{space.name}
+
{space.type.replace('_', ' ')}
+
+
+
+
+
+ {#if space.type === 'DESK' && status.isOccupied}
+
+
Currently Booked
+
+
+ {status.reservation?.user?.email?.charAt(0).toUpperCase()}
+
+
+
{status.reservation?.user?.email}
+
+ {formatTime(status.reservation.startTime)} - {formatTime(status.reservation.endTime)}
+
+
+
+
+
This space is currently unavailable for booking.
+ {:else}
+ {#if error}
+
+ {error}
+
+ {/if}
+
+
+
+
+
+
+
+ {#if space.type === 'MEETING_ROOM'}
+
+
+
+ {#if loadingReservations}
+
+
+
+ {:else}
+
+ {#each timeOptions as time}
+ {@const reservation = isSlotBooked(time)}
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+ {:else}
+
+
Desks are booked for the full day.
+
+ {/if}
+
+
+
+ {/if}
+
+
+
diff --git a/apps/web/src/lib/components/FloorplanViewer.svelte b/apps/web/src/lib/components/FloorplanViewer.svelte
index 27e0806..da8efb9 100644
--- a/apps/web/src/lib/components/FloorplanViewer.svelte
+++ b/apps/web/src/lib/components/FloorplanViewer.svelte
@@ -1,23 +1,36 @@
@@ -55,23 +77,56 @@
style="width: 800px; height: 600px; background-image: url({planImageUrl}); background-size: contain; background-repeat: no-repeat; background-position: center;"
>
{#each spaces as space (space.id)}
- handleSpaceClick(space)}
+ class="absolute p-2 border-2 rounded-lg shadow-md transition-all {space.type !== 'AMENITY' ? 'hover:scale-105 cursor-pointer' : 'cursor-default opacity-80'} {status.isOccupied ? 'bg-yellow-50 border-yellow-500' : 'bg-green-50 border-green-500'}"
style="left: 0; top: 0; transform: translate({(space.x / 100) * (container?.clientWidth || 800)}px, {(space.y / 100) * (container?.clientHeight || 600)}px)"
- title="{space.name} ({spaceStatus[space.id] ? 'Occupied' : 'Available'})"
+ title="{space.name} ({status.isOccupied ? 'Booked' : 'Available'})"
>
-
-
+
+
+
+
+
+ {#if status.isOccupied && space.type === 'DESK'}
+
+ {#if status.reservation?.user?.profilePicture}
+

+ {:else}
+
+ {status.reservation?.user?.email?.charAt(0).toUpperCase()}
+
+ {/if}
+
+ {/if}
+
+
+
+
+ {space.name}
+
+ {#if status.isOccupied && space.type === 'DESK'}
+
+ {status.reservation?.user?.email.split('@')[0]}
+
+ {/if}
-
- {space.name}
-
-
+
{/each}
+{#if selectedSpace}
+ selectedSpace = null}
+ />
+{/if}
+