Allow admins and supervisors to book or update reservations for other users
- Add role-based restrictions for booking or updating reservations on behalf of others (Admins/Supervisors only). - Extend reservation DTOs (`CreateReservationDto`, `UpdateReservationDto`) to include `userId` and `note` fields. - Update Prisma schema to allow nullable `userId` and add support for reservation notes. - Enhance API logic to validate permissions and handle ownership changes for reservations. - Improve UI for booking flow; introduce "Book for" options, user search, and reservation notes. - Localize new strings in `en.json` and `es-ES.json`.main
parent
0b6a043f19
commit
604d4c7244
|
|
@ -4,10 +4,16 @@
|
||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Improve real-time space status handling and date-based resets - Add logic to clear real-time updates when the selected date changes. - Enhance space status updates to consider reservation time ranges for relevance. - Update relevant Svelte components (`SpaceCard`, `FloorplanViewer`) to handle date-sensitive real-time updates. - Update API to include reservation details in `space_status_changed` events for improved update precision.">
|
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Prevent past bookings and updates across web and API - Add validation logic to block creating or updating reservations in the past. - Update date pickers to enforce minimum selectable date as today. - Extend i18n files (`en.json`, `es-ES.json`) with new error messages. - Refine UI to disable interaction with past time slots and display appropriate messages.">
|
||||||
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/api/src/reservations/dto/create-reservation.dto.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/reservations/dto/create-reservation.dto.ts" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/api/src/reservations/dto/update-reservation.dto.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/reservations/dto/update-reservation.dto.ts" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/api/src/reservations/reservations.controller.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/reservations/reservations.controller.ts" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/api/src/reservations/reservations.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/reservations/reservations.service.ts" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/BookingModal.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/BookingModal.svelte" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/i18n/locales/en.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/i18n/locales/en.json" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/i18n/locales/en.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/i18n/locales/en.json" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/i18n/locales/es-ES.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/i18n/locales/es-ES.json" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/i18n/locales/es-ES.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/i18n/locales/es-ES.json" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/my-bookings/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/my-bookings/+page.svelte" afterDir="false" />
|
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
|
|
@ -129,7 +135,7 @@
|
||||||
<updated>1774643312599</updated>
|
<updated>1774643312599</updated>
|
||||||
<workItem from="1774643313611" duration="74000" />
|
<workItem from="1774643313611" duration="74000" />
|
||||||
<workItem from="1774643422240" duration="5278000" />
|
<workItem from="1774643422240" duration="5278000" />
|
||||||
<workItem from="1774706361502" duration="11708000" />
|
<workItem from="1774706361502" duration="12417000" />
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services - Set up SvelteKit for the web interface with Tailwind CSS. - Build the API using NestJS with Prisma ORM for database interaction. - Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. - Create shared data models for consistent typing across services. - Establish basic routing and layouts for web and API services.">
|
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services - Set up SvelteKit for the web interface with Tailwind CSS. - Build the API using NestJS with Prisma ORM for database interaction. - Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. - Create shared data models for consistent typing across services. - Establish basic routing and layouts for web and API services.">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
|
|
@ -315,17 +321,37 @@
|
||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1774717937214</updated>
|
<updated>1774717937214</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="24" />
|
<task id="LOCAL-00024" summary="Add filtering for past and upcoming bookings in "My Bookings" page - Enhance UI to separate upcoming and past bookings with collapsible sections. - Add new i18n keys for handling booking filters (`en.json`, `es-ES.json`). - Update booking component logic to filter reservations based on time. - Introduce "Show/Hide Past Bookings" toggle for improved user experience.">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1774718091896</created>
|
||||||
|
<option name="number" value="00024" />
|
||||||
|
<option name="presentableId" value="LOCAL-00024" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1774718091896</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00025" summary="Refactor date picker components for improved accessibility and usability - Add `showPicker` event to trigger native date picker overlays. - Adjust z-index and positioning for hidden `input` elements to improve interaction. - Reorganize markup for consistent focus and selection behavior across components.">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1774718250404</created>
|
||||||
|
<option name="number" value="00025" />
|
||||||
|
<option name="presentableId" value="LOCAL-00025" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1774718250404</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00026" summary="Prevent past bookings and updates across web and API - Add validation logic to block creating or updating reservations in the past. - Update date pickers to enforce minimum selectable date as today. - Extend i18n files (`en.json`, `es-ES.json`) with new error messages. - Refine UI to disable interaction with past time slots and display appropriate messages.">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1774718397058</created>
|
||||||
|
<option name="number" value="00026" />
|
||||||
|
<option name="presentableId" value="LOCAL-00026" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1774718397058</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="27" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
<option name="version" value="3" />
|
<option name="version" value="3" />
|
||||||
</component>
|
</component>
|
||||||
<component name="VcsManagerConfiguration">
|
<component name="VcsManagerConfiguration">
|
||||||
<MESSAGE value="Initialize Hot Desking app with web and API services - Set up SvelteKit for the web interface with Tailwind CSS. - Build the API using NestJS with Prisma ORM for database interaction. - Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. - Create shared data models for consistent typing across services. - Establish basic routing and layouts for web and API services." />
|
|
||||||
<MESSAGE value="Remove compiled API files from version control - Deleted compiled JavaScript files, type definitions, and source maps from the `dist` folder." />
|
|
||||||
<MESSAGE value="Implement authentication, organization management, and floor layout features - Add API authentication with JWT strategy using NestJS. - Configure Prisma schema for organizations, users, buildings, floors, and spaces. - Seed initial database with test data and relationships. - Build SvelteKit web pages for dashboard and floor layout with secured routing and organization hierarchy display. - Remove deprecated `AppService` from API." />
|
|
||||||
<MESSAGE value="Add floorplan editor for admin layout management - Implement `FloorplanEditor.svelte` component for adding, editing, and positioning spaces. - Add backend support for floorplan image upload and batch updating space positions. - Update Prisma schema to include `planImageUrl` for floors. - Enable API proxy in Vite configuration for straightforward local development. - Refactor API calls to use a centralized `apiFetch` utility with token handling." />
|
|
||||||
<MESSAGE value="Add building-level dashboard and interactive floorplan viewer - Create `+page.svelte` for building-level dashboard with floor selection and layout preview. - Add `FloorplanViewer.svelte` component with WebSocket integration for real-time space status updates. - Extend `SpacesService` and API endpoints for building floors and floor details retrieval. - Update dashboard UI with cleaner building and floor presentation. - Improve loading states and error handling across dashboard and floor pages." />
|
<MESSAGE value="Add building-level dashboard and interactive floorplan viewer - Create `+page.svelte` for building-level dashboard with floor selection and layout preview. - Add `FloorplanViewer.svelte` component with WebSocket integration for real-time space status updates. - Extend `SpacesService` and API endpoints for building floors and floor details retrieval. - Update dashboard UI with cleaner building and floor presentation. - Improve loading states and error handling across dashboard and floor pages." />
|
||||||
<MESSAGE value="Add booking modal and enhance reservation handling - Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations. - Add API endpoint to fetch reservations by space ID and date. - Update `SpacesService` with enhanced validation rules and conflict checks for reservations. - Extend WebSocket updates to include reservation data for real-time synchronization. - Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`)." />
|
<MESSAGE value="Add booking modal and enhance reservation handling - Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations. - Add API endpoint to fetch reservations by space ID and date. - Update `SpacesService` with enhanced validation rules and conflict checks for reservations. - Extend WebSocket updates to include reservation data for real-time synchronization. - Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`)." />
|
||||||
<MESSAGE value="Add support for space resizing and enhanced layout management - Update `SpacesService` to handle width and height properties for spaces. - Introduce `UpdateSpaceDto` for improved validation in batch update operations. - Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing. - Modify Prisma schema to include `width` and `height` fields for spaces. - Enhance floorplan rendering to maintain proportional layout during editing and viewing." />
|
<MESSAGE value="Add support for space resizing and enhanced layout management - Update `SpacesService` to handle width and height properties for spaces. - Introduce `UpdateSpaceDto` for improved validation in batch update operations. - Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing. - Modify Prisma schema to include `width` and `height` fields for spaces. - Enhance floorplan rendering to maintain proportional layout during editing and viewing." />
|
||||||
|
|
@ -347,6 +373,10 @@
|
||||||
<MESSAGE value="Add i18n integration and translations for improved localization - Extend English (`en.json`) and Spanish (`es-ES.json`) locale files with additional keys for buildings, bookings, reports, and user management. - Update Svelte components and pages to use `svelte-i18n` for dynamic translations across the app. - Refactor labels, titles, and error messages for multilingual support. - Improve date formatting based on locale preferences." />
|
<MESSAGE value="Add i18n integration and translations for improved localization - Extend English (`en.json`) and Spanish (`es-ES.json`) locale files with additional keys for buildings, bookings, reports, and user management. - Update Svelte components and pages to use `svelte-i18n` for dynamic translations across the app. - Refactor labels, titles, and error messages for multilingual support. - Improve date formatting based on locale preferences." />
|
||||||
<MESSAGE value="Add seed service to initialize default admin and organization - Implement `SeedService` to create a default admin account and organization on app startup. - Update `PrismaModule` to include `SeedService` as a provider. - Remove seeded credentials display from login page. - Refactor existing seed script for consistency with the new service." />
|
<MESSAGE value="Add seed service to initialize default admin and organization - Implement `SeedService` to create a default admin account and organization on app startup. - Update `PrismaModule` to include `SeedService` as a provider. - Remove seeded credentials display from login page. - Refactor existing seed script for consistency with the new service." />
|
||||||
<MESSAGE value="Improve real-time space status handling and date-based resets - Add logic to clear real-time updates when the selected date changes. - Enhance space status updates to consider reservation time ranges for relevance. - Update relevant Svelte components (`SpaceCard`, `FloorplanViewer`) to handle date-sensitive real-time updates. - Update API to include reservation details in `space_status_changed` events for improved update precision." />
|
<MESSAGE value="Improve real-time space status handling and date-based resets - Add logic to clear real-time updates when the selected date changes. - Enhance space status updates to consider reservation time ranges for relevance. - Update relevant Svelte components (`SpaceCard`, `FloorplanViewer`) to handle date-sensitive real-time updates. - Update API to include reservation details in `space_status_changed` events for improved update precision." />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="Improve real-time space status handling and date-based resets - Add logic to clear real-time updates when the selected date changes. - Enhance space status updates to consider reservation time ranges for relevance. - Update relevant Svelte components (`SpaceCard`, `FloorplanViewer`) to handle date-sensitive real-time updates. - Update API to include reservation details in `space_status_changed` events for improved update precision." />
|
<MESSAGE value="Add past and upcoming reservations filtering in My Bookings - Introduce filters to separate and toggle display of past and upcoming reservations. - Enhance localization with new translations for filter-related actions in English and Spanish. - Update My Bookings UI to dynamically display reservations based on their timeline. - Improve reservation display logic using `isPast` from `date-fns`." />
|
||||||
|
<MESSAGE value="Add filtering for past and upcoming bookings in "My Bookings" page - Enhance UI to separate upcoming and past bookings with collapsible sections. - Add new i18n keys for handling booking filters (`en.json`, `es-ES.json`). - Update booking component logic to filter reservations based on time. - Introduce "Show/Hide Past Bookings" toggle for improved user experience." />
|
||||||
|
<MESSAGE value="Refactor date picker components for improved accessibility and usability - Add `showPicker` event to trigger native date picker overlays. - Adjust z-index and positioning for hidden `input` elements to improve interaction. - Reorganize markup for consistent focus and selection behavior across components." />
|
||||||
|
<MESSAGE value="Prevent past bookings and updates across web and API - Add validation logic to block creating or updating reservations in the past. - Update date pickers to enforce minimum selectable date as today. - Extend i18n files (`en.json`, `es-ES.json`) with new error messages. - Refine UI to disable interaction with past time slots and display appropriate messages." />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="Prevent past bookings and updates across web and API - Add validation logic to block creating or updating reservations in the past. - Update date pickers to enforce minimum selectable date as today. - Extend i18n files (`en.json`, `es-ES.json`) with new error messages. - Refine UI to disable interaction with past time slots and display appropriate messages." />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
|
@ -96,12 +96,13 @@ model Reservation {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
startTime DateTime
|
startTime DateTime
|
||||||
endTime DateTime
|
endTime DateTime
|
||||||
userId String
|
userId String?
|
||||||
spaceId String
|
spaceId String
|
||||||
|
note String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
space Space @relation(fields: [spaceId], references: [id])
|
space Space @relation(fields: [spaceId], references: [id])
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Role {
|
enum Role {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { IsString, IsDateString, IsNotEmpty } from 'class-validator';
|
import { IsString, IsDateString, IsNotEmpty, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class CreateReservationDto {
|
export class CreateReservationDto {
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
|
|
@ -12,4 +12,12 @@ export class CreateReservationDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,4 +12,12 @@ export class UpdateReservationDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,13 @@ import {
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Req,
|
Req,
|
||||||
Patch,
|
Patch,
|
||||||
|
ForbiddenException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ReservationsService } from './reservations.service';
|
import { ReservationsService } from './reservations.service';
|
||||||
import { CreateReservationDto } from './dto/create-reservation.dto';
|
import { CreateReservationDto } from './dto/create-reservation.dto';
|
||||||
import { UpdateReservationDto } from './dto/update-reservation.dto';
|
import { UpdateReservationDto } from './dto/update-reservation.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
|
||||||
@Controller('reservations')
|
@Controller('reservations')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
|
@ -21,7 +23,16 @@ export class ReservationsController {
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
create(@Req() req, @Body() createReservationDto: CreateReservationDto) {
|
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')
|
@Get('my')
|
||||||
|
|
@ -37,11 +48,20 @@ export class ReservationsController {
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
update(@Param('id') id: string, @Req() req, @Body() updateReservationDto: UpdateReservationDto) {
|
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')
|
@Delete(':id')
|
||||||
cancel(@Param('id') id: string, @Req() req) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ export class ReservationsService {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(userId: string, createReservationDto: CreateReservationDto) {
|
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({
|
const space = await this.prisma.space.findUnique({
|
||||||
where: { id: spaceId },
|
where: { id: spaceId },
|
||||||
|
|
@ -77,8 +78,9 @@ export class ReservationsService {
|
||||||
data: {
|
data: {
|
||||||
startTime: start,
|
startTime: start,
|
||||||
endTime: end,
|
endTime: end,
|
||||||
userId,
|
userId: targetUserId,
|
||||||
spaceId,
|
spaceId,
|
||||||
|
note: createReservationDto.note,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
|
|
@ -109,7 +111,7 @@ export class ReservationsService {
|
||||||
return reservation;
|
return reservation;
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancel(userId: string, reservationId: string) {
|
async cancel(userId: string, reservationId: string, userRole?: string) {
|
||||||
const reservation = await this.prisma.reservation.findUnique({
|
const reservation = await this.prisma.reservation.findUnique({
|
||||||
where: { id: reservationId },
|
where: { id: reservationId },
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -126,17 +128,8 @@ export class ReservationsService {
|
||||||
throw new NotFoundException(`Reservation with ID ${reservationId} not found`);
|
throw new NotFoundException(`Reservation with ID ${reservationId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user exists to get role
|
if (reservation.userId !== userId && userRole !== Role.ADMIN && userRole !== Role.SUPERVISOR) {
|
||||||
const requestingUser = await this.prisma.user.findUnique({
|
throw new ForbiddenException('You can only cancel your own reservations unless you are an administrator or supervisor');
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.reservation.delete({
|
await this.prisma.reservation.delete({
|
||||||
|
|
@ -191,13 +184,21 @@ export class ReservationsService {
|
||||||
throw new NotFoundException(`Reservation with ID ${id} not found`);
|
throw new NotFoundException(`Reservation with ID ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reservation.userId !== userId) {
|
const isDifferentUser = reservation.userId !== userId;
|
||||||
throw new ForbiddenException('You can only update your own reservations');
|
|
||||||
|
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 spaceId = updateReservationDto.spaceId || reservation.spaceId;
|
||||||
const startTime = updateReservationDto.startTime || reservation.startTime;
|
const startTime = updateReservationDto.startTime || reservation.startTime;
|
||||||
const endTime = updateReservationDto.endTime || reservation.endTime;
|
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({
|
const space = await this.prisma.space.findUnique({
|
||||||
where: { id: spaceId },
|
where: { id: spaceId },
|
||||||
|
|
@ -262,6 +263,8 @@ export class ReservationsService {
|
||||||
startTime: start,
|
startTime: start,
|
||||||
endTime: end,
|
endTime: end,
|
||||||
spaceId,
|
spaceId,
|
||||||
|
userId: targetUserId,
|
||||||
|
note,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { user } from '$lib/stores/auth';
|
import { user } from '$lib/stores/auth';
|
||||||
import { apiFetch } from '$lib/api/client';
|
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 { t, locale } from 'svelte-i18n';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { es, enUS } from 'date-fns/locale';
|
import { es, enUS } from 'date-fns/locale';
|
||||||
|
|
@ -48,9 +48,45 @@
|
||||||
let confirmDelete = false;
|
let confirmDelete = false;
|
||||||
let error = "";
|
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;
|
$: isOwner = status.reservation?.userId === $user?.id || status.reservation?.user?.id === $user?.id;
|
||||||
$: isAdmin = $user?.role === 'ADMIN';
|
$: 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 = [];
|
const timeOptions = [];
|
||||||
for (let h = 7; h <= 20; h++) {
|
for (let h = 7; h <= 20; h++) {
|
||||||
for (let m = 0; m < 60; m += 15) {
|
for (let m = 0; m < 60; m += 15) {
|
||||||
|
|
@ -109,6 +145,8 @@
|
||||||
spaceId: space.id,
|
spaceId: space.id,
|
||||||
startTime: start.toISOString(),
|
startTime: start.toISOString(),
|
||||||
endTime: end.toISOString(),
|
endTime: end.toISOString(),
|
||||||
|
userId: bookFor === 'other' ? targetUserId : (bookFor === 'none' ? null : $user?.id),
|
||||||
|
note: note || undefined
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -208,15 +246,32 @@
|
||||||
<p class="text-yellow-800 font-bold mb-1">{$t('booking.currentlyBooked')}</p>
|
<p class="text-yellow-800 font-bold mb-1">{$t('booking.currentlyBooked')}</p>
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-10 h-10 rounded-full bg-yellow-200 flex items-center justify-center text-yellow-700 font-bold">
|
{#if status.reservation?.user}
|
||||||
{status.reservation?.user?.email?.charAt(0).toUpperCase()}
|
<div class="w-10 h-10 rounded-full bg-yellow-200 flex items-center justify-center text-yellow-700 font-bold">
|
||||||
</div>
|
{status.reservation.user.email?.charAt(0).toUpperCase()}
|
||||||
<div>
|
</div>
|
||||||
<p class="text-sm font-medium text-yellow-900">{status.reservation?.user?.email}</p>
|
<div>
|
||||||
<p class="text-xs text-yellow-700">
|
<p class="text-sm font-medium text-yellow-900">{status.reservation.user.email}</p>
|
||||||
{formatTime(status.reservation.startTime)} - {formatTime(status.reservation.endTime)}
|
{#if status.reservation.note}
|
||||||
</p>
|
<p class="text-xs text-yellow-800 italic">"{status.reservation.note}"</p>
|
||||||
</div>
|
{/if}
|
||||||
|
<p class="text-xs text-yellow-700">
|
||||||
|
{formatTime(status.reservation.startTime)} - {formatTime(status.reservation.endTime)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="w-10 h-10 rounded-full bg-yellow-200 flex items-center justify-center text-yellow-700 font-bold">
|
||||||
|
<FileText size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-yellow-900">
|
||||||
|
{status.reservation.note || $t('booking.nonEmployee')}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-yellow-700">
|
||||||
|
{formatTime(status.reservation.startTime)} - {formatTime(status.reservation.endTime)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isOwner || isAdmin}
|
{#if isOwner || isAdmin}
|
||||||
|
|
@ -266,6 +321,100 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
{#if isAdminOrSupervisor}
|
||||||
|
<div class="bg-gray-50 p-4 rounded-xl border border-gray-100 space-y-3">
|
||||||
|
<label class="block text-sm font-bold text-gray-700 flex items-center gap-2">
|
||||||
|
<UserIcon size={16} />
|
||||||
|
{$t('booking.bookFor')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={() => bookFor = 'myself'}
|
||||||
|
class="px-3 py-1.5 text-xs font-bold rounded-full transition-colors border
|
||||||
|
{bookFor === 'myself' ? 'bg-indigo-600 text-white border-indigo-700' : 'bg-white text-gray-600 border-gray-200 hover:border-indigo-400'}"
|
||||||
|
>
|
||||||
|
{$t('booking.myself')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={() => bookFor = 'other'}
|
||||||
|
class="px-3 py-1.5 text-xs font-bold rounded-full transition-colors border
|
||||||
|
{bookFor === 'other' ? 'bg-indigo-600 text-white border-indigo-700' : 'bg-white text-gray-600 border-gray-200 hover:border-indigo-400'}"
|
||||||
|
>
|
||||||
|
{$t('booking.someoneElse')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={() => bookFor = 'none'}
|
||||||
|
class="px-3 py-1.5 text-xs font-bold rounded-full transition-colors border
|
||||||
|
{bookFor === 'none' ? 'bg-indigo-600 text-white border-indigo-700' : 'bg-white text-gray-600 border-gray-200 hover:border-indigo-400'}"
|
||||||
|
>
|
||||||
|
{$t('booking.nonEmployee')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if bookFor === 'other'}
|
||||||
|
<div class="mt-2 relative">
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400">
|
||||||
|
<Search size={16} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
on:focus={() => 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"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400">
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showUserDropdown && (searchQuery || users.length > 0)}
|
||||||
|
<div class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-xl max-h-48 overflow-y-auto">
|
||||||
|
{#if filteredUsers.length === 0}
|
||||||
|
<div class="p-3 text-sm text-gray-500 text-center">
|
||||||
|
{$t('booking.noUsersFound')}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each filteredUsers as userItem}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={() => selectUser(userItem)}
|
||||||
|
class="w-full text-left p-3 hover:bg-indigo-50 transition-colors border-b border-gray-50 last:border-0"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-bold text-gray-800">{userItem.name || userItem.email}</p>
|
||||||
|
<p class="text-xs text-gray-500">{userItem.email}</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showUserDropdown}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-10"
|
||||||
|
on:click={() => showUserDropdown = false}
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="block text-xs font-bold text-gray-500 mb-1">{$t('booking.note')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={note}
|
||||||
|
placeholder={$t('booking.notePlaceholder')}
|
||||||
|
class="w-full p-2.5 bg-white border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-bold text-gray-700 mb-1 flex items-center gap-2">
|
<label class="block text-sm font-bold text-gray-700 mb-1 flex items-center gap-2">
|
||||||
<Calendar size={16} />
|
<Calendar size={16} />
|
||||||
|
|
@ -311,7 +460,7 @@
|
||||||
reservation ? 'bg-red-100 text-red-700 border-red-200' :
|
reservation ? 'bg-red-100 text-red-700 border-red-200' :
|
||||||
startTime === time ? 'bg-indigo-600 text-white border-indigo-700 ring-2 ring-indigo-200' :
|
startTime === time ? 'bg-indigo-600 text-white border-indigo-700 ring-2 ring-indigo-200' :
|
||||||
'bg-white text-gray-600 border-gray-200 hover:border-indigo-400'}"
|
'bg-white text-gray-600 border-gray-200 hover:border-indigo-400'}"
|
||||||
title={reservation?.isPast ? $t('booking.past') : reservation ? $t('booking.bookedBy', { values: { user: reservation.user.email } }) : $t('booking.availableAt', { values: { time: time } })}
|
title={reservation?.isPast ? $t('booking.past') : reservation ? $t('booking.bookedBy', { values: { user: reservation.user?.email || reservation.note || $t('booking.nonEmployee') } }) : $t('booking.availableAt', { values: { time: time } })}
|
||||||
>
|
>
|
||||||
{time}
|
{time}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,15 @@
|
||||||
"errorTime": "End time must be after start time",
|
"errorTime": "End time must be after start time",
|
||||||
"errorPast": "Cannot book in the past",
|
"errorPast": "Cannot book in the past",
|
||||||
"past": "This time has already passed",
|
"past": "This time has already passed",
|
||||||
|
"bookFor": "Book for",
|
||||||
|
"myself": "Myself",
|
||||||
|
"someoneElse": "Someone else / Event",
|
||||||
|
"selectUser": "Select user",
|
||||||
|
"nonEmployee": "Non-employee / Event (no user)",
|
||||||
|
"note": "Note",
|
||||||
|
"notePlaceholder": "Reason for booking, name of guest, event name, etc.",
|
||||||
|
"searchUserPlaceholder": "Search by name or email...",
|
||||||
|
"noUsersFound": "No users found",
|
||||||
"spaceType": {
|
"spaceType": {
|
||||||
"DESK": "Desk",
|
"DESK": "Desk",
|
||||||
"MEETING_ROOM": "Meeting Room",
|
"MEETING_ROOM": "Meeting Room",
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,15 @@
|
||||||
"errorTime": "La hora de finalización debe ser posterior a la de inicio",
|
"errorTime": "La hora de finalización debe ser posterior a la de inicio",
|
||||||
"errorPast": "No se puede reservar en el pasado",
|
"errorPast": "No se puede reservar en el pasado",
|
||||||
"past": "Esta hora ya ha pasado",
|
"past": "Esta hora ya ha pasado",
|
||||||
|
"bookFor": "Reservar para",
|
||||||
|
"myself": "Mí mismo/a",
|
||||||
|
"someoneElse": "Otra persona / Evento",
|
||||||
|
"selectUser": "Seleccionar usuario",
|
||||||
|
"nonEmployee": "No empleado / Evento (sin usuario)",
|
||||||
|
"note": "Nota",
|
||||||
|
"notePlaceholder": "Motivo de la reserva, nombre del invitado, nombre del evento, etc.",
|
||||||
|
"searchUserPlaceholder": "Buscar por nombre o email...",
|
||||||
|
"noUsersFound": "No se encontraron usuarios",
|
||||||
"spaceType": {
|
"spaceType": {
|
||||||
"DESK": "Puesto",
|
"DESK": "Puesto",
|
||||||
"MEETING_ROOM": "Sala de reuniones",
|
"MEETING_ROOM": "Sala de reuniones",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue