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" />
|
||||
</component>
|
||||
<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$/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/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/prisma/schema.prisma" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" 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/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/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" />
|
||||
<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" />
|
||||
|
|
@ -111,8 +107,8 @@
|
|||
</configuration>
|
||||
<recent_temporary>
|
||||
<list>
|
||||
<item itemvalue="npm.web > dev" />
|
||||
<item itemvalue="npm.api > start:dev" />
|
||||
<item itemvalue="npm.web > dev" />
|
||||
<item itemvalue="Docker.docker-compose.yml: Compose Deployment" />
|
||||
</list>
|
||||
</recent_temporary>
|
||||
|
|
@ -133,7 +129,7 @@
|
|||
<updated>1774643312599</updated>
|
||||
<workItem from="1774643313611" duration="74000" />
|
||||
<workItem from="1774643422240" duration="5278000" />
|
||||
<workItem from="1774706361502" duration="1946000" />
|
||||
<workItem from="1774706361502" duration="2888000" />
|
||||
</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.">
|
||||
<option name="closed" value="true" />
|
||||
|
|
@ -175,7 +171,15 @@
|
|||
<option name="project" value="LOCAL" />
|
||||
<updated>1774707617715</updated>
|
||||
</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 />
|
||||
</component>
|
||||
<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="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." />
|
||||
<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>
|
||||
</project>
|
||||
|
|
@ -58,6 +58,8 @@ model Space {
|
|||
floorId String
|
||||
x Float? // Percentage-based for responsiveness (Stage 3)
|
||||
y Float?
|
||||
width Float? // Percentage-based for resizing (Stage 4)
|
||||
height Float? // Percentage-based for resizing (Stage 4)
|
||||
reservations Reservation[]
|
||||
createdAt DateTime @default(now())
|
||||
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 { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { Role } from '@prisma/client';
|
||||
import { UpdateSpaceDto } from './dto/update-space.dto';
|
||||
|
||||
@Controller('spaces')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
|
|
@ -33,7 +34,7 @@ export class SpacesController {
|
|||
|
||||
@Post('batch-update')
|
||||
@Roles(Role.ADMIN)
|
||||
async batchUpdate(@Body() spaces: any[]) {
|
||||
async batchUpdate(@Body(new ParseArrayPipe({ items: UpdateSpaceDto })) spaces: UpdateSpaceDto[]) {
|
||||
return this.spacesService.batchUpdateSpaces(spaces);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
import { UpdateSpaceDto } from './dto/update-space.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SpacesService {
|
||||
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 { id, ...data } = space;
|
||||
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
|
||||
return this.prisma.space.create({
|
||||
data: {
|
||||
name: data.name || `${data.type} ${Math.floor(Math.random() * 1000)}`,
|
||||
type: data.type,
|
||||
floorId: data.floorId!,
|
||||
floorId: data.floorId,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -90,6 +100,8 @@ export class SpacesService {
|
|||
data: {
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
name: data.name,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
let socket: Socket;
|
||||
let spaceStatus: Record<string, any> = {};
|
||||
let selectedSpace: any = null;
|
||||
let containerWidth = 800;
|
||||
let containerHeight = 600;
|
||||
|
||||
$: {
|
||||
// Initialize space status from the current state
|
||||
|
|
@ -26,6 +28,50 @@
|
|||
};
|
||||
});
|
||||
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
|
||||
|
|
@ -74,14 +120,14 @@
|
|||
<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;"
|
||||
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)}
|
||||
{@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'}"
|
||||
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'})"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
|
|
@ -133,6 +179,6 @@
|
|||
background-image:
|
||||
linear-gradient(to right, #e2e8f0 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #e2e8f0 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,43 @@
|
|||
|
||||
let container: HTMLDivElement;
|
||||
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 = [
|
||||
{ type: 'DESK', icon: Laptop, label: 'Desk' },
|
||||
{ type: 'MEETING_ROOM', icon: Users, label: 'Meeting Room' },
|
||||
|
|
@ -21,17 +58,25 @@
|
|||
let editingId: string | null = null;
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
onMount(() => {
|
||||
initInteract();
|
||||
});
|
||||
function interactAction(node: HTMLElement, space: any) {
|
||||
const isDesk = space.type === 'DESK';
|
||||
const interactable = interact(node);
|
||||
|
||||
function initInteract() {
|
||||
interact('.draggable-space').draggable({
|
||||
interactable.draggable({
|
||||
inertia: true,
|
||||
allowFrom: '.drag-handle',
|
||||
modifiers: [
|
||||
interact.modifiers.restrictRect({
|
||||
restriction: 'parent',
|
||||
endOnly: true
|
||||
}),
|
||||
interact.modifiers.snap({
|
||||
targets: [
|
||||
interact.snappers.grid({ x: 20, y: 20 })
|
||||
],
|
||||
range: Infinity,
|
||||
relativePoints: [ { x: 0, y: 0 } ],
|
||||
offset: 'parent'
|
||||
})
|
||||
],
|
||||
autoScroll: true,
|
||||
|
|
@ -40,6 +85,37 @@
|
|||
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) {
|
||||
|
|
@ -52,21 +128,84 @@
|
|||
target.setAttribute('data-y', y);
|
||||
}
|
||||
|
||||
function onDragEnd(event: any) {
|
||||
function resizeMoveListener(event: any) {
|
||||
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 x = parseFloat(target.getAttribute('data-x')) || 0;
|
||||
const y = parseFloat(target.getAttribute('data-y')) || 0;
|
||||
const width = target.offsetWidth;
|
||||
const height = target.offsetHeight;
|
||||
|
||||
// Convert to percentage
|
||||
const rect = container.getBoundingClientRect();
|
||||
const xPercent = (x / rect.width) * 100;
|
||||
const yPercent = (y / rect.height) * 100;
|
||||
if (!containerWidth || !containerHeight) return; // Prevent division by zero
|
||||
|
||||
// If the element is outside the current container, grow it!
|
||||
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);
|
||||
if (index !== -1) {
|
||||
spaces[index].x = xPercent;
|
||||
spaces[index].y = yPercent;
|
||||
spaces[index].width = widthPercent;
|
||||
spaces[index].height = heightPercent;
|
||||
spaces = [...spaces];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,12 +216,11 @@
|
|||
type: item.type,
|
||||
x: 5,
|
||||
y: 5,
|
||||
width: item.type === 'DESK' ? 12 : 20, // Default widths as percentage
|
||||
height: item.type === 'DESK' ? 8 : 15, // Default heights as percentage
|
||||
floorId
|
||||
};
|
||||
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) {
|
||||
|
|
@ -113,10 +251,14 @@
|
|||
async function saveLayout() {
|
||||
saving = true;
|
||||
try {
|
||||
await apiFetch('/spaces/batch-update', {
|
||||
const response = await apiFetch<any[]>('/spaces/batch-update', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(spaces)
|
||||
});
|
||||
// Update local spaces with returned data (especially for new IDs)
|
||||
if (Array.isArray(response)) {
|
||||
spaces = response;
|
||||
}
|
||||
alert('Layout saved successfully!');
|
||||
} catch (err) {
|
||||
console.error('Save failed', err);
|
||||
|
|
@ -204,15 +346,14 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor Canvas -->
|
||||
<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 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}
|
||||
<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" />
|
||||
<p>Upload a floorplan image to start</p>
|
||||
</div>
|
||||
|
|
@ -220,54 +361,70 @@
|
|||
|
||||
{#each spaces as space (space.id)}
|
||||
<div
|
||||
class="draggable-space absolute cursor-move p-2 bg-white border-2 border-blue-500 rounded-lg shadow-lg group"
|
||||
style="left: 0; top: 0; transform: translate({(space.x / 100) * (container?.clientWidth || 800)}px, {(space.y / 100) * (container?.clientHeight || 600)}px)"
|
||||
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' : ''}"
|
||||
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) * (container?.clientWidth || 800)}
|
||||
data-y={(space.y / 100) * (container?.clientHeight || 600)}
|
||||
data-x={(space.x / 100) * containerWidth}
|
||||
data-y={(space.y / 100) * containerHeight}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-blue-600">
|
||||
{#if space.type === 'DESK'}
|
||||
<Laptop size={20} />
|
||||
{:else if space.type === 'MEETING_ROOM'}
|
||||
<Users size={20} />
|
||||
{:else if space.subtype === 'TOILET'}
|
||||
<Bath size={20} />
|
||||
{:else if space.subtype === 'COFFEE'}
|
||||
<Coffee size={20} />
|
||||
<div class="flex flex-col h-full drag-handle cursor-move">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="text-blue-600 shrink-0">
|
||||
{#if space.type === 'DESK'}
|
||||
<Laptop size={16} />
|
||||
{:else if space.type === 'MEETING_ROOM'}
|
||||
<Users size={16} />
|
||||
{:else if space.subtype === 'TOILET'}
|
||||
<Bath size={16} />
|
||||
{: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}
|
||||
<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}
|
||||
|
||||
<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>
|
||||
|
||||
{#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-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 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}
|
||||
|
||||
<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>
|
||||
|
||||
{#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>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -281,6 +438,15 @@
|
|||
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)"]) {
|
||||
/* 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".
|
||||
|
|
@ -293,6 +459,6 @@
|
|||
background-image:
|
||||
linear-gradient(to right, #e2e8f0 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #e2e8f0 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in New Issue