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
parent
b3d7e4195f
commit
165cd76b6f
|
|
@ -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 - Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats. - Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS). - Create `ReportsService` and `ReportsModule` for backend logic. - Introduce `xlsx` package for generating Excel reports. - Update layout to include "Reports" link for admin users. - 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 - 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 - 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" />
|
||||
|
|
@ -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 - Implement `+page.svelte` for admin user management, allowing role updates, password resets, and invite link generation. - Add `+page.svelte` for user registration using invite tokens. -">
|
||||
<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 - 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 admin reporting dashboard with utilization statistics and downloadable reports - Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats. - Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS). - Create `ReportsService` and `ReportsModule` for backend logic. - Introduce `xlsx` package for generating Excel reports. - Update layout to include "Reports" link for admin users. - 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 - Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats. - Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS). - Create `ReportsService` and `ReportsModule` for backend logic. - Introduce `xlsx` package for generating Excel reports. - Update layout to include "Reports" link for admin users. - Extend Prisma querying for detailed building, space type, and employee role data." />
|
||||
<MESSAGE value="Add user management and registration features with invite-based onboarding - 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 - Implement `+page.svelte` for admin user management, allowing role updates, password resets, and invite link generation." />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue