diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 28b394f..e86c4ee 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,10 +4,16 @@
-
+
+
+
+
+
+
+
+
-
@@ -129,7 +135,7 @@
1774643312599
-
+
@@ -315,17 +321,37 @@
1774717937214
-
+
+
+ 1774718091896
+
+
+
+ 1774718091896
+
+
+
+ 1774718250404
+
+
+
+ 1774718250404
+
+
+
+ 1774718397058
+
+
+
+ 1774718397058
+
+
-
-
-
-
@@ -347,6 +373,10 @@
-
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index fe378ff..8caaa2b 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -96,12 +96,13 @@ model Reservation {
id String @id @default(uuid())
startTime DateTime
endTime DateTime
- userId String
+ userId String?
spaceId String
+ note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
space Space @relation(fields: [spaceId], references: [id])
- user User @relation(fields: [userId], references: [id])
+ user User? @relation(fields: [userId], references: [id])
}
enum Role {
diff --git a/apps/api/src/reservations/dto/create-reservation.dto.ts b/apps/api/src/reservations/dto/create-reservation.dto.ts
index d8e2aa4..367060c 100644
--- a/apps/api/src/reservations/dto/create-reservation.dto.ts
+++ b/apps/api/src/reservations/dto/create-reservation.dto.ts
@@ -1,4 +1,4 @@
-import { IsString, IsDateString, IsNotEmpty } from 'class-validator';
+import { IsString, IsDateString, IsNotEmpty, IsOptional } from 'class-validator';
export class CreateReservationDto {
@IsDateString()
@@ -12,4 +12,12 @@ export class CreateReservationDto {
@IsString()
@IsNotEmpty()
spaceId: string;
+
+ @IsString()
+ @IsOptional()
+ userId?: string;
+
+ @IsString()
+ @IsOptional()
+ note?: string;
}
diff --git a/apps/api/src/reservations/dto/update-reservation.dto.ts b/apps/api/src/reservations/dto/update-reservation.dto.ts
index f0d03ff..407dfea 100644
--- a/apps/api/src/reservations/dto/update-reservation.dto.ts
+++ b/apps/api/src/reservations/dto/update-reservation.dto.ts
@@ -12,4 +12,12 @@ export class UpdateReservationDto {
@IsString()
@IsOptional()
spaceId?: string;
+
+ @IsString()
+ @IsOptional()
+ userId?: string;
+
+ @IsString()
+ @IsOptional()
+ note?: string;
}
diff --git a/apps/api/src/reservations/reservations.controller.ts b/apps/api/src/reservations/reservations.controller.ts
index 6515079..7d5bec7 100644
--- a/apps/api/src/reservations/reservations.controller.ts
+++ b/apps/api/src/reservations/reservations.controller.ts
@@ -8,11 +8,13 @@ import {
UseGuards,
Req,
Patch,
+ ForbiddenException,
} from '@nestjs/common';
import { ReservationsService } from './reservations.service';
import { CreateReservationDto } from './dto/create-reservation.dto';
import { UpdateReservationDto } from './dto/update-reservation.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
+import { Role } from '@prisma/client';
@Controller('reservations')
@UseGuards(JwtAuthGuard)
@@ -21,7 +23,16 @@ export class ReservationsController {
@Post()
create(@Req() req, @Body() createReservationDto: CreateReservationDto) {
- return this.reservationsService.create(req.user.id, createReservationDto);
+ let targetUserId = req.user.id;
+
+ if (createReservationDto.userId && createReservationDto.userId !== req.user.id) {
+ if (req.user.role !== Role.ADMIN && req.user.role !== Role.SUPERVISOR) {
+ throw new ForbiddenException('Only admins and supervisors can book for other people');
+ }
+ targetUserId = createReservationDto.userId;
+ }
+
+ return this.reservationsService.create(targetUserId, createReservationDto);
}
@Get('my')
@@ -37,11 +48,20 @@ export class ReservationsController {
@Patch(':id')
update(@Param('id') id: string, @Req() req, @Body() updateReservationDto: UpdateReservationDto) {
- return this.reservationsService.update(req.user.id, id, updateReservationDto);
+ let targetUserId = req.user.id;
+
+ if (updateReservationDto.userId && updateReservationDto.userId !== req.user.id) {
+ if (req.user.role !== Role.ADMIN && req.user.role !== Role.SUPERVISOR) {
+ throw new ForbiddenException('Only admins and supervisors can change the owner of a reservation');
+ }
+ targetUserId = updateReservationDto.userId;
+ }
+
+ return this.reservationsService.update(targetUserId, id, updateReservationDto);
}
@Delete(':id')
cancel(@Param('id') id: string, @Req() req) {
- return this.reservationsService.cancel(req.user.id, id);
+ return this.reservationsService.cancel(req.user.id, id, req.user.role);
}
}
diff --git a/apps/api/src/reservations/reservations.service.ts b/apps/api/src/reservations/reservations.service.ts
index 20c405a..9e0e047 100644
--- a/apps/api/src/reservations/reservations.service.ts
+++ b/apps/api/src/reservations/reservations.service.ts
@@ -13,7 +13,8 @@ export class ReservationsService {
) {}
async create(userId: string, createReservationDto: CreateReservationDto) {
- const { spaceId, startTime, endTime } = createReservationDto;
+ const { spaceId, startTime, endTime, userId: requestedUserId } = createReservationDto;
+ const targetUserId = requestedUserId || userId;
const space = await this.prisma.space.findUnique({
where: { id: spaceId },
@@ -77,8 +78,9 @@ export class ReservationsService {
data: {
startTime: start,
endTime: end,
- userId,
+ userId: targetUserId,
spaceId,
+ note: createReservationDto.note,
},
include: {
user: {
@@ -109,7 +111,7 @@ export class ReservationsService {
return reservation;
}
- async cancel(userId: string, reservationId: string) {
+ async cancel(userId: string, reservationId: string, userRole?: string) {
const reservation = await this.prisma.reservation.findUnique({
where: { id: reservationId },
include: {
@@ -126,17 +128,8 @@ export class ReservationsService {
throw new NotFoundException(`Reservation with ID ${reservationId} not found`);
}
- // Check if user exists to get role
- const requestingUser = await this.prisma.user.findUnique({
- where: { id: userId },
- });
-
- if (!requestingUser) {
- throw new NotFoundException(`User with ID ${userId} not found`);
- }
-
- if (reservation.userId !== userId && requestingUser.role !== 'ADMIN') {
- throw new ForbiddenException('You can only cancel your own reservations unless you are an administrator');
+ if (reservation.userId !== userId && userRole !== Role.ADMIN && userRole !== Role.SUPERVISOR) {
+ throw new ForbiddenException('You can only cancel your own reservations unless you are an administrator or supervisor');
}
await this.prisma.reservation.delete({
@@ -191,13 +184,21 @@ export class ReservationsService {
throw new NotFoundException(`Reservation with ID ${id} not found`);
}
- if (reservation.userId !== userId) {
- throw new ForbiddenException('You can only update your own reservations');
+ const isDifferentUser = reservation.userId !== userId;
+
+ if (isDifferentUser) {
+ // Check if the current user is an admin or supervisor
+ const requester = await this.prisma.user.findUnique({ where: { id: userId } });
+ if (!requester || (requester.role !== Role.ADMIN && requester.role !== Role.SUPERVISOR)) {
+ throw new ForbiddenException('You can only update your own reservations');
+ }
}
const spaceId = updateReservationDto.spaceId || reservation.spaceId;
const startTime = updateReservationDto.startTime || reservation.startTime;
const endTime = updateReservationDto.endTime || reservation.endTime;
+ const targetUserId = updateReservationDto.userId === null ? null : (updateReservationDto.userId || reservation.userId);
+ const note = updateReservationDto.note === undefined ? reservation.note : updateReservationDto.note;
const space = await this.prisma.space.findUnique({
where: { id: spaceId },
@@ -262,6 +263,8 @@ export class ReservationsService {
startTime: start,
endTime: end,
spaceId,
+ userId: targetUserId,
+ note,
},
include: {
user: {
diff --git a/apps/web/src/lib/components/BookingModal.svelte b/apps/web/src/lib/components/BookingModal.svelte
index d8ce122..940c2d2 100644
--- a/apps/web/src/lib/components/BookingModal.svelte
+++ b/apps/web/src/lib/components/BookingModal.svelte
@@ -2,7 +2,7 @@
import { createEventDispatcher } from 'svelte';
import { user } from '$lib/stores/auth';
import { apiFetch } from '$lib/api/client';
- import { X, Calendar, Clock, Loader2, Trash2, AlertTriangle } from 'lucide-svelte';
+ import { X, Calendar, Clock, Loader2, Trash2, AlertTriangle, User as UserIcon, FileText, Search, ChevronDown } from 'lucide-svelte';
import { t, locale } from 'svelte-i18n';
import { format } from 'date-fns';
import { es, enUS } from 'date-fns/locale';
@@ -48,9 +48,45 @@
let confirmDelete = false;
let error = "";
+ let users: any[] = [];
+ let loadingUsers = false;
+ let bookFor = 'myself'; // 'myself', 'other', 'none'
+ let targetUserId = '';
+ let note = '';
+ let searchQuery = '';
+ let showUserDropdown = false;
+
+ $: filteredUsers = users.filter(u =>
+ (u.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ u.email?.toLowerCase().includes(searchQuery.toLowerCase()))
+ );
+
+ function selectUser(selectedUser: any) {
+ targetUserId = selectedUser.id;
+ searchQuery = selectedUser.name || selectedUser.email;
+ showUserDropdown = false;
+ }
+
+ $: isAdminOrSupervisor = $user?.role === 'ADMIN' || $user?.role === 'SUPERVISOR';
$: isOwner = status.reservation?.userId === $user?.id || status.reservation?.user?.id === $user?.id;
$: isAdmin = $user?.role === 'ADMIN';
+ async function fetchUsers() {
+ if (!isAdminOrSupervisor) return;
+ loadingUsers = true;
+ try {
+ users = await apiFetch('/users');
+ } catch (e) {
+ console.error("Failed to fetch users", e);
+ } finally {
+ loadingUsers = false;
+ }
+ }
+
+ $: if (isAdminOrSupervisor && space) {
+ fetchUsers();
+ }
+
const timeOptions = [];
for (let h = 7; h <= 20; h++) {
for (let m = 0; m < 60; m += 15) {
@@ -109,6 +145,8 @@
spaceId: space.id,
startTime: start.toISOString(),
endTime: end.toISOString(),
+ userId: bookFor === 'other' ? targetUserId : (bookFor === 'none' ? null : $user?.id),
+ note: note || undefined
})
});
@@ -208,15 +246,32 @@
{$t('booking.currentlyBooked')}
-
- {status.reservation?.user?.email?.charAt(0).toUpperCase()}
-
-
-
{status.reservation?.user?.email}
-
- {formatTime(status.reservation.startTime)} - {formatTime(status.reservation.endTime)}
-
-
+ {#if status.reservation?.user}
+
+ {status.reservation.user.email?.charAt(0).toUpperCase()}
+
+
+
{status.reservation.user.email}
+ {#if status.reservation.note}
+
"{status.reservation.note}"
+ {/if}
+
+ {formatTime(status.reservation.startTime)} - {formatTime(status.reservation.endTime)}
+
+
+ {:else}
+
+
+
+
+
+ {status.reservation.note || $t('booking.nonEmployee')}
+
+
+ {formatTime(status.reservation.startTime)} - {formatTime(status.reservation.endTime)}
+
+
+ {/if}
{#if isOwner || isAdmin}
@@ -266,6 +321,100 @@
{/if}
+ {#if isAdminOrSupervisor}
+
+
+
+
+
+
+
+
+
+ {#if bookFor === 'other'}
+
+
+
+
+
+
showUserDropdown = true}
+ placeholder={$t('booking.searchUserPlaceholder')}
+ class="w-full pl-10 pr-10 py-2.5 bg-white border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 outline-none"
+ />
+
+
+
+
+
+ {#if showUserDropdown && (searchQuery || users.length > 0)}
+
+ {#if filteredUsers.length === 0}
+
+ {$t('booking.noUsersFound')}
+
+ {:else}
+ {#each filteredUsers as userItem}
+
+ {/each}
+ {/if}
+
+ {/if}
+
+ {#if showUserDropdown}
+
showUserDropdown = false}
+ >
+ {/if}
+
+ {/if}
+
+
+
+
+
+
+ {/if}
+