desker/apps/web/src/lib/components/FloorplanViewer.svelte

206 lines
7.3 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { io, Socket } from 'socket.io-client';
import { Laptop, Bath, Coffee, Users, AlertCircle } from 'lucide-svelte';
import BookingModal from './BookingModal.svelte';
export let floorId: string;
export let spaces: any[] = [];
export let planImageUrl: string | null = null;
export let selectedDate: Date = new Date();
let container: HTMLDivElement;
let socket: Socket;
let spaceStatus: Record<string, any> = {};
let selectedSpace: any = null;
let containerWidth = 800;
let containerHeight = 600;
// Track if we have received any real-time updates to avoid overwriting them with stale initial status
let realtimeUpdates: Record<string, any> = {};
$: {
// Initialize space status from the current state (e.g. from API)
spaces.forEach(s => {
// Don't overwrite if we already have a real-time update for today
if (isToday && realtimeUpdates[s.id]) {
spaceStatus[s.id] = realtimeUpdates[s.id];
return;
}
const activeReservation = s.reservations && s.reservations.length > 0 ? s.reservations[0] : null;
spaceStatus[s.id] = {
isOccupied: !!activeReservation,
reservation: activeReservation
};
});
spaceStatus = { ...spaceStatus };
updateContainerSizeToFitAll();
}
function updateContainerSizeToFitAll() {
if (spaces.length === 0) return;
let maxR = 800;
let maxB = 600;
spaces.forEach(s => {
const w = s.width || (s.type === 'DESK' ? 12 : 20);
const h = s.height || (s.type === 'DESK' ? 8 : 15);
const r = ((s.x + w) / 100) * 800; // Use 800 as base or whatever
const b = ((s.y + h) / 100) * 600;
if (r > maxR) maxR = r;
if (b > maxB) maxB = b;
});
// To be consistent with editor, we should probably use the same base.
// However, in the viewer, we just want to show everything.
// If spaces were saved with percentages relative to a larger container,
// we need to know that container's aspect ratio.
// Actually, the percentages ARE the aspect ratio.
// If a space is at x=110%, it means it's outside the "original" 800px.
// So we should just find the max percentage and scale the container.
let maxXP = 100;
let maxYP = 100;
spaces.forEach(s => {
const w = s.width || (s.type === 'DESK' ? 12 : 20);
const h = s.height || (s.type === 'DESK' ? 8 : 15);
if (s.x + w > maxXP) maxXP = s.x + w;
if (s.y + h > maxYP) maxYP = s.y + h;
});
containerWidth = (maxXP / 100) * 800;
containerHeight = (maxYP / 100) * 600;
// Add some padding
containerWidth += 40;
containerHeight += 40;
}
$: isToday = new Date(selectedDate.getTime()).setHours(0,0,0,0) === new Date().setHours(0,0,0,0);
onMount(() => {
const socketUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
socket = io(socketUrl, {
transports: ['websocket'], // Prefer WebSockets
});
socket.on('connect', () => {
console.log('Connected to WebSocket for floor:', floorId);
socket.emit('join_floor', floorId);
});
socket.on('space_status_changed', (data: { spaceId: string, isOccupied: boolean, reservation?: any }) => {
console.log('Space status changed received:', data);
if (!isToday) {
console.log('Ignoring real-time update: not today', {
selectedDate: selectedDate.toDateString(),
now: new Date().toDateString()
});
return;
}
const update = {
isOccupied: data.isOccupied,
reservation: data.reservation
};
// Save to realtimeUpdates to prevent overwriting by API re-fetches
realtimeUpdates[data.spaceId] = update;
spaceStatus[data.spaceId] = update;
spaceStatus = { ...spaceStatus }; // Trigger reactivity
});
});
onDestroy(() => {
if (socket) {
socket.emit('leave_floor', floorId);
socket.disconnect();
}
});
function getSpaceTypeIcon(type: string, name: string) {
if (type === 'DESK') return Laptop;
if (type === 'MEETING_ROOM') return Users;
const lowerName = name.toLowerCase();
if (lowerName.includes('toilet') || lowerName.includes('bath')) return Bath;
if (lowerName.includes('coffee')) return Coffee;
return AlertCircle;
}
function handleSpaceClick(space: any) {
if (space.type === 'AMENITY') return;
selectedSpace = space;
}
</script>
<div
bind:this={container}
class="relative bg-white shadow-xl rounded-lg overflow-hidden border mx-auto floorplan-container"
style="width: {containerWidth}px; height: {containerHeight}px; background-image: url({planImageUrl}); background-size: cover; background-repeat: no-repeat; background-position: center;"
>
{#each spaces as space (space.id)}
{@const status = spaceStatus[space.id] || { isOccupied: false }}
<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; width: {(space.width / 100) * containerWidth}px; height: {(space.height / 100) * containerHeight}px; transform: translate({(space.x / 100) * containerWidth}px, {(space.y / 100) * containerHeight}px)"
title="{space.name} ({status.isOccupied ? 'Booked' : 'Available'})"
>
<div class="flex items-center gap-1">
<div class="relative">
<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>
</button>
{/each}
</div>
{#if selectedSpace}
<BookingModal
space={selectedSpace}
status={spaceStatus[selectedSpace.id] || { isOccupied: false }}
selectedDate={selectedDate}
on:close={() => selectedSpace = null}
/>
{/if}
<style>
.floorplan-container {
background-color: #f8fafc;
background-image:
linear-gradient(to right, #e2e8f0 1px, transparent 1px),
linear-gradient(to bottom, #e2e8f0 1px, transparent 1px);
background-size: 20px 20px;
}
</style>