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
parent
165cd76b6f
commit
1b3c3044ca
|
|
@ -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 - 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 "My Bookings" page and enable reservation updates and cancellations - Implement `+page.svelte` for managing user reservations with real-time updates. - Add API endpoints for updating and canceling reservations with enhanced validation and authorization. - Introduce `UpdateReservationDto` for refined reservation update handling. - Update `reservations.service.ts` to handle overlapping checks, time adjustments, and admin actions. - Extend layout to include a link to the "My Bookings" page. - 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 - Set up SvelteKit for the web interface with Tailwind CSS. - Build the API using NestJS with Prisma ORM for database interaction. - Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. - Create shared data models for consistent typing across services. - Establish basic routing and layouts for web and API services.">
|
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services - Set up SvelteKit for the web interface with Tailwind CSS. - Build the API using NestJS with Prisma ORM for database interaction. - Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. - Create shared data models for consistent typing across services. - Establish basic routing and layouts for web and API services.">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
|
|
@ -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 "My Bookings" page and enable reservation updates and cancellations - Implement `+page.svelte` for managing user reservations with real-time updates. - Add API endpoints for updating and canceling reservations with enhanced validation and authorization. - Introduce `UpdateReservationDto` for refined reservation update handling. - Update `reservations.service.ts` to handle overlapping checks, time adjustments, and admin actions. - Extend layout to include a link to the "My Bookings" page. - 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 - 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." />
|
<MESSAGE value="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." />
|
||||||
<MESSAGE value="Add admin reporting dashboard with utilization statistics and downloadable reports - Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats. - Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS). - Create `ReportsService` and `ReportsModule` for backend logic. - Introduce `xlsx` package for generating Excel reports. - Update layout to include "Reports" link for admin users. - Extend Prisma querying for detailed building, space type, and employee role data." />
|
<MESSAGE value="Add admin reporting dashboard with utilization statistics and downloadable reports - Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats. - Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS). - Create `ReportsService` and `ReportsModule` for backend logic. - Introduce `xlsx` package for generating Excel reports. - Update layout to include "Reports" link for admin users. - Extend Prisma querying for detailed building, space type, and employee role data." />
|
||||||
<MESSAGE value="Add user management and registration features with invite-based onboarding - 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 - 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 - 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 - 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. - Create DTOs (`RegisterDto`, `UpdateUserDto`) for input validation in user management endpoints. - Extend `AuthService` and `AuthController` with invite-based registration logic. - Update admin layout to include "Users" link for easy access to user management." />
|
||||||
|
<MESSAGE value="Add "My Bookings" page and enable reservation updates and cancellations - Implement `+page.svelte` for managing user reservations with real-time updates. - Add API endpoints for updating and canceling reservations with enhanced validation and authorization. - Introduce `UpdateReservationDto` for refined reservation update handling. - Update `reservations.service.ts` to handle overlapping checks, time adjustments, and admin actions. - Extend layout to include a link to the "My Bookings" page. - Add `date-fns` library for date formatting." />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="Add "My Bookings" page and enable reservation updates and cancellations - Implement `+page.svelte` for managing user reservations with real-time updates. - Add API endpoints for updating and canceling reservations with enhanced validation and authorization. - Introduce `UpdateReservationDto` for refined reservation update handling. - Update `reservations.service.ts` to handle overlapping checks, time adjustments, and admin actions. - Extend layout to include a link to the "My Bookings" page. - Add `date-fns` library for date formatting." />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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">
|
||||||
|
<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} />
|
<Building2 class="text-indigo-600" size={24} />
|
||||||
</div>
|
</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>
|
||||||
<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}
|
||||||
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue