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.
main
Pau Costa Ferrer 2026-03-28 15:15:28 +01:00
parent 3b09daeb04
commit 7683066ceb
14 changed files with 482 additions and 81 deletions

View File

@ -6,13 +6,15 @@
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Implement authentication, organization management, and floor layout features&#10;&#10;- Add API authentication with JWT strategy using NestJS.&#10;- Configure Prisma schema for organizations, users, buildings, floors, and spaces.&#10;- Seed initial database with test data and relationships.&#10;- Build SvelteKit web pages for dashboard and floor layout with secured routing and organization hierarchy display.&#10;- Remove deprecated `AppService` from API."> <list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Implement authentication, organization management, and floor layout features&#10;&#10;- Add API authentication with JWT strategy using NestJS.&#10;- Configure Prisma schema for organizations, users, buildings, floors, and spaces.&#10;- Seed initial database with test data and relationships.&#10;- Build SvelteKit web pages for dashboard and floor layout with secured routing and organization hierarchy display.&#10;- Remove deprecated `AppService` from API.">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/package.json" 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/prisma/schema.prisma" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/app.module.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/app.module.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/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/package.json" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/web/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/api/client.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/api/client.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/web/src/lib/api/client.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/api/client.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/+page.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/floor/[id]/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/floor/[id]/+page.svelte" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/floor/[id]/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/floor/[id]/+page.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/login/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/login/+page.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/vite.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/vite.config.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" /> <change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
@ -21,7 +23,7 @@
<option name="LAST_RESOLUTION" value="IGNORE" /> <option name="LAST_RESOLUTION" value="IGNORE" />
</component> </component>
<component name="EmbeddingIndexingInfo"> <component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="10" /> <option name="cachedIndexableFilesCount" value="83" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" /> <option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component> </component>
<component name="Git.Settings"> <component name="Git.Settings">
@ -40,42 +42,44 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"Docker.docker-compose.yml: Compose Deployment.executor": "Run", &quot;Docker.docker-compose.yml: Compose Deployment.executor&quot;: &quot;Run&quot;,
"ModuleVcsDetector.initialDetectionPerformed": "true", &quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
"RunOnceActivity.MCP Project settings loaded": "true", &quot;RunOnceActivity.MCP Project settings loaded&quot;: &quot;true&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"RunOnceActivity.typescript.service.memoryLimit.init": "true", &quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
"codeWithMe.voiceChat.enabledByDefault": "false", &quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true", &quot;com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1&quot;: &quot;true&quot;,
"git-widget-placeholder": "main", &quot;git-widget-placeholder&quot;: &quot;main&quot;,
"javascript.preferred.runtime.type.id": "node", &quot;javascript.preferred.runtime.type.id&quot;: &quot;node&quot;,
"junie.onboarding.icon.badge.shown": "true", &quot;junie.onboarding.icon.badge.shown&quot;: &quot;true&quot;,
"node.js.detected.package.eslint": "true", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"node.js.detected.package.tslint": "true", &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"nodejs_interpreter_path": "/home/micosil/.nvm/versions/node/v22.21.1/bin/node", &quot;nodejs_interpreter_path&quot;: &quot;/home/micosil/.nvm/versions/node/v22.21.1/bin/node&quot;,
"nodejs_package_manager_path": "npm", &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
"npm.api > start:dev.executor": "Run", &quot;npm.api &gt; start:dev.executor&quot;: &quot;Run&quot;,
"npm.web > dev.executor": "Run", &quot;npm.web &gt; dev.executor&quot;: &quot;Run&quot;,
"settings.editor.selected.configurable": "settings.javascript.runtime", &quot;settings.editor.selected.configurable&quot;: &quot;settings.javascript.runtime&quot;,
"to.speed.mode.migration.done": "true", &quot;to.speed.mode.migration.done&quot;: &quot;true&quot;,
"ts.external.directory.path": "/home/micosil/WebstormProjects/desker/node_modules/typescript/lib", &quot;ts.external.directory.path&quot;: &quot;/home/micosil/WebstormProjects/desker/node_modules/typescript/lib&quot;,
"vue.rearranger.settings.migration": "true" &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}, },
"keyToStringList": { &quot;keyToStringList&quot;: {
"DatabaseDriversLRU": [ &quot;DatabaseDriversLRU&quot;: [
"postgresql" &quot;postgresql&quot;
] ]
} }
}]]></component> }</component>
<component name="RunManager" selected="npm.web &gt; dev"> <component name="RunManager" selected="npm.web &gt; dev">
<configuration default="true" type="docker-deploy" factoryName="docker-compose.yml" temporary="true"> <configuration default="true" type="docker-deploy" factoryName="docker-compose.yml" temporary="true">
<deployment type="docker-compose.yml" /> <deployment type="docker-compose.yml">
<settings />
</deployment>
<method v="2" /> <method v="2" />
</configuration> </configuration>
<configuration name="docker-compose.yml: Compose Deployment" type="docker-deploy" factoryName="docker-compose.yml" temporary="true" server-name="Docker"> <configuration name="docker-compose.yml: Compose Deployment" type="docker-deploy" factoryName="docker-compose.yml" temporary="true" server-name="Docker">
@ -130,6 +134,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="897000" />
</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" />

View File

@ -40,13 +40,14 @@ model Building {
} }
model Floor { model Floor {
id String @id @default(uuid()) id String @id @default(uuid())
number Int number Int
building Building @relation(fields: [buildingId], references: [id]) building Building @relation(fields: [buildingId], references: [id])
buildingId String buildingId String
spaces Space[] planImageUrl String?
createdAt DateTime @default(now()) spaces Space[]
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model Space { model Space {

View File

@ -1,7 +1,9 @@
import { Controller, Get, Param, UseGuards, Request } from '@nestjs/common'; import { Controller, Get, Post, Body, Param, UseGuards, Request, Patch } 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 { Role } from '@prisma/client';
@Controller('spaces') @Controller('spaces')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@ -17,4 +19,16 @@ export class SpacesController {
async getOrgHierarchy(@Request() req: { user: { organizationId: string } }) { async getOrgHierarchy(@Request() req: { user: { organizationId: string } }) {
return this.spacesService.getBuildingsAndFloors(req.user.organizationId); return this.spacesService.getBuildingsAndFloors(req.user.organizationId);
} }
@Patch('floor/:id/plan')
@Roles(Role.ADMIN)
async updateFloorPlan(@Param('id') id: string, @Body('planImageUrl') planImageUrl: string) {
return this.spacesService.updateFloorPlan(id, planImageUrl);
}
@Post('batch-update')
@Roles(Role.ADMIN)
async batchUpdate(@Body() spaces: any[]) {
return this.spacesService.batchUpdateSpaces(spaces);
}
} }

View File

@ -42,4 +42,38 @@ export class SpacesService {
}, },
}); });
} }
async updateFloorPlan(floorId: string, planImageUrl: string) {
return this.prisma.floor.update({
where: { id: floorId },
data: { planImageUrl },
});
}
async batchUpdateSpaces(spaces: { id: string; x: number; y: number; type?: any; name?: string; floorId?: string }[]) {
const updates = spaces.map((space) => {
const { id, ...data } = space;
if (id.startsWith('temp-')) {
// 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!,
x: data.x,
y: data.y,
},
});
}
return this.prisma.space.update({
where: { id },
data: {
x: data.x,
y: data.y,
name: data.name,
},
});
});
return Promise.all(updates);
}
} }

View File

@ -27,6 +27,7 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"import-meta-resolve": "^4.2.0", "import-meta-resolve": "^4.2.0",
"interactjs": "^1.10.27",
"socket.io-client": "^4.8.3" "socket.io-client": "^4.8.3"
} }
} }

View File

@ -5,15 +5,31 @@ export async function apiFetch<T>(
options: RequestInit & { fetch?: typeof fetch } = {}, options: RequestInit & { fetch?: typeof fetch } = {},
): Promise<T> { ): Promise<T> {
const fetchFn = options.fetch ?? fetch; const fetchFn = options.fetch ?? fetch;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> ?? {}),
};
// Automatically add Authorization header if token exists in localStorage (browser)
if (typeof window !== 'undefined') {
const token = localStorage.getItem('token');
if (token && !headers['Authorization']) {
headers['Authorization'] = `Bearer ${token}`;
}
}
const res = await fetchFn(`${API_BASE_URL}${path}`, { const res = await fetchFn(`${API_BASE_URL}${path}`, {
headers: {
'Content-Type': 'application/json',
...(options.headers ?? {}),
},
...options, ...options,
headers,
}); });
if (!res.ok) { if (!res.ok) {
if (res.status === 401 && typeof window !== 'undefined') {
// Potentially logout or redirect to login
console.warn('Unauthorized request, redirecting to login...');
// window.location.href = '/login';
}
const text = await res.text(); const text = await res.text();
throw new Error(text || `Request failed: ${res.status}`); throw new Error(text || `Request failed: ${res.status}`);
} }

View File

@ -0,0 +1,283 @@
<script lang="ts">
import { onMount } from 'svelte';
import interact from 'interactjs';
import { Laptop, Bath, Coffee, Users, Save, Upload } from 'lucide-svelte';
import { apiFetch } from '$lib/api/client';
export let floorId: string;
export let initialSpaces: any[] = [];
export let planImageUrl: string | null = null;
let container: HTMLDivElement;
let spaces = [...initialSpaces];
let toolboxItems = [
{ type: 'DESK', icon: Laptop, label: 'Desk' },
{ type: 'MEETING_ROOM', icon: Users, label: 'Meeting Room' },
{ type: 'AMENITY', subtype: 'TOILET', icon: Bath, label: 'Toilet' },
{ type: 'AMENITY', subtype: 'COFFEE', icon: Coffee, label: 'Coffee' },
];
let saving = false;
let editingId: string | null = null;
let fileInput: HTMLInputElement;
onMount(() => {
initInteract();
});
function initInteract() {
interact('.draggable-space').draggable({
inertia: true,
modifiers: [
interact.modifiers.restrictRect({
restriction: 'parent',
endOnly: true
})
],
autoScroll: true,
listeners: {
move: dragMoveListener,
end: onDragEnd
}
});
}
function dragMoveListener(event: any) {
const target = event.target;
const x = (parseFloat(target.getAttribute('data-x')) || 0) + event.dx;
const y = (parseFloat(target.getAttribute('data-y')) || 0) + event.dy;
target.style.transform = `translate(${x}px, ${y}px)`;
target.setAttribute('data-x', x);
target.setAttribute('data-y', y);
}
function onDragEnd(event: any) {
const target = event.target;
const id = target.getAttribute('data-id');
const x = parseFloat(target.getAttribute('data-x')) || 0;
const y = parseFloat(target.getAttribute('data-y')) || 0;
// Convert to percentage
const rect = container.getBoundingClientRect();
const xPercent = (x / rect.width) * 100;
const yPercent = (y / rect.height) * 100;
const index = spaces.findIndex(s => s.id === id);
if (index !== -1) {
spaces[index].x = xPercent;
spaces[index].y = yPercent;
}
}
function addSpace(item: any) {
const newSpace = {
id: `temp-${Date.now()}`,
name: `${item.label} ${spaces.length + 1}`,
type: item.type,
x: 5,
y: 5,
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) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
// In a real app, we would upload to S3/Cloudinary and get a URL
// For this stage, we'll use a data URL as a placeholder or mock the upload
const reader = new FileReader();
reader.onload = async (e) => {
const url = e.target?.result as string;
planImageUrl = url;
// Save to backend
try {
await apiFetch(`/spaces/floor/${floorId}/plan`, {
method: 'PATCH',
body: JSON.stringify({ planImageUrl: url })
});
} catch (err) {
console.error('Failed to update floor plan', err);
}
};
reader.readAsDataURL(file);
}
async function saveLayout() {
saving = true;
try {
await apiFetch('/spaces/batch-update', {
method: 'POST',
body: JSON.stringify(spaces)
});
alert('Layout saved successfully!');
} catch (err) {
console.error('Save failed', err);
alert('Failed to save layout');
} finally {
saving = false;
}
}
function removeSpace(id: string) {
if (editingId === id) editingId = null;
spaces = spaces.filter(s => s.id !== id);
}
function startEditing(id: string) {
editingId = id;
}
function stopEditing() {
editingId = null;
}
function handleKeydown(event: KeyboardEvent, id: string) {
if (event.key === 'Enter') {
stopEditing();
}
}
</script>
<div class="flex flex-col h-full bg-gray-50">
<div class="flex items-center justify-between p-4 bg-white border-b shadow-sm">
<h2 class="text-xl font-bold text-gray-800">Floorplan Editor</h2>
<div class="flex gap-2">
<button
on:click={() => fileInput.click()}
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
<Upload size={16} />
Upload Floorplan
</button>
<input
type="file"
accept="image/*"
bind:this={fileInput}
on:change={handleFileUpload}
class="hidden"
/>
<button
on:click={saveLayout}
disabled={saving}
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
<Save size={16} />
{saving ? 'Saving...' : 'Save Layout'}
</button>
</div>
</div>
<div class="flex flex-1 overflow-hidden">
<!-- Toolbox -->
<div class="w-64 p-4 bg-white border-r">
<h3 class="mb-4 text-sm font-semibold text-gray-500 uppercase tracking-wider">Toolbox</h3>
<div class="grid grid-cols-1 gap-3">
{#each toolboxItems as item}
<button
on:click={() => addSpace(item)}
class="flex items-center gap-3 p-3 transition-colors border rounded-lg hover:bg-blue-50 hover:border-blue-200 group"
>
<div class="p-2 text-gray-500 bg-gray-100 rounded group-hover:bg-blue-100 group-hover:text-blue-600">
<svelte:component this={item.icon} size={20} />
</div>
<span class="text-sm font-medium text-gray-700">{item.label}</span>
</button>
{/each}
</div>
<div class="mt-8">
<h3 class="mb-4 text-sm font-semibold text-gray-500 uppercase tracking-wider">Instructions</h3>
<ul class="text-xs text-gray-500 space-y-2 list-disc pl-4">
<li>Upload a JPG/PNG of the floorplan.</li>
<li>Click icons in the toolbox to add them.</li>
<li>Drag icons to position them on the map.</li>
<li>Positions are saved as percentages for responsiveness.</li>
</ul>
</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"
style="width: 800px; height: 600px; background-image: url({planImageUrl}); background-size: contain; background-repeat: no-repeat; background-position: center;"
>
{#if !planImageUrl}
<div class="absolute inset-0 flex flex-col items-center justify-center text-gray-400">
<Upload size={48} class="mb-4 opacity-20" />
<p>Upload a floorplan image to start</p>
</div>
{/if}
{#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)"
data-id={space.id}
data-x={(space.x / 100) * (container?.clientWidth || 800)}
data-y={(space.y / 100) * (container?.clientHeight || 600)}
>
<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} />
{:else}
<div class="w-5 h-5 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-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}
<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>
{/each}
</div>
</div>
</div>
</div>
<style>
.draggable-space {
touch-action: none;
user-select: none;
}
</style>

View File

@ -0,0 +1,24 @@
<script lang="ts">
import FloorplanEditor from '$lib/components/admin/FloorplanEditor.svelte';
export let data;
$: floor = data.floor;
$: floorId = floor?.id;
$: initialSpaces = floor?.spaces || [];
$: planImageUrl = floor?.planImageUrl;
</script>
<div class="h-screen flex flex-col">
{#if floorId}
<FloorplanEditor
{floorId}
{initialSpaces}
{planImageUrl}
/>
{:else}
<div class="p-8 text-center text-gray-500">
Loading floor data...
</div>
{/if}
</div>

View File

@ -0,0 +1,13 @@
import type { PageLoad } from './$types';
import { apiFetch } from '$lib/api/client';
import { error } from '@sveltejs/kit';
export const load: PageLoad = async ({ params, fetch }) => {
const floorId = params.id;
try {
const floor = await apiFetch<any>(`/spaces/floor/${floorId}`, { fetch });
return { floor };
} catch (err: any) {
throw error(err.status || 500, err.message || 'Failed to load floor data');
}
};

View File

@ -2,6 +2,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { token, user } from "$lib/stores/auth"; import { token, user } from "$lib/stores/auth";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { apiFetch } from "$lib/api/client";
let buildings = []; let buildings = [];
let loading = true; let loading = true;
@ -13,18 +14,12 @@
} }
try { try {
const res = await fetch("http://localhost:3000/spaces/org-hierarchy", { buildings = await apiFetch("/spaces/org-hierarchy");
headers: {
Authorization: `Bearer ${$token}`,
},
});
if (res.ok) {
buildings = await res.json();
} else {
goto("/login");
}
} catch (e) { } catch (e) {
console.error(e); console.error(e);
if (e instanceof Error && e.message.includes('401')) {
goto("/login");
}
} finally { } finally {
loading = false; loading = false;
} }
@ -47,6 +42,9 @@
<li class="mb-1"> <li class="mb-1">
Floor {floor.number} Floor {floor.number}
<a href="/dashboard/floor/{floor.id}" class="ml-2 text-blue-600 hover:underline">View Layout</a> <a href="/dashboard/floor/{floor.id}" class="ml-2 text-blue-600 hover:underline">View Layout</a>
{#if $user?.role === 'ADMIN'}
<a href="/admin/floorplan/{floor.id}" class="ml-4 text-orange-600 hover:underline">Edit Layout</a>
{/if}
</li> </li>
{/each} {/each}
</ul> </ul>

View File

@ -4,6 +4,7 @@
import { token } from "$lib/stores/auth"; import { token } from "$lib/stores/auth";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import SpaceCard from '$lib/components/SpaceCard.svelte'; import SpaceCard from '$lib/components/SpaceCard.svelte';
import { apiFetch } from "$lib/api/client";
let floor: any = null; let floor: any = null;
let loading = true; let loading = true;
@ -16,20 +17,12 @@
} }
try { try {
const apiBase = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000'; floor = await apiFetch(`/spaces/floor/${floorId}`);
const res = await fetch(`${apiBase}/spaces/floor/${floorId}`, { } catch (e: any) {
headers: { console.error(e);
'Content-Type': 'application/json', if (e.message?.includes('401')) {
'Authorization': `Bearer ${$token}`,
},
});
if (res.ok) {
floor = await res.json();
} else if (res.status === 401) {
goto("/login"); goto("/login");
} }
} catch (e) {
console.error(e);
} finally { } finally {
loading = false; loading = false;
} }

View File

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { token, user, logout } from "$lib/stores/auth"; import { token, user, logout } from "$lib/stores/auth";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { apiFetch } from "$lib/api/client";
let email = ""; let email = "";
let password = ""; let password = "";
@ -10,24 +10,22 @@
async function handleLogin() { async function handleLogin() {
try { try {
const res = await fetch("http://localhost:3000/auth/login", { const data: any = await apiFetch("/auth/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}); });
if (res.ok) { token.set(data.access_token);
const data = await res.json(); user.set(data.user);
token.set(data.access_token);
user.set(data.user);
const redirect = $page.url.searchParams.get("redirect") || "/dashboard"; const redirect = $page.url.searchParams.get("redirect") || "/dashboard";
goto(redirect); goto(redirect);
} else { } catch (e: any) {
if (e.message?.includes('401')) {
error = "Invalid credentials"; error = "Invalid credentials";
} else {
error = "Failed to connect to server";
} }
} catch (e) {
error = "Failed to connect to server";
} }
} }

View File

@ -6,6 +6,13 @@ export default defineConfig({
server: { server: {
fs: { fs: {
allow: ['../../shared'] allow: ['../../shared']
},
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
} }
} }
}); });

14
package-lock.json generated
View File

@ -65,6 +65,7 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"import-meta-resolve": "^4.2.0", "import-meta-resolve": "^4.2.0",
"interactjs": "^1.10.27",
"socket.io-client": "^4.8.3" "socket.io-client": "^4.8.3"
}, },
"devDependencies": { "devDependencies": {
@ -1580,6 +1581,11 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@interactjs/types": {
"version": "1.10.27",
"resolved": "https://registry.npmjs.org/@interactjs/types/-/types-1.10.27.tgz",
"integrity": "sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA=="
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -7331,6 +7337,14 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/interactjs": {
"version": "1.10.27",
"resolved": "https://registry.npmjs.org/interactjs/-/interactjs-1.10.27.tgz",
"integrity": "sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==",
"dependencies": {
"@interactjs/types": "1.10.27"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",