Add rotation support for spaces in floorplan editor

- Extend `Space` model and Prisma schema to include `rotation` property.
- Update API services and DTOs to handle rotation data.
- Add UI controls in `FloorplanEditor.svelte` for rotating spaces.
- Refactor space rendering logic in both editor and viewer to support rotation.
main
Pau Costa Ferrer 2026-03-28 17:47:32 +01:00
parent 4bc2454f44
commit e03335be67
6 changed files with 92 additions and 17 deletions

View File

@ -4,7 +4,14 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Update prompts to include progress indicator for documentation readability" />
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add Docker deployment support for API and Web services&#10;&#10;- Add Dockerfiles for API and Web apps with multi-stage builds.&#10;- Configure `docker-compose.yml` to manage services (Postgres, API, Web, pgAdmin).&#10;- Add `.dockerignore` for optimized builds.&#10;- Include `DOCKER_SETUP.md` for deployment steps and guidance.">
<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" />
<change beforePath="$PROJECT_DIR$/apps/api/src/spaces/dto/update-space.dto.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/spaces/dto/update-space.dto.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/FloorplanViewer.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/FloorplanViewer.svelte" 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" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
@ -125,7 +132,7 @@
<updated>1774643312599</updated>
<workItem from="1774643313611" duration="74000" />
<workItem from="1774643422240" duration="5278000" />
<workItem from="1774706361502" duration="9383000" />
<workItem from="1774706361502" duration="10075000" />
</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" />
@ -271,7 +278,15 @@
<option name="project" value="LOCAL" />
<updated>1774715728737</updated>
</task>
<option name="localTasksCounter" value="19" />
<task id="LOCAL-00019" summary="Add Docker deployment support for API and Web services&#10;&#10;- Add Dockerfiles for API and Web apps with multi-stage builds.&#10;- Configure `docker-compose.yml` to manage services (Postgres, API, Web, pgAdmin).&#10;- Add `.dockerignore` for optimized builds.&#10;- Include `DOCKER_SETUP.md` for deployment steps and guidance.">
<option name="closed" value="true" />
<created>1774715983233</created>
<option name="number" value="00019" />
<option name="presentableId" value="LOCAL-00019" />
<option name="project" value="LOCAL" />
<updated>1774715983233</updated>
</task>
<option name="localTasksCounter" value="20" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@ -298,6 +313,7 @@
<MESSAGE value="Add user profile management with avatar upload and update support&#10;&#10;- Create `+page.svelte` for managing user profiles, including name, avatar, and password updates.&#10;- Add `Avatar.svelte` component for rendering user avatars with Gravatar and fallback initials support.&#10;- Implement backend APIs for fetching and updating user profiles (`/users/profile`).&#10;- Add support for avatar uploads with file validation (max size: 5MB).&#10;- Update `FloorplanViewer.svelte` to display user names and avatars in reservations.&#10;- Introduce utilities for Gravatar URLs and user avatar management (`getGravatarUrl`, `getInitials`)." />
<MESSAGE value="Add i18n support with English and Spanish translations&#10;&#10;- Introduce `svelte-i18n` for internationalization.&#10;- Add English (`en.json`) and Spanish (`es-ES.json`) locale files.&#10;- Implement `setupI18n` function for initializing and managing locales.&#10;- Update project dependencies to include `svelte-i18n`." />
<MESSAGE value="Update prompts to include progress indicator for documentation readability" />
<option name="LAST_COMMIT_MESSAGE" value="Update prompts to include progress indicator for documentation readability" />
<MESSAGE value="Add Docker deployment support for API and Web services&#10;&#10;- Add Dockerfiles for API and Web apps with multi-stage builds.&#10;- Configure `docker-compose.yml` to manage services (Postgres, API, Web, pgAdmin).&#10;- Add `.dockerignore` for optimized builds.&#10;- Include `DOCKER_SETUP.md` for deployment steps and guidance." />
<option name="LAST_COMMIT_MESSAGE" value="Add Docker deployment support for API and Web services&#10;&#10;- Add Dockerfiles for API and Web apps with multi-stage builds.&#10;- Configure `docker-compose.yml` to manage services (Postgres, API, Web, pgAdmin).&#10;- Add `.dockerignore` for optimized builds.&#10;- Include `DOCKER_SETUP.md` for deployment steps and guidance." />
</component>
</project>

View File

@ -71,6 +71,7 @@ model Space {
updatedAt DateTime @updatedAt
height Float?
width Float?
rotation Float? @default(0)
reservations Reservation[]
floor Floor @relation(fields: [floorId], references: [id])
}

View File

@ -21,6 +21,10 @@ export class UpdateSpaceDto {
@IsOptional()
height?: number;
@IsNumber()
@IsOptional()
rotation?: number;
@IsString()
@IsOptional()
name?: string;

View File

@ -167,6 +167,7 @@ export class SpacesService {
y: data.y,
width: data.width,
height: data.height,
rotation: data.rotation,
},
});
}
@ -177,6 +178,7 @@ export class SpacesService {
y: data.y,
width: data.width,
height: data.height,
rotation: data.rotation,
name: data.name,
},
});

View File

@ -149,18 +149,25 @@
{@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'}"
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">
<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">
<div class="absolute -top-4 -left-4 shadow-sm" style="transform: rotate(-{space.rotation || 0}deg);">
<Avatar
user={status.reservation.user}
size="sm"

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import interact from 'interactjs';
import { Laptop, Bath, Coffee, Users, Save, Upload } from 'lucide-svelte';
import { Laptop, Bath, Coffee, Users, Save, Upload, RotateCw } from 'lucide-svelte';
import { apiFetch } from '$lib/api/client';
import { resolveAssetUrl } from '$lib/utils/url';
@ -219,6 +219,7 @@
y: 5,
width: item.type === 'DESK' ? 12 : 20, // Default widths as percentage
height: item.type === 'DESK' ? 8 : 15, // Default heights as percentage
rotation: 0,
floorId
};
spaces = [...spaces, newSpace];
@ -287,6 +288,24 @@
editingId = null;
}
function rotateSpace(id: string) {
const index = spaces.findIndex(s => s.id === id);
if (index !== -1) {
const space = spaces[index];
const oldWidth = space.width;
const oldHeight = space.height;
// Swap width and height
spaces[index].width = oldHeight * (containerHeight / containerWidth);
spaces[index].height = oldWidth * (containerWidth / containerHeight);
// Update rotation
spaces[index].rotation = ((spaces[index].rotation || 0) + 90) % 360;
spaces = [...spaces];
}
}
function handleKeydown(event: KeyboardEvent, id: string) {
if (event.key === 'Enter') {
stopEditing();
@ -368,13 +387,20 @@
{#each spaces as space (space.id)}
<div
use:interactAction={space}
class="draggable-space absolute p-2 bg-white border-2 border-blue-500 rounded-lg shadow-lg group {space.type !== 'DESK' ? 'resizable-space' : ''}"
class="draggable-space absolute p-2 bg-white border-2 border-blue-500 rounded-lg shadow-lg group overflow-hidden flex items-center justify-center {space.type !== 'DESK' ? 'resizable-space' : ''}"
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)"
data-id={space.id}
data-x={(space.x / 100) * containerWidth}
data-y={(space.y / 100) * containerHeight}
>
<div class="flex flex-col h-full drag-handle cursor-move">
<div
class="flex flex-col drag-handle cursor-move shrink-0 items-center 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="flex items-center gap-2 mb-1">
<div class="text-blue-600 shrink-0">
{#if space.type === 'DESK'}
@ -408,14 +434,33 @@
>
{space.name}
</span>
{/if}
{#if space.type === 'DESK'}
<button
on:click|stopPropagation={() => rotateSpace(space.id)}
class="hidden group-hover:flex items-center justify-center shrink-0 w-4 h-4 text-white bg-blue-500 rounded-full hover:bg-blue-600 ml-auto"
title="Rotate Desk"
style="transform: rotate(-{space.rotation || 0}deg);"
>
<RotateCw size={10} />
</button>
<button
on:click|stopPropagation={() => removeSpace(space.id)}
class="hidden group-hover:flex items-center justify-center shrink-0 w-4 h-4 text-white bg-red-500 rounded-full hover:bg-red-600 ml-auto"
class="hidden group-hover:flex items-center justify-center shrink-0 w-4 h-4 text-white bg-red-500 rounded-full hover:bg-red-600 ml-1"
style="transform: rotate(-{space.rotation || 0}deg);"
>
×
</button>
{:else}
<button
on:click|stopPropagation={() => removeSpace(space.id)}
class="hidden group-hover:flex items-center justify-center shrink-0 w-4 h-4 text-white bg-red-500 rounded-full hover:bg-red-600 ml-auto"
style="transform: rotate(-{space.rotation || 0}deg);"
>
×
</button>
{/if}
{/if}
</div>
{#if space.type !== 'DESK'}