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
parent
3b09daeb04
commit
7683066ceb
|
|
@ -6,13 +6,15 @@
|
|||
<component name="ChangeListManager">
|
||||
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="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.">
|
||||
<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/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/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/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/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" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
|
|
@ -21,7 +23,7 @@
|
|||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||
</component>
|
||||
<component name="EmbeddingIndexingInfo">
|
||||
<option name="cachedIndexableFilesCount" value="10" />
|
||||
<option name="cachedIndexableFilesCount" value="83" />
|
||||
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
|
||||
</component>
|
||||
<component name="Git.Settings">
|
||||
|
|
@ -40,42 +42,44 @@
|
|||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent"><![CDATA[{
|
||||
"keyToString": {
|
||||
"Docker.docker-compose.yml: Compose Deployment.executor": "Run",
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.MCP Project settings loaded": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
|
||||
"git-widget-placeholder": "main",
|
||||
"javascript.preferred.runtime.type.id": "node",
|
||||
"junie.onboarding.icon.badge.shown": "true",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_interpreter_path": "/home/micosil/.nvm/versions/node/v22.21.1/bin/node",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"npm.api > start:dev.executor": "Run",
|
||||
"npm.web > dev.executor": "Run",
|
||||
"settings.editor.selected.configurable": "settings.javascript.runtime",
|
||||
"to.speed.mode.migration.done": "true",
|
||||
"ts.external.directory.path": "/home/micosil/WebstormProjects/desker/node_modules/typescript/lib",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
<component name="PropertiesComponent">{
|
||||
"keyToString": {
|
||||
"Docker.docker-compose.yml: Compose Deployment.executor": "Run",
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.MCP Project settings loaded": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
|
||||
"git-widget-placeholder": "main",
|
||||
"javascript.preferred.runtime.type.id": "node",
|
||||
"junie.onboarding.icon.badge.shown": "true",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_interpreter_path": "/home/micosil/.nvm/versions/node/v22.21.1/bin/node",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"npm.api > start:dev.executor": "Run",
|
||||
"npm.web > dev.executor": "Run",
|
||||
"settings.editor.selected.configurable": "settings.javascript.runtime",
|
||||
"to.speed.mode.migration.done": "true",
|
||||
"ts.external.directory.path": "/home/micosil/WebstormProjects/desker/node_modules/typescript/lib",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
},
|
||||
"keyToStringList": {
|
||||
"DatabaseDriversLRU": [
|
||||
"postgresql"
|
||||
"keyToStringList": {
|
||||
"DatabaseDriversLRU": [
|
||||
"postgresql"
|
||||
]
|
||||
}
|
||||
}]]></component>
|
||||
}</component>
|
||||
<component name="RunManager" selected="npm.web > dev">
|
||||
<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" />
|
||||
</configuration>
|
||||
<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>
|
||||
<workItem from="1774643313611" duration="74000" />
|
||||
<workItem from="1774643422240" duration="5278000" />
|
||||
<workItem from="1774706361502" duration="897000" />
|
||||
</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" />
|
||||
|
|
|
|||
|
|
@ -40,13 +40,14 @@ model Building {
|
|||
}
|
||||
|
||||
model Floor {
|
||||
id String @id @default(uuid())
|
||||
number Int
|
||||
building Building @relation(fields: [buildingId], references: [id])
|
||||
buildingId String
|
||||
spaces Space[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
number Int
|
||||
building Building @relation(fields: [buildingId], references: [id])
|
||||
buildingId String
|
||||
planImageUrl String?
|
||||
spaces Space[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Space {
|
||||
|
|
|
|||
|
|
@ -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 { 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';
|
||||
|
||||
@Controller('spaces')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
|
|
@ -17,4 +19,16 @@ export class SpacesController {
|
|||
async getOrgHierarchy(@Request() req: { user: { organizationId: string } }) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"type": "module",
|
||||
"dependencies": {
|
||||
"import-meta-resolve": "^4.2.0",
|
||||
"interactjs": "^1.10.27",
|
||||
"socket.io-client": "^4.8.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,31 @@ export async function apiFetch<T>(
|
|||
options: RequestInit & { fetch?: typeof fetch } = {},
|
||||
): Promise<T> {
|
||||
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}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
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();
|
||||
throw new Error(text || `Request failed: ${res.status}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import { onMount } from "svelte";
|
||||
import { token, user } from "$lib/stores/auth";
|
||||
import { goto } from "$app/navigation";
|
||||
import { apiFetch } from "$lib/api/client";
|
||||
|
||||
let buildings = [];
|
||||
let loading = true;
|
||||
|
|
@ -13,18 +14,12 @@
|
|||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("http://localhost:3000/spaces/org-hierarchy", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${$token}`,
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
buildings = await res.json();
|
||||
} else {
|
||||
goto("/login");
|
||||
}
|
||||
buildings = await apiFetch("/spaces/org-hierarchy");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e instanceof Error && e.message.includes('401')) {
|
||||
goto("/login");
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
|
@ -47,6 +42,9 @@
|
|||
<li class="mb-1">
|
||||
Floor {floor.number}
|
||||
<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>
|
||||
{/each}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { token } from "$lib/stores/auth";
|
||||
import { goto } from "$app/navigation";
|
||||
import SpaceCard from '$lib/components/SpaceCard.svelte';
|
||||
import { apiFetch } from "$lib/api/client";
|
||||
|
||||
let floor: any = null;
|
||||
let loading = true;
|
||||
|
|
@ -16,20 +17,12 @@
|
|||
}
|
||||
|
||||
try {
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
|
||||
const res = await fetch(`${apiBase}/spaces/floor/${floorId}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${$token}`,
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
floor = await res.json();
|
||||
} else if (res.status === 401) {
|
||||
floor = await apiFetch(`/spaces/floor/${floorId}`);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
if (e.message?.includes('401')) {
|
||||
goto("/login");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { token, user, logout } from "$lib/stores/auth";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
import { page } from "$app/stores";
|
||||
import { apiFetch } from "$lib/api/client";
|
||||
|
||||
let email = "";
|
||||
let password = "";
|
||||
|
|
@ -10,24 +10,22 @@
|
|||
|
||||
async function handleLogin() {
|
||||
try {
|
||||
const res = await fetch("http://localhost:3000/auth/login", {
|
||||
const data: any = await apiFetch("/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
token.set(data.access_token);
|
||||
user.set(data.user);
|
||||
|
||||
const redirect = $page.url.searchParams.get("redirect") || "/dashboard";
|
||||
goto(redirect);
|
||||
} else {
|
||||
token.set(data.access_token);
|
||||
user.set(data.user);
|
||||
|
||||
const redirect = $page.url.searchParams.get("redirect") || "/dashboard";
|
||||
goto(redirect);
|
||||
} catch (e: any) {
|
||||
if (e.message?.includes('401')) {
|
||||
error = "Invalid credentials";
|
||||
} else {
|
||||
error = "Failed to connect to server";
|
||||
}
|
||||
} catch (e) {
|
||||
error = "Failed to connect to server";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ export default defineConfig({
|
|||
server: {
|
||||
fs: {
|
||||
allow: ['../../shared']
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@
|
|||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"import-meta-resolve": "^4.2.0",
|
||||
"interactjs": "^1.10.27",
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -1580,6 +1581,11 @@
|
|||
"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": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
|
|
@ -7331,6 +7337,14 @@
|
|||
"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": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
|
|
|||
Loading…
Reference in New Issue