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`).main
parent
f8af8cf2d7
commit
45e42c3d37
|
|
@ -4,11 +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="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.">
|
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="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.">
|
||||||
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/api/src/events/events.gateway.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/events/events.gateway.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/api/src/spaces/spaces.controller.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/spaces/spaces.controller.ts" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/apps/api/src/spaces/spaces.controller.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/spaces/spaces.controller.ts" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/apps/api/src/spaces/spaces.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/spaces/spaces.service.ts" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/apps/api/src/spaces/spaces.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/spaces/spaces.service.ts" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/admin/FloorplanEditor.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/admin/FloorplanEditor.svelte" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/FloorplanViewer.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/FloorplanViewer.svelte" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/+page.svelte" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/SpaceCard.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/SpaceCard.svelte" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/building/[id]/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/building/[id]/+page.svelte" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/floor/[id]/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/floor/[id]/+page.svelte" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/floor/[id]/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/floor/[id]/+page.svelte" afterDir="false" />
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
|
|
@ -128,7 +133,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="1235000" />
|
<workItem from="1774706361502" duration="1946000" />
|
||||||
</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" />
|
||||||
|
|
@ -162,7 +167,15 @@
|
||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1774707328435</updated>
|
<updated>1774707328435</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="5" />
|
<task id="LOCAL-00005" summary="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.">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1774707617715</created>
|
||||||
|
<option name="number" value="00005" />
|
||||||
|
<option name="presentableId" value="LOCAL-00005" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1774707617715</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="6" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
|
|
@ -173,6 +186,7 @@
|
||||||
<MESSAGE value="Remove compiled API files from version control - Deleted compiled JavaScript files, type definitions, and source maps from the `dist` folder." />
|
<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="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 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." />
|
||||||
<option name="LAST_COMMIT_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." />
|
||||||
|
<option name="LAST_COMMIT_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." />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
|
@ -36,10 +36,11 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
console.log(`Client ${client.id} left floor: ${floorId}`);
|
console.log(`Client ${client.id} left floor: ${floorId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
emitSpaceStatusChanged(floorId: string, spaceId: string, isOccupied: boolean) {
|
emitSpaceStatusChanged(floorId: string, spaceId: string, isOccupied: boolean, reservationData?: any) {
|
||||||
this.server.to(floorId).emit('space_status_changed', {
|
this.server.to(floorId).emit('space_status_changed', {
|
||||||
spaceId,
|
spaceId,
|
||||||
isOccupied,
|
isOccupied,
|
||||||
|
...reservationData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ export class ReservationsController {
|
||||||
return this.reservationsService.create(req.user.id, createReservationDto);
|
return this.reservationsService.create(req.user.id, createReservationDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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')
|
@Delete(':id')
|
||||||
cancel(@Param('id') id: string) {
|
cancel(@Param('id') id: string) {
|
||||||
return this.reservationsService.cancel(id);
|
return this.reservationsService.cancel(id);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { EventsGateway } from '../events/events.gateway';
|
import { EventsGateway } from '../events/events.gateway';
|
||||||
import { CreateReservationDto } from './dto/create-reservation.dto';
|
import { CreateReservationDto } from './dto/create-reservation.dto';
|
||||||
|
import { SpaceType } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReservationsService {
|
export class ReservationsService {
|
||||||
|
|
@ -21,17 +22,72 @@ export class ReservationsService {
|
||||||
throw new NotFoundException(`Space with ID ${spaceId} not found`);
|
throw new NotFoundException(`Space with ID ${spaceId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const reservation = await this.prisma.reservation.create({
|
const start = new Date(startTime);
|
||||||
data: {
|
const end = new Date(endTime);
|
||||||
startTime: new Date(startTime),
|
|
||||||
endTime: new Date(endTime),
|
// Validation
|
||||||
userId,
|
if (start >= end) {
|
||||||
|
throw new BadRequestException('Start time must be before end time');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules
|
||||||
|
if (space.type === SpaceType.DESK) {
|
||||||
|
// Desks: Full day (setting to midnight of start and end of that day)
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
} else if (space.type === SpaceType.MEETING_ROOM) {
|
||||||
|
// Meeting rooms: 15-min increments, between 07:00 and 20:00
|
||||||
|
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,
|
spaceId,
|
||||||
|
OR: [
|
||||||
|
{ startTime: { lt: end }, endTime: { gt: start } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new BadRequestException('Space is already booked for the selected time');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservation = await this.prisma.reservation.create({
|
||||||
|
data: {
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
userId,
|
||||||
|
spaceId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Notify clients on the floor
|
// Notify clients on the floor
|
||||||
this.eventsGateway.emitSpaceStatusChanged(space.floorId, spaceId, true);
|
this.eventsGateway.emitSpaceStatusChanged(space.floorId, spaceId, true, {
|
||||||
|
reservation: {
|
||||||
|
id: reservation.id,
|
||||||
|
startTime: reservation.startTime,
|
||||||
|
endTime: reservation.endTime,
|
||||||
|
user: reservation.user
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return reservation;
|
return reservation;
|
||||||
}
|
}
|
||||||
|
|
@ -66,4 +122,22 @@ export class ReservationsService {
|
||||||
include: { space: true },
|
include: { space: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findAllBySpaceId(spaceId: string) {
|
||||||
|
return this.prisma.reservation.findMany({
|
||||||
|
where: { spaceId },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
startTime: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Controller, Get, Post, Body, Param, UseGuards, Request, Patch } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Param, UseGuards, Request, Patch, Query } from '@nestjs/common';
|
||||||
import { SpacesService } from './spaces.service';
|
import { SpacesService } from './spaces.service';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
|
@ -11,8 +11,8 @@ export class SpacesController {
|
||||||
constructor(private readonly spacesService: SpacesService) {}
|
constructor(private readonly spacesService: SpacesService) {}
|
||||||
|
|
||||||
@Get('floor/:id')
|
@Get('floor/:id')
|
||||||
async getFloorLayout(@Param('id') id: string) {
|
async getFloorLayout(@Param('id') id: string, @Query('date') date?: string) {
|
||||||
return this.spacesService.getFloorLayout(id);
|
return this.spacesService.getFloorLayout(id, date ? new Date(date) : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('building/:id')
|
@Get('building/:id')
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export class SpacesService {
|
||||||
|
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async getFloorLayout(floorId: string) {
|
async getFloorLayout(floorId: string, date: Date = new Date()) {
|
||||||
return this.prisma.floor.findUnique({
|
return this.prisma.floor.findUnique({
|
||||||
where: { id: floorId },
|
where: { id: floorId },
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -16,10 +16,19 @@ export class SpacesService {
|
||||||
reservations: {
|
reservations: {
|
||||||
where: {
|
where: {
|
||||||
endTime: {
|
endTime: {
|
||||||
gte: new Date(),
|
gte: date,
|
||||||
},
|
},
|
||||||
startTime: {
|
startTime: {
|
||||||
lte: new Date(),
|
lte: date,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { apiFetch } from '$lib/api/client';
|
||||||
|
import { X, Calendar, Clock, Loader2 } from 'lucide-svelte';
|
||||||
|
|
||||||
|
export let space: any;
|
||||||
|
export let status: any;
|
||||||
|
export let selectedDate: Date = new Date();
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
let bookingDate = selectedDate.toISOString().split('T')[0];
|
||||||
|
let reservations: any[] = [];
|
||||||
|
let loadingReservations = false;
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (selectedDate) {
|
||||||
|
bookingDate = selectedDate.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (space && space.type === 'MEETING_ROOM') {
|
||||||
|
fetchReservations();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchReservations() {
|
||||||
|
loadingReservations = true;
|
||||||
|
try {
|
||||||
|
reservations = await apiFetch(`/reservations/space/${space.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch reservations", e);
|
||||||
|
} finally {
|
||||||
|
loadingReservations = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let startTime = "07:00";
|
||||||
|
let endTime = "08:00";
|
||||||
|
let loading = false;
|
||||||
|
let error = "";
|
||||||
|
|
||||||
|
const timeOptions = [];
|
||||||
|
for (let h = 7; h <= 20; h++) {
|
||||||
|
for (let m = 0; m < 60; m += 15) {
|
||||||
|
if (h === 20 && m > 0) break;
|
||||||
|
const hh = h.toString().padStart(2, '0');
|
||||||
|
const mm = m.toString().padStart(2, '0');
|
||||||
|
timeOptions.push(`${hh}:${mm}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBook() {
|
||||||
|
loading = true;
|
||||||
|
error = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
let start, end;
|
||||||
|
if (space.type === 'DESK') {
|
||||||
|
start = new Date(bookingDate);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
end = new Date(bookingDate);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
} else {
|
||||||
|
const [sh, sm] = startTime.split(':').map(Number);
|
||||||
|
const [eh, em] = endTime.split(':').map(Number);
|
||||||
|
|
||||||
|
start = new Date(bookingDate);
|
||||||
|
start.setHours(sh, sm, 0, 0);
|
||||||
|
|
||||||
|
end = new Date(bookingDate);
|
||||||
|
end.setHours(eh, em, 0, 0);
|
||||||
|
|
||||||
|
if (end <= start) {
|
||||||
|
error = "End time must be after start time";
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiFetch('/reservations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
spaceId: space.id,
|
||||||
|
startTime: start.toISOString(),
|
||||||
|
endTime: end.toISOString(),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch('close');
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message || "Failed to create booking";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
$: dayReservations = reservations.filter(r => {
|
||||||
|
const rDate = new Date(r.startTime).toISOString().split('T')[0];
|
||||||
|
return rDate === bookingDate;
|
||||||
|
});
|
||||||
|
|
||||||
|
$: isSlotBooked = (time: string) => {
|
||||||
|
const [h, m] = time.split(':').map(Number);
|
||||||
|
const slotStart = new Date(bookingDate);
|
||||||
|
slotStart.setHours(h, m, 0, 0);
|
||||||
|
|
||||||
|
return dayReservations.find(r => {
|
||||||
|
const rStart = new Date(r.startTime);
|
||||||
|
const rEnd = new Date(r.endTime);
|
||||||
|
return slotStart >= rStart && slotStart < rEnd;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function selectSlot(time: string) {
|
||||||
|
if (isSlotBooked(time)) return;
|
||||||
|
startTime = time;
|
||||||
|
// Default end time to 15 mins later, capping at 20:00
|
||||||
|
const [h, m] = time.split(':').map(Number);
|
||||||
|
const end = new Date();
|
||||||
|
end.setHours(h, m + 15);
|
||||||
|
|
||||||
|
let eh = end.getHours();
|
||||||
|
let em = end.getMinutes();
|
||||||
|
|
||||||
|
if (eh > 20 || (eh === 20 && em > 0)) {
|
||||||
|
eh = 20;
|
||||||
|
em = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ehStr = eh.toString().padStart(2, '0');
|
||||||
|
const emStr = em.toString().padStart(2, '0');
|
||||||
|
endTime = `${ehStr}:${emStr}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||||
|
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
|
||||||
|
<div class="p-6 border-b flex justify-between items-center bg-gray-50">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900">{space.name}</h3>
|
||||||
|
<p class="text-sm text-gray-500 uppercase tracking-wider font-semibold">{space.type.replace('_', ' ')}</p>
|
||||||
|
</div>
|
||||||
|
<button on:click={() => dispatch('close')} class="p-2 hover:bg-gray-200 rounded-full transition-colors text-gray-400">
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
{#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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-center text-gray-500 text-sm">This space is currently unavailable for booking.</p>
|
||||||
|
{:else}
|
||||||
|
{#if error}
|
||||||
|
<div class="bg-red-50 text-red-600 p-3 rounded-lg mb-4 text-sm font-medium border border-red-100">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-bold text-gray-700 mb-1 flex items-center gap-2">
|
||||||
|
<Calendar size={16} />
|
||||||
|
Booking Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
bind:value={bookingDate}
|
||||||
|
min={new Date().toISOString().split('T')[0]}
|
||||||
|
class="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if space.type === 'MEETING_ROOM'}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<label class="block text-sm font-bold text-gray-700 mb-1 flex items-center gap-2">
|
||||||
|
<Clock size={16} />
|
||||||
|
Availability for {bookingDate}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if loadingReservations}
|
||||||
|
<div class="flex justify-center py-8">
|
||||||
|
<Loader2 class="animate-spin text-indigo-500" size={32} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<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>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-bold text-gray-700 mb-1">
|
||||||
|
Start Time
|
||||||
|
</label>
|
||||||
|
<select bind:value={startTime} class="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none font-medium">
|
||||||
|
{#each timeOptions as time}
|
||||||
|
<option value={time}>{time}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-bold text-gray-700 mb-1">
|
||||||
|
End Time
|
||||||
|
</label>
|
||||||
|
<select bind:value={endTime} class="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none font-medium">
|
||||||
|
{#each timeOptions as time}
|
||||||
|
<option value={time}>{time}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-indigo-50 border border-indigo-100 rounded-xl p-4">
|
||||||
|
<p class="text-indigo-800 text-sm font-medium">Desks are booked for the full day.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
on:click={handleBook}
|
||||||
|
disabled={loading}
|
||||||
|
class="w-full py-4 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-colors shadow-lg shadow-indigo-100 flex items-center justify-center gap-2 disabled:opacity-50 mt-4"
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<Loader2 class="animate-spin" size={20} />
|
||||||
|
Booking...
|
||||||
|
{:else}
|
||||||
|
Confirm Booking
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -1,23 +1,36 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { io, Socket } from 'socket.io-client';
|
import { io, Socket } from 'socket.io-client';
|
||||||
import { Laptop, Bath, Coffee, Users } from 'lucide-svelte';
|
import { Laptop, Bath, Coffee, Users, AlertCircle } from 'lucide-svelte';
|
||||||
|
import BookingModal from './BookingModal.svelte';
|
||||||
|
|
||||||
export let floorId: string;
|
export let floorId: string;
|
||||||
export let spaces: any[] = [];
|
export let spaces: any[] = [];
|
||||||
export let planImageUrl: string | null = null;
|
export let planImageUrl: string | null = null;
|
||||||
|
export let selectedDate: Date = new Date();
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let socket: Socket;
|
let socket: Socket;
|
||||||
let spaceStatus: Record<string, boolean> = {};
|
let spaceStatus: Record<string, any> = {};
|
||||||
|
let selectedSpace: any = null;
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
// Initialize space status from the current state
|
// Initialize space status from the current state
|
||||||
spaces.forEach(s => {
|
spaces.forEach(s => {
|
||||||
spaceStatus[s.id] = s.reservations && s.reservations.length > 0;
|
// For desks, we check if there's any reservation for the selected date
|
||||||
|
// For meeting rooms, we check if there's a reservation overlapping with the current/selected time
|
||||||
|
const activeReservation = s.reservations && s.reservations.length > 0 ? s.reservations[0] : null;
|
||||||
|
spaceStatus[s.id] = {
|
||||||
|
isOccupied: !!activeReservation,
|
||||||
|
reservation: activeReservation
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
spaceStatus = { ...spaceStatus };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disable real-time updates if viewing a different date than today
|
||||||
|
$: isToday = new Date().toDateString() === selectedDate.toDateString();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const socketUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
|
const socketUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
|
||||||
socket = io(socketUrl);
|
socket = io(socketUrl);
|
||||||
|
|
@ -26,8 +39,12 @@
|
||||||
socket.emit('join_floor', floorId);
|
socket.emit('join_floor', floorId);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('space_status_changed', (data: { spaceId: string, isOccupied: boolean }) => {
|
socket.on('space_status_changed', (data: { spaceId: string, isOccupied: boolean, reservation?: any }) => {
|
||||||
spaceStatus[data.spaceId] = data.isOccupied;
|
if (!isToday) return; // Ignore real-time updates for historical/future views
|
||||||
|
spaceStatus[data.spaceId] = {
|
||||||
|
isOccupied: data.isOccupied,
|
||||||
|
reservation: data.reservation
|
||||||
|
};
|
||||||
spaceStatus = { ...spaceStatus }; // Trigger reactivity
|
spaceStatus = { ...spaceStatus }; // Trigger reactivity
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -45,7 +62,12 @@
|
||||||
const lowerName = name.toLowerCase();
|
const lowerName = name.toLowerCase();
|
||||||
if (lowerName.includes('toilet') || lowerName.includes('bath')) return Bath;
|
if (lowerName.includes('toilet') || lowerName.includes('bath')) return Bath;
|
||||||
if (lowerName.includes('coffee')) return Coffee;
|
if (lowerName.includes('coffee')) return Coffee;
|
||||||
return null;
|
return AlertCircle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSpaceClick(space: any) {
|
||||||
|
if (space.type === 'AMENITY') return;
|
||||||
|
selectedSpace = space;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -55,23 +77,56 @@
|
||||||
style="width: 800px; height: 600px; background-image: url({planImageUrl}); background-size: contain; background-repeat: no-repeat; background-position: center;"
|
style="width: 800px; height: 600px; background-image: url({planImageUrl}); background-size: contain; background-repeat: no-repeat; background-position: center;"
|
||||||
>
|
>
|
||||||
{#each spaces as space (space.id)}
|
{#each spaces as space (space.id)}
|
||||||
<div
|
{@const status = spaceStatus[space.id] || { isOccupied: false }}
|
||||||
class="absolute p-2 border-2 rounded-lg shadow-md transition-colors {spaceStatus[space.id] ? 'bg-red-50 border-red-500' : 'bg-green-50 border-green-500'}"
|
<button
|
||||||
|
on:click={() => handleSpaceClick(space)}
|
||||||
|
class="absolute p-2 border-2 rounded-lg shadow-md transition-all {space.type !== 'AMENITY' ? 'hover:scale-105 cursor-pointer' : 'cursor-default opacity-80'} {status.isOccupied ? 'bg-yellow-50 border-yellow-500' : 'bg-green-50 border-green-500'}"
|
||||||
style="left: 0; top: 0; transform: translate({(space.x / 100) * (container?.clientWidth || 800)}px, {(space.y / 100) * (container?.clientHeight || 600)}px)"
|
style="left: 0; top: 0; transform: translate({(space.x / 100) * (container?.clientWidth || 800)}px, {(space.y / 100) * (container?.clientHeight || 600)}px)"
|
||||||
title="{space.name} ({spaceStatus[space.id] ? 'Occupied' : 'Available'})"
|
title="{space.name} ({status.isOccupied ? 'Booked' : 'Available'})"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div class={spaceStatus[space.id] ? 'text-red-600' : 'text-green-600'}>
|
<div class="relative">
|
||||||
<svelte:component this={getSpaceTypeIcon(space.type, space.name)} size={16} />
|
<div class={status.isOccupied ? 'text-yellow-600' : 'text-green-600'}>
|
||||||
|
<svelte:component this={getSpaceTypeIcon(space.type, space.name)} size={16} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if status.isOccupied && space.type === 'DESK'}
|
||||||
|
<div class="absolute -top-4 -left-4 w-6 h-6 rounded-full bg-white border border-yellow-500 overflow-hidden shadow-sm flex items-center justify-center">
|
||||||
|
{#if status.reservation?.user?.profilePicture}
|
||||||
|
<img src={status.reservation.user.profilePicture} alt={status.reservation.user.email} class="w-full h-full object-cover" />
|
||||||
|
{:else}
|
||||||
|
<span class="text-[8px] font-bold text-yellow-700">
|
||||||
|
{status.reservation?.user?.email?.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-start leading-none">
|
||||||
|
<span class="text-[9px] font-bold text-gray-800 whitespace-nowrap">
|
||||||
|
{space.name}
|
||||||
|
</span>
|
||||||
|
{#if status.isOccupied && space.type === 'DESK'}
|
||||||
|
<span class="text-[7px] text-gray-500 truncate max-w-[40px]">
|
||||||
|
{status.reservation?.user?.email.split('@')[0]}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[9px] font-bold text-gray-800 whitespace-nowrap">
|
|
||||||
{space.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if selectedSpace}
|
||||||
|
<BookingModal
|
||||||
|
space={selectedSpace}
|
||||||
|
status={spaceStatus[selectedSpace.id] || { isOccupied: false }}
|
||||||
|
selectedDate={selectedDate}
|
||||||
|
on:close={() => selectedSpace = null}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.floorplan-container {
|
.floorplan-container {
|
||||||
background-color: #f8fafc;
|
background-color: #f8fafc;
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,17 @@
|
||||||
export let type: string;
|
export let type: string;
|
||||||
export let floorId: string;
|
export let floorId: string;
|
||||||
export let initialStatus: boolean = false; // true if occupied
|
export let initialStatus: boolean = false; // true if occupied
|
||||||
|
export let selectedDate: Date = new Date();
|
||||||
|
|
||||||
let isOccupied = initialStatus;
|
let isOccupied = initialStatus;
|
||||||
let socket: Socket;
|
let socket: Socket;
|
||||||
|
|
||||||
|
$: isToday = new Date().toDateString() === selectedDate.toDateString();
|
||||||
|
|
||||||
|
$: if (initialStatus !== undefined) {
|
||||||
|
isOccupied = initialStatus;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Connect to WebSocket using the environment variable for the backend URL.
|
// Connect to WebSocket using the environment variable for the backend URL.
|
||||||
const socketUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
|
const socketUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
|
||||||
|
|
@ -22,7 +29,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('space_status_changed', (data: { spaceId: string, isOccupied: boolean }) => {
|
socket.on('space_status_changed', (data: { spaceId: string, isOccupied: boolean }) => {
|
||||||
if (data.spaceId === spaceId) {
|
if (data.spaceId === spaceId && isToday) {
|
||||||
isOccupied = data.isOccupied;
|
isOccupied = data.isOccupied;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,25 @@
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { apiFetch } from "$lib/api/client";
|
import { apiFetch } from "$lib/api/client";
|
||||||
import FloorplanViewer from '$lib/components/FloorplanViewer.svelte';
|
import FloorplanViewer from '$lib/components/FloorplanViewer.svelte';
|
||||||
import { Building2, Layers, Map as MapIcon, ChevronLeft, Settings } from "lucide-svelte";
|
import { Building2, Layers, Map as MapIcon, ChevronLeft, Settings, Calendar } from "lucide-svelte";
|
||||||
|
|
||||||
let building: any = null;
|
let building: any = null;
|
||||||
let currentFloor: any = null;
|
let currentFloor: any = null;
|
||||||
let loading = true;
|
let loading = true;
|
||||||
let loadingFloor = false;
|
let loadingFloor = false;
|
||||||
let selectedFloorId = "";
|
let selectedFloorId = "";
|
||||||
|
let selectedDateString = new Date().toISOString().split('T')[0];
|
||||||
|
let selectedDate = new Date();
|
||||||
|
|
||||||
|
$: {
|
||||||
|
selectedDate = new Date(selectedDateString);
|
||||||
|
// Add time component to the selected date if it's today, otherwise use start of day
|
||||||
|
if (selectedDateString === new Date().toISOString().split('T')[0]) {
|
||||||
|
selectedDate = new Date();
|
||||||
|
} else {
|
||||||
|
selectedDate.setHours(7, 0, 0, 0); // Default to 7 AM for other days
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const buildingId = $page.params.id;
|
const buildingId = $page.params.id;
|
||||||
|
|
@ -39,7 +51,7 @@
|
||||||
async function loadFloor(floorId: string) {
|
async function loadFloor(floorId: string) {
|
||||||
loadingFloor = true;
|
loadingFloor = true;
|
||||||
try {
|
try {
|
||||||
currentFloor = await apiFetch(`/spaces/floor/${floorId}`);
|
currentFloor = await apiFetch(`/spaces/floor/${floorId}?date=${selectedDate.toISOString()}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -47,11 +59,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (selectedFloorId && building) {
|
$: if (selectedFloorId && building && selectedDate) {
|
||||||
// Check if currentFloor needs update
|
// Re-fetch if floor or date changes
|
||||||
if (!currentFloor || currentFloor.id !== selectedFloorId) {
|
loadFloor(selectedFloorId);
|
||||||
loadFloor(selectedFloorId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -86,6 +96,16 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 w-full lg:w-auto">
|
<div class="flex items-center gap-3 w-full lg:w-auto">
|
||||||
|
<div class="relative flex-1 lg:flex-none min-w-[180px]">
|
||||||
|
<label for="date-select" class="absolute -top-2 left-3 px-1 bg-gray-50 text-[10px] font-bold text-indigo-600 uppercase tracking-wider">Select Date</label>
|
||||||
|
<input
|
||||||
|
id="date-select"
|
||||||
|
type="date"
|
||||||
|
bind:value={selectedDateString}
|
||||||
|
class="w-full pl-3 pr-4 py-3 bg-white border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent font-medium text-gray-700 shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="relative flex-1 lg:flex-none min-w-[200px]">
|
<div class="relative flex-1 lg:flex-none min-w-[200px]">
|
||||||
<label for="floor-select" class="absolute -top-2 left-3 px-1 bg-gray-50 text-[10px] font-bold text-indigo-600 uppercase tracking-wider">Select Floor</label>
|
<label for="floor-select" class="absolute -top-2 left-3 px-1 bg-gray-50 text-[10px] font-bold text-indigo-600 uppercase tracking-wider">Select Floor</label>
|
||||||
<select
|
<select
|
||||||
|
|
@ -126,6 +146,7 @@
|
||||||
floorId={currentFloor.id}
|
floorId={currentFloor.id}
|
||||||
spaces={currentFloor.spaces}
|
spaces={currentFloor.spaces}
|
||||||
planImageUrl={currentFloor.planImageUrl}
|
planImageUrl={currentFloor.planImageUrl}
|
||||||
|
selectedDate={selectedDate}
|
||||||
/>
|
/>
|
||||||
{#if currentFloor.planImageUrl}
|
{#if currentFloor.planImageUrl}
|
||||||
<p class="mt-6 text-sm text-gray-500 flex items-center gap-2">
|
<p class="mt-6 text-sm text-gray-500 flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,21 @@
|
||||||
import SpaceCard from '$lib/components/SpaceCard.svelte';
|
import SpaceCard from '$lib/components/SpaceCard.svelte';
|
||||||
import { apiFetch } from "$lib/api/client";
|
import { apiFetch } from "$lib/api/client";
|
||||||
import FloorplanViewer from '$lib/components/FloorplanViewer.svelte';
|
import FloorplanViewer from '$lib/components/FloorplanViewer.svelte';
|
||||||
|
import { Calendar, ChevronLeft, Layers } from "lucide-svelte";
|
||||||
|
|
||||||
let floor: any = null;
|
let floor: any = null;
|
||||||
let loading = true;
|
let loading = true;
|
||||||
|
let selectedDateString = new Date().toISOString().split('T')[0];
|
||||||
|
let selectedDate = new Date();
|
||||||
|
|
||||||
|
$: {
|
||||||
|
selectedDate = new Date(selectedDateString);
|
||||||
|
if (selectedDateString === new Date().toISOString().split('T')[0]) {
|
||||||
|
selectedDate = new Date();
|
||||||
|
} else {
|
||||||
|
selectedDate.setHours(7, 0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const floorId = $page.params.id;
|
const floorId = $page.params.id;
|
||||||
|
|
@ -16,18 +28,24 @@
|
||||||
goto(`/login?redirect=/dashboard/floor/${floorId}`);
|
goto(`/login?redirect=/dashboard/floor/${floorId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await loadFloor(floorId);
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadFloor(floorId: string) {
|
||||||
try {
|
try {
|
||||||
floor = await apiFetch(`/spaces/floor/${floorId}`);
|
floor = await apiFetch(`/spaces/floor/${floorId}?date=${selectedDate.toISOString()}`);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
if (e.message?.includes('401')) {
|
if (e.message?.includes('401')) {
|
||||||
goto("/login");
|
goto("/login");
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
$: if (floor && floor.id && selectedDate) {
|
||||||
|
loadFloor(floor.id);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-8">
|
<div class="p-8">
|
||||||
|
|
@ -36,8 +54,24 @@
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p>Loading floor layout...</p>
|
<p>Loading floor layout...</p>
|
||||||
{:else if floor}
|
{:else if floor}
|
||||||
<h1 class="text-2xl font-bold mb-6">Floor {floor.number} - {floor.building?.name}</h1>
|
<div class="flex items-center justify-between mb-8">
|
||||||
<p class="mb-8">{floor.building?.organization?.name}</p>
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Floor {floor.number} - {floor.building?.name}</h1>
|
||||||
|
<p class="text-gray-500 mt-1">{floor.building?.organization?.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="relative min-w-[180px]">
|
||||||
|
<label for="date-select" class="absolute -top-2 left-3 px-1 bg-white text-[10px] font-bold text-indigo-600 uppercase tracking-wider">Select Date</label>
|
||||||
|
<input
|
||||||
|
id="date-select"
|
||||||
|
type="date"
|
||||||
|
bind:value={selectedDateString}
|
||||||
|
class="w-full pl-3 pr-4 py-3 bg-white border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent font-medium text-gray-700 shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-12">
|
<div class="mb-12">
|
||||||
<h2 class="text-lg font-semibold mb-4 text-gray-700 uppercase tracking-wide">Interactive Floorplan</h2>
|
<h2 class="text-lg font-semibold mb-4 text-gray-700 uppercase tracking-wide">Interactive Floorplan</h2>
|
||||||
|
|
@ -45,6 +79,7 @@
|
||||||
floorId={floor.id}
|
floorId={floor.id}
|
||||||
spaces={floor.spaces}
|
spaces={floor.spaces}
|
||||||
planImageUrl={floor.planImageUrl}
|
planImageUrl={floor.planImageUrl}
|
||||||
|
selectedDate={selectedDate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -59,6 +94,7 @@
|
||||||
type={space.type}
|
type={space.type}
|
||||||
floorId={floor.id}
|
floorId={floor.id}
|
||||||
initialStatus={space.reservations && space.reservations.length > 0}
|
initialStatus={space.reservations && space.reservations.length > 0}
|
||||||
|
selectedDate={selectedDate}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue