Add support for space resizing and enhanced layout management

- Update `SpacesService` to handle width and height properties for spaces.
- Introduce `UpdateSpaceDto` for improved validation in batch update operations.
- Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing.
- Modify Prisma schema to include `width` and `height` fields for spaces.
- Enhance floorplan rendering to maintain proportional layout during editing and viewing.
main
Pau Costa Ferrer 2026-03-28 15:53:33 +01:00
parent 45e42c3d37
commit 89fdbf6617
7 changed files with 344 additions and 77 deletions

View File

@ -4,17 +4,13 @@
<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 building-level dashboard and interactive floorplan viewer&#10;&#10;- Create `+page.svelte` for building-level dashboard with floor selection and layout preview.&#10;- Add `FloorplanViewer.svelte` component with WebSocket integration for real-time space status updates.&#10;- Extend `SpacesService` and API endpoints for building floors and floor details retrieval.&#10;- Update dashboard UI with cleaner building and floor presentation.&#10;- Improve loading states and error handling across dashboard and floor pages."> <list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add booking modal and enhance reservation handling&#10;&#10;- Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations.&#10;- Add API endpoint to fetch reservations by space ID and date.&#10;- Update `SpacesService` with enhanced validation rules and conflict checks for reservations.&#10;- Extend WebSocket updates to include reservation data for real-time synchronization.&#10;- Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`).">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <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/prisma/schema.prisma" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" 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/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/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/SpaceCard.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/SpaceCard.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" />
<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" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -111,8 +107,8 @@
</configuration> </configuration>
<recent_temporary> <recent_temporary>
<list> <list>
<item itemvalue="npm.web &gt; dev" />
<item itemvalue="npm.api &gt; start:dev" /> <item itemvalue="npm.api &gt; start:dev" />
<item itemvalue="npm.web &gt; dev" />
<item itemvalue="Docker.docker-compose.yml: Compose Deployment" /> <item itemvalue="Docker.docker-compose.yml: Compose Deployment" />
</list> </list>
</recent_temporary> </recent_temporary>
@ -133,7 +129,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="1946000" /> <workItem from="1774706361502" duration="2888000" />
</task> </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."> <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" /> <option name="closed" value="true" />
@ -175,7 +171,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1774707617715</updated> <updated>1774707617715</updated>
</task> </task>
<option name="localTasksCounter" value="6" /> <task id="LOCAL-00006" summary="Add booking modal and enhance reservation handling&#10;&#10;- Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations.&#10;- Add API endpoint to fetch reservations by space ID and date.&#10;- Update `SpacesService` with enhanced validation rules and conflict checks for reservations.&#10;- Extend WebSocket updates to include reservation data for real-time synchronization.&#10;- Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`).">
<option name="closed" value="true" />
<created>1774708414077</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1774708414077</updated>
</task>
<option name="localTasksCounter" value="7" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@ -187,6 +191,7 @@
<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="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." /> <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." />
<MESSAGE value="Add building-level dashboard and interactive floorplan viewer&#10;&#10;- Create `+page.svelte` for building-level dashboard with floor selection and layout preview.&#10;- Add `FloorplanViewer.svelte` component with WebSocket integration for real-time space status updates.&#10;- Extend `SpacesService` and API endpoints for building floors and floor details retrieval.&#10;- Update dashboard UI with cleaner building and floor presentation.&#10;- Improve loading states and error handling across dashboard and floor pages." /> <MESSAGE value="Add building-level dashboard and interactive floorplan viewer&#10;&#10;- Create `+page.svelte` for building-level dashboard with floor selection and layout preview.&#10;- Add `FloorplanViewer.svelte` component with WebSocket integration for real-time space status updates.&#10;- Extend `SpacesService` and API endpoints for building floors and floor details retrieval.&#10;- Update dashboard UI with cleaner building and floor presentation.&#10;- 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&#10;&#10;- Create `+page.svelte` for building-level dashboard with floor selection and layout preview.&#10;- Add `FloorplanViewer.svelte` component with WebSocket integration for real-time space status updates.&#10;- Extend `SpacesService` and API endpoints for building floors and floor details retrieval.&#10;- Update dashboard UI with cleaner building and floor presentation.&#10;- Improve loading states and error handling across dashboard and floor pages." /> <MESSAGE value="Add booking modal and enhance reservation handling&#10;&#10;- Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations.&#10;- Add API endpoint to fetch reservations by space ID and date.&#10;- Update `SpacesService` with enhanced validation rules and conflict checks for reservations.&#10;- Extend WebSocket updates to include reservation data for real-time synchronization.&#10;- Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`)." />
<option name="LAST_COMMIT_MESSAGE" value="Add booking modal and enhance reservation handling&#10;&#10;- Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations.&#10;- Add API endpoint to fetch reservations by space ID and date.&#10;- Update `SpacesService` with enhanced validation rules and conflict checks for reservations.&#10;- Extend WebSocket updates to include reservation data for real-time synchronization.&#10;- Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`)." />
</component> </component>
</project> </project>

View File

@ -58,6 +58,8 @@ model Space {
floorId String floorId String
x Float? // Percentage-based for responsiveness (Stage 3) x Float? // Percentage-based for responsiveness (Stage 3)
y Float? y Float?
width Float? // Percentage-based for resizing (Stage 4)
height Float? // Percentage-based for resizing (Stage 4)
reservations Reservation[] reservations Reservation[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@ -0,0 +1,35 @@
import { IsString, IsNumber, IsOptional, IsEnum, IsUUID } from 'class-validator';
import { SpaceType } from '@prisma/client';
export class UpdateSpaceDto {
@IsString()
id: string;
@IsNumber()
@IsOptional()
x?: number;
@IsNumber()
@IsOptional()
y?: number;
@IsNumber()
@IsOptional()
width?: number;
@IsNumber()
@IsOptional()
height?: number;
@IsString()
@IsOptional()
name?: string;
@IsEnum(SpaceType)
@IsOptional()
type?: SpaceType;
@IsUUID()
@IsOptional()
floorId?: string;
}

View File

@ -1,9 +1,10 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request, Patch, Query } from '@nestjs/common'; import { Controller, Get, Post, Body, Param, UseGuards, Request, Patch, Query, ParseArrayPipe } 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';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { UpdateSpaceDto } from './dto/update-space.dto';
@Controller('spaces') @Controller('spaces')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@ -33,7 +34,7 @@ export class SpacesController {
@Post('batch-update') @Post('batch-update')
@Roles(Role.ADMIN) @Roles(Role.ADMIN)
async batchUpdate(@Body() spaces: any[]) { async batchUpdate(@Body(new ParseArrayPipe({ items: UpdateSpaceDto })) spaces: UpdateSpaceDto[]) {
return this.spacesService.batchUpdateSpaces(spaces); return this.spacesService.batchUpdateSpaces(spaces);
} }
} }

View File

@ -1,6 +1,8 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { UpdateSpaceDto } from './dto/update-space.dto';
@Injectable() @Injectable()
export class SpacesService { export class SpacesService {
private readonly logger = new Logger(SpacesService.name); private readonly logger = new Logger(SpacesService.name);
@ -70,18 +72,26 @@ export class SpacesService {
}); });
} }
async batchUpdateSpaces(spaces: { id: string; x: number; y: number; type?: any; name?: string; floorId?: string }[]) { async batchUpdateSpaces(spaces: UpdateSpaceDto[]) {
const updates = spaces.map((space) => { const updates = spaces.map((space) => {
const { id, ...data } = space; const { id, ...data } = space;
if (id.startsWith('temp-')) { if (id.startsWith('temp-')) {
if (!data.type) {
throw new Error(`Space type is required for new spaces`);
}
if (!data.floorId) {
throw new Error(`FloorId is required for new spaces`);
}
// Create new space if it's a new one from the toolbox // Create new space if it's a new one from the toolbox
return this.prisma.space.create({ return this.prisma.space.create({
data: { data: {
name: data.name || `${data.type} ${Math.floor(Math.random() * 1000)}`, name: data.name || `${data.type} ${Math.floor(Math.random() * 1000)}`,
type: data.type, type: data.type,
floorId: data.floorId!, floorId: data.floorId,
x: data.x, x: data.x,
y: data.y, y: data.y,
width: data.width,
height: data.height,
}, },
}); });
} }
@ -90,6 +100,8 @@ export class SpacesService {
data: { data: {
x: data.x, x: data.x,
y: data.y, y: data.y,
width: data.width,
height: data.height,
name: data.name, name: data.name,
}, },
}); });

View File

@ -13,6 +13,8 @@
let socket: Socket; let socket: Socket;
let spaceStatus: Record<string, any> = {}; let spaceStatus: Record<string, any> = {};
let selectedSpace: any = null; let selectedSpace: any = null;
let containerWidth = 800;
let containerHeight = 600;
$: { $: {
// Initialize space status from the current state // Initialize space status from the current state
@ -26,6 +28,50 @@
}; };
}); });
spaceStatus = { ...spaceStatus }; 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;
} }
// Disable real-time updates if viewing a different date than today // Disable real-time updates if viewing a different date than today
@ -74,14 +120,14 @@
<div <div
bind:this={container} bind:this={container}
class="relative bg-white shadow-xl rounded-lg overflow-hidden border mx-auto floorplan-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;" 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)} {#each spaces as space (space.id)}
{@const status = spaceStatus[space.id] || { isOccupied: false }} {@const status = spaceStatus[space.id] || { isOccupied: false }}
<button <button
on:click={() => handleSpaceClick(space)} 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 {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; 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'})" title="{space.name} ({status.isOccupied ? 'Booked' : 'Available'})"
> >
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@ -133,6 +179,6 @@
background-image: background-image:
linear-gradient(to right, #e2e8f0 1px, transparent 1px), linear-gradient(to right, #e2e8f0 1px, transparent 1px),
linear-gradient(to bottom, #e2e8f0 1px, transparent 1px); linear-gradient(to bottom, #e2e8f0 1px, transparent 1px);
background-size: 40px 40px; background-size: 20px 20px;
} }
</style> </style>

View File

@ -10,6 +10,43 @@
let container: HTMLDivElement; let container: HTMLDivElement;
let spaces = [...initialSpaces]; let spaces = [...initialSpaces];
let containerWidth = 800;
let containerHeight = 600;
// Keep local spaces in sync if initialSpaces changes from parent (e.g. after full page load)
$: if (initialSpaces) {
// Only re-sync if the number of spaces changed or they are all new
// to avoid wiping out unsaved local changes while editing.
// In most cases, initialSpaces only changes on mount or full refresh.
if (spaces.length === 0 || (initialSpaces.length > 0 && !initialSpaces[0].id.startsWith('temp-') && spaces[0]?.id?.startsWith('temp-'))) {
spaces = [...initialSpaces];
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);
// Calculate right and bottom in pixels relative to 800x600 (or current if it's the master)
// Actually, since we're starting from percentages, we should probably pick a base pixel size.
// Let's use 800 as the base width.
const r = ((s.x + w) / 100) * containerWidth;
const b = ((s.y + h) / 100) * containerHeight;
if (r > maxR) maxR = r;
if (b > maxB) maxB = b;
});
containerWidth = Math.max(800, Math.ceil(maxR + 40)); // +40 for some padding
containerHeight = Math.max(600, Math.ceil(maxB + 40));
}
let toolboxItems = [ let toolboxItems = [
{ type: 'DESK', icon: Laptop, label: 'Desk' }, { type: 'DESK', icon: Laptop, label: 'Desk' },
{ type: 'MEETING_ROOM', icon: Users, label: 'Meeting Room' }, { type: 'MEETING_ROOM', icon: Users, label: 'Meeting Room' },
@ -21,17 +58,25 @@
let editingId: string | null = null; let editingId: string | null = null;
let fileInput: HTMLInputElement; let fileInput: HTMLInputElement;
onMount(() => { function interactAction(node: HTMLElement, space: any) {
initInteract(); const isDesk = space.type === 'DESK';
}); const interactable = interact(node);
function initInteract() { interactable.draggable({
interact('.draggable-space').draggable({
inertia: true, inertia: true,
allowFrom: '.drag-handle',
modifiers: [ modifiers: [
interact.modifiers.restrictRect({ interact.modifiers.restrictRect({
restriction: 'parent', restriction: 'parent',
endOnly: true endOnly: true
}),
interact.modifiers.snap({
targets: [
interact.snappers.grid({ x: 20, y: 20 })
],
range: Infinity,
relativePoints: [ { x: 0, y: 0 } ],
offset: 'parent'
}) })
], ],
autoScroll: true, autoScroll: true,
@ -40,6 +85,37 @@
end: onDragEnd end: onDragEnd
} }
}); });
if (!isDesk) {
interactable.resizable({
edges: { right: '.resize-handle', bottom: '.resize-handle' },
modifiers: [
interact.modifiers.restrictSize({
min: { width: 40, height: 40 }
}),
interact.modifiers.restrictRect({
restriction: 'parent'
}),
interact.modifiers.snap({
targets: [
interact.snappers.grid({ x: 20, y: 20 })
],
range: Infinity,
offset: 'parent'
})
],
listeners: {
move: resizeMoveListener,
end: onResizeEnd
}
});
}
return {
destroy() {
interactable.unset();
}
};
} }
function dragMoveListener(event: any) { function dragMoveListener(event: any) {
@ -52,21 +128,84 @@
target.setAttribute('data-y', y); target.setAttribute('data-y', y);
} }
function onDragEnd(event: any) { function resizeMoveListener(event: any) {
const target = event.target; const target = event.target;
let x = parseFloat(target.getAttribute('data-x')) || 0;
let y = parseFloat(target.getAttribute('data-y')) || 0;
target.style.width = event.rect.width + 'px';
target.style.height = event.rect.height + 'px';
x += event.deltaRect.left;
y += event.deltaRect.top;
target.style.transform = `translate(${x}px, ${y}px)`;
target.setAttribute('data-x', x);
target.setAttribute('data-y', y);
}
function onDragEnd(event: any) {
updateSpaceDimensions(event.target);
}
function onResizeEnd(event: any) {
updateSpaceDimensions(event.target);
}
function updateSpaceDimensions(target: any) {
const id = target.getAttribute('data-id'); const id = target.getAttribute('data-id');
const x = parseFloat(target.getAttribute('data-x')) || 0; const x = parseFloat(target.getAttribute('data-x')) || 0;
const y = parseFloat(target.getAttribute('data-y')) || 0; const y = parseFloat(target.getAttribute('data-y')) || 0;
const width = target.offsetWidth;
const height = target.offsetHeight;
// Convert to percentage if (!containerWidth || !containerHeight) return; // Prevent division by zero
const rect = container.getBoundingClientRect();
const xPercent = (x / rect.width) * 100; // If the element is outside the current container, grow it!
const yPercent = (y / rect.height) * 100; const right = x + width;
const bottom = y + height;
const oldWidth = containerWidth;
const oldHeight = containerHeight;
let needsResize = false;
if (right > containerWidth - 20) {
containerWidth = Math.ceil(right + 40);
needsResize = true;
}
if (bottom > containerHeight - 20) {
containerHeight = Math.ceil(bottom + 40);
needsResize = true;
}
if (needsResize) {
spaces = spaces.map(s => {
// Recalculate all percentages based on new container size to keep pixel positions same
return {
...s,
x: (s.x * oldWidth) / containerWidth,
y: (s.y * oldHeight) / containerHeight,
width: (s.width * oldWidth) / containerWidth,
height: (s.height * oldHeight) / containerHeight
};
});
}
// Convert current target to percentages based on the (potentially new) container size
const xPercent = (x / containerWidth) * 100;
const yPercent = (y / containerHeight) * 100;
const widthPercent = (width / containerWidth) * 100;
const heightPercent = (height / containerHeight) * 100;
if (isNaN(xPercent) || isNaN(yPercent) || isNaN(widthPercent) || isNaN(heightPercent)) return;
const index = spaces.findIndex(s => s.id === id); const index = spaces.findIndex(s => s.id === id);
if (index !== -1) { if (index !== -1) {
spaces[index].x = xPercent; spaces[index].x = xPercent;
spaces[index].y = yPercent; spaces[index].y = yPercent;
spaces[index].width = widthPercent;
spaces[index].height = heightPercent;
spaces = [...spaces];
} }
} }
@ -77,12 +216,11 @@
type: item.type, type: item.type,
x: 5, x: 5,
y: 5, y: 5,
width: item.type === 'DESK' ? 12 : 20, // Default widths as percentage
height: item.type === 'DESK' ? 8 : 15, // Default heights as percentage
floorId floorId
}; };
spaces = [...spaces, newSpace]; spaces = [...spaces, newSpace];
// We need to wait for the DOM to update before initializing interact on new element
setTimeout(() => initInteract(), 0);
} }
async function handleFileUpload(event: Event) { async function handleFileUpload(event: Event) {
@ -113,10 +251,14 @@
async function saveLayout() { async function saveLayout() {
saving = true; saving = true;
try { try {
await apiFetch('/spaces/batch-update', { const response = await apiFetch<any[]>('/spaces/batch-update', {
method: 'POST', method: 'POST',
body: JSON.stringify(spaces) body: JSON.stringify(spaces)
}); });
// Update local spaces with returned data (especially for new IDs)
if (Array.isArray(response)) {
spaces = response;
}
alert('Layout saved successfully!'); alert('Layout saved successfully!');
} catch (err) { } catch (err) {
console.error('Save failed', err); console.error('Save failed', err);
@ -204,15 +346,14 @@
</div> </div>
</div> </div>
<!-- Editor Canvas -->
<div class="flex-1 p-8 overflow-auto bg-gray-200 flex justify-center items-start"> <div class="flex-1 p-8 overflow-auto bg-gray-200 flex justify-center items-start">
<div <div
bind:this={container} bind:this={container}
class="relative bg-white shadow-xl rounded-lg overflow-hidden floorplan-container" 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;" style="width: {containerWidth}px; height: {containerHeight}px; background-image: url({planImageUrl}); background-size: cover; background-repeat: no-repeat; background-position: center;"
> >
{#if !planImageUrl} {#if !planImageUrl}
<div class="absolute inset-0 flex flex-col items-center justify-center text-gray-400"> <div class="absolute inset-0 flex flex-col items-center justify-center text-gray-400 pointer-events-none">
<Upload size={48} class="mb-4 opacity-20" /> <Upload size={48} class="mb-4 opacity-20" />
<p>Upload a floorplan image to start</p> <p>Upload a floorplan image to start</p>
</div> </div>
@ -220,54 +361,70 @@
{#each spaces as space (space.id)} {#each spaces as space (space.id)}
<div <div
class="draggable-space absolute cursor-move p-2 bg-white border-2 border-blue-500 rounded-lg shadow-lg group" use:interactAction={space}
style="left: 0; top: 0; transform: translate({(space.x / 100) * (container?.clientWidth || 800)}px, {(space.y / 100) * (container?.clientHeight || 600)}px)" class="draggable-space absolute p-2 bg-white border-2 border-blue-500 rounded-lg shadow-lg group {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-id={space.id}
data-x={(space.x / 100) * (container?.clientWidth || 800)} data-x={(space.x / 100) * containerWidth}
data-y={(space.y / 100) * (container?.clientHeight || 600)} data-y={(space.y / 100) * containerHeight}
> >
<div class="flex items-center gap-2"> <div class="flex flex-col h-full drag-handle cursor-move">
<div class="text-blue-600"> <div class="flex items-center gap-2 mb-1">
{#if space.type === 'DESK'} <div class="text-blue-600 shrink-0">
<Laptop size={20} /> {#if space.type === 'DESK'}
{:else if space.type === 'MEETING_ROOM'} <Laptop size={16} />
<Users size={20} /> {:else if space.type === 'MEETING_ROOM'}
{:else if space.subtype === 'TOILET'} <Users size={16} />
<Bath size={20} /> {:else if space.subtype === 'TOILET'}
{:else if space.subtype === 'COFFEE'} <Bath size={16} />
<Coffee size={20} /> {:else if space.subtype === 'COFFEE'}
<Coffee size={16} />
{:else}
<div class="w-4 h-4 bg-gray-400 rounded-full"></div>
{/if}
</div>
{#if editingId === space.id}
<input
type="text"
bind:value={space.name}
on:blur={stopEditing}
on:keydown={(e) => handleKeydown(e, space.id)}
on:click|stopPropagation
class="text-[10px] font-bold text-gray-700 border border-blue-300 outline-none bg-blue-50 rounded px-1 w-full"
autofocus
/>
{:else} {:else}
<div class="w-5 h-5 bg-gray-400 rounded-full"></div> <span
on:click|stopPropagation={() => startEditing(space.id)}
title="Click to rename"
class="text-[10px] font-bold text-gray-700 select-none truncate hover:bg-gray-100 px-1 rounded cursor-text"
>
{space.name}
</span>
{/if} {/if}
<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"
>
×
</button>
</div> </div>
{#if editingId === space.id} {#if space.type !== 'DESK'}
<input <div class="flex-1 border border-dashed border-gray-200 rounded flex items-center justify-center text-[8px] text-gray-400 overflow-hidden relative pointer-events-none">
type="text" {space.type.replace('_', ' ')}
bind:value={space.name} </div>
on:blur={stopEditing}
on:keydown={(e) => handleKeydown(e, space.id)}
on:click|stopPropagation
class="text-[10px] font-bold text-gray-700 border border-blue-300 outline-none bg-blue-50 rounded px-1 w-20"
autofocus
/>
{:else}
<span
on:click|stopPropagation={() => startEditing(space.id)}
title="Click to rename"
class="text-[10px] font-bold text-gray-700 select-none whitespace-nowrap hover:bg-gray-100 px-1 rounded cursor-text"
>
{space.name}
</span>
{/if} {/if}
<button
on:click|stopPropagation={() => removeSpace(space.id)}
class="hidden group-hover:flex items-center justify-center w-4 h-4 text-white bg-red-500 rounded-full hover:bg-red-600 ml-1"
>
×
</button>
</div> </div>
{#if space.type !== 'DESK'}
<!-- Explicit resize handle OUTSIDE drag-handle -->
<div class="resize-handle absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize z-10 flex items-center justify-center">
<div class="w-2 h-2 border-b-2 border-r-2 border-blue-400 opacity-50"></div>
</div>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
@ -281,6 +438,15 @@
user-select: none; user-select: none;
} }
.resize-handle {
background: linear-gradient(135deg, transparent 50%, rgba(59, 130, 246, 0.1) 50%, rgba(59, 130, 246, 0.1) 100%);
border-radius: 0 0 4px 0;
}
.resize-handle:hover {
background: linear-gradient(135deg, transparent 50%, rgba(59, 130, 246, 0.3) 50%, rgba(59, 130, 246, 0.3) 100%);
}
.floorplan-container:not([style*="background-image: url()"]):not([style*='background-image: url("")']):not([style*="background-image: url(null)"]) { .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, /* 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". but the instruction says "if layout isn't configured yet".
@ -293,6 +459,6 @@
background-image: background-image:
linear-gradient(to right, #e2e8f0 1px, transparent 1px), linear-gradient(to right, #e2e8f0 1px, transparent 1px),
linear-gradient(to bottom, #e2e8f0 1px, transparent 1px); linear-gradient(to bottom, #e2e8f0 1px, transparent 1px);
background-size: 40px 40px; background-size: 20px 20px;
} }
</style> </style>