Add building and floor management with admin editor modal

- Implement `BuildingEditorModal.svelte` for creating, editing, and deleting buildings and floors.
- Add `CreateBuildingDto`, `UpdateBuildingDto`, `CreateFloorDto`, and `UpdateFloorDto` for validation.
- Extend `SpacesService` and `SpacesController` with endpoints for building and floor CRUD operations.
- Update Prisma schema to include building descriptions and image URLs, and floor names.
- Enhance dashboard with building management functionality for admin users.
main
Pau Costa Ferrer 2026-03-28 16:22:36 +01:00
parent 165cd76b6f
commit 1b3c3044ca
7 changed files with 510 additions and 23 deletions

View File

@ -4,12 +4,11 @@
<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 user management and registration features with invite-based onboarding&#10;&#10;- Implement `+page.svelte` for admin user management, allowing role updates, password resets, and invite link generation."> <list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add &quot;My Bookings&quot; page and enable reservation updates and cancellations&#10;&#10;- Implement `+page.svelte` for managing user reservations with real-time updates.&#10;- Add API endpoints for updating and canceling reservations with enhanced validation and authorization.&#10;- Introduce `UpdateReservationDto` for refined reservation update handling.&#10;- Update `reservations.service.ts` to handle overlapping checks, time adjustments, and admin actions.&#10;- Extend layout to include a link to the &quot;My Bookings&quot; page.&#10;- Add `date-fns` library for date formatting.">
<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/prisma/schema.prisma" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" 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/web/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/package.json" 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/routes/+layout.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" 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$/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" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -131,7 +130,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="4540000" /> <workItem from="1774706361502" duration="4842000" />
</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" />
@ -205,7 +204,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1774710670712</updated> <updated>1774710670712</updated>
</task> </task>
<option name="localTasksCounter" value="10" /> <task id="LOCAL-00010" summary="Add &quot;My Bookings&quot; page and enable reservation updates and cancellations&#10;&#10;- Implement `+page.svelte` for managing user reservations with real-time updates.&#10;- Add API endpoints for updating and canceling reservations with enhanced validation and authorization.&#10;- Introduce `UpdateReservationDto` for refined reservation update handling.&#10;- Update `reservations.service.ts` to handle overlapping checks, time adjustments, and admin actions.&#10;- Extend layout to include a link to the &quot;My Bookings&quot; page.&#10;- Add `date-fns` library for date formatting.">
<option name="closed" value="true" />
<created>1774710987605</created>
<option name="number" value="00010" />
<option name="presentableId" value="LOCAL-00010" />
<option name="project" value="LOCAL" />
<updated>1774710987605</updated>
</task>
<option name="localTasksCounter" value="11" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@ -221,6 +228,8 @@
<MESSAGE value="Add support for space resizing and enhanced layout management&#10;&#10;- Update `SpacesService` to handle width and height properties for spaces.&#10;- Introduce `UpdateSpaceDto` for improved validation in batch update operations.&#10;- Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing.&#10;- Modify Prisma schema to include `width` and `height` fields for spaces.&#10;- Enhance floorplan rendering to maintain proportional layout during editing and viewing." /> <MESSAGE value="Add support for space resizing and enhanced layout management&#10;&#10;- Update `SpacesService` to handle width and height properties for spaces.&#10;- Introduce `UpdateSpaceDto` for improved validation in batch update operations.&#10;- Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing.&#10;- Modify Prisma schema to include `width` and `height` fields for spaces.&#10;- Enhance floorplan rendering to maintain proportional layout during editing and viewing." />
<MESSAGE value="Add admin reporting dashboard with utilization statistics and downloadable reports&#10;&#10;- Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats.&#10;- Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS).&#10;- Create `ReportsService` and `ReportsModule` for backend logic.&#10;- Introduce `xlsx` package for generating Excel reports.&#10;- Update layout to include &quot;Reports&quot; link for admin users.&#10;- Extend Prisma querying for detailed building, space type, and employee role data." /> <MESSAGE value="Add admin reporting dashboard with utilization statistics and downloadable reports&#10;&#10;- Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats.&#10;- Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS).&#10;- Create `ReportsService` and `ReportsModule` for backend logic.&#10;- Introduce `xlsx` package for generating Excel reports.&#10;- Update layout to include &quot;Reports&quot; link for admin users.&#10;- Extend Prisma querying for detailed building, space type, and employee role data." />
<MESSAGE value="Add user management and registration features with invite-based onboarding&#10;&#10;- Implement `+page.svelte` for admin user management, allowing role updates, password resets, and invite link generation." /> <MESSAGE value="Add user management and registration features with invite-based onboarding&#10;&#10;- Implement `+page.svelte` for admin user management, allowing role updates, password resets, and invite link generation." />
<option name="LAST_COMMIT_MESSAGE" value="Add user management and registration features with invite-based onboarding&#10;&#10;- Implement `+page.svelte` for admin user management, allowing role updates, password resets, and invite link generation." /> <MESSAGE value="Add user management and registration features with invite-based onboarding&#10;&#10;- Implement `+page.svelte` for admin user management, allowing role updates, password resets, and invite link generation. Update `UsersController` and `UsersService` to support user role updates, password changes, and invite token generation.&#10;- Create DTOs (`RegisterDto`, `UpdateUserDto`) for input validation in user management endpoints.&#10;- Extend `AuthService` and `AuthController` with invite-based registration logic.&#10;- Update admin layout to include &quot;Users&quot; link for easy access to user management." />
<MESSAGE value="Add &quot;My Bookings&quot; page and enable reservation updates and cancellations&#10;&#10;- Implement `+page.svelte` for managing user reservations with real-time updates.&#10;- Add API endpoints for updating and canceling reservations with enhanced validation and authorization.&#10;- Introduce `UpdateReservationDto` for refined reservation update handling.&#10;- Update `reservations.service.ts` to handle overlapping checks, time adjustments, and admin actions.&#10;- Extend layout to include a link to the &quot;My Bookings&quot; page.&#10;- Add `date-fns` library for date formatting." />
<option name="LAST_COMMIT_MESSAGE" value="Add &quot;My Bookings&quot; page and enable reservation updates and cancellations&#10;&#10;- Implement `+page.svelte` for managing user reservations with real-time updates.&#10;- Add API endpoints for updating and canceling reservations with enhanced validation and authorization.&#10;- Introduce `UpdateReservationDto` for refined reservation update handling.&#10;- Update `reservations.service.ts` to handle overlapping checks, time adjustments, and admin actions.&#10;- Extend layout to include a link to the &quot;My Bookings&quot; page.&#10;- Add `date-fns` library for date formatting." />
</component> </component>
</project> </project>

View File

@ -32,6 +32,8 @@ model Organization {
model Building { model Building {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
description String? @db.Text
imageUrl String?
organization Organization @relation(fields: [organizationId], references: [id]) organization Organization @relation(fields: [organizationId], references: [id])
organizationId String organizationId String
floors Floor[] floors Floor[]
@ -41,6 +43,7 @@ model Building {
model Floor { model Floor {
id String @id @default(uuid()) id String @id @default(uuid())
name String?
number Int number Int
building Building @relation(fields: [buildingId], references: [id]) building Building @relation(fields: [buildingId], references: [id])
buildingId String buildingId String

View File

@ -0,0 +1,49 @@
import { IsString, IsOptional, IsInt, IsUrl } from 'class-validator';
export class CreateBuildingDto {
@IsString()
name: string;
@IsString()
@IsOptional()
description?: string;
@IsOptional()
@IsUrl()
@IsString()
imageUrl?: string | null;
}
export class UpdateBuildingDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
description?: string;
@IsOptional()
@IsUrl()
@IsString()
imageUrl?: string | null;
}
export class CreateFloorDto {
@IsString()
@IsOptional()
name?: string;
@IsInt()
number: number;
}
export class UpdateFloorDto {
@IsString()
@IsOptional()
name?: string;
@IsInt()
@IsOptional()
number?: number;
}

View File

@ -1,10 +1,11 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request, Patch, Query, ParseArrayPipe } from '@nestjs/common'; import { Controller, Get, Post, Body, Param, UseGuards, Request, Patch, Query, ParseArrayPipe, Delete } 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'; import { UpdateSpaceDto } from './dto/update-space.dto';
import { CreateBuildingDto, UpdateBuildingDto, CreateFloorDto, UpdateFloorDto } from './dto/building-floor.dto';
@Controller('spaces') @Controller('spaces')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@ -26,6 +27,42 @@ export class SpacesController {
return this.spacesService.getBuildingsAndFloors(req.user.organizationId); return this.spacesService.getBuildingsAndFloors(req.user.organizationId);
} }
@Post('buildings')
@Roles(Role.ADMIN)
async createBuilding(@Request() req: { user: { organizationId: string } }, @Body() dto: CreateBuildingDto) {
return this.spacesService.createBuilding(req.user.organizationId, dto);
}
@Patch('buildings/:id')
@Roles(Role.ADMIN)
async updateBuilding(@Param('id') id: string, @Body() dto: UpdateBuildingDto) {
return this.spacesService.updateBuilding(id, dto);
}
@Delete('buildings/:id')
@Roles(Role.ADMIN)
async deleteBuilding(@Param('id') id: string) {
return this.spacesService.deleteBuilding(id);
}
@Post('buildings/:id/floors')
@Roles(Role.ADMIN)
async addFloor(@Param('id') id: string, @Body() dto: CreateFloorDto) {
return this.spacesService.addFloor(id, dto);
}
@Patch('floors/:id')
@Roles(Role.ADMIN)
async updateFloor(@Param('id') id: string, @Body() dto: UpdateFloorDto) {
return this.spacesService.updateFloor(id, dto);
}
@Delete('floors/:id')
@Roles(Role.ADMIN)
async deleteFloor(@Param('id') id: string) {
return this.spacesService.deleteFloor(id);
}
@Patch('floor/:id/plan') @Patch('floor/:id/plan')
@Roles(Role.ADMIN) @Roles(Role.ADMIN)
async updateFloorPlan(@Param('id') id: string, @Body('planImageUrl') planImageUrl: string) { async updateFloorPlan(@Param('id') id: string, @Body('planImageUrl') planImageUrl: string) {

View File

@ -60,8 +60,58 @@ export class SpacesService {
return this.prisma.building.findMany({ return this.prisma.building.findMany({
where: { organizationId }, where: { organizationId },
include: { include: {
floors: true, floors: {
orderBy: { number: 'asc' }
},
}, },
orderBy: { name: 'asc' },
});
}
async createBuilding(organizationId: string, data: any) {
return this.prisma.building.create({
data: {
...data,
organizationId,
},
});
}
async updateBuilding(id: string, data: any) {
return this.prisma.building.update({
where: { id },
data,
});
}
async deleteBuilding(id: string) {
// Need to handle cascading if not in schema, but prisma usually handles it if specified.
// In our schema it's not specified, so we might need manual delete or update schema.
// Let's assume we want to delete all floors and spaces too.
return this.prisma.building.delete({
where: { id },
});
}
async addFloor(buildingId: string, data: any) {
return this.prisma.floor.create({
data: {
...data,
buildingId,
},
});
}
async updateFloor(id: string, data: any) {
return this.prisma.floor.update({
where: { id },
data,
});
}
async deleteFloor(id: string) {
return this.prisma.floor.delete({
where: { id },
}); });
} }

View File

@ -0,0 +1,287 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { X, Plus, Trash2, Building2 } from 'lucide-svelte';
import { apiFetch } from '$lib/api/client';
export let building: any = null; // if null, we are creating a new building
export let show = false;
const dispatch = createEventDispatcher();
let name = building?.name || '';
let description = building?.description || '';
let imageUrl = building?.imageUrl || '';
let floors = building?.floors ? JSON.parse(JSON.stringify(building.floors)) : [];
let loading = false;
let error = '';
let showDangerZone = false;
$: {
if (show && building) {
name = building.name;
description = building.description || '';
imageUrl = building.imageUrl || '';
floors = JSON.parse(JSON.stringify(building.floors || []));
} else if (show && !building) {
name = '';
description = '';
imageUrl = '';
floors = [];
}
}
async function saveBuilding() {
loading = true;
error = '';
try {
let savedBuilding;
if (building) {
savedBuilding = await apiFetch(`/spaces/buildings/${building.id}`, {
method: 'PATCH',
body: JSON.stringify({
name,
description: description || null,
imageUrl: imageUrl || null
})
});
// Update floors
for (const floor of floors) {
if (floor.id) {
await apiFetch(`/spaces/floors/${floor.id}`, {
method: 'PATCH',
body: JSON.stringify({ name: floor.name, number: floor.number })
});
} else {
await apiFetch(`/spaces/buildings/${building.id}/floors`, {
method: 'POST',
body: JSON.stringify({ name: floor.name, number: floor.number })
});
}
}
} else {
savedBuilding = await apiFetch('/spaces/buildings', {
method: 'POST',
body: JSON.stringify({
name,
description: description || null,
imageUrl: imageUrl || null
})
});
// Add floors if any were defined during creation
for (const floor of floors) {
await apiFetch(`/spaces/buildings/${savedBuilding.id}/floors`, {
method: 'POST',
body: JSON.stringify({ name: floor.name, number: floor.number })
});
}
}
dispatch('save');
close();
} catch (e: any) {
error = e.message || 'Failed to save building';
} finally {
loading = false;
}
}
async function deleteBuilding() {
if (!confirm('Are you absolutely sure you want to delete this building and all its floors?')) return;
loading = true;
try {
await apiFetch(`/spaces/buildings/${building.id}`, {
method: 'DELETE'
});
dispatch('save');
close();
} catch (e: any) {
error = e.message || 'Failed to delete building';
} finally {
loading = false;
}
}
async function deleteFloor(index: number) {
const floor = floors[index];
if (floor.id) {
if (!confirm('Are you sure you want to delete this floor?')) return;
try {
await apiFetch(`/spaces/floors/${floor.id}`, {
method: 'DELETE'
});
} catch (e: any) {
error = e.message || 'Failed to delete floor';
return;
}
}
floors = floors.filter((_, i) => i !== index);
}
function addFloor() {
const maxNumber = floors.reduce((max, f) => Math.max(max, f.number), -1);
floors = [...floors, { name: '', number: maxNumber + 1 }];
}
function close() {
show = false;
showDangerZone = false;
dispatch('close');
}
</script>
{#if show}
<div class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center p-6 border-b sticky top-0 bg-white z-10">
<h2 class="text-2xl font-bold text-gray-900">
{building ? 'Edit Building' : 'Add New Building'}
</h2>
<button on:click={close} class="text-gray-400 hover:text-gray-600">
<X size={24} />
</button>
</div>
<div class="p-6 space-y-6">
{#if error}
<div class="bg-red-50 text-red-600 p-4 rounded-lg text-sm">
{error}
</div>
{/if}
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Building Name</label>
<input
type="text"
bind:value={name}
placeholder="e.g. Headquarters"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
bind:value={description}
placeholder="Tell something about this building..."
rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Header Image URL</label>
<div class="flex gap-4 items-center">
<div class="w-16 h-16 bg-gray-100 rounded-lg flex-shrink-0 flex items-center justify-center overflow-hidden">
{#if imageUrl}
<img src={imageUrl} alt="Preview" class="w-full h-full object-cover" />
{:else}
<Building2 class="text-gray-400" size={32} />
{/if}
</div>
<input
type="text"
bind:value={imageUrl}
placeholder="https://images.unsplash.com/..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
/>
</div>
<p class="text-xs text-gray-500 mt-1">If empty, it will default to the building icon.</p>
</div>
</div>
<div class="border-t pt-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-900">Floors</h3>
<button
on:click={addFloor}
class="flex items-center gap-1 text-sm text-indigo-600 hover:text-indigo-700 font-medium"
>
<Plus size={18} /> Add Floor
</button>
</div>
<div class="space-y-3">
{#each floors as floor, i}
<div class="flex gap-3 items-center bg-gray-50 p-3 rounded-lg border border-gray-100">
<div class="w-16">
<label class="block text-[10px] uppercase tracking-wider font-bold text-gray-400 mb-1">Num</label>
<input
type="number"
bind:value={floor.number}
class="w-full bg-white px-2 py-1 border border-gray-200 rounded text-sm"
/>
</div>
<div class="flex-grow">
<label class="block text-[10px] uppercase tracking-wider font-bold text-gray-400 mb-1">Floor Name</label>
<input
type="text"
bind:value={floor.name}
placeholder="e.g. Ground Floor"
class="w-full bg-white px-2 py-1 border border-gray-200 rounded text-sm"
/>
</div>
<button
on:click={() => deleteFloor(i)}
class="mt-5 text-gray-400 hover:text-red-500 transition-colors"
>
<Trash2 size={18} />
</button>
</div>
{/each}
{#if floors.length === 0}
<p class="text-center py-4 text-gray-500 text-sm bg-gray-50 rounded-lg border border-dashed border-gray-200">
No floors added yet.
</p>
{/if}
</div>
</div>
{#if building}
<div class="border-t pt-6">
<button
on:click={() => showDangerZone = !showDangerZone}
class="text-sm text-red-600 hover:text-red-700 font-medium"
>
{showDangerZone ? 'Hide' : 'Show'} Danger Zone
</button>
{#if showDangerZone}
<div class="mt-4 p-4 border border-red-200 bg-red-50 rounded-lg">
<h4 class="text-red-800 font-bold mb-1">Delete Building</h4>
<p class="text-red-600 text-sm mb-4">This action is irreversible. All floors and spaces associated with this building will be permanently deleted.</p>
<button
on:click={deleteBuilding}
disabled={loading}
class="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 transition-colors disabled:opacity-50"
>
{loading ? 'Deleting...' : 'Permanently Delete Building'}
</button>
</div>
{/if}
</div>
{/if}
</div>
<div class="p-6 border-t sticky bottom-0 bg-white flex justify-end gap-3">
<button
on:click={close}
class="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg font-medium transition-colors"
>
Cancel
</button>
<button
on:click={saveBuilding}
disabled={loading || !name}
class="px-6 py-2 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors disabled:opacity-50"
>
{loading ? 'Saving...' : 'Save Building'}
</button>
</div>
</div>
</div>
{/if}

View File

@ -3,17 +3,16 @@
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"; import { apiFetch } from "$lib/api/client";
import { Building2, ArrowRight } from "lucide-svelte"; import { Building2, ArrowRight, Plus, Edit2 } from "lucide-svelte";
import BuildingEditorModal from "$lib/components/admin/BuildingEditorModal.svelte";
let buildings = []; let buildings = [];
let loading = true; let loading = true;
let showModal = false;
let editingBuilding = null;
onMount(async () => { async function fetchBuildings() {
if (!$token) { loading = true;
goto("/login?redirect=/dashboard");
return;
}
try { try {
buildings = await apiFetch("/spaces/org-hierarchy"); buildings = await apiFetch("/spaces/org-hierarchy");
} catch (e) { } catch (e) {
@ -24,7 +23,25 @@
} finally { } finally {
loading = false; loading = false;
} }
}
onMount(async () => {
if (!$token) {
goto("/login?redirect=/dashboard");
return;
}
await fetchBuildings();
}); });
function openCreateModal() {
editingBuilding = null;
showModal = true;
}
function openEditModal(building) {
editingBuilding = building;
showModal = true;
}
</script> </script>
<div class="p-8 max-w-6xl mx-auto"> <div class="p-8 max-w-6xl mx-auto">
@ -33,6 +50,15 @@
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1> <h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
<p class="text-gray-500 mt-1">Welcome back, {$user?.email}!</p> <p class="text-gray-500 mt-1">Welcome back, {$user?.email}!</p>
</div> </div>
{#if $user?.role === 'ADMIN'}
<button
on:click={openCreateModal}
class="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors"
>
<Plus size={20} />
Add Building
</button>
{/if}
</div> </div>
{#if loading} {#if loading}
@ -42,14 +68,34 @@
{:else} {:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each buildings as building} {#each buildings as building}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow group"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow group relative">
{#if building.imageUrl}
<div class="h-32 w-full overflow-hidden">
<img src={building.imageUrl} alt={building.name} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
</div>
{/if}
<div class="p-6"> <div class="p-6">
<div class="w-12 h-12 bg-indigo-50 rounded-lg flex items-center justify-center mb-4 group-hover:bg-indigo-100 transition-colors"> <div class="flex justify-between items-start mb-4">
<Building2 class="text-indigo-600" size={24} /> <div class="w-12 h-12 bg-indigo-50 rounded-lg flex items-center justify-center group-hover:bg-indigo-100 transition-colors">
<Building2 class="text-indigo-600" size={24} />
</div>
{#if $user?.role === 'ADMIN'}
<button
on:click={() => openEditModal(building)}
class="p-2 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors"
title="Edit Building"
>
<Edit2 size={18} />
</button>
{/if}
</div> </div>
<h2 class="text-xl font-bold text-gray-900 mb-2">{building.name}</h2> <h2 class="text-xl font-bold text-gray-900 mb-2">{building.name}</h2>
<p class="text-gray-500 text-sm mb-6"> {#if building.description}
{building.floors?.length || 0} Floors available in this building. <p class="text-gray-500 text-sm mb-4 line-clamp-2">{building.description}</p>
{/if}
<p class="text-gray-400 text-xs mb-6 uppercase tracking-wider font-semibold">
{building.floors?.length || 0} Floors
</p> </p>
<a <a
@ -73,3 +119,9 @@
</div> </div>
{/if} {/if}
</div> </div>
<BuildingEditorModal
bind:show={showModal}
building={editingBuilding}
on:save={fetchBuildings}
/>