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
Pau Costa Ferrer 2026-03-28 18:26:47 +01:00
parent 0b6a043f19
commit 604d4c7244
9 changed files with 279 additions and 42 deletions

View File

@ -4,10 +4,16 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<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&#10;&#10;- Add logic to clear real-time updates when the selected date changes.&#10;- Enhance space status updates to consider reservation time ranges for relevance.&#10;- Update relevant Svelte components (`SpaceCard`, `FloorplanViewer`) to handle date-sensitive real-time updates.&#10;- 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&#10;&#10;- Add validation logic to block creating or updating reservations in the past.&#10;- Update date pickers to enforce minimum selectable date as today.&#10;- Extend i18n files (`en.json`, `es-ES.json`) with new error messages.&#10;- 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/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>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -129,7 +135,7 @@
<updated>1774643312599</updated>
<workItem from="1774643313611" duration="74000" />
<workItem from="1774643422240" duration="5278000" />
<workItem from="1774706361502" duration="11708000" />
<workItem from="1774706361502" duration="12417000" />
</task>
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services &#10;&#10;- Set up SvelteKit for the web interface with Tailwind CSS. &#10;- Build the API using NestJS with Prisma ORM for database interaction. &#10;- Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. &#10;- Create shared data models for consistent typing across services. &#10;- Establish basic routing and layouts for web and API services.">
<option name="closed" value="true" />
@ -315,17 +321,37 @@
<option name="project" value="LOCAL" />
<updated>1774717937214</updated>
</task>
<option name="localTasksCounter" value="24" />
<task id="LOCAL-00024" summary="Add filtering for past and upcoming bookings in &quot;My Bookings&quot; page&#10;&#10;- Enhance UI to separate upcoming and past bookings with collapsible sections.&#10;- Add new i18n keys for handling booking filters (`en.json`, `es-ES.json`).&#10;- Update booking component logic to filter reservations based on time.&#10;- Introduce &quot;Show/Hide Past Bookings&quot; 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&#10;&#10;- Add `showPicker` event to trigger native date picker overlays.&#10;- Adjust z-index and positioning for hidden `input` elements to improve interaction.&#10;- 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&#10;&#10;- Add validation logic to block creating or updating reservations in the past.&#10;- Update date pickers to enforce minimum selectable date as today.&#10;- Extend i18n files (`en.json`, `es-ES.json`) with new error messages.&#10;- 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 />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="Initialize Hot Desking app with web and API services &#10;&#10;- Set up SvelteKit for the web interface with Tailwind CSS. &#10;- Build the API using NestJS with Prisma ORM for database interaction. &#10;- Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. &#10;- Create shared data models for consistent typing across services. &#10;- Establish basic routing and layouts for web and API services." />
<MESSAGE value="Remove compiled API files from version control&#10;&#10;- Deleted compiled JavaScript files, type definitions, and source maps from the `dist` folder." />
<MESSAGE value="Implement authentication, organization management, and floor layout features&#10;&#10;- Add API authentication with JWT strategy using NestJS.&#10;- Configure Prisma schema for organizations, users, buildings, floors, and spaces.&#10;- Seed initial database with test data and relationships.&#10;- Build SvelteKit web pages for dashboard and floor layout with secured routing and organization hierarchy display.&#10;- Remove deprecated `AppService` from API." />
<MESSAGE value="Add floorplan editor for admin layout management&#10;&#10;- Implement `FloorplanEditor.svelte` component for adding, editing, and positioning spaces.&#10;- Add backend support for floorplan image upload and batch updating space positions.&#10;- Update Prisma schema to include `planImageUrl` for floors.&#10;- Enable API proxy in Vite configuration for straightforward local development.&#10;- Refactor API calls to use a centralized `apiFetch` utility with token handling." />
<MESSAGE value="Add building-level dashboard and interactive floorplan viewer&#10;&#10;- Create `+page.svelte` for building-level dashboard with floor selection and layout preview.&#10;- Add `FloorplanViewer.svelte` component with WebSocket integration for real-time space status updates.&#10;- Extend `SpacesService` and API endpoints for building floors and floor details retrieval.&#10;- Update dashboard UI with cleaner building and floor presentation.&#10;- Improve loading states and error handling across dashboard and floor pages." />
<MESSAGE value="Add booking modal and enhance reservation handling&#10;&#10;- Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations.&#10;- Add API endpoint to fetch reservations by space ID and date.&#10;- Update `SpacesService` with enhanced validation rules and conflict checks for reservations.&#10;- Extend WebSocket updates to include reservation data for real-time synchronization.&#10;- 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&#10;&#10;- Update `SpacesService` to handle width and height properties for spaces.&#10;- Introduce `UpdateSpaceDto` for improved validation in batch update operations.&#10;- Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing.&#10;- Modify Prisma schema to include `width` and `height` fields for spaces.&#10;- 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&#10;&#10;- Extend English (`en.json`) and Spanish (`es-ES.json`) locale files with additional keys for buildings, bookings, reports, and user management. &#10;- Update Svelte components and pages to use `svelte-i18n` for dynamic translations across the app. &#10;- Refactor labels, titles, and error messages for multilingual support. &#10;- Improve date formatting based on locale preferences." />
<MESSAGE value="Add seed service to initialize default admin and organization&#10;&#10;- Implement `SeedService` to create a default admin account and organization on app startup.&#10;- Update `PrismaModule` to include `SeedService` as a provider.&#10;- Remove seeded credentials display from login page.&#10;- Refactor existing seed script for consistency with the new service." />
<MESSAGE value="Improve real-time space status handling and date-based resets&#10;&#10;- Add logic to clear real-time updates when the selected date changes.&#10;- Enhance space status updates to consider reservation time ranges for relevance.&#10;- Update relevant Svelte components (`SpaceCard`, `FloorplanViewer`) to handle date-sensitive real-time updates.&#10;- 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&#10;&#10;- Add logic to clear real-time updates when the selected date changes.&#10;- Enhance space status updates to consider reservation time ranges for relevance.&#10;- Update relevant Svelte components (`SpaceCard`, `FloorplanViewer`) to handle date-sensitive real-time updates.&#10;- 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&#10;&#10;- Introduce filters to separate and toggle display of past and upcoming reservations.&#10;- Enhance localization with new translations for filter-related actions in English and Spanish.&#10;- Update My Bookings UI to dynamically display reservations based on their timeline.&#10;- Improve reservation display logic using `isPast` from `date-fns`." />
<MESSAGE value="Add filtering for past and upcoming bookings in &quot;My Bookings&quot; page&#10;&#10;- Enhance UI to separate upcoming and past bookings with collapsible sections.&#10;- Add new i18n keys for handling booking filters (`en.json`, `es-ES.json`).&#10;- Update booking component logic to filter reservations based on time.&#10;- Introduce &quot;Show/Hide Past Bookings&quot; toggle for improved user experience." />
<MESSAGE value="Refactor date picker components for improved accessibility and usability&#10;&#10;- Add `showPicker` event to trigger native date picker overlays.&#10;- Adjust z-index and positioning for hidden `input` elements to improve interaction.&#10;- Reorganize markup for consistent focus and selection behavior across components." />
<MESSAGE value="Prevent past bookings and updates across web and API&#10;&#10;- Add validation logic to block creating or updating reservations in the past.&#10;- Update date pickers to enforce minimum selectable date as today.&#10;- Extend i18n files (`en.json`, `es-ES.json`) with new error messages.&#10;- 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&#10;&#10;- Add validation logic to block creating or updating reservations in the past.&#10;- Update date pickers to enforce minimum selectable date as today.&#10;- Extend i18n files (`en.json`, `es-ES.json`) with new error messages.&#10;- Refine UI to disable interaction with past time slots and display appropriate messages." />
</component>
</project>

View File

@ -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 {

View File

@ -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;
}

View File

@ -12,4 +12,12 @@ export class UpdateReservationDto {
@IsString()
@IsOptional()
spaceId?: string;
@IsString()
@IsOptional()
userId?: string;
@IsString()
@IsOptional()
note?: string;
}

View File

@ -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);
}
}

View File

@ -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) {
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: {

View File

@ -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 @@
<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 gap-3">
{#if status.reservation?.user}
<div class="w-10 h-10 rounded-full bg-yellow-200 flex items-center justify-center text-yellow-700 font-bold">
{status.reservation?.user?.email?.charAt(0).toUpperCase()}
{status.reservation.user.email?.charAt(0).toUpperCase()}
</div>
<div>
<p class="text-sm font-medium text-yellow-900">{status.reservation?.user?.email}</p>
<p class="text-sm font-medium text-yellow-900">{status.reservation.user.email}</p>
{#if status.reservation.note}
<p class="text-xs text-yellow-800 italic">"{status.reservation.note}"</p>
{/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>
{#if isOwner || isAdmin}
@ -266,6 +321,100 @@
{/if}
<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>
<label class="block text-sm font-bold text-gray-700 mb-1 flex items-center gap-2">
<Calendar size={16} />
@ -311,7 +460,7 @@
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' :
'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}
</button>

View File

@ -79,6 +79,15 @@
"errorTime": "End time must be after start time",
"errorPast": "Cannot book in the past",
"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": {
"DESK": "Desk",
"MEETING_ROOM": "Meeting Room",

View File

@ -79,6 +79,15 @@
"errorTime": "La hora de finalización debe ser posterior a la de inicio",
"errorPast": "No se puede reservar en el 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": {
"DESK": "Puesto",
"MEETING_ROOM": "Sala de reuniones",