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.
main
Pau Costa Ferrer 2026-03-28 15:20:17 +01:00
parent 7683066ceb
commit f8af8cf2d7
8 changed files with 333 additions and 30 deletions

View File

@ -4,18 +4,12 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Implement authentication, organization management, and floor layout features&#10;&#10;- Add API authentication with JWT strategy using NestJS.&#10;- Configure Prisma schema for organizations, users, buildings, floors, and spaces.&#10;- Seed initial database with test data and relationships.&#10;- Build SvelteKit web pages for dashboard and floor layout with secured routing and organization hierarchy display.&#10;- Remove deprecated `AppService` from API.">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" afterDir="false" />
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add floorplan editor for admin layout management&#10;&#10;- Implement `FloorplanEditor.svelte` component for adding, editing, and positioning spaces.&#10;- Add backend support for floorplan image upload and batch updating space positions.&#10;- Update Prisma schema to include `planImageUrl` for floors.&#10;- Enable API proxy in Vite configuration for straightforward local development.&#10;- Refactor API calls to use a centralized `apiFetch` utility with token handling.">
<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/web/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/api/client.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/api/client.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/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/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/login/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/login/+page.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/vite.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/vite.config.ts" 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" />
@ -134,7 +128,7 @@
<updated>1774643312599</updated>
<workItem from="1774643313611" duration="74000" />
<workItem from="1774643422240" duration="5278000" />
<workItem from="1774706361502" duration="897000" />
<workItem from="1774706361502" duration="1235000" />
</task>
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services &#10;&#10;- Set up SvelteKit for the web interface with Tailwind CSS. &#10;- Build the API using NestJS with Prisma ORM for database interaction. &#10;- Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. &#10;- Create shared data models for consistent typing across services. &#10;- Establish basic routing and layouts for web and API services.">
<option name="closed" value="true" />
@ -160,7 +154,15 @@
<option name="project" value="LOCAL" />
<updated>1774654565964</updated>
</task>
<option name="localTasksCounter" value="4" />
<task id="LOCAL-00004" summary="Add floorplan editor for admin layout management&#10;&#10;- Implement `FloorplanEditor.svelte` component for adding, editing, and positioning spaces.&#10;- Add backend support for floorplan image upload and batch updating space positions.&#10;- Update Prisma schema to include `planImageUrl` for floors.&#10;- Enable API proxy in Vite configuration for straightforward local development.&#10;- Refactor API calls to use a centralized `apiFetch` utility with token handling.">
<option name="closed" value="true" />
<created>1774707328435</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1774707328435</updated>
</task>
<option name="localTasksCounter" value="5" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@ -170,6 +172,7 @@
<MESSAGE value="Initialize Hot Desking app with web and API services &#10;&#10;- Set up SvelteKit for the web interface with Tailwind CSS. &#10;- Build the API using NestJS with Prisma ORM for database interaction. &#10;- Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. &#10;- Create shared data models for consistent typing across services. &#10;- Establish basic routing and layouts for web and API services." />
<MESSAGE value="Remove compiled API files from version control&#10;&#10;- Deleted compiled JavaScript files, type definitions, and source maps from the `dist` folder." />
<MESSAGE value="Implement authentication, organization management, and floor layout features&#10;&#10;- Add API authentication with JWT strategy using NestJS.&#10;- Configure Prisma schema for organizations, users, buildings, floors, and spaces.&#10;- Seed initial database with test data and relationships.&#10;- Build SvelteKit web pages for dashboard and floor layout with secured routing and organization hierarchy display.&#10;- Remove deprecated `AppService` from API." />
<option name="LAST_COMMIT_MESSAGE" value="Implement authentication, organization management, and floor layout features&#10;&#10;- Add API authentication with JWT strategy using NestJS.&#10;- Configure Prisma schema for organizations, users, buildings, floors, and spaces.&#10;- Seed initial database with test data and relationships.&#10;- Build SvelteKit web pages for dashboard and floor layout with secured routing and organization hierarchy display.&#10;- Remove deprecated `AppService` from API." />
<MESSAGE value="Add floorplan editor for admin layout management&#10;&#10;- Implement `FloorplanEditor.svelte` component for adding, editing, and positioning spaces.&#10;- Add backend support for floorplan image upload and batch updating space positions.&#10;- Update Prisma schema to include `planImageUrl` for floors.&#10;- Enable API proxy in Vite configuration for straightforward local development.&#10;- 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&#10;&#10;- Implement `FloorplanEditor.svelte` component for adding, editing, and positioning spaces.&#10;- Add backend support for floorplan image upload and batch updating space positions.&#10;- Update Prisma schema to include `planImageUrl` for floors.&#10;- Enable API proxy in Vite configuration for straightforward local development.&#10;- Refactor API calls to use a centralized `apiFetch` utility with token handling." />
</component>
</project>

View File

@ -15,6 +15,11 @@ export class SpacesController {
return this.spacesService.getFloorLayout(id);
}
@Get('building/:id')
async getBuildingFloors(@Param('id') id: string) {
return this.spacesService.getBuildingFloors(id);
}
@Get('org-hierarchy')
async getOrgHierarchy(@Request() req: { user: { organizationId: string } }) {
return this.spacesService.getBuildingsAndFloors(req.user.organizationId);

View File

@ -34,6 +34,17 @@ export class SpacesService {
});
}
async getBuildingFloors(buildingId: string) {
return this.prisma.building.findUnique({
where: { id: buildingId },
include: {
floors: {
orderBy: { number: 'asc' }
},
},
});
}
async getBuildingsAndFloors(organizationId: string) {
return this.prisma.building.findMany({
where: { organizationId },

View File

@ -0,0 +1,83 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { io, Socket } from 'socket.io-client';
import { Laptop, Bath, Coffee, Users } from 'lucide-svelte';
export let floorId: string;
export let spaces: any[] = [];
export let planImageUrl: string | null = null;
let container: HTMLDivElement;
let socket: Socket;
let spaceStatus: Record<string, boolean> = {};
$: {
// Initialize space status from the current state
spaces.forEach(s => {
spaceStatus[s.id] = s.reservations && s.reservations.length > 0;
});
}
onMount(() => {
const socketUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
socket = io(socketUrl);
socket.on('connect', () => {
socket.emit('join_floor', floorId);
});
socket.on('space_status_changed', (data: { spaceId: string, isOccupied: boolean }) => {
spaceStatus[data.spaceId] = data.isOccupied;
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 null;
}
</script>
<div
bind:this={container}
class="relative bg-white shadow-xl rounded-lg overflow-hidden border mx-auto floorplan-container"
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)}
<div
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'}"
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'})"
>
<div class="flex items-center gap-1">
<div class={spaceStatus[space.id] ? 'text-red-600' : 'text-green-600'}>
<svelte:component this={getSpaceTypeIcon(space.type, space.name)} size={16} />
</div>
<span class="text-[9px] font-bold text-gray-800 whitespace-nowrap">
{space.name}
</span>
</div>
</div>
{/each}
</div>
<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: 40px 40px;
}
</style>

View File

@ -208,7 +208,7 @@
<div class="flex-1 p-8 overflow-auto bg-gray-200 flex justify-center items-start">
<div
bind:this={container}
class="relative bg-white shadow-xl rounded-lg overflow-hidden"
class="relative bg-white shadow-xl rounded-lg overflow-hidden floorplan-container"
style="width: 800px; height: 600px; background-image: url({planImageUrl}); background-size: contain; background-repeat: no-repeat; background-position: center;"
>
{#if !planImageUrl}
@ -280,4 +280,19 @@
touch-action: none;
user-select: none;
}
.floorplan-container:not([style*="background-image: url()"]):not([style*='background-image: url("")']):not([style*="background-image: url(null)"]) {
/* When an image is set, we don't need the grid if we want it to be clean,
but the instruction says "if layout isn't configured yet".
If planImageUrl is null or empty, we'll show the grid.
*/
}
.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: 40px 40px;
}
</style>

View File

@ -3,6 +3,7 @@
import { token, user } from "$lib/stores/auth";
import { goto } from "$app/navigation";
import { apiFetch } from "$lib/api/client";
import { Building2, ArrowRight } from "lucide-svelte";
let buildings = [];
let loading = true;
@ -26,30 +27,49 @@
});
</script>
<div class="p-8">
<h1 class="text-2xl font-bold mb-6">Dashboard</h1>
<p class="mb-4">Welcome, {$user?.email}!</p>
<div class="p-8 max-w-6xl mx-auto">
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
<p class="text-gray-500 mt-1">Welcome back, {$user?.email}!</p>
</div>
</div>
{#if loading}
<p>Loading...</p>
<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}
<div class="grid gap-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each buildings as building}
<div class="border p-4 rounded-lg shadow-sm">
<h2 class="text-xl font-semibold mb-2">{building.name}</h2>
<ul class="list-disc pl-5">
{#each building.floors as floor}
<li class="mb-1">
Floor {floor.number}
<a href="/dashboard/floor/{floor.id}" class="ml-2 text-blue-600 hover:underline">View Layout</a>
{#if $user?.role === 'ADMIN'}
<a href="/admin/floorplan/{floor.id}" class="ml-4 text-orange-600 hover:underline">Edit Layout</a>
{/if}
</li>
{/each}
</ul>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow group">
<div class="p-6">
<div class="w-12 h-12 bg-indigo-50 rounded-lg flex items-center justify-center mb-4 group-hover:bg-indigo-100 transition-colors">
<Building2 class="text-indigo-600" size={24} />
</div>
<h2 class="text-xl font-bold text-gray-900 mb-2">{building.name}</h2>
<p class="text-gray-500 text-sm mb-6">
{building.floors?.length || 0} Floors available in this building.
</p>
<a
href="/dashboard/building/{building.id}"
class="inline-flex items-center justify-center w-full px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors gap-2"
>
View Building
<ArrowRight size={18} />
</a>
</div>
</div>
{/each}
{#if buildings.length === 0}
<div class="col-span-full bg-gray-50 border-2 border-dashed border-gray-300 rounded-xl p-12 text-center">
<Building2 class="mx-auto text-gray-400 mb-4" size={48} />
<h3 class="text-lg font-medium text-gray-900">No buildings found</h3>
<p class="text-gray-500 mt-1">Your organization doesn't have any buildings assigned yet.</p>
</div>
{/if}
</div>
{/if}
</div>

View File

@ -0,0 +1,153 @@
<script lang="ts">
import { page } from "$app/stores";
import { onMount } from "svelte";
import { token, user } from "$lib/stores/auth";
import { goto } from "$app/navigation";
import { apiFetch } from "$lib/api/client";
import FloorplanViewer from '$lib/components/FloorplanViewer.svelte';
import { Building2, Layers, Map as MapIcon, ChevronLeft, Settings } from "lucide-svelte";
let building: any = null;
let currentFloor: any = null;
let loading = true;
let loadingFloor = false;
let selectedFloorId = "";
onMount(async () => {
const buildingId = $page.params.id;
if (!$token) {
goto(`/login?redirect=/dashboard/building/${buildingId}`);
return;
}
try {
building = await apiFetch(`/spaces/building/${buildingId}`);
if (building.floors && building.floors.length > 0) {
selectedFloorId = building.floors[0].id;
await loadFloor(selectedFloorId);
}
} catch (e: any) {
console.error(e);
if (e.message?.includes('401')) {
goto("/login");
}
} finally {
loading = false;
}
});
async function loadFloor(floorId: string) {
loadingFloor = true;
try {
currentFloor = await apiFetch(`/spaces/floor/${floorId}`);
} catch (e) {
console.error(e);
} finally {
loadingFloor = false;
}
}
$: if (selectedFloorId && building) {
// Check if currentFloor needs update
if (!currentFloor || currentFloor.id !== selectedFloorId) {
loadFloor(selectedFloorId);
}
}
</script>
<div class="p-8 max-w-7xl mx-auto">
<div class="mb-6">
<a href="/dashboard" class="inline-flex items-center text-indigo-600 hover:text-indigo-700 font-medium gap-1">
<ChevronLeft size={20} />
Back to Dashboard
</a>
</div>
{#if loading}
<div class="flex flex-col items-center justify-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mb-4"></div>
<p class="text-gray-500">Loading building details...</p>
</div>
{:else if building}
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center mb-8 gap-4">
<div class="flex items-center gap-4">
<div class="w-14 h-14 bg-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-100">
<Building2 class="text-white" size={30} />
</div>
<div>
<h1 class="text-3xl font-bold text-gray-900">{building.name}</h1>
<div class="flex items-center gap-4 mt-1 text-gray-500">
<span class="flex items-center gap-1.5 text-sm">
<Layers size={16} />
{building.floors?.length || 0} Floors
</span>
</div>
</div>
</div>
<div class="flex items-center gap-3 w-full lg:w-auto">
<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>
<select
id="floor-select"
bind:value={selectedFloorId}
class="w-full pl-3 pr-10 py-3 bg-white border border-gray-300 rounded-xl appearance-none focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent font-medium text-gray-700 shadow-sm"
>
{#each building.floors as floor}
<option value={floor.id}>Floor {floor.number}</option>
{/each}
</select>
<div class="absolute inset-y-0 right-3 flex items-center pointer-events-none text-gray-400">
<Layers size={20} />
</div>
</div>
{#if $user?.role === 'ADMIN' && selectedFloorId}
<a
href="/admin/floorplan/{selectedFloorId}"
class="p-3 bg-white border border-gray-300 text-gray-700 rounded-xl hover:bg-gray-50 transition-colors shadow-sm"
title="Edit Floorplan"
>
<Settings size={20} />
</a>
{/if}
</div>
</div>
<div class="bg-gray-100 rounded-3xl p-8 min-h-[700px] flex items-center justify-center border border-gray-200 shadow-inner relative overflow-hidden">
{#if loadingFloor}
<div class="flex flex-col items-center">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-indigo-600 mb-4"></div>
<p class="text-gray-500 font-medium">Switching floor...</p>
</div>
{:else if currentFloor}
<div class="flex flex-col items-center">
<FloorplanViewer
floorId={currentFloor.id}
spaces={currentFloor.spaces}
planImageUrl={currentFloor.planImageUrl}
/>
{#if currentFloor.planImageUrl}
<p class="mt-6 text-sm text-gray-500 flex items-center gap-2">
<MapIcon size={16} />
Interactive View - Click a space to see details or book.
</p>
{:else}
<p class="mt-6 text-sm text-gray-400 flex items-center gap-2 italic">
<MapIcon size={16} />
No floorplan image uploaded. Showing default grid.
</p>
{/if}
</div>
{:else}
<p class="text-gray-400">Select a floor to see the layout</p>
{/if}
</div>
{:else}
<div class="text-center py-20">
<h2 class="text-2xl font-bold text-gray-900">Building not found</h2>
<p class="text-gray-500 mt-2">The building you're looking for doesn't exist or you don't have access.</p>
<a href="/dashboard" class="mt-8 inline-block text-indigo-600 font-medium">Return to Dashboard</a>
</div>
{/if}
</div>

View File

@ -5,6 +5,7 @@
import { goto } from "$app/navigation";
import SpaceCard from '$lib/components/SpaceCard.svelte';
import { apiFetch } from "$lib/api/client";
import FloorplanViewer from '$lib/components/FloorplanViewer.svelte';
let floor: any = null;
let loading = true;
@ -38,6 +39,18 @@
<h1 class="text-2xl font-bold mb-6">Floor {floor.number} - {floor.building?.name}</h1>
<p class="mb-8">{floor.building?.organization?.name}</p>
<div class="mb-12">
<h2 class="text-lg font-semibold mb-4 text-gray-700 uppercase tracking-wide">Interactive Floorplan</h2>
<FloorplanViewer
floorId={floor.id}
spaces={floor.spaces}
planImageUrl={floor.planImageUrl}
/>
</div>
<h2 class="text-lg font-semibold mb-4 text-gray-700 uppercase tracking-wide">
{floor.planImageUrl ? 'All Spaces' : 'Spaces'}
</h2>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each floor.spaces as space}
<SpaceCard