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
parent
45e42c3d37
commit
89fdbf6617
|
|
@ -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 - 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.">
|
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add booking modal and enhance reservation handling - Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations. - Add API endpoint to fetch reservations by space ID and date. - Update `SpacesService` with enhanced validation rules and conflict checks for reservations. - Extend WebSocket updates to include reservation data for real-time synchronization. - 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 > dev" />
|
|
||||||
<item itemvalue="npm.api > start:dev" />
|
<item itemvalue="npm.api > start:dev" />
|
||||||
|
<item itemvalue="npm.web > 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 - Set up SvelteKit for the web interface with Tailwind CSS. - Build the API using NestJS with Prisma ORM for database interaction. - Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. - Create shared data models for consistent typing across services. - Establish basic routing and layouts for web and API services.">
|
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services - Set up SvelteKit for the web interface with Tailwind CSS. - Build the API using NestJS with Prisma ORM for database interaction. - Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. - Create shared data models for consistent typing across services. - 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 - Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations. - Add API endpoint to fetch reservations by space ID and date. - Update `SpacesService` with enhanced validation rules and conflict checks for reservations. - Extend WebSocket updates to include reservation data for real-time synchronization. - 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 - Add API authentication with JWT strategy using NestJS. - Configure Prisma schema for organizations, users, buildings, floors, and spaces. - Seed initial database with test data and relationships. - Build SvelteKit web pages for dashboard and floor layout with secured routing and organization hierarchy display. - Remove deprecated `AppService` from API." />
|
<MESSAGE value="Implement authentication, organization management, and floor layout features - Add API authentication with JWT strategy using NestJS. - Configure Prisma schema for organizations, users, buildings, floors, and spaces. - Seed initial database with test data and relationships. - Build SvelteKit web pages for dashboard and floor layout with secured routing and organization hierarchy display. - Remove deprecated `AppService` from API." />
|
||||||
<MESSAGE value="Add floorplan editor for admin layout management - Implement `FloorplanEditor.svelte` component for adding, editing, and positioning spaces. - Add backend support for floorplan image upload and batch updating space positions. - Update Prisma schema to include `planImageUrl` for floors. - Enable API proxy in Vite configuration for straightforward local development. - Refactor API calls to use a centralized `apiFetch` utility with token handling." />
|
<MESSAGE value="Add floorplan editor for admin layout management - Implement `FloorplanEditor.svelte` component for adding, editing, and positioning spaces. - Add backend support for floorplan image upload and batch updating space positions. - Update Prisma schema to include `planImageUrl` for floors. - Enable API proxy in Vite configuration for straightforward local development. - Refactor API calls to use a centralized `apiFetch` utility with token handling." />
|
||||||
<MESSAGE value="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." />
|
<MESSAGE value="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." />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="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." />
|
<MESSAGE value="Add booking modal and enhance reservation handling - Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations. - Add API endpoint to fetch reservations by space ID and date. - Update `SpacesService` with enhanced validation rules and conflict checks for reservations. - Extend WebSocket updates to include reservation data for real-time synchronization. - 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 - Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations. - Add API endpoint to fetch reservations by space ID and date. - Update `SpacesService` with enhanced validation rules and conflict checks for reservations. - Extend WebSocket updates to include reservation data for real-time synchronization. - Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`)." />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,24 +361,26 @@
|
||||||
|
|
||||||
{#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">
|
||||||
|
<div class="text-blue-600 shrink-0">
|
||||||
{#if space.type === 'DESK'}
|
{#if space.type === 'DESK'}
|
||||||
<Laptop size={20} />
|
<Laptop size={16} />
|
||||||
{:else if space.type === 'MEETING_ROOM'}
|
{:else if space.type === 'MEETING_ROOM'}
|
||||||
<Users size={20} />
|
<Users size={16} />
|
||||||
{:else if space.subtype === 'TOILET'}
|
{:else if space.subtype === 'TOILET'}
|
||||||
<Bath size={20} />
|
<Bath size={16} />
|
||||||
{:else if space.subtype === 'COFFEE'}
|
{:else if space.subtype === 'COFFEE'}
|
||||||
<Coffee size={20} />
|
<Coffee size={16} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-5 h-5 bg-gray-400 rounded-full"></div>
|
<div class="w-4 h-4 bg-gray-400 rounded-full"></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -248,14 +391,14 @@
|
||||||
on:blur={stopEditing}
|
on:blur={stopEditing}
|
||||||
on:keydown={(e) => handleKeydown(e, space.id)}
|
on:keydown={(e) => handleKeydown(e, space.id)}
|
||||||
on:click|stopPropagation
|
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"
|
class="text-[10px] font-bold text-gray-700 border border-blue-300 outline-none bg-blue-50 rounded px-1 w-full"
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<span
|
<span
|
||||||
on:click|stopPropagation={() => startEditing(space.id)}
|
on:click|stopPropagation={() => startEditing(space.id)}
|
||||||
title="Click to rename"
|
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"
|
class="text-[10px] font-bold text-gray-700 select-none truncate hover:bg-gray-100 px-1 rounded cursor-text"
|
||||||
>
|
>
|
||||||
{space.name}
|
{space.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -263,11 +406,25 @@
|
||||||
|
|
||||||
<button
|
<button
|
||||||
on:click|stopPropagation={() => removeSpace(space.id)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if space.type !== 'DESK'}
|
||||||
|
<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">
|
||||||
|
{space.type.replace('_', ' ')}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue