Add "My Bookings" page and enable reservation updates and cancellations

- Implement `+page.svelte` for managing user reservations with real-time updates.
- Add API endpoints for updating and canceling reservations with enhanced validation and authorization.
- Introduce `UpdateReservationDto` for refined reservation update handling.
- Update `reservations.service.ts` to handle overlapping checks, time adjustments, and admin actions.
- Extend layout to include a link to the "My Bookings" page.
- Add `date-fns` library for date formatting.
main
Pau Costa Ferrer 2026-03-28 16:16:27 +01:00
parent b3d7e4195f
commit 165cd76b6f
9 changed files with 529 additions and 39 deletions

View File

@ -4,10 +4,12 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add admin reporting dashboard with utilization statistics and downloadable reports&#10;&#10;- Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats.&#10;- Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS).&#10;- Create `ReportsService` and `ReportsModule` for backend logic.&#10;- Introduce `xlsx` package for generating Excel reports.&#10;- Update layout to include &quot;Reports&quot; link for admin users.&#10;- Extend Prisma querying for detailed building, space type, and employee role data.">
<change beforePath="$PROJECT_DIR$/apps/api/src/app.module.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/app.module.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/auth/auth.controller.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/auth/auth.controller.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/auth/auth.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/auth/auth.service.ts" afterDir="false" />
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add user management and registration features with invite-based onboarding&#10;&#10;- Implement `+page.svelte` for admin user management, allowing role updates, password resets, and invite link generation.">
<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/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -129,7 +131,7 @@
<updated>1774643312599</updated>
<workItem from="1774643313611" duration="74000" />
<workItem from="1774643422240" duration="5278000" />
<workItem from="1774706361502" duration="4182000" />
<workItem from="1774706361502" duration="4540000" />
</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" />
@ -195,7 +197,15 @@
<option name="project" value="LOCAL" />
<updated>1774710366017</updated>
</task>
<option name="localTasksCounter" value="9" />
<task id="LOCAL-00009" summary="Add user management and registration features with invite-based onboarding&#10;&#10;- Implement `+page.svelte` for admin user management, allowing role updates, password resets, and invite link generation.&#10;- Add `+page.svelte` for user registration using invite tokens.&#10;-">
<option name="closed" value="true" />
<created>1774710670712</created>
<option name="number" value="00009" />
<option name="presentableId" value="LOCAL-00009" />
<option name="project" value="LOCAL" />
<updated>1774710670712</updated>
</task>
<option name="localTasksCounter" value="10" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@ -210,6 +220,7 @@
<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." />
<MESSAGE value="Add admin reporting dashboard with utilization statistics and downloadable reports&#10;&#10;- Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats.&#10;- Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS).&#10;- Create `ReportsService` and `ReportsModule` for backend logic.&#10;- Introduce `xlsx` package for generating Excel reports.&#10;- Update layout to include &quot;Reports&quot; link for admin users.&#10;- Extend Prisma querying for detailed building, space type, and employee role data." />
<option name="LAST_COMMIT_MESSAGE" value="Add admin reporting dashboard with utilization statistics and downloadable reports&#10;&#10;- Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats.&#10;- Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS).&#10;- Create `ReportsService` and `ReportsModule` for backend logic.&#10;- Introduce `xlsx` package for generating Excel reports.&#10;- Update layout to include &quot;Reports&quot; link for admin users.&#10;- Extend Prisma querying for detailed building, space type, and employee role data." />
<MESSAGE value="Add user management and registration features with invite-based onboarding&#10;&#10;- Implement `+page.svelte` for admin user management, allowing role updates, password resets, and invite link generation." />
<option name="LAST_COMMIT_MESSAGE" value="Add user management and registration features with invite-based onboarding&#10;&#10;- Implement `+page.svelte` for admin user management, allowing role updates, password resets, and invite link generation." />
</component>
</project>

View File

@ -0,0 +1,15 @@
import { IsString, IsDateString, IsOptional } from 'class-validator';
export class UpdateReservationDto {
@IsDateString()
@IsOptional()
startTime?: string;
@IsDateString()
@IsOptional()
endTime?: string;
@IsString()
@IsOptional()
spaceId?: string;
}

View File

@ -7,9 +7,11 @@ import {
Param,
UseGuards,
Req,
Patch,
} 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';
@Controller('reservations')
@ -22,19 +24,24 @@ export class ReservationsController {
return this.reservationsService.create(req.user.id, createReservationDto);
}
@Get('my')
findAllMy(@Req() req) {
return this.reservationsService.findAllByUserId(req.user.id);
}
@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);
@Patch(':id')
update(@Param('id') id: string, @Req() req, @Body() updateReservationDto: UpdateReservationDto) {
return this.reservationsService.update(req.user.id, id, updateReservationDto);
}
@Get('my')
findAllMy(@Req() req) {
return this.reservationsService.findAllByUserId(req.user.id);
@Delete(':id')
cancel(@Param('id') id: string, @Req() req) {
return this.reservationsService.cancel(req.user.id, id);
}
}

View File

@ -1,7 +1,8 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EventsGateway } from '../events/events.gateway';
import { CreateReservationDto } from './dto/create-reservation.dto';
import { UpdateReservationDto } from './dto/update-reservation.dto';
import { SpaceType } from '@prisma/client';
@Injectable()
@ -92,16 +93,36 @@ export class ReservationsService {
return reservation;
}
async cancel(reservationId: string) {
async cancel(userId: string, reservationId: string) {
const reservation = await this.prisma.reservation.findUnique({
where: { id: reservationId },
include: { space: true },
include: {
space: true,
user: {
select: {
role: true
}
}
},
});
if (!reservation) {
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');
}
await this.prisma.reservation.delete({
where: { id: reservationId },
});
@ -119,10 +140,136 @@ export class ReservationsService {
async findAllByUserId(userId: string) {
return this.prisma.reservation.findMany({
where: { userId },
include: { space: true },
include: {
space: {
include: {
floor: {
include: {
building: true,
},
},
},
},
},
orderBy: {
startTime: 'desc',
},
});
}
async update(userId: string, id: string, updateReservationDto: UpdateReservationDto) {
const reservation = await this.prisma.reservation.findUnique({
where: { id },
});
if (!reservation) {
throw new NotFoundException(`Reservation with ID ${id} not found`);
}
if (reservation.userId !== userId) {
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 space = await this.prisma.space.findUnique({
where: { id: spaceId },
});
if (!space) {
throw new NotFoundException(`Space with ID ${spaceId} not found`);
}
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) {
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
} else if (space.type === SpaceType.MEETING_ROOM) {
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,
id: { not: id },
OR: [
{ startTime: { lt: end }, endTime: { gt: start } },
],
},
});
if (existing) {
throw new BadRequestException('Space is already booked for the selected time');
}
const updated = await this.prisma.reservation.update({
where: { id },
data: {
startTime: start,
endTime: end,
spaceId,
},
include: {
user: {
select: {
id: true,
email: true,
role: true,
}
},
space: true
}
});
// Notify clients on the floor if space or time changed
if (reservation.spaceId !== updated.spaceId) {
// Notify old floor
const oldSpace = await this.prisma.space.findUnique({ where: { id: reservation.spaceId } });
if (oldSpace) {
this.eventsGateway.emitSpaceStatusChanged(oldSpace.floorId, reservation.spaceId, false);
}
// Notify new floor
this.eventsGateway.emitSpaceStatusChanged(space.floorId, spaceId, true, {
reservation: {
id: updated.id,
startTime: updated.startTime,
endTime: updated.endTime,
user: updated.user
}
});
} else {
// Just notify current floor
this.eventsGateway.emitSpaceStatusChanged(space.floorId, spaceId, true, {
reservation: {
id: updated.id,
startTime: updated.startTime,
endTime: updated.endTime,
user: updated.user
}
});
}
return updated;
}
async findAllBySpaceId(spaceId: string) {
return this.prisma.reservation.findMany({
where: { spaceId },

View File

@ -26,6 +26,7 @@
},
"type": "module",
"dependencies": {
"date-fns": "^4.1.0",
"import-meta-resolve": "^4.2.0",
"interactjs": "^1.10.27",
"socket.io-client": "^4.8.3"

View File

@ -1,7 +1,8 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { user } from '$lib/stores/auth';
import { apiFetch } from '$lib/api/client';
import { X, Calendar, Clock, Loader2 } from 'lucide-svelte';
import { X, Calendar, Clock, Loader2, Trash2, AlertTriangle } from 'lucide-svelte';
export let space: any;
export let status: any;
@ -36,8 +37,13 @@
let startTime = "07:00";
let endTime = "08:00";
let loading = false;
let deleting = false;
let confirmDelete = false;
let error = "";
$: isOwner = status.reservation?.userId === $user?.id;
$: isAdmin = $user?.role === 'ADMIN';
const timeOptions = [];
for (let h = 7; h <= 20; h++) {
for (let m = 0; m < 60; m += 15) {
@ -93,6 +99,28 @@
}
}
async function handleDelete(reservationId: string) {
if (isAdmin && !confirmDelete) {
confirmDelete = true;
return;
}
deleting = true;
error = "";
try {
await apiFetch(`/reservations/${reservationId}`, {
method: 'DELETE'
});
dispatch('close');
} catch (e: any) {
error = e.message || "Failed to delete booking";
confirmDelete = false;
} finally {
deleting = false;
}
}
function formatTime(dateStr: string) {
return new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
}
@ -152,17 +180,54 @@
{#if space.type === 'DESK' && status.isOccupied}
<div class="bg-yellow-50 border border-yellow-200 rounded-xl p-4 mb-6">
<p class="text-yellow-800 font-bold mb-1">Currently Booked</p>
<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">
{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-xs text-yellow-700">
{formatTime(status.reservation.startTime)} - {formatTime(status.reservation.endTime)}
</p>
<div class="flex items-center justify-between 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">
{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-xs text-yellow-700">
{formatTime(status.reservation.startTime)} - {formatTime(status.reservation.endTime)}
</p>
</div>
</div>
{#if isOwner || isAdmin}
<button
on:click={() => handleDelete(status.reservation.id)}
disabled={deleting}
class="p-2 text-red-600 hover:bg-red-100 rounded-lg transition-colors"
title="Delete Booking"
>
<Trash2 size={20} />
</button>
{/if}
</div>
{#if isAdmin && confirmDelete}
<div class="mt-4 p-3 bg-red-100 border border-red-200 rounded-lg">
<div class="flex items-center gap-2 text-red-800 text-sm font-bold mb-2">
<AlertTriangle size={16} />
Warning: Admin Action
</div>
<p class="text-xs text-red-700 mb-3">You are about to delete another user's booking. This action cannot be undone.</p>
<div class="flex gap-2">
<button
on:click={() => handleDelete(status.reservation.id)}
class="flex-1 py-2 bg-red-600 text-white text-xs font-bold rounded-md hover:bg-red-700"
>
Confirm Delete
</button>
<button
on:click={() => confirmDelete = false}
class="flex-1 py-2 bg-white text-gray-700 text-xs font-bold border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
{/if}
</div>
<p class="text-center text-gray-500 text-sm">This space is currently unavailable for booking.</p>
{:else}
@ -201,18 +266,29 @@
<div class="grid grid-cols-4 sm:grid-cols-6 gap-2 max-h-60 overflow-y-auto p-1 border rounded-xl bg-gray-50">
{#each timeOptions as time}
{@const reservation = isSlotBooked(time)}
<button
type="button"
on:click={() => selectSlot(time)}
disabled={!!reservation}
class="py-2 text-[10px] font-bold rounded-md transition-all border
{reservation ? 'bg-red-100 text-red-700 border-red-200 cursor-not-allowed' :
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 ? `Booked by ${reservation.user.email}` : `Available at ${time}`}
>
{time}
</button>
<div class="relative group">
<button
type="button"
on:click={() => selectSlot(time)}
disabled={!!reservation && reservation.userId !== $user?.id && $user?.role !== 'ADMIN'}
class="w-full py-2 text-[10px] font-bold rounded-md transition-all border
{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 ? `Booked by ${reservation.user.email}` : `Available at ${time}`}
>
{time}
</button>
{#if reservation && (reservation.userId === $user?.id || $user?.role === 'ADMIN')}
<button
on:click|stopPropagation={() => handleDelete(reservation.id)}
class="absolute -top-1 -right-1 p-0.5 bg-red-600 text-white rounded-full shadow-sm opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={10} />
</button>
{/if}
</div>
{/each}
</div>
{/if}

View File

@ -18,6 +18,7 @@
<a href="/admin/reports" class="text-gray-600 hover:text-indigo-600 font-medium">Reports</a>
{/if}
<a href="/dashboard" class="text-gray-600 hover:text-indigo-600 font-medium">Dashboard</a>
<a href="/dashboard/my-bookings" class="text-gray-600 hover:text-indigo-600 font-medium">My Bookings</a>
<a href="/login" class="bg-indigo-50 text-indigo-700 px-3 py-1.5 rounded-md hover:bg-indigo-100 font-medium flex items-center space-x-2">
<div class="w-5 h-5 bg-indigo-200 rounded-full text-[10px] flex items-center justify-center font-bold">{$user?.email?.charAt(0).toUpperCase()}</div>
<span>Profile</span>

View File

@ -0,0 +1,222 @@
<script lang="ts">
import { onMount } from "svelte";
import { token } from "$lib/stores/auth";
import { apiFetch } from "$lib/api/client";
import { Calendar, Clock, MapPin, Trash2, Edit2, ChevronRight, X } from "lucide-svelte";
import { format } from "date-fns";
let reservations = [];
let loading = true;
let error = null;
let editingReservation = null;
let editStartTime = "";
let editEndTime = "";
let saving = false;
onMount(async () => {
await fetchReservations();
});
async function fetchReservations() {
loading = true;
try {
reservations = await apiFetch("/reservations/my");
} catch (e) {
error = "Failed to load bookings";
console.error(e);
} finally {
loading = false;
}
}
async function deleteReservation(id) {
if (!confirm("Are you sure you want to cancel this booking?")) return;
try {
await apiFetch(`/reservations/${id}`, { method: "DELETE" });
reservations = reservations.filter(r => r.id !== id);
} catch (e) {
alert("Failed to cancel booking");
}
}
function openEditModal(res) {
editingReservation = res;
// Format for datetime-local input
editStartTime = format(new Date(res.startTime), "yyyy-MM-dd'T'HH:mm");
editEndTime = format(new Date(res.endTime), "yyyy-MM-dd'T'HH:mm");
}
function closeEditModal() {
editingReservation = null;
}
async function handleUpdate() {
saving = true;
try {
await apiFetch(`/reservations/${editingReservation.id}`, {
method: "PATCH",
body: JSON.stringify({
startTime: new Date(editStartTime).toISOString(),
endTime: new Date(editEndTime).toISOString(),
})
});
await fetchReservations();
closeEditModal();
} catch (e: any) {
alert(e.message || "Failed to update booking");
} finally {
saving = false;
}
}
function getSpaceTypeLabel(type) {
switch (type) {
case 'DESK': return 'Desk';
case 'MEETING_ROOM': return 'Meeting Room';
default: return type;
}
}
</script>
<div class="max-w-4xl mx-auto">
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-900">My Bookings</h1>
<p class="text-gray-500 mt-1">Manage your upcoming and past reservations.</p>
</div>
</div>
{#if loading && !reservations.length}
<div class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
{:else if error}
<div class="bg-red-50 text-red-700 p-4 rounded-lg border border-red-200">
{error}
</div>
{:else if reservations.length === 0}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
<Calendar class="mx-auto text-gray-300 mb-4" size={48} />
<h3 class="text-lg font-medium text-gray-900">No bookings found</h3>
<p class="text-gray-500 mt-1">You haven't made any reservations yet.</p>
<a href="/dashboard" class="mt-6 inline-flex items-center text-indigo-600 font-medium hover:underline">
Go to dashboard to book a space <ChevronRight size={16} />
</a>
</div>
{:else}
<div class="space-y-4">
{#each reservations as res}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:border-indigo-300 transition-colors">
<div class="p-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex items-start gap-4">
<div class="w-12 h-12 {res.space.type === 'DESK' ? 'bg-blue-50 text-blue-600' : 'bg-purple-50 text-purple-600'} rounded-lg flex items-center justify-center flex-shrink-0">
{#if res.space.type === 'DESK'}
<MapPin size={24} />
{:else}
<Clock size={24} />
{/if}
</div>
<div>
<h3 class="text-lg font-bold text-gray-900">{res.space.name}</h3>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1 text-sm text-gray-500">
<span class="flex items-center gap-1">
<MapPin size={14} />
{res.space.floor.building.name}, Floor {res.space.floor.number}
</span>
<span class="flex items-center gap-1">
<Calendar size={14} />
{format(new Date(res.startTime), "MMM d, yyyy")}
</span>
<span class="flex items-center gap-1">
<Clock size={14} />
{format(new Date(res.startTime), "HH:mm")} - {format(new Date(res.endTime), "HH:mm")}
</span>
</div>
</div>
</div>
<div class="flex items-center gap-2 border-t md:border-t-0 pt-4 md:pt-0">
<button
on:click={() => openEditModal(res)}
class="p-2 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors"
title="Edit booking"
>
<Edit2 size={20} />
</button>
<button
on:click={() => deleteReservation(res.id)}
class="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Cancel booking"
>
<Trash2 size={20} />
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{#if editingReservation}
<div class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-2xl shadow-xl max-w-md w-full overflow-hidden">
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
<h3 class="text-xl font-bold text-gray-900">Edit Booking</h3>
<button on:click={closeEditModal} class="text-gray-400 hover:text-gray-600">
<X size={24} />
</button>
</div>
<div class="p-6 space-y-4">
<div class="bg-gray-50 p-4 rounded-xl mb-4">
<p class="text-sm font-medium text-gray-500 uppercase tracking-wider">Space</p>
<p class="text-lg font-bold text-gray-900">{editingReservation.space.name}</p>
<p class="text-sm text-gray-500">{editingReservation.space.floor.building.name}, Floor {editingReservation.space.floor.number}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Start Time</label>
<input
type="datetime-local"
bind:value={editStartTime}
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">End Time</label>
<input
type="datetime-local"
bind:value={editEndTime}
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
{#if editingReservation.space.type === 'DESK'}
<p class="text-xs text-amber-600 bg-amber-50 p-2 rounded">
Note: Desk bookings are full-day. Times will be automatically adjusted to encompass the entire day.
</p>
{/if}
</div>
<div class="p-6 bg-gray-50 flex gap-3">
<button
on:click={closeEditModal}
class="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-100 transition-colors"
>
Cancel
</button>
<button
on:click={handleUpdate}
disabled={saving}
class="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div>
{/if}

10
package-lock.json generated
View File

@ -65,6 +65,7 @@
"apps/web": {
"version": "0.0.1",
"dependencies": {
"date-fns": "^4.1.0",
"import-meta-resolve": "^4.2.0",
"interactjs": "^1.10.27",
"socket.io-client": "^4.8.3"
@ -5630,6 +5631,15 @@
"node": ">=4"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",