213 lines
7.6 KiB
Svelte
213 lines
7.6 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';
|
|
import Avatar from './Avatar.svelte';
|
|
import { resolveAssetUrl } from '$lib/utils/url';
|
|
|
|
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({resolveAssetUrl(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 overflow-hidden flex items-center justify-center {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 shrink-0 justify-center"
|
|
style="
|
|
transform: rotate({space.rotation || 0}deg);
|
|
width: {(space.rotation % 180 === 90) ? (space.height / 100) * containerHeight : (space.width / 100) * containerWidth}px;
|
|
height: {(space.rotation % 180 === 90) ? (space.width / 100) * containerWidth : (space.height / 100) * containerHeight}px;
|
|
"
|
|
>
|
|
<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 shadow-sm" style="transform: rotate(-{space.rotation || 0}deg);">
|
|
<Avatar
|
|
user={status.reservation.user}
|
|
size="sm"
|
|
className="border-yellow-500 bg-white"
|
|
/>
|
|
</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?.name || 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>
|